Loading [MathJax]/extensions/tex2jax.js
LSST Applications g0fba68d861+83433b07ee,g16d25e1f1b+23bc9e47ac,g1ec0fe41b4+3ea9d11450,g1fd858c14a+9be2b0f3b9,g2440f9efcc+8c5ae1fdc5,g35bb328faa+8c5ae1fdc5,g4a4af6cd76+d25431c27e,g4d2262a081+c74e83464e,g53246c7159+8c5ae1fdc5,g55585698de+1e04e59700,g56a49b3a55+92a7603e7a,g60b5630c4e+1e04e59700,g67b6fd64d1+3fc8cb0b9e,g78460c75b0+7e33a9eb6d,g786e29fd12+668abc6043,g8352419a5c+8c5ae1fdc5,g8852436030+60e38ee5ff,g89139ef638+3fc8cb0b9e,g94187f82dc+1e04e59700,g989de1cb63+3fc8cb0b9e,g9d31334357+1e04e59700,g9f33ca652e+0a83e03614,gabe3b4be73+8856018cbb,gabf8522325+977d9fabaf,gb1101e3267+8b4b9c8ed7,gb89ab40317+3fc8cb0b9e,gc0af124501+57ccba3ad1,gcf25f946ba+60e38ee5ff,gd6cbbdb0b4+1cc2750d2e,gd794735e4e+7be992507c,gdb1c4ca869+be65c9c1d7,gde0f65d7ad+c7f52e58fe,ge278dab8ac+6b863515ed,ge410e46f29+3fc8cb0b9e,gf35d7ec915+97dd712d81,gf5e32f922b+8c5ae1fdc5,gf618743f1b+747388abfa,gf67bdafdda+3fc8cb0b9e,w.2025.18
LSST Data Management Base Package
All Classes Namespaces Files Functions Variables Typedefs Enumerations Enumerator Properties Friends Macros Modules Pages
getTemplate.py
Go to the documentation of this file.
1# This file is part of ip_diffim.
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/>.
21import collections
22
23import numpy as np
24from deprecated.sphinx import deprecated
25
26import lsst.afw.image as afwImage
27import lsst.geom as geom
28import lsst.afw.geom as afwGeom
29import lsst.afw.table as afwTable
30from lsst.afw.math._warper import computeWarpedBBox
31import lsst.afw.math as afwMath
32import lsst.pex.config as pexConfig
33import lsst.pipe.base as pipeBase
34
35from lsst.skymap import BaseSkyMap
36from lsst.ip.diffim.dcrModel import DcrModel
37from lsst.meas.algorithms import CoaddPsf, CoaddPsfConfig
38from lsst.utils.timer import timeMethod
39
40__all__ = [
41 "GetTemplateTask",
42 "GetTemplateConfig",
43 "GetDcrTemplateTask",
44 "GetDcrTemplateConfig",
45]
46
47
49 pipeBase.PipelineTaskConnections,
50 dimensions=("instrument", "visit", "detector", "skymap"),
51 defaultTemplates={"coaddName": "goodSeeing", "warpTypeSuffix": "", "fakesType": ""},
52):
53 bbox = pipeBase.connectionTypes.Input(
54 doc="Bounding box of exposure to determine the geometry of the output template.",
55 name="{fakesType}calexp.bbox",
56 storageClass="Box2I",
57 dimensions=("instrument", "visit", "detector"),
58 )
59 wcs = pipeBase.connectionTypes.Input(
60 doc="WCS of the exposure that we will construct the template for.",
61 name="{fakesType}calexp.wcs",
62 storageClass="Wcs",
63 dimensions=("instrument", "visit", "detector"),
64 )
65 skyMap = pipeBase.connectionTypes.Input(
66 doc="Geometry of the tracts and patches that the coadds are defined on.",
67 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME,
68 dimensions=("skymap",),
69 storageClass="SkyMap",
70 )
71 coaddExposures = pipeBase.connectionTypes.Input(
72 doc="Coadds that may overlap the desired region, as possible inputs to the template."
73 " Will be restricted to those that directly overlap the projected bounding box.",
74 dimensions=("tract", "patch", "skymap", "band"),
75 storageClass="ExposureF",
76 name="{fakesType}{coaddName}Coadd{warpTypeSuffix}",
77 multiple=True,
78 deferLoad=True,
79 deferGraphConstraint=True,
80 )
81
82 template = pipeBase.connectionTypes.Output(
83 doc="Warped template, pixel matched to the bounding box and WCS.",
84 dimensions=("instrument", "visit", "detector"),
85 storageClass="ExposureF",
86 name="{fakesType}{coaddName}Diff_templateExp{warpTypeSuffix}",
87 )
88
89
90class GetTemplateConfig(
91 pipeBase.PipelineTaskConfig, pipelineConnections=GetTemplateConnections
92):
93 templateBorderSize = pexConfig.Field(
94 dtype=int,
95 default=20,
96 doc="Number of pixels to grow the requested template image to account for warping",
97 )
98 warp = pexConfig.ConfigField(
99 dtype=afwMath.Warper.ConfigClass,
100 doc="warper configuration",
101 )
102 coaddPsf = pexConfig.ConfigField(
103 doc="Configuration for CoaddPsf",
104 dtype=CoaddPsfConfig,
105 )
106
107 def setDefaults(self):
108 # Use a smaller cache: per SeparableKernel.computeCache, this should
109 # give a warping error of a fraction of a count (these must match).
110 self.warp.cacheSize = 100000
111 self.coaddPsf.cacheSize = self.warp.cacheSize
112 # The WCS for LSST should be smoothly varying, so we can use a longer
113 # interpolation length for WCS evaluations.
114 self.warp.interpLength = 100
115 self.warp.warpingKernelName = "lanczos3"
116 self.coaddPsf.warpingKernelName = self.warp.warpingKernelName
117
118
119class GetTemplateTask(pipeBase.PipelineTask):
120 ConfigClass = GetTemplateConfig
121 _DefaultName = "getTemplate"
122
123 def __init__(self, *args, **kwargs):
124 super().__init__(*args, **kwargs)
125 self.warper = afwMath.Warper.fromConfig(self.config.warp)
126 self.schema = afwTable.ExposureTable.makeMinimalSchema()
127 self.schema.addField(
128 "tract", type=np.int32, doc="Which tract this exposure came from."
129 )
130 self.schema.addField(
131 "patch",
132 type=np.int32,
133 doc="Which patch in the tract this exposure came from.",
134 )
135 self.schema.addField(
136 "weight",
137 type=float,
138 doc="Weight for each exposure, used to make the CoaddPsf; should always be 1.",
139 )
140
141 def runQuantum(self, butlerQC, inputRefs, outputRefs):
142 inputs = butlerQC.get(inputRefs)
143 bbox = inputs.pop("bbox")
144 wcs = inputs.pop("wcs")
145 coaddExposures = inputs.pop("coaddExposures")
146 skymap = inputs.pop("skyMap")
147
148 # This should not happen with a properly configured execution context.
149 assert not inputs, "runQuantum got more inputs than expected"
150
151 results = self.getExposures(coaddExposures, bbox, skymap, wcs)
152 physical_filter = butlerQC.quantum.dataId["physical_filter"]
153 outputs = self.run(
154 coaddExposureHandles=results.coaddExposures,
155 bbox=bbox,
156 wcs=wcs,
157 dataIds=results.dataIds,
158 physical_filter=physical_filter,
159 )
160 butlerQC.put(outputs, outputRefs)
161
162 @deprecated(
163 reason="Replaced by getExposures, which uses explicit arguments instead of a kwargs dict. "
164 "This method will be removed after v29.",
165 version="v29.0",
166 category=FutureWarning,
167 )
168 def getOverlappingExposures(self, inputs):
169 return self.getExposures(
170 inputs["coaddExposures"], inputs["bbox"], inputs["skyMap"], inputs["wcs"]
171 )
172
173 def getExposures(self, coaddExposureHandles, bbox, skymap, wcs):
174 """Return a data structure containing the coadds that overlap the
175 specified bbox projected onto the sky, and a corresponding data
176 structure of their dataIds.
177 These are the appropriate inputs to this task's `run` method.
178
179 The spatial index in the butler registry has generous padding and often
180 supplies patches near, but not directly overlapping the desired region.
181 This method filters the inputs so that `run` does not have to read in
182 all possibly-matching coadd exposures.
183
184 Parameters
185 ----------
186 coaddExposureHandles : `iterable` \
187 [`lsst.daf.butler.DeferredDatasetHandle` of \
188 `lsst.afw.image.Exposure`]
189 Dataset handles to exposures that might overlap the desired
190 region.
191 bbox : `lsst.geom.Box2I`
192 Template bounding box of the pixel geometry onto which the
193 coaddExposures will be resampled.
194 skymap : `lsst.skymap.SkyMap`
195 Geometry of the tracts and patches the coadds are defined on.
196 wcs : `lsst.afw.geom.SkyWcs`
197 Template WCS onto which the coadds will be resampled.
198
199 Returns
200 -------
201 result : `lsst.pipe.base.Struct`
202 A struct with attributes:
203
204 ``coaddExposures``
205 Dict of coadd exposures that overlap the projected bbox,
206 indexed on tract id
207 (`dict` [`int`, `list` [`lsst.daf.butler.DeferredDatasetHandle` of
208 `lsst.afw.image.Exposure`] ]).
209 ``dataIds``
210 Dict of data IDs of the coadd exposures that overlap the
211 projected bbox, indexed on tract id
212 (`dict` [`int`, `list [`lsst.daf.butler.DataCoordinate`] ]).
213
214 Raises
215 ------
216 NoWorkFound
217 Raised if no patches overlap the input detector bbox, or the input
218 WCS is None.
219 """
220 if wcs is None:
221 raise pipeBase.NoWorkFound(
222 "WCS is None; cannot find overlapping exposures."
223 )
224
225 # Exposure's validPolygon would be more accurate
226 detectorPolygon = geom.Box2D(bbox)
227 overlappingArea = 0
228 coaddExposures = collections.defaultdict(list)
229 dataIds = collections.defaultdict(list)
230
231 for coaddRef in coaddExposureHandles:
232 dataId = coaddRef.dataId
233 patchWcs = skymap[dataId["tract"]].getWcs()
234 patchBBox = skymap[dataId["tract"]][dataId["patch"]].getOuterBBox()
235 patchCorners = patchWcs.pixelToSky(geom.Box2D(patchBBox).getCorners())
236 patchPolygon = afwGeom.Polygon(wcs.skyToPixel(patchCorners))
237 if patchPolygon.intersection(detectorPolygon):
238 overlappingArea += patchPolygon.intersectionSingle(
239 detectorPolygon
240 ).calculateArea()
241 self.log.info(
242 "Using template input tract=%s, patch=%s",
243 dataId["tract"],
244 dataId["patch"],
245 )
246 coaddExposures[dataId["tract"]].append(coaddRef)
247 dataIds[dataId["tract"]].append(dataId)
248
249 if not overlappingArea:
250 raise pipeBase.NoWorkFound("No patches overlap detector")
251
252 return pipeBase.Struct(coaddExposures=coaddExposures, dataIds=dataIds)
253
254 @timeMethod
255 def run(self, *, coaddExposureHandles, bbox, wcs, dataIds, physical_filter):
256 """Warp coadds from multiple tracts and patches to form a template to
257 subtract from a science image.
258
259 Tract and patch overlap regions are combined by a variance-weighted
260 average, and the variance planes are combined with the same weights,
261 not added in quadrature; the overlap regions are not statistically
262 independent, because they're derived from the same original data.
263 The PSF on the template is created by combining the CoaddPsf on each
264 template image into a meta-CoaddPsf.
265
266 Parameters
267 ----------
268 coaddExposureHandles : `dict` [`int`, `list` of \
269 [`lsst.daf.butler.DeferredDatasetHandle` of \
270 `lsst.afw.image.Exposure`]]
271 Coadds to be mosaicked, indexed on tract id.
272 bbox : `lsst.geom.Box2I`
273 Template Bounding box of the detector geometry onto which to
274 resample the ``coaddExposureHandles``. Modified in-place to include the
275 template border.
276 wcs : `lsst.afw.geom.SkyWcs`
277 Template WCS onto which to resample the ``coaddExposureHandles``.
278 dataIds : `dict` [`int`, `list` [`lsst.daf.butler.DataCoordinate`]]
279 Record of the tract and patch of each coaddExposure, indexed on
280 tract id.
281 physical_filter : `str`
282 Physical filter of the science image.
283
284 Returns
285 -------
286 result : `lsst.pipe.base.Struct`
287 A struct with attributes:
288
289 ``template``
290 A template coadd exposure assembled out of patches
291 (`lsst.afw.image.ExposureF`).
292
293 Raises
294 ------
295 NoWorkFound
296 If no coadds are found with sufficient un-masked pixels.
297 """
298 band, photoCalib = self._checkInputs(dataIds, coaddExposureHandles)
299
300 bbox.grow(self.config.templateBorderSize)
301
302 warped = {}
303 catalogs = []
304 for tract in coaddExposureHandles:
305 maskedImages, catalog, totalBox = self._makeExposureCatalog(
306 coaddExposureHandles[tract], dataIds[tract]
307 )
308 warpedBox = computeWarpedBBox(catalog[0].wcs, bbox, wcs)
309 warpedBox.grow(5) # to ensure we catch all relevant input pixels
310 # Combine images from individual patches together.
311 unwarped, count, included = self._merge(
312 maskedImages, warpedBox, catalog[0].wcs
313 )
314 # Delete `maskedImages` after combining into one large image to reduce peak memory use
315 del maskedImages
316 if count == 0:
317 self.log.info(
318 "No valid pixels from coadd patches in tract %s; not including in output.",
319 tract,
320 )
321 continue
322 warpedBox.clip(totalBox)
323 potentialInput = self.warper.warpExposure(
324 wcs, unwarped.subset(warpedBox), destBBox=bbox
325 )
326
327 # Delete the single large `unwarped` image after warping to reduce peak memory use
328 del unwarped
329 if np.all(
330 potentialInput.mask.array
331 & potentialInput.mask.getPlaneBitMask("NO_DATA")
332 ):
333 self.log.info(
334 "No overlap from coadd patches in tract %s; not including in output.",
335 tract,
336 )
337 continue
338
339 # Trim the exposure catalog to just the patches that were used.
340 tempCatalog = afwTable.ExposureCatalog(self.schema)
341 tempCatalog.reserve(len(included))
342 for i in included:
343 tempCatalog.append(catalog[i])
344 catalogs.append(tempCatalog)
345 warped[tract] = potentialInput.maskedImage
346
347 if len(warped) == 0:
348 raise pipeBase.NoWorkFound("No patches found to overlap science exposure.")
349 # At this point, all entries will be valid, so we can ignore included.
350 template, count, _ = self._merge(warped, bbox, wcs)
351 if count == 0:
352 raise pipeBase.NoWorkFound("No valid pixels in warped template.")
353
354 # Make a single catalog containing all the inputs that were accepted.
355 catalog = afwTable.ExposureCatalog(self.schema)
356 catalog.reserve(sum([len(c) for c in catalogs]))
357 for c in catalogs:
358 catalog.extend(c)
359
360 template.setPsf(self._makePsf(template, catalog, wcs))
361 template.setFilter(afwImage.FilterLabel(band, physical_filter))
362 template.setPhotoCalib(photoCalib)
363 return pipeBase.Struct(template=template)
364
365 @staticmethod
366 def _checkInputs(dataIds, coaddExposures):
367 """Check that the all the dataIds are from the same band and that
368 the exposures all have the same photometric calibration.
369
370 Parameters
371 ----------
372 dataIds : `dict` [`int`, `list` [`lsst.daf.butler.DataCoordinate`]]
373 Record of the tract and patch of each coaddExposure.
374 coaddExposures : `dict` [`int`, `list` of \
375 [`lsst.daf.butler.DeferredDatasetHandle` of \
376 `lsst.afw.image.Exposure` or
377 `lsst.afw.image.Exposure`]]
378 Coadds to be mosaicked.
379
380 Returns
381 -------
382 band : `str`
383 Filter band of all the input exposures.
384 photoCalib : `lsst.afw.image.PhotoCalib`
385 Photometric calibration of all of the input exposures.
386
387 Raises
388 ------
389 RuntimeError
390 Raised if the bands or calibrations of the input exposures are not
391 all the same.
392 """
393 bands = set(dataId["band"] for tract in dataIds for dataId in dataIds[tract])
394 if len(bands) > 1:
395 raise RuntimeError(f"GetTemplateTask called with multiple bands: {bands}")
396 band = bands.pop()
397 photoCalibs = [
398 exposure.get(component="photoCalib")
399 for exposures in coaddExposures.values()
400 for exposure in exposures
401 ]
402 if not all([photoCalibs[0] == x for x in photoCalibs]):
403 msg = f"GetTemplateTask called with exposures with different photoCalibs: {photoCalibs}"
404 raise RuntimeError(msg)
405 photoCalib = photoCalibs[0]
406 return band, photoCalib
407
408 def _makeExposureCatalog(self, exposureRefs, dataIds):
409 """Make an exposure catalog for one tract.
410
411 Parameters
412 ----------
413 exposureRefs : `list` of [`lsst.daf.butler.DeferredDatasetHandle` of \
414 `lsst.afw.image.Exposure`]
415 Exposures to include in the catalog.
416 dataIds : `list` [`lsst.daf.butler.DataCoordinate`]
417 Data ids of each of the included exposures; must have "tract" and
418 "patch" entries.
419
420 Returns
421 -------
422 images : `dict` [`lsst.afw.image.MaskedImage`]
423 MaskedImages of each of the input exposures, for warping.
424 catalog : `lsst.afw.table.ExposureCatalog`
425 Catalog of metadata for each exposure
426 totalBox : `lsst.geom.Box2I`
427 The union of the bounding boxes of all the input exposures.
428 """
429 catalog = afwTable.ExposureCatalog(self.schema)
430 catalog.reserve(len(exposureRefs))
431 exposures = (exposureRef.get() for exposureRef in exposureRefs)
432 images = {}
433 totalBox = geom.Box2I()
434
435 for coadd, dataId in zip(exposures, dataIds):
436 images[dataId] = coadd.maskedImage
437 bbox = coadd.getBBox()
438 totalBox = totalBox.expandedTo(bbox)
439 record = catalog.addNew()
440 record.setPsf(coadd.psf)
441 record.setWcs(coadd.wcs)
442 record.setPhotoCalib(coadd.photoCalib)
443 record.setBBox(bbox)
444 record.setValidPolygon(afwGeom.Polygon(geom.Box2D(bbox).getCorners()))
445 record.set("tract", dataId["tract"])
446 record.set("patch", dataId["patch"])
447 # Weight is used by CoaddPsf, but the PSFs from overlapping patches
448 # should be very similar, so this value mostly shouldn't matter.
449 record.set("weight", 1)
450
451 return images, catalog, totalBox
452
453 def _merge(self, maskedImages, bbox, wcs):
454 """Merge the images that came from one tract into one larger image,
455 ignoring NaN pixels and non-finite variance pixels from individual
456 exposures.
457
458 Parameters
459 ----------
460 maskedImages : `dict` [`lsst.afw.image.MaskedImage` or
461 `lsst.afw.image.Exposure`]
462 Images to be merged into one larger bounding box.
463 bbox : `lsst.geom.Box2I`
464 Bounding box defining the image to merge into.
465 wcs : `lsst.afw.geom.SkyWcs`
466 WCS of all of the input images to set on the output image.
467
468 Returns
469 -------
470 merged : `lsst.afw.image.MaskedImage`
471 Merged image with all of the inputs at their respective bbox
472 positions.
473 count : `int`
474 Count of the number of good pixels (those with positive weights)
475 in the merged image.
476 included : `list` [`int`]
477 List of indexes of patches that were included in the merged
478 result, to be used to trim the exposure catalog.
479 """
480 merged = afwImage.ExposureF(bbox, wcs)
481 weights = afwImage.ImageF(bbox)
482 included = [] # which patches were included in the result
483 for i, (dataId, maskedImage) in enumerate(maskedImages.items()):
484 # Only merge into the trimmed box, to save memory
485 clippedBox = geom.Box2I(maskedImage.getBBox())
486 clippedBox.clip(bbox)
487 if clippedBox.area == 0:
488 self.log.debug("%s does not overlap template region.", dataId)
489 continue # nothing in this image overlaps the output
490 maskedImage = maskedImage.subset(clippedBox)
491 # Catch both zero-value and NaN variance plane pixels
492 good = (maskedImage.variance.array > 0) & (
493 np.isfinite(maskedImage.variance.array)
494 )
495 weight = maskedImage.variance.array[good] ** (-0.5)
496 bad = np.isnan(maskedImage.image.array) | ~good
497 # Note that modifying the patch MaskedImage in place is fine;
498 # we're throwing it away at the end anyway.
499 maskedImage.image.array[bad] = 0.0
500 maskedImage.variance.array[bad] = 0.0
501 # Reset mask, too, since these pixels don't contribute to sum.
502 maskedImage.mask.array[bad] = 0
503 # Cannot use `merged.maskedImage *= weight` because that operator
504 # multiplies the variance by the weight twice; in this case
505 # `weight` are the exact values we want to scale by.
506 maskedImage.image.array[good] *= weight
507 maskedImage.variance.array[good] *= weight
508 weights[clippedBox].array[good] += weight
509 # Free memory before creating new large arrays
510 del weight
511 merged.maskedImage[clippedBox] += maskedImage
512 included.append(i)
513
514 good = weights.array > 0
515
516 # Cannot use `merged.maskedImage /= weights` because that
517 # operator divides the variance by the weight twice; in this case
518 # `weights` are the exact values we want to scale by.
519 weights = weights.array[good]
520 merged.image.array[good] /= weights
521 merged.variance.array[good] /= weights
522
523 merged.mask.array[~good] |= merged.mask.getPlaneBitMask("NO_DATA")
524
525 return merged, good.sum(), included
526
527 def _makePsf(self, template, catalog, wcs):
528 """Return a PSF containing the PSF at each of the input regions.
529
530 Note that although this includes all the exposures from the catalog,
531 the PSF knows which part of the template the inputs came from, so when
532 evaluated at a given position it will not include inputs that never
533 went in to those pixels.
534
535 Parameters
536 ----------
537 template : `lsst.afw.image.Exposure`
538 Generated template the PSF is for.
539 catalog : `lsst.afw.table.ExposureCatalog`
540 Catalog of exposures that went into the template that contains all
541 of the input PSFs.
542 wcs : `lsst.afw.geom.SkyWcs`
543 WCS of the template, to warp the PSFs to.
544
545 Returns
546 -------
547 coaddPsf : `lsst.meas.algorithms.CoaddPsf`
548 The meta-psf constructed from all of the input catalogs.
549 """
550 # CoaddPsf centroid not only must overlap image, but must overlap the
551 # part of image with data. Use centroid of region with data.
552 boolmask = template.mask.array & template.mask.getPlaneBitMask("NO_DATA") == 0
553 maskx = afwImage.makeMaskFromArray(boolmask.astype(afwImage.MaskPixel))
554 centerCoord = afwGeom.SpanSet.fromMask(maskx, 1).computeCentroid()
555
556 ctrl = self.config.coaddPsf.makeControl()
557 coaddPsf = CoaddPsf(
558 catalog, wcs, centerCoord, ctrl.warpingKernelName, ctrl.cacheSize
559 )
560 return coaddPsf
561
562
564 GetTemplateConnections,
565 dimensions=("instrument", "visit", "detector", "skymap"),
566 defaultTemplates={"coaddName": "dcr", "warpTypeSuffix": "", "fakesType": ""},
567):
568 visitInfo = pipeBase.connectionTypes.Input(
569 doc="VisitInfo of calexp used to determine observing conditions.",
570 name="{fakesType}calexp.visitInfo",
571 storageClass="VisitInfo",
572 dimensions=("instrument", "visit", "detector"),
573 )
574 dcrCoadds = pipeBase.connectionTypes.Input(
575 doc="Input DCR template to match and subtract from the exposure",
576 name="{fakesType}dcrCoadd{warpTypeSuffix}",
577 storageClass="ExposureF",
578 dimensions=("tract", "patch", "skymap", "band", "subfilter"),
579 multiple=True,
580 deferLoad=True,
581 )
582
583 def __init__(self, *, config=None):
584 super().__init__(config=config)
585 self.inputs.remove("coaddExposures")
586
587
588class GetDcrTemplateConfig(
589 GetTemplateConfig, pipelineConnections=GetDcrTemplateConnections
590):
591 numSubfilters = pexConfig.Field(
592 doc="Number of subfilters in the DcrCoadd.",
593 dtype=int,
594 default=3,
595 )
596 effectiveWavelength = pexConfig.Field(
597 doc="Effective wavelength of the filter in nm.",
598 optional=False,
599 dtype=float,
600 )
601 bandwidth = pexConfig.Field(
602 doc="Bandwidth of the physical filter.",
603 optional=False,
604 dtype=float,
605 )
606
607 def validate(self):
608 if self.effectiveWavelength is None or self.bandwidth is None:
609 raise ValueError(
610 "The effective wavelength and bandwidth of the physical filter "
611 "must be set in the getTemplate config for DCR coadds. "
612 "Required until transmission curves are used in DM-13668."
613 )
614
615
616class GetDcrTemplateTask(GetTemplateTask):
617 ConfigClass = GetDcrTemplateConfig
618 _DefaultName = "getDcrTemplate"
619
620 def runQuantum(self, butlerQC, inputRefs, outputRefs):
621 inputs = butlerQC.get(inputRefs)
622 bbox = inputs.pop("bbox")
623 wcs = inputs.pop("wcs")
624 dcrCoaddExposureHandles = inputs.pop("dcrCoadds")
625 skymap = inputs.pop("skyMap")
626 visitInfo = inputs.pop("visitInfo")
627
628 # This should not happen with a properly configured execution context.
629 assert not inputs, "runQuantum got more inputs than expected"
630
631 results = self.getExposures(
632 dcrCoaddExposureHandles, bbox, skymap, wcs, visitInfo
633 )
634 physical_filter = butlerQC.quantum.dataId["physical_filter"]
635 outputs = self.run(
636 coaddExposureHandles=results.coaddExposures,
637 bbox=bbox,
638 wcs=wcs,
639 dataIds=results.dataIds,
640 physical_filter=physical_filter,
641 )
642 butlerQC.put(outputs, outputRefs)
643
644 @deprecated(
645 reason="Replaced by getExposures, which uses explicit arguments instead of a kwargs dict. "
646 "This method will be removed after v29.",
647 version="v29.0",
648 category=FutureWarning,
649 )
650 def getOverlappingExposures(self, inputs):
651 return self.getExposures(
652 inputs["dcrCoadds"],
653 inputs["bbox"],
654 inputs["skyMap"],
655 inputs["wcs"],
656 inputs["visitInfo"],
657 )
658
659 def getExposures(self, dcrCoaddExposureHandles, bbox, skymap, wcs, visitInfo):
660 """Return lists of coadds and their corresponding dataIds that overlap
661 the detector.
662
663 The spatial index in the registry has generous padding and often
664 supplies patches near, but not directly overlapping the detector.
665 Filters inputs so that we don't have to read in all input coadds.
666
667 Parameters
668 ----------
669 dcrCoaddExposureHandles : `list` \
670 [`lsst.daf.butler.DeferredDatasetHandle` of \
671 `lsst.afw.image.Exposure`]
672 Data references to exposures that might overlap the detector.
673 bbox : `lsst.geom.Box2I`
674 Template Bounding box of the detector geometry onto which to
675 resample the coaddExposures.
676 skymap : `lsst.skymap.SkyMap`
677 Input definition of geometry/bbox and projection/wcs for
678 template exposures.
679 wcs : `lsst.afw.geom.SkyWcs`
680 Template WCS onto which to resample the coaddExposures.
681 visitInfo : `lsst.afw.image.VisitInfo`
682 Metadata for the science image.
683
684 Returns
685 -------
686 result : `lsst.pipe.base.Struct`
687 A struct with attibutes:
688
689 ``coaddExposures``
690 Dict of coadd exposures that overlap the projected bbox,
691 indexed on tract id
692 (`dict` [`int`, `list` [`lsst.afw.image.Exposure`] ]).
693 ``dataIds``
694 Dict of data IDs of the coadd exposures that overlap the
695 projected bbox, indexed on tract id
696 (`dict` [`int`, `list [`lsst.daf.butler.DataCoordinate`] ]).
697
698 Raises
699 ------
700 pipeBase.NoWorkFound
701 Raised if no patches overlatp the input detector bbox.
702 """
703 # Check that the patches actually overlap the detector
704 # Exposure's validPolygon would be more accurate
705 if wcs is None:
706 raise pipeBase.NoWorkFound("Exposure has no WCS; cannot create a template.")
707
708 detectorPolygon = geom.Box2D(bbox)
709 overlappingArea = 0
710 dataIds = collections.defaultdict(list)
711 patchList = dict()
712 for coaddRef in dcrCoaddExposureHandles:
713 dataId = coaddRef.dataId
714 subfilter = dataId["subfilter"]
715 patchWcs = skymap[dataId["tract"]].getWcs()
716 patchBBox = skymap[dataId["tract"]][dataId["patch"]].getOuterBBox()
717 patchCorners = patchWcs.pixelToSky(geom.Box2D(patchBBox).getCorners())
718 patchPolygon = afwGeom.Polygon(wcs.skyToPixel(patchCorners))
719 if patchPolygon.intersection(detectorPolygon):
720 overlappingArea += patchPolygon.intersectionSingle(
721 detectorPolygon
722 ).calculateArea()
723 self.log.info(
724 "Using template input tract=%s, patch=%s, subfilter=%s"
725 % (dataId["tract"], dataId["patch"], dataId["subfilter"])
726 )
727 if dataId["tract"] in patchList:
728 patchList[dataId["tract"]].append(dataId["patch"])
729 else:
730 patchList[dataId["tract"]] = [
731 dataId["patch"],
732 ]
733 if subfilter == 0:
734 dataIds[dataId["tract"]].append(dataId)
735
736 if not overlappingArea:
737 raise pipeBase.NoWorkFound("No patches overlap detector")
738
739 self.checkPatchList(patchList)
740
741 coaddExposures = self.getDcrModel(patchList, dcrCoaddExposureHandles, visitInfo)
742 return pipeBase.Struct(coaddExposures=coaddExposures, dataIds=dataIds)
743
744 def checkPatchList(self, patchList):
745 """Check that all of the DcrModel subfilters are present for each
746 patch.
747
748 Parameters
749 ----------
750 patchList : `dict`
751 Dict of the patches containing valid data for each tract.
752
753 Raises
754 ------
755 RuntimeError
756 If the number of exposures found for a patch does not match the
757 number of subfilters.
758 """
759 for tract in patchList:
760 for patch in set(patchList[tract]):
761 if patchList[tract].count(patch) != self.config.numSubfilters:
762 raise RuntimeError(
763 "Invalid number of DcrModel subfilters found: %d vs %d expected",
764 patchList[tract].count(patch),
765 self.config.numSubfilters,
766 )
767
768 def getDcrModel(self, patchList, coaddRefs, visitInfo):
769 """Build DCR-matched coadds from a list of exposure references.
770
771 Parameters
772 ----------
773 patchList : `dict`
774 Dict of the patches containing valid data for each tract.
775 coaddRefs : `list` [`lsst.daf.butler.DeferredDatasetHandle`]
776 Data references to `~lsst.afw.image.Exposure` representing
777 DcrModels that overlap the detector.
778 visitInfo : `lsst.afw.image.VisitInfo`
779 Metadata for the science image.
780
781 Returns
782 -------
783 coaddExposures : `list` [`lsst.afw.image.Exposure`]
784 Coadd exposures that overlap the detector.
785 """
786 coaddExposures = collections.defaultdict(list)
787 for tract in patchList:
788 for patch in set(patchList[tract]):
789 coaddRefList = [
790 coaddRef
791 for coaddRef in coaddRefs
792 if _selectDataRef(coaddRef, tract, patch)
793 ]
794
795 dcrModel = DcrModel.fromQuantum(
796 coaddRefList,
797 self.config.effectiveWavelength,
798 self.config.bandwidth,
799 self.config.numSubfilters,
800 )
801 coaddExposures[tract].append(dcrModel.buildMatchedExposureHandle(visitInfo=visitInfo))
802 return coaddExposures
803
804
805def _selectDataRef(coaddRef, tract, patch):
806 condition = (coaddRef.dataId["tract"] == tract) & (
807 coaddRef.dataId["patch"] == patch
808 )
809 return condition
Cartesian polygons.
Definition Polygon.h:59
A group of labels for a filter in an exposure or coadd.
Definition FilterLabel.h:58
A floating-point coordinate rectangle geometry.
Definition Box.h:413
An integer coordinate rectangle.
Definition Box.h:55
CoaddPsf is the Psf derived to be used for non-PSF-matched Coadd images.
Definition CoaddPsf.h:58
_selectDataRef(coaddRef, tract, patch)
checkPatchList(self, patchList)
_merge(self, maskedImages, bbox, wcs)
_makeExposureCatalog(self, exposureRefs, dataIds)
run(self, *, coaddExposureHandles, bbox, wcs, dataIds, physical_filter)
getDcrModel(self, patchList, coaddRefs, visitInfo)
_makePsf(self, template, catalog, wcs)