diff --git a/autolens/lens/tracer.py b/autolens/lens/tracer.py index 0ce08d750..84ec1ac1a 100644 --- a/autolens/lens/tracer.py +++ b/autolens/lens/tracer.py @@ -20,6 +20,7 @@ """ from abc import ABC import numpy as np +import warnings from scipy.interpolate import griddata from typing import Dict, List, Optional, Type, Union @@ -27,12 +28,146 @@ import autoarray as aa import autogalaxy as ag +from autoarray import validate + from autogalaxy.profiles.geometry_profiles import GeometryProfile +from autogalaxy.profiles.light.abstract import LightProfile from autogalaxy.profiles.light.snr import LightProfileSNR +from autogalaxy.profiles.mass.abstract.abstract import MassProfile +from autogalaxy.profiles.point_sources import Point, PointSolved from autolens.lens import tracer_util +LENSABLE_CLS = (LightProfile, aa.Pixelization, Point, PointSolved) +""" +Everything a tracer can gravitationally lens. + +Emphatically **not** just ``LightProfile``: a source reconstructed by a +``Pixelization`` carries no light profile, and a point source carries neither — yet +both are light to be lensed. Enumerating only light profiles made the redshift-ordering +warning below fire on every pixelized-source and point-source configuration in the test +suite, which is precisely the "would a warning be noise?" failure it must avoid. +""" + + +class MultiPlaneRedshiftWarning(UserWarning): + """ + Warned when a tracer's redshifts describe a system in which no light lies behind + any mass, so nothing in it can be gravitationally lensed. + + This has its own category so it can be silenced with a single filter, without + suppressing unrelated warnings: + + :: + + import warnings + from autolens.lens.tracer import MultiPlaneRedshiftWarning + + warnings.filterwarnings("ignore", category=MultiPlaneRedshiftWarning) + + It is a **warning and never an error**, deliberately. Multi-plane ray tracing + genuinely supports geometries that look wrong under two-plane "lens and source" + naming, so a configuration this flags may still be exactly what the user intended. + """ + + +def _warn_if_no_light_is_behind_any_mass(galaxies): + """ + Warn if every light-bearing galaxy lies in front of (or level with) every + mass-bearing galaxy, across more than one redshift plane. + + In that configuration no light can be deflected by any mass, so the tracer + produces an unlensed image while looking like a lens model. The usual cause is + the lens and source redshifts being the wrong way round — the case reported on + PyAutoLens#532, where ``z_lens=1.0`` with ``z_source=0.5`` returned a finite image + and said nothing. + + __Why this warns and does not raise__ + + Multi-plane lensing legitimately supports geometries that look inverted under + two-plane naming, so this cannot be an error. The test on the ordering is + deliberately narrow for the same reason: it fires only when *no* light at all + sits behind *any* mass. A system with mass at z=1.0 and light at both z=0.5 and + z=1.5 is a real multi-plane configuration and is not flagged, because some of its + light is genuinely lensed. + + Single-plane systems (one distinct redshift) are never flagged — a galaxy with its + own light and mass at one redshift is ordinary PyAutoGalaxy usage, not a mistake. + + __Tracer safety__ + + Redshifts can be free model parameters (e.g. a subhalo redshift under + ``jax.jit``), so only concrete redshifts are compared; a traced redshift makes the + check skip rather than coerce a traced boolean. + + Parameters + ---------- + galaxies + The galaxies the tracer was constructed with. + """ + try: + galaxy_list = list(galaxies) + except TypeError: + return + + mass_redshifts = [] + light_redshifts = [] + all_redshifts = [] + + for galaxy in galaxy_list: + redshift = getattr(galaxy, "redshift", None) + + if not validate.is_concrete_scalar(redshift): + return + + all_redshifts.append(redshift) + + has = getattr(galaxy, "has", None) + + if has is None: + return + + is_mass = has(cls=MassProfile) + is_lensable = any(has(cls=cls) for cls in LENSABLE_CLS) + + # An entirely empty galaxy is a scaffold — a placeholder in a model being + # composed, or a source not yet filled in. We cannot judge the geometry of a + # system that is still being built, so say nothing rather than warn someone + # mid-construction. + if not is_mass and not is_lensable: + return + + if is_mass: + mass_redshifts.append(redshift) + + if is_lensable: + light_redshifts.append(redshift) + + if not mass_redshifts or not light_redshifts: + return + + if len(set(all_redshifts)) < 2: + return + + if max(light_redshifts) > min(mass_redshifts): + return + + warnings.warn( + f"No light in this tracer lies behind any mass, so nothing in it is " + f"gravitationally lensed: the light-bearing galaxies are at redshifts " + f"{sorted(set(light_redshifts))} and the mass-bearing galaxies at " + f"{sorted(set(mass_redshifts))}. The usual cause is the lens and source " + f"redshifts being the wrong way round.\n\n" + f"This is a warning, not an error — multi-plane ray tracing supports " + f"geometries that look inverted under two-plane naming, so this may be " + f"intended. Silence it with:\n\n" + f" warnings.filterwarnings('ignore', category=MultiPlaneRedshiftWarning)", + MultiPlaneRedshiftWarning, + stacklevel=3, + ) + + def _validate_galaxies(galaxies): """ Raise if ``galaxies`` is not something the tracer can treat as a collection of @@ -131,6 +266,7 @@ def __init__( # galaxies = list(galaxies.values()) _validate_galaxies(galaxies=galaxies) + _warn_if_no_light_is_behind_any_mass(galaxies=galaxies) self.galaxies = galaxies diff --git a/test_autolens/lens/test_tracer_validation.py b/test_autolens/lens/test_tracer_validation.py index e477825dc..1bdc53f3e 100644 --- a/test_autolens/lens/test_tracer_validation.py +++ b/test_autolens/lens/test_tracer_validation.py @@ -10,16 +10,19 @@ The negative-redshift half of #532 is guarded in PyAutoGalaxy, where `Galaxy` and its `redshift` assignment actually live (`al.Galaxy` IS `ag.Galaxy`), and is tested there. -`z_lens > z_source` is phase 4 of the audit and explicitly NOT implemented — it is -held pending the reporter's answer. The control at the bottom pins today's permissive -behaviour so phase 4 cannot regress it silently. +`z_lens > z_source` (phase 4) is implemented at the bottom of this module as a +*warning* under its own filterable category — never an error, because multi-plane +lensing genuinely supports geometries that look inverted under two-plane naming. """ +import warnings + import numpy as np import pytest import autofit as af import autolens as al +from autolens.lens.tracer import MultiPlaneRedshiftWarning @pytest.fixture(name="grid") @@ -107,25 +110,197 @@ def test__control__a_model_instance_of_galaxies_is_accepted(lens_galaxy, source_ # ====================================================================================== -# Phase 4 guard-rail — z_lens > z_source must NOT raise +# Phase 4 — z_lens > z_source WARNS, and must never raise # ====================================================================================== +# +# Resolved 2026-08-09. @rhayes777 was asked on #532 whether a warning here would be +# noise in a real multi-plane setup and did not answer; the campaign was closed with +# the warning implemented behind its own filterable category, so a user for whom it +# IS noise can silence it with one filter rather than living with it. +# +# The rule is deliberately narrow — it fires only when NO light lies behind ANY mass, +# i.e. when nothing in the tracer can be lensed at all. Genuine multi-plane systems, +# where some light is lensed and some is not, stay quiet. -def test__control__lens_redshift_above_source_redshift_still_constructs_and_evaluates( - grid, -): - """ - PHASE 4 GUARD-RAIL — deliberately pinning today's permissive behaviour. +def _mass_galaxy(redshift): + return al.Galaxy( + redshift=redshift, mass=al.mp.IsothermalSph(einstein_radius=1.0) + ) + + +def _light_galaxy(redshift): + return al.Galaxy(redshift=redshift, bulge=al.lp.Sersic(intensity=1.0)) + - Multi-plane lensing genuinely supports geometries that look inverted under - two-plane naming, so this must not raise. Whether it should even *warn* is the - open question put to @rhayes777 on #532. This test exists so phase 4 cannot - quietly turn it into an error. +def test__phase4__inverted_redshifts_warn_but_still_construct_and_evaluate(grid): """ - lens = al.Galaxy(redshift=1.0, mass=al.mp.IsothermalSph(einstein_radius=1.0)) - source = al.Galaxy(redshift=0.5, bulge=al.lp.Sersic(intensity=1.0)) + The reported case. It must WARN — and must still produce a finite image, because + multi-plane genuinely supports geometries that look inverted under two-plane + naming. A warning, never an error. + """ + galaxies = [_mass_galaxy(1.0), _light_galaxy(0.5)] + + with pytest.warns(MultiPlaneRedshiftWarning, match="lies behind"): + tracer = al.Tracer(galaxies=galaxies) - image = al.Tracer(galaxies=[lens, source]).image_2d_from(grid=grid) + image = tracer.image_2d_from(grid=grid) assert np.isfinite(np.asarray(image)).all() assert np.asarray(image).sum() > 0.0 + + +def test__phase4__the_warning_names_both_sets_of_redshifts(): + with pytest.warns(MultiPlaneRedshiftWarning) as record: + al.Tracer(galaxies=[_mass_galaxy(1.0), _light_galaxy(0.5)]) + + message = str(record[0].message) + + assert "0.5" in message + assert "1.0" in message + + +def test__phase4__the_warning_can_be_silenced_by_its_own_category(): + """ + The whole point of a dedicated category: a user for whom this is noise silences + exactly this, and nothing else. + """ + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + warnings.filterwarnings("ignore", category=MultiPlaneRedshiftWarning) + + al.Tracer(galaxies=[_mass_galaxy(1.0), _light_galaxy(0.5)]) + + assert [w for w in caught if issubclass(w.category, MultiPlaneRedshiftWarning)] == [] + + +@pytest.mark.parametrize( + "label,galaxies_fn", + [ + ("normal lens/source ordering", lambda: [_mass_galaxy(0.5), _light_galaxy(1.0)]), + ( + "lens with its own light, plus a source behind it", + lambda: [ + al.Galaxy( + redshift=0.5, + mass=al.mp.IsothermalSph(einstein_radius=1.0), + bulge=al.lp.Sersic(intensity=1.0), + ), + _light_galaxy(1.0), + ], + ), + ( + "genuine multi-plane: mass at 1.0, light at 0.5 AND 1.5", + lambda: [_mass_galaxy(1.0), _light_galaxy(0.5), _light_galaxy(1.5)], + ), + ("everything on one plane", lambda: [_mass_galaxy(0.5), _light_galaxy(0.5)]), + ("mass only, no light to lens", lambda: [_mass_galaxy(1.0), _mass_galaxy(0.5)]), + ("light only, no mass", lambda: [_light_galaxy(1.0), _light_galaxy(0.5)]), + ("empty tracer", lambda: []), + ], +) +def test__phase4__legitimate_configurations_stay_quiet(label, galaxies_fn): + """ + The noise question, answered by construction. The genuine multi-plane case is the + one that matters most: mass at z=1.0 with light at BOTH 0.5 and 1.5 has some of + its light lensed, so it is a real configuration and must not be flagged. + """ + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + + al.Tracer(galaxies=galaxies_fn()) + + assert [ + w for w in caught if issubclass(w.category, MultiPlaneRedshiftWarning) + ] == [], label + + +@pytest.mark.parametrize( + "label,source_galaxy_fn", + [ + ( + "pixelized source — carries NO LightProfile but is still light to lens", + lambda: al.Galaxy( + redshift=1.0, + pixelization=al.Pixelization( + mesh=al.mesh.RectangularUniform(shape=(3, 3)), + regularization=al.reg.Constant(coefficient=1.0), + ), + ), + ), + ( + "point source — carries neither light profile nor pixelization", + lambda: al.Galaxy(redshift=1.0, point_0=al.ps.Point(centre=(0.0, 0.0))), + ), + ], +) +def test__phase4__lensable_sources_without_a_light_profile_do_not_warn( + label, source_galaxy_fn +): + """ + Regression for the false-positive classes found while building this warning. + + Counting only `LightProfile` made this fire on every pixelized-source and + point-source configuration in the suite — core PyAutoLens usage, and exactly the + noise the reporter asked about. `LENSABLE_CLS` is the fix; this pins it. + """ + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + + al.Tracer(galaxies=[_mass_galaxy(0.5), source_galaxy_fn()]) + + assert [ + w for w in caught if issubclass(w.category, MultiPlaneRedshiftWarning) + ] == [], label + + +def test__phase4__an_empty_placeholder_galaxy_suppresses_the_warning(): + """ + A galaxy with nothing in it is a scaffold — a model being composed, or a source + not yet filled in. Judging the geometry of a half-built system and warning about + it is unhelpful, so the check stands down. + """ + lens = al.Galaxy( + redshift=0.5, + light=al.lp.SersicSph(intensity=2.0), + mass=al.mp.IsothermalSph(einstein_radius=1.0), + ) + empty_source = al.Galaxy(redshift=1.0) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + + al.Tracer(galaxies=[lens, empty_source]) + + assert [ + w for w in caught if issubclass(w.category, MultiPlaneRedshiftWarning) + ] == [] + + +def test__phase4__a_non_concrete_redshift_skips_the_check_entirely(): + """ + Redshifts can be free model parameters (a traced subhalo redshift under jax.jit), + so the check must skip rather than coerce a traced boolean. + """ + + class _TracerLikeRedshift: + def __bool__(self): + raise AssertionError("the warning compared a non-concrete redshift") + + def __lt__(self, other): + return self + + def __gt__(self, other): + return self + + galaxy = al.Galaxy(redshift=0.5, mass=al.mp.IsothermalSph(einstein_radius=1.0)) + galaxy.redshift = _TracerLikeRedshift() + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + + al.Tracer(galaxies=[galaxy, _light_galaxy(0.5)]) + + assert [ + w for w in caught if issubclass(w.category, MultiPlaneRedshiftWarning) + ] == []