Coverage for python / lsst / pipe / tasks / selectImages.py: 21%

300 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-05 18:38 +0000

1# This file is part of pipe_tasks. 

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 

22__all__ = ["BaseSelectImagesTask", "BaseExposureInfo", "WcsSelectImagesTask", "PsfWcsSelectImagesTask", 

23 "DatabaseSelectImagesConfig", "BestSeeingSelectVisitsTask", 

24 "BestSeeingQuantileSelectVisitsTask"] 

25 

26import numpy as np 

27import lsst.sphgeom 

28import lsst.pex.config as pexConfig 

29import lsst.pex.exceptions as pexExceptions 

30import lsst.geom as geom 

31import lsst.pipe.base as pipeBase 

32from lsst.skymap import BaseSkyMap 

33from lsst.daf.base import DateTime 

34from lsst.utils.timer import timeMethod 

35 

36 

37def _meetsVisitSummaryMinValues(visit, visitSummary, visitSummaryMinValues, logger=None): 

38 """Check if the visitSummary meets minimum values in visitSummaryMinValues. 

39 

40 Parameters 

41 ---------- 

42 visit : `int` 

43 Visit number. 

44 visitSummary : `lsst.afw.table.ExposureCatalog` 

45 Exposure catalog with per-detector summary information. 

46 visitSummaryMinValues : `dict` 

47 Dictionary with column names as keys and minimum allowed values as values. 

48 logger : `lsst.log.Logger` 

49 Logger to log debug and warning messages. 

50 

51 Returns 

52 ------- 

53 result : `bool` 

54 True if all columns in visitSummary meet the minimum values specified 

55 in visitSummaryMinValues, False otherwise. 

56 """ 

57 for columnName, minThreshold in visitSummaryMinValues.items(): 

58 # Get values for this column across all detectors with a valid WCS 

59 values = np.asarray([vs[columnName] for vs in visitSummary if vs.getWcs()]) 

60 meanValue = np.nanmean(values) 

61 if meanValue < minThreshold: 

62 if logger is not None: 

63 logger.info(f'Rejecting visit {visit}: mean {columnName} ({meanValue:.3f}) ' 

64 f'is below the minimum threshold ({minThreshold:.3f}).') 

65 return False 

66 return True 

67 

68 

69class DatabaseSelectImagesConfig(pexConfig.Config): 

70 """Base configuration for subclasses of BaseSelectImagesTask that use a 

71 database. 

72 """ 

73 

74 host = pexConfig.Field( 

75 doc="Database server host name", 

76 dtype=str, 

77 ) 

78 port = pexConfig.Field( 

79 doc="Database server port", 

80 dtype=int, 

81 ) 

82 database = pexConfig.Field( 

83 doc="Name of database", 

84 dtype=str, 

85 ) 

86 maxExposures = pexConfig.Field( 

87 doc="maximum exposures to select; intended for debugging; ignored if None", 

88 dtype=int, 

89 optional=True, 

90 ) 

91 

92 

93class BaseExposureInfo(pipeBase.Struct): 

94 """Data about a selected exposure. 

95 

96 Parameters 

97 ---------- 

98 dataId : `dict` 

99 Data ID keys of exposure. 

100 coordList : `list` [`lsst.afw.geom.SpherePoint`] 

101 ICRS coordinates of the corners of the exposure 

102 plus any others items that are desired. 

103 """ 

104 

105 def __init__(self, dataId, coordList): 

106 super(BaseExposureInfo, self).__init__(dataId=dataId, coordList=coordList) 

107 

108 

109class BaseSelectImagesTask(pipeBase.Task): 

110 """Base task for selecting images suitable for coaddition. 

111 """ 

112 

113 ConfigClass = pexConfig.Config 

114 _DefaultName = "selectImages" 

115 

116 @timeMethod 

117 def run(self, coordList): 

118 """Select images suitable for coaddition in a particular region. 

119 

120 Parameters 

121 ---------- 

122 coordList : `list` [`lsst.geom.SpherePoint`] or `None` 

123 List of coordinates defining region of interest; if `None`, then 

124 select all images subclasses may add additional keyword arguments, 

125 as required. 

126 

127 Returns 

128 ------- 

129 result : `pipeBase.Struct` 

130 Results as a struct with attributes: 

131 

132 ``exposureInfoList`` 

133 A list of exposure information objects (subclasses of 

134 BaseExposureInfo), which have at least the following fields: 

135 - dataId: Data ID dictionary (`dict`). 

136 - coordList: ICRS coordinates of the corners of the exposure. 

137 (`list` [`lsst.geom.SpherePoint`]) 

138 """ 

139 raise NotImplementedError() 

140 

141 

142def _extractKeyValue(dataList, keys=None): 

143 """Extract the keys and values from a list of dataIds. 

144 

145 The input dataList is a list of objects that have 'dataId' members. 

146 This allows it to be used for both a list of data references and a 

147 list of ExposureInfo. 

148 

149 Parameters 

150 ---------- 

151 dataList : `Unknown` 

152 keys : `Unknown` 

153 

154 Returns 

155 ------- 

156 keys : `Unknown` 

157 values : `Unknown` 

158 

159 Raises 

160 ------ 

161 RuntimeError 

162 Raised if DataId keys are inconsistent. 

163 """ 

164 assert len(dataList) > 0 

165 if keys is None: 

166 keys = sorted(dataList[0].dataId.keys()) 

167 keySet = set(keys) 

168 values = list() 

169 for data in dataList: 

170 thisKeys = set(data.dataId.keys()) 

171 if thisKeys != keySet: 

172 raise RuntimeError("DataId keys inconsistent: %s vs %s" % (keySet, thisKeys)) 

173 values.append(tuple(data.dataId[k] for k in keys)) 

174 return keys, values 

175 

176 

177class SelectStruct(pipeBase.Struct): 

178 """A container for data to be passed to the WcsSelectImagesTask. 

179 

180 Parameters 

181 ---------- 

182 dataRef : `Unknown` 

183 Data reference. 

184 wcs : `lsst.afw.geom.SkyWcs` 

185 Coordinate system definition (wcs). 

186 bbox : `lsst.geom.box.Box2I` 

187 Integer bounding box for image. 

188 """ 

189 

190 def __init__(self, dataRef, wcs, bbox): 

191 super(SelectStruct, self).__init__(dataRef=dataRef, wcs=wcs, bbox=bbox) 

192 

193 

194class WcsSelectImagesConfig(pexConfig.Config): 

195 excludeDetectors = pexConfig.ListField( 

196 dtype=int, 

197 default=[], 

198 doc="Detectors to exclude from selection.", 

199 optional=True, 

200 ) 

201 

202 

203class WcsSelectImagesTask(BaseSelectImagesTask): 

204 """Select images using their Wcs. 

205 

206 We use the "convexHull" method of lsst.sphgeom.ConvexPolygon to define 

207 polygons on the celestial sphere, and test the polygon of the 

208 patch for overlap with the polygon of the image. 

209 

210 We use "convexHull" instead of generating a ConvexPolygon 

211 directly because the standard for the inputs to ConvexPolygon 

212 are pretty high and we don't want to be responsible for reaching them. 

213 """ 

214 

215 ConfigClass = WcsSelectImagesConfig 

216 _DefaultName = "WcsSelectImages" 

217 

218 def run(self, wcsList, bboxList, coordList, dataIds=None, **kwargs): 

219 """Return indices of provided lists that meet the selection criteria. 

220 

221 Parameters 

222 ---------- 

223 wcsList : `list` [`lsst.afw.geom.SkyWcs`] 

224 Specifying the WCS's of the input ccds to be selected. 

225 bboxList : `list` [`lsst.geom.Box2I`] 

226 Specifying the bounding boxes of the input ccds to be selected. 

227 coordList : `list` [`lsst.geom.SpherePoint`] 

228 ICRS coordinates specifying boundary of the patch. 

229 dataIds : iterable [`lsst.daf.butler.dataId`] or `None`, optional 

230 An iterable object of dataIds which point to reference catalogs. 

231 **kwargs 

232 Additional keyword arguments. 

233 

234 Returns 

235 ------- 

236 result : `list` [`int`] 

237 The indices of selected ccds. 

238 """ 

239 if dataIds is None: 

240 dataIds = [None] * len(wcsList) 

241 patchVertices = [coord.getVector() for coord in coordList] 

242 patchPoly = lsst.sphgeom.ConvexPolygon.convexHull(patchVertices) 

243 result = [] 

244 for i, (imageWcs, imageBox, dataId) in enumerate(zip(wcsList, bboxList, dataIds)): 

245 if dataId and ("detector" in dataId) and (dataId["detector"] in self.config.excludeDetectors): 

246 self.log.info("De-selecting exposure %s because detector %s is excluded from processing", 

247 dataId, dataId["detector"]) 

248 elif imageWcs is None: 

249 self.log.info("De-selecting exposure %s: Exposure has no WCS.", dataId) 

250 else: 

251 imageCorners = self.getValidImageCorners(imageWcs, imageBox, patchPoly, dataId) 

252 if imageCorners: 

253 result.append(i) 

254 return result 

255 

256 def getValidImageCorners(self, imageWcs, imageBox, patchPoly, dataId=None): 

257 """Return corners or `None` if bad. 

258 

259 Parameters 

260 ---------- 

261 imageWcs : `Unknown` 

262 imageBox : `Unknown` 

263 patchPoly : `Unknown` 

264 dataId : `Unknown` 

265 """ 

266 try: 

267 imageCorners = [imageWcs.pixelToSky(pix) for pix in geom.Box2D(imageBox).getCorners()] 

268 except (pexExceptions.DomainError, pexExceptions.RuntimeError) as e: 

269 # Protecting ourselves from awful Wcs solutions in input images 

270 self.log.debug("WCS error in testing calexp %s (%s): deselecting", dataId, e) 

271 return None 

272 

273 imagePoly = lsst.sphgeom.ConvexPolygon.convexHull([coord.getVector() for coord in imageCorners]) 

274 if imagePoly is None: 

275 self.log.debug("Unable to create polygon from image %s: deselecting", dataId) 

276 return None 

277 

278 if patchPoly.intersects(imagePoly): 

279 # "intersects" also covers "contains" or "is contained by" 

280 self.log.info("Selecting calexp %s", dataId) 

281 return imageCorners 

282 

283 return None 

284 

285 

