Skip to content

Commit 31d2080

Browse files
cailmdaleyclaude
andcommitted
review: address PR #761 code review
- do_ngmix_metacal: seed the original-PSF pre-fit from a snapshot of the metacal RNG so it no longer advances the rng boot.go consumes — galaxy/shear results are now bit-identical to the pre-PR base (only the *_PSF_ORIG columns are added). Conservative: galaxy science unchanged vs base. - remove the redundant galaxy.sigma_to_fwhm wrapper; inline cs_size.sigma_to_fwhm(sigma) * pixel_scale at call sites (spread_model); cs_util unchanged. - drop leftover psf_fit_prior knob references (docstrings/comments/tests). - canonicalize the ESTIMATOR_COMPONENT_OBJECT grammar spec string across all sites. - trim over-explained copy-PSF / leakage / weighting prose to one canonical spot; document that bit-identity rests on BOTH pristine gal_obs.psf AND the snapshot RNG. - add tests: PSF columns survive a failed-fit NaN-fill; all-epochs-failed raises; grammar token check runs over both shipped param files. Fix a stale cross-ref. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e2f37be commit 31d2080

11 files changed

Lines changed: 137 additions & 306 deletions

File tree

src/shapepipe/modules/make_cat_package/make_cat.py

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -329,18 +329,16 @@ def _save_ngmix_data(self, ngmix_cat_path, moments=False):
329329
330330
Save the NGMIX catalogue into the final one.
331331
332-
Column grammar: ``NGMIX[m]_<COMPONENT>[_ERR][_<OBJECT>][_<SHEAR>]``.
333-
``OBJECT`` and ``SHEAR`` are both optional: the per-object metadata
334-
columns (``NGMIX_MCAL_FLAGS``, ``NGMIX_N_EPOCH``,
335-
``NGMIX_MCAL_TYPES_FAIL``) carry neither. When present, ``OBJECT`` is
336-
one of ``GAL`` (galaxy), ``PSF_ORIG`` (the original psfex/mccd image
337-
PSF, fit by ``ngmix.average_original_psf`` — its true ellipticity and
338-
size, feeding object-wise PSF leakage), or ``PSF_RECONV`` (the metacal
339-
reconvolution kernel, round and enlarged by construction, fit by
340-
``ngmix.average_multiepoch_psf`` — the Tgal / Tpsf size cut and a g~0
341-
sanity check). ``PSF_ORIG`` and ``PSF_RECONV`` are independent fits of
342-
different PSFs, no longer the single aliased value of the pre-fix code
343-
(shapepipe#749).
332+
Column grammar: ``NGMIX[m]_<COMPONENT>[_ERR]_<OBJECT>_<SHEAR>``, plus
333+
three OBJECT/SHEAR-less per-object metadata columns
334+
(``NGMIX[m]_MCAL_FLAGS``, ``NGMIX_N_EPOCH``,
335+
``NGMIX_MCAL_TYPES_FAIL``). ``OBJECT`` is one of ``GAL`` (galaxy),
336+
``PSF_ORIG`` (the original image PSF, fit by
337+
:func:`ngmix.average_original_psf`), or ``PSF_RECONV`` (the metacal
338+
reconvolution kernel, fit by :func:`ngmix.average_multiepoch_psf`);
339+
see those functions for what each PSF family IS. ``PSF_ORIG`` and
340+
``PSF_RECONV`` are independent fits of different PSFs, no longer the
341+
single aliased value of the pre-fix code (shapepipe#749).
344342
345343
Parameters
346344
----------
@@ -390,9 +388,11 @@ def _save_ngmix_data(self, ngmix_cat_path, moments=False):
390388
prefix = f"NGMIX{m}"
391389

392390
# Galaxy (GAL), original image PSF (PSF_ORIG) and metacal
393-
# reconvolution kernel (PSF_RECONV). G1/G2 are scalar reduced-shear
394-
# components, not a 2-vector. Sentinels: sizes/fluxes/mags/flags 0,
395-
# *_ERR fluxes/mags -1, ellipticities -10, *_ERR sizes 1e30.
391+
# reconvolution kernel (PSF_RECONV); see ngmix.average_original_psf /
392+
# average_multiepoch_psf for what each PSF family is. G1/G2 are scalar
393+
# reduced-shear components, not a 2-vector. Sentinels:
394+
# sizes/fluxes/mags/flags 0, *_ERR fluxes/mags -1, ellipticities -10,
395+
# *_ERR sizes 1e30.
396396
for key_str in (
397397
f"{prefix}_T_GAL_",
398398
f"{prefix}_SNR_GAL_",
@@ -484,8 +484,7 @@ def _save_ngmix_data(self, ngmix_cat_path, moments=False):
484484
ncf_data["flags"][ind[0]], idx
485485
)
486486

487-
# Original image PSF (psfex/mccd) — its genuine,
488-
# un-reconvolved ellipticity and size.
487+
# Original image PSF (see ngmix.average_original_psf).
489488
self._add2dict(
490489
f"{prefix}_G1_PSF_ORIG_{key}",
491490
ncf_data["g1_psf_orig"][ind[0]], idx
@@ -511,8 +510,8 @@ def _save_ngmix_data(self, ngmix_cat_path, moments=False):
511510
ncf_data["T_err_psf_orig"][ind[0]], idx
512511
)
513512

514-
# Metacal reconvolution kernel — round, enlarged by
515-
# construction; the Tgal/Tpsf cut and g~0 sanity check.
513+
# Metacal reconvolution kernel (see
514+
# ngmix.average_multiepoch_psf).
516515
self._add2dict(
517516
f"{prefix}_G1_PSF_RECONV_{key}",
518517
ncf_data["g1_psf_reconv"][ind[0]], idx

src/shapepipe/modules/ngmix_package/ngmix.py

Lines changed: 30 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -380,16 +380,13 @@ def compile_results(self, results):
380380
Compiled results ready to be written to a file.
381381
382382
Two PSF column families — each carrying ellipticity *and* size,
383-
for *different* PSFs (shapepipe#749):
383+
for *different* PSFs (shapepipe#749). See the function docstrings
384+
for what each family IS:
384385
385-
* ``*_psf_orig`` (``g1``/``g2`` + ``*_err``, ``T``) — the
386-
ORIGINAL image PSF (the psfex/mccd model stamp), fit before
387-
metacal reconvolution by :func:`average_original_psf`. This is
388-
the PSF whose true ellipticity and size enter object-wise
389-
PSF-leakage diagnostics.
390-
* ``*_psf_reconv`` — the metacal RECONVOLUTION kernel (round and
391-
enlarged by construction, used for the Tgal/Tpsf size cut and a
392-
g~0 sanity check), fit by :func:`average_multiepoch_psf`.
386+
* ``*_psf_orig`` (``g1``/``g2`` + ``*_err``, ``T``) — the original
387+
image PSF, fit by :func:`average_original_psf`.
388+
* ``*_psf_reconv`` — the metacal reconvolution kernel, fit by
389+
:func:`average_multiepoch_psf`.
393390
394391
Raises
395392
------
@@ -1232,23 +1229,18 @@ def average_original_psf(gal_obs_list, psf_runner):
12321229
"""Fit and average the *original* image PSF over epochs.
12331230
12341231
The original PSF is the psfex/mccd model stamp handed to ngmix
1235-
(``gal_obs.psf``), fit here with the same ``psf_runner`` machinery — and
1236-
so the same ``psf_fit_prior`` — used inside metacal, but on the PSF
1237-
*before* metacal's reconvolution. Exported to the original-PSF columns
1232+
(``gal_obs.psf``), fit here with the same ``psf_runner`` (hence the same
1233+
fit prior and guesser) used inside metacal, but on the PSF *before*
1234+
metacal's reconvolution. Exported to the original-PSF columns
12381235
(``NGMIX_G1/G2_PSF_ORIG``, ``NGMIX_T_PSF_ORIG``). Distinct from the
12391236
reconvolution-kernel fit (:func:`average_multiepoch_psf`): the original
12401237
PSF retains its true ellipticity and size, whereas the reconvolution
12411238
kernel is round and enlarged by construction. This is the PSF whose true
12421239
shape and size enter object-wise PSF-leakage diagnostics.
12431240
1244-
Epochs are weighted by the *galaxy* inverse-variance weight
1245-
(``gal_obs.weight.sum()``). This is the same *form* as
1246-
:func:`average_multiepoch_psf` (``weight.sum()`` per epoch, skipping
1247-
``flags != 0`` fits) but not the identical weight: the reconvolution path
1248-
weights by the fixnoise-combined inverse variance of the noshear metacal
1249-
image, whereas this path uses the raw galaxy inverse variance. So the two
1250-
PSF families share an averaging *scheme* and differ in which PSF is fit
1251-
and in the precise per-epoch weighting factor.
1241+
Weighted by the raw galaxy inverse variance (``gal_obs.weight.sum()`` per
1242+
epoch); :func:`average_multiepoch_psf` uses the same scheme but the
1243+
fixnoise-combined metacal-image weight instead.
12521244
12531245
The fit runs on a *copy* of each PSF observation so ``gal_obs.psf`` —
12541246
the object metacal later deep-copies and consumes via
@@ -1258,8 +1250,11 @@ def average_original_psf(gal_obs_list, psf_runner):
12581250
would survive metacal's deep copy and be reused as the
12591251
``MetacalFitGaussPSF`` fallback when its own admom+ML PSF fits both fail,
12601252
silently rescuing objects the base branch dropped (``BootPSFFailure``) and
1261-
changing the galaxy/shear result set. Fitting a copy keeps this add-column
1262-
refactor bit-identical on the galaxy results.
1253+
changing the galaxy/shear result set. Fitting a copy closes this
1254+
PSF-aliasing channel; combined with :func:`do_ngmix_metacal` seeding this
1255+
pre-fit from a *snapshot* of the metacal RNG (so the pre-fit does not
1256+
advance it), the add-column refactor stays bit-identical on the galaxy
1257+
results.
12631258
12641259
Parameters
12651260
----------
@@ -1268,19 +1263,17 @@ def average_original_psf(gal_obs_list, psf_runner):
12681263
(pre-metacal) PSF observation to fit, with no further ``.psf`` of
12691264
its own so the runner fits the stamp itself. Left pristine.
12701265
psf_runner : ngmix.runners.PSFRunner
1271-
The module's PSF runner, carrying the resolved ``psf_fit_prior``.
1266+
The module's PSF runner (built by :func:`make_runners` from the
1267+
shared ``prior``).
12721268
12731269
Returns
12741270
-------
12751271
dict
12761272
Same keys as :func:`average_multiepoch_psf`.
12771273
"""
12781274
def fit(gal_obs):
1279-
# Fit a COPY of the PSF observation: PSFRunner.go fits the PSF stamp
1280-
# and sets its .meta['result'] (and .gmix on success). Reading from
1281-
# the copy leaves gal_obs.psf pristine — no gmix to leak through
1282-
# metacal's deep copy into the MetacalFitGaussPSF fallback. Failed
1283-
# fits keep flags != 0 and are dropped by _average_psf_fits.
1275+
# Fit a COPY so gal_obs.psf stays pristine for metacal — see docstring.
1276+
# Failed fits keep flags != 0 and are dropped by _average_psf_fits.
12841277
psf_obs = gal_obs.psf.copy()
12851278
psf_runner.go(psf_obs)
12861279
return psf_obs.meta['result'], gal_obs.weight.sum()
@@ -1379,14 +1372,15 @@ def do_ngmix_metacal(stamp, prior, flux_guess, rng, centroid_source="hsm"):
13791372

13801373
runner, psf_runner = make_runners(prior, flux_guess, rng)
13811374

1382-
# Fit the ORIGINAL (psfex/mccd) PSF before metacal reconvolves it, using
1383-
# the same psf_runner (so the same psf_fit_prior and centroid). This is
1384-
# the PSF_ORIG family; metacal below produces the PSF_RECONV family. Run
1385-
# first so the original PSF is fit on its own stamp, distinct from the
1386-
# round, enlarged kernel metacal convolves back in. average_original_psf
1387-
# fits a COPY of each gal_obs.psf, so gal_obs_list reaches boot.go below
1388-
# pristine — no stray gmix to leak into MetacalFitGaussPSF's fallback.
1389-
psf_orig_res = average_original_psf(gal_obs_list, psf_runner)
1375+
# Fit the ORIGINAL (psfex/mccd) PSF before metacal reconvolves it. Use a
1376+
# psf_runner seeded from a snapshot of rng's state so this prefit does NOT
1377+
# advance the rng that boot.go consumes below — keeping the galaxy/shear
1378+
# results bit-identical to the no-prefit branch. average_original_psf fits a
1379+
# COPY of each gal_obs.psf, so gal_obs_list also reaches boot.go pristine.
1380+
prefit_rng = np.random.RandomState()
1381+
prefit_rng.set_state(rng.get_state())
1382+
_, prefit_psf_runner = make_runners(prior, flux_guess, prefit_rng)
1383+
psf_orig_res = average_original_psf(gal_obs_list, prefit_psf_runner)
13901384

13911385
metacal_pars = {
13921386
'types': ['noshear', '1p', '1m', '2p', '2m'],

src/shapepipe/modules/spread_model_package/spread_model.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88

99
import galsim
1010
import numpy as np
11+
from cs_util import size as cs_size
1112
from sqlitedict import SqliteDict
1213

1314
from shapepipe.pipeline import file_io
14-
from shapepipe.utilities import galaxy
1515

1616

1717
def get_sm(obj_vign, psf_vign, model_vign, weight_vign):
@@ -102,7 +102,7 @@ def get_model(sigma, flux, img_shape, pixel_scale=0.186):
102102
103103
"""
104104
# Get scale radius
105-
scale_radius = 1 / 16 * galaxy.sigma_to_fwhm(sigma, pixel_scale=pixel_scale)
105+
scale_radius = 1 / 16 * cs_size.sigma_to_fwhm(sigma) * pixel_scale
106106

107107
# Get galaxy model
108108
gal_obj = galsim.Exponential(scale_radius=scale_radius, flux=flux)

src/shapepipe/utilities/galaxy.py

Lines changed: 0 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -5,94 +5,3 @@
55
:Author: Martin Kilbinger <martin.kilbinger@cea.fr>
66
77
"""
8-
9-
import numpy as np
10-
from cs_util import size as cs_size
11-
12-
13-
def sigma_to_fwhm(sigma, pixel_scale=1.0):
14-
r"""Convert Sigma to FWHM.
15-
16-
Transform standard deviation of a 1D Gaussian, sigma, to FWHM
17-
(Full Width Half Maximum).
18-
19-
Thin wrapper over :func:`cs_util.size.sigma_to_fwhm`, the single
20-
source of truth for the bare ``FWHM = 2 sqrt(2 ln 2) sigma``
21-
conversion; this layer adds the optional ``pixel_scale`` rescaling
22-
and ShapePipe's input validation.
23-
24-
Parameters
25-
----------
26-
sigma : numpy.ndarray
27-
input standard deviation(s)
28-
pixel_scale : float, optional, default=1
29-
pixel size in arcsec, set to 1 if no scaling
30-
required
31-
32-
Returns
33-
-------
34-
fwhm : (array of) float
35-
output fwhm(s)
36-
37-
Raises
38-
------
39-
TypeError
40-
If ``sigma`` is not of type numpy array or float
41-
TypeError
42-
If ``sigma`` array values are not of type float
43-
TypeError
44-
If ``pixel_scale`` is not of type float
45-
ValueError
46-
If ``sigma`` array values are not greater than 0.0
47-
ValueError
48-
If ``sigma`` is not greater than 0.0
49-
ValueError
50-
If ``pixel_scale`` is not greater than 0.0
51-
52-
Notes
53-
-----
54-
To compute the FWHMh for a 1D Gaussian N(x), solve the equation
55-
56-
.. math::
57-
58-
N(x) = (\sigma \sqrt{2\pi})^{-1} \exp[x^2/2\sigma^2] = \frac 1 2 N(x)
59-
60-
61-
for :math:`x`. The FWHM is :math:`x + (-x) = 2x`. The solution is
62-
63-
.. math::
64-
65-
\textrm{FWHM} = 2 \sqrt(2 \ln 2) \sigma \approx 2.355 \sigma
66-
67-
"""
68-
if not isinstance(sigma, (np.ndarray, float)):
69-
raise TypeError(
70-
f"Sigma must be of type numpy array or float, not {type(sigma)}."
71-
)
72-
elif isinstance(sigma, np.ndarray) and sigma.dtype != np.float64:
73-
raise TypeError(
74-
f"Sigma array values must be of type float, not {sigma.dtype}."
75-
)
76-
77-
if not isinstance(pixel_scale, float):
78-
raise TypeError(
79-
f"The pixel scale must of type float, not {type(pixel_scale)}."
80-
)
81-
82-
if isinstance(sigma, np.ndarray) and np.any(sigma <= 0.0):
83-
raise ValueError(
84-
f"Found {sigma[sigma <=0].size} invalid standard deviation array "
85-
+ "values, all elements must to be greater than 0.0."
86-
)
87-
elif isinstance(sigma, float) and sigma <= 0.0:
88-
raise ValueError(
89-
f"Invalid standard deviation {sigma}, needs to be greater than "
90-
+ "0.0."
91-
)
92-
93-
if pixel_scale <= 0.0:
94-
raise ValueError(
95-
f"Invalid pixel scale {pixel_scale}, needs to be greater than 0.0."
96-
)
97-
98-
return cs_size.sigma_to_fwhm(sigma) * pixel_scale

tests/module/test_make_cat.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22
33
Drives ``SaveCatalogue._save_ngmix_data`` against a synthetic ngmix catalogue
44
to lock in the renamed PSF-column grammar
5-
``NGMIX_<COMPONENT>[_ERR]_<OBJECT>[_<SHEAR>]`` (shapepipe#749): the original
6-
image PSF (``PSF_ORIG``) and the metacal reconvolution kernel (``PSF_RECONV``)
7-
are independent fits of *different* PSFs, no longer the single aliased value of
8-
the pre-fix code.
5+
``NGMIX[m]_<COMPONENT>[_ERR]_<OBJECT>_<SHEAR>`` (shapepipe#749), plus the three
6+
OBJECT/SHEAR-less metadata columns (``NGMIX[m]_MCAL_FLAGS``, ``NGMIX_N_EPOCH``,
7+
``NGMIX_MCAL_TYPES_FAIL``): the original image PSF (``PSF_ORIG``) and the metacal
8+
reconvolution kernel (``PSF_RECONV``) are independent fits of *different* PSFs,
9+
no longer the single aliased value of the pre-fix code.
910
"""
1011

1112
import numpy as np

tests/module/test_ngmix.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ def test_compile_results_psf_families_are_unaliased():
238238
came from one ``average_multiepoch_psf`` result and so were byte-
239239
identical; here they must differ, each tracing its own source. The
240240
companion size un-aliasing is checked in
241-
``test_compile_results_size_columns_are_half_light_radii``.
241+
``test_compile_results_size_columns_are_unaliased``.
242242
"""
243243
from shapepipe.modules.ngmix_package.ngmix import Ngmix
244244

@@ -296,6 +296,14 @@ def test_compile_results_nan_fills_failed_fit_types():
296296
assert failed["flags"] == [0x8]
297297
assert failed["mcal_flags"] == [0x8] and failed["mcal_flags"][0] != 0
298298

299+
# The object-level PSF columns survive on a failed fit type: they are copied
300+
# outside the flags==0 guard, so the failed type keeps a full-length PSF row
301+
# (gating that copy on the galaxy flags would shorten it -> save crash).
302+
npt.assert_allclose(failed["T_psf_orig"], [ORIG_PSF_T])
303+
npt.assert_allclose(failed["T_psf_reconv"], [0.09])
304+
npt.assert_allclose(failed["g1_psf_orig"], [ORIG_PSF_G[0]])
305+
npt.assert_allclose(failed["g1_psf_reconv"], [RECONV_PSF_G[0]])
306+
299307
# successful types are untouched, but share the object's mcal_flags
300308
npt.assert_allclose(out["noshear"]["flux"], [100.0])
301309
assert out["noshear"]["flags"] == [0]

tests/module/test_psf_averaging_properties.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
import numpy as np
2222
import numpy.testing as npt
23+
import pytest
2324
from astropy.io import fits
2425
from hypothesis import given, settings
2526
from hypothesis import strategies as st
@@ -199,6 +200,31 @@ def test_single_survivor_returns_its_own_value(survivor, flagged_specs):
199200
assert out["n_epoch"] == 1
200201

201202

203+
@given(
204+
st.lists(
205+
st.tuples(_weight, st.integers(min_value=1, max_value=255)),
206+
min_size=1, max_size=5,
207+
)
208+
)
209+
@settings(deadline=None, max_examples=25)
210+
def test_all_epochs_failed_raises_zero_division(flagged_specs):
211+
"""All-epochs-failed is the contract that wsum == 0 raises, not returns 0/NaN.
212+
213+
An object whose PSF fit failed on every epoch reaches both PSF-family
214+
wrappers (reconv and orig) with zero survivors, so ``wsum`` stays 0. The
215+
core must raise ``ZeroDivisionError`` rather than silently divide — a
216+
regression returning NaN/0 would be caught here. Every input epoch is
217+
flagged (``flags != 0``) with an arbitrary positive weight, so no survivor
218+
can rescue the sum.
219+
"""
220+
flagged = [
221+
(_result(np.nan, np.nan, 1e6, np.nan, np.nan, np.nan, flags=f), w)
222+
for w, f in flagged_specs
223+
]
224+
with pytest.raises(ZeroDivisionError):
225+
_average_psf_fits(flagged)
226+
227+
202228
# --------------------------------------------------------------------------- #
203229
# (d) make_cat: an obj_id absent from the ngmix cat keeps its sentinel fill.
204230
# --------------------------------------------------------------------------- #

0 commit comments

Comments
 (0)