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

525 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-30 09:08 +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 raiseOnBadSubtractionRatio = pexConfig.Field( 

350 dtype=bool, 

351 default=True, 

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

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

354 " image exceeds ``badSubtractionRatioThreshold``", 

355 ) 

356 badSubtractionRatioThreshold = pexConfig.Field( 

357 dtype=float, 

358 default=0.2, 

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

360 " the same footprints on the science image." 

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

362 ) 

363 badSubtractionVariationThreshold = pexConfig.Field( 

364 dtype=float, 

365 default=0.4, 

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

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

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

369 ) 

370 raiseOnNoDiaSources = pexConfig.Field( 

371 dtype=bool, 

372 default=True, 

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

374 ) 

375 run_sattle = pexConfig.Field( 

376 dtype=bool, 

377 default=False, 

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

379 "to the sattle service." 

380 ) 

381 sattle_historical = pexConfig.Field( 

382 dtype=bool, 

383 default=False, 

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

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

386 "closest in time to the exposure." 

387 ) 

388 idGenerator = DetectorVisitIdGeneratorConfig.make_field() 

389 

390 def setDefaults(self): 

391 # Background subtraction 

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

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

394 # faint diaSources 

395 self.subtractInitialBackground.binSize = 8 

396 self.subtractInitialBackground.useApprox = False 

397 self.subtractInitialBackground.statisticsProperty = "MEDIAN" 

398 self.subtractInitialBackground.doFilterSuperPixels = True 

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

400 "EDGE", 

401 "DETECTED", 

402 "DETECTED_NEGATIVE", 

403 "NO_DATA", 

404 ] 

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

406 # over-subtraction of bright objects. 

407 self.subtractFinalBackground.binSize = 40 

408 self.subtractFinalBackground.useApprox = False 

409 self.subtractFinalBackground.statisticsProperty = "MEDIAN" 

410 self.subtractFinalBackground.doFilterSuperPixels = True 

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

412 "EDGE", 

413 "DETECTED", 

414 "DETECTED_NEGATIVE", 

415 "NO_DATA", 

416 ] 

417 # Cosmic ray detection 

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

419 # DiaSource Detection 

420 self.detection.thresholdPolarity = "both" 

421 self.detection.thresholdValue = 5.0 

422 self.detection.reEstimateBackground = False 

423 self.detection.thresholdType = "pixel_stdev" 

424 self.detection.excludeMaskPlanes = [] 

425 

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

427 self.streakDetection.thresholdType = self.detection.thresholdType 

428 self.streakDetection.reEstimateBackground = False 

429 self.streakDetection.excludeMaskPlanes = self.detection.excludeMaskPlanes 

430 self.streakDetection.thresholdValue = self.detection.thresholdValue 

431 # Only detect positive streaks 

432 self.streakDetection.thresholdPolarity = "positive" 

433 # Do not grow detected mask for streaks 

434 self.streakDetection.nSigmaToGrow = 0 

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

436 # detected mask is set. 

437 self.maskStreaks.onlyMaskDetected = False 

438 # Restrict streak masking from growing too large 

439 self.maskStreaks.maxStreakWidth = 100 

440 # Restrict the number of iterations allowed for fitting streaks 

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

442 self.maskStreaks.maxFitIter = 10 

443 # Only mask to 2 sigma in width 

444 self.maskStreaks.nSigmaMask = 2 

445 # Threshold for including streaks after the Hough Transform. 

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

447 self.maskStreaks.absMinimumKernelHeight = 2 

448 

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

450 "base_LocalPhotoCalib", 

451 "base_LocalWcs", 

452 "ext_shapeHSM_HsmSourceMoments", 

453 "ext_shapeHSM_HsmPsfMoments", 

454 "base_ClassificationSizeExtendedness", 

455 ] 

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

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

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

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

460 self.forcedMeasurement.copyColumns = { 

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

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

463 self.forcedMeasurement.slots.shape = None 

464 

465 # Keep track of which footprints contain streaks 

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

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

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

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

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

471 

472 def validate(self): 

473 super().validate() 

474 

475 if self.run_sattle: 

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

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

478 "Sattle requested but SATTLE_URI_BASE " 

479 "environment variable not set.") 

