Skip to content

Commit 0523ea9

Browse files
authored
Merge pull request #395 from PyAutoLabs/feature/ellipse-fit-masked-loop-tests
test: pin FitEllipse masked-points-loop behaviour
2 parents 2ce7eb7 + b3ab24c commit 0523ea9

1 file changed

Lines changed: 203 additions & 0 deletions

File tree

test_autogalaxy/ellipse/test_fit_ellipse.py

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,206 @@ def test__log_likelihood(imaging_lh, imaging_lh_masked):
197197
fit = ag.FitEllipse(dataset=imaging_lh_masked, ellipse=ellipse_0)
198198

199199
assert fit.log_likelihood == pytest.approx(-0.169821080058, 1.0e-4)
200+
201+
202+
# ── mask-rejection loop tests (pinned for JAX-rewrite regression) ──────────
203+
204+
205+
@pytest.fixture(name="imaging_30x30")
206+
def make_imaging_30x30():
207+
return ag.Imaging(
208+
data=ag.Array2D.ones(shape_native=(30, 30), pixel_scales=(1.0, 1.0)),
209+
noise_map=ag.Array2D.ones(shape_native=(30, 30), pixel_scales=(1.0, 1.0)),
210+
)
211+
212+
213+
def test__points_from_major_axis__zero_masked(imaging_30x30):
214+
# Mask has one corner pixel True so interp.mask_interp is constructed and the loop
215+
# fires, but the masked pixel is far from the ellipse perimeter. The equals-branch
216+
# fires every iteration and the returned points must be identical to the unmasked fit.
217+
ellipse = ag.Ellipse(centre=(0.0, 0.0), ell_comps=(0.3, 0.2), major_axis=5.0)
218+
219+
mask_array = np.full((30, 30), False)
220+
mask_array[0, 0] = True
221+
mask = ag.Mask2D(mask=mask_array.tolist(), pixel_scales=1.0)
222+
223+
fit_unmasked = ag.FitEllipse(dataset=imaging_30x30, ellipse=ellipse)
224+
fit_masked = ag.FitEllipse(
225+
dataset=imaging_30x30.apply_mask(mask=mask), ellipse=ellipse
226+
)
227+
228+
assert fit_masked._points_from_major_axis.shape[0] == ellipse.total_points_from(
229+
pixel_scale=1.0
230+
) - 1
231+
232+
np.testing.assert_allclose(
233+
fit_masked._points_from_major_axis,
234+
fit_unmasked._points_from_major_axis,
235+
rtol=1e-12,
236+
)
237+
238+
239+
def test__points_from_major_axis__under_masked_trim(imaging_30x30):
240+
# mask[13, 15] causes: at i=1 the extra-points branch regenerates to n_i=1 (31 pts);
241+
# at i=2 the 31-point set has 0 masked points (unmasked=31 > required=30) so the
242+
# trim branch fires and removes 1 extra point; subsequent iterations hit equals-branch.
243+
ellipse = ag.Ellipse(centre=(0.0, 0.0), ell_comps=(0.3, 0.2), major_axis=5.0)
244+
245+
mask_array = np.full((30, 30), False)
246+
mask_array[13, 15] = True
247+
mask = ag.Mask2D(mask=mask_array.tolist(), pixel_scales=1.0)
248+
249+
fit = ag.FitEllipse(
250+
dataset=imaging_30x30.apply_mask(mask=mask), ellipse=ellipse
251+
)
252+
253+
assert fit._points_from_major_axis.shape[0] == ellipse.total_points_from(
254+
pixel_scale=1.0
255+
) - 1
256+
257+
expected = np.array(
258+
[
259+
[-0.8875687769622342, 4.318959680242291],
260+
[-1.9465967000974331, 4.536106717915154],
261+
[-2.7904545348693404, 4.00915543006672],
262+
[-3.121759540619221, 2.9674537201425717],
263+
[-3.0971817353867803, 1.9304881368053626],
264+
[-2.9362004594598003, 1.0874492395351023],
265+
[-2.7382783396152313, 0.4194888120858164],
266+
[-2.533386213036308, -0.12847880766605385],
267+
[-2.325860264652024, -0.6022069457624895],
268+
[-2.110815651174979, -1.0354045082443406],
269+
[-1.8786866984151742, -1.4542117489414634],
270+
[-1.6151780479744147, -1.881457224557489],
271+
[-1.2986189827210775, -2.3396631393811322],
272+
[-0.894688693486922, -2.8515790329629698],
273+
[-0.34900318176777473, -3.4320284500721225],
274+
[0.41165293239048256, -4.048113740292],
275+
[1.4128512563813995, -4.503082523252524],
276+
[2.4255072367965793, -4.3699267851023045],
277+
[3.0174521816485678, -3.5149110737600795],
278+
[3.137367386840201, -2.428503123238989],
279+
[3.0251208310290503, -1.4838926102749905],
280+
[2.83904957529029, -0.7350808643096405],
281+
[2.6361114386419064, -0.13368844148940043],
282+
[2.430126819911951, 0.3722817356273538],
283+
[2.2197796316635774, 0.8221161006262405],
284+
[1.9976499645857964, 1.2451447437071157],
285+
[1.7519886861942746, 1.6653894308155623],
286+
[1.4652965048117415, 2.105248935438702],
287+
[1.1104174658849209, 2.5875786835756953],
288+
[0.6439087789280391, 3.1332964003786445],
289+
]
290+
)
291+
292+
np.testing.assert_allclose(fit._points_from_major_axis, expected, rtol=1e-12)
293+
294+
295+
def test__points_from_major_axis__over_masked_extra_points(imaging_30x30):
296+
# A 3x3 block at rows 16-18, cols 18-20 causes 2 points to be masked on the initial
297+
# 30-point set (unmasked=28 < required=30). The extra-points branch fires at i=1
298+
# (n_i=1, 31 pts, still 2 masked -> unmasked=29 < 30) and again at i=2 (n_i=2,
299+
# 32 pts, still 2 masked -> unmasked=30 == 30). From i=3 the equals-branch fires.
300+
ellipse = ag.Ellipse(centre=(0.0, 0.0), ell_comps=(0.3, 0.2), major_axis=5.0)
301+
302+
mask_array = np.full((30, 30), False)
303+
mask_array[16:19, 18:21] = True
304+
mask = ag.Mask2D(mask=mask_array.tolist(), pixel_scales=1.0)
305+
306+
fit = ag.FitEllipse(
307+
dataset=imaging_30x30.apply_mask(mask=mask), ellipse=ellipse
308+
)
309+
310+
assert fit._points_from_major_axis.shape[0] == ellipse.total_points_from(
311+
pixel_scale=1.0
312+
) - 1
313+
314+
# Spot-check first and last points with full-precision reference values.
315+
np.testing.assert_allclose(
316+
fit._points_from_major_axis[0],
317+
np.array([-0.0, 3.7420680720326427]),
318+
rtol=1e-12,
319+
)
320+
np.testing.assert_allclose(
321+
fit._points_from_major_axis[-1],
322+
np.array([1.4354511411249111, 2.1483044498322945]),
323+
rtol=1e-12,
324+
)
325+
326+
327+
def test__points_from_major_axis__unreachable_raises(imaging_30x30):
328+
# Masking all pixels except a tiny top-left 5x5 region means the ellipse (major_axis=5,
329+
# centred at origin) cannot accumulate the required number of unmasked points regardless
330+
# of how many extra angles are added. The loop must reach i=300 and raise.
331+
ellipse = ag.Ellipse(centre=(0.0, 0.0), ell_comps=(0.3, 0.2), major_axis=5.0)
332+
333+
mask_array = np.full((30, 30), True)
334+
mask_array[0:5, 0:5] = False
335+
mask = ag.Mask2D(mask=mask_array.tolist(), pixel_scales=1.0)
336+
337+
fit = ag.FitEllipse(
338+
dataset=imaging_30x30.apply_mask(mask=mask), ellipse=ellipse
339+
)
340+
341+
with pytest.raises(ValueError, match="attempted to add over 300 extra points"):
342+
_ = fit._points_from_major_axis
343+
344+
345+
def test__points_from_major_axis__with_multipole_under_masked(imaging_30x30):
346+
# Same geometry as test__points_from_major_axis__under_masked_trim (mask[13,15],
347+
# EXTRA->TRIM->EQUAL path) but with a m=4 multipole that perturbs the points inside
348+
# the inner loop block (fit_ellipse.py lines 112-120). The output must differ from
349+
# the no-multipole case, confirming that multipole perturbation was applied.
350+
ellipse = ag.Ellipse(centre=(0.0, 0.0), ell_comps=(0.3, 0.2), major_axis=5.0)
351+
multipole = ag.EllipseMultipole(m=4, multipole_comps=(0.05, 0.0))
352+
353+
mask_array = np.full((30, 30), False)
354+
mask_array[13, 15] = True
355+
mask = ag.Mask2D(mask=mask_array.tolist(), pixel_scales=1.0)
356+
357+
fit = ag.FitEllipse(
358+
dataset=imaging_30x30.apply_mask(mask=mask),
359+
ellipse=ellipse,
360+
multipole_list=[multipole],
361+
)
362+
363+
assert fit._points_from_major_axis.shape[0] == ellipse.total_points_from(
364+
pixel_scale=1.0
365+
) - 1
366+
367+
expected = np.array(
368+
[
369+
[-0.8948637627342421, 4.35445749205703],
370+
[-1.9662891852074944, 4.5819956347080995],
371+
[-2.8090599546476698, 4.0358865660880605],
372+
[-3.118093237115593, 2.963968638787185],
373+
[-3.0636273709808624, 1.9095735415513715],
374+
[-2.8898535455248733, 1.0702842274696087],
375+
[-2.7100443128493983, 0.4151635182772758],
376+
[-2.54343822078039, -0.12898858780260153],
377+
[-2.366937919038917, -0.6128426873688835],
378+
[-2.154272852773611, -1.0567212833146127],
379+
[-1.8978749492680655, -1.4690645606718333],
380+
[-1.605428202942083, -1.8701000144979532],
381+
[-1.2768336674699277, -2.300413521324486],
382+
[-0.8806522731441982, -2.806841726860251],
383+
[-0.34700836576138805, -3.4124118229345113],
384+
[0.4136477483968692, -4.067730367429611],
385+
[1.4268876767241232, -4.547819829355243],
386+
[2.447292552047729, -4.409176403158951],
387+
[3.0272020266808997, -3.5262682838196153],
388+
[3.11817913598731, -2.413650311508619],
389+
[2.981663629430418, -1.4625758352047185],
390+
[2.797971920903396, -0.7244451227032465],
391+
[2.626059430897824, -0.13317866135285275],
392+
[2.4583608466777838, 0.3766070294358943],
393+
[2.2661265455985045, 0.839281112691734],
394+
[2.031204328991714, 1.266059338961107],
395+
[1.7556549896979028, 1.6688745121709494],
396+
[1.446691085033412, 2.0785177994173614],
397+
[1.0907249807748596, 2.5416897667827496],
398+
[0.6366137931560313, 3.0977985885639057],
399+
]
400+
)
401+
402+
np.testing.assert_allclose(fit._points_from_major_axis, expected, rtol=1e-12)

0 commit comments

Comments
 (0)