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