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

645 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-30 09:32 +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 dataclasses 

23import numpy as np 

24import requests 

25import os 

26 

27 

28import lsst.afw.detection as afwDetection 

29import lsst.afw.image as afwImage 

30import lsst.afw.math as afwMath 

31import lsst.afw.table as afwTable 

32import lsst.daf.base as dafBase 

33import lsst.geom 

34from lsst.images import Mask, get_legacy_difference_image_mask_planes 

35from lsst.images.fields import field_from_legacy_background 

36from lsst.ip.diffim.utils import (evaluateMaskFraction, computeDifferenceImageMetrics, 

37 populate_sattle_visit_cache, 

38 get_difference_image_provenance) 

39from lsst.meas.algorithms import SkyObjectsTask, SourceDetectionTask, SetPrimaryFlagsTask, MaskStreaksTask 

40from lsst.meas.algorithms import FindGlintTrailsTask, FindCosmicRaysConfig, findCosmicRays 

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

42import lsst.meas.deblender 

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

44import lsst.meas.extensions.shapeHSM 

45import lsst.pex.config as pexConfig 

46from lsst.pex.exceptions import InvalidParameterError, LengthError 

47import lsst.pipe.base as pipeBase 

48import lsst.utils 

49from lsst.utils.timer import timeMethod 

50 

51from . import DipoleFitTask 

52 

53__all__ = ["DetectAndMeasureConfig", "DetectAndMeasureTask", 

54 "DetectAndMeasureScoreConfig", "DetectAndMeasureScoreTask"] 

55 

56 

57class BadSubtractionError(pipeBase.AlgorithmError): 

58 """Raised when the residuals in footprints of stars used to compute the 

59 psf-matching kernel exceeds the configured maximum. 

60 """ 

61 def __init__(self, *, ratio, threshold): 

62 msg = ("The ratio of residual power in source footprints on the" 

63 " difference image to the power in the footprints on the" 

64 f" science image was {ratio}, which exceeds the maximum" 

65 f" threshold of {threshold}") 

66 super().__init__(msg) 

67 self.ratio = ratio 

68 self.threshold = threshold 

69 

70 @property 

71 def metadata(self): 

72 return {"ratio": self.ratio, 

73 "threshold": self.threshold 

74 } 

75 

76 

77class NoDiaSourcesError(pipeBase.AlgorithmError): 

78 """Raised when there are no diaSources detected on an image difference. 

79 """ 

80 def __init__(self): 

81 msg = ("No diaSources detected!") 

82 super().__init__(msg) 

83 

84 @property 

85 def metadata(self): 

86 return {} 

87 

88 

89class TooManyCosmicRays(pipeBase.AlgorithmError): 

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

91 

92 Parameters 

93 ---------- 

94 maxCosmicRays : `int` 

95 Maximum number of cosmic rays allowed. 

96 """ 

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

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

99 self.msg = msg 

100 self._metadata = kwargs 

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

102 self._metadata["maxCosmicRays"] = maxCosmicRays 

103 

104 def __str__(self): 

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

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

107 

108 @property 

109 def metadata(self): 

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

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

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

113 return self._metadata 

114 

115 

116class DetectAndMeasureConnections(pipeBase.PipelineTaskConnections, 

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

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

119 "warpTypeSuffix": "", 

120 "fakesType": ""}): 

121 science = pipeBase.connectionTypes.Input( 

122 doc="Input science exposure.", 

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

124 storageClass="ExposureF", 

125 name="{fakesType}calexp" 

126 ) 

127 matchedTemplate = pipeBase.connectionTypes.Input( 

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

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

130 storageClass="ExposureF", 

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

132 ) 

133 difference = pipeBase.connectionTypes.Input( 

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

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

136 storageClass="ExposureF", 

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

138 ) 

139 kernelSources = pipeBase.connectionTypes.Input( 

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

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

142 storageClass="SourceCatalog", 

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

144 ) 

145 outputSchema = pipeBase.connectionTypes.InitOutput( 

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

147 storageClass="SourceCatalog", 

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

149 ) 

150 diaSources = pipeBase.connectionTypes.Output( 

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

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

153 storageClass="SourceCatalog", 

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

155 ) 

156 subtractedMeasuredExposure = pipeBase.connectionTypes.Output( 

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

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

159 storageClass="ExposureF", 

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

161 ) 

162 differenceBackground = pipeBase.connectionTypes.Output( 

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

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

165 storageClass="Background", 

166 name="difference_background", 

167 ) 

168 maskedStreaks = pipeBase.connectionTypes.Output( 

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

170 storageClass="ArrowNumpyDict", 

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

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

173 ) 

174 glintTrailInfo = pipeBase.connectionTypes.Output( 

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

176 storageClass="ArrowNumpyDict", 

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

178 name="trailed_glints", 

179 ) 

180 

181 def __init__(self, *, config): 

182 super().__init__(config=config) 

183 if not (self.config.doCalculateResidualMetics): 183 ↛ 184line 183 didn't jump to line 184 because the condition on line 183 was never true

184 self.inputs.remove("kernelSources") 

185 if not (self.config.writeStreakInfo and self.config.doMaskStreaks): 185 ↛ 188line 185 didn't jump to line 188 because the condition on line 185 was always true

186 self.outputs.remove("maskedStreaks") 

187 # In future mode the background is recorded on the output images. 

188 if (self.config.image_type == "future" 

189 or not (self.config.doSubtractBackground and self.config.doWriteBackground)): 

190 self.outputs.remove("differenceBackground") 

191 if not (self.config.writeGlintInfo): 191 ↛ 192line 191 didn't jump to line 192 because the condition on line 191 was never true

192 self.outputs.remove("glintTrailInfo") 

193 if self.config.image_type == "future": 

194 self.difference = dataclasses.replace(self.difference, storageClass="DifferenceImage") 

195 self.subtractedMeasuredExposure = dataclasses.replace(self.subtractedMeasuredExposure, 

196 storageClass="DifferenceImage") 

197 

198 

199class DetectAndMeasureConfig(pipeBase.PipelineTaskConfig, 

200 pipelineConnections=DetectAndMeasureConnections): 

201 """Config for DetectAndMeasureTask 

202 """ 

203 doMerge = pexConfig.Field( 

204 dtype=bool, 

205 default=True, 

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

207 "set by growFootprint" 

208 ) 

209 doForcedMeasurement = pexConfig.Field( 

210 dtype=bool, 

211 default=True, 

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

213 ) 

214 doAddMetrics = pexConfig.Field( 

215 dtype=bool, 

216 default=False, 

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

218 ) 

219 doSubtractBackground = pexConfig.Field( 

220 dtype=bool, 

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

222 default=True, 

223 ) 

224 doWriteBackground = pexConfig.Field( 

225 dtype=bool, 

226 doc="Persist the fitted background model? Ignored when" 

227 " image_type='future', which records it on the output images.", 

228 default=False, 

229 deprecated="Use image_type='future', which records the background on the output images." 

230 " Will be removed after v31.", 

231 ) 

232 doCalculateResidualMetics = pexConfig.Field( 

233 dtype=bool, 

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

235 "metadata?", 

236 default=True, 

237 ) 

238 subtractInitialBackground = pexConfig.ConfigurableField( 

239 target=lsst.meas.algorithms.SubtractBackgroundTask, 

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

241 ) 

242 subtractFinalBackground = pexConfig.ConfigurableField( 

243 target=lsst.meas.algorithms.SubtractBackgroundTask, 

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

245 ) 

246 doFindCosmicRays = pexConfig.Field( 

247 dtype=bool, 

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

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

250 default=True, 

251 ) 

252 cosmicray = pexConfig.ConfigField( 

253 dtype=FindCosmicRaysConfig, 

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

255 ) 

256 detection = pexConfig.ConfigurableField( 

257 target=SourceDetectionTask, 

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

259 ) 

260 streakDetection = pexConfig.ConfigurableField( 

261 target=SourceDetectionTask, 

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

263 ) 

264 doDeblend = pexConfig.Field( 

265 dtype=bool, 

266 default=False, 

267 doc="Deblend DIASources after detection?" 

268 ) 

269 deblend = pexConfig.ConfigurableField( 

270 target=lsst.meas.deblender.SourceDeblendTask, 

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

272 ) 

273 measurement = pexConfig.ConfigurableField( 

274 target=DipoleFitTask, 

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

276 ) 

277 doApCorr = lsst.pex.config.Field( 

278 dtype=bool, 

279 default=True, 

280 doc="Run subtask to apply aperture corrections" 

281 ) 

282 applyApCorr = lsst.pex.config.ConfigurableField( 

283 target=ApplyApCorrTask, 

284 doc="Task to apply aperture corrections" 

285 ) 

286 forcedMeasurement = pexConfig.ConfigurableField( 

287 target=ForcedMeasurementTask, 

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

289 ) 

290 growFootprint = pexConfig.Field( 

291 dtype=int, 

292 default=2, 

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

294 ) 

295 diaSourceMatchRadius = pexConfig.Field( 

296 dtype=float, 

297 default=0.5, 

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

299 ) 

300 doSkySources = pexConfig.Field( 

301 dtype=bool, 

302 default=False, 

303 doc="Generate sky sources?", 

304 ) 

305 skySources = pexConfig.ConfigurableField( 

306 target=SkyObjectsTask, 

307 doc="Generate sky sources", 

308 ) 

309 doMaskStreaks = pexConfig.Field( 

310 dtype=bool, 

311 default=True, 

312 doc="Turn on streak masking", 

313 ) 

314 maskStreaks = pexConfig.ConfigurableField( 

315 target=MaskStreaksTask, 

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

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

318 ) 

319 streakBinFactor = pexConfig.Field( 

320 dtype=int, 

321 default=4, 

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

323 "Only used if doMaskStreaks is True.", 

324 ) 

325 writeStreakInfo = pexConfig.Field( 

326 dtype=bool, 

327 default=False, 

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

329 "development work." 

330 ) 

331 findGlints = pexConfig.ConfigurableField( 

332 target=FindGlintTrailsTask, 

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

334 ) 

335 writeGlintInfo = pexConfig.Field( 

336 dtype=bool, 

337 default=True, 

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

339 ) 

340 setPrimaryFlags = pexConfig.ConfigurableField( 

341 target=SetPrimaryFlagsTask, 

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

343 ) 

344 badSourceFlags = lsst.pex.config.ListField( 

345 dtype=str, 

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

347 default=("base_PixelFlags_flag_offimage", 

348 "base_PixelFlags_flag_interpolatedCenterAll", 

349 "base_PixelFlags_flag_badCenter", 

350 "base_PixelFlags_flag_edgeCenter", 

351 "base_PixelFlags_flag_nodataCenter", 

352 "base_PixelFlags_flag_saturatedCenter", 

353 "base_PixelFlags_flag_sat_templateCenter", 

354 "base_PixelFlags_flag_spikeCenter", 

355 ), 

356 ) 

357 clearMaskPlanes = lsst.pex.config.ListField( 

358 dtype=str, 

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

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

361 ) 

362 doRejectBadMaskPlaneDetections = pexConfig.Field( 

363 dtype=bool, 

364 default=True, 

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

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

367 "but filtering earlier can save time." 

368 ) 

369 badMaskPlanes = lsst.pex.config.ListField( 

370 dtype=str, 

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

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

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

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

375 ) 

376 raiseOnBadSubtractionRatio = pexConfig.Field( 

377 dtype=bool, 

378 default=True, 

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

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

381 " image exceeds ``badSubtractionRatioThreshold``", 

382 ) 

383 badSubtractionRatioThreshold = pexConfig.Field( 

384 dtype=float, 

385 default=0.2, 

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

387 " the same footprints on the science image." 

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

389 ) 

390 badSubtractionVariationThreshold = pexConfig.Field( 

391 dtype=float, 

392 default=0.4, 

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

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

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

396 ) 

397 raiseOnNoDiaSources = pexConfig.Field( 

398 dtype=bool, 

399 default=True, 

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

401 ) 

402 run_sattle = pexConfig.Field( 

403 dtype=bool, 

404 default=False, 

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

406 "to the sattle service." 

407 ) 

408 sattle_historical = pexConfig.Field( 

409 dtype=bool, 

410 default=False, 

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

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

413 "closest in time to the exposure." 

414 ) 

415 idGenerator = DetectorVisitIdGeneratorConfig.make_field() 

416 image_type = pexConfig.ChoiceField[str]( 

417 "Image type of the input and measured difference images (and, for" 

418 " DetectAndMeasureScoreTask, the input and measured score images).", 

419 allowed={ 

420 "legacy": "Read and write lsst.afw.image.ExposureF.", 

421 "future": "Read and write lsst.images.DifferenceImage.", 

422 }, 

423 optional=False, 

424 default="legacy", 

425 ) 

426 

427 def setDefaults(self): 

428 # Background subtraction 

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

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

431 # faint diaSources 

432 self.subtractInitialBackground.binSize = 8 

433 self.subtractInitialBackground.useApprox = False 

434 self.subtractInitialBackground.statisticsProperty = "MEDIAN" 

435 self.subtractInitialBackground.doFilterSuperPixels = True 

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

437 "EDGE", 

438 "DETECTED", 

439 "DETECTED_NEGATIVE", 

440 "NO_DATA", 

441 ] 

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

443 # over-subtraction of bright objects. 

444 self.subtractFinalBackground.binSize = 40 

445 self.subtractFinalBackground.useApprox = False 

446 self.subtractFinalBackground.statisticsProperty = "MEDIAN" 

447 self.subtractFinalBackground.doFilterSuperPixels = True 

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

449 "EDGE", 

450 "DETECTED", 

451 "DETECTED_NEGATIVE", 

452 "NO_DATA", 

453 ] 

454 # Cosmic ray detection 

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

456 # DiaSource Detection 

457 self.detection.thresholdPolarity = "both" 

458 self.detection.thresholdValue = 5.0 

459 self.detection.reEstimateBackground = False 

460 self.detection.thresholdType = "pixel_stdev" 

461 self.detection.excludeMaskPlanes = [] 

462 

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

464 self.streakDetection.thresholdType = self.detection.thresholdType 

465 self.streakDetection.reEstimateBackground = False 

466 self.streakDetection.excludeMaskPlanes = self.detection.excludeMaskPlanes 

467 self.streakDetection.thresholdValue = self.detection.thresholdValue 

468 # Only detect positive streaks 

469 self.streakDetection.thresholdPolarity = "positive" 

470 # Do not grow detected mask for streaks 

471 self.streakDetection.nSigmaToGrow = 0 

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

473 # detected mask is set. 

474 self.maskStreaks.onlyMaskDetected = False 

475 # Restrict streak masking from growing too large 

476 self.maskStreaks.maxStreakWidth = 100 

477 # Restrict the number of iterations allowed for fitting streaks 

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

479 self.maskStreaks.maxFitIter = 10 

480 # Only mask to 2 sigma in width 

481 self.maskStreaks.nSigmaMask = 2 

482 # Threshold for including streaks after the Hough Transform. 

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

484 self.maskStreaks.absMinimumKernelHeight = 2 

485 

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

487 "base_LocalPhotoCalib", 

488 "base_LocalWcs", 

489 "ext_shapeHSM_HsmSourceMoments", 

490 "ext_shapeHSM_HsmPsfMoments", 

491 "base_ClassificationSizeExtendedness", 

492 ] 

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

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

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

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

497 self.forcedMeasurement.copyColumns = { 

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

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

500 self.forcedMeasurement.slots.shape = None 

501 

502 # Keep track of which footprints contain streaks 

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

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

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

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

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

508 

509 def validate(self): 

510 super().validate() 

511 

512 if self.run_sattle: 

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

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

515 "Sattle requested but SATTLE_URI_BASE " 

516 "environment variable not set.") 

517 

518 

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

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

521 """ 

522 ConfigClass = DetectAndMeasureConfig 

523 _DefaultName = "detectAndMeasure" 

524 

525 def __init__(self, **kwargs): 

526 super().__init__(**kwargs) 

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

528 

529 self.algMetadata = dafBase.PropertyList() 

530 if self.config.doSubtractBackground: 

531 self.makeSubtask("subtractInitialBackground") 

532 self.makeSubtask("subtractFinalBackground") 

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

534 if self.config.doDeblend: 

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

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

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

538 algMetadata=self.algMetadata) 

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

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

541 if self.config.doForcedMeasurement: 

542 self.schema.addField( 

543 "ip_diffim_forced_PsfFlux_instFlux", "D", 

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

545 units="count") 

546 self.schema.addField( 

547 "ip_diffim_forced_PsfFlux_instFluxErr", "D", 

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

549 units="count") 

550 self.schema.addField( 

551 "ip_diffim_forced_PsfFlux_area", "F", 

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

553 units="pixel") 

554 self.schema.addField( 

555 "ip_diffim_forced_PsfFlux_flag", "Flag", 

556 "Forced PSF flux general failure flag.") 

557 self.schema.addField( 

558 "ip_diffim_forced_PsfFlux_flag_noGoodPixels", "Flag", 

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

560 self.schema.addField( 

561 "ip_diffim_forced_PsfFlux_flag_edge", "Flag", 

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

563 self.schema.addField( 

564 "ip_diffim_forced_template_PsfFlux_instFlux", "D", 

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

566 units="count") 

567 self.schema.addField( 

568 "ip_diffim_forced_template_PsfFlux_instFluxErr", "D", 

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

570 units="count") 

571 self.schema.addField( 

572 "ip_diffim_forced_template_PsfFlux_area", "F", 

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

574 units="pixel") 

575 self.schema.addField( 

576 "ip_diffim_forced_template_PsfFlux_flag", "Flag", 

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

578 self.schema.addField( 

579 "ip_diffim_forced_template_PsfFlux_flag_noGoodPixels", "Flag", 

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

581 self.schema.addField( 

582 "ip_diffim_forced_template_PsfFlux_flag_edge", "Flag", 

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

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

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

586 

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

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

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

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

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

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

593 self.makeSubtask("maskStreaks") 

594 self.makeSubtask("streakDetection") 

595 self.makeSubtask("findGlints") 

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

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

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

599 

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

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

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

603 

604 # Check that the schema and config are consistent 

605 for flag in self.config.badSourceFlags: 

606 if flag not in self.schema: 

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

608 

609 # initialize InitOutputs 

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

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

612 

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

614 inputRefs: pipeBase.InputQuantizedConnection, 

615 outputRefs: pipeBase.OutputQuantizedConnection): 

616 inputs = butlerQC.get(inputRefs) 

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

618 idFactory = idGenerator.make_table_id_factory() 

619 

620 # Input image that each output is measured from, keyed by output name. 

621 inputImages = {} 

622 if self.config.image_type == "future": 

623 dataId = butlerQC.quantum.dataId 

624 for output, connection in (("subtractedMeasuredExposure", "difference"), 

625 ("scoreMeasuredExposure", "scoreExposure")): 

626 if connection not in inputs: 

627 continue 

628 image = inputs[connection] 

629 # The output is written back into this image, so it must 

630 # already carry the kernel and the template records. 

631 get_difference_image_provenance(image, "kernel", connection, dataId) 

632 get_difference_image_provenance(image, "templates", connection, dataId) 

633 inputImages[output] = image 

634 inputs[connection] = image.to_legacy() 

635 

636 # Specify the fields that `annotate` and `convert_outputs_to_future` 

637 # need below, to ensure they exist, even as None. 

638 measurementResults = pipeBase.Struct( 

639 subtractedMeasuredExposure=None, 

640 scoreMeasuredExposure=None, 

641 diaSources=None, 

642 maskedStreaks=None, 

643 differenceBackground=None, 

644 ) 

645 try: 

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

647 except pipeBase.AlgorithmError as e: 

648 # Annotate before converting 

649 error = pipeBase.AnnotatedPartialOutputsError.annotate( 

650 e, 

651 self, 

652 measurementResults.subtractedMeasuredExposure, 

653 measurementResults.scoreMeasuredExposure, 

654 measurementResults.diaSources, 

655 measurementResults.maskedStreaks, 

656 log=self.log 

657 ) 

658 if self.config.image_type == "future": 

659 self.convert_outputs_to_future(measurementResults, inputImages) 

660 butlerQC.put(measurementResults, outputRefs) 

661 raise error from e 

662 if self.config.image_type == "future": 

663 self.convert_outputs_to_future(measurementResults, inputImages) 

664 butlerQC.put(measurementResults, outputRefs) 

665 

666 def convert_outputs_to_future(self, results, inputImages): 

667 """Write the output images in a result struct back into the 

668 `lsst.images` images they were measured from. 

669 

670 The image, variance, and mask planes of each input image are replaced 

671 by those of the corresponding legacy output, and the background 

672 subtracted by this task is attached. The kernel, template records, 

673 and observation metadata of the input are kept. Outputs that are 

674 absent or `None` are skipped. 

675 

676 Parameters 

677 ---------- 

678 results : `lsst.pipe.base.Struct` 

679 Output struct to read and modify in place. 

680 inputImages : `dict` [`str`, `lsst.images.DifferenceImage`] 

681 Input image each output was measured from, keyed by the name of 

682 the output. Each is modified in place. 

683 

684 Notes 

685 ----- 

686 This uses the default legacy mask plane map of 

687 `lsst.images.get_legacy_difference_image_mask_planes` and adds any 

688 planes from `~lsst.images.get_legacy_optional_mask_planes` that are set 

689 for any pixels in the image. 

690 """ 

691 background = getattr(results, "differenceBackground", None) 

692 for name, image in inputImages.items(): 

693 exposure = getattr(results, name, None) 

694 if exposure is None: 694 ↛ 695line 694 didn't jump to line 695 because the condition on line 694 was never true

695 continue 

696 # These are no-ops when the legacy output is a view of the input, 

697 # as returned by `~lsst.images.DifferenceImage.to_legacy`. 

698 image.image.array[...] = exposure.image.array 

699 image.variance.array[...] = exposure.variance.array 

700 image.mask = Mask.from_legacy(exposure.mask, get_legacy_difference_image_mask_planes(), 

701 sky_projection=image.sky_projection) 

702 if background is not None and len(background) > 0: 

703 # The same background is subtracted from the difference image 

704 # and the score image, so it applies to both. 

705 image.backgrounds.add( 

706 "subtracted", 

707 field_from_legacy_background(background, bounds=image.bbox, unit=image.unit), 

708 description="Background subtracted from this image before detection.", 

709 is_subtracted=True, 

710 ) 

711 setattr(results, name, image) 

712 if background is not None and len(background) == 0: 

713 # An empty BackgroundList cannot be converted to a field, and means 

714 # that no background was subtracted. 

715 self.log.debug("No background model to attach to the output images.") 

716 

717 @timeMethod 

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

719 idFactory=None, measurementResults=None): 

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

721 

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

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

724 Close positive and negative detections will optionally be merged into 

725 dipole diaSources. 

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

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

728 detections. 

729 

730 Parameters 

731 ---------- 

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

733 Science exposure that the template was subtracted from. 

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

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

736 difference image. 

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

738 Result of subtracting template from the science image. 

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

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

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

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

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

744 deblending and merging positive/negative peaks. 

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

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

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

748 this is also returned. 

749 

750 Returns 

751 ------- 

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

753 

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

755 Subtracted exposure with detection mask applied. 

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

757 The catalog of detected sources. 

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

759 Background that was subtracted from the difference image. 

760 """ 

761 if measurementResults is None: 

762 measurementResults = pipeBase.Struct() 

763 if idFactory is None: 

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

765 

766 # Check image properties and clear detection mask planes. 

767 self._prepareInputs(difference) 

768 if self.config.doSubtractBackground: 

769 background = self._fitAndSubtractBackground(differenceExposure=difference) 

770 else: 

771 background = afwMath.BackgroundList() 

772 

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

774 self.findAndMaskCosmicRays(difference) 

775 

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

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

778 # know about past ids). 

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

780 results = self.detection.run( 

781 table=table, 

782 exposure=difference, 

783 doSmooth=True, 

784 background=background, 

785 clearMask=True, 

786 ) 

787 measurementResults.differenceBackground = background 

788 

789 if self.config.doDeblend: 

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

791 results.positive, 

792 results.negative) 

793 

794 else: 

795 positives = afwTable.SourceCatalog(self.schema) 

796 results.positive.makeSources(positives) 

797 negatives = afwTable.SourceCatalog(self.schema) 

798 results.negative.makeSources(negatives) 

799 sources = results.sources 

800 

801 self.processResults(science, matchedTemplate, difference, 

802 sources, idFactory, kernelSources, 

803 positives=positives, 

804 negatives=negatives, 

805 measurementResults=measurementResults) 

806 return measurementResults 

807 

808 def _prepareInputs(self, difference): 

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

810 

811 Parameters 

812 ---------- 

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

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

815 The mask plane will be modified in place. 

816 

817 Raises 

818 ------ 

819 lsst.pipe.base.UpstreamFailureNoWorkFound 

820 If the PSF is not usable for measurement. 

821 """ 

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

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

824 if np.isnan(sigma): 

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

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

827 mask = difference.mask 

828 for mp in self.config.clearMaskPlanes: 

829 if mp not in mask.getMaskPlaneDict(): 

830 mask.addMaskPlane(mp) 

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

832 

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

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

835 

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

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

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

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

840 scheme is what suppresses spurious detections from glints and 

841 extended structures: the small-binsize initial fit aggressively 

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

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

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

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

846 detection runs on a properly background-subtracted score. 

847 

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

849 leaves on the clone is used downstream. 

850 

851 Parameters 

852 ---------- 

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

854 Difference image. The final background is subtracted from its 

855 image in place but its mask is not modified. 

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

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

858 background is also subtracted from its image. 

859 

860 Returns 

861 ------- 

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

863 The fit final background. 

864 """ 

865 if scoreExposure is None: 

866 detectionExposure = differenceExposure.clone() 

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

868 doSmooth = True 

869 else: 

870 # Use a clone of differenceExposure because the background is 

871 # subtracted in place. 

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

873 detectionExposure = scoreExposure.clone() 

874 detectionExposure.image -= background.getImage() 

875 doSmooth = False 

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

877 self.detection.run( 

878 table=table, 

879 exposure=detectionExposure, 

880 doSmooth=doSmooth, 

881 background=background, 

882 clearMask=True, 

883 ) 

884 # Use the temporary detection mask for the final background subtraction, 

885 # then restore the original mask. The score image variant never 

886 # clears the detection planes of the difference image. 

887 mask = differenceExposure.mask 

888 originalMask = mask.array.copy() 

889 detectedBit = mask.getPlaneBitMask(["DETECTED"]) 

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

891 detectedNegativeBit = mask.getPlaneBitMask(["DETECTED_NEGATIVE"]) 

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

893 mask.array[detectedPix] |= detectedBit 

894 mask.array[detectedNegativePix] |= detectedNegativeBit 

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

896 mask.array[...] = originalMask 

897 if scoreExposure is not None: 

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

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

900 scoreExposure.image -= background.getImage() 

901 return background 

902 

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

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

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

906 

907 Parameters 

908 ---------- 

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

910 Science exposure that the template was subtracted from. 

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

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

913 difference image. 

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

915 Result of subtracting template from the science image. 

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

917 Detected sources on the difference exposure. 

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

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

920 difference image. 

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

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

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

924 Positive polarity footprints. 

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

926 Negative polarity footprints. 

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

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

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

930 this is also returned. 

931 

932 Returns 

933 ------- 

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

935 

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

937 Subtracted exposure with detection mask applied. 

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

939 The catalog of detected sources. 

940 """ 

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

942 measurementResults = pipeBase.Struct() 

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

944 if self.config.doMerge: 

945 # preserve peak schema, if there are any footprints 

946 if len(positives) > 0: 

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

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

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

950 else: 

951 peakSchema = afwDetection.PeakTable.makeMinimalSchema() 

952 mergeList = afwDetection.FootprintMergeList(self.schema, 

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

954 initialDiaSources = afwTable.SourceCatalog(self.schema) 

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

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

957 # deblended positive sources. 

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

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

960 mergeList.getFinalSources(initialDiaSources) 

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

962 # footprint set. 

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

964 ~initialDiaSources["merge_footprint_positive"] 

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

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

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

968 self._reorderPeaksBySignificance(initialDiaSources) 

969 else: 

970 initialDiaSources = sources 

971 

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

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

974 for source in initialDiaSources: 

975 source.setId(idFactory()) 

976 # Ensure sources added after this get correct ids. 

977 initialDiaSources.getTable().setIdFactory(idFactory) 

978 initialDiaSources.setMetadata(self.algMetadata) 

979 

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

981 

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

983 streakInfo = self._runStreakMasking(difference) 

984 

985 if self.config.doSkySources: 

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

987 

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

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

990 # measurement. 

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

992 

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

994 initialDiaSources = initialDiaSources.copy(deep=True) 

995 

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

997 

998 # Remove unphysical diaSources per config.badSourceFlags 

999 diaSources = self._removeBadSources(initialDiaSources) 

1000 

1001 if self.config.run_sattle: 

1002 diaSources = self.filterSatellites(diaSources, science) 

1003 

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

1005 diaSources, trail_parameters = self._find_glint_trails(diaSources) 

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

1007 measurementResults.mergeItems(trail_parameters, 'glintTrailInfo') 

1008 

1009 if self.config.doForcedMeasurement: 

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

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

1012 template=True) 

1013 

1014 # Clear the image plane for regions with NO_DATA. 

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

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

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

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

1019 

1020 measurementResults.subtractedMeasuredExposure = difference 

1021 

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

1023 measurementResults.maskedStreaks = streakInfo.maskedStreaks 

1024 

1025 if kernelSources is not None: 

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

1027 

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

1029 measurementResults.diaSources = diaSources 

1030 elif self.config.raiseOnNoDiaSources: 

1031 raise NoDiaSourcesError() 

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

1033 # This option allows returning sky sources, 

1034 # even if there are no diaSources 

1035 measurementResults.diaSources = diaSources 

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

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

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

1039 ) 

1040 return measurementResults 

1041 

1042 def _reorderPeaksBySignificance(self, diaSources): 

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

1044 

1045 Parameters 

1046 ---------- 

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

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

1049 """ 

1050 for source in diaSources: 

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

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

1053 continue 

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

1055 newPeaks = afwDetection.PeakCatalog(peaks.table) 

1056 newPeaks.extend(ordered) 

1057 peaks.clear() 

1058 source.getFootprint().setPeakCatalog(newPeaks) 

1059 

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

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

1062 containing just the children, and the deblended footprints. 

1063 

1064 Parameters 

1065 ---------- 

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

1067 Result of subtracting template from the science image. 

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

1069 Positive and negative polarity footprints measured on 

1070 ``difference`` to be deblended separately. 

1071 

1072 Returns 

1073 ------- 

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

1075 Positive and negative deblended children. 

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

1077 Deblended positive and negative polarity sources with footprints 

1078 detected on ``difference``. 

1079 """ 

1080 

1081 def deblend(footprints, negative=False): 

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

1083 and return the deblended children. 

1084 

1085 Parameters 

1086 ---------- 

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

1088 negative : `bool` 

1089 Set True if the footprints contain negative fluxes 

1090 

1091 Returns 

1092 ------- 

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

1094 """ 

1095 sources = afwTable.SourceCatalog(self.schema) 

1096 footprints.makeSources(sources) 

1097 if negative: 

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

1099 difference_inverted = difference.clone() 

1100 difference_inverted.image *= -1 

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

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

1103 # Set the heavy footprint pixel values back to reality 

1104 for child in children: 

1105 footprint = child.getFootprint() 

1106 array = footprint.getImageArray() 

1107 array *= -1 

1108 else: 

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

1110 self.setPrimaryFlags.run(sources) 

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

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

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

1114 sources['parent'] = 0 

1115 return sources.copy(deep=True) 

1116 

1117 positives = deblend(positiveFootprints) 

1118 negatives = deblend(negativeFootprints, negative=True) 

1119 

1120 sources = afwTable.SourceCatalog(self.schema) 

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

1122 sources.extend(positives, deep=True) 

1123 sources.extend(negatives, deep=True) 

1124 if len(negatives) > 0: 

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

1126 return sources, positives, negatives 

1127 

1128 def _rejectBadMaskedDetections(self, initialDiaSources, mask): 

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

1130 

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

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

1133 

1134 Parameters 

1135 ---------- 

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

1137 The catalog of detected peaks. 

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

1139 Mask of the image used for detection. 

1140 

1141 Returns 

1142 ------- 

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

1144 The catalog with bad-masked detections removed. 

1145 """ 

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

1147 self.metadata["nRejectedBadMaskPlanes"] = 0 

1148 return initialDiaSources 

1149 

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

1151 if mp in mask.getMaskPlaneDict()] 

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

1153 self.metadata["nRejectedBadMaskPlanes"] = 0 

1154 return initialDiaSources 

1155 

1156 badBitMask = mask.getPlaneBitMask(presentPlanes) 

1157 x0, y0 = mask.getXY0() 

1158 

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

1160 for i, source in enumerate(initialDiaSources): 

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

1162 for peak in peaks: 

1163 ix = peak.getIx() - x0 

1164 iy = peak.getIy() - y0 

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

1166 selector[i] = False 

1167 break 

1168 nRejected = np.count_nonzero(~selector) 

1169 self.metadata["nRejectedBadMaskPlanes"] = nRejected 

1170 if nRejected > 0: 

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

1172 "planes %s before measurement.", 

1173 nRejected, presentPlanes) 

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

1175 

1176 def _removeBadSources(self, diaSources): 

1177 """Remove unphysical diaSources from the catalog. 

1178 

1179 Parameters 

1180 ---------- 

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

1182 The catalog of detected sources. 

1183 

1184 Returns 

1185 ------- 

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

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

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

1189 """ 

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

1191 for flag in self.config.badSourceFlags: 

1192 flags = diaSources[flag] 

1193 nBad = np.count_nonzero(flags) 

1194 if nBad > 0: 

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

1196 selector &= ~flags 

1197 nBadTotal = np.count_nonzero(~selector) 

1198 self.metadata["nRemovedBadFlaggedSources"] = nBadTotal 

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

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

1201 

1202 def _find_glint_trails(self, diaSources): 

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

1204 

1205 Parameters 

1206 ---------- 

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

1208 The catalog of detected sources. 

1209 

1210 Returns 

1211 ------- 

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

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

1214 called 'glint_trail' added. 

1215 

1216 trail_parameters : `dict` 

1217 Parameters of all the trails that were found. 

1218 """ 

1219 if self.config.doSkySources: 

1220 # Do not include sky sources in glint detection 

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

1222 else: 

1223 candidateDiaSources = diaSources 

1224 trailed_glints = self.findGlints.run(candidateDiaSources) 

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

1226 if np.any(glint_mask): 

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

1228 

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

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

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

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

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

1234 center_xs = np.array([trail.center_x for trail in trailed_glints.parameters]) 

1235 center_ys = np.array([trail.center_y for trail in trailed_glints.parameters]) 

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

1237 'angles': angles, 'center_xs': center_xs, 'center_ys': center_ys} 

1238 

1239 trail_parameters = pipeBase.Struct(glintTrailInfo=parameters) 

1240 

1241 return diaSources, trail_parameters 

1242 

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

1244 subtask=None): 

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

1246 for measuring the background. 

1247 

1248 Parameters 

1249 ---------- 

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

1251 The catalog of detected sources. 

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

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

1254 seed : `int` 

1255 Seed value to initialize the random number generator. 

1256 """ 

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

1258 subtask = self.skySources 

1259 if subtask.config.nSources <= 0: 

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

1261 return 

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

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

1264 

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

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

1267 

1268 Parameters 

1269 ---------- 

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

1271 The catalog of detected sources. 

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

1273 Science exposure that the template was subtracted from. 

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

1275 Result of subtracting template from the science image. 

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

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

1278 difference image. 

1279 """ 

1280 # Ensure that the required mask planes are present 

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

1282 difference.mask.addMaskPlane(mp) 

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

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

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

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

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

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

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

1290 else: 

1291 self.applyApCorr.run( 

1292 catalog=diaSources, 

1293 apCorrMap=apCorrMap, 

1294 ) 

1295 

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

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

1298 

1299 Parameters 

1300 ---------- 

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

1302 The catalog of detected sources. 

1303 image: `lsst.afw.image.ExposureF` 

1304 Exposure that the forced measurement is being performed on 

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

1306 Coordinate system definition (wcs) for the exposure. 

1307 template : `bool` 

1308 Is the forced measurement being performed on the template? 

1309 """ 

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

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

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

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

1314 

1315 if template: 

1316 base_key = 'ip_diffim_forced_template_PsfFlux' 

1317 else: 

1318 base_key = 'ip_diffim_forced_PsfFlux' 

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

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

