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/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