Coverage for tests/test_visit_image.py: 65%

614 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-13 10:40 +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.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) 

45from lsst.images.aperture_corrections import ApertureCorrectionMap, aperture_corrections_to_legacy 

46from lsst.images.cameras import Detector 

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

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

49from lsst.images.fits import ExtensionKey, FitsOpaqueMetadata 

50from lsst.images.psfs import GaussianPointSpreadFunction, PointSpreadFunction 

51from lsst.images.serialization import ArchiveReadError, read_archive 

52from lsst.images.tests import ( 

53 DP2_VISIT_DETECTOR_DATA_ID, 

54 RoundtripFits, 

55 RoundtripJson, 

56 RoundtripNdf, 

57 TemporaryButler, 

58 assert_close, 

59 assert_masked_images_equal, 

60 assert_sky_projections_equal, 

61 assert_visit_images_equal, 

62 compare_aperture_corrections_to_legacy, 

63 compare_detector_to_legacy, 

64 compare_photo_calib_to_legacy, 

65 compare_visit_image_to_legacy, 

66 current_fixture_path, 

67 make_random_sky_projection, 

68) 

69 

70try: 

71 import h5py # noqa: F401 

72 

73 HAVE_H5PY = True 

74except ImportError: 

75 HAVE_H5PY = False 

76 

77try: 

78 from lsst.afw.image import Exposure as LegacyExposure 

79 from lsst.afw.image import VisitInfo as LegacyVisitInfo 

80except ImportError: 

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

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

83 

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

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

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

87 

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

89 

90 

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

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

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

94 rng = np.random.default_rng(500) 

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

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

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

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

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

100 aperture_corrections: ApertureCorrectionMap = { 

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

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

103 } 

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

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

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

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

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

109 # polygon is the lower triangle of the image. 

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

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

112 return { 

113 "mask_schema": mask_schema, 

114 "obs_info": obs_info, 

115 "summary_stats": summary_stats, 

116 "gaussian_psf": gaussian_psf, 

117 "aperture_corrections": aperture_corrections, 

118 "detector": detector, 

119 "image": image, 

120 "variance": variance, 

121 "polygon": polygon, 

122 "sky_projection": sky_projection, 

123 } 

124 

125 

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

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

128 det_frame = components["sky_projection"].pixel_frame 

129 opaque = FitsOpaqueMetadata() 

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

131 with warnings.catch_warnings(): 

132 # Silence warnings about long keys becoming HIERARCH. 

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

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

135 opaque.extract_legacy_primary_header(hdr) 

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

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

138 vi = VisitImage( 

139 components["image"], 

140 variance=components["variance"], 

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

142 mask_schema=components["mask_schema"], 

143 sky_projection=components["sky_projection"], 

144 obs_info=components["obs_info"], 

145 summary_stats=components["summary_stats"], 

146 detector=components["detector"], 

147 bounds=components["polygon"], 

148 aperture_corrections=components["aperture_corrections"], 

149 band="r", 

150 ) 

151 vi.backgrounds.add( 

152 "standard", 

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

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

155 is_subtracted=True, 

156 ) 

157 vi._opaque_metadata = opaque 

158 return vi 

159 

160 

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

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

163 return VisitImage( 

164 components["image"], 

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

166 mask_schema=components["mask_schema"], 

167 sky_projection=components["sky_projection"], 

168 detector=components["detector"], 

169 obs_info=components["obs_info"], 

170 band="r", 

171 ) 

172 

173 

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

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

176 rng = np.random.default_rng(42) 

177 bbox = visit_image.image.sky_projection.pixel_frame.bbox 

178 bin_y = bbox.y.linspace(6) 

179 bin_x = bbox.x.linspace(7) 

180 spline_a = SplineField( 

181 bbox, 

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

183 y=bin_y, 

184 x=bin_x, 

185 ) 

186 spline_b = SplineField( 

187 bbox, 

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

189 y=bin_y, 

190 x=bin_x, 

191 ) 

192 sum_field = SumField([spline_a, spline_b]) 

193 bg_map = BackgroundMap() 

194 bg_map.add( 

195 "stacked", 

196 sum_field, 

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

198 is_subtracted=True, 

199 ) 

