Coverage for python/lsst/pipe/tasks/calibrateImage.py: 26%

250 statements  

« prev     ^ index     » next       coverage.py v7.4.3, created at 2024-02-28 12:33 +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 

22import collections.abc 

23 

24import numpy as np 

25 

26import lsst.afw.table as afwTable 

27import lsst.afw.image as afwImage 

28import lsst.meas.algorithms 

29import lsst.meas.algorithms.installGaussianPsf 

30import lsst.meas.algorithms.measureApCorr 

31import lsst.meas.algorithms.setPrimaryFlags 

32import lsst.meas.base 

33import lsst.meas.astrom 

34import lsst.meas.deblender 

35import lsst.meas.extensions.shapeHSM 

36import lsst.pex.config as pexConfig 

37import lsst.pipe.base as pipeBase 

38from lsst.pipe.base import connectionTypes 

39from lsst.utils.timer import timeMethod 

40 

41from . import measurePsf, repair, photoCal, computeExposureSummaryStats, maskStreaks, snapCombine 

42 

43 

44class CalibrateImageConnections(pipeBase.PipelineTaskConnections, 

45 dimensions=("instrument", "visit", "detector")): 

46 

47 astrometry_ref_cat = connectionTypes.PrerequisiteInput( 

48 doc="Reference catalog to use for astrometric calibration.", 

49 name="gaia_dr3_20230707", 

50 storageClass="SimpleCatalog", 

51 dimensions=("skypix",), 

52 deferLoad=True, 

53 multiple=True, 

54 ) 

55 photometry_ref_cat = connectionTypes.PrerequisiteInput( 

56 doc="Reference catalog to use for photometric calibration.", 

57 name="ps1_pv3_3pi_20170110", 

58 storageClass="SimpleCatalog", 

59 dimensions=("skypix",), 

60 deferLoad=True, 

61 multiple=True 

62 ) 

63 

64 exposures = connectionTypes.Input( 

65 doc="Exposure (or two snaps) to be calibrated, and detected and measured on.", 

66 name="postISRCCD", 

67 storageClass="Exposure", 

68 multiple=True, # to handle 1 exposure or 2 snaps 

69 dimensions=["instrument", "exposure", "detector"], 

70 ) 

71 

72 # outputs 

73 initial_stars_schema = connectionTypes.InitOutput( 

74 doc="Schema of the output initial stars catalog.", 

75 name="initial_stars_schema", 

76 storageClass="SourceCatalog", 

77 ) 

78 

79 # TODO: We want some kind of flag on Exposures/Catalogs to make it obvious 

80 # which components had failed to be computed/persisted 

81 output_exposure = connectionTypes.Output( 

82 doc="Photometrically calibrated exposure with fitted calibrations and summary statistics.", 

83 name="initial_pvi", 

84 storageClass="ExposureF", 

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

86 ) 

87 stars = connectionTypes.Output( 

88 doc="Catalog of unresolved sources detected on the calibrated exposure.", 

89 name="initial_stars_detector", 

90 storageClass="ArrowAstropy", 

91 dimensions=["instrument", "visit", "detector"], 

92 ) 

93 stars_footprints = connectionTypes.Output( 

94 doc="Catalog of unresolved sources detected on the calibrated exposure; " 

95 "includes source footprints.", 

96 name="initial_stars_footprints_detector", 

97 storageClass="SourceCatalog", 

98 dimensions=["instrument", "visit", "detector"], 

99 ) 

100 applied_photo_calib = connectionTypes.Output( 

101 doc="Photometric calibration that was applied to exposure.", 

102 name="initial_photoCalib_detector", 

103 storageClass="PhotoCalib", 

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

105 ) 

106 background = connectionTypes.Output( 

107 doc="Background models estimated during calibration task.", 

108 name="initial_pvi_background", 

109 storageClass="Background", 

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

111 ) 

112 

113 # Optional outputs 

114 psf_stars_footprints = connectionTypes.Output( 

115 doc="Catalog of bright unresolved sources detected on the exposure used for PSF determination; " 

116 "includes source footprints.", 

117 name="initial_psf_stars_footprints_detector", 

118 storageClass="SourceCatalog", 

119 dimensions=["instrument", "visit", "detector"], 

120 ) 

121 psf_stars = connectionTypes.Output( 

122 doc="Catalog of bright unresolved sources detected on the exposure used for PSF determination.", 

123 name="initial_psf_stars_detector", 

124 storageClass="ArrowAstropy", 

125 dimensions=["instrument", "visit", "detector"], 

126 ) 

127 astrometry_matches = connectionTypes.Output( 

128 doc="Source to reference catalog matches from the astrometry solver.", 

129 name="initial_astrometry_match_detector", 

130 storageClass="Catalog", 

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

132 ) 

