Coverage for python/lsst/images/_observation_summary_stats.py: 61%

92 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-03 08:10 +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. 

11from __future__ import annotations 

12 

13__all__ = ("ObservationSummaryStats",) 

14 

15import dataclasses 

16import math 

17from typing import TYPE_CHECKING, Any, Self, get_origin 

18 

19import pydantic 

20 

21if TYPE_CHECKING: 

22 try: 

23 from lsst.afw.image import ExposureSummaryStats as LegacyExposureSummaryStats 

24 except ImportError: 

25 type LegacyExposureSummaryStats = Any # type: ignore[no-redef] 

26 

27 

28def _default_corners() -> tuple[float, float, float, float]: 

29 return (math.nan, math.nan, math.nan, math.nan) 

30 

31 

32class ObservationSummaryStats(pydantic.BaseModel, ser_json_inf_nan="constants"): 

33 version: int = pydantic.Field(0, description="Version of the model.") 

34 

35 psfSigma: float = pydantic.Field(math.nan, description="PSF determinant radius (pixels).") 

36 

37 psfArea: float = pydantic.Field(math.nan, description="PSF effective area (pixels**2).") 

38 

39 psfIxx: float = pydantic.Field(math.nan, description="PSF shape Ixx (pixels**2).") 

40 

41 psfIyy: float = pydantic.Field(math.nan, description="PSF shape Iyy (pixels**2).") 

42 

43 psfIxy: float = pydantic.Field(math.nan, description="PSF shape Ixy (pixels**2).") 

44 

45 ra: float = pydantic.Field(math.nan, description="Bounding box center Right Ascension (degrees).") 

46 

47 dec: float = pydantic.Field(math.nan, description="Bounding box center Declination (degrees).") 

48 

49 pixelScale: float = pydantic.Field(math.nan, description="Measured detector pixel scale (arcsec/pixel).") 

50 

51 zenithDistance: float = pydantic.Field( 

52 math.nan, description="Bounding box center zenith distance (degrees)." 

53 ) 

54 

55 expTime: float = pydantic.Field(math.nan, description="Exposure time of the exposure (seconds).") 

56 

57 zeroPoint: float = pydantic.Field(math.nan, description="Mean zeropoint in detector (mag).") 

58 

59 skyBg: float = pydantic.Field(math.nan, description="Average sky background (ADU).") 

60 

61 skyNoise: float = pydantic.Field(math.nan, description="Average sky noise (ADU).") 

62 

63 meanVar: float = pydantic.Field(math.nan, description="Mean variance of the weight plane (ADU**2).") 

64 

65 raCorners: tuple[float, float, float, float] = pydantic.Field( 

66 default_factory=_default_corners, description="Right Ascension of bounding box corners (degrees)." 

67 ) 

68 

69 decCorners: tuple[float, float, float, float] = pydantic.Field( 

70 default_factory=_default_corners, description="Declination of bounding box corners (degrees)." 

71 ) 

72 

73 psfAdaptiveThresholdValue: float = pydantic.Field( 

74 math.nan, 

75 description="Threshold value used in the adaptive threshold detection pass for PSF modelling.", 

76 ) 

77 

78 psfAdaptiveIncludeThresholdMultiplier: float = pydantic.Field( 

79 math.nan, 

80 description="Threshold multiplier used in the adaptive threshold detection pass for PSF modelling.", 

81 ) 

82 

83 nShapeletsStar: int = pydantic.Field( 

84 0, 

85 description="Number of sources used in the shapelet decomposition.", 

86 ) 

87 

88 shapeletsOnlyIqScore: float = pydantic.Field( 

89 math.nan, 

90 description=( 

91 "The dimensionless image quality score as determined from the shapelets decomposition " 

92 "that includes power only from the non-atmospheric decomposition coefficients. The " 

93 "score spans the range [0.0, 1.0] with lower values indicating better image quality." 

94 ), 

95 ) 

96 

97 shapeletsIqScore: float = pydantic.Field( 

98 math.nan, 

99 description=( 

100 "The dimensionless image quality score as determined from the shapelets decomposition " 

101 "that includes power from the median centroid offset between those used in the decomposition " 

102 "and those of the centroid slot in addition to non-atmospheric decomposition coefficients. " 

103 "The score spans the range [0.0, 1.0] with lower values indicating better image quality." 

104 ), 

105 ) 

106 

107 shapeletsCoeffs: tuple[float, ...] = pydantic.Field( 

108 default_factory=tuple, 

109 description="Coefficients from the PSF star shapelet decomposition.", 

110 ) 

111 

