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