Coverage for tests/test_visit_image.py: 72%

756 statements  

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

14import dataclasses 

15import math 

16import os 

17import warnings 

18from pathlib import Path 

19from typing import Any, Literal 

20 

21import astropy.io.fits 

22import astropy.units as u 

23import astropy.wcs 

24import numpy as np 

25import pytest 

26from astro_metadata_translator import ObservationInfo 

27 

28from lsst.daf.butler import DimensionUniverse 

29from lsst.images import ( 

30 Background, 

31 BackgroundMap, 

32 Box, 

33 DetectorFrame, 

34 DifferenceImage, 

35 Image, 

36 MaskPlane, 

37 MaskSchema, 

38 ObservationSummaryStats, 

39 Polygon, 

40 SkyProjectionAstropyView, 

41 TractFrame, 

42 VisitImage, 

43 get_legacy_difference_image_mask_planes, 

44 get_legacy_visit_image_mask_planes, 

45 json, 

46) 

47from lsst.images.aperture_corrections import ApertureCorrectionMap, aperture_corrections_to_legacy 

48from lsst.images.cameras import Detector 

49from lsst.images.describe import DescribableMixin, DescribeOptions, FieldRole, Report 

50from lsst.images.fields import ChebyshevField, SplineField, SumField, field_from_legacy_photo_calib 

51from lsst.images.fits import ExtensionKey, FitsOpaqueMetadata 

52from lsst.images.psfs import GaussianPointSpreadFunction, PointSpreadFunction 

53from lsst.images.serialization import ArchiveReadError, read_archive 

54from lsst.images.tests import ( 

55 DP2_VISIT_DETECTOR_DATA_ID, 

56 RoundtripFits, 

57 RoundtripJson, 

58 RoundtripNdf, 

59 TemporaryButler, 

60 assert_masked_images_equal, 

61 assert_sky_projections_equal, 

62 assert_values_equal, 

63 assert_visit_images_equal, 

64 compare_aperture_corrections_to_legacy, 

65 compare_detector_to_legacy, 

66 compare_photo_calib_to_legacy, 

67 compare_visit_image_to_legacy, 

68 current_fixture_path, 

69 get_dp2_exposure_record, 

70 make_random_sky_projection, 

71 reset_afw_mask_planes, # noqa: F401 

72) 

73 

74try: 

75 import h5py # noqa: F401 

76 

77 HAVE_H5PY = True 

78except ImportError: 

79 HAVE_H5PY = False 

80 

81try: 

82 from lsst.afw.image import Exposure as LegacyExposure 

83 from lsst.afw.image import VisitInfo as LegacyVisitInfo 

84except ImportError: 

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

86 type LegacyVisitInfo = Any # type: ignore[no-redef] 

87 

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

89LOCAL_DATA_DIR = os.path.join(os.path.dirname(__file__), "data") 

90FIXTURE_DIR = Path(__file__).parent / "data" / "schemas" 

91MINIMAL_VISIT_DATA_ID = {key: DP2_VISIT_DETECTOR_DATA_ID[key] for key in ("instrument", "visit", "detector")} 

92 

93skip_no_h5py = pytest.mark.skipif(not HAVE_H5PY, reason="h5py is not installed") 

94 

95 

96@pytest.fixture(scope="session") 

97def visit_image_components() -> dict[str, Any]: 

98 """Return a dictionary of VisitImage components.""" 

99 rng = np.random.default_rng(500) 

100 det_frame = DetectorFrame(instrument="Inst", visit=1234, detector=1, bbox=Box.factory[1:4096, 1:4096]) 

101 mask_schema = MaskSchema([MaskPlane("M1", "D1")]) 

102 # The IDs must match the DP2 exposure record that legacy round-trip tests 

103 # supply, because conversion validates the VisitInfo ID against it. 

104 obs_info = ObservationInfo( 

105 instrument="LSSTCam", 

106 detector_num=4, 

107 physical_filter="r1", 

108 exposure_id=2025052000177, 

109 observation_id="MC_O_20250520_000177", 

110 ) 

111 summary_stats = ObservationSummaryStats(psfSigma=2.5, zeroPoint=31.4) 

112 gaussian_psf = GaussianPointSpreadFunction(2.5, stamp_size=33, bounds=Box.factory[-10:10, -12:13]) 

113 aperture_corrections: ApertureCorrectionMap = { 

114 "flux1": ChebyshevField(det_frame.bbox, np.array([0.75])), 

115 "flux2": ChebyshevField(det_frame.bbox, np.array([0.625])), 

116 } 

117 detector = read_archive(os.path.join(LOCAL_DATA_DIR, "detector.json"), Detector) 

118 # Real visit images have float pixels, and some operations (e.g. rendering 

119 # a photometric scaling) are only defined for floating-point images. 

120 image = Image(42.0, shape=(1024, 1024), unit=u.nJy, dtype=np.float32) 

121 variance = Image(5.0, shape=(1024, 1024), unit=u.nJy * u.nJy, dtype=np.float32) 

122 # polygon is the lower triangle of the image. 

123 polygon = Polygon(x_vertices=[-0.5, 1023.5, -0.5], y_vertices=[-0.5, -0.5, 1023.5]) 

124 sky_projection = make_random_sky_projection(rng, det_frame, det_frame.bbox) 

125 return { 

126 "mask_schema": mask_schema, 

127 "obs_info": obs_info, 

128 "summary_stats": summary_stats, 

129 "gaussian_psf": gaussian_psf, 

130 "aperture_corrections": aperture_corrections, 

131 "detector": detector, 

132 "image": image, 

133 "variance": variance, 

134 "polygon": polygon, 

135 "sky_projection": sky_projection, 

136 } 

137 

138 

139def make_visit_image(components: dict[str, Any]) -> VisitImage: 

140 """Construct a new VisitImage with most components populated.""" 

141 det_frame = components["sky_projection"].pixel_frame 

142 opaque = FitsOpaqueMetadata() 

143 hdr = astropy.io.fits.Header() 

144 with warnings.catch_warnings(): 

145 # Silence warnings about long keys becoming HIERARCH. 

146 warnings.simplefilter("ignore", category=astropy.io.fits.verify.VerifyWarning) 

147 hdr.update({"PLATFORM": "lsstcam", "LSST BUTLER ID": "123456789"}) 

148 opaque.extract_legacy_primary_header(hdr) 

149 # API signature suggests sky_projection and obs_info can be None but 

150 # they are required (unless you pass them in via the image plane). 

151 vi = VisitImage( 

152 components["image"], 

153 variance=components["variance"], 

154 psf=GaussianPointSpreadFunction(2.5, stamp_size=33, bounds=Box.factory[-10:10, -12:13]), 

155 mask_schema=components["mask_schema"], 

156 sky_projection=components["sky_projection"], 

157 obs_info=components["obs_info"], 

158 summary_stats=components["summary_stats"], 

159 detector=components["detector"], 

160 bounds=components["polygon"], 

161 aperture_corrections=components["aperture_corrections"], 

162 band="r", 

163 ) 

164 vi.backgrounds.add( 

165 "standard", 

166 ChebyshevField(det_frame.bbox, np.array([[2.0]])), 

167 description="Background subtracted from the image.", 

168 is_subtracted=True, 

169 ) 

170 vi._opaque_metadata = opaque 

171 return vi 

172 

173 

174def make_simplest_visit_image(components: dict[str, Any]) -> VisitImage: 

175 """Construct a VisitImage with the minimal set of components populated.""" 

176 return VisitImage( 

177 components["image"], 

178 psf=GaussianPointSpreadFunction(2.5, stamp_size=33, bounds=Box.factory[-10:10, -12:13]), 

179 mask_schema=components["mask_schema"], 

180 sky_projection=components["sky_projection"], 

181 detector=components["detector"], 

182 obs_info=components["obs_info"], 

183 band="r", 

184 ) 

185 

186 

187def _make_sum_background_visit_image(components: dict[str, Any], visit_image: VisitImage) -> VisitImage: 

188 """Return a VisitImage whose subtracted background is a SumField.""" 

189 rng = np.random.default_rng(42) 

190 bbox = visit_image.image.sky_projection.pixel_frame.bbox 

191 bin_y = bbox.y.linspace(6) 

192 bin_x = bbox.x.linspace(7) 

193 spline_a = SplineField( 

194 bbox, 

195 rng.standard_normal(size=(bin_y.size, bin_x.size)), 

196 y=bin_y, 

197 x=bin_x, 

198 ) 

199 spline_b = SplineField( 

200 bbox, 

201 rng.standard_normal(size=(bin_y.size, bin_x.size)), 

202 y=bin_y, 

203 x=bin_x, 

204 ) 

205 sum_field = SumField([spline_a, spline_b]) 

206 bg_map = BackgroundMap() 

207 bg_map.add( 

208 "stacked", 

209 sum_field, 

210 description="Two-operand SumField subtracted background.", 

211 is_subtracted=True, 

212 ) 

213 return VisitImage( 

214 components["image"], 

215 variance=components["variance"], 

216 psf=components["gaussian_psf"], 

217 mask_schema=components["mask_schema"], 

218 sky_projection=components["sky_projection"], 

219 obs_info=components["obs_info"], 

220 summary_stats=components["summary_stats"], 

221 detector=components["detector"], 

222 band="r", 

223 backgrounds=bg_map, 

224 ) 

225 

226 

227def _check_sum_background_round_trip(result: VisitImage, original: VisitImage) -> None: 

228 """Assert that a round-tripped SumField background matches the original.""" 

229 subtracted = result.backgrounds.subtracted 

230 assert subtracted is not None 

231 assert isinstance(subtracted.field, SumField) 

232 original_subtracted = original.backgrounds.subtracted 

233 assert original_subtracted is not None 

234 original_field = original_subtracted.field 

235 assert isinstance(original_field, SumField) 

236 round_field = subtracted.field 

237 assert isinstance(round_field, SumField) 

238 assert len(round_field.operands) == len(original_field.operands) 

239 for round_op, orig_op in zip(round_field.operands, original_field.operands, strict=True): 

240 assert round_op == orig_op 

241 

242 

243def test_visit_image_repr_str_pinned(visit_image_components: dict[str, Any]) -> None: 

244 """Pin the exact str and repr output of a VisitImage.""" 

245 visit = make_simplest_visit_image(visit_image_components) 

246 assert str(visit) == "VisitImage(Image([y=0:1024, x=0:1024], float32), ['M1'])" 

247 assert repr(visit) == ( 

248 "VisitImage(Image(..., bbox=Box(y=Interval(start=0, stop=1024), x=Interval(start=0, stop=1024))," 

249 " dtype=dtype('float32')), mask_schema=MaskSchema([MaskPlane(name='M1', description='D1')]," 

250 " dtype=dtype('uint8')))" 

251 ) 

252 

253 

254def test_basics(visit_image_components: dict[str, Any]) -> None: 

255 """Verify VisitImage constructor patterns and required-argument checks.""" 

256 c = visit_image_components 

257 # Test default fill of variance. 

258 visit = make_simplest_visit_image(c) 

259 assert visit.variance.array[0, 0] == 1.0 

260 assert visit[...] is not visit 

261 assert str(visit) == "VisitImage(Image([y=0:1024, x=0:1024], float32), ['M1'])" 

262 assert repr(visit) == ( 

263 "VisitImage(Image(..., bbox=Box(y=Interval(start=0, stop=1024), x=Interval(start=0, stop=1024))," 

264 " dtype=dtype('float32')), mask_schema=MaskSchema([MaskPlane(name='M1', description='D1')]," 

265 " dtype=dtype('uint8')))" 

266 ) 

267 

268 astropy_wcs = visit.astropy_wcs 

269 assert isinstance(astropy_wcs, SkyProjectionAstropyView) 

270 approx_wcs = visit.fits_wcs 

271 assert isinstance(approx_wcs, astropy.wcs.WCS) 

272 

273 with pytest.raises(TypeError): 

274 # Requires a PSF. 

275 VisitImage( 

276 c["image"], 

277 mask_schema=c["mask_schema"], 

278 sky_projection=c["sky_projection"], 

279 obs_info=c["obs_info"], 

280 detector=c["detector"], 

281 band="r", 

282 ) 

283 

284 with pytest.raises(TypeError): 

285 # Requires ObservationInfo. 

286 VisitImage( 

287 c["image"], 

288 psf=c["gaussian_psf"], 

289 mask_schema=c["mask_schema"], 

290 sky_projection=c["sky_projection"], 

291 detector=c["detector"], 

292 band="r", 

293 ) 

294 

295 with pytest.raises(TypeError): 

296 # Requires a sky_projection. 

297 VisitImage( 

298 c["image"], 

299 psf=c["gaussian_psf"], 

300 mask_schema=c["mask_schema"], 

301 obs_info=c["obs_info"], 

302 detector=c["detector"], 

303 band="r", 

304 ) 

305 

306 with pytest.raises(TypeError): 

307 # Requires a detector. 

308 VisitImage( 

309 c["image"], 

310 psf=c["gaussian_psf"], 

311 mask_schema=c["mask_schema"], 

312 sky_projection=c["sky_projection"], 

313 obs_info=c["obs_info"], 

314 band="r", 

315 ) 

316 

317 with pytest.raises(TypeError): 

318 # Requires some form of mask. 

319 VisitImage( 

320 c["image"], 

321 psf=c["gaussian_psf"], 

322 sky_projection=c["sky_projection"], 

323 obs_info=c["obs_info"], 

324 detector=c["detector"], 

325 band="r", 

326 ) 

327 

328 with pytest.raises(TypeError): 

329 VisitImage( 

330 Image(42, shape=(5, 5)), 

331 psf=c["gaussian_psf"], 

332 mask_schema=c["mask_schema"], 

333 sky_projection=c["sky_projection"], 

334 obs_info=c["obs_info"], 

335 detector=c["detector"], 

336 band="r", 

337 ) 

338 

339 # Requires a DetectorFrame. 

340 rng = np.random.default_rng(501) 

341 tract_frame = TractFrame(skymap="Skymap", tract=1, bbox=Box.factory[1:10, 1:10]) 

342 tract_proj = make_random_sky_projection(rng, tract_frame, Box.factory[1:4096, 1:4096]) 

343 with pytest.raises(TypeError): 

344 VisitImage( 

345 c["image"], 

346 sky_projection=tract_proj, 

347 psf=c["gaussian_psf"], 

348 mask_schema=c["mask_schema"], 

349 obs_info=c["obs_info"], 

350 detector=c["detector"], 

351 band="r", 

352 ) 

353 

354 # Variance unit mismatch. 

355 with pytest.raises(ValueError, match="should be the square of the image unit"): 

356 VisitImage( 

357 c["image"], 

358 variance=c["image"], 

359 psf=c["gaussian_psf"], 

360 mask_schema=c["mask_schema"], 

361 sky_projection=c["sky_projection"], 

362 obs_info=c["obs_info"], 

363 detector=c["detector"], 

364 band="r", 

365 ) 

366 

367 

368def test_copy_and_slice(visit_image_components: dict[str, Any]) -> None: 

369 """Verify that copy deep-copies arrays and components while slice shares 

370 them. 

371 """ 

372 c = visit_image_components 

373 visit_image = make_visit_image(c) 

374 copy = visit_image.copy() 

375 copy.image.array[0, 0] = 30.0 

376 assert visit_image.image.array[0, 0] == 42.0 

377 assert copy.image.array[0, 0] == 30.0 

378 subvisit = visit_image[Box.factory[0:5, 0:5]] 

379 # Check summary stats. 

380 assert copy.summary_stats == visit_image.summary_stats 

381 assert copy.summary_stats is not visit_image.summary_stats 

382 assert subvisit.summary_stats == visit_image.summary_stats 

383 assert subvisit.summary_stats is visit_image.summary_stats 

384 # Check aperture corrections. 

385 assert copy.aperture_corrections.keys() == visit_image.aperture_corrections.keys() 

386 assert copy.aperture_corrections is not visit_image.aperture_corrections 

387 assert subvisit.aperture_corrections.keys() == visit_image.aperture_corrections.keys() 

388 assert subvisit.aperture_corrections is visit_image.aperture_corrections 

389 # Check backgrounds. 

390 assert copy.backgrounds.keys() == visit_image.backgrounds.keys() 

391 assert copy.backgrounds is not visit_image.backgrounds 

392 assert subvisit.backgrounds.keys() == visit_image.backgrounds.keys() 

393 assert subvisit.backgrounds is visit_image.backgrounds 

394 # Check bounds. 

395 assert copy.bounds is c["polygon"] 

396 assert subvisit.bounds == subvisit.bbox # original polygon wholly encloses subvisit.bbox 

397 

398 

399def test_obs_info(visit_image_components: dict[str, Any]) -> None: 

400 """Verify that ObservationInfo is present and carries the expected 

401 instrument. 

402 """ 

403 visit_image = make_visit_image(visit_image_components) 

404 assert visit_image.obs_info is not None 

405 assert visit_image.obs_info.instrument == "LSSTCam" 

406 

407 

408def test_summary_stats(visit_image_components: dict[str, Any]) -> None: 

409 """Verify ObservationSummaryStats equality and inequality comparisons.""" 

410 summary_stats = visit_image_components["summary_stats"] 

411 assert summary_stats == ObservationSummaryStats(psfSigma=2.5, zeroPoint=31.4) 

412 assert summary_stats != ObservationSummaryStats(psfSigma=2.5) 

413 assert summary_stats != ObservationSummaryStats(psfSigma=2.5, raCorners=(5.2, 5.4, 5.4, 5.2)) 

414 

415 

416def test_summary_stats_to_legacy() -> None: 

417 """Verify ObservationSummaryStats round-trips through the legacy 

418 ExposureSummaryStats even when this package defines fields that the 

419 installed afw does not. 

420 """ 

421 try: 

422 from lsst.afw.image import ExposureSummaryStats 

423 except ImportError: 

424 pytest.skip("lsst.afw.image is not available") 

425 

426 summary_stats = ObservationSummaryStats(psfSigma=2.5, zeroPoint=31.4) 

427 legacy = summary_stats.to_legacy() 

428 assert isinstance(legacy, ExposureSummaryStats) 

429 assert legacy.psfSigma == 2.5 

430 assert legacy.zeroPoint == 31.4 

431 # Empty (NaN) fields unknown to the legacy struct are dropped, so the 

432 # round trip reproduces the original. 

433 assert ObservationSummaryStats.from_legacy(legacy) == summary_stats 

434 

435 # A real value in a field unknown to the legacy struct cannot be 

436 # represented and must raise rather than be silently dropped. 

437 legacy_fields = {field.name for field in dataclasses.fields(ExposureSummaryStats)} 

438 extra_float_fields = [ 

439 name 

440 for name, info in ObservationSummaryStats.model_fields.items() 

441 if name not in legacy_fields and info.annotation is float 

442 ] 

443 if extra_float_fields: 443 ↛ 444line 443 didn't jump to line 444 because the condition on line 443 was never true

444 with pytest.raises(ValueError, match="not supported by this version"): 

445 ObservationSummaryStats(**{extra_float_fields[0]: 1.0}).to_legacy() 

446 

447 

448def test_summary_stats_from_legacy_unknown_field() -> None: 

449 """Verify from_legacy drops empty unknown fields but errors on set ones.""" 

450 

451 @dataclasses.dataclass 

452 class FakeLegacy: 

453 psfSigma: float = 2.5 

454 notARealField: float = math.nan 

455 

456 # An unknown field that is empty is dropped. 

457 stats = ObservationSummaryStats.from_legacy(FakeLegacy()) 

458 assert stats.psfSigma == 2.5 

459 

460 # An unknown field that holds a real value cannot be represented. 

461 with pytest.raises(ValueError, match="is not known to ObservationSummaryStats"): 

462 ObservationSummaryStats.from_legacy(FakeLegacy(notARealField=1.0)) 

463 

464 

465def test_summary_stats_legacy_butler_read(reset_afw_mask_planes: None) -> None: # noqa: F811 

466 """Test that a round-tripped ObservationSummaryStats can be read back as 

467 a legacy afw ExposureSummaryStats via the converter registered on the 

468 ExposureSummaryStats storage class. 

469 """ 

470 from lsst.afw.image import ExposureSummaryStats as LegacyExposureSummaryStats 

471 

472 summary_stats = ObservationSummaryStats(psfSigma=2.5, zeroPoint=31.4) 

473 with RoundtripFits(summary_stats, storage_class="ObservationSummaryStats") as roundtrip: 

474 legacy_stats = roundtrip.get(storageClass="ExposureSummaryStats") 

475 assert isinstance(legacy_stats, LegacyExposureSummaryStats) 

476 assert legacy_stats.psfSigma == 2.5 

477 assert legacy_stats.zeroPoint == 31.4 

478 assert ObservationSummaryStats.from_legacy(legacy_stats) == summary_stats 

479 

480 

481def test_detector_legacy_butler_read(reset_afw_mask_planes: None) -> None: # noqa: F811 

482 """Test that a round-tripped Detector (written under the DetectorV2 

483 storage class) can be read back as a legacy lsst.afw.cameraGeom.Detector 

484 via the converter registered on the Detector storage class. 

485 """ 

486 from lsst.afw.cameraGeom import Detector as LegacyDetector 

487 

488 detector = read_archive(os.path.join(LOCAL_DATA_DIR, "detector.json"), Detector) 

489 with RoundtripFits(detector, storage_class="DetectorV2") as roundtrip: 

490 legacy_detector = roundtrip.get(storageClass="Detector") 

491 assert isinstance(legacy_detector, LegacyDetector) 

492 compare_detector_to_legacy(detector, legacy_detector, is_raw_assembled=True) 

493 

494 

495def test_repeated_metadata_keys_legacy_round_trip( 

496 visit_image_components: dict[str, Any], 

497 reset_afw_mask_planes: None, # noqa: F811 

498) -> None: 

499 """Verify that a header key set more than once keeps all of its values 

500 when a VisitImage is converted to a legacy Exposure and back. 

501 """ 

502 from lsst.afw.detection import GaussianPsf 

503 

504 opaque_metadata = FitsOpaqueMetadata() 

505 header = astropy.io.fits.Header() 

506 header.append(("PLATFORM", "lsstcam"), end=True) 

507 # SubtractBackgroundTask writes one BGMEAN card per background fit. 

508 header.append(("BGMEAN", 1.5), end=True) 

509 header.append(("BGMEAN", 2.5), end=True) 

510 opaque_metadata.extract_legacy_primary_header(header) 

511 visit_image = VisitImage( 

512 visit_image_components["image"], 

513 variance=visit_image_components["variance"], 

514 # A legacy-backed PSF, so that to_legacy can attach it to the 

515 # Exposure and from_legacy can read it back. 

516 psf=PointSpreadFunction.from_legacy(GaussianPsf(33, 33, 2.5), bounds=Box.factory[0:1024, 0:1024]), 

517 mask_schema=visit_image_components["mask_schema"], 

518 sky_projection=visit_image_components["sky_projection"], 

519 detector=visit_image_components["detector"], 

520 obs_info=visit_image_components["obs_info"], 

521 band="r", 

522 ) 

523 visit_image._opaque_metadata = opaque_metadata 

524 

525 legacy_exposure = visit_image.to_legacy() 

526 legacy_metadata = legacy_exposure.getMetadata() 

527 assert legacy_metadata.getArray("BGMEAN") == [1.5, 2.5] 

528 assert legacy_metadata["PLATFORM"] == "lsstcam" 

529 # Add a random key directly 

530 legacy_metadata.add("DUMMYVAR", 0.5) 

531 legacy_metadata.add("DUMMYVAR", 1.5) 

532 

533 round_tripped = VisitImage.from_legacy( 

534 legacy_exposure, 

535 exposure_record=get_dp2_exposure_record(DimensionUniverse()), 

536 ) 

537 round_tripped_header = round_tripped._opaque_metadata.headers[ExtensionKey()] 

538 assert "DUMMYVAR" in round_tripped_header 

539 assert "BGMEAN" in round_tripped_header 

540 assert round_tripped_header["PLATFORM"] == "lsstcam" 

541 assert [card.value for card in round_tripped_header.cards if card.keyword == "BGMEAN"] == [1.5, 2.5] 

542 assert [card.value for card in round_tripped_header.cards if card.keyword == "DUMMYVAR"] == [0.5, 1.5] 

543 

544 

545def test_external_metadata_legacy_round_trip( 

546 visit_image_components: dict[str, Any], 

547 reset_afw_mask_planes: None, # noqa: F811 

548 tmp_path: Path, 

549) -> None: 

550 """Verify that native metadata is written to a legacy file as 

551 ``LSST IMAGES`` cards and is not visible as external metadata when the 

552 file is read back. 

553 """ 

554 from lsst.afw.detection import GaussianPsf 

555 

556 opaque_metadata = FitsOpaqueMetadata() 

557 header = astropy.io.fits.Header() 

558 header.append(("PLATFORM", "lsstcam"), end=True) 

559 header.append(("BGMEAN", 1.5), end=True) 

560 header.append(("BGMEAN", 2.5), end=True) 

561 # An external ID card must neither block nor replace the native "id". 

562 header.append(("ID", 99), end=True) 

563 opaque_metadata.extract_legacy_primary_header(header) 

564 visit_image = VisitImage( 

565 visit_image_components["image"], 

566 variance=visit_image_components["variance"], 

567 psf=PointSpreadFunction.from_legacy(GaussianPsf(33, 33, 2.5), bounds=Box.factory[0:1024, 0:1024]), 

568 mask_schema=visit_image_components["mask_schema"], 

569 sky_projection=visit_image_components["sky_projection"], 

570 detector=visit_image_components["detector"], 

571 obs_info=visit_image_components["obs_info"], 

572 band="r", 

573 metadata={"native_key": 7, "MixedCase": "yes"}, 

574 ) 

575 visit_image._opaque_metadata = opaque_metadata 

576 path = tmp_path / "legacy.fits" 

577 visit_image.to_legacy().writeFits(str(path)) 

578 

579 with astropy.io.fits.open(path) as hdu_list: 

580 primary = hdu_list[0].header 

581 native_cards = {} 

582 n = 1 

583 while f"LSST IMAGES KEY {n}" in primary: 

584 native_cards[primary[f"LSST IMAGES KEY {n}"]] = primary[f"LSST IMAGES VALUE {n}"] 

585 n += 1 

586 assert native_cards == {"native_key": 7, "MixedCase": "yes"} 

587 assert [card.value for card in primary.cards if card.keyword == "BGMEAN"] == [1.5, 2.5] 

588 

589 result = VisitImage.read_legacy( 

590 str(path), 

591 exposure_record=get_dp2_exposure_record(DimensionUniverse()), 

592 ) 

593 assert result.metadata.native["native_key"] == 7 

594 assert result.metadata.native["MixedCase"] == "yes" 

595 assert "id" in result.metadata.native 

596 assert not [key for key in result.metadata.external if key.startswith("LSST IMAGES")] 

597 assert result.metadata.external.get_all("BGMEAN") == (1.5, 2.5) 

598 assert result.metadata["platform"] == "lsstcam" 

599 assert result.metadata.external["ID"] == 99 

600 

601 # Converting the result back must not duplicate the external cards. 

602 legacy_metadata = result.to_legacy().getMetadata() 

603 assert legacy_metadata.getArray("BGMEAN") == [1.5, 2.5] 

604 assert legacy_metadata["LSST IMAGES KEY 1"] == "native_key" 

605 

606 

607@skip_no_h5py 

608def test_round_trip_ndf(visit_image_components: dict[str, Any]) -> None: 

609 """Verify NDF round-trip produces a VisitImage equal to the original.""" 

610 visit_image = make_visit_image(visit_image_components) 

611 with RoundtripNdf(visit_image, "VisitImage") as roundtrip: 

612 assert_visit_images_equal(roundtrip.result, visit_image, expect_view=False) 

613 

614 

615@skip_no_h5py 

616def test_fits_ndf_consistency(visit_image_components: dict[str, Any]) -> None: 

617 """Verify FITS and NDF backends produce equal VisitImages on round-trip.""" 

618 visit_image = make_visit_image(visit_image_components) 

619 with RoundtripFits(visit_image) as fits_rt, RoundtripNdf(visit_image) as ndf_rt: 

620 assert_visit_images_equal(visit_image, fits_rt.result, expect_view=False) 

621 assert_visit_images_equal(visit_image, ndf_rt.result, expect_view=False) 

622 assert_visit_images_equal(fits_rt.result, ndf_rt.result, expect_view=False) 

623 

624 

625def test_fits_json_consistency(visit_image_components: dict[str, Any]) -> None: 

626 """Verify FITS and JSON backends produce equal VisitImages.""" 

627 visit_image = make_visit_image(visit_image_components) 

628 with ( 

629 RoundtripFits(visit_image) as fits_rt, 

630 RoundtripJson(visit_image) as json_rt, 

631 ): 

632 assert_visit_images_equal(visit_image, fits_rt.result, expect_view=False) 

633 assert_visit_images_equal(visit_image, json_rt.result, expect_view=False) 

634 assert_visit_images_equal(fits_rt.result, json_rt.result, expect_view=False) 

635 

636 

637def test_serialized_schema_version(visit_image_components: dict[str, Any]) -> None: 

638 """The written tree's schema_version matches its schema_url. 

639 

640 The image, mask, and variance subtrees are stamped with their own 

641 versions too. 

642 """ 

