Coverage for python / lsst / analysis / tools / actions / plot / propertyMapPlot.py: 9%
344 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-22 09:09 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-22 09:09 +0000
1# This file is part of analysis_tools.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
21from __future__ import annotations
23__all__ = (
24 "PerTractPropertyMapPlot",
25 "SurveyWidePropertyMapPlot",
26)
28import logging
29from typing import Mapping, Union
31import lsst.pex.config as pexConfig
32import lsst.sphgeom as sphgeom
33import matplotlib.patheffects as mpl_path_effects
34import numpy as np
35import skyproj
36from healsparse.healSparseMap import HealSparseMap
37from lsst.analysis.tools.tasks.propertyMapAnalysis import (
38 PerTractPropertyMapAnalysisConfig,
39 SurveyWidePropertyMapAnalysisConfig,
40)
41from lsst.skymap.tractInfo import ExplicitTractInfo
42from lsst.utils.plotting import make_figure, set_rubin_plotstyle
43from matplotlib import cm, rc_context
44from matplotlib.figure import Figure
45from matplotlib.legend_handler import HandlerTuple
46from mpl_toolkits.axes_grid1 import make_axes_locatable
47from mpl_toolkits.axes_grid1.axes_size import AxesX, AxesY, Fraction
49from ...interfaces import KeyedData, PlotAction
51_LOG = logging.getLogger(__name__)
53# Holds unit renames to match style guidelines: {"old_unit": "new_unit"}.
54unitRenameDict = {"mag(AB)": r"$\rm mag_{AB}$"}
57def getZoomedExtent(box, n):
58 """Get a zoomed plot extent in degrees for a bounding box.
60 Parameters
61 ----------
62 box : `lsst.sphgeom.NormalizedAngleInterval`
63 The bounding box to get an extent for.
64 n : `float`
65 Zoom factor; for instance, n=2 means zooming in 2 times at the
66 center. Must be positive.
68 Returns
69 -------
70 `tuple` [`float`]
71 New extent as (new_lon_min, new_lon_max, new_lat_min, new_lat_max).
72 """
73 lon = box.getLon()
74 lat = box.getLat()
75 if n > 1:
76 factor = (1.0 - 1.0 / n) / 2.0
77 lon.erodeBy(sphgeom.NormalizedAngle(factor * box.getWidth()))
78 lat.erodeBy(sphgeom.NormalizedAngle(factor * box.getHeight()))
79 elif n < 1:
80 factor = (1.0 / n - 1) / 2.0
81 lon.dilateBy(sphgeom.NormalizedAngle(factor * box.getWidth()))
82 lat.dilateBy(sphgeom.NormalizedAngle(factor * box.getHeight()))
84 extent = (
85 lon.getA().asDegrees(),
86 lon.getB().asDegrees(),
87 lat.getA().asDegrees(),
88 lat.getB().asDegrees(),
89 )
90 return extent
93def getLongestSuffixMatch(s, options):
94 """Find the longest suffix in the provided list that matches the end of
95 the given string.
97 Parameters
98 ----------
99 s : `str`
100 The target string for which we want to find a matching suffix.
101 options : `list` [`str`]
102 A list of potential suffix strings to match against the target
103 string `s`.
105 Returns
106 -------
107 `str`
108 The longest matching suffix from the `options` list. If no match is
109 found, returns `None`.
110 """
111 return next((opt for opt in sorted(options, key=len, reverse=True) if s.endswith(opt)), None)
114def addTextToColorbar(
115 cb, text, orientation="vertical", color="black", fontsize=14, fontweight="bold", alpha=0.8
116):
117 """Helper method to add text inside the horizontal colorbar.
119 Parameters
120 ----------
121 cb : `~matplotlib.colorbar.Colorbar`
122 The colorbar object.
123 text : `str`
124 The text to add.
125 orientation : `str`, optional
126 The orientation of the colorbar. Can be either "vertical" or
127 "horizontal".
128 fontsize : `int`, optional
129 The fontsize of the text.
130 fontweight : `str`, optional
131 The fontweight of the text.
132 alpha : `float`, optional
133 The alpha value of the text.
135 Returns
136 -------
137 `None`
138 The text is added to the colorbar in place.
139 """
140 if color is None:
141 color = "black"
142 vmid = (cb.vmin + cb.vmax) / 2
143 positions = {"vertical": (0.5, vmid), "horizontal": (vmid, 0.5)}
144 cbtext = cb.ax.text(
145 *positions[orientation],
146 text,
147 color=color,
148 va="center",
149 ha="center",
150 fontsize=fontsize,
151 fontweight=fontweight,
152 rotation=orientation,
153 alpha=alpha,
154 )
155 # Add a distinct outline around the text for better visibility in
156 # various backgrounds.
157 cbtext.set_path_effects(
158 [mpl_path_effects.Stroke(linewidth=4, foreground="white", alpha=0.8), mpl_path_effects.Normal()]
159 )
162class CustomHandler(HandlerTuple):
163 """Custom legend handler to overlay multiple patches for a single
164 legend entry.
166 This handler class inherits from `HandlerTuple` and is designed to
167 handle cases where multiple artists (e.g., patches) need to be overlaid
168 on top of each other in a single legend entry, as opposed to
169 side-by-side which is the default behavior of `HandlerTuple`.
171 Methods
172 -------
173 create_artists:
174 Override the `create_artists` method of `HandlerTuple` to modify
175 the positioning of the artists so that they overlay directly on top
176 of one another in the legend.
178 Example
179 -------
180 # Plot some data.
181 line, = ax.plot(x, y, label="Sample Line")
183 # Use CustomHandler for overlaid patches and also include the regular
184 # line legend if desired.
185 handles = [(patch1, patch2), line]
186 labels = ['Overlaid Patches', line.get_label()]
187 leg = ax.legend(
188 handles, labels, handler_map={tuple: CustomHandler()}, loc="best"
189 )
190 """
192 def create_artists(self, legend, orig_handle, xdescent, ydescent, width, height, fontsize, trans):
193 artists = HandlerTuple.create_artists(
194 self, legend, orig_handle, xdescent, ydescent, width, height, fontsize, trans
195 )
196 # Overlay the two patches.
197 for a in artists:
198 a.set_transform(trans)
199 return artists
202class PerTractPropertyMapPlot(PlotAction):
203 draw_patch_bounds = pexConfig.Field[bool](
204 doc="Whether to draw patch inner boundaries or not",
205 default=False,
206 )
207 label_patches = pexConfig.ChoiceField[str](
208 doc="Which patches to label by ID",
209 default="none",
210 allowed={
211 "all": "All patches",
212 "edge": "Edge patches only",
213 "none": "No labels",
214 },
215 )
216 plotName = pexConfig.Field[str](doc="The name for the plotting task.", optional=True)
218 def __call__(
219 self,
220 data: KeyedData,
221 tractInfo: ExplicitTractInfo,
222 plotConfig: PerTractPropertyMapAnalysisConfig,
223 plotInfo: Mapping[str, Union[Mapping[str, str], str, int]],
224 **kwargs,
225 ) -> Mapping[str, Figure]:
226 self._validateInput(data, tractInfo, plotConfig, plotInfo)
227 return self.makePlot(data, tractInfo, plotConfig, plotInfo)
229 def _validateInput(
230 self,
231 data: KeyedData,
232 tractInfo: ExplicitTractInfo,
233 plotConfig: PerTractPropertyMapAnalysisConfig,
234 plotInfo: Mapping[str, Union[Mapping[str, str], str, int]],
235 ) -> None:
236 """Validate the input data."""
238 if not isinstance(tractInfo, ExplicitTractInfo):
239 raise TypeError(f"Input `tractInfo` type must be {ExplicitTractInfo} not {type(tractInfo)}.")
241 if not isinstance(plotConfig, PerTractPropertyMapAnalysisConfig):
242 raise TypeError(
243 "`plotConfig` must be a "
244 "`lsst.analysis.tools.tasks.propertyMapAnalysis.PerTractPropertyMapAnalysisConfig`. "
245 f"Got {type(plotConfig)}."
246 )
248 if not isinstance(plotInfo, dict):
249 raise TypeError("`plotConfig` must be a dictionary.")
251 if not plotConfig.publicationStyle:
252 zoomFactors = plotConfig.zoomFactors
253 isListOfFloats = isinstance(zoomFactors, pexConfig.listField.List) and all(
254 isinstance(zf, float) for zf in zoomFactors
255 )
256 if not (isListOfFloats and len(zoomFactors) == 2) or any(zf <= 1 for zf in zoomFactors):
257 raise TypeError(
258 "`zoomFactors` must be a two-element `lsst.pex.config.listField.List` of floats > 1."
259 )
261 for atool in plotConfig.atools:
262 if not isinstance(atool.nBinsHist, int) or atool.nBinsHist <= 0:
263 raise ValueError(
264 f"`nBinsHist` for property `{atool.process.buildActions.data.mapKey}` must be a positive "
265 f"integer. Got {atool.nBinsHist}."
266 )
268 # Identify any invalid entries in `data`.
269 invalidEntries = {
270 key: pytype
271 for key, pytype in {k: v.ref.datasetType.storageClass.pytype for k, v in data.items()}.items()
272 if pytype != HealSparseMap
273 }
275 # If any invalid entries are found, raise a TypeError with details.
276 if invalidEntries:
277 errorMessage = "; ".join(
278 f"`{key}` should be {HealSparseMap}, got {type_}" for key, type_ in invalidEntries.items()
279 )
280 raise TypeError(f"Invalid input types found in `data`: {errorMessage}")
282 def addPlotInfo(
283 self,
284 fig: Figure,
285 plotInfo: Mapping[str, Union[Mapping[str, str], str, int]],
286 toolName: str,
287 ) -> Figure:
288 """Add useful information to the plot.
290 Parameters
291 ----------
292 fig : `matplotlib.figure.Figure`
293 The figure to add the information to.
294 plotInfo : `dict`
295 A dictionary of the plot information.
296 toolName : `str`
297 The name of the tool used to generate the plot.
299 Returns
300 -------
301 fig : `matplotlib.figure.Figure`
302 The figure with the information added.
303 """
305 run = plotInfo["run"]
306 tableType = f"\nTable: {plotInfo['tableNames'][toolName]}"
308 dataIdText = f"Tract: {plotInfo['tract']}, Band: {plotInfo['band']}"
309 propertyDescription = plotInfo["description"]
310 # Lowercase the first letter unless the string starts with more than
311 # one uppercase letter in which case we keep it as is, e.g. PSF, DCR.
312 if propertyDescription[0].isupper() and propertyDescription[1].islower():
313 propertyDescription = propertyDescription[0].lower() + propertyDescription[1:]
314 mapText = (
315 f", Property: {propertyDescription}, "
316 f"Unit: {plotInfo['unit']}, "
317 f"Operation: {plotInfo['operation']}, "
318 f"Coadd: {plotInfo['coaddName']}"
319 )
320 geomText = (
321 f", Valid area: {plotInfo['valid_area']:.2f} sq. deg., "
322 + f"NSIDE: {plotInfo['nside']}, projection: {plotInfo['projection']}"
323 )
324 infoText = f"\n{dataIdText}{mapText}"
326 fig.text(
327 0.04,
328 0.965,
329 f'{plotInfo["plotName"]} of {plotInfo["property"]}',
330 fontsize=19,
331 transform=fig.transFigure,
332 ha="left",
333 va="top",
334 )
335 t = fig.text(
336 0.04,
337 0.942,
338 f"{run}{tableType}{geomText}{infoText}",
339 fontsize=15,
340 transform=fig.transFigure,
341 alpha=0.6,
342 ha="left",
343 va="top",
344 )
345 t.set_linespacing(1.4)
347 return fig
349 def makePlot(
350 self,
351 data: KeyedData,
352 tractInfo: ExplicitTractInfo,
353 plotConfig: PerTractPropertyMapAnalysisConfig,
354 plotInfo: Mapping[str, Union[Mapping[str, str], str, int]],
355 ) -> Mapping[str, Figure]:
356 """Make the survey property map plot.
358 Parameters
359 ----------
360 data : `KeyedData`
361 The HealSparseMap to plot the points from.
362 tractInfo: `~lsst.skymap.tractInfo.ExplicitTractInfo`
363 The tract info object.
364 plotConfig :
365 `~lsst.analysis.tools.tasks.perTractPropertyMapAnalysis.
366 PerTractPropertyMapAnalysisConfig`
367 The configuration for the plot.
368 plotInfo : `dict`
369 A dictionary of information about the data being plotted.
371 Returns
372 -------
373 fig : `~matplotlib.figure.Figure`
374 The resulting figure.
375 """
377 set_rubin_plotstyle()
379 # 'plotName' by default is constructed from the attribute specified in
380 # 'atools.<attribute>' in the pipeline YAML. If it is explicitly
381 # set in `~lsst.analysis.tools.atools.propertyMap.PropertyMapTool`,
382 # it will override this default.
383 if self.plotName:
384 # Set the plot name using 'produce.plot.plotName' from
385 # PropertyMapTool's instance.
386 plotInfo["plotName"] = self.plotName
388 # Plotting customization.
389 colorbarLocation = "right"
390 colorbarTickLabelSize = 14
391 colorBarAspect = 16
392 colorBarFraction = 0.07 # w.r.t. the axes size.
393 colorBarPad = 0 # w.r.t. the axes size.
394 rcparams = {
395 "axes.labelsize": 22 if plotConfig.publicationStyle else 19,
396 "axes.linewidth": 1.8,
397 "xtick.labelsize": 15 if plotConfig.publicationStyle else 14,
398 "ytick.labelsize": 15 if plotConfig.publicationStyle else 14,
399 }
401 colorbarKwargs = dict(plotConfig.colorbarKwargs)
402 projectionKwargs = dict(plotConfig.projectionKwargs)
404 if plotConfig.publicationStyle:
405 zoomFactors = []
406 _LOG.info(
407 "Zoom factors are not used in publication-style plots. "
408 "Only the full-tract map will be plotted."
409 )
410 if "cmap" in colorbarKwargs and colorbarKwargs["cmap"] != "viridis":
411 _LOG.warning(
412 "Color map set to 'viridis' for publication style plots. "
413 f"The color map '{colorbarKwargs['cmap']}' set in the config will be ignored."
414 )
415 colorbarKwargs["cmap"] = "viridis"
416 cmap = cm.get_cmap(colorbarKwargs["cmap"])
417 # Colorbar text color only, not used for any histogram.
418 histColors = [cmap(0.1)]
419 labelpad = 14
420 else:
421 zoomFactors = plotConfig.zoomFactors
422 colorbarKwargs["cmap"] = colorbarKwargs.get("cmap", "viridis")
423 # Muted green for the full map, and muted red and blue for the two
424 # zoomed-in maps. Used for boxes, colorbar texts and histograms.
425 histColors = ["#265D40", "#8B0000", "#00008B"]
426 labelpad = 11
428 toolName = data["data"].ref.datasetType.name
429 mapName = toolName.replace("_map_", "_")
430 mapData = data["data"].get()
432 with rc_context(rcparams):
433 if plotConfig.publicationStyle:
434 # Make a single plot for the full tract.
435 fig = make_figure(figsize=(8, 8))
436 ax1 = fig.add_subplot(111)
437 fig.subplots_adjust(left=0.133, right=0.888, bottom=0.1, top=0.855)
438 else:
439 # Make a 2x2 grid of subplots for the full tract, two zoomed-in
440 # views, and a histogram of values.
441 fig = make_figure(figsize=(16, 16))
442 ax1 = fig.add_subplot(221)
443 ax2 = fig.add_subplot(222)
444 ax3 = fig.add_subplot(223)
445 ax4 = fig.add_subplot(224)
447 # Reduce whitespace but leave some room at the top for info.
448 fig.subplots_adjust(left=0.064, right=0.96, top=0.855, bottom=0.07, wspace=0.275, hspace=0.24)
450 # Get the values for the valid pixels of the full tract.
451 values = mapData[mapData.valid_pixels]
452 goodValues = np.isfinite(values)
453 values = values[goodValues] # As a precaution.
455 # Make a concise human-readable label for the plot.
456 plotInfo["unit"] = "N/A" # Unless overridden.
457 if hasattr(mapData, "metadata") and all(
458 key in mapData.metadata for key in ["DESCRIPTION", "OPERATION", "UNIT"]
459 ):
460 hasMetadata = True
461 metadata = mapData.metadata
462 plotInfo["description"] = metadata["DESCRIPTION"]
463 plotInfo["operation"] = metadata["OPERATION"]
464 if metadata["UNIT"]:
465 plotInfo["unit"] = metadata["UNIT"]
466 elif metadata["UNIT"] == "":
467 plotInfo["unit"] = "dimensionless"
468 else:
469 hasMetadata = False
470 plotInfo["operation"] = getLongestSuffixMatch(
471 mapName, ["min", "max", "mean", "weighted_mean", "sum"]
472 ).replace("_", " ")
473 plotInfo["coaddName"] = mapName.split("Coadd_")[0]
474 plotInfo["operation"] = plotInfo["operation"].replace("minimum", "min").replace("maximum", "max")
475 propertyName = mapName[len(f"{plotInfo['coaddName']}Coadd_") : -len(plotInfo["operation"])].strip(
476 "_"
477 )
478 if not hasMetadata:
479 # Infer the property description from the map name (all
480 # lower case), and properly handle formatting.
481 plotInfo["description"] = (
482 propertyName.replace("_", " ")
483 .replace("psf", "PSF")
484 .replace("dcr", "DCR")
485 .replace("dra", r"$\Delta$RA")
486 .replace("ddec", r"$\Delta$Dec")
487 )
488 plotInfo["property"] = (
489 propertyName.replace("_", " ")
490 .title() # Capitalize and handle edge cases below.
491 .replace("Psf", "PSF")
492 .replace("Dcr", "DCR")
493 .replace("Dra", r"$\Delta$RA")
494 .replace("Ddec", r"$\Delta$Dec")
495 .replace("E1", "e1")
496 .replace("E2", "e2")
497 )
499 # Handle unit renaming.
500 if plotInfo["unit"] in unitRenameDict:
501 plotInfo["unit"] = unitRenameDict[plotInfo["unit"]]
503 atool = getattr(plotConfig.atools, toolName)
504 nBinsHist = atool.nBinsHist
506 # Use the full tract bounding box to set the default extent.
507 box = tractInfo.getOuterSkyPolygon().getBoundingBox()
508 ctr_lonlat = box.getCenter()
509 ctr_lon = ctr_lonlat.getLon().asDegrees()
510 ctr_lat = ctr_lonlat.getLat().asDegrees()
512 # Prepare plot elements for the full tract and optional zoomed-in
513 # views.
514 if plotConfig.publicationStyle:
515 skyprojAxes = [ax1]
516 else:
517 skyprojAxes = [ax1, ax3, ax4]
519 zoomIdx = []
520 for ax, zoomFactor, histColor in zip(skyprojAxes, [1.0, *zoomFactors], histColors):
521 extent = getZoomedExtent(box, zoomFactor)
523 sp = skyproj.GnomonicSkyproj(
524 ax=ax,
525 lon_0=ctr_lon,
526 lat_0=ctr_lat,
527 extent=extent,
528 **projectionKwargs,
529 )
531 sp.draw_hspmap(mapData, zoom=False, cmap=colorbarKwargs["cmap"])
533 sp.ax.set_xlabel("R.A.", labelpad=labelpad, fontsize=rcparams["axes.labelsize"])
534 sp.ax.set_ylabel("Dec.", labelpad=labelpad, fontsize=rcparams["axes.labelsize"])
536 if self.draw_patch_bounds:
537 label_all = self.label_patches == "all"
538 label_edge = self.label_patches == "edge"
539 if label_all or label_edge:
540 patchids_max = tuple(x - 1 for x in tractInfo.getNumPatches())
541 lon_min, lon_max = min(extent[:2]), max(extent[:2])
542 lat_min, lat_max = min(extent[2:]), max(extent[2:])
544 for patchInfo in tractInfo:
545 vertices = patchInfo.getInnerSkyPolygon().getVertices()
546 clipped = tractInfo.inner_sky_region.clipTo(
547 sphgeom.Box(sphgeom.LonLat(vertices[0]), sphgeom.LonLat(vertices[2]))
548 )
549 lonlats_patch = np.array(
550 [
551 [x.asDegrees() for x in (lonlat.getA(), lonlat.getB())]
552 for lonlat in (clipped.getLon(), clipped.getLat())
553 ]
554 )
555 lons_patch = np.concat((lonlats_patch[0, :], lonlats_patch[0, ::-1]))
556 lats_patch = np.repeat(lonlats_patch[1, :], 2)
558 sp.draw_polygon(lons_patch, lats_patch, edgecolor="gray")
559 label_id = label_all or (
560 label_edge
561 and (
562 (patchInfo.index[0] == 0)
563 or (patchInfo.index[1] == 0)
564 or (patchInfo.index[0] == patchids_max[0])
565 or (patchInfo.index[1] == patchids_max[1])
566 )
567 )
568 if label_id:
569 lon_label = clipped.getCenter().getLon().asDegrees()
570 lat_label = clipped.getCenter().getLat().asDegrees()
571 if (lon_min < lon_label < lon_max) and (lat_min < lat_label < lat_max):
572 sp.ax.text(
573 lon_label,
574 lat_label,
575 patchInfo.getSequentialIndex(),
576 ha="center",
577 va="center",
578 size=7,
579 c=[0, 0, 0, 0.5],
580 )
582 # Specify the size and padding of the colorbar axes with
583 # respect to the main axes.
584 refAx = AxesX(ax) if colorbarLocation in ("left", "right") else AxesY(ax)
585 cbsize = Fraction(colorBarFraction, refAx)
586 cbpad = Fraction(colorBarPad, refAx)
588 # Make divider and a colorbar axes to be attached to the main
589 # axes.
590 divider = make_axes_locatable(ax)
591 cax = divider.append_axes(colorbarLocation, size=cbsize, pad=cbpad)
593 cbar = sp.draw_colorbar(
594 **{
595 "location": colorbarLocation,
596 "aspect": colorBarAspect,
597 "fraction": colorBarFraction,
598 "pad": colorBarPad,
599 "cax": cax,
600 **colorbarKwargs,
601 }
602 )
603 cbar.ax.tick_params(labelsize=colorbarTickLabelSize)
604 if plotConfig.publicationStyle:
605 if plotInfo["unit"] not in ["dimensionless", "N/A"]:
606 cbarText = f"{plotInfo['property']} ({plotInfo['unit']})"
607 else:
608 cbarText = plotInfo["property"]
609 else:
610 cbarText = (
611 "Full Tract" if zoomFactor == 1.0 else f"{self.prettyPrintFloat(zoomFactor)}x Zoom"
612 )
613 addTextToColorbar(cbar, cbarText, color=histColor)
614 if zoomFactor == 1.0:
615 # Store the "full tract" map so that we can overplot
616 # the zoom rectangles.
617 spf = sp
618 else:
619 # Create a rectangle for the zoomed-in region.
620 x0, x1, y0, y1 = extent
621 for c, ls, lw in zip(["white", histColor], ["solid", "dashed"], [3.5, 1.5]):
622 spf.draw_polygon(
623 [x0, x0, x1, x1],
624 [y0, y1, y1, y0],
625 facecolor="none",
626 edgecolor=c,
627 linestyle=ls,
628 linewidth=lw,
629 alpha=0.8,
630 )
631 zoomText = spf.ax.text(
632 (x0 + x1) / 2,
633 y0,
634 f"{self.prettyPrintFloat(zoomFactor)}x",
635 color=histColor,
636 fontsize=14,
637 fontweight="bold",
638 alpha=0.8,
639 ha="center",
640 va="bottom",
641 )
642 # Add a distinct outline around the text for better
643 # visibility in various backgrounds.
644 zoomText.set_path_effects(
645 [
646 mpl_path_effects.Stroke(linewidth=4, foreground="white", alpha=0.8),
647 mpl_path_effects.Normal(),
648 ]
649 )
650 # Get the indices of pixels in the zoomed-in region.
651 pos = mapData.valid_pixels_pos()
652 # Reversed axes consideration.
653 xmin, xmax = sorted([x0, x1])
654 idx = (pos[0] > xmin) & (pos[0] < xmax) & (pos[1] > y0) & (pos[1] < y1)
655 zoomIdx.append(idx[goodValues])
657 # Calculate weights for each bin to ensure that the peak of the
658 # histogram reaches 1.
659 if len(values) == 0 or np.ptp(values) < 1e-12:
660 # np.histogram cannot use 100 bins if no variance in values
661 weights = np.ones_like(values) / values.size
662 nBinsHist = 1
663 _LOG.info(f"No variance in {toolName}; set histogram to a single bin.")
664 else:
665 weights = np.ones_like(values) / np.histogram(values, bins=nBinsHist)[0].max()
667 if not plotConfig.publicationStyle:
668 # Compute full-tract histogram and get its bins.
669 # NOTE: `exposure_time` histograms are quantized and look more
670 # bar-like, so they look better with fewer bins.
671 bins = ax2.hist(
672 values,
673 bins=nBinsHist,
674 label="Full Tract",
675 color=histColors[0],
676 weights=weights,
677 alpha=0.7,
678 )[1]
680 # Align the histogram (top right panel) with the skyproj plots.
681 pos1 = spf.ax.get_position() # Top left.
682 pos4 = sp.ax.get_position() # Bottom right.
683 cbarWidth = cbar.ax.get_position().height / colorBarAspect
684 # NOTE: cbarWidth != cbar.ax.get_position().width
685 ax2.set_position([pos4.x0, pos1.y0, pos4.width + cbarWidth, pos1.height])
687 # Overplot the histograms for the zoomed-in plots.
688 for zoomFactor, zidx, color, linestyle, hatch in zip(
689 zoomFactors, zoomIdx, histColors[1:], ["solid", "dotted"], ["//", "xxxx"]
690 ):
691 weights = np.ones_like(values[zidx]) / np.histogram(values[zidx], bins=bins)[0].max()
692 histLabel = f"{self.prettyPrintFloat(zoomFactor)}x Zoom"
693 histValues = ax2.hist(
694 values[zidx],
695 bins=bins,
696 label=histLabel,
697 color=color,
698 weights=weights,
699 histtype="step",
700 linewidth=2,
701 linestyle=linestyle,
702 alpha=0.6,
703 )[0]
704 # Fill the area under the step.
705 ax2.fill_between(
706 bins[:-1],
707 histValues,
708 step="post",
709 color=color,
710 alpha=0.2,
711 hatch=hatch,
712 label="hidden",
713 )
715 # Set labels and legend.
716 xlabel = plotInfo["property"]
717 if plotInfo["unit"] not in ["dimensionless", "N/A"]:
718 xlabel += f" ({plotInfo['unit']})"
719 xtext = ax2.set_xlabel(xlabel, labelpad=labelpad)
720 ytext = ax2.set_ylabel("Normalized Count", labelpad=labelpad)
721 xtext.set_fontsize(rcparams["axes.labelsize"])
722 ytext.set_fontsize(rcparams["axes.labelsize"])
724 # Get handles and labels from the axis.
725 handles, labels = ax2.get_legend_handles_labels()
727 # Add a legend with custom handler that combines the handle
728 # pairs for the zoomed-in cases.
729 handles = [handles[0], (handles[1], handles[2]), (handles[3], handles[4])]
730 while "hidden" in labels:
731 labels.remove("hidden")
732 legend = ax2.legend(
733 handles,
734 labels,
735 handler_map={tuple: CustomHandler()},
736 loc="best",
737 frameon=False,
738 fontsize=15,
739 )
741 for line, text in zip(handles, legend.get_texts()):
742 if isinstance(line, tuple):
743 # Use the first handle to get the color.
744 line = line[0]
745 color = line.get_edgecolor() if line.get_facecolor()[-1] == 0 else line.get_facecolor()
746 text.set_color(color)
748 # Add extra info to plotInfo.
749 plotInfo["projection"] = "Gnomonic"
750 plotInfo["nside"] = mapData.nside_sparse
751 plotInfo["valid_area"] = mapData.get_valid_area()
753 # Add useful information to the plot.
754 self.addPlotInfo(fig, plotInfo, toolName)
755 style = ""
756 else:
757 style = "publication-style "
758 fig.suptitle(
759 f"{plotInfo['description']} {plotInfo['operation']} map",
760 fontsize=18.5,
761 ha="center",
762 va="top",
763 y=0.985,
764 )
765 fig.text(
766 0.5,
767 0.925,
768 f"Tract: {plotInfo['tract']}, Band: {plotInfo['band']}, Coadd: {plotInfo['coaddName']}",
769 ha="center",
770 fontsize=15.5,
771 )
773 _LOG.info(
774 f"Made {style}per-tract property map plot for dataset type '{toolName}', "
775 f"tract: {plotInfo['tract']}, band: '{plotInfo['band']}'."
776 )
778 return fig
780 @staticmethod
781 def prettyPrintFloat(n):
782 if n.is_integer():
783 return str(int(n))
784 return str(n)
787class SurveyWidePropertyMapPlot(PlotAction):
788 plotName = pexConfig.Field[str](doc="The name for the plotting task.", optional=True)
790 def __call__(
791 self,
792 data: KeyedData,
793 plotConfig: SurveyWidePropertyMapAnalysisConfig,
794 plotInfo: Mapping[str, Union[Mapping[str, str], str, int]],
795 **kwargs,
796 ) -> Mapping[str, Figure]:
797 return self.makePlot(data, plotConfig, plotInfo)
799 def addPlotInfo(
800 self,
801 fig: Figure,
802 plotInfo: Mapping[str, Union[Mapping[str, str], str, int]],
803 toolName: str,
804 ) -> Figure:
805 """Add useful information to the plot.
807 Parameters
808 ----------
809 fig : `matplotlib.figure.Figure`
810 The figure to add the information to.
811 plotInfo : `dict`
812 A dictionary of the plot information.
813 toolName : `str`
814 The name of the tool used to generate the plot.
816 Returns
817 -------
818 fig : `matplotlib.figure.Figure`
819 The figure with the information added.
820 """
822 run = plotInfo["run"]
823 tableType = f"\nTable: {plotInfo['tableNames'][toolName]}"
825 dataIdText = f"Band: {plotInfo['band']}"
826 propertyDescription = plotInfo["description"]
827 # Lowercase the first letter unless the string starts with more than
828 # one uppercase letter in which case we keep it as is, e.g. PSF, DCR.
829 if propertyDescription[0].isupper() and propertyDescription[1].islower():
830 propertyDescription = propertyDescription[0].lower() + propertyDescription[1:]
831 mapText = (
832 f", Property: {propertyDescription}, "
833 f"Unit: {plotInfo['unit']}, "
834 f"Operation: {plotInfo['operation']}, "
835 f"Coadd: {plotInfo['coaddName']}"
836 )
837 geomText = (
838 f", Valid area: {plotInfo['valid_area']:.2f} sq. deg., "
839 + f"NSIDE: {plotInfo['nside']}, projection: {plotInfo['projection']}"
840 )
841 infoText = f"\n{dataIdText}{mapText}"
843 titleBoxTopLeftCorner = (0.045, 0.89)
844 title = fig.text(
845 *titleBoxTopLeftCorner,
846 f'{plotInfo["plotName"]} of {plotInfo["property"]}',
847 fontsize=19,
848 transform=fig.transFigure,
849 ha="left",
850 va="top",
851 )
852 lineHeightFraction = title.get_fontsize() / (fig.get_size_inches()[1] * fig.dpi)
853 infoBoxTopLeftCorner = (titleBoxTopLeftCorner[0], titleBoxTopLeftCorner[1] - 1.8 * lineHeightFraction)
854 info = fig.text(
855 *infoBoxTopLeftCorner,
856 f"{run}{tableType}{geomText}{infoText}",
857 fontsize=15,
858 transform=fig.transFigure,
859 alpha=0.6,
860 ha="left",
861 va="top",
862 )
863 info.set_linespacing(1.4)
865 return fig
867 def makePlot(
868 self,
869 data: KeyedData,
870 plotConfig: SurveyWidePropertyMapAnalysisConfig,
871 plotInfo: Mapping[str, Union[Mapping[str, str], str, int]],
872 ) -> Figure:
873 """Make the survey property map plot.
875 Parameters
876 ----------
877 data : `KeyedData`
878 The HealSparseMap to plot the points from.
879 plotConfig :
880 `~lsst.analysis.tools.tasks.propertyMapSurveyAnalysis.
881 SurveyWidePropertyMapAnalysisConfig`
882 The configuration for the plot.
883 plotInfo : `dict`
884 A dictionary of information about the data being plotted.
886 Returns
887 -------
888 fig : `~matplotlib.figure.Figure`
889 The resulting figure.
890 """
892 set_rubin_plotstyle()
894 # 'plotName' by default is constructed from the attribute specified in
895 # 'atools.<attribute>' in the pipeline YAML. If it is explicitly
896 # set in `~lsst.analysis.tools.atools.healSparsePropertyMap.
897 # SurveyWidePropertyMapTool`, it will override this default.
898 if self.plotName:
899 # Set the plot name using 'produce.plot.plotName' from
900 # SurveyWidePropertyMapTool's instance.
901 plotInfo["plotName"] = self.plotName
903 # Plotting customization.
904 colorbarTickLabelSize = 16
905 rcparams = {
906 "axes.labelsize": 18,
907 "axes.linewidth": 1.8,
908 "xtick.labelsize": 13,
909 "ytick.labelsize": 13,
910 }
912 toolName = data["data"].ref.datasetType.name
913 mapName = toolName.replace("_consolidated_map_", "_")
914 mapData = data["data"].get()
916 with rc_context(rcparams):
917 # The figsize should be decided based on the survey footprint.
918 fig = make_figure(figsize=(19, 7))
919 ax = fig.add_subplot(111)
921 if not plotConfig.publicationStyle:
922 # Leave some room at the top for plotInfo.
923 fig.subplots_adjust(left=0.072, right=0.945, top=0.55)
925 # Get the values for the valid pixels of the full tract.
926 values = mapData[mapData.valid_pixels]
927 goodValues = np.isfinite(values)
928 values = values[goodValues] # As a precaution.
930 # Make a concise human-readable label for the plot.
931 plotInfo["unit"] = "N/A" # Unless overridden.
932 if hasattr(mapData, "metadata") and all(
933 key in mapData.metadata for key in ["DESCRIPTION", "OPERATION", "UNIT"]
934 ):
935 hasMetadata = True
936 metadata = mapData.metadata
937 plotInfo["description"] = metadata["DESCRIPTION"]
938 plotInfo["operation"] = metadata["OPERATION"]
939 if metadata["UNIT"]:
940 plotInfo["unit"] = metadata["UNIT"]
941 elif metadata["UNIT"] == "":
942 plotInfo["unit"] = "dimensionless"
943 else:
944 hasMetadata = False
945 plotInfo["operation"] = getLongestSuffixMatch(
946 mapName, ["min", "max", "mean", "weighted_mean", "sum"]
947 ).replace("_", " ")
948 plotInfo["coaddName"] = mapName.split("Coadd_")[0]
949 plotInfo["operation"] = plotInfo["operation"].replace("minimum", "min").replace("maximum", "max")
950 propertyName = mapName[
951 len(f"{plotInfo['coaddName']}Coadd_") : -len(f"{plotInfo['operation']}")
952 ].strip("_")
953 if not hasMetadata:
954 # Infer the property description from the map name (all
955 # lower case), and properly handle formatting.
956 plotInfo["description"] = (
957 propertyName.replace("_", " ")
958 .replace("psf", "PSF")
959 .replace("dcr", "DCR")
960 .replace("dra", r"$\Delta$RA")
961 .replace("ddec", r"$\Delta$Dec")
962 )
963 plotInfo["property"] = (
964 propertyName.replace("_", " ")
965 .title() # Capitalize and handle edge cases below.
966 .replace("Psf", "PSF")
967 .replace("Dcr", "DCR")
968 .replace("Dra", r"$\Delta$RA")
969 .replace("Ddec", r"$\Delta$Dec")
970 .replace("E1", "e1")
971 .replace("E2", "e2")
972 )
974 # Handle unit renaming.
975 if plotInfo["unit"] in unitRenameDict:
976 plotInfo["unit"] = unitRenameDict[plotInfo["unit"]]
978 sp = getattr(skyproj, f"{plotConfig.projection}Skyproj")(ax=ax, **plotConfig.projectionKwargs)
980 colorbarKwargs = dict(plotConfig.colorbarKwargs)
981 if plotConfig.publicationStyle:
982 colorbarKwargs["cmap"] = "viridis"
983 else:
984 colorbarKwargs["cmap"] = colorbarKwargs.get("cmap", "viridis")
986 # Work around skyproj bug that will fail to zoom on empty map.
987 if mapData.n_valid == 0:
988 if plotConfig.autozoom:
989 _LOG.warning("No valid pixels found in the map. Auto zooming is disabled.")
990 sp.draw_hspmap(mapData, zoom=False, cmap=colorbarKwargs["cmap"])
991 else:
992 sp.draw_hspmap(mapData, zoom=plotConfig.autozoom, cmap=colorbarKwargs["cmap"])
993 sp.ax.set_xlabel("R.A.")
994 sp.ax.set_ylabel("Dec.")
996 # In the below, colorbarKwargs takes precedence over hardcoded
997 # arguments in case of conflict.
998 cbar = sp.draw_colorbar(**{"location": "top", "pad": 0.2, **colorbarKwargs})
999 cbar.ax.tick_params(labelsize=colorbarTickLabelSize)
1000 unit = f" ({plotInfo['unit']})" if plotInfo["unit"] not in ["dimensionless", "N/A"] else ""
1001 cbarText = f"{plotInfo['property']}{unit}"
1002 cbarLoc = colorbarKwargs["location"]
1003 cbarOrientation = colorbarKwargs.get("orientation", None)
1004 if cbarOrientation is None:
1005 cbarOrientation = "vertical" if cbarLoc in ["right", "left"] else "horizontal"
1006 addTextToColorbar(cbar, cbarText, color="#265D40", fontsize=16, orientation=cbarOrientation)
1008 # Add extra info to plotInfo.
1009 plotInfo["projection"] = plotConfig.projection
1010 plotInfo["nside"] = mapData.nside_sparse
1011 plotInfo["valid_area"] = mapData.get_valid_area()
1013 # Add useful information to the plot.
1014 self.addPlotInfo(fig, plotInfo, toolName)
1016 _LOG.info(
1017 f"Made survey-wide property map plot for dataset type '{toolName}', "
1018 f"band: '{plotInfo['band']}'."
1019 )
1021 return fig