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

674 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-14 19:06 +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 

12from __future__ import annotations 

13 

14__all__ = ( 

15 "annotate_errors", 

16 "arrays_to_legacy_points", 

17 "assert_cell_coadds_equal", 

18 "assert_images_equal", 

19 "assert_masked_images_equal", 

20 "assert_masks_equal", 

21 "assert_obs_metadata_fields_equal", 

22 "assert_psfs_equal", 

23 "assert_sky_projections_equal", 

24 "assert_values_equal", 

25 "assert_visit_images_equal", 

26 "check_archive_tree_class_invariants", 

27 "check_astropy_wcs_interface", 

28 "check_bounds_contains_broadcasting", 

29 "check_projection", 

30 "check_transform", 

31 "compare_amplifier_to_legacy", 

32 "compare_aperture_corrections_to_legacy", 

33 "compare_cell_coadd_to_legacy", 

34 "compare_detector_to_legacy", 

35 "compare_field_to_legacy", 

36 "compare_image_to_legacy", 

37 "compare_mask_to_legacy", 

38 "compare_masked_image_to_legacy", 

39 "compare_observation_summary_stats_to_legacy", 

40 "compare_photo_calib_to_legacy", 

41 "compare_psf_to_legacy", 

42 "compare_sky_projection_to_legacy_wcs", 

43 "compare_visit_image_to_legacy", 

44 "iter_concrete_archive_tree_subclasses", 

45 "legacy_coords_to_astropy", 

46 "legacy_points_to_xy_array", 

47) 

48 

49import dataclasses 

50import re 

51from abc import ABC, abstractmethod 

52from collections.abc import Generator, Iterator, Mapping 

53from contextlib import contextmanager 

54from typing import TYPE_CHECKING, Any, Literal, NoReturn, Self, cast 

55 

56import astropy.coordinates as coords 

57import astropy.time 

58import astropy.units as u 

59import astropy.wcs.wcsapi 

60import numpy as np 

61import pytest 

62from astropy.coordinates import SkyCoord 

63 

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

65from .._image import Image 

66from .._mask import Mask, MaskPlane, get_legacy_visit_image_mask_planes 

67from .._masked_image import MaskedImage 

68from .._observation_summary_stats import ObservationSummaryStats 

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

70from .._visit_image import VisitImage 

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

72from ..cells import CellCoadd, CellIJ, CoaddProvenance 

73from ..fields import BaseField, ChebyshevField 

74from ..psfs import PointSpreadFunction 

75from ..serialization import ArchiveTree 

76 

77if TYPE_CHECKING: 

78 try: 

79 from lsst.cell_coadds import MultipleCellCoadd 

80 except ImportError: 

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

82 try: 

83 from lsst.afw.image import PhotoCalib as LegacyPhotoCalib 

84 except ImportError: 

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

86 

87 

88# The sky-to-pixel direction of a SkyProjection is a numerical AST inversion 

89# of the celestial projection, so it carries a small error floor (~1e-9 px). 

90# A relative-only tolerance collapses to exact equality at pixel coordinate 

91# zero, so comparisons that can include the origin need a small absolute pixel 

92# tolerance to avoid reporting that numerical noise as a difference. 

93_AST_INV_ATOL = 1e-7 

94 

95# Relative tolerance for comparisons of AST-backed transforms and projections. 

96_AST_RTOL = 1e-9 

97 

98# Default absolute tolerance for great-circle sky-coordinate comparisons, in 

99# the same order of magnitude as _AST_INV_ATOL (which is in pixels). A 

100# nonzero default is important because sky coordinates can be near zero 

101# (e.g. RA/Dec ~ 0), where a relative tolerance provides no error floor. 

102_AST_SKY_ATOL = 1e-7 * u.deg 

103 

104 

105class _BaseCmpArg(ABC): 

106 """A helper base clas for `assert_values_equal` whose subclasses represent 

107 different types of comparisons. 

108 

109 Parameters 

110 ---------- 

111 value 

112 The coerced operand; type is subclass-dependent.. 

113 """ 

114 

115 def __init__(self, value: Any): 

116 self.value = value 

117 

118 @property 

119 @abstractmethod 

120 def unit(self) -> u.UnitBase: 

121 raise NotImplementedError() 

122 

123 def coerce_atol(self, atol: float | u.Quantity | astropy.time.TimeDelta) -> float: 

124 try: 

125 if isinstance(atol, astropy.time.TimeDelta): 

126 return atol.to_value(self.unit) 

127 if atol != 0.0: 

128 return u.Quantity(atol).to_value(self.unit) 

129 return 0.0 

130 except u.UnitConversionError as err: 

131 err.add_note("Invalid atol for these operands.") 

132 raise 

133 

134 def get_nan_mask(self) -> np.ndarray: 

135 return np.isnan(np.asarray(self.value)) 

136 

137 @abstractmethod 

138 def get_separation(self, other: Self) -> np.ndarray: 

139 raise NotImplementedError 

140 

141 @abstractmethod 

142 def find_mismatches( 

143 self, 

144 other: Self, 

145 *, 

146 rtol: float, 

147 atol_value: float, 

148 equal_nan: bool, 

149 ) -> np.ndarray | None: 

150 """Search for element-wise mismatches against ``other``. 

151 

152 Each comparison family implements its own tolerance semantics. 

153 

154 Parameters 

155 ---------- 

156 other 

157 The other comparison argument. 

158 rtol 

159 Relative tolerance. 

160 atol_value 

161 Absolute tolerance, already coerced to this comparison's unit. 

162 equal_nan 

163 If `True`, treat NaN as equal to NaN wherever the operand can 

164 hold NaN. 

165 

166 Returns 

167 ------- 

168 mismatch : `numpy.ndarray` | None 

169 Boolean mask marking differing elements, or `None` if all 

170 elements agree within tolerance. 

171 """ 

172 raise NotImplementedError() 

173 

174 def report_mismatches( 

175 self, 

176 other: Self, 

177 a: Any, 

178 b: Any, 

179 mismatch: np.ndarray, 

180 label: str, 

181 ) -> NoReturn: 

182 """Raise an `AssertionError` reporting a computed mismatch. 

183 

184 Parameters 

185 ---------- 

186 other : _BaseCmpArg 

187 The other comparison argument. 

188 a 

189 First original operand; used for the scalar failure message. 

190 b 

191 Second original operand; used for the scalar failure message. 

192 mismatch 

193 Boolean mask marking differing elements, from `find_mismatches`. 

194 label 

195 Prefix prepended to the failure message. 

196 """ 

197 n_mismatch = int(np.count_nonzero(mismatch)) 

198 prefix = f"{label}: " if label else "" 

199 separation = self.get_separation(other) 

200 if mismatch.shape == (): 

201 raise AssertionError(f"{prefix}{a!r} != {b!r} (differ by {float(separation)}{self.unit})") 

202 nan_mismatch = int(np.count_nonzero(mismatch & (self.get_nan_mask() | other.get_nan_mask()))) 

203 nan_str = f"; {nan_mismatch} NaN mismatches" if nan_mismatch else "" 

204 if np.isnan(separation).all(): 

205 raise AssertionError(f"{prefix}{n_mismatch}/{mismatch.size} values differ{nan_str}") 

206 maxdiff = float(np.nanmax(separation)) 

207 # Convert to plain ints so the index prints as e.g. (2198,) 

208 # rather than (np.int64(2198),). 

209 loc = tuple(int(i) for i in np.unravel_index(np.nanargmax(separation), separation.shape)) 

210 raise AssertionError( 

211 f"{prefix}{n_mismatch}/{mismatch.size} values differ; " 

212 f"max abs diff {maxdiff}{self.unit} at index {loc} {nan_str}" 

213 ) 

214 

215 

216class _QuantityCmpArg(_BaseCmpArg): 

217 """Direct comparison of (possibly dimensionless) 

218 `astropy.units.Quantity` instances, used for all non-separation 

219 floating-point comparisons. 

220 """ 

221 

222 value: u.Quantity 

223 

224 @property 

225 def unit(self) -> u.UnitBase: 

226 return self.value.unit 

227 

228 def get_separation(self, other: Self) -> np.ndarray: 

229 a_vals = self.value.to_value(self.unit) 

230 b_vals = other.value.to_value(self.unit) 

231 return np.abs(a_vals - b_vals) 

232 

233 def find_mismatches( 

234 self, 

235 other: Self, 

236 *, 

237 rtol: float, 

238 atol_value: float, 

239 equal_nan: bool, 

240 ) -> np.ndarray | None: 

241 a_vals = self.value.to_value(self.unit) 

242 b_vals = other.value.to_value(self.unit) 

243 mismatch = ~np.isclose(a_vals, b_vals, rtol=rtol, atol=atol_value, equal_nan=equal_nan) 

244 return mismatch if np.any(mismatch) else None 

245 

246 

247class _SeparationCmpArg(_BaseCmpArg): 

248 """A base class for operands compared through a pairwise separation. 

249 

250 ``rtol`` is meaningless and must be zero for these comparisons. 

251 """ 

252 

253 @property 

254 @abstractmethod 

255 def unit(self) -> u.UnitBase: 

256 raise NotImplementedError 

257 

258 def find_mismatches( 

259 self, 

260 other: Self, 

261 *, 

262 rtol: float, 

263 atol_value: float, 

264 equal_nan: bool, 

265 ) -> np.ndarray | None: 

266 if rtol != 0.0: 

267 raise ValueError(f"rtol is meaningless for separations; use atol (in {self.unit}) instead.") 

268 # A NaN separation (from a NaN operand element) fails the ``<=`` 

269 # test and so counts as a mismatch unless equal_nan clears it. 

270 separation = np.asarray(self.get_separation(other)) 

271 mismatch = np.logical_not(separation <= atol_value) 

272 if equal_nan: 

273 mismatch = mismatch & ~(self.get_nan_mask() & other.get_nan_mask()) 

274 return mismatch if np.any(mismatch) else None 

275 

276 

277class _TimeCmpArg(_SeparationCmpArg): 

278 value: astropy.time.Time 

279 

280 @property 

281 def unit(self) -> u.UnitBase: 

282 return u.s 

283 

284 def get_separation(self, other: Self) -> np.ndarray: 

285 return np.abs((self.value - other.value).sec) 

286 

287 def get_nan_mask(self) -> np.ndarray: 

288 # astropy.time.Time rejects non-finite values at construction. 

289 return np.zeros(self.value.shape, dtype=bool) 

290 

291 

292class _SkyCoordCmpArg(_SeparationCmpArg): 

293 value: SkyCoord 

294 

295 @property 

296 def unit(self) -> u.UnitBase: 

297 return u.arcsec 

298 

299 def get_separation(self, other: Self) -> np.ndarray: 

300 return self.value.separation(other.value).to_value(self.unit) 

301 

302 def get_nan_mask(self) -> np.ndarray: 

303 # A coordinate counts as NaN only when *both* components are NaN; 

304 # a half-NaN coordinate is a difference to report, not a NaN match. 

305 return np.isnan(self.value.ra) & np.isnan(self.value.dec) 

306 

307 

308class _WrappedAngleCmpArg(_SeparationCmpArg): 

309 value: u.Quantity 

310 

311 @property 

312 def unit(self) -> u.UnitBase: 

313 return u.rad 

314 

315 def get_separation(self, other: Self) -> np.ndarray: 

316 a_rad = np.asarray(self.value.to_value(u.rad)) 

317 b_rad = np.asarray(other.value.to_value(u.rad)) 

318 return np.abs((a_rad - b_rad + np.pi) % (2.0 * np.pi) - np.pi) 

319 

320 

321def _classify_operand(arg: Any, *, wrap_angles: bool = False) -> _BaseCmpArg | None: 

322 """Coerce one comparison operand into its comparison-argument wrapper. 

323 

324 Parameters 

325 ---------- 

326 arg 

327 Operand to classify. 

328 wrap_angles 

329 If `True`, ``arg`` must be an angular `~astropy.units.Quantity` and 

330 is classified for wrapped-angle comparison. 

331 

332 Returns 

333 ------- 

334 cmp_arg : _BaseCmpArg | None 

335 Wrapper selecting the comparison space for ``arg``, or `None` for 

336 operands that cannot hold floating-point values (integers, bools, 

337 strings); those are compared exactly by the driver. 

338 """ 

339 if wrap_angles: 

340 return _WrappedAngleCmpArg(u.Quantity(arg).to(u.rad)) 

341 match arg: 

342 case astropy.time.Time(): 

343 return _TimeCmpArg(arg) 

344 case astropy.time.TimeDelta(): 

345 return _QuantityCmpArg(arg.to(u.s)) 

346 case SkyCoord(): 

347 return _SkyCoordCmpArg(arg) 

348 case u.Quantity(): 

349 if arg.dtype.kind not in "fc": 349 ↛ 351line 349 didn't jump to line 351 because the condition on line 349 was never true

350 # Coerce integer Quantities to floating point. 

351 arg = arg.astype(float) 

352 return _QuantityCmpArg(arg) 

353 arg = np.asarray(arg) 

354 if arg.dtype.kind not in "fc": 

355 return None 

356 return _QuantityCmpArg(u.Quantity(arg)) 

357 

358 

359def assert_values_equal( 

360 a: (np.ndarray | u.Quantity | float | astropy.time.Time | astropy.time.TimeDelta | SkyCoord), 

361 b: (np.ndarray | u.Quantity | float | astropy.time.Time | astropy.time.TimeDelta | SkyCoord), 

362 *, 

363 rtol: float = 0.0, 

364 atol: float | u.Quantity | astropy.time.TimeDelta = 0.0, 

365 equal_nan: bool = True, 

366 label: str = "", 

367 wrap_angles: bool = False, 

368) -> None: 

369 """Assert that two arrays, quantities, floats, astropy times, or sky 

370 coordinates are equal or close. 

371 

372 On mismatch this raises an `AssertionError` with a concise report (count, 

373 max absolute difference, location, NaN-mismatch count) rather than dumping 

374 the arrays. Exact (non-floating-point) mismatches instead report through 

375 `numpy.testing.assert_array_equal`. 

376 

377 Both operands must be of the same kind: both floating-point numeric 

378 (bare or as `~astropy.units.Quantity` or `~astropy.time.TimeDelta`), 

379 or both the same astropy type (`~astropy.time.Time` or 

380 `astropy.coordinates.SkyCoord`). 

381 Operands that cannot hold floating-point values (integers, bools, 

382 strings) are compared exactly, with no tolerance or NaN semantics. 

383 Tolerances never have their units guessed: a bare-float ``atol`` is 

384 only valid for dimensionless operands, and for unitful operands only 

385 exactly ``0.0`` (which is unit-independent) is accepted without units. 

386 

387 Parameters 

388 ---------- 

389 a 

390 First value to compare. 

391 b 

392 Second value to compare. 

393 rtol 

394 Relative tolerance, applied to the operand magnitudes. Must be zero 

395 for comparisons of separations. 

396 atol 

397 Absolute tolerance. For dimensionless operands, a bare float. For 

398 unitful operands, a `~astropy.units.Quantity` with units compatible 

399 with ``a``, or exactly ``0.0``. 

400 equal_nan 

401 If `True`, treat NaN as equal to NaN. A `astropy.coordinates.SkyCoord` 

402 element counts as NaN only when both of its coordinates are NaN. 

403 label 

404 Prefix prepended to the failure message. 

405 wrap_angles 

406 If `True`, compare ``a`` and ``b`` (angular quantities) modulo 2 pi. 

407 """ 

408 a_arg = _classify_operand(a, wrap_angles=wrap_angles) 

409 b_arg = _classify_operand(b, wrap_angles=wrap_angles) 

410 if type(a_arg) is not type(b_arg): 

411 raise TypeError(f"cannot compare {type(a).__name__} with {type(b).__name__}") 

412 if a_arg is None or b_arg is None: 

413 # The type check above means both are None: exact comparison of 

414 # operands that cannot hold floating-point values. 

415 if rtol != 0.0 or atol != 0.0: 

416 raise ValueError("rtol and atol are only supported for floating-point operands.") 

417 np.testing.assert_array_equal( 

418 np.asarray(a), 

419 np.asarray(b), 

420 strict=True, 

421 err_msg=f"{label}: arrays are not equal." if label else "", 

422 ) 

423 return 

424 atol_value = a_arg.coerce_atol(atol) 

425 if a_arg.value.shape != b_arg.value.shape: 

426 raise AssertionError(f"{label}: shape {a_arg.value.shape} != {b_arg.value.shape}") 

427 mismatch = a_arg.find_mismatches(b_arg, rtol=rtol, atol_value=atol_value, equal_nan=equal_nan) 

428 if mismatch is not None: 

429 a_arg.report_mismatches(b_arg, a, b, mismatch, label) 

430 

431 

432@contextmanager 

433def annotate_errors(note: str) -> Generator[None]: 

434 """Attach a note to any exception raised in the block. 

435 

436 Parameters 

437 ---------- 

438 note 

439 Note to attach on failure. 

440 """ 

441 try: 

442 yield 

443 except Exception as err: 

444 err.add_note(note) 

445 raise 

446 

447 

