Coverage for python/lsst/images/_observation_summary_stats.py: 61%
92 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-05-29 08:40 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-05-29 08:40 +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, Self, get_origin
19import pydantic
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]
28def _default_corners() -> tuple[float, float, float, float]:
29 return (math.nan, math.nan, math.nan, math.nan)
32class ObservationSummaryStats(pydantic.BaseModel, ser_json_inf_nan="constants"):
33 version: int = pydantic.Field(0, description="Version of the model.")
35 psfSigma: float = pydantic.Field(math.nan, description="PSF determinant radius (pixels).")
37 psfArea: float = pydantic.Field(math.nan, description="PSF effective area (pixels**2).")
39 psfIxx: float = pydantic.Field(math.nan, description="PSF shape Ixx (pixels**2).")
41 psfIyy: float = pydantic.Field(math.nan, description="PSF shape Iyy (pixels**2).")
43 psfIxy: float = pydantic.Field(math.nan, description="PSF shape Ixy (pixels**2).")
45 ra: float = pydantic.Field(math.nan, description="Bounding box center Right Ascension (degrees).")
47 dec: float = pydantic.Field(math.nan, description="Bounding box center Declination (degrees).")
49 pixelScale: float = pydantic.Field(math.nan, description="Measured detector pixel scale (arcsec/pixel).")
51 zenithDistance: float = pydantic.Field(
52 math.nan, description="Bounding box center zenith distance (degrees)."
53 )
55 expTime: float = pydantic.Field(math.nan, description="Exposure time of the exposure (seconds).")
57 zeroPoint: float = pydantic.Field(math.nan, description="Mean zeropoint in detector (mag).")
59 skyBg: float = pydantic.Field(math.nan, description="Average sky background (ADU).")
61 skyNoise: float = pydantic.Field(math.nan, description="Average sky noise (ADU).")
63 meanVar: float = pydantic.Field(math.nan, description="Mean variance of the weight plane (ADU**2).")
65 raCorners: tuple[float, float, float, float] = pydantic.Field(
66 default_factory=_default_corners, description="Right Ascension of bounding box corners (degrees)."
67 )
69 decCorners: tuple[float, float, float, float] = pydantic.Field(
70 default_factory=_default_corners, description="Declination of bounding box corners (degrees)."
71 )
73 psfAdaptiveThresholdValue: float = pydantic.Field(
74 math.nan,
75 description="Threshold value used in the adaptive threshold detection pass for PSF modelling.",
76 )
78 psfAdaptiveIncludeThresholdMultiplier: float = pydantic.Field(
79 math.nan,
80 description="Threshold multiplier used in the adaptive threshold detection pass for PSF modelling.",
81 )
83 nShapeletsStar: int = pydantic.Field(
84 0,
85 description="Number of sources used in the shapelet decomposition.",
86 )
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 )
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 )
107 shapeletsCoeffs: tuple[float, ...] = pydantic.Field(
108 default_factory=tuple,
109 description="Coefficients from the PSF star shapelet decomposition.",
110 )
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 )
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 )
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 )
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 )
144 astromOffsetMean: float = pydantic.Field(math.nan, description="Astrometry match offset mean.")
146 astromOffsetStd: float = pydantic.Field(math.nan, description="Astrometry match offset stddev.")
148 nPsfStar: int = pydantic.Field(0, description="Number of stars used for psf model.")
150 psfStarDeltaE1Median: float = pydantic.Field(
151 math.nan, description="Psf stars median E1 residual (starE1 - psfE1)."
152 )
154 psfStarDeltaE2Median: float = pydantic.Field(
155 math.nan, description="Psf stars median E2 residual (starE2 - psfE2)."
156 )
158 psfStarDeltaE1Scatter: float = pydantic.Field(
159 math.nan, description="Psf stars MAD E1 scatter (starE1 - psfE1)."
160 )
162 psfStarDeltaE2Scatter: float = pydantic.Field(
163 math.nan, description="Psf stars MAD E2 scatter (starE2 - psfE2)."
164 )
166 psfStarDeltaSizeMedian: float = pydantic.Field(
167 math.nan, description="Psf stars median size residual (starSize - psfSize)."
168 )
170 psfStarDeltaSizeScatter: float = pydantic.Field(
171 math.nan, description="Psf stars MAD size scatter (starSize - psfSize)."
172 )
174 psfStarScaledDeltaSizeScatter: float = pydantic.Field(
175 math.nan, description="Psf stars MAD size scatter scaled by psfSize**2."
176 )
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 )
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 )
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 )
202 maxDistToNearestPsf: float = pydantic.Field(
203 math.nan,
204 description="Maximum distance of an unmasked pixel to its nearest model psf star (pixels).",
205 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
276 effTime: float = pydantic.Field(
277 math.nan,
278 description="Effective exposure time calculated from psfSigma, skyBg, and zeroPoint (seconds).",
279 )
281 effTimePsfSigmaScale: float = pydantic.Field(
282 math.nan, description="PSF scaling of the effective exposure time."
283 )
285 effTimeSkyBgScale: float = pydantic.Field(
286 math.nan, description="Sky background scaling of the effective exposure time."
287 )
289 effTimeZeroPointScale: float = pydantic.Field(
290 math.nan, description="Zeropoint scaling of the effective exposure time."
291 )
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 )
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
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)
327 def to_legacy(self) -> LegacyExposureSummaryStats:
328 """Convert to an `lsst.afw.image.ExposureSummaryStats` instance."""
329 from lsst.afw.image import ExposureSummaryStats as LegacyExposureSummaryStats
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)