Coverage for python/lsst/analysis/tools/actions/plot/multiVisitCoveragePlot.py: 8%
406 statements
« prev ^ index » next coverage.py v6.5.0, created at 2023-03-30 03:57 -0700
« prev ^ index » next coverage.py v6.5.0, created at 2023-03-30 03:57 -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:
255 `"run"`
256 Output run for the plots (`str`).
257 `"tableName"`
258 Name of the table from which results are taken (`str`).
259 camera : `lsst.afw.cameraGeom.Camera`, optional
260 The camera object associated with the data. This is to enable the
261 conversion of to focal plane coordinates (if needed, i.e. for the
262 focal plane projection version of this plot) and to obtain the
263 projected (in RA/Dec) size of a typical detector (for reference in
264 the raDec projection plots when requested, i.e. if the config
265 ``plotDetectorOutline`` is `True`).
266 skymap : `lsst.skymap.BaseSkyMap`, optional
267 The sky map used for this dataset. If a specific tract[List] is
268 provided, this is used to determine the appropriate RA & Dec limits
269 to downselect the data to within those limits for plotting.
270 calibrateConfig : `lsst.pex.config.Config`, optional
271 The persisted config used in the calibration task for the given
272 collection. Used to introspect threshold values used in the run.
273 makeWarpConfig : `lsst.pex.config.Config`, optional
274 The persisted config used in the makeWarp task for the given
275 collection. Used to introspect threshold values used in the run.
277 Returns
278 -------
279 fig : `matplotlib.figure.Figure`
280 The resulting figure.
281 """
282 mpl.rcParams["figure.dpi"] = 350
283 mpl.rcParams["font.size"] = 5
284 mpl.rcParams["xtick.labelsize"] = 5
285 mpl.rcParams["ytick.labelsize"] = 5
286 mpl.rcParams["xtick.major.size"] = 1.5
287 mpl.rcParams["ytick.major.size"] = 1.5
288 mpl.rcParams["xtick.major.width"] = 0.5
289 mpl.rcParams["ytick.major.width"] = 0.5
291 if "medianE" in self.parametersToPlotList:
292 data["medianE"] = np.sqrt(
293 data["psfStarDeltaE1Median"] ** 2.0 + data["psfStarDeltaE2Median"] ** 2.0
294 )
296 # Make sure all columns requested actually exist in ccdVisitTable.
297 notFoundKeys = []
298 for zKey in self.parametersToPlotList:
299 if zKey not in data.keys():
300 notFoundKeys.append(zKey)
301 if len(notFoundKeys) > 0:
302 raise RuntimeError(f"Requested column(s) {notFoundKeys} is(are) not in the data table.")
304 maxMeanDistanceArcsec = calibrateConfig.astrometry.maxMeanDistanceArcsec # type: ignore
306 if makeWarpConfig is None:
307 maxEllipResidual = 0.007
308 maxScaledSizeScatter = 0.009
309 else:
310 maxEllipResidual = makeWarpConfig.select.value.maxEllipResidual # type: ignore
311 maxScaledSizeScatter = makeWarpConfig.select.value.maxScaledSizeScatter # type: ignore
313 cameraName = "" if camera is None else camera.getName()
314 if self.projection == "focalPlane":
315 if camera is None:
316 raise RuntimeError("Must have an input camera if plotting focalPlane projection.")
317 xKey = "xFp"
318 yKey = "yFp"
319 # Add the detector center in Focal Plane coords to the data table.
320 detFpDict = {}
321 for det in camera: # type: ignore
322 if det.getType() == DetectorType.SCIENCE:
323 detFpDict[det.getId()] = det.getCenter(FOCAL_PLANE)
324 log.info("Number of SCIENCE detectors in {} camera = {}".format(cameraName, len(detFpDict)))
325 xFpList = []
326 yFpList = []
327 for det in data["detector"]: # type: ignore
328 xFpList.append(detFpDict[det].getX())
329 yFpList.append(detFpDict[det].getY())
331 data["xFp"] = xFpList # type: ignore
332 data["yFp"] = yFpList # type: ignore
334 corners = camera[0].getCorners(FOCAL_PLANE) # type: ignore
335 xCorners, yCorners = zip(*corners)
336 xScatLen = 0.4 * (np.nanmax(xCorners) - np.nanmin(xCorners))
337 yScatLen = 0.4 * (np.nanmax(yCorners) - np.nanmin(yCorners))
338 tractList: List[int] = []
339 elif self.projection == "raDec":
340 xKey = "ra"
341 yKey = "decl"
342 xScatLen, yScatLen = 0, 0
343 # Use downselector without limits to get rid of any non-finite
344 # RA/Dec entries.
345 data = self._planeAreaSelector(data, xKey=xKey, yKey=yKey)
346 if self.tractsToPlotList is not None and len(self.tractsToPlotList) > 0:
347 tractList = self.tractsToPlotList
348 else:
349 ras = data["ra"]
350 decs = data["decl"]
351 tractList = list(set(skymap.findTractIdArray(ras, decs, degrees=True))) # type: ignore
352 log.info("List of tracts overlapping data: {}".format(tractList))
353 tractLimitsDict = self._getTractLimitsDict(skymap, tractList)
354 else:
355 raise ValueError("Unknown projection: {}".format(self.projection))
357 perTract = True if self.tractsToPlotList is not None and self.projection == "raDec" else False
359 if perTract and len(self.tractsToPlotList) > 0:
360 data = self._trimDataToTracts(data, skymap)
361 nData = len(cast(Vector, data[list(data.keys())[0]]))
362 if nData == 0:
363 raise RuntimeError(
364 "No data to plot. Did your tract selection of "
365 f"{self.tractsToPlotList} remove all data?"
366 )
368 if self.doScatterInRaDec:
369 raRange = max(cast(Vector, data["ra"])) - min(cast(Vector, data["ra"]))
370 decRange = max(cast(Vector, data["decl"])) - min(cast(Vector, data["decl"]))
371 scatRad = max(0.05 * max(raRange, decRange), 0.12) # min is of order of an LSSTCam detector
372 log.info("Scattering data in RA/Dec within radius {:.3f} (deg)".format(scatRad))
374 dataDf = pd.DataFrame(data)
375 nDataId = len(dataDf)
376 log.info("Number of detector data points: %i", nDataId)
377 if nDataId == 0:
378 raise RuntimeError("No data to plot. Exiting...")
379 nVisit = len(set(dataDf["visitId"]))
381 # Make a sorted list of the bands that exist in the data table.
382 dataBandList = list(set(dataDf["band"]))
383 bandList = [band for band in self.sortedFullBandList if band in dataBandList]
384 missingBandList = list(set(dataBandList) - set(self.sortedFullBandList))
385 if len(missingBandList) > 0:
386 log.warning(
387 "The band(s) {} are not included in self.sortedFullBandList. Please add them so "
388 "they get sorted properly (namely by wavelength). For now, they will just be "
389 "appended to the end of the list of those that could be sorted. You may also "
390 "wish to give them entries in self.bandLabelColorList to specify the label color "
391 "(otherwise, if not present, it defaults to teal).".format(missingBandList)
392 )
393 bandList.extend(missingBandList)
394 log.info("Sorted list of existing bands: {}".format(bandList))
396 ptSize = min(5, max(0.3, 600.0 / ((nDataId / len(bandList)) ** 0.5)))
397 if self.doScatterInRaDec:
398 ptSize = min(3, 0.7 * ptSize)
400 nRow = len(bandList)
401 nCol = len(self.parametersToPlotList)
402 colMultiplier = 4 if nCol == 1 else 2.5
403 fig, axes = plt.subplots(
404 nRow,
405 nCol,
406 figsize=(int(colMultiplier * nCol), 2 * nRow),
407 constrained_layout=True,
408 )
410 # Select reasonable limits for colormaps so they can be matched for
411 # all bands.
412 nPercent = max(2, int(0.02 * nDataId))
413 vMinDict = {}
414 vMaxDict = {}
415 for zKey in self.parametersToPlotList:
416 zKeySorted = dataDf[zKey].sort_values()
417 zKeySorted = zKeySorted[np.isfinite(zKeySorted)]
418 vMinDict[zKey] = np.nanmean(zKeySorted.head(nPercent))
419 if zKey == "medianE":
420 vMaxDict[zKey] = maxEllipResidual
421 elif zKey == "psfStarScaledDeltaSizeScatter":
422 vMaxDict[zKey] = maxScaledSizeScatter
423 elif zKey == "astromOffsetMean" and self.projection != "raDec":
424 vMaxDict[zKey] = min(maxMeanDistanceArcsec, 1.1 * np.nanmean(zKeySorted.tail(nPercent)))
425 else:
426 vMaxDict[zKey] = np.nanmean(zKeySorted.tail(nPercent))
428 for iRow, band in enumerate(bandList):
429 dataBand = dataDf[dataDf["band"] == band].copy()
431 nDataIdBand = len(dataBand)
432 nVisitBand = len(set(dataBand["visitId"]))
433 if nDataIdBand < 2:
434 log.warning("Fewer than 2 points to plot for {}. Skipping...".format(band))
435 continue
437 for zKey in self.parametersToPlotList:
438 # Don't match the colorbars when it doesn't make sense to.
439 if zKey in ["skyBg", "zeroPoint"]:
440 nPercent = max(2, int(0.02 * nDataIdBand))
441 zKeySorted = dataBand[zKey].sort_values()
442 zKeySorted = zKeySorted[np.isfinite(zKeySorted)]
443 vMinDict[zKey] = np.nanmean(zKeySorted.head(nPercent))
444 vMaxDict[zKey] = np.nanmean(zKeySorted.tail(nPercent))
446 # Scatter the plots within the detector for focal plane plots.
447 if self.doScatterInRaDec:
448 scatRads = scatRad * np.sqrt(np.random.uniform(size=nDataIdBand))
449 scatTheta = 2.0 * np.pi * np.random.uniform(size=nDataIdBand)
450 xScatter = scatRads * np.cos(scatTheta)
451 yScatter = scatRads * np.sin(scatTheta)
452 xLabel = xKey + " + rand(scatter)"
453 yLabel = yKey + " + rand(scatter)"
454 else:
455 xScatter = np.random.uniform(-xScatLen, xScatLen, len(dataBand[xKey]))
456 yScatter = np.random.uniform(-yScatLen, yScatLen, len(dataBand[yKey]))
457 xLabel = xKey
458 yLabel = yKey
459 dataBand["xScat"] = dataBand[xKey] + xScatter
460 dataBand["yScat"] = dataBand[yKey] + yScatter
461 # Accommodate odd number of quarter-turn rotated detectors.
462 if self.projection == "focalPlane":
463 for det in camera: # type: ignore
464 if det.getOrientation().getNQuarter() % 2 != 0:
465 detId = int(det.getId())
466 xFpRot = dataBand.loc[dataBand.detector == detId, xKey]
467 yFpRot = dataBand.loc[dataBand.detector == detId, yKey]
468 xScatRot = dataBand.loc[dataBand.detector == detId, "xScat"]
469 yScatRot = dataBand.loc[dataBand.detector == detId, "yScat"]
470 dataBand.loc[dataBand.detector == detId, "xScat"] = xFpRot + (yScatRot - yFpRot)
471 dataBand.loc[dataBand.detector == detId, "yScat"] = yFpRot + (xScatRot - xFpRot)
473 if band not in self.bandLabelColorDict:
474 log.warning(
475 "The band {} is not included in the bandLabelColorList config. Please add it "
476 "to specify the label color (otherwise, it defaults to teal).".format(band)
477 )
478 color = self.bandLabelColorDict[band] if band in self.bandLabelColorDict else "teal"
479 fontDict = {"fontsize": 5, "color": color}
481 for iCol, zKey in enumerate(self.parametersToPlotList):
482 if self.cmapName is None:
483 cmap = mkColormap(["darkOrange", "thistle", "midnightblue"])
484 else:
485 cmap = mpl.cm.get_cmap(self.cmapName).copy()
487 if zKey in ["medianE", "psfStarScaledDeltaSizeScatter"]:
488 cmap.set_over("red")
489 elif (
490 zKey in ["astromOffsetMean"]
491 and self.projection != "raDec"
492 and vMaxDict[zKey] >= maxMeanDistanceArcsec
493 ):
494 cmap.set_over("red")
495 else:
496 if self.cmapName is None:
497 cmap.set_over("black")
498 else:
499 cmap.set_over("lemonchiffon")
501 if zKey in ["psfSigma"]:
502 cmap.set_under("red")
503 else:
504 if self.cmapName is None:
505 cmap.set_under("lemonchiffon")
506 else:
507 cmap.set_under("black")
509 titleStr = "band: {} nVisit: {} nData: {}".format(band, nVisitBand, nDataIdBand)
511 ax = axes[iRow, iCol] if axes.ndim > 1 else axes[max(iRow, iCol)]
512 ax.set_title("{}".format(titleStr), loc="left", fontdict=fontDict, pad=2)
513 ax.set_xlabel("{} ({})".format(xLabel, self.unitsDict[xKey]), labelpad=0)
514 ax.set_ylabel("{} ({})".format(yLabel, self.unitsDict[yKey]), labelpad=1)
515 ax.set_aspect("equal")
516 ax.tick_params("x", labelrotation=45, pad=0)
518 if self.projection == "focalPlane":
519 for det in camera: # type: ignore
520 if det.getType() == DetectorType.SCIENCE:
521 corners = det.getCorners(FOCAL_PLANE)
522 xCorners, yCorners = zip(*corners)
523 xFpMin, xFpMax = min(xCorners), max(xCorners)
524 yFpMin, yFpMax = min(yCorners), max(yCorners)
525 detId = int(det.getId())
526 perDetData = dataBand[dataBand["detector"] == detId]
527 if len(perDetData) < 1:
528 log.debug("No data to plot for detector {}. Skipping...".format(detId))
529 continue
530 if sum(np.isfinite(perDetData[zKey])) < 1:
531 log.debug(
532 "No finited data to plot for detector {}. Skipping...".format(detId)
533 )
534 continue
535 pcm = plotProjectionWithBinning(
536 ax,
537 perDetData["xScat"].values,
538 perDetData["yScat"].values,
539 perDetData[zKey].values,
540 cmap,
541 xFpMin,
542 xFpMax,
543 yFpMin,
544 yFpMax,
545 xNumBins=1,
546 fixAroundZero=False,
547 nPointBinThresh=max(1, int(self.nPointBinThresh / len(detFpDict))),
548 isSorted=False,
549 vmin=vMinDict[zKey],
550 vmax=vMaxDict[zKey],
551 scatPtSize=ptSize,
552 )
553 if self.projection == "raDec":
554 raMin, raMax = min(cast(Vector, data["ra"])), max(cast(Vector, data["ra"]))
555 decMin, decMax = min(cast(Vector, data["decl"])), max(cast(Vector, data["decl"]))
556 raMin, raMax, decMin, decMax = _setLimitsToEqualRatio(raMin, raMax, decMin, decMax)
557 pcm = plotProjectionWithBinning(
558 ax,
559 dataBand["xScat"].values,
560 dataBand["yScat"].values,
561 dataBand[zKey].values,
562 cmap,
563 raMin,
564 raMax,
565 decMin,
566 decMax,
567 xNumBins=self.nBins,
568 fixAroundZero=False,
569 nPointBinThresh=self.nPointBinThresh,
570 isSorted=False,
571 vmin=vMinDict[zKey],
572 vmax=vMaxDict[zKey],
573 scatPtSize=ptSize,
574 )
576 # Make sure all panels get the same axis limits and always make
577 # equal axis ratio panels.
578 if iRow == 0 and iCol == 0:
579 if self.raDecLimitsDict is not None and self.projection == "raDec":
580 xLimMin = self.raDecLimitsDict["raMin"] # type: ignore
581 xLimMax = self.raDecLimitsDict["raMax"] # type: ignore
582 yLimMin = self.raDecLimitsDict["decMin"] # type: ignore
583 yLimMax = self.raDecLimitsDict["decMax"] # type: ignore
584 else:
585 xLimMin, xLimMax = ax.get_xlim()
586 yLimMin, yLimMax = ax.get_ylim()
587 if self.projection == "focalPlane":
588 minDim = (
589 max(
590 camera.getFpBBox().getWidth(), # type: ignore
591 camera.getFpBBox().getHeight(), # type: ignore
592 )
593 / 2
594 )
595 xLimMin = min(-minDim, xLimMin)
596 xLimMax = max(minDim, xLimMax)
597 if self.trimToTract:
598 for tract, tractLimits in tractLimitsDict.items():
599 xLimMin = min(xLimMin, min(tractLimits["ras"]))
600 xLimMax = max(xLimMax, max(tractLimits["ras"]))
601 yLimMin = min(yLimMin, min(tractLimits["decs"]))
602 yLimMax = max(yLimMax, max(tractLimits["decs"]))
603 xDelta = xLimMax - xLimMin
604 xLimMin -= 0.04 * xDelta
605 xLimMax += 0.04 * xDelta
607 xLimMin, xLimMax, yLimMin, yLimMax = _setLimitsToEqualRatio(
608 xLimMin, xLimMax, yLimMin, yLimMax
609 )
610 limRange = xLimMax - xLimMin
611 yTickFmt = _tickFormatter(yLimMin * 10)
613 if self.plotAllTractOutlines and self.projection == "raDec":
614 allTractsList = []
615 for tractInfo in skymap: # type: ignore
616 centerRa = tractInfo.getCtrCoord()[0].asDegrees()
617 centerDec = tractInfo.getCtrCoord()[1].asDegrees()
618 if (
619 centerRa > xLimMin
620 and centerRa < xLimMax
621 and centerDec > yLimMin
622 and centerDec < yLimMax
623 ):
624 allTractsList.append(int(tractInfo.getId()))
625 tractLimitsDict = self._getTractLimitsDict(skymap, allTractsList)
627 upperHandles = []
628 if self.doScatterInRaDec and self.projection == "raDec":
629 patch = mpl.patches.Circle(
630 (xLimMax - 1.5 * scatRad, yLimMax - 1.5 * scatRad),
631 radius=scatRad,
632 facecolor="gray",
633 edgecolor="None",
634 alpha=0.2,
635 label="scatter area",
636 )
637 ax.add_patch(patch)
638 upperHandles.append(patch)
640 # Add a shaded area of the size of a detector for reference.
641 if self.plotDetectorOutline and self.projection == "raDec":
642 if camera is None:
643 log.warning(
644 "Config plotDetectorOutline is True, but no camera was provided. "
645 "Reference detector outline will not be included in the plot."
646 )
647 else:
648 # Calculate area of polygon with known vertices.
649 x1, x2, x3, x4 = (
650 dataBand["llcra"],
651 dataBand["lrcra"],
652 dataBand["urcra"],
653 dataBand["ulcra"],
654 )
655 y1, y2, y3, y4 = (
656 dataBand["llcdec"],
657 dataBand["lrcdec"],
658 dataBand["urcdec"],
659 dataBand["ulcdec"],
660 )
661 areaDeg = (
662 np.abs(
663 (x1 * y2 - y1 * x2)
664 + (x2 * y3 - y2 * x3)
665 + (x3 * y4 - y3 * x4)
666 + (x4 * y1 - y4 * x1)
667 )
668 / 2.0
669 )
670 detScaleDeg = np.sqrt(areaDeg / (dataBand["xSize"] * dataBand["ySize"]))
671 detWidthDeg = np.nanmedian(detScaleDeg * dataBand["xSize"])
672 detHeightDeg = np.nanmedian(detScaleDeg * dataBand["ySize"])
674 patch = mpl.patches.Rectangle(
675 (xLimMax - 0.02 * limRange - detWidthDeg, yLimMin + 0.03 * limRange),
676 detWidthDeg,
677 detHeightDeg,
678 facecolor="turquoise",
679 alpha=0.3,
680 label="det size",
681 )
682 ax.add_patch(patch)
683 upperHandles.append(patch)
685 if self.projection == "raDec":
686 if iRow == 0 and iCol == 0 and len(upperHandles) > 0:
687 ax.legend(
688 handles=upperHandles,
689 loc="upper left",
690 bbox_to_anchor=(0, 1.17 + 0.05 * len(upperHandles)),
691 edgecolor="black",
692 framealpha=0.4,
693 fontsize=5,
694 )
696 # Overplot tract outlines if tracts were specified, but only if
697 # the plot limits span fewer than the width of 5 tracts
698 # (otherwise the labels will be too crowded to be useful).
699 if len(tractList) > 0:
700 tractInfo = skymap[tractList[0]] # type: ignore
701 tractWidthDeg = tractInfo.outer_sky_polygon.getBoundingBox().getWidth().asDegrees()
702 if limRange <= 5 * tractWidthDeg:
703 deltaLim = 0.05 * limRange
704 for tract, tractLimits in tractLimitsDict.items():
705 centerRa = tractLimits["center"][0]
706 centerDec = tractLimits["center"][1]
707 if (
708 centerRa > xLimMin + deltaLim
709 and centerRa < xLimMax - deltaLim
710 and centerDec > yLimMin + deltaLim
711 and centerDec < yLimMax - deltaLim
712 ):
713 ax.plot(tractLimits["ras"], tractLimits["decs"], color="dimgray", lw=0.4)
714 fontSize = 3 if limRange < 20 else 2
715 ax.annotate(
716 str(tract),
717 tractLimits["center"],
718 va="center",
719 ha="center",
720 color="dimgray",
721 fontsize=fontSize,
722 annotation_clip=True,
723 path_effects=[mpl.patheffects.withStroke(linewidth=1, foreground="w")],
724 )
725 if self.projection == "raDec":
726 ax.set_xlim(xLimMax, xLimMin)
727 else:
728 ax.set_xlim(xLimMin, xLimMax)
729 ax.set_ylim(yLimMin, yLimMax)
730 ax.yaxis.set_major_formatter(yTickFmt)
732 # Get a tick formatter that will give all anticipated value
733 # ranges the same length. This is so that the label padding
734 # has the same effect on all colorbars.
735 value = vMaxDict[zKey] if np.abs(vMaxDict[zKey]) > np.abs(vMinDict[zKey]) else vMinDict[zKey]
736 if vMinDict[zKey] < 0 and np.abs(vMinDict[zKey]) >= vMaxDict[zKey] / 10:
737 value = vMinDict[zKey]
738 tickFmt = _tickFormatter(value)
739 cb = fig.colorbar(
740 pcm,
741 ax=ax,
742 extend="both",
743 aspect=14,
744 format=tickFmt,
745 pad=0.02,
746 )
748 cbLabel = zKey
749 if zKey not in self.unitsDict:
750 log.warning(
751 "Data column {} does not have an entry in unitsDict config. Units "
752 "will not be included in the colorbar text.".format(zKey)
753 )
754 elif len(self.unitsDict[zKey]) > 0:
755 cbLabel = "{} ({})".format(zKey, self.unitsDict[zKey])
757 cb.set_label(
758 cbLabel,
759 labelpad=-29,
760 color="black",
761 fontsize=6,
762 path_effects=[mpl.patheffects.withStroke(linewidth=1, foreground="w")],
763 )
765 runName = plotInfo["run"] # type: ignore
766 supTitle = "{} {} nVisit: {} nData: {}".format(runName, cameraName, nVisit, nDataId)
767 if nCol == 1:
768 supTitle = "{} {}\n nVisit: {} nData: {}".format(runName, cameraName, nVisit, nDataId)
769 fig.suptitle(supTitle, fontsize=4 + nCol, ha="center")
771 return fig
773 def _getTractLimitsDict(self, skymap, tractList):
774 """Return a dict containing tract limits needed for outline plotting.
776 Parameters
777 ----------
778 skymap : `lsst.skymap.BaseSkyMap`
779 The sky map used for this dataset. Used to obtain tract
780 parameters.
781 tractList : `list` [`int`]
782 The list of tract ids (as integers) for which to determine the
783 limits.
785 Returns
786 -------
787 tractLimitsDict : `dict` [`dict`]
788 A dictionary keyed on tract id. Each entry includes a `dict`
789 including the tract RA corners, Dec corners, and the tract center,
790 all in units of degrees. These are used for plotting the tract
791 outlines.
792 """
793 tractLimitsDict = {}
794 for tract in tractList:
795 tractInfo = skymap[tract]
796 tractBbox = tractInfo.outer_sky_polygon.getBoundingBox()
797 tractCenter = tractBbox.getCenter()
798 tractRa0 = (tractCenter[0] - tractBbox.getWidth() / 2).asDegrees()
799 tractRa1 = (tractCenter[0] + tractBbox.getWidth() / 2).asDegrees()
800 tractDec0 = (tractCenter[1] - tractBbox.getHeight() / 2).asDegrees()
801 tractDec1 = (tractCenter[1] + tractBbox.getHeight() / 2).asDegrees()
802 tractLimitsDict[tract] = {
803 "ras": [tractRa0, tractRa1, tractRa1, tractRa0, tractRa0],
804 "decs": [tractDec0, tractDec0, tractDec1, tractDec1, tractDec0],
805 "center": [tractCenter[0].asDegrees(), tractCenter[1].asDegrees()],
806 }
808 return tractLimitsDict
810 def _planeAreaSelector(
811 self,
812 data,
813 xMin=np.nextafter(float("-inf"), 0),
814 xMax=np.nextafter(float("inf"), 0),
815 yMin=np.nextafter(float("-inf"), 0),
816 yMax=np.nextafter(float("inf"), 0),
817 xKey="ra",
818 yKey="decl",
819 ):
820 """Helper function for downselecting on within an area on a plane.
822 Parameters
823 ----------
824 data : `lsst.analysis.tools.interfaces.KeyedData`
825 The key-based catalog of data to select on.
826 xMin, xMax, yMin, yMax : `float`
827 The min/max x/y values defining the range within which to
828 down-select the data.
829 xKey, yKey : `str`
830 The column keys defining the "x" and "y" positions on the plane.
832 Returns
833 -------
834 downSelectedData : `lsst.analysis.tools.interfaces.KeyedData`
835 The down-selected catalog.
836 """
837 xSelector = RangeSelector(key=xKey, minimum=xMin, maximum=xMax)
838 ySelector = RangeSelector(key=yKey, minimum=yMin, maximum=yMax)
839 keyedSelector = KeyedDataSelectorAction(vectorKeys=data.keys())
840 keyedSelector.selectors.xSelector = xSelector
841 keyedSelector.selectors.ySelector = ySelector
842 downSelectedData = keyedSelector(data)
844 return downSelectedData
846 def _trimDataToTracts(self, data, skymap):
847 """Trim the data to limits set by tracts in self.tractsToPlotList.
849 Parameters
850 ----------
851 data : `lsst.analysis.tools.interfaces.KeyedData`
852 The key-based catalog of data to select on.
853 skymap : `lsst.skymap.BaseSkyMap`
854 The sky map used for this dataset. Used to obtain tract
855 parameters.
857 Returns
858 -------
859 downSelectedData : `lsst.analysis.tools.interfaces.KeyedData`
860 The down-selected catalog.
861 """
862 tractRaMin = 1e12
863 tractRaMax = -1e12
864 tractDecMin = 1e12
865 tractDecMax = -1e12
866 for tract in self.tractsToPlotList:
867 tractInfo = skymap[tract]
868 tractBbox = tractInfo.outer_sky_polygon.getBoundingBox()
869 tractCenter = tractBbox.getCenter()
870 tractRa0 = (tractCenter[0] - tractBbox.getWidth() / 2).asDegrees()
871 tractRa1 = (tractCenter[0] + tractBbox.getWidth() / 2).asDegrees()
872 tractDec0 = (tractCenter[1] - tractBbox.getHeight() / 2).asDegrees()
873 tractDec1 = (tractCenter[1] + tractBbox.getHeight() / 2).asDegrees()
874 tractRaMin = min(tractRaMin, tractRa0)
875 tractRaMax = max(tractRaMax, tractRa1)
876 tractDecMin = min(tractDecMin, tractDec0)
877 tractDecMax = max(tractDecMax, tractDec1)
878 downSelectedData = self._planeAreaSelector(
879 data,
880 xMin=tractRaMin,
881 xMax=tractRaMax,
882 yMin=tractDecMin,
883 yMax=tractDecMax,
884 xKey="ra",
885 yKey="decl",
886 )
887 return downSelectedData
890def _tickFormatter(value):
891 """Create a tick formatter such that all anticipated values end up with the
892 same length.
894 This accommodates values ranging from +/-0.0001 -> +/-99999
896 Parameters
897 ----------
898 value : `float`
899 The the value used to determine the appropriate formatting.
901 Returns
902 -------
903 tickFmt : `matplotlib.ticker.FormatStrFormatter`
904 The tick formatter to use with matplotlib functions.
905 """
906 if np.abs(value) >= 10000:
907 tickFmt = FormatStrFormatter("%.0f")
908 elif np.abs(value) >= 1000:
909 tickFmt = FormatStrFormatter("%.1f")
910 if value < 0:
911 tickFmt = FormatStrFormatter("%.0f")
912 elif np.abs(value) >= 100:
913 tickFmt = FormatStrFormatter("%.2f")
914 if value < 0:
915 tickFmt = FormatStrFormatter("%.1f")
916 elif np.abs(value) >= 10:
917 tickFmt = FormatStrFormatter("%.3f")
918 if value < 0:
919 tickFmt = FormatStrFormatter("%.2f")
920 elif np.abs(value) >= 1:
921 tickFmt = FormatStrFormatter("%.4f")
922 if value < 0:
923 tickFmt = FormatStrFormatter("%.3f")
924 else:
925 tickFmt = FormatStrFormatter("%.4f")
926 if value < 0:
927 tickFmt = FormatStrFormatter("%.3f")
928 return tickFmt
931def _setLimitsToEqualRatio(xMin, xMax, yMin, yMax):
932 """For a given set of x/y min/max, redefine to have equal aspect ratio.
934 The limits are extended on both ends such that the central value is
935 preserved.
937 Parameters
938 ----------
939 xMin, xMax, yMin, yMax : `float`
940 The min/max values of the x/y ranges for which to match in dynamic
941 range while perserving the central values.
943 Returns
944 -------
945 xMin, xMax, yMin, yMax : `float`
946 The adjusted min/max values of the x/y ranges with equal aspect ratios.
947 """
948 xDelta = xMax - xMin
949 yDelta = yMax - yMin
950 deltaDiff = yDelta - xDelta
951 if deltaDiff > 0:
952 xMin -= 0.5 * deltaDiff
953 xMax += 0.5 * deltaDiff
954 elif deltaDiff < 0:
955 yMin -= 0.5 * np.abs(deltaDiff)
956 yMax += 0.5 * np.abs(deltaDiff)
957 return xMin, xMax, yMin, yMax