Coverage for python/lsst/analysis/tools/actions/plot/histPlot.py: 16%

239 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-12 01:25 -0800

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 

22 

23__all__ = ("HistPanel", "HistPlot", "HistStatsPanel") 

24 

25import logging 

26from collections import defaultdict 

27from typing import Mapping 

28 

29import numpy as np 

30from lsst.pex.config import ( 

31 ChoiceField, 

32 Config, 

33 ConfigDictField, 

34 ConfigField, 

35 DictField, 

36 Field, 

37 FieldValidationError, 

38 ListField, 

39) 

40from lsst.utils.plotting import make_figure 

41from matplotlib import cm 

42from matplotlib.figure import Figure 

43from matplotlib.gridspec import GridSpec 

44from matplotlib.patches import Rectangle 

45 

46from ...interfaces import KeyedData, KeyedDataSchema, PlotAction, Vector 

47from ...math import nanMax, nanMedian, nanMin, sigmaMad 

48from .plotUtils import addPlotInfo 

49 

50log = logging.getLogger(__name__) 

51 

52 

53class HistStatsPanel(Config): 

54 """A Config class that holds parameters to configure a the stats panel 

55 shown for histPlot. 

56 

57 The fields in this class correspond to the parameters that can be used to 

58 customize the HistPlot stats panel. 

59 

60 - The ListField parameter a dict to specify names of 3 stat columns accepts 

61 latex formating 

62 

63 - The other parameters (stat1, stat2, stat3) are lists of strings that 

64 specify vector keys correspoinding to scalar values computed in the 

65 prep/process/produce steps of an analysis tools plot/metric configurable 

66 action. There should be one key for each group in the HistPanel. 

67 

68 A separate config class is used instead of constructing 

69 `~lsst.pex.config.DictField`'s in HistPanel for each parameter for clarity 

70 and consistency. 

71 

72 Notes 

73 ----- 

74 This is intended to be used as a configuration of the HistPlot/HistPanel 

75 class. 

76 

77 If no HistStatsPanel is specified then the default behavor persists where 

78 the stats panel shows N / median / sigma_mad for each group in the panel. 

79 """ 

80 

81 statsLabels = ListField[str]( 

82 doc="list specifying the labels for stats", 

83 length=3, 

84 default=("N$_{{data}}$", "Med", "${{\\sigma}}_{{MAD}}$"), 

85 ) 

86 stat1 = ListField[str]( 

87 doc="A list specifying the vector keys of the first scalar statistic to be shown in this panel." 

88 "there should be one entry for each hist in the panel", 

89 default=None, 

90 optional=True, 

91 ) 

92 stat2 = ListField[str]( 

93 doc="A list specifying the vector keys of the second scalar statistic to be shown in this panel." 

94 "there should be one entry for each hist in the panel", 

95 default=None, 

96 optional=True, 

97 ) 

98 stat3 = ListField[str]( 

99 doc="A list specifying the vector keys of the third scalar statistic to be shown in this panel." 

100 "there should be one entry for each hist in the panel", 

101 default=None, 

102 optional=True, 

103 ) 

104 

105 def validate(self): 

106 super().validate() 

107 if not all([self.stat1, self.stat2, self.stat3]) and any([self.stat1, self.stat2, self.stat3]): 

108 raise ValueError(f"{self._name}: If one stat is configured, all 3 stats must be configured") 

109 

110 

111class HistPanel(Config): 

112 """A Config class that holds parameters to configure a single panel of a 

113 histogram plot. This class is intended to be used within the ``HistPlot`` 

114 class. 

115 """ 

116 

117 label = Field[str]( 

118 doc="Panel x-axis label.", 

119 default="label", 

120 ) 

121 hists = DictField[str, str]( 

122 doc="A dict specifying the histograms to be plotted in this panel. Keys are used to identify " 

123 "histogram IDs. Values are used to add to the legend label displayed in the upper corner of the " 

124 "panel.", 

125 optional=False, 

126 ) 

