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

249 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-03-20 13:15 +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, 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_deblend = pexConfig.ConfigurableField( 

211 target=lsst.meas.deblender.SourceDeblendTask, 

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

213 ) 

214 star_measurement = pexConfig.ConfigurableField( 

215 target=lsst.meas.base.SingleFrameMeasurementTask, 

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

217 ) 

218 star_apply_aperture_correction = pexConfig.ConfigurableField( 

219 target=lsst.meas.base.ApplyApCorrTask, 

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

221 ) 

222 star_catalog_calculation = pexConfig.ConfigurableField( 

223 target=lsst.meas.base.CatalogCalculationTask, 

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

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

226 ) 

227 star_set_primary_flags = pexConfig.ConfigurableField( 

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

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

230 ) 

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

232 default="science", 

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

234 ) 

235 

236 # final calibrations and statistics 

237 astrometry = pexConfig.ConfigurableField( 

238 target=lsst.meas.astrom.AstrometryTask, 

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

240 ) 

241 astrometry_ref_loader = pexConfig.ConfigField( 

242 dtype=lsst.meas.algorithms.LoadReferenceObjectsConfig, 

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

244 ) 

245 photometry = pexConfig.ConfigurableField( 

246 target=photoCal.PhotoCalTask, 

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

248 ) 

249 photometry_ref_loader = pexConfig.ConfigField( 

250 dtype=lsst.meas.algorithms.LoadReferenceObjectsConfig, 

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

252 ) 

253 

254 compute_summary_stats = pexConfig.ConfigurableField( 

255 target=computeExposureSummaryStats.ComputeExposureSummaryStatsTask, 

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

257 ) 

258 

259 def setDefaults(self): 

260 super().setDefaults() 

261 

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

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

264 # like CRs for very good seeing images. 

265 self.install_simple_psf.fwhm = 4 

266 

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

268 self.psf_detection.thresholdValue = 5.0 

269 self.psf_detection.includeThresholdMultiplier = 10.0 

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

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

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

273 self.psf_detection.doTempLocalBackground = False 

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

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

276 

277 # Minimal measurement plugins for PSF determination. 

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

279 # shapeHSM/moments for star/galaxy separation. 

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

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

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

283 "base_SdssCentroid", 

284 "ext_shapeHSM_HsmSourceMoments", 

285 "base_CircularApertureFlux", 

286 "base_GaussianFlux", 

287 "base_PsfFlux", 

288 "base_ClassificationSizeExtendedness", 

289 ] 

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

291 # Only measure apertures we need for PSF measurement. 

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

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

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

295 "base_CircularApertureFlux_12_0_instFlux" 

296 

297 # No extendeness information available: we need the aperture 

298 # corrections to determine that. 

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

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

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

302 

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

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

305 self.star_detection.thresholdValue = 5.0 

306 self.star_detection.includeThresholdMultiplier = 2.0 

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

308 "base_SdssCentroid", 

309 "ext_shapeHSM_HsmSourceMoments", 

310 'ext_shapeHSM_HsmPsfMoments', 

311 "base_GaussianFlux", 

312 "base_PsfFlux", 

313 "base_CircularApertureFlux", 

314 "base_ClassificationSizeExtendedness", 

315 ] 

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

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

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

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

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_deblend", schema=initial_stars_schema) 

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

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

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

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

399 self.makeSubtask("star_selector") 

400 

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

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

403 

404 self.makeSubtask("compute_summary_stats") 

405 

406 # For the butler to persist it. 

407 self.initial_stars_schema = afwTable.SourceCatalog(initial_stars_schema) 

408 

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

410 inputs = butlerQC.get(inputRefs) 

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

412 

