Coverage for tests/test_visit_image.py: 65%

612 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-30 08:38 +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 typing import Any, Literal 

19 

20import astropy.io.fits 

21import astropy.units as u 

22import astropy.wcs 

23import numpy as np 

24import pytest 

25from astro_metadata_translator import ObservationInfo 

26 

27from lsst.images import ( 

28 Background, 

29 BackgroundMap, 

30 Box, 

31 DetectorFrame, 

32 DifferenceImage, 

33 Image, 

34 MaskPlane, 

35 MaskSchema, 

36 ObservationSummaryStats, 

37 Polygon, 

38 SkyProjectionAstropyView, 

39 TractFrame, 

40 VisitImage, 

41 get_legacy_difference_image_mask_planes, 

42 get_legacy_visit_image_mask_planes, 

43) 

44from lsst.images.aperture_corrections import ApertureCorrectionMap, aperture_corrections_to_legacy 

45from lsst.images.cameras import Detector 

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

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

48from lsst.images.fits import ExtensionKey, FitsOpaqueMetadata 

49from lsst.images.psfs import GaussianPointSpreadFunction, PointSpreadFunction 

50from lsst.images.serialization import ArchiveReadError, read_archive 

51from lsst.images.tests import ( 

52 DP2_VISIT_DETECTOR_DATA_ID, 

53 RoundtripFits, 

54 RoundtripJson, 

55 RoundtripNdf, 

56 TemporaryButler, 

57 assert_close, 

58 assert_masked_images_equal, 

59 assert_sky_projections_equal, 

60 assert_visit_images_equal, 

61 compare_aperture_corrections_to_legacy, 

62 compare_detector_to_legacy, 

63 compare_photo_calib_to_legacy, 

64 compare_visit_image_to_legacy, 

65 make_random_sky_projection, 

66) 

67 

68try: 

69 import h5py # noqa: F401 

70 

71 HAVE_H5PY = True 

72except ImportError: 

73 HAVE_H5PY = False 

74 

75try: 

76 from lsst.afw.image import Exposure as LegacyExposure 

77 from lsst.afw.image import VisitInfo as LegacyVisitInfo 

78except ImportError: 

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

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

81 

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

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

84 

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

86 

87 

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

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

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

91 rng = np.random.default_rng(500) 

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

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

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

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

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

97 aperture_corrections: ApertureCorrectionMap = { 

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

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

100 } 

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

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

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

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

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

106 # polygon is the lower triangle of the image. 

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

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

109 return { 

110 "mask_schema": mask_schema, 

111 "obs_info": obs_info, 

112 "summary_stats": summary_stats, 

113 "gaussian_psf": gaussian_psf, 

114 "aperture_corrections": aperture_corrections, 

115 "detector": detector, 

116 "image": image, 

117 "variance": variance, 

118 "polygon": polygon, 

119 "sky_projection": sky_projection, 

120 } 

121 

122 

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

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

125 det_frame = components["sky_projection"].pixel_frame 

126 opaque = FitsOpaqueMetadata() 

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

128 with warnings.catch_warnings(): 

129 # Silence warnings about long keys becoming HIERARCH. 

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

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

132 opaque.extract_legacy_primary_header(hdr) 

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

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

135 vi = VisitImage( 

136 components["image"], 

137 variance=components["variance"], 

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

139 mask_schema=components["mask_schema"], 

140 sky_projection=components["sky_projection"], 

141 obs_info=components["obs_info"], 

142 summary_stats=components["summary_stats"], 

143 detector=components["detector"], 

144 bounds=components["polygon"], 

145 aperture_corrections=components["aperture_corrections"], 

146 band="r", 

147 ) 

148 vi.backgrounds.add( 

149 "standard", 

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

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

152 is_subtracted=True, 

153 ) 

154 vi._opaque_metadata = opaque 

155 return vi 

156 

157 

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

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

