stdpipe 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stdpipe/__init__.py +9 -0
- stdpipe/artefacts.py +370 -0
- stdpipe/astrometry.py +2001 -0
- stdpipe/astrometry_quad.py +1643 -0
- stdpipe/astrometry_wcs.py +1141 -0
- stdpipe/catalogs.py +942 -0
- stdpipe/cutouts.py +570 -0
- stdpipe/data/legacysurvey_bricks.fits.gz +0 -0
- stdpipe/data/ps1skycells.txt +199708 -0
- stdpipe/db.py +130 -0
- stdpipe/fringe_removal.py +1086 -0
- stdpipe/lcs.py +440 -0
- stdpipe/photometry.py +2340 -0
- stdpipe/photometry_background.py +998 -0
- stdpipe/photometry_iraf.py +913 -0
- stdpipe/photometry_measure.py +2268 -0
- stdpipe/photometry_model.py +931 -0
- stdpipe/photometry_psf.py +1124 -0
- stdpipe/photometry_quality.py +185 -0
- stdpipe/pipeline.py +1303 -0
- stdpipe/plots.py +1582 -0
- stdpipe/psf.py +1542 -0
- stdpipe/realbogus.py +971 -0
- stdpipe/realbogus_features.py +1657 -0
- stdpipe/reproject.py +943 -0
- stdpipe/resolve.py +267 -0
- stdpipe/sfft.py +1077 -0
- stdpipe/simulation.py +2131 -0
- stdpipe/smoothing.py +488 -0
- stdpipe/subtraction.py +1324 -0
- stdpipe/templates.py +929 -0
- stdpipe/utils.py +365 -0
- stdpipe-0.4.0.dist-info/METADATA +171 -0
- stdpipe-0.4.0.dist-info/RECORD +37 -0
- stdpipe-0.4.0.dist-info/WHEEL +5 -0
- stdpipe-0.4.0.dist-info/licenses/LICENSE.md +21 -0
- stdpipe-0.4.0.dist-info/top_level.txt +1 -0
stdpipe/astrometry.py
ADDED
|
@@ -0,0 +1,2001 @@
|
|
|
1
|
+
import os, tempfile, shutil, shlex, re, warnings
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
from astropy.wcs import WCS
|
|
5
|
+
from astropy.io import fits
|
|
6
|
+
from astropy.wcs.utils import fit_wcs_from_points
|
|
7
|
+
from astropy.coordinates import SkyCoord, search_around_sky
|
|
8
|
+
from astropy.table import Table
|
|
9
|
+
from astropy import units as u
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
from scipy.spatial import KDTree
|
|
13
|
+
|
|
14
|
+
from . import utils
|
|
15
|
+
|
|
16
|
+
# Put these to common namespace
|
|
17
|
+
from .astrometry_quad import refine_wcs_quadhash
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_frame_center(filename=None, header=None, wcs=None, width=None, height=None, shape=None):
|
|
21
|
+
"""Returns image center RA, Dec, and angular radius in degrees.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
filename : str, optional
|
|
26
|
+
Path to FITS file.
|
|
27
|
+
header : astropy.io.fits.Header, optional
|
|
28
|
+
FITS header containing WCS keywords.
|
|
29
|
+
wcs : astropy.wcs.WCS, optional
|
|
30
|
+
WCS object.
|
|
31
|
+
width : int, optional
|
|
32
|
+
Image width in pixels. Read from header if not provided.
|
|
33
|
+
height : int, optional
|
|
34
|
+
Image height in pixels. Read from header if not provided.
|
|
35
|
+
shape : tuple of int, optional
|
|
36
|
+
Image shape ``(height, width)``. Used if `width`/`height` not given.
|
|
37
|
+
|
|
38
|
+
Returns
|
|
39
|
+
-------
|
|
40
|
+
ra : float or None
|
|
41
|
+
Right Ascension of frame center in degrees.
|
|
42
|
+
dec : float or None
|
|
43
|
+
Declination of frame center in degrees.
|
|
44
|
+
sr : float or None
|
|
45
|
+
Angular radius from center to corner in degrees.
|
|
46
|
+
"""
|
|
47
|
+
if not wcs:
|
|
48
|
+
if header:
|
|
49
|
+
wcs = WCS(header)
|
|
50
|
+
elif filename:
|
|
51
|
+
header = fits.getheader(filename, -1)
|
|
52
|
+
wcs = WCS(header)
|
|
53
|
+
|
|
54
|
+
if width is None or height is None:
|
|
55
|
+
if header is not None:
|
|
56
|
+
width = header['NAXIS1']
|
|
57
|
+
height = header['NAXIS2']
|
|
58
|
+
elif shape is not None:
|
|
59
|
+
height, width = shape
|
|
60
|
+
|
|
61
|
+
if not wcs or not wcs.is_celestial:
|
|
62
|
+
return None, None, None
|
|
63
|
+
|
|
64
|
+
ra1, dec1 = wcs.all_pix2world(0, 0, 0)
|
|
65
|
+
ra0, dec0 = wcs.all_pix2world(width / 2, height / 2, 0)
|
|
66
|
+
|
|
67
|
+
sr = spherical_distance(ra0, dec0, ra1, dec1)
|
|
68
|
+
|
|
69
|
+
return ra0.item(), dec0.item(), sr.item()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_pixscale(wcs=None, filename=None, header=None):
|
|
73
|
+
"""Returns pixel scale of an image in degrees per pixel.
|
|
74
|
+
|
|
75
|
+
Parameters
|
|
76
|
+
----------
|
|
77
|
+
wcs : astropy.wcs.WCS, optional
|
|
78
|
+
WCS object.
|
|
79
|
+
filename : str, optional
|
|
80
|
+
Path to FITS file.
|
|
81
|
+
header : astropy.io.fits.Header, optional
|
|
82
|
+
FITS header containing WCS keywords.
|
|
83
|
+
|
|
84
|
+
Returns
|
|
85
|
+
-------
|
|
86
|
+
float
|
|
87
|
+
Pixel scale in degrees per pixel.
|
|
88
|
+
"""
|
|
89
|
+
if not wcs:
|
|
90
|
+
if header:
|
|
91
|
+
wcs = WCS(header=header)
|
|
92
|
+
elif filename:
|
|
93
|
+
header = fits.getheader(filename, -1)
|
|
94
|
+
wcs = WCS(header=header)
|
|
95
|
+
|
|
96
|
+
return np.hypot(wcs.pixel_scale_matrix[0, 0], wcs.pixel_scale_matrix[0, 1])
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def radectoxyz(ra, dec):
|
|
100
|
+
ra_rad = np.deg2rad(ra)
|
|
101
|
+
dec_rad = np.deg2rad(dec)
|
|
102
|
+
xyz = np.array(
|
|
103
|
+
(
|
|
104
|
+
np.cos(dec_rad) * np.cos(ra_rad),
|
|
105
|
+
np.cos(dec_rad) * np.sin(ra_rad),
|
|
106
|
+
np.sin(dec_rad),
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return xyz
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def xyztoradec(xyz):
|
|
114
|
+
ra = np.arctan2(xyz[1], xyz[0])
|
|
115
|
+
ra += 2 * np.pi * (ra < 0)
|
|
116
|
+
dec = np.arcsin(xyz[2] / np.linalg.norm(xyz, axis=0))
|
|
117
|
+
|
|
118
|
+
return (np.rad2deg(ra), np.rad2deg(dec))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def spherical_distance(ra1, dec1, ra2, dec2):
|
|
122
|
+
"""Compute spherical distance between two points or sets of points.
|
|
123
|
+
|
|
124
|
+
Parameters
|
|
125
|
+
----------
|
|
126
|
+
ra1 : float or array_like
|
|
127
|
+
First point or set of points RA in degrees.
|
|
128
|
+
dec1 : float or array_like
|
|
129
|
+
First point or set of points Dec in degrees.
|
|
130
|
+
ra2 : float or array_like
|
|
131
|
+
Second point or set of points RA in degrees.
|
|
132
|
+
dec2 : float or array_like
|
|
133
|
+
Second point or set of points Dec in degrees.
|
|
134
|
+
|
|
135
|
+
Returns
|
|
136
|
+
-------
|
|
137
|
+
float or ndarray
|
|
138
|
+
Spherical distance in degrees.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
x = np.sin(np.deg2rad((ra1 - ra2) / 2))
|
|
142
|
+
x *= x
|
|
143
|
+
y = np.sin(np.deg2rad((dec1 - dec2) / 2))
|
|
144
|
+
y *= y
|
|
145
|
+
|
|
146
|
+
z = np.cos(np.deg2rad((dec1 + dec2) / 2))
|
|
147
|
+
z *= z
|
|
148
|
+
|
|
149
|
+
return np.rad2deg(2 * np.arcsin(np.sqrt(x * (z - y) + y)))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def spherical_match(ra1, dec1, ra2, dec2, sr=1 / 3600):
|
|
153
|
+
"""Positional match on the sphere for two lists of coordinates.
|
|
154
|
+
|
|
155
|
+
Aimed to be a direct replacement for :func:`esutil.htm.HTM.match` method with :code:`maxmatch=0`.
|
|
156
|
+
|
|
157
|
+
Parameters
|
|
158
|
+
----------
|
|
159
|
+
ra1 : array_like
|
|
160
|
+
First set of points RA in degrees.
|
|
161
|
+
dec1 : array_like
|
|
162
|
+
First set of points Dec in degrees.
|
|
163
|
+
ra2 : array_like
|
|
164
|
+
Second set of points RA in degrees.
|
|
165
|
+
dec2 : array_like
|
|
166
|
+
Second set of points Dec in degrees.
|
|
167
|
+
sr : float, optional
|
|
168
|
+
Maximal acceptable pair distance to be considered a match, in degrees.
|
|
169
|
+
Default is 1/3600 (1 arcsecond).
|
|
170
|
+
|
|
171
|
+
Returns
|
|
172
|
+
-------
|
|
173
|
+
idx1 : ndarray of int
|
|
174
|
+
Indices into the first list for matched pairs.
|
|
175
|
+
idx2 : ndarray of int
|
|
176
|
+
Indices into the second list for matched pairs.
|
|
177
|
+
dist : ndarray of float
|
|
178
|
+
Pairwise distances in degrees.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
# Ensure that inputs are arrays, and drop units if any
|
|
182
|
+
ra1 = np.array(np.atleast_1d(ra1))
|
|
183
|
+
dec1 = np.array(np.atleast_1d(dec1))
|
|
184
|
+
ra2 = np.array(np.atleast_1d(ra2))
|
|
185
|
+
dec2 = np.array(np.atleast_1d(dec2))
|
|
186
|
+
|
|
187
|
+
idx1, idx2, dist, _ = search_around_sky(
|
|
188
|
+
SkyCoord(ra1, dec1, unit='deg'), SkyCoord(ra2, dec2, unit='deg'), sr * u.deg
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
dist = dist.deg # convert to degrees
|
|
192
|
+
|
|
193
|
+
return idx1, idx2, dist
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def planar_match(x1, y1, x2, y2, sr=1):
|
|
197
|
+
"""Positional match on the plane for two lists of coordinates.
|
|
198
|
+
|
|
199
|
+
Parameters
|
|
200
|
+
----------
|
|
201
|
+
x1 : array_like
|
|
202
|
+
First set of points X coordinates.
|
|
203
|
+
y1 : array_like
|
|
204
|
+
First set of points Y coordinates.
|
|
205
|
+
x2 : array_like
|
|
206
|
+
Second set of points X coordinates.
|
|
207
|
+
y2 : array_like
|
|
208
|
+
Second set of points Y coordinates.
|
|
209
|
+
sr : float, optional
|
|
210
|
+
Maximal acceptable pair distance to be considered a match. Default is 1.
|
|
211
|
+
|
|
212
|
+
Returns
|
|
213
|
+
-------
|
|
214
|
+
idx1 : ndarray of int
|
|
215
|
+
Indices into the first list for matched pairs.
|
|
216
|
+
idx2 : ndarray of int
|
|
217
|
+
Indices into the second list for matched pairs.
|
|
218
|
+
dist : ndarray of float
|
|
219
|
+
Pairwise distances.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
kd = KDTree(np.array([x2, y2]).T)
|
|
223
|
+
idx1, idx2 = [], []
|
|
224
|
+
for i, js in enumerate(kd.query_ball_point(np.array([x1, y1]).T, sr)):
|
|
225
|
+
for j in js:
|
|
226
|
+
idx1.append(i)
|
|
227
|
+
idx2.append(j)
|
|
228
|
+
|
|
229
|
+
idx1 = np.array(idx1)
|
|
230
|
+
idx2 = np.array(idx2)
|
|
231
|
+
dist = np.hypot(x1[idx1] - x2[idx2], y1[idx1] - y2[idx2])
|
|
232
|
+
|
|
233
|
+
return idx1, idx2, dist
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def get_objects_center(obj, col_ra='ra', col_dec='dec'):
|
|
237
|
+
"""
|
|
238
|
+
Returns the center RA, Dec, and radius in degrees for a cloud of objects on the sky.
|
|
239
|
+
"""
|
|
240
|
+
xyz = radectoxyz(obj[col_ra], obj[col_dec])
|
|
241
|
+
xyz0 = np.mean(xyz, axis=1)
|
|
242
|
+
ra0, dec0 = xyztoradec(xyz0)
|
|
243
|
+
|
|
244
|
+
sr0 = np.max(spherical_distance(ra0, dec0, obj[col_ra], obj[col_dec]))
|
|
245
|
+
|
|
246
|
+
return ra0, dec0, sr0
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def blind_match_objects(
|
|
250
|
+
obj,
|
|
251
|
+
order=2,
|
|
252
|
+
update=False,
|
|
253
|
+
sn=20,
|
|
254
|
+
get_header=False,
|
|
255
|
+
width=None,
|
|
256
|
+
height=None,
|
|
257
|
+
center_ra=None,
|
|
258
|
+
center_dec=None,
|
|
259
|
+
radius=None,
|
|
260
|
+
scale_lower=None,
|
|
261
|
+
scale_upper=None,
|
|
262
|
+
scale_units='arcsecperpix',
|
|
263
|
+
config=None,
|
|
264
|
+
extra={},
|
|
265
|
+
_workdir=None,
|
|
266
|
+
_tmpdir=None,
|
|
267
|
+
_exe=None,
|
|
268
|
+
verbose=False,
|
|
269
|
+
):
|
|
270
|
+
"""Thin wrapper for blind plate solving using local Astrometry.Net and a list of detected objects.
|
|
271
|
+
|
|
272
|
+
It requires `solve-field` binary from Astrometry.Net and some index files to be locally available.
|
|
273
|
+
|
|
274
|
+
Parameters
|
|
275
|
+
----------
|
|
276
|
+
obj : astropy.table.Table
|
|
277
|
+
List of objects on the frame, must contain at least ``x``, ``y``, and ``flux`` columns.
|
|
278
|
+
order : int, optional
|
|
279
|
+
Order for the SIP spatial distortion polynomial.
|
|
280
|
+
update : bool, optional
|
|
281
|
+
If True, the object list will be updated in-place with correct ``ra`` and ``dec`` sky coordinates.
|
|
282
|
+
sn : float, optional
|
|
283
|
+
If provided, only objects with S/N exceeding this value will be used for matching.
|
|
284
|
+
get_header : bool, optional
|
|
285
|
+
If True, return the FITS header object instead of WCS solution.
|
|
286
|
+
width : int, optional
|
|
287
|
+
Image width in pixels, used for guessing the frame center.
|
|
288
|
+
height : int, optional
|
|
289
|
+
Image height in pixels, used for guessing the frame center.
|
|
290
|
+
center_ra : float, optional
|
|
291
|
+
Approximate center RA of the field in degrees.
|
|
292
|
+
center_dec : float, optional
|
|
293
|
+
Approximate center Dec of the field in degrees.
|
|
294
|
+
radius : float, optional
|
|
295
|
+
Search radius in degrees around the specified center.
|
|
296
|
+
scale_lower : float, optional
|
|
297
|
+
Lower limit for the pixel scale.
|
|
298
|
+
scale_upper : float, optional
|
|
299
|
+
Upper limit for the pixel scale.
|
|
300
|
+
scale_units : str, optional
|
|
301
|
+
Units for ``scale_lower``/``scale_upper``. One of ``arcsecperpix``,
|
|
302
|
+
``arcminwidth``, or ``degwidth``.
|
|
303
|
+
config : str, optional
|
|
304
|
+
Path to config file for ``solve-field``.
|
|
305
|
+
extra : dict, optional
|
|
306
|
+
Additional parameters to pass to ``solve-field`` binary.
|
|
307
|
+
_workdir : str, optional
|
|
308
|
+
If specified, all temporary files will be kept in this directory after the run.
|
|
309
|
+
_tmpdir : str, optional
|
|
310
|
+
If specified, temporary files will be created inside this path.
|
|
311
|
+
_exe : str, optional
|
|
312
|
+
Full path to ``solve-field`` executable. Auto-detected from :envvar:`PATH` if not provided.
|
|
313
|
+
verbose : bool or callable, optional
|
|
314
|
+
Whether to show verbose messages. May be boolean or a ``print``-like callable.
|
|
315
|
+
|
|
316
|
+
Returns
|
|
317
|
+
-------
|
|
318
|
+
astropy.wcs.WCS or astropy.io.fits.Header or None
|
|
319
|
+
Astrometric solution, or FITS header if ``get_header=True``, or None on failure.
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
# Simple wrapper around print for logging in verbose mode only
|
|
323
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
324
|
+
|
|
325
|
+
# Find the binary
|
|
326
|
+
binname = None
|
|
327
|
+
|
|
328
|
+
if _exe is not None:
|
|
329
|
+
# Check user-provided binary path, and fail if not found
|
|
330
|
+
if os.path.isfile(_exe):
|
|
331
|
+
binname = _exe
|
|
332
|
+
else:
|
|
333
|
+
# Find it in standard paths - does it even go there on any system?..
|
|
334
|
+
binname = shutil.which('solve-field')
|
|
335
|
+
|
|
336
|
+
if binname is None:
|
|
337
|
+
for path in [
|
|
338
|
+
'.',
|
|
339
|
+
'/usr/bin',
|
|
340
|
+
'/usr/local/bin',
|
|
341
|
+
'/opt/local/bin',
|
|
342
|
+
'/usr/astrometry/bin',
|
|
343
|
+
'/usr/local/astrometry/bin',
|
|
344
|
+
'/opt/local/astrometry/bin',
|
|
345
|
+
]:
|
|
346
|
+
for exe in ['solve-field']:
|
|
347
|
+
if os.path.isfile(os.path.join(path, exe)):
|
|
348
|
+
binname = os.path.join(path, exe)
|
|
349
|
+
break
|
|
350
|
+
|
|
351
|
+
if binname is None:
|
|
352
|
+
log("Can't find Astrometry.Net binary")
|
|
353
|
+
return None
|
|
354
|
+
# else:
|
|
355
|
+
# log("Using Astrometry.Net binary at", binname)
|
|
356
|
+
|
|
357
|
+
# Sort objects according to decreasing flux
|
|
358
|
+
aidx = np.argsort(-obj['flux'])
|
|
359
|
+
|
|
360
|
+
# Filter out least-significant detections, if SN limit is specified
|
|
361
|
+
if sn is not None and sn > 0:
|
|
362
|
+
aidx = [_ for _ in aidx if obj['flux'][_] / obj['fluxerr'][_] > sn]
|
|
363
|
+
|
|
364
|
+
if width is None:
|
|
365
|
+
width = int(np.max(obj['x']))
|
|
366
|
+
if height is None:
|
|
367
|
+
height = int(np.max(obj['y']))
|
|
368
|
+
|
|
369
|
+
workdir = (
|
|
370
|
+
_workdir if _workdir is not None else tempfile.mkdtemp(prefix='astrometry', dir=_tmpdir)
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
columns = [
|
|
374
|
+
fits.Column(name='XIMAGE', format='1D', array=obj['x'][aidx] + 1),
|
|
375
|
+
fits.Column(name='YIMAGE', format='1D', array=obj['y'][aidx] + 1),
|
|
376
|
+
fits.Column(name='FLUX', format='1D', array=obj['flux'][aidx]),
|
|
377
|
+
]
|
|
378
|
+
tbhdu = fits.BinTableHDU.from_columns(columns)
|
|
379
|
+
objname = os.path.join(workdir, 'list.fits')
|
|
380
|
+
tbhdu.writeto(objname, overwrite=True)
|
|
381
|
+
|
|
382
|
+
tmpname = os.path.join(workdir, 'list.tmp')
|
|
383
|
+
wcsname = os.path.join(workdir, 'list.wcs')
|
|
384
|
+
|
|
385
|
+
opts = {
|
|
386
|
+
'x-column': 'XIMAGE',
|
|
387
|
+
'y-column': 'YIMAGE',
|
|
388
|
+
'sort-column': 'FLUX',
|
|
389
|
+
'width': width,
|
|
390
|
+
'height': height,
|
|
391
|
+
'crpix-center': True,
|
|
392
|
+
#
|
|
393
|
+
'overwrite': True,
|
|
394
|
+
'no-plots': True,
|
|
395
|
+
'dir': workdir,
|
|
396
|
+
'verbose': True if verbose else False,
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if config is not None:
|
|
400
|
+
opts['config'] = config
|
|
401
|
+
|
|
402
|
+
if order is not None and order > 0:
|
|
403
|
+
opts['tweak-order'] = order
|
|
404
|
+
else:
|
|
405
|
+
opts['no-tweak'] = True
|
|
406
|
+
|
|
407
|
+
if scale_lower is not None:
|
|
408
|
+
opts['scale-low'] = scale_lower
|
|
409
|
+
if scale_upper is not None:
|
|
410
|
+
opts['scale-high'] = scale_upper
|
|
411
|
+
if scale_units is not None:
|
|
412
|
+
opts['scale-units'] = scale_units
|
|
413
|
+
|
|
414
|
+
if center_ra is not None:
|
|
415
|
+
opts['ra'] = center_ra
|
|
416
|
+
if center_dec is not None:
|
|
417
|
+
opts['dec'] = center_dec
|
|
418
|
+
if radius is not None:
|
|
419
|
+
opts['radius'] = radius
|
|
420
|
+
|
|
421
|
+
opts.update(extra)
|
|
422
|
+
|
|
423
|
+
wcs = None
|
|
424
|
+
|
|
425
|
+
for iter in range(2):
|
|
426
|
+
if os.path.exists(wcsname):
|
|
427
|
+
shutil.move(wcsname, tmpname)
|
|
428
|
+
opts['verify'] = tmpname
|
|
429
|
+
|
|
430
|
+
# Build the command line
|
|
431
|
+
command = binname + ' ' + shlex.quote(objname) + ' ' + utils.format_long_opts(opts)
|
|
432
|
+
if not verbose:
|
|
433
|
+
command += ' > /dev/null 2>/dev/null'
|
|
434
|
+
log('Will run iteration %d of Astrometry.Net like that:' % (iter,))
|
|
435
|
+
log(command)
|
|
436
|
+
|
|
437
|
+
res = os.system(command)
|
|
438
|
+
|
|
439
|
+
if res == 0 and os.path.exists(wcsname):
|
|
440
|
+
log('Successfully run iteration %d' % (iter,))
|
|
441
|
+
|
|
442
|
+
else:
|
|
443
|
+
log('Error %s running Astrometry.Net' % res)
|
|
444
|
+
|
|
445
|
+
if res == 0 and os.path.exists(wcsname):
|
|
446
|
+
header = fits.getheader(wcsname)
|
|
447
|
+
wcs = WCS(header)
|
|
448
|
+
|
|
449
|
+
ra0, dec0, sr0 = get_frame_center(wcs=wcs, width=width, height=height)
|
|
450
|
+
pixscale = get_pixscale(wcs=wcs)
|
|
451
|
+
|
|
452
|
+
log(
|
|
453
|
+
'Got WCS solution with center at %.4f %.4f radius %.2f deg and pixel scale %.2f arcsec/pix'
|
|
454
|
+
% (ra0, dec0, sr0, pixscale * 3600)
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
if update and wcs:
|
|
458
|
+
obj['ra'], obj['dec'] = wcs.all_pix2world(obj['x'], obj['y'], 0)
|
|
459
|
+
|
|
460
|
+
if _workdir is None:
|
|
461
|
+
shutil.rmtree(workdir)
|
|
462
|
+
|
|
463
|
+
return wcs
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def blind_match_astrometrynet(
|
|
467
|
+
obj,
|
|
468
|
+
order=2,
|
|
469
|
+
update=False,
|
|
470
|
+
sn=20,
|
|
471
|
+
get_header=False,
|
|
472
|
+
width=None,
|
|
473
|
+
height=None,
|
|
474
|
+
solve_timeout=600,
|
|
475
|
+
api_key=None,
|
|
476
|
+
center_ra=None,
|
|
477
|
+
center_dec=None,
|
|
478
|
+
radius=None,
|
|
479
|
+
scale_lower=None,
|
|
480
|
+
scale_upper=None,
|
|
481
|
+
scale_units='arcsecperpix',
|
|
482
|
+
**kwargs,
|
|
483
|
+
):
|
|
484
|
+
"""Thin wrapper for remote plate solving using Astrometry.Net and a list of detected objects.
|
|
485
|
+
|
|
486
|
+
Most parameters are passed directly to
|
|
487
|
+
``astroquery.astrometrynet.AstrometryNet.solve_from_source_list``.
|
|
488
|
+
API key may be provided as an argument or set in ``~/.astropy/config/astroquery.cfg``.
|
|
489
|
+
|
|
490
|
+
Parameters
|
|
491
|
+
----------
|
|
492
|
+
obj : astropy.table.Table
|
|
493
|
+
List of objects on the frame, must contain at least ``x``, ``y``, and ``flux`` columns.
|
|
494
|
+
order : int, optional
|
|
495
|
+
Order for the SIP spatial distortion polynomial.
|
|
496
|
+
update : bool, optional
|
|
497
|
+
If True, the object list will be updated in-place with correct ``ra`` and ``dec`` sky coordinates.
|
|
498
|
+
sn : float, optional
|
|
499
|
+
If provided, only objects with S/N exceeding this value will be used for matching.
|
|
500
|
+
get_header : bool, optional
|
|
501
|
+
If True, return the FITS header object instead of WCS solution.
|
|
502
|
+
width : int, optional
|
|
503
|
+
Image width in pixels, used for guessing the frame center.
|
|
504
|
+
height : int, optional
|
|
505
|
+
Image height in pixels, used for guessing the frame center.
|
|
506
|
+
solve_timeout : float, optional
|
|
507
|
+
Timeout in seconds to wait for the solution from the remote server.
|
|
508
|
+
api_key : str, optional
|
|
509
|
+
Astrometry.Net API key. If not set, must be configured in
|
|
510
|
+
``~/.astropy/config/astroquery.cfg``.
|
|
511
|
+
center_ra : float, optional
|
|
512
|
+
Approximate center RA of the field in degrees.
|
|
513
|
+
center_dec : float, optional
|
|
514
|
+
Approximate center Dec of the field in degrees.
|
|
515
|
+
radius : float, optional
|
|
516
|
+
Search radius in degrees around the specified center.
|
|
517
|
+
scale_lower : float, optional
|
|
518
|
+
Lower limit for the pixel scale.
|
|
519
|
+
scale_upper : float, optional
|
|
520
|
+
Upper limit for the pixel scale.
|
|
521
|
+
scale_units : str, optional
|
|
522
|
+
Units for ``scale_lower``/``scale_upper``. One of ``arcsecperpix``,
|
|
523
|
+
``arcminwidth``, or ``degwidth``.
|
|
524
|
+
|
|
525
|
+
Returns
|
|
526
|
+
-------
|
|
527
|
+
astropy.wcs.WCS or astropy.io.fits.Header or None
|
|
528
|
+
Astrometric solution, or FITS header if ``get_header=True``, or None on failure.
|
|
529
|
+
"""
|
|
530
|
+
|
|
531
|
+
# Import required module - we postpone it until now to hide warnings for API key
|
|
532
|
+
from astroquery.astrometry_net import AstrometryNet
|
|
533
|
+
|
|
534
|
+
# Sort objects according to decreasing flux
|
|
535
|
+
aidx = np.argsort(-obj['flux'])
|
|
536
|
+
|
|
537
|
+
# Filter out least-significant detections, if SN limit is specified
|
|
538
|
+
if sn is not None and sn > 0:
|
|
539
|
+
aidx = [_ for _ in aidx if obj['flux'][_] / obj['fluxerr'][_] > sn]
|
|
540
|
+
|
|
541
|
+
if width is None:
|
|
542
|
+
width = int(np.max(obj['x']))
|
|
543
|
+
if height is None:
|
|
544
|
+
height = int(np.max(obj['y']))
|
|
545
|
+
|
|
546
|
+
an = AstrometryNet()
|
|
547
|
+
if api_key is not None:
|
|
548
|
+
an.api_key = api_key
|
|
549
|
+
|
|
550
|
+
try:
|
|
551
|
+
header = an.solve_from_source_list(
|
|
552
|
+
obj['x'][aidx] + 1,
|
|
553
|
+
obj['y'][aidx] + 1,
|
|
554
|
+
width,
|
|
555
|
+
height,
|
|
556
|
+
center_ra=center_ra,
|
|
557
|
+
center_dec=center_dec,
|
|
558
|
+
radius=radius,
|
|
559
|
+
scale_lower=scale_lower,
|
|
560
|
+
scale_upper=scale_upper,
|
|
561
|
+
scale_units=scale_units,
|
|
562
|
+
solve_timeout=solve_timeout,
|
|
563
|
+
tweak_order=order,
|
|
564
|
+
**kwargs,
|
|
565
|
+
)
|
|
566
|
+
except:
|
|
567
|
+
import traceback
|
|
568
|
+
|
|
569
|
+
traceback.print_exc()
|
|
570
|
+
|
|
571
|
+
header = None
|
|
572
|
+
|
|
573
|
+
if header is not None:
|
|
574
|
+
wcs = WCS(header)
|
|
575
|
+
|
|
576
|
+
if update:
|
|
577
|
+
obj['ra'], obj['dec'] = wcs.all_pix2world(obj['x'], obj['y'], 0)
|
|
578
|
+
|
|
579
|
+
if get_header:
|
|
580
|
+
return header
|
|
581
|
+
else:
|
|
582
|
+
return wcs
|
|
583
|
+
|
|
584
|
+
return None
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def refine_wcs_simple(
|
|
588
|
+
obj,
|
|
589
|
+
cat,
|
|
590
|
+
order=2,
|
|
591
|
+
match=True,
|
|
592
|
+
sr=3 / 3600,
|
|
593
|
+
update=False,
|
|
594
|
+
cat_col_ra='RAJ2000',
|
|
595
|
+
cat_col_dec='DEJ2000',
|
|
596
|
+
method='astropy',
|
|
597
|
+
_tmpdir=None,
|
|
598
|
+
verbose=False,
|
|
599
|
+
):
|
|
600
|
+
"""Refine the WCS using detected objects and a reference catalogue.
|
|
601
|
+
|
|
602
|
+
Parameters
|
|
603
|
+
----------
|
|
604
|
+
obj : astropy.table.Table
|
|
605
|
+
List of detected objects with at least ``x``, ``y``, ``ra``, ``dec`` columns.
|
|
606
|
+
cat : astropy.table.Table
|
|
607
|
+
Reference astrometric catalogue.
|
|
608
|
+
order : int, optional
|
|
609
|
+
SIP polynomial order for distortion solution.
|
|
610
|
+
match : bool, optional
|
|
611
|
+
If True, perform nearest-neighbor matching between objects and catalogue.
|
|
612
|
+
If False, assume they are already matched line by line.
|
|
613
|
+
sr : float, optional
|
|
614
|
+
Matching radius in degrees. Default is 3/3600 (3 arcseconds).
|
|
615
|
+
update : bool, optional
|
|
616
|
+
If True, update object sky coordinates in-place using the new WCS.
|
|
617
|
+
cat_col_ra : str, optional
|
|
618
|
+
Catalogue column name for Right Ascension.
|
|
619
|
+
cat_col_dec : str, optional
|
|
620
|
+
Catalogue column name for Declination.
|
|
621
|
+
method : str, optional
|
|
622
|
+
Fitting method. Either ``'astropy'`` or ``'astrometrynet'``.
|
|
623
|
+
_tmpdir : str, optional
|
|
624
|
+
If specified, temporary files will be created inside this path.
|
|
625
|
+
verbose : bool or callable, optional
|
|
626
|
+
Whether to show verbose messages. May be boolean or a ``print``-like callable.
|
|
627
|
+
|
|
628
|
+
Returns
|
|
629
|
+
-------
|
|
630
|
+
astropy.wcs.WCS or None
|
|
631
|
+
Refined WCS solution, or None on failure.
|
|
632
|
+
"""
|
|
633
|
+
|
|
634
|
+
# Simple wrapper around print for logging in verbose mode only
|
|
635
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
636
|
+
|
|
637
|
+
if match:
|
|
638
|
+
# Perform simple nearest-neighbor matching within given radius
|
|
639
|
+
oidx, cidx, dist = spherical_match(
|
|
640
|
+
obj['ra'], obj['dec'], cat[cat_col_ra], cat[cat_col_dec], sr
|
|
641
|
+
)
|
|
642
|
+
_obj = obj[oidx]
|
|
643
|
+
_cat = cat[cidx]
|
|
644
|
+
else:
|
|
645
|
+
# Assume supplied objects and catalogue are already matched line by line
|
|
646
|
+
_obj = obj
|
|
647
|
+
_cat = cat
|
|
648
|
+
|
|
649
|
+
wcs = None
|
|
650
|
+
|
|
651
|
+
if method == 'astropy':
|
|
652
|
+
wcs = fit_wcs_from_points(
|
|
653
|
+
[_obj['x'], _obj['y']],
|
|
654
|
+
SkyCoord(_cat[cat_col_ra], _cat[cat_col_dec], unit='deg'),
|
|
655
|
+
sip_degree=order,
|
|
656
|
+
)
|
|
657
|
+
elif method == 'astrometrynet':
|
|
658
|
+
binname = None
|
|
659
|
+
|
|
660
|
+
# Rough estimate of frame dimensions
|
|
661
|
+
width = np.max(obj['x'])
|
|
662
|
+
height = np.max(obj['y'])
|
|
663
|
+
|
|
664
|
+
for path in ['.', '/usr/local', '/opt/local']:
|
|
665
|
+
if os.path.isfile(os.path.join(path, 'astrometry', 'bin', 'fit-wcs')):
|
|
666
|
+
binname = os.path.join(path, 'astrometry', 'bin', 'fit-wcs')
|
|
667
|
+
break
|
|
668
|
+
|
|
669
|
+
if binname:
|
|
670
|
+
dirname = tempfile.mkdtemp(prefix='astrometry', dir=_tmpdir)
|
|
671
|
+
|
|
672
|
+
columns = [
|
|
673
|
+
fits.Column(name='FIELD_X', format='1D', array=obj['x'] + 1),
|
|
674
|
+
fits.Column(name='FIELD_Y', format='1D', array=obj['y'] + 1),
|
|
675
|
+
fits.Column(name='INDEX_RA', format='1D', array=cat[cat_col_ra]),
|
|
676
|
+
fits.Column(name='INDEX_DEC', format='1D', array=cat[cat_col_dec]),
|
|
677
|
+
]
|
|
678
|
+
tbhdu = fits.BinTableHDU.from_columns(columns)
|
|
679
|
+
filename = os.path.join(dirname, 'list.fits')
|
|
680
|
+
wcsname = os.path.join(dirname, 'list.wcs')
|
|
681
|
+
|
|
682
|
+
tbhdu.writeto(filename, overwrite=True)
|
|
683
|
+
|
|
684
|
+
command = "%s -c %s -o %s -W %d -H %d -C -s %d" % (
|
|
685
|
+
binname,
|
|
686
|
+
filename,
|
|
687
|
+
wcsname,
|
|
688
|
+
width,
|
|
689
|
+
height,
|
|
690
|
+
order,
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
log('Running Astrometry.Net WCS fitter like that:')
|
|
694
|
+
log(command)
|
|
695
|
+
|
|
696
|
+
os.system(command)
|
|
697
|
+
|
|
698
|
+
if os.path.isfile(wcsname):
|
|
699
|
+
header = fits.getheader(wcsname)
|
|
700
|
+
wcs = WCS(header)
|
|
701
|
+
log('WCS fitter run succeeded')
|
|
702
|
+
else:
|
|
703
|
+
log('WCS fitter run failed')
|
|
704
|
+
|
|
705
|
+
shutil.rmtree(dirname)
|
|
706
|
+
|
|
707
|
+
else:
|
|
708
|
+
log("Astrometry.Net fit-wcs binary not found")
|
|
709
|
+
|
|
710
|
+
if wcs:
|
|
711
|
+
if update:
|
|
712
|
+
log('Updating object sky coordinates in-place')
|
|
713
|
+
# Update the sky coordinates of objects using new wcs
|
|
714
|
+
obj['ra'], obj['dec'] = wcs.all_pix2world(obj['x'], obj['y'], 0)
|
|
715
|
+
else:
|
|
716
|
+
log('WCS refinement failed')
|
|
717
|
+
|
|
718
|
+
return wcs
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def clear_wcs(
|
|
722
|
+
header,
|
|
723
|
+
remove_comments=False,
|
|
724
|
+
remove_history=False,
|
|
725
|
+
remove_underscored=False,
|
|
726
|
+
copy=False,
|
|
727
|
+
):
|
|
728
|
+
"""Clear WCS-related keywords from a FITS header.
|
|
729
|
+
|
|
730
|
+
Parameters
|
|
731
|
+
----------
|
|
732
|
+
header : astropy.io.fits.Header
|
|
733
|
+
Header to operate on.
|
|
734
|
+
remove_comments : bool, optional
|
|
735
|
+
If True, also remove COMMENT keywords.
|
|
736
|
+
remove_history : bool, optional
|
|
737
|
+
If True, also remove HISTORY keywords.
|
|
738
|
+
remove_underscored : bool, optional
|
|
739
|
+
If True, also remove all keywords starting with underscore (often added by Astrometry.Net).
|
|
740
|
+
copy : bool, optional
|
|
741
|
+
If True, operate on a copy and leave the original unchanged.
|
|
742
|
+
|
|
743
|
+
Returns
|
|
744
|
+
-------
|
|
745
|
+
astropy.io.fits.Header
|
|
746
|
+
Modified FITS header.
|
|
747
|
+
"""
|
|
748
|
+
if copy:
|
|
749
|
+
header = header.copy()
|
|
750
|
+
|
|
751
|
+
wcs_keywords = [
|
|
752
|
+
'WCSAXES',
|
|
753
|
+
'CRPIX1',
|
|
754
|
+
'CRPIX2',
|
|
755
|
+
'PC1_1',
|
|
756
|
+
'PC1_2',
|
|
757
|
+
'PC2_1',
|
|
758
|
+
'PC2_2',
|
|
759
|
+
'CDELT1',
|
|
760
|
+
'CDELT2',
|
|
761
|
+
'CUNIT1',
|
|
762
|
+
'CUNIT2',
|
|
763
|
+
'CTYPE1',
|
|
764
|
+
'CTYPE2',
|
|
765
|
+
'CRVAL1',
|
|
766
|
+
'CRVAL2',
|
|
767
|
+
'LONPOLE',
|
|
768
|
+
'LATPOLE',
|
|
769
|
+
'RADESYS',
|
|
770
|
+
'EQUINOX',
|
|
771
|
+
'B_ORDER',
|
|
772
|
+
'A_ORDER',
|
|
773
|
+
'BP_ORDER',
|
|
774
|
+
'AP_ORDER',
|
|
775
|
+
'CD1_1',
|
|
776
|
+
'CD2_1',
|
|
777
|
+
'CD1_2',
|
|
778
|
+
'CD2_2',
|
|
779
|
+
'IMAGEW',
|
|
780
|
+
'IMAGEH',
|
|
781
|
+
]
|
|
782
|
+
|
|
783
|
+
scamp_keywords = [
|
|
784
|
+
'FGROUPNO',
|
|
785
|
+
'ASTIRMS1',
|
|
786
|
+
'ASTIRMS2',
|
|
787
|
+
'ASTRRMS1',
|
|
788
|
+
'ASTRRMS2',
|
|
789
|
+
'ASTINST',
|
|
790
|
+
'FLXSCALE',
|
|
791
|
+
'MAGZEROP',
|
|
792
|
+
'PHOTIRMS',
|
|
793
|
+
'PHOTINST',
|
|
794
|
+
'PHOTLINK',
|
|
795
|
+
]
|
|
796
|
+
|
|
797
|
+
remove = []
|
|
798
|
+
|
|
799
|
+
for key in header.keys():
|
|
800
|
+
if key:
|
|
801
|
+
is_delete = False
|
|
802
|
+
|
|
803
|
+
if key in wcs_keywords:
|
|
804
|
+
is_delete = True
|
|
805
|
+
if key in scamp_keywords:
|
|
806
|
+
is_delete = True
|
|
807
|
+
if re.match('^(A|B|AP|BP)_\d+_\d+$', key):
|
|
808
|
+
# SIP
|
|
809
|
+
is_delete = True
|
|
810
|
+
if re.match('^PV_?\d+_\d+$', key):
|
|
811
|
+
# PV
|
|
812
|
+
is_delete = True
|
|
813
|
+
if key[0] == '_' and remove_underscored:
|
|
814
|
+
is_delete = True
|
|
815
|
+
if key == 'COMMENT' and remove_comments:
|
|
816
|
+
is_delete = True
|
|
817
|
+
if key == 'HISTORY' and remove_history:
|
|
818
|
+
is_delete = True
|
|
819
|
+
|
|
820
|
+
if is_delete:
|
|
821
|
+
remove.append(key)
|
|
822
|
+
|
|
823
|
+
for key in remove:
|
|
824
|
+
header.remove(key, remove_all=True, ignore_missing=True)
|
|
825
|
+
|
|
826
|
+
return header
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def wcs_pv2sip(header, order=None, accuracy=1e-4):
|
|
830
|
+
"""Convert a WCS header from any projection to TAN-SIP representation.
|
|
831
|
+
|
|
832
|
+
The original WCS is sampled on a dense pixel grid and SIP
|
|
833
|
+
polynomial coefficients (A, B for the forward distortion and
|
|
834
|
+
AP, BP for the reverse) are fitted to reproduce the same
|
|
835
|
+
pixel→sky mapping via least squares.
|
|
836
|
+
|
|
837
|
+
Works for any input projection (TPV, ZPN, ZEA, etc.).
|
|
838
|
+
|
|
839
|
+
Parameters
|
|
840
|
+
----------
|
|
841
|
+
header : `~astropy.io.fits.Header`
|
|
842
|
+
Input FITS header with WCS keywords.
|
|
843
|
+
order : int or None
|
|
844
|
+
SIP polynomial order. If ``None`` (default), the order is
|
|
845
|
+
chosen automatically (2 through 6) to achieve *accuracy*
|
|
846
|
+
on the fitting grid.
|
|
847
|
+
accuracy : float
|
|
848
|
+
Target accuracy in arcseconds for automatic order selection
|
|
849
|
+
(default 1e-4, i.e. 0.1 mas).
|
|
850
|
+
|
|
851
|
+
Returns
|
|
852
|
+
-------
|
|
853
|
+
header : `~astropy.io.fits.Header`
|
|
854
|
+
New header with TAN-SIP WCS (CTYPE ``RA---TAN-SIP``).
|
|
855
|
+
"""
|
|
856
|
+
|
|
857
|
+
header = header.copy()
|
|
858
|
+
|
|
859
|
+
# Parse the original WCS before modifying the header
|
|
860
|
+
wcs_orig = WCS(header)
|
|
861
|
+
|
|
862
|
+
nx = int(header.get('NAXIS1', 1024))
|
|
863
|
+
ny = int(header.get('NAXIS2', 1024))
|
|
864
|
+
|
|
865
|
+
# Ensure CD matrix is present (convert PC+CDELT → CD)
|
|
866
|
+
if 'CD1_1' not in header and 'PC1_1' in header:
|
|
867
|
+
cdelt = [header.get('CDELT1'), header.get('CDELT2')]
|
|
868
|
+
header['CD1_1'] = header.pop('PC1_1') * cdelt[0]
|
|
869
|
+
header['CD2_1'] = header.pop('PC2_1') * cdelt[0]
|
|
870
|
+
header['CD1_2'] = header.pop('PC1_2') * cdelt[0]
|
|
871
|
+
header['CD2_2'] = header.pop('PC2_2') * cdelt[0]
|
|
872
|
+
|
|
873
|
+
crpix1 = header['CRPIX1']
|
|
874
|
+
crpix2 = header['CRPIX2']
|
|
875
|
+
crval1 = header['CRVAL1']
|
|
876
|
+
crval2 = header['CRVAL2']
|
|
877
|
+
|
|
878
|
+
cd = np.array(
|
|
879
|
+
[
|
|
880
|
+
[header['CD1_1'], header.get('CD1_2', 0)],
|
|
881
|
+
[header.get('CD2_1', 0), header['CD2_2']],
|
|
882
|
+
]
|
|
883
|
+
)
|
|
884
|
+
cd_inv = np.linalg.inv(cd)
|
|
885
|
+
|
|
886
|
+
# Dense pixel grid
|
|
887
|
+
ngrid = int(max(50, min(200, max(nx, ny) // 10)))
|
|
888
|
+
gx = np.linspace(0, nx - 1, ngrid)
|
|
889
|
+
gy = np.linspace(0, ny - 1, ngrid)
|
|
890
|
+
xx, yy = np.meshgrid(gx, gy)
|
|
891
|
+
xf, yf = xx.ravel(), yy.ravel()
|
|
892
|
+
|
|
893
|
+
# Sky coordinates from the original WCS
|
|
894
|
+
ra, dec = wcs_orig.all_pix2world(xf, yf, 0)
|
|
895
|
+
good = np.isfinite(ra) & np.isfinite(dec)
|
|
896
|
+
xf, yf, ra, dec = xf[good], yf[good], ra[good], dec[good]
|
|
897
|
+
|
|
898
|
+
# TAN inverse projection: (RA, Dec) → (xi, eta) in degrees
|
|
899
|
+
ra0 = np.radians(crval1)
|
|
900
|
+
dec0 = np.radians(crval2)
|
|
901
|
+
ra_r = np.radians(ra)
|
|
902
|
+
dec_r = np.radians(dec)
|
|
903
|
+
cos_dec = np.cos(dec_r)
|
|
904
|
+
sin_dec = np.sin(dec_r)
|
|
905
|
+
cos_dec0 = np.cos(dec0)
|
|
906
|
+
sin_dec0 = np.sin(dec0)
|
|
907
|
+
dra = ra_r - ra0
|
|
908
|
+
denom = sin_dec * sin_dec0 + cos_dec * cos_dec0 * np.cos(dra)
|
|
909
|
+
xi = np.degrees(cos_dec * np.sin(dra) / denom)
|
|
910
|
+
eta = np.degrees((sin_dec * cos_dec0 - cos_dec * sin_dec0 * np.cos(dra)) / denom)
|
|
911
|
+
|
|
912
|
+
# Pixel offsets from CRPIX (0-based)
|
|
913
|
+
u = xf - (crpix1 - 1)
|
|
914
|
+
v = yf - (crpix2 - 1)
|
|
915
|
+
|
|
916
|
+
# Target distorted pixel coords: CD_inv · (xi, eta)
|
|
917
|
+
u_target = cd_inv[0, 0] * xi + cd_inv[0, 1] * eta
|
|
918
|
+
v_target = cd_inv[1, 0] * xi + cd_inv[1, 1] * eta
|
|
919
|
+
|
|
920
|
+
# SIP corrections: A(u, v) = u_target - u, B(u, v) = v_target - v
|
|
921
|
+
du = u_target - u
|
|
922
|
+
dv = v_target - v
|
|
923
|
+
|
|
924
|
+
def _sip_terms(sip_order):
|
|
925
|
+
"""Return list of (p, q) exponent pairs for SIP order."""
|
|
926
|
+
pq = []
|
|
927
|
+
for total in range(2, sip_order + 1):
|
|
928
|
+
for q in range(total + 1):
|
|
929
|
+
pq.append((total - q, q))
|
|
930
|
+
return pq
|
|
931
|
+
|
|
932
|
+
def _sip_basis(u, v, pq, scale):
|
|
933
|
+
"""Build normalized SIP polynomial basis matrix.
|
|
934
|
+
|
|
935
|
+
Coordinates are divided by *scale* for numerical stability;
|
|
936
|
+
coefficients are rescaled to raw-pixel convention afterwards.
|
|
937
|
+
"""
|
|
938
|
+
un, vn = u / scale, v / scale
|
|
939
|
+
return np.column_stack([un**p * vn**q for p, q in pq])
|
|
940
|
+
|
|
941
|
+
def _fit_order(u, v, du, dv, sip_order, scale):
|
|
942
|
+
"""Fit SIP coefficients at a given order, return residual."""
|
|
943
|
+
pq = _sip_terms(sip_order)
|
|
944
|
+
A_mat = _sip_basis(u, v, pq, scale)
|
|
945
|
+
ca_n, _, _, _ = np.linalg.lstsq(A_mat, du, rcond=None)
|
|
946
|
+
cb_n, _, _, _ = np.linalg.lstsq(A_mat, dv, rcond=None)
|
|
947
|
+
# Evaluate residual in normalized space (numerically stable)
|
|
948
|
+
res_u = du - A_mat @ ca_n
|
|
949
|
+
res_v = dv - A_mat @ cb_n
|
|
950
|
+
pix_scale = np.sqrt(np.abs(np.linalg.det(cd))) * 3600
|
|
951
|
+
max_res = max(np.max(np.abs(res_u)), np.max(np.abs(res_v)))
|
|
952
|
+
max_res *= pix_scale # arcsec
|
|
953
|
+
# Rescale to raw-pixel convention: coeff_raw = coeff_norm / scale^(p+q)
|
|
954
|
+
ca = ca_n.copy()
|
|
955
|
+
cb = cb_n.copy()
|
|
956
|
+
for i, (p, q) in enumerate(pq):
|
|
957
|
+
s = scale ** (p + q)
|
|
958
|
+
ca[i] /= s
|
|
959
|
+
cb[i] /= s
|
|
960
|
+
return ca, cb, pq, max_res
|
|
961
|
+
|
|
962
|
+
# Normalization scale for numerical stability
|
|
963
|
+
scale = max(np.max(np.abs(u)), np.max(np.abs(v)), 1.0)
|
|
964
|
+
|
|
965
|
+
if order is not None:
|
|
966
|
+
ca, cb, pq, max_res = _fit_order(u, v, du, dv, order, scale)
|
|
967
|
+
else:
|
|
968
|
+
# Auto-select order: try increasing orders, pick the best one.
|
|
969
|
+
# Cap at 6 — higher orders tend to become numerically unstable
|
|
970
|
+
# when the distortion is inherently non-polynomial (e.g. ZPN).
|
|
971
|
+
best_res = np.inf
|
|
972
|
+
best_result = None
|
|
973
|
+
best_order = 2
|
|
974
|
+
for try_order in range(2, 7):
|
|
975
|
+
ca_t, cb_t, pq_t, max_res_t = _fit_order(u, v, du, dv, try_order, scale)
|
|
976
|
+
if max_res_t < best_res:
|
|
977
|
+
best_res = max_res_t
|
|
978
|
+
best_result = (ca_t, cb_t, pq_t, max_res_t)
|
|
979
|
+
best_order = try_order
|
|
980
|
+
if max_res_t < accuracy:
|
|
981
|
+
break
|
|
982
|
+
ca, cb, pq, max_res = best_result
|
|
983
|
+
order = best_order
|
|
984
|
+
|
|
985
|
+
# Strip old distortion keywords
|
|
986
|
+
for key in list(header.keys()):
|
|
987
|
+
if key.startswith(('A_', 'B_', 'AP_', 'BP_', 'PV')):
|
|
988
|
+
del header[key]
|
|
989
|
+
|
|
990
|
+
# Write forward SIP coefficients
|
|
991
|
+
header['CTYPE1'] = 'RA---TAN-SIP'
|
|
992
|
+
header['CTYPE2'] = 'DEC--TAN-SIP'
|
|
993
|
+
header['A_ORDER'] = order
|
|
994
|
+
header['B_ORDER'] = order
|
|
995
|
+
|
|
996
|
+
for i, (p, q) in enumerate(pq):
|
|
997
|
+
if abs(ca[i]) > 1e-20:
|
|
998
|
+
header['A_%d_%d' % (p, q)] = float(ca[i])
|
|
999
|
+
if abs(cb[i]) > 1e-20:
|
|
1000
|
+
header['B_%d_%d' % (p, q)] = float(cb[i])
|
|
1001
|
+
|
|
1002
|
+
# Fit reverse SIP (AP, BP): map distorted coords back to undistorted
|
|
1003
|
+
du_rev = u - u_target
|
|
1004
|
+
dv_rev = v - v_target
|
|
1005
|
+
pq_rev = _sip_terms(order)
|
|
1006
|
+
A_rev = _sip_basis(u_target, v_target, pq_rev, scale)
|
|
1007
|
+
cap, _, _, _ = np.linalg.lstsq(A_rev, du_rev, rcond=None)
|
|
1008
|
+
cbp, _, _, _ = np.linalg.lstsq(A_rev, dv_rev, rcond=None)
|
|
1009
|
+
for i, (p, q) in enumerate(pq_rev):
|
|
1010
|
+
s = scale ** (p + q)
|
|
1011
|
+
cap[i] /= s
|
|
1012
|
+
cbp[i] /= s
|
|
1013
|
+
|
|
1014
|
+
header['AP_ORDER'] = order
|
|
1015
|
+
header['BP_ORDER'] = order
|
|
1016
|
+
for i, (p, q) in enumerate(pq_rev):
|
|
1017
|
+
if abs(cap[i]) > 1e-20:
|
|
1018
|
+
header['AP_%d_%d' % (p, q)] = float(cap[i])
|
|
1019
|
+
if abs(cbp[i]) > 1e-20:
|
|
1020
|
+
header['BP_%d_%d' % (p, q)] = float(cbp[i])
|
|
1021
|
+
|
|
1022
|
+
return header
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def _fit_tpv_coefficients(wcs_orig, header, order=5):
|
|
1026
|
+
"""Numerically fit TPV polynomial coefficients for a non-TAN WCS.
|
|
1027
|
+
|
|
1028
|
+
Samples the original WCS on a dense pixel grid, projects the sky
|
|
1029
|
+
coordinates through a TAN gnomonic projection, and fits TPV
|
|
1030
|
+
polynomial coefficients to reproduce the mapping.
|
|
1031
|
+
|
|
1032
|
+
Parameters
|
|
1033
|
+
----------
|
|
1034
|
+
wcs_orig : `~astropy.wcs.WCS`
|
|
1035
|
+
Original WCS (any projection, with or without SIP).
|
|
1036
|
+
header : `~astropy.io.fits.Header`
|
|
1037
|
+
Header with CRVAL/CRPIX/CD (will be modified in-place to add
|
|
1038
|
+
PV keywords and set CTYPE to TPV).
|
|
1039
|
+
order : int
|
|
1040
|
+
Maximum polynomial order for the TPV fit (default 5).
|
|
1041
|
+
|
|
1042
|
+
Returns
|
|
1043
|
+
-------
|
|
1044
|
+
header : modified header with PV keywords and CTYPE set to TPV.
|
|
1045
|
+
"""
|
|
1046
|
+
nx = int(header.get('NAXIS1', 1024))
|
|
1047
|
+
ny = int(header.get('NAXIS2', 1024))
|
|
1048
|
+
|
|
1049
|
+
# Dense pixel grid (avoid edges by half-pixel margin)
|
|
1050
|
+
ngrid = int(max(50, min(200, max(nx, ny) // 10)))
|
|
1051
|
+
gx = np.linspace(0, nx - 1, ngrid)
|
|
1052
|
+
gy = np.linspace(0, ny - 1, ngrid)
|
|
1053
|
+
xx, yy = np.meshgrid(gx, gy)
|
|
1054
|
+
xf, yf = xx.ravel(), yy.ravel()
|
|
1055
|
+
|
|
1056
|
+
# Sky coordinates from the original WCS
|
|
1057
|
+
ra, dec = wcs_orig.all_pix2world(xf, yf, 0)
|
|
1058
|
+
|
|
1059
|
+
# Filter out NaN / non-finite (can happen near edges)
|
|
1060
|
+
good = np.isfinite(ra) & np.isfinite(dec)
|
|
1061
|
+
xf, yf, ra, dec = xf[good], yf[good], ra[good], dec[good]
|
|
1062
|
+
|
|
1063
|
+
# CRVAL and CD matrix from header
|
|
1064
|
+
crval1 = header['CRVAL1']
|
|
1065
|
+
crval2 = header['CRVAL2']
|
|
1066
|
+
crpix1 = header['CRPIX1']
|
|
1067
|
+
crpix2 = header['CRPIX2']
|
|
1068
|
+
|
|
1069
|
+
# Build CD matrix
|
|
1070
|
+
if 'CD1_1' in header:
|
|
1071
|
+
cd = np.array(
|
|
1072
|
+
[
|
|
1073
|
+
[header['CD1_1'], header.get('CD1_2', 0)],
|
|
1074
|
+
[header.get('CD2_1', 0), header['CD2_2']],
|
|
1075
|
+
]
|
|
1076
|
+
)
|
|
1077
|
+
else:
|
|
1078
|
+
pc = np.array(
|
|
1079
|
+
[
|
|
1080
|
+
[header.get('PC1_1', 1), header.get('PC1_2', 0)],
|
|
1081
|
+
[header.get('PC2_1', 0), header.get('PC2_2', 1)],
|
|
1082
|
+
]
|
|
1083
|
+
)
|
|
1084
|
+
cdelt = np.array([header.get('CDELT1', 1), header.get('CDELT2', 1)])
|
|
1085
|
+
cd = pc * cdelt[:, None]
|
|
1086
|
+
|
|
1087
|
+
# Intermediate pixel coords: (u, v) = CD · (x - crpix, y - crpix)
|
|
1088
|
+
dx = xf - (crpix1 - 1) # 0-based pixel coords → 1-based offset
|
|
1089
|
+
dy = yf - (crpix2 - 1)
|
|
1090
|
+
u = cd[0, 0] * dx + cd[0, 1] * dy
|
|
1091
|
+
v = cd[1, 0] * dx + cd[1, 1] * dy
|
|
1092
|
+
|
|
1093
|
+
# TAN gnomonic projection: (RA, Dec) → (xi, eta) in degrees
|
|
1094
|
+
ra0 = np.radians(crval1)
|
|
1095
|
+
dec0 = np.radians(crval2)
|
|
1096
|
+
ra_r = np.radians(ra)
|
|
1097
|
+
dec_r = np.radians(dec)
|
|
1098
|
+
|
|
1099
|
+
cos_dec = np.cos(dec_r)
|
|
1100
|
+
sin_dec = np.sin(dec_r)
|
|
1101
|
+
cos_dec0 = np.cos(dec0)
|
|
1102
|
+
sin_dec0 = np.sin(dec0)
|
|
1103
|
+
dra = ra_r - ra0
|
|
1104
|
+
|
|
1105
|
+
denom = sin_dec * sin_dec0 + cos_dec * cos_dec0 * np.cos(dra)
|
|
1106
|
+
xi = np.degrees(cos_dec * np.sin(dra) / denom)
|
|
1107
|
+
eta = np.degrees((sin_dec * cos_dec0 - cos_dec * sin_dec0 * np.cos(dra)) / denom)
|
|
1108
|
+
|
|
1109
|
+
# Build TPV polynomial design matrix
|
|
1110
|
+
# TPV basis for axis 1: terms in (u, v), axis 2: terms in (v, u)
|
|
1111
|
+
# Following the TPV convention, excluding radial terms at
|
|
1112
|
+
# indices 3, 11, 23 which are degenerate with polynomial terms
|
|
1113
|
+
def _tpv_basis(p, q, max_order):
|
|
1114
|
+
"""Build TPV polynomial basis.
|
|
1115
|
+
|
|
1116
|
+
p is the "primary" variable (u for axis 1, v for axis 2),
|
|
1117
|
+
q is the "secondary" variable.
|
|
1118
|
+
"""
|
|
1119
|
+
terms = []
|
|
1120
|
+
indices = []
|
|
1121
|
+
|
|
1122
|
+
# Mapping from (total_order, sub_index) to PV index
|
|
1123
|
+
# Order 0: PV_0 = 1
|
|
1124
|
+
# Order 1: PV_1 = p, PV_2 = q
|
|
1125
|
+
# Order 2: PV_4 = p², PV_5 = pq, PV_6 = q²
|
|
1126
|
+
# Order 3: PV_7 = p³, PV_8 = p²q, PV_9 = pq², PV_10 = q³
|
|
1127
|
+
# (PV_3=r and PV_11=r³ are radial, skipped)
|
|
1128
|
+
# Order 4: PV_12..PV_16
|
|
1129
|
+
# Order 5: PV_17..PV_22 (PV_23=r⁵ skipped)
|
|
1130
|
+
# Order 6: PV_24..PV_30
|
|
1131
|
+
# Order 7: PV_31..PV_38 (PV_39=r⁷ skipped)
|
|
1132
|
+
|
|
1133
|
+
pv_idx = 0
|
|
1134
|
+
for total in range(max_order + 1):
|
|
1135
|
+
for j in range(total + 1):
|
|
1136
|
+
i = total - j
|
|
1137
|
+
# p^i * q^j
|
|
1138
|
+
terms.append(p**i * q**j)
|
|
1139
|
+
indices.append(pv_idx)
|
|
1140
|
+
pv_idx += 1
|
|
1141
|
+
|
|
1142
|
+
# Skip radial term after orders 1, 3, 5, 7
|
|
1143
|
+
if total in (1, 3, 5, 7):
|
|
1144
|
+
pv_idx += 1 # skip PV_3, PV_11, PV_23, PV_39
|
|
1145
|
+
|
|
1146
|
+
return np.column_stack(terms), indices
|
|
1147
|
+
|
|
1148
|
+
A1, idx1 = _tpv_basis(u, v, order)
|
|
1149
|
+
A2, idx2 = _tpv_basis(v, u, order)
|
|
1150
|
+
|
|
1151
|
+
# Fit via least squares: xi = A1 @ c1, eta = A2 @ c2
|
|
1152
|
+
c1, _, _, _ = np.linalg.lstsq(A1, xi, rcond=None)
|
|
1153
|
+
c2, _, _, _ = np.linalg.lstsq(A2, eta, rcond=None)
|
|
1154
|
+
|
|
1155
|
+
# Write PV keywords
|
|
1156
|
+
for i, pv_idx in enumerate(idx1):
|
|
1157
|
+
if abs(c1[i]) > 1e-16:
|
|
1158
|
+
header['PV1_%d' % pv_idx] = float(c1[i])
|
|
1159
|
+
for i, pv_idx in enumerate(idx2):
|
|
1160
|
+
if abs(c2[i]) > 1e-16:
|
|
1161
|
+
header['PV2_%d' % pv_idx] = float(c2[i])
|
|
1162
|
+
|
|
1163
|
+
header['CTYPE1'] = 'RA---TPV'
|
|
1164
|
+
header['CTYPE2'] = 'DEC--TPV'
|
|
1165
|
+
|
|
1166
|
+
return header
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def wcs_sip2pv(header):
|
|
1170
|
+
"""Convert a WCS header from SIP (or any projection) to TPV representation.
|
|
1171
|
+
|
|
1172
|
+
The original WCS is sampled on a dense pixel grid and TPV polynomial
|
|
1173
|
+
coefficients are fitted to reproduce the same pixel→sky mapping via least
|
|
1174
|
+
squares. Works for any projection type (TAN-SIP, ZPN-SIP, etc.) with
|
|
1175
|
+
sub-milliarcsecond accuracy.
|
|
1176
|
+
|
|
1177
|
+
Parameters
|
|
1178
|
+
----------
|
|
1179
|
+
header : astropy.io.fits.Header
|
|
1180
|
+
Input FITS header with WCS keywords.
|
|
1181
|
+
|
|
1182
|
+
Returns
|
|
1183
|
+
-------
|
|
1184
|
+
astropy.io.fits.Header
|
|
1185
|
+
New header with TPV WCS (``CTYPE`` set to ``RA---TPV``).
|
|
1186
|
+
"""
|
|
1187
|
+
|
|
1188
|
+
header = header.copy()
|
|
1189
|
+
|
|
1190
|
+
# Parse the original WCS before modifying the header
|
|
1191
|
+
wcs_orig = WCS(header)
|
|
1192
|
+
|
|
1193
|
+
# Ensure CD matrix is present (convert PC+CDELT → CD)
|
|
1194
|
+
if 'CD1_1' not in header and 'PC1_1' in header:
|
|
1195
|
+
cdelt = [header.get('CDELT1'), header.get('CDELT2')]
|
|
1196
|
+
|
|
1197
|
+
header['CD1_1'] = header.pop('PC1_1') * cdelt[0]
|
|
1198
|
+
header['CD2_1'] = header.pop('PC2_1') * cdelt[0]
|
|
1199
|
+
header['CD1_2'] = header.pop('PC1_2') * cdelt[0]
|
|
1200
|
+
header['CD2_2'] = header.pop('PC2_2') * cdelt[0]
|
|
1201
|
+
|
|
1202
|
+
# Strip SIP and old PV keywords before fitting new ones
|
|
1203
|
+
for key in list(header.keys()):
|
|
1204
|
+
if key.startswith(('A_', 'B_', 'AP_', 'BP_', 'PV')):
|
|
1205
|
+
del header[key]
|
|
1206
|
+
|
|
1207
|
+
_fit_tpv_coefficients(wcs_orig, header)
|
|
1208
|
+
|
|
1209
|
+
return header
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
def table_to_ldac(table, header=None, writeto=None):
|
|
1213
|
+
|
|
1214
|
+
primary_hdu = fits.PrimaryHDU()
|
|
1215
|
+
|
|
1216
|
+
header_str = header.tostring(endcard=True)
|
|
1217
|
+
# FIXME: this is a quick and dirty hack to preserve final 'END ' in the string
|
|
1218
|
+
# as astropy.io.fits tends to strip trailing whitespaces from string data, and it breaks at least SCAMP
|
|
1219
|
+
header_str += fits.Header().tostring(endcard=True)
|
|
1220
|
+
|
|
1221
|
+
header_col = fits.Column(
|
|
1222
|
+
name='Field Header Card', format='%dA' % len(header_str), array=[header_str]
|
|
1223
|
+
)
|
|
1224
|
+
header_hdu = fits.BinTableHDU.from_columns(fits.ColDefs([header_col]))
|
|
1225
|
+
header_hdu.header['EXTNAME'] = 'LDAC_IMHEAD'
|
|
1226
|
+
|
|
1227
|
+
data_hdu = fits.table_to_hdu(table)
|
|
1228
|
+
data_hdu.header['EXTNAME'] = 'LDAC_OBJECTS'
|
|
1229
|
+
|
|
1230
|
+
hdulist = fits.HDUList([primary_hdu, header_hdu, data_hdu])
|
|
1231
|
+
|
|
1232
|
+
if writeto is not None:
|
|
1233
|
+
hdulist.writeto(writeto, overwrite=True)
|
|
1234
|
+
|
|
1235
|
+
return hdulist
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def refine_wcs_scamp(
|
|
1239
|
+
obj,
|
|
1240
|
+
cat=None,
|
|
1241
|
+
wcs=None,
|
|
1242
|
+
header=None,
|
|
1243
|
+
sr=2 / 3600,
|
|
1244
|
+
order=3,
|
|
1245
|
+
cat_col_ra='RAJ2000',
|
|
1246
|
+
cat_col_dec='DEJ2000',
|
|
1247
|
+
cat_col_ra_err='e_RAJ2000',
|
|
1248
|
+
cat_col_dec_err='e_DEJ2000',
|
|
1249
|
+
cat_col_mag='rmag',
|
|
1250
|
+
cat_col_mag_err='e_rmag',
|
|
1251
|
+
cat_mag_lim=99,
|
|
1252
|
+
sn=None,
|
|
1253
|
+
pos_err_floor=0.25,
|
|
1254
|
+
max_sigma=None,
|
|
1255
|
+
min_matches_per_param=3,
|
|
1256
|
+
auto_max_order=5,
|
|
1257
|
+
auto_threshold=0.05,
|
|
1258
|
+
position_maxerr=None,
|
|
1259
|
+
posangle_maxerr=None,
|
|
1260
|
+
pixscale_maxerr=None,
|
|
1261
|
+
match_flipped=False,
|
|
1262
|
+
extra={},
|
|
1263
|
+
get_header=False,
|
|
1264
|
+
update=False,
|
|
1265
|
+
_workdir=None,
|
|
1266
|
+
_tmpdir=None,
|
|
1267
|
+
_exe=None,
|
|
1268
|
+
verbose=False,
|
|
1269
|
+
):
|
|
1270
|
+
"""Wrapper for running SCAMP to get a refined astrometric solution.
|
|
1271
|
+
|
|
1272
|
+
Parameters
|
|
1273
|
+
----------
|
|
1274
|
+
obj : astropy.table.Table
|
|
1275
|
+
List of objects on the frame, must contain at least ``x``, ``y``, and ``flux`` columns.
|
|
1276
|
+
cat : astropy.table.Table or str, optional
|
|
1277
|
+
Reference astrometric catalogue, or a SCAMP network catalogue name (e.g. ``'GAIA-DR2'``).
|
|
1278
|
+
wcs : astropy.wcs.WCS, optional
|
|
1279
|
+
Initial WCS solution.
|
|
1280
|
+
header : astropy.io.fits.Header, optional
|
|
1281
|
+
FITS header containing the initial astrometric solution.
|
|
1282
|
+
sr : float, optional
|
|
1283
|
+
Matching radius in degrees. Controls SCAMP's ``CROSSID_RADIUS``.
|
|
1284
|
+
order : int or str, optional
|
|
1285
|
+
Polynomial order for PV distortion solution (1 or greater), or ``'auto'``
|
|
1286
|
+
to select it based on hold-out residuals, so that higher degrees are only
|
|
1287
|
+
used when they actually improve the solution. Automatic selection needs a
|
|
1288
|
+
local reference catalogue, and costs one extra SCAMP run per degree tried.
|
|
1289
|
+
cat_col_ra : str, optional
|
|
1290
|
+
Catalogue column name for Right Ascension.
|
|
1291
|
+
cat_col_dec : str, optional
|
|
1292
|
+
Catalogue column name for Declination.
|
|
1293
|
+
cat_col_ra_err : str, optional
|
|
1294
|
+
Catalogue column name for Right Ascension error.
|
|
1295
|
+
cat_col_dec_err : str, optional
|
|
1296
|
+
Catalogue column name for Declination error.
|
|
1297
|
+
cat_col_mag : str, optional
|
|
1298
|
+
Catalogue column name for magnitude.
|
|
1299
|
+
cat_col_mag_err : str, optional
|
|
1300
|
+
Catalogue column name for magnitude error.
|
|
1301
|
+
cat_mag_lim : float or sequence of float, optional
|
|
1302
|
+
Magnitude limit(s) for catalogue stars. A single value is used as an
|
|
1303
|
+
upper limit; two values ``[min, max]`` define a range.
|
|
1304
|
+
sn : float or sequence of float, optional
|
|
1305
|
+
S/N threshold(s) for SCAMP (passed as ``SN_THRESHOLDS``).
|
|
1306
|
+
pos_err_floor : float, optional
|
|
1307
|
+
Floor for object positional errors, in pixels, added to them in quadrature.
|
|
1308
|
+
Centroiding errors are formal photon noise ones and do not include
|
|
1309
|
+
atmospheric and other systematic terms, so without such floor SCAMP
|
|
1310
|
+
over-weights the brightest stars and reports meaninglessly large chi2.
|
|
1311
|
+
Default is 0.25 pixels.
|
|
1312
|
+
max_sigma : float, optional
|
|
1313
|
+
Maximum acceptable rms of astrometric residuals w.r.t. the reference
|
|
1314
|
+
catalogue, in arcsec. Solutions with larger residuals are rejected.
|
|
1315
|
+
Default is a half of the matching radius ``sr``.
|
|
1316
|
+
min_matches_per_param : float, optional
|
|
1317
|
+
Minimal number of matched reference stars per free parameter of the
|
|
1318
|
+
distortion polynomial. If the fit has less of them, it is repeated with
|
|
1319
|
+
lower degree, down to ``order=1``, as such fits overfit the data and thus
|
|
1320
|
+
have deceivingly small residuals. Default is 3.
|
|
1321
|
+
auto_max_order : int, optional
|
|
1322
|
+
Largest distortion degree to try when ``order='auto'``. Default is 5.
|
|
1323
|
+
auto_threshold : float, optional
|
|
1324
|
+
Minimal relative improvement of hold-out residuals for accepting the next
|
|
1325
|
+
distortion degree when ``order='auto'``. Default is 0.05, i.e. 5 percent.
|
|
1326
|
+
position_maxerr : float, optional
|
|
1327
|
+
Maximum positional uncertainty of the initial WCS in arcminutes.
|
|
1328
|
+
Controls the search range for field center offset during pattern matching.
|
|
1329
|
+
Default is SCAMP's built-in default of 1 arcmin.
|
|
1330
|
+
posangle_maxerr : float, optional
|
|
1331
|
+
Maximum position angle uncertainty in degrees.
|
|
1332
|
+
Default is SCAMP's built-in default of 5 degrees.
|
|
1333
|
+
pixscale_maxerr : float, optional
|
|
1334
|
+
Maximum pixel scale uncertainty as a multiplicative factor (>1.0).
|
|
1335
|
+
E.g. 1.2 means ±20% around the initial scale.
|
|
1336
|
+
Default is SCAMP's built-in default of 1.2.
|
|
1337
|
+
match_flipped : bool, optional
|
|
1338
|
+
If True, also try matching with flipped (mirrored) axes. Default is False.
|
|
1339
|
+
extra : dict, optional
|
|
1340
|
+
Additional parameters to pass to the SCAMP binary.
|
|
1341
|
+
get_header : bool, optional
|
|
1342
|
+
If True, return the FITS header object instead of WCS solution.
|
|
1343
|
+
update : bool, optional
|
|
1344
|
+
If True, update object sky coordinates in-place using the new WCS.
|
|
1345
|
+
_workdir : str, optional
|
|
1346
|
+
If specified, all temporary files will be kept in this directory after the run.
|
|
1347
|
+
_tmpdir : str, optional
|
|
1348
|
+
If specified, temporary files will be created inside this path.
|
|
1349
|
+
_exe : str, optional
|
|
1350
|
+
Full path to the SCAMP executable. Auto-detected from :envvar:`PATH` if not provided.
|
|
1351
|
+
verbose : bool or callable, optional
|
|
1352
|
+
Whether to show verbose messages. May be boolean or a ``print``-like callable.
|
|
1353
|
+
|
|
1354
|
+
Returns
|
|
1355
|
+
-------
|
|
1356
|
+
astropy.wcs.WCS or astropy.io.fits.Header or None
|
|
1357
|
+
Refined astrometric solution, or FITS header if ``get_header=True``, or None on failure.
|
|
1358
|
+
"""
|
|
1359
|
+
|
|
1360
|
+
# Simple wrapper around print for logging in verbose mode only
|
|
1361
|
+
log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
|
|
1362
|
+
|
|
1363
|
+
# Find the binary
|
|
1364
|
+
binname = None
|
|
1365
|
+
|
|
1366
|
+
if _exe is not None:
|
|
1367
|
+
# Check user-provided binary path, and fail if not found
|
|
1368
|
+
if os.path.isfile(_exe):
|
|
1369
|
+
binname = _exe
|
|
1370
|
+
else:
|
|
1371
|
+
# Find SExtractor binary in common paths
|
|
1372
|
+
for exe in ['scamp']:
|
|
1373
|
+
binname = shutil.which(exe)
|
|
1374
|
+
if binname is not None:
|
|
1375
|
+
break
|
|
1376
|
+
|
|
1377
|
+
if binname is None:
|
|
1378
|
+
log("Can't find SCAMP binary")
|
|
1379
|
+
return None
|
|
1380
|
+
# else:
|
|
1381
|
+
# log("Using SCAMP binary at", binname)
|
|
1382
|
+
|
|
1383
|
+
workdir = _workdir if _workdir is not None else tempfile.mkdtemp(prefix='scamp', dir=_tmpdir)
|
|
1384
|
+
|
|
1385
|
+
if header is None:
|
|
1386
|
+
# Construct minimal FITS header covering our data points
|
|
1387
|
+
header = fits.Header(
|
|
1388
|
+
{
|
|
1389
|
+
'NAXIS': 2,
|
|
1390
|
+
'NAXIS1': np.max(obj['x'] + 1),
|
|
1391
|
+
'NAXIS2': np.max(obj['y'] + 1),
|
|
1392
|
+
'BITPIX': -64,
|
|
1393
|
+
'EQUINOX': 2000.0,
|
|
1394
|
+
}
|
|
1395
|
+
)
|
|
1396
|
+
else:
|
|
1397
|
+
header = header.copy()
|
|
1398
|
+
|
|
1399
|
+
if wcs is not None and wcs.is_celestial:
|
|
1400
|
+
# Add WCS information to the header
|
|
1401
|
+
header += wcs.to_header(relax=True)
|
|
1402
|
+
|
|
1403
|
+
# Ensure the header is in TPV convention, as SCAMP does not support SIP
|
|
1404
|
+
if wcs.sip is not None:
|
|
1405
|
+
log("Converting the header from SIP to TPV convention")
|
|
1406
|
+
header = wcs_sip2pv(header)
|
|
1407
|
+
else:
|
|
1408
|
+
log("Can't operate without initial WCS")
|
|
1409
|
+
return None
|
|
1410
|
+
|
|
1411
|
+
# Dummy config filename, to prevent loading from current dir
|
|
1412
|
+
confname = os.path.join(workdir, 'empty.conf')
|
|
1413
|
+
utils.file_write(confname)
|
|
1414
|
+
|
|
1415
|
+
opts = {
|
|
1416
|
+
'c': confname,
|
|
1417
|
+
'VERBOSE_TYPE': 'QUIET',
|
|
1418
|
+
'SOLVE_PHOTOM': 'N',
|
|
1419
|
+
'CHECKPLOT_TYPE': 'NONE',
|
|
1420
|
+
'WRITE_XML': 'Y',
|
|
1421
|
+
'PROJECTION_TYPE': 'TPV',
|
|
1422
|
+
'CROSSID_RADIUS': sr * 3600,
|
|
1423
|
+
'STABILITY_TYPE': 'EXPOSURE',
|
|
1424
|
+
'SN_THRESHOLDS': [3.0, 30.0],
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
if sn is not None:
|
|
1428
|
+
if np.isscalar(sn):
|
|
1429
|
+
opts['SN_THRESHOLDS'] = [sn, 10 * sn]
|
|
1430
|
+
else:
|
|
1431
|
+
opts['SN_THRESHOLDS'] = [sn[0], sn[1]]
|
|
1432
|
+
|
|
1433
|
+
# Pattern matching search range parameters
|
|
1434
|
+
if position_maxerr is not None:
|
|
1435
|
+
opts['POSITION_MAXERR'] = position_maxerr
|
|
1436
|
+
if posangle_maxerr is not None:
|
|
1437
|
+
opts['POSANGLE_MAXERR'] = posangle_maxerr
|
|
1438
|
+
if pixscale_maxerr is not None:
|
|
1439
|
+
opts['PIXSCALE_MAXERR'] = pixscale_maxerr
|
|
1440
|
+
if match_flipped:
|
|
1441
|
+
opts['MATCH_FLIPPED'] = 'Y'
|
|
1442
|
+
|
|
1443
|
+
opts.update(extra)
|
|
1444
|
+
|
|
1445
|
+
# Object positional errors from centroiding are formal photon noise ones, and
|
|
1446
|
+
# thus routinely underestimate the actual scatter w.r.t. the reference catalogue,
|
|
1447
|
+
# which also includes atmospheric and other systematic terms. Thus we add a floor
|
|
1448
|
+
# to them in quadrature, so that SCAMP weighting and its chi2 stay sensible.
|
|
1449
|
+
xerr = np.hypot(np.asarray(obj['xerr'], dtype=float), pos_err_floor)
|
|
1450
|
+
yerr = np.hypot(np.asarray(obj['yerr'], dtype=float), pos_err_floor)
|
|
1451
|
+
|
|
1452
|
+
# Minimal LDAC table with objects
|
|
1453
|
+
t_obj = Table(
|
|
1454
|
+
data={
|
|
1455
|
+
'XWIN_IMAGE': obj['x'] + 1, # SCAMP uses 1-based coordinates
|
|
1456
|
+
'YWIN_IMAGE': obj['y'] + 1,
|
|
1457
|
+
'ERRAWIN_IMAGE': xerr,
|
|
1458
|
+
'ERRBWIN_IMAGE': yerr,
|
|
1459
|
+
'FLUX_AUTO': obj['flux'],
|
|
1460
|
+
'FLUXERR_AUTO': obj['fluxerr'],
|
|
1461
|
+
'MAG_AUTO': obj['mag'],
|
|
1462
|
+
'MAGERR_AUTO': obj['magerr'],
|
|
1463
|
+
'FLAGS': obj['flags'],
|
|
1464
|
+
}
|
|
1465
|
+
)
|
|
1466
|
+
|
|
1467
|
+
objname = os.path.join(workdir, 'objects.cat')
|
|
1468
|
+
table_to_ldac(t_obj, header, objname)
|
|
1469
|
+
|
|
1470
|
+
def write_catalogue(catalogue, catname):
|
|
1471
|
+
"""Store the reference catalogue as an LDAC file for SCAMP to use"""
|
|
1472
|
+
t_cat = Table(
|
|
1473
|
+
data={
|
|
1474
|
+
'X_WORLD': catalogue[cat_col_ra],
|
|
1475
|
+
'Y_WORLD': catalogue[cat_col_dec],
|
|
1476
|
+
'ERRA_WORLD': utils.table_get(catalogue, cat_col_ra_err, 1 / 3600),
|
|
1477
|
+
'ERRB_WORLD': utils.table_get(catalogue, cat_col_dec_err, 1 / 3600),
|
|
1478
|
+
'MAG': utils.table_get(catalogue, cat_col_mag, 0),
|
|
1479
|
+
'MAGERR': utils.table_get(catalogue, cat_col_mag_err, 0.01),
|
|
1480
|
+
'OBSDATE': np.ones_like(catalogue[cat_col_ra]) * 2000.0,
|
|
1481
|
+
'FLAGS': np.zeros_like(catalogue[cat_col_ra], dtype=int),
|
|
1482
|
+
}
|
|
1483
|
+
)
|
|
1484
|
+
|
|
1485
|
+
# Remove masked values
|
|
1486
|
+
for _ in t_cat.colnames:
|
|
1487
|
+
if np.ma.is_masked(t_cat[_]):
|
|
1488
|
+
t_cat = t_cat[~t_cat[_].mask]
|
|
1489
|
+
|
|
1490
|
+
# Convert units of err columns to degrees, if any
|
|
1491
|
+
for _ in ['ERRA_WORLD', 'ERRB_WORLD']:
|
|
1492
|
+
if t_cat[_].unit and t_cat[_].unit != 'deg':
|
|
1493
|
+
t_cat[_] = t_cat[_].to('deg')
|
|
1494
|
+
|
|
1495
|
+
# Limit the catalogue to given magnitude range
|
|
1496
|
+
if cat_mag_lim is not None:
|
|
1497
|
+
if hasattr(cat_mag_lim, '__len__') and len(cat_mag_lim) == 2:
|
|
1498
|
+
# Two elements provided, treat them as lower and upper limits
|
|
1499
|
+
t_cat = t_cat[
|
|
1500
|
+
(t_cat['MAG'] >= cat_mag_lim[0]) & (t_cat['MAG'] <= cat_mag_lim[1])
|
|
1501
|
+
]
|
|
1502
|
+
else:
|
|
1503
|
+
# One element provided, treat it as upper limit
|
|
1504
|
+
t_cat = t_cat[t_cat['MAG'] <= cat_mag_lim]
|
|
1505
|
+
|
|
1506
|
+
table_to_ldac(t_cat, header, catname)
|
|
1507
|
+
|
|
1508
|
+
return catname
|
|
1509
|
+
|
|
1510
|
+
catname = None
|
|
1511
|
+
|
|
1512
|
+
if cat:
|
|
1513
|
+
if type(cat) == str:
|
|
1514
|
+
# Match with network catalogue by name
|
|
1515
|
+
opts['ASTREF_CATALOG'] = cat
|
|
1516
|
+
log('Using', cat, 'as a network catalogue')
|
|
1517
|
+
else:
|
|
1518
|
+
# Match with user-provided catalogue
|
|
1519
|
+
catname = write_catalogue(cat, os.path.join(workdir, 'catalogue.cat'))
|
|
1520
|
+
opts['ASTREF_CATALOG'] = 'FILE'
|
|
1521
|
+
opts['ASTREFCAT_NAME'] = catname
|
|
1522
|
+
log('Using user-provided local catalogue')
|
|
1523
|
+
else:
|
|
1524
|
+
log('Using default settings for network catalogue')
|
|
1525
|
+
|
|
1526
|
+
def run_scamp(order, catname=None, tag='', quiet=False):
|
|
1527
|
+
"""Run SCAMP for a given distortion degree, and return the solution as a
|
|
1528
|
+
FITS header along with the number of matched reference stars and the rms
|
|
1529
|
+
of their residuals in arcsec. Header is None if the run failed."""
|
|
1530
|
+
# Simple wrapper around log for quiet mode only
|
|
1531
|
+
rlog = (lambda *args, **kwargs: None) if quiet else log
|
|
1532
|
+
|
|
1533
|
+
hdrname = os.path.join(workdir, 'objects%s.head' % tag)
|
|
1534
|
+
xmlname = os.path.join(workdir, 'scamp%s.xml' % tag)
|
|
1535
|
+
|
|
1536
|
+
for _ in [hdrname, xmlname]:
|
|
1537
|
+
if os.path.exists(_):
|
|
1538
|
+
os.unlink(_)
|
|
1539
|
+
|
|
1540
|
+
run_opts = dict(opts)
|
|
1541
|
+
run_opts['DISTORT_DEGREES'] = max(1, order)
|
|
1542
|
+
run_opts['HEADER_NAME'] = hdrname
|
|
1543
|
+
run_opts['XML_NAME'] = xmlname
|
|
1544
|
+
if catname is not None:
|
|
1545
|
+
run_opts['ASTREFCAT_NAME'] = catname
|
|
1546
|
+
|
|
1547
|
+
# Build the command line
|
|
1548
|
+
command = (
|
|
1549
|
+
binname + ' ' + shlex.quote(objname) + ' ' + utils.format_astromatic_opts(run_opts)
|
|
1550
|
+
)
|
|
1551
|
+
if not verbose:
|
|
1552
|
+
command += ' > /dev/null 2>/dev/null'
|
|
1553
|
+
rlog('Will run SCAMP like that:')
|
|
1554
|
+
rlog(command)
|
|
1555
|
+
|
|
1556
|
+
# Run the command!
|
|
1557
|
+
|
|
1558
|
+
res = os.system(command)
|
|
1559
|
+
|
|
1560
|
+
if res != 0 or not os.path.exists(hdrname) or not os.path.exists(xmlname):
|
|
1561
|
+
rlog('Error', res, 'running SCAMP')
|
|
1562
|
+
return None, 0, None
|
|
1563
|
+
|
|
1564
|
+
rlog('SCAMP run successfully')
|
|
1565
|
+
|
|
1566
|
+
diag = Table.read(xmlname, table_id=0)[0]
|
|
1567
|
+
|
|
1568
|
+
# Log detailed diagnostics from SCAMP XML
|
|
1569
|
+
n_matches = int(diag['NDeg_Reference'])
|
|
1570
|
+
reduced_chi2 = float(diag['Chi2_Reference'])
|
|
1571
|
+
rlog('Reference matches: %d, reduced chi2: %.2f' % (n_matches, reduced_chi2))
|
|
1572
|
+
|
|
1573
|
+
# Log matching diagnostics if available (pattern matching was performed)
|
|
1574
|
+
for key in ['AS_Contrast', 'XY_Contrast', 'DPixelScale', 'DPosAngle', 'Shear']:
|
|
1575
|
+
if key in diag.colnames:
|
|
1576
|
+
rlog(' %s: %s' % (key, diag[key]))
|
|
1577
|
+
|
|
1578
|
+
# Log astrometric residuals if available
|
|
1579
|
+
sigma = None
|
|
1580
|
+
for key in ['AstromOffset_Reference', 'AstromSigma_Reference']:
|
|
1581
|
+
if key in diag.colnames:
|
|
1582
|
+
val = np.atleast_1d(diag[key]).astype(float)
|
|
1583
|
+
rlog(' %s: %s arcsec' % (key, ' '.join('%.3f' % v for v in val)))
|
|
1584
|
+
if key == 'AstromSigma_Reference':
|
|
1585
|
+
# Combine per-axis residual rms values into a single number
|
|
1586
|
+
sigma = np.sqrt(np.mean(val**2))
|
|
1587
|
+
|
|
1588
|
+
with open(hdrname, 'r') as f:
|
|
1589
|
+
h1 = fits.Header.fromstring(f.read().encode('ascii', 'ignore'), sep='\n')
|
|
1590
|
+
|
|
1591
|
+
# Sometimes SCAMP returns TAN type solution even despite PV keywords present
|
|
1592
|
+
if h1['CTYPE1'] != 'RA---TPV' and 'PV1_0' in h1.keys():
|
|
1593
|
+
rlog(
|
|
1594
|
+
'Got WCS solution with CTYPE1 =',
|
|
1595
|
+
h1['CTYPE1'],
|
|
1596
|
+
' and PV keywords, fixing it',
|
|
1597
|
+
)
|
|
1598
|
+
h1['CTYPE1'] = 'RA---TPV'
|
|
1599
|
+
h1['CTYPE2'] = 'DEC--TPV'
|
|
1600
|
+
# .. while sometimes it does the opposite
|
|
1601
|
+
elif h1['CTYPE1'] == 'RA---TPV' and 'PV1_0' not in h1.keys():
|
|
1602
|
+
rlog(
|
|
1603
|
+
'Got WCS solution with CTYPE1 =',
|
|
1604
|
+
h1['CTYPE1'],
|
|
1605
|
+
' and without PV keywords, fixing it',
|
|
1606
|
+
)
|
|
1607
|
+
h1['CTYPE1'] = 'RA---TAN'
|
|
1608
|
+
h1['CTYPE2'] = 'DEC--TAN'
|
|
1609
|
+
h1 = WCS(h1).to_header(relax=True)
|
|
1610
|
+
|
|
1611
|
+
return h1, n_matches, sigma
|
|
1612
|
+
|
|
1613
|
+
def min_matches_for(order):
|
|
1614
|
+
"""Minimal number of matched reference stars for a given distortion degree.
|
|
1615
|
+
The fit should have noticeably more of them than it has free parameters,
|
|
1616
|
+
else the residuals get small just due to overfitting. The number below is
|
|
1617
|
+
the amount of polynomial terms of a given degree, for both axes."""
|
|
1618
|
+
return int(np.ceil(min_matches_per_param * (max(1, order) + 1) * (max(1, order) + 2)))
|
|
1619
|
+
|
|
1620
|
+
def select_order():
|
|
1621
|
+
"""Choose the distortion degree by fitting one half of the reference
|
|
1622
|
+
catalogue and measuring the residuals on the other, unused one. Unlike the
|
|
1623
|
+
residuals of the fit itself, these get worse as soon as it starts to
|
|
1624
|
+
overfit, so we may just pick the lowest degree that reaches the best of
|
|
1625
|
+
them. We have to check all the degrees, as the improvement is not monotonic
|
|
1626
|
+
- the distortion is often dominated by odd radial terms, so e.g. order 2
|
|
1627
|
+
may add nothing at all over order 1 while order 3 improves a lot."""
|
|
1628
|
+
if catname is None:
|
|
1629
|
+
# We can only split a local catalogue into fit and test halves
|
|
1630
|
+
default_order = min(3, auto_max_order)
|
|
1631
|
+
log(
|
|
1632
|
+
'Automatic order selection needs a local catalogue, using order %d'
|
|
1633
|
+
% default_order
|
|
1634
|
+
)
|
|
1635
|
+
return default_order
|
|
1636
|
+
|
|
1637
|
+
log('Selecting the distortion degree using half of the catalogue as a test set')
|
|
1638
|
+
|
|
1639
|
+
cat_fit, cat_test = cat[::2], cat[1::2]
|
|
1640
|
+
catname_fit = write_catalogue(cat_fit, os.path.join(workdir, 'catalogue_fit.cat'))
|
|
1641
|
+
|
|
1642
|
+
# Trial fits see only a half of the catalogue, and thus get roughly half
|
|
1643
|
+
# the matches the final fit will have, so scale the requirement accordingly
|
|
1644
|
+
scale = len(cat_fit) / len(cat)
|
|
1645
|
+
|
|
1646
|
+
results = {}
|
|
1647
|
+
pairs = None
|
|
1648
|
+
|
|
1649
|
+
for o in range(1, auto_max_order + 1):
|
|
1650
|
+
h, n, _ = run_scamp(o, catname=catname_fit, tag='_auto', quiet=True)
|
|
1651
|
+
|
|
1652
|
+
if h is None:
|
|
1653
|
+
break
|
|
1654
|
+
|
|
1655
|
+
if n < scale * min_matches_for(o):
|
|
1656
|
+
log(
|
|
1657
|
+
' order %d: %d matches, less than %d needed'
|
|
1658
|
+
% (o, n, int(np.ceil(scale * min_matches_for(o))))
|
|
1659
|
+
)
|
|
1660
|
+
break
|
|
1661
|
+
|
|
1662
|
+
ra, dec = WCS(h).all_pix2world(obj['x'], obj['y'], 0)
|
|
1663
|
+
|
|
1664
|
+
if pairs is None:
|
|
1665
|
+
# Fix the set of object - test star pairs using the lowest degree
|
|
1666
|
+
# solution, so that all the degrees are compared on the same stars
|
|
1667
|
+
oidx, cidx, dist = spherical_match(
|
|
1668
|
+
ra, dec, cat_test[cat_col_ra], cat_test[cat_col_dec], sr=sr
|
|
1669
|
+
)
|
|
1670
|
+
|
|
1671
|
+
if not len(dist):
|
|
1672
|
+
break
|
|
1673
|
+
|
|
1674
|
+
# Keep only the nearest test star for every object
|
|
1675
|
+
idx = np.argsort(dist)
|
|
1676
|
+
_, first = np.unique(oidx[idx], return_index=True)
|
|
1677
|
+
pairs = (oidx[idx][first], cidx[idx][first])
|
|
1678
|
+
|
|
1679
|
+
oidx, cidx = pairs
|
|
1680
|
+
res = 3600 * np.median(
|
|
1681
|
+
spherical_distance(
|
|
1682
|
+
ra[oidx], dec[oidx],
|
|
1683
|
+
cat_test[cat_col_ra][cidx], cat_test[cat_col_dec][cidx],
|
|
1684
|
+
)
|
|
1685
|
+
)
|
|
1686
|
+
results[o] = res
|
|
1687
|
+
|
|
1688
|
+
log(' order %d: %d matches, test residual %.3f arcsec' % (o, n, res))
|
|
1689
|
+
|
|
1690
|
+
if not results:
|
|
1691
|
+
log('Order selection failed, using order 1')
|
|
1692
|
+
return 1
|
|
1693
|
+
|
|
1694
|
+
# Lowest degree that is within the threshold from the best residual
|
|
1695
|
+
best_res = min(results.values())
|
|
1696
|
+
best_order = min(o for o, res in results.items() if res <= best_res * (1 + auto_threshold))
|
|
1697
|
+
|
|
1698
|
+
log('Using distortion degree %d' % best_order)
|
|
1699
|
+
|
|
1700
|
+
return best_order
|
|
1701
|
+
|
|
1702
|
+
if order == 'auto':
|
|
1703
|
+
order = select_order()
|
|
1704
|
+
|
|
1705
|
+
h1, n_matches, sigma = run_scamp(order, catname=catname)
|
|
1706
|
+
|
|
1707
|
+
# Degrade the distortion degree if there are not enough reference stars to
|
|
1708
|
+
# constrain it, instead of just failing - lower order still beats no solution
|
|
1709
|
+
while h1 is not None and n_matches < min_matches_for(order) and order > 1:
|
|
1710
|
+
new_order = order - 1
|
|
1711
|
+
while new_order > 1 and n_matches < min_matches_for(new_order):
|
|
1712
|
+
new_order -= 1
|
|
1713
|
+
|
|
1714
|
+
log(
|
|
1715
|
+
'Too few matches (%d < %d) for order %d, repeating with order %d'
|
|
1716
|
+
% (n_matches, min_matches_for(order), order, new_order)
|
|
1717
|
+
)
|
|
1718
|
+
order = new_order
|
|
1719
|
+
h1, n_matches, sigma = run_scamp(order, catname=catname)
|
|
1720
|
+
|
|
1721
|
+
wcs = None
|
|
1722
|
+
|
|
1723
|
+
if h1 is not None:
|
|
1724
|
+
# Validate the solution quality using the actual residuals w.r.t. the
|
|
1725
|
+
# reference catalogue. We do not check Chi2_Reference here - it is a
|
|
1726
|
+
# reduced chi2 computed against the quoted per-star positional errors,
|
|
1727
|
+
# and thus reflects how well these errors are estimated at least as much
|
|
1728
|
+
# as the quality of the fit itself, so it is logged for information only.
|
|
1729
|
+
if max_sigma is None:
|
|
1730
|
+
max_sigma = 0.5 * sr * 3600
|
|
1731
|
+
|
|
1732
|
+
if n_matches < min_matches_for(order):
|
|
1733
|
+
log(
|
|
1734
|
+
'Too few matches (%d < %d), fitting likely failed'
|
|
1735
|
+
% (n_matches, min_matches_for(order))
|
|
1736
|
+
)
|
|
1737
|
+
elif sigma is not None and sigma > max_sigma:
|
|
1738
|
+
log(
|
|
1739
|
+
'Residuals too large (%.2f > %.2f arcsec), fitting likely failed'
|
|
1740
|
+
% (sigma, max_sigma)
|
|
1741
|
+
)
|
|
1742
|
+
elif get_header:
|
|
1743
|
+
# FIXME: should we really return raw / unfixed header here?..
|
|
1744
|
+
log('Returning raw header instead of WCS solution')
|
|
1745
|
+
wcs = h1
|
|
1746
|
+
else:
|
|
1747
|
+
wcs = WCS(h1)
|
|
1748
|
+
|
|
1749
|
+
if update:
|
|
1750
|
+
obj['ra'], obj['dec'] = wcs.all_pix2world(obj['x'], obj['y'], 0)
|
|
1751
|
+
|
|
1752
|
+
log(
|
|
1753
|
+
'Astrometric accuracy: %.2f" %.2f"'
|
|
1754
|
+
% (h1.get('ASTRRMS1', 0) * 3600, h1.get('ASTRRMS2', 0) * 3600)
|
|
1755
|
+
)
|
|
1756
|
+
|
|
1757
|
+
if _workdir is None:
|
|
1758
|
+
shutil.rmtree(workdir)
|
|
1759
|
+
|
|
1760
|
+
return wcs
|
|
1761
|
+
|
|
1762
|
+
|
|
1763
|
+
def store_wcs(filename, wcs, overwrite=True):
|
|
1764
|
+
"""Store WCS information in an (empty) FITS file.
|
|
1765
|
+
|
|
1766
|
+
Parameters
|
|
1767
|
+
----------
|
|
1768
|
+
filename : str
|
|
1769
|
+
Path to the output FITS file.
|
|
1770
|
+
wcs : astropy.wcs.WCS
|
|
1771
|
+
WCS object to store.
|
|
1772
|
+
overwrite : bool, optional
|
|
1773
|
+
If True, overwrite an existing file. Default is True.
|
|
1774
|
+
"""
|
|
1775
|
+
dirname = os.path.split(filename)[0]
|
|
1776
|
+
|
|
1777
|
+
try:
|
|
1778
|
+
# For Python3, we may simply use exists_ok=True to avoid wrapping it inside try-cache
|
|
1779
|
+
os.makedirs(dirname)
|
|
1780
|
+
except:
|
|
1781
|
+
pass
|
|
1782
|
+
|
|
1783
|
+
hdu = fits.PrimaryHDU(header=wcs.to_header(relax=True))
|
|
1784
|
+
hdu.writeto(filename, overwrite=overwrite)
|
|
1785
|
+
|
|
1786
|
+
|
|
1787
|
+
def upscale_wcs(wcs, scale=2, will_rebin=False):
|
|
1788
|
+
"""Return a WCS corresponding to a frame upscaled by a given factor.
|
|
1789
|
+
|
|
1790
|
+
Parameters
|
|
1791
|
+
----------
|
|
1792
|
+
wcs : astropy.wcs.WCS
|
|
1793
|
+
Input WCS to upscale.
|
|
1794
|
+
scale : float, optional
|
|
1795
|
+
Upscaling factor (not necessarily integer). Default is 2.
|
|
1796
|
+
will_rebin : bool, optional
|
|
1797
|
+
If True, adjust CRPIX so that rebinning the result back to original
|
|
1798
|
+
resolution with :func:`utils.rebin_image` will not introduce a shift.
|
|
1799
|
+
|
|
1800
|
+
Returns
|
|
1801
|
+
-------
|
|
1802
|
+
astropy.wcs.WCS
|
|
1803
|
+
WCS corresponding to the upscaled frame.
|
|
1804
|
+
"""
|
|
1805
|
+
|
|
1806
|
+
with warnings.catch_warnings():
|
|
1807
|
+
warnings.simplefilter('ignore')
|
|
1808
|
+
whdr = wcs.to_header(relax=True)
|
|
1809
|
+
|
|
1810
|
+
for _ in ['CRPIX1', 'CRPIX2']:
|
|
1811
|
+
whdr[_] = (whdr[_] - 1) * scale + 1
|
|
1812
|
+
|
|
1813
|
+
for _ in ['PC1_1', 'PC1_2', 'PC2_1', 'PC2_2']:
|
|
1814
|
+
whdr[_] /= scale
|
|
1815
|
+
|
|
1816
|
+
if 'SIP' in whdr['CTYPE1']:
|
|
1817
|
+
# SIP-type distortions
|
|
1818
|
+
for i in range(0, whdr.get('A_ORDER', 0) + 1):
|
|
1819
|
+
for j in range(0, whdr.get('A_ORDER', 0) + 1):
|
|
1820
|
+
if 'A_%d_%d' % (i, j) in whdr:
|
|
1821
|
+
whdr['A_%d_%d' % (i, j)] /= scale ** (i + j - 1)
|
|
1822
|
+
|
|
1823
|
+
for i in range(0, whdr.get('B_ORDER', 0) + 1):
|
|
1824
|
+
for j in range(0, whdr.get('B_ORDER', 0) + 1):
|
|
1825
|
+
if 'B_%d_%d' % (i, j) in whdr:
|
|
1826
|
+
whdr['B_%d_%d' % (i, j)] /= scale ** (i + j - 1)
|
|
1827
|
+
|
|
1828
|
+
for i in range(0, whdr.get('AP_ORDER', 0) + 1):
|
|
1829
|
+
for j in range(0, whdr.get('AP_ORDER', 0) + 1):
|
|
1830
|
+
if 'AP_%d_%d' % (i, j) in whdr:
|
|
1831
|
+
whdr['AP_%d_%d' % (i, j)] /= scale ** (i + j - 1)
|
|
1832
|
+
|
|
1833
|
+
for i in range(0, whdr.get('BP_ORDER', 0) + 1):
|
|
1834
|
+
for j in range(0, whdr.get('BP_ORDER', 0) + 1):
|
|
1835
|
+
if 'BP_%d_%d' % (i, j) in whdr:
|
|
1836
|
+
whdr['BP_%d_%d' % (i, j)] /= scale ** (i + j - 1)
|
|
1837
|
+
|
|
1838
|
+
new = WCS(whdr)
|
|
1839
|
+
|
|
1840
|
+
if will_rebin:
|
|
1841
|
+
# Switch from conserving pixel center position to pixel corner, so
|
|
1842
|
+
# that naive downscaling back will give the same image as original
|
|
1843
|
+
new.wcs.crpix -= -0.5 * scale + 0.5
|
|
1844
|
+
|
|
1845
|
+
return new
|
|
1846
|
+
|
|
1847
|
+
|
|
1848
|
+
def fit_astrometric_residuals(
|
|
1849
|
+
x_obs, y_obs, x_pred, y_pred,
|
|
1850
|
+
*,
|
|
1851
|
+
image_shape=None,
|
|
1852
|
+
backend='grid',
|
|
1853
|
+
**smoother_kwargs,
|
|
1854
|
+
):
|
|
1855
|
+
"""Fit a smooth (dx, dy) correction field for astrometric residuals.
|
|
1856
|
+
|
|
1857
|
+
Given matched pairs of observed and WCS-predicted pixel positions of
|
|
1858
|
+
the same sources, fit a smooth two-component field that maps any
|
|
1859
|
+
predicted position to the corresponding observed one. The returned
|
|
1860
|
+
callable applies the additive correction at arbitrary positions.
|
|
1861
|
+
|
|
1862
|
+
Parameters
|
|
1863
|
+
----------
|
|
1864
|
+
x_obs, y_obs : array-like, shape (N,)
|
|
1865
|
+
Observed pixel positions of matched sources (e.g. SEP centroids).
|
|
1866
|
+
x_pred, y_pred : array-like, shape (N,)
|
|
1867
|
+
WCS-predicted pixel positions of the same sources from a reference
|
|
1868
|
+
catalogue. ``x_obs - x_pred`` is the residual being modelled.
|
|
1869
|
+
image_shape : (H, W), optional
|
|
1870
|
+
Forwarded to the grid backend; ignored by LOESS.
|
|
1871
|
+
backend : {"grid", "loess"}
|
|
1872
|
+
Smoothing backend; see :func:`stdpipe.smoothing.fit_vector_field_2d`.
|
|
1873
|
+
Default ``"grid"`` because the typical astrometric-residual workflow
|
|
1874
|
+
evaluates the correction at far more positions than were used to
|
|
1875
|
+
fit it (e.g. forced-position photometry of large catalogues), where
|
|
1876
|
+
grid lookup is ~600--1000x faster than LOESS prediction.
|
|
1877
|
+
**smoother_kwargs :
|
|
1878
|
+
Forwarded to :func:`fit_vector_field_2d`. Grid backend keys:
|
|
1879
|
+
``grid_shape``, ``min_per_cell``, ``smooth_sigma``. LOESS backend
|
|
1880
|
+
keys: ``scales``, ``k``, ``robust_iters``, ...
|
|
1881
|
+
|
|
1882
|
+
Returns
|
|
1883
|
+
-------
|
|
1884
|
+
correct : callable
|
|
1885
|
+
``correct(x, y) -> (x_corrected, y_corrected)``: adds the smooth
|
|
1886
|
+
``(dx, dy)`` field at ``(x, y)``. Has a ``.smoother`` attribute
|
|
1887
|
+
holding the underlying field model for diagnostics.
|
|
1888
|
+
"""
|
|
1889
|
+
from . import smoothing
|
|
1890
|
+
|
|
1891
|
+
x_obs = np.asarray(x_obs, float)
|
|
1892
|
+
y_obs = np.asarray(y_obs, float)
|
|
1893
|
+
x_pred = np.asarray(x_pred, float)
|
|
1894
|
+
y_pred = np.asarray(y_pred, float)
|
|
1895
|
+
|
|
1896
|
+
smoother = smoothing.fit_vector_field_2d(
|
|
1897
|
+
x_pred, y_pred,
|
|
1898
|
+
x_obs - x_pred,
|
|
1899
|
+
y_obs - y_pred,
|
|
1900
|
+
backend=backend,
|
|
1901
|
+
image_shape=image_shape,
|
|
1902
|
+
**smoother_kwargs,
|
|
1903
|
+
)
|
|
1904
|
+
|
|
1905
|
+
def correct(x, y):
|
|
1906
|
+
cdx, cdy = smoother(x, y)
|
|
1907
|
+
return np.asarray(x, float) + cdx, np.asarray(y, float) + cdy
|
|
1908
|
+
|
|
1909
|
+
correct.smoother = smoother
|
|
1910
|
+
return correct
|
|
1911
|
+
|
|
1912
|
+
|
|
1913
|
+
def refine_positions_from_catalog(
|
|
1914
|
+
match, obj, cat, wcs,
|
|
1915
|
+
*,
|
|
1916
|
+
obj_col_x='x', obj_col_y='y',
|
|
1917
|
+
cat_col_ra='ra', cat_col_dec='dec',
|
|
1918
|
+
image_shape=None,
|
|
1919
|
+
backend='grid',
|
|
1920
|
+
**smoother_kwargs,
|
|
1921
|
+
):
|
|
1922
|
+
"""Build an astrometric residual correction from a match dict.
|
|
1923
|
+
|
|
1924
|
+
Wraps :func:`fit_astrometric_residuals` with the column-extraction
|
|
1925
|
+
boilerplate needed when the matches come from
|
|
1926
|
+
:func:`stdpipe.pipeline.calibrate_photometry` or
|
|
1927
|
+
:func:`stdpipe.photometry.match`. Returns the correction callable
|
|
1928
|
+
plus a small dictionary of pre/post-correction residual statistics.
|
|
1929
|
+
|
|
1930
|
+
Parameters
|
|
1931
|
+
----------
|
|
1932
|
+
match : dict
|
|
1933
|
+
Cross-match dictionary with at least the keys ``idx`` (boolean
|
|
1934
|
+
mask of accepted matches), ``oidx`` (object indices into ``obj``),
|
|
1935
|
+
and ``cidx`` (catalogue indices into ``cat``).
|
|
1936
|
+
obj, cat : astropy.table.Table
|
|
1937
|
+
Object and catalogue tables matched by ``match``.
|
|
1938
|
+
wcs : astropy.wcs.WCS
|
|
1939
|
+
WCS used to project catalogue positions into pixel coordinates.
|
|
1940
|
+
obj_col_x, obj_col_y : str
|
|
1941
|
+
Column names for observed pixel positions in ``obj``.
|
|
1942
|
+
cat_col_ra, cat_col_dec : str
|
|
1943
|
+
Column names for sky positions in ``cat``.
|
|
1944
|
+
image_shape : (H, W), optional
|
|
1945
|
+
Forwarded to the smoothing backend.
|
|
1946
|
+
backend : {"grid", "loess"}
|
|
1947
|
+
Smoothing backend.
|
|
1948
|
+
**smoother_kwargs :
|
|
1949
|
+
Forwarded to :func:`fit_astrometric_residuals`.
|
|
1950
|
+
|
|
1951
|
+
Returns
|
|
1952
|
+
-------
|
|
1953
|
+
correct : callable
|
|
1954
|
+
Same as :func:`fit_astrometric_residuals`.
|
|
1955
|
+
info : dict
|
|
1956
|
+
``n_matched``, ``n_used``, ``raw_median_dr_pix``,
|
|
1957
|
+
``corrected_median_dr_pix``, ``raw_q90_dr_pix``,
|
|
1958
|
+
``corrected_q90_dr_pix``. The corrected statistics are evaluated
|
|
1959
|
+
at the same matched positions used for the fit (in-sample), so
|
|
1960
|
+
they over-state the gain; for an unbiased estimate, subset
|
|
1961
|
+
``match['idx']`` into a holdout mask before calling this.
|
|
1962
|
+
"""
|
|
1963
|
+
accepted = np.asarray(match['idx'], bool)
|
|
1964
|
+
oidx = np.asarray(match['oidx'])[accepted]
|
|
1965
|
+
cidx = np.asarray(match['cidx'])[accepted]
|
|
1966
|
+
|
|
1967
|
+
x_obs = np.asarray(obj[obj_col_x][oidx], float)
|
|
1968
|
+
y_obs = np.asarray(obj[obj_col_y][oidx], float)
|
|
1969
|
+
x_pred, y_pred = wcs.all_world2pix(
|
|
1970
|
+
np.asarray(cat[cat_col_ra][cidx], float),
|
|
1971
|
+
np.asarray(cat[cat_col_dec][cidx], float),
|
|
1972
|
+
0,
|
|
1973
|
+
)
|
|
1974
|
+
|
|
1975
|
+
keep = (
|
|
1976
|
+
np.isfinite(x_obs) & np.isfinite(y_obs)
|
|
1977
|
+
& np.isfinite(x_pred) & np.isfinite(y_pred)
|
|
1978
|
+
)
|
|
1979
|
+
x_obs = x_obs[keep]; y_obs = y_obs[keep]
|
|
1980
|
+
x_pred = x_pred[keep]; y_pred = y_pred[keep]
|
|
1981
|
+
|
|
1982
|
+
correct = fit_astrometric_residuals(
|
|
1983
|
+
x_obs, y_obs, x_pred, y_pred,
|
|
1984
|
+
image_shape=image_shape, backend=backend, **smoother_kwargs,
|
|
1985
|
+
)
|
|
1986
|
+
|
|
1987
|
+
raw_dx = x_obs - x_pred
|
|
1988
|
+
raw_dy = y_obs - y_pred
|
|
1989
|
+
pred_dx, pred_dy = correct.smoother(x_pred, y_pred)
|
|
1990
|
+
corr_dr = np.hypot(raw_dx - pred_dx, raw_dy - pred_dy)
|
|
1991
|
+
raw_dr = np.hypot(raw_dx, raw_dy)
|
|
1992
|
+
|
|
1993
|
+
info = {
|
|
1994
|
+
'n_matched': int(np.sum(accepted)),
|
|
1995
|
+
'n_used': int(keep.sum()),
|
|
1996
|
+
'raw_median_dr_pix': float(np.median(raw_dr)) if raw_dr.size else float('nan'),
|
|
1997
|
+
'corrected_median_dr_pix': float(np.median(corr_dr)) if corr_dr.size else float('nan'),
|
|
1998
|
+
'raw_q90_dr_pix': float(np.percentile(raw_dr, 90)) if raw_dr.size else float('nan'),
|
|
1999
|
+
'corrected_q90_dr_pix': float(np.percentile(corr_dr, 90)) if corr_dr.size else float('nan'),
|
|
2000
|
+
}
|
|
2001
|
+
return correct, info
|