Coverage for tests/test_approximate_from_legacy.py: 26%

264 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-30 09:29 +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. 

11 

12"""Unit tests for the missing/partial-cell sampling rules of conversions 

13from legacy (non-cell) coadd components. 

14""" 

15 

16from __future__ import annotations 

17 

18import dataclasses 

19import os 

20import pickle 

21import types 

22from collections.abc import Iterable 

23from typing import Any 

24 

25import numpy as np 

26import pytest 

27 

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) 

45 

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 

61 

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 

74 

75 

76EXTERNAL_DATA_DIR = os.environ.get("TESTDATA_IMAGES_DIR", None) 

77 

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 

84 

85 

86@dataclasses.dataclass(frozen=True) 

87class _TrimmedLegacyCoaddComponents: 

88 """Legacy coadd components rebuilt without the inputs covering a point.""" 

89 

90 ccds: LegacyExposureCatalog 

91 psf: LegacyCoaddPsf 

92 ap_corrections: dict[str, LegacyCoaddBoundedField] 

93 dropped: frozenset[tuple[int, int]] # dropped (visit, detector) pairs 

94 

95 

96@dataclasses.dataclass(frozen=True) 

97class _LegacyTestCoadd: 

98 """Test data loaded from EXTERNAL_DATA_DIR.""" 

99 

100 coadd: LegacyExposure 

101 tract_info: LegacyTractInfo 

102 patch: PatchDefinition 

103 bounds: CellGridBounds 

104 

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 ) 

118 

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())) 

125 

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) 

130 

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) 

135 

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) 

140 

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. 

153 

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() 

163 

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 ) 

172 

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 ) 

205 

206 

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) 

228 

229 

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 ) 

236 

237 

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 ) 

247 

248 

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)) 

266 

267 

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)) 

302 

303 

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) 

319 

320 

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) 

346 

347 

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() 

368 

369 

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) 

381 

382 

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 

394 

395 

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 ) 

420 

421 

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) 

438 

439 

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 

452 

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 ) 

461 

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. 

474 

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) 

480 

481 

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 

487 

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)) 

492 

493 

494def _provenance_keys(provenance: CoaddProvenance) -> set[tuple[int, int]]: 

495 return {(int(row["visit"]), int(row["detector"])) for row in provenance.inputs} 

496 

497 

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} 

522 

523 

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) 

543 

544 

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