Coverage for python/lsst/summit/utils/astrometry/anet.py: 19%

173 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-01-04 03:40 -0800

1# This file is part of summit_utils. 

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 

22import os 

23import shutil 

24import subprocess 

25import tempfile 

26import numpy as np 

27from astropy.io import fits 

28import time 

29import uuid 

30import warnings 

31 

32from dataclasses import dataclass 

33from functools import cached_property 

34 

35import lsst.geom as geom 

36 

37from .utils import headerToWcs 

38 

39__all__ = ['AstrometryNetResult', 'CommandLineSolver', 'OnlineSolver'] 

40 

41 

42@dataclass(frozen=True) 

43class AstrometryNetResult: 

44 """Minimal wrapper class to construct and return results from the command 

45 line fitter. 

46 

47 Constructs a DM wcs from the output of the command line fitter, and 

48 calculates the plate scale and astrometric scatter measurement in arcsec 

49 and pixels. 

50 

51 Parameters 

52 ---------- 

53 wcsFile : `str` 

54 The path to the .wcs file from the fit. 

55 corrFile : `str`, optional 

56 The path to the .corr file from the fit. 

57 """ 

58 wcsFile: str 

59 corrFile: str = None 

60 

61 def __post_init__(self): 

62 # touch these properties to ensure the files needed to calculate them 

63 # are read immediately, in case they are deleted from temp 

64 self.wcs 

65 self.rmsErrorArsec 

66 

67 @cached_property 

68 def wcs(self): 

69 with fits.open(self.wcsFile) as f: 

70 header = f[0].header 

71 return headerToWcs(header) 

72 

73 @cached_property 

74 def plateScale(self): 

75 return self.wcs.getPixelScale().asArcseconds() 

76 

77 @cached_property 

78 def meanSqErr(self): 

79 if not self.corrFile: 

80 return None 

81 

82 try: 

83 with fits.open(self.corrFile) as f: 

84 data = f[1].data 

85 

86 meanSqErr = 0.0 

87 count = 0 

88 for i in range(data.shape[0]): 

89 row = data[i] 

90 count += 1 

91 error = (row[0] - row[4])**2 + (row[1] - row[5])**2 # square error in pixels 

92 error *= row[10] # multiply by weight 

93 meanSqErr += error 

94 meanSqErr /= count # divide by number of stars 

95 return meanSqErr 

96 except Exception as e: 

97 print(f'Failed for calculate astrometric scatter: {repr(e)}') 

98 

99 @cached_property 

100 def rmsErrorPixels(self): 

101 return np.sqrt(self.meanSqErr) 

102 

103 @cached_property 

104 def rmsErrorArsec(self): 

105 return self.rmsErrorPixels * self.plateScale 

106 

107 

108class CommandLineSolver(): 

109 """An interface for the solve-field command line tool from astrometry.net. 

110 

111 Parameters 

112 ---------- 

113 indexFilePath : `str` 

114 The path to the index files. Do not include the 4100 or 4200 etc. in 

115 the path. This is selected automatically depending on the `isWideField` 

116 flag when calling `run()`. 

117 checkInParallel : `bool`, optional 

118 Do the checks in parallel. Default is True. 

119 timeout : `float`, optional 

120 The timeout for the solve-field command. Default is 300 seconds. 

121 binary : `str`, optional 

122 The path to the solve-field binary. Default is 'solve-field', i.e. it 

123 is assumed to be on the path. 

124 """ 

125 def __init__(self, 

126 indexFilePath=None, 

127 checkInParallel=True, 

128 timeout=300, 

129 binary='solve-field', 

130 ): 

131 self.indexFilePath = indexFilePath 

132 self.checkInParallel = checkInParallel 

133 self.timeout = timeout 

134 self.binary = binary 

135 if not shutil.which(binary): 

136 raise RuntimeError(f"Could not find {binary} in path, please install 'solve-field' and either" 

137 " put it on your PATH or specify the full path to it in the 'binary' argument") 

138 

139 def _writeConfigFile(self, wide): 

140 """Write a temporary config file for astrometry.net. 

141 

142 Parameters 

143 ---------- 

144 wide : `bool` 

145 Is this a wide field image? Used to select the 4100 vs 4200 dir in 

146 the index file path. 

147 

148 Returns 

149 ------- 

150 filename : `str` 

151 The filename to which the config file was written. 

152 """ 

