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

139 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-23 09:23 +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, ClassVar, Self, final, get_origin 

18 

19import pydantic 

20 

21from lsst.images.serialization import ArchiveTree, InputArchive, InvalidParameterError, OutputArchive 

22 

23if TYPE_CHECKING: 

24 try: 

25 from lsst.afw.image import ExposureSummaryStats as LegacyExposureSummaryStats 

26 except ImportError: 

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

28 

29 

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

31 return (math.nan, math.nan, math.nan, math.nan) 

32 

33 

34def _is_empty(value: Any) -> bool: 

35 """Return whether a summary-statistic value is unset. 

36 

37 A value counts as unset if it is NaN, or an empty sequence, or a sequence 

38 whose entries are all unset. Such fields carry no information and can be 

39 dropped when converting to or from the legacy representation, allowing the 

40 two representations to define different sets of fields as long as the 

41 fields they do not share are empty. 

42 """ 

43 if isinstance(value, (list, tuple)): 

44 return all(_is_empty(item) for item in value) 

45 return isinstance(value, float) and math.isnan(value) 

46 

47 

48@final 

49class ObservationSummaryStats(ArchiveTree): 

50 """Various statistics obtained from a single observation.""" 

51 

52 SCHEMA_NAME: ClassVar[str] = "observation_summary_stats" 

53 SCHEMA_VERSION: ClassVar[str] = "1.0.0.dev0" 

54 MIN_READ_VERSION: ClassVar[int] = 1 

55 PUBLIC_TYPE: ClassVar[type] # Assigned after class construction. 

56 

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

58 

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

60 

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

62 

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

64 

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

66 

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

68 

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

70 

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

72 

73 zenithDistance: float = pydantic.Field( 

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

75 ) 

76 

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

78 

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

80 

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

82 

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

84 

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

86 

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

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

89 ) 

90 

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

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

93 ) 

94 

95 psfAdaptiveThresholdValue: float = pydantic.Field( 

96 math.nan, 

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

98 ) 

99 

100 psfAdaptiveIncludeThresholdMultiplier: float = pydantic.Field( 

101 math.nan, 

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

103 ) 

104 

105 nShapeletsStar: int = pydantic.Field( 

106 0, 

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

108 ) 

109 

110 shapeletsOnlyIqScore: float = pydantic.Field( 

111 math.nan, 

112 description=( 

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

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

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

116 ), 

117 ) 

118 

119 shapeletsIqScore: float = pydantic.Field( 

120 math.nan, 

121 description=( 

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

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

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

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

126 ), 

127 ) 

128 

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

130 default_factory=tuple, 

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

132 ) 

133 

134 centroidDiffShapeletsVsSlotMedian: float = pydantic.Field( 

135 math.nan, 

136 description=( 

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

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

139 ), 

140 ) 

141 

142 shapeletsStarEMedian: float = pydantic.Field( 

143 math.nan, 

144 description=( 

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

146 "shapelet decomposition." 

147 ), 

148 ) 

149 

150 shapeletsStarUnNormalizedEMedian: float = pydantic.Field( 

151 math.nan, 

152 description=( 

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

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

155 ), 

156 ) 

157 

158 refCatSourceDensity: float = pydantic.Field( 

159 math.nan, 

160 description=( 

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

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

163 ), 

164 ) 

165 

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

167 

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

169 

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

171 

172 psfStarDeltaE1Median: float = pydantic.Field( 

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

174 ) 

175 

176 psfStarDeltaE2Median: float = pydantic.Field( 

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

178 ) 

179 

180 psfStarDeltaE1Scatter: float = pydantic.Field( 

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

182 ) 

183 

184 psfStarDeltaE2Scatter: float = pydantic.Field( 

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

186 ) 

187 

188 psfStarDeltaSizeMedian: float = pydantic.Field( 

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

190 ) 

191 

192 psfStarDeltaSizeScatter: float = pydantic.Field( 

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

194 ) 

195 

196 psfStarScaledDeltaSizeScatter: float = pydantic.Field( 

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

198 ) 

