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

256 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-01 02:40 -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 importlib.resources as importResources 

26import logging 

27from collections import defaultdict 

28from collections.abc import Mapping 

29 

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 

36 

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 

49 

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

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

52from .plotUtils import addPlotInfo 

53 

54log = logging.getLogger(__name__) 

55 

56 

57class HistStatsPanel(Config): 

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

59 shown for histPlot. 

60 

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

62 customize the HistPlot stats panel. 

63 

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

65 latex formatting 

66 

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. 

71 

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. 

75 

76 Notes 

77 ----- 

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

79 class. 

80 

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 """ 

84 

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 ) 

108 

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") 

113 

114 

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 """ 

120 

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 ) 

187 

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) 

208 

209 

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 """ 

215 

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 

228 def getInputSchema(self) -> KeyedDataSchema: 

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

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

231 yield histData, Vector 

232 

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

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

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

236 

237 def makePlot( 

238 self, 

239 data: KeyedData, 

240 plotInfo: Mapping[str, str] = None, 

241 **kwargs, # type: ignore 

242 ) -> Figure: 

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

244 displayed in each panel. 

245 

246 Parameters 

247 ---------- 

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

249 The catalog to plot the points from. 

250 plotInfo : `dict` 

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

252 `"run"` 

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

254 `"tractTableType"` 

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

256 `"plotName"` 

257 Output plot name (`str`) 

258 `"SN"` 

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

260 `"skymap"` 

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

262 `"tract"` 

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

264 `"bands"` 

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

266 `"visit"` 

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

268 

269 Returns 

270 ------- 

271 fig : `matplotlib.figure.Figure` 

272 The resulting figure. 

273 

274 Examples 

275 -------- 

276 An example histogram plot may be seen below: 

277 

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

279 

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

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

282 """ 

283 

284 # set up figure 

285 fig = make_figure(dpi=300) 

286 set_rubin_plotstyle() 

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

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

289 

290 # loop over each panel; plot histograms 

291 colors = self._assignColors() 

292 nth_panel = len(self.panels) 

293 nth_col = ncols 

294 nth_row = nrows - 1 

295 label_font_size = max(6, 10 - nrows) 

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

297 nth_panel -= 1 

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

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

300 nth_col -= 1 

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

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

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

304 data, 

305 panel, 

306 ax, 

307 colors[panel], 

308 label_font_size=label_font_size, 

309 legend_font_size=legend_font_size, 

310 ncols=ncols, 

311 addThresholds=self.panels[panel].addThresholds, 

312 ) 

313 

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

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

316 all_handles += handles 

317 all_nums += nums 

318 all_meds += meds 

319 all_mads += mads 

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

321 # add side panel; add statistics 

322 self._addStatisticsPanel( 

323 side_fig, 

324 all_handles, 

325 all_nums, 

326 all_meds, 

327 all_mads, 

328 stats_dict, 

329 legend_font_size=legend_font_size, 

330 yAnchor0=ax.get_position().y0, 

331 nth_row=nth_row, 

332 nth_col=nth_col, 

333 title_str=title_str, 

334 ) 

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

336 

337 # add general plot info 

338 if plotInfo is not None: 

339 hist_fig = addPlotInfo(hist_fig, plotInfo) 

340 

341 # finish up 

342 fig.canvas.draw() 

343 return fig 

344 

345 def _makeAxes(self, fig): 

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

347 num_panels = len(self.panels) 

348 if num_panels <= 1: 

349 ncols = 1 

350 else: 

351 ncols = 2 

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

353 

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

355 

356 axs = [] 

357 counter = 0 

358 for row in range(nrows): 

359 for col in range(ncols): 

360 counter += 1 

361 if counter < num_panels: 

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

363 else: 

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

365 break 

366 

367 return axs, ncols, nrows 

368 

369 def _assignColors(self): 

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

371 custom_cmaps = dict( 

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

373 newtab10=[ 

374 "#4e79a7", 

375 "#f28e2b", 

376 "#e15759", 

377 "#76b7b2", 

378 "#59a14f", 

379 "#edc948", 

380 "#b07aa1", 

381 "#ff9da7", 

382 "#9c755f", 

383 "#bab0ac", 

384 ], 

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

386 bright=[ 

387 "#4477AA", 

388 "#EE6677", 

389 "#228833", 

390 "#CCBB44", 

391 "#66CCEE", 

392 "#AA3377", 

393 "#BBBBBB", 

394 ], 

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

396 vibrant=[ 

397 "#EE7733", 

398 "#0077BB", 

399 "#33BBEE", 

400 "#EE3377", 

401 "#CC3311", 

402 "#009988", 

403 "#BBBBBB", 

404 ], 

405 rubin=[ 

406 "#0173B2", 

407 "#DE8F05", 

408 "#029E73", 

409 "#D55E00", 

410 "#CC78BC", 

411 "#CA9161", 

412 "#FBAFE4", 

413 "#949494", 

414 "#ECE133", 

415 "#56B4E9", 

416 ], 

417 bands=[get_multiband_plot_colors()], 

418 ) 

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

420 all_colors = custom_cmaps[self.cmap] 

421 else: 

422 try: 

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

424 except AttributeError: 

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

426 

427 counter = 0 

428 colors = defaultdict(list) 

429 

430 for panel in self.panels: 

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

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

433 counter += 1 

434 return colors 

435 

436 def _makePanel( 

437 self, data, panel, ax, colors, label_font_size=9, legend_font_size=7, ncols=1, addThresholds=False 

438 ): 

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

440 nums, meds, mads = [], [], [] 

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

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

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

444 nums.append(num) 

445 meds.append(med) 

446 mads.append(mad) 

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

448 if self.panels[panel].addThresholds: 