448def assert_images_equal( 

449 a: Image, 

450 b: Image, 

451 *, 

452 rtol: float = 0.0, 

453 atol: float = 0.0, 

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

455) -> None: 

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

457 

458 Parameters 

459 ---------- 

460 a 

461 First image to compare. 

462 b 

463 Second image to compare. 

464 rtol 

465 Relative tolerance for the pixel comparison. 

466 atol 

467 Absolute tolerance for the pixel comparison. 

468 expect_view 

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

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

471 """ 

472 assert a.bbox == b.bbox 

473 assert a.unit == b.unit 

474 assert_sky_projections_equal(a.sky_projection, b.sky_projection) 

475 if expect_view is not None: 

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

477 if expect_view == "array": 

478 assert a.metadata.native == b.metadata.native 

479 else: 

480 assert (a._metadata is b._metadata) == expect_view 

481 if not expect_view: 

482 assert_values_equal(a.array, b.array, atol=atol, rtol=rtol) 

483 assert a.metadata.native == b.metadata.native 

484 

485 

486def _note_mask_difference( 

487 err: AssertionError, a: Mask, b: Mask, *, label: str, names: Mapping[str, str] | None = None 

488) -> None: 

489 """Embed a plane-by-plane `Mask.compare` summary as a note on ``err``. 

490 

491 Parameters 

492 ---------- 

493 err 

494 Exception to attach the summary to. 

495 a 

496 First mask compared. 

497 b 

498 Second mask compared. 

499 label 

500 Prefix for the failure message. 

501 names 

502 Optional mapping from mask-plane names used by ``a`` and ``b`` to 

503 names to display in the summary (e.g. the legacy names, when 

504 comparing against legacy code). 

505 """ 

506 diff = a.compare(b) 

507 if not diff: 507 ↛ 508line 507 didn't jump to line 508 because the condition on line 507 was never true

508 return 

509 shown = names or {} 

510 summary = "\n".join( 

511 f"{label}[{shown.get(name, name)}]: +{added} -{removed}" for name, (added, removed) in diff.items() 

512 ) 

513 err.add_note(summary) 

514 

515 

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

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

518 

519 Parameters 

520 ---------- 

521 a 

522 First mask to compare. 

523 b 

524 Second mask to compare. 

525 """ 

526 assert a.bbox == b.bbox 

527 assert a.schema == b.schema 

528 assert a.metadata.native == b.metadata.native 

529 assert_sky_projections_equal(a.sky_projection, b.sky_projection) 

530 try: 

531 assert_values_equal(a.array, b.array, label="mask") 

532 except AssertionError as err: 

533 _note_mask_difference(err, a, b, label="mask") 

534 raise 

535 

536 

537def assert_masked_images_equal( 

538 a: MaskedImage, 

539 b: MaskedImage, 

540 *, 

541 rtol: float = 0.0, 

542 atol: float = 0.0, 

543 expect_view: bool | None = None, 

544) -> None: 

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

546 

547 Parameters 

548 ---------- 

549 a 

550 First masked image to compare. 

551 b 

552 Second masked image to compare. 

553 rtol 

554 Relative tolerance for the pixel comparison. 

555 atol 

556 Absolute tolerance for the pixel comparison. 

557 expect_view 

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

559 (i.e. is a view). 

560 """ 

561 assert a.metadata.native == b.metadata.native 

562 assert_sky_projections_equal(a.sky_projection, b.sky_projection) 

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

564 assert_masks_equal(a.mask, b.mask) 

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

566 

567 

568def assert_psfs_equal( 

569 psf1: PointSpreadFunction, 

570 psf2: PointSpreadFunction, 

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

572) -> int: 

573 """Compare two PSF objets. 

574 

575 Parameters 

576 ---------- 

577 psf1 

578 Point-spread function to test. 

579 psf2 

580 The other point-spread function to test. 

581 points 

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

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

584 

585 Returns 

586 ------- 

587 `int` 

588 The number of points actually tested. 

589 """ 

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

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

592 

593 assert psf1.kernel_bbox == psf2.kernel_bbox 

594 

595 n_points_tested: int = 0 

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

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

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

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

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

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

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

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

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

605 assert contains1 == contains2, ( 

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

607 ) 

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

609 continue 

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

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

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

613 n_points_tested += 1 

614 return n_points_tested 

615 

616 

617def assert_visit_images_equal( 

618 a: VisitImage, 

619 b: VisitImage, 

620 *, 

621 expect_view: bool | None = None, 

622) -> None: 

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

624 

625 Extends `assert_masked_images_equal` with the VisitImage-specific 

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

627 corrections, photometric scaling, backgrounds, polygon bounds, 

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

629 silently miss differences in any of them. 

630 

631 Parameters 

632 ---------- 

633 a 

634 First visit image to compare. 

635 b 

636 Second visit image to compare. 

637 expect_view 

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

639 (i.e. is a view). 

640 """ 

641 assert_masked_images_equal(a, b, expect_view=expect_view) 

642 assert a.summary_stats == b.summary_stats 

643 assert a.physical_filter == b.physical_filter 

644 assert a.band == b.band 

645 assert a.obs_info == b.obs_info 

646 assert a.detector == b.detector 

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

648 assert a.photometric_scaling == b.photometric_scaling 

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

650 assert a.backgrounds.subtracted == b.backgrounds.subtracted 

651 assert a.bounds == b.bounds 

652 assert_psfs_equal(a.psf, b.psf) 

653 

654 

655def assert_cell_coadds_equal( 

656 a: CellCoadd, 

657 b: CellCoadd, 

658 *, 

659 expect_view: bool | None = None, 

660) -> None: 

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

662 

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

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

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

666 silently miss differences in any of them. 

667 

668 Parameters 

669 ---------- 

670 a 

671 First cell coadd to compare. 

672 b 

673 Second cell coadd to compare. 

674 expect_view 

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

676 (i.e. is a view). 

677 """ 

678 assert_masked_images_equal(a, b, expect_view=expect_view) 

679 assert a.band == b.band 

680 assert a.patch == b.patch 

681 assert a.tract == b.tract 

682 assert a.grid == b.grid 

683 assert a.bounds.missing == b.bounds.missing 

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

685 assert a.backgrounds.subtracted == b.backgrounds.subtracted 

686 assert_psfs_equal(a.psf, b.psf) 

687 

688 

689def compare_image_to_legacy( 

690 image: Image, legacy_image: Any, expect_view: bool | None = None, *, label: str = "image" 

691) -> None: 

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

693 

694 Parameters 

695 ---------- 

696 image 

697 Image to compare. 

698 legacy_image 

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

700 expect_view 

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

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

703 label 

704 Prefix for the failure message. 

705 """ 

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

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

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

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

710 assert_values_equal(image.array, legacy_image.array, label=label) 

711 

712 

713def compare_mask_to_legacy( 

714 mask: Mask, 

715 legacy_mask: Any, 

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

717 *, 

718 label: str = "mask", 

719) -> None: 

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

721 

722 Parameters 

723 ---------- 

724 mask 

725 Mask to compare. 

726 legacy_mask 

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

728 plane_map 

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

730 the planes in ``mask.schema``. Schema planes the map does not name 

731 are compared under their own names. A plane the legacy mask does 

732 not define must have no pixels set in ``mask``. 

733 label 

734 Prefix for the failure message; each plane is reported as 

735 ``"{label}[<plane>]"``. 

736 """ 

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

738 if plane_map is None: 

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

740 legacy_planes = legacy_mask.getMaskPlaneDict() 

741 # Compare schema planes the map does not name (e.g. the planes of 

742 # `get_legacy_optional_mask_planes`) under their own names. 

743 plane_map = dict(plane_map) 

744 mapped = {plane.name for plane in plane_map.values()} 

745 plane_map.update( 

746 {plane.name: plane for plane in mask.schema if plane is not None and plane.name not in mapped} 

747 ) 

748 try: 

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

750 pixels = mask.get(new_plane.name) 

751 if old_name not in legacy_planes: 751 ↛ 753line 751 didn't jump to line 753 because the condition on line 751 was never true

752 # A plane the legacy mask does not define must be empty. 

753 assert_values_equal(np.zeros_like(pixels), pixels, label=f"{label}[{old_name}]") 

754 continue 

755 assert_values_equal( 

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

757 pixels, 

758 label=f"{label}[{old_name}]", 

759 ) 

760 except AssertionError as err: 

761 names = {new_plane.name: old_name for old_name, new_plane in plane_map.items()} 

762 _note_mask_difference(err, mask, Mask.from_legacy(legacy_mask, plane_map), label=label, names=names) 

763 raise 

764 

765 

766def compare_masked_image_to_legacy( 

767 masked_image: MaskedImage, 

768 legacy_masked_image: Any, 

769 *, 

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

771 expect_view: bool | None = None, 

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

773) -> None: 

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

775 object. 

776 

777 Parameters 

778 ---------- 

779 masked_image 

780 New image to test. 

781 legacy_masked_image 

782 Legacy image to test against. 

783 plane_map 

784 Mapping between new and legacy mask planes. 

785 expect_view 

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

787 memory. 

788 alternates 

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

790 check against the legacy versions of those components. 

791 """ 

