Coverage for python/lsst/images/_visit_image.py: 59%

428 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-15 09:44 +0000

1# This file is part of lsst-images. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

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

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

7# for details of code ownership. 

8# 

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

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

11 

12from __future__ import annotations 

13 

14__all__ = ("VisitImage", "VisitImageSerializationModel") 

15 

16import functools 

17import logging 

18from collections.abc import Mapping 

19from types import EllipsisType 

20from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast 

21 

22import astropy.io.fits 

23import astropy.units 

24import numpy as np 

25import pydantic 

26from astro_metadata_translator import ObservationInfo 

27 

28from ._backgrounds import BackgroundMap, BackgroundMapSerializationModel 

29from ._concrete_bounds import BoundsSerializationModel 

30from ._geom import Bounds, Box 

31from ._image import Image, ImageSerializationModel 

32from ._mask import Mask, MaskPlane, MaskSchema, MaskSerializationModel, get_legacy_visit_image_mask_planes 

33from ._masked_image import MaskedImage, MaskedImageSerializationModel 

34from ._obs_info_from_legacy import obs_info_from_legacy 

35from ._observation_summary_stats import ObservationSummaryStats 

36from ._polygon import Polygon 

37from ._transforms import ( 

38 DetectorFrame, 

39 SkyProjection, 

40 SkyProjectionAstropyView, 

41 SkyProjectionSerializationModel, 

42) 

43from .aperture_corrections import ( 

44 ApertureCorrectionMap, 

45 ApertureCorrectionMapSerializationModel, 

46 aperture_corrections_from_legacy, 

47 aperture_corrections_to_legacy, 

48) 

49from .cameras import Detector, DetectorSerializationModel 

50from .describe import DescribeOptions, FieldRole, Report, ReportField 

51from .fields import BaseField, Field, FieldSerializationModel, field_from_legacy_photo_calib 

52from .fits import FitsOpaqueMetadata, parse_legacy_bunit 

53from .psfs import ( 

54 GaussianPointSpreadFunction, 

55 GaussianPSFSerializationModel, 

56 LegacyPointSpreadFunction, 

57 PiffSerializationModel, 

58 PiffWrapper, 

59 PointSpreadFunction, 

60 PSFExSerializationModel, 

61 PSFExWrapper, 

62) 

63from .serialization import ArchiveReadError, InputArchive, InvalidParameterError, MetadataValue, OutputArchive 

64from .utils import is_none 

65 

66if TYPE_CHECKING: 

67 try: 

68 from lsst.afw.cameraGeom import Detector as LegacyDetector 

69 from lsst.afw.image import Exposure as LegacyExposure 

70 from lsst.afw.image import FilterLabel as LegacyFilterLabel 

71 from lsst.afw.image import VisitInfo as LegacyVisitInfo 

72 from lsst.daf.butler import DimensionRecord 

73 except ImportError: 

74 type LegacyDetector = Any # type: ignore[no-redef] 

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

76 type LegacyFilterLabel = Any # type: ignore[no-redef] 

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

78 type DimensionRecord = Any # type: ignore[no-redef] 

79 

80_LOG = logging.getLogger("lsst.images") 

81 

82 

83class VisitImage(MaskedImage): 

84 """A calibrated single-visit image. 

85 

86 Parameters 

87 ---------- 

88 image 

89 The main image plane. If this has a `SkyProjection`, it will be used 

90 for all planes unless a ``sky_projection`` is passed separately. 

91 mask 

92 A bitmask image that annotates the main image plane. Must have the 

93 same bounding box as ``image`` if provided. Any attached 

94 ``sky_projection`` is replaced (possibly by `None`). 

95 variance 

96 The per-pixel uncertainty of the main image as an image of variance 

97 values. Must have the same bounding box as ``image`` if provided, and 

98 its units must be the square of ``image.unit`` or `None`. 

99 Values default to ``1.0``. Any attached ``sky_projection`` is replaced 

100 (possibly by `None`). 

101 mask_schema 

102 Schema for the mask plane. Must be provided if and only if ``mask`` is 

103 not provided. 

104 sky_projection 

105 Projection that maps the pixel grid to the sky. Can only be `None` if 

106 a ``sky_projection`` is already attached to ``image``. 

107 bounds 

108 The region where this image's pixels and other properties are valid. 

109 If not provided, the bounding box of the image is used. Other 

110 components (``psf``, ``sky_projection``, ``aperture_corrections``, 

111 etc.) are assumed to have their own bounds which may or may not be the 

112 same as the image bounds. If ``bounds`` extends beyond the image 

113 bounding box, the intersection between ``bounds`` and the image 

114 bounding box is used instead. 

115 obs_info 

116 General information about this visit in standardized form. 

117 summary_stats 

118 Summary statistics associated with this visit. Initialized to default 

119 values if not provided. 

120 photometric_scaling 

121 Field that can be used to multiply a post-ISR image units to yield 

122 calibrated image units. This may be a scaling that was already 

123 applied (so dividing by it will recover the post-ISR units) or a 

124 scaling that has not been applied, depending on ``image.unit``. 

125 psf 

126 Point-spread function model for this image, or an exception explaining 

127 why it could not be read (to be raised if the PSF is requested later). 

128 detector 

129 Geometry and electronic information for the detector attached to this 

130 image. 

131 aperture_corrections : `dict` [`str`, `~fields.BaseField`] 

132 Mapping from photometry algorithm name to the aperture correction for 

133 that algorithm. 

134 backgrounds 

135 Background models associated with this image. 

136 band 

137 Name of the passband the image was observed with (this is a shorter, 

138 less specific version of ``obs_info.physical_filter``). 

139 metadata 

140 Arbitrary flexible metadata to associate with the image. 

141 """ 

142 

143 def __init__( 

144 self, 

145 image: Image, 

146 *, 

147 mask: Mask | None = None, 

148 variance: Image | None = None, 

149 mask_schema: MaskSchema | None = None, 

150 sky_projection: SkyProjection[DetectorFrame] | None = None, 

151 bounds: Bounds | None = None, 

152 obs_info: ObservationInfo | None = None, 

153 summary_stats: ObservationSummaryStats | None = None, 

154 photometric_scaling: Field | None = None, 

155 psf: PointSpreadFunction | ArchiveReadError, 

156 detector: Detector, 

157 aperture_corrections: ApertureCorrectionMap | None = None, 

158 backgrounds: BackgroundMap | None = None, 

159 band: str, 

160 metadata: dict[str, MetadataValue] | None = None, 

161 ) -> None: 

162 super().__init__( 

163 image, 

164 mask=mask, 

165 variance=variance, 

166 mask_schema=mask_schema, 

167 sky_projection=sky_projection, 

168 metadata=metadata, 

169 ) 

170 if self.image.unit is None: 

171 raise TypeError("The image component of a VisitImage must have units.") 

172 if self.image.sky_projection is None: 

173 raise TypeError("The sky_projection component of a VisitImage cannot be None.") 

