From 98c817db0a34a366ddd899c073383461ec600cd0 Mon Sep 17 00:00:00 2001 From: James Nightingale Date: Thu, 13 Aug 2026 20:29:06 -0400 Subject: [PATCH] fix: stabilize degenerate border PCA axes --- autoarray/inversion/mesh/border_relocator.py | 9 +++++ .../pixelization/test_border_relocator.py | 38 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/autoarray/inversion/mesh/border_relocator.py b/autoarray/inversion/mesh/border_relocator.py index 0205fe97b..d0cd1ab2f 100644 --- a/autoarray/inversion/mesh/border_relocator.py +++ b/autoarray/inversion/mesh/border_relocator.py @@ -249,6 +249,15 @@ def ellipse_params_via_border_pca_from(border_grid, xp=np, eps=1e-12): phi = xp.arctan2(v_major[1], v_major[0]) + # PCA eigenvectors are undefined for an isotropic covariance. NumPy and + # JAX may therefore choose different, equally valid orientations whose + # downstream max-extent ellipses are not equivalent. Use a deterministic + # axis-aligned frame when the eigenvalue gap is at floating-point scale. + eigenvalue_scale = xp.maximum(xp.max(xp.abs(evals)), eps) + relative_gap = (evals[-1] - evals[0]) / eigenvalue_scale + isotropy_tolerance = xp.sqrt(xp.finfo(C.dtype).eps) + phi = xp.where(relative_gap <= isotropy_tolerance, 0.0, phi) + # Rotate border points into ellipse-aligned frame c = xp.cos(phi) s = xp.sin(phi) diff --git a/test_autoarray/inversion/pixelization/test_border_relocator.py b/test_autoarray/inversion/pixelization/test_border_relocator.py index 17482959b..c58911d4a 100644 --- a/test_autoarray/inversion/pixelization/test_border_relocator.py +++ b/test_autoarray/inversion/pixelization/test_border_relocator.py @@ -4,6 +4,7 @@ import autoarray as aa from autoarray.inversion.mesh.border_relocator import ( + ellipse_params_via_border_pca_from, sub_border_pixel_slim_indexes_from, ) @@ -376,3 +377,40 @@ def test__relocated_grid_from__positive_origin_included_in_relocate(): relocated_grid = border_relocator.relocated_grid_from(grid=grid) assert relocated_grid.over_sampled[1] == pytest.approx([1.95, 1.0], 1e-4) + +def test__ellipse_params__near_isotropic_border_uses_deterministic_axis(): + border_grid = np.array( + [ + [-0.0960155108, 0.0960155108], + [-0.55, 0.0], + [-0.0960155108, -0.0960155108], + [0.0, 0.55], + [0.0, -0.55], + [0.0960155108, 0.0960155108], + [0.55, 0.0], + [0.0960155108, -0.0960155108], + ] + ) + + _, a, b, phi = ellipse_params_via_border_pca_from(border_grid=border_grid) + + assert float(phi) == 0.0 + assert float(a) == pytest.approx(0.55 + 1.0e-12) + assert float(b) == pytest.approx(0.55 + 1.0e-12) + + +def test__ellipse_params__anisotropic_border_retains_pca_major_axis(): + border_grid = np.array( + [ + [-2.0, 0.0], + [0.0, 1.0], + [2.0, 0.0], + [0.0, -1.0], + ] + ) + + _, a, b, phi = ellipse_params_via_border_pca_from(border_grid=border_grid) + + assert abs(float(phi)) == pytest.approx(np.pi / 2.0) + assert float(a) > float(b) +