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

670 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-10-11 02:25 -0700

1# This file is part of drp_tasks. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

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

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

7# for details of code ownership. 

8# 

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

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

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

12# (at your option) any later version. 

13# 

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

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

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

17# GNU General Public License for more details. 

18# 

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

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

21 

22from __future__ import annotations 

23 

24__all__ = [ 

25 "AssembleCoaddTask", 

26 "AssembleCoaddConnections", 

27 "AssembleCoaddConfig", 

28 "CompareWarpAssembleCoaddTask", 

29 "CompareWarpAssembleCoaddConfig", 

30] 

31 

32import copy 

33import logging 

34import warnings 

35 

36import lsstDebug 

37import numpy 

38 

39import lsst.afw.geom as afwGeom 

40import lsst.afw.image as afwImage 

41import lsst.afw.math as afwMath 

42import lsst.afw.table as afwTable 

43import lsst.coadd.utils as coaddUtils 

44import lsst.geom as geom 

45import lsst.meas.algorithms as measAlg 

46import lsst.pex.config as pexConfig 

47import lsst.pex.exceptions as pexExceptions 

48import lsst.pipe.base as pipeBase 

49import lsst.utils as utils 

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

51from lsst.pipe.tasks.coaddBase import ( 

52 CoaddBaseTask, 

53 makeSkyInfo, 

54 removeMaskPlanes, 

55 reorderAndPadList, 

56 setRejectedMaskMapping, 

57 subBBoxIter, 

58) 

59from lsst.pipe.tasks.healSparseMapping import HealSparseInputMapTask 

60from lsst.pipe.tasks.interpImage import InterpImageTask 

61from lsst.pipe.tasks.scaleZeroPoint import ScaleZeroPointTask 

62from lsst.skymap import BaseSkyMap 

63from lsst.utils.timer import timeMethod 

64 

65log = logging.getLogger(__name__) 

66 

67 

68class AssembleCoaddConnections( 

69 pipeBase.PipelineTaskConnections, 

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

71 defaultTemplates={ 

72 "inputCoaddName": "deep", 

73 "outputCoaddName": "deep", 

74 "warpType": "direct", 

75 "warpTypeSuffix": "", 

76 }, 

77): 

78 inputWarps = pipeBase.connectionTypes.Input( 

79 doc=( 

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

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

82 "warpType config parameter" 

83 ), 

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

85 storageClass="ExposureF", 

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

87 deferLoad=True, 

88 multiple=True, 

89 ) 

90 skyMap = pipeBase.connectionTypes.Input( 

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

92 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME, 

93 storageClass="SkyMap", 

94 dimensions=("skymap",), 

95 ) 

96 selectedVisits = pipeBase.connectionTypes.Input( 

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

98 name="{outputCoaddName}Visits", 

99 storageClass="StructuredDataDict", 

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

101 ) 

102 brightObjectMask = pipeBase.connectionTypes.PrerequisiteInput( 

103 doc=( 

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

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

106 ), 

107 name="brightObjectMask", 

108 storageClass="ObjectMaskCatalog", 

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

110 minimum=0, 

111 ) 

112 coaddExposure = pipeBase.connectionTypes.Output( 

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

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

115 storageClass="ExposureF", 

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

117 ) 

118 nImage = pipeBase.connectionTypes.Output( 

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

120 name="{outputCoaddName}Coadd_nImage", 

121 storageClass="ImageU", 

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

123 ) 

124 inputMap = pipeBase.connectionTypes.Output( 

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

126 name="{outputCoaddName}Coadd_inputMap", 

127 storageClass="HealSparseMap", 

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

129 ) 

130 

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

132 super().__init__(config=config) 

133 

134 if not config.doMaskBrightObjects: 

135 self.prerequisiteInputs.remove("brightObjectMask") 

136 

137 if not config.doSelectVisits: 

138 self.inputs.remove("selectedVisits") 

139 

140 if not config.doNImage: 

141 self.outputs.remove("nImage") 

142 

143 if not self.config.doInputMap: 

144 self.outputs.remove("inputMap") 

145 

146 if not self.config.doWriteArtifactMasks: 

147 self.outputs.remove("artifactMasks") 

148 

149 

150class AssembleCoaddConfig( 

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

152): 

153 warpType = pexConfig.ChoiceField( 

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

155 dtype=str, 

156 default="direct", 

157 allowed={ 

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

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

160 }, 

161 ) 

162 subregionSize = pexConfig.ListField( 

163 dtype=int, 

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

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

166 " at once.", 

167 length=2, 

168 default=(2000, 2000), 

169 ) 

170 statistic = pexConfig.Field( 

171 dtype=str, 

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

173 default="MEANCLIP", 

174 ) 

175 doOnlineForMean = pexConfig.Field( 

176 dtype=bool, 

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

178 default=False, 

179 ) 

180 sigmaClip = pexConfig.Field( 

181 dtype=float, 

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

183 default=3.0, 

184 ) 

185 clipIter = pexConfig.Field( 

186 dtype=int, 

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

188 default=2, 

189 ) 

190 calcErrorFromInputVariance = pexConfig.Field( 

191 dtype=bool, 

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

193 "statistic. Passed to " 

194 "StatisticsControl.setCalcErrorFromInputVariance()", 

195 default=True, 

196 ) 

197 doScaleZeroPoint = pexConfig.Field( 

198 dtype=bool, 

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

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

201 default=False, 

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

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

204 "after v29.", 

205 ) 

206 scaleZeroPoint = pexConfig.ConfigurableField( 

207 target=ScaleZeroPointTask, 

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

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

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

211 "after v29.", 

212 ) 

213 doInterp = pexConfig.Field( 

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

215 dtype=bool, 

216 default=True, 

217 ) 

218 interpImage = pexConfig.ConfigurableField( 

219 target=InterpImageTask, 

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

221 ) 

222 doWrite = pexConfig.Field( 

223 doc="Persist coadd?", 

224 dtype=bool, 

225 default=True, 

226 ) 

227 doWriteArtifactMasks = pexConfig.Field( 

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

229 dtype=bool, 

230 default=False, 

231 ) 

232 doNImage = pexConfig.Field( 

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

234 dtype=bool, 

235 default=False, 

236 ) 

237 maskPropagationThresholds = pexConfig.DictField( 

238 keytype=str, 

239 itemtype=float, 

240 doc=( 

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

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

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

244 "would have contributed exceeds this value." 

245 ), 

246 default={"SAT": 0.1}, 

247 ) 

248 removeMaskPlanes = pexConfig.ListField( 

249 dtype=str, 

250 default=["NOT_DEBLENDED"], 

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

252 ) 

253 doMaskBrightObjects = pexConfig.Field( 

254 dtype=bool, 

255 default=False, 

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

257 ) 

258 brightObjectMaskName = pexConfig.Field( 

259 dtype=str, 

260 default="BRIGHT_OBJECT", 

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

262 ) 

263 coaddPsf = pexConfig.ConfigField( 

264 doc="Configuration for CoaddPsf", 

265 dtype=measAlg.CoaddPsfConfig, 

266 ) 

267 doAttachTransmissionCurve = pexConfig.Field( 

268 dtype=bool, 

269 default=False, 

270 optional=False, 

271 doc=( 

272 "Attach a piecewise TransmissionCurve for the coadd? " 

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

274 ), 

275 ) 

276 hasFakes = pexConfig.Field( 

277 dtype=bool, 

278 default=False, 

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

280 ) 

281 doSelectVisits = pexConfig.Field( 

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

283 dtype=bool, 

284 default=False, 

285 ) 

286 doInputMap = pexConfig.Field( 

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

288 dtype=bool, 

289 default=False, 

290 ) 

291 inputMapper = pexConfig.ConfigurableField( 

292 doc="Input map creation subtask.", 

293 target=HealSparseInputMapTask, 

294 ) 

295 

296 def setDefaults(self): 

297 super().setDefaults() 

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

299 self.coaddPsf.cacheSize = 0 

300 

301 def validate(self): 

302 super().validate() 

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

304 raise pexConfig.FieldValidationError( 

305 self.__class__.doInterp, 

306 self, 

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

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

309 ) 

310 

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

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

313 stackableStats = [ 

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

315 ] 

316 raise pexConfig.FieldValidationError( 

317 self.__class__.statistic, 

318 self, 

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

320 ) 

321 

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

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

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

325 raise pexConfig.FieldValidationError( 

326 self.__class__.doWriteArtifactMasks, 

327 self, 

328 "doWriteArtifactMasks is only valid for CompareWarpAssembleCoaddConfig.", 

329 ) 

330 

331 

332class AssembleCoaddTask(CoaddBaseTask, pipeBase.PipelineTask): 

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

334 

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

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

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

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

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

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

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

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

343 

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

345 

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

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

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

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

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

351 

352 You can retarget these subtasks if you wish. 

353 

354 Raises 

355 ------ 

356 RuntimeError 

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

358 

359 Notes 

360 ----- 

361 Debugging: 

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

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

364 documentation for the subtasks for further information. 

365 """ 

366 

367 ConfigClass = AssembleCoaddConfig 

368 _DefaultName = "assembleCoadd" 

369 

370 _doUsePsfMatchedPolygons: bool = False 

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

372 

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

374 warps to non Psf-Matched warps. 

375 """ 

376 

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

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

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

380 if args: 

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

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

383 warnings.warn( 

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

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

386 FutureWarning, 

387 stacklevel=2, 

388 ) 

389 

390 super().__init__(**kwargs) 

391 self.makeSubtask("interpImage") 

392 if self.config.doScaleZeroPoint: 

393 # Remove completely in DM-49083 

394 self.makeSubtask("scaleZeroPoint") 

395 

396 if self.config.doMaskBrightObjects: 

397 mask = afwImage.Mask() 

398 try: 

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

400 except pexExceptions.LsstCppException: 

401 raise RuntimeError( 

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

403 % mask.getMaskPlaneDict().keys() 

404 ) 

405 del mask 

406 

407 if self.config.doInputMap: 

408 self.makeSubtask("inputMapper") 

409 

410 self.warpType = self.config.warpType 

411 

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

413 inputData = butlerQC.get(inputRefs) 

414 

415 # Construct skyInfo expected by run 

416 # Do not remove skyMap from inputData in case _makeSupplementaryData 

417 # needs it 

418 skyMap = inputData["skyMap"] 

419 outputDataId = butlerQC.quantum.dataId 

420 

421 inputData["skyInfo"] = makeSkyInfo( 

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

423 ) 

424 

425 if self.config.doSelectVisits: 

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

427 else: 

428 warpRefList = inputData["inputWarps"] 

429 

430 # Although psfMatchedWarps are specifically required by only by a 

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

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

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

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

435 if self._doUsePsfMatchedPolygons: 

436 if self.config.doSelectVisits: 

437 psfMatchedWarpRefList = self.filterWarps( 

438 inputData["psfMatchedWarps"], 

439 inputData["selectedVisits"], 

440 ) 

441 else: 

442 psfMatchedWarpRefList = inputData["psfMatchedWarps"] 

443 else: 

444 psfMatchedWarpRefList = [] 

445 

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

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

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

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

450 

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

452 retStruct = self.run( 

453 inputData["skyInfo"], 

454 warpRefList=inputs.warpRefList, 

455 imageScalerList=inputs.imageScalerList, 

456 weightList=inputs.weightList, 

457 psfMatchedWarpRefList=inputs.psfMatchedWarpRefList, 

458 supplementaryData=supplementaryData, 

459 ) 

460 

461 inputData.setdefault("brightObjectMask", None) 

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

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

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

465 

466 if self.config.doWriteArtifactMasks: 

467 artifactMasksRefList = reorderAndPadList( 

468 outputRefs.artifactMasks, 

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

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

471 ) 

472 for altMask, warpRef, outputRef in zip( 

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

474 ): 

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

476 self.applyAltMaskPlanes(mask, altMask) 

477 butlerQC.put(mask, outputRef) 

478 

479 if self.config.doWrite: 

480 butlerQC.put(retStruct, outputRefs) 

481 

482 return retStruct 

483 

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

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

486 

487 Parameters 

488 ---------- 

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

490 The coadded exposure to process. 

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

492 Table of bright objects to mask. 

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

494 Data identification. 

495 """ 

496 if self.config.doInterp: 

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

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

499 varArray = coaddExposure.variance.array 

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

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

502 

503 if self.config.doMaskBrightObjects: 

504 self.setBrightObjectMasks(coaddExposure, brightObjectMasks, dataId) 

505 

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

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

508 

509 Duplicates interface of `runQuantum` method. 

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

511 coadd dataRef/handle for performing preliminary processing before 

512 assembling the coadd. 

513 

514 Parameters 

515 ---------- 

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

517 Gen3 Butler object for fetching additional data products before 

518 running the Task specialized for quantum being processed. 

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

520 Attributes are the names of the connections describing input 

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

522 corresponding dataset type. DataIds are guaranteed to match data 

523 objects in ``inputData``. 

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

525 Attributes are the names of the connections describing output 

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

527 for the corresponding dataset type. 

528 """ 

529 return pipeBase.Struct() 

530 

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

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

