Hide keyboard shortcuts

Hot-keys 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

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 

30 

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

32 "DatabaseSelectImagesConfig", "BestSeeingWcsSelectImagesTask", "BestSeeingSelectVisitsTask", 

33 "BestSeeingQuantileSelectVisitsTask"] 

34 

35 

36class DatabaseSelectImagesConfig(pexConfig.Config): 

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

38 host = pexConfig.Field( 

39 doc="Database server host name", 

40 dtype=str, 

41 ) 

42 port = pexConfig.Field( 

43 doc="Database server port", 

44 dtype=int, 

45 ) 

46 database = pexConfig.Field( 

47 doc="Name of database", 

48 dtype=str, 

49 ) 

50 maxExposures = pexConfig.Field( 

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

52 dtype=int, 

53 optional=True, 

54 ) 

55 

56 

57class BaseExposureInfo(pipeBase.Struct): 

58 """Data about a selected exposure 

59 """ 

60 

61 def __init__(self, dataId, coordList): 

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

63 

64 The object has the following fields: 

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

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

67 plus any others items that are desired 

68 """ 

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

70 

71 

72class BaseSelectImagesTask(pipeBase.Task): 

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

74 """ 

75 ConfigClass = pexConfig.Config 

76 _DefaultName = "selectImages" 

77 

78 @pipeBase.timeMethod 

79 def run(self, coordList): 

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

81 

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

83 subclasses may add additional keyword arguments, as required 

84 

85 @return a pipeBase Struct containing: 

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

87 which have at least the following fields: 

88 - dataId: data ID dictionary 

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

90 """ 

91 raise NotImplementedError() 

92 

93 def _runArgDictFromDataId(self, dataId): 

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

95 

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

97 """ 

98 raise NotImplementedError() 

99 

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

101 """Run based on a data reference 

102 

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

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

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

106 additional control over the selection. 

107 

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

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

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

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

112 @return a pipeBase Struct containing: 

113 - exposureInfoList: a list of objects derived from ExposureInfo 

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

115 """ 

116 runArgDict = self._runArgDictFromDataId(dataRef.dataId) 

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

118 

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

120 # Restrict the exposure selection further 

121 ccdKeys, ccdValues = _extractKeyValue(exposureInfoList) 

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

123 inValues = set(inValues) 

124 newExposureInfoList = [] 

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

126 if ccdVal in inValues: 

127 newExposureInfoList.append(info) 

128 else: 

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

130 exposureInfoList = newExposureInfoList 

131 

132 if makeDataRefList: 

133 butler = dataRef.butlerSubset.butler 

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

135 dataId=expInfo.dataId, 

136 ) for expInfo in exposureInfoList] 

137 else: 

138 dataRefList = None 

139 

140 return pipeBase.Struct( 

141 dataRefList=dataRefList, 

142 exposureInfoList=exposureInfoList, 

143 ) 

144 

145 

146def _extractKeyValue(dataList, keys=None): 

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

148 

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

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

151 list of ExposureInfo 

152 """ 

153 assert len(dataList) > 0 

154 if keys is None: 

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

156 keySet = set(keys) 

157 values = list() 

158 for data in dataList: 

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

160 if thisKeys != keySet: 

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

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

163 return keys, values 

164 

165 

166class SelectStruct(pipeBase.Struct): 

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

168 

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

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

171 

172 

173class WcsSelectImagesTask(BaseSelectImagesTask): 

174 """Select images using their Wcs 

175 

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

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

178 patch for overlap with the polygon of the image. 

179 

180 We use "convexHull" instead of generating a ConvexPolygon 

181 directly because the standard for the inputs to ConvexPolygon 

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

183 """ 

184 

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

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

187 

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

189 Will be deprecated in v22. 

190 

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

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

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

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

195 """ 

196 dataRefList = [] 

197 exposureInfoList = [] 

198 

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

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

201 

202 for data in selectDataList: 

203 dataRef = data.dataRef 

204 imageWcs = data.wcs 

205 imageBox = data.bbox 

206 

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

208 if imageCorners: 

209 dataRefList.append(dataRef) 

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

211 

212 return pipeBase.Struct( 

213 dataRefList=dataRefList if makeDataRefList else None, 

214 exposureInfoList=exposureInfoList, 

215 ) 

216 

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

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

219 

220 Parameters: 

221 ----------- 

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

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

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

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

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

227 ICRS coordinates specifying boundary of the patch. 

228 

229 Returns: 

230 -------- 

231 result: `list` of `int` 

232 of indices of selected ccds 

233 """ 

234 if dataIds is None: 

235 dataIds = [None] * len(wcsList) 

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

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

238 result = [] 

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

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

241 if imageCorners: 

242 result.append(i) 

243 return result 

244 

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

246 "Return corners or None if bad" 

247 try: 

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

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

250 # Protecting ourselves from awful Wcs solutions in input images 

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

252 return 

253 

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

255 if imagePoly is None: 

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

257 return 

258 

259 if patchPoly.intersects(imagePoly): 

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

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

262 return imageCorners 

263 

264 

265def sigmaMad(array): 

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

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

268 

269 

270class PsfWcsSelectImagesConnections(pipeBase.PipelineTaskConnections, 

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

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

273 pass 

274 

275 

276class PsfWcsSelectImagesConfig(pipeBase.PipelineTaskConfig, 

277 pipelineConnections=PsfWcsSelectImagesConnections): 

278 maxEllipResidual = pexConfig.Field( 

279 doc="Maximum median ellipticity residual", 

280 dtype=float, 

281 default=0.007, 

282 optional=True, 

283 ) 

284 maxSizeScatter = pexConfig.Field( 

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

286 dtype=float, 

287 optional=True, 

288 ) 

289 maxScaledSizeScatter = pexConfig.Field( 

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

291 dtype=float, 

292 default=0.009, 

293 optional=True, 

294 ) 

295 starSelection = pexConfig.Field( 

296 doc="select star with this field", 

297 dtype=str, 

298 default='calib_psf_used' 

299 ) 

300 starShape = pexConfig.Field( 

301 doc="name of star shape", 

302 dtype=str, 

303 default='base_SdssShape' 

304 ) 

305 psfShape = pexConfig.Field( 

306 doc="name of psf shape", 

307 dtype=str, 

308 default='base_SdssShape_psf' 

309 ) 

310 

311 

312class PsfWcsSelectImagesTask(WcsSelectImagesTask): 

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

314 

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

316 adaptive second moments of the star and the PSF. 

317 

318 The criteria are: 

319 - the median of the ellipticty residuals 

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

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

322 the median size 

323 """ 

324 

325 ConfigClass = PsfWcsSelectImagesConfig 

326 _DefaultName = "PsfWcsSelectImages" 

327 

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

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

330 

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

332 Will be deprecated in v22. 

333 

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

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

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

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

338 """ 

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

340 selectDataList) 

341 

342 dataRefList = [] 

343 exposureInfoList = [] 

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

345 butler = dataRef.butlerSubset.butler 

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

347 valid = self.isValid(srcCatalog, dataRef.dataId) 

348 if valid is False: 

349 continue 

350 

351 dataRefList.append(dataRef) 

352 exposureInfoList.append(exposureInfo) 

353 

354 return pipeBase.Struct( 

355 dataRefList=dataRefList, 

356 exposureInfoList=exposureInfoList, 

357 ) 

358 

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

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

361 

362 Parameters: 

363 ----------- 

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

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

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

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

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

369 ICRS coordinates specifying boundary of the patch. 

370 srcList : `list` of `lsst.afw.table.SourceCatalog` 

371 containing the PSF shape information for the input ccds to be selected 

372 

373 Returns: 

374 -------- 

375 goodPsf: `list` of `int` 

376 of indices of selected ccds 

377 """ 

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

379 coordList=coordList, dataIds=dataIds) 

380 

381 goodPsf = [] 

382 if dataIds is None: 

383 dataIds = [None] * len(srcList) 

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

385 if i not in goodWcs: 

386 continue 

387 if self.isValid(srcCatalog, dataId): 

388 goodPsf.append(i) 

389 

390 return goodPsf 

391 

392 def isValid(self, srcCatalog, dataId=None): 

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

394 

395 Parameters 

396 ---------- 

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

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

399 Used only for logging. Defaults to None. 

400 

401 Returns 

402 ------- 

403 valid : `bool` 

404 True if selected. 

405 """ 

406 mask = srcCatalog[self.config.starSelection] 

407 

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

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

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

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

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

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

414 

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

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

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

418 medianSize = np.median(starSize) 

419 

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

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

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

423 

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

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

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

427 

428 scatterSize = sigmaMad(starSize - psfSize) 

429 scaledScatterSize = scatterSize/medianSize**2 

430 

431 valid = True 

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

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

434 (dataId, medianE, self.config.maxEllipResidual)) 

435 valid = False 

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

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

438 (dataId, scatterSize, self.config.maxSizeScatter)) 

439 valid = False 

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

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

442 (dataId, scaledScatterSize, self.config.maxScaledSizeScatter)) 

443 valid = False 

444 

445 return valid 

446 

447 

448class BestSeeingWcsSelectImageConfig(WcsSelectImagesTask.ConfigClass): 

449 """Base configuration for BestSeeingSelectImagesTask. 

450 """ 

451 nImagesMax = pexConfig.RangeField( 

452 dtype=int, 

453 doc="Maximum number of images to select", 

454 default=5, 

455 min=0) 

456 maxPsfFwhm = pexConfig.Field( 

457 dtype=float, 

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

459 default=1.5, 

460 optional=True) 

461 minPsfFwhm = pexConfig.Field( 

462 dtype=float, 

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

464 default=0., 

465 optional=True) 

466 

467 

468class BestSeeingWcsSelectImagesTask(WcsSelectImagesTask): 

469 """Select up to a maximum number of the best-seeing images using their Wcs. 

470 """ 

471 ConfigClass = BestSeeingWcsSelectImageConfig 

472 

473 def runDataRef(self, dataRef, coordList, makeDataRefList=True, 

474 selectDataList=None): 

475 """Select the best-seeing images in the selectDataList that overlap the patch. 

476 

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

478 Will be deprecated in v22. 

479 

480 Parameters 

481 ---------- 

482 dataRef : `lsst.daf.persistence.ButlerDataRef` 

483 Data reference for coadd/tempExp (with tract, patch) 

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

485 List of ICRS sky coordinates specifying boundary of patch 

486 makeDataRefList : `boolean`, optional 

487 Construct a list of data references? 

488 selectDataList : `list` of `SelectStruct` 

489 List of SelectStruct, to consider for selection 

490 

491 Returns 

492 ------- 

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

494 Result struct with components: 

495 - ``exposureList``: the selected exposures 

496 (`list` of `lsst.pipe.tasks.selectImages.BaseExposureInfo`). 

497 - ``dataRefList``: the optional data references corresponding to 

498 each element of ``exposureList`` 

499 (`list` of `lsst.daf.persistence.ButlerDataRef`, or `None`). 

500 """ 

501 psfSizes = [] 

502 dataRefList = [] 

503 exposureInfoList = [] 

504 

505 if selectDataList is None: 

506 selectDataList = [] 

507 

508 result = super().runDataRef(dataRef, coordList, makeDataRefList=True, selectDataList=selectDataList) 

509 

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

511 cal = dataRef.get("calexp", immediate=True) 

512 

513 # if min/max PSF values are defined, remove images out of bounds 

514 pixToArcseconds = cal.getWcs().getPixelScale().asArcseconds() 

515 psfSize = cal.getPsf().computeShape().getDeterminantRadius()*pixToArcseconds 

516 sizeFwhm = psfSize * np.sqrt(8.*np.log(2.)) 

517 if self.config.maxPsfFwhm and sizeFwhm > self.config.maxPsfFwhm: 

518 continue 

519 if self.config.minPsfFwhm and sizeFwhm < self.config.minPsfFwhm: 

520 continue 

521 psfSizes.append(sizeFwhm) 

522 dataRefList.append(dataRef) 

523 exposureInfoList.append(exposureInfo) 

524 

525 if len(psfSizes) > self.config.nImagesMax: 

526 sortedIndices = np.argsort(psfSizes)[:self.config.nImagesMax] 

527 filteredDataRefList = [dataRefList[i] for i in sortedIndices] 

528 filteredExposureInfoList = [exposureInfoList[i] for i in sortedIndices] 

529 self.log.info(f"{len(sortedIndices)} images selected with FWHM " 

530 f"range of {psfSizes[sortedIndices[0]]}--{psfSizes[sortedIndices[-1]]} arcseconds") 

531 

532 else: 

533 if len(psfSizes) == 0: 

534 self.log.warn("0 images selected.") 

535 else: 

536 self.log.debug(f"{len(psfSizes)} images selected with FWHM range " 

537 f"of {psfSizes[0]}--{psfSizes[-1]} arcseconds") 

538 filteredDataRefList = dataRefList 

539 filteredExposureInfoList = exposureInfoList 

540 

541 return pipeBase.Struct( 

542 dataRefList=filteredDataRefList if makeDataRefList else None, 

543 exposureInfoList=filteredExposureInfoList, 

544 ) 

545 

546 

547class BestSeeingSelectVisitsConnections(pipeBase.PipelineTaskConnections, 

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

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

550 skyMap = pipeBase.connectionTypes.Input( 

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

552 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME, 

553 storageClass="SkyMap", 

554 dimensions=("skymap",), 

555 ) 

556 visitSummaries = pipeBase.connectionTypes.Input( 

557 doc="Per-visit consolidated exposure metadata from ConsolidateVisitSummaryTask", 

558 name="visitSummary", 

559 storageClass="ExposureCatalog", 

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

561 multiple=True, 

562 deferLoad=True 

563 ) 

564 goodVisits = pipeBase.connectionTypes.Output( 

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

566 name="{coaddName}Visits", 

567 storageClass="StructuredDataDict", 

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

569 ) 

570 

571 

572class BestSeeingSelectVisitsConfig(pipeBase.PipelineTaskConfig, 

573 pipelineConnections=BestSeeingSelectVisitsConnections): 

574 nVisitsMax = pexConfig.RangeField( 

575 dtype=int, 

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

577 default=12, 

578 min=0 

579 ) 

580 maxPsfFwhm = pexConfig.Field( 

581 dtype=float, 

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

583 default=1.5, 

584 optional=True 

585 ) 

586 minPsfFwhm = pexConfig.Field( 

587 dtype=float, 

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

589 default=0., 

590 optional=True 

591 ) 

592 doConfirmOverlap = pexConfig.Field( 

593 dtype=bool, 

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

595 default=True, 

596 ) 

597 

598 

599class BestSeeingSelectVisitsTask(pipeBase.PipelineTask): 

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

601 

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

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

604 BestSeeingSelectImagesTask. This Task selects full visits based on the 

605 average PSF of the entire visit. 

606 """ 

607 ConfigClass = BestSeeingSelectVisitsConfig 

608 _DefaultName = 'bestSeeingSelectVisits' 

609 

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

611 inputs = butlerQC.get(inputRefs) 

612 quantumDataId = butlerQC.quantum.dataId 

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

614 butlerQC.put(outputs, outputRefs) 

615 

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

617 """Run task 

618 

619 Parameters: 

620 ----------- 

621 visitSummary : `list` 

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

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

624 skyMap : `lsst.skyMap.SkyMap` 

625 SkyMap for checking visits overlap patch 

626 dataId : `dict` of dataId keys 

627 For retrieving patch info for checking visits overlap patch 

628 

629 Returns 

630 ------- 

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

632 Result struct with components: 

633 

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

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

636 StructuredDataList's are currently limited. 

637 """ 

