Skip to content

feat: MassField — standalone MassProfile-only container for external fields #620

Description

@Jammy2211

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

  • PyAutoGalaxy (primary)

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

  1. 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.

  2. 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).
  3. 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 OperateImageListblurred_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.
  4. Exportautogalaxy/__init__.py: from .galaxy.mass_field import MassField beside Galaxy / Galaxies.

  5. JAX pytreeautogalaxy/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.

  6. 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.

  7. Docsdocs/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.

  8. Teststest_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.pyGalaxy; 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

  1. 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.
  2. @PyAutoGalaxy/autogalaxy/galaxy/mass_field.pyclass 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).
  3. Export ag.MassField from @PyAutoGalaxy/autogalaxy/__init__.py beside
    Galaxy / Galaxies.
  4. 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.
  5. 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.
  6. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions