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

178 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-04-13 04:54 -0700

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 time 

27import uuid 

28import warnings 

29from dataclasses import dataclass 

30from functools import cached_property 

31 

32import numpy as np 

33from astropy.io import fits 

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 

59 wcsFile: str 

60 corrFile: str | None = None 

61 

62 def __post_init__(self): 

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

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

65 self.wcs 

66 self.rmsErrorArsec 

67 

68 @cached_property 

69 def wcs(self): 

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

71 header = f[0].header 

72 return headerToWcs(header) 

73 

74 @cached_property 

75 def plateScale(self): 

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

77 

78 @cached_property 

79 def meanSqErr(self): 

80 if not self.corrFile: 

81 return None 

82 

83 try: 

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

85 data = f[1].data 

86 

87 meanSqErr = 0.0 

88 count = 0 

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

90 row = data[i] 

91 count += 1 

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

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

94 meanSqErr += error 

95 meanSqErr /= count # divide by number of stars 

96 return meanSqErr 

97 except Exception as e: 

98 print(f"Failed for calculate astrometric scatter: {repr(e)}") 

99 

100 @cached_property 

101 def rmsErrorPixels(self): 

102 return np.sqrt(self.meanSqErr) 

103 

104 @cached_property 

105 def rmsErrorArsec(self): 

106 return self.rmsErrorPixels * self.plateScale 

107 

108 

109class CommandLineSolver: 

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

111 

112 Parameters 

113 ---------- 

114 indexFilePath : `str` 

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

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

117 flag when calling `run()`. 

118 checkInParallel : `bool`, optional 

119 Do the checks in parallel. Default is True. 

120 timeout : `float`, optional 

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

122 binary : `str`, optional 

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

124 is assumed to be on the path. 

125 """ 

126 

127 def __init__( 

128 self, 

129 indexFilePath=None, 

130 checkInParallel=True, 

131 timeout=300, 

132 binary="solve-field", 

133 fluxSlot="base_CircularApertureFlux_3_0_instFlux", 

134 ): 

135 self.indexFilePath = indexFilePath 

136 self.checkInParallel = checkInParallel 

137 self.timeout = timeout 

138 self.binary = binary 

139 self.fluxSlot = fluxSlot 

140 if not shutil.which(binary): 

141 raise RuntimeError( 

142 f"Could not find {binary} in path, please install 'solve-field' and either" 

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

144 ) 

145 

146 def _writeConfigFile(self, wide, useGaia): 

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

148 

149 Parameters 

150 ---------- 

151 wide : `bool` 

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

153 the index file path. Ignored if ``useGaia`` is ``True``. 

154 useGaia : `bool` 

155 Use the 5200 Gaia catalog? If ``True``, ``wide`` is ignored. 

156 

157 Returns 

158 ------- 

159 filename : `str` 

160 The filename to which the config file was written. 

161 """ 

162 fileSet = "4100" if wide else "4200" 

163 fileSet = "5200/LITE" if useGaia else fileSet 

164 indexFileDir = os.path.join(self.indexFilePath, fileSet) 

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

166 raise RuntimeError( 

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

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

169 ) 

170 

171 lines = [] 

172 if self.checkInParallel: 

173 lines.append("inparallel") 

174 

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

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

177 lines.append("autoindex") 

178 filename = tempfile.mktemp(suffix=".cfg") 

179 with open(filename, "w") as f: 

180 f.writelines(line + "\n" for line in lines) 

181 return filename 

182 

183 def _writeFitsTable(self, sourceCat): 

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

185 

186 Parameters 

187 ---------- 

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

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

190 

191 Returns 

192 ------- 

193 filename : `str` 

194 The filename to which the catalog was written. 

195 """ 

196 fluxArray = sourceCat[self.fluxSlot] 

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

198 fluxArray = fluxArray[fluxFinite] 

199 indices = np.argsort(fluxArray) 

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

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

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

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

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

205 x = fits.Column(name="X", format="D", array=xArray) 

206 y = fits.Column(name="Y", format="D", array=yArray) 

207 flux = fits.Column(name="FLUX", format="D", array=fluxArray) 

208 print(f" of which {len(fluxArray)} made it into the fit") 

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

210 

211 filename = tempfile.mktemp(suffix=".fits") 

212 hdu.writeto(filename) 

213 return filename 

214 

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

216 # to the run method on the OnlineSolver 

217 def run( 

218 self, exp, sourceCat, isWideField, *, useGaia=False, percentageScaleError=10, radius=None, silent=True 

219 ): 

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

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

222 

223 Parameters 

224 ---------- 

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

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

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

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

229 default run of CharacterizeImageTask is suitable. 

230 isWideField : `bool` 

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

232 Ignored if ``useGaia`` is ``True``. 

233 useGaia : `bool` 

234 Use the Gaia 5200/LITE index files? If set, ``isWideField`` is 

235 ignored. 

236 percentageScaleError : `float`, optional 

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

238 radius : `float`, optional 

239 The search radius from the nominal wcs in degrees. 

240 silent : `bool`, optional 

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

242 chatty, so this is recommended. 

243 

244 Returns 

245 ------- 

246 result : `AstrometryNetResult` or `None` 

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

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

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

250 """ 

251 wcs = exp.getWcs() 

252 if not wcs: 

253 raise ValueError("No WCS in exposure") 

254 

255 configFile = self._writeConfigFile(wide=isWideField, useGaia=useGaia) 

256 print(f"Fitting image with {len(sourceCat)} sources", end="") 

257 fitsFile = self._writeFitsTable(sourceCat) 