533 each warp. 

534 

535 Before coadding these Warps together compute the weight for each 

536 Warp. 

537 

538 Parameters 

539 ---------- 

540 refList : `list` 

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

542 psfMatchedWarpRefList : `list` | None, optional 

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

544 

545 Returns 

546 ------- 

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

548 Results as a struct with attributes: 

549 

550 ``warpRefList`` 

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

552 ``weightList`` 

553 `list` of weightings. 

554 ``imageScalerList`` 

555 `list` of image scalers. 

556 Deprecated and will be removed in DM-49083. 

557 """ 

558 statsCtrl = afwMath.StatisticsControl() 

559 statsCtrl.setNumSigmaClip(self.config.sigmaClip) 

560 statsCtrl.setNumIter(self.config.clipIter) 

561 statsCtrl.setAndMask(self.getBadPixelMask()) 

562 statsCtrl.setNanSafe(True) 

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

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

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

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

567 warpRefList = [] 

568 weightList = [] 

569 # Remove in DM-49083 

570 imageScalerList = [] 

571 outputPsfMatchedWarpRefList = [] 

572 

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

574 # their dataId. 

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

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

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

578 

579 if psfMatchedWarpRefList is None: 

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

581 else: 

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

583 

584 warpName = self.getTempExpDatasetName(self.warpType) 

585 for warpRef in refList: 

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

587 # Ignore any input warp that is empty of data 

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

589 continue 

590 maskedImage = warp.getMaskedImage() 

591 

592 # Allow users to scale the zero point for backward 

593 # compatibility. Remove imageScalarList in DM-49083. 

594 if self.config.doScaleZeroPoint: 

595 imageScaler = self.scaleZeroPoint.computeImageScaler( 

596 exposure=warp, 

597 dataRef=warpRef, # FIXME 

598 ) 

599 try: 

600 imageScaler.scaleMaskedImage(maskedImage) 

601 except Exception as e: 

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

603 continue 

604 imageScalerList.append(imageScaler) 

605 else: 

606 imageScalerList.append(None) 

607 if "BUNIT" not in warp.metadata: 

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

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

610 raise ValueError( 

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

612 ) 

613 statObj = afwMath.makeStatistics( 

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

615 ) 

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

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

618 weight = 1.0 / float(meanVar) 

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

620 else: 

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

622 continue 

623 

624 del maskedImage 

625 del warp 

626 

627 warpRefList.append(warpRef) 

628 weightList.append(weight) 

629 

630 dataId = warpRef.dataId 

631 psfMatchedWarpRef = psfMatchedWarpRefDict.get(dataId, None) 

632 outputPsfMatchedWarpRefList.append(psfMatchedWarpRef) 

633 

634 return pipeBase.Struct( 

635 warpRefList=warpRefList, 

636 weightList=weightList, 

637 imageScalerList=imageScalerList, 

638 psfMatchedWarpRefList=outputPsfMatchedWarpRefList, 

639 ) 

640 

641 def prepareStats(self, mask=None): 

642 """Prepare the statistics for coadding images. 

643 

644 Parameters 

645 ---------- 

646 mask : `int`, optional 

647 Bit mask value to exclude from coaddition. 

648 

649 Returns 

650 ------- 

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

652 Statistics as a struct with attributes: 

653 

654 ``statsCtrl`` 

655 Statistics control object for coadd 

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

657 ``statsFlags`` 

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

659 """ 

660 if mask is None: 

661 mask = self.getBadPixelMask() 

662 statsCtrl = afwMath.StatisticsControl() 

663 statsCtrl.setNumSigmaClip(self.config.sigmaClip) 

664 statsCtrl.setNumIter(self.config.clipIter) 

665 statsCtrl.setAndMask(mask) 

666 statsCtrl.setNanSafe(True) 

667 statsCtrl.setWeighted(True) 

668 statsCtrl.setCalcErrorFromInputVariance(self.config.calcErrorFromInputVariance) 

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

670 bit = afwImage.Mask.getMaskPlane(plane) 

671 statsCtrl.setMaskPropagationThreshold(bit, threshold) 

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

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

674 

675 @timeMethod 

676 def run( 

677 self, 

678 skyInfo, 

679 *, 

680 warpRefList, 

681 imageScalerList, 

682 weightList, 

683 psfMatchedWarpRefList=None, 

684 altMaskList=None, 

685 mask=None, 

686 supplementaryData=None, 

687 ): 

688 """Assemble a coadd from input warps. 

689 

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

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

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

693 conserve memory usage. Iterate over subregions within the outer 

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

695 subregions from the coaddTempExps with the statistic specified. 

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

697 

698 Parameters 

699 ---------- 

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

701 Struct with geometric information about the patch. 

702 warpRefList : `list` 

703 List of dataset handles (data references) to Warps 

704 (previously called CoaddTempExps). 

705 imageScalerList : `list` 

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

707 in DM-49083. 

708 weightList : `list` 

709 List of weights. 

710 psfMatchedWarpRefList : `list`, optional 

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

712 altMaskList : `list`, optional 

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

714 warp. 

715 mask : `int`, optional 

716 Bit mask value to exclude from coaddition. 

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

718 Struct with additional data products needed to assemble coadd. 

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

720 and override `run`. 

721 

722 Returns 

723 ------- 

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

725 Results as a struct with attributes: 

726 

727 ``coaddExposure`` 

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

729 ``nImage`` 

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

731 ``inputMap`` 

732 Bit-wise map of inputs, if requested. 

733 ``warpRefList`` 

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

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

736 ``imageScalerList`` 

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

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

739 ``weightList`` 

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

741 

742 Raises 

743 ------ 

744 lsst.pipe.base.NoWorkFound 

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

746 """ 

747 warpName = self.getTempExpDatasetName(self.warpType) 

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

749 if not warpRefList: 

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

751 

752 stats = self.prepareStats(mask=mask) 

753 

754 if altMaskList is None: 

755 altMaskList = [None] * len(warpRefList) 

756 

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

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

759 if self.config.doScaleZeroPoint: 

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

761 else: 

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

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

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

765 coaddMaskedImage = coaddExposure.getMaskedImage() 

766 subregionSizeArr = self.config.subregionSize 

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

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

769 # assembleSubregion. 

770 if self.config.doNImage: 

771 nImage = afwImage.ImageU(skyInfo.bbox) 

772 else: 

773 nImage = None 

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

775 # masked in assembleSubregion. 

776 if self.config.doInputMap: 

777 self.inputMapper.build_ccd_input_map( 

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

779 ) 

780 

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

782 try: 

783 self.assembleOnlineMeanCoadd( 

784 coaddExposure, 

785 warpRefList, 

786 imageScalerList, 

787 weightList, 

788 altMaskList, 

789 stats.ctrl, 

790 nImage=nImage, 

791 ) 

792 except Exception as e: 

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

794 raise 

795 else: 

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

797 try: 

798 self.assembleSubregion( 

799 coaddExposure, 

800 subBBox, 

801 warpRefList, 

802 imageScalerList, 

803 weightList, 

804 altMaskList, 

805 stats.flags, 

806 stats.ctrl, 

807 nImage=nImage, 

808 ) 

809 except Exception as e: 

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

811 raise 

812 

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

814 # accumulation. 

815 if self.config.doInputMap: 

816 self.inputMapper.finalize_ccd_input_map_mask() 

817 inputMap = self.inputMapper.ccd_input_map 

818 else: 

819 inputMap = None 

820 

821 self.setInexactPsf(coaddMaskedImage.getMask()) 

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

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

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

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

826 return pipeBase.Struct( 

827 coaddExposure=coaddExposure, 

828 nImage=nImage, 

829 warpRefList=warpRefList, 

830 imageScalerList=imageScalerList, 

831 weightList=weightList, 

832 inputMap=inputMap, 

833 altMaskList=altMaskList, 

834 ) 

835 

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

837 """Set the metadata for the coadd. 

838 

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

840 

841 Parameters 

842 ---------- 

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

844 The target exposure for the coadd. 

845 warpRefList : `list` 

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

847 weightList : `list` 

848 List of weights. 

849 psfMatchedWarpRefList : `list` | None, optional 

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

851 

852 Raises 

853 ------ 

854 AssertionError 

855 Raised if there is a length mismatch. 

856 """ 

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

858 

859 if psfMatchedWarpRefList: 

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

861 

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

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

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

865 # (see #2777). 

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

867 

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

869 

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

871 

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

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

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

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

876 coaddInputs.ccds.reserve(numCcds) 

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

878 

879 # Set the exposure units to nJy 

880 # No need to check after DM-49083. 

881 if not self.config.doScaleZeroPoint: 

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

883 

884 # psfMatchedWarpRefList should be empty except in CompareWarpCoadd. 

885 if self._doUsePsfMatchedPolygons: 

886 # Set validPolygons for warp before addVisitToCoadd 

887 self._setValidPolygons(warpList, psfMatchedWarpRefList) 

888 

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

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

891 

892 coaddInputs.visits.sort() 

893 coaddInputs.ccds.sort() 

894 if self.warpType == "psfMatched": 

895 # The modelPsf BBox for a psfMatchedWarp/coaddTempExp was 

896 # dynamically defined by ModelPsfMatchTask as the square box 

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

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

899 # having the maximum width (sufficient because square) 

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

901 modelPsfWidthList = [ 

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

903 ] 

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

905 else: 

906 psf = measAlg.CoaddPsf( 

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

908 ) 

909 coaddExposure.setPsf(psf) 

910 apCorrMap = measAlg.makeCoaddApCorrMap( 

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

912 ) 

913 coaddExposure.getInfo().setApCorrMap(apCorrMap) 

914 if self.config.doAttachTransmissionCurve: 

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

916 coaddExposure.getInfo().setTransmissionCurve(transmissionCurve) 

917 

918 def assembleSubregion( 

919 self, 

920 coaddExposure, 

921 bbox, 

922 warpRefList, 

923 imageScalerList, 

924 weightList, 

925 altMaskList, 

926 statsFlags, 

927 statsCtrl, 

928 nImage=None, 

929 ): 

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

931 

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

933 if one is passed. Remove mask planes listed in 

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

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

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

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

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

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

940 

941 Parameters 

942 ---------- 

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

944 The target exposure for the coadd. 

945 bbox : `lsst.geom.Box` 

946 Sub-region to coadd. 

947 warpRefList : `list` 

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

949 imageScalerList : `list` 

950 List of image scalers. 

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

952 weightList : `list` 

953 List of weights. 

954 altMaskList : `list` 

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

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

957 name to which to add the spans. 

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

959 Property object for statistic for coadd. 

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

961 Statistics control object for coadd. 

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

963 Keeps track of exposure count for each pixel. 

964 """ 

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

966 

967 coaddExposure.mask.addMaskPlane("REJECTED") 

968 coaddExposure.mask.addMaskPlane("CLIPPED") 

969 coaddExposure.mask.addMaskPlane("SENSOR_EDGE") 

970 maskMap = setRejectedMaskMapping(statsCtrl) 

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

972 maskedImageList = [] 

973 if nImage is not None: 

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

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

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

977 

978 maskedImage = exposure.getMaskedImage() 

979 mask = maskedImage.getMask() 

980 if altMask is not None: 

981 self.applyAltMaskPlanes(mask, altMask) 

982 # Remove in DM-49083 

983 if imageScaler is not None: 

984 imageScaler.scaleMaskedImage(maskedImage) 

985 

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

987 # In legacyCoadd, pixels may also be excluded by 

988 # afwMath.statisticsStack. 

989 if nImage is not None: 

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

991 if self.config.removeMaskPlanes: 

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

993 maskedImageList.append(maskedImage) 

994 

995 if self.config.doInputMap: 

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

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

998 

999 with self.timer("stack"): 

1000 coaddSubregion = afwMath.statisticsStack( 

1001 maskedImageList, 

1002 statsFlags, 

1003 statsCtrl, 

1004 weightList, 

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

1006 maskMap, 

1007 ) 

1008 coaddExposure.maskedImage.assign(coaddSubregion, bbox) 

1009 if nImage is not None: 

1010 nImage.assign(subNImage, bbox) 

1011 

1012 def assembleOnlineMeanCoadd( 

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

1014 ): 

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

1016 

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

1018 It only works for MEAN statistics. 

1019 

1020 Parameters 

1021 ---------- 

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

1023 The target exposure for the coadd. 

1024 warpRefList : `list` 

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

1026 imageScalerList : `list` 

1027 List of image scalers. 

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

1029 weightList : `list` 

1030 List of weights. 

1031 altMaskList : `list` 

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

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

1034 name to which to add the spans. 

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

1036 Statistics control object for coadd. 

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

1038 Keeps track of exposure count for each pixel. 

