Coverage for python/lsst/meas/astrom/astrometry.py: 22%

134 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-05-04 02:55 -0700

1# This file is part of meas_astrom. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <https://www.gnu.org/licenses/>. 

21 

22__all__ = ["AstrometryConfig", "AstrometryTask"] 

23 

24import numpy as np 

25from astropy import units 

26import scipy.stats 

27 

28import lsst.pex.config as pexConfig 

29import lsst.pipe.base as pipeBase 

30from lsst.utils.timer import timeMethod 

31from . import exceptions 

32from .ref_match import RefMatchTask, RefMatchConfig 

33from .fitTanSipWcs import FitTanSipWcsTask 

34from .display import displayAstrometry 

35 

36 

37class AstrometryConfig(RefMatchConfig): 

38 """Config for AstrometryTask. 

39 """ 

40 wcsFitter = pexConfig.ConfigurableField( 

41 target=FitTanSipWcsTask, 

42 doc="WCS fitter", 

43 ) 

44 forceKnownWcs = pexConfig.Field( 

45 dtype=bool, 

46 doc="If True then load reference objects and match sources but do not fit a WCS; " 

47 "this simply controls whether 'run' calls 'solve' or 'loadAndMatch'", 

48 default=False, 

49 ) 

50 maxIter = pexConfig.RangeField( 

51 doc="maximum number of iterations of match sources and fit WCS" 

52 "ignored if not fitting a WCS", 

53 dtype=int, 

54 default=3, 

55 min=1, 

56 ) 

57 minMatchDistanceArcSec = pexConfig.RangeField( 

58 doc="the match distance below which further iteration is pointless (arcsec); " 

59 "ignored if not fitting a WCS", 

60 dtype=float, 

61 default=0.001, 

62 min=0, 

63 ) 

64 maxMeanDistanceArcsec = pexConfig.RangeField( 

65 doc="Maximum mean on-sky distance (in arcsec) between matched source and reference " 

66 "objects post-fit. A mean distance greater than this threshold raises BadAstrometryFit " 

67 "and the WCS fit is considered a failure. The default is set to the maximum tolerated " 

68 "by the external global calibration (e.g. jointcal) step for conceivable recovery; " 

69 "the appropriate value will be dataset and workflow dependent.", 

70 dtype=float, 

71 default=0.5, 

72 min=0, 

73 ) 

74 doMagnitudeOutlierRejection = pexConfig.Field( 

75 dtype=bool, 

76 doc=("If True then a rough zeropoint will be computed from matched sources " 

77 "and outliers will be rejected in the iterations."), 

78 default=True, 

79 ) 

80 magnitudeOutlierRejectionNSigma = pexConfig.Field( 

81 dtype=float, 

82 doc=("Number of sigma (measured from the distribution) in magnitude " 

83 "for a potential reference/source match to be rejected during " 

84 "iteration."), 

85 default=3.0, 

86 ) 

87 

88 def setDefaults(self): 

89 # Override the default source selector for astrometry tasks 

90 self.sourceFluxType = "Psf" 

91 # Configured to match the deprecated "matcher" selector: isolated, 

92 # SN > 40, some bad flags, valid centroids. 

93 self.sourceSelector["science"].doSignalToNoise = True 

94 self.sourceSelector["science"].signalToNoise.minimum = 40 

95 self.sourceSelector["science"].signalToNoise.fluxField = f"slot_{self.sourceFluxType}Flux_instFlux" 

96 self.sourceSelector["science"].signalToNoise.errField = f"slot_{self.sourceFluxType}Flux_instFluxErr" 

97 self.sourceSelector["science"].doFlags = True 

98 self.sourceSelector["science"].flags.bad = ["base_PixelFlags_flag_edge", 

99 "base_PixelFlags_flag_interpolatedCenter", 

100 "base_PixelFlags_flag_saturated", 

101 "base_SdssCentroid_flag", 

102 ] 

103 self.sourceSelector["science"].doRequirePrimary = True 

104 self.sourceSelector["science"].doIsolated = True 

105 

106 

107class AstrometryTask(RefMatchTask): 

108 """Match an input source catalog with objects from a reference catalog and 

109 solve for the WCS. 

110 

111 This task is broken into two main subasks: matching and WCS fitting which 

112 are very interactive. The matching here can be considered in part a first 

113 pass WCS fitter due to the fitter's sensitivity to outliers. 

114 

115 Parameters 

116 ---------- 

117 refObjLoader : `lsst.meas.algorithms.ReferenceLoader` 

118 A reference object loader object; gen3 pipeline tasks will pass `None` 

119 and call `setRefObjLoader` in `runQuantum`. 

120 schema : `lsst.afw.table.Schema` 

121 Used to set "calib_astrometry_used" flag in output source catalog. 

122 **kwargs 

123 Additional keyword arguments for pipe_base 

124 `lsst.pipe.base.Task.__init__`. 

125 """ 