199 

200 psfTraceRadiusDelta: float = pydantic.Field( 

201 math.nan, 

202 description=( 

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

204 "unmasked pixels (pixels)." 

205 ), 

206 ) 

207 

208 psfApFluxDelta: float = pydantic.Field( 

209 math.nan, 

210 description=( 

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

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

213 ), 

214 ) 

215 

216 psfApCorrSigmaScaledDelta: float = pydantic.Field( 

217 math.nan, 

218 description=( 

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

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

221 ), 

222 ) 

223 

224 maxDistToNearestPsf: float = pydantic.Field( 

225 math.nan, 

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

227 ) 

228 

229 starEMedian: float = pydantic.Field( 

230 math.nan, 

231 description=( 

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

233 ), 

234 ) 

235 

236 starUnNormalizedEMedian: float = pydantic.Field( 

237 math.nan, 

238 description=( 

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

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

241 ), 

242 ) 

243 

244 starComa1Median: float = pydantic.Field( 

245 math.nan, 

246 description=( 

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

248 ), 

249 ) 

250 

251 starComa2Median: float = pydantic.Field( 

252 math.nan, 

253 description=( 

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

255 ), 

256 ) 

257 

258 starTrefoil1Median: float = pydantic.Field( 

259 math.nan, 

260 description=( 

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

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

263 ), 

264 ) 

265 

266 starTrefoil2Median: float = pydantic.Field( 

267 math.nan, 

268 description=( 

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

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

271 ), 

272 ) 

273 

274 starKurtosisMedian: float = pydantic.Field( 

275 math.nan, 

276 description=( 

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

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

279 ), 

280 ) 

281 

282 starE41Median: float = pydantic.Field( 

283 math.nan, 

284 description=( 

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

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

287 ), 

288 ) 

289 

290 starE42Median: float = pydantic.Field( 

291 math.nan, 

292 description=( 

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

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

295 ), 

296 ) 

297 

298 effTime: float = pydantic.Field( 

299 math.nan, 

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

301 ) 

302 

303 effTimePsfSigmaScale: float = pydantic.Field( 

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

305 ) 

306 

307 effTimeSkyBgScale: float = pydantic.Field( 

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

309 ) 

310 

311 effTimeZeroPointScale: float = pydantic.Field( 

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

313 ) 

314 

315 magLim: float = pydantic.Field( 

316 math.nan, 

317 description=( 

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

319 " zeroPoint, and readNoise." 

320 ), 

321 ) 

322 

323 psfTE1e1: float = pydantic.Field( 

324 math.nan, 

325 description=( 

326 "Per-exposure TE1e1 ~ <de1 de1> of PSF residual ellipticity, averaged over theta " 

327 "[0,1] arcmin via treecorr KK correlation. Dimensionless; used to form the " 

328 "full-survey TE1 metric." 

329 ), 

330 ) 

331 

332 psfTE1e2: float = pydantic.Field( 

333 math.nan, 

334 description=( 

335 "Per-exposure TE1e2 ~ <de2 de2> of PSF residual ellipticity, averaged over theta " 

336 "[0,1] arcmin via treecorr KK correlation. Dimensionless; used to form the " 

337 "full-survey TE1 metric." 

338 ), 

339 ) 

340 

341 psfTE1ex: float = pydantic.Field( 

342 math.nan, 

343 description=( 

344 "Per-exposure TE1ex ~ <de1 de2> of PSF residual ellipticity, averaged over theta " 

345 "[0,1] arcmin via treecorr KK correlation. Dimensionless; used to form the " 

346 "full-survey TE1 metric." 

347 ), 

348 ) 

349 

350 psfTE2e1: float = pydantic.Field( 

351 math.nan, 

352 description=( 

353 "Per-exposure TE2e1 ~ <de1 de1> of PSF residual ellipticity, averaged over theta " 

354 "[5,100] arcmin via treecorr KK correlation. Dimensionless; used to form the " 

355 "full-survey TE2 metric." 

356 ), 

357 ) 

