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

679 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-21 10:50 +0000

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 

453 try: 

454 retStruct = self.run( 

455 inputData["skyInfo"], 

456 warpRefList=inputs.warpRefList, 

457 imageScalerList=inputs.imageScalerList, 

458 weightList=inputs.weightList, 

459 psfMatchedWarpRefList=inputs.psfMatchedWarpRefList, 

460 supplementaryData=supplementaryData, 

461 ) 

462 except pipeBase.AlgorithmError as e: 

463 error = pipeBase.AnnotatedPartialOutputsError.annotate(e, self, log=self.log) 

464 raise error from e 

465 

466 inputData.setdefault("brightObjectMask", None) 

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

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

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

470 

471 if self.config.doWriteArtifactMasks: 

472 artifactMasksRefList = reorderAndPadList( 

473 outputRefs.artifactMasks, 

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

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

476 ) 

477 for altMask, warpRef, outputRef in zip( 

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

479 ): 

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

481 self.applyAltMaskPlanes(mask, altMask) 

482 butlerQC.put(mask, outputRef) 

483 

484 if self.config.doWrite: 

485 butlerQC.put(retStruct, outputRefs) 

486 

487 return retStruct 

488 

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

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

491 

492 Parameters 

493 ---------- 

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

495 The coadded exposure to process. 

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

497 Table of bright objects to mask. 

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

499 Data identification. 

500 """ 

501 if self.config.doInterp: 

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

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

504 varArray = coaddExposure.variance.array 

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

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

507 

508 if self.config.doMaskBrightObjects: 

509 self.setBrightObjectMasks(coaddExposure, brightObjectMasks, dataId) 

510 

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

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

513 

514 Duplicates interface of `runQuantum` method. 

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

516 coadd dataRef/handle for performing preliminary processing before 

517 assembling the coadd. 

518 

519 Parameters 

520 ---------- 

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

522 Gen3 Butler object for fetching additional data products before 

523 running the Task specialized for quantum being processed. 

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

525 Attributes are the names of the connections describing input 

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

527 corresponding dataset type. DataIds are guaranteed to match data 

528 objects in ``inputData``. 

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

530 Attributes are the names of the connections describing output 

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

532 for the corresponding dataset type. 

533 """ 

534 return pipeBase.Struct() 

535 

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

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

538 each warp. 

539 

540 Before coadding these Warps together compute the weight for each 

541 Warp. 

542 

543 Parameters 

544 ---------- 

545 refList : `list` 

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

547 psfMatchedWarpRefList : `list` | None, optional 

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

549 

550 Returns 

551 ------- 

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

553 Results as a struct with attributes: 

554 

555 ``warpRefList`` 

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

557 ``weightList`` 

558 `list` of weightings. 

559 ``imageScalerList`` 

560 `list` of image scalers. 

561 Deprecated and will be removed in DM-49083. 

562 """ 

563 statsCtrl = afwMath.StatisticsControl() 

564 statsCtrl.setNumSigmaClip(self.config.sigmaClip) 

565 statsCtrl.setNumIter(self.config.clipIter) 

566 statsCtrl.setAndMask(self.getBadPixelMask()) 

567 statsCtrl.setNanSafe(True) 

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

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

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

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

572 warpRefList = [] 

573 weightList = [] 

574 # Remove in DM-49083 

575 imageScalerList = [] 

576 outputPsfMatchedWarpRefList = [] 

577 

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

579 # their dataId. 

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

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

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

583 

584 if psfMatchedWarpRefList is None: 

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

586 else: 

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

588 

589 warpName = self.getTempExpDatasetName(self.warpType) 

590 for warpRef in refList: 

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

592 # Ignore any input warp that is empty of data 

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

594 continue 

595 maskedImage = warp.getMaskedImage() 

596 

597 # Allow users to scale the zero point for backward 

598 # compatibility. Remove imageScalarList in DM-49083. 

599 if self.config.doScaleZeroPoint: 

600 imageScaler = self.scaleZeroPoint.computeImageScaler( 

601 exposure=warp, 

602 dataRef=warpRef, # FIXME 

603 ) 

604 try: 

605 imageScaler.scaleMaskedImage(maskedImage) 

606 except Exception as e: 

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

608 continue 

609 imageScalerList.append(imageScaler) 

610 else: 

611 imageScalerList.append(None) 

612 if "BUNIT" not in warp.metadata: 

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

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

615 raise ValueError( 

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

617 ) 

618 statObj = afwMath.makeStatistics( 

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

620 ) 

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

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

623 weight = 1.0 / float(meanVar) 

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

625 else: 

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

627 continue 

628 

629 del maskedImage 

630 del warp 

631 

632 warpRefList.append(warpRef) 

633 weightList.append(weight) 

634 

635 dataId = warpRef.dataId 

636 psfMatchedWarpRef = psfMatchedWarpRefDict.get(dataId, None) 

637 outputPsfMatchedWarpRefList.append(psfMatchedWarpRef) 

638 

639 return pipeBase.Struct( 

640 warpRefList=warpRefList, 

641 weightList=weightList, 

642 imageScalerList=imageScalerList, 

643 psfMatchedWarpRefList=outputPsfMatchedWarpRefList, 

644 ) 

645 

646 def prepareStats(self, mask=None): 

647 """Prepare the statistics for coadding images. 

648 

649 Parameters 

650 ---------- 

