Skip to content

bug: PointSolver magnification filter uses last-plane magnification instead of plane_redshift #480

Closed

Description

@Jammy2211

Summary

PointSolver._filter_low_magnification discards every candidate image position when solving for an intermediate source plane in a multi-plane tracer. This makes any point-source modeling that involves a source whose plane is not the last plane impossible: the simulator returns 0 positions, and AnalysisPoint likelihood evaluations fail.

Repro

import autolens as al

lens = al.Galaxy(
    redshift=0.5,
    mass=al.mp.Isothermal(
        centre=(0.0, 0.0),
        einstein_radius=1.6,
        ell_comps=al.convert.ell_comps_from(axis_ratio=0.9, angle=45.0),
    ),
)
source_0 = al.Galaxy(
    redshift=1.0,
    mass=al.mp.Isothermal(
        centre=(0.02, 0.03),
        einstein_radius=0.2,
        ell_comps=al.convert.ell_comps_from(axis_ratio=0.8, angle=60.0),
    ),
    point_0=al.ps.Point(centre=(0.02, 0.03)),
)
source_1 = al.Galaxy(redshift=2.0, point_1=al.ps.Point(centre=(0.0, 0.0)))

tracer = al.Tracer(galaxies=[lens, source_0, source_1])
grid = al.Grid2D.uniform(shape_native=(200, 200), pixel_scales=0.05)
solver = al.PointSolver.for_grid(grid=grid, pixel_scale_precision=0.001, magnification_threshold=0.1)

print(solver.solve(tracer=tracer, source_plane_coordinate=(0.02, 0.03), plane_redshift=1.0).shape)
# (0, 2)  ← expected (4, 2)

print(solver.solve(tracer=tracer, source_plane_coordinate=(0.0, 0.0), plane_redshift=2.0).shape)
# (4, 2)  ← OK, this is the last plane

Setting magnification_threshold=0.0 recovers 5 candidate positions for source_0, confirming the triangle search itself is fine — only the magnification post-filter rejects them.

Root cause

autolens/point/solver/shape_solver.py::_filter_low_magnification:

magnifications = ag.LensCalc.from_mass_obj(
    tracer
).magnification_2d_via_hessian_from(
    grid=aa.Grid2DIrregular(points).array, xp=xp
)

The hessian-based magnification implicitly uses the tracer's last plane (z=2.0 in the repro) as the source plane. Inspected at the candidate positions, magnifications come back as ~1e-3–1e-5 (i.e. de-magnified for a z=2.0 source at those locations). The 0.1 threshold then discards everything.

When solving for an intermediate plane, the magnification used by the filter should match the requested plane_redshift, not the tracer's final plane.

Suggested fix

Plumb plane_redshift through from PointSolver.solve → _filter_low_magnification and use it to compute the hessian magnification at the correct plane (e.g. by ray-tracing using plane_index_via_redshift_from, or by building a sub-tracer that ends at plane_redshift).

For the single-plane and last-plane cases the current behaviour is correct, so the fix should be a no-op when plane_redshift is None or equals the last plane's redshift.

Impact

  • Simulator scripts can't generate datasets for intermediate-plane sources in a multi-plane lens (e.g. the original point_source/features/double_einstein_cross/simulator.py).
  • AnalysisPoint log-likelihood evaluation fails when a fitted Point is at any plane other than the last one — every solver.solve() call returns 0 positions and FitPositions* cannot pair model images to data.
  • This blocks any factor-graph / multi-plane point-source modeling beyond the simplest "all sources at the same redshift" case.

Workaround used downstream

autolens_workspace issue PyAutoLabs/autolens_workspace#97 needed a multi-source point-source modeling tutorial. To unblock it the workspace example was simplified to drop the intermediate source's own mass profile (so the only deflector is the foreground lens). Once this PyAutoLens bug is fixed, the workspace simulator and model should be reverted to a richer multi-plane configuration where the intermediate source itself acts as a deflector for the further source — see the follow-up prompt in PyAutoPrompt/autolens/restore_multiple_sources_lensing_of_lens.md.