1039 """ 

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

1041 

1042 coaddExposure.mask.addMaskPlane("REJECTED") 

1043 coaddExposure.mask.addMaskPlane("CLIPPED") 

1044 coaddExposure.mask.addMaskPlane("SENSOR_EDGE") 

1045 maskMap = setRejectedMaskMapping(statsCtrl) 

1046 thresholdDict = AccumulatorMeanStack.stats_ctrl_to_threshold_dict(statsCtrl) 

1047 

1048 bbox = coaddExposure.maskedImage.getBBox() 

1049 

1050 stacker = AccumulatorMeanStack( 

1051 coaddExposure.image.array.shape, 

1052 statsCtrl.getAndMask(), 

1053 mask_threshold_dict=thresholdDict, 

1054 mask_map=maskMap, 

1055 no_good_pixels_mask=statsCtrl.getNoGoodPixelsMask(), 

1056 calc_error_from_input_variance=self.config.calcErrorFromInputVariance, 

1057 compute_n_image=(nImage is not None), 

1058 ) 

1059 

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

1061 warpRefList, imageScalerList, altMaskList, weightList 

1062 ): 

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

1064 maskedImage = exposure.getMaskedImage() 

1065 mask = maskedImage.getMask() 

1066 if altMask is not None: 

1067 self.applyAltMaskPlanes(mask, altMask) 

1068 # Remove in DM-49083. 

1069 if imageScaler is not None: 

1070 imageScaler.scaleMaskedImage(maskedImage) 

1071 if self.config.removeMaskPlanes: 

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

1073 

1074 stacker.add_masked_image(maskedImage, weight=weight) 

1075 

1076 if self.config.doInputMap: 

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

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

1079 

1080 stacker.fill_stacked_masked_image(coaddExposure.maskedImage) 

1081 

1082 if nImage is not None: 

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

1084 

1085 def applyAltMaskPlanes(self, mask, altMaskSpans): 

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

1087 

1088 Parameters 

1089 ---------- 

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

1091 Original mask. 

1092 altMaskSpans : `dict` 

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

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

1095 and list of SpanSets to apply to the mask. 

1096 

1097 Returns 

1098 ------- 

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

1100 Updated mask. 

1101 """ 

1102 if self._doUsePsfMatchedPolygons: 

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

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

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

1106 # still be rejected because of NO_DATA. 

1107 # self._doUsePsfMatchedPolygons should be True only in 

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

1109 # *before* applying the new masks below. 

1110 for spanSet in altMaskSpans["NO_DATA"]: 

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

1112 

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

1114 maskClipValue = mask.addMaskPlane(plane) 

1115 for spanSet in spanSetList: 

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

1117 return mask 

1118 

1119 def _setValidPolygons(self, warpList, psfMatchedWarpRefList): 

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

1121 it exists. 

1122 

1123 Parameters 

1124 ---------- 

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

1126 List of warps. 

1127 psfMatchedWarpRefList : `Iterable` \ 

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

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

1130 ``warpList``. 

1131 

1132 Raises 

1133 ------ 

1134 ValueError 

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

1136 (direct) warps. 

1137 """ 

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

1139 if psfMatchedWarpRef is None: 

1140 continue 

1141 

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

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

1144 

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

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

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

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

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

1150 visit = psfMatchedWarpRef.dataId["visit"] 

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

1152 

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

1154 self.log.debug( 

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

1156 "warp without a validPolygon", 

1157 psfMatchedWarpRef.dataId["visit"], 

1158 ) 

1159 

1160 for psfMatchedCcdRow in psfMatchedCcdTable: 

1161 if not psfMatchedCcdRow.validPolygon: 

1162 self.log.warning( 

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

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

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

1166 psfMatchedCcdRow.id, 

1167 ) 

1168 else: 

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

1170 ccdRow.validPolygon = psfMatchedCcdRow.validPolygon 

1171 

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

1173 """Set the bright object masks. 

1174 

1175 Parameters 

1176 ---------- 

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

1178 Exposure under consideration. 

1179 brightObjectMasks : `lsst.afw.table` 

1180 Table of bright objects to mask. 

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

1182 Data identifier dict for patch. 

1183 """ 

1184 if brightObjectMasks is None: 

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

1186 return 

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

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

1189 wcs = exposure.getWcs() 

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

1191 

1192 for rec in brightObjectMasks: 

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

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

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

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

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

1198 

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

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

1201 

1202 bbox = geom.BoxI( 

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

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

1205 ) 

1206 spans = afwGeom.SpanSet(bbox) 

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

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

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

1210 else: 

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

1212 continue 

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

1214 

1215 def setInexactPsf(self, mask): 

1216 """Set INEXACT_PSF mask plane. 

1217 

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

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

1220 these pixels. 

1221 

1222 Parameters 

1223 ---------- 

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

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

1226 """ 

1227 mask.addMaskPlane("INEXACT_PSF") 

1228 inexactPsf = mask.getPlaneBitMask("INEXACT_PSF") 

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

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

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

1232 array = mask.getArray() 

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

1234 array[selected] |= inexactPsf 

1235 

1236 def filterWarps(self, inputs, goodVisits): 

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

1238 goodVisit. 

1239 

1240 Parameters 

1241 ---------- 

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

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

1244 containing visit. 

1245 goodVisit : `dict` 

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

1247 

1248 Returns 

1249 ------- 

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

1251 Filtered and sorted list of inputRefs with visitId in goodVisits 

1252 ordered by goodVisit. 

1253 """ 

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

1255 filteredInputs = [] 

1256 for visit in goodVisits.keys(): 

1257 if visit in inputWarpDict: 

1258 filteredInputs.append(inputWarpDict[visit]) 

1259 return filteredInputs 

1260 

1261 

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

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

1264 footprint. 

1265 

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

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

1268 ignoreMask set. Return the count. 

1269 

1270 Parameters 

1271 ---------- 

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

1273 Mask to define intersection region by. 

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

1275 Footprint to define the intersection region by. 

1276 bitmask : `Unknown` 

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

1278 ignoreMask : `Unknown` 

1279 Pixels to not consider. 

1280 

1281 Returns 

1282 ------- 

1283 result : `int` 

1284 Number of pixels in footprint with specified mask. 