651 mask : `int`, optional 

652 Bit mask value to exclude from coaddition. 

653 

654 Returns 

655 ------- 

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

657 Statistics as a struct with attributes: 

658 

659 ``statsCtrl`` 

660 Statistics control object for coadd 

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

662 ``statsFlags`` 

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

664 """ 

665 if mask is None: 

666 mask = self.getBadPixelMask() 

667 statsCtrl = afwMath.StatisticsControl() 

668 statsCtrl.setNumSigmaClip(self.config.sigmaClip) 

669 statsCtrl.setNumIter(self.config.clipIter) 

670 statsCtrl.setAndMask(mask) 

671 statsCtrl.setNanSafe(True) 

672 statsCtrl.setWeighted(True) 

673 statsCtrl.setCalcErrorFromInputVariance(self.config.calcErrorFromInputVariance) 

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

675 bit = afwImage.Mask.getMaskPlane(plane) 

676 statsCtrl.setMaskPropagationThreshold(bit, threshold) 

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

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

679 

680 @timeMethod 

681 def run( 

682 self, 

683 skyInfo, 

684 *, 

685 warpRefList, 

686 imageScalerList, 

687 weightList, 

688 psfMatchedWarpRefList=None, 

689 altMaskList=None, 

690 mask=None, 

691 supplementaryData=None, 

692 ): 

693 """Assemble a coadd from input warps. 

694 

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

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

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

698 conserve memory usage. Iterate over subregions within the outer 

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

700 subregions from the coaddTempExps with the statistic specified. 

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

702 

703 Parameters 

704 ---------- 

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

706 Struct with geometric information about the patch. 

707 warpRefList : `list` 

708 List of dataset handles (data references) to Warps 

709 (previously called CoaddTempExps). 

710 imageScalerList : `list` 

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

712 in DM-49083. 

713 weightList : `list` 

714 List of weights. 

715 psfMatchedWarpRefList : `list`, optional 

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

717 altMaskList : `list`, optional 

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

719 warp. 

720 mask : `int`, optional 

721 Bit mask value to exclude from coaddition. 

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

723 Struct with additional data products needed to assemble coadd. 

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

725 and override `run`. 

726 

727 Returns 

728 ------- 

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

730 Results as a struct with attributes: 

731 

732 ``coaddExposure`` 

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

734 ``nImage`` 

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

736 ``inputMap`` 

737 Bit-wise map of inputs, if requested. 

738 ``warpRefList`` 

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

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

741 ``imageScalerList`` 

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

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

744 ``weightList`` 

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

746 

747 Raises 

748 ------ 

749 lsst.pipe.base.NoWorkFound 

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

751 """ 

752 warpName = self.getTempExpDatasetName(self.warpType) 

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

754 if not warpRefList: 

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

756 

757 stats = self.prepareStats(mask=mask) 

758 

759 if altMaskList is None: 

760 altMaskList = [None] * len(warpRefList) 

761 

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

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

764 if self.config.doScaleZeroPoint: 

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

766 else: 

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

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

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

770 coaddMaskedImage = coaddExposure.getMaskedImage() 

771 subregionSizeArr = self.config.subregionSize 

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

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

774 # assembleSubregion. 

775 if self.config.doNImage: 

776 nImage = afwImage.ImageU(skyInfo.bbox) 

777 else: 

778 nImage = None 

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

780 # masked in assembleSubregion. 

781 if self.config.doInputMap: 

782 self.inputMapper.build_ccd_input_map( 

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

784 ) 

785 

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

787 try: 

788 self.assembleOnlineMeanCoadd( 

789 coaddExposure, 

790 warpRefList, 

791 imageScalerList, 

792 weightList, 

793 altMaskList, 

794 stats.ctrl, 

795 nImage=nImage, 

796 ) 

797 except Exception as e: 

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

799 raise 

800 else: 

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

802 try: 

803 self.assembleSubregion( 

804 coaddExposure, 

805 subBBox, 

806 warpRefList, 

807 imageScalerList, 

808 weightList, 

809 altMaskList, 

810 stats.flags, 

811 stats.ctrl, 

812 nImage=nImage, 

813 ) 

814 except Exception as e: 

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

816 raise 

817 

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

819 # accumulation. 

820 if self.config.doInputMap: 

821 self.inputMapper.finalize_ccd_input_map_mask() 

822 inputMap = self.inputMapper.ccd_input_map 

823 else: 

824 inputMap = None 

825 

826 self.setInexactPsf(coaddMaskedImage.getMask()) 

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

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

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

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

831 return pipeBase.Struct( 

832 coaddExposure=coaddExposure, 

833 nImage=nImage, 

834 warpRefList=warpRefList, 

835 imageScalerList=imageScalerList, 

836 weightList=weightList, 

837 inputMap=inputMap, 

838 altMaskList=altMaskList, 

839 ) 

840 

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

842 """Set the metadata for the coadd. 

843 

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

845 

846 Parameters 

847 ---------- 

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

849 The target exposure for the coadd. 

850 warpRefList : `list` 

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

852 weightList : `list` 

853 List of weights. 

854 psfMatchedWarpRefList : `list` | None, optional 

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

856 

857 Raises 

858 ------ 

859 AssertionError 

860 Raised if there is a length mismatch. 

