Coverage for tests/test_visit_image.py: 72%

756 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-10-02 02:37 -0700

1# This file is part of lsst-images. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# Use of this source code is governed by a 3-clause BSD-style 

10# license that can be found in the LICENSE file. 

11 

12from __future__ import annotations 

13 

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.images import ( 

29 Background, 

30 BackgroundMap, 

31 Box, 

32 DetectorFrame, 

33 DifferenceImage, 

34 Image, 

35 MaskPlane, 

36 MaskSchema, 

37 ObservationSummaryStats, 

38 Polygon, 

39 SkyProjectionAstropyView, 

40 TractFrame, 

41 VisitImage, 

42 get_legacy_difference_image_mask_planes, 

43 get_legacy_visit_image_mask_planes, 

44 json, 

45) 

46from lsst.images.aperture_corrections import ApertureCorrectionMap, aperture_corrections_to_legacy 

47from lsst.images.cameras import Detector 

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

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

50from lsst.images.fits import ExtensionKey, FitsOpaqueMetadata 

51from lsst.images.psfs import GaussianPointSpreadFunction, PointSpreadFunction 

52from lsst.images.serialization import ArchiveReadError, read_archive 

53from lsst.images.tests import ( 

54 DP2_VISIT_DETECTOR_DATA_ID, 

55 RoundtripFits, 

56 RoundtripJson, 

57 RoundtripNdf, 

58 TemporaryButler, 

59 assert_masked_images_equal, 

60 assert_sky_projections_equal, 

61 assert_values_equal, 

62 assert_visit_images_equal, 

63 compare_aperture_corrections_to_legacy, 

64 compare_detector_to_legacy, 

65 compare_photo_calib_to_legacy, 

66 compare_visit_image_to_legacy, 

67 current_fixture_path, 

68 make_random_sky_projection, 

69 reset_afw_mask_planes, # noqa: F401 

70) 

71 

72try: 

73 import h5py # noqa: F401 

74 

75 HAVE_H5PY = True 

76except ImportError: 

77 HAVE_H5PY = False 

78 

79try: 

80 from lsst.afw.image import Exposure as LegacyExposure 

81 from lsst.afw.image import VisitInfo as LegacyVisitInfo 

82except ImportError: 

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

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

85 

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

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

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

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

90 

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

92 

93 

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

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

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

97 rng = np.random.default_rng(500) 

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

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

100 obs_info = ObservationInfo(instrument="LSSTCam", detector_num=4, physical_filter="r1") 

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

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

103 aperture_corrections: ApertureCorrectionMap = { 

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

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

106 } 

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

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

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

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

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

112 # polygon is the lower triangle of the image. 

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

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

115 return { 

116 "mask_schema": mask_schema, 

117 "obs_info": obs_info, 

118 "summary_stats": summary_stats, 

119 "gaussian_psf": gaussian_psf, 

120 "aperture_corrections": aperture_corrections, 

121 "detector": detector, 

122 "image": image, 

123 "variance": variance, 

124 "polygon": polygon, 

125 "sky_projection": sky_projection, 

126 } 

127 

128 

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

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

131 det_frame = components["sky_projection"].pixel_frame 

132 opaque = FitsOpaqueMetadata() 

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

134 with warnings.catch_warnings(): 

135 # Silence warnings about long keys becoming HIERARCH. 

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

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

138 opaque.extract_legacy_primary_header(hdr) 

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

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

141 vi = VisitImage( 

142 components["image"], 

143 variance=components["variance"], 

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

145 mask_schema=components["mask_schema"], 

146 sky_projection=components["sky_projection"], 

147 obs_info=components["obs_info"], 

148 summary_stats=components["summary_stats"], 

149 detector=components["detector"], 

150 bounds=components["polygon"], 

151 aperture_corrections=components["aperture_corrections"], 

152 band="r", 

153 ) 

154 vi.backgrounds.add( 

155 "standard", 

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

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

158 is_subtracted=True, 

159 ) 

160 vi._opaque_metadata = opaque 

161 return vi 

162 

163 

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

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

166 return VisitImage( 

167 components["image"], 

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

169 mask_schema=components["mask_schema"], 

170 sky_projection=components["sky_projection"], 

171 detector=components["detector"], 

172 obs_info=components["obs_info"], 

173 band="r", 

174 ) 

175 

176 

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

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

179 rng = np.random.default_rng(42) 

180 bbox = visit_image.image.sky_projection.pixel_frame.bbox 

181 bin_y = bbox.y.linspace(6) 

182 bin_x = bbox.x.linspace(7) 

183 spline_a = SplineField( 

184 bbox, 

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

186 y=bin_y, 

187 x=bin_x, 

188 ) 

189 spline_b = SplineField( 

190 bbox, 

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

192 y=bin_y, 

193 x=bin_x, 

194 ) 

195 sum_field = SumField([spline_a, spline_b]) 

196 bg_map = BackgroundMap() 

197 bg_map.add( 

198 "stacked", 

199 sum_field, 

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

201 is_subtracted=True, 

202 ) 

