Skip to content

Commit 1d8ae08

Browse files
Jammy2211claude
authored andcommitted
feat: declare the ell_comps disk on EllProfile so a search can project onto it (PyAutoFit#1537)
`EllProfile.__model_constraint__` already MEASURES how far outside the ellipticity clamp a profile sits, but a measure cannot say how to fix one. This declares the structure PyAutoFit's new `ClipperPriorBoxJoint` projects onto: __model_ball_constraints__ = ((("ell_comps",), convert.ELL_COMPS_MAGNITUDE_CLAMP),) The radius is the CLAMP (0.999), deliberately not `1 - margin`. Between 0.999 and 1.0 the conversion to an axis ratio saturates, so the likelihood is flat radially and a lane projected into that annulus has nothing to climb back out on -- it would be moved from a region the model rejects into one the optimizer cannot leave. Projecting onto the clamp puts it exactly where the radial gradient is alive again. Declared once at `EllProfile`, so it reaches every elliptical light and mass profile; the spherical subclasses inherit it but pin `ell_comps` to an instance, so PyAutoFit resolves no pair and projects nothing. `validate_ell_comps` is deliberately unchanged. Making the guard fire on the traced path would turn a 20%-of-lanes condition into a 20%-of-lanes crash in the middle of a multi-hour fit -- the fix belongs in the search's constraint handling, not in a validator that converts a survivable state into an exception. Also widens `AnalysisDataset.save_results`' catch to `(AttributeError, af.exc.SamplesException, af.exc.FitException)`, mirroring PyAutoLens#713: building the galaxies materializes the maximum log likelihood sample as a model instance, which the model may reject, and writing an optional output file must never kill a completed fit before `paths.completed()` (PyAutoFit#1535). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011joies4k5TdRqezPUK8YET
1 parent d55f3ab commit 1d8ae08

4 files changed

Lines changed: 196 additions & 2 deletions

File tree

autogalaxy/analysis/analysis/dataset.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,16 @@ def save_results(self, paths: af.DirectoryPaths, result: ResultDataset):
175175
obj=result.max_log_likelihood_galaxies,
176176
file_path=paths._files_path / "galaxies.json",
177177
)
178-
except AttributeError:
179-
pass
178+
except (AttributeError, af.exc.SamplesException, af.exc.FitException) as e:
179+
# Building the galaxies requires materializing the maximum log likelihood
180+
# sample as a model instance, which the model may reject (e.g. `ell_comps`
181+
# outside the unit disk). Writing an extra output file must never kill a
182+
# completed fit before `paths.completed()` is called (PyAutoFit #1535), so
183+
# the failure is logged and the fit finishes without `galaxies.json`.
184+
logger.warning(
185+
f"The maximum log likelihood galaxies could not be written to "
186+
f"galaxies.json, the model-fit is otherwise unaffected:\n{e}"
187+
)
180188