174 if obs_info is None: 

175 raise TypeError("The observation info component of a VisitImage cannot be None.") 

176 if obs_info.physical_filter is None: 176 ↛ 177line 176 didn't jump to line 177 because the condition on line 176 was never true

177 raise ValueError("The obs_info.physical_filter attribute of a VisitImage cannot be None.") 

178 self._obs_info = obs_info 

179 if not isinstance(self.image.sky_projection.pixel_frame, DetectorFrame): 

180 raise TypeError("The sky_projection's pixel frame must be a DetectorFrame for VisitImage.") 

181 if summary_stats is None: 

182 summary_stats = ObservationSummaryStats() 

183 self._summary_stats = summary_stats 

184 if photometric_scaling is not None and photometric_scaling.unit is None: 184 ↛ 185line 184 didn't jump to line 185 because the condition on line 184 was never true

185 raise TypeError("If a photometric_scaling is provided, it must have units.") 

186 self._photometric_scaling = photometric_scaling 

187 self._psf = psf 

188 self._detector = detector 

189 self._aperture_corrections = aperture_corrections if aperture_corrections is not None else {} 

190 self._bounds = bounds if bounds is not None else self.bbox 

191 if not self.bbox.contains(self._bounds.bbox): 

192 self._bounds = self._bounds.intersection(self.bbox) 

193 self._backgrounds = backgrounds if backgrounds is not None else BackgroundMap() 

194 self._band = band 

195 

196 @property 

197 def unit(self) -> astropy.units.UnitBase: 

198 """The units of the image plane (`astropy.units.Unit`).""" 

199 return cast(astropy.units.UnitBase, super().unit) 

200 

201 @property 

202 def sky_projection(self) -> SkyProjection[DetectorFrame]: 

203 """The projection that maps the pixel grid to the sky 

204 (`SkyProjection` [`DetectorFrame`]). 

205 """ 

206 return cast(SkyProjection[DetectorFrame], super().sky_projection) 

207 

208 @property 

209 def bounds(self) -> Bounds: 

210 """The region where pixels are valid (`Bounds`).""" 

211 return self._bounds 

212 

213 @property 

214 def obs_info(self) -> ObservationInfo: 

215 """General information about this observation in standard form. 

216 (`~astro_metadata_translator.ObservationInfo`). 

217 """ 

218 return self._obs_info 

219 

220 @property 

221 def physical_filter(self) -> str: 

222 """Full name of the physical bandpass filter (`str`).""" 

223 assert self._obs_info.physical_filter is not None, "Guaranteed at construction." 

224 return self._obs_info.physical_filter 

225 

226 @property 

227 def band(self) -> str: 

228 """Short name of the bandpass filter (`str`).""" 

229 return self._band 

230 

231 @property 

232 def astropy_wcs(self) -> SkyProjectionAstropyView: 

233 """An Astropy WCS for the pixel arrays (`SkyProjectionAstropyView`). 

234 

235 Notes 

236 ----- 

237 As expected for Astropy WCS objects, this defines pixel coordinates 

238 such that the first row and column in the arrays are ``(0, 0)``, not 

239 ``bbox.start``, as is the case for `sky_projection`. 

240 

241 This object satisfies the `astropy.wcs.wcsapi.BaseHighLevelWCS` and 

242 `astropy.wcs.wcsapi.BaseLowLevelWCS` interfaces, but it is not an 

243 `astropy.wcs.WCS` (use `fits_wcs` for that). 

244 """ 

245 return cast(SkyProjectionAstropyView, super().astropy_wcs) 

246 

247 @property 

248 def summary_stats(self) -> ObservationSummaryStats: 

249 """Optional summary statistics for this observation 

250 (`ObservationSummaryStats`). 

251 """ 

252 return self._summary_stats 

253 

254 @property 

255 def photometric_scaling(self) -> Field | None: 

256 """Field that multiplies a post-ISR image to yield the calibrated 

257 image (~`fields.BaseField`). 

258 """ 

259 return self._photometric_scaling 

260 

261 @photometric_scaling.setter 

262 def photometric_scaling(self, value: Field) -> None: 

263 if value.unit is None: 263 ↛ 264line 263 didn't jump to line 264 because the condition on line 263 was never true

264 raise TypeError("The photometric_scaling for a VisitImage must have units.") 

265 self._photometric_scaling = value 

266 

267 @property 

268 def psf(self) -> PointSpreadFunction: 

269 """The point-spread function model for this image 

270 (`.psfs.PointSpreadFunction`). 

271 """ 

272 if isinstance(self._psf, ArchiveReadError): 

273 raise self._psf 

274 return self._psf 

275 

276 @psf.setter 

277 def psf(self, value: PointSpreadFunction) -> None: 

278 self._psf = value 

279 

280 @property 

281 def detector(self) -> Detector: 

282 """Geometry and electronic information about the detector 

283 (`.cameras.Detector`). 

284 """ 

285 return self._detector 

286 

287 @property 

288 def aperture_corrections(self) -> ApertureCorrectionMap: 

289 """A mapping from photometry algorithm name to the aperture correction 

290 field for that algorithm (`dict` [`str`, `~.fields.BaseField`]). 

291 """ 

292 return self._aperture_corrections 

293 

294 @property 

295 def backgrounds(self) -> BackgroundMap: 

296 """A mapping of backgrounds associated with this image 

297 (`BackgroundMap`). 

298 """ 

299 return self._backgrounds 

300 

301 def __getitem__(self, bbox: Box | EllipsisType) -> VisitImage: 

302 bbox, _ = self._handle_getitem_args(bbox) 

303 return self._transfer_metadata( 

304 VisitImage( 

305 self.image[bbox], 

306 mask=self.mask[bbox], 

307 variance=self.variance[bbox], 

308 sky_projection=self.sky_projection, 

309 psf=self._psf, 

310 obs_info=self.obs_info, 

311 bounds=self._bounds, # don't need to intersect here, because __init__ will do that. 

312 summary_stats=self.summary_stats, 

313 detector=self._detector, 

314 photometric_scaling=self._photometric_scaling, 

315 aperture_corrections=self.aperture_corrections, 

316 backgrounds=self._backgrounds, 

317 band=self._band, 

318 ), 

319 bbox=bbox, 

320 ) 

321 

322 def _summary_stats_describe_options(self, options: DescribeOptions) -> DescribeOptions: 

323 """Return the options the summary-statistics child report is built 

324 with. 

325 

326 Parameters 

327 ---------- 

328 options : `DescribeOptions` 

329 Options this image's own report is being built with. 

330 

331 Returns 

332 ------- 

333 options : `DescribeOptions` 

334 Options for the child report. 

335 

336 Notes 

337 ----- 

338 `ObservationSummaryStats.raCorners` and 

339 `~ObservationSummaryStats.decCorners` hold the sky corners of the 

340 image the statistics were measured on, which the sky projection 

341 tabulates more readably. Taking a cutout does not recompute the 

342 statistics, so those corners still describe the original image and 

343 are worth keeping wherever they no longer match the pixels on hand. 

344 """ 