861 """ 

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

863 

864 if psfMatchedWarpRefList: 

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

866 

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

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

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

870 # (see #2777). 

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

872 

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

874 

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

876 

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

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

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

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

881 coaddInputs.ccds.reserve(numCcds) 

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

883 

884 # Set the exposure units to nJy 

885 # No need to check after DM-49083. 

886 if not self.config.doScaleZeroPoint: 

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

888 

889 # psfMatchedWarpRefList should be empty except in CompareWarpCoadd. 

890 if self._doUsePsfMatchedPolygons: 

891 # Set validPolygons for warp before addVisitToCoadd 

892 self._setValidPolygons(warpList, psfMatchedWarpRefList) 

893 

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

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

896 

897 coaddInputs.visits.sort() 

898 coaddInputs.ccds.sort() 

899 if self.warpType == "psfMatched": 

900 # The modelPsf BBox for a psfMatchedWarp/coaddTempExp was 

901 # dynamically defined by ModelPsfMatchTask as the square box 

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

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

904 # having the maximum width (sufficient because square) 

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

906 modelPsfWidthList = [ 

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

908 ] 

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

910 else: 

911 psf = measAlg.CoaddPsf( 

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

913 ) 

914 coaddExposure.setPsf(psf) 

915 apCorrMap = measAlg.makeCoaddApCorrMap( 

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

917 ) 

918 coaddExposure.getInfo().setApCorrMap(apCorrMap) 

919 if self.config.doAttachTransmissionCurve: 

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

921 coaddExposure.getInfo().setTransmissionCurve(transmissionCurve) 

922 

923 def assembleSubregion( 

924 self, 

925 coaddExposure, 

926 bbox, 

927 warpRefList, 

928 imageScalerList, 

929 weightList, 

930 altMaskList, 

931 statsFlags, 

932 statsCtrl, 

933 nImage=None, 

934 ): 

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

936 

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

938 if one is passed. Remove mask planes listed in 

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

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

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

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

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

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

945 

946 Parameters 

947 ---------- 

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

949 The target exposure for the coadd. 

950 bbox : `lsst.geom.Box` 

951 Sub-region to coadd. 

952 warpRefList : `list` 

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

954 imageScalerList : `list` 

955 List of image scalers. 

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

957 weightList : `list` 

958 List of weights. 

959 altMaskList : `list` 

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

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

962 name to which to add the spans. 

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

964 Property object for statistic for coadd. 

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

966 Statistics control object for coadd. 

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

968 Keeps track of exposure count for each pixel. 

969 """ 

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

971 

972 coaddExposure.mask.addMaskPlane("REJECTED") 

973 coaddExposure.mask.addMaskPlane("CLIPPED") 

974 coaddExposure.mask.addMaskPlane("SENSOR_EDGE") 

975 maskMap = setRejectedMaskMapping(statsCtrl) 

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

977 maskedImageList = [] 

978 if nImage is not None: 

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

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

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

982 

983 maskedImage = exposure.getMaskedImage() 

984 mask = maskedImage.getMask() 

985 if altMask is not None: 

986 self.applyAltMaskPlanes(mask, altMask) 

987 # Remove in DM-49083 

988 if imageScaler is not None: 

989 imageScaler.scaleMaskedImage(maskedImage) 

990 

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

992 # In legacyCoadd, pixels may also be excluded by 

993 # afwMath.statisticsStack. 

994 if nImage is not None: 

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

996 if self.config.removeMaskPlanes: 

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

998 maskedImageList.append(maskedImage) 

999 

1000 if self.config.doInputMap: 

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

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

1003 

1004 with self.timer("stack"): 

1005 coaddSubregion = afwMath.statisticsStack( 

1006 maskedImageList, 

1007 statsFlags, 

1008 statsCtrl, 

1009 weightList, 

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

1011 maskMap, 

1012 ) 

1013 coaddExposure.maskedImage.assign(coaddSubregion, bbox) 

1014 if nImage is not None: 

1015 nImage.assign(subNImage, bbox) 

1016 

1017 def assembleOnlineMeanCoadd( 

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

1019 ): 

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

1021 

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

1023 It only works for MEAN statistics. 

1024 

1025 Parameters 

1026 ---------- 

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

1028 The target exposure for the coadd. 

1029 warpRefList : `list` 

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

1031 imageScalerList : `list` 

1032 List of image scalers. 

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

1034 weightList : `list` 

1035 List of weights. 

1036 altMaskList : `list` 

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

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

1039 name to which to add the spans. 

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

1041 Statistics control object for coadd. 

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

1043 Keeps track of exposure count for each pixel. 

1044 """ 

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

1046 

1047 coaddExposure.mask.addMaskPlane("REJECTED") 

1048 coaddExposure.mask.addMaskPlane("CLIPPED") 

1049 coaddExposure.mask.addMaskPlane("SENSOR_EDGE") 

1050 maskMap = setRejectedMaskMapping(statsCtrl) 

1051 thresholdDict = AccumulatorMeanStack.stats_ctrl_to_threshold_dict(statsCtrl) 

1052 

1053 bbox = coaddExposure.maskedImage.getBBox() 

1054 

1055 stacker = AccumulatorMeanStack( 

1056 coaddExposure.image.array.shape, 

1057 statsCtrl.getAndMask(), 

1058 mask_threshold_dict=thresholdDict, 

1059 mask_map=maskMap, 

1060 no_good_pixels_mask=statsCtrl.getNoGoodPixelsMask(), 

1061 calc_error_from_input_variance=self.config.calcErrorFromInputVariance, 

1062 compute_n_image=(nImage is not None), 

1063 ) 

1064 

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

1066 warpRefList, imageScalerList, altMaskList, weightList 

1067 ): 

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

1069 maskedImage = exposure.getMaskedImage() 

1070 mask = maskedImage.getMask() 

1071 if altMask is not None: 

1072 self.applyAltMaskPlanes(mask, altMask) 

1073 # Remove in DM-49083. 

1074 if imageScaler is not None: 

1075 imageScaler.scaleMaskedImage(maskedImage) 

1076 if self.config.removeMaskPlanes: 

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

1078 

1079 stacker.add_masked_image(maskedImage, weight=weight) 

1080 

1081 if self.config.doInputMap: 

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

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

1084 

1085 stacker.fill_stacked_masked_image(coaddExposure.maskedImage) 

1086 

1087 if nImage is not None: 

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

1089 

1090 def applyAltMaskPlanes(self, mask, altMaskSpans): 

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

1092 

1093 Parameters 

1094 ---------- 

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

1096 Original mask. 

1097 altMaskSpans : `dict` 

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

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

1100 and list of SpanSets to apply to the mask. 

1101 

1102 Returns 

1103 ------- 

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

1105 Updated mask. 

1106 """ 

1107 if self._doUsePsfMatchedPolygons: 

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

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

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

1111 # still be rejected because of NO_DATA. 

1112 # self._doUsePsfMatchedPolygons should be True only in 

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

1114 # *before* applying the new masks below. 

1115 for spanSet in altMaskSpans["NO_DATA"]: 

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

1117 

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

1119 maskClipValue = mask.addMaskPlane(plane) 

1120 for spanSet in spanSetList: 

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

1122 return mask 

1123 

1124 def _setValidPolygons(self, warpList, psfMatchedWarpRefList): 

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

1126 it exists. 

1127 

1128 Parameters 

1129 ---------- 

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

1131 List of warps. 

1132 psfMatchedWarpRefList : `Iterable` \ 

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

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

1135 ``warpList``. 

1136 

1137 Raises 

1138 ------ 

1139 ValueError 

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

1141 (direct) warps. 

1142 """ 

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

1144 if psfMatchedWarpRef is None: 

1145 continue 

1146 

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

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

1149 

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

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

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

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

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

1155 visit = psfMatchedWarpRef.dataId["visit"] 

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

1157 

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

1159 self.log.debug( 

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

1161 "warp without a validPolygon", 

1162 psfMatchedWarpRef.dataId["visit"], 

1163 ) 

1164 

1165 for psfMatchedCcdRow in psfMatchedCcdTable: 

1166 if not psfMatchedCcdRow.validPolygon: 

1167 self.log.warning( 

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

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

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

1171 psfMatchedCcdRow.id, 

1172 ) 

1173 else: 

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

1175 ccdRow.validPolygon = psfMatchedCcdRow.validPolygon 

1176 

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

1178 """Set the bright object masks. 

1179 

1180 Parameters 

1181 ---------- 

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

1183 Exposure under consideration. 

1184 brightObjectMasks : `lsst.afw.table` 

1185 Table of bright objects to mask. 

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

1187 Data identifier dict for patch. 

1188 """ 

1189 if brightObjectMasks is None: 

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

1191 return 

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

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

1194 wcs = exposure.getWcs() 

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

1196 

1197 for rec in brightObjectMasks: 

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

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

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

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

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

1203 

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

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

1206 

1207 bbox = geom.BoxI( 

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

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

1210 ) 

1211 spans = afwGeom.SpanSet(bbox) 

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

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

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

1215 else: 

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

1217 continue 

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

1219 

1220 def setInexactPsf(self, mask): 

1221 """Set INEXACT_PSF mask plane. 

1222 

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

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

1225 these pixels. 

1226 

1227 Parameters 

1228 ---------- 

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

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

1231 """ 

1232 mask.addMaskPlane("INEXACT_PSF") 

1233 inexactPsf = mask.getPlaneBitMask("INEXACT_PSF") 

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

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

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

1237 array = mask.getArray() 

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

1239 array[selected] |= inexactPsf 

1240 

1241 def filterWarps(self, inputs, goodVisits): 

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

1243 goodVisit. 

1244 

1245 Parameters 

1246 ---------- 

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

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

1249 containing visit. 

1250 goodVisit : `dict` 

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

1252 

1253 Returns 

1254 ------- 

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

1256 Filtered and sorted list of inputRefs with visitId in goodVisits 

1257 ordered by goodVisit. 

1258 """ 

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

1260 filteredInputs = [] 

1261 for visit in goodVisits.keys(): 

1262 if visit in inputWarpDict: 

1263 filteredInputs.append(inputWarpDict[visit]) 

1264 return filteredInputs 

1265 

1266 

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

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

1269 footprint. 

1270 

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

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

1273 ignoreMask set. Return the count. 

1274 

1275 Parameters 

1276 ---------- 

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

1278 Mask to define intersection region by. 

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

1280 Footprint to define the intersection region by. 

1281 bitmask : `Unknown` 

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

1283 ignoreMask : `Unknown` 

1284 Pixels to not consider. 

1285 

1286 Returns 

1287 ------- 

1288 result : `int` 

