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

Shortcuts 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

164 statements  

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="PropertySet", 

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 

88class CpVerifyStatsConfig(pipeBase.PipelineTaskConfig, 

89 pipelineConnections=CpVerifyStatsConnections): 

90 """Configuration parameters for CpVerifyStatsTask. 

91 """ 

92 maskNameList = pexConfig.ListField( 

93 dtype=str, 

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

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

96 ) 

97 doVignette = pexConfig.Field( 

98 dtype=bool, 

99 doc="Mask vignetted regions?", 

100 default=False, 

101 ) 

102 doNormalize = pexConfig.Field( 

103 dtype=bool, 

104 doc="Normalize by exposure time?", 

105 default=False, 

106 ) 

107 

108 # Cosmic ray handling options. 

109 doCR = pexConfig.Field( 

110 dtype=bool, 

111 doc="Run CR rejection?", 

112 default=False, 

113 ) 

114 repair = pexConfig.ConfigurableField( 

115 target=RepairTask, 

116 doc="Repair task to use.", 

117 ) 

118 psfFwhm = pexConfig.Field( 

119 dtype=float, 

120 default=3.0, 

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

122 ) 

123 psfSize = pexConfig.Field( 

124 dtype=int, 

125 default=21, 

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

127 ) 

128 crGrow = pexConfig.Field( 

129 dtype=int, 

130 default=0, 

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

132 ) 

133 

134 # Statistics options. 

135 useReadNoise = pexConfig.Field( 

136 dtype=bool, 

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

138 default=True, 

139 ) 

140 numSigmaClip = pexConfig.Field( 

141 dtype=float, 

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

143 default=5.0, 

144 ) 

145 clipMaxIter = pexConfig.Field( 

146 dtype=int, 

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

148 default=3, 

149 ) 

150 

151 # Keywords and statistics to measure from different sources. 

152 imageStatKeywords = pexConfig.DictField( 

153 keytype=str, 

154 itemtype=str, 

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

156 default={}, 

157 ) 

158 unmaskedImageStatKeywords = pexConfig.DictField( 

159 keytype=str, 

160 itemtype=str, 

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

162 default={}, 

163 ) 

164 crImageStatKeywords = pexConfig.DictField( 

165 keytype=str, 

166 itemtype=str, 

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

168 default={}, 

169 ) 

170 normImageStatKeywords = pexConfig.DictField( 

171 keytype=str, 

172 itemtype=str, 

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

174 default={}, 

175 ) 

176 metadataStatKeywords = pexConfig.DictField( 

177 keytype=str, 

178 itemtype=str, 

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

180 default={}, 

181 ) 

182 catalogStatKeywords = pexConfig.DictField( 

183 keytype=str, 

184 itemtype=str, 

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

186 default={}, 

187 ) 

188 detectorStatKeywords = pexConfig.DictField( 

189 keytype=str, 

190 itemtype=str, 

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

192 default={}, 

193 ) 

194 

195 

196class CpVerifyStatsTask(pipeBase.PipelineTask, pipeBase.CmdLineTask): 

197 """Main statistic measurement and validation class. 

198 

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

200 designed to be subclassed so specific calibrations can apply their 

201 own validation methods. 

202 """ 

203 ConfigClass = CpVerifyStatsConfig 

204 _DefaultName = 'cpVerifyStats' 

205 

206 def __init__(self, **kwargs): 

207 super().__init__(**kwargs) 

208 self.makeSubtask("repair") 

209 

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

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

212 for a calibration. 

213 

214 Parameters 

215 ---------- 

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

217 The ISR processed exposure to be measured. 

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

219 The camera geometry for ``inputExp``. 

220 taskMetadata : `lsst.daf.base.PropertySet`, optional 

221 Task metadata containing additional statistics. 

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

223 The source catalog to measure. 

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

225 The alternate source catalog to measure. 

226 

227 Returns 

228 ------- 

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

230 Result struct with components: 

231 - ``outputStats`` : `dict` 

232 The output measured statistics. 

233 

234 Notes 

235 ----- 

236 The outputStats should have a yaml representation of the form 

237 

238 AMP: 

239 Amp1: 

240 STAT: value 

241 STAT2: value2 

242 Amp2: 

243 Amp3: 

244 DET: 

245 STAT: value 

246 STAT2: value 

247 CATALOG: 

248 STAT: value 

249 STAT2: value 

250 VERIFY: 

251 DET: 

252 TEST: boolean 

253 CATALOG: 

