Coverage for python/lsst/ip/diffim/detectAndMeasure.py: 87%

598 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-26 09:34 +0000

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, FindCosmicRaysConfig, findCosmicRays 

36from lsst.meas.base import ForcedMeasurementTask, ApplyApCorrTask, DetectorVisitIdGeneratorConfig 

37import lsst.meas.deblender 

38import lsst.meas.extensions.trailedSources # noqa: F401 

39import lsst.meas.extensions.shapeHSM 

40import lsst.pex.config as pexConfig 

41from lsst.pex.exceptions import InvalidParameterError, LengthError 

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 TooManyCosmicRays(pipeBase.AlgorithmError): 

85 """Raised if the cosmic ray task fails with too many cosmics. 

86 

87 Parameters 

88 ---------- 

89 maxCosmicRays : `int` 

90 Maximum number of cosmic rays allowed. 

91 """ 

92 def __init__(self, maxCosmicRays, **kwargs): 

93 msg = f"Cosmic ray task found more than {maxCosmicRays} cosmics." 

94 self.msg = msg 

95 self._metadata = kwargs 

96 super().__init__(msg, **kwargs) 

97 self._metadata["maxCosmicRays"] = maxCosmicRays 

98 

99 def __str__(self): 

100 # Exception doesn't handle **kwargs, so we need a custom str. 

101 return f"{self.msg}: {self.metadata}" 

102 

103 @property 

104 def metadata(self): 

105 for key, value in self._metadata.items(): 

106 if not (isinstance(value, int) or isinstance(value, float) or isinstance(value, str)): 

107 raise TypeError(f"{key} is of type {type(value)}, but only (int, float, str) are allowed.") 

108 return self._metadata 

109 

110 

111class DetectAndMeasureConnections(pipeBase.PipelineTaskConnections, 

112 dimensions=("instrument", "visit", "detector"), 

113 defaultTemplates={"coaddName": "deep", 

114 "warpTypeSuffix": "", 

115 "fakesType": ""}): 

116 science = pipeBase.connectionTypes.Input( 

117 doc="Input science exposure.", 

118 dimensions=("instrument", "visit", "detector"), 

119 storageClass="ExposureF", 

120 name="{fakesType}calexp" 

121 ) 

122 matchedTemplate = pipeBase.connectionTypes.Input( 

123 doc="Warped and PSF-matched template used to create the difference image.", 

124 dimensions=("instrument", "visit", "detector"), 

125 storageClass="ExposureF", 

126 name="{fakesType}{coaddName}Diff_matchedExp", 

127 ) 

128 difference = pipeBase.connectionTypes.Input( 

129 doc="Result of subtracting template from science.", 

130 dimensions=("instrument", "visit", "detector"), 

131 storageClass="ExposureF", 

132 name="{fakesType}{coaddName}Diff_differenceTempExp", 

133 ) 

134 kernelSources = pipeBase.connectionTypes.Input( 

135 doc="Final selection of sources used for psf matching.", 

136 dimensions=("instrument", "visit", "detector"), 

137 storageClass="SourceCatalog", 

138 name="{fakesType}{coaddName}Diff_psfMatchSources" 

139 ) 

140 outputSchema = pipeBase.connectionTypes.InitOutput( 

141 doc="Schema (as an example catalog) for output DIASource catalog.", 

142 storageClass="SourceCatalog", 

143 name="{fakesType}{coaddName}Diff_diaSrc_schema", 

144 ) 

145 diaSources = pipeBase.connectionTypes.Output( 

146 doc="Detected diaSources on the difference image.", 

147 dimensions=("instrument", "visit", "detector"), 

148 storageClass="SourceCatalog", 

149 name="{fakesType}{coaddName}Diff_diaSrc", 

150 ) 

151 subtractedMeasuredExposure = pipeBase.connectionTypes.Output( 

152 doc="Difference image with detection mask plane filled in.", 

153 dimensions=("instrument", "visit", "detector"), 

154 storageClass="ExposureF", 

155 name="{fakesType}{coaddName}Diff_differenceExp", 

156 ) 

157 differenceBackground = pipeBase.connectionTypes.Output( 

158 doc="Background model that was subtracted from the difference image.", 

159 dimensions=("instrument", "visit", "detector"), 

160 storageClass="Background", 

161 name="difference_background", 

162 ) 

163 maskedStreaks = pipeBase.connectionTypes.Output( 

164 doc='Catalog of streak fit parameters for the difference image.', 

165 storageClass="ArrowNumpyDict", 

166 dimensions=("instrument", "visit", "detector"), 

167 name="{fakesType}{coaddName}Diff_streaks", 

168 ) 

169 glintTrailInfo = pipeBase.connectionTypes.Output( 

170 doc='Dict of fit parameters for glint trails in the catalog.', 

171 storageClass="ArrowNumpyDict", 

172 dimensions=("instrument", "visit", "detector"), 

173 name="trailed_glints", 

174 ) 

175 

176 def __init__(self, *, config): 

177 super().__init__(config=config) 

178 if not (self.config.doCalculateResidualMetics): 

179 self.inputs.remove("kernelSources") 

180 if not (self.config.writeStreakInfo and self.config.doMaskStreaks): 

181 self.outputs.remove("maskedStreaks") 

182 if not (self.config.doSubtractBackground and self.config.doWriteBackground): 

183 self.outputs.remove("differenceBackground") 

184 if not (self.config.writeGlintInfo): 

185 self.outputs.remove("glintTrailInfo") 

186 

187 

188class DetectAndMeasureConfig(pipeBase.PipelineTaskConfig, 

189 pipelineConnections=DetectAndMeasureConnections): 

190 """Config for DetectAndMeasureTask 

191 """ 

192 doMerge = pexConfig.Field( 

193 dtype=bool, 

194 default=True, 

195 doc="Merge positive and negative diaSources with grow radius " 

196 "set by growFootprint" 

197 ) 

198 doForcedMeasurement = pexConfig.Field( 

199 dtype=bool, 

200 default=True, 

201 doc="Force photometer diaSource locations on PVI?" 

202 ) 

203 doAddMetrics = pexConfig.Field( 

204 dtype=bool, 

205 default=False, 

206 doc="Add columns to the source table to hold analysis metrics?" 

207 ) 

208 doSubtractBackground = pexConfig.Field( 

209 dtype=bool, 

210 doc="Subtract a background model from the image before detection?", 

211 default=True, 

212 ) 

213 doWriteBackground = pexConfig.Field( 

214 dtype=bool, 

215 doc="Persist the fitted background model?", 

216 default=False, 

217 ) 

218 doCalculateResidualMetics = pexConfig.Field( 

219 dtype=bool, 

220 doc="Calculate metrics to assess image subtraction quality for the task" 

221 "metadata?", 

222 default=True, 

223 ) 

224 subtractInitialBackground = pexConfig.ConfigurableField( 

225 target=lsst.meas.algorithms.SubtractBackgroundTask, 

226 doc="Task to perform intial background subtraction, before first detection pass.", 

227 ) 

228 subtractFinalBackground = pexConfig.ConfigurableField( 

229 target=lsst.meas.algorithms.SubtractBackgroundTask, 

230 doc="Task to perform final background subtraction, after first detection pass.", 

231 ) 

232 doFindCosmicRays = pexConfig.Field( 

233 dtype=bool, 

234 doc="Detect and mask cosmic rays on the difference image?" 

235 "CRs can be interpolated over by setting cosmicray.keepCRs=False", 

236 default=True, 

237 ) 

238 cosmicray = pexConfig.ConfigField( 

239 dtype=FindCosmicRaysConfig, 

240 doc="Options for finding and masking cosmic rays", 

241 ) 

242 detection = pexConfig.ConfigurableField( 

243 target=SourceDetectionTask, 

244 doc="Final source detection for diaSource measurement", 

245 ) 

246 streakDetection = pexConfig.ConfigurableField( 

247 target=SourceDetectionTask, 

248 doc="Separate source detection used only for streak masking", 

249 ) 

250 doDeblend = pexConfig.Field( 

251 dtype=bool, 

252 default=False, 

253 doc="Deblend DIASources after detection?" 

254 ) 

255 deblend = pexConfig.ConfigurableField( 

256 target=lsst.meas.deblender.SourceDeblendTask, 

257 doc="Task to split blended sources into their components." 

258 ) 

259 measurement = pexConfig.ConfigurableField( 

260 target=DipoleFitTask, 

261 doc="Task to measure sources on the difference image.", 

262 ) 

263 doApCorr = lsst.pex.config.Field( 

264 dtype=bool, 

265 default=True, 

266 doc="Run subtask to apply aperture corrections" 

267 ) 

268 applyApCorr = lsst.pex.config.ConfigurableField( 

269 target=ApplyApCorrTask, 

270 doc="Task to apply aperture corrections" 

271 ) 

272 forcedMeasurement = pexConfig.ConfigurableField( 

273 target=ForcedMeasurementTask, 

274 doc="Task to force photometer science image at diaSource locations.", 

275 ) 

276 growFootprint = pexConfig.Field( 

277 dtype=int, 

278 default=2, 

279 doc="Grow positive and negative footprints by this many pixels before merging" 

280 ) 

281 diaSourceMatchRadius = pexConfig.Field( 

282 dtype=float, 

283 default=0.5, 

284 doc="Match radius (in arcseconds) for DiaSource to Source association" 

285 ) 

286 doSkySources = pexConfig.Field( 

287 dtype=bool, 

288 default=False, 

289 doc="Generate sky sources?", 

290 ) 