792 compare_image_to_legacy( 

793 masked_image.image, legacy_masked_image.getImage(), expect_view=expect_view, label="image" 

794 ) 

795 compare_mask_to_legacy( 

796 masked_image.mask, legacy_masked_image.getMask(), plane_map=plane_map, label="mask" 

797 ) 

798 compare_image_to_legacy( 

799 masked_image.variance, legacy_masked_image.getVariance(), expect_view=expect_view, label="variance" 

800 ) 

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

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

803 compare_image_to_legacy( 

804 image, legacy_masked_image.getImage(), expect_view=expect_view, label="image" 

805 ) 

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

807 compare_mask_to_legacy(mask, legacy_masked_image.getMask(), plane_map=plane_map, label="mask") 

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

809 compare_image_to_legacy( 

810 variance, legacy_masked_image.getVariance(), expect_view=expect_view, label="variance" 

811 ) 

812 

813 

814def compare_visit_image_to_legacy( 

815 visit_image: VisitImage, 

816 legacy_exposure: Any, 

817 *, 

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

819 expect_view: bool | None = None, 

820 instrument: str, 

821 visit: int, 

822 detector: int, 

823 applied_legacy_photo_calib: LegacyPhotoCalib | None = None, 

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

825 check_photometric_scaling: bool = True, 

826) -> None: 

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

828 object. 

829 

830 Parameters 

831 ---------- 

832 visit_image 

833 New image to test. 

834 legacy_exposure 

835 Legacy image to test against. 

836 plane_map 

837 Mapping between new and legacy mask planes; defaults to the planes 

838 used by the legacy (`lsst.afw.image.Exposure`) visit image. 

839 expect_view 

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

841 memory. 

842 instrument 

843 Expected instrument name. 

844 visit 

845 Expected visit ID. 

846 detector 

847 Expected detector ID. 

848 applied_legacy_photo_calib 

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

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

851 alternates 

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

853 check against the legacy versions of those components. 

854 check_photometric_scaling 

855 If `False`, skip the photometric-scaling comparison. 

856 """ 

857 if plane_map is None: 

858 plane_map = get_legacy_visit_image_mask_planes() 

859 compare_masked_image_to_legacy( 

860 visit_image, 

861 legacy_exposure.getMaskedImage(), 

862 plane_map=plane_map, 

863 expect_view=expect_view, 

864 alternates=alternates, 

865 ) 

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

867 with annotate_errors("sky_projection"): 

868 compare_sky_projection_to_legacy_wcs( 

869 visit_image.sky_projection, 

870 legacy_exposure.getWcs(), 

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

872 visit_image.bbox, 

873 ) 

874 assert visit_image.sky_projection is visit_image.mask.sky_projection 

875 assert visit_image.sky_projection is visit_image.variance.sky_projection 

876 with annotate_errors("psf"): 

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

878 with annotate_errors("summary_stats"): 

879 compare_observation_summary_stats_to_legacy( 

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

881 ) 

882 with annotate_errors("detector"): 

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

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

885 # get expensive otherwise. 

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

887 with annotate_errors("aperture_corrections"): 

888 compare_aperture_corrections_to_legacy( 

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

890 ) 

891 if check_photometric_scaling: 

892 with annotate_errors("photometric_scaling"): 

893 compare_photo_calib_to_legacy( 

894 visit_image.photometric_scaling, 

895 legacy_exposure.info.getPhotoCalib(), 

896 applied_legacy_photo_calib=applied_legacy_photo_calib, 

897 subimage_bbox=tiny_bbox, 

898 ) 

899 if alternates: 

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

901 assert bbox == visit_image.bbox 

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

903 with annotate_errors("sky_projection"): 

904 compare_sky_projection_to_legacy_wcs( 

905 sky_projection, 

906 legacy_exposure.getWcs(), 

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

908 visit_image.bbox, 

909 ) 

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

911 with annotate_errors("psf"): 

912 compare_psf_to_legacy(psf, legacy_exposure.getPsf()) 

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

914 with annotate_errors("summary_stats"): 

915 compare_observation_summary_stats_to_legacy( 

916 summary_stats, legacy_exposure.info.getSummaryStats() 

917 ) 

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

919 with annotate_errors("detector"): 

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

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

922 visitInfo = legacy_exposure.visitInfo 

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

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

925 with annotate_errors("aperture_corrections"): 

926 compare_aperture_corrections_to_legacy( 

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

928 ) 

929 if check_photometric_scaling: 

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

931 with annotate_errors("photometric_scaling"): 

932 compare_photo_calib_to_legacy( 

933 photometric_scaling, 

934 legacy_exposure.info.getPhotoCalib(), 

935 applied_legacy_photo_calib=applied_legacy_photo_calib, 

936 subimage_bbox=tiny_bbox, 

937 ) 

938 

939 

940def compare_photo_calib_to_legacy( 

941 photometric_scaling: BaseField | None, 

942 legacy_photo_calib: LegacyPhotoCalib, 

943 *, 

944 applied_legacy_photo_calib: LegacyPhotoCalib | None = None, 

945 subimage_bbox: Box, 

946) -> None: 

947 if legacy_photo_calib._isConstant: 

948 if legacy_photo_calib.getCalibrationMean() == 1.0: 

949 if applied_legacy_photo_calib is None: 

950 assert photometric_scaling is None 

951 return 

952 else: 

953 legacy_photo_calib = applied_legacy_photo_calib 

954 if legacy_photo_calib._isConstant: 

955 assert isinstance(photometric_scaling, ChebyshevField) 

956 assert_values_equal( 

957 photometric_scaling.coefficients, 

958 np.array([[legacy_photo_calib.getCalibrationMean()]]), 

959 # The coefficients come from a numerical fit, so this can be 

960 # bigger than just round-off error epislon. 

961 rtol=1e-5, 

962 ) 

963 else: 

964 assert photometric_scaling is not None 

965 compare_field_to_legacy( 

966 photometric_scaling / legacy_photo_calib.getCalibrationMean(), 

967 legacy_photo_calib.computeScaledCalibration(), 

968 subimage_bbox, 

969 ) 

970 

971 

972def compare_cell_coadd_to_legacy( 

973 cell_coadd: CellCoadd, 

974 legacy_cell_coadd: MultipleCellCoadd, 

975 *, 

976 tract_bbox: Box, 

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

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

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

980) -> None: 

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

982 `lsst.cell_coadds.MultipleCellCoadd` object. 

983 

984 Parameters 

985 ---------- 

986 cell_coadd 

987 New coadd to test. 

988 legacy_cell_coadd 

989 Legacy coadd to test against. 

990 tract_bbox 

991 Bounding box of the full tract. 

992 plane_map 

993 Mapping between new and legacy mask planes. 

994 alternates 

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

996 check against the legacy versions of those components. 

997 psf_points 

998 Points to use to compare the PSFs. 

999 """ 

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

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

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

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

1004 if legacy_stitched.mask_fractions is not None: 

1005 compare_image_to_legacy( 

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

1007 ) 

1008 for n in range(legacy_stitched.n_noise_realizations): 

1009 compare_image_to_legacy( 

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

1011 ) 

1012 assert cell_coadd.skymap == legacy_stitched.identifiers.skymap 

1013 assert cell_coadd.tract == legacy_stitched.identifiers.tract 

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

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

1016 assert cell_coadd.band == legacy_stitched.identifiers.band 

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

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

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

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

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

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

1023 compare_sky_projection_to_legacy_wcs( 

1024 cell_coadd.sky_projection, 

1025 legacy_cell_coadd.wcs, 

1026 TractFrame( 

1027 skymap=legacy_cell_coadd.identifiers.skymap, 

1028 tract=legacy_cell_coadd.identifiers.tract, 

1029 bbox=tract_bbox, 

1030 ), 

1031 cell_coadd.bbox, 

1032 is_fits=True, 

1033 ) 

1034 assert cell_coadd.sky_projection is cell_coadd.mask.sky_projection 

1035 assert cell_coadd.sky_projection is cell_coadd.variance.sky_projection 

1036 compare_psf_to_legacy( 

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

1038 ) 

1039 compare_aperture_corrections_to_legacy( 

1040 cell_coadd.aperture_corrections, legacy_stitched.ap_corr_map, cell_coadd.bbox 

1041 ) 