643 visit_image = make_visit_image(visit_image_components) 

644 tree = json.write(visit_image) 

645 tree_type = type(tree) 

646 assert tree.schema_version == tree_type.SCHEMA_VERSION 

647 assert tree.min_read_version == tree_type.MIN_READ_VERSION 

648 assert tree.schema_url.endswith(f"-{tree.schema_version}") 

649 assert tree.model_dump()["schema_version"] == tree_type.SCHEMA_VERSION 

650 for name in ("image", "mask", "variance"): 

651 component = getattr(tree, name) 

652 assert component.schema_version == type(component).SCHEMA_VERSION, name 

653 

654 

655def test_read_write(visit_image_components: dict[str, Any]) -> None: 

656 """Verify a VisitImage round-trips through FITS with correct compression. 

657 

658 Checks compression headers, subimage reads, equality, and opaque 

659 metadata. Contains only butler-free assertions; component reads live 

660 in `test_read_write_components`. 

661 """ 

662 visit_image = make_visit_image(visit_image_components) 

663 with RoundtripFits(visit_image, "VisitImage") as roundtrip: 

664 # Check that we're still using the right compression, and that we 

665 # wrote WCSs. 

666 fits = roundtrip.inspect() 

667 assert fits[1].header["ZCMPTYPE"] == "GZIP_2" 

668 assert fits[1].header["CTYPE1"] == "RA---TAN" 

669 assert fits[2].header["ZCMPTYPE"] == "GZIP_2" 

670 assert fits[2].header["CTYPE1"] == "RA---TAN" 

671 assert fits[3].header["ZCMPTYPE"] == "GZIP_2" 

672 assert fits[3].header["CTYPE1"] == "RA---TAN" 

673 # Check a subimage read (no component arg — does not trigger a skip). 

674 subbox = Box.factory[8:13, 9:30] 

675 subimage = roundtrip.get(bbox=subbox) 

676 assert_masked_images_equal(subimage, visit_image[subbox], expect_view=False) 

677 

678 assert_visit_images_equal(roundtrip.result, visit_image, expect_view=False) 

679 # Check that the round-tripped headers are the same (up to card order). 

680 assert len(roundtrip.result._opaque_metadata.headers[ExtensionKey()]) == 1 

681 assert dict(visit_image._opaque_metadata.headers[ExtensionKey()]) == dict( 

682 roundtrip.result._opaque_metadata.headers[ExtensionKey()] 

683 ) 

684 assert not roundtrip.result._opaque_metadata.headers[ExtensionKey("IMAGE")] 

685 assert not roundtrip.result._opaque_metadata.headers[ExtensionKey("MASK")] 

686 assert not roundtrip.result._opaque_metadata.headers[ExtensionKey("VARIANCE")] 

687 # Spot-check the concrete background contents (names, field types, 

688 # subtracted entry) against the known fixture, so the equality check 

689 # above is not vacuously satisfied by empty background maps. 

690 assert isinstance(roundtrip.result.backgrounds, BackgroundMap) 

691 assert roundtrip.result.backgrounds.keys() == {"standard"} 

692 assert isinstance(roundtrip.result.backgrounds["standard"].field, ChebyshevField) 

693 assert roundtrip.result.backgrounds.subtracted.name == "standard" 

694 assert roundtrip.result.backgrounds.subtracted.description == "Background subtracted from the image." 

695 

696 

697def test_read_write_components(visit_image_components: dict[str, Any]) -> None: 

698 """Verify component reads and storage-class overrides round-trip correctly. 

699 

700 Requires a butler; skips when `lsst.daf.butler` is absent. 

701 Butler-free assertions live in `test_read_write`. 

702 """ 

703 c = visit_image_components 

704 visit_image = make_visit_image(c) 

705 with RoundtripFits(visit_image, "VisitImage") as roundtrip: 

706 subbox = Box.factory[8:13, 9:30] 

707 subimage = roundtrip.get(bbox=subbox) 

708 

709 # Get an explicit masked image to compare with the subimage. 

710 subimage_masked = roundtrip.get("masked_image", bbox=subbox) 

711 assert_masked_images_equal(subimage_masked, subimage, expect_view=False) 

712 

713 # Get the same masked image in a multi-component get and ensure 

714 # it is the same thing. 

715 components = roundtrip.get("components", components=["masked_image", "psf"], bbox=subbox) 

716 assert set(components) == {"masked_image", "psf"} 

717 assert_masked_images_equal(components["masked_image"], subimage_masked, expect_view=False) 

718 

719 assert roundtrip.get("bbox") == visit_image.bbox 

720 

721 obs_info = roundtrip.get("obs_info") 

722 assert isinstance(obs_info, ObservationInfo) 

723 assert obs_info == visit_image.obs_info 

724 

725 summary_stats = roundtrip.get("summary_stats") 

726 assert isinstance(summary_stats, ObservationSummaryStats) 

727 assert summary_stats == visit_image.summary_stats 

728 

729 psf = roundtrip.get("psf") 

730 assert isinstance(psf, GaussianPointSpreadFunction) 

731 assert psf.kernel_bbox == c["gaussian_psf"].kernel_bbox 

732 

733 backgrounds = roundtrip.get("backgrounds") 

734 assert isinstance(backgrounds, BackgroundMap) 

735 assert backgrounds.keys() == {"standard"} 

736 assert isinstance(backgrounds["standard"].field, ChebyshevField) 

737 assert backgrounds.subtracted.name == "standard" 

738 assert roundtrip.result.backgrounds.subtracted.description == "Background subtracted from the image." 

739 

740 # Test some components get edge cases. 

741 components = roundtrip.get("components", components="image") 

742 assert isinstance(components["image"], Image) 

743 

744 components = roundtrip.get("components") 

745 assert set(components) == { 

746 "image", 

747 "variance", 

748 "psf", 

749 "bbox", 

750 "mask", 

751 "obs_info", 

752 "backgrounds", 

753 "detector", 

754 "aperture_corrections", 

755 "sky_projection", 

756 "summary_stats", 

757 "photometric_scaling", 

758 } 

759 

760 # Butler morphs RuntimeError to ValueError. 

761 with pytest.raises(ValueError, match="component nonexistent"): 

762 roundtrip.get("components", components=["image", "nonexistent"]) 

763 

764 with pytest.raises(ValueError, match="should not be specified"): 

765 roundtrip.get("components", components=["image", "components"]) 

766 

767 with pytest.raises(ValueError, match="empty request"): 

768 roundtrip.get("components", components=[]) 

769 

770 with pytest.raises(ValueError, match="not known to any of the requested components"): 

771 # PSF does not know how to use bbox so this fails. 

772 roundtrip.get("components", components="psf", bbox=subbox) 

773 

774 

775def test_sum_background_round_trip_fits(visit_image_components: dict[str, Any]) -> None: 

776 """Verify FITS backend keeps two same-named SumField operands as distinct 

777 EXTVERs. 

778 """ 

779 visit_image = make_visit_image(visit_image_components) 

780 visit = _make_sum_background_visit_image(visit_image_components, visit_image) 

781 with RoundtripFits(visit) as roundtrip: 

782 _check_sum_background_round_trip(roundtrip.result, visit) 

783 

784 

785@skip_no_h5py 

786def test_sum_background_round_trip_ndf(visit_image_components: dict[str, Any]) -> None: 

787 """Verify NDF backend disambiguates the repeated ``data`` leaf, just as 

788 the FITS backend does. 

789 """ 

790 visit_image = make_visit_image(visit_image_components) 

791 visit = _make_sum_background_visit_image(visit_image_components, visit_image) 

792 with RoundtripNdf(visit) as roundtrip: 

793 _check_sum_background_round_trip(roundtrip.result, visit) 

794 

795 

796@pytest.mark.parametrize( 

797 ("scaling_unit", "operation"), 

798 [(u.electron / u.nJy, "multiply"), (u.nJy / u.electron, "divide")], 

799 ids=["multiply", "divide"], 

800) 

801def test_convert_unit_subimage( 

802 visit_image_components: dict[str, Any], 

803 scaling_unit: u.UnitBase, 

804 operation: Literal["multiply", "divide"], 

805) -> None: 

806 """Verify that converting the units of a subimage applies only the portion 

807 of the photometric scaling that overlaps the subimage. 

808 

809 A photometric scaling keeps the bounds it was modeled over when the image 

810 is subset, so both branches of the conversion must render it over the 

811 subimage's bbox rather than over its own bounds. 

812 """ 

813 visit_image = make_visit_image(visit_image_components) 

814 scaling = ChebyshevField( 

815 visit_image.bbox, 

816 np.array([[4.0, 0.5, 0.125], [0.25, 0.0625, 0.0], [0.03125, 0.0, 0.0]]), 

817 unit=scaling_unit, 

818 ) 

819 visit_image.photometric_scaling = scaling 

820 # Trim a different number of pixels from each side so a scaling rendered 

821 # over the wrong box cannot match by chance. 

822 subbox = Box.factory[ 

823 visit_image.bbox.y.start + 7 : visit_image.bbox.y.stop - 13, 

824 visit_image.bbox.x.start + 3 : visit_image.bbox.x.stop - 21, 

825 ] 

826 subimage = visit_image[subbox] 

827 assert subimage.photometric_scaling.bounds.bbox == visit_image.bbox 

828 

829 converted = subimage.convert_unit(u.electron) 

830 

831 assert converted.unit == u.electron 

832 assert converted.image.bbox == subbox 

833 scaling_array = scaling.render(subbox, dtype=subimage.image.array.dtype).array 

834 if operation == "divide": 

835 scaling_array = 1.0 / scaling_array 

836 assert_values_equal(converted.image.array, subimage.image.array * scaling_array, rtol=1e-5) 

837 assert_values_equal(converted.variance.array, subimage.variance.array * scaling_array**2, rtol=1e-5) 

838 

