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

266 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-25 08:55 +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 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 panelsPerRow = Field[int]( 

228 doc="Maximum number of histogram panels to place in each row. Set to 1 to stack panels vertically.", 

229 default=2, 

230 ) 

231 

232 def validate(self): 

233 super().validate() 

234 if self.panelsPerRow < 1: 

235 msg = "panelsPerRow must be at least 1." 

236 raise FieldValidationError(self.__class__.panelsPerRow, self, msg) 

237 

238 def getInputSchema(self) -> KeyedDataSchema: 

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

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

241 yield histData, Vector 

242 

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

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

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

246 

247 def makePlot( 

248 self, 

249 data: KeyedData, 

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

251 **kwargs, # type: ignore 

252 ) -> Figure: 

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

254 displayed in each panel. 

255 

256 Parameters 

257 ---------- 

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

259 The catalog to plot the points from. 

260 plotInfo : `dict` 

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

262 `"run"` 

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

264 `"tractTableType"` 

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

266 `"plotName"` 

267 Output plot name (`str`) 

268 `"SN"` 

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

270 `"skymap"` 

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

272 `"tract"` 

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

274 `"bands"` 

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

276 `"visit"` 

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

278 

279 Returns 

280 ------- 

281 fig : `matplotlib.figure.Figure` 

282 The resulting figure. 

283 

284 Examples 

285 -------- 

286 An example histogram plot may be seen below: 

287 

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

289 

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

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

292 """ 

293 

294 # set up figure 

295 fig = make_figure(dpi=300) 

296 set_rubin_plotstyle() 

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

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

299 

300 # loop over each panel; plot histograms 

301 colors = self._assignColors() 

302 nth_panel = len(self.panels) 

303 nth_col = ncols 

304 nth_row = nrows - 1 

305 label_font_size = max(6, 10 - ((len(self.panels) + 1) // 2)) 

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

307 nth_panel -= 1 

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

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

310 nth_col -= 1 

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

312 legend_font_size = max(4, int(7 - len(self.panels[panel].hists) / 2 - len(self.panels) / 4)) 

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

314 data, 

315 panel, 

316 ax, 

317 colors[panel], 

318 label_font_size=label_font_size, 

319 legend_font_size=legend_font_size, 

320 ncols=ncols, 

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

322 ) 

323 

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

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

326 all_handles += handles 

327 all_nums += nums 

328 all_meds += meds 

329 all_mads += mads 

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

331 # add side panel; add statistics 

332 self._addStatisticsPanel( 

333 side_fig, 

334 all_handles, 

335 all_nums, 

336 all_meds, 

337 all_mads, 

338 stats_dict, 

339 legend_font_size=legend_font_size, 

340 yAnchor0=ax.get_position().y0, 

341 nth_row=nth_row, 

342 nth_col=nth_col, 

343 title_str=title_str, 

344 ) 

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

346 

347 # add general plot info 

348 if plotInfo is not None: 

349 hist_fig = addPlotInfo(hist_fig, plotInfo) 

350 

351 # finish up 

352 fig.canvas.draw() 

353 return fig 

354 

355 def _makeAxes(self, fig): 

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

357 num_panels = len(self.panels) 

358 if num_panels <= 1: 

359 ncols = 1 

360 else: 

361 ncols = min(self.panelsPerRow, num_panels) 

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

363 

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

365 

366 axs = [] 

367 counter = 0 

368 for row in range(nrows): 

369 for col in range(ncols): 

370 counter += 1 

371 if counter < num_panels: 

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

373 else: 

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

375 break 

376 

377 return axs, ncols, nrows 

378 

379 def _assignColors(self): 

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

381 custom_cmaps = dict( 

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

383 newtab10=[ 

384 "#4e79a7", 

385 "#f28e2b", 

386 "#e15759", 

387 "#76b7b2", 

388 "#59a14f", 

389 "#edc948", 

390 "#b07aa1", 

391 "#ff9da7", 

392 "#9c755f", 

393 "#bab0ac", 

394 ], 

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

396 bright=[ 

397 "#4477AA", 

398 "#EE6677", 

399 "#228833", 

400 "#CCBB44", 

401 "#66CCEE", 

402 "#AA3377", 

403 "#BBBBBB", 

404 ], 

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

406 vibrant=[ 

407 "#EE7733", 

408 "#0077BB", 

409 "#33BBEE", 

410 "#EE3377", 

411 "#CC3311", 

412 "#009988", 

413 "#BBBBBB", 

414 ], 

415 rubin=[ 

416 "#0173B2", 

417 "#DE8F05", 

418 "#029E73", 

419 "#D55E00", 

420 "#CC78BC", 

421 "#CA9161", 

422 "#FBAFE4", 

423 "#949494", 

424 "#ECE133", 

425 "#56B4E9", 

426 ], 

427 bands=[get_multiband_plot_colors()], 

428 ) 

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

430 all_colors = custom_cmaps[self.cmap] 

431 else: 

432 try: 

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

434 except AttributeError: 

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

436 

437 counter = 0 

438 colors = defaultdict(list) 

439 

440 for panel in self.panels: 

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

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

443 counter += 1 

444 return colors 

445 

446 def _makePanel( 

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

448 ): 

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

450 nums, meds, mads = [], [], [] 

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

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

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

454 nums.append(num) 

455 meds.append(med) 

456 mads.append(mad) 

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

458 if self.panels[panel].addThresholds: 

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

460 metricDefs = yaml.safe_load(metricThresholdFile) 

461 

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

463 nHist = 0 

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

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

466 if len(hist_data) > 0: 

467 ax.hist( 

468 hist_data, 

469 range=panel_range, 

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

471 histtype="step", 

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

473 lw=2, 

474 color=colors[i], 

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

476 ) 

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

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

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

480 lowThreshold = metricDefs[hist]["lowThreshold"] 

481 if np.isfinite(lowThreshold): 

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

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

484 highThreshold = metricDefs[hist]["highThreshold"] 

485 if np.isfinite(highThreshold): 

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

487 nHist += 1 

488 

489 if nHist > 0: 

490 ax.legend(fontsize=legend_font_size, loc="upper left", frameon=False, borderaxespad=1.1) 

491 ax.set_xlim(panel_range) 

492 # The following accommodates spacing for ranges with large numbers 

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

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

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

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

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

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

499 ax.set_ylabel(y_label, fontsize=label_font_size) 

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

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

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

503 ylims = list(ax.get_ylim()) 

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

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

506 else: 

507 ylims[1] *= 1.1 

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

509 

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

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

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

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

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

515 

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

517 # has been created. 

518 statList = [ 

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

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

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

522 ] 

523 if not any(statList): 

524 stats_dict = { 

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

526 "stat1": nums, 

527 "stat2": meds, 

528 "stat3": mads, 

529 } 

530 elif all(statList): 

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

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

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

534 stats_dict = { 

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

536 "stat1": stat1, 

537 "stat2": stat2, 

538 "stat3": stat3, 

539 } 

540 else: 

541 raise RuntimeError("Invalid configuration of HistStatPanel") 

542 else: 

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

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

545 return nums, meds, mads, stats_dict 

546 

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

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

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

550 rangeType = self.panels[panel].rangeType 

551 lowerRange = self.panels[panel].lowerRange 

552 upperRange = self.panels[panel].upperRange 

553 if rangeType == "percentile": 

554 panel_range = self._getPercentilePanelRange(data, panel) 

555 elif rangeType == "sigmaMad": 

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

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

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

559 # panel. 

560 maxMad = nanMax(mads) 

561 maxMed = nanMax(meds) 

562 minMed = nanMin(meds) 

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

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

565 log.info( 

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

567 "percentile range instead." 

568 ) 

569 panel_range = self._getPercentilePanelRange(data, panel) 

570 elif rangeType == "fixed": 

571 panel_range = [lowerRange, upperRange] 

572 else: 

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

574 return panel_range 

575 

576 def _getPercentilePanelRange(self, data, panel): 

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

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

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

580 data_hist = data[hist] 

581 # TODO: Consider raising instead 

582 if len(data_hist) > 0: 

583 hist_range = np.nanpercentile( 

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

585 ) 

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

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

588 return panel_range 

589 

590 def _calcStats(self, data): 

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

592 deviation of input data.""" 