133 photometry_matches = connectionTypes.Output( 

134 doc="Source to reference catalog matches from the photometry solver.", 

135 name="initial_photometry_match_detector", 

136 storageClass="Catalog", 

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

138 ) 

139 

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

141 super().__init__(config=config) 

142 if not config.optional_outputs: 

143 del self.psf_stars 

144 del self.psf_stars_footprints 

145 del self.astrometry_matches 

146 del self.photometry_matches 

147 

148 

149class CalibrateImageConfig(pipeBase.PipelineTaskConfig, pipelineConnections=CalibrateImageConnections): 

150 optional_outputs = pexConfig.ListField( 

151 doc="Which optional outputs to save (as their connection name)?", 

152 dtype=str, 

153 # TODO: note somewhere to disable this for benchmarking, but should 

154 # we always have it on for production runs? 

155 default=["psf_stars", "psf_stars_footprints", "astrometry_matches", "photometry_matches"], 

156 optional=True 

157 ) 

158 

159 # To generate catalog ids consistently across subtasks. 

160 id_generator = lsst.meas.base.DetectorVisitIdGeneratorConfig.make_field() 

161 

162 snap_combine = pexConfig.ConfigurableField( 

163 target=snapCombine.SnapCombineTask, 

164 doc="Task to combine two snaps to make one exposure.", 

165 ) 

166 

167 # subtasks used during psf characterization 

168 install_simple_psf = pexConfig.ConfigurableField( 

169 target=lsst.meas.algorithms.installGaussianPsf.InstallGaussianPsfTask, 

170 doc="Task to install a simple PSF model into the input exposure to use " 

171 "when detecting bright sources for PSF estimation.", 

172 ) 

173 psf_repair = pexConfig.ConfigurableField( 

174 target=repair.RepairTask, 

175 doc="Task to repair cosmic rays on the exposure before PSF determination.", 

176 ) 

177 psf_subtract_background = pexConfig.ConfigurableField( 

178 target=lsst.meas.algorithms.SubtractBackgroundTask, 

179 doc="Task to perform intial background subtraction, before first detection pass.", 

180 ) 

181 psf_detection = pexConfig.ConfigurableField( 

182 target=lsst.meas.algorithms.SourceDetectionTask, 

183 doc="Task to detect sources for PSF determination." 

184 ) 

185 psf_source_measurement = pexConfig.ConfigurableField( 

186 target=lsst.meas.base.SingleFrameMeasurementTask, 

187 doc="Task to measure sources to be used for psf estimation." 

188 ) 

189 psf_measure_psf = pexConfig.ConfigurableField( 

190 target=measurePsf.MeasurePsfTask, 

191 doc="Task to measure the psf on bright sources." 

192 ) 

193 

194 # TODO DM-39203: we can remove aperture correction from this task once we are 

195 # using the shape-based star/galaxy code. 

196 measure_aperture_correction = pexConfig.ConfigurableField( 

197 target=lsst.meas.algorithms.measureApCorr.MeasureApCorrTask, 

198 doc="Task to compute the aperture correction from the bright stars." 

199 ) 

200 

201 # subtasks used during star measurement 

202 star_detection = pexConfig.ConfigurableField( 

203 target=lsst.meas.algorithms.SourceDetectionTask, 

204 doc="Task to detect stars to return in the output catalog." 

205 ) 

206 star_sky_sources = pexConfig.ConfigurableField( 

207 target=lsst.meas.algorithms.SkyObjectsTask, 

208 doc="Task to generate sky sources ('empty' regions where there are no detections).", 

209 ) 

210 star_mask_streaks = pexConfig.ConfigurableField( 

211 target=maskStreaks.MaskStreaksTask, 

212 doc="Task for masking streaks. Adds a STREAK mask plane to an exposure.", 

213 ) 

214 star_deblend = pexConfig.ConfigurableField( 

215 target=lsst.meas.deblender.SourceDeblendTask, 

216 doc="Split blended sources into their components." 

217 ) 

218 star_measurement = pexConfig.ConfigurableField( 

219 target=lsst.meas.base.SingleFrameMeasurementTask, 

220 doc="Task to measure stars to return in the output catalog." 

221 ) 

222 star_apply_aperture_correction = pexConfig.ConfigurableField( 

223 target=lsst.meas.base.ApplyApCorrTask, 

224 doc="Task to apply aperture corrections to the selected stars." 

225 ) 

226 star_catalog_calculation = pexConfig.ConfigurableField( 

227 target=lsst.meas.base.CatalogCalculationTask, 

228 doc="Task to compute extendedness values on the star catalog, " 

229 "for the star selector to remove extended sources." 

230 ) 