160 return VisitImage( 

161 components["image"], 

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

163 mask_schema=components["mask_schema"], 

164 sky_projection=components["sky_projection"], 

165 detector=components["detector"], 

166 obs_info=components["obs_info"], 

167 band="r", 

168 ) 

169 

170 

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

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

173 rng = np.random.default_rng(42) 

174 bbox = visit_image.image.sky_projection.pixel_frame.bbox 

175 bin_y = bbox.y.linspace(6) 

176 bin_x = bbox.x.linspace(7) 

177 spline_a = SplineField( 

178 bbox, 

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

180 y=bin_y, 

181 x=bin_x, 

182 ) 

183 spline_b = SplineField( 

184 bbox, 

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

186 y=bin_y, 

187 x=bin_x, 

188 ) 

189 sum_field = SumField([spline_a, spline_b]) 

190 bg_map = BackgroundMap() 

191 bg_map.add( 

192 "stacked", 

193 sum_field, 

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

195 is_subtracted=True, 

196 ) 

197 return VisitImage( 

198 components["image"], 

199 variance=components["variance"], 

200 psf=components["gaussian_psf"], 

201 mask_schema=components["mask_schema"], 

202 sky_projection=components["sky_projection"], 

203 obs_info=components["obs_info"], 

204 summary_stats=components["summary_stats"], 

205 detector=components["detector"], 

206 band="r", 

207 backgrounds=bg_map, 

208 ) 

209 

210 

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

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

213 subtracted = result.backgrounds.subtracted 

214 assert subtracted is not None 

215 assert isinstance(subtracted.field, SumField) 

216 original_subtracted = original.backgrounds.subtracted 

217 assert original_subtracted is not None 

218 original_field = original_subtracted.field 

219 assert isinstance(original_field, SumField) 

220 round_field = subtracted.field 

221 assert isinstance(round_field, SumField) 

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

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

224 assert round_op == orig_op 

225 

226 

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

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

229 visit = make_simplest_visit_image(visit_image_components) 

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

231 assert repr(visit) == ( 

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

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

234 " dtype=dtype('uint8')))" 

235 ) 

236 

237 

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

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

240 c = visit_image_components 

241 # Test default fill of variance. 

242 visit = make_simplest_visit_image(c) 

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

244 assert visit[...] is not visit 

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

246 assert repr(visit) == ( 

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

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

249 " dtype=dtype('uint8')))" 

250 ) 

251 

252 astropy_wcs = visit.astropy_wcs 

253 assert isinstance(astropy_wcs, SkyProjectionAstropyView) 

254 approx_wcs = visit.fits_wcs 

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

256 

257 with pytest.raises(TypeError): 

258 # Requires a PSF. 

259 VisitImage( 

260 c["image"], 

261 mask_schema=c["mask_schema"], 

262 sky_projection=c["sky_projection"], 

263 obs_info=c["obs_info"], 

264 detector=c["detector"], 

265 band="r", 

266 ) 

267 

268 with pytest.raises(TypeError): 

269 # Requires ObservationInfo. 

270 VisitImage( 

271 c["image"], 

272 psf=c["gaussian_psf"], 

273 mask_schema=c["mask_schema"], 

274 sky_projection=c["sky_projection"], 

275 detector=c["detector"], 

276 band="r", 

277 ) 

278 

279 with pytest.raises(TypeError): 

280 # Requires a sky_projection. 

281 VisitImage( 

282 c["image"], 

283 psf=c["gaussian_psf"], 

284 mask_schema=c["mask_schema"], 

285 obs_info=c["obs_info"], 

286 detector=c["detector"], 

287 band="r", 

288 ) 

289 

290 with pytest.raises(TypeError): 

291 # Requires a detector. 

292 VisitImage( 

293 c["image"], 

294 psf=c["gaussian_psf"], 

295 mask_schema=c["mask_schema"], 

296 sky_projection=c["sky_projection"], 

297 obs_info=c["obs_info"], 

298 band="r", 

299 ) 

300 

301 with pytest.raises(TypeError): 

