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

337 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-10-11 02:05 -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 

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, ChoiceField 

38from lsst.meas.algorithms import DynamicDetectionTask, ReferenceObjectLoader, ScaleVarianceTask, \ 

39 SetPrimaryFlagsTask 

40from lsst.meas.algorithms.subtractBackground import TooManyMaskedPixelsError 

41from lsst.meas.base import ( 

42 SingleFrameMeasurementTask, 

43 ApplyApCorrTask, 

44 CatalogCalculationTask, 

45 SkyMapIdGeneratorConfig, 

46) 

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

48from lsst.meas.astrom import DirectMatchTask, denormalizeMatches 

49from lsst.pipe.tasks.propagateSourceFlags import PropagateSourceFlagsTask 

50import lsst.afw.table as afwTable 

51import lsst.afw.math as afwMath 

52from lsst.daf.base import PropertyList 

53from lsst.skymap import BaseSkyMap 

54 

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

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

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

58from .multiBandUtils import CullPeaksConfig # noqa: F401 

59from .deblendCoaddSourcesPipeline import DeblendCoaddSourcesMultiConfig # noqa: F401 

60from .deblendCoaddSourcesPipeline import DeblendCoaddSourcesMultiTask # noqa: F401 

61 

62 

63""" 

64New set types: 

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

66* deepCoadd_mergeDet: merged detections (tract, patch) 

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

68* deepCoadd_ref: reference sources (tract, patch) 

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

70 

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

72the mergeDet, meas, and ref dataset Footprints: 

73* deepCoadd_peak_schema 

74""" 

75 

76 

77############################################################################################################## 

