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

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
31__all__ = ["BaseSelectImagesTask", "BaseExposureInfo", "WcsSelectImagesTask", "PsfWcsSelectImagesTask",
32 "DatabaseSelectImagesConfig", "BestSeeingWcsSelectImagesTask", "BestSeeingSelectVisitsTask",
33 "BestSeeingQuantileSelectVisitsTask"]
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 )
57class BaseExposureInfo(pipeBase.Struct):
58 """Data about a selected exposure
59 """
61 def __init__(self, dataId, coordList):
62 """Create exposure information that can be used to generate data references
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)
72class BaseSelectImagesTask(pipeBase.Task):
73 """Base task for selecting images suitable for coaddition
74 """
75 ConfigClass = pexConfig.Config
76 _DefaultName = "selectImages"
78 @pipeBase.timeMethod
79 def run(self, coordList):
80 """Select images suitable for coaddition in a particular region
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
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()
93 def _runArgDictFromDataId(self, dataId):
94 """Extract keyword arguments for run (other than coordList) from a data ID
96 @return keyword arguments for run (other than coordList), as a dict
97 """
98 raise NotImplementedError()
100 def runDataRef(self, dataRef, coordList, makeDataRefList=True, selectDataList=[]):
101 """Run based on a data reference
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.
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
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
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
140 return pipeBase.Struct(
141 dataRefList=dataRefList,
142 exposureInfoList=exposureInfoList,
143 )
146def _extractKeyValue(dataList, keys=None):
147 """Extract the keys and values from a list of dataIds
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
166class SelectStruct(pipeBase.Struct):
167 """A container for data to be passed to the WcsSelectImagesTask"""
169 def __init__(self, dataRef, wcs, bbox):
170 super(SelectStruct, self).__init__(dataRef=dataRef, wcs=wcs, bbox=bbox)
173class WcsSelectImagesTask(BaseSelectImagesTask):
174 """Select images using their Wcs
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.
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 """
185 def runDataRef(self, dataRef, coordList, makeDataRefList=True, selectDataList=[]):
186 """Select images in the selectDataList that overlap the patch
188 This method is the old entry point for the Gen2 commandline tasks and drivers
189 Will be deprecated in v22.
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 = []
199 patchVertices = [coord.getVector() for coord in coordList]
200 patchPoly = lsst.sphgeom.ConvexPolygon.convexHull(patchVertices)
202 for data in selectDataList:
203 dataRef = data.dataRef
204 imageWcs = data.wcs
205 imageBox = data.bbox
207 imageCorners = self.getValidImageCorners(imageWcs, imageBox, patchPoly, dataId=None)
208 if imageCorners:
209 dataRefList.append(dataRef)
210 exposureInfoList.append(BaseExposureInfo(dataRef.dataId, imageCorners))
212 return pipeBase.Struct(
213 dataRefList=dataRefList if makeDataRefList else None,
214 exposureInfoList=exposureInfoList,
215 )
217 def run(self, wcsList, bboxList, coordList, dataIds=None, **kwargs):
218 """Return indices of provided lists that meet the selection criteria
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.
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
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
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
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
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)))
270class PsfWcsSelectImagesConnections(pipeBase.PipelineTaskConnections,
271 dimensions=("tract", "patch", "skymap", "instrument", "visit"),
272 defaultTemplates={"coaddName": "deep"}):
273 pass
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 )
312class PsfWcsSelectImagesTask(WcsSelectImagesTask):
313 """Select images using their Wcs and cuts on the PSF properties
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.
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 """
325 ConfigClass = PsfWcsSelectImagesConfig
326 _DefaultName = "PsfWcsSelectImages"
328 def runDataRef(self, dataRef, coordList, makeDataRefList=True, selectDataList=[]):
329 """Select images in the selectDataList that overlap the patch and satisfy PSF quality critera.
331 This method is the old entry point for the Gen2 commandline tasks and drivers
332 Will be deprecated in v22.
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)
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
351 dataRefList.append(dataRef)
352 exposureInfoList.append(exposureInfo)
354 return pipeBase.Struct(
355 dataRefList=dataRefList,
356 exposureInfoList=exposureInfoList,
357 )
359 def run(self, wcsList, bboxList, coordList, srcList, dataIds=None, **kwargs):
360 """Return indices of provided lists that meet the selection criteria
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
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)
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)
390 return goodPsf
392 def isValid(self, srcCatalog, dataId=None):
393 """Should this ccd be selected based on its PSF shape information
395 Parameters
396 ----------
397 srcCatalog : `lsst.afw.table.SourceCatalog`
398 dataId : `dict` of dataId keys, optional.
399 Used only for logging. Defaults to None.
401 Returns
402 -------
403 valid : `bool`
404 True if selected.
405 """
406 mask = srcCatalog[self.config.starSelection]
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]
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)
420 psfSize = np.power(psfXX*psfYY - psfXY**2, 0.25)
421 psfE1 = (psfXX - psfYY)/(psfXX + psfYY)
422 psfE2 = 2*psfXY/(psfXX + psfYY)
424 medianE1 = np.abs(np.median(starE1 - psfE1))
425 medianE2 = np.abs(np.median(starE2 - psfE2))
426 medianE = np.sqrt(medianE1**2 + medianE2**2)
428 scatterSize = sigmaMad(starSize - psfSize)
429 scaledScatterSize = scatterSize/medianSize**2
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
445 return valid
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)
468class BestSeeingWcsSelectImagesTask(WcsSelectImagesTask):
469 """Select up to a maximum number of the best-seeing images using their Wcs.
470 """
471 ConfigClass = BestSeeingWcsSelectImageConfig
473 def runDataRef(self, dataRef, coordList, makeDataRefList=True,
474 selectDataList=None):
475 """Select the best-seeing images in the selectDataList that overlap the patch.
477 This method is the old entry point for the Gen2 commandline tasks and drivers
478 Will be deprecated in v22.
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
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 = []
505 if selectDataList is None:
506 selectDataList = []
508 result = super().runDataRef(dataRef, coordList, makeDataRefList=True, selectDataList=selectDataList)
510 for dataRef, exposureInfo in zip(result.dataRefList, result.exposureInfoList):
511 cal = dataRef.get("calexp", immediate=True)
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)
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")
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
541 return pipeBase.Struct(
542 dataRefList=filteredDataRefList if makeDataRefList else None,
543 exposureInfoList=filteredExposureInfoList,
544 )
547class BestSeeingSelectVisitsConnections(pipeBase.PipelineTaskConnections,
548 dimensions=("tract", "patch", "skymap", "band", "instrument"),
549 defaultTemplates={"coaddName": "bestSeeing"}):
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}VisitsDict",
567 storageClass="StructuredDataDict",
568 dimensions=("instrument", "tract", "patch", "skymap", "band"),
569 )
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 )
599class BestSeeingSelectVisitsTask(pipeBase.PipelineTask):
600 """Select up to a maximum number of the best-seeing visits
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'
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)
616 def run(self, visitSummaries, skyMap, dataId):
617 """Run task
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
629 Returns
630 -------
631 result : `lsst.pipe.base.Struct`
632 Result struct with components:
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 """
639 if self.config.doConfirmOverlap:
640 patchPolygon = self.makePatchPolygon(skyMap, dataId)
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()
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.))
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
662 fwhmSizes.append(fwhm)
663 visits.append(visit)
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")
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)
675 def makePatchPolygon(self, skyMap, dataId):
676 """Return True if sky polygon overlaps visit
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
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
696 def doesIntersectPolygon(self, visitSummary, polygon):
697 """Return True if sky polygon overlaps visit
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
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
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 )
754class BestSeeingQuantileSelectVisitsTask(BestSeeingSelectVisitsTask):
755 """Select a quantile of the best-seeing visits
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'
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)
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)
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)