200 return VisitImage( 

201 components["image"], 

202 variance=components["variance"], 

203 psf=components["gaussian_psf"], 

204 mask_schema=components["mask_schema"], 

205 sky_projection=components["sky_projection"], 

206 obs_info=components["obs_info"], 

207 summary_stats=components["summary_stats"], 

208 detector=components["detector"], 

209 band="r", 

210 backgrounds=bg_map, 

211 ) 

212 

213 

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

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

216 subtracted = result.backgrounds.subtracted 

217 assert subtracted is not None 

218 assert isinstance(subtracted.field, SumField) 

219 original_subtracted = original.backgrounds.subtracted 

220 assert original_subtracted is not None 

221 original_field = original_subtracted.field 

222 assert isinstance(original_field, SumField) 

223 round_field = subtracted.field 

224 assert isinstance(round_field, SumField) 

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

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

227 assert round_op == orig_op 

228 

229 

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

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

232 visit = make_simplest_visit_image(visit_image_components) 

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

234 assert repr(visit) == ( 

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

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

237 " dtype=dtype('uint8')))" 

238 ) 

239 

240 

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

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

243 c = visit_image_components 

244 # Test default fill of variance. 

245 visit = make_simplest_visit_image(c) 

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

247 assert visit[...] is not visit 

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

249 assert repr(visit) == ( 

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

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

252 " dtype=dtype('uint8')))" 

253 ) 

254 

255 astropy_wcs = visit.astropy_wcs 

256 assert isinstance(astropy_wcs, SkyProjectionAstropyView) 

257 approx_wcs = visit.fits_wcs 

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

259 

260 with pytest.raises(TypeError): 

261 # Requires a PSF. 

262 VisitImage( 

263 c["image"], 

264 mask_schema=c["mask_schema"], 

265 sky_projection=c["sky_projection"], 

266 obs_info=c["obs_info"], 

267 detector=c["detector"], 

268 band="r", 

269 ) 

270 

271 with pytest.raises(TypeError): 

272 # Requires ObservationInfo. 

273 VisitImage( 

274 c["image"], 

275 psf=c["gaussian_psf"], 

276 mask_schema=c["mask_schema"], 

277 sky_projection=c["sky_projection"], 

278 detector=c["detector"], 

279 band="r", 

280 ) 

281 

282 with pytest.raises(TypeError): 

283 # Requires a sky_projection. 

284 VisitImage( 

285 c["image"], 

286 psf=c["gaussian_psf"], 

287 mask_schema=c["mask_schema"], 

288 obs_info=c["obs_info"], 

289 detector=c["detector"], 

290 band="r", 

291 ) 

292 

293 with pytest.raises(TypeError): 

294 # Requires a detector. 

295 VisitImage( 

296 c["image"], 

297 psf=c["gaussian_psf"], 

298 mask_schema=c["mask_schema"], 

299 sky_projection=c["sky_projection"], 

300 obs_info=c["obs_info"], 

301 band="r", 

302 ) 

303 

304 with pytest.raises(TypeError): 

305 # Requires some form of mask. 

306 VisitImage( 

307 c["image"], 

308 psf=c["gaussian_psf"], 

309 sky_projection=c["sky_projection"], 

310 obs_info=c["obs_info"], 

311 detector=c["detector"], 

312 band="r", 

313 ) 

314 

315 with pytest.raises(TypeError): 

316 VisitImage( 

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

318 psf=c["gaussian_psf"], 

319 mask_schema=c["mask_schema"], 

320 sky_projection=c["sky_projection"], 

321 obs_info=c["obs_info"], 

322 detector=c["detector"], 

323 band="r", 

324 ) 

325 

326 # Requires a DetectorFrame. 

327 rng = np.random.default_rng(501) 

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

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

330 with pytest.raises(TypeError): 

331 VisitImage( 

332 c["image"], 

333 sky_projection=tract_proj, 

334 psf=c["gaussian_psf"], 

335 mask_schema=c["mask_schema"], 

336 obs_info=c["obs_info"], 

337 detector=c["detector"], 

338 band="r", 

339 ) 