1289 Number of pixels in footprint with specified mask. 

1290 """ 

1291 bbox = footprint.getBBox() 

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

1293 fp = afwImage.Mask(bbox) 

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

1295 footprint.spans.setMask(fp, bitmask) 

1296 return numpy.logical_and( 

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

1298 ).sum() 

1299 

1300 

1301class CompareWarpAssembleCoaddConnections(AssembleCoaddConnections): 

1302 psfMatchedWarps = pipeBase.connectionTypes.Input( 

1303 doc=( 

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

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

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

1307 ), 

1308 name="{inputCoaddName}Coadd_psfMatchedWarp", 

1309 storageClass="ExposureF", 

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

1311 deferLoad=True, 

1312 multiple=True, 

1313 ) 

1314 templateCoadd = pipeBase.connectionTypes.Output( 

1315 doc=( 

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

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

1318 ), 

1319 name="{outputCoaddName}CoaddPsfMatched", 

1320 storageClass="ExposureF", 

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

1322 ) 

1323 artifactMasks = pipeBase.connectionTypes.Output( 

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

1325 name="compare_warp_artifact_mask", 

1326 storageClass="Mask", 

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

1328 multiple=True, 

1329 ) 

1330 

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

1332 super().__init__(config=config) 

1333 if not config.assembleStaticSkyModel.doWrite: 

1334 self.outputs.remove("templateCoadd") 

1335 config.validate() 

1336 

1337 

1338class CompareWarpAssembleCoaddConfig( 

1339 AssembleCoaddConfig, pipelineConnections=CompareWarpAssembleCoaddConnections 

1340): 

1341 assembleStaticSkyModel = pexConfig.ConfigurableField( 

1342 target=AssembleCoaddTask, 

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

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

1345 ) 

1346 detect = pexConfig.ConfigurableField( 

1347 target=SourceDetectionTask, 

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

1349 ) 

1350 detectTemplate = pexConfig.ConfigurableField( 

1351 target=SourceDetectionTask, 

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

1353 ) 

1354 maskStreaks = pexConfig.ConfigurableField( 

1355 target=MaskStreaksTask, 

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

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

1358 "streakMaskName", 

1359 ) 

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

1361 maxNumEpochs = pexConfig.Field( 

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

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

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

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

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

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

1368 "than transient and not masked.", 

1369 dtype=int, 

1370 default=2, 

1371 ) 

1372 maxFractionEpochsLow = pexConfig.RangeField( 

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

1374 "Effective maxNumEpochs = " 

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

1376 dtype=float, 

1377 default=0.4, 

1378 min=0.0, 

1379 max=1.0, 

1380 ) 

1381 maxFractionEpochsHigh = pexConfig.RangeField( 

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

1383 "Effective maxNumEpochs = " 

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

1385 dtype=float, 

1386 default=0.03, 

1387 min=0.0, 

1388 max=1.0, 

1389 ) 

1390 spatialThreshold = pexConfig.RangeField( 

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

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

1393 dtype=float, 

1394 default=0.5, 

1395 min=0.0, 

1396 max=1.0, 

1397 inclusiveMin=True, 

1398 inclusiveMax=True, 

1399 ) 

1400 doScaleWarpVariance = pexConfig.Field( 

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

1402 dtype=bool, 

1403 default=True, 

1404 ) 

1405 scaleWarpVariance = pexConfig.ConfigurableField( 

1406 target=ScaleVarianceTask, 

1407 doc="Rescale variance on warps", 

1408 ) 

1409 doPreserveContainedBySource = pexConfig.Field( 

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

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

1412 dtype=bool, 

1413 default=True, 

1414 ) 

1415 doPrefilterArtifacts = pexConfig.Field( 

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

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

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

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

1420 dtype=bool, 

1421 default=True, 

1422 ) 

1423 prefilterArtifactsMaskPlanes = pexConfig.ListField( 

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

1425 dtype=str, 

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

1427 ) 

1428 prefilterArtifactsRatio = pexConfig.Field( 

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

1430 dtype=float, 

1431 default=0.05, 

1432 ) 

1433 doFilterMorphological = pexConfig.Field( 

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

1435 "be streaks.", 

1436 dtype=bool, 

1437 default=False, 

1438 ) 

1439 growStreakFp = pexConfig.Field( 

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

1441 ) 

1442 

1443 def setDefaults(self): 

1444 AssembleCoaddConfig.setDefaults(self) 

1445 self.statistic = "MEAN" 

1446 

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

1448 # matching kernel. CompareWarp applies psfMatched EDGE pixels to 

1449 # directWarps before assembling. 

1450 if "EDGE" in self.badMaskPlanes: 

1451 self.badMaskPlanes.remove("EDGE") 

1452 self.removeMaskPlanes.append("EDGE") 

1453 self.assembleStaticSkyModel.badMaskPlanes = [ 

1454 "NO_DATA", 

1455 ] 

1456 self.assembleStaticSkyModel.warpType = "psfMatched" 

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

1458 self.assembleStaticSkyModel.statistic = "MEANCLIP" 

1459 self.assembleStaticSkyModel.sigmaClip = 2.5 

1460 self.assembleStaticSkyModel.clipIter = 3 

1461 self.assembleStaticSkyModel.calcErrorFromInputVariance = True 

1462 self.assembleStaticSkyModel.doWrite = False 

1463 self.detect.doTempLocalBackground = False 

1464 self.detect.reEstimateBackground = False 

1465 self.detect.returnOriginalFootprints = False 

1466 self.detect.thresholdPolarity = "both" 

1467 self.detect.thresholdValue = 7.5 

1468 self.detect.minPixels = 4 

1469 self.detect.isotropicGrow = True 

1470 self.detect.thresholdType = "pixel_stdev" 

1471 self.detect.nSigmaToGrow = 0.4 

1472 # The default nSigmaToGrow for SourceDetectionTask is already 2.4, 

1473 # Explicitly restating because ratio with detect.nSigmaToGrow matters 

1474 self.detectTemplate.nSigmaToGrow = 2.4 

1475 # Higher thresholds make smaller and fewer protected zones around 

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

1477 self.detectTemplate.thresholdValue = 300 

1478 self.detectTemplate.doTempLocalBackground = True 

1479 self.detectTemplate.reEstimateBackground = True 

1480 self.detectTemplate.background.useApprox = False 

1481 self.detectTemplate.returnOriginalFootprints = False 

1482 

1483 def validate(self): 

1484 super().validate() 

1485 if self.assembleStaticSkyModel.doNImage: 

1486 raise ValueError( 

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

1488 "Please set assembleStaticSkyModel.doNImage=False" 

1489 ) 

1490 

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

1492 raise ValueError( 

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

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

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

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

1497 ) 

1498 

1499 

1500class CompareWarpAssembleCoaddTask(AssembleCoaddTask): 

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

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

1503 

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

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

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

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

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

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

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

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

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

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

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

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

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

1517 ``temporalThreshold`` and ``spatialThreshold``. The temporalThreshold is 

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

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

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

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

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

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

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

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

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

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

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

1529 surveys. 

1530 

1531 ``CompareWarpAssembleCoaddTask`` sub-classes 

1532 ``AssembleCoaddTask`` and instantiates ``AssembleCoaddTask`` 

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

1534 

1535 Notes 

1536 ----- 

1537 Debugging: 

1538 This task supports the following debug variables: 

1539 - ``saveCountIm`` 

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

1541 - ``figPath`` 

1542 Path to save the debug fits images and figures 

1543 """ 

