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
23 changes: 16 additions & 7 deletions autogalaxy/config/latent.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,22 @@
# Workspaces should mirror this file in their own `config/latent.yaml` to
# override defaults locally (workspace values shadow library values).

# `total_galaxy_0_flux_mujy` — total integrated flux of the first galaxy in
# the fit (i.e. `fit.galaxies[0]`), converted from the fit's linear flux
# units to microjanskies via `magzero` passed through Analysis kwargs.
# `total_galaxy_0_flux` — total integrated flux of the first galaxy
# (`fit.galaxies[0]`) in the raw image units the fit was performed in.
# Returns NaN when galaxy 0 has no light profile.
#
# Default `false` because computing this latent requires the user to pass
# `magzero=<value>` to `AnalysisImaging(...)` — if the value is missing the
# computation raises loudly (no silent NaN). Users who want this latent
# should enable it here AND pass `magzero` via Analysis kwargs.
# Requires no instrument inputs — default `true`. See the workspace flux
# guide (`scripts/guides/units/flux.py`) for how to convert this to a
# microjansky flux using a user-supplied `magzero`.
total_galaxy_0_flux: true

# `total_galaxy_0_flux_mujy` — the same total flux converted to
# microjanskies via `magzero` passed through Analysis kwargs.
#
# Default `false` because the conversion needs a per-instrument zero-point
# that the user must supply (`AnalysisImaging(..., magzero=<value>)`). When
# enabled without a `magzero`, the latent returns NaN and emits a single
# warning per process — it does not raise. Workspaces with a known
# zero-point (e.g. the Euclid pipeline) override this to `true` and pass
# `magzero` explicitly.
total_galaxy_0_flux_mujy: false
69 changes: 59 additions & 10 deletions autogalaxy/imaging/model/latent.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,33 @@

logger = logging.getLogger(__name__)

# Latent names that have already emitted a "magzero missing" warning in this
# process. Used by ``_maybe_magzero_warn`` to deduplicate the message across
# the many fit evaluations a single search performs.
_MAGZERO_WARNED: set = set()


def _maybe_magzero_warn(magzero, name) -> bool:
"""
Return True when ``magzero`` is missing (and emit a one-time-per-process
warning for ``name``); False otherwise.

Callers that get True must early-return ``xp.nan`` — the µJy conversion
is meaningless without a zero-point, but a search-killing raise here
would discard otherwise-converged fits.
"""
if magzero is None:
if name not in _MAGZERO_WARNED:
logger.warning(
"magzero not set on Analysis; '%s' latent will be NaN. "
"Pass magzero=<value> to AnalysisImaging to enable it, "
"or disable in config/latent.yaml to silence this warning.",
name,
)
_MAGZERO_WARNED.add(name)
return True
return False


def ab_mag_via_flux_from(flux, magzero, xp=np):
"""
Expand Down Expand Up @@ -48,22 +75,43 @@ def flux_mujy_via_ab_mag_from(ab_mag, xp=np):
return 10 ** ((23.9 - ab_mag) / 2.5)


def total_galaxy_0_flux(fit, magzero=None, xp=np):
"""
Total integrated flux of the first galaxy in the fit, in the raw image
units the fit was performed in (the sum of the model image pixels).

Requires no instrument inputs — ``magzero`` is accepted for uniform
dispatcher context but ignored. See ``autolens_workspace`` and
``autogalaxy_workspace`` flux guides for how to convert this to a
microjansky flux using a user-supplied ``magzero``.

Returns NaN when the first galaxy has no light profile (which raises
inside ``fit.galaxy_image_dict``).
"""
try:
image = fit.galaxy_image_dict[fit.galaxies[0]]
except (AttributeError, KeyError, IndexError):
return xp.nan
return xp.sum(image.array)


def total_galaxy_0_flux_mujy(fit, magzero, xp=np):
"""
Total integrated flux of the first galaxy in the fit, converted to
microjanskies via the image's magnitude zero-point.

Returns NaN when the first galaxy has no light profile (which raises
AttributeError inside ``fit.galaxy_image_dict``). Raises if ``magzero``
is missing — there is no sensible default for a photometric calibration.
Returns NaN — with a one-time-per-process warning — when ``magzero``
is missing, rather than raising. The µJy conversion is meaningless
without a zero-point, but a hard raise during post-fit latent
computation would discard the result of an otherwise-converged
multi-hour search. Users who want this column populated should pass
``magzero=<value>`` to ``AnalysisImaging`` (Euclid pipeline pattern)
or use :func:`total_galaxy_0_flux` and convert in post.

