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

242 statements  

« prev     ^ index     » next       coverage.py v7.5.1, created at 2024-05-09 04:18 -0700

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 if len(data["x"]) == 0: 

196 noDataFig = Figure() 

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

198 noDataFig = addPlotInfo(noDataFig, plotInfo) 

199 return noDataFig 

200 

201 if self.addHistogram: 

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

203 else: 

204 fig = plt.figure(dpi=300) 

205 ax = fig.add_subplot(111) 

206 

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

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

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

210 for detectorId in detectorIds: 

211 detector = camera[detectorId] 

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

213 

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

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

216 

217 fp_x, fp_y = map.applyForward(points) 

218 focalPlane_x[detectorInd] = fp_x 

219 focalPlane_y[detectorInd] = fp_y 

220 

221 if self.doUseAdaptiveBinning: 

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

223 # in regions where there are sources. 

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

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

226 

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

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

229 

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

231 numBins = max(numBins, self.nBins) 

232 else: 

233 numBins = self.nBins 

234 

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

236 # not equal the maximum. 

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

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

239 

240 statistic, x_edge, y_edge, binnumber = binned_statistic_2d( 

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

242 ) 

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

244 

245 if self.showStats: 

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

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

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

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

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

251 

252 median = nanMedian(statistic.ravel()) 

253 mad = nanSigmaMad(statistic.ravel()) 

254 

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

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

257 

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

259 

260 divider = make_axes_locatable(ax) 

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

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

263 text = cax.text( 

264 0.5, 

265 0.5, 

266 self.zAxisLabel, 

267 color="k", 

268 rotation="vertical", 

269 transform=cax.transAxes, 

270 ha="center", 

271 va="center", 

272 fontsize=10, 

273 ) 

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

275 cax.tick_params(labelsize=7) 

276 

277 ax.set_xlabel(self.xAxisLabel) 

278 ax.set_ylabel(self.yAxisLabel) 

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

280 ax.tick_params(labelsize=7) 

281 

282 ax.set_aspect("equal") 

283 

284 if self.addHistogram: 

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

286 

287 plt.draw() 

288 

289 # Add useful information to the plot 

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

291 fig = plt.gcf() 

292 if plotInfo: 

293 fig = addPlotInfo(fig, plotInfo) 

294 

295 return fig 

296 

297 

298class FocalPlaneGeometryPlot(FocalPlanePlot): 

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

300 geometry units: amplifiers and detectors. 

301 

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

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

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

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

306 fall upon. 

307 

308 The ``xAxisLabel``, ``yAxisLabel``, ``zAxisLabel``, and 

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

310 """ 

311 

312 level = ChoiceField[str]( 

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

314 default="amplifier", 

315 allowed={ 

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

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

318 }, 

319 ) 

320 

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

322 base = [] 

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

324 if self.level == "amplifier": 

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

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

327 

328 return base 

329 

330 def makePlot( 

331 self, 

332 data: KeyedData, 

333 camera: Camera, 

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

335 **kwargs, 

336 ) -> Figure: 

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

338 column. 

339 

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

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

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

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

344 selectorActions to decide which points to plot and the 

345 statisticSelector actions to determine which points to use for the 

346 printed statistics. 

347 

348 Parameters 

349 ---------- 

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

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

352 have the following columns/keys: 

353 

354 ``"detector"`` 

355 The integer detector id for the points. 

356 ``"amplifier"`` 

357 The string amplifier name for the points. 

358 ``"z"`` 

359 The numerical value that will be combined via 

360 ``statistic`` to the binned value. 

361 ``"x"`` 

362 Focal plane x position, optional. 

363 ``"y"`` 

364 Focal plane y position, optional. 

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

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

367 plotInfo : `dict` 

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

369 

370 ``"run"`` 

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

372 ``"skymap"`` 

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

374 ``"filter"`` 

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

376 ``"tract"`` 

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

378 ``"bands"`` 

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

380 

381 Returns 

382 ------- 

383 fig : `matplotlib.figure.Figure` 

384 The resulting figure. 

385 """ 

386 

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

388 cmap.set_bad(color="none") 

389 

390 if plotInfo is None: 

391 plotInfo = {} 

392 

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

394 noDataFig = Figure() 

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

396 noDataFig = addPlotInfo(noDataFig, plotInfo) 

397 return noDataFig 

398 

399 if self.addHistogram: 

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

401 else: 