231 star_set_primary_flags = pexConfig.ConfigurableField( 

232 target=lsst.meas.algorithms.setPrimaryFlags.SetPrimaryFlagsTask, 

233 doc="Task to add isPrimary to the catalog." 

234 ) 

235 star_selector = lsst.meas.algorithms.sourceSelectorRegistry.makeField( 

236 default="science", 

237 doc="Task to select isolated stars to use for calibration." 

238 ) 

239 

240 # final calibrations and statistics 

241 astrometry = pexConfig.ConfigurableField( 

242 target=lsst.meas.astrom.AstrometryTask, 

243 doc="Task to perform astrometric calibration to fit a WCS.", 

244 ) 

245 astrometry_ref_loader = pexConfig.ConfigField( 

246 dtype=lsst.meas.algorithms.LoadReferenceObjectsConfig, 

247 doc="Configuration of reference object loader for astrometric fit.", 

248 ) 

249 photometry = pexConfig.ConfigurableField( 

250 target=photoCal.PhotoCalTask, 

251 doc="Task to perform photometric calibration to fit a PhotoCalib.", 

252 ) 

253 photometry_ref_loader = pexConfig.ConfigField( 

254 dtype=lsst.meas.algorithms.LoadReferenceObjectsConfig, 

255 doc="Configuration of reference object loader for photometric fit.", 

256 ) 

257 

258 compute_summary_stats = pexConfig.ConfigurableField( 

259 target=computeExposureSummaryStats.ComputeExposureSummaryStatsTask, 

260 doc="Task to to compute summary statistics on the calibrated exposure." 

261 ) 

262 

263 def setDefaults(self): 

264 super().setDefaults() 

265 

266 # Use a very broad PSF here, to throughly reject CRs. 

267 # TODO investigation: a large initial psf guess may make stars look 

268 # like CRs for very good seeing images. 

269 self.install_simple_psf.fwhm = 4 

270 

271 # S/N>=50 sources for PSF determination, but detection to S/N=5. 

272 self.psf_detection.thresholdValue = 5.0 

273 self.psf_detection.includeThresholdMultiplier = 10.0 

274 # TODO investigation: Probably want False here, but that may require 

275 # tweaking the background spatial scale, to make it small enough to 

276 # prevent extra peaks in the wings of bright objects. 

277 self.psf_detection.doTempLocalBackground = False 

278 # NOTE: we do want reEstimateBackground=True in psf_detection, so that 

279 # each measurement step is done with the best background available. 

280 

281 # Minimal measurement plugins for PSF determination. 

282 # TODO DM-39203: We can drop GaussianFlux and PsfFlux, if we use 

283 # shapeHSM/moments for star/galaxy separation. 

284 # TODO DM-39203: we can remove aperture correction from this task once 

285 # we are using the shape-based star/galaxy code. 

286 self.psf_source_measurement.plugins = ["base_PixelFlags", 

287 "base_SdssCentroid", 

288 "ext_shapeHSM_HsmSourceMoments", 

289 "base_CircularApertureFlux", 

290 "base_GaussianFlux", 

291 "base_PsfFlux", 

292 "base_ClassificationSizeExtendedness", 

293 ] 

294 self.psf_source_measurement.slots.shape = "ext_shapeHSM_HsmSourceMoments" 

295 # Only measure apertures we need for PSF measurement. 

296 self.psf_source_measurement.plugins["base_CircularApertureFlux"].radii = [12.0] 

297 # TODO DM-40843: Remove this line once this is the psfex default. 

298 self.psf_measure_psf.psfDeterminer["psfex"].photometricFluxField = \ 

299 "base_CircularApertureFlux_12_0_instFlux" 

300 

301 # No extendeness information available: we need the aperture 

302 # corrections to determine that. 

303 self.measure_aperture_correction.sourceSelector["science"].doUnresolved = False 

304 self.measure_aperture_correction.sourceSelector["science"].flags.good = ["calib_psf_used"] 

305 self.measure_aperture_correction.sourceSelector["science"].flags.bad = [] 

306 

307 # Detection for good S/N for astrometry/photometry and other 

308 # downstream tasks; detection mask to S/N>=5, but S/N>=10 peaks. 

309 self.star_detection.thresholdValue = 5.0 

310 self.star_detection.includeThresholdMultiplier = 2.0 

311 self.star_measurement.plugins = ["base_PixelFlags", 

312 "base_SdssCentroid", 

313 "ext_shapeHSM_HsmSourceMoments", 

314 'ext_shapeHSM_HsmPsfMoments', 

315 "base_GaussianFlux", 

316 "base_PsfFlux", 

317 "base_CircularApertureFlux", 

318 "base_ClassificationSizeExtendedness", 

319 ] 

