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

185 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-11-29 11:31 +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 

140 ``"run"`` 

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

142 ``"skymap"`` 

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

144 ``"filter"`` 

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

146 ``"tract"`` 

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

148 ``"bands"`` 

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

150 

151 Returns 

152 ------- 

153 fig : `matplotlib.figure.Figure` 

154 The resulting figure. 

155 """ 

156 if plotInfo is None: 

157 plotInfo = {} 

158 

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

160 noDataFig = Figure() 

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

162 noDataFig = addPlotInfo(noDataFig, plotInfo) 

163 return noDataFig 

164 

165 fig = plt.figure(dpi=300) 

166 ax = fig.add_subplot(111) 

167 

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

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

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

171 for detectorId in detectorIds: 

172 detector = camera[detectorId] 

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

174 

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

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

177 

178 fp_x, fp_y = map.applyForward(points) 

179 focalPlane_x[detectorInd] = fp_x 

180 focalPlane_y[detectorInd] = fp_y 

181 

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

183 # not equal the maximum. 

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

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

186 

187 statistic, x_edge, y_edge, binnumber = binned_statistic_2d( 

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

189 ) 

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

191 

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

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

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

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

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

197 

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

199 mad = nansigmaMad(statistic.ravel()) 

200 

201 vmin = median - 2 * mad 

202 vmax = median + 2 * mad 

203 

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

205 

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

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

208 text = cax.text( 

209 0.5, 

210 0.5, 

211 self.zAxisLabel, 

212 color="k", 

213 rotation="vertical", 

214 transform=cax.transAxes, 

215 ha="center", 

216 va="center", 

217 fontsize=10, 

218 ) 

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

220 cax.tick_params(labelsize=7) 

221 

222 ax.set_xlabel(self.xAxisLabel) 

223 ax.set_ylabel(self.yAxisLabel) 

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

225 ax.tick_params(labelsize=7) 

226 

227 ax.set_aspect("equal") 

228 plt.draw() 

229 

230 # Add useful information to the plot 

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

232 fig = plt.gcf() 

233 fig = addPlotInfo(fig, plotInfo) 

234 

235 return fig 

236 

237 

238class FocalPlaneGeometryPlot(FocalPlanePlot): 

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

240 geometry units: amplifiers and detectors. 

241 

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

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

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

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

246 fall upon. 

247 

248 The ``xAxisLabel``, ``yAxisLabel``, ``zAxisLabel``, and 

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

250 """ 

251 

252 level = ChoiceField[str]( 

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

254 default="amplifier", 

255 allowed={ 

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

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

258 }, 

259 ) 

260 

261 def makePlot( 

262 self, 

263 data: KeyedData, 

264 camera: Camera, 

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

266 **kwargs, 

267 ) -> Figure: 

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

269 column. 

270 

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

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

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

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

275 selectorActions to decide which points to plot and the 

276 statisticSelector actions to determine which points to use for the 

277 printed statistics. 

278 

279 Parameters 

280 ---------- 

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

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

283 have the following columns/keys: 

284 

285 ``"detector"`` 

286 The integer detector id for the points. 

287 ``"amplifier"`` 

288 The string amplifier name for the points. 

289 ``"z"`` 

290 The numerical value that will be combined via 

291 ``statistic`` to the binned value. 

292 ``"x"`` 

293 Focal plane x position, optional. 

294 ``"y"`` 

295 Focal plane y position, optional. 

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

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

298 plotInfo : `dict` 

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

300 

301 ``"run"`` 

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

303 ``"skymap"`` 

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

305 ``"filter"`` 

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

307 ``"tract"`` 

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

309 ``"bands"`` 

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

311 

312 Returns 

313 ------- 

314 fig : `matplotlib.figure.Figure` 

315 The resulting figure. 

