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

406 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-06-13 02:05 -0700

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 """Plot the coverage for a set of visits.""" 

48 

49 plotName = Field[str]( 

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

51 optional=False, 

52 ) 

53 cmapName = Field[str]( 

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

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

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

57 default=None, 

58 optional=True, 

59 ) 

60 projection = Field[str]( 

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

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

63 default="raDec", 

64 ) 

65 nBins = Field[int]( 

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

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

68 "effectively one per detector).", 

69 default=25, 

70 ) 

71 nPointBinThresh = Field[int]( 

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

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

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

75 default=400, 

76 ) 

77 unitsDict = DictField[str, str]( 

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

79 default={ 

80 "astromOffsetMean": "arcsec", 

81 "astromOffsetStd": "arcsec", 

82 "psfSigma": "pixels", 

83 "skyBg": "counts", 

84 "skyNoise": "counts", 

85 "visit": "number", 

86 "detector": "number", 

87 "zenithDistance": "deg", 

88 "zeroPoint": "mag", 

89 "ra": "deg", 

90 "decl": "deg", 

91 "xFp": "mm", 

92 "yFp": "mm", 

93 "medianE": "", 

94 "psfStarScaledDeltaSizeScatter": "", 

95 }, 

96 ) 

97 sortedFullBandList = ListField[str]( 

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

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

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

101 ) 

102 bandLabelColorDict = DictField[str, str]( 

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

104 default={ 

105 "u": "tab:purple", 

106 "g": "tab:blue", 

107 "r": "tab:green", 

108 "i": "gold", 

109 "z": "tab:orange", 

110 "y": "tab:red", 

111 "N921": "tab:pink", 

112 }, 

113 ) 

114 vectorsToLoadList = ListField[str]( 

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

116 default=[ 

117 "visitId", 

118 "detector", 

119 "band", 

120 "ra", 

121 "decl", 

122 "zeroPoint", 

123 "psfSigma", 

124 "skyBg", 

125 "astromOffsetMean", 

126 "psfStarDeltaE1Median", 

127 "psfStarDeltaE2Median", 

128 "psfStarScaledDeltaSizeScatter", 

129 "llcra", 

130 "lrcra", 

131 "ulcra", 

132 "urcra", 

133 "llcdec", 

134 "lrcdec", 

135 "ulcdec", 

136 "urcdec", 

137 "xSize", 

138 "ySize", 

139 ], 

140 ) 

141 parametersToPlotList = ListField[str]( 

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

143 "plot these parameters for each band.", 

144 default=[ 

145 "psfSigma", 

146 "astromOffsetMean", 

147 "medianE", 

148 "psfStarScaledDeltaSizeScatter", 

149 "skyBg", 

150 "zeroPoint", 

151 ], 

152 ) 

153 tractsToPlotList = ListField[int]( 

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

155 default=None, 

156 optional=True, 

157 ) 

158 trimToTract = Field[bool]( 

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

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

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

162 default=False, 

163 ) 

164 doScatterInRaDec = Field[bool]( 

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

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

167 default=False, 

168 ) 

169 plotAllTractOutlines = Field[bool]( 

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

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

172 default=False, 

173 ) 

174 raDecLimitsDict = DictField[str, float]( 

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

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

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

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

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

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

181 default=None, 

182 optional=True, 

183 ) 

184 plotDetectorOutline = Field[bool]( 

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

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

187 "in the inputs.", 

188 default=False, 

189 ) 

190 

191 def getInputSchema(self) -> KeyedDataSchema: 

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

193 for vector in self.vectorsToLoadList: 

194 base.append((vector, Vector)) 

195 return base 

196 

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

198 self._validateInput(data, **kwargs) 

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

200 

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

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

203 check that the data is consistent with Vector. 