126 ConfigClass = AstrometryConfig 

127 _DefaultName = "astrometricSolver" 

128 

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

130 RefMatchTask.__init__(self, refObjLoader=refObjLoader, **kwargs) 

131 

132 if schema is not None: 

133 self.usedKey = schema.addField("calib_astrometry_used", type="Flag", 

134 doc="set if source was used in astrometric calibration") 

135 else: 

136 self.usedKey = None 

137 

138 self.makeSubtask("wcsFitter") 

139 

140 @timeMethod 

141 def run(self, sourceCat, exposure): 

142 """Load reference objects, match sources and optionally fit a WCS. 

143 

144 This is a thin layer around solve or loadAndMatch, depending on 

145 config.forceKnownWcs. 

146 

147 Parameters 

148 ---------- 

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

150 exposure whose WCS is to be fit 

151 The following are read only: 

152 

153 - bbox 

154 - filter (may be unset) 

155 - detector (if wcs is pure tangent; may be absent) 

156 

157 The following are updated: 

158 

159 - wcs (the initial value is used as an initial guess, and is 

160 required) 

161 

162 sourceCat : `lsst.afw.table.SourceCatalog` 

163 catalog of sources detected on the exposure 

164 

165 Returns 

166 ------- 

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

168 with these fields: 

169 

170 - ``refCat`` : reference object catalog of objects that overlap the 

171 exposure (with some margin) (`lsst.afw.table.SimpleCatalog`). 

172 - ``matches`` : astrometric matches 

173 (`list` of `lsst.afw.table.ReferenceMatch`). 

174 - ``scatterOnSky`` : median on-sky separation between reference 

175 objects and sources in "matches" 

176 (`lsst.afw.geom.Angle`) or `None` if config.forceKnownWcs True 

177 - ``matchMeta`` : metadata needed to unpersist matches 

178 (`lsst.daf.base.PropertyList`) 

179 """ 

180 if self.refObjLoader is None: 

181 raise RuntimeError("Running matcher task with no refObjLoader set in __init__ or setRefObjLoader") 

182 if self.config.forceKnownWcs: 

183 res = self.loadAndMatch(exposure=exposure, sourceCat=sourceCat) 

184 res.scatterOnSky = None 

185 else: 

186 res = self.solve(exposure=exposure, sourceCat=sourceCat) 

187 return res 

188 

189 @timeMethod 

190 def solve(self, exposure, sourceCat): 

191 """Load reference objects overlapping an exposure, match to sources and 

192 fit a WCS 

193 

194 Returns 

195 ------- 

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

197 Result struct with components: 

198 

199 - ``refCat`` : reference object catalog of objects that overlap the 

200 exposure (with some margin) (`lsst::afw::table::SimpleCatalog`). 

201 - ``matches`` : astrometric matches 

202 (`list` of `lsst.afw.table.ReferenceMatch`). 

203 - ``scatterOnSky`` : median on-sky separation between reference 

204 objects and sources in "matches" (`lsst.geom.Angle`) 

205 - ``matchMeta`` : metadata needed to unpersist matches 

206 (`lsst.daf.base.PropertyList`) 

207 

208 Raises 

209 ------ 

210 BadAstrometryFit 

211 If the measured mean on-sky distance between the matched source and 

212 reference objects is greater than 

213 ``self.config.maxMeanDistanceArcsec``. 

214 

215 Notes 

216 ----- 

217 ignores config.forceKnownWcs 

218 """ 

219 if self.refObjLoader is None: 

220 raise RuntimeError("Running matcher task with no refObjLoader set in __init__ or setRefObjLoader") 

221 import lsstDebug 

222 debug = lsstDebug.Info(__name__) 

223 

224 epoch = exposure.visitInfo.date.toAstropy() 

225 

226 sourceSelection = self.sourceSelector.run(sourceCat) 

227 

228 self.log.info("Purged %d sources, leaving %d good sources", 

229 len(sourceCat) - len(sourceSelection.sourceCat), 

230 len(sourceSelection.sourceCat)) 

231 

232 loadResult = self.refObjLoader.loadPixelBox( 

233 bbox=exposure.getBBox(), 

234 wcs=exposure.wcs, 

235 filterName=exposure.filter.bandLabel, 

236 epoch=epoch, 

237 ) 

238 

239 refSelection = self.referenceSelector.run(loadResult.refCat) 

240 

241 if debug.display: 

242 frame = int(debug.frame) 

243 displayAstrometry( 

244 refCat=refSelection.sourceCat, 

245 sourceCat=sourceSelection.sourceCat, 

246 exposure=exposure, 

247 bbox=exposure.getBBox(), 

248 frame=frame, 

249 title="Reference catalog", 

250 ) 

