Coverage for python/lsst/images/cells/_aperture_corrections.py: 54%

126 statements  

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

11 

12from __future__ import annotations 

13 

14__all__ = ("CellApertureCorrectionMapSerializationModel", "CellField") 

15 

16from collections.abc import Mapping 

17from typing import TYPE_CHECKING, Any, ClassVar, final 

18 

19import astropy.table 

20import astropy.units 

21import numpy as np 

22import pydantic 

23 

24from .._cell_grid import CellGridBounds, CellIJ 

25from .._geom import BoundsError, Box 

26from .._image import Image 

27from ..fields import BaseField 

28from ..serialization import ( 

29 ArchiveReadError, 

30 ArchiveTree, 

31 InputArchive, 

32 InvalidParameterError, 

33 OutputArchive, 

34 TableModel, 

35) 

36 

37if TYPE_CHECKING: 

38 try: 

39 from lsst.afw.image import ApCorrMap as LegacyApCorrMap 

40 from lsst.cell_coadds import StitchedApertureCorrection as LegacyStichedApertureCorrection 

41 except ImportError: 

42 type LegacyApCorrMap = Any # type: ignore[no-redef] 

43 type LegacyStichedApertureCorrection = Any # type: ignore[no-redef] 

44 

45 

46@final 

47class CellField(BaseField): 

48 """A piecewise 2-d function on a cell-coadd grid. 

49 

50 Parameters 

51 ---------- 

52 bounds 

53 Description of the cell grid and any missing cells. Array entries for 

54 missing cells should be NaN. 

55 array 

56 A 2-d array of cell values with shape 

57 ``bounds.subgrid_size.as_tuple()``. 

58 unit 

59 Units of the field values, or `None` if dimensionless. 

60 

61 Notes 

62 ----- 

63 `CellField` is not directly serializable and is not included in the 

64 ``Field`` union type alias as a result. A `~collections.abc.Mapping` of 

65 `CellField` is instead serializable via 

66 `CellApertureCorrectionMapSerializationModel`. 

67 """ 

68 

69 def __init__( 

70 self, bounds: CellGridBounds, array: np.ndarray, unit: astropy.units.UnitBase | None = None 

71 ) -> None: 

72 self._array = array 

73 self._bounds = bounds 

74 self._unit = unit 

75 if self._array.shape != self._bounds.subgrid_size.as_tuple(): 75 ↛ 76line 75 didn't jump to line 76 because the condition on line 75 was never true

76 raise ValueError( 

77 f"Array shape ({self._array.shape}) differs from subgrid size ({self._bounds.subgrid_size})." 

78 ) 

79 

80 __hash__ = None # type: ignore[assignment] 

81 

82 @property 

83 def bounds(self) -> CellGridBounds: 

84 return self._bounds 

85 

86 @property 

87 def unit(self) -> astropy.units.UnitBase | None: 

88 return self._unit 

89 

90 @property 

91 def is_constant(self) -> bool: 

92 indices = iter(self._bounds.cell_indices()) 

93 try: 

94 first = self.value_in_cell(next(indices)) 

95 except StopIteration: 

96 return True 

97 for other_index in indices: 

98 if self.value_in_cell(other_index) != first: 

99 return False 

100 return True 

101 

102 def value_in_cell(self, key: CellIJ) -> float: 

103 """Return the value of the field in the cell with the given index. 

104 

105 Parameters 

106 ---------- 

107 key 

108 Index of the cell to evaluate. 

109 """ 

110 if key in self._bounds.missing: 

111 raise BoundsError(f"Cell {key} is missing for this field.") 

112 index = key - self._bounds.subgrid_start 

113 try: 

114 return self._array[index.i, index.j] 

115 except IndexError: 

116 raise BoundsError(f"Cell {key} is out of bounds for this field.") from None 

117 

118 def quantity_in_cell(self, key: CellIJ) -> astropy.units.Quantity: 

119 """Return the quantity (value with units) of the field in the cell 

120 with the given index. 

121 

122 Parameters 

123 ---------- 

124 key 

125 Index of the cell to evaluate. 

126 """ 

127 return astropy.units.Quantity(self.value_in_cell(key), self._unit) 

128 

129 def _evaluate( 

130 self, *, x: np.ndarray, y: np.ndarray, quantity: bool 

131 ) -> np.ndarray | astropy.units.Quantity: 

132 # This implementation is optimized for the case where there are many 

133 # more evaluation points than cells. We could switch to an 

134 # implementation that zip-broadcast-iterates over x and y when that is 

135 # not the case, but that feels like a premature optimization right now. 

136 result = np.full(np.broadcast_shapes(y.shape, x.shape), np.nan, dtype=np.float64) 

137 for cell_index in self._bounds.cell_indices(): 

138 cell_bbox = self._bounds.grid.bbox_of(cell_index) 

139 result[cell_bbox.contains(x=x, y=y)] = self.value_in_cell(cell_index) 

140 if quantity: 

141 return astropy.units.Quantity(result, self._unit) 

142 return result 

143 

144 def render(self, bbox: Box | None = None, *, dtype: np.typing.DTypeLike | None = None) -> Image: 

145 if bbox is None: 

146 bbox = self._bounds.bbox 

147 bounds = self._bounds 

148 else: 

149 bounds = self._bounds[bbox] 

150 result = Image(np.nan, bbox=bbox, dtype=dtype, unit=self._unit) 

151 for cell_index in bounds.cell_indices(): 

152 cell_bbox = self._bounds.grid.bbox_of(cell_index).intersection(bbox) 

153 result[cell_bbox].array = self.value_in_cell(cell_index) 

154 return result 

155 