203 return VisitImage( 

204 components["image"], 

205 variance=components["variance"], 

206 psf=components["gaussian_psf"], 

207 mask_schema=components["mask_schema"], 

208 sky_projection=components["sky_projection"], 

209 obs_info=components["obs_info"], 

210 summary_stats=components["summary_stats"], 

211 detector=components["detector"], 

212 band="r", 

213 backgrounds=bg_map, 

214 ) 

215 

216 

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

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

219 subtracted = result.backgrounds.subtracted 

220 assert subtracted is not None 

221 assert isinstance(subtracted.field, SumField) 

222 original_subtracted = original.backgrounds.subtracted 

223 assert original_subtracted is not None 

224 original_field = original_subtracted.field 

225 assert isinstance(original_field, SumField) 

226 round_field = subtracted.field 

227 assert isinstance(round_field, SumField) 

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

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

230 assert round_op == orig_op 

231 

232 

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

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

235 visit = make_simplest_visit_image(visit_image_components) 

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

237 assert repr(visit) == ( 

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

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

240 " dtype=dtype('uint8')))" 

241 ) 

242 

243 

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

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

246 c = visit_image_components 

247 # Test default fill of variance. 

248 visit = make_simplest_visit_image(c) 

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

250 assert visit[...] is not visit 

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

252 assert repr(visit) == ( 

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

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

255 " dtype=dtype('uint8')))" 

256 ) 

257 

258 astropy_wcs = visit.astropy_wcs 

259 assert isinstance(astropy_wcs, SkyProjectionAstropyView) 

260 approx_wcs = visit.fits_wcs 

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

262 

263 with pytest.raises(TypeError): 

264 # Requires a PSF. 

265 VisitImage( 

266 c["image"], 

267 mask_schema=c["mask_schema"], 

268 sky_projection=c["sky_projection"], 

269 obs_info=c["obs_info"], 

270 detector=c["detector"], 

271 band="r", 

272 ) 

273 

274 with pytest.raises(TypeError): 

275 # Requires ObservationInfo. 

276 VisitImage( 

277 c["image"], 

278 psf=c["gaussian_psf"], 

279 mask_schema=c["mask_schema"], 

280 sky_projection=c["sky_projection"], 

281 detector=c["detector"], 

282 band="r", 

283 ) 

284 

285 with pytest.raises(TypeError): 

286 # Requires a sky_projection. 

287 VisitImage( 

288 c["image"], 

289 psf=c["gaussian_psf"], 

290 mask_schema=c["mask_schema"], 

291 obs_info=c["obs_info"], 

292 detector=c["detector"], 

293 band="r", 

294 ) 

295 

296 with pytest.raises(TypeError): 

297 # Requires a detector. 

298 VisitImage( 

299 c["image"], 

300 psf=c["gaussian_psf"], 

301 mask_schema=c["mask_schema"], 

302 sky_projection=c["sky_projection"], 

303 obs_info=c["obs_info"], 

304 band="r", 

305 ) 

306 

307 with pytest.raises(TypeError): 

308 # Requires some form of mask. 

309 VisitImage( 

310 c["image"], 

311 psf=c["gaussian_psf"], 

312 sky_projection=c["sky_projection"], 

313 obs_info=c["obs_info"], 

314 detector=c["detector"], 

315 band="r", 

316 ) 

317 

318 with pytest.raises(TypeError): 

319 VisitImage( 

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

321 psf=c["gaussian_psf"], 

322 mask_schema=c["mask_schema"], 

323 sky_projection=c["sky_projection"], 

324 obs_info=c["obs_info"], 

325 detector=c["detector"], 

326 band="r", 

327 ) 

328 

329 # Requires a DetectorFrame. 

330 rng = np.random.default_rng(501) 

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

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

333 with pytest.raises(TypeError): 

334 VisitImage( 

335 c["image"], 

336 sky_projection=tract_proj, 

337 psf=c["gaussian_psf"], 

338 mask_schema=c["mask_schema"], 

339 obs_info=c["obs_info"], 

340 detector=c["detector"], 

341 band="r", 

342 ) 

343 

344 # Variance unit mismatch. 

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

346 VisitImage( 

347 c["image"], 

348 variance=c["image"], 

349 psf=c["gaussian_psf"], 

350 mask_schema=c["mask_schema"], 

351 sky_projection=c["sky_projection"], 

352 obs_info=c["obs_info"], 

353 detector=c["detector"], 

354 band="r", 

355 ) 

356 

357 

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

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

360 them. 

361 """ 

362 c = visit_image_components 

363 visit_image = make_visit_image(c) 

364 copy = visit_image.copy() 

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

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

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

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

369 # Check summary stats. 

370 assert copy.summary_stats == visit_image.summary_stats 

371 assert copy.summary_stats is not visit_image.summary_stats 

372 assert subvisit.summary_stats == visit_image.summary_stats 

373 assert subvisit.summary_stats is visit_image.summary_stats 

374 # Check aperture corrections. 

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

376 assert copy.aperture_corrections is not visit_image.aperture_corrections 

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

378 assert subvisit.aperture_corrections is visit_image.aperture_corrections 

379 # Check backgrounds. 

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

381 assert copy.backgrounds is not visit_image.backgrounds 

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

383 assert subvisit.backgrounds is visit_image.backgrounds 

384 # Check bounds. 

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

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

387 

388 

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

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

391 instrument. 

392 """ 

393 visit_image = make_visit_image(visit_image_components) 

394 assert visit_image.obs_info is not None 

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

396 

397 

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

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

400 summary_stats = visit_image_components["summary_stats"] 

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

402 assert summary_stats != ObservationSummaryStats(psfSigma=2.5) 

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

404 

405 

406def test_summary_stats_to_legacy() -> None: 

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

408 ExposureSummaryStats even when this package defines fields that the 

409 installed afw does not. 

410 """ 

411 try: 

412 from lsst.afw.image import ExposureSummaryStats 

413 except ImportError: 

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

415 

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

417 legacy = summary_stats.to_legacy() 

418 assert isinstance(legacy, ExposureSummaryStats) 

419 assert legacy.psfSigma == 2.5 

420 assert legacy.zeroPoint == 31.4 

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

422 # round trip reproduces the original. 

423 assert ObservationSummaryStats.from_legacy(legacy) == summary_stats 

424 

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

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

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

428 extra_float_fields = [ 

429 name 

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

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

432 ] 

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

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

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

436 

437 

438def test_summary_stats_from_legacy_unknown_field() -> None: 

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

440 

441 @dataclasses.dataclass 

442 class FakeLegacy: 

443 psfSigma: float = 2.5 

444 notARealField: float = math.nan 

445 

446 # An unknown field that is empty is dropped. 

447 stats = ObservationSummaryStats.from_legacy(FakeLegacy()) 

448 assert stats.psfSigma == 2.5 

449 

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

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

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

453 

454 

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

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

457 a legacy afw ExposureSummaryStats via the converter registered on the 

458 ExposureSummaryStats storage class. 

459 """ 

460 from lsst.afw.image import ExposureSummaryStats as LegacyExposureSummaryStats 

461 

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

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

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

465 assert isinstance(legacy_stats, LegacyExposureSummaryStats) 

466 assert legacy_stats.psfSigma == 2.5 

467 assert legacy_stats.zeroPoint == 31.4 

468 assert ObservationSummaryStats.from_legacy(legacy_stats) == summary_stats 

469 

470 

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

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

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

474 via the converter registered on the Detector storage class. 

475 """ 

476 from lsst.afw.cameraGeom import Detector as LegacyDetector 

477 

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

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

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

481 assert isinstance(legacy_detector, LegacyDetector) 

482 compare_detector_to_legacy(detector, legacy_detector, is_raw_assembled=True) 

483 

484 

485def test_repeated_metadata_keys_legacy_round_trip( 

486 visit_image_components: dict[str, Any], 

487 reset_afw_mask_planes: None, # noqa: F811 

488) -> None: 

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

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

491 """ 

492 from lsst.afw.detection import GaussianPsf 

493 

494 opaque_metadata = FitsOpaqueMetadata() 

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

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

497 # SubtractBackgroundTask writes one BGMEAN card per background fit. 

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

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

500 opaque_metadata.extract_legacy_primary_header(header) 

501 visit_image = VisitImage( 

502 visit_image_components["image"], 

503 variance=visit_image_components["variance"], 

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

505 # Exposure and from_legacy can read it back. 

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

507 mask_schema=visit_image_components["mask_schema"], 

508 sky_projection=visit_image_components["sky_projection"], 

509 detector=visit_image_components["detector"], 

510 obs_info=visit_image_components["obs_info"], 

511 band="r", 

512 ) 

513 visit_image._opaque_metadata = opaque_metadata 

514 

515 legacy_exposure = visit_image.to_legacy() 

516 legacy_metadata = legacy_exposure.getMetadata() 

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

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

519 # Add a random key directly 

520 legacy_metadata.add("DUMMYVAR", 0.5) 

521 legacy_metadata.add("DUMMYVAR", 1.5) 

522 

523 round_tripped = VisitImage.from_legacy( 

524 legacy_exposure, 

525 instrument=visit_image_components["obs_info"].instrument, 

526 visit=visit_image_components["sky_projection"].pixel_frame.visit, 

527 ) 

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

529 assert "DUMMYVAR" in round_tripped_header 

530 assert "BGMEAN" in round_tripped_header 

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

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

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

534 

535 

536def test_external_metadata_legacy_round_trip( 

537 visit_image_components: dict[str, Any], 

538 reset_afw_mask_planes: None, # noqa: F811 

539 tmp_path: Path, 

540) -> None: 

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

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

543 file is read back. 

