Coverage for python / lsst / drp / tasks / assemble_coadd.py: 14%

675 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-31 01:48 -0800

1# This file is part of drp_tasks. 

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 

22from __future__ import annotations 

23 

24__all__ = [ 

25 "AssembleCoaddTask", 

26 "AssembleCoaddConnections", 

27 "AssembleCoaddConfig", 

28 "CompareWarpAssembleCoaddTask", 

29 "CompareWarpAssembleCoaddConfig", 

30] 

31 

32import copy 

33import logging 

34import warnings 

35 

36import lsstDebug 

37import numpy 

38 

39import lsst.afw.geom as afwGeom 

40import lsst.afw.image as afwImage 

41import lsst.afw.math as afwMath 

42import lsst.afw.table as afwTable 

43import lsst.coadd.utils as coaddUtils 

44import lsst.geom as geom 

45import lsst.meas.algorithms as measAlg 

46import lsst.pex.config as pexConfig 

47import lsst.pex.exceptions as pexExceptions 

48import lsst.pipe.base as pipeBase 

49import lsst.utils as utils 

50from lsst.meas.algorithms import AccumulatorMeanStack, MaskStreaksTask, ScaleVarianceTask, SourceDetectionTask 

51from lsst.pipe.tasks.coaddBase import ( 

52 CoaddBaseTask, 

53 makeSkyInfo, 

54 removeMaskPlanes, 

55 reorderAndPadList, 

56 setRejectedMaskMapping, 

57 subBBoxIter, 

58) 

59from lsst.pipe.tasks.healSparseMapping import HealSparseInputMapTask 

60from lsst.pipe.tasks.interpImage import InterpImageTask 

61from lsst.pipe.tasks.scaleZeroPoint import ScaleZeroPointTask 

62from lsst.skymap import BaseSkyMap 

63from lsst.utils.timer import timeMethod 

64 

65log = logging.getLogger(__name__) 

66 

67 

68class AssembleCoaddConnections( 

69 pipeBase.PipelineTaskConnections, 

70 dimensions=("tract", "patch", "band", "skymap"), 

71 defaultTemplates={ 

72 "inputCoaddName": "deep", 

73 "outputCoaddName": "deep", 

74 "warpType": "direct", 

75 "warpTypeSuffix": "", 

76 }, 

77): 

78 inputWarps = pipeBase.connectionTypes.Input( 

79 doc=( 

80 "Input list of warps to be assemebled i.e. stacked." 

81 "WarpType (e.g. direct, psfMatched) is controlled by the " 

82 "warpType config parameter" 

83 ), 

84 name="{inputCoaddName}Coadd_{warpType}Warp", 

85 storageClass="ExposureF", 

86 dimensions=("tract", "patch", "skymap", "visit", "instrument"), 

87 deferLoad=True, 

88 multiple=True, 

89 ) 

90 skyMap = pipeBase.connectionTypes.Input( 

91 doc="Input definition of geometry/bbox and projection/wcs for coadded " "exposures", 

92 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME, 

93 storageClass="SkyMap", 

94 dimensions=("skymap",), 

95 ) 

96 selectedVisits = pipeBase.connectionTypes.Input( 

97 doc="Selected visits to be coadded.", 

98 name="{outputCoaddName}Visits", 

99 storageClass="StructuredDataDict", 

100 dimensions=("instrument", "tract", "patch", "skymap", "band"), 

101 ) 

102 brightObjectMask = pipeBase.connectionTypes.PrerequisiteInput( 

103 doc=( 

104 "Input Bright Object Mask mask produced with external catalogs " 

105 "to be applied to the mask plane BRIGHT_OBJECT." 

106 ), 

107 name="brightObjectMask", 

108 storageClass="ObjectMaskCatalog", 

109 dimensions=("tract", "patch", "skymap", "band"), 

110 minimum=0, 

111 ) 

112 coaddExposure = pipeBase.connectionTypes.Output( 

113 doc="Output coadded exposure, produced by stacking input warps", 

114 name="{outputCoaddName}Coadd{warpTypeSuffix}", 

115 storageClass="ExposureF", 

116 dimensions=("tract", "patch", "skymap", "band"), 

117 ) 

118 nImage = pipeBase.connectionTypes.Output( 

119 doc="Output image of number of input images per pixel", 

120 name="{outputCoaddName}Coadd_nImage", 

121 storageClass="ImageU", 

122 dimensions=("tract", "patch", "skymap", "band"), 

123 ) 

124 inputMap = pipeBase.connectionTypes.Output( 

125 doc="Output healsparse map of input images", 

126 name="{outputCoaddName}Coadd_inputMap", 

127 storageClass="HealSparseMap", 

128 dimensions=("tract", "patch", "skymap", "band"), 

129 ) 

130 

131 def __init__(self, *, config=None): 

132 super().__init__(config=config) 

133 

134 if not config.doMaskBrightObjects: 

135 self.prerequisiteInputs.remove("brightObjectMask") 

136 

137 if not config.doSelectVisits: 

138 self.inputs.remove("selectedVisits") 

139 

140 if not config.doNImage: 

141 self.outputs.remove("nImage") 

142 

143 if not self.config.doInputMap: 

144 self.outputs.remove("inputMap") 

145 

146 if not self.config.doWriteArtifactMasks: 

147 self.outputs.remove("artifactMasks") 

148 

149 

150class AssembleCoaddConfig( 

151 CoaddBaseTask.ConfigClass, pipeBase.PipelineTaskConfig, pipelineConnections=AssembleCoaddConnections 

152): 

153 warpType = pexConfig.ChoiceField( 

154 doc="Warp name: one of 'direct' or 'psfMatched'", 

155 dtype=str, 

156 default="direct", 

157 allowed={ 

158 "direct": "Weighted mean of directWarps, with outlier rejection", 

159 "psfMatched": "Weighted mean of PSF-matched warps", 

160 }, 

161 ) 

162 subregionSize = pexConfig.ListField( 

163 dtype=int, 

164 doc="Width, height of stack subregion size; " 

165 "make small enough that a full stack of images will fit into memory " 

166 " at once.", 

167 length=2, 

168 default=(2000, 2000), 

169 ) 

170 statistic = pexConfig.Field( 

171 dtype=str, 

172 doc="Main stacking statistic for aggregating over the epochs.", 

173 default="MEANCLIP", 

174 ) 

175 doOnlineForMean = pexConfig.Field( 

176 dtype=bool, 

177 doc='Perform online coaddition when statistic="MEAN" to save memory?', 

178 default=False, 

179 ) 

180 sigmaClip = pexConfig.Field( 

181 dtype=float, 

182 doc="Sigma for outlier rejection; ignored if non-clipping statistic " "selected.", 

183 default=3.0, 

184 ) 

185 clipIter = pexConfig.Field( 

186 dtype=int, 

187 doc="Number of iterations of outlier rejection; ignored if " "non-clipping statistic selected.", 

188 default=2, 

189 ) 

190 calcErrorFromInputVariance = pexConfig.Field( 

191 dtype=bool, 

192 doc="Calculate coadd variance from input variance by stacking " 

193 "statistic. Passed to " 

194 "StatisticsControl.setCalcErrorFromInputVariance()", 

195 default=True, 

196 ) 

197 doScaleZeroPoint = pexConfig.Field( 

198 dtype=bool, 

199 doc="Scale the photometric zero point of the coadd temp exposures " 

200 "such that the magnitude zero point results in a flux in nJy.", 

201 default=False, 

202 deprecated="Now that visits are scaled to nJy it is no longer necessary or " 

203 "recommended to scale the zero point, so this will be removed " 

204 "after v29.", 

205 ) 

206 scaleZeroPoint = pexConfig.ConfigurableField( 

207 target=ScaleZeroPointTask, 

208 doc="Task to adjust the photometric zero point of the coadd temp " "exposures", 

209 deprecated="Now that visits are scaled to nJy it is no longer necessary or " 

210 "recommended to scale the zero point, so this will be removed " 

211 "after v29.", 

212 ) 

213 doInterp = pexConfig.Field( 

214 doc="Interpolate over NaN pixels? Also extrapolate, if necessary, but " "the results are ugly.", 

215 dtype=bool, 

216 default=True, 

217 ) 

218 interpImage = pexConfig.ConfigurableField( 

219 target=InterpImageTask, 

220 doc="Task to interpolate (and extrapolate) over NaN pixels", 

221 ) 

222 doWrite = pexConfig.Field( 

223 doc="Persist coadd?", 

224 dtype=bool, 

225 default=True, 

226 ) 

227 doWriteArtifactMasks = pexConfig.Field( 

228 doc="Persist artifact masks? Should be True for CompareWarp only.", 

229 dtype=bool, 

230 default=False, 

231 ) 

232 doNImage = pexConfig.Field( 

233 doc="Create image of number of contributing exposures for each pixel", 

234 dtype=bool, 

235 default=False, 

236 ) 

237 maskPropagationThresholds = pexConfig.DictField( 

238 keytype=str, 

239 itemtype=float, 

240 doc=( 

241 "Threshold (in fractional weight) of rejection at which we " 

242 "propagate a mask plane to the coadd; that is, we set the mask " 

243 "bit on the coadd if the fraction the rejected frames " 

244 "would have contributed exceeds this value." 

245 ), 

246 default={"SAT": 0.1}, 

247 ) 

248 removeMaskPlanes = pexConfig.ListField( 

249 dtype=str, 

250 default=["NOT_DEBLENDED"], 

251 doc="Mask planes to remove before coadding", 

252 ) 

253 doMaskBrightObjects = pexConfig.Field( 

254 dtype=bool, 

255 default=False, 

256 doc="Set mask and flag bits for bright objects?", 

257 ) 

258 brightObjectMaskName = pexConfig.Field( 

259 dtype=str, 

260 default="BRIGHT_OBJECT", 

261 doc="Name of mask bit used for bright objects", 

262 ) 

263 coaddPsf = pexConfig.ConfigField( 

264 doc="Configuration for CoaddPsf", 

265 dtype=measAlg.CoaddPsfConfig, 

266 ) 

267 doAttachTransmissionCurve = pexConfig.Field( 

268 dtype=bool, 

269 default=False, 

270 optional=False, 

271 doc=( 

272 "Attach a piecewise TransmissionCurve for the coadd? " 

273 "(requires all input Exposures to have TransmissionCurves)." 

274 ), 

275 ) 

276 hasFakes = pexConfig.Field( 

277 dtype=bool, 

278 default=False, 

279 doc="Should be set to True if fake sources have been inserted into the input data.", 

280 ) 

281 doSelectVisits = pexConfig.Field( 

282 doc="Coadd only visits selected by a SelectVisitsTask", 

283 dtype=bool, 

284 default=False, 

285 ) 

286 doInputMap = pexConfig.Field( 

287 doc="Create a bitwise map of coadd inputs", 

288 dtype=bool, 

289 default=False, 

290 ) 

291 inputMapper = pexConfig.ConfigurableField( 

292 doc="Input map creation subtask.", 

293 target=HealSparseInputMapTask, 

294 ) 

295 

296 def setDefaults(self): 

297 super().setDefaults() 

298 self.badMaskPlanes = ["NO_DATA", "BAD", "SAT", "EDGE"] 

299 self.coaddPsf.cacheSize = 0 

300 

301 def validate(self): 

302 super().validate() 

303 if self.doInterp and self.statistic not in ["MEAN", "MEDIAN", "MEANCLIP", "VARIANCE", "VARIANCECLIP"]: 

304 raise pexConfig.FieldValidationError( 

305 self.__class__.doInterp, 

306 self, 

307 f"Must set doInterp=False for statistic={self.statistic}, which does not " 

308 "compute and set a non-zero coadd variance estimate.", 

309 ) 

310 

311 unstackableStats = ["NOTHING", "ERROR", "ORMASK"] 

312 if not hasattr(afwMath.Property, self.statistic) or self.statistic in unstackableStats: 

313 stackableStats = [ 

314 str(k) for k in afwMath.Property.__members__.keys() if str(k) not in unstackableStats 

315 ] 

316 raise pexConfig.FieldValidationError( 

317 self.__class__.statistic, 

318 self, 

319 f"statistic {self.statistic} is not allowed. Please choose one of {stackableStats}.", 

320 ) 

321 

322 # Admittedly, it's odd for a parent class to condition on a child class 

323 # but such is the case until the CompareWarp refactor in DM-38630. 

324 if self.doWriteArtifactMasks and not isinstance(self, CompareWarpAssembleCoaddConfig): 

325 raise pexConfig.FieldValidationError( 

326 self.__class__.doWriteArtifactMasks, 

327 self, 

328 "doWriteArtifactMasks is only valid for CompareWarpAssembleCoaddConfig.", 

329 ) 

330 

331 

332class AssembleCoaddTask(CoaddBaseTask, pipeBase.PipelineTask): 

333 """Assemble a coadded image from a set of warps. 

334 

335 Each Warp that goes into a coadd will have its flux calibrated to 

336 nJy. WarpType may be one of 'direct' or 

337 'psfMatched', and the boolean configs `config.makeDirect` and 

338 `config.makePsfMatched` set which of the warp types will be coadded. 

339 The coadd is computed as a mean with optional outlier rejection. 

340 Criteria for outlier rejection are set in `AssembleCoaddConfig`. 

341 Finally, Warps can have bad 'NaN' pixels which received no input from the 

342 source calExps. We interpolate over these bad (NaN) pixels. 

343 

344 `AssembleCoaddTask` uses several sub-tasks. These are 

345 

346 - `~lsst.pipe.tasks.ScaleZeroPointTask` 

347 - create and use an ``imageScaler`` object to scale the photometric 

348 zeropoint for each Warp (deprecated and will be removed in DM-49083). 

349 - `~lsst.pipe.tasks.InterpImageTask` 

350 - interpolate across bad pixels (NaN) in the final coadd 

351 

352 You can retarget these subtasks if you wish. 

353 

354 Raises 

355 ------ 

356 RuntimeError 

357 Raised if unable to define mask plane for bright objects. 

358 

359 Notes 

360 ----- 

361 Debugging: 

362 `AssembleCoaddTask` has no debug variables of its own. Some of the 

363 subtasks may support `~lsst.base.lsstDebug` variables. See the 

364 documentation for the subtasks for further information. 

365 """ 

366 

367 ConfigClass = AssembleCoaddConfig 

368 _DefaultName = "assembleCoadd" 

369 

370 _doUsePsfMatchedPolygons: bool = False 

371 """Use ValidPolygons from shrunk Psf-Matched Calexps? 

372 

373 This needs to be set to True by child classes that use compare Psf-Matched 

374 warps to non Psf-Matched warps. 

375 """ 

376 

377 def __init__(self, *args, **kwargs): 

378 # TODO: DM-17415 better way to handle previously allowed passed args 

379 # e.g.`AssembleCoaddTask(config)` 

380 if args: 

381 argNames = ["config", "name", "parentTask", "log"] 

382 kwargs.update({k: v for k, v in zip(argNames, args)}) 

383 warnings.warn( 

384 "AssembleCoadd received positional args, and casting them as kwargs: %s. " 

385 "PipelineTask will not take positional args" % argNames, 

386 FutureWarning, 

387 stacklevel=2, 

388 ) 

389 

390 super().__init__(**kwargs) 

391 self.makeSubtask("interpImage") 

392 if self.config.doScaleZeroPoint: 

393 # Remove completely in DM-49083 

394 self.makeSubtask("scaleZeroPoint") 

395 

396 if self.config.doMaskBrightObjects: 

397 mask = afwImage.Mask() 

398 try: 

399 self.brightObjectBitmask = 1 << mask.addMaskPlane(self.config.brightObjectMaskName) 

400 except pexExceptions.LsstCppException: 

401 raise RuntimeError( 

402 "Unable to define mask plane for bright objects; planes used are %s" 

403 % mask.getMaskPlaneDict().keys() 

404 ) 

405 del mask 

406 

407 if self.config.doInputMap: 

408 self.makeSubtask("inputMapper") 

409 

410 self.warpType = self.config.warpType 

411 

412 def runQuantum(self, butlerQC, inputRefs, outputRefs): 

413 inputData = butlerQC.get(inputRefs) 

414 

415 # Construct skyInfo expected by run 

416 # Do not remove skyMap from inputData in case _makeSupplementaryData 

417 # needs it 

418 skyMap = inputData["skyMap"] 

419 outputDataId = butlerQC.quantum.dataId 

420 

421 inputData["skyInfo"] = makeSkyInfo( 

422 skyMap, tractId=outputDataId["tract"], patchId=outputDataId["patch"] 

423 ) 

424 

425 if self.config.doSelectVisits: 

426 warpRefList = self.filterWarps(inputData["inputWarps"], inputData["selectedVisits"]) 

427 else: 

428 warpRefList = inputData["inputWarps"] 

429 

430 # Although psfMatchedWarps are specifically required by only by a 

431 # specific set of a subclass, and since runQuantum is not overridden, 

432 # the selection and filtering must happen here on a conditional basis. 

433 # Otherwise, the elements in the various lists will not line up. 

434 # This design cries out for a refactor, which is planned in DM-38630. 

435 if self._doUsePsfMatchedPolygons: 

436 if self.config.doSelectVisits: 

437 psfMatchedWarpRefList = self.filterWarps( 

438 inputData["psfMatchedWarps"], 

439 inputData["selectedVisits"], 

440 ) 

441 else: 

442 psfMatchedWarpRefList = inputData["psfMatchedWarps"] 

443 else: 

444 psfMatchedWarpRefList = [] 

445 

446 inputs = self.prepareInputs(warpRefList, inputData["skyInfo"].bbox, psfMatchedWarpRefList) 

447 self.log.info("Found %d %s", len(inputs.warpRefList), self.getTempExpDatasetName(self.warpType)) 

448 if len(inputs.warpRefList) == 0: 

449 raise pipeBase.NoWorkFound("No coadd temporary exposures found") 

450 

451 supplementaryData = self._makeSupplementaryData(butlerQC, inputRefs, outputRefs) 

452 retStruct = self.run( 

453 inputData["skyInfo"], 

454 warpRefList=inputs.warpRefList, 

455 imageScalerList=inputs.imageScalerList, 

456 weightList=inputs.weightList, 

457 psfMatchedWarpRefList=inputs.psfMatchedWarpRefList, 

458 supplementaryData=supplementaryData, 

459 ) 

460 

461 inputData.setdefault("brightObjectMask", None) 

462 if self.config.doMaskBrightObjects and inputData["brightObjectMask"] is None: 

463 log.warning("doMaskBrightObjects is set to True, but brightObjectMask not loaded") 

464 self.processResults(retStruct.coaddExposure, inputData["brightObjectMask"], outputDataId) 

465 

466 if self.config.doWriteArtifactMasks: 

467 artifactMasksRefList = reorderAndPadList( 

468 outputRefs.artifactMasks, 

469 [ref.dataId for ref in outputRefs.artifactMasks], 

470 [ref.dataId for ref in inputs.warpRefList], 

471 ) 

472 for altMask, warpRef, outputRef in zip( 

473 retStruct.altMaskList, inputs.warpRefList, artifactMasksRefList, strict=True 

474 ): 

475 mask = warpRef.get(component="mask", parameters={"bbox": retStruct.coaddExposure.getBBox()}) 

476 self.applyAltMaskPlanes(mask, altMask) 

477 butlerQC.put(mask, outputRef) 

478 

479 if self.config.doWrite: 

480 butlerQC.put(retStruct, outputRefs) 

481 

482 return retStruct 

483 

484 def processResults(self, coaddExposure, brightObjectMasks=None, dataId=None): 

485 """Interpolate over missing data and mask bright stars. 

486 

487 Parameters 

488 ---------- 

489 coaddExposure : `lsst.afw.image.Exposure` 

490 The coadded exposure to process. 

491 brightObjectMasks : `lsst.afw.table` or `None`, optional 

492 Table of bright objects to mask. 

493 dataId : `lsst.daf.butler.DataId` or `None`, optional 

494 Data identification. 

495 """ 

496 if self.config.doInterp: 

497 self.interpImage.run(coaddExposure.getMaskedImage(), planeName="NO_DATA") 

498 # The variance must be positive; work around for DM-3201. 

499 varArray = coaddExposure.variance.array 

500 with numpy.errstate(invalid="ignore"): 

501 varArray[:] = numpy.where(varArray > 0, varArray, numpy.inf) 

502 

503 if self.config.doMaskBrightObjects: 

504 self.setBrightObjectMasks(coaddExposure, brightObjectMasks, dataId) 

505 

506 def _makeSupplementaryData(self, butlerQC, inputRefs, outputRefs): 

507 """Make additional inputs to run() specific to subclasses (Gen3). 

508 

509 Duplicates interface of `runQuantum` method. 

510 Available to be implemented by subclasses only if they need the 

511 coadd dataRef/handle for performing preliminary processing before 

512 assembling the coadd. 

513 

514 Parameters 

515 ---------- 

516 butlerQC : `~lsst.pipe.base.QuantumContext` 

517 Gen3 Butler object for fetching additional data products before 

518 running the Task specialized for quantum being processed. 

519 inputRefs : `~lsst.pipe.base.InputQuantizedConnection` 

520 Attributes are the names of the connections describing input 

521 dataset types. Values are DatasetRefs that task consumes for the 

522 corresponding dataset type. DataIds are guaranteed to match data 

523 objects in ``inputData``. 

524 outputRefs : `~lsst.pipe.base.OutputQuantizedConnection` 

525 Attributes are the names of the connections describing output 

526 dataset types. Values are DatasetRefs that task is to produce 

527 for the corresponding dataset type. 

528 """ 

529 return pipeBase.Struct() 

530 

531 def prepareInputs(self, refList, coadd_bbox, psfMatchedWarpRefList=None): 

532 """Prepare the input warps for coaddition by measuring the weight for 

533 each warp. 

534 

535 Before coadding these Warps together compute the weight for each 

536 Warp. 

537 

538 Parameters 

539 ---------- 

540 refList : `list` 

541 List of dataset handles (data references) to warp. 

542 psfMatchedWarpRefList : `list` | None, optional 

543 List of dataset handles (data references) to psfMatchedWarp. 

544 

545 Returns 

546 ------- 

547 result : `~lsst.pipe.base.Struct` 

548 Results as a struct with attributes: 

549 

550 ``warpRefList`` 

551 `list` of dataset handles (data references) to warp. 

552 ``weightList`` 

553 `list` of weightings. 

554 ``imageScalerList`` 

555 `list` of image scalers. 

556 Deprecated and will be removed in DM-49083. 

557 """ 

558 statsCtrl = afwMath.StatisticsControl() 

559 statsCtrl.setNumSigmaClip(self.config.sigmaClip) 

560 statsCtrl.setNumIter(self.config.clipIter) 

561 statsCtrl.setAndMask(self.getBadPixelMask()) 

562 statsCtrl.setNanSafe(True) 

563 # compute warpRefList: a list of warpRef that actually exist 

564 # and weightList: a list of the weight of the associated coadd warp 

565 # and imageScalerList: a list of scale factors for the associated coadd 

566 # warp. (deprecated and will be removed in DM-49083) 

