Coverage for python/lsst/images/tests/_checks.py: 49%

455 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-16 15:24 -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. 

11 

12from __future__ import annotations 

13 

14__all__ = ( 

15 "arrays_to_legacy_points", 

16 "assert_cell_coadds_equal", 

17 "assert_close", 

18 "assert_equal_allow_nan", 

19 "assert_images_equal", 

20 "assert_masked_images_equal", 

21 "assert_masks_equal", 

22 "assert_psfs_equal", 

23 "assert_sky_projections_equal", 

24 "assert_visit_images_equal", 

25 "check_archive_tree_class_invariants", 

26 "check_astropy_wcs_interface", 

27 "check_bounds_contains_broadcasting", 

28 "check_projection", 

29 "check_transform", 

30 "compare_amplifier_to_legacy", 

31 "compare_aperture_corrections_to_legacy", 

32 "compare_cell_coadd_to_legacy", 

33 "compare_detector_to_legacy", 

34 "compare_field_to_legacy", 

35 "compare_image_to_legacy", 

36 "compare_mask_to_legacy", 

37 "compare_masked_image_to_legacy", 

38 "compare_observation_summary_stats_to_legacy", 

39 "compare_photo_calib_to_legacy", 

40 "compare_psf_to_legacy", 

41 "compare_sky_projection_to_legacy_wcs", 

42 "compare_visit_image_to_legacy", 

43 "iter_concrete_archive_tree_subclasses", 

44 "legacy_coords_to_astropy", 

45 "legacy_points_to_xy_array", 

46) 

47 

48import dataclasses 

49import math 

50import re 

51from collections.abc import Iterator, Mapping 

52from typing import TYPE_CHECKING, Any, Literal, cast 

53 

54import astropy.units as u 

55import astropy.wcs.wcsapi 

56import numpy as np 

57import pytest 

58from astropy.coordinates import SkyCoord 

59 

60from .._geom import XY, YX, Bounds, Box 

61from .._image import Image 

62from .._mask import Mask, MaskPlane 

63from .._masked_image import MaskedImage 

64from .._observation_summary_stats import ObservationSummaryStats 

65from .._transforms import DetectorFrame, Frame, SkyFrame, SkyProjection, TractFrame, Transform 

66from .._visit_image import VisitImage 

67from ..cameras import Amplifier, Detector, DetectorType, ReadoutCorner 

68from ..cells import CellCoadd, CellIJ, CoaddProvenance 

69from ..fields import BaseField, ChebyshevField 

70from ..psfs import PointSpreadFunction 

71from ..serialization import ArchiveTree 

72 

73if TYPE_CHECKING: 

74 try: 

75 from lsst.cell_coadds import MultipleCellCoadd 

76 except ImportError: 

77 type MultipleCellCoadd = Any # type: ignore[no-redef] 

78 try: 

79 from lsst.afw.image import PhotoCalib as LegacyPhotoCalib 

80 except ImportError: 

81 type LegacyPhotoCalib = Any # type: ignore[no-redef] 

82 

83 

84def assert_close( 

85 a: np.ndarray | u.Quantity | float, 

86 b: np.ndarray | u.Quantity | float, 

87 **kwargs: Any, 

88) -> None: 

89 """Test that two arrays, floats, or quantities are almost equal. 

90 

91 Parameters 

92 ---------- 

93 a 

94 Array, float, or quantity to compare. 

95 b 

96 Array, float, or quantity to compare. 

97 **kwargs 

98 Forwarded to `astropy.units.allclose`. 

99 """ 

100 assert u.allclose(a, b, **kwargs), f"{a} != {b}" 

101 

102 

103def assert_equal_allow_nan(a: float, b: float) -> None: 

104 """Test that two floating point values are equal, with nan == nan. 

105 

106 Parameters 

107 ---------- 

108 a 

109 First value to compare. 

110 b 

111 Second value to compare. 

112 """ 

113 if not (a == b or (math.isnan(a) and math.isnan(b))): 

114 raise AssertionError(f"{a!r} != {b!r}") 

115 

116 

117def assert_images_equal( 

118 a: Image, 

119 b: Image, 

120 *, 

121 rtol: float = 0.0, 

122 atol: float = 0.0, 

123 expect_view: bool | Literal["array"] | None = None, 

124) -> None: 

125 """Assert that two images are equal or nearly equal. 

126 

127 Parameters 

128 ---------- 

129 a 

130 First image to compare. 

131 b 

132 Second image to compare. 

133 rtol 

134 Relative tolerance for the pixel comparison. 

135 atol 

136 Absolute tolerance for the pixel comparison. 

137 expect_view 

138 If not `None`, also assert whether ``b`` shares memory with ``a`` 

139 (i.e. is a view); ``"array"`` checks only the pixel arrays. 

140 """ 

141 assert a.bbox == b.bbox 

142 assert a.unit == b.unit 

143 assert_sky_projections_equal(a.sky_projection, b.sky_projection) 

144 if expect_view is not None: 

145 assert np.may_share_memory(a.array, b.array) == bool(expect_view) 

146 if expect_view == "array": 

147 assert a.metadata == b.metadata 

148 else: 

149 assert (a.metadata is b.metadata) == expect_view 

150 if not expect_view: 

151 assert_close(a.array, b.array, atol=atol, rtol=rtol) 

152 assert a.metadata == b.metadata 

153 

154 

155def assert_masks_equal(a: Mask, b: Mask) -> None: 

156 """Assert that two masks are equal or nearly equal. 

157 

158 Parameters 

159 ---------- 

160 a 

161 First mask to compare. 

162 b 

163 Second mask to compare. 

164 """ 

165 assert a.bbox == b.bbox 

166 assert a.schema == b.schema 

167 assert a.metadata == b.metadata 

168 assert_sky_projections_equal(a.sky_projection, b.sky_projection) 

169 np.testing.assert_array_equal(a.array, b.array) 

170 

171 

172def assert_masked_images_equal( 

173 a: MaskedImage, 

174 b: MaskedImage, 

175 *, 

176 rtol: float = 0.0, 

177 atol: float = 0.0, 

178 expect_view: bool | None = None, 

179) -> None: 

180 """Assert that two masked images are equal or nearly equal. 

181 

182 Parameters 

183 ---------- 

184 a 

185 First masked image to compare. 

186 b 

187 Second masked image to compare. 

188 rtol 

189 Relative tolerance for the pixel comparison. 

190 atol 

191 Absolute tolerance for the pixel comparison. 

192 expect_view 

193 If not `None`, also assert whether ``b`` shares memory with ``a`` 

194 (i.e. is a view). 

195 """ 

196 assert a.metadata == b.metadata 

197 assert_sky_projections_equal(a.sky_projection, b.sky_projection) 

198 assert_images_equal(a.image, b.image, rtol=rtol, atol=atol, expect_view=expect_view) 

199 assert_masks_equal(a.mask, b.mask) 

200 assert_images_equal(a.variance, b.variance, rtol=rtol, atol=atol, expect_view=expect_view) 

201 

202 

203def assert_psfs_equal( 

204 psf1: PointSpreadFunction, 

205 psf2: PointSpreadFunction, 

206 points: YX[np.ndarray] | XY[np.ndarray] | None = None, 

207) -> int: 

208 """Compare two PSF objets. 

209 

210 Parameters 

211 ---------- 

212 psf1 

213 Point-spread function to test. 

214 psf2 

215 The other point-spread function to test. 

216 points 

217 Points to evaluate the PSFs at. If not provided, the intersection of 

218 the PSF bounding boxes are used to generate points on a grid. 

219 

220 Returns 

221 ------- 

222 `int` 

223 The number of points actually tested. 

224 """ 

225 if points is None: 225 ↛ 228line 225 didn't jump to line 228 because the condition on line 225 was always true

226 points = psf1.bounds.bbox.intersection(psf2.bounds.bbox).meshgrid(3).map(np.ravel) 

227 

228 assert psf1.kernel_bbox == psf2.kernel_bbox 

229 

230 n_points_tested: int = 0 

231 for x, y in zip(points.x, points.y): 

232 # The two PSFs must agree on which points fall inside their input 

233 # domain. Querying ``.contains`` directly (rather than relying on 

234 # ``compute_kernel_image`` to raise) makes this test tolerant of 

235 # implementations that do not raise on out-of-domain points -- in 

236 # particular ``CellPointSpreadFunction``, where evaluating in a 

237 # missing cell does not always raise ``BoundsError``. 

238 contains1 = psf1.bounds.contains(x=x, y=y) 

239 contains2 = psf2.bounds.contains(x=x, y=y) 