302 # Requires some form of mask. 

303 VisitImage( 

304 c["image"], 

305 psf=c["gaussian_psf"], 

306 sky_projection=c["sky_projection"], 

307 obs_info=c["obs_info"], 

308 detector=c["detector"], 

309 band="r", 

310 ) 

311 

312 with pytest.raises(TypeError): 

313 VisitImage( 

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

315 psf=c["gaussian_psf"], 

316 mask_schema=c["mask_schema"], 

317 sky_projection=c["sky_projection"], 

318 obs_info=c["obs_info"], 

319 detector=c["detector"], 

320 band="r", 

321 ) 

322 

323 # Requires a DetectorFrame. 

324 rng = np.random.default_rng(501) 

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

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

327 with pytest.raises(TypeError): 

328 VisitImage( 

329 c["image"], 

330 sky_projection=tract_proj, 

331 psf=c["gaussian_psf"], 

332 mask_schema=c["mask_schema"], 

333 obs_info=c["obs_info"], 

334 detector=c["detector"], 

335 band="r", 

336 ) 

337 

338 # Variance unit mismatch. 

339 with pytest.raises(ValueError): 

340 VisitImage( 

341 c["image"], 

342 variance=c["image"], 

343 psf=c["gaussian_psf"], 

344 mask_schema=c["mask_schema"], 

345 sky_projection=c["sky_projection"], 

346 obs_info=c["obs_info"], 

347 detector=c["detector"], 

348 band="r", 

349 ) 

350 

351 

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

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

354 them. 

355 """ 

356 c = visit_image_components 

357 visit_image = make_visit_image(c) 

358 copy = visit_image.copy() 

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

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

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

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

363 # Check summary stats. 

364 assert copy.summary_stats == visit_image.summary_stats 

365 assert copy.summary_stats is not visit_image.summary_stats 

366 assert subvisit.summary_stats == visit_image.summary_stats 

367 assert subvisit.summary_stats is visit_image.summary_stats 

368 # Check aperture corrections. 

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

370 assert copy.aperture_corrections is not visit_image.aperture_corrections 

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

372 assert subvisit.aperture_corrections is visit_image.aperture_corrections 

373 # Check backgrounds. 

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

375 assert copy.backgrounds is not visit_image.backgrounds 

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

377 assert subvisit.backgrounds is visit_image.backgrounds 

378 # Check bounds. 

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

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

381 

382 

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

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

385 instrument. 

386 """ 

387 visit_image = make_visit_image(visit_image_components) 

388 assert visit_image.obs_info is not None 

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

390 

391 

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

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

394 summary_stats = visit_image_components["summary_stats"] 

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

396 assert summary_stats != ObservationSummaryStats(psfSigma=2.5) 

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

398 

399 

400def test_summary_stats_to_legacy() -> None: 

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

402 ExposureSummaryStats even when this package defines fields that the 

403 installed afw does not. 

404 """ 

405 try: 

406 from lsst.afw.image import ExposureSummaryStats 

407 except ImportError: 

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

409 

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

411 legacy = summary_stats.to_legacy() 

412 assert isinstance(legacy, ExposureSummaryStats) 

413 assert legacy.psfSigma == 2.5 

414 assert legacy.zeroPoint == 31.4 

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

416 # round trip reproduces the original. 

417 assert ObservationSummaryStats.from_legacy(legacy) == summary_stats 

418 

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

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

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

422 extra_float_fields = [ 

423 name 

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

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

426 ] 

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

428 with pytest.raises(ValueError): 

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

430 

431 

432def test_summary_stats_from_legacy_unknown_field() -> None: 

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

434 

435 @dataclasses.dataclass 

436 class FakeLegacy: 

437 psfSigma: float = 2.5 

438 notARealField: float = math.nan 

439 

440 # An unknown field that is empty is dropped. 

441 stats = ObservationSummaryStats.from_legacy(FakeLegacy()) 

442 assert stats.psfSigma == 2.5 

443 

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

445 with pytest.raises(ValueError): 

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

447 

448 

449@skip_no_h5py 

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

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

452 visit_image = make_visit_image(visit_image_components) 

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

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

455 

456 

457@skip_no_h5py 

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

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

460 visit_image = make_visit_image(visit_image_components) 

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

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

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

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

465 

466 

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

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

469 visit_image = make_visit_image(visit_image_components) 

470 with ( 

471 RoundtripFits(visit_image) as fits_rt, 

472 RoundtripJson(visit_image) as json_rt, 

473 ): 

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

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

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

477 

478 

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

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

481 

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

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

484 in `test_read_write_components`. 

485 """ 

486 visit_image = make_visit_image(visit_image_components) 

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

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

489 # wrote WCSs. 

490 fits = roundtrip.inspect() 

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

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

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

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

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

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

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

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

499 subimage = roundtrip.get(bbox=subbox) 

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

501 

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

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

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

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

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

507 ) 

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

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

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

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

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

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

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

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

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

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

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

519 

520 

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

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

523 

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

525 Butler-free assertions live in `test_read_write`. 

526 """ 

527 c = visit_image_components 

528 visit_image = make_visit_image(c) 

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

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

531 subimage = roundtrip.get(bbox=subbox) 

532 

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

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

535 assert_masked_images_equal(subimage_masked, subimage, expect_view=False) 

536 

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

538 # it is the same thing. 

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

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

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

542 

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

544 

545 obs_info = roundtrip.get("obs_info") 

546 assert isinstance(obs_info, ObservationInfo) 

547 assert obs_info == visit_image.obs_info 

548 

549 summary_stats = roundtrip.get("summary_stats") 

550 assert isinstance(summary_stats, ObservationSummaryStats) 

551 assert summary_stats == visit_image.summary_stats 

552 

553 psf = roundtrip.get("psf") 

554 assert isinstance(psf, GaussianPointSpreadFunction) 

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

556 

557 backgrounds = roundtrip.get("backgrounds") 

558 assert isinstance(backgrounds, BackgroundMap) 

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

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

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

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

563 

564 # Test some components get edge cases. 

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

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

567 

568 components = roundtrip.get("components") 

569 assert set(components) == { 

570 "image", 

571 "variance", 

572 "psf", 

573 "bbox", 

574 "mask", 

575 "obs_info", 

576 "backgrounds", 

577 "detector", 

578 "aperture_corrections", 

579 "sky_projection", 

580 "summary_stats", 

581 "photometric_scaling", 

582 } 

583 

584 # Butler morphs RuntimeError to ValueError. 

585 with pytest.raises(ValueError): 

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

587 

588 with pytest.raises(ValueError): 

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

590 

591 with pytest.raises(ValueError): 

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

593 

594 with pytest.raises(ValueError): 

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

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

597 

598 

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

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

601 EXTVERs. 

602 """ 

603 visit_image = make_visit_image(visit_image_components) 

604 visit = _make_sum_background_visit_image(visit_image_components, visit_image) 

605 with RoundtripFits(visit) as roundtrip: 

606 _check_sum_background_round_trip(roundtrip.result, visit) 

607 

608 

609@skip_no_h5py 

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

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

612 the FITS backend does. 

613 """ 

614 visit_image = make_visit_image(visit_image_components) 

615 visit = _make_sum_background_visit_image(visit_image_components, visit_image) 

616 with RoundtripNdf(visit) as roundtrip: 

617 _check_sum_background_round_trip(roundtrip.result, visit) 

618 

619 

620@pytest.mark.parametrize( 

621 "scaling_unit,operation", 

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

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

624) 

625def test_convert_unit_subimage( 

626 visit_image_components: dict[str, Any], 

627 scaling_unit: u.UnitBase, 

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

629) -> None: 

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

631 of the photometric scaling that overlaps the subimage. 

632 

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

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

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

636 """ 

