Skip to content

fix: measure PointSolver magnification at the plane being solved for (#480) - #712

Merged
Jammy2211 merged 2 commits into
mainfrom
claude/point-source-json-regime-tmk7la
Aug 27, 2026
Merged

Jammy2211 merged 2 commits into
mainfrom
claude/point-source-json-regime-tmk7la

Conversation

@Jammy2211

Copy link
Copy Markdown
Collaborator

Summary

Fixes #480. PointSolver.solve returned no images for a source on an intermediate plane of a multi-plane tracer, making any point-source modeling beyond "all sources at the same redshift" impossible.

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. Candidate images of an intermediate-plane source are heavily de-magnified measured that way (~1e-3–1e-5 in the issue's configuration), so magnification_threshold discarded every one and the solve returned an empty grid. point_solver.py had plane_redshift in scope and did not forward it.

This is the pattern the fit side already uses. AbstractFitPoint.magnifications_at_positions and solved.py's _lens_calc_for both measure with LensCalc.from_tracer(..., plane_j=...) at the source's own plane — and positions/image/abstract.py already passed plane_redshift into solver.solve. So 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 right. The argument was plumbed end-to-end through the callers; only the last consumer ignored it.

No-op for the default case, verified numerically rather than argued. 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 alike.

API Changes

No public API removed or renamed. Behaviour changes:

  • PointSolver.solve(..., plane_redshift=z) now returns the correct images for an intermediate plane, where it previously returned an empty grid. Results for plane_redshift=None and for the last plane are unchanged.
  • A plane_redshift matching no plane now raises ValueError naming the tracer's actual redshifts. It previously reached traced_grids_list[None] and raised a bare TypeError.

The plane-index resolution is now shared by the search and the filter (ShapeSolver._plane_index) — the bug was those two disagreeing about the plane, so a second copy of the resolution invites the same drift back.

Test Plan

  • New test_autolens/point/triangles/test_solver_multi_plane.py (9 tests). Nothing passed plane_redshift to the solver in any test before this — the multi-plane solve path had no coverage, which is why the bug shipped.
  • 5 of the 9 fail against the unfixed solver; the other 4 pin behaviour that must not change.
  • Full test_autolens: 544 passed, 1 skipped (coolest not installed).
  • JAX solve path exercised directly — same 4 images as NumPy.

Magnification accuracy was cross-checked three ways at the solved plane, 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
Full API Changes (for automation & release notes)

Added

  • ShapeSolver._plane_index(tracer, plane_redshift=None) -> int — resolves the plane being solved for, shared by _plane_grid and _filter_low_magnification. Raises ValueError when no plane matches.

Changed

  • ShapeSolver._filter_low_magnification(tracer, points, xp, plane_redshift=None) — gained plane_redshift; now constructs via ag.LensCalc.from_tracer(..., plane_j=plane_index) instead of from_mass_obj(tracer).
  • PointSolver._solve_array — forwards plane_redshift to _filter_low_magnification.

Migration

None. Both changed methods are private, and the public solve signature is unchanged.

Follow-ups filed (not in this PR)

Cross-checking this fix surfaced three separate items, filed as PyAutoMind prompts rather than widened into here:

  • LensCalc's NumPy Richardson Hessian uses a hardcoded 0.01″ step. 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. Pre-existing and independent of bug: PointSolver magnification filter uses last-plane magnification instead of plane_redshift #480, but it is the default path.
  • Multi-plane time delays — the time-delay equivalent of this fix. tracer_util.time_delays_from raises on >2 planes, 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; three call sites currently hand-roll their own and resolve the plane by different keys.

Generated by the PyAutoLabs agent workflow.


Generated by Claude Code

James Nightingale and others added 2 commits August 27, 2026 14:27
…480)

PointSolver.solve returned no images for a source on an intermediate plane of a
multi-plane tracer. 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. Candidate images of an intermediate-plane source are heavily
de-magnified measured that way (~1e-3-1e-5 in the issue's configuration), so
magnification_threshold discarded every one and the solve returned an empty grid.

point_solver.py had plane_redshift in scope and did not forward it; it now does,
and the filter measures via LensCalc.from_tracer(..., plane_j=plane_index) -- the
same callable the search uses.

This is the pattern the fit side already uses: AbstractFitPoint's
magnifications_at_positions and solved.py's _lens_calc_for both measure with
from_tracer(..., plane_j=...) at the source's own plane. The two halves of one
likelihood evaluation therefore disagreed, since
positions/image/abstract.py already passed plane_redshift into solver.solve.
The argument was plumbed end-to-end; only the last consumer ignored it.

No-op for the default case, verified numerically rather than argued: Tracer's
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 alike.

Also resolves the plane index once, shared by the search and the filter. The bug
was those two disagreeing about the plane, so a second copy of the resolution
invites the same drift back. That resolution now raises a ValueError naming the
tracer's actual redshifts when plane_redshift matches no plane, where
plane_index_via_redshift_from's None previously reached traced_grids_list[None]
and raised a bare TypeError.

Adds test_solver_multi_plane.py. Nothing passed plane_redshift to the solver in
any test before this, which is why the bug shipped -- the multi-plane solve path
had no coverage. Four of the seven new tests fail against the unfixed solver;
the other three pin the behaviour that must not change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188mKnNzXptgLi1CUZ4H7NZ
#480)

Two checks on the #480 fix, answering different questions.

Consistency. The solver and the fit reach the plane by different keys: the fit
from the profile name via extract_plane_index_of_profile, the solver from a
redshift via plane_index_via_redshift_from, with AbstractFitPoint.plane_redshift
carrying the fit's answer across as planes[plane_index].redshift. That is a
name -> index -> redshift -> index round trip spanning two modules, so it is
pinned rather than assumed, through the solver's own _plane_index helper rather
than a re-derivation of it. Both land on the same plane, and
magnifications_at_positions then matches the solver's measurement to 1e-12.

Accuracy. Agreement between the two is not evidence either is right, so the
magnification is also cross-checked against one derived from ray-tracing alone:
central differences of theta -> beta_j via traced_grid_2d_list_from, with
mu = 1/det(J), sharing no code with the Hessian path. The two agree to ~1e-8.

Both were confirmed against exact JAX autodiff (float64) during development,
which agrees with the ray-traced Jacobian to 1.8e-09 at the solved plane.

Five of the nine tests in this module now fail against the unfixed solver.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188mKnNzXptgLi1CUZ4H7NZ
@Jammy2211 Jammy2211 added the pending-release PR queued for the next release build label Aug 27, 2026 — with Claude
@Jammy2211
Jammy2211 merged commit c1bba66 into main Aug 27, 2026
4 checks passed
@Jammy2211
Jammy2211 deleted the claude/point-source-json-regime-tmk7la branch August 27, 2026 14:51
@Jammy2211 Jammy2211 removed the pending-release PR queued for the next release build label Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant