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 ChoiceField, Config, Field, ListField 

5from lsst.verify import Measurement, 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 

10 

11 

12__all__ = ("PA1TaskConfig", "PA1Task", "PF1TaskConfig", "PF1Task", 

13 "AMxTaskConfig", "AMxTask", "ADxTask", "AFxTask", "AB1TaskConfig", "AB1Task", "ModelPhotRepTask") 

14 

15 

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

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

18 

19 

20class PA1TaskConfig(Config): 

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

22 """ 

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

24 dtype=float, default=200) 

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

26 dtype=float, default=np.Inf) 

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

28 dtype=int, default=50) 

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

30 dtype=bool, default=False) 

31 

32 

33class PA1Task(Task): 

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

35 input set of multiple visits of the same field. 

36 

37 Notes 

38 ----- 

39 The intended usage is to retarget the run method of 

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

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

42 """ 

43 

44 ConfigClass = PA1TaskConfig 

45 _DefaultName = "PA1Task" 

46 

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

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

49 

50 def run(self, matchedCatalog, metricName): 

51 """Calculate the photometric repeatability. 

52 

53 Parameters 

54 ---------- 

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

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

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

58 metricName : `str` 

59 The name of the metric. 

60 

61 Returns 

62 ------- 

63 measurement : `lsst.verify.Measurement` 

64 Measurement of the repeatability and its associated metadata. 

65 """ 

66 self.log.info("Measuring %s", metricName) 

67 

68 pa1 = photRepeat(matchedCatalog, nMinPhotRepeat=self.config.nMinPhotRepeat, 

69 snrMax=self.config.brightSnrMax, snrMin=self.config.brightSnrMin) 

70 

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

72 if self.config.writeExtras: 

73 extras = { 

74 'rms': Datum(pa1['rms'], label='RMS', 

75 description='Photometric repeatability rms for each star.'), 

76 'count': Datum(pa1['count']*u.count, label='count', 

77 description='Number of detections used to calculate repeatability.'), 

78 'mean_mag': Datum(pa1['magMean'], label='mean_mag', 

79 description='Mean magnitude of each star.'), 

80 } 

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

82 else: 

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

84 else: 

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

86 

87 

88class PF1TaskConfig(Config): 

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

90 dtype=float, default=200) 

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

92 dtype=float, default=np.Inf) 

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

94 dtype=int, default=50) 

95 # The defaults for threshPA2 correspond to the SRD "design" thresholds. 

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

97 

98 

99class PF1Task(Task): 

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

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

102 

103 Notes 

104 ----- 

105 The intended usage is to retarget the run method of 

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

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

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

109 """ 

110 ConfigClass = PF1TaskConfig 

111 _DefaultName = "PF1Task" 

112 

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

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

115 

116 def run(self, matchedCatalog, metricName): 

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

118 

119 Parameters 

120 ---------- 

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

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

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

124 metricName : `str` 

125 The name of the metric. 

126 

127 Returns 

128 ------- 

129 measurement : `lsst.verify.Measurement` 

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

131 """ 

132 self.log.info("Measuring %s", metricName) 

133 pa2_thresh = self.config.threshPA2 * u.mmag 

134 

135 pf1 = photRepeat(matchedCatalog, nMinPhotRepeat=self.config.nMinPhotRepeat, 

136 snrMax=self.config.brightSnrMax, snrMin=self.config.brightSnrMin) 

137 

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

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

140 # Now, use all of them. 

141 # Keep only stars with > 2 observations: 

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

143 magResid0 = pf1['magResid'] 

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

145 

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

147 

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

149 else: 

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

151 

152 

153def isSorted(a): 

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

155 

156 

157def bins(window, n): 

158 delta = window/n 

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

160 

161 

162class AMxTaskConfig(Config): 

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

164 dtype=float, default=5.) 

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

166 dtype=float, default=2.) 

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

168 dtype=float, default=17.0) 

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

170 dtype=float, default=21.5) 

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

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

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

174 dtype=float, default=10.0) 

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

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

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

178 

179 

180class AMxTask(Task): 

181 ConfigClass = AMxTaskConfig 

182 _DefaultName = "AMxTask" 

183 

184 def run(self, matchedCatalog, metric_name): 

185 self.log.info("Measuring %s", metric_name) 

186 

187 filteredCat = filterMatches(matchedCatalog) 

188 

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

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

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

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

193 

194 rmsDistances = calcRmsDistances( 

195 filteredCat, 

196 annulus, 

197 magRange=magRange) 

198 

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

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

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

202 

203 if len(rmsDistances) == 0: 

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

205 

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

207 extras=extras)) 

208 

209 

210class ADxTask(Task): 

211 ConfigClass = AMxTaskConfig 

212 _DefaultName = "ADxTask" 

213 

214 def run(self, matchedCatalog, metric_name): 

215 self.log.info("Measuring %s", metric_name) 

216 

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

218 self.config.faint_mag_cut, self.config.annulus_r, 

219 self.config.width) 

220 

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

222 afPercentile = 100.0*u.percent - afThresh 

223 

224 if len(sepDistances) <= 1: 

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

226 else: 

227 # absolute value of the difference between each astrometric rms 

228 # and the median astrometric RMS 

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

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

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

232 afPercentile.value)*u.marcsec)) 

