Coverage for python/lsst/analysis/tools/actions/plot/multiVisitCoveragePlot.py: 8%

406 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-04-09 10:50 +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/>. 

21from __future__ import annotations 

22 

23__all__ = ("MultiVisitCoveragePlot",) 

24 

25import logging 

26from typing import List, Mapping, Optional, cast 

27 

28import matplotlib as mpl 

29import matplotlib.pyplot as plt 

30import numpy as np 

31import pandas as pd 

32from lsst.afw.cameraGeom import FOCAL_PLANE, Camera, DetectorType 

33from lsst.pex.config import Config, DictField, Field, ListField 

34from lsst.skymap import BaseSkyMap 

35from matplotlib.figure import Figure 

36from matplotlib.ticker import FormatStrFormatter 

37 

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

39from ..keyedData import KeyedDataSelectorAction 

40from ..vector.selectors import RangeSelector 

41from .plotUtils import mkColormap, plotProjectionWithBinning 

42 

43log = logging.getLogger(__name__) 

44 

45 

46class MultiVisitCoveragePlot(PlotAction): 

47 plotName = Field[str]( 

48 doc="The name for the plot.", 

49 optional=False, 

50 ) 

51 cmapName = Field[str]( 

52 doc="Name of the color map to be used if not using the default color-blind friendly " 

53 "orange/blue default (used if this is set to `None`). Any name available via " 

54 "`matplotlib.cm` may be used.", 

55 default=None, 

56 optional=True, 

57 ) 

58 projection = Field[str]( 

59 doc='Projection to plot. Currently only "raDec" and "focalPlane" are permitted. ' 

60 "In either case, one point is plotted per visit/detector combination.", 

61 default="raDec", 

62 ) 

63 nBins = Field[int]( 

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

65 'Only used in the "raDec" projection (for the "focalPlane" projection, the binning is ' 

66 "effectively one per detector).", 

67 default=25, 

68 ) 

69 nPointBinThresh = Field[int]( 

70 doc="Threshold number of data points above which binning of the data will be performed in " 

71 'the RA/Dec projection. If ``projection`` is "focalPlane", the per-detector nPoint ' 

72 "threshold is nPointMinThresh/number of science detectors in the given ``camera``.", 

73 default=400, 

74 ) 

75 unitsDict = DictField[str, str]( 

76 doc="A dict mapping a parameter to its appropriate units (for label plotting).", 

77 default={ 

78 "astromOffsetMean": "arcsec", 

79 "astromOffsetStd": "arcsec", 

80 "psfSigma": "pixels", 

81 "skyBg": "counts", 

82 "skyNoise": "counts", 

83 "visit": "number", 

84 "detector": "number", 

85 "zenithDistance": "deg", 

86 "zeroPoint": "mag", 

87 "ra": "deg", 

88 "decl": "deg", 

89 "xFp": "mm", 

90 "yFp": "mm", 

91 "medianE": "", 

92 "psfStarScaledDeltaSizeScatter": "", 

93 }, 

94 ) 

95 sortedFullBandList = ListField[str]( 

96 doc="List of all bands that could, in principle, but do not have to, exist in data table. " 

97 "The sorting of the plot panels will follow this list (typically by wavelength).", 

98 default=["u", "g", "r", "i", "z", "y", "N921"], 

99 ) 

100 bandLabelColorDict = DictField[str, str]( 

101 doc="A dict mapping which color to use for the labels of a given band.", 

102 default={ 

103 "u": "tab:purple", 

104 "g": "tab:blue", 

105 "r": "tab:green", 

106 "i": "gold", 

107 "z": "tab:orange", 

108 "y": "tab:red", 

109 "N921": "tab:pink", 

110 }, 

111 ) 

112 vectorsToLoadList = ListField[str]( 

113 doc="List of columns to load from input table.", 

114 default=[ 

115 "visitId", 

116 "detector", 

117 "band", 

118 "ra", 

119 "decl", 

120 "zeroPoint", 

121 "psfSigma", 

122 "skyBg", 

123 "astromOffsetMean", 

124 "psfStarDeltaE1Median", 

125 "psfStarDeltaE2Median", 

126 "psfStarScaledDeltaSizeScatter", 

127 "llcra", 

128 "lrcra", 

129 "ulcra", 

130 "urcra", 

131 "llcdec", 

132 "lrcdec", 

133 "ulcdec", 

134 "urcdec", 

135 "xSize", 

136 "ySize", 

137 ], 

138 ) 

139 parametersToPlotList = ListField[str]( 

140 doc="List of paramters to plot. They are plotted along rows and the columns " 

141 "plot these parameters for each band.", 

142 default=[ 

143 "psfSigma", 

144 "astromOffsetMean", 

145 "medianE", 

146 "psfStarScaledDeltaSizeScatter", 

147 "skyBg", 

148 "zeroPoint", 

149 ], 

150 ) 

151 tractsToPlotList = ListField[int]( 

152 doc="List of tracts within which to limit the RA and Dec limits of the plot.", 

153 default=None, 

154 optional=True, 

155 ) 

156 trimToTract = Field[bool]( 

157 doc="Whether to trim the plot limits to the tract limit(s). Otherwise, plot " 

158 "will be trimmed to data limits (both will be expanded in the smaller range " 

159 "direction for an equal aspect square plot).", 

160 default=False, 

161 ) 

