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

697 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-10 01:41 -0800

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_TEMPLATE_EPOCH_NAMES = { 

57 "TEMPLATE MJD MIN": "templateMjdMin", 

58 "TEMPLATE MJD MAX": "templateMjdMax", 

59 "TEMPLATE MJD MEAN": "templateMjdMean", 

60} 

61"""Image metadata key of each summary of the template epochs of the 

62diaSources, mapped to the name of the diaSource field and task metadata 

63entry it summarizes. 

64""" 

65 

66 

67class BadSubtractionError(pipeBase.AlgorithmError): 

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

69 psf-matching kernel exceeds the configured maximum. 

70 """ 

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

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

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

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

75 f" threshold of {threshold}") 

76 super().__init__(msg) 

77 self.ratio = ratio 

78 self.threshold = threshold 

79 

80 @property 

81 def metadata(self): 

82 return {"ratio": self.ratio, 

83 "threshold": self.threshold 

84 } 

85 

86 

87class NoDiaSourcesError(pipeBase.AlgorithmError): 

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

89 """ 

90 def __init__(self): 

91 msg = ("No diaSources detected!") 

92 super().__init__(msg) 

93 

94 @property 

95 def metadata(self): 

96 return {} 

97 

98 

99class TooManyCosmicRays(pipeBase.AlgorithmError): 

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

101 

102 Parameters 

103 ---------- 

104 maxCosmicRays : `int` 

105 Maximum number of cosmic rays allowed. 

106 """ 

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

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

109 self.msg = msg 

110 self._metadata = kwargs 

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

112 self._metadata["maxCosmicRays"] = maxCosmicRays 

113 

114 def __str__(self): 

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

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

117 

118 @property 

119 def metadata(self): 

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

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

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

123 return self._metadata 

124 

125 

126class DetectAndMeasureConnections(pipeBase.PipelineTaskConnections, 

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

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

129 "warpTypeSuffix": "", 

130 "fakesType": ""}): 

131 science = pipeBase.connectionTypes.Input( 

132 doc="Input science exposure.", 

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

134 storageClass="ExposureF", 

135 name="{fakesType}calexp" 

136 ) 

137 matchedTemplate = pipeBase.connectionTypes.Input( 

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

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

140 storageClass="ExposureF", 

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

142 ) 

143 difference = pipeBase.connectionTypes.Input( 

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

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

146 storageClass="ExposureF", 

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

148 ) 

149 kernelSources = pipeBase.connectionTypes.Input( 

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

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

152 storageClass="SourceCatalog", 

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

154 ) 

155 outputSchema = pipeBase.connectionTypes.InitOutput( 

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

157 storageClass="SourceCatalog", 

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

159 ) 

160 diaSources = pipeBase.connectionTypes.Output( 

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

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

163 storageClass="SourceCatalog", 

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

165 ) 

166 subtractedMeasuredExposure = pipeBase.connectionTypes.Output( 

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

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

169 storageClass="ExposureF", 

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

171 ) 

172 differenceBackground = pipeBase.connectionTypes.Output( 

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

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

175 storageClass="Background", 

176 name="difference_background", 

177 ) 

178 maskedStreaks = pipeBase.connectionTypes.Output( 

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

180 storageClass="ArrowNumpyDict", 

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

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

183 ) 

184 glintTrailInfo = pipeBase.connectionTypes.Output( 

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

186 storageClass="ArrowNumpyDict", 

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

188 name="trailed_glints", 

189 ) 

190 

191 def __init__(self, *, config): 

192 super().__init__(config=config) 

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

194 self.inputs.remove("kernelSources") 

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

196 self.outputs.remove("maskedStreaks") 

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

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

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

200 self.outputs.remove("differenceBackground") 

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

202 self.outputs.remove("glintTrailInfo") 

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

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

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

206 storageClass="DifferenceImage") 

207 

208 

209class DetectAndMeasureConfig(pipeBase.PipelineTaskConfig, 

210 pipelineConnections=DetectAndMeasureConnections): 

211 """Config for DetectAndMeasureTask 