340 

341 # Variance unit mismatch. 

342 with pytest.raises(ValueError): 

343 VisitImage( 

344 c["image"], 

345 variance=c["image"], 

346 psf=c["gaussian_psf"], 

347 mask_schema=c["mask_schema"], 

348 sky_projection=c["sky_projection"], 

349 obs_info=c["obs_info"], 

350 detector=c["detector"], 

351 band="r", 

352 ) 

353 

354 

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

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

357 them. 

358 """ 

359 c = visit_image_components 

360 visit_image = make_visit_image(c) 

361 copy = visit_image.copy() 

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

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

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

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

366 # Check summary stats. 

367 assert copy.summary_stats == visit_image.summary_stats 

368 assert copy.summary_stats is not visit_image.summary_stats 

369 assert subvisit.summary_stats == visit_image.summary_stats 

370 assert subvisit.summary_stats is visit_image.summary_stats 

371 # Check aperture corrections. 

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

373 assert copy.aperture_corrections is not visit_image.aperture_corrections 

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

375 assert subvisit.aperture_corrections is visit_image.aperture_corrections 

376 # Check backgrounds. 

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

378 assert copy.backgrounds is not visit_image.backgrounds 

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

380 assert subvisit.backgrounds is visit_image.backgrounds 

381 # Check bounds. 

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

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

384 

385 

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

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

388 instrument. 

389 """ 

390 visit_image = make_visit_image(visit_image_components) 

391 assert visit_image.obs_info is not None 

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

393 

394 

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

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

397 summary_stats = visit_image_components["summary_stats"] 

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

399 assert summary_stats != ObservationSummaryStats(psfSigma=2.5) 

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

401 

402 

403def test_summary_stats_to_legacy() -> None: 

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

405 ExposureSummaryStats even when this package defines fields that the 

406 installed afw does not. 

407 """ 

408 try: 

409 from lsst.afw.image import ExposureSummaryStats 

410 except ImportError: 

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

412 

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

414 legacy = summary_stats.to_legacy() 

415 assert isinstance(legacy, ExposureSummaryStats) 

416 assert legacy.psfSigma == 2.5 

417 assert legacy.zeroPoint == 31.4 

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

419 # round trip reproduces the original. 

420 assert ObservationSummaryStats.from_legacy(legacy) == summary_stats 

421 

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

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

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

425 extra_float_fields = [ 

426 name 

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

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

429 ] 

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

431 with pytest.raises(ValueError): 

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

433 

434 

435def test_summary_stats_from_legacy_unknown_field() -> None: 

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

437 

438 @dataclasses.dataclass 

439 class FakeLegacy: 

440 psfSigma: float = 2.5 

441 notARealField: float = math.nan 

442 

443 # An unknown field that is empty is dropped. 

444 stats = ObservationSummaryStats.from_legacy(FakeLegacy()) 

445 assert stats.psfSigma == 2.5 

446 

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

448 with pytest.raises(ValueError): 

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

450 

451 

452@skip_no_h5py 

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

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

455 visit_image = make_visit_image(visit_image_components) 

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

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

458 

459 

460@skip_no_h5py 

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

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

463 visit_image = make_visit_image(visit_image_components) 

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

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

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

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

468 

469 

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

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

472 visit_image = make_visit_image(visit_image_components) 

473 with ( 

474 RoundtripFits(visit_image) as fits_rt, 

475 RoundtripJson(visit_image) as json_rt, 

476 ): 

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

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

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

480 

481 

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

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

484 

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

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

487 in `test_read_write_components`. 

488 """ 

489 visit_image = make_visit_image(visit_image_components) 

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

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

492 # wrote WCSs. 

493 fits = roundtrip.inspect() 

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

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

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

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

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

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

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

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

502 subimage = roundtrip.get(bbox=subbox) 

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

504 

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

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

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

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

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

510 ) 

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

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

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

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

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

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

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

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

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

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

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

522 

523 

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

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

526 

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

528 Butler-free assertions live in `test_read_write`. 