480 

481 

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

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

484 """ 

485 ConfigClass = DetectAndMeasureConfig 

486 _DefaultName = "detectAndMeasure" 

487 

488 def __init__(self, **kwargs): 

489 super().__init__(**kwargs) 

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

491 

492 self.algMetadata = dafBase.PropertyList() 

493 if self.config.doSubtractBackground: 

494 self.makeSubtask("subtractInitialBackground") 

495 self.makeSubtask("subtractFinalBackground") 

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

497 if self.config.doDeblend: 

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

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

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

501 algMetadata=self.algMetadata) 

502 if self.config.doApCorr: 

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

504 if self.config.doForcedMeasurement: 

505 self.schema.addField( 

506 "ip_diffim_forced_PsfFlux_instFlux", "D", 

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

508 units="count") 

509 self.schema.addField( 

510 "ip_diffim_forced_PsfFlux_instFluxErr", "D", 

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

512 units="count") 

513 self.schema.addField( 

514 "ip_diffim_forced_PsfFlux_area", "F", 

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

516 units="pixel") 

517 self.schema.addField( 

518 "ip_diffim_forced_PsfFlux_flag", "Flag", 

519 "Forced PSF flux general failure flag.") 

520 self.schema.addField( 

521 "ip_diffim_forced_PsfFlux_flag_noGoodPixels", "Flag", 

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

523 self.schema.addField( 

524 "ip_diffim_forced_PsfFlux_flag_edge", "Flag", 

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

526 self.schema.addField( 

527 "ip_diffim_forced_template_PsfFlux_instFlux", "D", 

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

529 units="count") 

530 self.schema.addField( 

531 "ip_diffim_forced_template_PsfFlux_instFluxErr", "D", 

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

533 units="count") 

534 self.schema.addField( 

535 "ip_diffim_forced_template_PsfFlux_area", "F", 

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

537 units="pixel") 

538 self.schema.addField( 

539 "ip_diffim_forced_template_PsfFlux_flag", "Flag", 

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

541 self.schema.addField( 

542 "ip_diffim_forced_template_PsfFlux_flag_noGoodPixels", "Flag", 

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

544 self.schema.addField( 

545 "ip_diffim_forced_template_PsfFlux_flag_edge", "Flag", 

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

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

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

549 

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

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

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

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

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

555 if self.config.doMaskStreaks: 

556 self.makeSubtask("maskStreaks") 

557 self.makeSubtask("streakDetection") 

558 self.makeSubtask("findGlints") 

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

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

561 

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

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

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

565 

566 # Check that the schema and config are consistent 

567 for flag in self.config.badSourceFlags: 

568 if flag not in self.schema: 

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

570 

571 # initialize InitOutputs 

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

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

574 

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

576 inputRefs: pipeBase.InputQuantizedConnection, 

577 outputRefs: pipeBase.OutputQuantizedConnection): 

578 inputs = butlerQC.get(inputRefs) 

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

580 idFactory = idGenerator.make_table_id_factory() 

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

582 # exist, even as None. 

583 measurementResults = pipeBase.Struct( 

584 subtractedMeasuredExposure=None, 

585 diaSources=None, 

586 maskedStreaks=None, 

587 differenceBackground=None, 

588 ) 

589 try: 

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

591 except pipeBase.AlgorithmError as e: 

592 error = pipeBase.AnnotatedPartialOutputsError.annotate( 

593 e, 

594 self, 

595 measurementResults.subtractedMeasuredExposure, 

596 measurementResults.diaSources, 

597 measurementResults.maskedStreaks, 

598 log=self.log 

599 ) 

600 butlerQC.put(measurementResults, outputRefs) 

601 raise error from e 

602 butlerQC.put(measurementResults, outputRefs) 

603 

604 @timeMethod 

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

606 idFactory=None, measurementResults=None): 

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

608 

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

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

611 Close positive and negative detections will optionally be merged into 

612 dipole diaSources. 

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

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

615 detections. 

616 

617 Parameters 

618 ---------- 

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

620 Science exposure that the template was subtracted from. 

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

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

623 difference image. 

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

625 Result of subtracting template from the science image. 

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

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

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

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

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

631 deblending and merging positive/negative peaks. 

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

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

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

635 this is also returned. 

636 

637 Returns 

638 ------- 

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

640 

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

642 Subtracted exposure with detection mask applied. 

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

644 The catalog of detected sources. 

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

646 Background that was subtracted from the difference image. 

647 """ 

648 if measurementResults is None: 

649 measurementResults = pipeBase.Struct() 

650 if idFactory is None: 

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

652 

653 if self.config.doSubtractBackground: 

654 # Run background subtraction before clearing the mask planes 

655 detectionExposure = difference.clone() 

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

657 else: 

658 detectionExposure = difference 

659 background = afwMath.BackgroundList() 

660 

661 self._prepareInputs(detectionExposure) 

662 

663 if self.config.doFindCosmicRays and not self.config.doSubtractBackground: 

664 # Detect and interpolate over any remaining cosmic rays 

665 # This only needs to run once, right before the final detection. 

666 self.findAndMaskCosmicRays(detectionExposure) 

667 

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

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

670 # know about past ids). 

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

672 results = self.detection.run( 

673 table=table, 

674 exposure=detectionExposure, 

675 doSmooth=True, 

676 background=background 

677 ) 

678 

679 if self.config.doSubtractBackground: 

680 # Run background subtraction again after detecting peaks 

681 # but before measurement 

682 # First update the mask using the detection image 

683 difference.setMask(detectionExposure.mask) 

684 background = self.subtractFinalBackground.run(difference).background 

685 

686 if self.config.doFindCosmicRays: 

687 # Detect and interpolate over any remaining cosmic rays 

688 self.findAndMaskCosmicRays(difference) 

689 

690 # Re-run detection to get final footprints 

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

692 results = self.detection.run( 

693 table=table, 

694 exposure=difference, 

695 doSmooth=True, 

696 background=background 

697 ) 

698 measurementResults.differenceBackground = background 

699 

700 if self.config.doDeblend: 

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

702 results.positive, 

703 results.negative) 

704 

705 else: 

706 positives = afwTable.SourceCatalog(self.schema) 

707 results.positive.makeSources(positives) 

708 negatives = afwTable.SourceCatalog(self.schema) 

709 results.negative.makeSources(negatives) 

710 sources = results.sources 

711 

712 self.processResults(science, matchedTemplate, difference, 

713 sources, idFactory, kernelSources, 

714 positives=positives, 

715 negatives=negatives, 

716 measurementResults=measurementResults) 

717 return measurementResults 

718 

719 def _prepareInputs(self, difference): 

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

721 

722 Parameters 

723 ---------- 

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

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

726 The mask plane will be modified in place. 

727 

728 Raises 

729 ------ 

730 lsst.pipe.base.UpstreamFailureNoWorkFound 

731 If the PSF is not usable for measurement. 

732 """ 

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

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

735 if np.isnan(sigma): 

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

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

738 mask = difference.mask 

739 for mp in self.config.clearMaskPlanes: 

740 if mp not in mask.getMaskPlaneDict(): 

741 mask.addMaskPlane(mp) 

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

743 

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

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

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

747 

748 Parameters 

749 ---------- 

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

751 Science exposure that the template was subtracted from. 

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

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

754 difference image. 

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

756 Result of subtracting template from the science image. 

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

758 Detected sources on the difference exposure. 

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

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

761 difference image. 

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

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

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

765 Positive polarity footprints. 

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

767 Negative polarity footprints. 

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

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

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

771 this is also returned. 

772 

773 Returns 

774 ------- 

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

776 

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

778 Subtracted exposure with detection mask applied. 

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

780 The catalog of detected sources. 

781 """ 

782 if measurementResults is None: 

783 measurementResults = pipeBase.Struct() 

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

785 if self.config.doMerge: 

786 # preserve peak schema, if there are any footprints 

