Coverage for python/lsst/summit/utils/astrometry/anet.py: 23%
178 statements
« prev ^ index » next coverage.py v7.4.4, created at 2024-04-17 12:31 +0000
« prev ^ index » next coverage.py v7.4.4, created at 2024-04-17 12:31 +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 time
27import uuid
28import warnings
29from dataclasses import dataclass
30from functools import cached_property
32import numpy as np
33from astropy.io import fits
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 """
59 wcsFile: str
60 corrFile: str | None = None
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
68 @cached_property
69 def wcs(self):
70 with fits.open(self.wcsFile) as f:
71 header = f[0].header
72 return headerToWcs(header)
74 @cached_property
75 def plateScale(self):
76 return self.wcs.getPixelScale().asArcseconds()
78 @cached_property
79 def meanSqErr(self):
80 if not self.corrFile:
81 return None
83 try:
84 with fits.open(self.corrFile) as f:
85 data = f[1].data
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)}")
100 @cached_property
101 def rmsErrorPixels(self):
102 return np.sqrt(self.meanSqErr)
104 @cached_property
105 def rmsErrorArsec(self):
106 return self.rmsErrorPixels * self.plateScale
109class CommandLineSolver:
110 """An interface for the solve-field command line tool from astrometry.net.
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 """
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 )
146 def _writeConfigFile(self, wide, useGaia):
147 """Write a temporary config file for astrometry.net.
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.
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 )
171 lines = []
172 if self.checkInParallel:
173 lines.append("inparallel")
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
183 def _writeFitsTable(self, sourceCat):
184 """Write the source table to a FITS file and return the filename.
186 Parameters
187 ----------
188 sourceCat : `lsst.afw.table.SourceCatalog`
189 The source catalog to write to a FITS file for the solver.
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])
211 filename = tempfile.mktemp(suffix=".fits")
212 hdu.writeto(filename)
213 return filename
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.
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.
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")
255 configFile = self._writeConfigFile(wide=isWideField, useGaia=useGaia)
256 print(f"Fitting image with {len(sourceCat)} sources", end="")
257 fitsFile = self._writeFitsTable(sourceCat)
259 plateScale = wcs.getPixelScale().asArcseconds()
260 scaleMin = plateScale * (1 - percentageScaleError / 100)
261 scaleMax = plateScale * (1 + percentageScaleError / 100)
263 ra, dec = wcs.getSkyOrigin()
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)
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 )
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()
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"
304 if not os.path.exists(wcsFile):
305 print("but failed to find a solution.")
306 return None
308 result = AstrometryNetResult(wcsFile, corrFile)
309 return result
310 else:
311 print("Fit failed")
312 return None
315class OnlineSolver:
316 """A class to solve an image using the Astrometry.net online service."""
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
325 self.apiKey = self.getApiKey() # raises if not present so do first
326 self.adn = AstrometryNet()
327 self.adn.api_key = self.apiKey
329 @staticmethod
330 def getApiKey():
331 """Get the astrometry.net API key if possible.
333 Raises a RuntimeError if it isn't found.
335 Returns
336 -------
337 apiKey : str
338 The astrometry.net API key, if present.
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
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.
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.
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")
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.")
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
437 print("Finished solving!")
439 nominalRa, nominalDec = exp.getInfo().getVisitInfo().getBoresightRaDec()
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)
446 deltaRa = geom.Angle(wcs_header["CRVAL1"] - nominalRa.asDegrees(), geom.degrees)
447 deltaDec = geom.Angle(wcs_header["CRVAL2"] - nominalDec.asDegrees(), geom.degrees)
449 # TODO: DM-37213 change this to return an AstrometryNetResult class
450 # like the CommandLineSolver does.
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 }
465 return result