637 visit_image = make_visit_image(visit_image_components) 

638 scaling = ChebyshevField( 

639 visit_image.bbox, 

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

641 unit=scaling_unit, 

642 ) 

643 visit_image.photometric_scaling = scaling 

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

645 # over the wrong box cannot match by chance. 

646 subbox = Box.factory[ 

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

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

649 ] 

650 subimage = visit_image[subbox] 

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

652 

653 converted = subimage.convert_unit(u.electron) 

654 

655 assert converted.unit == u.electron 

656 assert converted.image.bbox == subbox 

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

658 if operation == "divide": 

659 scaling_array = 1.0 / scaling_array 

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

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

662 

663 

664@dataclasses.dataclass 

665class _LegacyTestData: 

666 filename: str 

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

668 unit: u.Unit = u.nJy 

669 storage_class: str = "VisitImage" 

670 read_cls: type[VisitImage] = VisitImage 

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

672 

673 @classmethod 

674 def get( 

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

676 ) -> _LegacyTestData: 

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

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

679 result = cls( 

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

681 ) 

682 match which: 

683 case "preliminary_visit_image": 

684 result.unit = u.electron 

685 case "difference_image": 

686 result.storage_class = "DifferenceImage" 

687 result.read_cls = DifferenceImage 

688 result.plane_map = get_legacy_difference_image_mask_planes() 

689 case "visit_image": 

690 pass 

691 try: 

692 from lsst.afw.image import ExposureFitsReader 

693 

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

695 except ImportError: 

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

697 result.visit_image = result.read_cls.read_legacy( 

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

699 ) 

700 return result 

701 

702 

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

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

705 """Return legacy test data. 

706 

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

708 test images. 

709 """ 

710 return _LegacyTestData.get(request.param) 

711 

712 

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

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

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

716 

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

718 preliminary_visit_image, since that has 'electron' pixel units 

719 """ 

720 return _LegacyTestData.get(request.param) 

721 

722 

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

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

725 assert isinstance(obs_info, ObservationInfo) 

726 assert obs_info.instrument == "LSSTCam" 

727 assert obs_info.detector_num == 85, obs_info 

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

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

730 

731 

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

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

734 conflicting arguments. 

735 """ 

736 with pytest.raises(ValueError): 

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

738 with pytest.raises(ValueError): 

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

740 with pytest.raises(ValueError): 

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

742 visit = VisitImage.from_legacy( 

743 legacy_test_data.legacy_exposure, 

744 instrument="LSSTCam", 

745 unit=legacy_test_data.unit, 

746 visit=2025052000177, 

747 ) 

748 assert visit.unit == legacy_test_data.unit 

749 

750 with pytest.raises(ValueError): 

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

752 with pytest.raises(ValueError): 

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

754 

755 

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

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

758 file. 

759 """ 

760 visit = VisitImage.read_legacy(legacy_test_data.filename) 

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

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

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

764 assert image == visit.image 

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

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

767 assert variance == visit.variance 

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

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

770 assert mask == visit.mask 

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

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

773 assert isinstance(psf, PointSpreadFunction) 

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

775 _check_legacy_obs_info(obs_info) 

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

777 assert isinstance(summary_stats, ObservationSummaryStats) 

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

779 compare_aperture_corrections_to_legacy( 

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

781 legacy_test_data.legacy_exposure.info.getApCorrMap(), 

782 visit.bbox, 

783 ) 

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

785 compare_detector_to_legacy( 

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

787 ) 

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

789 compare_photo_calib_to_legacy( 

790 photometric_scaling, 

791 legacy_test_data.legacy_exposure.getPhotoCalib(), 

792 subimage_bbox=visit.bbox, 

793 ) 

794 

795 

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

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

798 exposure. 

799 """ 

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

801 assert legacy.obs_info is not None 

802 assert legacy.obs_info == legacy_test_data.visit_image.obs_info 

803 assert legacy.obs_info is not None # for mypy 

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

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

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

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

