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

573 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-07 08:35 +0000

1# This file is part of ip_diffim. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <https://www.gnu.org/licenses/>. 

21 

22import numpy as np 

23import requests 

24import os 

25 

26import lsst.afw.detection as afwDetection 

27import lsst.afw.image as afwImage 

28import lsst.afw.math as afwMath 

29import lsst.afw.table as afwTable 

30import lsst.daf.base as dafBase 

31import lsst.geom 

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

33 populate_sattle_visit_cache) 

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

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

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

37import lsst.meas.deblender 

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

39import lsst.meas.extensions.shapeHSM 

40import lsst.pex.config as pexConfig 

41from lsst.pex.exceptions import InvalidParameterError, LengthError 

42import lsst.pipe.base as pipeBase 

43import lsst.utils 

44from lsst.utils.timer import timeMethod 

45 

46from . import DipoleFitTask 

47 

48__all__ = ["DetectAndMeasureConfig", "DetectAndMeasureTask", 

49 "DetectAndMeasureScoreConfig", "DetectAndMeasureScoreTask"] 

50 

51 

52class BadSubtractionError(pipeBase.AlgorithmError): 

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

54 psf-matching kernel exceeds the configured maximum. 

55 """ 

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

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

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

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

60 f" threshold of {threshold}") 

61 super().__init__(msg) 

62 self.ratio = ratio 

63 self.threshold = threshold 

64 

65 @property 

66 def metadata(self): 

67 return {"ratio": self.ratio, 

68 "threshold": self.threshold 

69 } 

70 

71 

72class NoDiaSourcesError(pipeBase.AlgorithmError): 

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

74 """ 

75 def __init__(self): 

76 msg = ("No diaSources detected!") 

77 super().__init__(msg) 

78 

79 @property 

80 def metadata(self): 

81 return {} 

82 

83 

84class TooManyCosmicRays(pipeBase.AlgorithmError): 

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

86 

87 Parameters 

88 ---------- 

89 maxCosmicRays : `int` 

90 Maximum number of cosmic rays allowed. 

91 """ 

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

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

94 self.msg = msg 

95 self._metadata = kwargs 

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

97 self._metadata["maxCosmicRays"] = maxCosmicRays 

98 

99 def __str__(self): 

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

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

102 

103 @property 

104 def metadata(self): 

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

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

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

108 return self._metadata 

109 

110 

111class DetectAndMeasureConnections(pipeBase.PipelineTaskConnections, 

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

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

114 "warpTypeSuffix": "", 

115 "fakesType": ""}): 

116 science = pipeBase.connectionTypes.Input( 

117 doc="Input science exposure.", 

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

119 storageClass="ExposureF", 

120 name="{fakesType}calexp" 

121 ) 

122 matchedTemplate = pipeBase.connectionTypes.Input( 

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

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

125 storageClass="ExposureF", 

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

127 ) 

128 difference = pipeBase.connectionTypes.Input( 

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

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

131 storageClass="ExposureF", 

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

133 ) 

134 kernelSources = pipeBase.connectionTypes.Input( 

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

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

137 storageClass="SourceCatalog", 

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

139 ) 

140 outputSchema = pipeBase.connectionTypes.InitOutput( 

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

142 storageClass="SourceCatalog", 

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

144 ) 

145 diaSources = pipeBase.connectionTypes.Output( 

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

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

148 storageClass="SourceCatalog", 

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

150 ) 

151 subtractedMeasuredExposure = pipeBase.connectionTypes.Output( 

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

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

154 storageClass="ExposureF", 

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

156 ) 

157 differenceBackground = pipeBase.connectionTypes.Output( 

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

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

160 storageClass="Background", 

161 name="difference_background", 

162 ) 

163 maskedStreaks = pipeBase.connectionTypes.Output( 

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

165 storageClass="ArrowNumpyDict", 

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

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

168 ) 

169 glintTrailInfo = pipeBase.connectionTypes.Output( 

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

171 storageClass="ArrowNumpyDict", 

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

173 name="trailed_glints", 

174 ) 

175 

176 def __init__(self, *, config): 

177 super().__init__(config=config) 

178 if not (self.config.doCalculateResidualMetics): 

179 self.inputs.remove("kernelSources") 

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

181 self.outputs.remove("maskedStreaks") 

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

183 self.outputs.remove("differenceBackground") 

184 if not (self.config.writeGlintInfo): 

185 self.outputs.remove("glintTrailInfo") 

186 

187 

188class DetectAndMeasureConfig(pipeBase.PipelineTaskConfig, 

189 pipelineConnections=DetectAndMeasureConnections): 

190 """Config for DetectAndMeasureTask 