1042 compare_cell_coadd_provenance_to_legacy(cell_coadd.provenance, legacy_cell_coadd) 

1043 if alternates: 

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

1045 compare_sky_projection_to_legacy_wcs( 

1046 sky_projection, 

1047 legacy_stitched.wcs, 

1048 TractFrame( 

1049 skymap=legacy_cell_coadd.identifiers.skymap, 

1050 tract=legacy_cell_coadd.identifiers.tract, 

1051 bbox=tract_bbox, 

1052 ), 

1053 cell_coadd.bbox, 

1054 is_fits=True, 

1055 ) 

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

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

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

1059 compare_aperture_corrections_to_legacy( 

1060 aperture_corrections, legacy_stitched.ap_corr_map, cell_coadd.bbox 

1061 ) 

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

1063 compare_cell_coadd_provenance_to_legacy(provenance, legacy_cell_coadd) 

1064 

1065 

1066def compare_cell_coadd_provenance_to_legacy( 

1067 provenance: CoaddProvenance, legacy_cell_coadd: MultipleCellCoadd 

1068) -> None: 

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

1070 `lsst.cell_coadds.MultipleCellCoadd` object. 

1071 

1072 Parameters 

1073 ---------- 

1074 provenance 

1075 New provenance object to test. 

1076 legacy_cell_coadd 

1077 Legacy coadd to test against. 

1078 """ 

1079 from lsst.cell_coadds import ObservationIdentifiers 

1080 

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

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

1083 prov = provenance[cell_index] 

1084 assert prov is not None, f"Legacy cell {cell_index} has inputs, so its provenance is not empty." 

1085 legacy_table = astropy.table.Table( 

1086 rows=[ 

1087 [ 

1088 ids.instrument, 

1089 ids.visit, 

1090 ids.detector, 

1091 ids.day_obs, 

1092 ids.physical_filter, 

1093 legacy_input.overlaps_center, 

1094 legacy_input.overlap_fraction, 

1095 legacy_input.weight, 

1096 legacy_input.psf_shape.getIxx(), 

1097 legacy_input.psf_shape.getIyy(), 

1098 legacy_input.psf_shape.getIxy(), 

1099 legacy_input.psf_shape_flag, 

1100 ] 

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

1102 ], 

1103 dtype=[ 

1104 np.object_, 

1105 np.uint64, 

1106 np.uint16, 

1107 np.uint32, 

1108 np.object_, 

1109 np.bool_, 

1110 np.float64, 

1111 np.float64, 

1112 np.float64, 

1113 np.float64, 

1114 np.float64, 

1115 np.bool_, 

1116 ], 

1117 names=[ 

1118 "instrument", 

1119 "visit", 

1120 "detector", 

1121 "day_obs", 

1122 "physical_filter", 

1123 "overlaps_center", 

1124 "overlap_fraction", 

1125 "weight", 

1126 "psf_shape_xx", 

1127 "psf_shape_yy", 

1128 "psf_shape_xy", 

1129 "psf_shape_flag", 

1130 ], 

1131 ) 

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

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

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

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

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

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

1138 assert_values_equal(prov.inputs["instrument"], prov.contributions["instrument"], label="inputs") 

1139 assert_values_equal(prov.inputs["visit"], prov.contributions["visit"], label="inputs") 

1140 assert_values_equal(prov.inputs["detector"], prov.contributions["detector"], label="inputs") 

1141 assert_values_equal(prov.inputs["instrument"], legacy_table["instrument"], label="inputs") 

1142 assert_values_equal(prov.inputs["visit"], legacy_table["visit"], label="inputs") 

1143 assert_values_equal(prov.inputs["detector"], legacy_table["detector"], label="inputs") 

1144 assert_values_equal(prov.inputs["physical_filter"], legacy_table["physical_filter"], label="inputs") 

1145 assert_values_equal(prov.inputs["day_obs"], legacy_table["day_obs"], label="inputs") 

1146 assert_values_equal( 

1147 prov.contributions["overlaps_center"], legacy_table["overlaps_center"], label="inputs" 

1148 ) 

1149 assert_values_equal( 

1150 prov.contributions["overlap_fraction"], legacy_table["overlap_fraction"], label="inputs" 

1151 ) 

1152 assert_values_equal(prov.contributions["weight"], legacy_table["weight"], label="inputs") 

1153 assert_values_equal(prov.contributions["psf_shape_xx"], legacy_table["psf_shape_xx"], label="inputs") 

1154 assert_values_equal(prov.contributions["psf_shape_yy"], legacy_table["psf_shape_yy"], label="inputs") 

1155 assert_values_equal(prov.contributions["psf_shape_xy"], legacy_table["psf_shape_xy"], label="inputs") 

1156 assert_values_equal( 

1157 prov.contributions["psf_shape_flag"], legacy_table["psf_shape_flag"], label="inputs" 

1158 ) 

1159 for row in prov.inputs: 

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

1161 legacy_polygon = legacy_cell_coadd.common.visit_polygons[polygon_key] 

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

1163 

1164 

1165def compare_psf_to_legacy( 

1166 psf: PointSpreadFunction, 

1167 legacy_psf: Any, 

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

1169 expect_legacy_raise_on_out_of_bounds: bool = False, 

1170) -> int: 

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

1172 

1173 Parameters 

1174 ---------- 

1175 psf 

1176 Point-spread function to test. 

1177 legacy_psf 

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

1179 points 

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

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

1182 expect_legacy_raise_on_out_of_bounds 

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

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

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

1186 

1187 Returns 

1188 ------- 

1189 `int` 

1190 The number of points actually tested. 

1191 """ 

1192 from lsst.afw.detection import InvalidPsfError 

1193 

1194 if points is None: 

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

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

1197 n_points_tested: int = 0 

1198 for p in legacy_points: 

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

1200 if expect_legacy_raise_on_out_of_bounds: 

1201 with pytest.raises(InvalidPsfError): 

1202 legacy_psf.computeKernelImage(p) 

1203 continue 

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

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

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

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

1208 n_points_tested += 1 

1209 return n_points_tested 

1210 

1211 

1212def compare_field_to_legacy( 

1213 field: BaseField, 

1214 legacy_field: Any, 

1215 subimage_bbox: Box, 

1216) -> None: 

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

1218 `lsst.afw.math.BoundedField`. 

1219 

1220 Parameters 

1221 ---------- 

1222 field 

1223 Field to test. 

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

1225 Equivalent legacy bounded field. 

1226 subimage_bbox 

1227 Bounding box for full-image tests. 

1228 """ 

1229 from lsst.afw.math import BoundedField as LegacyBoundedField 

1230 

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

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

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

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

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

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

1237 try: 

1238 assert_values_equal( 

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

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

1241 # The field coefficients come from a numerical fit, so this can 

1242 # be bigger than just round-off error epsilon. 

1243 rtol=1e-5, 

1244 ) 

1245 except AssertionError as err: 

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

1247 raise 

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

1249 # Legacy StitchedApertureCorrection objects are not true BoundedFields 

1250 # and don't have addToImage. 

1251 return 

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

1253 legacy_field.addToImage(legacy_image_1, overlapOnly=True) 

1254 assert_images_equal( 

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

1256 ) 

1257 

1258 

1259def compare_aperture_corrections_to_legacy( 

1260 aperture_corrections: Mapping[str, BaseField], 

1261 legacy_ap_corr_map: Any, 

1262 subimage_bbox: Box, 

1263) -> None: 

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

1265 `lsst.afw.image.ApCorrMap`. 

1266 

1267 Parameters 

1268 ---------- 

1269 aperture_corrections 

1270 Dictionary to test. 

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

1272 Equivalent legacy aperture correction map. 

1273 subimage_bbox 

1274 Bounding box for full-image tests. 

1275 """ 

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

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

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

1279 

1280 

1281def compare_observation_summary_stats_to_legacy( 

1282 summary_stats: ObservationSummaryStats, 

1283 legacy_summary_stats: Any, 

1284) -> None: 

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

1286 `lsst.afw.image.ExposureSummaryStats`. 

1287 

1288 Parameters 

1289 ---------- 

1290 summary_stats 

1291 Struct to test. 

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

1293 Equivalent legacy struct. 

