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

201 statements  

« prev     ^ index     » next       coverage.py v7.4.1, created at 2024-02-06 12:37 +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.patches import Polygon 

36from scipy.stats import binned_statistic_2d, binned_statistic_dd 

37 

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

39from ...math import nanMedian, nanSigmaMad 

40from .plotUtils import addPlotInfo, sortAllArrays 

41 

42 

43class FocalPlanePlot(PlotAction): 

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

45 

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

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

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

49 focal plane coordinates. 

50 """ 

51 

52 xAxisLabel = Field[str](doc="Label to use for the x axis.", optional=False) 

53 yAxisLabel = Field[str](doc="Label to use for the y axis.", optional=False) 

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

55 

56 nBins = Field[int]( 

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

58 default=200, 

59 ) 

60 doUseAdaptiveBinning = Field[bool]( 

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

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

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

64 default=False, 

65 ) 

66 statistic = Field[str]( 

67 doc="Operation to perform in binned_statistic_2d", 

68 default="mean", 

69 ) 

70 

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

72 self._validateInput(data, **kwargs) 

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

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

75 

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

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

78 check that the data is consistent with Vector 

79 """ 

80 needed = self.getInputSchema(**kwargs) 

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

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

83 }: 

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

85 for name, typ in needed: 

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

87 if isScalar and typ != Scalar: 

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

89 

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

91 base = [] 

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

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

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

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

96 

97 return base 

98 

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

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

101 and some text. 

102 """ 

103 numPoints = len(arr) 

104 if mask is not None: 

105 arr = arr[mask] 

106 med = nanMedian(arr) 

107 sigMad = nanSigmaMad(arr) 

108 

109 statsText = ( 

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

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

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

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

114 + "{}".format(numPoints) 

115 ) 

116 

117 return med, sigMad, statsText 

118 

119 def makePlot( 

120 self, 

121 data: KeyedData, 

122 camera: Camera, 

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

124 **kwargs, 

125 ) -> Figure: 

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

127 column. 

128 

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

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

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

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

133 selectorActions to decide which points to plot and the 

134 statisticSelector actions to determine which points to use for the 

135 printed statistics. 

136 

137 Parameters 

138 ---------- 

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

140 The catalog to plot the points from. 

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

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

143 plotInfo : `dict` 

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

145 

146 ``"run"`` 

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

148 ``"skymap"`` 

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

150 ``"filter"`` 

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

152 ``"tract"`` 

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

154 ``"bands"`` 

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

156 

157 Returns 

158 ------- 

159 fig : `matplotlib.figure.Figure` 

160 The resulting figure. 

161 """ 

162 if plotInfo is None: 

163 plotInfo = {} 

164 

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

166 noDataFig = Figure() 

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

168 noDataFig = addPlotInfo(noDataFig, plotInfo) 

169 return noDataFig 

170 

171 fig = plt.figure(dpi=300) 

172 ax = fig.add_subplot(111) 

173 

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

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

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

177 for detectorId in detectorIds: 

178 detector = camera[detectorId] 

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

180 

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

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

183 

184 fp_x, fp_y = map.applyForward(points) 

185 focalPlane_x[detectorInd] = fp_x 

186 focalPlane_y[detectorInd] = fp_y 

187 

188 if self.doUseAdaptiveBinning: 

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

190 # in regions where there are sources. 

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

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

193 

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

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

196 

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

198 numBins = max(numBins, self.nBins) 

199 else: 

200 numBins = self.nBins 

201 

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

203 # not equal the maximum. 

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

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

206 

207 statistic, x_edge, y_edge, binnumber = binned_statistic_2d( 

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

209 ) 

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

211 

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

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

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

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

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

217 

218 median = nanMedian(statistic.ravel()) 

219 mad = nanSigmaMad(statistic.ravel()) 

220 

221 vmin = median - 2 * mad 

222 vmax = median + 2 * mad 

223 

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

225 

226 cax = fig.add_axes([0.87 + 0.04, 0.11, 0.04, 0.77]) 

227 plt.colorbar(plot, cax=cax, extend="both") 

228 text = cax.text( 

229 0.5, 

230 0.5, 

231 self.zAxisLabel, 

232 color="k", 

233 rotation="vertical", 

234 transform=cax.transAxes, 

235 ha="center", 

236 va="center", 

237 fontsize=10, 

238 ) 

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

240 cax.tick_params(labelsize=7) 

241 

242 ax.set_xlabel(self.xAxisLabel) 

243 ax.set_ylabel(self.yAxisLabel) 

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

245 ax.tick_params(labelsize=7) 

246 

247 ax.set_aspect("equal") 

248 plt.draw() 

249 

250 # Add useful information to the plot 

251 plt.subplots_adjust(wspace=0.0, hspace=0.0, right=0.85) 

252 fig = plt.gcf() 

253 fig = addPlotInfo(fig, plotInfo) 

254 

255 return fig 

256 

257 

258class FocalPlaneGeometryPlot(FocalPlanePlot): 

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

260 geometry units: amplifiers and detectors. 

261 

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

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

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

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

266 fall upon. 

267 

268 The ``xAxisLabel``, ``yAxisLabel``, ``zAxisLabel``, and 

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