345 if self.bbox == self.detector.bbox: 

346 return options.for_child("corners") 

347 return options.for_child() 

348 

349 def _describe_psf(self, options: DescribeOptions) -> Report: 

350 """Return the report for this image's PSF, or for the error that 

351 stopped it being read. 

352 

353 Parameters 

354 ---------- 

355 options : `DescribeOptions` 

356 Options the child report is built with. 

357 

358 Returns 

359 ------- 

360 report : `Report` 

361 Report describing the PSF, or naming it unreadable. 

362 

363 Notes 

364 ----- 

365 A PSF that could not be read is a supported state of a visit image, 

366 not a failure of it, and it is often the only unreadable part: a PSF 

367 model can depend on a package the reader does not have installed. 

368 Describing it therefore has to say what is missing and why, rather 

369 than raise and take the report of everything else with it. 

370 """ 

371 if isinstance(self._psf, ArchiveReadError): 

372 return Report( 

373 type_name="PointSpreadFunction", 

374 summary=f"unreadable ({self._psf})", 

375 inline=True, 

376 ) 

377 return self._psf._describe(options) 

378 

379 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report: 

380 """Return a `Report` describing this visit image. 

381 

382 Parameters 

383 ---------- 

384 options : `DescribeOptions`, optional 

385 Rendering options; forwarded to all children. Child construction 

386 can be expensive, so `DescribeOptions.brief` skips it. 

387 """ 

388 # The image and mask schema are rendered as children below, and the 

389 # image renders as ``Image(bbox, dtype)``, which would restate the 

390 # shared bbox. Both are REPR_ONLY so only repr sees them. 

391 fields = [ 

392 ReportField( 

393 label="image", 

394 value=self.image, 

395 repr_value=repr(self.image), 

396 positional=True, 

397 role=FieldRole.REPR_ONLY, 

398 ), 

399 ReportField( 

400 label="mask_schema", 

401 value=self.mask.schema, 

402 repr_value=repr(self.mask.schema), 

403 role=FieldRole.REPR_ONLY, 

404 ), 

405 ReportField(label="band", value=self.band, role=FieldRole.DERIVED), 

406 ReportField(label="physical_filter", value=self.physical_filter, role=FieldRole.DERIVED), 

407 ReportField(label="bbox", value=self.bbox, repr_value=repr(self.bbox), role=FieldRole.DERIVED), 

408 ] 

409 summary = f"VisitImage({self.image!s}, {list(self.mask.schema.names)})" 

410 if options.brief: 

411 return Report(type_name="VisitImage", summary=summary, fields=fields) 

412 child = options.for_child() 

413 plane = options.for_child("sky_projection", "bbox") 

414 children: dict[str, Report] = { 

415 "image": self.image._describe(plane), 

416 "mask": self.mask._describe(plane), 

417 "variance": self.variance._describe(plane), 

418 "sky_projection": self.sky_projection._describe(child, bbox=self.bbox), 

419 "psf": self._describe_psf(child), 

420 "detector": self.detector._describe(child), 

421 "summary_stats": self.summary_stats._describe(self._summary_stats_describe_options(options)), 

422 "backgrounds": self.backgrounds._describe(child), 

423 } 

424 if self.photometric_scaling is not None: 

425 children["photometric_scaling"] = self.photometric_scaling._describe(child) 

426 return Report( 

427 type_name="VisitImage", 

428 summary=summary, 

429 fields=fields, 

430 children=children, 

431 ) 

432 

433 def copy(self, *, copy_detector: bool = False) -> VisitImage: 

434 """Deep-copy the visit image. 

435 

436 Parameters 

437 ---------- 

438 copy_detector 

439 Whether to deep-copy the `detector` attribute. 

440 """ 

441 return self._transfer_metadata( 

442 VisitImage( 

443 image=self._image.copy(), 

444 mask=self._mask.copy(), 

445 variance=self._variance.copy(), 

446 psf=self._psf, 

447 obs_info=self.obs_info, 

448 bounds=self._bounds, 

449 summary_stats=self.summary_stats.model_copy(), 

450 detector=self._detector.copy() if copy_detector else self._detector, 

451 photometric_scaling=self._photometric_scaling, 

452 aperture_corrections=self.aperture_corrections.copy(), 

453 backgrounds=self._backgrounds.copy(), 

454 band=self.band, 

455 ), 

456 copy=True, 

457 ) 

458 

459 def convert_unit( 

460 self, 

461 unit: astropy.units.UnitBase = astropy.units.nJy, 

462 copy: Literal["as-needed"] | bool = True, 

463 copy_detector: bool = False, 

464 ) -> VisitImage: 

465 """Return an equivalent image with different pixel units. 

466 

467 Parameters 

468 ---------- 

469 unit 

470 The unit to transform to. This may be any of the following: 

471 

472 - any unit directly relatable to the current units via Astropy; 

473 - any unit relatable to the product of the current units with the 

474 `photometric_scaling` (i.e. if the current image is in 

475 instrumental units but we know how to calibrate them) 

476 - any unit relatable to the quotient of the current units with the 

477 `photometric_scaling` (i.e. if the current image is in 

478 calibrated units and we want to revert back to instrumental 

479 units). 

480 copy 

481 Whether to copy the images and other components. If `True`, all 

482 components that aren't controlled by some other argument will 

483 always be deep-copied. If `False`, the operation will fail if the 

484 image is not already in the right units. If ``as-needed``, only 

485 the image and variance will be copied, and only if they are not 

486 already in the right units. 

487 copy_detector 

488 Whether to deep-copy the `detector` attribute. 

489 

490 Returns 

491 ------- 

492 `VisitImage` 

493 An image with the given units. 

494 """ 

495 if copy not in (True, False, "as-needed"): 495 ↛ 496line 495 didn't jump to line 496 because the condition on line 495 was never true

496 raise TypeError(f"Invalid value for 'copy' parameter: {copy!r}.") 

497 if (factor := _get_unit_conversion_factor(self.unit, unit)) is not None: 497 ↛ 498line 497 didn't jump to line 498 because the condition on line 497 was never true

498 if factor == 1.0: 

499 if copy is True: # not "as-needed" 

500 return self.copy() 

501 else: 

502 return self[...] 

503 elif copy is False: 

504 raise astropy.units.UnitConversionError( 

505 f"Units must be converted ({self.unit} -> {unit}), but copy=False." 

506 ) 

507 image = Image( 

508 self._image.array * factor, bbox=self.bbox, sky_projection=self.sky_projection, unit=unit 

509 ) 

510 variance = Image( 

511 self._variance.array * factor**2, 

512 bbox=self.bbox, 

513 unit=unit**2, 

514 ) 

515 elif self._photometric_scaling is None: 515 ↛ 516line 515 didn't jump to line 516 because the condition on line 515 was never true

