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

643 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-15 09:47 +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): 1226 ↛ 1227line 1226 didn't jump to line 1227 because the condition on line 1226 was never true

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 parameters = {'slopes': slopes, 'intercepts': intercepts, 'stderrs': stderrs, 'lengths': lengths, 

1235 'angles': angles} 

1236 

1237 trail_parameters = pipeBase.Struct(glintTrailInfo=parameters) 

1238 

1239 return diaSources, trail_parameters 

1240 

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

1242 subtask=None): 

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

1244 for measuring the background. 

1245 

1246 Parameters 

1247 ---------- 

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

1249 The catalog of detected sources. 

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

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

1252 seed : `int` 

1253 Seed value to initialize the random number generator. 

1254 """ 

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

1256 subtask = self.skySources 

1257 if subtask.config.nSources <= 0: 

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

1259 return 

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

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

1262 

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

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

1265 

1266 Parameters 

1267 ---------- 

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

1269 The catalog of detected sources. 

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

1271 Science exposure that the template was subtracted from. 

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

1273 Result of subtracting template from the science image. 

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

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

1276 difference image. 

1277 """ 

1278 # Ensure that the required mask planes are present 

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

1280 difference.mask.addMaskPlane(mp) 

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

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

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

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

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

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

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

1288 else: 

1289 self.applyApCorr.run( 

1290 catalog=diaSources, 

1291 apCorrMap=apCorrMap, 

1292 ) 

1293 

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

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

1296 

1297 Parameters 

1298 ---------- 

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

1300 The catalog of detected sources. 

1301 image: `lsst.afw.image.ExposureF` 

1302 Exposure that the forced measurement is being performed on 

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

1304 Coordinate system definition (wcs) for the exposure. 

1305 template : `bool` 

1306 Is the forced measurement being performed on the template? 

1307 """ 

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

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

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

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

1312 

1313 if template: 

1314 base_key = 'ip_diffim_forced_template_PsfFlux' 

1315 else: 

1316 base_key = 'ip_diffim_forced_PsfFlux' 

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

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

1319 f"{base_key}_instFlux", True) 

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

1321 f"{base_key}_instFluxErr", True) 

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

1323 f"{base_key}_area", True) 

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

1325 f"{base_key}_flag", True) 

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

1327 f"{base_key}_flag_noGoodPixels", True) 

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

1329 f"{base_key}_flag_edge", True) 

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

1331 diaSource.assign(forcedSource, mapper) 

1332 

1333 def findAndMaskCosmicRays(self, difference): 

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

1335 

1336 Parameters 

1337 ---------- 

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

1339 The background-subtracted difference image. 

1340 The mask plane will be modified in place. 

1341 """ 

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

1343 # remaining background should be ~0. 

1344 medianBg = 0. 

1345 try: 

1346 crs = findCosmicRays( 

1347 difference.maskedImage, 

1348 difference.psf, 

1349 medianBg, 

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

1351 self.config.cosmicray.keepCRs, 

1352 ) 

1353 except LengthError: 

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

1355 num = 0 

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

1357 mask = difference.maskedImage.mask 

1358 crBit = mask.getPlaneBitMask("CR") 

1359 afwDetection.setMaskFromFootprintList(mask, crs, crBit) 

1360 num = len(crs) 

1361 

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

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

1364 self.metadata["cosmic_ray_count"] = num 

1365 

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

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

1368 

1369 This may be used to produce corresponding metrics (see 

1370 lsst.analysis.tools.tasks.diffimTaskDetectorVisitMetricAnalysis). 

1371 

1372 Parameters 

1373 ---------- 

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

1375 Science exposure that was subtracted. 

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

1377 The target difference image to calculate metrics for. 

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

1379 The catalog of detected sources. 

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

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

1382 """ 

1383 mask = difference.mask 

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

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

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

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

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

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

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

1391 detPosPix &= badPix 

1392 detNegPix &= badPix 

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

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

1395 

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

1397 for maskPlane in metricsMaskPlanes: 

1398 try: 

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

1400 except InvalidParameterError: 

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

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

1403 

1404 if self.config.doSkySources: 

1405 skySources = diaSources[diaSources["sky_source"]] 

1406 else: 

1407 skySources = None 

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

1409 

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

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

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

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

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

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

1416 self.metadata["residualFootprintRatioMean"], 

1417 self.metadata["residualFootprintRatioStdev"]) 

1418 if self.config.raiseOnBadSubtractionRatio: 

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

1420 raise BadSubtractionError(ratio=metrics.differenceFootprintRatioMean, 

1421 threshold=self.config.badSubtractionRatioThreshold) 

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

1423 raise BadSubtractionError(ratio=metrics.differenceFootprintRatioStdev, 

1424 threshold=self.config.badSubtractionVariationThreshold) 

1425 

1426 def getSattleDiaSourceAllowlist(self, diaSources, science): 

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

1428 

1429 Parameters 

1430 ---------- 

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

1432 The catalog of detected sources. 

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

1434 Science exposure that was subtracted. 

1435 

1436 Returns 

1437 ---------- 

1438 allow_list : `list` of `int` 

1439 diaSourceIds of diaSources that can be made public. 

1440 

1441 Raises 

1442 ------ 

1443 requests.HTTPError 

1444 Raised if sattle call does not return success. 

1445 """ 

1446 wcs = science.getWcs() 

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

1448 visit_id = visit_info.getId() 

1449 sattle_uri_base = os.getenv('SATTLE_URI_BASE') 

1450 

1451 dia_sources_json = [] 

1452 for source in diaSources: 

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

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

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

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

1457 

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

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

1460 

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

1462 json=payload) 

1463 

1464 # retry once if visit cache is not populated 

1465 if sattle_output.status_code == 404: 

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

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

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

1469 

1470 sattle_output.raise_for_status() 

1471 

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

1473 

1474 @timeMethod 

1475 def filterSatellites(self, diaSources, science): 

1476 """Remove diaSources overlapping predicted satellite positions. 

1477 

1478 Parameters 

1479 ---------- 

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

1481 The catalog of detected sources. 

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

1483 Science exposure that was subtracted. 

1484 

1485 Returns 

1486 ---------- 

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

1488 Filtered catalog of diaSources 

1489 """ 

1490 

1491 allow_list = self.getSattleDiaSourceAllowlist(diaSources, science) 

1492 

1493 if allow_list: 

1494 allow_set = set(allow_list) 

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

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

1497 else: 

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

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

1500 return diaSources 

1501 

1502 def _runStreakMasking(self, difference): 

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

1504 fit parameters in a catalog. 

1505 

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

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

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

1509 surface brightness streaks are more likely to fall above the 

1510 detection threshold. 

1511 

1512 Parameters 

1513 ---------- 

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

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

1516 mask. 

1517 

1518 Returns 

1519 ------- 

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

1521 ``rho`` : `np.ndarray` 

1522 Angle of detected streak. 

1523 ``theta`` : `np.ndarray` 

1524 Distance from center of detected streak. 

1525 ``sigma`` : `np.ndarray` 

1526 Width of streak profile. 

1527 ``reducedChi2`` : `np.ndarray` 

1528 Reduced chi2 of the best-fit streak profile. 

1529 ``modelMaximum`` : `np.ndarray` 

1530 Peak value of the fit line profile. 