544 """ 

545 from lsst.afw.detection import GaussianPsf 

546 

547 opaque_metadata = FitsOpaqueMetadata() 

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

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

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

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

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

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

554 opaque_metadata.extract_legacy_primary_header(header) 

555 visit_image = VisitImage( 

556 visit_image_components["image"], 

557 variance=visit_image_components["variance"], 

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

559 mask_schema=visit_image_components["mask_schema"], 

560 sky_projection=visit_image_components["sky_projection"], 

561 detector=visit_image_components["detector"], 

562 obs_info=visit_image_components["obs_info"], 

563 band="r", 

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

565 ) 

566 visit_image._opaque_metadata = opaque_metadata 

567 path = tmp_path / "legacy.fits" 

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

569 

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

571 primary = hdu_list[0].header 

572 native_cards = {} 

573 n = 1 

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

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

576 n += 1 

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

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

579 

580 result = VisitImage.read_legacy( 

581 str(path), 

582 instrument=visit_image_components["obs_info"].instrument, 

583 visit=visit_image_components["sky_projection"].pixel_frame.visit, 

584 ) 

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

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

587 assert "id" in result.metadata.native 

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

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

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

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

592 

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

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

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

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

597 

598 

599@skip_no_h5py 

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

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

602 visit_image = make_visit_image(visit_image_components) 

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

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

605 

606 

607@skip_no_h5py 

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

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

610 visit_image = make_visit_image(visit_image_components) 

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

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

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

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

615 

616 

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

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

619 visit_image = make_visit_image(visit_image_components) 

620 with ( 

621 RoundtripFits(visit_image) as fits_rt, 

622 RoundtripJson(visit_image) as json_rt, 

623 ): 

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

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

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

627 

628 

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

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

631 

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

633 versions too. 

634 """ 

635 visit_image = make_visit_image(visit_image_components) 

636 tree = json.write(visit_image) 

637 tree_type = type(tree) 

638 assert tree.schema_version == tree_type.SCHEMA_VERSION 

639 assert tree.min_read_version == tree_type.MIN_READ_VERSION 

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

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

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

643 component = getattr(tree, name) 

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

645 

646 

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

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

649 

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

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

652 in `test_read_write_components`. 

653 """ 

654 visit_image = make_visit_image(visit_image_components) 

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

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

657 # wrote WCSs. 

658 fits = roundtrip.inspect() 

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

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

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

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

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

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

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

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

667 subimage = roundtrip.get(bbox=subbox) 

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

669 

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

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

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

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

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

675 ) 

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

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

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

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

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

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

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

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

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

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

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

687 

688 

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

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

691 

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

693 Butler-free assertions live in `test_read_write`. 

694 """ 

695 c = visit_image_components 

696 visit_image = make_visit_image(c) 

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

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

699 subimage = roundtrip.get(bbox=subbox) 

700 

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

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

703 assert_masked_images_equal(subimage_masked, subimage, expect_view=False) 

704 

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

706 # it is the same thing. 

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

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

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

710 

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

712 

713 obs_info = roundtrip.get("obs_info") 

714 assert isinstance(obs_info, ObservationInfo) 

715 assert obs_info == visit_image.obs_info 

716 

717 summary_stats = roundtrip.get("summary_stats") 

718 assert isinstance(summary_stats, ObservationSummaryStats) 

719 assert summary_stats == visit_image.summary_stats 

720 

721 psf = roundtrip.get("psf") 

722 assert isinstance(psf, GaussianPointSpreadFunction) 

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

724 

725 backgrounds = roundtrip.get("backgrounds") 

726 assert isinstance(backgrounds, BackgroundMap) 

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

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

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

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

731 

732 # Test some components get edge cases. 

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

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

735 

736 components = roundtrip.get("components") 

737 assert set(components) == { 

738 "image", 

739 "variance", 

740 "psf", 

741 "bbox", 

742 "mask", 

743 "obs_info", 

744 "backgrounds", 

745 "detector", 

746 "aperture_corrections", 

747 "sky_projection", 

748 "summary_stats", 

749 "photometric_scaling", 

750 } 

751 

752 # Butler morphs RuntimeError to ValueError. 

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

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

755 

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

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

758 

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

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

761 

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

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

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

765 

766 

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

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

769 EXTVERs. 

770 """ 

771 visit_image = make_visit_image(visit_image_components) 

772 visit = _make_sum_background_visit_image(visit_image_components, visit_image) 

773 with RoundtripFits(visit) as roundtrip: 

774 _check_sum_background_round_trip(roundtrip.result, visit) 

775 

776 

777@skip_no_h5py 

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

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

780 the FITS backend does. 

781 """ 

782 visit_image = make_visit_image(visit_image_components) 

783 visit = _make_sum_background_visit_image(visit_image_components, visit_image) 

784 with RoundtripNdf(visit) as roundtrip: 

785 _check_sum_background_round_trip(roundtrip.result, visit) 

786 

787 

788@pytest.mark.parametrize( 

789 ("scaling_unit", "operation"), 

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

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

792) 

793def test_convert_unit_subimage( 

794 visit_image_components: dict[str, Any], 

795 scaling_unit: u.UnitBase, 

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

797) -> None: 

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

799 of the photometric scaling that overlaps the subimage. 

800 

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

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

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

