Coverage for python/lsst/pipe/tasks/make_direct_warp.py: 32%

207 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-05-01 04:34 -0700

1# This file is part of pipe_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 

24from typing import TYPE_CHECKING, Iterable 

25 

26import numpy as np 

27from lsst.afw.geom import makeWcsPairTransform 

28from lsst.afw.image import ExposureF, Mask 

29from lsst.afw.math import Warper 

30from lsst.coadd.utils import copyGoodPixels 

31from lsst.meas.algorithms import CoaddPsf, CoaddPsfConfig, WarpedPsf 

32from lsst.meas.algorithms.cloughTocher2DInterpolator import ( 

33 CloughTocher2DInterpolateTask, 

34) 

35from lsst.meas.base import DetectorVisitIdGeneratorConfig 

36from lsst.pex.config import ( 

37 ConfigField, 

38 ConfigurableField, 

39 Field, 

40 RangeField, 

41) 

42from lsst.pipe.base import ( 

43 NoWorkFound, 

44 PipelineTask, 

45 PipelineTaskConfig, 

46 PipelineTaskConnections, 

47 Struct, 

48) 

49from lsst.pipe.base.connectionTypes import Input, Output 

50from lsst.pipe.tasks.coaddBase import makeSkyInfo 

51from lsst.skymap import BaseSkyMap 

52 

53from .coaddInputRecorder import CoaddInputRecorderTask 

54 

55if TYPE_CHECKING: 55 ↛ 56line 55 didn't jump to line 56, because the condition on line 55 was never true

56 from lsst.afw.image import MaskedImage 

57 from lsst.afw.table import Exposure, ExposureCatalog 

58 

59 

60__all__ = ( 

61 "MakeDirectWarpConfig", 

62 "MakeDirectWarpTask", 

63) 

64 

65 

66class MakeDirectWarpConnections( 

67 PipelineTaskConnections, 

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

69 defaultTemplates={ 

70 "coaddName": "deep", 

71 "calexpType": "", 

72 }, 

73): 

74 """Connections for MakeWarpTask""" 

75 

76 calexp_list = Input( 

77 doc="Input exposures to be interpolated and resampled onto a SkyMap " 

78 "projection/patch.", 

79 name="{calexpType}calexp", 

80 storageClass="ExposureF", 

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

82 multiple=True, 

83 deferLoad=True, 

84 ) 

85 background_revert_list = Input( 

86 doc="Background to be reverted (i.e., added back to the calexp). " 

87 "This connection is used only if doRevertOldBackground=False.", 

88 name="calexpBackground", 

89 storageClass="Background", 

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

91 multiple=True, 

92 ) 

93 background_apply_list = Input( 

94 doc="Background to be applied (subtracted from the calexp). " 

95 "This is used only if doApplyNewBackground=True.", 

96 name="skyCorr", 

97 storageClass="Background", 

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

99 multiple=True, 

100 ) 

101 visit_summary = Input( 

102 doc="Input visit-summary catalog with updated calibration objects.", 

103 name="finalVisitSummary", 

104 storageClass="ExposureCatalog", 

105 dimensions=("instrument", "visit"), 

106 ) 

107 sky_map = Input( 

108 doc="Input definition of geometry/bbox and projection/wcs for warps.", 

109 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME, 

110 storageClass="SkyMap", 

111 dimensions=("skymap",), 

112 ) 

113 # Declare all possible outputs (except noise, which is configurable) 

114 warp = Output( 

115 doc="Output direct warped exposure produced by resampling calexps " 

116 "onto the skyMap patch geometry.", 

117 name="{coaddName}Coadd_directWarp", 

118 storageClass="ExposureF", 

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

120 ) 

121 masked_fraction_warp = Output( 

122 doc="Output masked fraction warped exposure.", 

123 name="{coaddName}Coadd_directWarp_maskedFraction", 

124 storageClass="ImageF", 

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

126 ) 

127 

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

129 super().__init__(config=config) 

130 if not config: 

131 return 

132 

133 if not config.doRevertOldBackground: 

134 del self.background_revert_list 

135 if not config.doApplyNewBackground: 

136 del self.background_apply_list 

137 

138 # Dynamically set output connections for noise images, depending on the 

139 # number of noise realization specified in the config. 

140 for n in range(config.numberOfNoiseRealizations): 

141 noise_warp = Output( 

142 doc=f"Output direct warped noise exposure ({n})", 

143 name=f"{config.connections.coaddName}Coadd_directWarp_noise{n}", 

144 # Store it as a MaskedImage to preserve the variance plane. 

145 storageClass="MaskedImageF", 

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

147 ) 

148 setattr(self, f"noise_warp{n}", noise_warp) 

149 

150 

151class MakeDirectWarpConfig( 

152 PipelineTaskConfig, 

153 pipelineConnections=MakeDirectWarpConnections, 

154): 

155 """Configuration for the MakeDirectWarpTask. 

156 

157 The config fields are as similar as possible to the corresponding fields in 

158 MakeWarpConfig. 

159 

160 Notes 

161 ----- 

162 The config fields are in camelCase to match the fields in the earlier 

163 version of the makeWarp task as closely as possible. 

164 """ 

165 

166 MAX_NUMBER_OF_NOISE_REALIZATIONS = 3 

167 """ 

168 numberOfNoiseRealizations is defined as a RangeField to prevent from 

169 making multiple output connections and blowing up the memory usage by 

170 accident. An upper bound of 3 is based on the best guess of the maximum 

171 number of noise realizations that will be used for metadetection. 

172 """ 

173 

174 numberOfNoiseRealizations = RangeField[int]( 

175 doc="Number of noise realizations to simulate and persist.", 

176 default=1, 

177 min=0, 

178 max=MAX_NUMBER_OF_NOISE_REALIZATIONS, 

179 inclusiveMax=True, 

180 ) 

181 seedOffset = Field[int]( 

182 doc="Offset to the seed used for the noise realization. This can be " 

183 "used to create a different noise realization if the default ones " 

184 "are catastrophic, or for testing sensitivity to the noise.", 

185 default=0, 

186 ) 

187 useMedianVariance = Field[bool]( 

188 doc="Use the median of variance plane in the input calexp to generate " 

189 "noise realizations? If False, per-pixel variance will be used.", 

190 default=True, 

191 ) 

192 doRevertOldBackground = Field[bool]( 

193 doc="Revert the old backgrounds from the `background_revert_list` " 

194 "connection?", 

195 default=True, 

196 ) 

197 doApplyNewBackground = Field[bool]( 

198 doc="Apply the new backgrounds from the `background_apply_list` " 

199 "connection?", 

200 default=False, 

201 ) 

202 useVisitSummaryPsf = Field[bool]( 

203 doc="If True, use the PSF model and aperture corrections from the " 

204 "'visit_summary' connection. If False, use the PSF model and " 

205 "aperture corrections from the 'calexp' connection.", 

206 default=True, 

207 ) 

208 doPreWarpInterpolation = Field[bool]( 

209 doc="Interpolate over bad pixels before warping?", 

210 default=True, 

211 ) 

212 preWarpInterpolation = ConfigurableField( 

213 doc="Interpolation task to use for pre-warping interpolation", 

214 target=CloughTocher2DInterpolateTask, 

215 ) 

216 inputRecorder = ConfigurableField( 

217 doc="Subtask that helps fill CoaddInputs catalogs added to the final " 

218 "coadd", 

219 target=CoaddInputRecorderTask, 

220 ) 

221 includeCalibVar = Field[bool]( 

222 doc="Add photometric calibration variance to warp variance plane?", 

223 default=False, 

224 ) 

225 matchingKernelSize = Field[int]( 225 ↛ exitline 225 didn't jump to the function exit

226 doc="Size in pixels of matching kernel. Must be odd.", 

227 default=21, 

228 check=lambda x: x % 2 == 1, 

229 ) 

230 warper = ConfigField( 

231 doc="Configuration for the warper that warps the image and noise", 

232 dtype=Warper.ConfigClass, 

233 ) 

234 maskedFractionWarper = ConfigField( 

235 doc="Configuration for the warp that warps the mask fraction image", 

236 dtype=Warper.ConfigClass, 

237 ) 

238 coaddPsf = ConfigField( 

239 doc="Configuration for CoaddPsf", 

240 dtype=CoaddPsfConfig, 

241 ) 

242 idGenerator = DetectorVisitIdGeneratorConfig.make_field() 

243 

244 # Use bgSubtracted and doApplySkyCorr to match the old MakeWarpConfig, 

245 # but as properties instead of config fields. 

246 @property 

247 def bgSubtracted(self) -> bool: 

248 return not self.doRevertOldBackground 

249 

250 @bgSubtracted.setter 

251 def bgSubtracted(self, value: bool) -> None: 

252 self.doRevertOldBackground = ~value 

253 

254 @property 

255 def doApplySkyCorr(self) -> bool: 

256 return self.doApplyNewBackground 

257 

258 @doApplySkyCorr.setter 

259 def doApplySkyCorr(self, value: bool) -> None: 

260 self.doApplyNewBackground = value 

261 

262 def setDefaults(self) -> None: 

263 super().setDefaults() 

264 self.warper.warpingKernelName = "lanczos3" 

265 self.maskedFractionWarper.warpingKernelName = "bilinear" 

266 

267 

268class MakeDirectWarpTask(PipelineTask): 

269 """Warp single-detector images onto a common projection. 

270 

271 This task iterates over multiple images (corresponding to different 

272 detectors) from a single visit that overlap the target patch. Pixels that 

273 receive no input from any detector are set to NaN in the output image, and 

274 NO_DATA bit is set in the mask plane. 

275 

276 This differs from the standard `MakeWarp` Task in the following 

277 ways: 

278 

279 1. No selection on ccds at the time of warping. This is done later during 

280 the coaddition stage. 

281 2. Interpolate over a set of masked pixels before warping. 

282 3. Generate an image where each pixel denotes how much of the pixel is 

283 masked. 

284 4. Generate multiple noise warps with the same interpolation applied. 

285 5. No option to produce a PSF-matched warp. 

286 """ 

287 

288 ConfigClass = MakeDirectWarpConfig 

289 _DefaultName = "makeDirectWarp" 

290 

291 def __init__(self, **kwargs): 

292 super().__init__(**kwargs) 

293 self.makeSubtask("inputRecorder") 

294 self.makeSubtask("preWarpInterpolation") 

295 

296 self.warper = Warper.fromConfig(self.config.warper) 

297 self.maskedFractionWarper = Warper.fromConfig(self.config.maskedFractionWarper) 

298 

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

300 # Docstring inherited. 

301 

302 # Read in all inputs. 

303 inputs = butlerQC.get(inputRefs) 

304 

305 if not inputs["calexp_list"]: 

306 raise NoWorkFound("No input warps provided for co-addition") 

307 

308 sky_map = inputs.pop("sky_map") 

309 

310 quantumDataId = butlerQC.quantum.dataId 

311 sky_info = makeSkyInfo( 

312 sky_map, 

313 tractId=quantumDataId["tract"], 

314 patchId=quantumDataId["patch"], 

315 ) 

316 

317 visit_summary = inputs["visit_summary"] if self.config.useVisitSummaryPsf else None 

318 

319 results = self.run(inputs, sky_info, visit_summary) 

320 butlerQC.put(results, outputRefs) 

321 

322 def run(self, inputs, sky_info, visit_summary): 

323 """Create a Warp dataset from inputs. 

324 

325 Parameters 

326 ---------- 

327 inputs : `Mapping` 

328 Dictionary of input datasets. It must have a list of input calexps 

329 under the key "calexp_list". Other supported keys are 

330 "background_revert_list" and "background_apply_list", corresponding 

331 to the old and the new backgrounds to be reverted and applied to 

332 the calexps. They must be in the same order as the calexps. 

333 sky_info : `~lsst.pipe.base.Struct` 

334 A Struct object containing wcs, bounding box, and other information 

335 about the patches within the tract. 

336 visit_summary : `~lsst.afw.table.ExposureCatalog` | None 

337 Table of visit summary information. If provided, the visit summary 

338 information will be used to update the calibration of the input 

339 exposures. If None, the input exposures will be used as-is. 

340 

341 Returns 

342 ------- 

343 results : `~lsst.pipe.base.Struct` 

344 A Struct object containing the warped exposure, noise exposure(s), 

345 and masked fraction image. 

346 """ 

347 target_bbox, target_wcs = sky_info.bbox, sky_info.wcs 

348 

349 # Initialize the objects that will hold the warp. 

350 final_warp = ExposureF(target_bbox, target_wcs) 

351 

352 exposures = inputs["calexp_list"] 

353 background_revert_list = inputs.get("background_revert_list", [None] * len(exposures)) 

354 background_apply_list = inputs.get("background_apply_list", [None] * len(exposures)) 

355 

356 visit_id = exposures[0].dataId["visit"] 

357 

358 # The warpExposure routine is expensive, and we do not want to call 

359 # it twice (i.e., a second time for PSF-matched warps). We do not 

360 # want to hold all the warped exposures in memory at once either. 

361 # So we create empty exposure(s) to accumulate the warps of each type, 

362 # and then process each detector serially. 

363 final_warp = self._prepareEmptyExposure(sky_info) 

364 final_masked_fraction_warp = self._prepareEmptyExposure(sky_info) 

365 final_noise_warps = { 

366 n_noise: self._prepareEmptyExposure(sky_info) 

367 for n_noise in range(self.config.numberOfNoiseRealizations) 

368 } 

369 

370 # We need a few bookkeeping variables only for the science coadd. 

371 totalGoodPixels = 0 

372 inputRecorder = self.inputRecorder.makeCoaddTempExpRecorder( 

373 visit_id, 

374 len(exposures), 

375 ) 

376 

377 for index, (calexp_ref, old_background, new_background) in enumerate( 

378 zip(exposures, background_revert_list, background_apply_list, strict=True) 

379 ): 

380 dataId = calexp_ref.dataId 

381 self.log.debug( 

382 "Warping exposure %d/%d for id=%s", 

383 index + 1, 

384 len(exposures), 

385 dataId, 

386 ) 

387 calexp = calexp_ref.get() 

388 

389 # Generate noise image(s) in-situ. 

390 seed = self.get_seed_from_data_id(dataId) 

391 rng = np.random.RandomState(seed + self.config.seedOffset) 

392 

393 # Generate noise images in-situ. 

394 noise_calexps = self.make_noise_exposures(calexp, rng) 

395 

396 # Warp the PSF before processing nad overwriting exposure. 

397 xyTransform = makeWcsPairTransform(calexp.getWcs(), target_wcs) 

398 psfWarped = WarpedPsf(calexp.getPsf(), xyTransform) 

399 

400 warpedExposure = self.process( 

401 calexp, 

402 target_wcs, 

403 self.warper, 

404 old_background, 

405 new_background, 

406 visit_summary, 

407 destBBox=target_bbox, 

408 ) 

409 warpedExposure.setPsf(psfWarped) 

410 

411 # Accumulate the partial warps in an online fashion. 

412 nGood = copyGoodPixels( 

413 final_warp.maskedImage, 

414 warpedExposure.maskedImage, 

415 final_warp.mask.getPlaneBitMask(["NO_DATA"]), 

416 ) 

417 ccdId = self.config.idGenerator.apply(dataId).catalog_id 

418 inputRecorder.addCalExp(calexp, ccdId, nGood) 

419 totalGoodPixels += nGood 

420 

421 # Obtain the masked fraction exposure and warp it. 

422 if self.config.doPreWarpInterpolation: 

423 badMaskPlanes = self.preWarpInterpolation.config.badMaskPlanes 

424 else: 

425 badMaskPlanes = [] 

426 masked_fraction_exp = self._get_bad_mask(calexp, badMaskPlanes) 

427 masked_fraction_warp = self.maskedFractionWarper.warpExposure( 

428 target_wcs, masked_fraction_exp, destBBox=target_bbox 

429 ) 

430 copyGoodPixels( 

431 final_masked_fraction_warp.maskedImage, 

432 masked_fraction_warp.maskedImage, 

433 final_masked_fraction_warp.mask.getPlaneBitMask(["NO_DATA"]), 

434 ) 

435 

436 # Process and accumulate noise images. 

437 for n_noise in range(self.config.numberOfNoiseRealizations): 

438 noise_calexp = noise_calexps[n_noise] 