162 doScatterInRaDec = Field[bool]( 

163 doc="Whether to scatter the points in RA/Dec before plotting. This may be useful " 

164 "for visualization for surveys with tight dithering patterns.", 

165 default=False, 

166 ) 

167 plotAllTractOutlines = Field[bool]( 

168 doc="Whether to plot tract outlines for all tracts within the plot limits " 

169 "(regardless if they have any data in them).", 

170 default=False, 

171 ) 

172 raDecLimitsDict = DictField[str, float]( 

173 doc="A dict mapping the RA/Dec limits to apply to the plot. Set to `None` to use " 

174 "base limits on the default or the other config options. The dict must contain " 

175 "the keys raMin, ramax, decMin, decMax, e.g. " 

176 'raDecLimitsDict = {"raMin": 0, "raMax": 360, "decMin": -90, "decMax": 90}. ' 

177 "Not compatible with ``trimToTract`` or ``tractsToPlotList`` (i.e. the latter two " 

178 "will be ignored if the dict is not `None`).", 

179 default=None, 

180 optional=True, 

181 ) 

182 plotDetectorOutline = Field[bool]( 

183 doc="Whether to plot a shaded outline of the detector size in the RA/Dec projection" 

184 "for reference. Ignored if ``projection`` is not raDec or no camera is provided " 

185 "in the inputs.", 

186 default=False, 

187 ) 

188 

189 def getInputSchema(self) -> KeyedDataSchema: 

190 base: list[tuple[str, type[Vector] | type[Scalar]]] = [] 

191 for vector in self.vectorsToLoadList: 

192 base.append((vector, Vector)) 

193 return base 

194 

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

196 self._validateInput(data, **kwargs) 

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

198 

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

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

201 check that the data is consistent with Vector. 

202 """ 

203 needed = self.getFormattedInputSchema(**kwargs) 

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

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

206 }: 

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

208 for name, typ in needed: 

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

210 if isScalar and typ != Scalar: 

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

212 

213 if "medianE" in self.parametersToPlotList: 

214 if not all( 

215 vector in self.vectorsToLoadList 

216 for vector in ["psfStarDeltaE1Median", "psfStarDeltaE2Median"] 

217 ): 

218 raise RuntimeError( 

219 "If medianE is to be plotted, both psfStarDeltaE1Median and " 

220 "psfStarDeltaE2Median must be included in vectorsToLoadList." 

221 ) 

222 if self.raDecLimitsDict is not None: 

223 requiredKeys = set(["raMin", "raMax", "decMin", "decMax"]) 

224 if requiredKeys != set(self.raDecLimitsDict.keys()): 

225 raise RuntimeError( 

226 f"The following keys (and only these) are required in raDecLimitDict: {requiredKeys}." 

227 f"The dict provided gave {set(self.raDecLimitsDict.keys())}." 

228 ) 

229 

230 def makePlot( 

231 self, 

232 data: KeyedData, 

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

234 camera: Optional[Camera] = None, 

235 skymap: Optional[BaseSkyMap] = None, 

236 calibrateConfig: Optional[Config] = None, 

237 makeWarpConfig: Optional[Config] = None, 

238 **kwargs, 

239 ) -> Figure: 

240 """Make an Nband x Nparameter panel multi-visit coverage plot. 

241 

242 The panels rows are for different bands,sorted according to the order 

243 in config ``sortedFullBandList``. The columns are per-parameter, 

244 plotted in the order given by the config ``parametersToPlotList``. 

245 Red "over" colors indicate thresholds in play in the data processing 

246 (e.g. used to select against data of sufficient quality). 

247 

248 Parameters 

249 ---------- 

250 data : `lsst.analysis.tools.interfaces.KeyedData` 

251 The key-based catalog of data to plot. 

252 plotInfo : `dict` [`str`], optional 

253 A dictionary of information about the data being plotted with (at 

254 least) keys: 

255 `"run"` 

256 Output run for the plots (`str`). 

257 `"tableName"` 

258 Name of the table from which results are taken (`str`). 

259 camera : `lsst.afw.cameraGeom.Camera`, optional 

260 The camera object associated with the data. This is to enable the 

261 conversion of to focal plane coordinates (if needed, i.e. for the 

262 focal plane projection version of this plot) and to obtain the 

263 projected (in RA/Dec) size of a typical detector (for reference in 

264 the raDec projection plots when requested, i.e. if the config 

265 ``plotDetectorOutline`` is `True`). 

266 skymap : `lsst.skymap.BaseSkyMap`, optional 

267 The sky map used for this dataset. If a specific tract[List] is 

268 provided, this is used to determine the appropriate RA & Dec limits 

269 to downselect the data to within those limits for plotting. 

270 calibrateConfig : `lsst.pex.config.Config`, optional 

271 The persisted config used in the calibration task for the given 

272 collection. Used to introspect threshold values used in the run. 

273 makeWarpConfig : `lsst.pex.config.Config`, optional 

274 The persisted config used in the makeWarp task for the given 

275 collection. Used to introspect threshold values used in the run. 

276 

277 Returns 

278 ------- 

279 fig : `matplotlib.figure.Figure` 

280 The resulting figure. 

