Coverage for python/lsst/pipe/tasks/multiBand.py: 23%

298 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-25 09:10 +0000

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 

22__all__ = ["DetectCoaddSourcesConfig", "DetectCoaddSourcesTask", 

23 "MeasureMergedCoaddSourcesConfig", "MeasureMergedCoaddSourcesTask", 

24 ] 

25 

26import numpy as np 

27 

28from lsst.geom import Extent2I 

29from lsst.pipe.base import ( 

30 AnnotatedPartialOutputsError, 

31 Struct, 

32 PipelineTask, 

33 PipelineTaskConfig, 

34 PipelineTaskConnections 

35) 

36import lsst.pipe.base.connectionTypes as cT 

37from lsst.pex.config import Field, ConfigurableField 

38from lsst.meas.algorithms import ( 

39 DynamicDetectionTask, 

40 ExceedsMaxVarianceScaleError, 

41 InsufficientSourcesError, 

42 PsfGenerationError, 

43 ScaleVarianceTask, 

44 SetPrimaryFlagsTask, 

45 TooManyMaskedPixelsError, 

46 ZeroFootprintError, 

47) 

48from lsst.meas.base import ( 

49 SingleFrameMeasurementTask, 

50 ApplyApCorrTask, 

51 CatalogCalculationTask, 

52 SkyMapIdGeneratorConfig, 

53) 

54from lsst.meas.extensions.scarlet.io import updateCatalogFootprints 

55from lsst.pipe.tasks.propagateSourceFlags import PropagateSourceFlagsTask 

56import lsst.afw.image as afwImage 

57import lsst.afw.math as afwMath 

58import lsst.afw.table as afwTable 

59from lsst.daf.base import PropertyList 

60from lsst.skymap import BaseSkyMap 

61 

62# NOTE: these imports are a convenience so multiband users only have to import this file. 

63from .mergeDetections import MergeDetectionsConfig, MergeDetectionsTask # noqa: F401 

64from .mergeMeasurements import MergeMeasurementsConfig, MergeMeasurementsTask # noqa: F401 

65from .multiBandUtils import CullPeaksConfig # noqa: F401 

66from .deblendCoaddSourcesPipeline import DeblendCoaddSourcesMultiConfig # noqa: F401 

67from .deblendCoaddSourcesPipeline import DeblendCoaddSourcesMultiTask # noqa: F401 

68 

69 

70""" 

71New set types: 

72* deepCoadd_det: detections from what used to be processCoadd (tract, patch, filter) 

73* deepCoadd_mergeDet: merged detections (tract, patch) 

74* deepCoadd_meas: measurements of merged detections (tract, patch, filter) 

75* deepCoadd_ref: reference sources (tract, patch) 

76All of these have associated *_schema catalogs that require no data ID and hold no records. 

77 

78In addition, we have a schema-only dataset, which saves the schema for the PeakRecords in 

79the mergeDet, meas, and ref dataset Footprints: 

80* deepCoadd_peak_schema 

81""" 

82 

83 

84############################################################################################################## 

85class DetectCoaddSourcesConnections(PipelineTaskConnections, 

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

87 defaultTemplates={"inputCoaddName": "deep", "outputCoaddName": "deep"}): 

88 detectionSchema = cT.InitOutput( 

89 doc="Schema of the detection catalog", 

90 name="{outputCoaddName}Coadd_det_schema", 

91 storageClass="SourceCatalog", 

92 ) 

93 exposure = cT.Input( 

94 doc="Exposure on which detections are to be performed. ", 

95 name="{inputCoaddName}Coadd", 

96 storageClass="ExposureF", 

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

98 ) 

99 exposure_cells = cT.Input( 

100 doc="Exposure on which detections are to be performed. ", 

101 name="{inputCoaddName}CoaddCell", 

102 storageClass="MultipleCellCoadd", 

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

104 ) 

105 skyMap = cT.Input( 

106 doc="Description of the skymap's tracts and patches.", 

107 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME, 

108 storageClass="SkyMap", 

109 dimensions=("skymap",), 

110 ) 

111 outputBackgrounds = cT.Output( 

112 doc="Output Backgrounds used in detection", 

113 name="{outputCoaddName}Coadd_calexp_background", 

114 storageClass="Background", 

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

116 ) 

117 outputSources = cT.Output( 

118 doc="Detected sources catalog", 

119 name="{outputCoaddName}Coadd_det", 

120 storageClass="SourceCatalog", 

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

122 ) 

123 outputExposure = cT.Output( 

124 doc="Exposure post detection", 

125 name="{outputCoaddName}Coadd_calexp", 

126 storageClass="ExposureF", 

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

128 ) 

129 

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

131 super().__init__(config=config) 

132 assert isinstance(config, DetectCoaddSourcesConfig) 

133 

134 if config.useCellCoadds: 

135 del self.exposure 

136 else: 

137 del self.exposure_cells 

138 

139 if not self.config.forceExactBinning: 

140 del self.skyMap 

141 if self.config.writeOnlyBackgrounds: 

142 del self.outputExposure 

143 del self.outputSources 

144 del self.detectionSchema 

145 

146 

147class DetectCoaddSourcesConfig(PipelineTaskConfig, pipelineConnections=DetectCoaddSourcesConnections): 

148 """Configuration parameters for the DetectCoaddSourcesTask 

149 """ 

150 

151 doScaleVariance = Field(dtype=bool, default=True, doc="Scale variance plane using empirical noise?") 

152 scaleVariance = ConfigurableField(target=ScaleVarianceTask, doc="Variance rescaling") 

153 detection = ConfigurableField(target=DynamicDetectionTask, doc="Source detection") 

154 coaddName = Field(dtype=str, default="deep", doc="Name of coadd") 

155 useCellCoadds = Field(dtype=bool, default=False, doc="Whether to use cell coadds?") 

156 hasFakes = Field( 

157 dtype=bool, 

158 default=False, 

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

160 ) 

161 idGenerator = SkyMapIdGeneratorConfig.make_field() 

162 forceExactBinning = Field( 

163 dtype=bool, 

164 default=False, 

165 doc=( 

166 "Check that the background bin size evenly divides the patch inner region, and " 

167 "crop the outer region to an integer number of bins." 

168 ) 

169 ) 

170 writeOnlyBackgrounds = Field(dtype=bool, default=False, doc="If true, only save the background models.") 

171 writeEmptyBackgrounds = Field( 

172 dtype=bool, 

173 default=True, 

174 doc=( 

175 "If true, save a placeholder background with NaNs in all bins (but the right geometry) when " 

176 "there are no pixels to compute a background from. This can be useful if a later task combines " 

177 "backgrounds from multiple patches as input." 

178 ) 

179 ) 

180 

181 def setDefaults(self): 

182 super().setDefaults() 

183 self.detection.thresholdType = "pixel_stdev" 

184 self.detection.isotropicGrow = True 

185 # Coadds are made from background-subtracted CCDs, so any background subtraction should be very basic 

186 self.detection.reEstimateBackground = False 

187 self.detection.background.useApprox = False 

188 self.detection.background.binSize = 4096 

189 self.detection.background.undersampleStyle = 'REDUCE_INTERP_ORDER' 

190 self.detection.doTempWideBackground = True # Suppress large footprints that overwhelm the deblender 

191 # Include band in packed data IDs that go into object IDs (None -> "as 

192 # many bands as are defined", rather than the default of zero). 

193 self.idGenerator.packer.n_bands = None 

194 

195 

196class DetectCoaddSourcesTask(PipelineTask): 

197 """Detect sources on a single filter coadd. 

198 

199 Coadding individual visits requires each exposure to be warped. This 

200 introduces covariance in the noise properties across pixels. Before 

201 detection, we correct the coadd variance by scaling the variance plane in 

202 the coadd to match the observed variance. This is an approximate 

203 approach -- strictly, we should propagate the full covariance matrix -- 

204 but it is simple and works well in practice. 

205 

206 After scaling the variance plane, we detect sources and generate footprints 

207 by delegating to the @ref SourceDetectionTask_ "detection" subtask. 

208 

209 DetectCoaddSourcesTask is meant to be run after assembling a coadded image 

210 in a given band. The purpose of the task is to update the background, 

211 detect all sources in a single band and generate a set of parent 

212 footprints. Subsequent tasks in the multi-band processing procedure will 

213 merge sources across bands and, eventually, perform forced photometry. 

214 

215 Parameters 

216 ---------- 

217 schema : `lsst.afw.table.Schema`, optional 

218 Initial schema for the output catalog, modified-in place to include all 

219 fields set by this task. If None, the source minimal schema will be used. 

220 **kwargs 

221 Additional keyword arguments. 

222 """ 

223 

224 _DefaultName = "detectCoaddSources" 

225 ConfigClass = DetectCoaddSourcesConfig 

226 

227 def __init__(self, schema=None, **kwargs): 

228 # N.B. Super is used here to handle the multiple inheritance of PipelineTasks, the init tree 

229 # call structure has been reviewed carefully to be sure super will work as intended. 

230 super().__init__(**kwargs) 

231 if schema is None: 

232 schema = afwTable.SourceTable.makeMinimalSchema() 

233 self.schema = schema 

234 self.makeSubtask("detection", schema=self.schema) 

235 if self.config.doScaleVariance: 