1294 """ 

1295 for field in dataclasses.fields(legacy_summary_stats): 

1296 # Always skip version since that field has no meaning in the new 

1297 # type. 

1298 if field.name == "version": 

1299 continue 

1300 a = getattr(legacy_summary_stats, field.name) 

1301 b = getattr(summary_stats, field.name) 

1302 assert_values_equal(a, b, label=field.name) 

1303 

1304 

1305def compare_sky_projection_to_legacy_wcs[F: Frame]( 

1306 sky_projection: SkyProjection[F], 

1307 legacy_wcs: Any, 

1308 pixel_frame: F, 

1309 subimage_bbox: Box, 

1310 is_fits: bool = False, 

1311) -> None: 

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

1313 `lsst.afw.geom.SkyWcs`. 

1314 

1315 Parameters 

1316 ---------- 

1317 sky_projection 

1318 Projection to test. 

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

1320 Equivalent legacy WCS. 

1321 pixel_frame 

1322 Expected pixel frame for the sky_projection. 

1323 subimage_bbox 

1324 Bounding box of points to generate for tests. 

1325 is_fits 

1326 Whether this sky_projection is expected to be exactly representable as 

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

1328 attached instead. 

1329 """ 

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

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

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

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

1334 sky_coords = legacy_coords_to_astropy( 

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

1336 ) 

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

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

1339 # change. 

1340 check_projection(sky_projection, pixel_xy, sky_coords, pixel_frame, pixel_atol=_AST_INV_ATOL) 

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

1342 # array indices. 

1343 check_astropy_wcs_interface( 

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

1345 ) 

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

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

1348 assert fits_wcs is not None 

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

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

1351 # Projection from a FITS WCS, too. 

1352 fits_projection = SkyProjection.from_fits_wcs(fits_wcs, pixel_frame) 

1353 check_projection( 

1354 fits_projection, 

1355 subimage_array_xy, 

1356 sky_coords, 

1357 pixel_frame, 

1358 pixel_atol=1e-5, 

1359 ) 

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

1361 # AST FrameSet so we can convert them into legacy 

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

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

1364 else: 

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

1366 # The legacy SkyWcs should instead have a FITS approximation 

1367 # attached; run the same tests on that. 

1368 assert sky_projection.fits_approximation is not None 

1369 compare_sky_projection_to_legacy_wcs( 

1370 sky_projection.fits_approximation, 

1371 legacy_wcs.getFitsApproximation(), 

1372 pixel_frame, 

1373 subimage_bbox, 

1374 is_fits=True, 

1375 ) 

1376 

1377 

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

1379 transform: Transform[I, O], 

1380 input_xy: XY[np.ndarray], 

1381 output_xy: XY[np.ndarray], 

1382 in_frame: Frame, 

1383 out_frame: Frame, 

1384 *, 

1385 check_inverted: bool = True, 

1386 in_atol: u.Quantity | float | None = None, 

1387 out_atol: u.Quantity | float | None = None, 

1388 angle_wrapped: bool = False, 

1389) -> None: 

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

1391 

1392 Parameters 

1393 ---------- 

1394 transform 

1395 Transform to test. 

1396 input_xy 

1397 Arrays of input points. 

1398 output_xy 

1399 Arrays of output points. 

1400 in_frame 

1401 Expected input frame. 

1402 out_frame 

1403 Expected output frame. 

1404 check_inverted 

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

1406 in_atol 

1407 Expected absolute precision of input points. 

1408 out_atol 

1409 Expected absolute precision of output points. 

1410 angle_wrapped 

1411 If `True`, treat comparisons on sky-coordinate axes (as determined 

1412 by the frames) as angles modulo 2 pi, so differences that cross 

1413 zero or 360 degrees compare correctly. 

1414 """ 

1415 assert transform.in_frame == in_frame 

1416 assert transform.out_frame == out_frame 

1417 in_atol = 0.0 if in_atol is None else in_atol 

1418 out_atol = 0.0 if out_atol is None else out_atol 

1419 in_atol_v = in_atol.to_value(in_frame.unit) if isinstance(in_atol, u.Quantity) else float(in_atol) 

1420 out_atol_v = out_atol.to_value(out_frame.unit) if isinstance(out_atol, u.Quantity) else float(out_atol) 

1421 out_wrapped = angle_wrapped and out_frame == SkyFrame.ICRS 

1422 in_wrapped = angle_wrapped and in_frame == SkyFrame.ICRS 

1423 

1424 def assert_close( 

1425 test: np.ndarray | float | u.Quantity, 

1426 expected: np.ndarray | float | u.Quantity, 

1427 atol: float | u.Quantity, 

1428 *, 

1429 wrapped: bool, 

1430 ) -> None: 

1431 """Compare one axis, wrap-aware if that axis is a sky angle. 

1432 

1433 Parameters 

1434 ---------- 

1435 test 

1436 Values to test. 

1437 expected 

1438 Expected values. 

1439 atol 

1440 Absolute tolerance, in the units the values are expressed in. 

1441 wrapped 

1442 If `True`, treat the values as angles modulo 2 pi (radians). 

1443 """ 

1444 if not wrapped: 

1445 assert_values_equal(test, expected, atol=atol, rtol=_AST_RTOL) 

1446 return 

1447 test_values = np.asarray(getattr(test, "value", test)) 

1448 expected_values = np.asarray(getattr(expected, "value", expected)) 

1449 atol_rad = atol.to_value(u.rad) if isinstance(atol, u.Quantity) else float(atol) 

1450 assert_values_equal( 

1451 test_values * u.rad, 

1452 expected_values * u.rad, 

1453 atol=atol_rad * u.rad, 

1454 equal_nan=False, 

1455 wrap_angles=True, 

1456 ) 

1457 

1458 # Test array interfaces. 

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

1460 assert_close(test_output_xy.x, output_xy.x, out_atol_v, wrapped=out_wrapped) 

1461 assert_close(test_output_xy.y, output_xy.y, out_atol_v, wrapped=out_wrapped) 

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

1463 assert_close(test_input_xy.x, input_xy.x, in_atol_v, wrapped=in_wrapped) 

1464 assert_close(test_input_xy.y, input_xy.y, in_atol_v, wrapped=in_wrapped) 

1465 # Test scalar interfaces with numpy scalars. 

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

1467 forward_xy = transform.apply_forward(x=input_x, y=input_y) 

1468 assert_close(forward_xy.x, output_x, out_atol_v, wrapped=out_wrapped) 

1469 assert_close(forward_xy.y, output_y, out_atol_v, wrapped=out_wrapped) 

1470 inverse_xy = transform.apply_inverse(x=output_x, y=output_y) 

1471 assert_close(inverse_xy.x, input_x, in_atol_v, wrapped=in_wrapped) 

1472 assert_close(inverse_xy.y, input_y, in_atol_v, wrapped=in_wrapped) 

1473 # Test quantity array interfaces. 

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

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

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

1477 assert_close(test_output_q_xy.x, output_q_xy.x, out_atol, wrapped=out_wrapped) 

1478 assert_close(test_output_q_xy.y, output_q_xy.y, out_atol, wrapped=out_wrapped) 

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

1480 assert_close(test_input_q_xy.x, input_q_xy.x, in_atol, wrapped=in_wrapped) 

1481 assert_close(test_input_q_xy.y, input_q_xy.y, in_atol, wrapped=in_wrapped) 

1482 # Test quantity scalar interfaces. 

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

1484 input_q_xy.x, input_q_xy.y, output_q_xy.x, output_q_xy.y 

1485 ): 

1486 forward_q_xy = transform.apply_forward_q(x=input_q_x, y=input_q_y) 

1487 assert_close(forward_q_xy.x, output_q_x, out_atol, wrapped=out_wrapped) 

1488 assert_close(forward_q_xy.y, output_q_y, out_atol, wrapped=out_wrapped) 

1489 inverse_q_xy = transform.apply_inverse_q(x=output_q_x, y=output_q_y) 

1490 assert_close(inverse_q_xy.x, input_q_x, in_atol, wrapped=in_wrapped) 

1491 assert_close(inverse_q_xy.y, input_q_y, in_atol, wrapped=in_wrapped) 

1492 if check_inverted: 

1493 # Test the inverse transform. 

1494 check_transform( 

1495 transform.inverted(), 

1496 output_xy, 

1497 input_xy, 

1498 out_frame, 

1499 in_frame, 

1500 check_inverted=False, 

1501 out_atol=in_atol, 

1502 in_atol=out_atol, 

1503 angle_wrapped=angle_wrapped, 

1504 ) 

1505 

1506 

1507def check_projection[P: Frame]( 

1508 sky_projection: SkyProjection[P], 

1509 pixel_xy: XY[np.ndarray], 

1510 sky_coords: SkyCoord, 

1511 pixel_frame: Frame, 

1512 *, 

1513 pixel_atol: float | None = None, 

1514 sky_atol: u.Quantity | None = None, 

1515) -> None: 

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

1517 coordinates. 

1518 

1519 Parameters 

1520 ---------- 

1521 sky_projection 

1522 Projection to test. 

1523 pixel_xy 

1524 Arrays of pixel coordinates. 

1525 sky_coords 

1526 Corresponding sky coordinates. 

1527 pixel_frame 

1528 Expected pixel frame. 

1529 pixel_atol 

1530 Expected absolute precision of pixel points. 

1531 sky_atol 

1532 Expected absolute precision of sky coordinates, as a maximum 

1533 great-circle separation. 

1534 """ 