808 

809 

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

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

812 ApCorrMap. 

813 """ 

814 ap_corrections = legacy_test_data.visit_image.aperture_corrections 

815 legacy_ap_corr_map = aperture_corrections_to_legacy(ap_corrections) 

816 compare_aperture_corrections_to_legacy( 

817 ap_corrections, 

818 legacy_ap_corr_map, 

819 legacy_test_data.visit_image.bbox, 

820 ) 

821 

822 

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

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

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

826 assert "EXPTIME" in header 

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

828 assert "LSST BUTLER ID" not in header 

829 assert "AR HDU" not in header 

830 assert "A_ORDER" not in header 

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

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

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

834 

835 

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

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

838 read_legacy. 

839 """ 

840 assert legacy_test_data.visit_image.unit == legacy_test_data.unit 

841 _check_legacy_headers(legacy_test_data.visit_image) 

842 

843 

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

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

846 correctly. 

847 """ 

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

849 assert legacy.unit == legacy_test_data.unit 

850 _check_legacy_headers(legacy) 

851 

852 

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

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

855 pixel values and all components. 

856 """ 

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

858 fits = roundtrip.inspect() 

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

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

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

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

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

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

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

866 subimage = roundtrip.get(bbox=subbox) 

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

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

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

870 alternates = { 

871 k: roundtrip.get(k) 

872 for k in [ 

873 "sky_projection", 

874 "image", 

875 "mask", 

876 "variance", 

877 "psf", 

878 "obs_info", 

879 "summary_stats", 

880 "aperture_corrections", 

881 "detector", 

882 "photometric_scaling", 

883 ] 

884 } 

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

886 assert isinstance(legacy_exposure, LegacyExposure) 

887 compare_visit_image_to_legacy( 

888 legacy_test_data.visit_image, 

889 legacy_exposure, 

890 expect_view=False, 

891 plane_map=legacy_test_data.plane_map, 

892 **DP2_VISIT_DETECTOR_DATA_ID, 

893 ) 

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

895 assert legacy_exposure.getPhotoCalib()._isConstant 

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

897 else: 

898 compare_photo_calib_to_legacy( 

899 legacy_test_data.visit_image.photometric_scaling, 

900 legacy_exposure.getPhotoCalib(), 

901 subimage_bbox=subbox, 

902 ) 

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

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

905 assert isinstance(visit_info, LegacyVisitInfo) 

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

907 

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

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

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

911 ) 

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

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

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

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

916 compare_visit_image_to_legacy( 

917 roundtrip.result, 

918 legacy_test_data.legacy_exposure, 

919 expect_view=False, 

920 plane_map=legacy_test_data.plane_map, 

921 **DP2_VISIT_DETECTOR_DATA_ID, 

922 alternates=alternates, 

923 ) 

924 compare_visit_image_to_legacy( 

925 legacy_test_data.read_cls.from_legacy( 

926 legacy_test_data.legacy_exposure, plane_map=legacy_test_data.plane_map 

927 ), 

928 legacy_test_data.legacy_exposure, 

929 expect_view=True, 

930 plane_map=legacy_test_data.plane_map, 

931 **DP2_VISIT_DETECTOR_DATA_ID, 

932 ) 

933 

934 

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

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

937 an Exposure. 

