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

246 statements  

« prev     ^ index     » next       coverage.py v7.4.1, created at 2024-02-20 12:29 +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.base 

32import lsst.meas.astrom 

33import lsst.meas.deblender 

34import lsst.meas.extensions.shapeHSM 

35import lsst.pex.config as pexConfig 

36import lsst.pipe.base as pipeBase 

37from lsst.pipe.base import connectionTypes 

38from lsst.utils.timer import timeMethod 

39 

40from . import measurePsf, repair, setPrimaryFlags, photoCal, \ 

41 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 # TODO DM-40061: persist a parquet version of this! 

88 stars = connectionTypes.Output( 

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

90 "includes source footprints.", 

91 name="initial_stars_footprints_detector", 

92 storageClass="SourceCatalog", 

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

94 ) 

95 applied_photo_calib = connectionTypes.Output( 

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

97 name="initial_photoCalib_detector", 

98 storageClass="PhotoCalib", 

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

100 ) 

101 background = connectionTypes.Output( 

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

103 name="initial_pvi_background", 

104 storageClass="Background", 

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

106 ) 

107 

108 # Optional outputs 

109 

110 # TODO: We need to decide on what intermediate outputs we want to save, 

111 # and which to save by default. 

112 # TODO DM-40061: persist a parquet version of this! 

113 psf_stars = connectionTypes.Output( 

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

115 "includes source footprints.", 

116 name="initial_psf_stars_footprints", 

117 storageClass="SourceCatalog", 

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

119 ) 

120 astrometry_matches = connectionTypes.Output( 

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

122 name="initial_astrometry_match_detector", 

123 storageClass="Catalog", 

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

125 ) 

126 photometry_matches = connectionTypes.Output( 

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

128 name="initial_photometry_match_detector", 

129 storageClass="Catalog", 

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

131 ) 

132 

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

134 super().__init__(config=config) 

135 if not config.optional_outputs: 

136 self.outputs.remove("psf_stars") 

137 self.outputs.remove("astrometry_matches") 

138 self.outputs.remove("photometry_matches") 

139 

140 

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

142 optional_outputs = pexConfig.ListField( 

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

144 dtype=str, 

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

146 # we always have it on for production runs? 

147 default=["psf_stars", "astrometry_matches", "photometry_matches"], 

148 optional=True 

149 ) 

150 

151 # To generate catalog ids consistently across subtasks. 

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

153 

154 snap_combine = pexConfig.ConfigurableField( 

155 target=snapCombine.SnapCombineTask, 

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

157 ) 

158 

159 # subtasks used during psf characterization 

160 install_simple_psf = pexConfig.ConfigurableField( 

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

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

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

164 ) 

165 psf_repair = pexConfig.ConfigurableField( 

166 target=repair.RepairTask, 

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

168 ) 

169 psf_subtract_background = pexConfig.ConfigurableField( 

170 target=lsst.meas.algorithms.SubtractBackgroundTask, 

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

172 ) 

173 psf_detection = pexConfig.ConfigurableField( 

174 target=lsst.meas.algorithms.SourceDetectionTask, 

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

176 ) 

177 psf_source_measurement = pexConfig.ConfigurableField( 

178 target=lsst.meas.base.SingleFrameMeasurementTask, 

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

180 ) 

181 psf_measure_psf = pexConfig.ConfigurableField( 

182 target=measurePsf.MeasurePsfTask, 

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

184 ) 

185 

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

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

188 measure_aperture_correction = pexConfig.ConfigurableField( 

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

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

191 ) 

192 

193 # subtasks used during star measurement 

194 star_detection = pexConfig.ConfigurableField( 

195 target=lsst.meas.algorithms.SourceDetectionTask, 

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

197 ) 

198 star_sky_sources = pexConfig.ConfigurableField( 

199 target=lsst.meas.algorithms.SkyObjectsTask, 

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

201 ) 

202 star_mask_streaks = pexConfig.ConfigurableField( 

203 target=maskStreaks.MaskStreaksTask, 

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

205 ) 

206 star_deblend = pexConfig.ConfigurableField( 

207 target=lsst.meas.deblender.SourceDeblendTask, 

208 doc="Split blended sources into their components" 

209 ) 

210 star_measurement = pexConfig.ConfigurableField( 

211 target=lsst.meas.base.SingleFrameMeasurementTask, 

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

213 ) 

214 star_apply_aperture_correction = pexConfig.ConfigurableField( 

215 target=lsst.meas.base.ApplyApCorrTask, 

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

217 ) 

