Coverage for python / lsst / images / _transforms / _frames.py: 70%
140 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-16 07:54 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-16 07:54 +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__ = (
15 "ICRS",
16 "DetectorFrame",
17 "FieldAngleFrame",
18 "FocalPlaneFrame",
19 "Frame",
20 "GeneralFrame",
21 "SerializableFrame",
22 "SkyFrame",
23 "TractFrame",
24)
26import enum
27from typing import Annotated, Literal, Protocol, final
29import astropy.units as u
30import numpy as np
31import pydantic
33from .._geom import Box
34from ..serialization import Unit
35from ..utils import is_none
38class Frame(Protocol):
39 """A description of a coordinate system."""
41 @property
42 def unit(self) -> u.UnitBase:
43 """Units of the coordinates in this frame
44 (`astropy.units.UnitBase`).
45 """
46 ...
48 def standardize_x[T: float | np.ndarray](self, x: T) -> T:
49 """Coerce ``x`` coordinates into their standard range."""
50 ...
52 def standardize_y[T: float | np.ndarray](self, y: T) -> T:
53 """Coerce ``y`` coordinates into their standard range."""
54 ...
56 def serialize(self) -> SerializableFrame:
57 """Return a Pydantic-serializable version of this Frame.
59 Notes
60 -----
61 The returned object must support direct nesting with Pydantic models
62 and have a ``deserialize`` method (taking no arguments) that converts
63 back to this `Frame` type. It is common for `serialize` and
64 ``deserialize`` to just return ``self``, when the frame object is
65 natively serializable.
66 """
67 ...
69 @property
70 def _ast_ident(self) -> str:
71 """String to use as the 'Ident' attribute of an AST Frame."""
72 ...
75@final
76class DetectorFrame(pydantic.BaseModel, frozen=True):
77 """A coordinate frame for a particular detector's pixels.
79 Notes
80 -----
81 This frame is only used for post-assembly images (i.e. not those with
82 overscan regions still present).
83 """
85 instrument: str = pydantic.Field(description="Name of the instrument.")
86 visit: int | None = pydantic.Field(
87 default=None,
88 description=(
89 "ID of the visit. May be unset in contexts where there "
90 "is no visit or only a single relevant visit."
91 ),
92 exclude_if=is_none,
93 )
94 detector: int = pydantic.Field(description="ID of the detector.")
95 bbox: Box = pydantic.Field(description="Bounding box of the detector.")
96 frame_type: Literal["DETECTOR"] = pydantic.Field(
97 default="DETECTOR", description="Discriminator for the frame type."
98 )
100 @property
101 def unit(self) -> u.UnitBase:
102 """Units of the coordinates in this frame
103 (`astropy.units.UnitBase`).
104 """
105 return u.pix
107 def standardize_x[T: float | np.ndarray](self, x: T) -> T:
108 """Coerce ``x`` coordinates into their standard range."""
109 return x
111 def standardize_y[T: float | np.ndarray](self, y: T) -> T:
112 """Coerce ``y`` coordinates into their standard range."""
113 return y
115 def serialize(self) -> DetectorFrame:
116 """Return a Pydantic-serializable version of this Frame."""
117 return self
119 def deserialize(self) -> DetectorFrame:
120 """Convert a serialized frame to an in-memory one."""
121 return self
123 @property
124 def _ast_ident(self) -> str:
125 return f"{_camera_ast_ident(self.instrument, self.visit)}/DETECTOR_{self.detector:03d}"
128@final
129class FocalPlaneFrame(pydantic.BaseModel, frozen=True):
130 """A Euclidean coordinate frame for the focal plane of a camera."""
132 instrument: str = pydantic.Field(description="Name of the instrument.")
133 visit: int | None = pydantic.Field(
134 default=None,
135 description=(
136 "ID of the visit. May be unset in contexts where there "
137 "is no visit or only a relevant single visit."
138 ),
139 exclude_if=is_none,
140 )
141 unit: Unit = pydantic.Field(description="Units of the coordinates in this frame.")
143 frame_type: Literal["FOCAL_PLANE"] = pydantic.Field(
144 default="FOCAL_PLANE", description="Discriminator for the frame type."
145 )
147 def standardize_x[T: float | np.ndarray](self, x: T) -> T:
148 """Coerce ``x`` coordinates into their standard range."""
149 return x
151 def standardize_y[T: float | np.ndarray](self, y: T) -> T:
152 """Coerce ``y`` coordinates into their standard range."""
153 return y
155 def serialize(self) -> FocalPlaneFrame:
156 """Return a Pydantic-serializable version of this Frame."""
157 return self
159 def deserialize(self) -> FocalPlaneFrame:
160 """Convert a serialized frame to an in-memory one."""
161 return self
163 @property
164 def _ast_ident(self) -> str:
165 return f"{_camera_ast_ident(self.instrument, self.visit)}/FOCAL_PLANE"
168@final
169class FieldAngleFrame(pydantic.BaseModel, frozen=True):
170 """An angular coordinate frame that maps a camera onto the sky about its
171 boresight.
173 Notes
174 -----
175 The transform between a `FocalPlaneFrame` and a `FieldAngleFrame` includes
176 optical distortions but no rotation. It may include a parity flip.
177 """
179 instrument: str = pydantic.Field(description="Name of the instrument.")
180 visit: int | None = pydantic.Field(
181 default=None,
182 description=(
183 "ID of the visit. May be unset in contexts where there "
184 "is no visit or only a relevant single visit."
185 ),
186 exclude_if=is_none,
187 )
188 frame_type: Literal["FIELD_ANGLE"] = pydantic.Field(
189 default="FIELD_ANGLE", description="Discriminator for the frame type."
190 )
192 @property
193 def unit(self) -> u.UnitBase:
194 """Units of the coordinates in this frame
195 (`astropy.units.UnitBase`).
196 """
197 return u.rad
199 def standardize_x[T: float | np.ndarray](self, x: T) -> T:
200 """Coerce ``x`` coordinates into their standard range."""
201 return _wrap_symmetric(x)
203 def standardize_y[T: float | np.ndarray](self, y: T) -> T:
204 """Coerce ``y`` coordinates into their standard range."""
205 return _wrap_symmetric(y)
207 def serialize(self) -> FieldAngleFrame:
208 """Return a Pydantic-serializable version of this Frame."""
209 return self
211 def deserialize(self) -> FieldAngleFrame:
212 """Convert a serialized frame to an in-memory one."""
213 return self
215 @property
216 def _ast_ident(self) -> str:
217 return f"{_camera_ast_ident(self.instrument, self.visit)}/FIELD_ANGLE"
220@final
221class TractFrame(pydantic.BaseModel, frozen=True):
222 """The pixel coordinates of a tract: a region on the sky used for
223 coaddition, defined by a 'skymap' and split into 'patches' that share
224 a common pixel grid.
225 """
227 skymap: str = pydantic.Field(description="Name of the skymap.")
228 tract: int = pydantic.Field(description="ID of the tract within its skymap.")
229 bbox: Box = pydantic.Field(description="Bounding box of the full tract.")
230 frame_type: Literal["TRACT"] = pydantic.Field(
231 default="TRACT", description="Discriminator for the frame type."
232 )
234 @property
235 def unit(self) -> u.UnitBase:
236 """Units of the coordinates in this frame
237 (`astropy.units.UnitBase`).
238 """
239 return u.pix
241 def standardize_x[T: float | np.ndarray](self, x: T) -> T:
242 """Coerce ``x`` coordinates into their standard range."""
243 return x
245 def standardize_y[T: float | np.ndarray](self, y: T) -> T:
246 """Coerce ``y`` coordinates into their standard range."""
247 return y
249 def serialize(self) -> TractFrame:
250 """Return a Pydantic-serializable version of this Frame."""
251 return self
253 def deserialize(self) -> TractFrame:
254 """Convert a serialized frame to an in-memory one."""
255 return self
257 @property
258 def _ast_ident(self) -> str:
259 return f"{self.skymap}@{self.tract}"
262@final
263class GeneralFrame(pydantic.BaseModel, frozen=True):
264 """An arbitrary Euclidean coordinate system."""
266 unit: Unit = pydantic.Field(description="Units of the coordinates in this frame.")
268 frame_type: Literal["GENERAL"] = pydantic.Field(
269 default="GENERAL", description="Discriminator for the frame type."
270 )
272 def standardize_x[T: float | np.ndarray](self, x: T) -> T:
273 """Coerce ``x`` coordinates into their standard range."""
274 return x
276 def standardize_y[T: float | np.ndarray](self, y: T) -> T:
277 """Coerce ``y`` coordinates into their standard range."""
278 return y
280 def serialize(self) -> GeneralFrame:
281 """Return a Pydantic-serializable version of this Frame."""
282 return self
284 def deserialize(self) -> GeneralFrame:
285 """Convert a serialized frame to an in-memory one."""
286 return self
288 @property
289 def _ast_ident(self) -> str:
290 return "GENERAL"
293class SkyFrame(enum.StrEnum):
294 """The special frame that represents the sky, in ICRS coordinates."""
296 ICRS = "ICRS"
298 @property
299 def unit(self) -> u.UnitBase:
300 """Units of the coordinates in this frame
301 (`astropy.units.UnitBase`).
302 """
303 return u.rad
305 def standardize_x[T: float | np.ndarray](self, x: T) -> T:
306 """Coerce ``x`` coordinates into their standard range."""
307 return _wrap_positive(x)
309 def standardize_y[T: float | np.ndarray](self, y: T) -> T:
310 """Coerce ``x`` coordinates into their standard range."""
311 return _wrap_symmetric(y)
313 def serialize(self) -> SkyFrame:
314 """Return a Pydantic-serializable version of this Frame."""
315 return self
317 def deserialize(self) -> SkyFrame:
318 """Convert a serialized frame to an in-memory one."""
319 return self
321 @property
322 def _ast_ident(self) -> str:
323 return self.value
326ICRS = SkyFrame.ICRS
329type SerializableFrame = (
330 SkyFrame
331 | Annotated[
332 DetectorFrame | TractFrame | FocalPlaneFrame | FieldAngleFrame | GeneralFrame,
333 pydantic.Field(discriminator="frame_type"),
334 ]
335)
338_TWOPI: float = np.pi * 2
341def _camera_ast_ident(instrument: str, visit: int | None) -> str:
342 return f"{instrument}@{visit}" if visit is not None else instrument
345def _wrap_positive[T: float | np.ndarray](a: T) -> T:
346 return a % _TWOPI # type: ignore[return-value]
349def _wrap_symmetric[T: float | np.ndarray](a: T) -> T:
350 return (a + np.pi) % _TWOPI - np.pi # type: ignore[return-value]