Hide keyboard shortcuts

Hot-keys 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

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 doMagnitudeOutlierRejection = pexConfig.Field( 

64 dtype=bool, 

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

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

67 default=False, 

68 ) 

69 magnitudeOutlierRejectionNSigma = pexConfig.Field( 

70 dtype=float, 

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

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

73 "iteration."), 

74 default=3.0, 

75 ) 

76 

77 def setDefaults(self): 

78 # Override the default source selector for astrometry tasks 

79 self.sourceFluxType = "Ap" 

80 

81 self.sourceSelector.name = "matcher" 

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

83 

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

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

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

87 

88 

89class AstrometryTask(RefMatchTask): 

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

91 solve for the WCS. 

92 

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

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

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

96 

97 Parameters 

98 ---------- 

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

100 A reference object loader object 

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

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

103 **kwargs 

104 additional keyword arguments for pipe_base 

105 `lsst.pipe.base.Task.__init__` 

106 """ 

107 ConfigClass = AstrometryConfig 

108 _DefaultName = "astrometricSolver" 

109 

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

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

112 

113 if schema is not None: 

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

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

116 else: 

117 self.usedKey = None 

118 

119 self.makeSubtask("wcsFitter") 

120 

121 @pipeBase.timeMethod 

122 def run(self, sourceCat, exposure): 

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

124 

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

126 config.forceKnownWcs. 

127 

128 Parameters 

129 ---------- 

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

131 exposure whose WCS is to be fit 

132 The following are read only: 

133 

134 - bbox 

135 - photoCalib (may be absent) 

136 - filter (may be unset) 

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

138 

139 The following are updated: 

140 

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

142 required) 

143 

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

145 catalog of sources detected on the exposure 

146 

147 Returns 

148 ------- 

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

150 with these fields: 

151 

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

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

154 - ``matches`` : astrometric matches 

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

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

157 objects and sources in "matches" 

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

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

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

161 """ 

162 if self.refObjLoader is None: 

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

164 if self.config.forceKnownWcs: 

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

166 res.scatterOnSky = None 

167 else: 

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

169 return res 

170 

171 @pipeBase.timeMethod 

172 def solve(self, exposure, sourceCat): 

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

174 fit a WCS 

175 

176 Returns 

177 ------- 

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

179 Result struct with components: 

180 

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

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

183 - ``matches`` : astrometric matches 

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

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

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

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

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

189 

190 Notes 

191 ----- 

192 ignores config.forceKnownWcs 

193 """ 

194 if self.refObjLoader is None: 

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

196 import lsstDebug 

197 debug = lsstDebug.Info(__name__) 

198 

199 expMd = self._getExposureMetadata(exposure) 

200 

201 sourceSelection = self.sourceSelector.run(sourceCat) 

202 

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

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

205 len(sourceSelection.sourceCat))) 

206 

207 loadRes = self.refObjLoader.loadPixelBox( 

208 bbox=expMd.bbox, 

209 wcs=expMd.wcs, 

210 filterName=expMd.filterName, 

211 photoCalib=expMd.photoCalib, 

212 epoch=expMd.epoch, 

213 ) 

214 

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

216 

217 matchMeta = self.refObjLoader.getMetadataBox( 

218 bbox=expMd.bbox, 

219 wcs=expMd.wcs, 

220 filterName=expMd.filterName, 

221 photoCalib=expMd.photoCalib, 

222 epoch=expMd.epoch, 

223 ) 

224 

225 if debug.display: 

226 frame = int(debug.frame) 

227 displayAstrometry( 

228 refCat=refSelection.sourceCat, 

229 sourceCat=sourceSelection.sourceCat, 

230 exposure=exposure, 

231 bbox=expMd.bbox, 

232 frame=frame, 

233 title="Reference catalog", 

234 ) 

235 

236 res = None 

237 wcs = expMd.wcs 

238 match_tolerance = None 

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

240 iterNum = i + 1 

241 try: 

242 tryRes = self._matchAndFitWcs( 

243 refCat=refSelection.sourceCat, 

244 sourceCat=sourceCat, 

245 goodSourceCat=sourceSelection.sourceCat, 

246 refFluxField=loadRes.fluxField, 

247 bbox=expMd.bbox, 

248 wcs=wcs, 

249 exposure=exposure, 

250 match_tolerance=match_tolerance, 

251 ) 

252 except Exception as e: 

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

254 if i > 0: 

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

256 iterNum -= 1 

257 break 

258 else: 

259 raise 

260 

261 match_tolerance = tryRes.match_tolerance 

262 tryMatchDist = self._computeMatchStatsOnSky(tryRes.matches) 

263 self.log.debug( 

264 "Match and fit WCS iteration %d: found %d matches with scatter = %0.3f +- %0.3f arcsec; " 

265 "max match distance = %0.3f arcsec", 

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

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

268 

269 maxMatchDist = tryMatchDist.maxMatchDist 

270 res = tryRes 

271 wcs = res.wcs 

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

273 self.log.debug( 

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

275 "that's good enough", 

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

277 break 

278 match_tolerance.maxMatchDist = maxMatchDist 

279 

280 self.log.info( 

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

282 "found %d matches with scatter = %0.3f +- %0.3f arcsec" % 

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

284 tryMatchDist.distStdDev.asArcseconds())) 

285 for m in res.matches: 

286 if self.usedKey: 

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

288 exposure.setWcs(res.wcs) 

289 

290 # Record the scatter in the exposure metadata 

291 md = exposure.getMetadata() 

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

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

294 

295 return pipeBase.Struct( 

296 refCat=refSelection.sourceCat, 

297 matches=res.matches, 

298 scatterOnSky=res.scatterOnSky, 

299 matchMeta=matchMeta, 

300 ) 

301 

302 @pipeBase.timeMethod 

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

304 exposure=None): 

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

306 

307 Parameters 

308 ---------- 

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

310 catalog of reference objects 

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

312 catalog of sources detected on the exposure 

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

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

315 refFluxField : 'str' 

316 field of refCat to use for flux 

317 bbox : `lsst.geom.Box2I` 

318 bounding box of exposure 

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

320 initial guess for WCS of exposure 

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

322 a MatchTolerance object (or None) specifying 

323 internal tolerances to the matcher. See the MatchTolerance 

324 definition in the respective matcher for the class definition. 

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

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

327 display. 

328 

329 Returns 

330 ------- 

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

332 Result struct with components: 

333 

334 - ``matches``: astrometric matches 

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

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

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

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

339 """ 

340 import lsstDebug 

341 debug = lsstDebug.Info(__name__) 

342 

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

344 

345 matchRes = self.matcher.matchObjectsToSources( 

346 refCat=refCat, 

347 sourceCat=goodSourceCat, 

348 wcs=wcs, 

349 sourceFluxField=sourceFluxField, 

350 refFluxField=refFluxField, 

351 match_tolerance=match_tolerance, 

352 ) 

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

354 if debug.display: 

355 frame = int(debug.frame) 

356 displayAstrometry( 

357 refCat=refCat, 

358 sourceCat=matchRes.usableSourceCat, 

359 matches=matchRes.matches, 

360 exposure=exposure, 

361 bbox=bbox, 

362 frame=frame + 1, 

363 title="Initial WCS", 

364 ) 

365 

366 if self.config.doMagnitudeOutlierRejection: 

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

368 else: 

369 matches = matchRes.matches 

370 

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

372 fitRes = self.wcsFitter.fitWcs( 

373 matches=matches, 

374 initWcs=wcs, 

375 bbox=bbox, 

376 refCat=refCat, 

377 sourceCat=sourceCat, 

378 exposure=exposure, 

379 ) 

380 fitWcs = fitRes.wcs 

381 scatterOnSky = fitRes.scatterOnSky 

382 if debug.display: 

383 frame = int(debug.frame) 

384 displayAstrometry( 

385 refCat=refCat, 

386 sourceCat=matchRes.usableSourceCat, 

387 matches=matches, 

388 exposure=exposure, 

389 bbox=bbox, 

390 frame=frame + 2, 

391 title="Fit TAN-SIP WCS", 

392 ) 

393 

394 return pipeBase.Struct( 

395 matches=matches, 

396 wcs=fitWcs, 

397 scatterOnSky=scatterOnSky, 

398 match_tolerance=matchRes.match_tolerance, 

399 ) 

400 

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

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

403 

404 Parameters 

405 ---------- 

406 sourceFluxField : `str` 

407 Field in source catalog for instrumental fluxes. 

408 refFluxField : `str` 

409 Field in reference catalog for fluxes (nJy). 

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

411 List of source/reference matches input 

412 

413 Returns 

414 ------- 

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

416 List of source/reference matches with magnitude 

417 outliers removed. 

418 """ 

419 nMatch = len(matchesIn) 

420 sourceMag = np.zeros(nMatch) 

421 refMag = np.zeros(nMatch) 

422 for i, match in enumerate(matchesIn): 

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

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

425 

426 deltaMag = refMag - sourceMag 

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

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

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

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

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

432 # rejecting objects in zero-noise tests. 

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

434 1e-3, 

435 None) 

436 

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

438 zp, zpSigma) 

439 

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

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

442 

443 nOutlier = nMatch - goodStars.size 

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

445 nOutlier, nMatch) 

446 

447 matchesOut = [] 

448 for matchInd in goodStars: 

449 matchesOut.append(matchesIn[matchInd]) 

450 

451 return matchesOut