1321 f"{base_key}_instFlux", True) 

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

1323 f"{base_key}_instFluxErr", True) 

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

1325 f"{base_key}_area", True) 

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

1327 f"{base_key}_flag", True) 

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

1329 f"{base_key}_flag_noGoodPixels", True) 

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

1331 f"{base_key}_flag_edge", True) 

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

1333 diaSource.assign(forcedSource, mapper) 

1334 

1335 def findAndMaskCosmicRays(self, difference): 

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

1337 

1338 Parameters 

1339 ---------- 

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

1341 The background-subtracted difference image. 

1342 The mask plane will be modified in place. 

1343 """ 

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

1345 # remaining background should be ~0. 

1346 medianBg = 0. 

1347 try: 

1348 crs = findCosmicRays( 

1349 difference.maskedImage, 

1350 difference.psf, 

1351 medianBg, 

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

1353 self.config.cosmicray.keepCRs, 

1354 ) 

1355 except LengthError: 

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

1357 num = 0 

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

1359 mask = difference.maskedImage.mask 

1360 crBit = mask.getPlaneBitMask("CR") 

1361 afwDetection.setMaskFromFootprintList(mask, crs, crBit) 

1362 num = len(crs) 

1363 

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

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

1366 self.metadata["cosmic_ray_count"] = num 

1367 

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

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

1370 

1371 This may be used to produce corresponding metrics (see 

1372 lsst.analysis.tools.tasks.diffimTaskDetectorVisitMetricAnalysis). 

1373 

1374 Parameters 

1375 ---------- 

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

1377 Science exposure that was subtracted. 

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

1379 The target difference image to calculate metrics for. 

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

1381 The catalog of detected sources. 

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

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

1384 """ 