254 TEST: boolean 

255 AMP: 

256 Amp1: 

257 TEST: boolean 

258 TEST2: boolean 

259 Amp2: 

260 Amp3: 

261 SUCCESS: boolean 

262 

263 """ 

264 outputStats = {} 

265 

266 if self.config.doVignette: 

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

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

269 vignetteValue=None, log=self.log) 

270 

271 mask = inputExp.getMask() 

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

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

274 self.config.clipMaxIter, 

275 maskVal) 

276 

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

278 # make a number of different image stats. 

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

280 

281 if len(self.config.metadataStatKeywords): 

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

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

284 else: 

285 outputStats['METADATA'] = {} 

286 

287 if len(self.config.catalogStatKeywords): 

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

289 statControl) 

290 else: 

291 outputStats['CATALOG'] = {} 

292 if len(self.config.detectorStatKeywords): 

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

294 else: 

295 outputStats['DET'] = {} 

296 

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

298 

299 return pipeBase.Struct( 

300 outputStats=outputStats, 

301 ) 

302 

303 @staticmethod 

304 def _emptyAmpDict(exposure): 

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

306 

307 Parameters 

308 ---------- 

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

310 Exposure to extract detector from. 

311 

312 Returns 

313 ------- 

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

315 A skeleton statistics dictionary. 

316 

317 Raises 

318 ------ 

319 RuntimeError : 

320 Raised if no detector can be found. 

321 """ 

322 outputStatistics = {} 

323 detector = exposure.getDetector() 

324 if detector is None: 

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

326 

327 for amp in detector.getAmplifiers(): 

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

329 

330 return outputStatistics 

331 

332 # Image measurement methods. 

333 def imageStatistics(self, exposure, statControl): 

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

335 modifications. 

336 

337 Parameters 

338 ---------- 

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

340 Exposure containing the ISR processed data to measure. 

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

342 Statistics control object with parameters defined by 

343 the config. 

344 

345 Returns 

346 ------- 

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

348 A dictionary indexed by the amplifier name, containing 

349 dictionaries of the statistics measured and their values. 

350 

351 """ 

352 outputStatistics = self._emptyAmpDict(exposure) 

353 

354 if len(self.config.imageStatKeywords): 

355 outputStatistics = mergeStatDict(outputStatistics, 

356 self.amplifierStats(exposure, 

357 self.config.imageStatKeywords, 

358 statControl)) 

359 if len(self.config.unmaskedImageStatKeywords): 

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

361 

362 if len(self.config.normImageStatKeywords): 

363 outputStatistics = mergeStatDict(outputStatistics, 

364 self.normalizedImageStats(exposure, statControl)) 

365 

366 if len(self.config.crImageStatKeywords): 

367 outputStatistics = mergeStatDict(outputStatistics, 

368 self.crImageStats(exposure, statControl)) 

369 

370 return outputStatistics 

371 

372 @staticmethod 

373 def _configHelper(keywordDict): 

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

375 

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

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

378 

379 Parameters 

380 ---------- 

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

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

383 values the string name associated with the 

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

385 

386 Returns 

387 ------- 

388 statisticToRun : `int` 

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

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

391 Dictionary containing statistics property indexed by name. 

392 """ 

393 statisticToRun = 0 

394 statAccessor = {} 

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

396 statValue = afwMath.stringToStatisticsProperty(v) 

397 statisticToRun |= statValue 

398 statAccessor[k] = statValue 

399 

400 return statisticToRun, statAccessor 

401 

402 def metadataStatistics(self, exposure, taskMetadata): 

403 """Extract task metadata information for verification. 

404 

405 Parameters 

406 ---------- 

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

408 The exposure to measure. 

409 taskMetadata : `lsst.daf.base.PropertySet` 

410 The metadata to extract values from. 

411 

412 Returns 

413 ------- 

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

415 A dictionary indexed by the amplifier name, containing 

416 dictionaries of the statistics measured and their values. 

417 """ 

418 metadataStats = {} 

419 keywordDict = self.config.metadataStatKeywords 

420 

421 if taskMetadata: 

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

423 if value == 'AMP': 

424 metadataStats[key] = {} 

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

426 ampName = amp.getName() 

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

428 metadataStats[key][ampName] = None 

429 for name in taskMetadata: 

430 if expectedKey in taskMetadata[name]: 

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

432 else: 

433 # Assume it's detector-wide. 

434 expectedKey = key 

435 for name in taskMetadata: 

436 if expectedKey in taskMetadata[name]: 

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