529 """ 

530 c = visit_image_components 

531 visit_image = make_visit_image(c) 

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

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

534 subimage = roundtrip.get(bbox=subbox) 

535 

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

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

538 assert_masked_images_equal(subimage_masked, subimage, expect_view=False) 

539 

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

541 # it is the same thing. 

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

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

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

545 

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

547 

548 obs_info = roundtrip.get("obs_info") 

549 assert isinstance(obs_info, ObservationInfo) 

550 assert obs_info == visit_image.obs_info 

551 

552 summary_stats = roundtrip.get("summary_stats") 

553 assert isinstance(summary_stats, ObservationSummaryStats) 

554 assert summary_stats == visit_image.summary_stats 

555 

556 psf = roundtrip.get("psf") 

557 assert isinstance(psf, GaussianPointSpreadFunction) 

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

559 

560 backgrounds = roundtrip.get("backgrounds") 

561 assert isinstance(backgrounds, BackgroundMap) 

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

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

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

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

566 

567 # Test some components get edge cases. 

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

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

570 

571 components = roundtrip.get("components") 

572 assert set(components) == { 

573 "image", 

574 "variance", 

575 "psf", 

576 "bbox", 

577 "mask", 

578 "obs_info", 

579 "backgrounds", 

580 "detector", 

581 "aperture_corrections", 

582 "sky_projection", 

583 "summary_stats", 

584 "photometric_scaling", 

585 } 

586 

587 # Butler morphs RuntimeError to ValueError. 

588 with pytest.raises(ValueError): 

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

590 

591 with pytest.raises(ValueError): 

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

593 

594 with pytest.raises(ValueError): 

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

596 

597 with pytest.raises(ValueError): 

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

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

600 

601 

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

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

604 EXTVERs. 

605 """ 

606 visit_image = make_visit_image(visit_image_components) 

607 visit = _make_sum_background_visit_image(visit_image_components, visit_image) 

608 with RoundtripFits(visit) as roundtrip: 

609 _check_sum_background_round_trip(roundtrip.result, visit) 

610 

611 

612@skip_no_h5py 

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

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

615 the FITS backend does. 

616 """ 

617 visit_image = make_visit_image(visit_image_components) 

618 visit = _make_sum_background_visit_image(visit_image_components, visit_image) 

619 with RoundtripNdf(visit) as roundtrip: 

620 _check_sum_background_round_trip(roundtrip.result, visit) 

621 

622 

623@pytest.mark.parametrize( 

624 "scaling_unit,operation", 

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

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

627) 

628def test_convert_unit_subimage( 

629 visit_image_components: dict[str, Any], 

630 scaling_unit: u.UnitBase, 

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

632) -> None: 

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

634 of the photometric scaling that overlaps the subimage. 

635 

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

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

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

639 """ 

640 visit_image = make_visit_image(visit_image_components) 

641 scaling = ChebyshevField( 

642 visit_image.bbox, 

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

644 unit=scaling_unit, 

645 ) 

646 visit_image.photometric_scaling = scaling 

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

648 # over the wrong box cannot match by chance. 

649 subbox = Box.factory[ 

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

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

652 ] 

653 subimage = visit_image[subbox] 

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

655 

656 converted = subimage.convert_unit(u.electron) 

657 

658 assert converted.unit == u.electron 

659 assert converted.image.bbox == subbox 

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

661 if operation == "divide": 

662 scaling_array = 1.0 / scaling_array 

663 assert_close(converted.image.array, subimage.image.array * scaling_array) 

664 assert_close(converted.variance.array, subimage.variance.array * scaling_array**2) 

665 

666 

667@dataclasses.dataclass 

668class _LegacyTestData: 

669 filename: str 

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

671 unit: u.Unit = u.nJy 

672 storage_class: str = "VisitImage" 

673 read_cls: type[VisitImage] = VisitImage 

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

675 

676 @classmethod 

677 def get( 

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

679 ) -> _LegacyTestData: 

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

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

682 result = cls( 

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

684 ) 

685 match which: 

686 case "preliminary_visit_image": 

687 result.unit = u.electron 

688 case "difference_image": 

689 result.storage_class = "DifferenceImage" 

690 result.read_cls = DifferenceImage 

691 result.plane_map = get_legacy_difference_image_mask_planes() 

