diff --git a/config/aggregateCalibrateImageMetadata.py b/config/aggregateCalibrateImageMetadata.py
new file mode 100644
index 000000000..bd6820b03
--- /dev/null
+++ b/config/aggregateCalibrateImageMetadata.py
@@ -0,0 +1,82 @@
+# Configuration for lsst.analysis.tools.tasks.AggregatedTaskMetadataAnalysisTask
+# when aggregating calibrateImage task metadata across detectors to produce
+# per-visit aggregate statistics.
+
+from lsst.analysis.tools.actions.scalar import CountAction, MaxAction, MedianAction, MinAction, SigmaMadAction
+from lsst.analysis.tools.atools import AggregatedTaskMetadataMetricTool
+
+config.atools.calibrateImageMetadataMetrics = AggregatedTaskMetadataMetricTool
+config.atools.calibrateImageMetadataMetrics.taskName = "calibrateImage"
+config.atools.calibrateImageMetadataMetrics.metrics = {
+ "initial_psf_positive_footprint_count": "ct",
+ "initial_psf_negative_footprint_count": "ct",
+ "initial_psf_positive_peak_count": "ct",
+ "initial_psf_negative_peak_count": "ct",
+ "simple_psf_positive_footprint_count": "ct",
+ "simple_psf_negative_footprint_count": "ct",
+ "simple_psf_positive_peak_count": "ct",
+ "simple_psf_negative_peak_count": "ct",
+ "bad_mask_fraction": "",
+ "cr_mask_fraction": "",
+ "crosstalk_mask_fraction": "",
+ "detected_mask_fraction": "",
+ "detected_negative_mask_fraction": "",
+ "edge_mask_fraction": "",
+ "intrp_mask_fraction": "",
+ "no_data_mask_fraction": "",
+ "sat_mask_fraction": "",
+ "suspect_mask_fraction": "",
+ "unmaskednan_mask_fraction": "",
+ "numAvailStars": "ct",
+ "numGoodStars": "ct",
+ "sky_footprint_count": "ct",
+ "post_deblend_source_count": "ct",
+ "star_count": "ct",
+ "saturated_source_count": "ct",
+ "bad_source_count": "ct",
+ "cosmic_ray_count": "ct",
+ "matched_psf_star_count": "ct",
+ "final_psf_sigma": "pixel",
+ "astrometry_matches_count": "ct",
+ "photometry_matches_count": "ct",
+ "bg_subtracted_skyPixel_instFlux_median": "adu",
+ "bg_subtracted_skyPixel_instFlux_stdev": "adu",
+ "bg_subtracted_skySource_flux_median": "nJy",
+ "bg_subtracted_skySource_flux_stdev": "nJy",
+ "adaptive_threshold_value": "",
+ "initial_to_final_wcs": "arcsec",
+ "astrom_offset_mean": "arcsec",
+ "astrom_offset_std": "arcsec",
+ "astrom_offset_median": "arcsec",
+ "failed_deblend_source_count": "ct",
+}
+config.atools.calibrateImageMetadataMetrics.subTaskNames = {
+ "numAvailStars": "psf_measure_psf",
+ "numGoodStars": "psf_measure_psf",
+ "sky_footprint_count": "star_sky_sources",
+ "cosmic_ray_count": "psf_repair",
+}
+config.atools.calibrateImageMetadataMetrics.newNames = {
+ "numAvailStars": "psf_available_star_count",
+ "numGoodStars": "psf_good_star_count",
+ "adaptive_threshold_value": "final_adaptive_threshold_value",
+}
+
+aggregations = {
+ "min": MinAction,
+ "max": MaxAction,
+ "median": MedianAction,
+ "mad": SigmaMadAction,
+ "ct": CountAction,
+}
+config.atools.calibrateImageMetadataMetrics.aggregationUnits = {"ct": "ct"}
+
+for metric in config.atools.calibrateImageMetadataMetrics.metrics:
+ for agg_name, agg_cls in aggregations.items():
+ action = agg_cls()
+ action.vectorKey = metric
+ setattr(
+ config.atools.calibrateImageMetadataMetrics.process.calculateActions,
+ f"{metric}_{agg_name}",
+ action,
+ )
diff --git a/pipelines/visitQualityCore.yaml b/pipelines/visitQualityCore.yaml
index ca63f8de1..f5cbfcb6d 100644
--- a/pipelines/visitQualityCore.yaml
+++ b/pipelines/visitQualityCore.yaml
@@ -38,66 +38,37 @@ tasks:
config:
atools.calexpSummaryMetrics: CalexpSummaryMetrics
python: from lsst.analysis.tools.atools import *
- analyzeCalibrateImageMetadata:
- class: lsst.analysis.tools.tasks.TaskMetadataAnalysisTask
+ aggregateCalibrateImageMetadata:
+ class: lsst.analysis.tools.tasks.AggregatedTaskMetadataAnalysisTask
config:
connections.inputName: calibrateImage_metadata
- connections.outputName: calibrateImage_metadata # Will be appended with "_metrics"
- connections.storageClass: TaskMetadata
- raiseNoWorkFoundOnIncompleteMetadata: true
- inputDimensions: ["instrument", "visit", "detector"]
- atools.calibrateImageMetadataMetrics: TaskMetadataMetricTool
- atools.calibrateImageMetadataMetrics.taskName: calibrateImage
- atools.calibrateImageMetadataMetrics.metrics:
- initial_psf_positive_footprint_count: ct
- initial_psf_negative_footprint_count: ct
- initial_psf_positive_peak_count: ct
- initial_psf_negative_peak_count: ct
- simple_psf_positive_footprint_count: ct
- simple_psf_negative_footprint_count: ct
- simple_psf_positive_peak_count: ct
- simple_psf_negative_peak_count: ct
- bad_mask_fraction: ""
- cr_mask_fraction: ""
- crosstalk_mask_fraction: ""
- detected_mask_fraction: ""
- detected_negative_mask_fraction: ""
- edge_mask_fraction: ""
- intrp_mask_fraction: ""
- no_data_mask_fraction: ""
- sat_mask_fraction: ""
- suspect_mask_fraction: ""
- unmaskednan_mask_fraction: ""
- numAvailStars: ct
- numGoodStars: ct
- sky_footprint_count: ct
- post_deblend_source_count: ct
- star_count: ct
- saturated_source_count: ct
- bad_source_count: ct
- cosmic_ray_count: ct
- matched_psf_star_count: ct
- final_psf_sigma: pixel
- astrometry_matches_count: ct
- photometry_matches_count: ct
- bg_subtracted_skyPixel_instFlux_median: adu
- bg_subtracted_skyPixel_instFlux_stdev: adu
- bg_subtracted_skySource_flux_median: nJy
- bg_subtracted_skySource_flux_stdev: nJy
- adaptive_threshold_value: ""
- initial_to_final_wcs: arcsec
- astrom_offset_mean: arcsec
- astrom_offset_std: arcsec
- astrom_offset_median: arcsec
- failed_deblend_source_count: ct
- atools.calibrateImageMetadataMetrics.subTaskNames:
- numAvailStars: psf_measure_psf
- numGoodStars: psf_measure_psf
- sky_footprint_count: star_sky_sources
- cosmic_ray_count: psf_repair
- atools.calibrateImageMetadataMetrics.newNames:
- numAvailStars: psf_available_star_count
- numGoodStars: psf_good_star_count
- adaptive_threshold_value: final_adaptive_threshold_value
- python: |
- from lsst.analysis.tools.atools import TaskMetadataMetricTool
+ connections.outputName: calibrateImage_metadata_aggregate
+ inputDataDimensions: ["instrument", "visit", "detector"]
+ outputDimensions: ["instrument", "visit"]
+ file: $ANALYSIS_TOOLS_DIR/config/aggregateCalibrateImageMetadata.py
+ makeMetricTableAggregateCalibrateImageMetadata:
+ class: lsst.analysis.tools.tasks.MakeMetricTableTask
+ config:
+ connections.metricBundleName: "calibrateImage_metadata_aggregate_metrics"
+ connections.outputTableName: "calibrateImage_metadata_aggregate_metrics_table"
+ inputDataDimensions: ["instrument", "visit"]
+ outputTableDimensions: ["instrument"]
+ dataIdFieldsToIncludeAsColumns: ["visit", "band"]
+ aggregateVisitSummary:
+ class: lsst.analysis.tools.tasks.ExposureCatalogAnalysisTask
+ config:
+ connections.inputName: visitSummary
+ connections.outputName: aggregateVisitSummary
+ inputTableDimensions: ["visit"]
+ taskDimensions: ["visit"]
+ atools.aggregateCalexpSummaryStats: AggregateCalexpSummaryStats
+ atools.aggregateCornerSeparations: AggregateCornerSeparations
+ python: from lsst.analysis.tools.atools import *
+ makeMetricTableAggregateVisitSummary:
+ class: lsst.analysis.tools.tasks.MakeMetricTableTask
+ config:
+ connections.metricBundleName: aggregateVisitSummary_metrics
+ connections.outputTableName: aggregateVisitSummary_metricsTable
+ inputDataDimensions: ["visit"]
+ outputTableDimensions: ["instrument"]
+ dataIdFieldsToIncludeAsColumns: ["visit", "band"]
diff --git a/pipelines/visitQualityExtended.yaml b/pipelines/visitQualityExtended.yaml
index 0fdaca864..ac6548888 100644
--- a/pipelines/visitQualityExtended.yaml
+++ b/pipelines/visitQualityExtended.yaml
@@ -1,8 +1,6 @@
description: |
Tier2 plots and metrics to assess visit quality
tasks:
- catalogMatchVisit:
- class: lsst.analysis.tools.tasks.astrometricCatalogMatch.AstrometricCatalogMatchVisitTask
refCatSourceVisit:
class: lsst.analysis.tools.tasks.refCatSourceAnalysis.RefCatSourceAnalysisTask
deltaSkyCorrHist:
@@ -13,3 +11,66 @@ tasks:
atools.deltaSkyCorr: DeltaSkyCorrXYPlot
python: |
from lsst.analysis.tools.atools import *
+ analyzeCalibrateImageMetadata:
+ class: lsst.analysis.tools.tasks.TaskMetadataAnalysisTask
+ config:
+ connections.inputName: calibrateImage_metadata
+ connections.outputName: calibrateImage_metadata # Will be appended with "_metrics"
+ connections.storageClass: TaskMetadata
+ raiseNoWorkFoundOnIncompleteMetadata: true
+ inputDimensions: ["instrument", "visit", "detector"]
+ atools.calibrateImageMetadataMetrics: TaskMetadataMetricTool
+ atools.calibrateImageMetadataMetrics.taskName: calibrateImage
+ atools.calibrateImageMetadataMetrics.metrics:
+ initial_psf_positive_footprint_count: ct
+ initial_psf_negative_footprint_count: ct
+ initial_psf_positive_peak_count: ct
+ initial_psf_negative_peak_count: ct
+ simple_psf_positive_footprint_count: ct
+ simple_psf_negative_footprint_count: ct
+ simple_psf_positive_peak_count: ct
+ simple_psf_negative_peak_count: ct
+ bad_mask_fraction: ""
+ cr_mask_fraction: ""
+ crosstalk_mask_fraction: ""
+ detected_mask_fraction: ""
+ detected_negative_mask_fraction: ""
+ edge_mask_fraction: ""
+ intrp_mask_fraction: ""
+ no_data_mask_fraction: ""
+ sat_mask_fraction: ""
+ suspect_mask_fraction: ""
+ unmaskednan_mask_fraction: ""
+ numAvailStars: ct
+ numGoodStars: ct
+ sky_footprint_count: ct
+ post_deblend_source_count: ct
+ star_count: ct
+ saturated_source_count: ct
+ bad_source_count: ct
+ cosmic_ray_count: ct
+ matched_psf_star_count: ct
+ final_psf_sigma: pixel
+ astrometry_matches_count: ct
+ photometry_matches_count: ct
+ bg_subtracted_skyPixel_instFlux_median: adu
+ bg_subtracted_skyPixel_instFlux_stdev: adu
+ bg_subtracted_skySource_flux_median: nJy
+ bg_subtracted_skySource_flux_stdev: nJy
+ adaptive_threshold_value: ""
+ initial_to_final_wcs: arcsec
+ astrom_offset_mean: arcsec
+ astrom_offset_std: arcsec
+ astrom_offset_median: arcsec
+ failed_deblend_source_count: ct
+ atools.calibrateImageMetadataMetrics.subTaskNames:
+ numAvailStars: psf_measure_psf
+ numGoodStars: psf_measure_psf
+ sky_footprint_count: star_sky_sources
+ cosmic_ray_count: psf_repair
+ atools.calibrateImageMetadataMetrics.newNames:
+ numAvailStars: psf_available_star_count
+ numGoodStars: psf_good_star_count
+ adaptive_threshold_value: final_adaptive_threshold_value
+ python: |
+ from lsst.analysis.tools.atools import TaskMetadataMetricTool
diff --git a/python/lsst/analysis/tools/actions/plot/matrixPlot.py b/python/lsst/analysis/tools/actions/plot/matrixPlot.py
index 2227d2f92..aa6d79892 100644
--- a/python/lsst/analysis/tools/actions/plot/matrixPlot.py
+++ b/python/lsst/analysis/tools/actions/plot/matrixPlot.py
@@ -310,7 +310,7 @@ def _validateInput(self, data: KeyedData, **kwargs: Any) -> None:
if not isinstance(data[self.matrixKey], np.ndarray) and data[self.matrixKey].ndim != 2:
raise ValueError(f"Input data is not a 2d array: {data[self.matrixKey]}")
# Check that the keyword arguments are valid.
- acceptableKwargs = {"plotInfo", "skymap", "band", "metric_tags", "fig"}
+ acceptableKwargs = {"plotInfo", "skymap", "band", "metric_tags", "produce_plots", "fig"}
if not set(kwargs).issubset(acceptableKwargs):
raise ValueError(
f"Only the following keyword arguments are allowed: {acceptableKwargs}. Got: {kwargs}"
diff --git a/python/lsst/analysis/tools/atools/__init__.py b/python/lsst/analysis/tools/atools/__init__.py
index a6cff4af2..703bf4c53 100644
--- a/python/lsst/analysis/tools/atools/__init__.py
+++ b/python/lsst/analysis/tools/atools/__init__.py
@@ -1,7 +1,6 @@
from .actionMagnitudeScatterPlot import *
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
deleted file mode 100644
index 8c0b7a77d..000000000
--- a/python/lsst/analysis/tools/atools/astrometryMetrics.py
+++ /dev/null
@@ -1,89 +0,0 @@
-# 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/atools/calexpMetrics.py b/python/lsst/analysis/tools/atools/calexpMetrics.py
index 517c5ea7f..68c50d8fa 100644
--- a/python/lsst/analysis/tools/atools/calexpMetrics.py
+++ b/python/lsst/analysis/tools/atools/calexpMetrics.py
@@ -23,14 +23,67 @@
__all__ = (
"CalexpSummaryMetrics",
"CalexpMetricHists",
+ "AggregateCalexpSummaryStats",
+ "AggregateCornerSeparations",
)
from lsst.pex.config import DictField
from ..actions.plot import HistPanel, HistPlot
-from ..actions.vector import BandSelector, LoadVector
+from ..actions.scalar import (
+ MaxAction,
+ MedianAction,
+ MinAction,
+ SigmaMadAction,
+)
+from ..actions.vector import (
+ AngularSeparation,
+ BandSelector,
+ DivideVector,
+ LoadVector,
+)
from ..interfaces import AnalysisTool
+# Common to both CalexpSummaryMetrics and AggregateCalexpSummaryStats.
+# Units in comments indicate compound units, which are unsupported.
+_SUMMARY_STATS_UNITS = {
+ "psfSigma": "pixel",
+ "psfArea": "", # pixel**2
+ "psfIxx": "", # pixel**2
+ "psfIyy": "", # pixel**2
+ "psfIxy": "", # pixel**2
+ "ra": "degree",
+ "dec": "degree",
+ "pixelScale": "", # arcsec/pixel.
+ "zenithDistance": "degree",
+ "expTime": "s",
+ "zeroPoint": "mag",
+ "skyBg": "electron",
+ "skyNoise": "electron",
+ "meanVar": "", # electron**2
+ "astromOffsetMean": "arcsec",
+ "astromOffsetStd": "arcsec",
+ "nPsfStar": "ct",
+ "psfStarDeltaE1Median": "",
+ "psfStarDeltaE2Median": "",
+ "psfStarDeltaE1Scatter": "",
+ "psfStarDeltaE2Scatter": "",
+ "psfStarDeltaSizeMedian": "pixel",
+ "psfStarDeltaSizeScatter": "pixel",
+ "psfStarScaledDeltaSizeScatter": "",
+ "psfTraceRadiusDelta": "pixel",
+ "psfApFluxDelta": "",
+ "psfApCorrSigmaScaledDelta": "",
+ "maxDistToNearestPsf": "pixel",
+ "starEMedian": "",
+ "starUnNormalizedEMedian": "", # pixel**2
+ "effTime": "s",
+ "effTimePsfSigmaScale": "",
+ "effTimeSkyBgScale": "",
+ "effTimeZeroPointScale": "",
+ "magLim": "mag",
+}
+
class CalexpSummaryMetrics(AnalysisTool):
"""
@@ -40,47 +93,7 @@ class CalexpSummaryMetrics(AnalysisTool):
propagateData: bool = True
- # raCorners and decCorners statistics cannot be written to a metric,
- # as metrics can only be single-valued (i.e., scalars).
- # Units in comments are to indicate compound units, which are currently
- # unsupported.
- _units = {
- "psfSigma": "pixel",
- "psfArea": "", # pixel**2
- "psfIxx": "", # pixel**2
- "psfIyy": "", # pixel**2
- "psfIxy": "", # pixel**2
- "ra": "degree",
- "dec": "degree",
- "pixelScale": "", # arcsec/pixel.
- "zenithDistance": "degree",
- "expTime": "s",
- "zeroPoint": "mag",
- "skyBg": "electron",
- "skyNoise": "electron",
- "meanVar": "", # electron**2
- "astromOffsetMean": "arcsec",
- "astromOffsetStd": "arcsec",
- "nPsfStar": "ct",
- "psfStarDeltaE1Median": "",
- "psfStarDeltaE2Median": "",
- "psfStarDeltaE1Scatter": "",
- "psfStarDeltaE2Scatter": "",
- "psfStarDeltaSizeMedian": "pixel",
- "psfStarDeltaSizeScatter": "pixel",
- "psfStarScaledDeltaSizeScatter": "",
- "psfTraceRadiusDelta": "pixel",
- "psfApFluxDelta": "",
- "psfApCorrSigmaScaledDelta": "",
- "maxDistToNearestPsf": "pixel",
- "starEMedian": "",
- "starUnNormalizedEMedian": "", # pixel**2
- "effTime": "s",
- "effTimePsfSigmaScale": "",
- "effTimeSkyBgScale": "",
- "effTimeZeroPointScale": "",
- "magLim": "mag",
- }
+ _units = _SUMMARY_STATS_UNITS
def setDefaults(self):
super().setDefaults()
@@ -110,3 +123,83 @@ def finalize(self):
for metric, label in self.metrics.items():
setattr(self.process.buildActions, metric, LoadVector(vectorKey=metric))
self.produce.plot.panels[metric] = HistPanel(hists={metric: "Number of calexps"}, label=label)
+
+
+class AggregateCalexpSummaryStats(AnalysisTool):
+ """
+ Class to calculate per-stat aggregates (min, max, median, sigmaMad) of
+ vectors of summary stats.
+ """
+
+ _units = _SUMMARY_STATS_UNITS
+
+ aggregators = {
+ "min": MinAction,
+ "max": MaxAction,
+ "median": MedianAction,
+ "sigmaMad": SigmaMadAction,
+ }
+
+ def finalize(self):
+ units = {}
+ for key in self._units.keys():
+ for agg_name, agg_cls in self.aggregators.items():
+ action = agg_cls()
+ action.vectorKey = key
+ setattr(self.process.calculateActions, f"{key}_{agg_name}", action)
+ units[f"{key}_{agg_name}"] = self._units[key]
+ self.produce.metric.units = units
+
+
+class AggregateCornerSeparations(AnalysisTool):
+ """
+ Class to calculate aggregate corner-to-corner separations
+ from vectors of on-sky corner coordinates.
+ """
+
+ 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.cornerSeparation_min = MinAction(vectorKey="cornersep")
+ self.process.calculateActions.cornerSeparation_max = MaxAction(vectorKey="cornersep")
+ self.process.calculateActions.cornerSeparation_median = MedianAction(vectorKey="cornersep")
+ self.process.calculateActions.cornerSeparation_sigmaMad = SigmaMadAction(vectorKey="cornersep")
+
+ self.process.calculateActions.cornerSeparationRatio_min = MinAction(vectorKey="ratio")
+ self.process.calculateActions.cornerSeparationRatio_max = MaxAction(vectorKey="ratio")
+ self.process.calculateActions.cornerSeparationRatio_median = MedianAction(vectorKey="ratio")
+ self.process.calculateActions.cornerSeparationRatio_sigmaMad = SigmaMadAction(vectorKey="ratio")
+
+ self.produce.metric.units = {
+ "cornerSeparation_min": "arcmin",
+ "cornerSeparation_max": "arcmin",
+ "cornerSeparation_median": "arcmin",
+ "cornerSeparation_sigmaMad": "arcmin",
+ "cornerSeparationRatio_min": "",
+ "cornerSeparationRatio_max": "",
+ "cornerSeparationRatio_median": "",
+ "cornerSeparationRatio_sigmaMad": "",
+ }
diff --git a/python/lsst/analysis/tools/atools/metadataMetrics.py b/python/lsst/analysis/tools/atools/metadataMetrics.py
index 736bce15d..a910ee542 100644
--- a/python/lsst/analysis/tools/atools/metadataMetrics.py
+++ b/python/lsst/analysis/tools/atools/metadataMetrics.py
@@ -21,6 +21,7 @@
from __future__ import annotations
__all__ = (
+ "AggregatedTaskMetadataMetricTool",
"DatasetMetadataMetricTool",
"TaskMetadataMetricTool",
)
@@ -117,3 +118,57 @@ def finalize(self):
if self.newNames is not None:
self.produce.metric.newNames = dict(self.newNames.items())
+
+
+class AggregatedTaskMetadataMetricTool(MetadataMetricTool):
+ """Tool to compute aggregate statistics from task metadata across
+ multiple inputs.
+ """
+
+ taskName = Field[str](
+ doc="The name of the task to extract metadata from.",
+ default=None,
+ )
+
+ subTaskNames = DictField[str, str](
+ doc="The names of subtasks to extract metadata from. "
+ "If the metric name is identified as one of the keys, then "
+ "the corresponding value is taken as the subTask metadata "
+ "from which to extract metadata.",
+ default=None,
+ optional=True,
+ )
+
+ aggregationUnits = DictField[str, str](
+ doc="Fixed units for specific aggregations, keyed by aggregation name "
+ "(the suffix after the metric name, e.g. 'ct' or 'frac'). "
+ "Overrides the source metric's unit for that aggregation.",
+ default={},
+ optional=True,
+ )
+
+ def finalize(self):
+ # Attribute names can't contain spaces, but metadata metric names
+ # might. Remove any spaces from names.
+ valid_metric_names = {self.makeValidAttributeName(k): v for k, v in self.metrics.items()}
+ units = {}
+ # This is the same as looping over the metric names, but it also
+ # provides the aggregator name at the same time which is needed
+ # for post-aggregation unit lookup.
+ for name, action in self.process.calculateActions.items():
+ vector_key = getattr(action, "vectorKey", None)
+ if vector_key is not None:
+ valid_vector_key = self.makeValidAttributeName(vector_key)
+ if valid_vector_key not in valid_metric_names:
+ raise ValueError(
+ f"Action {name!r} has vectorKey {vector_key!r} which is "
+ f"not in metrics: {list(self.metrics.keys())}"
+ )
+ agg_name = name[len(valid_vector_key) + 1 :]
+ if self.aggregationUnits and agg_name in self.aggregationUnits:
+ units[name] = self.aggregationUnits[agg_name]
+ else:
+ units[name] = valid_metric_names[valid_vector_key]
+ self.produce.metric.units = units
+ if self.newNames is not None:
+ self.produce.metric.newNames = dict(self.newNames.items())
diff --git a/python/lsst/analysis/tools/interfaces/_actions.py b/python/lsst/analysis/tools/interfaces/_actions.py
index 1b6b3a284..41d8d8988 100644
--- a/python/lsst/analysis/tools/interfaces/_actions.py
+++ b/python/lsst/analysis/tools/interfaces/_actions.py
@@ -302,7 +302,7 @@ class JointAction(AnalysisAction):
plot = ConfigurableActionField[PlotAction](doc="Action to run that will produce one or more plots")
def __call__(self, data: KeyedData, **kwargs) -> JointResults:
- if isinstance(self.plot, NoPlot):
+ if isinstance(self.plot, NoPlot) or not kwargs.get("produce_plots", True):
plots = None
else:
plots = self.plot(data, **kwargs)
diff --git a/python/lsst/analysis/tools/interfaces/_analysisTools.py b/python/lsst/analysis/tools/interfaces/_analysisTools.py
index 1dc61fbcf..985e142d9 100644
--- a/python/lsst/analysis/tools/interfaces/_analysisTools.py
+++ b/python/lsst/analysis/tools/interfaces/_analysisTools.py
@@ -157,6 +157,13 @@ def __init_subclass__(cls: type[AnalysisTool], **kwargs):
key/value.
"""
+ doProducePlots = Field[bool](
+ doc="""If this value is set to False, then no plots are generated.
+ Metric generation is unaffected. If the `AnalysisTool` produces no
+ metrics, then no outputs are generated""",
+ default=True,
+ )
+
def __call__(self, data: KeyedData, **kwargs) -> KeyedResults:
bands = kwargs.pop("bands", None)
if "plotInfo" in kwargs and kwargs.get("plotInfo") is not None:
@@ -186,12 +193,17 @@ def _call_single(self, data: KeyedData, **kwargs) -> KeyedResults:
# create a shallow copy of kwargs
kwargs = dict(**kwargs)
kwargs["metric_tags"] = list(self.metric_tags or ())
+ kwargs["produce_plots"] = self.doProducePlots
prepped: KeyedData = self.prep(data, **kwargs) # type: ignore
if self.propagateData:
prepped = ChainMap(data, prepped)
processed: KeyedData = self.process(prepped, **kwargs) # type: ignore
if self.propagateData:
processed = ChainMap(data, processed)
+ # If the analysis tool only produces plots, and doProducePlots is False
+ # then don't run produce; return nothing.
+ if not self.doProducePlots and isinstance(self.produce, PlotAction):
+ return {}
finalized: (
Mapping[str, PlotTypes] | PlotTypes | Mapping[str, Measurement] | Measurement | JointResults
) = self.produce(
@@ -278,6 +290,8 @@ def getOutputNames(self, config: pexConfig.Config | None = None) -> Iterable[str
result : `tuple` of `str`
Names for each plot produced by this action.
"""
+ if not self.doProducePlots:
+ return tuple()
match self.produce:
case JointAction(plot=NoPlot()):
return tuple()
diff --git a/python/lsst/analysis/tools/interfaces/_task.py b/python/lsst/analysis/tools/interfaces/_task.py
index 0a2d3eb86..0774bbfdd 100644
--- a/python/lsst/analysis/tools/interfaces/_task.py
+++ b/python/lsst/analysis/tools/interfaces/_task.py
@@ -192,6 +192,8 @@ def __init__(self, *, config: AnalysisBaseConfig = None): # type: ignore
# AnalysisPlots.
names: Mapping[str, AnalysisTool] = {}
for action in config.atools:
+ if not action.doProducePlots:
+ continue
if action.dynamicOutputNames:
outNames = action.getOutputNames(config=config)
else:
@@ -333,6 +335,12 @@ class AnalysisBaseConfig(PipelineTaskConfig, pipelineConnections=AnalysisBaseCon
metric_tags = ListField[str](
doc="List of tags which will be added to all configurable actions", default=[]
)
+ doProducePlots = Field[bool](
+ doc="Produce plot outputs for the atools configured on this "
+ "task. Set to False to disable plot generation for all atools "
+ "run by this task while still producing their metrics.",
+ default=True,
+ )
dataset_identifier = Field[str](doc="An identifier to be associated with output Metrics", optional=True)
reference_package = Field[str](
doc="A package who's version, at the time of metric upload to a "
@@ -399,6 +407,8 @@ def freeze(self):
for tool in self.atools:
for tag in self.metric_tags:
tool.metric_tags.insert(-1, tag)
+ if not self.doProducePlots:
+ tool.doProducePlots = False
super().freeze()
def validate(self):
diff --git a/python/lsst/analysis/tools/tasks/metadataAnalysis.py b/python/lsst/analysis/tools/tasks/metadataAnalysis.py
index 4791bcbf6..2f3eb25ad 100644
--- a/python/lsst/analysis/tools/tasks/metadataAnalysis.py
+++ b/python/lsst/analysis/tools/tasks/metadataAnalysis.py
@@ -24,6 +24,8 @@
"MetadataAnalysisConfig",
"DatasetMetadataAnalysisTask",
"TaskMetadataAnalysisTask",
+ "AggregatedTaskMetadataAnalysisConfig",
+ "AggregatedTaskMetadataAnalysisTask",
)
from lsst.pex.config import Field, ListField
@@ -194,3 +196,112 @@ def validateMetrics(self, metadata, taskName):
raise UpstreamFailureNoWorkFound(
f"None of the specified metrics were found in the {taskName} metadata"
)
+
+
+class AggregatedTaskMetadataAnalysisConnections(
+ AnalysisBaseConnections,
+ dimensions={},
+ defaultTemplates={"inputName": "", "outputName": "", "storageClass": "TaskMetadata"},
+):
+ def __init__(self, *, config=None):
+ """Allow for the dimensions of the input and output data and task
+ to be changed from the defaults via the corresponding config parameter.
+ This enables the task to work with any TaskMetadata.
+
+ Parameters
+ ----------
+ config : `AggregatedTaskMetadataAnalysisConfig`
+ Configuration for this task.
+ """
+ super().__init__(config=config)
+
+ self.data = connectionTypes.Input(
+ doc="Task metadata to aggregate across additional dimensions.",
+ name=config.connections.inputName,
+ storageClass=config.connections.storageClass,
+ deferLoad=True,
+ dimensions=frozenset(config.inputDataDimensions),
+ multiple=True,
+ )
+
+ self.dimensions.update(frozenset(config.outputDimensions))
+
+
+class AggregatedTaskMetadataAnalysisConfig(
+ AnalysisBaseConfig,
+ pipelineConnections=AggregatedTaskMetadataAnalysisConnections,
+):
+ inputDataDimensions = ListField(
+ default=["instrument", "visit", "detector"],
+ dtype=str,
+ doc="Dimensions of the input task metadata datasets.",
+ )
+ outputDimensions = ListField(
+ default=["instrument", "visit"],
+ dtype=str,
+ doc="Dimensions of the output metric bundle. Also this task's dimensions.",
+ )
+
+
+class AggregatedTaskMetadataAnalysisTask(AnalysisPipelineTask):
+ ConfigClass = AggregatedTaskMetadataAnalysisConfig
+ _DefaultName = "aggregatedTaskMetadataAnalysis"
+
+ def _collectData(self, handles, taskName):
+ """Collect metric vectors from a list of deferred dataset handles.
+
+ Parameters
+ ----------
+ handles : `list`
+ Deferred dataset handles whose ``.get().to_dict()`` returns the
+ task metadata as a nested dict.
+ taskName : `str`
+ The name of the task, used as the top-level key in the metadata
+ dict (subtask keys are of the form ``taskName:subTaskName``).
+
+ Returns
+ -------
+ data : `dict` [`str`, `list` [`float`]]
+ Mapping from metric name to list of per-input values.
+
+ Raises
+ ------
+ lsst.pipe.base.UpstreamFailureNoWorkFound
+ If no data could be collected from any input.
+ """
+ data: dict[str, list[float]] = {}
+ for i, handle in enumerate(handles):
+ metadata = handle.get().to_dict()
+ if not metadata:
+ continue
+ for atool in self.config.atools:
+ if not hasattr(atool, "metrics"):
+ continue
+ subTaskNames = getattr(atool, "subTaskNames", None) or {}
+ for metric in atool.metrics.keys():
+ if metric in subTaskNames:
+ taskFullName = f"{taskName}:{subTaskNames[metric]}"
+ else:
+ taskFullName = taskName
+ value = metadata.get(taskFullName, {}).get(metric)
+ if value is not None:
+ if metric not in data.keys():
+ data[metric] = [float("nan")] * len(handles)
+ data[metric][i] = float(value)
+
+ if not data:
+ raise UpstreamFailureNoWorkFound(f"No metadata entries found for {taskName}.")
+
+ return data
+
+ def runQuantum(self, butlerQC, inputRefs, outputRefs):
+ inputs = butlerQC.get(inputRefs)
+
+ # Collecting data from [taskName]_metedata datasetType.
+ # Extract taskName from the datasetType name.
+ taskName = inputRefs.data[0].datasetType.name
+ taskName = taskName[: taskName.find("_")]
+
+ data = self._collectData(inputs["data"], taskName)
+ outputs = self.run(data=data)
+ butlerQC.put(outputs, outputRefs)
diff --git a/tests/test_aggregatedMetadata.py b/tests/test_aggregatedMetadata.py
new file mode 100644
index 000000000..d6f10228e
--- /dev/null
+++ b/tests/test_aggregatedMetadata.py
@@ -0,0 +1,298 @@
+# 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
+
+from unittest import TestCase, main
+
+import numpy as np
+
+import lsst.utils.tests
+from lsst.analysis.tools.actions.scalar import CountAction, MaxAction, MedianAction, MinAction
+from lsst.analysis.tools.atools import AggregatedTaskMetadataMetricTool
+from lsst.analysis.tools.tasks import AggregatedTaskMetadataAnalysisTask
+from lsst.analysis.tools.tasks.metadataAnalysis import AggregatedTaskMetadataAnalysisConfig
+from lsst.pipe.base import UpstreamFailureNoWorkFound
+
+_PLOT_INFO = {"tableName": "", "run": "", "plotName": ""}
+
+
+def _make_tool(metrics, aggregation_units=None, subtask_names=None):
+ """Create and finalize a minimal AggregatedTaskMetadataMetricTool.
+
+ Parameters
+ ----------
+ metrics : `dict` [`str`, `str`]
+ Metric names and their units.
+ aggregation_units : `dict` [`str`, `str`], optional
+ Fixed units keyed by aggregation suffix.
+ subtask_names : `dict` [`str`, `str`], optional
+ Subtask names keyed by metric name.
+
+ Returns
+ -------
+ tool : `AggregatedTaskMetadataMetricTool`
+ A finalized tool.
+ """
+ tool = AggregatedTaskMetadataMetricTool()
+ tool.taskName = "test"
+ tool.metrics = metrics
+ if subtask_names is not None:
+ tool.subTaskNames = subtask_names
+ if aggregation_units is not None:
+ tool.aggregationUnits = aggregation_units
+
+ aggregations = {"min": MinAction, "max": MaxAction, "median": MedianAction, "ct": CountAction}
+ for metric in tool.metrics:
+ sanitized = metric.replace(" ", "_")
+ for agg_name, agg_cls in aggregations.items():
+ action = agg_cls()
+ action.vectorKey = metric
+ setattr(tool.process.calculateActions, f"{sanitized}_{agg_name}", action)
+
+ tool.finalize()
+ return tool
+
+
+def _make_task(metrics, subtask_names=None):
+ """Create a minimal AggregatedTaskMetadataAnalysisTask.
+
+ Parameters
+ ----------
+ metrics : `dict` [`str`, `str`]
+ Metric names and their units.
+ subtask_names : `dict` [`str`, `str`], optional
+ Subtask names keyed by metric name.
+
+ Returns
+ -------
+ task : `AggregatedTaskMetadataAnalysisTask`
+ A configured task.
+ """
+ config = AggregatedTaskMetadataAnalysisConfig()
+ config.connections.inputName = "test_metadata"
+ config.connections.outputName = "test_metadata_agg"
+ config.atools.testTool = AggregatedTaskMetadataMetricTool
+ config.atools.testTool.taskName = "test"
+ config.atools.testTool.metrics = metrics
+ if subtask_names is not None:
+ config.atools.testTool.subTaskNames = subtask_names
+
+ action = MedianAction()
+ action.vectorKey = next(iter(metrics))
+ config.atools.testTool.process.calculateActions.metric_median = action
+
+ return AggregatedTaskMetadataAnalysisTask(config=config)
+
+
+class _MockHandle:
+ """Minimal stand-in for a deferred dataset handle."""
+
+ def __init__(self, metadata_dict):
+ self._metadata = metadata_dict
+
+ def get(self):
+ metadata = self._metadata
+
+ class _MockMetadata:
+ def to_dict(self):
+ return metadata
+
+ return _MockMetadata()
+
+
+def _make_handles(metadata_dicts):
+ """Create a list of mock handles from a list of metadata dicts.
+
+ Parameters
+ ----------
+ metadata_dicts : `list` [`dict`]
+ One dict per handle, as returned by ``TaskMetadata.to_dict()``.
+
+ Returns
+ -------
+ handles : `list` [`_MockHandle`]
+ """
+ return [_MockHandle(md) for md in metadata_dicts]
+
+
+class TestAggregatedTaskMetadataMetricToolFinalize(TestCase):
+ """Tests for AggregatedTaskMetadataMetricTool.finalize."""
+
+ def testUnitsInheritedFromSourceMetric(self):
+ """min/max/median aggregations should inherit the source metric's
+ unit."""
+ tool = _make_tool({"nStars": "ct", "psfSigma": "pixel"})
+ units = tool.produce.metric.units
+ self.assertEqual(units["nStars_min"], "ct")
+ self.assertEqual(units["nStars_max"], "ct")
+ self.assertEqual(units["nStars_median"], "ct")
+ self.assertEqual(units["psfSigma_min"], "pixel")
+ self.assertEqual(units["psfSigma_max"], "pixel")
+ self.assertEqual(units["psfSigma_median"], "pixel")
+
+ def testAggregationUnitsOverride(self):
+ """aggregationUnits should override the inherited unit for that
+ suffix."""
+ tool = _make_tool(
+ {"nStars": "ct", "psfSigma": "pixel"},
+ aggregation_units={"ct": "ct"},
+ )
+ units = tool.produce.metric.units
+ # Count aggregations should use the override unit regardless of source.
+ self.assertEqual(units["nStars_ct"], "ct")
+ self.assertEqual(units["psfSigma_ct"], "ct")
+ # Other aggregations should still inherit.
+ self.assertEqual(units["psfSigma_min"], "pixel")
+
+ def testSpacesInMetricNames(self):
+ """Metric names containing spaces must be handled gracefully."""
+ tool = _make_tool({"n stars": "ct"})
+ units = tool.produce.metric.units
+ # The action name uses underscores; units should still be populated.
+ self.assertIn("n_stars_min", units)
+ self.assertIn("n_stars_max", units)
+ self.assertIn("n_stars_median", units)
+ self.assertEqual(units["n_stars_min"], "ct")
+
+ def testActionWithUnknownVectorKeyRaisesError(self):
+ """An action whose vectorKey is not in metrics should raise ValueError.
+
+ This guards against misconfiguration where someone assigns a vectorKey
+ that doesn't correspond to any configured metric.
+ """
+ tool = AggregatedTaskMetadataMetricTool()
+ tool.taskName = "test"
+ tool.metrics = {"nStars": "ct"}
+
+ known_action = MedianAction()
+ known_action.vectorKey = "nStars"
+ tool.process.calculateActions.nStars_median = known_action
+
+ unknown_action = MedianAction()
+ unknown_action.vectorKey = "notAMetric"
+ tool.process.calculateActions.notAMetric_median = unknown_action
+
+ with self.assertRaises(ValueError):
+ tool.finalize()
+
+
+class TestAggregatedTaskMetadataMetricToolNumerics(TestCase):
+ """Numerical correctness of aggregations."""
+
+ def setUp(self):
+ self.values = np.array([10.0, 20.0, 30.0, 40.0, 50.0])
+ self.tool = _make_tool({"nStars": "ct"}, aggregation_units={"ct": "ct"})
+
+ def _call_tool(self, data):
+ return self.tool(data, plotInfo=dict(_PLOT_INFO))
+
+ def testMin(self):
+ result = self._call_tool({"nStars": self.values})
+ self.assertAlmostEqual(result["nStars_min"].quantity.value, 10.0)
+
+ def testMax(self):
+ result = self._call_tool({"nStars": self.values})
+ self.assertAlmostEqual(result["nStars_max"].quantity.value, 50.0)
+
+ def testMedian(self):
+ result = self._call_tool({"nStars": self.values})
+ self.assertAlmostEqual(result["nStars_median"].quantity.value, 30.0)
+
+ def testCount(self):
+ result = self._call_tool({"nStars": self.values})
+ self.assertAlmostEqual(result["nStars_ct"].quantity.value, 5.0)
+
+ def testCountExcludesNaN(self):
+ """Count should exclude NaN values."""
+ values_with_nan = np.array([10.0, np.nan, 30.0, np.nan, 50.0])
+ result = self._call_tool({"nStars": values_with_nan})
+ self.assertAlmostEqual(result["nStars_ct"].quantity.value, 3.0)
+
+
+class TestAggregatedTaskMetadataAnalysisTask(TestCase):
+ """Tests for AggregatedTaskMetadataAnalysisTask._collectData."""
+
+ def testEmptyMetadataRaisesNoWorkFound(self):
+ """All inputs returning empty metadata should raise NoWorkFound."""
+ task = _make_task({"nStars": "ct"})
+ handles = _make_handles([{}, {}, {}])
+ with self.assertRaises(UpstreamFailureNoWorkFound):
+ task._collectData(handles, "test")
+
+ def testPartialInputsAccepted(self):
+ """Inputs missing a metric in some (but not all) detectors are fine."""
+ task = _make_task({"nStars": "ct"})
+ # One input lacks nStars — its value should be NaN.
+ handles = _make_handles(
+ [
+ {"test": {"nStars": 10.0}},
+ {"test": {}},
+ {"test": {"nStars": 30.0}},
+ ]
+ )
+ data = task._collectData(handles, "test")
+ self.assertEqual(len(data["nStars"]), 3)
+ self.assertEqual(data["nStars"][0], 10.0)
+ self.assertTrue(np.isnan(data["nStars"][1]))
+ self.assertEqual(data["nStars"][2], 30.0)
+
+ def testSubTaskNamesRespected(self):
+ """Metrics in subtasks should be found under the correct key."""
+ task = _make_task(
+ {"cosmicRayCount": "ct"},
+ subtask_names={"cosmicRayCount": "repair"},
+ )
+ handles = _make_handles(
+ [
+ {"test:repair": {"cosmicRayCount": 5.0}},
+ {"test:repair": {"cosmicRayCount": 8.0}},
+ ]
+ )
+ data = task._collectData(handles, "test")
+ self.assertIn("cosmicRayCount", data)
+ self.assertEqual(len(data["cosmicRayCount"]), 2)
+
+ def testTypoInMetricNameRaisesNoWorkFound(self):
+ """A metric name that doesn't match any key in the metadata raises
+ NoWorkFound, covering the case of a configuration typo.
+ """
+ task = _make_task({"nSatrs": "ct"}) # typo: nSatrs instead of nStars
+ handles = _make_handles(
+ [
+ {"test": {"nStars": 10.0}},
+ {"test": {"nStars": 20.0}},
+ ]
+ )
+ with self.assertRaises(UpstreamFailureNoWorkFound):
+ task._collectData(handles, "test")
+
+
+class MyMemoryTestCase(lsst.utils.tests.MemoryTestCase):
+ pass
+
+
+def setup_module(module):
+ lsst.utils.tests.init()
+
+
+if __name__ == "__main__":
+ lsst.utils.tests.init()
+ main()
diff --git a/tests/test_producePlots.py b/tests/test_producePlots.py
new file mode 100644
index 000000000..74c57168a
--- /dev/null
+++ b/tests/test_producePlots.py
@@ -0,0 +1,160 @@
+# 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 unittest import TestCase, main
+
+import matplotlib.pyplot as plt
+
+import lsst.utils.tests
+from lsst.analysis.tools.actions.vector import LoadVector
+from lsst.analysis.tools.atools import SkyObjectHistPlot
+from lsst.analysis.tools.interfaces import AnalysisTool, JointAction, PlotAction
+
+# BaseMetricAction is used to produce a real Measurement without needing to
+# hand-roll a MetricAction subclass.
+from lsst.analysis.tools.interfaces._stages import BaseMetricAction
+from lsst.analysis.tools.tasks.objectTableTractAnalysis import ObjectTableTractAnalysisConfig
+from lsst.pipe.base.connections import iterConnections
+from lsst.verify import Measurement
+
+
+class NullPlot(PlotAction):
+ def __call__(self, data, **kwargs):
+ return plt.figure()
+
+
+class ProducePlotsToolTestCase(TestCase):
+ """Test the `AnalysisTool.doProducePlots` switch."""
+
+ def _makeJointTool(self) -> AnalysisTool:
+ tool = AnalysisTool()
+ tool.prep.keysToLoad = ["value"]
+ tool.process.buildActions.value = LoadVector(vectorKey="value")
+ joint = JointAction(metric=BaseMetricAction(), plot=NullPlot())
+ joint.metric.units = {"value": "ct"}
+ tool.produce = joint
+ return tool
+
+ def testJointActionSkipsPlotOnly(self):
+ """With doProducePlots=False, the metric is still produced but the
+ plot is not.
+ """
+ tool = self._makeJointTool()
+ data = {"value": 5}
+
+ resultsWithPlots = tool(data)
+ self.assertTrue(any(isinstance(v, Measurement) for v in resultsWithPlots.values()))
+ self.assertTrue(any(hasattr(v, "savefig") for v in resultsWithPlots.values()))
+
+ tool.doProducePlots = False
+ resultsNoPlots = tool(data)
+ self.assertTrue(any(isinstance(v, Measurement) for v in resultsNoPlots.values()))
+ self.assertFalse(any(hasattr(v, "savefig") for v in resultsNoPlots.values()))
+
+ def testPurePlotActionProducesNothing(self):
+ """An AnalysisTool whose produce action is only a PlotAction (no
+ metric) produces no results at all when doProducePlots is False.
+ """
+ tool = AnalysisTool()
+ tool.produce = NullPlot()
+
+ self.assertTrue(tool({}))
+
+ tool.doProducePlots = False
+ self.assertEqual(tool({}), {})
+
+ def testGetOutputNamesEmptyWhenDisabled(self):
+ tool = self._makeJointTool()
+ self.assertNotEqual(tuple(tool.getOutputNames()), tuple())
+
+ tool.doProducePlots = False
+ self.assertEqual(tuple(tool.getOutputNames()), tuple())
+
+
+class ProducePlotsTaskTestCase(TestCase):
+ """Test that `AnalysisBaseConfig.doProducePlots` propagates to atools on
+ freeze, without ever re-enabling a tool that has disabled its own plots.
+ """
+
+ def _makeConfig(self, taskDoProducePlots: bool, toolDoProducePlots: bool | None = None):
+ config = ObjectTableTractAnalysisConfig()
+ config.connections.outputName = "test"
+ config.atools.skyObjectHistPlot = SkyObjectHistPlot()
+ if toolDoProducePlots is not None:
+ config.atools.skyObjectHistPlot.doProducePlots = toolDoProducePlots
+ config.doProducePlots = taskDoProducePlots
+ config.validate()
+ config.freeze()
+ return config
+
+ def testTaskLevelForcesToolsOff(self):
+ config = self._makeConfig(taskDoProducePlots=False)
+ self.assertFalse(config.atools.skyObjectHistPlot.doProducePlots)
+
+ def testTaskLevelDoesNotForceToolsOn(self):
+ config = self._makeConfig(taskDoProducePlots=True)
+ self.assertTrue(config.atools.skyObjectHistPlot.doProducePlots)
+
+ def testTaskLevelDoesNotReenableDisabledTool(self):
+ # There may be occasions when one wants to off the plots for one
+ # analysis tool within a suite of analysis tools.
+ config = self._makeConfig(taskDoProducePlots=True, toolDoProducePlots=False)
+ self.assertFalse(config.atools.skyObjectHistPlot.doProducePlots)
+
+
+class ProducePlotsConnectionsTestCase(TestCase):
+ """Test that plot output connections are not created for atools with
+ plots disabled.
+ """
+
+ def _plotConnectionNames(self, config) -> set[str]:
+ connections = config.connections.ConnectionsClass(config=config)
+ return {c.name for c in iterConnections(connections, "outputs") if c.storageClass == "Plot"}
+
+ def _makeConfig(self, doProducePlots: bool):
+ config = ObjectTableTractAnalysisConfig()
+ config.connections.outputName = "test"
+ config.atools.skyObjectHistPlot = SkyObjectHistPlot()
+ config.doProducePlots = doProducePlots
+ config.validate()
+ config.freeze()
+ return config
+
+ def testPlotConnectionsPresentWhenEnabled(self):
+ config = self._makeConfig(doProducePlots=True)
+ self.assertTrue(self._plotConnectionNames(config))
+
+ def testPlotConnectionsAbsentWhenDisabled(self):
+ config = self._makeConfig(doProducePlots=False)
+ self.assertEqual(self._plotConnectionNames(config), set())
+
+
+class MyMemoryTestCase(lsst.utils.tests.MemoryTestCase):
+ pass
+
+
+def setup_module(module):
+ lsst.utils.tests.init()
+
+
+if __name__ == "__main__":
+ lsst.utils.tests.init()
+ main()