320 self.star_measurement.slots.psfShape = "ext_shapeHSM_HsmPsfMoments" 

321 self.star_measurement.slots.shape = "ext_shapeHSM_HsmSourceMoments" 

322 # Only measure the apertures we need for star selection. 

323 self.star_measurement.plugins["base_CircularApertureFlux"].radii = [12.0] 

324 

325 # Keep track of which footprints contain streaks 

326 self.star_measurement.plugins['base_PixelFlags'].masksFpAnywhere = ['STREAK'] 

327 self.star_measurement.plugins['base_PixelFlags'].masksFpCenter = ['STREAK'] 

328 

329 # Select isolated stars with reliable measurements and no bad flags. 

330 self.star_selector["science"].doFlags = True 

331 self.star_selector["science"].doUnresolved = True 

332 self.star_selector["science"].doSignalToNoise = True 

333 self.star_selector["science"].doIsolated = True 

334 self.star_selector["science"].signalToNoise.minimum = 10.0 

335 # Keep sky sources in the output catalog, even though they aren't 

336 # wanted for calibration. 

337 self.star_selector["science"].doSkySources = True 

338 

339 # Use the affine WCS fitter (assumes we have a good camera geometry). 

340 self.astrometry.wcsFitter.retarget(lsst.meas.astrom.FitAffineWcsTask) 

341 # phot_g_mean is the primary Gaia band for all input bands. 

342 self.astrometry_ref_loader.anyFilterMapsToThis = "phot_g_mean" 

343 

344 # Only reject sky sources; we already selected good stars. 

345 self.astrometry.sourceSelector["science"].doFlags = True 

346 self.astrometry.sourceSelector["science"].flags.bad = ["sky_source"] 

347 self.photometry.match.sourceSelection.doFlags = True 

348 self.photometry.match.sourceSelection.flags.bad = ["sky_source"] 

349 

350 # All sources should be good for PSF summary statistics. 

351 # TODO: These should both be changed to calib_psf_used with DM-41640. 

352 self.compute_summary_stats.starSelection = "calib_photometry_used" 

353 self.compute_summary_stats.starSelector.flags.good = ["calib_photometry_used"] 

354 

355 

356class CalibrateImageTask(pipeBase.PipelineTask): 

357 """Compute the PSF, aperture corrections, astrometric and photometric 

358 calibrations, and summary statistics for a single science exposure, and 

359 produce a catalog of brighter stars that were used to calibrate it. 

360 

361 Parameters 

362 ---------- 

363 initial_stars_schema : `lsst.afw.table.Schema` 

364 Schema of the initial_stars output catalog. 

365 """ 

366 _DefaultName = "calibrateImage" 

367 ConfigClass = CalibrateImageConfig 

368 

369 def __init__(self, initial_stars_schema=None, **kwargs): 

370 super().__init__(**kwargs) 

371 

372 self.makeSubtask("snap_combine") 

373 

374 # PSF determination subtasks 

375 self.makeSubtask("install_simple_psf") 

376 self.makeSubtask("psf_repair") 

377 self.makeSubtask("psf_subtract_background") 

378 self.psf_schema = afwTable.SourceTable.makeMinimalSchema() 

379 self.makeSubtask("psf_detection", schema=self.psf_schema) 

380 self.makeSubtask("psf_source_measurement", schema=self.psf_schema) 

381 self.makeSubtask("psf_measure_psf", schema=self.psf_schema) 

382 

383 self.makeSubtask("measure_aperture_correction", schema=self.psf_schema) 

384 

385 # star measurement subtasks 

386 if initial_stars_schema is None: 

387 initial_stars_schema = afwTable.SourceTable.makeMinimalSchema() 

388 

389 # These fields let us track which sources were used for psf and 

390 # aperture correction calculations. 

391 self.psf_fields = ("calib_psf_candidate", "calib_psf_used", "calib_psf_reserved", 

392 # TODO DM-39203: these can be removed once apcorr is gone. 

393 "apcorr_slot_CalibFlux_used", "apcorr_base_GaussianFlux_used", 

394 "apcorr_base_PsfFlux_used") 

395 for field in self.psf_fields: 

396 item = self.psf_schema.find(field) 

397 initial_stars_schema.addField(item.getField()) 

398 

399 afwTable.CoordKey.addErrorFields(initial_stars_schema) 

400 self.makeSubtask("star_detection", schema=initial_stars_schema) 

401 self.makeSubtask("star_sky_sources", schema=initial_stars_schema) 

402 self.makeSubtask("star_mask_streaks") 

403 self.makeSubtask("star_deblend", schema=initial_stars_schema) 

404 self.makeSubtask("star_measurement", schema=initial_stars_schema) 