291 skySources = pexConfig.ConfigurableField( 

292 target=SkyObjectsTask, 

293 doc="Generate sky sources", 

294 ) 

295 doMaskStreaks = pexConfig.Field( 

296 dtype=bool, 

297 default=True, 

298 doc="Turn on streak masking", 

299 ) 

300 maskStreaks = pexConfig.ConfigurableField( 

301 target=MaskStreaksTask, 

302 doc="Subtask for masking streaks. Only used if doMaskStreaks is True. " 

303 "Adds a mask plane to an exposure, with the mask plane name set by streakMaskName.", 

304 ) 

305 streakBinFactor = pexConfig.Field( 

306 dtype=int, 

307 default=4, 

308 doc="Bin scale factor to use when rerunning detection for masking streaks. " 

309 "Only used if doMaskStreaks is True.", 

310 ) 

311 writeStreakInfo = pexConfig.Field( 

312 dtype=bool, 

313 default=False, 

314 doc="Record the parameters of any detected streaks. For LSST, this should be turned off except for " 

315 "development work." 

316 ) 

317 findGlints = pexConfig.ConfigurableField( 

318 target=FindGlintTrailsTask, 

319 doc="Subtask for finding glint trails, usually caused by satellites or debris." 

320 ) 

321 writeGlintInfo = pexConfig.Field( 

322 dtype=bool, 

323 default=True, 

324 doc="Record the parameters of any detected glint trails." 

325 ) 

326 setPrimaryFlags = pexConfig.ConfigurableField( 

327 target=SetPrimaryFlagsTask, 

328 doc="Task to add isPrimary and deblending-related flags to the catalog." 

329 ) 

330 badSourceFlags = lsst.pex.config.ListField( 

331 dtype=str, 

332 doc="Sources with any of these flags set are removed before writing the output catalog.", 

333 default=("base_PixelFlags_flag_offimage", 

334 "base_PixelFlags_flag_interpolatedCenterAll", 

335 "base_PixelFlags_flag_badCenter", 

336 "base_PixelFlags_flag_edgeCenter", 

337 "base_PixelFlags_flag_nodataCenter", 

338 "base_PixelFlags_flag_saturatedCenter", 

339 "base_PixelFlags_flag_sat_templateCenter", 

340 "base_PixelFlags_flag_spikeCenter", 

341 ), 

342 ) 

343 clearMaskPlanes = lsst.pex.config.ListField( 

344 dtype=str, 

345 doc="Mask planes to clear before running detection.", 

346 default=("DETECTED", "DETECTED_NEGATIVE", "NOT_DEBLENDED", "STREAK"), 

347 ) 

348 doRejectBadMaskPlaneDetections = pexConfig.Field( 

349 dtype=bool, 

350 default=True, 

351 doc="Reject any peaks detected on ``badMaskPlanes`` before measurement." 

352 "These should all be rejected downstream by ``badSourceFlags``, " 

353 "but filtering earlier can save time." 

354 ) 

355 badMaskPlanes = lsst.pex.config.ListField( 

356 dtype=str, 

357 doc="Detections whose footprint peak lies on a pixel with any of these" 

358 " mask planes set will be rejected before measurement." 

359 " Any missing mask planes will be silently ignored.", 

360 default=("NO_DATA", "BAD", "SAT", "EDGE"), 

361 ) 

362 raiseOnBadSubtractionRatio = pexConfig.Field( 

363 dtype=bool, 

364 default=True, 

365 doc="Raise an error if the ratio of power in detected footprints" 

366 " on the difference image to the power in footprints on the science" 

367 " image exceeds ``badSubtractionRatioThreshold``", 

368 ) 

369 badSubtractionRatioThreshold = pexConfig.Field( 

370 dtype=float, 

371 default=0.2, 

372 doc="Maximum ratio of power in footprints on the difference image to" 

373 " the same footprints on the science image." 

374 "Only used if ``raiseOnBadSubtractionRatio`` is set", 

375 ) 

376 badSubtractionVariationThreshold = pexConfig.Field( 

377 dtype=float, 

378 default=0.4, 

379 doc="Maximum standard deviation of the ratio of power in footprints on" 

380 " the difference image to the same footprints on the science image." 

381 "Only used if ``raiseOnBadSubtractionRatio`` is set", 

382 ) 

383 raiseOnNoDiaSources = pexConfig.Field( 

384 dtype=bool, 

385 default=True, 

386 doc="Raise an algorithm error if no diaSources are detected.", 

387 ) 

388 run_sattle = pexConfig.Field( 

389 dtype=bool, 

390 default=False, 

391 doc="If true, dia source bounding boxes will be sent for verification" 

392 "to the sattle service." 

393 ) 

394 sattle_historical = pexConfig.Field( 

395 dtype=bool, 

396 default=False, 

397 doc="If re-running a pipeline that requires sattle, this should be set " 

398 "to True. This will populate sattle's cache with the historic data " 

399 "closest in time to the exposure." 

400 ) 

401 idGenerator = DetectorVisitIdGeneratorConfig.make_field() 

402 

403 def setDefaults(self): 

404 # Background subtraction 

405 # Use a small binsize for the first pass to reduce detections on glints 

406 # and extended structures. Should not affect the detectability of 

407 # faint diaSources 

408 self.subtractInitialBackground.binSize = 8 

409 self.subtractInitialBackground.useApprox = False 

410 self.subtractInitialBackground.statisticsProperty = "MEDIAN" 

411 self.subtractInitialBackground.doFilterSuperPixels = True 

412 self.subtractInitialBackground.ignoredPixelMask = ["BAD", 

413 "EDGE", 

414 "DETECTED", 

415 "DETECTED_NEGATIVE", 

416 "NO_DATA", 

417 ] 

418 # Use a larger binsize for the final background subtraction, to reduce 

419 # over-subtraction of bright objects. 

420 self.subtractFinalBackground.binSize = 40 

421 self.subtractFinalBackground.useApprox = False 

422 self.subtractFinalBackground.statisticsProperty = "MEDIAN" 

423 self.subtractFinalBackground.doFilterSuperPixels = True 

424 self.subtractFinalBackground.ignoredPixelMask = ["BAD", 

425 "EDGE", 

426 "DETECTED", 

427 "DETECTED_NEGATIVE", 

428 "NO_DATA", 

429 ] 

430 # Cosmic ray detection 

431 self.cosmicray.keepCRs = True # do not interpolate over detected CRs 

432 # DiaSource Detection 

433 self.detection.thresholdPolarity = "both" 

434 self.detection.thresholdValue = 5.0 

435 self.detection.reEstimateBackground = False 

436 self.detection.thresholdType = "pixel_stdev" 

437 self.detection.excludeMaskPlanes = [] 

438 

439 # Copy configs for binned streak detection from the base detection task 

440 self.streakDetection.thresholdType = self.detection.thresholdType 

441 self.streakDetection.reEstimateBackground = False 

442 self.streakDetection.excludeMaskPlanes = self.detection.excludeMaskPlanes 

443 self.streakDetection.thresholdValue = self.detection.thresholdValue 

444 # Only detect positive streaks 

445 self.streakDetection.thresholdPolarity = "positive" 

446 # Do not grow detected mask for streaks 

447 self.streakDetection.nSigmaToGrow = 0 

448 # Set the streak mask along the entire fit line, not only where the 

449 # detected mask is set. 

450 self.maskStreaks.onlyMaskDetected = False 

451 # Restrict streak masking from growing too large 

452 self.maskStreaks.maxStreakWidth = 100 

453 # Restrict the number of iterations allowed for fitting streaks 

454 # When the fit is good it should solve quickly, and exit a bad fit quickly 

455 self.maskStreaks.maxFitIter = 10 

456 # Only mask to 2 sigma in width 

457 self.maskStreaks.nSigmaMask = 2 

458 # Threshold for including streaks after the Hough Transform. 

459 # A lower value will detect more features that are less linear. 

460 self.maskStreaks.absMinimumKernelHeight = 2 

461 

462 self.measurement.plugins.names |= ["ext_trailedSources_Naive", 

463 "base_LocalPhotoCalib", 

464 "base_LocalWcs", 

465 "ext_shapeHSM_HsmSourceMoments", 

466 "ext_shapeHSM_HsmPsfMoments", 

467 "base_ClassificationSizeExtendedness", 

468 ] 

469 self.measurement.slots.psfShape = "ext_shapeHSM_HsmPsfMoments" 

470 self.measurement.slots.shape = "ext_shapeHSM_HsmSourceMoments" 

471 self.measurement.plugins["base_SdssCentroid"].maxDistToPeak = 5.0 

472 self.forcedMeasurement.plugins = ["base_TransformedCentroid", "base_PsfFlux"] 

473 self.forcedMeasurement.copyColumns = { 

474 "id": "objectId", "parent": "parentObjectId", "coord_ra": "coord_ra", "coord_dec": "coord_dec"} 

475 self.forcedMeasurement.slots.centroid = "base_TransformedCentroid" 

476 self.forcedMeasurement.slots.shape = None 

477 

478 # Keep track of which footprints contain streaks 

479 self.measurement.plugins["base_PixelFlags"].masksFpAnywhere = [ 

480 "STREAK", "INJECTED", "INJECTED_TEMPLATE", "HIGH_VARIANCE", "SAT_TEMPLATE", "SPIKE"] 