236 self.makeSubtask("scaleVariance") 

237 

238 self.detectionSchema = afwTable.SourceCatalog(self.schema) 

239 

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

241 inputs = butlerQC.get(inputRefs) 

242 idGenerator = self.config.idGenerator.apply(butlerQC.quantum.dataId) 

243 

244 if self.config.useCellCoadds: 

245 multiple_cell_coadd = inputs.pop("exposure_cells") 

246 exposure = multiple_cell_coadd.stitch().asExposure() 

247 else: 

248 exposure = inputs.pop("exposure") 

249 

250 skyMap = inputs.pop("skyMap", None) 

251 if skyMap is not None: 

252 patchInfo = skyMap[butlerQC.quantum.dataId["tract"]][butlerQC.quantum.dataId["patch"]] 

253 else: 

254 patchInfo = None 

255 

256 assert not inputs, "runQuantum got more inputs than expected." 

257 try: 

258 outputs = self.run( 

259 exposure=exposure, 

260 idFactory=idGenerator.make_table_id_factory(), 

261 expId=idGenerator.catalog_id, 

262 patchInfo=patchInfo, 

263 ) 

264 except ( 

265 TooManyMaskedPixelsError, 

266 ExceedsMaxVarianceScaleError, 

267 InsufficientSourcesError, 

268 PsfGenerationError, 

269 ZeroFootprintError, 

270 ) as e: 

271 if self.config.writeEmptyBackgrounds: 

272 butlerQC.put(self._makeEmptyBackground(exposure, patchInfo), outputRefs.outputBackgrounds) 

273 # Detection failed, so clear any leftover the detected mask planes. 

274 for maskName in ["DETECTED", "DETECTED_NEGATIVE"]: 

275 if maskName in exposure.mask.getMaskPlaneDict().keys(): 

276 detectedMask = exposure.mask.getMaskPlane(maskName) 

277 exposure.mask.clearMaskPlane(detectedMask) 

278 butlerQC.put(exposure, outputRefs.outputExposure) 

279 error = AnnotatedPartialOutputsError.annotate( 

280 e, 

281 self, 

282 exposure, 

283 log=self.log, 

284 ) 

285 raise error from e 

286 

287 butlerQC.put(outputs, outputRefs) 

288 

289 def run(self, exposure, idFactory, expId, patchInfo=None): 

290 """Run detection on an exposure. 

291 

292 First scale the variance plane to match the observed variance 

293 using ``ScaleVarianceTask``. Then invoke the ``SourceDetectionTask_`` "detection" subtask to 

294 detect sources. 

295 

296 Parameters 

297 ---------- 

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

299 Exposure on which to detect (may be background-subtracted and scaled, 

300 depending on configuration). 

301 idFactory : `lsst.afw.table.IdFactory` 

302 IdFactory to set source identifiers. 

303 expId : `int` 

304 Exposure identifier (integer) for RNG seed. 

305 patchInfo : `lsst.skymap.PatchInfo`, optional 

306 Description of the patch geometry. Only needed if 

307 `~DetectCoaddSourceConfig.forceExactBinning` is `True`. 

308 

309 Returns 

310 ------- 

311 result : `lsst.pipe.base.Struct` 

312 Results as a struct with attributes: 

313 

314 ``sources`` 

315 Catalog of detections (`lsst.afw.table.SourceCatalog`). 

316 ``backgrounds`` 

317 List of backgrounds (`list`). 

318 """ 

319 if self.config.forceExactBinning: 

320 exposure = self._cropToExactBinning(exposure, patchInfo) 

321 if self.config.doScaleVariance: 

322 varScale = self.scaleVariance.run(exposure.maskedImage) 

323 exposure.getMetadata().add("VARIANCE_SCALE", varScale) 

324 backgrounds = afwMath.BackgroundList() 

325 table = afwTable.SourceTable.make(self.schema, idFactory) 

326 detections = self.detection.run(table, exposure, expId=expId) 

327 sources = detections.sources 

328 if hasattr(detections, "background") and detections.background: 

329 for bg in detections.background: 

330 backgrounds.append(bg) 

331 if len(backgrounds) == 0: 

332 # Persist a constant background with value of NaN to get around 

333 # inability to persist empty BackgroundList. 

334 emptyBg = self._makeEmptyBackground(exposure, patchInfo) 

335 backgrounds.append(emptyBg) 

336 

337 return Struct(outputSources=sources, outputBackgrounds=backgrounds, outputExposure=exposure) 

338 

339 def _cropToExactBinning(self, exposure, patchInfo): 