405 self.makeSubtask("star_apply_aperture_correction", schema=initial_stars_schema) 

406 self.makeSubtask("star_catalog_calculation", schema=initial_stars_schema) 

407 self.makeSubtask("star_set_primary_flags", schema=initial_stars_schema, isSingleFrame=True) 

408 self.makeSubtask("star_selector") 

409 

410 self.makeSubtask("astrometry", schema=initial_stars_schema) 

411 self.makeSubtask("photometry", schema=initial_stars_schema) 

412 

413 self.makeSubtask("compute_summary_stats") 

414 

415 # For the butler to persist it. 

416 self.initial_stars_schema = afwTable.SourceCatalog(initial_stars_schema) 

417 

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

419 inputs = butlerQC.get(inputRefs) 

420 id_generator = self.config.id_generator.apply(butlerQC.quantum.dataId) 

421 

422 astrometry_loader = lsst.meas.algorithms.ReferenceObjectLoader( 

423 dataIds=[ref.datasetRef.dataId for ref in inputRefs.astrometry_ref_cat], 

424 refCats=inputs.pop("astrometry_ref_cat"), 

425 name=self.config.connections.astrometry_ref_cat, 

426 config=self.config.astrometry_ref_loader, log=self.log) 

427 self.astrometry.setRefObjLoader(astrometry_loader) 

428 

429 photometry_loader = lsst.meas.algorithms.ReferenceObjectLoader( 

430 dataIds=[ref.datasetRef.dataId for ref in inputRefs.photometry_ref_cat], 

431 refCats=inputs.pop("photometry_ref_cat"), 

432 name=self.config.connections.photometry_ref_cat, 

433 config=self.config.photometry_ref_loader, log=self.log) 

434 self.photometry.match.setRefObjLoader(photometry_loader) 

435 

436 outputs = self.run(id_generator=id_generator, **inputs) 

437 

438 butlerQC.put(outputs, outputRefs) 

439 

440 @timeMethod 

441 def run(self, *, exposures, id_generator=None): 

442 """Find stars and perform psf measurement, then do a deeper detection 

443 and measurement and calibrate astrometry and photometry from that. 

444 

445 Parameters 

446 ---------- 

447 exposures : `lsst.afw.image.Exposure` or `list` [`lsst.afw.image.Exposure`] 

448 Post-ISR exposure(s), with an initial WCS, VisitInfo, and Filter. 

449 Modified in-place during processing if only one is passed. 

450 If two exposures are passed, treat them as snaps and combine 

451 before doing further processing. 

452 id_generator : `lsst.meas.base.IdGenerator`, optional 

453 Object that generates source IDs and provides random seeds. 

454 

455 Returns 

456 ------- 

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

458 Results as a struct with attributes: 

459 

460 ``output_exposure`` 

461 Calibrated exposure, with pixels in nJy units. 

462 (`lsst.afw.image.Exposure`) 

463 ``stars`` 

464 Stars that were used to calibrate the exposure, with 

465 calibrated fluxes and magnitudes. 

466 (`astropy.table.Table`) 

467 ``stars_footprints`` 

468 Footprints of stars that were used to calibrate the exposure. 

469 (`lsst.afw.table.SourceCatalog`) 

470 ``psf_stars`` 

471 Stars that were used to determine the image PSF. 

472 (`astropy.table.Table`) 

473 ``psf_stars_footprints`` 

474 Footprints of stars that were used to determine the image PSF. 

475 (`lsst.afw.table.SourceCatalog`) 

476 ``background`` 

477 Background that was fit to the exposure when detecting 

478 ``stars``. (`lsst.afw.math.BackgroundList`) 

479 ``applied_photo_calib`` 

480 Photometric calibration that was fit to the star catalog and 

481 applied to the exposure. (`lsst.afw.image.PhotoCalib`) 

482 ``astrometry_matches`` 

483 Reference catalog stars matches used in the astrometric fit. 

484 (`list` [`lsst.afw.table.ReferenceMatch`] or `lsst.afw.table.BaseCatalog`) 

485 ``photometry_matches`` 

486 Reference catalog stars matches used in the photometric fit. 

487 (`list` [`lsst.afw.table.ReferenceMatch`] or `lsst.afw.table.BaseCatalog`) 

488 """ 

489 if id_generator is None: 

490 id_generator = lsst.meas.base.IdGenerator() 

491 

492 exposure = self._handle_snaps(exposures) 

493 

494 psf_stars, background, candidates = self._compute_psf(exposure) 

495 

496 self._measure_aperture_correction(exposure, psf_stars) 

497 

498 stars = self._find_stars(exposure, background, id_generator) 

499 self._match_psf_stars(psf_stars, stars) 