839 

840@dataclasses.dataclass 

841class _LegacyTestData: 

842 filename: str 

843 plane_map: dict[str, MaskPlane] = dataclasses.field(default_factory=get_legacy_visit_image_mask_planes) 

844 unit: u.Unit = u.nJy 

845 storage_class: str = "VisitImage" 

846 read_cls: type[VisitImage] = VisitImage 

847 exposure_record: Any = None 

848 legacy_exposure: LegacyExposure = dataclasses.field(init=False) 

849 

850 @classmethod 

851 def get( 

852 cls, which: Literal["visit_image", "preliminary_visit_image", "difference_image"] 

853 ) -> _LegacyTestData: 

854 if EXTERNAL_DATA_DIR is None: 854 ↛ 856line 854 didn't jump to line 856 because the condition on line 854 was always true

855 pytest.skip("TESTDATA_IMAGES is not set up.") 

856 result = cls( 

857 os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", f"{which}.fits"), 

858 ) 

859 match which: 

860 case "preliminary_visit_image": 

861 result.unit = u.electron 

862 case "difference_image": 

863 result.storage_class = "DifferenceImage" 

864 result.read_cls = DifferenceImage 

865 result.plane_map = get_legacy_difference_image_mask_planes() 

866 case "visit_image": 

867 pass 

868 try: 

869 from lsst.afw.image import ExposureFitsReader 

870 

871 result.legacy_exposure = ExposureFitsReader(result.filename).read() 

872 except ImportError: 

873 pytest.skip("lsst.afw.image is not available; cannot read legacy exposures") 

874 # All three legacy files are the same DP2 exposure. 

875 result.exposure_record = get_dp2_exposure_record(DimensionUniverse()) 

876 result.visit_image = result.read_cls.read_legacy( 

877 result.filename, 

878 preserve_quantization=True, 

879 plane_map=result.plane_map, 

880 exposure_record=result.exposure_record, 

881 ) 

882 return result 

883 

884 

885@pytest.fixture(params=["visit_image", "preliminary_visit_image", "difference_image"]) 

886def legacy_test_data(request: pytest.FixtureRequest, reset_afw_mask_planes: None) -> _LegacyTestData: # noqa: F811 

887 """Return legacy test data. 

888 

889 Tests that depend on this parameterized fixture run on all of the legacy 

890 test images. 

891 """ 

892 return _LegacyTestData.get(request.param) 

893 

894 

895@pytest.fixture(params=["visit_image", "difference_image"]) 

896def legacy_test_data_calibrated( 

897 request: pytest.FixtureRequest, 

898 reset_afw_mask_planes: None, # noqa: F811 

899) -> _LegacyTestData: 

900 """Return legacy test data for calibrated images only. 

901 

902 Tests that depend on this parameterized fixture do not run on 

903 preliminary_visit_image, since that has 'electron' pixel units 

904 """ 

905 return _LegacyTestData.get(request.param) 

906 

907 

908def _check_legacy_obs_info(obs_info: ObservationInfo | None) -> None: 

909 """Assert obs_info carries expected LSSTCam/DP2 field values.""" 

910 assert isinstance(obs_info, ObservationInfo) 

911 assert obs_info.instrument == "LSSTCam" 

912 assert obs_info.detector_num == 85, obs_info 

913 assert obs_info.detector_unique_name == "R21_S11", obs_info 

914 assert obs_info.physical_filter == "r_57", obs_info 

915 

916 

917def test_legacy_errors(legacy_test_data: _LegacyTestData) -> None: 

918 """Verify that from_legacy and read_legacy raise on bad arguments.""" 

919 with pytest.raises(TypeError): 

920 # exposure_record is a required argument. 

921 VisitImage.from_legacy(legacy_test_data.legacy_exposure) 

922 with pytest.raises(ValueError, match="BUNIT value .* disagrees with given unit"): 

923 VisitImage.from_legacy( 

924 legacy_test_data.legacy_exposure, 

925 unit=u.mJy, 

926 exposure_record=legacy_test_data.exposure_record, 

927 ) 

928 visit = VisitImage.from_legacy( 

929 legacy_test_data.legacy_exposure, 

930 unit=legacy_test_data.unit, 

931 exposure_record=legacy_test_data.exposure_record, 

932 ) 

933 assert visit.unit == legacy_test_data.unit 

934 

935 with pytest.raises(TypeError): 

936 legacy_test_data.read_cls.read_legacy(legacy_test_data.filename) 

937 

938 

939def test_component_reads(legacy_test_data: _LegacyTestData) -> None: 

940 """Verify that individual components can be read from a legacy FITS 

941 file. 

942 """ 

943 visit = VisitImage.read_legacy( 

944 legacy_test_data.filename, exposure_record=legacy_test_data.exposure_record 

945 ) 

946 proj = VisitImage.read_legacy( 

947 legacy_test_data.filename, 

948 exposure_record=legacy_test_data.exposure_record, 

949 component="sky_projection", 

950 ) 

951 assert_sky_projections_equal(proj, visit.sky_projection, expect_identity=False) 

952 image = VisitImage.read_legacy( 

953 legacy_test_data.filename, 

954 exposure_record=legacy_test_data.exposure_record, 

955 component="image", 

956 ) 

957 assert image == visit.image 

958 assert_sky_projections_equal(proj, image.sky_projection, expect_identity=False) 

959 variance = VisitImage.read_legacy( 

960 legacy_test_data.filename, 

961 exposure_record=legacy_test_data.exposure_record, 

962 component="variance", 

963 ) 

964 assert variance == visit.variance 

965 assert_sky_projections_equal(proj, variance.sky_projection, expect_identity=False) 

966 mask = VisitImage.read_legacy( 

967 legacy_test_data.filename, 

968 exposure_record=legacy_test_data.exposure_record, 

969 component="mask", 

970 ) 

971 assert mask == visit.mask 

972 assert_sky_projections_equal(proj, mask.sky_projection, expect_identity=False) 

973 psf = VisitImage.read_legacy( 

974 legacy_test_data.filename, 

975 exposure_record=legacy_test_data.exposure_record, 

976 component="psf", 

977 ) 

978 assert isinstance(psf, PointSpreadFunction) 

979 obs_info = VisitImage.read_legacy( 

980 legacy_test_data.filename, 

981 exposure_record=legacy_test_data.exposure_record, 

982 component="obs_info", 

983 ) 

984 _check_legacy_obs_info(obs_info) 

985 summary_stats = VisitImage.read_legacy( 

986 legacy_test_data.filename, 

987 exposure_record=legacy_test_data.exposure_record, 

988 component="summary_stats", 

989 ) 

990 assert isinstance(summary_stats, ObservationSummaryStats) 

991 assert summary_stats.nPsfStar == legacy_test_data.legacy_exposure.info.getSummaryStats().nPsfStar 

992 compare_aperture_corrections_to_legacy( 

993 VisitImage.read_legacy( 

994 legacy_test_data.filename, 

995 exposure_record=legacy_test_data.exposure_record, 

996 component="aperture_corrections", 

997 ), 

998 legacy_test_data.legacy_exposure.info.getApCorrMap(), 

999 visit.bbox, 

1000 ) 

1001 detector = VisitImage.read_legacy( 

1002 legacy_test_data.filename, 

1003 exposure_record=legacy_test_data.exposure_record, 

1004 component="detector", 

1005 ) 

1006 compare_detector_to_legacy( 

1007 detector, legacy_test_data.legacy_exposure.getDetector(), is_raw_assembled=True 

1008 ) 

1009 photometric_scaling = VisitImage.read_legacy( 

1010 legacy_test_data.filename, 

1011 exposure_record=legacy_test_data.exposure_record, 

1012 component="photometric_scaling", 

1013 ) 

1014 compare_photo_calib_to_legacy( 

1015 photometric_scaling, 

1016 legacy_test_data.legacy_exposure.getPhotoCalib(), 

1017 subimage_bbox=visit.bbox, 

1018 ) 

1019 

1020 

1021def test_legacy_obs_info(legacy_test_data: _LegacyTestData) -> None: 

1022 """Verify that ObservationInfo is constructed correctly from a legacy 

1023 exposure. 

1024 """ 

1025 legacy = VisitImage.from_legacy( 

1026 legacy_test_data.legacy_exposure, 

1027 plane_map=legacy_test_data.plane_map, 

1028 exposure_record=legacy_test_data.exposure_record, 

1029 ) 

1030 assert legacy.obs_info is not None 

1031 assert legacy.obs_info == legacy_test_data.visit_image.obs_info 

1032 assert legacy.obs_info is not None # for mypy 

1033 assert legacy.obs_info.instrument == "LSSTCam" 

1034 assert legacy.obs_info.detector_num == 85, legacy.obs_info 

1035 assert legacy.obs_info.detector_unique_name == "R21_S11", legacy.obs_info 

1036 assert legacy.obs_info.physical_filter == "r_57", legacy.obs_info 

1037 

1038 

1039def test_aperture_corrections_to_legacy(legacy_test_data: _LegacyTestData) -> None: 

1040 """Verify that aperture corrections round-trip through a legacy 

1041 ApCorrMap. 

1042 """ 

1043 ap_corrections = legacy_test_data.visit_image.aperture_corrections 

1044 legacy_ap_corr_map = aperture_corrections_to_legacy(ap_corrections) 

1045 compare_aperture_corrections_to_legacy( 

1046 ap_corrections, 

1047 legacy_ap_corr_map, 

1048 legacy_test_data.visit_image.bbox, 

1049 ) 

1050 

1051 

1052def _check_legacy_headers(visit_image: VisitImage) -> None: 

1053 """Assert that primary and extension headers are stripped correctly.""" 

1054 header = visit_image._opaque_metadata.headers[ExtensionKey()] 

1055 assert "EXPTIME" in header 

1056 assert header["PLATFORM"] == "lsstcam" 

1057 assert "LSST BUTLER ID" not in header 