1385 mask = difference.mask 

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

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

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

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

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

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

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

1393 detPosPix &= badPix 

1394 detNegPix &= badPix 

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

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

1397 

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

1399 for maskPlane in metricsMaskPlanes: 

1400 try: 

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

1402 except InvalidParameterError: 

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

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

1405 

1406 if self.config.doSkySources: 

1407 skySources = diaSources[diaSources["sky_source"]] 

1408 else: 

1409 skySources = None 

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

1411 

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

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

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

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

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

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

1418 self.metadata["residualFootprintRatioMean"], 

1419 self.metadata["residualFootprintRatioStdev"]) 

1420 if self.config.raiseOnBadSubtractionRatio: 

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

1422 raise BadSubtractionError(ratio=metrics.differenceFootprintRatioMean, 

1423 threshold=self.config.badSubtractionRatioThreshold) 

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

1425 raise BadSubtractionError(ratio=metrics.differenceFootprintRatioStdev, 

1426 threshold=self.config.badSubtractionVariationThreshold) 

1427 

1428 def getSattleDiaSourceAllowlist(self, diaSources, science): 

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

1430 

1431 Parameters 

1432 ---------- 

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

1434 The catalog of detected sources. 

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

1436 Science exposure that was subtracted. 

1437 

1438 Returns 

1439 ---------- 

1440 allow_list : `list` of `int` 

1441 diaSourceIds of diaSources that can be made public. 

1442 

1443 Raises 

1444 ------ 

1445 requests.HTTPError 

1446 Raised if sattle call does not return success. 

1447 """ 

1448 wcs = science.getWcs() 

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

1450 visit_id = visit_info.getId() 

1451 sattle_uri_base = os.getenv('SATTLE_URI_BASE') 

1452 

1453 dia_sources_json = [] 

1454 for source in diaSources: 

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

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

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

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

1459 

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

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

1462 

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

1464 json=payload) 

1465 

1466 # retry once if visit cache is not populated 

1467 if sattle_output.status_code == 404: 

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

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

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

1471 

1472 sattle_output.raise_for_status() 

1473 

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

1475 

1476 @timeMethod 

1477 def filterSatellites(self, diaSources, science): 

1478 """Remove diaSources overlapping predicted satellite positions. 

1479 

1480 Parameters 

1481 ---------- 

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

1483 The catalog of detected sources. 

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

1485 Science exposure that was subtracted. 

1486 

1487 Returns 

1488 ---------- 

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

1490 Filtered catalog of diaSources 