1531 """ 

1532 maskedImage = difference.maskedImage 

1533 # Bin the diffim to enhance low surface brightness streaks 

1534 binnedMaskedImage = afwMath.binImage(maskedImage, 

1535 self.config.streakBinFactor, 

1536 self.config.streakBinFactor) 

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

1538 binnedExposure.setMaskedImage(binnedMaskedImage) 

1539 # Clear the DETECTED mask plane before streak detection 

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

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

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

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

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

1545 sigma=sigma/self.config.streakBinFactor) 

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

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

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

1549 axis=1) 

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

1551 streakMaskedImage = maskedImage.clone() 

1552 ysize, xsize = rescaledDetectedMaskPlane.shape 

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

1554 # Detect streaks on this new version of the diffim 

1555 streaks = self.maskStreaks.run(streakMaskedImage) 

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

1557 # Apply the new STREAK mask to the original diffim 

1558 maskedImage.mask.array |= streakMaskPlane 

1559 

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

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

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

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

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

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

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

1567 'modelMaximum': modelMaximums} 

1568 else: 

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

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

1571 return pipeBase.Struct(maskedStreaks=streakInfo) 

1572 

1573 

1574class DetectAndMeasureScoreConnections(DetectAndMeasureConnections): 

1575 scoreExposure = pipeBase.connectionTypes.Input( 

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

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

1578 storageClass="ExposureF", 

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

1580 ) 

1581 scoreMeasuredExposure = pipeBase.connectionTypes.Output( 

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

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

1584 storageClass="ExposureF", 

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

1586 ) 

1587 

1588 def __init__(self, *, config): 

1589 super().__init__(config=config) 

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

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

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

1593 storageClass="DifferenceImage") 

1594 

1595 

1596class DetectAndMeasureScoreConfig(DetectAndMeasureConfig, 

1597 pipelineConnections=DetectAndMeasureScoreConnections): 

1598 pass 

1599 

1600 

1601class DetectAndMeasureScoreTask(DetectAndMeasureTask): 

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

1603 and measure the detections on the difference image. 

1604 

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

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

1607 Close positive and negative detections will optionally be merged into 

1608 dipole diaSources. 

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

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

1611 detections. 

1612 """ 

1613 ConfigClass = DetectAndMeasureScoreConfig 

1614 _DefaultName = "detectAndMeasureScore" 

1615 

1616 @timeMethod 

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

1618 idFactory=None, measurementResults=None): 

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

1620 

1621 Parameters 

1622 ---------- 

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

1624 Science exposure that the template was subtracted from. 

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

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

1627 difference image. 

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

1629 Result of subtracting template from the science image. 

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

1631 Score or maximum likelihood difference image 

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

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

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

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

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

1637 deblending and merging positive/negative peaks. 

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

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

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

1641 this is also returned. 

1642 

1643 Returns 

1644 ------- 

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

1646 

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

1648 Subtracted exposure with detection mask applied. 

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

1650 Score exposure with detection mask applied. 

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

1652 The catalog of detected sources. 

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

1654 Background that was subtracted from the difference image. 

1655 """ 

1656 if measurementResults is None: 

1657 measurementResults = pipeBase.Struct() 

1658 if idFactory is None: 

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

1660 

1661 # Check image properties and clear detection mask planes. 

1662 self._prepareInputs(difference) 

1663 self._prepareInputs(scoreExposure) 

1664 if self.config.doSubtractBackground: 

1665 background = self._fitAndSubtractBackground( 

1666 differenceExposure=difference, scoreExposure=scoreExposure, 

1667 ) 

1668 else: 

1669 background = afwMath.BackgroundList() 

1670 

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

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

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

1674 self.findAndMaskCosmicRays(difference) 

1675 scoreExposure.mask.array |= difference.mask.array 

1676 

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

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

1679 # know about past ids). 

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

1681 detectResults = self.detection.run( 

1682 table=table, 

1683 exposure=scoreExposure, 

1684 doSmooth=False, 

1685 background=background, 

1686 clearMask=True, 

1687 ) 

1688 measurementResults.differenceBackground = background 

1689 measurementResults.scoreMeasuredExposure = scoreExposure 

1690 

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

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

1693 # the size of the convolution kernel. 

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

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

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

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

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

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

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

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

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

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

1704 

1705 if self.config.doDeblend: 

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

1707 detectResults.positive, 

1708 detectResults.negative) 

1709 

1710 self.processResults(science, matchedTemplate, difference, 

1711 sources, idFactory, kernelSources, 

1712 positives=positives, 

1713 negatives=negatives, 

1714 measurementResults=measurementResults) 

1715 

1716 else: 

1717 positives = afwTable.SourceCatalog(self.schema) 

1718 detectResults.positive.makeSources(positives) 

1719 negatives = afwTable.SourceCatalog(self.schema) 

1720 detectResults.negative.makeSources(negatives) 

1721 self.processResults(science, matchedTemplate, difference, 

1722 detectResults.sources, idFactory, kernelSources, 

1723 positives=positives, 

1724 negatives=negatives, 

1725 measurementResults=measurementResults) 

1726 return measurementResults