Coverage for python/lsst/analysis/tools/actions/plot/focalPlanePlot.py: 13%
242 statements
« prev ^ index » next coverage.py v7.5.0, created at 2024-05-04 11:05 +0000
« prev ^ index » next coverage.py v7.5.0, created at 2024-05-04 11:05 +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/>.
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 if plotInfo:
293 fig = addPlotInfo(fig, plotInfo)
295 return fig
298class FocalPlaneGeometryPlot(FocalPlanePlot):
299 """Plots the focal plane distribution of a parameter in afw camera
300 geometry units: amplifiers and detectors.
302 Given the detector positions in x and y, the focal plane positions
303 are calculated using the camera model. A 2d binned statistic
304 (default is mean) is then calculated and plotted for the parameter
305 z as a function of the camera geometry segment the input points
306 fall upon.
308 The ``xAxisLabel``, ``yAxisLabel``, ``zAxisLabel``, and
309 ``statistic`` variables are inherited from the parent class.
310 """
312 level = ChoiceField[str](
313 doc="Which geometry level should values be plotted?",
314 default="amplifier",
315 allowed={
316 "amplifier": "Plot values per readout amplifier.",
317 "detector": "Plot values per detector.",
318 },
319 )
321 def getInputSchema(self, **kwargs) -> KeyedDataSchema:
322 base = []
323 base.append(("detector", Vector))
324 if self.level == "amplifier":
325 base.append(("amplifier", Vector))
326 base.append(("z", Vector))
328 return base
330 def makePlot(
331 self,
332 data: KeyedData,
333 camera: Camera,
334 plotInfo: Optional[Mapping[str, str]] = None,
335 **kwargs,
336 ) -> Figure:
337 """Prep the catalogue and then make a focalPlanePlot of the given
338 column.
340 Uses the axisLabels config options `x` and `y` to make an image, where
341 the color corresponds to the 2d binned statistic (the mean is the
342 default) applied to the `z` column. A summary panel is shown in the
343 upper right corner of the resultant plot. The code uses the
344 selectorActions to decide which points to plot and the
345 statisticSelector actions to determine which points to use for the
346 printed statistics.
348 Parameters
349 ----------
350 data : `pandas.core.frame.DataFrame`
351 The catalog to plot the points from. This is expected to
352 have the following columns/keys:
354 ``"detector"``
355 The integer detector id for the points.
356 ``"amplifier"``
357 The string amplifier name for the points.
358 ``"z"``
359 The numerical value that will be combined via
360 ``statistic`` to the binned value.
361 ``"x"``
362 Focal plane x position, optional.
363 ``"y"``
364 Focal plane y position, optional.
365 camera : `lsst.afw.cameraGeom.Camera`
366 The camera used to map from pixel to focal plane positions.
367 plotInfo : `dict`
368 A dictionary of information about the data being plotted with keys:
370 ``"run"``
371 The output run for the plots (`str`).
372 ``"skymap"``
373 The type of skymap used for the data (`str`).
374 ``"filter"``
375 The filter used for this data (`str`).
376 ``"tract"``
377 The tract that the data comes from (`str`).
378 ``"bands"``
379 The band(s) that the data comes from (`list` of `str`).
381 Returns
382 -------
383 fig : `matplotlib.figure.Figure`
384 The resulting figure.
385 """
387 cmap = mkColormap(["midnightBlue", "lightcyan", "darkgreen"])
388 cmap.set_bad(color="none")
390 if plotInfo is None:
391 plotInfo = {}
393 if len(data["z"]) == 0:
394 noDataFig = Figure()
395 noDataFig.text(0.3, 0.5, "No data to plot after selectors applied")
396 noDataFig = addPlotInfo(noDataFig, plotInfo)
397 return noDataFig
399 if self.addHistogram:
400 fig, [ax, histAx] = plt.subplots(1, 2, dpi=300, figsize=(12, 6), width_ratios=[3, 2])
401 else:
402 fig = plt.figure(dpi=300)
403 ax = fig.add_subplot(111)
405 detectorIds = np.unique(data["detector"])
406 focalPlane_x = np.zeros(len(data["z"]))
407 focalPlane_y = np.zeros(len(data["z"]))
409 patches = []
410 values = []
412 # Plot bounding box that will be used to set the axes below.
413 plotLimit_x = [0.0, 0.0]
414 plotLimit_y = [0.0, 0.0]
416 for detectorId in detectorIds:
417 detector = camera[detectorId]
419 # We can go stright to fp coordinates.
420 corners = [(c.getX(), c.getY()) for c in detector.getCorners(FOCAL_PLANE)]
421 corners = np.array(corners)
423 # U/V coordinates represent focal plane locations.
424 minU, minV = corners.min(axis=0)
425 maxU, maxV = corners.max(axis=0)
427 # See if the plot bounding box needs to be extended:
428 if minU < plotLimit_x[0]:
429 plotLimit_x[0] = minU
430 if minV < plotLimit_y[0]:
431 plotLimit_y[0] = minV
432 if maxU > plotLimit_x[1]:
433 plotLimit_x[1] = maxU
434 if maxV > plotLimit_y[1]:
435 plotLimit_y[1] = maxV
437 # X/Y coordinates represent detector internal coordinates.
438 # Detector extent in detector coordinates
439 minX, minY = detector.getBBox().getMin()
440 maxX, maxY = detector.getBBox().getMax()
442 if self.level.lower() == "detector":
443 detectorInd = data["detector"] == detectorId
445 # This does the appropriate statistic for this
446 # detector's data.
447 statistic, _, _ = binned_statistic_dd(
448 [focalPlane_x[detectorInd], focalPlane_y[detectorInd]],
449 data["z"][detectorInd],
450 statistic=self.statistic,
451 bins=[1, 1],
452 )
453 patches.append(Polygon(corners, closed=True))
454 values.append(statistic.ravel()[0])
455 else:
456 # It's at amplifier level. This uses the focal
457 # plane position of the corners of the detector to
458 # generate corners for the individual amplifier
459 # segments.
460 rotation = detector.getOrientation().getNQuarter() # N * 90 degrees.
461 alpha, beta = np.cos(rotation * np.pi / 2.0), np.sin(rotation * np.pi / 2.0)
463 # Calculate the rotation matrix between X/Y and U/V
464 # coordinates.
465 scaleUX = alpha * (maxU - minU) / (maxX - minX)
466 scaleVX = beta * (maxV - minV) / (maxX - minX)
467 scaleVY = alpha * (maxV - minV) / (maxY - minY)
468 scaleUY = beta * (maxU - minU) / (maxY - minY)
470 # After the rotation, some of the corners may have
471 # negative offsets. This corresponds to corners that
472 # reference the maximum edges of the box in U/V
473 # coordinates.
474 baseU = minU if rotation % 4 in (0, 1) else maxU
475 baseV = maxV if rotation % 4 in (2, 3) else minV
477 for amplifier in detector:
478 ampName = amplifier.getName()
479 detectorInd = data["detector"] == detectorId
480 ampInd = data["amplifier"] == ampName
481 ampInd &= detectorInd
483 # Determine amplifier extent in X/Y coordinates.
484 ampMinX, ampMinY = amplifier.getBBox().getMin()
485 ampMaxX, ampMaxY = amplifier.getBBox().getMax()
487 # The corners are rotated into U/V coordinates,
488 # and the appropriate offset added.
489 ampCorners = []
490 ampCorners.append(
491 (
492 scaleUX * (ampMinX - minX) + scaleUY * (ampMinY - minY) + baseU,
493 scaleVY * (ampMinY - minY) + scaleVX * (ampMinX - minX) + baseV,
494 )
495 )
496 ampCorners.append(
497 (
498 scaleUX * (ampMaxX - minX) + scaleUY * (ampMaxY - minY) + baseU,
499 scaleVY * (ampMinY - minY) + scaleVX * (ampMinX - minX) + baseV,
500 )
501 )
502 ampCorners.append(
503 (
504 scaleUX * (ampMaxX - minX) + scaleUY * (ampMaxY - minY) + baseU,
505 scaleVY * (ampMaxY - minY) + scaleVX * (ampMaxX - minX) + baseV,
506 )
507 )
508 ampCorners.append(
509 (
510 scaleUX * (ampMinX - minX) + scaleUY * (ampMinY - minY) + baseU,
511 scaleVY * (ampMaxY - minY) + scaleVX * (ampMaxX - minX) + baseV,
512 )
513 )
514 patches.append(Polygon(ampCorners, closed=True))
515 # This does the appropriate statistic for this
516 # amplifier's data.
517 if len(data["z"][ampInd]) > 0:
518 statistic, _, _ = binned_statistic_dd(
519 [focalPlane_x[ampInd], focalPlane_y[ampInd]],
520 data["z"][ampInd],
521 statistic=self.statistic,
522 bins=[1, 1],
523 )
524 values.append(statistic.ravel()[0])
525 else:
526 values.append(np.nan)
528 # Set bounding box for this figure.
529 ax.set_xlim(plotLimit_x)
530 ax.set_ylim(plotLimit_y)
532 # Do not mask values.
533 if self.showStats:
534 statMed, statMad, statsText = self.statsAndText(values, mask=None)
535 bbox = dict(facecolor="paleturquoise", alpha=0.5, edgecolor="none")
536 ax.text(0.8, 0.91, statsText, transform=fig.transFigure, fontsize=8, bbox=bbox)
538 # Defaults to med + 4 sigma Mad to match
539 # the camera team plots
540 if self.plotMin is not None:
541 vmin = self.plotMin
542 else:
543 vmin = statMed - 4.0 * statMad
544 if self.plotMax is not None:
545 vmax = self.plotMax
546 else:
547 vmax = statMed + 4.0 * statMad
549 valuesPlot = np.clip(values, vmin, vmax)
551 patchCollection = PatchCollection(
552 patches, edgecolor="white", cmap=cmap, linewidth=0.5, linestyle=(0, (0.5, 3))
553 )
554 patchCollection.set_array(valuesPlot)
555 ax.add_collection(patchCollection)
557 divider = make_axes_locatable(ax)
558 cax = divider.append_axes("right", size="5%", pad=0.05)
559 fig.colorbar(patchCollection, cax=cax, extend="both")
560 text = cax.text(
561 0.5,
562 0.5,
563 self.zAxisLabel,
564 color="k",
565 rotation="vertical",
566 transform=cax.transAxes,
567 ha="center",
568 va="center",
569 fontsize=10,
570 )
571 text.set_path_effects([pathEffects.Stroke(linewidth=3, foreground="w"), pathEffects.Normal()])
572 cax.tick_params(labelsize=7)
574 ax.set_xlabel(self.xAxisLabel)
575 ax.set_ylabel(self.yAxisLabel)
576 ax.tick_params(axis="x", labelrotation=25)
577 ax.tick_params(labelsize=7)
579 ax.set_aspect("equal")
581 if self.addHistogram:
582 self._addHistogram(histAx, data["z"])
584 plt.draw()
586 # Add useful information to the plot
587 fig.subplots_adjust(left=0.05, right=0.95)
588 fig = plt.gcf()
589 if plotInfo:
590 fig = addPlotInfo(fig, plotInfo)
592 return fig