481 self.measurement.plugins["base_PixelFlags"].masksFpCenter = [ 

482 "STREAK", "INJECTED", "INJECTED_TEMPLATE", "HIGH_VARIANCE", "SAT_TEMPLATE", "SPIKE"] 

483 self.skySources.avoidMask = ["DETECTED", "DETECTED_NEGATIVE", "BAD", "NO_DATA", "EDGE"] 

484 

485 def validate(self): 

486 super().validate() 

487 

488 if self.run_sattle: 

489 if not os.getenv("SATTLE_URI_BASE"): 

490 raise pexConfig.FieldValidationError(DetectAndMeasureConfig.run_sattle, self, 

491 "Sattle requested but SATTLE_URI_BASE " 

492 "environment variable not set.") 

493 

494 

495class DetectAndMeasureTask(lsst.pipe.base.PipelineTask): 

496 """Detect and measure sources on a difference image. 

497 """ 

498 ConfigClass = DetectAndMeasureConfig 

499 _DefaultName = "detectAndMeasure" 

500 

501 def __init__(self, **kwargs): 

502 super().__init__(**kwargs) 

503 self.schema = afwTable.SourceTable.makeMinimalSchema() 

504 

505 self.algMetadata = dafBase.PropertyList() 

506 if self.config.doSubtractBackground: 

507 self.makeSubtask("subtractInitialBackground") 

508 self.makeSubtask("subtractFinalBackground") 

509 self.makeSubtask("detection", schema=self.schema) 

510 if self.config.doDeblend: 

511 self.makeSubtask("deblend", schema=self.schema) 

512 self.makeSubtask("setPrimaryFlags", schema=self.schema, isSingleFrame=True) 

513 self.makeSubtask("measurement", schema=self.schema, 

514 algMetadata=self.algMetadata) 

515 if self.config.doApCorr: 515 ↛ 517line 515 didn't jump to line 517 because the condition on line 515 was always true

516 self.makeSubtask("applyApCorr", schema=self.measurement.schema) 

517 if self.config.doForcedMeasurement: 

518 self.schema.addField( 

519 "ip_diffim_forced_PsfFlux_instFlux", "D", 

520 "Forced PSF flux measured on the direct image.", 

521 units="count") 

522 self.schema.addField( 

523 "ip_diffim_forced_PsfFlux_instFluxErr", "D", 

524 "Forced PSF flux error measured on the direct image.", 

525 units="count") 

526 self.schema.addField( 

527 "ip_diffim_forced_PsfFlux_area", "F", 

528 "Forced PSF flux effective area of PSF.", 

529 units="pixel") 

530 self.schema.addField( 

531 "ip_diffim_forced_PsfFlux_flag", "Flag", 

532 "Forced PSF flux general failure flag.") 

533 self.schema.addField( 

534 "ip_diffim_forced_PsfFlux_flag_noGoodPixels", "Flag", 

535 "Forced PSF flux not enough non-rejected pixels in data to attempt the fit.") 

536 self.schema.addField( 

537 "ip_diffim_forced_PsfFlux_flag_edge", "Flag", 

538 "Forced PSF flux object was too close to the edge of the image to use the full PSF model.") 

539 self.schema.addField( 

540 "ip_diffim_forced_template_PsfFlux_instFlux", "D", 

541 "Forced PSF flux measured on the template image.", 

542 units="count") 

543 self.schema.addField( 

544 "ip_diffim_forced_template_PsfFlux_instFluxErr", "D", 

545 "Forced PSF flux error measured on the template image.", 

546 units="count") 

547 self.schema.addField( 

548 "ip_diffim_forced_template_PsfFlux_area", "F", 

549 "Forced template PSF flux effective area of PSF.", 

550 units="pixel") 

551 self.schema.addField( 

552 "ip_diffim_forced_template_PsfFlux_flag", "Flag", 

553 "Forced template PSF flux general failure flag.") 

554 self.schema.addField( 

555 "ip_diffim_forced_template_PsfFlux_flag_noGoodPixels", "Flag", 

556 "Forced template PSF flux not enough non-rejected pixels in data to attempt the fit.") 

557 self.schema.addField( 

558 "ip_diffim_forced_template_PsfFlux_flag_edge", "Flag", 

559 """Forced template PSF flux object was too close to the edge of the image """ 

560 """to use the full PSF model.""") 

561 self.makeSubtask("forcedMeasurement", refSchema=self.schema) 

562 

563 self.schema.addField("refMatchId", "L", "unique id of reference catalog match") 

564 self.schema.addField("srcMatchId", "L", "unique id of source match") 

565 # Create the sky source task for use by metrics, 

566 # even if sky sources are not added to the diaSource catalog 

567 self.makeSubtask("skySources", schema=self.schema) 

568 if self.config.doMaskStreaks: 568 ↛ 571line 568 didn't jump to line 571 because the condition on line 568 was always true

569 self.makeSubtask("maskStreaks") 

570 self.makeSubtask("streakDetection") 

571 self.makeSubtask("findGlints") 

572 self.schema.addField("glint_trail", "Flag", "DiaSource is part of a glint trail.") 

573 self.schema.addField("reliability", type="F", doc="Reliability score of the DiaSource") 

574 self.schema.addField("reliabilityVersion", type=str, size=7, doc="Version of the reliability model") 

575 

576 # To get the "merge_*" fields in the schema; have to re-initialize 

577 # this later, once we have a peak schema post-detection. 

578 lsst.afw.detection.FootprintMergeList(self.schema, ["positive", "negative"]) 

579 

580 # Check that the schema and config are consistent 

581 for flag in self.config.badSourceFlags: 

582 if flag not in self.schema: 

583 raise pipeBase.InvalidQuantumError("Field %s not in schema" % flag) 

584 

585 # initialize InitOutputs 

586 self.outputSchema = afwTable.SourceCatalog(self.schema) 

587 self.outputSchema.getTable().setMetadata(self.algMetadata) 

588 

589 def runQuantum(self, butlerQC: pipeBase.QuantumContext, 

590 inputRefs: pipeBase.InputQuantizedConnection, 

591 outputRefs: pipeBase.OutputQuantizedConnection): 

592 inputs = butlerQC.get(inputRefs) 

593 idGenerator = self.config.idGenerator.apply(butlerQC.quantum.dataId) 

594 idFactory = idGenerator.make_table_id_factory() 

595 # Specify the fields that `annotate` needs below, to ensure they 

596 # exist, even as None. 

597 measurementResults = pipeBase.Struct( 

598 subtractedMeasuredExposure=None, 

599 diaSources=None, 

600 maskedStreaks=None, 

601 differenceBackground=None, 

602 ) 

603 try: 

604 self.run(**inputs, idFactory=idFactory, measurementResults=measurementResults) 

605 except pipeBase.AlgorithmError as e: 

606 error = pipeBase.AnnotatedPartialOutputsError.annotate( 

607 e, 

608 self, 

609 measurementResults.subtractedMeasuredExposure, 

610 measurementResults.diaSources, 

611 measurementResults.maskedStreaks, 

612 log=self.log 

613 ) 

614 butlerQC.put(measurementResults, outputRefs) 

615 raise error from e 

616 butlerQC.put(measurementResults, outputRefs) 

617 

618 @timeMethod 

619 def run(self, science, matchedTemplate, difference, kernelSources=None, 

620 idFactory=None, measurementResults=None): 

621 """Detect and measure sources on a difference image. 

622 

623 The difference image will be convolved with a gaussian approximation of 

624 the PSF to form a maximum likelihood image for detection. 

625 Close positive and negative detections will optionally be merged into 

626 dipole diaSources. 

627 Sky sources, or forced detections in background regions, will optionally 

628 be added, and the configured measurement algorithm will be run on all 

629 detections. 

630 

631 Parameters 

632 ---------- 

633 science : `lsst.afw.image.ExposureF` 

634 Science exposure that the template was subtracted from. 

635 matchedTemplate : `lsst.afw.image.ExposureF` 

636 Warped and PSF-matched template that was used produce the 

637 difference image. 

638 difference : `lsst.afw.image.ExposureF` 

639 Result of subtracting template from the science image. 

640 kernelSources : `lsst.afw.table.SourceCatalog`, optional 

641 Final selection of sources that was used for psf matching. 

642 idFactory : `lsst.afw.table.IdFactory`, optional 

643 Generator object used to assign ids to detected sources in the 

644 difference image. Ids from this generator are not set until after 

645 deblending and merging positive/negative peaks. 

646 measurementResults : `lsst.pipe.base.Struct`, optional 

647 Result struct that is modified to allow saving of partial outputs 

648 for some failure conditions. If the task completes successfully, 

649 this is also returned. 

650 

651 Returns 

652 ------- 

653 measurementResults : `lsst.pipe.base.Struct` 

654 

655 ``subtractedMeasuredExposure`` : `lsst.afw.image.ExposureF` 

656 Subtracted exposure with detection mask applied. 

657 ``diaSources`` : `lsst.afw.table.SourceCatalog` 

658 The catalog of detected sources. 

659 ``differenceBackground`` : `lsst.afw.math.BackgroundList` 

660 Background that was subtracted from the difference image. 

661 """ 

662 if measurementResults is None: 662 ↛ 664line 662 didn't jump to line 664 because the condition on line 662 was always true

663 measurementResults = pipeBase.Struct() 

664 if idFactory is None: 

665 idFactory = lsst.meas.base.IdGenerator().make_table_id_factory() 

666 

667 # Check image properties and clear detection mask planes. 

668 self._prepareInputs(difference) 

669 if self.config.doSubtractBackground: 

670 background = self._fitAndSubtractBackground(differenceExposure=difference) 

671 else: 

672 background = afwMath.BackgroundList() 

673 

674 if self.config.doFindCosmicRays: 674 ↛ 680line 674 didn't jump to line 680 because the condition on line 674 was always true

675 self.findAndMaskCosmicRays(difference) 

676 

677 # Don't use the idFactory until after deblend+merge, so that we aren't 

678 # generating ids that just get thrown away (footprint merge doesn't 

679 # know about past ids). 

680 table = afwTable.SourceTable.make(self.schema) 

681 results = self.detection.run( 

682 table=table, 

683 exposure=difference, 

684 doSmooth=True, 

685 background=background, 

686 clearMask=True, 

687 ) 

688 measurementResults.differenceBackground = background 

689 

690 if self.config.doDeblend: 

691 sources, positives, negatives = self._deblend(difference, 

692 results.positive, 

693 results.negative) 

694 

695 else: 

696 positives = afwTable.SourceCatalog(self.schema) 

697 results.positive.makeSources(positives) 

698 negatives = afwTable.SourceCatalog(self.schema) 

699 results.negative.makeSources(negatives) 

700 sources = results.sources 

701 

702 self.processResults(science, matchedTemplate, difference, 

703 sources, idFactory, kernelSources, 

704 positives=positives, 

705 negatives=negatives, 

706 measurementResults=measurementResults) 

707 return measurementResults 

708 

709 def _prepareInputs(self, difference): 

710 """Ensure that we start with an empty detection and deblended mask. 

711 

712 Parameters 

713 ---------- 

714 difference : `lsst.afw.image.ExposureF` 

715 The difference image that will be used for detecting diaSources. 

716 The mask plane will be modified in place. 

717 

718 Raises 

719 ------ 

720 lsst.pipe.base.UpstreamFailureNoWorkFound 

721 If the PSF is not usable for measurement. 

722 """ 

723 # Check that we have a valid PSF now before we do more work 

724 sigma = difference.psf.computeShape(difference.psf.getAveragePosition()).getDeterminantRadius() 

725 if np.isnan(sigma): 

726 raise pipeBase.UpstreamFailureNoWorkFound("Invalid PSF detected! PSF width evaluates to NaN.") 

727 # Ensure that we start with an empty detection and deblended mask. 

728 mask = difference.mask 

729 for mp in self.config.clearMaskPlanes: 

730 if mp not in mask.getMaskPlaneDict(): 

731 mask.addMaskPlane(mp) 

732 mask &= ~mask.getPlaneBitMask(self.config.clearMaskPlanes) 

733 

734 def _fitAndSubtractBackground(self, differenceExposure, scoreExposure=None): 

735 """Fit the final background to ``differenceExposure``. 

736 

737 A rough background is first fit on the difference and an internal 

738 first-pass detection is run on the (background-subtracted) clone; the 

739 resulting detection mask is then transferred to ``differenceExposure`` 

740 so the final background fit can mask out real sources. This two-pass 

741 scheme is what suppresses spurious detections from glints and 

742 extended structures: the small-binsize initial fit aggressively 

743 over-subtracts those features, and the resulting peak mask lets the 

744 large-binsize final fit produce a clean background. When 

745 ``scoreExposure`` is supplied (score-image variant), the final 

746 background is also subtracted from its image so the caller's final 

747 detection runs on a properly background-subtracted score. 

748 

749 The first-pass detection's `Struct` is discarded; only the mask it 

750 leaves on the clone is used downstream. 

751 

752 Parameters 

753 ---------- 

754 differenceExposure : `lsst.afw.image.ExposureF` 

755 Difference image. The final background is subtracted from its 

756 image in place but its mask is not modified. 

757 scoreExposure : `lsst.afw.image.ExposureF`, optional 

758 Maximum-likelihood score image. When supplied, the final 

759 background is also subtracted from its image. 

760 

761 Returns 

762 ------- 

763 background : `lsst.afw.math.BackgroundList` 

764 The fit final background. 

765 """ 

766 if scoreExposure is None: 

767 detectionExposure = differenceExposure.clone() 

768 background = self.subtractInitialBackground.run(detectionExposure).background 

769 doSmooth = True 

770 else: 

771 # Use a clone of differenceExposure because the background is 

772 # subtracted in place. 

773 background = self.subtractInitialBackground.run(differenceExposure.clone()).background 

774 detectionExposure = scoreExposure.clone() 

775 detectionExposure.image -= background.getImage() 

776 doSmooth = False 

777 table = afwTable.SourceTable.make(self.schema) 

778 self.detection.run( 

779 table=table, 

780 exposure=detectionExposure, 

781 doSmooth=doSmooth, 

782 background=background, 

783 clearMask=True, 

784 ) 

785 # Use the temporary detection mask for the final background subtraction. 

786 # The detection mask planes will be cleared before the final detection 

787 # step, so it is OK if they get set for differenceExposure. 

788 detectedBit = differenceExposure.mask.getPlaneBitMask(["DETECTED"]) 

789 detectedPix = detectionExposure.mask.array & detectedBit > 0 

790 detectedNegativeBit = differenceExposure.mask.getPlaneBitMask(["DETECTED_NEGATIVE"]) 

791 detectedNegativePix = detectionExposure.mask.array & detectedNegativeBit > 0 

792 differenceExposure.mask.array[detectedPix] |= detectedBit 

793 differenceExposure.mask.array[detectedNegativePix] |= detectedNegativeBit 

794 background = self.subtractFinalBackground.run(differenceExposure).background 

795 if scoreExposure is not None: 

796 # The preconvolution kernel is normalized to 1, so the same 

797 # background level applies to the difference and score images. 

798 scoreExposure.image -= background.getImage() 

799 return background 

800 

801 def processResults(self, science, matchedTemplate, difference, sources, idFactory, 

802 kernelSources=None, positives=None, negatives=None, measurementResults=None): 

803 """Measure and process the results of source detection. 

804 

805 Parameters 

806 ---------- 

807 science : `lsst.afw.image.ExposureF` 

808 Science exposure that the template was subtracted from. 

809 matchedTemplate : `lsst.afw.image.ExposureF` 

810 Warped and PSF-matched template that was used produce the 

811 difference image. 

812 difference : `lsst.afw.image.ExposureF` 

813 Result of subtracting template from the science image. 

814 sources : `lsst.afw.table.SourceCatalog` 

815 Detected sources on the difference exposure. 

816 idFactory : `lsst.afw.table.IdFactory` 

817 Generator object used to assign ids to detected sources in the 

818 difference image. 

819 kernelSources : `lsst.afw.table.SourceCatalog`, optional 

820 Final selection of sources that was used for psf matching. 

821 positives : `lsst.afw.table.SourceCatalog`, optional 

822 Positive polarity footprints. 

823 negatives : `lsst.afw.table.SourceCatalog`, optional 

824 Negative polarity footprints. 

825 measurementResults : `lsst.pipe.base.Struct`, optional 

826 Result struct that is modified to allow saving of partial outputs 

827 for some failure conditions. If the task completes successfully, 

828 this is also returned. 

829 

830 Returns 

831 ------- 

832 measurementResults : `lsst.pipe.base.Struct` 

833 

834 ``subtractedMeasuredExposure`` : `lsst.afw.image.ExposureF` 

835 Subtracted exposure with detection mask applied. 

836 ``diaSources`` : `lsst.afw.table.SourceCatalog` 

837 The catalog of detected sources. 

838 """ 

839 if measurementResults is None: 839 ↛ 840line 839 didn't jump to line 840 because the condition on line 839 was never true

840 measurementResults = pipeBase.Struct() 

841 self.metadata["nUnmergedDiaSources"] = len(sources) 

842 if self.config.doMerge: 

843 # preserve peak schema, if there are any footprints 

844 if len(positives) > 0: 

845 peakSchema = positives[0].getFootprint().peaks.schema 

846 elif len(negatives) > 0: 846 ↛ 849line 846 didn't jump to line 849 because the condition on line 846 was always true

847 peakSchema = negatives[0].getFootprint().peaks.schema 

848 else: 

849 peakSchema = afwDetection.PeakTable.makeMinimalSchema() 

850 mergeList = afwDetection.FootprintMergeList(self.schema, 

851 ["positive", "negative"], peakSchema) 

852 initialDiaSources = afwTable.SourceCatalog(self.schema) 

853 # Start with positive, as FootprintMergeList will self-merge the 

854 # subsequent added catalogs, and we want to try to preserve 

855 # deblended positive sources. 

856 mergeList.addCatalog(initialDiaSources.table, positives, "positive", minNewPeakDist=0) 

857 mergeList.addCatalog(initialDiaSources.table, negatives, "negative", minNewPeakDist=0) 

858 mergeList.getFinalSources(initialDiaSources) 

859 # Flag as negative those sources that *only* came from the negative 

860 # footprint set. 

861 initialDiaSources["is_negative"] = initialDiaSources["merge_footprint_negative"] & \ 

862 ~initialDiaSources["merge_footprint_positive"] 

863 self.log.info("Merging detections into %d sources", len(initialDiaSources)) 

864 # All positive peaks were added before any negative peaks, so 

865 # re-order the peaks in each footprint by significance. 

866 self._reorderPeaksBySignificance(initialDiaSources) 

867 else: 

868 initialDiaSources = sources 

869 

870 # Assign source ids at the end: deblend/merge mean that we don't keep 

871 # track of parents and children, we only care about the final ids. 

872 for source in initialDiaSources: 

873 source.setId(idFactory()) 

874 # Ensure sources added after this get correct ids. 

875 initialDiaSources.getTable().setIdFactory(idFactory) 

876 initialDiaSources.setMetadata(self.algMetadata) 

877 

878 self.metadata["nMergedDiaSources"] = len(initialDiaSources) 

879 

880 if self.config.doMaskStreaks: 880 ↛ 883line 880 didn't jump to line 883 because the condition on line 880 was always true

881 streakInfo = self._runStreakMasking(difference) 

882 

883 if self.config.doSkySources: 

884 self.addSkySources(initialDiaSources, difference.mask, difference.info.id) 

885 

886 if self.config.doRejectBadMaskPlaneDetections: 886 ↛ 891line 886 didn't jump to line 891 because the condition on line 886 was always true

887 # Save time by rejecting peaks on bad mask planes prior to 

888 # measurement. 

889 initialDiaSources = self._rejectBadMaskedDetections(initialDiaSources, difference.mask) 

890 

891 if not initialDiaSources.isContiguous(): 891 ↛ 892line 891 didn't jump to line 892 because the condition on line 891 was never true

892 initialDiaSources = initialDiaSources.copy(deep=True) 

893 

894 self.measureDiaSources(initialDiaSources, science, difference, matchedTemplate) 

895 

896 # Remove unphysical diaSources per config.badSourceFlags 

897 diaSources = self._removeBadSources(initialDiaSources) 

898 

899 if self.config.run_sattle: 

900 diaSources = self.filterSatellites(diaSources, science) 

901 

902 # Flag diaSources in glint trails, but do not remove them 

903 diaSources, trail_parameters = self._find_glint_trails(diaSources) 

904 if self.config.writeGlintInfo: 904 ↛ 907line 904 didn't jump to line 907 because the condition on line 904 was always true

905 measurementResults.mergeItems(trail_parameters, 'glintTrailInfo') 

906 

907 if self.config.doForcedMeasurement: 

908 self.measureForcedSources(diaSources, science, difference.getWcs()) 

909 self.measureForcedSources(diaSources, matchedTemplate, difference.getWcs(), 

910 template=True) 

911 

912 # Clear the image plane for regions with NO_DATA. 

913 # These regions are most often caused by insufficient template coverage. 

914 # Do this for the final difference image after detection and measurement 

915 # since the subtasks should all be configured to handle NO_DATA properly 

916 difference.image.array[difference.mask.array & difference.mask.getPlaneBitMask('NO_DATA') > 0] = 0 

917 

918 measurementResults.subtractedMeasuredExposure = difference 

919 

920 if self.config.doMaskStreaks and self.config.writeStreakInfo: 920 ↛ 921line 920 didn't jump to line 921 because the condition on line 920 was never true

921 measurementResults.maskedStreaks = streakInfo.maskedStreaks 

922 

923 if kernelSources is not None: 

924 self.calculateMetrics(science, difference, diaSources, kernelSources) 

925 

926 if np.count_nonzero(~diaSources["sky_source"]) > 0: 

927 measurementResults.diaSources = diaSources 

928 elif self.config.raiseOnNoDiaSources: 

929 raise NoDiaSourcesError() 

930 elif len(diaSources) > 0: 930 ↛ 934line 930 didn't jump to line 934 because the condition on line 930 was always true

931 # This option allows returning sky sources, 

932 # even if there are no diaSources 

933 measurementResults.diaSources = diaSources 

934 self.log.info("Measured %d diaSources and %d sky sources", 

935 np.count_nonzero(~diaSources["sky_source"]), 

936 np.count_nonzero(diaSources["sky_source"]) 

937 ) 

938 return measurementResults 

939 

940 def _reorderPeaksBySignificance(self, diaSources): 

941 """Sort each merged source's footprint peaks by significance. 

942 

943 Parameters 

944 ---------- 

945 diaSources : `lsst.afw.table.SourceCatalog` 

946 Merged sources whose footprint peaks are re-ordered in place. 

947 """ 

948 for source in diaSources: 

949 peaks = source.getFootprint().getPeaks() 

950 if len(peaks) < 2 or "significance" not in peaks.schema.getNames(): 

951 continue 

952 ordered = sorted(peaks, key=lambda peak: peak["significance"], reverse=True) 

953 newPeaks = afwDetection.PeakCatalog(peaks.table) 

954 newPeaks.extend(ordered) 

955 peaks.clear() 

956 source.getFootprint().setPeakCatalog(newPeaks) 

957 

958 def _deblend(self, difference, positiveFootprints, negativeFootprints): 

959 """Deblend the positive and negative footprints and return a catalog 

960 containing just the children, and the deblended footprints. 

961 

962 Parameters 

963 ---------- 

964 difference : `lsst.afw.image.Exposure` 

965 Result of subtracting template from the science image. 

966 positiveFootprints, negativeFootprints : `lsst.afw.detection.FootprintSet` 

967 Positive and negative polarity footprints measured on 

968 ``difference`` to be deblended separately. 

969 

970 Returns 

971 ------- 

972 sources : `lsst.afw.table.SourceCatalog` 

973 Positive and negative deblended children. 

974 positives, negatives : `lsst.afw.table.SourceCatalog` 

975 Deblended positive and negative polarity sources with footprints 

976 detected on ``difference``. 

977 """ 

978 

979 def deblend(footprints, negative=False): 

980 """Deblend a positive or negative footprint set, 

981 and return the deblended children. 

982 

983 Parameters 

984 ---------- 

985 footprints : `lsst.afw.detection.FootprintSet` 

986 negative : `bool` 

987 Set True if the footprints contain negative fluxes 

988 

989 Returns 

990 ------- 

991 sources : `lsst.afw.table.SourceCatalog` 

992 """ 

993 sources = afwTable.SourceCatalog(self.schema) 

994 footprints.makeSources(sources) 

995 if negative: 

996 # Invert the image so the deblender can run on positive peaks 

997 difference_inverted = difference.clone() 

998 difference_inverted.image *= -1 

999 self.deblend.run(exposure=difference_inverted, sources=sources) 

1000 children = sources[sources["parent"] != 0] 

1001 # Set the heavy footprint pixel values back to reality 

1002 for child in children: 

1003 footprint = child.getFootprint() 

1004 array = footprint.getImageArray() 

1005 array *= -1 

1006 else: 

1007 self.deblend.run(exposure=difference, sources=sources) 

1008 self.setPrimaryFlags.run(sources) 

1009 children = sources["detect_isDeblendedSource"] == 1 

1010 sources = sources[children].copy(deep=True) 

1011 # Clear parents, so that measurement plugins behave correctly. 

1012 sources['parent'] = 0 

1013 return sources.copy(deep=True) 

1014 

1015 positives = deblend(positiveFootprints) 

1016 negatives = deblend(negativeFootprints, negative=True) 

1017 

1018 sources = afwTable.SourceCatalog(self.schema) 

1019 sources.reserve(len(positives) + len(negatives)) 

1020 sources.extend(positives, deep=True) 

1021 sources.extend(negatives, deep=True) 

1022 if len(negatives) > 0: 

1023 sources[-len(negatives):]["is_negative"] = True 

1024 return sources, positives, negatives 

1025 

1026 def _rejectBadMaskedDetections(self, initialDiaSources, mask): 

1027 """Remove detections whose footprint peak lies on a bad-masked pixel. 

1028 

1029 Detections are rejected if any peak of the source footprint falls on a 

1030 pixel with any of the ``config.badMaskPlanes`` bits set. 

1031 

1032 Parameters 

1033 ---------- 

1034 initialDiaSources : `lsst.afw.table.SourceCatalog` 

1035 The catalog of detected peaks. 

1036 mask : `lsst.afw.image.Mask` 

1037 Mask of the image used for detection. 

1038 

1039 Returns 

1040 ------- 

1041 initialDiaSources : `lsst.afw.table.SourceCatalog` 

1042 The catalog with bad-masked detections removed. 

1043 """ 

1044 if not self.config.badMaskPlanes or len(initialDiaSources) == 0: 1044 ↛ 1045line 1044 didn't jump to line 1045 because the condition on line 1044 was never true

1045 self.metadata["nRejectedBadMaskPlanes"] = 0 

1046 return initialDiaSources 

1047 

1048 presentPlanes = [mp for mp in self.config.badMaskPlanes 

1049 if mp in mask.getMaskPlaneDict()] 

1050 if not presentPlanes: 1050 ↛ 1051line 1050 didn't jump to line 1051 because the condition on line 1050 was never true

1051 self.metadata["nRejectedBadMaskPlanes"] = 0 

1052 return initialDiaSources 

1053 

1054 badBitMask = mask.getPlaneBitMask(presentPlanes) 

1055 x0, y0 = mask.getXY0() 

1056 

1057 selector = np.ones(len(initialDiaSources), dtype=bool) 

1058 for i, source in enumerate(initialDiaSources): 

1059 peaks = source.getFootprint().getPeaks() 

1060 for peak in peaks: 

1061 ix = peak.getIx() - x0 

1062 iy = peak.getIy() - y0 

1063 if mask.array[iy, ix] & badBitMask: 

1064 selector[i] = False 

1065 break 

1066 nRejected = np.count_nonzero(~selector) 

1067 self.metadata["nRejectedBadMaskPlanes"] = nRejected 

1068 if nRejected > 0: 

1069 self.log.info("Rejected %d detections on pixels with bad mask " 

1070 "planes %s before measurement.", 

1071 nRejected, presentPlanes) 

1072 return initialDiaSources[selector].copy(deep=True) 

1073 

1074 def _removeBadSources(self, diaSources): 

1075 """Remove unphysical diaSources from the catalog. 

1076 

1077 Parameters 

1078 ---------- 

1079 diaSources : `lsst.afw.table.SourceCatalog` 

1080 The catalog of detected sources. 

1081 

1082 Returns 

1083 ------- 

1084 diaSources : `lsst.afw.table.SourceCatalog` 

1085 The updated catalog of detected sources, with any source that has a 

1086 flag in ``config.badSourceFlags`` set removed. 

1087 """ 

1088 selector = np.ones(len(diaSources), dtype=bool) 

1089 for flag in self.config.badSourceFlags: 

1090 flags = diaSources[flag] 

1091 nBad = np.count_nonzero(flags) 

1092 if nBad > 0: 

1093 self.log.debug("Found %d unphysical sources with flag %s.", nBad, flag) 

1094 selector &= ~flags 

1095 nBadTotal = np.count_nonzero(~selector) 

1096 self.metadata["nRemovedBadFlaggedSources"] = nBadTotal 

1097 self.log.info("Removed %d unphysical sources.", nBadTotal) 

1098 return diaSources[selector].copy(deep=True) 

1099 

1100 def _find_glint_trails(self, diaSources): 

1101 """Define a new flag column for diaSources that are in a glint trail. 

1102 

1103 Parameters 

1104 ---------- 

1105 diaSources : `lsst.afw.table.SourceCatalog` 

1106 The catalog of detected sources. 

1107 

1108 Returns 

1109 ------- 

1110 diaSources : `lsst.afw.table.SourceCatalog` 

1111 The updated catalog of detected sources, with a new bool column 

1112 called 'glint_trail' added. 

1113 

1114 trail_parameters : `dict` 

1115 Parameters of all the trails that were found. 

1116 """ 

1117 if self.config.doSkySources: 

1118 # Do not include sky sources in glint detection 

1119 candidateDiaSources = diaSources[~diaSources["sky_source"]].copy(deep=True) 

1120 else: 

1121 candidateDiaSources = diaSources 

1122 trailed_glints = self.findGlints.run(candidateDiaSources) 

1123 glint_mask = [True if id in trailed_glints.trailed_ids else False for id in diaSources['id']] 

1124 if np.any(glint_mask): 1124 ↛ 1125line 1124 didn't jump to line 1125 because the condition on line 1124 was never true

1125 diaSources['glint_trail'] = np.array(glint_mask) 

1126 

1127 slopes = np.array([trail.slope for trail in trailed_glints.parameters]) 

1128 intercepts = np.array([trail.intercept for trail in trailed_glints.parameters]) 

1129 stderrs = np.array([trail.stderr for trail in trailed_glints.parameters]) 

1130 lengths = np.array([trail.length for trail in trailed_glints.parameters]) 

1131 angles = np.array([trail.angle for trail in trailed_glints.parameters]) 

1132 parameters = {'slopes': slopes, 'intercepts': intercepts, 'stderrs': stderrs, 'lengths': lengths, 

1133 'angles': angles} 

1134 

1135 trail_parameters = pipeBase.Struct(glintTrailInfo=parameters) 

1136 

1137 return diaSources, trail_parameters 

1138 

1139 def addSkySources(self, diaSources, mask, seed, 

1140 subtask=None): 

1141 """Add sources in empty regions of the difference image 

1142 for measuring the background. 

1143 

1144 Parameters 

1145 ---------- 

1146 diaSources : `lsst.afw.table.SourceCatalog` 

1147 The catalog of detected sources. 

1148 mask : `lsst.afw.image.Mask` 

1149 Mask plane for determining regions where Sky sources can be added. 

1150 seed : `int` 

1151 Seed value to initialize the random number generator. 

1152 """ 

1153 if subtask is None: 1153 ↛ 1155line 1153 didn't jump to line 1155 because the condition on line 1153 was always true

1154 subtask = self.skySources 

1155 if subtask.config.nSources <= 0: 

1156 self.metadata[f"n_{subtask.getName()}"] = 0 

1157 return 

1158 skySourceFootprints = subtask.run(mask=mask, seed=seed, catalog=diaSources) 

1159 self.metadata[f"n_{subtask.getName()}"] = len(skySourceFootprints) 

1160 

1161 def measureDiaSources(self, diaSources, science, difference, matchedTemplate): 

1162 """Use (matched) template and science image to constrain dipole fitting. 

1163 

1164 Parameters 

1165 ---------- 

1166 diaSources : `lsst.afw.table.SourceCatalog` 

1167 The catalog of detected sources. 

1168 science : `lsst.afw.image.ExposureF` 

1169 Science exposure that the template was subtracted from. 

1170 difference : `lsst.afw.image.ExposureF` 

1171 Result of subtracting template from the science image. 

1172 matchedTemplate : `lsst.afw.image.ExposureF` 

1173 Warped and PSF-matched template that was used produce the 

1174 difference image. 

1175 """ 

1176 # Ensure that the required mask planes are present 

1177 for mp in self.config.measurement.plugins["base_PixelFlags"].masksFpAnywhere: 

1178 difference.mask.addMaskPlane(mp) 

1179 # Note that this may not be correct if we convolved the science image. 

1180 # In the future we may wish to persist the matchedScience image. 

1181 self.measurement.run(diaSources, difference, science, matchedTemplate) 

1182 if self.config.doApCorr: 1182 ↛ exitline 1182 didn't return from function 'measureDiaSources' because the condition on line 1182 was always true

1183 apCorrMap = difference.getInfo().getApCorrMap() 

1184 if apCorrMap is None: 1184 ↛ 1187line 1184 didn't jump to line 1187 because the condition on line 1184 was always true

1185 self.log.warning("Difference image does not have valid aperture correction; skipping.") 

1186 else: 

1187 self.applyApCorr.run( 

1188 catalog=diaSources, 

1189 apCorrMap=apCorrMap, 

1190 ) 

1191 

1192 def measureForcedSources(self, diaSources, image, wcs, template=False): 

1193 """Perform forced measurement of the diaSources on a direct image. 

1194 

1195 Parameters 

1196 ---------- 

1197 diaSources : `lsst.afw.table.SourceCatalog` 

1198 The catalog of detected sources. 

1199 image: `lsst.afw.image.ExposureF` 

1200 Exposure that the forced measurement is being performed on 

1201 wcs : `lsst.afw.geom.SkyWcs` 

1202 Coordinate system definition (wcs) for the exposure. 

1203 template : `bool` 

1204 Is the forced measurement being performed on the template? 

1205 """ 

1206 # Run forced psf photometry on the image at the diaSource locations. 

1207 # Copy the measured flux and error into the diaSource. 

1208 forcedSources = self.forcedMeasurement.generateMeasCat(image, diaSources, wcs) 

1209 self.forcedMeasurement.run(forcedSources, image, diaSources, wcs) 

1210 

1211 if template: 

1212 base_key = 'ip_diffim_forced_template_PsfFlux' 

1213 else: 

1214 base_key = 'ip_diffim_forced_PsfFlux' 

1215 mapper = afwTable.SchemaMapper(forcedSources.schema, diaSources.schema) 

1216 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_instFlux")[0], 

1217 f"{base_key}_instFlux", True) 

1218 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_instFluxErr")[0], 

1219 f"{base_key}_instFluxErr", True) 

1220 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_area")[0], 

1221 f"{base_key}_area", True) 

1222 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_flag")[0], 

1223 f"{base_key}_flag", True) 

1224 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_flag_noGoodPixels")[0], 

1225 f"{base_key}_flag_noGoodPixels", True) 

1226 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_flag_edge")[0], 

1227 f"{base_key}_flag_edge", True) 

1228 for diaSource, forcedSource in zip(diaSources, forcedSources): 

1229 diaSource.assign(forcedSource, mapper) 

1230 

1231 def findAndMaskCosmicRays(self, difference): 

1232 """Detect and mask cosmic rays on the difference image. 

1233 

1234 Parameters 

1235 ---------- 

1236 difference : `lsst.afw.image.Exposure` 

1237 The background-subtracted difference image. 

1238 The mask plane will be modified in place. 

1239 """ 

1240 # This is run on a background-subtracted difference image, so the 

1241 # remaining background should be ~0. 

1242 medianBg = 0. 

1243 try: 

1244 crs = findCosmicRays( 

1245 difference.maskedImage, 

1246 difference.psf, 

1247 medianBg, 

1248 pexConfig.makePropertySet(self.config.cosmicray), 

1249 self.config.cosmicray.keepCRs, 

1250 ) 

1251 except LengthError: 

1252 raise TooManyCosmicRays(self.config.cosmicray.nCrPixelMax) from None 

1253 num = 0 

1254 if crs is not None: 1254 ↛ 1260line 1254 didn't jump to line 1260 because the condition on line 1254 was always true

1255 mask = difference.maskedImage.mask 

1256 crBit = mask.getPlaneBitMask("CR") 

1257 afwDetection.setMaskFromFootprintList(mask, crs, crBit) 

1258 num = len(crs) 

1259 

1260 text = "masked" if self.config.cosmicray.keepCRs else "interpolated over" 

1261 self.log.info("Identified and %s %s cosmic rays.", text, num) 

1262 self.metadata["cosmic_ray_count"] = num 

1263 