567 warpRefList = [] 

568 weightList = [] 

569 # Remove in DM-49083 

570 imageScalerList = [] 

571 outputPsfMatchedWarpRefList = [] 

572 

573 # Convert psfMatchedWarpRefList to a dict, so we can look them up by 

574 # their dataId. 

575 # Note: Some warps may not have a corresponding psfMatchedWarp, which 

576 # could have happened due to a failure in the PSF matching, or not 

577 # having enough good pixels to support the PSF-matching kernel. 

578 

579 if psfMatchedWarpRefList is None: 

580 psfMatchedWarpRefDict = {ref.dataId: None for ref in refList} 

581 else: 

582 psfMatchedWarpRefDict = {ref.dataId: ref for ref in psfMatchedWarpRefList} 

583 

584 warpName = self.getTempExpDatasetName(self.warpType) 

585 for warpRef in refList: 

586 warp = warpRef.get(parameters={"bbox": coadd_bbox}) 

587 # Ignore any input warp that is empty of data 

588 if numpy.isnan(warp.image.array).all(): 

589 continue 

590 maskedImage = warp.getMaskedImage() 

591 

592 # Allow users to scale the zero point for backward 

593 # compatibility. Remove imageScalarList in DM-49083. 

594 if self.config.doScaleZeroPoint: 

595 imageScaler = self.scaleZeroPoint.computeImageScaler( 

596 exposure=warp, 

597 dataRef=warpRef, # FIXME 

598 ) 

599 try: 

600 imageScaler.scaleMaskedImage(maskedImage) 

601 except Exception as e: 

602 self.log.warning("Scaling failed for %s (skipping it): %s", warpRef.dataId, e) 

603 continue 

604 imageScalerList.append(imageScaler) 

605 else: 

606 imageScalerList.append(None) 

607 if "BUNIT" not in warp.metadata: 

608 raise ValueError(f"Warp {warpRef.dataId} has no BUNIT metadata") 

609 if warp.metadata["BUNIT"] != "nJy": 

610 raise ValueError( 

611 f"Warp {warpRef.dataId} has BUNIT {warp.metadata['BUNIT']}, expected nJy" 

612 ) 

613 statObj = afwMath.makeStatistics( 

614 maskedImage.getVariance(), maskedImage.getMask(), afwMath.MEANCLIP, statsCtrl 

615 ) 

616 meanVar, meanVarErr = statObj.getResult(afwMath.MEANCLIP) 

617 if meanVar > 0.0 and numpy.isfinite(meanVar): 

618 weight = 1.0 / float(meanVar) 

619 self.log.info("Weight of %s %s = %0.3f", warpName, warpRef.dataId, weight) 

620 else: 

621 self.log.warning("Non-finite weight for %s: skipping", warpRef.dataId) 

622 continue 

623 

624 del maskedImage 

625 del warp 

626 

627 warpRefList.append(warpRef) 

628 weightList.append(weight) 

629 

630 dataId = warpRef.dataId 

631 psfMatchedWarpRef = psfMatchedWarpRefDict.get(dataId, None) 

632 outputPsfMatchedWarpRefList.append(psfMatchedWarpRef) 

633 

634 return pipeBase.Struct( 

635 warpRefList=warpRefList, 

636 weightList=weightList, 

637 imageScalerList=imageScalerList, 

638 psfMatchedWarpRefList=outputPsfMatchedWarpRefList, 

639 ) 

640 

641 def prepareStats(self, mask=None): 

642 """Prepare the statistics for coadding images. 

643 

644 Parameters 

645 ---------- 

646 mask : `int`, optional 

647 Bit mask value to exclude from coaddition. 

648 

649 Returns 

650 ------- 

651 stats : `~lsst.pipe.base.Struct` 

652 Statistics as a struct with attributes: 

653 

654 ``statsCtrl`` 

655 Statistics control object for coadd 

656 (`~lsst.afw.math.StatisticsControl`). 

657 ``statsFlags`` 

658 Statistic for coadd (`~lsst.afw.math.Property`). 

659 """ 

660 if mask is None: 

661 mask = self.getBadPixelMask() 

662 statsCtrl = afwMath.StatisticsControl() 

663 statsCtrl.setNumSigmaClip(self.config.sigmaClip) 

664 statsCtrl.setNumIter(self.config.clipIter) 

665 statsCtrl.setAndMask(mask) 

666 statsCtrl.setNanSafe(True) 

667 statsCtrl.setWeighted(True) 

668 statsCtrl.setCalcErrorFromInputVariance(self.config.calcErrorFromInputVariance) 

669 for plane, threshold in self.config.maskPropagationThresholds.items(): 

670 bit = afwImage.Mask.getMaskPlane(plane) 

671 statsCtrl.setMaskPropagationThreshold(bit, threshold) 

672 statsFlags = afwMath.stringToStatisticsProperty(self.config.statistic) 

673 return pipeBase.Struct(ctrl=statsCtrl, flags=statsFlags) 

674 

675 @timeMethod 

676 def run( 

677 self, 

678 skyInfo, 

679 *, 

680 warpRefList, 

681 imageScalerList, 

682 weightList, 

683 psfMatchedWarpRefList=None, 

684 altMaskList=None, 

685 mask=None, 

686 supplementaryData=None, 

687 ): 

688 """Assemble a coadd from input warps. 

689 

690 Assemble the coadd using the provided list of coaddTempExps. Since 

691 the full coadd covers a patch (a large area), the assembly is 

692 performed over small areas on the image at a time in order to 

693 conserve memory usage. Iterate over subregions within the outer 

694 bbox of the patch using `assembleSubregion` to stack the corresponding 

695 subregions from the coaddTempExps with the statistic specified. 

696 Set the edge bits the coadd mask based on the weight map. 

697 

698 Parameters 

699 ---------- 

700 skyInfo : `~lsst.pipe.base.Struct` 

701 Struct with geometric information about the patch. 

702 warpRefList : `list` 

703 List of dataset handles (data references) to Warps 

704 (previously called CoaddTempExps). 

705 imageScalerList : `list` 

706 List of image scalers. Deprecated and will be removed after v29 

707 in DM-49083. 

708 weightList : `list` 

709 List of weights. 

710 psfMatchedWarpRefList : `list`, optional 

711 List of dataset handles (data references) to psfMatchedWarps. 

712 altMaskList : `list`, optional 

713 List of alternate masks to use rather than those stored with 

714 warp. 

715 mask : `int`, optional 

716 Bit mask value to exclude from coaddition. 

717 supplementaryData : `~lsst.pipe.base.Struct`, optional 

718 Struct with additional data products needed to assemble coadd. 

719 Only used by subclasses that implement ``_makeSupplementaryData`` 

720 and override `run`. 

721 

722 Returns 

723 ------- 

724 result : `~lsst.pipe.base.Struct` 

725 Results as a struct with attributes: 

726 

727 ``coaddExposure`` 

728 Coadded exposure (`~lsst.afw.image.Exposure`). 

729 ``nImage`` 

730 Exposure count image (`~lsst.afw.image.Image`), if requested. 

731 ``inputMap`` 

732 Bit-wise map of inputs, if requested. 

733 ``warpRefList`` 

734 Input list of dataset handles (data refs) to the warps 

735 (`~lsst.daf.butler.DeferredDatasetHandle`) (unmodified). 

736 ``imageScalerList`` 

737 Input list of image scalers (`list`) (unmodified). 

738 Deprecated and will be removed after v29 in DM-49083. 

739 ``weightList`` 

740 Input list of weights (`list`) (unmodified). 

741 

742 Raises 

743 ------ 

744 lsst.pipe.base.NoWorkFound 

745 Raised if no dataset handles (data references) are provided. 

746 """ 

747 warpName = self.getTempExpDatasetName(self.warpType) 

748 self.log.info("Assembling %s %s", len(warpRefList), warpName) 

749 if not warpRefList: 

750 raise pipeBase.NoWorkFound("No exposures provided for co-addition.") 

751 

752 stats = self.prepareStats(mask=mask) 

753 

754 if altMaskList is None: 

755 altMaskList = [None] * len(warpRefList) 

756 

757 coaddExposure = afwImage.ExposureF(skyInfo.bbox, skyInfo.wcs) 

758 # Deprecated, keep only the `else` branch in DM-49083. 

759 if self.config.doScaleZeroPoint: 

760 coaddExposure.setPhotoCalib(self.scaleZeroPoint.getPhotoCalib()) 

761 else: 

762 coaddExposure.setPhotoCalib(afwImage.PhotoCalib(1.0)) 

763 coaddExposure.getInfo().setCoaddInputs(self.inputRecorder.makeCoaddInputs()) 

764 self.assembleMetadata(coaddExposure, warpRefList, weightList, psfMatchedWarpRefList) 

765 coaddMaskedImage = coaddExposure.getMaskedImage() 

766 subregionSizeArr = self.config.subregionSize 

767 subregionSize = geom.Extent2I(subregionSizeArr[0], subregionSizeArr[1]) 

768 # if nImage is requested, create a zero one which can be passed to 

769 # assembleSubregion. 

770 if self.config.doNImage: 

771 nImage = afwImage.ImageU(skyInfo.bbox) 

772 else: 

773 nImage = None 

774 # If inputMap is requested, create the initial version that can be 

775 # masked in assembleSubregion. 

776 if self.config.doInputMap: 

777 self.inputMapper.build_ccd_input_map( 

778 skyInfo.bbox, skyInfo.wcs, coaddExposure.getInfo().getCoaddInputs().ccds 

779 ) 

780 

781 if self.config.doOnlineForMean and self.config.statistic == "MEAN": 

782 try: 

783 self.assembleOnlineMeanCoadd( 

784 coaddExposure, 

785 warpRefList, 

786 imageScalerList, 

787 weightList, 

788 altMaskList, 

789 stats.ctrl, 

790 nImage=nImage, 

791 ) 

792 except Exception as e: 

793 self.log.exception("Cannot compute online coadd %s", e) 

794 raise 

795 else: 

796 for subBBox in subBBoxIter(skyInfo.bbox, subregionSize): 

797 try: 

798 self.assembleSubregion( 

799 coaddExposure, 

800 subBBox, 

801 warpRefList, 

802 imageScalerList, 

803 weightList, 

804 altMaskList, 

805 stats.flags, 

806 stats.ctrl, 

807 nImage=nImage, 

808 ) 

809 except Exception as e: 

810 self.log.exception("Cannot compute coadd %s: %s", subBBox, e) 

811 raise 

812 

813 # If inputMap is requested, we must finalize the map after the 

814 # accumulation. 

815 if self.config.doInputMap: 

816 self.inputMapper.finalize_ccd_input_map_mask() 

817 inputMap = self.inputMapper.ccd_input_map 

818 else: 

819 inputMap = None 

820 

821 self.setInexactPsf(coaddMaskedImage.getMask()) 

822 # Despite the name, the following doesn't really deal with "EDGE" 

823 # pixels: it identifies pixels that didn't receive any unmasked inputs 

824 # (as occurs around the edge of the field). 

825 coaddUtils.setCoaddEdgeBits(coaddMaskedImage.getMask(), coaddMaskedImage.getVariance()) 

826 return pipeBase.Struct( 

827 coaddExposure=coaddExposure, 

828 nImage=nImage, 

829 warpRefList=warpRefList, 

830 imageScalerList=imageScalerList, 

831 weightList=weightList, 

832 inputMap=inputMap, 

833 altMaskList=altMaskList, 

834 ) 

835 

836 def assembleMetadata(self, coaddExposure, warpRefList, weightList, psfMatchedWarpRefList=None): 

837 """Set the metadata for the coadd. 

838 

839 This basic implementation sets the filter from the first input. 

840 

841 Parameters 

842 ---------- 

843 coaddExposure : `lsst.afw.image.Exposure` 

844 The target exposure for the coadd. 

845 warpRefList : `list` 

846 List of dataset handles (data references) to warp. 

847 weightList : `list` 

848 List of weights. 

849 psfMatchedWarpRefList : `list` | None, optional 

850 List of dataset handles (data references) to psfMatchedWarps. 

851 

852 Raises 

853 ------ 

854 AssertionError 

855 Raised if there is a length mismatch. 

856 """ 

857 assert len(warpRefList) == len(weightList), "Length mismatch" 

858 

859 if psfMatchedWarpRefList: 

860 assert len(warpRefList) == len(psfMatchedWarpRefList), "Length mismatch" 

861 

862 # We load a single pixel of each coaddTempExp, because we just want to 

863 # get at the metadata (and we need more than just the PropertySet that 

864 # contains the header), which is not possible with the current butler 

865 # (see #2777). 

866 bbox = geom.Box2I(coaddExposure.getBBox().getMin(), geom.Extent2I(1, 1)) 

867 

868 warpList = [warpRef.get(parameters={"bbox": bbox}) for warpRef in warpRefList] 

869 

870 numCcds = sum(len(warp.getInfo().getCoaddInputs().ccds) for warp in warpList) 

871 

872 # Set the coadd FilterLabel to the band of the first input exposure: 

873 # Coadds are calibrated, so the physical label is now meaningless. 

874 coaddExposure.setFilter(afwImage.FilterLabel(warpList[0].getFilter().bandLabel)) 

875 coaddInputs = coaddExposure.getInfo().getCoaddInputs() 

876 coaddInputs.ccds.reserve(numCcds) 

877 coaddInputs.visits.reserve(len(warpList)) 

878 

879 # Set the exposure units to nJy 

880 # No need to check after DM-49083. 

881 if not self.config.doScaleZeroPoint: 

882 coaddExposure.metadata["BUNIT"] = "nJy" 

883 

884 # psfMatchedWarpRefList should be empty except in CompareWarpCoadd. 

885 if self._doUsePsfMatchedPolygons: 

886 # Set validPolygons for warp before addVisitToCoadd 

887 self._setValidPolygons(warpList, psfMatchedWarpRefList) 

888 

889 for warp, weight in zip(warpList, weightList): 

890 self.inputRecorder.addVisitToCoadd(coaddInputs, warp, weight) 

891 

892 coaddInputs.visits.sort() 

893 coaddInputs.ccds.sort() 

894 if self.warpType == "psfMatched": 

895 # The modelPsf BBox for a psfMatchedWarp/coaddTempExp was 

896 # dynamically defined by ModelPsfMatchTask as the square box 

897 # bounding its spatially-variable, pre-matched WarpedPsf. 

898 # Likewise, set the PSF of a PSF-Matched Coadd to the modelPsf 

899 # having the maximum width (sufficient because square) 

900 modelPsfList = [warp.getPsf() for warp in warpList] 

901 modelPsfWidthList = [ 

902 modelPsf.computeBBox(modelPsf.getAveragePosition()).getWidth() for modelPsf in modelPsfList 

903 ] 

904 psf = modelPsfList[modelPsfWidthList.index(max(modelPsfWidthList))] 

905 else: 

906 psf = measAlg.CoaddPsf( 

907 coaddInputs.ccds, coaddExposure.getWcs(), self.config.coaddPsf.makeControl() 

908 ) 

909 coaddExposure.setPsf(psf) 

910 apCorrMap = measAlg.makeCoaddApCorrMap( 

911 coaddInputs.ccds, coaddExposure.getBBox(afwImage.PARENT), coaddExposure.getWcs() 

912 ) 

913 coaddExposure.getInfo().setApCorrMap(apCorrMap) 

914 if self.config.doAttachTransmissionCurve: 

915 transmissionCurve = measAlg.makeCoaddTransmissionCurve(coaddExposure.getWcs(), coaddInputs.ccds) 

916 coaddExposure.getInfo().setTransmissionCurve(transmissionCurve) 

917 

918 def assembleSubregion( 

919 self, 

920 coaddExposure, 

921 bbox, 

922 warpRefList, 

923 imageScalerList, 

924 weightList, 

925 altMaskList, 

926 statsFlags, 

927 statsCtrl, 

928 nImage=None, 

929 ): 

930 """Assemble the coadd for a sub-region. 

931 

932 For each coaddTempExp, check for (and swap in) an alternative mask 

933 if one is passed. Remove mask planes listed in 

934 `config.removeMaskPlanes`. Finally, stack the actual exposures using 

935 `lsst.afw.math.statisticsStack` with the statistic specified by 

936 statsFlags. Typically, the statsFlag will be one of lsst.afw.math.MEAN 

937 for a mean-stack or `lsst.afw.math.MEANCLIP` for outlier rejection 

938 using an N-sigma clipped mean where N and iterations are specified by 

939 statsCtrl. Assign the stacked subregion back to the coadd. 

940 

941 Parameters 

942 ---------- 

943 coaddExposure : `lsst.afw.image.Exposure` 

944 The target exposure for the coadd. 

945 bbox : `lsst.geom.Box` 

946 Sub-region to coadd. 

947 warpRefList : `list` 

948 List of dataset handles (data references) to warp. 

949 imageScalerList : `list` 

950 List of image scalers. 

951 Deprecated and will be removed after v29 in DM-49083. 

952 weightList : `list` 

953 List of weights. 

954 altMaskList : `list` 

955 List of alternate masks to use rather than those stored with 

956 warp, or None. Each element is dict with keys = mask plane 

957 name to which to add the spans. 

958 statsFlags : `lsst.afw.math.Property` 

959 Property object for statistic for coadd. 

960 statsCtrl : `lsst.afw.math.StatisticsControl` 

961 Statistics control object for coadd. 

962 nImage : `lsst.afw.image.ImageU`, optional 

963 Keeps track of exposure count for each pixel. 

964 """ 

965 self.log.info("Stacking subregion %s", bbox) 

966 

967 coaddExposure.mask.addMaskPlane("REJECTED") 

968 coaddExposure.mask.addMaskPlane("CLIPPED") 

969 coaddExposure.mask.addMaskPlane("SENSOR_EDGE") 

970 maskMap = setRejectedMaskMapping(statsCtrl) 

971 clipped = afwImage.Mask.getPlaneBitMask("CLIPPED") 

972 maskedImageList = [] 

973 if nImage is not None: 

974 subNImage = afwImage.ImageU(bbox.getWidth(), bbox.getHeight()) 

975 for warpRef, imageScaler, altMask in zip(warpRefList, imageScalerList, altMaskList): 

976 exposure = warpRef.get(parameters={"bbox": bbox}) 

977 

978 maskedImage = exposure.getMaskedImage() 

979 mask = maskedImage.getMask() 

980 if altMask is not None: 

981 self.applyAltMaskPlanes(mask, altMask) 

982 # Remove in DM-49083 

983 if imageScaler is not None: 

984 imageScaler.scaleMaskedImage(maskedImage) 

985 

986 # Add 1 for each pixel which is not excluded by the exclude mask. 

987 # In legacyCoadd, pixels may also be excluded by 

988 # afwMath.statisticsStack. 

989 if nImage is not None: 

990 subNImage.getArray()[maskedImage.getMask().getArray() & statsCtrl.getAndMask() == 0] += 1 

991 if self.config.removeMaskPlanes: 

992 removeMaskPlanes(maskedImage.mask, self.config.removeMaskPlanes, logger=self.log) 

993 maskedImageList.append(maskedImage) 

994 

995 if self.config.doInputMap: 

996 visit = exposure.getInfo().getCoaddInputs().visits[0].getId() 

997 self.inputMapper.mask_warp_bbox(bbox, visit, mask, statsCtrl.getAndMask()) 

998 

999 with self.timer("stack"): 

1000 coaddSubregion = afwMath.statisticsStack( 

1001 maskedImageList, 

1002 statsFlags, 

1003 statsCtrl, 

1004 weightList, 

1005 clipped, # also set output to CLIPPED if sigma-clipped 

1006 maskMap, 

1007 ) 

1008 coaddExposure.maskedImage.assign(coaddSubregion, bbox) 

1009 if nImage is not None: 

1010 nImage.assign(subNImage, bbox) 

1011 

1012 def assembleOnlineMeanCoadd( 

1013 self, coaddExposure, warpRefList, imageScalerList, weightList, altMaskList, statsCtrl, nImage=None 

1014 ): 

1015 """Assemble the coadd using the "online" method. 

1016 

1017 This method takes a running sum of images and weights to save memory. 

1018 It only works for MEAN statistics. 

1019 

1020 Parameters 

1021 ---------- 

1022 coaddExposure : `lsst.afw.image.Exposure` 

1023 The target exposure for the coadd. 

1024 warpRefList : `list` 

1025 List of dataset handles (data references) to warp. 

1026 imageScalerList : `list` 

1027 List of image scalers. 

1028 Deprecated and will be removed after v29 in DM-49083. 

1029 weightList : `list` 

1030 List of weights. 

1031 altMaskList : `list` 

1032 List of alternate masks to use rather than those stored with 

1033 warp, or None. Each element is dict with keys = mask plane 

1034 name to which to add the spans. 

1035 statsCtrl : `lsst.afw.math.StatisticsControl` 

1036 Statistics control object for coadd. 

1037 nImage : `lsst.afw.image.ImageU`, optional 

1038 Keeps track of exposure count for each pixel. 

1039 """ 

1040 self.log.debug("Computing online coadd.") 

1041 

1042 coaddExposure.mask.addMaskPlane("REJECTED") 

1043 coaddExposure.mask.addMaskPlane("CLIPPED") 

1044 coaddExposure.mask.addMaskPlane("SENSOR_EDGE") 

1045 maskMap = setRejectedMaskMapping(statsCtrl) 

1046 thresholdDict = AccumulatorMeanStack.stats_ctrl_to_threshold_dict(statsCtrl) 

1047 

1048 bbox = coaddExposure.maskedImage.getBBox() 

1049 

1050 stacker = AccumulatorMeanStack( 

1051 coaddExposure.image.array.shape, 

1052 statsCtrl.getAndMask(), 

1053 mask_threshold_dict=thresholdDict, 

1054 mask_map=maskMap, 

1055 no_good_pixels_mask=statsCtrl.getNoGoodPixelsMask(), 

1056 calc_error_from_input_variance=self.config.calcErrorFromInputVariance, 

1057 compute_n_image=(nImage is not None), 

1058 ) 

1059 

1060 for warpRef, imageScaler, altMask, weight in zip( 

1061 warpRefList, imageScalerList, altMaskList, weightList 

1062 ): 

1063 exposure = warpRef.get(parameters={"bbox": bbox}) 

1064 maskedImage = exposure.getMaskedImage() 

1065 mask = maskedImage.getMask() 

1066 if altMask is not None: 

1067 self.applyAltMaskPlanes(mask, altMask) 

1068 # Remove in DM-49083. 

1069 if imageScaler is not None: 

1070 imageScaler.scaleMaskedImage(maskedImage) 

1071 if self.config.removeMaskPlanes: 

1072 removeMaskPlanes(maskedImage.mask, self.config.removeMaskPlanes, logger=self.log) 

1073 

1074 stacker.add_masked_image(maskedImage, weight=weight) 

1075 

1076 if self.config.doInputMap: 

1077 visit = exposure.getInfo().getCoaddInputs().visits[0].getId() 

1078 self.inputMapper.mask_warp_bbox(bbox, visit, mask, statsCtrl.getAndMask()) 

1079 

1080 stacker.fill_stacked_masked_image(coaddExposure.maskedImage) 

1081 

1082 if nImage is not None: 

1083 nImage.array[:, :] = stacker.n_image 

1084 

1085 def applyAltMaskPlanes(self, mask, altMaskSpans): 

1086 """Apply in place alt mask formatted as SpanSets to a mask. 

1087 

1088 Parameters 

1089 ---------- 

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

1091 Original mask. 

1092 altMaskSpans : `dict` 

1093 SpanSet lists to apply. Each element contains the new mask 

1094 plane name (e.g. "CLIPPED and/or "NO_DATA") as the key, 

1095 and list of SpanSets to apply to the mask. 

1096 

1097 Returns 

1098 ------- 

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

1100 Updated mask. 

1101 """ 

1102 if self._doUsePsfMatchedPolygons: 

1103 if ("NO_DATA" in altMaskSpans) and ("NO_DATA" in self.config.badMaskPlanes): 

1104 # Clear away any other masks outside the validPolygons. These 

1105 # pixels are no longer contributing to inexact PSFs, and will 

1106 # still be rejected because of NO_DATA. 

1107 # self._doUsePsfMatchedPolygons should be True only in 

1108 # CompareWarpAssemble. This mask-clearing step must only occur 

1109 # *before* applying the new masks below. 

1110 for spanSet in altMaskSpans["NO_DATA"]: 

1111 spanSet.clippedTo(mask.getBBox()).clearMask(mask, self.getBadPixelMask()) 

1112 

1113 for plane, spanSetList in altMaskSpans.items(): 

1114 maskClipValue = mask.addMaskPlane(plane) 

1115 for spanSet in spanSetList: 

1116 spanSet.clippedTo(mask.getBBox()).setMask(mask, 2**maskClipValue) 

1117 return mask 

1118 

1119 def _setValidPolygons(self, warpList, psfMatchedWarpRefList): 

1120 """Set the valid polygons for the warps the same as psfMatchedWarps, if 

1121 it exists. 

1122 

1123 Parameters 

1124 ---------- 

1125 warpList : `Iterable` [`lsst.afw.image.Exposure`] 

1126 List of warps. 

1127 psfMatchedWarpRefList : `Iterable` \ 

1128 [`lsst.daf.butler.DeferredDatasetHandle`] 

1129 List of references to psfMatchedWarps, in the same order as 

1130 ``warpList``. 

1131 

1132 Raises 

1133 ------ 

1134 ValueError 

1135 Raised if PSF-matched warps have detectors that are absent in the 

1136 (direct) warps. 

1137 """ 

1138 for warp, psfMatchedWarpRef in zip(warpList, psfMatchedWarpRefList): 

1139 if psfMatchedWarpRef is None: 

1140 continue 

1141 

1142 psfMatchedCcdTable = psfMatchedWarpRef.get(component="coaddInputs").ccds 

1143 ccdTable = warp.getInfo().getCoaddInputs().ccds 

1144 

1145 # In some (literal) edge cases, a small part of the CCD may be 

1146 # present in directWarp that gets excluded in psfMatchedWarp. It is 

1147 # okay to leave validPolygon for those CCDs empty. However, the 

1148 # converse is not expected, and an error is raised in that case. 

1149 if not set(psfMatchedCcdTable["id"]).issubset(ccdTable["id"]): 

1150 visit = psfMatchedWarpRef.dataId["visit"] 

1151 raise ValueError(f"PSF-matched warp has additional CCDs for {visit=}") 

1152 

1153 if len(psfMatchedCcdTable) < len(ccdTable): 

1154 self.log.debug( 

1155 "PSF-matched warp has missing CCDs for visit = %d, which leaves some CCDs in the direct " 

1156 "warp without a validPolygon", 

1157 psfMatchedWarpRef.dataId["visit"], 

1158 ) 

1159 

1160 for psfMatchedCcdRow in psfMatchedCcdTable: 

1161 if not psfMatchedCcdRow.validPolygon: 

1162 self.log.warning( 

1163 "No validPolygon in PSF-matched warp found for %s. This is likely due to a mismatch " 

1164 "in the LSST Science Pipelines version used to produce the warps and the current " 

1165 "version. To avoid this warning, regenerate the warps with the current version.", 

1166 psfMatchedCcdRow.id, 

1167 ) 

1168 else: 

1169 ccdRow = ccdTable.find(value=psfMatchedCcdRow.id, key=ccdTable.getIdKey()) 

1170 ccdRow.validPolygon = psfMatchedCcdRow.validPolygon 

1171 

1172 def setBrightObjectMasks(self, exposure, brightObjectMasks, dataId=None): 

1173 """Set the bright object masks. 

1174 

1175 Parameters 

1176 ---------- 

1177 exposure : `lsst.afw.image.Exposure` 

1178 Exposure under consideration. 

1179 brightObjectMasks : `lsst.afw.table` 

1180 Table of bright objects to mask. 

1181 dataId : `lsst.daf.butler.DataId`, optional 

1182 Data identifier dict for patch. 

1183 """ 

1184 if brightObjectMasks is None: 

1185 self.log.warning("Unable to apply bright object mask: none supplied") 

1186 return 

1187 self.log.info("Applying %d bright object masks to %s", len(brightObjectMasks), dataId) 

1188 mask = exposure.getMaskedImage().getMask() 

1189 wcs = exposure.getWcs() 

1190 plateScale = wcs.getPixelScale(exposure.getBBox().getCenter()).asArcseconds() 

1191 

1192 for rec in brightObjectMasks: 

1193 center = geom.PointI(wcs.skyToPixel(rec.getCoord())) 

1194 if rec["type"] == "box": 

1195 assert rec["angle"] == 0.0, "Angle != 0 for mask object %s" % rec["id"] 

1196 width = rec["width"].asArcseconds() / plateScale # convert to pixels 

1197 height = rec["height"].asArcseconds() / plateScale # convert to pixels 

1198 

1199 halfSize = geom.ExtentI(0.5 * width, 0.5 * height) 

1200 bbox = geom.Box2I(center - halfSize, center + halfSize) 

1201 

1202 bbox = geom.BoxI( 

1203 geom.PointI(int(center[0] - 0.5 * width), int(center[1] - 0.5 * height)), 

1204 geom.PointI(int(center[0] + 0.5 * width), int(center[1] + 0.5 * height)), 

1205 ) 

1206 spans = afwGeom.SpanSet(bbox) 

1207 elif rec["type"] == "circle": 

1208 radius = int(rec["radius"].asArcseconds() / plateScale) # convert to pixels 

1209 spans = afwGeom.SpanSet.fromShape(radius, offset=center) 

1210 else: 

1211 self.log.warning("Unexpected region type %s at %s", rec["type"], center) 

1212 continue 

1213 spans.clippedTo(mask.getBBox()).setMask(mask, self.brightObjectBitmask) 

1214 

1215 def setInexactPsf(self, mask): 

1216 """Set INEXACT_PSF mask plane. 

1217 

1218 If any of the input images isn't represented in the coadd (due to 

1219 clipped pixels or chip gaps), the `CoaddPsf` will be inexact. Flag 

1220 these pixels. 

1221 

1222 Parameters 

1223 ---------- 

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

1225 Coadded exposure's mask, modified in-place. 

1226 """ 

1227 mask.addMaskPlane("INEXACT_PSF") 

1228 inexactPsf = mask.getPlaneBitMask("INEXACT_PSF") 

1229 sensorEdge = mask.getPlaneBitMask("SENSOR_EDGE") # chip edges (so PSF is discontinuous) 

1230 clipped = mask.getPlaneBitMask("CLIPPED") # pixels clipped from coadd 

1231 rejected = mask.getPlaneBitMask("REJECTED") # pixels rejected from coadd due to masks 

1232 array = mask.getArray() 

1233 selected = array & (sensorEdge | clipped | rejected) > 0 

1234 array[selected] |= inexactPsf 

1235 

1236 def filterWarps(self, inputs, goodVisits): 

1237 """Return list of only inputRefs with visitId in goodVisits ordered by 

1238 goodVisit. 

1239 

1240 Parameters 

1241 ---------- 

1242 inputs : `list` of `~lsst.pipe.base.connections.DeferredDatasetRef` 

1243 List of `lsst.pipe.base.connections.DeferredDatasetRef` with dataId 

1244 containing visit. 

1245 goodVisit : `dict` 

1246 Dictionary with good visitIds as the keys. Value ignored. 

1247 

1248 Returns 

1249 ------- 

1250 filterInputs : `list` [`lsst.pipe.base.connections.DeferredDatasetRef`] 

1251 Filtered and sorted list of inputRefs with visitId in goodVisits 

1252 ordered by goodVisit. 

1253 """ 

1254 inputWarpDict = {inputRef.ref.dataId["visit"]: inputRef for inputRef in inputs} 

1255 filteredInputs = [] 

1256 for visit in goodVisits.keys(): 

1257 if visit in inputWarpDict: 

1258 filteredInputs.append(inputWarpDict[visit]) 

1259 return filteredInputs 

1260 

1261 

1262def countMaskFromFootprint(mask, footprint, bitmask, ignoreMask): 

1263 """Function to count the number of pixels with a specific mask in a 

1264 footprint. 

1265 

1266 Find the intersection of mask & footprint. Count all pixels in the mask 

1267 that are in the intersection that have bitmask set but do not have 

1268 ignoreMask set. Return the count. 

1269 

1270 Parameters 

1271 ---------- 

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

1273 Mask to define intersection region by. 

1274 footprint : `lsst.afw.detection.Footprint` 

1275 Footprint to define the intersection region by. 

1276 bitmask : `Unknown` 

1277 Specific mask that we wish to count the number of occurances of. 

1278 ignoreMask : `Unknown` 

1279 Pixels to not consider. 

1280 

1281 Returns 

1282 ------- 

1283 result : `int` 

1284 Number of pixels in footprint with specified mask. 

1285 """ 

1286 bbox = footprint.getBBox() 

1287 bbox.clip(mask.getBBox(afwImage.PARENT)) 

1288 fp = afwImage.Mask(bbox) 

1289 subMask = mask.Factory(mask, bbox, afwImage.PARENT) 

1290 footprint.spans.setMask(fp, bitmask) 

1291 return numpy.logical_and( 

1292 (subMask.getArray() & fp.getArray()) > 0, (subMask.getArray() & ignoreMask) == 0 

1293 ).sum() 

1294 

1295 

1296class CompareWarpAssembleCoaddConnections(AssembleCoaddConnections): 

1297 psfMatchedWarps = pipeBase.connectionTypes.Input( 

1298 doc=( 

1299 "PSF-Matched Warps are required by CompareWarp regardless of the coadd type requested. " 

1300 "Only PSF-Matched Warps make sense for image subtraction. " 

1301 "Therefore, they must be an additional declared input." 

1302 ), 

1303 name="{inputCoaddName}Coadd_psfMatchedWarp", 

1304 storageClass="ExposureF", 

1305 dimensions=("tract", "patch", "skymap", "visit"), 

1306 deferLoad=True, 

1307 multiple=True, 

1308 ) 

1309 templateCoadd = pipeBase.connectionTypes.Output( 

1310 doc=( 

1311 "Model of the static sky, used to find temporal artifacts. Typically a PSF-Matched, " 

1312 "sigma-clipped coadd. Written if and only if assembleStaticSkyModel.doWrite=True" 

1313 ), 

1314 name="{outputCoaddName}CoaddPsfMatched", 

1315 storageClass="ExposureF", 

1316 dimensions=("tract", "patch", "skymap", "band"), 

1317 ) 