1544 

1545 ConfigClass = CompareWarpAssembleCoaddConfig 

1546 _DefaultName = "compareWarpAssembleCoadd" 

1547 

1548 # See the parent class for docstring. 

1549 _doUsePsfMatchedPolygons: bool = True 

1550 

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

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

1553 self.makeSubtask("assembleStaticSkyModel") 

1554 detectionSchema = afwTable.SourceTable.makeMinimalSchema() 

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

1556 if self.config.doPreserveContainedBySource: 

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

1558 if self.config.doScaleWarpVariance: 

1559 self.makeSubtask("scaleWarpVariance") 

1560 if self.config.doFilterMorphological: 

1561 self.makeSubtask("maskStreaks") 

1562 

1563 @utils.inheritDoc(AssembleCoaddTask) 

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

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

1566 subtract from PSF-Matched warps. 

1567 

1568 Returns 

1569 ------- 

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

1571 Results as a struct with attributes: 

1572 

1573 ``templateCoadd`` 

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

1575 ``nImage`` 

1576 Keeps track of exposure count for each pixel 

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

1578 

1579 Raises 

1580 ------ 

1581 RuntimeError 

1582 Raised if ``templateCoadd`` is `None`. 

1583 """ 

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

1585 # generation. 

1586 staticSkyModelInputRefs = copy.deepcopy(inputRefs) 

1587 staticSkyModelInputRefs.inputWarps = inputRefs.psfMatchedWarps 

1588 

1589 # self.assembleStaticSkyModel is not expecting psfMatchedWarps as 

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

1591 # read all of them in to memory. 

1592 del staticSkyModelInputRefs.psfMatchedWarps 

1593 

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

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

1596 staticSkyModelOutputRefs = copy.deepcopy(outputRefs) 

1597 if self.config.assembleStaticSkyModel.doWrite: 

1598 staticSkyModelOutputRefs.coaddExposure = staticSkyModelOutputRefs.templateCoadd 

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

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

1601 del outputRefs.templateCoadd 

1602 del staticSkyModelOutputRefs.templateCoadd 

1603 

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

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

1606 del staticSkyModelOutputRefs.nImage 

1607 

1608 templateCoadd = self.assembleStaticSkyModel.runQuantum( 

1609 butlerQC, staticSkyModelInputRefs, staticSkyModelOutputRefs 

1610 ) 

1611 if templateCoadd is None: 

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

1613 

1614 return pipeBase.Struct( 

1615 templateCoadd=templateCoadd.coaddExposure, 

1616 nImage=templateCoadd.nImage, 

1617 warpRefList=templateCoadd.warpRefList, 

1618 imageScalerList=templateCoadd.imageScalerList, 

1619 weightList=templateCoadd.weightList, 

1620 psfMatchedWarpRefList=inputRefs.psfMatchedWarps, 

1621 ) 

1622 

1623 def _noTemplateMessage(self, warpType): 

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

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

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

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

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

1629 

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

1631 another algorithm like: 

1632 

1633 from lsst.pipe.tasks.assembleCoadd import SafeClipAssembleCoaddTask 

1634 config.assemble.retarget(SafeClipAssembleCoaddTask) 

1635 """ % { 

1636 "warpName": warpName 

1637 } 

