From b7c9a9394af08fa57047b1323140a26c3b426ddd Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Thu, 9 Jul 2026 12:45:28 +0100 Subject: [PATCH] feat: CSV API extensions from the stress-test (light variants, guards, table properties, flat cosmology) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stress-tested the named-galaxy model CSVs + galaxy_table against realistic user requirements (PyAutoGalaxy#490); dPIEMassLenstool/.par rows, NFW redshift args, shear+PowerLaw sparse files, multipoles and PointFlux all passed as-is. Gaps fixed: - light.csv: linear/operated/linear_operated variants via qualified class names ('linear.Sersic'); plain names stay standard (shared class names made a namespace chain ambiguous). - Loud guards: duplicate (galaxy, attr_name) rows and typo'd parameter columns now raise (both previously silent — a typo silently left the profile at its default). - GalaxyTable.properties: extra catalogue columns (numeric -> float lists, strings -> string lists) load instead of being silently dropped; writer gains properties=. Member catalogues can carry shape + mag. - dPIEMassLenstool(Sph): H0/Om0 float args (Planck15-value defaults) so a Lenstool run's cosmology is CSV-able/prior-configurable; prior configs added. 12 new/updated unit tests; full suite green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PUuWXiS23FvmfQPLvMNjeM --- .../total/dual_pseudo_isothermal_mass.yaml | 40 ++++ autogalaxy/galaxy/galaxy_model_csv.py | 104 ++++++++-- autogalaxy/galaxy/galaxy_table.py | 50 ++++- .../mass/total/dual_pseudo_isothermal_mass.py | 21 +- .../galaxy/test_galaxy_model_csv.py | 193 ++++++++++++++++-- test_autogalaxy/galaxy/test_galaxy_table.py | 51 ++++- .../total/test_dual_pseudo_isothermal_mass.py | 17 +- 7 files changed, 419 insertions(+), 57 deletions(-) diff --git a/autogalaxy/config/priors/mass/total/dual_pseudo_isothermal_mass.yaml b/autogalaxy/config/priors/mass/total/dual_pseudo_isothermal_mass.yaml index 9c568f16..ed239ba9 100644 --- a/autogalaxy/config/priors/mass/total/dual_pseudo_isothermal_mass.yaml +++ b/autogalaxy/config/priors/mass/total/dual_pseudo_isothermal_mass.yaml @@ -215,6 +215,26 @@ dPIEMassLenstool: limits: lower: 0.0 upper: inf + H0: + type: Uniform + lower_limit: 50.0 + upper_limit: 100.0 + width_modifier: + type: Relative + value: 0.25 + limits: + lower: 0.0 + upper: inf + Om0: + type: Uniform + lower_limit: 0.1 + upper_limit: 0.5 + width_modifier: + type: Relative + value: 0.25 + limits: + lower: 0.0 + upper: 1.0 dPIEMassLenstoolSph: centre_0: type: Gaussian @@ -286,3 +306,23 @@ dPIEMassLenstoolSph: limits: lower: 0.0 upper: inf + H0: + type: Uniform + lower_limit: 50.0 + upper_limit: 100.0 + width_modifier: + type: Relative + value: 0.25 + limits: + lower: 0.0 + upper: inf + Om0: + type: Uniform + lower_limit: 0.1 + upper_limit: 0.5 + width_modifier: + type: Relative + value: 0.25 + limits: + lower: 0.0 + upper: 1.0 diff --git a/autogalaxy/galaxy/galaxy_model_csv.py b/autogalaxy/galaxy/galaxy_model_csv.py index fc2bd3a3..ebdde323 100644 --- a/autogalaxy/galaxy/galaxy_model_csv.py +++ b/autogalaxy/galaxy/galaxy_model_csv.py @@ -22,6 +22,7 @@ The module stays inside :mod:`autogalaxy`; PyAutoLens re-exports the public helpers under ``al.*`` so workspace scripts can use the canonical namespace. """ + import inspect from dataclasses import dataclass, field from pathlib import Path @@ -32,6 +33,9 @@ from autogalaxy.galaxy.galaxy import Galaxy from autogalaxy.profiles import mass as _mp_module from autogalaxy.profiles.light import standard as _lp_module +from autogalaxy.profiles.light import linear as _lp_linear_module +from autogalaxy.profiles.light import operated as _lp_operated_module +from autogalaxy.profiles.light import linear_operated as _lp_linear_operated_module from autogalaxy.profiles import point_sources as _ps_module @@ -41,6 +45,58 @@ "point": _ps_module, } +# The light family spans several sub-namespaces whose classes share names +# (``lp.Sersic`` vs ``lp_linear.Sersic``). The ``profile_class`` cell therefore +# carries a qualifier for the non-standard variants — ``linear.Sersic``, +# ``operated.Gaussian``, ``linear_operated.Gaussian`` — while plain names +# resolve against the standard namespace (and mass / point are unqualified). +_LIGHT_VARIANT_NAMESPACES = { + "linear": _lp_linear_module, + "operated": _lp_operated_module, + "linear_operated": _lp_linear_operated_module, +} + + +def _class_name_for_family( + cls: type, family: str, galaxy_name: str, attr_name: str +) -> str: + """The ``profile_class`` cell for *cls*, with a light-variant qualifier when needed.""" + namespace = _FAMILY_NAMESPACES[family] + if getattr(namespace, cls.__name__, None) is cls: + return cls.__name__ + if family == "light": + for qualifier, module in _LIGHT_VARIANT_NAMESPACES.items(): + if getattr(module, cls.__name__, None) is cls: + return f"{qualifier}.{cls.__name__}" + raise ValueError( + f"Profile {galaxy_name!r}.{attr_name!r} has class " + f"{cls.__name__!r} which is not exposed in family " + f"namespace '{namespace.__name__}' (family '{family}'). " + f"Check that the profile belongs to the family declared " + f"on this CSV — mass profiles go in 'mass', light profiles " + f"in 'light' (linear/operated variants as 'linear.Name' etc.), " + f"point sources in 'point'." + ) + + +def _class_from_cell(cls_name: str, family: str) -> type: + """Resolve a ``profile_class`` cell, honouring light-variant qualifiers.""" + namespace = _FAMILY_NAMESPACES[family] + if "." in cls_name: + qualifier, bare_name = cls_name.split(".", 1) + module = _LIGHT_VARIANT_NAMESPACES.get(qualifier) if family == "light" else None + cls = getattr(module, bare_name, None) if module is not None else None + else: + cls = getattr(namespace, cls_name, None) + if cls is None: + raise ValueError( + f"profile_class '{cls_name}' not found in namespace " + f"'{namespace.__name__}' (family '{family}'). Verify the class " + f"name is spelled correctly and exposed in that namespace " + f"(light variants use qualified names, e.g. 'linear.Sersic')." + ) + return cls + _TUPLE_PARAM_COL_NAMES = { "centre": ("y", "x"), @@ -160,19 +216,12 @@ def galaxy_models_to_csv( for galaxy_name, attr_dict in profiles_by_galaxy.items(): for attr_name, profile in attr_dict.items(): cls = type(profile) - if getattr(namespace, cls.__name__, None) is not cls: - raise ValueError( - f"Profile {galaxy_name!r}.{attr_name!r} has class " - f"{cls.__name__!r} which is not exposed in family " - f"namespace '{namespace.__name__}' (family '{family}'). " - f"Check that the profile belongs to the family declared " - f"on this CSV — mass profiles go in 'mass', light profiles " - f"in 'light', point sources in 'point'." - ) row: Dict[str, Any] = { "galaxy": galaxy_name, "attr_name": attr_name, - "profile_class": cls.__name__, + "profile_class": _class_name_for_family( + cls, family=family, galaxy_name=galaxy_name, attr_name=attr_name + ), } for param_name, default in _profile_init_params(cls): value = getattr(profile, param_name, None) @@ -183,7 +232,9 @@ def galaxy_models_to_csv( row[col0] = float(value[0]) row[col1] = float(value[1]) else: - row[param_name] = float(value) if isinstance(value, (int, float)) else value + row[param_name] = ( + float(value) if isinstance(value, (int, float)) else value + ) if redshifts is not None and galaxy_name in redshifts: row[_REDSHIFT_HEADER] = float(redshifts[galaxy_name]) rows.append(row) @@ -232,24 +283,21 @@ def galaxy_models_from_csv( raw_rows = csvable.list_from_csv(file_path) parsed: List[GalaxyModelRow] = [] + consumed_columns = set(_FIXED_HEADERS) | {_REDSHIFT_HEADER} for raw in raw_rows: cls_name = raw["profile_class"] - cls = getattr(namespace, cls_name, None) - if cls is None: - raise ValueError( - f"profile_class '{cls_name}' not found in namespace " - f"'{namespace.__name__}' (family '{family}'). Verify the class " - f"name is spelled correctly and exposed in that namespace." - ) + cls = _class_from_cell(cls_name, family=family) params: Dict[str, Any] = {} for param_name, default in _profile_init_params(cls): if _is_tuple_param(default): col0, col1 = _tuple_col_names(param_name) + consumed_columns.update((col0, col1)) v0, v1 = raw.get(col0), raw.get(col1) if v0 not in ("", None) and v1 not in ("", None): params[param_name] = (float(v0), float(v1)) else: + consumed_columns.add(param_name) v = raw.get(param_name) if v not in ("", None): params[param_name] = _coerce_scalar(v) @@ -267,13 +315,33 @@ def galaxy_models_from_csv( ) ) + if raw_rows: + unknown = [c for c in raw_rows[0] if c not in consumed_columns] + if unknown: + raise ValueError( + f"CSV at '{file_path}' has column(s) {unknown} which no row's " + f"profile_class consumes — most likely a typo'd parameter name. " + f"A silently ignored column would leave the profile at its " + f"default value, so this is rejected loudly instead." + ) + return GalaxyModelTable(rows=parsed, family=family) def _group_rows_by_galaxy(*tables: GalaxyModelTable) -> Dict[str, List[GalaxyModelRow]]: by_galaxy: Dict[str, List[GalaxyModelRow]] = {} + seen_attrs = set() for table in tables: for row in table.rows: + key = (row.galaxy, row.attr_name) + if key in seen_attrs: + raise ValueError( + f"Duplicate CSV rows for galaxy '{row.galaxy}' attribute " + f"'{row.attr_name}' — each (galaxy, attr_name) pair must be " + f"unique across the input tables (a duplicate would silently " + f"overwrite the earlier profile)." + ) + seen_attrs.add(key) by_galaxy.setdefault(row.galaxy, []).append(row) return by_galaxy diff --git a/autogalaxy/galaxy/galaxy_table.py b/autogalaxy/galaxy/galaxy_table.py index f41c0fa8..b2d93c53 100644 --- a/autogalaxy/galaxy/galaxy_table.py +++ b/autogalaxy/galaxy/galaxy_table.py @@ -11,10 +11,12 @@ This module provides the typed schema layer for that file format. The expected CSV columns are: - y, x, luminosity, redshift? + y, x, luminosity, redshift?, ... -The ``redshift`` column is optional. Extra columns are silently ignored. Row order is -preserved on read and on write. +The ``redshift`` column is optional. Any further numeric columns (e.g. ``ellipticity``, +``angle_pos``, ``mag`` for a Lenstool-style member catalogue) are loaded into +``GalaxyTable.properties`` keyed by column name — nothing is silently dropped. Row order +is preserved on read and on write. The actual CSV I/O is delegated to :mod:`autoconf.csvable`; this module only owns the column-name conventions and the typed return value. @@ -23,9 +25,10 @@ ``output_to_csv`` / ``list_from_csv`` functions). The two formats deliberately do not share infrastructure — the column conventions differ, and coupling them would be premature. """ + from dataclasses import dataclass, field from pathlib import Path -from typing import List, Optional, Sequence, Tuple, Union +from typing import Dict, List, Optional, Sequence, Tuple, Union from autoconf import csvable @@ -46,11 +49,16 @@ class GalaxyTable: redshifts Per-galaxy redshifts in the same order, or ``None`` if the input did not carry a ``redshift`` column. + properties + Any additional numeric columns, keyed by column name (e.g. + ``properties["ellipticity"]``), each a per-galaxy list in row order. Empty dict + when the CSV has no extra columns. """ centres: Grid2DIrregular luminosities: List[float] redshifts: Optional[List[float]] = field(default=None) + properties: Dict[str, List[float]] = field(default_factory=dict) def galaxy_table_from_csv(file_path: Union[str, Path]) -> GalaxyTable: @@ -63,7 +71,9 @@ def galaxy_table_from_csv(file_path: Union[str, Path]) -> GalaxyTable: redshift, others do not) is rejected with ``ValueError`` — the partial-population convention mirrors :func:`autolens.point.dataset.list_from_csv`. - Extra columns are silently ignored. Row order is preserved. + Additional columns are loaded into ``GalaxyTable.properties`` keyed by column name — + numeric columns as per-galaxy floats, non-numeric ones (names, notes) as strings. + Nothing is silently dropped. Row order is preserved. Parameters ---------- @@ -91,10 +101,22 @@ def galaxy_table_from_csv(file_path: Union[str, Path]) -> GalaxyTable: else: redshifts = None + reserved = {"y", "x", "luminosity", "redshift"} + properties: Dict[str, List] = {} + for column in rows[0]: + if column in reserved: + continue + try: + properties[column] = [float(r[column]) for r in rows] + except (TypeError, ValueError): + # Non-numeric catalogue columns (names, notes, flags) ride along as strings. + properties[column] = [r[column] for r in rows] + return GalaxyTable( centres=Grid2DIrregular(centres), luminosities=luminosities, redshifts=redshifts, + properties=properties, ) @@ -103,6 +125,7 @@ def galaxy_table_to_csv( luminosities: Sequence[float], file_path: Union[str, Path], redshifts: Optional[Sequence[float]] = None, + properties: Optional[Dict[str, Sequence[float]]] = None, ) -> None: """ Write a galaxy population to ``file_path`` as a CSV with columns @@ -122,6 +145,10 @@ def galaxy_table_to_csv( Destination CSV path. Parent directories are created if missing. redshifts Optional per-galaxy redshifts. + properties + Optional extra per-galaxy numeric columns, keyed by column name (each the same + length as ``centres``) — e.g. ``{"ellipticity": [...], "angle_pos": [...], + "mag": [...]}`` for a member catalogue carrying shape + magnitude. """ if len(centres) != len(luminosities): raise ValueError( @@ -134,9 +161,19 @@ def galaxy_table_to_csv( f"when provided." ) + if properties is not None: + for name, values in properties.items(): + if len(values) != len(centres): + raise ValueError( + f"properties['{name}'] ({len(values)}) must match centres " + f"({len(centres)}) length." + ) + headers = ["y", "x", "luminosity"] if redshifts is not None: headers.append("redshift") + if properties is not None: + headers.extend(properties.keys()) rows = [] for i, (yx, lum) in enumerate(zip(centres, luminosities)): @@ -147,6 +184,9 @@ def galaxy_table_to_csv( } if redshifts is not None: row["redshift"] = float(redshifts[i]) + if properties is not None: + for name, values in properties.items(): + row[name] = float(values[i]) rows.append(row) csvable.output_to_csv(rows, file_path, headers=headers) diff --git a/autogalaxy/profiles/mass/total/dual_pseudo_isothermal_mass.py b/autogalaxy/profiles/mass/total/dual_pseudo_isothermal_mass.py index 2fd78dca..45804b8d 100644 --- a/autogalaxy/profiles/mass/total/dual_pseudo_isothermal_mass.py +++ b/autogalaxy/profiles/mass/total/dual_pseudo_isothermal_mass.py @@ -1103,10 +1103,17 @@ def __init__( r_cut: float = 20.0, redshift_object: float = 0.5, redshift_source: float = 1.0, + H0: float = 67.66, + Om0: float = 0.30966, ): - from autogalaxy.cosmology.model import Planck15 + from autogalaxy.cosmology.model import FlatLambdaCDM - cosmology = Planck15() + # H0 / Om0 are plain floats (Planck15 values by default) so the profile is + # fully constructable from flat inputs — CSV rows, prior configs — while a + # Lenstool run's own cosmology (typically H0=70, Om0=0.3) can be matched + # exactly. They are model *constants* in practice; the priors config carries + # them only so af.Model composition works. + cosmology = FlatLambdaCDM(H0=H0, Om0=Om0) axis_ratio = np.sqrt((1.0 - ellipticity) / (1.0 + ellipticity)) ell_comps = convert.ell_comps_from(axis_ratio=axis_ratio, angle=angle_pos) @@ -1133,6 +1140,8 @@ def __init__( self.r_cut = r_cut self.redshift_object = redshift_object self.redshift_source = redshift_source + self.H0 = H0 + self.Om0 = Om0 class dPIEMassLenstoolSph(dPIEMassSph): @@ -1165,10 +1174,12 @@ def __init__( r_cut: float = 20.0, redshift_object: float = 0.5, redshift_source: float = 1.0, + H0: float = 67.66, + Om0: float = 0.30966, ): - from autogalaxy.cosmology.model import Planck15 + from autogalaxy.cosmology.model import FlatLambdaCDM - cosmology = Planck15() + cosmology = FlatLambdaCDM(H0=H0, Om0=Om0) b0 = _b0_from_lenstool_sigma( sigma=sigma, @@ -1189,3 +1200,5 @@ def __init__( self.r_cut = r_cut self.redshift_object = redshift_object self.redshift_source = redshift_source + self.H0 = H0 + self.Om0 = Om0 diff --git a/test_autogalaxy/galaxy/test_galaxy_model_csv.py b/test_autogalaxy/galaxy/test_galaxy_model_csv.py index a446c08a..1153bfb7 100644 --- a/test_autogalaxy/galaxy/test_galaxy_model_csv.py +++ b/test_autogalaxy/galaxy/test_galaxy_model_csv.py @@ -15,8 +15,12 @@ def test__mass_single_class__round_trip(tmp_path): file_path = tmp_path / "main_lens_mass.csv" profiles_by_galaxy = { - "lens_0": {"mass": ag.mp.dPIEMassSph(centre=(0.0, 0.0), ra=8.0, rs=20.0, b0=3.0)}, - "lens_1": {"mass": ag.mp.dPIEMassSph(centre=(10.0, 8.0), ra=5.0, rs=12.0, b0=1.2)}, + "lens_0": { + "mass": ag.mp.dPIEMassSph(centre=(0.0, 0.0), ra=8.0, rs=20.0, b0=3.0) + }, + "lens_1": { + "mass": ag.mp.dPIEMassSph(centre=(10.0, 8.0), ra=5.0, rs=12.0, b0=1.2) + }, } ag.galaxy_models_to_csv( @@ -32,7 +36,12 @@ def test__mass_single_class__round_trip(tmp_path): assert [r.galaxy for r in table.rows] == ["lens_0", "lens_1"] assert [r.attr_name for r in table.rows] == ["mass", "mass"] assert all(r.profile_class is ag.mp.dPIEMassSph for r in table.rows) - assert table.rows[0].params == {"centre": (0.0, 0.0), "ra": 8.0, "rs": 20.0, "b0": 3.0} + assert table.rows[0].params == { + "centre": (0.0, 0.0), + "ra": 8.0, + "rs": 20.0, + "b0": 3.0, + } assert table.rows[0].redshift == 0.5 @@ -40,7 +49,9 @@ def test__mass_sparse_columns__dpie_plus_nfw__round_trip(tmp_path): file_path = tmp_path / "main_lens_mass.csv" profiles_by_galaxy = { - "lens_0": {"mass": ag.mp.dPIEMassSph(centre=(0.0, 0.0), ra=8.0, rs=20.0, b0=3.0)}, + "lens_0": { + "mass": ag.mp.dPIEMassSph(centre=(0.0, 0.0), ra=8.0, rs=20.0, b0=3.0) + }, "host_halo": { "dark": ag.mp.NFWMCRLudlowSph( centre=(0.0, 0.0), @@ -60,8 +71,20 @@ def test__mass_sparse_columns__dpie_plus_nfw__round_trip(tmp_path): raw_rows = _read_raw_rows(file_path) assert set(raw_rows[0].keys()).issuperset( - {"galaxy", "attr_name", "profile_class", "y", "x", "ra", "rs", "b0", - "mass_at_200", "redshift_object", "redshift_source", "redshift"} + { + "galaxy", + "attr_name", + "profile_class", + "y", + "x", + "ra", + "rs", + "b0", + "mass_at_200", + "redshift_object", + "redshift_source", + "redshift", + } ) # dPIE row leaves NFW-only columns blank. assert raw_rows[0]["galaxy"] == "lens_0" @@ -74,7 +97,12 @@ def test__mass_sparse_columns__dpie_plus_nfw__round_trip(tmp_path): table = ag.galaxy_models_from_csv(file_path, family="mass") assert table.rows[0].profile_class is ag.mp.dPIEMassSph - assert table.rows[0].params == {"centre": (0.0, 0.0), "ra": 8.0, "rs": 20.0, "b0": 3.0} + assert table.rows[0].params == { + "centre": (0.0, 0.0), + "ra": 8.0, + "rs": 20.0, + "b0": 3.0, + } assert table.rows[1].profile_class is ag.mp.NFWMCRLudlowSph assert table.rows[1].params == { "centre": (0.0, 0.0), @@ -88,7 +116,11 @@ def test__light_family__sersic_sph_plus_sersic_core__round_trip(tmp_path): file_path = tmp_path / "light.csv" profiles_by_galaxy = { - "lens_0": {"bulge": ag.lp.SersicSph(centre=(0.0, 0.0), intensity=1.5, effective_radius=3.0, sersic_index=4.0)}, + "lens_0": { + "bulge": ag.lp.SersicSph( + centre=(0.0, 0.0), intensity=1.5, effective_radius=3.0, sersic_index=4.0 + ) + }, "source_0": { "bulge": ag.lp.SersicCore( centre=(0.3, 0.5), @@ -143,7 +175,9 @@ def test__cross_family_join__builds_named_galaxies(tmp_path): ag.galaxy_models_to_csv( profiles_by_galaxy={ - "lens_0": {"mass": ag.mp.dPIEMassSph(centre=(0.0, 0.0), ra=8.0, rs=20.0, b0=3.0)}, + "lens_0": { + "mass": ag.mp.dPIEMassSph(centre=(0.0, 0.0), ra=8.0, rs=20.0, b0=3.0) + }, }, file_path=mass_csv, family="mass", @@ -151,8 +185,23 @@ def test__cross_family_join__builds_named_galaxies(tmp_path): ) ag.galaxy_models_to_csv( profiles_by_galaxy={ - "lens_0": {"bulge": ag.lp.SersicSph(centre=(0.0, 0.0), intensity=1.5, effective_radius=3.0, sersic_index=4.0)}, - "source_0": {"bulge": ag.lp.SersicCore(centre=(0.3, 0.5), ell_comps=(0.1, -0.2), intensity=2.0, effective_radius=0.3, sersic_index=1.0)}, + "lens_0": { + "bulge": ag.lp.SersicSph( + centre=(0.0, 0.0), + intensity=1.5, + effective_radius=3.0, + sersic_index=4.0, + ) + }, + "source_0": { + "bulge": ag.lp.SersicCore( + centre=(0.3, 0.5), + ell_comps=(0.1, -0.2), + intensity=2.0, + effective_radius=0.3, + sersic_index=1.0, + ) + }, }, file_path=light_csv, family="light", @@ -187,7 +236,9 @@ def test__af_models_round_trip(tmp_path): ag.galaxy_models_to_csv( profiles_by_galaxy={ - "lens_0": {"mass": ag.mp.dPIEMassSph(centre=(0.0, 0.0), ra=8.0, rs=20.0, b0=3.0)}, + "lens_0": { + "mass": ag.mp.dPIEMassSph(centre=(0.0, 0.0), ra=8.0, rs=20.0, b0=3.0) + }, }, file_path=mass_csv, family="mass", @@ -238,13 +289,26 @@ def test__redshift_consistency_check__raises(tmp_path): light_csv = tmp_path / "light.csv" ag.galaxy_models_to_csv( - profiles_by_galaxy={"lens_0": {"mass": ag.mp.dPIEMassSph(centre=(0.0, 0.0), ra=8.0, rs=20.0, b0=3.0)}}, + profiles_by_galaxy={ + "lens_0": { + "mass": ag.mp.dPIEMassSph(centre=(0.0, 0.0), ra=8.0, rs=20.0, b0=3.0) + } + }, file_path=mass_csv, family="mass", redshifts={"lens_0": 0.5}, ) ag.galaxy_models_to_csv( - profiles_by_galaxy={"lens_0": {"bulge": ag.lp.SersicSph(centre=(0.0, 0.0), intensity=1.5, effective_radius=3.0, sersic_index=4.0)}}, + profiles_by_galaxy={ + "lens_0": { + "bulge": ag.lp.SersicSph( + centre=(0.0, 0.0), + intensity=1.5, + effective_radius=3.0, + sersic_index=4.0, + ) + } + }, file_path=light_csv, family="light", redshifts={"lens_0": 0.7}, @@ -264,7 +328,14 @@ def test__writer_rejects_profile_from_wrong_family(tmp_path): with pytest.raises(ValueError, match="not exposed in family namespace"): ag.galaxy_models_to_csv( profiles_by_galaxy={ - "lens_0": {"bulge": ag.lp.SersicSph(centre=(0.0, 0.0), intensity=1.0, effective_radius=1.0, sersic_index=2.0)}, + "lens_0": { + "bulge": ag.lp.SersicSph( + centre=(0.0, 0.0), + intensity=1.0, + effective_radius=1.0, + sersic_index=2.0, + ) + }, }, file_path=file_path, family="mass", @@ -274,9 +345,97 @@ def test__writer_rejects_profile_from_wrong_family(tmp_path): def test__class_not_found_in_namespace__raises(tmp_path): file_path = tmp_path / "mass.csv" file_path.write_text( - "galaxy,attr_name,profile_class,y,x\n" - "lens_0,mass,NotARealClass,0.0,0.0\n" + "galaxy,attr_name,profile_class,y,x\n" "lens_0,mass,NotARealClass,0.0,0.0\n" ) with pytest.raises(ValueError, match="profile_class 'NotARealClass' not found"): ag.galaxy_models_from_csv(file_path, family="mass") + + +def test__light_variant_namespaces__qualified_class_names_round_trip(tmp_path): + import autogalaxy as ag + from autogalaxy.galaxy.galaxy_model_csv import ( + galaxy_models_to_csv, + galaxy_models_from_csv, + galaxies_from_csv_tables, + ) + + lp_linear = ag.lp_linear.Sersic( + centre=(0.0, 0.0), ell_comps=(0.1, 0.0), effective_radius=0.8, sersic_index=2.5 + ) + lp_operated = ag.lp_operated.Gaussian( + centre=(0.0, 0.0), ell_comps=(0.0, 0.0), intensity=1.0, sigma=0.3 + ) + + path = tmp_path / "light.csv" + galaxy_models_to_csv( + {"gal": {"bulge": lp_linear, "psf": lp_operated}}, path, family="light" + ) + + text = path.read_text() + assert "linear.Sersic" in text + assert "operated.Gaussian" in text + + galaxies = galaxies_from_csv_tables(galaxy_models_from_csv(path, family="light")) + + assert type(galaxies["gal"].bulge) is ag.lp_linear.Sersic + assert type(galaxies["gal"].psf) is ag.lp_operated.Gaussian + + +def test__lenstool_parameterized_mass_row__round_trips(tmp_path): + import autogalaxy as ag + from autogalaxy.galaxy.galaxy_model_csv import ( + galaxy_models_to_csv, + galaxy_models_from_csv, + galaxies_from_csv_tables, + ) + + profile = ag.mp.dPIEMassLenstool( + centre=(1.5, -3.0), + ellipticity=0.68, + angle_pos=8.97, + sigma=987.3, + r_core=18.96, + r_cut=283.5, + redshift_object=0.39, + redshift_source=11.76, + ) + + path = tmp_path / "mass.csv" + galaxy_models_to_csv( + {"O1": {"mass": profile}}, path, family="mass", redshifts={"O1": 0.39} + ) + galaxies = galaxies_from_csv_tables(galaxy_models_from_csv(path, family="mass")) + + assert galaxies["O1"].mass.b0 == pytest.approx(profile.b0, rel=1e-12) + assert galaxies["O1"].redshift == 0.39 + + +def test__duplicate_galaxy_attr_rows__raise(tmp_path): + from autogalaxy.galaxy.galaxy_model_csv import ( + galaxy_models_from_csv, + galaxies_from_csv_tables, + ) + + path = tmp_path / "dup.csv" + path.write_text( + "galaxy,attr_name,profile_class,y,x,einstein_radius\n" + "lens,mass,IsothermalSph,0.0,0.0,1.0\n" + "lens,mass,IsothermalSph,0.0,0.0,2.0\n" + ) + + with pytest.raises(ValueError, match="Duplicate CSV rows"): + galaxies_from_csv_tables(galaxy_models_from_csv(path, family="mass")) + + +def test__unknown_column__raises_instead_of_silent_default(tmp_path): + from autogalaxy.galaxy.galaxy_model_csv import galaxy_models_from_csv + + path = tmp_path / "typo.csv" + path.write_text( + "galaxy,attr_name,profile_class,y,x,einstien_radius\n" + "lens,mass,IsothermalSph,0.0,0.0,1.5\n" + ) + + with pytest.raises(ValueError, match="einstien_radius"): + galaxy_models_from_csv(path, family="mass") diff --git a/test_autogalaxy/galaxy/test_galaxy_table.py b/test_autogalaxy/galaxy/test_galaxy_table.py index cecb337e..971d9e0b 100644 --- a/test_autogalaxy/galaxy/test_galaxy_table.py +++ b/test_autogalaxy/galaxy/test_galaxy_table.py @@ -43,11 +43,7 @@ def test__round_trip__with_redshift(tmp_path): def test__missing_redshift_column__redshifts_none(tmp_path): file_path = tmp_path / "galaxies.csv" - file_path.write_text( - "y,x,luminosity\n" - "3.5,2.5,0.9\n" - "-4.4,-5.0,0.45\n" - ) + file_path.write_text("y,x,luminosity\n" "3.5,2.5,0.9\n" "-4.4,-5.0,0.45\n") table = ag.galaxy_table_from_csv(file_path) @@ -59,16 +55,14 @@ def test__missing_redshift_column__redshifts_none(tmp_path): def test__partial_redshift__raises(tmp_path): file_path = tmp_path / "galaxies.csv" file_path.write_text( - "y,x,luminosity,redshift\n" - "3.5,2.5,0.9,0.5\n" - "-4.4,-5.0,0.45,\n" + "y,x,luminosity,redshift\n" "3.5,2.5,0.9,0.5\n" "-4.4,-5.0,0.45,\n" ) with pytest.raises(ValueError, match="partially populated 'redshift'"): ag.galaxy_table_from_csv(file_path) -def test__extra_columns_ignored(tmp_path): +def test__extra_columns_loaded_as_properties(tmp_path): file_path = tmp_path / "galaxies.csv" file_path.write_text( "name,y,x,luminosity,notes\n" @@ -80,6 +74,8 @@ def test__extra_columns_ignored(tmp_path): assert table.luminosities == [0.9, 0.45] assert table.redshifts is None + assert table.properties["name"] == ["g0", "g1"] + assert table.properties["notes"] == ["bright", "faint"] def test__row_order_preserved(tmp_path): @@ -157,3 +153,40 @@ def test__to_csv__redshift_column_only_when_provided(tmp_path): rows = _read_raw_rows(file_path) assert rows[0]["redshift"] == "0.7" + + +def test__extra_property_columns__loaded_and_round_tripped(tmp_path): + from autogalaxy.galaxy.galaxy_table import ( + galaxy_table_from_csv, + galaxy_table_to_csv, + ) + + path = tmp_path / "members.csv" + galaxy_table_to_csv( + centres=[(1.0, 2.0), (3.0, 4.0)], + luminosities=[0.4, 0.2], + file_path=path, + properties={ + "ellipticity": [0.3, 0.1], + "angle_pos": [45.0, -10.0], + "mag": [19.5, 21.0], + }, + ) + + table = galaxy_table_from_csv(file_path=path) + + assert table.properties["ellipticity"] == [0.3, 0.1] + assert table.properties["angle_pos"] == [45.0, -10.0] + assert table.properties["mag"] == [19.5, 21.0] + assert table.luminosities == [0.4, 0.2] + + +def test__legacy_three_column_schema__properties_empty(tmp_path): + from autogalaxy.galaxy.galaxy_table import galaxy_table_from_csv + + path = tmp_path / "legacy.csv" + path.write_text("y,x,luminosity\n1.0,2.0,0.4\n") + + table = galaxy_table_from_csv(file_path=path) + + assert table.properties == {} diff --git a/test_autogalaxy/profiles/mass/total/test_dual_pseudo_isothermal_mass.py b/test_autogalaxy/profiles/mass/total/test_dual_pseudo_isothermal_mass.py index 6f26d507..98de5f1a 100644 --- a/test_autogalaxy/profiles/mass/total/test_dual_pseudo_isothermal_mass.py +++ b/test_autogalaxy/profiles/mass/total/test_dual_pseudo_isothermal_mass.py @@ -258,7 +258,12 @@ def test__lenstool_wrapper__matches_from_lenstool_constructor(): ) wrapper = ag.mp.dPIEMassLenstool(**kwargs) - constructed = ag.mp.dPIEMass.from_lenstool(**kwargs) + # The wrapper's flat-input cosmology (H0/Om0 floats -> FlatLambdaCDM) matches the + # classmethod when the classmethod is handed the same background cosmology; the + # Planck15 subclass differs at the massive-neutrino level. + constructed = ag.mp.dPIEMass.from_lenstool( + cosmology=ag.cosmo.FlatLambdaCDM(), **kwargs + ) assert wrapper.b0 == pytest.approx(constructed.b0, rel=1e-10) assert wrapper.ra == constructed.ra @@ -277,7 +282,9 @@ def test__lenstool_wrapper__matches_from_lenstool_constructor(): } wrapper_sph = ag.mp.dPIEMassLenstoolSph(**sph_kwargs) - constructed_sph = ag.mp.dPIEMassSph.from_lenstool(**sph_kwargs) + constructed_sph = ag.mp.dPIEMassSph.from_lenstool( + cosmology=ag.cosmo.FlatLambdaCDM(), **sph_kwargs + ) assert wrapper_sph.b0 == pytest.approx(constructed_sph.b0, rel=1e-10) @@ -291,10 +298,12 @@ def test__lenstool_wrapper__supports_model_composition(): model = af.Model(ag.mp.dPIEMassLenstool) - assert model.prior_count == 9 + assert model.prior_count == 11 model.redshift_object = 0.5 model.redshift_source = 2.0 + model.H0 = 70.0 + model.Om0 = 0.3 assert model.prior_count == 7 @@ -305,4 +314,4 @@ def test__lenstool_wrapper__supports_model_composition(): model_sph = af.Model(ag.mp.dPIEMassLenstoolSph) - assert model_sph.prior_count == 7 + assert model_sph.prior_count == 9