240 assert contains1 == contains2, ( 

241 f"PSFs disagree on whether ({x}, {y}) is in-bounds: psf1={contains1}, psf2={contains2}" 

242 ) 

243 if not contains1: 243 ↛ 244line 243 didn't jump to line 244 because the condition on line 243 was never true

244 continue 

245 assert psf1.compute_kernel_image(x=x, y=y) == psf2.compute_kernel_image(x=x, y=y) 

246 assert psf1.compute_stellar_bbox(x=x, y=y) == psf2.compute_stellar_bbox(x=x, y=y) 

247 assert psf1.compute_stellar_image(x=x, y=y) == psf2.compute_stellar_image(x=x, y=y) 

248 n_points_tested += 1 

249 return n_points_tested 

250 

251 

252def assert_visit_images_equal( 

253 a: VisitImage, 

254 b: VisitImage, 

255 *, 

256 expect_view: bool | None = None, 

257) -> None: 

258 """Assert that two `.VisitImage` instances carry the same persistent state. 

259 

260 Extends `assert_masked_images_equal` with the VisitImage-specific 

261 attributes (PSF, filter, observation info, detector, aperture 

262 corrections, photometric scaling, backgrounds, polygon bounds, 

263 summary stats) so a round-trip check on a `.VisitImage` does not 

264 silently miss differences in any of them. 

265 

266 Parameters 

267 ---------- 

268 a 

269 First visit image to compare. 

270 b 

271 Second visit image to compare. 

272 expect_view 

273 If not `None`, also assert whether ``b`` shares memory with ``a`` 

274 (i.e. is a view). 

275 """ 

276 assert_masked_images_equal(a, b, expect_view=expect_view) 

277 assert a.summary_stats == b.summary_stats 

278 assert a.physical_filter == b.physical_filter 

279 assert a.band == b.band 

280 assert a.obs_info == b.obs_info 

281 assert a.detector == b.detector 

282 assert dict(a.aperture_corrections) == dict(b.aperture_corrections) 

283 assert a.photometric_scaling == b.photometric_scaling 

284 assert dict(a.backgrounds) == dict(b.backgrounds) 

285 assert a.backgrounds.subtracted == b.backgrounds.subtracted 

286 assert a.bounds == b.bounds 

287 assert_psfs_equal(a.psf, b.psf) 

288 

289 

290def assert_cell_coadds_equal( 

291 a: CellCoadd, 

292 b: CellCoadd, 

293 *, 

294 expect_view: bool | None = None, 

295) -> None: 

296 """Assert that two `.CellCoadd` instances carry the same persistent state. 

297 

298 Extends the masked-image-style equality check with the 

299 CellCoadd-specific attributes (PSF, cell grid, missing cells, 

300 backgrounds, patch/tract, band) so a round-trip check does not 

301 silently miss differences in any of them. 

302 

303 Parameters 

304 ---------- 

305 a 

306 First cell coadd to compare. 

307 b 

308 Second cell coadd to compare. 

309 expect_view 

310 If not `None`, also assert whether ``b`` shares memory with ``a`` 

311 (i.e. is a view). 

312 """ 

313 assert_masked_images_equal(a, b, expect_view=expect_view) 

314 assert a.band == b.band 

315 assert a.patch == b.patch 

316 assert a.tract == b.tract 

317 assert a.grid == b.grid 

318 assert a.bounds.missing == b.bounds.missing 

319 assert dict(a.backgrounds) == dict(b.backgrounds) 

320 assert a.backgrounds.subtracted == b.backgrounds.subtracted 

321 assert_psfs_equal(a.psf, b.psf) 

322 

323 

324def compare_image_to_legacy(image: Image, legacy_image: Any, expect_view: bool | None = None) -> None: 

325 """Compare an `.Image` object to a legacy `lsst.afw.image.Image` object. 

326 

327 Parameters 

328 ---------- 

329 image 

330 Image to compare. 

331 legacy_image 

332 Legacy `lsst.afw.image.Image` to compare against. 

333 expect_view 

334 If not `None`, also assert whether ``image`` shares memory with 

335 ``legacy_image`` (i.e. is a view). 

336 """ 

337 assert image.bbox == Box.from_legacy(legacy_image.getBBox()) 

338 if expect_view is not None: 338 ↛ 340line 338 didn't jump to line 340 because the condition on line 338 was always true

339 assert np.may_share_memory(image.array, legacy_image.array) == expect_view 

340 if not expect_view: 340 ↛ exitline 340 didn't return from function 'compare_image_to_legacy' because the condition on line 340 was always true

341 np.testing.assert_array_equal(image.array, legacy_image.array) 

342 

343 

344def compare_mask_to_legacy( 

345 mask: Mask, legacy_mask: Any, plane_map: Mapping[str, MaskPlane] | None = None 

346) -> None: 

347 """Compare a `.Mask` object to a legacy `lsst.afw.image.Mask` object. 

348 

349 Parameters 

350 ---------- 

351 mask 

352 Mask to compare. 

353 legacy_mask 

354 Legacy `lsst.afw.image.Mask` to compare against. 

355 plane_map 

356 Mapping from legacy plane name to the new mask plane; defaults to 

357 the planes in ``mask.schema``. 

358 """ 

359 assert mask.bbox == Box.from_legacy(legacy_mask.getBBox()) 

360 if plane_map is None: 360 ↛ 362line 360 didn't jump to line 362 because the condition on line 360 was always true

361 plane_map = {plane.name: plane for plane in mask.schema if plane is not None} 

362 for old_name, new_plane in plane_map.items(): 

363 np.testing.assert_array_equal( 

364 (legacy_mask.array & legacy_mask.getPlaneBitMask(old_name)).astype(bool), 

365 mask.get(new_plane.name), 

366 ) 

367 

368 

369def compare_masked_image_to_legacy( 

370 masked_image: MaskedImage, 

371 legacy_masked_image: Any, 

372 *, 

373 plane_map: Mapping[str, MaskPlane] | None = None, 

374 expect_view: bool | None = None, 

375 alternates: Mapping[str, Any] | None = None, 

376) -> None: 

377 """Compare a `.MaskedImage` object to a legacy `lsst.afw.image.MaskedImage` 

378 object. 

379 

380 Parameters 

381 ---------- 

382 masked_image 

383 New image to test. 

384 legacy_masked_image 

385 Legacy image to test against. 

386 plane_map 

387 Mapping between new and legacy mask planes. 

388 expect_view 

389 Whether to test that the image and variance arrays do or do not share 

390 memory. 

391 alternates 

392 A mapping of other versions of one or more (new) components to also 

393 check against the legacy versions of those components. 

394 """ 

395 compare_image_to_legacy(masked_image.image, legacy_masked_image.getImage(), expect_view=expect_view) 

396 compare_mask_to_legacy(masked_image.mask, legacy_masked_image.getMask(), plane_map=plane_map) 

397 compare_image_to_legacy(masked_image.variance, legacy_masked_image.getVariance(), expect_view=expect_view) 

398 if alternates: 398 ↛ 399line 398 didn't jump to line 399 because the condition on line 398 was never true

399 if image := alternates.get("image"): 

400 compare_image_to_legacy(image, legacy_masked_image.getImage(), expect_view=expect_view) 

401 if mask := alternates.get("mask"): 

402 compare_mask_to_legacy(mask, legacy_masked_image.getMask(), plane_map=plane_map) 

403 if variance := alternates.get("variance"): 

404 compare_image_to_legacy(variance, legacy_masked_image.getVariance(), expect_view=expect_view) 

405 

406 

407def compare_visit_image_to_legacy( 

408 visit_image: VisitImage, 

409 legacy_exposure: Any, 

410 *, 

411 plane_map: Mapping[str, MaskPlane] | None = None, 

412 expect_view: bool | None = None, 

413 instrument: str, 

414 visit: int, 

415 detector: int, 

416 applied_legacy_photo_calib: LegacyPhotoCalib | None = None, 

417 alternates: Mapping[str, Any] | None = None, 

418) -> None: 

419 """Compare a `.VisitImage` object to a legacy `lsst.afw.image.Exposure` 

420 object. 

421 

422 Parameters 

423 ---------- 

424 visit_image 

425 New image to test. 

426 legacy_exposure 

427 Legacy image to test against. 

428 plane_map 

429 Mapping between new and legacy mask planes. 

430 expect_view 

431 Whether to test that the image and variance arrays do or do not share 

432 memory. 

433 instrument 

434 Expected instrument name. 

435 visit 

436 Expected visit ID. 

437 detector 

438 Expected detector ID. 

439 applied_legacy_photo_calib 

440 Legacy `lsst.afw.image.PhotoCalib` already applied to 

441 ``legacy_exposure``, used when comparing photometric scaling. 

442 alternates 

443 A mapping of other versions of one or more (new) components to also 

444 check against the legacy versions of those components. 

445 """ 

