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

Shortcuts on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

128 statements  

1# 

2# LSST Data Management System 

3# Copyright 2008-2016 AURA/LSST. 

4# 

5# This product includes software developed by the 

6# LSST Project (http://www.lsst.org/). 

7# 

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

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

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

11# (at your option) any later version. 

12# 

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

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

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

16# GNU General Public License for more details. 

17# 

18# You should have received a copy of the LSST License Statement and 

19# the GNU General Public License along with this program. If not, 

20# see <https://www.lsstcorp.org/LegalNotices/>. 

21# 

22 

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

24 

25import numpy as np 

26from astropy import units 

27import scipy.stats 

28 

29import lsst.pex.config as pexConfig 

30import lsst.pipe.base as pipeBase 

31from .ref_match import RefMatchTask, RefMatchConfig 

32from .fitTanSipWcs import FitTanSipWcsTask 

33from .display import displayAstrometry 

34 

35 

36class AstrometryConfig(RefMatchConfig): 

37 """Config for AstrometryTask. 

38 """ 

39 wcsFitter = pexConfig.ConfigurableField( 

40 target=FitTanSipWcsTask, 

41 doc="WCS fitter", 

42 ) 

43 forceKnownWcs = pexConfig.Field( 

44 dtype=bool, 

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

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

47 default=False, 

48 ) 

49 maxIter = pexConfig.RangeField( 

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

51 "ignored if not fitting a WCS", 

52 dtype=int, 

53 default=3, 

54 min=1, 

55 ) 

56 minMatchDistanceArcSec = pexConfig.RangeField( 

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

58 "ignored if not fitting a WCS", 

59 dtype=float, 

60 default=0.001, 

61 min=0, 

62 ) 

63 maxMeanDistanceArcsec = pexConfig.RangeField( 

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

65 "objects post-fit. A mean distance greater than this threshold raises a TaskError " 

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

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

68 "Appropriate value will be dataset and workflow dependent.", 

69 dtype=float, 

70 default=0.5, 

71 min=0, 

72 ) 

73 doMagnitudeOutlierRejection = pexConfig.Field( 

74 dtype=bool, 

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

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

77 default=False, 

78 ) 

79 magnitudeOutlierRejectionNSigma = pexConfig.Field( 

80 dtype=float, 

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

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

83 "iteration."), 

84 default=3.0, 

85 ) 

86 

87 def setDefaults(self): 

88 # Override the default source selector for astrometry tasks 

89 self.sourceFluxType = "Ap" 

90 

91 self.sourceSelector.name = "matcher" 

92 self.sourceSelector["matcher"].sourceFluxType = self.sourceFluxType 

93 

94 # Note that if the matcher is MatchOptimisticBTask, then the 

95 # default should be self.sourceSelector['matcher'].excludePixelFlags = False 

96 # However, there is no way to do this automatically. 

97 

98 

99class AstrometryTask(RefMatchTask): 

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

101 solve for the WCS. 

102 

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

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

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

106 

107 Parameters 

108 ---------- 

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

110 A reference object loader object 

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

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

113 **kwargs 

114 additional keyword arguments for pipe_base 

115 `lsst.pipe.base.Task.__init__` 

116 """ 

117 ConfigClass = AstrometryConfig 

118 _DefaultName = "astrometricSolver" 

119 

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

121 RefMatchTask.__init__(self, refObjLoader, **kwargs) 

122 

123 if schema is not None: 

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

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

126 else: 

127 self.usedKey = None 

128 

129 self.makeSubtask("wcsFitter") 

130 

131 @pipeBase.timeMethod 

132 def run(self, sourceCat, exposure): 

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

134 

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

136 config.forceKnownWcs. 

137 

138 Parameters 

139 ---------- 

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

141 exposure whose WCS is to be fit 

142 The following are read only: 

143 

144 - bbox 

145 - photoCalib (may be absent) 

146 - filter (may be unset) 

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

148 

149 The following are updated: 

150 

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

152 required) 

153 

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

155 catalog of sources detected on the exposure 

156 

157 Returns 

158 ------- 

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

160 with these fields: 

161 

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

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

164 - ``matches`` : astrometric matches 

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

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

167 objects and sources in "matches" 

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

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

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

171 """ 

172 if self.refObjLoader is None: 

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

174 if self.config.forceKnownWcs: 

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

176 res.scatterOnSky = None 

177 else: 

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

179 return res 

180 

181 @pipeBase.timeMethod 

182 def solve(self, exposure, sourceCat): 

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

184 fit a WCS 

185 

186 Returns 

187 ------- 

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