1318 artifactMasks = pipeBase.connectionTypes.Output( 

1319 doc="Mask of artifacts detected in the coadd", 

1320 name="compare_warp_artifact_mask", 

1321 storageClass="Mask", 

1322 dimensions=("tract", "patch", "skymap", "visit", "instrument"), 

1323 multiple=True, 

1324 ) 

1325 

1326 def __init__(self, *, config=None): 

1327 super().__init__(config=config) 

1328 if not config.assembleStaticSkyModel.doWrite: 

1329 self.outputs.remove("templateCoadd") 

1330 config.validate() 

1331 

1332 

1333class CompareWarpAssembleCoaddConfig( 

1334 AssembleCoaddConfig, pipelineConnections=CompareWarpAssembleCoaddConnections 

1335): 

1336 assembleStaticSkyModel = pexConfig.ConfigurableField( 

1337 target=AssembleCoaddTask, 

1338 doc="Task to assemble an artifact-free, PSF-matched Coadd to serve as " 

1339 "a naive/first-iteration model of the static sky.", 

1340 ) 

1341 detect = pexConfig.ConfigurableField( 

1342 target=SourceDetectionTask, 

1343 doc="Detect outlier sources on difference between each psfMatched warp and static sky model", 

1344 ) 

1345 detectTemplate = pexConfig.ConfigurableField( 

1346 target=SourceDetectionTask, 

1347 doc="Detect sources on static sky model. Only used if doPreserveContainedBySource is True", 

1348 ) 

1349 maskStreaks = pexConfig.ConfigurableField( 

1350 target=MaskStreaksTask, 

1351 doc="Detect streaks on difference between each psfMatched warp and static sky model. Only used if " 

1352 "doFilterMorphological is True. Adds a mask plane to an exposure, with the mask plane name set by" 

1353 "streakMaskName", 

1354 ) 

1355 streakMaskName = pexConfig.Field(dtype=str, default="STREAK", doc="Name of mask bit used for streaks") 

1356 maxNumEpochs = pexConfig.Field( 

1357 doc="Charactistic maximum local number of epochs/visits in which an artifact candidate can appear " 

1358 "and still be masked. The effective maxNumEpochs is a broken linear function of local " 

1359 "number of epochs (N): min(maxFractionEpochsLow*N, maxNumEpochs + maxFractionEpochsHigh*N). " 

1360 "For each footprint detected on the image difference between the psfMatched warp and static sky " 

1361 "model, if a significant fraction of pixels (defined by spatialThreshold) are residuals in more " 

1362 "than the computed effective maxNumEpochs, the artifact candidate is deemed persistant rather " 

1363 "than transient and not masked.", 

1364 dtype=int, 

1365 default=2, 

1366 ) 

1367 maxFractionEpochsLow = pexConfig.RangeField( 

1368 doc="Fraction of local number of epochs (N) to use as effective maxNumEpochs for low N. " 

1369 "Effective maxNumEpochs = " 

1370 "min(maxFractionEpochsLow * N, maxNumEpochs + maxFractionEpochsHigh * N)", 

1371 dtype=float, 

1372 default=0.4, 

1373 min=0.0, 

1374 max=1.0, 

1375 ) 

1376 maxFractionEpochsHigh = pexConfig.RangeField( 

1377 doc="Fraction of local number of epochs (N) to use as effective maxNumEpochs for high N. " 

1378 "Effective maxNumEpochs = " 

1379 "min(maxFractionEpochsLow * N, maxNumEpochs + maxFractionEpochsHigh * N)", 

1380 dtype=float, 

1381 default=0.03, 

1382 min=0.0, 

1383 max=1.0, 

1384 ) 

1385 spatialThreshold = pexConfig.RangeField( 

1386 doc="Unitless fraction of pixels defining how much of the outlier region has to meet the " 

1387 "temporal criteria. If 0, clip all. If 1, clip none.", 

1388 dtype=float, 

1389 default=0.5, 

1390 min=0.0, 

1391 max=1.0, 

1392 inclusiveMin=True, 

1393 inclusiveMax=True, 

1394 ) 

1395 doScaleWarpVariance = pexConfig.Field( 

1396 doc="Rescale Warp variance plane using empirical noise?", 

1397 dtype=bool, 

1398 default=True, 

1399 ) 

1400 scaleWarpVariance = pexConfig.ConfigurableField( 

1401 target=ScaleVarianceTask, 

1402 doc="Rescale variance on warps", 

1403 ) 

1404 doPreserveContainedBySource = pexConfig.Field( 

1405 doc="Rescue artifacts from clipping that completely lie within a footprint detected" 

1406 "on the PsfMatched Template Coadd. Replicates a behavior of SafeClip.", 

1407 dtype=bool, 

1408 default=True, 

1409 ) 

1410 doPrefilterArtifacts = pexConfig.Field( 

1411 doc="Ignore artifact candidates that are mostly covered by the bad pixel mask, " 

1412 "because they will be excluded anyway. This prevents them from contributing " 

1413 "to the outlier epoch count image and potentially being labeled as persistant." 

1414 "'Mostly' is defined by the config 'prefilterArtifactsRatio'.", 

1415 dtype=bool, 

1416 default=True, 

1417 ) 

1418 prefilterArtifactsMaskPlanes = pexConfig.ListField( 

1419 doc="Prefilter artifact candidates that are mostly covered by these bad mask planes.", 

1420 dtype=str, 

1421 default=("NO_DATA", "BAD", "SUSPECT"), 

1422 ) 

1423 prefilterArtifactsRatio = pexConfig.Field( 

1424 doc="Prefilter artifact candidates with less than this fraction overlapping good pixels", 

1425 dtype=float, 

1426 default=0.05, 

1427 ) 

1428 doFilterMorphological = pexConfig.Field( 

1429 doc="Filter artifact candidates based on morphological criteria, i.g. those that appear to " 

1430 "be streaks.", 

1431 dtype=bool, 

1432 default=False, 

1433 ) 

1434 growStreakFp = pexConfig.Field( 

1435 doc="Grow streak footprints by this number multiplied by the PSF width", dtype=float, default=5 

1436 ) 

1437 

1438 def setDefaults(self): 

1439 AssembleCoaddConfig.setDefaults(self) 

1440 self.statistic = "MEAN" 

1441 

1442 # Real EDGE removed by psfMatched NO_DATA border half the width of the 

1443 # matching kernel. CompareWarp applies psfMatched EDGE pixels to 

1444 # directWarps before assembling. 

1445 if "EDGE" in self.badMaskPlanes: 

1446 self.badMaskPlanes.remove("EDGE") 

1447 self.removeMaskPlanes.append("EDGE") 

1448 self.assembleStaticSkyModel.badMaskPlanes = [ 

1449 "NO_DATA", 

1450 ] 

1451 self.assembleStaticSkyModel.warpType = "psfMatched" 

1452 self.assembleStaticSkyModel.connections.warpType = "psfMatched" 

1453 self.assembleStaticSkyModel.statistic = "MEANCLIP" 

1454 self.assembleStaticSkyModel.sigmaClip = 2.5 

1455 self.assembleStaticSkyModel.clipIter = 3 

1456 self.assembleStaticSkyModel.calcErrorFromInputVariance = True 

1457 self.assembleStaticSkyModel.doWrite = False 

1458 self.detect.doTempLocalBackground = False 

1459 self.detect.reEstimateBackground = False 

1460 self.detect.returnOriginalFootprints = False 

1461 self.detect.thresholdPolarity = "both" 

1462 self.detect.thresholdValue = 7.5 

1463 self.detect.minPixels = 4 

1464 self.detect.isotropicGrow = True 

1465 self.detect.thresholdType = "pixel_stdev" 

1466 self.detect.nSigmaToGrow = 0.4 

1467 # The default nSigmaToGrow for SourceDetectionTask is already 2.4, 

1468 # Explicitly restating because ratio with detect.nSigmaToGrow matters 

1469 self.detectTemplate.nSigmaToGrow = 2.4 

1470 # Higher thresholds make smaller and fewer protected zones around 

1471 # bright stars. Sources with snr < 300 tend to subtract OK empirically 

1472 self.detectTemplate.thresholdValue = 300 

1473 self.detectTemplate.doTempLocalBackground = True 

1474 self.detectTemplate.reEstimateBackground = True 

1475 self.detectTemplate.background.useApprox = False 

1476 self.detectTemplate.returnOriginalFootprints = False 

1477 

1478 def validate(self): 

1479 super().validate() 

1480 if self.assembleStaticSkyModel.doNImage: 

1481 raise ValueError( 

1482 "No dataset type exists for a PSF-Matched Template N Image." 

1483 "Please set assembleStaticSkyModel.doNImage=False" 

1484 ) 

1485 

1486 if self.assembleStaticSkyModel.doWrite and (self.warpType == self.assembleStaticSkyModel.warpType): 

1487 raise ValueError( 

1488 "warpType (%s) == assembleStaticSkyModel.warpType (%s) and will compete for " 

1489 "the same dataset name. Please set assembleStaticSkyModel.doWrite to False " 

1490 "or warpType to 'direct'. assembleStaticSkyModel.warpType should ways be " 

1491 "'PsfMatched'" % (self.warpType, self.assembleStaticSkyModel.warpType) 

1492 ) 

1493 

1494 

1495class CompareWarpAssembleCoaddTask(AssembleCoaddTask): 

1496 """Assemble a compareWarp coadded image from a set of warps 

1497 by masking artifacts detected by comparing PSF-matched warps. 

1498 

1499 In ``AssembleCoaddTask``, we compute the coadd as an clipped mean (i.e., 

1500 we clip outliers). The problem with doing this is that when computing the 

1501 coadd PSF at a given location, individual visit PSFs from visits with 

1502 outlier pixels contribute to the coadd PSF and cannot be treated correctly. 

1503 In this task, we correct for this behavior by creating a new badMaskPlane 

1504 'CLIPPED' which marks pixels in the individual warps suspected to contain 

1505 an artifact. We populate this plane on the input warps by comparing 

1506 PSF-matched warps with a PSF-matched median coadd which serves as a 

1507 model of the static sky. Any group of pixels that deviates from the 

1508 PSF-matched template coadd by more than config.detect.threshold sigma, 

1509 is an artifact candidate. The candidates are then filtered to remove 

1510 variable sources and sources that are difficult to subtract such as 

1511 bright stars. This filter is configured using the config parameters 

1512 ``temporalThreshold`` and ``spatialThreshold``. The temporalThreshold is 

1513 the maximum fraction of epochs that the deviation can appear in and still 

1514 be considered an artifact. The spatialThreshold is the maximum fraction of 

1515 pixels in the footprint of the deviation that appear in other epochs 

1516 (where other epochs is defined by the temporalThreshold). If the deviant 

1517 region meets this criteria of having a significant percentage of pixels 

1518 that deviate in only a few epochs, these pixels have the 'CLIPPED' bit 

1519 set in the mask. These regions will not contribute to the final coadd. 

1520 Furthermore, any routine to determine the coadd PSF can now be cognizant 

1521 of clipped regions. Note that the algorithm implemented by this task is 

1522 preliminary and works correctly for HSC data. Parameter modifications and 

1523 or considerable redesigning of the algorithm is likley required for other 

1524 surveys. 

1525 

1526 ``CompareWarpAssembleCoaddTask`` sub-classes 

1527 ``AssembleCoaddTask`` and instantiates ``AssembleCoaddTask`` 

1528 as a subtask to generate the TemplateCoadd (the model of the static sky). 

1529 

1530 Notes 

1531 ----- 

1532 Debugging: 

1533 This task supports the following debug variables: 

1534 - ``saveCountIm`` 

1535 If True then save the Epoch Count Image as a fits file in the `figPath` 

1536 - ``figPath`` 

1537 Path to save the debug fits images and figures 

1538 """ 

