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