439 warpedNoise = self.process( 

440 noise_calexp, 

441 target_wcs, 

442 self.warper, 

443 old_background, 

444 new_background, 

445 visit_summary, 

446 destBBox=target_bbox, 

447 ) 

448 copyGoodPixels( 

449 final_noise_warps[n_noise].maskedImage, 

450 warpedNoise.maskedImage, 

451 final_noise_warps[n_noise].mask.getPlaneBitMask(["NO_DATA"]), 

452 ) 

453 

454 # Finish the inputRecorder and add the coaddPsf to the final warp. 

455 if totalGoodPixels > 0: 

456 inputRecorder.finish(final_warp, totalGoodPixels) 

457 

458 coaddPsf = CoaddPsf( 

459 inputRecorder.coaddInputs.ccds, 

460 sky_info.wcs, 

461 self.config.coaddPsf.makeControl(), 

462 ) 

463 

464 final_warp.setPsf(coaddPsf) 

465 final_warp.setFilter(calexp.getFilter()) 

466 final_warp.getInfo().setVisitInfo(calexp.getInfo().getVisitInfo()) 

467 

468 results = Struct( 

469 warp=final_warp, 

470 masked_fraction_warp=final_masked_fraction_warp.image, 

471 ) 

472 

473 for noise_index, noise_exposure in final_noise_warps.items(): 

474 setattr(results, f"noise_warp{noise_index}", noise_exposure.maskedImage) 

475 

476 return results 

477 

478 def process( 

479 self, 

480 exposure, 

481 target_wcs, 

482 warper, 

483 old_background=None, 

484 new_background=None, 

485 visit_summary=None, 

486 maxBBox=None, 

487 destBBox=None, 

488 ): 

489 """Process an exposure. 

490 

491 There are three processing steps that are applied to the input: 

492 

493 1. Interpolate over bad pixels before warping. 

494 2. Apply all calibrations from visit_summary to the exposure. 

495 3. Warp the exposure to the target coordinate system. 

496 

497 Parameters 

498 ---------- 

499 exposure : `~lsst.afw.image.Exposure` 

500 The input exposure to be processed. 

501 target_wcs : `~lsst.afw.geom.SkyWcs` 

502 The WCS of the target patch. 

503 warper : `~lsst.afw.math.Warper` 

504 The warper to use for warping the input exposure. 

505 old_background : `~lsst.afw.image.Background` | None 

506 The old background to be added back into the calexp. 

507 new_background : `~lsst.afw.image.Background` | None 

508 The new background to be subtracted from the calexp. 

509 visit_summary : `~lsst.afw.table.ExposureCatalog` | None 

510 Table of visit summary information. If not None, the visit_summary 

511 information will be used to update the calibration of the input 

512 exposures. Otherwise, the input exposures will be used as-is. 

513 maxBBox : `~lsst.geom.Box2I` | None 

514 Maximum bounding box of the warped exposure. If None, this is 

515 determined automatically. 

516 destBBox : `~lsst.geom.Box2I` | None 

517 Exact bounding box of the warped exposure. If None, this is 

518 determined automatically. 

519 

520 Returns 

521 ------- 

522 warped_exposure : `~lsst.afw.image.Exposure` 

523 The processed and warped exposure. 

524 """ 

525 

526 if self.config.doPreWarpInterpolation: 

527 self.preWarpInterpolation.run(exposure.maskedImage) 

528 

529 self._apply_all_calibrations( 

530 exposure, 

531 old_background, 

532 new_background, 

533 logger=self.log, 

534 visit_summary=visit_summary, 

535 includeScaleUncertainty=self.config.includeCalibVar, 

536 ) 

537 with self.timer("warp"): 

538 warped_exposure = warper.warpExposure( 

539 target_wcs, 

540 exposure, 

541 maxBBox=maxBBox, 

542 destBBox=destBBox, 

543 ) 

544 

545 # Potentially a post-warp interpolation here? Relies on DM-38630. 

546 

547 return warped_exposure 

548 

549 @staticmethod 

550 def _apply_all_calibrations( 

551 exp: Exposure, 

552 old_background, 

553 new_background, 

554 logger, 

555 visit_summary: ExposureCatalog | None = None, 

556 includeScaleUncertainty: bool = False, 

557 ) -> None: 

