LSST Applications g013ef56533+d2224463a4,g199a45376c+0ba108daf9,g19c4beb06c+9f335b2115,g1fd858c14a+2459ca3e43,g210f2d0738+2d3d333a78,g262e1987ae+abbb004f04,g2825c19fe3+eedc38578d,g29ae962dfc+0cb55f06ef,g2cef7863aa+aef1011c0b,g35bb328faa+8c5ae1fdc5,g3fd5ace14f+19c3a54948,g47891489e3+501a489530,g4cdb532a89+a047e97985,g511e8cfd20+ce1f47b6d6,g53246c7159+8c5ae1fdc5,g54cd7ddccb+890c8e1e5d,g5fd55ab2c7+951cc3f256,g64539dfbff+2d3d333a78,g67b6fd64d1+501a489530,g67fd3c3899+2d3d333a78,g74acd417e5+0ea5dee12c,g786e29fd12+668abc6043,g87389fa792+8856018cbb,g89139ef638+501a489530,g8d7436a09f+5ea4c44d25,g8ea07a8fe4+81eaaadc04,g90f42f885a+34c0557caf,g9486f8a5af+165c016931,g97be763408+d5e351dcc8,gbf99507273+8c5ae1fdc5,gc2a301910b+2d3d333a78,gca7fc764a6+501a489530,gce8aa8abaa+8c5ae1fdc5,gd7ef33dd92+501a489530,gdab6d2f7ff+0ea5dee12c,ge410e46f29+501a489530,geaed405ab2+e3b4b2a692,gf9a733ac38+8c5ae1fdc5,w.2025.41
LSST Data Management Base Package
Loading...
Searching...
No Matches
detectAndMeasure.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/>.
21
22import numpy as np
23import requests
24import os
25
26import lsst.afw.detection as afwDetection
27import lsst.afw.image as afwImage
28import lsst.afw.math as afwMath
29import lsst.afw.table as afwTable
30import lsst.daf.base as dafBase
31import lsst.geom
32from lsst.ip.diffim.utils import (evaluateMaskFraction, computeDifferenceImageMetrics,
33 populate_sattle_visit_cache)
34from lsst.meas.algorithms import SkyObjectsTask, SourceDetectionTask, SetPrimaryFlagsTask, MaskStreaksTask
35from lsst.meas.algorithms import FindGlintTrailsTask
36from lsst.meas.base import ForcedMeasurementTask, ApplyApCorrTask, DetectorVisitIdGeneratorConfig
39import lsst.meas.extensions.shapeHSM
40import lsst.pex.config as pexConfig
41from lsst.pex.exceptions import InvalidParameterError
42import lsst.pipe.base as pipeBase
43import lsst.utils
44from lsst.utils.timer import timeMethod
45
46from . import DipoleFitTask
47
48__all__ = ["DetectAndMeasureConfig", "DetectAndMeasureTask",
49 "DetectAndMeasureScoreConfig", "DetectAndMeasureScoreTask"]
50
51
52class BadSubtractionError(pipeBase.AlgorithmError):
53 """Raised when the residuals in footprints of stars used to compute the
54 psf-matching kernel exceeds the configured maximum.
55 """
56 def __init__(self, *, ratio, threshold):
57 msg = ("The ratio of residual power in source footprints on the"
58 " difference image to the power in the footprints on the"
59 f" science image was {ratio}, which exceeds the maximum"
60 f" threshold of {threshold}")
61 super().__init__(msg)
62 self.ratio = ratio
63 self.threshold = threshold
64
65 @property
66 def metadata(self):
67 return {"ratio": self.ratio,
68 "threshold": self.threshold
69 }
70
71
72class NoDiaSourcesError(pipeBase.AlgorithmError):
73 """Raised when there are no diaSources detected on an image difference.
74 """
75 def __init__(self):
76 msg = ("No diaSources detected!")
77 super().__init__(msg)
78
79 @property
80 def metadata(self):
81 return {}
82
83
84class DetectAndMeasureConnections(pipeBase.PipelineTaskConnections,
85 dimensions=("instrument", "visit", "detector"),
86 defaultTemplates={"coaddName": "deep",
87 "warpTypeSuffix": "",
88 "fakesType": ""}):
89 science = pipeBase.connectionTypes.Input(
90 doc="Input science exposure.",
91 dimensions=("instrument", "visit", "detector"),
92 storageClass="ExposureF",
93 name="{fakesType}calexp"
94 )
95 matchedTemplate = pipeBase.connectionTypes.Input(
96 doc="Warped and PSF-matched template used to create the difference image.",
97 dimensions=("instrument", "visit", "detector"),
98 storageClass="ExposureF",
99 name="{fakesType}{coaddName}Diff_matchedExp",
100 )
101 difference = pipeBase.connectionTypes.Input(
102 doc="Result of subtracting template from science.",
103 dimensions=("instrument", "visit", "detector"),
104 storageClass="ExposureF",
105 name="{fakesType}{coaddName}Diff_differenceTempExp",
106 )
107 kernelSources = pipeBase.connectionTypes.Input(
108 doc="Final selection of sources used for psf matching.",
109 dimensions=("instrument", "visit", "detector"),
110 storageClass="SourceCatalog",
111 name="{fakesType}{coaddName}Diff_psfMatchSources"
112 )
113 outputSchema = pipeBase.connectionTypes.InitOutput(
114 doc="Schema (as an example catalog) for output DIASource catalog.",
115 storageClass="SourceCatalog",
116 name="{fakesType}{coaddName}Diff_diaSrc_schema",
117 )
118 diaSources = pipeBase.connectionTypes.Output(
119 doc="Detected diaSources on the difference image.",
120 dimensions=("instrument", "visit", "detector"),
121 storageClass="SourceCatalog",
122 name="{fakesType}{coaddName}Diff_diaSrc",
123 )
124 subtractedMeasuredExposure = pipeBase.connectionTypes.Output(
125 doc="Difference image with detection mask plane filled in.",
126 dimensions=("instrument", "visit", "detector"),
127 storageClass="ExposureF",
128 name="{fakesType}{coaddName}Diff_differenceExp",
129 )
130 differenceBackground = pipeBase.connectionTypes.Output(
131 doc="Background model that was subtracted from the difference image.",
132 dimensions=("instrument", "visit", "detector"),
133 storageClass="Background",
134 name="difference_background",
135 )
136 maskedStreaks = pipeBase.connectionTypes.Output(
137 doc='Catalog of streak fit parameters for the difference image.',
138 storageClass="ArrowNumpyDict",
139 dimensions=("instrument", "visit", "detector"),
140 name="{fakesType}{coaddName}Diff_streaks",
141 )
142 glintTrailInfo = pipeBase.connectionTypes.Output(
143 doc='Dict of fit parameters for glint trails in the catalog.',
144 storageClass="ArrowNumpyDict",
145 dimensions=("instrument", "visit", "detector"),
146 name="trailed_glints",
147 )
148
149 def __init__(self, *, config):
150 super().__init__(config=config)
151 if not (self.config.doCalculateResidualMetics):
152 self.inputs.remove("kernelSources")
153 if not (self.config.writeStreakInfo and self.config.doMaskStreaks):
154 self.outputs.remove("maskedStreaks")
155 if not (self.config.doSubtractBackground and self.config.doWriteBackground):
156 self.outputs.remove("differenceBackground")
157 if not (self.config.writeGlintInfo):
158 self.outputs.remove("glintTrailInfo")
159
160
161class DetectAndMeasureConfig(pipeBase.PipelineTaskConfig,
162 pipelineConnections=DetectAndMeasureConnections):
163 """Config for DetectAndMeasureTask
164 """
165 doMerge = pexConfig.Field(
166 dtype=bool,
167 default=True,
168 doc="Merge positive and negative diaSources with grow radius "
169 "set by growFootprint"
170 )
171 doForcedMeasurement = pexConfig.Field(
172 dtype=bool,
173 default=True,
174 doc="Force photometer diaSource locations on PVI?"
175 )
176 doAddMetrics = pexConfig.Field(
177 dtype=bool,
178 default=False,
179 doc="Add columns to the source table to hold analysis metrics?"
180 )
181 doSubtractBackground = pexConfig.Field(
182 dtype=bool,
183 doc="Subtract a background model from the image before detection?",
184 default=True,
185 )
186 doWriteBackground = pexConfig.Field(
187 dtype=bool,
188 doc="Persist the fitted background model?",
189 default=False,
190 )
191 doCalculateResidualMetics = pexConfig.Field(
192 dtype=bool,
193 doc="Calculate metrics to assess image subtraction quality for the task"
194 "metadata?",
195 default=True,
196 )
197 subtractInitialBackground = pexConfig.ConfigurableField(
199 doc="Task to perform intial background subtraction, before first detection pass.",
200 )
201 subtractFinalBackground = pexConfig.ConfigurableField(
203 doc="Task to perform final background subtraction, after first detection pass.",
204 )
205 detection = pexConfig.ConfigurableField(
206 target=SourceDetectionTask,
207 doc="Final source detection for diaSource measurement",
208 )
209 streakDetection = pexConfig.ConfigurableField(
210 target=SourceDetectionTask,
211 doc="Separate source detection used only for streak masking",
212 )
213 doDeblend = pexConfig.Field(
214 dtype=bool,
215 default=False,
216 doc="Deblend DIASources after detection?"
217 )
218 deblend = pexConfig.ConfigurableField(
220 doc="Task to split blended sources into their components."
221 )
222 measurement = pexConfig.ConfigurableField(
223 target=DipoleFitTask,
224 doc="Task to measure sources on the difference image.",
225 )
226 doApCorr = lsst.pex.config.Field(
227 dtype=bool,
228 default=True,
229 doc="Run subtask to apply aperture corrections"
230 )
232 target=ApplyApCorrTask,
233 doc="Task to apply aperture corrections"
234 )
235 forcedMeasurement = pexConfig.ConfigurableField(
236 target=ForcedMeasurementTask,
237 doc="Task to force photometer science image at diaSource locations.",
238 )
239 growFootprint = pexConfig.Field(
240 dtype=int,
241 default=2,
242 doc="Grow positive and negative footprints by this many pixels before merging"
243 )
244 diaSourceMatchRadius = pexConfig.Field(
245 dtype=float,
246 default=0.5,
247 doc="Match radius (in arcseconds) for DiaSource to Source association"
248 )
249 doSkySources = pexConfig.Field(
250 dtype=bool,
251 default=False,
252 doc="Generate sky sources?",
253 )
254 skySources = pexConfig.ConfigurableField(
255 target=SkyObjectsTask,
256 doc="Generate sky sources",
257 )
258 doMaskStreaks = pexConfig.Field(
259 dtype=bool,
260 default=True,
261 doc="Turn on streak masking",
262 )
263 maskStreaks = pexConfig.ConfigurableField(
264 target=MaskStreaksTask,
265 doc="Subtask for masking streaks. Only used if doMaskStreaks is True. "
266 "Adds a mask plane to an exposure, with the mask plane name set by streakMaskName.",
267 )
268 streakBinFactor = pexConfig.Field(
269 dtype=int,
270 default=4,
271 doc="Bin scale factor to use when rerunning detection for masking streaks. "
272 "Only used if doMaskStreaks is True.",
273 )
274 writeStreakInfo = pexConfig.Field(
275 dtype=bool,
276 default=False,
277 doc="Record the parameters of any detected streaks. For LSST, this should be turned off except for "
278 "development work."
279 )
280 findGlints = pexConfig.ConfigurableField(
281 target=FindGlintTrailsTask,
282 doc="Subtask for finding glint trails, usually caused by satellites or debris."
283 )
284 writeGlintInfo = pexConfig.Field(
285 dtype=bool,
286 default=True,
287 doc="Record the parameters of any detected glint trails."
288 )
289 setPrimaryFlags = pexConfig.ConfigurableField(
290 target=SetPrimaryFlagsTask,
291 doc="Task to add isPrimary and deblending-related flags to the catalog."
292 )
293 badSourceFlags = lsst.pex.config.ListField(
294 dtype=str,
295 doc="Sources with any of these flags set are removed before writing the output catalog.",
296 default=("base_PixelFlags_flag_offimage",
297 "base_PixelFlags_flag_edge",
298 "base_PixelFlags_flag_interpolatedCenterAll",
299 "base_PixelFlags_flag_badCenterAll",
300 "base_PixelFlags_flag_edgeCenterAll",
301 "base_PixelFlags_flag_nodataCenterAll",
302 "base_PixelFlags_flag_saturatedCenterAll",
303 "base_PixelFlags_flag_saturated_templateCenterAll",
304 ),
305 )
306 clearMaskPlanes = lsst.pex.config.ListField(
307 dtype=str,
308 doc="Mask planes to clear before running detection.",
309 default=("DETECTED", "DETECTED_NEGATIVE", "NOT_DEBLENDED", "STREAK"),
310 )
311 raiseOnBadSubtractionRatio = pexConfig.Field(
312 dtype=bool,
313 default=True,
314 doc="Raise an error if the ratio of power in detected footprints"
315 " on the difference image to the power in footprints on the science"
316 " image exceeds ``badSubtractionRatioThreshold``",
317 )
318 badSubtractionRatioThreshold = pexConfig.Field(
319 dtype=float,
320 default=0.2,
321 doc="Maximum ratio of power in footprints on the difference image to"
322 " the same footprints on the science image."
323 "Only used if ``raiseOnBadSubtractionRatio`` is set",
324 )
325 badSubtractionVariationThreshold = pexConfig.Field(
326 dtype=float,
327 default=0.4,
328 doc="Maximum standard deviation of the ratio of power in footprints on"
329 " the difference image to the same footprints on the science image."
330 "Only used if ``raiseOnBadSubtractionRatio`` is set",
331 )
332 raiseOnNoDiaSources = pexConfig.Field(
333 dtype=bool,
334 default=True,
335 doc="Raise an algorithm error if no diaSources are detected.",
336 )
337 run_sattle = pexConfig.Field(
338 dtype=bool,
339 default=False,
340 doc="If true, dia source bounding boxes will be sent for verification"
341 "to the sattle service."
342 )
343 sattle_historical = pexConfig.Field(
344 dtype=bool,
345 default=False,
346 doc="If re-running a pipeline that requires sattle, this should be set "
347 "to True. This will populate sattle's cache with the historic data "
348 "closest in time to the exposure."
349 )
350 idGenerator = DetectorVisitIdGeneratorConfig.make_field()
351
352 def setDefaults(self):
353 # Background subtraction
354 # Use a small binsize for the first pass to reduce detections on glints
355 # and extended structures. Should not affect the detectability of
356 # faint diaSources
357 self.subtractInitialBackground.binSize = 8
358 self.subtractInitialBackground.useApprox = False
359 self.subtractInitialBackground.statisticsProperty = "MEDIAN"
360 self.subtractInitialBackground.doFilterSuperPixels = True
361 self.subtractInitialBackground.ignoredPixelMask = ["BAD",
362 "EDGE",
363 "DETECTED",
364 "DETECTED_NEGATIVE",
365 "NO_DATA",
366 ]
367 # Use a larger binsize for the final background subtraction, to reduce
368 # over-subtraction of bright objects.
369 self.subtractFinalBackground.binSize = 40
370 self.subtractFinalBackground.useApprox = False
371 self.subtractFinalBackground.statisticsProperty = "MEDIAN"
372 self.subtractFinalBackground.doFilterSuperPixels = True
373 self.subtractFinalBackground.ignoredPixelMask = ["BAD",
374 "EDGE",
375 "DETECTED",
376 "DETECTED_NEGATIVE",
377 "NO_DATA",
378 ]
379 # DiaSource Detection
380 self.detection.thresholdPolarity = "both"
381 self.detection.thresholdValue = 5.0
382 self.detection.reEstimateBackground = False
383 self.detection.thresholdType = "pixel_stdev"
384 self.detection.excludeMaskPlanes = []
385
386 # Copy configs for binned streak detection from the base detection task
387 self.streakDetection.thresholdType = self.detection.thresholdType
388 self.streakDetection.reEstimateBackground = False
389 self.streakDetection.excludeMaskPlanes = self.detection.excludeMaskPlanes
390 self.streakDetection.thresholdValue = self.detection.thresholdValue
391 # Only detect positive streaks
392 self.streakDetection.thresholdPolarity = "positive"
393 # Do not grow detected mask for streaks
394 self.streakDetection.nSigmaToGrow = 0
395 # Set the streak mask along the entire fit line, not only where the
396 # detected mask is set.
397 self.maskStreaks.onlyMaskDetected = False
398 # Restrict streak masking from growing too large
399 self.maskStreaks.maxStreakWidth = 100
400 # Restrict the number of iterations allowed for fitting streaks
401 # When the fit is good it should solve quickly, and exit a bad fit quickly
402 self.maskStreaks.maxFitIter = 10
403 # Only mask to 2 sigma in width
404 self.maskStreaks.nSigmaMask = 2
405 # Threshold for including streaks after the Hough Transform.
406 # A lower value will detect more features that are less linear.
407 self.maskStreaks.absMinimumKernelHeight = 2
408
409 self.measurement.plugins.names |= ["ext_trailedSources_Naive",
410 "base_LocalPhotoCalib",
411 "base_LocalWcs",
412 "ext_shapeHSM_HsmSourceMoments",
413 "ext_shapeHSM_HsmPsfMoments",
414 "base_ClassificationSizeExtendedness",
415 ]
416 self.measurement.slots.psfShape = "ext_shapeHSM_HsmPsfMoments"
417 self.measurement.slots.shape = "ext_shapeHSM_HsmSourceMoments"
418 self.measurement.plugins["base_SdssCentroid"].maxDistToPeak = 5.0
419 self.forcedMeasurement.plugins = ["base_TransformedCentroid", "base_PsfFlux"]
420 self.forcedMeasurement.copyColumns = {
421 "id": "objectId", "parent": "parentObjectId", "coord_ra": "coord_ra", "coord_dec": "coord_dec"}
422 self.forcedMeasurement.slots.centroid = "base_TransformedCentroid"
423 self.forcedMeasurement.slots.shape = None
424
425 # Keep track of which footprints contain streaks
426 self.measurement.plugins["base_PixelFlags"].masksFpAnywhere = [
427 "STREAK", "INJECTED", "INJECTED_TEMPLATE", "HIGH_VARIANCE", "SATURATED_TEMPLATE"]
428 self.measurement.plugins["base_PixelFlags"].masksFpCenter = [
429 "STREAK", "INJECTED", "INJECTED_TEMPLATE", "HIGH_VARIANCE", "SATURATED_TEMPLATE"]
430 self.skySources.avoidMask = ["DETECTED", "DETECTED_NEGATIVE", "BAD", "NO_DATA", "EDGE"]
431
432 def validate(self):
433 super().validate()
434
435 if self.run_sattle:
436 if not os.getenv("SATTLE_URI_BASE"):
437 raise pexConfig.FieldValidationError(DetectAndMeasureConfig.run_sattle, self,
438 "Sattle requested but SATTLE_URI_BASE "
439 "environment variable not set.")
440
441
442class DetectAndMeasureTask(lsst.pipe.base.PipelineTask):
443 """Detect and measure sources on a difference image.
444 """
445 ConfigClass = DetectAndMeasureConfig
446 _DefaultName = "detectAndMeasure"
447
448 def __init__(self, **kwargs):
449 super().__init__(**kwargs)
450 self.schema = afwTable.SourceTable.makeMinimalSchema()
451
452 self.algMetadata = dafBase.PropertyList()
453 if self.config.doSubtractBackground:
454 self.makeSubtask("subtractInitialBackground")
455 self.makeSubtask("subtractFinalBackground")
456 self.makeSubtask("detection", schema=self.schema)
457 if self.config.doDeblend:
458 self.makeSubtask("deblend", schema=self.schema)
459 self.makeSubtask("setPrimaryFlags", schema=self.schema, isSingleFrame=True)
460 self.makeSubtask("measurement", schema=self.schema,
461 algMetadata=self.algMetadata)
462 if self.config.doApCorr:
463 self.makeSubtask("applyApCorr", schema=self.measurement.schema)
464 if self.config.doForcedMeasurement:
465 self.schema.addField(
466 "ip_diffim_forced_PsfFlux_instFlux", "D",
467 "Forced PSF flux measured on the direct image.",
468 units="count")
469 self.schema.addField(
470 "ip_diffim_forced_PsfFlux_instFluxErr", "D",
471 "Forced PSF flux error measured on the direct image.",
472 units="count")
473 self.schema.addField(
474 "ip_diffim_forced_PsfFlux_area", "F",
475 "Forced PSF flux effective area of PSF.",
476 units="pixel")
477 self.schema.addField(
478 "ip_diffim_forced_PsfFlux_flag", "Flag",
479 "Forced PSF flux general failure flag.")
480 self.schema.addField(
481 "ip_diffim_forced_PsfFlux_flag_noGoodPixels", "Flag",
482 "Forced PSF flux not enough non-rejected pixels in data to attempt the fit.")
483 self.schema.addField(
484 "ip_diffim_forced_PsfFlux_flag_edge", "Flag",
485 "Forced PSF flux object was too close to the edge of the image to use the full PSF model.")
486 self.schema.addField(
487 "ip_diffim_forced_template_PsfFlux_instFlux", "D",
488 "Forced PSF flux measured on the template image.",
489 units="count")
490 self.schema.addField(
491 "ip_diffim_forced_template_PsfFlux_instFluxErr", "D",
492 "Forced PSF flux error measured on the template image.",
493 units="count")
494 self.schema.addField(
495 "ip_diffim_forced_template_PsfFlux_area", "F",
496 "Forced template PSF flux effective area of PSF.",
497 units="pixel")
498 self.schema.addField(
499 "ip_diffim_forced_template_PsfFlux_flag", "Flag",
500 "Forced template PSF flux general failure flag.")
501 self.schema.addField(
502 "ip_diffim_forced_template_PsfFlux_flag_noGoodPixels", "Flag",
503 "Forced template PSF flux not enough non-rejected pixels in data to attempt the fit.")
504 self.schema.addField(
505 "ip_diffim_forced_template_PsfFlux_flag_edge", "Flag",
506 """Forced template PSF flux object was too close to the edge of the image """
507 """to use the full PSF model.""")
508 self.makeSubtask("forcedMeasurement", refSchema=self.schema)
509
510 self.schema.addField("refMatchId", "L", "unique id of reference catalog match")
511 self.schema.addField("srcMatchId", "L", "unique id of source match")
512 # Create the sky source task for use by metrics,
513 # even if sky sources are not added to the diaSource catalog
514 self.makeSubtask("skySources", schema=self.schema)
515 if self.config.doMaskStreaks:
516 self.makeSubtask("maskStreaks")
517 self.makeSubtask("streakDetection")
518 self.makeSubtask("findGlints")
519 self.schema.addField("glint_trail", "Flag", "DiaSource is part of a glint trail.")
520 self.schema.addField("reliability", type="F", doc="Reliability score of the DiaSource")
521
522 # To get the "merge_*" fields in the schema; have to re-initialize
523 # this later, once we have a peak schema post-detection.
524 lsst.afw.detection.FootprintMergeList(self.schema, ["positive", "negative"])
525
526 # Check that the schema and config are consistent
527 for flag in self.config.badSourceFlags:
528 if flag not in self.schema:
529 raise pipeBase.InvalidQuantumError("Field %s not in schema" % flag)
530
531 # initialize InitOutputs
532 self.outputSchema = afwTable.SourceCatalog(self.schema)
533 self.outputSchema.getTable().setMetadata(self.algMetadata)
534
535 def runQuantum(self, butlerQC: pipeBase.QuantumContext,
536 inputRefs: pipeBase.InputQuantizedConnection,
537 outputRefs: pipeBase.OutputQuantizedConnection):
538 inputs = butlerQC.get(inputRefs)
539 idGenerator = self.config.idGenerator.apply(butlerQC.quantum.dataId)
540 idFactory = idGenerator.make_table_id_factory()
541 # Specify the fields that `annotate` needs below, to ensure they
542 # exist, even as None.
543 measurementResults = pipeBase.Struct(
544 subtractedMeasuredExposure=None,
545 diaSources=None,
546 maskedStreaks=None,
547 differenceBackground=None,
548 )
549 try:
550 self.run(**inputs, idFactory=idFactory, measurementResults=measurementResults)
551 except pipeBase.AlgorithmError as e:
552 error = pipeBase.AnnotatedPartialOutputsError.annotate(
553 e,
554 self,
555 measurementResults.subtractedMeasuredExposure,
556 measurementResults.diaSources,
557 measurementResults.maskedStreaks,
558 log=self.log
559 )
560 butlerQC.put(measurementResults, outputRefs)
561 raise error from e
562 butlerQC.put(measurementResults, outputRefs)
563
564 @timeMethod
565 def run(self, science, matchedTemplate, difference, kernelSources=None,
566 idFactory=None, measurementResults=None):
567 """Detect and measure sources on a difference image.
568
569 The difference image will be convolved with a gaussian approximation of
570 the PSF to form a maximum likelihood image for detection.
571 Close positive and negative detections will optionally be merged into
572 dipole diaSources.
573 Sky sources, or forced detections in background regions, will optionally
574 be added, and the configured measurement algorithm will be run on all
575 detections.
576
577 Parameters
578 ----------
579 science : `lsst.afw.image.ExposureF`
580 Science exposure that the template was subtracted from.
581 matchedTemplate : `lsst.afw.image.ExposureF`
582 Warped and PSF-matched template that was used produce the
583 difference image.
584 difference : `lsst.afw.image.ExposureF`
585 Result of subtracting template from the science image.
586 kernelSources : `lsst.afw.table.SourceCatalog`, optional
587 Final selection of sources that was used for psf matching.
588 idFactory : `lsst.afw.table.IdFactory`, optional
589 Generator object used to assign ids to detected sources in the
590 difference image. Ids from this generator are not set until after
591 deblending and merging positive/negative peaks.
592 measurementResults : `lsst.pipe.base.Struct`, optional
593 Result struct that is modified to allow saving of partial outputs
594 for some failure conditions. If the task completes successfully,
595 this is also returned.
596
597 Returns
598 -------
599 measurementResults : `lsst.pipe.base.Struct`
600
601 ``subtractedMeasuredExposure`` : `lsst.afw.image.ExposureF`
602 Subtracted exposure with detection mask applied.
603 ``diaSources`` : `lsst.afw.table.SourceCatalog`
604 The catalog of detected sources.
605 ``differenceBackground`` : `lsst.afw.math.BackgroundList`
606 Background that was subtracted from the difference image.
607 """
608 if measurementResults is None:
609 measurementResults = pipeBase.Struct()
610 if idFactory is None:
611 idFactory = lsst.meas.base.IdGenerator().make_table_id_factory()
612
613 if self.config.doSubtractBackground:
614 # Run background subtraction before clearing the mask planes
615 detectionExposure = difference.clone()
616 background = self.subtractInitialBackground.run(detectionExposure).background
617 else:
618 detectionExposure = difference
619 background = afwMath.BackgroundList()
620
621 self._prepareInputs(detectionExposure)
622
623 # Don't use the idFactory until after deblend+merge, so that we aren't
624 # generating ids that just get thrown away (footprint merge doesn't
625 # know about past ids).
626 table = afwTable.SourceTable.make(self.schema)
627 results = self.detection.run(
628 table=table,
629 exposure=detectionExposure,
630 doSmooth=True,
631 background=background
632 )
633
634 if self.config.doSubtractBackground:
635 # Run background subtraction again after detecting peaks
636 # but before measurement
637 # First update the mask using the detection image
638 difference.setMask(detectionExposure.mask)
639 background = self.subtractFinalBackground.run(difference).background
640
641 # Re-run detection to get final footprints
642 table = afwTable.SourceTable.make(self.schema)
643 results = self.detection.run(
644 table=table,
645 exposure=difference,
646 doSmooth=True,
647 background=background
648 )
649 measurementResults.differenceBackground = background
650
651 if self.config.doDeblend:
652 sources, positives, negatives = self._deblend(difference,
653 results.positive,
654 results.negative)
655
656 else:
657 positives = afwTable.SourceCatalog(self.schema)
658 results.positive.makeSources(positives)
659 negatives = afwTable.SourceCatalog(self.schema)
660 results.negative.makeSources(negatives)
661 sources = results.sources
662
663 self.processResults(science, matchedTemplate, difference,
664 sources, idFactory, kernelSources,
665 positives=positives,
666 negatives=negatives,
667 measurementResults=measurementResults)
668 return measurementResults
669
670 def _prepareInputs(self, difference):
671 """Ensure that we start with an empty detection and deblended mask.
672
673 Parameters
674 ----------
675 difference : `lsst.afw.image.ExposureF`
676 The difference image that will be used for detecting diaSources.
677 The mask plane will be modified in place.
678
679 Raises
680 ------
681 lsst.pipe.base.UpstreamFailureNoWorkFound
682 If the PSF is not usable for measurement.
683 """
684 # Check that we have a valid PSF now before we do more work
685 sigma = difference.psf.computeShape(difference.psf.getAveragePosition()).getDeterminantRadius()
686 if np.isnan(sigma):
687 raise pipeBase.UpstreamFailureNoWorkFound("Invalid PSF detected! PSF width evaluates to NaN.")
688 # Ensure that we start with an empty detection and deblended mask.
689 mask = difference.mask
690 for mp in self.config.clearMaskPlanes:
691 if mp not in mask.getMaskPlaneDict():
692 mask.addMaskPlane(mp)
693 mask &= ~mask.getPlaneBitMask(self.config.clearMaskPlanes)
694
695 def processResults(self, science, matchedTemplate, difference, sources, idFactory,
696 kernelSources=None, positives=None, negatives=None, measurementResults=None):
697 """Measure and process the results of source detection.
698
699 Parameters
700 ----------
701 science : `lsst.afw.image.ExposureF`
702 Science exposure that the template was subtracted from.
703 matchedTemplate : `lsst.afw.image.ExposureF`
704 Warped and PSF-matched template that was used produce the
705 difference image.
706 difference : `lsst.afw.image.ExposureF`
707 Result of subtracting template from the science image.
708 sources : `lsst.afw.table.SourceCatalog`
709 Detected sources on the difference exposure.
710 idFactory : `lsst.afw.table.IdFactory`
711 Generator object used to assign ids to detected sources in the
712 difference image.
713 kernelSources : `lsst.afw.table.SourceCatalog`, optional
714 Final selection of sources that was used for psf matching.
715 positives : `lsst.afw.table.SourceCatalog`, optional
716 Positive polarity footprints.
717 negatives : `lsst.afw.table.SourceCatalog`, optional
718 Negative polarity footprints.
719 measurementResults : `lsst.pipe.base.Struct`, optional
720 Result struct that is modified to allow saving of partial outputs
721 for some failure conditions. If the task completes successfully,
722 this is also returned.
723
724 Returns
725 -------
726 measurementResults : `lsst.pipe.base.Struct`
727
728 ``subtractedMeasuredExposure`` : `lsst.afw.image.ExposureF`
729 Subtracted exposure with detection mask applied.
730 ``diaSources`` : `lsst.afw.table.SourceCatalog`
731 The catalog of detected sources.
732 """
733 if measurementResults is None:
734 measurementResults = pipeBase.Struct()
735 self.metadata["nUnmergedDiaSources"] = len(sources)
736 if self.config.doMerge:
737 # preserve peak schema, if there are any footprints
738 if len(positives) > 0:
739 peakSchema = positives[0].getFootprint().peaks.schema
740 elif len(negatives) > 0:
741 peakSchema = negatives[0].getFootprint().peaks.schema
742 else:
743 peakSchema = afwDetection.PeakTable.makeMinimalSchema()
744 mergeList = afwDetection.FootprintMergeList(self.schema,
745 ["positive", "negative"], peakSchema)
746 initialDiaSources = afwTable.SourceCatalog(self.schema)
747 # Start with positive, as FootprintMergeList will self-merge the
748 # subsequent added catalogs, and we want to try to preserve
749 # deblended positive sources.
750 mergeList.addCatalog(initialDiaSources.table, positives, "positive", minNewPeakDist=0)
751 mergeList.addCatalog(initialDiaSources.table, negatives, "negative", minNewPeakDist=0)
752 mergeList.getFinalSources(initialDiaSources)
753 # Flag as negative those sources that *only* came from the negative
754 # footprint set.
755 initialDiaSources["is_negative"] = initialDiaSources["merge_footprint_negative"] & \
756 ~initialDiaSources["merge_footprint_positive"]
757 self.log.info("Merging detections into %d sources", len(initialDiaSources))
758 else:
759 initialDiaSources = sources
760
761 # Assign source ids at the end: deblend/merge mean that we don't keep
762 # track of parents and children, we only care about the final ids.
763 for source in initialDiaSources:
764 source.setId(idFactory())
765 # Ensure sources added after this get correct ids.
766 initialDiaSources.getTable().setIdFactory(idFactory)
767 initialDiaSources.setMetadata(self.algMetadata)
768
769 self.metadata["nMergedDiaSources"] = len(initialDiaSources)
770
771 if self.config.doMaskStreaks:
772 streakInfo = self._runStreakMasking(difference)
773
774 if self.config.doSkySources:
775 self.addSkySources(initialDiaSources, difference.mask, difference.info.id)
776
777 if not initialDiaSources.isContiguous():
778 initialDiaSources = initialDiaSources.copy(deep=True)
779
780 self.measureDiaSources(initialDiaSources, science, difference, matchedTemplate)
781
782 # Remove unphysical diaSources per config.badSourceFlags
783 diaSources = self._removeBadSources(initialDiaSources)
784
785 if self.config.run_sattle:
786 diaSources = self.filterSatellites(diaSources, science)
787
788 # Flag diaSources in glint trails, but do not remove them
789 diaSources, trail_parameters = self._find_glint_trails(diaSources)
790 if self.config.writeGlintInfo:
791 measurementResults.mergeItems(trail_parameters, 'glintTrailInfo')
792
793 if self.config.doForcedMeasurement:
794 self.measureForcedSources(diaSources, science, difference.getWcs())
795 self.measureForcedSources(diaSources, matchedTemplate, difference.getWcs(),
796 template=True)
797
798 # Clear the image plane for regions with NO_DATA.
799 # These regions are most often caused by insufficient template coverage.
800 # Do this for the final difference image after detection and measurement
801 # since the subtasks should all be configured to handle NO_DATA properly
802 difference.image.array[difference.mask.array & difference.mask.getPlaneBitMask('NO_DATA') > 0] = 0
803
804 measurementResults.subtractedMeasuredExposure = difference
805
806 if self.config.doMaskStreaks and self.config.writeStreakInfo:
807 measurementResults.maskedStreaks = streakInfo.maskedStreaks
808
809 if kernelSources is not None:
810 self.calculateMetrics(science, difference, diaSources, kernelSources)
811
812 if np.count_nonzero(~diaSources["sky_source"]) > 0:
813 measurementResults.diaSources = diaSources
814 elif self.config.raiseOnNoDiaSources:
815 raise NoDiaSourcesError()
816 elif len(diaSources) > 0:
817 # This option allows returning sky sources,
818 # even if there are no diaSources
819 measurementResults.diaSources = diaSources
820 self.log.info("Measured %d diaSources and %d sky sources",
821 np.count_nonzero(~diaSources["sky_source"]),
822 np.count_nonzero(diaSources["sky_source"])
823 )
824 return measurementResults
825
826 def _deblend(self, difference, positiveFootprints, negativeFootprints):
827 """Deblend the positive and negative footprints and return a catalog
828 containing just the children, and the deblended footprints.
829
830 Parameters
831 ----------
832 difference : `lsst.afw.image.Exposure`
833 Result of subtracting template from the science image.
834 positiveFootprints, negativeFootprints : `lsst.afw.detection.FootprintSet`
835 Positive and negative polarity footprints measured on
836 ``difference`` to be deblended separately.
837
838 Returns
839 -------
840 sources : `lsst.afw.table.SourceCatalog`
841 Positive and negative deblended children.
842 positives, negatives : `lsst.afw.table.SourceCatalog`
843 Deblended positive and negative polarity sources with footprints
844 detected on ``difference``.
845 """
846
847 def deblend(footprints, negative=False):
848 """Deblend a positive or negative footprint set,
849 and return the deblended children.
850
851 Parameters
852 ----------
853 footprints : `lsst.afw.detection.FootprintSet`
854 negative : `bool`
855 Set True if the footprints contain negative fluxes
856
857 Returns
858 -------
859 sources : `lsst.afw.table.SourceCatalog`
860 """
861 sources = afwTable.SourceCatalog(self.schema)
862 footprints.makeSources(sources)
863 if negative:
864 # Invert the image so the deblender can run on positive peaks
865 difference_inverted = difference.clone()
866 difference_inverted.image *= -1
867 self.deblend.run(exposure=difference_inverted, sources=sources)
868 children = sources[sources["parent"] != 0]
869 # Set the heavy footprint pixel values back to reality
870 for child in children:
871 footprint = child.getFootprint()
872 array = footprint.getImageArray()
873 array *= -1
874 else:
875 self.deblend.run(exposure=difference, sources=sources)
876 self.setPrimaryFlags.run(sources)
877 children = sources["detect_isDeblendedSource"] == 1
878 sources = sources[children].copy(deep=True)
879 # Clear parents, so that measurement plugins behave correctly.
880 sources['parent'] = 0
881 return sources.copy(deep=True)
882
883 positives = deblend(positiveFootprints)
884 negatives = deblend(negativeFootprints, negative=True)
885
886 sources = afwTable.SourceCatalog(self.schema)
887 sources.reserve(len(positives) + len(negatives))
888 sources.extend(positives, deep=True)
889 sources.extend(negatives, deep=True)
890 if len(negatives) > 0:
891 sources[-len(negatives):]["is_negative"] = True
892 return sources, positives, negatives
893
894 def _removeBadSources(self, diaSources):
895 """Remove unphysical diaSources from the catalog.
896
897 Parameters
898 ----------
899 diaSources : `lsst.afw.table.SourceCatalog`
900 The catalog of detected sources.
901
902 Returns
903 -------
904 diaSources : `lsst.afw.table.SourceCatalog`
905 The updated catalog of detected sources, with any source that has a
906 flag in ``config.badSourceFlags`` set removed.
907 """
908 selector = np.ones(len(diaSources), dtype=bool)
909 for flag in self.config.badSourceFlags:
910 flags = diaSources[flag]
911 nBad = np.count_nonzero(flags)
912 if nBad > 0:
913 self.log.debug("Found %d unphysical sources with flag %s.", nBad, flag)
914 selector &= ~flags
915 nBadTotal = np.count_nonzero(~selector)
916 self.metadata["nRemovedBadFlaggedSources"] = nBadTotal
917 self.log.info("Removed %d unphysical sources.", nBadTotal)
918 return diaSources[selector].copy(deep=True)
919
920 def _find_glint_trails(self, diaSources):
921 """Define a new flag column for diaSources that are in a glint trail.
922
923 Parameters
924 ----------
925 diaSources : `lsst.afw.table.SourceCatalog`
926 The catalog of detected sources.
927
928 Returns
929 -------
930 diaSources : `lsst.afw.table.SourceCatalog`
931 The updated catalog of detected sources, with a new bool column
932 called 'glint_trail' added.
933
934 trail_parameters : `dict`
935 Parameters of all the trails that were found.
936 """
937 if self.config.doSkySources:
938 # Do not include sky sources in glint detection
939 candidateDiaSources = diaSources[~diaSources["sky_source"]].copy(deep=True)
940 else:
941 candidateDiaSources = diaSources
942 trailed_glints = self.findGlints.run(candidateDiaSources)
943 glint_mask = [True if id in trailed_glints.trailed_ids else False for id in diaSources['id']]
944 if np.any(glint_mask):
945 diaSources['glint_trail'] = np.array(glint_mask)
946
947 slopes = np.array([trail.slope for trail in trailed_glints.parameters])
948 intercepts = np.array([trail.intercept for trail in trailed_glints.parameters])
949 stderrs = np.array([trail.stderr for trail in trailed_glints.parameters])
950 lengths = np.array([trail.length for trail in trailed_glints.parameters])
951 angles = np.array([trail.angle for trail in trailed_glints.parameters])
952 parameters = {'slopes': slopes, 'intercepts': intercepts, 'stderrs': stderrs, 'lengths': lengths,
953 'angles': angles}
954
955 trail_parameters = pipeBase.Struct(glintTrailInfo=parameters)
956
957 return diaSources, trail_parameters
958
959 def addSkySources(self, diaSources, mask, seed,
960 subtask=None):
961 """Add sources in empty regions of the difference image
962 for measuring the background.
963
964 Parameters
965 ----------
966 diaSources : `lsst.afw.table.SourceCatalog`
967 The catalog of detected sources.
968 mask : `lsst.afw.image.Mask`
969 Mask plane for determining regions where Sky sources can be added.
970 seed : `int`
971 Seed value to initialize the random number generator.
972 """
973 if subtask is None:
974 subtask = self.skySources
975 if subtask.config.nSources <= 0:
976 self.metadata[f"n_{subtask.getName()}"] = 0
977 return
978 skySourceFootprints = subtask.run(mask=mask, seed=seed, catalog=diaSources)
979 self.metadata[f"n_{subtask.getName()}"] = len(skySourceFootprints)
980
981 def measureDiaSources(self, diaSources, science, difference, matchedTemplate):
982 """Use (matched) template and science image to constrain dipole fitting.
983
984 Parameters
985 ----------
986 diaSources : `lsst.afw.table.SourceCatalog`
987 The catalog of detected sources.
988 science : `lsst.afw.image.ExposureF`
989 Science exposure that the template was subtracted from.
990 difference : `lsst.afw.image.ExposureF`
991 Result of subtracting template from the science image.
992 matchedTemplate : `lsst.afw.image.ExposureF`
993 Warped and PSF-matched template that was used produce the
994 difference image.
995 """
996 # Ensure that the required mask planes are present
997 for mp in self.config.measurement.plugins["base_PixelFlags"].masksFpAnywhere:
998 difference.mask.addMaskPlane(mp)
999 # Note that this may not be correct if we convolved the science image.
1000 # In the future we may wish to persist the matchedScience image.
1001 self.measurement.run(diaSources, difference, science, matchedTemplate)
1002 if self.config.doApCorr:
1003 apCorrMap = difference.getInfo().getApCorrMap()
1004 if apCorrMap is None:
1005 self.log.warning("Difference image does not have valid aperture correction; skipping.")
1006 else:
1007 self.applyApCorr.run(
1008 catalog=diaSources,
1009 apCorrMap=apCorrMap,
1010 )
1011
1012 def measureForcedSources(self, diaSources, image, wcs, template=False):
1013 """Perform forced measurement of the diaSources on a direct image.
1014
1015 Parameters
1016 ----------
1017 diaSources : `lsst.afw.table.SourceCatalog`
1018 The catalog of detected sources.
1019 image: `lsst.afw.image.ExposureF`
1020 Exposure that the forced measurement is being performed on
1021 wcs : `lsst.afw.geom.SkyWcs`
1022 Coordinate system definition (wcs) for the exposure.
1023 template : `bool`
1024 Is the forced measurement being performed on the template?
1025 """
1026 # Run forced psf photometry on the image at the diaSource locations.
1027 # Copy the measured flux and error into the diaSource.
1028 forcedSources = self.forcedMeasurement.generateMeasCat(image, diaSources, wcs)
1029 self.forcedMeasurement.run(forcedSources, image, diaSources, wcs)
1030
1031 if template:
1032 base_key = 'ip_diffim_forced_template_PsfFlux'
1033 else:
1034 base_key = 'ip_diffim_forced_PsfFlux'
1035 mapper = afwTable.SchemaMapper(forcedSources.schema, diaSources.schema)
1036 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_instFlux")[0],
1037 f"{base_key}_instFlux", True)
1038 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_instFluxErr")[0],
1039 f"{base_key}_instFluxErr", True)
1040 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_area")[0],
1041 f"{base_key}_area", True)
1042 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_flag")[0],
1043 f"{base_key}_flag", True)
1044 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_flag_noGoodPixels")[0],
1045 f"{base_key}_flag_noGoodPixels", True)
1046 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_flag_edge")[0],
1047 f"{base_key}_flag_edge", True)
1048 for diaSource, forcedSource in zip(diaSources, forcedSources):
1049 diaSource.assign(forcedSource, mapper)
1050
1051 def calculateMetrics(self, science, difference, diaSources, kernelSources):
1052 """Add difference image QA metrics to the Task metadata.
1053
1054 This may be used to produce corresponding metrics (see
1055 lsst.analysis.tools.tasks.diffimTaskDetectorVisitMetricAnalysis).
1056
1057 Parameters
1058 ----------
1059 science : `lsst.afw.image.ExposureF`
1060 Science exposure that was subtracted.
1061 difference : `lsst.afw.image.Exposure`
1062 The target difference image to calculate metrics for.
1063 diaSources : `lsst.afw.table.SourceCatalog`
1064 The catalog of detected sources.
1065 kernelSources : `lsst.afw.table.SourceCatalog`
1066 Final selection of sources that was used for psf matching.
1067 """
1068 mask = difference.mask
1069 badPix = (mask.array & mask.getPlaneBitMask(self.config.detection.excludeMaskPlanes)) > 0
1070 self.metadata["nGoodPixels"] = np.sum(~badPix)
1071 self.metadata["nBadPixels"] = np.sum(badPix)
1072 detPosPix = (mask.array & mask.getPlaneBitMask("DETECTED")) > 0
1073 detNegPix = (mask.array & mask.getPlaneBitMask("DETECTED_NEGATIVE")) > 0
1074 self.metadata["nPixelsDetectedPositive"] = np.sum(detPosPix)
1075 self.metadata["nPixelsDetectedNegative"] = np.sum(detNegPix)
1076 detPosPix &= badPix
1077 detNegPix &= badPix
1078 self.metadata["nBadPixelsDetectedPositive"] = np.sum(detPosPix)
1079 self.metadata["nBadPixelsDetectedNegative"] = np.sum(detNegPix)
1080
1081 metricsMaskPlanes = list(mask.getMaskPlaneDict().keys())
1082 for maskPlane in metricsMaskPlanes:
1083 try:
1084 self.metadata["%s_mask_fraction"%maskPlane.lower()] = evaluateMaskFraction(mask, maskPlane)
1085 except InvalidParameterError:
1086 self.metadata["%s_mask_fraction"%maskPlane.lower()] = -1
1087 self.log.info("Unable to calculate metrics for mask plane %s: not in image"%maskPlane)
1088
1089 if self.config.doSkySources:
1090 skySources = diaSources[diaSources["sky_source"]]
1091 else:
1092 skySources = None
1093 metrics = computeDifferenceImageMetrics(science, difference, kernelSources, sky_sources=skySources)
1094
1095 self.metadata["residualFootprintRatioMean"] = metrics.differenceFootprintRatioMean
1096 self.metadata["residualFootprintRatioStdev"] = metrics.differenceFootprintRatioStdev
1097 self.metadata["differenceFootprintSkyRatioMean"] = metrics.differenceFootprintSkyRatioMean
1098 self.metadata["differenceFootprintSkyRatioStdev"] = metrics.differenceFootprintSkyRatioStdev
1099 self.log.info("Mean, stdev of ratio of difference to science "
1100 "pixels in star footprints: %5.4f, %5.4f",
1101 self.metadata["residualFootprintRatioMean"],
1102 self.metadata["residualFootprintRatioStdev"])
1103 if self.config.raiseOnBadSubtractionRatio:
1104 if metrics.differenceFootprintRatioMean > self.config.badSubtractionRatioThreshold:
1105 raise BadSubtractionError(ratio=metrics.differenceFootprintRatioMean,
1106 threshold=self.config.badSubtractionRatioThreshold)
1107 if metrics.differenceFootprintRatioStdev > self.config.badSubtractionVariationThreshold:
1108 raise BadSubtractionError(ratio=metrics.differenceFootprintRatioStdev,
1109 threshold=self.config.badSubtractionVariationThreshold)
1110
1111 def getSattleDiaSourceAllowlist(self, diaSources, science):
1112 """Query the sattle service and determine which diaSources are allowed.
1113
1114 Parameters
1115 ----------
1116 diaSources : `lsst.afw.table.SourceCatalog`
1117 The catalog of detected sources.
1118 science : `lsst.afw.image.ExposureF`
1119 Science exposure that was subtracted.
1120
1121 Returns
1122 ----------
1123 allow_list : `list` of `int`
1124 diaSourceIds of diaSources that can be made public.
1125
1126 Raises
1127 ------
1128 requests.HTTPError
1129 Raised if sattle call does not return success.
1130 """
1131 wcs = science.getWcs()
1132 visit_info = science.getInfo().getVisitInfo()
1133 visit_id = visit_info.getId()
1134 sattle_uri_base = os.getenv('SATTLE_URI_BASE')
1135
1136 dia_sources_json = []
1137 for source in diaSources:
1138 source_bbox = source.getFootprint().getBBox()
1139 corners = wcs.pixelToSky([lsst.geom.Point2D(c) for c in source_bbox.getCorners()])
1140 bbox_radec = [[pt.getRa().asDegrees(), pt.getDec().asDegrees()] for pt in corners]
1141 dia_sources_json.append({"diasource_id": source["id"], "bbox": bbox_radec})
1142
1143 payload = {"visit_id": visit_id, "detector_id": science.getDetector().getId(),
1144 "diasources": dia_sources_json, "historical": self.config.sattle_historical}
1145
1146 sattle_output = requests.put(f'{sattle_uri_base}/diasource_allow_list',
1147 json=payload)
1148
1149 # retry once if visit cache is not populated
1150 if sattle_output.status_code == 404:
1151 self.log.warning(f'Visit {visit_id} not found in sattle cache, re-sending')
1152 populate_sattle_visit_cache(visit_info, historical=self.config.sattle_historical)
1153 sattle_output = requests.put(f'{sattle_uri_base}/diasource_allow_list', json=payload)
1154
1155 sattle_output.raise_for_status()
1156
1157 return sattle_output.json()['allow_list']
1158
1159 def filterSatellites(self, diaSources, science):
1160 """Remove diaSources overlapping predicted satellite positions.
1161
1162 Parameters
1163 ----------
1164 diaSources : `lsst.afw.table.SourceCatalog`
1165 The catalog of detected sources.
1166 science : `lsst.afw.image.ExposureF`
1167 Science exposure that was subtracted.
1168
1169 Returns
1170 ----------
1171 filterdDiaSources : `lsst.afw.table.SourceCatalog`
1172 Filtered catalog of diaSources
1173 """
1174
1175 allow_list = self.getSattleDiaSourceAllowlist(diaSources, science)
1176
1177 if allow_list:
1178 allow_set = set(allow_list)
1179 allowed_ids = [source['id'] in allow_set for source in diaSources]
1180 diaSources = diaSources[np.array(allowed_ids)].copy(deep=True)
1181 else:
1182 self.log.warning('Sattle allowlist is empty, all diaSources removed')
1183 diaSources = diaSources[0:0].copy(deep=True)
1184 return diaSources
1185
1186 def _runStreakMasking(self, difference):
1187 """Do streak masking and optionally save the resulting streak
1188 fit parameters in a catalog.
1189
1190 Only returns non-empty streakInfo if self.config.writeStreakInfo
1191 is set. The difference image is binned by self.config.streakBinFactor
1192 (and detection is run a second time) so that regions with lower
1193 surface brightness streaks are more likely to fall above the
1194 detection threshold.
1195
1196 Parameters
1197 ----------
1198 difference: `lsst.afw.image.Exposure`
1199 The exposure in which to search for streaks. Must have a detection
1200 mask.
1201
1202 Returns
1203 -------
1204 streakInfo: `lsst.pipe.base.Struct`
1205 ``rho`` : `np.ndarray`
1206 Angle of detected streak.
1207 ``theta`` : `np.ndarray`
1208 Distance from center of detected streak.
1209 ``sigma`` : `np.ndarray`
1210 Width of streak profile.
1211 ``reducedChi2`` : `np.ndarray`
1212 Reduced chi2 of the best-fit streak profile.
1213 ``modelMaximum`` : `np.ndarray`
1214 Peak value of the fit line profile.
1215 """
1216 maskedImage = difference.maskedImage
1217 # Bin the diffim to enhance low surface brightness streaks
1218 binnedMaskedImage = afwMath.binImage(maskedImage,
1219 self.config.streakBinFactor,
1220 self.config.streakBinFactor)
1221 binnedExposure = afwImage.ExposureF(binnedMaskedImage.getBBox())
1222 binnedExposure.setMaskedImage(binnedMaskedImage)
1223 # Clear the DETECTED mask plane before streak detection
1224 binnedExposure.mask &= ~binnedExposure.mask.getPlaneBitMask('DETECTED')
1225 # Rerun detection to set the DETECTED mask plane on binnedExposure
1226 sigma = difference.psf.computeShape(difference.psf.getAveragePosition()).getDeterminantRadius()
1227 _table = afwTable.SourceTable.make(afwTable.SourceTable.makeMinimalSchema())
1228 self.streakDetection.run(table=_table, exposure=binnedExposure, doSmooth=True,
1229 sigma=sigma/self.config.streakBinFactor)
1230 binnedDetectedMaskPlane = binnedExposure.mask.array & binnedExposure.mask.getPlaneBitMask('DETECTED')
1231 rescaledDetectedMaskPlane = binnedDetectedMaskPlane.repeat(self.config.streakBinFactor,
1232 axis=0).repeat(self.config.streakBinFactor,
1233 axis=1)
1234 # Create new version of a diffim with DETECTED based on binnedExposure
1235 streakMaskedImage = maskedImage.clone()
1236 ysize, xsize = rescaledDetectedMaskPlane.shape
1237 streakMaskedImage.mask.array[:ysize, :xsize] |= rescaledDetectedMaskPlane
1238 # Detect streaks on this new version of the diffim
1239 streaks = self.maskStreaks.run(streakMaskedImage)
1240 streakMaskPlane = streakMaskedImage.mask.array & streakMaskedImage.mask.getPlaneBitMask('STREAK')
1241 # Apply the new STREAK mask to the original diffim
1242 maskedImage.mask.array |= streakMaskPlane
1243
1244 if self.config.writeStreakInfo:
1245 rhos = np.array([line.rho for line in streaks.lines])
1246 thetas = np.array([line.theta for line in streaks.lines])
1247 sigmas = np.array([line.sigma for line in streaks.lines])
1248 chi2s = np.array([line.reducedChi2 for line in streaks.lines])
1249 modelMaximums = np.array([line.modelMaximum for line in streaks.lines])
1250 streakInfo = {'rho': rhos, 'theta': thetas, 'sigma': sigmas, 'reducedChi2': chi2s,
1251 'modelMaximum': modelMaximums}
1252 else:
1253 streakInfo = {'rho': np.array([]), 'theta': np.array([]), 'sigma': np.array([]),
1254 'reducedChi2': np.array([]), 'modelMaximum': np.array([])}
1255 return pipeBase.Struct(maskedStreaks=streakInfo)
1256
1257
1258class DetectAndMeasureScoreConnections(DetectAndMeasureConnections):
1259 scoreExposure = pipeBase.connectionTypes.Input(
1260 doc="Maximum likelihood image for detection.",
1261 dimensions=("instrument", "visit", "detector"),
1262 storageClass="ExposureF",
1263 name="{fakesType}{coaddName}Diff_scoreExp",
1264 )
1265
1266
1267class DetectAndMeasureScoreConfig(DetectAndMeasureConfig,
1268 pipelineConnections=DetectAndMeasureScoreConnections):
1269 pass
1270
1271
1272class DetectAndMeasureScoreTask(DetectAndMeasureTask):
1273 """Detect DIA sources using a score image,
1274 and measure the detections on the difference image.
1275
1276 Source detection is run on the supplied score, or maximum likelihood,
1277 image. Note that no additional convolution will be done in this case.
1278 Close positive and negative detections will optionally be merged into
1279 dipole diaSources.
1280 Sky sources, or forced detections in background regions, will optionally
1281 be added, and the configured measurement algorithm will be run on all
1282 detections.
1283 """
1284 ConfigClass = DetectAndMeasureScoreConfig
1285 _DefaultName = "detectAndMeasureScore"
1286
1287 @timeMethod
1288 def run(self, science, matchedTemplate, difference, scoreExposure, kernelSources,
1289 idFactory=None):
1290 """Detect and measure sources on a score image.
1291
1292 Parameters
1293 ----------
1294 science : `lsst.afw.image.ExposureF`
1295 Science exposure that the template was subtracted from.
1296 matchedTemplate : `lsst.afw.image.ExposureF`
1297 Warped and PSF-matched template that was used produce the
1298 difference image.
1299 difference : `lsst.afw.image.ExposureF`
1300 Result of subtracting template from the science image.
1301 scoreExposure : `lsst.afw.image.ExposureF`
1302 Score or maximum likelihood difference image
1303 kernelSources : `lsst.afw.table.SourceCatalog`
1304 Final selection of sources that was used for psf matching.
1305 idFactory : `lsst.afw.table.IdFactory`, optional
1306 Generator object used to assign ids to detected sources in the
1307 difference image. Ids from this generator are not set until after
1308 deblending and merging positive/negative peaks.
1309
1310 Returns
1311 -------
1312 measurementResults : `lsst.pipe.base.Struct`
1313
1314 ``subtractedMeasuredExposure`` : `lsst.afw.image.ExposureF`
1315 Subtracted exposure with detection mask applied.
1316 ``diaSources`` : `lsst.afw.table.SourceCatalog`
1317 The catalog of detected sources.
1318 """
1319 if idFactory is None:
1320 idFactory = lsst.meas.base.IdGenerator().make_table_id_factory()
1321
1322 self._prepareInputs(scoreExposure)
1323
1324 # Don't use the idFactory until after deblend+merge, so that we aren't
1325 # generating ids that just get thrown away (footprint merge doesn't
1326 # know about past ids).
1327 table = afwTable.SourceTable.make(self.schema)
1328 results = self.detection.run(
1329 table=table,
1330 exposure=scoreExposure,
1331 doSmooth=False,
1332 )
1333 # Copy the detection mask from the Score image to the difference image
1334 difference.mask.assign(scoreExposure.mask, scoreExposure.getBBox())
1335
1336 if self.config.doDeblend:
1337 sources, positives, negatives = self._deblend(difference,
1338 results.positive,
1339 results.negative)
1340
1341 return self.processResults(science, matchedTemplate, difference,
1342 sources, idFactory, kernelSources,
1343 positives=positives,
1344 negatives=negatives)
1345
1346 else:
1347 positives = afwTable.SourceCatalog(self.schema)
1348 results.positive.makeSources(positives)
1349 negatives = afwTable.SourceCatalog(self.schema)
1350 results.negative.makeSources(negatives)
1351 return self.processResults(science, matchedTemplate, difference,
1352 results.sources, idFactory, kernelSources,
1353 positives=positives,
1354 negatives=negatives)
A mapping between the keys of two Schemas, used to copy data between them.
Class for storing ordered metadata with comments.
std::shared_ptr< ImageT > binImage(ImageT const &inImage, int const binX, int const binY, lsst::afw::math::Property const flags=lsst::afw::math::MEAN)
Definition binImage.cc:44
run(self, *, coaddExposureHandles, bbox, wcs, dataIds, physical_filter)