112 centroidDiffShapeletsVsSlotMedian: float = pydantic.Field( 

113 math.nan, 

114 description=( 

115 "Median centroid difference (sqrt((slot_x - shapelet_x)**2 + (slot_y - shapelet_y)**2)) for " 

116 "sources used in the shapelet decomposition (pixels)." 

117 ), 

118 ) 

119 

120 shapeletsStarEMedian: float = pydantic.Field( 

121 math.nan, 

122 description=( 

123 "Median ellipticity (sqrt(starE1**2.0 + starE2**2.0)) of the sources used in the " 

124 "shapelet decomposition." 

125 ), 

126 ) 

127 

128 shapeletsStarUnNormalizedEMedian: float = pydantic.Field( 

129 math.nan, 

130 description=( 

131 "Median un-normalized ellipticity (sqrt((starXX - starYY)**2.0 + (2.0*starXY)**2.0)) " 

132 "of the sources used in the shapelet decomposition (pixel**2)." 

133 ), 

134 ) 

135 

136 refCatSourceDensity: float = pydantic.Field( 

137 math.nan, 

138 description=( 

139 "Source density for the detector region as computed from the loaded reference catalog " 

140 "(number per degrees**2)." 

141 ), 

142 ) 

143 

144 astromOffsetMean: float = pydantic.Field(math.nan, description="Astrometry match offset mean.") 

145 

146 astromOffsetStd: float = pydantic.Field(math.nan, description="Astrometry match offset stddev.") 

147 

148 nPsfStar: int = pydantic.Field(0, description="Number of stars used for psf model.") 

149 

150 psfStarDeltaE1Median: float = pydantic.Field( 

151 math.nan, description="Psf stars median E1 residual (starE1 - psfE1)." 

152 ) 

153 

154 psfStarDeltaE2Median: float = pydantic.Field( 

155 math.nan, description="Psf stars median E2 residual (starE2 - psfE2)." 

156 ) 

157 

158 psfStarDeltaE1Scatter: float = pydantic.Field( 

159 math.nan, description="Psf stars MAD E1 scatter (starE1 - psfE1)." 

160 ) 

161 

162 psfStarDeltaE2Scatter: float = pydantic.Field( 

163 math.nan, description="Psf stars MAD E2 scatter (starE2 - psfE2)." 

164 ) 

165 

166 psfStarDeltaSizeMedian: float = pydantic.Field( 

167 math.nan, description="Psf stars median size residual (starSize - psfSize)." 

168 ) 

169 

170 psfStarDeltaSizeScatter: float = pydantic.Field( 

171 math.nan, description="Psf stars MAD size scatter (starSize - psfSize)." 

172 ) 

173 

174 psfStarScaledDeltaSizeScatter: float = pydantic.Field( 

175 math.nan, description="Psf stars MAD size scatter scaled by psfSize**2." 

176 ) 

177 

178 psfTraceRadiusDelta: float = pydantic.Field( 

179 math.nan, 

180 description=( 

181 "Delta (max - min) of the model psf trace radius values evaluated on a grid of " 

182 "unmasked pixels (pixels)." 

183 ), 

184 ) 

185 

186 psfApFluxDelta: float = pydantic.Field( 

187 math.nan, 

188 description=( 

189 "Delta (max - min) of the model psf aperture flux (with aperture radius of max(2, 3*psfSigma)) " 

190 "values evaluated on a grid of unmasked pixels." 

191 ), 

192 ) 

193 

194 psfApCorrSigmaScaledDelta: float = pydantic.Field( 

195 math.nan, 

196 description=( 

197 "Delta (max - min) of the psf flux aperture correction factors scaled (divided) by the " 

198 "psfSigma evaluated on a grid of unmasked pixels." 

199 ), 

200 ) 

201 

202 maxDistToNearestPsf: float = pydantic.Field( 

203 math.nan, 

204 description="Maximum distance of an unmasked pixel to its nearest model psf star (pixels).", 

205 ) 

206 

207 starEMedian: float = pydantic.Field( 

208 math.nan, 

209 description=( 

210 "Median ellipticity (sqrt(starE1**2.0 + starE2**2.0)) of the stars used in the PSF model." 

211 ), 

212 ) 

213 

214 starUnNormalizedEMedian: float = pydantic.Field( 

215 math.nan, 

216 description=( 

217 "Median un-normalized ellipticity (sqrt((starXX - starYY)**2.0 + " 

218 "(2.0*starXY)**2.0)) of the stars used in the PSF model." 

219 ), 

220 ) 

221 

222 starComa1Median: float = pydantic.Field( 

223 math.nan, 

224 description=( 

225 "Coma-like higher-order moment combination: median M30 + M12 of the stars used in the PSF model." 

226 ), 

227 ) 