78class DetectCoaddSourcesConnections(PipelineTaskConnections, 

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

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

81 detectionSchema = cT.InitOutput( 

82 doc="Schema of the detection catalog", 

83 name="{outputCoaddName}Coadd_det_schema", 

84 storageClass="SourceCatalog", 

85 ) 

86 exposure = cT.Input( 

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

88 name="{inputCoaddName}Coadd", 

89 storageClass="ExposureF", 

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

91 ) 

92 exposure_cells = cT.Input( 

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

94 name="{inputCoaddName}CoaddCell", 

95 storageClass="MultipleCellCoadd", 

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

97 ) 

98 skyMap = cT.Input( 

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

100 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME, 

101 storageClass="SkyMap", 

102 dimensions=("skymap",), 

103 ) 

104 outputBackgrounds = cT.Output( 

105 doc="Output Backgrounds used in detection", 

106 name="{outputCoaddName}Coadd_calexp_background", 

107 storageClass="Background", 

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

109 ) 

110 outputSources = cT.Output( 

111 doc="Detected sources catalog", 

112 name="{outputCoaddName}Coadd_det", 

113 storageClass="SourceCatalog", 

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

115 ) 

116 outputExposure = cT.Output( 

117 doc="Exposure post detection", 

118 name="{outputCoaddName}Coadd_calexp", 

119 storageClass="ExposureF", 

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

121 ) 

122 

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

124 super().__init__(config=config) 

125 assert isinstance(config, DetectCoaddSourcesConfig) 

126 

127 if config.useCellCoadds: 

128 del self.exposure 

129 else: 

130 del self.exposure_cells 

131 

132 if not self.config.forceExactBinning: 

133 del self.skyMap 

134 if self.config.writeOnlyBackgrounds: 

135 del self.outputExposure 

136 del self.outputSources 

137 del self.detectionSchema 

138 

139 

140class DetectCoaddSourcesConfig(PipelineTaskConfig, pipelineConnections=DetectCoaddSourcesConnections): 

141 """Configuration parameters for the DetectCoaddSourcesTask 

142 """ 

143 

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

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

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

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

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

149 hasFakes = Field( 

150 dtype=bool, 

151 default=False, 

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

153 ) 

154 idGenerator = SkyMapIdGeneratorConfig.make_field() 

155 forceExactBinning = Field( 

156 dtype=bool, 

157 default=False, 

158 doc=( 

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

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

161 ) 

162 ) 

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

164 writeEmptyBackgrounds = Field( 

165 dtype=bool, 

166 default=False, 

167 doc=( 

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

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

170 "backgrounds from multiple patches as input." 

171 ) 

172 ) 

173 

174 def setDefaults(self): 

175 super().setDefaults() 

176 self.detection.thresholdType = "pixel_stdev" 

177 self.detection.isotropicGrow = True 

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

179 self.detection.reEstimateBackground = False 

180 self.detection.background.useApprox = False 

181 self.detection.background.binSize = 4096 

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

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

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

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

186 self.idGenerator.packer.n_bands = None 

187 

188 

189class DetectCoaddSourcesTask(PipelineTask): 

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

191 

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

193 introduces covariance in the noise properties across pixels. Before 

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

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

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

197 but it is simple and works well in practice. 

198 

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

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

201 

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

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

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

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

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

207 

208 Parameters 

209 ---------- 

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

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

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

213 **kwargs 

214 Additional keyword arguments. 

215 """ 

216 

217 _DefaultName = "detectCoaddSources" 

218 ConfigClass = DetectCoaddSourcesConfig 

219 

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

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

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

223 super().__init__(**kwargs) 

224 if schema is None: 

225 schema = afwTable.SourceTable.makeMinimalSchema() 

226 self.schema = schema 

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

228 if self.config.doScaleVariance: 

229 self.makeSubtask("scaleVariance") 

230 

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

232 

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

234 inputs = butlerQC.get(inputRefs) 

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

236 

237 if self.config.useCellCoadds: 

238 multiple_cell_coadd = inputs.pop("exposure_cells") 

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

240 else: 

241 exposure = inputs.pop("exposure") 

242 

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

244 if skyMap is not None: 

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

246 else: 

247 patchInfo = None 

248 

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

250 try: 

251 outputs = self.run( 

252 exposure=exposure, 

253 idFactory=idGenerator.make_table_id_factory(), 

254 expId=idGenerator.catalog_id, 

255 patchInfo=patchInfo, 

256 ) 

257 except TooManyMaskedPixelsError as e: 

258 if self.config.writeEmptyBackgrounds: 

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

260 error = AnnotatedPartialOutputsError.annotate( 

261 e, 

262 self, 

263 exposure, 

264 log=self.log, 

265 ) 

266 raise error from e 

267 

268 butlerQC.put(outputs, outputRefs) 

269 

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

271 """Run detection on an exposure. 

272 

273 First scale the variance plane to match the observed variance 

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

275 detect sources. 

276 

277 Parameters 

278 ---------- 

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

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

281 depending on configuration). 

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

283 IdFactory to set source identifiers. 

284 expId : `int` 

285 Exposure identifier (integer) for RNG seed. 

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

287 Description of the patch geometry. Only needed if 

288 `~DetectCoaddSourceConfig.forceExactBinning` is `True`. 

289 

290 Returns 

291 ------- 

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

293 Results as a struct with attributes: 

294 

295 ``sources`` 

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

297 ``backgrounds`` 

298 List of backgrounds (`list`). 

299 """ 

300 if self.config.forceExactBinning: 

301 exposure = self._cropToExactBinning(exposure, patchInfo) 

302 if self.config.doScaleVariance: 

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

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

305 backgrounds = afwMath.BackgroundList() 

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

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

308 sources = detections.sources 

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

310 for bg in detections.background: 

311 backgrounds.append(bg) 

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

313 

314 def _cropToExactBinning(self, exposure, patchInfo): 

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

316 background binning. 

317 

318 Parameters 

319 ---------- 

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

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

322 patchInfo : `lsst.skymap.PatchInfo` 

323 Description of the patch geometry. 

324 

325 Returns 

326 ------- 

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

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

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

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

331 contained by the patch outer bounding box. 

332 

333 Raises 

334 ------ 

335 ValueError 

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

337 of the background bin size. 

338 """ 

339 bbox = patchInfo.getInnerBBox() 

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

341 raise ValueError( 

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

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

344 ) 

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

346 raise ValueError( 

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

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

349 ) 

350 outer_bbox = patchInfo.getOuterBBox() 

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

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

353 bbox.grow( 

354 Extent2I( 

355 n_bins_grow_x*self.detection.background.binSizeX, 

356 n_bins_grow_y*self.detection.background.binSizeY, 

357 ) 

358 ) 

359 assert outer_bbox.contains(bbox) 

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

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

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

363 return exposure[bbox] 

364 

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

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

367 

368 Parameters 

369 ---------- 

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

371 Exposure that the background should correspond to. 

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

373 Description of the patch geometry. Only needed if 

374 `~DetectCoaddSourceConfig.forceExactBinning` is `True`. 

375 

376 Returns 

377 ------- 

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

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

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

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

382 subtraction. 

383 """ 

384 # BackgroundList objects are a huge pain to construct without actually 

385 # measuring the background, so that's what we do - on an image with 

386 # 0s everywhere and no masks. And then we replace the background 

387 # "stats image" with NaNs and NO_DATA. 

388 if self.config.forceExactBinning: 

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

390 exposure.image.array[:, :] = 0.0 

391 exposure.mask[:, :] = 0 

392 background = self.detection.background.run(exposure).background 

393 for bg, *_ in background: 

394 stats = bg.getStatsImage() 

395 stats.image.array[:, :] = np.nan 

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

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

398 return background 

399 

400 

401class MeasureMergedCoaddSourcesConnections( 

402 PipelineTaskConnections, 

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

404 defaultTemplates={ 

405 "inputCoaddName": "deep", 

406 "outputCoaddName": "deep", 

407 "deblendedCatalog": "deblendedFlux", 

408 }, 

409 deprecatedTemplates={ 

410 # TODO[DM-47797]: remove this deprecated connection template. 

411 "deblendedCatalog": "Support for old deblender outputs will be removed after v29." 

412 }, 

413): 

414 inputSchema = cT.InitInput( 

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

416 name="{inputCoaddName}Coadd_deblendedFlux_schema", 

417 storageClass="SourceCatalog" 

418 ) 

419 outputSchema = cT.InitOutput( 

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

421 name="{inputCoaddName}Coadd_meas_schema", 

422 storageClass="SourceCatalog" 

423 ) 

424 # TODO[DM-47797]: remove this deprecated connection. 

425 refCat = cT.PrerequisiteInput( 

426 doc="Reference catalog used to match measured sources against known sources", 

427 name="ref_cat", 

428 storageClass="SimpleCatalog", 

429 dimensions=("skypix",), 

430 deferLoad=True, 

431 multiple=True, 

432 deprecated="Reference matching in measureCoaddSources will be removed after v29.", 

433 ) 

434 exposure = cT.Input( 

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

436 name="{inputCoaddName}Coadd_calexp", 

437 storageClass="ExposureF", 

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

439 ) 

440 exposure_cells = cT.Input( 

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

442 name="{inputCoaddName}CoaddCell", 

443 storageClass="MultipleCellCoadd", 

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

445 ) 

446 background = cT.Input( 

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

448 name="{inputCoaddName}Coadd_calexp_background", 

449 storageClass="Background", 

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

451 ) 

452 skyMap = cT.Input( 

453 doc="SkyMap to use in processing", 

454 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME, 

455 storageClass="SkyMap", 

456 dimensions=("skymap",), 

457 ) 

458 # TODO[DM-47424]: remove this deprecated connection. 

459 visitCatalogs = cT.Input( 

460 doc="Deprecated and unused.", 

461 name="src", 

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

463 storageClass="SourceCatalog", 

464 multiple=True, 

465 deprecated="Deprecated and unused. Will be removed after v29.", 

466 ) 

467 sourceTableHandles = cT.Input( 

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

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

470 "PSF flags."), 

471 name="sourceTable_visit", 

472 storageClass="ArrowAstropy", 

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

474 multiple=True, 

475 deferLoad=True, 

476 ) 

477 finalizedSourceTableHandles = cT.Input( 

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

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

480 name="finalized_src_table", 

481 storageClass="ArrowAstropy", 

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

483 multiple=True, 

484 deferLoad=True, 

485 ) 

486 finalVisitSummaryHandles = cT.Input( 

487 doc="Final visit summary table", 

488 name="finalVisitSummary", 

489 storageClass="ExposureCatalog", 

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

491 multiple=True, 

492 deferLoad=True, 

493 ) 

494 # TODO[DM-47797]: remove this deprecated connection. 

495 inputCatalog = cT.Input( 

496 doc=("Name of the input catalog to use." 

497 "If the single band deblender was used this should be 'deblendedFlux." 

498 "If the multi-band deblender was used this should be 'deblendedModel, " 

499 "or deblendedFlux if the multiband deblender was configured to output " 

500 "deblended flux catalogs. If no deblending was performed this should " 

501 "be 'mergeDet'"), 

502 name="{inputCoaddName}Coadd_{deblendedCatalog}", 

503 storageClass="SourceCatalog", 

504 deprecated="Support for old deblender outputs will be removed after v29.", 

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

506 ) 

507 scarletCatalog = cT.Input( 

508 doc="Catalogs produced by multiband deblending", 

509 name="{inputCoaddName}Coadd_deblendedCatalog", 

510 storageClass="SourceCatalog", 

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

512 ) 

513 scarletModels = cT.Input( 

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

515 name="{inputCoaddName}Coadd_scarletModelData", 

516 storageClass="ScarletModelData", 

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

518 ) 

519 outputSources = cT.Output( 

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

521 name="{outputCoaddName}Coadd_meas", 

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

523 storageClass="SourceCatalog", 

524 ) 

525 # TODO[DM-47797]: remove this deprecated connection. 

526 matchResult = cT.Output( 

527 doc="Match catalog produced by configured matcher, optional on doMatchSources", 

528 name="{outputCoaddName}Coadd_measMatch", 

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

530 storageClass="Catalog", 

531 deprecated="Reference matching in measureCoaddSources will be removed after v29.", 

532 ) 

533 # TODO[DM-47797]: remove this deprecated connection. 

534 denormMatches = cT.Output( 

535 doc="Denormalized Match catalog produced by configured matcher, optional on " 

536 "doWriteMatchesDenormalized", 

537 name="{outputCoaddName}Coadd_measMatchFull", 

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

539 storageClass="Catalog", 

540 deprecated="Reference matching in measureCoaddSources will be removed after v29.", 

541 ) 

542 

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

544 super().__init__(config=config) 

545 del self.visitCatalogs 

546 if not config.doPropagateFlags: 

547 del self.sourceTableHandles 

548 del self.finalizedSourceTableHandles 

549 else: 

550 # Check for types of flags required. 

551 if not config.propagateFlags.source_flags: 

552 del self.sourceTableHandles 

553 if not config.propagateFlags.finalized_source_flags: 

554 del self.finalizedSourceTableHandles 

555 # TODO[DM-47797]: only the 'if' block contents here should survive. 

556 if config.inputCatalog == "deblendedCatalog": 

557 del self.inputCatalog 

558 if not config.doAddFootprints: 

559 del self.scarletModels 

560 else: 

561 del self.deblendedCatalog 

562 del self.scarletModels 

563 

564 # TODO[DM-47797]: delete the conditionals below. 

565 if not config.doMatchSources: 

566 del self.refCat 

567 del self.matchResult 

568 

569 if not config.doWriteMatchesDenormalized: 

570 del self.denormMatches 

571 

572 if config.useCellCoadds: 

573 del self.exposure 

574 else: 

575 del self.exposure_cells 

576 del self.background 

577 

578 

579class MeasureMergedCoaddSourcesConfig(PipelineTaskConfig, 

580 pipelineConnections=MeasureMergedCoaddSourcesConnections): 

581 """Configuration parameters for the MeasureMergedCoaddSourcesTask 

