From 15849db9edc003f7eeda756022518af8363d6f6e Mon Sep 17 00:00:00 2001 From: jrmullaney Date: Wed, 12 Jun 2024 03:45:14 -0700 Subject: [PATCH 1/2] Add summary metric creation to calibrateImage.py --- python/lsst/pipe/tasks/calibrateImage.py | 46 +++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/python/lsst/pipe/tasks/calibrateImage.py b/python/lsst/pipe/tasks/calibrateImage.py index c2ccb69c4..248781721 100644 --- a/python/lsst/pipe/tasks/calibrateImage.py +++ b/python/lsst/pipe/tasks/calibrateImage.py @@ -41,6 +41,23 @@ from . import measurePsf, repair, photoCal, computeExposureSummaryStats, snapCombine +class _EmptyTargetTask(pipeBase.PipelineTask): + """ + This is a placeholder target for create_summary_metrics and must be retargeted at + runtime. create_summary_metrics should target an analysis tool task, but that + would, at the time of writing, result in a circular import. + + As a result, this class should not be used for anything else. + """ + ConfigClass = pipeBase.PipelineTaskConfig + + def __init__(self, **kwargs) -> None: + raise NotImplementedError( + "do_create_summary_metrics is set to True, in which " + "case create_summary_metrics must be retargeted." + ) + + class CalibrateImageConnections(pipeBase.PipelineTaskConnections, dimensions=("instrument", "visit", "detector")): @@ -136,6 +153,11 @@ class CalibrateImageConnections(pipeBase.PipelineTaskConnections, storageClass="Catalog", dimensions=("instrument", "visit", "detector"), ) + summary_metrics = connectionTypes.Output( + name="initial_summary_metrics", + storageClass="MetricMeasurementBundle", + dimensions=("instrument", "visit", "detector"), + ) def __init__(self, *, config=None): super().__init__(config=config) @@ -147,6 +169,8 @@ def __init__(self, *, config=None): del self.astrometry_matches if config.optional_outputs is None or "photometry_matches" not in config.optional_outputs: del self.photometry_matches + if not config.do_create_summary_metrics: + del self.summary_metrics class CalibrateImageConfig(pipeBase.PipelineTaskConfig, pipelineConnections=CalibrateImageConnections): @@ -259,6 +283,16 @@ class CalibrateImageConfig(pipeBase.PipelineTaskConfig, pipelineConnections=Cali target=computeExposureSummaryStats.ComputeExposureSummaryStatsTask, doc="Task to to compute summary statistics on the calibrated exposure." ) + do_create_summary_metrics = pexConfig.Field( + dtype=bool, + default=False, + doc="Run the subtask to create summary metrics, and then write those metrics." + ) + create_summary_metrics = pexConfig.ConfigurableField( + target=_EmptyTargetTask, + doc="Subtask to create metrics from the summary stats. This must be retargeted, likely to an" + "analysis_tools task such as CalexpSummaryMetrics." + ) def setDefaults(self): super().setDefaults() @@ -411,6 +445,9 @@ def __init__(self, initial_stars_schema=None, **kwargs): self.makeSubtask("compute_summary_stats") + if self.config.do_create_summary_metrics: + self.makeSubtask("create_summary_metrics") + # For the butler to persist it. self.initial_stars_schema = afwTable.SourceCatalog(initial_stars_schema) @@ -543,7 +580,9 @@ def run(self, *, exposures, id_generator=None, result=None): result.photometry_matches = lsst.meas.astrom.denormalizeMatches(photometry_matches, photometry_meta) - self._summarize(result.exposure, result.stars_footprints, result.background) + result.summary_metrics = self._summarize(result.exposure, + result.stars_footprints, + result.background) return result @@ -856,3 +895,8 @@ def _summarize(self, exposure, stars, background): # applied calibration). This needs to be checked. summary = self.compute_summary_stats.run(exposure, stars, background) exposure.info.setSummaryStats(summary) + + summaryMetrics = None + if self.config.do_create_summary_metrics: + summaryMetrics = self.create_summary_metrics.run(data=summary.__dict__).metrics + return summaryMetrics From 68bbba53bbfcb447b02fddcf3f2963ffe938d990 Mon Sep 17 00:00:00 2001 From: jrmullaney Date: Mon, 17 Jun 2024 04:49:50 -0700 Subject: [PATCH 2/2] Add test for summary metrics in calibrateImage.py --- tests/test_calibrateImage.py | 37 ++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/test_calibrateImage.py b/tests/test_calibrateImage.py index f4cf37795..9117482ef 100644 --- a/tests/test_calibrateImage.py +++ b/tests/test_calibrateImage.py @@ -41,6 +41,9 @@ import lsst.meas.base.tests import lsst.pipe.base.testUtils from lsst.pipe.tasks.calibrateImage import CalibrateImageTask +from lsst.analysis.tools.tasks import CalexpSummaryAnalysisTask +from lsst.analysis.tools.atools import CalexpSummaryMetrics +from lsst.analysis.tools.interfaces import MetricMeasurementBundle import lsst.utils.tests @@ -138,6 +141,10 @@ def setUp(self): # Something about this test dataset prefers a larger threshold here. self.config.star_selector["science"].unresolved.maximum = 0.2 + self.config.do_create_summary_metrics = True + self.config.create_summary_metrics.retarget(CalexpSummaryAnalysisTask) + self.config.create_summary_metrics.atools.initial_pvi_metrics = CalexpSummaryMetrics + def _check_run(self, calibrate, result): """Test the result of CalibrateImage.run(). @@ -162,6 +169,27 @@ def _check_run(self, calibrate, result): self.assertFloatsAlmostEqual(summary.ra, self.sky_center.getRa().asDegrees(), rtol=1e-7) self.assertFloatsAlmostEqual(summary.dec, self.sky_center.getDec().asDegrees(), rtol=1e-7) + # Check that the summary metrics are reasonable. + metrics = result.summary_metrics + if self.config.do_create_summary_metrics: + self.assertIsInstance(metrics, MetricMeasurementBundle) + + for metric in metrics['initial_pvi_metrics']: + if metric.metric_name.metric == 'psfSigma': + self.assertFloatsAlmostEqual(metric.quantity.value, 2.0, rtol=1e-2) + if metric.metric_name.metric == 'ra': + self.assertFloatsAlmostEqual( + metric.quantity.value, + self.sky_center.getRa().asDegrees(), + rtol=1e-7) + if metric.metric_name.metric == 'dec': + self.assertFloatsAlmostEqual( + metric.quantity.value, + self.sky_center.getDec().asDegrees(), + rtol=1e-7) + else: + self.assertIsNone(metrics) + # Should have finite sky coordinates in the afw and astropy catalogs. self.assertTrue(np.isfinite(result.stars_footprints["coord_ra"]).all()) self.assertTrue(np.isfinite(result.stars["coord_ra"]).all()) @@ -212,6 +240,7 @@ def test_run_no_optionals(self): struct, as appropriate. """ self.config.optional_outputs = None + self.config.do_create_summary_metrics = False calibrate = CalibrateImageTask(config=self.config) calibrate.astrometry.setRefObjLoader(self.ref_loader) calibrate.photometry.match.setRefObjLoader(self.ref_loader) @@ -480,6 +509,10 @@ def setUp(self): "initial_photometry_match_detector", {"instrument", "visit", "detector"}, "Catalog") + butlerTests.addDatasetType(self.repo, + "initial_summary_metrics", + {"instrument", "visit", "detector"}, + "MetricMeasurementBundle") # dataIds self.exposure0_id = self.repo.registry.expandDataId( @@ -520,6 +553,7 @@ def test_runQuantum(self): "initial_pvi_background": self.visit_id, "astrometry_matches": self.visit_id, "photometry_matches": self.visit_id, + "summary_metrics": self.visit_id, }) mock_run = lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum) @@ -549,6 +583,7 @@ def test_runQuantum_2_snaps(self): "initial_pvi_background": self.visit_id, "astrometry_matches": self.visit_id, "photometry_matches": self.visit_id, + "summary_metrics": self.visit_id, }) mock_run = lsst.pipe.base.testUtils.runTestQuantum(task, self.butler, quantum) @@ -575,6 +610,7 @@ def test_runQuantum_no_optional_outputs(self): "initial_pvi_background": self.visit_id, "astrometry_matches": self.visit_id, "photometry_matches": self.visit_id, + "summary_metrics": self.visit_id, } # Check that we can turn off one output at a time. @@ -621,6 +657,7 @@ def test_runQuantum_exception(self): "initial_pvi_background": self.visit_id, "astrometry_matches": self.visit_id, "photometry_matches": self.visit_id, + "summary_metrics": self.visit_id, }) # A generic exception should raise directly.