286class PsfWcsSelectImagesConnections(pipeBase.PipelineTaskConnections, 

287 dimensions=("tract", "patch", "skymap", "instrument", "visit"), 

288 defaultTemplates={"coaddName": "deep"}): 

289 pass 

290 

291 

292class PsfWcsSelectImagesConfig(pipeBase.PipelineTaskConfig, 

293 pipelineConnections=PsfWcsSelectImagesConnections): 

294 maxPsfFwhm = pexConfig.Field( 

295 doc="Maximum PSF FWHM (in arcseconds) to select. This can be used in conjunction with the " 

296 "per-visit maxPsfFwhm in BestSeeingSelectVisitsTask to ensure that no outlier CCD has a " 

297 "PSF FWHM larger than this value.", 

298 dtype=float, 

299 default=1.8, 

300 optional=True, 

301 ) 

302 maxEllipResidual = pexConfig.Field( 

303 doc="Maximum median ellipticity residual", 

304 dtype=float, 

305 default=0.007, 

306 optional=True, 

307 ) 

308 maxSizeScatter = pexConfig.Field( 

309 doc="Maximum scatter in the size residuals", 

310 dtype=float, 

311 optional=True, 

312 ) 

313 maxScaledSizeScatter = pexConfig.Field( 

314 doc="Maximum scatter in the size residuals, scaled by the median size", 

315 dtype=float, 

316 default=0.019, 

317 optional=True, 

318 ) 

319 maxPsfTraceRadiusDelta = pexConfig.Field( 

320 doc="Maximum delta (max - min) of model PSF trace radius values evaluated on a grid on " 

321 "the unmasked detector pixels (pixel).", 

322 dtype=float, 

323 default=0.7, 

324 optional=True, 

325 ) 

326 maxPsfApFluxDelta = pexConfig.Field( 

327 doc="Maximum delta (max - min) of model PSF aperture flux (with aperture radius of " 

328 "max(2, 3*psfSigma)) values evaluated on a grid on the unmasked detector pixels (based " 

329 "on a normalized-to-one flux).", 

330 dtype=float, 

331 default=0.24, 

332 optional=True, 

333 ) 

334 maxPsfApCorrSigmaScaledDelta = pexConfig.Field( 

335 doc="Maximum delta (max - min) of model PSF aperture correction values evaluated on a grid " 

336 "on the unmasked detector pixels scaled (divided) by the measured model psfSigma.", 

337 dtype=float, 

338 default=0.22, 

339 optional=True, 

340 ) 

341 minNPsfStarPerBand = pexConfig.DictField( 

342 keytype=str, 

343 itemtype=int, 

344 default={ 

345 "u": 6, 

346 "g": 6, 

347 "r": 6, 

348 "i": 6, 

349 "z": 6, 

350 "y": 6, 

351 "fallback": 6, 

352 }, 

353 doc="Minimum number of PSF stars for the final PSF model to be considered " 

354 "well-constrained and suitible for inclusion in the coadd. This number should " 

355 "take into consideration the spatial order used for the PSF model. If the current " 

356 "band for the exposure is not included as a key in this dict, the value associated " 

357 "with the \"fallback\" key will be used.", 

358 ) 

359 maxStarEPerBand = pexConfig.DictField( 

360 keytype=str, 

361 itemtype=float, 

362 default={ 

363 "u": 0.4, 

364 "g": 0.4, 

365 "r": 0.4, 

366 "i": 0.4, 

367 "z": 0.4, 

368 "y": 0.4, 

369 "fallback": 0.4, 

370 }, 

371 doc="Maximum median of the ellipticity (sqrt(starE1**2.0 + starE2**2.0)) " 

372 "distribution of the star sources used in the PSF model. If the current band " 

373 "for the exposure is not included as a key in this dict, the value associated " 

374 "with the \"fallback\" key will be used.", 

375 ) 

376 maxStarUnNormalizedEPerBand = pexConfig.DictField( 

377 keytype=str, 

378 itemtype=float, 

379 default={ 

380 "u": 2.8, 

381 "g": 2.8, 

382 "r": 2.8, 

383 "i": 2.8, 

384 "z": 2.8, 

385 "y": 2.8, 

386 "fallback": 2.8, 

387 }, 

388 doc="Maximum median of the unnormalized ellipticity " 

389 "(sqrt((starXX - starYY)**2.0 + (2.0*starXY)**2.0)) distribution of the star " 

390 "sources used in the PSF model. If the current band for the exposure is not " 

391 "included as a key in this dict, the value associated with the \"fallback\" key " 

392 "will be used.", 

393 ) 

394 excludeDetectors = pexConfig.ListField( 

395 dtype=int, 

396 default=[], 

397 doc="Detectors to exclude from selection.", 

398 optional=True, 

399 ) 

400 

401 def validate(self): 

402 super().validate() 

403 if "fallback" not in self.minNPsfStarPerBand: 

404 msg = ("Must include a \"fallback\" key in the config.minNPsfStarPerBand config dict. " 

405 f"It is currently: {self.minNPsfStarPerBand}.") 

406 raise pexConfig.FieldValidationError(self.__class__.minNPsfStarPerBand, self, msg) 

407 if "fallback" not in self.maxStarEPerBand: 

