Coverage for python/lsst/summit/utils/astrometry/anet.py: 19%
173 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-12-16 11:38 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2022-12-16 11:38 +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/>.
22import os
23import shutil
24import subprocess
25import tempfile
26import numpy as np
27from astropy.io import fits
28import time
29import uuid
30import warnings
32from dataclasses import dataclass
33from functools import cached_property
35import lsst.geom as geom
37from .utils import headerToWcs
39__all__ = ['AstrometryNetResult', 'CommandLineSolver', 'OnlineSolver']
42@dataclass(frozen=True)
43class AstrometryNetResult:
44 """Minimal wrapper class to construct and return results from the command
45 line fitter.
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.
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
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
67 @cached_property
68 def wcs(self):
69 with fits.open(self.wcsFile) as f:
70 header = f[0].header
71 return headerToWcs(header)
73 @cached_property
74 def plateScale(self):
75 return self.wcs.getPixelScale().asArcseconds()
77 @cached_property
78 def meanSqErr(self):
79 if not self.corrFile:
80 return None
82 try:
83 with fits.open(self.corrFile) as f:
84 data = f[1].data
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)}')
99 @cached_property
100 def rmsErrorPixels(self):
101 return np.sqrt(self.meanSqErr)
103 @cached_property
104 def rmsErrorArsec(self):
105 return self.rmsErrorPixels * self.plateScale
108class CommandLineSolver():
109 """An interface for the solve-field command line tool from astrometry.net.
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")
139 def _writeConfigFile(self, wide):
140 """Write a temporary config file for astrometry.net.
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.
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).")
158 lines = []
159 if self.checkInParallel:
160 lines.append('inparallel')
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
170 def _writeFitsTable(self, sourceCat):
171 """Write the source table to a FITS file and return the filename.
173 Parameters
174 ----------
175 sourceCat : `lsst.afw.table.SourceCatalog`
176 The source catalog to write to a FITS file for the solver.
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])
197 filename = tempfile.mktemp(suffix='.fits')
198 hdu.writeto(filename)
199 return filename
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.
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.
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")
235 configFile = self._writeConfigFile(wide=isWideField)
236 fitsFile = self._writeFitsTable(sourceCat)
238 plateScale = wcs.getPixelScale().asArcseconds()
239 scaleMin = plateScale*(1 - percentageScaleError/100)
240 scaleMax = plateScale*(1 + percentageScaleError/100)
242 ra, dec = wcs.getSkyOrigin()
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)
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 )
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()
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'
280 if not os.path.exists(wcsFile):
281 print("but failed to find a solution.")
282 return None
284 result = AstrometryNetResult(wcsFile, corrFile)
285 return result
286 else:
287 print("Fit failed")
288 return None
291class OnlineSolver():
292 """A class to solve an image using the Astrometry.net online service.
293 """
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
302 self.apiKey = self.getApiKey() # raises if not present so do first
303 self.adn = AstrometryNet()
304 self.adn.api_key = self.apiKey
306 @staticmethod
307 def getApiKey():
308 """Get the astrometry.net API key if possible.
310 Raises a RuntimeError if it isn't found.
312 Returns
313 -------
314 apiKey : str
315 The astrometry.net API key, if present.
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
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.
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.
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')
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.')
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
411 print('Finished solving!')
413 nominalRa, nominalDec = exp.getInfo().getVisitInfo().getBoresightRaDec()
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)
420 deltaRa = geom.Angle(wcs_header['CRVAL1'] - nominalRa.asDegrees(), geom.degrees)
421 deltaDec = geom.Angle(wcs_header['CRVAL2'] - nominalDec.asDegrees(), geom.degrees)
423 # TODO: DM-37213 change this to return an AstrometryNetResult class
424 # like the CommandLineSolver does.
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 }
438 return result