516 raise astropy.units.UnitConversionError( 

517 "VisitImage.photometric_scaling is None, and there " 

518 f"is no constant conversion from {self.unit} to {unit}." 

519 ) 

520 else: 

521 if copy is False: 521 ↛ 522line 521 didn't jump to line 522 because the condition on line 521 was never true

522 raise astropy.units.UnitConversionError( 

523 f"Photometric scaling must be applied to go from ={self.unit} to {unit}, but copy=False." 

524 ) 

525 scaling = self._photometric_scaling 

526 assert scaling.unit is not None, "Checked at construction." 

527 if (constant_factor := _get_unit_conversion_factor(self.unit * scaling.unit, unit)) is not None: 

528 if constant_factor != 1.0: 528 ↛ 529line 528 didn't jump to line 529 because the condition on line 528 was never true

529 scaling = scaling * constant_factor 

530 scaling_array = scaling.render(self.bbox, dtype=self.image.array.dtype).array 

531 elif (constant_factor := _get_unit_conversion_factor(self.unit / scaling.unit, unit)) is not None: 531 ↛ 537line 531 didn't jump to line 537 because the condition on line 531 was always true

532 if constant_factor != 1.0: 532 ↛ 533line 532 didn't jump to line 533 because the condition on line 532 was never true

533 scaling = scaling / constant_factor 

534 scaling_array = scaling.render(self.bbox, dtype=self.image.array.dtype).array 

535 np.true_divide(1.0, scaling_array, out=scaling_array) 

536 else: 

537 raise astropy.units.UnitConversionError( 

538 f"photometric_scaling with units {scaling.unit} does not " 

539 f"provide a path from {self.unit} to {unit}." 

540 ) 

541 # We needed to allocate a new array to evaluate the scaling field, 

542 # and then we need to allocate another to hold its square for the 

543 # variance scaling. But then we can multiply those arrays in-place 

544 # to get the output image and variance to avoid yet more 

545 # allocations (note we can't instead multiply the visit image's 

546 # image and variance arrays in place because they might have other 

547 # references that are still associated with the old units). 

548 image = Image(scaling_array, bbox=self.bbox, unit=unit) 

549 variance = Image(np.square(scaling_array), bbox=self.bbox, unit=unit**2) 

550 image.array *= self._image.array 

551 variance.array *= self._variance.array 

552 copy_components = copy is True 

553 return self._transfer_metadata( 

554 VisitImage( 

555 image=image, 

556 mask=self._mask if not copy_components else self._mask.copy(), 

557 variance=variance, 

558 sky_projection=self.sky_projection, # never copied; immutable 

559 obs_info=self.obs_info if not copy_components else self.obs_info.model_copy(), 

560 psf=self._psf, # never copied; immutable 

561 bounds=self._bounds, # never copied; immutable 

562 summary_stats=self.summary_stats if not copy_components else self.summary_stats.model_copy(), 

563 detector=self._detector if not copy_detector else self._detector.copy(), 

564 photometric_scaling=self._photometric_scaling, # never copied; immutable 

565 aperture_corrections=( 

566 self.aperture_corrections if not copy_components else self.aperture_corrections.copy() 

567 ), 

568 backgrounds=self.backgrounds if not copy_components else self.backgrounds.copy(), 

569 band=self.band, 

570 ) 

571 ) 

572 

573 def serialize(self, archive: OutputArchive[Any]) -> VisitImageSerializationModel[Any]: 

574 return self._serialize_impl(VisitImageSerializationModel, archive) 

575 

576 # This is slightly bad Liskov substitution - we're demanding M be a 

577 # VisitImageSerializationModel, not just a MaskedImageSerializationModel, 

578 # but that's because we know only `serialize` will call it. 

579 def _serialize_impl[M: VisitImageSerializationModel[Any]]( # type: ignore[override] 

580 self, model_type: type[M], archive: OutputArchive[Any] 

581 ) -> M: 

582 result = super()._serialize_impl(model_type, archive) 

583 match self._psf: 

584 # MyPy is able to figure things out here with this match statement, 

585 # but not a single isinstance check on the three types. 

586 case PiffWrapper(): 586 ↛ 587line 586 didn't jump to line 587 because the pattern on line 586 never matched

587 result.psf = archive.serialize_direct("psf", self._psf.serialize) 

588 case PSFExWrapper(): 588 ↛ 589line 588 didn't jump to line 589 because the pattern on line 588 never matched

589 result.psf = archive.serialize_direct("psf", self._psf.serialize) 

590 case GaussianPointSpreadFunction(): 590 ↛ 592line 590 didn't jump to line 592 because the pattern on line 590 always matched

591 result.psf = archive.serialize_direct("psf", self._psf.serialize) 

592 case _: 

593 raise TypeError( 

594 f"Cannot serialize VisitImage with unrecognized PSF type {type(self._psf).__name__}." 

595 ) 

596 assert result.sky_projection is not None, "VisitImage always has a sky_projection." 

597 result.obs_info = self.obs_info 

598 result.summary_stats = self.summary_stats 

599 result.bounds = self._bounds.serialize() if self._bounds != self.bbox else None 

600 result.detector = archive.serialize_direct("detector", self._detector.serialize) 

601 result.band = self.band 

602 result.photometric_scaling = ( 

603 # MyPy can't quite follow the type union through the serialize 

604 # method return types. 

605 archive.serialize_direct( 

606 "photometric_scaling", 

607 self._photometric_scaling.serialize, 

608 ) # type: ignore[assignment] 

609 if self._photometric_scaling is not None 

610 else None 

611 ) 

612 result.aperture_corrections = archive.serialize_direct( 

613 "aperture_corrections", 

614 functools.partial(ApertureCorrectionMapSerializationModel.serialize, self.aperture_corrections), 

615 ) 

616 result.backgrounds = archive.serialize_direct("backgrounds", self._backgrounds.serialize) 

617 return result 

618 

619 @staticmethod 

620 def _get_archive_tree_type[P: pydantic.BaseModel]( 

621 pointer_type: type[P], 

622 ) -> type[VisitImageSerializationModel[P]]: 

623 """Return the serialization model type for this object for an archive 

624 type that uses the given pointer type. 

625 """ 

626 return VisitImageSerializationModel[pointer_type] # type: ignore 

627 

628 @staticmethod 

629 def from_legacy( # type: ignore[override] 

630 legacy: LegacyExposure, 

631 *, 

632 exposure_record: DimensionRecord | None, 

633 unit: astropy.units.UnitBase | None = None, 

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

635 ) -> VisitImage: 

