diff --git a/autogalaxy/config/latent.yaml b/autogalaxy/config/latent.yaml index 149b6494..fd4f783f 100644 --- a/autogalaxy/config/latent.yaml +++ b/autogalaxy/config/latent.yaml @@ -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=` 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=)`). 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 diff --git a/autogalaxy/imaging/model/latent.py b/autogalaxy/imaging/model/latent.py index 9f7e53c2..5a3e40e7 100644 --- a/autogalaxy/imaging/model/latent.py +++ b/autogalaxy/imaging/model/latent.py @@ -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= 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): """ @@ -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=`` 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= 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]] @@ -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, } diff --git a/test_autogalaxy/config/latent.yaml b/test_autogalaxy/config/latent.yaml index db77b885..eb0eaaad 100644 --- a/test_autogalaxy/config/latent.yaml +++ b/test_autogalaxy/config/latent.yaml @@ -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 diff --git a/test_autogalaxy/imaging/model/test_latent.py b/test_autogalaxy/imaging/model/test_latent.py index f585e9a5..94d3badb 100644 --- a/test_autogalaxy/imaging/model/test_latent.py +++ b/test_autogalaxy/imaging/model/test_latent.py @@ -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, ) @@ -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): @@ -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): @@ -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())