212 """ 

213 doMerge = pexConfig.Field( 

214 dtype=bool, 

215 default=True, 

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

217 "set by growFootprint" 

218 ) 

219 doForcedMeasurement = pexConfig.Field( 

220 dtype=bool, 

221 default=True, 

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

223 ) 

224 doAddMetrics = pexConfig.Field( 

225 dtype=bool, 

226 default=False, 

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

228 ) 

229 doSubtractBackground = pexConfig.Field( 

230 dtype=bool, 

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

232 default=True, 

233 ) 

234 doWriteBackground = pexConfig.Field( 

235 dtype=bool, 

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

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

238 default=False, 

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

240 " Will be removed after v31.", 

241 ) 

242 doCalculateResidualMetics = pexConfig.Field( 

243 dtype=bool, 

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

245 "metadata?", 

246 default=True, 

247 ) 

248 subtractInitialBackground = pexConfig.ConfigurableField( 

249 target=lsst.meas.algorithms.SubtractBackgroundTask, 

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

251 ) 

252 subtractFinalBackground = pexConfig.ConfigurableField( 

253 target=lsst.meas.algorithms.SubtractBackgroundTask, 

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

255 ) 

256 doFindCosmicRays = pexConfig.Field( 

257 dtype=bool, 

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

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

260 default=True, 

261 ) 

262 cosmicray = pexConfig.ConfigField( 

263 dtype=FindCosmicRaysConfig, 

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

265 ) 

266 detection = pexConfig.ConfigurableField( 

267 target=SourceDetectionTask, 

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

269 ) 

270 streakDetection = pexConfig.ConfigurableField( 

271 target=SourceDetectionTask, 

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

273 ) 

274 doDeblend = pexConfig.Field( 

275 dtype=bool, 

276 default=False, 

277 doc="Deblend DIASources after detection?" 

278 ) 

279 deblend = pexConfig.ConfigurableField( 

280 target=lsst.meas.deblender.SourceDeblendTask, 

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

282 ) 

283 measurement = pexConfig.ConfigurableField( 

284 target=DipoleFitTask, 

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

286 ) 

287 doApCorr = lsst.pex.config.Field( 

288 dtype=bool, 

289 default=True, 

290 doc="Run subtask to apply aperture corrections" 

291 ) 

292 applyApCorr = lsst.pex.config.ConfigurableField( 

293 target=ApplyApCorrTask, 

294 doc="Task to apply aperture corrections" 

295 ) 

296 forcedMeasurement = pexConfig.ConfigurableField( 

297 target=ForcedMeasurementTask, 

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

299 ) 

300 growFootprint = pexConfig.Field( 

301 dtype=int, 

302 default=2, 

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

304 ) 

305 diaSourceMatchRadius = pexConfig.Field( 

306 dtype=float, 

307 default=0.5, 

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

309 ) 

310 doSkySources = pexConfig.Field( 

311 dtype=bool, 

312 default=False, 

313 doc="Generate sky sources?", 

314 ) 

315 skySources = pexConfig.ConfigurableField( 

316 target=SkyObjectsTask, 

317 doc="Generate sky sources", 

318 ) 

319 doMaskStreaks = pexConfig.Field( 

320 dtype=bool, 

321 default=True, 

322 doc="Turn on streak masking", 

323 ) 

324 maskStreaks = pexConfig.ConfigurableField( 

325 target=MaskStreaksTask, 

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

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

328 ) 

329 streakBinFactor = pexConfig.Field( 

330 dtype=int, 

331 default=4, 

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

333 "Only used if doMaskStreaks is True.", 

334 ) 

335 writeStreakInfo = pexConfig.Field( 

336 dtype=bool, 

337 default=False, 

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

339 "development work." 

340 ) 

341 findGlints = pexConfig.ConfigurableField( 

342 target=FindGlintTrailsTask, 

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

344 ) 

345 writeGlintInfo = pexConfig.Field( 

346 dtype=bool, 

347 default=True, 

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

349 ) 

350 setPrimaryFlags = pexConfig.ConfigurableField( 

351 target=SetPrimaryFlagsTask, 

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

353 ) 

354 badSourceFlags = lsst.pex.config.ListField( 

355 dtype=str, 

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

357 default=("base_PixelFlags_flag_offimage", 

358 "base_PixelFlags_flag_interpolatedCenterAll", 

359 "base_PixelFlags_flag_badCenter", 

360 "base_PixelFlags_flag_edgeCenter", 

361 "base_PixelFlags_flag_nodataCenter", 

362 "base_PixelFlags_flag_saturatedCenter", 

363 "base_PixelFlags_flag_sat_templateCenter", 

364 "base_PixelFlags_flag_spikeCenter", 

365 ), 

366 ) 

367 clearMaskPlanes = lsst.pex.config.ListField( 

368 dtype=str, 

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

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

371 ) 

372 doRejectBadMaskPlaneDetections = pexConfig.Field( 

373 dtype=bool, 

374 default=True, 

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

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

377 "but filtering earlier can save time." 

378 ) 

379 badMaskPlanes = lsst.pex.config.ListField( 

380 dtype=str, 

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

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

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

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

385 ) 

386 raiseOnBadSubtractionRatio = pexConfig.Field( 

387 dtype=bool, 

388 default=True, 

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

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

391 " image exceeds ``badSubtractionRatioThreshold``", 

392 ) 

393 badSubtractionRatioThreshold = pexConfig.Field( 

394 dtype=float, 

395 default=0.2, 

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

397 " the same footprints on the science image." 

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

399 ) 

400 badSubtractionVariationThreshold = pexConfig.Field( 

401 dtype=float, 

402 default=0.4, 

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

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

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

406 ) 

407 raiseOnNoDiaSources = pexConfig.Field( 

408 dtype=bool, 

409 default=True, 

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

411 ) 

412 run_sattle = pexConfig.Field( 

413 dtype=bool, 

414 default=False, 

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

416 "to the sattle service." 

417 ) 

418 sattle_historical = pexConfig.Field( 

419 dtype=bool, 

420 default=False, 

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

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

423 "closest in time to the exposure." 

424 ) 

425 idGenerator = DetectorVisitIdGeneratorConfig.make_field() 

426 image_type = pexConfig.ChoiceField[str]( 

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

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

429 allowed={ 

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

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

432 }, 

433 optional=False, 

434 default="legacy", 

435 ) 

436 

437 def setDefaults(self): 

438 # Background subtraction 

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

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

441 # faint diaSources 

442 self.subtractInitialBackground.binSize = 8 

443 self.subtractInitialBackground.useApprox = False 

444 self.subtractInitialBackground.statisticsProperty = "MEDIAN" 

445 self.subtractInitialBackground.doFilterSuperPixels = True 

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

447 "EDGE", 

448 "DETECTED", 

449 "DETECTED_NEGATIVE", 

450 "NO_DATA", 

451 ] 

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

453 # over-subtraction of bright objects. 

454 self.subtractFinalBackground.binSize = 40 

455 self.subtractFinalBackground.useApprox = False 

456 self.subtractFinalBackground.statisticsProperty = "MEDIAN" 

457 self.subtractFinalBackground.doFilterSuperPixels = True 

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

459 "EDGE", 

460 "DETECTED", 

461 "DETECTED_NEGATIVE", 

462 "NO_DATA", 

463 ] 

464 # Cosmic ray detection 

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

466 # DiaSource Detection 

467 self.detection.thresholdPolarity = "both" 

468 self.detection.thresholdValue = 5.0 

469 self.detection.reEstimateBackground = False 

470 self.detection.thresholdType = "pixel_stdev" 

471 self.detection.excludeMaskPlanes = [] 

472 

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

474 self.streakDetection.thresholdType = self.detection.thresholdType 

475 self.streakDetection.reEstimateBackground = False 

476 self.streakDetection.excludeMaskPlanes = self.detection.excludeMaskPlanes 

477 self.streakDetection.thresholdValue = self.detection.thresholdValue 

478 # Only detect positive streaks 

479 self.streakDetection.thresholdPolarity = "positive" 

480 # Do not grow detected mask for streaks 

481 self.streakDetection.nSigmaToGrow = 0 

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

483 # detected mask is set. 

484 self.maskStreaks.onlyMaskDetected = False 

485 # Restrict streak masking from growing too large 

486 self.maskStreaks.maxStreakWidth = 100 

487 # Restrict the number of iterations allowed for fitting streaks 

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

489 self.maskStreaks.maxFitIter = 10 

490 # Only mask to 2 sigma in width 

491 self.maskStreaks.nSigmaMask = 2 

492 # Threshold for including streaks after the Hough Transform. 

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

494 self.maskStreaks.absMinimumKernelHeight = 2 

495 

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

497 "base_LocalPhotoCalib", 

498 "base_LocalWcs", 

499 "ext_shapeHSM_HsmSourceMoments", 

500 "ext_shapeHSM_HsmPsfMoments", 

501 "base_ClassificationSizeExtendedness", 

502 ] 

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

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

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

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

507 self.forcedMeasurement.copyColumns = { 

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

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

510 self.forcedMeasurement.slots.shape = None 

511 

512 # Keep track of which footprints contain streaks 

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

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

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

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

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

518 

519 def validate(self): 

520 super().validate() 

521 

522 if self.run_sattle: 

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

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

525 "Sattle requested but SATTLE_URI_BASE " 

526 "environment variable not set.") 

527 

528 

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

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

531 """ 

