Coverage for python/lsst/analysis/tools/actions/plot/focalPlanePlot.py: 13%
201 statements
« prev ^ index » next coverage.py v7.4.1, created at 2024-02-03 13:17 +0000
« prev ^ index » next coverage.py v7.4.1, created at 2024-02-03 13:17 +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.patches import Polygon
36from scipy.stats import binned_statistic_2d, binned_statistic_dd
38from ...interfaces import KeyedData, KeyedDataSchema, PlotAction, Scalar, Vector
39from ...math import nanMedian, nanSigmaMad
40from .plotUtils import addPlotInfo, sortAllArrays
43class FocalPlanePlot(PlotAction):
44 """Plots the focal plane distribution of a parameter.
46 Given the detector positions in x and y, the focal plane positions are
47 calculated using the camera model. A 2d binned statistic (default is mean)
48 is then calculated and plotted for the parameter z as a function of the
49 focal plane coordinates.
50 """
52 xAxisLabel = Field[str](doc="Label to use for the x axis.", optional=False)
53 yAxisLabel = Field[str](doc="Label to use for the y axis.", optional=False)
54 zAxisLabel = Field[str](doc="Label to use for the z axis.", optional=False)
56 nBins = Field[int](
57 doc="Number of bins to use within the effective plot ranges along the spatial directions.",
58 default=200,
59 )
60 doUseAdaptiveBinning = Field[bool](
61 doc="If set to True, the number of bins is adapted to the source"
62 " density, with lower densities using fewer bins. Under these"
63 " circumstances the nBins parameter sets the minimum number of bins.",
64 default=False,
65 )
66 statistic = Field[str](
67 doc="Operation to perform in binned_statistic_2d",
68 default="mean",
69 )
71 def __call__(self, data: KeyedData, **kwargs) -> Mapping[str, Figure] | Figure:
72 self._validateInput(data, **kwargs)
73 return self.makePlot(data, **kwargs)
74 # table is a dict that needs: x, y, run, skymap, filter, tract,
76 def _validateInput(self, data: KeyedData, **kwargs) -> None:
77 """NOTE currently can only check that something is not a Scalar, not
78 check that the data is consistent with Vector
79 """
80 needed = self.getInputSchema(**kwargs)
81 if remainder := {key.format(**kwargs) for key, _ in needed} - {
82 key.format(**kwargs) for key in data.keys()
83 }:
84 raise ValueError(f"Task needs keys {remainder} but they were not found in input")
85 for name, typ in needed:
86 isScalar = issubclass((colType := type(data[name.format(**kwargs)])), Scalar)
87 if isScalar and typ != Scalar:
88 raise ValueError(f"Data keyed by {name} has type {colType} but action requires type {typ}")
90 def getInputSchema(self, **kwargs) -> KeyedDataSchema:
91 base = []
92 base.append(("x", Vector))
93 base.append(("y", Vector))
94 base.append(("z", Vector))
95 base.append(("statMask", Vector))
97 return base
99 def statsAndText(self, arr, mask=None):
100 """Calculate some stats from an array and return them
101 and some text.
102 """
103 numPoints = len(arr)
104 if mask is not None:
105 arr = arr[mask]
106 med = nanMedian(arr)
107 sigMad = nanSigmaMad(arr)
109 statsText = (
110 "Median: {:0.2f}\n".format(med)
111 + r"$\sigma_{MAD}$: "
112 + "{:0.2f}\n".format(sigMad)
113 + r"n$_{points}$: "
114 + "{}".format(numPoints)
115 )
117 return med, sigMad, statsText
119 def makePlot(
120 self,
121 data: KeyedData,
122 camera: Camera,
123 plotInfo: Optional[Mapping[str, str]] = None,
124 **kwargs,
125 ) -> Figure:
126 """Prep the catalogue and then make a focalPlanePlot of the given
127 column.
129 Uses the axisLabels config options `x` and `y` to make an image, where
130 the color corresponds to the 2d binned statistic (the mean is the
131 default) applied to the `z` column. A summary panel is shown in the
132 upper right corner of the resultant plot. The code uses the
133 selectorActions to decide which points to plot and the
134 statisticSelector actions to determine which points to use for the
135 printed statistics.
137 Parameters
138 ----------
139 data : `pandas.core.frame.DataFrame`
140 The catalog to plot the points from.
141 camera : `lsst.afw.cameraGeom.Camera`
142 The camera used to map from pixel to focal plane positions.
143 plotInfo : `dict`
144 A dictionary of information about the data being plotted with keys:
146 ``"run"``
147 The output run for the plots (`str`).
148 ``"skymap"``
149 The type of skymap used for the data (`str`).
150 ``"filter"``
151 The filter used for this data (`str`).
152 ``"tract"``
153 The tract that the data comes from (`str`).
154 ``"bands"``
155 The band(s) that the data comes from (`list` of `str`).
157 Returns
158 -------
159 fig : `matplotlib.figure.Figure`
160 The resulting figure.
161 """
162 if plotInfo is None:
163 plotInfo = {}
165 if len(data["x"]) == 0:
166 noDataFig = Figure()
167 noDataFig.text(0.3, 0.5, "No data to plot after selectors applied")
168 noDataFig = addPlotInfo(noDataFig, plotInfo)
169 return noDataFig
171 fig = plt.figure(dpi=300)
172 ax = fig.add_subplot(111)
174 detectorIds = np.unique(data["detector"])
175 focalPlane_x = np.zeros(len(data["x"]))
176 focalPlane_y = np.zeros(len(data["y"]))
177 for detectorId in detectorIds:
178 detector = camera[detectorId]
179 map = detector.getTransform(PIXELS, FOCAL_PLANE).getMapping()
181 detectorInd = data["detector"] == detectorId
182 points = np.array([data["x"][detectorInd], data["y"][detectorInd]])
184 fp_x, fp_y = map.applyForward(points)
185 focalPlane_x[detectorInd] = fp_x
186 focalPlane_y[detectorInd] = fp_y
188 if self.doUseAdaptiveBinning:
189 # Use a course 32x32 binning to determine the mean source density
190 # in regions where there are sources.
191 binsx = np.linspace(focalPlane_x.min() - 1e-5, focalPlane_x.max() + 1e-5, 33)
192 binsy = np.linspace(focalPlane_y.min() - 1e-5, focalPlane_y.max() + 1e-5, 33)
194 binnedNumSrc = np.histogram2d(focalPlane_x, focalPlane_y, bins=[binsx, binsy])[0]
195 meanSrcDensity = np.mean(binnedNumSrc, where=binnedNumSrc > 0.0)
197 numBins = int(np.round(16.0 * np.sqrt(meanSrcDensity)))
198 numBins = max(numBins, self.nBins)
199 else:
200 numBins = self.nBins
202 # Add an arbitrary small offset to bins to ensure that the minimum does
203 # not equal the maximum.
204 binsx = np.linspace(focalPlane_x.min() - 1e-5, focalPlane_x.max() + 1e-5, numBins)
205 binsy = np.linspace(focalPlane_y.min() - 1e-5, focalPlane_y.max() + 1e-5, numBins)
207 statistic, x_edge, y_edge, binnumber = binned_statistic_2d(
208 focalPlane_x, focalPlane_y, data["z"], statistic=self.statistic, bins=[binsx, binsy]
209 )
210 binExtent = [x_edge[0], x_edge[-1], y_edge[0], y_edge[-1]]
212 sortedArrs = sortAllArrays([data["z"], data["x"], data["y"], data["statMask"]])
213 [colorVals, xs, ys, stat] = sortedArrs
214 statMed, statMad, statsText = self.statsAndText(colorVals, mask=stat)
215 bbox = dict(facecolor="paleturquoise", alpha=0.5, edgecolor="none")
216 ax.text(0.8, 0.91, statsText, transform=fig.transFigure, fontsize=8, bbox=bbox)
218 median = nanMedian(statistic.ravel())
219 mad = nanSigmaMad(statistic.ravel())
221 vmin = median - 2 * mad
222 vmax = median + 2 * mad
224 plot = ax.imshow(statistic.T, extent=binExtent, vmin=vmin, vmax=vmax, origin="lower")
226 cax = fig.add_axes([0.87 + 0.04, 0.11, 0.04, 0.77])
227 plt.colorbar(plot, cax=cax, extend="both")
228 text = cax.text(
229 0.5,
230 0.5,
231 self.zAxisLabel,
232 color="k",
233 rotation="vertical",
234 transform=cax.transAxes,
235 ha="center",
236 va="center",
237 fontsize=10,
238 )
239 text.set_path_effects([pathEffects.Stroke(linewidth=3, foreground="w"), pathEffects.Normal()])
240 cax.tick_params(labelsize=7)
242 ax.set_xlabel(self.xAxisLabel)
243 ax.set_ylabel(self.yAxisLabel)
244 ax.tick_params(axis="x", labelrotation=25)
245 ax.tick_params(labelsize=7)
247 ax.set_aspect("equal")
248 plt.draw()
250 # Add useful information to the plot
251 plt.subplots_adjust(wspace=0.0, hspace=0.0, right=0.85)
252 fig = plt.gcf()
253 fig = addPlotInfo(fig, plotInfo)
255 return fig
258class FocalPlaneGeometryPlot(FocalPlanePlot):
259 """Plots the focal plane distribution of a parameter in afw camera
260 geometry units: amplifiers and detectors.
262 Given the detector positions in x and y, the focal plane positions
263 are calculated using the camera model. A 2d binned statistic
264 (default is mean) is then calculated and plotted for the parameter
265 z as a function of the camera geometry segment the input points
266 fall upon.
268 The ``xAxisLabel``, ``yAxisLabel``, ``zAxisLabel``, and
269 ``statistic`` variables are inherited from the parent class.
270 """
272 level = ChoiceField[str](
273 doc="Which geometry level should values be plotted?",
274 default="amplifier",
275 allowed={
276 "amplifier": "Plot values per readout amplifier.",
277 "detector": "Plot values per detector.",
278 },
279 )
281 def getInputSchema(self, **kwargs) -> KeyedDataSchema:
282 base = []
283 base.append(("detector", Vector))
284 if self.level == "amplifier":
285 base.append(("amplifier", Vector))
286 base.append(("z", Vector))
288 return base
290 def makePlot(
291 self,
292 data: KeyedData,
293 camera: Camera,
294 plotInfo: Optional[Mapping[str, str]] = None,
295 **kwargs,
296 ) -> Figure:
297 """Prep the catalogue and then make a focalPlanePlot of the given
298 column.
300 Uses the axisLabels config options `x` and `y` to make an image, where
301 the color corresponds to the 2d binned statistic (the mean is the
302 default) applied to the `z` column. A summary panel is shown in the
303 upper right corner of the resultant plot. The code uses the
304 selectorActions to decide which points to plot and the
305 statisticSelector actions to determine which points to use for the
306 printed statistics.
308 Parameters
309 ----------
310 data : `pandas.core.frame.DataFrame`
311 The catalog to plot the points from. This is expected to
312 have the following columns/keys:
314 ``"detector"``
315 The integer detector id for the points.
316 ``"amplifier"``
317 The string amplifier name for the points.
318 ``"z"``
319 The numerical value that will be combined via
320 ``statistic`` to the binned value.
321 ``"x"``
322 Focal plane x position, optional.
323 ``"y"``
324 Focal plane y position, optional.
325 camera : `lsst.afw.cameraGeom.Camera`
326 The camera used to map from pixel to focal plane positions.
327 plotInfo : `dict`
328 A dictionary of information about the data being plotted with keys:
330 ``"run"``
331 The output run for the plots (`str`).
332 ``"skymap"``
333 The type of skymap used for the data (`str`).
334 ``"filter"``
335 The filter used for this data (`str`).
336 ``"tract"``
337 The tract that the data comes from (`str`).
338 ``"bands"``
339 The band(s) that the data comes from (`list` of `str`).
341 Returns
342 -------
343 fig : `matplotlib.figure.Figure`
344 The resulting figure.
345 """
346 if plotInfo is None:
347 plotInfo = {}
349 if len(data["z"]) == 0:
350 noDataFig = Figure()
351 noDataFig.text(0.3, 0.5, "No data to plot after selectors applied")
352 noDataFig = addPlotInfo(noDataFig, plotInfo)
353 return noDataFig
355 fig = plt.figure(dpi=300)
356 ax = fig.add_subplot(111)
358 detectorIds = np.unique(data["detector"])
359 focalPlane_x = np.zeros(len(data["z"]))
360 focalPlane_y = np.zeros(len(data["z"]))
362 patches = []
363 values = []
365 # Plot bounding box that will be used to set the axes below.
366 plotLimit_x = [0.0, 0.0]
367 plotLimit_y = [0.0, 0.0]
369 for detectorId in detectorIds:
370 detector = camera[detectorId]
372 # We can go stright to fp coordinates.
373 corners = [(c.getX(), c.getY()) for c in detector.getCorners(FOCAL_PLANE)]
374 corners = np.array(corners)
376 # U/V coordinates represent focal plane locations.
377 minU, minV = corners.min(axis=0)
378 maxU, maxV = corners.max(axis=0)
380 # See if the plot bounding box needs to be extended:
381 if minU < plotLimit_x[0]:
382 plotLimit_x[0] = minU
383 if minV < plotLimit_y[0]:
384 plotLimit_y[0] = minV
385 if maxU > plotLimit_x[1]:
386 plotLimit_x[1] = maxU
387 if maxV > plotLimit_y[1]:
388 plotLimit_y[1] = maxV
390 # X/Y coordinates represent detector internal coordinates.
391 # Detector extent in detector coordinates
392 minX, minY = detector.getBBox().getMin()
393 maxX, maxY = detector.getBBox().getMax()
395 if self.level.lower() == "detector":
396 detectorInd = data["detector"] == detectorId
398 # This does the appropriate statistic for this
399 # detector's data.
400 statistic, _, _ = binned_statistic_dd(
401 [focalPlane_x[detectorInd], focalPlane_y[detectorInd]],
402 data["z"][detectorInd],
403 statistic=self.statistic,
404 bins=[1, 1],
405 )
406 patches.append(Polygon(corners, closed=True))
407 values.append(statistic.ravel()[0])
408 else:
409 # It's at amplifier level. This uses the focal
410 # plane position of the corners of the detector to
411 # generate corners for the individual amplifier
412 # segments.
413 rotation = detector.getOrientation().getNQuarter() # N * 90 degrees.
414 alpha, beta = np.cos(rotation * np.pi / 2.0), np.sin(rotation * np.pi / 2.0)
416 # Calculate the rotation matrix between X/Y and U/V
417 # coordinates.
418 scaleUX = alpha * (maxU - minU) / (maxX - minX)
419 scaleVX = beta * (maxV - minV) / (maxX - minX)
420 scaleVY = alpha * (maxV - minV) / (maxY - minY)
421 scaleUY = beta * (maxU - minU) / (maxY - minY)
423 # After the rotation, some of the corners may have
424 # negative offsets. This corresponds to corners that
425 # reference the maximum edges of the box in U/V
426 # coordinates.
427 baseU = minU if rotation % 4 in (0, 1) else maxU
428 baseV = maxV if rotation % 4 in (2, 3) else minV
430 for amplifier in detector:
431 ampName = amplifier.getName()
432 detectorInd = data["detector"] == detectorId
433 ampInd = data["amplifier"] == ampName
434 ampInd &= detectorInd
436 # Determine amplifier extent in X/Y coordinates.
437 ampMinX, ampMinY = amplifier.getBBox().getMin()
438 ampMaxX, ampMaxY = amplifier.getBBox().getMax()
440 # The corners are rotated into U/V coordinates,
441 # and the appropriate offset added.
442 ampCorners = []
443 ampCorners.append(
444 (
445 scaleUX * (ampMinX - minX) + scaleUY * (ampMinY - minY) + baseU,
446 scaleVY * (ampMinY - minY) + scaleVX * (ampMinX - minX) + baseV,
447 )
448 )
449 ampCorners.append(
450 (
451 scaleUX * (ampMaxX - minX) + scaleUY * (ampMaxY - minY) + baseU,
452 scaleVY * (ampMinY - minY) + scaleVX * (ampMinX - minX) + baseV,
453 )
454 )
455 ampCorners.append(
456 (
457 scaleUX * (ampMaxX - minX) + scaleUY * (ampMaxY - minY) + baseU,
458 scaleVY * (ampMaxY - minY) + scaleVX * (ampMaxX - minX) + baseV,
459 )
460 )
461 ampCorners.append(
462 (
463 scaleUX * (ampMinX - minX) + scaleUY * (ampMinY - minY) + baseU,
464 scaleVY * (ampMaxY - minY) + scaleVX * (ampMaxX - minX) + baseV,
465 )
466 )
467 patches.append(Polygon(ampCorners, closed=True))
468 # This does the appropriate statistic for this
469 # amplifier's data.
470 if len(data["z"][ampInd]) > 0:
471 statistic, _, _ = binned_statistic_dd(
472 [focalPlane_x[ampInd], focalPlane_y[ampInd]],
473 data["z"][ampInd],
474 statistic=self.statistic,
475 bins=[1, 1],
476 )
477 values.append(statistic.ravel()[0])
478 else:
479 values.append(np.nan)
481 # Set bounding box for this figure.
482 ax.set_xlim(plotLimit_x)
483 ax.set_ylim(plotLimit_y)
485 # Do not mask values.
486 statMed, statMad, statsText = self.statsAndText(values, mask=None)
487 bbox = dict(facecolor="paleturquoise", alpha=0.5, edgecolor="none")
488 ax.text(0.8, 0.91, statsText, transform=fig.transFigure, fontsize=8, bbox=bbox)
490 patchCollection = PatchCollection(patches, alpha=0.4, edgecolor="black")
491 patchCollection.set_array(values)
492 ax.add_collection(patchCollection)
494 cax = fig.add_axes([0.87 + 0.04, 0.11, 0.04, 0.77])
495 fig.colorbar(patchCollection, cax=cax, extend="both")
496 text = cax.text(
497 0.5,
498 0.5,
499 self.zAxisLabel,
500 color="k",
501 rotation="vertical",
502 transform=cax.transAxes,
503 ha="center",
504 va="center",
505 fontsize=10,
506 )
507 text.set_path_effects([pathEffects.Stroke(linewidth=3, foreground="w"), pathEffects.Normal()])
508 cax.tick_params(labelsize=7)
510 ax.set_xlabel(self.xAxisLabel)
511 ax.set_ylabel(self.yAxisLabel)
512 ax.tick_params(axis="x", labelrotation=25)
513 ax.tick_params(labelsize=7)
515 ax.set_aspect("equal")
516 plt.draw()
518 # Add useful information to the plot
519 plt.subplots_adjust(wspace=0.0, hspace=0.0, right=0.85)
520 fig = plt.gcf()
521 fig = addPlotInfo(fig, plotInfo)
523 return fig