189 Result struct with components: 

190 

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

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

193 - ``matches`` : astrometric matches 

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

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

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

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

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

199 

200 Raises 

201 ------ 

202 TaskError 

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

204 reference objects is greater than 

205 ``self.config.maxMeanDistanceArcsec``. 

206 

207 Notes 

208 ----- 

209 ignores config.forceKnownWcs 

210 """ 

211 if self.refObjLoader is None: 

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

213 import lsstDebug 

214 debug = lsstDebug.Info(__name__) 

215 

216 expMd = self._getExposureMetadata(exposure) 

217 

218 sourceSelection = self.sourceSelector.run(sourceCat) 

219 

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

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

222 len(sourceSelection.sourceCat))) 

223 

224 loadRes = self.refObjLoader.loadPixelBox( 

225 bbox=expMd.bbox, 

226 wcs=expMd.wcs, 

227 filterName=expMd.filterName, 

228 photoCalib=expMd.photoCalib, 

229 epoch=expMd.epoch, 

230 ) 

231 

232 refSelection = self.referenceSelector.run(loadRes.refCat) 

233 

234 matchMeta = self.refObjLoader.getMetadataBox( 

235 bbox=expMd.bbox, 

236 wcs=expMd.wcs, 

237 filterName=expMd.filterName, 

238 photoCalib=expMd.photoCalib, 

239 epoch=expMd.epoch, 

240 ) 

241 

242 if debug.display: 

243 frame = int(debug.frame) 

244 displayAstrometry( 

245 refCat=refSelection.sourceCat, 

246 sourceCat=sourceSelection.sourceCat, 

247 exposure=exposure, 

248 bbox=expMd.bbox, 

249 frame=frame, 

250 title="Reference catalog", 

251 ) 

252 

253 res = None 

254 wcs = expMd.wcs 

255 match_tolerance = None 

256 for i in range(self.config.maxIter): 

257 iterNum = i + 1 

258 try: 

259 tryRes = self._matchAndFitWcs( 

260 refCat=refSelection.sourceCat, 

261 sourceCat=sourceCat, 

262 goodSourceCat=sourceSelection.sourceCat, 

263 refFluxField=loadRes.fluxField, 

264 bbox=expMd.bbox, 

265 wcs=wcs, 

266 exposure=exposure, 

267 match_tolerance=match_tolerance, 

268 ) 

269 except Exception as e: 

270 # if we have had a succeessful iteration then use that; otherwise fail 

271 if i > 0: 

272 self.log.info("Fit WCS iter %d failed; using previous iteration: %s" % (iterNum, e)) 

273 iterNum -= 1 

274 break 

275 else: 

276 raise 

277 

278 match_tolerance = tryRes.match_tolerance 

279 tryMatchDist = self._computeMatchStatsOnSky(tryRes.matches) 

280 self.log.debug( 

281 "Match and fit WCS iteration %d: found %d matches with on-sky distance mean " 

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

283 iterNum, len(tryRes.matches), tryMatchDist.distMean.asArcseconds(), 

284 tryMatchDist.distStdDev.asArcseconds(), tryMatchDist.maxMatchDist.asArcseconds()) 

285 

286 maxMatchDist = tryMatchDist.maxMatchDist 

287 res = tryRes 

288 wcs = res.wcs 

289 if maxMatchDist.asArcseconds() < self.config.minMatchDistanceArcSec: 

290 self.log.debug( 

291 "Max match distance = %0.3f arcsec < %0.3f = config.minMatchDistanceArcSec; " 

292 "that's good enough", 

293 maxMatchDist.asArcseconds(), self.config.minMatchDistanceArcSec) 

294 break 

295 match_tolerance.maxMatchDist = maxMatchDist 

296 

297 self.log.info( 

298 "Matched and fit WCS in %d iterations; " 

299 "found %d matches with on-sky distance mean and scatter = %0.3f +- %0.3f arcsec", 

300 iterNum, len(tryRes.matches), tryMatchDist.distMean.asArcseconds(), 

301 tryMatchDist.distStdDev.asArcseconds()) 

302 if tryMatchDist.distMean.asArcseconds() > self.config.maxMeanDistanceArcsec: 

303 raise pipeBase.TaskError( 

304 "Fatal astrometry failure detected: mean on-sky distance = %0.3f arcsec > %0.3f " 

305 "(maxMeanDistanceArcsec)" % 

306 (tryMatchDist.distMean.asArcseconds(), self.config.maxMeanDistanceArcsec)) 

307 for m in res.matches: 

308 if self.usedKey: 

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

310 exposure.setWcs(res.wcs) 

311 

312 # Record the scatter in the exposure metadata 

313 md = exposure.getMetadata() 

314 md['SFM_ASTROM_OFFSET_MEAN'] = tryMatchDist.distMean.asArcseconds() 

315 md['SFM_ASTROM_OFFSET_STD'] = tryMatchDist.distStdDev.asArcseconds() 

316 

317 return pipeBase.Struct( 

318 refCat=refSelection.sourceCat, 

319 matches=res.matches, 

320 scatterOnSky=res.scatterOnSky, 

321 matchMeta=matchMeta, 

322 ) 

323 

324 @pipeBase.timeMethod 

325 def _matchAndFitWcs(self, refCat, sourceCat, goodSourceCat, refFluxField, bbox, wcs, match_tolerance, 

326 exposure=None): 

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

328 

329 Parameters 

330 ---------- 

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

332 catalog of reference objects 

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

334 catalog of sources detected on the exposure 

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

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

337 refFluxField : 'str' 

338 field of refCat to use for flux 

339 bbox : `lsst.geom.Box2I` 

340 bounding box of exposure 

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

342 initial guess for WCS of exposure 

343 match_tolerance : `lsst.meas.astrom.MatchTolerance` 

344 a MatchTolerance object (or None) specifying 

345 internal tolerances to the matcher. See the MatchTolerance 

346 definition in the respective matcher for the class definition. 

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

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

349 display. 

350 

351 Returns 

352 ------- 

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

354 Result struct with components: 

355 

356 - ``matches``: astrometric matches 

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

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

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

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

361 """ 