636 """Convert from an `lsst.afw.image.Exposure` instance. 

637 

638 Parameters 

639 ---------- 

640 legacy 

641 An `lsst.afw.image.Exposure` instance that will share image and 

642 variance (but not mask) pixel data with the returned object. 

643 exposure_record 

644 The ``exposure`` dimension record for this observation, as a 

645 source of additional required metadata. Pass `None` explicitly to 

646 convert without some `~astro_metadata_translator.ObservationInfo` 

647 fields (with a warning). 

648 unit 

649 Units of the image. If not provided, the ``BUNIT`` metadata 

650 key will be used, if available. 

651 plane_map 

652 A mapping from legacy mask plane name to the new plane name and 

653 description. If `None` (default) 

654 `get_legacy_visit_image_mask_planes` is used. 

655 """ 

656 from lsst.afw.image import setVisitInfoMetadata 

657 from lsst.daf.base import PropertyList 

658 

659 if plane_map is None: 659 ↛ 661line 659 didn't jump to line 661 because the condition on line 659 was always true

660 plane_map = get_legacy_visit_image_mask_planes() 

661 legacy_wcs = legacy.getWcs() 

662 if legacy_wcs is None: 662 ↛ 663line 662 didn't jump to line 663 because the condition on line 662 was never true

663 raise ValueError("Exposure does not have a SkyWcs.") 

664 legacy_detector = legacy.getDetector() 

665 if legacy_detector is None: 665 ↛ 666line 665 didn't jump to line 666 because the condition on line 665 was never true

666 raise ValueError("Exposure does not have a Detector.") 

667 detector_bbox = Box.from_legacy(legacy_detector.getBBox()) 

668 visit_info = legacy.info.getVisitInfo() 

669 if visit_info is None: 669 ↛ 670line 669 didn't jump to line 670 because the condition on line 669 was never true

670 raise ValueError("Exposure does not have a VisitInfo.") 

671 md = legacy.getMetadata() 

672 primary_header = md.to_astropy_header() 

673 # afw's ExposureReader strips the VisitInfo's header cards from its 

674 # metadata, but we want to include them in the FITS header we 

675 # attach. 

676 pl = PropertyList() 

677 setVisitInfoMetadata(pl, visit_info) 

678 primary_header.update(pl.to_astropy_header()) 

679 telescope = primary_header.get("TELESCOP") 

680 obs_info = obs_info_from_legacy( 

681 visit_info, 

682 exposure_record, 

683 legacy_detector, 

684 detector_exposure_id=legacy.info.getId(), 

685 telescope=telescope, 

686 physical_filter=legacy.info.getFilter().physicalLabel or None, 

687 ) 

688 if exposure_record is not None: 688 ↛ 692line 688 didn't jump to line 692 because the condition on line 688 was always true

689 instrument = exposure_record.instrument 

690 visit = exposure_record.id # assume visit ID == exposure ID; always true in practice 

691 else: 

692 instrument = visit_info.getInstrumentLabel() 

693 visit = visit_info.getId() 

694 opaque_fits_metadata = FitsOpaqueMetadata() 

695 metadata = opaque_fits_metadata.extract_legacy_primary_header(primary_header) 

696 instrumental_unit = opaque_fits_metadata.get_instrumental_unit() or astropy.units.electron 

697 hdr_unit: astropy.units.UnitBase | None = None 

698 if hdr_unit_str := md.get("BUNIT"): 698 ↛ 700line 698 didn't jump to line 700 because the condition on line 698 was always true

699 hdr_unit = parse_legacy_bunit(hdr_unit_str, instrumental_unit=instrumental_unit) 

700 if unit is None: 700 ↛ 702line 700 didn't jump to line 702 because the condition on line 700 was always true

701 unit = hdr_unit 

702 elif hdr_unit is not None and hdr_unit != unit: 

703 raise ValueError(f"BUNIT value {hdr_unit} disagrees with given unit {unit}.") 

704 sky_projection = SkyProjection.from_legacy( 

705 legacy_wcs, 

706 DetectorFrame( 

707 instrument=instrument, 

708 visit=visit, 

709 detector=legacy_detector.getId(), 

710 bbox=detector_bbox, 

711 ), 

712 ) 

713 legacy_psf = legacy.getPsf() 

714 if legacy_psf is None: 714 ↛ 715line 714 didn't jump to line 715 because the condition on line 714 was never true

715 raise ValueError("Exposure file does not have a Psf.") 

716 psf = PointSpreadFunction.from_legacy(legacy_psf, bounds=detector_bbox) 

717 masked_image = MaskedImage.from_legacy(legacy.getMaskedImage(), unit=unit, plane_map=plane_map) 

718 legacy_summary_stats = legacy.info.getSummaryStats() 

719 legacy_ap_corr_map = legacy.info.getApCorrMap() 

720 legacy_polygon = legacy.info.getValidPolygon() 

721 legacy_photo_calib = legacy.info.getPhotoCalib() 

722 detector = Detector.from_legacy( 

723 legacy_detector, instrument=instrument, visit=visit, is_raw_assembled=True 

724 ) 

725 if instrument == "LSSTCam": 

726 obs_info = _fix_lsstcam_detector_serial(obs_info, detector) 

727 result = VisitImage( 

728 image=masked_image.image.view(unit=unit), 

729 mask=masked_image.mask, 

730 variance=masked_image.variance, 

731 sky_projection=sky_projection, 

732 psf=psf, 

733 obs_info=obs_info, 

734 summary_stats=( 

735 ObservationSummaryStats.from_legacy(legacy_summary_stats) 

736 if legacy_summary_stats is not None 

737 else None 

738 ), 

739 detector=detector, 

740 aperture_corrections=( 

741 aperture_corrections_from_legacy(legacy_ap_corr_map) 

742 if legacy_ap_corr_map is not None 

743 else None 

744 ), 

745 bounds=Polygon.from_legacy(legacy_polygon) if legacy_polygon is not None else None, 

746 photometric_scaling=( 

747 field_from_legacy_photo_calib( 

748 legacy_photo_calib, bounds=detector_bbox, instrumental_unit=instrumental_unit 

749 ) 

750 if legacy_photo_calib is not None 

751 else None 

752 ), 

753 band=legacy.info.getFilter().bandLabel, 

754 metadata=metadata, 

755 ) 

756 result._metadata["id"] = legacy.info.getId() 

757 result._opaque_metadata = opaque_fits_metadata 

758 return result 

759 

760 def to_legacy( 

761 self, *, copy: bool | None = None, plane_map: Mapping[str, MaskPlane] | None = None 

762 ) -> LegacyExposure: 

763 """Convert to an `lsst.afw.image.Exposure` instance. 

764 

765 Parameters 

766 ---------- 

767 copy 

768 If `True`, always copy the image and variance pixel data. 

769 If `False`, return a view, and raise `TypeError` if the pixel data 

770 is read-only (this is not supported by afw). If `None`, only copy 

771 if the pixel data is read-only. Mask pixel data is always copied. 

772 plane_map 

773 A mapping from legacy mask plane name to the new plane name and 

774 description. If `None` (default), 

775 `get_legacy_visit_image_mask_planes` is used. 

776 """ 

777 from lsst.afw.image import Exposure as LegacyExposure 