1285 """ 

1286 bbox = footprint.getBBox() 

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

1288 fp = afwImage.Mask(bbox) 

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

1290 footprint.spans.setMask(fp, bitmask) 

1291 return numpy.logical_and( 

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

1293 ).sum() 

1294 

1295 

1296class CompareWarpAssembleCoaddConnections(AssembleCoaddConnections): 

1297 psfMatchedWarps = pipeBase.connectionTypes.Input( 

1298 doc=( 

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

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

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

1302 ), 

1303 name="{inputCoaddName}Coadd_psfMatchedWarp", 

1304 storageClass="ExposureF", 

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

1306 deferLoad=True, 

1307 multiple=True, 

1308 ) 

1309 templateCoadd = pipeBase.connectionTypes.Output( 

1310 doc=( 

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

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

1313 ), 

1314 name="{outputCoaddName}CoaddPsfMatched", 

1315 storageClass="ExposureF", 

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

1317 ) 

1318 artifactMasks = pipeBase.connectionTypes.Output( 

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

1320 name="compare_warp_artifact_mask", 

1321 storageClass="Mask", 

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

1323 multiple=True, 

1324 ) 

1325 

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

1327 super().__init__(config=config) 

1328 if not config.assembleStaticSkyModel.doWrite: 

1329 self.outputs.remove("templateCoadd") 

1330 config.validate() 

1331 

1332 

1333class CompareWarpAssembleCoaddConfig( 

1334 AssembleCoaddConfig, pipelineConnections=CompareWarpAssembleCoaddConnections 

1335): 

1336 assembleStaticSkyModel = pexConfig.ConfigurableField( 

1337 target=AssembleCoaddTask, 

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

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

1340 ) 

1341 detect = pexConfig.ConfigurableField( 

1342 target=SourceDetectionTask, 

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

1344 ) 

1345 detectTemplate = pexConfig.ConfigurableField( 

1346 target=SourceDetectionTask, 

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

1348 ) 

1349 maskStreaks = pexConfig.ConfigurableField( 

1350 target=MaskStreaksTask, 

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

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

1353 "streakMaskName", 

1354 ) 

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

1356 maxNumEpochs = pexConfig.Field( 

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

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

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

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

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

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

1363 "than transient and not masked.", 

1364 dtype=int, 

1365 default=2, 

1366 ) 

1367 maxFractionEpochsLow = pexConfig.RangeField( 

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

1369 "Effective maxNumEpochs = " 

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

1371 dtype=float, 

1372 default=0.4, 

1373 min=0.0, 

1374 max=1.0, 

1375 ) 

1376 maxFractionEpochsHigh = pexConfig.RangeField( 

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

1378 "Effective maxNumEpochs = " 

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

1380 dtype=float, 

1381 default=0.03, 

1382 min=0.0, 

1383 max=1.0, 

1384 ) 

1385 spatialThreshold = pexConfig.RangeField( 

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

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

1388 dtype=float, 

1389 default=0.5, 

1390 min=0.0, 

1391 max=1.0, 

1392 inclusiveMin=True, 

1393 inclusiveMax=True, 

1394 ) 

1395 doScaleWarpVariance = pexConfig.Field( 

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

1397 dtype=bool, 

1398 default=True, 

1399 ) 

1400 scaleWarpVariance = pexConfig.ConfigurableField( 

1401 target=ScaleVarianceTask, 

1402 doc="Rescale variance on warps", 

1403 ) 

1404 doPreserveContainedBySource = pexConfig.Field( 

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

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

1407 dtype=bool, 

1408 default=True, 

1409 ) 

1410 doPrefilterArtifacts = pexConfig.Field( 

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

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

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

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

1415 dtype=bool, 

1416 default=True, 

1417 ) 

1418 prefilterArtifactsMaskPlanes = pexConfig.ListField( 

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

1420 dtype=str, 

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

1422 ) 

1423 prefilterArtifactsRatio = pexConfig.Field( 

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

1425 dtype=float, 

1426 default=0.05, 

1427 ) 

1428 doFilterMorphological = pexConfig.Field( 

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

1430 "be streaks.", 

1431 dtype=bool, 

1432 default=False, 

1433 ) 

1434 growStreakFp = pexConfig.Field( 

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

1436 ) 

1437 

1438 def setDefaults(self): 

1439 AssembleCoaddConfig.setDefaults(self) 

1440 self.statistic = "MEAN" 

1441 

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

1443 # matching kernel. CompareWarp applies psfMatched EDGE pixels to 

1444 # directWarps before assembling. 

1445 if "EDGE" in self.badMaskPlanes: 

1446 self.badMaskPlanes.remove("EDGE") 

1447 self.removeMaskPlanes.append("EDGE") 

1448 self.assembleStaticSkyModel.badMaskPlanes = [ 

1449 "NO_DATA", 

1450 ] 

1451 self.assembleStaticSkyModel.warpType = "psfMatched" 

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

1453 self.assembleStaticSkyModel.statistic = "MEANCLIP" 

1454 self.assembleStaticSkyModel.sigmaClip = 2.5 

1455 self.assembleStaticSkyModel.clipIter = 3 

1456 self.assembleStaticSkyModel.calcErrorFromInputVariance = False 

1457 self.assembleStaticSkyModel.doWrite = False 

1458 self.detect.doTempLocalBackground = False 

1459 self.detect.reEstimateBackground = False 

1460 self.detect.returnOriginalFootprints = False 

1461 self.detect.thresholdPolarity = "both" 

1462 self.detect.thresholdValue = 5 

1463 self.detect.minPixels = 4 

1464 self.detect.isotropicGrow = True 

1465 self.detect.thresholdType = "pixel_stdev" 

1466 self.detect.nSigmaToGrow = 0.4 

1467 # The default nSigmaToGrow for SourceDetectionTask is already 2.4, 

1468 # Explicitly restating because ratio with detect.nSigmaToGrow matters 

1469 self.detectTemplate.nSigmaToGrow = 2.4 

1470 # Higher thresholds make smaller and fewer protected zones around 

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

1472 self.detectTemplate.thresholdValue = 50 

1473 self.detectTemplate.doTempLocalBackground = False 

1474 self.detectTemplate.reEstimateBackground = False 

1475 self.detectTemplate.returnOriginalFootprints = False 

1476 

1477 def validate(self): 

1478 super().validate() 

1479 if self.assembleStaticSkyModel.doNImage: 

1480 raise ValueError( 

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

1482 "Please set assembleStaticSkyModel.doNImage=False" 

1483 ) 

1484 

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

1486 raise ValueError( 

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

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

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

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

1491 ) 

1492 

1493 

1494class CompareWarpAssembleCoaddTask(AssembleCoaddTask): 

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

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

1497 

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

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

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

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

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

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

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

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

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

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

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

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

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

1511 ``temporalThreshold`` and ``spatialThreshold``. The temporalThreshold is 

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

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

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

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

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

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

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

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

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

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

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

1523 surveys. 

1524 

1525 ``CompareWarpAssembleCoaddTask`` sub-classes 

1526 ``AssembleCoaddTask`` and instantiates ``AssembleCoaddTask`` 

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

1528 

1529 Notes 

1530 ----- 

1531 Debugging: 

1532 This task supports the following debug variables: 

1533 - ``saveCountIm`` 

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

1535 - ``figPath`` 

1536 Path to save the debug fits images and figures 

1537 """ 

1538 

1539 ConfigClass = CompareWarpAssembleCoaddConfig 