532 ConfigClass = DetectAndMeasureConfig 

533 _DefaultName = "detectAndMeasure" 

534 

535 def __init__(self, **kwargs): 

536 super().__init__(**kwargs) 

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

538 

539 self.algMetadata = dafBase.PropertyList() 

540 if self.config.doSubtractBackground: 

541 self.makeSubtask("subtractInitialBackground") 

542 self.makeSubtask("subtractFinalBackground") 

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

544 if self.config.doDeblend: 

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

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

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

548 algMetadata=self.algMetadata) 

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

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

551 if self.config.doForcedMeasurement: 

552 self.schema.addField( 

553 "ip_diffim_forced_PsfFlux_instFlux", "D", 

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

555 units="count") 

556 self.schema.addField( 

557 "ip_diffim_forced_PsfFlux_instFluxErr", "D", 

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

559 units="count") 

560 self.schema.addField( 

561 "ip_diffim_forced_PsfFlux_area", "F", 

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

563 units="pixel") 

564 self.schema.addField( 

565 "ip_diffim_forced_PsfFlux_flag", "Flag", 

566 "Forced PSF flux general failure flag.") 

567 self.schema.addField( 

568 "ip_diffim_forced_PsfFlux_flag_noGoodPixels", "Flag", 

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

570 self.schema.addField( 

571 "ip_diffim_forced_PsfFlux_flag_edge", "Flag", 

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

573 self.schema.addField( 

574 "ip_diffim_forced_template_PsfFlux_instFlux", "D", 

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

576 units="count") 

577 self.schema.addField( 

578 "ip_diffim_forced_template_PsfFlux_instFluxErr", "D", 

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

580 units="count") 

581 self.schema.addField( 

582 "ip_diffim_forced_template_PsfFlux_area", "F", 

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

584 units="pixel") 

585 self.schema.addField( 

586 "ip_diffim_forced_template_PsfFlux_flag", "Flag", 

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

588 self.schema.addField( 

589 "ip_diffim_forced_template_PsfFlux_flag_noGoodPixels", "Flag", 

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

591 self.schema.addField( 

592 "ip_diffim_forced_template_PsfFlux_flag_edge", "Flag", 

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

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

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

596 

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

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

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

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

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

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

603 self.makeSubtask("maskStreaks") 

604 self.makeSubtask("streakDetection") 

605 self.makeSubtask("findGlints") 

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

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

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

609 self.schema.addField("templateMjdMean", type="D", units="d", 

610 doc="Weighted mean time (MJD, TAI) of the template inputs that" 

611 " cover the diaSource centroid.") 

612 self.schema.addField("templateMjdMin", type="D", units="d", 

613 doc="Earliest time (MJD, TAI) of the template inputs that" 

614 " cover the diaSource centroid.") 

615 self.schema.addField("templateMjdMax", type="D", units="d", 

616 doc="Latest time (MJD, TAI) of the template inputs that" 

617 " cover the diaSource centroid.") 

618 

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

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

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

622 

623 # Check that the schema and config are consistent 

624 for flag in self.config.badSourceFlags: 

625 if flag not in self.schema: 

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

627 

628 # initialize InitOutputs 

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

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

631 

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

633 inputRefs: pipeBase.InputQuantizedConnection, 

634 outputRefs: pipeBase.OutputQuantizedConnection): 

635 inputs = butlerQC.get(inputRefs) 

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

637 idFactory = idGenerator.make_table_id_factory() 

638 

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

640 inputImages = {} 

641 templateInfo = None 

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

643 dataId = butlerQC.quantum.dataId 

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

645 ("scoreMeasuredExposure", "scoreExposure")): 

646 if connection not in inputs: 

647 continue 

648 image = inputs[connection] 

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

650 # already carry the kernel and the template records. 

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

652 imageTemplates = get_difference_image_provenance(image, "templates", connection, dataId) 

653 if connection == "difference": 

654 templateInfo = imageTemplates 

655 inputImages[output] = image 

656 inputs[connection] = image.to_legacy() 

657 

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

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

660 measurementResults = pipeBase.Struct( 

661 subtractedMeasuredExposure=None, 

662 scoreMeasuredExposure=None, 

663 diaSources=None, 

664 maskedStreaks=None, 

665 differenceBackground=None, 

666 templateEpochs=None, 

667 ) 

668 try: 

669 self.run(**inputs, idFactory=idFactory, measurementResults=measurementResults, 

670 templateInfo=templateInfo) 

671 except pipeBase.AlgorithmError as e: 

672 # Annotate before converting 

673 error = pipeBase.AnnotatedPartialOutputsError.annotate( 

674 e, 

675 self, 

676 measurementResults.subtractedMeasuredExposure, 

677 measurementResults.scoreMeasuredExposure, 

678 measurementResults.diaSources, 

679 measurementResults.maskedStreaks, 

680 log=self.log 

681 ) 

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

683 self.convert_outputs_to_future(measurementResults, inputImages) 

684 butlerQC.put(measurementResults, outputRefs) 

685 raise error from e 

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

687 self.convert_outputs_to_future(measurementResults, inputImages) 

688 butlerQC.put(measurementResults, outputRefs) 

689 

690 def convert_outputs_to_future(self, results, inputImages): 

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

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

693 

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

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

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

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

698 absent or `None` are skipped. 

699 

700 Parameters 

701 ---------- 

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

703 Output struct to read and modify in place. 

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

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

706 the output. Each is modified in place. 

707 

708 Notes 

709 ----- 

710 This uses the default legacy mask plane map of 

711 `lsst.images.get_legacy_difference_image_mask_planes` and adds any 

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

713 for any pixels in the image. 

714 """ 

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

716 templateEpochs = getattr(results, "templateEpochs", None) or {} 

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

718 exposure = getattr(results, name, None) 

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

720 continue 

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

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

723 image.image.array[...] = exposure.image.array 

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

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

726 sky_projection=image.sky_projection) 

727 image.metadata.update(templateEpochs) 

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

729 # The same background is subtracted from the difference image 

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

731 image.backgrounds.add( 

732 "subtracted", 

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

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

735 is_subtracted=True, 

736 ) 

737 setattr(results, name, image) 

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

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

740 # that no background was subtracted. 

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

742 

743 @timeMethod 

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

745 idFactory=None, measurementResults=None, templateInfo=None): 

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

747 

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

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

750 Close positive and negative detections will optionally be merged into 

751 dipole diaSources. 

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

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

754 detections. 

755 

756 Parameters 

757 ---------- 

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

759 Science exposure that the template was subtracted from. 

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

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

762 difference image. 

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

764 Result of subtracting template from the science image. 

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

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

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

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

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

770 deblending and merging positive/negative peaks. 

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

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

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

774 this is also returned. 

775 templateInfo : `list` [`lsst.images.DifferenceImageTemplateInfo`], \ 

776 optional 

777 Information about the coadds and their input images that the 

778 template was built from. Without it the template epochs of every 

779 diaSource are NaN. 

780 

781 Returns 

782 ------- 

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

784 

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

786 Subtracted exposure with detection mask applied. 

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

788 The catalog of detected sources. 

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

790 Background that was subtracted from the difference image. 

791 ``templateEpochs`` : `dict` [`str`, `float`] 

792 Summary of the template epochs of the diaSources, keyed by 

793 image metadata key; empty if they are not known. 

794 """ 

795 if measurementResults is None: 

796 measurementResults = pipeBase.Struct() 

797 if idFactory is None: 

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

799 

800 # Check image properties and clear detection mask planes. 

801 self._prepareInputs(difference) 

802 if self.config.doSubtractBackground: 

803 background = self._fitAndSubtractBackground(differenceExposure=difference) 

804 else: 

805 background = afwMath.BackgroundList() 

806 

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

808 self.findAndMaskCosmicRays(difference) 

809 

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

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

812 # know about past ids). 

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

814 results = self.detection.run( 

815 table=table, 

816 exposure=difference, 

817 doSmooth=True, 

818 background=background, 

819 clearMask=True, 

820 ) 

821 measurementResults.differenceBackground = background 

822 

823 if self.config.doDeblend: 

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

825 results.positive, 

826 results.negative) 

827 

828 else: 

829 positives = afwTable.SourceCatalog(self.schema) 

830 results.positive.makeSources(positives) 

831 negatives = afwTable.SourceCatalog(self.schema) 

832 results.negative.makeSources(negatives) 

833 sources = results.sources 

834 

835 self.processResults(science, matchedTemplate, difference, 

836 sources, idFactory, kernelSources, 

837 positives=positives, 

838 negatives=negatives, 

839 measurementResults=measurementResults, 

840 templateInfo=templateInfo) 

841 return measurementResults 

842 

843 def _prepareInputs(self, difference): 

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

845 

846 Parameters 

847 ---------- 

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

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

850 The mask plane will be modified in place. 

851 

852 Raises 

853 ------ 

854 lsst.pipe.base.UpstreamFailureNoWorkFound 

855 If the PSF is not usable for measurement. 

856 """ 

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

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

859 if np.isnan(sigma): 

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

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

862 mask = difference.mask 

863 for mp in self.config.clearMaskPlanes: 

864 if mp not in mask.getMaskPlaneDict(): 

865 mask.addMaskPlane(mp) 

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

867 

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

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

870 

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

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

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

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

875 scheme is what suppresses spurious detections from glints and 

876 extended structures: the small-binsize initial fit aggressively 

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

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

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

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

881 detection runs on a properly background-subtracted score. 

882 

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

884 leaves on the clone is used downstream. 

885 

886 Parameters 

887 ---------- 

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

889 Difference image. The final background is subtracted from its 

890 image in place but its mask is not modified. 

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

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

893 background is also subtracted from its image. 

894 

895 Returns 

896 ------- 

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

898 The fit final background. 

899 """ 

900 if scoreExposure is None: 

901 detectionExposure = differenceExposure.clone() 

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

903 doSmooth = True 

904 else: 

905 # Use a clone of differenceExposure because the background is 

906 # subtracted in place. 

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

908 detectionExposure = scoreExposure.clone() 

909 detectionExposure.image -= background.getImage() 

910 doSmooth = False 

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

912 self.detection.run( 

913 table=table, 

914 exposure=detectionExposure, 

915 doSmooth=doSmooth, 

916 background=background, 

917 clearMask=True, 

918 ) 

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

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

921 # clears the detection planes of the difference image. 

922 mask = differenceExposure.mask 

923 originalMask = mask.array.copy() 

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

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

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

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

928 mask.array[detectedPix] |= detectedBit 

929 mask.array[detectedNegativePix] |= detectedNegativeBit 

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

931 mask.array[...] = originalMask 

932 if scoreExposure is not None: 

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

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

935 scoreExposure.image -= background.getImage() 

936 return background 

937 

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

939 kernelSources=None, positives=None, negatives=None, measurementResults=None, 

940 templateInfo=None): 

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

942 

943 Parameters 

944 ---------- 

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

946 Science exposure that the template was subtracted from. 

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

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

949 difference image. 

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

951 Result of subtracting template from the science image. 

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

953 Detected sources on the difference exposure. 

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

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

956 difference image. 

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

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

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

960 Positive polarity footprints. 

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

962 Negative polarity footprints. 

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

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

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

966 this is also returned. 

967 templateInfo : `list` [`lsst.images.DifferenceImageTemplateInfo`], \ 

968 optional 

969 Information about the coadds and their input images that the 

970 template was built from. 

971 

972 Returns 

973 ------- 

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

975 

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

977 Subtracted exposure with detection mask applied. 

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

979 The catalog of detected sources. 

980 ``templateEpochs`` : `dict` [`str`, `float`] 

981 Summary of the template epochs of the diaSources, keyed by 

982 image metadata key; empty if they are not known. 

983 """ 

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

985 measurementResults = pipeBase.Struct() 

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

987 if self.config.doMerge: 

988 # preserve peak schema, if there are any footprints 

989 if len(positives) > 0: 

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

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

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

993 else: 

994 peakSchema = afwDetection.PeakTable.makeMinimalSchema() 

995 mergeList = afwDetection.FootprintMergeList(self.schema, 

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

997 initialDiaSources = afwTable.SourceCatalog(self.schema) 

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

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

1000 # deblended positive sources. 

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

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

1003 mergeList.getFinalSources(initialDiaSources) 

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

1005 # footprint set. 

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

1007 ~initialDiaSources["merge_footprint_positive"] 

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

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

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

1011 self._reorderPeaksBySignificance(initialDiaSources) 

1012 else: 

1013 initialDiaSources = sources 

1014 

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

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

1017 for source in initialDiaSources: 

1018 source.setId(idFactory()) 

1019 # Ensure sources added after this get correct ids. 

1020 initialDiaSources.getTable().setIdFactory(idFactory) 

1021 initialDiaSources.setMetadata(self.algMetadata) 

1022 

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

1024 

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

1026 streakInfo = self._runStreakMasking(difference) 

1027 

1028 if self.config.doSkySources: 

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

1030 

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

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

1033 # measurement. 

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

1035 

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

1037 initialDiaSources = initialDiaSources.copy(deep=True) 

1038 

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

1040 

1041 # Remove unphysical diaSources per config.badSourceFlags 

1042 diaSources = self._removeBadSources(initialDiaSources) 

1043 

1044 if self.config.run_sattle: 

1045 diaSources = self.filterSatellites(diaSources, science) 

1046 

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

1048 diaSources, trail_parameters = self._find_glint_trails(diaSources) 

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

1050 measurementResults.mergeItems(trail_parameters, 'glintTrailInfo') 

1051 

1052 self._measureTemplateEpochs(diaSources, templateInfo) 

1053 measurementResults.templateEpochs = self._summarizeTemplateEpochs(diaSources) 

1054 

1055 if self.config.doForcedMeasurement: 

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

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

1058 template=True) 

1059 

1060 # Clear the image plane for regions with NO_DATA. 

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

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

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

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

1065 

1066 measurementResults.subtractedMeasuredExposure = difference 

1067 

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

1069 measurementResults.maskedStreaks = streakInfo.maskedStreaks 

1070 

1071 if kernelSources is not None: 

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

1073 

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

1075 measurementResults.diaSources = diaSources 

1076 elif self.config.raiseOnNoDiaSources: 

1077 raise NoDiaSourcesError() 

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

1079 # This option allows returning sky sources, 

1080 # even if there are no diaSources 

1081 measurementResults.diaSources = diaSources 

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

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

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

1085 ) 

1086 return measurementResults 

1087 

1088 def _reorderPeaksBySignificance(self, diaSources): 

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

1090 

1091 Parameters 

1092 ---------- 

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

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

1095 """ 

1096 for source in diaSources: 

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

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

1099 continue 

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

1101 newPeaks = afwDetection.PeakCatalog(peaks.table) 

1102 newPeaks.extend(ordered) 

1103 peaks.clear() 

1104 source.getFootprint().setPeakCatalog(newPeaks) 

1105 

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

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

1108 containing just the children, and the deblended footprints. 

1109 

1110 Parameters 

1111 ---------- 

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

1113 Result of subtracting template from the science image. 

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

1115 Positive and negative polarity footprints measured on 

1116 ``difference`` to be deblended separately. 

1117 

1118 Returns 

1119 ------- 

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

1121 Positive and negative deblended children. 

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

1123 Deblended positive and negative polarity sources with footprints 

1124 detected on ``difference``. 

1125 """ 

1126 

1127 def deblend(footprints, negative=False): 

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

1129 and return the deblended children. 

1130 

1131 Parameters 

1132 ---------- 

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

1134 negative : `bool` 

1135 Set True if the footprints contain negative fluxes 

1136 

1137 Returns 

1138 ------- 

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

1140 """ 

1141 sources = afwTable.SourceCatalog(self.schema) 

1142 footprints.makeSources(sources) 

1143 if negative: 

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

1145 difference_inverted = difference.clone() 

1146 difference_inverted.image *= -1 

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

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

1149 # Set the heavy footprint pixel values back to reality 

1150 for child in children: 

1151 footprint = child.getFootprint() 

1152 array = footprint.getImageArray() 

1153 array *= -1 

1154 else: 

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

1156 self.setPrimaryFlags.run(sources) 

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

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

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

1160 sources['parent'] = 0 

1161 return sources.copy(deep=True) 

1162 

1163 positives = deblend(positiveFootprints) 

1164 negatives = deblend(negativeFootprints, negative=True) 

1165 

1166 sources = afwTable.SourceCatalog(self.schema) 

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

1168 sources.extend(positives, deep=True) 

1169 sources.extend(negatives, deep=True) 

1170 if len(negatives) > 0: 

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

1172 return sources, positives, negatives 

1173 

1174 def _rejectBadMaskedDetections(self, initialDiaSources, mask): 

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

1176 

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

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

1179 

1180 Parameters 

1181 ---------- 

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

1183 The catalog of detected peaks. 

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

1185 Mask of the image used for detection. 

1186 

1187 Returns 

1188 ------- 

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

1190 The catalog with bad-masked detections removed. 

1191 """ 

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

1193 self.metadata["nRejectedBadMaskPlanes"] = 0 

1194 return initialDiaSources 

1195 

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

1197 if mp in mask.getMaskPlaneDict()] 

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

1199 self.metadata["nRejectedBadMaskPlanes"] = 0 

1200 return initialDiaSources 

1201 

1202 badBitMask = mask.getPlaneBitMask(presentPlanes) 

1203 x0, y0 = mask.getXY0() 

1204 

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

1206 for i, source in enumerate(initialDiaSources): 

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

1208 for peak in peaks: 

1209 ix = peak.getIx() - x0 

1210 iy = peak.getIy() - y0 

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

1212 selector[i] = False 

1213 break 

1214 nRejected = np.count_nonzero(~selector) 

1215 self.metadata["nRejectedBadMaskPlanes"] = nRejected 

1216 if nRejected > 0: 

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

1218 "planes %s before measurement.", 

1219 nRejected, presentPlanes) 

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

1221 

1222 def _removeBadSources(self, diaSources): 

1223 """Remove unphysical diaSources from the catalog. 

1224 

1225 Parameters 

1226 ---------- 

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

1228 The catalog of detected sources. 

1229 

1230 Returns 

1231 ------- 

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

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

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

1235 """ 

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

1237 for flag in self.config.badSourceFlags: 

1238 flags = diaSources[flag] 

1239 nBad = np.count_nonzero(flags) 

1240 if nBad > 0: 

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

1242 selector &= ~flags 

1243 nBadTotal = np.count_nonzero(~selector) 

1244 self.metadata["nRemovedBadFlaggedSources"] = nBadTotal 

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

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

1247 

1248 def _find_glint_trails(self, diaSources): 

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

1250 

1251 Parameters 

1252 ---------- 

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

1254 The catalog of detected sources. 

1255 

1256 Returns 

1257 ------- 

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

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

1260 called 'glint_trail' added. 

1261 

1262 trail_parameters : `dict` 

1263 Parameters of all the trails that were found. 

1264 """ 

1265 if self.config.doSkySources: 

1266 # Do not include sky sources in glint detection 

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

1268 else: 

1269 candidateDiaSources = diaSources 

1270 trailed_glints = self.findGlints.run(candidateDiaSources) 

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

1272 if np.any(glint_mask): 

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

1274 

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

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

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

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

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

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

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

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

1283 'angles': angles, 'center_xs': center_xs, 'center_ys': center_ys} 

1284 

1285 trail_parameters = pipeBase.Struct(glintTrailInfo=parameters) 

1286 

1287 return diaSources, trail_parameters 

1288 

1289 def _measureTemplateEpochs(self, diaSources, templateInfo): 

1290 """Determine the observed epochs of the template for each diaSource. 

1291 

1292 Parameters 

1293 ---------- 

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

1295 Contiguous catalog to fill in place. 

1296 templateInfo : `list` [`lsst.images.DifferenceImageTemplateInfo`] or \ 

1297 `None` 

1298 Information about the coadds and their input images that the 

1299 template was built from. 

1300 

1301 Notes 

1302 ----- 

1303 An input covers a diaSource if both its coadd's bounds and its own 

1304 bounds contain the centroid. Each coadd's inputs give a mean weighted 

1305 by their coadd weights, and where coadds overlap their means are 

1306 averaged with weights ``sqrt(W)``, ``W`` being the total weight of a 

1307 coadd's inputs there. `GetTemplateTask` merges the patches of a tract 

1308 with weights ``variance**-0.5``, and the variance of a coadd is about 

1309 ``1/W``. 

1310 """ 

1311 x = diaSources.getX() 

1312 y = diaSources.getY() 

1313 numerator = np.zeros(len(diaSources)) 

1314 denominator = np.zeros(len(diaSources)) 

1315 epochMin = np.full(len(diaSources), np.inf) 

1316 epochMax = np.full(len(diaSources), -np.inf) 

1317 for info in templateInfo or []: 

1318 inCoadd = info.bounds.contains(x=x, y=y) 

1319 if not inCoadd.any(): 

1320 continue 

1321 weightSum = np.zeros(len(diaSources)) 

1322 weightedSum = np.zeros(len(diaSources)) 

1323 for record in info.inputs: 

1324 covers = inCoadd & record.bounds.contains(x=x, y=y) 

1325 weightSum[covers] += record.weight 

1326 weightedSum[covers] += record.weight*record.mid_mjd_tai 

1327 epochMin[covers] = np.minimum(epochMin[covers], record.mid_mjd_tai) 

1328 epochMax[covers] = np.maximum(epochMax[covers], record.mid_mjd_tai) 

1329 inputs = weightSum > 0 

1330 numerator[inputs] += weightedSum[inputs]/np.sqrt(weightSum[inputs]) 

1331 denominator[inputs] += np.sqrt(weightSum[inputs]) 

1332 known = denominator > 0 

1333 epoch = np.full(len(diaSources), np.nan) 

1334 epoch[known] = numerator[known]/denominator[known] 

1335 epochMin[~known] = np.nan 