413 astrometry_loader = lsst.meas.algorithms.ReferenceObjectLoader( 

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

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

416 name=self.config.connections.astrometry_ref_cat, 

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

418 self.astrometry.setRefObjLoader(astrometry_loader) 

419 

420 photometry_loader = lsst.meas.algorithms.ReferenceObjectLoader( 

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

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

423 name=self.config.connections.photometry_ref_cat, 

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

425 self.photometry.match.setRefObjLoader(photometry_loader) 

426 

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

428 

429 butlerQC.put(outputs, outputRefs) 

430 

431 @timeMethod 

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

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

434 and measurement and calibrate astrometry and photometry from that. 

435 

436 Parameters 

437 ---------- 

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

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

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

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

442 before doing further processing. 

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

444 Object that generates source IDs and provides random seeds. 

445 

446 Returns 

447 ------- 

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

449 Results as a struct with attributes: 

450 

451 ``output_exposure`` 

452 Calibrated exposure, with pixels in nJy units. 

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

454 ``stars`` 

455 Stars that were used to calibrate the exposure, with 

456 calibrated fluxes and magnitudes. 

457 (`astropy.table.Table`) 

458 ``stars_footprints`` 

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

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

461 ``psf_stars`` 

462 Stars that were used to determine the image PSF. 

463 (`astropy.table.Table`) 

464 ``psf_stars_footprints`` 

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

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

467 ``background`` 

468 Background that was fit to the exposure when detecting 

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

470 ``applied_photo_calib`` 

471 Photometric calibration that was fit to the star catalog and 

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

473 ``astrometry_matches`` 

474 Reference catalog stars matches used in the astrometric fit. 

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

476 ``photometry_matches`` 

477 Reference catalog stars matches used in the photometric fit. 

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

479 """ 

480 if id_generator is None: 

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

482 

483 exposure = self._handle_snaps(exposures) 

484 

485 # TODO remove on DM-43083: work around the fact that we don't want 

486 # to run streak detection in this task in production. 

487 exposure.mask.addMaskPlane("STREAK") 

488 

489 psf_stars, background, candidates = self._compute_psf(exposure, id_generator) 

490 

491 self._measure_aperture_correction(exposure, psf_stars) 

492 

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

494 self._match_psf_stars(psf_stars, stars) 

495 

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

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

498 

499 self._summarize(exposure, stars, background) 

500 

501 if self.config.optional_outputs: 

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

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

504 

505 return pipeBase.Struct(output_exposure=exposure, 

506 stars_footprints=stars, 

507 stars=stars.asAstropy(), 

508 psf_stars_footprints=psf_stars, 

509 psf_stars=psf_stars.asAstropy(), 

510 background=background, 

511 applied_photo_calib=photo_calib, 

512 astrometry_matches=astrometry_matches, 

513 photometry_matches=photometry_matches) 

514 

515 def _handle_snaps(self, exposure): 

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

517 

518 Parameters 

519 ---------- 

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

521 One or two exposures to combine as snaps. 

522 

523 Returns 

524 ------- 

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

526 A single exposure to continue processing. 

527 

528 Raises 

529 ------ 

530 RuntimeError 

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

532 """ 

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

534 return exposure 

535 

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

537 match len(exposure): 

538 case 1: 

539 return exposure[0] 

540 case 2: 

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

542 case n: 

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

544 

545 def _compute_psf(self, exposure, id_generator): 

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

547 them, repairing likely cosmic rays before detection. 

548 

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

550 model does not include contributions from cosmic rays. 

551 

552 Parameters 

553 ---------- 

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

555 Exposure to detect and measure bright stars on. 

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

557 Object that generates source IDs and provides random seeds. 

558 

559 Returns 

560 ------- 

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

562 Catalog of detected bright sources. 

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

564 Background that was fit to the exposure during detection. 

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

566 PSF candidates returned by the psf determiner. 

567 """ 

568 def log_psf(msg): 

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

570 message. 

571 """ 

572 position = exposure.psf.getAveragePosition() 

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

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

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

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

577 msg, sigma, dimensions, median_background) 

578 

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

580 self.config.install_simple_psf.fwhm) 

581 self.install_simple_psf.run(exposure=exposure) 

582 

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

584 log_psf("Initial PSF:") 

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

586 

587 table = afwTable.SourceTable.make(self.psf_schema, id_generator.make_table_id_factory()) 

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

589 # measurement uses the most accurate background-subtraction. 

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

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

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

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

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

595 self.install_simple_psf.run(exposure=exposure) 

596 

597 log_psf("Rerunning with simple PSF:") 

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

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

600 # use the fitted PSF? 

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

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

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

604 # once DM-39203 is merged? 

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

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

607 # measurement uses the most accurate background-subtraction. 

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

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

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

611 

612 log_psf("Final PSF:") 

613 

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

615 self.psf_repair.run(exposure=exposure) 

616 # Final measurement with the CRs removed. 

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

618 

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

620 return detections.sources, background, psf_result.cellSet 

621 