281 """ 

282 mpl.rcParams["figure.dpi"] = 350 

283 mpl.rcParams["font.size"] = 5 

284 mpl.rcParams["xtick.labelsize"] = 5 

285 mpl.rcParams["ytick.labelsize"] = 5 

286 mpl.rcParams["xtick.major.size"] = 1.5 

287 mpl.rcParams["ytick.major.size"] = 1.5 

288 mpl.rcParams["xtick.major.width"] = 0.5 

289 mpl.rcParams["ytick.major.width"] = 0.5 

290 

291 if "medianE" in self.parametersToPlotList: 

292 data["medianE"] = np.sqrt( 

293 data["psfStarDeltaE1Median"] ** 2.0 + data["psfStarDeltaE2Median"] ** 2.0 

294 ) 

295 

296 # Make sure all columns requested actually exist in ccdVisitTable. 

297 notFoundKeys = [] 

298 for zKey in self.parametersToPlotList: 

299 if zKey not in data.keys(): 

300 notFoundKeys.append(zKey) 

301 if len(notFoundKeys) > 0: 

302 raise RuntimeError(f"Requested column(s) {notFoundKeys} is(are) not in the data table.") 

303 

304 maxMeanDistanceArcsec = calibrateConfig.astrometry.maxMeanDistanceArcsec # type: ignore 

305 

306 if makeWarpConfig is None: 

307 maxEllipResidual = 0.007 

308 maxScaledSizeScatter = 0.009 

309 else: 

310 maxEllipResidual = makeWarpConfig.select.value.maxEllipResidual # type: ignore 

311 maxScaledSizeScatter = makeWarpConfig.select.value.maxScaledSizeScatter # type: ignore 

312 

313 cameraName = "" if camera is None else camera.getName() 

314 if self.projection == "focalPlane": 

315 if camera is None: 

316 raise RuntimeError("Must have an input camera if plotting focalPlane projection.") 

317 xKey = "xFp" 

318 yKey = "yFp" 

319 # Add the detector center in Focal Plane coords to the data table. 

320 detFpDict = {} 

321 for det in camera: # type: ignore 

322 if det.getType() == DetectorType.SCIENCE: 

323 detFpDict[det.getId()] = det.getCenter(FOCAL_PLANE) 

324 log.info("Number of SCIENCE detectors in {} camera = {}".format(cameraName, len(detFpDict))) 

325 xFpList = [] 

326 yFpList = [] 

327 for det in data["detector"]: # type: ignore 

328 xFpList.append(detFpDict[det].getX()) 

329 yFpList.append(detFpDict[det].getY()) 

330 

331 data["xFp"] = xFpList # type: ignore 

332 data["yFp"] = yFpList # type: ignore 

333 

334 corners = camera[0].getCorners(FOCAL_PLANE) # type: ignore 

335 xCorners, yCorners = zip(*corners) 

336 xScatLen = 0.4 * (np.nanmax(xCorners) - np.nanmin(xCorners)) 

337 yScatLen = 0.4 * (np.nanmax(yCorners) - np.nanmin(yCorners)) 

338 tractList: List[int] = [] 

339 elif self.projection == "raDec": 

340 xKey = "ra" 

341 yKey = "decl" 

342 xScatLen, yScatLen = 0, 0 

343 # Use downselector without limits to get rid of any non-finite 

344 # RA/Dec entries. 

345 data = self._planeAreaSelector(data, xKey=xKey, yKey=yKey) 

346 if self.tractsToPlotList is not None and len(self.tractsToPlotList) > 0: 

347 tractList = self.tractsToPlotList 

348 else: 

349 ras = data["ra"] 

350 decs = data["decl"] 

351 tractList = list(set(skymap.findTractIdArray(ras, decs, degrees=True))) # type: ignore 

352 log.info("List of tracts overlapping data: {}".format(tractList)) 

353 tractLimitsDict = self._getTractLimitsDict(skymap, tractList) 

354 else: 

355 raise ValueError("Unknown projection: {}".format(self.projection)) 

356 

357 perTract = True if self.tractsToPlotList is not None and self.projection == "raDec" else False 

358 

359 if perTract and len(self.tractsToPlotList) > 0: 

360 data = self._trimDataToTracts(data, skymap) 

361 nData = len(cast(Vector, data[list(data.keys())[0]])) 

362 if nData == 0: 

363 raise RuntimeError( 

364 "No data to plot. Did your tract selection of " 

365 f"{self.tractsToPlotList} remove all data?" 

366 ) 

367 

368 if self.doScatterInRaDec: 

369 raRange = max(cast(Vector, data["ra"])) - min(cast(Vector, data["ra"])) 

370 decRange = max(cast(Vector, data["decl"])) - min(cast(Vector, data["decl"])) 

371 scatRad = max(0.05 * max(raRange, decRange), 0.12) # min is of order of an LSSTCam detector 

372 log.info("Scattering data in RA/Dec within radius {:.3f} (deg)".format(scatRad)) 

373 

374 dataDf = pd.DataFrame(data) 

375 nDataId = len(dataDf) 

376 log.info("Number of detector data points: %i", nDataId) 

377 if nDataId == 0: 

378 raise RuntimeError("No data to plot. Exiting...") 

379 nVisit = len(set(dataDf["visitId"])) 

380 

381 # Make a sorted list of the bands that exist in the data table. 

382 dataBandList = list(set(dataDf["band"])) 

383 bandList = [band for band in self.sortedFullBandList if band in dataBandList] 

384 missingBandList = list(set(dataBandList) - set(self.sortedFullBandList)) 

385 if len(missingBandList) > 0: 

386 log.warning( 

387 "The band(s) {} are not included in self.sortedFullBandList. Please add them so " 

388 "they get sorted properly (namely by wavelength). For now, they will just be " 

389 "appended to the end of the list of those that could be sorted. You may also " 

390 "wish to give them entries in self.bandLabelColorList to specify the label color " 

391 "(otherwise, if not present, it defaults to teal).".format(missingBandList) 

392 ) 

393 bandList.extend(missingBandList) 

394 log.info("Sorted list of existing bands: {}".format(bandList)) 

395 

396 ptSize = min(5, max(0.3, 600.0 / ((nDataId / len(bandList)) ** 0.5))) 

397 if self.doScatterInRaDec: 

398 ptSize = min(3, 0.7 * ptSize) 

399 

400 nRow = len(bandList) 

401 nCol = len(self.parametersToPlotList) 

402 colMultiplier = 4 if nCol == 1 else 2.5 

403 fig, axes = plt.subplots( 

404 nRow, 

405 nCol, 

406 figsize=(int(colMultiplier * nCol), 2 * nRow), 

407 constrained_layout=True, 

408 ) 

409 

410 # Select reasonable limits for colormaps so they can be matched for 

411 # all bands. 

412 nPercent = max(2, int(0.02 * nDataId)) 

413 vMinDict = {} 

414 vMaxDict = {} 

415 for zKey in self.parametersToPlotList: 

416 zKeySorted = dataDf[zKey].sort_values() 

417 zKeySorted = zKeySorted[np.isfinite(zKeySorted)] 

418 vMinDict[zKey] = np.nanmean(zKeySorted.head(nPercent)) 

419 if zKey == "medianE": 

420 vMaxDict[zKey] = maxEllipResidual 

421 elif zKey == "psfStarScaledDeltaSizeScatter": 

422 vMaxDict[zKey] = maxScaledSizeScatter 

423 elif zKey == "astromOffsetMean" and self.projection != "raDec": 

424 vMaxDict[zKey] = min(maxMeanDistanceArcsec, 1.1 * np.nanmean(zKeySorted.tail(nPercent))) 

425 else: 

426 vMaxDict[zKey] = np.nanmean(zKeySorted.tail(nPercent)) 

427 

428 for iRow, band in enumerate(bandList): 

429 dataBand = dataDf[dataDf["band"] == band].copy() 

430 

431 nDataIdBand = len(dataBand) 

432 nVisitBand = len(set(dataBand["visitId"])) 

433 if nDataIdBand < 2: 

434 log.warning("Fewer than 2 points to plot for {}. Skipping...".format(band)) 

435 continue 

436 

437 for zKey in self.parametersToPlotList: 

438 # Don't match the colorbars when it doesn't make sense to. 

439 if zKey in ["skyBg", "zeroPoint"]: 

440 nPercent = max(2, int(0.02 * nDataIdBand)) 

441 zKeySorted = dataBand[zKey].sort_values() 

442 zKeySorted = zKeySorted[np.isfinite(zKeySorted)] 

443 vMinDict[zKey] = np.nanmean(zKeySorted.head(nPercent)) 

444 vMaxDict[zKey] = np.nanmean(zKeySorted.tail(nPercent)) 

445 

446 # Scatter the plots within the detector for focal plane plots. 

447 if self.doScatterInRaDec: 

448 scatRads = scatRad * np.sqrt(np.random.uniform(size=nDataIdBand)) 

449 scatTheta = 2.0 * np.pi * np.random.uniform(size=nDataIdBand) 

450 xScatter = scatRads * np.cos(scatTheta) 

451 yScatter = scatRads * np.sin(scatTheta) 

452 xLabel = xKey + " + rand(scatter)" 

453 yLabel = yKey + " + rand(scatter)" 

454 else: 

455 xScatter = np.random.uniform(-xScatLen, xScatLen, len(dataBand[xKey])) 

456 yScatter = np.random.uniform(-yScatLen, yScatLen, len(dataBand[yKey])) 

457 xLabel = xKey 

458 yLabel = yKey 

459 dataBand["xScat"] = dataBand[xKey] + xScatter 

460 dataBand["yScat"] = dataBand[yKey] + yScatter 

461 # Accommodate odd number of quarter-turn rotated detectors. 

462 if self.projection == "focalPlane": 

463 for det in camera: # type: ignore 

464 if det.getOrientation().getNQuarter() % 2 != 0: 

465 detId = int(det.getId()) 

466 xFpRot = dataBand.loc[dataBand.detector == detId, xKey] 

467 yFpRot = dataBand.loc[dataBand.detector == detId, yKey] 

468 xScatRot = dataBand.loc[dataBand.detector == detId, "xScat"] 

469 yScatRot = dataBand.loc[dataBand.detector == detId, "yScat"] 

470 dataBand.loc[dataBand.detector == detId, "xScat"] = xFpRot + (yScatRot - yFpRot) 

471 dataBand.loc[dataBand.detector == detId, "yScat"] = yFpRot + (xScatRot - xFpRot) 

472 

473 if band not in self.bandLabelColorDict: 

474 log.warning( 

475 "The band {} is not included in the bandLabelColorList config. Please add it " 

476 "to specify the label color (otherwise, it defaults to teal).".format(band) 

477 ) 

478 color = self.bandLabelColorDict[band] if band in self.bandLabelColorDict else "teal" 

479 fontDict = {"fontsize": 5, "color": color} 

480 

481 for iCol, zKey in enumerate(self.parametersToPlotList): 

482 if self.cmapName is None: 

483 cmap = mkColormap(["darkOrange", "thistle", "midnightblue"]) 

484 else: 

485 cmap = mpl.cm.get_cmap(self.cmapName).copy() 

486 

487 if zKey in ["medianE", "psfStarScaledDeltaSizeScatter"]: 

488 cmap.set_over("red") 

489 elif ( 

490 zKey in ["astromOffsetMean"] 

491 and self.projection != "raDec" 

492 and vMaxDict[zKey] >= maxMeanDistanceArcsec 

493 ): 

494 cmap.set_over("red") 

495 else: 

496 if self.cmapName is None: 

497 cmap.set_over("black") 

498 else: 

499 cmap.set_over("lemonchiffon") 

500 

501 if zKey in ["psfSigma"]: 

502 cmap.set_under("red") 

503 else: 

504 if self.cmapName is None: 

505 cmap.set_under("lemonchiffon") 

506 else: 

507 cmap.set_under("black") 

508 

509 titleStr = "band: {} nVisit: {} nData: {}".format(band, nVisitBand, nDataIdBand) 

510 

511 ax = axes[iRow, iCol] if axes.ndim > 1 else axes[max(iRow, iCol)] 

512 ax.set_title("{}".format(titleStr), loc="left", fontdict=fontDict, pad=2) 

513 ax.set_xlabel("{} ({})".format(xLabel, self.unitsDict[xKey]), labelpad=0) 

514 ax.set_ylabel("{} ({})".format(yLabel, self.unitsDict[yKey]), labelpad=1) 

515 ax.set_aspect("equal") 

516 ax.tick_params("x", labelrotation=45, pad=0) 

517 

518 if self.projection == "focalPlane": 

519 for det in camera: # type: ignore 

520 if det.getType() == DetectorType.SCIENCE: 

521 corners = det.getCorners(FOCAL_PLANE) 

522 xCorners, yCorners = zip(*corners) 

523 xFpMin, xFpMax = min(xCorners), max(xCorners) 

524 yFpMin, yFpMax = min(yCorners), max(yCorners) 

525 detId = int(det.getId()) 

526 perDetData = dataBand[dataBand["detector"] == detId] 

527 if len(perDetData) < 1: 

528 log.debug("No data to plot for detector {}. Skipping...".format(detId)) 

529 continue 

530 if sum(np.isfinite(perDetData[zKey])) < 1: 

531 log.debug( 

532 "No finited data to plot for detector {}. Skipping...".format(detId) 

533 ) 

534 continue 

535 pcm = plotProjectionWithBinning( 

536 ax, 

537 perDetData["xScat"].values, 

538 perDetData["yScat"].values, 

539 perDetData[zKey].values, 

540 cmap, 

541 xFpMin, 

542 xFpMax, 

543 yFpMin, 

544 yFpMax, 

545 xNumBins=1, 

546 fixAroundZero=False, 

547 nPointBinThresh=max(1, int(self.nPointBinThresh / len(detFpDict))), 

548 isSorted=False, 

549 vmin=vMinDict[zKey], 

550 vmax=vMaxDict[zKey], 

551 scatPtSize=ptSize, 

552 ) 

553 if self.projection == "raDec": 

554 raMin, raMax = min(cast(Vector, data["ra"])), max(cast(Vector, data["ra"])) 

555 decMin, decMax = min(cast(Vector, data["decl"])), max(cast(Vector, data["decl"])) 

556 raMin, raMax, decMin, decMax = _setLimitsToEqualRatio(raMin, raMax, decMin, decMax) 

557 pcm = plotProjectionWithBinning( 

558 ax, 

559 dataBand["xScat"].values, 

560 dataBand["yScat"].values, 

561 dataBand[zKey].values, 

562 cmap, 

563 raMin, 

564 raMax, 

565 decMin, 

566 decMax, 

567 xNumBins=self.nBins, 

568 fixAroundZero=False, 

569 nPointBinThresh=self.nPointBinThresh, 

570 isSorted=False, 

571 vmin=vMinDict[zKey], 

572 vmax=vMaxDict[zKey], 

573 scatPtSize=ptSize, 

574 ) 

575 

576 # Make sure all panels get the same axis limits and always make 

577 # equal axis ratio panels. 

578 if iRow == 0 and iCol == 0: 

579 if self.raDecLimitsDict is not None and self.projection == "raDec": 

580 xLimMin = self.raDecLimitsDict["raMin"] # type: ignore 

581 xLimMax = self.raDecLimitsDict["raMax"] # type: ignore 

582 yLimMin = self.raDecLimitsDict["decMin"] # type: ignore 

583 yLimMax = self.raDecLimitsDict["decMax"] # type: ignore 

584 else: 

585 xLimMin, xLimMax = ax.get_xlim() 

586 yLimMin, yLimMax = ax.get_ylim() 

587 if self.projection == "focalPlane": 

588 minDim = ( 

589 max( 

590 camera.getFpBBox().getWidth(), # type: ignore 

591 camera.getFpBBox().getHeight(), # type: ignore 

592 ) 

593 / 2 

594 ) 

595 xLimMin = min(-minDim, xLimMin) 

596 xLimMax = max(minDim, xLimMax) 

597 if self.trimToTract: 

598 for tract, tractLimits in tractLimitsDict.items(): 

599 xLimMin = min(xLimMin, min(tractLimits["ras"])) 

600 xLimMax = max(xLimMax, max(tractLimits["ras"])) 

601 yLimMin = min(yLimMin, min(tractLimits["decs"])) 

602 yLimMax = max(yLimMax, max(tractLimits["decs"])) 

603 xDelta = xLimMax - xLimMin 

604 xLimMin -= 0.04 * xDelta 

605 xLimMax += 0.04 * xDelta 

606 

607 xLimMin, xLimMax, yLimMin, yLimMax = _setLimitsToEqualRatio( 

608 xLimMin, xLimMax, yLimMin, yLimMax 

609 ) 

610 limRange = xLimMax - xLimMin 

611 yTickFmt = _tickFormatter(yLimMin * 10) 

612 

613 if self.plotAllTractOutlines and self.projection == "raDec": 

614 allTractsList = [] 

615 for tractInfo in skymap: # type: ignore 

616 centerRa = tractInfo.getCtrCoord()[0].asDegrees() 

617 centerDec = tractInfo.getCtrCoord()[1].asDegrees() 

618 if ( 

619 centerRa > xLimMin 

620 and centerRa < xLimMax 

621 and centerDec > yLimMin 

622 and centerDec < yLimMax 

623 ): 

624 allTractsList.append(int(tractInfo.getId())) 

625 tractLimitsDict = self._getTractLimitsDict(skymap, allTractsList) 

626 

627 upperHandles = [] 

628 if self.doScatterInRaDec and self.projection == "raDec": 

629 patch = mpl.patches.Circle( 

630 (xLimMax - 1.5 * scatRad, yLimMax - 1.5 * scatRad), 

631 radius=scatRad, 

632 facecolor="gray", 

633 edgecolor="None", 

634 alpha=0.2, 

635 label="scatter area", 

636 ) 

637 ax.add_patch(patch) 

638 upperHandles.append(patch) 

639 

640 # Add a shaded area of the size of a detector for reference. 

641 if self.plotDetectorOutline and self.projection == "raDec": 

642 if camera is None: 

643 log.warning( 

644 "Config plotDetectorOutline is True, but no camera was provided. " 

645 "Reference detector outline will not be included in the plot." 

646 ) 

647 else: 

648 # Calculate area of polygon with known vertices. 

649 x1, x2, x3, x4 = ( 

650 dataBand["llcra"], 

651 dataBand["lrcra"], 

652 dataBand["urcra"], 

653 dataBand["ulcra"], 

654 ) 

655 y1, y2, y3, y4 = ( 

656 dataBand["llcdec"], 

657 dataBand["lrcdec"], 

658 dataBand["urcdec"], 

659 dataBand["ulcdec"], 

660 ) 

661 areaDeg = ( 

662 np.abs( 

663 (x1 * y2 - y1 * x2) 

664 + (x2 * y3 - y2 * x3) 

665 + (x3 * y4 - y3 * x4) 

666 + (x4 * y1 - y4 * x1) 

667 ) 

668 / 2.0 

669 ) 

670 detScaleDeg = np.sqrt(areaDeg / (dataBand["xSize"] * dataBand["ySize"])) 

671 detWidthDeg = np.nanmedian(detScaleDeg * dataBand["xSize"]) 

672 detHeightDeg = np.nanmedian(detScaleDeg * dataBand["ySize"]) 

673 

674 patch = mpl.patches.Rectangle( 

675 (xLimMax - 0.02 * limRange - detWidthDeg, yLimMin + 0.03 * limRange), 

676 detWidthDeg, 

677 detHeightDeg, 

678 facecolor="turquoise", 

679 alpha=0.3, 

680 label="det size", 

681 ) 

682 ax.add_patch(patch) 

683 upperHandles.append(patch) 

684 

685 if self.projection == "raDec": 

686 if iRow == 0 and iCol == 0 and len(upperHandles) > 0: 

687 ax.legend( 

688 handles=upperHandles, 

689 loc="upper left", 

690 bbox_to_anchor=(0, 1.17 + 0.05 * len(upperHandles)), 

691 edgecolor="black", 

692 framealpha=0.4, 

693 fontsize=5, 

694 ) 

695 

696 # Overplot tract outlines if tracts were specified, but only if 

697 # the plot limits span fewer than the width of 5 tracts 

698 # (otherwise the labels will be too crowded to be useful). 

699 if len(tractList) > 0: 

700 tractInfo = skymap[tractList[0]] # type: ignore 

701 tractWidthDeg = tractInfo.outer_sky_polygon.getBoundingBox().getWidth().asDegrees() 

702 if limRange <= 5 * tractWidthDeg: 

703 deltaLim = 0.05 * limRange 

704 for tract, tractLimits in tractLimitsDict.items(): 

705 centerRa = tractLimits["center"][0] 

706 centerDec = tractLimits["center"][1] 

707 if ( 

708 centerRa > xLimMin + deltaLim 

709 and centerRa < xLimMax - deltaLim 

710 and centerDec > yLimMin + deltaLim 

711 and centerDec < yLimMax - deltaLim 

712 ): 

713 ax.plot(tractLimits["ras"], tractLimits["decs"], color="dimgray", lw=0.4) 

714 fontSize = 3 if limRange < 20 else 2 

715 ax.annotate( 

716 str(tract), 

717 tractLimits["center"], 

718 va="center", 

719 ha="center", 

720 color="dimgray", 

721 fontsize=fontSize, 

722 annotation_clip=True, 

723 path_effects=[mpl.patheffects.withStroke(linewidth=1, foreground="w")], 

724 ) 

725 if self.projection == "raDec": 

726 ax.set_xlim(xLimMax, xLimMin) 

727 else: 

728 ax.set_xlim(xLimMin, xLimMax) 

729 ax.set_ylim(yLimMin, yLimMax) 

730 ax.yaxis.set_major_formatter(yTickFmt) 

731 

732 # Get a tick formatter that will give all anticipated value 

733 # ranges the same length. This is so that the label padding 

734 # has the same effect on all colorbars. 

735 value = vMaxDict[zKey] if np.abs(vMaxDict[zKey]) > np.abs(vMinDict[zKey]) else vMinDict[zKey] 

736 if vMinDict[zKey] < 0 and np.abs(vMinDict[zKey]) >= vMaxDict[zKey] / 10: 

737 value = vMinDict[zKey] 

738 tickFmt = _tickFormatter(value) 

739 cb = fig.colorbar( 

740 pcm, 

741 ax=ax, 

742 extend="both", 

743 aspect=14, 

744 format=tickFmt, 

745 pad=0.02, 

746 ) 

747 

748 cbLabel = zKey 

749 if zKey not in self.unitsDict: 

750 log.warning( 

751 "Data column {} does not have an entry in unitsDict config. Units " 

752 "will not be included in the colorbar text.".format(zKey) 

753 ) 

754 elif len(self.unitsDict[zKey]) > 0: 

755 cbLabel = "{} ({})".format(zKey, self.unitsDict[zKey]) 

756 

757 cb.set_label( 

758 cbLabel, 

759 labelpad=-29, 

760 color="black", 

761 fontsize=6, 

762 path_effects=[mpl.patheffects.withStroke(linewidth=1, foreground="w")], 

763 ) 

764 

765 runName = plotInfo["run"] # type: ignore 

766 supTitle = "{} {} nVisit: {} nData: {}".format(runName, cameraName, nVisit, nDataId) 

767 if nCol == 1: 

768 supTitle = "{} {}\n nVisit: {} nData: {}".format(runName, cameraName, nVisit, nDataId) 

769 fig.suptitle(supTitle, fontsize=4 + nCol, ha="center") 

770 

771 return fig 

772 

773 def _getTractLimitsDict(self, skymap, tractList): 

774 """Return a dict containing tract limits needed for outline plotting. 