1058 assert "AR HDU" not in header 

1059 assert "A_ORDER" not in header 

1060 assert not visit_image._opaque_metadata.headers.get(ExtensionKey("IMAGE"), astropy.io.fits.Header()) 

1061 assert not visit_image._opaque_metadata.headers.get(ExtensionKey("MASK"), astropy.io.fits.Header()) 

1062 assert not visit_image._opaque_metadata.headers.get(ExtensionKey("VARIANCE"), astropy.io.fits.Header()) 

1063 

1064 

1065def test_read_legacy_headers(legacy_test_data: _LegacyTestData) -> None: 

1066 """Verify that headers were stripped and interpreted correctly in 

1067 read_legacy. 

1068 """ 

1069 assert legacy_test_data.visit_image.unit == legacy_test_data.unit 

1070 _check_legacy_headers(legacy_test_data.visit_image) 

1071 

1072 

1073def test_from_legacy_headers(legacy_test_data: _LegacyTestData) -> None: 

1074 """Verify that from_legacy handles primary and extension headers 

1075 correctly. 

1076 """ 

1077 legacy = VisitImage.from_legacy( 

1078 legacy_test_data.legacy_exposure, 

1079 plane_map=legacy_test_data.plane_map, 

1080 exposure_record=legacy_test_data.exposure_record, 

1081 ) 

1082 assert legacy.unit == legacy_test_data.unit 

1083 _check_legacy_headers(legacy) 

1084 

1085 

1086def test_rewrite(legacy_test_data: _LegacyTestData) -> None: 

1087 """Verify that a legacy VisitImage can be rewritten and round-trips both 

1088 pixel values and all components. 

1089 """ 

1090 with RoundtripFits(legacy_test_data.visit_image, legacy_test_data.storage_class) as roundtrip: 

1091 fits = roundtrip.inspect() 

1092 assert fits[1].header["ZCMPTYPE"] == "RICE_1" 

1093 assert fits[1].header["CTYPE1"] == "RA---TAN-SIP" 

1094 assert fits[2].header["ZCMPTYPE"] == "GZIP_2" 

1095 assert fits[2].header["CTYPE1"] == "RA---TAN-SIP" 

1096 assert fits[3].header["ZCMPTYPE"] == "RICE_1" 

1097 assert fits[3].header["CTYPE1"] == "RA---TAN-SIP" 

1098 subbox = Box.factory[8:13, 9:30] 

1099 subimage = roundtrip.get(bbox=subbox) 

1100 assert_masked_images_equal(subimage, legacy_test_data.visit_image[subbox], expect_view=False) 

1101 alternates: dict[str, Any] = {} 

1102 assert roundtrip.get("bbox") == legacy_test_data.visit_image.bbox 

1103 alternates = { 

1104 k: roundtrip.get(k) 

1105 for k in [ 

1106 "sky_projection", 

1107 "image", 

1108 "mask", 

1109 "variance", 

1110 "psf", 

1111 "obs_info", 

1112 "summary_stats", 

1113 "aperture_corrections", 

1114 "detector", 

1115 "photometric_scaling", 

1116 ] 

1117 } 

1118 legacy_exposure = roundtrip.get(storageClass="Exposure") 

1119 assert isinstance(legacy_exposure, LegacyExposure) 

1120 compare_visit_image_to_legacy( 

1121 legacy_test_data.visit_image, 

1122 legacy_exposure, 

1123 expect_view=False, 

1124 plane_map=legacy_test_data.plane_map, 

1125 **MINIMAL_VISIT_DATA_ID, 

1126 ) 

1127 if legacy_test_data.visit_image.unit == u.nJy: 

1128 assert legacy_exposure.getPhotoCalib()._isConstant 

1129 assert legacy_exposure.getPhotoCalib().getCalibrationMean() == 1.0 

1130 else: 

1131 compare_photo_calib_to_legacy( 

1132 legacy_test_data.visit_image.photometric_scaling, 

1133 legacy_exposure.getPhotoCalib(), 

1134 subimage_bbox=subbox, 

1135 ) 

1136 assert legacy_exposure.info.getId() == legacy_test_data.legacy_exposure.info.getId() 

1137 visit_info = roundtrip.get("obs_info", storageClass="VisitInfo") 

1138 assert isinstance(visit_info, LegacyVisitInfo) 

1139 assert visit_info.getInstrumentLabel() == "LSSTCam" 

1140 

1141 assert_visit_images_equal(roundtrip.result, legacy_test_data.visit_image, expect_view=False) 

1142 assert dict(legacy_test_data.visit_image._opaque_metadata.headers[ExtensionKey()]) == dict( 

1143 roundtrip.result._opaque_metadata.headers[ExtensionKey()] 

1144 ) 

1145 assert not roundtrip.result._opaque_metadata.headers[ExtensionKey("IMAGE")] 

1146 assert not roundtrip.result._opaque_metadata.headers[ExtensionKey("MASK")] 

1147 assert not roundtrip.result._opaque_metadata.headers[ExtensionKey("VARIANCE")] 

1148 assert roundtrip.result._opaque_metadata.headers[ExtensionKey()]["PLATFORM"] == "lsstcam" 

1149 compare_visit_image_to_legacy( 

1150 roundtrip.result, 

1151 legacy_test_data.legacy_exposure, 

1152 expect_view=False, 

1153 plane_map=legacy_test_data.plane_map, 

1154 **MINIMAL_VISIT_DATA_ID, 

1155 alternates=alternates, 

1156 ) 

1157 compare_visit_image_to_legacy( 

1158 legacy_test_data.read_cls.from_legacy( 

1159 legacy_test_data.legacy_exposure, 

1160 plane_map=legacy_test_data.plane_map, 

1161 exposure_record=legacy_test_data.exposure_record, 

1162 ), 

1163 legacy_test_data.legacy_exposure, 

1164 expect_view=True, 

1165 plane_map=legacy_test_data.plane_map, 

1166 **MINIMAL_VISIT_DATA_ID, 

1167 ) 

1168 

1169 

1170def test_butler_converters(legacy_test_data: _LegacyTestData) -> None: 

1171 """Verify that a VisitImage can be read from a Butler dataset written as 

1172 an Exposure. 

1173 """ 

1174 try: 

1175 from lsst.daf.butler import FileDataset 

1176 except ImportError: 

1177 pytest.skip("lsst.daf.butler could not be imported.") 

1178 

1179 with TemporaryButler(legacy="ExposureF") as helper: 

1180 helper.butler.ingest( 

1181 FileDataset(path=legacy_test_data.filename, refs=[helper.legacy]), transfer="symlink" 

1182 ) 

1183 visit_image_ref = helper.legacy.overrideStorageClass(legacy_test_data.storage_class) 

1184 record_params = {"exposure_record": legacy_test_data.exposure_record} 

1185 with warnings.catch_warnings(): 

1186 warnings.filterwarnings("ignore", message=".*filter label mismatch.*", category=UserWarning) 

1187 visit_image = helper.butler.get(visit_image_ref, parameters=record_params) 

1188 assert visit_image._opaque_metadata.precompressed.keys() == set() 

1189 visit_image = helper.butler.get( 

1190 visit_image_ref, parameters={"preserve_quantization": True, **record_params} 

1191 ) 

1192 assert visit_image._opaque_metadata.precompressed.keys() == {"IMAGE", "VARIANCE"} 

1193 bbox = helper.butler.get(visit_image_ref.makeComponentRef("bbox"), parameters=record_params) 

1194 assert bbox == visit_image.bbox 

1195 # bbox/psf/detector reads route through VisitImage and require the 

1196 # exposure record; image/mask/variance route to MaskedImage, which 

1197 # carries no ObservationInfo and must not be given it. 

1198 alternates = { 

1199 k: helper.butler.get(visit_image_ref.makeComponentRef(k)) for k in ["image", "mask", "variance"] 

1200 } | { 

1201 k: helper.butler.get(visit_image_ref.makeComponentRef(k), parameters=record_params) 

1202 for k in ["bbox", "psf", "detector"] 

1203 } 

1204 compare_visit_image_to_legacy( 

1205 visit_image, 

1206 legacy_test_data.legacy_exposure, 

1207 expect_view=False, 

1208 plane_map=legacy_test_data.plane_map, 

1209 alternates=alternates, 

1210 **MINIMAL_VISIT_DATA_ID, 

1211 ) 

1212 helper.butler.pruneDatasets([helper.legacy], purge=True, unstore=True, disassociate=True) 

1213 visit_image.metadata["MixedCaseKey"] = 52 

1214 helper.butler.put(visit_image, visit_image_ref) 

1215 with warnings.catch_warnings(): 

1216 warnings.filterwarnings("ignore", message=".*filter label mismatch.*", category=UserWarning) 

1217 legacy_exposure = helper.butler.get(helper.legacy) 

1218 compare_visit_image_to_legacy( 

1219 visit_image, 

1220 legacy_exposure, 

1221 expect_view=False, 

1222 plane_map=legacy_test_data.plane_map, 

1223 alternates=alternates, 

1224 **MINIMAL_VISIT_DATA_ID, 

1225 ) 

1226 visit_image_2 = helper.butler.get(visit_image_ref, parameters=record_params) 

1227 compare_visit_image_to_legacy( 

1228 visit_image_2, 

1229 legacy_exposure, 

1230 expect_view=False, 

1231 plane_map=legacy_test_data.plane_map, 

1232 alternates=alternates, 

1233 **MINIMAL_VISIT_DATA_ID, 

1234 ) 

1235 assert visit_image_2.metadata["MixedCaseKey"] == 52 

1236 

1237 

1238def test_convert_unit(legacy_test_data_calibrated: _LegacyTestData) -> None: 

1239 """Verify convert_unit round-trips between nJy, mJy, and electron via 

1240 photometric_scaling. 

1241 """ 

1242 from lsst.afw.table import ExposureCatalog 

1243 

1244 legacy_test_data = legacy_test_data_calibrated 

