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