127 yscale = Field[str]( 

128 doc="Y axis scaling.", 

129 default="linear", 

130 ) 

131 bins = Field[int]( 

132 doc="Number of x axis bins within plot x-range.", 

133 default=50, 

134 ) 

135 rangeType = ChoiceField[str]( 

136 doc="Set the type of range to use for the x-axis. Range bounds will be set according to " 

137 "the values of lowerRange and upperRange.", 

138 allowed={ 

139 "percentile": "Upper and lower percentile ranges of the data.", 

140 "sigmaMad": "Range is (sigmaMad - lowerRange*sigmaMad, sigmaMad + upperRange*sigmaMad).", 

141 "fixed": "Range is fixed to (lowerRange, upperRange).", 

142 }, 

143 default="percentile", 

144 ) 

145 lowerRange = Field[float]( 

146 doc="Lower range specifier for the histogram bins. See rangeType for interpretation " 

147 "based on the type of range requested. If more than one histogram is plotted in a given " 

148 "panel and rangeType is not set to fixed, the limit is the minimum value across all input " 

149 "data.", 

150 default=0.0, 

151 ) 

152 upperRange = Field[float]( 

153 doc="Upper range specifier for the histogram bins. See rangeType for interpretation " 

154 "based on the type of range requested. If more than one histogram is plotted in a given " 

155 "panel and rangeType is not set to fixed, the limit is the maximum value across all input " 

156 "data.", 

157 default=100.0, 

158 ) 

159 referenceValue = Field[float]( 

160 doc="Value at which to add a black solid vertical line. Ignored if set to `None`.", 

161 default=None, 

162 optional=True, 

163 ) 

164 refRelativeToMedian = Field[bool]( 

165 doc="Is the referenceValue meant to be an offset from the median?", 

166 default=False, 

167 optional=True, 

168 ) 

169 histDensity = Field[bool]( 

170 doc="Whether to plot the histogram as a normalized probability distribution. Must also " 

171 "provide a value for referenceValue", 

172 default=False, 

173 ) 

174 statsPanel = ConfigField[HistStatsPanel]( 

175 doc="configuration for stats to be shown on plot, if None then " 

176 "default stats: N, median, sigma mad are shown", 

177 default=None, 

178 ) 

179 

180 def validate(self): 

181 super().validate() 

182 if self.rangeType == "percentile" and self.lowerRange < 0.0 or self.upperRange > 100.0: 

183 msg = ( 

184 "For rangeType %s, ranges must obey: lowerRange >= 0 and upperRange <= 100." % self.rangeType 

185 ) 

186 raise FieldValidationError(self.__class__.rangeType, self, msg) 

187 if self.rangeType == "sigmaMad" and self.lowerRange < 0.0: 

188 msg = ( 

189 "For rangeType %s, lower range must obey: lowerRange >= 0 (the lower range is " 

190 "set as median - lowerRange*sigmaMad." % self.rangeType 

191 ) 

192 raise FieldValidationError(self.__class__.rangeType, self, msg) 

193 if self.rangeType == "fixed" and (self.upperRange - self.lowerRange) == 0.0: 

194 msg = ( 

195 "For rangeType %s, lower and upper ranges must differ (i.e. must obey: " 

196 "upperRange - lowerRange != 0)." % self.rangeType 

197 ) 

198 raise FieldValidationError(self.__class__.rangeType, self, msg) 

199 if self.histDensity and self.referenceValue is None: 

200 msg = "Must provide referenceValue if histDensity is True." 

201 raise FieldValidationError(self.__class__.referenceValue, self, msg) 

202 

203 

204class HistPlot(PlotAction): 

205 """Make an N-panel plot with a configurable number of histograms displayed 

206 in each panel. Reference lines showing values of interest may also be added 

207 to each histogram. Panels are configured using the ``HistPanel`` class. 

208 """ 