316 """ 

317 if plotInfo is None: 

318 plotInfo = {} 

319 

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

321 noDataFig = Figure() 

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

323 noDataFig = addPlotInfo(noDataFig, plotInfo) 

324 return noDataFig 

325 

326 fig = plt.figure(dpi=300) 

327 ax = fig.add_subplot(111) 

328 

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

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

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

332 

333 patches = [] 

334 values = [] 

335 

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

337 plotLimit_x = [0.0, 0.0] 

338 plotLimit_y = [0.0, 0.0] 

339 

340 for detectorId in detectorIds: 

341 detector = camera[detectorId] 

342 

343 # We can go stright to fp coordinates. 

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

345 corners = np.array(corners) 

346 

347 # U/V coordinates represent focal plane locations. 

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

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

350 

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

352 if minU < plotLimit_x[0]: 

353 plotLimit_x[0] = minU 

354 if minV < plotLimit_y[0]: 

355 plotLimit_y[0] = minV 

356 if maxU > plotLimit_x[1]: 

357 plotLimit_x[1] = maxU 

358 if maxV > plotLimit_y[1]: 

359 plotLimit_y[1] = maxV 

360 

361 # X/Y coordinates represent detector internal coordinates. 

362 # Detector extent in detector coordinates 

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

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

365 

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

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

368 

369 # This does the appropriate statistic for this 

370 # detector's data. 

371 statistic, _, _ = binned_statistic_dd( 

372 [focalPlane_x[detectorInd], focalPlane_y[detectorInd]], 

373 data["z"][detectorInd], 

374 statistic=self.statistic, 

375 bins=[1, 1], 

376 ) 

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

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

379 else: 

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

381 # plane position of the corners of the detector to 

382 # generate corners for the individual amplifier 

383 # segments. 

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

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

386 

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

388 # coordinates. 

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

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

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

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

393 

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

395 # negative offsets. This corresponds to corners that 

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

397 # coordinates. 

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

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

400 

401 for amplifier in detector: 

402 ampName = amplifier.getName() 

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

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

405 ampInd &= detectorInd 

406 

407 # Determine amplifier extent in X/Y coordinates. 

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

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

410 

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

412 # and the appropriate offset added. 

413 ampCorners = [] 

414 ampCorners.append( 

415 ( 

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

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

418 ) 

419 ) 

420 ampCorners.append( 

421 ( 

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

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

424 ) 

425 ) 

426 ampCorners.append( 

427 ( 

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

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

430 ) 

431 ) 

432 ampCorners.append( 

433 ( 

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

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

436 ) 

437 ) 

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

439 # This does the appropriate statistic for this 

440 # amplifier's data. 

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

442 statistic, _, _ = binned_statistic_dd( 

443 [focalPlane_x[ampInd], focalPlane_y[ampInd]], 

444 data["z"][ampInd], 

445 statistic=self.statistic, 

446 bins=[1, 1], 

447 ) 

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

449 else: 

450 values.append(np.nan) 

451 

452 # Set bounding box for this figure. 

453 ax.set_xlim(plotLimit_x) 

454 ax.set_ylim(plotLimit_y) 

455 

456 # Do not mask values. 

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

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

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

460 

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

462 patchCollection.set_array(values) 

463 ax.add_collection(patchCollection) 

464 

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

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

467 text = cax.text( 

468 0.5, 

469 0.5, 

470 self.zAxisLabel, 

471 color="k", 

472 rotation="vertical", 

473 transform=cax.transAxes, 

474 ha="center", 

475 va="center", 

476 fontsize=10, 

477 ) 

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

479 cax.tick_params(labelsize=7) 

480 

481 ax.set_xlabel(self.xAxisLabel) 

482 ax.set_ylabel(self.yAxisLabel) 

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

484 ax.tick_params(labelsize=7) 

485 

486 ax.set_aspect("equal") 

487 plt.draw() 

488 

489 # Add useful information to the plot 

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

491 fig = plt.gcf() 

492 fig = addPlotInfo(fig, plotInfo) 

493 

494 return fig