Repository navigation
feat: ShapeSolver source→image mappings, subplot_mappings, multiple-image positions (image-source-mappings P2) - #720
Merged
Conversation
…multiple-image positions (image-source-mappings phase 2) Validation suite for ShapeSolver (test_shape_solver.py + the revived test_extended.py): SIS analytic magnification, a grid-trace oracle, refinement convergence, every Shape type, plane_redshift on a three-plane tracer. Audit fixes: find_magnification no longer hardcodes xp=np; magnification_threshold is now actually applied (it was accepted and unused, so the singular centre of an SIS returned a spurious third image); a per-image split; a stated convergence bound. Two defects it exposed are deferred behind a raise and a strict xfail — the JAX containers cap every step at MAX_CONTAINING_SIZE=15 triangles, which makes ShapeSolver's JAX path silently wrong (0.13 against a true 6.86), and its area still cannot survive jax.jit. Two more are fixed on PyAutoArray#518: the triangle tiling was transposed for non-square grids (PointSolver missed images), and ArrayTrianglesNp.with_vertices carried a stray NameError. New: ShapeSolver.image_regions_from / mapping_from, autolens/lens/mappings.py (engine dispatch, multiple-image positions in arcsec and pixels, per-image magnifications), and subplot_mappings(fit) exported as aplt.subplot_fit_imaging_mappings and wired to the previously dead plots.yaml `inversion: subplot_mappings` key. Closes #719. Depends on PyAutoArray#518 (phase 2a). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011EVnQ4sEYrRM7GCvFyveUA
…or is the same without jax installed `_xp_from` resolved `xp=None` through `self._xp` and only then checked whether the result was a JAX module. `self._xp` imports `jax.numpy` when `use_jax=True`, so on an install with no JAX the user got `ModuleNotFoundError: No module named 'jax'` from inside the solver instead of the NotImplementedError explaining that ShapeSolver cannot use the JAX containers at all -- the wrong error, and one which blames the environment for a decision the solver had already taken. Caught by the `unittest-nojax` CI leg of PyAutoLens#720. The `use_jax=True` case now raises before `self._xp` is touched, and the message both routes raise is a plain class-level string, so nothing on the rejection path imports jax. The explicitly-passed-module check was already import-free (it reads `__name__`). `test_use_jax_solver_is_rejected_rather_than_silently_wrong` deliberately carries no `importorskip` -- a JAX-free install is exactly the case that must work -- and now covers `find_magnification`, `solve_triangles` and `steps`. The explicit `xp=jax.numpy` route moves to its own `importorskip`-guarded test, since it needs a module to pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011EVnQ4sEYrRM7GCvFyveUA
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase 2 of the
image-source-mappingsepic. Closes #719.ShapeSolverbecomes the source→image engine for parametric sources, so the colour-matched "one look" mapping figure that Phase 1 (PyAutoArray#517) built for pixelized sources now works for any source, and the same solver output feeds the numerical quantities (per-image magnification, brightest image-plane coordinate of each multiple image for spectroscopic fibre pointing).ShapeSolvervalidation suite (test_autolens/point/triangles/test_shape_solver.py): SIS analytic magnification, agreement with a grid-trace oracle, refinement convergence, all shape types, multi-planeplane_redshift, and the JAX path (deferred — see the audit). The commented-out test intest_extended.pyis revived.ShapeSolver.image_regions_from/mapping_from: kept triangles grouped into the separate multiple images and exposed as Phase 1ImageRegionobjects (triangle polygons + pixel coverage), plus a per-imagefind_magnificationsplit.autolens/lens/mappings.py(new):mappings_from_fit(pixelized → engine A, parametric →ShapeSolverwith aCircleat the light centre),multiple_image_positions_from(arcsec) andmultiple_image_pixel_coordinates_from(pixels),magnifications_from,source_mapping_from,traced_region_from. No RA/Dec in the library — the workspace guide (Phase 3) shows the astropy conversion.subplot_mappings(fit)inautolens/imaging/plot/fit_imaging_plots.py, exported fromautolens.plot, wired to the existingplots.yaml: subplot_mappingskey.Depends on PyAutoArray#518 (Phase 2a,
Shape.contains/Shape.boundary); CI here is source-installed against PyAutoArraymainand goes green once #518 merges. Merge order: #518 first.ShapeSolver audit
The 2026-08-19 audit's findings, re-verified against the code as it stands, plus what the new validation suite found. Every "fixed" row is fixed in the solver, with a regression test named below; nothing was worked around in the mappings layer.
find_magnificationhardcodesxp=npand never consultsself._xp, souse_jax=Trueis silently ignoredxp=Nonenow resolves throughShapeSolver._xp_from, which consults the flag and raises instead of ignoring it (see the next row for why raising, not switching).solve_trianglesandstepslikewise takexp=Nonerather than requiring it positionally.test_shape_solver.py::test_jax_solver_is_rejected_rather_than_silently_wronguse_jax=Trueis not merely ignored, it is wrong: the JAX triangle containers truncate every refinement step toArrayTriangles.MAX_CONTAINING_SIZE = 15triangles to keep static shapes. That is ample for aPointand meaningless for aShapewith area. Measured on an Isothermal sphere: JAX keeps 15 of the ~800 triangles NumPy keeps and reports a magnification of 0.13 where the truth is 6.86. Routingxpthroughself._xpwithout noticing this would have replaced a silently-ignored flag with a silently wrong number.ShapeSolverraisesNotImplementedErrornamingMAX_CONTAINING_SIZE.test_shape_solver.py::test_jax_and_numpy_kept_triangles_agreeisxfail(strict=True)with the reason recorded, and is the test to un-xfail when the containers grow a dynamic kept set.areacannot survivejax.jit(__len__returns a tracedcount_nonzero)jax.jit(lambda: solver.solve_triangles(...).area)raisesTracerIntegerConversionError(__index__on a tracedint64[]). Same root cause family as the cap above — deferred with it, and recorded in theShapeSolverclass docstring.find_magnification(per_image=True)andimage_regions_fromgroup the kept triangles into connected components by shared vertices. On an Isothermal sphere withtheta_E = 1.0and a source atbeta = 0.3they give 4.324 and 2.330 against the analytic1 + theta_E/beta = 4.333andtheta_E/beta - 1 = 2.333(0.2 % and 0.13 %).test_shape_solver.py::test_per_image_magnification_matches_the_analytic_isothermal_spheremagnification_thresholdwas accepted by the constructor and used only byPointSolver.solve, never byShapeSolverimage_regions_from/per_image. Without it the singular centre of an Isothermal sphere contributes a spurious two-triangle "image" of magnification 0.004 at the lens centre.test_shape_solver.py::test_magnification_threshold_removes_the_central_imagefind_magnification's docstring now states the bound (perimeter * side_length, so halvingpixel_scale_precisionhalves the error, and the area approaches the truth from above) and separates it from the physical finite-source error, quoting measured numbers: atpixel_scale_precision=0.005aCircleof radius 0.2 / 0.1 / 0.05 gives 7.12 / 6.76 / 6.66 against an analytic 6.67.test_shape_solver.py::test_total_magnification_converges_as_the_source_shrinksasserts the error shrinks with the radius and ends below 1 %.test_extended.py::test_solver_basic(the identity-lens magnification of 1) revived, plus two siblings; 16 further tests in the newtest_shape_solver.py. The solver now has three in-library consumers (autolens/lens/mappings.py,subplot_mappings, the visualizer).steps()— asserting monotonicity would have pinned a property the algorithm does not have. Over seven steps it runs 0.2143413, 0.2154238, 0.2126498, 0.2122439, 0.2123877, 0.2124807, 0.2124794: step 0 keeps whole coarse triangles, and each later step filters the neighbourhood of the previous kept set, which is larger than that set.step.neighbourhood.areais monotone non-increasing, and the final refinement changes the kept area by less than any earlier one.test_shape_solver.py::test_refinement_shrinks_the_search_and_converges, and thestepsdocstring.AbstractTriangles.indices(built bynp.unique) splits the two images of an Isothermal sphere into 397 fragments rather than 2._vertex_labels_fromquantises vertices to a thousandth of a triangle edge before deduplicating — far below the spacing of distinct vertices, far above the ulp.CoordinateArrayTriangles{,Np}.for_limits_and_scalenamed its first pair of limitsx_min/x_maxand tiled them along element 0 of every vertex, while element 0 isyeverywhere else. Both the keyword caller (AbstractSolver._initial_triangles) and the positional caller (AbstractTriangles.for_grid) therefore tiled the transposed rectangle. A square grid hides it; on a 24×80 grid of 0.05" pixelsPointSolver.solvefound one of an Isothermal's two images instead of both, the missing one lying well inside the grid.67b1cb75.test_autoarray/structures/triangles/test_coordinate.py::test_for_limits_and_scale__element_0_spans_the_y_limitsand::test_for_grid__rectangular_grid_is_tiled_the_same_way_roundbbbbstatement inArrayTrianglesNp.with_vertices— aNameErroron every call — committed 2025-11-13 and uncaught because the solver only ever reaches theCoordinateArrayTrianglesNpoverride of that name67b1cb75.::test_array_triangles_with_vertices_returns_new_trianglesplot_regionsdrew one label per region entry, at the mean of all that region's polygons. A mapping's image regions are its multiple images, on opposite sides of the lens, so the label landed at their midpoint — on empty sky, labelling nothing, in exactly the figure the overlay exists for.a9a9120a. The label is repeated once per polygon at that polygon's own centre; a single-polygon region is unchanged.test_autoarray/plot/test_utils.py::test__plot_regions__multi_polygon_region_is_labelled_once_per_polygonand::test__plot_regions__single_polygon_region_is_labelled_oncesubplot_mappingsoriginally wrapped the engine intry/except Exception → logger.warning, so a source plane which could not be mapped drew four panels with no regions on themshape=. The identical guard around the visualizer's call inPlotterImaging.fit_imagingis removed for the same reason.test_fit_imaging_plots.py::test__subplot_mappings__unmappable_plane__raises_rather_than_drawing_nothingShapeSolver(use_jax=True)on a JAX-free install raisedModuleNotFoundErrorfrom_xpbefore the rejection fired —_xp_fromresolvedxp=Nonethroughself._xp(which importsjax.numpy) and only then checked the result, so the user got the wrong error and one which blamed the environmentuse_jaxcase now raises beforeself._xpis touched, and the shared message is a plain class-level string, so nothing on the rejection path imports jax. Caught by theunittest-nojaxCI leg;test_use_jax_solver_is_rejected_rather_than_silently_wrongdeliberately carries noimportorskipand now coversfind_magnification/solve_triangles/steps, with the explicitxp=jax.numpyroute split into its own guarded test. Commitc376f10API Changes
PyAutoLens — new public API
autolens.lens.mappings(new module, exported asal.mappings):source_mapping_from(tracer, grid, shape, plane_index=-1, plane_redshift=None, pixel_scale_precision=None, magnification_threshold=0.1) -> aa.Mappingtraced_region_from(tracer, region_grid, plane_index=-1) -> np.ndarray— source-plane convex hull of a traced image-plane region, as a closed(N, 2)(y, x)loop.mappings_from_fit(fit, plane_index=-1, shape=None, grid=None, pixel_scale_precision=None, magnification_threshold=0.1, **clump_kwargs) -> List[aa.Mapping]— engine A when the planehas(cls=aa.Pixelization), engine B otherwise.multiple_image_positions_from(fit, plane_index=-1, use_centroid=False, **mapping_kwargs) -> aa.Grid2DIrregular— the brightest pixel (or flux-weighted centroid) of each multiple image, in arcsec.multiple_image_pixel_coordinates_from(fit, plane_index=-1, use_centroid=False, **mapping_kwargs) -> List[Tuple[float, float]]— the same, as 1-based pixel-centre WCS pixel coordinates viaGeometry2D.pixel_coordinates_wcs_2d_from. No RA/Dec in the library.magnifications_from(fit, plane_index=-1, shape=None, grid=None, pixel_scale_precision=None, magnification_threshold=0.1, **clump_kwargs) -> List[float]— engine B returns the absolute per-image magnification (find_magnification(per_image=True)); engine A has no source-plane area to divide by and returns each image's share of its clump's total model flux (summing to 1 per clump). The two scales are different and the docstring says so.ShapeSolver.image_regions_from(tracer, shape, plane_redshift=None, grid=None, xp=None) -> List[aa.ImageRegion](new)ShapeSolver.mapping_from(tracer, shape, plane_redshift=None, grid=None) -> aa.Mapping(new)autolens.imaging.plot.fit_imaging_plots.subplot_mappings(fit, plane_index=-1, shape=None, output_path=None, output_filename="mappings", output_format=None, colormap=None, threshold=None, min_pixels=None, total_clumps=None, pix_indexes=None, weight_threshold=0.0, pixel_scale_precision=None, magnification_threshold=0.1, region_alpha=0.25, image_plane_lines=None, image_plane_line_colors=None, source_plane_lines=None, source_plane_line_colors=None, title_prefix=None)(new), exported asaplt.subplot_fit_imaging_mappings. Writesmappings_{plane_index}.png.PyAutoLens — changed
ShapeSolver.find_magnification(tracer, shape, xp=np, plane_redshift=None)→(tracer, shape, xp=None, plane_redshift=None, per_image=False).xpnow defaults toNone(resolving toself._xp) rather thannp;per_image=TruereturnsList[float]instead offloat. AShapeSolverbuilt withuse_jax=Truenow raisesNotImplementedErrorwhere it previously returned a NumPy answer — deliberate, see the audit.AbstractSolver.solve_triangles(tracer, shape, xp, plane_redshift=None)andAbstractSolver.steps(...):xpis now optional (xp=None→self._xp). Back-compatible for every existing caller.AbstractSolver.__init__gains a non-pytreeself.gridattribute (Noneunless set byfor_grid), whichfor_gridnow populates soimage_regions_fromknows which pixels to report on. It is deliberately excluded fromtree_flatten._plot_source_plane(private) gainsregions/region_alpha/region_labels.config/visualize/plots.yaml: theinversion: subplot_mappingskey — dead since the mapping visuals were removed in 2025 — is now read byPlotterImaging.fit_imaging, which writes the fit-level subplot for every source plane. Its comment is updated to say it now covers parametric sources too. The default staysfalse.PyAutoArray (on #518, this branch's dependency)
plot_regionsnow draws a region's label once per polygon rather than once per region (behaviour change for any multi-polygon region; single-polygon regions are unchanged).CoordinateArrayTriangles.for_limits_and_scaleandCoordinateArrayTrianglesNp.for_limits_and_scale: parameter order changed from(x_min, x_max, y_min, y_max, scale)to(y_min, y_max, x_min, x_max, scale), matchingAbstractTriangles.for_limits_and_scaleandArrayTrianglesNp. Keyword callers are unaffected in meaning but now tile the rectangle they asked for; the one positional caller (AbstractTriangles.for_grid) is fixed by the reorder.Tests
Run with the worktree PyAutoArray on
PYTHONPATH(Shape.contains/Shape.boundaryfrom #518).Targeted set —
pytest test_autolens/lens/test_mappings.py test_autolens/point/triangles test_autolens/imaging/plot -q:test_autolens/point/triangles/test_shape_solver.py(new, 18 tests)test_autolens/point/triangles/test_extended.py(revived)test_autolens/lens/test_mappings.py(new)test_autolens/imaging/plot/test_fit_imaging_plots.py(+3)test_autolens/point/trianglesFull suite —
pytest test_autolens -q:and with JAX made unimportable (the
unittest-nojaxleg),test_autolens/point/triangles/test_shape_solver.pyis 15 passed, 2 skipped — the two skips being the tests which genuinely need ajax.numpymodule to pass in.PyAutoArray (the phase-2a branch, with the two triangle fixes) —
pytest test_autoarray -q:including 3 new tests in
test_autoarray/structures/triangles/test_coordinate.pyand 2 intest_autoarray/plot/test_utils.py.pytest test_autoarray/plot test_autoarray/inversion/plot -q(Phase 1'saasubplot_mappingsincluded): 42 passed.xfails (one,
strict=True):test_shape_solver.py::test_jax_and_numpy_kept_triangles_agree— theMAX_CONTAINING_SIZE = 15cap on the JAX triangle containers, deferred; see the audit. Its reason string names the condition for removing it.No JAX in the unit tests except that one case, which is
importorskip-guarded.blackwas applied to the four files this PR adds or rewrites; the pre-existing files it edits were notblack-clean onmain, so they are left in their own style rather than reformatted wholesale.Verification
(i) Renders — a simulated
Isothermal(axis ratio 0.7 / 0.9,theta_E = 1.6) +Sersicsource, 100×100 at 0.1", fitted at the true model, then the same dataset fitted with aRectangularUniform(30, 30)+Constantpixelization:/tmp/claude-1000/-home-jammy-Code-PyAutoLabs/7ac5cc85-dfd9-41e2-a4bd-725f33f0f24f/scratchpad/p2_verify/mappings_1.png/tmp/claude-1000/-home-jammy-Code-PyAutoLabs/7ac5cc85-dfd9-41e2-a4bd-725f33f0f24f/scratchpad/p2_verify/mappings_pix_1.pngBoth figures draw the source region filled and outlined in the source-plane panels, its multiple images in the same colour and with the same label on each image (the PyAutoArray#518 label fix above) in the two image-plane panels, and the critical curve / caustic. The two engines return the same multiple-image positions on the same data —
(1.65, 1.15)and(-0.95, -0.65)— which is an independent check that the ShapeSolver engine agrees with the mapping-matrix engine.(ii) Multiple-image positions against
PointSolver— a full-resolution simulated quad (PYAUTO_SMALL_DATASETSunset, soPointSolver.solveis not short-circuited):Isothermal(axis_ratio=0.7, angle=45, theta_E=1.6)with a compactSersicSph(effective_radius=0.05) at(0.05, 0.03), inside the inner caustic; 120×120 at 0.1".multiple_image_positions_from(y, x)PointSolver.solve(y, x)Four positions each, every pair matched within 0.034" — about a third of a pixel (0.1"), and well inside the "within one pixel" requirement. The mapped positions are exact pixel centres by construction (they are the brightest pixel of each image region), which is why the residual is of order half a pixel.
(iii) Grid-trace oracle — for a
Circleof radius 0.1 on an Isothermal sphere over a 100×100 / 0.05" mask, the oracle (trace the image grid,shape.contains,image_regions_from_slim_mask) finds 2 regions covering 86 pixels and the solver finds 2 regions covering 124. Every oracle pixel is in the solver's regions (the oracle is a strict subset) and no solver pixel lies outside a one-pixel dilation of the oracle — 0 pixels outside. The difference is the expected one: the solver marks every pixel containing a point which traces inside the shape, the oracle only pixels whose centre does.Epic
image-source-mappingsphase 2 — ledgerPyAutoMind/draft/feature/autoarray/image_source_mappings_epic.md. Phase 3 (workspace guide +tutorial_2_mappersrewrite) opens after this and #518 release. Labelpending-release.🤖 Generated with Claude Code
https://claude.ai/code/session_011EVnQ4sEYrRM7GCvFyveUA