Coverage for python/lsst/images/cameras.py: 61%
290 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-29 15:33 -0700
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-29 15:33 -0700
1# This file is part of lsst-images.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# Use of this source code is governed by a 3-clause BSD-style
10# license that can be found in the LICENSE file.
11from __future__ import annotations
13__all__ = (
14 "Amplifier",
15 "AmplifierCalibrations",
16 "AmplifierRawGeometry",
17 "Detector",
18 "DetectorAttributes",
19 "DetectorSerializationModel",
20 "DetectorType",
21 "Orientation",
22 "ReadoutCorner",
23)
25import builtins
26import enum
27from collections.abc import Iterable
28from typing import TYPE_CHECKING, Any, ClassVar, final
30import astropy.units
31import numpy as np
32import pydantic
34from ._geom import YX, Box
35from ._transforms import (
36 CameraFrameSet,
37 CameraFrameSetSerializationModel,
38 DetectorFrame,
39 FieldAngleFrame,
40 FocalPlaneFrame,
41 Transform,
42)
43from .serialization import (
44 ArchiveReadError,
45 ArchiveTree,
46 InlineArray,
47 InputArchive,
48 InvalidParameterError,
49 OutputArchive,
50 Quantity,
51)
53if TYPE_CHECKING:
54 try:
55 from lsst.afw.cameraGeom import Amplifier as LegacyAmplifier
56 from lsst.afw.cameraGeom import Detector as LegacyDetector
57 from lsst.afw.cameraGeom import DetectorType as LegacyDetectorType
58 from lsst.afw.cameraGeom import Orientation as LegacyOrientation
59 from lsst.afw.cameraGeom import ReadoutCorner as LegacyReadoutCorner
60 except ImportError:
61 type LegacyDetector = Any # type: ignore[no-redef]
62 type LegacyDetectorType = Any # type: ignore[no-redef]
63 type LegacyOrientation = Any # type: ignore[no-redef]
64 type LegacyReadoutCorner = Any # type: ignore[no-redef]
65 type LegacyAmplifier = Any # type: ignore[no-redef]
68class DetectorType(enum.StrEnum):
69 """Enumeration of the types of a detector."""
71 SCIENCE = "SCIENCE"
72 FOCUS = "FOCUS"
73 GUIDER = "GUIDER"
74 WAVEFRONT = "WAVEFRONT"
76 def to_legacy(self) -> LegacyDetectorType:
77 """Convert to `lsst.afw.cameraGeom.DetectorType`."""
78 from lsst.afw.cameraGeom import DetectorType as LegacyDetectorType
80 return getattr(LegacyDetectorType, self.value)
82 @classmethod
83 def from_legacy(cls, legacy_detector_type: LegacyDetectorType) -> DetectorType:
84 """Convert from `lsst.afw.cameraGeom.DetectorType`.
86 Parameters
87 ----------
88 legacy_detector_type
89 Legacy detector type to convert.
90 """
91 return getattr(cls, legacy_detector_type.name)
94@final
95class Orientation(pydantic.BaseModel, ser_json_inf_nan="constants"):
96 """A struct that represents the nominal position and rotation of a
97 detector within a camera focal plane.
98 """
100 focal_plane_x: float = pydantic.Field(description="Focal plane X coordinate of the reference position.")
101 focal_plane_y: float = pydantic.Field(description="Focal plane Y coordinate of the reference position.")
102 focal_plane_z: float = pydantic.Field(description="Focal plane Z coordinate of the reference position.")
103 pixel_reference_x: float = pydantic.Field(0.5, description="Pixel X coordinate of the reference point.")
104 pixel_reference_y: float = pydantic.Field(0.5, description="Pixel Y coordinate of the reference point.")
105 yaw: Quantity = pydantic.Field(
106 default_factory=lambda: 0.0 * astropy.units.radian,
107 description="Rotation about the Z axis.",
108 )
109 pitch: Quantity = pydantic.Field(
110 default_factory=lambda: 0.0 * astropy.units.radian,
111 description="Rotation about the Y axis (as defined after applying 'yaw').",
112 )
113 roll: Quantity = pydantic.Field(
114 default_factory=lambda: 0.0 * astropy.units.radian,
115 description="Rotation about the X axis (as defined after applying 'yaw' and 'pitch').",
116 )
118 def to_legacy(self) -> LegacyOrientation:
119 """Convert to `lsst.afw.cameraGeom.Orientation`."""
120 from lsst.afw.cameraGeom import Orientation as LegacyOrientation
121 from lsst.geom import Point2D, Point3D, radians
123 return LegacyOrientation(
124 Point3D(self.focal_plane_x, self.focal_plane_y, self.focal_plane_z),
125 Point2D(self.pixel_reference_x, self.pixel_reference_y),
126 self.yaw.to_value(astropy.units.radian) * radians,
127 self.pitch.to_value(astropy.units.radian) * radians,
128 self.roll.to_value(astropy.units.radian) * radians,
129 )
131 @staticmethod
132 def from_legacy(legacy_orientation: LegacyOrientation) -> Orientation:
133 """Convert from `lsst.afw.cameraGeom.Orientation`.
135 Parameters
136 ----------
137 legacy_orientation
138 Legacy orientation to convert.
139 """
140 focal_plane_x, focal_plane_y, focal_plane_z = legacy_orientation.getFpPosition3()
141 pixel_reference_x, pixel_reference_y = legacy_orientation.getReferencePoint()
142 return Orientation(
143 focal_plane_x=focal_plane_x,
144 focal_plane_y=focal_plane_y,
145 focal_plane_z=focal_plane_z,
146 pixel_reference_x=pixel_reference_x,
147 pixel_reference_y=pixel_reference_y,
148 yaw=legacy_orientation.getYaw().asRadians() * astropy.units.radian,
149 pitch=legacy_orientation.getPitch().asRadians() * astropy.units.radian,
150 roll=legacy_orientation.getRoll().asRadians() * astropy.units.radian,
151 )
154@final
155class DetectorAttributes(pydantic.BaseModel, ser_json_inf_nan="constants"):
156 """Struct holding the plain-old-data attributes of a detector."""
158 name: str = pydantic.Field(description="Name of the detector.")
159 id: int = pydantic.Field(description="ID of the detector.")
160 type: DetectorType = pydantic.Field(description="Enumerated type of the detector.")
161 serial: str = pydantic.Field(description="Serial number for the detector.")
162 bbox: Box = pydantic.Field(
163 description="Bounding box of the detector's science data region after amplifier assembly."
164 )
165 orientation: Orientation = pydantic.Field(description="Nominal position and rotation of the detector.")
166 pixel_size: float = pydantic.Field(
167 description="Nominal size of a pixel (assumed square) in focal plane coordinate units."
168 )
169 physical_type: str = pydantic.Field(
170 description=(
171 "Vendor name or technology type for this detector "
172 "(may have a different interpretation for different cameras)."
173 )
174 )
177class ReadoutCorner(enum.StrEnum):
178 """Enumeration of the possible readout corners of an amplifier."""
180 LL = "LL"
181 LR = "LR"
182 UR = "UR"
183 UL = "UL"
185 def to_legacy(self) -> LegacyReadoutCorner:
186 """Convert to `lsst.afw.cameraGeom.ReadoutCorner`."""
187 from lsst.afw.cameraGeom import ReadoutCorner as LegacyReadoutCorner
189 return getattr(LegacyReadoutCorner, self.value)
191 @classmethod
192 def from_legacy(cls, legacy_readout_corner: LegacyReadoutCorner) -> ReadoutCorner:
193 """Convert from `lsst.afw.cameraGeom.ReadoutCorner`.
195 Parameters
196 ----------
197 legacy_readout_corner
198 Legacy readout corner to convert.
199 """
200 return getattr(cls, legacy_readout_corner.name)
202 def as_flips(self) -> YX[bool]:
203 """Return a tuple indicating how the image needs to be flipped to
204 bring the readout corner to ``LL``.
205 """
206 return YX(
207 y=self is ReadoutCorner.UL or self is ReadoutCorner.UR,
208 x=self is ReadoutCorner.LR or self is ReadoutCorner.UR,
209 )
211 @classmethod
212 def from_flips(cls, *, y: bool, x: bool) -> ReadoutCorner:
213 """Construct from booleans indicating how the image needs to be
214 flipped to bring the readout corner to ``LL``.
216 Parameters
217 ----------
218 y
219 Whether the image is flipped in the y direction.
220 x
221 Whether the image is flipped in the x direction.
222 """
223 match y, x:
224 case False, False:
225 return cls.LL
226 case False, True:
227 return cls.LR
228 case True, True:
229 return cls.UR
230 case True, False: 230 ↛ 232line 230 didn't jump to line 232 because the pattern on line 230 always matched
231 return cls.UL
232 raise TypeError(f"Invalid arguments: y={y}, x={x} (expected booleans).")
234 def apply_flips(self, *, y: bool, x: bool) -> ReadoutCorner:
235 """Return the new readout corner after applying the given flips.
237 Parameters
238 ----------
239 y
240 Whether to flip in the y direction.
241 x
242 Whether to flip in the x direction.
243 """
244 current = self.as_flips()
245 return self.from_flips(y=current.y ^ y, x=current.x ^ x)
248@final
249class AmplifierRawGeometry(pydantic.BaseModel):
250 """A struct that describes the geometry of an amplifire in a raw image."""
252 bbox: Box = pydantic.Field(description="Bounding box of the full untrimmed amplifier in the raw image.")
253 data_bbox: Box = pydantic.Field(description="Bounding box of the data section in the raw image.")
254 flip_x: bool = pydantic.Field(False, description="Whether to flip the X coordinates during assembly.")
255 flip_y: bool = pydantic.Field(False, description="Whether to flip the Y coordinates during assembly.")
256 x_offset: int = pydantic.Field(
257 0,
258 description=(
259 "X offset between the raw position of this amplifier and the trimmed, "
260 "assembled position of the amplifier."
261 ),
262 )
263 y_offset: int = pydantic.Field(
264 0,
265 description=(
266 "Y offset between the raw position of this amplifier and the trimmed, "
267 "assembled position of the amplifier."
268 ),
269 )
270 serial_overscan_bbox: Box = pydantic.Field(
271 description="Bounding box of the serial (horizontal) overscan region in the raw image."
272 )
273 parallel_overscan_bbox: Box = pydantic.Field(
274 description="Bounding box of the parallel (vertical) overscan region in the raw image."
275 )
276 prescan_bbox: Box = pydantic.Field(
277 description="Bounding box of the serial (horizontal) pre-scan region in the raw image."
278 )
279 readout_corner: ReadoutCorner = pydantic.Field(
280 description=(
281 "Readout corner of the amplifier in the raw image "
282 "(with x increasing to the right and y increasing up)."
283 )
284 )
286 @property
287 def horizontal_overscan_bbox(self) -> Box:
288 """Bounding box of the serial (horizon) overscan region in the raw
289 image (`.Box`).
290 """
291 return self.serial_overscan_bbox
293 @horizontal_overscan_bbox.setter
294 def horizontal_overscan_bbox(self, value: Box) -> None:
295 self.serial_overscan_bbox = value
297 @property
298 def vertical_overscan_bbox(self) -> Box:
299 """Bounding box of the parallel (vertical) overscan region in the raw
300 image (`.Box`).
301 """
302 return self.parallel_overscan_bbox
304 @vertical_overscan_bbox.setter
305 def vertical_overscan_bbox(self, value: Box) -> None:
306 self.parallel_overscan_bbox = value
308 @property
309 def horizontal_prescan_bbox(self) -> Box:
310 """Bounding box of the serial (horizon) prescan region in the raw
311 image (`.Box`).
312 """
313 return self.prescan_bbox
315 @horizontal_prescan_bbox.setter
316 def horizontal_prescan_bbox(self, value: Box) -> None:
317 self.prescan_bbox = value
319 @property
320 def serial_prescan_bbox(self) -> Box:
321 """Bounding box of the serial (horizon) prescan region in the raw
322 image (`.Box`).
323 """
324 return self.prescan_bbox
326 @serial_prescan_bbox.setter
327 def serial_prescan_bbox(self, value: Box) -> None:
328 self.prescan_bbox = value
330 @staticmethod
331 def from_legacy_amplifier(legacy_amplifier: LegacyAmplifier) -> AmplifierRawGeometry:
332 """Convert from a `lsst.afw.cameraGeom.Amplifier`.
334 Parameters
335 ----------
336 legacy_amplifier
337 Legacy amplifier to convert.
338 """
339 x_offset, y_offset = legacy_amplifier.getRawXYOffset()
340 return AmplifierRawGeometry(
341 bbox=Box.from_legacy(legacy_amplifier.getRawBBox()),
342 data_bbox=Box.from_legacy(legacy_amplifier.getRawDataBBox()),
343 flip_x=legacy_amplifier.getRawFlipX(),
344 flip_y=legacy_amplifier.getRawFlipY(),
345 x_offset=x_offset,
346 y_offset=y_offset,
347 serial_overscan_bbox=Box.from_legacy(legacy_amplifier.getRawSerialOverscanBBox()),
348 parallel_overscan_bbox=Box.from_legacy(legacy_amplifier.getRawParallelOverscanBBox()),
349 prescan_bbox=Box.from_legacy(legacy_amplifier.getRawPrescanBBox()),
350 readout_corner=ReadoutCorner.from_legacy(legacy_amplifier.getReadoutCorner()),
351 )
354@final
355class AmplifierCalibrations(pydantic.BaseModel, ser_json_inf_nan="constants"):
356 """A struct that holds nominal information about an amplifier that is
357 often superseded by separate calibration datasets.
358 """
360 gain: float
361 read_noise: float
362 saturation: float
363 suspect_level: float
364 linearity_coefficients: InlineArray
365 linearity_type: str
367 def __eq__(self, other: object) -> bool:
368 if type(other) is not AmplifierCalibrations: 368 ↛ 369line 368 didn't jump to line 369 because the condition on line 368 was never true
369 return NotImplemented
370 # ``suspect_level`` is a float whose "unset" sentinel is ``NaN``;
371 # treat NaN==NaN as equal here so a round-tripped calibration
372 # block does not spuriously compare unequal to its source.
373 return (
374 self.gain == other.gain
375 and self.read_noise == other.read_noise
376 and self.saturation == other.saturation
377 and (
378 self.suspect_level == other.suspect_level
379 or (np.isnan(self.suspect_level) and np.isnan(other.suspect_level))
380 )
381 and np.array_equal(self.linearity_coefficients, other.linearity_coefficients)
382 and self.linearity_type == other.linearity_type
383 )
385 @staticmethod
386 def from_legacy_amplifier(legacy_amplifier: LegacyAmplifier) -> AmplifierCalibrations:
387 """Convert from a `lsst.afw.cameraGeom.Amplifier`.
389 Parameters
390 ----------
391 legacy_amplifier
392 Legacy amplifier to convert.
393 """
394 return AmplifierCalibrations(
395 gain=legacy_amplifier.getGain(),
396 read_noise=legacy_amplifier.getReadNoise(),
397 saturation=legacy_amplifier.getSaturation(),
398 suspect_level=legacy_amplifier.getSuspectLevel(),
399 linearity_coefficients=legacy_amplifier.getLinearityCoeffs(),
400 linearity_type=legacy_amplifier.getLinearityType(),
401 )
404@final
405class Amplifier(pydantic.BaseModel, ser_json_inf_nan="constants"):
406 """A struct that holds information about an amplifier."""
408 name: str = pydantic.Field(description="Name of the amplifier.")
409 bbox: Box = pydantic.Field(
410 description="Bounding box of the amplifier data region in a trimmed, assembled detector."
411 )
412 readout_corner: ReadoutCorner = pydantic.Field(
413 description=(
414 "Readout corner of the amplifier in the final assembled, trimmed "
415 "image (with x increasing to the right and y increasing up). "
416 )
417 )
418 assembled_raw_geometry: AmplifierRawGeometry | None = pydantic.Field(
419 None,
420 description=(
421 "Geometry of this amplifier in an assembled but untrimmed raw image that has all amplifiers."
422 ),
423 )
424 unassembled_raw_geometry: AmplifierRawGeometry | None = pydantic.Field(
425 None,
426 description=(
427 "Geometry of this amplifier in an unassembled, untrimmed raw image that has just this amplifier."
428 ),
429 )
430 nominal_calibrations: AmplifierCalibrations | None = pydantic.Field(
431 None,
432 description=(
433 "Nominal calibration information that may be superseded by separate calibration datasets."
434 ),
435 )
437 def to_legacy_builder(self, is_raw_assembled: bool) -> LegacyAmplifier.Builder:
438 """Convert to a `lsst.afw.cameraGeom.Amplifier.Builder`.
440 Parameters
441 ----------
442 is_raw_assembled
443 Whether to use `Amplifier.assembled_raw_geometry` (`True`) or
444 `Amplifier.unassembled_raw_geometry` (`False`). If `None`, this
445 is set to ``self.visit is not None``, since we expect to only add
446 a visit ID to detectors that have been assembled.
447 """
448 from lsst.afw.cameraGeom import Amplifier as LegacyAmplifier
449 from lsst.geom import Extent2I
451 builder = LegacyAmplifier.Builder()
452 builder.setName(self.name)
453 builder.setBBox(self.bbox.to_legacy())
454 if is_raw_assembled:
455 if (raw_geom := self.assembled_raw_geometry) is None:
456 raise ValueError(
457 f"is_raw_assembled=True but assembled_raw_geometry is None for amp {self.name}."
458 )
459 else:
460 if (raw_geom := self.unassembled_raw_geometry) is None:
461 raise ValueError(
462 f"is_raw_assembled=False but unassembled_raw_geometry is None for amp {self.name}."
463 )
464 # The afw readout corner definition corresponds to the image it is
465 # attached to (which might be a raw), not the final trimmed image
466 # (despite the docs, until a change on this ticket).
467 builder.setReadoutCorner(raw_geom.readout_corner.to_legacy())
468 builder.setRawBBox(raw_geom.bbox.to_legacy())
469 builder.setRawDataBBox(raw_geom.data_bbox.to_legacy())
470 builder.setRawFlipX(raw_geom.flip_x)
471 builder.setRawFlipY(raw_geom.flip_y)
472 builder.setRawXYOffset(Extent2I(raw_geom.x_offset, raw_geom.y_offset))
473 builder.setRawSerialOverscanBBox(raw_geom.serial_overscan_bbox.to_legacy())
474 builder.setRawParallelOverscanBBox(raw_geom.parallel_overscan_bbox.to_legacy())
475 builder.setRawPrescanBBox(raw_geom.prescan_bbox.to_legacy())
476 if self.nominal_calibrations is not None:
477 builder.setGain(self.nominal_calibrations.gain)
478 builder.setReadNoise(self.nominal_calibrations.read_noise)
479 builder.setSaturation(self.nominal_calibrations.saturation)
480 builder.setSuspectLevel(self.nominal_calibrations.suspect_level)
481 builder.setLinearityCoeffs(self.nominal_calibrations.linearity_coefficients)
482 builder.setLinearityType(self.nominal_calibrations.linearity_type)
483 return builder
485 @staticmethod
486 def from_legacy(legacy_amplifier: LegacyAmplifier, is_raw_assembled: bool) -> Amplifier:
487 """Convert from a `lsst.afw.cameraGeom.Amplifier`.
489 Parameters
490 ----------
491 legacy_amplifier
492 Legacy amplifier to convert.
493 is_raw_assembled
494 Whether to populate `Amplifier.assembled_raw_geometry` (`True`) or
495 `Amplifier.unassembled_raw_geometry` (`False`).
496 """
497 raw_geometry = AmplifierRawGeometry.from_legacy_amplifier(legacy_amplifier)
498 nominal_calibrations = AmplifierCalibrations.from_legacy_amplifier(legacy_amplifier)
499 readout_corner = raw_geometry.readout_corner.apply_flips(y=raw_geometry.flip_y, x=raw_geometry.flip_x)
500 return Amplifier(
501 name=legacy_amplifier.getName(),
502 bbox=Box.from_legacy(legacy_amplifier.getBBox()),
503 readout_corner=readout_corner,
504 assembled_raw_geometry=raw_geometry if is_raw_assembled else None,
505 unassembled_raw_geometry=raw_geometry if not is_raw_assembled else None,
506 nominal_calibrations=nominal_calibrations,
507 )
510@final
511class Detector:
512 """Information about a detector in a camera.
514 Parameters
515 ----------
516 attributes
517 Identifying attributes and metadata for the detector.
518 amplifiers
519 Amplifiers that make up the detector.
520 frames
521 Coordinate systems and transforms for the camera.
522 visit
523 Visit number whose geometry to use, or `None` for the nominal
524 detector geometry.
525 """
527 def __init__(
528 self,
529 attributes: DetectorAttributes,
530 amplifiers: Iterable[Amplifier],
531 frames: CameraFrameSet,
532 visit: int | None = None,
533 ) -> None:
534 self._attributes = attributes
535 self._amplifiers = list(amplifiers)
536 self._frames = frames
537 self._frame = frames.detector(attributes.id, visit=visit)
539 def __eq__(self, other: object) -> bool:
540 if type(other) is not Detector: 540 ↛ 541line 540 didn't jump to line 541 because the condition on line 540 was never true
541 return NotImplemented
542 return (
543 self._attributes == other._attributes
544 and self._amplifiers == other._amplifiers
545 and self._frames == other._frames
546 and self.visit == other.visit
547 )
549 __hash__ = None # type: ignore[assignment]
551 @property
552 def instrument(self) -> str:
553 """The name of the instrument this detector belongs to (`str`)."""
554 return self._frame.instrument
556 @property
557 def visit(self) -> int | None:
558 """The ID of the visit this detector is associated with (`int` or
559 `None`).
560 """
561 return self._frame.visit
563 @property
564 def name(self) -> str:
565 """Name of the detector (`str`)."""
566 return self._attributes.name
568 @property
569 def id(self) -> int:
570 """ID of the detector (`int`)."""
571 return self._attributes.id
573 @property
574 def type(self) -> DetectorType:
575 """Enumerated type of the detector (`DetectorType`)."""
576 return self._attributes.type
578 @property
579 def serial(self) -> str:
580 """Serial number for the detector (`str`)."""
581 return self._attributes.serial
583 @property
584 def bbox(self) -> Box:
585 """Bounding box of the detector's science data region after amplifier
586 assembly (`.Box`).
587 """
588 return self._attributes.bbox
590 @property
591 def orientation(self) -> Orientation:
592 """Nominal position and rotation of the detector
593 (`Orientation`).
594 """
595 return self._attributes.orientation
597 @property
598 def pixel_size(self) -> float:
599 """Nominal size of a pixel (assumed square) in focal plane coordinate
600 units (`float`).
601 """
602 return self._attributes.pixel_size
604 @property
605 def physical_type(self) -> str:
606 """Vendor name or technology type for this detector (`str`).
608 This may have a different interpretation for different cameras.
609 """
610 return self._attributes.physical_type
612 @property
613 def frame(self) -> DetectorFrame:
614 """The coordinate system of this detector's trimmed, assembled pixel
615 grid (`.DetectorFrame`).
616 """
617 return self._frame
619 @property
620 def to_focal_plane(self) -> Transform[DetectorFrame, FocalPlaneFrame]:
621 """The transform from pixels to focal-plane coordinates
622 (`.Transform` [`.DetectorFrame`, `.FocalPlaneFrame`]).
623 """
624 return self._frames[self._frame, self._frames.focal_plane(self.visit)]
626 @property
627 def to_field_angle(self) -> Transform[DetectorFrame, FieldAngleFrame]:
628 """The transform from pixels to field angle coordinates
629 (`.Transform` [`.DetectorFrame`, `.FieldAngleFrame`]).
630 """
631 return self._frames[self._frame, self._frames.field_angle(self.visit)]
633 @property
634 def amplifiers(self) -> list[Amplifier]:
635 """The amplifiers of this detectors (`list` [`Amplifier`])."""
636 return self._amplifiers
638 def copy(self) -> Detector:
639 """Copy the detector.
641 This deep-copies all data fields and amplifiers, but only
642 shallow-copies the internal `.CameraFrameSet`, as that's conceptually
643 immutable.
644 """
645 return Detector(
646 self._attributes.model_copy(deep=True),
647 amplifiers=[a.model_copy(deep=True) for a in self._amplifiers],
648 frames=self._frames,
649 )
651 def serialize(self, archive: OutputArchive[Any], save_frames: bool = True) -> DetectorSerializationModel:
652 """Serialize this detector to an archive.
654 Parameters
655 ----------
656 archive
657 Archive to save to.
658 save_frames
659 Whether to save the `.CameraFrameSet` held by this detector. This
660 allows the frame set to be saved once for multiple detectors when
661 they are part of a multi-detector object.
662 """
663 return DetectorSerializationModel(
664 attributes=self._attributes,
665 amplifiers=self._amplifiers,
666 frames=archive.serialize_direct("frames", self._frames.serialize) if save_frames else None,
667 visit=self.visit,
668 )
670 @staticmethod
671 def _get_archive_tree_type(
672 pointer_type: builtins.type[Any],
673 ) -> builtins.type[DetectorSerializationModel]:
674 """Return the serialization model type for this object for an archive
675 type that uses the given pointer type.
676 """
677 return DetectorSerializationModel
679 def to_legacy(self, *, is_raw_assembled: bool | None = None) -> LegacyDetector:
680 """Convert to a legacy `lsst.afw.cameraGeom.Detector` instance.
682 Parameters
683 ----------
684 is_raw_assembled
685 Whether to use `Amplifier.assembled_raw_geometry` (`True`) or
686 `Amplifier.unassembled_raw_geometry` (`False`). If `None`, this
687 is set to ``self.visit is not None``, since we expect to only add
688 a visit ID to detectors that have been assembled.
689 """
690 from lsst.afw.cameraGeom import FIELD_ANGLE, FOCAL_PLANE, Camera
691 from lsst.geom import Extent2D, Point2D
693 if is_raw_assembled is None:
694 is_raw_assembled = self.visit is not None
695 # Legacy Detectors can only be built from scratch as a part of a
696 # camera.
697 camera_builder = Camera.Builder(self.name)
698 fp_to_fa = self._frames[self._frames.focal_plane(), self._frames.field_angle()]
699 legacy_fp_to_fa = fp_to_fa.to_legacy()
700 camera_builder.setFocalPlaneParity(np.linalg.det(legacy_fp_to_fa.getJacobian(Point2D(0.0, 0.0))) < 0)
701 camera_builder.setTransformFromFocalPlaneTo(FIELD_ANGLE, legacy_fp_to_fa)
702 detector_builder = camera_builder.add(self.name, self.id)
703 detector_builder.setBBox(self.bbox.to_legacy())
704 detector_builder.setType(self.type.to_legacy())
705 detector_builder.setSerial(self.serial)
706 detector_builder.setPhysicalType(self.physical_type)
707 detector_builder.setOrientation(self.orientation.to_legacy())
708 detector_builder.setPixelSize(Extent2D(self.pixel_size, self.pixel_size))
709 detector_builder.setTransformFromPixelsTo(FOCAL_PLANE, self.to_focal_plane.to_legacy())
710 for amp in self.amplifiers:
711 try:
712 detector_builder.append(amp.to_legacy_builder(is_raw_assembled))
713 except Exception as err:
714 err.add_note(f"On detector {self.id}/{self.name}.")
715 raise
716 camera = camera_builder.finish()
717 return camera[self.id]
719 @staticmethod
720 def from_legacy(
721 legacy_detector: LegacyDetector,
722 *,
723 instrument: str,
724 visit: int | None = None,
725 is_raw_assembled: bool | None = None,
726 ) -> Detector:
727 """Convert from a legacy `lsst.afw.cameraGeom.Detector` instance.
729 Parameters
730 ----------
731 legacy_detector
732 Legacy detector to convert.
733 instrument
734 Name of the instrument this detector belongs to.
735 visit
736 Visit ID, if this camera geometry can be associated with a
737 particular visit.
738 is_raw_assembled
739 Whether to populate `Amplifier.assembled_raw_geometry` (`True`) or
740 `Amplifier.unassembled_raw_geometry` (`False`). If `None`, this
741 is set to ``visit is not None``, since we expect to only add
742 a visit ID to detectors that have been assembled.
743 """
744 if is_raw_assembled is None:
745 is_raw_assembled = visit is not None
746 attributes = DetectorAttributes(
747 name=legacy_detector.getName(),
748 id=legacy_detector.getId(),
749 type=DetectorType.from_legacy(legacy_detector.getType()),
750 bbox=Box.from_legacy(legacy_detector.getBBox()),
751 serial=legacy_detector.getSerial(),
752 orientation=Orientation.from_legacy(legacy_detector.getOrientation()),
753 pixel_size=legacy_detector.getPixelSize().getX(),
754 physical_type=legacy_detector.getPhysicalType(),
755 )
756 amplifiers = [
757 Amplifier.from_legacy(legacy_amp, is_raw_assembled=is_raw_assembled)
758 for legacy_amp in legacy_detector.getAmplifiers()
759 ]
760 transform_map = legacy_detector.getTransformMap()
761 frames = CameraFrameSet(instrument, transform_map.makeFrameSet([legacy_detector]))
762 return Detector(attributes, amplifiers, frames, visit=visit)
765class DetectorSerializationModel(ArchiveTree):
766 """Serialization model for `Detector`."""
768 SCHEMA_NAME: ClassVar[str] = "detector"
769 SCHEMA_VERSION: ClassVar[str] = "1.0.0"
770 MIN_READ_VERSION: ClassVar[int] = 1
771 PUBLIC_TYPE: ClassVar[type] = Detector
773 attributes: DetectorAttributes = pydantic.Field(
774 description="The simple plain-old-data attributes of the detector."
775 )
777 amplifiers: list[Amplifier] = pydantic.Field(
778 default_factory=list,
779 description="Descriptions of the amplifiers.",
780 )
782 frames: CameraFrameSetSerializationModel | None = pydantic.Field(
783 default=None, description="Mappings to other camera coordinate systems."
784 )
786 visit: int | None = pydantic.Field(description="ID of the visit this detector is associated with.")
788 def deserialize(
789 self, archive: InputArchive[Any], frames: CameraFrameSet | None = None, **kwargs: Any
790 ) -> Detector:
791 """Deserialize this detector from an archive.
793 Parameters
794 ----------
795 archive
796 Serialization model instance for this detector.
797 frames
798 Coordinate systems and transforms to use instead of what is saved
799 in ``model``. Must be provided if ``model.frames`` is `None`.
800 **kwargs
801 Unsupported keyword arguments are accepted only to provide
802 better error messages (raising
803 `.serialization.InvalidParameterError`).
804 """
805 if kwargs: 805 ↛ 806line 805 didn't jump to line 806 because the condition on line 805 was never true
806 raise InvalidParameterError(f"Unrecognized parameters for Detector: {set(kwargs.keys())}.")
807 if frames is None: 807 ↛ 814line 807 didn't jump to line 814 because the condition on line 807 was always true
808 if self.frames is None: 808 ↛ 809line 808 didn't jump to line 809 because the condition on line 808 was never true
809 raise ArchiveReadError(
810 "Serialized detector did not include coordinate transforms, "
811 "and 'frames' was not provided."
812 )
813 frames = self.frames.deserialize(archive)
814 return Detector(self.attributes, self.amplifiers, frames, visit=self.visit)