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
« 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/>.
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
33import numpy as np
34from astropy.io import fits
36import lsst.afw.geom as afwGeom
37import lsst.afw.image as afwImage
38import lsst.afw.table as afwTable
39import lsst.geom as geom
41from .utils import headerToWcs
43__all__ = ["AstrometryNetResult", "CommandLineSolver", "OnlineSolver"]
46@dataclass(frozen=True)
47class AstrometryNetResult:
48 """Minimal wrapper class to construct and return results from the command
49 line fitter.
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.
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 """
63 wcsFile: str
64 corrFile: str | None = None
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
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)
78 @cached_property
79 def plateScale(self) -> float:
80 return self.wcs.getPixelScale().asArcseconds()
82 @cached_property
83 def meanSqErr(self) -> float | None:
84 if not self.corrFile:
85 return None
87 try:
88 with fits.open(self.corrFile) as f:
89 data = f[1].data
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
105 @cached_property
106 def rmsErrorPixels(self) -> float:
107 return np.sqrt(self.meanSqErr)
109 @cached_property
110 def rmsErrorArsec(self) -> float:
111 return self.rmsErrorPixels * self.plateScale
114class CommandLineSolver:
115 """An interface for the solve-field command line tool from astrometry.net.
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 """
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 )
151 def _writeConfigFile(self, wide: bool, useGaia: bool) -> str:
152 """Write a temporary config file for astrometry.net.
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.
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 )
176 lines = []
177 if self.checkInParallel:
178 lines.append("inparallel")
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
188 def _writeFitsTable(self, sourceCat: afwTable.SourceCatalog) -> str:
189 """Write the source table to a FITS file and return the filename.
191 Parameters
192 ----------
193 sourceCat : `lsst.afw.table.SourceCatalog`
194 The source catalog to write to a FITS file for the solver.
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])
216 filename = tempfile.mktemp(suffix=".fits")
217 hdu.writeto(filename)
218 return filename
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.
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.
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")
268 configFile = self._writeConfigFile(wide=isWideField, useGaia=useGaia)
269 print(f"Fitting image with {len(sourceCat)} sources", end="")
270 fitsFile = self._writeFitsTable(sourceCat)
272 plateScale = wcs.getPixelScale().asArcseconds()
273 scaleMin = plateScale * (1 - percentageScaleError / 100)
274 scaleMax = plateScale * (1 + percentageScaleError / 100)
276 ra, dec = wcs.getSkyOrigin()
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)
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 )
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()
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"
317 if not os.path.exists(wcsFile):
318 print("but failed to find a solution.")
319 return None
321 result = AstrometryNetResult(wcsFile, corrFile)
322 return result
323 else:
324 print("Fit failed")
325 return None
328class OnlineSolver:
329 """A class to solve an image using the Astrometry.net online service."""
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
338 self.apiKey = self.getApiKey() # raises if not present so do first
339 self.adn = AstrometryNet()
340 self.adn.api_key = self.apiKey
342 @staticmethod
343 def getApiKey() -> str:
344 """Get the astrometry.net API key if possible.
346 Raises a RuntimeError if it isn't found.
348 Returns
349 -------
350 apiKey : str
351 The astrometry.net API key, if present.
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
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.
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.
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")
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.")
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
458 print("Finished solving!")
460 nominalRa, nominalDec = exp.getInfo().getVisitInfo().getBoresightRaDec()
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)
467 deltaRa = geom.Angle(wcs_header["CRVAL1"] - nominalRa.asDegrees(), geom.degrees)
468 deltaDec = geom.Angle(wcs_header["CRVAL2"] - nominalDec.asDegrees(), geom.degrees)
470 # TODO: DM-37213 change this to return an AstrometryNetResult class
471 # like the CommandLineSolver does.
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 }
486 return result