270 """ 

271 

272 level = ChoiceField[str]( 

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

274 default="amplifier", 

275 allowed={ 

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

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

278 }, 

279 ) 

280 

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

282 base = [] 

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

284 if self.level == "amplifier": 

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

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

287 

288 return base 

289 

290 def makePlot( 

291 self, 

292 data: KeyedData, 

293 camera: Camera, 

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

295 **kwargs, 

296 ) -> Figure: 

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

298 column. 

299 

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

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

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

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

304 selectorActions to decide which points to plot and the 

305 statisticSelector actions to determine which points to use for the 

306 printed statistics. 

307 

308 Parameters 

309 ---------- 

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

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

312 have the following columns/keys: 

313 

314 ``"detector"`` 

315 The integer detector id for the points. 

316 ``"amplifier"`` 

317 The string amplifier name for the points. 

318 ``"z"`` 

319 The numerical value that will be combined via 

320 ``statistic`` to the binned value. 

321 ``"x"`` 

322 Focal plane x position, optional. 

323 ``"y"`` 

324 Focal plane y position, optional. 

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

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

327 plotInfo : `dict` 

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

329 

330 ``"run"`` 

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

332 ``"skymap"`` 

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

334 ``"filter"`` 

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

336 ``"tract"`` 

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

338 ``"bands"`` 

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

340 

341 Returns 

342 ------- 

343 fig : `matplotlib.figure.Figure` 

344 The resulting figure. 

345 """ 

346 if plotInfo is None: 

347 plotInfo = {} 

348 

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

350 noDataFig = Figure() 

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

352 noDataFig = addPlotInfo(noDataFig, plotInfo) 

353 return noDataFig 

354 

355 fig = plt.figure(dpi=300) 

356 ax = fig.add_subplot(111) 

357 

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

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

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

361 

362 patches = [] 

363 values = [] 

364 

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

366 plotLimit_x = [0.0, 0.0] 

367 plotLimit_y = [0.0, 0.0] 

368 

369 for detectorId in detectorIds: 

370 detector = camera[detectorId] 

371 

372 # We can go stright to fp coordinates. 

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

374 corners = np.array(corners) 

375 

376 # U/V coordinates represent focal plane locations. 

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

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

379 

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

381 if minU < plotLimit_x[0]: 

382 plotLimit_x[0] = minU 

383 if minV < plotLimit_y[0]: 

384 plotLimit_y[0] = minV 

385 if maxU > plotLimit_x[1]: 

386 plotLimit_x[1] = maxU 

387 if maxV > plotLimit_y[1]: 

388 plotLimit_y[1] = maxV 

389 

390 # X/Y coordinates represent detector internal coordinates. 

391 # Detector extent in detector coordinates 

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

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

394 

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

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

397 

398 # This does the appropriate statistic for this 

399 # detector's data. 

400 statistic, _, _ = binned_statistic_dd( 

401 [focalPlane_x[detectorInd], focalPlane_y[detectorInd]], 

402 data["z"][detectorInd], 

403 statistic=self.statistic, 

404 bins=[1, 1], 

405 ) 

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

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

408 else: 

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

410 # plane position of the corners of the detector to 

411 # generate corners for the individual amplifier 

412 # segments. 

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

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

415 

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

417 # coordinates. 

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

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

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

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

422 

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

424 # negative offsets. This corresponds to corners that 

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

426 # coordinates. 

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

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

429 

430 for amplifier in detector: 

431 ampName = amplifier.getName() 

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

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

434 ampInd &= detectorInd 

435 

436 # Determine amplifier extent in X/Y coordinates. 

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

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

439 

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

441 # and the appropriate offset added. 

442 ampCorners = [] 

443 ampCorners.append( 

444 ( 

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

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

447 ) 

448 ) 

449 ampCorners.append( 

450 ( 

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

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

453 ) 

454 ) 

455 ampCorners.append( 

456 ( 

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

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

459 ) 

460 ) 

461 ampCorners.append( 

462 ( 

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

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

465 ) 

466 ) 

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

468 # This does the appropriate statistic for this 

469 # amplifier's data. 

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

471 statistic, _, _ = binned_statistic_dd( 

472 [focalPlane_x[ampInd], focalPlane_y[ampInd]], 

473 data["z"][ampInd], 

474 statistic=self.statistic, 

475 bins=[1, 1], 

476 ) 

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

478 else: 

479 values.append(np.nan) 

480 

481 # Set bounding box for this figure. 

482 ax.set_xlim(plotLimit_x) 

483 ax.set_ylim(plotLimit_y) 

484 

485 # Do not mask values. 

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

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

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

489 

490 patchCollection = PatchCollection(patches, alpha=0.4, edgecolor="black") 

491 patchCollection.set_array(values) 

492 ax.add_collection(patchCollection) 

493 

494 cax = fig.add_axes([0.87 + 0.04, 0.11, 0.04, 0.77]) 

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

496 text = cax.text( 

497 0.5, 

498 0.5, 

499 self.zAxisLabel, 

500 color="k", 

501 rotation="vertical", 

502 transform=cax.transAxes, 

503 ha="center", 

504 va="center", 

505 fontsize=10, 

506 ) 

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

508 cax.tick_params(labelsize=7) 

509 

510 ax.set_xlabel(self.xAxisLabel) 

511 ax.set_ylabel(self.yAxisLabel) 

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

513 ax.tick_params(labelsize=7) 

514 

515 ax.set_aspect("equal") 

516 plt.draw() 

517 

518 # Add useful information to the plot 

519 plt.subplots_adjust(wspace=0.0, hspace=0.0, right=0.85) 

520 fig = plt.gcf() 

521 fig = addPlotInfo(fig, plotInfo) 

522 

523 return fig