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

194 statements  

« prev     ^ index     » next       coverage.py v7.4.0, created at 2024-01-23 13:09 +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 makePlot( 

282 self, 

283 data: KeyedData, 

284 camera: Camera, 

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

286 **kwargs, 

287 ) -> Figure: 

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

289 column. 

290 

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

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

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

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

295 selectorActions to decide which points to plot and the 

296 statisticSelector actions to determine which points to use for the 

297 printed statistics. 

298 

299 Parameters 

300 ---------- 

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

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

303 have the following columns/keys: 

304 

305 ``"detector"`` 

306 The integer detector id for the points. 

307 ``"amplifier"`` 

308 The string amplifier name for the points. 

309 ``"z"`` 

310 The numerical value that will be combined via 

311 ``statistic`` to the binned value. 

312 ``"x"`` 

313 Focal plane x position, optional. 

314 ``"y"`` 

315 Focal plane y position, optional. 

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

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

318 plotInfo : `dict` 

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

320 

321 ``"run"`` 

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

323 ``"skymap"`` 

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

325 ``"filter"`` 

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

327 ``"tract"`` 

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

329 ``"bands"`` 

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

331 

332 Returns 

333 ------- 

334 fig : `matplotlib.figure.Figure` 

335 The resulting figure. 

336 """ 

337 if plotInfo is None: 

338 plotInfo = {} 

339 

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

341 noDataFig = Figure() 

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

343 noDataFig = addPlotInfo(noDataFig, plotInfo) 

344 return noDataFig 

345 

346 fig = plt.figure(dpi=300) 

347 ax = fig.add_subplot(111) 

348 

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

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

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

352 

353 patches = [] 

354 values = [] 

355 

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

357 plotLimit_x = [0.0, 0.0] 

358 plotLimit_y = [0.0, 0.0] 

359 

360 for detectorId in detectorIds: 

361 detector = camera[detectorId] 

362 

363 # We can go stright to fp coordinates. 

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

365 corners = np.array(corners) 

366 

367 # U/V coordinates represent focal plane locations. 

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

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

370 

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

372 if minU < plotLimit_x[0]: 

373 plotLimit_x[0] = minU 

374 if minV < plotLimit_y[0]: 

375 plotLimit_y[0] = minV 

376 if maxU > plotLimit_x[1]: 

377 plotLimit_x[1] = maxU 

378 if maxV > plotLimit_y[1]: 

379 plotLimit_y[1] = maxV 

380 

381 # X/Y coordinates represent detector internal coordinates. 

382 # Detector extent in detector coordinates 

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

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

385 

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

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

388 

389 # This does the appropriate statistic for this 

390 # detector's data. 

391 statistic, _, _ = binned_statistic_dd( 

392 [focalPlane_x[detectorInd], focalPlane_y[detectorInd]], 

393 data["z"][detectorInd], 

394 statistic=self.statistic, 

395 bins=[1, 1], 

396 ) 

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

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

399 else: 

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

401 # plane position of the corners of the detector to 

402 # generate corners for the individual amplifier 

403 # segments. 

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

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

406 

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

408 # coordinates. 

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

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

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

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

413 

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

415 # negative offsets. This corresponds to corners that 

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

417 # coordinates. 

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

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

420 

421 for amplifier in detector: 

422 ampName = amplifier.getName() 

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

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

425 ampInd &= detectorInd 

426 

427 # Determine amplifier extent in X/Y coordinates. 

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

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

430 

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

432 # and the appropriate offset added. 

433 ampCorners = [] 

434 ampCorners.append( 

435 ( 

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

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

438 ) 

439 ) 

440 ampCorners.append( 

441 ( 

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

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

444 ) 

445 ) 

446 ampCorners.append( 

447 ( 

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

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

450 ) 

451 ) 

452 ampCorners.append( 

453 ( 

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

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

456 ) 

457 ) 

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

459 # This does the appropriate statistic for this 

460 # amplifier's data. 

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

462 statistic, _, _ = binned_statistic_dd( 

463 [focalPlane_x[ampInd], focalPlane_y[ampInd]], 

464 data["z"][ampInd], 

465 statistic=self.statistic, 

466 bins=[1, 1], 

467 ) 

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

469 else: 

470 values.append(np.nan) 

471 

472 # Set bounding box for this figure. 

473 ax.set_xlim(plotLimit_x) 

474 ax.set_ylim(plotLimit_y) 

475 

476 # Do not mask values. 

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

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

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

480 

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

482 patchCollection.set_array(values) 

483 ax.add_collection(patchCollection) 

484 

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

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

487 text = cax.text( 

488 0.5, 

489 0.5, 

490 self.zAxisLabel, 

491 color="k", 

492 rotation="vertical", 

493 transform=cax.transAxes, 

494 ha="center", 

495 va="center", 

496 fontsize=10, 

497 ) 

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

499 cax.tick_params(labelsize=7) 

500 

501 ax.set_xlabel(self.xAxisLabel) 

502 ax.set_ylabel(self.yAxisLabel) 

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

504 ax.tick_params(labelsize=7) 

505 

506 ax.set_aspect("equal") 

507 plt.draw() 

508 

509 # Add useful information to the plot 

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

511 fig = plt.gcf() 

512 fig = addPlotInfo(fig, plotInfo) 

513 

514 return fig