446 compare_masked_image_to_legacy( 

447 visit_image, 

448 legacy_exposure.getMaskedImage(), 

449 plane_map=plane_map, 

450 expect_view=expect_view, 

451 alternates=alternates, 

452 ) 

453 detector_bbox = Box.from_legacy(legacy_exposure.getDetector().getBBox()) 

454 compare_sky_projection_to_legacy_wcs( 

455 visit_image.sky_projection, 

456 legacy_exposure.getWcs(), 

457 DetectorFrame(instrument=instrument, visit=visit, detector=detector, bbox=detector_bbox), 

458 visit_image.bbox, 

459 ) 

460 assert visit_image.sky_projection is visit_image.mask.sky_projection 

461 assert visit_image.sky_projection is visit_image.variance.sky_projection 

462 compare_psf_to_legacy(visit_image.psf, legacy_exposure.getPsf()) 

463 compare_observation_summary_stats_to_legacy( 

464 visit_image.summary_stats, legacy_exposure.info.getSummaryStats() 

465 ) 

466 compare_detector_to_legacy(visit_image.detector, legacy_exposure.getDetector(), is_raw_assembled=True) 

467 # Make a tiny box for Field comparisons that need to make arrays; that can 

468 # get expensive otherwisre. 

469 tiny_bbox = detector_bbox.local[2:4, 3:6] 

470 compare_aperture_corrections_to_legacy( 

471 visit_image.aperture_corrections, legacy_exposure.info.getApCorrMap(), tiny_bbox 

472 ) 

473 compare_photo_calib_to_legacy( 

474 visit_image.photometric_scaling, 

475 legacy_exposure.info.getPhotoCalib(), 

476 applied_legacy_photo_calib=applied_legacy_photo_calib, 

477 subimage_bbox=tiny_bbox, 

478 ) 

479 if alternates: 

480 if (bbox := alternates.get("bbox")) is not None: 

481 assert bbox == visit_image.bbox 

482 if sky_projection := alternates.get("sky_projection"): 

483 compare_sky_projection_to_legacy_wcs( 

484 sky_projection, 

485 legacy_exposure.getWcs(), 

486 DetectorFrame(instrument=instrument, visit=visit, detector=detector, bbox=detector_bbox), 

487 visit_image.bbox, 

488 ) 

489 if psf := alternates.get("psf"): 

490 compare_psf_to_legacy(psf, legacy_exposure.getPsf()) 

491 if summary_stats := alternates.get("summary_stats"): 

492 compare_observation_summary_stats_to_legacy(summary_stats, legacy_exposure.info.getSummaryStats()) 

493 if detector_obj := alternates.get("detector"): 

494 compare_detector_to_legacy(detector_obj, legacy_exposure.getDetector(), is_raw_assembled=True) 

495 if obs_info := alternates.get("obs_info"): 

496 visitInfo = legacy_exposure.visitInfo 

497 assert obs_info.instrument == visitInfo.getInstrumentLabel() 

498 if aperture_corrections := alternates.get("aperture_corrections"): 

499 compare_aperture_corrections_to_legacy( 

500 aperture_corrections, legacy_exposure.info.getApCorrMap(), tiny_bbox 

501 ) 

502 if (photometric_scaling := alternates.get("photometic_scaling", ...)) is not ...: 

503 compare_photo_calib_to_legacy( 

504 photometric_scaling, 

505 legacy_exposure.info.getPhotoCalib(), 

506 applied_legacy_photo_calib=applied_legacy_photo_calib, 

507 subimage_bbox=tiny_bbox, 

508 ) 

509 

510 

511def compare_photo_calib_to_legacy( 

512 photometric_scaling: BaseField | None, 

513 legacy_photo_calib: LegacyPhotoCalib, 

514 *, 

515 applied_legacy_photo_calib: LegacyPhotoCalib | None = None, 

516 subimage_bbox: Box, 

517) -> None: 

518 if legacy_photo_calib._isConstant: 

519 if legacy_photo_calib.getCalibrationMean() == 1.0: 

520 if applied_legacy_photo_calib is None: 

521 assert photometric_scaling is None 

522 return 

523 else: 

524 legacy_photo_calib = applied_legacy_photo_calib 

525 if legacy_photo_calib._isConstant: 

526 assert isinstance(photometric_scaling, ChebyshevField) 

527 assert_close(photometric_scaling.coefficients, np.array([[legacy_photo_calib.getCalibrationMean()]])) 

528 else: 

529 assert photometric_scaling is not None 

530 compare_field_to_legacy( 

531 photometric_scaling / legacy_photo_calib.getCalibrationMean(), 

532 legacy_photo_calib.computeScaledCalibration(), 

533 subimage_bbox, 

534 ) 

535 

536 

537def compare_cell_coadd_to_legacy( 

538 cell_coadd: CellCoadd, 

539 legacy_cell_coadd: MultipleCellCoadd, 

540 *, 

541 tract_bbox: Box, 

542 plane_map: Mapping[str, MaskPlane] | None = None, 

543 alternates: Mapping[str, Any] | None = None, 

544 psf_points: XY[np.ndarray] | YX[np.ndarray] | None = None, 

545) -> None: 

546 """Compare a `.cells.CellCoadd` object to a legacy 

547 `lsst.cell_coadds.MultipleCellCoadd` object. 

548 

549 Parameters 

550 ---------- 

551 cell_coadd 

552 New coadd to test. 

553 legacy_cell_coadd 

554 Legacy coadd to test against. 

555 tract_bbox 

556 Bounding box of the full tract. 

557 plane_map 

558 Mapping between new and legacy mask planes. 

559 alternates 

560 A mapping of other versions of one or more (new) components to also 

561 check against the legacy versions of those components. 

562 psf_points 

563 Points to use to compare the PSFs. 

564 """ 

565 legacy_stitched = legacy_cell_coadd.stitch(cell_coadd.bbox.to_legacy()) 

566 compare_image_to_legacy(cell_coadd.image, legacy_stitched.image, expect_view=False) 

567 compare_mask_to_legacy(cell_coadd.mask, legacy_stitched.mask, plane_map=plane_map) 

568 compare_image_to_legacy(cell_coadd.variance, legacy_stitched.variance, expect_view=False) 

569 if legacy_stitched.mask_fractions is not None: 

570 compare_image_to_legacy( 

571 cell_coadd.mask_fractions["rejected"], legacy_stitched.mask_fractions, expect_view=False 

572 ) 

573 for n in range(legacy_stitched.n_noise_realizations): 

574 compare_image_to_legacy( 

575 cell_coadd.noise_realizations[n], legacy_stitched.noise_realizations[n], expect_view=False 

576 ) 

577 assert cell_coadd.skymap == legacy_stitched.identifiers.skymap 

578 assert cell_coadd.tract == legacy_stitched.identifiers.tract 

579 assert cell_coadd.patch.index.x == legacy_stitched.identifiers.patch.x 

580 assert cell_coadd.patch.index.y == legacy_stitched.identifiers.patch.y 

581 assert cell_coadd.band == legacy_stitched.identifiers.band 

582 assert tract_bbox.contains(cell_coadd.patch.outer_bbox) 

583 assert cell_coadd.patch.outer_bbox.contains(cell_coadd.patch.inner_bbox) 

584 assert cell_coadd.patch.outer_bbox.contains(cell_coadd.bbox) 

585 assert cell_coadd.unit == u.Unit(legacy_cell_coadd.common.units.value) 

586 assert cell_coadd.bounds.bbox.contains(cell_coadd.bbox) 

587 assert cell_coadd.grid.bbox.contains(cell_coadd.bbox) 

588 compare_sky_projection_to_legacy_wcs( 

589 cell_coadd.sky_projection, 

590 legacy_cell_coadd.wcs, 

591 TractFrame( 

592 skymap=legacy_cell_coadd.identifiers.skymap, 

593 tract=legacy_cell_coadd.identifiers.tract, 

594 bbox=tract_bbox, 

595 ), 

596 cell_coadd.bbox, 

597 is_fits=True, 

598 ) 

599 assert cell_coadd.sky_projection is cell_coadd.mask.sky_projection 

600 assert cell_coadd.sky_projection is cell_coadd.variance.sky_projection 

601 compare_psf_to_legacy( 

602 cell_coadd.psf, legacy_stitched.psf, expect_legacy_raise_on_out_of_bounds=True, points=psf_points 

603 ) 

