Coverage for python/lsst/images/_observation_summary_stats.py: 42%
139 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-22 17:43 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-22 17:43 +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
13__all__ = ("ObservationSummaryStats",)
15import dataclasses
16import math
17from typing import TYPE_CHECKING, Any, ClassVar, Self, final, get_origin
19import pydantic
21from lsst.images.serialization import ArchiveTree, InputArchive, InvalidParameterError, OutputArchive
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]
30def _default_corners() -> tuple[float, float, float, float]:
31 return (math.nan, math.nan, math.nan, math.nan)
34def _is_empty(value: Any) -> bool:
35 """Return whether a summary-statistic value is unset.
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)
48@final
49class ObservationSummaryStats(ArchiveTree):
50 """Various statistics obtained from a single observation."""
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.
57 psfSigma: float = pydantic.Field(math.nan, description="PSF determinant radius (pixels).")
59 psfArea: float = pydantic.Field(math.nan, description="PSF effective area (pixels**2).")
61 psfIxx: float = pydantic.Field(math.nan, description="PSF shape Ixx (pixels**2).")
63 psfIyy: float = pydantic.Field(math.nan, description="PSF shape Iyy (pixels**2).")
65 psfIxy: float = pydantic.Field(math.nan, description="PSF shape Ixy (pixels**2).")
67 ra: float = pydantic.Field(math.nan, description="Bounding box center Right Ascension (degrees).")
69 dec: float = pydantic.Field(math.nan, description="Bounding box center Declination (degrees).")
71 pixelScale: float = pydantic.Field(math.nan, description="Measured detector pixel scale (arcsec/pixel).")
73 zenithDistance: float = pydantic.Field(
74 math.nan, description="Bounding box center zenith distance (degrees)."
75 )
77 expTime: float = pydantic.Field(math.nan, description="Exposure time of the exposure (seconds).")
79 zeroPoint: float = pydantic.Field(math.nan, description="Mean zeropoint in detector (mag).")
81 skyBg: float = pydantic.Field(math.nan, description="Average sky background (ADU).")
83 skyNoise: float = pydantic.Field(math.nan, description="Average sky noise (ADU).")
85 meanVar: float = pydantic.Field(math.nan, description="Mean variance of the weight plane (ADU**2).")
87 raCorners: tuple[float, float, float, float] = pydantic.Field(
88 default_factory=_default_corners, description="Right Ascension of bounding box corners (degrees)."
89 )
91 decCorners: tuple[float, float, float, float] = pydantic.Field(
92 default_factory=_default_corners, description="Declination of bounding box corners (degrees)."
93 )
95 psfAdaptiveThresholdValue: float = pydantic.Field(
96 math.nan,
97 description="Threshold value used in the adaptive threshold detection pass for PSF modelling.",
98 )
100 psfAdaptiveIncludeThresholdMultiplier: float = pydantic.Field(
101 math.nan,
102 description="Threshold multiplier used in the adaptive threshold detection pass for PSF modelling.",
103 )
105 nShapeletsStar: int = pydantic.Field(
106 0,
107 description="Number of sources used in the shapelet decomposition.",
108 )
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 )
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 )
129 shapeletsCoeffs: tuple[float, ...] = pydantic.Field(
130 default_factory=tuple,
131 description="Coefficients from the PSF star shapelet decomposition.",
132 )
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 )
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 )
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 )
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 )
166 astromOffsetMean: float = pydantic.Field(math.nan, description="Astrometry match offset mean.")
168 astromOffsetStd: float = pydantic.Field(math.nan, description="Astrometry match offset stddev.")
170 nPsfStar: int = pydantic.Field(0, description="Number of stars used for psf model.")
172 psfStarDeltaE1Median: float = pydantic.Field(
173 math.nan, description="Psf stars median E1 residual (starE1 - psfE1)."
174 )
176 psfStarDeltaE2Median: float = pydantic.Field(
177 math.nan, description="Psf stars median E2 residual (starE2 - psfE2)."
178 )
180 psfStarDeltaE1Scatter: float = pydantic.Field(
181 math.nan, description="Psf stars MAD E1 scatter (starE1 - psfE1)."
182 )
184 psfStarDeltaE2Scatter: float = pydantic.Field(
185 math.nan, description="Psf stars MAD E2 scatter (starE2 - psfE2)."
186 )
188 psfStarDeltaSizeMedian: float = pydantic.Field(
189 math.nan, description="Psf stars median size residual (starSize - psfSize)."
190 )
192 psfStarDeltaSizeScatter: float = pydantic.Field(
193 math.nan, description="Psf stars MAD size scatter (starSize - psfSize)."
194 )
196 psfStarScaledDeltaSizeScatter: float = pydantic.Field(
197 math.nan, description="Psf stars MAD size scatter scaled by psfSize**2."
198 )
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 )
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 )
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 )
224 maxDistToNearestPsf: float = pydantic.Field(
225 math.nan,
226 description="Maximum distance of an unmasked pixel to its nearest model psf star (pixels).",
227 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
298 effTime: float = pydantic.Field(
299 math.nan,
300 description="Effective exposure time calculated from psfSigma, skyBg, and zeroPoint (seconds).",
301 )
303 effTimePsfSigmaScale: float = pydantic.Field(
304 math.nan, description="PSF scaling of the effective exposure time."
305 )
307 effTimeSkyBgScale: float = pydantic.Field(
308 math.nan, description="Sky background scaling of the effective exposure time."
309 )
311 effTimeZeroPointScale: float = pydantic.Field(
312 math.nan, description="Zeropoint scaling of the effective exposure time."
313 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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
450 def deserialize(self, archive: InputArchive[Any], **kwargs: Any) -> Self:
451 """Extract this object from an archive.
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
467 def serialize(self, archive: OutputArchive[Any]) -> Self:
468 """Write this object to an archive.
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)
479 @classmethod
480 def from_legacy(cls, exposure_summary_stats: LegacyExposureSummaryStats) -> Self:
481 """Return an `ObservationSummaryStats` from a legacy
482 `lsst.afw.image.ExposureSummaryStats`.
484 Parameters
485 ----------
486 exposure_summary_stats
487 Legacy exposure summary statistics to convert.
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)
514 def to_legacy(self) -> LegacyExposureSummaryStats:
515 """Convert to an `lsst.afw.image.ExposureSummaryStats` instance.
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
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)
553# Can not assign to itself in construction so assign now.
554ObservationSummaryStats.PUBLIC_TYPE = ObservationSummaryStats