Coverage for python/lsst/cp/verify/verifyStats.py: 26%

167 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-25 11:59 +0000

1# This file is part of cp_verify. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (http://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 <http://www.gnu.org/licenses/>. 

21import math 

22 

23import lsst.afw.geom as afwGeom 

24import lsst.afw.math as afwMath 

25import lsst.pex.config as pexConfig 

26import lsst.pex.exceptions as pexException 

27import lsst.pipe.base as pipeBase 

28import lsst.pipe.base.connectionTypes as cT 

29import lsst.meas.algorithms as measAlg 

30 

31from lsst.ip.isr.vignette import maskVignettedRegion 

32from lsst.pipe.tasks.repair import RepairTask 

33from .utils import mergeStatDict 

34 

35 

36__all__ = ['CpVerifyStatsConfig', 'CpVerifyStatsTask'] 

37 

38 

39class CpVerifyStatsConnections(pipeBase.PipelineTaskConnections, 

40 dimensions={"instrument", "exposure", "detector"}, 

41 defaultTemplates={}): 

42 inputExp = cT.Input( 

43 name="postISRCCD", 

44 doc="Input exposure to calculate statistics for.", 

45 storageClass="Exposure", 

46 dimensions=["instrument", "exposure", "detector"], 

47 ) 

48 taskMetadata = cT.Input( 

49 name="isrTask_metadata", 

50 doc="Input task metadata to extract statistics from.", 

51 storageClass="TaskMetadata", 

52 dimensions=["instrument", "exposure", "detector"], 

53 ) 

54 inputCatalog = cT.Input( 

55 name="src", 

56 doc="Input catalog to calculate statistics for.", 

57 storageClass="SourceCatalog", 

58 dimensions=["instrument", "visit", "detector"], 

59 ) 

60 uncorrectedCatalog = cT.Input( 

61 name="uncorrectedSrc", 

62 doc="Input catalog without correction applied.", 

63 storageClass="SourceCatalog", 

64 dimensions=["instrument", "visit", "detector"], 

65 ) 

66 camera = cT.PrerequisiteInput( 

67 name="camera", 

68 storageClass="Camera", 

69 doc="Input camera.", 

70 dimensions=["instrument", ], 

71 isCalibration=True, 

72 ) 

73 

74 outputStats = cT.Output( 

75 name="detectorStats", 

76 doc="Output statistics from cp_verify.", 

77 storageClass="StructuredDataDict", 

78 dimensions=["instrument", "exposure", "detector"], 

79 ) 

80 

81 def __init__(self, *, config=None): 

82 super().__init__(config=config) 

83 

84 if len(config.metadataStatKeywords) < 1: 

85 self.inputs.discard('taskMetadata') 

86 

87 if len(config.catalogStatKeywords) < 1: 

88 self.inputs.discard('inputCatalog') 

89 self.inputs.discard('uncorrectedCatalog') 

90 

91 

92class CpVerifyStatsConfig(pipeBase.PipelineTaskConfig, 

93 pipelineConnections=CpVerifyStatsConnections): 

94 """Configuration parameters for CpVerifyStatsTask. 

95 """ 

96 maskNameList = pexConfig.ListField( 

97 dtype=str, 

98 doc="Mask list to exclude from statistics calculations.", 

99 default=['DETECTED', 'BAD', 'NO_DATA'], 

100 ) 

101 doVignette = pexConfig.Field( 

102 dtype=bool, 

103 doc="Mask vignetted regions?", 

104 default=False, 

105 ) 

106 doNormalize = pexConfig.Field( 

107 dtype=bool, 

108 doc="Normalize by exposure time?", 

109 default=False, 

110 ) 

111 

112 # Cosmic ray handling options. 

113 doCR = pexConfig.Field( 

114 dtype=bool, 

115 doc="Run CR rejection?", 

116 default=False, 

117 ) 

118 repair = pexConfig.ConfigurableField( 

119 target=RepairTask, 

120 doc="Repair task to use.", 

121 ) 

122 psfFwhm = pexConfig.Field( 

123 dtype=float, 

124 default=3.0, 

125 doc="Repair PSF FWHM (pixels).", 

126 ) 

127 psfSize = pexConfig.Field( 

128 dtype=int, 

129 default=21, 

130 doc="Repair PSF bounding-box size (pixels).", 

131 ) 

132 crGrow = pexConfig.Field( 

133 dtype=int, 

134 default=0, 

135 doc="Grow radius for CR (pixels).", 

136 ) 

137 

138 # Statistics options. 

139 useReadNoise = pexConfig.Field( 

140 dtype=bool, 

141 doc="Compare sigma against read noise?", 

142 default=True, 

143 ) 

144 numSigmaClip = pexConfig.Field( 

145 dtype=float, 

146 doc="Rejection threshold (sigma) for statistics clipping.", 

147 default=5.0, 

148 ) 

149 clipMaxIter = pexConfig.Field( 

150 dtype=int, 

151 doc="Max number of clipping iterations to apply.", 

152 default=3, 

153 ) 

154 

155 # Keywords and statistics to measure from different sources. 

156 imageStatKeywords = pexConfig.DictField( 

157 keytype=str, 

158 itemtype=str, 

159 doc="Image statistics to run on amplifier segments.", 

160 default={}, 

161 ) 

162 unmaskedImageStatKeywords = pexConfig.DictField( 

163 keytype=str, 

164 itemtype=str, 

165 doc="Image statistics to run on amplifier segments, ignoring masks.", 

166 default={}, 

167 ) 

168 crImageStatKeywords = pexConfig.DictField( 

169 keytype=str, 

170 itemtype=str, 

171 doc="Image statistics to run on CR cleaned amplifier segments.", 

172 default={}, 

173 ) 

174 normImageStatKeywords = pexConfig.DictField( 

175 keytype=str, 

176 itemtype=str, 

177 doc="Image statistics to run on expTime normalized amplifier segments.", 

178 default={}, 

179 ) 

180 metadataStatKeywords = pexConfig.DictField( 

181 keytype=str, 

182 itemtype=str, 

183 doc="Statistics to measure from the metadata of the exposure.", 

184 default={}, 

185 ) 

186 catalogStatKeywords = pexConfig.DictField( 

187 keytype=str, 

188 itemtype=str, 

189 doc="Statistics to measure from source catalogs of objects in the exposure.", 

190 default={}, 

191 ) 

192 detectorStatKeywords = pexConfig.DictField( 

193 keytype=str, 

194 itemtype=str, 

195 doc="Statistics to create for the full detector from the per-amplifier measurements.", 

196 default={}, 

197 ) 

198 

199 

200class CpVerifyStatsTask(pipeBase.PipelineTask): 

201 """Main statistic measurement and validation class. 

202 

203 This operates on a single (exposure, detector) pair, and is 

204 designed to be subclassed so specific calibrations can apply their 

205 own validation methods. 

206 """ 

207 ConfigClass = CpVerifyStatsConfig 

208 _DefaultName = 'cpVerifyStats' 

209 

210 def __init__(self, **kwargs): 

211 super().__init__(**kwargs) 

212 self.makeSubtask("repair") 

213 

214 def run(self, inputExp, camera, taskMetadata=None, inputCatalog=None, uncorrectedCatalog=None): 