500 

501 astrometry_matches, astrometry_meta = self._fit_astrometry(exposure, stars) 

502 stars, photometry_matches, photometry_meta, photo_calib = self._fit_photometry(exposure, stars) 

503 

504 self._summarize(exposure, stars, background) 

505 

506 if self.config.optional_outputs: 

507 astrometry_matches = lsst.meas.astrom.denormalizeMatches(astrometry_matches, astrometry_meta) 

508 photometry_matches = lsst.meas.astrom.denormalizeMatches(photometry_matches, photometry_meta) 

509 

510 return pipeBase.Struct(output_exposure=exposure, 

511 stars_footprints=stars, 

512 stars=stars.asAstropy(), 

513 psf_stars_footprints=psf_stars, 

514 psf_stars=psf_stars.asAstropy(), 

515 background=background, 

516 applied_photo_calib=photo_calib, 

517 astrometry_matches=astrometry_matches, 

518 photometry_matches=photometry_matches) 

519 

520 def _handle_snaps(self, exposure): 

521 """Combine two snaps into one exposure, or return a single exposure. 

522 

523 Parameters 

524 ---------- 

525 exposure : `lsst.afw.image.Exposure` or `list` [`lsst.afw.image.Exposure]` 

526 One or two exposures to combine as snaps. 

527 

528 Returns 

529 ------- 

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

531 A single exposure to continue processing. 

532 

533 Raises 

534 ------ 

535 RuntimeError 

536 Raised if input does not contain either 1 or 2 exposures. 

537 """ 

538 if isinstance(exposure, lsst.afw.image.Exposure): 

539 return exposure 

540 

541 if isinstance(exposure, collections.abc.Sequence): 

542 match len(exposure): 

543 case 1: 

544 return exposure[0] 

545 case 2: 

546 return self.snap_combine.run(exposure[0], exposure[1]).exposure 

547 case n: 

548 raise RuntimeError(f"Can only process 1 or 2 snaps, not {n}.") 

549 

550 def _compute_psf(self, exposure, guess_psf=True): 

551 """Find bright sources detected on an exposure and fit a PSF model to 

552 them, repairing likely cosmic rays before detection. 

553 

554 Repair, detect, measure, and compute PSF twice, to ensure the PSF 

555 model does not include contributions from cosmic rays. 

556 

557 Parameters 

558 ---------- 

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

560 Exposure to detect and measure bright stars on. 

561 

562 Returns 

563 ------- 

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

565 Catalog of detected bright sources. 

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

567 Background that was fit to the exposure during detection. 

568 cell_set : `lsst.afw.math.SpatialCellSet` 

569 PSF candidates returned by the psf determiner. 

570 """ 

571 def log_psf(msg): 

572 """Log the parameters of the psf and background, with a prepended 

573 message. 

574 """ 

575 position = exposure.psf.getAveragePosition() 

576 sigma = exposure.psf.computeShape(position).getDeterminantRadius() 

577 dimensions = exposure.psf.computeImage(position).getDimensions() 

578 median_background = np.median(background.getImage().array) 

579 self.log.info("%s sigma=%0.4f, dimensions=%s; median background=%0.2f", 

580 msg, sigma, dimensions, median_background) 

581 

582 self.log.info("First pass detection with Guassian PSF FWHM=%s pixels", 

583 self.config.install_simple_psf.fwhm) 

584 self.install_simple_psf.run(exposure=exposure) 

585 

586 background = self.psf_subtract_background.run(exposure=exposure).background 

587 log_psf("Initial PSF:") 

588 self.psf_repair.run(exposure=exposure, keepCRs=True) 

589 

590 table = afwTable.SourceTable.make(self.psf_schema) 

591 # Re-estimate the background during this detection step, so that 

592 # measurement uses the most accurate background-subtraction. 

593 detections = self.psf_detection.run(table=table, exposure=exposure, background=background) 

594 self.psf_source_measurement.run(detections.sources, exposure) 

595 psf_result = self.psf_measure_psf.run(exposure=exposure, sources=detections.sources) 

596 # Replace the initial PSF with something simpler for the second 

597 # repair/detect/measure/measure_psf step: this can help it converge. 

598 self.install_simple_psf.run(exposure=exposure) 

599 

600 log_psf("Rerunning with simple PSF:") 

601 # TODO investigation: Should we only re-run repair here, to use the 

602 # new PSF? Maybe we *do* need to re-run measurement with PsfFlux, to 

603 # use the fitted PSF? 

604 # TODO investigation: do we need a separate measurement task here 

605 # for the post-psf_measure_psf step, since we only want to do PsfFlux 

606 # and GaussianFlux *after* we have a PSF? Maybe that's not relevant 