408 msg = ("Must include a \"fallback\" key in the config.maxStarEPerBand config dict. " 

409 f"It is currently: {self.maxStarEPerBand}.") 

410 raise pexConfig.FieldValidationError(self.__class__.maxStarEPerBand, self, msg) 

411 if "fallback" not in self.maxStarUnNormalizedEPerBand: 

412 msg = ("Must include a \"fallback\" key in the config.maxStarUnNormalizedEPerBand " 

413 f"config dict. It is currently: {self.maxStarUnNormalizedEPerBand}.") 

414 raise pexConfig.FieldValidationError(self.__class__.maxStarUnNormalizedEPerBand, self, msg) 

415 

416 

417class PsfWcsSelectImagesTask(WcsSelectImagesTask): 

418 """Select images using their Wcs and cuts on the PSF properties. 

419 

420 The PSF quality criteria are based on the size and ellipticity 

421 residuals from the adaptive second moments of the star and the PSF. 

422 

423 The criteria are: 

424 - the median of the ellipticty residuals. 

425 - the robust scatter of the size residuals (using the median absolute 

426 deviation). 

427 - the robust scatter of the size residuals scaled by the square of 

428 the median size. 

429 """ 

430 

431 ConfigClass = PsfWcsSelectImagesConfig 

432 _DefaultName = "PsfWcsSelectImages" 

433 

434 def run(self, wcsList, bboxList, coordList, visitSummary, dataIds=None, **kwargs): 

435 """Return indices of provided lists that meet the selection criteria. 

436 

437 Parameters 

438 ---------- 

439 wcsList : `list` [`lsst.afw.geom.SkyWcs`] 

440 Specifying the WCS's of the input ccds to be selected. 

441 bboxList : `list` [`lsst.geom.Box2I`] 

442 Specifying the bounding boxes of the input ccds to be selected. 

443 coordList : `list` [`lsst.geom.SpherePoint`] 

444 ICRS coordinates specifying boundary of the patch. 

445 visitSummary : `list` [`lsst.afw.table.ExposureCatalog`] 

446 containing the PSF shape information for the input ccds to be 

447 selected. 

448 dataIds : iterable [`lsst.daf.butler.dataId`] or `None`, optional 

449 An iterable object of dataIds which point to reference catalogs. 

450 **kwargs 

451 Additional keyword arguments. 

452 

453 Returns 

454 ------- 

455 goodPsf : `list` [`int`] 

456 The indices of selected ccds. 

457 """ 

458 goodWcs = super(PsfWcsSelectImagesTask, self).run(wcsList=wcsList, bboxList=bboxList, 

459 coordList=coordList, dataIds=dataIds) 

460 

461 goodPsf = [] 

462 

463 for i, dataId in enumerate(dataIds): 

464 if i not in goodWcs: 

465 continue 

466 if self.isValid(visitSummary, dataId["detector"]): 

467 goodPsf.append(i) 

468 

469 return goodPsf 

470 

471 def isValid(self, visitSummary, detectorId): 

472 """Should this ccd be selected based on its PSF shape information. 

473 

474 Parameters 

475 ---------- 

476 visitSummary : `lsst.afw.table.ExposureCatalog` 

477 Exposure catalog with per-detector summary information. 

478 detectorId : `int` 

479 Detector identifier. 

480 

481 Returns 

482 ------- 

483 valid : `bool` 

484 True if selected. 

485 """ 

486 row = visitSummary.find(detectorId) 

487 if row is None: 

488 # This is not listed, so it must be bad. 

489 self.log.warning("Removing detector %d because summary stats not available.", detectorId) 

490 return False 

491 

492 band = row["band"] 

493 if band in self.config.minNPsfStarPerBand: 

494 minNPsfStar = self.config.minNPsfStarPerBand[band] 

495 else: 

496 minNPsfStar = self.config.minNPsfStarPerBand["fallback"] 

497 if band in self.config.maxStarEPerBand: 

498 maxStarE = self.config.maxStarEPerBand[band] 

499 else: 

500 maxStarE = self.config.maxStarEPerBand["fallback"] 

501 if band in self.config.maxStarUnNormalizedEPerBand: 

502 maxStarUnNormalizedE = self.config.maxStarUnNormalizedEPerBand[band] 

503 else: 

504 maxStarUnNormalizedE = self.config.maxStarUnNormalizedEPerBand["fallback"] 

505 nPsfStar = row["nPsfStar"] 

506 medianE = np.sqrt(row["psfStarDeltaE1Median"]**2. + row["psfStarDeltaE2Median"]**2.) 

507 scatterSize = row["psfStarDeltaSizeScatter"] 

508 scaledScatterSize = row["psfStarScaledDeltaSizeScatter"] 

509 psfTraceRadiusDelta = row["psfTraceRadiusDelta"] 

510 psfApFluxDelta = row["psfApFluxDelta"] 

511 psfApCorrSigmaScaledDelta = row["psfApCorrSigmaScaledDelta"] 

512 starEMedian = row["starEMedian"] 

513 starUnNormalizedEMedian = row["starUnNormalizedEMedian"] 

514 

515 valid = True 

516 # The logic of this condition does not follow the ones below. Isolating 

517 # it from the get-go such that it doesn't break the established flow of 

518 # the selections below (and having the psfFwhm cut as the first/dominant 

