Coverage for python/lsst/images/_visit_image.py: 61%
428 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-17 09:26 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-17 09:26 +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.
12from __future__ import annotations
14__all__ = ("VisitImage", "VisitImageSerializationModel")
16import functools
17import logging
18from collections.abc import Mapping
19from types import EllipsisType
20from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast
22import astropy.io.fits
23import astropy.units
24import numpy as np
25import pydantic
26from astro_metadata_translator import ObservationInfo
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
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]
80_LOG = logging.getLogger("lsst.images")
83class VisitImage(MaskedImage):
84 """A calibrated single-visit image.
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 """
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
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)
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)
208 @property
209 def bounds(self) -> Bounds:
210 """The region where pixels are valid (`Bounds`)."""
211 return self._bounds
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
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
226 @property
227 def band(self) -> str:
228 """Short name of the bandpass filter (`str`)."""
229 return self._band
231 @property
232 def astropy_wcs(self) -> SkyProjectionAstropyView:
233 """An Astropy WCS for the pixel arrays (`SkyProjectionAstropyView`).
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`.
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)
247 @property
248 def summary_stats(self) -> ObservationSummaryStats:
249 """Optional summary statistics for this observation
250 (`ObservationSummaryStats`).
251 """
252 return self._summary_stats
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
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
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
276 @psf.setter
277 def psf(self, value: PointSpreadFunction) -> None:
278 self._psf = value
280 @property
281 def detector(self) -> Detector:
282 """Geometry and electronic information about the detector
283 (`.cameras.Detector`).
284 """
285 return self._detector
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
294 @property
295 def backgrounds(self) -> BackgroundMap:
296 """A mapping of backgrounds associated with this image
297 (`BackgroundMap`).
298 """
299 return self._backgrounds
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 )
322 def _summary_stats_describe_options(self, options: DescribeOptions) -> DescribeOptions:
323 """Return the options the summary-statistics child report is built
324 with.
326 Parameters
327 ----------
328 options : `DescribeOptions`
329 Options this image's own report is being built with.
331 Returns
332 -------
333 options : `DescribeOptions`
334 Options for the child report.
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()
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.
353 Parameters
354 ----------
355 options : `DescribeOptions`
356 Options the child report is built with.
358 Returns
359 -------
360 report : `Report`
361 Report describing the PSF, or naming it unreadable.
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)
379 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report:
380 """Return a `Report` describing this visit image.
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 )
433 def copy(self, *, copy_detector: bool = False) -> VisitImage:
434 """Deep-copy the visit image.
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 )
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.
467 Parameters
468 ----------
469 unit
470 The unit to transform to. This may be any of the following:
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.
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 )
573 def serialize(self, archive: OutputArchive[Any]) -> VisitImageSerializationModel[Any]:
574 return self._serialize_impl(VisitImageSerializationModel, archive)
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
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
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.
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. It is only consulted when
646 the legacy metadata does not carry an embedded
647 `~astro_metadata_translator.ObservationInfo` (flat ``OBS_INFO``
648 cards); in that case pass `None` explicitly to convert without
649 some ``ObservationInfo`` fields (with a warning).
650 unit
651 Units of the image. If not provided, the ``BUNIT`` metadata
652 key will be used, if available.
653 plane_map
654 A mapping from legacy mask plane name to the new plane name and
655 description. If `None` (default)
656 `get_legacy_visit_image_mask_planes` is used.
657 """
658 from lsst.afw.image import setVisitInfoMetadata
659 from lsst.daf.base import PropertyList
661 if plane_map is None: 661 ↛ 663line 661 didn't jump to line 663 because the condition on line 661 was always true
662 plane_map = get_legacy_visit_image_mask_planes()
663 legacy_wcs = legacy.getWcs()
664 if legacy_wcs is None: 664 ↛ 665line 664 didn't jump to line 665 because the condition on line 664 was never true
665 raise ValueError("Exposure does not have a SkyWcs.")
666 legacy_detector = legacy.getDetector()
667 if legacy_detector is None: 667 ↛ 668line 667 didn't jump to line 668 because the condition on line 667 was never true
668 raise ValueError("Exposure does not have a Detector.")
669 detector_bbox = Box.from_legacy(legacy_detector.getBBox())
670 visit_info = legacy.info.getVisitInfo()
671 if visit_info is None: 671 ↛ 672line 671 didn't jump to line 672 because the condition on line 671 was never true
672 raise ValueError("Exposure does not have a VisitInfo.")
673 md = legacy.getMetadata()
674 primary_header = md.to_astropy_header()
675 # afw's ExposureReader strips the VisitInfo's header cards from its
676 # metadata, but we want to include them in the FITS header we
677 # attach.
678 pl = PropertyList()
679 setVisitInfoMetadata(pl, visit_info)
680 primary_header.update(pl.to_astropy_header())
681 telescope = primary_header.get("TELESCOP")
682 obs_info = obs_info_from_legacy(
683 visit_info,
684 exposure_record,
685 legacy_detector,
686 detector_exposure_id=legacy.info.getId(),
687 telescope=telescope,
688 physical_filter=legacy.info.getFilter().physicalLabel or None,
689 header=primary_header,
690 )
691 if exposure_record is not None:
692 instrument = exposure_record.instrument
693 visit = exposure_record.id # assume visit ID == exposure ID; always true in practice
694 else:
695 instrument = visit_info.getInstrumentLabel()
696 visit = visit_info.getId()
697 opaque_fits_metadata = FitsOpaqueMetadata()
698 metadata = opaque_fits_metadata.extract_legacy_primary_header(primary_header)
699 instrumental_unit = opaque_fits_metadata.get_instrumental_unit() or astropy.units.electron
700 hdr_unit: astropy.units.UnitBase | None = None
701 if hdr_unit_str := md.get("BUNIT"): 701 ↛ 703line 701 didn't jump to line 703 because the condition on line 701 was always true
702 hdr_unit = parse_legacy_bunit(hdr_unit_str, instrumental_unit=instrumental_unit)
703 if unit is None: 703 ↛ 705line 703 didn't jump to line 705 because the condition on line 703 was always true
704 unit = hdr_unit
705 elif hdr_unit is not None and hdr_unit != unit:
706 raise ValueError(f"BUNIT value {hdr_unit} disagrees with given unit {unit}.")
707 sky_projection = SkyProjection.from_legacy(
708 legacy_wcs,
709 DetectorFrame(
710 instrument=instrument,
711 visit=visit,
712 detector=legacy_detector.getId(),
713 bbox=detector_bbox,
714 ),
715 )
716 legacy_psf = legacy.getPsf()
717 if legacy_psf is None: 717 ↛ 718line 717 didn't jump to line 718 because the condition on line 717 was never true
718 raise ValueError("Exposure file does not have a Psf.")
719 psf = PointSpreadFunction.from_legacy(legacy_psf, bounds=detector_bbox)
720 masked_image = MaskedImage.from_legacy(legacy.getMaskedImage(), unit=unit, plane_map=plane_map)
721 legacy_summary_stats = legacy.info.getSummaryStats()
722 legacy_ap_corr_map = legacy.info.getApCorrMap()
723 legacy_polygon = legacy.info.getValidPolygon()
724 legacy_photo_calib = legacy.info.getPhotoCalib()
725 detector = Detector.from_legacy(
726 legacy_detector, instrument=instrument, visit=visit, is_raw_assembled=True
727 )
728 if instrument == "LSSTCam":
729 obs_info = _fix_lsstcam_detector_serial(obs_info, detector)
730 result = VisitImage(
731 image=masked_image.image.view(unit=unit),
732 mask=masked_image.mask,
733 variance=masked_image.variance,
734 sky_projection=sky_projection,
735 psf=psf,
736 obs_info=obs_info,
737 summary_stats=(
738 ObservationSummaryStats.from_legacy(legacy_summary_stats)
739 if legacy_summary_stats is not None
740 else None
741 ),
742 detector=detector,
743 aperture_corrections=(
744 aperture_corrections_from_legacy(legacy_ap_corr_map)
745 if legacy_ap_corr_map is not None
746 else None
747 ),
748 bounds=Polygon.from_legacy(legacy_polygon) if legacy_polygon is not None else None,
749 photometric_scaling=(
750 field_from_legacy_photo_calib(
751 legacy_photo_calib, bounds=detector_bbox, instrumental_unit=instrumental_unit
752 )
753 if legacy_photo_calib is not None
754 else None
755 ),
756 band=legacy.info.getFilter().bandLabel,
757 metadata=metadata,
758 )
759 result._metadata["id"] = legacy.info.getId()
760 result._opaque_metadata = opaque_fits_metadata
761 return result
763 def to_legacy(
764 self, *, copy: bool | None = None, plane_map: Mapping[str, MaskPlane] | None = None
765 ) -> LegacyExposure:
766 """Convert to an `lsst.afw.image.Exposure` instance.
768 Parameters
769 ----------
770 copy
771 If `True`, always copy the image and variance pixel data.
772 If `False`, return a view, and raise `TypeError` if the pixel data
773 is read-only (this is not supported by afw). If `None`, only copy
774 if the pixel data is read-only. Mask pixel data is always copied.
775 plane_map
776 A mapping from legacy mask plane name to the new plane name and
777 description. If `None` (default),
778 `get_legacy_visit_image_mask_planes` is used.
779 """
780 from lsst.afw.image import Exposure as LegacyExposure
781 from lsst.afw.image import FilterLabel as LegacyFilterLabel
782 from lsst.obs.base.makeRawVisitInfoViaObsInfo import MakeRawVisitInfoViaObsInfo
784 if plane_map is None: 784 ↛ 786line 784 didn't jump to line 786 because the condition on line 784 was always true
785 plane_map = get_legacy_visit_image_mask_planes()
786 legacy_masked_image = super().to_legacy(copy=copy, plane_map=plane_map)
787 result = LegacyExposure(legacy_masked_image, dtype=self.image.array.dtype)
788 result_info = result.info
789 result_info.setId(self._metadata.get("id"))
790 result_info.setWcs(self.sky_projection.to_legacy())
791 result_info.setDetector(self.detector.to_legacy())
792 result_info.setFilter(LegacyFilterLabel.fromBandPhysical(self.band, self.obs_info.physical_filter))
793 if self._photometric_scaling is not None: 793 ↛ 794line 793 didn't jump to line 794 because the condition on line 793 was never true
794 result_info.setPhotoCalib(self._photometric_scaling.to_legacy_photo_calib(self.unit))
795 else:
796 result_info.setPhotoCalib(BaseField.make_legacy_photo_calib(self.unit))
797 self._fill_legacy_metadata(result_info.getMetadata())
798 if isinstance(self._psf, LegacyPointSpreadFunction): 798 ↛ 799line 798 didn't jump to line 799 because the condition on line 798 was never true
799 result_info.setPsf(self._psf.legacy_psf)
800 elif isinstance(self._psf, PiffWrapper | GaussianPointSpreadFunction): 800 ↛ 802line 800 didn't jump to line 802 because the condition on line 800 was always true
801 result_info.setPsf(self._psf.to_legacy())
802 if isinstance(self.bounds, Polygon): 802 ↛ 803line 802 didn't jump to line 803 because the condition on line 802 was never true
803 result_info.setValidPolygon(self.bounds.to_legacy())
804 if self.aperture_corrections: 804 ↛ 805line 804 didn't jump to line 805 because the condition on line 804 was never true
805 result_info.setApCorrMap(aperture_corrections_to_legacy(self.aperture_corrections))
806 result_info.setVisitInfo(MakeRawVisitInfoViaObsInfo.observationInfo2visitInfo(self.obs_info))
807 result_info.setSummaryStats(self.summary_stats.to_legacy())
808 return result
810 @staticmethod
811 def read_legacy( # type: ignore[override]
812 filename: str,
813 *,
814 exposure_record: DimensionRecord | None,
815 preserve_quantization: bool = False,
816 plane_map: Mapping[str, MaskPlane] | None = None,
817 component: Literal[
818 "bbox",
819 "image",
820 "mask",
821 "variance",
822 "sky_projection",
823 "psf",
824 "detector",
825 "photometric_scaling",
826 "obs_info",
827 "summary_stats",
828 "aperture_corrections",
829 "bounds",
830 "unit",
831 ]
832 | None = None,
833 ) -> Any:
834 """Read a FITS file written by `lsst.afw.image.Exposure.writeFits`.
836 Parameters
837 ----------
838 filename
839 Full name of the file.
840 exposure_record
841 The ``exposure`` dimension record for this observation, as a
842 source of additional required metadata. It is only consulted when
843 the legacy metadata does not carry an embedded
844 `~astro_metadata_translator.ObservationInfo` (flat ``OBS_INFO``
845 cards); in that case pass `None` explicitly to convert without
846 some ``ObservationInfo`` fields (with a warning).
847 preserve_quantization
848 If `True`, ensure that writing the masked image back out again will
849 exactly preserve quantization-compressed pixel values. This causes
850 the image and variance plane arrays to be marked as read-only and
851 stores the original binary table data for those planes in memory.
852 If the `MaskedImage` is copied, the precompressed pixel values are
853 not transferred to the copy.
854 plane_map
855 A mapping from legacy mask plane name to the new plane name and
856 description. If `None` (default)
857 `get_legacy_visit_image_mask_planes` is used.
858 component
859 A component to read instead of the full image.
860 """
861 from lsst.afw.image import ExposureFitsReader
863 reader = ExposureFitsReader(filename)
864 if component == "bbox": 864 ↛ 865line 864 didn't jump to line 865 because the condition on line 864 was never true
865 return Box.from_legacy(reader.readBBox())
866 if component == "bounds": 866 ↛ 867line 866 didn't jump to line 867 because the condition on line 866 was never true
867 bbox = Box.from_legacy(reader.readBBox())
868 if (legacy_polygon := reader.readValidPolygon()) is None:
869 return bbox
870 # Clip to the image, as the VisitImage constructor does.
871 bounds = Polygon.from_legacy(legacy_polygon)
872 return bounds if bbox.contains(bounds.bbox) else bounds.intersection(bbox)
873 legacy_detector = reader.readDetector()
874 if legacy_detector is None: 874 ↛ 875line 874 didn't jump to line 875 because the condition on line 874 was never true
875 raise ValueError(f"Exposure file {filename!r} does not have a Detector.")
876 detector_bbox = Box.from_legacy(legacy_detector.getBBox())
877 legacy_wcs = None
878 if component in (None, "image", "mask", "variance", "sky_projection"):
879 legacy_wcs = reader.readWcs()
880 if legacy_wcs is None: 880 ↛ 881line 880 didn't jump to line 881 because the condition on line 880 was never true
881 raise ValueError(f"Exposure file {filename!r} does not have a SkyWcs.")
882 legacy_exposure_info = reader.readExposureInfo()
883 summary_stats = None
884 if component in (None, "summary_stats"):
885 legacy_stats = legacy_exposure_info.getSummaryStats()
886 if legacy_stats is not None:
887 summary_stats = ObservationSummaryStats.from_legacy(legacy_stats)
888 if component == "summary_stats": 888 ↛ 889line 888 didn't jump to line 889 because the condition on line 888 was never true
889 return summary_stats
890 if component in (None, "psf"):
891 legacy_psf = reader.readPsf()
892 if legacy_psf is None: 892 ↛ 893line 892 didn't jump to line 893 because the condition on line 892 was never true
893 raise ValueError(f"Exposure file {filename!r} does not have a Psf.")
894 psf = PointSpreadFunction.from_legacy(legacy_psf, bounds=detector_bbox)
895 if component == "psf": 895 ↛ 896line 895 didn't jump to line 896 because the condition on line 895 was never true
896 return psf
897 aperture_corrections: ApertureCorrectionMap = {}
898 if component in (None, "aperture_corrections"):
899 legacy_ap_corr_map = reader.readApCorrMap()
900 if legacy_ap_corr_map is not None: 900 ↛ 901line 900 didn't jump to line 901 because the condition on line 900 was never true
901 aperture_corrections = aperture_corrections_from_legacy(legacy_ap_corr_map)
902 if component == "aperture_corrections": 902 ↛ 903line 902 didn't jump to line 903 because the condition on line 902 was never true
903 return aperture_corrections
904 assert component in (
905 None,
906 "image",
907 "mask",
908 "variance",
909 "sky_projection",
910 "obs_info",
911 "detector",
912 "photometric_scaling",
913 "unit",
914 ), component # for MyPy
915 visit_info = legacy_exposure_info.getVisitInfo()
916 if visit_info is None: 916 ↛ 917line 916 didn't jump to line 917 because the condition on line 916 was never true
917 raise ValueError(f"Exposure file {filename!r} does not have a VisitInfo.")
918 filter_label = reader.readFilter()
919 with astropy.io.fits.open(filename) as hdu_list:
920 primary_header = hdu_list[0].header
921 telescope = primary_header.get("TELESCOP")
922 obs_info = obs_info_from_legacy(
923 visit_info,
924 exposure_record,
925 legacy_detector,
926 detector_exposure_id=legacy_exposure_info.getId(),
927 telescope=str(telescope) if telescope is not None else None,
928 physical_filter=filter_label.physicalLabel or None,
929 header=primary_header,
930 )
931 if exposure_record is not None:
932 instrument = exposure_record.instrument
933 visit = exposure_record.id
934 else:
935 instrument = visit_info.getInstrumentLabel()
936 visit = visit_info.getId()
937 if component == "obs_info":
938 if instrument == "LSSTCam":
939 obs_info = _fix_lsstcam_detector_serial(obs_info)
940 return obs_info
941 opaque_metadata = FitsOpaqueMetadata()
942 # This extraction is destructive, so we need to be sure to pass
943 # this opaque_metadata down to MaskedImage._read_legacy_hdus
944 # so it doesn't try to extract it again.
945 metadata = opaque_metadata.extract_legacy_primary_header(primary_header)
946 if component == "unit": 946 ↛ 947line 946 didn't jump to line 947 because the condition on line 946 was never true
947 if (fits_unit := hdu_list[1].header.get("BUNIT")) is None:
948 return None
949 return parse_legacy_bunit(fits_unit, opaque_metadata.get_instrumental_unit())
950 if (instrumental_unit := opaque_metadata.get_instrumental_unit()) is None: 950 ↛ 952line 950 didn't jump to line 952 because the condition on line 950 was always true
951 instrumental_unit = astropy.units.electron
952 photometric_scaling: Field | None = None
953 if component in (None, "photometric_scaling"): 953 ↛ 959line 953 didn't jump to line 959 because the condition on line 953 was always true
954 legacy_photo_calib = reader.readPhotoCalib()
955 if legacy_photo_calib is not None:
956 photometric_scaling = field_from_legacy_photo_calib(
957 legacy_photo_calib, bounds=detector_bbox, instrumental_unit=instrumental_unit
958 )
959 if component == "photometric_scaling": 959 ↛ 960line 959 didn't jump to line 960 because the condition on line 959 was never true
960 return photometric_scaling
961 if component in ("detector", None): 961 ↛ 969line 961 didn't jump to line 969 because the condition on line 961 was always true
962 detector = Detector.from_legacy(
963 legacy_detector, instrument=instrument, visit=visit, is_raw_assembled=True
964 )
965 if instrument == "LSSTCam":
966 obs_info = _fix_lsstcam_detector_serial(obs_info, detector)
967 if component == "detector": 967 ↛ 968line 967 didn't jump to line 968 because the condition on line 967 was never true
968 return detector
969 assert component != "detector", "MyPy can't work this out from the above."
970 sky_projection = SkyProjection.from_legacy(
971 legacy_wcs,
972 DetectorFrame(
973 instrument=instrument,
974 visit=visit,
975 detector=legacy_detector.getId(),
976 bbox=detector_bbox,
977 ),
978 )
979 if component == "sky_projection": 979 ↛ 980line 979 didn't jump to line 980 because the condition on line 979 was never true
980 return sky_projection
981 if plane_map is None: 981 ↛ 983line 981 didn't jump to line 983 because the condition on line 981 was always true
982 plane_map = get_legacy_visit_image_mask_planes()
983 from_masked_image = MaskedImage._read_legacy_hdus(
984 hdu_list,
985 filename,
986 opaque_metadata=opaque_metadata,
987 preserve_quantization=preserve_quantization,
988 plane_map=plane_map,
989 component=component,
990 )
991 if component is not None: 991 ↛ 994line 991 didn't jump to line 994 because the condition on line 991 was never true
992 # This is the image, mask, or variance; attach the sky_projection
993 # and obs_info and return
994 return from_masked_image.view(sky_projection=sky_projection)
995 legacy_polygon = reader.readValidPolygon()
996 result = VisitImage(
997 from_masked_image.image,
998 mask=from_masked_image.mask,
999 variance=from_masked_image.variance,
1000 sky_projection=sky_projection,
1001 psf=psf,
1002 detector=detector,
1003 obs_info=obs_info,
1004 summary_stats=summary_stats,
1005 aperture_corrections=aperture_corrections,
1006 bounds=Polygon.from_legacy(legacy_polygon) if legacy_polygon is not None else None,
1007 photometric_scaling=photometric_scaling,
1008 band=filter_label.bandLabel,
1009 metadata=metadata,
1010 )
1011 result._opaque_metadata = from_masked_image._opaque_metadata
1012 result._metadata["id"] = reader.readExposureId()
1013 return result
1016class VisitImageSerializationModel[P: pydantic.BaseModel](MaskedImageSerializationModel[P]):
1017 """A Pydantic model used to represent a serialized `VisitImage`."""
1019 SCHEMA_NAME: ClassVar[str] = "visit_image"
1020 SCHEMA_VERSION: ClassVar[str] = "1.1.0"
1021 MIN_READ_VERSION: ClassVar[int] = 1
1022 PUBLIC_TYPE: ClassVar[type] = VisitImage
1024 # Inherited attributes are duplicated because that improves the docs
1025 # (some limitation in the sphinx/pydantic integration), and these are
1026 # important docs.
1028 image: ImageSerializationModel[P] = pydantic.Field(description="The main data image.")
1029 mask: MaskSerializationModel[P] = pydantic.Field(
1030 description="Bitmask that annotates the main image's pixels."
1031 )
1032 variance: ImageSerializationModel[P] = pydantic.Field(
1033 description="Per-pixel variance estimates for the main image."
1034 )
1035 sky_projection: SkyProjectionSerializationModel[P] = pydantic.Field(
1036 description="Projection that maps the pixel grid to the sky.",
1037 )
1038 psf: PiffSerializationModel | PSFExSerializationModel | GaussianPSFSerializationModel | Any = (
1039 pydantic.Field(union_mode="left_to_right", description="PSF model for the image.")
1040 )
1041 obs_info: ObservationInfo = pydantic.Field(
1042 description="Standardized description of visit metadata",
1043 )
1044 photometric_scaling: FieldSerializationModel | None = pydantic.Field(
1045 default=None,
1046 description="Scaling that can be used to multiply a post-ISR image to yield calibrated pixel values.",
1047 )
1048 summary_stats: ObservationSummaryStats = pydantic.Field(
1049 description="Summary statistics for the observation."
1050 )
1051 detector: DetectorSerializationModel = pydantic.Field(
1052 description="Geometry and electronic information for the detector."
1053 )
1054 aperture_corrections: ApertureCorrectionMapSerializationModel = pydantic.Field(
1055 default_factory=ApertureCorrectionMapSerializationModel,
1056 description="Aperture corrections, keyed by flux algorithm.",
1057 )
1058 bounds: BoundsSerializationModel | None = pydantic.Field(
1059 default=None,
1060 description="Pixel validity region, if different from the image bounding box.",
1061 exclude_if=is_none,
1062 )
1063 backgrounds: BackgroundMapSerializationModel = pydantic.Field(
1064 default_factory=BackgroundMapSerializationModel,
1065 description="Background models associated with this image.",
1066 )
1067 band: str = pydantic.Field(description="Short name of the bandpass filter.")
1069 def deserialize(
1070 self, archive: InputArchive[Any], *, bbox: Box | None = None, **kwargs: Any
1071 ) -> VisitImage:
1072 if kwargs: 1072 ↛ 1073line 1072 didn't jump to line 1073 because the condition on line 1072 was never true
1073 raise InvalidParameterError(f"Unrecognized parameters for VisitImage: {set(kwargs.keys())}.")
1074 masked_image = super().deserialize(archive, bbox=bbox)
1075 try:
1076 psf = self.psf.deserialize(archive)
1077 except ArchiveReadError as err:
1078 # Defer this until/unless somebody actually asks for the PSF.
1079 psf = err
1080 detector = self.detector.deserialize(archive)
1081 aperture_corrections = self.aperture_corrections.deserialize(archive)
1082 photometric_scaling = (
1083 self.photometric_scaling.deserialize(archive) if self.photometric_scaling is not None else None
1084 )
1085 return VisitImage(
1086 masked_image.image,
1087 mask=masked_image.mask,
1088 variance=masked_image.variance,
1089 psf=psf,
1090 sky_projection=masked_image.sky_projection,
1091 obs_info=self.obs_info,
1092 summary_stats=self.summary_stats,
1093 detector=detector,
1094 aperture_corrections=aperture_corrections,
1095 photometric_scaling=photometric_scaling,
1096 bounds=self.bounds.deserialize() if self.bounds is not None else None,
1097 backgrounds=self.backgrounds.deserialize(archive),
1098 band=self.band,
1099 )._finish_deserialize(self)
1101 def deserialize_component(self, component: str, archive: InputArchive[Any], **kwargs: Any) -> Any:
1102 if kwargs and component not in ("image", "mask", "variance", "masked_image"): 1102 ↛ 1103line 1102 didn't jump to line 1103 because the condition on line 1102 was never true
1103 raise InvalidParameterError(
1104 f"Unsupported parameters for VisitImage component {component}: {set(kwargs.keys())}."
1105 )
1106 if component == "masked_image":
1107 return super().deserialize(archive, **kwargs)
1108 if component == "bounds":
1109 return self.bounds.deserialize() if self.bounds is not None else self.bbox
1110 return super().deserialize_component(component, archive, **kwargs)
1113# Serial numbers that were wrong in LSSTCam camera definitions before the
1114# DM-55080 fix: the R24 and R34 serial blocks were swapped, and one ITL sensor
1115# had a stale serial. Keys are detector IDs, values are correct serial.
1116_DM55080_DETECTOR_SERIALS: dict[int, str] = {
1117 108: "E2V-CCD250-140",
1118 109: "E2V-CCD250-314",
1119 110: "E2V-CCD250-302",
1120 111: "E2V-CCD250-298",
1121 112: "E2V-CCD250-305",
1122 113: "E2V-CCD250-318",
1123 114: "E2V-CCD250-304",
1124 115: "E2V-CCD250-301",
1125 116: "E2V-CCD250-385",
1126 153: "E2V-CCD250-160",
1127 154: "E2V-CCD250-411",
1128 155: "E2V-CCD250-256",
1129 156: "E2V-CCD250-253",
1130 157: "E2V-CCD250-194",
1131 158: "E2V-CCD250-231",
1132 159: "E2V-CCD250-224",
1133 160: "E2V-CCD250-189",
1134 161: "E2V-CCD250-134",
1135 173: "ITL-3800C-327",
1136}
1139def _fix_lsstcam_detector_serial(
1140 obs_info: ObservationInfo, detector: Detector | None = None
1141) -> ObservationInfo:
1142 assert obs_info.detector_num is not None
1143 expected = _DM55080_DETECTOR_SERIALS.get(obs_info.detector_num)
1144 if expected is None: 1144 ↛ 1146line 1144 didn't jump to line 1146 because the condition on line 1144 was always true
1145 return obs_info
1146 if detector is not None and detector.serial != expected:
1147 _LOG.warning(
1148 "Detector %d has camera-geometry serial %s, which predates the"
1149 " DM-55080 camera-definition fix; using %s.",
1150 detector.id,
1151 detector.serial,
1152 expected,
1153 )
1154 detector._attributes.serial = expected
1155 if obs_info.detector_serial != expected:
1156 with obs_info.edit_copy() as obs_info:
1157 obs_info.detector_serial = expected
1158 return obs_info
1161def _get_unit_conversion_factor(
1162 original: astropy.units.UnitBase, new: astropy.units.UnitBase
1163) -> float | None:
1164 try:
1165 return original.to(new)
1166 except astropy.units.UnitConversionError:
1167 return None