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

407 statements  

« prev     ^ index     » next       coverage.py v7.4.0, created at 2024-01-10 14:08 +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 ...math import nanMax, nanMean, nanMedian, nanMin 

40from ..keyedData import KeyedDataSelectorAction 

41from ..vector.selectors import RangeSelector 

42from .plotUtils import mkColormap, plotProjectionWithBinning 

43 

44log = logging.getLogger(__name__) 

45 

46 

47class MultiVisitCoveragePlot(PlotAction): 

48 """Plot the coverage for a set of visits.""" 

49 

50 plotName = Field[str]( 

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

52 optional=False, 

53 ) 

54 cmapName = Field[str]( 

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

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

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

58 default=None, 

59 optional=True, 

60 ) 

61 projection = Field[str]( 

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

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

64 default="raDec", 

65 ) 

66 nBins = Field[int]( 

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

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

69 "effectively one per detector).", 

70 default=25, 

71 ) 

72 nPointBinThresh = Field[int]( 

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

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

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

76 default=400, 

77 ) 

78 unitsDict = DictField[str, str]( 

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

80 default={ 

81 "astromOffsetMean": "arcsec", 

82 "astromOffsetStd": "arcsec", 

83 "psfSigma": "pixels", 

84 "skyBg": "counts", 

85 "skyNoise": "counts", 

86 "visit": "number", 

87 "detector": "number", 

88 "zenithDistance": "deg", 

89 "zeroPoint": "mag", 

90 "ra": "deg", 

91 "decl": "deg", 

92 "xFp": "mm", 

93 "yFp": "mm", 

94 "medianE": "", 

95 "psfStarScaledDeltaSizeScatter": "", 

96 }, 

97 ) 

98 sortedFullBandList = ListField[str]( 

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

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

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

102 ) 

103 bandLabelColorDict = DictField[str, str]( 

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

105 default={ 

106 "u": "tab:purple", 

107 "g": "tab:blue", 

108 "r": "tab:green", 

109 "i": "gold", 

110 "z": "tab:orange", 

111 "y": "tab:red", 

112 "N921": "tab:pink", 

113 }, 

114 ) 

115 vectorsToLoadList = ListField[str]( 

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

117 default=[ 

118 "visitId", 

119 "detector", 

120 "band", 

121 "ra", 

122 "decl", 

123 "zeroPoint", 

124 "psfSigma", 

125 "skyBg", 

126 "astromOffsetMean", 

127 "psfStarDeltaE1Median", 

128 "psfStarDeltaE2Median", 

129 "psfStarScaledDeltaSizeScatter", 

130 "llcra", 

131 "lrcra", 

132 "ulcra", 

133 "urcra", 

134 "llcdec", 

135 "lrcdec", 

136 "ulcdec", 

137 "urcdec", 

138 "xSize", 

139 "ySize", 

140 ], 

141 ) 

142 parametersToPlotList = ListField[str]( 

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

144 "plot these parameters for each band.", 

145 default=[ 

146 "psfSigma", 

147 "astromOffsetMean", 

148 "medianE", 

149 "psfStarScaledDeltaSizeScatter", 

150 "skyBg", 

151 "zeroPoint", 

152 ], 

153 ) 

154 tractsToPlotList = ListField[int]( 

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

156 default=None, 

157 optional=True, 

158 ) 

159 trimToTract = Field[bool]( 

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

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

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

163 default=False, 

164 ) 

165 doScatterInRaDec = Field[bool]( 

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

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

168 default=False, 

169 ) 

170 plotAllTractOutlines = Field[bool]( 

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

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

173 default=False, 

174 ) 

175 raDecLimitsDict = DictField[str, float]( 

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

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

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

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

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

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

182 default=None, 

183 optional=True, 

184 ) 

185 plotDetectorOutline = Field[bool]( 

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

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

188 "in the inputs.", 

189 default=False, 

190 ) 

191 

192 def getInputSchema(self) -> KeyedDataSchema: 

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

194 for vector in self.vectorsToLoadList: 

195 base.append((vector, Vector)) 

196 return base 

197 

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

199 self._validateInput(data, **kwargs) 

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

201 

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

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

204 check that the data is consistent with Vector. 

