Coverage for python/lsst/images/_observation_summary_stats.py: 47%
155 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 08:38 +0000
« 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.
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.describe import (
22 DescribableMixin,
23 DescribeOptions,
24 FieldRole,
25 Report,
26 ReportField,
27 ReportValueGroup,
28)
29from lsst.images.serialization import ArchiveTree, InputArchive, InvalidParameterError, OutputArchive
31if TYPE_CHECKING:
32 try:
33 from lsst.afw.image import ExposureSummaryStats as LegacyExposureSummaryStats
34 except ImportError:
35 type LegacyExposureSummaryStats = Any # type: ignore[no-redef]
38def _default_corners() -> tuple[float, float, float, float]:
39 return (math.nan, math.nan, math.nan, math.nan)
42def _is_empty(value: Any) -> bool:
43 """Return whether a summary-statistic value is unset.
45 A value counts as unset if it is NaN, or an empty sequence, or a sequence
46 whose entries are all unset. Such fields carry no information and can be
47 dropped when converting to or from the legacy representation, allowing the
48 two representations to define different sets of fields as long as the
49 fields they do not share are empty.
50 """
51 if isinstance(value, (list, tuple)):
52 return all(_is_empty(item) for item in value)
53 return isinstance(value, float) and math.isnan(value)
56@final
57class ObservationSummaryStats(ArchiveTree, DescribableMixin):
58 """Various statistics obtained from a single observation."""
60 SCHEMA_NAME: ClassVar[str] = "observation_summary_stats"
61 SCHEMA_VERSION: ClassVar[str] = "1.0.0.dev0"
62 MIN_READ_VERSION: ClassVar[int] = 1
63 PUBLIC_TYPE: ClassVar[type] # Assigned after class construction.
65 psfSigma: float = pydantic.Field(math.nan, description="PSF determinant radius (pixels).")
67 psfArea: float = pydantic.Field(math.nan, description="PSF effective area (pixels**2).")
69 psfIxx: float = pydantic.Field(math.nan, description="PSF shape Ixx (pixels**2).")
71 psfIyy: float = pydantic.Field(math.nan, description="PSF shape Iyy (pixels**2).")
73 psfIxy: float = pydantic.Field(math.nan, description="PSF shape Ixy (pixels**2).")
75 ra: float = pydantic.Field(math.nan, description="Bounding box center Right Ascension (degrees).")
77 dec: float = pydantic.Field(math.nan, description="Bounding box center Declination (degrees).")
79 pixelScale: float = pydantic.Field(math.nan, description="Measured detector pixel scale (arcsec/pixel).")
81 zenithDistance: float = pydantic.Field(
82 math.nan, description="Bounding box center zenith distance (degrees)."
83 )
85 expTime: float = pydantic.Field(math.nan, description="Exposure time of the exposure (seconds).")
87 zeroPoint: float = pydantic.Field(math.nan, description="Mean zeropoint in detector (mag).")
89 skyBg: float = pydantic.Field(math.nan, description="Average sky background (ADU).")
91 skyNoise: float = pydantic.Field(math.nan, description="Average sky noise (ADU).")
93 meanVar: float = pydantic.Field(math.nan, description="Mean variance of the weight plane (ADU**2).")
95 raCorners: tuple[float, float, float, float] = pydantic.Field(
96 default_factory=_default_corners, description="Right Ascension of bounding box corners (degrees)."
97 )
99 decCorners: tuple[float, float, float, float] = pydantic.Field(
100 default_factory=_default_corners, description="Declination of bounding box corners (degrees)."
101 )
103 psfAdaptiveThresholdValue: float = pydantic.Field(
104 math.nan,
105 description="Threshold value used in the adaptive threshold detection pass for PSF modelling.",
106 )
108 psfAdaptiveIncludeThresholdMultiplier: float = pydantic.Field(
109 math.nan,
110 description="Threshold multiplier used in the adaptive threshold detection pass for PSF modelling.",
111 )
113 nShapeletsStar: int = pydantic.Field(
114 0,
115 description="Number of sources used in the shapelet decomposition.",
116 )
118 shapeletsOnlyIqScore: float = pydantic.Field(
119 math.nan,
120 description=(
121 "The dimensionless image quality score as determined from the shapelets decomposition "
122 "that includes power only from the non-atmospheric decomposition coefficients. The "
123 "score spans the range [0.0, 1.0] with lower values indicating better image quality."
124 ),
125 )
127 shapeletsIqScore: float = pydantic.Field(
128 math.nan,
129 description=(
130 "The dimensionless image quality score as determined from the shapelets decomposition "
131 "that includes power from the median centroid offset between those used in the decomposition "
132 "and those of the centroid slot in addition to non-atmospheric decomposition coefficients. "
133 "The score spans the range [0.0, 1.0] with lower values indicating better image quality."
134 ),
135 )
137 shapeletsCoeffs: tuple[float, ...] = pydantic.Field(
138 default_factory=tuple,
139 description="Coefficients from the PSF star shapelet decomposition.",
140 )
142 centroidDiffShapeletsVsSlotMedian: float = pydantic.Field(
143 math.nan,
144 description=(
145 "Median centroid difference (sqrt((slot_x - shapelet_x)**2 + (slot_y - shapelet_y)**2)) for "
146 "sources used in the shapelet decomposition (pixels)."
147 ),
148 )
150 shapeletsStarEMedian: float = pydantic.Field(
151 math.nan,
152 description=(
153 "Median ellipticity (sqrt(starE1**2.0 + starE2**2.0)) of the sources used in the "
154 "shapelet decomposition."
155 ),
156 )
158 shapeletsStarUnNormalizedEMedian: float = pydantic.Field(
159 math.nan,
160 description=(
161 "Median un-normalized ellipticity (sqrt((starXX - starYY)**2.0 + (2.0*starXY)**2.0)) "
162 "of the sources used in the shapelet decomposition (pixel**2)."
163 ),
164 )
166 refCatSourceDensity: float = pydantic.Field(
167 math.nan,
168 description=(
169 "Source density for the detector region as computed from the loaded reference catalog "
170 "(number per degrees**2)."
171 ),
172 )
174 astromOffsetMean: float = pydantic.Field(math.nan, description="Astrometry match offset mean.")
176 astromOffsetStd: float = pydantic.Field(math.nan, description="Astrometry match offset stddev.")
178 nPsfStar: int = pydantic.Field(0, description="Number of stars used for psf model.")
180 psfStarDeltaE1Median: float = pydantic.Field(
181 math.nan, description="Psf stars median E1 residual (starE1 - psfE1)."
182 )
184 psfStarDeltaE2Median: float = pydantic.Field(
185 math.nan, description="Psf stars median E2 residual (starE2 - psfE2)."
186 )
188 psfStarDeltaE1Scatter: float = pydantic.Field(
189 math.nan, description="Psf stars MAD E1 scatter (starE1 - psfE1)."
190 )
192 psfStarDeltaE2Scatter: float = pydantic.Field(
193 math.nan, description="Psf stars MAD E2 scatter (starE2 - psfE2)."
194 )
196 psfStarDeltaSizeMedian: float = pydantic.Field(
197 math.nan, description="Psf stars median size residual (starSize - psfSize)."
198 )
200 psfStarDeltaSizeScatter: float = pydantic.Field(
201 math.nan, description="Psf stars MAD size scatter (starSize - psfSize)."
202 )
204 psfStarScaledDeltaSizeScatter: float = pydantic.Field(
205 math.nan, description="Psf stars MAD size scatter scaled by psfSize**2."
206 )
208 psfTraceRadiusDelta: float = pydantic.Field(
209 math.nan,
210 description=(
211 "Delta (max - min) of the model psf trace radius values evaluated on a grid of "
212 "unmasked pixels (pixels)."
213 ),
214 )
216 psfApFluxDelta: float = pydantic.Field(
217 math.nan,
218 description=(
219 "Delta (max - min) of the model psf aperture flux (with aperture radius of max(2, 3*psfSigma)) "
220 "values evaluated on a grid of unmasked pixels."
221 ),
222 )
224 psfApCorrSigmaScaledDelta: float = pydantic.Field(
225 math.nan,
226 description=(
227 "Delta (max - min) of the psf flux aperture correction factors scaled (divided) by the "
228 "psfSigma evaluated on a grid of unmasked pixels."
229 ),
230 )
232 maxDistToNearestPsf: float = pydantic.Field(
233 math.nan,
234 description="Maximum distance of an unmasked pixel to its nearest model psf star (pixels).",
235 )
237 starEMedian: float = pydantic.Field(
238 math.nan,
239 description=(
240 "Median ellipticity (sqrt(starE1**2.0 + starE2**2.0)) of the stars used in the PSF model."
241 ),
242 )
244 starUnNormalizedEMedian: float = pydantic.Field(
245 math.nan,
246 description=(
247 "Median un-normalized ellipticity (sqrt((starXX - starYY)**2.0 + "
248 "(2.0*starXY)**2.0)) of the stars used in the PSF model."
249 ),
250 )
252 starComa1Median: float = pydantic.Field(
253 math.nan,
254 description=(
255 "Coma-like higher-order moment combination: median M30 + M12 of the stars used in the PSF model."
256 ),
257 )
259 starComa2Median: float = pydantic.Field(
260 math.nan,
261 description=(
262 "Coma-like higher-order moment combination: median M21 + M03 of the stars used in the PSF model."
263 ),
264 )
266 starTrefoil1Median: float = pydantic.Field(
267 math.nan,
268 description=(
269 "Trefoil-like higher-order moment combination: median M30 - 3*M12 "
270 "of the stars used in the PSF model."
271 ),
272 )
274 starTrefoil2Median: float = pydantic.Field(
275 math.nan,
276 description=(
277 "Trefoil-like higher-order moment combination: median 3*M21 - M03 "
278 "of the stars used in the PSF model."
279 ),
280 )
282 starKurtosisMedian: float = pydantic.Field(
283 math.nan,
284 description=(
285 "Kurtosis-like higher-order moment combination: median M40 + 2*M22 + M04 "
286 "of the stars used in the PSF model."
287 ),
288 )
290 starE41Median: float = pydantic.Field(
291 math.nan,
292 description=(
293 "Fourth-order ellipticity-like higher-order moment combination: median M40 - M04 "
294 "of the stars used in the PSF model."
295 ),
296 )
298 starE42Median: float = pydantic.Field(
299 math.nan,
300 description=(
301 "Fourth-order ellipticity-like higher-order moment combination: median 2*(M31 + M13) "
302 "of the stars used in the PSF model."
303 ),
304 )
306 effTime: float = pydantic.Field(
307 math.nan,
308 description="Effective exposure time calculated from psfSigma, skyBg, and zeroPoint (seconds).",
309 )
311 effTimePsfSigmaScale: float = pydantic.Field(
312 math.nan, description="PSF scaling of the effective exposure time."
313 )
315 effTimeSkyBgScale: float = pydantic.Field(
316 math.nan, description="Sky background scaling of the effective exposure time."
317 )
319 effTimeZeroPointScale: float = pydantic.Field(
320 math.nan, description="Zeropoint scaling of the effective exposure time."
321 )
323 magLim: float = pydantic.Field(
324 math.nan,
325 description=(
326 "Magnitude limit at fixed SNR (default SNR=5) calculated from psfSigma, skyBg,"
327 " zeroPoint, and readNoise."
328 ),
329 )
331 psfTE1e1: float = pydantic.Field(
332 math.nan,
333 description=(
334 "Per-exposure TE1e1 ~ <de1 de1> of PSF residual ellipticity, averaged over theta "
335 "[0,1] arcmin via treecorr KK correlation. Dimensionless; used to form the "
336 "full-survey TE1 metric."
337 ),
338 )
340 psfTE1e2: float = pydantic.Field(
341 math.nan,
342 description=(
343 "Per-exposure TE1e2 ~ <de2 de2> of PSF residual ellipticity, averaged over theta "
344 "[0,1] arcmin via treecorr KK correlation. Dimensionless; used to form the "
345 "full-survey TE1 metric."
346 ),
347 )
349 psfTE1ex: float = pydantic.Field(
350 math.nan,
351 description=(
352 "Per-exposure TE1ex ~ <de1 de2> of PSF residual ellipticity, averaged over theta "
353 "[0,1] arcmin via treecorr KK correlation. Dimensionless; used to form the "
354 "full-survey TE1 metric."
355 ),
356 )
358 psfTE2e1: float = pydantic.Field(
359 math.nan,
360 description=(
361 "Per-exposure TE2e1 ~ <de1 de1> of PSF residual ellipticity, averaged over theta "
362 "[5,100] arcmin via treecorr KK correlation. Dimensionless; used to form the "
363 "full-survey TE2 metric."
364 ),
365 )
367 psfTE2e2: float = pydantic.Field(
368 math.nan,
369 description=(
370 "Per-exposure TE2e2 ~ <de2 de2> of PSF residual ellipticity, averaged over theta "
371 "[5,100] arcmin via treecorr KK correlation. Dimensionless; used to form the "
372 "full-survey TE2 metric."
373 ),
374 )
376 psfTE2ex: float = pydantic.Field(
377 math.nan,
378 description=(
379 "Per-exposure TE2ex ~ <de1 de2> of PSF residual ellipticity, averaged over theta "
380 "[5,100] arcmin via treecorr KK correlation. Dimensionless; used to form the "
381 "full-survey TE2 metric."
382 ),
383 )
385 psfTE3e1: float = pydantic.Field(
386 math.nan,
387 description=(
388 "Per-exposure median-over-CCDs of TE3e1 ~ <de1 de1> of PSF residual ellipticity, "
389 "where each CCD uses theta within [0,5] arcmin bins. Dimensionless; downstream "
390 "pipelines take the 85th percentile over images to evaluate TE3."
391 ),
392 )
394 psfTE3e2: float = pydantic.Field(
395 math.nan,
396 description=(
397 "Per-exposure median-over-CCDs of TE3e2 ~ <de2 de2> of PSF residual ellipticity, "
398 "where each CCD uses theta within [0,5] arcmin bins. Dimensionless; downstream "
399 "pipelines take the 85th percentile over images to evaluate TE3."
400 ),
401 )
403 psfTE3ex: float = pydantic.Field(
404 math.nan,
405 description=(
406 "Per-exposure median-over-CCDs of TE3ex ~ <de1 de2> of PSF residual ellipticity, "
407 "where each CCD uses theta within [0,5] arcmin bins. Dimensionless; downstream "
408 "pipelines take the 85th percentile over images to evaluate TE3."
409 ),
410 )
412 psfTE4e1: float = pydantic.Field(
413 math.nan,
414 description=(
415 "Per-exposure median-over-CCDs of TE4e1 ~ <de1 de1> of PSF residual ellipticity, "
416 "where each CCD uses theta within [5,20] arcmin bins. Dimensionless; downstream "
417 "pipelines take the 85th percentile over images to evaluate TE4."
418 ),
419 )
421 psfTE4e2: float = pydantic.Field(
422 math.nan,
423 description=(
424 "Per-exposure median-over-CCDs of TE4e2 ~ <de2 de2> of PSF residual ellipticity, "
425 "where each CCD uses theta within [5,20] arcmin bins. Dimensionless; downstream "
426 "pipelines take the 85th percentile over images to evaluate TE4."
427 ),
428 )
430 psfTE4ex: float = pydantic.Field(
431 math.nan,
432 description=(
433 "Per-exposure median-over-CCDs of TE4ex ~ <de1 de2> of PSF residual ellipticity, "
434 "where each CCD uses theta within [5,20] arcmin bins. Dimensionless; downstream "
435 "pipelines take the 85th percentile over images to evaluate TE4."
436 ),
437 )
439 def __eq__(self, other: object) -> bool:
440 if not isinstance(other, ObservationSummaryStats): 440 ↛ 441line 440 didn't jump to line 441 because the condition on line 440 was never true
441 return NotImplemented
442 for name in ObservationSummaryStats.model_fields:
443 if name in ArchiveTree.model_fields:
444 # Parent class fields, not summary statistics.
445 continue
446 a = getattr(self, name)
447 b = getattr(other, name)
448 if isinstance(a, tuple) and isinstance(b, tuple):
449 if len(a) != len(b): 449 ↛ 450line 449 didn't jump to line 450 because the condition on line 449 was never true
450 return False
451 for ai, bi in zip(a, b):
452 if ai != bi and not (math.isnan(ai) and math.isnan(bi)): 452 ↛ 453line 452 didn't jump to line 453 because the condition on line 452 was never true
453 return False
454 elif a != b and not (math.isnan(a) and math.isnan(b)):
455 return False
456 return True
458 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report:
459 """Return a `Report` describing these summary statistics.
461 Parameters
462 ----------
463 options : `DescribeOptions`, optional
464 Rendering options. `DescribeOptions.brief` reports how many
465 statistics are set instead of listing them.
467 Notes
468 -----
469 Which statistics an observation carries depends on which pipeline
470 steps produced it, and most of the many fields are unset in any given
471 one, so the report covers whichever of them hold a value rather than a
472 fixed selection.
473 """
474 # Everything this class adds to ArchiveTree is a statistic; what it
475 # inherits from it is serialization plumbing.
476 names = sorted(set(type(self).model_fields) - set(ArchiveTree.model_fields))
477 present = [(name, value) for name in names if not _is_empty(value := getattr(self, name))]
478 summary = f"ObservationSummaryStats({len(present)} of {len(names)} statistics set)"
479 if options.brief:
480 # The count is worth stating on its own where the values are not
481 # listed; alongside them it would only restate what is visible.
482 return Report(
483 type_name="ObservationSummaryStats",
484 summary=summary,
485 fields=[
486 ReportField(
487 label="statistics set",
488 value=f"{len(present)} of {len(names)}",
489 role=FieldRole.DERIVED,
490 )
491 ],
492 )
493 scalars: list[tuple[str, Any]] = []
494 fields: list[ReportField] = []
495 for name, value in present:
496 if isinstance(value, list | tuple):
497 # Too long to pack alongside the scalars; give it a line.
498 fields.append(ReportField(label=name, value=value, role=FieldRole.DERIVED))
499 else:
500 scalars.append((name, value))
501 return Report(
502 type_name="ObservationSummaryStats",
503 summary=summary,
504 fields=fields,
505 value_groups=[ReportValueGroup(values=scalars)] if scalars else [],
506 )
508 def __str__(self) -> str:
509 # pydantic's __str__ precedes the mixin's in the MRO and spells out all
510 # of the fields, nearly all of which are unset. ``repr`` keeps that
511 # exhaustive form, since it is the one that round-trips; ``str`` is the
512 # readable one, so it takes the report's summary.
513 return DescribableMixin.__str__(self)
515 def deserialize(self, archive: InputArchive[Any], **kwargs: Any) -> Self:
516 """Extract this object from an archive.
518 Parameters
519 ----------
520 archive
521 Archive to read from.
522 **kwargs
523 Optional parameters. Not supported by this class.
524 """
525 if kwargs: 525 ↛ 526line 525 didn't jump to line 526 because the condition on line 525 was never true
526 raise InvalidParameterError(
527 f"Unrecognized parameters for ObservationSummaryStats: {set(kwargs.keys())}."
528 )
529 # The model we want *is* the ArchiveTree.
530 return self
532 def serialize(self, archive: OutputArchive[Any]) -> Self:
533 """Write this object to an archive.
535 Parameters
536 ----------
537 archive
538 Archive to write to.
539 """
540 # Copy so write-time overrides (metadata, butler_info) applied
541 # by serialize_root do not mutate this object.
542 return self.model_copy(deep=True)
544 @classmethod
545 def from_legacy(cls, exposure_summary_stats: LegacyExposureSummaryStats) -> Self:
546 """Return an `ObservationSummaryStats` from a legacy
547 `lsst.afw.image.ExposureSummaryStats`.
549 Parameters
550 ----------
551 exposure_summary_stats
552 Legacy exposure summary statistics to convert.
554 Notes
555 -----
556 Legacy fields that are empty (NaN) are dropped, so a legacy struct that
557 carries fields unknown to this class is accepted as long as those
558 fields are empty. A legacy field that holds a real value but is
559 unknown here raises `ValueError`, since dropping it would lose data.
560 """
561 known_fields = set(cls.model_fields)
562 kwargs: dict[str, Any] = {}
563 for name, value in dataclasses.asdict(exposure_summary_stats).items():
564 if _is_empty(value):
565 continue
566 # Strip version since it carries no information (it is always 0
567 # in all our existing files) and this class uses explicit schema
568 # versioning.
569 if name == "version":
570 continue
571 if name not in known_fields:
572 raise ValueError(
573 f"Legacy field {name!r} has a value ({value!r}) but is not known to "
574 f"ObservationSummaryStats."
575 )
576 kwargs[name] = value
577 return cls.model_validate(kwargs)
579 def to_legacy(self) -> LegacyExposureSummaryStats:
580 """Convert to an `lsst.afw.image.ExposureSummaryStats` instance.
582 Notes
583 -----
584 Empty (NaN) fields are not passed to the legacy struct, so fields
585 defined here that are unknown to the installed version of
586 `~lsst.afw.image.ExposureSummaryStats` are dropped when empty. A field
587 that holds a real value but is unknown to the legacy struct raises
588 `ValueError`, since dropping it would lose data.
589 """
590 from lsst.afw.image import ExposureSummaryStats as LegacyExposureSummaryStats
592 legacy_fields = {field.name for field in dataclasses.fields(LegacyExposureSummaryStats)}
593 kwargs: dict[str, Any] = {}
594 for name, info in ObservationSummaryStats.model_fields.items():
595 if name in ArchiveTree.model_fields:
596 # Parent class fields, not summary statistics.
597 continue
598 value = getattr(self, name)
599 if _is_empty(value):
600 continue
601 if name not in legacy_fields: 601 ↛ 602line 601 didn't jump to line 602 because the condition on line 601 was never true
602 raise ValueError(
603 f"Field {name!r} has a value ({value!r}) but is not supported by this "
604 f"version of lsst.afw.image.ExposureSummaryStats."
605 )
606 # Doing this in general is hard, so we handle the fields that we
607 # know about and raise if somebody adds a field with a new type
608 # without updating this function.
609 if info.annotation in (float, int): 609 ↛ 611line 609 didn't jump to line 611 because the condition on line 609 was always true
610 kwargs[name] = value
611 elif get_origin(info.annotation) is tuple:
612 kwargs[name] = list(value)
613 else:
614 raise NotImplementedError(f"Unsupported field type: {info.annotation}.")
615 return LegacyExposureSummaryStats(**kwargs)
618# Can not assign to itself in construction so assign now.
619ObservationSummaryStats.PUBLIC_TYPE = ObservationSummaryStats