Coverage for python/lsst/images/fields/_base.py: 45%

74 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-05-30 02:13 -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, overload 

18 

19import astropy.units 

20import numpy as np 

21 

22from .._geom import Bounds, Box 

23from .._image import Image 

24 

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] 

32 

33 

34class BaseField(ABC): 

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

36 generally representing some sort of calculated image. 

37 

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. 

43 

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. 

48 

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

53 

54 @property 

55 @abstractmethod 

56 def bounds(self) -> Bounds: 

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

58 raise NotImplementedError() 

59 

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

65 

66 @property 

67 @abstractmethod 

68 def is_constant(self) -> bool: 

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

70 raise NotImplementedError() 

71 

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

74 

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

79 

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

84 

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) 

89 

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. 

98 

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

108 

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

110 return self.multiply_constant(factor) 

111 

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

113 return self.multiply_constant(factor) 

114 

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

116 return self.multiply_constant(1.0 / factor) 

117 

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. 

123 

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

137 

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. 

141 

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

150 

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

154 

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

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

157 

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 

165 

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 ) 

193 

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 

200 

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 

208 

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. 

214 

215 This is a convenience function for subclass implementations of 

216 `multiply_constant`. 

217 

218 Parameters 

219 ---------- 

220 factor 

221 Factor passed by the caller. 

222 

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