215 """Calculate quality statistics and verify they meet the requirements 

216 for a calibration. 

217 

218 Parameters 

219 ---------- 

220 inputExp : `lsst.afw.image.Exposure` 

221 The ISR processed exposure to be measured. 

222 camera : `lsst.afw.cameraGeom.Camera` 

223 The camera geometry for ``inputExp``. 

224 taskMetadata : `lsst.pipe.base.TaskMetadata`, optional 

225 Task metadata containing additional statistics. 

226 inputCatalog : `lsst.afw.image.Table` 

227 The source catalog to measure. 

228 uncorrectedCatalog : `lsst.afw.image.Table` 

229 The alternate source catalog to measure. 

230 

231 Returns 

232 ------- 

233 result : `lsst.pipe.base.Struct` 

234 Result struct with components: 

235 - ``outputStats`` : `dict` 

236 The output measured statistics. 

237 

238 Notes 

239 ----- 

240 The outputStats should have a yaml representation of the form 

241 

242 AMP: 

243 Amp1: 

244 STAT: value 

245 STAT2: value2 

246 Amp2: 

247 Amp3: 

248 DET: 

249 STAT: value 

250 STAT2: value 

251 CATALOG: 

252 STAT: value 

253 STAT2: value 

254 VERIFY: 

255 DET: 

256 TEST: boolean 

257 CATALOG: 

258 TEST: boolean 

259 AMP: 

260 Amp1: 

261 TEST: boolean 

262 TEST2: boolean 

263 Amp2: 

264 Amp3: 

265 SUCCESS: boolean 

266 

267 """ 

268 outputStats = {} 

269 

270 if self.config.doVignette: 

271 polygon = inputExp.getInfo().getValidPolygon() 

272 maskVignettedRegion(inputExp, polygon, maskPlane='NO_DATA', 

273 vignetteValue=None, log=self.log) 

274 

275 mask = inputExp.getMask() 

276 maskVal = mask.getPlaneBitMask(self.config.maskNameList) 

277 statControl = afwMath.StatisticsControl(self.config.numSigmaClip, 

278 self.config.clipMaxIter, 

279 maskVal) 

280 

281 # This is wrapped below to check for config lengths, as we can 

282 # make a number of different image stats. 

283 outputStats['AMP'] = self.imageStatistics(inputExp, statControl) 

284 

285 if len(self.config.metadataStatKeywords): 

286 # These are also defined on a amp-by-amp basis. 

287 outputStats['METADATA'] = self.metadataStatistics(inputExp, taskMetadata) 

288 else: 

289 outputStats['METADATA'] = {} 

290 

291 if len(self.config.catalogStatKeywords): 

292 outputStats['CATALOG'] = self.catalogStatistics(inputExp, inputCatalog, uncorrectedCatalog, 

293 statControl) 

294 else: 

295 outputStats['CATALOG'] = {} 

296 if len(self.config.detectorStatKeywords): 

297 outputStats['DET'] = self.detectorStatistics(outputStats, statControl) 

298 else: 

299 outputStats['DET'] = {} 

300 

301 outputStats['VERIFY'], outputStats['SUCCESS'] = self.verify(inputExp, outputStats) 

302 

303 return pipeBase.Struct( 

304 outputStats=outputStats, 

305 ) 

306 

307 @staticmethod 

308 def _emptyAmpDict(exposure): 

309 """Construct empty dictionary indexed by amplifier names. 

310 

311 Parameters 

312 ---------- 

313 exposure : `lsst.afw.image.Exposure` 

314 Exposure to extract detector from. 

315 

316 Returns 

317 ------- 

318 outputStatistics : `dict` [`str`, `dict`] 

319 A skeleton statistics dictionary. 

320 

321 Raises 

322 ------ 

323 RuntimeError : 

324 Raised if no detector can be found. 

325 """ 

326 outputStatistics = {} 

327 detector = exposure.getDetector() 

328 if detector is None: 

329 raise RuntimeError("No detector found in exposure!") 

330 

331 for amp in detector.getAmplifiers(): 

332 outputStatistics[amp.getName()] = {} 

333 

334 return outputStatistics 

335 

336 # Image measurement methods. 

337 def imageStatistics(self, exposure, statControl): 

338 """Measure image statistics for a number of simple image 

339 modifications. 

340 

341 Parameters 

342 ---------- 

343 exposure : `lsst.afw.image.Exposure` 

344 Exposure containing the ISR processed data to measure. 

345 statControl : `lsst.afw.math.StatisticsControl` 

346 Statistics control object with parameters defined by 

347 the config. 

348 

349 Returns 

350 ------- 

351 outputStatistics : `dict` [`str`, `dict` [`str`, scalar]] 

352 A dictionary indexed by the amplifier name, containing 

353 dictionaries of the statistics measured and their values. 

354 

355 """ 