787 if len(positives) > 0: 

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

789 elif len(negatives) > 0: 

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

791 else: 

792 peakSchema = afwDetection.PeakTable.makeMinimalSchema() 

793 mergeList = afwDetection.FootprintMergeList(self.schema, 

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

795 initialDiaSources = afwTable.SourceCatalog(self.schema) 

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

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

798 # deblended positive sources. 

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

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

801 mergeList.getFinalSources(initialDiaSources) 

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

803 # footprint set. 

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

805 ~initialDiaSources["merge_footprint_positive"] 

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

807 else: 

808 initialDiaSources = sources 

809 

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

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

812 for source in initialDiaSources: 

813 source.setId(idFactory()) 

814 # Ensure sources added after this get correct ids. 

815 initialDiaSources.getTable().setIdFactory(idFactory) 

816 initialDiaSources.setMetadata(self.algMetadata) 

817 

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

819 

820 if self.config.doMaskStreaks: 

821 streakInfo = self._runStreakMasking(difference) 

822 

823 if self.config.doSkySources: 

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

825 

826 if not initialDiaSources.isContiguous(): 

827 initialDiaSources = initialDiaSources.copy(deep=True) 

828 

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

830 

831 # Remove unphysical diaSources per config.badSourceFlags 

832 diaSources = self._removeBadSources(initialDiaSources) 

833 

834 if self.config.run_sattle: 

835 diaSources = self.filterSatellites(diaSources, science) 

836 

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

838 diaSources, trail_parameters = self._find_glint_trails(diaSources) 

839 if self.config.writeGlintInfo: 

840 measurementResults.mergeItems(trail_parameters, 'glintTrailInfo') 

841 

842 if self.config.doForcedMeasurement: 

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

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

845 template=True) 

846 

847 # Clear the image plane for regions with NO_DATA. 

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

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

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

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

852 

853 measurementResults.subtractedMeasuredExposure = difference 

854 

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

856 measurementResults.maskedStreaks = streakInfo.maskedStreaks 

857 

858 if kernelSources is not None: 

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

860 

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

862 measurementResults.diaSources = diaSources 

863 elif self.config.raiseOnNoDiaSources: 

864 raise NoDiaSourcesError() 

865 elif len(diaSources) > 0: 

866 # This option allows returning sky sources, 

867 # even if there are no diaSources 

868 measurementResults.diaSources = diaSources 

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

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

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

872 ) 

873 return measurementResults 

874 

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

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

877 containing just the children, and the deblended footprints. 

878 

879 Parameters 

880 ---------- 

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

882 Result of subtracting template from the science image. 

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

884 Positive and negative polarity footprints measured on 

885 ``difference`` to be deblended separately. 

886 

887 Returns 

888 ------- 

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

890 Positive and negative deblended children. 

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

892 Deblended positive and negative polarity sources with footprints 

893 detected on ``difference``. 

894 """ 

895 

896 def deblend(footprints, negative=False): 

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

898 and return the deblended children. 

899 

900 Parameters 

901 ---------- 

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

903 negative : `bool` 

904 Set True if the footprints contain negative fluxes 

905 

906 Returns 

907 ------- 

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

909 """ 

910 sources = afwTable.SourceCatalog(self.schema) 

911 footprints.makeSources(sources) 

912 if negative: 

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

914 difference_inverted = difference.clone() 

915 difference_inverted.image *= -1 

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

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

918 # Set the heavy footprint pixel values back to reality 

919 for child in children: 

920 footprint = child.getFootprint() 

921 array = footprint.getImageArray() 

922 array *= -1 

923 else: 

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

925 self.setPrimaryFlags.run(sources) 

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

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

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

929 sources['parent'] = 0 

930 return sources.copy(deep=True) 

931 

932 positives = deblend(positiveFootprints) 

933 negatives = deblend(negativeFootprints, negative=True) 

934 

935 sources = afwTable.SourceCatalog(self.schema) 

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

937 sources.extend(positives, deep=True) 

938 sources.extend(negatives, deep=True) 

939 if len(negatives) > 0: 

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