218 star_catalog_calculation = pexConfig.ConfigurableField( 

219 target=lsst.meas.base.CatalogCalculationTask, 

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

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

222 ) 

223 star_set_primary_flags = pexConfig.ConfigurableField( 

224 target=setPrimaryFlags.SetPrimaryFlagsTask, 

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

226 ) 

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

228 default="science", 

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

230 ) 

231 

232 # final calibrations and statistics 

233 astrometry = pexConfig.ConfigurableField( 

234 target=lsst.meas.astrom.AstrometryTask, 

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

236 ) 

237 astrometry_ref_loader = pexConfig.ConfigField( 

238 dtype=lsst.meas.algorithms.LoadReferenceObjectsConfig, 

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

240 ) 

241 photometry = pexConfig.ConfigurableField( 

242 target=photoCal.PhotoCalTask, 

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

244 ) 

245 photometry_ref_loader = pexConfig.ConfigField( 

246 dtype=lsst.meas.algorithms.LoadReferenceObjectsConfig, 

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

248 ) 

249 

250 compute_summary_stats = pexConfig.ConfigurableField( 

251 target=computeExposureSummaryStats.ComputeExposureSummaryStatsTask, 

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

253 ) 

254 

255 def setDefaults(self): 

256 super().setDefaults() 

257 

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

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

260 # like CRs for very good seeing images. 

261 self.install_simple_psf.fwhm = 4 

262 

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

264 self.psf_detection.thresholdValue = 5.0 

265 self.psf_detection.includeThresholdMultiplier = 10.0 

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

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

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

269 self.psf_detection.doTempLocalBackground = False 

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

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

272 

273 # Minimal measurement plugins for PSF determination. 

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

275 # shapeHSM/moments for star/galaxy separation. 

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

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

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

279 "base_SdssCentroid", 

280 "ext_shapeHSM_HsmSourceMoments", 

281 "base_CircularApertureFlux", 

282 "base_GaussianFlux", 

283 "base_PsfFlux", 

284 "base_ClassificationSizeExtendedness", 

285 ] 

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

287 # Only measure apertures we need for PSF measurement. 

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

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

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

291 "base_CircularApertureFlux_12_0_instFlux" 

292 

293 # No extendeness information available: we need the aperture 

294 # corrections to determine that. 

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

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

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

298 

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

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

301 self.star_detection.thresholdValue = 5.0 

302 self.star_detection.includeThresholdMultiplier = 2.0 

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

304 "base_SdssCentroid", 

305 "ext_shapeHSM_HsmSourceMoments", 

306 'ext_shapeHSM_HsmPsfMoments', 

307 "base_GaussianFlux", 

308 "base_PsfFlux", 

309 "base_CircularApertureFlux", 

310 "base_ClassificationSizeExtendedness", 

311 ] 

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

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

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

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

316 

317 # Keep track of which footprints contain streaks 

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

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

320 

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

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

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

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

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

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

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

328 # wanted for calibration. 

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

330 

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

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

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

334 self.astrometry_ref_loader.anyFilterMapsToThis = "phot_g_mean" 

335 

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

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

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

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

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

341 

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

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

344 self.compute_summary_stats.starSelection = "calib_photometry_used" 

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

346 

347 

348class CalibrateImageTask(pipeBase.PipelineTask): 

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

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

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

352 

353 Parameters 

354 ---------- 

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

356 Schema of the initial_stars output catalog. 

