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