Coverage for python/lsst/analysis/tools/actions/plot/completenessPlot.py: 82%
126 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-23 09:49 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-23 09:49 +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/>.
23from collections.abc import Mapping
25import numpy as np
26from matplotlib.figure import Figure
28from lsst.pex.config import ChoiceField, Field
29from lsst.pex.config.configurableActions import ConfigurableActionField
30from lsst.utils.plotting import make_figure, set_rubin_plotstyle
32from ...actions.keyedData import CalcCompletenessHistogramAction
33from ...interfaces import KeyedData, KeyedDataSchema, PlotAction, Scalar
34from .plotUtils import addPlotInfo
36__all__ = ("CompletenessHist",)
39class CompletenessHist(PlotAction):
40 """Makes plots of completeness and purity."""
42 label_shift = Field[float](
43 doc="Fraction of plot width to shift completeness/purity labels by."
44 "Ignored if percentiles_style is not 'below_line'",
45 default=-0.1,
46 )
47 action = ConfigurableActionField[CalcCompletenessHistogramAction](
48 doc="Action to compute completeness/purity",
49 )
50 color_counts = Field[str](doc="Color for the line showing object counts", default="#029E73")
51 color_right = Field[str](
52 doc="Color for the line showing the correctly classified fraction", default="#949494"
53 )
54 color_wrong = Field[str](
55 doc="Color for the line showing the wrongly classified fraction", default="#DE8F05"
56 )
57 legendLocation = Field[str](doc="Legend position within main plot", default="lower left")
58 mag_ref_label = Field[str](
59 doc="Label for the completeness x axis.", default="{band}-band {name_flux} Magnitude"
60 )
61 mag_target_label = Field[str](
62 doc="Label for the purity x axis.", default="{band}-band {name_flux} Magnitude"
63 )
64 object_label = Field[str](doc="Label for measured objects", default="Object")
65 reference_label = Field[str](doc="Label for reference objects", default="Reference")
66 percentiles_style = ChoiceField[str](
67 doc="Style and locations for completeness threshold percentile labels",
68 allowed={
69 "above_plot": "Labels in a semicolon-separated list above plot",
70 "below_line": "Labels under the horizontal part of each line",
71 },
72 default="below_line",
73 )
74 publicationStyle = Field[bool](doc="Make a publication-style of plot", default=False)
75 show_purity = Field[bool](doc="Whether to include a purity plot below completness", default=True)
77 def getInputSchema(self) -> KeyedDataSchema:
78 yield from self.action.getOutputSchema()
80 def __call__(self, data: KeyedData, **kwargs) -> Mapping[str, Figure] | Figure:
81 self._validateInput(data, **kwargs)
82 return self.makePlot(data, **kwargs)
84 def _validateInput(self, data: KeyedData, **kwargs) -> None:
85 """NOTE currently can only check that something is not a Scalar, not
86 check that the data is consistent with Vector
87 """
88 needed = self.getFormattedInputSchema(**kwargs)
89 if remainder := {key.format(**kwargs) for key, _ in needed} - { 89 ↛ 92line 89 didn't jump to line 92 because the condition on line 89 was never true
90 key.format(**kwargs) for key in data.keys()
91 }:
92 raise ValueError(f"Task needs keys {remainder} but they were not found in input")
93 for name, typ in needed: 93 ↛ 94line 93 didn't jump to line 94 because the loop on line 93 never started
94 isScalar = issubclass((colType := type(data[name.format(**kwargs)])), Scalar)
95 if isScalar and typ != Scalar:
96 raise ValueError(f"Data keyed by {name} has type {colType} but action requires type {typ}")
98 def makePlot(self, data, plotInfo, **kwargs):
99 """Makes a plot showing the fraction of injected sources recovered by
100 input magnitude.
102 The behavior of this plot is controlled by `self.action`. This action
103 must be added to a struct (usually self.process.calculateActions) by
104 the tool that calls this plot.
106 Parameters
107 ----------
108 data : `KeyedData`
109 All the data
110 plotInfo : `dict`
111 A dictionary of information about the data being plotted with keys:
112 ``camera``
113 The camera used to take the data (`lsst.afw.cameraGeom.Camera`)
114 ``"cameraName"``
115 The name of camera used to take the data (`str`).
116 ``"filter"``
117 The filter used for this data (`str`).
118 ``"ccdKey"``
119 The ccd/dectector key associated with this camera (`str`).
120 ``"visit"``
121 The visit of the data; only included if the data is from a
122 single epoch dataset (`str`).
123 ``"patch"``
124 The patch that the data is from; only included if the data is
125 from a coadd dataset (`str`).
126 ``"tract"``
127 The tract that the data comes from (`str`).
128 ``"photoCalibDataset"``
129 The dataset used for the calibration, e.g. "jointcal" or "fgcm"
130 (`str`).
131 ``"skyWcsDataset"``
132 The sky Wcs dataset used (`str`).
133 ``"rerun"``
134 The rerun the data is stored in (`str`).
136 Returns
137 ------
138 ``fig``
139 The figure to be saved (`matplotlib.figure.Figure`).
141 Notes
142 -----
143 The behaviour of this plot is largel
145 Examples
146 --------
147 An example of the plot produced from this code from tract 3828 of the
148 DC2 simulations is here:
150 .. image:: /_static/analysis_tools/completenessPlotExample.png
152 """
154 # Make plot showing the fraction recovered in magnitude bins
155 set_rubin_plotstyle()
156 n_sub = 1 + self.show_purity
157 fig = make_figure(dpi=300, figsize=(8, 4 * n_sub))
158 if self.show_purity: 158 ↛ 161line 158 didn't jump to line 161 because the condition on line 158 was always true
159 axes = (fig.add_subplot(2, 1, 1), fig.add_subplot(2, 1, 2))
160 else:
161 axes = [fig.add_axes([0.1, 0.15, 0.8, 0.75])]
162 max_left = 1.05
164 band = kwargs.get("band")
165 action_hist = self.action.action
166 names = {}
167 for name in (
168 "range_minimum",
169 "range_maximum",
170 "count",
171 "count_ref",
172 "count_target",
173 "completeness",
174 "completeness_bad_match",
175 "completeness_good_match",
176 "purity",
177 "purity_bad_match",
178 "purity_good_match",
179 ):
180 key = getattr(action_hist, f"name_{name}")
181 if band is not None: 181 ↛ 183line 181 didn't jump to line 183 because the condition on line 181 was always true
182 key = key.format(band=band)
183 names[name] = key
185 ranges_min = data[names["range_minimum"]]
186 ranges_max = data[names["range_maximum"]]
187 x = (ranges_max + ranges_min) / 2.0
188 interval = self.action.bins.mag_width / 1000.0
189 x_err = interval / 2.0
191 counts_all = data[names["count"]]
193 if self.publicationStyle: 193 ↛ 194line 193 didn't jump to line 194 because the condition on line 193 was never true
194 lineTuples = (
195 (data[names["completeness"]], False, "k", "Completeness"),
196 (data[names["completeness_bad_match"]], False, self.color_wrong, "Incorrect Class"),
197 )
198 else:
199 lineTuples = (
200 (data[names["completeness"]], True, "k", "Completeness"),
201 (data[names["completeness_bad_match"]], False, self.color_wrong, "Incorrect class"),
202 (data[names["completeness_good_match"]], False, self.color_right, "Correct Class"),
203 )
205 # If the user left the name_flux template in the label value
206 # but never formatted it, apply a sensible generic default
207 mag_labels = {
208 "ref": (self.mag_ref_label, "Reference"),
209 "target": (self.mag_target_label, "Measured"),
210 }
211 for key, (label, name_flux_default) in mag_labels.items():
212 mag_labels[key] = label.format(band=band, name_flux=name_flux_default)
214 plots = {
215 "Completeness": {
216 "count_type": self.reference_label,
217 "counts": data[names["count_ref"]],
218 "lines": lineTuples,
219 "xlabel": mag_labels["ref"],
220 },
221 }
222 if self.show_purity: 222 ↛ 236line 222 didn't jump to line 236 because the condition on line 222 was always true
223 plots["Purity"] = {
224 "count_type": self.object_label,
225 "counts": data[names["count_target"]],
226 "lines": (
227 (data[names["purity"]], True, "k", "Purity"),
228 (data[names["purity_bad_match"]], False, self.color_wrong, "Incorrect class"),
229 (data[names["purity_good_match"]], False, self.color_right, "Correct class"),
230 ),
231 "xlabel": mag_labels["target"],
232 }
234 # idx == 0 should be completeness; update this if that assumption
235 # is changed
236 for idx, (ylabel, plot_data) in enumerate(plots.items()):
237 axes_idx = axes[idx]
238 xlim = (ranges_min[0], ranges_max[-1])
239 axes_idx.set(
240 xlabel=plot_data["xlabel"],
241 ylabel=ylabel,
242 xlim=xlim,
243 ylim=(0, max_left),
244 xticks=np.arange(round(xlim[0]), round(xlim[1])),
245 yticks=np.linspace(0, 1, 11),
246 )
247 if not self.publicationStyle: 247 ↛ 249line 247 didn't jump to line 249 because the condition on line 247 was always true
248 axes_idx.grid(color="lightgrey", ls="-")
249 ax_right = axes_idx.twinx()
250 ax_right.set_ylabel(f"{plot_data['count_type']} Counts/Magnitude", color="k")
251 ax_right.set_yscale("log")
253 for y, do_err, color, label in plot_data["lines"]:
254 axes_idx.errorbar(
255 x=x,
256 y=y,
257 xerr=x_err if do_err else None,
258 yerr=1.0 / np.sqrt(counts_all + 1) if do_err else None,
259 capsize=0,
260 color=color,
261 label=label,
262 )
263 y = plot_data["counts"] / interval
264 # It should be unusual for np.max(y) to be zero; nonetheless...
265 lines_left, labels_left = axes_idx.get_legend_handles_labels()
266 ax_right.step(
267 [x[0] - interval] + list(x) + [x[-1] + interval],
268 [0] + list(y) + [0],
269 where="mid",
270 color=self.color_counts,
271 label="Counts",
272 )
274 # Force the inputs counts histogram to the back
275 ax_right.zorder = 1
276 axes_idx.zorder = 2
277 axes_idx.patch.set_visible(False)
279 ax_right.set_ylim(0.999, 10 ** (max_left * np.log10(max(np.nanmax(y), 2))))
280 ax_right.tick_params(axis="y", labelcolor=self.color_counts)
281 lines_right, labels_right = ax_right.get_legend_handles_labels()
283 # Using fig for legend
284 (axes_idx if self.show_purity else fig).legend(
285 lines_left + lines_right,
286 labels_left + labels_right,
287 loc=self.legendLocation,
288 ncol=3 if self.publicationStyle else 2,
289 )
291 if idx == 0:
292 if not self.publicationStyle: 292 ↛ 295line 292 didn't jump to line 295 because the condition on line 292 was always true
293 percentiles = self.action.config_metrics.completeness_percentiles
294 else:
295 percentiles = [90.0, 50.0]
296 if percentiles: 296 ↛ 236line 296 didn't jump to line 236 because the condition on line 296 was always true
297 above_plot = self.percentiles_style == "above_plot"
298 below_line = self.percentiles_style == "below_line"
299 kwargs_lines = dict(color="dimgrey", ls=":")
300 xlims = axes_idx.get_xlim()
301 if above_plot: 301 ↛ 302line 301 didn't jump to line 302 because the condition on line 301 was never true
302 texts = []
303 elif below_line: 303 ↛ 306line 303 didn't jump to line 306 because the condition on line 303 was always true
304 offset = self.label_shift * (xlims[1] - xlims[0])
305 else:
306 raise RuntimeError(f"Unimplemented {self.percentiles_style=}")
307 for pct in percentiles:
308 name_pct = self.action.action.name_mag_completeness(
309 self.action.getPercentileName(pct),
310 )
311 if band is not None: 311 ↛ 313line 311 didn't jump to line 313 because the condition on line 311 was always true
312 name_pct = name_pct.format(band=band)
313 mag_completeness = data.get(name_pct, None)
314 pct /= 100.0
315 if mag_completeness is not None and np.isfinite(mag_completeness): 315 ↛ 307line 315 didn't jump to line 307 because the condition on line 315 was always true
316 axes_idx.plot([xlims[0], mag_completeness], [pct, pct], **kwargs_lines)
317 axes_idx.plot([mag_completeness, mag_completeness], [0, pct], **kwargs_lines)
318 text = f"{pct*100:.2g}%: {mag_completeness:.2f}"
319 if above_plot: 319 ↛ 320line 319 didn't jump to line 320 because the condition on line 319 was never true
320 texts.append(text)
321 elif below_line: 321 ↛ 307line 321 didn't jump to line 307 because the condition on line 321 was always true
322 axes_idx.text(
323 mag_completeness + offset,
324 pct - 0.02,
325 text,
326 ha="right",
327 va="top",
328 fontsize=12,
329 )
330 if above_plot: 330 ↛ 331line 330 didn't jump to line 331 because the condition on line 330 was never true
331 texts = f"Thresholds: {'; '.join(texts)}"
332 axes_idx.text(xlims[0], max_left, texts, ha="left", va="bottom")
334 # Add useful information to the plot
335 if not self.publicationStyle: 335 ↛ 337line 335 didn't jump to line 337 because the condition on line 335 was always true
336 addPlotInfo(fig, plotInfo)
337 if self.show_purity: 337 ↛ 340line 337 didn't jump to line 340 because the condition on line 337 was always true
338 fig.tight_layout()
339 fig.subplots_adjust(top=0.90)
340 return fig