604 compare_aperture_corrections_to_legacy( 

605 cell_coadd.aperture_corrections, legacy_stitched.ap_corr_map, cell_coadd.bbox 

606 ) 

607 compare_cell_coadd_provenance_to_legacy(cell_coadd.provenance, legacy_cell_coadd) 

608 if alternates: 

609 if sky_projection := alternates.get("sky_projection"): 

610 compare_sky_projection_to_legacy_wcs( 

611 sky_projection, 

612 legacy_stitched.wcs, 

613 TractFrame( 

614 skymap=legacy_cell_coadd.identifiers.skymap, 

615 tract=legacy_cell_coadd.identifiers.tract, 

616 bbox=tract_bbox, 

617 ), 

618 cell_coadd.bbox, 

619 is_fits=True, 

620 ) 

621 if psf := alternates.get("psf"): 

622 compare_psf_to_legacy(psf, legacy_stitched.psf, points=psf_points) 

623 if aperture_corrections := alternates.get("aperture_corrections"): 

624 compare_aperture_corrections_to_legacy( 

625 aperture_corrections, legacy_stitched.ap_corr_map, cell_coadd.bbox 

626 ) 

627 if provenance := alternates.get("provenance"): 

628 compare_cell_coadd_provenance_to_legacy(provenance, legacy_cell_coadd) 

629 

630 

631def compare_cell_coadd_provenance_to_legacy( 

632 provenance: CoaddProvenance, legacy_cell_coadd: MultipleCellCoadd 

633) -> None: 

634 """Compare a `.cells.CoaddProvenance` object to a legacy 

635 `lsst.cell_coadds.MultipleCellCoadd` object. 

636 

637 Parameters 

638 ---------- 

639 provenance 

640 New provenance object to test. 

641 legacy_cell_coadd 

642 Legacy coadd to test against. 

643 """ 

644 from lsst.cell_coadds import ObservationIdentifiers 

645 

646 for legacy_cell in legacy_cell_coadd.cells.values(): 

647 cell_index = CellIJ.from_legacy(legacy_cell.identifiers.cell) 

648 prov = provenance[cell_index] 

649 legacy_table = astropy.table.Table( 

650 rows=[ 

651 [ 

652 ids.instrument, 

653 ids.visit, 

654 ids.detector, 

655 ids.day_obs, 

656 ids.physical_filter, 

657 legacy_input.overlaps_center, 

658 legacy_input.overlap_fraction, 

659 legacy_input.weight, 

660 legacy_input.psf_shape.getIxx(), 

661 legacy_input.psf_shape.getIyy(), 

662 legacy_input.psf_shape.getIxy(), 

663 legacy_input.psf_shape_flag, 

664 ] 

665 for ids, legacy_input in legacy_cell.inputs.items() 

666 ], 

667 dtype=[ 

668 np.object_, 

669 np.uint64, 

670 np.uint16, 

671 np.uint32, 

672 np.object_, 

673 np.bool_, 

674 np.float64, 

675 np.float64, 

676 np.float64, 

677 np.float64, 

678 np.float64, 

679 np.bool_, 

680 ], 

681 names=[ 

682 "instrument", 

683 "visit", 

684 "detector", 

685 "day_obs", 

686 "physical_filter", 

687 "overlaps_center", 

688 "overlap_fraction", 

689 "weight", 

690 "psf_shape_xx", 

691 "psf_shape_yy", 

692 "psf_shape_xy", 

693 "psf_shape_flag", 

694 ], 

695 ) 

696 # For a single cell all 'inputs' are also 'contributions'. 

697 assert len(legacy_cell.inputs) == len(prov.inputs) 

698 assert len(legacy_cell.inputs) == len(prov.contributions) 

699 prov.inputs.sort(["instrument", "visit", "detector"]) 

700 prov.contributions.sort(["instrument", "visit", "detector"]) 

701 legacy_table.sort(["instrument", "visit", "detector"]) 

702 np.testing.assert_array_equal(prov.inputs["instrument"], prov.contributions["instrument"]) 

703 np.testing.assert_array_equal(prov.inputs["visit"], prov.contributions["visit"]) 

704 np.testing.assert_array_equal(prov.inputs["detector"], prov.contributions["detector"]) 

705 np.testing.assert_array_equal(prov.inputs["instrument"], legacy_table["instrument"]) 

706 np.testing.assert_array_equal(prov.inputs["visit"], legacy_table["visit"]) 

707 np.testing.assert_array_equal(prov.inputs["detector"], legacy_table["detector"]) 

708 np.testing.assert_array_equal(prov.inputs["physical_filter"], legacy_table["physical_filter"]) 

709 np.testing.assert_array_equal(prov.inputs["day_obs"], legacy_table["day_obs"]) 

710 np.testing.assert_array_equal(prov.contributions["overlaps_center"], legacy_table["overlaps_center"]) 

711 np.testing.assert_array_equal( 

712 prov.contributions["overlap_fraction"], legacy_table["overlap_fraction"] 

713 ) 

714 np.testing.assert_array_equal(prov.contributions["weight"], legacy_table["weight"]) 

715 np.testing.assert_array_equal(prov.contributions["psf_shape_xx"], legacy_table["psf_shape_xx"]) 

716 np.testing.assert_array_equal(prov.contributions["psf_shape_yy"], legacy_table["psf_shape_yy"]) 

717 np.testing.assert_array_equal(prov.contributions["psf_shape_xy"], legacy_table["psf_shape_xy"]) 

718 np.testing.assert_array_equal(prov.contributions["psf_shape_flag"], legacy_table["psf_shape_flag"]) 

719 for row in prov.inputs: 

720 polygon_key = ObservationIdentifiers(**{k: row[k] for k in row.keys() if k != "polygon"}) 

721 legacy_polygon = legacy_cell_coadd.common.visit_polygons[polygon_key] 

722 assert legacy_polygon == row["polygon"].to_legacy() 

723 

724 

725def compare_psf_to_legacy( 

726 psf: PointSpreadFunction, 

727 legacy_psf: Any, 

728 points: YX[np.ndarray] | XY[np.ndarray] | None = None, 

729 expect_legacy_raise_on_out_of_bounds: bool = False, 

730) -> int: 

731 """Compare a PSF model object to its legacy interface. 

732 

733 Parameters 

734 ---------- 

735 psf 

736 Point-spread function to test. 

737 legacy_psf 

738 Legacy `lsst.afw.detection.Psf` instance to compare with. 

739 points 

740 Points to evaluate the PSFs at. If not provided, the intersection of 

741 the PSF bounding boxes are used to generate points on a grid. 

742 expect_legacy_raise_on_out_of_bounds 

743 If `True`, expect ``legacy_psf`` to raise 

744 `lsst.afw.detection.InvalidPsfError` when evaluated at a position 

745 considered out-of-bounds by ``psf``. 

746 

747 Returns 

748 ------- 

749 `int` 

750 The number of points actually tested. 

751 """ 

752 from lsst.afw.detection import InvalidPsfError 

753 

754 if points is None: 

755 points = psf.bounds.bbox.meshgrid(n=3).map(np.ravel) 

756 legacy_points = arrays_to_legacy_points(points.x, points.y) 

757 n_points_tested: int = 0 

758 for p in legacy_points: 

759 if not psf.bounds.contains(x=p.x, y=p.y): 

760 if expect_legacy_raise_on_out_of_bounds: 

761 with pytest.raises(InvalidPsfError): 

762 legacy_psf.computeKernelImage(p) 

763 continue 

764 assert psf.kernel_bbox == Box.from_legacy(legacy_psf.computeKernelBBox(p)) 

765 assert psf.compute_kernel_image(x=p.x, y=p.y) == Image.from_legacy(legacy_psf.computeKernelImage(p)) 

766 assert psf.compute_stellar_bbox(x=p.x, y=p.y) == Box.from_legacy(legacy_psf.computeImageBBox(p)) 

767 assert psf.compute_stellar_image(x=p.x, y=p.y) == Image.from_legacy(legacy_psf.computeImage(p)) 

768 n_points_tested += 1 

769 return n_points_tested 

770 

771 

772def compare_field_to_legacy( 

773 field: BaseField, 

774 legacy_field: Any, 

775 subimage_bbox: Box, 

776) -> None: 

777 """Test a Field object by comparing it to an equivalent 

778 `lsst.afw.math.BoundedField`. 

779 

780 Parameters 

781 ---------- 

782 field 

783 Field to test. 

784 legacy_field : ``lsst.afw.math.BoundedField`` 

785 Equivalent legacy bounded field. 

786 subimage_bbox 

787 Bounding box for full-image tests. 

788 """ 

789 from lsst.afw.math import BoundedField as LegacyBoundedField 

