From d8e2ee9849dccef22daa8f95e06c13c4984458d8 Mon Sep 17 00:00:00 2001 From: jrmullaney Date: Fri, 28 Nov 2025 03:55:49 -0800 Subject: [PATCH 1/3] Add a simple task to load an ExposureCatalog --- python/lsst/analysis/tools/tasks/__init__.py | 1 + .../tools/tasks/exposureCatalogAnalysis.py | 120 ++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 python/lsst/analysis/tools/tasks/exposureCatalogAnalysis.py diff --git a/python/lsst/analysis/tools/tasks/__init__.py b/python/lsst/analysis/tools/tasks/__init__.py index 5d33cd197..469172885 100644 --- a/python/lsst/analysis/tools/tasks/__init__.py +++ b/python/lsst/analysis/tools/tasks/__init__.py @@ -19,6 +19,7 @@ from .diffimTaskDetectorVisitMetricsAnalysis import * from .diffimTaskDetectorVisitSpatiallySampledAnalysis import * from .diffMatchedAnalysis import * +from .exposureCatalogAnalysis import * from .gatherResourceUsage import * from .injectedObjectAnalysis import * from .makeMetricTable import * diff --git a/python/lsst/analysis/tools/tasks/exposureCatalogAnalysis.py b/python/lsst/analysis/tools/tasks/exposureCatalogAnalysis.py new file mode 100644 index 000000000..9df6a0e19 --- /dev/null +++ b/python/lsst/analysis/tools/tasks/exposureCatalogAnalysis.py @@ -0,0 +1,120 @@ +# This file is part of analysis_tools. +# +# Developed for the LSST Data Management System. +# This product includes software developed by the LSST Project +# (https://www.lsst.org). +# See the COPYRIGHT file at the top-level directory of this distribution +# for details of code ownership. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +from __future__ import annotations + +__all__ = ("ExposureCatalogAnalysisConfig", "ExposureCatalogAnalysisTask") + +import numpy as np +from astropy.table import Column + +from lsst.pex.config import Field, ListField +from lsst.pipe.base import connectionTypes as cT +from lsst.skymap import BaseSkyMap + +from ..interfaces import AnalysisBaseConfig, AnalysisBaseConnections, AnalysisPipelineTask + + +class ExposureCatalogAnalysisConnections( + AnalysisBaseConnections, + dimensions=(), + defaultTemplates={"inputName": ""}, +): + data = cT.Input( + doc="Exposure Catalog to analyze.", + name="{inputName}", + storageClass="ExposureCatalog", + dimensions=(), + deferLoad=True, + ) + + skymap = cT.Input( + doc="The skymap that covers the tract that the data is from.", + name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME, + storageClass="SkyMap", + dimensions=("skymap",), + ) + + camera = cT.PrerequisiteInput( + doc="Input camera to use for focal plane geometry.", + name="camera", + storageClass="Camera", + dimensions=("instrument",), + isCalibration=True, + ) + + def __init__(self, *, config=None): + super().__init__(config=config) + + self.data = cT.Input( + doc=self.data.doc, + name=self.data.name, + storageClass=self.data.storageClass, + deferLoad=self.data.deferLoad, + dimensions=frozenset(sorted(config.inputTableDimensions)), + multiple=self.data.multiple, + ) + + self.dimensions.update(frozenset(sorted(config.taskDimensions))) + + if not config.loadSkymap: + del self.skymap + if not config.loadCamera: + del self.camera + + +class ExposureCatalogAnalysisConfig( + AnalysisBaseConfig, pipelineConnections=ExposureCatalogAnalysisConnections +): + + inputTableDimensions = ListField[str](doc="Dimensions of the input table.", optional=False) + + taskDimensions = ListField[str](doc="Task and output dimensions.", optional=False) + + loadSkymap = Field[bool](doc="Whether to load the skymap.", default=False) + + loadCamera = Field[bool](doc="Whether to load the camera.", default=False) + + +class ExposureCatalogAnalysisTask(AnalysisPipelineTask): + """An ``AnalysisPipelineTask`` that loads an ExposureCatalog passes it + to the atools for analysis. + + It will also pass the ``camera`` and ``skyMap`` inputs to the parent run + methods if these are requested by the config parameters. + """ + + ConfigClass = ExposureCatalogAnalysisConfig + _DefaultName = "exposureCatalogAnalysisTask" + + def loadData(self, handle): + # The parent class loadData does not work for ExposureCatalog. + + data = handle.get().asAstropy() + for colname in list(data.columns.keys()): + # An ExposureCatalog may contain elements that are themselves + # arrays. In such cases, the arrays are expanded into multiple + # columns named: _0, _1, etc. + if isinstance(data[colname][0], np.ndarray): + for index in np.arange(len(data[colname][0])): + values = [row[index] for row in data[colname]] + data.add_column(Column(name=f"{colname}_{index}", data=values)) + + return data From 21c43d4c2c5d085600b510ae84a08937787281e9 Mon Sep 17 00:00:00 2001 From: jrmullaney Date: Fri, 28 Nov 2025 03:57:20 -0800 Subject: [PATCH 2/3] Add action to calc minmax range of vector --- .../analysis/tools/actions/scalar/scalarActions.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/python/lsst/analysis/tools/actions/scalar/scalarActions.py b/python/lsst/analysis/tools/actions/scalar/scalarActions.py index 089ac1251..0f49e0e77 100644 --- a/python/lsst/analysis/tools/actions/scalar/scalarActions.py +++ b/python/lsst/analysis/tools/actions/scalar/scalarActions.py @@ -33,6 +33,7 @@ "FracThreshold", "MaxAction", "MinAction", + "FullRangeAction", "FracInRange", "FracNan", "SumAction", @@ -284,6 +285,16 @@ def __call__(self, data: KeyedData, **kwargs) -> Scalar: return nanMin(_dataToArray(data[self.vectorKey.format(**kwargs)])[mask]) +class FullRangeAction(ScalarFromVectorAction): + """Returns the full range (i.e., max-min) of the given data.""" + + def __call__(self, data: KeyedData, **kwargs) -> Scalar: + mask = self.getMask(**kwargs) + return nanMax(_dataToArray(data[self.vectorKey.format(**kwargs)][mask])) - nanMin( + _dataToArray(data[self.vectorKey.format(**kwargs)][mask]) + ) + + class FracInRange(ScalarFromVectorAction): """Compute the fraction of a distribution that is between specified minimum and maximum values, and is not NaN. From e85b04f34fe1fb153eb21b7f89e109e747285833 Mon Sep 17 00:00:00 2001 From: jrmullaney Date: Fri, 28 Nov 2025 03:58:37 -0800 Subject: [PATCH 3/3] Add atool to summarize visit-level astrometry The atool calculates the on-sky corner-to-corner distance of each detector then reports summary stats for a whole visit. It also reports summary stats for pixelScales across the whole visit. --- python/lsst/analysis/tools/atools/__init__.py | 1 + .../tools/atools/astrometryMetrics.py | 89 +++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 python/lsst/analysis/tools/atools/astrometryMetrics.py diff --git a/python/lsst/analysis/tools/atools/__init__.py b/python/lsst/analysis/tools/atools/__init__.py index f341ff294..926d30a88 100644 --- a/python/lsst/analysis/tools/atools/__init__.py +++ b/python/lsst/analysis/tools/atools/__init__.py @@ -1,5 +1,6 @@ from .amplifierCorrelation import * from .astrometricRepeatability import * +from .astrometryMetrics import * from .calexpMetrics import * from .calibQuantityProfile import * from .calibration import * diff --git a/python/lsst/analysis/tools/atools/astrometryMetrics.py b/python/lsst/analysis/tools/atools/astrometryMetrics.py new file mode 100644 index 000000000..8c0b7a77d --- /dev/null +++ b/python/lsst/analysis/tools/atools/astrometryMetrics.py @@ -0,0 +1,89 @@ +# This file is part of analysis_tools. +# +# Developed for the LSST Data Management System. +# This product includes software developed by the LSST Project +# (https://www.lsst.org). +# See the COPYRIGHT file at the top-level directory of this distribution +# for details of code ownership. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from ..actions.scalar import ( + FullRangeAction, + MaxAction, + MeanAction, + MedianAction, + MinAction, + SigmaMadAction, + StdevAction, +) +from ..actions.vector import AngularSeparation, DivideVector +from ..interfaces import AnalysisTool + +__all__ = ("AstrometryStatistics",) + + +class AstrometryStatistics(AnalysisTool): + """Calculate astrometry metrics from the visit_summary table.""" + + def setDefaults(self): + super().setDefaults() + + self.process.buildActions.cornersep = AngularSeparation( + raKey_A="raCorners_0", + decKey_A="decCorners_0", + raKey_B="raCorners_2", + decKey_B="decCorners_2", + outputUnit="arcminute", + ) + self.process.buildActions.ratio = DivideVector() + self.process.buildActions.ratio.actionA = AngularSeparation( + raKey_A="raCorners_0", + decKey_A="decCorners_0", + raKey_B="raCorners_2", + decKey_B="decCorners_2", + outputUnit="arcminute", + ) + self.process.buildActions.ratio.actionB = AngularSeparation( + raKey_A="raCorners_1", + decKey_A="decCorners_1", + raKey_B="raCorners_3", + decKey_B="decCorners_3", + outputUnit="arcminute", + ) + + self.process.calculateActions.minCornerSeparation = MinAction(vectorKey="cornersep") + self.process.calculateActions.maxCornerSeparation = MaxAction(vectorKey="cornersep") + self.process.calculateActions.minCornerSeparationRatio = MinAction(vectorKey="ratio") + self.process.calculateActions.maxCornerSeparationRatio = MaxAction(vectorKey="ratio") + self.process.calculateActions.minPixelScale = MinAction(vectorKey="pixelScale") + self.process.calculateActions.maxPixelScale = MaxAction(vectorKey="pixelScale") + self.process.calculateActions.fullRangePixelScale = FullRangeAction(vectorKey="pixelScale") + self.process.calculateActions.medianPixelScale = MedianAction(vectorKey="pixelScale") + self.process.calculateActions.sigmaMADPixelScale = SigmaMadAction(vectorKey="pixelScale") + self.process.calculateActions.meanPixelScale = MeanAction(vectorKey="pixelScale") + self.process.calculateActions.stdevPixelScale = StdevAction(vectorKey="pixelScale") + + self.produce.metric.units = { + "minCornerSeparation": "arcmin", + "maxCornerSeparation": "arcmin", + "minCornerSeparationRatio": "", + "maxCornerSeparationRatio": "", + "minPixelScale": "arcsec", + "maxPixelScale": "arcsec", + "medianPixelScale": "arcsec", + "sigmaMADPixelScale": "arcsec", + "meanPixelScale": "arcsec", + "stdevPixelScale": "arcsec", + }