1540 _DefaultName = "compareWarpAssembleCoadd" 

1541 

1542 # See the parent class for docstring. 

1543 _doUsePsfMatchedPolygons: bool = True 

1544 

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

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

1547 self.makeSubtask("assembleStaticSkyModel") 

1548 detectionSchema = afwTable.SourceTable.makeMinimalSchema() 

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

1550 if self.config.doPreserveContainedBySource: 

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

1552 if self.config.doScaleWarpVariance: 

1553 self.makeSubtask("scaleWarpVariance") 

1554 if self.config.doFilterMorphological: 

1555 self.makeSubtask("maskStreaks") 

1556 

1557 @utils.inheritDoc(AssembleCoaddTask) 

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

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

1560 subtract from PSF-Matched warps. 

1561 

1562 Returns 

1563 ------- 

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

1565 Results as a struct with attributes: 

1566 

1567 ``templateCoadd`` 

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

1569 ``nImage`` 

1570 Keeps track of exposure count for each pixel 

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

1572 

1573 Raises 

1574 ------ 

1575 RuntimeError 

1576 Raised if ``templateCoadd`` is `None`. 

1577 """ 

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

1579 # generation. 

1580 staticSkyModelInputRefs = copy.deepcopy(inputRefs) 

1581 staticSkyModelInputRefs.inputWarps = inputRefs.psfMatchedWarps 

1582 

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

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

1585 staticSkyModelOutputRefs = copy.deepcopy(outputRefs) 

1586 if self.config.assembleStaticSkyModel.doWrite: 

1587 staticSkyModelOutputRefs.coaddExposure = staticSkyModelOutputRefs.templateCoadd 

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

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

1590 del outputRefs.templateCoadd 

1591 del staticSkyModelOutputRefs.templateCoadd 

1592 

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

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

1595 del staticSkyModelOutputRefs.nImage 

1596 

1597 templateCoadd = self.assembleStaticSkyModel.runQuantum( 

1598 butlerQC, staticSkyModelInputRefs, staticSkyModelOutputRefs 

1599 ) 

1600 if templateCoadd is None: 

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

1602 

1603 return pipeBase.Struct( 

1604 templateCoadd=templateCoadd.coaddExposure, 

1605 nImage=templateCoadd.nImage, 

1606 warpRefList=templateCoadd.warpRefList, 

1607 imageScalerList=templateCoadd.imageScalerList, 

1608 weightList=templateCoadd.weightList, 

1609 psfMatchedWarpRefList=inputRefs.psfMatchedWarps, 

1610 ) 

1611 

1612 def _noTemplateMessage(self, warpType): 

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

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

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

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

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

1618 

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

1620 another algorithm like: 

1621 

1622 from lsst.pipe.tasks.assembleCoadd import SafeClipAssembleCoaddTask 

1623 config.assemble.retarget(SafeClipAssembleCoaddTask) 

1624 """ % { 

1625 "warpName": warpName 

1626 } 

1627 return message 

1628 

1629 @utils.inheritDoc(AssembleCoaddTask) 

1630 @timeMethod 

1631 def run( 

1632 self, 

1633 skyInfo, 

1634 *, 

1635 warpRefList, 

1636 imageScalerList, 

1637 weightList, 

1638 psfMatchedWarpRefList, 

1639 supplementaryData, 

1640 ): 

1641 """Notes 

1642 ----- 

1643 Assemble the coadd. 

1644 

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

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

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

1648 method. 

1649 """ 

1650 # Check and match the order of the supplementaryData 

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

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

1653 

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

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

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

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

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

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

1660 

1661 if dataIds != psfMatchedDataIds: 

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

1663 supplementaryData.warpRefList = reorderAndPadList( 

1664 supplementaryData.warpRefList, psfMatchedDataIds, dataIds 

1665 ) 

1666 # Remove in DM-49083 

1667 supplementaryData.imageScalerList = reorderAndPadList( 

1668 supplementaryData.imageScalerList, psfMatchedDataIds, dataIds 

1669 ) 

1670 

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

1672 # artifacts. 

1673 spanSetMaskList = self.findArtifacts( 

1674 supplementaryData.templateCoadd, supplementaryData.warpRefList, supplementaryData.imageScalerList 

1675 ) 

1676 

1677 badMaskPlanes = self.config.badMaskPlanes[:] 

1678 badMaskPlanes.append("CLIPPED") 

1679 badPixelMask = afwImage.Mask.getPlaneBitMask(badMaskPlanes) 

1680 

1681 result = AssembleCoaddTask.run( 

1682 self, 

1683 skyInfo, 

1684 warpRefList=warpRefList, 

1685 imageScalerList=imageScalerList, 

1686 weightList=weightList, 

1687 altMaskList=spanSetMaskList, 

1688 mask=badPixelMask, 

1689 psfMatchedWarpRefList=psfMatchedWarpRefList, 

1690 ) 

1691 

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

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

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

1695 return result 

1696 

1697 def applyAltEdgeMask(self, mask, altMaskList): 

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

1699 

1700 Parameters 

1701 ---------- 

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

1703 Original mask. 

1704 altMaskList : `list` of `dict` 

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

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

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

1708 the mask. 

1709 """ 

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

1711 for visitMask in altMaskList: 

1712 if "EDGE" in visitMask: 

1713 for spanSet in visitMask["EDGE"]: 

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

1715 

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

1717 """Find artifacts. 

1718 

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

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

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

1722 difference image and filters the artifacts detected in each using 

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

1724 difficult to subtract cleanly. 

1725 

1726 Parameters 

1727 ---------- 

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

1729 Exposure to serve as model of static sky. 

1730 warpRefList : `list` 

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

1732 imageScalerList : `list` 

1733 List of image scalers. 

1734 Deprecated and will be removed after v29. 

1735 

1736 Returns 

1737 ------- 

1738 altMasks : `list` of `dict` 

1739 List of dicts containing information about CLIPPED 

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