191 """ 

192 doMerge = pexConfig.Field( 

193 dtype=bool, 

194 default=True, 

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

196 "set by growFootprint" 

197 ) 

198 doForcedMeasurement = pexConfig.Field( 

199 dtype=bool, 

200 default=True, 

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

202 ) 

203 doAddMetrics = pexConfig.Field( 

204 dtype=bool, 

205 default=False, 

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

207 ) 

208 doSubtractBackground = pexConfig.Field( 

209 dtype=bool, 

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

211 default=True, 

212 ) 

213 doWriteBackground = pexConfig.Field( 

214 dtype=bool, 

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

216 default=False, 

217 ) 

218 doCalculateResidualMetics = pexConfig.Field( 

219 dtype=bool, 

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

221 "metadata?", 

222 default=True, 

223 ) 

224 subtractInitialBackground = pexConfig.ConfigurableField( 

225 target=lsst.meas.algorithms.SubtractBackgroundTask, 

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

227 ) 

228 subtractFinalBackground = pexConfig.ConfigurableField( 

229 target=lsst.meas.algorithms.SubtractBackgroundTask, 

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

231 ) 

232 doFindCosmicRays = pexConfig.Field( 

233 dtype=bool, 

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

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

236 default=True, 

237 ) 

238 cosmicray = pexConfig.ConfigField( 

239 dtype=FindCosmicRaysConfig, 

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

241 ) 

242 detection = pexConfig.ConfigurableField( 

243 target=SourceDetectionTask, 

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

245 ) 

246 streakDetection = pexConfig.ConfigurableField( 

247 target=SourceDetectionTask, 

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

249 ) 

250 doDeblend = pexConfig.Field( 

251 dtype=bool, 

252 default=False, 

253 doc="Deblend DIASources after detection?" 

254 ) 

255 deblend = pexConfig.ConfigurableField( 

256 target=lsst.meas.deblender.SourceDeblendTask, 

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

258 ) 

259 measurement = pexConfig.ConfigurableField( 

260 target=DipoleFitTask, 

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

262 ) 

263 doApCorr = lsst.pex.config.Field( 

264 dtype=bool, 

265 default=True, 

266 doc="Run subtask to apply aperture corrections" 

267 ) 

268 applyApCorr = lsst.pex.config.ConfigurableField( 

269 target=ApplyApCorrTask, 

270 doc="Task to apply aperture corrections" 

271 ) 

272 forcedMeasurement = pexConfig.ConfigurableField( 

273 target=ForcedMeasurementTask, 

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

275 ) 

276 growFootprint = pexConfig.Field( 

277 dtype=int, 

278 default=2, 

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

280 ) 

281 diaSourceMatchRadius = pexConfig.Field( 

282 dtype=float, 

283 default=0.5, 

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

285 ) 

286 doSkySources = pexConfig.Field( 

287 dtype=bool, 

288 default=False, 

289 doc="Generate sky sources?", 

290 ) 

291 skySources = pexConfig.ConfigurableField( 

292 target=SkyObjectsTask, 

293 doc="Generate sky sources", 

294 ) 

295 doMaskStreaks = pexConfig.Field( 

296 dtype=bool, 

297 default=True, 

298 doc="Turn on streak masking", 

299 ) 

300 maskStreaks = pexConfig.ConfigurableField( 

301 target=MaskStreaksTask, 

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

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

304 ) 

305 streakBinFactor = pexConfig.Field( 

306 dtype=int, 

307 default=4, 

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

309 "Only used if doMaskStreaks is True.", 

310 ) 

311 writeStreakInfo = pexConfig.Field( 

312 dtype=bool, 

313 default=False, 

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

315 "development work." 

316 ) 

317 findGlints = pexConfig.ConfigurableField( 

318 target=FindGlintTrailsTask, 

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

320 ) 

321 writeGlintInfo = pexConfig.Field( 

322 dtype=bool, 

323 default=True, 

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

325 ) 

326 setPrimaryFlags = pexConfig.ConfigurableField( 

327 target=SetPrimaryFlagsTask, 

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

329 ) 

330 badSourceFlags = lsst.pex.config.ListField( 

331 dtype=str, 

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

333 default=("base_PixelFlags_flag_offimage", 

334 "base_PixelFlags_flag_edge", 

335 "base_PixelFlags_flag_interpolatedCenterAll", 

336 "base_PixelFlags_flag_badCenter", 

337 "base_PixelFlags_flag_edgeCenter", 

338 "base_PixelFlags_flag_nodataCenter", 

339 "base_PixelFlags_flag_saturatedCenter", 

340 "base_PixelFlags_flag_saturated_templateCenter", 

341 "base_PixelFlags_flag_spikeCenter", 

342 ), 

343 ) 

344 clearMaskPlanes = lsst.pex.config.ListField( 

345 dtype=str, 

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

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

348 ) 

349 doRejectBadMaskPlaneDetections = pexConfig.Field( 

350 dtype=bool, 

351 default=True, 

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

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

354 "but filtering earlier can save time." 

355 ) 

356 badMaskPlanes = lsst.pex.config.ListField( 

357 dtype=str, 

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

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

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

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

362 ) 

363 raiseOnBadSubtractionRatio = pexConfig.Field( 

364 dtype=bool, 

365 default=True, 

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

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

368 " image exceeds ``badSubtractionRatioThreshold``", 

369 ) 

370 badSubtractionRatioThreshold = pexConfig.Field( 

371 dtype=float, 

372 default=0.2, 

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

374 " the same footprints on the science image." 

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

376 ) 

377 badSubtractionVariationThreshold = pexConfig.Field( 

378 dtype=float, 

379 default=0.4, 

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

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

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

383 ) 

384 raiseOnNoDiaSources = pexConfig.Field( 

385 dtype=bool, 

386 default=True, 

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

388 ) 

389 run_sattle = pexConfig.Field( 

390 dtype=bool, 

391 default=False, 

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

393 "to the sattle service." 

394 ) 

395 sattle_historical = pexConfig.Field( 

396 dtype=bool, 

397 default=False, 

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

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

400 "closest in time to the exposure." 

401 ) 

402 idGenerator = DetectorVisitIdGeneratorConfig.make_field() 

403 

404 def setDefaults(self): 

405 # Background subtraction 

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

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

408 # faint diaSources 

409 self.subtractInitialBackground.binSize = 8 

410 self.subtractInitialBackground.useApprox = False 

411 self.subtractInitialBackground.statisticsProperty = "MEDIAN" 

412 self.subtractInitialBackground.doFilterSuperPixels = True 

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

414 "EDGE", 

415 "DETECTED", 

416 "DETECTED_NEGATIVE", 

417 "NO_DATA", 

418 ] 

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

420 # over-subtraction of bright objects. 

421 self.subtractFinalBackground.binSize = 40 

422 self.subtractFinalBackground.useApprox = False 

423 self.subtractFinalBackground.statisticsProperty = "MEDIAN" 

424 self.subtractFinalBackground.doFilterSuperPixels = True 

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

426 "EDGE", 

427 "DETECTED", 

428 "DETECTED_NEGATIVE", 

429 "NO_DATA", 

430 ] 

431 # Cosmic ray detection 

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

433 # DiaSource Detection 

434 self.detection.thresholdPolarity = "both" 

435 self.detection.thresholdValue = 5.0 

436 self.detection.reEstimateBackground = False 

437 self.detection.thresholdType = "pixel_stdev" 

438 self.detection.excludeMaskPlanes = [] 

439 

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

441 self.streakDetection.thresholdType = self.detection.thresholdType 

442 self.streakDetection.reEstimateBackground = False 

443 self.streakDetection.excludeMaskPlanes = self.detection.excludeMaskPlanes 

444 self.streakDetection.thresholdValue = self.detection.thresholdValue 

445 # Only detect positive streaks 

446 self.streakDetection.thresholdPolarity = "positive" 

447 # Do not grow detected mask for streaks 

448 self.streakDetection.nSigmaToGrow = 0 

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

450 # detected mask is set. 

451 self.maskStreaks.onlyMaskDetected = False 

452 # Restrict streak masking from growing too large 

453 self.maskStreaks.maxStreakWidth = 100 

454 # Restrict the number of iterations allowed for fitting streaks 

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

456 self.maskStreaks.maxFitIter = 10 

457 # Only mask to 2 sigma in width 

458 self.maskStreaks.nSigmaMask = 2 

459 # Threshold for including streaks after the Hough Transform. 

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

461 self.maskStreaks.absMinimumKernelHeight = 2 

462 

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

464 "base_LocalPhotoCalib", 

465 "base_LocalWcs", 

466 "ext_shapeHSM_HsmSourceMoments", 

467 "ext_shapeHSM_HsmPsfMoments", 

468 "base_ClassificationSizeExtendedness", 

469 ] 

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

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

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

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

474 self.forcedMeasurement.copyColumns = { 

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

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

477 self.forcedMeasurement.slots.shape = None 

478 

479 # Keep track of which footprints contain streaks 

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

481 "STREAK", "INJECTED", "INJECTED_TEMPLATE", "HIGH_VARIANCE", "SATURATED_TEMPLATE", "SPIKE"] 

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

483 "STREAK", "INJECTED", "INJECTED_TEMPLATE", "HIGH_VARIANCE", "SATURATED_TEMPLATE", "SPIKE"] 

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

485 

486 def validate(self): 

487 super().validate() 

488 

489 if self.run_sattle: 

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

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

492 "Sattle requested but SATTLE_URI_BASE " 

493 "environment variable not set.") 

494 

495 

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

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

498 """ 

499 ConfigClass = DetectAndMeasureConfig 

500 _DefaultName = "detectAndMeasure" 

501 

502 def __init__(self, **kwargs): 

503 super().__init__(**kwargs) 

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

505 

506 self.algMetadata = dafBase.PropertyList() 

507 if self.config.doSubtractBackground: 

508 self.makeSubtask("subtractInitialBackground") 

509 self.makeSubtask("subtractFinalBackground") 

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

511 if self.config.doDeblend: 

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

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

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

515 algMetadata=self.algMetadata) 

516 if self.config.doApCorr: 

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

518 if self.config.doForcedMeasurement: 

519 self.schema.addField( 

520 "ip_diffim_forced_PsfFlux_instFlux", "D", 

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

522 units="count") 

523 self.schema.addField( 

524 "ip_diffim_forced_PsfFlux_instFluxErr", "D", 

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

526 units="count") 

527 self.schema.addField( 

528 "ip_diffim_forced_PsfFlux_area", "F", 

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

530 units="pixel") 

531 self.schema.addField( 

532 "ip_diffim_forced_PsfFlux_flag", "Flag", 

533 "Forced PSF flux general failure flag.") 

534 self.schema.addField( 

535 "ip_diffim_forced_PsfFlux_flag_noGoodPixels", "Flag", 

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

537 self.schema.addField( 

538 "ip_diffim_forced_PsfFlux_flag_edge", "Flag", 

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

540 self.schema.addField( 

541 "ip_diffim_forced_template_PsfFlux_instFlux", "D", 

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

543 units="count") 

544 self.schema.addField( 

545 "ip_diffim_forced_template_PsfFlux_instFluxErr", "D", 

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

547 units="count") 

548 self.schema.addField( 

549 "ip_diffim_forced_template_PsfFlux_area", "F", 

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

551 units="pixel") 

552 self.schema.addField( 

553 "ip_diffim_forced_template_PsfFlux_flag", "Flag", 

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

555 self.schema.addField( 

556 "ip_diffim_forced_template_PsfFlux_flag_noGoodPixels", "Flag", 

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

558 self.schema.addField( 

559 "ip_diffim_forced_template_PsfFlux_flag_edge", "Flag", 

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

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

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

563 

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

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

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

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

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

569 if self.config.doMaskStreaks: 

570 self.makeSubtask("maskStreaks") 

571 self.makeSubtask("streakDetection") 

572 self.makeSubtask("findGlints") 

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

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

575 

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

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

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

579 

580 # Check that the schema and config are consistent 

581 for flag in self.config.badSourceFlags: 

582 if flag not in self.schema: 

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

584 

585 # initialize InitOutputs 

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

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

588 

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

590 inputRefs: pipeBase.InputQuantizedConnection, 

591 outputRefs: pipeBase.OutputQuantizedConnection): 

592 inputs = butlerQC.get(inputRefs) 

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

594 idFactory = idGenerator.make_table_id_factory() 

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

596 # exist, even as None. 

597 measurementResults = pipeBase.Struct( 

598 subtractedMeasuredExposure=None, 

599 diaSources=None, 

600 maskedStreaks=None, 

601 differenceBackground=None, 

602 ) 

603 try: 

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

605 except pipeBase.AlgorithmError as e: 

606 error = pipeBase.AnnotatedPartialOutputsError.annotate( 

607 e, 

608 self, 

609 measurementResults.subtractedMeasuredExposure, 

610 measurementResults.diaSources, 

611 measurementResults.maskedStreaks, 

612 log=self.log 

613 ) 

614 butlerQC.put(measurementResults, outputRefs) 

615 raise error from e 

616 butlerQC.put(measurementResults, outputRefs) 

617 

618 @timeMethod 

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

620 idFactory=None, measurementResults=None): 

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

622 

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

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

625 Close positive and negative detections will optionally be merged into 

626 dipole diaSources. 

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

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

629 detections. 

630 

631 Parameters 

632 ---------- 

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

634 Science exposure that the template was subtracted from. 

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

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

637 difference image. 

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

639 Result of subtracting template from the science image. 

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

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

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

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

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

645 deblending and merging positive/negative peaks. 

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

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

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

649 this is also returned. 

650 

651 Returns 

652 ------- 

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

654 

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

656 Subtracted exposure with detection mask applied. 

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

658 The catalog of detected sources. 

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

660 Background that was subtracted from the difference image. 

661 """ 

662 if measurementResults is None: 

663 measurementResults = pipeBase.Struct() 

664 if idFactory is None: 

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

666 

667 # Check image properties and clear detection mask planes. 

668 self._prepareInputs(difference) 

669 if self.config.doSubtractBackground: 

670 background = self._fitAndSubtractBackground(differenceExposure=difference) 

671 else: 

672 background = afwMath.BackgroundList() 

673 

674 if self.config.doFindCosmicRays: 

675 self.findAndMaskCosmicRays(difference) 

676 

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

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

679 # know about past ids). 

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

681 results = self.detection.run( 

682 table=table, 

683 exposure=difference, 

684 doSmooth=True, 

685 background=background, 

686 clearMask=True, 

687 ) 

688 measurementResults.differenceBackground = background 

689 

690 if self.config.doDeblend: 

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

692 results.positive, 

693 results.negative) 

694 

695 else: 

696 positives = afwTable.SourceCatalog(self.schema) 

697 results.positive.makeSources(positives) 

698 negatives = afwTable.SourceCatalog(self.schema) 

699 results.negative.makeSources(negatives) 

700 sources = results.sources 

701 

702 self.processResults(science, matchedTemplate, difference, 

703 sources, idFactory, kernelSources, 

704 positives=positives, 

705 negatives=negatives, 

706 measurementResults=measurementResults) 

707 return measurementResults 

708 

709 def _prepareInputs(self, difference): 

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

711 

712 Parameters 

713 ---------- 

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

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

716 The mask plane will be modified in place. 

717 

718 Raises 

719 ------ 

720 lsst.pipe.base.UpstreamFailureNoWorkFound 

721 If the PSF is not usable for measurement. 

722 """ 

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

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

725 if np.isnan(sigma): 

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

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

728 mask = difference.mask 

729 for mp in self.config.clearMaskPlanes: 

730 if mp not in mask.getMaskPlaneDict(): 

731 mask.addMaskPlane(mp) 

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

733 

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

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

736 

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

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

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

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

741 scheme is what suppresses spurious detections from glints and 

742 extended structures: the small-binsize initial fit aggressively 

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

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

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

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

747 detection runs on a properly background-subtracted score. 

748 

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

750 leaves on the clone is used downstream. 

751 

752 Parameters 

753 ---------- 

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

755 Difference image. The final background is subtracted from its 

756 image in place but its mask is not modified. 

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

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

759 background is also subtracted from its image. 

760 

761 Returns 

762 ------- 

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

764 The fit final background. 

765 """ 

766 if scoreExposure is None: 

767 detectionExposure = differenceExposure.clone() 

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

769 doSmooth = True 

770 else: 

771 # Use a clone of differenceExposure because the background is 

772 # subtracted in place. 

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

774 detectionExposure = scoreExposure.clone() 

775 detectionExposure.image -= background.getImage() 

776 doSmooth = False 

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

778 self.detection.run( 

779 table=table, 

780 exposure=detectionExposure, 

781 doSmooth=doSmooth, 

782 background=background, 

783 clearMask=True, 

784 ) 

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

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

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

788 differenceExposure.setMask(detectionExposure.mask) 

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

790 if scoreExposure is not None: 

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

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

793 scoreExposure.image -= background.getImage() 

794 return background 

795 

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

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

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

799 

800 Parameters 

801 ---------- 

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

803 Science exposure that the template was subtracted from. 

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

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

806 difference image. 

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

808 Result of subtracting template from the science image. 

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

810 Detected sources on the difference exposure. 

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

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

813 difference image. 

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

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

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

817 Positive polarity footprints. 

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

819 Negative polarity footprints. 

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

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

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

823 this is also returned. 

824 

825 Returns 

826 ------- 

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

828 

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

830 Subtracted exposure with detection mask applied. 

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

832 The catalog of detected sources. 

833 """ 

834 if measurementResults is None: 

835 measurementResults = pipeBase.Struct() 

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

837 if self.config.doMerge: 

838 # preserve peak schema, if there are any footprints 

839 if len(positives) > 0: 

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

841 elif len(negatives) > 0: 

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

843 else: 

844 peakSchema = afwDetection.PeakTable.makeMinimalSchema() 

845 mergeList = afwDetection.FootprintMergeList(self.schema, 

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

847 initialDiaSources = afwTable.SourceCatalog(self.schema) 

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

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

850 # deblended positive sources. 

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

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

853 mergeList.getFinalSources(initialDiaSources) 

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

855 # footprint set. 

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

857 ~initialDiaSources["merge_footprint_positive"] 

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

859 else: 

860 initialDiaSources = sources 

861 

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

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

864 for source in initialDiaSources: 

865 source.setId(idFactory()) 

866 # Ensure sources added after this get correct ids. 

867 initialDiaSources.getTable().setIdFactory(idFactory) 

868 initialDiaSources.setMetadata(self.algMetadata) 

869 

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

871 

872 if self.config.doMaskStreaks: 

873 streakInfo = self._runStreakMasking(difference) 

874 

875 if self.config.doSkySources: 

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

877 

878 if self.config.doRejectBadMaskPlaneDetections: 

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

880 # measurement. 

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

882 

883 if not initialDiaSources.isContiguous(): 

884 initialDiaSources = initialDiaSources.copy(deep=True) 

885 

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

887 

888 # Remove unphysical diaSources per config.badSourceFlags 

889 diaSources = self._removeBadSources(initialDiaSources) 

890 

891 if self.config.run_sattle: 

892 diaSources = self.filterSatellites(diaSources, science) 

893 

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

895 diaSources, trail_parameters = self._find_glint_trails(diaSources) 

896 if self.config.writeGlintInfo: 

897 measurementResults.mergeItems(trail_parameters, 'glintTrailInfo') 

898 

899 if self.config.doForcedMeasurement: 

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

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

902 template=True) 

903 

904 # Clear the image plane for regions with NO_DATA. 

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

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

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

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

909 

910 measurementResults.subtractedMeasuredExposure = difference 

911 

912 if self.config.doMaskStreaks and self.config.writeStreakInfo: 

913 measurementResults.maskedStreaks = streakInfo.maskedStreaks 

914 

915 if kernelSources is not None: 

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

917 

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

919 measurementResults.diaSources = diaSources 

920 elif self.config.raiseOnNoDiaSources: 

921 raise NoDiaSourcesError() 

922 elif len(diaSources) > 0: 

923 # This option allows returning sky sources, 

924 # even if there are no diaSources 

925 measurementResults.diaSources = diaSources 

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

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

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

929 ) 

930 return measurementResults 

931 

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

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

934 containing just the children, and the deblended footprints. 

935 

936 Parameters 

937 ---------- 

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

939 Result of subtracting template from the science image. 

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

941 Positive and negative polarity footprints measured on 

942 ``difference`` to be deblended separately. 

943 

944 Returns 

945 ------- 

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

947 Positive and negative deblended children. 

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

949 Deblended positive and negative polarity sources with footprints 

950 detected on ``difference``. 

951 """ 

952 

953 def deblend(footprints, negative=False): 

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

955 and return the deblended children. 

956 

957 Parameters 

958 ---------- 

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

960 negative : `bool` 

961 Set True if the footprints contain negative fluxes 

962 

963 Returns 

964 ------- 

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

966 """ 

967 sources = afwTable.SourceCatalog(self.schema) 

968 footprints.makeSources(sources) 

969 if negative: 

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

971 difference_inverted = difference.clone() 

972 difference_inverted.image *= -1 

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

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

975 # Set the heavy footprint pixel values back to reality 

976 for child in children: 

977 footprint = child.getFootprint() 

978 array = footprint.getImageArray() 

979 array *= -1 

980 else: 

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

982 self.setPrimaryFlags.run(sources) 

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

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

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

986 sources['parent'] = 0 

987 return sources.copy(deep=True) 

988 

989 positives = deblend(positiveFootprints) 

990 negatives = deblend(negativeFootprints, negative=True) 

991 

992 sources = afwTable.SourceCatalog(self.schema) 

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

994 sources.extend(positives, deep=True) 

995 sources.extend(negatives, deep=True) 

996 if len(negatives) > 0: 

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

998 return sources, positives, negatives 

999 

1000 def _rejectBadMaskedDetections(self, initialDiaSources, mask): 

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

1002 

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

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

1005 

1006 Parameters 

1007 ---------- 

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

1009 The catalog of detected peaks. 

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

1011 Mask of the image used for detection. 

1012 

1013 Returns 

1014 ------- 

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

1016 The catalog with bad-masked detections removed. 

1017 """ 

1018 if not self.config.badMaskPlanes or len(initialDiaSources) == 0: 

1019 self.metadata["nRejectedBadMaskPlanes"] = 0 

1020 return initialDiaSources 

1021 

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

1023 if mp in mask.getMaskPlaneDict()] 

1024 if not presentPlanes: 

1025 self.metadata["nRejectedBadMaskPlanes"] = 0 

1026 return initialDiaSources 

1027 

1028 badBitMask = mask.getPlaneBitMask(presentPlanes) 

1029 x0, y0 = mask.getXY0() 

1030 

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

1032 for i, source in enumerate(initialDiaSources): 

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

1034 for peak in peaks: 

1035 ix = peak.getIx() - x0 

1036 iy = peak.getIy() - y0 

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

1038 selector[i] = False 

1039 break 

1040 nRejected = np.count_nonzero(~selector) 

1041 self.metadata["nRejectedBadMaskPlanes"] = nRejected 

1042 if nRejected > 0: 

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

1044 "planes %s before measurement.", 

1045 nRejected, presentPlanes) 

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

1047 

1048 def _removeBadSources(self, diaSources): 

1049 """Remove unphysical diaSources from the catalog. 

1050 

1051 Parameters 

1052 ---------- 

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

1054 The catalog of detected sources. 

1055 

1056 Returns 

1057 ------- 

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

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

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

1061 """ 

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

1063 for flag in self.config.badSourceFlags: 

1064 flags = diaSources[flag] 

1065 nBad = np.count_nonzero(flags) 

1066 if nBad > 0: 

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

1068 selector &= ~flags 

1069 nBadTotal = np.count_nonzero(~selector) 

1070 self.metadata["nRemovedBadFlaggedSources"] = nBadTotal 

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

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

1073 

1074 def _find_glint_trails(self, diaSources): 

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

1076 

1077 Parameters 

1078 ---------- 

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

1080 The catalog of detected sources. 

1081 

1082 Returns 

1083 ------- 

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

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

1086 called 'glint_trail' added. 

1087 

1088 trail_parameters : `dict` 

1089 Parameters of all the trails that were found. 

1090 """ 

1091 if self.config.doSkySources: 

1092 # Do not include sky sources in glint detection 

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

1094 else: 

1095 candidateDiaSources = diaSources 

1096 trailed_glints = self.findGlints.run(candidateDiaSources) 

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

1098 if np.any(glint_mask): 

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

1100 

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

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

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

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

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

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

1107 'angles': angles} 

1108 

1109 trail_parameters = pipeBase.Struct(glintTrailInfo=parameters) 

1110 

1111 return diaSources, trail_parameters 

1112 

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

1114 subtask=None): 

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