1535 assert sky_projection.pixel_frame == pixel_frame 

1536 assert sky_projection.sky_frame == SkyFrame.ICRS 

1537 sky_atol = _AST_SKY_ATOL if sky_atol is None else u.Quantity(sky_atol) 

1538 pixel_atol = 0.0 if pixel_atol is None else pixel_atol 

1539 pixel_atol_q = pixel_atol * u.pix 

1540 # Test array interfaces. 

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

1542 assert_values_equal(test_pixel_xy.x, pixel_xy.x, atol=pixel_atol, rtol=_AST_RTOL) 

1543 assert_values_equal(test_pixel_xy.y, pixel_xy.y, atol=pixel_atol, rtol=_AST_RTOL) 

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

1545 assert_values_equal(test_sky_astropy, sky_coords, atol=sky_atol, label="pixel_to_sky") 

1546 # Test scalar interfaces. 

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

1548 assert_values_equal( 

1549 sky_projection.sky_to_pixel(sky_single).x, pixel_x, atol=pixel_atol, rtol=_AST_RTOL 

1550 ) 

1551 assert_values_equal( 

1552 sky_projection.sky_to_pixel(sky_single).y, pixel_y, atol=pixel_atol, rtol=_AST_RTOL 

1553 ) 

1554 test_sky_single = sky_projection.pixel_to_sky(x=pixel_x, y=pixel_y) 

1555 assert_values_equal(test_sky_single, sky_single, atol=sky_atol, label="pixel_to_sky") 

1556 # Test the underlying Transform object. 

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

1558 check_transform( 

1559 sky_projection.pixel_to_sky_transform, 

1560 pixel_xy, 

1561 sky_xy, 

1562 pixel_frame, 

1563 SkyFrame.ICRS, 

1564 check_inverted=False, 

1565 in_atol=pixel_atol_q, 

1566 out_atol=sky_atol, 

1567 angle_wrapped=True, 

1568 ) 

1569 check_transform( 

1570 sky_projection.sky_to_pixel_transform, 

1571 sky_xy, 

1572 pixel_xy, 

1573 SkyFrame.ICRS, 

1574 pixel_frame, 

1575 check_inverted=False, 

1576 in_atol=sky_atol, 

1577 out_atol=pixel_atol_q, 

1578 angle_wrapped=True, 

1579 ) 

1580 # Test the Astropy interface adapter. 

1581 check_astropy_wcs_interface( 

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

1583 ) 

1584 

1585 

1586def assert_sky_projections_equal( 

1587 a: SkyProjection[Any] | None, 

1588 b: SkyProjection[Any] | None, 

1589 expect_identity: bool | None = None, 

1590) -> None: 

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

1592 

1593 Parameters 

1594 ---------- 

1595 a 

1596 First sky projection to compare. 

1597 b 

1598 Second sky projection to compare. 

1599 expect_identity 

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

1601 """ 

1602 if a is None and b is None: 

1603 return 

1604 assert a is not None 

1605 assert b is not None 

1606 match expect_identity: 

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

1608 assert a is b 

1609 return 

1610 case False: 

1611 assert a is not b 

1612 case None if a is b: 

1613 return 

1614 assert a == b 

1615 

1616 

1617def check_astropy_wcs_interface( 

1618 wcs: astropy.wcs.wcsapi.BaseHighLevelWCS, 

1619 pixel_xy: XY[np.ndarray], 

1620 sky_coords: SkyCoord, 

1621 *, 

1622 pixel_atol: float | None = None, 

1623 sky_atol: u.Quantity | None = None, 

1624) -> None: 

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

1626 sky coordinates. 

1627 

1628 Parameters 

1629 ---------- 

1630 wcs 

1631 Astropy WCS object to test. 

1632 pixel_xy 

1633 Arrays of pixel coordinates. 

1634 sky_coords 

1635 Corresponding sky coordinates. 

1636 pixel_atol 

1637 Expected absolute precision of pixel points. 

1638 sky_atol 

1639 Expected absolute precision of sky coordinates, as a maximum 

1640 great-circle separation. 

1641 """ 

1642 pixel_atol = 0.0 if pixel_atol is None else pixel_atol 

1643 sky_atol = _AST_SKY_ATOL if sky_atol is None else u.Quantity(sky_atol) 

1644 test_x, test_y = wcs.world_to_pixel(sky_coords) 

1645 assert_values_equal(test_x, pixel_xy.x, atol=pixel_atol, rtol=_AST_RTOL) 

1646 assert_values_equal(test_y, pixel_xy.y, atol=pixel_atol, rtol=_AST_RTOL) 

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

1648 assert_values_equal(test_sky_coords, sky_coords, atol=sky_atol, label="pixel_to_world") 

1649 

1650 

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

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

1653 

1654 Parameters 

1655 ---------- 

1656 legacy_points 

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

1658 """ 

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

1660 

1661 

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

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

1664 coordinate object. 

1665 

1666 Parameters 

1667 ---------- 

1668 legacy_coords 

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

1670 """ 

1671 return SkyCoord( 

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

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

1674 ) 

1675 

1676 

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

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

1679 

1680 Parameters 

1681 ---------- 

1682 x 

1683 X coordinates of the points. 

1684 y 

1685 Y coordinates of the points. 

1686 """ 

1687 from lsst.geom import Point2D 

1688 

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

1690 

1691 

1692def compare_amplifier_to_legacy( 

1693 amplifier: Amplifier, 

1694 legacy_amplifier: Any, 

1695 *, 

1696 is_raw_assembled: bool, 

1697 expect_nominal_calibrations: bool = True, 

1698) -> None: 

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

1700 `lsst.afw.cameraGeom.Amplifier`. 

1701 

1702 Parameters 

1703 ---------- 

1704 amplifier 

1705 Amplifier to compare. 

1706 legacy_amplifier 

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

1708 is_raw_assembled 

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

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

1711 expect_nominal_calibrations 

1712 Whether the amplifier is expected to carry nominal calibrations. 

1713 """ 

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

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

1716 if is_raw_assembled: 1716 ↛ 1719line 1716 didn't jump to line 1719 because the condition on line 1716 was always true

1717 raw_geom = amplifier.assembled_raw_geometry 

1718 else: 

1719 raw_geom = amplifier.unassembled_raw_geometry 

1720 assert raw_geom is not None 

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

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

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

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

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

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

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

1728 assert ( 

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

1730 ) 

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

1732 if raw_geom.horizontal_prescan_bbox is None: 

1733 # `Box` requires a positive size, so an amplifier with no prescan 

1734 # region carries `None` here. 

1735 assert legacy_amplifier.getRawPrescanBBox().isEmpty() 

1736 else: 

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

1738 if expect_nominal_calibrations: 1738 ↛ exitline 1738 didn't return from function 'compare_amplifier_to_legacy' because the condition on line 1738 was always true

1739 assert amplifier.nominal_calibrations is not None 

1740 assert_values_equal( 

1741 legacy_amplifier.getGain(), 

1742 amplifier.nominal_calibrations.gain, 

1743 equal_nan=True, 

1744 label="gain", 

1745 ) 

1746 assert_values_equal( 

1747 legacy_amplifier.getReadNoise(), 

1748 amplifier.nominal_calibrations.read_noise, 

1749 equal_nan=True, 

1750 label="read_noise", 

1751 ) 

1752 assert_values_equal( 

1753 legacy_amplifier.getSaturation(), 

1754 amplifier.nominal_calibrations.saturation, 

1755 equal_nan=True, 

1756 label="saturation", 

1757 ) 

1758 assert_values_equal( 

1759 legacy_amplifier.getSuspectLevel(), 

1760 amplifier.nominal_calibrations.suspect_level, 

1761 equal_nan=True, 

1762 label="suspect_level", 

1763 ) 

1764 assert_values_equal( 

1765 legacy_amplifier.getLinearityCoeffs(), 

1766 amplifier.nominal_calibrations.linearity_coefficients, 

1767 label="linearity_coefficients", 

1768 ) 

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

1770 

1771 

1772def compare_detector_to_legacy( 

1773 detector: Detector, 

1774 legacy_detector: Any, 

1775 *, 

1776 is_raw_assembled: bool, 

1777 expect_nominal_calibrations: bool = True, 

1778) -> None: 

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

1780 

1781 Parameters 

1782 ---------- 

1783 detector 

1784 Detector to compare. 

1785 legacy_detector 

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

1787 is_raw_assembled 

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

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