228 

229 starComa2Median: float = pydantic.Field( 

230 math.nan, 

231 description=( 

232 "Coma-like higher-order moment combination: median M21 + M03 of the stars used in the PSF model." 

233 ), 

234 ) 

235 

236 starTrefoil1Median: float = pydantic.Field( 

237 math.nan, 

238 description=( 

239 "Trefoil-like higher-order moment combination: median M30 - 3*M12 " 

240 "of the stars used in the PSF model." 

241 ), 

242 ) 

243 

244 starTrefoil2Median: float = pydantic.Field( 

245 math.nan, 

246 description=( 

247 "Trefoil-like higher-order moment combination: median 3*M21 - M03 " 

248 "of the stars used in the PSF model." 

249 ), 

250 ) 

251 

252 starKurtosisMedian: float = pydantic.Field( 

253 math.nan, 

254 description=( 

255 "Kurtosis-like higher-order moment combination: median M40 + 2*M22 + M04 " 

256 "of the stars used in the PSF model." 

257 ), 

258 ) 

259 

260 starE41Median: float = pydantic.Field( 

261 math.nan, 

262 description=( 

263 "Fourth-order ellipticity-like higher-order moment combination: median M40 - M04 " 

264 "of the stars used in the PSF model." 

265 ), 

266 ) 

267 

268 starE42Median: float = pydantic.Field( 

269 math.nan, 

270 description=( 

271 "Fourth-order ellipticity-like higher-order moment combination: median 2*(M31 + M13) " 

272 "of the stars used in the PSF model." 

273 ), 

274 ) 

275 

276 effTime: float = pydantic.Field( 

277 math.nan, 

278 description="Effective exposure time calculated from psfSigma, skyBg, and zeroPoint (seconds).", 

279 ) 

280 

281 effTimePsfSigmaScale: float = pydantic.Field( 

282 math.nan, description="PSF scaling of the effective exposure time." 

283 ) 

284 

285 effTimeSkyBgScale: float = pydantic.Field( 

286 math.nan, description="Sky background scaling of the effective exposure time." 

287 ) 

288 

289 effTimeZeroPointScale: float = pydantic.Field( 

290 math.nan, description="Zeropoint scaling of the effective exposure time." 

291 ) 

292 

293 magLim: float = pydantic.Field( 

294 math.nan, 

295 description=( 

296 "Magnitude limit at fixed SNR (default SNR=5) calculated from psfSigma, skyBg," 

297 " zeroPoint, and readNoise." 

298 ), 

299 ) 

300 

301 def __eq__(self, other: object) -> bool: 

302 if not isinstance(other, ObservationSummaryStats): 

303 return NotImplemented 

304 for name in ObservationSummaryStats.model_fields: 

305 a = getattr(self, name) 

306 b = getattr(other, name) 

307 if isinstance(a, tuple) and isinstance(b, tuple): 

308 if len(a) != len(b): 

309 return False 

310 for ai, bi in zip(a, b): 

311 if ai != bi and not (math.isnan(ai) and math.isnan(bi)): 

312 return False 

313 elif a != b and not (math.isnan(a) and math.isnan(b)): 

314 return False 

315 return True 

316 

317 @classmethod 

318 def from_legacy(cls, exposure_summary_stats: LegacyExposureSummaryStats) -> Self: 

319 """Return an `ObservationSummaryStats` from a legacy 

320 `lsst.afw.image.ExposureSummaryStats`. 

321 """ 

322 # Assume that all the fields in an ExposureSummaryStats dataclass 

323 # are compatible with an ObservationSummaryStats. 

324 summary_stats = dataclasses.asdict(exposure_summary_stats) 

325 return cls.model_validate(summary_stats) 

326 

327 def to_legacy(self) -> LegacyExposureSummaryStats: 

328 """Convert to an `lsst.afw.image.ExposureSummaryStats` instance.""" 

329 from lsst.afw.image import ExposureSummaryStats as LegacyExposureSummaryStats 

330 

331 kwargs: dict[str, Any] = {} 

332 for name, info in ObservationSummaryStats.model_fields.items(): 

333 # Doing this in general is hard, so we handle the fields that we 

334 # know about and raise if somebody adds a field with a new type 

335 # without updating this function. 

336 if info.annotation in (float, int): 

337 kwargs[name] = getattr(self, name) 

338 elif get_origin(info.annotation) is tuple: 

339 kwargs[name] = list(getattr(self, name)) 

340 else: 

341 raise NotImplementedError(f"Unsupported field type: {info.annotation}.") 

342 return LegacyExposureSummaryStats(**kwargs)