Coverage for python/lsst/images/cameras/_amplifier.py: 46%
135 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-29 02:47 -0700
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-29 02:47 -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 "ReadoutCorner",
18)
20import enum
21from typing import TYPE_CHECKING, Any, final
23import numpy as np
24import pydantic
26from .._geom import YX, Box
27from ..serialization import InlineArray
29if TYPE_CHECKING:
30 try:
31 from lsst.afw.cameraGeom import Amplifier as LegacyAmplifier
32 from lsst.afw.cameraGeom import ReadoutCorner as LegacyReadoutCorner
33 except ImportError:
34 type LegacyReadoutCorner = Any # type: ignore[no-redef]
35 type LegacyAmplifier = Any # type: ignore[no-redef]
38class ReadoutCorner(enum.StrEnum):
39 """Enumeration of the possible readout corners of an amplifier."""
41 LL = "LL"
42 LR = "LR"
43 UR = "UR"
44 UL = "UL"
46 def to_legacy(self) -> LegacyReadoutCorner:
47 """Convert to `lsst.afw.cameraGeom.ReadoutCorner`."""
48 from lsst.afw.cameraGeom import ReadoutCorner as LegacyReadoutCorner
50 return getattr(LegacyReadoutCorner, self.value)
52 @classmethod
53 def from_legacy(cls, legacy_readout_corner: LegacyReadoutCorner) -> ReadoutCorner:
54 """Convert from `lsst.afw.cameraGeom.ReadoutCorner`.
56 Parameters
57 ----------
58 legacy_readout_corner
59 Legacy readout corner to convert.
60 """
61 return getattr(cls, legacy_readout_corner.name)
63 def as_flips(self) -> YX[bool]:
64 """Return a tuple indicating how the image needs to be flipped to
65 bring the readout corner to ``LL``.
66 """
67 return YX(
68 y=self is ReadoutCorner.UL or self is ReadoutCorner.UR,
69 x=self is ReadoutCorner.LR or self is ReadoutCorner.UR,
70 )
72 @classmethod
73 def from_flips(cls, *, y: bool, x: bool) -> ReadoutCorner:
74 """Construct from booleans indicating how the image needs to be
75 flipped to bring the readout corner to ``LL``.
77 Parameters
78 ----------
79 y
80 Whether the image is flipped in the y direction.
81 x
82 Whether the image is flipped in the x direction.
83 """
84 match y, x:
85 case False, False:
86 return cls.LL
87 case False, True:
88 return cls.LR
89 case True, True:
90 return cls.UR
91 case True, False: 91 ↛ 93line 91 didn't jump to line 93 because the pattern on line 91 always matched
92 return cls.UL
93 raise TypeError(f"Invalid arguments: y={y}, x={x} (expected booleans).")
95 def apply_flips(self, *, y: bool, x: bool) -> ReadoutCorner:
96 """Return the new readout corner after applying the given flips.
98 Parameters
99 ----------
100 y
101 Whether to flip in the y direction.
102 x
103 Whether to flip in the x direction.
104 """
105 current = self.as_flips()
106 return self.from_flips(y=current.y ^ y, x=current.x ^ x)
109@final
110class AmplifierRawGeometry(pydantic.BaseModel):
111 """A struct that describes the geometry of an amplifire in a raw image."""
113 bbox: Box = pydantic.Field(description="Bounding box of the full untrimmed amplifier in the raw image.")
114 data_bbox: Box = pydantic.Field(description="Bounding box of the data section in the raw image.")
115 flip_x: bool = pydantic.Field(False, description="Whether to flip the X coordinates during assembly.")
116 flip_y: bool = pydantic.Field(False, description="Whether to flip the Y coordinates during assembly.")
117 x_offset: int = pydantic.Field(
118 0,
119 description=(
120 "X offset between the raw position of this amplifier and the trimmed, "
121 "assembled position of the amplifier."
122 ),
123 )
124 y_offset: int = pydantic.Field(
125 0,
126 description=(
127 "Y offset between the raw position of this amplifier and the trimmed, "
128 "assembled position of the amplifier."
129 ),
130 )
131 serial_overscan_bbox: Box = pydantic.Field(
132 description="Bounding box of the serial (horizontal) overscan region in the raw image."
133 )
134 parallel_overscan_bbox: Box = pydantic.Field(
135 description="Bounding box of the parallel (vertical) overscan region in the raw image."
136 )
137 prescan_bbox: Box | None = pydantic.Field(
138 None,
139 description=(
140 "Bounding box of the serial (horizontal) pre-scan region in the raw image, "
141 "or `None` for an amplifier that has no pre-scan region."
142 ),
143 )
144 readout_corner: ReadoutCorner = pydantic.Field(
145 description=(
146 "Readout corner of the amplifier in the raw image "
147 "(with x increasing to the right and y increasing up)."
148 )
149 )
151 @property
152 def horizontal_overscan_bbox(self) -> Box:
153 """Bounding box of the serial (horizon) overscan region in the raw
154 image (`.Box`).
155 """
156 return self.serial_overscan_bbox
158 @horizontal_overscan_bbox.setter
159 def horizontal_overscan_bbox(self, value: Box) -> None:
160 self.serial_overscan_bbox = value
162 @property
163 def vertical_overscan_bbox(self) -> Box:
164 """Bounding box of the parallel (vertical) overscan region in the raw
165 image (`.Box`).
166 """
167 return self.parallel_overscan_bbox
169 @vertical_overscan_bbox.setter
170 def vertical_overscan_bbox(self, value: Box) -> None:
171 self.parallel_overscan_bbox = value
173 @property
174 def horizontal_prescan_bbox(self) -> Box | None:
175 """Bounding box of the serial (horizon) prescan region in the raw
176 image (`.Box`).
177 """
178 return self.prescan_bbox
180 @horizontal_prescan_bbox.setter
181 def horizontal_prescan_bbox(self, value: Box | None) -> None:
182 self.prescan_bbox = value
184 @property
185 def serial_prescan_bbox(self) -> Box | None:
186 """Bounding box of the serial (horizon) prescan region in the raw
187 image (`.Box`).
188 """
189 return self.prescan_bbox
191 @serial_prescan_bbox.setter
192 def serial_prescan_bbox(self, value: Box | None) -> None:
193 self.prescan_bbox = value
195 @staticmethod
196 def from_legacy_amplifier(legacy_amplifier: LegacyAmplifier) -> AmplifierRawGeometry:
197 """Convert from a `lsst.afw.cameraGeom.Amplifier`.
199 Parameters
200 ----------
201 legacy_amplifier
202 Legacy amplifier to convert.
204 Notes
205 -----
206 `AmplifierRawGeometry.prescan_bbox` is `None` when the legacy
207 amplifier reports an empty prescan region, since `.Box` requires a
208 positive size.
209 """
210 legacy_prescan_bbox = legacy_amplifier.getRawPrescanBBox()
211 x_offset, y_offset = legacy_amplifier.getRawXYOffset()
212 return AmplifierRawGeometry(
213 bbox=Box.from_legacy(legacy_amplifier.getRawBBox()),
214 data_bbox=Box.from_legacy(legacy_amplifier.getRawDataBBox()),
215 flip_x=legacy_amplifier.getRawFlipX(),
216 flip_y=legacy_amplifier.getRawFlipY(),
217 x_offset=x_offset,
218 y_offset=y_offset,
219 serial_overscan_bbox=Box.from_legacy(legacy_amplifier.getRawSerialOverscanBBox()),
220 parallel_overscan_bbox=Box.from_legacy(legacy_amplifier.getRawParallelOverscanBBox()),
221 prescan_bbox=(None if legacy_prescan_bbox.isEmpty() else Box.from_legacy(legacy_prescan_bbox)),
222 readout_corner=ReadoutCorner.from_legacy(legacy_amplifier.getReadoutCorner()),
223 )
226@final
227class AmplifierCalibrations(pydantic.BaseModel, ser_json_inf_nan="constants"):
228 """A struct that holds nominal information about an amplifier that is
229 often superseded by separate calibration datasets.
230 """
232 gain: float
233 read_noise: float
234 saturation: float
235 suspect_level: float
236 linearity_coefficients: InlineArray
237 linearity_type: str
239 def __eq__(self, other: object) -> bool:
240 if type(other) is not AmplifierCalibrations: 240 ↛ 241line 240 didn't jump to line 241 because the condition on line 240 was never true
241 return NotImplemented
242 # ``suspect_level`` is a float whose "unset" sentinel is ``NaN``;
243 # treat NaN==NaN as equal here so a round-tripped calibration
244 # block does not spuriously compare unequal to its source.
245 return (
246 self.gain == other.gain
247 and self.read_noise == other.read_noise
248 and self.saturation == other.saturation
249 and (
250 self.suspect_level == other.suspect_level
251 or (np.isnan(self.suspect_level) and np.isnan(other.suspect_level))
252 )
253 and np.array_equal(self.linearity_coefficients, other.linearity_coefficients)
254 and self.linearity_type == other.linearity_type
255 )
257 @staticmethod
258 def from_legacy_amplifier(legacy_amplifier: LegacyAmplifier) -> AmplifierCalibrations:
259 """Convert from a `lsst.afw.cameraGeom.Amplifier`.
261 Parameters
262 ----------
263 legacy_amplifier
264 Legacy amplifier to convert.
265 """
266 return AmplifierCalibrations(
267 gain=legacy_amplifier.getGain(),
268 read_noise=legacy_amplifier.getReadNoise(),
269 saturation=legacy_amplifier.getSaturation(),
270 suspect_level=legacy_amplifier.getSuspectLevel(),
271 linearity_coefficients=legacy_amplifier.getLinearityCoeffs(),
272 linearity_type=legacy_amplifier.getLinearityType(),
273 )
276@final
277class Amplifier(pydantic.BaseModel, ser_json_inf_nan="constants"):
278 """A struct that holds information about an amplifier."""
280 name: str = pydantic.Field(description="Name of the amplifier.")
281 bbox: Box = pydantic.Field(
282 description="Bounding box of the amplifier data region in a trimmed, assembled detector."
283 )
284 readout_corner: ReadoutCorner = pydantic.Field(
285 description=(
286 "Readout corner of the amplifier in the final assembled, trimmed "
287 "image (with x increasing to the right and y increasing up). "
288 )
289 )
290 assembled_raw_geometry: AmplifierRawGeometry | None = pydantic.Field(
291 None,
292 description=(
293 "Geometry of this amplifier in an assembled but untrimmed raw image that has all amplifiers."
294 ),
295 )
296 unassembled_raw_geometry: AmplifierRawGeometry | None = pydantic.Field(
297 None,
298 description=(
299 "Geometry of this amplifier in an unassembled, untrimmed raw image that has just this amplifier."
300 ),
301 )
302 nominal_calibrations: AmplifierCalibrations | None = pydantic.Field(
303 None,
304 description=(
305 "Nominal calibration information that may be superseded by separate calibration datasets."
306 ),
307 )
309 def to_legacy_builder(self, is_raw_assembled: bool) -> LegacyAmplifier.Builder:
310 """Convert to a `lsst.afw.cameraGeom.Amplifier.Builder`.
312 Parameters
313 ----------
314 is_raw_assembled
315 Whether to use `Amplifier.assembled_raw_geometry` (`True`) or
316 `Amplifier.unassembled_raw_geometry` (`False`). If `None`, this
317 is set to ``self.visit is not None``, since we expect to only add
318 a visit ID to detectors that have been assembled.
319 """
320 from lsst.afw.cameraGeom import Amplifier as LegacyAmplifier
321 from lsst.geom import Extent2I
323 builder = LegacyAmplifier.Builder()
324 builder.setName(self.name)
325 builder.setBBox(self.bbox.to_legacy())
326 if is_raw_assembled:
327 if (raw_geom := self.assembled_raw_geometry) is None: 327 ↛ 328line 327 didn't jump to line 328 because the condition on line 327 was never true
328 raise ValueError(
329 f"is_raw_assembled=True but assembled_raw_geometry is None for amp {self.name}."
330 )
331 else:
332 if (raw_geom := self.unassembled_raw_geometry) is None: 332 ↛ 339line 332 didn't jump to line 339 because the condition on line 332 was always true
333 raise ValueError(
334 f"is_raw_assembled=False but unassembled_raw_geometry is None for amp {self.name}."
335 )
336 # The afw readout corner definition corresponds to the image it is
337 # attached to (which might be a raw), not the final trimmed image
338 # (despite the docs, until a change on this ticket).
339 builder.setReadoutCorner(raw_geom.readout_corner.to_legacy())
340 builder.setRawBBox(raw_geom.bbox.to_legacy())
341 builder.setRawDataBBox(raw_geom.data_bbox.to_legacy())
342 builder.setRawFlipX(raw_geom.flip_x)
343 builder.setRawFlipY(raw_geom.flip_y)
344 builder.setRawXYOffset(Extent2I(raw_geom.x_offset, raw_geom.y_offset))
345 builder.setRawSerialOverscanBBox(raw_geom.serial_overscan_bbox.to_legacy())
346 builder.setRawParallelOverscanBBox(raw_geom.parallel_overscan_bbox.to_legacy())
347 # An amplifier with no prescan region keeps the builder's own default,
348 # the empty box afw uses to spell that.
349 if raw_geom.prescan_bbox is not None:
350 builder.setRawPrescanBBox(raw_geom.prescan_bbox.to_legacy())
351 if self.nominal_calibrations is not None: 351 ↛ 358line 351 didn't jump to line 358 because the condition on line 351 was always true
352 builder.setGain(self.nominal_calibrations.gain)
353 builder.setReadNoise(self.nominal_calibrations.read_noise)
354 builder.setSaturation(self.nominal_calibrations.saturation)
355 builder.setSuspectLevel(self.nominal_calibrations.suspect_level)
356 builder.setLinearityCoeffs(self.nominal_calibrations.linearity_coefficients)
357 builder.setLinearityType(self.nominal_calibrations.linearity_type)
358 return builder
360 @staticmethod
361 def from_legacy(legacy_amplifier: LegacyAmplifier, is_raw_assembled: bool) -> Amplifier:
362 """Convert from a `lsst.afw.cameraGeom.Amplifier`.
364 Parameters
365 ----------
366 legacy_amplifier
367 Legacy amplifier to convert.
368 is_raw_assembled
369 Whether to populate `Amplifier.assembled_raw_geometry` (`True`) or
370 `Amplifier.unassembled_raw_geometry` (`False`).
371 """
372 raw_geometry = AmplifierRawGeometry.from_legacy_amplifier(legacy_amplifier)
373 nominal_calibrations = AmplifierCalibrations.from_legacy_amplifier(legacy_amplifier)
374 readout_corner = raw_geometry.readout_corner.apply_flips(y=raw_geometry.flip_y, x=raw_geometry.flip_x)
375 return Amplifier(
376 name=legacy_amplifier.getName(),
377 bbox=Box.from_legacy(legacy_amplifier.getBBox()),
378 readout_corner=readout_corner,
379 assembled_raw_geometry=raw_geometry if is_raw_assembled else None,
380 unassembled_raw_geometry=raw_geometry if not is_raw_assembled else None,
381 nominal_calibrations=nominal_calibrations,
382 )