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 return pipeBase.Struct( 

291 refCat=refSelection.sourceCat, 

292 matches=res.matches, 

293 scatterOnSky=res.scatterOnSky, 

294 matchMeta=matchMeta, 

295 ) 

296 

297 @pipeBase.timeMethod 

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

299 exposure=None): 

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

301 

302 Parameters 

303 ---------- 

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

305 catalog of reference objects 

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

307 catalog of sources detected on the exposure 

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

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

310 refFluxField : 'str' 

311 field of refCat to use for flux 

312 bbox : `lsst.geom.Box2I` 

313 bounding box of exposure 

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

315 initial guess for WCS of exposure 

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

317 a MatchTolerance object (or None) specifying 

318 internal tolerances to the matcher. See the MatchTolerance 

319 definition in the respective matcher for the class definition. 

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

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

322 display. 

323 

324 Returns 

325 ------- 

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

327 Result struct with components: 

328 

329 - ``matches``: astrometric matches 

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

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

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

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

334 """ 

335 import lsstDebug 

336 debug = lsstDebug.Info(__name__) 

337 

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

339 

340 matchRes = self.matcher.matchObjectsToSources( 

341 refCat=refCat, 

342 sourceCat=goodSourceCat, 

343 wcs=wcs, 

344 sourceFluxField=sourceFluxField, 

345 refFluxField=refFluxField, 

346 match_tolerance=match_tolerance, 

347 ) 

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

349 if debug.display: 

350 frame = int(debug.frame) 

351 displayAstrometry( 

352 refCat=refCat, 

353 sourceCat=matchRes.usableSourceCat, 

354 matches=matchRes.matches, 

355 exposure=exposure, 

356 bbox=bbox, 

357 frame=frame + 1, 

358 title="Initial WCS", 

359 ) 

360 

361 if self.config.doMagnitudeOutlierRejection: 

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

363 else: 

364 matches = matchRes.matches 

365 

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

367 fitRes = self.wcsFitter.fitWcs( 

368 matches=matches, 

369 initWcs=wcs, 

370 bbox=bbox, 

371 refCat=refCat, 

372 sourceCat=sourceCat, 

373 exposure=exposure, 

374 ) 

375 fitWcs = fitRes.wcs 

376 scatterOnSky = fitRes.scatterOnSky 

377 if debug.display: 

378 frame = int(debug.frame) 

379 displayAstrometry( 

380 refCat=refCat, 

381 sourceCat=matchRes.usableSourceCat, 

382 matches=matches, 

383 exposure=exposure, 

384 bbox=bbox, 

385 frame=frame + 2, 

386 title="Fit TAN-SIP WCS", 

387 ) 

388 

389 return pipeBase.Struct( 

390 matches=matches, 

391 wcs=fitWcs, 

392 scatterOnSky=scatterOnSky, 

393 match_tolerance=matchRes.match_tolerance, 

394 ) 

395 

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

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

398 

399 Parameters 

400 ---------- 

401 sourceFluxField : `str` 

402 Field in source catalog for instrumental fluxes. 

403 refFluxField : `str` 

404 Field in reference catalog for fluxes (nJy). 

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

406 List of source/reference matches input 

407 

408 Returns 

409 ------- 

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

411 List of source/reference matches with magnitude 

412 outliers removed. 

413 """ 

414 nMatch = len(matchesIn) 

415 sourceMag = np.zeros(nMatch) 

416 refMag = np.zeros(nMatch) 

417 for i, match in enumerate(matchesIn): 

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

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

420 

421 deltaMag = refMag - sourceMag 

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

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

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

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

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

427 # rejecting objects in zero-noise tests. 

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

429 1e-3, 

430 None) 

431 

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

433 zp, zpSigma) 

434 

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

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

437 

438 nOutlier = nMatch - goodStars.size 

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

440 nOutlier, nMatch) 

441 

442 matchesOut = [] 

443 for matchInd in goodStars: 

444 matchesOut.append(matchesIn[matchInd]) 

445 

446 return matchesOut