582 """ 

583 inputCatalog = ChoiceField( 

584 dtype=str, 

585 default="deblendedCatalog", 

586 allowed={ 

587 "deblendedCatalog": "Output catalog from ScarletDeblendTask", 

588 "deblendedFlux": "Output catalog from SourceDeblendTask", 

589 "mergeDet": "The merged detections before deblending." 

590 }, 

591 doc="The name of the input catalog.", 

592 # TODO[DM-47797]: remove this config option and anything using it. 

593 deprecated="Support for old deblender outputs will be removed after v29.", 

594 ) 

595 doAddFootprints = Field(dtype=bool, 

596 default=True, 

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

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

599 "otherwise this should be False.") 

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

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

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

603 "model footprints.") 

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

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

606 "saving to disk. " 

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

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

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

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

611 doPropagateFlags = Field( 

612 dtype=bool, default=True, 

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

614 ) 

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

616 doMatchSources = Field( 

617 dtype=bool, 

618 default=False, 

619 doc="Match sources to reference catalog?", 

620 deprecated="Reference matching in measureCoaddSources will be removed after v29.", 

621 ) 

622 match = ConfigurableField( 

623 target=DirectMatchTask, 

624 doc="Matching to reference catalog", 

625 deprecated="Reference matching in measureCoaddSources will be removed after v29.", 

626 ) 

627 doWriteMatchesDenormalized = Field( 

628 dtype=bool, 

629 default=False, 

630 doc=("Write reference matches in denormalized format? " 

631 "This format uses more disk space, but is more convenient to read."), 

632 deprecated="Reference matching in measureCoaddSources will be removed after v29.", 

633 ) 

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

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

636 checkUnitsParseStrict = Field( 

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

638 dtype=str, 

639 default="raise", 

640 ) 

641 doApCorr = Field( 

642 dtype=bool, 

643 default=True, 

644 doc="Apply aperture corrections" 

645 ) 

646 applyApCorr = ConfigurableField( 

647 target=ApplyApCorrTask, 

648 doc="Subtask to apply aperture corrections" 

649 ) 

650 doRunCatalogCalculation = Field( 

651 dtype=bool, 

652 default=True, 

653 doc='Run catalogCalculation task' 

654 ) 

655 catalogCalculation = ConfigurableField( 

656 target=CatalogCalculationTask, 

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

658 ) 

659 

660 hasFakes = Field( 

661 dtype=bool, 

662 default=False, 

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

664 ) 

665 idGenerator = SkyMapIdGeneratorConfig.make_field() 

666 

667 @property 

668 def refObjLoader(self): 

669 return self.match.refObjLoader 

670 

671 def setDefaults(self): 

672 super().setDefaults() 

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

674 'base_Variance', 

675 'base_LocalPhotoCalib', 

676 'base_LocalWcs'] 

677 

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

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

680 # still set flags with it here. 

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

682 'INEXACT_PSF'] 

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

684 'INEXACT_PSF'] 

685 

686 def validate(self): 

687 super().validate() 

688 

689 if not self.doMatchSources and self.doWriteMatchesDenormalized: 

690 raise ValueError("Cannot set doWriteMatchesDenormalized if doMatchSources is False.") 

691 

692 

693class MeasureMergedCoaddSourcesTask(PipelineTask): 

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

695 

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

697 measurement in each coadd. 

698 

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

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

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

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

703 

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

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

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

707 propagated to the coadd sources. 

708 

709 Optionally, we can match the coadd sources to an external reference 

710 catalog. 

711 

712 After MeasureMergedCoaddSourcesTask has been run on multiple coadds, we 

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

714 processing procedure will merge these measurements into a suitable catalog 

715 for driving forced photometry. 

716 

717 Parameters 

718 ---------- 

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

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

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

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

723 refObjLoader : `lsst.meas.algorithms.ReferenceObjectLoader`, optional 

724 An instance of ReferenceObjectLoader that supplies an external reference 

725 catalog. May be None if the loader can be constructed from the butler argument or all steps 

726 requiring a reference catalog are disabled. 

727 initInputs : `dict`, optional 

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

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

730 **kwargs 

731 Additional keyword arguments. 

732 """ 