233 

234 

235class AFxTask(Task): 

236 ConfigClass = AMxTaskConfig 

237 _DefaultName = "AFxTask" 

238 

239 def run(self, matchedCatalog, metric_name): 

240 self.log.info("Measuring %s", metric_name) 

241 

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

243 self.config.faint_mag_cut, self.config.annulus_r, 

244 self.config.width) 

245 

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

247 

248 if len(sepDistances) <= 1: 

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

250 else: 

251 # absolute value of the difference between each astrometric rms 

252 # and the median astrometric RMS 

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

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

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

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

257 

258 

259class AB1TaskConfig(Config): 

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

261 dtype=float, default=17.0) 

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

263 dtype=float, default=21.5) 

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

265 dtype=str, default="r") 

266 

267 

268class AB1Task(Task): 

269 ConfigClass = AB1TaskConfig 

270 _DefaultName = "AB1Task" 

271 

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

273 self.log.info("Measuring %s", metric_name) 

274 

275 if self.config.ref_filter not in filter_dict: 

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

277 

278 filteredCat = filterMatches(matchedCatalogMulti) 

279 rmsDistancesAll = [] 

280 

281 if len(filteredCat) > 0: 

282 

283 filtnum = filter_dict[self.config.ref_filter] 

284 

285 refVisits = set() 

286 for id in filteredCat.ids: 

287 grptmp = filteredCat[id] 

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

289 if len(filtmch) > 0: 

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

291 

292 refVisits = list(refVisits) 

293 

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

295 for rv in refVisits: 

296 rmsDistances = calcRmsDistancesVsRef( 

297 filteredCat, 

298 rv, 

299 magRange=magRange, 

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

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

302 if len(finiteEntries) > 0: 

303 rmsDistancesAll.append(rmsDistances[finiteEntries]) 

304 

305 if len(rmsDistancesAll) == 0: 

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

307 else: 

308 rmsDistancesAll = np.concatenate(rmsDistancesAll) 

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

310 

311 else: 

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

313 

314 

315class ModelPhotRepTaskConfig(Config): 

316 """Config fields for the *ModelPhotRep photometric repeatability metrics. 

317 """ 

318 index = ChoiceField(doc="Index of the metric definition", dtype=int, 

319 allowed={x: f"Nth (N={x}) lowest S/N bin" for x in range(1, 5)}, 

320 optional=False) 

321 magName = Field(doc="Name of the magnitude column", dtype=str, default="slot_ModelFlux_mag") 

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

323 dtype=int, default=50) 

324 prefix = Field(doc="Prefix of the metric name", dtype=str, default="model") 

325 selectExtended = Field(doc="Whether to select extended sources", dtype=bool) 

326 selectSnrMin = Field(doc="Minimum median SNR for a source to be selected.", 

327 dtype=float) 

328 selectSnrMax = Field(doc="Maximum median SNR for a source to be selected.", 

329 dtype=float) 

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

331 dtype=bool, default=False) 

332 

333 

334class ModelPhotRepTask(Task): 

335 """A Task that computes a *ModelPhotRep photometric repeatability metric 

336 from an input set of multiple visits of the same field. 

337 

338 Notes 

339 ----- 

340 The intended usage is to retarget the run method of 

341 `lsst.faro.measurement.TractMatchedMeasurementTask` to ModelPhotRepTask. 

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

343 """ 

344 

345 ConfigClass = ModelPhotRepTaskConfig 

346 _DefaultName = "ModelPhotRepTask" 

347 

348 def __init__(self, config: ModelPhotRepTaskConfig, *args, **kwargs): 

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

350 

351 def run(self, matchedCatalog, metricName): 

352 """Calculate the photometric repeatability. 

353 

354 Parameters 

355 ---------- 

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

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

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

359 metricName : `str` 

360 The name of the metric. 

361 

362 Returns 

363 ------- 

364 measurement : `lsst.verify.Measurement` 

365 Measurement of the repeatability and its associated metadata. 

366 """ 

367 self.log.info("Measuring %s", metricName) 

368 

369 meas = photRepeat(matchedCatalog, nMinPhotRepeat=self.config.nMinPhotRepeat, 

370 snrMax=self.config.selectSnrMax, snrMin=self.config.selectSnrMin, 

371 magName=self.config.magName, extended=self.config.selectExtended) 

372 

373 name_type = "Gal" if self.config.selectExtended else "Star" 

374 name_meas = f'{self.config.prefix}PhotRep{name_type}{self.config.index}' 

375 

376 if 'magMean' in meas.keys(): 

377 if self.config.writeExtras: 

378 extras = { 

379 'rms': Datum(meas['rms'], label='RMS', 

380 description='Photometric repeatability rms for each star.'), 

381 'count': Datum(meas['count']*u.count, label='count', 

382 description='Number of detections used to calculate repeatability.'), 

383 'mean_mag': Datum(meas['magMean'], label='mean_mag', 

384 description='Mean magnitude of each star.'), 

385 } 

386 return Struct(measurement=Measurement(name_meas, meas['repeatability'], extras=extras)) 

387 else: 

388 return Struct(measurement=Measurement(name_meas, meas['repeatability'])) 

389 else: 

390 return Struct(measurement=Measurement(name_meas, np.nan*u.mmag))