692 case "visit_image": 

693 pass 

694 try: 

695 from lsst.afw.image import ExposureFitsReader 

696 

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

698 except ImportError: 

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

700 result.visit_image = result.read_cls.read_legacy( 

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

702 ) 

703 return result 

704 

705 

706@pytest.fixture(scope="session", params=["visit_image", "preliminary_visit_image", "difference_image"]) 

707def legacy_test_data(request: pytest.FixtureRequest) -> _LegacyTestData: 

708 """Return legacy test data. 

709 

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

711 test images. 

712 """ 

713 return _LegacyTestData.get(request.param) 

714 

715 

716@pytest.fixture(scope="session", params=["visit_image", "difference_image"]) 

717def legacy_test_data_calibrated(request: pytest.FixtureRequest) -> _LegacyTestData: 

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

719 

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

721 preliminary_visit_image, since that has 'electron' pixel units 

722 """ 

723 return _LegacyTestData.get(request.param) 

724 

725 

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

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

728 assert isinstance(obs_info, ObservationInfo) 

729 assert obs_info.instrument == "LSSTCam" 

730 assert obs_info.detector_num == 85, obs_info 

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

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

733 

734 

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

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

737 conflicting arguments. 

738 """ 

739 with pytest.raises(ValueError): 

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

741 with pytest.raises(ValueError): 

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

743 with pytest.raises(ValueError): 

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

745 visit = VisitImage.from_legacy( 

746 legacy_test_data.legacy_exposure, 

747 instrument="LSSTCam", 

748 unit=legacy_test_data.unit, 

749 visit=2025052000177, 

750 ) 

751 assert visit.unit == legacy_test_data.unit 

752 

753 with pytest.raises(ValueError): 

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

755 with pytest.raises(ValueError): 

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

757 

758 

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

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

761 file. 

762 """ 

763 visit = VisitImage.read_legacy(legacy_test_data.filename) 

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

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

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

767 assert image == visit.image 

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

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

770 assert variance == visit.variance 

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

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

773 assert mask == visit.mask 

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

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

776 assert isinstance(psf, PointSpreadFunction) 

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

778 _check_legacy_obs_info(obs_info) 

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

780 assert isinstance(summary_stats, ObservationSummaryStats) 

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

782 compare_aperture_corrections_to_legacy( 

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

784 legacy_test_data.legacy_exposure.info.getApCorrMap(), 

785 visit.bbox, 

786 ) 

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

788 compare_detector_to_legacy( 

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

790 ) 

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

792 compare_photo_calib_to_legacy( 

793 photometric_scaling, 

794 legacy_test_data.legacy_exposure.getPhotoCalib(), 

795 subimage_bbox=visit.bbox, 

796 ) 

797 

798 

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

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

801 exposure. 

802 """ 

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

804 assert legacy.obs_info is not None 

805 assert legacy.obs_info == legacy_test_data.visit_image.obs_info 

806 assert legacy.obs_info is not None # for mypy 

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

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

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

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

811 

812 

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

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

815 ApCorrMap. 

816 """ 

817 ap_corrections = legacy_test_data.visit_image.aperture_corrections 

818 legacy_ap_corr_map = aperture_corrections_to_legacy(ap_corrections) 

819 compare_aperture_corrections_to_legacy( 

820 ap_corrections, 

821 legacy_ap_corr_map, 

822 legacy_test_data.visit_image.bbox, 

823 ) 

824 

825 

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

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

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

829 assert "EXPTIME" in header 

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

831 assert "LSST BUTLER ID" not in header 

832 assert "AR HDU" not in header 

833 assert "A_ORDER" not in header 

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

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

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

837 

838 

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

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

841 read_legacy. 

842 """ 

843 assert legacy_test_data.visit_image.unit == legacy_test_data.unit 

844 _check_legacy_headers(legacy_test_data.visit_image) 

845 

846 

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

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

849 correctly. 

850 """ 

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

852 assert legacy.unit == legacy_test_data.unit 

853 _check_legacy_headers(legacy) 

854 

855 

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

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

858 pixel values and all components. 

859 """ 

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