Also returns NaN when the first galaxy has no light profile.
"""
if magzero is None:
raise ValueError(
"magzero must be passed to AnalysisImaging via kwargs to compute "
"the 'total_galaxy_0_flux_mujy' latent. Disable it in "
"config/latent.yaml or pass magzero=<value> when constructing "
"the Analysis."
)
if _maybe_magzero_warn(magzero, "total_galaxy_0_flux_mujy"):
return xp.nan

try:
image = fit.galaxy_image_dict[fit.galaxies[0]]
Expand All @@ -76,6 +124,7 @@ def total_galaxy_0_flux_mujy(fit, magzero, xp=np):


LATENT_FUNCTIONS: Dict[str, Callable] = {
"total_galaxy_0_flux": total_galaxy_0_flux,
"total_galaxy_0_flux_mujy": total_galaxy_0_flux_mujy,
}

Expand Down
4 changes: 4 additions & 0 deletions test_autogalaxy/config/latent.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
# Test config mirror of `autogalaxy/config/latent.yaml`. Pushed by the
# autouse `set_config_path` fixture in `test_autogalaxy/conftest.py`, which
# shadows the library defaults during the test suite.
#
# The µJy key is `true` here (vs `false` in the library default) so the
# test suite exercises both the raw-flux and magzero-converted paths.
total_galaxy_0_flux: true
total_galaxy_0_flux_mujy: true
74 changes: 62 additions & 12 deletions test_autogalaxy/imaging/model/test_latent.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@

import autofit as af
import autogalaxy as ag
from autogalaxy.imaging.model import latent as _latent_module
from autogalaxy.imaging.model.latent import (
LATENT_FUNCTIONS,
ab_mag_via_flux_from,
flux_mujy_via_ab_mag_from,
latent_keys_enabled,
total_galaxy_0_flux,
total_galaxy_0_flux_mujy,
)

Expand Down Expand Up @@ -55,29 +57,76 @@ def test_total_galaxy_0_flux_mujy_returns_nan_when_no_light_profile():
assert np.isnan(result)


def test_total_galaxy_0_flux_mujy_missing_magzero_raises():
def test_total_galaxy_0_flux_mujy_missing_magzero_returns_nan_and_warns(caplog):
_latent_module._MAGZERO_WARNED.discard("total_galaxy_0_flux_mujy")
caplog.set_level(logging.WARNING)

image = SimpleNamespace(array=np.array([1.0, 2.0, 3.0]))
galaxy = object()
fit = SimpleNamespace(galaxies=[galaxy], galaxy_image_dict={galaxy: image})

result = total_galaxy_0_flux_mujy(fit=fit, magzero=None)

assert np.isnan(result)
assert any(
"magzero" in rec.message and "total_galaxy_0_flux_mujy" in rec.message
for rec in caplog.records
)


def test_total_galaxy_0_flux_against_known_image():
image = SimpleNamespace(array=np.array([1.0, 2.0, 3.0, 4.0]))
galaxy = object()
fit = SimpleNamespace(galaxies=[galaxy], galaxy_image_dict={galaxy: image})

# Raw sum — no AB-mag conversion. `magzero` is accepted but ignored.
assert total_galaxy_0_flux(fit=fit, magzero=None) == pytest.approx(10.0)
assert total_galaxy_0_flux(fit=fit, magzero=25.0) == pytest.approx(10.0)


def test_total_galaxy_0_flux_returns_nan_when_no_light_profile():
fit = MagicMock()
fit.galaxy_image_dict.__getitem__.side_effect = KeyError("no light")
fit.galaxies = [object()]

assert np.isnan(total_galaxy_0_flux(fit=fit))


def test_maybe_magzero_warn_logs_only_once_per_name(caplog):
_latent_module._MAGZERO_WARNED.discard("total_galaxy_0_flux_mujy")
caplog.set_level(logging.WARNING)

with pytest.raises(ValueError, match="magzero"):
image = SimpleNamespace(array=np.array([1.0]))
galaxy = object()
fit = SimpleNamespace(galaxies=[galaxy], galaxy_image_dict={galaxy: image})

for _ in range(3):
total_galaxy_0_flux_mujy(fit=fit, magzero=None)

matching = [
r for r in caplog.records if "total_galaxy_0_flux_mujy" in r.message
]
assert len(matching) == 1


def test_latent_keys_enabled_filters_disabled():
enabled = latent_keys_enabled(yaml_config={"total_galaxy_0_flux_mujy": False})
enabled = latent_keys_enabled(
yaml_config={
"total_galaxy_0_flux": False,
"total_galaxy_0_flux_mujy": False,
}
)
assert enabled == []


def test_latent_keys_enabled_preserves_yaml_order():
# Insert an unknown second key so we can assert ordering across two keys
# without depending on a future second registered latent.
yaml_config = {
"total_galaxy_0_flux_mujy": True,
"future_latent_zzz": True,
"total_galaxy_0_flux": True,
}
enabled = latent_keys_enabled(yaml_config=yaml_config)

# Unknown keys drop; the known key stays in its yaml-insertion position.
assert enabled == ["total_galaxy_0_flux_mujy"]
assert enabled == ["total_galaxy_0_flux_mujy", "total_galaxy_0_flux"]


def test_latent_keys_enabled_drops_unknown_with_warning(caplog):
Expand Down Expand Up @@ -105,8 +154,9 @@ def test_analysis_imaging_compute_latent_variables_aligns_with_latent_keys(

assert isinstance(values, tuple)
assert len(values) == len(analysis.LATENT_KEYS)
assert analysis.LATENT_KEYS == ["total_galaxy_0_flux_mujy"]
assert np.isfinite(values[0])
# test_autogalaxy/config/latent.yaml enables both keys, raw flux first.
assert analysis.LATENT_KEYS == ["total_galaxy_0_flux", "total_galaxy_0_flux_mujy"]
assert all(np.isfinite(v) for v in values)


def test_analysis_imaging_compute_latent_variables_raises_when_empty(monkeypatch):
Expand All @@ -126,9 +176,9 @@ def test_analysis_imaging_compute_latent_variables_raises_when_empty(monkeypatch

def test_analysis_imaging_latent_keys_property_reads_config():
# The autouse fixture in test_autogalaxy/conftest.py pushes the test
# config dir whose latent.yaml enables total_galaxy_0_flux_mujy.
# config dir whose latent.yaml enables both keys.
dataset = MagicMock()
analysis = ag.AnalysisImaging(dataset=dataset, use_jax=False)

assert analysis.LATENT_KEYS == ["total_galaxy_0_flux_mujy"]
assert analysis.LATENT_KEYS == ["total_galaxy_0_flux", "total_galaxy_0_flux_mujy"]
assert set(analysis.LATENT_KEYS).issubset(LATENT_FUNCTIONS.keys())
Loading