181189
def adapt_images_via_instance_from(
182190
self,

autogalaxy/profiles/geometry_profiles.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,21 @@ def __init__(
237237
validate.validate_ell_comps(ell_comps=ell_comps)
238238
self.ell_comps = ell_comps
239239

240+
# The projectable form of the constraint below, read by PyAutoFit's
241+
# `ClipperPriorBoxJoint` (see `autofit.mapper.prior_model.constraint`).
242+
# `__model_constraint__` MEASURES how far outside the disk a profile sits;
243+
# this states the disk itself, which is what a search needs to put a lane
244+
# back inside it.
245+
#
246+
# The radius is the CLAMP (0.999), not the guard's 1.0 and not `1 - margin`
247+
# for any small margin. Between 0.999 and 1.0 the conversion to an axis
248+
# ratio saturates, so the likelihood is flat in the radial direction and a
249+
# gradient lane projected into that annulus has nothing to climb back out
250+
# on -- it would be moved from a region the model rejects into one the
251+
# optimizer cannot leave. Projecting onto the clamp puts the lane exactly at
252+
# the edge of the region where the radial gradient is alive again.
253+
__model_ball_constraints__ = ((("ell_comps",), convert.ELL_COMPS_MAGNITUDE_CLAMP),)
254+
240255
def __model_constraint__(self, xp=np):
241256
"""
242257
How far beyond the ellipticity clamp this profile's `ell_comps` sit.

test_autogalaxy/analysis/analysis/test_analysis_dataset.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,43 @@ def test__instance_with_associated_adapt_images_from__galaxy_name_image_plane_me
8080
assert adapt_images.galaxy_image_plane_mesh_grid_dict[
8181
galaxies.source
8282
].native == pytest.approx(4.0 * np.ones((2, 2)), 1.0e-4)
83+
84+
85+
class _RaisingGalaxiesResult:
86+
"""
87+
Result double whose galaxies cannot be built, because materializing the maximum log
88+
likelihood sample as a model instance fails.
89+
"""
90+
91+
def __init__(self, error):
92+
self._error = error
93+
94+
@property
95+
def max_log_likelihood_galaxies(self):
96+
raise self._error
97+
98+
99+
@pytest.mark.parametrize(
100+
"error",
101+
[
102+
AttributeError("no galaxies on this result"),
103+
af.exc.SamplesException("stored parameters cannot be reconstructed"),
104+
af.exc.FitException("ell_comps must satisfy e0**2+e1**2 < 1"),
105+
],
106+
)
107+
def test__save_results__galaxies_failure_never_kills_the_fit(
108+
analysis_imaging_7x7, error
109+
):
110+
"""
111+
`save_results` runs after the search has finished but before `paths.completed()`, so a
112+
failure writing the (optional) `galaxies.json` must be logged and swallowed rather than
113+
losing the run its `.completed` marker (PyAutoFit #1535).
114+
"""
115+
paths = af.DirectoryPaths()
116+
117+
analysis_imaging_7x7.save_results(
118+
paths=paths,
119+
result=_RaisingGalaxiesResult(error),
120+
)
121+
122+
assert not (paths._files_path / "galaxies.json").exists()

test_autogalaxy/profiles/test_model_constraint.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
import inspect
2+
13
import numpy as np
24
import pytest
35

6+
import autofit as af
47
import autogalaxy as ag
58
from autogalaxy import convert
9+
from autogalaxy.profiles import validate
610
from autogalaxy.profiles.geometry_profiles import EllProfile
711

812

@@ -83,3 +87,130 @@ def test_conversion_saturates_at_the_constant(self):
8387

8488
def test_value_is_unchanged_by_the_refactor(self):
8589
assert convert.ELL_COMPS_MAGNITUDE_CLAMP == 0.999
90+
91+
92+
def _ell_profile_subclasses():
93+
"""Every `EllProfile` subclass reachable from the public `ag.lp` / `ag.mp`
94+
namespaces, so a profile added later is covered without editing this list."""
95+
classes = set()
96+
for namespace in (ag.lp, ag.mp, ag.lmp):
97+
for _, obj in inspect.getmembers(namespace, inspect.isclass):
98+
if issubclass(obj, EllProfile):
99+
classes.add(obj)
100+
return sorted(classes, key=lambda cls: cls.__name__)
101+
102+
103+
class TestBallDeclaration:
104+
def test_there_are_profiles_to_check(self):
105+
"""Guards the sweep below: an empty namespace scan would pass vacuously."""
106+
assert len(_ell_profile_subclasses()) > 20
107+
108+
def test_every_elliptical_profile_declares_the_ball(self):
109+
"""`ell_comps` has one assignment site, at `EllProfile`, so the ball
110+
declaration reaches every elliptical light and mass profile — including
111+
the spherical ones, whose components are pinned and therefore never
112+
projected."""
113+
for cls in _ell_profile_subclasses():
114+
assert cls.__model_ball_constraints__ == (
115+
(("ell_comps",), convert.ELL_COMPS_MAGNITUDE_CLAMP),
116+
), cls.__name__
117+
118+
def test_the_radius_is_the_clamp_not_the_validity_boundary(self):
119+
"""Between 0.999 and 1.0 the conversion to an axis ratio saturates, so the
120+
likelihood is flat radially. Projecting onto `1 - margin` would move a
121+
lane from a region the model rejects into one the optimizer cannot leave;
122+
projecting onto the clamp puts it where the gradient is alive again."""
123+
((_, radius),) = EllProfile.__model_ball_constraints__
124+
125+
assert radius == convert.ELL_COMPS_MAGNITUDE_CLAMP
126+
assert radius == 0.999
127+
assert radius < 1.0
128+
129+
def test_pyautofit_resolves_the_declaration_to_a_parameter_pair(self):
130+
"""The declaration is only useful if PyAutoFit can turn it into indices
131+
into the vector a search steps."""
132+
model = af.Collection(
133+
galaxies=af.Collection(
134+
lens=af.Model(ag.Galaxy, redshift=0.5, mass=ag.mp.Isothermal),
135+
)
136+
)
137+
138+
((index_0, index_1, radius),) = model.ball_constraint_index_pairs()
139+
140+
names = [tuple_.name for tuple_ in model.prior_tuples_ordered_by_id]
141+
assert names[index_0] == "ell_comps_0"
142+
assert names[index_1] == "ell_comps_1"
143+
assert radius == convert.ELL_COMPS_MAGNITUDE_CLAMP
144+
145+
def test_a_spherical_profile_contributes_no_pair(self):
146+
"""`IsothermalSph` inherits the declaration but pins `ell_comps` to
147+
`(0, 0)`, so there is no free pair to project."""
148+
model = af.Collection(
149+
galaxies=af.Collection(
150+
lens=af.Model(ag.Galaxy, redshift=0.5, mass=ag.mp.IsothermalSph),
151+
)
152+
)
153+
154+
assert model.ball_constraint_index_pairs() == ()
155+
156+
def test_the_joint_clipper_projects_a_real_lens_model(self):
157+
"""End to end, with a real profile and PyAutoFit's opt-in clipper: a lane
158+
at `|e| = 1.4` — inside both `ell_comps` prior boxes, outside the disk —
159+
comes back inside it."""
160+
model = af.Collection(
161+
galaxies=af.Collection(
162+
lens=af.Model(ag.Galaxy, redshift=0.5, mass=ag.mp.Isothermal),
163+
)
164+
)
165+
166+
((index_0, index_1, radius),) = model.ball_constraint_index_pairs()
167+
168+
vector = np.array(model.physical_values_from_prior_medians)
169+
vector[index_0] = 1.4 / np.sqrt(2.0)
170+
vector[index_1] = 1.4 / np.sqrt(2.0)
171+
172+
projected, mask = af.ClipperPriorBoxJoint(margin=0.0).project(
173+
vector=vector, model=model
174+
)
175+
176+
assert np.hypot(projected[index_0], projected[index_1]) == pytest.approx(radius)
177+
assert mask[index_0]
178+
assert mask[index_1]
179+
180+
181+
class TestGuardIsUntouched:
182+
"""The ball is a *search-side* projection. `validate_ell_comps`'s
183+
standalone-construction behaviour is deliberately unchanged: making it fire on
184+
the traced path would turn a 20%-of-lanes condition into a 20%-of-lanes crash
185+
in the middle of a multi-hour fit."""
186+
187+
def test_it_still_rejects_a_magnitude_of_one_on_construction(self):
188+
with pytest.raises(Exception):
189+
ag.mp.Isothermal(ell_comps=(1.2, 0.0))
190+
191+
def test_it_still_rejects_the_corner_the_box_permits(self):
192+
with pytest.raises(Exception):
193+
ag.mp.Isothermal(ell_comps=(0.8, 0.8))
194+
195+
def test_it_still_accepts_the_saturating_annulus(self):
196+
"""0.999 <= magnitude < 1.0 remains constructible. The constraint flags it
197+
and the clipper projects out of it; the guard does not raise on it, and
198+
that has not changed."""
199+
assert ag.mp.Isothermal(ell_comps=(0.9995, 0.0)) is not None
200+
201+
def test_it_still_returns_early_for_a_non_concrete_magnitude(self):
202+
"""The escape hatch that makes the guard a no-op under a trace, which is
203+
why the search needed a projection in the first place."""
204+
205+
class Tracer:
206+
def __mul__(self, other):
207+
return self
208+
209+
__rmul__ = __mul__
210+
__add__ = __mul__
211+
__radd__ = __mul__
212+
213+
def __float__(self):
214+
raise TypeError("tracer")
215+
216+
validate.validate_ell_comps(ell_comps=(Tracer(), Tracer()))

0 commit comments

Comments
 (0)