Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1import astropy.units as u 

2import numpy as np 

3from lsst.pipe.base import Struct, Task 

4from lsst.pex.config import Config, Field, ListField 

5from lsst.verify import Measurement, ThresholdSpecification, Datum 

6from lsst.faro.utils.filtermatches import filterMatches 

7from lsst.faro.utils.separations import (calcRmsDistances, calcRmsDistancesVsRef, 

8 astromResiduals) 

9from lsst.faro.utils.phot_repeat import photRepeat 

10from lsst.faro.utils.tex import (correlation_function_ellipticity_from_matches, 

11 select_bin_from_corr) 

12 

13__all__ = ("PA1TaskConfig", "PA1Task", "PF1TaskConfig", "PF1Task", "TExTaskConfig", 

14 "TExTask", "AMxTaskConfig", "AMxTask", "ADxTask", "AFxTask", "AB1TaskConfig", "AB1Task") 

15 

16 

17filter_dict = {'u': 1, 'g': 2, 'r': 3, 'i': 4, 'z': 5, 'y': 6, 

18 'HSC-U': 1, 'HSC-G': 2, 'HSC-R': 3, 'HSC-I': 4, 'HSC-Z': 5, 'HSC-Y': 6} 

19 

20 

21class PA1TaskConfig(Config): 

22 """Config fields for the PA1 photometric repeatability metric. 

23 """ 

24 brightSnrMin = Field(doc="Minimum median SNR for a source to be considered bright.", 

25 dtype=float, default=200) 

26 brightSnrMax = Field(doc="Maximum median SNR for a source to be considered bright.", 

27 dtype=float, default=np.Inf) 

28 nMinPhotRepeat = Field(doc="Minimum number of objects required for photometric repeatability.", 

29 dtype=int, default=50) 

30 writeExtras = Field(doc="Write out the magnitude residuals and rms values for debugging.", 

31 dtype=bool, default=False) 

32 

33 

34class PA1Task(Task): 

35 """A Task that computes the PA1 photometric repeatability metric from an 

36 input set of multiple visits of the same field. 

37 

38 Notes 

39 ----- 

40 The intended usage is to retarget the run method of 

41 `lsst.faro.measurement.TractMatchedMeasurementTask` to PA1Task. 

42 This metric is calculated on a set of matched visits, and aggregated at the tract level. 

43 """ 

44 

45 ConfigClass = PA1TaskConfig 

46 _DefaultName = "PA1Task" 

47 

48 def __init__(self, config: PA1TaskConfig, *args, **kwargs): 

49 super().__init__(*args, config=config, **kwargs) 

50 self.brightSnrMin = self.config.brightSnrMin 

51 self.brightSnrMax = self.config.brightSnrMax 

52 self.nMinPhotRepeat = self.config.nMinPhotRepeat 

53 self.writeExtras = self.config.writeExtras 

54 

55 def run(self, matchedCatalog, metricName): 

56 """Calculate the photometric repeatability. 

57 

58 Parameters 

59 ---------- 

60 matchedCatalog : `lsst.afw.table.base.Catalog` 

61 `~lsst.afw.table.base.Catalog` object as created by 

62 `~lsst.afw.table.multiMatch` matching of sources from multiple visits. 

63 metricName : `str` 

64 The name of the metric. 

65 

66 Returns 

67 ------- 

68 measurement : `lsst.verify.Measurement` 

69 Measurement of the repeatability and its associated metadata. 

70 """ 

71 self.log.info(f"Measuring {metricName}") 

72 

73 pa1 = photRepeat(matchedCatalog, nMinPhotRepeat=self.nMinPhotRepeat, 

74 snrMax=self.brightSnrMax, snrMin=self.brightSnrMin) 

75 

76 if 'magMean' in pa1.keys(): 

77 if self.writeExtras: 

78 extras = {} 

79 extras['rms'] = Datum(pa1['rms'], label='RMS', 

80 description='Photometric repeatability rms for each star.') 

81 extras['count'] = Datum(pa1['count']*u.count, label='count', 

82 description='Number of detections used to calculate ' 

83 'repeatability.') 

84 extras['mean_mag'] = Datum(pa1['magMean'], label='mean_mag', 

85 description='Mean magnitude of each star.') 

