Coverage for python / lsst / analysis / tools / actions / plot / focalPlanePlot.py: 13%

244 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-22 09:08 +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/>. 

21 

22from __future__ import annotations 

23 

24__all__ = ("FocalPlanePlot", "FocalPlaneGeometryPlot") 

25 

26from typing import Mapping, Optional 

27 

28import matplotlib.patheffects as pathEffects 

29import matplotlib.pyplot as plt 

30import numpy as np 

31from lsst.afw.cameraGeom import FOCAL_PLANE, PIXELS, Camera 

32from lsst.pex.config import ChoiceField, Field 

33from matplotlib.collections import PatchCollection 

34from matplotlib.figure import Figure 

35from matplotlib.offsetbox import AnchoredText 

36from matplotlib.patches import Polygon 

37from mpl_toolkits.axes_grid1 import make_axes_locatable 

38from scipy.stats import binned_statistic_2d, binned_statistic_dd 

39 

40from ...interfaces import KeyedData, KeyedDataSchema, PlotAction, Scalar, Vector 

41from ...math import nanMax, nanMedian, nanMin, nanSigmaMad 

42from .plotUtils import addPlotInfo, mkColormap, sortAllArrays 

43 

44 

45class FocalPlanePlot(PlotAction): 

46 """Plots the focal plane distribution of a parameter. 

47 

48 Given the detector positions in x and y, the focal plane positions are 

49 calculated using the camera model. A 2d binned statistic (default is mean) 

50 is then calculated and plotted for the parameter z as a function of the 

51 focal plane coordinates. 

52 """ 

53 

54 xAxisLabel = Field[str](doc="Label to use for the x axis.", default="x (mm)", optional=True) 

55 yAxisLabel = Field[str](doc="Label to use for the y axis.", default="y (mm)", optional=True) 

56 zAxisLabel = Field[str](doc="Label to use for the z axis.", optional=False) 

57 nBins = Field[int]( 

58 doc="Number of bins to use within the effective plot ranges along the spatial directions.", 

59 default=200, 

60 ) 

61 doUseAdaptiveBinning = Field[bool]( 

62 doc="If set to True, the number of bins is adapted to the source" 

63 " density, with lower densities using fewer bins. Under these" 

64 " circumstances the nBins parameter sets the minimum number of bins.", 

65 default=False, 

66 ) 

67 statistic = Field[str]( 

68 doc="Operation to perform in binned_statistic_2d", 

69 default="mean", 

70 ) 

71 plotMin = Field[float]( 

72 doc="Minimum in z-value to display in the focal plane plot and in the histogram plot, if applicable", 

73 default=None, 

74 optional=True, 

75 ) 

76 plotMax = Field[float]( 

77 doc="Maximum in z-value to display in the focal plane plot and in the histogram plot, if applicable", 

78 default=None, 

79 optional=True, 

80 ) 

81 showStats = Field[bool](doc="Show statistics for plotted data", default=True) 

82 addHistogram = Field[bool](doc="Add a histogram of all input points", default=False) 

83 histBins = Field[int](doc="Number of bins to use in histogram", default=30) 

84 

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

86 self._validateInput(data, **kwargs) 

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

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

89 

90 def _validateInput(self, data: KeyedData, **kwargs) -> None: 

91 """NOTE currently can only check that something is not a Scalar, not 

92 check that the data is consistent with Vector 

93 """ 

94 needed = self.getInputSchema(**kwargs) 

95 if remainder := {key.format(**kwargs) for key, _ in needed} - { 

96 key.format(**kwargs) for key in data.keys() 

97 }: 

98 raise ValueError(f"Task needs keys {remainder} but they were not found in input") 

99 for name, typ in needed: 

100 isScalar = issubclass((colType := type(data[name.format(**kwargs)])), Scalar) 

101 if isScalar and typ != Scalar: 

102 raise ValueError(f"Data keyed by {name} has type {colType} but action requires type {typ}") 

103 

104 def getInputSchema(self, **kwargs) -> KeyedDataSchema: 

105 base = [] 

106 base.append(("x", Vector)) 

