Coverage for python / lsst / analysis / tools / actions / plot / histPlot.py: 15%
266 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-22 09:32 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-22 09:32 +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__ = ("HistPanel", "HistPlot", "HistStatsPanel")
25import importlib.resources as importResources
26import logging
27from collections import defaultdict
28from collections.abc import Mapping
30import numpy as np
31import yaml
32from matplotlib import cm
33from matplotlib.figure import Figure
34from matplotlib.gridspec import GridSpec
35from matplotlib.patches import Rectangle
37import lsst.analysis.tools
38from lsst.pex.config import (
39 ChoiceField,
40 Config,
41 ConfigDictField,
42 ConfigField,
43 DictField,
44 Field,
45 FieldValidationError,
46 ListField,
47)
48from lsst.utils.plotting import get_multiband_plot_colors, make_figure, set_rubin_plotstyle
50from ...interfaces import KeyedData, KeyedDataSchema, PlotAction, Vector
51from ...math import nanMax, nanMedian, nanMin, sigmaMad
52from .plotUtils import addPlotInfo
54log = logging.getLogger(__name__)
57class HistStatsPanel(Config):
58 """A Config class that holds parameters to configure a the stats panel
59 shown for histPlot.
61 The fields in this class correspond to the parameters that can be used to
62 customize the HistPlot stats panel.
64 - The ListField parameter a dict to specify names of 3 stat columns accepts
65 latex formatting
67 - The other parameters (stat1, stat2, stat3) are lists of strings that
68 specify vector keys corresponding to scalar values computed in the
69 prep/process/produce steps of an analysis tools plot/metric configurable
70 action. There should be one key for each group in the HistPanel.
72 A separate config class is used instead of constructing
73 `~lsst.pex.config.DictField`'s in HistPanel for each parameter for clarity
74 and consistency.
76 Notes
77 -----
78 This is intended to be used as a configuration of the HistPlot/HistPanel
79 class.
81 If no HistStatsPanel is specified then the default behavior persists where
82 the stats panel shows N / median / sigma_mad for each group in the panel.
83 """
85 statsLabels = ListField[str](
86 doc="list specifying the labels for stats",
87 length=3,
88 default=("N$_{{data}}$", "Med", "${{\\sigma}}_{{MAD}}$"),
89 )
90 stat1 = ListField[str](
91 doc="A list specifying the vector keys of the first scalar statistic to be shown in this panel."
92 "there should be one entry for each hist in the panel",
93 default=None,
94 optional=True,
95 )
96 stat2 = ListField[str](
97 doc="A list specifying the vector keys of the second scalar statistic to be shown in this panel."
98 "there should be one entry for each hist in the panel",
99 default=None,
100 optional=True,
101 )
102 stat3 = ListField[str](
103 doc="A list specifying the vector keys of the third scalar statistic to be shown in this panel."
104 "there should be one entry for each hist in the panel",
105 default=None,
106 optional=True,
107 )
109 def validate(self):
110 super().validate()
111 if not all([self.stat1, self.stat2, self.stat3]) and any([self.stat1, self.stat2, self.stat3]):
112 raise ValueError(f"{self._name}: If one stat is configured, all 3 stats must be configured")
115class HistPanel(Config):
116 """A Config class that holds parameters to configure a single panel of a
117 histogram plot. This class is intended to be used within the ``HistPlot``
118 class.
119 """
121 label = Field[str](
122 doc="Panel x-axis label.",
123 default="label",
124 )
125 hists = DictField[str, str](
126 doc="A dict specifying the histograms to be plotted in this panel. Keys are used to identify "
127 "histogram IDs. Values are used to add to the legend label displayed in the upper corner of the "
128 "panel.",
129 optional=False,
130 )
131 yscale = Field[str](
132 doc="Y axis scaling.",
133 default="linear",
134 )
135 bins = Field[int](
136 doc="Number of x axis bins within plot x-range.",
137 default=50,
138 )
139 rangeType = ChoiceField[str](
140 doc="Set the type of range to use for the x-axis. Range bounds will be set according to "
141 "the values of lowerRange and upperRange.",
142 allowed={
143 "percentile": "Upper and lower percentile ranges of the data.",
144 "sigmaMad": "Range is (sigmaMad - lowerRange*sigmaMad, sigmaMad + upperRange*sigmaMad).",
145 "fixed": "Range is fixed to (lowerRange, upperRange).",
146 },
147 default="percentile",
148 )
149 lowerRange = Field[float](
150 doc="Lower range specifier for the histogram bins. See rangeType for interpretation "
151 "based on the type of range requested. If more than one histogram is plotted in a given "
152 "panel and rangeType is not set to fixed, the limit is the minimum value across all input "
153 "data.",
154 default=0.0,
155 )
156 upperRange = Field[float](
157 doc="Upper range specifier for the histogram bins. See rangeType for interpretation "
158 "based on the type of range requested. If more than one histogram is plotted in a given "
159 "panel and rangeType is not set to fixed, the limit is the maximum value across all input "
160 "data.",
161 default=100.0,
162 )
163 referenceValue = Field[float](
164 doc="Value at which to add a black solid vertical line. Ignored if set to `None`.",
165 default=None,
166 optional=True,
167 )
168 refRelativeToMedian = Field[bool](
169 doc="Is the referenceValue meant to be an offset from the median?",
170 default=False,
171 optional=True,
172 )
173 histDensity = Field[bool](
174 doc="Whether to plot the histogram as a normalized probability distribution. Must also "
175 "provide a value for referenceValue",
176 default=False,
177 )
178 statsPanel = ConfigField[HistStatsPanel](
179 doc="configuration for stats to be shown on plot, if None then "
180 "default stats: N, median, sigma mad are shown",
181 default=None,
182 )
183 addThresholds = Field[bool](
184 doc="Read in the predefined thresholds and indicate them on the histogram.",
185 default=False,
186 )
188 def validate(self):
189 super().validate()
190 if self.rangeType == "percentile" and self.lowerRange < 0.0 or self.upperRange > 100.0:
191 msg = f"For rangeType {self.rangeType}, ranges must obey: lowerRange >= 0 and upperRange <= 100."
192 raise FieldValidationError(self.__class__.rangeType, self, msg)
193 if self.rangeType == "sigmaMad" and self.lowerRange < 0.0:
194 msg = (
195 f"For rangeType {self.rangeType}, lower range must obey: "
196 "lowerRange >= 0 (the lower range is set as median - lowerRange*sigmaMad)."
197 )
198 raise FieldValidationError(self.__class__.rangeType, self, msg)
199 if self.rangeType == "fixed" and (self.upperRange - self.lowerRange) == 0.0:
200 msg = (
201 f"For rangeType {self.rangeType}, lower and upper ranges must differ (i.e. must obey: "
202 "upperRange - lowerRange != 0)."
203 )
204 raise FieldValidationError(self.__class__.rangeType, self, msg)
205 if self.histDensity and self.referenceValue is None:
206 msg = "Must provide referenceValue if histDensity is True."
207 raise FieldValidationError(self.__class__.referenceValue, self, msg)
210class HistPlot(PlotAction):
211 """Make an N-panel plot with a configurable number of histograms displayed
212 in each panel. Reference lines showing values of interest may also be added
213 to each histogram. Panels are configured using the ``HistPanel`` class.
214 """
216 panels = ConfigDictField(
217 doc="A configurable dict describing the panels to be plotted, and the histograms for each panel.",
218 keytype=str,
219 itemtype=HistPanel,
220 default={},
221 )
222 cmap = Field[str](
223 doc="Color map used for histogram lines. All types available via `plt.cm` may be used. "
224 "A number of custom color maps are also defined: `newtab10`, `bright`, `vibrant`.",
225 default="rubin",
226 )
227 panelsPerRow = Field[int](
228 doc="Maximum number of histogram panels to place in each row. Set to 1 to stack panels vertically.",
229 default=2,
230 )
232 def validate(self):
233 super().validate()
234 if self.panelsPerRow < 1:
235 msg = "panelsPerRow must be at least 1."
236 raise FieldValidationError(self.__class__.panelsPerRow, self, msg)
238 def getInputSchema(self) -> KeyedDataSchema:
239 for panel in self.panels: # type: ignore
240 for histData in self.panels[panel].hists.items(): # type: ignore
241 yield histData, Vector
243 def __call__(self, data: KeyedData, **kwargs) -> Mapping[str, Figure] | Figure:
244 return self.makePlot(data, **kwargs)
245 # table is a dict that needs: x, y, run, skymap, filter, tract,
247 def makePlot(
248 self,
249 data: KeyedData,
250 plotInfo: Mapping[str, str] = None,
251 **kwargs, # type: ignore
252 ) -> Figure:
253 """Make an N-panel plot with a user-configurable number of histograms
254 displayed in each panel.
256 Parameters
257 ----------
258 data : `pandas.core.frame.DataFrame`
259 The catalog to plot the points from.
260 plotInfo : `dict`
261 A dictionary of information about the data being plotted with keys:
262 `"run"`
263 Output run for the plots (`str`).
264 `"tractTableType"`
265 Table from which results are taken (`str`).
266 `"plotName"`
267 Output plot name (`str`)
268 `"SN"`
269 The global signal-to-noise data threshold (`float`)
270 `"skymap"`
271 The type of skymap used for the data (`str`).
272 `"tract"`
273 The tract that the data comes from (`int`).
274 `"bands"`
275 The bands used for this data (`str` or `list`).
276 `"visit"`
277 The visit that the data comes from (`int`)
279 Returns
280 -------
281 fig : `matplotlib.figure.Figure`
282 The resulting figure.
284 Examples
285 --------
286 An example histogram plot may be seen below:
288 .. image:: /_static/analysis_tools/histPlotExample.png
290 For further details on how to generate a plot, please refer to the
291 :ref:`getting started guide<analysis-tools-getting-started>`.
292 """
294 # set up figure
295 fig = make_figure(dpi=300)
296 set_rubin_plotstyle()
297 hist_fig, side_fig = fig.subfigures(1, 2, wspace=0, width_ratios=[2.9, 1.1])
298 axs, ncols, nrows = self._makeAxes(hist_fig)
300 # loop over each panel; plot histograms
301 colors = self._assignColors()
302 nth_panel = len(self.panels)
303 nth_col = ncols
304 nth_row = nrows - 1
305 label_font_size = max(6, 10 - ((len(self.panels) + 1) // 2))
306 for panel, ax in zip(self.panels, axs):
307 nth_panel -= 1
308 nth_col = ncols - 1 if nth_col == 0 else nth_col - 1
309 if nth_panel == 0 and nrows * ncols - len(self.panels) > 0:
310 nth_col -= 1
311 # Set font size for legend based on number of panels being plotted.
312 legend_font_size = max(4, int(7 - len(self.panels[panel].hists) / 2 - len(self.panels) / 4))
313 nums, meds, mads, stats_dict = self._makePanel(
314 data,
315 panel,
316 ax,
317 colors[panel],
318 label_font_size=label_font_size,
319 legend_font_size=legend_font_size,
320 ncols=ncols,
321 addThresholds=self.panels[panel].addThresholds,
322 )
324 all_handles, all_nums, all_meds, all_mads = [], [], [], []
325 handles, labels = ax.get_legend_handles_labels() # code for plotting
326 all_handles += handles
327 all_nums += nums
328 all_meds += meds
329 all_mads += mads
330 title_str = self.panels[panel].label # type: ignore
331 # add side panel; add statistics
332 self._addStatisticsPanel(
333 side_fig,
334 all_handles,
335 all_nums,
336 all_meds,
337 all_mads,
338 stats_dict,
339 legend_font_size=legend_font_size,
340 yAnchor0=ax.get_position().y0,
341 nth_row=nth_row,
342 nth_col=nth_col,
343 title_str=title_str,
344 )
345 nth_row = nth_row - 1 if nth_col == 0 else nth_row
347 # add general plot info
348 if plotInfo is not None:
349 hist_fig = addPlotInfo(hist_fig, plotInfo)
351 # finish up
352 fig.canvas.draw()
353 return fig
355 def _makeAxes(self, fig):
356 """Determine axes layout for main histogram figure."""
357 num_panels = len(self.panels)
358 if num_panels <= 1:
359 ncols = 1
360 else:
361 ncols = min(self.panelsPerRow, num_panels)
362 nrows = int(np.ceil(num_panels / ncols))
364 gs = GridSpec(nrows, ncols, left=0.12, right=0.88, bottom=0.1, top=0.88, wspace=0.41, hspace=0.45)
366 axs = []
367 counter = 0
368 for row in range(nrows):
369 for col in range(ncols):
370 counter += 1
371 if counter < num_panels:
372 axs.append(fig.add_subplot(gs[row : row + 1, col : col + 1]))
373 else:
374 axs.append(fig.add_subplot(gs[row : row + 1, col : np.min([col + 2, ncols + 1])]))
375 break
377 return axs, ncols, nrows
379 def _assignColors(self):
380 """Assign colors to histograms using a given color map."""
381 custom_cmaps = dict(
382 # https://www.tableau.com/about/blog/2016/7/colors-upgrade-tableau-10-56782
383 newtab10=[
384 "#4e79a7",
385 "#f28e2b",
386 "#e15759",
387 "#76b7b2",
388 "#59a14f",
389 "#edc948",
390 "#b07aa1",
391 "#ff9da7",
392 "#9c755f",
393 "#bab0ac",
394 ],
395 # https://personal.sron.nl/~pault/#fig:scheme_bright
396 bright=[
397 "#4477AA",
398 "#EE6677",
399 "#228833",
400 "#CCBB44",
401 "#66CCEE",
402 "#AA3377",
403 "#BBBBBB",
404 ],
405 # https://personal.sron.nl/~pault/#fig:scheme_vibrant
406 vibrant=[
407 "#EE7733",
408 "#0077BB",
409 "#33BBEE",
410 "#EE3377",
411 "#CC3311",
412 "#009988",
413 "#BBBBBB",
414 ],
415 rubin=[
416 "#0173B2",
417 "#DE8F05",
418 "#029E73",
419 "#D55E00",
420 "#CC78BC",
421 "#CA9161",
422 "#FBAFE4",
423 "#949494",
424 "#ECE133",
425 "#56B4E9",
426 ],
427 bands=[get_multiband_plot_colors()],
428 )
429 if self.cmap in custom_cmaps.keys():
430 all_colors = custom_cmaps[self.cmap]
431 else:
432 try:
433 all_colors = getattr(cm, self.cmap).copy().colors
434 except AttributeError:
435 raise ValueError(f"Unrecognized color map: {self.cmap}")
437 counter = 0
438 colors = defaultdict(list)
440 for panel in self.panels:
441 for hist in self.panels[panel].hists:
442 colors[panel].append(all_colors[counter % len(all_colors)])
443 counter += 1
444 return colors
446 def _makePanel(
447 self, data, panel, ax, colors, label_font_size=9, legend_font_size=7, ncols=1, addThresholds=False
448 ):
449 """Plot a single panel containing histograms."""
450 nums, meds, mads = [], [], []
451 for i, hist in enumerate(self.panels[panel].hists):
452 hist_data = data[hist][np.isfinite(data[hist])]
453 num, med, mad = self._calcStats(hist_data)
454 nums.append(num)
455 meds.append(med)
456 mads.append(mad)
457 panel_range = self._getPanelRange(data, panel, mads=mads, meds=meds)
458 if self.panels[panel].addThresholds:
459 metricThresholdFile = importResources.read_text(lsst.analysis.tools, "metricInformation.yaml")
460 metricDefs = yaml.safe_load(metricThresholdFile)
462 if all(np.isfinite(panel_range)):
463 nHist = 0
464 for i, hist in enumerate(self.panels[panel].hists):
465 hist_data = data[hist][np.isfinite(data[hist])]
466 if len(hist_data) > 0:
467 ax.hist(
468 hist_data,
469 range=panel_range,
470 bins=self.panels[panel].bins,
471 histtype="step",
472 density=self.panels[panel].histDensity,
473 lw=2,
474 color=colors[i],
475 label=self.panels[panel].hists[hist],
476 )
477 ax.axvline(meds[i], ls=(0, (5, 3)), lw=1, c=colors[i])
478 if self.panels[panel].addThresholds and hist in metricDefs:
479 if "lowThreshold" in metricDefs[hist].keys():
480 lowThreshold = metricDefs[hist]["lowThreshold"]
481 if np.isfinite(lowThreshold):
482 ax.axvline(lowThreshold, color=colors[i])
483 if "highThreshold" in metricDefs[hist].keys():
484 highThreshold = metricDefs[hist]["highThreshold"]
485 if np.isfinite(highThreshold):
486 ax.axvline(highThreshold, color=colors[i])
487 nHist += 1
489 if nHist > 0:
490 ax.legend(fontsize=legend_font_size, loc="upper left", frameon=False, borderaxespad=1.1)
491 ax.set_xlim(panel_range)
492 # The following accommodates spacing for ranges with large numbers
493 # but small-ish dynamic range (example use case: RA 300-301).
494 if ncols > 1 and max(np.abs(panel_range)) >= 100 and (panel_range[1] - panel_range[0]) < 5:
495 ax.xaxis.set_major_formatter("{x:.2f}")
496 ax.tick_params(axis="x", labelrotation=25, pad=-1)
497 ax.set_xlabel(self.panels[panel].label, fontsize=label_font_size)
498 y_label = "Normalized (PDF)" if self.panels[panel].histDensity else "Frequency"
499 ax.set_ylabel(y_label, fontsize=label_font_size)
500 ax.set_yscale(self.panels[panel].yscale)
501 ax.tick_params(labelsize=max(5, label_font_size - 2))
502 # add a buffer to the top of the plot to allow headspace for labels
503 ylims = list(ax.get_ylim())
504 if ax.get_yscale() == "log":
505 ylims[1] = 10 ** (np.log10(ylims[1]) * 1.1)
506 else:
507 ylims[1] *= 1.1
508 ax.set_ylim(ylims[0], ylims[1])
510 # Draw a vertical line at a reference value, if given.
511 # If histDensity is True, also plot a reference PDF with
512 # mean = referenceValue and sigma = 1 for reference.
513 if self.panels[panel].referenceValue is not None:
514 ax = self._addReferenceLines(ax, panel, panel_range, meds, legend_font_size=legend_font_size)
516 # Check if we should use the default stats panel or if a custom one
517 # has been created.
518 statList = [
519 self.panels[panel].statsPanel.stat1,
520 self.panels[panel].statsPanel.stat2,
521 self.panels[panel].statsPanel.stat3,
522 ]
523 if not any(statList):
524 stats_dict = {
525 "statLabels": ["N$_{{data}}$", "Med", "${{\\sigma}}_{{MAD}}$"],
526 "stat1": nums,
527 "stat2": meds,
528 "stat3": mads,
529 }
530 elif all(statList):
531 stat1 = [data[stat] for stat in self.panels[panel].statsPanel.stat1]
532 stat2 = [data[stat] for stat in self.panels[panel].statsPanel.stat2]
533 stat3 = [data[stat] for stat in self.panels[panel].statsPanel.stat3]
534 stats_dict = {
535 "statLabels": self.panels[panel].statsPanel.statsLabels,
536 "stat1": stat1,
537 "stat2": stat2,
538 "stat3": stat3,
539 }
540 else:
541 raise RuntimeError("Invalid configuration of HistStatPanel")
542 else:
543 stats_dict = {key: [] for key in ("stat1", "stat2", "stat3")}
544 stats_dict["statLabels"] = [""] * 3
545 return nums, meds, mads, stats_dict
547 def _getPanelRange(self, data, panel, mads=None, meds=None):
548 """Determine panel x-axis range based config settings."""
549 panel_range = [np.nan, np.nan]
550 rangeType = self.panels[panel].rangeType
551 lowerRange = self.panels[panel].lowerRange
552 upperRange = self.panels[panel].upperRange
553 if rangeType == "percentile":
554 panel_range = self._getPercentilePanelRange(data, panel)
555 elif rangeType == "sigmaMad":
556 # Set the panel range to extend lowerRange[upperRange] times the
557 # maximum sigmaMad for the datasets in the panel to the left[right]
558 # from the minimum[maximum] median value of all datasets in the
559 # panel.
560 maxMad = nanMax(mads)
561 maxMed = nanMax(meds)
562 minMed = nanMin(meds)
563 panel_range = [minMed - lowerRange * maxMad, maxMed + upperRange * maxMad]
564 if panel_range[1] - panel_range[0] == 0:
565 log.info(
566 f"NOTE: panel_range for {panel} based on med/sigMad was 0. Computing using "
567 "percentile range instead."
568 )
569 panel_range = self._getPercentilePanelRange(data, panel)
570 elif rangeType == "fixed":
571 panel_range = [lowerRange, upperRange]
572 else:
573 raise RuntimeError(f"Invalid rangeType: {rangeType}")
574 return panel_range
576 def _getPercentilePanelRange(self, data, panel):
577 """Determine panel x-axis range based on data percentile limits."""
578 panel_range = [np.nan, np.nan]
579 for hist in self.panels[panel].hists:
580 data_hist = data[hist]
581 # TODO: Consider raising instead
582 if len(data_hist) > 0:
583 hist_range = np.nanpercentile(
584 data[hist], [self.panels[panel].lowerRange, self.panels[panel].upperRange]
585 )
586 panel_range[0] = nanMin([panel_range[0], hist_range[0]])
587 panel_range[1] = nanMax([panel_range[1], hist_range[1]])
588 return panel_range
590 def _calcStats(self, data):
591 """Calculate the number of data points, median, and median absolute
592 deviation of input data."""
593 num = len(data)
594 med = nanMedian(data)
595 mad = sigmaMad(data)
596 return num, med, mad
598 def _addReferenceLines(self, ax, panel, panel_range, meds, legend_font_size=7):
599 """Draw the vertical reference line and density curve (if requested)
600 on the panel.
601 """
602 ax2 = ax.twinx()
603 ax2.axis("off")
604 ax2.set_xlim(ax.get_xlim())
605 ax2.set_ylim(ax.get_ylim())
607 if self.panels[panel].histDensity:
608 reference_label = None
609 else:
610 if self.panels[panel].refRelativeToMedian:
611 reference_value = self.panels[panel].referenceValue + meds[0]
612 reference_label = f"${{\\mu_{{ref}}}}$: {reference_value:10.3F}"
613 else:
614 reference_value = self.panels[panel].referenceValue
615 reference_label = f"${{\\mu_{{ref}}}}$: {reference_value:10.3F}"
616 ax2.axvline(reference_value, ls="-", lw=1, c="black", zorder=0, label=reference_label)
617 if (
618 self.panels[panel].histDensity
619 and (panel_range[1] - panel_range[0] != 0)
620 and all(np.isfinite(panel_range))
621 ):
622 ref_x = np.arange(panel_range[0], panel_range[1], (panel_range[1] - panel_range[0]) / 100.0)
623 ref_mean = self.panels[panel].referenceValue
624 ref_std = 1.0
625 ref_y = (
626 1.0
627 / (ref_std * np.sqrt(2.0 * np.pi))
628 * np.exp(-((ref_x - ref_mean) ** 2) / (2.0 * ref_std**2))
629 )
630 ax2.fill_between(ref_x, ref_y, alpha=0.1, color="black", label="P$_{{norm}}(0,1)$", zorder=-1)
631 # Make sure the y-axis extends beyond the data plotted and that
632 # the y-ranges of both axes are in sync.
633 y_max = max(max(ref_y), ax2.get_ylim()[1])
634 if ax2.get_ylim()[1] < 1.05 * y_max:
635 ax.set_ylim(ax.get_ylim()[0], 1.05 * y_max)
636 ax2.set_ylim(ax.get_ylim())
637 ax2.legend(
638 fontsize=legend_font_size, handlelength=1.5, loc="upper right", frameon=False, borderaxespad=1.1
639 )
641 return ax
643 def _addStatisticsPanel(
644 self,
645 fig,
646 handles,
647 nums,
648 meds,
649 mads,
650 stats_dict,
651 legend_font_size=8,
652 yAnchor0=0.0,
653 nth_row=0,
654 nth_col=0,
655 title_str=None,
656 ):
657 """Add an adjoining panel containing histogram summary statistics."""
658 ax = fig.add_subplot(1, 1, 1)
659 ax.axis("off")
660 fig.subplots_adjust(left=0.05, right=0.95, bottom=0.0, top=1.0)
661 # empty handle, used to populate the bespoke legend layout
662 empty = Rectangle((0, 0), 1, 1, fc="w", fill=False, edgecolor="none", linewidth=0)
664 # set up new legend handles and labels
665 legend_handles = [empty] + handles + ([empty] * 3 * len(handles)) + ([empty] * 3)
667 def format_stat(x):
668 if isinstance(x, int):
669 return str(x) if abs(x) <= 9999 else f"{x:.2e}"
670 else:
671 return f"{x:.2f}" if abs(x) < 999 else f"{x:.2e}"
673 legend_labels = (
674 ([""] * (len(handles) + 1))
675 + [stats_dict["statLabels"][0]]
676 + [format_stat(x) for x in stats_dict["stat1"]]
677 + [stats_dict["statLabels"][1]]
678 + [format_stat(x) for x in stats_dict["stat2"]]
679 + [stats_dict["statLabels"][2]]
680 + [format_stat(x) for x in stats_dict["stat3"]]
681 )
682 # Replace "e+0" with "e" and "e-0" with "e-" to save space.
683 legend_labels = [label.replace("e+0", "e") for label in legend_labels]
684 legend_labels = [label.replace("e-0", "e-") for label in legend_labels]
686 # Set the y anchor for the legend such that it roughly lines up with
687 # the panels.
688 yAnchor = max(0, yAnchor0 - 0.01) + nth_col * (0.008 + len(nums) * 0.005) * legend_font_size
690 nth_legend = ax.legend(
691 legend_handles,
692 legend_labels,
693 loc="lower left",
694 bbox_to_anchor=(-0.25, yAnchor),
695 ncol=4,
696 handletextpad=-0.25,
697 fontsize=legend_font_size,
698 borderpad=0,
699 frameon=False,
700 columnspacing=-0.25,
701 title=title_str,
702 title_fontproperties={"weight": "bold", "size": legend_font_size},
703 )
704 if nth_row + nth_col > 0:
705 ax.add_artist(nth_legend)