Coverage for python/lsst/analysis/tools/actions/plot/focalPlanePlot.py: 13%
185 statements
« prev ^ index » next coverage.py v7.3.1, created at 2023-09-07 11:42 +0000
« prev ^ index » next coverage.py v7.3.1, created at 2023-09-07 11:42 +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 ...statistics import 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 statistic = Field[str](
61 doc="Operation to perform in binned_statistic_2d",
62 default="mean",
63 )
65 def __call__(self, data: KeyedData, **kwargs) -> Mapping[str, Figure] | Figure:
66 self._validateInput(data, **kwargs)
67 return self.makePlot(data, **kwargs)
68 # table is a dict that needs: x, y, run, skymap, filter, tract,
70 def _validateInput(self, data: KeyedData, **kwargs) -> None:
71 """NOTE currently can only check that something is not a Scalar, not
72 check that the data is consistent with Vector
73 """
74 needed = self.getInputSchema(**kwargs)
75 if remainder := {key.format(**kwargs) for key, _ in needed} - {
76 key.format(**kwargs) for key in data.keys()
77 }:
78 raise ValueError(f"Task needs keys {remainder} but they were not found in input")
79 for name, typ in needed:
80 isScalar = issubclass((colType := type(data[name.format(**kwargs)])), Scalar)
81 if isScalar and typ != Scalar:
82 raise ValueError(f"Data keyed by {name} has type {colType} but action requires type {typ}")
84 def getInputSchema(self, **kwargs) -> KeyedDataSchema:
85 base = []
86 base.append(("x", Vector))
87 base.append(("y", Vector))
88 base.append(("z", Vector))
89 base.append(("statMask", Vector))
91 return base
93 def statsAndText(self, arr, mask=None):
94 """Calculate some stats from an array and return them
95 and some text.
96 """
97 numPoints = len(arr)
98 if mask is not None:
99 arr = arr[mask]
100 med = np.nanmedian(arr)
101 sigMad = nansigmaMad(arr)
103 statsText = (
104 "Median: {:0.2f}\n".format(med)
105 + r"$\sigma_{MAD}$: "
106 + "{:0.2f}\n".format(sigMad)
107 + r"n$_{points}$: "
108 + "{}".format(numPoints)
109 )
111 return med, sigMad, statsText
113 def makePlot(
114 self,
115 data: KeyedData,
116 camera: Camera,
117 plotInfo: Optional[Mapping[str, str]] = None,
118 **kwargs,
119 ) -> Figure:
120 """Prep the catalogue and then make a focalPlanePlot of the given
121 column.
123 Uses the axisLabels config options `x` and `y` to make an image, where
124 the color corresponds to the 2d binned statistic (the mean is the
125 default) applied to the `z` column. A summary panel is shown in the
126 upper right corner of the resultant plot. The code uses the
127 selectorActions to decide which points to plot and the
128 statisticSelector actions to determine which points to use for the
129 printed statistics.
131 Parameters
132 ----------
133 data : `pandas.core.frame.DataFrame`
134 The catalog to plot the points from.
135 camera : `lsst.afw.cameraGeom.Camera`
136 The camera used to map from pixel to focal plane positions.
137 plotInfo : `dict`
138 A dictionary of information about the data being plotted with keys:
139 ``"run"``
140 The output run for the plots (`str`).
141 ``"skymap"``
142 The type of skymap used for the data (`str`).
143 ``"filter"``
144 The filter used for this data (`str`).
145 ``"tract"``
146 The tract that the data comes from (`str`).
147 ``"bands"``
148 The band(s) that the data comes from (`list` of `str`).
150 Returns
151 -------
152 fig : `matplotlib.figure.Figure`
153 The resulting figure.
154 """
155 if plotInfo is None:
156 plotInfo = {}
158 if len(data["x"]) == 0:
159 noDataFig = Figure()
160 noDataFig.text(0.3, 0.5, "No data to plot after selectors applied")
161 noDataFig = addPlotInfo(noDataFig, plotInfo)
162 return noDataFig
164 fig = plt.figure(dpi=300)
165 ax = fig.add_subplot(111)
167 detectorIds = np.unique(data["detector"])
168 focalPlane_x = np.zeros(len(data["x"]))
169 focalPlane_y = np.zeros(len(data["y"]))
170 for detectorId in detectorIds:
171 detector = camera[detectorId]
172 map = detector.getTransform(PIXELS, FOCAL_PLANE).getMapping()
174 detectorInd = data["detector"] == detectorId
175 points = np.array([data["x"][detectorInd], data["y"][detectorInd]])
177 fp_x, fp_y = map.applyForward(points)
178 focalPlane_x[detectorInd] = fp_x
179 focalPlane_y[detectorInd] = fp_y
181 # Add an arbitrary small offset to bins to ensure that the minimum does
182 # not equal the maximum.
183 binsx = np.linspace(focalPlane_x.min() - 1e-5, focalPlane_x.max() + 1e-5, self.nBins)
184 binsy = np.linspace(focalPlane_y.min() - 1e-5, focalPlane_y.max() + 1e-5, self.nBins)
186 statistic, x_edge, y_edge, binnumber = binned_statistic_2d(
187 focalPlane_x, focalPlane_y, data["z"], statistic=self.statistic, bins=[binsx, binsy]
188 )
189 binExtent = [x_edge[0], x_edge[-1], y_edge[0], y_edge[-1]]
191 sortedArrs = sortAllArrays([data["z"], data["x"], data["y"], data["statMask"]])
192 [colorVals, xs, ys, stat] = sortedArrs
193 statMed, statMad, statsText = self.statsAndText(colorVals, mask=stat)
194 bbox = dict(facecolor="paleturquoise", alpha=0.5, edgecolor="none")
195 ax.text(0.8, 0.91, statsText, transform=fig.transFigure, fontsize=8, bbox=bbox)
197 median = np.nanmedian(statistic.ravel())
198 mad = nansigmaMad(statistic.ravel())
200 vmin = median - 2 * mad
201 vmax = median + 2 * mad
203 plot = ax.imshow(statistic.T, extent=binExtent, vmin=vmin, vmax=vmax, origin="lower")
205 cax = fig.add_axes([0.87 + 0.04, 0.11, 0.04, 0.77])
206 plt.colorbar(plot, cax=cax, extend="both")
207 text = cax.text(
208 0.5,
209 0.5,
210 self.zAxisLabel,
211 color="k",
212 rotation="vertical",
213 transform=cax.transAxes,
214 ha="center",
215 va="center",
216 fontsize=10,
217 )
218 text.set_path_effects([pathEffects.Stroke(linewidth=3, foreground="w"), pathEffects.Normal()])
219 cax.tick_params(labelsize=7)
221 ax.set_xlabel(self.xAxisLabel)
222 ax.set_ylabel(self.yAxisLabel)
223 ax.tick_params(axis="x", labelrotation=25)
224 ax.tick_params(labelsize=7)
226 ax.set_aspect("equal")
227 plt.draw()
229 # Add useful information to the plot
230 plt.subplots_adjust(wspace=0.0, hspace=0.0, right=0.85)
231 fig = plt.gcf()
232 fig = addPlotInfo(fig, plotInfo)
234 return fig
237class FocalPlaneGeometryPlot(FocalPlanePlot):
238 """Plots the focal plane distribution of a parameter in afw camera
239 geometry units: amplifiers and detectors.
241 Given the detector positions in x and y, the focal plane positions
242 are calculated using the camera model. A 2d binned statistic
243 (default is mean) is then calculated and plotted for the parameter
244 z as a function of the camera geometry segment the input points
245 fall upon.
247 The ``xAxisLabel``, ``yAxisLabel``, ``zAxisLabel``, and
248 ``statistic`` variables are inherited from the parent class.
249 """
251 level = ChoiceField[str](
252 doc="Which geometry level should values be plotted?",
253 default="amplifier",
254 allowed={
255 "amplifier": "Plot values per readout amplifier.",
256 "detector": "Plot values per detector.",
257 },
258 )
260 def makePlot(
261 self,
262 data: KeyedData,
263 camera: Camera,
264 plotInfo: Optional[Mapping[str, str]] = None,
265 **kwargs,
266 ) -> Figure:
267 """Prep the catalogue and then make a focalPlanePlot of the given
268 column.
270 Uses the axisLabels config options `x` and `y` to make an image, where
271 the color corresponds to the 2d binned statistic (the mean is the
272 default) applied to the `z` column. A summary panel is shown in the
273 upper right corner of the resultant plot. The code uses the
274 selectorActions to decide which points to plot and the
275 statisticSelector actions to determine which points to use for the
276 printed statistics.
278 Parameters
279 ----------
280 data : `pandas.core.frame.DataFrame`
281 The catalog to plot the points from. This is expected to
282 have the following columns/keys:
283 ``"detector"``
284 The integer detector id for the points.
285 ``"amplifier"``
286 The string amplifier name for the points.
287 ``"z"``
288 The numerical value that will be combined via
289 ``statistic`` to the binned value.
290 ``"x"``
291 Focal plane x position, optional.
292 ``"y"``
293 Focal plane y position, optional.
294 camera : `lsst.afw.cameraGeom.Camera`
295 The camera used to map from pixel to focal plane positions.
296 plotInfo : `dict`
297 A dictionary of information about the data being plotted with keys:
298 ``"run"``
299 The output run for the plots (`str`).
300 ``"skymap"``
301 The type of skymap used for the data (`str`).
302 ``"filter"``
303 The filter used for this data (`str`).
304 ``"tract"``
305 The tract that the data comes from (`str`).
306 ``"bands"``
307 The band(s) that the data comes from (`list` of `str`).
309 Returns
310 -------
311 fig : `matplotlib.figure.Figure`
312 The resulting figure.
313 """
314 if plotInfo is None:
315 plotInfo = {}
317 if len(data["z"]) == 0:
318 noDataFig = Figure()
319 noDataFig.text(0.3, 0.5, "No data to plot after selectors applied")
320 noDataFig = addPlotInfo(noDataFig, plotInfo)
321 return noDataFig
323 fig = plt.figure(dpi=300)
324 ax = fig.add_subplot(111)
326 detectorIds = np.unique(data["detector"])
327 focalPlane_x = np.zeros(len(data["z"]))
328 focalPlane_y = np.zeros(len(data["z"]))
330 patches = []
331 values = []
333 # Plot bounding box that will be used to set the axes below.
334 plotLimit_x = [0.0, 0.0]
335 plotLimit_y = [0.0, 0.0]
337 for detectorId in detectorIds:
338 detector = camera[detectorId]
340 # We can go stright to fp coordinates.
341 corners = [(c.getX(), c.getY()) for c in detector.getCorners(FOCAL_PLANE)]
342 corners = np.array(corners)
344 # U/V coordinates represent focal plane locations.
345 minU, minV = corners.min(axis=0)
346 maxU, maxV = corners.max(axis=0)
348 # See if the plot bounding box needs to be extended:
349 if minU < plotLimit_x[0]:
350 plotLimit_x[0] = minU
351 if minV < plotLimit_y[0]:
352 plotLimit_y[0] = minV
353 if maxU > plotLimit_x[1]:
354 plotLimit_x[1] = maxU
355 if maxV > plotLimit_y[1]:
356 plotLimit_y[1] = maxV
358 # X/Y coordinates represent detector internal coordinates.
359 # Detector extent in detector coordinates
360 minX, minY = detector.getBBox().getMin()
361 maxX, maxY = detector.getBBox().getMax()
363 if self.level.lower() == "detector":
364 detectorInd = data["detector"] == detectorId
366 # This does the appropriate statistic for this
367 # detector's data.
368 statistic, _, _ = binned_statistic_dd(
369 [focalPlane_x[detectorInd], focalPlane_y[detectorInd]],
370 data["z"][detectorInd],
371 statistic=self.statistic,
372 bins=[1, 1],
373 )
374 patches.append(Polygon(corners, True))
375 values.append(statistic.ravel()[0])
376 else:
377 # It's at amplifier level. This uses the focal
378 # plane position of the corners of the detector to
379 # generate corners for the individual amplifier
380 # segments.
381 rotation = detector.getOrientation().getNQuarter() # N * 90 degrees.
382 alpha, beta = np.cos(rotation * np.pi / 2.0), np.sin(rotation * np.pi / 2.0)
384 # Calculate the rotation matrix between X/Y and U/V
385 # coordinates.
386 scaleUX = alpha * (maxU - minU) / (maxX - minX)
387 scaleVX = beta * (maxV - minV) / (maxX - minX)
388 scaleVY = alpha * (maxV - minV) / (maxY - minY)
389 scaleUY = beta * (maxU - minU) / (maxY - minY)
391 # After the rotation, some of the corners may have
392 # negative offsets. This corresponds to corners that
393 # reference the maximum edges of the box in U/V
394 # coordinates.
395 baseU = minU if rotation % 4 in (0, 1) else maxU
396 baseV = maxV if rotation % 4 in (2, 3) else minV
398 for amplifier in detector:
399 ampName = amplifier.getName()
400 detectorInd = data["detector"] == detectorId
401 ampInd = data["amplifier"] == ampName
402 ampInd &= detectorInd
404 # Determine amplifier extent in X/Y coordinates.
405 ampMinX, ampMinY = amplifier.getBBox().getMin()
406 ampMaxX, ampMaxY = amplifier.getBBox().getMax()
408 # The corners are rotated into U/V coordinates,
409 # and the appropriate offset added.
410 ampCorners = []
411 ampCorners.append(
412 (
413 scaleUX * (ampMinX - minX) + scaleUY * (ampMinY - minY) + baseU,
414 scaleVY * (ampMinY - minY) + scaleVX * (ampMinX - minX) + baseV,
415 )
416 )
417 ampCorners.append(
418 (
419 scaleUX * (ampMaxX - minX) + scaleUY * (ampMaxY - minY) + baseU,
420 scaleVY * (ampMinY - minY) + scaleVX * (ampMinX - minX) + baseV,
421 )
422 )
423 ampCorners.append(
424 (
425 scaleUX * (ampMaxX - minX) + scaleUY * (ampMaxY - minY) + baseU,
426 scaleVY * (ampMaxY - minY) + scaleVX * (ampMaxX - minX) + baseV,
427 )
428 )
429 ampCorners.append(
430 (
431 scaleUX * (ampMinX - minX) + scaleUY * (ampMinY - minY) + baseU,
432 scaleVY * (ampMaxY - minY) + scaleVX * (ampMaxX - minX) + baseV,
433 )
434 )
435 patches.append(Polygon(ampCorners, True))
436 # This does the appropriate statistic for this
437 # amplifier's data.
438 if len(data["z"][ampInd]) > 0:
439 statistic, _, _ = binned_statistic_dd(
440 [focalPlane_x[ampInd], focalPlane_y[ampInd]],
441 data["z"][ampInd],
442 statistic=self.statistic,
443 bins=[1, 1],
444 )
445 values.append(statistic.ravel()[0])
446 else:
447 values.append(np.nan)
449 # Set bounding box for this figure.
450 ax.set_xlim(plotLimit_x)
451 ax.set_ylim(plotLimit_y)
453 # Do not mask values.
454 statMed, statMad, statsText = self.statsAndText(values, mask=None)
455 bbox = dict(facecolor="paleturquoise", alpha=0.5, edgecolor="none")
456 ax.text(0.8, 0.91, statsText, transform=fig.transFigure, fontsize=8, bbox=bbox)
458 patchCollection = PatchCollection(patches, alpha=0.4, edgecolor="black")
459 patchCollection.set_array(values)
460 ax.add_collection(patchCollection)
462 cax = fig.add_axes([0.87 + 0.04, 0.11, 0.04, 0.77])
463 fig.colorbar(patchCollection, cax=cax, extend="both")
464 text = cax.text(
465 0.5,
466 0.5,
467 self.zAxisLabel,
468 color="k",
469 rotation="vertical",
470 transform=cax.transAxes,
471 ha="center",
472 va="center",
473 fontsize=10,
474 )
475 text.set_path_effects([pathEffects.Stroke(linewidth=3, foreground="w"), pathEffects.Normal()])
476 cax.tick_params(labelsize=7)
478 ax.set_xlabel(self.xAxisLabel)
479 ax.set_ylabel(self.yAxisLabel)
480 ax.tick_params(axis="x", labelrotation=25)
481 ax.tick_params(labelsize=7)
483 ax.set_aspect("equal")
484 plt.draw()
486 # Add useful information to the plot
487 plt.subplots_adjust(wspace=0.0, hspace=0.0, right=0.85)
488 fig = plt.gcf()
489 fig = addPlotInfo(fig, plotInfo)
491 return fig