356 outputStatistics = self._emptyAmpDict(exposure) 

357 

358 if len(self.config.imageStatKeywords): 

359 outputStatistics = mergeStatDict(outputStatistics, 

360 self.amplifierStats(exposure, 

361 self.config.imageStatKeywords, 

362 statControl)) 

363 if len(self.config.unmaskedImageStatKeywords): 

364 outputStatistics = mergeStatDict(outputStatistics, self.unmaskedImageStats(exposure)) 

365 

366 if len(self.config.normImageStatKeywords): 

367 outputStatistics = mergeStatDict(outputStatistics, 

368 self.normalizedImageStats(exposure, statControl)) 

369 

370 if len(self.config.crImageStatKeywords): 

371 outputStatistics = mergeStatDict(outputStatistics, 

372 self.crImageStats(exposure, statControl)) 

373 

374 return outputStatistics 

375 

376 @staticmethod 

377 def _configHelper(keywordDict): 

378 """Helper to convert keyword dictionary to stat value. 

379 

380 Convert the string names in the keywordDict to the afwMath values. 

381 The statisticToRun is then the bitwise-or of that set. 

382 

383 Parameters 

384 ---------- 

385 keywordDict : `dict` [`str`, `str`] 

386 A dictionary of keys to use in the output results, with 

387 values the string name associated with the 

388 `lsst.afw.math.statistics.Property` to measure. 

389 

390 Returns 

391 ------- 

392 statisticToRun : `int` 

393 The merged `lsst.afw.math` statistics property. 

394 statAccessor : `dict` [`str`, `int`] 

395 Dictionary containing statistics property indexed by name. 

396 """ 

397 statisticToRun = 0 

398 statAccessor = {} 

399 for k, v in keywordDict.items(): 

400 statValue = afwMath.stringToStatisticsProperty(v) 

401 statisticToRun |= statValue 

402 statAccessor[k] = statValue 

403 

404 return statisticToRun, statAccessor 

405 

406 def metadataStatistics(self, exposure, taskMetadata): 

407 """Extract task metadata information for verification. 

408 

409 Parameters 

410 ---------- 

411 exposure : `lsst.afw.image.Exposure` 

412 The exposure to measure. 

413 taskMetadata : `lsst.pipe.base.TaskMetadata` 

414 The metadata to extract values from. 

415 

416 Returns 

417 ------- 

418 ampStats : `dict` [`str`, `dict` [`str`, scalar]] 

419 A dictionary indexed by the amplifier name, containing 

420 dictionaries of the statistics measured and their values. 

421 """ 

422 metadataStats = {} 

423 keywordDict = self.config.metadataStatKeywords 

424 

425 if taskMetadata: 

426 for key, value in keywordDict.items(): 

427 if value == 'AMP': 

428 metadataStats[key] = {} 

429 for ampIdx, amp in enumerate(exposure.getDetector()): 

430 ampName = amp.getName() 

431 expectedKey = f"{key} {ampName}" 

432 metadataStats[key][ampName] = None 

433 for name in taskMetadata: 

434 if expectedKey in taskMetadata[name]: 

435 metadataStats[key][ampName] = taskMetadata[name][expectedKey] 

436 else: 

437 # Assume it's detector-wide. 

438 expectedKey = key 

439 for name in taskMetadata: 

440 if expectedKey in taskMetadata[name]: 

441 metadataStats[key] = taskMetadata[name][expectedKey] 

442 return metadataStats 

443 

444 def amplifierStats(self, exposure, keywordDict, statControl, failAll=False): 