775 

776 Parameters 

777 ---------- 

778 skymap : `lsst.skymap.BaseSkyMap` 

779 The sky map used for this dataset. Used to obtain tract 

780 parameters. 

781 tractList : `list` [`int`] 

782 The list of tract ids (as integers) for which to determine the 

783 limits. 

784 

785 Returns 

786 ------- 

787 tractLimitsDict : `dict` [`dict`] 

788 A dictionary keyed on tract id. Each entry includes a `dict` 

789 including the tract RA corners, Dec corners, and the tract center, 

790 all in units of degrees. These are used for plotting the tract 

791 outlines. 

792 """ 

793 tractLimitsDict = {} 

794 for tract in tractList: 

795 tractInfo = skymap[tract] 

796 tractBbox = tractInfo.outer_sky_polygon.getBoundingBox() 

797 tractCenter = tractBbox.getCenter() 

798 tractRa0 = (tractCenter[0] - tractBbox.getWidth() / 2).asDegrees() 

799 tractRa1 = (tractCenter[0] + tractBbox.getWidth() / 2).asDegrees() 

800 tractDec0 = (tractCenter[1] - tractBbox.getHeight() / 2).asDegrees() 

801 tractDec1 = (tractCenter[1] + tractBbox.getHeight() / 2).asDegrees() 

802 tractLimitsDict[tract] = { 

803 "ras": [tractRa0, tractRa1, tractRa1, tractRa0, tractRa0], 

804 "decs": [tractDec0, tractDec0, tractDec1, tractDec1, tractDec0], 

805 "center": [tractCenter[0].asDegrees(), tractCenter[1].asDegrees()], 

806 } 

807 

808 return tractLimitsDict 

809 

810 def _planeAreaSelector( 

811 self, 

812 data, 

813 xMin=np.nextafter(float("-inf"), 0), 

814 xMax=np.nextafter(float("inf"), 0), 

815 yMin=np.nextafter(float("-inf"), 0), 

816 yMax=np.nextafter(float("inf"), 0), 

817 xKey="ra", 

818 yKey="decl", 

819 ): 

820 """Helper function for downselecting on within an area on a plane. 

