Coverage for python/lsst/images/cameras/_camera_frame_set.py: 28%
114 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-20 09:30 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-20 09:30 +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.
12from __future__ import annotations
14__all__ = ("CameraFrameSet", "CameraFrameSetSerializationModel")
16from typing import Any, ClassVar
18import astropy.units as u
19import pydantic
21from .._geom import Bounds, Box
22from .._transforms import (
23 FrameLookupError,
24 FrameSet,
25 Transform,
26 _frames, # use this import style to facilitate pattern matching
27)
28from .._transforms import _ast as astshim
29from ..describe import DescribeOptions, FieldRole, Report, ReportField
30from ..serialization import ArchiveTree, InputArchive, InvalidParameterError, OutputArchive
33class CameraFrameSet(FrameSet):
34 """A `.FrameSet` that manages the coordinate systems of a camera.
36 The `CameraFrameSet` class constructor is considered a private
37 implementation detail. At present, instances can only be obtained by
38 loading them from storage (via
39 `~CameraFrameSetSerializationModel.deserialize`) or converting a legacy
40 `lsst.afw.cameraGeom` object (`from_legacy`).
42 Parameters
43 ----------
44 instrument
45 Short (butler dimension) name of the instrument.
46 ast
47 AST frame set describing the camera's coordinate systems.
48 """
50 # This constructor is kept private while we support both the astshim
51 # and starlink-pyast AST wrappers. For now:
52 # 'instrument': the short (butler dimension) name.
53 # 'ast': an astshim.FrameSet as returned by
54 # lsst.afw.cameraGeom.TransformMap.makeFrameSet.
55 # Should have frames with Ident values FOCAL_PLANE, FIELD_ANGLE
56 # and DETECTOR_${ID}, and the focal plane frame must know its
57 # units.
58 def __init__(self, instrument: str, ast: astshim.FrameSet) -> None:
59 self._ast = ast
60 self._focal_plane_frame_id: int = 0
61 self._field_angle_frame_id: int = 0
62 self._detector_frame_ids: dict[int, int] = {}
63 for frame_id in range(1, self._ast.nFrame + 1):
64 ast_frame = self._ast.getFrame(frame_id, copy=False)
65 match ast_frame.ident:
66 case "FOCAL_PLANE":
67 self._focal_plane_frame_id = frame_id
68 case "FIELD_ANGLE":
69 self._field_angle_frame_id = frame_id
70 case str(s) if s.startswith("DETECTOR_"): 70 ↛ 73line 70 didn't jump to line 73 because the pattern on line 70 always matched
71 detector_id = int(s.removeprefix("DETECTOR_"))
72 self._detector_frame_ids[detector_id] = frame_id
73 case _:
74 raise ValueError(f"Unexpected frame in camera AST FrameSet:\n{ast_frame.show()}.")
75 if self._focal_plane_frame_id == 0: 75 ↛ 76line 75 didn't jump to line 76 because the condition on line 75 was never true
76 raise ValueError("No FOCAL_PLANE frame in camera AST FrameSet.")
77 self._focal_plane_frame = _frames.FocalPlaneFrame(
78 instrument=instrument,
79 unit=u.Unit(self._ast.getFrame(self._focal_plane_frame_id, copy=False).getUnit(1)),
80 )
81 self._field_angle_frame = _frames.FieldAngleFrame(instrument=instrument)
82 if self._field_angle_frame_id == 0: 82 ↛ 83line 82 didn't jump to line 83 because the condition on line 82 was never true
83 raise ValueError("No FIELD_ANGLE frame in camera AST FrameSet.")
85 def __eq__(self, other: object) -> bool:
86 if type(other) is not CameraFrameSet: 86 ↛ 87line 86 didn't jump to line 87 because the condition on line 86 was never true
87 return NotImplemented
88 # Every cached attribute on this class is derived from ``_ast`` and
89 # the instrument name. Compare the *simplified* AST serialisations
90 # explicitly rather than relying on ``Object.__eq__``: what we care
91 # about is that the two frame sets describe the same transforms, and
92 # simplifying first makes the check about behaviour rather than the
93 # particular intermediate representation.
94 return (
95 self.instrument == other.instrument
96 and self._ast.simplified().show() == other._ast.simplified().show()
97 )
99 __hash__ = None # type: ignore[assignment]
101 @property
102 def instrument(self) -> str:
103 """Name of the instrument (`str`)."""
104 return self._focal_plane_frame.instrument
106 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report:
107 """Return a `Report` describing this camera frame set.
109 Parameters
110 ----------
111 options : `DescribeOptions`, optional
112 Rendering options. `DescribeOptions.brief` omits the detector
113 list, which is one entry per detector in the camera.
114 """
115 detectors = sorted(self._detector_frame_ids)
116 count = len(detectors)
117 summary = f"CameraFrameSet({self.instrument!r}, {count} detector{'' if count == 1 else 's'})"
118 fields = [
119 ReportField(label="instrument", value=self.instrument, role=FieldRole.DERIVED),
120 # The focal plane and field angle frames are required by the
121 # constructor, so only their configuration is worth reporting.
122 ReportField(
123 label="focal plane unit",
124 value=self._focal_plane_frame.unit,
125 role=FieldRole.DERIVED,
126 ),
127 ]
128 if not options.brief:
129 fields.append(
130 ReportField(
131 label="detectors",
132 value=", ".join(str(detector) for detector in detectors),
133 role=FieldRole.DERIVED,
134 )
135 )
136 return Report(type_name="CameraFrameSet", summary=summary, fields=fields)
138 def focal_plane(self, visit: int | None = None) -> _frames.FocalPlaneFrame:
139 """Return a focal plane frame for this instrument.
141 Parameters
142 ----------
143 visit
144 ID for the visit this frame will correspond to. This only needs
145 to be provided in contexts where camera frames will be related to
146 the sky via a `.SkyProjection`.
147 """
148 if visit is None:
149 return self._focal_plane_frame
150 else:
151 return self._focal_plane_frame.model_copy(update={"visit": visit})
153 def field_angle(self, visit: int | None = None) -> _frames.FieldAngleFrame:
154 """Return a field angle frame for this instrument.
156 Parameters
157 ----------
158 visit
159 ID for the visit this frame will correspond to. This only needs
160 to be provided in contexts where camera frames will be related to
161 the sky via a `.SkyProjection`.
162 """
163 if visit is None:
164 return self._field_angle_frame
165 else:
166 return self._field_angle_frame.model_copy(update={"visit": visit})
168 def detector(self, detector: int, *, visit: int | None = None) -> _frames.DetectorFrame:
169 """Return a detector pixel-coordinate frame for this instrument.
171 Parameters
172 ----------
173 detector
174 ID of the detector.
175 visit
176 ID for the visit this frame will correspond to. This only needs
177 to be provided in contexts where camera frames will be related to
178 the sky via a `.SkyProjection`.
179 """
180 try:
181 frame_id = self._detector_frame_ids[detector]
182 except KeyError:
183 raise FrameLookupError(
184 f"No frame for detector {detector!r} in camera for {self.instrument!r}."
185 ) from None
186 ast_frame = self._ast.getFrame(frame_id, copy=False)
187 bbox = Box.factory[
188 int(ast_frame.getBottom(2)) : int(ast_frame.getTop(2)),
189 int(ast_frame.getBottom(1)) : int(ast_frame.getTop(1)),
190 ]
191 return _frames.DetectorFrame(instrument=self.instrument, detector=detector, visit=visit, bbox=bbox)
193 def __contains__(self, frame: _frames.Frame) -> bool:
194 try:
195 self._parse_frame_arg(frame)
196 return True
197 except FrameLookupError:
198 return False
200 def __getitem__[I: _frames.Frame, O: _frames.Frame](self, key: tuple[I, O]) -> Transform[I, O]:
201 in_frame, out_frame = key
202 in_frame_id, in_bounds = self._parse_frame_arg(in_frame)
203 out_frame_id, out_bounds = self._parse_frame_arg(out_frame)
204 return Transform(
205 in_frame,
206 out_frame,
207 self._ast.getMapping(in_frame_id, out_frame_id),
208 in_bounds=in_bounds,
209 out_bounds=out_bounds,
210 )
212 def _parse_frame_arg(self, frame: _frames.Frame) -> tuple[int, Bounds | None]:
213 bounds: Bounds | None = None
214 match frame:
215 case _frames.DetectorFrame(instrument=self.instrument, detector=detector_id):
216 try:
217 frame_id = self._detector_frame_ids[detector_id]
218 except KeyError:
219 raise FrameLookupError(
220 f"No frame for detector {detector_id!r} in camera for {self.instrument!r}."
221 ) from None
222 bounds = frame.bbox
223 case _frames.FocalPlaneFrame(instrument=self.instrument):
224 frame_id = self._focal_plane_frame_id
225 case _frames.FieldAngleFrame(instrument=self.instrument):
226 frame_id = self._field_angle_frame_id
227 case _:
228 raise FrameLookupError(f"Invalid frame for camera {self.instrument}: {frame!r}.")
229 return frame_id, bounds
231 def serialize(self, archive: OutputArchive[Any]) -> CameraFrameSetSerializationModel:
232 """Serialize the frame set to an archive.
234 Parameters
235 ----------
236 archive
237 Archive to serialize to.
239 Returns
240 -------
241 `CameraFrameSetSerializationModel`
242 Serialized form of the frame set.
243 """
244 return CameraFrameSetSerializationModel(instrument=self.instrument, ast=self._ast.show())
246 @staticmethod
247 def _get_archive_tree_type(
248 pointer_type: type[pydantic.BaseModel],
249 ) -> type[CameraFrameSetSerializationModel]:
250 """Return the serialization model type for this object for an archive
251 type that uses the given pointer type.
252 """
253 return CameraFrameSetSerializationModel
255 @classmethod
256 def from_legacy(cls, camera: Any) -> CameraFrameSet:
257 """Construct a transform from a legacy `lsst.afw.cameraGeom.Camera`.
259 Parameters
260 ----------
261 camera
262 An `lsst.afw.cameraGeom.Camera` instance to convert.
263 """
264 transform_map = camera.getTransformMap()
265 ast_frame_set = transform_map.makeFrameSet(list(camera))
266 return CameraFrameSet("HSC", ast_frame_set)
269class CameraFrameSetSerializationModel(ArchiveTree):
270 """Serialization model for `CameraFrameSet`."""
272 SCHEMA_NAME: ClassVar[str] = "camera_frame_set"
273 SCHEMA_VERSION: ClassVar[str] = "1.0.0"
274 MIN_READ_VERSION: ClassVar[int] = 1
275 PUBLIC_TYPE: ClassVar[type] = CameraFrameSet
277 instrument: str = pydantic.Field(description="Name of the instrument.")
278 ast: str = pydantic.Field(
279 description="A serialized Starlink AST FrameSet, using the AST native encoding."
280 )
282 def deserialize(self, archive: InputArchive[Any], **kwargs: Any) -> CameraFrameSet:
283 """Deserialize a frame set from an archive.
285 Parameters
286 ----------
287 archive
288 Archive to read from.
289 **kwargs
290 Unsupported keyword arguments are accepted only to provide better
291 error messages (raising `.serialization.InvalidParameterError`).
292 """
293 if kwargs: 293 ↛ 294line 293 didn't jump to line 294 because the condition on line 293 was never true
294 raise InvalidParameterError(f"Unrecognized parameters for CameraFrameSet: {set(kwargs.keys())}.")
295 return CameraFrameSet(self.instrument, astshim.FrameSet.fromString(self.ast))