941 return sources, positives, negatives 

942 

943 def _removeBadSources(self, diaSources): 

944 """Remove unphysical diaSources from the catalog. 

945 

946 Parameters 

947 ---------- 

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

949 The catalog of detected sources. 

950 

951 Returns 

952 ------- 

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

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

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

956 """ 

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

958 for flag in self.config.badSourceFlags: 

959 flags = diaSources[flag] 

960 nBad = np.count_nonzero(flags) 

961 if nBad > 0: 

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

963 selector &= ~flags 

964 nBadTotal = np.count_nonzero(~selector) 

965 self.metadata["nRemovedBadFlaggedSources"] = nBadTotal 

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

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

968 

969 def _find_glint_trails(self, diaSources): 

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

971 

972 Parameters 

973 ---------- 

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

975 The catalog of detected sources. 

976 

977 Returns 

978 ------- 

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

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

981 called 'glint_trail' added. 

982 

983 trail_parameters : `dict` 

984 Parameters of all the trails that were found. 

985 """ 

986 if self.config.doSkySources: 

987 # Do not include sky sources in glint detection 

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

989 else: 

990 candidateDiaSources = diaSources 

991 trailed_glints = self.findGlints.run(candidateDiaSources) 

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

993 if np.any(glint_mask): 

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

995 

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

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

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

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

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

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

1002 'angles': angles} 

1003 

1004 trail_parameters = pipeBase.Struct(glintTrailInfo=parameters) 

1005 

1006 return diaSources, trail_parameters 

1007 

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

1009 subtask=None): 

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

1011 for measuring the background. 

1012 

1013 Parameters 

1014 ---------- 

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

1016 The catalog of detected sources. 

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

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

1019 seed : `int` 

1020 Seed value to initialize the random number generator. 

1021 """ 

1022 if subtask is None: 

1023 subtask = self.skySources 

1024 if subtask.config.nSources <= 0: 

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

1026 return 

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

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

1029 

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

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

1032 

1033 Parameters 

1034 ---------- 

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

1036 The catalog of detected sources. 

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

1038 Science exposure that the template was subtracted from. 

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

1040 Result of subtracting template from the science image. 

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

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

1043 difference image. 

1044 """ 

1045 # Ensure that the required mask planes are present 

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

1047 difference.mask.addMaskPlane(mp) 

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

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

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

1051 if self.config.doApCorr: 

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

1053 if apCorrMap is None: 

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

1055 else: 

1056 self.applyApCorr.run( 

1057 catalog=diaSources, 

1058 apCorrMap=apCorrMap, 

1059 ) 

1060 

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

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

1063 

1064 Parameters 

1065 ---------- 

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

1067 The catalog of detected sources. 

1068 image: `lsst.afw.image.ExposureF` 

1069 Exposure that the forced measurement is being performed on 

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

1071 Coordinate system definition (wcs) for the exposure. 

1072 template : `bool` 

1073 Is the forced measurement being performed on the template? 

1074 """ 

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

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

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

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

1079 

1080 if template: 

1081 base_key = 'ip_diffim_forced_template_PsfFlux' 

1082 else: 

1083 base_key = 'ip_diffim_forced_PsfFlux' 

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

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

1086 f"{base_key}_instFlux", True) 

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

1088 f"{base_key}_instFluxErr", True) 

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

1090 f"{base_key}_area", True) 

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

1092 f"{base_key}_flag", True) 

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

1094 f"{base_key}_flag_noGoodPixels", True) 

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

1096 f"{base_key}_flag_edge", True) 

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

1098 diaSource.assign(forcedSource, mapper) 

1099 

1100 def findAndMaskCosmicRays(self, difference): 

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

1102 

1103 Parameters 

1104 ---------- 

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

1106 The background-subtracted difference image. 

1107 The mask plane will be modified in place. 