358 

359 psfTE2e2: float = pydantic.Field( 

360 math.nan, 

361 description=( 

362 "Per-exposure TE2e2 ~ <de2 de2> of PSF residual ellipticity, averaged over theta " 

363 "[5,100] arcmin via treecorr KK correlation. Dimensionless; used to form the " 

364 "full-survey TE2 metric." 

365 ), 

366 ) 

367 

368 psfTE2ex: float = pydantic.Field( 

369 math.nan, 

370 description=( 

371 "Per-exposure TE2ex ~ <de1 de2> of PSF residual ellipticity, averaged over theta " 

372 "[5,100] arcmin via treecorr KK correlation. Dimensionless; used to form the " 

373 "full-survey TE2 metric." 

374 ), 

375 ) 

376 

377 psfTE3e1: float = pydantic.Field( 

378 math.nan, 

379 description=( 

380 "Per-exposure median-over-CCDs of TE3e1 ~ <de1 de1> of PSF residual ellipticity, " 

381 "where each CCD uses theta within [0,5] arcmin bins. Dimensionless; downstream " 

382 "pipelines take the 85th percentile over images to evaluate TE3." 

383 ), 

384 ) 

385 

386 psfTE3e2: float = pydantic.Field( 

387 math.nan, 

388 description=( 

389 "Per-exposure median-over-CCDs of TE3e2 ~ <de2 de2> of PSF residual ellipticity, " 

390 "where each CCD uses theta within [0,5] arcmin bins. Dimensionless; downstream " 

391 "pipelines take the 85th percentile over images to evaluate TE3." 

392 ), 

393 ) 

394 

395 psfTE3ex: float = pydantic.Field( 

396 math.nan, 

397 description=( 

398 "Per-exposure median-over-CCDs of TE3ex ~ <de1 de2> of PSF residual ellipticity, " 

399 "where each CCD uses theta within [0,5] arcmin bins. Dimensionless; downstream " 

400 "pipelines take the 85th percentile over images to evaluate TE3." 

401 ), 

402 ) 

403 

404 psfTE4e1: float = pydantic.Field( 

405 math.nan, 

406 description=( 

407 "Per-exposure median-over-CCDs of TE4e1 ~ <de1 de1> of PSF residual ellipticity, " 

408 "where each CCD uses theta within [5,20] arcmin bins. Dimensionless; downstream " 

409 "pipelines take the 85th percentile over images to evaluate TE4." 

410 ), 

411 ) 

412 

413 psfTE4e2: float = pydantic.Field( 

414 math.nan, 

415 description=( 

416 "Per-exposure median-over-CCDs of TE4e2 ~ <de2 de2> of PSF residual ellipticity, " 

417 "where each CCD uses theta within [5,20] arcmin bins. Dimensionless; downstream " 

418 "pipelines take the 85th percentile over images to evaluate TE4." 

419 ), 

420 ) 

421 

422 psfTE4ex: float = pydantic.Field( 

423 math.nan, 

424 description=( 

425 "Per-exposure median-over-CCDs of TE4ex ~ <de1 de2> of PSF residual ellipticity, " 

426 "where each CCD uses theta within [5,20] arcmin bins. Dimensionless; downstream " 

427 "pipelines take the 85th percentile over images to evaluate TE4." 

428 ), 

429 ) 

430 

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

432 if not isinstance(other, ObservationSummaryStats): 432 ↛ 433line 432 didn't jump to line 433 because the condition on line 432 was never true

433 return NotImplemented 

434 for name in ObservationSummaryStats.model_fields: 

435 if name in ArchiveTree.model_fields: 

436 # Parent class fields, not summary statistics. 

437 continue 

438 a = getattr(self, name) 

439 b = getattr(other, name) 

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

441 if len(a) != len(b): 441 ↛ 442line 441 didn't jump to line 442 because the condition on line 441 was never true

442 return False 

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