205 """ 

206 needed = self.getFormattedInputSchema(**kwargs) 

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

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

209 }: 

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

211 for name, typ in needed: 

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

213 if isScalar and typ != Scalar: 

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

215 

216 if "medianE" in self.parametersToPlotList: 

217 if not all( 

218 vector in self.vectorsToLoadList 

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

220 ): 

221 raise RuntimeError( 

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

223 "psfStarDeltaE2Median must be included in vectorsToLoadList." 

224 ) 

225 if self.raDecLimitsDict is not None: 

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

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

228 raise RuntimeError( 

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

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

231 ) 

232 

233 def makePlot( 

234 self, 

235 data: KeyedData, 

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

237 camera: Optional[Camera] = None, 

238 skymap: Optional[BaseSkyMap] = None, 

239 calibrateConfig: Optional[Config] = None, 

240 makeWarpConfig: Optional[Config] = None, 

241 **kwargs, 

242 ) -> Figure: 

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

244 

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

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

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

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

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

250 

251 Parameters 

252 ---------- 

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

254 The key-based catalog of data to plot. 

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

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

257 least) keys: 

258 

259 `"run"` 

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

261 `"tableName"` 

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

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

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

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

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

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

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

269 ``plotDetectorOutline`` is `True`). 

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

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

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

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

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

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

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

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

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

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

280 

281 Returns 

282 ------- 

283 fig : `matplotlib.figure.Figure` 

284 The resulting figure. 

285 """ 

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

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

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

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

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

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

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

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

294 

295 if "medianE" in self.parametersToPlotList: 

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

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

298 ) 

299 

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

301 notFoundKeys = [] 

302 for zKey in self.parametersToPlotList: 

303 if zKey not in data.keys(): 

304 notFoundKeys.append(zKey) 

305 if len(notFoundKeys) > 0: 

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

307 

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

309 

310 if makeWarpConfig is None: 

311 maxEllipResidual = 0.007 

312 maxScaledSizeScatter = 0.009 

313 else: 

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

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

316 

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

318 if self.projection == "focalPlane": 

319 if camera is None: 

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

321 xKey = "xFp" 

322 yKey = "yFp" 

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

324 detFpDict = {} 

325 for det in camera: # type: ignore 

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

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

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

329 xFpList = [] 

330 yFpList = [] 

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

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

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

334 

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

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

337 

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

339 xCorners, yCorners = zip(*corners) 

340 xScatLen = 0.4 * (nanMax(xCorners) - nanMin(xCorners)) 

341 yScatLen = 0.4 * (nanMax(yCorners) - nanMin(yCorners)) 

342 tractList: List[int] = [] 

343 elif self.projection == "raDec": 

344 xKey = "ra" 

345 yKey = "decl" 

346 xScatLen, yScatLen = 0, 0 

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

348 # RA/Dec entries. 

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

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

351 tractList = self.tractsToPlotList 

352 else: 

353 ras = data["ra"] 

354 decs = data["decl"] 

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

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

357 tractLimitsDict = self._getTractLimitsDict(skymap, tractList) 

358 else: 

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

360 

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

362 

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

364 data = self._trimDataToTracts(data, skymap) 

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

366 if nData == 0: 

367 raise RuntimeError( 

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

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

370 ) 

371 

372 if self.doScatterInRaDec: 

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

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

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

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

377 

378 dataDf = pd.DataFrame(data) 

379 nDataId = len(dataDf) 

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

381 if nDataId == 0: 

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

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

384 

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

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

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

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

389 if len(missingBandList) > 0: 

390 log.warning( 

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

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

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

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

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

396 ) 

397 bandList.extend(missingBandList) 

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

399 

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

401 if self.doScatterInRaDec: 

402 ptSize = min(3, 0.7 * ptSize) 

403 

404 nRow = len(bandList) 

405 nCol = len(self.parametersToPlotList) 

406 colMultiplier = 4 if nCol == 1 else 2.5 

407 fig, axes = plt.subplots( 

408 nRow, 

409 nCol, 

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

411 constrained_layout=True, 

412 ) 

413 

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

415 # all bands. 

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

417 vMinDict = {} 

418 vMaxDict = {} 

419 for zKey in self.parametersToPlotList: 

420 zKeySorted = dataDf[zKey].sort_values() 

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

422 vMinDict[zKey] = nanMean(zKeySorted.head(nPercent)) 

423 if zKey == "medianE": 

424 vMaxDict[zKey] = maxEllipResidual 

