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

129 statements  

« prev     ^ index     » next       coverage.py v6.4.2, created at 2022-08-06 09:08 +0000

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 lsst.utils.timer import timeMethod 

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 rerference " 

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

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 "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=False, 

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 = "Ap" 

91 

92 self.sourceSelector.name = "matcher" 

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

94 

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

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

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

98 

99 

100class AstrometryTask(RefMatchTask): 

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

102 solve for the WCS. 

103 

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

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

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

107 

108 Parameters 

109 ---------- 

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

111 A reference object loader object 

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

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

114 **kwargs 

115 additional keyword arguments for pipe_base 

116 `lsst.pipe.base.Task.__init__` 

117 """ 

118 ConfigClass = AstrometryConfig 

119 _DefaultName = "astrometricSolver" 

120 

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

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

123 

124 if schema is not None: 

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

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

127 else: 

128 self.usedKey = None 

129 

130 self.makeSubtask("wcsFitter") 

131 

132 @timeMethod 

133 def run(self, sourceCat, exposure): 

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

135 

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

137 config.forceKnownWcs. 

138 

139 Parameters 

140 ---------- 

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

142 exposure whose WCS is to be fit 

143 The following are read only: 

144 

145 - bbox 

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 @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 epoch=expMd.epoch, 

229 ) 

230 

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

232 

233 matchMeta = self.refObjLoader.getMetadataBox( 

234 bbox=expMd.bbox, 

235 wcs=expMd.wcs, 

236 filterName=expMd.filterName, 

237 epoch=expMd.epoch, 

238 ) 

239 

240 if debug.display: 

241 frame = int(debug.frame) 

242 displayAstrometry( 

243 refCat=refSelection.sourceCat, 

244 sourceCat=sourceSelection.sourceCat, 

245 exposure=exposure, 

246 bbox=expMd.bbox, 

247 frame=frame, 

248 title="Reference catalog", 

249 ) 

250 

251 res = None 

252 wcs = expMd.wcs 

253 match_tolerance = None 

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

255 iterNum = i + 1 

256 try: 

257 tryRes = self._matchAndFitWcs( 

258 refCat=refSelection.sourceCat, 

259 sourceCat=sourceCat, 

260 goodSourceCat=sourceSelection.sourceCat, 

261 refFluxField=loadRes.fluxField, 

262 bbox=expMd.bbox, 

263 wcs=wcs, 

264 exposure=exposure, 

265 match_tolerance=match_tolerance, 

266 ) 

267 except Exception as e: 

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

269 if i > 0: 

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

271 iterNum -= 1 

272 break 

273 else: 

274 raise 

275 

276 match_tolerance = tryRes.match_tolerance 

277 tryMatchDist = self._computeMatchStatsOnSky(tryRes.matches) 

278 self.log.debug( 

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

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

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

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

283 

284 maxMatchDist = tryMatchDist.maxMatchDist 

285 res = tryRes 

286 wcs = res.wcs 

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

288 self.log.debug( 

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

290 "that's good enough", 

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

292 break 

293 match_tolerance.maxMatchDist = maxMatchDist 

294 

295 self.log.info( 

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

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

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

299 tryMatchDist.distStdDev.asArcseconds()) 

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

301 raise pipeBase.TaskError( 

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

303 "(maxMeanDistanceArcsec)" % 

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

305 for m in res.matches: 

306 if self.usedKey: 

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

308 exposure.setWcs(res.wcs) 

309 

310 # Record the scatter in the exposure metadata 

311 md = exposure.getMetadata() 

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

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

314 

315 return pipeBase.Struct( 

316 refCat=refSelection.sourceCat, 

317 matches=res.matches, 

318 scatterOnSky=res.scatterOnSky, 

319 matchMeta=matchMeta, 

320 ) 

321 

322 @timeMethod 

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

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 match_tolerance : `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` 

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 match_tolerance=match_tolerance, 

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="Fit TAN-SIP WCS", 

412 ) 

413 

414 return pipeBase.Struct( 

415 matches=matches, 

416 wcs=fitWcs, 

417 scatterOnSky=scatterOnSky, 

418 match_tolerance=matchRes.match_tolerance, 

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 = [] 

468 for matchInd in goodStars: 

469 matchesOut.append(matchesIn[matchInd]) 

470 

471 return matchesOut