1539 

1540 ConfigClass = CompareWarpAssembleCoaddConfig 

1541 _DefaultName = "compareWarpAssembleCoadd" 

1542 

1543 # See the parent class for docstring. 

1544 _doUsePsfMatchedPolygons: bool = True 

1545 

1546 def __init__(self, *args, **kwargs): 

1547 AssembleCoaddTask.__init__(self, *args, **kwargs) 

1548 self.makeSubtask("assembleStaticSkyModel") 

1549 detectionSchema = afwTable.SourceTable.makeMinimalSchema() 

1550 self.makeSubtask("detect", schema=detectionSchema) 

1551 if self.config.doPreserveContainedBySource: 

1552 self.makeSubtask("detectTemplate", schema=afwTable.SourceTable.makeMinimalSchema()) 

1553 if self.config.doScaleWarpVariance: 

1554 self.makeSubtask("scaleWarpVariance") 

1555 if self.config.doFilterMorphological: 

1556 self.makeSubtask("maskStreaks") 

1557 

1558 @utils.inheritDoc(AssembleCoaddTask) 

1559 def _makeSupplementaryData(self, butlerQC, inputRefs, outputRefs): 

1560 """Generate a templateCoadd to use as a naive model of static sky to 

1561 subtract from PSF-Matched warps. 

1562 

1563 Returns 

1564 ------- 

1565 result : `~lsst.pipe.base.Struct` 

1566 Results as a struct with attributes: 

1567 

1568 ``templateCoadd`` 

1569 Coadded exposure (`lsst.afw.image.Exposure`). 

1570 ``nImage`` 

1571 Keeps track of exposure count for each pixel 

1572 (`lsst.afw.image.ImageU`). 

1573 

1574 Raises 

1575 ------ 

1576 RuntimeError 

1577 Raised if ``templateCoadd`` is `None`. 

1578 """ 

1579 # Ensure that psfMatchedWarps are used as input warps for template 

1580 # generation. 

1581 staticSkyModelInputRefs = copy.deepcopy(inputRefs) 

1582 staticSkyModelInputRefs.inputWarps = inputRefs.psfMatchedWarps 

1583 

1584 # self.assembleStaticSkyModel is not expecting psfMatchedWarps as 

1585 # input. But in its runQuantum, ButlerQC would try to be helpful and 

1586 # read all of them in to memory. 

1587 del staticSkyModelInputRefs.psfMatchedWarps 

1588 

1589 # Because subtasks don't have connections we have to make one. 

1590 # The main task's `templateCoadd` is the subtask's `coaddExposure` 

1591 staticSkyModelOutputRefs = copy.deepcopy(outputRefs) 

1592 if self.config.assembleStaticSkyModel.doWrite: 

1593 staticSkyModelOutputRefs.coaddExposure = staticSkyModelOutputRefs.templateCoadd 

1594 # Remove template coadd from both subtask's and main tasks outputs, 

1595 # because it is handled by the subtask as `coaddExposure` 

1596 del outputRefs.templateCoadd 

1597 del staticSkyModelOutputRefs.templateCoadd 

1598 

1599 # A PSF-Matched nImage does not exist as a dataset type 

1600 if "nImage" in staticSkyModelOutputRefs.keys(): 

1601 del staticSkyModelOutputRefs.nImage 

1602 

1603 templateCoadd = self.assembleStaticSkyModel.runQuantum( 

1604 butlerQC, staticSkyModelInputRefs, staticSkyModelOutputRefs 

1605 ) 

1606 if templateCoadd is None: 

1607 raise RuntimeError(self._noTemplateMessage(self.assembleStaticSkyModel.warpType)) 

1608 

1609 return pipeBase.Struct( 

1610 templateCoadd=templateCoadd.coaddExposure, 

1611 nImage=templateCoadd.nImage, 

1612 warpRefList=templateCoadd.warpRefList, 

1613 imageScalerList=templateCoadd.imageScalerList, 

1614 weightList=templateCoadd.weightList, 

1615 psfMatchedWarpRefList=inputRefs.psfMatchedWarps, 

1616 ) 

1617 

1618 def _noTemplateMessage(self, warpType): 

1619 warpName = warpType[0].upper() + warpType[1:] 

1620 message = """No %(warpName)s warps were found to build the template coadd which is 

1621 required to run CompareWarpAssembleCoaddTask. To continue assembling this type of coadd, 

1622 first either rerun makeCoaddTempExp with config.make%(warpName)s=True or 

1623 coaddDriver with config.makeCoadTempExp.make%(warpName)s=True, before assembleCoadd. 

1624 

1625 Alternatively, to use another algorithm with existing warps, retarget the CoaddDriverConfig to 

1626 another algorithm like: 

1627 

1628 from lsst.pipe.tasks.assembleCoadd import SafeClipAssembleCoaddTask 

1629 config.assemble.retarget(SafeClipAssembleCoaddTask) 

1630 """ % { 

1631 "warpName": warpName 

1632 } 

1633 return message 

1634 

1635 @utils.inheritDoc(AssembleCoaddTask) 

1636 @timeMethod 

1637 def run( 

1638 self, 

1639 skyInfo, 

1640 *, 

1641 warpRefList, 

1642 imageScalerList, 

1643 weightList, 

1644 psfMatchedWarpRefList, 

1645 supplementaryData, 

1646 ): 

1647 """Notes 

1648 ----- 

1649 Assemble the coadd. 

1650 

1651 Find artifacts and apply them to the warps' masks creating a list of 

1652 alternative masks with a new "CLIPPED" plane and updated "NO_DATA" 

1653 plane. Then pass these alternative masks to the base class's ``run`` 

1654 method. 

1655 """ 

1656 # Check and match the order of the supplementaryData 

1657 # (PSF-matched) inputs to the order of the direct inputs, 

1658 # so that the artifact mask is applied to the right warp 

1659 

1660 # For any missing psfMatched warp refs replaced with None, 

1661 # findArtifacts() will produce a NO_DATA mask covering the entire bbox. 

1662 # As long as NO_DATA is in self.config.badMaskPlanes, these direct 

1663 # warps with missing psfMatched warps will not contribute to the coadd. 

1664 dataIds = [ref.dataId for ref in warpRefList] 

1665 psfMatchedDataIds = [ref.dataId for ref in supplementaryData.warpRefList] 

1666 

1667 if dataIds != psfMatchedDataIds: 

1668 self.log.info("Reordering and or/padding PSF-matched visit input list") 

1669 supplementaryData.warpRefList = reorderAndPadList( 

1670 supplementaryData.warpRefList, psfMatchedDataIds, dataIds 

1671 ) 

1672 # Remove in DM-49083 

1673 supplementaryData.imageScalerList = reorderAndPadList( 

1674 supplementaryData.imageScalerList, psfMatchedDataIds, dataIds 

1675 ) 

1676 

1677 # Use PSF-Matched Warps (and corresponding scalers) and coadd to find 

1678 # artifacts. 

1679 spanSetMaskList = self.findArtifacts( 

1680 supplementaryData.templateCoadd, supplementaryData.warpRefList, supplementaryData.imageScalerList 

1681 ) 

1682 # The mask planes are defined globally, so we can load the dict from 

1683 # the templateCoadd or the warps. 

1684 badMaskPlanes = [ 

1685 mp 

1686 for mp in self.config.badMaskPlanes 

1687 if mp in supplementaryData.templateCoadd.mask.getMaskPlaneDict().keys() 

1688 ] 

1689 badMaskPlanes.append("CLIPPED") 

1690 badPixelMask = afwImage.Mask.getPlaneBitMask(badMaskPlanes) 

1691 

1692 result = AssembleCoaddTask.run( 

1693 self, 

1694 skyInfo, 

1695 warpRefList=warpRefList, 

1696 imageScalerList=imageScalerList, 

1697 weightList=weightList, 

1698 altMaskList=spanSetMaskList, 

1699 mask=badPixelMask, 

1700 psfMatchedWarpRefList=psfMatchedWarpRefList, 

1701 ) 

1702 

1703 # Propagate PSF-matched EDGE pixels to coadd SENSOR_EDGE and 

1704 # INEXACT_PSF. Psf-Matching moves the real edge inwards. 

1705 self.applyAltEdgeMask(result.coaddExposure.maskedImage.mask, spanSetMaskList) 

1706 return result 

1707 

1708 def applyAltEdgeMask(self, mask, altMaskList): 

1709 """Propagate alt EDGE mask to SENSOR_EDGE AND INEXACT_PSF planes. 

1710 

1711 Parameters 

1712 ---------- 

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

1714 Original mask. 

1715 altMaskList : `list` of `dict` 

1716 List of Dicts containing ``spanSet`` lists. 

1717 Each element contains the new mask plane name (e.g. "CLIPPED 

1718 and/or "NO_DATA") as the key, and list of ``SpanSets`` to apply to 

1719 the mask. 

1720 """ 

1721 maskValue = mask.getPlaneBitMask(["SENSOR_EDGE", "INEXACT_PSF"]) 

1722 for visitMask in altMaskList: 

1723 if "EDGE" in visitMask: 

1724 for spanSet in visitMask["EDGE"]: 

1725 spanSet.clippedTo(mask.getBBox()).setMask(mask, maskValue) 

1726 

1727 def findArtifacts(self, templateCoadd, warpRefList, imageScalerList): 

1728 """Find artifacts. 

1729 

1730 Loop through warps twice. The first loop builds a map with the count 

1731 of how many epochs each pixel deviates from the templateCoadd by more 

1732 than ``config.chiThreshold`` sigma. The second loop takes each 

1733 difference image and filters the artifacts detected in each using 

1734 count map to filter out variable sources and sources that are 

1735 difficult to subtract cleanly. 

1736 

1737 Parameters 

1738 ---------- 

1739 templateCoadd : `lsst.afw.image.Exposure` 

1740 Exposure to serve as model of static sky. 

1741 warpRefList : `list` 

1742 List of dataset handles (data references) to warps. 

1743 imageScalerList : `list` 

1744 List of image scalers. 

1745 Deprecated and will be removed after v29. 

1746 

1747 Returns 

1748 ------- 

1749 altMasks : `list` of `dict` 

1750 List of dicts containing information about CLIPPED 

1751 (i.e., artifacts), NO_DATA, and EDGE pixels. 

1752 """ 

1753 self.log.debug("Generating Count Image, and mask lists.") 

1754 coaddBBox = templateCoadd.getBBox() 

1755 slateIm = afwImage.ImageU(coaddBBox) 

1756 epochCountImage = afwImage.ImageU(coaddBBox) 

1757 nImage = afwImage.ImageU(coaddBBox) 

1758 spanSetArtifactList = [] 

1759 spanSetNoDataMaskList = [] 

1760 spanSetEdgeList = [] 

1761 spanSetBadMorphoList = [] 

1762 badPixelMask = self.getBadPixelMask() 

1763 

1764 # mask of the warp diffs should = that of only the warp 

1765 templateCoadd.mask.clearAllMaskPlanes() 

1766 

1767 if self.config.doPreserveContainedBySource: 

1768 sacrificeToDetection = templateCoadd.clone() 

1769 templateFootprints = self.detectTemplate.detectFootprints(sacrificeToDetection) 

1770 del sacrificeToDetection 

1771 else: 

1772 templateFootprints = None 

1773 

1774 for warpRef, imageScaler in zip(warpRefList, imageScalerList): 

1775 warpDiffExp = self._readAndComputeWarpDiff(warpRef, imageScaler, templateCoadd) 

1776 if warpDiffExp is not None: 

1777 # This nImage only approximates the final nImage because it 