425 elif zKey == "psfStarScaledDeltaSizeScatter": 

426 vMaxDict[zKey] = maxScaledSizeScatter 

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

428 vMaxDict[zKey] = min(maxMeanDistanceArcsec, 1.1 * nanMean(zKeySorted.tail(nPercent))) 

429 else: 

430 vMaxDict[zKey] = nanMean(zKeySorted.tail(nPercent)) 

431 

432 for iRow, band in enumerate(bandList): 

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

434 

435 nDataIdBand = len(dataBand) 

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

437 if nDataIdBand < 2: 

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

439 continue 

440 

441 for zKey in self.parametersToPlotList: 

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

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

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

445 zKeySorted = dataBand[zKey].sort_values() 

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

447 vMinDict[zKey] = nanMean(zKeySorted.head(nPercent)) 

448 vMaxDict[zKey] = nanMean(zKeySorted.tail(nPercent)) 

449 

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

451 if self.doScatterInRaDec: 

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

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

454 xScatter = scatRads * np.cos(scatTheta) 

455 yScatter = scatRads * np.sin(scatTheta) 

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

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

458 else: 

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

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

461 xLabel = xKey 

462 yLabel = yKey 

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

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

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

466 if self.projection == "focalPlane": 

467 for det in camera: # type: ignore 

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

469 detId = int(det.getId()) 

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

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

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

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

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

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

476 

477 if band not in self.bandLabelColorDict: 

478 log.warning( 

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

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

481 ) 

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

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

484 

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

486 if self.cmapName is None: 

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

488 else: 

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

490 

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

492 cmap.set_over("red") 

493 elif ( 

494 zKey in ["astromOffsetMean"] 

495 and self.projection != "raDec" 

496 and vMaxDict[zKey] >= maxMeanDistanceArcsec 

497 ): 

498 cmap.set_over("red") 

499 else: 

500 if self.cmapName is None: 

501 cmap.set_over("black") 

502 else: 

503 cmap.set_over("lemonchiffon") 

504 

505 if zKey in ["psfSigma"]: 

506 cmap.set_under("red") 

507 else: 

508 if self.cmapName is None: 

509 cmap.set_under("lemonchiffon") 

510 else: 

511 cmap.set_under("black") 

512 

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

514 

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

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

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

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

519 ax.set_aspect("equal") 

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

521 

522 if self.projection == "focalPlane": 

523 for det in camera: # type: ignore 

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

525 corners = det.getCorners(FOCAL_PLANE) 

526 xCorners, yCorners = zip(*corners) 

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

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

529 detId = int(det.getId()) 

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

531 if len(perDetData) < 1: 

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

533 continue 

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

535 log.debug( 

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

537 ) 

538 continue 

539 pcm = plotProjectionWithBinning( 

540 ax, 

541 perDetData["xScat"].values, 

542 perDetData["yScat"].values, 

543 perDetData[zKey].values, 

544 cmap, 

545 xFpMin, 

546 xFpMax, 

547 yFpMin, 

548 yFpMax, 

549 xNumBins=1, 

550 fixAroundZero=False, 

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

552 isSorted=False, 

553 vmin=vMinDict[zKey], 

554 vmax=vMaxDict[zKey], 

555 scatPtSize=ptSize, 

556 ) 

557 if self.projection == "raDec": 

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

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

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

561 pcm = plotProjectionWithBinning( 

562 ax, 

563 dataBand["xScat"].values, 

564 dataBand["yScat"].values, 

565 dataBand[zKey].values, 

566 cmap, 

567 raMin, 

568 raMax, 

569 decMin, 

570 decMax, 

571 xNumBins=self.nBins, 

572 fixAroundZero=False, 

573 nPointBinThresh=self.nPointBinThresh, 

574 isSorted=False, 

575 vmin=vMinDict[zKey], 

576 vmax=vMaxDict[zKey], 

577 scatPtSize=ptSize, 

578 ) 

579 

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

581 # equal axis ratio panels. 

582 if iRow == 0 and iCol == 0: 

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

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

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

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

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

588 else: 

589 xLimMin, xLimMax = ax.get_xlim() 

590 yLimMin, yLimMax = ax.get_ylim() 

591 if self.projection == "focalPlane": 

592 minDim = ( 

593 max( 

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

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

596 ) 

597 / 2 

598 ) 

599 xLimMin = min(-minDim, xLimMin) 