733 

734 _DefaultName = "measureCoaddSources" 

735 ConfigClass = MeasureMergedCoaddSourcesConfig 

736 

737 def __init__(self, schema=None, peakSchema=None, refObjLoader=None, initInputs=None, 

738 **kwargs): 

739 super().__init__(**kwargs) 

740 self.deblended = self.config.inputCatalog.startswith("deblended") 

741 self.inputCatalog = "Coadd_" + self.config.inputCatalog 

742 if initInputs is not None: 

743 schema = initInputs['inputSchema'].schema 

744 if schema is None: 

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

746 self.schemaMapper = afwTable.SchemaMapper(schema) 

747 self.schemaMapper.addMinimalSchema(schema) 

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

749 self.algMetadata = PropertyList() 

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

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

752 # TODO[DM-47797]: remove match subtask 

753 if self.config.doMatchSources: 

754 self.makeSubtask("match", refObjLoader=refObjLoader) 

755 if self.config.doPropagateFlags: 

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

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

758 if self.config.doApCorr: 

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

760 if self.config.doRunCatalogCalculation: 

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

762 

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

764 

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

766 inputs = butlerQC.get(inputRefs) 

767 

768 # TODO[DM-47797]: remove this block 

769 if self.config.doMatchSources: 

770 refObjLoader = ReferenceObjectLoader([ref.datasetRef.dataId for ref in inputRefs.refCat], 

771 inputs.pop('refCat'), 

772 name=self.config.connections.refCat, 

773 config=self.config.refObjLoader, 

774 log=self.log) 

