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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
104 changes: 86 additions & 18 deletions autogalaxy/galaxy/galaxy_model_csv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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


Expand All @@ -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"),
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand Down
50 changes: 45 additions & 5 deletions autogalaxy/galaxy/galaxy_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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?, <property>...

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.
Expand All @@ -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

Expand All @@ -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:
Expand All @@ -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
----------
Expand Down Expand Up @@ -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,
)


Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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)):
Expand All @@ -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)
Loading
Loading