821 

822 Parameters 

823 ---------- 

824 data : `lsst.analysis.tools.interfaces.KeyedData` 

825 The key-based catalog of data to select on. 

826 xMin, xMax, yMin, yMax : `float` 

827 The min/max x/y values defining the range within which to 

828 down-select the data. 

829 xKey, yKey : `str` 

830 The column keys defining the "x" and "y" positions on the plane. 

831 

832 Returns 

833 ------- 

834 downSelectedData : `lsst.analysis.tools.interfaces.KeyedData` 

835 The down-selected catalog. 

836 """ 

837 xSelector = RangeSelector(key=xKey, minimum=xMin, maximum=xMax) 

838 ySelector = RangeSelector(key=yKey, minimum=yMin, maximum=yMax) 

839 keyedSelector = KeyedDataSelectorAction(vectorKeys=data.keys()) 

840 keyedSelector.selectors.xSelector = xSelector 

841 keyedSelector.selectors.ySelector = ySelector 

842 downSelectedData = keyedSelector(data) 

843 

844 return downSelectedData 

845 

846 def _trimDataToTracts(self, data, skymap): 

847 """Trim the data to limits set by tracts in self.tractsToPlotList. 

848 

849 Parameters 

850 ---------- 

851 data : `lsst.analysis.tools.interfaces.KeyedData` 