519 # one seems reasonable). 

520 if self.config.maxPsfFwhm: 

521 pixelScale = row['pixelScale'] 

522 psfSigma = row['psfSigma'] 

523 fwhm = psfSigma * pixelScale * np.sqrt(8.*np.log(2.)) 

524 if not (fwhm <= self.config.maxPsfFwhm): 

525 self.log.info("Removing visit %d detector %d because FWHM too large: %f vs %f", 

526 row["visit"], detectorId, fwhm, self.config.maxPsfFwhm) 

527 valid = False 

528 return valid 

529 

530 if self.config.maxEllipResidual and not (medianE <= self.config.maxEllipResidual): 

531 self.log.info("Removing visit %d detector %d because median e residual too large: %f vs %f", 

532 row["visit"], detectorId, medianE, self.config.maxEllipResidual) 

533 valid = False 

534 elif self.config.maxSizeScatter and not (scatterSize <= self.config.maxSizeScatter): 

535 self.log.info("Removing visit %d detector %d because size scatter too large: %f vs %f", 

536 row["visit"], detectorId, scatterSize, self.config.maxSizeScatter) 

537 valid = False 

538 elif self.config.maxScaledSizeScatter and not (scaledScatterSize <= self.config.maxScaledSizeScatter): 

539 self.log.info("Removing visit %d detector %d because scaled size scatter too large: %f vs %f", 

540 row["visit"], detectorId, scaledScatterSize, self.config.maxScaledSizeScatter) 

541 valid = False 

542 elif ( 

543 self.config.maxPsfTraceRadiusDelta is not None 

544 and not (psfTraceRadiusDelta <= self.config.maxPsfTraceRadiusDelta) 

545 ): 

546 self.log.info( 

547 "Removing visit %d detector %d because max-min delta of model PSF trace radius values " 

548 "across the unmasked detector pixels is not finite or too large: %.3f vs %.3f (pixels)", 

549 row["visit"], detectorId, psfTraceRadiusDelta, self.config.maxPsfTraceRadiusDelta 

550 ) 

551 valid = False 

552 elif ( 

553 self.config.maxPsfApFluxDelta is not None 

554 and not (psfApFluxDelta <= self.config.maxPsfApFluxDelta) 

555 ): 

556 self.log.info( 

557 "Removing visit %d detector %d because max-min delta of model PSF aperture flux values " 

558 "across the unmasked detector pixels is not finite or too large: %.3f vs %.3f (pixels)", 

559 row["visit"], detectorId, psfApFluxDelta, self.config.maxPsfApFluxDelta 

560 ) 

561 valid = False 

562 elif ( 

563 self.config.maxPsfApCorrSigmaScaledDelta is not None 

564 and not (psfApCorrSigmaScaledDelta <= self.config.maxPsfApCorrSigmaScaledDelta) 

565 ): 

566 self.log.info( 

567 "Removing visit %d detector %d because max-min delta of the model PSF aperture correction " 

568 "values scaled (divided) by the psfSigma across the unmasked detector pixels is not " 

569 "finite or too large: %.3f vs %.3f (factor)", 

570 row["visit"], detectorId, psfApCorrSigmaScaledDelta, self.config.maxPsfApCorrSigmaScaledDelta 

571 ) 

572 valid = False 

573 elif minNPsfStar and (nPsfStar < minNPsfStar): 

574 self.log.info( 

575 "Removing visit %d detector %d because the PSF model had too few stars: %d vs %d", 

576 row["visit"], detectorId, nPsfStar, minNPsfStar 

577 ) 

578 valid = False 

579 elif maxStarE and not (starEMedian <= maxStarE): 

580 self.log.info( 

581 "Removing visit %d detector %d because the median star ellipticity is too large: " 

582 "%.2f vs %.2f", 

583 row["visit"], detectorId, starEMedian, maxStarE 

584 ) 

585 valid = False 

586 elif maxStarUnNormalizedE and not (starUnNormalizedEMedian <= maxStarUnNormalizedE): 

587 self.log.info( 

588 "Removing visit %d detector %d because the median star unnormalized ellipticity " 

589 "is too large: %.2f vs %.2f", 

590 row["visit"], detectorId, starUnNormalizedEMedian, maxStarUnNormalizedE 

591 ) 

592 valid = False 

593 

594 return valid 

595 

596 

597class BestSeeingSelectVisitsConnections(pipeBase.PipelineTaskConnections, 

598 dimensions=("tract", "patch", "skymap", "band", "instrument"), 

599 defaultTemplates={"coaddName": "goodSeeing", 

600 "calexpType": ""}): 

601 skyMap = pipeBase.connectionTypes.Input( 

602 doc="Input definition of geometry/bbox and projection/wcs for coadded exposures", 

603 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME, 

604 storageClass="SkyMap", 

605 dimensions=("skymap",), 

606 ) 

607 visitSummaries = pipeBase.connectionTypes.Input( 

608 doc="Per-visit consolidated exposure metadata", 

609 name="finalVisitSummary", 

610 storageClass="ExposureCatalog", 

611 dimensions=("instrument", "visit",), 

612 multiple=True, 

613 deferLoad=True 

614 ) 