804 """ 

805 visit_image = make_visit_image(visit_image_components) 

806 scaling = ChebyshevField( 

807 visit_image.bbox, 

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

809 unit=scaling_unit, 

810 ) 

811 visit_image.photometric_scaling = scaling 

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

813 # over the wrong box cannot match by chance. 

814 subbox = Box.factory[ 

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

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

817 ] 

818 subimage = visit_image[subbox] 

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

820 

821 converted = subimage.convert_unit(u.electron) 

822 

823 assert converted.unit == u.electron 

824 assert converted.image.bbox == subbox 

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

826 if operation == "divide": 

827 scaling_array = 1.0 / scaling_array 

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

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

830 

831 

832@dataclasses.dataclass 

833class _LegacyTestData: 

834 filename: str 

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

836 unit: u.Unit = u.nJy 

837 storage_class: str = "VisitImage" 

838 read_cls: type[VisitImage] = VisitImage 

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

840 

841 @classmethod 

842 def get( 

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

844 ) -> _LegacyTestData: 

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

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

847 result = cls( 

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

849 ) 

850 match which: 

851 case "preliminary_visit_image": 

852 result.unit = u.electron 

853 case "difference_image": 

854 result.storage_class = "DifferenceImage" 

855 result.read_cls = DifferenceImage 

856 result.plane_map = get_legacy_difference_image_mask_planes() 

857 case "visit_image": 

858 pass 

859 try: 

860 from lsst.afw.image import ExposureFitsReader 

861 

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

863 except ImportError: 

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

865 result.visit_image = result.read_cls.read_legacy( 

866 result.filename, preserve_quantization=True, plane_map=result.plane_map 

867 ) 

868 return result 

869 

870 

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

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

873 """Return legacy test data. 

874 

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

876 test images. 

877 """ 

878 return _LegacyTestData.get(request.param) 

879 

880 

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

882def legacy_test_data_calibrated( 

883 request: pytest.FixtureRequest, 

884 reset_afw_mask_planes: None, # noqa: F811 

885) -> _LegacyTestData: 

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

887 

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

889 preliminary_visit_image, since that has 'electron' pixel units 

890 """ 

891 return _LegacyTestData.get(request.param) 

892 

893 

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

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

896 assert isinstance(obs_info, ObservationInfo) 

897 assert obs_info.instrument == "LSSTCam" 

898 assert obs_info.detector_num == 85, obs_info 

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

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

901 

902 

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

904 """Verify that from_legacy and read_legacy raise ValueError on 

905 conflicting arguments. 

906 """ 

907 with pytest.raises(ValueError, match="does not match"): 

908 VisitImage.from_legacy(legacy_test_data.legacy_exposure, instrument="HSC") 

909 with pytest.raises(ValueError, match="does not match"): 

910 VisitImage.from_legacy(legacy_test_data.legacy_exposure, visit=123456) 

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

912 VisitImage.from_legacy(legacy_test_data.legacy_exposure, unit=u.mJy) 

913 visit = VisitImage.from_legacy( 

914 legacy_test_data.legacy_exposure, 

915 instrument="LSSTCam", 

916 unit=legacy_test_data.unit, 

917 visit=2025052000177, 

918 ) 

919 assert visit.unit == legacy_test_data.unit 

920 

921 with pytest.raises(ValueError, match="does not match"): 

922 legacy_test_data.read_cls.read_legacy(legacy_test_data.filename, instrument="HSC") 

923 with pytest.raises(ValueError, match="does not match"): 

924 legacy_test_data.read_cls.read_legacy(legacy_test_data.filename, visit=123456) 

925 

926 

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

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

929 file. 

930 """ 

931 visit = VisitImage.read_legacy(legacy_test_data.filename) 

932 proj = VisitImage.read_legacy(legacy_test_data.filename, component="sky_projection") 

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

934 image = VisitImage.read_legacy(legacy_test_data.filename, component="image") 

935 assert image == visit.image 

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

937 variance = VisitImage.read_legacy(legacy_test_data.filename, component="variance") 

938 assert variance == visit.variance 

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

940 mask = VisitImage.read_legacy(legacy_test_data.filename, component="mask") 

941 assert mask == visit.mask 

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

943 psf = VisitImage.read_legacy(legacy_test_data.filename, component="psf") 

944 assert isinstance(psf, PointSpreadFunction) 

945 obs_info = VisitImage.read_legacy(legacy_test_data.filename, component="obs_info") 

946 _check_legacy_obs_info(obs_info) 

947 summary_stats = VisitImage.read_legacy(legacy_test_data.filename, component="summary_stats") 

948 assert isinstance(summary_stats, ObservationSummaryStats) 

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

950 compare_aperture_corrections_to_legacy( 

951 VisitImage.read_legacy(legacy_test_data.filename, component="aperture_corrections"), 

952 legacy_test_data.legacy_exposure.info.getApCorrMap(), 

953 visit.bbox, 

954 ) 

955 detector = VisitImage.read_legacy(legacy_test_data.filename, component="detector") 

956 compare_detector_to_legacy( 

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

958 ) 

959 photometric_scaling = VisitImage.read_legacy(legacy_test_data.filename, component="photometric_scaling") 

960 compare_photo_calib_to_legacy( 

961 photometric_scaling, 

962 legacy_test_data.legacy_exposure.getPhotoCalib(), 

963 subimage_bbox=visit.bbox, 

964 ) 

965 

966 

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

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

969 exposure. 

970 """ 

971 legacy = VisitImage.from_legacy(legacy_test_data.legacy_exposure, plane_map=legacy_test_data.plane_map) 

972 assert legacy.obs_info is not None 

973 assert legacy.obs_info == legacy_test_data.visit_image.obs_info 

