Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions autolens/lens/tracer.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,154 @@
"""
from abc import ABC
import numpy as np
import warnings
from scipy.interpolate import griddata
from typing import Dict, List, Optional, Type, Union

import autofit as af
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
Expand Down Expand Up @@ -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

Expand Down
207 changes: 191 additions & 16 deletions test_autolens/lens/test_tracer_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
] == []
Loading