1790 expect_nominal_calibrations 

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

1792 calibrations. 

1793 """ 

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

1795 

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

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

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

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

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

1801 legacy_orientation = legacy_detector.getOrientation() 

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

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

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

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

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

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

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

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

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

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

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

1813 compare_amplifier_to_legacy( 

1814 amplifier, 

1815 legacy_amplifier, 

1816 is_raw_assembled=is_raw_assembled, 

1817 expect_nominal_calibrations=expect_nominal_calibrations, 

1818 ) 

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

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

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

1822 check_transform( 

1823 detector.to_focal_plane, 

1824 pixel_xy, 

1825 legacy_points_to_xy_array(fp_legacy_points), 

1826 detector.frame, 

1827 detector.to_focal_plane.out_frame, 

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

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

1830 ) 

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

1832 check_transform( 

1833 detector.to_field_angle, 

1834 pixel_xy, 

1835 legacy_points_to_xy_array(fa_legacy_points), 

1836 detector.frame, 

1837 detector.to_field_angle.out_frame, 

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

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

1840 ) 

1841 

1842 

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

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

1845 

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

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

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

1849 this is called. 

1850 

1851 This discovery is deliberately separate from 

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

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

1854 """ 

1855 seen: set[type] = set() 

1856 stack: list[type] = [ArchiveTree] 

1857 while stack: 

1858 kls = stack.pop() 

1859 sub: type 

1860 for sub in kls.__subclasses__(): 

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

1862 continue 

1863 seen.add(sub) 

1864 stack.append(sub) 

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

1866 yield sub 

1867 

1868 

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

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

1871 

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

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

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

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

1876 

1877 Parameters 

1878 ---------- 

1879 bounds 

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

1881 """ 

1882 bbox = bounds.bbox 

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

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

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

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

1887 ys = np.array( 

1888 [ 

1889 bbox.y.start - 1, 

1890 bbox.y.start, 

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

1892 bbox.y.stop, 

1893 ] 

1894 ) 

1895 xs = np.array( 

1896 [ 

1897 bbox.x.start - 1, 

1898 bbox.x.start, 

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

1900 bbox.x.stop, 

1901 ] 

1902 ) 

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

1904 expected = np.array( 

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

1906 dtype=bool, 

1907 ) 

1908 # 1-D ndarray (diagonal pairs). 

1909 assert_values_equal( 

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

1911 np.diagonal(expected), 

1912 label="bounds.contains", 

1913 ) 

1914 # list inputs (array-like). 

1915 assert_values_equal( 

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

1917 np.diagonal(expected), 

1918 label="bounds.contains", 

1919 ) 

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

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

1922 assert_values_equal( 

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

1924 expected[fixed_yi, :], 

1925 label="bounds.contains", 

1926 ) 

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

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

1929 assert_values_equal( 

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

1931 expected[:, fixed_xi], 

1932 label="bounds.contains", 

1933 ) 

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

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

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

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

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

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

1940 assert_values_equal(bounds.contains(XY(x=xs, y=ys)), np.diagonal(expected), label="bounds.contains") 

1941 assert_values_equal(bounds.contains(YX(y=ys, x=xs)), np.diagonal(expected), label="bounds.contains") 

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

1943 assert_values_equal( 

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

1945 expected, 

1946 label="bounds.contains", 

1947 ) 

1948 

1949 

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

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

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

1953 

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

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

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

1957 schema major. 

1958 

1959 Parameters 

1960 ---------- 

1961 tree_type 

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

1963 """ 

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

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

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

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

1968 assert isinstance(tree_type.SCHEMA_NAME, str) 

1969 assert len(tree_type.SCHEMA_NAME) > 0 

1970 # Allow an optional PEP 440 development-release suffix (e.g. 1.0.0.dev0) 

1971 # for schemas still in development. 

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

1973 assert isinstance(tree_type.MIN_READ_VERSION, int) 

1974 assert tree_type.MIN_READ_VERSION >= 1 

1975 assert isinstance(tree_type.PUBLIC_TYPE, type) 

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

1977 assert tree_type.MIN_READ_VERSION <= major 

1978 

1979 

1980def _angle_to_astropy(angle: Any) -> u.Quantity: 

1981 """Convert an `lsst.geom.Angle` to an Astropy angular quantity.""" 

1982 return u.Quantity(angle.asRadians(), u.rad) 

1983 

1984 

1985def _observatory_to_location(observatory: Any) -> coords.EarthLocation: 

1986 """Convert an `lsst.afw.coord.Observatory` to an Astropy 

1987 `~astropy.coordinates.EarthLocation`. 

1988 """ 

1989 return coords.EarthLocation.from_geodetic( 

1990 lon=observatory.getLongitude().asRadians() * u.rad, 

1991 lat=observatory.getLatitude().asRadians() * u.rad, 

1992 height=observatory.getElevation() * u.m, 

1993 ) 

1994 

1995 

1996def assert_obs_metadata_fields_equal(actual: Any, expected: Any, *, label: str = "") -> None: 

1997 """Assert that two ObservationInfo or VisitInfo field values are equal. 

1998 

1999 Dispatches on the type of ``expected`` so that values affected by afw's 

2000 float64 date storage, unit conversions, and angle wrapping are compared 

2001 with appropriate tolerances; anything else is compared with ``==``. 

2002 

2003 Parameters 

2004 ---------- 

2005 actual : `object` 

2006 The field value to check. 

2007 expected : `object` 

2008 The field value to check it against. 

2009 label : `str`, optional 

2010 Prefix for the failure message. 

2011 """ 

2012 from lsst.afw.coord import Observatory, Weather 

2013 from lsst.daf.base import DateTime 

2014 from lsst.geom import Angle 

2015 

2016 if isinstance(expected, DateTime): 

2017 assert_obs_metadata_fields_equal(actual.toAstropy(), expected.toAstropy(), label=label) 

2018 elif isinstance(expected, astropy.time.Time): 

2019 # afw stores VisitInfo dates as float64 MJDs, which limits precision. 

2020 assert_values_equal(actual, expected, atol=1e-9 * u.s, label=label) 

2021 elif isinstance(expected, coords.AltAz): 

2022 assert_values_equal(actual.az, expected.az, wrap_angles=True, label=f"{label}.az") 

2023 assert_values_equal(actual.alt, expected.alt, atol=1e-3 * u.arcsec, label=f"{label}.alt") 

2024 elif isinstance(expected, coords.SkyCoord): 

2025 assert_values_equal(actual, expected, atol=1e-3 * u.arcsec, label=label) 

2026 elif isinstance(expected, coords.EarthLocation): 

2027 assert_values_equal( 

2028 u.Quantity([actual.x, actual.y, actual.z]), 

2029 u.Quantity([expected.x, expected.y, expected.z]), 

2030 rtol=1e-9, 

2031 label=label, 

2032 ) 

2033 elif isinstance(expected, Observatory): 

2034 assert_obs_metadata_fields_equal( 

2035 _observatory_to_location(actual), _observatory_to_location(expected), label=label 

2036 ) 

2037 elif isinstance(expected, Weather): 

2038 assert_obs_metadata_fields_equal( 

2039 u.Quantity(actual.getAirTemperature(), u.deg_C), 

2040 u.Quantity(expected.getAirTemperature(), u.deg_C), 

2041 label=f"{label}.temperature", 

2042 ) 

2043 assert_obs_metadata_fields_equal( 

2044 actual.getAirPressure(), expected.getAirPressure(), label=f"{label}.pressure" 

2045 ) 

2046 assert_obs_metadata_fields_equal( 

2047 actual.getHumidity(), expected.getHumidity(), label=f"{label}.humidity" 

2048 ) 

2049 elif isinstance(expected, Angle): 

2050 assert_values_equal( 

2051 _angle_to_astropy(actual), 

2052 _angle_to_astropy(expected), 

2053 atol=1e-12 * u.rad, 

2054 wrap_angles=True, 

2055 label=label, 

2056 ) 

2057 elif ( 

2058 isinstance(expected, u.Quantity) 

2059 and isinstance(actual, u.Quantity) 

2060 and expected.unit.physical_type == "temperature" 

2061 and actual.unit.physical_type == "temperature" 

2062 ): 

2063 assert_values_equal( 

2064 actual.to(u.K, equivalencies=u.temperature()), 

2065 expected.to(u.K, equivalencies=u.temperature()), 

2066 atol=1e-6 * u.K, 

2067 label=label, 

2068 ) 

2069 elif isinstance(expected, u.Quantity | float): 

2070 assert_values_equal(actual, expected, rtol=1e-9, label=label) 

2071 elif actual != expected: 

2072 raise AssertionError(f"{label}: {actual!r} != {expected!r}")