Coverage for python / lsst / images / _transforms / _frames.py: 71%
133 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-17 09:16 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-17 09:16 +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 "SerializableFrame",
21 "SkyFrame",
22 "TractFrame",
23)
25import enum
26from typing import Annotated, Literal, Protocol, Self, cast, final
28import astropy.units as u
29import numpy as np
30import pydantic
32from .._geom import Box
33from ..serialization import ArchiveTree, Unit
34from ..utils import is_none
37class Frame(Protocol):
38 """A description of a coordinate system."""
40 @property
41 def unit(self) -> u.UnitBase:
42 """Units of the coordinates in this frame
43 (`astropy.units.UnitBase`).
44 """
45 ...
47 def standardize_x[T: float | np.ndarray](self, x: T) -> T:
48 """Coerce ``x`` coordinates into their standard range."""
49 ...
51 def standardize_y[T: float | np.ndarray](self, y: T) -> T:
52 """Coerce ``y`` coordinates into their standard range."""
53 ...
55 # At present all Frames are members of the same serializable Union,
56 # so their serialized form is just the original frame. But this may not
57 # always be the case.
59 def serialize(self) -> SerializableFrame:
60 """Return a Pydantic-serializable version of this Frame."""
61 ...
63 @classmethod
64 def deserialize(cls, serialized: SerializableFrame) -> Self:
65 """Convert a serialized frame to an in-memory one."""
66 return cast(Self, serialized)
68 @property
69 def _ast_ident(self) -> str:
70 """String to use as the 'Ident' attribute of an AST Frame."""
71 ...
74@final
75class DetectorFrame(ArchiveTree, frozen=True):
76 """A coordinate frame for a particular detector's pixels.
78 Notes
79 -----
80 This frame is only used for post-assembly images (i.e. not those with
81 overscan regions still present).
82 """
84 instrument: str = pydantic.Field(description="Name of the instrument.")
85 visit: int | None = pydantic.Field(
86 default=None,
87 description=(
88 "ID of the visit. May be unset in contexts where there "
89 "is no visit or only a single relevant visit."
90 ),
91 exclude_if=is_none,
92 )
93 detector: int = pydantic.Field(description="ID of the detector.")
94 bbox: Box = pydantic.Field(description="Bounding box of the detector.")
95 frame_type: Literal["DETECTOR"] = pydantic.Field(
96 default="DETECTOR", description="Descriminator for the frame type."
97 )
99 @property
100 def unit(self) -> u.UnitBase:
101 """Units of the coordinates in this frame
102 (`astropy.units.UnitBase`).
103 """
104 return u.pix
106 def standardize_x[T: float | np.ndarray](self, x: T) -> T:
107 """Coerce ``x`` coordinates into their standard range."""
108 return x
110 def standardize_y[T: float | np.ndarray](self, y: T) -> T:
111 """Coerce ``y`` coordinates into their standard range."""
112 return y
114 def serialize(self) -> SerializableFrame:
115 """Return a Pydantic-serializable version of this Frame."""
116 return cast(SerializableFrame, self)
118 @classmethod
119 def deserialize(cls, serialized: SerializableFrame) -> Self:
120 """Convert a serialized frame to an in-memory one."""
121 return cast(Self, serialized)
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(ArchiveTree, frozen=True):
130 """A Euclidian 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="Descriminator 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) -> SerializableFrame:
156 """Return a Pydantic-serializable version of this Frame."""
157 return cast(SerializableFrame, self)
159 @classmethod
160 def deserialize(cls, serialized: SerializableFrame) -> Self:
161 """Convert a serialized frame to an in-memory one."""
162 return cast(Self, serialized)
164 @property
165 def _ast_ident(self) -> str:
166 return f"{_camera_ast_ident(self.instrument, self.visit)}/FOCAL_PLANE"
169@final
170class FieldAngleFrame(ArchiveTree, frozen=True):
171 """An angular coordinate frame that maps a camera onto the sky about its
172 boresight.
174 Notes
175 -----
176 The transform between a `FocalPlaneFrame` and a `FieldAngleFrame` includes
177 optical distortions but no rotation. It may include a parity flip.
178 """
180 instrument: str = pydantic.Field(description="Name of the instrument.")
181 visit: int | None = pydantic.Field(
182 default=None,
183 description=(
184 "ID of the visit. May be unset in contexts where there "
185 "is no visit or only a relevant single visit."
186 ),
187 exclude_if=is_none,
188 )
189 frame_type: Literal["FIELD_ANGLE"] = pydantic.Field(
190 default="FIELD_ANGLE", description="Descriminator for the frame type."
191 )
193 @property
194 def unit(self) -> u.UnitBase:
195 """Units of the coordinates in this frame
196 (`astropy.units.UnitBase`).
197 """
198 return u.rad
200 def standardize_x[T: float | np.ndarray](self, x: T) -> T:
201 """Coerce ``x`` coordinates into their standard range."""
202 return _wrap_symmetric(x)
204 def standardize_y[T: float | np.ndarray](self, y: T) -> T:
205 """Coerce ``y`` coordinates into their standard range."""
206 return _wrap_symmetric(y)
208 def serialize(self) -> SerializableFrame:
209 """Return a Pydantic-serializable version of this Frame."""
210 return cast(SerializableFrame, self)
212 @classmethod
213 def deserialize(cls, serialized: SerializableFrame) -> Self:
214 """Convert a serialized frame to an in-memory one."""
215 return cast(Self, serialized)
217 @property
218 def _ast_ident(self) -> str:
219 return f"{_camera_ast_ident(self.instrument, self.visit)}/FIELD_ANGLE"
222@final
223class TractFrame(ArchiveTree, frozen=True):
224 """The pixel coordinates of a tract: a region on the sky used for
225 coaddition, defined by a 'skymap' and split into 'patches' that share
226 a common pixel grid.
227 """
229 skymap: str = pydantic.Field(description="Name of the skymap.")
230 tract: int = pydantic.Field(description="ID of the tract within its skymap.")
231 bbox: Box = pydantic.Field(description="Bounding box of the full tract.")
232 frame_type: Literal["TRACT"] = pydantic.Field(
233 default="TRACT", description="Descriminator for the frame type."
234 )
236 @property
237 def unit(self) -> u.UnitBase:
238 """Units of the coordinates in this frame
239 (`astropy.units.UnitBase`).
240 """
241 return u.pix
243 def standardize_x[T: float | np.ndarray](self, x: T) -> T:
244 """Coerce ``x`` coordinates into their standard range."""
245 return x
247 def standardize_y[T: float | np.ndarray](self, y: T) -> T:
248 """Coerce ``y`` coordinates into their standard range."""
249 return y
251 def serialize(self) -> SerializableFrame:
252 """Return a Pydantic-serializable version of this Frame."""
253 return cast(SerializableFrame, self)
255 @classmethod
256 def deserialize(cls, serialized: SerializableFrame) -> Self:
257 """Convert a serialized frame to an in-memory one."""
258 return cast(Self, serialized)
260 @property
261 def _ast_ident(self) -> str:
262 return f"{self.skymap}@{self.tract}"
265class SkyFrame(enum.StrEnum):
266 """The special frame that represents the sky, in ICRS coordinates."""
268 ICRS = "ICRS"
270 @property
271 def unit(self) -> u.UnitBase:
272 """Units of the coordinates in this frame
273 (`astropy.units.UnitBase`).
274 """
275 return u.rad
277 def standardize_x[T: float | np.ndarray](self, x: T) -> T:
278 """Coerce ``x`` coordinates into their standard range."""
279 return _wrap_positive(x)
281 def standardize_y[T: float | np.ndarray](self, y: T) -> T:
282 """Coerce ``x`` coordinates into their standard range."""
283 return _wrap_symmetric(y)
285 def serialize(self) -> SerializableFrame:
286 """Return a Pydantic-serializable version of this Frame."""
287 return cast(SerializableFrame, self)
289 @classmethod
290 def deserialize(cls, serialized: SerializableFrame) -> Self:
291 """Convert a serialized frame to an in-memory one."""
292 return cast(Self, serialized)
294 @property
295 def _ast_ident(self) -> str:
296 return self.value
299ICRS = SkyFrame.ICRS
302type SerializableFrame = (
303 SkyFrame
304 | Annotated[
305 DetectorFrame | TractFrame | FocalPlaneFrame | FieldAngleFrame,
306 pydantic.Field(discriminator="frame_type"),
307 ]
308)
311_TWOPI: float = np.pi * 2
314def _camera_ast_ident(instrument: str, visit: int | None) -> str:
315 return f"{instrument}@{visit}" if visit is not None else instrument
318def _wrap_positive[T: float | np.ndarray](a: T) -> T:
319 return a % _TWOPI # type: ignore[return-value]
322def _wrap_symmetric[T: float | np.ndarray](a: T) -> T:
323 return (a + np.pi) % _TWOPI - np.pi # type: ignore[return-value]