615 goodVisits = pipeBase.connectionTypes.Output( 

616 doc="Selected visits to be coadded.", 

617 name="{coaddName}Visits", 

618 storageClass="StructuredDataDict", 

619 dimensions=("instrument", "tract", "patch", "skymap", "band"), 

620 ) 

621 

622 

623class BestSeeingSelectVisitsConfig(pipeBase.PipelineTaskConfig, 

624 pipelineConnections=BestSeeingSelectVisitsConnections): 

625 nVisitsMax = pexConfig.RangeField( 

626 dtype=int, 

627 doc="Maximum number of visits to select; use -1 to select all.", 

628 default=12, 

629 min=-1, 

630 ) 

631 maxPsfFwhm = pexConfig.Field( 

632 dtype=float, 

633 doc="Maximum PSF FWHM (in arcseconds) to select", 

634 default=1.5, 

635 optional=True 

636 ) 

637 minPsfFwhm = pexConfig.Field( 

638 dtype=float, 

639 doc="Minimum PSF FWHM (in arcseconds) to select", 

640 default=0., 

641 optional=True 

642 ) 

643 doConfirmOverlap = pexConfig.Field( 

644 dtype=bool, 

645 doc="Do remove visits that do not actually overlap the patch?", 

646 default=True, 

647 ) 

648 minMJD = pexConfig.Field( 

649 dtype=float, 

650 doc="Minimum visit MJD to select", 

651 default=None, 

652 optional=True 

653 ) 

654 maxMJD = pexConfig.Field( 

655 dtype=float, 

656 doc="Maximum visit MJD to select", 

657 default=None, 

658 optional=True 

659 ) 

660 visitSummaryMinValues = pexConfig.DictField( 

661 keytype=str, 

662 itemtype=float, 

663 doc=("Optional thresholds for visit selection. Keys are visit_summary column names" 

664 "(e.g. 'effTimeZeroPointScale'), and values are minimum allowed values."), 

665 default=None, 

666 optional=True 

667 ) 

668 

669 

670class BestSeeingSelectVisitsTask(pipeBase.PipelineTask): 

671 """Select up to a maximum number of the best-seeing visits. 

672 

673 Don't exceed the FWHM range specified by configs min(max)PsfFwhm. 

674 This Task is a port of the Gen2 image-selector used in the AP pipeline: 

675 BestSeeingSelectImagesTask. This Task selects full visits based on the 

676 average PSF of the entire visit. 

677 """ 

678 

679 ConfigClass = BestSeeingSelectVisitsConfig 

680 _DefaultName = 'bestSeeingSelectVisits' 

681 

682 def runQuantum(self, butlerQC, inputRefs, outputRefs): 

683 inputs = butlerQC.get(inputRefs) 

684 quantumDataId = butlerQC.quantum.dataId 

685 outputs = self.run(**inputs, dataId=quantumDataId) 

686 butlerQC.put(outputs, outputRefs) 

687 

688 def run(self, visitSummaries, skyMap, dataId): 

689 """Run task. 

690 

691 Parameters 

692 ---------- 

693 visitSummary : `list` [`lsst.pipe.base.connections.DeferredDatasetRef`] 

694 List of `lsst.pipe.base.connections.DeferredDatasetRef` of 

695 visitSummary tables of type `lsst.afw.table.ExposureCatalog`. 

696 skyMap : `lsst.skyMap.SkyMap` 

697 SkyMap for checking visits overlap patch. 

698 dataId : `dict` of dataId keys 

699 For retrieving patch info for checking visits overlap patch. 

700 

701 Returns 

702 ------- 

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

704 Results as a struct with attributes: 

705 

706 ``goodVisits`` 

707 A `dict` with selected visit ids as keys, 

708 so that it can be be saved as a StructuredDataDict. 

709 StructuredDataList's are currently limited. 

710 """ 

711 if self.config.doConfirmOverlap: 

712 patchPolygon = self.makePatchPolygon(skyMap, dataId) 

713 

714 inputVisits = [visitSummary.ref.dataId['visit'] for visitSummary in visitSummaries] 

715 fwhmSizes = [] 

716 visits = [] 

717 for visit, visitSummary in zip(inputVisits, visitSummaries): 

718 # read in one-by-one and only once. There may be hundreds 

719 visitSummary = visitSummary.get() 

720 

721 # mjd is guaranteed to be the same for every detector in the 

722 # visitSummary. 

723 mjd = visitSummary[0].getVisitInfo().getDate().get(system=DateTime.MJD) 

724 

725 pixelScales = np.array([vs['pixelScale'] for vs in visitSummary if vs.getWcs()]) 

726 # psfSigma is PSF model determinant radius at chip center in pixels 

727 psfSigmas = np.array([vs['psfSigma'] for vs in visitSummary if vs.getWcs()]) 

728 fwhm = np.nanmean(psfSigmas * pixelScales) * np.sqrt(8.*np.log(2.)) 

729 

730 if self.config.maxPsfFwhm and fwhm > self.config.maxPsfFwhm: 

731 continue 

732 if self.config.minPsfFwhm and fwhm < self.config.minPsfFwhm: 

733 continue 

734 if self.config.minMJD and mjd < self.config.minMJD: 

735 self.log.debug('MJD %f earlier than %.2f; rejecting', mjd, self.config.minMJD) 

736 continue 

