Coverage for python/lsst/faro/measurement/MatchedCatalogMeasurementTasks.py: 47%
161 statements
« prev ^ index » next coverage.py v6.4.2, created at 2022-08-05 01:29 -0700
« prev ^ index » next coverage.py v6.4.2, created at 2022-08-05 01:29 -0700
1# This file is part of faro.
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/>.
22import astropy.units as u
23import numpy as np
24from lsst.pipe.base import Struct, Task
25from lsst.pex.config import ChoiceField, Config, Field, ListField
26from lsst.verify import Measurement, Datum
27from lsst.faro.utils.filtermatches import filterMatches
28from lsst.faro.utils.separations import (
29 calcRmsDistances,
30 calcRmsDistancesVsRef,
31 astromResiduals,
32)
33from lsst.faro.utils.phot_repeat import photRepeat
36__all__ = (
37 "PA1Config",
38 "PA1Task",
39 "PF1Config",
40 "PF1Task",
41 "AMxConfig",
42 "AMxTask",
43 "ADxTask",
44 "AFxTask",
45 "AB1Config",
46 "AB1Task",
47 "ModelPhotRepTask",
48)
51filter_dict = {
52 "u": 1,
53 "g": 2,
54 "r": 3,
55 "i": 4,
56 "z": 5,
57 "y": 6,
58 "HSC-U": 1,
59 "HSC-G": 2,
60 "HSC-R": 3,
61 "HSC-I": 4,
62 "HSC-Z": 5,
63 "HSC-Y": 6,
64}
67class PA1Config(Config):
68 """Config fields for the PA1 photometric repeatability metric.
69 """
71 brightSnrMin = Field(
72 doc="Minimum median SNR for a source to be considered bright.",
73 dtype=float,
74 default=200,
75 )
76 brightSnrMax = Field(
77 doc="Maximum median SNR for a source to be considered bright.",
78 dtype=float,
79 default=np.Inf,
80 )
81 nMinPhotRepeat = Field(
82 doc="Minimum number of objects required for photometric repeatability.",
83 dtype=int,
84 default=50,
85 )
86 writeExtras = Field(
87 doc="Write out the magnitude residuals and rms values for debugging.",
88 dtype=bool,
89 default=False,
90 )
93class PA1Task(Task):
94 """A Task that computes the PA1 photometric repeatability metric from an
95 input set of multiple visits of the same field.
97 Notes
98 -----
99 The intended usage is to retarget the run method of
100 `lsst.faro.measurement.TractMatchedMeasurementTask` to PA1Task.
101 This metric is calculated on a set of matched visits, and aggregated at the tract level.
102 """
104 ConfigClass = PA1Config
105 _DefaultName = "PA1Task"
107 def __init__(self, config: PA1Config, *args, **kwargs):
108 super().__init__(*args, config=config, **kwargs)
110 def run(self, metricName, matchedCatalog):
111 """Calculate the photometric repeatability.
113 Parameters
114 ----------
115 matchedCatalog : `lsst.afw.table.base.Catalog`
116 `~lsst.afw.table.base.Catalog` object as created by
117 `~lsst.afw.table.multiMatch` matching of sources from multiple visits.
118 metricName : `str`
119 The name of the metric.
121 Returns
122 -------
123 measurement : `lsst.verify.Measurement`
124 Measurement of the repeatability and its associated metadata.
125 """
126 self.log.info("Measuring %s", metricName)
128 pa1 = photRepeat(
129 matchedCatalog,
130 nMinPhotRepeat=self.config.nMinPhotRepeat,
131 snrMax=self.config.brightSnrMax,
132 snrMin=self.config.brightSnrMin,
133 doFlags=False, isPrimary=False,
134 )
136 if "magMean" in pa1.keys():
137 if self.config.writeExtras:
138 extras = {
139 "rms": Datum(
140 pa1["rms"],
141 label="RMS",
142 description="Photometric repeatability rms for each star.",
143 ),
144 "count": Datum(
145 pa1["count"] * u.count,
146 label="count",
147 description="Number of detections used to calculate repeatability.",
148 ),
149 "mean_mag": Datum(
150 pa1["magMean"],
151 label="mean_mag",
152 description="Mean magnitude of each star.",
153 ),
154 }
155 return Struct(
156 measurement=Measurement("PA1", pa1["repeatability"], extras=extras)
157 )
158 else:
159 return Struct(measurement=Measurement("PA1", pa1["repeatability"]))
160 else:
161 return Struct(measurement=Measurement("PA1", np.nan * u.mmag))
164class PF1Config(Config):
165 brightSnrMin = Field(
166 doc="Minimum median SNR for a source to be considered bright.",
167 dtype=float,
168 default=200,
169 )
170 brightSnrMax = Field(
171 doc="Maximum median SNR for a source to be considered bright.",
172 dtype=float,
173 default=np.Inf,
174 )
175 nMinPhotRepeat = Field(
176 doc="Minimum number of objects required for photometric repeatability.",
177 dtype=int,
178 default=50,
179 )
180 # The defaults for threshPA2 correspond to the SRD "design" thresholds.
181 threshPA2 = Field(
182 doc="Threshold in mmag for PF1 calculation.", dtype=float, default=15.0
183 )
186class PF1Task(Task):
187 """A Task that computes PF1, the percentage of photometric repeatability measurements
188 that deviate by more than PA2 mmag from the mean.
190 Notes
191 -----
192 The intended usage is to retarget the run method of
193 `lsst.faro.measurement.TractMatchedMeasurementTask` to PF1Task. This Task uses the
194 same set of photometric residuals that are calculated for the PA1 metric.
195 This metric is calculated on a set of matched visits, and aggregated at the tract level.
196 """
198 ConfigClass = PF1Config
199 _DefaultName = "PF1Task"
201 def __init__(self, config: PF1Config, *args, **kwargs):
202 super().__init__(*args, config=config, **kwargs)
204 def run(self, metricName, matchedCatalog):
205 """Calculate the percentage of outliers in the photometric repeatability values.
207 Parameters
208 ----------
209 matchedCatalog : `lsst.afw.table.base.Catalog`
210 `~lsst.afw.table.base.Catalog` object as created by
211 `~lsst.afw.table.multiMatch` matching of sources from multiple visits.
212 metricName : `str`
213 The name of the metric.
215 Returns
216 -------
217 measurement : `lsst.verify.Measurement`
218 Measurement of the percentage of repeatability outliers, and associated metadata.
219 """
220 self.log.info("Measuring %s", metricName)
221 pa2_thresh = self.config.threshPA2 * u.mmag
223 pf1 = photRepeat(
224 matchedCatalog,
225 nMinPhotRepeat=self.config.nMinPhotRepeat,
226 snrMax=self.config.brightSnrMax,
227 snrMin=self.config.brightSnrMin,
228 doFlags=False, isPrimary=False,
229 )
231 if "magResid" in pf1.keys():
232 # Previously, validate_drp used the first random sample from PA1 measurement
233 # Now, use all of them.
234 # Keep only stars with > 2 observations:
235 okrms = pf1["count"] > 2
236 magResid0 = pf1["magResid"]
237 magResid = np.concatenate(magResid0[okrms])
239 percentileAtPA2 = (
240 100 * np.mean(np.abs(magResid.value) > pa2_thresh.value) * u.percent
241 )
243 return Struct(measurement=Measurement("PF1", percentileAtPA2))
244 else:
245 return Struct(measurement=Measurement("PF1", np.nan * u.percent))
248def isSorted(a):
249 return all(a[i] <= a[i + 1] for i in range(len(a) - 1))
252def bins(window, n):
253 delta = window / n
254 return [i * delta for i in range(n + 1)]
257class AMxConfig(Config):
258 annulus_r = Field(
259 doc="Radial distance of the annulus in arcmin (5, 20, or 200 for AM1, AM2, AM3)",
260 dtype=float,
261 default=5.0,
262 )
263 width = Field(doc="Width of annulus in arcmin", dtype=float, default=2.0)
264 bright_mag_cut = Field(
265 doc="Bright limit of catalog entries to include", dtype=float, default=17.0
266 )
267 faint_mag_cut = Field(
268 doc="Faint limit of catalog entries to include", dtype=float, default=21.5
269 )
270 # The defaults for threshADx and threshAFx correspond to the SRD "design" thresholds.
271 threshAD = Field(
272 doc="Threshold in mas for AFx calculation.", dtype=float, default=20.0
273 )
274 threshAF = Field(
275 doc="Percentile of differences that can vary by more than threshAD.",
276 dtype=float,
277 default=10.0,
278 )
279 bins = ListField(
280 doc="Bins for histogram.",
281 dtype=float,
282 minLength=2,
283 maxLength=1500,
284 listCheck=isSorted,
285 default=bins(30, 200),
286 )
289class AMxTask(Task):
290 ConfigClass = AMxConfig
291 _DefaultName = "AMxTask"
293 def run(self, metricName, matchedCatalog):
294 self.log.info("Measuring %s", metricName)
296 filteredCat = filterMatches(matchedCatalog)
298 magRange = (
299 np.array([self.config.bright_mag_cut, self.config.faint_mag_cut]) * u.mag
300 )
301 D = self.config.annulus_r * u.arcmin
302 width = self.config.width * u.arcmin
303 annulus = D + (width / 2) * np.array([-1, +1])
305 rmsDistances = calcRmsDistances(filteredCat, annulus, magRange=magRange)
307 values, bins = np.histogram(
308 rmsDistances.to(u.marcsec), bins=self.config.bins * u.marcsec
309 )
310 extras = {
311 "bins": Datum(bins, label="binvalues", description="bins"),
312 "values": Datum(
313 values * u.count, label="counts", description="icounts in bins"
314 ),
315 }
317 if len(rmsDistances) == 0:
318 return Struct(
319 measurement=Measurement(metricName, np.nan * u.marcsec, extras=extras)
320 )
322 return Struct(
323 measurement=Measurement(
324 metricName, np.median(rmsDistances.to(u.marcsec)), extras=extras
325 )
326 )
329class ADxTask(Task):
330 ConfigClass = AMxConfig
331 _DefaultName = "ADxTask"
333 def run(self, metricName, matchedCatalog):
334 self.log.info("Measuring %s", metricName)
336 sepDistances = astromResiduals(
337 matchedCatalog,
338 self.config.bright_mag_cut,
339 self.config.faint_mag_cut,
340 self.config.annulus_r,
341 self.config.width,
342 )
344 afThresh = self.config.threshAF * u.percent
345 afPercentile = 100.0 * u.percent - afThresh
347 if len(sepDistances) <= 1:
348 return Struct(measurement=Measurement(metricName, np.nan * u.marcsec))
349 else:
350 # absolute value of the difference between each astrometric rms
351 # and the median astrometric RMS
352 # absRmsDiffs = np.abs(rmsDistances - np.median(rmsDistances)).to(u.marcsec)
353 absDiffsMarcsec = (sepDistances - np.median(sepDistances)).to(u.marcsec)
354 return Struct(
355 measurement=Measurement(
356 metricName,
357 np.percentile(absDiffsMarcsec.value, afPercentile.value)
358 * u.marcsec,
359 )
360 )
363class AFxTask(Task):
364 ConfigClass = AMxConfig
365 _DefaultName = "AFxTask"
367 def run(self, metricName, matchedCatalog):
368 self.log.info("Measuring %s", metricName)
370 sepDistances = astromResiduals(
371 matchedCatalog,
372 self.config.bright_mag_cut,
373 self.config.faint_mag_cut,
374 self.config.annulus_r,
375 self.config.width,
376 )
378 adxThresh = self.config.threshAD * u.marcsec
380 if len(sepDistances) <= 1:
381 return Struct(measurement=Measurement(metricName, np.nan * u.percent))
382 else:
383 # absolute value of the difference between each astrometric rms
384 # and the median astrometric RMS
385 # absRmsDiffs = np.abs(rmsDistances - np.median(rmsDistances)).to(u.marcsec)
386 absDiffsMarcsec = (sepDistances - np.median(sepDistances)).to(u.marcsec)
387 percentileAtADx = (
388 100
389 * np.mean(np.abs(absDiffsMarcsec.value) > adxThresh.value)
390 * u.percent
391 )
392 return Struct(measurement=Measurement(metricName, percentileAtADx))
395class AB1Config(Config):
396 bright_mag_cut = Field(
397 doc="Bright limit of catalog entries to include", dtype=float, default=17.0
398 )
399 faint_mag_cut = Field(
400 doc="Faint limit of catalog entries to include", dtype=float, default=21.5
401 )
402 ref_filter = Field(
403 doc="String representing the filter to use as reference", dtype=str, default="r"
404 )
407class AB1Task(Task):
408 ConfigClass = AB1Config
409 _DefaultName = "AB1Task"
411 def run(self, metricName, matchedCatalogMulti, in_id, out_id):
412 self.log.info("Measuring %s", metricName)
414 if self.config.ref_filter not in filter_dict:
415 raise Exception("Reference filter supplied for AB1 not in dictionary.")
417 filteredCat = filterMatches(matchedCatalogMulti)
418 rmsDistancesAll = []
420 if len(filteredCat) > 0:
422 filtnum = filter_dict[self.config.ref_filter]
424 refVisits = set()
425 for id in filteredCat.ids:
426 grptmp = filteredCat[id]
427 filtmch = grptmp["filt"] == filtnum
428 if len(filtmch) > 0:
429 refVisits.update(set(grptmp[filtmch]["visit"]))
431 refVisits = list(refVisits)
433 magRange = (
434 np.array([self.config.bright_mag_cut, self.config.faint_mag_cut])
435 * u.mag
436 )
437 for rv in refVisits:
438 rmsDistances = calcRmsDistancesVsRef(
439 filteredCat, rv, magRange=magRange, band=filter_dict[out_id["band"]]
440 )
441 finiteEntries = np.where(np.isfinite(rmsDistances))[0]
442 if len(finiteEntries) > 0:
443 rmsDistancesAll.append(rmsDistances[finiteEntries])
445 if len(rmsDistancesAll) == 0:
446 return Struct(measurement=Measurement(metricName, np.nan * u.marcsec))
447 else:
448 rmsDistancesAll = np.concatenate(rmsDistancesAll)
449 return Struct(
450 measurement=Measurement(metricName, np.mean(rmsDistancesAll))
451 )
453 else:
454 return Struct(measurement=Measurement(metricName, np.nan * u.marcsec))
457class ModelPhotRepConfig(Config):
458 """Config fields for the *ModelPhotRep photometric repeatability metrics.
459 """
461 index = ChoiceField(
462 doc="Index of the metric definition",
463 dtype=int,
464 allowed={x: f"Nth (N={x}) lowest S/N bin" for x in range(1, 5)},
465 optional=False,
466 )
467 magName = Field(
468 doc="Name of the magnitude column", dtype=str, default="slot_ModelFlux_mag"
469 )
470 nMinPhotRepeat = Field(
471 doc="Minimum number of objects required for photometric repeatability.",
472 dtype=int,
473 default=50,
474 )
475 prefix = Field(doc="Prefix of the metric name", dtype=str, default="model")
476 selectExtended = Field(doc="Whether to select extended sources", dtype=bool)
477 selectSnrMin = Field(
478 doc="Minimum median SNR for a source to be selected.", dtype=float
479 )
480 selectSnrMax = Field(
481 doc="Maximum median SNR for a source to be selected.", dtype=float
482 )
483 writeExtras = Field(
484 doc="Write out the magnitude residuals and rms values for debugging.",
485 dtype=bool,
486 default=False,
487 )
490class ModelPhotRepTask(Task):
491 """A Task that computes a *ModelPhotRep* photometric repeatability metric
492 from an input set of multiple visits of the same field.
494 Notes
495 -----
496 The intended usage is to retarget the run method of
497 `lsst.faro.measurement.TractMatchedMeasurementTask` to ModelPhotRepTask.
498 This metric is calculated on a set of matched visits, and aggregated at the tract level.
499 """
501 ConfigClass = ModelPhotRepConfig
502 _DefaultName = "ModelPhotRepTask"
504 def __init__(self, config: ModelPhotRepConfig, *args, **kwargs):
505 super().__init__(*args, config=config, **kwargs)
507 def run(self, metricName, matchedCatalog):
508 """Calculate the photometric repeatability.
510 Parameters
511 ----------
512 matchedCatalog : `lsst.afw.table.base.Catalog`
513 `~lsst.afw.table.base.Catalog` object as created by
514 `~lsst.afw.table.multiMatch` matching of sources from multiple visits.
515 metricName : `str`
516 The name of the metric.
518 Returns
519 -------
520 measurement : `lsst.verify.Measurement`
521 Measurement of the repeatability and its associated metadata.
522 """
523 self.log.info("Measuring %s", metricName)
525 meas = photRepeat(
526 matchedCatalog,
527 nMinPhotRepeat=self.config.nMinPhotRepeat,
528 snrMax=self.config.selectSnrMax,
529 snrMin=self.config.selectSnrMin,
530 magName=self.config.magName,
531 extended=self.config.selectExtended,
532 doFlags=False, isPrimary=False,
533 )
535 name_type = "Gal" if self.config.selectExtended else "Star"
536 name_meas = f"{self.config.prefix}PhotRep{name_type}{self.config.index}"
538 if "magMean" in meas.keys():
539 if self.config.writeExtras:
540 extras = {
541 "rms": Datum(
542 meas["rms"],
543 label="RMS",
544 description="Photometric repeatability rms for each star.",
545 ),
546 "count": Datum(
547 meas["count"] * u.count,
548 label="count",
549 description="Number of detections used to calculate repeatability.",
550 ),
551 "mean_mag": Datum(
552 meas["magMean"],
553 label="mean_mag",
554 description="Mean magnitude of each star.",
555 ),
556 }
557 return Struct(
558 measurement=Measurement(
559 name_meas, meas["repeatability"], extras=extras
560 )
561 )
562 else:
563 return Struct(measurement=Measurement(name_meas, meas["repeatability"]))
564 else:
565 return Struct(measurement=Measurement(name_meas, np.nan * u.mmag))