Coverage for tests/test_psfs.py: 60%
156 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-16 02:39 -0700
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-16 02:39 -0700
1# This file is part of lsst-images.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# Use of this source code is governed by a 3-clause BSD-style
10# license that can be found in the LICENSE file.
12from __future__ import annotations
14import os
15import warnings
16from typing import Any
18import numpy as np
19import pytest
21from lsst.images import Box, Image
22from lsst.images.describe import DescribableMixin, Report
23from lsst.images.psfs import GaussianPointSpreadFunction, PiffWrapper, PointSpreadFunction, PSFExWrapper
24from lsst.images.psfs._piff import _ArchivePiffWriter
25from lsst.images.tests import (
26 RoundtripFits,
27 RoundtripJson,
28 RoundtripNdf,
29 compare_psf_to_legacy,
30 reset_afw_mask_planes, # noqa: F401
31)
33try:
34 import h5py # noqa: F401
36 HAVE_H5PY = True
37except ImportError:
38 HAVE_H5PY = False
40try:
41 from lsst.afw.detection import Psf as LegacyPsf
42except ImportError:
43 type LegacyPsf = Any # type: ignore[no-redef]
45EXTERNAL_DATA_DIR = os.environ.get("TESTDATA_IMAGES_DIR", None)
47skip_no_h5py = pytest.mark.skipif(not HAVE_H5PY, reason="h5py is not installed")
50@pytest.fixture
51def legacy_piff_psf_and_bbox(reset_afw_mask_planes: None) -> tuple[LegacyPsf, Box]: # noqa: F811
52 """Return a legacy-wrapped Piff PSF and its bounding box.
54 Skips if TESTDATA_IMAGES_DIR is unset, piff is unavailable, or afw is
55 unavailable.
56 """
57 # reset_afw_mask_planes will have already skipped if afw is not available.
58 from lsst.afw.image import ExposureFitsReader
60 if EXTERNAL_DATA_DIR is None: 60 ↛ 62line 60 didn't jump to line 62 because the condition on line 60 was always true
61 pytest.skip("TESTDATA_IMAGES_DIR is not in the environment.")
62 try:
63 import piff # noqa: F401
64 except ImportError:
65 pytest.skip("'piff' could not be imported.")
66 filename = os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "visit_image.fits")
67 reader = ExposureFitsReader(filename)
68 legacy_psf = reader.readPsf()
69 bounds = Box.from_legacy(reader.readBBox())
70 return legacy_psf, bounds
73@pytest.fixture
74def legacy_psfex_psf_and_bbox(reset_afw_mask_planes: None) -> tuple[LegacyPsf, Box]: # noqa: F811
75 """Return a legacy PSFEx PSF and its bounding box
77 Skips if TESTDATA_IMAGES_DIR is unset, afw is unavailable, or psfex is
78 unavailable.
79 """
80 if EXTERNAL_DATA_DIR is None: 80 ↛ 82line 80 didn't jump to line 82 because the condition on line 80 was always true
81 pytest.skip("TESTDATA_IMAGES_DIR is not in the environment.")
82 try:
83 from lsst.afw.image import ExposureFitsReader
84 from lsst.meas.extensions.psfex import PsfexPsf # noqa: F401
85 except ImportError:
86 pytest.skip("'lsst.afw.image' or 'lsst.meas.extensions.psfex' could not be imported.")
87 filename = os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "preliminary_visit_image.fits")
88 reader = ExposureFitsReader(filename)
89 legacy_psf = reader.readPsf()
90 bounds = Box.from_legacy(reader.readBBox())
91 return legacy_psf, bounds
94def test_gaussian_psf_repr_pinned() -> None:
95 """GaussianPointSpreadFunction repr matches its documented form."""
96 psf = GaussianPointSpreadFunction(2.5, bounds=Box.factory[0:10, 0:10], stamp_size=21)
97 assert repr(psf) == f"GaussianPointSpreadFunction(2.5, stamp_size=21, bounds={psf.bounds!r})"
100def test_gaussian_psf_describe() -> None:
101 """GaussianPointSpreadFunction._describe returns a Report."""
102 psf = GaussianPointSpreadFunction(2.5, bounds=Box.factory[0:10, 0:10], stamp_size=21)
103 assert isinstance(psf, DescribableMixin)
104 report = psf._describe()
105 assert isinstance(report, Report)
106 assert report.type_name == "GaussianPointSpreadFunction"
107 labels = {f.label for f in report.fields}
108 assert "sigma" in labels
109 assert "stamp_size" in labels
110 assert "bounds" in labels
113def test_psf_base_describe() -> None:
114 """Base _describe uses type(self).__name__ and includes bounds/kernel_bbox.
116 GaussianPointSpreadFunction overrides _describe, so a minimal concrete
117 subclass is used to exercise the actual base implementation.
118 """
120 class _MinimalPSF(PointSpreadFunction):
121 """Minimal concrete subclass that exercises the base _describe path."""
123 @property
124 def bounds(self) -> Box:
125 return Box.factory[-5:5, -5:5]
127 @property
128 def kernel_bbox(self) -> Box:
129 return Box.factory[-2:3, -2:3]
131 def compute_kernel_image(self, *, x: float, y: float) -> Image:
132 arr = np.zeros((5, 5))
133 arr[2, 2] = 1.0
134 return Image(arr, bbox=self.kernel_bbox)
136 def compute_stellar_image(self, *, x: float, y: float) -> Image:
137 return self.compute_kernel_image(x=x, y=y)
139 def compute_stellar_bbox(self, *, x: float, y: float) -> Box:
140 return self.kernel_bbox
142 psf = _MinimalPSF()
143 report = psf._describe()
144 assert report.type_name == "_MinimalPSF"
145 labels = {f.label for f in report.fields}
146 assert "bounds" in labels
147 assert "kernel_bbox" in labels
150def test_gaussian() -> None:
151 """Test the built-in Gaussian PSF implementation."""
152 bounds = Box.factory[-1024:1024, -2048:2048]
153 psf = GaussianPointSpreadFunction(2.5, bounds=bounds, stamp_size=33)
154 assert psf.bounds == bounds
156 kernel = psf.compute_kernel_image(x=5.0, y=3.0)
157 assert kernel.bbox == psf.kernel_bbox
158 assert abs(float(kernel.array.sum()) - 1.0) < 1e-6
159 center = kernel.array.shape[0] // 2
160 assert np.unravel_index(np.argmax(kernel.array), kernel.array.shape) == (center, center)
162 stellar = psf.compute_stellar_image(x=5.25, y=3.75)
163 assert stellar.bbox == psf.compute_stellar_bbox(x=5.25, y=3.75)
164 assert abs(float(stellar.array.sum()) - 1.0) < 1e-6
165 assert stellar.array[center - 1, center] > stellar.array[center + 1, center]
166 assert stellar.array[center, center] > stellar.array[center, center - 1]
167 assert stellar.array[center, center] > stellar.array[center - 1, center]
169 with RoundtripFits(psf) as roundtrip:
170 assert roundtrip.result == psf, f"{roundtrip.result} != {psf}"
172 with pytest.raises(ValueError, match="stamp_size must be odd"):
173 # Even stamp size.
174 GaussianPointSpreadFunction(2.5, bounds=bounds, stamp_size=32)
176 with pytest.raises(ValueError, match="stamp_size must be positive"):
177 # Negative stamp size.
178 GaussianPointSpreadFunction(2.5, bounds=bounds, stamp_size=-33)
180 with pytest.raises(ValueError, match="sigma must be positive"):
181 # Negative sigma.
182 GaussianPointSpreadFunction(-2.5, bounds=bounds, stamp_size=33)
185def test_piff_writer_normalizes_tuple_metadata(): # intentionally untyped
186 """Test that Piff metadata is normalized to JSON-like values."""
187 writer = _ArchivePiffWriter()
188 writer.write_struct(
189 "interp",
190 {
191 "keys": ("u", "v"),
192 "scale": np.float64(1.5),
193 "flags": [np.bool_(True), np.int64(3)],
194 },
195 )
196 model = writer.serialize(None) # type: ignore[arg-type]
197 assert model.structs["interp"]["keys"] == ["u", "v"]
198 assert model.structs["interp"]["scale"] == 1.5
199 assert model.structs["interp"]["flags"] == [True, 3]
200 with warnings.catch_warnings():
201 warnings.simplefilter("error", UserWarning)
202 model.model_dump_json()
205def test_piff(legacy_piff_psf_and_bbox: tuple[LegacyPsf, Box]) -> None:
206 """Test round-tripping a legacy Piff PSF through FITS and JSON archives,
207 and converting it back to a legacy PSF.
208 """
209 from piff import PSF
211 legacy_psf, bounds = legacy_piff_psf_and_bbox
212 psf = PointSpreadFunction.from_legacy(legacy_psf, bounds)
213 assert isinstance(psf, PiffWrapper)
214 assert psf.bounds == bounds
215 assert isinstance(psf.piff_psf, PSF)
216 compare_psf_to_legacy(psf, legacy_psf)
217 with RoundtripFits(psf) as roundtrip1:
218 pass
219 compare_psf_to_legacy(roundtrip1.result, legacy_psf)
220 with RoundtripJson(psf) as roundtrip2:
221 pass
222 compare_psf_to_legacy(roundtrip2.result, legacy_psf)
223 legacy_psf_2 = roundtrip1.result.to_legacy()
224 compare_psf_to_legacy(psf, legacy_psf_2)
225 assert legacy_psf.getAveragePosition() == legacy_psf_2.getAveragePosition()
228@skip_no_h5py
229def test_piff_ndf_roundtrip(legacy_piff_psf_and_bbox: tuple[LegacyPsf, Box]) -> None:
230 """Test round-tripping a legacy Piff PSF through an NDF archive."""
231 legacy_psf, bounds = legacy_piff_psf_and_bbox
232 psf = PointSpreadFunction.from_legacy(legacy_psf, bounds)
233 with RoundtripNdf(psf) as roundtrip:
234 pass
235 compare_psf_to_legacy(roundtrip.result, legacy_psf)
238def test_psfex(legacy_psfex_psf_and_bbox: tuple[LegacyPsf, Box]) -> None:
239 """Test wrapping a legacy PSFEx PSF and round-tripping through FITS and
240 JSON.
241 """
242 from lsst.meas.extensions.psfex import PsfexPsf
244 legacy_psf, bounds = legacy_psfex_psf_and_bbox
245 psf = PointSpreadFunction.from_legacy(legacy_psf, bounds)
246 assert isinstance(psf, PSFExWrapper)
247 assert psf.bounds == bounds
248 assert isinstance(psf.legacy_psf, PsfexPsf)
249 compare_psf_to_legacy(psf, legacy_psf)
250 with RoundtripFits(psf) as roundtrip1:
251 pass
252 compare_psf_to_legacy(roundtrip1.result, legacy_psf)
253 with RoundtripJson(psf) as roundtrip2:
254 pass
255 compare_psf_to_legacy(roundtrip2.result, legacy_psf)