737 if self.config.maxMJD and mjd > self.config.maxMJD: 

738 self.log.debug('MJD %f later than %.2f; rejecting', mjd, self.config.maxMJD) 

739 continue 

740 if self.config.doConfirmOverlap and not self.doesIntersectPolygon(visitSummary, patchPolygon): 

741 continue 

742 if (self.config.visitSummaryMinValues 

743 and not _meetsVisitSummaryMinValues(visit, visitSummary, self.config.visitSummaryMinValues, 

744 self.log)): 

745 continue 

746 

747 fwhmSizes.append(fwhm) 

748 visits.append(visit) 

749 

750 sortedVisits = [ind for (_, ind) in sorted(zip(fwhmSizes, visits))] 

751 if self.config.nVisitsMax < 0: 

752 output = sortedVisits 

753 else: 

754 output = sortedVisits[:self.config.nVisitsMax] 

755 

756 if len(output) == 0: 

757 self.log.info("All images rejected in BestSeeingSelectVisitsTask.") 

758 raise pipeBase.NoWorkFound(f"No good images found for {dataId}") 

759 else: 

760 self.log.info( 

761 "%d images selected with FWHM range of %f--%f arcseconds", 

762 len(output), 

763 fwhmSizes[visits.index(output[0])], 

764 fwhmSizes[visits.index(output[-1])], 

765 ) 

766 

767 # In order to store as a StructuredDataDict, convert list to dict 

768 goodVisits = {key: True for key in output} 

769 return pipeBase.Struct(goodVisits=goodVisits) 

770 

771 def makePatchPolygon(self, skyMap, dataId): 

772 """Return True if sky polygon overlaps visit. 

773 

774 Parameters 

775 ---------- 

776 skyMap : `lsst.afw.table.ExposureCatalog` 

777 Exposure catalog with per-detector geometry. 

778 dataId : `dict` of dataId keys 

779 For retrieving patch info. 

780 

781 Returns 

782 ------- 

783 result : `lsst.sphgeom.ConvexPolygon.convexHull` 

784 Polygon of patch's outer bbox. 

785 """ 

786 wcs = skyMap[dataId['tract']].getWcs() 

787 bbox = skyMap[dataId['tract']][dataId['patch']].getOuterBBox() 

788 sphCorners = wcs.pixelToSky(lsst.geom.Box2D(bbox).getCorners()) 

789 result = lsst.sphgeom.ConvexPolygon.convexHull([coord.getVector() for coord in sphCorners]) 

790 return result 

791 

792 def doesIntersectPolygon(self, visitSummary, polygon): 

793 """Return True if sky polygon overlaps visit. 

794 

795 Parameters 

796 ---------- 

797 visitSummary : `lsst.afw.table.ExposureCatalog` 

798 Exposure catalog with per-detector geometry. 

799 polygon :` lsst.sphgeom.ConvexPolygon.convexHull` 

800 Polygon to check overlap. 

801 

802 Returns 

803 ------- 

804 doesIntersect : `bool` 

805 True if the visit overlaps the polygon. 

806 """ 

807 doesIntersect = False 

808 for detectorSummary in visitSummary: 

809 if (np.all(np.isfinite(detectorSummary['raCorners'])) 

810 and np.all(np.isfinite(detectorSummary['decCorners']))): 

811 corners = [lsst.geom.SpherePoint(ra, dec, units=lsst.geom.degrees).getVector() 

812 for (ra, dec) in 

813 zip(detectorSummary['raCorners'], detectorSummary['decCorners'])] 

814 detectorPolygon = lsst.sphgeom.ConvexPolygon.convexHull(corners) 

815 if detectorPolygon.intersects(polygon): 

816 doesIntersect = True 

817 break 

818 return doesIntersect 

819 

820 

821class BestSeeingQuantileSelectVisitsConfig(pipeBase.PipelineTaskConfig, 

822 pipelineConnections=BestSeeingSelectVisitsConnections): 

823 qMin = pexConfig.RangeField( 

824 doc="Lower bound of quantile range to select. Sorts visits by seeing from narrow to wide, " 

825 "and select those in the interquantile range (qMin, qMax). Set qMin to 0 for Best Seeing. " 

826 "This config should be changed from zero only for exploratory diffIm testing.", 

827 dtype=float, 

828 default=0, 

829 min=0, 

830 max=1, 

831 ) 

832 qMax = pexConfig.RangeField( 

833 doc="Upper bound of quantile range to select. Sorts visits by seeing from narrow to wide, " 

834 "and select those in the interquantile range (qMin, qMax). Set qMax to 1 for Worst Seeing.", 

835 dtype=float, 

836 default=0.33, 

837 min=0, 

838 max=1, 

839 ) 

840 maxPsfFwhm = pexConfig.Field( 

841 dtype=float, 

842 doc="Maximum PSF FWHM (in arcseconds) to select. Any visits with larger FWHM will be excluded " 

843 "before quantile selection.", 

844 default=None, 

845 optional=True 

846 ) 