251 

252 result = pipeBase.Struct(matchTolerance=None) 

253 maxMatchDistance = np.inf 

254 i = 0 

255 while (maxMatchDistance > self.config.minMatchDistanceArcSec and i < self.config.maxIter): 

256 i += 1 

257 try: 

258 result = self._matchAndFitWcs( 

259 refCat=refSelection.sourceCat, 

260 sourceCat=sourceCat, 

261 goodSourceCat=sourceSelection.sourceCat, 

262 refFluxField=loadResult.fluxField, 

263 bbox=exposure.getBBox(), 

264 wcs=exposure.wcs, 

265 exposure=exposure, 

266 matchTolerance=result.matchTolerance, 

267 ) 

268 exposure.setWcs(result.wcs) 

269 except exceptions.AstrometryError as e: 

270 e._metadata['iterations'] = i 

271 sourceCat["coord_ra"] = np.nan 

272 sourceCat["coord_dec"] = np.nan 

273 exposure.setWcs(None) 

274 self.log.error("Failure fitting astrometry. %s: %s", type(e).__name__, e) 

275 raise 

276 

277 result.stats = self._computeMatchStatsOnSky(result.matches) 

278 maxMatchDistance = result.stats.maxMatchDist.asArcseconds() 

279 distMean = result.stats.distMean.asArcseconds() 

280 distStdDev = result.stats.distMean.asArcseconds() 

281 self.log.info("Astrometric fit iteration %d: found %d matches with mean separation " 

282 "= %0.3f +- %0.3f arcsec; max match distance = %0.3f arcsec.", 

283 i, len(result.matches), distMean, distStdDev, maxMatchDistance) 

284 

285 # If fitter converged, record the scatter in the exposure metadata 

286 # even if the fit was deemed a failure according to the value of 

287 # the maxMeanDistanceArcsec config. 

288 md = exposure.getMetadata() 

289 md['SFM_ASTROM_OFFSET_MEAN'] = distMean 

290 md['SFM_ASTROM_OFFSET_STD'] = distStdDev 

291 

292 # Poor quality fits are a failure. 

293 if distMean > self.config.maxMeanDistanceArcsec: 

294 exception = exceptions.BadAstrometryFit(nMatches=len(result.matches), iterations=i, 

295 distMean=distMean, 

296 maxMeanDist=self.config.maxMeanDistanceArcsec, 

297 distMedian=result.scatterOnSky.asArcseconds()) 

298 exposure.setWcs(None) 

299 sourceCat["coord_ra"] = np.nan 

300 sourceCat["coord_dec"] = np.nan 

301 self.log.error(exception) 

302 raise exception 

303 

304 if self.usedKey: 

305 for m in result.matches: 

306 m.second.set(self.usedKey, True) 

307 

308 matchMeta = self.refObjLoader.getMetadataBox( 

309 bbox=exposure.getBBox(), 

310 wcs=exposure.wcs, 

311 filterName=exposure.filter.bandLabel, 

312 epoch=epoch, 

313 ) 

314 

315 return pipeBase.Struct( 

316 refCat=refSelection.sourceCat, 

317 matches=result.matches, 

318 scatterOnSky=result.scatterOnSky, 

319 matchMeta=matchMeta, 

320 ) 

321 

322 @timeMethod 

323 def _matchAndFitWcs(self, refCat, sourceCat, goodSourceCat, refFluxField, bbox, wcs, matchTolerance, 

324 exposure=None): 

325 """Match sources to reference objects and fit a WCS. 

326 

327 Parameters 

328 ---------- 

329 refCat : `lsst.afw.table.SimpleCatalog` 

330 catalog of reference objects 

331 sourceCat : `lsst.afw.table.SourceCatalog` 

332 catalog of sources detected on the exposure 

333 goodSourceCat : `lsst.afw.table.SourceCatalog` 

334 catalog of down-selected good sources detected on the exposure 

335 refFluxField : 'str' 

336 field of refCat to use for flux 

337 bbox : `lsst.geom.Box2I` 

338 bounding box of exposure 

339 wcs : `lsst.afw.geom.SkyWcs` 

340 initial guess for WCS of exposure 

341 matchTolerance : `lsst.meas.astrom.MatchTolerance` 

342 a MatchTolerance object (or None) specifying 

343 internal tolerances to the matcher. See the MatchTolerance 

344 definition in the respective matcher for the class definition. 

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

346 exposure whose WCS is to be fit, or None; used only for the debug 

347 display. 

348 

349 Returns 

350 ------- 

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

352 Result struct with components: 

353 

354 - ``matches``: astrometric matches 

355 (`list` of `lsst.afw.table.ReferenceMatch`). 

356 - ``wcs``: the fit WCS (lsst.afw.geom.SkyWcs). 

357 - ``scatterOnSky`` : median on-sky separation between reference 

358 objects and sources in "matches" (`lsst.afw.geom.Angle`). 

359 """ 