438 return metadataStats 

439 

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

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

442 

443 Parameters 

444 ---------- 

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

446 The exposure to measure. 

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

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

449 values the string name associated with the 

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

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

452 Statistics control object with parameters defined by 

453 the config. 

454 failAll : `bool`, optional 

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

456 

457 Returns 

458 ------- 

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

460 A dictionary indexed by the amplifier name, containing 

461 dictionaries of the statistics measured and their values. 

462 """ 

463 ampStats = {} 

464 

465 statisticToRun, statAccessor = self._configHelper(keywordDict) 

466 

467 # Measure stats on all amplifiers. 

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

469 ampName = amp.getName() 

470 theseStats = {} 

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

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

473 

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

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

476 

477 if failAll: 

478 theseStats['FORCE_FAILURE'] = failAll 

479 ampStats[ampName] = theseStats 

480 

481 return ampStats 

482 

483 def unmaskedImageStats(self, exposure): 

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

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

486 

487 Parameters 

488 ---------- 

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

490 The exposure to measure. 

491 

492 Returns 

493 ------- 

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

495 A dictionary indexed by the amplifier name, containing 

496 dictionaries of the statistics measured and their values. 

497 """ 

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

499 self.config.clipMaxIter, 

500 0x0) 

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

502 

503 def normalizedImageStats(self, exposure, statControl): 

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

505 by the exposure time. 

506 

507 Parameters 

508 ---------- 

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

510 The exposure to measure. 

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

512 Statistics control object with parameters defined by 

513 the config. 

514 

515 Returns 

516 ------- 

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

518 A dictionary indexed by the amplifier name, containing 

519 dictionaries of the statistics measured and their values. 

520 

521 Raises 

522 ------ 

523 RuntimeError : 

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

525 """ 

526 scaledExposure = exposure.clone() 

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

528 if exposureTime <= 0: 

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

530 mi = scaledExposure.getMaskedImage() 

531 mi /= exposureTime 

532 

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

534 

535 def crImageStats(self, exposure, statControl): 

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

537 after running cosmic ray rejection. 

538 

539 Parameters 

540 ---------- 

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

542 The exposure to measure. 

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

544 Statistics control object with parameters defined by 

545 the config. 

546 

547 Returns 

548 ------- 

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

550 A dictionary indexed by the amplifier name, containing 

551 dictionaries of the statistics measured and their values. 

552 

553 """ 

554 crRejectedExp = exposure.clone() 

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

556 self.config.psfSize, 

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

558 crRejectedExp.setPsf(psf) 

559 try: 

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

561 failAll = False 

562 except pexException.LengthError: 

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

564 failAll = True 

565 

566 if self.config.crGrow > 0: 

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

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

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

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

571 spans.setMask(crRejectedExp.mask, crMask) 

572 

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

574 statControl, failAll=failAll) 

575 

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

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

578 """Calculate statistics from a catalog. 

579 

580 Parameters 

581 ---------- 

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

583 The exposure to measure. 

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

585 The catalog to measure. 

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

587 The alternate catalog to measure. 

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

589 Statistics control object with parameters defined by 

590 the config. 

591 

592 Returns 

593 ------- 

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

595 A dictionary indexed by the amplifier name, containing 

596 dictionaries of the statistics measured and their values. 

597 """ 

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

599 

600 def detectorStatistics(self, statisticsDict, statControl): 

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

602 per-amplifier measurements. 

603 

604 Parameters 

605 ---------- 

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

607 Dictionary of measured statistics. The inner dictionary 

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

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

610 the mostly likely types). 

611 

612 Returns 

613 ------- 

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

615 A dictionary of the statistics measured and their values. 

616 

617 Raises 

618 ------ 

619 NotImplementedError : 

620 This method must be implemented by the calibration-type 

621 subclass. 

622 """ 

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

624 

625 def verify(self, exposure, statisticsDict): 

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

627 

628 Parameters 

629 ---------- 

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

631 The exposure the statistics are from. 

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

633 Dictionary of measured statistics. The inner dictionary 

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

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

636 the mostly likely types). 

637 

638 Returns 

639 ------- 

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

641 A dictionary indexed by the amplifier name, containing 

642 dictionaries of the verification criteria. 

643 success : `bool` 

644 A boolean indicating whether all tests have passed. 

645 

646 Raises 

647 ------ 

648 NotImplementedError : 

649 This method must be implemented by the calibration-type 

650 subclass. 

651 """ 

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