209 

210 panels = ConfigDictField( 

211 doc="A configurable dict describing the panels to be plotted, and the histograms for each panel.", 

212 keytype=str, 

213 itemtype=HistPanel, 

214 default={}, 

215 ) 

216 cmap = Field[str]( 

217 doc="Color map used for histogram lines. All types available via `plt.cm` may be used. " 

218 "A number of custom color maps are also defined: `newtab10`, `bright`, `vibrant`.", 

219 default="newtab10", 

220 ) 

221 

222 def getInputSchema(self) -> KeyedDataSchema: 

223 for panel in self.panels: # type: ignore 

224 for histData in self.panels[panel].hists.items(): # type: ignore 

225 yield histData, Vector 

226 

227 def __call__(self, data: KeyedData, **kwargs) -> Mapping[str, Figure] | Figure: 

228 return self.makePlot(data, **kwargs) 

229 # table is a dict that needs: x, y, run, skymap, filter, tract, 

230 

231 def makePlot( 

232 self, data: KeyedData, plotInfo: Mapping[str, str] = None, **kwargs # type: ignore 

233 ) -> Figure: 

234 """Make an N-panel plot with a user-configurable number of histograms 

235 displayed in each panel. 

236 

237 Parameters 

238 ---------- 

239 data : `pandas.core.frame.DataFrame` 

240 The catalog to plot the points from. 

241 plotInfo : `dict` 

242 A dictionary of information about the data being plotted with keys: 

243 `"run"` 

244 Output run for the plots (`str`). 

245 `"tractTableType"` 

246 Table from which results are taken (`str`). 

247 `"plotName"` 

248 Output plot name (`str`) 

249 `"SN"` 

250 The global signal-to-noise data threshold (`float`) 

251 `"skymap"` 

252 The type of skymap used for the data (`str`). 

253 `"tract"` 

254 The tract that the data comes from (`int`). 

255 `"bands"` 

256 The bands used for this data (`str` or `list`). 

257 `"visit"` 

258 The visit that the data comes from (`int`) 

259 

260 Returns 

261 ------- 

262 fig : `matplotlib.figure.Figure` 

263 The resulting figure. 

264 

265 Examples 

266 -------- 

267 An example histogram plot may be seen below: 

268 

269 .. image:: /_static/analysis_tools/histPlotExample.png 

270 

271 For further details on how to generate a plot, please refer to the 

272 :ref:`getting started guide<analysis-tools-getting-started>`. 

273 """ 

274 

275 # set up figure 

276 fig = make_figure(dpi=300) 

277 hist_fig, side_fig = fig.subfigures(1, 2, wspace=0, width_ratios=[2.9, 1.1]) 

278 axs, ncols, nrows = self._makeAxes(hist_fig) 

279 

280 # loop over each panel; plot histograms 

281 colors = self._assignColors() 

282 nth_panel = len(self.panels) 

283 nth_col = ncols 

284 nth_row = nrows - 1 

285 label_font_size = max(6, 10 - nrows) 

286 for panel, ax in zip(self.panels, axs): 

287 nth_panel -= 1 

288 nth_col = ncols - 1 if nth_col == 0 else nth_col - 1 

289 if nth_panel == 0 and nrows * ncols - len(self.panels) > 0: 

290 nth_col -= 1 

291 # Set font size for legend based on number of panels being plotted. 