558 """Apply all of the calibrations from visit_summary to the exposure. 

559 

560 Specifically, this method updates the following (if available) to the 

561 input exposure ``exp`` in place from ``visit_summary``: 

562 

563 - Aperture correction map 

564 - Photometric calibration 

565 - PSF 

566 - WCS 

567 

568 Parameters 

569 ---------- 

570 exp : `~lsst.afw.image.Exposure` 

571 Exposure to be updated. 

572 old_background : `~lsst.afw.image.Exposure` 

573 Exposure corresponding to the old background, to be added back. 

574 new_background : `~lsst.afw.image.Exposure` 

575 Exposure corresponding to the new background, to be subtracted. 

576 logger : `logging.Logger` 

577 Logger object from the caller Task to write the logs onto. 

578 visit_summary : `~lsst.afw.table.ExposureCatalog` | None 

579 Table of visit summary information. If not None, the visit summary 

580 information will be used to update the calibration of the input 

581 exposures. Otherwise, the input exposures will be used as-is. 

582 includeScaleUncertainty : bool 

583 Whether to include the uncertainty on the calibration in the 

584 resulting variance? Passed onto the `calibrateImage` method of the 

585 PhotoCalib object attached to ``exp``. 

586 

587 Raises 

588 ------ 

589 RuntimeError 

590 Raised if ``visit_summary`` is provided but does not contain a 

591 record corresponding to ``exp``. 

592 """ 

593 if not visit_summary: 

594 logger.debug("No visit summary provided.") 

595 else: 

596 logger.debug("Updating calibration from visit summary.") 

597 

598 if old_background: 

599 exp.maskedImage += old_background.getImage() 

600 

601 if visit_summary: 

602 detector = exp.info.getDetector().getId() 

603 row = visit_summary.find(detector) 

604 

605 if row is None: 

606 raise RuntimeError(f"Unexpectedly incomplete visit_summary: {detector=} is missing.") 

607 

608 if photo_calib := row.getPhotoCalib(): 

609 exp.setPhotoCalib(photo_calib) 

610 else: 

611 logger.warning( 

612 "No photometric calibration found in visit summary for detector = %s.", 

613 detector, 

614 ) 

615 

616 if wcs := row.getWcs(): 

617 exp.setWcs(wcs) 

618 else: 

619 logger.warning("No WCS found in visit summary for detector = %s.", detector) 

620 

621 if psf := row.getPsf(): 

622 exp.setPsf(psf) 

623 else: 

624 logger.warning("No PSF found in visit summary for detector = %s.", detector) 

625 

626 if apcorr_map := row.getApCorrMap(): 

627 exp.setApCorrMap(apcorr_map) 

628 else: 

629 logger.warning( 

630 "No aperture correction map found in visit summary for detector = %s.", 

631 detector, 

632 ) 

633 

634 if new_background: 

635 exp.maskedImage -= new_background.getImage() 

636 

637 # Calibrate the (masked) image. 

638 # This should likely happen even if visit_summary is None. 

639 photo_calib = exp.getPhotoCalib() 

640 exp.maskedImage = photo_calib.calibrateImage( 

641 exp.maskedImage, includeScaleUncertainty=includeScaleUncertainty 

642 ) 

643 exp.maskedImage /= photo_calib.getCalibrationMean() 

644 

645 # This method is copied from makeWarp.py 

646 @classmethod 

647 def _prepareEmptyExposure(cls, sky_info): 

648 """Produce an empty exposure for a given patch. 

649 

650 Parameters 

651 ---------- 

652 sky_info : `lsst.pipe.base.Struct` 

653 Struct from `~lsst.pipe.base.coaddBase.makeSkyInfo` with 

654 geometric information about the patch. 

655 

656 Returns 

657 ------- 

658 exp : `lsst.afw.image.exposure.ExposureF` 

659 An empty exposure for a given patch. 

660 """ 

661 exp = ExposureF(sky_info.bbox, sky_info.wcs) 

662 exp.getMaskedImage().set(np.nan, Mask.getPlaneBitMask("NO_DATA"), np.inf) 

663 return exp 

664 

