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

240 statements  

« prev     ^ index     » next       coverage.py v7.4.3, created at 2024-02-24 11:17 +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 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 fig = addPlotInfo(fig, plotInfo) 

293 

294 return fig 

295 

296 

297class FocalPlaneGeometryPlot(FocalPlanePlot): 

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

299 geometry units: amplifiers and detectors. 

300 

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

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

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

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

305 fall upon. 

306 

307 The ``xAxisLabel``, ``yAxisLabel``, ``zAxisLabel``, and 

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

309 """ 

310 

311 level = ChoiceField[str]( 

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

313 default="amplifier", 

314 allowed={ 

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

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

317 }, 

318 ) 

319 

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

321 base = [] 

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

323 if self.level == "amplifier": 

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

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

326 

327 return base 

328 

329 def makePlot( 

330 self, 

331 data: KeyedData, 

332 camera: Camera, 

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

334 **kwargs, 

335 ) -> Figure: 

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

337 column. 

338 

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

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

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

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

343 selectorActions to decide which points to plot and the 

344 statisticSelector actions to determine which points to use for the 

345 printed statistics. 

346 

347 Parameters 

348 ---------- 

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

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

351 have the following columns/keys: 

352 

353 ``"detector"`` 

354 The integer detector id for the points. 

355 ``"amplifier"`` 

356 The string amplifier name for the points. 

357 ``"z"`` 

358 The numerical value that will be combined via 

359 ``statistic`` to the binned value. 

360 ``"x"`` 

361 Focal plane x position, optional. 

362 ``"y"`` 

363 Focal plane y position, optional. 

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

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

366 plotInfo : `dict` 

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

368 

369 ``"run"`` 

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

371 ``"skymap"`` 

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

373 ``"filter"`` 

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

375 ``"tract"`` 

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

377 ``"bands"`` 

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

379 

380 Returns 

381 ------- 

382 fig : `matplotlib.figure.Figure` 

383 The resulting figure. 

384 """ 

385 

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

387 cmap.set_bad(color="none") 

388 

389 if plotInfo is None: 

390 plotInfo = {} 

391 

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

393 noDataFig = Figure() 

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

395 noDataFig = addPlotInfo(noDataFig, plotInfo) 

396 return noDataFig 

397 

398 if self.addHistogram: 

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

400 else: 

401 fig = plt.figure(dpi=300) 

402 ax = fig.add_subplot(111) 

403 

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

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

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

407 

408 patches = [] 

409 values = [] 

410 

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

412 plotLimit_x = [0.0, 0.0] 

413 plotLimit_y = [0.0, 0.0] 

414 

415 for detectorId in detectorIds: 

416 detector = camera[detectorId] 

417 

418 # We can go stright to fp coordinates. 

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

420 corners = np.array(corners) 

421 

422 # U/V coordinates represent focal plane locations. 

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

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

425 

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

427 if minU < plotLimit_x[0]: 

428 plotLimit_x[0] = minU 

429 if minV < plotLimit_y[0]: 

430 plotLimit_y[0] = minV 

431 if maxU > plotLimit_x[1]: 

432 plotLimit_x[1] = maxU 

433 if maxV > plotLimit_y[1]: 

434 plotLimit_y[1] = maxV 

435 

436 # X/Y coordinates represent detector internal coordinates. 

437 # Detector extent in detector coordinates 

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

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

440 

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

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

443 

444 # This does the appropriate statistic for this 

445 # detector's data. 

446 statistic, _, _ = binned_statistic_dd( 

447 [focalPlane_x[detectorInd], focalPlane_y[detectorInd]], 

448 data["z"][detectorInd], 

449 statistic=self.statistic, 

450 bins=[1, 1], 

451 ) 

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

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

454 else: 

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

456 # plane position of the corners of the detector to 

457 # generate corners for the individual amplifier 

458 # segments. 

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

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

461 

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

463 # coordinates. 

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

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

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

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

468 

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

470 # negative offsets. This corresponds to corners that 

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

472 # coordinates. 

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

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

475 

476 for amplifier in detector: 

477 ampName = amplifier.getName() 

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

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

480 ampInd &= detectorInd 

481 

482 # Determine amplifier extent in X/Y coordinates. 

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

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

485 

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

487 # and the appropriate offset added. 

488 ampCorners = [] 

489 ampCorners.append( 

490 ( 

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

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

493 ) 

494 ) 

495 ampCorners.append( 

496 ( 

497 scaleUX * (ampMaxX - minX) + scaleUY * (ampMaxY - 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 * (ampMaxY - minY) + scaleVX * (ampMaxX - minX) + baseV, 

505 ) 

506 ) 

507 ampCorners.append( 

508 ( 

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

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

511 ) 

512 ) 

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

514 # This does the appropriate statistic for this 

515 # amplifier's data. 

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

517 statistic, _, _ = binned_statistic_dd( 

518 [focalPlane_x[ampInd], focalPlane_y[ampInd]], 

519 data["z"][ampInd], 

520 statistic=self.statistic, 

521 bins=[1, 1], 

522 ) 

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

524 else: 

525 values.append(np.nan) 

526 

527 # Set bounding box for this figure. 

528 ax.set_xlim(plotLimit_x) 

529 ax.set_ylim(plotLimit_y) 

530 

531 # Do not mask values. 

532 if self.showStats: 

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

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

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

536 

537 # Defaults to med + 4 sigma Mad to match 

538 # the camera team plots 

539 if self.plotMin is not None: 

540 vmin = self.plotMin 

541 else: 

542 vmin = statMed - 4.0 * statMad 

543 if self.plotMax is not None: 

544 vmax = self.plotMax 

545 else: 

546 vmax = statMed + 4.0 * statMad 

547 

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

549 

550 patchCollection = PatchCollection( 

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

552 ) 

553 patchCollection.set_array(valuesPlot) 

554 ax.add_collection(patchCollection) 

555 

556 divider = make_axes_locatable(ax) 

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

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

559 text = cax.text( 

560 0.5, 

561 0.5, 

562 self.zAxisLabel, 

563 color="k", 

564 rotation="vertical", 

565 transform=cax.transAxes, 

566 ha="center", 

567 va="center", 

568 fontsize=10, 

569 ) 

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

571 cax.tick_params(labelsize=7) 

572 

573 ax.set_xlabel(self.xAxisLabel) 

574 ax.set_ylabel(self.yAxisLabel) 

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

576 ax.tick_params(labelsize=7) 

577 

578 ax.set_aspect("equal") 

579 

580 if self.addHistogram: 

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

582 

583 plt.draw() 

584 

585 # Add useful information to the plot 

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

587 fig = plt.gcf() 

588 fig = addPlotInfo(fig, plotInfo) 

589 

590 return fig