638 

639 if self.config.doConfirmOverlap: 

640 patchPolygon = self.makePatchPolygon(skyMap, dataId) 

641 

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

643 fwhmSizes = [] 

644 visits = [] 

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

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

647 visitSummary = visitSummary.get() 

648 

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

650 for vs in visitSummary] 

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

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

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

654 

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

656 continue 

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

658 continue 

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

660 continue 

661 

662 fwhmSizes.append(fwhm) 

663 visits.append(visit) 

664 

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

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

667 self.log.info(f"{len(output)} images selected with FWHM " 

668 f"range of {fwhmSizes[visits.index(output[0])]}" 

669 f"--{fwhmSizes[visits.index(output[-1])]} arcseconds") 

670 

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

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

673 return pipeBase.Struct(goodVisits=goodVisits) 

674 

675 def makePatchPolygon(self, skyMap, dataId): 

676 """Return True if sky polygon overlaps visit 

677 

678 Parameters: 

679 ----------- 

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

681 Exposure catalog with per-detector geometry 

682 dataId : `dict` of dataId keys 

683 For retrieving patch info 

684 

685 Returns: 

686 -------- 

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

688 Polygon of patch's outer bbox 

689 """ 

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

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

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

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

694 return result 

695 

696 def doesIntersectPolygon(self, visitSummary, polygon): 

697 """Return True if sky polygon overlaps visit 

698 

699 Parameters: 

700 ----------- 

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

702 Exposure catalog with per-detector geometry 

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

704 Polygon to check overlap 

705 

706 Returns: 

707 -------- 

708 doesIntersect: `bool` 

709 Does the visit overlap the polygon 

710 """ 

711 doesIntersect = False 

712 for detectorSummary in visitSummary: 

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

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

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

716 if detectorPolygon.intersects(polygon): 

717 doesIntersect = True 

718 break 

719 return doesIntersect 

720 

721 

722class BestSeeingQuantileSelectVisitsConfig(pipeBase.PipelineTaskConfig, 

723 pipelineConnections=BestSeeingSelectVisitsConnections): 

724 qMin = pexConfig.RangeField( 

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

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

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

728 dtype=float, 

729 default=0, 

730 min=0, 

731 max=1, 

732 ) 

733 qMax = pexConfig.RangeField( 

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

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

736 dtype=float, 

737 default=0.33, 

738 min=0, 

739 max=1, 

740 ) 

741 nVisitsMin = pexConfig.Field( 

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

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

744 dtype=int, 

745 default=3, 

746 ) 

747 doConfirmOverlap = pexConfig.Field( 

748 dtype=bool, 

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

750 default=True, 

751 ) 

752 

753 

754class BestSeeingQuantileSelectVisitsTask(BestSeeingSelectVisitsTask): 

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

756 

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

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

759 experiments that require templates with the worst seeing visits. 

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

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

762 """ 

763 ConfigClass = BestSeeingQuantileSelectVisitsConfig 

764 _DefaultName = 'bestSeeingQuantileSelectVisits' 

765 

766 @utils.inheritDoc(BestSeeingSelectVisitsTask) 

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

768 if self.config.doConfirmOverlap: 

769 patchPolygon = self.makePatchPolygon(skyMap, dataId) 

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

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

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

773 for i, visitSummary in enumerate(visitSummaries): 

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

775 visitSummary = visitSummary.get() 

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

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

778 radius[i] = psfSigma 

779 if self.config.doConfirmOverlap: 

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

781 

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

783 lowerBound = min(int(np.round(self.config.qMin*len(visits))), 

784 max(0, len(visits) - self.config.nVisitsMin)) 

785 upperBound = max(int(np.round(self.config.qMax*len(visits))), self.config.nVisitsMin) 

786 

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

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

789 return pipeBase.Struct(goodVisits=goodVisits)