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

256 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-05 18:53 +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 

22 

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

24 

25import importlib.resources as importResources 

26import logging 

27from collections import defaultdict 

28from typing import Mapping 

29 

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 

48 

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

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

51from .plotUtils import addPlotInfo 

52 

53log = logging.getLogger(__name__) 

54 

55 

56class HistStatsPanel(Config): 

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

58 shown for histPlot. 

59 

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

61 customize the HistPlot stats panel. 

62 

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

64 latex formating 

65 

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. 

70 

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. 

74 

75 Notes 

76 ----- 

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

78 class. 

79 

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

83 

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 ) 

107 

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

112 

113 

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

119 

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 ) 

186 

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) 

209 

210 

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

216 

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 ) 

228 

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 

233 

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, 

237 

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. 

243 

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

266 

267 Returns 

268 ------- 

269 fig : `matplotlib.figure.Figure` 

270 The resulting figure. 

271 

272 Examples 

273 -------- 

274 An example histogram plot may be seen below: 

275 

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

277 

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

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

280 """ 

281 

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) 

287 

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 ) 

311 

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 

334 

335 # add general plot info 

336 if plotInfo is not None: 

337 hist_fig = addPlotInfo(hist_fig, plotInfo) 

338 

339 # finish up 

340 fig.canvas.draw() 

341 return fig 

342 

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

351 

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

353 

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 

364 

365 return axs, ncols, nrows 

366 

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

424 

425 counter = 0 

426 colors = defaultdict(list) 

427 

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 

433 

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) 

449 

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 

476 

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

497 

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) 

503 

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 

534 

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 

563 

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 

577 

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 

585 

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

594 

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) 

626 

627 return ax 

628 

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) 

649 

650 # set up new legend handles and labels 

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

652 

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] 

665 

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 

669 

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)