600 xLimMax = max(minDim, xLimMax) 

601 if self.trimToTract: 

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

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

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

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

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

607 xDelta = xLimMax - xLimMin 

608 xLimMin -= 0.04 * xDelta 

609 xLimMax += 0.04 * xDelta 

610 

611 xLimMin, xLimMax, yLimMin, yLimMax = _setLimitsToEqualRatio( 

612 xLimMin, xLimMax, yLimMin, yLimMax 

613 ) 

614 limRange = xLimMax - xLimMin 

615 yTickFmt = _tickFormatter(yLimMin * 10) 

616 

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

618 allTractsList = [] 

619 for tractInfo in skymap: # type: ignore 

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

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

622 if ( 

623 centerRa > xLimMin 

624 and centerRa < xLimMax 

625 and centerDec > yLimMin 

626 and centerDec < yLimMax 

627 ): 

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

629 tractLimitsDict = self._getTractLimitsDict(skymap, allTractsList) 

630 

631 upperHandles = [] 

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

633 patch = mpl.patches.Circle( 

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

635 radius=scatRad, 

636 facecolor="gray", 

637 edgecolor="None", 

638 alpha=0.2, 

639 label="scatter area", 

640 ) 

641 ax.add_patch(patch) 

642 upperHandles.append(patch) 

643 

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

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

646 if camera is None: 

647 log.warning( 

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

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

650 ) 

651 else: 

652 # Calculate area of polygon with known vertices. 

653 x1, x2, x3, x4 = ( 

654 dataBand["llcra"], 

655 dataBand["lrcra"], 

656 dataBand["urcra"], 

657 dataBand["ulcra"], 

658 ) 

659 y1, y2, y3, y4 = ( 

660 dataBand["llcdec"], 

661 dataBand["lrcdec"], 

662 dataBand["urcdec"], 

663 dataBand["ulcdec"], 

664 ) 

665 areaDeg = ( 

666 np.abs( 

667 (x1 * y2 - y1 * x2) 

668 + (x2 * y3 - y2 * x3) 

669 + (x3 * y4 - y3 * x4) 

670 + (x4 * y1 - y4 * x1) 

671 ) 

672 / 2.0 

673 ) 

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

675 detWidthDeg = nanMedian(detScaleDeg * dataBand["xSize"]) 

676 detHeightDeg = nanMedian(detScaleDeg * dataBand["ySize"]) 

677 

678 patch = mpl.patches.Rectangle( 

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

680 detWidthDeg, 

681 detHeightDeg, 

682 facecolor="turquoise", 

683 alpha=0.3, 

684 label="det size", 

685 ) 

686 ax.add_patch(patch) 

687 upperHandles.append(patch) 

688 

689 if self.projection == "raDec": 

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

691 ax.legend( 

692 handles=upperHandles, 

693 loc="upper left", 

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

695 edgecolor="black", 

696 framealpha=0.4, 

697 fontsize=5, 

698 ) 

699 

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

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

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

703 if len(tractList) > 0: 

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

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

706 if limRange <= 5 * tractWidthDeg: 

707 deltaLim = 0.05 * limRange 

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

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

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

711 if ( 

712 centerRa > xLimMin + deltaLim 

713 and centerRa < xLimMax - deltaLim 

714 and centerDec > yLimMin + deltaLim 

715 and centerDec < yLimMax - deltaLim 

716 ): 

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

718 fontSize = 3 if limRange < 20 else 2 

719 ax.annotate( 

720 str(tract), 

721 tractLimits["center"], 

722 va="center", 

723 ha="center", 

724 color="dimgray", 

725 fontsize=fontSize, 

726 annotation_clip=True, 

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

728 ) 

729 if self.projection == "raDec": 

730 ax.set_xlim(xLimMax, xLimMin) 

731 else: 

732 ax.set_xlim(xLimMin, xLimMax) 

733 ax.set_ylim(yLimMin, yLimMax) 

734 ax.yaxis.set_major_formatter(yTickFmt) 

735 

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

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

738 # has the same effect on all colorbars. 

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

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

741 value = vMinDict[zKey] 

742 tickFmt = _tickFormatter(value) 

743 cb = fig.colorbar( 

744 pcm, 

745 ax=ax, 

746 extend="both", 

747 aspect=14, 

748 format=tickFmt, 

749 pad=0.02, 

750 ) 

