Skip to content

feat: twins stellar-plus-dark transformation and diagnostics (lenstwin 3) - #806

Merged
Jammy2211 merged 1 commit into
mainfrom
feature/lenstwin-p3-stellar-dark
Oct 28, 2026
Merged

Jammy2211 merged 1 commit into
mainfrom
feature/lenstwin-p3-stellar-dark

Conversation

@Jammy2211

Copy link
Copy Markdown
Collaborator

Summary

Phase 3 of the LensTwin epic (closes #805). It adds the first real twin transformation: replace a lens galaxy's total-mass profile with stellar mass that follows its MGE light, plus a dark-matter halo, normalised inside the parent's Einstein radius. It also adds the diagnostics that say what a transformation changes before any inference is run.

3A — transformation

  • al.twins.einstein_radius_aperture_from(tracer, grid, centre, ...) measures θ_E for the whole lens model (via LensCalc) and returns an ag.CircularAperture of that radius, centred on the replaced galaxy.
  • al.twins.transform.ReplaceTotalMassWithStellarPlusDark(galaxy, light, *, scale_radius_kpc, f_dark=0.5, ...):
    • Inputs: galaxy is addressed by model name (e.g. "lens") or by the reference's role. dark_cls defaults to NFWSph; gNFWSph takes inner_slope.
    • Removed: the galaxy's total-mass profile(s).
    • Re-expressed: the MGE light basis becomes an lmp.Gaussian basis in place, with identical light. A test shows the lens image is bit-identical, so the light is not counted twice.
    • Added: a dark halo centred on the light. The split is solved by ag.MassBudget (PyAutoGalaxy#676).
    • Units: r_s is given in kpc and converted with the setup cosmology at the galaxy's redshift.
    • Shear: external shear is kept by default and never enters M_ref.
    • Inputs untouched: the twin, tracer and setup it is given are never mutated.
  • The TransformationRecord holds:
    • requested: the arguments.
    • procedure: convention text citing PyAutoGalaxy/docs/api/mass_budget.rst, plus the solved numbers — θ_E of parent and twin, M_ref, Υ, κ_s, r_s in kpc and arcsec, cosmology, aperture geometry.
    • changed: the replaced component paths.
    • side_effects: shear kept; the twin's own θ_E (reported, not asserted).
    • preserved: ["projected mass of <galaxy> inside theta_E,parent"].
    • The ground-truth parameters also appear flat under "truth" in twin.json, via the new TransformationRecord.truth.

3B — diagnostics and plotter (R6)

  • al.twins.compare(parent, twin, ...) returns a TwinComparison holding:
    • convergence and deflection differences;
    • enclosed-mass profiles M(<R) for both;
    • tangential critical curves and θ_E for both;
    • the normalised residual and Δχ²_image, the noise-free difference between the two model images on the parent's masked grid.
  • al.twins.compare_to_data(twin, dataset) returns Δχ²_data = χ²(twin | data) − χ²(parent | data), from two FitImaging objects. It handles parametric sources only and raises for pixelised ones. Δχ²_image and Δχ²_data are different quantities and are named separately.
  • aplt.subplot_twin_comparison(comparison, ...) draws a 2×3 panel: parent image, twin image, normalised residual (with Δχ²_image), convergence difference, M(<R) with both θ_E, and both critical curves.

Rulings honoured (2026-10-28)

  • R3: the aperture is a circle of radius θ_E,parent, computed from the whole lens model and centred on the replaced galaxy. M_ref is the replaced components only; external shear and other galaxies are untouched.
  • R4: the halo is NFWSph on the light centre, with a fixed physical r_s.
  • R6: the plotter lives in the library and follows the aplt conventions.

Fixes a test that failed depending on run order. test__simulate__observed_route_leaves_global_random_state failed whenever it was the first test in a process to run the observed route, for example under pytest -n 4. The cause is upstream:

  • The first import jax in a process draws 13 numbers from NumPy's global random state. jax/_src/clusters/k8s_cluster.py evaluates np.random.rand(...) at import time.
  • The observed route triggers that import lazily, via autoarray's AbstractNDArray.__getitem__.

Twin.simulate itself draws only from default_rng(seed). The test now runs the route once before seeding.

API Changes

The public API grew.

  • New in al.twins: einstein_radius_aperture_from, compare, compare_to_data, TwinComparison and transform.ReplaceTotalMassWithStellarPlusDark.
  • New in aplt: subplot_twin_comparison.
  • TransformationRecord.truth and a "truth" key in twin.json.

Nothing existing was removed or changed in behaviour.
See full details below.

Test Plan

  • test_autolens/twins/test_stellar_dark.py (13 tests):
    • The witness: on a fixture SIE + shear + MGE-light reference with a 50/50 budget, the stellar and dark masses inside θ_E,parent are each 0.5 of M_ref to ±1e-3, and the shear is unchanged. This is checked on the same aperture and grid as the solve, so it holds to machine precision by construction.
    • The record round-trips through al.to_dict/from_dict and through SimulatedTwin.output_to/from_directory.
    • The parent is not mutated.
    • The lens light is unchanged.
    • kpc→arcsec conversion.
    • gNFWSph inner_slope round trip.
    • keep_shear=False.
    • A two-galaxy budget raises.
  • test_autolens/twins/test_compare.py (7 tests):
    • Comparing a twin with itself gives zero everywhere.
    • The transformed twin has Δχ²_image > 0, and M(<θ_E) matches the parent.
    • compare_to_data returns finite values on the 7x7 fixture.
    • subplot_twin_comparison writes a PNG.
  • python -m pytest test_autolens/twins -n 4: 39 passed.
  • Full suite python -m pytest test_autolens -n 8: 973 passed, 1 skipped, 1 xfailed.
  • CI matrix (3.12, 3.13) green.
Full API Changes (for automation & release notes)

Added

  • al.twins.einstein_radius_aperture_from(tracer, grid, centre, pixel_scale=0.05, **integrator_settings) -> ag.CircularAperture (θ_E is .radius).
  • al.twins.transform.ReplaceTotalMassWithStellarPlusDark(galaxy, light, *, scale_radius_kpc, f_dark=0.5, budget=None, dark_cls=None, keep_shear=True, aperture_settings=None, einstein_radius_pixel_scale=0.05, grid=None, dark_name="dark", **dark_fixed), with apply(twin) -> (tracer, setup, record).
  • al.twins.compare(parent, twin, grid=None, radii=None, centre=None, pixel_scale=0.05, aperture_settings=None) -> TwinComparison.
  • al.twins.TwinComparison:
    • attributes: maps, radii, mass_enclosed_parent/twin, tangential_critical_curves_parent/twin, einstein_radius_parent/twin, image_parent/twin, noise_map, centre, grid.
    • properties: convergence_difference, deflections_difference, deflections_y/x_difference, mass_enclosed_difference, normalized_residual_map, delta_chi_squared_image.
  • al.twins.compare_to_data(twin, dataset, parent=None) -> float.
  • aplt.subplot_twin_comparison(comparison, output_path=None, output_filename="twin_comparison", output_format=None, colormap=None, title_prefix=None).
  • TransformationRecord.truth (property); SimulatedTwin.twin_json()["truth"].
  • docs/api/twins.rst: galaxy addressing, the transformation, the diagnostics and the plotter.

Removed

  • None.

Migration

  • None required.

Generated by the PyAutoLabs agent workflow.


Generated by Claude Code

…n 3)

Public API grew: al.twins.einstein_radius_aperture_from,
al.twins.transform.ReplaceTotalMassWithStellarPlusDark, al.twins.compare,
al.twins.TwinComparison, al.twins.compare_to_data,
aplt.subplot_twin_comparison, TransformationRecord.truth and a flat
"truth" list in twin.json. Nothing removed. Issue #805.

Rulings honoured (2026-10-28): R3 (aperture = circle of radius
theta_E,parent from the whole tracer, centred on the replaced galaxy;
M_ref = the replaced total-mass components only, shear excluded), R4
(NFWSph on the light centre, fixed r_s given in kpc and converted with the
setup cosmology) and R6 (library plotter, aplt conventions). The split is
solved by ag.MassBudget (PyAutoGalaxy#676). The light basis is re-expressed
as lmp.Gaussian in place, so the light is unchanged and not double-counted.

Also makes test__simulate__observed_route_leaves_global_random_state
order-independent: the first import of jax in a process draws from NumPy's
global RNG (jax/_src/clusters/k8s_cluster.py, reached lazily via autoarray
AbstractNDArray.__getitem__), so the test warms the route before seeding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016nhQ8NtZ6QtuqxKKAnhhdg
@Jammy2211 Jammy2211 added the pending-release PR queued for the next release build label Oct 28, 2026 — with Claude
@Jammy2211
Jammy2211 merged commit b29ffcc into main Oct 28, 2026
4 checks passed
@Jammy2211
Jammy2211 deleted the feature/lenstwin-p3-stellar-dark branch October 28, 2026 15:52
@pyautolabs-bot pyautolabs-bot Bot removed the pending-release PR queued for the next release build label Oct 29, 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.

feat: twins stellar-plus-dark transformation and diagnostics (lenstwin 3)

2 participants