Coverage for python/lsst/images/fields/_base.py: 45%
74 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-03 01:09 -0700
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-03 01:09 -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, overload
19import astropy.units
20import numpy as np
22from .._geom import Bounds, Box
23from .._image import Image
25if TYPE_CHECKING:
26 try:
27 from lsst.afw.image import PhotoCalib as LegacyPhotoCalib
28 from lsst.afw.math import BoundedField as LegacyBoundedField
29 except ImportError:
30 type LegacyBoundedField = Any # type: ignore[no-redef]
31 type LegacyPhotoCalib = Any # type: ignore[no-redef]
34class BaseField(ABC):
35 """An abstract base class for parametric or interpolated 2-d functions,
36 generally representing some sort of calculated image.
38 Notes
39 -----
40 The field hierarchy is closed to the types in this package, so we can
41 enumerate all of the serializations and avoid any kind of extension system.
42 All field types are immutable.
44 Field types implement the function call operator and both multiplication
45 and division by a constant via operator overloading. See the named
46 `evaluate` and `multiply_constant` methods (respectively) for more
47 information about those operations.
49 This interface will probably change in the future to incorporate options
50 for dealing with out-of-bounds positions. At present the behavior for
51 such positions is implementation-specific and should not be relied upon.
52 """
54 @property
55 @abstractmethod
56 def bounds(self) -> Bounds:
57 """The region over which this field can be evaluated (`.Bounds`)."""
58 raise NotImplementedError()
60 @property
61 @abstractmethod
62 def unit(self) -> astropy.units.UnitBase | None:
63 """The units of the field (`astropy.units.UnitBase` or `None`)."""
64 raise NotImplementedError()
66 @property
67 @abstractmethod
68 def is_constant(self) -> bool:
69 """Whether the field is spatially constant (`bool`)."""
70 raise NotImplementedError()
72 @overload
73 def __call__(self, *, x: np.ndarray, y: np.ndarray, quantity: Literal[False] = False) -> np.ndarray: ... 73 ↛ exitline 73 didn't return from function '__call__' because
75 @overload
76 def __call__( 76 ↛ exitline 76 didn't return from function '__call__' because
77 self, *, x: np.ndarray, y: np.ndarray, quantity: Literal[True]
78 ) -> astropy.units.Quantity: ...
80 @overload
81 def __call__( 81 ↛ exitline 81 didn't return from function '__call__' because
82 self, *, x: np.ndarray, y: np.ndarray, quantity: bool
83 ) -> np.ndarray | astropy.units.Quantity: ...
85 def __call__(
86 self, *, x: np.ndarray, y: np.ndarray, quantity: bool = False
87 ) -> np.ndarray | astropy.units.Quantity:
88 return self.evaluate(x=x, y=y, quantity=quantity)
90 @abstractmethod
91 def render(
92 self,
93 bbox: Box | None = None,
94 *,
95 dtype: np.typing.DTypeLike | None = None,
96 ) -> Image:
97 """Create an image realization of the field.
99 Parameters
100 ----------
101 bbox
102 Bounding box of the image. If not provided, ``self.bounds.bbox``
103 will be used.
104 dtype
105 Pixel data type for the returned image.
106 """
107 raise NotImplementedError()
109 def __mul__(self, factor: float | astropy.units.Quantity | astropy.units.UnitBase) -> Self:
110 return self.multiply_constant(factor)
112 def __rmul__(self, factor: float | astropy.units.Quantity | astropy.units.UnitBase) -> Self:
113 return self.multiply_constant(factor)
115 def __truediv__(self, factor: float | astropy.units.Quantity | astropy.units.UnitBase) -> Self:
116 return self.multiply_constant(1.0 / factor)
118 @abstractmethod
119 def evaluate(
120 self, *, x: np.ndarray, y: np.ndarray, quantity: bool
121 ) -> np.ndarray | astropy.units.Quantity:
122 """Evaluate at non-gridded points.
124 Parameters
125 ----------
126 x
127 X coordinates to evaluate at.
128 y
129 Y coordinates to evaluate at; must be broadcast-compatible with
130 ``x``.
131 quantity
132 If `True`, return an `astropy.units.Quantity` instead of a
133 `numpy.ndarray`. If `unit` is `None`, the returned object will
134 be a dimensionless `~astropy.units.Quantity`.
135 """
136 raise NotImplementedError()
138 @abstractmethod
139 def multiply_constant(self, factor: float | astropy.units.Quantity | astropy.units.UnitBase) -> Self:
140 """Multiply by a constant, returning a new field of the same type.
142 Parameters
143 ----------
144 factor
145 Factor to multiply by. When this has units, those should multiply
146 ``self.unit`` or set the units of the returned field if
147 ``self.unit is None``.
148 """
149 raise NotImplementedError()
151 def to_legacy(self) -> LegacyBoundedField:
152 """Convert to a legacy `lsst.afw.math.BoundedField`."""
153 raise NotImplementedError(f"{type(self).__name__} has no lsst.afw.math.BoundedField representation.")
155 def to_legacy_photo_calib(self, image_unit: astropy.units.UnitBase) -> LegacyPhotoCalib:
156 """Convert to a legacy `lsst.afw.image.PhotoCalib`.
158 Parameters
159 ----------
160 image_unit
161 The units of the pixels the returned ``PhotoCalib`` will be
162 associated with.
163 """
164 from lsst.afw.image import PhotoCalib
166 if (result := self.make_legacy_photo_calib(image_unit)) is not None:
167 return result
168 field = self
169 factor = image_unit.to(astropy.units.nJy / self.unit)
170 if factor != 1.0:
171 # TODO[DM-54556]: make sure this shouldn't be 1/factor.
172 field = self.multiply_constant(factor) # this lies about units, but we'll discard them anyway.
173 (field_at_center,) = field(
174 x=np.array([field.bounds.bbox.x.center]),
175 y=np.array([field.bounds.bbox.y.center]),
176 )
177 if field.is_constant:
178 return PhotoCalib(field_at_center)
179 else:
180 # Constructing a legacy PhotoCalib from a BoundedField alone
181 # doesn't always work, because ProductBoundedField doesn't
182 # implement computeMean(). Luckily PhotoCalib doesn't really care
183 # about getting a true mean; it just wants some sort of central
184 # tendency, so we can evaluate the field at the bbox center and use
185 # that (this is what fgcmcal does when it makes a
186 # ProductBoundedField PhotoCalib).
187 return PhotoCalib(
188 calibrationMean=field_at_center,
189 calibrationErr=0.0, # we don't round-trip this; it's not useful
190 calibration=field.to_legacy(),
191 isConstant=False,
192 )
194 @staticmethod
195 def make_legacy_photo_calib(image_unit: astropy.units.UnitBase) -> LegacyPhotoCalib | None:
196 """Make a legacy `lsst.afw.image.PhotoCalib` for an image with the
197 given units, if that is possible without a photometric scaling field.
198 """
199 from lsst.afw.image import PhotoCalib
201 try:
202 factor = image_unit.to(astropy.units.nJy)
203 except astropy.units.UnitConversionError:
204 pass
205 else:
206 return PhotoCalib(factor)
207 return None
209 def _handle_factor_units(
210 self, factor: float | astropy.units.Quantity | astropy.units.UnitBase
211 ) -> tuple[float, astropy.units.UnitBase | None]:
212 """Interpret the ``factor`` argument to `multiply_constant` and apply
213 any units it carries to this field's units.
215 This is a convenience function for subclass implementations of
216 `multiply_constant`.
218 Parameters
219 ----------
220 factor
221 Factor passed by the caller.
223 Returns
224 -------
225 `float`
226 The factor to multiply by as a pure `float`
227 `astropy.units.UnitBase` | `None`
228 The units for the new field returned by `multiply_constant`.
229 """
230 unit = self.unit
231 factor_unit = None
232 if isinstance(factor, astropy.units.Quantity):
233 factor_unit = factor.unit
234 factor = factor.to_value()
235 elif isinstance(factor, astropy.units.UnitBase):
236 factor_unit = factor
237 factor = 1.0
238 if factor_unit is not None:
239 if unit is None:
240 unit = factor_unit
241 else:
242 unit *= factor_unit
243 return factor, unit