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

695 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-03 08:18 +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.meas.algorithms.subtractBackground import TooManyMaskedPixelsError 

52from lsst.pipe.tasks.coaddBase import ( 

53 CoaddBaseTask, 

54 makeSkyInfo, 

55 removeMaskPlanes, 

56 reorderAndPadList, 

57 setRejectedMaskMapping, 

58 subBBoxIter, 

59) 

60from lsst.pipe.tasks.healSparseMapping import HealSparseInputMapTask 

61from lsst.pipe.tasks.interpImage import InterpImageTask 

62from lsst.pipe.tasks.scaleZeroPoint import ScaleZeroPointTask 

63from lsst.skymap import BaseSkyMap 

64from lsst.utils.timer import timeMethod 

65 

66log = logging.getLogger(__name__) 

67 

68 

69class AssembleCoaddConnections( 

70 pipeBase.PipelineTaskConnections, 

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

72 defaultTemplates={ 

73 "inputCoaddName": "deep", 

74 "outputCoaddName": "deep", 

75 "warpType": "direct", 

76 "warpTypeSuffix": "", 

77 }, 

78): 

79 inputWarps = pipeBase.connectionTypes.Input( 

80 doc=( 

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

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

83 "warpType config parameter" 

84 ), 

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

86 storageClass="ExposureF", 

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

88 deferLoad=True, 

89 multiple=True, 

90 ) 

91 skyMap = pipeBase.connectionTypes.Input( 

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

93 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME, 

94 storageClass="SkyMap", 

95 dimensions=("skymap",), 

96 ) 

97 selectedVisits = pipeBase.connectionTypes.Input( 

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

99 name="{outputCoaddName}Visits", 

100 storageClass="StructuredDataDict", 

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

102 ) 

103 brightObjectMask = pipeBase.connectionTypes.PrerequisiteInput( 

104 doc=( 

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

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

107 ), 

108 name="brightObjectMask", 

109 storageClass="ObjectMaskCatalog", 

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

111 minimum=0, 

112 ) 

113 coaddExposure = pipeBase.connectionTypes.Output( 

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

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

116 storageClass="ExposureF", 

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

118 ) 

119 nImage = pipeBase.connectionTypes.Output( 

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

121 name="{outputCoaddName}Coadd_nImage", 

122 storageClass="ImageU", 

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

124 ) 

125 inputMap = pipeBase.connectionTypes.Output( 

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

127 name="{outputCoaddName}Coadd_inputMap", 

128 storageClass="HealSparseMap", 

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

130 ) 

131 

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

133 super().__init__(config=config) 

134 

135 if not config.doMaskBrightObjects: 

136 self.prerequisiteInputs.remove("brightObjectMask") 

137 

138 if not config.doSelectVisits: 

139 self.inputs.remove("selectedVisits") 

140 

141 if not config.doNImage: 

142 self.outputs.remove("nImage") 

143 

144 if not self.config.doInputMap: 

145 self.outputs.remove("inputMap") 

146 

147 if not self.config.doWriteArtifactMasks: 

148 self.outputs.remove("artifactMasks") 

149 

150 

151class AssembleCoaddConfig( 

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

153): 

154 warpType = pexConfig.ChoiceField( 

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

156 dtype=str, 

157 default="direct", 

158 allowed={ 

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

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

161 }, 

162 ) 

163 subregionSize = pexConfig.ListField( 

164 dtype=int, 

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

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

167 " at once.", 

168 length=2, 

169 default=(2000, 2000), 

170 ) 

171 statistic = pexConfig.Field( 

172 dtype=str, 

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

174 default="MEANCLIP", 

175 ) 

176 doOnlineForMean = pexConfig.Field( 

177 dtype=bool, 

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

179 default=False, 

180 ) 

181 sigmaClip = pexConfig.Field( 

182 dtype=float, 

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

184 default=3.0, 

185 ) 

186 clipIter = pexConfig.Field( 

187 dtype=int, 

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

189 default=2, 

190 ) 

191 calcErrorFromInputVariance = pexConfig.Field( 

192 dtype=bool, 

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

194 "statistic. Passed to " 

195 "StatisticsControl.setCalcErrorFromInputVariance()", 

196 default=True, 

197 ) 

198 doScaleZeroPoint = pexConfig.Field( 

199 dtype=bool, 

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

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

202 default=False, 

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

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

205 "after v29.", 

206 ) 

207 scaleZeroPoint = pexConfig.ConfigurableField( 

208 target=ScaleZeroPointTask, 

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

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

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

212 "after v29.", 

213 ) 

214 doInterp = pexConfig.Field( 

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

216 dtype=bool, 

217 default=True, 

218 ) 

219 interpImage = pexConfig.ConfigurableField( 

220 target=InterpImageTask, 

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

222 ) 

223 doWrite = pexConfig.Field( 

224 doc="Persist coadd?", 

225 dtype=bool, 

226 default=True, 

227 ) 

228 doWriteArtifactMasks = pexConfig.Field( 

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

230 dtype=bool, 

231 default=False, 

232 ) 

233 doNImage = pexConfig.Field( 

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

235 dtype=bool, 

236 default=False, 

237 ) 

238 maskPropagationThresholds = pexConfig.DictField( 

239 keytype=str, 

240 itemtype=float, 

241 doc=( 

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

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

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

245 "would have contributed exceeds this value." 

246 ), 

247 default={"SAT": 0.1}, 

248 ) 

249 removeMaskPlanes = pexConfig.ListField( 

250 dtype=str, 

251 default=["NOT_DEBLENDED"], 

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

253 ) 

254 doMaskBrightObjects = pexConfig.Field( 

255 dtype=bool, 

256 default=False, 

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

258 ) 

259 brightObjectMaskName = pexConfig.Field( 

260 dtype=str, 

261 default="BRIGHT_OBJECT", 

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

263 ) 

264 coaddPsf = pexConfig.ConfigField( 

265 doc="Configuration for CoaddPsf", 

266 dtype=measAlg.CoaddPsfConfig, 

267 ) 

268 doAttachTransmissionCurve = pexConfig.Field( 

269 dtype=bool, 

270 default=False, 

271 optional=False, 

272 doc=( 

273 "Attach a piecewise TransmissionCurve for the coadd? " 

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

275 ), 

276 ) 

277 hasFakes = pexConfig.Field( 

278 dtype=bool, 

279 default=False, 

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

281 ) 

282 doSelectVisits = pexConfig.Field( 

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

284 dtype=bool, 

285 default=False, 

286 ) 

287 doInputMap = pexConfig.Field( 

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

289 dtype=bool, 

290 default=False, 

291 ) 

292 inputMapper = pexConfig.ConfigurableField( 

293 doc="Input map creation subtask.", 

294 target=HealSparseInputMapTask, 

295 ) 

296 

297 def setDefaults(self): 

298 super().setDefaults() 

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

300 self.coaddPsf.cacheSize = 0 

301 

302 def validate(self): 

303 super().validate() 

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

305 raise pexConfig.FieldValidationError( 

306 self.__class__.doInterp, 

307 self, 

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

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

310 ) 

311 

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

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

314 stackableStats = [ 

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

316 ] 

317 raise pexConfig.FieldValidationError( 

318 self.__class__.statistic, 

319 self, 

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

321 ) 

322 

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

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

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

326 raise pexConfig.FieldValidationError( 

327 self.__class__.doWriteArtifactMasks, 

328 self, 

329 "doWriteArtifactMasks is only valid for CompareWarpAssembleCoaddConfig.", 

330 ) 

331 

332 

333class AssembleCoaddTask(CoaddBaseTask, pipeBase.PipelineTask): 

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

335 

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

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

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

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

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

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

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

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

344 

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

346 

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

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

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

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

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

352 

353 You can retarget these subtasks if you wish. 

354 

355 Raises 

356 ------ 

357 RuntimeError 

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

359 

360 Notes 

361 ----- 

362 Debugging: 

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

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

365 documentation for the subtasks for further information. 

366 """ 

367 

368 ConfigClass = AssembleCoaddConfig 

369 _DefaultName = "assembleCoadd" 

370 

371 _doUsePsfMatchedPolygons: bool = False 

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

373 

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

375 warps to non Psf-Matched warps. 

376 """ 

377 

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

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

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

381 if args: 

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

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

384 warnings.warn( 

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

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

387 FutureWarning, 

388 stacklevel=2, 

389 ) 

390 

391 super().__init__(**kwargs) 

392 self.makeSubtask("interpImage") 

393 if self.config.doScaleZeroPoint: 

394 # Remove completely in DM-49083 

395 self.makeSubtask("scaleZeroPoint") 

396 

397 if self.config.doMaskBrightObjects: 

398 mask = afwImage.Mask() 

399 try: 

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

401 except pexExceptions.LsstCppException: 

402 raise RuntimeError( 

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

404 % mask.getMaskPlaneDict().keys() 

405 ) 

406 del mask 

407 

408 if self.config.doInputMap: 

409 self.makeSubtask("inputMapper") 

410 

411 self.warpType = self.config.warpType 

412 

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

414 inputData = butlerQC.get(inputRefs) 

415 

416 # Construct skyInfo expected by run 

417 # Do not remove skyMap from inputData in case _makeSupplementaryData 

418 # needs it 

419 skyMap = inputData["skyMap"] 

420 outputDataId = butlerQC.quantum.dataId 

421 

422 inputData["skyInfo"] = makeSkyInfo( 

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

424 ) 

425 

426 if self.config.doSelectVisits: 

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

428 else: 

429 warpRefList = inputData["inputWarps"] 

430 

431 # Although psfMatchedWarps are specifically required by only by a 

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

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

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

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

436 if self._doUsePsfMatchedPolygons: 

437 if self.config.doSelectVisits: 

438 psfMatchedWarpRefList = self.filterWarps( 

439 inputData["psfMatchedWarps"], 

440 inputData["selectedVisits"], 

441 ) 

442 else: 

443 psfMatchedWarpRefList = inputData["psfMatchedWarps"] 

444 else: 

445 psfMatchedWarpRefList = [] 

446 

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

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

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

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

451 

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

453 

454 try: 

455 retStruct = self.run( 

456 inputData["skyInfo"], 

457 warpRefList=inputs.warpRefList, 

458 imageScalerList=inputs.imageScalerList, 

459 weightList=inputs.weightList, 

460 psfMatchedWarpRefList=inputs.psfMatchedWarpRefList, 

461 supplementaryData=supplementaryData, 

462 ) 

463 except pipeBase.AlgorithmError as e: 

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

465 raise error from e 

466 

467 inputData.setdefault("brightObjectMask", None) 

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

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

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

471 

472 if self.config.doWriteArtifactMasks: 

473 artifactMasksRefList = reorderAndPadList( 

474 outputRefs.artifactMasks, 

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

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

477 ) 

478 for altMask, warpRef, outputRef in zip( 

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

480 ): 

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

482 self.applyAltMaskPlanes(mask, altMask) 

483 butlerQC.put(mask, outputRef) 

484 

485 if self.config.doWrite: 

486 butlerQC.put(retStruct, outputRefs) 

487 

488 return retStruct 

489 

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

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

492 

493 Parameters 

494 ---------- 

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

496 The coadded exposure to process. 

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

498 Table of bright objects to mask. 

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

500 Data identification. 

501 """ 

502 if self.config.doInterp: 

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

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

505 varArray = coaddExposure.variance.array 

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

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

508 

509 if self.config.doMaskBrightObjects: 

510 self.setBrightObjectMasks(coaddExposure, brightObjectMasks, dataId) 

511 

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

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

514 

515 Duplicates interface of `runQuantum` method. 

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

517 coadd dataRef/handle for performing preliminary processing before 

518 assembling the coadd. 

519 

520 Parameters 

521 ---------- 

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

523 Gen3 Butler object for fetching additional data products before 

524 running the Task specialized for quantum being processed. 

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

526 Attributes are the names of the connections describing input 

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

528 corresponding dataset type. DataIds are guaranteed to match data 

529 objects in ``inputData``. 

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

531 Attributes are the names of the connections describing output 

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

533 for the corresponding dataset type. 

534 """ 

535 return pipeBase.Struct() 

536 

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

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

539 each warp. 

540 

541 Before coadding these Warps together compute the weight for each 

542 Warp. 

543 

544 Parameters 

545 ---------- 

546 refList : `list` 

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

548 psfMatchedWarpRefList : `list` | None, optional 

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

550 

551 Returns 

552 ------- 

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

554 Results as a struct with attributes: 

555 

556 ``warpRefList`` 

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

558 ``weightList`` 

559 `list` of weightings. 

560 ``imageScalerList`` 

561 `list` of image scalers. 

562 Deprecated and will be removed in DM-49083. 

563 """ 

564 statsCtrl = afwMath.StatisticsControl() 

565 statsCtrl.setNumSigmaClip(self.config.sigmaClip) 

566 statsCtrl.setNumIter(self.config.clipIter) 

567 statsCtrl.setAndMask(self.getBadPixelMask()) 

568 statsCtrl.setNanSafe(True) 

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

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

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

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

573 warpRefList = [] 

574 weightList = [] 

575 # Remove in DM-49083 

576 imageScalerList = [] 

577 outputPsfMatchedWarpRefList = [] 

578 

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

580 # their dataId. 

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

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

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

584 

585 if psfMatchedWarpRefList is None: 

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

587 else: 

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

589 

590 warpName = self.getTempExpDatasetName(self.warpType) 

591 for warpRef in refList: 

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

593 # Ignore any input warp that is empty of data 

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

595 continue 

596 maskedImage = warp.getMaskedImage() 

597 

598 # Allow users to scale the zero point for backward 

599 # compatibility. Remove imageScalarList in DM-49083. 

600 if self.config.doScaleZeroPoint: 

601 imageScaler = self.scaleZeroPoint.computeImageScaler( 

602 exposure=warp, 

603 dataRef=warpRef, # FIXME 

604 ) 

605 try: 

606 imageScaler.scaleMaskedImage(maskedImage) 

607 except Exception as e: 

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

609 continue 

610 imageScalerList.append(imageScaler) 

611 else: 

612 imageScalerList.append(None) 

613 if "BUNIT" not in warp.metadata: 

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

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

616 raise ValueError( 

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

618 ) 

619 statObj = afwMath.makeStatistics( 

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

621 ) 

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

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

624 weight = 1.0 / float(meanVar) 

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

626 else: 

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

628 continue 

629 

630 del maskedImage 

631 del warp 

632 

633 warpRefList.append(warpRef) 

634 weightList.append(weight) 

635 

636 dataId = warpRef.dataId 

637 psfMatchedWarpRef = psfMatchedWarpRefDict.get(dataId, None) 

638 outputPsfMatchedWarpRefList.append(psfMatchedWarpRef) 

639 

640 return pipeBase.Struct( 

641 warpRefList=warpRefList, 

642 weightList=weightList, 

643 imageScalerList=imageScalerList, 

644 psfMatchedWarpRefList=outputPsfMatchedWarpRefList, 

645 ) 

646 

647 def prepareStats(self, mask=None): 

648 """Prepare the statistics for coadding images. 

649 

650 Parameters 

651 ---------- 

652 mask : `int`, optional 

653 Bit mask value to exclude from coaddition. 

654 

655 Returns 

656 ------- 

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

658 Statistics as a struct with attributes: 

659 

660 ``statsCtrl`` 

661 Statistics control object for coadd 

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

663 ``statsFlags`` 

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

665 """ 

666 if mask is None: 

667 mask = self.getBadPixelMask() 

668 statsCtrl = afwMath.StatisticsControl() 

669 statsCtrl.setNumSigmaClip(self.config.sigmaClip) 

670 statsCtrl.setNumIter(self.config.clipIter) 

671 statsCtrl.setAndMask(mask) 

672 statsCtrl.setNanSafe(True) 

673 statsCtrl.setWeighted(True) 

674 statsCtrl.setCalcErrorFromInputVariance(self.config.calcErrorFromInputVariance) 

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

676 bit = afwImage.Mask.getMaskPlane(plane) 

677 statsCtrl.setMaskPropagationThreshold(bit, threshold) 

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

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

680 

681 @timeMethod 

682 def run( 

683 self, 

684 skyInfo, 

685 *, 

686 warpRefList, 

687 imageScalerList, 

688 weightList, 

689 psfMatchedWarpRefList=None, 

690 altMaskList=None, 

691 mask=None, 

692 supplementaryData=None, 

693 ): 

694 """Assemble a coadd from input warps. 

695 

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

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

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

699 conserve memory usage. Iterate over subregions within the outer 

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

701 subregions from the coaddTempExps with the statistic specified. 

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

703 

704 Parameters 

705 ---------- 

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

707 Struct with geometric information about the patch. 

708 warpRefList : `list` 

709 List of dataset handles (data references) to Warps 

710 (previously called CoaddTempExps). 

711 imageScalerList : `list` 

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

713 in DM-49083. 

714 weightList : `list` 

715 List of weights. 

716 psfMatchedWarpRefList : `list`, optional 

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

718 altMaskList : `list`, optional 

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

720 warp. 

721 mask : `int`, optional 

722 Bit mask value to exclude from coaddition. 

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

724 Struct with additional data products needed to assemble coadd. 

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

726 and override `run`. 

727 

728 Returns 

729 ------- 

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

731 Results as a struct with attributes: 

732 

733 ``coaddExposure`` 

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

735 ``nImage`` 

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

737 ``inputMap`` 

738 Bit-wise map of inputs, if requested. 

739 ``warpRefList`` 

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

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

742 ``imageScalerList`` 

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

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

745 ``weightList`` 

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

747 

748 Raises 

749 ------ 

750 lsst.pipe.base.NoWorkFound 

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

752 """ 

753 warpName = self.getTempExpDatasetName(self.warpType) 

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

755 if not warpRefList: 

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

757 

758 stats = self.prepareStats(mask=mask) 

759 

760 if altMaskList is None: 

761 altMaskList = [None] * len(warpRefList) 

762 

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

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

765 if self.config.doScaleZeroPoint: 

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

767 else: 

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

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

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

771 coaddMaskedImage = coaddExposure.getMaskedImage() 

772 subregionSizeArr = self.config.subregionSize 

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

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

775 # assembleSubregion. 

776 if self.config.doNImage: 

777 nImage = afwImage.ImageU(skyInfo.bbox) 

778 else: 

779 nImage = None 

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

781 # masked in assembleSubregion. 

782 if self.config.doInputMap: 

783 self.inputMapper.build_ccd_input_map( 

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

785 ) 

786 

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

788 try: 

789 self.assembleOnlineMeanCoadd( 

790 coaddExposure, 

791 warpRefList, 

792 imageScalerList, 

793 weightList, 

794 altMaskList, 

795 stats.ctrl, 

796 nImage=nImage, 

797 ) 

798 except Exception as e: 

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

800 raise 

801 else: 

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

803 try: 

804 self.assembleSubregion( 

805 coaddExposure, 

806 subBBox, 

807 warpRefList, 

808 imageScalerList, 

809 weightList, 

810 altMaskList, 

811 stats.flags, 

812 stats.ctrl, 

813 nImage=nImage, 

814 ) 

815 except Exception as e: 

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

817 raise 

818 

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

820 # accumulation. 

821 if self.config.doInputMap: 

822 self.inputMapper.finalize_ccd_input_map_mask() 

823 inputMap = self.inputMapper.ccd_input_map 

824 else: 

825 inputMap = None 

826 

827 self.setInexactPsf(coaddMaskedImage.getMask()) 

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

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

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

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

832 return pipeBase.Struct( 

833 coaddExposure=coaddExposure, 

834 nImage=nImage, 

835 warpRefList=warpRefList, 

836 imageScalerList=imageScalerList, 

837 weightList=weightList, 

838 inputMap=inputMap, 

839 altMaskList=altMaskList, 

840 ) 

841 

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

843 """Set the metadata for the coadd. 

844 

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

846 

847 Parameters 

848 ---------- 

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

850 The target exposure for the coadd. 

851 warpRefList : `list` 

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

853 weightList : `list` 

854 List of weights. 

855 psfMatchedWarpRefList : `list` | None, optional 

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

857 

858 Raises 

859 ------ 

860 AssertionError 

861 Raised if there is a length mismatch. 

862 """ 

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

864 

865 if psfMatchedWarpRefList: 

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

867 

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

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

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

871 # (see #2777). 

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

873 

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

875 

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

877 

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

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

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

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

882 coaddInputs.ccds.reserve(numCcds) 

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

884 

885 # Set the exposure units to nJy 

886 # No need to check after DM-49083. 

887 if not self.config.doScaleZeroPoint: 

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

889 

890 # psfMatchedWarpRefList should be empty except in CompareWarpCoadd. 

891 if self._doUsePsfMatchedPolygons: 

892 # Set validPolygons for warp before addVisitToCoadd 

893 self._setValidPolygons(warpList, psfMatchedWarpRefList) 

894 

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

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

897 

898 coaddInputs.visits.sort() 

899 coaddInputs.ccds.sort() 

900 if self.warpType == "psfMatched": 

901 # The modelPsf BBox for a psfMatchedWarp/coaddTempExp was 

902 # dynamically defined by ModelPsfMatchTask as the square box 

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

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

905 # having the maximum width (sufficient because square) 

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

907 modelPsfWidthList = [ 

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

909 ] 

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

911 else: 

912 psf = measAlg.CoaddPsf( 

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

914 ) 

915 coaddExposure.setPsf(psf) 

916 apCorrMap = measAlg.makeCoaddApCorrMap( 

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

918 ) 

919 coaddExposure.getInfo().setApCorrMap(apCorrMap) 

920 if self.config.doAttachTransmissionCurve: 

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

922 coaddExposure.getInfo().setTransmissionCurve(transmissionCurve) 

923 

924 def assembleSubregion( 

925 self, 

926 coaddExposure, 

927 bbox, 

928 warpRefList, 

929 imageScalerList, 

930 weightList, 

931 altMaskList, 

932 statsFlags, 

933 statsCtrl, 

934 nImage=None, 

935 ): 

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

937 

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

939 if one is passed. Remove mask planes listed in 

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

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

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

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

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

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

946 

947 Parameters 

948 ---------- 

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

950 The target exposure for the coadd. 

951 bbox : `lsst.geom.Box` 

952 Sub-region to coadd. 

953 warpRefList : `list` 

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

955 imageScalerList : `list` 

956 List of image scalers. 

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

958 weightList : `list` 

959 List of weights. 

960 altMaskList : `list` 

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

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

963 name to which to add the spans. 

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

965 Property object for statistic for coadd. 

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

967 Statistics control object for coadd. 

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

969 Keeps track of exposure count for each pixel. 

970 """ 

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

972 

973 coaddExposure.mask.addMaskPlane("REJECTED") 

974 coaddExposure.mask.addMaskPlane("CLIPPED") 

975 coaddExposure.mask.addMaskPlane("SENSOR_EDGE") 

976 maskMap = setRejectedMaskMapping(statsCtrl) 

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

978 maskedImageList = [] 

979 if nImage is not None: 

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

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

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

983 

984 maskedImage = exposure.getMaskedImage() 

985 mask = maskedImage.getMask() 

986 if altMask is not None: 

987 self.applyAltMaskPlanes(mask, altMask) 

988 # Remove in DM-49083 

989 if imageScaler is not None: 

990 imageScaler.scaleMaskedImage(maskedImage) 

991 

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

993 # In legacyCoadd, pixels may also be excluded by 

994 # afwMath.statisticsStack. 

995 if nImage is not None: 

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

997 if self.config.removeMaskPlanes: 

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

999 maskedImageList.append(maskedImage) 

1000 

1001 if self.config.doInputMap: 

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

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

1004 

1005 with self.timer("stack"): 

1006 coaddSubregion = afwMath.statisticsStack( 

1007 maskedImageList, 

1008 statsFlags, 

1009 statsCtrl, 

1010 weightList, 

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

1012 maskMap, 

1013 ) 

1014 coaddExposure.maskedImage.assign(coaddSubregion, bbox) 

1015 if nImage is not None: 

1016 nImage.assign(subNImage, bbox) 

1017 

1018 def assembleOnlineMeanCoadd( 

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

1020 ): 

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

1022 

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

1024 It only works for MEAN statistics. 

1025 

1026 Parameters 

1027 ---------- 

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

1029 The target exposure for the coadd. 

1030 warpRefList : `list` 

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

1032 imageScalerList : `list` 

1033 List of image scalers. 

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

1035 weightList : `list` 

1036 List of weights. 

1037 altMaskList : `list` 

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

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

1040 name to which to add the spans. 

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

1042 Statistics control object for coadd. 

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

1044 Keeps track of exposure count for each pixel. 

1045 """ 

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

1047 

1048 coaddExposure.mask.addMaskPlane("REJECTED") 

1049 coaddExposure.mask.addMaskPlane("CLIPPED") 

1050 coaddExposure.mask.addMaskPlane("SENSOR_EDGE") 

1051 maskMap = setRejectedMaskMapping(statsCtrl) 

1052 thresholdDict = AccumulatorMeanStack.stats_ctrl_to_threshold_dict(statsCtrl) 

1053 

1054 bbox = coaddExposure.maskedImage.getBBox() 

1055 

1056 stacker = AccumulatorMeanStack( 

1057 coaddExposure.image.array.shape, 

1058 statsCtrl.getAndMask(), 

1059 mask_threshold_dict=thresholdDict, 

1060 mask_map=maskMap, 

1061 no_good_pixels_mask=statsCtrl.getNoGoodPixelsMask(), 

1062 calc_error_from_input_variance=self.config.calcErrorFromInputVariance, 

1063 compute_n_image=(nImage is not None), 

1064 ) 

1065 

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

1067 warpRefList, imageScalerList, altMaskList, weightList 

1068 ): 

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

1070 maskedImage = exposure.getMaskedImage() 

1071 mask = maskedImage.getMask() 

1072 if altMask is not None: 

1073 self.applyAltMaskPlanes(mask, altMask) 

1074 # Remove in DM-49083. 

1075 if imageScaler is not None: 

1076 imageScaler.scaleMaskedImage(maskedImage) 

1077 if self.config.removeMaskPlanes: 

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

1079 

1080 stacker.add_masked_image(maskedImage, weight=weight) 

1081 

1082 if self.config.doInputMap: 

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

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

1085 

1086 stacker.fill_stacked_masked_image(coaddExposure.maskedImage) 

1087 

1088 if nImage is not None: 

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

1090 

1091 def applyAltMaskPlanes(self, mask, altMaskSpans): 

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

1093 

1094 Parameters 

1095 ---------- 

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

1097 Original mask. 

1098 altMaskSpans : `dict` 

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

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

1101 and list of SpanSets to apply to the mask. 

1102 

1103 Returns 

1104 ------- 

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

1106 Updated mask. 

1107 """ 