258 

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

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

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

262 

263 ra, dec = wcs.getSkyOrigin() 

264 

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

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

267 mainTempDir = tempfile.gettempdir() 

268 tempDirSuffix = str(uuid.uuid1()).split("-")[0] 

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

270 

271 cmd = ( 

272 f"{self.binary} {fitsFile} " # the data 

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

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

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

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

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

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

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

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

281 f"--scale-units arcsecperpix " 

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

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

284 f"--config {configFile} " 

285 f"-D {tempDir} " 

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

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

288 ) 

289 

290 t0 = time.time() 

291 with open(os.devnull, "w") as devnull: 

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

293 t1 = time.time() 

294 

295 if result.returncode == 0: 

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

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

298 # for each obj 

299 basename = os.path.basename(fitsFile).removesuffix(".fits") 

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

301 wcsFile = outputTemplate + ".wcs" 

302 corrFile = outputTemplate + ".corr" 

303 

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

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

306 return None 

307 

308 result = AstrometryNetResult(wcsFile, corrFile) 

309 return result 

310 else: 

311 print("Fit failed") 

312 return None 

313 

314 

315class OnlineSolver: 

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

317 

318 def __init__(self): 

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

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

321 with warnings.catch_warnings(): 

322 warnings.simplefilter("ignore") 

323 from astroquery.astrometry_net import AstrometryNet 

324 

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

326 self.adn = AstrometryNet() 

327 self.adn.api_key = self.apiKey 

328 

329 @staticmethod 

330 def getApiKey(): 

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

332 

333 Raises a RuntimeError if it isn't found. 

334 

335 Returns 

336 ------- 

337 apiKey : str 

338 The astrometry.net API key, if present. 

339 

340 Raises 

341 ------ 

342 RuntimeError 

343 Raised if the ASTROMETRY_NET_API_KEY is not set. 

344 """ 

345 try: 

346 return os.environ["ASTROMETRY_NET_API_KEY"] 

347 except KeyError as e: 

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

349 raise RuntimeError(msg) from e 

350 

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

352 # to the run method on the CommandLineSolver 

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

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

355 online solver. 

356 

357 Parameters 

358 ---------- 

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

360 The input exposure. Only used for its wcs. 

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

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

363 default run of CharacterizeImageTask is suitable. 

364 percentageScaleError : `float`, optional 

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

366 radius : `float`, optional 

367 The search radius from the nominal wcs in degrees. 

368 scaleEstimate : `float`, optional 

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

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

371 

372 Returns 

373 ------- 

374 result : `dict` or `None` 

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

376 the fit failed: 

377 ``nominalRa`` : `lsst.geom.Angle` 

378 The nominal ra from the exposure's boresight. 

379 ``nominalDec`` : `lsst.geom.Angle` 

380 The nominal dec from the exposure's boresight. 

381 ``calculatedRa`` : `lsst.geom.Angle` 

382 The fitted ra. 

383 ``calculatedDec`` : `lsst.geom.Angle` 

384 The fitted dec. 

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

386 The change in ra, as an Angle. 

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

388 The change in dec, as an Angle. 

389 ``deltaRaArcsec`` : `float`` 

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

391 ``deltaDecArcsec`` : `float` 

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

393 ``astrometry_net_wcs_header`` : `dict` 

394 The fitted wcs, as a header dict. 

395 """ 

396 nominalWcs = exp.getWcs() 

397 if nominalWcs is not None: 

398 ra, dec = nominalWcs.getSkyOrigin() 

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

400 else: 

401 print("Trying to process image with None wcs - good luck!") 

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

403 ra, dec = vi.boresightRaDec 

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

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

406 

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

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

409 

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

411 scale_units = "arcsecperpix" 

412 scale_type = "ev" # ev means submit estimate and % error 

413 scale_err = percentageScaleError # error as percentage 

414 center_ra = ra.asDegrees() 

415 center_dec = dec.asDegrees() 

416 radius = radius if radius else 180 # degrees 

417 try: 

418 wcs_header = self.adn.solve_from_source_list( 

419 sourceCat["base_SdssCentroid_x"], 

420 sourceCat["base_SdssCentroid_y"], 

421 image_width, 

422 image_height, 

423 scale_units=scale_units, 

424 scale_type=scale_type, 

425 scale_est=scaleEstimate, 

426 scale_err=scale_err, 

427 center_ra=center_ra, 

428 center_dec=center_dec, 

429 radius=radius, 

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

431 solve_timeout=240, 

432 ) 

433 except RuntimeError: 

434 print("Failed to find a solution") 

435 return None 

436 

437 print("Finished solving!") 

438 

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

440 

441 if "CRVAL1" not in wcs_header: 

442 raise RuntimeError("Astrometric fit failed.") 

443 calculatedRa = geom.Angle(wcs_header["CRVAL1"], geom.degrees) 

444 calculatedDec = geom.Angle(wcs_header["CRVAL2"], geom.degrees) 

445 

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

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

448 

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

450 # like the CommandLineSolver does. 

451 

452 result = { 

453 "nominalRa": nominalRa, 

454 "nominalDec": nominalDec, 

455 "calculatedRa": calculatedRa, 

456 "calculatedDec": calculatedDec, 

457 "deltaRa": deltaRa, 

458 "deltaDec": deltaDec, 

459 "deltaRaArcsec": deltaRa.asArcseconds(), 

460 "deltaDecArcsec": deltaDec.asArcseconds(), 

461 "astrometry_net_wcs_header": wcs_header, 

462 "nSources": len(sourceCat), 

463 } 

464 

465 return result