852 The key-based catalog of data to select on. 

853 skymap : `lsst.skymap.BaseSkyMap` 

854 The sky map used for this dataset. Used to obtain tract 

855 parameters. 

856 

857 Returns 

858 ------- 

859 downSelectedData : `lsst.analysis.tools.interfaces.KeyedData` 

860 The down-selected catalog. 

861 """ 

862 tractRaMin = 1e12 

863 tractRaMax = -1e12 

864 tractDecMin = 1e12 

865 tractDecMax = -1e12 

866 for tract in self.tractsToPlotList: 

867 tractInfo = skymap[tract] 

868 tractBbox = tractInfo.outer_sky_polygon.getBoundingBox() 

869 tractCenter = tractBbox.getCenter() 

870 tractRa0 = (tractCenter[0] - tractBbox.getWidth() / 2).asDegrees() 

871 tractRa1 = (tractCenter[0] + tractBbox.getWidth() / 2).asDegrees() 

872 tractDec0 = (tractCenter[1] - tractBbox.getHeight() / 2).asDegrees() 

873 tractDec1 = (tractCenter[1] + tractBbox.getHeight() / 2).asDegrees() 

874 tractRaMin = min(tractRaMin, tractRa0) 

875 tractRaMax = max(tractRaMax, tractRa1) 

876 tractDecMin = min(tractDecMin, tractDec0) 

877 tractDecMax = max(tractDecMax, tractDec1) 

878 downSelectedData = self._planeAreaSelector( 

879 data, 

880 xMin=tractRaMin, 

881 xMax=tractRaMax, 

882 yMin=tractDecMin, 

883 yMax=tractDecMax, 

884 xKey="ra", 

885 yKey="decl", 

886 ) 

887 return downSelectedData 

888 

889 

890def _tickFormatter(value): 

891 """Create a tick formatter such that all anticipated values end up with the 

