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

184 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-22 09:40 +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 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) -> None: 

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 assert self.meanSqErr is not None, "No meanSqErr calculated, cannot calculate rmsErrorPixels." 

108 return np.sqrt(self.meanSqErr) 

109 

110 @cached_property 

111 def rmsErrorArsec(self) -> float: 

112 return self.rmsErrorPixels * self.plateScale 

113 

114 

115class CommandLineSolver: 

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

117 

118 Parameters 

119 ---------- 

120 indexFilePath : `str` 

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

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

123 flag when calling `run()`. 

124 checkInParallel : `bool`, optional 

125 Do the checks in parallel. Default is True. 

126 timeout : `float`, optional 

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

128 binary : `str`, optional 

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

130 is assumed to be on the path. 

131 """ 

132 

133 def __init__( 

134 self, 

135 indexFilePath: str, 

136 checkInParallel: bool = True, 

137 timeout: float | int = 300, 

138 binary: str = "solve-field", 

139 fluxSlot: str = "base_CircularApertureFlux_3_0_instFlux", 

140 ): 

141 self.indexFilePath = indexFilePath 

142 self.checkInParallel = checkInParallel 

143 self.timeout = timeout 

144 self.binary = binary 

145 self.fluxSlot = fluxSlot 

146 if not shutil.which(binary): 

147 raise RuntimeError( 

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

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

150 ) 

151 

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

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

154 

155 Parameters 

156 ---------- 

157 wide : `bool` 

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

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

160 useGaia : `bool` 

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

162 

163 Returns 

164 ------- 

165 filename : `str` 

166 The filename to which the config file was written. 

167 """ 

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

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

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

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

172 raise RuntimeError( 

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

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

175 ) 

176 

177 lines = [] 

178 if self.checkInParallel: 

179 lines.append("inparallel") 

180 

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

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

183 lines.append("autoindex") 

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

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

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

187 return filename 

188 

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

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

191 

192 Parameters 

193 ---------- 

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

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

196 

197 Returns 

198 ------- 

199 filename : `str` 

200 The filename to which the catalog was written. 

201 """ 

202 fluxArray = sourceCat[self.fluxSlot] 

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

204 fluxArray = fluxArray[fluxFinite] 

205 indices = np.argsort(fluxArray) 

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

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

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

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

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

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

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

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

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

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

216 

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

218 hdu.writeto(filename) 

219 return filename 

220 

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

222 # to the run method on the OnlineSolver 

223 def run( 

224 self, 

225 exp: afwImage.Exposure, 

226 sourceCat: afwTable.SourceCatalog, 

227 isWideField: bool, 

228 *, 

229 useGaia: bool = False, 

230 percentageScaleError: float | int = 10, 

231 radius: float | None = None, 

232 silent: bool = True, 

233 ) -> AstrometryNetResult | None: 

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

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

236 

237 Parameters 

238 ---------- 

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

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

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

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

243 default run of CharacterizeImageTask is suitable. 

244 isWideField : `bool` 

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

246 Ignored if ``useGaia`` is ``True``. 

247 useGaia : `bool` 

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

249 ignored. 

250 percentageScaleError : `float`, optional 

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

252 radius : `float`, optional 

253 The search radius from the nominal wcs in degrees. 

254 silent : `bool`, optional 

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

256 chatty, so this is recommended. 

257 

258 Returns 

259 ------- 

260 result : `AstrometryNetResult` or `None` 

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

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

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

264 """ 

265 wcs = exp.getWcs() 

266 if not wcs: 

267 raise ValueError("No WCS in exposure") 

268 

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

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

271 fitsFile = self._writeFitsTable(sourceCat) 

272 

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

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

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

276 

277 ra, dec = wcs.getSkyOrigin() 

278 

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

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

281 mainTempDir = tempfile.gettempdir() 

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

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

284 

285 cmd = ( 

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

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

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

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

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

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

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

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

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

295 f"--scale-units arcsecperpix " 

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

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

298 f"--config {configFile} " 

299 f"-D {tempDir} " 

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

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

302 ) 

303 

304 t0 = time.time() 

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

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

307 t1 = time.time() 

308 

309 if result.returncode == 0: 

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

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

312 # for each obj 

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

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

315 wcsFile = outputTemplate + ".wcs" 

316 corrFile = outputTemplate + ".corr" 

317 

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

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

320 return None 

321 

322 fitResult = AstrometryNetResult(wcsFile, corrFile) 

323 return fitResult 

324 else: 

325 print("Fit failed") 

