diff --git a/pipelines/coaddQualityCore.yaml b/pipelines/coaddQualityCore.yaml
index 6d8f0098e..50e913b32 100644
--- a/pipelines/coaddQualityCore.yaml
+++ b/pipelines/coaddQualityCore.yaml
@@ -35,3 +35,18 @@ tasks:
class: lsst.analysis.tools.tasks.catalogMatch.CatalogMatchTask
refCatObjectTract:
class: lsst.analysis.tools.tasks.refCatObjectAnalysis.RefCatObjectAnalysisTask
+ plotPropertyMapTract:
+ class: lsst.analysis.tools.tasks.PropertyMapTractAnalysisTask
+ config:
+ connections.outputName: propertyMapTract
+ atools.healSparseMap: PropertyMapTool
+ zoomFactors: [2, 8]
+ python: |
+ from lsst.analysis.tools.atools import *
+ config.properties["exposure_time"] = PropertyMapConfig
+ config.properties["exposure_time"].coaddName= "deep"
+ config.properties["exposure_time"].operations = ["sum"]
+ config.properties["psf_size"] = PropertyMapConfig
+ config.properties["psf_size"].operations = ["weighted_mean"]
+ config.properties["sky_background"] = PropertyMapConfig
+ config.properties["sky_background"].operations = ["weighted_mean"]
diff --git a/python/lsst/analysis/tools/actions/plot/__init__.py b/python/lsst/analysis/tools/actions/plot/__init__.py
index 7c68b4e39..82b80f827 100644
--- a/python/lsst/analysis/tools/actions/plot/__init__.py
+++ b/python/lsst/analysis/tools/actions/plot/__init__.py
@@ -4,6 +4,7 @@
from .focalPlanePlot import *
from .histPlot import *
from .multiVisitCoveragePlot import *
+from .propertyMapPlot import *
from .rhoStatisticsPlot import *
from .scatterplotWithTwoHists import *
from .skyPlot import *
diff --git a/python/lsst/analysis/tools/actions/plot/propertyMapPlot.py b/python/lsst/analysis/tools/actions/plot/propertyMapPlot.py
new file mode 100644
index 000000000..a47a79e60
--- /dev/null
+++ b/python/lsst/analysis/tools/actions/plot/propertyMapPlot.py
@@ -0,0 +1,441 @@
+# 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
+
+__all__ = ("PropertyMapPlot",)
+
+import logging
+from typing import Iterable, Mapping, Optional
+
+import lsst.pex.config as pexConfig
+import matplotlib.patheffects as mpl_path_effects
+import matplotlib.pyplot as plt
+import numpy as np
+import skyproj
+from healsparse.healSparseMap import HealSparseMap
+from lsst.skymap.tractInfo import ExplicitTractInfo
+from matplotlib.figure import Figure
+
+from ...interfaces import KeyedData, PlotAction
+
+_LOG = logging.getLogger(__name__)
+
+
+class PropertyMapPlot(PlotAction):
+ plotName = pexConfig.Field[str](doc="The name for the plotting task.", optional=True)
+
+ def __call__(
+ self, data: KeyedData, tract: ExplicitTractInfo, zoomFactors: pexConfig.listField.List, **kwargs
+ ) -> Mapping[str, Figure]:
+ self._validateInput(data, tract, zoomFactors, **kwargs)
+ return self.makePlot(data, tract, zoomFactors, **kwargs)
+
+ def _validateInput(
+ self, data: KeyedData, tract: ExplicitTractInfo, zoomFactors: pexConfig.listField.List, **kwargs
+ ) -> None:
+ """Validate the input data."""
+
+ if not isinstance(tract, ExplicitTractInfo):
+ raise TypeError(f"Input `tract` type must be {ExplicitTractInfo} not {type(tract)}.")
+
+ isListOfFloats = isinstance(zoomFactors, pexConfig.listField.List) and all(
+ isinstance(zf, float) for zf in zoomFactors
+ )
+ if not (isListOfFloats and len(zoomFactors) == 2):
+ raise TypeError("`zoomFactors` must be a two-element `lsst.pex.config.listField.List` of floats.")
+
+ # Identify any invalid entries in `data`.
+ invalidEntries = {
+ key: type(value) for key, value in data.items() if not isinstance(value, HealSparseMap)
+ }
+
+ # If any invalid entries are found, raise a TypeError with details.
+ if invalidEntries:
+ error_message = ", ".join(
+ f"`{key}` should be {HealSparseMap}, got {type_}" for key, type_ in invalidEntries.items()
+ )
+ raise TypeError(f"Invalid input types found: {error_message}")
+
+ def addPlotInfo(self, fig: Figure, plotInfo: Mapping[str, str], mapName: Mapping[str, str]) -> Figure:
+ """Add useful information to the plot.
+
+ Parameters
+ ----------
+ fig : `matplotlib.figure.Figure`
+ The figure to add the information to.
+
+ plotInfo : `dict`
+ A dictionary of the plot information.
+
+ mapName : `str`
+ The name of the map being plotted.
+
+ Returns
+ -------
+ fig : `matplotlib.figure.Figure`
+ The figure with the information added.
+ """
+
+ dataIdText = f"Tract: {plotInfo['tract']}"
+ mapText = (
+ f", Property: {plotInfo['property']}, "
+ f"Operation: {plotInfo['operation']}, "
+ f"Coadd: {plotInfo['coaddName']}"
+ )
+
+ bandText = f", Band: {plotInfo['band']}"
+ infoText = f"{dataIdText}{bandText}{mapText}"
+ extraText = f"Valid area: {plotInfo['valid_area']:.2f} sq. deg., " \
+ f"nside_coverage: {plotInfo['nside_coverage']}, nside_sparse: {plotInfo['nside_sparse']}"
+
+ fig.text(
+ 0.036,
+ 0.965,
+ f'{plotInfo["plotName"]}: {mapName}',
+ fontsize=20,
+ transform=fig.transFigure,
+ ha="left",
+ va="top",
+ )
+ fig.text(
+ 0.036, 0.94, infoText, fontsize=15, transform=fig.transFigure, alpha=0.6, ha="left", va="top"
+ )
+ fig.text(
+ 0.036, 0.92, extraText, fontsize=15, transform=fig.transFigure, alpha=0.6, ha="left", va="top"
+ )
+ return fig
+
+ def makePlot(
+ self,
+ data: KeyedData,
+ tract: ExplicitTractInfo,
+ zoomFactors: list,
+ plotInfo: Optional[Mapping[str, str]] = None,
+ **kwargs,
+ ) -> Mapping[str, Figure]:
+ """Make the survey property map plot.
+
+ Parameters
+ ----------
+ data : `KeyedData`
+ The HealSparseMap to plot the points from.
+
+ tract: `lsst.skymap.tractInfo.ExplicitTractInfo`
+ The tract info object.
+
+ plotInfo : `dict`
+ A dictionary of information about the data being plotted.
+
+ **kwargs :
+ Additional keyword arguments to pass to the plot.
+
+ Returns
+ -------
+ figDict : `dict` [`~matplotlib.figure.Figure`]
+ The resulting figures.
+ """
+
+ figDict: dict[str, Figure] = {}
+
+ # Plotting customization.
+ colorbarTickLabelSize = 14
+ colorBarAspect = 16
+ nBinsHist = 100
+ rcparams = {
+ "axes.labelsize": 18,
+ "axes.linewidth": 1.8,
+ "xtick.labelsize": 13,
+ "ytick.labelsize": 13,
+ }
+ plt.rcParams.update(rcparams)
+
+ # Muted green, red and blue for labeling the full and zoomed-in maps.
+ zoomColors = ["#265D40", "#8B0000", "#00008B"]
+
+ for mapName, mapData in data.items():
+ fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(16, 16))
+
+ # Reduce whitespace but leave some room at the top for `plotInfo`.
+ plt.subplots_adjust(left=0.064, right=0.96, top=0.855, bottom=0.07, wspace=0.18, hspace=0.24)
+
+ # Get the values for the valid pixels of the full tract.
+ values = mapData[mapData.valid_pixels]
+ goodValues = np.isfinite(values)
+ values = values[goodValues] # As a precaution.
+
+ # Make a concise human-readable label for the plot.
+ plotInfo["coaddName"] = mapName.split("Coadd_")[0]
+ plotInfo["operation"] = self.getLongestSuffixMatch(
+ mapName, ["min", "max", "mean", "weighted_mean", "sum"]
+ )
+ plotInfo["property"] = mapName[
+ len(f"{plotInfo['coaddName']}Coadd_") : -len(plotInfo["operation"])
+ ].strip("_")
+ label = plotInfo["property"].replace("_", " ").title().replace("Psf", "PSF")
+
+ fullExtent = None
+ zoomIdx = []
+ for ax, zoom, zoomFactor, zoomColor in zip(
+ [ax1, ax3, ax4], [True, False, False], [None, *zoomFactors], zoomColors
+ ):
+ extent = self.getZoomedExtent(fullExtent, zoomFactor)
+ sp = skyproj.GnomonicSkyproj(
+ ax=ax,
+ lon_0=tract.ctr_coord.getRa().asDegrees(),
+ lat_0=tract.ctr_coord.getDec().asDegrees(),
+ extent=extent,
+ rcparams=rcparams,
+ )
+ sp.draw_hspmap(mapData, zoom=zoom)
+ sp.set_xlabel("RA (deg)")
+ sp.set_ylabel("Dec (deg)")
+ cbar = sp.draw_colorbar(location="right", fraction=0.15, aspect=colorBarAspect, pad=0)
+ cbar.ax.tick_params(labelsize=colorbarTickLabelSize)
+ cbarText = (
+ "Full Tract" if zoomFactor is None else f"{self.prettyPrintFloat(zoomFactor)}X Zoom"
+ )
+ self.addTextToColorbar(cbar, cbarText, color=zoomColor)
+ if zoomFactor is None:
+ # Save the skyproj object of the full-tract plot.
+ # Will be used in drawing zoom rectangles etc.
+ spf = sp
+ # Get the extent of the full tract.
+ fullExtent = spf.get_extent()
+ else:
+ # Create a rectangle for the zoomed-in region.
+ x0, x1, y0, y1 = extent
+ for c, ls, lw in zip(["white", zoomColor], ["solid", "dashed"], [3.5, 1.5]):
+ spf.draw_polygon(
+ [x0, x0, x1, x1],
+ [y0, y1, y1, y0],
+ facecolor="none",
+ edgecolor=c,
+ linestyle=ls,
+ linewidth=lw,
+ alpha=0.8,
+ )
+ zoomText = spf._ax.text(
+ (x0 + x1) / 2,
+ y0,
+ f"{self.prettyPrintFloat(zoomFactor)}X",
+ color=zoomColor,
+ fontsize=14,
+ fontweight="bold",
+ alpha=0.8,
+ ha="center",
+ va="bottom",
+ )
+ # Add a distinct outline around the text for better
+ # visibility in various backgrounds.
+ zoomText.set_path_effects(
+ [
+ mpl_path_effects.Stroke(linewidth=4, foreground="white", alpha=0.8),
+ mpl_path_effects.Normal(),
+ ]
+ )
+ # Get the indices of pixels in the zoomed-in region.
+ pos = mapData.valid_pixels_pos()
+ # Reversed axes consideration.
+ xmin, xmax = sorted([x0, x1])
+ idx = (pos[0] > xmin) & (pos[0] < xmax) & (pos[1] > y0) & (pos[1] < y1)
+ zoomIdx.append(idx[goodValues])
+
+ # Calculate weights for each bin to ensure that the peak of the
+ # histogram reaches 1.
+ weights = np.ones_like(values) / np.histogram(values, bins=nBinsHist)[0].max()
+
+ # Compute full-tract histogram and get its bins.
+ bins = ax2.hist(
+ values,
+ bins=nBinsHist,
+ label="Full Tract",
+ color=zoomColors[0],
+ weights=weights,
+ alpha=0.7,
+ )[1]
+
+ # Align the histogram (top right panel) with the skyproj plots.
+ pos1 = spf._ax.get_position() # Top left.
+ pos4 = sp._ax.get_position() # Bottom right.
+ cbarWidth = cbar.ax.get_position().height / colorBarAspect
+ # NOTE: cbarWidth != cbar.ax.get_position().width
+ ax2.set_position([pos4.x0, pos1.y0, pos4.width + cbarWidth, pos1.height])
+
+ # `exposure_time` histograms are bar-like, not suited for
+ # overplotting, so we exclude them.
+ if "exposure_time" not in mapName:
+ # Overplot the histograms for the zoomed-in plots.
+ for zoomFactor, zidx, color in zip(zoomFactors, zoomIdx, zoomColors[1:]):
+ weights = np.ones_like(values[zidx]) / np.histogram(values[zidx], bins=bins)[0].max()
+ histLabel = f"{self.prettyPrintFloat(zoomFactor)}X Zoom"
+ ax2.hist(
+ values[zidx],
+ bins=bins,
+ label=histLabel,
+ color=color,
+ weights=weights,
+ alpha=0.6,
+ )
+
+ # Set labels and legend.
+ ax2.set_xlabel(label)
+ ax2.set_ylabel("Normalized Count")
+ legend = ax2.legend(loc="best", frameon=False, fontsize=15)
+ for line, text in zip(legend.legendHandles, legend.get_texts()):
+ text.set_color(line.get_facecolor())
+
+ # Add nside info to `plotInfo`.
+ plotInfo["nside_coverage"] = mapData.nside_coverage
+ plotInfo["nside_sparse"] = mapData.nside_sparse
+ plotInfo["valid_area"] = mapData.get_valid_area()
+
+ # Add useful information to the plot.
+ figDict[mapName] = self.addPlotInfo(fig, plotInfo, mapName)
+
+ _LOG.info(
+ f"Made property map plot for dataset type {mapName}, tract: {plotInfo['tract']}, "
+ f"band: '{plotInfo['band']}'."
+ )
+
+ return figDict
+
+ def getOutputNames(self, config=None) -> Iterable[str]:
+ # Docstring inherited.
+
+ # Names needed for making corresponding output connections for the maps
+ # that are configured to run.
+ outputNames: tuple[str] = ()
+ for propertyName in config.properties:
+ coaddName = config.properties[propertyName].coaddName
+ for operationName in config.properties[propertyName].operations:
+ outputNames += (f"{coaddName}Coadd_{propertyName}_{operationName}",)
+
+ return outputNames
+
+ @staticmethod
+ def getZoomedExtent(fullExtent, n):
+ """Get zoomed extent centered on the original full plot.
+
+ Parameters
+ ----------
+ fullExtent : `tuple` of `float`
+ The full extent defined by (lon_min, lon_max, lat_min, lat_max):
+
+ lon_min : `float`
+ Minimum longitude of the original extent.
+
+ lon_max : `float`
+ Maximum longitude of the original extent.
+
+ lat_min : `float`
+ Minimum latitude of the original extent.
+
+ lat_max : `float`
+ Maximum latitude of the original extent.
+
+ n : `float`, optional
+ Zoom factor; for instance, n=2 means zooming in 2 times at the
+ center. If None, the function returns None.
+
+ Returns
+ -------
+ Results : `tuple` [`float`]
+ New extent as (new_lon_min, new_lon_max, new_lat_min, new_lat_max).
+ """
+ if n is None:
+ return None
+ lon_min, lon_max, lat_min, lat_max = fullExtent
+ lon_center, lat_center = (lon_min + lon_max) / 2, (lat_min + lat_max) / 2
+ half_lon = (lon_max - lon_min) / (2 * n)
+ half_lat = (lat_max - lat_min) / (2 * n)
+ return lon_center - half_lon, lon_center + half_lon, lat_center - half_lat, lat_center + half_lat
+
+ @staticmethod
+ def prettyPrintFloat(n):
+ if n.is_integer():
+ return str(int(n))
+ return str(n)
+
+ @staticmethod
+ def addTextToColorbar(cb, text, orientation="vertical", color="black", fontsize=14, fontweight="bold"):
+ """Helper method to add text inside the horizontal colorbar.
+
+ Parameters
+ ----------
+ cb : `matplotlib.colorbar.Colorbar`
+ The colorbar object.
+
+ text : `str`
+ The text to add.
+
+ fontsize : `int`, optional
+ The fontsize of the text.
+
+ fontweight : `str`, optional
+ The fontweight of the text.
+
+ Returns
+ -------
+ Results : `None`
+ The text is added to the colorbar in place.
+
+ """
+ if color is None:
+ color = "black"
+ vmid = (cb.vmin + cb.vmax) / 2
+ positions = {"vertical": (0.5, vmid), "horizontal": (vmid, 0.5)}
+ cbtext = cb.ax.text(
+ *positions[orientation],
+ text,
+ color=color,
+ va="center",
+ ha="center",
+ fontsize=fontsize,
+ fontweight=fontweight,
+ rotation=orientation,
+ alpha=0.8,
+ )
+ # Add a distinct outline around the text for better visibility in
+ # various backgrounds.
+ cbtext.set_path_effects(
+ [mpl_path_effects.Stroke(linewidth=4, foreground="white", alpha=0.8), mpl_path_effects.Normal()]
+ )
+
+ @staticmethod
+ def getLongestSuffixMatch(s, options):
+ """Find the longest suffix.
+ TODO: polish docstring.
+
+ Parameters
+ ----------
+ s : `str`
+ The string to match.
+
+ options : `list` [`str`]
+ The list of options to match.
+
+ Returns
+ -------
+ Results : `str`
+ The longest string of s that matches any of the options.
+ """
+ return next((opt for opt in sorted(options, key=len, reverse=True) if s.endswith(opt)), None)
diff --git a/python/lsst/analysis/tools/atools/__init__.py b/python/lsst/analysis/tools/atools/__init__.py
index 5541bbfdf..e25b05bfd 100644
--- a/python/lsst/analysis/tools/atools/__init__.py
+++ b/python/lsst/analysis/tools/atools/__init__.py
@@ -13,6 +13,7 @@
from .numericalValidity import *
from .photometricRepeatability import *
from .photometry import *
+from .propertyMap import *
from .seeingMetric import *
from .shapes import *
from .simpleDiaPlot import *
diff --git a/python/lsst/analysis/tools/atools/propertyMap.py b/python/lsst/analysis/tools/atools/propertyMap.py
new file mode 100644
index 000000000..dd446d2cc
--- /dev/null
+++ b/python/lsst/analysis/tools/atools/propertyMap.py
@@ -0,0 +1,63 @@
+# 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
+
+__all__ = ("PropertyMapTool",)
+
+from typing import Any, Dict, Iterable, MutableMapping
+
+from healsparse.healSparseMap import HealSparseMap
+from lsst.pex.config import Field
+
+from ..actions.plot.propertyMapPlot import PropertyMapPlot
+from ..interfaces import AnalysisAction, AnalysisTool
+
+
+class LoadHealSparseMap(AnalysisAction):
+ """Load a selection of HealSparseMaps configured for plotting."""
+
+ mapsKey = Field[str](
+ doc="The key used to access the dictionary of requested HealSparseMap objects to be loaded."
+ )
+
+ def getInputSchema(self) -> Iterable[tuple[str, Dict]]:
+ return [(self.mapsKey, Dict[str, HealSparseMap])]
+
+ def __call__(
+ self, data: MutableMapping[str, Dict[str, HealSparseMap]], **kwds: Any
+ ) -> Dict[str, HealSparseMap]:
+ return data[self.mapsKey]
+
+
+class PropertyMapTool(AnalysisTool):
+ """An `AnalysisTool` for plotting property maps."""
+
+ # Make the getOutputNames() method in the plot action config-aware.
+ dynamicOutputNames: bool = True
+
+ # Do not iterate over multiple bands in a parameterized manner.
+ parameterizedBand: bool = False
+
+ def setDefaults(self):
+ super().setDefaults()
+ self.process.buildActions.data = LoadHealSparseMap()
+ self.process.buildActions.data.mapsKey = "maps"
+ self.produce.plot = PropertyMapPlot()
diff --git a/python/lsst/analysis/tools/tasks/__init__.py b/python/lsst/analysis/tools/tasks/__init__.py
index c9e85dbd2..06cf75209 100644
--- a/python/lsst/analysis/tools/tasks/__init__.py
+++ b/python/lsst/analysis/tools/tasks/__init__.py
@@ -6,4 +6,5 @@
from .diffMatchedAnalysis import *
from .objectTableSurveyAnalysis import *
from .objectTableTractAnalysis import *
+from .propertyMapTractAnalysis import *
from .sourceTableVisitAnalysis import *
diff --git a/python/lsst/analysis/tools/tasks/propertyMapTractAnalysis.py b/python/lsst/analysis/tools/tasks/propertyMapTractAnalysis.py
new file mode 100644
index 000000000..5098a3a2b
--- /dev/null
+++ b/python/lsst/analysis/tools/tasks/propertyMapTractAnalysis.py
@@ -0,0 +1,179 @@
+# 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
+
+__all__ = [
+ "PropertyMapTractAnalysisConfig",
+ "PropertyMapTractAnalysisTask",
+]
+
+from typing import Mapping
+
+from lsst.daf.butler import DataCoordinate
+from lsst.pex.config import Config, ConfigDictField, Field, ListField
+from lsst.pipe.base import ButlerQuantumContext, InputQuantizedConnection, OutputQuantizedConnection
+from lsst.pipe.base import connectionTypes as ct
+from lsst.skymap import BaseSkyMap
+
+from ..interfaces import AnalysisBaseConfig, AnalysisBaseConnections, AnalysisPipelineTask
+
+
+class PropertyMapConfig(Config):
+ coaddName = Field(
+ dtype=str,
+ doc="Coadd name: typically one of deep or goodSeeing.",
+ default="deep",
+ )
+ operations = ListField(
+ dtype=str,
+ doc="List of operations whose corresponding maps should be retrieved.",
+ default=["min", "max", "mean", "weighted_mean", "sum"],
+ )
+
+
+class PropertyMapTractAnalysisConnections(
+ AnalysisBaseConnections,
+ dimensions=("skymap", "tract", "band"),
+ defaultTemplates={"outputName": "propertyMapTract"},
+):
+ healSparsePropertyMapsConfig = ct.Input(
+ doc="Configuration parameters for HealSparseInputMapTask in pipe_tasks.",
+ name="healSparsePropertyMaps_config",
+ storageClass="Config",
+ dimensions=(),
+ )
+
+ skymap = ct.Input(
+ doc="The skymap that covers the tract that the data is from.",
+ name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME,
+ storageClass="SkyMap",
+ dimensions=("skymap",),
+ )
+
+ def __init__(self, *, config=None):
+ super().__init__(config=config)
+
+ operationNameLookup = {
+ "min": "Minimum",
+ "max": "Maximum",
+ "mean": "Mean",
+ "weighted_mean": "Weighted mean",
+ "sum": "Sum",
+ }
+
+ # Making connections for the maps that are configured to run.
+ for propertyName in config.properties:
+ coaddName = config.properties[propertyName].coaddName
+ for operationName in config.properties[propertyName].operations:
+ operationLongName = operationNameLookup[operationName]
+ name = f"{coaddName}Coadd_{propertyName}_map_{operationName}"
+ setattr(
+ self,
+ f"{coaddName}Coadd_{propertyName}_{operationName}",
+ ct.Input(
+ doc=f"{operationLongName}-value map of {{propertyLongName}}",
+ name=name,
+ storageClass="HealSparseMap",
+ dimensions=("tract", "skymap", "band"),
+ multiple=False,
+ deferLoad=False,
+ ),
+ )
+
+
+class PropertyMapTractAnalysisConfig(
+ AnalysisBaseConfig, pipelineConnections=PropertyMapTractAnalysisConnections
+):
+ zoomFactors = ListField(
+ dtype=float,
+ doc="Two-element list of zoom factors to use when plotting the maps.",
+ default=[2, 8],
+ )
+
+ properties = ConfigDictField(
+ doc="A configurable dictionary describing the property maps to be plotted, and the coadd name and "
+ "operations for each map. The available properties include 'exposure_time', 'psf_size', 'psf_e1', "
+ "'psf_e2', 'psf_maglim', 'sky_noise', 'sky_background', 'dcr_dra', 'dcr_ddec', 'dcr_e1', 'dcr_e2', "
+ "and 'epoch'.",
+ keytype=str,
+ itemtype=PropertyMapConfig,
+ default={},
+ )
+
+
+class PropertyMapTractAnalysisTask(AnalysisPipelineTask):
+ ConfigClass = PropertyMapTractAnalysisConfig
+ _DefaultName = "propertyMapTractAnalysisTask"
+
+ def parsePlotInfo(self, dataId: DataCoordinate | None) -> Mapping[str, str]:
+ """Parse plot info from the dataId.
+
+ Parameters
+ ----------
+ dataId : `dict`
+ The dataId of the data to be plotted.
+
+ Returns
+ -------
+ plotInfo : `dict`
+ A dictionary of the plot information.
+
+ Notes
+ -----
+ We customized this method to fit our needs. 'tract' can be obtained
+ from dataId, but 'run' and 'tableName' are not relevant for
+ HealSparseMaps.
+ """
+
+ # Initialize the plot info dictionary.
+ plotInfo = {}
+
+ self._populatePlotInfoWithDataId(plotInfo, dataId)
+ return plotInfo
+
+ def runQuantum(
+ self,
+ butlerQC: ButlerQuantumContext,
+ inputRefs: InputQuantizedConnection,
+ outputRefs: OutputQuantizedConnection,
+ ) -> None:
+ # Docstring inherited.
+
+ inputs = butlerQC.get(inputRefs)
+
+ if "skymap" in inputs.keys():
+ skymap = inputs["skymap"]
+ else:
+ skymap = None
+ dataId = butlerQC.quantum.dataId
+
+ plotInfo = self.parsePlotInfo(dataId)
+ tract = skymap[dataId["tract"]]
+
+ mapsDict = {}
+ for key, value in inputs.items():
+ if key in ["skymap", "healSparsePropertyMapsConfig"]:
+ continue
+ mapsDict[key] = value
+
+ kwargs = {"tract": tract, "zoomFactors": self.config.zoomFactors, "plotInfo": plotInfo}
+ outputs = self.run(data={"maps": mapsDict}, **kwargs)
+ butlerQC.put(outputs, outputRefs)