1264 def calculateMetrics(self, science, difference, diaSources, kernelSources): 

1265 """Add difference image QA metrics to the Task metadata. 

1266 

1267 This may be used to produce corresponding metrics (see 

1268 lsst.analysis.tools.tasks.diffimTaskDetectorVisitMetricAnalysis). 

1269 

1270 Parameters 

1271 ---------- 

1272 science : `lsst.afw.image.ExposureF` 

1273 Science exposure that was subtracted. 

1274 difference : `lsst.afw.image.Exposure` 

1275 The target difference image to calculate metrics for. 

1276 diaSources : `lsst.afw.table.SourceCatalog` 

1277 The catalog of detected sources. 

1278 kernelSources : `lsst.afw.table.SourceCatalog` 

1279 Final selection of sources that was used for psf matching. 

1280 """ 

1281 mask = difference.mask 

1282 badPix = (mask.array & mask.getPlaneBitMask(self.config.detection.excludeMaskPlanes)) > 0 

1283 self.metadata["nGoodPixels"] = np.sum(~badPix) 

1284 self.metadata["nBadPixels"] = np.sum(badPix) 

1285 detPosPix = (mask.array & mask.getPlaneBitMask("DETECTED")) > 0 

1286 detNegPix = (mask.array & mask.getPlaneBitMask("DETECTED_NEGATIVE")) > 0 

1287 self.metadata["nPixelsDetectedPositive"] = np.sum(detPosPix) 

1288 self.metadata["nPixelsDetectedNegative"] = np.sum(detNegPix) 

1289 detPosPix &= badPix 

1290 detNegPix &= badPix 

1291 self.metadata["nBadPixelsDetectedPositive"] = np.sum(detPosPix) 

1292 self.metadata["nBadPixelsDetectedNegative"] = np.sum(detNegPix) 

1293 

1294 metricsMaskPlanes = list(mask.getMaskPlaneDict().keys()) 

1295 for maskPlane in metricsMaskPlanes: 

1296 try: 

1297 self.metadata["%s_mask_fraction"%maskPlane.lower()] = evaluateMaskFraction(mask, maskPlane) 

1298 except InvalidParameterError: 

1299 self.metadata["%s_mask_fraction"%maskPlane.lower()] = -1 

1300 self.log.info("Unable to calculate metrics for mask plane %s: not in image"%maskPlane) 

1301 

1302 if self.config.doSkySources: 

1303 skySources = diaSources[diaSources["sky_source"]] 

1304 else: 

1305 skySources = None 

1306 metrics = computeDifferenceImageMetrics(science, difference, kernelSources, sky_sources=skySources) 

1307 

1308 self.metadata["residualFootprintRatioMean"] = metrics.differenceFootprintRatioMean 

1309 self.metadata["residualFootprintRatioStdev"] = metrics.differenceFootprintRatioStdev 

1310 self.metadata["differenceFootprintSkyRatioMean"] = metrics.differenceFootprintSkyRatioMean 

1311 self.metadata["differenceFootprintSkyRatioStdev"] = metrics.differenceFootprintSkyRatioStdev 

1312 self.log.info("Mean, stdev of ratio of difference to science " 

1313 "pixels in star footprints: %5.4f, %5.4f", 

1314 self.metadata["residualFootprintRatioMean"], 

1315 self.metadata["residualFootprintRatioStdev"]) 

1316 if self.config.raiseOnBadSubtractionRatio: 

1317 if metrics.differenceFootprintRatioMean > self.config.badSubtractionRatioThreshold: 1317 ↛ 1318line 1317 didn't jump to line 1318 because the condition on line 1317 was never true

1318 raise BadSubtractionError(ratio=metrics.differenceFootprintRatioMean, 

1319 threshold=self.config.badSubtractionRatioThreshold) 

1320 if metrics.differenceFootprintRatioStdev > self.config.badSubtractionVariationThreshold: 1320 ↛ 1321line 1320 didn't jump to line 1321 because the condition on line 1320 was never true

1321 raise BadSubtractionError(ratio=metrics.differenceFootprintRatioStdev, 

1322 threshold=self.config.badSubtractionVariationThreshold) 

1323 

1324 def getSattleDiaSourceAllowlist(self, diaSources, science): 

1325 """Query the sattle service and determine which diaSources are allowed. 

1326 

1327 Parameters 

1328 ---------- 

1329 diaSources : `lsst.afw.table.SourceCatalog` 

1330 The catalog of detected sources. 

1331 science : `lsst.afw.image.ExposureF` 

1332 Science exposure that was subtracted. 

1333 

1334 Returns 

1335 ---------- 

1336 allow_list : `list` of `int` 

1337 diaSourceIds of diaSources that can be made public. 

1338 

1339 Raises 

1340 ------ 

1341 requests.HTTPError 

1342 Raised if sattle call does not return success. 

1343 """ 

1344 wcs = science.getWcs() 

1345 visit_info = science.getInfo().getVisitInfo() 

1346 visit_id = visit_info.getId() 

1347 sattle_uri_base = os.getenv('SATTLE_URI_BASE') 

1348 

1349 dia_sources_json = [] 

1350 for source in diaSources: 

1351 source_bbox = source.getFootprint().getBBox() 

1352 corners = wcs.pixelToSky([lsst.geom.Point2D(c) for c in source_bbox.getCorners()]) 

1353 bbox_radec = [[pt.getRa().asDegrees(), pt.getDec().asDegrees()] for pt in corners] 

1354 dia_sources_json.append({"diasource_id": source["id"], "bbox": bbox_radec}) 

1355 

1356 payload = {"visit_id": visit_id, "detector_id": science.getDetector().getId(), 

1357 "diasources": dia_sources_json, "historical": self.config.sattle_historical} 

1358 

1359 sattle_output = requests.put(f'{sattle_uri_base}/diasource_allow_list', 

1360 json=payload) 

1361 

1362 # retry once if visit cache is not populated 

1363 if sattle_output.status_code == 404: 

1364 self.log.warning(f'Visit {visit_id} not found in sattle cache, re-sending') 

1365 populate_sattle_visit_cache(visit_info, historical=self.config.sattle_historical) 

1366 sattle_output = requests.put(f'{sattle_uri_base}/diasource_allow_list', json=payload) 

1367 

1368 sattle_output.raise_for_status() 

1369 

1370 return sattle_output.json()['allow_list'] 

1371 

1372 def filterSatellites(self, diaSources, science): 

1373 """Remove diaSources overlapping predicted satellite positions. 

1374 

1375 Parameters 

1376 ---------- 

1377 diaSources : `lsst.afw.table.SourceCatalog` 

1378 The catalog of detected sources. 

1379 science : `lsst.afw.image.ExposureF` 

1380 Science exposure that was subtracted. 

1381 

1382 Returns 

1383 ---------- 

1384 filterdDiaSources : `lsst.afw.table.SourceCatalog` 

1385 Filtered catalog of diaSources 

1386 """ 

1387 

1388 allow_list = self.getSattleDiaSourceAllowlist(diaSources, science) 

1389 

1390 if allow_list: 

1391 allow_set = set(allow_list) 

1392 allowed_ids = [source['id'] in allow_set for source in diaSources] 

1393 diaSources = diaSources[np.array(allowed_ids)].copy(deep=True) 

1394 else: 

1395 self.log.warning('Sattle allowlist is empty, all diaSources removed') 

1396 diaSources = diaSources[0:0].copy(deep=True) 

1397 return diaSources 

1398 

1399 def _runStreakMasking(self, difference): 

1400 """Do streak masking and optionally save the resulting streak 

1401 fit parameters in a catalog. 

1402 

1403 Only returns non-empty streakInfo if self.config.writeStreakInfo 

1404 is set. The difference image is binned by self.config.streakBinFactor 

1405 (and detection is run a second time) so that regions with lower 

1406 surface brightness streaks are more likely to fall above the 

1407 detection threshold. 

1408 

1409 Parameters 

1410 ---------- 

1411 difference: `lsst.afw.image.Exposure` 

1412 The exposure in which to search for streaks. Must have a detection 

1413 mask. 

1414 

1415 Returns 

1416 ------- 

1417 streakInfo: `lsst.pipe.base.Struct` 

1418 ``rho`` : `np.ndarray` 

1419 Angle of detected streak. 

1420 ``theta`` : `np.ndarray` 

1421 Distance from center of detected streak. 

1422 ``sigma`` : `np.ndarray` 

1423 Width of streak profile. 

1424 ``reducedChi2`` : `np.ndarray` 

1425 Reduced chi2 of the best-fit streak profile. 

1426 ``modelMaximum`` : `np.ndarray` 

1427 Peak value of the fit line profile. 

1428 """ 

1429 maskedImage = difference.maskedImage 

1430 # Bin the diffim to enhance low surface brightness streaks 

1431 binnedMaskedImage = afwMath.binImage(maskedImage, 

1432 self.config.streakBinFactor, 

1433 self.config.streakBinFactor) 

1434 binnedExposure = afwImage.ExposureF(binnedMaskedImage.getBBox()) 

1435 binnedExposure.setMaskedImage(binnedMaskedImage) 

1436 # Clear the DETECTED mask plane before streak detection 

1437 binnedExposure.mask &= ~binnedExposure.mask.getPlaneBitMask('DETECTED') 

1438 # Rerun detection to set the DETECTED mask plane on binnedExposure 

1439 sigma = difference.psf.computeShape(difference.psf.getAveragePosition()).getDeterminantRadius() 