892 same length. 

893 

894 This accommodates values ranging from +/-0.0001 -> +/-99999 

895 

896 Parameters 

897 ---------- 

898 value : `float` 

899 The the value used to determine the appropriate formatting. 

900 

901 Returns 

902 ------- 

903 tickFmt : `matplotlib.ticker.FormatStrFormatter` 

904 The tick formatter to use with matplotlib functions. 

905 """ 

906 if np.abs(value) >= 10000: 

907 tickFmt = FormatStrFormatter("%.0f") 

908 elif np.abs(value) >= 1000: 

909 tickFmt = FormatStrFormatter("%.1f") 

910 if value < 0: 

911 tickFmt = FormatStrFormatter("%.0f") 

912 elif np.abs(value) >= 100: 

913 tickFmt = FormatStrFormatter("%.2f") 

914 if value < 0: 

915 tickFmt = FormatStrFormatter("%.1f") 

916 elif np.abs(value) >= 10: 

917 tickFmt = FormatStrFormatter("%.3f") 

918 if value < 0: 

919 tickFmt = FormatStrFormatter("%.2f") 

920 elif np.abs(value) >= 1: 

921 tickFmt = FormatStrFormatter("%.4f") 

922 if value < 0: 

923 tickFmt = FormatStrFormatter("%.3f") 

924 else: 

925 tickFmt = FormatStrFormatter("%.4f") 

926 if value < 0: 

927 tickFmt = FormatStrFormatter("%.3f") 

928 return tickFmt 

929 

930 

931def _setLimitsToEqualRatio(xMin, xMax, yMin, yMax): 

932 """For a given set of x/y min/max, redefine to have equal aspect ratio. 

933 

934 The limits are extended on both ends such that the central value is 

935 preserved. 

936 

937 Parameters 

938 ---------- 

939 xMin, xMax, yMin, yMax : `float` 

940 The min/max values of the x/y ranges for which to match in dynamic 

941 range while perserving the central values. 

942 

943 Returns 

944 ------- 

945 xMin, xMax, yMin, yMax : `float` 

946 The adjusted min/max values of the x/y ranges with equal aspect ratios. 

947 """ 

948 xDelta = xMax - xMin 

949 yDelta = yMax - yMin 

950 deltaDiff = yDelta - xDelta 

951 if deltaDiff > 0: 

952 xMin -= 0.5 * deltaDiff 

953 xMax += 0.5 * deltaDiff 

954 elif deltaDiff < 0: 

955 yMin -= 0.5 * np.abs(deltaDiff) 

956 yMax += 0.5 * np.abs(deltaDiff) 

957 return xMin, xMax, yMin, yMax