292 legend_font_size = max(4, int(7 - len(self.panels[panel].hists) / 2 - nrows // 2)) # type: ignore 

293 nums, meds, mads, stats_dict = self._makePanel( 

294 data, 

295 panel, 

296 ax, 

297 colors[panel], 

298 label_font_size=label_font_size, 

299 legend_font_size=legend_font_size, 

300 ncols=ncols, 

301 ) 

302 

303 all_handles, all_nums, all_meds, all_mads = [], [], [], [] 

304 handles, labels = ax.get_legend_handles_labels() # code for plotting 

305 all_handles += handles 

306 all_nums += nums 

307 all_meds += meds 

308 all_mads += mads 

309 title_str = self.panels[panel].label # type: ignore 

310 # add side panel; add statistics 

311 self._addStatisticsPanel( 

312 side_fig, 

313 all_handles, 

314 all_nums, 

315 all_meds, 

316 all_mads, 

317 stats_dict, 

318 legend_font_size=legend_font_size, 

319 yAnchor0=ax.get_position().y0, 

320 nth_row=nth_row, 

321 nth_col=nth_col, 

322 title_str=title_str, 

323 ) 

324 nth_row = nth_row - 1 if nth_col == 0 else nth_row 

325 

326 # add general plot info 

327 if plotInfo is not None: 

328 hist_fig = addPlotInfo(hist_fig, plotInfo) 

329 

330 # finish up 

331 fig.canvas.draw() 

332 return fig 

333 

334 def _makeAxes(self, fig): 

335 """Determine axes layout for main histogram figure.""" 

336 num_panels = len(self.panels) 

337 if num_panels <= 1: 

338 ncols = 1 

339 else: 

340 ncols = 2 

341 nrows = int(np.ceil(num_panels / ncols)) 

342 

343 gs = GridSpec(nrows, ncols, left=0.12, right=0.88, bottom=0.1, top=0.88, wspace=0.41, hspace=0.45) 

344 

345 axs = [] 

346 counter = 0 

347 for row in range(nrows): 

348 for col in range(ncols): 

349 counter += 1 

350 if counter < num_panels: 

351 axs.append(fig.add_subplot(gs[row : row + 1, col : col + 1])) 

352 else: 

353 axs.append(fig.add_subplot(gs[row : row + 1, col : np.min([col + 2, ncols + 1])])) 

354 break 

355 

356 return axs, ncols, nrows 

357 

358 def _assignColors(self): 

359 """Assign colors to histograms using a given color map.""" 

360 custom_cmaps = dict( 

361 # https://www.tableau.com/about/blog/2016/7/colors-upgrade-tableau-10-56782 

362 newtab10=[ 

363 "#4e79a7", 

364 "#f28e2b", 

365 "#e15759", 

366 "#76b7b2", 

367 "#59a14f", 

368 "#edc948", 

369 "#b07aa1", 

370 "#ff9da7", 

371 "#9c755f", 

372 "#bab0ac", 

373 ], 

374 # https://personal.sron.nl/~pault/#fig:scheme_bright 

375 bright=[ 

376 "#4477AA", 

377 "#EE6677", 

378 "#228833", 

379 "#CCBB44", 

380 "#66CCEE", 

381 "#AA3377", 

382 "#BBBBBB", 

383 ], 

384 # https://personal.sron.nl/~pault/#fig:scheme_vibrant 

385 vibrant=[ 

386 "#EE7733", 

387 "#0077BB", 

388 "#33BBEE", 

389 "#EE3377", 

390 "#CC3311", 

391 "#009988", 

392 "#BBBBBB", 

393 ], 

394 ) 

395 if self.cmap in custom_cmaps.keys(): 

396 all_colors = custom_cmaps[self.cmap] 

397 else: 

398 try: 

399 all_colors = getattr(cm, self.cmap).copy().colors 

400 except AttributeError: 

401 raise ValueError(f"Unrecognized color map: {self.cmap}") 

402 

403 counter = 0 

404 colors = defaultdict(list) 

405 for panel in self.panels: 

406 for hist in self.panels[panel].hists: 

407 colors[panel].append(all_colors[counter % len(all_colors)]) 

408 counter += 1 

409 return colors 

410 

411 def _makePanel(self, data, panel, ax, colors, label_font_size=9, legend_font_size=7, ncols=1): 

412 """Plot a single panel containing histograms.""" 

413 nums, meds, mads = [], [], [] 

414 for i, hist in enumerate(self.panels[panel].hists): 

415 hist_data = data[hist][np.isfinite(data[hist])] 

416 num, med, mad = self._calcStats(hist_data) 

417 nums.append(num) 

418 meds.append(med) 

419 mads.append(mad) 

420 panel_range = self._getPanelRange(data, panel, mads=mads, meds=meds) 

421 if all(np.isfinite(panel_range)): 

422 nHist = 0 

423 for i, hist in enumerate(self.panels[panel].hists): 

424 hist_data = data[hist][np.isfinite(data[hist])] 

425 if len(hist_data) > 0: 

426 ax.hist( 

427 hist_data, 

428 range=panel_range, 

429 bins=self.panels[panel].bins, 

430 histtype="step", 

431 density=self.panels[panel].histDensity, 

432 lw=2, 

433 color=colors[i], 

434 label=self.panels[panel].hists[hist], 

435 ) 

436 ax.axvline(meds[i], ls=(0, (5, 3)), lw=1, c=colors[i]) 

437 nHist += 1 

438 

439 if nHist > 0: 

440 ax.legend(fontsize=legend_font_size, loc="upper left", frameon=False) 

441 ax.set_xlim(panel_range) 

442 # The following accommodates spacing for ranges with large numbers 

443 # but small-ish dynamic range (example use case: RA 300-301). 

444 if ncols > 1 and max(np.abs(panel_range)) >= 100 and (panel_range[1] - panel_range[0]) < 5: 

445 ax.xaxis.set_major_formatter("{x:.2f}") 

446 ax.tick_params(axis="x", labelrotation=25, pad=-1) 

447 ax.set_xlabel(self.panels[panel].label, fontsize=label_font_size) 

448 y_label = "Normalized (PDF)" if self.panels[panel].histDensity else "Frequency" 

449 ax.set_ylabel(y_label, fontsize=label_font_size) 

450 ax.set_yscale(self.panels[panel].yscale) 

451 ax.tick_params(labelsize=max(5, label_font_size - 2)) 

452 # add a buffer to the top of the plot to allow headspace for labels 

453 ylims = list(ax.get_ylim()) 

454 if ax.get_yscale() == "log": 

455 ylims[1] = 10 ** (np.log10(ylims[1]) * 1.1) 

456 else: 

457 ylims[1] *= 1.1 

458 ax.set_ylim(ylims[0], ylims[1]) 

459 

460 # Draw a vertical line at a reference value, if given. 

461 # If histDensity is True, also plot a reference PDF with 

462 # mean = referenceValue and sigma = 1 for reference. 

463 if self.panels[panel].referenceValue is not None: 

464 ax = self._addReferenceLines(ax, panel, panel_range, meds, legend_font_size=legend_font_size) 

465 

466 # Check if we should use the default stats panel or if a custom one 

467 # has been created. 

468 statList = [ 

469 self.panels[panel].statsPanel.stat1, 

470 self.panels[panel].statsPanel.stat2, 

471 self.panels[panel].statsPanel.stat3, 

472 ] 

473 if not any(statList): 

474 stats_dict = { 

475 "statLabels": ["N$_{{data}}$", "Med", "${{\\sigma}}_{{MAD}}$"], 

476 "stat1": nums, 

477 "stat2": meds, 

478 "stat3": mads, 

479 } 

480 elif all(statList): 

481 stat1 = [data[stat] for stat in self.panels[panel].statsPanel.stat1] 

482 stat2 = [data[stat] for stat in self.panels[panel].statsPanel.stat2] 

483 stat3 = [data[stat] for stat in self.panels[panel].statsPanel.stat3] 

484 stats_dict = { 

485 "statLabels": self.panels[panel].statsPanel.statsLabels, 

486 "stat1": stat1, 

487 "stat2": stat2, 

488 "stat3": stat3, 

489 } 

490 else: 

491 raise RuntimeError("Invalid configuration of HistStatPanel") 

492 else: 

493 stats_dict = {key: [] for key in ("stat1", "stat2", "stat3")} 

494 stats_dict["statLabels"] = [""] * 3 

495 return nums, meds, mads, stats_dict 

496 

497 def _getPanelRange(self, data, panel, mads=None, meds=None): 

498 """Determine panel x-axis range based config settings.""" 

499 panel_range = [np.nan, np.nan] 

500 rangeType = self.panels[panel].rangeType 

501 lowerRange = self.panels[panel].lowerRange 

502 upperRange = self.panels[panel].upperRange 

503 if rangeType == "percentile": 

504 panel_range = self._getPercentilePanelRange(data, panel) 

505 elif rangeType == "sigmaMad": 

506 # Set the panel range to extend lowerRange[upperRange] times the 

507 # maximum sigmaMad for the datasets in the panel to the left[right] 

508 # from the minimum[maximum] median value of all datasets in the 

509 # panel. 

510 maxMad = nanMax(mads) 

511 maxMed = nanMax(meds) 

512 minMed = nanMin(meds) 

513 panel_range = [minMed - lowerRange * maxMad, maxMed + upperRange * maxMad] 

514 if panel_range[1] - panel_range[0] == 0: 

515 log.info( 

516 "NOTE: panel_range for {} based on med/sigMad was 0. Computing using " 

517 "percentile range instead.".format(panel) 

518 ) 

519 panel_range = self._getPercentilePanelRange(data, panel) 

520 elif rangeType == "fixed": 

521 panel_range = [lowerRange, upperRange] 

522 else: 

523 raise RuntimeError(f"Invalid rangeType: {rangeType}") 

524 return panel_range 

525 

526 def _getPercentilePanelRange(self, data, panel): 

527 """Determine panel x-axis range based on data percentile limits.""" 

528 panel_range = [np.nan, np.nan] 

529 for hist in self.panels[panel].hists: 

530 data_hist = data[hist] 

531 # TODO: Consider raising instead 

532 if len(data_hist) > 0: 

533 hist_range = np.nanpercentile( 

534 data[hist], [self.panels[panel].lowerRange, self.panels[panel].upperRange] 

535 ) 

536 panel_range[0] = nanMin([panel_range[0], hist_range[0]]) 

537 panel_range[1] = nanMax([panel_range[1], hist_range[1]]) 

538 return panel_range 

539 

540 def _calcStats(self, data): 

541 """Calculate the number of data points, median, and median absolute 

542 deviation of input data.""" 

543 num = len(data) 

544 med = nanMedian(data) 

545 mad = sigmaMad(data) 

546 return num, med, mad 

547 

548 def _addReferenceLines(self, ax, panel, panel_range, meds, legend_font_size=7): 

549 """Draw the vertical reference line and density curve (if requested) 

550 on the panel. 

551 """ 

552 ax2 = ax.twinx() 

553 ax2.axis("off") 

554 ax2.set_xlim(ax.get_xlim()) 

555 ax2.set_ylim(ax.get_ylim()) 

556 

557 if self.panels[panel].histDensity: 

558 reference_label = None 

559 else: 

560 if self.panels[panel].refRelativeToMedian: 

561 reference_value = self.panels[panel].referenceValue + meds[0] 

562 reference_label = "${{\\mu_{{ref}}}}$: {:10.3F}".format(reference_value) 

563 else: 

564 reference_value = self.panels[panel].referenceValue 

565 reference_label = "${{\\mu_{{ref}}}}$: {:10.3F}".format(reference_value) 

566 ax2.axvline(reference_value, ls="-", lw=1, c="black", zorder=0, label=reference_label) 

567 if ( 

568 self.panels[panel].histDensity 

569 and (panel_range[1] - panel_range[0] != 0) 

570 and all(np.isfinite(panel_range)) 

571 ): 

572 ref_x = np.arange(panel_range[0], panel_range[1], (panel_range[1] - panel_range[0]) / 100.0) 

573 ref_mean = self.panels[panel].referenceValue 

574 ref_std = 1.0 

575 ref_y = ( 

576 1.0 

577 / (ref_std * np.sqrt(2.0 * np.pi)) 

578 * np.exp(-((ref_x - ref_mean) ** 2) / (2.0 * ref_std**2)) 

579 ) 

580 ax2.fill_between(ref_x, ref_y, alpha=0.1, color="black", label="P$_{{norm}}(0,1)$", zorder=-1) 

581 # Make sure the y-axis extends beyond the data plotted and that 

582 # the y-ranges of both axes are in sync. 

583 y_max = max(max(ref_y), ax2.get_ylim()[1]) 

584 if ax2.get_ylim()[1] < 1.05 * y_max: 

585 ax.set_ylim(ax.get_ylim()[0], 1.05 * y_max) 

586 ax2.set_ylim(ax.get_ylim()) 

587 ax2.legend(fontsize=legend_font_size, handlelength=1.5, loc="upper right", frameon=False) 

588 

589 return ax 

590 

591 def _addStatisticsPanel( 

592 self, 

593 fig, 

594 handles, 

595 nums, 

596 meds, 

597 mads, 

598 stats_dict, 

599 legend_font_size=8, 

600 yAnchor0=0.0, 

601 nth_row=0, 

602 nth_col=0, 

603 title_str=None, 

604 ): 

605 """Add an adjoining panel containing histogram summary statistics.""" 

606 ax = fig.add_subplot(1, 1, 1) 

607 ax.axis("off") 

608 fig.subplots_adjust(left=0.05, right=0.95, bottom=0.0, top=1.0) 

609 # empty handle, used to populate the bespoke legend layout 

610 empty = Rectangle((0, 0), 1, 1, fc="w", fill=False, edgecolor="none", linewidth=0) 

611 

612 # set up new legend handles and labels 

613 legend_handles = [empty] + handles + ([empty] * 3 * len(handles)) + ([empty] * 3) 

614 

615 legend_labels = ( 

616 ([""] * (len(handles) + 1)) 

617 + [stats_dict["statLabels"][0]] 

618 + [f"{x:.3g}" if abs(x) > 0.01 else f"{x:.2e}" for x in stats_dict["stat1"]] 

619 + [stats_dict["statLabels"][1]] 

620 + [f"{x:.3g}" if abs(x) > 0.01 else f"{x:.2e}" for x in stats_dict["stat2"]] 

621 + [stats_dict["statLabels"][2]] 

622 + [f"{x:.3g}" if abs(x) > 0.01 else f"{x:.2e}" for x in stats_dict["stat3"]] 

623 ) 

624 # Replace "e+0" with "e" and "e-0" with "e-" to save space. 

625 legend_labels = [label.replace("e+0", "e") for label in legend_labels] 

626 legend_labels = [label.replace("e-0", "e-") for label in legend_labels] 

627 

628 # Set the y anchor for the legend such that it roughly lines up with 

629 # the panels. 

630 yAnchor = max(0, yAnchor0 - 0.01) + nth_col * (0.008 + len(nums) * 0.005) * legend_font_size 

631 

632 nth_legend = ax.legend( 

633 legend_handles, 

634 legend_labels, 

635 loc="lower left", 

636 bbox_to_anchor=(-0.25, yAnchor), 

637 ncol=4, 

638 handletextpad=-0.25, 

639 fontsize=legend_font_size, 

640 borderpad=0, 

641 frameon=False, 

642 columnspacing=-0.25, 

643 title=title_str, 

644 title_fontproperties={"weight": "bold", "size": legend_font_size}, 

645 ) 

646 if nth_row + nth_col > 0: 

647 ax.add_artist(nth_legend)