1108 """ 

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

1110 # remaining background should be ~0. 

1111 medianBg = 0. 

1112 try: 

1113 crs = findCosmicRays( 

1114 difference.maskedImage, 

1115 difference.psf, 

1116 medianBg, 

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

1118 self.config.cosmicray.keepCRs, 

1119 ) 

1120 except LengthError: 

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

1122 num = 0 

1123 if crs is not None: 

1124 mask = difference.maskedImage.mask 

1125 crBit = mask.getPlaneBitMask("CR") 

1126 afwDetection.setMaskFromFootprintList(mask, crs, crBit) 

1127 num = len(crs) 

1128 

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

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

1131 self.metadata["cosmic_ray_count"] = num 

1132 

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

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

1135 

1136 This may be used to produce corresponding metrics (see 

1137 lsst.analysis.tools.tasks.diffimTaskDetectorVisitMetricAnalysis). 

1138 

1139 Parameters 

1140 ---------- 

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

1142 Science exposure that was subtracted. 

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

1144 The target difference image to calculate metrics for. 

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

1146 The catalog of detected sources. 

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

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

1149 """ 

1150 mask = difference.mask 

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

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

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

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

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

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

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

1158 detPosPix &= badPix 

1159 detNegPix &= badPix 

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

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

1162 

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

1164 for maskPlane in metricsMaskPlanes: 

1165 try: 

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

1167 except InvalidParameterError: 

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

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

1170 

1171 if self.config.doSkySources: 

1172 skySources = diaSources[diaSources["sky_source"]] 

1173 else: 

1174 skySources = None 

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

1176 

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

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

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

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

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

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

1183 self.metadata["residualFootprintRatioMean"], 

1184 self.metadata["residualFootprintRatioStdev"]) 

1185 if self.config.raiseOnBadSubtractionRatio: 

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

1187 raise BadSubtractionError(ratio=metrics.differenceFootprintRatioMean, 

1188 threshold=self.config.badSubtractionRatioThreshold) 

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

1190 raise BadSubtractionError(ratio=metrics.differenceFootprintRatioStdev, 

1191 threshold=self.config.badSubtractionVariationThreshold) 

1192 

1193 def getSattleDiaSourceAllowlist(self, diaSources, science): 

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

1195 

1196 Parameters 

1197 ---------- 

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

1199 The catalog of detected sources. 

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

1201 Science exposure that was subtracted. 

1202 

1203 Returns 

1204 ---------- 

1205 allow_list : `list` of `int` 

1206 diaSourceIds of diaSources that can be made public. 

1207 

1208 Raises 

1209 ------ 

1210 requests.HTTPError 

1211 Raised if sattle call does not return success. 

1212 """ 

1213 wcs = science.getWcs() 

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

1215 visit_id = visit_info.getId() 

1216 sattle_uri_base = os.getenv('SATTLE_URI_BASE') 

1217 

1218 dia_sources_json = [] 

1219 for source in diaSources: 

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

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

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

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

1224 

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

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

1227 

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

1229 json=payload) 

1230 

1231 # retry once if visit cache is not populated 

1232 if sattle_output.status_code == 404: 

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

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

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

1236 

1237 sattle_output.raise_for_status() 

1238 

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

1240 

1241 def filterSatellites(self, diaSources, science): 

1242 """Remove diaSources overlapping predicted satellite positions. 

1243 

1244 Parameters 

1245 ---------- 

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

1247 The catalog of detected sources. 

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

1249 Science exposure that was subtracted. 

1250 

1251 Returns 

1252 ---------- 

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

1254 Filtered catalog of diaSources 

1255 """ 

1256 

1257 allow_list = self.getSattleDiaSourceAllowlist(diaSources, science) 

1258 

1259 if allow_list: 

1260 allow_set = set(allow_list) 

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

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

1263 else: 

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

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

1266 return diaSources 

1267 

1268 def _runStreakMasking(self, difference): 

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

1270 fit parameters in a catalog. 

1271 

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

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

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

1275 surface brightness streaks are more likely to fall above the 

1276 detection threshold. 

1277 

1278 Parameters 

1279 ---------- 

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

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

1282 mask. 

1283 

1284 Returns 

1285 ------- 

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

1287 ``rho`` : `np.ndarray` 

1288 Angle of detected streak. 

1289 ``theta`` : `np.ndarray` 

1290 Distance from center of detected streak. 

1291 ``sigma`` : `np.ndarray` 

1292 Width of streak profile. 

1293 ``reducedChi2`` : `np.ndarray` 

1294 Reduced chi2 of the best-fit streak profile. 

1295 ``modelMaximum`` : `np.ndarray` 

1296 Peak value of the fit line profile. 

1297 """ 