751 

752 cbLabel = zKey 

753 if zKey not in self.unitsDict: 

754 log.warning( 

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

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

757 ) 

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

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

760 

761 cb.set_label( 

762 cbLabel, 

763 labelpad=-29, 

764 color="black", 

765 fontsize=6, 

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

767 ) 

768 

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

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

771 if nCol == 1: 

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

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

774 

775 return fig 

776 

777 def _getTractLimitsDict(self, skymap, tractList): 

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

779 

780 Parameters 

781 ---------- 

782 skymap : `lsst.skymap.BaseSkyMap` 

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

784 parameters. 

785 tractList : `list` [`int`] 

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

787 limits. 

788 

789 Returns 

790 ------- 

791 tractLimitsDict : `dict` [`dict`] 

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

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

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

795 outlines. 

796 """ 

797 tractLimitsDict = {} 

798 for tract in tractList: 

799 tractInfo = skymap[tract] 

800 tractBbox = tractInfo.outer_sky_polygon.getBoundingBox() 

801 tractCenter = tractBbox.getCenter() 

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

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

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

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

806 tractLimitsDict[tract] = { 

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

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

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

810 } 

811 

812 return tractLimitsDict 

813 

814 def _planeAreaSelector( 

815 self, 

816 data, 

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

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

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

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

821 xKey="ra", 

822 yKey="decl", 

823 ): 

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

825 

826 Parameters 

827 ---------- 

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

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

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

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

832 down-select the data. 

833 xKey, yKey : `str` 

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

835 

836 Returns 

837 ------- 

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

839 The down-selected catalog. 

840 """ 

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

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

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

844 keyedSelector.selectors.xSelector = xSelector 

845 keyedSelector.selectors.ySelector = ySelector 

846 downSelectedData = keyedSelector(data) 

847 

848 return downSelectedData 

849 

850 def _trimDataToTracts(self, data, skymap): 

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

852 

853 Parameters 

854 ---------- 

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

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

857 skymap : `lsst.skymap.BaseSkyMap` 

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

859 parameters. 

860 

861 Returns 

862 ------- 

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

864 The down-selected catalog. 

865 """ 

866 tractRaMin = 1e12 

867 tractRaMax = -1e12 

868 tractDecMin = 1e12 

869 tractDecMax = -1e12 

870 for tract in self.tractsToPlotList: 

871 tractInfo = skymap[tract] 

872 tractBbox = tractInfo.outer_sky_polygon.getBoundingBox() 

873 tractCenter = tractBbox.getCenter() 

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

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

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

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

878 tractRaMin = min(tractRaMin, tractRa0) 

879 tractRaMax = max(tractRaMax, tractRa1) 

880 tractDecMin = min(tractDecMin, tractDec0) 

881 tractDecMax = max(tractDecMax, tractDec1) 

882 downSelectedData = self._planeAreaSelector( 

883 data, 

884 xMin=tractRaMin, 

885 xMax=tractRaMax, 

886 yMin=tractDecMin, 

887 yMax=tractDecMax, 

888 xKey="ra", 

889 yKey="decl", 

890 ) 

891 return downSelectedData 

892 

893 

894def _tickFormatter(value): 

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

896 same length. 

897 

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

899 

900 Parameters 

901 ---------- 

902 value : `float` 

903 The the value used to determine the appropriate formatting. 

904 

905 Returns 

906 ------- 

907 tickFmt : `matplotlib.ticker.FormatStrFormatter` 

908 The tick formatter to use with matplotlib functions. 

909 """ 

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

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

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

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

914 if value < 0: 

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

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

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

918 if value < 0: 

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

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

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

922 if value < 0: 

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

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

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

926 if value < 0: 

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

928 else: 

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

930 if value < 0: 

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

932 return tickFmt 

933 

934 

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

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

937 

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

939 preserved. 

940 

941 Parameters 

942 ---------- 

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

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

945 range while perserving the central values. 

946 

947 Returns 

948 ------- 

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

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

951 """ 

952 xDelta = xMax - xMin 

953 yDelta = yMax - yMin 

954 deltaDiff = yDelta - xDelta 

955 if deltaDiff > 0: 

956 xMin -= 0.5 * deltaDiff 

957 xMax += 0.5 * deltaDiff 

958 elif deltaDiff < 0: 

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

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

961 return xMin, xMax, yMin, yMax