974 assert legacy.obs_info is not None # for mypy 

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

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

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

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

979 

980 

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

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

983 ApCorrMap. 

984 """ 

985 ap_corrections = legacy_test_data.visit_image.aperture_corrections 

986 legacy_ap_corr_map = aperture_corrections_to_legacy(ap_corrections) 

987 compare_aperture_corrections_to_legacy( 

988 ap_corrections, 

989 legacy_ap_corr_map, 

990 legacy_test_data.visit_image.bbox, 

991 ) 

992 

993 

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

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

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

997 assert "EXPTIME" in header 

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

999 assert "LSST BUTLER ID" not in header 

1000 assert "AR HDU" not in header 

1001 assert "A_ORDER" not in header 

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

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

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

1005 

1006 

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

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

1009 read_legacy. 

1010 """ 

1011 assert legacy_test_data.visit_image.unit == legacy_test_data.unit 

1012 _check_legacy_headers(legacy_test_data.visit_image) 

1013 

1014 

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

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

1017 correctly. 

1018 """ 

1019 legacy = VisitImage.from_legacy(legacy_test_data.legacy_exposure, plane_map=legacy_test_data.plane_map) 

1020 assert legacy.unit == legacy_test_data.unit 

1021 _check_legacy_headers(legacy) 

1022 

1023 

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

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

1026 pixel values and all components. 

1027 """ 

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

1029 fits = roundtrip.inspect() 

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

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

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

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

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

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

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

1037 subimage = roundtrip.get(bbox=subbox) 

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

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

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

1041 alternates = { 

1042 k: roundtrip.get(k) 

1043 for k in [ 

1044 "sky_projection", 

1045 "image", 

1046 "mask", 

1047 "variance", 

1048 "psf", 

1049 "obs_info", 

1050 "summary_stats", 

1051 "aperture_corrections", 

1052 "detector", 

1053 "photometric_scaling", 

1054 ] 

1055 } 

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

1057 assert isinstance(legacy_exposure, LegacyExposure) 

1058 compare_visit_image_to_legacy( 

1059 legacy_test_data.visit_image, 

1060 legacy_exposure, 

1061 expect_view=False, 

1062 plane_map=legacy_test_data.plane_map, 

1063 **MINIMAL_VISIT_DATA_ID, 

1064 ) 

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

1066 assert legacy_exposure.getPhotoCalib()._isConstant 

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

1068 else: 

1069 compare_photo_calib_to_legacy( 

1070 legacy_test_data.visit_image.photometric_scaling, 

1071 legacy_exposure.getPhotoCalib(), 

1072 subimage_bbox=subbox, 

1073 ) 

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

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

1076 assert isinstance(visit_info, LegacyVisitInfo) 

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

1078 

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

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

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

1082 ) 

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

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

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

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

1087 compare_visit_image_to_legacy( 

1088 roundtrip.result, 

1089 legacy_test_data.legacy_exposure, 

1090 expect_view=False, 

1091 plane_map=legacy_test_data.plane_map, 

1092 **MINIMAL_VISIT_DATA_ID, 

1093 alternates=alternates, 

1094 ) 

1095 compare_visit_image_to_legacy( 

1096 legacy_test_data.read_cls.from_legacy( 

1097 legacy_test_data.legacy_exposure, plane_map=legacy_test_data.plane_map 

1098 ), 

1099 legacy_test_data.legacy_exposure, 

1100 expect_view=True, 

1101 plane_map=legacy_test_data.plane_map, 

1102 **MINIMAL_VISIT_DATA_ID, 

1103 ) 

1104 

1105 

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

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

1108 an Exposure. 

1109 """ 

1110 try: 

1111 from lsst.daf.butler import FileDataset 

1112 except ImportError: 

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

1114 

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

1116 helper.butler.ingest( 

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

1118 ) 

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

1120 with warnings.catch_warnings(): 

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

1122 visit_image = helper.butler.get(visit_image_ref) 

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

1124 visit_image = helper.butler.get(visit_image_ref, parameters={"preserve_quantization": True}) 

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

1126 bbox = helper.butler.get(visit_image_ref.makeComponentRef("bbox")) 

1127 assert bbox == visit_image.bbox 

1128 alternates = { 

1129 k: helper.butler.get(visit_image_ref.makeComponentRef(k)) 

1130 for k in ["image", "mask", "variance", "bbox", "psf", "detector"] 

1131 } 

1132 compare_visit_image_to_legacy( 

1133 visit_image, 

1134 legacy_test_data.legacy_exposure, 

1135 expect_view=False, 

1136 plane_map=legacy_test_data.plane_map, 

1137 alternates=alternates, 

1138 **MINIMAL_VISIT_DATA_ID, 

1139 ) 

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

1141 visit_image.metadata["MixedCaseKey"] = 52 

1142 helper.butler.put(visit_image, visit_image_ref) 

1143 with warnings.catch_warnings(): 

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

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

1146 compare_visit_image_to_legacy( 

1147 visit_image, 

1148 legacy_exposure, 

1149 expect_view=False, 

1150 plane_map=legacy_test_data.plane_map, 

1151 alternates=alternates, 

1152 **MINIMAL_VISIT_DATA_ID, 

1153 ) 

1154 visit_image_2 = helper.butler.get(visit_image_ref) 

1155 compare_visit_image_to_legacy( 

1156 visit_image_2, 

1157 legacy_exposure, 

1158 expect_view=False, 

1159 plane_map=legacy_test_data.plane_map, 

1160 alternates=alternates, 

1161 **MINIMAL_VISIT_DATA_ID, 

1162 ) 

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

1164 

1165 

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

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

1168 photometric_scaling. 

1169 """ 

