Coverage for python/lsst/images/_transforms/_frames.py: 18%
154 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__ = (
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 ..describe import DescribableMixin, DescribeOptions, FieldRole, Report, ReportField
35from ..serialization import Unit
36from ..utils import is_none
39def _frame_report(frame: pydantic.BaseModel) -> Report:
40 """Return a `Report` describing a pydantic frame model.
42 Parameters
43 ----------
44 frame
45 A frozen pydantic model representing a coordinate frame.
46 """
47 # These are the model's own fields, so they are constructor arguments even
48 # though pydantic's repr, not the report, is what currently renders them.
49 fields = [
50 ReportField(label=name, value=getattr(frame, name), role=FieldRole.ARG)
51 for name in type(frame).model_fields
52 ]
53 return Report(type_name=type(frame).__name__, fields=fields)
56class Frame(Protocol):
57 """A description of a coordinate system."""
59 @property
60 def unit(self) -> u.UnitBase:
61 """Units of the coordinates in this frame
62 (`astropy.units.UnitBase`).
63 """
64 ...
66 def standardize_x[T: float | np.ndarray](self, x: T) -> T:
67 """Coerce ``x`` coordinates into their standard range.
69 Parameters
70 ----------
71 x
72 ``x`` coordinate values to standardize.
73 """
74 ...
76 def standardize_y[T: float | np.ndarray](self, y: T) -> T:
77 """Coerce ``y`` coordinates into their standard range.
79 Parameters
80 ----------
81 y
82 ``y`` coordinate values to standardize.
83 """
84 ...
86 def serialize(self) -> SerializableFrame:
87 """Return a Pydantic-serializable version of this Frame.
89 Notes
90 -----
91 The returned object must support direct nesting with Pydantic models
92 and have a ``deserialize`` method (taking no arguments) that converts
93 back to this `Frame` type. It is common for `serialize` and
94 ``deserialize`` to just return ``self``, when the frame object is
95 natively serializable.
96 """
97 ...
99 @property
100 def _ast_ident(self) -> str:
101 """String to use as the 'Ident' attribute of an AST Frame."""
102 ...
105@final
106class DetectorFrame(pydantic.BaseModel, DescribableMixin, frozen=True):
107 """A coordinate frame for a particular detector's pixels.
109 Notes
110 -----
111 This frame is only used for post-assembly images (i.e. not those with
112 overscan regions still present).
113 """
115 instrument: str = pydantic.Field(description="Name of the instrument.")
116 visit: int | None = pydantic.Field(
117 default=None,
118 description=(
119 "ID of the visit. May be unset in contexts where there "
120 "is no visit or only a single relevant visit."
121 ),
122 exclude_if=is_none,
123 )
124 detector: int = pydantic.Field(description="ID of the detector.")
125 bbox: Box = pydantic.Field(description="Bounding box of the detector.")
126 frame_type: Literal["DETECTOR"] = pydantic.Field(
127 default="DETECTOR", description="Discriminator for the frame type."
128 )
130 @property
131 def unit(self) -> u.UnitBase:
132 """Units of the coordinates in this frame
133 (`astropy.units.UnitBase`).
134 """
135 return u.pix
137 def standardize_x[T: float | np.ndarray](self, x: T) -> T:
138 """Coerce ``x`` coordinates into their standard range.
140 Parameters
141 ----------
142 x
143 ``x`` coordinate values to standardize.
144 """
145 return x
147 def standardize_y[T: float | np.ndarray](self, y: T) -> T:
148 """Coerce ``y`` coordinates into their standard range.
150 Parameters
151 ----------
152 y
153 ``y`` coordinate values to standardize.
154 """
155 return y
157 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report:
158 """Return a `Report` describing this detector frame.
160 Parameters
161 ----------
162 options : `DescribeOptions`, optional
163 Unused; accepted for interface compatibility.
164 """
165 return _frame_report(self)
167 def serialize(self) -> DetectorFrame:
168 """Return a Pydantic-serializable version of this Frame."""
169 return self
171 def deserialize(self) -> DetectorFrame:
172 """Convert a serialized frame to an in-memory one."""
173 return self
175 @property
176 def _ast_ident(self) -> str:
177 return f"{_camera_ast_ident(self.instrument, self.visit)}/DETECTOR_{self.detector:03d}"
180@final
181class FocalPlaneFrame(pydantic.BaseModel, DescribableMixin, frozen=True):
182 """A Euclidean coordinate frame for the focal plane of a camera."""
184 instrument: str = pydantic.Field(description="Name of the instrument.")
185 visit: int | None = pydantic.Field(
186 default=None,
187 description=(
188 "ID of the visit. May be unset in contexts where there "
189 "is no visit or only a relevant single visit."
190 ),
191 exclude_if=is_none,
192 )
193 unit: Unit = pydantic.Field(description="Units of the coordinates in this frame.")
195 frame_type: Literal["FOCAL_PLANE"] = pydantic.Field(
196 default="FOCAL_PLANE", description="Discriminator for the frame type."
197 )
199 def standardize_x[T: float | np.ndarray](self, x: T) -> T:
200 """Coerce ``x`` coordinates into their standard range.
202 Parameters
203 ----------
204 x
205 Coordinates to standardize.
206 """
207 return x
209 def standardize_y[T: float | np.ndarray](self, y: T) -> T:
210 """Coerce ``y`` coordinates into their standard range.
212 Parameters
213 ----------
214 y
215 Coordinates to standardize.
216 """
217 return y
219 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report:
220 """Return a `Report` describing this focal plane frame.
222 Parameters
223 ----------
224 options : `DescribeOptions`, optional
225 Unused; accepted for interface compatibility.
226 """
227 return _frame_report(self)
229 def serialize(self) -> FocalPlaneFrame:
230 """Return a Pydantic-serializable version of this Frame."""
231 return self
233 def deserialize(self) -> FocalPlaneFrame:
234 """Convert a serialized frame to an in-memory one."""
235 return self
237 @property
238 def _ast_ident(self) -> str:
239 return f"{_camera_ast_ident(self.instrument, self.visit)}/FOCAL_PLANE"
242@final
243class FieldAngleFrame(pydantic.BaseModel, DescribableMixin, frozen=True):
244 """An angular coordinate frame that maps a camera onto the sky about its
245 boresight.
247 Notes
248 -----
249 The transform between a `FocalPlaneFrame` and a `FieldAngleFrame` includes
250 optical distortions but no rotation. It may include a parity flip.
251 """
253 instrument: str = pydantic.Field(description="Name of the instrument.")
254 visit: int | None = pydantic.Field(
255 default=None,
256 description=(
257 "ID of the visit. May be unset in contexts where there "
258 "is no visit or only a relevant single visit."
259 ),
260 exclude_if=is_none,
261 )
262 frame_type: Literal["FIELD_ANGLE"] = pydantic.Field(
263 default="FIELD_ANGLE", description="Discriminator for the frame type."
264 )
266 @property
267 def unit(self) -> u.UnitBase:
268 """Units of the coordinates in this frame
269 (`astropy.units.UnitBase`).
270 """
271 return u.rad
273 def standardize_x[T: float | np.ndarray](self, x: T) -> T:
274 """Coerce ``x`` coordinates into their standard range.
276 Parameters
277 ----------
278 x
279 Coordinates to standardize.
280 """
281 return _wrap_symmetric(x)
283 def standardize_y[T: float | np.ndarray](self, y: T) -> T:
284 """Coerce ``y`` coordinates into their standard range.
286 Parameters
287 ----------
288 y
289 Coordinates to standardize.
290 """
291 return _wrap_symmetric(y)
293 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report:
294 """Return a `Report` describing this field angle frame.
296 Parameters
297 ----------
298 options : `DescribeOptions`, optional
299 Unused; accepted for interface compatibility.
300 """
301 return _frame_report(self)
303 def serialize(self) -> FieldAngleFrame:
304 """Return a Pydantic-serializable version of this Frame."""
305 return self
307 def deserialize(self) -> FieldAngleFrame:
308 """Convert a serialized frame to an in-memory one."""
309 return self
311 @property
312 def _ast_ident(self) -> str:
313 return f"{_camera_ast_ident(self.instrument, self.visit)}/FIELD_ANGLE"
316@final
317class TractFrame(pydantic.BaseModel, DescribableMixin, frozen=True):
318 """The pixel coordinates of a tract: a region on the sky used for
319 coaddition, defined by a 'skymap' and split into 'patches' that share
320 a common pixel grid.
321 """
323 skymap: str = pydantic.Field(description="Name of the skymap.")
324 tract: int = pydantic.Field(description="ID of the tract within its skymap.")
325 bbox: Box = pydantic.Field(description="Bounding box of the full tract.")
326 frame_type: Literal["TRACT"] = pydantic.Field(
327 default="TRACT", description="Discriminator for the frame type."
328 )
330 @property
331 def unit(self) -> u.UnitBase:
332 """Units of the coordinates in this frame
333 (`astropy.units.UnitBase`).
334 """
335 return u.pix
337 def standardize_x[T: float | np.ndarray](self, x: T) -> T:
338 """Coerce ``x`` coordinates into their standard range.
340 Parameters
341 ----------
342 x
343 Coordinates to standardize.
344 """
345 return x
347 def standardize_y[T: float | np.ndarray](self, y: T) -> T:
348 """Coerce ``y`` coordinates into their standard range.
350 Parameters
351 ----------
352 y
353 Coordinates to standardize.
354 """
355 return y
357 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report:
358 """Return a `Report` describing this tract frame.
360 Parameters
361 ----------
362 options : `DescribeOptions`, optional
363 Unused; accepted for interface compatibility.
364 """
365 return _frame_report(self)
367 def serialize(self) -> TractFrame:
368 """Return a Pydantic-serializable version of this Frame."""
369 return self
371 def deserialize(self) -> TractFrame:
372 """Convert a serialized frame to an in-memory one."""
373 return self
375 @property
376 def _ast_ident(self) -> str:
377 return f"{self.skymap}@{self.tract}"
380@final
381class GeneralFrame(pydantic.BaseModel, DescribableMixin, frozen=True):
382 """An arbitrary Euclidean coordinate system."""
384 unit: Unit = pydantic.Field(description="Units of the coordinates in this frame.")
386 frame_type: Literal["GENERAL"] = pydantic.Field(
387 default="GENERAL", description="Discriminator for the frame type."
388 )
390 def standardize_x[T: float | np.ndarray](self, x: T) -> T:
391 """Coerce ``x`` coordinates into their standard range.
393 Parameters
394 ----------
395 x
396 Coordinates to standardize.
397 """
398 return x
400 def standardize_y[T: float | np.ndarray](self, y: T) -> T:
401 """Coerce ``y`` coordinates into their standard range.
403 Parameters
404 ----------
405 y
406 Coordinates to standardize.
407 """
408 return y
410 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report:
411 """Return a `Report` describing this general frame.
413 Parameters
414 ----------
415 options : `DescribeOptions`, optional
416 Unused; accepted for interface compatibility.
417 """
418 return _frame_report(self)
420 def serialize(self) -> GeneralFrame:
421 """Return a Pydantic-serializable version of this Frame."""
422 return self
424 def deserialize(self) -> GeneralFrame:
425 """Convert a serialized frame to an in-memory one."""
426 return self
428 @property
429 def _ast_ident(self) -> str:
430 return "GENERAL"
433class SkyFrame(enum.StrEnum):
434 """The special frame that represents the sky, in ICRS coordinates."""
436 ICRS = "ICRS"
438 @property
439 def unit(self) -> u.UnitBase:
440 """Units of the coordinates in this frame
441 (`astropy.units.UnitBase`).
442 """
443 return u.rad
445 def standardize_x[T: float | np.ndarray](self, x: T) -> T:
446 """Coerce ``x`` coordinates into their standard range.
448 Parameters
449 ----------
450 x
451 Coordinates to standardize.
452 """
453 return _wrap_positive(x)
455 def standardize_y[T: float | np.ndarray](self, y: T) -> T:
456 """Coerce ``y`` coordinates into their standard range.
458 Parameters
459 ----------
460 y
461 Coordinates to standardize.
462 """
463 return _wrap_symmetric(y)
465 def serialize(self) -> SkyFrame:
466 """Return a Pydantic-serializable version of this Frame."""
467 return self
469 def deserialize(self) -> SkyFrame:
470 """Convert a serialized frame to an in-memory one."""
471 return self
473 @property
474 def _ast_ident(self) -> str:
475 return self.value
478ICRS = SkyFrame.ICRS
481type SerializableFrame = (
482 SkyFrame
483 | Annotated[
484 DetectorFrame | TractFrame | FocalPlaneFrame | FieldAngleFrame | GeneralFrame,
485 pydantic.Field(discriminator="frame_type"),
486 ]
487)
490_TWOPI: float = np.pi * 2
493def _camera_ast_ident(instrument: str, visit: int | None) -> str:
494 return f"{instrument}@{visit}" if visit is not None else instrument
497def _wrap_positive[T: float | np.ndarray](a: T) -> T:
498 return a % _TWOPI # type: ignore[return-value]
501def _wrap_symmetric[T: float | np.ndarray](a: T) -> T:
502 return (a + np.pi) % _TWOPI - np.pi # type: ignore[return-value]