204 """ 

205 needed = self.getFormattedInputSchema(**kwargs) 

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

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

208 }: 

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

210 for name, typ in needed: 

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

212 if isScalar and typ != Scalar: 

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

214 

215 if "medianE" in self.parametersToPlotList: 

216 if not all( 

217 vector in self.vectorsToLoadList 

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

219 ): 

220 raise RuntimeError( 

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

222 "psfStarDeltaE2Median must be included in vectorsToLoadList." 

223 ) 

224 if self.raDecLimitsDict is not None: 

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

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

227 raise RuntimeError( 

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

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

230 ) 

231 

232 def makePlot( 

233 self, 

234 data: KeyedData, 

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

236 camera: Optional[Camera] = None, 

237 skymap: Optional[BaseSkyMap] = None, 

238 calibrateConfig: Optional[Config] = None, 

239 makeWarpConfig: Optional[Config] = None, 

240 **kwargs, 

241 ) -> Figure: 

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

243 

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

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

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

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

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

249 

250 Parameters 

251 ---------- 

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

253 The key-based catalog of data to plot. 

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

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

256 least) keys: 

257 

258 `"run"` 

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

260 `"tableName"` 

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

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

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

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

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

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

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

268 ``plotDetectorOutline`` is `True`). 

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

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

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

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

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

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

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

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

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

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

279 

280 Returns 

281 ------- 

282 fig : `matplotlib.figure.Figure` 

283 The resulting figure. 

284 """ 

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

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

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

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

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

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

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

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

293 

294 if "medianE" in self.parametersToPlotList: 

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

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

297 ) 

298 

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

300 notFoundKeys = [] 

301 for zKey in self.parametersToPlotList: 

302 if zKey not in data.keys(): 

303 notFoundKeys.append(zKey) 

304 if len(notFoundKeys) > 0: 

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

306 

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

308 

309 if makeWarpConfig is None: 

310 maxEllipResidual = 0.007 

311 maxScaledSizeScatter = 0.009 

312 else: 

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

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

315 

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

317 if self.projection == "focalPlane": 

318 if camera is None: 

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

320 xKey = "xFp" 

321 yKey = "yFp" 

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

323 detFpDict = {} 

324 for det in camera: # type: ignore 

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

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

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

328 xFpList = [] 

329 yFpList = [] 

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

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

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

333 

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

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

336 

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

338 xCorners, yCorners = zip(*corners) 

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

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

341 tractList: List[int] = [] 

342 elif self.projection == "raDec": 

343 xKey = "ra" 

344 yKey = "decl" 

345 xScatLen, yScatLen = 0, 0 

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

347 # RA/Dec entries. 

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

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

350 tractList = self.tractsToPlotList 

351 else: 

352 ras = data["ra"] 

353 decs = data["decl"] 

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

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

356 tractLimitsDict = self._getTractLimitsDict(skymap, tractList) 

357 else: 

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

359 

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

361 

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

363 data = self._trimDataToTracts(data, skymap) 

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

365 if nData == 0: 

366 raise RuntimeError( 

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

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

369 ) 

370 

371 if self.doScatterInRaDec: 

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

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

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

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

376 

377 dataDf = pd.DataFrame(data) 

378 nDataId = len(dataDf) 

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

380 if nDataId == 0: 

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

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

383 

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

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

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

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

388 if len(missingBandList) > 0: 

389 log.warning( 

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

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

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

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

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

395 ) 

396 bandList.extend(missingBandList) 

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

398 

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

400 if self.doScatterInRaDec: 

401 ptSize = min(3, 0.7 * ptSize) 

402 

403 nRow = len(bandList) 

404 nCol = len(self.parametersToPlotList) 

405 colMultiplier = 4 if nCol == 1 else 2.5 

406 fig, axes = plt.subplots( 

407 nRow, 

408 nCol, 

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

410 constrained_layout=True, 

411 ) 

412 

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

414 # all bands. 

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

416 vMinDict = {} 

417 vMaxDict = {} 

418 for zKey in self.parametersToPlotList: 

419 zKeySorted = dataDf[zKey].sort_values() 

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

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

422 if zKey == "medianE": 

423 vMaxDict[zKey] = maxEllipResidual 

424 elif zKey == "psfStarScaledDeltaSizeScatter": 

425 vMaxDict[zKey] = maxScaledSizeScatter 

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

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

428 else: 

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

430 

431 for iRow, band in enumerate(bandList): 

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