778 from lsst.afw.image import FilterLabel as LegacyFilterLabel 

779 from lsst.obs.base.makeRawVisitInfoViaObsInfo import MakeRawVisitInfoViaObsInfo 

780 

781 if plane_map is None: 781 ↛ 783line 781 didn't jump to line 783 because the condition on line 781 was always true

782 plane_map = get_legacy_visit_image_mask_planes() 

783 legacy_masked_image = super().to_legacy(copy=copy, plane_map=plane_map) 

784 result = LegacyExposure(legacy_masked_image, dtype=self.image.array.dtype) 

785 result_info = result.info 

786 result_info.setId(self._metadata.get("id")) 

787 result_info.setWcs(self.sky_projection.to_legacy()) 

788 result_info.setDetector(self.detector.to_legacy()) 

789 result_info.setFilter(LegacyFilterLabel.fromBandPhysical(self.band, self.obs_info.physical_filter)) 

790 if self._photometric_scaling is not None: 790 ↛ 791line 790 didn't jump to line 791 because the condition on line 790 was never true

791 result_info.setPhotoCalib(self._photometric_scaling.to_legacy_photo_calib(self.unit)) 

792 else: 

793 result_info.setPhotoCalib(BaseField.make_legacy_photo_calib(self.unit)) 

794 self._fill_legacy_metadata(result_info.getMetadata()) 

795 if isinstance(self._psf, LegacyPointSpreadFunction): 795 ↛ 796line 795 didn't jump to line 796 because the condition on line 795 was never true

796 result_info.setPsf(self._psf.legacy_psf) 

797 elif isinstance(self._psf, PiffWrapper | GaussianPointSpreadFunction): 797 ↛ 799line 797 didn't jump to line 799 because the condition on line 797 was always true

798 result_info.setPsf(self._psf.to_legacy()) 

799 if isinstance(self.bounds, Polygon): 799 ↛ 800line 799 didn't jump to line 800 because the condition on line 799 was never true

800 result_info.setValidPolygon(self.bounds.to_legacy()) 

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

802 result_info.setApCorrMap(aperture_corrections_to_legacy(self.aperture_corrections)) 

803 result_info.setVisitInfo(MakeRawVisitInfoViaObsInfo.observationInfo2visitInfo(self.obs_info)) 

804 result_info.setSummaryStats(self.summary_stats.to_legacy()) 

805 return result 

806 

807 @staticmethod 

808 def read_legacy( # type: ignore[override] 

809 filename: str, 

810 *, 

811 exposure_record: DimensionRecord | None, 

812 preserve_quantization: bool = False, 

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

814 component: Literal[ 

815 "bbox", 

816 "image", 

817 "mask", 

818 "variance", 

819 "sky_projection", 

820 "psf", 

821 "detector", 

822 "photometric_scaling", 

823 "obs_info", 

824 "summary_stats", 

825 "aperture_corrections", 

826 "bounds", 

827 "unit", 

828 ] 

829 | None = None, 

830 ) -> Any: 

831 """Read a FITS file written by `lsst.afw.image.Exposure.writeFits`. 

832 

833 Parameters 

834 ---------- 

835 filename 

836 Full name of the file. 

837 exposure_record 

838 The ``exposure`` dimension record for this observation, as a 

839 source of additional required metadata. Pass `None` explicitly to 

840 convert without some `~astro_metadata_translator.ObservationInfo` 

841 fields (with a warning). 

842 preserve_quantization 

843 If `True`, ensure that writing the masked image back out again will 

844 exactly preserve quantization-compressed pixel values. This causes 

845 the image and variance plane arrays to be marked as read-only and 

846 stores the original binary table data for those planes in memory. 

847 If the `MaskedImage` is copied, the precompressed pixel values are 

848 not transferred to the copy. 

849 plane_map 

850 A mapping from legacy mask plane name to the new plane name and 

851 description. If `None` (default) 

852 `get_legacy_visit_image_mask_planes` is used. 

853 component 

854 A component to read instead of the full image. 

855 """ 

856 from lsst.afw.image import ExposureFitsReader 

857 

858 reader = ExposureFitsReader(filename) 

859 if component == "bbox": 859 ↛ 860line 859 didn't jump to line 860 because the condition on line 859 was never true

860 return Box.from_legacy(reader.readBBox()) 

861 if component == "bounds": 861 ↛ 862line 861 didn't jump to line 862 because the condition on line 861 was never true

862 bbox = Box.from_legacy(reader.readBBox()) 

863 if (legacy_polygon := reader.readValidPolygon()) is None: 

864 return bbox 

865 # Clip to the image, as the VisitImage constructor does. 

866 bounds = Polygon.from_legacy(legacy_polygon) 

867 return bounds if bbox.contains(bounds.bbox) else bounds.intersection(bbox) 

868 legacy_detector = reader.readDetector() 

869 if legacy_detector is None: 869 ↛ 870line 869 didn't jump to line 870 because the condition on line 869 was never true

870 raise ValueError(f"Exposure file {filename!r} does not have a Detector.") 

871 detector_bbox = Box.from_legacy(legacy_detector.getBBox()) 

872 legacy_wcs = None 

873 if component in (None, "image", "mask", "variance", "sky_projection"): 873 ↛ 877line 873 didn't jump to line 877 because the condition on line 873 was always true

874 legacy_wcs = reader.readWcs() 

875 if legacy_wcs is None: 875 ↛ 876line 875 didn't jump to line 876 because the condition on line 875 was never true

876 raise ValueError(f"Exposure file {filename!r} does not have a SkyWcs.") 

877 legacy_exposure_info = reader.readExposureInfo() 

878 summary_stats = None 

879 if component in (None, "summary_stats"): 879 ↛ 885line 879 didn't jump to line 885 because the condition on line 879 was always true

880 legacy_stats = legacy_exposure_info.getSummaryStats() 

881 if legacy_stats is not None: 

882 summary_stats = ObservationSummaryStats.from_legacy(legacy_stats) 

883 if component == "summary_stats": 883 ↛ 884line 883 didn't jump to line 884 because the condition on line 883 was never true

884 return summary_stats 

885 if component in (None, "psf"): 885 ↛ 892line 885 didn't jump to line 892 because the condition on line 885 was always true

886 legacy_psf = reader.readPsf() 

887 if legacy_psf is None: 887 ↛ 888line 887 didn't jump to line 888 because the condition on line 887 was never true

888 raise ValueError(f"Exposure file {filename!r} does not have a Psf.") 

889 psf = PointSpreadFunction.from_legacy(legacy_psf, bounds=detector_bbox) 

890 if component == "psf": 890 ↛ 891line 890 didn't jump to line 891 because the condition on line 890 was never true

891 return psf 

892 aperture_corrections: ApertureCorrectionMap = {} 

893 if component in (None, "aperture_corrections"): 893 ↛ 899line 893 didn't jump to line 899 because the condition on line 893 was always true

894 legacy_ap_corr_map = reader.readApCorrMap() 