847 nVisitsMin = pexConfig.Field( 

848 doc="The minimum number of visits to select, if qMin and qMax alone would have " 

849 "selected fewer. In regimes with many visits, at least this number of visits will be " 

850 "selected, superceding quantile when necessary. " 

851 "For example, if 10 visits cover this patch, qMin=0, qMax=0.33, and nVisitsMin=5, " 

852 "the best 5 visits will be selected, even though 5 > 0.33*10. " 

853 "In regimes with few visits, all available visits will be selected. " 

854 "For example, if 2 visits cover this patch and nVisitsMin=12, " 

855 "both visits will be selected regardless of qMin and qMax.", 

856 dtype=int, 

857 default=6, 

858 ) 

859 doConfirmOverlap = pexConfig.Field( 

860 dtype=bool, 

861 doc="Do remove visits that do not actually overlap the patch?", 

862 default=True, 

863 ) 

864 minMJD = pexConfig.Field( 

865 dtype=float, 

866 doc="Minimum visit MJD to select", 

867 default=None, 

868 optional=True 

869 ) 

870 maxMJD = pexConfig.Field( 

871 dtype=float, 

872 doc="Maximum visit MJD to select", 

873 default=None, 

874 optional=True 

875 ) 

876 visitSummaryMinValues = pexConfig.DictField( 

877 keytype=str, 

878 itemtype=float, 

879 doc=("Optional thresholds for visit selection. Keys are visit_summary column names" 

880 "(e.g. 'effTimeZeroPointScale'), and values are minimum allowed values."), 

881 default=None, 

882 optional=True 

883 ) 

884 

885 

886class BestSeeingQuantileSelectVisitsTask(BestSeeingSelectVisitsTask): 

887 """Select a quantile of the best-seeing visits. 

888 

889 Selects the best (for example, third) full visits based on the average 

890 PSF width in the entire visit. It can also be used for difference imaging 

891 experiments that require templates with the worst seeing visits. 

892 For example, selecting the worst third can be acheived by 

893 changing the config parameters qMin to 0.66 and qMax to 1. 

894 """ 

895 ConfigClass = BestSeeingQuantileSelectVisitsConfig 

896 _DefaultName = 'bestSeeingQuantileSelectVisits' 

897 

898 def run(self, visitSummaries, skyMap, dataId): 

899 if self.config.doConfirmOverlap: 

900 patchPolygon = self.makePatchPolygon(skyMap, dataId) 

901 visits = np.array([visitSummary.ref.dataId['visit'] for visitSummary in visitSummaries]) 

902 radius = np.empty(len(visits)) 

903 intersects = np.full(len(visits), True) 

904 for i, visitSummary in enumerate(visitSummaries): 

905 # read in one-by-one and only once. There may be hundreds 

906 visitSummary = visitSummary.get() 

907 # psfSigma is PSF model determinant radius at chip center in pixels 

908 psfSigmas = np.array([vs['psfSigma'] for vs in visitSummary if vs.getWcs()]) 

909 pixelScales = np.array([vs['pixelScale'] for vs in visitSummary if vs.getWcs()]) 

910 fwhm = np.nanmedian(psfSigmas * pixelScales) * np.sqrt(8.*np.log(2.)) 

911 radius[i] = fwhm 

912 if self.config.doConfirmOverlap: 

913 intersects[i] = self.doesIntersectPolygon(visitSummary, patchPolygon) 

914 if self.config.minMJD or self.config.maxMJD: 

915 # mjd is guaranteed to be the same for every detector in the 

916 # visitSummary. 

917 mjd = visitSummary[0].getVisitInfo().getDate().get(system=DateTime.MJD) 

918 aboveMin = mjd > self.config.minMJD if self.config.minMJD else True 

919 belowMax = mjd < self.config.maxMJD if self.config.maxMJD else True 

920 intersects[i] = intersects[i] and aboveMin and belowMax 

921 if self.config.maxPsfFwhm and fwhm > self.config.maxPsfFwhm: 

922 intersects[i] = False 

923 self.log.info(f"Rejecting visit {visits[i]}: with FWHM {fwhm:.2f} > {self.config.maxPsfFwhm}") 

924 if (self.config.visitSummaryMinValues 

925 and not _meetsVisitSummaryMinValues(visits[i], visitSummary, 

926 self.config.visitSummaryMinValues, self.log)): 

927 intersects[i] = False 

928 

929 sortedVisits = [v for rad, v in sorted(zip(radius[intersects], visits[intersects]))] 

930 lowerBound = min(int(np.round(self.config.qMin*len(visits[intersects]))), 

931 max(0, len(visits[intersects]) - self.config.nVisitsMin)) 

932 upperBound = max(int(np.round(self.config.qMax*len(visits[intersects]))), self.config.nVisitsMin) 

933 

934 # In order to store as a StructuredDataDict, convert list to dict 

935 goodVisits = {int(visit): True for visit in sortedVisits[lowerBound:upperBound]} 

936 

937 if len(goodVisits) == 0: 

938 self.log.info("All visits rejected") 

939 # To behave same as BestSeeingSelectVisitsTask 

940 raise pipeBase.NoWorkFound(f"No good visits found for {dataId}") 

941 else: 

942 selectedRadii = sorted(radius[intersects])[lowerBound:upperBound] 

943 self.log.info(f"Selected {len(goodVisits)} visits with FWHM range of " 

944 f"{selectedRadii[0]:.2f}--{selectedRadii[-1]:.2f} arcseconds.") 

945 return pipeBase.Struct(goodVisits=goodVisits)