665 @staticmethod 

666 def compute_median_variance(mi: MaskedImage) -> float: 

667 """Compute the median variance across the good pixels of a MaskedImage. 

668 

669 Parameters 

670 ---------- 

671 mi : `~lsst.afw.image.MaskedImage` 

672 The input image on which to compute the median variance. 

673 

674 Returns 

675 ------- 

676 median_variance : `float` 

677 Median variance of the input calexp. 

678 """ 

679 # Shouldn't this exclude pixels that are masked, to be safe? 

680 # This is implemented as it was in descwl_coadd. 

681 return np.median(mi.variance.array[np.isfinite(mi.variance.array) & np.isfinite(mi.image.array)]) 

682 

683 def get_seed_from_data_id(self, data_id) -> int: 

684 """Get a seed value given a data_id. 

685 

686 This method generates a unique, reproducible pseudo-random number for 

687 a data id. This is not affected by ordering of the input, or what 

688 set of visits, ccds etc. are given. 

689 

690 This is implemented as a public method, so that simulations that 

691 don't necessary deal with the middleware can mock up a ``data_id`` 

692 instance, or override this method with a different one to obtain a 

693 seed value consistent with the pipeline task. 

694 

695 Parameters 

696 ---------- 

697 data_id : `~lsst.daf.butler.DataCoordinate` 

698 Data identifier dictionary. 

699 

700 Returns 

701 ------- 

702 seed : `int` 

703 A unique seed for this data_id to seed a random number generator. 

704 """ 

705 return self.config.idGenerator.apply(data_id).catalog_id 

706 

707 def make_noise_exposures(self, calexp: ExposureF, rng) -> dict[int, ExposureF]: 

708 """Make pure noise realizations based on ``calexp``. 

709 

710 Parameters 

711 ---------- 

712 calexp : `~lsst.afw.image.ExposureF` 

713 The input exposure on which to base the noise realizations. 

714 rng : `np.random.RandomState` 

715 Random number generator to use for the noise realizations. 

716 

717 Returns 

718 ------- 

719 noise_calexps : `dict` [`int`, `~lsst.afw.image.ExposureF`] 

720 A mapping of integers ranging from 0 up to 

721 config.numberOfNoiseRealizations to the corresponding 

722 noise realization exposures. 

723 """ 

724 noise_calexps = {} 

725 

726 # If no noise exposures are requested, return the empty dictionary 

727 # without any further computations. 

728 if self.config.numberOfNoiseRealizations == 0: 

729 return noise_calexps 

730 

731 if self.config.useMedianVariance: 

732 variance = self.compute_median_variance(calexp.maskedImage) 

733 else: 

734 variance = calexp.variance.array 

735 

736 for n_noise in range(self.config.numberOfNoiseRealizations): 

737 noise_calexp = calexp.clone() 

738 noise_calexp.image.array[:, :] = rng.normal( 

739 scale=np.sqrt(variance), 

740 size=noise_calexp.image.array.shape, 

741 ) 

742 noise_calexp.variance.array[:, :] = variance 

743 noise_calexps[n_noise] = noise_calexp 

744 

745 return noise_calexps 

746 

747 @classmethod 

748 def _get_bad_mask(cls, exp: ExposureF, badMaskPlanes: Iterable[str]) -> ExposureF: 

749 """Get an Exposure of bad mask 

750 

751 Parameters 

752 ---------- 

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

754 The exposure data. 

755 badMaskPlanes: `list` [`str`] 

756 List of mask planes to be considered as bad. 

757 

758 Returns 

759 ------- 

760 bad_mask: `~lsst.afw.image.Exposure` 

761 An Exposure with boolean array with True if inverse variance <= 0 

762 or if any of the badMaskPlanes bits are set, and False otherwise. 

763 """ 

764 

765 bad_mask = exp.clone() 

766 

767 var = exp.variance.array 

768 mask = exp.mask.array 

769 

770 bitMask = exp.mask.getPlaneBitMask(badMaskPlanes) 

771 

772 bad_mask.image.array[:, :] = (var < 0) | np.isinf(var) | ((mask & bitMask) != 0) 

773 

774 bad_mask.variance.array *= 0.0 

775 

776 return bad_mask