@@ -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