1108 if self._doUsePsfMatchedPolygons: 

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

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

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

1112 # still be rejected because of NO_DATA. 

1113 # self._doUsePsfMatchedPolygons should be True only in 

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

1115 # *before* applying the new masks below. 

1116 for spanSet in altMaskSpans["NO_DATA"]: 

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

1118 

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

1120 maskClipValue = mask.addMaskPlane(plane) 

1121 for spanSet in spanSetList: 

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

1123 return mask 

1124 

1125 def _setValidPolygons(self, warpList, psfMatchedWarpRefList): 

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

1127 it exists. 

1128 

1129 Parameters 

1130 ---------- 

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

1132 List of warps. 

1133 psfMatchedWarpRefList : `Iterable` \ 

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

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

1136 ``warpList``. 

1137 

1138 Raises 

1139 ------ 

1140 ValueError 

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

1142 (direct) warps. 

1143 """ 

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

1145 if psfMatchedWarpRef is None: 

1146 continue 

1147 

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

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

1150 

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

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

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

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

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

1156 visit = psfMatchedWarpRef.dataId["visit"] 

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

1158 

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

1160 self.log.debug( 

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

1162 "warp without a validPolygon", 

1163 psfMatchedWarpRef.dataId["visit"], 

1164 ) 

1165 

1166 for psfMatchedCcdRow in psfMatchedCcdTable: 

1167 if not psfMatchedCcdRow.validPolygon: 

1168 self.log.warning( 

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

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

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

1172 psfMatchedCcdRow.id, 

1173 ) 

1174 else: 

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

1176 ccdRow.validPolygon = psfMatchedCcdRow.validPolygon 

1177 

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

1179 """Set the bright object masks. 

1180 

1181 Parameters 

1182 ---------- 

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

1184 Exposure under consideration. 

1185 brightObjectMasks : `lsst.afw.table` 

1186 Table of bright objects to mask. 

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

1188 Data identifier dict for patch. 

1189 """ 

1190 if brightObjectMasks is None: 

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

1192 return 

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

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

1195 wcs = exposure.getWcs() 

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

1197 

1198 for rec in brightObjectMasks: 

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

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

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

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

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

1204 

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

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

1207 

1208 bbox = geom.BoxI( 

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

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

1211 ) 

1212 spans = afwGeom.SpanSet(bbox) 

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

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

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

1216 else: 

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

1218 continue 

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

1220 

1221 def setInexactPsf(self, mask): 

1222 """Set INEXACT_PSF mask plane. 

1223 

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

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

1226 these pixels. 

1227 

1228 Parameters 

1229 ---------- 

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

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

1232 """ 

1233 mask.addMaskPlane("INEXACT_PSF") 

1234 inexactPsf = mask.getPlaneBitMask("INEXACT_PSF") 

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

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

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

1238 array = mask.getArray() 

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

1240 array[selected] |= inexactPsf 

1241 

1242 def filterWarps(self, inputs, goodVisits): 

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

1244 goodVisit. 

1245 

1246 Parameters 

1247 ---------- 

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

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

1250 containing visit. 

1251 goodVisit : `dict` 

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

1253 

1254 Returns 

1255 ------- 

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

1257 Filtered and sorted list of inputRefs with visitId in goodVisits 

1258 ordered by goodVisit. 

1259 """ 

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

1261 filteredInputs = [] 

1262 for visit in goodVisits.keys(): 

1263 if visit in inputWarpDict: 

1264 filteredInputs.append(inputWarpDict[visit]) 

1265 return filteredInputs 

1266 

1267 

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

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

1270 footprint. 

1271 

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

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

1274 ignoreMask set. Return the count. 

1275 

1276 Parameters 

1277 ---------- 

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

1279 Mask to define intersection region by. 

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

1281 Footprint to define the intersection region by. 

1282 bitmask : `Unknown` 

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

1284 ignoreMask : `Unknown` 

1285 Pixels to not consider. 

1286 

1287 Returns 

1288 ------- 

1289 result : `int` 

1290 Number of pixels in footprint with specified mask. 