938 """ 

939 try: 

940 from lsst.daf.butler import FileDataset 

941 except ImportError: 

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

943 

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

945 helper.butler.ingest( 

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

947 ) 

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

949 with warnings.catch_warnings(): 

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

951 visit_image = helper.butler.get(visit_image_ref) 

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

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

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

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

956 assert bbox == visit_image.bbox 

957 alternates = { 

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

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

960 } 

961 compare_visit_image_to_legacy( 

962 visit_image, 

963 legacy_test_data.legacy_exposure, 

964 expect_view=False, 

965 plane_map=legacy_test_data.plane_map, 

966 alternates=alternates, 

967 **DP2_VISIT_DETECTOR_DATA_ID, 

968 ) 

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

970 visit_image.metadata["MixedCaseKey"] = 52 

971 helper.butler.put(visit_image, visit_image_ref) 

972 with warnings.catch_warnings(): 

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

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

975 compare_visit_image_to_legacy( 

976 visit_image, 

977 legacy_exposure, 

978 expect_view=False, 

979 plane_map=legacy_test_data.plane_map, 

980 alternates=alternates, 

981 **DP2_VISIT_DETECTOR_DATA_ID, 

982 ) 

983 visit_image_2 = helper.butler.get(visit_image_ref) 

984 compare_visit_image_to_legacy( 

985 visit_image_2, 

986 legacy_exposure, 

987 expect_view=False, 

988 plane_map=legacy_test_data.plane_map, 

989 alternates=alternates, 

990 **DP2_VISIT_DETECTOR_DATA_ID, 

991 ) 

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

993 

994 

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

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

997 photometric_scaling. 

998 """ 

999 from lsst.afw.table import ExposureCatalog 

1000 

1001 legacy_test_data = legacy_test_data_calibrated 

1002 original = legacy_test_data.visit_image.copy() 

1003 with pytest.raises(u.UnitConversionError): 

1004 original.convert_unit(u.electron) 

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

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

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

1008 with pytest.raises(u.UnitConversionError): 

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

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

1011 assert visit_image_mJy.unit == u.mJy 

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

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

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

1015 legacy_exposure_mJy = visit_image_mJy.to_legacy() 

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

1017 legacy_masked_image_nJy = legacy_exposure_mJy.getPhotoCalib().calibrateImage( 

1018 legacy_exposure_mJy.maskedImage 

1019 ) 

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

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

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

1023 assert visit_image_mJy.sky_projection is original.sky_projection 

1024 assert visit_image_mJy.obs_info is original.obs_info 

1025 assert visit_image_mJy.summary_stats is original.summary_stats 

1026 assert visit_image_mJy.psf is original.psf 

1027 assert visit_image_mJy.detector is original.detector 

1028 assert visit_image_mJy.bounds is original.bounds 

1029 assert visit_image_mJy.aperture_corrections is original.aperture_corrections 

1030 assert visit_image_mJy.photometric_scaling is original.photometric_scaling 

1031 visit_summary = ExposureCatalog.readFits( 

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

1033 ) 

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

1035 visit_image_nJy.photometric_scaling = field_from_legacy_photo_calib( 

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

1037 ) 

1038 compare_photo_calib_to_legacy( 

1039 visit_image_nJy.photometric_scaling, 

1040 legacy_test_data.legacy_exposure.getPhotoCalib(), 

1041 applied_legacy_photo_calib=legacy_photo_calib, 

1042 subimage_bbox=visit_image_nJy.bbox, 

1043 ) 

1044 with pytest.raises(u.UnitConversionError): 

1045 visit_image_nJy.convert_unit(u.mm) 

1046 with pytest.raises(u.UnitConversionError): 

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

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

1049 visit_image_e = visit_image_nJy.convert_unit(u.electron) 

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

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

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

1053 visit_image_mJy.photometric_scaling = visit_image_nJy.photometric_scaling 

1054 visit_image_e = visit_image_mJy.convert_unit(u.electron) 

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

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

1057 visit_image_nJy_2 = visit_image_e.convert_unit(u.nJy) 

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

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

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

1061 visit_image_nJy_3 = visit_image_e.convert_unit(u.nJy) 

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

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

1064 legacy_exposure_e = visit_image_e.to_legacy() 

1065 assert_close( 

1066 legacy_exposure_e.getPhotoCalib().getCalibrationMean(), 

1067 legacy_photo_calib.getCalibrationMean(), 

1068 ) 

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

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

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

1072 

1073 

1074def test_background_map_describe() -> None: 

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

1076 bg_map = BackgroundMap() 

1077 assert isinstance(bg_map, DescribableMixin) 

