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

185 statements  

« prev     ^ index     » next       coverage.py v7.3.1, created at 2023-09-07 11:42 +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 ...statistics import 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 statistic = Field[str]( 

61 doc="Operation to perform in binned_statistic_2d", 

62 default="mean", 

63 ) 

64 

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

66 self._validateInput(data, **kwargs) 

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

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

69 

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

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

72 check that the data is consistent with Vector 

73 """ 

74 needed = self.getInputSchema(**kwargs) 

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

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

77 }: 

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

79 for name, typ in needed: 

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

81 if isScalar and typ != Scalar: 

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

83 

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

85 base = [] 

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

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

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

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

90 

91 return base 

92 

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

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

95 and some text. 

96 """ 

97 numPoints = len(arr) 

98 if mask is not None: 

99 arr = arr[mask] 

100 med = np.nanmedian(arr) 

101 sigMad = nansigmaMad(arr) 

102 

103 statsText = ( 

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

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

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

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

108 + "{}".format(numPoints) 

109 ) 

110 

111 return med, sigMad, statsText 

112 

113 def makePlot( 

114 self, 

115 data: KeyedData, 

116 camera: Camera, 

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

118 **kwargs, 

119 ) -> Figure: 

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

121 column. 

122 

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

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

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

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

127 selectorActions to decide which points to plot and the 

128 statisticSelector actions to determine which points to use for the 

129 printed statistics. 

130 

131 Parameters 

132 ---------- 

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

134 The catalog to plot the points from. 

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

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

137 plotInfo : `dict` 

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

139 ``"run"`` 

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

141 ``"skymap"`` 

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

143 ``"filter"`` 

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

145 ``"tract"`` 

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

147 ``"bands"`` 

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

149 

150 Returns 

151 ------- 

152 fig : `matplotlib.figure.Figure` 

153 The resulting figure. 

154 """ 

155 if plotInfo is None: 

156 plotInfo = {} 

157 

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

159 noDataFig = Figure() 

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

161 noDataFig = addPlotInfo(noDataFig, plotInfo) 

162 return noDataFig 

163 

164 fig = plt.figure(dpi=300) 

165 ax = fig.add_subplot(111) 

166 

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

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

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

170 for detectorId in detectorIds: 

171 detector = camera[detectorId] 

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

173 

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

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

176 

177 fp_x, fp_y = map.applyForward(points) 

178 focalPlane_x[detectorInd] = fp_x 

179 focalPlane_y[detectorInd] = fp_y 

180 

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

182 # not equal the maximum. 

183 binsx = np.linspace(focalPlane_x.min() - 1e-5, focalPlane_x.max() + 1e-5, self.nBins) 

184 binsy = np.linspace(focalPlane_y.min() - 1e-5, focalPlane_y.max() + 1e-5, self.nBins) 

185 

186 statistic, x_edge, y_edge, binnumber = binned_statistic_2d( 

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

188 ) 

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

190 

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

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

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

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

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

196 

197 median = np.nanmedian(statistic.ravel()) 

198 mad = nansigmaMad(statistic.ravel()) 

199 

200 vmin = median - 2 * mad 

201 vmax = median + 2 * mad 

202 

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

204 

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

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

207 text = cax.text( 

208 0.5, 

209 0.5, 

210 self.zAxisLabel, 

211 color="k", 

212 rotation="vertical", 

213 transform=cax.transAxes, 

214 ha="center", 

215 va="center", 

216 fontsize=10, 

217 ) 

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

219 cax.tick_params(labelsize=7) 

220 

221 ax.set_xlabel(self.xAxisLabel) 

222 ax.set_ylabel(self.yAxisLabel) 

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

224 ax.tick_params(labelsize=7) 

225 

226 ax.set_aspect("equal") 

227 plt.draw() 

228 

229 # Add useful information to the plot 

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

231 fig = plt.gcf() 

232 fig = addPlotInfo(fig, plotInfo) 

233 

234 return fig 

235 

236 

237class FocalPlaneGeometryPlot(FocalPlanePlot): 

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

239 geometry units: amplifiers and detectors. 

240 

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

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

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

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

245 fall upon. 

246 

247 The ``xAxisLabel``, ``yAxisLabel``, ``zAxisLabel``, and 

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

249 """ 

250 

251 level = ChoiceField[str]( 

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

253 default="amplifier", 

254 allowed={ 

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

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

257 }, 

258 ) 

259 

260 def makePlot( 

261 self, 

262 data: KeyedData, 

263 camera: Camera, 

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

265 **kwargs, 

266 ) -> Figure: 

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

268 column. 

269 

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

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

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

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

274 selectorActions to decide which points to plot and the 

275 statisticSelector actions to determine which points to use for the 

276 printed statistics. 

277 

278 Parameters 

279 ---------- 

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

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

282 have the following columns/keys: 

283 ``"detector"`` 

