From 8fa2165d7c8180c3191e39056757bfdba3a46130 Mon Sep 17 00:00:00 2001 From: James Mullaney Date: Fri, 17 Apr 2026 01:04:11 +0100 Subject: [PATCH 01/14] Add metadataMetric aggregator tool --- .../analysis/tools/atools/metadataMetrics.py | 55 +++++++++ .../analysis/tools/tasks/metadataAnalysis.py | 111 ++++++++++++++++++ 2 files changed, 166 insertions(+) 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/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) From a3f368f27049dd0989d732ddc16bd68226572b9b Mon Sep 17 00:00:00 2001 From: jrmullaney Date: Fri, 17 Apr 2026 13:41:56 +0100 Subject: [PATCH 02/14] Add unit tests for the MetadataMetrics aggregator --- tests/test_aggregatedMetadata.py | 300 +++++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 tests/test_aggregatedMetadata.py diff --git a/tests/test_aggregatedMetadata.py b/tests/test_aggregatedMetadata.py new file mode 100644 index 000000000..96efae5ae --- /dev/null +++ b/tests/test_aggregatedMetadata.py @@ -0,0 +1,300 @@ +# 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 = "myTask" + 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 = "myTask_metadata" + config.connections.outputName = "myTask_metadata_agg" + config.atools.myTool = AggregatedTaskMetadataMetricTool + config.atools.myTool.taskName = "myTask" + config.atools.myTool.metrics = metrics + if subtask_names is not None: + config.atools.myTool.subTaskNames = subtask_names + + action = MedianAction() + action.vectorKey = next(iter(metrics)) + config.atools.myTool.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 = "myTask" + 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, "myTask") + + def testPartialInputsAccepted(self): + """Inputs missing a metric in some (but not all) detectors are fine.""" + task = _make_task({"nStars": "ct"}) + # One input lacks nStars — it should be silently skipped. + handles = _make_handles( + [ + {"myTask": {"nStars": 10.0}}, + {"myTask": {}}, + {"myTask": {"nStars": 30.0}}, + ] + ) + data = task._collectData(handles, "myTask") + self.assertEqual(len(data["nStars"]), 3) + self.assertIn(10.0, data["nStars"]) + self.assertIn(30.0, data["nStars"]) + # Verify values are collected as-is, not transformed. + self.assertAlmostEqual(min(data["nStars"]), 10.0) + self.assertAlmostEqual(max(data["nStars"]), 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( + [ + {"myTask:repair": {"cosmicRayCount": 5.0}}, + {"myTask:repair": {"cosmicRayCount": 8.0}}, + ] + ) + data = task._collectData(handles, "myTask") + 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( + [ + {"myTask": {"nStars": 10.0}}, + {"myTask": {"nStars": 20.0}}, + ] + ) + with self.assertRaises(UpstreamFailureNoWorkFound): + task._collectData(handles, "myTask") + + +class MyMemoryTestCase(lsst.utils.tests.MemoryTestCase): + pass + + +def setup_module(module): + lsst.utils.tests.init() + + +if __name__ == "__main__": + lsst.utils.tests.init() + main() From ce589dc75453e03996d32dda1c89b29216a09d1f Mon Sep 17 00:00:00 2001 From: jrmullaney Date: Fri, 17 Apr 2026 14:01:34 +0100 Subject: [PATCH 03/14] Add aggregates to visitQualityCore --- config/aggregateCalibrateImageMetadata.py | 82 +++++++++++++++++++++++ pipelines/visitQualityCore.yaml | 67 ++---------------- 2 files changed, 88 insertions(+), 61 deletions(-) create mode 100644 config/aggregateCalibrateImageMetadata.py 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..cbc0857ef 100644 --- a/pipelines/visitQualityCore.yaml +++ b/pipelines/visitQualityCore.yaml @@ -38,66 +38,11 @@ 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 From 66236581be19a542e1d334151a71def05cad6245 Mon Sep 17 00:00:00 2001 From: jrmullaney Date: Fri, 17 Apr 2026 14:01:49 +0100 Subject: [PATCH 04/14] Move calibrateImage_metadata metrics With aggregates now calculated in visitQualityCore, move the visit-detector level calibrateImage_metadata metrics to visitQualityExtended for use when one-off visits need inspecting. --- pipelines/visitQualityExtended.yaml | 63 +++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/pipelines/visitQualityExtended.yaml b/pipelines/visitQualityExtended.yaml index 0fdaca864..ebf39ad1f 100644 --- a/pipelines/visitQualityExtended.yaml +++ b/pipelines/visitQualityExtended.yaml @@ -13,3 +13,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 From 56015a050c264b4fe6f4f2f07db486f9719c3ee8 Mon Sep 17 00:00:00 2001 From: James Mullaney Date: Tue, 23 Jun 2026 19:03:29 +0100 Subject: [PATCH 05/14] Add aggregated metadata metric table --- pipelines/visitQualityCore.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pipelines/visitQualityCore.yaml b/pipelines/visitQualityCore.yaml index cbc0857ef..ea3aee96e 100644 --- a/pipelines/visitQualityCore.yaml +++ b/pipelines/visitQualityCore.yaml @@ -46,3 +46,11 @@ tasks: 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_metricsTable" + inputDataDimensions: ["instrument", "visit"] + outputTableDimensions: ["instrument"] + dataIdFieldsToIncludeAsColumns: ["visit", "band"] From ad1039987d8bc94de142ee340ac5dd1bd45b05c7 Mon Sep 17 00:00:00 2001 From: James Mullaney Date: Fri, 26 Jun 2026 08:39:05 +0100 Subject: [PATCH 06/14] Remove catalogMatchVisit from visitQualExtended Drive-by: catalogMatchVisit appears in both visitQualityExtended and visitQualityCore. This can produce a task-name-clash if both yamls are imported. Rather than rename one of them, I'm removing the instance in visitQualityExtended, as visitQualityExtended should always follow a run of visitQualtyCore, either in the same run or as an afterburn. --- pipelines/visitQualityExtended.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/pipelines/visitQualityExtended.yaml b/pipelines/visitQualityExtended.yaml index ebf39ad1f..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: From d90fd80398f21672f4c8ecc1449b47f568fbd85f Mon Sep 17 00:00:00 2001 From: jrmullaney Date: Fri, 17 Apr 2026 13:41:56 +0100 Subject: [PATCH 07/14] Add unit tests for the MetadataMetrics aggregator --- tests/test_aggregatedMetadata.py | 50 +++++++++++++++----------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/tests/test_aggregatedMetadata.py b/tests/test_aggregatedMetadata.py index 96efae5ae..d6f10228e 100644 --- a/tests/test_aggregatedMetadata.py +++ b/tests/test_aggregatedMetadata.py @@ -52,7 +52,7 @@ def _make_tool(metrics, aggregation_units=None, subtask_names=None): A finalized tool. """ tool = AggregatedTaskMetadataMetricTool() - tool.taskName = "myTask" + tool.taskName = "test" tool.metrics = metrics if subtask_names is not None: tool.subTaskNames = subtask_names @@ -87,17 +87,17 @@ def _make_task(metrics, subtask_names=None): A configured task. """ config = AggregatedTaskMetadataAnalysisConfig() - config.connections.inputName = "myTask_metadata" - config.connections.outputName = "myTask_metadata_agg" - config.atools.myTool = AggregatedTaskMetadataMetricTool - config.atools.myTool.taskName = "myTask" - config.atools.myTool.metrics = metrics + 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.myTool.subTaskNames = subtask_names + config.atools.testTool.subTaskNames = subtask_names action = MedianAction() action.vectorKey = next(iter(metrics)) - config.atools.myTool.process.calculateActions.metric_median = action + config.atools.testTool.process.calculateActions.metric_median = action return AggregatedTaskMetadataAnalysisTask(config=config) @@ -179,7 +179,7 @@ def testActionWithUnknownVectorKeyRaisesError(self): that doesn't correspond to any configured metric. """ tool = AggregatedTaskMetadataMetricTool() - tool.taskName = "myTask" + tool.taskName = "test" tool.metrics = {"nStars": "ct"} known_action = MedianAction() @@ -235,26 +235,24 @@ def testEmptyMetadataRaisesNoWorkFound(self): task = _make_task({"nStars": "ct"}) handles = _make_handles([{}, {}, {}]) with self.assertRaises(UpstreamFailureNoWorkFound): - task._collectData(handles, "myTask") + 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 — it should be silently skipped. + # One input lacks nStars — its value should be NaN. handles = _make_handles( [ - {"myTask": {"nStars": 10.0}}, - {"myTask": {}}, - {"myTask": {"nStars": 30.0}}, + {"test": {"nStars": 10.0}}, + {"test": {}}, + {"test": {"nStars": 30.0}}, ] ) - data = task._collectData(handles, "myTask") + data = task._collectData(handles, "test") self.assertEqual(len(data["nStars"]), 3) - self.assertIn(10.0, data["nStars"]) - self.assertIn(30.0, data["nStars"]) - # Verify values are collected as-is, not transformed. - self.assertAlmostEqual(min(data["nStars"]), 10.0) - self.assertAlmostEqual(max(data["nStars"]), 30.0) + 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.""" @@ -264,11 +262,11 @@ def testSubTaskNamesRespected(self): ) handles = _make_handles( [ - {"myTask:repair": {"cosmicRayCount": 5.0}}, - {"myTask:repair": {"cosmicRayCount": 8.0}}, + {"test:repair": {"cosmicRayCount": 5.0}}, + {"test:repair": {"cosmicRayCount": 8.0}}, ] ) - data = task._collectData(handles, "myTask") + data = task._collectData(handles, "test") self.assertIn("cosmicRayCount", data) self.assertEqual(len(data["cosmicRayCount"]), 2) @@ -279,12 +277,12 @@ def testTypoInMetricNameRaisesNoWorkFound(self): task = _make_task({"nSatrs": "ct"}) # typo: nSatrs instead of nStars handles = _make_handles( [ - {"myTask": {"nStars": 10.0}}, - {"myTask": {"nStars": 20.0}}, + {"test": {"nStars": 10.0}}, + {"test": {"nStars": 20.0}}, ] ) with self.assertRaises(UpstreamFailureNoWorkFound): - task._collectData(handles, "myTask") + task._collectData(handles, "test") class MyMemoryTestCase(lsst.utils.tests.MemoryTestCase): From e4e0186be08bba5d74817efc3f6750d7022aa87e Mon Sep 17 00:00:00 2001 From: James Mullaney Date: Tue, 23 Jun 2026 19:03:29 +0100 Subject: [PATCH 08/14] Add aggregated metadata metric table --- pipelines/visitQualityCore.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pipelines/visitQualityCore.yaml b/pipelines/visitQualityCore.yaml index ea3aee96e..157618d4f 100644 --- a/pipelines/visitQualityCore.yaml +++ b/pipelines/visitQualityCore.yaml @@ -50,7 +50,7 @@ tasks: class: lsst.analysis.tools.tasks.MakeMetricTableTask config: connections.metricBundleName: "calibrateImage_metadata_aggregate_metrics" - connections.outputTableName: "calibrateImage_metadata_aggregate_metricsTable" + connections.outputTableName: "calibrateImage_metadata_aggregate_metrics_table" inputDataDimensions: ["instrument", "visit"] outputTableDimensions: ["instrument"] dataIdFieldsToIncludeAsColumns: ["visit", "band"] From 47c978001e526cab3d461c498806c12ed1dd316a Mon Sep 17 00:00:00 2001 From: James Mullaney Date: Wed, 19 Aug 2026 16:32:47 +0100 Subject: [PATCH 09/14] Add plot generation switch to tools --- .../analysis/tools/actions/plot/matrixPlot.py | 2 +- .../analysis/tools/interfaces/_actions.py | 2 +- .../tools/interfaces/_analysisTools.py | 14 +++ tests/test_producePlots.py | 99 +++++++++++++++++++ 4 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 tests/test_producePlots.py 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/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/tests/test_producePlots.py b/tests/test_producePlots.py new file mode 100644 index 000000000..c4ca2e40f --- /dev/null +++ b/tests/test_producePlots.py @@ -0,0 +1,99 @@ +# 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.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.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 MyMemoryTestCase(lsst.utils.tests.MemoryTestCase): + pass + + +def setup_module(module): + lsst.utils.tests.init() + + +if __name__ == "__main__": + lsst.utils.tests.init() + main() From 0a7ae1867907c86b8090a32ec3f1788c59fa9dad Mon Sep 17 00:00:00 2001 From: James Mullaney Date: Wed, 19 Aug 2026 16:40:48 +0100 Subject: [PATCH 10/14] Add plot generation switch to tasks This addition allows plots to be turned off at the task level so that none of the atools generate any of their plots. --- .../lsst/analysis/tools/interfaces/_task.py | 10 +++ tests/test_producePlots.py | 61 +++++++++++++++++++ 2 files changed, 71 insertions(+) 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/tests/test_producePlots.py b/tests/test_producePlots.py index c4ca2e40f..74c57168a 100644 --- a/tests/test_producePlots.py +++ b/tests/test_producePlots.py @@ -25,11 +25,14 @@ 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 @@ -86,6 +89,64 @@ def testGetOutputNamesEmptyWhenDisabled(self): 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 From 2ed377f467f3e58ca59d4567b4d3f888af0ebf54 Mon Sep 17 00:00:00 2001 From: jrmullaney Date: Tue, 30 Jun 2026 17:24:25 +0100 Subject: [PATCH 11/14] Add atool to aggregate VisitSummary stats --- .../analysis/tools/atools/calexpMetrics.py | 116 +++++++++++------- 1 file changed, 75 insertions(+), 41 deletions(-) diff --git a/python/lsst/analysis/tools/atools/calexpMetrics.py b/python/lsst/analysis/tools/atools/calexpMetrics.py index 517c5ea7f..c2c55ac32 100644 --- a/python/lsst/analysis/tools/atools/calexpMetrics.py +++ b/python/lsst/analysis/tools/atools/calexpMetrics.py @@ -23,15 +23,63 @@ __all__ = ( "CalexpSummaryMetrics", "CalexpMetricHists", + "AggregateCalexpSummaryStats", ) from lsst.pex.config import DictField from ..actions.plot import HistPanel, HistPlot +from ..actions.scalar import ( + MaxAction, + MedianAction, + MinAction, + SigmaMadAction, +) from ..actions.vector import BandSelector, 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): """ Class to load statistics from the summary stats contained with a calexp's @@ -40,47 +88,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 +118,29 @@ 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 From 007257aba76ec78b4220de2b89246ac2e905fc6b Mon Sep 17 00:00:00 2001 From: jrmullaney Date: Tue, 30 Jun 2026 17:54:17 +0100 Subject: [PATCH 12/14] Move CornerToCornerSep atool The visit summary stats aggregator in calexpMetrics now calculates aggregate pixel scales, so only the corner-to-corner separations are needed from the astrometryMetrics atool. Since the astrometryMetrics atool also aggregates over values derived from ExposureSummaryStats I think calexpMetrics is a better home now. --- python/lsst/analysis/tools/atools/__init__.py | 1 - .../tools/atools/astrometryMetrics.py | 89 ------------------- .../analysis/tools/atools/calexpMetrics.py | 63 ++++++++++++- 3 files changed, 62 insertions(+), 91 deletions(-) delete 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 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 c2c55ac32..8bbf076c2 100644 --- a/python/lsst/analysis/tools/atools/calexpMetrics.py +++ b/python/lsst/analysis/tools/atools/calexpMetrics.py @@ -24,6 +24,7 @@ "CalexpSummaryMetrics", "CalexpMetricHists", "AggregateCalexpSummaryStats", + "AggregateCornerSeparations", ) from lsst.pex.config import DictField @@ -35,7 +36,12 @@ MinAction, SigmaMadAction, ) -from ..actions.vector import BandSelector, LoadVector +from ..actions.vector import ( + AngularSeparation, + BandSelector, + DivideVector, + LoadVector, +) from ..interfaces import AnalysisTool @@ -144,3 +150,58 @@ def finalize(self): 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": "", + } + From 99d512418beefcc9f5467546af2fe9661784a938 Mon Sep 17 00:00:00 2001 From: jrmullaney Date: Tue, 30 Jun 2026 18:04:43 +0100 Subject: [PATCH 13/14] Add visitSummary aggregator to visitQualityCore --- pipelines/visitQualityCore.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/pipelines/visitQualityCore.yaml b/pipelines/visitQualityCore.yaml index 157618d4f..f5cbfcb6d 100644 --- a/pipelines/visitQualityCore.yaml +++ b/pipelines/visitQualityCore.yaml @@ -54,3 +54,21 @@ tasks: 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"] From ff7c9663622bffea54d97ce06697ab065bf62792 Mon Sep 17 00:00:00 2001 From: jrmullaney Date: Tue, 30 Jun 2026 10:35:35 -0700 Subject: [PATCH 14/14] Minor linting edits --- .../lsst/analysis/tools/atools/calexpMetrics.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/python/lsst/analysis/tools/atools/calexpMetrics.py b/python/lsst/analysis/tools/atools/calexpMetrics.py index 8bbf076c2..68c50d8fa 100644 --- a/python/lsst/analysis/tools/atools/calexpMetrics.py +++ b/python/lsst/analysis/tools/atools/calexpMetrics.py @@ -37,14 +37,13 @@ SigmaMadAction, ) from ..actions.vector import ( - AngularSeparation, - BandSelector, - DivideVector, + 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 = { @@ -124,7 +123,7 @@ 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): """ @@ -133,14 +132,14 @@ class AggregateCalexpSummaryStats(AnalysisTool): """ _units = _SUMMARY_STATS_UNITS - + aggregators = { "min": MinAction, "max": MaxAction, "median": MedianAction, "sigmaMad": SigmaMadAction, } - + def finalize(self): units = {} for key in self._units.keys(): @@ -157,7 +156,7 @@ class AggregateCornerSeparations(AnalysisTool): Class to calculate aggregate corner-to-corner separations from vectors of on-sky corner coordinates. """ - + def setDefaults(self): super().setDefaults() @@ -204,4 +203,3 @@ def setDefaults(self): "cornerSeparationRatio_median": "", "cornerSeparationRatio_sigmaMad": "", } -