1078 report = bg_map._describe() 

1079 assert isinstance(report, Report) 

1080 assert report.type_name == "BackgroundMap" 

1081 assert report.inline 

1082 assert report.summary == "no backgrounds" 

1083 assert report.children == {} 

1084 

1085 

1086def test_background_map_with_entries_describe() -> None: 

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

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

1089 bg_map = BackgroundMap( 

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

1091 subtracted="sky", 

1092 ) 

1093 report = bg_map._describe() 

1094 assert report.type_name == "BackgroundMap" 

1095 assert report.inline 

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

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

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

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

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

1101 sky = report.children["sky"] 

1102 assert sky.type_name == "ChebyshevField" 

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

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

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

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

1107 assert "bounds" in sky_fields 

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

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

1110 assert "subtracted" not in fringe_fields 

1111 assert "description" not in fringe_fields 

1112 

1113 

1114def test_background_map_describe_brief_skips_children() -> None: 

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

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

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

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

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

1120 assert report.children == {} 

1121 

1122 

1123def test_visit_image_repr_str_with_unreadable_psf() -> None: 

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

1125 

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

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

1128 would raise when it accesses the PSF). 

1129 """ 

1130 path = os.path.join(LOCAL_DATA_DIR, "schema_v1", "visit_image.json") 

1131 visit_image = read_archive(path) 

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

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

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

1135 

1136 

1137def test_observation_summary_stats_describe() -> None: 

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

1139 

1140 Unset ones are omitted rather than shown as NaN. 

1141 """ 

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

1143 assert isinstance(stats, DescribableMixin) 

1144 report = stats._describe() 

1145 assert isinstance(report, Report) 

1146 assert report.type_name == "ObservationSummaryStats" 

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

1148 (group,) = report.value_groups 

1149 assert group.role is FieldRole.DERIVED 

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

1151 values = dict(group.values) 

1152 assert values["psfSigma"] == 2.5 

1153 assert values["zeroPoint"] == 31.4 

1154 assert values["ra"] == 180.0 

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

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

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

1158 assert "expTime" not in values 

1159 assert "skyBg" not in values 

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

1161 

1162 

1163def test_observation_summary_stats_describe_omits_empty_sequences() -> None: 

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

1165 empty = ObservationSummaryStats(psfSigma=2.5) 

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

1167 # empty sequence does. 

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

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

1170 

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

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

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

1174 assert corners.role is FieldRole.DERIVED 

1175 

1176 

1177def test_observation_summary_stats_describe_brief_counts() -> None: 

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

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

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

1181 assert brief.type_name == "ObservationSummaryStats" 

1182 assert brief.value_groups == [] 

1183 (field,) = brief.fields 

1184 assert field.label == "statistics set" 

1185 assert field.role is FieldRole.DERIVED 

1186 

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

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

1189 full = stats._describe() 

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

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

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

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

1194 

1195 

1196def test_observation_summary_stats_pydantic_repr() -> None: 

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

1198 stats = ObservationSummaryStats(psfSigma=2.5) 

1199 r = repr(stats) 

1200 assert r.startswith("ObservationSummaryStats(") 

1201 assert "psfSigma=2.5" in r 

1202 

1203 

1204def test_observation_summary_stats_str_is_the_report_summary() -> None: 

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

1206 

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

1208 round-trips. 

1209 """ 

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

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

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

1213 # zero rather than to NaN. 

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

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

1216 # The unset statistics reach repr but not str. 

1217 assert "nan" not in str(stats) 

1218 assert "nan" in repr(stats) 

1219 

1220 

1221def test_archive_tree_repr_omits_schema_bookkeeping() -> None: 

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

1223 

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

1225 """ 

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

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

1228 assert name not in repr(stats), name 

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

1230 # from serialization. 

1231 assert stats.schema_version == ObservationSummaryStats.SCHEMA_VERSION 

1232 dumped = stats.model_dump() 

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

1234 assert name in dumped, name