444 if ai != bi and not (math.isnan(ai) and math.isnan(bi)): 444 ↛ 445line 444 didn't jump to line 445 because the condition on line 444 was never true

445 return False 

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

447 return False 

448 return True 

449 

450 def deserialize(self, archive: InputArchive[Any], **kwargs: Any) -> Self: 

451 """Extract this object from an archive. 

452 

453 Parameters 

454 ---------- 

455 archive 

456 Archive to read from. 

457 **kwargs 

458 Optional parameters. Not supported by this class. 

459 """ 

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

461 raise InvalidParameterError( 

462 f"Unrecognized parameters for ObservationSummaryStats: {set(kwargs.keys())}." 

463 ) 

464 # The model we want *is* the ArchiveTree. 

465 return self 

466 

467 def serialize(self, archive: OutputArchive[Any]) -> Self: 

468 """Write this object to an archive. 

469 

470 Parameters 

471 ---------- 

472 archive 

473 Archive to write to. 

474 """ 

475 # Copy so write-time overrides (metadata, butler_info) applied 

476 # by serialize_root do not mutate this object. 

477 return self.model_copy(deep=True) 

478 

479 @classmethod 

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

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

482 `lsst.afw.image.ExposureSummaryStats`. 

483 

484 Parameters 

485 ---------- 

486 exposure_summary_stats 

487 Legacy exposure summary statistics to convert. 

488 

489 Notes 

490 ----- 

491 Legacy fields that are empty (NaN) are dropped, so a legacy struct that 

492 carries fields unknown to this class is accepted as long as those 

493 fields are empty. A legacy field that holds a real value but is 

494 unknown here raises `ValueError`, since dropping it would lose data. 

495 """ 

496 known_fields = set(cls.model_fields) 

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

498 for name, value in dataclasses.asdict(exposure_summary_stats).items(): 

499 if _is_empty(value): 

500 continue 

501 # Strip version since it carries no information (it is always 0 

502 # in all our existing files) and this class uses explicit schema 

503 # versioning. 

504 if name == "version": 

505 continue 

506 if name not in known_fields: 

507 raise ValueError( 

508 f"Legacy field {name!r} has a value ({value!r}) but is not known to " 

509 f"ObservationSummaryStats." 

510 ) 

511 kwargs[name] = value 

512 return cls.model_validate(kwargs) 

513 

514 def to_legacy(self) -> LegacyExposureSummaryStats: 

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

516 

517 Notes 

518 ----- 

519 Empty (NaN) fields are not passed to the legacy struct, so fields 

520 defined here that are unknown to the installed version of 

521 `~lsst.afw.image.ExposureSummaryStats` are dropped when empty. A field 

522 that holds a real value but is unknown to the legacy struct raises 

523 `ValueError`, since dropping it would lose data. 

524 """ 

525 from lsst.afw.image import ExposureSummaryStats as LegacyExposureSummaryStats 

526 

527 legacy_fields = {field.name for field in dataclasses.fields(LegacyExposureSummaryStats)} 

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

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

530 if name in ArchiveTree.model_fields: 

531 # Parent class fields, not summary statistics. 

532 continue 

533 value = getattr(self, name) 

534 if _is_empty(value): 

535 continue 

536 if name not in legacy_fields: 536 ↛ 537line 536 didn't jump to line 537 because the condition on line 536 was never true

537 raise ValueError( 

538 f"Field {name!r} has a value ({value!r}) but is not supported by this " 

539 f"version of lsst.afw.image.ExposureSummaryStats." 

540 ) 

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

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

543 # without updating this function. 

544 if info.annotation in (float, int): 544 ↛ 546line 544 didn't jump to line 546 because the condition on line 544 was always true

545 kwargs[name] = value 

546 elif get_origin(info.annotation) is tuple: 

547 kwargs[name] = list(value) 

548 else: 

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

550 return LegacyExposureSummaryStats(**kwargs) 

551 

552 

553# Can not assign to itself in construction so assign now. 

554ObservationSummaryStats.PUBLIC_TYPE = ObservationSummaryStats