1116 for measuring the background. 

1117 

1118 Parameters 

1119 ---------- 

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

1121 The catalog of detected sources. 

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

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

1124 seed : `int` 

1125 Seed value to initialize the random number generator. 

1126 """ 

1127 if subtask is None: 

1128 subtask = self.skySources 

1129 if subtask.config.nSources <= 0: 

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

1131 return 

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

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

1134 

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

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

1137 

1138 Parameters 

1139 ---------- 

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

1141 The catalog of detected sources. 

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

1143 Science exposure that the template was subtracted from. 

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

1145 Result of subtracting template from the science image. 

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

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

1148 difference image. 

1149 """ 

1150 # Ensure that the required mask planes are present 

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

1152 difference.mask.addMaskPlane(mp) 

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

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

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

1156 if self.config.doApCorr: 

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

1158 if apCorrMap is None: 

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

1160 else: 

1161 self.applyApCorr.run( 

1162 catalog=diaSources, 

1163 apCorrMap=apCorrMap, 

1164 ) 

1165 

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

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

1168 

1169 Parameters 

1170 ---------- 

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

1172 The catalog of detected sources. 

1173 image: `lsst.afw.image.ExposureF` 

1174 Exposure that the forced measurement is being performed on 

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

1176 Coordinate system definition (wcs) for the exposure. 

1177 template : `bool` 

1178 Is the forced measurement being performed on the template? 

1179 """ 

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

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

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

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

1184 

1185 if template: 

1186 base_key = 'ip_diffim_forced_template_PsfFlux' 

1187 else: 

1188 base_key = 'ip_diffim_forced_PsfFlux' 

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

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

1191 f"{base_key}_instFlux", True) 

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

1193 f"{base_key}_instFluxErr", True) 

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

1195 f"{base_key}_area", True) 

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

1197 f"{base_key}_flag", True) 

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

1199 f"{base_key}_flag_noGoodPixels", True) 

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

1201 f"{base_key}_flag_edge", True) 

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

1203 diaSource.assign(forcedSource, mapper) 

1204 

1205 def findAndMaskCosmicRays(self, difference): 

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

1207 

1208 Parameters 

1209 ---------- 

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

1211 The background-subtracted difference image. 

1212 The mask plane will be modified in place. 

1213 """ 

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

1215 # remaining background should be ~0. 

1216 medianBg = 0. 

1217 try: 

1218 crs = findCosmicRays( 

1219 difference.maskedImage, 

1220 difference.psf, 

1221 medianBg, 

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

1223 self.config.cosmicray.keepCRs, 

1224 ) 

1225 except LengthError: 

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

1227 num = 0 

1228 if crs is not None: 

1229 mask = difference.maskedImage.mask 

1230 crBit = mask.getPlaneBitMask("CR") 

1231 afwDetection.setMaskFromFootprintList(mask, crs, crBit) 

1232 num = len(crs) 

1233 

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

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

1236 self.metadata["cosmic_ray_count"] = num 

1237 

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

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

1240 

1241 This may be used to produce corresponding metrics (see 

1242 lsst.analysis.tools.tasks.diffimTaskDetectorVisitMetricAnalysis). 

1243 

1244 Parameters 

1245 ---------- 

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

1247 Science exposure that was subtracted. 

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

1249 The target difference image to calculate metrics for. 

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

1251 The catalog of detected sources. 

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

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

1254 """ 

1255 mask = difference.mask 

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

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

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

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

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

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

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

1263 detPosPix &= badPix 

1264 detNegPix &= badPix 

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

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

1267 

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

1269 for maskPlane in metricsMaskPlanes: 

1270 try: 

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

1272 except InvalidParameterError: 

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

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

1275 

1276 if self.config.doSkySources: 

1277 skySources = diaSources[diaSources["sky_source"]] 

1278 else: 

1279 skySources = None 

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

1281 

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

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

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

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

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

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

1288 self.metadata["residualFootprintRatioMean"], 

1289 self.metadata["residualFootprintRatioStdev"]) 

1290 if self.config.raiseOnBadSubtractionRatio: 

1291 if metrics.differenceFootprintRatioMean > self.config.badSubtractionRatioThreshold: 

1292 raise BadSubtractionError(ratio=metrics.differenceFootprintRatioMean, 

1293 threshold=self.config.badSubtractionRatioThreshold) 

1294 if metrics.differenceFootprintRatioStdev > self.config.badSubtractionVariationThreshold: 

1295 raise BadSubtractionError(ratio=metrics.differenceFootprintRatioStdev, 

1296 threshold=self.config.badSubtractionVariationThreshold) 

1297 

1298 def getSattleDiaSourceAllowlist(self, diaSources, science): 

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

1300 

1301 Parameters 

1302 ---------- 

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

1304 The catalog of detected sources. 

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

1306 Science exposure that was subtracted. 

1307 

1308 Returns 

1309 ---------- 

1310 allow_list : `list` of `int` 

1311 diaSourceIds of diaSources that can be made public. 

1312 

1313 Raises 

1314 ------ 

1315 requests.HTTPError 

1316 Raised if sattle call does not return success. 

1317 """ 