1245 original = legacy_test_data.visit_image.copy() 

1246 with pytest.raises(u.UnitConversionError): 

1247 original.convert_unit(u.electron) 

1248 visit_image_nJy = original.convert_unit(u.nJy, copy=False) 

1249 assert np.may_share_memory(visit_image_nJy.image.array, original.image.array) 

1250 assert np.may_share_memory(visit_image_nJy.variance.array, original.variance.array) 

1251 with pytest.raises(u.UnitConversionError): 

1252 original.convert_unit(u.mJy, copy=False) 

1253 visit_image_mJy = original.convert_unit(u.mJy, copy="as-needed") 

1254 assert visit_image_mJy.unit == u.mJy 

1255 assert_values_equal(visit_image_mJy.image.array, original.image.array * 1e-6, rtol=1e-5) 

1256 assert np.may_share_memory(visit_image_nJy.mask.array, original.mask.array) 

1257 assert_values_equal(visit_image_mJy.variance.array, original.variance.array * 1e-12, rtol=1e-5) 

1258 legacy_exposure_mJy = visit_image_mJy.to_legacy() 

1259 assert_values_equal(legacy_exposure_mJy.getPhotoCalib().getCalibrationMean(), 1e6, rtol=1e-14) 

1260 legacy_masked_image_nJy = legacy_exposure_mJy.getPhotoCalib().calibrateImage( 

1261 legacy_exposure_mJy.maskedImage 

1262 ) 

1263 assert_values_equal(visit_image_nJy.image.array, legacy_masked_image_nJy.image.array, rtol=1e-5) 

1264 assert_values_equal(visit_image_nJy.variance.array, legacy_masked_image_nJy.variance.array, rtol=1e-5) 

1265 assert np.may_share_memory(visit_image_mJy.mask.array, original.mask.array) 

1266 assert visit_image_mJy.sky_projection is original.sky_projection 

1267 assert visit_image_mJy.obs_info is original.obs_info 

1268 assert visit_image_mJy.summary_stats is original.summary_stats 

1269 assert visit_image_mJy.psf is original.psf 

1270 assert visit_image_mJy.detector is original.detector 

1271 assert visit_image_mJy.bounds is original.bounds 

1272 assert visit_image_mJy.aperture_corrections is original.aperture_corrections 

1273 assert visit_image_mJy.photometric_scaling is original.photometric_scaling 

1274 visit_summary = ExposureCatalog.readFits( 

1275 os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "visit_summary.fits") 

1276 ) 

1277 legacy_photo_calib = visit_summary.find(DP2_VISIT_DETECTOR_DATA_ID["detector"]).getPhotoCalib() 

1278 visit_image_nJy.photometric_scaling = field_from_legacy_photo_calib( 

1279 legacy_photo_calib, bounds=original.detector.bbox, instrumental_unit=u.electron 

1280 ) 

1281 compare_photo_calib_to_legacy( 

1282 visit_image_nJy.photometric_scaling, 

1283 legacy_test_data.legacy_exposure.getPhotoCalib(), 

1284 applied_legacy_photo_calib=legacy_photo_calib, 

1285 subimage_bbox=visit_image_nJy.bbox, 

1286 ) 

1287 with pytest.raises(u.UnitConversionError): 

1288 visit_image_nJy.convert_unit(u.mm) 

1289 with pytest.raises(u.UnitConversionError): 

1290 visit_image_nJy.convert_unit(u.electron, copy=False) 

1291 legacy_masked_image_e = legacy_photo_calib.uncalibrateImage(legacy_test_data.legacy_exposure.maskedImage) 

1292 visit_image_e = visit_image_nJy.convert_unit(u.electron) 

1293 assert_values_equal(visit_image_e.image.array, legacy_masked_image_e.image.array, rtol=1e-5) 

1294 assert_values_equal(visit_image_e.variance.array, legacy_masked_image_e.variance.array, rtol=1e-5) 

1295 assert not np.may_share_memory(visit_image_e.mask.array, visit_image_nJy.mask.array) 

1296 visit_image_mJy.photometric_scaling = visit_image_nJy.photometric_scaling 

1297 visit_image_e = visit_image_mJy.convert_unit(u.electron) 

1298 assert_values_equal(visit_image_e.image.array, legacy_masked_image_e.image.array, rtol=1e-5) 

1299 assert_values_equal(visit_image_e.variance.array, legacy_masked_image_e.variance.array, rtol=1e-5) 

1300 visit_image_nJy_2 = visit_image_e.convert_unit(u.nJy) 

1301 assert_values_equal(visit_image_nJy_2.image.array, visit_image_nJy.image.array, rtol=1e-5) 

1302 assert_values_equal(visit_image_nJy_2.variance.array, original.variance.array, rtol=1e-5) 

1303 visit_image_e.photometric_scaling = visit_image_nJy.photometric_scaling * (1e-6 * u.mJy / u.nJy) 

1304 visit_image_nJy_3 = visit_image_e.convert_unit(u.nJy) 

1305 assert_values_equal(visit_image_nJy_3.image.array, visit_image_nJy.image.array, rtol=1e-5) 

1306 assert_values_equal(visit_image_nJy_3.variance.array, original.variance.array, rtol=1e-5) 

1307 legacy_exposure_e = visit_image_e.to_legacy() 

1308 assert_values_equal( 

1309 legacy_exposure_e.getPhotoCalib().getCalibrationMean(), 

1310 legacy_photo_calib.getCalibrationMean(), 

1311 rtol=1e-5, 

1312 ) 

1313 legacy_masked_image_nJy = legacy_exposure_e.getPhotoCalib().calibrateImage(legacy_exposure_e.maskedImage) 

1314 assert_values_equal(visit_image_nJy.image.array, legacy_masked_image_nJy.image.array, rtol=1e-5) 

1315 assert_values_equal(visit_image_nJy.variance.array, legacy_masked_image_nJy.variance.array, rtol=1e-5) 

1316 

1317 

1318def test_background_map_describe() -> None: 

1319 """An empty BackgroundMap._describe reports no backgrounds inline.""" 

1320 bg_map = BackgroundMap() 

1321 assert isinstance(bg_map, DescribableMixin) 

1322 report = bg_map._describe() 

1323 assert isinstance(report, Report) 

1324 assert report.type_name == "BackgroundMap" 

1325 assert report.inline 

1326 assert report.summary == "no backgrounds" 

1327 assert report.children == {} 

1328 

1329 

1330def test_background_map_with_entries_describe() -> None: 

1331 """BackgroundMap._describe recurses into each background model.""" 

1332 cheby = ChebyshevField(Box.factory[0:100, 0:200], np.array([[1.0]])) 

1333 bg_map = BackgroundMap( 

1334 [Background("sky", cheby, "Sky model."), Background("fringe", cheby)], 

1335 subtracted="sky", 

1336 ) 

1337 report = bg_map._describe() 

1338 assert report.type_name == "BackgroundMap" 

1339 assert report.inline 

1340 # The inline summary leads with the subtracted background and marks it, 

1341 # since that is all a composite holding this map will show. 

1342 # Only the subtracted background is marked, not the whole line. 

1343 assert report.summary == "**sky [SUBTRACTED]**; also: fringe" 

1344 assert report.emphasis_markup 

1345 # str reads the summary as text, so the delimiters never reach a user. 

1346 assert report.to_str() == "sky [SUBTRACTED]; also: fringe" 

1347 # Standalone, each background is a child carrying its own model's report. 

1348 assert set(report.children) == {"sky", "fringe"} 

1349 sky = report.children["sky"] 

1350 assert sky.type_name == "ChebyshevField" 

1351 # The marker is on the heading, where it is read before the fields. 

1352 assert sky.title == "ChebyshevField **[SUBTRACTED]**" 

1353 assert sky.emphasis_markup 

1354 sky_fields = {f.label: f.value for f in sky.fields} 

1355 assert sky_fields["description"] == "Sky model." 

1356 # The model's own fields survive alongside the background's attributes. 

1357 assert "bounds" in sky_fields 

1358 # Only the subtracted one is marked, and an absent description is omitted. 

1359 fringe = report.children["fringe"] 

1360 assert fringe.title is None 

1361 assert fringe.emphasis_markup is False 

1362 assert "description" not in {f.label for f in fringe.fields} 

1363 

1364 

1365def test_background_map_describe_none_subtracted() -> None: 

1366 """A map with no subtracted background says so rather than being silent.""" 

1367 cheby = ChebyshevField(Box.factory[0:100, 0:200], np.array([[1.0]])) 

1368 bg_map = BackgroundMap([Background("sky", cheby), Background("fringe", cheby)]) 

1369 report = bg_map._describe() 

1370 assert report.summary == "sky, fringe (none subtracted)" 

1371 assert report.emphasis_markup is False 

1372 assert not any(child.emphasis_markup for child in report.children.values()) 

1373 

1374 

1375def test_background_map_describe_leads_with_subtracted() -> None: 

1376 """The subtracted background leads the summary wherever it sits in the 

1377 map, and only its child is marked. 

1378 """ 

1379 cheby = ChebyshevField(Box.factory[0:100, 0:200], np.array([[1.0]])) 

1380 bg_map = BackgroundMap( 

1381 [Background("sky", cheby), Background("skyCorr", cheby)], 

1382 subtracted="skyCorr", 

1383 ) 

1384 report = bg_map._describe() 

1385 assert report.to_str() == "skyCorr [SUBTRACTED]; also: sky" 

1386 assert report.children["sky"].emphasis_markup is False 

1387 assert report.children["skyCorr"].emphasis_markup 

1388 

1389 

1390def test_background_map_describe_brief_skips_children() -> None: 

1391 """A brief background map report keeps the summary but not the models.""" 

1392 cheby = ChebyshevField(Box.factory[0:100, 0:200], np.array([[1.0]])) 

1393 bg_map = BackgroundMap([Background("sky", cheby)], subtracted="sky") 