1291 """ 

1292 bbox = footprint.getBBox() 

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

1294 fp = afwImage.Mask(bbox) 

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

1296 footprint.spans.setMask(fp, bitmask) 

1297 return numpy.logical_and( 

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

1299 ).sum() 

1300 

1301 

1302class CompareWarpAssembleCoaddConnections(AssembleCoaddConnections): 

1303 psfMatchedWarps = pipeBase.connectionTypes.Input( 

1304 doc=( 

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

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

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

1308 ), 

1309 name="{inputCoaddName}Coadd_psfMatchedWarp", 

1310 storageClass="ExposureF", 

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

1312 deferLoad=True, 

1313 multiple=True, 

1314 ) 

1315 templateCoadd = pipeBase.connectionTypes.Output( 

1316 doc=( 

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

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

1319 ), 

1320 name="{outputCoaddName}CoaddPsfMatched", 

1321 storageClass="ExposureF", 

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

1323 ) 

1324 artifactMasks = pipeBase.connectionTypes.Output( 

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

1326 name="compare_warp_artifact_mask", 

1327 storageClass="Mask", 

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

1329 multiple=True, 

1330 ) 

1331 

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

1333 super().__init__(config=config) 

1334 if not config.assembleStaticSkyModel.doWrite: 

1335 self.outputs.remove("templateCoadd") 

1336 config.validate() 

1337 

1338 

1339class CompareWarpAssembleCoaddConfig( 

1340 AssembleCoaddConfig, pipelineConnections=CompareWarpAssembleCoaddConnections 

1341): 

1342 assembleStaticSkyModel = pexConfig.ConfigurableField( 

1343 target=AssembleCoaddTask, 

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

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

1346 ) 

1347 detect = pexConfig.ConfigurableField( 

1348 target=SourceDetectionTask, 

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

1350 ) 

1351 detectTemplate = pexConfig.ConfigurableField( 

1352 target=SourceDetectionTask, 

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

1354 ) 

1355 detectTemplateIterMultiplier = pexConfig.Field( 

1356 dtype=float, 

1357 doc="Factor by which to scale the negative polarity detection threshold at each iteraction " 

1358 "(when attempting to recove from a background estimation failure). The maximum factor " 

1359 "attempted is controlled by this value along with the maximum number of iterations set " 

1360 "in config.maxDetectTemplateIter.", 

1361 default=1.25, 

1362 ) 

1363 maxDetectTemplateIter = pexConfig.Field( 

1364 dtype=int, 

1365 doc="Maximum number of iterations when increasing the factor by which to multiply the " 

1366 "negative (and potentially positive) polarity detection threshold (in an attempt at " 

1367 "recovering in the case of a background estimation failure).", 

1368 default=10, 

1369 ) 

1370 maskStreaks = pexConfig.ConfigurableField( 

1371 target=MaskStreaksTask, 

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

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

1374 "streakMaskName", 

1375 ) 

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

1377 maxNumEpochs = pexConfig.Field( 

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

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

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

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

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

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

1384 "than transient and not masked.", 

1385 dtype=int, 

1386 default=2, 

1387 ) 

1388 maxFractionEpochsLow = pexConfig.RangeField( 

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

1390 "Effective maxNumEpochs = " 

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

1392 dtype=float, 

1393 default=0.4, 

1394 min=0.0, 

1395 max=1.0, 

1396 ) 

1397 maxFractionEpochsHigh = pexConfig.RangeField( 

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

1399 "Effective maxNumEpochs = " 

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

1401 dtype=float, 

1402 default=0.03, 

1403 min=0.0, 

1404 max=1.0, 

1405 ) 

1406 spatialThreshold = pexConfig.RangeField( 

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

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

1409 dtype=float, 

1410 default=0.5, 

1411 min=0.0, 

1412 max=1.0, 

1413 inclusiveMin=True, 

1414 inclusiveMax=True, 

1415 ) 

1416 doScaleWarpVariance = pexConfig.Field( 

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

1418 dtype=bool, 

1419 default=True, 

1420 ) 

1421 scaleWarpVariance = pexConfig.ConfigurableField( 

1422 target=ScaleVarianceTask, 

1423 doc="Rescale variance on warps", 

1424 ) 

1425 doPreserveContainedBySource = pexConfig.Field( 

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

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

1428 dtype=bool, 

1429 default=True, 

1430 ) 

1431 doPrefilterArtifacts = pexConfig.Field( 

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

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

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

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

1436 dtype=bool, 

1437 default=True, 

1438 ) 

1439 prefilterArtifactsMaskPlanes = pexConfig.ListField( 

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

1441 dtype=str, 

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

1443 ) 

1444 prefilterArtifactsRatio = pexConfig.Field( 

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

1446 dtype=float, 

1447 default=0.05, 

1448 ) 

1449 doFilterMorphological = pexConfig.Field( 

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

1451 "be streaks.", 

1452 dtype=bool, 

1453 default=False, 

1454 ) 

1455 growStreakFp = pexConfig.Field( 

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

1457 ) 

1458 

1459 def setDefaults(self): 

1460 AssembleCoaddConfig.setDefaults(self) 

1461 self.statistic = "MEAN" 

1462 

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

1464 # matching kernel. CompareWarp applies psfMatched EDGE pixels to 

1465 # directWarps before assembling. 

1466 if "EDGE" in self.badMaskPlanes: 

1467 self.badMaskPlanes.remove("EDGE") 

1468 self.removeMaskPlanes.append("EDGE") 

1469 self.assembleStaticSkyModel.badMaskPlanes = [ 

1470 "NO_DATA", 

1471 ] 

1472 self.assembleStaticSkyModel.warpType = "psfMatched" 

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

1474 self.assembleStaticSkyModel.statistic = "MEANCLIP" 

1475 self.assembleStaticSkyModel.sigmaClip = 2.5 

1476 self.assembleStaticSkyModel.clipIter = 3 

1477 self.assembleStaticSkyModel.calcErrorFromInputVariance = True 

1478 self.assembleStaticSkyModel.doWrite = False 

1479 self.detect.doTempLocalBackground = False 

1480 self.detect.reEstimateBackground = False 

1481 self.detect.returnOriginalFootprints = False 

1482 self.detect.thresholdPolarity = "both" 

1483 self.detect.thresholdValue = 7.5 

1484 self.detect.minPixels = 4 

1485 self.detect.isotropicGrow = True 

1486 self.detect.thresholdType = "pixel_stdev" 

1487 self.detect.nSigmaToGrow = 0.4 

1488 # The default nSigmaToGrow for SourceDetectionTask is already 2.4, 

1489 # Explicitly restating because ratio with detect.nSigmaToGrow matters 

1490 self.detectTemplate.nSigmaToGrow = 2.4 

1491 # Higher thresholds make smaller and fewer protected zones around 

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

1493 self.detectTemplate.thresholdValue = 300 

1494 self.detectTemplate.doTempLocalBackground = True 

1495 self.detectTemplate.reEstimateBackground = True 

1496 self.detectTemplate.background.useApprox = False 

1497 self.detectTemplate.returnOriginalFootprints = False 

1498 

1499 def validate(self): 

1500 super().validate() 

1501 if self.assembleStaticSkyModel.doNImage: 

1502 raise ValueError( 

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

1504 "Please set assembleStaticSkyModel.doNImage=False" 

1505 ) 

1506 

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

1508 raise ValueError( 

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

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

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

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

1513 ) 

1514 

1515 

1516class CompareWarpAssembleCoaddTask(AssembleCoaddTask): 

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

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

1519 

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

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

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

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

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

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

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

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

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

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

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

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

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

1533 ``temporalThreshold`` and ``spatialThreshold``. The temporalThreshold is 

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

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

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

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

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

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

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

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

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

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

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

1545 surveys. 

1546 

1547 ``CompareWarpAssembleCoaddTask`` sub-classes 

1548 ``AssembleCoaddTask`` and instantiates ``AssembleCoaddTask`` 

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

1550 

1551 Notes 

1552 ----- 

1553 Debugging: 

1554 This task supports the following debug variables: 

1555 - ``saveCountIm`` 

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

1557 - ``figPath`` 

1558 Path to save the debug fits images and figures 

1559 """ 

1560 

1561 ConfigClass = CompareWarpAssembleCoaddConfig 

1562 _DefaultName = "compareWarpAssembleCoadd" 

1563 

1564 # See the parent class for docstring. 

1565 _doUsePsfMatchedPolygons: bool = True 

1566 

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

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

1569 self.makeSubtask("assembleStaticSkyModel") 

1570 detectionSchema = afwTable.SourceTable.makeMinimalSchema() 

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

1572 if self.config.doPreserveContainedBySource: 

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

1574 if self.config.doScaleWarpVariance: 

1575 self.makeSubtask("scaleWarpVariance") 

1576 if self.config.doFilterMorphological: 

1577 self.makeSubtask("maskStreaks") 

1578 

1579 @utils.inheritDoc(AssembleCoaddTask) 

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

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

1582 subtract from PSF-Matched warps. 

1583 

1584 Returns 

1585 ------- 

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

1587 Results as a struct with attributes: 

1588 

1589 ``templateCoadd`` 

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

1591 ``nImage`` 

1592 Keeps track of exposure count for each pixel 

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

1594 

1595 Raises 

1596 ------ 

1597 RuntimeError 

1598 Raised if ``templateCoadd`` is `None`. 