775 self.match.setRefObjLoader(refObjLoader) 

776 

777 if self.config.useCellCoadds: 

778 multiple_cell_coadd = inputs.pop("exposure_cells") 

779 stitched_coadd = multiple_cell_coadd.stitch() 

780 exposure = stitched_coadd.asExposure() 

781 background = inputs.pop("background") 

782 exposure.image -= background.getImage() 

783 

784 ccdInputs = stitched_coadd.ccds 

785 apCorrMap = stitched_coadd.ap_corr_map 

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

787 else: 

788 exposure = inputs.pop("exposure") 

789 # Set psfcache 

790 # move this to run after gen2 deprecation 

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

792 

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

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

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

796 

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

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

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

800 

801 # Transform inputCatalog 

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

803 sources = afwTable.SourceCatalog(table) 

804 # Load the correct input catalog 

805 if "scarletCatalog" in inputs: 

806 inputCatalog = inputs.pop("scarletCatalog") 

807 catalogRef = inputRefs.scarletCatalog 

808 else: 

809 inputCatalog = inputs.pop("inputCatalog") 

810 catalogRef = inputRefs.inputCatalog 

811 sources.extend(inputCatalog, self.schemaMapper) 

812 del inputCatalog 

813 # Add the HeavyFootprints to the deblended sources 

814 if self.config.doAddFootprints: 

815 modelData = inputs.pop('scarletModels') 

816 if self.config.doConserveFlux: 

817 imageForRedistribution = exposure 

818 else: 

819 imageForRedistribution = None 

820 updateCatalogFootprints( 

821 modelData=modelData, 

822 catalog=sources, 

823 band=band, 

824 imageForRedistribution=imageForRedistribution, 

825 removeScarletData=True, 

826 updateFluxColumns=True, 

827 ) 

828 table = sources.getTable() 

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

830 

831 skyMap = inputs.pop('skyMap') 

832 tractNumber = catalogRef.dataId['tract'] 

833 tractInfo = skyMap[tractNumber] 

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

835 skyInfo = Struct( 

836 skyMap=skyMap, 

837 tractInfo=tractInfo, 

838 patchInfo=patchInfo, 

839 wcs=tractInfo.getWcs(), 

840 bbox=patchInfo.getOuterBBox() 

841 ) 

842 

843 if self.config.doPropagateFlags: 

844 if "sourceTableHandles" in inputs: 

845 sourceTableHandles = inputs.pop("sourceTableHandles") 

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

847 else: 

848 sourceTableHandleDict = None 

849 if "finalizedSourceTableHandles" in inputs: 

850 finalizedSourceTableHandles = inputs.pop("finalizedSourceTableHandles") 

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