895 if legacy_ap_corr_map is not None: 895 ↛ 896line 895 didn't jump to line 896 because the condition on line 895 was never true

896 aperture_corrections = aperture_corrections_from_legacy(legacy_ap_corr_map) 

897 if component == "aperture_corrections": 897 ↛ 898line 897 didn't jump to line 898 because the condition on line 897 was never true

898 return aperture_corrections 

899 assert component in ( 

900 None, 

901 "image", 

902 "mask", 

903 "variance", 

904 "sky_projection", 

905 "obs_info", 

906 "detector", 

907 "photometric_scaling", 

908 "unit", 

909 ), component # for MyPy 

910 visit_info = legacy_exposure_info.getVisitInfo() 

911 if visit_info is None: 911 ↛ 912line 911 didn't jump to line 912 because the condition on line 911 was never true

912 raise ValueError(f"Exposure file {filename!r} does not have a VisitInfo.") 

913 filter_label = reader.readFilter() 

914 with astropy.io.fits.open(filename) as hdu_list: 

915 primary_header = hdu_list[0].header 

916 telescope = primary_header.get("TELESCOP") 

917 obs_info = obs_info_from_legacy( 

918 visit_info, 

919 exposure_record, 

920 legacy_detector, 

921 detector_exposure_id=legacy_exposure_info.getId(), 

922 telescope=str(telescope) if telescope is not None else None, 

923 physical_filter=filter_label.physicalLabel or None, 

924 ) 

925 if exposure_record is not None: 925 ↛ 929line 925 didn't jump to line 929 because the condition on line 925 was always true

926 instrument = exposure_record.instrument 

927 visit = exposure_record.id 

928 else: 

929 instrument = visit_info.getInstrumentLabel() 

930 visit = visit_info.getId() 

931 if component == "obs_info": 931 ↛ 932line 931 didn't jump to line 932 because the condition on line 931 was never true

932 if instrument == "LSSTCam": 

933 obs_info = _fix_lsstcam_detector_serial(obs_info) 

934 return obs_info 

935 opaque_metadata = FitsOpaqueMetadata() 

936 # This extraction is destructive, so we need to be sure to pass 

937 # this opaque_metadata down to MaskedImage._read_legacy_hdus 

938 # so it doesn't try to extract it again. 

939 metadata = opaque_metadata.extract_legacy_primary_header(primary_header) 

940 if component == "unit": 940 ↛ 941line 940 didn't jump to line 941 because the condition on line 940 was never true

941 if (fits_unit := hdu_list[1].header.get("BUNIT")) is None: 

942 return None 

943 return parse_legacy_bunit(fits_unit, opaque_metadata.get_instrumental_unit()) 

944 if (instrumental_unit := opaque_metadata.get_instrumental_unit()) is None: 944 ↛ 946line 944 didn't jump to line 946 because the condition on line 944 was always true

945 instrumental_unit = astropy.units.electron 

946 photometric_scaling: Field | None = None 

947 if component in (None, "photometric_scaling"): 947 ↛ 953line 947 didn't jump to line 953 because the condition on line 947 was always true

948 legacy_photo_calib = reader.readPhotoCalib() 

949 if legacy_photo_calib is not None: 

950 photometric_scaling = field_from_legacy_photo_calib( 

951 legacy_photo_calib, bounds=detector_bbox, instrumental_unit=instrumental_unit 

952 ) 

953 if component == "photometric_scaling": 953 ↛ 954line 953 didn't jump to line 954 because the condition on line 953 was never true

954 return photometric_scaling 

955 if component in ("detector", None): 955 ↛ 963line 955 didn't jump to line 963 because the condition on line 955 was always true

956 detector = Detector.from_legacy( 

957 legacy_detector, instrument=instrument, visit=visit, is_raw_assembled=True 

958 ) 

959 if instrument == "LSSTCam": 

960 obs_info = _fix_lsstcam_detector_serial(obs_info, detector) 

961 if component == "detector": 961 ↛ 962line 961 didn't jump to line 962 because the condition on line 961 was never true

962 return detector 

963 assert component != "detector", "MyPy can't work this out from the above." 

964 sky_projection = SkyProjection.from_legacy( 

965 legacy_wcs, 

966 DetectorFrame( 

967 instrument=instrument, 

968 visit=visit, 

969 detector=legacy_detector.getId(), 

970 bbox=detector_bbox, 

971 ), 

972 ) 

973 if component == "sky_projection": 973 ↛ 974line 973 didn't jump to line 974 because the condition on line 973 was never true

974 return sky_projection 

975 if plane_map is None: 975 ↛ 977line 975 didn't jump to line 977 because the condition on line 975 was always true

976 plane_map = get_legacy_visit_image_mask_planes() 

977 from_masked_image = MaskedImage._read_legacy_hdus( 

978 hdu_list, 

979 filename, 

980 opaque_metadata=opaque_metadata, 

981 preserve_quantization=preserve_quantization, 

982 plane_map=plane_map, 

983 component=component, 

984 ) 

985 if component is not None: 985 ↛ 988line 985 didn't jump to line 988 because the condition on line 985 was never true

986 # This is the image, mask, or variance; attach the sky_projection 

987 # and obs_info and return 

988 return from_masked_image.view(sky_projection=sky_projection) 

989 legacy_polygon = reader.readValidPolygon() 

990 result = VisitImage( 

991 from_masked_image.image, 

992 mask=from_masked_image.mask, 

993 variance=from_masked_image.variance, 

994 sky_projection=sky_projection, 

995 psf=psf, 

996 detector=detector, 

997 obs_info=obs_info, 

998 summary_stats=summary_stats, 

999 aperture_corrections=aperture_corrections, 

1000 bounds=Polygon.from_legacy(legacy_polygon) if legacy_polygon is not None else None, 

1001 photometric_scaling=photometric_scaling, 

1002 band=filter_label.bandLabel, 

1003 metadata=metadata, 

1004 ) 

1005 result._opaque_metadata = from_masked_image._opaque_metadata 

1006 result._metadata["id"] = reader.readExposureId() 

1007 return result 

1008 

1009 

1010class VisitImageSerializationModel[P: pydantic.BaseModel](MaskedImageSerializationModel[P]): 

1011 """A Pydantic model used to represent a serialized `VisitImage`.""" 

1012 

1013 SCHEMA_NAME: ClassVar[str] = "visit_image" 

1014 SCHEMA_VERSION: ClassVar[str] = "1.1.0" 

1015 MIN_READ_VERSION: ClassVar[int] = 1 

1016 PUBLIC_TYPE: ClassVar[type] = VisitImage 

1017 

1018 # Inherited attributes are duplicated because that improves the docs 

1019 # (some limitation in the sphinx/pydantic integration), and these are 

1020 # important docs. 

1021 

1022 image: ImageSerializationModel[P] = pydantic.Field(description="The main data image.") 