1318 wcs = science.getWcs() 

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

1320 visit_id = visit_info.getId() 

1321 sattle_uri_base = os.getenv('SATTLE_URI_BASE') 

1322 

1323 dia_sources_json = [] 

1324 for source in diaSources: 

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

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

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

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

1329 

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

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

1332 

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

1334 json=payload) 

1335 

1336 # retry once if visit cache is not populated 

1337 if sattle_output.status_code == 404: 

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

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

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

1341 

1342 sattle_output.raise_for_status() 

1343 

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

1345 

1346 def filterSatellites(self, diaSources, science): 

1347 """Remove diaSources overlapping predicted satellite positions. 

1348 

1349 Parameters 

1350 ---------- 

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

1352 The catalog of detected sources. 

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

1354 Science exposure that was subtracted. 

1355 

1356 Returns 

1357 ---------- 

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

1359 Filtered catalog of diaSources 

1360 """ 

1361 

1362 allow_list = self.getSattleDiaSourceAllowlist(diaSources, science) 

1363 

1364 if allow_list: 

1365 allow_set = set(allow_list) 

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

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

1368 else: 

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

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

1371 return diaSources 

1372 

1373 def _runStreakMasking(self, difference): 

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

1375 fit parameters in a catalog. 

1376 

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

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

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

1380 surface brightness streaks are more likely to fall above the 

1381 detection threshold. 

1382 

1383 Parameters 

1384 ---------- 

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

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

1387 mask. 

1388 

1389 Returns 

1390 ------- 

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

1392 ``rho`` : `np.ndarray` 

1393 Angle of detected streak. 

1394 ``theta`` : `np.ndarray` 

1395 Distance from center of detected streak. 

1396 ``sigma`` : `np.ndarray` 

1397 Width of streak profile. 

1398 ``reducedChi2`` : `np.ndarray` 

