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

299 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2024-02-08 07:10 +0000

1# 

2# LSST Data Management System 

3# Copyright 2008, 2009, 2010 LSST Corporation. 

4# 

5# This product includes software developed by the 

6# LSST Project (http://www.lsst.org/). 

7# 

8# This program is free software: you can redistribute it and/or modify 

9# it under the terms of the GNU General Public License as published by 

10# the Free Software Foundation, either version 3 of the License, or 

11# (at your option) any later version. 

12# 

13# This program is distributed in the hope that it will be useful, 

14# but WITHOUT ANY WARRANTY; without even the implied warranty of 

15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

16# GNU General Public License for more details. 

17# 

18# You should have received a copy of the LSST License Statement and 

19# the GNU General Public License along with this program. If not, 

20# see <http://www.lsstcorp.org/LegalNotices/>. 

21# 

22import numpy as np 

23import lsst.sphgeom 

24import lsst.utils as utils 

25import lsst.pex.config as pexConfig 

26import lsst.pex.exceptions as pexExceptions 

27import lsst.geom as geom 

28import lsst.pipe.base as pipeBase 

29from lsst.skymap import BaseSkyMap 

30from lsst.daf.base import DateTime 

31from lsst.utils.timer import timeMethod 

32 

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

34 "DatabaseSelectImagesConfig", "BestSeeingSelectVisitsTask", 

35 "BestSeeingQuantileSelectVisitsTask"] 

36 

37 

38class DatabaseSelectImagesConfig(pexConfig.Config): 

39 """Base configuration for subclasses of BaseSelectImagesTask that use a database""" 

40 host = pexConfig.Field( 

41 doc="Database server host name", 

42 dtype=str, 

43 ) 

44 port = pexConfig.Field( 

45 doc="Database server port", 

46 dtype=int, 

47 ) 

48 database = pexConfig.Field( 

49 doc="Name of database", 

50 dtype=str, 

51 ) 

52 maxExposures = pexConfig.Field( 

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

54 dtype=int, 

55 optional=True, 

56 ) 

57 

58 

59class BaseExposureInfo(pipeBase.Struct): 

60 """Data about a selected exposure 

61 """ 

62 

63 def __init__(self, dataId, coordList): 

64 """Create exposure information that can be used to generate data references 

65 

66 The object has the following fields: 

67 - dataId: data ID of exposure (a dict) 

68 - coordList: ICRS coordinates of the corners of the exposure (list of lsst.geom.SpherePoint) 

69 plus any others items that are desired 

70 """ 

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

72 

73 

74class BaseSelectImagesTask(pipeBase.Task): 

75 """Base task for selecting images suitable for coaddition 

76 """ 

77 ConfigClass = pexConfig.Config 

78 _DefaultName = "selectImages" 

79 

80 @timeMethod 

81 def run(self, coordList): 

82 """Select images suitable for coaddition in a particular region 

83 

84 @param[in] coordList: list of coordinates defining region of interest; if None then select all images 

85 subclasses may add additional keyword arguments, as required 

86 

87 @return a pipeBase Struct containing: 

88 - exposureInfoList: a list of exposure information objects (subclasses of BaseExposureInfo), 

89 which have at least the following fields: 

90 - dataId: data ID dictionary 

91 - coordList: ICRS coordinates of the corners of the exposure (list of lsst.geom.SpherePoint) 

92 """ 

93 raise NotImplementedError() 

94 

95 def _runArgDictFromDataId(self, dataId): 

96 """Extract keyword arguments for run (other than coordList) from a data ID 

97 

98 @return keyword arguments for run (other than coordList), as a dict 

99 """ 

100 raise NotImplementedError() 

101 

102 def runDataRef(self, dataRef, coordList, makeDataRefList=True, selectDataList=[]): 

103 """Run based on a data reference 

104 

105 This delegates to run() and _runArgDictFromDataId() to do the actual 

106 selection. In the event that the selectDataList is non-empty, this will 

107 be used to further restrict the selection, providing the user with 

108 additional control over the selection. 

109 

110 @param[in] dataRef: data reference; must contain any extra keys needed by the subclass 

111 @param[in] coordList: list of coordinates defining region of interest; if None, search the whole sky 

112 @param[in] makeDataRefList: if True, return dataRefList 

113 @param[in] selectDataList: List of SelectStruct with dataRefs to consider for selection 

114 @return a pipeBase Struct containing: 

115 - exposureInfoList: a list of objects derived from ExposureInfo 

116 - dataRefList: a list of data references (None if makeDataRefList False) 

117 """ 

118 runArgDict = self._runArgDictFromDataId(dataRef.dataId) 

119 exposureInfoList = self.run(coordList, **runArgDict).exposureInfoList 

120 

121 if len(selectDataList) > 0 and len(exposureInfoList) > 0: 

122 # Restrict the exposure selection further 

123 ccdKeys, ccdValues = _extractKeyValue(exposureInfoList) 

124 inKeys, inValues = _extractKeyValue([s.dataRef for s in selectDataList], keys=ccdKeys) 

125 inValues = set(inValues) 

126 newExposureInfoList = [] 

127 for info, ccdVal in zip(exposureInfoList, ccdValues): 

128 if ccdVal in inValues: 

129 newExposureInfoList.append(info) 

130 else: 

131 self.log.info("De-selecting exposure %s: not in selectDataList", info.dataId) 

132 exposureInfoList = newExposureInfoList 

133 

134 if makeDataRefList: 

135 butler = dataRef.butlerSubset.butler 

136 dataRefList = [butler.dataRef(datasetType="calexp", 

137 dataId=expInfo.dataId, 

138 ) for expInfo in exposureInfoList] 

139 else: 

140 dataRefList = None 

141 

142 return pipeBase.Struct( 

143 dataRefList=dataRefList, 

144 exposureInfoList=exposureInfoList, 

145 ) 

146 

147 

148def _extractKeyValue(dataList, keys=None): 

149 """Extract the keys and values from a list of dataIds 

150 

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

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

153 list of ExposureInfo 

154 """ 

155 assert len(dataList) > 0 

156 if keys is None: 

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

158 keySet = set(keys) 

159 values = list() 

160 for data in dataList: 

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

162 if thisKeys != keySet: 

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

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

165 return keys, values 

166 

167 

168class SelectStruct(pipeBase.Struct): 

169 """A container for data to be passed to the WcsSelectImagesTask""" 

170 

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

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

173 

174 

175class WcsSelectImagesTask(BaseSelectImagesTask): 

176 """Select images using their Wcs 

177 

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

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

180 patch for overlap with the polygon of the image. 

181 

182 We use "convexHull" instead of generating a ConvexPolygon 

183 directly because the standard for the inputs to ConvexPolygon 

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

185 """ 

186 

187 def runDataRef(self, dataRef, coordList, makeDataRefList=True, selectDataList=[]): 

188 """Select images in the selectDataList that overlap the patch 

189 

190 This method is the old entry point for the Gen2 commandline tasks and drivers 

191 Will be deprecated in v22. 

192 

193 @param dataRef: Data reference for coadd/tempExp (with tract, patch) 

194 @param coordList: List of ICRS coordinates (lsst.geom.SpherePoint) specifying boundary of patch 

195 @param makeDataRefList: Construct a list of data references? 

196 @param selectDataList: List of SelectStruct, to consider for selection 

197 """ 

198 dataRefList = [] 

199 exposureInfoList = [] 

200 

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

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

203 

204 for data in selectDataList: 

205 dataRef = data.dataRef 

206 imageWcs = data.wcs 

207 imageBox = data.bbox 

208 

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

210 if imageCorners: 

211 dataRefList.append(dataRef) 

