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