1336 epochMax[~known] = np.nan 

1337 diaSources["templateMjdMean"] = epoch 

1338 diaSources["templateMjdMin"] = epochMin 

1339 diaSources["templateMjdMax"] = epochMax 

1340 

1341 def _summarizeTemplateEpochs(self, diaSources): 

1342 """Summarize the template epochs of a catalog in the task metadata. 

1343 

1344 The earliest, latest and mean values over the rows with a template 

1345 epoch, sky sources included, are set in the task metadata under the 

1346 names of the diaSource fields they summarize. They are NaN if no row 

1347 has a template epoch. 

1348 

1349 Parameters 

1350 ---------- 

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

1352 Contiguous catalog with the template epoch fields set. 

1353 

1354 Returns 

1355 ------- 

1356 templateEpochs : `dict` [`str`, `float`] 

1357 The same values keyed by their image metadata key in 

1358 ``_TEMPLATE_EPOCH_NAMES``, or empty if no row has a template epoch. 

1359 """ 

1360 known = np.isfinite(diaSources["templateMjdMean"]) 

1361 if not known.any(): 

1362 for name in _TEMPLATE_EPOCH_NAMES.values(): 

1363 self.metadata[name] = np.nan 

1364 return {} 

1365 values = {"templateMjdMin": float(np.min(diaSources["templateMjdMin"][known])), 

1366 "templateMjdMax": float(np.max(diaSources["templateMjdMax"][known])), 

1367 "templateMjdMean": float(np.mean(diaSources["templateMjdMean"][known]))} 

1368 for name, value in values.items(): 

1369 self.metadata[name] = value 

1370 return {key: values[name] for key, name in _TEMPLATE_EPOCH_NAMES.items()} 

