Coverage for python/lsst/images/_transforms/_frames.py: 86%
140 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-01 09:14 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-01 09:14 +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.
51 Parameters
52 ----------
53 x
54 ``x`` coordinate values to standardize.
55 """
56 ...
58 def standardize_y[T: float | np.ndarray](self, y: T) -> T:
59 """Coerce ``y`` coordinates into their standard range.
61 Parameters
62 ----------
63 y
64 ``y`` coordinate values to standardize.
65 """
66 ...
68 def serialize(self) -> SerializableFrame:
69 """Return a Pydantic-serializable version of this Frame.
71 Notes
72 -----
73 The returned object must support direct nesting with Pydantic models
74 and have a ``deserialize`` method (taking no arguments) that converts
75 back to this `Frame` type. It is common for `serialize` and
76 ``deserialize`` to just return ``self``, when the frame object is
77 natively serializable.
78 """
79 ...
81 @property
82 def _ast_ident(self) -> str:
83 """String to use as the 'Ident' attribute of an AST Frame."""
84 ...
87@final
88class DetectorFrame(pydantic.BaseModel, frozen=True):
89 """A coordinate frame for a particular detector's pixels.
91 Notes
92 -----
93 This frame is only used for post-assembly images (i.e. not those with
94 overscan regions still present).
95 """
97 instrument: str = pydantic.Field(description="Name of the instrument.")
98 visit: int | None = pydantic.Field(
99 default=None,
100 description=(
101 "ID of the visit. May be unset in contexts where there "
102 "is no visit or only a single relevant visit."
103 ),
104 exclude_if=is_none,
105 )
106 detector: int = pydantic.Field(description="ID of the detector.")
107 bbox: Box = pydantic.Field(description="Bounding box of the detector.")
108 frame_type: Literal["DETECTOR"] = pydantic.Field(
109 default="DETECTOR", description="Discriminator for the frame type."
110 )
112 @property
113 def unit(self) -> u.UnitBase:
114 """Units of the coordinates in this frame
115 (`astropy.units.UnitBase`).
116 """
117 return u.pix
119 def standardize_x[T: float | np.ndarray](self, x: T) -> T:
120 """Coerce ``x`` coordinates into their standard range.
122 Parameters
123 ----------
124 x
125 ``x`` coordinate values to standardize.
126 """
127 return x
129 def standardize_y[T: float | np.ndarray](self, y: T) -> T:
130 """Coerce ``y`` coordinates into their standard range.
132 Parameters
133 ----------
134 y
135 ``y`` coordinate values to standardize.
136 """
137 return y
139 def serialize(self) -> DetectorFrame:
140 """Return a Pydantic-serializable version of this Frame."""
141 return self
143 def deserialize(self) -> DetectorFrame:
144 """Convert a serialized frame to an in-memory one."""
145 return self
147 @property
148 def _ast_ident(self) -> str:
149 return f"{_camera_ast_ident(self.instrument, self.visit)}/DETECTOR_{self.detector:03d}"
152@final
153class FocalPlaneFrame(pydantic.BaseModel, frozen=True):
154 """A Euclidean coordinate frame for the focal plane of a camera."""
156 instrument: str = pydantic.Field(description="Name of the instrument.")
157 visit: int | None = pydantic.Field(
158 default=None,
159 description=(
160 "ID of the visit. May be unset in contexts where there "
161 "is no visit or only a relevant single visit."
162 ),
163 exclude_if=is_none,
164 )
165 unit: Unit = pydantic.Field(description="Units of the coordinates in this frame.")
167 frame_type: Literal["FOCAL_PLANE"] = pydantic.Field(
168 default="FOCAL_PLANE", description="Discriminator for the frame type."
169 )
171 def standardize_x[T: float | np.ndarray](self, x: T) -> T:
172 """Coerce ``x`` coordinates into their standard range.
174 Parameters
175 ----------
176 x
177 Coordinates to standardize.
178 """
179 return x
181 def standardize_y[T: float | np.ndarray](self, y: T) -> T:
182 """Coerce ``y`` coordinates into their standard range.
184 Parameters
185 ----------
186 y
187 Coordinates to standardize.
188 """
189 return y
191 def serialize(self) -> FocalPlaneFrame:
192 """Return a Pydantic-serializable version of this Frame."""
193 return self
195 def deserialize(self) -> FocalPlaneFrame:
196 """Convert a serialized frame to an in-memory one."""
197 return self
199 @property
200 def _ast_ident(self) -> str:
201 return f"{_camera_ast_ident(self.instrument, self.visit)}/FOCAL_PLANE"
204@final
205class FieldAngleFrame(pydantic.BaseModel, frozen=True):
206 """An angular coordinate frame that maps a camera onto the sky about its
207 boresight.
209 Notes
210 -----
211 The transform between a `FocalPlaneFrame` and a `FieldAngleFrame` includes
212 optical distortions but no rotation. It may include a parity flip.
213 """
215 instrument: str = pydantic.Field(description="Name of the instrument.")
216 visit: int | None = pydantic.Field(
217 default=None,
218 description=(
219 "ID of the visit. May be unset in contexts where there "
220 "is no visit or only a relevant single visit."
221 ),
222 exclude_if=is_none,
223 )
224 frame_type: Literal["FIELD_ANGLE"] = pydantic.Field(
225 default="FIELD_ANGLE", description="Discriminator for the frame type."
226 )
228 @property
229 def unit(self) -> u.UnitBase:
230 """Units of the coordinates in this frame
231 (`astropy.units.UnitBase`).
232 """
233 return u.rad
235 def standardize_x[T: float | np.ndarray](self, x: T) -> T:
236 """Coerce ``x`` coordinates into their standard range.
238 Parameters
239 ----------
240 x
241 Coordinates to standardize.
242 """
243 return _wrap_symmetric(x)
245 def standardize_y[T: float | np.ndarray](self, y: T) -> T:
246 """Coerce ``y`` coordinates into their standard range.
248 Parameters
249 ----------
250 y
251 Coordinates to standardize.
252 """
253 return _wrap_symmetric(y)
255 def serialize(self) -> FieldAngleFrame:
256 """Return a Pydantic-serializable version of this Frame."""
257 return self
259 def deserialize(self) -> FieldAngleFrame:
260 """Convert a serialized frame to an in-memory one."""
261 return self
263 @property
264 def _ast_ident(self) -> str:
265 return f"{_camera_ast_ident(self.instrument, self.visit)}/FIELD_ANGLE"
268@final
269class TractFrame(pydantic.BaseModel, frozen=True):
270 """The pixel coordinates of a tract: a region on the sky used for
271 coaddition, defined by a 'skymap' and split into 'patches' that share
272 a common pixel grid.
273 """
275 skymap: str = pydantic.Field(description="Name of the skymap.")
276 tract: int = pydantic.Field(description="ID of the tract within its skymap.")
277 bbox: Box = pydantic.Field(description="Bounding box of the full tract.")
278 frame_type: Literal["TRACT"] = pydantic.Field(
279 default="TRACT", description="Discriminator for the frame type."
280 )
282 @property
283 def unit(self) -> u.UnitBase:
284 """Units of the coordinates in this frame
285 (`astropy.units.UnitBase`).
286 """
287 return u.pix
289 def standardize_x[T: float | np.ndarray](self, x: T) -> T:
290 """Coerce ``x`` coordinates into their standard range.
292 Parameters
293 ----------
294 x
295 Coordinates to standardize.
296 """
297 return x
299 def standardize_y[T: float | np.ndarray](self, y: T) -> T:
300 """Coerce ``y`` coordinates into their standard range.
302 Parameters
303 ----------
304 y
305 Coordinates to standardize.
306 """
307 return y
309 def serialize(self) -> TractFrame:
310 """Return a Pydantic-serializable version of this Frame."""
311 return self
313 def deserialize(self) -> TractFrame:
314 """Convert a serialized frame to an in-memory one."""
315 return self
317 @property
318 def _ast_ident(self) -> str:
319 return f"{self.skymap}@{self.tract}"
322@final
323class GeneralFrame(pydantic.BaseModel, frozen=True):
324 """An arbitrary Euclidean coordinate system."""
326 unit: Unit = pydantic.Field(description="Units of the coordinates in this frame.")
328 frame_type: Literal["GENERAL"] = pydantic.Field(
329 default="GENERAL", description="Discriminator for the frame type."
330 )
332 def standardize_x[T: float | np.ndarray](self, x: T) -> T:
333 """Coerce ``x`` coordinates into their standard range.
335 Parameters
336 ----------
337 x
338 Coordinates to standardize.
339 """
340 return x
342 def standardize_y[T: float | np.ndarray](self, y: T) -> T:
343 """Coerce ``y`` coordinates into their standard range.
345 Parameters
346 ----------
347 y
348 Coordinates to standardize.
349 """
350 return y
352 def serialize(self) -> GeneralFrame:
353 """Return a Pydantic-serializable version of this Frame."""
354 return self
356 def deserialize(self) -> GeneralFrame:
357 """Convert a serialized frame to an in-memory one."""
358 return self
360 @property
361 def _ast_ident(self) -> str:
362 return "GENERAL"
365class SkyFrame(enum.StrEnum):
366 """The special frame that represents the sky, in ICRS coordinates."""
368 ICRS = "ICRS"
370 @property
371 def unit(self) -> u.UnitBase:
372 """Units of the coordinates in this frame
373 (`astropy.units.UnitBase`).
374 """
375 return u.rad
377 def standardize_x[T: float | np.ndarray](self, x: T) -> T:
378 """Coerce ``x`` coordinates into their standard range.
380 Parameters
381 ----------
382 x
383 Coordinates to standardize.
384 """
385 return _wrap_positive(x)
387 def standardize_y[T: float | np.ndarray](self, y: T) -> T:
388 """Coerce ``y`` coordinates into their standard range.
390 Parameters
391 ----------
392 y
393 Coordinates to standardize.
394 """
395 return _wrap_symmetric(y)
397 def serialize(self) -> SkyFrame:
398 """Return a Pydantic-serializable version of this Frame."""
399 return self
401 def deserialize(self) -> SkyFrame:
402 """Convert a serialized frame to an in-memory one."""
403 return self
405 @property
406 def _ast_ident(self) -> str:
407 return self.value
410ICRS = SkyFrame.ICRS
413type SerializableFrame = (
414 SkyFrame
415 | Annotated[
416 DetectorFrame | TractFrame | FocalPlaneFrame | FieldAngleFrame | GeneralFrame,
417 pydantic.Field(discriminator="frame_type"),
418 ]
419)
422_TWOPI: float = np.pi * 2
425def _camera_ast_ident(instrument: str, visit: int | None) -> str:
426 return f"{instrument}@{visit}" if visit is not None else instrument
429def _wrap_positive[T: float | np.ndarray](a: T) -> T:
430 return a % _TWOPI # type: ignore[return-value]
433def _wrap_symmetric[T: float | np.ndarray](a: T) -> T:
434 return (a + np.pi) % _TWOPI - np.pi # type: ignore[return-value]