362 import lsstDebug 

363 debug = lsstDebug.Info(__name__) 

364 

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

366 

367 matchRes = self.matcher.matchObjectsToSources( 

368 refCat=refCat, 

369 sourceCat=goodSourceCat, 

370 wcs=wcs, 

371 sourceFluxField=sourceFluxField, 

372 refFluxField=refFluxField, 

373 match_tolerance=match_tolerance, 

374 ) 

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

376 if debug.display: 

377 frame = int(debug.frame) 

378 displayAstrometry( 

379 refCat=refCat, 

380 sourceCat=matchRes.usableSourceCat, 

381 matches=matchRes.matches, 

382 exposure=exposure, 

383 bbox=bbox, 

384 frame=frame + 1, 

385 title="Initial WCS", 

386 ) 

387 

388 if self.config.doMagnitudeOutlierRejection: 

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

390 else: 

391 matches = matchRes.matches 

392 

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

394 fitRes = self.wcsFitter.fitWcs( 

395 matches=matches, 

396 initWcs=wcs, 

397 bbox=bbox, 

398 refCat=refCat, 

399 sourceCat=sourceCat, 

400 exposure=exposure, 

401 ) 

402 fitWcs = fitRes.wcs 

403 scatterOnSky = fitRes.scatterOnSky 

404 if debug.display: 

405 frame = int(debug.frame) 

406 displayAstrometry( 

407 refCat=refCat, 

408 sourceCat=matchRes.usableSourceCat, 

409 matches=matches, 

410 exposure=exposure, 

411 bbox=bbox, 

412 frame=frame + 2, 

413 title="Fit TAN-SIP WCS", 

414 ) 

415 

416 return pipeBase.Struct( 

417 matches=matches, 

418 wcs=fitWcs, 

419 scatterOnSky=scatterOnSky, 

420 match_tolerance=matchRes.match_tolerance, 

421 ) 

422 

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

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

425 

426 Parameters 

427 ---------- 

428 sourceFluxField : `str` 

429 Field in source catalog for instrumental fluxes. 

430 refFluxField : `str` 

431 Field in reference catalog for fluxes (nJy). 

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

433 List of source/reference matches input 

434 

435 Returns 

436 ------- 

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

438 List of source/reference matches with magnitude 

439 outliers removed. 

440 """ 

441 nMatch = len(matchesIn) 

442 sourceMag = np.zeros(nMatch) 

443 refMag = np.zeros(nMatch) 

444 for i, match in enumerate(matchesIn): 

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

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

447 

448 deltaMag = refMag - sourceMag 

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

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

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

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

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

454 # rejecting objects in zero-noise tests. 

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

456 1e-3, 

457 None) 

458 

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

460 zp, zpSigma) 

461 

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

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

464 

465 nOutlier = nMatch - goodStars.size 

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

467 nOutlier, nMatch) 

468 

469 matchesOut = [] 

470 for matchInd in goodStars: 

471 matchesOut.append(matchesIn[matchInd]) 

472 

473 return matchesOut