326 return None 

327 

328 

329class OnlineSolver: 

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

331 

332 def __init__(self) -> None: 

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

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

335 with warnings.catch_warnings(): 

336 warnings.simplefilter("ignore") 

337 from astroquery.astrometry_net import AstrometryNet 

338 

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

340 self.adn = AstrometryNet() 

341 self.adn.api_key = self.apiKey 

342 

343 @staticmethod 

344 def getApiKey() -> str: 

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

346 

347 Raises a RuntimeError if it isn't found. 

348 

349 Returns 

350 ------- 

351 apiKey : str 

352 The astrometry.net API key, if present. 

353 

354 Raises 

355 ------ 

356 RuntimeError 

357 Raised if the ASTROMETRY_NET_API_KEY is not set. 

358 """ 

359 try: 

360 return os.environ["ASTROMETRY_NET_API_KEY"] 

361 except KeyError as e: 

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

363 raise RuntimeError(msg) from e 

364 

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

366 # to the run method on the CommandLineSolver 

367 def run( 

368 self, 

369 exp: afwImage.Exposure, 

370 sourceCat: afwTable.SourceCatalog, 

371 *, 

372 percentageScaleError: float | int = 10, 

373 radius: float | None = None, 

374 scaleEstimate: float | None = None, 

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

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

377 online solver. 

378 

379 Parameters 

380 ---------- 

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

382 The input exposure. Only used for its wcs. 

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

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

385 default run of CharacterizeImageTask is suitable. 

386 percentageScaleError : `float`, optional 

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

388 radius : `float`, optional 

389 The search radius from the nominal wcs in degrees. 

390 scaleEstimate : `float`, optional 

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

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

393 

394 Returns 

395 ------- 

396 result : `dict` or `None` 

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

398 the fit failed: 

399 ``nominalRa`` : `lsst.geom.Angle` 

400 The nominal ra from the exposure's boresight. 

401 ``nominalDec`` : `lsst.geom.Angle` 

402 The nominal dec from the exposure's boresight. 

403 ``calculatedRa`` : `lsst.geom.Angle` 

404 The fitted ra. 

405 ``calculatedDec`` : `lsst.geom.Angle` 

406 The fitted dec. 

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

408 The change in ra, as an Angle. 

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

410 The change in dec, as an Angle. 

411 ``deltaRaArcsec`` : `float`` 

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

413 ``deltaDecArcsec`` : `float` 

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

415 ``astrometry_net_wcs_header`` : `dict` 

416 The fitted wcs, as a header dict. 

417 """ 

418 nominalWcs = exp.getWcs() 

419 if nominalWcs is not None: 

420 ra, dec = nominalWcs.getSkyOrigin() 

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

422 else: 

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

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

425 ra, dec = vi.boresightRaDec 

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

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

428 

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

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

431 

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

433 scale_units = "arcsecperpix" 

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

435 scale_err = percentageScaleError # error as percentage 

436 center_ra = ra.asDegrees() 

437 center_dec = dec.asDegrees() 

438 radius = radius if radius else 180 # degrees 

439 try: 

440 wcs_header = self.adn.solve_from_source_list( 

441 sourceCat["base_SdssCentroid_x"], 

442 sourceCat["base_SdssCentroid_y"], 

443 image_width, 

444 image_height, 

445 scale_units=scale_units, 

446 scale_type=scale_type, 

447 scale_est=scaleEstimate, 

448 scale_err=scale_err, 

449 center_ra=center_ra, 

450 center_dec=center_dec, 

451 radius=radius, 

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

453 solve_timeout=240, 

454 ) 

455 except RuntimeError: 

456 print("Failed to find a solution") 

457 return None 

458 

459 print("Finished solving!") 

460 

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

462 

463 if "CRVAL1" not in wcs_header: 

464 raise RuntimeError("Astrometric fit failed.") 

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

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

467 

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

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

470 

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

472 # like the CommandLineSolver does. 

473 

474 result = { 

475 "nominalRa": nominalRa, 

476 "nominalDec": nominalDec, 

477 "calculatedRa": calculatedRa, 

478 "calculatedDec": calculatedDec, 

479 "deltaRa": deltaRa, 

480 "deltaDec": deltaDec, 

481 "deltaRaArcsec": deltaRa.asArcseconds(), 

482 "deltaDecArcsec": deltaDec.asArcseconds(), 

483 "astrometry_net_wcs_header": wcs_header, 

484 "nSources": len(sourceCat), 

485 } 

486 

487 return result