153 indexFileDir = os.path.join(self.indexFilePath, ('4100' if wide else '4200')) 

154 if not os.path.isdir(indexFileDir): 

155 raise RuntimeError(f"No index files found at {self.indexFilePath}, in {indexFileDir} (you need a" 

156 " 4100 dir for wide field and 4200 dir for narrow field images).") 

157 

158 lines = [] 

159 if self.checkInParallel: 

160 lines.append('inparallel') 

161 

162 lines.append(f"cpulimit {self.timeout}") 

163 lines.append(f"add_path {indexFileDir}") 

164 lines.append("autoindex") 

165 filename = tempfile.mktemp(suffix='.cfg') 

166 with open(filename, 'w') as f: 

167 f.writelines(line + '\n' for line in lines) 

168 return filename 

169 

170 def _writeFitsTable(self, sourceCat): 

171 """Write the source table to a FITS file and return the filename. 

172 

173 Parameters 

174 ---------- 

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

176 The source catalog to write to a FITS file for the solver. 

177 

178 Returns 

179 ------- 

180 filename : `str` 

181 The filename to which the catalog was written. 

182 """ 

183 fluxArray = sourceCat.columns.getGaussianInstFlux() 

184 fluxFinite = np.logical_and(np.isfinite(fluxArray), fluxArray > 0) 

185 fluxArray = fluxArray[fluxFinite] 

186 indices = np.argsort(fluxArray) 

187 x = sourceCat.getColumnView().getX()[fluxFinite] 

188 y = sourceCat.getColumnView().getY()[fluxFinite] 

189 fluxArray = fluxArray[indices][::-1] # brightest finite flux 

190 xArray = x[indices][::-1] 

191 yArray = y[indices][::-1] 

192 x = fits.Column(name='X', format='D', array=xArray) 

193 y = fits.Column(name='Y', format='D', array=yArray) 

194 flux = fits.Column(name='FLUX', format='D', array=fluxArray) 

195 hdu = fits.BinTableHDU.from_columns([flux, x, y]) 

196 

197 filename = tempfile.mktemp(suffix='.fits') 

198 hdu.writeto(filename) 

199 return filename 

200 

201 # try to keep this call sig and the defaults as similar as possible 

202 # to the run method on the OnlineSolver 

203 def run(self, exp, sourceCat, isWideField, *, percentageScaleError=10, radius=None, silent=True): 

204 """Get the astrometric solution for an image using astrometry.net using 

205 the binary ``solve-field`` and a set of index files. 

206 

207 Parameters 

208 ---------- 

209 exp : `lsst.afw.image.Exposure` 

210 The input exposure. Only used for its wcs and its dimensions. 

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

212 The detected source catalog for the exposure. One produced by a 

213 default run of CharacterizeImageTask is suitable. 

214 isWideField : `bool` 

215 Is this a wide field image? Used to select the correct index files. 

216 percentageScaleError : `float`, optional 

217 The percentage scale error to allow in the astrometric solution. 

218 radius : `float`, optional 

219 The search radius from the nominal wcs in degrees. 

220 silent : `bool`, optional 

221 Swallow the output from the command line? The solver is *very* 

222 chatty, so this is recommended. 

223 

224 Returns 

225 ------- 

226 result : `AstrometryNetResult` or `None` 

227 The result of the fit. If the fit was successful, the result will 

228 contain a valid DM wcs, a scatter in arcseconds and a scatter in 

229 pixels. If the fit failed, ``None`` is returned. 

230 """ 

231 wcs = exp.getWcs() 

232 if not wcs: 

233 raise ValueError("No WCS in exposure") 

234 

235 configFile = self._writeConfigFile(wide=isWideField) 

236 fitsFile = self._writeFitsTable(sourceCat) 

237 

238 plateScale = wcs.getPixelScale().asArcseconds() 

239 scaleMin = plateScale*(1 - percentageScaleError/100) 

240 scaleMax = plateScale*(1 + percentageScaleError/100) 

241 

242 ra, dec = wcs.getSkyOrigin() 

243 

244 # do not use tempfile.TemporaryDirectory() because it must not exist, 

245 # it is made by the solve-field binary and barfs if it exists already! 

246 mainTempDir = tempfile.gettempdir() 