107 base.append(("y", Vector)) 

108 base.append(("z", Vector)) 

109 base.append(("statMask", Vector)) 

110 

111 return base 

112 

113 def statsAndText(self, arr, mask=None): 

114 """Calculate some stats from an array and return them 

115 and some text. 

116 """ 

117 numPoints = len(arr) 

118 if mask is not None: 

119 arr = arr[mask] 

120 med = nanMedian(arr) 

121 sigMad = nanSigmaMad(arr) 

122 

123 statsText = ( 

124 "Median: {:0.2f}\n".format(med) 

125 + r"$\sigma_{MAD}$: " 

126 + "{:0.2f}\n".format(sigMad) 

127 + r"n$_{points}$: " 

128 + "{}".format(numPoints) 

129 ) 

130 

131 return med, sigMad, statsText 

132 

133 def _addHistogram(self, histAx, data): 

134 bins = np.linspace( 

135 (self.plotMin if self.plotMin else nanMin(data.astype(float))), 

136 (self.plotMax if self.plotMax else nanMax(data.astype(float))), 

137 self.histBins, 

138 ) 

139 histAx.hist(data.astype(float), bins=bins) 

140 histAx.set_xlabel(self.zAxisLabel) 

141 histAx.set_ylabel("Bin count") 

142 underflow = np.count_nonzero(data < bins[0]) 

143 overflow = np.count_nonzero(data > bins[-1]) 

144 nonfinite = np.count_nonzero(~np.isfinite(data)) 

145 text = f"Underflow = {underflow}\nOverflow = {overflow}\nNon-Finite = {nonfinite}" 

146 anchored_text = AnchoredText(text, loc=1, pad=0.5) 

147 histAx.add_artist(anchored_text) 

148 

149 def makePlot( 

150 self, 

151 data: KeyedData, 

152 camera: Camera, 

153 plotInfo: Optional[Mapping[str, str]] = None, 

154 **kwargs, 

155 ) -> Figure: 

156 """Prep the catalogue and then make a focalPlanePlot of the given 

157 column. 

158 

159 Uses the axisLabels config options `x` and `y` to make an image, where 

160 the color corresponds to the 2d binned statistic (the mean is the 

161 default) applied to the `z` column. A summary panel is shown in the 

162 upper right corner of the resultant plot. The code uses the 

163 selectorActions to decide which points to plot and the 

164 statisticSelector actions to determine which points to use for the 

165 printed statistics. 

166 

167 Parameters 

168 ---------- 

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

170 The catalog to plot the points from. 

171 camera : `lsst.afw.cameraGeom.Camera` 

172 The camera used to map from pixel to focal plane positions. 

173 plotInfo : `dict` 

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

175 

176 ``"run"`` 

177 The output run for the plots (`str`). 

178 ``"skymap"`` 

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

180 ``"filter"`` 

181 The filter used for this data (`str`). 

182 ``"tract"`` 

183 The tract that the data comes from (`str`). 

184 ``"bands"`` 

185 The band(s) that the data comes from (`list` of `str`). 

186 

187 Returns 

188 ------- 

189 fig : `matplotlib.figure.Figure` 

190 The resulting figure. 

191 """ 

192 if plotInfo is None: 

193 plotInfo = {} 

194 

195 # This is a little hacky but is the easiest way to solve 

196 # the bands being printed on the plot correctly. 

197 if "band" in plotInfo.keys(): 

198 plotInfo["bands"] = [plotInfo["band"]] 

199 

200 if len(data["x"]) == 0: 

201 noDataFig = Figure() 

202 noDataFig.text(0.3, 0.5, "No data to plot after selectors applied") 

203 noDataFig = addPlotInfo(noDataFig, plotInfo) 

204 return noDataFig 

205 

206 if self.addHistogram: 

207 fig, [ax, histAx] = plt.subplots(1, 2, dpi=300, figsize=(12, 6), width_ratios=[3, 2]) 

208 else: 

209 fig = plt.figure(dpi=300) 

210 ax = fig.add_subplot(111) 

211 

212 detectorIds = np.unique(data["detector"]) 