622 def _measure_aperture_correction(self, exposure, bright_sources): 

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

624 previously-measured bright sources. 

625 

626 Parameters 

627 ---------- 

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

629 Exposure to set the ApCorrMap on. 

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

631 Catalog of detected bright sources; modified to include columns 

632 necessary for point source determination for the aperture correction 

633 calculation. 

634 """ 

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

636 exposure.setApCorrMap(result.apCorrMap) 

637 

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

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

640 PSF, circular aperture, compensated gaussian fluxes. 

641 

642 Parameters 

643 ---------- 

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

645 Exposure to set the ApCorrMap on. 

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

647 Background that was fit to the exposure during detection; 

648 modified in-place during subsequent detection. 

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

650 Object that generates source IDs and provides random seeds. 

651 

652 Returns 

653 ------- 

654 stars : `SourceCatalog` 

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

656 measurements performed on them. 

657 """ 

658 table = afwTable.SourceTable.make(self.initial_stars_schema.schema, 

659 id_generator.make_table_id_factory()) 

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

661 # measurement uses the most accurate background-subtraction. 

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

663 sources = detections.sources 

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

665 

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

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

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

669 # contiguity for subsequent tasks. 

670 if not sources.isContiguous(): 

671 sources = sources.copy(deep=True) 

672 

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

674 self.star_measurement.run(sources, exposure) 

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

676 self.star_catalog_calculation.run(sources) 

677 self.star_set_primary_flags.run(sources) 

678 

679 result = self.star_selector.run(sources) 

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

681 if not result.sourceCat.isContiguous(): 

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

683 else: 

684 return result.sourceCat 

685 

686 def _match_psf_stars(self, psf_stars, stars): 

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

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

689 

690 Parameters 

691 ---------- 

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

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

694 populate psf-related flag fields. 

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

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

697 be updated in-place. 

698 

699 Notes 

700 ----- 

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

702 """ 

703 control = afwTable.MatchControl() 

704 # Return all matched objects, to separate blends. 

705 control.findOnlyClosest = False 

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

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

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

709 

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

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

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

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

714 best = {} 

715 for match_psf, match_stars, d in matches: 

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

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

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

719 matches = list(best.values()) 

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

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

722 

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

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

725 # in best above. 

726 n_matches = len(matches) 

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

728 if n_unique != n_matches: 

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

730 n_matches, n_unique) 

731 if n_matches == 0: 

732 msg = (f"0 psf_stars out of {len(psf_stars)} matched {len(stars)} calib stars." 

733 " Downstream processes probably won't have useful stars in this case." 

734 " Is `star_source_selector` too strict?") 

735 # TODO DM-39842: Turn this into an AlgorithmicError. 

736 raise RuntimeError(msg) 

737 

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

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

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

741 for field in self.psf_fields: 

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

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

744 stars[field] = result 

745 

746 def _fit_astrometry(self, exposure, stars): 

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

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

749 

750 Parameters 

751 ---------- 

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

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

754 Modified to add the fitted skyWcs. 

755 stars : `SourceCatalog` 

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

757 computed from the pixel positions and fitted WCS. 

758 

759 Returns 

760 ------- 

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

762 Reference/stars matches used in the fit. 

763 """ 

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

765 return result.matches, result.matchMeta 

766 

767 def _fit_photometry(self, exposure, stars): 

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

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

770 

771 Parameters 

772 ---------- 

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

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

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

776 identically 1. 

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

778 Good stars selected for use in calibration. 

779 

780 Returns 

781 ------- 

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

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

784 photoCalib. 

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

786 Reference/stars matches used in the fit. 

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

788 Photometric calibration that was fit to the star catalog. 

789 """ 

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

791 calibrated_stars = result.photoCalib.calibrateCatalog(stars) 

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

793 identity = afwImage.PhotoCalib(1.0, 

794 result.photoCalib.getCalibrationErr(), 

795 bbox=exposure.getBBox()) 

796 exposure.setPhotoCalib(identity) 

797 

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

799 

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

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

802 calibrations attached to it. 

803 

804 Parameters 

805 ---------- 

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

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

808 Modified to contain the computed summary statistics. 

809 stars : `SourceCatalog` 

810 Good stars selected used in calibration. 

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

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

813 above stars. 

814 """ 

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

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

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

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

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

820 exposure.info.setSummaryStats(summary)