340 """Crop a coadd `~lsst.afw.image.Exposure` instance to ensure exact 

341 background binning. 

342 

343 Parameters 

344 ---------- 

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

346 Exposure to crop, assumed to cover the patch outer bounding box. 

347 patchInfo : `lsst.skymap.PatchInfo` 

348 Description of the patch geometry. 

349 

350 Returns 

351 ------- 

352 cropped : `lsst.afw.image.Exposure` 

353 View of ``exposure`` with background bins that evenly divide both 

354 the full cropped image and the patch inner region. The bounding 

355 box is guaranteed to contain the patch inner bounding box and be 

356 contained by the patch outer bounding box. 

357 

358 Raises 

359 ------ 

360 ValueError 

361 Raised if the patch inner region width or height is not a multiple 

362 of the background bin size. 

363 """ 

364 bbox = patchInfo.getInnerBBox() 

365 if bbox.width % self.detection.background.binSizeX: 

366 raise ValueError( 

367 f"Patch inner width {bbox.width} does not evenly " 

368 f"divide bin width {self.detection.background.binSizeX}." 

369 ) 

370 if bbox.height % self.detection.background.binSizeY: 

371 raise ValueError( 

372 f"Patch inner height {bbox.height} does not evenly " 

373 f"divide bin height {self.detection.background.binSizeY}." 

374 ) 

375 outer_bbox = patchInfo.getOuterBBox() 

376 n_bins_grow_x = (bbox.x.begin - outer_bbox.x.begin) // self.detection.background.binSizeX 

377 n_bins_grow_y = (bbox.y.begin - outer_bbox.y.begin) // self.detection.background.binSizeY 

378 bbox.grow( 

379 Extent2I( 

380 n_bins_grow_x*self.detection.background.binSizeX, 

381 n_bins_grow_y*self.detection.background.binSizeY, 

382 ) 

383 ) 

384 assert outer_bbox.contains(bbox) 

385 assert bbox.contains(patchInfo.getInnerBBox()) 

386 assert bbox.width % self.detection.background.binSizeX == 0 

387 assert bbox.height % self.detection.background.binSizeY == 0 

388 return exposure[bbox] 

389 

390 def _makeEmptyBackground(self, exposure, patchInfo=None): 

391 """Construct an empty `lsst.afw.math.BackgroundList` with NaN values. 

392 

393 Parameters 

394 ---------- 

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

396 Exposure that the background should correspond to. 

397 patchInfo : `lsst.skymap.PatchInfo`, optional 

398 Description of the patch geometry. Only needed if 

399 `~DetectCoaddSourceConfig.forceExactBinning` is `True`. 

400 

401 Returns 

402 ------- 

403 background : `lsst.afw.math.BackgroundList` 

404 A background object with a single layer and the same bin geometry 

405 that a background for that exposure would have had if it had enough 

406 usable pixels. This object cannot actually be used for background 

407 subtraction. 

408 """ 

409 # Create a backgroundList with one entry whose "stats image" is NaNs 

410 # and has all pixels set as NO_DATA. 

411 if self.config.forceExactBinning: 

412 exposure = self._cropToExactBinning(exposure, patchInfo).clone() 

413 

414 bgLevel = np.nan 

415 bgStats = afwImage.MaskedImageF(1, 1) 

416 bgStats.set(bgLevel, 0, bgLevel) 

417 bg = afwMath.BackgroundMI(exposure.getBBox(), bgStats) 

418 bgData = (bg, afwMath.Interpolate.LINEAR, afwMath.REDUCE_INTERP_ORDER, 

419 afwMath.ApproximateControl.UNKNOWN, 0, 0, False) 

420 background = afwMath.BackgroundList() 

421 background.append(bgData) 

422 for bg, *_ in background: 

423 stats = bg.getStatsImage() 

424 stats.mask.array[:, :] = stats.mask.getPlaneBitMask("NO_DATA") 

425 stats.variance.array[:, :] = 0.0 

426 return background 

427 

428 

429class MeasureMergedCoaddSourcesConnections( 

430 PipelineTaskConnections, 

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

432 defaultTemplates={ 

433 "inputCoaddName": "deep", 

434 "outputCoaddName": "deep", 

435 }, 

436): 

437 inputSchema = cT.InitInput( 

438 doc="Input schema for measure merged task produced by a deblender or detection task", 

439 name="{inputCoaddName}Coadd_deblendedFlux_schema", 

440 storageClass="SourceCatalog" 

441 ) 

442 outputSchema = cT.InitOutput( 

443 doc="Output schema after all new fields are added by task", 

444 name="{inputCoaddName}Coadd_meas_schema", 

445 storageClass="SourceCatalog" 

446 ) 

447 exposure = cT.Input( 

448 doc="Input non-cell-based coadd image", 

449 name="{inputCoaddName}Coadd_calexp", 

450 storageClass="ExposureF", 

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

452 ) 

