Skip to content

Commit e92cf0f

Browse files
committed
Add tests for the extended source class.
1 parent 512ca19 commit e92cf0f

2 files changed

Lines changed: 325 additions & 42 deletions

File tree

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
"""
2+
Unit tests for ExtendedSourceKingPDF.
3+
4+
Covers initialization (composition, not inheritance), pdf() correctness
5+
(normalization, boundary behaviour, shape), and evaluate() correctness
6+
(sparse output, geometry screening, mask reuse).
7+
"""
8+
9+
import numpy as np
10+
import pytest
11+
from numpy.testing import assert_allclose
12+
from scipy.sparse import csr_array
13+
14+
from kingmaker.pdf import ExtendedSourceKingPDF, KingPDF
15+
16+
17+
# ---------------------------------------------------------------------------
18+
# Shared fixtures
19+
# ---------------------------------------------------------------------------
20+
21+
22+
# Minimal grid shared by most pdf() tests; built once per module.
23+
@pytest.fixture(scope="module")
24+
def ext_pdf():
25+
return ExtendedSourceKingPDF(
26+
points_alpha=np.radians(np.logspace(-1, 1, 10)),
27+
points_beta=np.logspace(np.log10(1.01), 1, 8),
28+
points_extension=np.radians(np.logspace(-1.5, np.log10(4.9), 8)),
29+
points_psi=np.concatenate([[0.0], np.logspace(-4, np.log10(np.pi), 200)]),
30+
n_quad=16,
31+
)
32+
33+
34+
# Fixture for evaluate() tests: small angular_cutoff so far events are screened.
35+
@pytest.fixture(scope="module")
36+
def ext_eval():
37+
return ExtendedSourceKingPDF(
38+
angular_cutoff=np.radians(10.0),
39+
points_alpha=np.radians(np.logspace(-1, 1, 10)),
40+
points_beta=np.logspace(np.log10(1.01), 1, 8),
41+
points_extension=np.radians(np.logspace(-1.5, np.log10(4.9), 8)),
42+
points_psi=np.concatenate([[0.0], np.logspace(-4, np.log10(np.pi), 200)]),
43+
n_quad=16,
44+
)
45+
46+
47+
PARAM_CASES = [
48+
pytest.param(np.radians(0.5), 2.0, np.radians(0.5), id="narrow-moderate-small-ext"),
49+
pytest.param(np.radians(1.0), 2.5, np.radians(1.0), id="moderate-moderate-med-ext"),
50+
pytest.param(np.radians(2.0), 4.0, np.radians(1.5), id="wide-heavy-med-ext"),
51+
]
52+
53+
54+
# ---------------------------------------------------------------------------
55+
# Initialization
56+
# ---------------------------------------------------------------------------
57+
58+
59+
class TestExtendedSourceKingPDFInit:
60+
def test_not_instance_of_king_pdf(self, ext_pdf):
61+
assert not isinstance(ext_pdf, KingPDF)
62+
63+
def test_default_angular_cutoff(self, ext_pdf):
64+
assert ext_pdf.angular_cutoff == pytest.approx(np.pi)
65+
66+
def test_custom_angular_cutoff(self):
67+
cutoff = np.radians(5.0)
68+
ext = ExtendedSourceKingPDF(
69+
angular_cutoff=cutoff,
70+
points_alpha=np.radians([0.5, 1.0]),
71+
points_beta=np.array([1.5, 3.0]),
72+
points_extension=np.radians([0.5, 1.0]),
73+
n_quad=4,
74+
)
75+
assert ext.angular_cutoff == pytest.approx(cutoff)
76+
77+
def test_default_maximum_sigma(self, ext_pdf):
78+
assert ext_pdf.maximum_sigma == pytest.approx(3.0)
79+
80+
def test_custom_maximum_sigma(self):
81+
ext = ExtendedSourceKingPDF(
82+
maximum_sigma=5.0,
83+
points_alpha=np.radians([0.5, 1.0]),
84+
points_beta=np.array([1.5, 3.0]),
85+
points_extension=np.radians([0.5, 1.0]),
86+
n_quad=4,
87+
)
88+
assert ext.maximum_sigma == pytest.approx(5.0)
89+
90+
def test_table_shape(self, ext_pdf):
91+
expected = (
92+
len(ext_pdf._log10_points_alpha),
93+
len(ext_pdf._log10_points_beta),
94+
len(ext_pdf._log10_points_extension),
95+
len(ext_pdf._points_psi),
96+
)
97+
assert ext_pdf._table.shape == expected
98+
99+
def test_table_finite(self, ext_pdf):
100+
assert np.all(np.isfinite(ext_pdf._table))
101+
102+
def test_table_nonneg(self, ext_pdf):
103+
assert np.all(ext_pdf._table >= 0.0)
104+
105+
106+
# ---------------------------------------------------------------------------
107+
# pdf()
108+
# ---------------------------------------------------------------------------
109+
110+
111+
class TestExtendedSourceKingPDFPdf:
112+
@pytest.mark.parametrize("alpha, beta, extension", PARAM_CASES)
113+
def test_pdf_valid(self, ext_pdf, alpha, beta, extension):
114+
psi = np.linspace(0, np.radians(5), 50)
115+
vals = ext_pdf.pdf(psi, np.full_like(psi, alpha), np.full_like(psi, beta), extension)
116+
assert np.all(vals >= 0)
117+
assert np.all(np.isfinite(vals))
118+
119+
def test_zero_at_psi_zero(self, ext_pdf):
120+
val = ext_pdf.pdf(0.0, np.radians(1.0), 2.5, np.radians(1.0))
121+
assert val == 0.0
122+
123+
def test_zero_beyond_psi_max(self):
124+
"""Angles above _points_psi[-1] (but still ≤ π) must return 0.
125+
126+
Use angular_cutoff=10° without a custom points_psi so the default
127+
upper bound is max_sigma*max_ext + angular_cutoff ≈ 16° < π, leaving
128+
room for test points on the sphere that are out-of-table.
129+
"""
130+
small = ExtendedSourceKingPDF(
131+
angular_cutoff=np.radians(10.0),
132+
points_alpha=np.radians([0.5, 1.0, 2.0]),
133+
points_beta=np.array([1.5, 2.5, 5.0]),
134+
points_extension=np.radians([0.5, 1.0, 2.0]),
135+
n_quad=4,
136+
)
137+
psi_max = small._points_psi[-1]
138+
assert psi_max < np.pi, "fixture must end before π for this test to be meaningful"
139+
beyond = np.array([psi_max + np.radians(5.0), psi_max + np.radians(20.0)])
140+
beyond = beyond[beyond <= np.pi]
141+
alpha = np.full(len(beyond), np.radians(1.0))
142+
beta = np.full(len(beyond), 2.5)
143+
ext = np.full(len(beyond), np.radians(1.0))
144+
assert np.all(small.pdf(beyond, alpha, beta, ext) == 0.0)
145+
146+
def test_output_shape_array(self, ext_pdf):
147+
psi = np.linspace(0.01, np.radians(5), 20)
148+
vals = ext_pdf.pdf(psi, np.radians(1.0), 2.5, np.radians(1.0))
149+
assert vals.shape == psi.shape
150+
151+
def test_scalar_input_finite(self, ext_pdf):
152+
val = ext_pdf.pdf(np.radians(1.0), np.radians(1.0), 2.5, np.radians(1.0))
153+
assert np.isfinite(val)
154+
155+
def test_oob_alpha_raises(self, ext_pdf):
156+
with pytest.raises(ValueError):
157+
ext_pdf.pdf(np.radians(1.0), np.radians(0.001), 2.5, np.radians(1.0))
158+
159+
def test_oob_extension_raises(self, ext_pdf):
160+
with pytest.raises(ValueError):
161+
ext_pdf.pdf(np.radians(1.0), np.radians(1.0), 2.5, np.radians(10.0))
162+
163+
@pytest.mark.parametrize("alpha, beta, extension", PARAM_CASES)
164+
def test_normalization(self, ext_pdf, alpha, beta, extension):
165+
"""∫ pdf(ψ) 2π ψ dψ ≈ 1 (flat-sky)."""
166+
psi = np.linspace(1e-4, ext_pdf._points_psi[-1], 30_000)
167+
dpsi = psi[1] - psi[0]
168+
vals = ext_pdf.pdf(
169+
psi,
170+
np.full_like(psi, alpha),
171+
np.full_like(psi, beta),
172+
np.full_like(psi, extension),
173+
)
174+
integral = np.sum(vals * 2.0 * np.pi * psi) * dpsi
175+
assert_allclose(integral, 1.0, rtol=0.02)
176+
177+
@pytest.mark.parametrize("alpha, beta, extension", PARAM_CASES)
178+
def test_small_extension_approaches_king(self, ext_pdf, alpha, beta, extension):
179+
"""Convolved PDF with the smallest grid extension should be close to flat-sky King."""
180+
tiny_ext = ext_pdf._points_extension[0]
181+
psi = np.radians([0.5, 1.0, 2.0])
182+
psi = psi[psi < alpha * 3] # stay in the PSF core where flat-sky is accurate
183+
if len(psi) == 0:
184+
pytest.skip("no test angles within PSF core for this alpha")
185+
186+
flat_norm = (beta - 1.0) / (2.0 * np.pi * beta * alpha**2)
187+
flat_king = flat_norm * (1.0 + psi**2 / (2.0 * beta * alpha**2)) ** (-beta)
188+
conv = ext_pdf.pdf(
189+
psi,
190+
np.full_like(psi, alpha),
191+
np.full_like(psi, beta),
192+
np.full_like(psi, tiny_ext),
193+
)
194+
assert_allclose(conv, flat_king, rtol=0.15)
195+
196+
def test_larger_extension_broader(self, ext_pdf):
197+
"""Larger extension shifts probability outward, reducing the PDF near psi=0."""
198+
alpha = np.radians(1.0)
199+
beta = 2.5
200+
psi_near = np.radians(0.1)
201+
val_small = ext_pdf.pdf(psi_near, alpha, beta, ext_pdf._points_extension[0])
202+
val_large = ext_pdf.pdf(psi_near, alpha, beta, ext_pdf._points_extension[-1])
203+
assert val_small > val_large
204+
205+
206+
# ---------------------------------------------------------------------------
207+
# evaluate()
208+
# ---------------------------------------------------------------------------
209+
210+
211+
class TestExtendedSourceKingPDFEvaluate:
212+
def test_returns_csr_array(self, ext_eval):
213+
result = ext_eval.evaluate(
214+
np.array([0.0]),
215+
np.array([0.0]),
216+
np.array([np.radians(1.0)]),
217+
np.array([0.0]),
218+
np.array([0.0]),
219+
np.array([np.radians(1.0)]),
220+
np.array([2.5]),
221+
)
222+
assert isinstance(result, csr_array)
223+
224+
def test_output_shape(self, ext_eval):
225+
src_ras = np.radians([0.0, 45.0])
226+
src_decs = np.radians([0.0, 10.0])
227+
src_exts = np.radians([1.0, 1.0])
228+
ev_ras = np.radians(np.linspace(0, 5, 8))
229+
ev_decs = np.zeros(8)
230+
alpha = np.full(8, np.radians(1.0))
231+
beta = np.full(8, 2.5)
232+
result = ext_eval.evaluate(src_ras, src_decs, src_exts, ev_ras, ev_decs, alpha, beta)
233+
assert result.shape == (8, 2)
234+
235+
def test_nonneg(self, ext_eval):
236+
rng = np.random.default_rng(0)
237+
ev_ras = rng.uniform(0, 2 * np.pi, 30)
238+
ev_decs = np.arcsin(rng.uniform(-1, 1, 30))
239+
alpha = np.full(30, np.radians(1.0))
240+
beta = np.full(30, 2.5)
241+
result = ext_eval.evaluate(
242+
np.array([0.0]),
243+
np.array([0.0]),
244+
np.array([np.radians(1.0)]),
245+
ev_ras,
246+
ev_decs,
247+
alpha,
248+
beta,
249+
)
250+
assert np.all(result.toarray() >= 0)
251+
252+
def test_near_source_positive(self, ext_eval):
253+
"""Events close to a source should get a positive PDF value."""
254+
result = ext_eval.evaluate(
255+
np.array([0.0]),
256+
np.array([0.0]),
257+
np.array([np.radians(1.0)]),
258+
np.array([np.radians(0.1)]),
259+
np.array([0.0]),
260+
np.array([np.radians(1.0)]),
261+
np.array([2.5]),
262+
)
263+
assert result.toarray()[0, 0] > 0
264+
265+
def test_zero_beyond_search_radius(self, ext_eval):
266+
"""Events beyond maximum_sigma * ext + angular_cutoff should be zero."""
267+
src_ext = np.radians(1.0)
268+
radius = ext_eval.maximum_sigma * src_ext + ext_eval.angular_cutoff
269+
# Place one event just inside and one well outside
270+
psi_far = min(radius + np.radians(5.0), np.pi)
271+
result = ext_eval.evaluate(
272+
np.array([0.0]),
273+
np.array([0.0]),
274+
np.array([src_ext]),
275+
np.array([np.radians(0.5), psi_far]),
276+
np.array([0.0, 0.0]),
277+
np.array([np.radians(1.0), np.radians(1.0)]),
278+
np.array([2.5, 2.5]),
279+
).toarray()
280+
assert result[0, 0] > 0
281+
assert result[1, 0] == 0.0
282+
283+
def test_mask_gives_same_result(self, ext_eval):
284+
rng = np.random.default_rng(42)
285+
src_ras = np.radians([0.0, 45.0])
286+
src_decs = np.radians([0.0, 10.0])
287+
src_exts = np.radians([1.0, 2.0])
288+
ev_ras = rng.uniform(0, 2 * np.pi, 30)
289+
ev_decs = np.arcsin(rng.uniform(-1, 1, 30))
290+
alpha = np.full(30, np.radians(1.0))
291+
beta = np.full(30, 2.5)
292+
first = ext_eval.evaluate(src_ras, src_decs, src_exts, ev_ras, ev_decs, alpha, beta)
293+
second = ext_eval.evaluate(
294+
src_ras, src_decs, src_exts, ev_ras, ev_decs, alpha, beta, mask=first
295+
)
296+
assert_allclose(first.toarray(), second.toarray(), rtol=1e-12)
297+
298+
def test_two_sources_prefer_nearest(self, ext_eval):
299+
"""An event near source 0 should get a higher PDF for source 0 than source 1."""
300+
src_ras = np.radians([0.0, 90.0])
301+
src_decs = np.radians([0.0, 0.0])
302+
src_exts = np.radians([1.0, 1.0])
303+
ev_ras = np.radians([1.0])
304+
ev_decs = np.radians([0.0])
305+
alpha = np.array([np.radians(1.0)])
306+
beta = np.array([2.5])
307+
result = ext_eval.evaluate(
308+
src_ras, src_decs, src_exts, ev_ras, ev_decs, alpha, beta
309+
).toarray()
310+
assert result[0, 0] > result[0, 1]

0 commit comments

Comments
 (0)