861 fits = roundtrip.inspect() 

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

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

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

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

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

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

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

869 subimage = roundtrip.get(bbox=subbox) 

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

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

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

873 alternates = { 

874 k: roundtrip.get(k) 

875 for k in [ 

876 "sky_projection", 

877 "image", 

878 "mask", 

879 "variance", 

880 "psf", 

881 "obs_info", 

882 "summary_stats", 

883 "aperture_corrections", 

884 "detector", 

885 "photometric_scaling", 

886 ] 

887 } 

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

889 assert isinstance(legacy_exposure, LegacyExposure) 

890 compare_visit_image_to_legacy( 

891 legacy_test_data.visit_image, 

892 legacy_exposure, 

893 expect_view=False, 

894 plane_map=legacy_test_data.plane_map, 

895 **DP2_VISIT_DETECTOR_DATA_ID, 

896 ) 

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

898 assert legacy_exposure.getPhotoCalib()._isConstant 

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

900 else: 

901 compare_photo_calib_to_legacy( 

902 legacy_test_data.visit_image.photometric_scaling, 

903 legacy_exposure.getPhotoCalib(), 

904 subimage_bbox=subbox, 

905 ) 

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

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

908 assert isinstance(visit_info, LegacyVisitInfo) 

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

910 

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

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

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

914 ) 

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

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

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

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

919 compare_visit_image_to_legacy( 

920 roundtrip.result, 

921 legacy_test_data.legacy_exposure, 

922 expect_view=False, 

923 plane_map=legacy_test_data.plane_map, 

924 **DP2_VISIT_DETECTOR_DATA_ID, 

925 alternates=alternates, 

926 ) 

927 compare_visit_image_to_legacy( 

928 legacy_test_data.read_cls.from_legacy( 

929 legacy_test_data.legacy_exposure, plane_map=legacy_test_data.plane_map 

930 ), 

931 legacy_test_data.legacy_exposure, 

932 expect_view=True, 

933 plane_map=legacy_test_data.plane_map, 

934 **DP2_VISIT_DETECTOR_DATA_ID, 

935 ) 

936 

937 

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

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

940 an Exposure. 

941 """ 

942 try: 

943 from lsst.daf.butler import FileDataset 

944 except ImportError: 

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

946 

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

948 helper.butler.ingest( 

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

950 ) 

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

952 with warnings.catch_warnings(): 

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

954 visit_image = helper.butler.get(visit_image_ref) 

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

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

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

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

959 assert bbox == visit_image.bbox 

960 alternates = { 

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

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

963 } 

964 compare_visit_image_to_legacy( 

965 visit_image, 

966 legacy_test_data.legacy_exposure, 

967 expect_view=False, 

968 plane_map=legacy_test_data.plane_map, 

969 alternates=alternates, 

970 **DP2_VISIT_DETECTOR_DATA_ID, 

971 ) 

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

973 visit_image.metadata["MixedCaseKey"] = 52 

974 helper.butler.put(visit_image, visit_image_ref) 

975 with warnings.catch_warnings(): 

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

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

978 compare_visit_image_to_legacy( 

979 visit_image, 

980 legacy_exposure, 

981 expect_view=False, 

982 plane_map=legacy_test_data.plane_map, 

983 alternates=alternates, 

984 **DP2_VISIT_DETECTOR_DATA_ID, 

985 ) 

986 visit_image_2 = helper.butler.get(visit_image_ref) 

987 compare_visit_image_to_legacy( 

988 visit_image_2, 

989 legacy_exposure, 

990 expect_view=False, 

991 plane_map=legacy_test_data.plane_map, 

992 alternates=alternates, 

993 **DP2_VISIT_DETECTOR_DATA_ID, 

994 ) 

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

996 

997 

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

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

1000 photometric_scaling. 

1001 """ 

1002 from lsst.afw.table import ExposureCatalog 

1003 

1004 legacy_test_data = legacy_test_data_calibrated 

1005 original = legacy_test_data.visit_image.copy() 

1006 with pytest.raises(u.UnitConversionError): 

1007 original.convert_unit(u.electron) 

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

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

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

1011 with pytest.raises(u.UnitConversionError): 

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

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

