Skip to content
82 changes: 82 additions & 0 deletions config/aggregateCalibrateImageMetadata.py
Original file line number Diff line number Diff line change
@@ -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,
)
75 changes: 14 additions & 61 deletions pipelines/visitQualityCore.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,66 +38,19 @@ tasks:
config:
atools.calexpSummaryMetrics: CalexpSummaryMetrics
python: from lsst.analysis.tools.atools import *
analyzeCalibrateImageMetadata:
class: lsst.analysis.tools.tasks.TaskMetadataAnalysisTask
aggregateCalibrateImageMetadata:
class: lsst.analysis.tools.tasks.AggregatedTaskMetadataAnalysisTask
config:
connections.inputName: calibrateImage_metadata
connections.outputName: calibrateImage_metadata # Will be appended with "_metrics"
connections.storageClass: TaskMetadata
raiseNoWorkFoundOnIncompleteMetadata: true
inputDimensions: ["instrument", "visit", "detector"]
atools.calibrateImageMetadataMetrics: TaskMetadataMetricTool
atools.calibrateImageMetadataMetrics.taskName: calibrateImage
atools.calibrateImageMetadataMetrics.metrics:
initial_psf_positive_footprint_count: ct
initial_psf_negative_footprint_count: ct
initial_psf_positive_peak_count: ct
initial_psf_negative_peak_count: ct
simple_psf_positive_footprint_count: ct
simple_psf_negative_footprint_count: ct
simple_psf_positive_peak_count: ct
simple_psf_negative_peak_count: ct
bad_mask_fraction: ""
cr_mask_fraction: ""
crosstalk_mask_fraction: ""
detected_mask_fraction: ""
detected_negative_mask_fraction: ""
edge_mask_fraction: ""
intrp_mask_fraction: ""
no_data_mask_fraction: ""
sat_mask_fraction: ""
suspect_mask_fraction: ""
unmaskednan_mask_fraction: ""
numAvailStars: ct
numGoodStars: ct
sky_footprint_count: ct
post_deblend_source_count: ct
star_count: ct
saturated_source_count: ct
bad_source_count: ct
cosmic_ray_count: ct
matched_psf_star_count: ct
final_psf_sigma: pixel
astrometry_matches_count: ct
photometry_matches_count: ct
bg_subtracted_skyPixel_instFlux_median: adu
bg_subtracted_skyPixel_instFlux_stdev: adu
bg_subtracted_skySource_flux_median: nJy
bg_subtracted_skySource_flux_stdev: nJy
adaptive_threshold_value: ""
initial_to_final_wcs: arcsec
astrom_offset_mean: arcsec
astrom_offset_std: arcsec
astrom_offset_median: arcsec
failed_deblend_source_count: ct
atools.calibrateImageMetadataMetrics.subTaskNames:
numAvailStars: psf_measure_psf
numGoodStars: psf_measure_psf
sky_footprint_count: star_sky_sources
cosmic_ray_count: psf_repair
atools.calibrateImageMetadataMetrics.newNames:
numAvailStars: psf_available_star_count
numGoodStars: psf_good_star_count
adaptive_threshold_value: final_adaptive_threshold_value
python: |
from lsst.analysis.tools.atools import TaskMetadataMetricTool
connections.outputName: calibrateImage_metadata_aggregate
inputDataDimensions: ["instrument", "visit", "detector"]
outputDimensions: ["instrument", "visit"]
file: $ANALYSIS_TOOLS_DIR/config/aggregateCalibrateImageMetadata.py
makeMetricTableAggregateCalibrateImageMetadata:
class: lsst.analysis.tools.tasks.MakeMetricTableTask
config:
connections.metricBundleName: "calibrateImage_metadata_aggregate_metrics"
connections.outputTableName: "calibrateImage_metadata_aggregate_metrics_table"
inputDataDimensions: ["instrument", "visit"]
outputTableDimensions: ["instrument"]
dataIdFieldsToIncludeAsColumns: ["visit", "band"]
65 changes: 63 additions & 2 deletions pipelines/visitQualityExtended.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -13,3 +11,66 @@ tasks:
atools.deltaSkyCorr: DeltaSkyCorrXYPlot
python: |
from lsst.analysis.tools.atools import *
analyzeCalibrateImageMetadata:
class: lsst.analysis.tools.tasks.TaskMetadataAnalysisTask
config:
connections.inputName: calibrateImage_metadata
connections.outputName: calibrateImage_metadata # Will be appended with "_metrics"
connections.storageClass: TaskMetadata
raiseNoWorkFoundOnIncompleteMetadata: true
inputDimensions: ["instrument", "visit", "detector"]
atools.calibrateImageMetadataMetrics: TaskMetadataMetricTool
atools.calibrateImageMetadataMetrics.taskName: calibrateImage
atools.calibrateImageMetadataMetrics.metrics:
initial_psf_positive_footprint_count: ct
initial_psf_negative_footprint_count: ct
initial_psf_positive_peak_count: ct
initial_psf_negative_peak_count: ct
simple_psf_positive_footprint_count: ct
simple_psf_negative_footprint_count: ct
simple_psf_positive_peak_count: ct
simple_psf_negative_peak_count: ct
bad_mask_fraction: ""
cr_mask_fraction: ""
crosstalk_mask_fraction: ""
detected_mask_fraction: ""
detected_negative_mask_fraction: ""
edge_mask_fraction: ""
intrp_mask_fraction: ""
no_data_mask_fraction: ""
sat_mask_fraction: ""
suspect_mask_fraction: ""
unmaskednan_mask_fraction: ""
numAvailStars: ct
numGoodStars: ct
sky_footprint_count: ct
post_deblend_source_count: ct
star_count: ct
saturated_source_count: ct
bad_source_count: ct
cosmic_ray_count: ct
matched_psf_star_count: ct
final_psf_sigma: pixel
astrometry_matches_count: ct
photometry_matches_count: ct
bg_subtracted_skyPixel_instFlux_median: adu
bg_subtracted_skyPixel_instFlux_stdev: adu
bg_subtracted_skySource_flux_median: nJy
bg_subtracted_skySource_flux_stdev: nJy
adaptive_threshold_value: ""
initial_to_final_wcs: arcsec
astrom_offset_mean: arcsec
astrom_offset_std: arcsec
astrom_offset_median: arcsec
failed_deblend_source_count: ct
atools.calibrateImageMetadataMetrics.subTaskNames:
numAvailStars: psf_measure_psf
numGoodStars: psf_measure_psf
sky_footprint_count: star_sky_sources
cosmic_ray_count: psf_repair
atools.calibrateImageMetadataMetrics.newNames:
numAvailStars: psf_available_star_count
numGoodStars: psf_good_star_count
adaptive_threshold_value: final_adaptive_threshold_value
python: |
from lsst.analysis.tools.atools import TaskMetadataMetricTool
2 changes: 1 addition & 1 deletion python/lsst/analysis/tools/actions/plot/matrixPlot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
55 changes: 55 additions & 0 deletions python/lsst/analysis/tools/atools/metadataMetrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from __future__ import annotations

__all__ = (
"AggregatedTaskMetadataMetricTool",
"DatasetMetadataMetricTool",
"TaskMetadataMetricTool",
)
Expand Down Expand Up @@ -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())
2 changes: 1 addition & 1 deletion python/lsst/analysis/tools/interfaces/_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions python/lsst/analysis/tools/interfaces/_analysisTools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Expand Down
10 changes: 10 additions & 0 deletions python/lsst/analysis/tools/interfaces/_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -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):
Expand Down
Loading
Loading