Coverage for tests/test_approximate_from_legacy.py: 26%
264 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-03 01:36 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-03 01:36 +0000
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.
12"""Unit tests for the missing/partial-cell sampling rules of conversions
13from legacy (non-cell) coadd components.
14"""
16from __future__ import annotations
18import dataclasses
19import os
20import pickle
21import types
22from collections.abc import Iterable
23from typing import Any
25import numpy as np
26import pytest
28from lsst.images import XY, YX, BoundsError, Box, Image, SkyProjection, TractFrame
29from lsst.images.cells import (
30 CellField,
31 CellGridBounds,
32 CellIJ,
33 CellPointSpreadFunction,
34 CoaddProvenance,
35 PatchDefinition,
36)
37from lsst.images.tests import (
38 DP2_COADD_DATA_ID,
39 assert_values_equal,
40 cell_center_points,
41 compare_cell_coadd_provenance_to_legacy_lazy,
42 compare_psf_to_legacy,
43 reset_afw_mask_planes, # noqa: F401
44)
46try:
47 from lsst.afw.detection import InvalidPsfError
48 from lsst.afw.geom import makeCdMatrix, makeSkyWcs
49 from lsst.afw.image import Exposure as LegacyExposure
50 from lsst.afw.image import ExposureFitsReader
51 from lsst.afw.math import ChebyshevBoundedField as LegacyChebyshevBoundedField
52 from lsst.afw.table import ExposureCatalog as LegacyExposureCatalog
53 from lsst.geom import Box2D as LegacyBox2D
54 from lsst.geom import Box2I as LegacyBox2I
55 from lsst.geom import Point2I as LegacyPoint2I
56 from lsst.meas.algorithms import CoaddBoundedField as LegacyCoaddBoundedField
57 from lsst.meas.algorithms import CoaddBoundedFieldElement as LegacyCoaddBoundedFieldElement
58 from lsst.meas.algorithms import CoaddPsf as LegacyCoaddPsf
59 from lsst.pex.exceptions import DomainError
60 from lsst.skymap import TractInfo as LegacyTractInfo
62 HAVE_LEGACY = True
63except ImportError:
64 type LegacyChebyshevBoundedField = Any # type: ignore[no-redef]
65 type LegacyExposure = Any # type: ignore[no-redef]
66 type LegacyExposureCatalog = Any # type: ignore[no-redef]
67 type LegacyBox2I = Any # type: ignore[no-redef]
68 type LegacyPoint2I = Any # type: ignore[no-redef]
69 type LegacyCoaddBoundedFieldElement = Any # type: ignore[no-redef]
70 type LegacyCoaddBoundedField = Any # type: ignore[no-redef]
71 type LegacyCoaddPsf = Any # type: ignore[no-redef]
72 type LegacyTractInfo = Any # type: ignore[no-redef]
73 HAVE_LEGACY = False
76EXTERNAL_DATA_DIR = os.environ.get("TESTDATA_IMAGES_DIR", None)
78# Index into the sparse cell's ``Box.boundary()`` corners whose overlapping
79# inputs are also (in addition to the center's inputs).
80PARTIAL_CORNER_INDEX = 0
81# Number of the sparse cell's corners that stay valid under ``trim_partial`` (4
82# corners minus the dropped one).
83PARTIAL_N_VALID_CORNERS = 3
86@dataclasses.dataclass(frozen=True)
87class _TrimmedLegacyCoaddComponents:
88 """Legacy coadd components rebuilt without the inputs covering a point."""
90 ccds: LegacyExposureCatalog
91 psf: LegacyCoaddPsf
92 ap_corrections: dict[str, LegacyCoaddBoundedField]
93 dropped: frozenset[tuple[int, int]] # dropped (visit, detector) pairs
96@dataclasses.dataclass(frozen=True)
97class _LegacyTestCoadd:
98 """Test data loaded from EXTERNAL_DATA_DIR."""
100 coadd: LegacyExposure
101 tract_info: LegacyTractInfo
102 patch: PatchDefinition
103 bounds: CellGridBounds
105 @property
106 def sky_projection(self) -> SkyProjection[TractFrame]:
107 """The coadd's sky projection in its tract frame."""
108 tract_bbox = Box.from_legacy(self.tract_info.getBBox())
109 return SkyProjection.from_legacy(
110 self.coadd.getWcs(),
111 TractFrame(
112 skymap=DP2_COADD_DATA_ID["skymap"],
113 tract=DP2_COADD_DATA_ID["tract"],
114 bbox=tract_bbox,
115 ),
116 pixel_bounds=tract_bbox,
117 )
119 @property
120 def sparse_cell_index(self) -> CellIJ:
121 """Index of the sparse cell that has an invalid corner or center,
122 depending on the test.
123 """
124 return next(iter(self.bounds.cell_indices()))
126 @property
127 def sparse_cell_box(self) -> Box:
128 """Pixel bounding box of the sparse cell."""
129 return self.patch.cells.bbox_of(self.sparse_cell_index)
131 @property
132 def control_cell_index(self) -> CellIJ:
133 """Index of a control cell: a regular cell fully covered by inputs."""
134 return self.sparse_cell_index + CellIJ(i=0, j=1)
136 @property
137 def control_cell_box(self) -> Box:
138 """Pixel bounding box of the control cell."""
139 return self.patch.cells.bbox_of(self.control_cell_index)
141 def trim(
142 self,
143 *points: XY[float] | YX[int],
144 ap_corr_names: Iterable[str] = (
145 "base_PsfFlux_instFlux",
146 "base_PsfFlux_instFluxErr",
147 "base_GaussianFlux_instFluxErr",
148 "ext_convolved_ConvolvedFlux_0_3_3_instFlux",
149 ),
150 ) -> _TrimmedLegacyCoaddComponents:
151 """Trim the original coadd's inputs to leave some of the sparse
152 cell uncovered.
154 Parameters
155 ----------
156 *points
157 Points that should be left uncovered by inputs.
158 ap_corr_names
159 Aperture correction fields to propagate (we don't need them all
160 for testing).
161 """
162 coadd_wcs = self.coadd.getWcs()
164 def _element_covers(element: LegacyCoaddBoundedFieldElement, point: XY[float] | YX[int]) -> bool:
165 """Test whether one visit-level ApCorr covers a coadd point."""
166 projected = element.wcs.skyToPixel(coadd_wcs.pixelToSky(point.to_legacy_float_point()))
167 # Box2D.contains rejects NaN projections (distant elements outside
168 # the TAN domain project here as NaN).
169 return LegacyBox2D(element.field.getBBox()).contains(projected) and element.validPolygon.contains(
170 projected
171 )
173 ccds = self.coadd.info.getCoaddInputs().ccds
174 dropped: set[tuple[int, int]] = set()
175 for point in points:
176 for record in ccds.subsetContaining(
177 point.to_legacy_float_point(), coadd_wcs, includeValidPolygon=True
178 ):
179 dropped.add((record.get("visit"), record.get("ccd")))
180 trimmed = ccds.copy()
181 # 'del catalog[i]' only supports one row at a time; drop from the back.
182 drop_rows = sorted(
183 i for i, record in enumerate(ccds) if (record.get("visit"), record.get("ccd")) in dropped
184 )
185 for i in reversed(drop_rows):
186 del trimmed[i]
187 psf = LegacyCoaddPsf(trimmed, coadd_wcs)
188 fields: dict[str, Any] = {}
189 for name in ap_corr_names:
190 stored = self.coadd.info.getApCorrMap()[name]
191 keep = [
192 element
193 for element in stored.getElements()
194 if not any(_element_covers(element, point) for point in points)
195 ]
196 assert keep, f"every element of {name} covers the trimmed points"
197 field = LegacyCoaddBoundedField(stored.getBBox(), coadd_wcs, keep)
198 # Check that the default throw behavior is what other tests expect
199 # so we don't miss out on coverage of both modes.
200 assert field.getThrowOnMissing(), f"{name} does not throw at uncovered points"
201 fields[name] = field
202 return _TrimmedLegacyCoaddComponents(
203 ccds=trimmed, psf=psf, ap_corrections=fields, dropped=frozenset(dropped)
204 )
207@pytest.fixture
208def legacy_test_coadd(reset_afw_mask_planes: None) -> _LegacyTestCoadd: # noqa: F811
209 """Return the DP2 template_coadd and its derived cell bounds."""
210 if not HAVE_LEGACY: 210 ↛ 211line 210 didn't jump to line 211 because the condition on line 210 was never true
211 pytest.skip("Legacy packages (lsst.afw, etc) could not be imported.")
212 if EXTERNAL_DATA_DIR is None: 212 ↛ 214line 212 didn't jump to line 214 because the condition on line 212 was always true
213 pytest.skip("TESTDATA_IMAGES_DIR is not in the environment.")
214 filename = os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "template_coadd.fits")
215 coadd = ExposureFitsReader(filename).read()
216 filename = os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "skyMap.pickle")
217 with open(filename, "rb") as stream:
218 skymap = pickle.load(stream)
219 tract_info = skymap[DP2_COADD_DATA_ID["tract"]]
220 patch = PatchDefinition.from_legacy_patch_info(tract_info[DP2_COADD_DATA_ID["patch"]])
221 # Cell bounds over the four sparsest cells of the patch (2x2 region).
222 # Tract-frame origin of the sparsest cell of patch 43: its center is
223 # covered by only 20 of the fixture's 103 inputs, while all four outermost
224 # pixel centers keep coverage from at least one other input.
225 region = Box.from_shape(patch.cells.cell_shape.map(lambda s: 2 * s), start=YX(y=12450, x=9600))
226 bounds = CellGridBounds(grid=patch.cells, bbox=patch.cells.bbox)[region]
227 return _LegacyTestCoadd(coadd=coadd, tract_info=tract_info, patch=patch, bounds=bounds)
230@pytest.fixture
231def trim_missing(legacy_test_coadd: _LegacyTestCoadd) -> _TrimmedLegacyCoaddComponents:
232 """Components with all inputs covering the sparse cell dropped."""
233 return legacy_test_coadd.trim(
234 legacy_test_coadd.sparse_cell_box.center, *legacy_test_coadd.sparse_cell_box.boundary()
235 )
238@pytest.fixture
239def trim_partial(legacy_test_coadd: _LegacyTestCoadd) -> _TrimmedLegacyCoaddComponents:
240 """Components with the inputs covering the sparse cell's center and one
241 corner dropped, leaving the other three corners valid.
242 """
243 return legacy_test_coadd.trim(
244 legacy_test_coadd.sparse_cell_box.center,
245 list(legacy_test_coadd.sparse_cell_box.boundary())[PARTIAL_CORNER_INDEX],
246 )
249def test_trim_missing_fixture(
250 legacy_test_coadd: _LegacyTestCoadd, trim_missing: _TrimmedLegacyCoaddComponents
251) -> None:
252 """Test that the `trim_missing` test fixture does what it claims."""
253 sparse_box = legacy_test_coadd.sparse_cell_box
254 center = sparse_box.center.to_legacy_float_point()
255 corners = [corner.to_legacy_float_point() for corner in sparse_box.boundary()]
256 for point in (center, *corners):
257 with pytest.raises(InvalidPsfError):
258 trim_missing.psf.computeKernelImage(point)
259 for field in trim_missing.ap_corrections.values():
260 with pytest.raises(DomainError):
261 field.evaluate(point)
262 other_center = legacy_test_coadd.control_cell_box.center.to_legacy_float_point()
263 trim_missing.psf.computeKernelImage(other_center) # must not raise
264 for field in trim_missing.ap_corrections.values():
265 assert np.isfinite(field.evaluate(other_center))
268def test_trim_partial_fixture(
269 legacy_test_coadd: _LegacyTestCoadd, trim_partial: _TrimmedLegacyCoaddComponents
270) -> None:
271 """Test that the `trim_partial` fixture does what it claims."""
272 sparse_box = legacy_test_coadd.sparse_cell_box
273 corners = [corner.to_legacy_float_point() for corner in sparse_box.boundary()]
274 for point in (sparse_box.center.to_legacy_float_point(), corners[PARTIAL_CORNER_INDEX]):
275 with pytest.raises(InvalidPsfError):
276 trim_partial.psf.computeKernelImage(point)
277 for field in trim_partial.ap_corrections.values():
278 with pytest.raises(DomainError):
279 field.evaluate(point)
280 valid_others = []
281 for k, corner in enumerate(corners):
282 if k == PARTIAL_CORNER_INDEX:
283 continue
284 try:
285 trim_partial.psf.computeKernelImage(corner)
286 finite = all(
287 np.isfinite(field.evaluate(corner)) for field in trim_partial.ap_corrections.values()
288 )
289 except (InvalidPsfError, DomainError):
290 finite = False
291 if finite:
292 valid_others.append(k)
293 # Exact count and identity: exactly the three corners other than the
294 # dropped one stay valid (the partial-corner regime the averaging tests
295 # below rely on).
296 assert valid_others == [k for k in range(len(corners)) if k != PARTIAL_CORNER_INDEX]
297 assert len(valid_others) == PARTIAL_N_VALID_CORNERS
298 other_center = legacy_test_coadd.control_cell_box.center.to_legacy_float_point()
299 trim_partial.psf.computeKernelImage(other_center) # must not raise
300 for field in trim_partial.ap_corrections.values():
301 assert np.isfinite(field.evaluate(other_center))
304def test_psf_valid_cells_store_center_stamps(
305 legacy_test_coadd: _LegacyTestCoadd, trim_missing: _TrimmedLegacyCoaddComponents
306) -> None:
307 """Test that where the center is valid, the stored stamp is exactly the
308 legacy kernel image at the center (zero-padded into the shared kernel
309 bbox).
310 """
311 psf = CellPointSpreadFunction.approximate_from_legacy(trim_missing.psf, legacy_test_coadd.bounds)
312 stamp = Image.from_legacy(
313 trim_missing.psf.computeKernelImage(legacy_test_coadd.control_cell_box.center.to_legacy_float_point())
314 )
315 expected = np.zeros(psf.kernel_bbox.shape, dtype=np.float64)
316 slices = stamp.bbox.slice_within(psf.kernel_bbox)
317 expected[slices.y, slices.x] = stamp.array
318 assert psf[legacy_test_coadd.control_cell_index] == Image(expected, bbox=psf.kernel_bbox)
321def test_psf_partial_corner_fallback_averages_valid_corners(
322 legacy_test_coadd: _LegacyTestCoadd, trim_partial: _TrimmedLegacyCoaddComponents
323) -> None:
324 """Test that the cell whose center and one corner are invalid stores the
325 mean of only the valid corner stamps.
326 """
327 psf = CellPointSpreadFunction.approximate_from_legacy(trim_partial.psf, legacy_test_coadd.bounds)
328 assert legacy_test_coadd.sparse_cell_index not in psf.bounds.missing
329 corners = [corner.to_legacy_float_point() for corner in legacy_test_coadd.sparse_cell_box.boundary()]
330 total = np.zeros(psf.kernel_bbox.shape, dtype=np.float64)
331 n_valid = 0
332 for k, corner in enumerate(corners):
333 if k == PARTIAL_CORNER_INDEX:
334 continue
335 stamp = Image.from_legacy(trim_partial.psf.computeKernelImage(corner))
336 slices = stamp.bbox.slice_within(psf.kernel_bbox)
337 total[slices.y, slices.x] += stamp.array
338 n_valid += 1
339 assert n_valid == PARTIAL_N_VALID_CORNERS
340 expected = total / n_valid
341 stored = psf[legacy_test_coadd.sparse_cell_index]
342 assert stored.bbox == psf.kernel_bbox
343 # Bitwise: the conversion sums the same valid stamps in the same
344 # boundary() order, then divides in place by the number of valid stamps.
345 assert np.array_equal(stored.array, expected)
348def test_psf_missing_cells_marked_and_nan(
349 legacy_test_coadd: _LegacyTestCoadd, trim_missing: _TrimmedLegacyCoaddComponents
350) -> None:
351 """Test that a fully-invalid cell is added to bounds.missing and its
352 array slice is NaN (the documented convention), and that pre-existing
353 missing cells survive the bounds reconstruction.
354 """
355 pre_existing = CellGridBounds(
356 grid=legacy_test_coadd.bounds.grid,
357 bbox=legacy_test_coadd.bounds.bbox,
358 missing=frozenset({legacy_test_coadd.control_cell_index}),
359 )
360 psf = CellPointSpreadFunction.approximate_from_legacy(trim_missing.psf, pre_existing)
361 assert legacy_test_coadd.sparse_cell_index in psf.bounds.missing
362 assert legacy_test_coadd.control_cell_index in psf.bounds.missing
363 # We have to access the private array to see that it's NaN (public
364 # interface
365 # raises).
366 index = legacy_test_coadd.sparse_cell_index - psf.bounds.subgrid_start
367 assert np.isnan(psf._array[index.i, index.j]).all()
370def test_psf_raises_when_no_cell_is_valid(
371 legacy_test_coadd: _LegacyTestCoadd, trim_missing: _TrimmedLegacyCoaddComponents
372) -> None:
373 """Test that conversion of a region with no valid sample anywhere is an
374 error, not an all-missing result.
375 """
376 single = legacy_test_coadd.bounds[
377 legacy_test_coadd.bounds.grid.bbox_of(legacy_test_coadd.sparse_cell_index)
378 ]
379 with pytest.raises(ValueError, match="could not be evaluated"):
380 CellPointSpreadFunction.approximate_from_legacy(trim_missing.psf, single)
383def test_field_valid_cells_store_center_values(
384 legacy_test_coadd: _LegacyTestCoadd, trim_missing: _TrimmedLegacyCoaddComponents
385) -> None:
386 """Test that where the center is valid, the stored value is exactly the
387 legacy evaluation at the center.
388 """
389 for name, legacy_field in trim_missing.ap_corrections.items():
390 field = CellField.approximate_from_legacy(legacy_field, legacy_test_coadd.bounds)
391 assert field.value_in_cell(legacy_test_coadd.control_cell_index) == legacy_field.evaluate(
392 legacy_test_coadd.control_cell_box.center.to_legacy_float_point()
393 ), name
396def test_field_partial_corner_fallback_averages_valid_only(
397 legacy_test_coadd: _LegacyTestCoadd, trim_partial: _TrimmedLegacyCoaddComponents
398) -> None:
399 """Test that the cell whose center and one corner are invalid stores the
400 mean of only the valid corner evaluations. A mean over the valid samples
401 differs from a sum over them divided by four, so this pins the divisor
402 against a regression to the corner count.
403 """
404 cell_a = legacy_test_coadd.sparse_cell_index
405 corners = [corner.to_legacy_float_point() for corner in legacy_test_coadd.sparse_cell_box.boundary()]
406 for name, legacy_field in trim_partial.ap_corrections.items():
407 field = CellField.approximate_from_legacy(legacy_field, legacy_test_coadd.bounds)
408 assert cell_a not in field.bounds.missing, name
409 values = [
410 legacy_field.evaluate(corner) for k, corner in enumerate(corners) if k != PARTIAL_CORNER_INDEX
411 ]
412 assert len(values) == PARTIAL_N_VALID_CORNERS
413 assert all(np.isfinite(values)), name
414 assert_values_equal(
415 field.value_in_cell(cell_a),
416 float(np.mean(values)),
417 rtol=1e-13,
418 label=f"partial fallback cell value [{name}]",
419 )
422def test_field_missing_cells_marked_and_nan(
423 legacy_test_coadd: _LegacyTestCoadd, trim_missing: _TrimmedLegacyCoaddComponents
424) -> None:
425 """Test that a cell where center and all corners raise is marked missing,
426 its array entry is NaN (the documented convention), and the accessor
427 refuses missing cells.
428 """
429 for name, legacy_field in trim_missing.ap_corrections.items():
430 field = CellField.approximate_from_legacy(legacy_field, legacy_test_coadd.bounds)
431 assert legacy_test_coadd.sparse_cell_index in field.bounds.missing, name
432 # We have to access the private array to see that it's NaN (public
433 # interface raises).
434 index = legacy_test_coadd.sparse_cell_index - field.bounds.subgrid_start
435 assert np.isnan(field._array[index.i, index.j]), name
436 with pytest.raises(BoundsError):
437 field.value_in_cell(legacy_test_coadd.sparse_cell_index)
440def test_provenance_skips_missing_cells_and_matches_trimmed_inputs(
441 legacy_test_coadd: _LegacyTestCoadd,
442 trim_missing: _TrimmedLegacyCoaddComponents,
443) -> None:
444 """Test that provenance built from the trimmed catalog contributes
445 nothing to the missing cell and is consistent with the trimmed catalog's
446 own geometry.
447 """
448 psf = CellPointSpreadFunction.approximate_from_legacy(trim_missing.psf, legacy_test_coadd.bounds)
449 bounds = psf.bounds
450 cell_a = legacy_test_coadd.sparse_cell_index
451 assert cell_a in bounds.missing
453 provenance = CoaddProvenance.from_legacy_lazy(
454 # from_legacy_lazy takes a CoaddInputs-like object and reads only
455 # its .ccds attribute (lsst/images/cells/_provenance.py).
456 types.SimpleNamespace(ccds=trim_missing.ccds),
457 bounds=bounds,
458 sky_projection=legacy_test_coadd.sky_projection,
459 instrument="LSSTCam",
460 )
462 contribution_cells = {
463 CellIJ(i=int(row["cell_i"]), j=int(row["cell_j"])) for row in provenance.contributions
464 }
465 assert cell_a not in contribution_cells
466 # Every remaining cell here is covered by surviving inputs, so
467 # contributions cover exactly the non-missing cells of the region.
468 assert contribution_cells == set(bounds.cell_indices())
469 # The provenance inputs table carries (visit, detector) identities;
470 # from_legacy_lazy legitimately skips inputs that do not overlap the
471 # region bounds (the NoOverlapError path in _provenance.py), so the
472 # shipped helper asserts the identities are a subset of the trimmed
473 # catalog rather than equal to it.
475 trimmed_legacy = types.SimpleNamespace(
476 info=types.SimpleNamespace(getCoaddInputs=lambda: types.SimpleNamespace(ccds=trim_missing.ccds)),
477 getWcs=lambda: legacy_test_coadd.coadd.getWcs(),
478 )
479 compare_cell_coadd_provenance_to_legacy_lazy(provenance, trimmed_legacy, bounds)
482def _move_record_far_away(record: Any, coadd_wcs: Any) -> None:
483 """Give a coadd-input record a WCS centered on the far side of the sky
484 from the coadd's tangent point, outside the coadd projection's domain.
485 """
486 from lsst.geom import Point2D, SpherePoint, degrees
488 origin = coadd_wcs.getSkyOrigin()
489 antipode = SpherePoint(origin.getLongitude() + 180.0 * degrees, -origin.getLatitude())
490 cd_matrix = makeCdMatrix(scale=record.getWcs().getPixelScale())
491 record.setWcs(makeSkyWcs(crpix=Point2D(0.0, 0.0), crval=antipode, cdMatrix=cd_matrix))
494def _provenance_keys(provenance: CoaddProvenance) -> set[tuple[int, int]]:
495 return {(int(row["visit"]), int(row["detector"])) for row in provenance.inputs}
498def test_provenance_skips_inputs_outside_projection_domain(legacy_test_coadd: _LegacyTestCoadd) -> None:
499 """Test that an input whose footprint cannot be projected onto the coadd
500 is dropped rather than aborting the conversion.
501 """
502 ccds = legacy_test_coadd.coadd.info.getCoaddInputs().ccds
503 expected = CoaddProvenance.from_legacy_lazy(
504 types.SimpleNamespace(ccds=ccds),
505 bounds=legacy_test_coadd.bounds,
506 sky_projection=legacy_test_coadd.sky_projection,
507 instrument="LSSTCam",
508 )
509 moved = ccds.copy(deep=True)
510 # Move an input that does overlap the test region, so the drop is visible.
511 moved_key = next(iter(sorted(_provenance_keys(expected))))
512 for record in moved:
513 if (int(record.get("visit")), int(record.get("ccd"))) == moved_key:
514 _move_record_far_away(record, legacy_test_coadd.coadd.getWcs())
515 provenance = CoaddProvenance.from_legacy_lazy(
516 types.SimpleNamespace(ccds=moved),
517 bounds=legacy_test_coadd.bounds,
518 sky_projection=legacy_test_coadd.sky_projection,
519 instrument="LSSTCam",
520 )
521 assert _provenance_keys(provenance) == _provenance_keys(expected) - {moved_key}
524def test_compare_psf_to_legacy_approximate_kernel_bbox(legacy_test_coadd: _LegacyTestCoadd) -> None:
525 """Test that the approximate PSF comparison accepts a stored kernel bbox
526 larger than the legacy kernel bbox at a point (as the shared bbox of a
527 converted cell PSF generally is), while the exact comparison does not.
528 """
529 legacy_psf = legacy_test_coadd.coadd.getPsf()
530 psf = CellPointSpreadFunction.approximate_from_legacy(legacy_psf, legacy_test_coadd.bounds)
531 padded = CellPointSpreadFunction(np.pad(psf._array, [(0, 0), (0, 0), (1, 1), (1, 1)]), bounds=psf.bounds)
532 assert padded.kernel_bbox != psf.kernel_bbox
533 points = cell_center_points(psf.bounds)
534 assert compare_psf_to_legacy(padded, legacy_psf, points=points, rtol=1e-5) == psf.bounds.n_cells
535 with pytest.raises(AssertionError):
536 compare_psf_to_legacy(padded, legacy_psf, points=points)
537 # A stamp with flux outside the legacy kernel bbox is still rejected.
538 corrupted_array = padded._array.copy()
539 corrupted_array[:, :, 0, 0] = 1.0
540 corrupted = CellPointSpreadFunction(corrupted_array, bounds=psf.bounds)
541 with pytest.raises(AssertionError):
542 compare_psf_to_legacy(corrupted, legacy_psf, points=points, rtol=1e-5)
545def test_compare_psf_to_legacy_exact_mode_propagates_invalid_psf(
546 legacy_test_coadd: _LegacyTestCoadd, trim_missing: _TrimmedLegacyCoaddComponents
547) -> None:
548 """Test that a legacy PSF that cannot be evaluated at a comparison point
549 fails an exact comparison, and is only skipped by an approximate one.
550 """
551 psf = CellPointSpreadFunction.approximate_from_legacy(
552 legacy_test_coadd.coadd.getPsf(), legacy_test_coadd.bounds
553 )
554 center = legacy_test_coadd.sparse_cell_box.center
555 points = XY(x=np.array([center.x]), y=np.array([center.y]))
556 with pytest.raises(InvalidPsfError):
557 compare_psf_to_legacy(psf, trim_missing.psf, points=points)
558 assert compare_psf_to_legacy(psf, trim_missing.psf, points=points, rtol=1e-5) == 0