607 # once DM-39203 is merged? 

608 self.psf_repair.run(exposure=exposure, keepCRs=True) 

609 # Re-estimate the background during this detection step, so that 

610 # measurement uses the most accurate background-subtraction. 

611 detections = self.psf_detection.run(table=table, exposure=exposure, background=background) 

612 self.psf_source_measurement.run(detections.sources, exposure) 

613 psf_result = self.psf_measure_psf.run(exposure=exposure, sources=detections.sources) 

614 

615 log_psf("Final PSF:") 

616 

617 # Final repair with final PSF, removing cosmic rays this time. 

618 self.psf_repair.run(exposure=exposure) 

619 # Final measurement with the CRs removed. 

620 self.psf_source_measurement.run(detections.sources, exposure) 

621 

622 # PSF is set on exposure; only return candidates for optional saving. 

623 return detections.sources, background, psf_result.cellSet 

624 

625 def _measure_aperture_correction(self, exposure, bright_sources): 

626 """Measure and set the ApCorrMap on the Exposure, using 

627 previously-measured bright sources. 

628 

629 Parameters 

630 ---------- 

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

632 Exposure to set the ApCorrMap on. 

633 bright_sources : `lsst.afw.table.SourceCatalog` 

634 Catalog of detected bright sources; modified to include columns 

635 necessary for point source determination for the aperture correction 

636 calculation. 

637 """ 

638 result = self.measure_aperture_correction.run(exposure, bright_sources) 

639 exposure.setApCorrMap(result.apCorrMap) 

640 

641 def _find_stars(self, exposure, background, id_generator): 

642 """Detect stars on an exposure that has a PSF model, and measure their 

643 PSF, circular aperture, compensated gaussian fluxes. 

644 

645 Parameters 

646 ---------- 

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

648 Exposure to set the ApCorrMap on. 

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

650 Background that was fit to the exposure during detection; 

651 modified in-place during subsequent detection. 

652 id_generator : `lsst.meas.base.IdGenerator` 

653 Object that generates source IDs and provides random seeds. 

654 

655 Returns 

656 ------- 

657 stars : `SourceCatalog` 

658 Sources that are very likely to be stars, with a limited set of 

659 measurements performed on them. 

660 """ 

661 table = afwTable.SourceTable.make(self.initial_stars_schema.schema) 

662 # Re-estimate the background during this detection step, so that 

663 # measurement uses the most accurate background-subtraction. 

664 detections = self.star_detection.run(table=table, exposure=exposure, background=background) 

665 sources = detections.sources 

666 self.star_sky_sources.run(exposure.mask, id_generator.catalog_id, sources) 

667 

668 # Mask streaks 

669 self.star_mask_streaks.run(exposure) 

670 

671 # TODO investigation: Could this deblender throw away blends of non-PSF sources? 

672 self.star_deblend.run(exposure=exposure, sources=sources) 

673 # The deblender may not produce a contiguous catalog; ensure 

674 # contiguity for subsequent tasks. 

675 if not sources.isContiguous(): 

676 sources = sources.copy(deep=True) 

677 

678 # Measure everything, and use those results to select only stars. 

679 self.star_measurement.run(sources, exposure) 

680 self.star_apply_aperture_correction.run(sources, exposure.info.getApCorrMap()) 

681 self.star_catalog_calculation.run(sources) 

682 self.star_set_primary_flags.run(sources) 

683 

684 result = self.star_selector.run(sources) 

685 # The star selector may not produce a contiguous catalog. 

686 if not result.sourceCat.isContiguous(): 

687 return result.sourceCat.copy(deep=True) 

688 else: 

689 return result.sourceCat 

690 

691 def _match_psf_stars(self, psf_stars, stars): 

692 """Match calibration stars to psf stars, to identify which were psf 

693 candidates, and which were used or reserved during psf measurement. 

694 

695 Parameters 

696 ---------- 

697 psf_stars : `lsst.afw.table.SourceCatalog` 

698 PSF candidate stars that were sent to the psf determiner. Used to 

699 populate psf-related flag fields. 

700 stars : `lsst.afw.table.SourceCatalog` 

701 Stars that will be used for calibration; psf-related fields will 

702 be updated in-place. 

703 

704 Notes 

705 ----- 

706 This code was adapted from CalibrateTask.copyIcSourceFields(). 

707 """ 

708 control = afwTable.MatchControl() 

709 # Return all matched objects, to separate blends. 

710 control.findOnlyClosest = False 

711 matches = afwTable.matchXy(psf_stars, stars, 3.0, control) 

712 deblend_key = stars.schema["deblend_nChild"].asKey() 

713 matches = [m for m in matches if m[1].get(deblend_key) == 0] 

