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

183 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-05-03 04:43 -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 

31from typing import Any 

32 

33import numpy as np 

34from astropy.io import fits 

35 

36import lsst.afw.geom as afwGeom 

37import lsst.afw.image as afwImage 

38import lsst.afw.table as afwTable 

39import lsst.geom as geom 

40 

41from .utils import headerToWcs 

42 

43__all__ = ["AstrometryNetResult", "CommandLineSolver", "OnlineSolver"] 

44 

45 

46@dataclass(frozen=True) 

47class AstrometryNetResult: 

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

49 line fitter. 

50 

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

52 calculates the plate scale and astrometric scatter measurement in arcsec 

53 and pixels. 

54 

55 Parameters 

56 ---------- 

57 wcsFile : `str` 

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

59 corrFile : `str`, optional 

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

61 """ 

62 

63 wcsFile: str 

64 corrFile: str | None = None 

65 

66 def __post_init__(self): 

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

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

69 self.wcs 

70 self.rmsErrorArsec 

71 

72 @cached_property 

73 def wcs(self) -> afwGeom.SkyWcs: 

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

75 header = f[0].header 

76 return headerToWcs(header) 

77 

78 @cached_property 

79 def plateScale(self) -> float: 

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

81 

82 @cached_property 

83 def meanSqErr(self) -> float | None: 

84 if not self.corrFile: 

85 return None 

86 

87 try: 

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

89 data = f[1].data 

90 

91 meanSqErr = 0.0 

92 count = 0 

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

94 row = data[i] 

95 count += 1 

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

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

98 meanSqErr += error 

99 meanSqErr /= count # divide by number of stars 

100 return meanSqErr 

101 except Exception as e: 

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

103 return None 

104 

105 @cached_property 

106 def rmsErrorPixels(self) -> float: 

107 return np.sqrt(self.meanSqErr) 

108 

109 @cached_property 

110 def rmsErrorArsec(self) -> float: 

111 return self.rmsErrorPixels * self.plateScale 

112 

113 

114class CommandLineSolver: 

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

116 

117 Parameters 

118 ---------- 

119 indexFilePath : `str` 

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

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

122 flag when calling `run()`. 

123 checkInParallel : `bool`, optional 

124 Do the checks in parallel. Default is True. 

125 timeout : `float`, optional 

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

127 binary : `str`, optional 

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

129 is assumed to be on the path. 

130 """ 

131 

132 def __init__( 

133 self, 

134 indexFilePath: str | None = None, 

135 checkInParallel: bool = True, 

136 timeout: float | int = 300, 

137 binary: str = "solve-field", 

138 fluxSlot: str = "base_CircularApertureFlux_3_0_instFlux", 

139 ): 

140 self.indexFilePath = indexFilePath 

141 self.checkInParallel = checkInParallel 

142 self.timeout = timeout 

143 self.binary = binary 

144 self.fluxSlot = fluxSlot 

145 if not shutil.which(binary): 

146 raise RuntimeError( 

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

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

149 ) 

150 

151 def _writeConfigFile(self, wide: bool, useGaia: bool) -> str: 

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

153 

154 Parameters 

155 ---------- 

156 wide : `bool` 

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

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

159 useGaia : `bool` 

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

161 

162 Returns 

163 ------- 

164 filename : `str` 

165 The filename to which the config file was written. 

166 """ 

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

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

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

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

171 raise RuntimeError( 

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

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

174 ) 

175 

176 lines = [] 

177 if self.checkInParallel: 

178 lines.append("inparallel") 

179 

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

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

182 lines.append("autoindex") 

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

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

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

186 return filename 

187 

188 def _writeFitsTable(self, sourceCat: afwTable.SourceCatalog) -> str: 

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

190 

191 Parameters 

192 ---------- 

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

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

195 

196 Returns 

197 ------- 

198 filename : `str` 

199 The filename to which the catalog was written. 

200 """ 

201 fluxArray = sourceCat[self.fluxSlot] 

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

203 fluxArray = fluxArray[fluxFinite] 

204 indices = np.argsort(fluxArray) 

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

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

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

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

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

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

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

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

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

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

215 

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

217 hdu.writeto(filename) 

218 return filename 

219 

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

221 # to the run method on the OnlineSolver 

222 def run( 

223 self, 

224 exp: afwImage.Exposure, 

225 sourceCat: afwTable.SourceCatalog, 

226 isWideField: bool, 

227 *, 

228 useGaia: bool = False, 

229 percentageScaleError: float | int = 10, 

230 radius: float | None = None, 

231 silent: bool = True, 

232 ) -> AstrometryNetResult | None: 

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

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

235 

236 Parameters 

237 ---------- 

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

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

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

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

242 default run of CharacterizeImageTask is suitable. 

243 isWideField : `bool` 

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

245 Ignored if ``useGaia`` is ``True``. 

246 useGaia : `bool` 

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

248 ignored. 

249 percentageScaleError : `float`, optional 

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

251 radius : `float`, optional 

252 The search radius from the nominal wcs in degrees. 

253 silent : `bool`, optional 

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

255 chatty, so this is recommended. 

256 

257 Returns 

258 ------- 

259 result : `AstrometryNetResult` or `None` 

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

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

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

