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

176 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-03-09 13:18 +0000

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 fluxSlot='base_CircularApertureFlux_3_0_instFlux', 

131 ): 

132 self.indexFilePath = indexFilePath 

133 self.checkInParallel = checkInParallel 

134 self.timeout = timeout 

135 self.binary = binary 

136 self.fluxSlot = fluxSlot 

137 if not shutil.which(binary): 

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

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

140 

141 def _writeConfigFile(self, wide): 

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

143 

144 Parameters 

145 ---------- 

146 wide : `bool` 

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

148 the index file path. 

149 

150 Returns 

151 ------- 

152 filename : `str` 

153 The filename to which the config file was written. 

154 """ 

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

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

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

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

159 

160 lines = [] 

161 if self.checkInParallel: 

162 lines.append('inparallel') 

163 

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

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

166 lines.append("autoindex") 

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

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

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

170 return filename 

171 

172 def _writeFitsTable(self, sourceCat): 

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

174 

175 Parameters 

176 ---------- 

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

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

179 

180 Returns 

181 ------- 

182 filename : `str` 

183 The filename to which the catalog was written. 

184 """ 

185 fluxArray = sourceCat[self.fluxSlot] 

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

187 fluxArray = fluxArray[fluxFinite] 

188 indices = np.argsort(fluxArray) 

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

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

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

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

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

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

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

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

197 print(f' of which {len(fluxArray)} made it into the fit') 

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

199 

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

201 hdu.writeto(filename) 

202 return filename 

203 

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

205 # to the run method on the OnlineSolver 

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

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

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

209 

210 Parameters 

211 ---------- 

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

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

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

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

216 default run of CharacterizeImageTask is suitable. 

217 isWideField : `bool` 

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

219 percentageScaleError : `float`, optional 

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

221 radius : `float`, optional 

222 The search radius from the nominal wcs in degrees. 

223 silent : `bool`, optional 

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

225 chatty, so this is recommended. 

226 

227 Returns 

228 ------- 

229 result : `AstrometryNetResult` or `None` 

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

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

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

233 """ 

234 wcs = exp.getWcs() 

235 if not wcs: 

236 raise ValueError("No WCS in exposure") 

237 

238 configFile = self._writeConfigFile(wide=isWideField) 

239 print(f'Fitting image with {len(sourceCat)} sources', end='') 

240 fitsFile = self._writeFitsTable(sourceCat) 

241 

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

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

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

245 

246 ra, dec = wcs.getSkyOrigin() 

247 

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

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

250 mainTempDir = tempfile.gettempdir() 

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

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

253 

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

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

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

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

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

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

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

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

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

263 f"--scale-units arcsecperpix " 

264 f"--crpix-x {wcs.getPixelOrigin()[0]} " # set the pixel origin 

265 f"--crpix-y {wcs.getPixelOrigin()[1]} " # set the pixel origin 

266 f"--config {configFile} " 

267 f"-D {tempDir} " 

268 "--no-plots " # don't make plots 

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

270 ) 

271 

272 t0 = time.time() 

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

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

275 t1 = time.time() 

276 

277 if result.returncode == 0: 

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

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

280 # for each obj 

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

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

283 wcsFile = outputTemplate + '.wcs' 

284 corrFile = outputTemplate + '.corr' 

285 

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

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

288 return None 

289 

290 result = AstrometryNetResult(wcsFile, corrFile) 

291 return result 

292 else: 

293 print("Fit failed") 

294 return None 

295 

296 

297class OnlineSolver(): 

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

299 """ 

300 

301 def __init__(self): 

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

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

304 with warnings.catch_warnings(): 

305 warnings.simplefilter("ignore") 

306 from astroquery.astrometry_net import AstrometryNet 

307 

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

309 self.adn = AstrometryNet() 

310 self.adn.api_key = self.apiKey 

311 

312 @staticmethod 

313 def getApiKey(): 

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

315 

316 Raises a RuntimeError if it isn't found. 

317 

318 Returns 

319 ------- 

320 apiKey : str 

321 The astrometry.net API key, if present. 

322 

323 Raises 

324 ------ 

325 RuntimeError 

326 Raised if the ASTROMETRY_NET_API_KEY is not set. 

327 """ 

328 try: 

329 return os.environ['ASTROMETRY_NET_API_KEY'] 

330 except KeyError as e: 

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

332 raise RuntimeError(msg) from e 

333 

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

335 # to the run method on the CommandLineSolver 

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

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

338 online solver. 

339 

340 Parameters 

341 ---------- 

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

343 The input exposure. Only used for its wcs. 

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

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

346 default run of CharacterizeImageTask is suitable. 

347 percentageScaleError : `float`, optional 

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

349 radius : `float`, optional 

350 The search radius from the nominal wcs in degrees. 

351 scaleEstimate : `float`, optional 

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

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

354 

355 Returns 

356 ------- 

357 result : `dict` or `None` 

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

359 the fit failed: 

360 ``nominalRa`` : `lsst.geom.Angle` 

361 The nominal ra from the exposure's boresight. 

362 ``nominalDec`` : `lsst.geom.Angle` 

363 The nominal dec from the exposure's boresight. 

364 ``calculatedRa`` : `lsst.geom.Angle` 

365 The fitted ra. 

366 ``calculatedDec`` : `lsst.geom.Angle` 

367 The fitted dec. 

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

369 The change in ra, as an Angle. 

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

371 The change in dec, as an Angle. 

372 ``deltaRaArcsec`` : `float`` 

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

374 ``deltaDecArcsec`` : `float` 

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

376 ``astrometry_net_wcs_header`` : `dict` 

377 The fitted wcs, as a header dict. 

378 """ 

379 nominalWcs = exp.getWcs() 

380 if nominalWcs is not None: 

381 ra, dec = nominalWcs.getSkyOrigin() 

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

383 else: 

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

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

386 ra, dec = vi.boresightRaDec 

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

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

389 

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

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

392 

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

394 scale_units = 'arcsecperpix' 

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

396 scale_err = percentageScaleError # error as percentage 

397 center_ra = ra.asDegrees() 

398 center_dec = dec.asDegrees() 

399 radius = radius if radius else 180 # degrees 

400 try: 

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

402 sourceCat['base_SdssCentroid_y'], 

403 image_width, image_height, 

404 scale_units=scale_units, 

405 scale_type=scale_type, 

406 scale_est=scaleEstimate, 

407 scale_err=scale_err, 

408 center_ra=center_ra, 

409 center_dec=center_dec, 

410 radius=radius, 

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

412 solve_timeout=240) 

413 except RuntimeError: 

414 print('Failed to find a solution') 

415 return None 

416 

417 print('Finished solving!') 

418 

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

420 

421 if 'CRVAL1' not in wcs_header: 

422 raise RuntimeError("Astrometric fit failed.") 

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

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

425 

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

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

428 

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

430 # like the CommandLineSolver does. 

431 

432 result = {'nominalRa': nominalRa, 

433 'nominalDec': nominalDec, 

434 'calculatedRa': calculatedRa, 

435 'calculatedDec': calculatedDec, 

436 'deltaRa': deltaRa, 

437 'deltaDec': deltaDec, 

438 'deltaRaArcsec': deltaRa.asArcseconds(), 

439 'deltaDecArcsec': deltaDec.asArcseconds(), 

440 'astrometry_net_wcs_header': wcs_header, 

441 'nSources': len(sourceCat), 

442 } 

443 

444 return result