445 """Measure amplifier level statistics from the exposure. 

446 

447 Parameters 

448 ---------- 

449 exposure : `lsst.afw.image.Exposure` 

450 The exposure to measure. 

451 keywordDict : `dict` [`str`, `str`] 

452 A dictionary of keys to use in the output results, with 

453 values the string name associated with the 

454 `lsst.afw.math.statistics.Property` to measure. 

455 statControl : `lsst.afw.math.StatisticsControl` 

456 Statistics control object with parameters defined by 

457 the config. 

458 failAll : `bool`, optional 

459 If True, all tests will be set as failed. 

460 

461 Returns 

462 ------- 

463 ampStats : `dict` [`str`, `dict` [`str`, scalar]] 

464 A dictionary indexed by the amplifier name, containing 

465 dictionaries of the statistics measured and their values. 

466 """ 

467 ampStats = {} 

468 

469 statisticToRun, statAccessor = self._configHelper(keywordDict) 

470 

471 # Measure stats on all amplifiers. 

472 for ampIdx, amp in enumerate(exposure.getDetector()): 

473 ampName = amp.getName() 

474 theseStats = {} 

475 ampExp = exposure.Factory(exposure, amp.getBBox()) 

476 stats = afwMath.makeStatistics(ampExp.getMaskedImage(), statisticToRun, statControl) 

477 

478 for k, v in statAccessor.items(): 

479 theseStats[k] = stats.getValue(v) 

480 

481 if failAll: 

482 theseStats['FORCE_FAILURE'] = failAll 

483 ampStats[ampName] = theseStats 

484 

485 return ampStats 

486 

487 def unmaskedImageStats(self, exposure): 

488 """Measure amplifier level statistics on the exposure, including all 

489 pixels in the exposure, regardless of any mask planes set. 

490 

491 Parameters 

492 ---------- 

493 exposure : `lsst.afw.image.Exposure` 

494 The exposure to measure. 

495 

496 Returns 

497 ------- 

498 outputStatistics : `dict` [`str`, `dict` [`str`, scalar]] 

499 A dictionary indexed by the amplifier name, containing 

500 dictionaries of the statistics measured and their values. 

501 """ 

502 noMaskStatsControl = afwMath.StatisticsControl(self.config.numSigmaClip, 

503 self.config.clipMaxIter, 

504 0x0) 

505 return self.amplifierStats(exposure, self.config.unmaskedImageStatKeywords, noMaskStatsControl) 

506 

507 def normalizedImageStats(self, exposure, statControl): 

508 """Measure amplifier level statistics on the exposure after dividing 

509 by the exposure time. 

510 

511 Parameters 

512 ---------- 

513 exposure : `lsst.afw.image.Exposure` 

514 The exposure to measure. 

515 statControl : `lsst.afw.math.StatisticsControl` 

516 Statistics control object with parameters defined by 

517 the config. 

518 

519 Returns 

520 ------- 

521 outputStatistics : `dict` [`str`, `dict` [`str`, scalar]] 

522 A dictionary indexed by the amplifier name, containing 

523 dictionaries of the statistics measured and their values. 

524 

525 Raises 

526 ------ 

527 RuntimeError : 

528 Raised if the exposure time cannot be used for normalization. 

529 """ 

530 scaledExposure = exposure.clone() 

531 exposureTime = scaledExposure.getInfo().getVisitInfo().getExposureTime() 

532 if exposureTime <= 0: 

533 raise RuntimeError(f"Invalid exposureTime {exposureTime}.") 

534 mi = scaledExposure.getMaskedImage() 

535 mi /= exposureTime 

536 

537 return self.amplifierStats(scaledExposure, self.config.normImageStatKeywords, statControl) 

538 

539 def crImageStats(self, exposure, statControl): 

540 """Measure amplifier level statistics on the exposure, 

541 after running cosmic ray rejection. 

542 

543 Parameters 

544 ---------- 

545 exposure : `lsst.afw.image.Exposure` 

546 The exposure to measure. 

547 statControl : `lsst.afw.math.StatisticsControl` 

548 Statistics control object with parameters defined by 

549 the config. 

550 

551 Returns 

552 ------- 

553 outputStatistics : `dict` [`str`, `dict` [`str`, scalar]] 

554 A dictionary indexed by the amplifier name, containing 

555 dictionaries of the statistics measured and their values. 

556 

557 """ 

558 crRejectedExp = exposure.clone() 