212 exposureInfoList.append(BaseExposureInfo(dataRef.dataId, imageCorners)) 

213 

214 return pipeBase.Struct( 

215 dataRefList=dataRefList if makeDataRefList else None, 

216 exposureInfoList=exposureInfoList, 

217 ) 

218 

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

220 """Return indices of provided lists that meet the selection criteria 

221 

222 Parameters: 

223 ----------- 

224 wcsList : `list` of `lsst.afw.geom.SkyWcs` 

225 specifying the WCS's of the input ccds to be selected 

226 bboxList : `list` of `lsst.geom.Box2I` 

227 specifying the bounding boxes of the input ccds to be selected 

228 coordList : `list` of `lsst.geom.SpherePoint` 

229 ICRS coordinates specifying boundary of the patch. 

230 

231 Returns: 

232 -------- 

233 result: `list` of `int` 

234 of indices of selected ccds 

235 """ 

236 if dataIds is None: 

237 dataIds = [None] * len(wcsList) 

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

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

240 result = [] 

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

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

243 if imageCorners: 

244 result.append(i) 

245 return result 

246 

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

248 "Return corners or None if bad" 

249 try: 

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

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

252 # Protecting ourselves from awful Wcs solutions in input images 

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

254 return 

255 

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

257 if imagePoly is None: 

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

259 return 

260 

261 if patchPoly.intersects(imagePoly): 

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

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

264 return imageCorners 

265 

266 

267def sigmaMad(array): 

268 "Return median absolute deviation scaled to normally distributed data" 

269 return 1.4826*np.median(np.abs(array - np.median(array))) 

270 

271 