1371 

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

1373 subtask=None): 

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

1375 for measuring the background. 

1376 

1377 Parameters 

1378 ---------- 

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

1380 The catalog of detected sources. 

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

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

1383 seed : `int` 

1384 Seed value to initialize the random number generator. 

1385 """ 

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

1387 subtask = self.skySources 

1388 if subtask.config.nSources <= 0: 

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

1390 return 

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

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

1393 

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

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

1396 

1397 Parameters 

1398 ---------- 

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

1400 The catalog of detected sources. 

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

1402 Science exposure that the template was subtracted from. 

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

1404 Result of subtracting template from the science image. 

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

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

1407 difference image. 

1408 """ 

1409 # Ensure that the required mask planes are present 

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

1411 difference.mask.addMaskPlane(mp) 

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

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

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

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

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

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

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

1419 else: 

1420 self.applyApCorr.run( 

1421 catalog=diaSources, 

1422 apCorrMap=apCorrMap, 

1423 ) 

1424 

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

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

1427 

1428 Parameters 

1429 ---------- 

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

1431 The catalog of detected sources. 

1432 image: `lsst.afw.image.ExposureF` 

1433 Exposure that the forced measurement is being performed on 

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

1435 Coordinate system definition (wcs) for the exposure. 

1436 template : `bool` 

1437 Is the forced measurement being performed on the template? 

1438 """ 

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

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

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

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

1443 

1444 if template: 

1445 base_key = 'ip_diffim_forced_template_PsfFlux' 

1446 else: 

1447 base_key = 'ip_diffim_forced_PsfFlux' 

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

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

1450 f"{base_key}_instFlux", True) 

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

1452 f"{base_key}_instFluxErr", True) 

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

1454 f"{base_key}_area", True) 

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

1456 f"{base_key}_flag", True) 

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

1458 f"{base_key}_flag_noGoodPixels", True) 

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

1460 f"{base_key}_flag_edge", True) 

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

1462 diaSource.assign(forcedSource, mapper) 

1463 

1464 def findAndMaskCosmicRays(self, difference): 

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

1466 

1467 Parameters 

1468 ---------- 

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

1470 The background-subtracted difference image. 

1471 The mask plane will be modified in place. 

1472 """ 

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

1474 # remaining background should be ~0. 

1475 medianBg = 0. 

1476 try: 

1477 crs = findCosmicRays( 

1478 difference.maskedImage, 

1479 difference.psf, 

1480 medianBg, 

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

1482 self.config.cosmicray.keepCRs, 

1483 ) 

1484 except LengthError: 

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

1486 num = 0 

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

1488 mask = difference.maskedImage.mask 

1489 crBit = mask.getPlaneBitMask("CR") 

1490 afwDetection.setMaskFromFootprintList(mask, crs, crBit) 

1491 num = len(crs) 

1492 

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

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

1495 self.metadata["cosmic_ray_count"] = num 

1496 

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

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

1499 

1500 This may be used to produce corresponding metrics (see 

1501 lsst.analysis.tools.tasks.diffimTaskDetectorVisitMetricAnalysis). 

1502 

1503 Parameters 

1504 ---------- 

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

1506 Science exposure that was subtracted. 

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

1508 The target difference image to calculate metrics for. 

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

1510 The catalog of detected sources. 

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

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

1513 """ 

1514 mask = difference.mask 

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

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

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

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

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

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

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

1522 detPosPix &= badPix 

1523 detNegPix &= badPix 

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

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

1526 

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

1528 for maskPlane in metricsMaskPlanes: 

1529 try: 

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

1531 except InvalidParameterError: 

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

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

1534 

1535 if self.config.doSkySources: 

1536 skySources = diaSources[diaSources["sky_source"]] 

1537 else: 

1538 skySources = None 

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

1540 

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

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

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

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

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

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

1547 self.metadata["residualFootprintRatioMean"], 

1548 self.metadata["residualFootprintRatioStdev"]) 

1549 if self.config.raiseOnBadSubtractionRatio: 

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

1551 raise BadSubtractionError(ratio=metrics.differenceFootprintRatioMean, 

1552 threshold=self.config.badSubtractionRatioThreshold) 

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

1554 raise BadSubtractionError(ratio=metrics.differenceFootprintRatioStdev, 

1555 threshold=self.config.badSubtractionVariationThreshold) 

1556 

1557 def getSattleDiaSourceAllowlist(self, diaSources, science): 

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

1559 

1560 Parameters 

1561 ---------- 

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

1563 The catalog of detected sources. 

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

1565 Science exposure that was subtracted. 

1566 

1567 Returns 

1568 ---------- 

1569 allow_list : `list` of `int` 

1570 diaSourceIds of diaSources that can be made public. 

1571 

1572 Raises 

1573 ------ 

1574 requests.HTTPError 

1575 Raised if sattle call does not return success. 

1576 """ 

1577 wcs = science.getWcs() 

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

1579 visit_id = visit_info.getId() 

1580 sattle_uri_base = os.getenv('SATTLE_URI_BASE') 

1581 

1582 dia_sources_json = [] 

1583 for source in diaSources: 

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

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

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

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

1588 

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

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

1591 

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

1593 json=payload) 

1594 

1595 # retry once if visit cache is not populated 

1596 if sattle_output.status_code == 404: 

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

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

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

1600 

1601 sattle_output.raise_for_status() 

1602 

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

1604 

1605 @timeMethod 

1606 def filterSatellites(self, diaSources, science): 

1607 """Remove diaSources overlapping predicted satellite positions. 

1608 

1609 Parameters 

1610 ---------- 

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

1612 The catalog of detected sources. 

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

1614 Science exposure that was subtracted. 

1615 

1616 Returns 

1617 ---------- 

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

1619 Filtered catalog of diaSources 

1620 """ 

1621 

1622 allow_list = self.getSattleDiaSourceAllowlist(diaSources, science) 

1623 

1624 if allow_list: 

1625 allow_set = set(allow_list) 

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

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

1628 else: 

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

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

1631 return diaSources 

1632 

1633 def _runStreakMasking(self, difference): 

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

1635 fit parameters in a catalog. 

1636 

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

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

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

1640 surface brightness streaks are more likely to fall above the 

1641 detection threshold. 

