lsst.pipe.tasks g253578fa50+c1a9b1f270
Loading...
Searching...
No Matches
selectImages.py
Go to the documentation of this file.
1# This file is part of pipe_tasks.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
21
22__all__ = ["BaseSelectImagesTask", "BaseExposureInfo", "WcsSelectImagesTask", "PsfWcsSelectImagesTask",
23 "DatabaseSelectImagesConfig", "BestSeeingSelectVisitsTask",
24 "BestSeeingQuantileSelectVisitsTask"]
25
26import numpy as np
27import lsst.sphgeom
28import lsst.pex.config as pexConfig
29import lsst.pex.exceptions as pexExceptions
30import lsst.geom as geom
31import lsst.pipe.base as pipeBase
32from lsst.skymap import BaseSkyMap
33from lsst.daf.base import DateTime
34from lsst.utils.timer import timeMethod
35
36
37def _meetsVisitSummaryMinValues(visit, visitSummary, visitSummaryMinValues, logger=None):
38 """Check if the visitSummary meets minimum values in visitSummaryMinValues.
39
40 Parameters
41 ----------
42 visit : `int`
43 Visit number.
44 visitSummary : `lsst.afw.table.ExposureCatalog`
45 Exposure catalog with per-detector summary information.
46 visitSummaryMinValues : `dict`
47 Dictionary with column names as keys and minimum allowed values as values.
48 logger : `lsst.log.Logger`
49 Logger to log debug and warning messages.
50
51 Returns
52 -------
53 result : `bool`
54 True if all columns in visitSummary meet the minimum values specified
55 in visitSummaryMinValues, False otherwise.
56 """
57 for columnName, minThreshold in visitSummaryMinValues.items():
58 # Get values for this column across all detectors with a valid WCS
59 values = np.asarray([vs[columnName] for vs in visitSummary if vs.getWcs()])
60 meanValue = np.nanmean(values)
61 if meanValue < minThreshold:
62 if logger is not None:
63 logger.info(f'Rejecting visit {visit}: mean {columnName} ({meanValue:.3f}) '
64 f'is below the minimum threshold ({minThreshold:.3f}).')
65 return False
66 return True
67
68
69class DatabaseSelectImagesConfig(pexConfig.Config):
70 """Base configuration for subclasses of BaseSelectImagesTask that use a
71 database.
72 """
73
74 host = pexConfig.Field(
75 doc="Database server host name",
76 dtype=str,
77 )
78 port = pexConfig.Field(
79 doc="Database server port",
80 dtype=int,
81 )
82 database = pexConfig.Field(
83 doc="Name of database",
84 dtype=str,
85 )
86 maxExposures = pexConfig.Field(
87 doc="maximum exposures to select; intended for debugging; ignored if None",
88 dtype=int,
89 optional=True,
90 )
91
92
93class BaseExposureInfo(pipeBase.Struct):
94 """Data about a selected exposure.
95
96 Parameters
97 ----------
98 dataId : `dict`
99 Data ID keys of exposure.
100 coordList : `list` [`lsst.afw.geom.SpherePoint`]
101 ICRS coordinates of the corners of the exposure
102 plus any others items that are desired.
103 """
104
105 def __init__(self, dataId, coordList):
106 super(BaseExposureInfo, self).__init__(dataId=dataId, coordList=coordList)
107
108
109class BaseSelectImagesTask(pipeBase.Task):
110 """Base task for selecting images suitable for coaddition.
111 """
112
113 ConfigClass = pexConfig.Config
114 _DefaultName = "selectImages"
115
116 @timeMethod
117 def run(self, coordList):
118 """Select images suitable for coaddition in a particular region.
119
120 Parameters
121 ----------
122 coordList : `list` [`lsst.geom.SpherePoint`] or `None`
123 List of coordinates defining region of interest; if `None`, then
124 select all images subclasses may add additional keyword arguments,
125 as required.
126
127 Returns
128 -------
129 result : `pipeBase.Struct`
130 Results as a struct with attributes:
131
132 ``exposureInfoList``
133 A list of exposure information objects (subclasses of
134 BaseExposureInfo), which have at least the following fields:
135 - dataId: Data ID dictionary (`dict`).
136 - coordList: ICRS coordinates of the corners of the exposure.
137 (`list` [`lsst.geom.SpherePoint`])
138 """
139 raise NotImplementedError()
140
141
142def _extractKeyValue(dataList, keys=None):
143 """Extract the keys and values from a list of dataIds.
144
145 The input dataList is a list of objects that have 'dataId' members.
146 This allows it to be used for both a list of data references and a
147 list of ExposureInfo.
148
149 Parameters
150 ----------
151 dataList : `Unknown`
152 keys : `Unknown`
153
154 Returns
155 -------
156 keys : `Unknown`
157 values : `Unknown`
158
159 Raises
160 ------
161 RuntimeError
162 Raised if DataId keys are inconsistent.
163 """
164 assert len(dataList) > 0
165 if keys is None:
166 keys = sorted(dataList[0].dataId.keys())
167 keySet = set(keys)
168 values = list()
169 for data in dataList:
170 thisKeys = set(data.dataId.keys())
171 if thisKeys != keySet:
172 raise RuntimeError("DataId keys inconsistent: %s vs %s" % (keySet, thisKeys))
173 values.append(tuple(data.dataId[k] for k in keys))
174 return keys, values
175
176
177class SelectStruct(pipeBase.Struct):
178 """A container for data to be passed to the WcsSelectImagesTask.
179
180 Parameters
181 ----------
182 dataRef : `Unknown`
183 Data reference.
184 wcs : `lsst.afw.geom.SkyWcs`
185 Coordinate system definition (wcs).
186 bbox : `lsst.geom.box.Box2I`
187 Integer bounding box for image.
188 """
189
190 def __init__(self, dataRef, wcs, bbox):
191 super(SelectStruct, self).__init__(dataRef=dataRef, wcs=wcs, bbox=bbox)
192
193
194class WcsSelectImagesConfig(pexConfig.Config):
195 excludeDetectors = pexConfig.ListField(
196 dtype=int,
197 default=[],
198 doc="Detectors to exclude from selection.",
199 optional=True,
200 )
201
202
204 """Select images using their Wcs.
205
206 We use the "convexHull" method of lsst.sphgeom.ConvexPolygon to define
207 polygons on the celestial sphere, and test the polygon of the
208 patch for overlap with the polygon of the image.
209
210 We use "convexHull" instead of generating a ConvexPolygon
211 directly because the standard for the inputs to ConvexPolygon
212 are pretty high and we don't want to be responsible for reaching them.
213 """
214
215 ConfigClass = WcsSelectImagesConfig
216 _DefaultName = "WcsSelectImages"
217
218 def run(self, wcsList, bboxList, coordList, dataIds=None, **kwargs):
219 """Return indices of provided lists that meet the selection criteria.
220
221 Parameters
222 ----------
223 wcsList : `list` [`lsst.afw.geom.SkyWcs`]
224 Specifying the WCS's of the input ccds to be selected.
225 bboxList : `list` [`lsst.geom.Box2I`]
226 Specifying the bounding boxes of the input ccds to be selected.
227 coordList : `list` [`lsst.geom.SpherePoint`]
228 ICRS coordinates specifying boundary of the patch.
229 dataIds : iterable [`lsst.daf.butler.dataId`] or `None`, optional
230 An iterable object of dataIds which point to reference catalogs.
231 **kwargs
232 Additional keyword arguments.
233
234 Returns
235 -------
236 result : `list` [`int`]
237 The indices of selected ccds.
238 """
239 if dataIds is None:
240 dataIds = [None] * len(wcsList)
241 patchVertices = [coord.getVector() for coord in coordList]
242 patchPoly = lsst.sphgeom.ConvexPolygon.convexHull(patchVertices)
243 result = []
244 for i, (imageWcs, imageBox, dataId) in enumerate(zip(wcsList, bboxList, dataIds)):
245 if dataId and ("detector" in dataId) and (dataId["detector"] in self.config.excludeDetectors):
246 self.log.info("De-selecting exposure %s because detector %s is excluded from processing",
247 dataId, dataId["detector"])
248 elif imageWcs is None:
249 self.log.info("De-selecting exposure %s: Exposure has no WCS.", dataId)
250 else:
251 imageCorners = self.getValidImageCorners(imageWcs, imageBox, patchPoly, dataId)
252 if imageCorners:
253 result.append(i)
254 return result
255
256 def getValidImageCorners(self, imageWcs, imageBox, patchPoly, dataId=None):
257 """Return corners or `None` if bad.
258
259 Parameters
260 ----------
261 imageWcs : `Unknown`
262 imageBox : `Unknown`
263 patchPoly : `Unknown`
264 dataId : `Unknown`
265 """
266 try:
267 imageCorners = [imageWcs.pixelToSky(pix) for pix in geom.Box2D(imageBox).getCorners()]
268 except (pexExceptions.DomainError, pexExceptions.RuntimeError) as e:
269 # Protecting ourselves from awful Wcs solutions in input images
270 self.log.debug("WCS error in testing calexp %s (%s): deselecting", dataId, e)
271 return None
272
273 imagePoly = lsst.sphgeom.ConvexPolygon.convexHull([coord.getVector() for coord in imageCorners])
274 if imagePoly is None:
275 self.log.debug("Unable to create polygon from image %s: deselecting", dataId)
276 return None
277
278 if patchPoly.intersects(imagePoly):
279 # "intersects" also covers "contains" or "is contained by"
280 self.log.info("Selecting calexp %s", dataId)
281 return imageCorners
282
283 return None
284
285
286class PsfWcsSelectImagesConnections(pipeBase.PipelineTaskConnections,
287 dimensions=("tract", "patch", "skymap", "instrument", "visit"),
288 defaultTemplates={"coaddName": "deep"}):
289 pass
290
291
292class PsfWcsSelectImagesConfig(pipeBase.PipelineTaskConfig,
293 pipelineConnections=PsfWcsSelectImagesConnections):
294 maxPsfFwhm = pexConfig.Field(
295 doc="Maximum PSF FWHM (in arcseconds) to select. This can be used in conjunction with the "
296 "per-visit maxPsfFwhm in BestSeeingSelectVisitsTask to ensure that no outlier CCD has a "
297 "PSF FWHM larger than this value.",
298 dtype=float,
299 default=1.8,
300 optional=True,
301 )
302 maxEllipResidual = pexConfig.Field(
303 doc="Maximum median ellipticity residual",
304 dtype=float,
305 default=0.007,
306 optional=True,
307 )
308 maxSizeScatter = pexConfig.Field(
309 doc="Maximum scatter in the size residuals",
310 dtype=float,
311 optional=True,
312 )
313 maxScaledSizeScatter = pexConfig.Field(
314 doc="Maximum scatter in the size residuals, scaled by the median size",
315 dtype=float,
316 default=0.019,
317 optional=True,
318 )
319 maxPsfTraceRadiusDelta = pexConfig.Field(
320 doc="Maximum delta (max - min) of model PSF trace radius values evaluated on a grid on "
321 "the unmasked detector pixels (pixel).",
322 dtype=float,
323 default=0.7,
324 optional=True,
325 )
326 maxPsfApFluxDelta = pexConfig.Field(
327 doc="Maximum delta (max - min) of model PSF aperture flux (with aperture radius of "
328 "max(2, 3*psfSigma)) values evaluated on a grid on the unmasked detector pixels (based "
329 "on a normalized-to-one flux).",
330 dtype=float,
331 default=0.24,
332 optional=True,
333 )
334 maxPsfApCorrSigmaScaledDelta = pexConfig.Field(
335 doc="Maximum delta (max - min) of model PSF aperture correction values evaluated on a grid "
336 "on the unmasked detector pixels scaled (divided) by the measured model psfSigma.",
337 dtype=float,
338 default=0.22,
339 optional=True,
340 )
341 minNPsfStarPerBand = pexConfig.DictField(
342 keytype=str,
343 itemtype=int,
344 default={
345 "u": 6,
346 "g": 6,
347 "r": 6,
348 "i": 6,
349 "z": 6,
350 "y": 6,
351 "fallback": 6,
352 },
353 doc="Minimum number of PSF stars for the final PSF model to be considered "
354 "well-constrained and suitible for inclusion in the coadd. This number should "
355 "take into consideration the spatial order used for the PSF model. If the current "
356 "band for the exposure is not included as a key in this dict, the value associated "
357 "with the \"fallback\" key will be used.",
358 )
359 maxStarEPerBand = pexConfig.DictField(
360 keytype=str,
361 itemtype=float,
362 default={
363 "u": 0.4,
364 "g": 0.4,
365 "r": 0.4,
366 "i": 0.4,
367 "z": 0.4,
368 "y": 0.4,
369 "fallback": 0.4,
370 },
371 doc="Maximum median of the ellipticity (sqrt(starE1**2.0 + starE2**2.0)) "
372 "distribution of the star sources used in the PSF model. If the current band "
373 "for the exposure is not included as a key in this dict, the value associated "
374 "with the \"fallback\" key will be used.",
375 )
376 maxStarUnNormalizedEPerBand = pexConfig.DictField(
377 keytype=str,
378 itemtype=float,
379 default={
380 "u": 2.8,
381 "g": 2.8,
382 "r": 2.8,
383 "i": 2.8,
384 "z": 2.8,
385 "y": 2.8,
386 "fallback": 2.8,
387 },
388 doc="Maximum median of the unnormalized ellipticity "
389 "(sqrt((starXX - starYY)**2.0 + (2.0*starXY)**2.0)) distribution of the star "
390 "sources used in the PSF model. If the current band for the exposure is not "
391 "included as a key in this dict, the value associated with the \"fallback\" key "
392 "will be used.",
393 )
394 excludeDetectors = pexConfig.ListField(
395 dtype=int,
396 default=[],
397 doc="Detectors to exclude from selection.",
398 optional=True,
399 )
400
401 def validate(self):
402 super().validate()
403 if "fallback" not in self.minNPsfStarPerBand:
404 msg = ("Must include a \"fallback\" key in the config.minNPsfStarPerBand config dict. "
405 f"It is currently: {self.minNPsfStarPerBand}.")
406 raise pexConfig.FieldValidationError(self.__class__.minNPsfStarPerBand, self, msg)
407 if "fallback" not in self.maxStarEPerBand:
408 msg = ("Must include a \"fallback\" key in the config.maxStarEPerBand config dict. "
409 f"It is currently: {self.maxStarEPerBand}.")
410 raise pexConfig.FieldValidationError(self.__class__.maxStarEPerBand, self, msg)
411 if "fallback" not in self.maxStarUnNormalizedEPerBand:
412 msg = ("Must include a \"fallback\" key in the config.maxStarUnNormalizedEPerBand "
413 f"config dict. It is currently: {self.maxStarUnNormalizedEPerBand}.")
414 raise pexConfig.FieldValidationError(self.__class__.maxStarUnNormalizedEPerBand, self, msg)
415
416
418 """Select images using their Wcs and cuts on the PSF properties.
419
420 The PSF quality criteria are based on the size and ellipticity
421 residuals from the adaptive second moments of the star and the PSF.
422
423 The criteria are:
424 - the median of the ellipticty residuals.
425 - the robust scatter of the size residuals (using the median absolute
426 deviation).
427 - the robust scatter of the size residuals scaled by the square of
428 the median size.
429 """
430
431 ConfigClass = PsfWcsSelectImagesConfig
432 _DefaultName = "PsfWcsSelectImages"
433
434 def run(self, wcsList, bboxList, coordList, visitSummary, dataIds=None, **kwargs):
435 """Return indices of provided lists that meet the selection criteria.
436
437 Parameters
438 ----------
439 wcsList : `list` [`lsst.afw.geom.SkyWcs`]
440 Specifying the WCS's of the input ccds to be selected.
441 bboxList : `list` [`lsst.geom.Box2I`]
442 Specifying the bounding boxes of the input ccds to be selected.
443 coordList : `list` [`lsst.geom.SpherePoint`]
444 ICRS coordinates specifying boundary of the patch.
445 visitSummary : `list` [`lsst.afw.table.ExposureCatalog`]
446 containing the PSF shape information for the input ccds to be
447 selected.
448 dataIds : iterable [`lsst.daf.butler.dataId`] or `None`, optional
449 An iterable object of dataIds which point to reference catalogs.
450 **kwargs
451 Additional keyword arguments.
452
453 Returns
454 -------
455 goodPsf : `list` [`int`]
456 The indices of selected ccds.
457 """
458 goodWcs = super(PsfWcsSelectImagesTask, self).run(wcsList=wcsList, bboxList=bboxList,
459 coordList=coordList, dataIds=dataIds)
460
461 goodPsf = []
462
463 for i, dataId in enumerate(dataIds):
464 if i not in goodWcs:
465 continue
466 if self.isValid(visitSummary, dataId["detector"]):
467 goodPsf.append(i)
468
469 return goodPsf
470
471 def isValid(self, visitSummary, detectorId):
472 """Should this ccd be selected based on its PSF shape information.
473
474 Parameters
475 ----------
476 visitSummary : `lsst.afw.table.ExposureCatalog`
477 Exposure catalog with per-detector summary information.
478 detectorId : `int`
479 Detector identifier.
480
481 Returns
482 -------
483 valid : `bool`
484 True if selected.
485 """
486 row = visitSummary.find(detectorId)
487 if row is None:
488 # This is not listed, so it must be bad.
489 self.log.warning("Removing detector %d because summary stats not available.", detectorId)
490 return False
491
492 band = row["band"]
493 if band in self.config.minNPsfStarPerBand:
494 minNPsfStar = self.config.minNPsfStarPerBand[band]
495 else:
496 minNPsfStar = self.config.minNPsfStarPerBand["fallback"]
497 if band in self.config.maxStarEPerBand:
498 maxStarE = self.config.maxStarEPerBand[band]
499 else:
500 maxStarE = self.config.maxStarEPerBand["fallback"]
501 if band in self.config.maxStarUnNormalizedEPerBand:
502 maxStarUnNormalizedE = self.config.maxStarUnNormalizedEPerBand[band]
503 else:
504 maxStarUnNormalizedE = self.config.maxStarUnNormalizedEPerBand["fallback"]
505 nPsfStar = row["nPsfStar"]
506 medianE = np.sqrt(row["psfStarDeltaE1Median"]**2. + row["psfStarDeltaE2Median"]**2.)
507 scatterSize = row["psfStarDeltaSizeScatter"]
508 scaledScatterSize = row["psfStarScaledDeltaSizeScatter"]
509 psfTraceRadiusDelta = row["psfTraceRadiusDelta"]
510 psfApFluxDelta = row["psfApFluxDelta"]
511 psfApCorrSigmaScaledDelta = row["psfApCorrSigmaScaledDelta"]
512 starEMedian = row["starEMedian"]
513 starUnNormalizedEMedian = row["starUnNormalizedEMedian"]
514
515 valid = True
516 # The logic of this condition does not follow the ones below. Isolating
517 # it from the get-go such that it doesn't break the established flow of
518 # the selections below (and having the psfFwhm cut as the first/dominant
519 # one seems reasonable).
520 if self.config.maxPsfFwhm:
521 pixelScale = row['pixelScale']
522 psfSigma = row['psfSigma']
523 fwhm = psfSigma * pixelScale * np.sqrt(8.*np.log(2.))
524 if not (fwhm <= self.config.maxPsfFwhm):
525 self.log.info("Removing visit %d detector %d because FWHM too large: %f vs %f",
526 row["visit"], detectorId, fwhm, self.config.maxPsfFwhm)
527 valid = False
528 return valid
529
530 if self.config.maxEllipResidual and not (medianE <= self.config.maxEllipResidual):
531 self.log.info("Removing visit %d detector %d because median e residual too large: %f vs %f",
532 row["visit"], detectorId, medianE, self.config.maxEllipResidual)
533 valid = False
534 elif self.config.maxSizeScatter and not (scatterSize <= self.config.maxSizeScatter):
535 self.log.info("Removing visit %d detector %d because size scatter too large: %f vs %f",
536 row["visit"], detectorId, scatterSize, self.config.maxSizeScatter)
537 valid = False
538 elif self.config.maxScaledSizeScatter and not (scaledScatterSize <= self.config.maxScaledSizeScatter):
539 self.log.info("Removing visit %d detector %d because scaled size scatter too large: %f vs %f",
540 row["visit"], detectorId, scaledScatterSize, self.config.maxScaledSizeScatter)
541 valid = False
542 elif (
543 self.config.maxPsfTraceRadiusDelta is not None
544 and not (psfTraceRadiusDelta <= self.config.maxPsfTraceRadiusDelta)
545 ):
546 self.log.info(
547 "Removing visit %d detector %d because max-min delta of model PSF trace radius values "
548 "across the unmasked detector pixels is not finite or too large: %.3f vs %.3f (pixels)",
549 row["visit"], detectorId, psfTraceRadiusDelta, self.config.maxPsfTraceRadiusDelta
550 )
551 valid = False
552 elif (
553 self.config.maxPsfApFluxDelta is not None
554 and not (psfApFluxDelta <= self.config.maxPsfApFluxDelta)
555 ):
556 self.log.info(
557 "Removing visit %d detector %d because max-min delta of model PSF aperture flux values "
558 "across the unmasked detector pixels is not finite or too large: %.3f vs %.3f (pixels)",
559 row["visit"], detectorId, psfApFluxDelta, self.config.maxPsfApFluxDelta
560 )
561 valid = False
562 elif (
563 self.config.maxPsfApCorrSigmaScaledDelta is not None
564 and not (psfApCorrSigmaScaledDelta <= self.config.maxPsfApCorrSigmaScaledDelta)
565 ):
566 self.log.info(
567 "Removing visit %d detector %d because max-min delta of the model PSF aperture correction "
568 "values scaled (divided) by the psfSigma across the unmasked detector pixels is not "
569 "finite or too large: %.3f vs %.3f (factor)",
570 row["visit"], detectorId, psfApCorrSigmaScaledDelta, self.config.maxPsfApCorrSigmaScaledDelta
571 )
572 valid = False
573 elif minNPsfStar and (nPsfStar < minNPsfStar):
574 self.log.info(
575 "Removing visit %d detector %d because the PSF model had too few stars: %d vs %d",
576 row["visit"], detectorId, nPsfStar, minNPsfStar
577 )
578 valid = False
579 elif maxStarE and not (starEMedian <= maxStarE):
580 self.log.info(
581 "Removing visit %d detector %d because the median star ellipticity is too large: "
582 "%.2f vs %.2f",
583 row["visit"], detectorId, starEMedian, maxStarE
584 )
585 valid = False
586 elif maxStarUnNormalizedE and not (starUnNormalizedEMedian <= maxStarUnNormalizedE):
587 self.log.info(
588 "Removing visit %d detector %d because the median star unnormalized ellipticity "
589 "is too large: %.2f vs %.2f",
590 row["visit"], detectorId, starUnNormalizedEMedian, maxStarUnNormalizedE
591 )
592 valid = False
593
594 return valid
595
596
597class BestSeeingSelectVisitsConnections(pipeBase.PipelineTaskConnections,
598 dimensions=("tract", "patch", "skymap", "band", "instrument"),
599 defaultTemplates={"coaddName": "goodSeeing",
600 "calexpType": ""}):
601 skyMap = pipeBase.connectionTypes.Input(
602 doc="Input definition of geometry/bbox and projection/wcs for coadded exposures",
603 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME,
604 storageClass="SkyMap",
605 dimensions=("skymap",),
606 )
607 visitSummaries = pipeBase.connectionTypes.Input(
608 doc="Per-visit consolidated exposure metadata",
609 name="finalVisitSummary",
610 storageClass="ExposureCatalog",
611 dimensions=("instrument", "visit",),
612 multiple=True,
613 deferLoad=True
614 )
615 goodVisits = pipeBase.connectionTypes.Output(
616 doc="Selected visits to be coadded.",
617 name="{coaddName}Visits",
618 storageClass="StructuredDataDict",
619 dimensions=("instrument", "tract", "patch", "skymap", "band"),
620 )
621
622
623class BestSeeingSelectVisitsConfig(pipeBase.PipelineTaskConfig,
624 pipelineConnections=BestSeeingSelectVisitsConnections):
625 nVisitsMax = pexConfig.RangeField(
626 dtype=int,
627 doc="Maximum number of visits to select; use -1 to select all.",
628 default=12,
629 min=-1,
630 )
631 maxPsfFwhm = pexConfig.Field(
632 dtype=float,
633 doc="Maximum PSF FWHM (in arcseconds) to select",
634 default=1.5,
635 optional=True
636 )
637 minPsfFwhm = pexConfig.Field(
638 dtype=float,
639 doc="Minimum PSF FWHM (in arcseconds) to select",
640 default=0.,
641 optional=True
642 )
643 doConfirmOverlap = pexConfig.Field(
644 dtype=bool,
645 doc="Do remove visits that do not actually overlap the patch?",
646 default=True,
647 )
648 minMJD = pexConfig.Field(
649 dtype=float,
650 doc="Minimum visit MJD to select",
651 default=None,
652 optional=True
653 )
654 maxMJD = pexConfig.Field(
655 dtype=float,
656 doc="Maximum visit MJD to select",
657 default=None,
658 optional=True
659 )
660 visitSummaryMinValues = pexConfig.DictField(
661 keytype=str,
662 itemtype=float,
663 doc=("Optional thresholds for visit selection. Keys are visit_summary column names"
664 "(e.g. 'effTimeZeroPointScale'), and values are minimum allowed values."),
665 default=None,
666 optional=True
667 )
668
669
670class BestSeeingSelectVisitsTask(pipeBase.PipelineTask):
671 """Select up to a maximum number of the best-seeing visits.
672
673 Don't exceed the FWHM range specified by configs min(max)PsfFwhm.
674 This Task is a port of the Gen2 image-selector used in the AP pipeline:
675 BestSeeingSelectImagesTask. This Task selects full visits based on the
676 average PSF of the entire visit.
677 """
678
679 ConfigClass = BestSeeingSelectVisitsConfig
680 _DefaultName = 'bestSeeingSelectVisits'
681
682 def runQuantum(self, butlerQC, inputRefs, outputRefs):
683 inputs = butlerQC.get(inputRefs)
684 quantumDataId = butlerQC.quantum.dataId
685 outputs = self.run(**inputs, dataId=quantumDataId)
686 butlerQC.put(outputs, outputRefs)
687
688 def run(self, visitSummaries, skyMap, dataId):
689 """Run task.
690
691 Parameters
692 ----------
693 visitSummary : `list` [`lsst.pipe.base.connections.DeferredDatasetRef`]
694 List of `lsst.pipe.base.connections.DeferredDatasetRef` of
695 visitSummary tables of type `lsst.afw.table.ExposureCatalog`.
696 skyMap : `lsst.skyMap.SkyMap`
697 SkyMap for checking visits overlap patch.
698 dataId : `dict` of dataId keys
699 For retrieving patch info for checking visits overlap patch.
700
701 Returns
702 -------
703 result : `lsst.pipe.base.Struct`
704 Results as a struct with attributes:
705
706 ``goodVisits``
707 A `dict` with selected visit ids as keys,
708 so that it can be be saved as a StructuredDataDict.
709 StructuredDataList's are currently limited.
710 """
711 if self.config.doConfirmOverlap:
712 patchPolygon = self.makePatchPolygon(skyMap, dataId)
713
714 inputVisits = [visitSummary.ref.dataId['visit'] for visitSummary in visitSummaries]
715 fwhmSizes = []
716 visits = []
717 for visit, visitSummary in zip(inputVisits, visitSummaries):
718 # read in one-by-one and only once. There may be hundreds
719 visitSummary = visitSummary.get()
720
721 # mjd is guaranteed to be the same for every detector in the
722 # visitSummary.
723 mjd = visitSummary[0].getVisitInfo().getDate().get(system=DateTime.MJD)
724
725 pixelScales = np.array([vs['pixelScale'] for vs in visitSummary if vs.getWcs()])
726 # psfSigma is PSF model determinant radius at chip center in pixels
727 psfSigmas = np.array([vs['psfSigma'] for vs in visitSummary if vs.getWcs()])
728 fwhm = np.nanmean(psfSigmas * pixelScales) * np.sqrt(8.*np.log(2.))
729
730 if self.config.maxPsfFwhm and fwhm > self.config.maxPsfFwhm:
731 continue
732 if self.config.minPsfFwhm and fwhm < self.config.minPsfFwhm:
733 continue
734 if self.config.minMJD and mjd < self.config.minMJD:
735 self.log.debug('MJD %f earlier than %.2f; rejecting', mjd, self.config.minMJD)
736 continue
737 if self.config.maxMJD and mjd > self.config.maxMJD:
738 self.log.debug('MJD %f later than %.2f; rejecting', mjd, self.config.maxMJD)
739 continue
740 if self.config.doConfirmOverlap and not self.doesIntersectPolygon(visitSummary, patchPolygon):
741 continue
742 if (self.config.visitSummaryMinValues
743 and not _meetsVisitSummaryMinValues(visit, visitSummary, self.config.visitSummaryMinValues,
744 self.log)):
745 continue
746
747 fwhmSizes.append(fwhm)
748 visits.append(visit)
749
750 sortedVisits = [ind for (_, ind) in sorted(zip(fwhmSizes, visits))]
751 if self.config.nVisitsMax < 0:
752 output = sortedVisits
753 else:
754 output = sortedVisits[:self.config.nVisitsMax]
755
756 if len(output) == 0:
757 self.log.info("All images rejected in BestSeeingSelectVisitsTask.")
758 raise pipeBase.NoWorkFound(f"No good images found for {dataId}")
759 else:
760 self.log.info(
761 "%d images selected with FWHM range of %f--%f arcseconds",
762 len(output),
763 fwhmSizes[visits.index(output[0])],
764 fwhmSizes[visits.index(output[-1])],
765 )
766
767 # In order to store as a StructuredDataDict, convert list to dict
768 goodVisits = {key: True for key in output}
769 return pipeBase.Struct(goodVisits=goodVisits)
770
771 def makePatchPolygon(self, skyMap, dataId):
772 """Return True if sky polygon overlaps visit.
773
774 Parameters
775 ----------
776 skyMap : `lsst.afw.table.ExposureCatalog`
777 Exposure catalog with per-detector geometry.
778 dataId : `dict` of dataId keys
779 For retrieving patch info.
780
781 Returns
782 -------
783 result : `lsst.sphgeom.ConvexPolygon.convexHull`
784 Polygon of patch's outer bbox.
785 """
786 wcs = skyMap[dataId['tract']].getWcs()
787 bbox = skyMap[dataId['tract']][dataId['patch']].getOuterBBox()
788 sphCorners = wcs.pixelToSky(lsst.geom.Box2D(bbox).getCorners())
789 result = lsst.sphgeom.ConvexPolygon.convexHull([coord.getVector() for coord in sphCorners])
790 return result
791
792 def doesIntersectPolygon(self, visitSummary, polygon):
793 """Return True if sky polygon overlaps visit.
794
795 Parameters
796 ----------
797 visitSummary : `lsst.afw.table.ExposureCatalog`
798 Exposure catalog with per-detector geometry.
799 polygon :` lsst.sphgeom.ConvexPolygon.convexHull`
800 Polygon to check overlap.
801
802 Returns
803 -------
804 doesIntersect : `bool`
805 True if the visit overlaps the polygon.
806 """
807 doesIntersect = False
808 for detectorSummary in visitSummary:
809 if (np.all(np.isfinite(detectorSummary['raCorners']))
810 and np.all(np.isfinite(detectorSummary['decCorners']))):
811 corners = [lsst.geom.SpherePoint(ra, dec, units=lsst.geom.degrees).getVector()
812 for (ra, dec) in
813 zip(detectorSummary['raCorners'], detectorSummary['decCorners'])]
814 detectorPolygon = lsst.sphgeom.ConvexPolygon.convexHull(corners)
815 if detectorPolygon.intersects(polygon):
816 doesIntersect = True
817 break
818 return doesIntersect
819
820
821class BestSeeingQuantileSelectVisitsConfig(pipeBase.PipelineTaskConfig,
822 pipelineConnections=BestSeeingSelectVisitsConnections):
823 qMin = pexConfig.RangeField(
824 doc="Lower bound of quantile range to select. Sorts visits by seeing from narrow to wide, "
825 "and select those in the interquantile range (qMin, qMax). Set qMin to 0 for Best Seeing. "
826 "This config should be changed from zero only for exploratory diffIm testing.",
827 dtype=float,
828 default=0,
829 min=0,
830 max=1,
831 )
832 qMax = pexConfig.RangeField(
833 doc="Upper bound of quantile range to select. Sorts visits by seeing from narrow to wide, "
834 "and select those in the interquantile range (qMin, qMax). Set qMax to 1 for Worst Seeing.",
835 dtype=float,
836 default=0.33,
837 min=0,
838 max=1,
839 )
840 maxPsfFwhm = pexConfig.Field(
841 dtype=float,
842 doc="Maximum PSF FWHM (in arcseconds) to select. Any visits with larger FWHM will be excluded "
843 "before quantile selection.",
844 default=None,
845 optional=True
846 )
847 nVisitsMin = pexConfig.Field(
848 doc="The minimum number of visits to select, if qMin and qMax alone would have "
849 "selected fewer. In regimes with many visits, at least this number of visits will be "
850 "selected, superceding quantile when necessary. "
851 "For example, if 10 visits cover this patch, qMin=0, qMax=0.33, and nVisitsMin=5, "
852 "the best 5 visits will be selected, even though 5 > 0.33*10. "
853 "In regimes with few visits, all available visits will be selected. "
854 "For example, if 2 visits cover this patch and nVisitsMin=12, "
855 "both visits will be selected regardless of qMin and qMax.",
856 dtype=int,
857 default=6,
858 )
859 doConfirmOverlap = pexConfig.Field(
860 dtype=bool,
861 doc="Do remove visits that do not actually overlap the patch?",
862 default=True,
863 )
864 minMJD = pexConfig.Field(
865 dtype=float,
866 doc="Minimum visit MJD to select",
867 default=None,
868 optional=True
869 )
870 maxMJD = pexConfig.Field(
871 dtype=float,
872 doc="Maximum visit MJD to select",
873 default=None,
874 optional=True
875 )
876 visitSummaryMinValues = pexConfig.DictField(
877 keytype=str,
878 itemtype=float,
879 doc=("Optional thresholds for visit selection. Keys are visit_summary column names"
880 "(e.g. 'effTimeZeroPointScale'), and values are minimum allowed values."),
881 default=None,
882 optional=True
883 )
884
885
886class BestSeeingQuantileSelectVisitsTask(BestSeeingSelectVisitsTask):
887 """Select a quantile of the best-seeing visits.
888
889 Selects the best (for example, third) full visits based on the average
890 PSF width in the entire visit. It can also be used for difference imaging
891 experiments that require templates with the worst seeing visits.
892 For example, selecting the worst third can be acheived by
893 changing the config parameters qMin to 0.66 and qMax to 1.
894 """
895 ConfigClass = BestSeeingQuantileSelectVisitsConfig
896 _DefaultName = 'bestSeeingQuantileSelectVisits'
897
898 def run(self, visitSummaries, skyMap, dataId):
899 if self.config.doConfirmOverlap:
900 patchPolygon = self.makePatchPolygon(skyMap, dataId)
901 visits = np.array([visitSummary.ref.dataId['visit'] for visitSummary in visitSummaries])
902 radius = np.empty(len(visits))
903 intersects = np.full(len(visits), True)
904 for i, visitSummary in enumerate(visitSummaries):
905 # read in one-by-one and only once. There may be hundreds
906 visitSummary = visitSummary.get()
907 # psfSigma is PSF model determinant radius at chip center in pixels
908 psfSigmas = np.array([vs['psfSigma'] for vs in visitSummary if vs.getWcs()])
909 pixelScales = np.array([vs['pixelScale'] for vs in visitSummary if vs.getWcs()])
910 fwhm = np.nanmedian(psfSigmas * pixelScales) * np.sqrt(8.*np.log(2.))
911 radius[i] = fwhm
912 if self.config.doConfirmOverlap:
913 intersects[i] = self.doesIntersectPolygon(visitSummary, patchPolygon)
914 if self.config.minMJD or self.config.maxMJD:
915 # mjd is guaranteed to be the same for every detector in the
916 # visitSummary.
917 mjd = visitSummary[0].getVisitInfo().getDate().get(system=DateTime.MJD)
918 aboveMin = mjd > self.config.minMJD if self.config.minMJD else True
919 belowMax = mjd < self.config.maxMJD if self.config.maxMJD else True
920 intersects[i] = intersects[i] and aboveMin and belowMax
921 if self.config.maxPsfFwhm and fwhm > self.config.maxPsfFwhm:
922 intersects[i] = False
923 self.log.info(f"Rejecting visit {visits[i]}: with FWHM {fwhm:.2f} > {self.config.maxPsfFwhm}")
924 if (self.config.visitSummaryMinValues
925 and not _meetsVisitSummaryMinValues(visits[i], visitSummary,
926 self.config.visitSummaryMinValues, self.log)):
927 intersects[i] = False
928
929 sortedVisits = [v for rad, v in sorted(zip(radius[intersects], visits[intersects]))]
930 lowerBound = min(int(np.round(self.config.qMin*len(visits[intersects]))),
931 max(0, len(visits[intersects]) - self.config.nVisitsMin))
932 upperBound = max(int(np.round(self.config.qMax*len(visits[intersects]))), self.config.nVisitsMin)
933
934 # In order to store as a StructuredDataDict, convert list to dict
935 goodVisits = {int(visit): True for visit in sortedVisits[lowerBound:upperBound]}
936
937 if len(goodVisits) == 0:
938 self.log.info("All visits rejected")
939 # To behave same as BestSeeingSelectVisitsTask
940 raise pipeBase.NoWorkFound(f"No good visits found for {dataId}")
941 else:
942 selectedRadii = sorted(radius[intersects])[lowerBound:upperBound]
943 self.log.info(f"Selected {len(goodVisits)} visits with FWHM range of "
944 f"{selectedRadii[0]:.2f}--{selectedRadii[-1]:.2f} arcseconds.")
945 return pipeBase.Struct(goodVisits=goodVisits)
run(self, wcsList, bboxList, coordList, visitSummary, dataIds=None, **kwargs)
run(self, wcsList, bboxList, coordList, dataIds=None, **kwargs)
getValidImageCorners(self, imageWcs, imageBox, patchPoly, dataId=None)
static ConvexPolygon convexHull(std::vector< UnitVector3d > const &points)
_extractKeyValue(dataList, keys=None)
_meetsVisitSummaryMinValues(visit, visitSummary, visitSummaryMinValues, logger=None)