1298 maskedImage = difference.maskedImage 

1299 # Bin the diffim to enhance low surface brightness streaks 

1300 binnedMaskedImage = afwMath.binImage(maskedImage, 

1301 self.config.streakBinFactor, 

1302 self.config.streakBinFactor) 

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

1304 binnedExposure.setMaskedImage(binnedMaskedImage) 

1305 # Clear the DETECTED mask plane before streak detection 

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

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

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

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

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

1311 sigma=sigma/self.config.streakBinFactor) 

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

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

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

1315 axis=1) 

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

1317 streakMaskedImage = maskedImage.clone() 

1318 ysize, xsize = rescaledDetectedMaskPlane.shape 

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

1320 # Detect streaks on this new version of the diffim 

1321 streaks = self.maskStreaks.run(streakMaskedImage) 

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

1323 # Apply the new STREAK mask to the original diffim 

1324 maskedImage.mask.array |= streakMaskPlane 

1325 

1326 if self.config.writeStreakInfo: 

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

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

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

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

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

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

1333 'modelMaximum': modelMaximums} 

1334 else: 

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

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

1337 return pipeBase.Struct(maskedStreaks=streakInfo) 

1338 

1339 

1340class DetectAndMeasureScoreConnections(DetectAndMeasureConnections): 

1341 scoreExposure = pipeBase.connectionTypes.Input( 

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

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

1344 storageClass="ExposureF", 

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

1346 ) 

1347 

1348 

1349class DetectAndMeasureScoreConfig(DetectAndMeasureConfig, 

1350 pipelineConnections=DetectAndMeasureScoreConnections): 

1351 pass 

1352 

1353 

1354class DetectAndMeasureScoreTask(DetectAndMeasureTask): 

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

1356 and measure the detections on the difference image. 

1357 

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

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

1360 Close positive and negative detections will optionally be merged into 

1361 dipole diaSources. 

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

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

1364 detections. 

1365 """ 

1366 ConfigClass = DetectAndMeasureScoreConfig 

1367 _DefaultName = "detectAndMeasureScore" 

1368 

1369 @timeMethod 

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

1371 idFactory=None): 

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

1373 

1374 Parameters 

1375 ---------- 

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

1377 Science exposure that the template was subtracted from. 

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

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

1380 difference image. 

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

1382 Result of subtracting template from the science image. 

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

1384 Score or maximum likelihood difference image 

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

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

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

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

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

1390 deblending and merging positive/negative peaks. 

1391 

1392 Returns 

1393 ------- 

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

1395 

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

1397 Subtracted exposure with detection mask applied. 

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

1399 The catalog of detected sources. 

1400 """ 

1401 if idFactory is None: 

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

1403 

1404 self._prepareInputs(scoreExposure) 

1405 

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

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

1408 # know about past ids). 

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

1410 results = self.detection.run( 

1411 table=table, 

1412 exposure=scoreExposure, 

1413 doSmooth=False, 

1414 ) 

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

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

1417 

1418 if self.config.doDeblend: 

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

1420 results.positive, 

1421 results.negative) 

1422 

1423 return self.processResults(science, matchedTemplate, difference, 

1424 sources, idFactory, kernelSources, 

1425 positives=positives, 

1426 negatives=negatives) 

1427 

1428 else: 

1429 positives = afwTable.SourceCatalog(self.schema) 

1430 results.positive.makeSources(positives) 

1431 negatives = afwTable.SourceCatalog(self.schema) 

1432 results.negative.makeSources(negatives) 

1433 return self.processResults(science, matchedTemplate, difference, 

1434 results.sources, idFactory, kernelSources, 

1435 positives=positives, 

1436 negatives=negatives)