156 def _multiply_constant( 

157 self, factor: float | astropy.units.Quantity | astropy.units.UnitBase 

158 ) -> CellField: 

159 factor, unit = self._handle_factor_units(factor) 

160 return CellField(self._bounds, self._array * factor, unit=unit) 

161 

162 @staticmethod 

163 def from_legacy_aperture_correction( 

164 legacy: LegacyStichedApertureCorrection, bounds: CellGridBounds 

165 ) -> CellField: 

166 """Convert from a legacy `lsst.cell_coadds.StitchedApertureCorrection`. 

167 

168 Parameters 

169 ---------- 

170 legacy 

171 Legacy field to convert. 

172 bounds 

173 The grid and bounds of the returned field. 

174 """ 

175 array = np.full(bounds.subgrid_size.as_tuple(), np.nan, dtype=np.float64) 

176 for cell_index in bounds.cell_indices(): 

177 array_index = cell_index - bounds.subgrid_start 

178 array[array_index.i, array_index.j] = legacy.gc[cell_index.to_legacy()] 

179 return CellField(bounds, array) 

180 

181 def to_legacy_aperture_correction(self) -> LegacyStichedApertureCorrection: 

182 """Convert to a legacy 

183 `lsst.cell_coadds.StitchedApertureCorrection`. 

184 """ 

185 from lsst.cell_coadds import GridContainer, StitchedApertureCorrection 

186 

187 grid = self.bounds.grid.to_legacy() 

188 gc = GridContainer[float](grid.shape) 

189 for cell_index in self.bounds.cell_indices(): 

190 gc[cell_index.to_legacy()] = self.value_in_cell(cell_index) 

191 return StitchedApertureCorrection(grid, gc) 

192 

193 

194class CellApertureCorrectionMapSerializationModel(ArchiveTree): 

195 """A serialization model for a `~collections.abc.Mapping` of `CellField`, 

196 which is used to represent aperture corrections for cell-based coadds. 

197 """ 

198 

199 SCHEMA_NAME: ClassVar[str] = "cell_aperture_correction_map" 

200 SCHEMA_VERSION: ClassVar[str] = "1.0.0" 

201 MIN_READ_VERSION: ClassVar[int] = 1 

202 PUBLIC_TYPE: ClassVar[type] = dict 

203 

204 table: TableModel = pydantic.Field( 

205 description="Table with one row for each cell and different photometry algorithms in columns." 

206 ) 

207 bounds: CellGridBounds = pydantic.Field( 

208 description=( 

209 "Description of the cell grid and any missing cells. Array entries for " 

210 "missing cells should be NaN." 

211 ), 

212 ) 

213 

214 @staticmethod 

215 def serialize( 

216 aperture_correction_map: Mapping[str, CellField], archive: OutputArchive[Any] 

217 ) -> CellApertureCorrectionMapSerializationModel | None: 

218 if not aperture_correction_map: 218 ↛ 219line 218 didn't jump to line 219 because the condition on line 218 was never true

219 return None 

220 bounds = next(iter(aperture_correction_map.values())).bounds 

221 if not all(field.bounds == bounds for field in aperture_correction_map.values()): 221 ↛ 222line 221 didn't jump to line 222 because the condition on line 221 was never true

222 raise ValueError("Cell aperture corrections do not have consistent bounds.") 

223 if any(field.unit is not None for field in aperture_correction_map.values()): 223 ↛ 224line 223 didn't jump to line 224 because the condition on line 223 was never true

224 raise ValueError("Aperture corrections should be dimensionless.") 

225 table = astropy.table.Table( 

226 rows=[cell_index.as_tuple() for cell_index in bounds.cell_indices()], names=["cell_i", "cell_j"] 

227 ) 

228 good_cell_mask = np.ones(bounds.subgrid_size.as_tuple(), dtype=bool) 

229 for cell_index in bounds.missing: 

230 array_index = cell_index - bounds.subgrid_start 

231 good_cell_mask[array_index.i, array_index.j] = False 

232 for name, field in aperture_correction_map.items(): 

233 table.add_column(field._array[good_cell_mask], name=name, copy=False) 

234 return CellApertureCorrectionMapSerializationModel( 

235 table=archive.add_table(table, name="table"), bounds=bounds 

236 ) 

237 

238 def deserialize(self, archive: InputArchive[Any], **kwargs: Any) -> dict[str, CellField]: 

239 if kwargs: 239 ↛ 240line 239 didn't jump to line 240 because the condition on line 239 was never true

240 raise InvalidParameterError( 

241 f"Unrecognized parameters for cell aperture correction map: {set(kwargs.keys())}." 

242 ) 

243 good_cell_mask = np.zeros(self.bounds.subgrid_size.as_tuple(), dtype=bool) 

244 table = archive.get_table(self.table) 

245 for tbl_ij, cell_index in zip( 

246 table["cell_i", "cell_j"].iterrows(), self.bounds.cell_indices(), strict=True 

247 ): 

248 if cell_index.as_tuple() != tbl_ij: 248 ↛ 249line 248 didn't jump to line 249 because the condition on line 248 was never true

249 raise ArchiveReadError( 

250 "Inconsistency between serialized aperture correction bounds and table." 

251 ) 

252 array_index = cell_index - self.bounds.subgrid_start 

253 good_cell_mask[array_index.i, array_index.j] = True 

254 result: dict[str, CellField] = {} 

255 for name, column in table.columns.items(): 

256 if name in ("cell_i", "cell_j"): 

257 continue 

258 array = np.full(self.bounds.subgrid_size.as_tuple(), np.nan, dtype=np.float64) 

259 array[good_cell_mask] = column 

260 result[name] = CellField(self.bounds, array) 

261 return result