1394 report = bg_map._describe(DescribeOptions(brief=True)) 

1395 assert report.to_str() == "sky [SUBTRACTED]" 

1396 assert report.children == {} 

1397 

1398 

1399def test_visit_image_repr_str_with_unreadable_psf() -> None: 

1400 """Repr and str succeed even when the PSF stored an ArchiveReadError. 

1401 

1402 An unreadable component is a supported state; repr and str read only the 

1403 cheap fields and summary, so they do not build the child tree at all. 

1404 """ 

1405 path = current_fixture_path(FIXTURE_DIR, "visit_image") 

1406 visit_image = read_archive(path) 

1407 visit_image._psf = ArchiveReadError("psf unreadable") 

1408 assert repr(visit_image).startswith("VisitImage(") 

1409 assert str(visit_image).startswith("VisitImage(") 

1410 

1411 

1412def test_visit_image_describe_names_an_unreadable_psf() -> None: 

1413 """A full report says the PSF could not be read, and describes the rest. 

1414 

1415 A PSF model can need a package the reader does not have installed, so an 

1416 unreadable PSF must not cost the report of everything else. 

1417 """ 

1418 path = current_fixture_path(FIXTURE_DIR, "visit_image") 

1419 visit_image = read_archive(path) 

1420 visit_image._psf = ArchiveReadError("Failed to import piff.") 

1421 report = visit_image.describe(detail=True) 

1422 psf = report.children["psf"] 

1423 assert psf.inline 

1424 assert psf.to_str() == "unreadable (Failed to import piff.)" 

1425 # Every other component is described as usual. 

1426 assert "sky_projection" in report.children 

1427 assert "detector" in report.children 

1428 # Both renderers run over the report that contains it. 

1429 assert "unreadable" in report._repr_html_() 

1430 report.__rich__() 

1431 

1432 

1433def test_visit_image_slice_preserves_unreadable_psf() -> None: 

1434 """Slicing propagates a deferred PSF read failure without raising it.""" 

1435 path = current_fixture_path(FIXTURE_DIR, "visit_image") 

1436 visit_image = read_archive(path) 

1437 error = ArchiveReadError("psf unreadable") 

1438 visit_image._psf = error 

1439 

1440 sliced = visit_image[...] 

1441 

1442 assert sliced._psf is error 

1443 with pytest.raises(ArchiveReadError, match="psf unreadable"): 

1444 sliced.psf 

1445 

1446 

1447def test_observation_summary_stats_describe() -> None: 

1448 """ObservationSummaryStats._describe reports the statistics that are set. 

1449 

1450 Unset ones are omitted rather than shown as NaN. 

1451 """ 

1452 stats = ObservationSummaryStats(psfSigma=2.5, zeroPoint=31.4, ra=180.0, dec=-30.0) 

1453 assert isinstance(stats, DescribableMixin) 

1454 report = stats._describe() 

1455 assert isinstance(report, Report) 

1456 assert report.type_name == "ObservationSummaryStats" 

1457 # Scalars are packed into one group, ordered by name. 

1458 (group,) = report.value_groups 

1459 assert group.role is FieldRole.DERIVED 

1460 assert [name for name, _ in group.values] == sorted(name for name, _ in group.values) 

1461 values = dict(group.values) 

1462 assert values["psfSigma"] == 2.5 

1463 assert values["zeroPoint"] == 31.4 

1464 assert values["ra"] == 180.0 

1465 assert values["dec"] == -30.0 

1466 # Unset statistics are omitted rather than reported as NaN, and the 

1467 # serialization plumbing this class inherits never appears at all. 

1468 assert "expTime" not in values 

1469 assert "skyBg" not in values 

1470 assert not {"metadata", "butler_info", "schema_version"} & set(values) 

1471 

1472 

1473def test_observation_summary_stats_describe_omits_empty_sequences() -> None: 

1474 """Sequence statistics appear only when they carry a value.""" 

1475 empty = ObservationSummaryStats(psfSigma=2.5) 

1476 # raCorners defaults to all-NaN, which carries no more information than an 

1477 # empty sequence does. 

1478 assert all(math.isnan(v) for v in empty.raCorners) 

1479 assert not any(f.label == "raCorners" for f in empty._describe().fields) 

1480 

1481 filled = ObservationSummaryStats(psfSigma=2.5, raCorners=(5.2, 5.4, 5.4, 5.2)) 

1482 corners = next(f for f in filled._describe().fields if f.label == "raCorners") 

1483 assert corners.value == (5.2, 5.4, 5.4, 5.2) 

1484 assert corners.role is FieldRole.DERIVED 

1485 

1486 

1487def test_observation_summary_stats_describe_brief_counts() -> None: 

1488 """A brief report gives the number set rather than listing them.""" 

1489 stats = ObservationSummaryStats(psfSigma=2.5, raCorners=(5.2, 5.4, 5.4, 5.2)) 

1490 brief = stats._describe(DescribeOptions(brief=True)) 

1491 assert brief.type_name == "ObservationSummaryStats" 

1492 assert brief.value_groups == [] 

1493 (field,) = brief.fields 

1494 assert field.label == "statistics set" 

1495 assert field.role is FieldRole.DERIVED 

1496 

1497 # The count must agree with what the full report actually shows: two 

1498 # scalars set by default plus psfSigma, and raCorners as a sequence. 

1499 full = stats._describe() 

1500 listed = len(full.fields) + sum(len(g.values) for g in full.value_groups) 

1501 assert field.value.startswith(f"{listed} of ") 

1502 assert brief.to_str() == full.to_str() 

1503 assert f"{listed} of " in brief.to_str() 

1504 

1505 

1506def test_observation_summary_stats_pydantic_repr() -> None: 

1507 """ObservationSummaryStats uses pydantic's repr, not the mixin's.""" 

1508 stats = ObservationSummaryStats(psfSigma=2.5) 

1509 r = repr(stats) 

1510 assert r.startswith("ObservationSummaryStats(") 

1511 assert "psfSigma=2.5" in r 

1512 

1513 

1514def test_observation_summary_stats_str_is_the_report_summary() -> None: 

1515 """The str output reports the count, not pydantic's field-by-field dump. 

1516 

1517 The repr keeps the exhaustive form, since that is the one that 

1518 round-trips. 

1519 """ 

1520 stats = ObservationSummaryStats(psfSigma=2.5, zeroPoint=31.4) 

1521 assert str(stats) == stats.describe().to_str() 

1522 # The two set here, plus the integer counters, which default to a genuine 

1523 # zero rather than to NaN. 

1524 assert str(stats) == "ObservationSummaryStats(4 of 66 statistics set)" 

1525 assert stats.nPsfStar == 0 

1526 assert stats.nShapeletsStar == 0 

1527 # The unset statistics reach repr but not str. 

1528 assert "nan" not in str(stats) 

1529 assert "nan" in repr(stats) 

1530 

1531 

1532def test_archive_tree_repr_omits_schema_bookkeeping() -> None: 

1533 """Schema version fields mirror class constants, so repr leaves them out. 

1534 

1535 They are never passed on construction and say nothing the type does not. 

1536 """ 

1537 stats = ObservationSummaryStats(psfSigma=2.5, indirect=[1]) 

1538 for name in ("schema_version", "min_read_version", "indirect"): 

1539 assert name not in repr(stats), name 

1540 # They are still real fields, and hiding them from repr does not hide them 

1541 # from serialization. 

1542 assert stats.schema_version == ObservationSummaryStats.SCHEMA_VERSION 

1543 dumped = stats.model_dump() 

1544 for name in ("schema_version", "min_read_version", "indirect"): 

1545 assert name in dumped, name 

1546 

1547 

1548def _match_detector_to_image(visit_image: VisitImage) -> VisitImage: 

1549 """Return the visit image with its detector shrunk to the image bbox. 

1550 

1551 Parameters 

1552 ---------- 

1553 visit_image 

1554 Image to adjust. 

1555 

1556 Returns 

1557 ------- 

1558 visit_image : `VisitImage` 

1559 Image whose `~VisitImage.bbox` equals its detector's, as a full-frame 

1560 image read from a butler has. 

1561 """ 

1562 detector = visit_image.detector 

1563 visit_image._detector = Detector( 

1564 detector._attributes.model_copy(update={"bbox": visit_image.bbox}), 

1565 detector.amplifiers, 

1566 detector._frames, 

1567 detector.visit, 

1568 ) 

1569 return visit_image 

1570 

1571 

1572def test_visit_image_describe_hides_summary_stat_corners_for_a_full_frame( 

1573 visit_image_components: dict[str, Any], 

1574) -> None: 

1575 """The sky corners appear once, in the projection's readable table.""" 

1576 visit = _match_detector_to_image(make_visit_image(visit_image_components)) 

1577 visit.summary_stats.raCorners = (1.0, 2.0, 3.0, 4.0) 

1578 visit.summary_stats.decCorners = (-1.0, -2.0, -3.0, -4.0) 

1579 report = visit.describe() 

1580 stats = report.children["summary_stats"] 

1581 assert not {"raCorners", "decCorners"} & {field.label for field in stats.fields} 

1582 # The projection is where they are read instead, and it still has them. 

1583 assert any(table.title == "Corners" for table in report.children["sky_projection"].tables) 

1584 

1585 

1586def test_visit_image_cutout_keeps_summary_stat_corners() -> None: 

1587 """A cutout keeps the statistics' corners, which still describe the whole 

1588 image the statistics were measured on. 

1589 """ 

1590 # The DP2 variant is a cutout of a real image, so it carries the 

1591 # statistics measured on the whole of it. 

1592 path = current_fixture_path(FIXTURE_DIR, "visit_image", variant="dp2") 

1593 visit_image = read_archive(path) 

1594 assert visit_image.bbox != visit_image.detector.bbox 

1595 stats = visit_image.describe().children["summary_stats"] 

1596 assert {"raCorners", "decCorners"} <= {field.label for field in stats.fields}