453 exposure_cells = cT.Input( 

454 doc="Input cell-based coadd image", 

455 name="{inputCoaddName}CoaddCell", 

456 storageClass="MultipleCellCoadd", 

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

458 ) 

459 background = cT.Input( 

460 doc="Background to subtract from cell-based coadd image", 

461 name="{inputCoaddName}Coadd_calexp_background", 

462 storageClass="Background", 

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

464 ) 

465 skyMap = cT.Input( 

466 doc="SkyMap to use in processing", 

467 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME, 

468 storageClass="SkyMap", 

469 dimensions=("skymap",), 

470 ) 

471 sourceTableHandles = cT.Input( 

472 doc=("Source tables that are derived from the ``CalibrateTask`` sources. " 

473 "These tables contain astrometry and photometry flags, and optionally " 

474 "PSF flags."), 

475 name="sourceTable_visit", 

476 storageClass="ArrowAstropy", 

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

478 multiple=True, 

479 deferLoad=True, 

480 ) 

481 finalizedSourceTableHandles = cT.Input( 

482 doc=("Finalized source tables from ``FinalizeCalibrationTask``. These " 

483 "tables contain PSF flags from the finalized PSF estimation."), 

484 name="finalized_src_table", 

485 storageClass="ArrowAstropy", 

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

487 multiple=True, 

488 deferLoad=True, 

489 ) 

490 finalVisitSummaryHandles = cT.Input( 

491 doc="Final visit summary table", 

492 name="finalVisitSummary", 

493 storageClass="ExposureCatalog", 

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

495 multiple=True, 

496 deferLoad=True, 

497 ) 

498 scarletCatalog = cT.Input( 

499 doc="Catalogs produced by multiband deblending", 

500 name="{inputCoaddName}Coadd_deblendedCatalog", 

501 storageClass="SourceCatalog", 

502 dimensions=("tract", "patch", "skymap"), 

503 ) 

504 scarletModels = cT.Input( 

505 doc="Multiband scarlet models produced by the deblender", 

506 name="{inputCoaddName}Coadd_scarletModelData", 

507 storageClass="LsstScarletModelData", 

508 dimensions=("tract", "patch", "skymap"), 

509 ) 

510 outputSources = cT.Output( 

511 doc="Source catalog containing all the measurement information generated in this task", 

512 name="{outputCoaddName}Coadd_meas", 

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

514 storageClass="SourceCatalog", 

515 ) 

516 

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

518 super().__init__(config=config) 

519 if not config.doPropagateFlags: 

520 del self.sourceTableHandles 

521 del self.finalizedSourceTableHandles 

522 del self.finalVisitSummaryHandles 

523 else: 

524 # Check for types of flags required. 

525 if not config.propagateFlags.source_flags: 

526 del self.sourceTableHandles 

527 if not config.propagateFlags.finalized_source_flags: 

528 del self.finalizedSourceTableHandles 

529 if not config.doAddFootprints: 

530 del self.scarletModels 

531 

532 if config.useCellCoadds: 

533 del self.exposure 

534 else: 

535 del self.exposure_cells 

536 del self.background 

537 

538 

539class MeasureMergedCoaddSourcesConfig(PipelineTaskConfig, 

540 pipelineConnections=MeasureMergedCoaddSourcesConnections): 

541 """Configuration parameters for the MeasureMergedCoaddSourcesTask 

542 """ 

543 doAddFootprints = Field(dtype=bool, 

544 default=True, 

545 doc="Whether or not to add footprints to the input catalog from scarlet models. " 

546 "This should be true whenever using the multi-band deblender, " 

547 "otherwise this should be False.") 

548 doConserveFlux = Field(dtype=bool, default=True, 

549 doc="Whether to use the deblender models as templates to re-distribute the flux " 

550 "from the 'exposure' (True), or to perform measurements on the deblender " 

551 "model footprints.") 

552 doStripFootprints = Field(dtype=bool, default=True, 

553 doc="Whether to strip footprints from the output catalog before " 

554 "saving to disk. " 

555 "This is usually done when using scarlet models to save disk space.") 

556 useCellCoadds = Field(dtype=bool, default=False, doc="Whether to use cell coadds?") 

557 measurement = ConfigurableField(target=SingleFrameMeasurementTask, doc="Source measurement") 

558 setPrimaryFlags = ConfigurableField(target=SetPrimaryFlagsTask, doc="Set flags for primary tract/patch") 

559 doPropagateFlags = Field( 

560 dtype=bool, default=True, 

561 doc="Whether to match sources to CCD catalogs to propagate flags (to e.g. identify PSF stars)" 

562 ) 

563 propagateFlags = ConfigurableField(target=PropagateSourceFlagsTask, doc="Propagate source flags to coadd") 

