Coverage for python / lsst / images / cameras.py: 49%
277 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-27 01:31 -0700
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-27 01:31 -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, 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`."""
85 return getattr(cls, legacy_detector_type.name)
88@final
89class Orientation(pydantic.BaseModel, ser_json_inf_nan="constants"):
90 """A struct that represents the nominal position and rotation of a
91 detector within a camera focal plane.
92 """
94 focal_plane_x: float = pydantic.Field(description="Focal plane X coordinate of the reference position.")
95 focal_plane_y: float = pydantic.Field(description="Focal plane Y coordinate of the reference position.")
96 focal_plane_z: float = pydantic.Field(description="Focal plane Z coordinate of the reference position.")
97 pixel_reference_x: float = pydantic.Field(0.5, description="Pixel X coordinate of the reference point.")
98 pixel_reference_y: float = pydantic.Field(0.5, description="Pixel Y coordinate of the reference point.")
99 yaw: Quantity = pydantic.Field(
100 default_factory=lambda: 0.0 * astropy.units.radian,
101 description="Rotation about the Z axis.",
102 )
103 pitch: Quantity = pydantic.Field(
104 default_factory=lambda: 0.0 * astropy.units.radian,
105 description="Rotation about the Y axis (as defined after applying 'yaw').",
106 )
107 roll: Quantity = pydantic.Field(
108 default_factory=lambda: 0.0 * astropy.units.radian,
109 description="Rotation about the X axis (as defined after applying 'yaw' and 'pitch').",
110 )
112 def to_legacy(self) -> LegacyOrientation:
113 """Convert to `lsst.afw.cameraGeom.Orientation`."""
114 from lsst.afw.cameraGeom import Orientation as LegacyOrientation
115 from lsst.geom import Point2D, Point3D, radians
117 return LegacyOrientation(
118 Point3D(self.focal_plane_x, self.focal_plane_y, self.focal_plane_z),
119 Point2D(self.pixel_reference_x, self.pixel_reference_y),
120 self.yaw.to_value(astropy.units.radian) * radians,
121 self.pitch.to_value(astropy.units.radian) * radians,
122 self.roll.to_value(astropy.units.radian) * radians,
123 )
125 @staticmethod
126 def from_legacy(legacy_orientation: LegacyOrientation) -> Orientation:
127 """Convert from `lsst.afw.cameraGeom.Orientation`."""
128 focal_plane_x, focal_plane_y, focal_plane_z = legacy_orientation.getFpPosition3()
129 pixel_reference_x, pixel_reference_y = legacy_orientation.getReferencePoint()
130 return Orientation(
131 focal_plane_x=focal_plane_x,
132 focal_plane_y=focal_plane_y,
133 focal_plane_z=focal_plane_z,
134 pixel_reference_x=pixel_reference_x,
135 pixel_reference_y=pixel_reference_y,
136 yaw=legacy_orientation.getYaw().asRadians() * astropy.units.radian,
137 pitch=legacy_orientation.getPitch().asRadians() * astropy.units.radian,
138 roll=legacy_orientation.getRoll().asRadians() * astropy.units.radian,
139 )
142@final
143class DetectorAttributes(pydantic.BaseModel, ser_json_inf_nan="constants"):
144 """Struct holding the plain-old-data attributes of a detector."""
146 name: str = pydantic.Field(description="Name of the detector.")
147 id: int = pydantic.Field(description="ID of the detector.")
148 type: DetectorType = pydantic.Field(description="Enumerated type of the detector.")
149 serial: str = pydantic.Field(description="Serial number for the detector.")
150 bbox: Box = pydantic.Field(
151 description="Bounding box of the detector's science data region after amplifier assembly."
152 )
153 orientation: Orientation = pydantic.Field(description="Nominal position and rotation of the detector.")
154 pixel_size: float = pydantic.Field(
155 description="Nominal size of a pixel (assumed square) in focal plane coordinate units."
156 )
157 physical_type: str = pydantic.Field(
158 description=(
159 "Vendor name or technology type for this detector "
160 "(may have a different interpretation for different cameras)."
161 )
162 )
165class ReadoutCorner(enum.StrEnum):
166 """Enumeration of the possible readout corners of an amplifier."""
168 LL = "LL"
169 LR = "LR"
170 UR = "UR"
171 UL = "UL"
173 def to_legacy(self) -> LegacyReadoutCorner:
174 """Convert to `lsst.afw.cameraGeom.ReadoutCorner`."""
175 from lsst.afw.cameraGeom import ReadoutCorner as LegacyReadoutCorner
177 return getattr(LegacyReadoutCorner, self.value)
179 @classmethod
180 def from_legacy(cls, legacy_readout_corner: LegacyReadoutCorner) -> ReadoutCorner:
181 """Convert from `lsst.afw.cameraGeom.ReadoutCorner`."""
182 return getattr(cls, legacy_readout_corner.name)
184 def as_flips(self) -> YX[bool]:
185 """Return a tuple indicating how the image needs to be flipped to
186 bring the readout corner to ``LL``.
187 """
188 return YX(
189 y=self is ReadoutCorner.LL or self is ReadoutCorner.LR,
190 x=self is ReadoutCorner.UR or self is ReadoutCorner.UR,
191 )
193 @classmethod
194 def from_flips(cls, *, y: bool, x: bool) -> ReadoutCorner:
195 """Construct from booleans indicating how the image needs to be
196 flipped to bring the readout corner to ``LL``.
197 """
198 match y, x:
199 case False, False:
200 return cls.LL
201 case False, True:
202 return cls.LR
203 case True, True:
204 return cls.UR
205 case True, False:
206 return cls.UL
207 raise TypeError(f"Invalid arguments: y={y}, x={x} (expected booleans).")
209 def apply_flips(self, *, y: bool, x: bool) -> ReadoutCorner:
210 """Return the new readout corner after applying the given flips."""
211 current = self.as_flips()
212 return self.from_flips(y=current.y ^ y, x=current.x ^ x)
215@final
216class AmplifierRawGeometry(pydantic.BaseModel):
217 """A struct that describes the geometry of an amplifire in a raw image."""
219 bbox: Box = pydantic.Field(description="Bounding box of the full untrimmed amplifier in the raw image.")
220 data_bbox: Box = pydantic.Field(description="Bounding box of the data section in the raw image.")
221 flip_x: bool = pydantic.Field(False, description="Whether to flip the X coordinates during assembly.")
222 flip_y: bool = pydantic.Field(False, description="Whether to flip the Y coordinates during assembly.")
223 x_offset: int = pydantic.Field(
224 0,
225 description=(
226 "X offset between the raw position of this amplifier and the trimmed, "
227 "assembled position of the amplifier."
228 ),
229 )
230 y_offset: int = pydantic.Field(
231 0,
232 description=(
233 "Y offset between the raw position of this amplifier and the trimmed, "
234 "assembled position of the amplifier."
235 ),
236 )
237 serial_overscan_bbox: Box = pydantic.Field(
238 description="Bounding box of the serial (horizontal) overscan region in the raw image."
239 )
240 parallel_overscan_bbox: Box = pydantic.Field(
241 description="Bounding box of the parallel (vertical) overscan region in the raw image."
242 )
243 prescan_bbox: Box = pydantic.Field(
244 description="Bounding box of the serial (horizontal) pre-scan region in the raw image."
245 )
246 readout_corner: ReadoutCorner = pydantic.Field(
247 description=(
248 "Readout corner of the amplifier in the raw image "
249 "(with x increasing to the right and y increasing up)."
250 )
251 )
253 @property
254 def horizontal_overscan_bbox(self) -> Box:
255 """Bounding box of the serial (horizon) overscan region in the raw
256 image (`.Box`).
257 """
258 return self.serial_overscan_bbox
260 @horizontal_overscan_bbox.setter
261 def horizontal_overscan_bbox(self, value: Box) -> None:
262 self.serial_overscan_bbox = value
264 @property
265 def vertical_overscan_bbox(self) -> Box:
266 """Bounding box of the parallel (vertical) overscan region in the raw
267 image (`.Box`).
268 """
269 return self.parallel_overscan_bbox
271 @vertical_overscan_bbox.setter
272 def vertical_overscan_bbox(self, value: Box) -> None:
273 self.parallel_overscan_bbox = value
275 @property
276 def horizontal_prescan_bbox(self) -> Box:
277 """Bounding box of the serial (horizon) prescan region in the raw
278 image (`.Box`).
279 """
280 return self.prescan_bbox
282 @horizontal_prescan_bbox.setter
283 def horizontal_prescan_bbox(self, value: Box) -> None:
284 self.prescan_bbox = value
286 @property
287 def serial_prescan_bbox(self) -> Box:
288 """Bounding box of the serial (horizon) prescan region in the raw
289 image (`.Box`).
290 """
291 return self.prescan_bbox
293 @serial_prescan_bbox.setter
294 def serial_prescan_bbox(self, value: Box) -> None:
295 self.prescan_bbox = value
297 @staticmethod
298 def from_legacy_amplifier(legacy_amplifier: LegacyAmplifier) -> AmplifierRawGeometry:
299 """Convert from a `lsst.afw.cameraGeom.Amplifier`.
301 Parameters
302 ----------
303 legacy_amplifier
304 Legacy amplifier to convert.
305 """
306 x_offset, y_offset = legacy_amplifier.getRawXYOffset()
307 return AmplifierRawGeometry(
308 bbox=Box.from_legacy(legacy_amplifier.getRawBBox()),
309 data_bbox=Box.from_legacy(legacy_amplifier.getRawDataBBox()),
310 flip_x=legacy_amplifier.getRawFlipX(),
311 flip_y=legacy_amplifier.getRawFlipY(),
312 x_offset=x_offset,
313 y_offset=y_offset,
314 serial_overscan_bbox=Box.from_legacy(legacy_amplifier.getRawSerialOverscanBBox()),
315 parallel_overscan_bbox=Box.from_legacy(legacy_amplifier.getRawParallelOverscanBBox()),
316 prescan_bbox=Box.from_legacy(legacy_amplifier.getRawPrescanBBox()),
317 readout_corner=ReadoutCorner.from_legacy(legacy_amplifier.getReadoutCorner()),
318 )
321@final
322class AmplifierCalibrations(pydantic.BaseModel, ser_json_inf_nan="constants"):
323 """A struct that holds nominal information about an amplifier that is
324 often superseded by separate calibration datasets.
325 """
327 gain: float
328 read_noise: float
329 saturation: float
330 suspect_level: float
331 linearity_coefficients: InlineArray
332 linearity_type: str
334 @staticmethod
335 def from_legacy_amplifier(legacy_amplifier: LegacyAmplifier) -> AmplifierCalibrations:
336 """Convert from a `lsst.afw.cameraGeom.Amplifier`.
338 Parameters
339 ----------
340 legacy_amplifier
341 Legacy amplifier to convert.
342 """
343 return AmplifierCalibrations(
344 gain=legacy_amplifier.getGain(),
345 read_noise=legacy_amplifier.getReadNoise(),
346 saturation=legacy_amplifier.getSaturation(),
347 suspect_level=legacy_amplifier.getSuspectLevel(),
348 linearity_coefficients=legacy_amplifier.getLinearityCoeffs(),
349 linearity_type=legacy_amplifier.getLinearityType(),
350 )
353@final
354class Amplifier(pydantic.BaseModel, ser_json_inf_nan="constants"):
355 """A struct that holds information about an amplifier."""
357 name: str = pydantic.Field(description="Name of the amplifier.")
358 bbox: Box = pydantic.Field(
359 description="Bounding box of the amplifier data region in a trimmed, assembled detector."
360 )
361 readout_corner: ReadoutCorner = pydantic.Field(
362 description=(
363 "Readout corner of the amplifier in the final assembled, trimmed "
364 "image (with x increasing to the right and y increasing up). "
365 )
366 )
367 assembled_raw_geometry: AmplifierRawGeometry | None = pydantic.Field(
368 None,
369 description=(
370 "Geometry of this amplifier in an assembled but untrimmed raw image that has all amplifiers."
371 ),
372 )
373 unassembled_raw_geometry: AmplifierRawGeometry | None = pydantic.Field(
374 None,
375 description=(
376 "Geometry of this amplifier in an unassembled, untrimmed raw image that has just this amplifier."
377 ),
378 )
379 nominal_calibrations: AmplifierCalibrations | None = pydantic.Field(
380 None,
381 description=(
382 "Nominal calibration information that may be superseded by separate calibration datasets."
383 ),
384 )
386 def to_legacy_builder(self, is_raw_assembled: bool) -> LegacyAmplifier.Builder:
387 """Convert to a `lsst.afw.cameraGeom.Amplifier.Builder`.
389 Parameters
390 ----------
391 is_raw_assembled
392 Whether to use `Amplifier.assembled_raw_geometry` (`True`) or
393 `Amplifier.unassembled_raw_geometry` (`False`). If `None`, this
394 is set to ``self.visit is not None``, since we expect to only add
395 a visit ID to detectors that have been assembled.
396 """
397 from lsst.afw.cameraGeom import Amplifier as LegacyAmplifier
398 from lsst.geom import Extent2I
400 builder = LegacyAmplifier.Builder()
401 builder.setName(self.name)
402 builder.setBBox(self.bbox.to_legacy())
403 if is_raw_assembled:
404 if (raw_geom := self.assembled_raw_geometry) is None:
405 raise ValueError(
406 f"is_raw_assembled=True but assembled_raw_geometry is None for amp {self.name}."
407 )
408 else:
409 if (raw_geom := self.unassembled_raw_geometry) is None:
410 raise ValueError(
411 f"is_raw_assembled=False but unassembled_raw_geometry is None for amp {self.name}."
412 )
413 # The afw readout corner definition corresponds to the image it is
414 # attached to (which might be a raw), not the final trimmed image
415 # (despite the docs, until a change on this ticket).
416 builder.setReadoutCorner(raw_geom.readout_corner.to_legacy())
417 builder.setRawBBox(raw_geom.bbox.to_legacy())
418 builder.setRawDataBBox(raw_geom.data_bbox.to_legacy())
419 builder.setRawFlipX(raw_geom.flip_x)
420 builder.setRawFlipY(raw_geom.flip_y)
421 builder.setRawXYOffset(Extent2I(raw_geom.x_offset, raw_geom.y_offset))
422 builder.setRawSerialOverscanBBox(raw_geom.serial_overscan_bbox.to_legacy())
423 builder.setRawParallelOverscanBBox(raw_geom.parallel_overscan_bbox.to_legacy())
424 builder.setRawPrescanBBox(raw_geom.prescan_bbox.to_legacy())
425 if self.nominal_calibrations is not None:
426 builder.setGain(self.nominal_calibrations.gain)
427 builder.setReadNoise(self.nominal_calibrations.read_noise)
428 builder.setSaturation(self.nominal_calibrations.saturation)
429 builder.setSuspectLevel(self.nominal_calibrations.suspect_level)
430 builder.setLinearityCoeffs(self.nominal_calibrations.linearity_coefficients)
431 builder.setLinearityType(self.nominal_calibrations.linearity_type)
432 return builder
434 @staticmethod
435 def from_legacy(legacy_amplifier: LegacyAmplifier, is_raw_assembled: bool) -> Amplifier:
436 """Convert from a `lsst.afw.cameraGeom.Amplifier`.
438 Parameters
439 ----------
440 legacy_amplifier
441 Legacy amplifier to convert.
442 is_raw_assembled
443 Whether to populate `Amplifier.assembled_raw_geometry` (`True`) or
444 `Amplifier.unassembled_raw_geometry` (`False`).
445 """
446 raw_geometry = AmplifierRawGeometry.from_legacy_amplifier(legacy_amplifier)
447 nominal_calibrations = AmplifierCalibrations.from_legacy_amplifier(legacy_amplifier)
448 readout_corner = raw_geometry.readout_corner.apply_flips(y=raw_geometry.flip_y, x=raw_geometry.flip_x)
449 return Amplifier(
450 name=legacy_amplifier.getName(),
451 bbox=Box.from_legacy(legacy_amplifier.getBBox()),
452 readout_corner=readout_corner,
453 assembled_raw_geometry=raw_geometry if is_raw_assembled else None,
454 unassembled_raw_geometry=raw_geometry if not is_raw_assembled else None,
455 nominal_calibrations=nominal_calibrations,
456 )
459@final
460class Detector:
461 """Information about a detector in a camera."""
463 def __init__(
464 self,
465 attributes: DetectorAttributes,
466 amplifiers: Iterable[Amplifier],
467 frames: CameraFrameSet,
468 visit: int | None = None,
469 ):
470 self._attributes = attributes
471 self._amplifiers = list(amplifiers)
472 self._frames = frames
473 self._frame = frames.detector(attributes.id, visit=visit)
475 @property
476 def instrument(self) -> str:
477 """The name of the instrument this detector belongs to (`str`)."""
478 return self._frame.instrument
480 @property
481 def visit(self) -> int | None:
482 """The ID of the visit this detector is associated with (`int` or
483 `None`).
484 """
485 return self._frame.visit
487 @property
488 def name(self) -> str:
489 """Name of the detector (`str`)."""
490 return self._attributes.name
492 @property
493 def id(self) -> int:
494 """ID of the detector (`int`)."""
495 return self._attributes.id
497 @property
498 def type(self) -> DetectorType:
499 """Enumerated type of the detector (`DetectorType`)."""
500 return self._attributes.type
502 @property
503 def serial(self) -> str:
504 """Serial number for the detector (`str`)."""
505 return self._attributes.serial
507 @property
508 def bbox(self) -> Box:
509 """Bounding box of the detector's science data region after amplifier
510 assembly (`.Box`).
511 """
512 return self._attributes.bbox
514 @property
515 def orientation(self) -> Orientation:
516 """Nominal position and rotation of the detector
517 (`Orientation`).
518 """
519 return self._attributes.orientation
521 @property
522 def pixel_size(self) -> float:
523 """Nominal size of a pixel (assumed square) in focal plane coordinate
524 units (`float`).
525 """
526 return self._attributes.pixel_size
528 @property
529 def physical_type(self) -> str:
530 """Vendor name or technology type for this detector (`str`).
532 This may have a different interpretation for different cameras.
533 """
534 return self._attributes.physical_type
536 @property
537 def frame(self) -> DetectorFrame:
538 """The coordinate system of this detector's trimmed, assembled pixel
539 grid (`.DetectorFrame`).
540 """
541 return self._frame
543 @property
544 def to_focal_plane(self) -> Transform[DetectorFrame, FocalPlaneFrame]:
545 """The transform from pixels to focal-plane coordinates
546 (`.Transform` [`.DetectorFrame`, `.FocalPlaneFrame`]).
547 """
548 return self._frames[self._frame, self._frames.focal_plane(self.visit)]
550 @property
551 def to_field_angle(self) -> Transform[DetectorFrame, FieldAngleFrame]:
552 """The transform from pixels to field angle coordinates
553 (`.Transform` [`.DetectorFrame`, `.FieldAngleFrame`]).
554 """
555 return self._frames[self._frame, self._frames.field_angle(self.visit)]
557 @property
558 def amplifiers(self) -> list[Amplifier]:
559 """The amplifiers of this detectors (`list` [`Amplifier`])."""
560 return self._amplifiers
562 def copy(self) -> Detector:
563 """Copy the detector.
565 This deep-copies all data fields and amplifiers, but only
566 shallow-copies the internal `.CameraFrameSet`, as that's conceptually
567 immutable.
568 """
569 return Detector(
570 self._attributes.model_copy(deep=True),
571 amplifiers=[a.model_copy(deep=True) for a in self._amplifiers],
572 frames=self._frames,
573 )
575 def serialize(self, archive: OutputArchive[Any], save_frames: bool = True) -> DetectorSerializationModel:
576 """Serialize this detector to an archive.
578 Parameters
579 ----------
580 archive
581 Archive to save to.
582 save_frames
583 Whether to save the `.CameraFrameSet` held by this detector. This
584 allows the frame set to be saved once for multiple detectors when
585 they are part of a multi-detector object.
586 """
587 return DetectorSerializationModel(
588 attributes=self._attributes,
589 amplifiers=self._amplifiers,
590 frames=archive.serialize_direct("frames", self._frames.serialize) if save_frames else None,
591 visit=self.visit,
592 )
594 @staticmethod
595 def _get_archive_tree_type(
596 pointer_type: builtins.type[Any],
597 ) -> builtins.type[DetectorSerializationModel]:
598 """Return the serialization model type for this object for an archive
599 type that uses the given pointer type.
600 """
601 return DetectorSerializationModel # type: ignore
603 def to_legacy(self, *, is_raw_assembled: bool | None = None) -> LegacyDetector:
604 """Convert to a legacy `lsst.afw.cameraGeom.Detector` instance.
606 Parameters
607 ----------
608 is_raw_assembled
609 Whether to use `Amplifier.assembled_raw_geometry` (`True`) or
610 `Amplifier.unassembled_raw_geometry` (`False`). If `None`, this
611 is set to ``self.visit is not None``, since we expect to only add
612 a visit ID to detectors that have been assembled.
613 """
614 from lsst.afw.cameraGeom import FIELD_ANGLE, FOCAL_PLANE, Camera
615 from lsst.geom import Extent2D, Point2D
617 if is_raw_assembled is None:
618 is_raw_assembled = self.visit is not None
619 # Legacy Detectors can only be built from scratch as a part of a
620 # camera.
621 camera_builder = Camera.Builder(self.name)
622 fp_to_fa = self._frames[self._frames.focal_plane(), self._frames.field_angle()]
623 legacy_fp_to_fa = fp_to_fa.to_legacy()
624 camera_builder.setFocalPlaneParity(np.linalg.det(legacy_fp_to_fa.getJacobian(Point2D(0.0, 0.0))) < 0)
625 camera_builder.setTransformFromFocalPlaneTo(FIELD_ANGLE, legacy_fp_to_fa)
626 detector_builder = camera_builder.add(self.name, self.id)
627 detector_builder.setBBox(self.bbox.to_legacy())
628 detector_builder.setType(self.type.to_legacy())
629 detector_builder.setSerial(self.serial)
630 detector_builder.setPhysicalType(self.physical_type)
631 detector_builder.setOrientation(self.orientation.to_legacy())
632 detector_builder.setPixelSize(Extent2D(self.pixel_size, self.pixel_size))
633 detector_builder.setTransformFromPixelsTo(FOCAL_PLANE, self.to_focal_plane.to_legacy())
634 for amp in self.amplifiers:
635 try:
636 detector_builder.append(amp.to_legacy_builder(is_raw_assembled))
637 except Exception as err:
638 err.add_note(f"On detector {self.id}/{self.name}.")
639 raise
640 camera = camera_builder.finish()
641 return camera[self.id]
643 @staticmethod
644 def from_legacy(
645 legacy_detector: LegacyDetector,
646 *,
647 instrument: str,
648 visit: int | None = None,
649 is_raw_assembled: bool | None = None,
650 ) -> Detector:
651 """Convert from a legacy `lsst.afw.cameraGeom.Detector` instance.
653 Parameters
654 ----------
655 legacy_detector
656 Legacy detector to convert.
657 instrument
658 Name of the instrument this detector belongs to.
659 visit
660 Visit ID, if this camera geometry can be associated with a
661 particular visit.
662 is_raw_assembled
663 Whether to populate `Amplifier.assembled_raw_geometry` (`True`) or
664 `Amplifier.unassembled_raw_geometry` (`False`). If `None`, this
665 is set to ``visit is not None``, since we expect to only add
666 a visit ID to detectors that have been assembled.
667 """
668 if is_raw_assembled is None:
669 is_raw_assembled = visit is not None
670 attributes = DetectorAttributes(
671 name=legacy_detector.getName(),
672 id=legacy_detector.getId(),
673 type=DetectorType.from_legacy(legacy_detector.getType()),
674 bbox=Box.from_legacy(legacy_detector.getBBox()),
675 serial=legacy_detector.getSerial(),
676 orientation=Orientation.from_legacy(legacy_detector.getOrientation()),
677 pixel_size=legacy_detector.getPixelSize().getX(),
678 physical_type=legacy_detector.getPhysicalType(),
679 )
680 amplifiers = [
681 Amplifier.from_legacy(legacy_amp, is_raw_assembled=is_raw_assembled)
682 for legacy_amp in legacy_detector.getAmplifiers()
683 ]
684 transform_map = legacy_detector.getTransformMap()
685 frames = CameraFrameSet(instrument, transform_map.makeFrameSet([legacy_detector]))
686 return Detector(attributes, amplifiers, frames, visit=visit)
689class DetectorSerializationModel(ArchiveTree):
690 """Serialization model for `Detector`."""
692 attributes: DetectorAttributes = pydantic.Field(
693 description="The simple plain-old-data attributes of the detector."
694 )
696 amplifiers: list[Amplifier] = pydantic.Field(
697 default_factory=list,
698 description="Descriptions of the amplifiers.",
699 )
701 frames: CameraFrameSetSerializationModel | None = pydantic.Field(
702 default=None, description="Mappings to other camera coordinate systems."
703 )
705 visit: int | None = pydantic.Field(description="ID of the visit this detector is associated with.")
707 def deserialize(
708 self, archive: InputArchive[Any], frames: CameraFrameSet | None = None, **kwargs: Any
709 ) -> Detector:
710 """Deserialize this detector from an archive.
712 Parameters
713 ----------
714 model
715 Serialization model instance for this detector.
716 frames
717 Coordinate systems and transforms to use instead of what is saved
718 in ``model``. Must be provided if ``model.frames`` is `None`.
719 """
720 if kwargs:
721 raise InvalidParameterError(f"Unrecognized parameters for Detector: {set(kwargs.keys())}.")
722 if frames is None:
723 if self.frames is None:
724 raise ArchiveReadError(
725 "Serialized detector did not include coordinate transforms, "
726 "and 'frames' was not provided."
727 )
728 frames = self.frames.deserialize(archive)
729 return Detector(self.attributes, self.amplifiers, frames, visit=self.visit)