714 

715 # Because we had to allow multiple matches to handle parents, we now 

716 # need to prune to the best (closest) matches. 

717 # Closest matches is a dict of psf_stars source ID to Match record 

718 # (psf_stars source, sourceCat source, distance in pixels). 

719 best = {} 

720 for match_psf, match_stars, d in matches: 

721 match = best.get(match_psf.getId()) 

722 if match is None or d <= match[2]: 

723 best[match_psf.getId()] = (match_psf, match_stars, d) 

724 matches = list(best.values()) 

725 # We'll use this to construct index arrays into each catalog. 

726 ids = np.array([(match_psf.getId(), match_stars.getId()) for match_psf, match_stars, d in matches]).T 

727 

728 # Check that no stars sources are listed twice; we already know 

729 # that each match has a unique psf_stars id, due to using as the key 

730 # in best above. 

731 n_matches = len(matches) 

732 n_unique = len(set(m[1].getId() for m in matches)) 

733 if n_unique != n_matches: 

734 self.log.warning("%d psf_stars matched only %d stars; ", 

735 n_matches, n_unique) 

736 

737 # The indices of the IDs, so we can update the flag fields as arrays. 

738 idx_psf_stars = np.searchsorted(psf_stars["id"], ids[0]) 

739 idx_stars = np.searchsorted(stars["id"], ids[1]) 

740 for field in self.psf_fields: 

741 result = np.zeros(len(stars), dtype=bool) 

742 result[idx_stars] = psf_stars[field][idx_psf_stars] 

743 stars[field] = result 

744 

745 def _fit_astrometry(self, exposure, stars): 

746 """Fit an astrometric model to the data and return the reference 

747 matches used in the fit, and the fitted WCS. 

748 

749 Parameters 

750 ---------- 

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

752 Exposure that is being fit, to get PSF and other metadata from. 

753 Modified to add the fitted skyWcs. 

754 stars : `SourceCatalog` 

755 Good stars selected for use in calibration, with RA/Dec coordinates 

756 computed from the pixel positions and fitted WCS. 

757 

758 Returns 

759 ------- 

760 matches : `list` [`lsst.afw.table.ReferenceMatch`] 

761 Reference/stars matches used in the fit. 

762 """ 

763 result = self.astrometry.run(stars, exposure) 

764 return result.matches, result.matchMeta 

765 

766 def _fit_photometry(self, exposure, stars): 

767 """Fit a photometric model to the data and return the reference 

768 matches used in the fit, and the fitted PhotoCalib. 

769 

770 Parameters 

771 ---------- 

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

773 Exposure that is being fit, to get PSF and other metadata from. 

774 Modified to be in nanojanksy units, with an assigned photoCalib 

775 identically 1. 

776 stars : `lsst.afw.table.SourceCatalog` 

777 Good stars selected for use in calibration. 

778 

779 Returns 

780 ------- 

781 calibrated_stars : `lsst.afw.table.SourceCatalog` 

782 Star catalog with flux/magnitude columns computed from the fitted 

783 photoCalib. 

784 matches : `list` [`lsst.afw.table.ReferenceMatch`] 

785 Reference/stars matches used in the fit. 

786 photoCalib : `lsst.afw.image.PhotoCalib` 

787 Photometric calibration that was fit to the star catalog. 

788 """ 

789 result = self.photometry.run(exposure, stars) 

790 calibrated_stars = result.photoCalib.calibrateCatalog(stars) 

791 exposure.maskedImage = result.photoCalib.calibrateImage(exposure.maskedImage) 

792 identity = afwImage.PhotoCalib(1.0, 

793 result.photoCalib.getCalibrationErr(), 

794 bbox=exposure.getBBox()) 

795 exposure.setPhotoCalib(identity) 

796 

797 return calibrated_stars, result.matches, result.matchMeta, result.photoCalib 

798 

799 def _summarize(self, exposure, stars, background): 

800 """Compute summary statistics on the exposure and update in-place the 

801 calibrations attached to it. 

802 

803 Parameters 

804 ---------- 

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

806 Exposure that was calibrated, to get PSF and other metadata from. 

807 Modified to contain the computed summary statistics. 

808 stars : `SourceCatalog` 

809 Good stars selected used in calibration. 

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

811 Background that was fit to the exposure during detection of the 

812 above stars. 

813 """ 

814 # TODO investigation: because this takes the photoCalib from the 

815 # exposure, photometric summary values may be "incorrect" (i.e. they 

816 # will reflect the ==1 nJy calibration on the exposure, not the 

817 # applied calibration). This needs to be checked. 

818 summary = self.compute_summary_stats.run(exposure, stars, background) 

819 exposure.info.setSummaryStats(summary)