Coverage for python/lsst/analysis/tools/actions/plot/focalPlanePlot.py: 13%
194 statements
« prev ^ index » next coverage.py v7.4.0, created at 2024-01-20 13:15 +0000
« prev ^ index » next coverage.py v7.4.0, created at 2024-01-20 13:15 +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 makePlot(
282 self,
283 data: KeyedData,
284 camera: Camera,
285 plotInfo: Optional[Mapping[str, str]] = None,
286 **kwargs,
287 ) -> Figure:
288 """Prep the catalogue and then make a focalPlanePlot of the given
289 column.
291 Uses the axisLabels config options `x` and `y` to make an image, where
292 the color corresponds to the 2d binned statistic (the mean is the
293 default) applied to the `z` column. A summary panel is shown in the
294 upper right corner of the resultant plot. The code uses the
295 selectorActions to decide which points to plot and the
296 statisticSelector actions to determine which points to use for the
297 printed statistics.
299 Parameters
300 ----------
301 data : `pandas.core.frame.DataFrame`
302 The catalog to plot the points from. This is expected to
303 have the following columns/keys:
305 ``"detector"``
306 The integer detector id for the points.
307 ``"amplifier"``
308 The string amplifier name for the points.
309 ``"z"``
310 The numerical value that will be combined via
311 ``statistic`` to the binned value.
312 ``"x"``
313 Focal plane x position, optional.
314 ``"y"``
315 Focal plane y position, optional.
316 camera : `lsst.afw.cameraGeom.Camera`
317 The camera used to map from pixel to focal plane positions.
318 plotInfo : `dict`
319 A dictionary of information about the data being plotted with keys:
321 ``"run"``
322 The output run for the plots (`str`).
323 ``"skymap"``
324 The type of skymap used for the data (`str`).
325 ``"filter"``
326 The filter used for this data (`str`).
327 ``"tract"``
328 The tract that the data comes from (`str`).
329 ``"bands"``
330 The band(s) that the data comes from (`list` of `str`).
332 Returns
333 -------
334 fig : `matplotlib.figure.Figure`
335 The resulting figure.
336 """
337 if plotInfo is None:
338 plotInfo = {}
340 if len(data["z"]) == 0:
341 noDataFig = Figure()
342 noDataFig.text(0.3, 0.5, "No data to plot after selectors applied")
343 noDataFig = addPlotInfo(noDataFig, plotInfo)
344 return noDataFig
346 fig = plt.figure(dpi=300)
347 ax = fig.add_subplot(111)
349 detectorIds = np.unique(data["detector"])
350 focalPlane_x = np.zeros(len(data["z"]))
351 focalPlane_y = np.zeros(len(data["z"]))
353 patches = []
354 values = []
356 # Plot bounding box that will be used to set the axes below.
357 plotLimit_x = [0.0, 0.0]
358 plotLimit_y = [0.0, 0.0]
360 for detectorId in detectorIds:
361 detector = camera[detectorId]
363 # We can go stright to fp coordinates.
364 corners = [(c.getX(), c.getY()) for c in detector.getCorners(FOCAL_PLANE)]
365 corners = np.array(corners)
367 # U/V coordinates represent focal plane locations.
368 minU, minV = corners.min(axis=0)
369 maxU, maxV = corners.max(axis=0)
371 # See if the plot bounding box needs to be extended:
372 if minU < plotLimit_x[0]:
373 plotLimit_x[0] = minU
374 if minV < plotLimit_y[0]:
375 plotLimit_y[0] = minV
376 if maxU > plotLimit_x[1]:
377 plotLimit_x[1] = maxU
378 if maxV > plotLimit_y[1]:
379 plotLimit_y[1] = maxV
381 # X/Y coordinates represent detector internal coordinates.
382 # Detector extent in detector coordinates
383 minX, minY = detector.getBBox().getMin()
384 maxX, maxY = detector.getBBox().getMax()
386 if self.level.lower() == "detector":
387 detectorInd = data["detector"] == detectorId
389 # This does the appropriate statistic for this
390 # detector's data.
391 statistic, _, _ = binned_statistic_dd(
392 [focalPlane_x[detectorInd], focalPlane_y[detectorInd]],
393 data["z"][detectorInd],
394 statistic=self.statistic,
395 bins=[1, 1],
396 )
397 patches.append(Polygon(corners, closed=True))
398 values.append(statistic.ravel()[0])
399 else:
400 # It's at amplifier level. This uses the focal
401 # plane position of the corners of the detector to
402 # generate corners for the individual amplifier
403 # segments.
404 rotation = detector.getOrientation().getNQuarter() # N * 90 degrees.
405 alpha, beta = np.cos(rotation * np.pi / 2.0), np.sin(rotation * np.pi / 2.0)
407 # Calculate the rotation matrix between X/Y and U/V
408 # coordinates.
409 scaleUX = alpha * (maxU - minU) / (maxX - minX)
410 scaleVX = beta * (maxV - minV) / (maxX - minX)
411 scaleVY = alpha * (maxV - minV) / (maxY - minY)
412 scaleUY = beta * (maxU - minU) / (maxY - minY)
414 # After the rotation, some of the corners may have
415 # negative offsets. This corresponds to corners that
416 # reference the maximum edges of the box in U/V
417 # coordinates.
418 baseU = minU if rotation % 4 in (0, 1) else maxU
419 baseV = maxV if rotation % 4 in (2, 3) else minV
421 for amplifier in detector:
422 ampName = amplifier.getName()
423 detectorInd = data["detector"] == detectorId
424 ampInd = data["amplifier"] == ampName
425 ampInd &= detectorInd
427 # Determine amplifier extent in X/Y coordinates.
428 ampMinX, ampMinY = amplifier.getBBox().getMin()
429 ampMaxX, ampMaxY = amplifier.getBBox().getMax()
431 # The corners are rotated into U/V coordinates,
432 # and the appropriate offset added.
433 ampCorners = []
434 ampCorners.append(
435 (
436 scaleUX * (ampMinX - minX) + scaleUY * (ampMinY - minY) + baseU,
437 scaleVY * (ampMinY - minY) + scaleVX * (ampMinX - minX) + baseV,
438 )
439 )
440 ampCorners.append(
441 (
442 scaleUX * (ampMaxX - minX) + scaleUY * (ampMaxY - minY) + baseU,
443 scaleVY * (ampMinY - minY) + scaleVX * (ampMinX - minX) + baseV,
444 )
445 )
446 ampCorners.append(
447 (
448 scaleUX * (ampMaxX - minX) + scaleUY * (ampMaxY - minY) + baseU,
449 scaleVY * (ampMaxY - minY) + scaleVX * (ampMaxX - minX) + baseV,
450 )
451 )
452 ampCorners.append(
453 (
454 scaleUX * (ampMinX - minX) + scaleUY * (ampMinY - minY) + baseU,
455 scaleVY * (ampMaxY - minY) + scaleVX * (ampMaxX - minX) + baseV,
456 )
457 )
458 patches.append(Polygon(ampCorners, closed=True))
459 # This does the appropriate statistic for this
460 # amplifier's data.
461 if len(data["z"][ampInd]) > 0:
462 statistic, _, _ = binned_statistic_dd(
463 [focalPlane_x[ampInd], focalPlane_y[ampInd]],
464 data["z"][ampInd],
465 statistic=self.statistic,
466 bins=[1, 1],
467 )
468 values.append(statistic.ravel()[0])
469 else:
470 values.append(np.nan)
472 # Set bounding box for this figure.
473 ax.set_xlim(plotLimit_x)
474 ax.set_ylim(plotLimit_y)
476 # Do not mask values.
477 statMed, statMad, statsText = self.statsAndText(values, mask=None)
478 bbox = dict(facecolor="paleturquoise", alpha=0.5, edgecolor="none")
479 ax.text(0.8, 0.91, statsText, transform=fig.transFigure, fontsize=8, bbox=bbox)
481 patchCollection = PatchCollection(patches, alpha=0.4, edgecolor="black")
482 patchCollection.set_array(values)
483 ax.add_collection(patchCollection)
485 cax = fig.add_axes([0.87 + 0.04, 0.11, 0.04, 0.77])
486 fig.colorbar(patchCollection, cax=cax, extend="both")
487 text = cax.text(
488 0.5,
489 0.5,
490 self.zAxisLabel,
491 color="k",
492 rotation="vertical",
493 transform=cax.transAxes,
494 ha="center",
495 va="center",
496 fontsize=10,
497 )
498 text.set_path_effects([pathEffects.Stroke(linewidth=3, foreground="w"), pathEffects.Normal()])
499 cax.tick_params(labelsize=7)
501 ax.set_xlabel(self.xAxisLabel)
502 ax.set_ylabel(self.yAxisLabel)
503 ax.tick_params(axis="x", labelrotation=25)
504 ax.tick_params(labelsize=7)
506 ax.set_aspect("equal")
507 plt.draw()
509 # Add useful information to the plot
510 plt.subplots_adjust(wspace=0.0, hspace=0.0, right=0.85)
511 fig = plt.gcf()
512 fig = addPlotInfo(fig, plotInfo)
514 return fig