1440 _table = afwTable.SourceTable.make(afwTable.SourceTable.makeMinimalSchema()) 

1441 self.streakDetection.run(table=_table, exposure=binnedExposure, doSmooth=True, 

1442 sigma=sigma/self.config.streakBinFactor) 

1443 binnedDetectedMaskPlane = binnedExposure.mask.array & binnedExposure.mask.getPlaneBitMask('DETECTED') 

1444 rescaledDetectedMaskPlane = binnedDetectedMaskPlane.repeat(self.config.streakBinFactor, 

1445 axis=0).repeat(self.config.streakBinFactor, 

1446 axis=1) 

1447 # Create new version of a diffim with DETECTED based on binnedExposure 

1448 streakMaskedImage = maskedImage.clone() 

1449 ysize, xsize = rescaledDetectedMaskPlane.shape 

1450 streakMaskedImage.mask.array[:ysize, :xsize] |= rescaledDetectedMaskPlane 

1451 # Detect streaks on this new version of the diffim 

1452 streaks = self.maskStreaks.run(streakMaskedImage) 

1453 streakMaskPlane = streakMaskedImage.mask.array & streakMaskedImage.mask.getPlaneBitMask('STREAK') 

1454 # Apply the new STREAK mask to the original diffim 

1455 maskedImage.mask.array |= streakMaskPlane 

1456 

1457 if self.config.writeStreakInfo: 1457 ↛ 1458line 1457 didn't jump to line 1458 because the condition on line 1457 was never true

1458 rhos = np.array([line.rho for line in streaks.lines]) 

1459 thetas = np.array([line.theta for line in streaks.lines]) 

1460 sigmas = np.array([line.sigma for line in streaks.lines]) 

1461 chi2s = np.array([line.reducedChi2 for line in streaks.lines]) 

1462 modelMaximums = np.array([line.modelMaximum for line in streaks.lines]) 

1463 streakInfo = {'rho': rhos, 'theta': thetas, 'sigma': sigmas, 'reducedChi2': chi2s, 

1464 'modelMaximum': modelMaximums} 

1465 else: 

1466 streakInfo = {'rho': np.array([]), 'theta': np.array([]), 'sigma': np.array([]), 

1467 'reducedChi2': np.array([]), 'modelMaximum': np.array([])} 

1468 return pipeBase.Struct(maskedStreaks=streakInfo) 

1469 

1470 

1471class DetectAndMeasureScoreConnections(DetectAndMeasureConnections): 

1472 scoreExposure = pipeBase.connectionTypes.Input( 

1473 doc="Maximum likelihood image for detection.", 

1474 dimensions=("instrument", "visit", "detector"), 

1475 storageClass="ExposureF", 

1476 name="{fakesType}{coaddName}Diff_scoreTempExp", 

1477 ) 

1478 scoreMeasuredExposure = pipeBase.connectionTypes.Output( 

1479 doc="Score image after backgrond subtraction and detection.", 

1480 dimensions=("instrument", "visit", "detector"), 

1481 storageClass="ExposureF", 

1482 name="{fakesType}{coaddName}Diff_scoreExp", 

1483 ) 

1484 

1485 

1486class DetectAndMeasureScoreConfig(DetectAndMeasureConfig, 

1487 pipelineConnections=DetectAndMeasureScoreConnections): 

1488 pass 

1489 

1490 

1491class DetectAndMeasureScoreTask(DetectAndMeasureTask): 

1492 """Detect DIA sources using a score image, 

1493 and measure the detections on the difference image. 

1494 

1495 Source detection is run on the supplied score, or maximum likelihood, 

1496 image. Note that no additional convolution will be done in this case. 

1497 Close positive and negative detections will optionally be merged into 

1498 dipole diaSources. 

1499 Sky sources, or forced detections in background regions, will optionally 

1500 be added, and the configured measurement algorithm will be run on all 

1501 detections. 

1502 """ 

1503 ConfigClass = DetectAndMeasureScoreConfig 

1504 _DefaultName = "detectAndMeasureScore" 

1505 

1506 @timeMethod 

1507 def run(self, science, matchedTemplate, difference, scoreExposure, kernelSources, 

1508 idFactory=None, measurementResults=None): 

1509 """Detect and measure sources on a score image. 

1510 

1511 Parameters 

1512 ---------- 

1513 science : `lsst.afw.image.ExposureF` 

1514 Science exposure that the template was subtracted from. 

1515 matchedTemplate : `lsst.afw.image.ExposureF` 

1516 Warped and PSF-matched template that was used produce the 

1517 difference image. 

1518 difference : `lsst.afw.image.ExposureF` 

1519 Result of subtracting template from the science image. 

1520 scoreExposure : `lsst.afw.image.ExposureF` 

1521 Score or maximum likelihood difference image 

1522 kernelSources : `lsst.afw.table.SourceCatalog` 

1523 Final selection of sources that was used for psf matching. 

1524 idFactory : `lsst.afw.table.IdFactory`, optional 

1525 Generator object used to assign ids to detected sources in the 

1526 difference image. Ids from this generator are not set until after 

1527 deblending and merging positive/negative peaks. 

1528 measurementResults : `lsst.pipe.base.Struct`, optional 

1529 Result struct that is modified to allow saving of partial outputs 

1530 for some failure conditions. If the task completes successfully, 

1531 this is also returned. 

1532 

1533 Returns 

1534 ------- 

1535 measurementResults : `lsst.pipe.base.Struct` 

1536 

1537 ``subtractedMeasuredExposure`` : `lsst.afw.image.ExposureF` 

1538 Subtracted exposure with detection mask applied. 

1539 ``scoreMeasuredExposure`` : `lsst.afw.image.ExposureF` 

1540 Score exposure with detection mask applied. 

1541 ``diaSources`` : `lsst.afw.table.SourceCatalog` 

1542 The catalog of detected sources. 

1543 ``differenceBackground`` : `lsst.afw.math.BackgroundList` 

1544 Background that was subtracted from the difference image. 

1545 """ 

1546 if measurementResults is None: 1546 ↛ 1548line 1546 didn't jump to line 1548 because the condition on line 1546 was always true

1547 measurementResults = pipeBase.Struct() 

1548 if idFactory is None: 

1549 idFactory = lsst.meas.base.IdGenerator().make_table_id_factory() 

1550 

1551 # Check image properties and clear detection mask planes. 

1552 self._prepareInputs(difference) 

1553 self._prepareInputs(scoreExposure) 

1554 if self.config.doSubtractBackground: 

1555 background = self._fitAndSubtractBackground( 

1556 differenceExposure=difference, scoreExposure=scoreExposure, 

1557 ) 

1558 else: 

1559 background = afwMath.BackgroundList() 

1560 

1561 if self.config.doFindCosmicRays: 1561 ↛ 1570line 1561 didn't jump to line 1570 because the condition on line 1561 was always true

1562 # Cosmic rays are detected on the difference and the resulting 

1563 # mask is propagated to the score image used for detection. 

1564 self.findAndMaskCosmicRays(difference) 

1565 scoreExposure.mask.array |= difference.mask.array 

1566 

1567 # Don't use the idFactory until after deblend+merge, so that we aren't 

1568 # generating ids that just get thrown away (footprint merge doesn't 

1569 # know about past ids). 

1570 table = afwTable.SourceTable.make(self.schema) 

1571 detectResults = self.detection.run( 

1572 table=table, 

1573 exposure=scoreExposure, 

1574 doSmooth=False, 

1575 background=background, 

1576 clearMask=True, 

1577 ) 

1578 measurementResults.differenceBackground = background 

1579 measurementResults.scoreMeasuredExposure = scoreExposure 

1580 

1581 # Copy the final detection masks from the Score image to the difference. 

1582 # Do not copy all mask planes, since the convolution will grow them by 

1583 # the size of the convolution kernel. 

1584 detectedBit = difference.mask.getPlaneBitMask(["DETECTED"]) 

1585 detectedPix = scoreExposure.mask.array & detectedBit > 0 

1586 detectedNegativeBit = difference.mask.getPlaneBitMask(["DETECTED_NEGATIVE"]) 

1587 detectedNegativePix = scoreExposure.mask.array & detectedNegativeBit > 0 

1588 difference.mask.array[detectedPix] |= detectedBit 

1589 difference.mask.array[detectedNegativePix] |= detectedNegativeBit 

1590 # Also set the EDGE mask, since the science image was convolved 

1591 edgeBit = difference.mask.getPlaneBitMask(["EDGE"]) 

1592 edgePix = scoreExposure.mask.array & edgeBit > 0 

1593 difference.mask.array[edgePix] |= edgeBit 

1594 

1595 if self.config.doDeblend: 

1596 sources, positives, negatives = self._deblend(difference, 

1597 detectResults.positive, 

1598 detectResults.negative) 

1599 

1600 self.processResults(science, matchedTemplate, difference, 

1601 sources, idFactory, kernelSources, 

1602 positives=positives, 

1603 negatives=negatives, 

1604 measurementResults=measurementResults) 

1605 

1606 else: 

1607 positives = afwTable.SourceCatalog(self.schema) 

1608 detectResults.positive.makeSources(positives) 

1609 negatives = afwTable.SourceCatalog(self.schema) 

1610 detectResults.negative.makeSources(negatives) 

1611 self.processResults(science, matchedTemplate, difference, 

1612 detectResults.sources, idFactory, kernelSources, 

1613 positives=positives, 

1614 negatives=negatives, 

1615 measurementResults=measurementResults) 

1616 return measurementResults