593 num = len(data) 

594 med = nanMedian(data) 

595 mad = sigmaMad(data) 

596 return num, med, mad 

597 

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

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

600 on the panel. 

601 """ 

602 ax2 = ax.twinx() 

603 ax2.axis("off") 

604 ax2.set_xlim(ax.get_xlim()) 

605 ax2.set_ylim(ax.get_ylim()) 

606 

607 if self.panels[panel].histDensity: 

608 reference_label = None 

609 else: 

610 if self.panels[panel].refRelativeToMedian: 

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

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

613 else: 

614 reference_value = self.panels[panel].referenceValue 

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

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

617 if ( 

618 self.panels[panel].histDensity 

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

620 and all(np.isfinite(panel_range)) 

621 ): 

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

623 ref_mean = self.panels[panel].referenceValue 

624 ref_std = 1.0 

625 ref_y = ( 

626 1.0 

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

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

629 ) 

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

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

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

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

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

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

636 ax2.set_ylim(ax.get_ylim()) 

637 ax2.legend( 

638 fontsize=legend_font_size, handlelength=1.5, loc="upper right", frameon=False, borderaxespad=1.1 

639 ) 

640 

641 return ax 

642 

643 def _addStatisticsPanel( 

644 self, 

645 fig, 

646 handles, 

647 nums, 

648 meds, 

649 mads, 

650 stats_dict, 

651 legend_font_size=8, 

652 yAnchor0=0.0, 

653 nth_row=0, 

654 nth_col=0, 

655 title_str=None, 

656 ): 

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

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

659 ax.axis("off") 

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

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

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

663 

664 # set up new legend handles and labels 

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

666 

667 def format_stat(x): 

668 if isinstance(x, int): 

669 return str(x) if abs(x) <= 9999 else f"{x:.2e}" 

670 else: 

671 return f"{x:.2f}" if abs(x) < 999 else f"{x:.2e}" 

672 

673 legend_labels = ( 

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

675 + [stats_dict["statLabels"][0]] 

676 + [format_stat(x) for x in stats_dict["stat1"]] 

677 + [stats_dict["statLabels"][1]] 

678 + [format_stat(x) for x in stats_dict["stat2"]] 

679 + [stats_dict["statLabels"][2]] 

680 + [format_stat(x) for x in stats_dict["stat3"]] 

681 ) 

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

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

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

685 

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

687 # the panels. 

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

689 

690 nth_legend = ax.legend( 

691 legend_handles, 

692 legend_labels, 

693 loc="lower left", 

694 bbox_to_anchor=(-0.25, yAnchor), 

695 ncol=4, 

696 handletextpad=-0.25, 

697 fontsize=legend_font_size, 

698 borderpad=0, 

699 frameon=False, 

700 columnspacing=-0.25, 

701 title=title_str, 

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

703 ) 

704 if nth_row + nth_col > 0: 

705 ax.add_artist(nth_legend)