559 psf = measAlg.SingleGaussianPsf(self.config.psfSize, 

560 self.config.psfSize, 

561 self.config.psfFwhm/(2*math.sqrt(2*math.log(2)))) 

562 crRejectedExp.setPsf(psf) 

563 try: 

564 self.repair.run(crRejectedExp, keepCRs=False) 

565 failAll = False 

566 except pexException.LengthError: 

567 self.log.warning("Failure masking cosmic rays (too many found). Continuing.") 

568 failAll = True 

569 

570 if self.config.crGrow > 0: 

571 crMask = crRejectedExp.getMaskedImage().getMask().getPlaneBitMask("CR") 

572 spans = afwGeom.SpanSet.fromMask(crRejectedExp.mask, crMask) 

573 spans = spans.dilated(self.config.crGrow) 

574 spans = spans.clippedTo(crRejectedExp.getBBox()) 

575 spans.setMask(crRejectedExp.mask, crMask) 

576 

577 return self.amplifierStats(crRejectedExp, self.config.crImageStatKeywords, 

578 statControl, failAll=failAll) 

579 

580 # Methods that need to be implemented by the calibration-level subclasses. 

581 def catalogStatistics(self, exposure, catalog, uncorrectedCatalog, statControl): 

582 """Calculate statistics from a catalog. 

583 

584 Parameters 

585 ---------- 

586 exposure : `lsst.afw.image.Exposure` 

587 The exposure to measure. 

588 catalog : `lsst.afw.table.Table` 

589 The catalog to measure. 

590 uncorrectedCatalog : `lsst.afw.table.Table` 

591 The alternate catalog to measure. 

592 statControl : `lsst.afw.math.StatisticsControl` 

593 Statistics control object with parameters defined by 

594 the config. 

595 

596 Returns 

597 ------- 

598 outputStatistics : `dict` [`str`, `dict` [`str`, scalar]] 

599 A dictionary indexed by the amplifier name, containing 

600 dictionaries of the statistics measured and their values. 

601 """ 

602 raise NotImplementedError("Subclasses must implement catalog statistics method.") 

603 

604 def detectorStatistics(self, statisticsDict, statControl): 

605 """Calculate detector level statistics based on the existing 

606 per-amplifier measurements. 

607 

608 Parameters 

609 ---------- 

610 statisticsDictionary : `dict` [`str`, `dict` [`str`, scalar]], 

611 Dictionary of measured statistics. The inner dictionary 

612 should have keys that are statistic names (`str`) with 

613 values that are some sort of scalar (`int` or `float` are 

614 the mostly likely types). 

615 

616 Returns 

617 ------- 

618 outputStatistics : `dict` [`str`, scalar] 

619 A dictionary of the statistics measured and their values. 

620 

621 Raises 

622 ------ 

623 NotImplementedError : 

624 This method must be implemented by the calibration-type 

625 subclass. 

626 """ 

627 raise NotImplementedError("Subclasses must implement detector statistics method.") 

628 

629 def verify(self, exposure, statisticsDict): 

630 """Verify that the measured statistics meet the verification criteria. 

631 

632 Parameters 

633 ---------- 

634 exposure : `lsst.afw.image.Exposure` 

635 The exposure the statistics are from. 

636 statisticsDictionary : `dict` [`str`, `dict` [`str`, scalar]], 

637 Dictionary of measured statistics. The inner dictionary 

638 should have keys that are statistic names (`str`) with 

639 values that are some sort of scalar (`int` or `float` are 

640 the mostly likely types). 

641 

642 Returns 

643 ------- 

644 outputStatistics : `dict` [`str`, `dict` [`str`, `bool`]] 

645 A dictionary indexed by the amplifier name, containing 

646 dictionaries of the verification criteria. 

647 success : `bool` 

648 A boolean indicating whether all tests have passed. 

649 

650 Raises 

651 ------ 

652 NotImplementedError : 

653 This method must be implemented by the calibration-type 

654 subclass. 

655 """ 

656 raise NotImplementedError("Subclasses must implement verification criteria.")