213 focalPlane_x = np.zeros(len(data["x"])) 

214 focalPlane_y = np.zeros(len(data["y"])) 

215 for detectorId in detectorIds: 

216 detector = camera[detectorId] 

217 map = detector.getTransform(PIXELS, FOCAL_PLANE).getMapping() 

218 

219 detectorInd = data["detector"] == detectorId 

220 points = np.array([data["x"][detectorInd], data["y"][detectorInd]]) 

221 

222 fp_x, fp_y = map.applyForward(points) 

223 focalPlane_x[detectorInd] = fp_x 

224 focalPlane_y[detectorInd] = fp_y 

225 

226 if self.doUseAdaptiveBinning: 

227 # Use a course 32x32 binning to determine the mean source density 

228 # in regions where there are sources. 

229 binsx = np.linspace(focalPlane_x.min() - 1e-5, focalPlane_x.max() + 1e-5, 33) 

230 binsy = np.linspace(focalPlane_y.min() - 1e-5, focalPlane_y.max() + 1e-5, 33) 

231 

232 binnedNumSrc = np.histogram2d(focalPlane_x, focalPlane_y, bins=[binsx, binsy])[0] 

233 meanSrcDensity = np.mean(binnedNumSrc, where=binnedNumSrc > 0.0) 

234 

235 numBins = int(np.round(16.0 * np.sqrt(meanSrcDensity))) 

236 numBins = max(numBins, self.nBins) 

237 else: 

238 numBins = self.nBins 

239 

240 # Add an arbitrary small offset to bins to ensure that the minimum does 

241 # not equal the maximum. 

242 binsx = np.linspace(focalPlane_x.min() - 1e-5, focalPlane_x.max() + 1e-5, numBins) 

243 binsy = np.linspace(focalPlane_y.min() - 1e-5, focalPlane_y.max() + 1e-5, numBins) 

244 

245 statistic, x_edge, y_edge, binnumber = binned_statistic_2d( 

246 focalPlane_x, focalPlane_y, data["z"], statistic=self.statistic, bins=[binsx, binsy] 

247 ) 

248 binExtent = [x_edge[0], x_edge[-1], y_edge[0], y_edge[-1]] 

249 

250 if self.showStats: 

251 sortedArrs = sortAllArrays([data["z"], data["x"], data["y"], data["statMask"]]) 

252 [colorVals, xs, ys, stat] = sortedArrs 

253 statMed, statMad, statsText = self.statsAndText(colorVals, mask=stat) 

254 bbox = dict(facecolor="paleturquoise", alpha=0.5, edgecolor="none") 

255 ax.text(0.8, 0.91, statsText, transform=fig.transFigure, fontsize=8, bbox=bbox) 

256 

257 median = nanMedian(statistic.ravel()) 

258 mad = nanSigmaMad(statistic.ravel()) 

259 

260 vmin = self.plotMin if (self.plotMin is not None) else (median - 2 * mad) 

261 vmax = self.plotMax if (self.plotMax is not None) else (median + 2 * mad) 

262 

263 plot = ax.imshow(statistic.T, extent=binExtent, vmin=vmin, vmax=vmax, origin="lower") 

264 

265 divider = make_axes_locatable(ax) 

266 cax = divider.append_axes("right", size="5%", pad=0.05) 

267 fig.colorbar(plot, cax=cax, extend="both") 

268 text = cax.text( 

269 0.5, 

270 0.5, 

271 self.zAxisLabel, 

272 color="k", 

273 rotation="vertical", 

274 transform=cax.transAxes, 

275 ha="center", 

276 va="center", 

277 fontsize=10, 

278 ) 

279 text.set_path_effects([pathEffects.Stroke(linewidth=3, foreground="w"), pathEffects.Normal()]) 

280 cax.tick_params(labelsize=7) 

281 

282 ax.set_xlabel(self.xAxisLabel) 

283 ax.set_ylabel(self.yAxisLabel) 

284 ax.tick_params(axis="x", labelrotation=25) 

285 ax.tick_params(labelsize=7) 

286 

287 ax.set_aspect("equal") 

288 

289 if self.addHistogram: 