790 

791 assert field.bounds.bbox == Box.from_legacy(legacy_field.getBBox()) 

792 # Pixel coordinates to test the numpy array interface with. 

793 pixel_xy = field.bounds.bbox.meshgrid(n=5).map(np.ravel) 

794 if not isinstance(field.bounds, Box): 794 ↛ 795line 794 didn't jump to line 795 because the condition on line 794 was never true

795 mask = field.bounds.contains(x=pixel_xy.x, y=pixel_xy.y) 

796 pixel_xy = pixel_xy.map(lambda v: v[mask]) 

797 try: 

798 assert_close( 

799 field(x=pixel_xy.x, y=pixel_xy.y), 

800 legacy_field.evaluate(pixel_xy.x, pixel_xy.y), 

801 equal_nan=True, 

802 ) 

803 except AssertionError as err: 

804 err.add_note(f"evaluated at {pixel_xy}") 

805 raise 

806 if not isinstance(legacy_field, LegacyBoundedField): 806 ↛ 809line 806 didn't jump to line 809 because the condition on line 806 was never true

807 # Legacy StitchedApertureCorrection objects are not true BoundedFields 

808 # and don't have addToImage. 

809 return 

810 legacy_image_1 = Image(0, bbox=subimage_bbox, dtype=np.float64).to_legacy() 

811 legacy_field.addToImage(legacy_image_1, overlapOnly=True) 

812 assert_images_equal( 

813 field.render(subimage_bbox), Image.from_legacy(legacy_image_1, unit=field.unit), rtol=1e-13 

814 ) 

815 

816 

817def compare_aperture_corrections_to_legacy( 

818 aperture_corrections: Mapping[str, BaseField], 

819 legacy_ap_corr_map: Any, 

820 subimage_bbox: Box, 

821) -> None: 

822 """Test an aperture correction `dict` by comparing it to an equivalent 

823 `lsst.afw.image.ApCorrMap`. 

824 

825 Parameters 

826 ---------- 

827 aperture_corrections 

828 Dictionary to test. 

829 legacy_ap_corr_map : ``lsst.afw.image.ApCorrMap`` 

830 Equivalent legacy aperture correction map. 

831 subimage_bbox 

832 Bounding box for full-image tests. 

833 """ 

834 assert aperture_corrections.keys() == set(legacy_ap_corr_map.keys()) 

835 for name, field in aperture_corrections.items(): 

836 compare_field_to_legacy(field, legacy_ap_corr_map[name], subimage_bbox) 

837 

838 

839def compare_observation_summary_stats_to_legacy( 

840 summary_stats: ObservationSummaryStats, 

841 legacy_summary_stats: Any, 

842) -> None: 

843 """Test an ObservationSummaryStats object by comparing it to an equivalent 

844 `lsst.afw.image.ExposureSummaryStats`. 

845 

846 Parameters 

847 ---------- 

848 summary_stats 

849 Struct to test. 

850 legacy_summary_stats : ``lsst.afw.image.ExposureSummaryStats`` 

851 Equivalent legacy struct. 

852 """ 

853 for field in dataclasses.fields(legacy_summary_stats): 

854 a = getattr(legacy_summary_stats, field.name) 

855 b = getattr(summary_stats, field.name) 

856 if isinstance(b, tuple): 

857 for ai, bi in zip(a, b): 

858 assert ai == bi or (math.isnan(ai) and math.isnan(bi)), f"{field.name}: {a} != {b}" 

859 else: 

860 assert a == b or (math.isnan(a) and math.isnan(b)), f"{field.name}: {a} != {b}" 

861 

862 

863def compare_sky_projection_to_legacy_wcs[F: Frame]( 

864 sky_projection: SkyProjection[F], 

865 legacy_wcs: Any, 

866 pixel_frame: F, 

867 subimage_bbox: Box, 

868 is_fits: bool = False, 

869) -> None: 

870 """Test a Projection object by comparing it to an equivalent 

871 `lsst.afw.geom.SkyWcs`. 

872 

873 Parameters 

874 ---------- 

875 sky_projection 

876 Projection to test. 

877 legacy_wcs : ``lsst.afw.geom.SkyWcs`` 

878 Equivalent legacy WCS. 

879 pixel_frame 

880 Expected pixel frame for the sky_projection. 

881 subimage_bbox 

882 Bounding box of points to generate for tests. 

883 is_fits 

884 Whether this sky_projection is expected to be exactly representable as 

885 a FITS WCS. If `False` it is assumed to have a FITS approximation 

886 attached instead. 

887 """ 

888 # Pixel coordinates to test on over the subimage region of interest: 

889 pixel_xy = subimage_bbox.meshgrid(step=50).map(np.ravel) 

890 # Array indices of those pixel values (subtract off bbox starts): 

891 subimage_array_xy = XY(x=pixel_xy.x - subimage_bbox.x.start, y=pixel_xy.y - subimage_bbox.y.start) 

892 sky_coords = legacy_coords_to_astropy( 

893 legacy_wcs.pixelToSky(arrays_to_legacy_points(pixel_xy.x, pixel_xy.y)) 

894 ) 

895 # Test transforming with the Projection itself, which also tests its 

896 # nested Transform and an Astropy High-Level WCS view with no origin 

897 # change. 

898 check_projection(sky_projection, pixel_xy, sky_coords, pixel_frame) 

899 # Also test the Astropy High-Level WCS view with an origin change to 

900 # array indices. 

901 check_astropy_wcs_interface( 

902 sky_projection.as_astropy(subimage_bbox), subimage_array_xy, sky_coords, pixel_atol=1e-5 

903 ) 

904 if is_fits: 904 ↛ 923line 904 didn't jump to line 923 because the condition on line 904 was always true

905 fits_wcs = sky_projection.as_fits_wcs(subimage_bbox, allow_approximation=True) 

906 assert fits_wcs is not None 

907 check_astropy_wcs_interface(fits_wcs, subimage_array_xy, sky_coords, pixel_atol=1e-5) 

908 # Use that FITS approximation to check that we can make a 

909 # Projection from a FITS WCS, too. 

910 fits_projection = SkyProjection.from_fits_wcs(fits_wcs, pixel_frame) 

911 check_projection( 

912 fits_projection, 

913 subimage_array_xy, 

914 sky_coords, 

915 pixel_frame, 

916 pixel_atol=1e-5, 

917 ) 

918 # We want Projections we create from a FITS WCS to be backed by an 

919 # AST FrameSet so we can convert them into legacy 

920 # `lsst.afw.geom.SkyWcs` objects if desired. 

921 assert "Begin FrameSet" in fits_projection.show() 

922 else: 

923 assert sky_projection.as_fits_wcs(subimage_bbox, allow_approximation=False) is None 

924 # The legacy SkyWcs should instead have a FITS approximation 

925 # attached; run the same tests on that. 

926 assert sky_projection.fits_approximation is not None 

927 compare_sky_projection_to_legacy_wcs( 

928 sky_projection.fits_approximation, 

929 legacy_wcs.getFitsApproximation(), 

930 pixel_frame, 

931 subimage_bbox, 

932 is_fits=True, 

933 ) 

934 

935 

936def check_transform[I: Frame, O: Frame]( 

937 transform: Transform[I, O], 

938 input_xy: XY[np.ndarray], 

939 output_xy: XY[np.ndarray], 

940 in_frame: Frame, 

941 out_frame: Frame, 

942 *, 

943 check_inverted: bool = True, 

944 in_atol: u.Quantity | None = None, 

945 out_atol: u.Quantity | None = None, 

946) -> None: 

947 """Test Transform against known arrays of input and output points. 

948 

949 Parameters 

950 ---------- 

951 transform 

952 Transform to test. 

953 input_xy 

954 Arrays of input points. 

955 output_xy 

956 Arrays of output points. 

957 in_frame 

958 Expected input frame. 

959 out_frame 

960 Expected output frame. 

961 check_inverted 

962 If `True`, recurse (once) to test the inverse transform. 

963 in_atol 

964 Expected absolute precision of input points. 

965 out_atol 

966 Expected absolute precision of output points. 

967 """ 

968 assert transform.in_frame == in_frame 

969 assert transform.out_frame == out_frame 

970 in_atol_v = in_atol.to_value(in_frame.unit) if in_atol is not None else None 

971 out_atol_v = out_atol.to_value(out_frame.unit) if out_atol is not None else None 

972 # Test array interfaces. 

973 test_output_xy = transform.apply_forward(x=input_xy.x, y=input_xy.y) 

974 assert_close(test_output_xy.x, output_xy.x, atol=out_atol_v) 