1778 # uses the PSF-matched mask. 

1779 nImage.array += ( 

1780 numpy.isfinite(warpDiffExp.image.array) * ((warpDiffExp.mask.array & badPixelMask) == 0) 

1781 ).astype(numpy.uint16) 

1782 fpSet = self.detect.detectFootprints(warpDiffExp, doSmooth=False, clearMask=True) 

1783 fpSet.positive.merge(fpSet.negative) 

1784 footprints = fpSet.positive 

1785 slateIm.set(0) 

1786 spanSetList = [footprint.spans for footprint in footprints.getFootprints()] 

1787 

1788 # Remove artifacts due to defects before they contribute to 

1789 # the epochCountImage. 

1790 if self.config.doPrefilterArtifacts: 

1791 spanSetList = self.prefilterArtifacts(spanSetList, warpDiffExp) 

1792 

1793 # Clear mask before adding prefiltered spanSets 

1794 self.detect.clearMask(warpDiffExp.mask) 

1795 for spans in spanSetList: 

1796 spans.setImage(slateIm, 1, doClip=True) 

1797 spans.setMask(warpDiffExp.mask, warpDiffExp.mask.getPlaneBitMask("DETECTED")) 

1798 epochCountImage += slateIm 

1799 

1800 if self.config.doFilterMorphological: 

1801 maskName = self.config.streakMaskName 

1802 # clear single frame streak mask if it exists already 

1803 if maskName in warpDiffExp.mask.getMaskPlaneDict(): 

1804 warpDiffExp.mask.clearMaskPlane(warpDiffExp.mask.getMaskPlane(maskName)) 

1805 else: 

1806 self.log.debug(f"Did not (need to) clear {maskName} mask because it didn't exist") 

1807 

1808 _ = self.maskStreaks.run(warpDiffExp) 

1809 streakMask = warpDiffExp.mask 

1810 initSpanSetStreak = afwGeom.SpanSet.fromMask( 

1811 streakMask, streakMask.getPlaneBitMask(maskName) 

1812 ).split() 

1813 # Pad the streaks to account for low-surface brightness 

1814 # wings. 

1815 psf = warpDiffExp.getPsf() 

1816 spanSetStreak = [] 

1817 for sset in initSpanSetStreak: 

1818 if self.config.doPreserveContainedBySource and templateFootprints is not None: 

1819 doKeep = True 

1820 for footprint in templateFootprints.positive.getFootprints(): 

1821 if footprint.spans.contains(sset): 

1822 doKeep = False 

1823 break 

1824 if not doKeep: 

1825 continue 

1826 psfShape = psf.computeShape(sset.computeCentroid()) 

1827 dilation = self.config.growStreakFp * psfShape.getDeterminantRadius() 

1828 sset_dilated = sset.dilated(int(dilation)) 

1829 spanSetStreak.append(sset_dilated) 

1830 

1831 # PSF-Matched warps have less available area (~the matching 

1832 # kernel) because the calexps undergo a second convolution. 

1833 # Pixels with data in the direct warp but not in the 

1834 # PSF-matched warp will not have their artifacts detected. 

1835 # NaNs from the PSF-matched warp therefore must be masked in 

1836 # the direct warp. 

1837 nans = numpy.where(numpy.isnan(warpDiffExp.maskedImage.image.array), 1, 0) 

1838 nansMask = afwImage.makeMaskFromArray(nans.astype(afwImage.MaskPixel)) 

1839 nansMask.setXY0(warpDiffExp.getXY0()) 

1840 edgeMask = warpDiffExp.mask 

1841 spanSetEdgeMask = afwGeom.SpanSet.fromMask(edgeMask, edgeMask.getPlaneBitMask("EDGE")).split() 

1842 else: 

1843 # If the directWarp has <1% coverage, the psfMatchedWarp can 

1844 # have 0% and not exist. In this case, mask the whole epoch. 

1845 nansMask = afwImage.MaskX(coaddBBox, 1) 

1846 spanSetList = [] 

1847 spanSetEdgeMask = [] 

1848 spanSetStreak = [] 

1849 

1850 spanSetNoDataMask = afwGeom.SpanSet.fromMask(nansMask).split() 

1851 

1852 spanSetNoDataMaskList.append(spanSetNoDataMask) 

1853 spanSetArtifactList.append(spanSetList) 

1854 spanSetEdgeList.append(spanSetEdgeMask) 

1855 if self.config.doFilterMorphological: 

1856 spanSetBadMorphoList.append(spanSetStreak) 

1857 

1858 if lsstDebug.Info(__name__).saveCountIm: 

1859 path = self._dataRef2DebugPath("epochCountIm", warpRefList[0], coaddLevel=True) 

1860 epochCountImage.writeFits(path) 

1861 

1862 for i, spanSetList in enumerate(spanSetArtifactList): 

1863 if spanSetList: 

1864 filteredSpanSetList = self.filterArtifacts( 

1865 spanSetList, epochCountImage, nImage, templateFootprints 

1866 ) 

1867 spanSetArtifactList[i] = filteredSpanSetList 

1868 if self.config.doFilterMorphological: 

1869 spanSetArtifactList[i] += spanSetBadMorphoList[i] 

1870 

1871 altMasks = [] 

1872 for artifacts, noData, edge in zip(spanSetArtifactList, spanSetNoDataMaskList, spanSetEdgeList): 

1873 altMasks.append({"CLIPPED": artifacts, "NO_DATA": noData, "EDGE": edge}) 

1874 return altMasks 

1875 

1876 def prefilterArtifacts(self, spanSetList, exp): 

1877 """Remove artifact candidates covered by bad mask plane. 

1878 

1879 Any future editing of the candidate list that does not depend on 

1880 temporal information should go in this method. 

1881 

1882 Parameters 

1883 ---------- 

1884 spanSetList : `list` [`lsst.afw.geom.SpanSet`] 

1885 List of SpanSets representing artifact candidates. 

1886 exp : `lsst.afw.image.Exposure` 

1887 Exposure containing mask planes used to prefilter. 

1888 

1889 Returns 

1890 ------- 

1891 returnSpanSetList : `list` [`lsst.afw.geom.SpanSet`] 

1892 List of SpanSets with artifacts. 

1893 """ 

1894 existingMaskPlanes = [ 

1895 m for m in self.config.prefilterArtifactsMaskPlanes if m in exp.mask.getMaskPlaneDict() 

1896 ] 

1897 badPixelMask = exp.mask.getPlaneBitMask(existingMaskPlanes) 

1898 goodArr = (exp.mask.array & badPixelMask) == 0 

1899 returnSpanSetList = [] 

1900 bbox = exp.getBBox() 

1901 x0, y0 = exp.getXY0() 

1902 for i, span in enumerate(spanSetList): 

1903 y, x = span.clippedTo(bbox).indices() 

1904 yIndexLocal = numpy.array(y) - y0 

1905 xIndexLocal = numpy.array(x) - x0 

1906 goodRatio = numpy.count_nonzero(goodArr[yIndexLocal, xIndexLocal]) / span.getArea() 

1907 if goodRatio > self.config.prefilterArtifactsRatio: 

1908 returnSpanSetList.append(span) 

1909 return returnSpanSetList 

1910 

1911 def filterArtifacts(self, spanSetList, epochCountImage, nImage, footprintsToExclude=None): 

1912 """Filter artifact candidates. 

1913 

1914 Parameters 

1915 ---------- 

1916 spanSetList : `list` [`lsst.afw.geom.SpanSet`] 

1917 List of SpanSets representing artifact candidates. 

1918 epochCountImage : `lsst.afw.image.Image` 

1919 Image of accumulated number of warpDiff detections. 

1920 nImage : `lsst.afw.image.ImageU` 

1921 Image of the accumulated number of total epochs contributing. 

1922 

1923 Returns 

1924 ------- 

1925 maskSpanSetList : `list` [`lsst.afw.geom.SpanSet`] 

1926 List of SpanSets with artifacts. 

1927 """ 

1928 maskSpanSetList = [] 

1929 x0, y0 = epochCountImage.getXY0() 

1930 for i, span in enumerate(spanSetList): 

1931 y, x = span.indices() 

1932 yIdxLocal = [y1 - y0 for y1 in y] 

1933 xIdxLocal = [x1 - x0 for x1 in x] 

1934 outlierN = epochCountImage.array[yIdxLocal, xIdxLocal] 

1935 totalN = nImage.array[yIdxLocal, xIdxLocal] 

1936 

1937 # effectiveMaxNumEpochs is broken line (fraction of N) with 

1938 # characteristic config.maxNumEpochs. 

1939 effMaxNumEpochsHighN = self.config.maxNumEpochs + self.config.maxFractionEpochsHigh * numpy.mean( 

1940 totalN 

1941 ) 

1942 effMaxNumEpochsLowN = self.config.maxFractionEpochsLow * numpy.mean(totalN) 

1943 effectiveMaxNumEpochs = int(min(effMaxNumEpochsLowN, effMaxNumEpochsHighN)) 

1944 nPixelsBelowThreshold = numpy.count_nonzero((outlierN > 0) & (outlierN <= effectiveMaxNumEpochs)) 

1945 percentBelowThreshold = nPixelsBelowThreshold / len(outlierN) 

1946 if percentBelowThreshold > self.config.spatialThreshold: 

1947 maskSpanSetList.append(span) 

1948 

1949 if self.config.doPreserveContainedBySource and footprintsToExclude is not None: 

1950 # If a candidate is contained by a footprint on the template coadd, 

1951 # do not clip. 

1952 filteredMaskSpanSetList = [] 

1953 for span in maskSpanSetList: 

1954 doKeep = True 

1955 for footprint in footprintsToExclude.positive.getFootprints(): 

1956 if footprint.spans.contains(span): 

1957 doKeep = False 

1958 break 

1959 if doKeep: 

1960 filteredMaskSpanSetList.append(span) 

1961 maskSpanSetList = filteredMaskSpanSetList 

1962 

1963 return maskSpanSetList 

1964 

1965 def _readAndComputeWarpDiff(self, warpRef, imageScaler, templateCoadd): 

1966 """Fetch a warp from the butler and return a warpDiff. 

1967 

1968 Parameters 

1969 ---------- 

1970 warpRef : `lsst.daf.butler.DeferredDatasetHandle` 

1971 Dataset handle for the warp. 

1972 imageScaler : `lsst.pipe.tasks.scaleZeroPoint.ImageScaler` 

1973 An image scaler object. 

1974 Deprecated and will be removed after v29 in DM-49083. 

1975 templateCoadd : `lsst.afw.image.Exposure` 

1976 Exposure to be substracted from the scaled warp. 

1977 

1978 Returns 

1979 ------- 

1980 warp : `lsst.afw.image.Exposure` 

1981 Exposure of the image difference between the warp and template. 

1982 """ 

1983 # If the PSF-Matched warp did not exist for this direct warp 

1984 # None is holding its place to maintain order in Gen 3 

1985 if warpRef is None: 

1986 return None 

1987 

1988 warp = warpRef.get(parameters={"bbox": templateCoadd.getBBox()}) 

1989 # direct image scaler OK for PSF-matched Warp. 

1990 # Remove in DM-49083. 

1991 if imageScaler is not None: 

1992 imageScaler.scaleMaskedImage(warp.getMaskedImage()) 

1993 mi = warp.getMaskedImage() 

1994 if self.config.doScaleWarpVariance: 

1995 try: 

1996 self.scaleWarpVariance.run(mi) 

1997 except Exception as exc: 

1998 self.log.warning("Unable to rescale variance of warp (%s); leaving it as-is", exc) 

1999 mi -= templateCoadd.getMaskedImage() 

2000 return warp