1170 from lsst.afw.table import ExposureCatalog 

1171 

1172 legacy_test_data = legacy_test_data_calibrated 

1173 original = legacy_test_data.visit_image.copy() 

1174 with pytest.raises(u.UnitConversionError): 

1175 original.convert_unit(u.electron) 

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

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

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

1179 with pytest.raises(u.UnitConversionError): 

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

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

1182 assert visit_image_mJy.unit == u.mJy 

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

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

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

1186 legacy_exposure_mJy = visit_image_mJy.to_legacy() 

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

1188 legacy_masked_image_nJy = legacy_exposure_mJy.getPhotoCalib().calibrateImage( 

1189 legacy_exposure_mJy.maskedImage 

1190 ) 

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

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

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

1194 assert visit_image_mJy.sky_projection is original.sky_projection 

1195 assert visit_image_mJy.obs_info is original.obs_info 

1196 assert visit_image_mJy.summary_stats is original.summary_stats 

1197 assert visit_image_mJy.psf is original.psf 

1198 assert visit_image_mJy.detector is original.detector 

1199 assert visit_image_mJy.bounds is original.bounds 

1200 assert visit_image_mJy.aperture_corrections is original.aperture_corrections 

1201 assert visit_image_mJy.photometric_scaling is original.photometric_scaling 

1202 visit_summary = ExposureCatalog.readFits( 

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

1204 ) 

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

1206 visit_image_nJy.photometric_scaling = field_from_legacy_photo_calib( 

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

1208 ) 

1209 compare_photo_calib_to_legacy( 

1210 visit_image_nJy.photometric_scaling, 

1211 legacy_test_data.legacy_exposure.getPhotoCalib(), 

1212 applied_legacy_photo_calib=legacy_photo_calib, 

1213 subimage_bbox=visit_image_nJy.bbox, 

1214 ) 

1215 with pytest.raises(u.UnitConversionError): 

1216 visit_image_nJy.convert_unit(u.mm) 

1217 with pytest.raises(u.UnitConversionError): 

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

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

1220 visit_image_e = visit_image_nJy.convert_unit(u.electron) 

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

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

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

1224 visit_image_mJy.photometric_scaling = visit_image_nJy.photometric_scaling 

1225 visit_image_e = visit_image_mJy.convert_unit(u.electron) 

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

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

1228 visit_image_nJy_2 = visit_image_e.convert_unit(u.nJy) 

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

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

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

1232 visit_image_nJy_3 = visit_image_e.convert_unit(u.nJy) 

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

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

1235 legacy_exposure_e = visit_image_e.to_legacy() 

1236 assert_values_equal( 

1237 legacy_exposure_e.getPhotoCalib().getCalibrationMean(), 

1238 legacy_photo_calib.getCalibrationMean(), 

1239 rtol=1e-5, 

1240 ) 

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

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

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

1244 

1245 

1246def test_background_map_describe() -> None: 

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

1248 bg_map = BackgroundMap() 

1249 assert isinstance(bg_map, DescribableMixin) 

1250 report = bg_map._describe() 

1251 assert isinstance(report, Report) 

1252 assert report.type_name == "BackgroundMap" 

1253 assert report.inline 

1254 assert report.summary == "no backgrounds" 

1255 assert report.children == {} 

1256 

1257 

1258def test_background_map_with_entries_describe() -> None: 

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

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

1261 bg_map = BackgroundMap( 

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

1263 subtracted="sky", 

1264 ) 

1265 report = bg_map._describe() 

1266 assert report.type_name == "BackgroundMap" 

1267 assert report.inline 

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

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

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

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

1272 assert report.emphasis_markup 

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

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

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

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

1277 sky = report.children["sky"] 

1278 assert sky.type_name == "ChebyshevField" 

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

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

1281 assert sky.emphasis_markup 

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

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

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

1285 assert "bounds" in sky_fields 

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

1287 fringe = report.children["fringe"] 

1288 assert fringe.title is None 

1289 assert fringe.emphasis_markup is False 

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

1291 

1292 

1293def test_background_map_describe_none_subtracted() -> None: 

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

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

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

1297 report = bg_map._describe() 

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

1299 assert report.emphasis_markup is False 

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

1301 

1302 

1303def test_background_map_describe_leads_with_subtracted() -> None: 

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

1305 map, and only its child is marked. 

1306 """ 

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

1308 bg_map = BackgroundMap( 

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

1310 subtracted="skyCorr", 

1311 ) 

1312 report = bg_map._describe() 

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

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

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

1316 

1317 

1318def test_background_map_describe_brief_skips_children() -> None: 

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

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

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

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

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

1324 assert report.children == {} 

1325 

1326 

1327def test_visit_image_repr_str_with_unreadable_psf() -> None: 

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

1329 

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

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

1332 """ 