1642 

1643 Parameters 

1644 ---------- 

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

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

1647 mask. 

1648 

1649 Returns 

1650 ------- 

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

1652 ``rho`` : `np.ndarray` 

1653 Angle of detected streak. 

1654 ``theta`` : `np.ndarray` 

1655 Distance from center of detected streak. 

1656 ``sigma`` : `np.ndarray` 

1657 Width of streak profile. 

1658 ``reducedChi2`` : `np.ndarray` 

1659 Reduced chi2 of the best-fit streak profile. 

1660 ``modelMaximum`` : `np.ndarray` 

1661 Peak value of the fit line profile. 

1662 """ 

1663 maskedImage = difference.maskedImage 

1664 # Bin the diffim to enhance low surface brightness streaks 

1665 binnedMaskedImage = afwMath.binImage(maskedImage, 

1666 self.config.streakBinFactor, 

1667 self.config.streakBinFactor) 

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

1669 binnedExposure.setMaskedImage(binnedMaskedImage) 

1670 # Clear the DETECTED mask plane before streak detection 

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

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

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

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

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

1676 sigma=sigma/self.config.streakBinFactor) 

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

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

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

1680 axis=1) 

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

1682 streakMaskedImage = maskedImage.clone() 

1683 ysize, xsize = rescaledDetectedMaskPlane.shape 

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

1685 # Detect streaks on this new version of the diffim 

1686 streaks = self.maskStreaks.run(streakMaskedImage) 

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

1688 # Apply the new STREAK mask to the original diffim 

1689 maskedImage.mask.array |= streakMaskPlane 

1690 

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

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

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

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

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

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

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

1698 'modelMaximum': modelMaximums} 

1699 else: 

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

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

1702 return pipeBase.Struct(maskedStreaks=streakInfo) 

1703 

1704 

1705class DetectAndMeasureScoreConnections(DetectAndMeasureConnections): 

1706 scoreExposure = pipeBase.connectionTypes.Input( 

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

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

1709 storageClass="ExposureF", 

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

1711 ) 

1712 scoreMeasuredExposure = pipeBase.connectionTypes.Output( 

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

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

1715 storageClass="ExposureF", 

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

1717 ) 

1718 

1719 def __init__(self, *, config): 

1720 super().__init__(config=config) 

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

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

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

1724 storageClass="DifferenceImage") 

1725 

1726 

1727class DetectAndMeasureScoreConfig(DetectAndMeasureConfig, 

1728 pipelineConnections=DetectAndMeasureScoreConnections): 

1729 pass 

1730 

1731 

1732class DetectAndMeasureScoreTask(DetectAndMeasureTask): 

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

1734 and measure the detections on the difference image. 

1735 

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

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

1738 Close positive and negative detections will optionally be merged into 

1739 dipole diaSources. 

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

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

1742 detections. 

1743 """ 

1744 ConfigClass = DetectAndMeasureScoreConfig 

1745 _DefaultName = "detectAndMeasureScore" 

1746 

1747 @timeMethod 

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

1749 idFactory=None, measurementResults=None, templateInfo=None): 

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

1751 

1752 Parameters 

1753 ---------- 

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

1755 Science exposure that the template was subtracted from. 

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

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

1758 difference image. 

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

1760 Result of subtracting template from the science image. 

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

1762 Score or maximum likelihood difference image 

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

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

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

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

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

1768 deblending and merging positive/negative peaks. 

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

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

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

1772 this is also returned. 

1773 templateInfo : `list` [`lsst.images.DifferenceImageTemplateInfo`], \ 

1774 optional 

1775 Information about the coadds and their input images that the 

1776 template was built from. Without it the template epochs of every 

1777 diaSource are NaN. 

1778 

1779 Returns 

1780 ------- 

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

1782 

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

1784 Subtracted exposure with detection mask applied. 

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

1786 Score exposure with detection mask applied. 

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

1788 The catalog of detected sources. 

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

1790 Background that was subtracted from the difference image. 

1791 ``templateEpochs`` : `dict` [`str`, `float`] 

1792 Summary of the template epochs of the diaSources, keyed by 

1793 image metadata key; empty if they are not known. 

1794 """ 

1795 if measurementResults is None: 

1796 measurementResults = pipeBase.Struct() 

1797 if idFactory is None: 

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

1799 

1800 # Check image properties and clear detection mask planes. 

1801 self._prepareInputs(difference) 

1802 self._prepareInputs(scoreExposure) 

1803 if self.config.doSubtractBackground: 

1804 background = self._fitAndSubtractBackground( 

1805 differenceExposure=difference, scoreExposure=scoreExposure, 

1806 ) 

1807 else: 

1808 background = afwMath.BackgroundList() 

1809 

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

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

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

1813 self.findAndMaskCosmicRays(difference) 

1814 scoreExposure.mask.array |= difference.mask.array 

1815 

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

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

1818 # know about past ids). 

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

1820 detectResults = self.detection.run( 

1821 table=table, 

1822 exposure=scoreExposure, 

1823 doSmooth=False, 

1824 background=background, 

1825 clearMask=True, 

1826 ) 

1827 measurementResults.differenceBackground = background 

1828 measurementResults.scoreMeasuredExposure = scoreExposure 

1829 

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

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

1832 # the size of the convolution kernel. 

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

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

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

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

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

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

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

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

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

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

1843 

1844 if self.config.doDeblend: 

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

1846 detectResults.positive, 

1847 detectResults.negative) 

1848 

1849 self.processResults(science, matchedTemplate, difference, 

1850 sources, idFactory, kernelSources, 

1851 positives=positives, 

1852 negatives=negatives, 

1853 measurementResults=measurementResults, 

1854 templateInfo=templateInfo) 

1855 

1856 else: 

1857 positives = afwTable.SourceCatalog(self.schema) 

1858 detectResults.positive.makeSources(positives) 

1859 negatives = afwTable.SourceCatalog(self.schema) 

1860 detectResults.negative.makeSources(negatives) 

1861 self.processResults(science, matchedTemplate, difference, 

1862 detectResults.sources, idFactory, kernelSources, 

1863 positives=positives, 

1864 negatives=negatives, 

1865 measurementResults=measurementResults, 

1866 templateInfo=templateInfo) 

1867 return measurementResults