86 return Struct(measurement=Measurement("PA1", pa1['repeatability'], extras=extras)) 

87 else: 

88 return Struct(measurement=Measurement("PA1", pa1['repeatability'])) 

89 else: 

90 return Struct(measurement=Measurement("PA1", np.nan*u.mmag)) 

91 

92 

93class PF1TaskConfig(Config): 

94 brightSnrMin = Field(doc="Minimum median SNR for a source to be considered bright.", 

95 dtype=float, default=200) 

96 brightSnrMax = Field(doc="Maximum median SNR for a source to be considered bright.", 

97 dtype=float, default=np.Inf) 

98 nMinPhotRepeat = Field(doc="Minimum number of objects required for photometric repeatability.", 

99 dtype=int, default=50) 

100 # The defaults for threshPA2 and threshPF1 correspond to the SRD "design" thresholds. 

101 threshPA2 = Field(doc="Threshold in mmag for PF1 calculation.", dtype=float, default=15.0) 

102 

103 

104class PF1Task(Task): 

105 """A Task that computes PF1, the percentage of photometric repeatability measurements 

106 that deviate by more than PA2 mmag from the mean. 

107 

108 Notes 

109 ----- 

110 The intended usage is to retarget the run method of 

111 `lsst.faro.measurement.TractMatchedMeasurementTask` to PF1Task. This Task uses the 

112 same set of photometric residuals that are calculated for the PA1 metric. 

113 This metric is calculated on a set of matched visits, and aggregated at the tract level. 

114 """ 

115 ConfigClass = PF1TaskConfig 

116 _DefaultName = "PF1Task" 

117 

118 def __init__(self, config: PF1TaskConfig, *args, **kwargs): 

119 super().__init__(*args, config=config, **kwargs) 

120 self.brightSnrMin = self.config.brightSnrMin 

121 self.brightSnrMax = self.config.brightSnrMax 

122 self.nMinPhotRepeat = self.config.nMinPhotRepeat 

123 self.threshPA2 = self.config.threshPA2 

124 

125 def run(self, matchedCatalog, metricName): 

126 """Calculate the percentage of outliers in the photometric repeatability values. 

127 

128 Parameters 

129 ---------- 

130 matchedCatalog : `lsst.afw.table.base.Catalog` 

131 `~lsst.afw.table.base.Catalog` object as created by 

132 `~lsst.afw.table.multiMatch` matching of sources from multiple visits. 

133 metricName : `str` 

134 The name of the metric. 

135 

136 Returns 

137 ------- 

138 measurement : `lsst.verify.Measurement` 

139 Measurement of the percentage of repeatability outliers, and associated metadata. 

140 """ 

141 self.log.info(f"Measuring {metricName}") 

142 pa2_thresh = self.threshPA2 * u.mmag 

143 

144 pf1 = photRepeat(matchedCatalog, nMinPhotRepeat=self.nMinPhotRepeat, 

145 snrMax=self.brightSnrMax, snrMin=self.brightSnrMin) 

146 

147 if 'magResid' in pf1.keys(): 

148 # Previously, validate_drp used the first random sample from PA1 measurement 

149 # Now, use all of them. 

150 # Keep only stars with > 2 observations: 

151 okrms = (pf1['count'] > 2) 

152 magResid0 = pf1['magResid'] 

153 magResid = np.concatenate(magResid0[okrms]) 

154 

155 percentileAtPA2 = 100 * np.mean(np.abs(magResid.value) > pa2_thresh.value) * u.percent 

156 

157 return Struct(measurement=Measurement("PF1", percentileAtPA2)) 

158 else: 

159 return Struct(measurement=Measurement("PF1", np.nan*u.percent)) 

160 

161 

162class TExTaskConfig(Config): 

163 annulus_r = Field(doc="Radial size of the annulus in arcmin", 

164 dtype=float, default=1.) 

165 comparison_operator = Field(doc="String representation of the operator to use in comparisons", 

166 dtype=str, default="<=") 

167 

168 

169class TExTask(Task): 

170 ConfigClass = TExTaskConfig 

171 _DefaultName = "TExTask" 

172 