1023 mask: MaskSerializationModel[P] = pydantic.Field( 

1024 description="Bitmask that annotates the main image's pixels." 

1025 ) 

1026 variance: ImageSerializationModel[P] = pydantic.Field( 

1027 description="Per-pixel variance estimates for the main image." 

1028 ) 

1029 sky_projection: SkyProjectionSerializationModel[P] = pydantic.Field( 

1030 description="Projection that maps the pixel grid to the sky.", 

1031 ) 

1032 psf: PiffSerializationModel | PSFExSerializationModel | GaussianPSFSerializationModel | Any = ( 

1033 pydantic.Field(union_mode="left_to_right", description="PSF model for the image.") 

1034 ) 

1035 obs_info: ObservationInfo = pydantic.Field( 

1036 description="Standardized description of visit metadata", 

1037 ) 

1038 photometric_scaling: FieldSerializationModel | None = pydantic.Field( 

1039 default=None, 

1040 description="Scaling that can be used to multiply a post-ISR image to yield calibrated pixel values.", 

1041 ) 

1042 summary_stats: ObservationSummaryStats = pydantic.Field( 

1043 description="Summary statistics for the observation." 

1044 ) 

1045 detector: DetectorSerializationModel = pydantic.Field( 

1046 description="Geometry and electronic information for the detector." 

1047 ) 

1048 aperture_corrections: ApertureCorrectionMapSerializationModel = pydantic.Field( 

1049 default_factory=ApertureCorrectionMapSerializationModel, 

1050 description="Aperture corrections, keyed by flux algorithm.", 

1051 ) 

1052 bounds: BoundsSerializationModel | None = pydantic.Field( 

1053 default=None, 

1054 description="Pixel validity region, if different from the image bounding box.", 

1055 exclude_if=is_none, 

1056 ) 

1057 backgrounds: BackgroundMapSerializationModel = pydantic.Field( 

1058 default_factory=BackgroundMapSerializationModel, 

1059 description="Background models associated with this image.", 

1060 ) 

1061 band: str = pydantic.Field(description="Short name of the bandpass filter.") 

1062 

1063 def deserialize( 

1064 self, archive: InputArchive[Any], *, bbox: Box | None = None, **kwargs: Any 

1065 ) -> VisitImage: 

1066 if kwargs: 1066 ↛ 1067line 1066 didn't jump to line 1067 because the condition on line 1066 was never true

1067 raise InvalidParameterError(f"Unrecognized parameters for VisitImage: {set(kwargs.keys())}.") 

1068 masked_image = super().deserialize(archive, bbox=bbox) 

1069 try: 

1070 psf = self.psf.deserialize(archive) 

1071 except ArchiveReadError as err: 

1072 # Defer this until/unless somebody actually asks for the PSF. 

1073 psf = err 

1074 detector = self.detector.deserialize(archive) 

1075 aperture_corrections = self.aperture_corrections.deserialize(archive) 

1076 photometric_scaling = ( 

1077 self.photometric_scaling.deserialize(archive) if self.photometric_scaling is not None else None 

1078 ) 

1079 return VisitImage( 

1080 masked_image.image, 

1081 mask=masked_image.mask, 

1082 variance=masked_image.variance, 

1083 psf=psf, 

1084 sky_projection=masked_image.sky_projection, 

1085 obs_info=self.obs_info, 

1086 summary_stats=self.summary_stats, 

1087 detector=detector, 

1088 aperture_corrections=aperture_corrections, 

1089 photometric_scaling=photometric_scaling, 

1090 bounds=self.bounds.deserialize() if self.bounds is not None else None, 

1091 backgrounds=self.backgrounds.deserialize(archive), 

1092 band=self.band, 

1093 )._finish_deserialize(self) 

1094 

1095 def deserialize_component(self, component: str, archive: InputArchive[Any], **kwargs: Any) -> Any: 

1096 if kwargs and component not in ("image", "mask", "variance", "masked_image"): 1096 ↛ 1097line 1096 didn't jump to line 1097 because the condition on line 1096 was never true

1097 raise InvalidParameterError( 

1098 f"Unsupported parameters for VisitImage component {component}: {set(kwargs.keys())}." 

1099 ) 

1100 if component == "masked_image": 

1101 return super().deserialize(archive, **kwargs) 

1102 if component == "bounds": 

1103 return self.bounds.deserialize() if self.bounds is not None else self.bbox 

1104 return super().deserialize_component(component, archive, **kwargs) 

1105 

1106 

1107# Serial numbers that were wrong in LSSTCam camera definitions before the 

1108# DM-55080 fix: the R24 and R34 serial blocks were swapped, and one ITL sensor 

1109# had a stale serial. Keys are detector IDs, values are correct serial. 

1110_DM55080_DETECTOR_SERIALS: dict[int, str] = { 

1111 108: "E2V-CCD250-140", 

1112 109: "E2V-CCD250-314", 

1113 110: "E2V-CCD250-302", 

1114 111: "E2V-CCD250-298", 

1115 112: "E2V-CCD250-305", 

1116 113: "E2V-CCD250-318", 

1117 114: "E2V-CCD250-304", 

1118 115: "E2V-CCD250-301", 

1119 116: "E2V-CCD250-385", 

1120 153: "E2V-CCD250-160", 

1121 154: "E2V-CCD250-411", 

1122 155: "E2V-CCD250-256", 

1123 156: "E2V-CCD250-253", 

1124 157: "E2V-CCD250-194", 

1125 158: "E2V-CCD250-231", 

1126 159: "E2V-CCD250-224", 

1127 160: "E2V-CCD250-189", 

1128 161: "E2V-CCD250-134", 

1129 173: "ITL-3800C-327", 

1130} 

1131 

1132 

1133def _fix_lsstcam_detector_serial( 

1134 obs_info: ObservationInfo, detector: Detector | None = None 

1135) -> ObservationInfo: 

1136 assert obs_info.detector_num is not None 

1137 expected = _DM55080_DETECTOR_SERIALS.get(obs_info.detector_num) 

1138 if expected is None: 1138 ↛ 1140line 1138 didn't jump to line 1140 because the condition on line 1138 was always true

1139 return obs_info 

1140 if detector is not None and detector.serial != expected: 

1141 _LOG.warning( 

1142 "Detector %d has camera-geometry serial %s, which predates the" 

1143 " DM-55080 camera-definition fix; using %s.", 

1144 detector.id, 

1145 detector.serial, 

1146 expected, 

1147 ) 

1148 detector._attributes.serial = expected 

1149 if obs_info.detector_serial != expected: 

1150 with obs_info.edit_copy() as obs_info: 

1151 obs_info.detector_serial = expected 

1152 return obs_info 

1153 

1154 

1155def _get_unit_conversion_factor( 

1156 original: astropy.units.UnitBase, new: astropy.units.UnitBase 

1157) -> float | None: 

1158 try: 

1159 return original.to(new) 

1160 except astropy.units.UnitConversionError: 

1161 return None