247 tempDirSuffix = str(uuid.uuid1()).split('-')[0] 

248 tempDir = os.path.join(mainTempDir, tempDirSuffix) 

249 

250 cmd = (f"{self.binary} {fitsFile} " # the data 

251 f"--width {exp.getWidth()} " # image dimensions 

252 f"--height {exp.getHeight()} " # image dimensions 

253 f"-3 {ra.asDegrees()} " 

254 f"-4 {dec.asDegrees()} " 

255 f"-5 {radius if radius else 180} " 

256 "-X X -Y Y -v -z 2 -t 2 " # the parts of the bintable to use 

257 f"--scale-low {scaleMin:.3f} " # the scale range 

258 f"--scale-high {scaleMax:.3f} " # the scale range 

259 f"--scale-units arcsecperpix " 

260 "--crpix-center " # the CRPIX is always the center of the image 

261 f"--config {configFile} " 

262 f"-D {tempDir} " 

263 "--overwrite " # shouldn't matter as we're using temp files 

264 ) 

265 

266 t0 = time.time() 

267 with open(os.devnull, 'w') as devnull: 

268 result = subprocess.run(cmd, shell=True, check=True, stdout=devnull if silent else None) 

269 t1 = time.time() 

270 

271 if result.returncode == 0: 

272 print(f"Fitting code ran in {(t1-t0):.2f} seconds") 

273 # output template is /tmpdirname/fitstempname + various suffixes 

274 # for each obj 

275 basename = os.path.basename(fitsFile).removesuffix('.fits') 

276 outputTemplate = os.path.join(tempDir, basename) 

277 wcsFile = outputTemplate + '.wcs' 

278 corrFile = outputTemplate + '.corr' 

279 

280 if not os.path.exists(wcsFile): 

281 print("but failed to find a solution.") 

282 return None 

283 

284 result = AstrometryNetResult(wcsFile, corrFile) 

285 return result 

286 else: 

287 print("Fit failed") 

288 return None 

289 

290 

291class OnlineSolver(): 

292 """A class to solve an image using the Astrometry.net online service. 

293 """ 

294 

295 def __init__(self): 

296 # import seems to spew warnings even if the required key is present 

297 # so we swallow them, and raise on init if the key is missing 

298 with warnings.catch_warnings(): 

299 warnings.simplefilter("ignore") 

300 from astroquery.astrometry_net import AstrometryNet 

301 

302 self.apiKey = self.getApiKey() # raises if not present so do first 

303 self.adn = AstrometryNet() 

304 self.adn.api_key = self.apiKey 

305 

306 @staticmethod 

307 def getApiKey(): 

308 """Get the astrometry.net API key if possible. 

309 

310 Raises a RuntimeError if it isn't found. 

311 

312 Returns 

313 ------- 

314 apiKey : str 

315 The astrometry.net API key, if present. 

316 

317 Raises 

318 ------ 

319 RuntimeError 

320 Raised if the ASTROMETRY_NET_API_KEY is not set. 

321 """ 

322 try: 

323 return os.environ['ASTROMETRY_NET_API_KEY'] 

324 except KeyError as e: 

325 msg = "No AstrometryNet API key found. Sign up and get one, set it to $ASTROMETRY_NET_API_KEY" 

326 raise RuntimeError(msg) from e 

327 

328 # try to keep this call sig and the defaults as similar as possible 

329 # to the run method on the CommandLineSolver 

330 def run(self, exp, sourceCat, *, percentageScaleError=10, radius=None, scaleEstimate=None): 

