Coverage for python/lsst/images/fields/_base.py: 74%
99 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-16 15:24 -0700
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-16 15:24 -0700
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__ = ("BaseField",)
16from abc import ABC, abstractmethod
17from typing import TYPE_CHECKING, Any, Literal, Self, assert_type, cast, overload
19import astropy.units
20import numpy as np
21import numpy.typing as npt
23from .._geom import XY, YX, Bounds, Box
24from .._image import Image
26if TYPE_CHECKING:
27 try:
28 from lsst.afw.image import PhotoCalib as LegacyPhotoCalib
29 from lsst.afw.math import BoundedField as LegacyBoundedField
30 except ImportError:
31 type LegacyBoundedField = Any # type: ignore[no-redef]
32 type LegacyPhotoCalib = Any # type: ignore[no-redef]
35class BaseField(ABC):
36 """An abstract base class for parametric or interpolated 2-d functions,
37 generally representing some sort of calculated image.
39 Notes
40 -----
41 The field hierarchy is closed to the types in this package, so we can
42 enumerate all of the serializations and avoid any kind of extension system.
43 All field types are immutable.
45 Field types implement the function call operator and both multiplication
46 and division by a constant via operator overloading. Subclasses provide
47 those operations by implementing the ``_evaluate`` and
48 ``_multiply_constant`` hooks (respectively).
50 This interface will probably change in the future to incorporate options
51 for dealing with out-of-bounds positions. At present the behavior for
52 such positions is implementation-specific and should not be relied upon.
53 """
55 @property
56 @abstractmethod
57 def bounds(self) -> Bounds:
58 """The region over which this field can be evaluated (`.Bounds`)."""
59 raise NotImplementedError()
61 @property
62 @abstractmethod
63 def unit(self) -> astropy.units.UnitBase | None:
64 """The units of the field (`astropy.units.UnitBase` or `None`)."""
65 raise NotImplementedError()
67 @property
68 @abstractmethod
69 def is_constant(self) -> bool:
70 """Whether the field is spatially constant (`bool`)."""
71 raise NotImplementedError()
73 @overload
74 def __call__( 74 ↛ exitline 74 didn't return from function '__call__' because
75 self, point: XY[int | float] | YX[int | float], /, *, quantity: Literal[False] = False
76 ) -> float: ...
78 @overload
79 def __call__( 79 ↛ exitline 79 didn't return from function '__call__' because
80 self, point: XY[int | float] | YX[int | float], /, *, quantity: Literal[True]
81 ) -> astropy.units.Quantity: ...
83 @overload
84 def __call__( 84 ↛ exitline 84 didn't return from function '__call__' because
85 self, point: XY[npt.ArrayLike] | YX[npt.ArrayLike], /, *, quantity: bool = ...
86 ) -> np.ndarray | astropy.units.Quantity: ...
88 @overload
89 def __call__(self, /, *, x: int | float, y: int | float, quantity: Literal[False] = False) -> float: ... 89 ↛ exitline 89 didn't return from function '__call__' because
91 @overload
92 def __call__( 92 ↛ exitline 92 didn't return from function '__call__' because
93 self, /, *, x: int | float, y: int | float, quantity: Literal[True]
94 ) -> astropy.units.Quantity: ...
96 @overload
97 def __call__( 97 ↛ exitline 97 didn't return from function '__call__' because
98 self, /, *, x: npt.ArrayLike, y: npt.ArrayLike, quantity: bool = ...
99 ) -> np.ndarray | astropy.units.Quantity: ...
101 def __call__(
102 self,
103 point: XY[Any] | YX[Any] | None = None,
104 /,
105 *,
106 x: Any = None,
107 y: Any = None,
108 quantity: bool = False,
109 ) -> float | np.ndarray | astropy.units.Quantity:
110 match point:
111 case None:
112 if x is None or y is None: 112 ↛ 113line 112 didn't jump to line 113 because the condition on line 112 was never true
113 raise TypeError("Pass either a point or both x= and y= to field call.")
114 case XY() | YX(): 114 ↛ 118line 114 didn't jump to line 118 because the pattern on line 114 always matched
115 if x is not None or y is not None:
116 raise TypeError("Field call point argument is mutually exclusive with x= and y=.")
117 x, y = point.x, point.y
118 case _:
119 raise TypeError(f"Unexpected positional argument type: {type(point)!r}.")
120 x = np.asarray(x)
121 y = np.asarray(y)
122 scalar = not np.broadcast(x, y).shape
123 result = self._evaluate(x=x, y=y, quantity=quantity)
124 if scalar:
125 if quantity:
126 return result # 0-d Quantity
127 return float(result)
128 return result
130 @abstractmethod
131 def render(
132 self,
133 bbox: Box | None = None,
134 *,
135 dtype: np.typing.DTypeLike | None = None,
136 ) -> Image:
137 """Create an image realization of the field.
139 Parameters
140 ----------
141 bbox
142 Bounding box of the image. If not provided, ``self.bounds.bbox``
143 will be used.
144 dtype
145 Pixel data type for the returned image.
146 """
147 raise NotImplementedError()
149 def __mul__(self, factor: float | astropy.units.Quantity | astropy.units.UnitBase) -> Self:
150 return self._multiply_constant(factor)
152 def __rmul__(self, factor: float | astropy.units.Quantity | astropy.units.UnitBase) -> Self:
153 return self._multiply_constant(factor)
155 def __truediv__(self, factor: float | astropy.units.Quantity | astropy.units.UnitBase) -> Self:
156 return self._multiply_constant(1.0 / factor)
158 @abstractmethod
159 def _evaluate(
160 self, *, x: np.ndarray, y: np.ndarray, quantity: bool
161 ) -> np.ndarray | astropy.units.Quantity:
162 """Evaluate at non-gridded points.
164 Parameters
165 ----------
166 x
167 X coordinates to evaluate at.
168 y
169 Y coordinates to evaluate at; must be broadcast-compatible with
170 ``x``.
171 quantity
172 If `True`, return an `astropy.units.Quantity` instead of a
173 `numpy.ndarray`. If `unit` is `None`, the returned object will
174 be a dimensionless `~astropy.units.Quantity`.
175 """
176 raise NotImplementedError()
178 @abstractmethod
179 def _multiply_constant(self, factor: float | astropy.units.Quantity | astropy.units.UnitBase) -> Self:
180 """Multiply by a constant, returning a new field of the same type.
182 Parameters
183 ----------
184 factor
185 Factor to multiply by. When this has units, those should multiply
186 ``self.unit`` or set the units of the returned field if
187 ``self.unit is None``.
188 """
189 raise NotImplementedError()
191 def to_legacy(self) -> LegacyBoundedField:
192 """Convert to a legacy `lsst.afw.math.BoundedField`."""
193 raise NotImplementedError(f"{type(self).__name__} has no lsst.afw.math.BoundedField representation.")
195 def to_legacy_photo_calib(self, image_unit: astropy.units.UnitBase) -> LegacyPhotoCalib:
196 """Convert to a legacy `lsst.afw.image.PhotoCalib`.
198 Parameters
199 ----------
200 image_unit
201 The units of the pixels the returned ``PhotoCalib`` will be
202 associated with.
203 """
204 from lsst.afw.image import PhotoCalib
206 if (result := self.make_legacy_photo_calib(image_unit)) is not None:
207 return result
208 field = self
209 factor = image_unit.to(astropy.units.nJy / self.unit)
210 if factor != 1.0:
211 # TODO[DM-54556]: make sure this shouldn't be 1/factor.
212 field = self._multiply_constant(factor) # this lies about units, but we'll discard them anyway.
213 (field_at_center,) = field(
214 x=np.array([field.bounds.bbox.x.center]),
215 y=np.array([field.bounds.bbox.y.center]),
216 )
217 if field.is_constant:
218 return PhotoCalib(field_at_center)
219 else:
220 # Constructing a legacy PhotoCalib from a BoundedField alone
221 # doesn't always work, because ProductBoundedField doesn't
222 # implement computeMean(). Luckily PhotoCalib doesn't really care
223 # about getting a true mean; it just wants some sort of central
224 # tendency, so we can evaluate the field at the bbox center and use
225 # that (this is what fgcmcal does when it makes a
226 # ProductBoundedField PhotoCalib).
227 return PhotoCalib(
228 calibrationMean=field_at_center,
229 calibrationErr=0.0, # we don't round-trip this; it's not useful
230 calibration=field.to_legacy(),
231 isConstant=False,
232 )
234 @staticmethod
235 def make_legacy_photo_calib(image_unit: astropy.units.UnitBase) -> LegacyPhotoCalib | None:
236 """Make a legacy `lsst.afw.image.PhotoCalib` for an image with the
237 given units, if that is possible without a photometric scaling field.
239 Parameters
240 ----------
241 image_unit
242 Units of the image the photometric calibration applies to.
243 """
244 from lsst.afw.image import PhotoCalib
246 try:
247 factor = image_unit.to(astropy.units.nJy)
248 except astropy.units.UnitConversionError:
249 pass
250 else:
251 return PhotoCalib(factor)
252 return None
254 def _handle_factor_units(
255 self, factor: float | astropy.units.Quantity | astropy.units.UnitBase
256 ) -> tuple[float, astropy.units.UnitBase | None]:
257 """Interpret the ``factor`` argument to `multiply_constant` and apply
258 any units it carries to this field's units.
260 This is a convenience function for subclass implementations of
261 `multiply_constant`.
263 Parameters
264 ----------
265 factor
266 Factor passed by the caller.
268 Returns
269 -------
270 `float`
271 The factor to multiply by as a pure `float`
272 `astropy.units.UnitBase` | `None`
273 The units for the new field returned by `multiply_constant`.
274 """
275 unit = self.unit
276 factor_unit = None
277 if isinstance(factor, astropy.units.Quantity):
278 factor_unit = factor.unit
279 factor = factor.to_value()
280 elif isinstance(factor, astropy.units.UnitBase):
281 factor_unit = factor
282 factor = 1.0
283 if factor_unit is not None:
284 if unit is None:
285 unit = factor_unit
286 else:
287 unit *= factor_unit
288 return factor, unit
291if TYPE_CHECKING:
293 def _test_types() -> None:
294 field = cast(BaseField, None)
295 arr = np.zeros(3)
297 # Scalar inputs without quantity → float
298 assert_type(field(x=1.0, y=2.0), float)
299 assert_type(field(x=1.0, y=2.0, quantity=False), float)
301 # Scalar inputs with quantity=True → astropy.units.Quantity
302 assert_type(field(x=1.0, y=2.0, quantity=True), astropy.units.Quantity)
304 # Array-like inputs → np.ndarray | astropy.units.Quantity
305 assert_type(field(x=arr, y=arr), np.ndarray | astropy.units.Quantity)