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.
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",
+ }
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