263 """ 

264 wcs = exp.getWcs() 

265 if not wcs: 

266 raise ValueError("No WCS in exposure") 

267 

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

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

270 fitsFile = self._writeFitsTable(sourceCat) 

271 

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

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

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

275 

276 ra, dec = wcs.getSkyOrigin() 

277 

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

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

280 mainTempDir = tempfile.gettempdir() 

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

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

283 

284 cmd = ( 

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

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

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

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

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

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

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

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

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

294 f"--scale-units arcsecperpix " 

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

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

297 f"--config {configFile} " 

298 f"-D {tempDir} " 

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

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

301 ) 

302 

303 t0 = time.time() 

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

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

306 t1 = time.time() 

307 

308 if result.returncode == 0: 

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

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

311 # for each obj 

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

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

314 wcsFile = outputTemplate + ".wcs" 

315 corrFile = outputTemplate + ".corr" 

316 

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

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

319 return None 

320 

321 result = AstrometryNetResult(wcsFile, corrFile) 

322 return result 

323 else: 

324 print("Fit failed") 

325 return None 

326 

327 

328class OnlineSolver: 

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

330 

331 def __init__(self): 

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

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

334 with warnings.catch_warnings(): 

335 warnings.simplefilter("ignore") 

336 from astroquery.astrometry_net import AstrometryNet 

337 

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

339 self.adn = AstrometryNet() 

340 self.adn.api_key = self.apiKey 

341 

342 @staticmethod 

343 def getApiKey() -> str: 

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

345 

346 Raises a RuntimeError if it isn't found. 

347 

348 Returns 

349 ------- 

350 apiKey : str 

351 The astrometry.net API key, if present. 

352 

353 Raises 

354 ------ 

355 RuntimeError 

356 Raised if the ASTROMETRY_NET_API_KEY is not set. 

357 """ 

358 try: 

359 return os.environ["ASTROMETRY_NET_API_KEY"] 

360 except KeyError as e: 

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

362 raise RuntimeError(msg) from e 

363 

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

365 # to the run method on the CommandLineSolver 

366 def run( 

367 self, 

368 exp: afwImage.Exposure, 

369 sourceCat: afwTable.SourceCatalog, 

370 *, 

371 percentageScaleError: float | int = 10, 

372 radius: float | None = None, 

373 scaleEstimate: float | None = None, 

374 ) -> dict[str, Any] | None: 

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

376 online solver. 

377 

378 Parameters 

379 ---------- 

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

381 The input exposure. Only used for its wcs. 

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

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

384 default run of CharacterizeImageTask is suitable. 

385 percentageScaleError : `float`, optional 

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

387 radius : `float`, optional 

388 The search radius from the nominal wcs in degrees. 

389 scaleEstimate : `float`, optional 

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

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

392 

393 Returns 

394 ------- 

395 result : `dict` or `None` 

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

397 the fit failed: 

398 ``nominalRa`` : `lsst.geom.Angle` 

399 The nominal ra from the exposure's boresight. 

400 ``nominalDec`` : `lsst.geom.Angle` 

401 The nominal dec from the exposure's boresight. 

402 ``calculatedRa`` : `lsst.geom.Angle` 

403 The fitted ra. 

404 ``calculatedDec`` : `lsst.geom.Angle` 

405 The fitted dec. 

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

407 The change in ra, as an Angle. 

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

409 The change in dec, as an Angle. 

410 ``deltaRaArcsec`` : `float`` 

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

412 ``deltaDecArcsec`` : `float` 

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

414 ``astrometry_net_wcs_header`` : `dict` 

415 The fitted wcs, as a header dict. 

416 """ 

417 nominalWcs = exp.getWcs() 

418 if nominalWcs is not None: 

419 ra, dec = nominalWcs.getSkyOrigin() 

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

421 else: 

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

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

424 ra, dec = vi.boresightRaDec 

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

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

427 

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

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

430 

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

432 scale_units = "arcsecperpix" 

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

434 scale_err = percentageScaleError # error as percentage 

435 center_ra = ra.asDegrees() 

436 center_dec = dec.asDegrees() 

437 radius = radius if radius else 180 # degrees 

438 try: 

439 wcs_header = self.adn.solve_from_source_list( 

440 sourceCat["base_SdssCentroid_x"], 

441 sourceCat["base_SdssCentroid_y"], 

442 image_width, 

443 image_height, 

444 scale_units=scale_units, 

445 scale_type=scale_type, 

446 scale_est=scaleEstimate, 

447 scale_err=scale_err, 

448 center_ra=center_ra, 

449 center_dec=center_dec, 

450 radius=radius, 

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

452 solve_timeout=240, 

453 ) 

454 except RuntimeError: 

455 print("Failed to find a solution") 

456 return None 

457 

458 print("Finished solving!") 

459 

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

461 

462 if "CRVAL1" not in wcs_header: 

463 raise RuntimeError("Astrometric fit failed.") 

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

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

466 

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

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

469 

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

471 # like the CommandLineSolver does. 

472 

473 result = { 

474 "nominalRa": nominalRa, 

475 "nominalDec": nominalDec, 

476 "calculatedRa": calculatedRa, 

477 "calculatedDec": calculatedDec, 

478 "deltaRa": deltaRa, 

479 "deltaDec": deltaDec, 

480 "deltaRaArcsec": deltaRa.asArcseconds(), 

481 "deltaDecArcsec": deltaDec.asArcseconds(), 

482 "astrometry_net_wcs_header": wcs_header, 

483 "nSources": len(sourceCat), 

484 } 

485 

486 return result