564 coaddName = Field(dtype=str, default="deep", doc="Name of coadd") 

565 psfCache = Field(dtype=int, default=100, doc="Size of psfCache") 

566 checkUnitsParseStrict = Field( 

567 doc="Strictness of Astropy unit compatibility check, can be 'raise', 'warn' or 'silent'", 

568 dtype=str, 

569 default="raise", 

570 ) 

571 doApCorr = Field( 

572 dtype=bool, 

573 default=True, 

574 doc="Apply aperture corrections" 

575 ) 

576 applyApCorr = ConfigurableField( 

577 target=ApplyApCorrTask, 

578 doc="Subtask to apply aperture corrections" 

579 ) 

580 doRunCatalogCalculation = Field( 

581 dtype=bool, 

582 default=True, 

583 doc='Run catalogCalculation task' 

584 ) 

585 catalogCalculation = ConfigurableField( 

586 target=CatalogCalculationTask, 

587 doc="Subtask to run catalogCalculation plugins on catalog" 

588 ) 

589 

590 hasFakes = Field( 

591 dtype=bool, 

592 default=False, 

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

594 ) 

595 idGenerator = SkyMapIdGeneratorConfig.make_field() 

596 

597 def setDefaults(self): 

598 super().setDefaults() 

599 self.measurement.plugins.names |= ['base_InputCount', 

600 'base_Variance', 

601 'base_LocalPhotoCalib', 

602 'base_LocalWcs'] 

603 

604 # TODO: Remove STREAK in DM-44658, streak masking to happen only in 

605 # ip_diffim; if we can propagate the streak mask from diffim, we can 

606 # still set flags with it here. 

607 self.measurement.plugins['base_PixelFlags'].masksFpAnywhere = ['CLIPPED', 'SENSOR_EDGE', 

608 'INEXACT_PSF'] 

609 self.measurement.plugins['base_PixelFlags'].masksFpCenter = ['CLIPPED', 'SENSOR_EDGE', 

610 'INEXACT_PSF'] 

611 

612 

613class MeasureMergedCoaddSourcesTask(PipelineTask): 

614 """Deblend sources from main catalog in each coadd seperately and measure. 

615 

616 Use peaks and footprints from a master catalog to perform deblending and 

617 measurement in each coadd. 

618 

619 Given a master input catalog of sources (peaks and footprints) or deblender 

620 outputs(including a HeavyFootprint in each band), measure each source on 

621 the coadd. Repeating this procedure with the same master catalog across 

622 multiple coadds will generate a consistent set of child sources. 

623 

624 The deblender retains all peaks and deblends any missing peaks (dropouts in 

625 that band) as PSFs. Source properties are measured and the @c is-primary 

626 flag (indicating sources with no children) is set. Visit flags are 

627 propagated to the coadd sources. 

628 

629 After MeasureMergedCoaddSourcesTask has been run on multiple coadds, we 

630 have a set of per-band catalogs. The next stage in the multi-band 

631 processing procedure will merge these measurements into a suitable catalog 

632 for driving forced photometry. 

633 

634 Parameters 

635 ---------- 

636 schema : ``lsst.afw.table.Schema`, optional 

637 The schema of the merged detection catalog used as input to this one. 

638 peakSchema : ``lsst.afw.table.Schema`, optional 

639 The schema of the PeakRecords in the Footprints in the merged detection catalog. 

640 initInputs : `dict`, optional 

641 Dictionary that can contain a key ``inputSchema`` containing the 

642 input schema. If present will override the value of ``schema``. 

643 **kwargs 

644 Additional keyword arguments. 

645 """ 

646 

647 _DefaultName = "measureCoaddSources" 

648 ConfigClass = MeasureMergedCoaddSourcesConfig 

649 

650 def __init__(self, schema=None, peakSchema=None, initInputs=None, **kwargs): 

651 super().__init__(**kwargs) 

652 if initInputs is not None: 

653 schema = initInputs['inputSchema'].schema 

654 if schema is None: 

655 raise ValueError("Schema must be defined.") 

656 self.schemaMapper = afwTable.SchemaMapper(schema) 

657 self.schemaMapper.addMinimalSchema(schema) 

658 self.schema = self.schemaMapper.getOutputSchema() 

659 self.algMetadata = PropertyList() 

660 self.makeSubtask("measurement", schema=self.schema, algMetadata=self.algMetadata) 

661 self.makeSubtask("setPrimaryFlags", schema=self.schema) 

662 if self.config.doPropagateFlags: 

663 self.makeSubtask("propagateFlags", schema=self.schema) 

664 self.schema.checkUnits(parse_strict=self.config.checkUnitsParseStrict) 

665 if self.config.doApCorr: 