1014 assert visit_image_mJy.unit == u.mJy 

1015 assert_close(visit_image_mJy.image.array, original.image.array * 1e-6) 

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

1017 assert_close(visit_image_mJy.variance.array, original.variance.array * 1e-12) 

1018 legacy_exposure_mJy = visit_image_mJy.to_legacy() 

1019 assert_close(legacy_exposure_mJy.getPhotoCalib().getCalibrationMean(), 1e6) 

1020 legacy_masked_image_nJy = legacy_exposure_mJy.getPhotoCalib().calibrateImage( 

1021 legacy_exposure_mJy.maskedImage 

1022 ) 

1023 assert_close(visit_image_nJy.image.array, legacy_masked_image_nJy.image.array) 

1024 assert_close(visit_image_nJy.variance.array, legacy_masked_image_nJy.variance.array) 

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

1026 assert visit_image_mJy.sky_projection is original.sky_projection 

1027 assert visit_image_mJy.obs_info is original.obs_info 

1028 assert visit_image_mJy.summary_stats is original.summary_stats 

1029 assert visit_image_mJy.psf is original.psf 

1030 assert visit_image_mJy.detector is original.detector 

1031 assert visit_image_mJy.bounds is original.bounds 

1032 assert visit_image_mJy.aperture_corrections is original.aperture_corrections 

1033 assert visit_image_mJy.photometric_scaling is original.photometric_scaling 

1034 visit_summary = ExposureCatalog.readFits( 

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

1036 ) 

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

1038 visit_image_nJy.photometric_scaling = field_from_legacy_photo_calib( 

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

1040 ) 

1041 compare_photo_calib_to_legacy( 

1042 visit_image_nJy.photometric_scaling, 

1043 legacy_test_data.legacy_exposure.getPhotoCalib(), 

1044 applied_legacy_photo_calib=legacy_photo_calib, 

1045 subimage_bbox=visit_image_nJy.bbox, 

1046 ) 

1047 with pytest.raises(u.UnitConversionError): 

1048 visit_image_nJy.convert_unit(u.mm) 

1049 with pytest.raises(u.UnitConversionError): 

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

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

1052 visit_image_e = visit_image_nJy.convert_unit(u.electron) 

1053 assert_close(visit_image_e.image.array, legacy_masked_image_e.image.array) 

1054 assert_close(visit_image_e.variance.array, legacy_masked_image_e.variance.array) 

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

1056 visit_image_mJy.photometric_scaling = visit_image_nJy.photometric_scaling 

1057 visit_image_e = visit_image_mJy.convert_unit(u.electron) 

1058 assert_close(visit_image_e.image.array, legacy_masked_image_e.image.array) 

1059 assert_close(visit_image_e.variance.array, legacy_masked_image_e.variance.array) 

1060 visit_image_nJy_2 = visit_image_e.convert_unit(u.nJy) 

1061 assert_close(visit_image_nJy_2.image.array, visit_image_nJy.image.array) 

1062 assert_close(visit_image_nJy_2.variance.array, original.variance.array) 

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

1064 visit_image_nJy_3 = visit_image_e.convert_unit(u.nJy) 

1065 assert_close(visit_image_nJy_3.image.array, visit_image_nJy.image.array) 

1066 assert_close(visit_image_nJy_3.variance.array, original.variance.array) 

1067 legacy_exposure_e = visit_image_e.to_legacy() 

1068 assert_close( 

1069 legacy_exposure_e.getPhotoCalib().getCalibrationMean(), 

1070 legacy_photo_calib.getCalibrationMean(), 

1071 ) 

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

1073 assert_close(visit_image_nJy.image.array, legacy_masked_image_nJy.image.array) 

1074 assert_close(visit_image_nJy.variance.array, legacy_masked_image_nJy.variance.array) 

1075 

1076 

1077def test_background_map_describe() -> None: 

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

1079 bg_map = BackgroundMap() 

1080 assert isinstance(bg_map, DescribableMixin) 

1081 report = bg_map._describe() 

1082 assert isinstance(report, Report) 

1083 assert report.type_name == "BackgroundMap" 

1084 assert report.inline 