284 The integer detector id for the points. 

285 ``"amplifier"`` 

286 The string amplifier name for the points. 

287 ``"z"`` 

288 The numerical value that will be combined via 

289 ``statistic`` to the binned value. 

290 ``"x"`` 

291 Focal plane x position, optional. 

292 ``"y"`` 

293 Focal plane y position, optional. 

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

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

296 plotInfo : `dict` 

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

298 ``"run"`` 

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

300 ``"skymap"`` 

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

302 ``"filter"`` 

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

304 ``"tract"`` 

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

306 ``"bands"`` 

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

308 

309 Returns 

310 ------- 

311 fig : `matplotlib.figure.Figure` 

312 The resulting figure. 

313 """ 

314 if plotInfo is None: 

315 plotInfo = {} 

316 

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

318 noDataFig = Figure() 

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

320 noDataFig = addPlotInfo(noDataFig, plotInfo) 

321 return noDataFig 

322 

323 fig = plt.figure(dpi=300) 

324 ax = fig.add_subplot(111) 

325 

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

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

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

329 

330 patches = [] 

331 values = [] 

332 

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

334 plotLimit_x = [0.0, 0.0] 

335 plotLimit_y = [0.0, 0.0] 

336 

337 for detectorId in detectorIds: 

338 detector = camera[detectorId] 

339 

340 # We can go stright to fp coordinates. 

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

342 corners = np.array(corners) 

343 

344 # U/V coordinates represent focal plane locations. 

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

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

347 

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

349 if minU < plotLimit_x[0]: 

350 plotLimit_x[0] = minU 

351 if minV < plotLimit_y[0]: 

352 plotLimit_y[0] = minV 

353 if maxU > plotLimit_x[1]: 

354 plotLimit_x[1] = maxU 

355 if maxV > plotLimit_y[1]: 

356 plotLimit_y[1] = maxV 

357 

358 # X/Y coordinates represent detector internal coordinates. 

359 # Detector extent in detector coordinates 

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

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

362 

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

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

365 

366 # This does the appropriate statistic for this 

367 # detector's data. 

368 statistic, _, _ = binned_statistic_dd( 

369 [focalPlane_x[detectorInd], focalPlane_y[detectorInd]], 

370 data["z"][detectorInd], 

371 statistic=self.statistic, 

372 bins=[1, 1], 

373 ) 

374 patches.append(Polygon(corners, True)) 

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

376 else: 

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

378 # plane position of the corners of the detector to 

379 # generate corners for the individual amplifier 

380 # segments. 

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

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

383 

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

385 # coordinates. 

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

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

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

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

390 

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

392 # negative offsets. This corresponds to corners that 

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

394 # coordinates. 

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

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

397 

398 for amplifier in detector: 

399 ampName = amplifier.getName() 

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

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

402 ampInd &= detectorInd 

403 

404 # Determine amplifier extent in X/Y coordinates. 

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

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

407 

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

409 # and the appropriate offset added. 

410 ampCorners = [] 

411 ampCorners.append( 

412 ( 

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

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

415 ) 

416 ) 

417 ampCorners.append( 

418 ( 

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

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

421 ) 

422 ) 

423 ampCorners.append( 

424 ( 

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

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

427 ) 

428 ) 

429 ampCorners.append( 

430 ( 

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

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

433 ) 

434 ) 

435 patches.append(Polygon(ampCorners, True)) 

436 # This does the appropriate statistic for this 

437 # amplifier's data. 

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

439 statistic, _, _ = binned_statistic_dd( 

440 [focalPlane_x[ampInd], focalPlane_y[ampInd]], 

441 data["z"][ampInd], 

442 statistic=self.statistic, 

443 bins=[1, 1], 

444 ) 

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

446 else: 

447 values.append(np.nan) 

448 

449 # Set bounding box for this figure. 

450 ax.set_xlim(plotLimit_x) 

451 ax.set_ylim(plotLimit_y) 

452 

453 # Do not mask values. 

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

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

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

457 

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

459 patchCollection.set_array(values) 

460 ax.add_collection(patchCollection) 

461 

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

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

464 text = cax.text( 

465 0.5, 

466 0.5, 

467 self.zAxisLabel, 

468 color="k", 

469 rotation="vertical", 

470 transform=cax.transAxes, 

471 ha="center", 

472 va="center", 

473 fontsize=10, 

474 ) 

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

476 cax.tick_params(labelsize=7) 

477 

478 ax.set_xlabel(self.xAxisLabel) 

479 ax.set_ylabel(self.yAxisLabel) 

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

481 ax.tick_params(labelsize=7) 

482 

483 ax.set_aspect("equal") 

484 plt.draw() 

485 

486 # Add useful information to the plot 

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

488 fig = plt.gcf() 

489 fig = addPlotInfo(fig, plotInfo) 

490 

491 return fig