357 """ 

358 _DefaultName = "calibrateImage" 

359 ConfigClass = CalibrateImageConfig 

360 

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

362 super().__init__(**kwargs) 

363 

364 self.makeSubtask("snap_combine") 

365 

366 # PSF determination subtasks 

367 self.makeSubtask("install_simple_psf") 

368 self.makeSubtask("psf_repair") 

369 self.makeSubtask("psf_subtract_background") 

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

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

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

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

374 

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

376 

377 # star measurement subtasks 

378 if initial_stars_schema is None: 

379 initial_stars_schema = afwTable.SourceTable.makeMinimalSchema() 

380 

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

382 # aperture correction calculations. 

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

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

385 "apcorr_slot_CalibFlux_used", "apcorr_base_GaussianFlux_used", 

386 "apcorr_base_PsfFlux_used") 

387 for field in self.psf_fields: 

388 item = self.psf_schema.find(field) 

389 initial_stars_schema.addField(item.getField()) 

390 

391 afwTable.CoordKey.addErrorFields(initial_stars_schema) 

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

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

394 self.makeSubtask("star_mask_streaks") 

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

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

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

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

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

400 self.makeSubtask("star_selector") 

401 

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

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

404 

405 self.makeSubtask("compute_summary_stats") 

406 

407 # For the butler to persist it. 

408 self.initial_stars_schema = afwTable.SourceCatalog(initial_stars_schema) 

409 

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

411 inputs = butlerQC.get(inputRefs) 

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

413 

414 astrometry_loader = lsst.meas.algorithms.ReferenceObjectLoader( 

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

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

417 name=self.config.connections.astrometry_ref_cat, 

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

419 self.astrometry.setRefObjLoader(astrometry_loader) 

420 

421 photometry_loader = lsst.meas.algorithms.ReferenceObjectLoader( 

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

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

424 name=self.config.connections.photometry_ref_cat, 

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

426 self.photometry.match.setRefObjLoader(photometry_loader) 

427 

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

429 

430 butlerQC.put(outputs, outputRefs) 

431 

432 @timeMethod 

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

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

435 and measurement and calibrate astrometry and photometry from that. 

436 

437 Parameters 

438 ---------- 

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

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

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

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

443 before doing further processing. 

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

445 Object that generates source IDs and provides random seeds. 

446 

447 Returns 

448 ------- 

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

450 Results as a struct with attributes: 

451 

452 ``output_exposure`` 

453 Calibrated exposure, with pixels in nJy units. 

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

455 ``stars`` 

456 Stars that were used to calibrate the exposure, with 

457 calibrated fluxes and magnitudes. 

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

459 ``psf_stars`` 

460 Stars that were used to determine the image PSF. 

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

462 ``background`` 

463 Background that was fit to the exposure when detecting 

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

465 ``applied_photo_calib`` 

466 Photometric calibration that was fit to the star catalog and 

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

468 ``astrometry_matches`` 

469 Reference catalog stars matches used in the astrometric fit. 

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

471 ``photometry_matches`` 

472 Reference catalog stars matches used in the photometric fit. 

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

474 """ 

475 if id_generator is None: 

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

477 

478 exposure = self._handle_snaps(exposures) 

479 

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

481 

482 self._measure_aperture_correction(exposure, psf_stars) 

483 

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

485 

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

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

488 

489 self._summarize(exposure, stars, background) 

490 

491 if self.config.optional_outputs: 

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

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

494 

495 return pipeBase.Struct(output_exposure=exposure, 

496 stars=stars, 

497 psf_stars=psf_stars, 

498 background=background, 

499 applied_photo_calib=photo_calib, 

500 astrometry_matches=astrometry_matches, 

501 photometry_matches=photometry_matches) 

502 

503 def _handle_snaps(self, exposure): 

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

505 

506 Parameters 

507 ---------- 

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

509 One or two exposures to combine as snaps. 

510 

511 Returns 

512 ------- 

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

514 A single exposure to continue processing. 

515 

516 Raises 

517 ------ 

518 RuntimeError 

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

520 """ 

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

522 return exposure 

523 

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

525 match len(exposure): 

526 case 1: 

527 return exposure[0] 

528 case 2: 

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

530 case n: 

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

532 

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

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

535 them, repairing likely cosmic rays before detection. 

536 

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

538 model does not include contributions from cosmic rays. 

539 

540 Parameters 

541 ---------- 

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

543 Exposure to detect and measure bright stars on. 

544 

545 Returns 

546 ------- 

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

548 Catalog of detected bright sources. 

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

550 Background that was fit to the exposure during detection. 

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

552 PSF candidates returned by the psf determiner. 

553 """ 

554 def log_psf(msg): 

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

556 message. 

557 """ 

558 position = exposure.psf.getAveragePosition() 

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

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

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

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

563 msg, sigma, dimensions, median_background) 

564 

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

566 self.config.install_simple_psf.fwhm) 

567 self.install_simple_psf.run(exposure=exposure) 

568 

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

570 log_psf("Initial PSF:") 

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

572 

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

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

575 # measurement uses the most accurate background-subtraction. 

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

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

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

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

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

581 self.install_simple_psf.run(exposure=exposure) 

582 

583 log_psf("Rerunning with simple PSF:") 

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

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

586 # use the fitted PSF? 

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

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

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

590 # once DM-39203 is merged? 

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

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

593 # measurement uses the most accurate background-subtraction. 

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

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

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

597 

598 log_psf("Final PSF:") 

599 

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

601 self.psf_repair.run(exposure=exposure) 

602 # Final measurement with the CRs removed. 

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

604 

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

606 return detections.sources, background, psf_result.cellSet 

607 

608 def _measure_aperture_correction(self, exposure, bright_sources): 

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

610 previously-measured bright sources. 

611 

612 Parameters 

613 ---------- 

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

615 Exposure to set the ApCorrMap on. 

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

617 Catalog of detected bright sources; modified to include columns 

618 necessary for point source determination for the aperture correction 

619 calculation. 

620 """ 

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

