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