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