1638 return message 

1639 

1640 @utils.inheritDoc(AssembleCoaddTask) 

1641 @timeMethod 

1642 def run( 

1643 self, 

1644 skyInfo, 

1645 *, 

1646 warpRefList, 

1647 imageScalerList, 

1648 weightList, 

1649 psfMatchedWarpRefList, 

1650 supplementaryData, 

1651 ): 

1652 """Notes 

1653 ----- 

1654 Assemble the coadd. 

1655 

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

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

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

1659 method. 

1660 """ 

1661 # Check and match the order of the supplementaryData 

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

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

1664 

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

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

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

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

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

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

1671 

1672 if dataIds != psfMatchedDataIds: 

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

1674 supplementaryData.warpRefList = reorderAndPadList( 

1675 supplementaryData.warpRefList, psfMatchedDataIds, dataIds 

1676 ) 

1677 # Remove in DM-49083 

1678 supplementaryData.imageScalerList = reorderAndPadList( 

1679 supplementaryData.imageScalerList, psfMatchedDataIds, dataIds 

1680 ) 

1681 

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

1683 # artifacts. 

1684 spanSetMaskList = self.findArtifacts( 

1685 supplementaryData.templateCoadd, supplementaryData.warpRefList, supplementaryData.imageScalerList 

1686 ) 

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

1688 # the templateCoadd or the warps. 

1689 badMaskPlanes = [ 

1690 mp 

1691 for mp in self.config.badMaskPlanes 

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

1693 ] 

1694 badMaskPlanes.append("CLIPPED") 

1695 badPixelMask = afwImage.Mask.getPlaneBitMask(badMaskPlanes) 

1696 

1697 result = AssembleCoaddTask.run( 

1698 self, 

1699 skyInfo, 

1700 warpRefList=warpRefList, 

1701 imageScalerList=imageScalerList, 

1702 weightList=weightList, 

1703 altMaskList=spanSetMaskList, 

1704 mask=badPixelMask, 

1705 psfMatchedWarpRefList=psfMatchedWarpRefList, 

1706 ) 

1707 

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

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

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

1711 return result 

1712 

1713 def applyAltEdgeMask(self, mask, altMaskList): 

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

1715 

1716 Parameters 

1717 ---------- 

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

1719 Original mask. 

1720 altMaskList : `list` of `dict` 

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

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

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

1724 the mask. 

1725 """ 

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

1727 for visitMask in altMaskList: 

1728 if "EDGE" in visitMask: 

1729 for spanSet in visitMask["EDGE"]: 

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

1731 

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

1733 """Find artifacts. 

1734 

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

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

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

1738 difference image and filters the artifacts detected in each using 

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

1740 difficult to subtract cleanly. 

1741 

1742 Parameters 

1743 ---------- 

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

1745 Exposure to serve as model of static sky. 

1746 warpRefList : `list` 

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

1748 imageScalerList : `list` 

1749 List of image scalers. 

1750 Deprecated and will be removed after v29. 

1751 

1752 Returns 

1753 ------- 

1754 altMasks : `list` of `dict` 

1755 List of dicts containing information about CLIPPED 

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