449 metricThresholdFile = importResources.read_text(lsst.analysis.tools, "metricInformation.yaml") 

450 metricDefs = yaml.safe_load(metricThresholdFile) 

451 

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

453 nHist = 0 

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

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

456 if len(hist_data) > 0: 

457 ax.hist( 

458 hist_data, 

459 range=panel_range, 

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

461 histtype="step", 

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

463 lw=2, 

464 color=colors[i], 

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

466 ) 

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

468 if self.panels[panel].addThresholds and hist in metricDefs: 

469 if "lowThreshold" in metricDefs[hist].keys(): 

470 lowThreshold = metricDefs[hist]["lowThreshold"] 

471 if np.isfinite(lowThreshold): 

472 ax.axvline(lowThreshold, color=colors[i]) 

473 if "highThreshold" in metricDefs[hist].keys(): 

474 highThreshold = metricDefs[hist]["highThreshold"] 

475 if np.isfinite(highThreshold): 

476 ax.axvline(highThreshold, color=colors[i]) 

477 nHist += 1 

478 

479 if nHist > 0: 

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

481 ax.set_xlim(panel_range) 

482 # The following accommodates spacing for ranges with large numbers 

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

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

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

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

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

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

489 ax.set_ylabel(y_label, fontsize=label_font_size) 

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

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

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

493 ylims = list(ax.get_ylim()) 

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

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

496 else: 

497 ylims[1] *= 1.1 

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

499 

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

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

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

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

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

505 

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

507 # has been created. 

508 statList = [ 

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

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

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

512 ] 

513 if not any(statList): 

514 stats_dict = { 

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

516 "stat1": nums, 

517 "stat2": meds, 

518 "stat3": mads, 

519 } 

520 elif all(statList): 

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

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

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

524 stats_dict = { 

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

526 "stat1": stat1, 

527 "stat2": stat2, 

528 "stat3": stat3, 

529 } 

530 else: 

531 raise RuntimeError("Invalid configuration of HistStatPanel") 

532 else: 

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

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

535 return nums, meds, mads, stats_dict 

536 

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

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

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

540 rangeType = self.panels[panel].rangeType 

541 lowerRange = self.panels[panel].lowerRange 

542 upperRange = self.panels[panel].upperRange 

543 if rangeType == "percentile": 

544 panel_range = self._getPercentilePanelRange(data, panel) 

545 elif rangeType == "sigmaMad": 

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

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

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

549 # panel. 

550 maxMad = nanMax(mads) 

551 maxMed = nanMax(meds) 

552 minMed = nanMin(meds) 

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

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

555 log.info( 

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

557 "percentile range instead." 

558 ) 

559 panel_range = self._getPercentilePanelRange(data, panel) 

560 elif rangeType == "fixed": 

561 panel_range = [lowerRange, upperRange] 

562 else: 

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

564 return panel_range 

565 

566 def _getPercentilePanelRange(self, data, panel): 

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

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

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

570 data_hist = data[hist] 

571 # TODO: Consider raising instead 

572 if len(data_hist) > 0: 

573 hist_range = np.nanpercentile( 

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

575 ) 

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

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

578 return panel_range 

579 

580 def _calcStats(self, data): 

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

582 deviation of input data.""" 

583 num = len(data) 

584 med = nanMedian(data) 

585 mad = sigmaMad(data) 

586 return num, med, mad 

587 

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

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

590 on the panel. 

591 """ 

592 ax2 = ax.twinx() 

593 ax2.axis("off") 

594 ax2.set_xlim(ax.get_xlim()) 

595 ax2.set_ylim(ax.get_ylim()) 

596 

597 if self.panels[panel].histDensity: 

598 reference_label = None 

599 else: 

600 if self.panels[panel].refRelativeToMedian: 

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

602 reference_label = f"${{\\mu_{{ref}}}}$: {reference_value:10.3F}" 

603 else: 

604 reference_value = self.panels[panel].referenceValue 

605 reference_label = f"${{\\mu_{{ref}}}}$: {reference_value:10.3F}" 

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

607 if ( 

608 self.panels[panel].histDensity 

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

610 and all(np.isfinite(panel_range)) 

611 ): 

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

613 ref_mean = self.panels[panel].referenceValue 

614 ref_std = 1.0 

615 ref_y = ( 

616 1.0 

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

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

619 ) 

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

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

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

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

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

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

626 ax2.set_ylim(ax.get_ylim()) 

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

628 

629 return ax 

630 

631 def _addStatisticsPanel( 

632 self, 

633 fig, 

634 handles, 

635 nums, 

636 meds, 

637 mads, 

638 stats_dict, 

639 legend_font_size=8, 

640 yAnchor0=0.0, 

641 nth_row=0, 

642 nth_col=0, 

643 title_str=None, 

644 ): 

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

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

647 ax.axis("off") 

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

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

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

651 

652 # set up new legend handles and labels 

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

654 

655 legend_labels = ( 

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

657 + [stats_dict["statLabels"][0]] 

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

659 + [stats_dict["statLabels"][1]] 

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

661 + [stats_dict["statLabels"][2]] 

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

663 ) 

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

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

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

667 

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

669 # the panels. 

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

671 

672 nth_legend = ax.legend( 

673 legend_handles, 

674 legend_labels, 

675 loc="lower left", 

676 bbox_to_anchor=(-0.25, yAnchor), 

677 ncol=4, 

678 handletextpad=-0.25, 

679 fontsize=legend_font_size, 

680 borderpad=0, 

681 frameon=False, 

682 columnspacing=-0.25, 

683 title=title_str, 

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

685 ) 

686 if nth_row + nth_col > 0: 

687 ax.add_artist(nth_legend)