1399 Reduced chi2 of the best-fit streak profile. 

1400 ``modelMaximum`` : `np.ndarray` 

1401 Peak value of the fit line profile. 

1402 """ 

1403 maskedImage = difference.maskedImage 

1404 # Bin the diffim to enhance low surface brightness streaks 

1405 binnedMaskedImage = afwMath.binImage(maskedImage, 

1406 self.config.streakBinFactor, 

1407 self.config.streakBinFactor) 

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

1409 binnedExposure.setMaskedImage(binnedMaskedImage) 

1410 # Clear the DETECTED mask plane before streak detection 

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

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

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

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

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

1416 sigma=sigma/self.config.streakBinFactor) 

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

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

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

1420 axis=1) 

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

1422 streakMaskedImage = maskedImage.clone() 

1423 ysize, xsize = rescaledDetectedMaskPlane.shape 

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

1425 # Detect streaks on this new version of the diffim 

1426 streaks = self.maskStreaks.run(streakMaskedImage) 

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

1428 # Apply the new STREAK mask to the original diffim 

1429 maskedImage.mask.array |= streakMaskPlane 

1430 

1431 if self.config.writeStreakInfo: 

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

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

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

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

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

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

1438 'modelMaximum': modelMaximums} 

1439 else: 

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

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

1442 return pipeBase.Struct(maskedStreaks=streakInfo) 

1443 

1444 

1445class DetectAndMeasureScoreConnections(DetectAndMeasureConnections): 

1446 scoreExposure = pipeBase.connectionTypes.Input( 

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

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

1449 storageClass="ExposureF", 

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

1451 ) 

1452 scoreMeasuredExposure = pipeBase.connectionTypes.Output( 

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

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

1455 storageClass="ExposureF", 

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

1457 ) 

1458 

1459 

1460class DetectAndMeasureScoreConfig(DetectAndMeasureConfig, 

1461 pipelineConnections=DetectAndMeasureScoreConnections): 

1462 pass 

1463 

1464 

1465class DetectAndMeasureScoreTask(DetectAndMeasureTask): 

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

1467 and measure the detections on the difference image. 

1468 

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

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

1471 Close positive and negative detections will optionally be merged into 

1472 dipole diaSources. 

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

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

1475 detections. 

1476 """ 

1477 ConfigClass = DetectAndMeasureScoreConfig 

1478 _DefaultName = "detectAndMeasureScore" 

1479 

1480 @timeMethod 

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

1482 idFactory=None, measurementResults=None): 

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

1484 

1485 Parameters 

1486 ---------- 

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

1488 Science exposure that the template was subtracted from. 

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

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

1491 difference image. 

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

1493 Result of subtracting template from the science image. 

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

1495 Score or maximum likelihood difference image 

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

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

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

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

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

1501 deblending and merging positive/negative peaks. 

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

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

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

1505 this is also returned. 

1506 

1507 Returns 

1508 ------- 

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

1510 

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

1512 Subtracted exposure with detection mask applied. 

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

1514 Score exposure with detection mask applied. 

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

1516 The catalog of detected sources. 

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

1518 Background that was subtracted from the difference image. 

1519 """ 

1520 if measurementResults is None: 

1521 measurementResults = pipeBase.Struct() 

1522 if idFactory is None: 

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

1524 

1525 # Check image properties and clear detection mask planes. 

1526 self._prepareInputs(difference) 

1527 self._prepareInputs(scoreExposure) 

1528 if self.config.doSubtractBackground: 

1529 background = self._fitAndSubtractBackground( 

1530 differenceExposure=difference, scoreExposure=scoreExposure, 

1531 ) 

1532 else: 

1533 background = afwMath.BackgroundList() 

1534 

1535 if self.config.doFindCosmicRays: 

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

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

1538 self.findAndMaskCosmicRays(difference) 

1539 scoreExposure.mask.array |= difference.mask.array 

1540 

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

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

1543 # know about past ids). 

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

1545 detectResults = self.detection.run( 

1546 table=table, 

1547 exposure=scoreExposure, 

1548 doSmooth=False, 

1549 background=background, 

1550 clearMask=True, 

1551 ) 

1552 measurementResults.differenceBackground = background 

1553 measurementResults.scoreMeasuredExposure = scoreExposure 

1554 

1555 # Copy the detection mask from the Score image to the difference image 

1556 difference.mask.assign(scoreExposure.mask, scoreExposure.getBBox()) 

1557 

1558 if self.config.doDeblend: 

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

1560 detectResults.positive, 

1561 detectResults.negative) 

1562 

1563 self.processResults(science, matchedTemplate, difference, 

1564 sources, idFactory, kernelSources, 

1565 positives=positives, 

1566 negatives=negatives, 

1567 measurementResults=measurementResults) 

1568 

1569 else: 

1570 positives = afwTable.SourceCatalog(self.schema) 

1571 detectResults.positive.makeSources(positives) 

1572 negatives = afwTable.SourceCatalog(self.schema) 

1573 detectResults.negative.makeSources(negatives) 

1574 self.processResults(science, matchedTemplate, difference, 

1575 detectResults.sources, idFactory, kernelSources, 

1576 positives=positives, 

1577 negatives=negatives, 

1578 measurementResults=measurementResults) 

1579 return measurementResults