Coverage for python/lsst/images/cameras/_detector.py: 14%
170 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-19 10:11 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-19 10:11 +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 "Detector",
15 "DetectorAttributes",
16 "DetectorSerializationModel",
17 "DetectorType",
18 "Orientation",
19)
21import builtins
22import enum
23from collections.abc import Iterable
24from typing import TYPE_CHECKING, Any, ClassVar, final
26import astropy.units
27import numpy as np
28import pydantic
30from .._geom import Box
31from .._transforms import (
32 DetectorFrame,
33 FieldAngleFrame,
34 FocalPlaneFrame,
35 Transform,
36)
37from ..describe import DescribableMixin, DescribeOptions, FieldRole, Report, ReportField
38from ..serialization import (
39 ArchiveReadError,
40 ArchiveTree,
41 InputArchive,
42 InvalidParameterError,
43 OutputArchive,
44 Quantity,
45)
46from ._amplifier import Amplifier
47from ._camera_frame_set import CameraFrameSet, CameraFrameSetSerializationModel
49if TYPE_CHECKING:
50 try:
51 from lsst.afw.cameraGeom import Detector as LegacyDetector
52 from lsst.afw.cameraGeom import DetectorType as LegacyDetectorType
53 from lsst.afw.cameraGeom import Orientation as LegacyOrientation
54 except ImportError:
55 type LegacyDetector = Any # type: ignore[no-redef]
56 type LegacyDetectorType = Any # type: ignore[no-redef]
57 type LegacyOrientation = Any # type: ignore[no-redef]
60class DetectorType(enum.StrEnum):
61 """Enumeration of the types of a detector."""
63 SCIENCE = "SCIENCE"
64 FOCUS = "FOCUS"
65 GUIDER = "GUIDER"
66 WAVEFRONT = "WAVEFRONT"
68 def to_legacy(self) -> LegacyDetectorType:
69 """Convert to `lsst.afw.cameraGeom.DetectorType`."""
70 from lsst.afw.cameraGeom import DetectorType as LegacyDetectorType
72 return getattr(LegacyDetectorType, self.value)
74 @classmethod
75 def from_legacy(cls, legacy_detector_type: LegacyDetectorType) -> DetectorType:
76 """Convert from `lsst.afw.cameraGeom.DetectorType`.
78 Parameters
79 ----------
80 legacy_detector_type
81 Legacy detector type to convert.
82 """
83 return getattr(cls, legacy_detector_type.name)
86@final
87class Orientation(pydantic.BaseModel, ser_json_inf_nan="constants"):
88 """A struct that represents the nominal position and rotation of a
89 detector within a camera focal plane.
90 """
92 focal_plane_x: float = pydantic.Field(description="Focal plane X coordinate of the reference position.")
93 focal_plane_y: float = pydantic.Field(description="Focal plane Y coordinate of the reference position.")
94 focal_plane_z: float = pydantic.Field(description="Focal plane Z coordinate of the reference position.")
95 pixel_reference_x: float = pydantic.Field(0.5, description="Pixel X coordinate of the reference point.")
96 pixel_reference_y: float = pydantic.Field(0.5, description="Pixel Y coordinate of the reference point.")
97 yaw: Quantity = pydantic.Field(
98 default_factory=lambda: 0.0 * astropy.units.radian,
99 description="Rotation about the Z axis.",
100 )
101 pitch: Quantity = pydantic.Field(
102 default_factory=lambda: 0.0 * astropy.units.radian,
103 description="Rotation about the Y axis (as defined after applying 'yaw').",
104 )
105 roll: Quantity = pydantic.Field(
106 default_factory=lambda: 0.0 * astropy.units.radian,
107 description="Rotation about the X axis (as defined after applying 'yaw' and 'pitch').",
108 )
110 def to_legacy(self) -> LegacyOrientation:
111 """Convert to `lsst.afw.cameraGeom.Orientation`."""
112 from lsst.afw.cameraGeom import Orientation as LegacyOrientation
113 from lsst.geom import Point2D, Point3D, radians
115 return LegacyOrientation(
116 Point3D(self.focal_plane_x, self.focal_plane_y, self.focal_plane_z),
117 Point2D(self.pixel_reference_x, self.pixel_reference_y),
118 self.yaw.to_value(astropy.units.radian) * radians,
119 self.pitch.to_value(astropy.units.radian) * radians,
120 self.roll.to_value(astropy.units.radian) * radians,
121 )
123 @staticmethod
124 def from_legacy(legacy_orientation: LegacyOrientation) -> Orientation:
125 """Convert from `lsst.afw.cameraGeom.Orientation`.
127 Parameters
128 ----------
129 legacy_orientation
130 Legacy orientation to convert.
131 """
132 focal_plane_x, focal_plane_y, focal_plane_z = legacy_orientation.getFpPosition3()
133 pixel_reference_x, pixel_reference_y = legacy_orientation.getReferencePoint()
134 return Orientation(
135 focal_plane_x=focal_plane_x,
136 focal_plane_y=focal_plane_y,
137 focal_plane_z=focal_plane_z,
138 pixel_reference_x=pixel_reference_x,
139 pixel_reference_y=pixel_reference_y,
140 yaw=legacy_orientation.getYaw().asRadians() * astropy.units.radian,
141 pitch=legacy_orientation.getPitch().asRadians() * astropy.units.radian,
142 roll=legacy_orientation.getRoll().asRadians() * astropy.units.radian,
143 )
146@final
147class DetectorAttributes(pydantic.BaseModel, ser_json_inf_nan="constants"):
148 """Struct holding the plain-old-data attributes of a detector."""
150 name: str = pydantic.Field(description="Name of the detector.")
151 id: int = pydantic.Field(description="ID of the detector.")
152 type: DetectorType = pydantic.Field(description="Enumerated type of the detector.")
153 serial: str = pydantic.Field(description="Serial number for the detector.")
154 bbox: Box = pydantic.Field(
155 description="Bounding box of the detector's science data region after amplifier assembly."
156 )
157 orientation: Orientation = pydantic.Field(description="Nominal position and rotation of the detector.")
158 pixel_size: float = pydantic.Field(
159 description="Nominal size of a pixel (assumed square) in focal plane coordinate units."
160 )
161 physical_type: str = pydantic.Field(
162 description=(
163 "Vendor name or technology type for this detector "
164 "(may have a different interpretation for different cameras)."
165 )
166 )
169@final
170class Detector(DescribableMixin):
171 """Information about a detector in a camera.
173 Parameters
174 ----------
175 attributes
176 Identifying attributes and metadata for the detector.
177 amplifiers
178 Amplifiers that make up the detector.
179 frames
180 Coordinate systems and transforms for the camera.
181 visit
182 Visit number whose geometry to use, or `None` for the nominal
183 detector geometry.
184 """
186 def __init__(
187 self,
188 attributes: DetectorAttributes,
189 amplifiers: Iterable[Amplifier],
190 frames: CameraFrameSet,
191 visit: int | None = None,
192 ) -> None:
193 self._attributes = attributes
194 self._amplifiers = list(amplifiers)
195 self._frames = frames
196 self._frame = frames.detector(attributes.id, visit=visit)
198 def __eq__(self, other: object) -> bool:
199 if type(other) is not Detector: 199 ↛ 200line 199 didn't jump to line 200 because the condition on line 199 was never true
200 return NotImplemented
201 return (
202 self._attributes == other._attributes
203 and self._amplifiers == other._amplifiers
204 and self._frames == other._frames
205 and self.visit == other.visit
206 )
208 __hash__ = None # type: ignore[assignment]
210 @property
211 def instrument(self) -> str:
212 """The name of the instrument this detector belongs to (`str`)."""
213 return self._frame.instrument
215 @property
216 def visit(self) -> int | None:
217 """The ID of the visit this detector is associated with (`int` or
218 `None`).
219 """
220 return self._frame.visit
222 @property
223 def name(self) -> str:
224 """Name of the detector (`str`)."""
225 return self._attributes.name
227 @property
228 def id(self) -> int:
229 """ID of the detector (`int`)."""
230 return self._attributes.id
232 @property
233 def type(self) -> DetectorType:
234 """Enumerated type of the detector (`DetectorType`)."""
235 return self._attributes.type
237 @property
238 def serial(self) -> str:
239 """Serial number for the detector (`str`)."""
240 return self._attributes.serial
242 @property
243 def bbox(self) -> Box:
244 """Bounding box of the detector's science data region after amplifier
245 assembly (`.Box`).
246 """
247 return self._attributes.bbox
249 @property
250 def orientation(self) -> Orientation:
251 """Nominal position and rotation of the detector
252 (`Orientation`).
253 """
254 return self._attributes.orientation
256 @property
257 def pixel_size(self) -> float:
258 """Nominal size of a pixel (assumed square) in focal plane coordinate
259 units (`float`).
260 """
261 return self._attributes.pixel_size
263 @property
264 def physical_type(self) -> str:
265 """Vendor name or technology type for this detector (`str`).
267 This may have a different interpretation for different cameras.
268 """
269 return self._attributes.physical_type
271 @property
272 def frame(self) -> DetectorFrame:
273 """The coordinate system of this detector's trimmed, assembled pixel
274 grid (`.DetectorFrame`).
275 """
276 return self._frame
278 @property
279 def to_focal_plane(self) -> Transform[DetectorFrame, FocalPlaneFrame]:
280 """The transform from pixels to focal-plane coordinates
281 (`.Transform` [`.DetectorFrame`, `.FocalPlaneFrame`]).
282 """
283 return self._frames[self._frame, self._frames.focal_plane(self.visit)]
285 @property
286 def to_field_angle(self) -> Transform[DetectorFrame, FieldAngleFrame]:
287 """The transform from pixels to field angle coordinates
288 (`.Transform` [`.DetectorFrame`, `.FieldAngleFrame`]).
289 """
290 return self._frames[self._frame, self._frames.field_angle(self.visit)]
292 @property
293 def amplifiers(self) -> list[Amplifier]:
294 """The amplifiers of this detectors (`list` [`Amplifier`])."""
295 return self._amplifiers
297 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report:
298 """Return a `Report` describing this detector.
300 Parameters
301 ----------
302 options : `DescribeOptions`, optional
303 Unused; accepted for interface compatibility.
304 """
305 return Report(
306 type_name="Detector",
307 summary=f"Detector {self.name!r} ({self.instrument})",
308 fields=[
309 ReportField(label="instrument", value=self.instrument, role=FieldRole.DERIVED),
310 ReportField(label="name", value=self.name, role=FieldRole.DERIVED),
311 ReportField(label="id", value=self.id, role=FieldRole.DERIVED),
312 ReportField(label="type", value=self.type, role=FieldRole.DERIVED),
313 ReportField(label="serial", value=self.serial, role=FieldRole.DERIVED),
314 ReportField(label="bbox", value=self.bbox, role=FieldRole.DERIVED),
315 ],
316 )
318 def copy(self) -> Detector:
319 """Copy the detector.
321 This deep-copies all data fields and amplifiers, but only
322 shallow-copies the internal `.CameraFrameSet`, as that's conceptually
323 immutable.
324 """
325 return Detector(
326 self._attributes.model_copy(deep=True),
327 amplifiers=[a.model_copy(deep=True) for a in self._amplifiers],
328 frames=self._frames,
329 )
331 def serialize(self, archive: OutputArchive[Any], save_frames: bool = True) -> DetectorSerializationModel:
332 """Serialize this detector to an archive.
334 Parameters
335 ----------
336 archive
337 Archive to save to.
338 save_frames
339 Whether to save the `.CameraFrameSet` held by this detector. This
340 allows the frame set to be saved once for multiple detectors when
341 they are part of a multi-detector object.
342 """
343 return DetectorSerializationModel(
344 attributes=self._attributes,
345 amplifiers=self._amplifiers,
346 frames=archive.serialize_direct("frames", self._frames.serialize) if save_frames else None,
347 visit=self.visit,
348 )
350 @staticmethod
351 def _get_archive_tree_type(
352 pointer_type: builtins.type[Any],
353 ) -> builtins.type[DetectorSerializationModel]:
354 """Return the serialization model type for this object for an archive
355 type that uses the given pointer type.
356 """
357 return DetectorSerializationModel
359 def to_legacy(self, *, is_raw_assembled: bool | None = None) -> LegacyDetector:
360 """Convert to a legacy `lsst.afw.cameraGeom.Detector` instance.
362 Parameters
363 ----------
364 is_raw_assembled
365 Whether to use `Amplifier.assembled_raw_geometry` (`True`) or
366 `Amplifier.unassembled_raw_geometry` (`False`). If `None`, this
367 is set to ``self.visit is not None``, since we expect to only add
368 a visit ID to detectors that have been assembled.
369 """
370 from lsst.afw.cameraGeom import FIELD_ANGLE, FOCAL_PLANE, Camera
371 from lsst.geom import Extent2D, Point2D
373 if is_raw_assembled is None:
374 is_raw_assembled = self.visit is not None
375 # Legacy Detectors can only be built from scratch as a part of a
376 # camera.
377 camera_builder = Camera.Builder(self.name)
378 fp_to_fa = self._frames[self._frames.focal_plane(), self._frames.field_angle()]
379 legacy_fp_to_fa = fp_to_fa.to_legacy()
380 camera_builder.setFocalPlaneParity(np.linalg.det(legacy_fp_to_fa.getJacobian(Point2D(0.0, 0.0))) < 0)
381 camera_builder.setTransformFromFocalPlaneTo(FIELD_ANGLE, legacy_fp_to_fa)
382 detector_builder = camera_builder.add(self.name, self.id)
383 detector_builder.setBBox(self.bbox.to_legacy())
384 detector_builder.setType(self.type.to_legacy())
385 detector_builder.setSerial(self.serial)
386 detector_builder.setPhysicalType(self.physical_type)
387 detector_builder.setOrientation(self.orientation.to_legacy())
388 detector_builder.setPixelSize(Extent2D(self.pixel_size, self.pixel_size))
389 detector_builder.setTransformFromPixelsTo(FOCAL_PLANE, self.to_focal_plane.to_legacy())
390 for amp in self.amplifiers:
391 try:
392 detector_builder.append(amp.to_legacy_builder(is_raw_assembled))
393 except Exception as err:
394 err.add_note(f"On detector {self.id}/{self.name}.")
395 raise
396 camera = camera_builder.finish()
397 return camera[self.id]
399 @staticmethod
400 def from_legacy(
401 legacy_detector: LegacyDetector,
402 *,
403 instrument: str,
404 visit: int | None = None,
405 is_raw_assembled: bool | None = None,
406 ) -> Detector:
407 """Convert from a legacy `lsst.afw.cameraGeom.Detector` instance.
409 Parameters
410 ----------
411 legacy_detector
412 Legacy detector to convert.
413 instrument
414 Name of the instrument this detector belongs to.
415 visit
416 Visit ID, if this camera geometry can be associated with a
417 particular visit.
418 is_raw_assembled
419 Whether to populate `Amplifier.assembled_raw_geometry` (`True`) or
420 `Amplifier.unassembled_raw_geometry` (`False`). If `None`, this
421 is set to ``visit is not None``, since we expect to only add
422 a visit ID to detectors that have been assembled.
423 """
424 if is_raw_assembled is None:
425 is_raw_assembled = visit is not None
426 attributes = DetectorAttributes(
427 name=legacy_detector.getName(),
428 id=legacy_detector.getId(),
429 type=DetectorType.from_legacy(legacy_detector.getType()),
430 bbox=Box.from_legacy(legacy_detector.getBBox()),
431 serial=legacy_detector.getSerial(),
432 orientation=Orientation.from_legacy(legacy_detector.getOrientation()),
433 pixel_size=legacy_detector.getPixelSize().getX(),
434 physical_type=legacy_detector.getPhysicalType(),
435 )
436 amplifiers = [
437 Amplifier.from_legacy(legacy_amp, is_raw_assembled=is_raw_assembled)
438 for legacy_amp in legacy_detector.getAmplifiers()
439 ]
440 transform_map = legacy_detector.getTransformMap()
441 frames = CameraFrameSet(instrument, transform_map.makeFrameSet([legacy_detector]))
442 return Detector(attributes, amplifiers, frames, visit=visit)
445class DetectorSerializationModel(ArchiveTree):
446 """Serialization model for `Detector`."""
448 SCHEMA_NAME: ClassVar[str] = "detector"
449 SCHEMA_VERSION: ClassVar[str] = "1.0.0"
450 MIN_READ_VERSION: ClassVar[int] = 1
451 PUBLIC_TYPE: ClassVar[type] = Detector
453 attributes: DetectorAttributes = pydantic.Field(
454 description="The simple plain-old-data attributes of the detector."
455 )
457 amplifiers: list[Amplifier] = pydantic.Field(
458 default_factory=list,
459 description="Descriptions of the amplifiers.",
460 )
462 frames: CameraFrameSetSerializationModel | None = pydantic.Field(
463 default=None, description="Mappings to other camera coordinate systems."
464 )
466 visit: int | None = pydantic.Field(description="ID of the visit this detector is associated with.")
468 def deserialize(
469 self, archive: InputArchive[Any], frames: CameraFrameSet | None = None, **kwargs: Any
470 ) -> Detector:
471 """Deserialize this detector from an archive.
473 Parameters
474 ----------
475 archive
476 Serialization model instance for this detector.
477 frames
478 Coordinate systems and transforms to use instead of what is saved
479 in ``model``. Must be provided if ``model.frames`` is `None`.
480 **kwargs
481 Unsupported keyword arguments are accepted only to provide
482 better error messages (raising
483 `.serialization.InvalidParameterError`).
484 """
485 if kwargs: 485 ↛ 486line 485 didn't jump to line 486 because the condition on line 485 was never true
486 raise InvalidParameterError(f"Unrecognized parameters for Detector: {set(kwargs.keys())}.")
487 if frames is None: 487 ↛ 494line 487 didn't jump to line 494 because the condition on line 487 was always true
488 if self.frames is None: 488 ↛ 489line 488 didn't jump to line 489 because the condition on line 488 was never true
489 raise ArchiveReadError(
490 "Serialized detector did not include coordinate transforms, "
491 "and 'frames' was not provided."
492 )
493 frames = self.frames.deserialize(archive)
494 return Detector(self.attributes, self.amplifiers, frames, visit=self.visit)