290 self._addHistogram(histAx, data["z"]) 

291 

292 plt.draw() 

293 

294 # Add useful information to the plot 

295 plt.subplots_adjust(left=0.05, right=0.95) 

296 fig = plt.gcf() 

297 if plotInfo: 

298 fig = addPlotInfo(fig, plotInfo) 

299 

300 return fig 

301 

302 

303class FocalPlaneGeometryPlot(FocalPlanePlot): 

304 """Plots the focal plane distribution of a parameter in afw camera 

305 geometry units: amplifiers and detectors. 

306 

307 Given the detector positions in x and y, the focal plane positions 

308 are calculated using the camera model. A 2d binned statistic 

309 (default is mean) is then calculated and plotted for the parameter 

310 z as a function of the camera geometry segment the input points 

311 fall upon. 

312 

313 The ``xAxisLabel``, ``yAxisLabel``, ``zAxisLabel``, and 

314 ``statistic`` variables are inherited from the parent class. 

315 """ 

316 

317 level = ChoiceField[str]( 

318 doc="Which geometry level should values be plotted?", 

319 default="amplifier", 

320 allowed={ 

321 "amplifier": "Plot values per readout amplifier.", 

322 "detector": "Plot values per detector.", 

323 }, 

324 ) 

325 

326 def getInputSchema(self, **kwargs) -> KeyedDataSchema: 

327 base = [] 

328 base.append(("detector", Vector)) 

329 if self.level == "amplifier": 

330 base.append(("amplifier", Vector)) 

331 base.append(("z", Vector)) 

332 

333 return base 

334 

335 def makePlot( 

336 self, 

337 data: KeyedData, 

338 camera: Camera, 

339 plotInfo: Optional[Mapping[str, str]] = None, 

340 **kwargs, 

341 ) -> Figure: 

342 """Prep the catalogue and then make a focalPlanePlot of the given 

343 column. 

344 

345 Uses the axisLabels config options `x` and `y` to make an image, where 

346 the color corresponds to the 2d binned statistic (the mean is the 

347 default) applied to the `z` column. A summary panel is shown in the 

348 upper right corner of the resultant plot. The code uses the 

349 selectorActions to decide which points to plot and the 

350 statisticSelector actions to determine which points to use for the 

351 printed statistics. 

352 

353 Parameters 

354 ---------- 

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

356 The catalog to plot the points from. This is expected to 

357 have the following columns/keys: 

358 

359 ``"detector"`` 

360 The integer detector id for the points. 

361 ``"amplifier"`` 

362 The string amplifier name for the points. 

363 ``"z"`` 

364 The numerical value that will be combined via 

365 ``statistic`` to the binned value. 

366 ``"x"`` 

367 Focal plane x position, optional. 

368 ``"y"`` 

369 Focal plane y position, optional. 

370 camera : `lsst.afw.cameraGeom.Camera` 

371 The camera used to map from pixel to focal plane positions. 

372 plotInfo : `dict` 

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

374 

375 ``"run"`` 

376 The output run for the plots (`str`). 

377 ``"skymap"`` 

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

379 ``"filter"`` 

380 The filter used for this data (`str`). 

381 ``"tract"`` 

382 The tract that the data comes from (`str`). 

383 ``"bands"`` 

384 The band(s) that the data comes from (`list` of `str`). 

385 

386 Returns 

387 ------- 

388 fig : `matplotlib.figure.Figure` 

389 The resulting figure. 

390 """ 

391 

392 cmap = mkColormap(["midnightBlue", "lightcyan", "darkgreen"]) 

393 cmap.set_bad(color="none") 

394 

395 if plotInfo is None: 

396 plotInfo = {} 

397 

398 if len(data["z"]) == 0: 

399 noDataFig = Figure() 

400 noDataFig.text(0.3, 0.5, "No data to plot after selectors applied") 

401 noDataFig = addPlotInfo(noDataFig, plotInfo) 

402 return noDataFig 

403 

404 if self.addHistogram: 

405 fig, [ax, histAx] = plt.subplots(1, 2, dpi=300, figsize=(12, 6), width_ratios=[3, 2]) 