173 def run(self, matchedCatalog, metric_name): 

174 self.log.info(f"Measuring {metric_name}") 

175 

176 D = self.config.annulus_r * u.arcmin 

177 filteredCat = filterMatches(matchedCatalog) 

178 nMinTEx = 50 

179 if filteredCat.count <= nMinTEx: 

180 return Struct(measurement=Measurement(metric_name, np.nan*u.Unit(''))) 

181 

182 radius, xip, xip_err = correlation_function_ellipticity_from_matches(filteredCat) 

183 operator = ThresholdSpecification.convert_operator_str(self.config.comparison_operator) 

184 corr, corr_err = select_bin_from_corr(radius, xip, xip_err, radius=D, operator=operator) 

185 return Struct(measurement=Measurement(metric_name, np.abs(corr)*u.Unit(''))) 

186 

187 

188def isSorted(a): 

189 return all(a[i] <= a[i+1] for i in range(len(a)-1)) 

190 

191 

192def bins(window, n): 

193 delta = window/n 

194 return [i*delta for i in range(n+1)] 

195 

196 

197class AMxTaskConfig(Config): 

198 annulus_r = Field(doc="Radial distance of the annulus in arcmin (5, 20, or 200 for AM1, AM2, AM3)", 

199 dtype=float, default=5.) 

200 width = Field(doc="Width of annulus in arcmin", 

201 dtype=float, default=2.) 

202 bright_mag_cut = Field(doc="Bright limit of catalog entries to include", 

203 dtype=float, default=17.0) 

204 faint_mag_cut = Field(doc="Faint limit of catalog entries to include", 

205 dtype=float, default=21.5) 

206 # The defaults for threshADx and threshAFx correspond to the SRD "design" thresholds. 

207 threshAD = Field(doc="Threshold in mas for AFx calculation.", dtype=float, default=20.0) 

208 threshAF = Field(doc="Percentile of differences that can vary by more than threshAD.", 

209 dtype=float, default=10.0) 

210 bins = ListField(doc="Bins for histogram.", 

211 dtype=float, minLength=2, maxLength=1500, 

212 listCheck=isSorted, default=bins(30, 200)) 

213 

214 

215class AMxTask(Task): 

216 ConfigClass = AMxTaskConfig 

217 _DefaultName = "AMxTask" 

218 

219 def run(self, matchedCatalog, metric_name): 

220 self.log.info(f"Measuring {metric_name}") 

221 

222 filteredCat = filterMatches(matchedCatalog) 

223 

224 magRange = np.array([self.config.bright_mag_cut, self.config.faint_mag_cut]) * u.mag 

225 D = self.config.annulus_r * u.arcmin 

226 width = self.config.width * u.arcmin 

227 annulus = D + (width/2)*np.array([-1, +1]) 

228 

229 rmsDistances = calcRmsDistances( 

230 filteredCat, 

231 annulus, 

232 magRange=magRange) 

233 

234 values, bins = np.histogram(rmsDistances.to(u.marcsec), bins=self.config.bins*u.marcsec) 

235 extras = {'bins': Datum(bins, label='binvalues', description='bins'), 

236 'values': Datum(values*u.count, label='counts', description='icounts in bins')} 

237 

238 if len(rmsDistances) == 0: 

239 return Struct(measurement=Measurement(metric_name, np.nan*u.marcsec, extras=extras)) 

240 

241 return Struct(measurement=Measurement(metric_name, np.median(rmsDistances.to(u.marcsec)), 

242 extras=extras)) 

243 

244 

245class ADxTask(Task): 

246 ConfigClass = AMxTaskConfig 

247 _DefaultName = "ADxTask" 

248 

249 def run(self, matchedCatalog, metric_name): 

250 self.log.info(f"Measuring {metric_name}") 

251 

252 sepDistances = astromResiduals(matchedCatalog, self.config.bright_mag_cut, 

253 self.config.faint_mag_cut, self.config.annulus_r, 

254 self.config.width) 

255 

256 afThresh = self.config.threshAF * u.percent 

257 afPercentile = 100.0*u.percent - afThresh 

258 

259 if len(sepDistances) <= 1: 