666 self.makeSubtask("applyApCorr", schema=self.schema) 

667 if self.config.doRunCatalogCalculation: 

668 self.makeSubtask("catalogCalculation", schema=self.schema) 

669 

670 self.outputSchema = afwTable.SourceCatalog(self.schema) 

671 

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

673 inputs = butlerQC.get(inputRefs) 

674 

675 if self.config.useCellCoadds: 

676 multiple_cell_coadd = inputs.pop("exposure_cells") 

677 stitched_coadd = multiple_cell_coadd.stitch() 

678 exposure = stitched_coadd.asExposure() 

679 background = inputs.pop("background") 

680 exposure.image -= background.getImage() 

681 

682 ccdInputs = stitched_coadd.ccds 

683 apCorrMap = stitched_coadd.ap_corr_map 

684 band = inputRefs.exposure_cells.dataId["band"] 

685 else: 

686 exposure = inputs.pop("exposure") 

687 # Set psfcache 

688 # move this to run after gen2 deprecation 

689 exposure.getPsf().setCacheCapacity(self.config.psfCache) 

690 

691 ccdInputs = exposure.getInfo().getCoaddInputs().ccds 

692 apCorrMap = exposure.getInfo().getApCorrMap() 

693 band = inputRefs.exposure.dataId["band"] 

694 

695 # Get unique integer ID for IdFactory and RNG seeds; only the latter 

696 # should really be used as the IDs all come from the input catalog. 

697 idGenerator = self.config.idGenerator.apply(butlerQC.quantum.dataId) 

698 

699 # Transform inputCatalog 

700 table = afwTable.SourceTable.make(self.schema, idGenerator.make_table_id_factory()) 

701 sources = afwTable.SourceCatalog(table) 

702 # Load the correct input catalog 

703 inputCatalog = inputs.pop("scarletCatalog") 

704 catalogRef = inputRefs.scarletCatalog 

705 sources.extend(inputCatalog, self.schemaMapper) 

706 del inputCatalog 

707 # Add the HeavyFootprints to the deblended sources 

708 if self.config.doAddFootprints: 

709 modelData = inputs.pop('scarletModels') 

710 if self.config.doConserveFlux: 

711 imageForRedistribution = exposure 

712 else: 

713 imageForRedistribution = None 

714 updateCatalogFootprints( 

715 modelData=modelData, 

716 catalog=sources, 

717 band=band, 

718 imageForRedistribution=imageForRedistribution, 

719 removeScarletData=True, 

720 updateFluxColumns=True, 

721 ) 

722 table = sources.getTable() 

723 table.setMetadata(self.algMetadata) # Capture algorithm metadata to write out to the source catalog. 

724 

725 skyMap = inputs.pop('skyMap') 

726 tractNumber = catalogRef.dataId['tract'] 

727 tractInfo = skyMap[tractNumber] 

728 patchInfo = tractInfo.getPatchInfo(catalogRef.dataId['patch']) 

729 skyInfo = Struct( 

730 skyMap=skyMap, 

731 tractInfo=tractInfo, 

732 patchInfo=patchInfo, 

733 wcs=tractInfo.getWcs(), 

734 bbox=patchInfo.getOuterBBox() 

735 ) 

736 

737 sourceTableHandleDict = None 

738 finalizedSourceTableHandleDict = None 

739 finalVisitSummaryHandleDict = None 

740 if self.config.doPropagateFlags: 

741 if "sourceTableHandles" in inputs: 

742 sourceTableHandles = inputs.pop("sourceTableHandles") 

743 sourceTableHandleDict = {handle.dataId["visit"]: handle for handle in sourceTableHandles} 

744 if "finalizedSourceTableHandles" in inputs: 

745 finalizedSourceTableHandles = inputs.pop("finalizedSourceTableHandles") 

746 finalizedSourceTableHandleDict = {handle.dataId["visit"]: handle 

747 for handle in finalizedSourceTableHandles} 

748 if "finalVisitSummaryHandles" in inputs: 

749 finalVisitSummaryHandles = inputs.pop("finalVisitSummaryHandles") 

750 finalVisitSummaryHandleDict = {handle.dataId["visit"]: handle 

751 for handle in finalVisitSummaryHandles} 

752 

753 assert not inputs, "runQuantum got more inputs than expected." 

754 outputs = self.run( 

755 exposure=exposure, 

756 sources=sources, 

757 skyInfo=skyInfo, 

758 exposureId=idGenerator.catalog_id, 

759 ccdInputs=ccdInputs, 

760 sourceTableHandleDict=sourceTableHandleDict, 

761 finalizedSourceTableHandleDict=finalizedSourceTableHandleDict, 

762 finalVisitSummaryHandleDict=finalVisitSummaryHandleDict, 

763 apCorrMap=apCorrMap, 

764 ) 