975 assert_close(test_output_xy.y, output_xy.y, atol=out_atol_v) 

976 test_input_xy = transform.apply_inverse(x=output_xy.x, y=output_xy.y) 

977 assert_close(test_input_xy.x, input_xy.x, atol=in_atol_v) 

978 assert_close(test_input_xy.y, input_xy.y, atol=in_atol_v) 

979 # Test scalar interfaces with numpy scalars. 

980 for input_x, input_y, output_x, output_y in zip(input_xy.x, input_xy.y, output_xy.x, output_xy.y): 

981 assert_close(transform.apply_forward(x=input_x, y=input_y).x, output_x, atol=out_atol_v) 

982 assert_close(transform.apply_forward(x=input_x, y=input_y).y, output_y, atol=out_atol_v) 

983 assert_close(transform.apply_inverse(x=output_x, y=output_y).x, input_x, atol=in_atol_v) 

984 assert_close(transform.apply_inverse(x=output_x, y=output_y).y, input_y, atol=in_atol_v) 

985 # Test quantity array interfaces. 

986 input_q_xy = XY(x=input_xy.x * transform.in_frame.unit, y=input_xy.y * transform.in_frame.unit) 

987 output_q_xy = XY(x=output_xy.x * transform.out_frame.unit, y=output_xy.y * transform.out_frame.unit) 

988 test_output_q_xy = transform.apply_forward_q(x=input_q_xy.x, y=input_q_xy.y) 

989 assert_close(test_output_q_xy.x, output_q_xy.x, atol=out_atol) 

990 assert_close(test_output_q_xy.y, output_q_xy.y, atol=out_atol) 

991 test_input_q_xy = transform.apply_inverse_q(x=output_q_xy.x, y=output_q_xy.y) 

992 assert_close(test_input_q_xy.x, input_q_xy.x, atol=in_atol) 

993 assert_close(test_input_q_xy.y, input_q_xy.y, atol=in_atol) 

994 # Test quantity scalar interfaces. 

995 for input_q_x, input_q_y, output_q_x, output_q_y in zip( 

996 input_q_xy.x, input_q_xy.y, output_q_xy.x, output_q_xy.y 

997 ): 

998 assert_close(transform.apply_forward_q(x=input_q_x, y=input_q_y).x, output_q_x, atol=out_atol) 

999 assert_close(transform.apply_forward_q(x=input_q_x, y=input_q_y).y, output_q_y, atol=out_atol) 

1000 assert_close(transform.apply_inverse_q(x=output_q_x, y=output_q_y).x, input_q_x, atol=in_atol) 

1001 assert_close(transform.apply_inverse_q(x=output_q_x, y=output_q_y).y, input_q_y, atol=in_atol) 

1002 if check_inverted: 

1003 # Test the inverse transform. 

1004 check_transform( 

1005 transform.inverted(), 

1006 output_xy, 

1007 input_xy, 

1008 out_frame, 

1009 in_frame, 

1010 check_inverted=False, 

1011 out_atol=in_atol, 

1012 in_atol=out_atol, 

1013 ) 

1014 

1015 

1016def check_projection[P: Frame]( 

1017 sky_projection: SkyProjection[P], 

1018 pixel_xy: XY[np.ndarray], 

1019 sky_coords: SkyCoord, 

1020 pixel_frame: Frame, 

1021 *, 

1022 pixel_atol: float | None = None, 

1023 sky_atol: u.Quantity | None = None, 

1024) -> None: 

1025 """Test a `.SkyProjection` instance against known arrays of pixel and sky 

1026 coordinates. 

1027 

1028 Parameters 

1029 ---------- 

1030 sky_projection 

1031 Projection to test. 

1032 pixel_xy 

1033 Arrays of pixel coordinates. 

1034 sky_coords 

1035 Corresponding sky coordinates. 

1036 pixel_frame 

1037 Expected pixel frame. 

1038 pixel_atol 

1039 Expected absolute precision of pixel points. 

1040 sky_atol 

1041 Expected absolute precision of sky coordinates. 

1042 """ 

1043 assert sky_projection.pixel_frame == pixel_frame 

1044 assert sky_projection.sky_frame == SkyFrame.ICRS 

1045 sky_atol_v = sky_atol.to_value(SkyFrame.ICRS.unit) if sky_atol is not None else None 

1046 pixel_atol_q = pixel_atol * u.pix if pixel_atol is not None else None 

1047 # Test array interfaces. 

1048 test_pixel_xy = cast(XY[np.ndarray], sky_projection.sky_to_pixel(sky_coords)) 

1049 assert_close(test_pixel_xy.x, pixel_xy.x, atol=pixel_atol) 

1050 assert_close(test_pixel_xy.y, pixel_xy.y, atol=pixel_atol) 

1051 test_sky_astropy = sky_projection.pixel_to_sky(x=pixel_xy.x, y=pixel_xy.y) 

1052 assert_close(test_sky_astropy.ra, sky_coords.ra, atol=sky_atol_v) 

1053 assert_close(test_sky_astropy.dec, sky_coords.dec, atol=sky_atol_v) 

1054 # Test scalar interfaces. 

1055 for pixel_x, pixel_y, sky_single in zip(pixel_xy.x, pixel_xy.y, sky_coords): 

1056 assert_close(sky_projection.sky_to_pixel(sky_single).x, pixel_x, atol=pixel_atol) 

1057 assert_close(sky_projection.sky_to_pixel(sky_single).y, pixel_y, atol=pixel_atol) 

1058 assert_close(sky_projection.pixel_to_sky(x=pixel_x, y=pixel_y).ra, sky_single.ra, atol=sky_atol_v) 

1059 assert_close(sky_projection.pixel_to_sky(x=pixel_x, y=pixel_y).dec, sky_single.dec, atol=sky_atol_v) 

1060 # Test the underlying Transform object. 

1061 sky_xy = XY(x=sky_coords.ra.to_value(u.rad), y=sky_coords.dec.to_value(u.rad)) 

1062 check_transform( 

1063 sky_projection.pixel_to_sky_transform, 

1064 pixel_xy, 

1065 sky_xy, 

1066 pixel_frame, 

1067 SkyFrame.ICRS, 

1068 check_inverted=False, 

1069 in_atol=pixel_atol_q, 

1070 out_atol=sky_atol, 

1071 ) 

1072 check_transform( 

1073 sky_projection.sky_to_pixel_transform, 

1074 sky_xy, 

1075 pixel_xy, 

1076 SkyFrame.ICRS, 

1077 pixel_frame, 

1078 check_inverted=False, 

1079 in_atol=sky_atol, 

1080 out_atol=pixel_atol_q, 

1081 ) 

1082 # Test the Astropy interface adapter. 

1083 check_astropy_wcs_interface( 

1084 sky_projection.as_astropy(), pixel_xy, sky_coords, pixel_atol=pixel_atol, sky_atol=sky_atol 

1085 ) 

1086 

1087 

1088def assert_sky_projections_equal( 

1089 a: SkyProjection[Any] | None, 

1090 b: SkyProjection[Any] | None, 

1091 expect_identity: bool | None = None, 

1092) -> None: 

1093 """Test that two `.SkyProjection` instances are equivalent. 

1094 

1095 Parameters 

1096 ---------- 

1097 a 

1098 First sky projection to compare. 

1099 b 

1100 Second sky projection to compare. 

1101 expect_identity 

1102 If not `None`, assert whether ``a`` and ``b`` are the same object. 

1103 """ 

1104 if a is None and b is None: 

1105 return 

1106 assert a is not None and b is not None 

1107 match expect_identity: 

1108 case True: 1108 ↛ 1109line 1108 didn't jump to line 1109 because the pattern on line 1108 never matched

1109 assert a is b 

1110 return 

1111 case False: 

1112 assert a is not b 

1113 case None if a is b: 

1114 return 

1115 assert a == b 

1116 

1117 

1118def check_astropy_wcs_interface( 

1119 wcs: astropy.wcs.wcsapi.BaseHighLevelWCS, 

1120 pixel_xy: XY[np.ndarray], 

1121 sky_coords: SkyCoord, 

1122 *, 

1123 pixel_atol: float | None = None, 

1124 sky_atol: u.Quantity | None = None, 

1125) -> None: 

1126 """Test an Astropy WCS instance against known arrays of pixel and 

1127 sky coordinates. 

1128 

1129 Parameters 

1130 ---------- 

1131 wcs 

1132 Astropy WCS object to test. 

1133 pixel_xy 

1134 Arrays of pixel coordinates. 

1135 sky_coords 

1136 Corresponding sky coordinates. 

1137 pixel_atol 

1138 Expected absolute precision of pixel points. 

1139 sky_atol 

1140 Expected absolute precision of sky coordinates. 

1141 """ 