406 else: 

407 fig = plt.figure(dpi=300) 

408 ax = fig.add_subplot(111) 

409 

410 detectorIds = np.unique(data["detector"]) 

411 focalPlane_x = np.zeros(len(data["z"])) 

412 focalPlane_y = np.zeros(len(data["z"])) 

413 

414 patches = [] 

415 values = [] 

416 

417 # Plot bounding box that will be used to set the axes below. 

418 plotLimit_x = [0.0, 0.0] 

419 plotLimit_y = [0.0, 0.0] 

420 

421 for detectorId in detectorIds: 

422 detector = camera[detectorId] 

423 

424 # We can go stright to fp coordinates. 

425 corners = [(c.getX(), c.getY()) for c in detector.getCorners(FOCAL_PLANE)] 

426 corners = np.array(corners) 

427 

428 # U/V coordinates represent focal plane locations. 

429 minU, minV = corners.min(axis=0) 

430 maxU, maxV = corners.max(axis=0) 

431 

432 # See if the plot bounding box needs to be extended: 

433 if minU < plotLimit_x[0]: 

434 plotLimit_x[0] = minU 

435 if minV < plotLimit_y[0]: 

436 plotLimit_y[0] = minV 

437 if maxU > plotLimit_x[1]: 

438 plotLimit_x[1] = maxU 

439 if maxV > plotLimit_y[1]: 

440 plotLimit_y[1] = maxV 

441 

442 # X/Y coordinates represent detector internal coordinates. 

443 # Detector extent in detector coordinates 

444 minX, minY = detector.getBBox().getMin() 

445 maxX, maxY = detector.getBBox().getMax() 

446 

447 if self.level.lower() == "detector": 

448 detectorInd = data["detector"] == detectorId 

449 

450 # This does the appropriate statistic for this 

451 # detector's data. 

452 statistic, _, _ = binned_statistic_dd( 

453 [focalPlane_x[detectorInd], focalPlane_y[detectorInd]], 

454 data["z"][detectorInd], 

455 statistic=self.statistic, 

456 bins=[1, 1], 

457 ) 

458 patches.append(Polygon(corners, closed=True)) 

459 values.append(statistic.ravel()[0]) 

460 else: 

461 # It's at amplifier level. This uses the focal 

462 # plane position of the corners of the detector to 

463 # generate corners for the individual amplifier 

464 # segments. 

465 rotation = detector.getOrientation().getNQuarter() # N * 90 degrees. 

466 alpha, beta = np.cos(rotation * np.pi / 2.0), np.sin(rotation * np.pi / 2.0) 

467 

468 # Calculate the rotation matrix between X/Y and U/V 

469 # coordinates. 

470 scaleUX = alpha * (maxU - minU) / (maxX - minX) 

471 scaleVX = beta * (maxV - minV) / (maxX - minX) 

472 scaleVY = alpha * (maxV - minV) / (maxY - minY) 

473 scaleUY = beta * (maxU - minU) / (maxY - minY) 

474 

475 # After the rotation, some of the corners may have 

476 # negative offsets. This corresponds to corners that 

477 # reference the maximum edges of the box in U/V 

478 # coordinates. 

479 baseU = minU if rotation % 4 in (0, 1) else maxU 

480 baseV = maxV if rotation % 4 in (2, 3) else minV 

481 

482 for amplifier in detector: 

483 ampName = amplifier.getName() 

484 detectorInd = data["detector"] == detectorId 

485 ampInd = data["amplifier"] == ampName 

486 ampInd &= detectorInd 

487 

488 # Determine amplifier extent in X/Y coordinates. 

489 ampMinX, ampMinY = amplifier.getBBox().getMin() 

490 ampMaxX, ampMaxY = amplifier.getBBox().getMax() 

491 

492 # The corners are rotated into U/V coordinates, 

493 # and the appropriate offset added. 

494 ampCorners = [] 

495 ampCorners.append( 

496 ( 

497 scaleUX * (ampMinX - minX) + scaleUY * (ampMinY - minY) + baseU, 

498 scaleVY * (ampMinY - minY) + scaleVX * (ampMinX - minX) + baseV, 

499 ) 

500 ) 

