Coverage for python/lsst/analysis/tools/actions/plot/focalPlanePlot.py: 13%
240 statements
« prev ^ index » next coverage.py v7.5.0, created at 2024-04-24 04:09 -0700
« prev ^ index » next coverage.py v7.5.0, created at 2024-04-24 04:09 -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/>.
22from __future__ import annotations
24__all__ = ("FocalPlanePlot", "FocalPlaneGeometryPlot")
26from typing import Mapping, Optional
28import matplotlib.patheffects as pathEffects
29import matplotlib.pyplot as plt
30import numpy as np
31from lsst.afw.cameraGeom import FOCAL_PLANE, PIXELS, Camera
32from lsst.pex.config import ChoiceField, Field
33from matplotlib.collections import PatchCollection
34from matplotlib.figure import Figure
35from matplotlib.offsetbox import AnchoredText
36from matplotlib.patches import Polygon
37from mpl_toolkits.axes_grid1 import make_axes_locatable
38from scipy.stats import binned_statistic_2d, binned_statistic_dd
40from ...interfaces import KeyedData, KeyedDataSchema, PlotAction, Scalar, Vector
41from ...math import nanMax, nanMedian, nanMin, nanSigmaMad
42from .plotUtils import addPlotInfo, mkColormap, sortAllArrays
45class FocalPlanePlot(PlotAction):
46 """Plots the focal plane distribution of a parameter.
48 Given the detector positions in x and y, the focal plane positions are
49 calculated using the camera model. A 2d binned statistic (default is mean)
50 is then calculated and plotted for the parameter z as a function of the
51 focal plane coordinates.
52 """
54 xAxisLabel = Field[str](doc="Label to use for the x axis.", default="x (mm)", optional=True)
55 yAxisLabel = Field[str](doc="Label to use for the y axis.", default="y (mm)", optional=True)
56 zAxisLabel = Field[str](doc="Label to use for the z axis.", optional=False)
57 nBins = Field[int](
58 doc="Number of bins to use within the effective plot ranges along the spatial directions.",
59 default=200,
60 )
61 doUseAdaptiveBinning = Field[bool](
62 doc="If set to True, the number of bins is adapted to the source"
63 " density, with lower densities using fewer bins. Under these"
64 " circumstances the nBins parameter sets the minimum number of bins.",
65 default=False,
66 )
67 statistic = Field[str](
68 doc="Operation to perform in binned_statistic_2d",
69 default="mean",
70 )
71 plotMin = Field[float](
72 doc="Minimum in z-value to display in the focal plane plot and in the histogram plot, if applicable",
73 default=None,
74 optional=True,
75 )
76 plotMax = Field[float](
77 doc="Maximum in z-value to display in the focal plane plot and in the histogram plot, if applicable",
78 default=None,
79 optional=True,
80 )
81 showStats = Field[bool](doc="Show statistics for plotted data", default=True)
82 addHistogram = Field[bool](doc="Add a histogram of all input points", default=False)
83 histBins = Field[int](doc="Number of bins to use in histogram", default=30)
85 def __call__(self, data: KeyedData, **kwargs) -> Mapping[str, Figure] | Figure:
86 self._validateInput(data, **kwargs)
87 return self.makePlot(data, **kwargs)
88 # table is a dict that needs: x, y, run, skymap, filter, tract,
90 def _validateInput(self, data: KeyedData, **kwargs) -> None:
91 """NOTE currently can only check that something is not a Scalar, not
92 check that the data is consistent with Vector
93 """
94 needed = self.getInputSchema(**kwargs)
95 if remainder := {key.format(**kwargs) for key, _ in needed} - {
96 key.format(**kwargs) for key in data.keys()
97 }:
98 raise ValueError(f"Task needs keys {remainder} but they were not found in input")
99 for name, typ in needed:
100 isScalar = issubclass((colType := type(data[name.format(**kwargs)])), Scalar)
101 if isScalar and typ != Scalar:
102 raise ValueError(f"Data keyed by {name} has type {colType} but action requires type {typ}")
104 def getInputSchema(self, **kwargs) -> KeyedDataSchema:
105 base = []
106 base.append(("x", Vector))
107 base.append(("y", Vector))
108 base.append(("z", Vector))
109 base.append(("statMask", Vector))
111 return base
113 def statsAndText(self, arr, mask=None):
114 """Calculate some stats from an array and return them
115 and some text.
116 """
117 numPoints = len(arr)
118 if mask is not None:
119 arr = arr[mask]
120 med = nanMedian(arr)
121 sigMad = nanSigmaMad(arr)
123 statsText = (
124 "Median: {:0.2f}\n".format(med)
125 + r"$\sigma_{MAD}$: "
126 + "{:0.2f}\n".format(sigMad)
127 + r"n$_{points}$: "
128 + "{}".format(numPoints)
129 )
131 return med, sigMad, statsText
133 def _addHistogram(self, histAx, data):
134 bins = np.linspace(
135 (self.plotMin if self.plotMin else nanMin(data.astype(float))),
136 (self.plotMax if self.plotMax else nanMax(data.astype(float))),
137 self.histBins,
138 )
139 histAx.hist(data.astype(float), bins=bins)
140 histAx.set_xlabel(self.zAxisLabel)
141 histAx.set_ylabel("Bin count")
142 underflow = np.count_nonzero(data < bins[0])
143 overflow = np.count_nonzero(data > bins[-1])
144 nonfinite = np.count_nonzero(~np.isfinite(data))
145 text = f"Underflow = {underflow}\nOverflow = {overflow}\nNon-Finite = {nonfinite}"
146 anchored_text = AnchoredText(text, loc=1, pad=0.5)
147 histAx.add_artist(anchored_text)
149 def makePlot(
150 self,
151 data: KeyedData,
152 camera: Camera,
153 plotInfo: Optional[Mapping[str, str]] = None,
154 **kwargs,
155 ) -> Figure:
156 """Prep the catalogue and then make a focalPlanePlot of the given
157 column.
159 Uses the axisLabels config options `x` and `y` to make an image, where
160 the color corresponds to the 2d binned statistic (the mean is the
161 default) applied to the `z` column. A summary panel is shown in the
162 upper right corner of the resultant plot. The code uses the
163 selectorActions to decide which points to plot and the
164 statisticSelector actions to determine which points to use for the
165 printed statistics.
167 Parameters
168 ----------
169 data : `pandas.core.frame.DataFrame`
170 The catalog to plot the points from.
171 camera : `lsst.afw.cameraGeom.Camera`
172 The camera used to map from pixel to focal plane positions.
173 plotInfo : `dict`
174 A dictionary of information about the data being plotted with keys:
176 ``"run"``
177 The output run for the plots (`str`).
178 ``"skymap"``
179 The type of skymap used for the data (`str`).
180 ``"filter"``
181 The filter used for this data (`str`).
182 ``"tract"``
183 The tract that the data comes from (`str`).
184 ``"bands"``
185 The band(s) that the data comes from (`list` of `str`).
187 Returns
188 -------
189 fig : `matplotlib.figure.Figure`
190 The resulting figure.
191 """
192 if plotInfo is None:
193 plotInfo = {}
195 if len(data["x"]) == 0:
196 noDataFig = Figure()
197 noDataFig.text(0.3, 0.5, "No data to plot after selectors applied")
198 noDataFig = addPlotInfo(noDataFig, plotInfo)
199 return noDataFig
201 if self.addHistogram:
202 fig, [ax, histAx] = plt.subplots(1, 2, dpi=300, figsize=(12, 6), width_ratios=[3, 2])
203 else:
204 fig = plt.figure(dpi=300)
205 ax = fig.add_subplot(111)
207 detectorIds = np.unique(data["detector"])
208 focalPlane_x = np.zeros(len(data["x"]))
209 focalPlane_y = np.zeros(len(data["y"]))
210 for detectorId in detectorIds:
211 detector = camera[detectorId]
212 map = detector.getTransform(PIXELS, FOCAL_PLANE).getMapping()
214 detectorInd = data["detector"] == detectorId
215 points = np.array([data["x"][detectorInd], data["y"][detectorInd]])
217 fp_x, fp_y = map.applyForward(points)
218 focalPlane_x[detectorInd] = fp_x
219 focalPlane_y[detectorInd] = fp_y
221 if self.doUseAdaptiveBinning:
222 # Use a course 32x32 binning to determine the mean source density
223 # in regions where there are sources.
224 binsx = np.linspace(focalPlane_x.min() - 1e-5, focalPlane_x.max() + 1e-5, 33)
225 binsy = np.linspace(focalPlane_y.min() - 1e-5, focalPlane_y.max() + 1e-5, 33)
227 binnedNumSrc = np.histogram2d(focalPlane_x, focalPlane_y, bins=[binsx, binsy])[0]
228 meanSrcDensity = np.mean(binnedNumSrc, where=binnedNumSrc > 0.0)
230 numBins = int(np.round(16.0 * np.sqrt(meanSrcDensity)))
231 numBins = max(numBins, self.nBins)
232 else:
233 numBins = self.nBins
235 # Add an arbitrary small offset to bins to ensure that the minimum does
236 # not equal the maximum.
237 binsx = np.linspace(focalPlane_x.min() - 1e-5, focalPlane_x.max() + 1e-5, numBins)
238 binsy = np.linspace(focalPlane_y.min() - 1e-5, focalPlane_y.max() + 1e-5, numBins)
240 statistic, x_edge, y_edge, binnumber = binned_statistic_2d(
241 focalPlane_x, focalPlane_y, data["z"], statistic=self.statistic, bins=[binsx, binsy]
242 )
243 binExtent = [x_edge[0], x_edge[-1], y_edge[0], y_edge[-1]]
245 if self.showStats:
246 sortedArrs = sortAllArrays([data["z"], data["x"], data["y"], data["statMask"]])
247 [colorVals, xs, ys, stat] = sortedArrs
248 statMed, statMad, statsText = self.statsAndText(colorVals, mask=stat)
249 bbox = dict(facecolor="paleturquoise", alpha=0.5, edgecolor="none")
250 ax.text(0.8, 0.91, statsText, transform=fig.transFigure, fontsize=8, bbox=bbox)
252 median = nanMedian(statistic.ravel())
253 mad = nanSigmaMad(statistic.ravel())
255 vmin = self.plotMin if (self.plotMin is not None) else (median - 2 * mad)
256 vmax = self.plotMax if (self.plotMax is not None) else (median + 2 * mad)
258 plot = ax.imshow(statistic.T, extent=binExtent, vmin=vmin, vmax=vmax, origin="lower")
260 divider = make_axes_locatable(ax)
261 cax = divider.append_axes("right", size="5%", pad=0.05)
262 fig.colorbar(plot, cax=cax, extend="both")
263 text = cax.text(
264 0.5,
265 0.5,
266 self.zAxisLabel,
267 color="k",
268 rotation="vertical",
269 transform=cax.transAxes,
270 ha="center",
271 va="center",
272 fontsize=10,
273 )
274 text.set_path_effects([pathEffects.Stroke(linewidth=3, foreground="w"), pathEffects.Normal()])
275 cax.tick_params(labelsize=7)
277 ax.set_xlabel(self.xAxisLabel)
278 ax.set_ylabel(self.yAxisLabel)
279 ax.tick_params(axis="x", labelrotation=25)
280 ax.tick_params(labelsize=7)
282 ax.set_aspect("equal")
284 if self.addHistogram:
285 self._addHistogram(histAx, data["z"])
287 plt.draw()
289 # Add useful information to the plot
290 plt.subplots_adjust(left=0.05, right=0.95)
291 fig = plt.gcf()
292 fig = addPlotInfo(fig, plotInfo)
294 return fig
297class FocalPlaneGeometryPlot(FocalPlanePlot):
298 """Plots the focal plane distribution of a parameter in afw camera
299 geometry units: amplifiers and detectors.
301 Given the detector positions in x and y, the focal plane positions
302 are calculated using the camera model. A 2d binned statistic
303 (default is mean) is then calculated and plotted for the parameter
304 z as a function of the camera geometry segment the input points
305 fall upon.
307 The ``xAxisLabel``, ``yAxisLabel``, ``zAxisLabel``, and
308 ``statistic`` variables are inherited from the parent class.
309 """
311 level = ChoiceField[str](
312 doc="Which geometry level should values be plotted?",
313 default="amplifier",
314 allowed={
315 "amplifier": "Plot values per readout amplifier.",
316 "detector": "Plot values per detector.",
317 },
318 )
320 def getInputSchema(self, **kwargs) -> KeyedDataSchema:
321 base = []
322 base.append(("detector", Vector))
323 if self.level == "amplifier":
324 base.append(("amplifier", Vector))
325 base.append(("z", Vector))
327 return base
329 def makePlot(
330 self,
331 data: KeyedData,
332 camera: Camera,
333 plotInfo: Optional[Mapping[str, str]] = None,
334 **kwargs,
335 ) -> Figure:
336 """Prep the catalogue and then make a focalPlanePlot of the given
337 column.
339 Uses the axisLabels config options `x` and `y` to make an image, where
340 the color corresponds to the 2d binned statistic (the mean is the
341 default) applied to the `z` column. A summary panel is shown in the
342 upper right corner of the resultant plot. The code uses the
343 selectorActions to decide which points to plot and the
344 statisticSelector actions to determine which points to use for the
345 printed statistics.
347 Parameters
348 ----------
349 data : `pandas.core.frame.DataFrame`
350 The catalog to plot the points from. This is expected to
351 have the following columns/keys:
353 ``"detector"``
354 The integer detector id for the points.
355 ``"amplifier"``
356 The string amplifier name for the points.
357 ``"z"``
358 The numerical value that will be combined via
359 ``statistic`` to the binned value.
360 ``"x"``
361 Focal plane x position, optional.
362 ``"y"``
363 Focal plane y position, optional.
364 camera : `lsst.afw.cameraGeom.Camera`
365 The camera used to map from pixel to focal plane positions.
366 plotInfo : `dict`
367 A dictionary of information about the data being plotted with keys:
369 ``"run"``
370 The output run for the plots (`str`).
371 ``"skymap"``
372 The type of skymap used for the data (`str`).
373 ``"filter"``
374 The filter used for this data (`str`).
375 ``"tract"``
376 The tract that the data comes from (`str`).
377 ``"bands"``
378 The band(s) that the data comes from (`list` of `str`).
380 Returns
381 -------
382 fig : `matplotlib.figure.Figure`
383 The resulting figure.
384 """
386 cmap = mkColormap(["midnightBlue", "lightcyan", "darkgreen"])
387 cmap.set_bad(color="none")
389 if plotInfo is None:
390 plotInfo = {}
392 if len(data["z"]) == 0:
393 noDataFig = Figure()
394 noDataFig.text(0.3, 0.5, "No data to plot after selectors applied")
395 noDataFig = addPlotInfo(noDataFig, plotInfo)
396 return noDataFig
398 if self.addHistogram:
399 fig, [ax, histAx] = plt.subplots(1, 2, dpi=300, figsize=(12, 6), width_ratios=[3, 2])
400 else:
401 fig = plt.figure(dpi=300)
402 ax = fig.add_subplot(111)
404 detectorIds = np.unique(data["detector"])
405 focalPlane_x = np.zeros(len(data["z"]))
406 focalPlane_y = np.zeros(len(data["z"]))
408 patches = []
409 values = []
411 # Plot bounding box that will be used to set the axes below.
412 plotLimit_x = [0.0, 0.0]
413 plotLimit_y = [0.0, 0.0]
415 for detectorId in detectorIds:
416 detector = camera[detectorId]
418 # We can go stright to fp coordinates.
419 corners = [(c.getX(), c.getY()) for c in detector.getCorners(FOCAL_PLANE)]
420 corners = np.array(corners)
422 # U/V coordinates represent focal plane locations.
423 minU, minV = corners.min(axis=0)
424 maxU, maxV = corners.max(axis=0)
426 # See if the plot bounding box needs to be extended:
427 if minU < plotLimit_x[0]:
428 plotLimit_x[0] = minU
429 if minV < plotLimit_y[0]:
430 plotLimit_y[0] = minV
431 if maxU > plotLimit_x[1]:
432 plotLimit_x[1] = maxU
433 if maxV > plotLimit_y[1]:
434 plotLimit_y[1] = maxV
436 # X/Y coordinates represent detector internal coordinates.
437 # Detector extent in detector coordinates
438 minX, minY = detector.getBBox().getMin()
439 maxX, maxY = detector.getBBox().getMax()
441 if self.level.lower() == "detector":
442 detectorInd = data["detector"] == detectorId
444 # This does the appropriate statistic for this
445 # detector's data.
446 statistic, _, _ = binned_statistic_dd(
447 [focalPlane_x[detectorInd], focalPlane_y[detectorInd]],
448 data["z"][detectorInd],
449 statistic=self.statistic,
450 bins=[1, 1],
451 )
452 patches.append(Polygon(corners, closed=True))
453 values.append(statistic.ravel()[0])
454 else:
455 # It's at amplifier level. This uses the focal
456 # plane position of the corners of the detector to
457 # generate corners for the individual amplifier
458 # segments.
459 rotation = detector.getOrientation().getNQuarter() # N * 90 degrees.
460 alpha, beta = np.cos(rotation * np.pi / 2.0), np.sin(rotation * np.pi / 2.0)
462 # Calculate the rotation matrix between X/Y and U/V
463 # coordinates.
464 scaleUX = alpha * (maxU - minU) / (maxX - minX)
465 scaleVX = beta * (maxV - minV) / (maxX - minX)
466 scaleVY = alpha * (maxV - minV) / (maxY - minY)
467 scaleUY = beta * (maxU - minU) / (maxY - minY)
469 # After the rotation, some of the corners may have
470 # negative offsets. This corresponds to corners that
471 # reference the maximum edges of the box in U/V
472 # coordinates.
473 baseU = minU if rotation % 4 in (0, 1) else maxU
474 baseV = maxV if rotation % 4 in (2, 3) else minV
476 for amplifier in detector:
477 ampName = amplifier.getName()
478 detectorInd = data["detector"] == detectorId
479 ampInd = data["amplifier"] == ampName
480 ampInd &= detectorInd
482 # Determine amplifier extent in X/Y coordinates.
483 ampMinX, ampMinY = amplifier.getBBox().getMin()
484 ampMaxX, ampMaxY = amplifier.getBBox().getMax()
486 # The corners are rotated into U/V coordinates,
487 # and the appropriate offset added.
488 ampCorners = []
489 ampCorners.append(
490 (
491 scaleUX * (ampMinX - minX) + scaleUY * (ampMinY - minY) + baseU,
492 scaleVY * (ampMinY - minY) + scaleVX * (ampMinX - minX) + baseV,
493 )
494 )
495 ampCorners.append(
496 (
497 scaleUX * (ampMaxX - minX) + scaleUY * (ampMaxY - minY) + baseU,
498 scaleVY * (ampMinY - minY) + scaleVX * (ampMinX - minX) + baseV,
499 )
500 )
501 ampCorners.append(
502 (
503 scaleUX * (ampMaxX - minX) + scaleUY * (ampMaxY - minY) + baseU,
504 scaleVY * (ampMaxY - minY) + scaleVX * (ampMaxX - minX) + baseV,
505 )
506 )
507 ampCorners.append(
508 (
509 scaleUX * (ampMinX - minX) + scaleUY * (ampMinY - minY) + baseU,
510 scaleVY * (ampMaxY - minY) + scaleVX * (ampMaxX - minX) + baseV,
511 )
512 )
513 patches.append(Polygon(ampCorners, closed=True))
514 # This does the appropriate statistic for this
515 # amplifier's data.
516 if len(data["z"][ampInd]) > 0:
517 statistic, _, _ = binned_statistic_dd(
518 [focalPlane_x[ampInd], focalPlane_y[ampInd]],
519 data["z"][ampInd],
520 statistic=self.statistic,
521 bins=[1, 1],
522 )
523 values.append(statistic.ravel()[0])
524 else:
525 values.append(np.nan)
527 # Set bounding box for this figure.
528 ax.set_xlim(plotLimit_x)
529 ax.set_ylim(plotLimit_y)
531 # Do not mask values.
532 if self.showStats:
533 statMed, statMad, statsText = self.statsAndText(values, mask=None)
534 bbox = dict(facecolor="paleturquoise", alpha=0.5, edgecolor="none")
535 ax.text(0.8, 0.91, statsText, transform=fig.transFigure, fontsize=8, bbox=bbox)
537 # Defaults to med + 4 sigma Mad to match
538 # the camera team plots
539 if self.plotMin is not None:
540 vmin = self.plotMin
541 else:
542 vmin = statMed - 4.0 * statMad
543 if self.plotMax is not None:
544 vmax = self.plotMax
545 else:
546 vmax = statMed + 4.0 * statMad
548 valuesPlot = np.clip(values, vmin, vmax)
550 patchCollection = PatchCollection(
551 patches, edgecolor="white", cmap=cmap, linewidth=0.5, linestyle=(0, (0.5, 3))
552 )
553 patchCollection.set_array(valuesPlot)
554 ax.add_collection(patchCollection)
556 divider = make_axes_locatable(ax)
557 cax = divider.append_axes("right", size="5%", pad=0.05)
558 fig.colorbar(patchCollection, cax=cax, extend="both")
559 text = cax.text(
560 0.5,
561 0.5,
562 self.zAxisLabel,
563 color="k",
564 rotation="vertical",
565 transform=cax.transAxes,
566 ha="center",
567 va="center",
568 fontsize=10,
569 )
570 text.set_path_effects([pathEffects.Stroke(linewidth=3, foreground="w"), pathEffects.Normal()])
571 cax.tick_params(labelsize=7)
573 ax.set_xlabel(self.xAxisLabel)
574 ax.set_ylabel(self.yAxisLabel)
575 ax.tick_params(axis="x", labelrotation=25)
576 ax.tick_params(labelsize=7)
578 ax.set_aspect("equal")
580 if self.addHistogram:
581 self._addHistogram(histAx, data["z"])
583 plt.draw()
585 # Add useful information to the plot
586 fig.subplots_adjust(left=0.05, right=0.95)
587 fig = plt.gcf()
588 fig = addPlotInfo(fig, plotInfo)
590 return fig