622 exposure.setApCorrMap(result.apCorrMap) 

623 

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

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

626 PSF, circular aperture, compensated gaussian fluxes. 

627 

628 Parameters 

629 ---------- 

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

631 Exposure to set the ApCorrMap on. 

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

633 Background that was fit to the exposure during detection; 

634 modified in-place during subsequent detection. 

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

636 Object that generates source IDs and provides random seeds. 

637 

638 Returns 

639 ------- 

640 stars : `SourceCatalog` 

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

642 measurements performed on them. 

643 """ 

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

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

646 # measurement uses the most accurate background-subtraction. 

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

648 sources = detections.sources 

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

650 

651 # Mask streaks 

652 self.star_mask_streaks.run(exposure) 

653 

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

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

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

657 # contiguity for subsequent tasks. 

658 if not sources.isContiguous(): 

659 sources = sources.copy(deep=True) 

660 

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

662 self.star_measurement.run(sources, exposure) 

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

664 self.star_catalog_calculation.run(sources) 

665 self.star_set_primary_flags.run(sources) 

666 

667 result = self.star_selector.run(sources) 

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

669 if not result.sourceCat.isContiguous(): 

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

671 else: 

672 return result.sourceCat 

673 

674 def _match_psf_stars(self, psf_stars, stars): 

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

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

677 

678 Parameters 

679 ---------- 

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

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

682 populate psf-related flag fields. 

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

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

685 be updated in-place. 

686 

687 Notes 

688 ----- 

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

690 """ 

691 control = afwTable.MatchControl() 

692 # Return all matched objects, to separate blends. 

693 control.findOnlyClosest = False 

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

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

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

697 

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

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

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

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

702 best = {} 

703 for match0, match1, d in matches: 

704 id0 = match0.getId() 

705 match = best.get(id0) 

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

707 best[id0] = (match0, match1, d) 

708 matches = list(best.values()) 

709 ids = np.array([(match0.getId(), match1.getId()) for match0, match1, d in matches]).T 

710 

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

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

713 # in best above. 

714 n_matches = len(matches) 

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

716 if n_unique != n_matches: 

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

718 n_matches, n_unique) 

719 

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

721 idx0 = np.searchsorted(psf_stars["id"], ids[0]) 

722 idx1 = np.searchsorted(stars["id"], ids[1]) 

723 for field in self.psf_fields: 

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

725 result[idx0] = psf_stars[field][idx1] 

726 stars[field] = result 

727 

728 def _fit_astrometry(self, exposure, stars): 

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

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

731 

732 Parameters 

733 ---------- 

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

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

736 Modified to add the fitted skyWcs. 

737 stars : `SourceCatalog` 

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

739 computed from the pixel positions and fitted WCS. 

740 

741 Returns 

742 ------- 

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

744 Reference/stars matches used in the fit. 

745 """ 

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

747 return result.matches, result.matchMeta 

748 

749 def _fit_photometry(self, exposure, stars): 

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

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

752 

753 Parameters 

754 ---------- 

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

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

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

758 identically 1. 

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

760 Good stars selected for use in calibration. 

761 

762 Returns 

763 ------- 

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

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

766 photoCalib. 

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

768 Reference/stars matches used in the fit. 

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

770 Photometric calibration that was fit to the star catalog. 

771 """ 

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

773 calibrated_stars = result.photoCalib.calibrateCatalog(stars) 

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

775 identity = afwImage.PhotoCalib(1.0, 

776 result.photoCalib.getCalibrationErr(), 

777 bbox=exposure.getBBox()) 

778 exposure.setPhotoCalib(identity) 

779 

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

781 

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

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

784 calibrations attached to it. 

785 

786 Parameters 

787 ---------- 

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

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

790 Modified to contain the computed summary statistics. 

791 stars : `SourceCatalog` 

792 Good stars selected used in calibration. 

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

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

795 above stars. 

796 """ 

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

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

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

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

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

802 exposure.info.setSummaryStats(summary)