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