433 

434 nDataIdBand = len(dataBand) 

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

436 if nDataIdBand < 2: 

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

438 continue 

439 

440 for zKey in self.parametersToPlotList: 

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

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

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

444 zKeySorted = dataBand[zKey].sort_values() 

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

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

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

448 

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

450 if self.doScatterInRaDec: 

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

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

453 xScatter = scatRads * np.cos(scatTheta) 

454 yScatter = scatRads * np.sin(scatTheta) 

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

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

457 else: 

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

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

460 xLabel = xKey 

461 yLabel = yKey 

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

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

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

465 if self.projection == "focalPlane": 

466 for det in camera: # type: ignore 

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

468 detId = int(det.getId()) 

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

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

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

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

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

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

475 

476 if band not in self.bandLabelColorDict: 

477 log.warning( 

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

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

480 ) 

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

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

483 

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

485 if self.cmapName is None: 

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

487 else: 

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

489 

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

491 cmap.set_over("red") 

492 elif ( 

493 zKey in ["astromOffsetMean"] 

494 and self.projection != "raDec" 

495 and vMaxDict[zKey] >= maxMeanDistanceArcsec 

496 ): 

497 cmap.set_over("red") 

498 else: 

499 if self.cmapName is None: 

500 cmap.set_over("black") 

501 else: 

502 cmap.set_over("lemonchiffon") 

503 

504 if zKey in ["psfSigma"]: 

505 cmap.set_under("red") 

506 else: 

507 if self.cmapName is None: 

508 cmap.set_under("lemonchiffon") 

509 else: 

510 cmap.set_under("black") 

511 

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

513 

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

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

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

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

518 ax.set_aspect("equal") 

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

520 

521 if self.projection == "focalPlane": 

522 for det in camera: # type: ignore 

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

524 corners = det.getCorners(FOCAL_PLANE) 

525 xCorners, yCorners = zip(*corners) 

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

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

528 detId = int(det.getId()) 

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

530 if len(perDetData) < 1: 

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

532 continue 

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

534 log.debug( 

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

536 ) 

537 continue 

538 pcm = plotProjectionWithBinning( 

539 ax, 

540 perDetData["xScat"].values, 

541 perDetData["yScat"].values, 

542 perDetData[zKey].values, 

543 cmap, 

544 xFpMin, 

545 xFpMax, 

546 yFpMin, 

547 yFpMax, 

548 xNumBins=1, 

549 fixAroundZero=False, 

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

551 isSorted=False, 

552 vmin=vMinDict[zKey], 

553 vmax=vMaxDict[zKey], 

554 scatPtSize=ptSize, 

555 ) 

556 if self.projection == "raDec": 

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

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

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

560 pcm = plotProjectionWithBinning( 

561 ax, 

562 dataBand["xScat"].values, 

563 dataBand["yScat"].values, 

564 dataBand[zKey].values, 

565 cmap, 

566 raMin, 

567 raMax, 

568 decMin, 

569 decMax, 

570 xNumBins=self.nBins, 

571 fixAroundZero=False, 

572 nPointBinThresh=self.nPointBinThresh, 

573 isSorted=False, 

574 vmin=vMinDict[zKey], 

575 vmax=vMaxDict[zKey], 

576 scatPtSize=ptSize, 

577 ) 

578 

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

580 # equal axis ratio panels. 

581 if iRow == 0 and iCol == 0: 

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

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

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

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

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

587 else: 

588 xLimMin, xLimMax = ax.get_xlim() 

589 yLimMin, yLimMax = ax.get_ylim() 

590 if self.projection == "focalPlane": 

591 minDim = ( 

592 max( 

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

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

595 ) 

596 / 2 

597 ) 

598 xLimMin = min(-minDim, xLimMin) 

599 xLimMax = max(minDim, xLimMax) 

600 if self.trimToTract: 

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

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

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

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

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

606 xDelta = xLimMax - xLimMin 

607 xLimMin -= 0.04 * xDelta 

608 xLimMax += 0.04 * xDelta 

609 

