Skip to content
Open
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
40 changes: 37 additions & 3 deletions src/shapepipe/testing/simulate.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ def make_data(
psf_shear=(0.0, 0.0),
wcs=None,
return_centers=False,
psf_model_fwhm_ratio=1.0,
psf_model_shear=None,
):
"""Simulate an exponential galaxy with Moffat PSF.

Expand Down Expand Up @@ -55,8 +57,7 @@ def make_data(
Ellipticity (g1, g2) applied to the Moffat PSF. Default (0, 0), a
round PSF. A non-round PSF is what the PSF-leakage / additive-bias
guardrail needs: the PSF carries a shape that an unbiased deconvolution
must remove from a round galaxy. The same sheared PSF is used both to
convolve the object and as the PSF model stamp.
must remove from a round galaxy.
wcs : galsim.BaseWCS, optional
Uniform drawing WCS. Default None means ``galsim.PixelScale
(pixel_scale)`` with the legacy world-space sub-pixel shift draw —
Expand All @@ -74,6 +75,24 @@ def make_data(
``galsim.PositionD`` in galsim image coordinates (1-based; stamp
centre is ``(img_size + 1) / 2``). Default False keeps the legacy
6-tuple return.
psf_model_fwhm_ratio : float, optional
Multiplicative size error injected into the PSF *model* stamp handed to
ngmix, relative to the *true* PSF that convolves the galaxy. Default 1.0
(no error: the same PSF object renders the galaxy and the model stamp,
byte-for-byte identical to the legacy behaviour). A value != 1.0 makes
the model PSF the wrong size — over-sized (>1) over-deconvolves, under-
sized (<1) leaves residual smoothing — so metacal deconvolves by a PSF
that does not match the one in the data. This is the controlled-PSF-
model-error knob: it tests whether metacal's response correction (which
is computed with the same wrong model) absorbs a deconvolution error,
the one shape-measurement systematic the zero-error sim could not probe.
psf_model_shear : tuple of float or None, optional
Extra ellipticity (Δg1, Δg2) added to the PSF *model* stamp on top of
``psf_shear``, relative to the true PSF. Default None (no error). A
non-zero value mismodels the PSF shape, injecting a controlled additive
(PSF-leakage) error into the recovered shear. When both this is None and
``psf_model_fwhm_ratio`` is 1.0 the model PSF *is* the true PSF object,
so the output is byte-for-byte the legacy result.

Returns
-------
Expand Down Expand Up @@ -132,7 +151,22 @@ def draw_shift():
),
).shift(dx, dy)

psf_im_ = psf.drawImage(nx=img_size, ny=img_size, wcs=wcs)
# The PSF *model* stamp handed to ngmix. By default it is the very same
# object that convolved the galaxy (zero model error, byte-for-byte). A
# size and/or shape error makes it differ from the true PSF, so metacal
# deconvolves by the wrong PSF — the systematic the zero-error sim could
# not exercise.
if psf_model_fwhm_ratio == 1.0 and psf_model_shear is None:
psf_model = psf
else:
psf_model = galsim.Moffat(
beta=2.5, fwhm=psf_fwhm * psf_model_fwhm_ratio
).shear(
g1=psf_shear[0] + (psf_model_shear[0] if psf_model_shear else 0.0),
g2=psf_shear[1] + (psf_model_shear[1] if psf_model_shear else 0.0),
)

psf_im_ = psf_model.drawImage(nx=img_size, ny=img_size, wcs=wcs)
psfs_sigmas.append(galsim.hsm.FindAdaptiveMom(psf_im_).moments_sigma)
psf_im = psf_im_.array.astype(np.float64)
im = obj.drawImage(nx=img_size, ny=img_size, wcs=wcs).array.astype(np.float64)
Expand Down
48 changes: 48 additions & 0 deletions tests/module/test_simulate_psf_model_error.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Guardrail tests for the PSF-model-error extension of ``make_data``.

``make_data`` renders the galaxy with the *true* PSF and, by default, hands ngmix
that same PSF as the model stamp (zero model error). The ``psf_model_fwhm_ratio``
and ``psf_model_shear`` knobs let the model stamp differ from the true PSF, so a
controlled deconvolution error can be injected. These tests pin the two
properties the digital twin's PSF-model-error sweep relies on:

1. with no error injected the output is byte-for-byte the legacy result, and
2. an injected error perturbs ONLY the PSF model stamp, never the galaxy image
(the galaxy is always convolved with the true PSF).
"""

import numpy as np

from shapepipe.testing.simulate import make_data

_KW = dict(shear=(0.02, 0.0), noise=1e-4, n_epochs=2, img_size=51,
pixel_scale=0.1857)


def _run(seed=123, **extra):
gals, psfs, *_ = make_data(rng=np.random.RandomState(seed), **_KW, **extra)
return np.array(gals), np.array(psfs)


def test_no_error_is_byte_for_byte():
"""Explicit no-error kwargs reproduce the implicit default exactly."""
g0, p0 = _run()
g1, p1 = _run(psf_model_fwhm_ratio=1.0, psf_model_shear=None)
np.testing.assert_array_equal(g0, g1)
np.testing.assert_array_equal(p0, p1)


def test_size_error_touches_only_the_psf_stamp():
"""A model size error changes the PSF stamp but leaves the galaxy intact."""
g0, p0 = _run()
g1, p1 = _run(psf_model_fwhm_ratio=1.05)
np.testing.assert_array_equal(g0, g1) # galaxy uses the TRUE PSF
assert not np.allclose(p0, p1) # model stamp is perturbed


def test_ellipticity_error_touches_only_the_psf_stamp():
"""A model ellipticity error changes the PSF stamp, not the galaxy."""
g0, p0 = _run()
g1, p1 = _run(psf_model_shear=(0.03, 0.0))
np.testing.assert_array_equal(g0, g1)
assert not np.allclose(p0, p1)