260 return Struct(measurement=Measurement(metric_name, np.nan*u.marcsec)) 

261 else: 

262 # absolute value of the difference between each astrometric rms 

263 # and the median astrometric RMS 

264 # absRmsDiffs = np.abs(rmsDistances - np.median(rmsDistances)).to(u.marcsec) 

265 absDiffsMarcsec = (sepDistances-np.median(sepDistances)).to(u.marcsec) 

266 return Struct(measurement=Measurement(metric_name, np.percentile(absDiffsMarcsec.value, 

267 afPercentile.value)*u.marcsec)) 

268 

269 

270class AFxTask(Task): 

271 ConfigClass = AMxTaskConfig 

272 _DefaultName = "AFxTask" 

273 

274 def run(self, matchedCatalog, metric_name): 

275 self.log.info(f"Measuring {metric_name}") 

276 

277 sepDistances = astromResiduals(matchedCatalog, self.config.bright_mag_cut, 

278 self.config.faint_mag_cut, self.config.annulus_r, 

279 self.config.width) 

280 

281 adxThresh = self.config.threshAD * u.marcsec 

282 

283 if len(sepDistances) <= 1: 

284 return Struct(measurement=Measurement(metric_name, np.nan*u.percent)) 

285 else: 

286 # absolute value of the difference between each astrometric rms 

287 # and the median astrometric RMS 

288 # absRmsDiffs = np.abs(rmsDistances - np.median(rmsDistances)).to(u.marcsec) 

289 absDiffsMarcsec = (sepDistances-np.median(sepDistances)).to(u.marcsec) 

290 percentileAtADx = 100 * np.mean(np.abs(absDiffsMarcsec.value) > adxThresh.value) * u.percent 

291 return Struct(measurement=Measurement(metric_name, percentileAtADx)) 

292 

293 

294class AB1TaskConfig(Config): 

295 bright_mag_cut = Field(doc="Bright limit of catalog entries to include", 

296 dtype=float, default=17.0) 

297 faint_mag_cut = Field(doc="Faint limit of catalog entries to include", 

298 dtype=float, default=21.5) 

299 ref_filter = Field(doc="String representing the filter to use as reference", 

300 dtype=str, default="r") 

301 

302 

303class AB1Task(Task): 

304 ConfigClass = AB1TaskConfig 

305 _DefaultName = "AB1Task" 

306 

307 def run(self, matchedCatalogMulti, metric_name, in_id, out_id): 

308 self.log.info(f"Measuring {metric_name}") 

309 

310 if self.config.ref_filter not in filter_dict: 

311 raise Exception('Reference filter supplied for AB1 not in dictionary.') 

312 

313 filteredCat = filterMatches(matchedCatalogMulti) 

314 rmsDistancesAll = [] 

315 

316 if len(filteredCat) > 0: 

317 

318 filtnum = filter_dict[self.config.ref_filter] 

319 

320 refVisits = set() 

321 for id in filteredCat.ids: 

322 grptmp = filteredCat[id] 

323 filtmch = (grptmp['filt'] == filtnum) 

324 if len(filtmch) > 0: 

325 refVisits.update(set(grptmp[filtmch]['visit'])) 

326 

327 refVisits = list(refVisits) 

328 

329 magRange = np.array([self.config.bright_mag_cut, self.config.faint_mag_cut]) * u.mag 

330 for rv in refVisits: 

331 rmsDistances = calcRmsDistancesVsRef( 

332 filteredCat, 

333 rv, 

334 magRange=magRange, 

335 band=filter_dict[out_id['band']]) 

336 finiteEntries = np.where(np.isfinite(rmsDistances))[0] 

337 if len(finiteEntries) > 0: 

338 rmsDistancesAll.append(rmsDistances[finiteEntries]) 

339 

340 if len(rmsDistancesAll) == 0: 

341 return Struct(measurement=Measurement(metric_name, np.nan*u.marcsec)) 

342 else: 

343 rmsDistancesAll = np.concatenate(rmsDistancesAll) 

344 return Struct(measurement=Measurement(metric_name, np.mean(rmsDistancesAll))) 

345 

346 else: 

347 return Struct(measurement=Measurement(metric_name, np.nan*u.marcsec))