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-02 11:54 -0700
« prev ^ index » next coverage.py v7.2.5, created at 2023-05-02 11:54 -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
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 plotName = Field[str](
48 doc="The name for the plot.",
49 optional=False,
50 )
51 cmapName = Field[str](
52 doc="Name of the color map to be used if not using the default color-blind friendly "
53 "orange/blue default (used if this is set to `None`). Any name available via "
54 "`matplotlib.cm` may be used.",
55 default=None,
56 optional=True,
57 )
58 projection = Field[str](
59 doc='Projection to plot. Currently only "raDec" and "focalPlane" are permitted. '
60 "In either case, one point is plotted per visit/detector combination.",
61 default="raDec",
62 )
63 nBins = Field[int](
64 doc="Number of bins to use within the effective plot ranges along the spatial directions. "
65 'Only used in the "raDec" projection (for the "focalPlane" projection, the binning is '
66 "effectively one per detector).",
67 default=25,
68 )
69 nPointBinThresh = Field[int](
70 doc="Threshold number of data points above which binning of the data will be performed in "
71 'the RA/Dec projection. If ``projection`` is "focalPlane", the per-detector nPoint '
72 "threshold is nPointMinThresh/number of science detectors in the given ``camera``.",
73 default=400,
74 )
75 unitsDict = DictField[str, str](
76 doc="A dict mapping a parameter to its appropriate units (for label plotting).",
77 default={
78 "astromOffsetMean": "arcsec",
79 "astromOffsetStd": "arcsec",
80 "psfSigma": "pixels",
81 "skyBg": "counts",
82 "skyNoise": "counts",
83 "visit": "number",
84 "detector": "number",
85 "zenithDistance": "deg",
86 "zeroPoint": "mag",
87 "ra": "deg",
88 "decl": "deg",
89 "xFp": "mm",
90 "yFp": "mm",
91 "medianE": "",
92 "psfStarScaledDeltaSizeScatter": "",
93 },
94 )
95 sortedFullBandList = ListField[str](
96 doc="List of all bands that could, in principle, but do not have to, exist in data table. "
97 "The sorting of the plot panels will follow this list (typically by wavelength).",
98 default=["u", "g", "r", "i", "z", "y", "N921"],
99 )
100 bandLabelColorDict = DictField[str, str](
101 doc="A dict mapping which color to use for the labels of a given band.",
102 default={
103 "u": "tab:purple",
104 "g": "tab:blue",
105 "r": "tab:green",
106 "i": "gold",
107 "z": "tab:orange",
108 "y": "tab:red",
109 "N921": "tab:pink",
110 },
111 )
112 vectorsToLoadList = ListField[str](
113 doc="List of columns to load from input table.",
114 default=[
115 "visitId",
116 "detector",
117 "band",
118 "ra",
119 "decl",
120 "zeroPoint",
121 "psfSigma",
122 "skyBg",
123 "astromOffsetMean",
124 "psfStarDeltaE1Median",
125 "psfStarDeltaE2Median",
126 "psfStarScaledDeltaSizeScatter",
127 "llcra",
128 "lrcra",
129 "ulcra",
130 "urcra",
131 "llcdec",
132 "lrcdec",
133 "ulcdec",
134 "urcdec",
135 "xSize",
136 "ySize",
137 ],
138 )
139 parametersToPlotList = ListField[str](
140 doc="List of paramters to plot. They are plotted along rows and the columns "
141 "plot these parameters for each band.",
142 default=[
143 "psfSigma",
144 "astromOffsetMean",
145 "medianE",
146 "psfStarScaledDeltaSizeScatter",
147 "skyBg",
148 "zeroPoint",
149 ],
150 )
151 tractsToPlotList = ListField[int](
152 doc="List of tracts within which to limit the RA and Dec limits of the plot.",
153 default=None,
154 optional=True,
155 )
156 trimToTract = Field[bool](
157 doc="Whether to trim the plot limits to the tract limit(s). Otherwise, plot "
158 "will be trimmed to data limits (both will be expanded in the smaller range "
159 "direction for an equal aspect square plot).",
160 default=False,
161 )
162 doScatterInRaDec = Field[bool](
163 doc="Whether to scatter the points in RA/Dec before plotting. This may be useful "
164 "for visualization for surveys with tight dithering patterns.",
165 default=False,
166 )
167 plotAllTractOutlines = Field[bool](
168 doc="Whether to plot tract outlines for all tracts within the plot limits "
169 "(regardless if they have any data in them).",
170 default=False,
171 )
172 raDecLimitsDict = DictField[str, float](
173 doc="A dict mapping the RA/Dec limits to apply to the plot. Set to `None` to use "
174 "base limits on the default or the other config options. The dict must contain "
175 "the keys raMin, ramax, decMin, decMax, e.g. "
176 'raDecLimitsDict = {"raMin": 0, "raMax": 360, "decMin": -90, "decMax": 90}. '
177 "Not compatible with ``trimToTract`` or ``tractsToPlotList`` (i.e. the latter two "
178 "will be ignored if the dict is not `None`).",
179 default=None,
180 optional=True,
181 )
182 plotDetectorOutline = Field[bool](
183 doc="Whether to plot a shaded outline of the detector size in the RA/Dec projection"
184 "for reference. Ignored if ``projection`` is not raDec or no camera is provided "
185 "in the inputs.",
186 default=False,
187 )
189 def getInputSchema(self) -> KeyedDataSchema:
190 base: list[tuple[str, type[Vector] | type[Scalar]]] = []
191 for vector in self.vectorsToLoadList:
192 base.append((vector, Vector))
193 return base
195 def __call__(self, data: KeyedData, **kwargs) -> Mapping[str, Figure] | Figure:
196 self._validateInput(data, **kwargs)
197 return self.makePlot(data, **kwargs)
199 def _validateInput(self, data: KeyedData, **kwargs) -> None:
200 """NOTE currently can only check that something is not a Scalar, not
201 check that the data is consistent with Vector.
202 """
203 needed = self.getFormattedInputSchema(**kwargs)
204 if remainder := {key.format(**kwargs) for key, _ in needed} - {
205 key.format(**kwargs) for key in data.keys()
206 }:
207 raise ValueError(f"Task needs keys {remainder} but they were not found in input.")
208 for name, typ in needed:
209 isScalar = issubclass((colType := type(data[name.format(**kwargs)])), Scalar)
210 if isScalar and typ != Scalar:
211 raise ValueError(f"Data keyed by {name} has type {colType} but action requires type {typ}.")
213 if "medianE" in self.parametersToPlotList:
214 if not all(
215 vector in self.vectorsToLoadList
216 for vector in ["psfStarDeltaE1Median", "psfStarDeltaE2Median"]
217 ):
218 raise RuntimeError(
219 "If medianE is to be plotted, both psfStarDeltaE1Median and "
220 "psfStarDeltaE2Median must be included in vectorsToLoadList."
221 )
222 if self.raDecLimitsDict is not None:
223 requiredKeys = set(["raMin", "raMax", "decMin", "decMax"])
224 if requiredKeys != set(self.raDecLimitsDict.keys()):
225 raise RuntimeError(
226 f"The following keys (and only these) are required in raDecLimitDict: {requiredKeys}."
227 f"The dict provided gave {set(self.raDecLimitsDict.keys())}."
228 )
230 def makePlot(
231 self,
232 data: KeyedData,
233 plotInfo: Optional[Mapping[str, str]] = None,
234 camera: Optional[Camera] = None,
235 skymap: Optional[BaseSkyMap] = None,
236 calibrateConfig: Optional[Config] = None,
237 makeWarpConfig: Optional[Config] = None,
238 **kwargs,
239 ) -> Figure:
240 """Make an Nband x Nparameter panel multi-visit coverage plot.
242 The panels rows are for different bands,sorted according to the order
243 in config ``sortedFullBandList``. The columns are per-parameter,
244 plotted in the order given by the config ``parametersToPlotList``.
245 Red "over" colors indicate thresholds in play in the data processing
246 (e.g. used to select against data of sufficient quality).
248 Parameters
249 ----------
250 data : `lsst.analysis.tools.interfaces.KeyedData`
251 The key-based catalog of data to plot.
252 plotInfo : `dict` [`str`], optional
253 A dictionary of information about the data being plotted with (at
254 least) keys:
256 `"run"`
257 Output run for the plots (`str`).
258 `"tableName"`
259 Name of the table from which results are taken (`str`).
260 camera : `lsst.afw.cameraGeom.Camera`, optional
261 The camera object associated with the data. This is to enable the
262 conversion of to focal plane coordinates (if needed, i.e. for the
263 focal plane projection version of this plot) and to obtain the
264 projected (in RA/Dec) size of a typical detector (for reference in
265 the raDec projection plots when requested, i.e. if the config
266 ``plotDetectorOutline`` is `True`).
267 skymap : `lsst.skymap.BaseSkyMap`, optional
268 The sky map used for this dataset. If a specific tract[List] is
269 provided, this is used to determine the appropriate RA & Dec limits
270 to downselect the data to within those limits for plotting.
271 calibrateConfig : `lsst.pex.config.Config`, optional
272 The persisted config used in the calibration task for the given
273 collection. Used to introspect threshold values used in the run.
274 makeWarpConfig : `lsst.pex.config.Config`, optional
275 The persisted config used in the makeWarp task for the given
276 collection. Used to introspect threshold values used in the run.
278 Returns
279 -------
280 fig : `matplotlib.figure.Figure`
281 The resulting figure.
282 """
283 mpl.rcParams["figure.dpi"] = 350
284 mpl.rcParams["font.size"] = 5
285 mpl.rcParams["xtick.labelsize"] = 5
286 mpl.rcParams["ytick.labelsize"] = 5
287 mpl.rcParams["xtick.major.size"] = 1.5
288 mpl.rcParams["ytick.major.size"] = 1.5
289 mpl.rcParams["xtick.major.width"] = 0.5
290 mpl.rcParams["ytick.major.width"] = 0.5
292 if "medianE" in self.parametersToPlotList:
293 data["medianE"] = np.sqrt(
294 data["psfStarDeltaE1Median"] ** 2.0 + data["psfStarDeltaE2Median"] ** 2.0
295 )
297 # Make sure all columns requested actually exist in ccdVisitTable.
298 notFoundKeys = []
299 for zKey in self.parametersToPlotList:
300 if zKey not in data.keys():
301 notFoundKeys.append(zKey)
302 if len(notFoundKeys) > 0:
303 raise RuntimeError(f"Requested column(s) {notFoundKeys} is(are) not in the data table.")
305 maxMeanDistanceArcsec = calibrateConfig.astrometry.maxMeanDistanceArcsec # type: ignore
307 if makeWarpConfig is None:
308 maxEllipResidual = 0.007
309 maxScaledSizeScatter = 0.009
310 else:
311 maxEllipResidual = makeWarpConfig.select.value.maxEllipResidual # type: ignore
312 maxScaledSizeScatter = makeWarpConfig.select.value.maxScaledSizeScatter # type: ignore
314 cameraName = "" if camera is None else camera.getName()
315 if self.projection == "focalPlane":
316 if camera is None:
317 raise RuntimeError("Must have an input camera if plotting focalPlane projection.")
318 xKey = "xFp"
319 yKey = "yFp"
320 # Add the detector center in Focal Plane coords to the data table.
321 detFpDict = {}
322 for det in camera: # type: ignore
323 if det.getType() == DetectorType.SCIENCE:
324 detFpDict[det.getId()] = det.getCenter(FOCAL_PLANE)
325 log.info("Number of SCIENCE detectors in {} camera = {}".format(cameraName, len(detFpDict)))
326 xFpList = []
327 yFpList = []
328 for det in data["detector"]: # type: ignore
329 xFpList.append(detFpDict[det].getX())
330 yFpList.append(detFpDict[det].getY())
332 data["xFp"] = xFpList # type: ignore
333 data["yFp"] = yFpList # type: ignore
335 corners = camera[0].getCorners(FOCAL_PLANE) # type: ignore
336 xCorners, yCorners = zip(*corners)
337 xScatLen = 0.4 * (np.nanmax(xCorners) - np.nanmin(xCorners))
338 yScatLen = 0.4 * (np.nanmax(yCorners) - np.nanmin(yCorners))
339 tractList: List[int] = []
340 elif self.projection == "raDec":
341 xKey = "ra"
342 yKey = "decl"
343 xScatLen, yScatLen = 0, 0
344 # Use downselector without limits to get rid of any non-finite
345 # RA/Dec entries.
346 data = self._planeAreaSelector(data, xKey=xKey, yKey=yKey)
347 if self.tractsToPlotList is not None and len(self.tractsToPlotList) > 0:
348 tractList = self.tractsToPlotList
349 else:
350 ras = data["ra"]
351 decs = data["decl"]
352 tractList = list(set(skymap.findTractIdArray(ras, decs, degrees=True))) # type: ignore
353 log.info("List of tracts overlapping data: {}".format(tractList))
354 tractLimitsDict = self._getTractLimitsDict(skymap, tractList)
355 else:
356 raise ValueError("Unknown projection: {}".format(self.projection))
358 perTract = True if self.tractsToPlotList is not None and self.projection == "raDec" else False
360 if perTract and len(self.tractsToPlotList) > 0:
361 data = self._trimDataToTracts(data, skymap)
362 nData = len(cast(Vector, data[list(data.keys())[0]]))
363 if nData == 0:
364 raise RuntimeError(
365 "No data to plot. Did your tract selection of "
366 f"{self.tractsToPlotList} remove all data?"
367 )
369 if self.doScatterInRaDec:
370 raRange = max(cast(Vector, data["ra"])) - min(cast(Vector, data["ra"]))
371 decRange = max(cast(Vector, data["decl"])) - min(cast(Vector, data["decl"]))
372 scatRad = max(0.05 * max(raRange, decRange), 0.12) # min is of order of an LSSTCam detector
373 log.info("Scattering data in RA/Dec within radius {:.3f} (deg)".format(scatRad))
375 dataDf = pd.DataFrame(data)
376 nDataId = len(dataDf)
377 log.info("Number of detector data points: %i", nDataId)
378 if nDataId == 0:
379 raise RuntimeError("No data to plot. Exiting...")
380 nVisit = len(set(dataDf["visitId"]))
382 # Make a sorted list of the bands that exist in the data table.
383 dataBandList = list(set(dataDf["band"]))
384 bandList = [band for band in self.sortedFullBandList if band in dataBandList]
385 missingBandList = list(set(dataBandList) - set(self.sortedFullBandList))
386 if len(missingBandList) > 0:
387 log.warning(
388 "The band(s) {} are not included in self.sortedFullBandList. Please add them so "
389 "they get sorted properly (namely by wavelength). For now, they will just be "
390 "appended to the end of the list of those that could be sorted. You may also "
391 "wish to give them entries in self.bandLabelColorList to specify the label color "
392 "(otherwise, if not present, it defaults to teal).".format(missingBandList)
393 )
394 bandList.extend(missingBandList)
395 log.info("Sorted list of existing bands: {}".format(bandList))
397 ptSize = min(5, max(0.3, 600.0 / ((nDataId / len(bandList)) ** 0.5)))
398 if self.doScatterInRaDec:
399 ptSize = min(3, 0.7 * ptSize)
401 nRow = len(bandList)
402 nCol = len(self.parametersToPlotList)
403 colMultiplier = 4 if nCol == 1 else 2.5
404 fig, axes = plt.subplots(
405 nRow,
406 nCol,
407 figsize=(int(colMultiplier * nCol), 2 * nRow),
408 constrained_layout=True,
409 )
411 # Select reasonable limits for colormaps so they can be matched for
412 # all bands.
413 nPercent = max(2, int(0.02 * nDataId))
414 vMinDict = {}
415 vMaxDict = {}
416 for zKey in self.parametersToPlotList:
417 zKeySorted = dataDf[zKey].sort_values()
418 zKeySorted = zKeySorted[np.isfinite(zKeySorted)]
419 vMinDict[zKey] = np.nanmean(zKeySorted.head(nPercent))
420 if zKey == "medianE":
421 vMaxDict[zKey] = maxEllipResidual
422 elif zKey == "psfStarScaledDeltaSizeScatter":
423 vMaxDict[zKey] = maxScaledSizeScatter
424 elif zKey == "astromOffsetMean" and self.projection != "raDec":
425 vMaxDict[zKey] = min(maxMeanDistanceArcsec, 1.1 * np.nanmean(zKeySorted.tail(nPercent)))
426 else:
427 vMaxDict[zKey] = np.nanmean(zKeySorted.tail(nPercent))
429 for iRow, band in enumerate(bandList):
430 dataBand = dataDf[dataDf["band"] == band].copy()
432 nDataIdBand = len(dataBand)
433 nVisitBand = len(set(dataBand["visitId"]))
434 if nDataIdBand < 2:
435 log.warning("Fewer than 2 points to plot for {}. Skipping...".format(band))
436 continue
438 for zKey in self.parametersToPlotList:
439 # Don't match the colorbars when it doesn't make sense to.
440 if zKey in ["skyBg", "zeroPoint"]:
441 nPercent = max(2, int(0.02 * nDataIdBand))
442 zKeySorted = dataBand[zKey].sort_values()
443 zKeySorted = zKeySorted[np.isfinite(zKeySorted)]
444 vMinDict[zKey] = np.nanmean(zKeySorted.head(nPercent))
445 vMaxDict[zKey] = np.nanmean(zKeySorted.tail(nPercent))
447 # Scatter the plots within the detector for focal plane plots.
448 if self.doScatterInRaDec:
449 scatRads = scatRad * np.sqrt(np.random.uniform(size=nDataIdBand))
450 scatTheta = 2.0 * np.pi * np.random.uniform(size=nDataIdBand)
451 xScatter = scatRads * np.cos(scatTheta)
452 yScatter = scatRads * np.sin(scatTheta)
453 xLabel = xKey + " + rand(scatter)"
454 yLabel = yKey + " + rand(scatter)"
455 else:
456 xScatter = np.random.uniform(-xScatLen, xScatLen, len(dataBand[xKey]))
457 yScatter = np.random.uniform(-yScatLen, yScatLen, len(dataBand[yKey]))
458 xLabel = xKey
459 yLabel = yKey
460 dataBand["xScat"] = dataBand[xKey] + xScatter
461 dataBand["yScat"] = dataBand[yKey] + yScatter
462 # Accommodate odd number of quarter-turn rotated detectors.
463 if self.projection == "focalPlane":
464 for det in camera: # type: ignore
465 if det.getOrientation().getNQuarter() % 2 != 0:
466 detId = int(det.getId())
467 xFpRot = dataBand.loc[dataBand.detector == detId, xKey]
468 yFpRot = dataBand.loc[dataBand.detector == detId, yKey]
469 xScatRot = dataBand.loc[dataBand.detector == detId, "xScat"]
470 yScatRot = dataBand.loc[dataBand.detector == detId, "yScat"]
471 dataBand.loc[dataBand.detector == detId, "xScat"] = xFpRot + (yScatRot - yFpRot)
472 dataBand.loc[dataBand.detector == detId, "yScat"] = yFpRot + (xScatRot - xFpRot)
474 if band not in self.bandLabelColorDict:
475 log.warning(
476 "The band {} is not included in the bandLabelColorList config. Please add it "
477 "to specify the label color (otherwise, it defaults to teal).".format(band)
478 )
479 color = self.bandLabelColorDict[band] if band in self.bandLabelColorDict else "teal"
480 fontDict = {"fontsize": 5, "color": color}
482 for iCol, zKey in enumerate(self.parametersToPlotList):
483 if self.cmapName is None:
484 cmap = mkColormap(["darkOrange", "thistle", "midnightblue"])
485 else:
486 cmap = mpl.cm.get_cmap(self.cmapName).copy()
488 if zKey in ["medianE", "psfStarScaledDeltaSizeScatter"]:
489 cmap.set_over("red")
490 elif (
491 zKey in ["astromOffsetMean"]
492 and self.projection != "raDec"
493 and vMaxDict[zKey] >= maxMeanDistanceArcsec
494 ):
495 cmap.set_over("red")
496 else:
497 if self.cmapName is None:
498 cmap.set_over("black")
499 else:
500 cmap.set_over("lemonchiffon")
502 if zKey in ["psfSigma"]:
503 cmap.set_under("red")
504 else:
505 if self.cmapName is None:
506 cmap.set_under("lemonchiffon")
507 else:
508 cmap.set_under("black")
510 titleStr = "band: {} nVisit: {} nData: {}".format(band, nVisitBand, nDataIdBand)
512 ax = axes[iRow, iCol] if axes.ndim > 1 else axes[max(iRow, iCol)]
513 ax.set_title("{}".format(titleStr), loc="left", fontdict=fontDict, pad=2)
514 ax.set_xlabel("{} ({})".format(xLabel, self.unitsDict[xKey]), labelpad=0)
515 ax.set_ylabel("{} ({})".format(yLabel, self.unitsDict[yKey]), labelpad=1)
516 ax.set_aspect("equal")
517 ax.tick_params("x", labelrotation=45, pad=0)
519 if self.projection == "focalPlane":
520 for det in camera: # type: ignore
521 if det.getType() == DetectorType.SCIENCE:
522 corners = det.getCorners(FOCAL_PLANE)
523 xCorners, yCorners = zip(*corners)
524 xFpMin, xFpMax = min(xCorners), max(xCorners)
525 yFpMin, yFpMax = min(yCorners), max(yCorners)
526 detId = int(det.getId())
527 perDetData = dataBand[dataBand["detector"] == detId]
528 if len(perDetData) < 1:
529 log.debug("No data to plot for detector {}. Skipping...".format(detId))
530 continue
531 if sum(np.isfinite(perDetData[zKey])) < 1:
532 log.debug(
533 "No finited data to plot for detector {}. Skipping...".format(detId)
534 )
535 continue
536 pcm = plotProjectionWithBinning(
537 ax,
538 perDetData["xScat"].values,
539 perDetData["yScat"].values,
540 perDetData[zKey].values,
541 cmap,
542 xFpMin,
543 xFpMax,
544 yFpMin,
545 yFpMax,
546 xNumBins=1,
547 fixAroundZero=False,
548 nPointBinThresh=max(1, int(self.nPointBinThresh / len(detFpDict))),
549 isSorted=False,
550 vmin=vMinDict[zKey],
551 vmax=vMaxDict[zKey],
552 scatPtSize=ptSize,
553 )
554 if self.projection == "raDec":
555 raMin, raMax = min(cast(Vector, data["ra"])), max(cast(Vector, data["ra"]))
556 decMin, decMax = min(cast(Vector, data["decl"])), max(cast(Vector, data["decl"]))
557 raMin, raMax, decMin, decMax = _setLimitsToEqualRatio(raMin, raMax, decMin, decMax)
558 pcm = plotProjectionWithBinning(
559 ax,
560 dataBand["xScat"].values,
561 dataBand["yScat"].values,
562 dataBand[zKey].values,
563 cmap,
564 raMin,
565 raMax,
566 decMin,
567 decMax,
568 xNumBins=self.nBins,
569 fixAroundZero=False,
570 nPointBinThresh=self.nPointBinThresh,
571 isSorted=False,
572 vmin=vMinDict[zKey],
573 vmax=vMaxDict[zKey],
574 scatPtSize=ptSize,
575 )
577 # Make sure all panels get the same axis limits and always make
578 # equal axis ratio panels.
579 if iRow == 0 and iCol == 0:
580 if self.raDecLimitsDict is not None and self.projection == "raDec":
581 xLimMin = self.raDecLimitsDict["raMin"] # type: ignore
582 xLimMax = self.raDecLimitsDict["raMax"] # type: ignore
583 yLimMin = self.raDecLimitsDict["decMin"] # type: ignore
584 yLimMax = self.raDecLimitsDict["decMax"] # type: ignore
585 else:
586 xLimMin, xLimMax = ax.get_xlim()
587 yLimMin, yLimMax = ax.get_ylim()
588 if self.projection == "focalPlane":
589 minDim = (
590 max(
591 camera.getFpBBox().getWidth(), # type: ignore
592 camera.getFpBBox().getHeight(), # type: ignore
593 )
594 / 2
595 )
596 xLimMin = min(-minDim, xLimMin)
597 xLimMax = max(minDim, xLimMax)
598 if self.trimToTract:
599 for tract, tractLimits in tractLimitsDict.items():
600 xLimMin = min(xLimMin, min(tractLimits["ras"]))
601 xLimMax = max(xLimMax, max(tractLimits["ras"]))
602 yLimMin = min(yLimMin, min(tractLimits["decs"]))
603 yLimMax = max(yLimMax, max(tractLimits["decs"]))
604 xDelta = xLimMax - xLimMin
605 xLimMin -= 0.04 * xDelta
606 xLimMax += 0.04 * xDelta
608 xLimMin, xLimMax, yLimMin, yLimMax = _setLimitsToEqualRatio(
609 xLimMin, xLimMax, yLimMin, yLimMax
610 )
611 limRange = xLimMax - xLimMin
612 yTickFmt = _tickFormatter(yLimMin * 10)
614 if self.plotAllTractOutlines and self.projection == "raDec":
615 allTractsList = []
616 for tractInfo in skymap: # type: ignore
617 centerRa = tractInfo.getCtrCoord()[0].asDegrees()
618 centerDec = tractInfo.getCtrCoord()[1].asDegrees()
619 if (
620 centerRa > xLimMin
621 and centerRa < xLimMax
622 and centerDec > yLimMin
623 and centerDec < yLimMax
624 ):
625 allTractsList.append(int(tractInfo.getId()))
626 tractLimitsDict = self._getTractLimitsDict(skymap, allTractsList)
628 upperHandles = []
629 if self.doScatterInRaDec and self.projection == "raDec":
630 patch = mpl.patches.Circle(
631 (xLimMax - 1.5 * scatRad, yLimMax - 1.5 * scatRad),
632 radius=scatRad,
633 facecolor="gray",
634 edgecolor="None",
635 alpha=0.2,
636 label="scatter area",
637 )
638 ax.add_patch(patch)
639 upperHandles.append(patch)
641 # Add a shaded area of the size of a detector for reference.
642 if self.plotDetectorOutline and self.projection == "raDec":
643 if camera is None:
644 log.warning(
645 "Config plotDetectorOutline is True, but no camera was provided. "
646 "Reference detector outline will not be included in the plot."
647 )
648 else:
649 # Calculate area of polygon with known vertices.
650 x1, x2, x3, x4 = (
651 dataBand["llcra"],
652 dataBand["lrcra"],
653 dataBand["urcra"],
654 dataBand["ulcra"],
655 )
656 y1, y2, y3, y4 = (
657 dataBand["llcdec"],
658 dataBand["lrcdec"],
659 dataBand["urcdec"],
660 dataBand["ulcdec"],
661 )
662 areaDeg = (
663 np.abs(
664 (x1 * y2 - y1 * x2)
665 + (x2 * y3 - y2 * x3)
666 + (x3 * y4 - y3 * x4)
667 + (x4 * y1 - y4 * x1)
668 )
669 / 2.0
670 )
671 detScaleDeg = np.sqrt(areaDeg / (dataBand["xSize"] * dataBand["ySize"]))
672 detWidthDeg = np.nanmedian(detScaleDeg * dataBand["xSize"])
673 detHeightDeg = np.nanmedian(detScaleDeg * dataBand["ySize"])
675 patch = mpl.patches.Rectangle(
676 (xLimMax - 0.02 * limRange - detWidthDeg, yLimMin + 0.03 * limRange),
677 detWidthDeg,
678 detHeightDeg,
679 facecolor="turquoise",
680 alpha=0.3,
681 label="det size",
682 )
683 ax.add_patch(patch)
684 upperHandles.append(patch)
686 if self.projection == "raDec":
687 if iRow == 0 and iCol == 0 and len(upperHandles) > 0:
688 ax.legend(
689 handles=upperHandles,
690 loc="upper left",
691 bbox_to_anchor=(0, 1.17 + 0.05 * len(upperHandles)),
692 edgecolor="black",
693 framealpha=0.4,
694 fontsize=5,
695 )
697 # Overplot tract outlines if tracts were specified, but only if
698 # the plot limits span fewer than the width of 5 tracts
699 # (otherwise the labels will be too crowded to be useful).
700 if len(tractList) > 0:
701 tractInfo = skymap[tractList[0]] # type: ignore
702 tractWidthDeg = tractInfo.outer_sky_polygon.getBoundingBox().getWidth().asDegrees()
703 if limRange <= 5 * tractWidthDeg:
704 deltaLim = 0.05 * limRange
705 for tract, tractLimits in tractLimitsDict.items():
706 centerRa = tractLimits["center"][0]
707 centerDec = tractLimits["center"][1]
708 if (
709 centerRa > xLimMin + deltaLim
710 and centerRa < xLimMax - deltaLim
711 and centerDec > yLimMin + deltaLim
712 and centerDec < yLimMax - deltaLim
713 ):
714 ax.plot(tractLimits["ras"], tractLimits["decs"], color="dimgray", lw=0.4)
715 fontSize = 3 if limRange < 20 else 2
716 ax.annotate(
717 str(tract),
718 tractLimits["center"],
719 va="center",
720 ha="center",
721 color="dimgray",
722 fontsize=fontSize,
723 annotation_clip=True,
724 path_effects=[mpl.patheffects.withStroke(linewidth=1, foreground="w")],
725 )
726 if self.projection == "raDec":
727 ax.set_xlim(xLimMax, xLimMin)
728 else:
729 ax.set_xlim(xLimMin, xLimMax)
730 ax.set_ylim(yLimMin, yLimMax)
731 ax.yaxis.set_major_formatter(yTickFmt)
733 # Get a tick formatter that will give all anticipated value
734 # ranges the same length. This is so that the label padding
735 # has the same effect on all colorbars.
736 value = vMaxDict[zKey] if np.abs(vMaxDict[zKey]) > np.abs(vMinDict[zKey]) else vMinDict[zKey]
737 if vMinDict[zKey] < 0 and np.abs(vMinDict[zKey]) >= vMaxDict[zKey] / 10:
738 value = vMinDict[zKey]
739 tickFmt = _tickFormatter(value)
740 cb = fig.colorbar(
741 pcm,
742 ax=ax,
743 extend="both",
744 aspect=14,
745 format=tickFmt,
746 pad=0.02,
747 )
749 cbLabel = zKey
750 if zKey not in self.unitsDict:
751 log.warning(
752 "Data column {} does not have an entry in unitsDict config. Units "
753 "will not be included in the colorbar text.".format(zKey)
754 )
755 elif len(self.unitsDict[zKey]) > 0:
756 cbLabel = "{} ({})".format(zKey, self.unitsDict[zKey])
758 cb.set_label(
759 cbLabel,
760 labelpad=-29,
761 color="black",
762 fontsize=6,
763 path_effects=[mpl.patheffects.withStroke(linewidth=1, foreground="w")],
764 )
766 runName = plotInfo["run"] # type: ignore
767 supTitle = "{} {} nVisit: {} nData: {}".format(runName, cameraName, nVisit, nDataId)
768 if nCol == 1:
769 supTitle = "{} {}\n nVisit: {} nData: {}".format(runName, cameraName, nVisit, nDataId)
770 fig.suptitle(supTitle, fontsize=4 + nCol, ha="center")
772 return fig
774 def _getTractLimitsDict(self, skymap, tractList):
775 """Return a dict containing tract limits needed for outline plotting.
777 Parameters
778 ----------
779 skymap : `lsst.skymap.BaseSkyMap`
780 The sky map used for this dataset. Used to obtain tract
781 parameters.
782 tractList : `list` [`int`]
783 The list of tract ids (as integers) for which to determine the
784 limits.
786 Returns
787 -------
788 tractLimitsDict : `dict` [`dict`]
789 A dictionary keyed on tract id. Each entry includes a `dict`
790 including the tract RA corners, Dec corners, and the tract center,
791 all in units of degrees. These are used for plotting the tract
792 outlines.
793 """
794 tractLimitsDict = {}
795 for tract in tractList:
796 tractInfo = skymap[tract]
797 tractBbox = tractInfo.outer_sky_polygon.getBoundingBox()
798 tractCenter = tractBbox.getCenter()
799 tractRa0 = (tractCenter[0] - tractBbox.getWidth() / 2).asDegrees()
800 tractRa1 = (tractCenter[0] + tractBbox.getWidth() / 2).asDegrees()
801 tractDec0 = (tractCenter[1] - tractBbox.getHeight() / 2).asDegrees()
802 tractDec1 = (tractCenter[1] + tractBbox.getHeight() / 2).asDegrees()
803 tractLimitsDict[tract] = {
804 "ras": [tractRa0, tractRa1, tractRa1, tractRa0, tractRa0],
805 "decs": [tractDec0, tractDec0, tractDec1, tractDec1, tractDec0],
806 "center": [tractCenter[0].asDegrees(), tractCenter[1].asDegrees()],
807 }
809 return tractLimitsDict
811 def _planeAreaSelector(
812 self,
813 data,
814 xMin=np.nextafter(float("-inf"), 0),
815 xMax=np.nextafter(float("inf"), 0),
816 yMin=np.nextafter(float("-inf"), 0),
817 yMax=np.nextafter(float("inf"), 0),
818 xKey="ra",
819 yKey="decl",
820 ):
821 """Helper function for downselecting on within an area on a plane.
823 Parameters
824 ----------
825 data : `lsst.analysis.tools.interfaces.KeyedData`
826 The key-based catalog of data to select on.
827 xMin, xMax, yMin, yMax : `float`
828 The min/max x/y values defining the range within which to
829 down-select the data.
830 xKey, yKey : `str`
831 The column keys defining the "x" and "y" positions on the plane.
833 Returns
834 -------
835 downSelectedData : `lsst.analysis.tools.interfaces.KeyedData`
836 The down-selected catalog.
837 """
838 xSelector = RangeSelector(key=xKey, minimum=xMin, maximum=xMax)
839 ySelector = RangeSelector(key=yKey, minimum=yMin, maximum=yMax)
840 keyedSelector = KeyedDataSelectorAction(vectorKeys=data.keys())
841 keyedSelector.selectors.xSelector = xSelector
842 keyedSelector.selectors.ySelector = ySelector
843 downSelectedData = keyedSelector(data)
845 return downSelectedData
847 def _trimDataToTracts(self, data, skymap):
848 """Trim the data to limits set by tracts in self.tractsToPlotList.
850 Parameters
851 ----------
852 data : `lsst.analysis.tools.interfaces.KeyedData`
853 The key-based catalog of data to select on.
854 skymap : `lsst.skymap.BaseSkyMap`
855 The sky map used for this dataset. Used to obtain tract
856 parameters.
858 Returns
859 -------
860 downSelectedData : `lsst.analysis.tools.interfaces.KeyedData`
861 The down-selected catalog.
862 """
863 tractRaMin = 1e12
864 tractRaMax = -1e12
865 tractDecMin = 1e12
866 tractDecMax = -1e12
867 for tract in self.tractsToPlotList:
868 tractInfo = skymap[tract]
869 tractBbox = tractInfo.outer_sky_polygon.getBoundingBox()
870 tractCenter = tractBbox.getCenter()
871 tractRa0 = (tractCenter[0] - tractBbox.getWidth() / 2).asDegrees()
872 tractRa1 = (tractCenter[0] + tractBbox.getWidth() / 2).asDegrees()
873 tractDec0 = (tractCenter[1] - tractBbox.getHeight() / 2).asDegrees()
874 tractDec1 = (tractCenter[1] + tractBbox.getHeight() / 2).asDegrees()
875 tractRaMin = min(tractRaMin, tractRa0)
876 tractRaMax = max(tractRaMax, tractRa1)
877 tractDecMin = min(tractDecMin, tractDec0)
878 tractDecMax = max(tractDecMax, tractDec1)
879 downSelectedData = self._planeAreaSelector(
880 data,
881 xMin=tractRaMin,
882 xMax=tractRaMax,
883 yMin=tractDecMin,
884 yMax=tractDecMax,
885 xKey="ra",
886 yKey="decl",
887 )
888 return downSelectedData
891def _tickFormatter(value):
892 """Create a tick formatter such that all anticipated values end up with the
893 same length.
895 This accommodates values ranging from +/-0.0001 -> +/-99999
897 Parameters
898 ----------
899 value : `float`
900 The the value used to determine the appropriate formatting.
902 Returns
903 -------
904 tickFmt : `matplotlib.ticker.FormatStrFormatter`
905 The tick formatter to use with matplotlib functions.
906 """
907 if np.abs(value) >= 10000:
908 tickFmt = FormatStrFormatter("%.0f")
909 elif np.abs(value) >= 1000:
910 tickFmt = FormatStrFormatter("%.1f")
911 if value < 0:
912 tickFmt = FormatStrFormatter("%.0f")
913 elif np.abs(value) >= 100:
914 tickFmt = FormatStrFormatter("%.2f")
915 if value < 0:
916 tickFmt = FormatStrFormatter("%.1f")
917 elif np.abs(value) >= 10:
918 tickFmt = FormatStrFormatter("%.3f")
919 if value < 0:
920 tickFmt = FormatStrFormatter("%.2f")
921 elif np.abs(value) >= 1:
922 tickFmt = FormatStrFormatter("%.4f")
923 if value < 0:
924 tickFmt = FormatStrFormatter("%.3f")
925 else:
926 tickFmt = FormatStrFormatter("%.4f")
927 if value < 0:
928 tickFmt = FormatStrFormatter("%.3f")
929 return tickFmt
932def _setLimitsToEqualRatio(xMin, xMax, yMin, yMax):
933 """For a given set of x/y min/max, redefine to have equal aspect ratio.
935 The limits are extended on both ends such that the central value is
936 preserved.
938 Parameters
939 ----------
940 xMin, xMax, yMin, yMax : `float`
941 The min/max values of the x/y ranges for which to match in dynamic
942 range while perserving the central values.
944 Returns
945 -------
946 xMin, xMax, yMin, yMax : `float`
947 The adjusted min/max values of the x/y ranges with equal aspect ratios.
948 """
949 xDelta = xMax - xMin
950 yDelta = yMax - yMin
951 deltaDiff = yDelta - xDelta
952 if deltaDiff > 0:
953 xMin -= 0.5 * deltaDiff
954 xMax += 0.5 * deltaDiff
955 elif deltaDiff < 0:
956 yMin -= 0.5 * np.abs(deltaDiff)
957 yMax += 0.5 * np.abs(deltaDiff)
958 return xMin, xMax, yMin, yMax