Coverage for python/lsst/analysis/tools/actions/plot/focalPlanePlot.py: 13%
185 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-11-30 14:25 +0000
« prev ^ index » next coverage.py v7.3.2, created at 2023-11-30 14:25 +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:
140 ``"run"``
141 The output run for the plots (`str`).
142 ``"skymap"``
143 The type of skymap used for the data (`str`).
144 ``"filter"``
145 The filter used for this data (`str`).
146 ``"tract"``
147 The tract that the data comes from (`str`).
148 ``"bands"``
149 The band(s) that the data comes from (`list` of `str`).
151 Returns
152 -------
153 fig : `matplotlib.figure.Figure`
154 The resulting figure.
155 """
156 if plotInfo is None:
157 plotInfo = {}
159 if len(data["x"]) == 0:
160 noDataFig = Figure()
161 noDataFig.text(0.3, 0.5, "No data to plot after selectors applied")
162 noDataFig = addPlotInfo(noDataFig, plotInfo)
163 return noDataFig
165 fig = plt.figure(dpi=300)
166 ax = fig.add_subplot(111)
168 detectorIds = np.unique(data["detector"])
169 focalPlane_x = np.zeros(len(data["x"]))
170 focalPlane_y = np.zeros(len(data["y"]))
171 for detectorId in detectorIds:
172 detector = camera[detectorId]
173 map = detector.getTransform(PIXELS, FOCAL_PLANE).getMapping()
175 detectorInd = data["detector"] == detectorId
176 points = np.array([data["x"][detectorInd], data["y"][detectorInd]])
178 fp_x, fp_y = map.applyForward(points)
179 focalPlane_x[detectorInd] = fp_x
180 focalPlane_y[detectorInd] = fp_y
182 # Add an arbitrary small offset to bins to ensure that the minimum does
183 # not equal the maximum.
184 binsx = np.linspace(focalPlane_x.min() - 1e-5, focalPlane_x.max() + 1e-5, self.nBins)
185 binsy = np.linspace(focalPlane_y.min() - 1e-5, focalPlane_y.max() + 1e-5, self.nBins)
187 statistic, x_edge, y_edge, binnumber = binned_statistic_2d(
188 focalPlane_x, focalPlane_y, data["z"], statistic=self.statistic, bins=[binsx, binsy]
189 )
190 binExtent = [x_edge[0], x_edge[-1], y_edge[0], y_edge[-1]]
192 sortedArrs = sortAllArrays([data["z"], data["x"], data["y"], data["statMask"]])
193 [colorVals, xs, ys, stat] = sortedArrs
194 statMed, statMad, statsText = self.statsAndText(colorVals, mask=stat)
195 bbox = dict(facecolor="paleturquoise", alpha=0.5, edgecolor="none")
196 ax.text(0.8, 0.91, statsText, transform=fig.transFigure, fontsize=8, bbox=bbox)
198 median = np.nanmedian(statistic.ravel())
199 mad = nansigmaMad(statistic.ravel())
201 vmin = median - 2 * mad
202 vmax = median + 2 * mad
204 plot = ax.imshow(statistic.T, extent=binExtent, vmin=vmin, vmax=vmax, origin="lower")
206 cax = fig.add_axes([0.87 + 0.04, 0.11, 0.04, 0.77])
207 plt.colorbar(plot, cax=cax, extend="both")
208 text = cax.text(
209 0.5,
210 0.5,
211 self.zAxisLabel,
212 color="k",
213 rotation="vertical",
214 transform=cax.transAxes,
215 ha="center",
216 va="center",
217 fontsize=10,
218 )
219 text.set_path_effects([pathEffects.Stroke(linewidth=3, foreground="w"), pathEffects.Normal()])
220 cax.tick_params(labelsize=7)
222 ax.set_xlabel(self.xAxisLabel)
223 ax.set_ylabel(self.yAxisLabel)
224 ax.tick_params(axis="x", labelrotation=25)
225 ax.tick_params(labelsize=7)
227 ax.set_aspect("equal")
228 plt.draw()
230 # Add useful information to the plot
231 plt.subplots_adjust(wspace=0.0, hspace=0.0, right=0.85)
232 fig = plt.gcf()
233 fig = addPlotInfo(fig, plotInfo)
235 return fig
238class FocalPlaneGeometryPlot(FocalPlanePlot):
239 """Plots the focal plane distribution of a parameter in afw camera
240 geometry units: amplifiers and detectors.
242 Given the detector positions in x and y, the focal plane positions
243 are calculated using the camera model. A 2d binned statistic
244 (default is mean) is then calculated and plotted for the parameter
245 z as a function of the camera geometry segment the input points
246 fall upon.
248 The ``xAxisLabel``, ``yAxisLabel``, ``zAxisLabel``, and
249 ``statistic`` variables are inherited from the parent class.
250 """
252 level = ChoiceField[str](
253 doc="Which geometry level should values be plotted?",
254 default="amplifier",
255 allowed={
256 "amplifier": "Plot values per readout amplifier.",
257 "detector": "Plot values per detector.",
258 },
259 )
261 def makePlot(
262 self,
263 data: KeyedData,
264 camera: Camera,
265 plotInfo: Optional[Mapping[str, str]] = None,
266 **kwargs,
267 ) -> Figure:
268 """Prep the catalogue and then make a focalPlanePlot of the given
269 column.
271 Uses the axisLabels config options `x` and `y` to make an image, where
272 the color corresponds to the 2d binned statistic (the mean is the
273 default) applied to the `z` column. A summary panel is shown in the
274 upper right corner of the resultant plot. The code uses the
275 selectorActions to decide which points to plot and the
276 statisticSelector actions to determine which points to use for the
277 printed statistics.
279 Parameters
280 ----------
281 data : `pandas.core.frame.DataFrame`
282 The catalog to plot the points from. This is expected to
283 have the following columns/keys:
285 ``"detector"``
286 The integer detector id for the points.
287 ``"amplifier"``
288 The string amplifier name for the points.
289 ``"z"``
290 The numerical value that will be combined via
291 ``statistic`` to the binned value.
292 ``"x"``
293 Focal plane x position, optional.
294 ``"y"``
295 Focal plane y position, optional.
296 camera : `lsst.afw.cameraGeom.Camera`
297 The camera used to map from pixel to focal plane positions.
298 plotInfo : `dict`
299 A dictionary of information about the data being plotted with keys:
301 ``"run"``
302 The output run for the plots (`str`).
303 ``"skymap"``
304 The type of skymap used for the data (`str`).
305 ``"filter"``
306 The filter used for this data (`str`).
307 ``"tract"``
308 The tract that the data comes from (`str`).
309 ``"bands"``
310 The band(s) that the data comes from (`list` of `str`).
312 Returns
313 -------
314 fig : `matplotlib.figure.Figure`
315 The resulting figure.
316 """
317 if plotInfo is None:
318 plotInfo = {}
320 if len(data["z"]) == 0:
321 noDataFig = Figure()
322 noDataFig.text(0.3, 0.5, "No data to plot after selectors applied")
323 noDataFig = addPlotInfo(noDataFig, plotInfo)
324 return noDataFig
326 fig = plt.figure(dpi=300)
327 ax = fig.add_subplot(111)
329 detectorIds = np.unique(data["detector"])
330 focalPlane_x = np.zeros(len(data["z"]))
331 focalPlane_y = np.zeros(len(data["z"]))
333 patches = []
334 values = []
336 # Plot bounding box that will be used to set the axes below.
337 plotLimit_x = [0.0, 0.0]
338 plotLimit_y = [0.0, 0.0]
340 for detectorId in detectorIds:
341 detector = camera[detectorId]
343 # We can go stright to fp coordinates.
344 corners = [(c.getX(), c.getY()) for c in detector.getCorners(FOCAL_PLANE)]
345 corners = np.array(corners)
347 # U/V coordinates represent focal plane locations.
348 minU, minV = corners.min(axis=0)
349 maxU, maxV = corners.max(axis=0)
351 # See if the plot bounding box needs to be extended:
352 if minU < plotLimit_x[0]:
353 plotLimit_x[0] = minU
354 if minV < plotLimit_y[0]:
355 plotLimit_y[0] = minV
356 if maxU > plotLimit_x[1]:
357 plotLimit_x[1] = maxU
358 if maxV > plotLimit_y[1]:
359 plotLimit_y[1] = maxV
361 # X/Y coordinates represent detector internal coordinates.
362 # Detector extent in detector coordinates
363 minX, minY = detector.getBBox().getMin()
364 maxX, maxY = detector.getBBox().getMax()
366 if self.level.lower() == "detector":
367 detectorInd = data["detector"] == detectorId
369 # This does the appropriate statistic for this
370 # detector's data.
371 statistic, _, _ = binned_statistic_dd(
372 [focalPlane_x[detectorInd], focalPlane_y[detectorInd]],
373 data["z"][detectorInd],
374 statistic=self.statistic,
375 bins=[1, 1],
376 )
377 patches.append(Polygon(corners, True))
378 values.append(statistic.ravel()[0])
379 else:
380 # It's at amplifier level. This uses the focal
381 # plane position of the corners of the detector to
382 # generate corners for the individual amplifier
383 # segments.
384 rotation = detector.getOrientation().getNQuarter() # N * 90 degrees.
385 alpha, beta = np.cos(rotation * np.pi / 2.0), np.sin(rotation * np.pi / 2.0)
387 # Calculate the rotation matrix between X/Y and U/V
388 # coordinates.
389 scaleUX = alpha * (maxU - minU) / (maxX - minX)
390 scaleVX = beta * (maxV - minV) / (maxX - minX)
391 scaleVY = alpha * (maxV - minV) / (maxY - minY)
392 scaleUY = beta * (maxU - minU) / (maxY - minY)
394 # After the rotation, some of the corners may have
395 # negative offsets. This corresponds to corners that
396 # reference the maximum edges of the box in U/V
397 # coordinates.
398 baseU = minU if rotation % 4 in (0, 1) else maxU
399 baseV = maxV if rotation % 4 in (2, 3) else minV
401 for amplifier in detector:
402 ampName = amplifier.getName()
403 detectorInd = data["detector"] == detectorId
404 ampInd = data["amplifier"] == ampName
405 ampInd &= detectorInd
407 # Determine amplifier extent in X/Y coordinates.
408 ampMinX, ampMinY = amplifier.getBBox().getMin()
409 ampMaxX, ampMaxY = amplifier.getBBox().getMax()
411 # The corners are rotated into U/V coordinates,
412 # and the appropriate offset added.
413 ampCorners = []
414 ampCorners.append(
415 (
416 scaleUX * (ampMinX - minX) + scaleUY * (ampMinY - minY) + baseU,
417 scaleVY * (ampMinY - minY) + scaleVX * (ampMinX - minX) + baseV,
418 )
419 )
420 ampCorners.append(
421 (
422 scaleUX * (ampMaxX - minX) + scaleUY * (ampMaxY - minY) + baseU,
423 scaleVY * (ampMinY - minY) + scaleVX * (ampMinX - minX) + baseV,
424 )
425 )
426 ampCorners.append(
427 (
428 scaleUX * (ampMaxX - minX) + scaleUY * (ampMaxY - minY) + baseU,
429 scaleVY * (ampMaxY - minY) + scaleVX * (ampMaxX - minX) + baseV,
430 )
431 )
432 ampCorners.append(
433 (
434 scaleUX * (ampMinX - minX) + scaleUY * (ampMinY - minY) + baseU,
435 scaleVY * (ampMaxY - minY) + scaleVX * (ampMaxX - minX) + baseV,
436 )
437 )
438 patches.append(Polygon(ampCorners, True))
439 # This does the appropriate statistic for this
440 # amplifier's data.
441 if len(data["z"][ampInd]) > 0:
442 statistic, _, _ = binned_statistic_dd(
443 [focalPlane_x[ampInd], focalPlane_y[ampInd]],
444 data["z"][ampInd],
445 statistic=self.statistic,
446 bins=[1, 1],
447 )
448 values.append(statistic.ravel()[0])
449 else:
450 values.append(np.nan)
452 # Set bounding box for this figure.
453 ax.set_xlim(plotLimit_x)
454 ax.set_ylim(plotLimit_y)
456 # Do not mask values.
457 statMed, statMad, statsText = self.statsAndText(values, mask=None)
458 bbox = dict(facecolor="paleturquoise", alpha=0.5, edgecolor="none")
459 ax.text(0.8, 0.91, statsText, transform=fig.transFigure, fontsize=8, bbox=bbox)
461 patchCollection = PatchCollection(patches, alpha=0.4, edgecolor="black")
462 patchCollection.set_array(values)
463 ax.add_collection(patchCollection)
465 cax = fig.add_axes([0.87 + 0.04, 0.11, 0.04, 0.77])
466 fig.colorbar(patchCollection, cax=cax, extend="both")
467 text = cax.text(
468 0.5,
469 0.5,
470 self.zAxisLabel,
471 color="k",
472 rotation="vertical",
473 transform=cax.transAxes,
474 ha="center",
475 va="center",
476 fontsize=10,
477 )
478 text.set_path_effects([pathEffects.Stroke(linewidth=3, foreground="w"), pathEffects.Normal()])
479 cax.tick_params(labelsize=7)
481 ax.set_xlabel(self.xAxisLabel)
482 ax.set_ylabel(self.yAxisLabel)
483 ax.tick_params(axis="x", labelrotation=25)
484 ax.tick_params(labelsize=7)
486 ax.set_aspect("equal")
487 plt.draw()
489 # Add useful information to the plot
490 plt.subplots_adjust(wspace=0.0, hspace=0.0, right=0.85)
491 fig = plt.gcf()
492 fig = addPlotInfo(fig, plotInfo)
494 return fig