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.
@@ -0,0 +1,1141 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ from astropy.wcs import WCS
5
+ from astropy.coordinates import SkyCoord, SkyOffsetFrame
6
+ import astropy.units as u
7
+
8
+
9
+ def _sky_residuals_arcsec(
10
+ wcs: WCS, xy: np.ndarray, frame, off: SkyOffsetFrame, sky_off: SkyCoord, coslat: np.ndarray
11
+ ) -> np.ndarray:
12
+ """
13
+ Residuals in offset-frame arcsec around the field center.
14
+
15
+ The reference positions are precomputed in the offset frame (`sky_off`,
16
+ with `coslat` = cos of their offset latitudes) since they do not change
17
+ during the fit. Longitude offsets are scaled by cos(lat) so that both
18
+ components are proper angular distances even far from the center.
19
+
20
+ Returns concatenated [d_lon_arcsec, d_lat_arcsec] per point.
21
+ """
22
+ ra, dec = wcs.all_pix2world(xy[:, 0], xy[:, 1], 0)
23
+ model = SkyCoord(ra * u.deg, dec * u.deg, frame=frame)
24
+ model_off = model.transform_to(off)
25
+
26
+ dlon = (model_off.lon - sky_off.lon).to_value(u.arcsec) * coslat
27
+ dlat = (model_off.lat - sky_off.lat).to_value(u.arcsec)
28
+ return np.concatenate([dlon, dlat])
29
+
30
+
31
+ def _spherical_mean(sky: SkyCoord) -> SkyCoord:
32
+ """Direction of the mean unit vector of *sky* (robust to the lon=0/360 wrap)."""
33
+ xyz = sky.cartesian.xyz.value
34
+ mean = np.mean(np.atleast_2d(xyz), axis=1)
35
+ norm = np.linalg.norm(mean)
36
+ if not np.isfinite(norm) or norm <= 0:
37
+ raise ValueError("Cannot determine field center from reference positions")
38
+ mean = mean / norm
39
+ return SkyCoord(
40
+ np.degrees(np.arctan2(mean[1], mean[0])) * u.deg,
41
+ np.degrees(np.arcsin(np.clip(mean[2], -1.0, 1.0))) * u.deg,
42
+ frame=sky.frame,
43
+ )
44
+
45
+
46
+ def _pack_params(w: WCS, pv_deg: int) -> np.ndarray:
47
+ # CRPIX, CRVAL, CD(2x2), PV2_0..PV2_pv_deg
48
+ p = []
49
+ p += [float(w.wcs.crpix[0]), float(w.wcs.crpix[1])]
50
+ p += [float(w.wcs.crval[0]), float(w.wcs.crval[1])]
51
+ cd = np.array(w.wcs.cd, dtype=float)
52
+ p += [cd[0, 0], cd[0, 1], cd[1, 0], cd[1, 1]]
53
+
54
+ pv = np.zeros(pv_deg + 1, dtype=float)
55
+ # astropy stores PV in w.wcs.get_pv() / w.wcs.set_pv(); get_pv returns list of (i, m, value)
56
+ for i, m, val in w.wcs.get_pv():
57
+ if i == 2 and 0 <= m <= pv_deg:
58
+ pv[m] = float(val)
59
+ p += pv.tolist()
60
+ return np.array(p, dtype=float)
61
+
62
+
63
+ def _unpack_params_to_wcs(base: WCS, p: np.ndarray, pv_deg: int) -> WCS:
64
+ w = base.deepcopy()
65
+ w.wcs.crpix = [p[0], p[1]]
66
+ w.wcs.crval = [p[2], p[3]]
67
+ w.wcs.cd = np.array([[p[4], p[5]], [p[6], p[7]]], dtype=float)
68
+
69
+ pv_vals = p[8 : 8 + (pv_deg + 1)]
70
+ # Preserve any PV keywords for other axes (e.g. PV1_*)
71
+ pv_list = [(i, m, val) for (i, m, val) in base.wcs.get_pv() if i != 2]
72
+ pv_list += [(2, m, float(pv_vals[m])) for m in range(pv_deg + 1)]
73
+ w.wcs.set_pv(pv_list)
74
+ return w
75
+
76
+
77
+ def _normalize_zpn_pv1(w: WCS) -> WCS:
78
+ """Normalize a ZPN WCS so that PV2_1 = 1, absorbing the scale into CD.
79
+
80
+ The ZPN projection R = Σ PV2_m·θ^m has a degeneracy: scaling all PV
81
+ by ``c`` and CD by ``c`` gives an identical pixel→sky mapping. This
82
+ function normalizes PV2_1 to 1 (the FITS standard convention), moving
83
+ any absorbed plate-scale factor into the CD matrix.
84
+
85
+ Parameters
86
+ ----------
87
+ w : WCS
88
+ ZPN WCS (modified in place).
89
+
90
+ Returns
91
+ -------
92
+ WCS
93
+ The same object, normalized.
94
+ """
95
+ pv_dict = {}
96
+ for i, m, val in w.wcs.get_pv():
97
+ if i == 2:
98
+ pv_dict[m] = val
99
+
100
+ pv1 = pv_dict.get(1, 1.0)
101
+ if not np.isfinite(pv1) or abs(pv1) < 1e-15 or abs(pv1 - 1.0) < 1e-12:
102
+ return w # already normalized or can't normalize
103
+
104
+ scale = 1.0 / pv1
105
+
106
+ # CD' = CD/pv1 so that intermediate coords scale together with PV.
107
+ cd = np.array(w.wcs.cd, dtype=float)
108
+ w.wcs.cd = cd / pv1
109
+
110
+ # Scale all PV2 coefficients
111
+ pv_list = [(i, m, val) for (i, m, val) in w.wcs.get_pv() if i != 2]
112
+ for m, val in pv_dict.items():
113
+ pv_list.append((2, m, float(val * scale)))
114
+ w.wcs.set_pv(pv_list)
115
+
116
+ return w
117
+
118
+
119
+ def fit_zpn_wcs_from_points(
120
+ xy: np.ndarray,
121
+ sky: SkyCoord,
122
+ wcs_init: WCS,
123
+ pv_deg: int = 5,
124
+ fit_crpix: bool = True,
125
+ fit_crval: bool = True,
126
+ fit_cd: bool = True,
127
+ fit_pv: bool = True,
128
+ robust_loss: str = "soft_l1",
129
+ f_scale_arcsec: float = 2.0,
130
+ max_nfev: int = 200,
131
+ verbose: bool = False,
132
+ ):
133
+ """
134
+ Fit a ZPN WCS by optimizing WCS parameters against matched (x,y) <-> (ra,dec).
135
+
136
+ Parameters
137
+ ----------
138
+ xy : (N,2) array
139
+ Pixel coordinates (0-based as in astropy WCS, i.e. origin=0).
140
+ sky : SkyCoord (N)
141
+ Reference sky positions.
142
+ wcs_init : astropy.wcs.WCS
143
+ Initial WCS; MUST already be ZPN (RA---ZPN/DEC--ZPN) or at least usable as base.
144
+ pv_deg : int
145
+ Degree for PV2_m coefficients to fit (PV2_0..PV2_pv_deg).
146
+ fit_* : bool
147
+ Toggle which parameter blocks to optimize.
148
+ robust_loss : str
149
+ Passed to scipy.optimize.least_squares(loss=...).
150
+ Good options: 'linear', 'soft_l1', 'huber', 'cauchy'.
151
+ f_scale_arcsec : float
152
+ Robust loss scale in arcsec.
153
+ max_nfev : int
154
+ Optimization iterations (SciPy).
155
+ verbose : bool or callable
156
+ Whether to show verbose messages during the run of the function or not.
157
+ May also be a print-like function.
158
+
159
+ Returns
160
+ -------
161
+ wcs_best : astropy.wcs.WCS
162
+ result : scipy OptimizeResult (or None if nothing was fitted)
163
+
164
+ Notes
165
+ -----
166
+ For stability, the solver runs in two stages when *fit_pv* is True:
167
+ it first fits CRPIX/CRVAL/CD with PV fixed, then fits all free
168
+ parameters (including PV) with conservative bounds to prevent
169
+ invalid projections.
170
+ """
171
+ try:
172
+ from scipy.optimize import least_squares
173
+ except ImportError as e:
174
+ raise RuntimeError(
175
+ "fit_zpn_wcs_from_points requires SciPy (scipy.optimize.least_squares)"
176
+ ) from e
177
+
178
+ # Simple wrapper around print for logging in verbose mode only
179
+ log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
180
+
181
+ xy = np.asarray(xy, dtype=float)
182
+ if xy.ndim != 2 or xy.shape[1] != 2:
183
+ raise ValueError("xy must be (N,2)")
184
+ if len(sky) != xy.shape[0]:
185
+ raise ValueError("sky and xy must have the same length")
186
+
187
+ # Parameter packing assumes the CD convention; fold PC/CDELT into CD if needed
188
+ if not wcs_init.wcs.has_cd():
189
+ cd = np.array(wcs_init.pixel_scale_matrix, dtype=float)
190
+ wcs_init = wcs_init.deepcopy()
191
+ wcs_init.wcs.cdelt = np.ones(2)
192
+ if wcs_init.wcs.has_pc():
193
+ wcs_init.wcs.__delattr__("pc")
194
+ wcs_init.wcs.cd = cd
195
+
196
+ if fit_pv:
197
+ pv1_init = 0.0
198
+ for i, m, val in wcs_init.wcs.get_pv():
199
+ if i == 2 and m == 1:
200
+ pv1_init = float(val)
201
+ if not np.isfinite(pv1_init) or pv1_init == 0:
202
+ raise ValueError(
203
+ "Initial ZPN WCS has PV2_1 = 0 or undefined; the linear radial term "
204
+ "must be non-zero to fit PV coefficients "
205
+ "(e.g. initialize the WCS with tan_wcs_to_zpn())"
206
+ )
207
+
208
+ # Stable center for residual evaluation: spherical mean of the reference
209
+ # positions (robust to the lon=0/360 wrap, unlike a coordinate-wise median)
210
+ center = _spherical_mean(sky)
211
+
212
+ # The offset frame and reference positions in it are constant during the
213
+ # fit, so compute them once here rather than per residual evaluation
214
+ off = SkyOffsetFrame(origin=center)
215
+ sky_off = sky.transform_to(off)
216
+ coslat = np.cos(sky_off.lat.to_value(u.rad))
217
+
218
+ def _estimate_theta_max_deg(w: WCS) -> float | None:
219
+ if w.pixel_shape is None:
220
+ return None
221
+
222
+ nx, ny = w.pixel_shape
223
+ if nx is None or ny is None:
224
+ return None
225
+
226
+ # Prefer a pixel-scale estimate (robust to projection issues)
227
+ pixel_scale_deg = None
228
+ try:
229
+ pscales = w.proj_plane_pixel_scales()
230
+ if hasattr(pscales[0], "to_value"):
231
+ pixel_scale_deg = float(np.mean([s.to_value(u.deg) for s in pscales]))
232
+ else:
233
+ pixel_scale_deg = float(np.mean(pscales))
234
+ except Exception:
235
+ pixel_scale_deg = None
236
+
237
+ if pixel_scale_deg is not None and np.isfinite(pixel_scale_deg) and pixel_scale_deg > 0:
238
+ r_pix = 0.5 * np.hypot(nx, ny)
239
+ return max(float(pixel_scale_deg * r_pix), 0.01)
240
+
241
+ # Fallback: estimate from footprint if possible
242
+ try:
243
+ crpix = np.array(w.wcs.crpix, dtype=float)
244
+ corners = np.array(
245
+ [[0.0, 0.0], [nx - 1.0, 0.0], [0.0, ny - 1.0], [nx - 1.0, ny - 1.0]],
246
+ dtype=float,
247
+ )
248
+ ra_c, dec_c = w.all_pix2world(crpix[0], crpix[1], 0)
249
+ ra_k, dec_k = w.all_pix2world(corners[:, 0], corners[:, 1], 0)
250
+ if (
251
+ np.isfinite(ra_c)
252
+ and np.isfinite(dec_c)
253
+ and np.all(np.isfinite(ra_k))
254
+ and np.all(np.isfinite(dec_k))
255
+ ):
256
+ center_c = SkyCoord(ra_c * u.deg, dec_c * u.deg, frame="icrs")
257
+ sky_k = SkyCoord(ra_k * u.deg, dec_k * u.deg, frame="icrs")
258
+ theta = float(np.max(center_c.separation(sky_k).to_value(u.deg)))
259
+ return max(theta, 0.01)
260
+ except Exception:
261
+ pass
262
+
263
+ return None
264
+
265
+ def _make_bounds(p0: np.ndarray, mask: np.ndarray, base: WCS, allow_pv: bool) -> tuple:
266
+ # Build bounds over the full parameter vector, then slice by mask.
267
+ # CRPIX/CRVAL/CD unbounded by default
268
+ lb = np.full_like(p0, -np.inf, dtype=float)
269
+ ub = np.full_like(p0, +np.inf, dtype=float)
270
+
271
+ if allow_pv and pv_deg >= 1:
272
+ # PV bounds: keep solution in a physically plausible neighborhood
273
+ pv0 = float(p0[8 + 0])
274
+ pv1 = float(p0[8 + 1]) # non-zero, guaranteed by the check above
275
+
276
+ theta_max_deg = _estimate_theta_max_deg(base)
277
+ pv1_abs = abs(pv1)
278
+
279
+ # PV2_0 ~ 0 (allow small drift; keep init inside bounds)
280
+ pv0_abs = max(1e-3, abs(pv0) * 2.0)
281
+ lb[8 + 0] = pv0 - pv0_abs
282
+ ub[8 + 0] = pv0 + pv0_abs
283
+
284
+ # PV2_1 (linear): allow moderate range, keep the initial sign
285
+ lo = min(max(0.1, pv1_abs * 0.2), pv1_abs)
286
+ hi = pv1_abs * 5.0
287
+ if pv1 > 0:
288
+ lb[8 + 1], ub[8 + 1] = lo, hi
289
+ else:
290
+ lb[8 + 1], ub[8 + 1] = -hi, -lo
291
+
292
+ # Higher-order PV terms: limit contribution at field edge
293
+ pv_frac = 0.3 # allow ~30% of linear term at the edge
294
+ for m in range(2, pv_deg + 1):
295
+ pv_m = float(p0[8 + m])
296
+ if theta_max_deg is not None and np.isfinite(theta_max_deg) and theta_max_deg > 0:
297
+ abs_bound = pv_frac * pv1_abs / (theta_max_deg ** (m - 1))
298
+ else:
299
+ abs_bound = max(abs(pv_m) * 5.0, 1e-6)
300
+ # Keep initial value inside bounds
301
+ abs_bound = max(abs_bound, abs(pv_m) * 2.0)
302
+ lb[8 + m] = pv_m - abs_bound
303
+ ub[8 + m] = pv_m + abs_bound
304
+
305
+ return lb[mask], ub[mask]
306
+
307
+ def _fit_with_mask(base: WCS, allow_pv: bool):
308
+ p0 = _pack_params(base, pv_deg=pv_deg)
309
+
310
+ # Build a mask over parameters to optionally freeze blocks
311
+ mask = np.ones_like(p0, dtype=bool)
312
+ # indices: 0..1 CRPIX, 2..3 CRVAL, 4..7 CD, 8.. PV
313
+ if not fit_crpix:
314
+ mask[0:2] = False
315
+ if not fit_crval:
316
+ mask[2:4] = False
317
+ if not fit_cd:
318
+ mask[4:8] = False
319
+ if not allow_pv:
320
+ mask[8:] = False
321
+ elif pv_deg >= 1:
322
+ # PV2_1 (linear scale) is degenerate with CD — let it float.
323
+ # Caller normalizes after fit via _normalize_zpn_pv1().
324
+ pass
325
+
326
+ if not np.any(mask):
327
+ return base, None
328
+
329
+ free0 = p0[mask]
330
+ lb, ub = _make_bounds(p0, mask, base, allow_pv=allow_pv)
331
+
332
+ def make_wcs_from_free(free: np.ndarray) -> WCS:
333
+ p = p0.copy()
334
+ p[mask] = free
335
+ return _unpack_params_to_wcs(base, p, pv_deg=pv_deg)
336
+
337
+ def fun(free: np.ndarray) -> np.ndarray:
338
+ w = make_wcs_from_free(free)
339
+ try:
340
+ res = _sky_residuals_arcsec(w, xy, sky.frame, off, sky_off, coslat)
341
+ except ValueError:
342
+ # wcslib may reject some in-bounds PV combinations as an
343
+ # invalid projection; report a very poor fit instead of
344
+ # crashing so the optimizer backs off
345
+ return np.full(2 * xy.shape[0], 1e6)
346
+ if not np.all(np.isfinite(res)):
347
+ res = np.nan_to_num(res, nan=1e6, posinf=1e6, neginf=-1e6)
348
+ return res
349
+
350
+ res = least_squares(
351
+ fun,
352
+ free0,
353
+ bounds=(lb, ub),
354
+ loss=robust_loss,
355
+ f_scale=float(f_scale_arcsec),
356
+ max_nfev=int(max_nfev),
357
+ x_scale="jac",
358
+ verbose=0,
359
+ )
360
+
361
+ w_best = make_wcs_from_free(res.x)
362
+ return w_best, res
363
+
364
+ def _log_residuals(label: str, res) -> None:
365
+ if res is None:
366
+ return
367
+ n = xy.shape[0]
368
+ r = np.hypot(res.fun[:n], res.fun[n:])
369
+ log(
370
+ f"ZPN fit {label}: median residual {np.median(r):.3g} arcsec "
371
+ f"over {n} points, {res.nfev} function evaluations"
372
+ )
373
+
374
+ # Two-stage fit: first solve CRPIX/CRVAL/CD with PV fixed,
375
+ # then allow PV with conservative bounds.
376
+ w_curr = wcs_init
377
+ res_last = None
378
+
379
+ if fit_pv and (fit_crpix or fit_crval or fit_cd):
380
+ w_curr, res_last = _fit_with_mask(w_curr, allow_pv=False)
381
+ _log_residuals("linear stage (PV fixed)", res_last)
382
+
383
+ w_curr, res_last = _fit_with_mask(w_curr, allow_pv=fit_pv)
384
+ _log_residuals("final stage", res_last)
385
+
386
+ # Normalize so PV2_1 = 1 (breaks the CD/PV degeneracy)
387
+ if fit_pv:
388
+ w_curr = _normalize_zpn_pv1(w_curr)
389
+
390
+ return w_curr, res_last
391
+
392
+
393
+ def _fit_zpn_sip(
394
+ wcs_zpn,
395
+ xy,
396
+ sky,
397
+ sip_degree=2,
398
+ pv_deg=5,
399
+ n_iter=15,
400
+ robust_loss="soft_l1",
401
+ f_scale_arcsec=2.0,
402
+ max_nfev=200,
403
+ verbose=False,
404
+ ):
405
+ """Add SIP distortion corrections on top of a ZPN WCS.
406
+
407
+ The SIP polynomials A(u,v), B(u,v) correct for non-radial distortions
408
+ (coma, astigmatism, etc.) that the ZPN radial model cannot capture.
409
+
410
+ Iterates between fitting SIP coefficients (pixel-space residuals) and
411
+ re-optimizing PV parameters with SIP applied.
412
+
413
+ Parameters
414
+ ----------
415
+ wcs_zpn : WCS
416
+ ZPN WCS with PV parameters already fitted (no SIP).
417
+ xy : (N, 2) array
418
+ Pixel coordinates of matched sources (0-based).
419
+ sky : SkyCoord
420
+ Reference sky positions.
421
+ sip_degree : int
422
+ SIP polynomial order (default 2).
423
+ pv_deg : int
424
+ ZPN PV polynomial degree for re-fitting.
425
+ n_iter : int
426
+ Maximal number of PV+SIP alternation iterations.
427
+ robust_loss, f_scale_arcsec, max_nfev :
428
+ Passed to ``fit_zpn_wcs_from_points`` for PV re-fitting.
429
+ verbose : bool or callable
430
+ Whether to show verbose messages during the run of the function or not.
431
+ May also be a print-like function.
432
+
433
+ Returns
434
+ -------
435
+ wcs_sip : WCS
436
+ ZPN-SIP WCS with both PV and SIP coefficients.
437
+ """
438
+ from astropy.wcs.wcs import Sip
439
+
440
+ # Simple wrapper around print for logging in verbose mode only
441
+ log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
442
+
443
+ xy = np.asarray(xy, dtype=float)
444
+ w = wcs_zpn.deepcopy()
445
+
446
+ # SIP term indices: (p, q) with 2 <= p+q <= sip_degree
447
+ pq = []
448
+ for total in range(2, sip_degree + 1):
449
+ for q in range(total + 1):
450
+ pq.append((total - q, q))
451
+
452
+ crpix = np.array(w.wcs.crpix, dtype=float) # 1-based
453
+ prev_q90 = np.inf
454
+
455
+ for iteration in range(n_iter):
456
+ # Strip SIP to get the base ZPN-only WCS
457
+ w_nosip = w.deepcopy()
458
+ w_nosip.sip = None
459
+ if '-SIP' in w_nosip.wcs.ctype[0]:
460
+ w_nosip.wcs.ctype = [c.replace('-SIP', '') for c in w_nosip.wcs.ctype]
461
+
462
+ # Undistorted pixel coords: where ZPN projection places catalog stars.
463
+ # quiet=True prevents NoConvergence for points near the projection edge;
464
+ # non-finite results are excluded below.
465
+ x_cat, y_cat = w_nosip.all_world2pix(sky.ra.deg, sky.dec.deg, 0, quiet=True)
466
+ u_cat = x_cat - (crpix[0] - 1)
467
+ v_cat = y_cat - (crpix[1] - 1)
468
+
469
+ # Observed (distorted) pixel coords
470
+ u_obs = xy[:, 0] - (crpix[0] - 1)
471
+ v_obs = xy[:, 1] - (crpix[1] - 1)
472
+
473
+ # SIP convention: u_distorted = u_undistorted + A(u, v)
474
+ # u_obs = undistorted (raw pixel); u_cat = distorted (from ZPN inverse)
475
+ # So: A(u_obs, v_obs) = u_cat - u_obs
476
+ du = u_cat - u_obs
477
+ dv = v_cat - v_obs
478
+
479
+ # Filter outliers (use median absolute deviation)
480
+ dist = np.sqrt(du**2 + dv**2)
481
+ finite = np.isfinite(dist)
482
+ if np.sum(finite) < len(pq) + 5:
483
+ log(f"SIP iteration {iteration}: too few finite points ({np.sum(finite)}), stopping")
484
+ break
485
+ med_dist = np.median(dist[finite])
486
+ mad = np.median(np.abs(dist[finite] - med_dist))
487
+ good = finite & (dist < med_dist + 5 * max(mad, 0.5)) # generous 5-sigma clip
488
+
489
+ if np.sum(good) < len(pq) + 5:
490
+ log(f"SIP iteration {iteration}: too few points after clipping ({np.sum(good)}), stopping")
491
+ break
492
+
493
+ # SIP basis uses undistorted (raw pixel) coordinates
494
+ u_fit = u_obs[good]
495
+ v_fit = v_obs[good]
496
+ du_fit = du[good]
497
+ dv_fit = dv[good]
498
+
499
+ # Normalize coordinates to prevent ill-conditioning for high
500
+ # SIP degrees. Without this, u^5 ~ 2000^5 ~ 3e16 creates a
501
+ # design matrix with condition number > 1e14.
502
+ u_scale = max(np.abs(u_fit).max(), 1.0)
503
+ v_scale = max(np.abs(v_fit).max(), 1.0)
504
+ u_norm = u_fit / u_scale
505
+ v_norm = v_fit / v_scale
506
+
507
+ # Build SIP basis matrix in normalized coordinates
508
+ basis = np.column_stack([u_norm**p * v_norm**q for p, q in pq])
509
+
510
+ # Fit A and B coefficients via least squares (normalized)
511
+ ca_norm, _, _, _ = np.linalg.lstsq(basis, du_fit, rcond=None)
512
+ cb_norm, _, _, _ = np.linalg.lstsq(basis, dv_fit, rcond=None)
513
+
514
+ # Convert back to original (unnormalized) SIP coefficients
515
+ a_vals = np.zeros((sip_degree + 1, sip_degree + 1))
516
+ b_vals = np.zeros((sip_degree + 1, sip_degree + 1))
517
+ for k, (p, q) in enumerate(pq):
518
+ scale_pq = u_scale**p * v_scale**q
519
+ a_vals[p, q] = ca_norm[k] / scale_pq
520
+ b_vals[p, q] = cb_norm[k] / scale_pq
521
+
522
+ # Apply SIP to the WCS
523
+ if '-SIP' not in w.wcs.ctype[0]:
524
+ w.wcs.ctype = [c + '-SIP' for c in w.wcs.ctype]
525
+
526
+ w.sip = Sip(
527
+ a_vals,
528
+ b_vals,
529
+ np.zeros((sip_degree + 1, sip_degree + 1)),
530
+ np.zeros((sip_degree + 1, sip_degree + 1)),
531
+ crpix,
532
+ )
533
+
534
+ # Check convergence: stop when SIP corrections stabilize.
535
+ # Use 90th percentile (sensitive to outer-field + center) rather
536
+ # than median which converges before the center is corrected.
537
+ curr_q90 = np.percentile(dist[good], 90)
538
+ log(f"SIP iteration {iteration}: q90 distortion {curr_q90:.4g} pix over {np.sum(good)} points")
539
+ if iteration >= 2 and prev_q90 < np.inf:
540
+ rel_change = abs(curr_q90 - prev_q90) / max(prev_q90, 1e-6)
541
+ if rel_change < 0.005:
542
+ log(f"SIP converged after {iteration + 1} iterations")
543
+ break
544
+ prev_q90 = curr_q90
545
+
546
+ # Re-fit PV with SIP now applied (last iteration skip PV refit)
547
+ if iteration < n_iter - 1:
548
+ w, _ = fit_zpn_wcs_from_points(
549
+ xy,
550
+ sky,
551
+ w,
552
+ pv_deg=pv_deg,
553
+ robust_loss=robust_loss,
554
+ f_scale_arcsec=f_scale_arcsec,
555
+ max_nfev=max_nfev,
556
+ verbose=verbose,
557
+ )
558
+ crpix = np.array(w.wcs.crpix, dtype=float)
559
+
560
+ return w
561
+
562
+
563
+ def tan_wcs_to_zpn(
564
+ w_tan: WCS,
565
+ pv_deg: int = 5,
566
+ n_samples: int = 256,
567
+ theta_max_deg: float | None = None,
568
+ ) -> WCS:
569
+ """
570
+ Convert a celestial TAN WCS into a ZPN WCS with PV2_m initialized to approximate TAN.
571
+
572
+ Notes
573
+ -----
574
+ TAN (gnomonic) radial law: r = tan(theta) [in radians]
575
+ In "degrees" units (common in FITS WCS plane coordinates), that's:
576
+ r_deg = tan(theta_rad) * (180/pi)
577
+
578
+ ZPN radial law: r_deg ≈ sum_{m=0..M} PV2_m * theta_deg^m
579
+ We set PV2_0 = 0 and fit PV2_1..PV2_M to approximate the TAN law
580
+ over theta in [0, theta_max_deg].
581
+
582
+ SIP and other pixel-space distortions of the input WCS are dropped
583
+ (they are not representable in the radial ZPN model); refit them on
584
+ top of the result if needed, e.g. with :func:`_fit_zpn_sip`.
585
+
586
+ Parameters
587
+ ----------
588
+ w_tan : astropy.wcs.WCS
589
+ Input TAN WCS (2D celestial).
590
+ pv_deg : int
591
+ Highest PV degree to initialize (PV2_0..PV2_pv_deg).
592
+ 5–9 is usually plenty; higher can get wiggly.
593
+ n_samples : int
594
+ Samples used for the polynomial fit.
595
+ theta_max_deg : float or None
596
+ Max angular radius (deg) over which to match TAN. If None,
597
+ estimated from image footprint corners using pixel_shape.
598
+
599
+ Returns
600
+ -------
601
+ w_zpn : astropy.wcs.WCS
602
+ A ZPN WCS with same CRVAL/CRPIX/CD and PV2_m initialized.
603
+ """
604
+ ctype1, ctype2 = w_tan.wcs.ctype
605
+ if len(ctype1) < 8 or len(ctype2) < 8:
606
+ raise ValueError("Expected CTYPE like 'RA---TAN'/'DEC--TAN'.")
607
+
608
+ # Clean linear WCS (CRPIX/CRVAL/CD + metadata) with ZPN projection;
609
+ # strips SIP and PV parameters
610
+ w = _wcs_to_linear(w_tan, "ZPN")
611
+
612
+ # Estimate theta_max from footprint if not provided
613
+ if theta_max_deg is None:
614
+ if w.pixel_shape is None:
615
+ raise ValueError(
616
+ "w_tan.pixel_shape is None; provide theta_max_deg explicitly "
617
+ "or set w_tan.pixel_shape = (nx, ny)."
618
+ )
619
+ nx, ny = w.pixel_shape # (NAXIS1, NAXIS2)
620
+ crpix = np.array(w.wcs.crpix, dtype=float)
621
+
622
+ # Corners in pixel coordinates (origin=0 convention for astropy WCS)
623
+ corners = np.array(
624
+ [
625
+ [0.0, 0.0],
626
+ [nx - 1.0, 0.0],
627
+ [0.0, ny - 1.0],
628
+ [nx - 1.0, ny - 1.0],
629
+ ],
630
+ dtype=float,
631
+ )
632
+
633
+ # Sky positions of corners and center under TAN WCS
634
+ ra_c, dec_c = w_tan.all_pix2world(crpix[0], crpix[1], 0)
635
+ center = SkyCoord(ra_c * u.deg, dec_c * u.deg, frame="icrs")
636
+
637
+ ra_k, dec_k = w_tan.all_pix2world(corners[:, 0], corners[:, 1], 0)
638
+ sky_k = SkyCoord(ra_k * u.deg, dec_k * u.deg, frame="icrs")
639
+
640
+ theta_max_deg = float(np.max(center.separation(sky_k).to_value(u.deg)))
641
+
642
+ # Safety floor
643
+ theta_max_deg = max(theta_max_deg, 0.01)
644
+
645
+ # Fit PV2_m so that ZPN radial r(theta) ~ TAN radial r(theta)
646
+ # Use degrees for theta and degrees for r on plane.
647
+ theta = np.linspace(0.0, theta_max_deg, n_samples, dtype=float)
648
+ theta_rad = np.deg2rad(theta)
649
+ r_tan_deg = np.tan(theta_rad) * (180.0 / np.pi)
650
+
651
+ # Build design matrix for m=1..pv_deg (PV2_0 fixed to 0)
652
+ # r ≈ sum c[m-1] * theta^m
653
+ A = np.vstack([theta**m for m in range(1, pv_deg + 1)]).T
654
+
655
+ # Mild weighting emphasizing the central region: stabilizes the fit
656
+ # against the steep TAN growth near theta_max; only used for initialization
657
+ wgt = 1.0 / (1.0 + (theta / (0.6 * theta_max_deg)) ** 2)
658
+ Aw = A * wgt[:, None]
659
+ bw = r_tan_deg * wgt
660
+
661
+ coeffs, *_ = np.linalg.lstsq(Aw, bw, rcond=None)
662
+
663
+ pv_list = [(2, 0, 0.0)] + [(2, m, float(coeffs[m - 1])) for m in range(1, pv_deg + 1)]
664
+ w.wcs.set_pv(pv_list)
665
+
666
+ return w
667
+
668
+
669
+ def convert_wcs_projection(
670
+ wcs_input: WCS,
671
+ target_projection: str,
672
+ pv_deg: int = 5,
673
+ ) -> WCS:
674
+ """Convert a celestial WCS to a different projection type.
675
+
676
+ Parameters
677
+ ----------
678
+ wcs_input : WCS
679
+ Input WCS (any celestial projection).
680
+ target_projection : str
681
+ Target projection code, e.g. ``'TAN'``, ``'ZPN'``, ``'STG'``,
682
+ ``'ARC'``, ``'ZEA'``, ``'SIN'``, etc.
683
+ pv_deg : int, optional
684
+ ZPN PV polynomial degree (only used when *target_projection* is
685
+ ``'ZPN'``). Default 5.
686
+
687
+ Returns
688
+ -------
689
+ WCS
690
+ New WCS with the target projection. For ZPN the PV coefficients
691
+ are initialised from the TAN radial law — an exact match for TAN
692
+ input, and a generic starting point (meant to be refined by a
693
+ subsequent fit) for other input projections. For other targets
694
+ CRPIX/CRVAL/CD are copied and CTYPE is replaced, so the mapping
695
+ away from the reference point changes; refit afterwards.
696
+ """
697
+ target = target_projection.upper().strip()
698
+
699
+ # Detect current projection code (last 3 chars of CTYPE after the dash)
700
+ try:
701
+ cur_proj = wcs_input.wcs.ctype[0].split('-')[-1]
702
+ # Strip trailing SIP suffix if present
703
+ if cur_proj == 'SIP':
704
+ parts = wcs_input.wcs.ctype[0].replace('-SIP', '').split('-')
705
+ cur_proj = parts[-1]
706
+ except Exception:
707
+ cur_proj = ''
708
+
709
+ if cur_proj == target:
710
+ return wcs_input.deepcopy()
711
+
712
+ # ZPN needs special initialisation (PV coefficients)
713
+ if target == 'ZPN':
714
+ # tan_wcs_to_zpn works from TAN (with or without SIP)
715
+ # For non-TAN input, we first need a TAN-like WCS base
716
+ if 'TAN' in cur_proj:
717
+ return tan_wcs_to_zpn(wcs_input, pv_deg=pv_deg)
718
+ else:
719
+ # Build a minimal TAN WCS from the input's linear terms,
720
+ # then convert to ZPN
721
+ w_tan = _wcs_to_linear(wcs_input, 'TAN')
722
+ return tan_wcs_to_zpn(w_tan, pv_deg=pv_deg)
723
+
724
+ # For all other projections: copy linear WCS with new CTYPE
725
+ return _wcs_to_linear(wcs_input, target)
726
+
727
+
728
+ def _wcs_to_linear(wcs_input: WCS, proj_code: str) -> WCS:
729
+ """Create a clean linear WCS with the same pointing but a new projection.
730
+
731
+ Copies CRPIX, CRVAL, CD (or derives CD from PC+CDELT), metadata, and
732
+ pixel_shape. Strips SIP distortion and PV parameters.
733
+ """
734
+ w = WCS(naxis=2)
735
+
736
+ # CD matrix; pixel_scale_matrix correctly folds CDELT_i * PC_ij
737
+ # (row scaling) when the input uses the PC/CDELT convention
738
+ cd = np.array(wcs_input.pixel_scale_matrix, dtype=float)
739
+
740
+ w.wcs.crpix = np.array(wcs_input.wcs.crpix, dtype=float)
741
+ w.wcs.crval = np.array(wcs_input.wcs.crval, dtype=float)
742
+ w.wcs.cd = cd
743
+
744
+ # Build CTYPE: preserve axis names (e.g. 'RA---' / 'DEC--')
745
+ ctype1, ctype2 = wcs_input.wcs.ctype
746
+ # Strip any existing projection + SIP suffix
747
+ base1 = ctype1.replace('-SIP', '')[:5]
748
+ base2 = ctype2.replace('-SIP', '')[:5]
749
+ w.wcs.ctype = (base1 + proj_code, base2 + proj_code)
750
+
751
+ # Copy metadata
752
+ for attr in ('cunit', 'radesys', 'equinox'):
753
+ try:
754
+ setattr(w.wcs, attr, getattr(wcs_input.wcs, attr))
755
+ except Exception:
756
+ pass
757
+ for attr in ('lonpole', 'latpole'):
758
+ try:
759
+ val = getattr(wcs_input.wcs, attr)
760
+ if np.isfinite(val):
761
+ setattr(w.wcs, attr, float(val))
762
+ except Exception:
763
+ pass
764
+
765
+ if wcs_input.pixel_shape is not None:
766
+ w.pixel_shape = wcs_input.pixel_shape
767
+
768
+ return w
769
+
770
+
771
+ def _fit_tan_sip_robust(
772
+ xy,
773
+ world_coords,
774
+ proj_point="center",
775
+ projection=None,
776
+ sip_degree=2,
777
+ robust_loss="soft_l1",
778
+ f_scale=None,
779
+ verbose=False,
780
+ ):
781
+ """Fit TAN+SIP WCS with robust loss function.
782
+
783
+ Replicates astropy's ``fit_wcs_from_points`` two-stage fitting
784
+ (linear WCS, then joint CD + CRPIX + SIP) but uses a robust loss
785
+ in the linear stage and a two-pass approach for the SIP stage:
786
+ first L2 to capture the distortion pattern, then robust re-fit
787
+ with data-driven ``f_scale`` to suppress outliers.
788
+
789
+ Parameters
790
+ ----------
791
+ xy : tuple of arrays ``(x, y)``
792
+ Pixel coordinates.
793
+ world_coords : `~astropy.coordinates.SkyCoord`
794
+ Reference sky positions.
795
+ proj_point : str or SkyCoord
796
+ Projection center ('center' or explicit).
797
+ projection : WCS or None
798
+ Template WCS; used as initial guess for CD matrix.
799
+ sip_degree : int
800
+ SIP polynomial degree.
801
+ robust_loss : str
802
+ Loss function for `scipy.optimize.least_squares`.
803
+ f_scale : float or None
804
+ Soft margin for robust loss in the SIP stage. If None
805
+ (default), estimated adaptively from the L2 SIP residuals.
806
+ The linear stage always uses a data-driven scale.
807
+ verbose : bool or callable
808
+ Whether to show verbose messages during the run of the function or not.
809
+ May also be a print-like function.
810
+
811
+ Returns
812
+ -------
813
+ wcs : `~astropy.wcs.WCS`
814
+ """
815
+ from scipy.optimize import least_squares
816
+
817
+ from astropy.wcs.utils import celestial_frame_to_wcs
818
+ from astropy.wcs.wcs import Sip
819
+
820
+ try:
821
+ # Private astropy helpers (stable since astropy 3.x, but not public API)
822
+ from astropy.wcs.utils import _linear_wcs_fit, _sip_fit
823
+ except ImportError as e:
824
+ raise RuntimeError(
825
+ "astropy internals (_linear_wcs_fit/_sip_fit) are not available in "
826
+ "this astropy version; _fit_tan_sip_robust needs updating"
827
+ ) from e
828
+
829
+ # Simple wrapper around print for logging in verbose mode only
830
+ log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
831
+
832
+ xp, yp = xy
833
+ try:
834
+ lon, lat = world_coords.data.lon.deg, world_coords.data.lat.deg
835
+ except AttributeError:
836
+ unit_sph = world_coords.unit_spherical
837
+ lon, lat = unit_sph.lon.deg, unit_sph.lat.deg
838
+
839
+ use_center_as_proj_point = str(proj_point) == "center"
840
+
841
+ # Build WCS template
842
+ if isinstance(projection, str) or projection is None:
843
+ proj_code = projection if isinstance(projection, str) else "TAN"
844
+ wcs = celestial_frame_to_wcs(frame=world_coords.frame, projection=proj_code)
845
+ else:
846
+ wcs = projection.deepcopy()
847
+ wcs.sip = None
848
+
849
+ if wcs.wcs.has_pc():
850
+ # pixel_scale_matrix folds CDELT_i * PC_ij with correct row scaling
851
+ wcs.wcs.cd = wcs.pixel_scale_matrix
852
+ wcs.wcs.cdelt = (1.0, 1.0)
853
+ wcs.wcs.__delattr__("pc")
854
+
855
+ xpmin, xpmax = xp.min(), xp.max()
856
+ ypmin, ypmax = yp.min(), yp.max()
857
+
858
+ wcs.pixel_shape = (
859
+ 1 if xpmax <= 0.0 else int(np.ceil(xpmax)),
860
+ 1 if ypmax <= 0.0 else int(np.ceil(ypmax)),
861
+ )
862
+
863
+ if use_center_as_proj_point:
864
+ sc1 = SkyCoord(lon.min() * u.deg, lat.max() * u.deg)
865
+ sc2 = SkyCoord(lon.max() * u.deg, lat.min() * u.deg)
866
+ pa = sc1.position_angle(sc2)
867
+ sep = sc1.separation(sc2)
868
+ midpoint_sc = sc1.directional_offset_by(pa, sep / 2)
869
+ wcs.wcs.crval = (midpoint_sc.data.lon.deg, midpoint_sc.data.lat.deg)
870
+ wcs.wcs.crpix = ((xpmax + xpmin) / 2.0, (ypmax + ypmin) / 2.0)
871
+ else:
872
+ proj_point = proj_point.transform_to(world_coords.frame)
873
+ wcs.wcs.crval = (proj_point.data.lon.deg, proj_point.data.lat.deg)
874
+ close = lambda l, p: p[np.argmin(np.abs(l))]
875
+ wcs.wcs.crpix = (
876
+ close(lon - wcs.wcs.crval[0], xp + 1),
877
+ close(lat - wcs.wcs.crval[1], yp + 1),
878
+ )
879
+
880
+ if xpmin == xpmax:
881
+ xpmin, xpmax = xpmin - 0.5, xpmax + 0.5
882
+ if ypmin == ypmax:
883
+ ypmin, ypmax = ypmin - 0.5, ypmax + 0.5
884
+
885
+ # --- Stage 1: linear WCS (CD + CRPIX) ---
886
+ # Use robust loss here: linear residuals include distortion as systematic
887
+ # pattern that looks like outliers; robust loss prevents them from biasing
888
+ # the linear fit.
889
+ p0_lin = np.concatenate([wcs.wcs.cd.flatten(), wcs.wcs.crpix.flatten()])
890
+ lin_resids = _linear_wcs_fit(p0_lin, lon, lat, xp, yp, wcs)
891
+ lin_f_scale = max(float(np.median(np.abs(lin_resids)) * 3), 1.0 / 3600)
892
+
893
+ fit = least_squares(
894
+ _linear_wcs_fit,
895
+ p0_lin,
896
+ args=(lon, lat, xp, yp, wcs),
897
+ bounds=[
898
+ [-np.inf, -np.inf, -np.inf, -np.inf, xpmin + 1, ypmin + 1],
899
+ [np.inf, np.inf, np.inf, np.inf, xpmax + 1, ypmax + 1],
900
+ ],
901
+ loss=robust_loss,
902
+ f_scale=lin_f_scale,
903
+ method="trf",
904
+ )
905
+ wcs.wcs.crpix = np.array(fit.x[4:6])
906
+ wcs.wcs.cd = np.array(fit.x[0:4].reshape((2, 2)))
907
+ log(
908
+ f"TAN-SIP linear stage: median residual "
909
+ f"{np.median(np.abs(fit.fun)) * 3600:.3g} arcsec"
910
+ )
911
+
912
+ # --- Stage 2: joint CD + CRPIX + SIP ---
913
+ if "-SIP" not in wcs.wcs.ctype[0]:
914
+ wcs.wcs.ctype = [x + "-SIP" for x in wcs.wcs.ctype]
915
+
916
+ coef_names = [
917
+ f"{i}_{j}"
918
+ for i in range(sip_degree + 1)
919
+ for j in range(sip_degree + 1)
920
+ if (i + j) < (sip_degree + 1) and (i + j) > 1
921
+ ]
922
+
923
+ sip_bounds = (
924
+ [xpmin + 1, ypmin + 1] + [-np.inf] * (4 + 2 * len(coef_names)),
925
+ [xpmax + 1, ypmax + 1] + [np.inf] * (4 + 2 * len(coef_names)),
926
+ )
927
+ # Parameter layout of _sip_fit: [CRPIX(2), CD(4), A_coeffs, B_coeffs]
928
+ sip_args = (lon, lat, xp, yp, wcs, sip_degree, coef_names)
929
+
930
+ # Pass 1: L2 fit to capture the full distortion pattern (SIP starts at 0)
931
+ p0_sip = np.concatenate(
932
+ (
933
+ np.array(wcs.wcs.crpix),
934
+ wcs.wcs.cd.flatten(),
935
+ np.zeros(2 * len(coef_names)),
936
+ )
937
+ )
938
+
939
+ # Compute parameter scales for the optimizer.
940
+ # SIP coefficients A_{p,q} multiply (u-crpix)^p * (v-crpix)^q where
941
+ # pixel offsets can be ~U pixels. Expected coefficient magnitude is
942
+ # ~1/U^(p+q), which spans many orders of magnitude for high SIP orders.
943
+ # Without proper scaling the optimizer cannot find meaningful SIP≥4
944
+ # coefficients on wide-field images.
945
+ U = max(
946
+ abs(xpmax - wcs.wcs.crpix[0]),
947
+ abs(xpmin - wcs.wcs.crpix[0]),
948
+ abs(ypmax - wcs.wcs.crpix[1]),
949
+ abs(ypmin - wcs.wcs.crpix[1]),
950
+ 1.0,
951
+ )
952
+ sip_x_scale = np.ones(len(p0_sip))
953
+ # CRPIX: O(pixels) → scale 1
954
+ # CD: O(deg/pixel) → use current magnitude
955
+ cd_scale = max(np.abs(wcs.wcs.cd).max(), 1e-10)
956
+ sip_x_scale[2:6] = cd_scale
957
+ # SIP coefficients: O(1/U^(p+q))
958
+ for k, coef_name in enumerate(coef_names):
959
+ p_deg, q_deg = int(coef_name[0]), int(coef_name[2])
960
+ order = p_deg + q_deg
961
+ scale = 1.0 / U**order
962
+ sip_x_scale[6 + k] = scale
963
+ sip_x_scale[6 + len(coef_names) + k] = scale
964
+
965
+ fit = least_squares(
966
+ _sip_fit,
967
+ p0_sip,
968
+ args=sip_args,
969
+ bounds=sip_bounds,
970
+ x_scale=sip_x_scale,
971
+ )
972
+
973
+ # Pass 2: robust re-fit starting from L2 solution
974
+ # f_scale estimated from L2 residuals so distortion signal is inlier
975
+ sip_resids = fit.fun
976
+ if f_scale is None:
977
+ sip_f_scale = max(float(np.median(np.abs(sip_resids)) * 3), 1e-7)
978
+ else:
979
+ sip_f_scale = float(f_scale)
980
+
981
+ fit = least_squares(
982
+ _sip_fit,
983
+ fit.x, # warm-start from L2 solution
984
+ args=sip_args,
985
+ bounds=sip_bounds,
986
+ loss=robust_loss,
987
+ f_scale=sip_f_scale,
988
+ method="trf",
989
+ x_scale=sip_x_scale,
990
+ )
991
+ log(
992
+ f"TAN-SIP robust stage: median residual "
993
+ f"{np.median(np.abs(fit.fun)) * 3600:.3g} arcsec, f_scale {sip_f_scale * 3600:.3g} arcsec"
994
+ )
995
+
996
+ coef_fit = (
997
+ list(fit.x[6 : 6 + len(coef_names)]),
998
+ list(fit.x[6 + len(coef_names) :]),
999
+ )
1000
+
1001
+ wcs.wcs.cd = fit.x[2:6].reshape((2, 2))
1002
+ wcs.wcs.crpix = fit.x[0:2]
1003
+
1004
+ a_vals = np.zeros((sip_degree + 1, sip_degree + 1))
1005
+ b_vals = np.zeros((sip_degree + 1, sip_degree + 1))
1006
+
1007
+ for coef_name in coef_names:
1008
+ a_vals[int(coef_name[0])][int(coef_name[2])] = coef_fit[0].pop(0)
1009
+ b_vals[int(coef_name[0])][int(coef_name[2])] = coef_fit[1].pop(0)
1010
+
1011
+ wcs.sip = Sip(
1012
+ a_vals,
1013
+ b_vals,
1014
+ np.zeros((sip_degree + 1, sip_degree + 1)),
1015
+ np.zeros((sip_degree + 1, sip_degree + 1)),
1016
+ wcs.wcs.crpix,
1017
+ )
1018
+
1019
+ return wcs
1020
+
1021
+
1022
+ def fit_wcs_from_points(
1023
+ xy,
1024
+ world_coords,
1025
+ proj_point="center",
1026
+ projection=None,
1027
+ sip_degree=None,
1028
+ pv_deg=5,
1029
+ verbose=False,
1030
+ ):
1031
+ """Drop-in wrapper around :func:`astropy.wcs.utils.fit_wcs_from_points`
1032
+ that also handles **ZPN** projection (which astropy does not natively fit)
1033
+ and **ZPN-SIP** (ZPN radial distortion plus SIP polynomial corrections).
1034
+
1035
+ Parameters
1036
+ ----------
1037
+ xy : tuple of arrays ``(x, y)`` or ``(2, N)`` array
1038
+ Pixel coordinates (same convention as the astropy function).
1039
+ world_coords : `~astropy.coordinates.SkyCoord`
1040
+ Reference sky positions.
1041
+ proj_point : str, optional
1042
+ Passed through to astropy for non-ZPN projections. Ignored for ZPN,
1043
+ where the projection center comes from the template WCS.
1044
+ projection : `~astropy.wcs.WCS` or other, optional
1045
+ Projection template. If this is a WCS with ``RA---ZPN / DEC--ZPN``
1046
+ CTYPEs, the ZPN fitter is used instead of astropy's.
1047
+ sip_degree : int or None, optional
1048
+ SIP polynomial degree. For TAN projections, controls SIP distortion
1049
+ order. For ZPN projections, if > 0, SIP corrections are fitted on
1050
+ top of ZPN PV parameters to capture non-radial distortions. For
1051
+ other projections it is ignored (SIP is not standard there).
1052
+ pv_deg : int, optional
1053
+ ZPN PV polynomial degree (``PV2_0 … PV2_pv_deg``). Default 5.
1054
+ verbose : bool or callable, optional
1055
+ Whether to show verbose messages during the run of the function or not.
1056
+ May also be a print-like function.
1057
+
1058
+ Returns
1059
+ -------
1060
+ wcs : `~astropy.wcs.WCS`
1061
+ Fitted WCS (same return type as the astropy function).
1062
+ """
1063
+ from astropy.wcs.utils import fit_wcs_from_points as _astropy_fit
1064
+
1065
+ # Simple wrapper around print for logging in verbose mode only
1066
+ log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
1067
+
1068
+ # Detect ZPN projection
1069
+ is_zpn = False
1070
+ if isinstance(projection, WCS):
1071
+ try:
1072
+ is_zpn = "ZPN" in projection.wcs.ctype[0]
1073
+ except Exception:
1074
+ pass
1075
+
1076
+ if is_zpn:
1077
+ # Convert xy to (N, 2) array expected by fit_zpn_wcs_from_points
1078
+ if isinstance(xy, (list, tuple)) and len(xy) == 2:
1079
+ # (x_array, y_array) form
1080
+ xy_arr = np.column_stack(
1081
+ [np.asarray(xy[0], dtype=float), np.asarray(xy[1], dtype=float)]
1082
+ )
1083
+ else:
1084
+ xy_arr = np.asarray(xy, dtype=float)
1085
+ if xy_arr.ndim == 2 and xy_arr.shape[0] == 2 and xy_arr.shape[1] != 2:
1086
+ # (2, N) -> (N, 2)
1087
+ xy_arr = xy_arr.T
1088
+
1089
+ zpn_deg = int(pv_deg)
1090
+
1091
+ # First fit ZPN PV parameters (radial distortion)
1092
+ log(f"Fitting ZPN WCS with pv_deg={zpn_deg} using {len(xy_arr)} points")
1093
+ wcs_best, _result = fit_zpn_wcs_from_points(
1094
+ xy_arr, world_coords, wcs_init=projection, pv_deg=zpn_deg, verbose=verbose
1095
+ )
1096
+
1097
+ # Then fit SIP corrections for non-radial distortions
1098
+ if sip_degree is not None and int(sip_degree) > 0:
1099
+ log(f"Fitting SIP degree {int(sip_degree)} corrections on top of ZPN")
1100
+ wcs_best = _fit_zpn_sip(
1101
+ wcs_best,
1102
+ xy_arr,
1103
+ world_coords,
1104
+ sip_degree=int(sip_degree),
1105
+ pv_deg=zpn_deg,
1106
+ verbose=verbose,
1107
+ )
1108
+
1109
+ return wcs_best
1110
+
1111
+ # ---------- Non-ZPN ----------
1112
+ # SIP only makes sense for TAN-based projections
1113
+ effective_sip = sip_degree
1114
+ if isinstance(projection, WCS):
1115
+ try:
1116
+ if "TAN" not in projection.wcs.ctype[0]:
1117
+ effective_sip = None
1118
+ except Exception:
1119
+ pass
1120
+
1121
+ if effective_sip is not None and effective_sip > 0:
1122
+ log(f"Fitting TAN-SIP WCS with sip_degree={int(effective_sip)}")
1123
+ return _fit_tan_sip_robust(
1124
+ xy,
1125
+ world_coords,
1126
+ proj_point=proj_point,
1127
+ projection=projection,
1128
+ sip_degree=int(effective_sip),
1129
+ verbose=verbose,
1130
+ )
1131
+
1132
+ if effective_sip != sip_degree:
1133
+ log("SIP is not supported for this projection, ignoring sip_degree")
1134
+
1135
+ log("Fitting WCS using astropy fit_wcs_from_points")
1136
+ return _astropy_fit(
1137
+ xy,
1138
+ world_coords,
1139
+ proj_point=proj_point,
1140
+ projection=projection,
1141
+ )