272class PsfWcsSelectImagesConnections(pipeBase.PipelineTaskConnections, 

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

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

275 pass 

276 

277 

278class PsfWcsSelectImagesConfig(pipeBase.PipelineTaskConfig, 

279 pipelineConnections=PsfWcsSelectImagesConnections): 

280 maxEllipResidual = pexConfig.Field( 

281 doc="Maximum median ellipticity residual", 

282 dtype=float, 

283 default=0.007, 

284 optional=True, 

285 ) 

286 maxSizeScatter = pexConfig.Field( 

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

288 dtype=float, 

289 optional=True, 

290 ) 

291 maxScaledSizeScatter = pexConfig.Field( 

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

293 dtype=float, 

294 default=0.009, 

295 optional=True, 

296 ) 

297 starSelection = pexConfig.Field( 

298 doc="select star with this field", 

299 dtype=str, 

300 default='calib_psf_used', 

301 deprecated=('This field has been moved to ComputeExposureSummaryStatsTask and ' 

302 'will be removed after v24.') 

303 ) 

304 starShape = pexConfig.Field( 

305 doc="name of star shape", 

306 dtype=str, 

307 default='base_SdssShape', 

308 deprecated=('This field has been moved to ComputeExposureSummaryStatsTask and ' 

309 'will be removed after v24.') 

310 ) 

311 psfShape = pexConfig.Field( 

312 doc="name of psf shape", 

313 dtype=str, 

314 default='base_SdssShape_psf', 

315 deprecated=('This field has been moved to ComputeExposureSummaryStatsTask and ' 

316 'will be removed after v24.') 

317 ) 

318 doLegacyStarSelectionComputation = pexConfig.Field( 

319 doc="Perform the legacy star selection computations (for backwards compatibility)", 

320 dtype=bool, 

321 default=False, 

322 deprecated=("This field is here for backwards compatibility and will be " 

323 "removed after v24.") 

324 ) 

325 maxPsfTraceRadiusDelta = pexConfig.Field( 

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

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

328 dtype=float, 

329 default=0.7, 

330 optional=True, 

331 ) 

332 

333 

334class PsfWcsSelectImagesTask(WcsSelectImagesTask): 

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

336 

337 The PSF quality criteria are based on the size and ellipticity residuals from the 

338 adaptive second moments of the star and the PSF. 

339 

340 The criteria are: 

341 - the median of the ellipticty residuals 

342 - the robust scatter of the size residuals (using the median absolute deviation) 

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

344 the median size 

345 """ 

346 

347 ConfigClass = PsfWcsSelectImagesConfig 

348 _DefaultName = "PsfWcsSelectImages" 

349 

350 def runDataRef(self, dataRef, coordList, makeDataRefList=True, selectDataList=[]): 

351 """Select images in the selectDataList that overlap the patch and satisfy PSF quality critera. 

352 

353 This method is the old entry point for the Gen2 commandline tasks and drivers 

354 Will be deprecated in v22. 

355 

356 @param dataRef: Data reference for coadd/tempExp (with tract, patch) 

357 @param coordList: List of ICRS coordinates (lsst.geom.SpherePoint) specifying boundary of patch 

358 @param makeDataRefList: Construct a list of data references? 

359 @param selectDataList: List of SelectStruct, to consider for selection 

360 """ 

361 result = super(PsfWcsSelectImagesTask, self).runDataRef(dataRef, coordList, makeDataRefList, 

362 selectDataList) 

363 

364 dataRefList = [] 

365 exposureInfoList = [] 

366 for dataRef, exposureInfo in zip(result.dataRefList, result.exposureInfoList): 

367 butler = dataRef.butlerSubset.butler 

368 srcCatalog = butler.get('src', dataRef.dataId) 

369 valid = self.isValidLegacy(srcCatalog, dataRef.dataId) 

370 if valid is False: 

371 continue 

372 

373 dataRefList.append(dataRef) 

374 exposureInfoList.append(exposureInfo) 

375 

376 return pipeBase.Struct( 

377 dataRefList=dataRefList, 

378 exposureInfoList=exposureInfoList, 

379 ) 

380 

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

382 """Return indices of provided lists that meet the selection criteria 

383 

384 Parameters: 

385 ----------- 

386 wcsList : `list` of `lsst.afw.geom.SkyWcs` 

387 specifying the WCS's of the input ccds to be selected 

388 bboxList : `list` of `lsst.geom.Box2I` 

389 specifying the bounding boxes of the input ccds to be selected 

390 coordList : `list` of `lsst.geom.SpherePoint` 

391 ICRS coordinates specifying boundary of the patch. 

392 visitSummary : `list` of `lsst.afw.table.ExposureCatalog` 

393 containing the PSF shape information for the input ccds to be selected. 

394 srcList : `list` of `lsst.afw.table.SourceCatalog`, optional 

395 containing the PSF shape information for the input ccds to be selected. 

396 This is only used if ``config.doLegacyStarSelectionComputation`` is 

397 True. 

398 

399 Returns: 

400 -------- 

401 goodPsf: `list` of `int` 

402 of indices of selected ccds 

403 """ 

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

405 coordList=coordList, dataIds=dataIds) 

406 

407 goodPsf = [] 

408 

409 if not self.config.doLegacyStarSelectionComputation: 

410 # Check for old inputs, and give a helpful error message if so. 

411 if 'nPsfStar' not in visitSummary[0].schema.getNames(): 

412 raise RuntimeError("Old calexps detected. " 

413 "Please set config.doLegacyStarSelectionComputation=True for " 

414 "backwards compatibility.") 

415 

416 for i, dataId in enumerate(dataIds): 

417 if i not in goodWcs: 

418 continue 

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

420 goodPsf.append(i) 

421 else: 

422 if dataIds is None: 

423 dataIds = [None] * len(srcList) 

424 for i, (srcCatalog, dataId) in enumerate(zip(srcList, dataIds)): 

425 if i not in goodWcs: 

426 continue 

427 if self.isValidLegacy(srcCatalog, dataId): 

428 goodPsf.append(i) 

429 

430 return goodPsf 

431 

432 def isValid(self, visitSummary, detectorId): 

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

434 

435 Parameters 

436 ---------- 

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

438 detectorId : `int` 

439 Detector identifier. 

440 

441 Returns 

442 ------- 

443 valid : `bool` 

444 True if selected. 

445 """ 

446 row = visitSummary.find(detectorId) 

447 if row is None: 

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

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

450 return False 

451 

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

453 scatterSize = row["psfStarDeltaSizeScatter"] 

454 scaledScatterSize = row["psfStarScaledDeltaSizeScatter"] 

455 psfTraceRadiusDelta = row["psfTraceRadiusDelta"] 

456 

457 valid = True 

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

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

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

461 valid = False 

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

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

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

465 valid = False 

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

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

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

469 valid = False 

470 elif ( 

471 self.config.maxPsfTraceRadiusDelta is not None 

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

473 ): 

474 self.log.info( 

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

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

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

478 ) 

479 valid = False 

480 

481 return valid 

482 

483 def isValidLegacy(self, srcCatalog, dataId=None): 

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

485 

486 This routine is only used in legacy processing (gen2 and 

487 backwards compatible old calexps) and should be removed after v24. 

488 

489 Parameters 

490 ---------- 

491 srcCatalog : `lsst.afw.table.SourceCatalog` 

492 dataId : `dict` of dataId keys, optional. 

493 Used only for logging. Defaults to None. 

494 

495 Returns 

496 ------- 

497 valid : `bool` 

498 True if selected. 

499 """ 

500 mask = srcCatalog[self.config.starSelection] 

501 

502 starXX = srcCatalog[self.config.starShape+'_xx'][mask] 

503 starYY = srcCatalog[self.config.starShape+'_yy'][mask] 

504 starXY = srcCatalog[self.config.starShape+'_xy'][mask] 

505 psfXX = srcCatalog[self.config.psfShape+'_xx'][mask] 

506 psfYY = srcCatalog[self.config.psfShape+'_yy'][mask] 

507 psfXY = srcCatalog[self.config.psfShape+'_xy'][mask] 

508 

509 starSize = np.power(starXX*starYY - starXY**2, 0.25) 

510 starE1 = (starXX - starYY)/(starXX + starYY) 

511 starE2 = 2*starXY/(starXX + starYY) 

512 medianSize = np.median(starSize) 

513 

514 psfSize = np.power(psfXX*psfYY - psfXY**2, 0.25) 

515 psfE1 = (psfXX - psfYY)/(psfXX + psfYY) 

516 psfE2 = 2*psfXY/(psfXX + psfYY) 

517 

518 medianE1 = np.abs(np.median(starE1 - psfE1)) 

519 medianE2 = np.abs(np.median(starE2 - psfE2)) 

520 medianE = np.sqrt(medianE1**2 + medianE2**2) 

521 

522 scatterSize = sigmaMad(starSize - psfSize) 

523 scaledScatterSize = scatterSize/medianSize**2 

524 

525 valid = True 

526 if self.config.maxEllipResidual and medianE > self.config.maxEllipResidual: 

527 self.log.info("Removing visit %s because median e residual too large: %f vs %f", 

528 dataId, medianE, self.config.maxEllipResidual) 

529 valid = False 

530 elif self.config.maxSizeScatter and scatterSize > self.config.maxSizeScatter: 

531 self.log.info("Removing visit %s because size scatter is too large: %f vs %f", 

532 dataId, scatterSize, self.config.maxSizeScatter) 

533 valid = False 

534 elif self.config.maxScaledSizeScatter and scaledScatterSize > self.config.maxScaledSizeScatter: 

535 self.log.info("Removing visit %s because scaled size scatter is too large: %f vs %f", 

536 dataId, scaledScatterSize, self.config.maxScaledSizeScatter) 

537 valid = False 

538 

539 return valid 

540 

541 

542class BestSeeingSelectVisitsConnections(pipeBase.PipelineTaskConnections, 

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

544 defaultTemplates={"coaddName": "goodSeeing"}): 

545 skyMap = pipeBase.connectionTypes.Input( 

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

547 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME, 

548 storageClass="SkyMap", 

549 dimensions=("skymap",), 

550 ) 

551 visitSummaries = pipeBase.connectionTypes.Input( 

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

553 name="finalVisitSummary", 

554 storageClass="ExposureCatalog", 

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

556 multiple=True, 

557 deferLoad=True 

558 ) 

559 goodVisits = pipeBase.connectionTypes.Output( 

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

561 name="{coaddName}Visits", 

562 storageClass="StructuredDataDict", 

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

564 ) 

565 

566 

567class BestSeeingSelectVisitsConfig(pipeBase.PipelineTaskConfig, 

568 pipelineConnections=BestSeeingSelectVisitsConnections): 

569 nVisitsMax = pexConfig.RangeField( 

570 dtype=int, 

571 doc="Maximum number of visits to select", 

572 default=12, 

573 min=0 

574 ) 

575 maxPsfFwhm = pexConfig.Field( 

576 dtype=float, 

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

578 default=1.5, 

579 optional=True 

580 ) 

581 minPsfFwhm = pexConfig.Field( 

582 dtype=float, 

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

584 default=0., 

585 optional=True 

586 ) 

587 doConfirmOverlap = pexConfig.Field( 

588 dtype=bool, 

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

590 default=True, 

591 ) 

592 minMJD = pexConfig.Field( 

593 dtype=float, 

594 doc="Minimum visit MJD to select", 

595 default=None, 

596 optional=True 

597 ) 

598 maxMJD = pexConfig.Field( 

599 dtype=float, 

600 doc="Maximum visit MJD to select", 

601 default=None, 

602 optional=True 

603 ) 

604 

605 

606class BestSeeingSelectVisitsTask(pipeBase.PipelineTask): 

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

608 

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

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

611 BestSeeingSelectImagesTask. This Task selects full visits based on the 

612 average PSF of the entire visit. 

613 """ 

614 ConfigClass = BestSeeingSelectVisitsConfig 

615 _DefaultName = 'bestSeeingSelectVisits' 

616 

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

618 inputs = butlerQC.get(inputRefs) 

619 quantumDataId = butlerQC.quantum.dataId 

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

621 butlerQC.put(outputs, outputRefs) 

622 

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

624 """Run task 

625 

626 Parameters: 

627 ----------- 

628 visitSummary : `list` 

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

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

631 skyMap : `lsst.skyMap.SkyMap` 

632 SkyMap for checking visits overlap patch 

633 dataId : `dict` of dataId keys 

634 For retrieving patch info for checking visits overlap patch 

635 

636 Returns 

637 ------- 

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

639 Result struct with components: 

640 

641 - `goodVisits`: `dict` with selected visit ids as keys, 

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

643 StructuredDataList's are currently limited. 

644 """ 

645 

646 if self.config.doConfirmOverlap: 

647 patchPolygon = self.makePatchPolygon(skyMap, dataId) 

648 

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

650 fwhmSizes = [] 

651 visits = [] 

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

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

654 visitSummary = visitSummary.get() 

655 

656 # mjd is guaranteed to be the same for every detector in the visitSummary. 

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

658 

659 pixToArcseconds = [vs.getWcs().getPixelScale(vs.getBBox().getCenter()).asArcseconds() 

660 for vs in visitSummary] 

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

662 psfSigmas = np.array([vs['psfSigma'] for vs in visitSummary]) 

663 fwhm = np.nanmean(psfSigmas * pixToArcseconds) * np.sqrt(8.*np.log(2.)) 

664 

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

666 continue 

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

668 continue 

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

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

671 continue 

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

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

674 continue 

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

676 continue 

677 

678 fwhmSizes.append(fwhm) 

679 visits.append(visit) 

680 

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

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

683 self.log.info("%d images selected with FWHM range of %d--%d arcseconds", 

684 len(output), fwhmSizes[visits.index(output[0])], fwhmSizes[visits.index(output[-1])]) 

685 

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

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

688 return pipeBase.Struct(goodVisits=goodVisits) 

689 

690 def makePatchPolygon(self, skyMap, dataId): 

691 """Return True if sky polygon overlaps visit 

692 

693 Parameters: 

694 ----------- 

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

696 Exposure catalog with per-detector geometry 

697 dataId : `dict` of dataId keys 

698 For retrieving patch info 

699 

700 Returns: 

701 -------- 

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

703 Polygon of patch's outer bbox 

704 """ 

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

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

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

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

709 return result 

710 

711 def doesIntersectPolygon(self, visitSummary, polygon): 

712 """Return True if sky polygon overlaps visit 

713 

714 Parameters: 

715 ----------- 

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

717 Exposure catalog with per-detector geometry 

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

719 Polygon to check overlap 

720 

721 Returns: 

722 -------- 

723 doesIntersect: `bool` 

724 Does the visit overlap the polygon 

725 """ 

726 doesIntersect = False 

727 for detectorSummary in visitSummary: 

728 corners = [lsst.geom.SpherePoint(ra, decl, units=lsst.geom.degrees).getVector() for (ra, decl) in 

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

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

731 if detectorPolygon.intersects(polygon): 

732 doesIntersect = True 

733 break 

734 return doesIntersect 

735 

736 

737class BestSeeingQuantileSelectVisitsConfig(pipeBase.PipelineTaskConfig, 

738 pipelineConnections=BestSeeingSelectVisitsConnections): 

739 qMin = pexConfig.RangeField( 

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

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

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

743 dtype=float, 

744 default=0, 

745 min=0, 

746 max=1, 

747 ) 

748 qMax = pexConfig.RangeField( 

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

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

751 dtype=float, 

752 default=0.33, 

753 min=0, 

754 max=1, 

755 ) 

756 nVisitsMin = pexConfig.Field( 

757 doc="At least this number of visits selected and supercedes quantile. For example, if 10 visits " 

758 "cover this patch, qMin=0.33, and nVisitsMin=5, the best 5 visits will be selected.", 

759 dtype=int, 

760 default=6, 

761 ) 

762 doConfirmOverlap = pexConfig.Field( 

763 dtype=bool, 

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

765 default=True, 

766 ) 

767 minMJD = pexConfig.Field( 

768 dtype=float, 

769 doc="Minimum visit MJD to select", 

770 default=None, 

771 optional=True 

772 ) 

773 maxMJD = pexConfig.Field( 

774 dtype=float, 

775 doc="Maximum visit MJD to select", 

776 default=None, 

777 optional=True 

778 ) 

779 

780 

781class BestSeeingQuantileSelectVisitsTask(BestSeeingSelectVisitsTask): 

782 """Select a quantile of the best-seeing visits 

783 

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

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

786 experiments that require templates with the worst seeing visits. 

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

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

789 """ 

790 ConfigClass = BestSeeingQuantileSelectVisitsConfig 

791 _DefaultName = 'bestSeeingQuantileSelectVisits' 

792 

793 @utils.inheritDoc(BestSeeingSelectVisitsTask) 

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

795 if self.config.doConfirmOverlap: 

796 patchPolygon = self.makePatchPolygon(skyMap, dataId) 

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

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

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

800 for i, visitSummary in enumerate(visitSummaries): 

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

802 visitSummary = visitSummary.get() 

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

804 psfSigma = np.nanmedian([vs['psfSigma'] for vs in visitSummary]) 

805 radius[i] = psfSigma 

806 if self.config.doConfirmOverlap: 

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

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

809 # mjd is guaranteed to be the same for every detector in the visitSummary. 

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

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

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

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

814 

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

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

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

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

819 

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

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

822 return pipeBase.Struct(goodVisits=goodVisits)