From f28cc10793e3dd15f28db8728b935cbe5f7a0032 Mon Sep 17 00:00:00 2001 From: James Mullaney Date: Fri, 17 Apr 2026 01:04:11 +0100 Subject: [PATCH 1/6] 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 f73387326fbc3b642414dc24e25f6b35accd9584 Mon Sep 17 00:00:00 2001 From: jrmullaney Date: Fri, 17 Apr 2026 13:41:56 +0100 Subject: [PATCH 2/6] 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 fcddee9892cf0ded0c0d474291ad8ccb6c63ed5d Mon Sep 17 00:00:00 2001 From: jrmullaney Date: Fri, 17 Apr 2026 14:01:34 +0100 Subject: [PATCH 3/6] 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 57c89781b9f2c57125b6fe23f996742382f366e3 Mon Sep 17 00:00:00 2001 From: jrmullaney Date: Fri, 17 Apr 2026 14:01:49 +0100 Subject: [PATCH 4/6] 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 32cc4348f50aba2ac773ffbdbd3ee7cacec53ce1 Mon Sep 17 00:00:00 2001 From: James Mullaney Date: Tue, 23 Jun 2026 19:03:29 +0100 Subject: [PATCH 5/6] 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 648e902806716a082f0318f1385d33f472946915 Mon Sep 17 00:00:00 2001 From: James Mullaney Date: Fri, 26 Jun 2026 08:39:05 +0100 Subject: [PATCH 6/6] 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: