Coverage for python/lsst/faro/measurement/MatchedCatalogMeasurementTasks.py: 47%

161 statements  

« prev     ^ index     » next       coverage.py v6.4.1, created at 2022-06-23 03:28 -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/>. 

21 

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 

34 

35 

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) 

49 

50 

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} 

65 

66 

67class PA1Config(Config): 

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

69 """ 

70 

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 ) 

91 

92 

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. 

96 

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 """ 

103 

104 ConfigClass = PA1Config 

105 _DefaultName = "PA1Task" 

106 

107 def __init__(self, config: PA1Config, *args, **kwargs): 

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

109 

110 def run(self, metricName, matchedCatalog): 

111 """Calculate the photometric repeatability. 

112 

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. 

120 

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) 

127 

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 ) 

135 

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)) 

162 

163 

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 ) 

184 

185 

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. 

189 

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 """ 

197 

198 ConfigClass = PF1Config 

199 _DefaultName = "PF1Task" 

200 

201 def __init__(self, config: PF1Config, *args, **kwargs): 

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

203 

204 def run(self, metricName, matchedCatalog): 

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

206 

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. 

214 

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 

222 

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 ) 

230 

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]) 

238 

239 percentileAtPA2 = ( 

240 100 * np.mean(np.abs(magResid.value) > pa2_thresh.value) * u.percent 

241 ) 

242 

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

244 else: 

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

246 

247 

248def isSorted(a): 

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

250 

251 

252def bins(window, n): 

253 delta = window / n 

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

255 

256 

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 ) 

287 

288 

289class AMxTask(Task): 

290 ConfigClass = AMxConfig 

291 _DefaultName = "AMxTask" 

292 

293 def run(self, metricName, matchedCatalog): 

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

295 

296 filteredCat = filterMatches(matchedCatalog) 

297 

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]) 

304 

305 rmsDistances = calcRmsDistances(filteredCat, annulus, magRange=magRange) 

306 

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 } 

316 

317 if len(rmsDistances) == 0: 

318 return Struct( 

319 measurement=Measurement(metricName, np.nan * u.marcsec, extras=extras) 

320 ) 

321 

322 return Struct( 

323 measurement=Measurement( 

324 metricName, np.median(rmsDistances.to(u.marcsec)), extras=extras 

325 ) 

326 ) 

327 

328 

329class ADxTask(Task): 

330 ConfigClass = AMxConfig 

331 _DefaultName = "ADxTask" 

332 

333 def run(self, metricName, matchedCatalog): 

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

335 

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 ) 

343 

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

345 afPercentile = 100.0 * u.percent - afThresh 

346 

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 ) 

361 

362 

363class AFxTask(Task): 

364 ConfigClass = AMxConfig 

365 _DefaultName = "AFxTask" 

366 

367 def run(self, metricName, matchedCatalog): 

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

369 

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 ) 

377 

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

379 

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)) 

393 

394 

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 ) 

405 

406 

407class AB1Task(Task): 

408 ConfigClass = AB1Config 

409 _DefaultName = "AB1Task" 

410 

411 def run(self, metricName, matchedCatalogMulti, in_id, out_id): 

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

413 

414 if self.config.ref_filter not in filter_dict: 

415 raise Exception("Reference filter supplied for AB1 not in dictionary.") 

416 

417 filteredCat = filterMatches(matchedCatalogMulti) 

418 rmsDistancesAll = [] 

419 

420 if len(filteredCat) > 0: 

421 

422 filtnum = filter_dict[self.config.ref_filter] 

423 

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"])) 

430 

431 refVisits = list(refVisits) 

432 

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]) 

444 

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 ) 

452 

453 else: 

454 return Struct(measurement=Measurement(metricName, np.nan * u.marcsec)) 

455 

456 

457class ModelPhotRepConfig(Config): 

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

459 """ 

460 

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 ) 

488 

489 

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. 

493 

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 """ 

500 

501 ConfigClass = ModelPhotRepConfig 

502 _DefaultName = "ModelPhotRepTask" 

503 

504 def __init__(self, config: ModelPhotRepConfig, *args, **kwargs): 

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

506 

507 def run(self, metricName, matchedCatalog): 

508 """Calculate the photometric repeatability. 

509 

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. 

517 

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) 

524 

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 ) 

534 

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

536 name_meas = f"{self.config.prefix}PhotRep{name_type}{self.config.index}" 

537 

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))