501 ampCorners.append( 

502 ( 

503 scaleUX * (ampMaxX - minX) + scaleUY * (ampMaxY - minY) + baseU, 

504 scaleVY * (ampMinY - minY) + scaleVX * (ampMinX - minX) + baseV, 

505 ) 

506 ) 

507 ampCorners.append( 

508 ( 

509 scaleUX * (ampMaxX - minX) + scaleUY * (ampMaxY - minY) + baseU, 

510 scaleVY * (ampMaxY - minY) + scaleVX * (ampMaxX - minX) + baseV, 

511 ) 

512 ) 

513 ampCorners.append( 

514 ( 

515 scaleUX * (ampMinX - minX) + scaleUY * (ampMinY - minY) + baseU, 

516 scaleVY * (ampMaxY - minY) + scaleVX * (ampMaxX - minX) + baseV, 

517 ) 

518 ) 

519 patches.append(Polygon(ampCorners, closed=True)) 

520 # This does the appropriate statistic for this 

521 # amplifier's data. 

522 if len(data["z"][ampInd]) > 0: 

523 statistic, _, _ = binned_statistic_dd( 

524 [focalPlane_x[ampInd], focalPlane_y[ampInd]], 

525 data["z"][ampInd], 

526 statistic=self.statistic, 

527 bins=[1, 1], 

528 ) 

529 values.append(statistic.ravel()[0]) 

530 else: 

531 values.append(np.nan) 

532 

533 # Set bounding box for this figure. 

534 ax.set_xlim(plotLimit_x) 

535 ax.set_ylim(plotLimit_y) 

536 

537 # Do not mask values. 

538 if self.showStats: 

539 statMed, statMad, statsText = self.statsAndText(values, mask=None) 

540 bbox = dict(facecolor="paleturquoise", alpha=0.5, edgecolor="none") 

541 ax.text(0.8, 0.91, statsText, transform=fig.transFigure, fontsize=8, bbox=bbox) 

542 

543 # Defaults to med + 4 sigma Mad to match 

544 # the camera team plots 

545 if self.plotMin is not None: 

546 vmin = self.plotMin 

547 else: 

548 vmin = statMed - 4.0 * statMad 

549 if self.plotMax is not None: 

550 vmax = self.plotMax 

551 else: 

552 vmax = statMed + 4.0 * statMad 

553 

554 valuesPlot = np.clip(values, vmin, vmax) 

555 

556 patchCollection = PatchCollection( 

557 patches, edgecolor="white", cmap=cmap, linewidth=0.5, linestyle=(0, (0.5, 3)) 

558 ) 

559 patchCollection.set_array(valuesPlot) 

560 ax.add_collection(patchCollection) 

561 

562 divider = make_axes_locatable(ax) 

563 cax = divider.append_axes("right", size="5%", pad=0.05) 

564 fig.colorbar(patchCollection, cax=cax, extend="both") 

565 text = cax.text( 

566 0.5, 

567 0.5, 

568 self.zAxisLabel, 

569 color="k", 

570 rotation="vertical", 

571 transform=cax.transAxes, 

572 ha="center", 

573 va="center", 

574 fontsize=10, 

575 ) 

576 text.set_path_effects([pathEffects.Stroke(linewidth=3, foreground="w"), pathEffects.Normal()]) 

577 cax.tick_params(labelsize=7) 

578 

579 ax.set_xlabel(self.xAxisLabel) 

580 ax.set_ylabel(self.yAxisLabel) 

581 ax.tick_params(axis="x", labelrotation=25) 

582 ax.tick_params(labelsize=7) 

583 

584 ax.set_aspect("equal") 

585 

586 if self.addHistogram: 

587 self._addHistogram(histAx, data["z"]) 

588 

589 plt.draw() 

590 

591 # Add useful information to the plot 

592 fig.subplots_adjust(left=0.05, right=0.95) 

593 fig = plt.gcf() 

594 if plotInfo: 

595 fig = addPlotInfo(fig, plotInfo) 

596 

597 return fig