1333 path = current_fixture_path(FIXTURE_DIR, "visit_image") 

1334 visit_image = read_archive(path) 

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

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

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

1338 

1339 

1340def test_visit_image_describe_names_an_unreadable_psf() -> None: 

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

1342 

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

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

1345 """ 

1346 path = current_fixture_path(FIXTURE_DIR, "visit_image") 

1347 visit_image = read_archive(path) 

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

1349 report = visit_image.describe(detail=True) 

1350 psf = report.children["psf"] 

1351 assert psf.inline 

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

1353 # Every other component is described as usual. 

1354 assert "sky_projection" in report.children 

1355 assert "detector" in report.children 

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

1357 assert "unreadable" in report._repr_html_() 

1358 report.__rich__() 

1359 

1360 

1361def test_visit_image_slice_preserves_unreadable_psf() -> None: 

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

1363 path = current_fixture_path(FIXTURE_DIR, "visit_image") 

1364 visit_image = read_archive(path) 

1365 error = ArchiveReadError("psf unreadable") 

1366 visit_image._psf = error 

1367 

1368 sliced = visit_image[...] 

1369 

1370 assert sliced._psf is error 

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

1372 sliced.psf 

1373 

1374 

1375def test_observation_summary_stats_describe() -> None: 

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

1377 

1378 Unset ones are omitted rather than shown as NaN. 

1379 """ 

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

1381 assert isinstance(stats, DescribableMixin) 

1382 report = stats._describe() 

1383 assert isinstance(report, Report) 

1384 assert report.type_name == "ObservationSummaryStats" 

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

1386 (group,) = report.value_groups 

1387 assert group.role is FieldRole.DERIVED 

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

1389 values = dict(group.values) 

1390 assert values["psfSigma"] == 2.5 

1391 assert values["zeroPoint"] == 31.4 

1392 assert values["ra"] == 180.0 

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

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

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

1396 assert "expTime" not in values 

1397 assert "skyBg" not in values 

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

1399 

1400 

1401def test_observation_summary_stats_describe_omits_empty_sequences() -> None: 

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

1403 empty = ObservationSummaryStats(psfSigma=2.5) 

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

1405 # empty sequence does. 

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

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

1408 

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

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

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

1412 assert corners.role is FieldRole.DERIVED 

1413 

1414 

1415def test_observation_summary_stats_describe_brief_counts() -> None: 

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

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

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

1419 assert brief.type_name == "ObservationSummaryStats" 

1420 assert brief.value_groups == [] 

1421 (field,) = brief.fields 

1422 assert field.label == "statistics set" 

1423 assert field.role is FieldRole.DERIVED 

1424 

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

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

1427 full = stats._describe() 

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

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

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

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

1432 

1433 

1434def test_observation_summary_stats_pydantic_repr() -> None: 

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

1436 stats = ObservationSummaryStats(psfSigma=2.5) 

1437 r = repr(stats) 

1438 assert r.startswith("ObservationSummaryStats(") 

1439 assert "psfSigma=2.5" in r 

1440 

1441 

1442def test_observation_summary_stats_str_is_the_report_summary() -> None: 

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

1444 

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

1446 round-trips. 

1447 """ 

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

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

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

1451 # zero rather than to NaN. 

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

1453 assert stats.nPsfStar == 0 

1454 assert stats.nShapeletsStar == 0 

1455 # The unset statistics reach repr but not str. 

1456 assert "nan" not in str(stats) 

1457 assert "nan" in repr(stats) 

1458 

1459 

1460def test_archive_tree_repr_omits_schema_bookkeeping() -> None: 

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

1462 

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

1464 """ 

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

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

1467 assert name not in repr(stats), name 

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

1469 # from serialization. 

1470 assert stats.schema_version == ObservationSummaryStats.SCHEMA_VERSION 

1471 dumped = stats.model_dump() 

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

1473 assert name in dumped, name 

1474 

1475 

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

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

1478 

1479 Parameters 

1480 ---------- 

1481 visit_image 

1482 Image to adjust. 

1483 

1484 Returns 

1485 ------- 

1486 visit_image : `VisitImage` 

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

1488 image read from a butler has. 

1489 """ 

1490 detector = visit_image.detector 

1491 visit_image._detector = Detector( 

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

1493 detector.amplifiers, 

1494 detector._frames, 

1495 detector.visit, 

1496 ) 

1497 return visit_image 

1498 

1499 

1500def test_visit_image_describe_hides_summary_stat_corners_for_a_full_frame( 

1501 visit_image_components: dict[str, Any], 

1502) -> None: 

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

1504 visit = _match_detector_to_image(make_visit_image(visit_image_components)) 

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

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

1507 report = visit.describe() 

1508 stats = report.children["summary_stats"] 

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

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

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

1512 

1513 

1514def test_visit_image_cutout_keeps_summary_stat_corners() -> None: 

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

1516 image the statistics were measured on. 

1517 """ 

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

1519 # statistics measured on the whole of it. 

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

1521 visit_image = read_archive(path) 

1522 assert visit_image.bbox != visit_image.detector.bbox 

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

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