765 # Strip HeavyFootprints to save space on disk 

766 if self.config.doStripFootprints: 

767 sources = outputs.outputSources 

768 for source in sources[sources["parent"] != 0]: 

769 source.setFootprint(None) 

770 butlerQC.put(outputs, outputRefs) 

771 

772 def run(self, exposure, sources, skyInfo, exposureId, ccdInputs=None, 

773 sourceTableHandleDict=None, finalizedSourceTableHandleDict=None, finalVisitSummaryHandleDict=None, 

774 apCorrMap=None): 

775 """Run measurement algorithms on the input exposure, and optionally populate the 

776 resulting catalog with extra information. 

777 

778 Parameters 

779 ---------- 

780 exposure : `lsst.afw.exposure.Exposure` 

781 The input exposure on which measurements are to be performed. 

782 sources : `lsst.afw.table.SourceCatalog` 

783 A catalog built from the results of merged detections, or 

784 deblender outputs. 

785 parentCatalog : `lsst.afw.table.SourceCatalog` 

786 Catalog of parent sources corresponding to sources. 

787 skyInfo : `lsst.pipe.base.Struct` 

788 A struct containing information about the position of the input exposure within 

789 a `SkyMap`, the `SkyMap`, its `Wcs`, and its bounding box. 

790 exposureId : `int` or `bytes` 

791 Packed unique number or bytes unique to the input exposure. 

792 ccdInputs : `lsst.afw.table.ExposureCatalog`, optional 

793 Catalog containing information on the individual visits which went into making 

794 the coadd. 

795 sourceTableHandleDict : `dict` [`int`, `lsst.daf.butler.DeferredDatasetHandle`], optional 

796 Dict for sourceTable_visit handles (key is visit) for propagating flags. 

797 These tables contain astrometry and photometry flags, and optionally PSF flags. 

798 finalizedSourceTableHandleDict : `dict` [`int`, `lsst.daf.butler.DeferredDatasetHandle`], optional 

799 Dict for finalized_src_table handles (key is visit) for propagating flags. 

800 These tables contain PSF flags from the finalized PSF estimation. 

801 finalVisitSummaryHandleDict : `dict` [`int`, `lsst.daf.butler.DeferredDatasetHandle`], optional 

802 Dict for visit_summary handles (key is visit) for visit-level information. 

803 These tables contain the WCS information of the single-visit input images. 

804 apCorrMap : `lsst.afw.image.ApCorrMap`, optional 

805 Aperture correction map attached to the ``exposure``. If None, it 

806 will be read from the ``exposure``. 

807 

808 Returns 

809 ------- 

810 results : `lsst.pipe.base.Struct` 

811 Results of running measurement task. Will contain the catalog in the 

812 sources attribute. 

813 """ 

814 if self.config.doPropagateFlags: 

815 # These mask planes may not be defined on the coadds always. 

816 # We add the mask planes, which is a no-op if already defined. 

817 for maskPlane in self.config.measurement.plugins["base_PixelFlags"].masksFpAnywhere: 

818 exposure.mask.addMaskPlane(maskPlane) 

819 for maskPlane in self.config.measurement.plugins["base_PixelFlags"].masksFpCenter: 

820 exposure.mask.addMaskPlane(maskPlane) 

821 

822 self.measurement.run(sources, exposure, exposureId=exposureId) 

823 

824 if self.config.doApCorr: 

825 if apCorrMap is None: 

826 apCorrMap = exposure.getInfo().getApCorrMap() 

827 self.applyApCorr.run( 

828 catalog=sources, 

829 apCorrMap=apCorrMap, 

830 ) 

831 

832 # TODO DM-11568: this contiguous check-and-copy could go away if we 

833 # reserve enough space during SourceDetection and/or SourceDeblend. 

834 # NOTE: sourceSelectors require contiguous catalogs, so ensure 

835 # contiguity now, so views are preserved from here on. 

836 if not sources.isContiguous(): 

837 sources = sources.copy(deep=True) 

838 

839 if self.config.doRunCatalogCalculation: 

840 self.catalogCalculation.run(sources) 

841 

842 self.setPrimaryFlags.run(sources, skyMap=skyInfo.skyMap, tractInfo=skyInfo.tractInfo, 

843 patchInfo=skyInfo.patchInfo) 

844 if self.config.doPropagateFlags: 

845 self.propagateFlags.run( 

846 sources, 

847 ccdInputs, 

848 sourceTableHandleDict, 

849 finalizedSourceTableHandleDict, 

850 finalVisitSummaryHandleDict, 

851 ) 

852 

853 results = Struct() 

854 results.outputSources = sources 

855 return results