852 for handle in finalizedSourceTableHandles} 

853 else: 

854 finalizedSourceTableHandleDict = None 

855 if "finalVisitSummaryHandles" in inputs: 

856 finalVisitSummaryHandles = inputs.pop("finalVisitSummaryHandles") 

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

858 for handle in finalVisitSummaryHandles} 

859 else: 

860 finalVisitSummaryHandleDict = None 

861 

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

863 outputs = self.run( 

864 exposure=exposure, 

865 sources=sources, 

866 skyInfo=skyInfo, 

867 exposureId=idGenerator.catalog_id, 

868 ccdInputs=ccdInputs, 

869 sourceTableHandleDict=sourceTableHandleDict, 

870 finalizedSourceTableHandleDict=finalizedSourceTableHandleDict, 

871 finalVisitSummaryHandleDict=finalVisitSummaryHandleDict, 

872 apCorrMap=apCorrMap, 

873 ) 

874 # Strip HeavyFootprints to save space on disk 

875 if self.config.doStripFootprints: 

876 sources = outputs.outputSources 

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

878 source.setFootprint(None) 

879 butlerQC.put(outputs, outputRefs) 

880 

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

882 sourceTableHandleDict=None, finalizedSourceTableHandleDict=None, finalVisitSummaryHandleDict=None, 

883 apCorrMap=None): 

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

885 resulting catalog with extra information. 

886 

887 Parameters 

888 ---------- 

889 exposure : `lsst.afw.exposure.Exposure` 

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

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

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

893 deblender outputs. 

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

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

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

897 exposureId : `int` or `bytes` 

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

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

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

901 the coadd. 

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

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

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

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

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

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

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

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

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

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

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

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

914 

915 Returns 

916 ------- 

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

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

919 sources attribute. Optionally will have results of matching to a 

920 reference catalog in the matchResults attribute, and denormalized 

921 matches in the denormMatches attribute. 

922 """ 

923 if self.config.doPropagateFlags: 

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

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

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

927 exposure.mask.addMaskPlane(maskPlane) 

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

929 exposure.mask.addMaskPlane(maskPlane) 

930 

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

932 

933 if self.config.doApCorr: 

934 if apCorrMap is None: 

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

936 self.applyApCorr.run( 

937 catalog=sources, 

938 apCorrMap=apCorrMap, 

939 ) 

940 

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

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

943 # NOTE: sourceSelectors require contiguous catalogs, so ensure 

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

945 if not sources.isContiguous(): 

946 sources = sources.copy(deep=True) 

947 

948 if self.config.doRunCatalogCalculation: 

949 self.catalogCalculation.run(sources) 

950 

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

952 patchInfo=skyInfo.patchInfo) 

953 if self.config.doPropagateFlags: 

954 self.propagateFlags.run( 

955 sources, 

956 ccdInputs, 

957 sourceTableHandleDict, 

958 finalizedSourceTableHandleDict, 

959 finalVisitSummaryHandleDict, 

960 ) 

961 

962 results = Struct() 

963 

964 # TODO[DM-47797]: remove this block 

965 if self.config.doMatchSources: 

966 matchResult = self.match.run(sources, exposure.getInfo().getFilter().bandLabel) 

967 matches = afwTable.packMatches(matchResult.matches) 

968 matches.table.setMetadata(matchResult.matchMeta) 

969 results.matchResult = matches 

970 if self.config.doWriteMatchesDenormalized: 

971 if matchResult.matches: 

972 denormMatches = denormalizeMatches(matchResult.matches, matchResult.matchMeta) 

973 else: 

974 self.log.warning("No matches, so generating dummy denormalized matches file") 

975 denormMatches = afwTable.BaseCatalog(afwTable.Schema()) 

976 denormMatches.setMetadata(PropertyList()) 

977 denormMatches.getMetadata().add("COMMENT", 

978 "This catalog is empty because no matches were found.") 

979 results.denormMatches = denormMatches 

980 results.denormMatches = denormMatches 

981 

982 results.outputSources = sources 

983 return results