402 fig = plt.figure(dpi=300) 

403 ax = fig.add_subplot(111) 

404 

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

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

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

408 

409 patches = [] 

410 values = [] 

411 

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

413 plotLimit_x = [0.0, 0.0] 

414 plotLimit_y = [0.0, 0.0] 

415 

416 for detectorId in detectorIds: 

417 detector = camera[detectorId] 

418 

419 # We can go stright to fp coordinates. 

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

421 corners = np.array(corners) 

422 

423 # U/V coordinates represent focal plane locations. 

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

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

426 

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

428 if minU < plotLimit_x[0]: 

429 plotLimit_x[0] = minU 

430 if minV < plotLimit_y[0]: 

431 plotLimit_y[0] = minV 

432 if maxU > plotLimit_x[1]: 

433 plotLimit_x[1] = maxU 

434 if maxV > plotLimit_y[1]: 

435 plotLimit_y[1] = maxV 

436 

437 # X/Y coordinates represent detector internal coordinates. 

438 # Detector extent in detector coordinates 

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

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

441 

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

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

444 

445 # This does the appropriate statistic for this 

446 # detector's data. 

447 statistic, _, _ = binned_statistic_dd( 

448 [focalPlane_x[detectorInd], focalPlane_y[detectorInd]], 

449 data["z"][detectorInd], 

450 statistic=self.statistic, 

451 bins=[1, 1], 

452 ) 

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

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

455 else: 

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

457 # plane position of the corners of the detector to 

458 # generate corners for the individual amplifier 

459 # segments. 

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

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

462 

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

464 # coordinates. 

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

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

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

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

469 

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

471 # negative offsets. This corresponds to corners that 

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

473 # coordinates. 

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

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

476 

477 for amplifier in detector: 

478 ampName = amplifier.getName() 

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

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

481 ampInd &= detectorInd 

482 

483 # Determine amplifier extent in X/Y coordinates. 

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

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

486 

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

488 # and the appropriate offset added. 

489 ampCorners = [] 

490 ampCorners.append( 

491 ( 

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

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

494 ) 

495 ) 

496 ampCorners.append( 

497 ( 

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

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

500 ) 

501 ) 

502 ampCorners.append( 

503 ( 

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

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

506 ) 

507 ) 

508 ampCorners.append( 

509 ( 

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

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

512 ) 

513 ) 

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

515 # This does the appropriate statistic for this 

516 # amplifier's data. 

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

518 statistic, _, _ = binned_statistic_dd( 

519 [focalPlane_x[ampInd], focalPlane_y[ampInd]], 

520 data["z"][ampInd], 

521 statistic=self.statistic, 

522 bins=[1, 1], 

523 ) 

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

525 else: 

526 values.append(np.nan) 

527 

528 # Set bounding box for this figure. 

529 ax.set_xlim(plotLimit_x) 

530 ax.set_ylim(plotLimit_y) 

531 

532 # Do not mask values. 

533 if self.showStats: 

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

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

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

537 

538 # Defaults to med + 4 sigma Mad to match 

539 # the camera team plots 

540 if self.plotMin is not None: 

541 vmin = self.plotMin 

542 else: 

543 vmin = statMed - 4.0 * statMad 

544 if self.plotMax is not None: 

545 vmax = self.plotMax 

546 else: 

547 vmax = statMed + 4.0 * statMad 

548 

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

550 

551 patchCollection = PatchCollection( 

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

553 ) 

554 patchCollection.set_array(valuesPlot) 

555 ax.add_collection(patchCollection) 

556 

557 divider = make_axes_locatable(ax) 

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

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

560 text = cax.text( 

561 0.5, 

562 0.5, 

563 self.zAxisLabel, 

564 color="k", 

565 rotation="vertical", 

566 transform=cax.transAxes, 

567 ha="center", 

568 va="center", 

569 fontsize=10, 

570 ) 

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

572 cax.tick_params(labelsize=7) 

573 

574 ax.set_xlabel(self.xAxisLabel) 

575 ax.set_ylabel(self.yAxisLabel) 

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

577 ax.tick_params(labelsize=7) 

578 

579 ax.set_aspect("equal") 

580 

581 if self.addHistogram: 

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

583 

584 plt.draw() 

585 

586 # Add useful information to the plot 

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

588 fig = plt.gcf() 

589 if plotInfo: 

590 fig = addPlotInfo(fig, plotInfo) 

591 

592 return fig