1757 """ 

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

1759 coaddBBox = templateCoadd.getBBox() 

1760 slateIm = afwImage.ImageU(coaddBBox) 

1761 epochCountImage = afwImage.ImageU(coaddBBox) 

1762 nImage = afwImage.ImageU(coaddBBox) 

1763 spanSetArtifactList = [] 

1764 spanSetNoDataMaskList = [] 

1765 spanSetEdgeList = [] 

1766 spanSetBadMorphoList = [] 

1767 badPixelMask = self.getBadPixelMask() 

1768 

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

1770 templateCoadd.mask.clearAllMaskPlanes() 

1771 

1772 if self.config.doPreserveContainedBySource: 

1773 sacrificeToDetection = templateCoadd.clone() 

1774 templateFootprints = self.detectTemplate.detectFootprints(sacrificeToDetection) 

1775 del sacrificeToDetection 

1776 else: 

1777 templateFootprints = None 

1778 

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

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

1781 if warpDiffExp is not None: 

1782 # This nImage only approximates the final nImage because it 

1783 # uses the PSF-matched mask. 

1784 nImage.array += ( 

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

1786 ).astype(numpy.uint16) 

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

1788 fpSet.positive.merge(fpSet.negative) 

1789 footprints = fpSet.positive 

1790 slateIm.set(0) 

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

1792 

1793 # Remove artifacts due to defects before they contribute to 

1794 # the epochCountImage. 

1795 if self.config.doPrefilterArtifacts: 

1796 spanSetList = self.prefilterArtifacts(spanSetList, warpDiffExp) 

1797 

1798 # Clear mask before adding prefiltered spanSets 

1799 self.detect.clearMask(warpDiffExp.mask) 

1800 for spans in spanSetList: 

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

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

1803 epochCountImage += slateIm 

1804 

1805 if self.config.doFilterMorphological: 

1806 maskName = self.config.streakMaskName 

1807 # clear single frame streak mask if it exists already 

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

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

1810 else: 

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

1812 

1813 _ = self.maskStreaks.run(warpDiffExp) 

1814 streakMask = warpDiffExp.mask 

1815 initSpanSetStreak = afwGeom.SpanSet.fromMask( 

1816 streakMask, streakMask.getPlaneBitMask(maskName) 

1817 ).split() 

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

1819 # wings. 

1820 psf = warpDiffExp.getPsf() 

1821 spanSetStreak = [] 

1822 for sset in initSpanSetStreak: 

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

1824 doKeep = True 

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

1826 if footprint.spans.contains(sset): 

1827 doKeep = False 

1828 break 

1829 if not doKeep: 

1830 continue 

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

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

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

1834 spanSetStreak.append(sset_dilated) 

1835 

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

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

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

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

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

1841 # the direct warp. 

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

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

1844 nansMask.setXY0(warpDiffExp.getXY0()) 

1845 edgeMask = warpDiffExp.mask 

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

1847 else: 

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

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

1850 nansMask = afwImage.MaskX(coaddBBox, 1) 

1851 spanSetList = [] 

1852 spanSetEdgeMask = [] 

1853 spanSetStreak = [] 

1854 

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

1856 

1857 spanSetNoDataMaskList.append(spanSetNoDataMask) 

1858 spanSetArtifactList.append(spanSetList) 

1859 spanSetEdgeList.append(spanSetEdgeMask) 

1860 if self.config.doFilterMorphological: 

1861 spanSetBadMorphoList.append(spanSetStreak) 

1862 

1863 if lsstDebug.Info(__name__).saveCountIm: 

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

1865 epochCountImage.writeFits(path) 

1866 

1867 for i, spanSetList in enumerate(spanSetArtifactList): 

1868 if spanSetList: 

1869 filteredSpanSetList = self.filterArtifacts( 

1870 spanSetList, epochCountImage, nImage, templateFootprints 

1871 ) 

1872 spanSetArtifactList[i] = filteredSpanSetList 

1873 if self.config.doFilterMorphological: 

1874 spanSetArtifactList[i] += spanSetBadMorphoList[i] 

1875 

1876 altMasks = [] 

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

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

1879 return altMasks 

1880 

1881 def prefilterArtifacts(self, spanSetList, exp): 

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

1883 

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

1885 temporal information should go in this method. 

1886 

1887 Parameters 

1888 ---------- 

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

1890 List of SpanSets representing artifact candidates. 

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

1892 Exposure containing mask planes used to prefilter. 

1893 

1894 Returns 

1895 ------- 

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

1897 List of SpanSets with artifacts. 

1898 """ 

1899 existingMaskPlanes = [ 

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

1901 ] 

1902 badPixelMask = exp.mask.getPlaneBitMask(existingMaskPlanes) 

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

1904 returnSpanSetList = [] 

1905 bbox = exp.getBBox() 

1906 x0, y0 = exp.getXY0() 

1907 for i, span in enumerate(spanSetList): 

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

1909 yIndexLocal = numpy.array(y) - y0 

1910 xIndexLocal = numpy.array(x) - x0 

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

1912 if goodRatio > self.config.prefilterArtifactsRatio: 

1913 returnSpanSetList.append(span) 

1914 return returnSpanSetList 

1915 

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

1917 """Filter artifact candidates. 

1918 

1919 Parameters 

1920 ---------- 

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

1922 List of SpanSets representing artifact candidates. 

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

1924 Image of accumulated number of warpDiff detections. 

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

1926 Image of the accumulated number of total epochs contributing. 

1927 

1928 Returns 

1929 ------- 

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

1931 List of SpanSets with artifacts. 

1932 """ 

1933 maskSpanSetList = [] 

1934 x0, y0 = epochCountImage.getXY0() 

1935 for i, span in enumerate(spanSetList): 

1936 y, x = span.indices() 

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

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

1939 outlierN = epochCountImage.array[yIdxLocal, xIdxLocal] 

1940 totalN = nImage.array[yIdxLocal, xIdxLocal] 

1941 

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

1943 # characteristic config.maxNumEpochs. 

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

1945 totalN 

1946 ) 

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

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

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

1950 percentBelowThreshold = nPixelsBelowThreshold / len(outlierN) 

1951 if percentBelowThreshold > self.config.spatialThreshold: 

1952 maskSpanSetList.append(span) 

1953 

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

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

1956 # do not clip. 

1957 filteredMaskSpanSetList = [] 

1958 for span in maskSpanSetList: 

1959 doKeep = True 

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

1961 if footprint.spans.contains(span): 

1962 doKeep = False 

1963 break 

1964 if doKeep: 

1965 filteredMaskSpanSetList.append(span) 

1966 maskSpanSetList = filteredMaskSpanSetList 

1967 

1968 return maskSpanSetList 

1969 

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

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

1972 

1973 Parameters 

1974 ---------- 

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

1976 Dataset handle for the warp. 

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

1978 An image scaler object. 

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

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

1981 Exposure to be substracted from the scaled warp. 

1982 

1983 Returns 

1984 ------- 

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

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

1987 """ 

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

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

1990 if warpRef is None: 

1991 return None 

1992 

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

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

1995 # Remove in DM-49083. 

1996 if imageScaler is not None: 

1997 imageScaler.scaleMaskedImage(warp.getMaskedImage()) 

1998 mi = warp.getMaskedImage() 

1999 if self.config.doScaleWarpVariance: 

2000 try: 

2001 self.scaleWarpVariance.run(mi) 

2002 except Exception as exc: 

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

2004 mi -= templateCoadd.getMaskedImage() 

2005 return warp