Coverage for python/lsst/summit/utils/astrometry/anet.py: 19%
176 statements
« prev ^ index » next coverage.py v6.5.0, created at 2023-03-10 03:53 -0800
« prev ^ index » next coverage.py v6.5.0, created at 2023-03-10 03:53 -0800
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 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")
141 def _writeConfigFile(self, wide):
142 """Write a temporary config file for astrometry.net.
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.
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).")
160 lines = []
161 if self.checkInParallel:
162 lines.append('inparallel')
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
172 def _writeFitsTable(self, sourceCat):
173 """Write the source table to a FITS file and return the filename.
175 Parameters
176 ----------
177 sourceCat : `lsst.afw.table.SourceCatalog`
178 The source catalog to write to a FITS file for the solver.
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])
200 filename = tempfile.mktemp(suffix='.fits')
201 hdu.writeto(filename)
202 return filename
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.
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.
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")
238 configFile = self._writeConfigFile(wide=isWideField)
239 print(f'Fitting image with {len(sourceCat)} sources', end='')
240 fitsFile = self._writeFitsTable(sourceCat)
242 plateScale = wcs.getPixelScale().asArcseconds()
243 scaleMin = plateScale*(1 - percentageScaleError/100)
244 scaleMax = plateScale*(1 + percentageScaleError/100)
246 ra, dec = wcs.getSkyOrigin()
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)
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 )
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()
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'
286 if not os.path.exists(wcsFile):
287 print("but failed to find a solution.")
288 return None
290 result = AstrometryNetResult(wcsFile, corrFile)
291 return result
292 else:
293 print("Fit failed")
294 return None
297class OnlineSolver():
298 """A class to solve an image using the Astrometry.net online service.
299 """
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
308 self.apiKey = self.getApiKey() # raises if not present so do first
309 self.adn = AstrometryNet()
310 self.adn.api_key = self.apiKey
312 @staticmethod
313 def getApiKey():
314 """Get the astrometry.net API key if possible.
316 Raises a RuntimeError if it isn't found.
318 Returns
319 -------
320 apiKey : str
321 The astrometry.net API key, if present.
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
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.
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.
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')
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.')
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
417 print('Finished solving!')
419 nominalRa, nominalDec = exp.getInfo().getVisitInfo().getBoresightRaDec()
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)
426 deltaRa = geom.Angle(wcs_header['CRVAL1'] - nominalRa.asDegrees(), geom.degrees)
427 deltaDec = geom.Angle(wcs_header['CRVAL2'] - nominalDec.asDegrees(), geom.degrees)
429 # TODO: DM-37213 change this to return an AstrometryNetResult class
430 # like the CommandLineSolver does.
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 }
444 return result