610 xLimMin, xLimMax, yLimMin, yLimMax = _setLimitsToEqualRatio( 

611 xLimMin, xLimMax, yLimMin, yLimMax 

612 ) 

613 limRange = xLimMax - xLimMin 

614 yTickFmt = _tickFormatter(yLimMin * 10) 

615 

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

617 allTractsList = [] 

618 for tractInfo in skymap: # type: ignore 

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

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

621 if ( 

622 centerRa > xLimMin 

623 and centerRa < xLimMax 

624 and centerDec > yLimMin 

625 and centerDec < yLimMax 

626 ): 

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

628 tractLimitsDict = self._getTractLimitsDict(skymap, allTractsList) 

629 

630 upperHandles = [] 

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

632 patch = mpl.patches.Circle( 

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

634 radius=scatRad, 

635 facecolor="gray", 

636 edgecolor="None", 

637 alpha=0.2, 

638 label="scatter area", 

639 ) 

640 ax.add_patch(patch) 

641 upperHandles.append(patch) 

642 

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

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

645 if camera is None: 

646 log.warning( 

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

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

649 ) 

650 else: 

651 # Calculate area of polygon with known vertices. 

652 x1, x2, x3, x4 = ( 

653 dataBand["llcra"], 

654 dataBand["lrcra"], 

655 dataBand["urcra"], 

656 dataBand["ulcra"], 

657 ) 

658 y1, y2, y3, y4 = ( 

659 dataBand["llcdec"], 

660 dataBand["lrcdec"], 

661 dataBand["urcdec"], 

662 dataBand["ulcdec"], 

663 ) 

664 areaDeg = ( 

665 np.abs( 

666 (x1 * y2 - y1 * x2) 

667 + (x2 * y3 - y2 * x3) 

668 + (x3 * y4 - y3 * x4) 

669 + (x4 * y1 - y4 * x1) 

670 ) 

671 / 2.0 

672 ) 

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

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

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

676 

677 patch = mpl.patches.Rectangle( 

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

679 detWidthDeg, 

680 detHeightDeg, 

681 facecolor="turquoise", 

682 alpha=0.3, 

683 label="det size", 

684 ) 

685 ax.add_patch(patch) 

686 upperHandles.append(patch) 

687 

688 if self.projection == "raDec": 

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

690 ax.legend( 

691 handles=upperHandles, 

692 loc="upper left", 

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

694 edgecolor="black", 

695 framealpha=0.4, 

696 fontsize=5, 

697 ) 

698 

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

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

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

702 if len(tractList) > 0: 

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

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

705 if limRange <= 5 * tractWidthDeg: 

706 deltaLim = 0.05 * limRange 

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

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

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

710 if ( 

711 centerRa > xLimMin + deltaLim 

712 and centerRa < xLimMax - deltaLim 

713 and centerDec > yLimMin + deltaLim 

714 and centerDec < yLimMax - deltaLim 

715 ): 

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

717 fontSize = 3 if limRange < 20 else 2 

718 ax.annotate( 

719 str(tract), 

720 tractLimits["center"], 

721 va="center", 

722 ha="center", 

723 color="dimgray", 

724 fontsize=fontSize, 

725 annotation_clip=True, 

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

727 ) 

728 if self.projection == "raDec": 

729 ax.set_xlim(xLimMax, xLimMin) 

730 else: 

731 ax.set_xlim(xLimMin, xLimMax) 

732 ax.set_ylim(yLimMin, yLimMax) 

733 ax.yaxis.set_major_formatter(yTickFmt) 

734 

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

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

737 # has the same effect on all colorbars. 

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

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

740 value = vMinDict[zKey] 

741 tickFmt = _tickFormatter(value) 

742 cb = fig.colorbar( 

743 pcm, 

744 ax=ax, 

745 extend="both", 

746 aspect=14, 

747 format=tickFmt, 

748 pad=0.02, 

749 ) 

750 

751 cbLabel = zKey 

752 if zKey not in self.unitsDict: 

753 log.warning( 

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

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

756 ) 

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

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

759 

760 cb.set_label( 

761 cbLabel, 

762 labelpad=-29, 

763 color="black", 

764 fontsize=6, 

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

766 ) 