1142 test_x, test_y = wcs.world_to_pixel(sky_coords) 

1143 assert_close(test_x, pixel_xy.x, atol=pixel_atol) 

1144 assert_close(test_y, pixel_xy.y, atol=pixel_atol) 

1145 test_sky_coords = wcs.pixel_to_world(pixel_xy.x, pixel_xy.y) 

1146 assert_close(test_sky_coords.ra, sky_coords.ra, atol=sky_atol) 

1147 assert_close(test_sky_coords.dec, sky_coords.dec, atol=sky_atol) 

1148 

1149 

1150def legacy_points_to_xy_array(legacy_points: list[Any]) -> XY[np.ndarray]: 

1151 """Convert a list of ``lsst.geom.Point2D`` objects to an `.XY` array. 

1152 

1153 Parameters 

1154 ---------- 

1155 legacy_points 

1156 Legacy ``lsst.geom.Point2D`` objects to convert. 

1157 """ 

1158 return XY(x=np.array([p.x for p in legacy_points]), y=np.array([p.y for p in legacy_points])) 

1159 

1160 

1161def legacy_coords_to_astropy(legacy_coords: list[Any]) -> SkyCoord: 

1162 """Convert a list of ``lsst.geom.SpherePoint`` objects to an Astropy 

1163 coordinate object. 

1164 

1165 Parameters 

1166 ---------- 

1167 legacy_coords 

1168 Legacy ``lsst.geom.SpherePoint`` objects to convert. 

1169 """ 

1170 return SkyCoord( 

1171 ra=np.array([p.getRa().asRadians() for p in legacy_coords]) * u.rad, 

1172 dec=np.array([p.getDec().asRadians() for p in legacy_coords]) * u.rad, 

1173 ) 

1174 

1175 

1176def arrays_to_legacy_points(x: np.ndarray, y: np.ndarray) -> list[Any]: 

1177 """Convert arrays of ``x`` and ``y`` to a list of ``lsst.geom.Point2D``. 

1178 

1179 Parameters 

1180 ---------- 

1181 x 

1182 X coordinates of the points. 

1183 y 

1184 Y coordinates of the points. 

1185 """ 

1186 from lsst.geom import Point2D 

1187 

1188 return [Point2D(x=xv, y=yv) for xv, yv in zip(x, y)] 

1189 

1190 

1191def compare_amplifier_to_legacy( 

1192 amplifier: Amplifier, 

1193 legacy_amplifier: Any, 

1194 *, 

1195 is_raw_assembled: bool, 

1196 expect_nominal_calibrations: bool = True, 

1197) -> None: 

1198 """Compare an `~.cameras.Amplifier` to a legacy 

1199 `lsst.afw.cameraGeom.Amplifier`. 

1200 

1201 Parameters 

1202 ---------- 

1203 amplifier 

1204 Amplifier to compare. 

1205 legacy_amplifier 

1206 Legacy `lsst.afw.cameraGeom.Amplifier` to compare against. 

1207 is_raw_assembled 

1208 Whether the raw geometry is expected to be the assembled-raw 

1209 geometry (`True`) or the unassembled-raw geometry (`False`). 

1210 expect_nominal_calibrations 

1211 Whether the amplifier is expected to carry nominal calibrations. 

1212 """ 

1213 assert legacy_amplifier.getName() == amplifier.name 

1214 assert Box.from_legacy(legacy_amplifier.getBBox()) == amplifier.bbox 

1215 if is_raw_assembled: 

1216 raw_geom = amplifier.assembled_raw_geometry 

1217 else: 

1218 raw_geom = amplifier.unassembled_raw_geometry 

1219 assert raw_geom is not None 

1220 assert ReadoutCorner.from_legacy(legacy_amplifier.getReadoutCorner()) == raw_geom.readout_corner 

1221 assert Box.from_legacy(legacy_amplifier.getRawBBox()) == raw_geom.bbox 

1222 assert Box.from_legacy(legacy_amplifier.getRawDataBBox()) == raw_geom.data_bbox 

1223 assert legacy_amplifier.getRawFlipX() == raw_geom.flip_x 

1224 assert legacy_amplifier.getRawFlipY() == raw_geom.flip_y 

1225 assert legacy_amplifier.getRawXYOffset().getX() == raw_geom.x_offset 

1226 assert legacy_amplifier.getRawXYOffset().getY() == raw_geom.y_offset 

1227 assert ( 

1228 Box.from_legacy(legacy_amplifier.getRawHorizontalOverscanBBox()) == raw_geom.horizontal_overscan_bbox 

1229 ) 

1230 assert Box.from_legacy(legacy_amplifier.getRawVerticalOverscanBBox()) == raw_geom.vertical_overscan_bbox 

1231 assert Box.from_legacy(legacy_amplifier.getRawPrescanBBox()) == raw_geom.horizontal_prescan_bbox 

1232 if expect_nominal_calibrations: 

1233 assert amplifier.nominal_calibrations is not None 

1234 assert_equal_allow_nan(legacy_amplifier.getGain(), amplifier.nominal_calibrations.gain) 

1235 assert_equal_allow_nan(legacy_amplifier.getReadNoise(), amplifier.nominal_calibrations.read_noise) 

1236 assert_equal_allow_nan(legacy_amplifier.getSaturation(), amplifier.nominal_calibrations.saturation) 

1237 assert_equal_allow_nan( 

1238 legacy_amplifier.getSuspectLevel(), amplifier.nominal_calibrations.suspect_level 

1239 ) 

1240 np.testing.assert_array_equal( 

1241 legacy_amplifier.getLinearityCoeffs(), amplifier.nominal_calibrations.linearity_coefficients 

1242 ) 

1243 assert legacy_amplifier.getLinearityType() == amplifier.nominal_calibrations.linearity_type 

1244 

1245 

1246def compare_detector_to_legacy( 

1247 detector: Detector, 

1248 legacy_detector: Any, 

1249 *, 

1250 is_raw_assembled: bool, 

1251 expect_nominal_calibrations: bool = True, 

1252) -> None: 

1253 """Compare a `~.cameras.Detector` to a `lsst.afw.cameraGeom.Detector`. 

1254 

1255 Parameters 

1256 ---------- 

1257 detector 

1258 Detector to compare. 

1259 legacy_detector 

1260 Legacy `lsst.afw.cameraGeom.Detector` to compare against. 

1261 is_raw_assembled 

1262 Whether the raw geometry is expected to be the assembled-raw 

1263 geometry (`True`) or the unassembled-raw geometry (`False`). 

1264 expect_nominal_calibrations 

1265 Whether the detector's amplifiers are expected to carry nominal 

1266 calibrations. 

1267 """ 

1268 from lsst.afw.cameraGeom import FIELD_ANGLE, FOCAL_PLANE, PIXELS 

1269 

1270 assert legacy_detector.getName() == detector.name 

1271 assert legacy_detector.getId() == detector.id 

1272 assert DetectorType.from_legacy(legacy_detector.getType()) == detector.type 

1273 assert Box.from_legacy(legacy_detector.getBBox()) == detector.bbox 

1274 assert legacy_detector.getSerial() == detector.serial 

1275 legacy_orientation = legacy_detector.getOrientation() 

1276 assert legacy_orientation.getFpPosition3().getX() == detector.orientation.focal_plane_x 

1277 assert legacy_orientation.getFpPosition3().getY() == detector.orientation.focal_plane_y 

1278 assert legacy_orientation.getFpPosition3().getZ() == detector.orientation.focal_plane_z 

1279 assert legacy_orientation.getReferencePoint().getX() == detector.orientation.pixel_reference_x 

1280 assert legacy_orientation.getReferencePoint().getY() == detector.orientation.pixel_reference_y 

1281 assert legacy_orientation.getYaw().asRadians() == detector.orientation.yaw.to_value(u.rad) 

1282 assert legacy_orientation.getPitch().asRadians() == detector.orientation.pitch.to_value(u.rad) 

1283 assert legacy_orientation.getRoll().asRadians() == detector.orientation.roll.to_value(u.rad) 

1284 assert legacy_detector.getPixelSize().getX() == detector.pixel_size 

1285 assert legacy_detector.getPhysicalType() == detector.physical_type 

1286 for amplifier, legacy_amplifier in zip(detector.amplifiers, legacy_detector.getAmplifiers(), strict=True): 

1287 compare_amplifier_to_legacy( 

1288 amplifier, 

1289 legacy_amplifier, 

1290 is_raw_assembled=is_raw_assembled, 

1291 expect_nominal_calibrations=expect_nominal_calibrations, 

1292 ) 

