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

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. 

11 

12from __future__ import annotations 

13 

14__all__ = ("BaseField",) 

15 

16from abc import ABC, abstractmethod 

17from typing import TYPE_CHECKING, Any, Literal, Self, assert_type, cast, overload 

18 

19import astropy.units 

20import numpy as np 

21import numpy.typing as npt 

22 

23from .._geom import XY, YX, Bounds, Box 

24from .._image import Image 

25 

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] 

33 

34 

35class BaseField(ABC): 

36 """An abstract base class for parametric or interpolated 2-d functions, 

37 generally representing some sort of calculated image. 

38 

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. 

44 

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). 

49 

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 """ 

54 

55 @property 

56 @abstractmethod 

57 def bounds(self) -> Bounds: 

58 """The region over which this field can be evaluated (`.Bounds`).""" 

59 raise NotImplementedError() 

60 

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() 

66 

67 @property 

68 @abstractmethod 

69 def is_constant(self) -> bool: 

70 """Whether the field is spatially constant (`bool`).""" 

71 raise NotImplementedError() 

72 

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: ... 

77 

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: ... 

82 

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: ... 

87 

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

90 

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: ... 

95 

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: ... 

100 

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 

129 

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. 

138 

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() 

148 

149 def __mul__(self, factor: float | astropy.units.Quantity | astropy.units.UnitBase) -> Self: 

150 return self._multiply_constant(factor) 

151 

152 def __rmul__(self, factor: float | astropy.units.Quantity | astropy.units.UnitBase) -> Self: 

153 return self._multiply_constant(factor) 

154 

155 def __truediv__(self, factor: float | astropy.units.Quantity | astropy.units.UnitBase) -> Self: 

156 return self._multiply_constant(1.0 / factor) 

157 

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. 

163 

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() 

177 

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. 

181 

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() 

190 

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.") 

194 

195 def to_legacy_photo_calib(self, image_unit: astropy.units.UnitBase) -> LegacyPhotoCalib: 

196 """Convert to a legacy `lsst.afw.image.PhotoCalib`. 

197 

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 

205 

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 ) 

233 

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. 

238 

239 Parameters 

240 ---------- 

241 image_unit 

242 Units of the image the photometric calibration applies to. 

243 """ 

244 from lsst.afw.image import PhotoCalib 

245 

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 

253 

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. 

259 

260 This is a convenience function for subclass implementations of 

261 `multiply_constant`. 

262 

263 Parameters 

264 ---------- 

265 factor 

266 Factor passed by the caller. 

267 

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 

289 

290 

291if TYPE_CHECKING: 

292 

293 def _test_types() -> None: 

294 field = cast(BaseField, None) 

295 arr = np.zeros(3) 

296 

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) 

300 

301 # Scalar inputs with quantity=True → astropy.units.Quantity 

302 assert_type(field(x=1.0, y=2.0, quantity=True), astropy.units.Quantity) 

303 

304 # Array-like inputs → np.ndarray | astropy.units.Quantity 

305 assert_type(field(x=arr, y=arr), np.ndarray | astropy.units.Quantity)