Activity

  1. added 2 commits that reference this issue on Aug 27, 2026
  2. Jammy2211 commented on Aug 27, 2026

    @Jammy2211
    CollaboratorAuthor

    Shipped

    Fixed in #712, merged as c1bba66.

    Root cause. The triangle search honoured plane_redshift and traced to the requested plane, but _filter_low_magnification did not — it built LensCalc.from_mass_obj(tracer), whose deflection callable is the whole tracer, i.e. the last plane. point_solver.py had plane_redshift in scope and simply did not forward it.

    Your suggested fix was right, and cheaper than it looked: LensCalc.from_tracer(tracer, use_multi_plane=True, plane_j=...) already existed in PyAutoGalaxy and already binds deflections_between_planes_from for a given plane. It is also the pattern the fit side was already using — AbstractFitPoint.magnifications_at_positions and point/fit/solved.py::_lens_calc_for both measure at the source's own plane. Since point/fit/positions/image/abstract.py already passed plane_redshift into solver.solve, the two halves of one likelihood evaluation disagreed: model_data measured at the last plane and returned nothing, while magnifications_at_positions measured at the source's plane and was correct. The argument was plumbed end-to-end; only the last consumer ignored it.

    The no-op requirement holds exactly, as you asked. Tracer.deflections_yx_2d_from dispatches to deflections_between_planes_from with plane_i=0, plane_j=-1 whenever the tracer has more than one plane, so from_mass_obj(tracer) and from_tracer(tracer, plane_j=-1) wrap the same callable. Magnifications compare bit-identical (max|diff| = 0.0) for multi-plane and single-plane tracers.

    Verification. Your repro now returns (4, 2) at z=1.0 where it returned (0, 2). Magnification at the solved plane was cross-checked three ways, agreeing to 1.7e-08:

    Method µ at the four solved images
    NumPy Richardson FD (shipped path) -27.89795126 -9.05806212 23.12205173 15.99681364
    JAX exact autodiff (float64) -27.89795173 -9.05806221 23.12205188 15.99681364
    Ray-traced Jacobian (no LensCalc) -27.89795168 -9.05806221 23.12205188 15.99681364

    New test_autolens/point/triangles/test_solver_multi_plane.py — 9 tests, 5 of which fail against the unfixed solver. Nothing passed plane_redshift to the solver in any test before this, which is why the bug survived. Full suite 544 passed, 1 skipped; CI green on all four legs (3.12, 3.13, nojax, docs).

    Two things folded in. The plane-index resolution is now shared by the search and the filter — the bug was those two disagreeing, so a second copy invites the drift back. And an unmatched plane_redshift now raises ValueError naming the tracer's actual redshifts, where it previously reached traced_grids_list[None] and raised a bare TypeError.

    Follow-ups filed, deliberately not widened into this PR:

    • Cross-checking this surfaced a separate, pre-existing bug: LensCalc's NumPy Richardson Hessian uses a hardcoded 0.01″ step, and at the last plane of this same tracer it disagrees with both JAX autodiff and the ray-traced Jacobian by 122%, with the wrong sign on three of four points. It is the default path and magnifications_at_positions uses it.
    • Multi-plane time delays — the time-delay equivalent of this issue. tracer_util.time_delays_from raises on >2 planes (which this fix now makes reachable), and LensCalc.from_tracer binds the deflection callable to the plane while leaving potential_2d_from as the tracer's all-planes sum.
    • A refactor giving plane-bound lensing quantities one construction path, since three call sites currently hand-roll their own.

    Still open downstream, as your issue notes: reverting the autolens_workspace multi-source example to a richer multi-plane configuration, and removing the two multiple_sources entries from config/build/no_run.yaml.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions