Overview
ExternalShear, MassSheet and ExternalPotential describe the tidal field of everything outside the modelled system, yet the only redshift-bearing container PyAutoGalaxy has is Galaxy, so they ride on one galaxy and model.info misrepresents them as a property of that galaxy (the shear_galaxy workaround in multi_galaxy/). This task adds ag.MassField(redshift, **mass_profiles): a standalone, MassProfile-only container (not a Galaxy subclass) with a zero-light interface so a plane (Galaxies) can hold it beside galaxies. It is phase 1 of the mass-field epic (PyAutoMind/draft/feature/autogalaxy/mass_field_epic.md); the PyAutoLens Tracer(fields=) integration is phase 2. Hard invariant: Galaxy's public behaviour, dict() output and the prior configs are unchanged — proven by an identifier pin.
Plan
- Extract the mass-sum implementation (
cls_list_from, has, deflections_yx_2d_from, convergence_2d_from, potential_2d_from) from Galaxy into a behaviour-preserving mixin that both Galaxy and MassField use; galaxy.py changes only by that extraction.
- Add
MassField(af.ModelObject, <mixin>): validates the redshift like Galaxy, rejects lists with the same message, and rejects any non-MassProfile component with a GalaxyException naming the offending key.
- Give it the zero-light interface
Galaxies / OperateImageGalaxies need (has False for non-mass classes, empty image list, zero image, no OperateImageList inheritance), plus __repr__, __eq__/__hash__ and a dict()/from_dict round trip.
- Export
ag.MassField; confirm the JAX pytree walker registers it when it sits in a Galaxies list, and measure with the three-way probe.
- Tests in
test_autogalaxy/galaxy/test_mass_field.py, including the identifier pin captured on main as a regression against a hard-coded value.
- Docs:
docs/api/galaxy.rst gains MassField; a short "External fields" paragraph in the galaxy overview.
Detailed implementation plan
Work Classification
Library (PyAutoGalaxy only). Phase 2 (PyAutoLens Tracer(fields=)) is a separate prompt, PyAutoMind/draft/feature/autolens/mass_field_integration.md, blocked on this one merging.
Affected Repositories
Branch Survey
| Repository |
Current Branch |
Dirty? |
PyAutoGalaxy (main @ 67f6ca6, PR #619 merged) |
main |
clean |
Remote branches: main, dev_Q, claude/slowest-ci-tests-s81dza, archive/condemned/pyautogalaxy-docs-cite-prodigy — none touch galaxy/. No active.md task claims PyAutoGalaxy (worktree_check_conflict exit 0).
Suggested branch: feature/mass-field-class
Worktree root (local-dev): ~/Code/PyAutoLabs-wt/mass-field-class/ (created by /start_library; a web session works on its clone instead)
Implementation Steps
-
Capture the identifier pin on main first (before any edit). Run, on main:
import autofit as af, autogalaxy as ag
model = af.Collection(galaxies=af.Collection(lens=af.Model(ag.Galaxy, redshift=0.5, mass=af.Model(ag.mp.Isothermal), shear=af.Model(ag.mp.ExternalShear))))
print(model.identifier)
Record the string; it becomes the hard-coded expected value in the regression test (step 8) with a comment naming the model. The same value must hold on the branch at every later step.
-
Mixin extraction — autogalaxy/galaxy/mass_aggregate.py (new), class MassProfileAggregate. Move verbatim from autogalaxy/galaxy/galaxy.py:
cls_list_from(cls, cls_filtered=None) (aa.util.misc.cls_list_from over self.__dict__.values()),
deflections_yx_2d_from (routed through deflections_memo.deflections_yx_2d_from(p, grid, xp); zeros (n, 2) when no mass profile),
convergence_2d_from and potential_2d_from (both @aa.decorators.to_array, zeros (n,) when no mass profile),
has(cls): OperateImageList.has raises NotImplementedError, so find where Galaxy actually resolves has (check af.ModelObject / the MRO at implementation time) and give the mixin an explicit has that returns any(isinstance(v, cls) for v in self.__dict__.values()) only if Galaxy does not already inherit a concrete one — otherwise leave Galaxy's resolution untouched and give MassField its own.
Galaxy becomes class Galaxy(af.ModelObject, OperateImageList, MassProfileAggregate) (or the mixin ahead of OperateImageList if has must come from it — decide by the MRO check, and keep Galaxy's existing resolution order for every other name). The module docstring of galaxy.py is updated to point at the mixin. Nothing else in galaxy.py changes: constructor, dict(), profile_dict, __eq__, __hash__, __repr__, traced_grid_2d_from, mass_angular_within_circle_from all stay in place (the latter two are not moved — they are Galaxy-only today and the prompt does not ask for them on a field).
-
autogalaxy/galaxy/mass_field.py (new) — class MassField(af.ModelObject, MassProfileAggregate):
__init__(self, redshift, **kwargs): super().__init__(); validate.validate_redshift(redshift=redshift); the same list-rejection exc.GalaxyException message as Galaxy; then for every (name, val): if not isinstance(val, MassProfile) raise exc.GalaxyException(f"MassField received '{name}' = {type(val).__name__}, which is not a MassProfile. A MassField holds only the mass components describing the tidal field of everything outside the modelled system (ExternalShear, MassSheet, ExternalPotential, or any other MassProfile); put light profiles, pixelizations and regularizations on a Galaxy."). MassProfile is autogalaxy.profiles.mass.abstract.abstract.MassProfile. Docstring names the three intended profiles and says any MassProfile is accepted.
- Zero-light interface (what
Galaxies, OperateImageList list-methods and OperateImageGalaxies call on a member — list each in the PR body): has(cls) → False unless some component is an instance of cls (so True for MassProfile and its subclasses, False for LightProfile, LightProfileLinear, LightProfileOperated, aa.Pixelization, Basis); image_2d_list_from(grid, xp=np, operated_only=None) → []; image_2d_from(grid, xp=np, operated_only=None) → xp.zeros((grid.shape[0],)) wrapped with @aa.decorators.to_array so Galaxies.image_2d_from sums an Array2D like it would for a light-less Galaxy; image_2d_unbinned_from(grid, xp=np, operated_only=None) → xp.zeros((grid.over_sampled.shape[0],)); image_2d_list_unbinned_from → []. Do not inherit OperateImageList — blurred_image_2d_from etc. flow through Galaxies, which calls image_2d_from on members; verify with the Galaxies([galaxy, field]) tests below rather than adding methods speculatively. extract_attribute(cls, attr_name) — copy Galaxy's (it is used by plotters over Galaxies) or move it into the mixin if it is pure cls_list_from logic; decide at implementation, keep Galaxy behaviour identical.
__repr__ → MassField(redshift=0.5, shear, mass_sheet) (component names in insertion order); __eq__ via self.dict() == other.dict() and __hash__ → int(self.id), mirroring Galaxy.
profile_dict (components that are GeometryProfile) and dict() exactly as Galaxy does them (instance_as_dict + to_dict per profile) so autonerves.dictable.from_dict rebuilds a MassField — test it.
-
Export — autogalaxy/__init__.py: from .galaxy.mass_field import MassField beside Galaxy / Galaxies.
-
JAX pytree — autogalaxy/jax/registration.py's _register_object_classes already registers any non-builtin class reachable from a Galaxies member via register_instance_pytree(cls) (the prompt's no_flatten=("redshift",) claim is not what the code does today — Galaxy is registered by the walker with no no_flatten). So MassField needs no code change there; add a docstring line naming it and measure with the three-way probe from PyAutoMind/complete/2026/07/public-register-galaxies-classes.md using ag.Galaxies([galaxy, ag.MassField(...)]) inside a user @jax.jit (deflections and convergence). If the probe fails because redshift is a Python float that must ride as aux, register MassField explicitly with no_flatten=("redshift",) in the walker and note the asymmetry with Galaxy in the PR.
-
chaining_util.py — it pattern-matches on Galaxy in several places (lines ~96–129); read them and confirm a MassField in a model is simply ignored (it should not be in galaxies collections in phase 1); no change expected, say so in the PR.
-
Docs — docs/api/galaxy.rst: add MassField to the autosummary; a short "External fields" paragraph in the galaxy overview page (docs/overview/overview_2_new_user_guide.md or wherever Galaxy is introduced) explaining when to use MassField and that the galaxy-attached form remains fully supported. No PyAutoMemory citations in public docs.
-
Tests — test_autogalaxy/galaxy/test_mass_field.py:
- construction with
shear, mass_sheet, potential individually and together; attribute access by name;
Sersic / Pixelization / Regularization / a list → GalaxyException, message names the key;
isinstance(field, ag.Galaxy) is False; has(LightProfile) False, has(MassProfile) True, has(ExternalShear) True;
deflections_yx_2d_from, convergence_2d_from, potential_2d_from on a grid_2d_7x7 are np.array_equal to the same profiles on an ag.Galaxy; zero when empty;
image_2d_from zeros of the grid shape, image_2d_list_from == [];
ag.Galaxies([galaxy, field]): deflections_yx_2d_from equals galaxy + field sums, image_2d_from equals the galaxy alone, has(LightProfile) unchanged, cls_list_from(MassProfile) includes the field's profiles, galaxies_with_cls_list_from(MassProfile) includes the field, perform_inversion False;
to_dict / from_dict round trip returns a MassField equal to the original;
__repr__ string;
- identifier pin:
model.identifier == "<value from step 1>" with a comment saying which model produced it and that it was captured on main;
git diff --name-only main contains no autogalaxy/config/priors/ path (a test in the PR description checklist, not pytest).
Run pytest test_autogalaxy -q -n auto — green.
Key Files
autogalaxy/galaxy/galaxy.py — Galaxy; the mass-sum methods move out into the mixin (only edit).
autogalaxy/galaxy/mass_aggregate.py — new mixin MassProfileAggregate.
autogalaxy/galaxy/mass_field.py — new MassField.
autogalaxy/galaxy/galaxies.py — read-only: defines what a plane calls on a member (image_2d_from, image_2d_unbinned_from, has, cls_list_from, deflections_yx_2d_from, convergence_2d_from, potential_2d_from, extract_attribute).
autogalaxy/operate/image.py — read-only: OperateImageList / OperateImageGalaxies member contract.
autogalaxy/jax/registration.py — walker that registers member classes; docstring note.
autogalaxy/profiles/mass/sheets/{external_shear,mass_sheet,external_potential}.py — the three intended components.
autogalaxy/__init__.py — export.
docs/api/galaxy.rst, docs/overview/ — API + overview docs.
test_autogalaxy/galaxy/test_mass_field.py — new tests.
Hard constraints (from the prompt)
Galaxy's import path, constructor, dict() output and hash/eq unchanged; the identifier pin proves nothing PyAutoFit hashes moved.
- No deprecation warning for sheets on a
Galaxy.
- No change under
autogalaxy/config/priors/.
Out of scope
Tracer(fields=), the analysis fields slot, COOLEST, the LOS sampler and model_util.mass_field_from are phase 2 (PyAutoLens); workspaces are phases 3–5.
Original Prompt
Click to expand starting prompt
MassField: a standalone, MassProfile-only container for external shear, mass sheets and external potentials
Type: feature
Target: PyAutoGalaxy
Repos:
- PyAutoGalaxy
Themes:
- cluster
Difficulty: small
Autonomy: supervised
Priority: normal
Consequence: glance
Witness: ag.MassField(redshift=0.5, shear=ag.mp.ExternalShear(0.05, 0.05)).deflections_yx_2d_from(grid) is np.array_equal to the same profile on an ag.Galaxy; isinstance(field, ag.Galaxy) is False; ag.MassField(redshift=0.5, bulge=ag.lp.Sersic()) raises; identifier pin: af.Collection(galaxies=af.Collection(lens=af.Model(ag.Galaxy, redshift=0.5, mass=af.Model(ag.mp.Isothermal), shear=af.Model(ag.mp.ExternalShear)))).identifier equal on main and on the branch; pytest test_autogalaxy -q -n auto green; git diff touches no autogalaxy/config/priors/ file.
Review-minutes: 5
Unattended: ready
Epic: mass-field
Phase: 1
Filed: 2026-09-17
Add MassField to PyAutoGalaxy: the redshift-bearing container for the mass
components that describe the tidal field of everything outside the modelled
system — ExternalShear, MassSheet, ExternalPotential — so a model can say
"this is a property of the system" instead of pinning the field to one galaxy.
It is its own thing, not a Galaxy subclass. The design, prior art and the
backwards-compatibility invariant are in the epic ledger
draft/feature/autogalaxy/mass_field_epic.md; read it first.
What to build
- Share the mass sums, do not copy them.
@PyAutoGalaxy/autogalaxy/galaxy/galaxy.py
implements has, cls_list_from, deflections_yx_2d_from,
convergence_2d_from, potential_2d_from and the deflections_memo
routing. Extract them, behaviour-preservingly, into a mixin
(e.g. autogalaxy/galaxy/mass_aggregate.py: MassProfileAggregate) that
Galaxy keeps using and MassField also uses. Galaxy's import path,
constructor, dict()/to_dict output and __eq__/__hash__ are unchanged
— the identifier pin in the Witness is the proof, run it before and after.
@PyAutoGalaxy/autogalaxy/galaxy/mass_field.py — class MassField(af.ModelObject, MassProfileAggregate).
__init__(self, redshift, **kwargs): validate the redshift the way
Galaxy does (validate.validate_redshift), reject lists with the same
message, then require every component to be a MassProfile
(autogalaxy.profiles.mass.abstract.abstract.MassProfile); a
LightProfile, Pixelization, Regularization or anything else raises
exc.GalaxyException naming the offending key and what a MassField is
for. Any MassProfile is accepted (a user may want an NFW "environment"
halo as a field); the docstring names the three intended ones.
- Zero-light interface, so a tracer plane can hold it beside galaxies:
has(cls) is False for any non-mass class; image_2d_list_from returns
[]; image_2d_from returns zeros of the grid's shape; whatever else
Galaxies / OperateImageGalaxies call on a member (read
autogalaxy/galaxy/galaxies.py and autogalaxy/operate/image.py and list
them in the PR) gets the no-op that makes a field contribute nothing to
light and everything to mass. Do not inherit OperateImageList.
__repr__: MassField(redshift=..., <component names>); __eq__/__hash__
following Galaxy's pattern (it is used as a dict key nowhere today, but
keep it hashable).
dict()/to_dict round trip via autonerves.dictable rebuilds a
MassField (test it).
- Export
ag.MassField from @PyAutoGalaxy/autogalaxy/__init__.py beside
Galaxy / Galaxies.
- JAX pytree registration —
@PyAutoGalaxy/autogalaxy/jax/registration.py
registers Galaxy with no_flatten=("redshift",); register MassField the
same way in the same walker. Measure with the three-way probe from
complete/2026/07/public-register-galaxies-classes.md using a MassField
in the Galaxies list.
- Tests —
@PyAutoGalaxy/test_autogalaxy/galaxy/test_mass_field.py:
construction with each of the three sheets and all three; rejection of
light profiles / pixelizations with the named key in the message;
isinstance(field, Galaxy) False; field equality of deflections /
convergence / potential against the same profiles on a Galaxy;
image_2d_from zeros; Galaxies([galaxy, field]) aggregate sums (a plane
can hold it); to_dict/from_dict round trip type; __repr__; the
identifier pin as a regression test against a hard-coded value captured on
main and a comment saying which model produced it.
- Docs —
@PyAutoGalaxy/docs/api/galaxy.rst gains MassField; a short
"External fields" paragraph in the galaxy overview page explaining when to
use it and that the galaxy-attached form remains supported.
Hard constraints
Galaxy's public behaviour is unchanged (import path, constructor, dict
output, hash/eq); the mixin extraction is the only edit to galaxy.py and
the identifier pin proves it moved nothing that PyAutoFit hashes.
- No deprecation warning anywhere for sheets on a
Galaxy.
- No prior config changes (
autogalaxy/config/priors/).
Out of scope (later phases)
The tracer's fields= argument, the analysis slot, COOLEST, the LOS sampler
and the model_util helper are phase 2; workspaces are phases 3–5.
Overview
ExternalShear,MassSheetandExternalPotentialdescribe the tidal field of everything outside the modelled system, yet the only redshift-bearing container PyAutoGalaxy has isGalaxy, so they ride on one galaxy andmodel.infomisrepresents them as a property of that galaxy (theshear_galaxyworkaround inmulti_galaxy/). This task addsag.MassField(redshift, **mass_profiles): a standalone,MassProfile-only container (not aGalaxysubclass) with a zero-light interface so a plane (Galaxies) can hold it beside galaxies. It is phase 1 of themass-fieldepic (PyAutoMind/draft/feature/autogalaxy/mass_field_epic.md); the PyAutoLensTracer(fields=)integration is phase 2. Hard invariant:Galaxy's public behaviour,dict()output and the prior configs are unchanged — proven by an identifier pin.Plan
cls_list_from,has,deflections_yx_2d_from,convergence_2d_from,potential_2d_from) fromGalaxyinto a behaviour-preserving mixin that bothGalaxyandMassFielduse;galaxy.pychanges only by that extraction.MassField(af.ModelObject, <mixin>): validates the redshift likeGalaxy, rejects lists with the same message, and rejects any non-MassProfilecomponent with aGalaxyExceptionnaming the offending key.Galaxies/OperateImageGalaxiesneed (hasFalse for non-mass classes, empty image list, zero image, noOperateImageListinheritance), plus__repr__,__eq__/__hash__and adict()/from_dictround trip.ag.MassField; confirm the JAX pytree walker registers it when it sits in aGalaxieslist, and measure with the three-way probe.test_autogalaxy/galaxy/test_mass_field.py, including the identifier pin captured onmainas a regression against a hard-coded value.docs/api/galaxy.rstgainsMassField; a short "External fields" paragraph in the galaxy overview.Detailed implementation plan
Work Classification
Library (PyAutoGalaxy only). Phase 2 (PyAutoLens
Tracer(fields=)) is a separate prompt,PyAutoMind/draft/feature/autolens/mass_field_integration.md, blocked on this one merging.Affected Repositories
Branch Survey
main@ 67f6ca6, PR #619 merged)Remote branches:
main,dev_Q,claude/slowest-ci-tests-s81dza,archive/condemned/pyautogalaxy-docs-cite-prodigy— none touchgalaxy/. Noactive.mdtask claims PyAutoGalaxy (worktree_check_conflictexit 0).Suggested branch:
feature/mass-field-classWorktree root (local-dev):
~/Code/PyAutoLabs-wt/mass-field-class/(created by/start_library; a web session works on its clone instead)Implementation Steps
Capture the identifier pin on
mainfirst (before any edit). Run, onmain:Record the string; it becomes the hard-coded expected value in the regression test (step 8) with a comment naming the model. The same value must hold on the branch at every later step.
Mixin extraction —
autogalaxy/galaxy/mass_aggregate.py(new),class MassProfileAggregate. Move verbatim fromautogalaxy/galaxy/galaxy.py:cls_list_from(cls, cls_filtered=None)(aa.util.misc.cls_list_fromoverself.__dict__.values()),deflections_yx_2d_from(routed throughdeflections_memo.deflections_yx_2d_from(p, grid, xp); zeros(n, 2)when no mass profile),convergence_2d_fromandpotential_2d_from(both@aa.decorators.to_array, zeros(n,)when no mass profile),has(cls):OperateImageList.hasraisesNotImplementedError, so find whereGalaxyactually resolveshas(checkaf.ModelObject/ the MRO at implementation time) and give the mixin an explicithasthat returnsany(isinstance(v, cls) for v in self.__dict__.values())only ifGalaxydoes not already inherit a concrete one — otherwise leaveGalaxy's resolution untouched and giveMassFieldits own.Galaxybecomesclass Galaxy(af.ModelObject, OperateImageList, MassProfileAggregate)(or the mixin ahead ofOperateImageListifhasmust come from it — decide by the MRO check, and keepGalaxy's existing resolution order for every other name). The module docstring ofgalaxy.pyis updated to point at the mixin. Nothing else ingalaxy.pychanges: constructor,dict(),profile_dict,__eq__,__hash__,__repr__,traced_grid_2d_from,mass_angular_within_circle_fromall stay in place (the latter two are not moved — they areGalaxy-only today and the prompt does not ask for them on a field).autogalaxy/galaxy/mass_field.py(new) —class MassField(af.ModelObject, MassProfileAggregate):__init__(self, redshift, **kwargs):super().__init__();validate.validate_redshift(redshift=redshift); the same list-rejectionexc.GalaxyExceptionmessage asGalaxy; then for every(name, val): if notisinstance(val, MassProfile)raiseexc.GalaxyException(f"MassField received '{name}' = {type(val).__name__}, which is not a MassProfile. A MassField holds only the mass components describing the tidal field of everything outside the modelled system (ExternalShear, MassSheet, ExternalPotential, or any other MassProfile); put light profiles, pixelizations and regularizations on a Galaxy.").MassProfileisautogalaxy.profiles.mass.abstract.abstract.MassProfile. Docstring names the three intended profiles and says anyMassProfileis accepted.Galaxies,OperateImageListlist-methods andOperateImageGalaxiescall on a member — list each in the PR body):has(cls)→ False unless some component is an instance ofcls(so True forMassProfileand its subclasses, False forLightProfile,LightProfileLinear,LightProfileOperated,aa.Pixelization,Basis);image_2d_list_from(grid, xp=np, operated_only=None)→[];image_2d_from(grid, xp=np, operated_only=None)→xp.zeros((grid.shape[0],))wrapped with@aa.decorators.to_arraysoGalaxies.image_2d_fromsums anArray2Dlike it would for a light-lessGalaxy;image_2d_unbinned_from(grid, xp=np, operated_only=None)→xp.zeros((grid.over_sampled.shape[0],));image_2d_list_unbinned_from→[]. Do not inheritOperateImageList—blurred_image_2d_frometc. flow throughGalaxies, which callsimage_2d_fromon members; verify with theGalaxies([galaxy, field])tests below rather than adding methods speculatively.extract_attribute(cls, attr_name)— copyGalaxy's (it is used by plotters overGalaxies) or move it into the mixin if it is purecls_list_fromlogic; decide at implementation, keepGalaxybehaviour identical.__repr__→MassField(redshift=0.5, shear, mass_sheet)(component names in insertion order);__eq__viaself.dict() == other.dict()and__hash__→int(self.id), mirroringGalaxy.profile_dict(components that areGeometryProfile) anddict()exactly asGalaxydoes them (instance_as_dict+to_dictper profile) soautonerves.dictable.from_dictrebuilds aMassField— test it.Export —
autogalaxy/__init__.py:from .galaxy.mass_field import MassFieldbesideGalaxy/Galaxies.JAX pytree —
autogalaxy/jax/registration.py's_register_object_classesalready registers any non-builtin class reachable from aGalaxiesmember viaregister_instance_pytree(cls)(the prompt'sno_flatten=("redshift",)claim is not what the code does today —Galaxyis registered by the walker with nono_flatten). SoMassFieldneeds no code change there; add a docstring line naming it and measure with the three-way probe fromPyAutoMind/complete/2026/07/public-register-galaxies-classes.mdusingag.Galaxies([galaxy, ag.MassField(...)])inside a user@jax.jit(deflections and convergence). If the probe fails becauseredshiftis a Python float that must ride as aux, registerMassFieldexplicitly withno_flatten=("redshift",)in the walker and note the asymmetry withGalaxyin the PR.chaining_util.py— it pattern-matches onGalaxyin several places (lines ~96–129); read them and confirm aMassFieldin a model is simply ignored (it should not be ingalaxiescollections in phase 1); no change expected, say so in the PR.Docs —
docs/api/galaxy.rst: addMassFieldto the autosummary; a short "External fields" paragraph in the galaxy overview page (docs/overview/overview_2_new_user_guide.mdor whereverGalaxyis introduced) explaining when to useMassFieldand that the galaxy-attached form remains fully supported. No PyAutoMemory citations in public docs.Tests —
test_autogalaxy/galaxy/test_mass_field.py:shear,mass_sheet,potentialindividually and together; attribute access by name;Sersic/Pixelization/Regularization/ a list →GalaxyException, message names the key;isinstance(field, ag.Galaxy)is False;has(LightProfile)False,has(MassProfile)True,has(ExternalShear)True;deflections_yx_2d_from,convergence_2d_from,potential_2d_fromon agrid_2d_7x7arenp.array_equalto the same profiles on anag.Galaxy; zero when empty;image_2d_fromzeros of the grid shape,image_2d_list_from == [];ag.Galaxies([galaxy, field]):deflections_yx_2d_fromequals galaxy + field sums,image_2d_fromequals the galaxy alone,has(LightProfile)unchanged,cls_list_from(MassProfile)includes the field's profiles,galaxies_with_cls_list_from(MassProfile)includes the field,perform_inversionFalse;to_dict/from_dictround trip returns aMassFieldequal to the original;__repr__string;model.identifier == "<value from step 1>"with a comment saying which model produced it and that it was captured onmain;git diff --name-only maincontains noautogalaxy/config/priors/path (a test in the PR description checklist, not pytest).Run
pytest test_autogalaxy -q -n auto— green.Key Files
autogalaxy/galaxy/galaxy.py—Galaxy; the mass-sum methods move out into the mixin (only edit).autogalaxy/galaxy/mass_aggregate.py— new mixinMassProfileAggregate.autogalaxy/galaxy/mass_field.py— newMassField.autogalaxy/galaxy/galaxies.py— read-only: defines what a plane calls on a member (image_2d_from,image_2d_unbinned_from,has,cls_list_from,deflections_yx_2d_from,convergence_2d_from,potential_2d_from,extract_attribute).autogalaxy/operate/image.py— read-only:OperateImageList/OperateImageGalaxiesmember contract.autogalaxy/jax/registration.py— walker that registers member classes; docstring note.autogalaxy/profiles/mass/sheets/{external_shear,mass_sheet,external_potential}.py— the three intended components.autogalaxy/__init__.py— export.docs/api/galaxy.rst,docs/overview/— API + overview docs.test_autogalaxy/galaxy/test_mass_field.py— new tests.Hard constraints (from the prompt)
Galaxy's import path, constructor,dict()output and hash/eq unchanged; the identifier pin proves nothing PyAutoFit hashes moved.Galaxy.autogalaxy/config/priors/.Out of scope
Tracer(fields=), the analysisfieldsslot, COOLEST, the LOS sampler andmodel_util.mass_field_fromare phase 2 (PyAutoLens); workspaces are phases 3–5.Original Prompt
Click to expand starting prompt
MassField: a standalone, MassProfile-only container for external shear, mass sheets and external potentials
Type: feature
Target: PyAutoGalaxy
Repos:
Themes:
Difficulty: small
Autonomy: supervised
Priority: normal
Consequence: glance
Witness:
ag.MassField(redshift=0.5, shear=ag.mp.ExternalShear(0.05, 0.05)).deflections_yx_2d_from(grid)isnp.array_equalto the same profile on anag.Galaxy;isinstance(field, ag.Galaxy)is False;ag.MassField(redshift=0.5, bulge=ag.lp.Sersic())raises; identifier pin:af.Collection(galaxies=af.Collection(lens=af.Model(ag.Galaxy, redshift=0.5, mass=af.Model(ag.mp.Isothermal), shear=af.Model(ag.mp.ExternalShear)))).identifierequal onmainand on the branch;pytest test_autogalaxy -q -n autogreen;git difftouches noautogalaxy/config/priors/file.Review-minutes: 5
Unattended: ready
Epic: mass-field
Phase: 1
Filed: 2026-09-17
Add
MassFieldto PyAutoGalaxy: the redshift-bearing container for the masscomponents that describe the tidal field of everything outside the modelled
system —
ExternalShear,MassSheet,ExternalPotential— so a model can say"this is a property of the system" instead of pinning the field to one galaxy.
It is its own thing, not a
Galaxysubclass. The design, prior art and thebackwards-compatibility invariant are in the epic ledger
draft/feature/autogalaxy/mass_field_epic.md; read it first.What to build
@PyAutoGalaxy/autogalaxy/galaxy/galaxy.pyimplements
has,cls_list_from,deflections_yx_2d_from,convergence_2d_from,potential_2d_fromand thedeflections_memorouting. Extract them, behaviour-preservingly, into a mixin
(e.g.
autogalaxy/galaxy/mass_aggregate.py: MassProfileAggregate) thatGalaxykeeps using andMassFieldalso uses.Galaxy's import path,constructor,
dict()/to_dictoutput and__eq__/__hash__are unchanged— the identifier pin in the Witness is the proof, run it before and after.
@PyAutoGalaxy/autogalaxy/galaxy/mass_field.py—class MassField(af.ModelObject, MassProfileAggregate).__init__(self, redshift, **kwargs): validate the redshift the wayGalaxydoes (validate.validate_redshift), reject lists with the samemessage, then require every component to be a
MassProfile(
autogalaxy.profiles.mass.abstract.abstract.MassProfile); aLightProfile,Pixelization,Regularizationor anything else raisesexc.GalaxyExceptionnaming the offending key and what aMassFieldisfor. Any
MassProfileis accepted (a user may want an NFW "environment"halo as a field); the docstring names the three intended ones.
has(cls)is False for any non-mass class;image_2d_list_fromreturns[];image_2d_fromreturns zeros of the grid's shape; whatever elseGalaxies/OperateImageGalaxiescall on a member (readautogalaxy/galaxy/galaxies.pyandautogalaxy/operate/image.pyand listthem in the PR) gets the no-op that makes a field contribute nothing to
light and everything to mass. Do not inherit
OperateImageList.__repr__:MassField(redshift=..., <component names>);__eq__/__hash__following
Galaxy's pattern (it is used as a dict key nowhere today, butkeep it hashable).
dict()/to_dictround trip viaautonerves.dictablerebuilds aMassField(test it).ag.MassFieldfrom@PyAutoGalaxy/autogalaxy/__init__.pybesideGalaxy/Galaxies.@PyAutoGalaxy/autogalaxy/jax/registration.pyregisters
Galaxywithno_flatten=("redshift",); registerMassFieldthesame way in the same walker. Measure with the three-way probe from
complete/2026/07/public-register-galaxies-classes.mdusing aMassFieldin the
Galaxieslist.@PyAutoGalaxy/test_autogalaxy/galaxy/test_mass_field.py:construction with each of the three sheets and all three; rejection of
light profiles / pixelizations with the named key in the message;
isinstance(field, Galaxy)False; field equality of deflections /convergence / potential against the same profiles on a
Galaxy;image_2d_fromzeros;Galaxies([galaxy, field])aggregate sums (a planecan hold it);
to_dict/from_dictround trip type;__repr__; theidentifier pin as a regression test against a hard-coded value captured on
mainand a comment saying which model produced it.@PyAutoGalaxy/docs/api/galaxy.rstgainsMassField; a short"External fields" paragraph in the galaxy overview page explaining when to
use it and that the galaxy-attached form remains supported.
Hard constraints
Galaxy's public behaviour is unchanged (import path, constructor, dictoutput, hash/eq); the mixin extraction is the only edit to
galaxy.pyandthe identifier pin proves it moved nothing that PyAutoFit hashes.
Galaxy.autogalaxy/config/priors/).Out of scope (later phases)
The tracer's
fields=argument, the analysis slot, COOLEST, the LOS samplerand the
model_utilhelper are phase 2; workspaces are phases 3–5.