1741 """ 

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

1743 coaddBBox = templateCoadd.getBBox() 

1744 slateIm = afwImage.ImageU(coaddBBox) 

1745 epochCountImage = afwImage.ImageU(coaddBBox) 

1746 nImage = afwImage.ImageU(coaddBBox) 

1747 spanSetArtifactList = [] 

1748 spanSetNoDataMaskList = [] 

1749 spanSetEdgeList = [] 

1750 spanSetBadMorphoList = [] 

1751 badPixelMask = self.getBadPixelMask() 

1752 

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

1754 templateCoadd.mask.clearAllMaskPlanes() 

1755 

1756 if self.config.doPreserveContainedBySource: 

1757 templateFootprints = self.detectTemplate.detectFootprints(templateCoadd) 

1758 else: 

1759 templateFootprints = None 

1760 

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

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

1763 if warpDiffExp is not None: 

1764 # This nImage only approximates the final nImage because it 

1765 # uses the PSF-matched mask. 

1766 nImage.array += ( 

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

1768 ).astype(numpy.uint16) 

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

1770 fpSet.positive.merge(fpSet.negative) 

1771 footprints = fpSet.positive 

1772 slateIm.set(0) 

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

1774 

1775 # Remove artifacts due to defects before they contribute to 

1776 # the epochCountImage. 

1777 if self.config.doPrefilterArtifacts: 

1778 spanSetList = self.prefilterArtifacts(spanSetList, warpDiffExp) 

1779 

1780 # Clear mask before adding prefiltered spanSets 

1781 self.detect.clearMask(warpDiffExp.mask) 

1782 for spans in spanSetList: 

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

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

1785 epochCountImage += slateIm 

1786 

1787 if self.config.doFilterMorphological: 

1788 maskName = self.config.streakMaskName 

1789 # clear single frame streak mask if it exists already 

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

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

1792 else: 

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

1794 

1795 _ = self.maskStreaks.run(warpDiffExp) 

1796 streakMask = warpDiffExp.mask 

1797 initSpanSetStreak = afwGeom.SpanSet.fromMask( 

1798 streakMask, streakMask.getPlaneBitMask(maskName) 

1799 ).split() 

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

1801 # wings. 

1802 psf = warpDiffExp.getPsf() 

1803 spanSetStreak = [] 

1804 for sset in initSpanSetStreak: 

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

1806 doKeep = True 

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

1808 if footprint.spans.contains(sset): 

1809 doKeep = False 

1810 break 

1811 if not doKeep: 

1812 continue 

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

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

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

1816 spanSetStreak.append(sset_dilated) 

1817 

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

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

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

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

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

1823 # the direct warp. 

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

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

1826 nansMask.setXY0(warpDiffExp.getXY0()) 

1827 edgeMask = warpDiffExp.mask 

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

1829 else: 

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

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

1832 nansMask = afwImage.MaskX(coaddBBox, 1) 

1833 spanSetList = [] 

1834 spanSetEdgeMask = [] 

1835 spanSetStreak = [] 

1836 

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

1838 

1839 spanSetNoDataMaskList.append(spanSetNoDataMask) 

1840 spanSetArtifactList.append(spanSetList) 

1841 spanSetEdgeList.append(spanSetEdgeMask) 

1842 if self.config.doFilterMorphological: 

1843 spanSetBadMorphoList.append(spanSetStreak) 

1844 

1845 if lsstDebug.Info(__name__).saveCountIm: 

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

1847 epochCountImage.writeFits(path) 

1848 

1849 for i, spanSetList in enumerate(spanSetArtifactList): 

1850 if spanSetList: 

1851 filteredSpanSetList = self.filterArtifacts( 

1852 spanSetList, epochCountImage, nImage, templateFootprints 

1853 ) 

1854 spanSetArtifactList[i] = filteredSpanSetList 

1855 if self.config.doFilterMorphological: 

1856 spanSetArtifactList[i] += spanSetBadMorphoList[i] 

1857 

1858 altMasks = [] 

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

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

1861 return altMasks 

1862 

1863 def prefilterArtifacts(self, spanSetList, exp): 

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

1865 

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

1867 temporal information should go in this method. 

1868 

1869 Parameters 

1870 ---------- 

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

1872 List of SpanSets representing artifact candidates. 

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

1874 Exposure containing mask planes used to prefilter. 

1875 

1876 Returns 

1877 ------- 

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

1879 List of SpanSets with artifacts. 

1880 """ 

1881 badPixelMask = exp.mask.getPlaneBitMask(self.config.prefilterArtifactsMaskPlanes) 

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

1883 returnSpanSetList = [] 

1884 bbox = exp.getBBox() 

1885 x0, y0 = exp.getXY0() 

1886 for i, span in enumerate(spanSetList): 

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

1888 yIndexLocal = numpy.array(y) - y0 

1889 xIndexLocal = numpy.array(x) - x0 

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

1891 if goodRatio > self.config.prefilterArtifactsRatio: 

1892 returnSpanSetList.append(span) 

1893 return returnSpanSetList 

1894 

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

1896 """Filter artifact candidates. 

1897 

1898 Parameters 

1899 ---------- 

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

1901 List of SpanSets representing artifact candidates. 

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

1903 Image of accumulated number of warpDiff detections. 

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

1905 Image of the accumulated number of total epochs contributing. 

1906 

1907 Returns 

1908 ------- 

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

1910 List of SpanSets with artifacts. 

1911 """ 

1912 maskSpanSetList = [] 

1913 x0, y0 = epochCountImage.getXY0() 

1914 for i, span in enumerate(spanSetList): 

1915 y, x = span.indices() 

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

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

1918 outlierN = epochCountImage.array[yIdxLocal, xIdxLocal] 

1919 totalN = nImage.array[yIdxLocal, xIdxLocal] 

1920 

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

1922 # characteristic config.maxNumEpochs. 

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

1924 totalN 

1925 ) 

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

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

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

1929 percentBelowThreshold = nPixelsBelowThreshold / len(outlierN) 

1930 if percentBelowThreshold > self.config.spatialThreshold: 

1931 maskSpanSetList.append(span) 

1932 

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

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

1935 # do not clip. 

1936 filteredMaskSpanSetList = [] 

1937 for span in maskSpanSetList: 

1938 doKeep = True 

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

1940 if footprint.spans.contains(span): 

1941 doKeep = False 

1942 break 

1943 if doKeep: 

1944 filteredMaskSpanSetList.append(span) 

1945 maskSpanSetList = filteredMaskSpanSetList 

1946 

1947 return maskSpanSetList 

1948 

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

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

1951 

1952 Parameters 

1953 ---------- 

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

1955 Dataset handle for the warp. 

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

1957 An image scaler object. 

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

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

1960 Exposure to be substracted from the scaled warp. 

1961 

1962 Returns 

1963 ------- 

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

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

1966 """ 

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

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

1969 if warpRef is None: 

1970 return None 

1971 

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

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

1974 # Remove in DM-49083. 

1975 if imageScaler is not None: 

1976 imageScaler.scaleMaskedImage(warp.getMaskedImage()) 

1977 mi = warp.getMaskedImage() 

1978 if self.config.doScaleWarpVariance: 

1979 try: 

1980 self.scaleWarpVariance.run(mi) 

1981 except Exception as exc: 

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

1983 mi -= templateCoadd.getMaskedImage() 

1984 return warp