360 import lsstDebug 

361 debug = lsstDebug.Info(__name__) 

362 

363 sourceFluxField = "slot_%sFlux_instFlux" % (self.config.sourceFluxType) 

364 

365 matchRes = self.matcher.matchObjectsToSources( 

366 refCat=refCat, 

367 sourceCat=goodSourceCat, 

368 wcs=wcs, 

369 sourceFluxField=sourceFluxField, 

370 refFluxField=refFluxField, 

371 matchTolerance=matchTolerance, 

372 ) 

373 self.log.debug("Found %s matches", len(matchRes.matches)) 

374 if debug.display: 

375 frame = int(debug.frame) 

376 displayAstrometry( 

377 refCat=refCat, 

378 sourceCat=matchRes.usableSourceCat, 

379 matches=matchRes.matches, 

380 exposure=exposure, 

381 bbox=bbox, 

382 frame=frame + 1, 

383 title="Initial WCS", 

384 ) 

385 

386 if self.config.doMagnitudeOutlierRejection: 

387 matches = self._removeMagnitudeOutliers(sourceFluxField, refFluxField, matchRes.matches) 

388 else: 

389 matches = matchRes.matches 

390 

391 self.log.debug("Fitting WCS") 

392 fitRes = self.wcsFitter.fitWcs( 

393 matches=matches, 

394 initWcs=wcs, 

395 bbox=bbox, 

396 refCat=refCat, 

397 sourceCat=sourceCat, 

398 exposure=exposure, 

399 ) 

400 fitWcs = fitRes.wcs 

401 scatterOnSky = fitRes.scatterOnSky 

402 if debug.display: 

403 frame = int(debug.frame) 

404 displayAstrometry( 

405 refCat=refCat, 

406 sourceCat=matchRes.usableSourceCat, 

407 matches=matches, 

408 exposure=exposure, 

409 bbox=bbox, 

410 frame=frame + 2, 

411 title=f"Fitter: {self.wcsFitter._DefaultName}", 

412 ) 

413 

414 return pipeBase.Struct( 

415 matches=matches, 

416 wcs=fitWcs, 

417 scatterOnSky=scatterOnSky, 

418 matchTolerance=matchRes.matchTolerance, 

419 ) 

420 

421 def _removeMagnitudeOutliers(self, sourceFluxField, refFluxField, matchesIn): 

422 """Remove magnitude outliers, computing a simple zeropoint. 

423 

424 Parameters 

425 ---------- 

426 sourceFluxField : `str` 

427 Field in source catalog for instrumental fluxes. 

428 refFluxField : `str` 

429 Field in reference catalog for fluxes (nJy). 

430 matchesIn : `list` [`lsst.afw.table.ReferenceMatch`] 

431 List of source/reference matches input 

432 

433 Returns 

434 ------- 

435 matchesOut : `list` [`lsst.afw.table.ReferenceMatch`] 

436 List of source/reference matches with magnitude 

437 outliers removed. 

438 """ 

439 nMatch = len(matchesIn) 

440 sourceMag = np.zeros(nMatch) 

441 refMag = np.zeros(nMatch) 

442 for i, match in enumerate(matchesIn): 

443 sourceMag[i] = -2.5*np.log10(match[1][sourceFluxField]) 

444 refMag[i] = (match[0][refFluxField]*units.nJy).to_value(units.ABmag) 

445 

446 deltaMag = refMag - sourceMag 

447 # Protect against negative fluxes and nans in the reference catalog. 

448 goodDelta, = np.where(np.isfinite(deltaMag)) 

449 zp = np.median(deltaMag[goodDelta]) 

450 # Use median absolute deviation (MAD) for zpSigma. 

451 # Also require a minimum scatter to prevent floating-point errors from 

452 # rejecting objects in zero-noise tests. 

453 zpSigma = np.clip(scipy.stats.median_abs_deviation(deltaMag[goodDelta], scale='normal'), 

454 1e-3, 

455 None) 

456 

457 self.log.info("Rough zeropoint from astrometry matches is %.4f +/- %.4f.", 

458 zp, zpSigma) 

459 

460 goodStars = goodDelta[(np.abs(deltaMag[goodDelta] - zp) 

461 <= self.config.magnitudeOutlierRejectionNSigma*zpSigma)] 

462 

463 nOutlier = nMatch - goodStars.size 

464 self.log.info("Removed %d magnitude outliers out of %d total astrometry matches.", 

465 nOutlier, nMatch) 

466 

467 matchesOut = [matchesIn[idx] for idx in goodStars] 

468 

469 return matchesOut