767 

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

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

770 if nCol == 1: 

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

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

773 

774 return fig 

775 

776 def _getTractLimitsDict(self, skymap, tractList): 

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

778 

779 Parameters 

780 ---------- 

781 skymap : `lsst.skymap.BaseSkyMap` 

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

783 parameters. 

784 tractList : `list` [`int`] 

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

786 limits. 

787 

788 Returns 

789 ------- 

790 tractLimitsDict : `dict` [`dict`] 

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

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

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

794 outlines. 

795 """ 

796 tractLimitsDict = {} 

797 for tract in tractList: 

798 tractInfo = skymap[tract] 

799 tractBbox = tractInfo.outer_sky_polygon.getBoundingBox() 

800 tractCenter = tractBbox.getCenter() 

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

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

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

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

805 tractLimitsDict[tract] = { 

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

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

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

809 } 

810 

811 return tractLimitsDict 

812 

813 def _planeAreaSelector( 

814 self, 

815 data, 

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

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

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

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

820 xKey="ra", 

821 yKey="decl", 

822 ): 

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

824 

825 Parameters 

826 ---------- 

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

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

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

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

831 down-select the data. 

832 xKey, yKey : `str` 

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

834 

835 Returns 

836 ------- 

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

838 The down-selected catalog. 

839 """ 

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

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

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

843 keyedSelector.selectors.xSelector = xSelector 

844 keyedSelector.selectors.ySelector = ySelector 

845 downSelectedData = keyedSelector(data) 

846 

847 return downSelectedData 

848 

849 def _trimDataToTracts(self, data, skymap): 

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

851 

852 Parameters 

853 ---------- 

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

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

856 skymap : `lsst.skymap.BaseSkyMap` 

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

858 parameters. 

859 

860 Returns 

861 ------- 

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

863 The down-selected catalog. 

864 """ 

865 tractRaMin = 1e12 

866 tractRaMax = -1e12 

867 tractDecMin = 1e12 

868 tractDecMax = -1e12 

869 for tract in self.tractsToPlotList: 

870 tractInfo = skymap[tract] 

871 tractBbox = tractInfo.outer_sky_polygon.getBoundingBox() 

872 tractCenter = tractBbox.getCenter() 

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

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

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

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

877 tractRaMin = min(tractRaMin, tractRa0) 

878 tractRaMax = max(tractRaMax, tractRa1) 

879 tractDecMin = min(tractDecMin, tractDec0) 

880 tractDecMax = max(tractDecMax, tractDec1) 

881 downSelectedData = self._planeAreaSelector( 

882 data, 

883 xMin=tractRaMin, 

884 xMax=tractRaMax, 

885 yMin=tractDecMin, 

886 yMax=tractDecMax, 

887 xKey="ra", 

888 yKey="decl", 

889 ) 

890 return downSelectedData 

891 

892 

893def _tickFormatter(value): 

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

895 same length. 

896 

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

898 

899 Parameters 

900 ---------- 

901 value : `float` 

902 The the value used to determine the appropriate formatting. 

903 

904 Returns 

905 ------- 

906 tickFmt : `matplotlib.ticker.FormatStrFormatter` 

907 The tick formatter to use with matplotlib functions. 

908 """ 

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

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

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

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

913 if value < 0: 

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

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

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

917 if value < 0: 

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

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

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

921 if value < 0: 

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

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

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

925 if value < 0: 

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

927 else: 

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

929 if value < 0: 

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

931 return tickFmt 

932 

933 

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

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

936 

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

938 preserved. 

939 

940 Parameters 

941 ---------- 

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

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

944 range while perserving the central values. 

945 

946 Returns 

947 ------- 

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

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

950 """ 

951 xDelta = xMax - xMin 

952 yDelta = yMax - yMin 

953 deltaDiff = yDelta - xDelta 

954 if deltaDiff > 0: 

955 xMin -= 0.5 * deltaDiff 

956 xMax += 0.5 * deltaDiff 

957 elif deltaDiff < 0: 

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

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

960 return xMin, xMax, yMin, yMax