1599 """ 

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

1601 # generation. 

1602 staticSkyModelInputRefs = copy.deepcopy(inputRefs) 

1603 staticSkyModelInputRefs.inputWarps = inputRefs.psfMatchedWarps 

1604 

1605 # self.assembleStaticSkyModel is not expecting psfMatchedWarps as 

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

1607 # read all of them in to memory. 

1608 del staticSkyModelInputRefs.psfMatchedWarps 

1609 

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

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

1612 staticSkyModelOutputRefs = copy.deepcopy(outputRefs) 

1613 if self.config.assembleStaticSkyModel.doWrite: 

1614 staticSkyModelOutputRefs.coaddExposure = staticSkyModelOutputRefs.templateCoadd 

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

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

1617 del outputRefs.templateCoadd 

1618 del staticSkyModelOutputRefs.templateCoadd 

1619 

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

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

1622 del staticSkyModelOutputRefs.nImage 

1623 

1624 templateCoadd = self.assembleStaticSkyModel.runQuantum( 

1625 butlerQC, staticSkyModelInputRefs, staticSkyModelOutputRefs 

1626 ) 

1627 if templateCoadd is None: 

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

1629 

1630 return pipeBase.Struct( 

1631 templateCoadd=templateCoadd.coaddExposure, 

1632 nImage=templateCoadd.nImage, 

1633 warpRefList=templateCoadd.warpRefList, 

1634 imageScalerList=templateCoadd.imageScalerList, 

1635 weightList=templateCoadd.weightList, 

1636 psfMatchedWarpRefList=inputRefs.psfMatchedWarps, 

1637 ) 

1638 

1639 def _noTemplateMessage(self, warpType): 

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

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

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

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

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

1645 

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

1647 another algorithm like: 

1648 

1649 from lsst.pipe.tasks.assembleCoadd import SafeClipAssembleCoaddTask 

1650 config.assemble.retarget(SafeClipAssembleCoaddTask) 

1651 """ % { 

1652 "warpName": warpName 

1653 } 

1654 return message 

1655 

1656 @utils.inheritDoc(AssembleCoaddTask) 

1657 @timeMethod 

1658 def run( 

1659 self, 

1660 skyInfo, 

1661 *, 

1662 warpRefList, 

1663 imageScalerList, 

1664 weightList, 

1665 psfMatchedWarpRefList, 

1666 supplementaryData, 

1667 ): 

1668 """Notes 

1669 ----- 

1670 Assemble the coadd. 

1671 

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

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

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

1675 method. 

1676 """ 

1677 # Check and match the order of the supplementaryData 

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

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

1680 

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

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

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

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

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

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

1687 

1688 if dataIds != psfMatchedDataIds: 

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

1690 supplementaryData.warpRefList = reorderAndPadList( 

1691 supplementaryData.warpRefList, psfMatchedDataIds, dataIds 

1692 ) 

1693 # Remove in DM-49083 

1694 supplementaryData.imageScalerList = reorderAndPadList( 

1695 supplementaryData.imageScalerList, psfMatchedDataIds, dataIds 

1696 ) 

1697 

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

1699 # artifacts. 

1700 spanSetMaskList = self.findArtifacts( 

1701 supplementaryData.templateCoadd, supplementaryData.warpRefList, supplementaryData.imageScalerList 

1702 ) 

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

1704 # the templateCoadd or the warps. 

1705 badMaskPlanes = [ 

1706 mp 

1707 for mp in self.config.badMaskPlanes 

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

1709 ] 

1710 badMaskPlanes.append("CLIPPED") 

1711 badPixelMask = afwImage.Mask.getPlaneBitMask(badMaskPlanes) 

1712 

1713 result = AssembleCoaddTask.run( 

1714 self, 

1715 skyInfo, 

1716 warpRefList=warpRefList, 

1717 imageScalerList=imageScalerList, 

1718 weightList=weightList, 

1719 altMaskList=spanSetMaskList, 

1720 mask=badPixelMask, 

1721 psfMatchedWarpRefList=psfMatchedWarpRefList, 

1722 ) 

1723 

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

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

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

1727 return result 

1728 

1729 def applyAltEdgeMask(self, mask, altMaskList): 

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

1731 

1732 Parameters 

1733 ---------- 

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

1735 Original mask. 

1736 altMaskList : `list` of `dict` 

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

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

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

1740 the mask. 

1741 """ 

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

1743 for visitMask in altMaskList: 

1744 if "EDGE" in visitMask: 

1745 for spanSet in visitMask["EDGE"]: 

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

1747 

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

1749 """Find artifacts. 

1750 

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

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

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

1754 difference image and filters the artifacts detected in each using 

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

1756 difficult to subtract cleanly. 

1757 

1758 Parameters 

1759 ---------- 

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

1761 Exposure to serve as model of static sky. 

1762 warpRefList : `list` 

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

1764 imageScalerList : `list` 

1765 List of image scalers. 

1766 Deprecated and will be removed after v29. 

1767 

1768 Returns 

1769 ------- 

1770 altMasks : `list` of `dict` 

1771 List of dicts containing information about CLIPPED 

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