331 """Get the astrometric solution for an image using the astrometry.net 

332 online solver. 

333 

334 Parameters 

335 ---------- 

336 exp : `lsst.afw.image.Exposure` 

337 The input exposure. Only used for its wcs. 

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

339 The detected source catalog for the exposure. One produced by a 

340 default run of CharacterizeImageTask is suitable. 

341 percentageScaleError : `float`, optional 

342 The percentage scale error to allow in the astrometric solution. 

343 radius : `float`, optional 

344 The search radius from the nominal wcs in degrees. 

345 scaleEstimate : `float`, optional 

346 An estimate of the scale in arcseconds per pixel. Only used if 

347 (and required when) the exposure has no wcs. 

348 

349 Returns 

350 ------- 

351 result : `dict` or `None` 

352 The results of the fit, with the following keys, or ``None`` if 

353 the fit failed: 

354 ``nominalRa`` : `lsst.geom.Angle` 

355 The nominal ra from the exposure's boresight. 

356 ``nominalDec`` : `lsst.geom.Angle` 

357 The nominal dec from the exposure's boresight. 

358 ``calculatedRa`` : `lsst.geom.Angle` 

359 The fitted ra. 

360 ``calculatedDec`` : `lsst.geom.Angle` 

361 The fitted dec. 

362 ``deltaRa`` : `lsst.geom.Angle`, 

363 The change in ra, as an Angle. 

364 ``deltaDec`` : `lsst.geom.Angle`, 

365 The change in dec, as an Angle. 

366 ``deltaRaArcsec`` : `float`` 

367 The change in ra in arcseconds, as a float. 

368 ``deltaDecArcsec`` : `float` 

369 The change in dec in arcseconds, as a float. 

370 ``astrometry_net_wcs_header`` : `dict` 

371 The fitted wcs, as a header dict. 

372 """ 

373 nominalWcs = exp.getWcs() 

374 if nominalWcs is not None: 

375 ra, dec = nominalWcs.getSkyOrigin() 

376 scaleEstimate = nominalWcs.getPixelScale().asArcseconds() 

377 else: 

378 print('Trying to process image with None wcs - good luck!') 

379 vi = exp.getInfo().getVisitInfo() 

380 ra, dec = vi.boresightRaDec 

381 if np.isnan(ra.asDegrees()) or np.isnan(dec.asDegrees()): 

382 raise RuntimeError('Exposure has no wcs and did not find nominal ra/dec in visitInfo') 

383 

384 if not scaleEstimate: # must either have a wcs or provide via kwarg 

385 raise RuntimeError('Got no kwarg for scaleEstimate and failed to find one in the nominal wcs.') 

386 

387 image_height, image_width = exp.image.array.shape 

388 scale_units = 'arcsecperpix' 

389 scale_type = 'ev' # ev means submit estimate and % error 

390 scale_err = percentageScaleError # error as percentage 

391 center_ra = ra.asDegrees() 

392 center_dec = dec.asDegrees() 

393 radius = radius if radius else 180 # degrees 

394 try: 

395 wcs_header = self.adn.solve_from_source_list(sourceCat['base_SdssCentroid_x'], 

396 sourceCat['base_SdssCentroid_y'], 

397 image_width, image_height, 

398 scale_units=scale_units, 

399 scale_type=scale_type, 

400 scale_est=scaleEstimate, 

401 scale_err=scale_err, 

402 center_ra=center_ra, 

403 center_dec=center_dec, 

404 radius=radius, 

405 crpix_center=True, # the CRPIX is always the center 

406 solve_timeout=240) 

407 except RuntimeError: 

408 print('Failed to find a solution') 

409 return None 

410 

411 print('Finished solving!') 

412 

413 nominalRa, nominalDec = exp.getInfo().getVisitInfo().getBoresightRaDec() 

414 

415 if 'CRVAL1' not in wcs_header: 

416 raise RuntimeError("Astrometric fit failed.") 

417 calculatedRa = geom.Angle(wcs_header['CRVAL1'], geom.degrees) 

418 calculatedDec = geom.Angle(wcs_header['CRVAL2'], geom.degrees) 

419 

420 deltaRa = geom.Angle(wcs_header['CRVAL1'] - nominalRa.asDegrees(), geom.degrees) 

421 deltaDec = geom.Angle(wcs_header['CRVAL2'] - nominalDec.asDegrees(), geom.degrees) 

422 

423 # TODO: DM-37213 change this to return an AstrometryNetResult class 

424 # like the CommandLineSolver does. 

425 

426 result = {'nominalRa': nominalRa, 

427 'nominalDec': nominalDec, 

428 'calculatedRa': calculatedRa, 

429 'calculatedDec': calculatedDec, 

430 'deltaRa': deltaRa, 

431 'deltaDec': deltaDec, 

432 'deltaRaArcsec': deltaRa.asArcseconds(), 

433 'deltaDecArcsec': deltaDec.asArcseconds(), 

434 'astrometry_net_wcs_header': wcs_header, 

435 'nSources': len(sourceCat), 

436 } 

437 

438 return result