Coverage for python/lsst/analysis/tools/actions/plot/multiVisitCoveragePlot.py: 8%
406 statements
« prev ^ index » next coverage.py v7.2.5, created at 2023-05-20 09:58 +0000
« prev ^ index » next coverage.py v7.2.5, created at 2023-05-20 09:58 +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
23__all__ = ("MultiVisitCoveragePlot",)
25import logging
26from typing import List, Mapping, Optional, cast
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
38from ...interfaces import KeyedData, KeyedDataSchema, PlotAction, Scalar, Vector
39from ..keyedData import KeyedDataSelectorAction
40from ..vector.selectors import RangeSelector
41from .plotUtils import mkColormap, plotProjectionWithBinning
43log = logging.getLogger(__name__)
46class MultiVisitCoveragePlot(PlotAction):
47 """Plot the coverage for a set of visits."""
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 )
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
197 def __call__(self, data: KeyedData, **kwargs) -> Mapping[str, Figure] | Figure:
198 self._validateInput(data, **kwargs)
199 return self.makePlot(data, **kwargs)
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}.")
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 )
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.
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).
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:
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.
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
294 if "medianE" in self.parametersToPlotList:
295 data["medianE"] = np.sqrt(
296 data["psfStarDeltaE1Median"] ** 2.0 + data["psfStarDeltaE2Median"] ** 2.0
297 )
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.")
307 maxMeanDistanceArcsec = calibrateConfig.astrometry.maxMeanDistanceArcsec # type: ignore
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
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())
334 data["xFp"] = xFpList # type: ignore
335 data["yFp"] = yFpList # type: ignore
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))
360 perTract = True if self.tractsToPlotList is not None and self.projection == "raDec" else False
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 )
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))
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"]))
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))
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)
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 )
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))
431 for iRow, band in enumerate(bandList):
432 dataBand = dataDf[dataDf["band"] == band].copy()
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
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))
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)
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}
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()
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")
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")
512 titleStr = "band: {} nVisit: {} nData: {}".format(band, nVisitBand, nDataIdBand)
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)
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 )
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
610 xLimMin, xLimMax, yLimMin, yLimMax = _setLimitsToEqualRatio(
611 xLimMin, xLimMax, yLimMin, yLimMax
612 )
613 limRange = xLimMax - xLimMin
614 yTickFmt = _tickFormatter(yLimMin * 10)
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)
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)
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"])
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)
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 )
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)
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 )
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])
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 )
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")
774 return fig
776 def _getTractLimitsDict(self, skymap, tractList):
777 """Return a dict containing tract limits needed for outline plotting.
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.
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 }
811 return tractLimitsDict
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.
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.
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)
847 return downSelectedData
849 def _trimDataToTracts(self, data, skymap):
850 """Trim the data to limits set by tracts in self.tractsToPlotList.
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.
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
893def _tickFormatter(value):
894 """Create a tick formatter such that all anticipated values end up with the
895 same length.
897 This accommodates values ranging from +/-0.0001 -> +/-99999
899 Parameters
900 ----------
901 value : `float`
902 The the value used to determine the appropriate formatting.
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
934def _setLimitsToEqualRatio(xMin, xMax, yMin, yMax):
935 """For a given set of x/y min/max, redefine to have equal aspect ratio.
937 The limits are extended on both ends such that the central value is
938 preserved.
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.
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