1773 """ 

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

1775 coaddBBox = templateCoadd.getBBox() 

1776 slateIm = afwImage.ImageU(coaddBBox) 

1777 epochCountImage = afwImage.ImageU(coaddBBox) 

1778 nImage = afwImage.ImageU(coaddBBox) 

1779 spanSetArtifactList = [] 

1780 spanSetNoDataMaskList = [] 

1781 spanSetEdgeList = [] 

1782 spanSetBadMorphoList = [] 

1783 badPixelMask = self.getBadPixelMask() 

1784 

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

1786 templateCoadd.mask.clearAllMaskPlanes() 

1787 

1788 factor = 1.0 

1789 factorNeg = 1.0 

1790 if self.config.doPreserveContainedBySource: 

1791 sacrificeToDetection = templateCoadd.clone() 

1792 for iFactorNeg in range(self.config.maxDetectTemplateIter): 

1793 try: 

1794 templateFootprints = self.detectTemplate.detectFootprints( 

1795 sacrificeToDetection, factor=factor, factorNeg=factorNeg 

1796 ) 

1797 break 

1798 except TooManyMaskedPixelsError as e: 

1799 if iFactorNeg < self.config.maxDetectTemplateIter - 1: 

1800 factorNeg *= self.config.detectTemplateIterMultiplier 

1801 self.log.warning( 

1802 "detectTemplate failed with: %s at attempt number %d (of a maximum of %d). " 

1803 "This is likely a result of background over-subtraction leading to a high " 

1804 "DETECTED_NEGATIVE masked fraction. Increasing the negative detection factor " 

1805 "to %.2f and retrying.", 

1806 e, 

1807 iFactorNeg + 1, 

1808 self.config.maxDetectTemplateIter, 

1809 factorNeg, 

1810 ) 

1811 if iFactorNeg > int(0.4 * self.config.maxDetectTemplateIter): 

1812 factor *= self.config.detectTemplateIterMultiplier 

1813 self.log.warning( 

1814 "Also raising factor for positive polarity detections to %.2f " 

1815 "(at nIter = %d)", 

1816 factor, 

1817 iFactorNeg, 

1818 ) 

1819 else: 

1820 raise e 

1821 del sacrificeToDetection 

1822 else: 

1823 templateFootprints = None 

1824 

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

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

1827 if warpDiffExp is not None: 

1828 # This nImage only approximates the final nImage because it 

1829 # uses the PSF-matched mask. 

1830 nImage.array += ( 

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

1832 ).astype(numpy.uint16) 

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

1834 fpSet.positive.merge(fpSet.negative) 

1835 footprints = fpSet.positive 

1836 slateIm.set(0) 

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

1838 

1839 # Remove artifacts due to defects before they contribute to 

1840 # the epochCountImage. 

1841 if self.config.doPrefilterArtifacts: 

1842 spanSetList = self.prefilterArtifacts(spanSetList, warpDiffExp) 

1843 

1844 # Clear mask before adding prefiltered spanSets 

1845 self.detect.clearMask(warpDiffExp.mask) 

1846 for spans in spanSetList: 

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

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

1849 epochCountImage += slateIm 

1850 

1851 if self.config.doFilterMorphological: 

1852 maskName = self.config.streakMaskName 

1853 # clear single frame streak mask if it exists already 

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

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

1856 else: 

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

1858 

1859 _ = self.maskStreaks.run(warpDiffExp) 

1860 streakMask = warpDiffExp.mask 

1861 initSpanSetStreak = afwGeom.SpanSet.fromMask( 

1862 streakMask, streakMask.getPlaneBitMask(maskName) 

1863 ).split() 

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

1865 # wings. 

1866 psf = warpDiffExp.getPsf() 

1867 spanSetStreak = [] 

1868 for sset in initSpanSetStreak: 

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

1870 doKeep = True 

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

1872 if footprint.spans.contains(sset): 

1873 doKeep = False 

1874 break 

1875 if not doKeep: 

1876 continue 

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

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

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

1880 spanSetStreak.append(sset_dilated) 

1881 

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

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

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

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

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

1887 # the direct warp. 

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

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

1890 nansMask.setXY0(warpDiffExp.getXY0()) 

1891 edgeMask = warpDiffExp.mask 

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

1893 else: 

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

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

1896 nansMask = afwImage.MaskX(coaddBBox, 1) 

1897 spanSetList = [] 

1898 spanSetEdgeMask = [] 

1899 spanSetStreak = [] 

1900 

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

1902 

1903 spanSetNoDataMaskList.append(spanSetNoDataMask) 

1904 spanSetArtifactList.append(spanSetList) 

1905 spanSetEdgeList.append(spanSetEdgeMask) 

1906 if self.config.doFilterMorphological: 

1907 spanSetBadMorphoList.append(spanSetStreak) 

1908 

1909 if lsstDebug.Info(__name__).saveCountIm: 

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

1911 epochCountImage.writeFits(path) 

1912 

1913 for i, spanSetList in enumerate(spanSetArtifactList): 

1914 if spanSetList: 

1915 filteredSpanSetList = self.filterArtifacts( 

1916 spanSetList, epochCountImage, nImage, templateFootprints 

1917 ) 

1918 spanSetArtifactList[i] = filteredSpanSetList 

1919 if self.config.doFilterMorphological: 

1920 spanSetArtifactList[i] += spanSetBadMorphoList[i] 

1921 

1922 altMasks = [] 

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

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

1925 return altMasks 

1926 

1927 def prefilterArtifacts(self, spanSetList, exp): 

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

1929 

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

1931 temporal information should go in this method. 

1932 

1933 Parameters 

1934 ---------- 

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

1936 List of SpanSets representing artifact candidates. 

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

1938 Exposure containing mask planes used to prefilter. 

1939 

1940 Returns 

1941 ------- 

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

1943 List of SpanSets with artifacts. 

1944 """ 

1945 existingMaskPlanes = [ 

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

1947 ] 

1948 badPixelMask = exp.mask.getPlaneBitMask(existingMaskPlanes) 

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

1950 returnSpanSetList = [] 

1951 bbox = exp.getBBox() 

1952 x0, y0 = exp.getXY0() 

1953 for i, span in enumerate(spanSetList): 

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

1955 yIndexLocal = numpy.array(y) - y0 

1956 xIndexLocal = numpy.array(x) - x0 

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

1958 if goodRatio > self.config.prefilterArtifactsRatio: 

1959 returnSpanSetList.append(span) 

1960 return returnSpanSetList 

1961 

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

1963 """Filter artifact candidates. 

1964 

1965 Parameters 

1966 ---------- 

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

1968 List of SpanSets representing artifact candidates. 

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

1970 Image of accumulated number of warpDiff detections. 

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

1972 Image of the accumulated number of total epochs contributing. 

1973 

1974 Returns 

1975 ------- 

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

1977 List of SpanSets with artifacts. 

1978 """ 

1979 maskSpanSetList = [] 

1980 x0, y0 = epochCountImage.getXY0() 

1981 for i, span in enumerate(spanSetList): 

1982 y, x = span.indices() 

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

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

1985 outlierN = epochCountImage.array[yIdxLocal, xIdxLocal] 

1986 totalN = nImage.array[yIdxLocal, xIdxLocal] 

1987 

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

1989 # characteristic config.maxNumEpochs. 

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

1991 totalN 

1992 ) 

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

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

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

1996 percentBelowThreshold = nPixelsBelowThreshold / len(outlierN) 

1997 if percentBelowThreshold > self.config.spatialThreshold: 

1998 maskSpanSetList.append(span) 

1999 

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

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

2002 # do not clip. 

2003 filteredMaskSpanSetList = [] 

2004 for span in maskSpanSetList: 

2005 doKeep = True 

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

2007 if footprint.spans.contains(span): 

2008 doKeep = False 

2009 break 

2010 if doKeep: 

2011 filteredMaskSpanSetList.append(span) 

2012 maskSpanSetList = filteredMaskSpanSetList 

2013 

2014 return maskSpanSetList 

2015 

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

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

2018 

2019 Parameters 

2020 ---------- 

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

2022 Dataset handle for the warp. 

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

2024 An image scaler object. 

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

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

2027 Exposure to be substracted from the scaled warp. 

2028 

2029 Returns 

2030 ------- 

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

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

2033 """ 

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

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

2036 if warpRef is None: 

2037 return None 

2038 

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

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

2041 # Remove in DM-49083. 

2042 if imageScaler is not None: 

2043 imageScaler.scaleMaskedImage(warp.getMaskedImage()) 

2044 mi = warp.getMaskedImage() 

2045 if self.config.doScaleWarpVariance: 

2046 try: 

2047 self.scaleWarpVariance.run(mi) 

2048 except Exception as exc: 

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

2050 mi -= templateCoadd.getMaskedImage() 

2051 return warp