1293 pixel_xy = detector.bbox.meshgrid(n=3).map(lambda z: z.ravel().astype(np.float64)) 

1294 pixel_legacy_points = arrays_to_legacy_points(y=pixel_xy.y, x=pixel_xy.x) 

1295 fp_legacy_points = legacy_detector.transform(pixel_legacy_points, PIXELS, FOCAL_PLANE) 

1296 check_transform( 

1297 detector.to_focal_plane, 

1298 pixel_xy, 

1299 legacy_points_to_xy_array(fp_legacy_points), 

1300 detector.frame, 

1301 detector.to_focal_plane.out_frame, 

1302 in_atol=1e-9 * u.pix, 

1303 out_atol=1e-7 * detector.to_focal_plane.out_frame.unit, 

1304 ) 

1305 fa_legacy_points = legacy_detector.transform(pixel_legacy_points, PIXELS, FIELD_ANGLE) 

1306 check_transform( 

1307 detector.to_field_angle, 

1308 pixel_xy, 

1309 legacy_points_to_xy_array(fa_legacy_points), 

1310 detector.frame, 

1311 detector.to_field_angle.out_frame, 

1312 in_atol=1e-9 * u.pix, 

1313 out_atol=1e-7 * u.arcsec, 

1314 ) 

1315 

1316 

1317def iter_concrete_archive_tree_subclasses() -> Iterator[type[ArchiveTree]]: 

1318 """Yield every importable concrete `.serialization.ArchiveTree` subclass. 

1319 

1320 Walks the ``ArchiveTree.__subclasses__()`` tree, skipping abstract 

1321 classes. Importing this module already imports every ``lsst.images`` 

1322 module that defines a subclass, so the tree is fully populated by the time 

1323 this is called. 

1324 

1325 This discovery is deliberately separate from 

1326 `check_archive_tree_class_invariants` so that the per-class check stays 

1327 usable on a single class even if this metaprogramming is removed later. 

1328 """ 

1329 seen: set[type] = set() 

1330 stack: list[type] = [ArchiveTree] 

1331 while stack: 

1332 kls = stack.pop() 

1333 for sub in kls.__subclasses__(): 

1334 if sub in seen: 1334 ↛ 1335line 1334 didn't jump to line 1335 because the condition on line 1334 was never true

1335 continue 

1336 seen.add(sub) 

1337 stack.append(sub) 

1338 if not getattr(sub, "__abstractmethods__", None): 1338 ↛ 1333line 1338 didn't jump to line 1333 because the condition on line 1338 was always true

1339 yield sub 

1340 

1341 

1342def check_bounds_contains_broadcasting(bounds: Bounds) -> None: 

1343 """Verify that `~lsst.images.Bounds.contains` accepts array-like inputs. 

1344 

1345 Uses the scalar overload as the reference and checks that 1-D arrays, 

1346 list inputs, mixed scalar-plus-array inputs, 2-D broadcast inputs, and 

1347 `.XY` / `.YX` positional-argument forms all produce results consistent 

1348 with calling the scalar overload on each ``(x, y)`` pair individually. 

1349 

1350 Parameters 

1351 ---------- 

1352 bounds 

1353 The `~lsst.images.Bounds` implementation to exercise. 

1354 """ 

1355 bbox = bounds.bbox 

1356 # One point outside each boundary, one on each boundary, one in the 

1357 # interior. The boundary points (start and stop) straddle inside/outside 

1358 # (start is inside, stop is outside) so we are guaranteed a mix of True 

1359 # and False results without hard-coding expected values. 

1360 ys = np.array( 

1361 [ 

1362 bbox.y.start - 1, 

1363 bbox.y.start, 

1364 (bbox.y.start + bbox.y.stop) // 2, 

1365 bbox.y.stop, 

1366 ] 

1367 ) 

1368 xs = np.array( 

1369 [ 

1370 bbox.x.start - 1, 

1371 bbox.x.start, 

1372 (bbox.x.start + bbox.x.stop) // 2, 

1373 bbox.x.stop, 

1374 ] 

1375 ) 

1376 # 2-D reference: expected[i, j] == contains(x=xs[j], y=ys[i]). 

1377 expected = np.array( 

1378 [[bounds.contains(x=int(xi), y=int(yi)) for xi in xs] for yi in ys], 

1379 dtype=bool, 

1380 ) 

1381 # 1-D ndarray (diagonal pairs). 

1382 np.testing.assert_array_equal( 

1383 bounds.contains(x=xs, y=ys), 

1384 np.diagonal(expected), 

1385 ) 

1386 # list inputs (array-like). 

1387 np.testing.assert_array_equal( 

1388 bounds.contains(x=xs.tolist(), y=ys.tolist()), 

1389 np.diagonal(expected), 

1390 ) 

1391 # Mixed: scalar y, 1-D array x — produces a 1-D result. 

1392 fixed_yi = 1 # index into ys; ys[1] == bbox.y.start (on the boundary) 

1393 np.testing.assert_array_equal( 

1394 bounds.contains(x=xs, y=int(ys[fixed_yi])), 

1395 expected[fixed_yi, :], 

1396 ) 

1397 # Mixed: 1-D array y, scalar x. 

1398 fixed_xi = 1 # index into xs; xs[1] == bbox.x.start (on the boundary) 

1399 np.testing.assert_array_equal( 

1400 bounds.contains(x=int(xs[fixed_xi]), y=ys), 

1401 expected[:, fixed_xi], 

1402 ) 

1403 # Float scalars: results must match the int-scalar reference. 

1404 assert bounds.contains(x=float(xs[fixed_xi]), y=float(ys[fixed_yi])) == expected[fixed_yi, fixed_xi] 

1405 # XY / YX scalar: results must match the keyword-scalar reference. 

1406 assert bounds.contains(XY(x=int(xs[fixed_xi]), y=int(ys[fixed_yi]))) == expected[fixed_yi, fixed_xi] 

1407 assert bounds.contains(YX(y=int(ys[fixed_yi]), x=int(xs[fixed_xi]))) == expected[fixed_yi, fixed_xi] 

1408 # XY / YX array: results must match the 1-D ndarray reference. 

1409 np.testing.assert_array_equal(bounds.contains(XY(x=xs, y=ys)), np.diagonal(expected)) 

1410 np.testing.assert_array_equal(bounds.contains(YX(y=ys, x=xs)), np.diagonal(expected)) 

1411 # 2-D broadcast: y shape (N, 1) × x shape (1, M) → (N, M). 

1412 np.testing.assert_array_equal( 

1413 bounds.contains(x=xs.reshape(1, -1), y=ys.reshape(-1, 1)), 

1414 expected, 

1415 ) 

1416 

1417 

1418def check_archive_tree_class_invariants(tree_type: type[ArchiveTree]) -> None: 

1419 """Assert that one concrete `.serialization.ArchiveTree` subclass declares 

1420 well-formed schema-version constants and an in-memory type. 

1421 

1422 Checks that ``SCHEMA_NAME``, ``SCHEMA_VERSION``, ``MIN_READ_VERSION`` and 

1423 ``PUBLIC_TYPE`` are present and well-typed, that the version is 

1424 ``major.minor.patch``, and that ``MIN_READ_VERSION`` does not exceed the 

1425 schema major. 

1426 

1427 Parameters 

1428 ---------- 

1429 tree_type 

1430 The concrete `.serialization.ArchiveTree` subclass to check. 

1431 """ 

1432 assert hasattr(tree_type, "SCHEMA_NAME"), f"{tree_type.__name__} lacks SCHEMA_NAME" 

1433 assert hasattr(tree_type, "SCHEMA_VERSION"), f"{tree_type.__name__} lacks SCHEMA_VERSION" 

1434 assert hasattr(tree_type, "MIN_READ_VERSION"), f"{tree_type.__name__} lacks MIN_READ_VERSION" 

1435 assert hasattr(tree_type, "PUBLIC_TYPE"), f"{tree_type.__name__} lacks PUBLIC_TYPE" 

1436 assert isinstance(tree_type.SCHEMA_NAME, str) 

1437 assert len(tree_type.SCHEMA_NAME) > 0 

1438 assert re.fullmatch(r"^\d+\.\d+\.\d+$", tree_type.SCHEMA_VERSION) 

1439 assert isinstance(tree_type.MIN_READ_VERSION, int) 

1440 assert tree_type.MIN_READ_VERSION >= 1 

1441 assert isinstance(tree_type.PUBLIC_TYPE, type) 

1442 major = int(tree_type.SCHEMA_VERSION.split(".")[0]) 

1443 assert tree_type.MIN_READ_VERSION <= major