1491 """ 

1492 

1493 allow_list = self.getSattleDiaSourceAllowlist(diaSources, science) 

1494 

1495 if allow_list: 

1496 allow_set = set(allow_list) 

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

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

1499 else: 

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

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

1502 return diaSources 

1503 

1504 def _runStreakMasking(self, difference): 

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

1506 fit parameters in a catalog. 

1507 

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

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

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

1511 surface brightness streaks are more likely to fall above the 

1512 detection threshold. 

1513 

1514 Parameters 

1515 ---------- 

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

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

1518 mask. 

1519 

1520 Returns 

1521 ------- 

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

1523 ``rho`` : `np.ndarray` 

1524 Angle of detected streak. 

1525 ``theta`` : `np.ndarray` 

1526 Distance from center of detected streak. 

1527 ``sigma`` : `np.ndarray` 

1528 Width of streak profile. 

1529 ``reducedChi2`` : `np.ndarray` 

1530 Reduced chi2 of the best-fit streak profile. 

1531 ``modelMaximum`` : `np.ndarray` 

1532 Peak value of the fit line profile. 

1533 """ 

1534 maskedImage = difference.maskedImage 

1535 # Bin the diffim to enhance low surface brightness streaks 

1536 binnedMaskedImage = afwMath.binImage(maskedImage, 

1537 self.config.streakBinFactor, 

1538 self.config.streakBinFactor) 

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

1540 binnedExposure.setMaskedImage(binnedMaskedImage) 

1541 # Clear the DETECTED mask plane before streak detection 

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

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

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

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

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

1547 sigma=sigma/self.config.streakBinFactor) 

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

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

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

1551 axis=1) 

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

1553 streakMaskedImage = maskedImage.clone() 

1554 ysize, xsize = rescaledDetectedMaskPlane.shape 

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

1556 # Detect streaks on this new version of the diffim 

1557 streaks = self.maskStreaks.run(streakMaskedImage) 

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

1559 # Apply the new STREAK mask to the original diffim 

1560 maskedImage.mask.array |= streakMaskPlane 

1561 

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

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

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

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

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

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

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

1569 'modelMaximum': modelMaximums} 

1570 else: 

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

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

1573 return pipeBase.Struct(maskedStreaks=streakInfo) 

1574 

1575 

1576class DetectAndMeasureScoreConnections(DetectAndMeasureConnections): 

1577 scoreExposure = pipeBase.connectionTypes.Input( 

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

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

1580 storageClass="ExposureF", 

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

1582 ) 

1583 scoreMeasuredExposure = pipeBase.connectionTypes.Output( 

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

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

1586 storageClass="ExposureF", 

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

1588 ) 

1589 

1590 def __init__(self, *, config): 

1591 super().__init__(config=config) 

1592 if self.config.image_type == "future": 

1593 self.scoreExposure = dataclasses.replace(self.scoreExposure, storageClass="DifferenceImage") 

1594 self.scoreMeasuredExposure = dataclasses.replace(self.scoreMeasuredExposure, 

1595 storageClass="DifferenceImage") 

1596 

1597 

1598class DetectAndMeasureScoreConfig(DetectAndMeasureConfig, 

1599 pipelineConnections=DetectAndMeasureScoreConnections): 

1600 pass 

1601 

1602 

1603class DetectAndMeasureScoreTask(DetectAndMeasureTask): 

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

1605 and measure the detections on the difference image. 

1606 

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

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

1609 Close positive and negative detections will optionally be merged into 

1610 dipole diaSources. 

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

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

1613 detections. 

1614 """ 

1615 ConfigClass = DetectAndMeasureScoreConfig 

1616 _DefaultName = "detectAndMeasureScore" 

1617 

1618 @timeMethod 

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

1620 idFactory=None, measurementResults=None): 

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

1622 

1623 Parameters 

1624 ---------- 

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

1626 Science exposure that the template was subtracted from. 

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

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

1629 difference image. 

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

1631 Result of subtracting template from the science image. 

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

1633 Score or maximum likelihood difference image 

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

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

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

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

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

1639 deblending and merging positive/negative peaks. 

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

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

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

1643 this is also returned. 

1644 

1645 Returns 

1646 ------- 

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

1648 

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

1650 Subtracted exposure with detection mask applied. 

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

1652 Score exposure with detection mask applied. 

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

1654 The catalog of detected sources. 

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

1656 Background that was subtracted from the difference image. 

1657 """ 

1658 if measurementResults is None: 

1659 measurementResults = pipeBase.Struct() 

1660 if idFactory is None: 

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

1662 

1663 # Check image properties and clear detection mask planes. 

1664 self._prepareInputs(difference) 

1665 self._prepareInputs(scoreExposure) 

1666 if self.config.doSubtractBackground: 

1667 background = self._fitAndSubtractBackground( 

1668 differenceExposure=difference, scoreExposure=scoreExposure, 

1669 ) 

1670 else: 

1671 background = afwMath.BackgroundList() 

1672 

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

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

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

1676 self.findAndMaskCosmicRays(difference) 

1677 scoreExposure.mask.array |= difference.mask.array 

1678 

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

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

1681 # know about past ids). 

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

1683 detectResults = self.detection.run( 

1684 table=table, 

1685 exposure=scoreExposure, 

1686 doSmooth=False, 

1687 background=background, 

1688 clearMask=True, 

1689 ) 

1690 measurementResults.differenceBackground = background 

1691 measurementResults.scoreMeasuredExposure = scoreExposure 

1692 

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

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

1695 # the size of the convolution kernel. 

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

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

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

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

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

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

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

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

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

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

1706 

1707 if self.config.doDeblend: 

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

1709 detectResults.positive, 

1710 detectResults.negative) 

1711 

1712 self.processResults(science, matchedTemplate, difference, 

1713 sources, idFactory, kernelSources, 

1714 positives=positives, 

1715 negatives=negatives, 

1716 measurementResults=measurementResults) 

1717 

1718 else: 

1719 positives = afwTable.SourceCatalog(self.schema) 

1720 detectResults.positive.makeSources(positives) 

1721 negatives = afwTable.SourceCatalog(self.schema) 

1722 detectResults.negative.makeSources(negatives) 

1723 self.processResults(science, matchedTemplate, difference, 

1724 detectResults.sources, idFactory, kernelSources, 

1725 positives=positives, 

1726 negatives=negatives, 

1727 measurementResults=measurementResults) 

1728 return measurementResults