Coverage for python/lsst/images/cameras.py: 48%
286 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-05-29 01:48 -0700
« prev ^ index » next coverage.py v7.14.1, created at 2026-05-29 01:48 -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 def __eq__(self, other: object) -> bool:
335 if type(other) is not AmplifierCalibrations:
336 return NotImplemented
337 # ``suspect_level`` is a float whose "unset" sentinel is ``NaN``;
338 # treat NaN==NaN as equal here so a round-tripped calibration
339 # block does not spuriously compare unequal to its source.
340 return (
341 self.gain == other.gain
342 and self.read_noise == other.read_noise
343 and self.saturation == other.saturation
344 and (
345 self.suspect_level == other.suspect_level
346 or (np.isnan(self.suspect_level) and np.isnan(other.suspect_level))
347 )
348 and np.array_equal(self.linearity_coefficients, other.linearity_coefficients)
349 and self.linearity_type == other.linearity_type
350 )
352 @staticmethod
353 def from_legacy_amplifier(legacy_amplifier: LegacyAmplifier) -> AmplifierCalibrations:
354 """Convert from a `lsst.afw.cameraGeom.Amplifier`.
356 Parameters
357 ----------
358 legacy_amplifier
359 Legacy amplifier to convert.
360 """
361 return AmplifierCalibrations(
362 gain=legacy_amplifier.getGain(),
363 read_noise=legacy_amplifier.getReadNoise(),
364 saturation=legacy_amplifier.getSaturation(),
365 suspect_level=legacy_amplifier.getSuspectLevel(),
366 linearity_coefficients=legacy_amplifier.getLinearityCoeffs(),
367 linearity_type=legacy_amplifier.getLinearityType(),
368 )
371@final
372class Amplifier(pydantic.BaseModel, ser_json_inf_nan="constants"):
373 """A struct that holds information about an amplifier."""
375 name: str = pydantic.Field(description="Name of the amplifier.")
376 bbox: Box = pydantic.Field(
377 description="Bounding box of the amplifier data region in a trimmed, assembled detector."
378 )
379 readout_corner: ReadoutCorner = pydantic.Field(
380 description=(
381 "Readout corner of the amplifier in the final assembled, trimmed "
382 "image (with x increasing to the right and y increasing up). "
383 )
384 )
385 assembled_raw_geometry: AmplifierRawGeometry | None = pydantic.Field(
386 None,
387 description=(
388 "Geometry of this amplifier in an assembled but untrimmed raw image that has all amplifiers."
389 ),
390 )
391 unassembled_raw_geometry: AmplifierRawGeometry | None = pydantic.Field(
392 None,
393 description=(
394 "Geometry of this amplifier in an unassembled, untrimmed raw image that has just this amplifier."
395 ),
396 )
397 nominal_calibrations: AmplifierCalibrations | None = pydantic.Field(
398 None,
399 description=(
400 "Nominal calibration information that may be superseded by separate calibration datasets."
401 ),
402 )
404 def to_legacy_builder(self, is_raw_assembled: bool) -> LegacyAmplifier.Builder:
405 """Convert to a `lsst.afw.cameraGeom.Amplifier.Builder`.
407 Parameters
408 ----------
409 is_raw_assembled
410 Whether to use `Amplifier.assembled_raw_geometry` (`True`) or
411 `Amplifier.unassembled_raw_geometry` (`False`). If `None`, this
412 is set to ``self.visit is not None``, since we expect to only add
413 a visit ID to detectors that have been assembled.
414 """
415 from lsst.afw.cameraGeom import Amplifier as LegacyAmplifier
416 from lsst.geom import Extent2I
418 builder = LegacyAmplifier.Builder()
419 builder.setName(self.name)
420 builder.setBBox(self.bbox.to_legacy())
421 if is_raw_assembled:
422 if (raw_geom := self.assembled_raw_geometry) is None:
423 raise ValueError(
424 f"is_raw_assembled=True but assembled_raw_geometry is None for amp {self.name}."
425 )
426 else:
427 if (raw_geom := self.unassembled_raw_geometry) is None:
428 raise ValueError(
429 f"is_raw_assembled=False but unassembled_raw_geometry is None for amp {self.name}."
430 )
431 # The afw readout corner definition corresponds to the image it is
432 # attached to (which might be a raw), not the final trimmed image
433 # (despite the docs, until a change on this ticket).
434 builder.setReadoutCorner(raw_geom.readout_corner.to_legacy())
435 builder.setRawBBox(raw_geom.bbox.to_legacy())
436 builder.setRawDataBBox(raw_geom.data_bbox.to_legacy())
437 builder.setRawFlipX(raw_geom.flip_x)
438 builder.setRawFlipY(raw_geom.flip_y)
439 builder.setRawXYOffset(Extent2I(raw_geom.x_offset, raw_geom.y_offset))
440 builder.setRawSerialOverscanBBox(raw_geom.serial_overscan_bbox.to_legacy())
441 builder.setRawParallelOverscanBBox(raw_geom.parallel_overscan_bbox.to_legacy())
442 builder.setRawPrescanBBox(raw_geom.prescan_bbox.to_legacy())
443 if self.nominal_calibrations is not None:
444 builder.setGain(self.nominal_calibrations.gain)
445 builder.setReadNoise(self.nominal_calibrations.read_noise)
446 builder.setSaturation(self.nominal_calibrations.saturation)
447 builder.setSuspectLevel(self.nominal_calibrations.suspect_level)
448 builder.setLinearityCoeffs(self.nominal_calibrations.linearity_coefficients)
449 builder.setLinearityType(self.nominal_calibrations.linearity_type)
450 return builder
452 @staticmethod
453 def from_legacy(legacy_amplifier: LegacyAmplifier, is_raw_assembled: bool) -> Amplifier:
454 """Convert from a `lsst.afw.cameraGeom.Amplifier`.
456 Parameters
457 ----------
458 legacy_amplifier
459 Legacy amplifier to convert.
460 is_raw_assembled
461 Whether to populate `Amplifier.assembled_raw_geometry` (`True`) or
462 `Amplifier.unassembled_raw_geometry` (`False`).
463 """
464 raw_geometry = AmplifierRawGeometry.from_legacy_amplifier(legacy_amplifier)
465 nominal_calibrations = AmplifierCalibrations.from_legacy_amplifier(legacy_amplifier)
466 readout_corner = raw_geometry.readout_corner.apply_flips(y=raw_geometry.flip_y, x=raw_geometry.flip_x)
467 return Amplifier(
468 name=legacy_amplifier.getName(),
469 bbox=Box.from_legacy(legacy_amplifier.getBBox()),
470 readout_corner=readout_corner,
471 assembled_raw_geometry=raw_geometry if is_raw_assembled else None,
472 unassembled_raw_geometry=raw_geometry if not is_raw_assembled else None,
473 nominal_calibrations=nominal_calibrations,
474 )
477@final
478class Detector:
479 """Information about a detector in a camera."""
481 def __init__(
482 self,
483 attributes: DetectorAttributes,
484 amplifiers: Iterable[Amplifier],
485 frames: CameraFrameSet,
486 visit: int | None = None,
487 ):
488 self._attributes = attributes
489 self._amplifiers = list(amplifiers)
490 self._frames = frames
491 self._frame = frames.detector(attributes.id, visit=visit)
493 def __eq__(self, other: object) -> bool:
494 if type(other) is not Detector:
495 return NotImplemented
496 return (
497 self._attributes == other._attributes
498 and self._amplifiers == other._amplifiers
499 and self._frames == other._frames
500 and self.visit == other.visit
501 )
503 __hash__ = None # type: ignore[assignment]
505 @property
506 def instrument(self) -> str:
507 """The name of the instrument this detector belongs to (`str`)."""
508 return self._frame.instrument
510 @property
511 def visit(self) -> int | None:
512 """The ID of the visit this detector is associated with (`int` or
513 `None`).
514 """
515 return self._frame.visit
517 @property
518 def name(self) -> str:
519 """Name of the detector (`str`)."""
520 return self._attributes.name
522 @property
523 def id(self) -> int:
524 """ID of the detector (`int`)."""
525 return self._attributes.id
527 @property
528 def type(self) -> DetectorType:
529 """Enumerated type of the detector (`DetectorType`)."""
530 return self._attributes.type
532 @property
533 def serial(self) -> str:
534 """Serial number for the detector (`str`)."""
535 return self._attributes.serial
537 @property
538 def bbox(self) -> Box:
539 """Bounding box of the detector's science data region after amplifier
540 assembly (`.Box`).
541 """
542 return self._attributes.bbox
544 @property
545 def orientation(self) -> Orientation:
546 """Nominal position and rotation of the detector
547 (`Orientation`).
548 """
549 return self._attributes.orientation
551 @property
552 def pixel_size(self) -> float:
553 """Nominal size of a pixel (assumed square) in focal plane coordinate
554 units (`float`).
555 """
556 return self._attributes.pixel_size
558 @property
559 def physical_type(self) -> str:
560 """Vendor name or technology type for this detector (`str`).
562 This may have a different interpretation for different cameras.
563 """
564 return self._attributes.physical_type
566 @property
567 def frame(self) -> DetectorFrame:
568 """The coordinate system of this detector's trimmed, assembled pixel
569 grid (`.DetectorFrame`).
570 """
571 return self._frame
573 @property
574 def to_focal_plane(self) -> Transform[DetectorFrame, FocalPlaneFrame]:
575 """The transform from pixels to focal-plane coordinates
576 (`.Transform` [`.DetectorFrame`, `.FocalPlaneFrame`]).
577 """
578 return self._frames[self._frame, self._frames.focal_plane(self.visit)]
580 @property
581 def to_field_angle(self) -> Transform[DetectorFrame, FieldAngleFrame]:
582 """The transform from pixels to field angle coordinates
583 (`.Transform` [`.DetectorFrame`, `.FieldAngleFrame`]).
584 """
585 return self._frames[self._frame, self._frames.field_angle(self.visit)]
587 @property
588 def amplifiers(self) -> list[Amplifier]:
589 """The amplifiers of this detectors (`list` [`Amplifier`])."""
590 return self._amplifiers
592 def copy(self) -> Detector:
593 """Copy the detector.
595 This deep-copies all data fields and amplifiers, but only
596 shallow-copies the internal `.CameraFrameSet`, as that's conceptually
597 immutable.
598 """
599 return Detector(
600 self._attributes.model_copy(deep=True),
601 amplifiers=[a.model_copy(deep=True) for a in self._amplifiers],
602 frames=self._frames,
603 )
605 def serialize(self, archive: OutputArchive[Any], save_frames: bool = True) -> DetectorSerializationModel:
606 """Serialize this detector to an archive.
608 Parameters
609 ----------
610 archive
611 Archive to save to.
612 save_frames
613 Whether to save the `.CameraFrameSet` held by this detector. This
614 allows the frame set to be saved once for multiple detectors when
615 they are part of a multi-detector object.
616 """
617 return DetectorSerializationModel(
618 attributes=self._attributes,
619 amplifiers=self._amplifiers,
620 frames=archive.serialize_direct("frames", self._frames.serialize) if save_frames else None,
621 visit=self.visit,
622 )
624 @staticmethod
625 def _get_archive_tree_type(
626 pointer_type: builtins.type[Any],
627 ) -> builtins.type[DetectorSerializationModel]:
628 """Return the serialization model type for this object for an archive
629 type that uses the given pointer type.
630 """
631 return DetectorSerializationModel # type: ignore
633 def to_legacy(self, *, is_raw_assembled: bool | None = None) -> LegacyDetector:
634 """Convert to a legacy `lsst.afw.cameraGeom.Detector` instance.
636 Parameters
637 ----------
638 is_raw_assembled
639 Whether to use `Amplifier.assembled_raw_geometry` (`True`) or
640 `Amplifier.unassembled_raw_geometry` (`False`). If `None`, this
641 is set to ``self.visit is not None``, since we expect to only add
642 a visit ID to detectors that have been assembled.
643 """
644 from lsst.afw.cameraGeom import FIELD_ANGLE, FOCAL_PLANE, Camera
645 from lsst.geom import Extent2D, Point2D
647 if is_raw_assembled is None:
648 is_raw_assembled = self.visit is not None
649 # Legacy Detectors can only be built from scratch as a part of a
650 # camera.
651 camera_builder = Camera.Builder(self.name)
652 fp_to_fa = self._frames[self._frames.focal_plane(), self._frames.field_angle()]
653 legacy_fp_to_fa = fp_to_fa.to_legacy()
654 camera_builder.setFocalPlaneParity(np.linalg.det(legacy_fp_to_fa.getJacobian(Point2D(0.0, 0.0))) < 0)
655 camera_builder.setTransformFromFocalPlaneTo(FIELD_ANGLE, legacy_fp_to_fa)
656 detector_builder = camera_builder.add(self.name, self.id)
657 detector_builder.setBBox(self.bbox.to_legacy())
658 detector_builder.setType(self.type.to_legacy())
659 detector_builder.setSerial(self.serial)
660 detector_builder.setPhysicalType(self.physical_type)
661 detector_builder.setOrientation(self.orientation.to_legacy())
662 detector_builder.setPixelSize(Extent2D(self.pixel_size, self.pixel_size))
663 detector_builder.setTransformFromPixelsTo(FOCAL_PLANE, self.to_focal_plane.to_legacy())
664 for amp in self.amplifiers:
665 try:
666 detector_builder.append(amp.to_legacy_builder(is_raw_assembled))
667 except Exception as err:
668 err.add_note(f"On detector {self.id}/{self.name}.")
669 raise
670 camera = camera_builder.finish()
671 return camera[self.id]
673 @staticmethod
674 def from_legacy(
675 legacy_detector: LegacyDetector,
676 *,
677 instrument: str,
678 visit: int | None = None,
679 is_raw_assembled: bool | None = None,
680 ) -> Detector:
681 """Convert from a legacy `lsst.afw.cameraGeom.Detector` instance.
683 Parameters
684 ----------
685 legacy_detector
686 Legacy detector to convert.
687 instrument
688 Name of the instrument this detector belongs to.
689 visit
690 Visit ID, if this camera geometry can be associated with a
691 particular visit.
692 is_raw_assembled
693 Whether to populate `Amplifier.assembled_raw_geometry` (`True`) or
694 `Amplifier.unassembled_raw_geometry` (`False`). If `None`, this
695 is set to ``visit is not None``, since we expect to only add
696 a visit ID to detectors that have been assembled.
697 """
698 if is_raw_assembled is None:
699 is_raw_assembled = visit is not None
700 attributes = DetectorAttributes(
701 name=legacy_detector.getName(),
702 id=legacy_detector.getId(),
703 type=DetectorType.from_legacy(legacy_detector.getType()),
704 bbox=Box.from_legacy(legacy_detector.getBBox()),
705 serial=legacy_detector.getSerial(),
706 orientation=Orientation.from_legacy(legacy_detector.getOrientation()),
707 pixel_size=legacy_detector.getPixelSize().getX(),
708 physical_type=legacy_detector.getPhysicalType(),
709 )
710 amplifiers = [
711 Amplifier.from_legacy(legacy_amp, is_raw_assembled=is_raw_assembled)
712 for legacy_amp in legacy_detector.getAmplifiers()
713 ]
714 transform_map = legacy_detector.getTransformMap()
715 frames = CameraFrameSet(instrument, transform_map.makeFrameSet([legacy_detector]))
716 return Detector(attributes, amplifiers, frames, visit=visit)
719class DetectorSerializationModel(ArchiveTree):
720 """Serialization model for `Detector`."""
722 attributes: DetectorAttributes = pydantic.Field(
723 description="The simple plain-old-data attributes of the detector."
724 )
726 amplifiers: list[Amplifier] = pydantic.Field(
727 default_factory=list,
728 description="Descriptions of the amplifiers.",
729 )
731 frames: CameraFrameSetSerializationModel | None = pydantic.Field(
732 default=None, description="Mappings to other camera coordinate systems."
733 )
735 visit: int | None = pydantic.Field(description="ID of the visit this detector is associated with.")
737 def deserialize(
738 self, archive: InputArchive[Any], frames: CameraFrameSet | None = None, **kwargs: Any
739 ) -> Detector:
740 """Deserialize this detector from an archive.
742 Parameters
743 ----------
744 model
745 Serialization model instance for this detector.
746 frames
747 Coordinate systems and transforms to use instead of what is saved
748 in ``model``. Must be provided if ``model.frames`` is `None`.
749 """
750 if kwargs:
751 raise InvalidParameterError(f"Unrecognized parameters for Detector: {set(kwargs.keys())}.")
752 if frames is None:
753 if self.frames is None:
754 raise ArchiveReadError(
755 "Serialized detector did not include coordinate transforms, "
756 "and 'frames' was not provided."
757 )
758 frames = self.frames.deserialize(archive)
759 return Detector(self.attributes, self.amplifiers, frames, visit=self.visit)