1085 assert report.summary == "no backgrounds" 

1086 assert report.children == {} 

1087 

1088 

1089def test_background_map_with_entries_describe() -> None: 

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

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

1092 bg_map = BackgroundMap( 

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

1094 subtracted="sky", 

1095 ) 

1096 report = bg_map._describe() 

1097 assert report.type_name == "BackgroundMap" 

1098 assert report.inline 

1099 # The inline summary lists every background and marks the subtracted one, 

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

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

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

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

1104 sky = report.children["sky"] 

1105 assert sky.type_name == "ChebyshevField" 

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

1107 assert sky_fields["subtracted"] == "yes" 

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

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

1110 assert "bounds" in sky_fields 

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

1112 fringe_fields = {f.label: f.value for f in report.children["fringe"].fields} 

1113 assert "subtracted" not in fringe_fields 

1114 assert "description" not in fringe_fields 

1115 

1116 

1117def test_background_map_describe_brief_skips_children() -> None: 

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

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

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

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

1122 assert report.summary == "sky (subtracted)" 

1123 assert report.children == {} 

1124 

1125 

1126def test_visit_image_repr_str_with_unreadable_psf() -> None: 

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

1128 

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

1130 cheap fields and summary, so they must not build the child tree (which 

1131 would raise when it accesses the PSF). 

1132 """ 

1133 path = current_fixture_path(FIXTURE_DIR, "visit_image") 

1134 visit_image = read_archive(path) 

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

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

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

1138 

1139 

1140def test_observation_summary_stats_describe() -> None: 

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

1142 

1143 Unset ones are omitted rather than shown as NaN. 

1144 """ 

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

1146 assert isinstance(stats, DescribableMixin) 

1147 report = stats._describe() 

1148 assert isinstance(report, Report) 

1149 assert report.type_name == "ObservationSummaryStats" 

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

1151 (group,) = report.value_groups 

1152 assert group.role is FieldRole.DERIVED 

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

1154 values = dict(group.values) 

1155 assert values["psfSigma"] == 2.5 

1156 assert values["zeroPoint"] == 31.4 

1157 assert values["ra"] == 180.0 

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

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

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

1161 assert "expTime" not in values 

1162 assert "skyBg" not in values 

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

1164 

1165 

1166def test_observation_summary_stats_describe_omits_empty_sequences() -> None: 

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

1168 empty = ObservationSummaryStats(psfSigma=2.5) 

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

1170 # empty sequence does. 

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

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

1173 

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

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

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

1177 assert corners.role is FieldRole.DERIVED 

1178 

1179 

1180def test_observation_summary_stats_describe_brief_counts() -> None: 

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

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

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

1184 assert brief.type_name == "ObservationSummaryStats" 

1185 assert brief.value_groups == [] 

1186 (field,) = brief.fields 

1187 assert field.label == "statistics set" 

1188 assert field.role is FieldRole.DERIVED 

1189 

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

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

1192 full = stats._describe() 

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

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

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

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

1197 

1198 

1199def test_observation_summary_stats_pydantic_repr() -> None: 

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

1201 stats = ObservationSummaryStats(psfSigma=2.5) 

1202 r = repr(stats) 

1203 assert r.startswith("ObservationSummaryStats(") 

1204 assert "psfSigma=2.5" in r 

1205 

1206 

1207def test_observation_summary_stats_str_is_the_report_summary() -> None: 

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

1209 

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

1211 round-trips. 

1212 """ 

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

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

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

1216 # zero rather than to NaN. 

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

1218 assert stats.nPsfStar == 0 and stats.nShapeletsStar == 0 

1219 # The unset statistics reach repr but not str. 

1220 assert "nan" not in str(stats) 

1221 assert "nan" in repr(stats) 

1222 

1223 

1224def test_archive_tree_repr_omits_schema_bookkeeping() -> None: 

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

1226 

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

1228 """ 

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

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

1231 assert name not in repr(stats), name 

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

1233 # from serialization. 

1234 assert stats.schema_version == ObservationSummaryStats.SCHEMA_VERSION 

1235 dumped = stats.model_dump() 

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

1237 assert name in dumped, name