Coverage for python/lsst/images/cameras/_amplifier.py: 15%
133 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-10 06:11 -0300
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-10 06:11 -0300
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 = pydantic.Field(
138 description="Bounding box of the serial (horizontal) pre-scan region in the raw image."
139 )
140 readout_corner: ReadoutCorner = pydantic.Field(
141 description=(
142 "Readout corner of the amplifier in the raw image "
143 "(with x increasing to the right and y increasing up)."
144 )
145 )
147 @property
148 def horizontal_overscan_bbox(self) -> Box:
149 """Bounding box of the serial (horizon) overscan region in the raw
150 image (`.Box`).
151 """
152 return self.serial_overscan_bbox
154 @horizontal_overscan_bbox.setter
155 def horizontal_overscan_bbox(self, value: Box) -> None:
156 self.serial_overscan_bbox = value
158 @property
159 def vertical_overscan_bbox(self) -> Box:
160 """Bounding box of the parallel (vertical) overscan region in the raw
161 image (`.Box`).
162 """
163 return self.parallel_overscan_bbox
165 @vertical_overscan_bbox.setter
166 def vertical_overscan_bbox(self, value: Box) -> None:
167 self.parallel_overscan_bbox = value
169 @property
170 def horizontal_prescan_bbox(self) -> Box:
171 """Bounding box of the serial (horizon) prescan region in the raw
172 image (`.Box`).
173 """
174 return self.prescan_bbox
176 @horizontal_prescan_bbox.setter
177 def horizontal_prescan_bbox(self, value: Box) -> None:
178 self.prescan_bbox = value
180 @property
181 def serial_prescan_bbox(self) -> Box:
182 """Bounding box of the serial (horizon) prescan region in the raw
183 image (`.Box`).
184 """
185 return self.prescan_bbox
187 @serial_prescan_bbox.setter
188 def serial_prescan_bbox(self, value: Box) -> None:
189 self.prescan_bbox = value
191 @staticmethod
192 def from_legacy_amplifier(legacy_amplifier: LegacyAmplifier) -> AmplifierRawGeometry:
193 """Convert from a `lsst.afw.cameraGeom.Amplifier`.
195 Parameters
196 ----------
197 legacy_amplifier
198 Legacy amplifier to convert.
199 """
200 x_offset, y_offset = legacy_amplifier.getRawXYOffset()
201 return AmplifierRawGeometry(
202 bbox=Box.from_legacy(legacy_amplifier.getRawBBox()),
203 data_bbox=Box.from_legacy(legacy_amplifier.getRawDataBBox()),
204 flip_x=legacy_amplifier.getRawFlipX(),
205 flip_y=legacy_amplifier.getRawFlipY(),
206 x_offset=x_offset,
207 y_offset=y_offset,
208 serial_overscan_bbox=Box.from_legacy(legacy_amplifier.getRawSerialOverscanBBox()),
209 parallel_overscan_bbox=Box.from_legacy(legacy_amplifier.getRawParallelOverscanBBox()),
210 prescan_bbox=Box.from_legacy(legacy_amplifier.getRawPrescanBBox()),
211 readout_corner=ReadoutCorner.from_legacy(legacy_amplifier.getReadoutCorner()),
212 )
215@final
216class AmplifierCalibrations(pydantic.BaseModel, ser_json_inf_nan="constants"):
217 """A struct that holds nominal information about an amplifier that is
218 often superseded by separate calibration datasets.
219 """
221 gain: float
222 read_noise: float
223 saturation: float
224 suspect_level: float
225 linearity_coefficients: InlineArray
226 linearity_type: str
228 def __eq__(self, other: object) -> bool:
229 if type(other) is not AmplifierCalibrations: 229 ↛ 230line 229 didn't jump to line 230 because the condition on line 229 was never true
230 return NotImplemented
231 # ``suspect_level`` is a float whose "unset" sentinel is ``NaN``;
232 # treat NaN==NaN as equal here so a round-tripped calibration
233 # block does not spuriously compare unequal to its source.
234 return (
235 self.gain == other.gain
236 and self.read_noise == other.read_noise
237 and self.saturation == other.saturation
238 and (
239 self.suspect_level == other.suspect_level
240 or (np.isnan(self.suspect_level) and np.isnan(other.suspect_level))
241 )
242 and np.array_equal(self.linearity_coefficients, other.linearity_coefficients)
243 and self.linearity_type == other.linearity_type
244 )
246 @staticmethod
247 def from_legacy_amplifier(legacy_amplifier: LegacyAmplifier) -> AmplifierCalibrations:
248 """Convert from a `lsst.afw.cameraGeom.Amplifier`.
250 Parameters
251 ----------
252 legacy_amplifier
253 Legacy amplifier to convert.
254 """
255 return AmplifierCalibrations(
256 gain=legacy_amplifier.getGain(),
257 read_noise=legacy_amplifier.getReadNoise(),
258 saturation=legacy_amplifier.getSaturation(),
259 suspect_level=legacy_amplifier.getSuspectLevel(),
260 linearity_coefficients=legacy_amplifier.getLinearityCoeffs(),
261 linearity_type=legacy_amplifier.getLinearityType(),
262 )
265@final
266class Amplifier(pydantic.BaseModel, ser_json_inf_nan="constants"):
267 """A struct that holds information about an amplifier."""
269 name: str = pydantic.Field(description="Name of the amplifier.")
270 bbox: Box = pydantic.Field(
271 description="Bounding box of the amplifier data region in a trimmed, assembled detector."
272 )
273 readout_corner: ReadoutCorner = pydantic.Field(
274 description=(
275 "Readout corner of the amplifier in the final assembled, trimmed "
276 "image (with x increasing to the right and y increasing up). "
277 )
278 )
279 assembled_raw_geometry: AmplifierRawGeometry | None = pydantic.Field(
280 None,
281 description=(
282 "Geometry of this amplifier in an assembled but untrimmed raw image that has all amplifiers."
283 ),
284 )
285 unassembled_raw_geometry: AmplifierRawGeometry | None = pydantic.Field(
286 None,
287 description=(
288 "Geometry of this amplifier in an unassembled, untrimmed raw image that has just this amplifier."
289 ),
290 )
291 nominal_calibrations: AmplifierCalibrations | None = pydantic.Field(
292 None,
293 description=(
294 "Nominal calibration information that may be superseded by separate calibration datasets."
295 ),
296 )
298 def to_legacy_builder(self, is_raw_assembled: bool) -> LegacyAmplifier.Builder:
299 """Convert to a `lsst.afw.cameraGeom.Amplifier.Builder`.
301 Parameters
302 ----------
303 is_raw_assembled
304 Whether to use `Amplifier.assembled_raw_geometry` (`True`) or
305 `Amplifier.unassembled_raw_geometry` (`False`). If `None`, this
306 is set to ``self.visit is not None``, since we expect to only add
307 a visit ID to detectors that have been assembled.
308 """
309 from lsst.afw.cameraGeom import Amplifier as LegacyAmplifier
310 from lsst.geom import Extent2I
312 builder = LegacyAmplifier.Builder()
313 builder.setName(self.name)
314 builder.setBBox(self.bbox.to_legacy())
315 if is_raw_assembled:
316 if (raw_geom := self.assembled_raw_geometry) is None:
317 raise ValueError(
318 f"is_raw_assembled=True but assembled_raw_geometry is None for amp {self.name}."
319 )
320 else:
321 if (raw_geom := self.unassembled_raw_geometry) is None:
322 raise ValueError(
323 f"is_raw_assembled=False but unassembled_raw_geometry is None for amp {self.name}."
324 )
325 # The afw readout corner definition corresponds to the image it is
326 # attached to (which might be a raw), not the final trimmed image
327 # (despite the docs, until a change on this ticket).
328 builder.setReadoutCorner(raw_geom.readout_corner.to_legacy())
329 builder.setRawBBox(raw_geom.bbox.to_legacy())
330 builder.setRawDataBBox(raw_geom.data_bbox.to_legacy())
331 builder.setRawFlipX(raw_geom.flip_x)
332 builder.setRawFlipY(raw_geom.flip_y)
333 builder.setRawXYOffset(Extent2I(raw_geom.x_offset, raw_geom.y_offset))
334 builder.setRawSerialOverscanBBox(raw_geom.serial_overscan_bbox.to_legacy())
335 builder.setRawParallelOverscanBBox(raw_geom.parallel_overscan_bbox.to_legacy())
336 builder.setRawPrescanBBox(raw_geom.prescan_bbox.to_legacy())
337 if self.nominal_calibrations is not None:
338 builder.setGain(self.nominal_calibrations.gain)
339 builder.setReadNoise(self.nominal_calibrations.read_noise)
340 builder.setSaturation(self.nominal_calibrations.saturation)
341 builder.setSuspectLevel(self.nominal_calibrations.suspect_level)
342 builder.setLinearityCoeffs(self.nominal_calibrations.linearity_coefficients)
343 builder.setLinearityType(self.nominal_calibrations.linearity_type)
344 return builder
346 @staticmethod
347 def from_legacy(legacy_amplifier: LegacyAmplifier, is_raw_assembled: bool) -> Amplifier:
348 """Convert from a `lsst.afw.cameraGeom.Amplifier`.
350 Parameters
351 ----------
352 legacy_amplifier
353 Legacy amplifier to convert.
354 is_raw_assembled
355 Whether to populate `Amplifier.assembled_raw_geometry` (`True`) or
356 `Amplifier.unassembled_raw_geometry` (`False`).
357 """
358 raw_geometry = AmplifierRawGeometry.from_legacy_amplifier(legacy_amplifier)
359 nominal_calibrations = AmplifierCalibrations.from_legacy_amplifier(legacy_amplifier)
360 readout_corner = raw_geometry.readout_corner.apply_flips(y=raw_geometry.flip_y, x=raw_geometry.flip_x)
361 return Amplifier(
362 name=legacy_amplifier.getName(),
363 bbox=Box.from_legacy(legacy_amplifier.getBBox()),
364 readout_corner=readout_corner,
365 assembled_raw_geometry=raw_geometry if is_raw_assembled else None,
366 unassembled_raw_geometry=raw_geometry if not is_raw_assembled else None,
367 nominal_calibrations=nominal_calibrations,
368 )