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,1643 @@
1
+ """
2
+ Quad-hash astrometry solver (Python-only) with practical upgrades:
3
+
4
+ 1) Multi-scale quad pools with canonical descriptors:
5
+ - fully canonical quad descriptors (astrometry.net-style A/B convention),
6
+ so a hash hit directly provides the point correspondence for the fit
7
+ - quads are partitioned into baseline-length *rank bins* (e.g. 6 bins)
8
+ - consistent binning between detection and reference sets via shared edges
9
+ - matching is done only within the same bin, reducing ambiguity
10
+
11
+ 2) Two-stage hypothesis scoring with accuracy enhancements:
12
+ - Stage 1: weighted scoring on a small subset with multi-probe hashing
13
+ - Stage 2: mutual nearest-neighbor matching with weighted scoring
14
+ - Iterative affine re-matching to grow the match set
15
+ - Progressive sigma-clipping in final WCS refinement
16
+
17
+ Inputs
18
+ ------
19
+ obj : astropy.table.Table with 'x','y' and either 'flux' or 'mag'
20
+ cat : astropy.table.Table with 'ra','dec','mag'
21
+ wcs_init : astropy.wcs.WCS (rough)
22
+
23
+ Outputs
24
+ -------
25
+ refined_wcs : astropy.wcs.WCS
26
+ match : astropy.table.Table
27
+ diagnostics : dict
28
+
29
+ Deps: numpy, scipy, astropy
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import dataclasses
35
+ from dataclasses import dataclass
36
+ from typing import Dict, List, Optional, Tuple
37
+
38
+ import numpy as np
39
+ from scipy.spatial import cKDTree
40
+
41
+ from astropy.coordinates import SkyCoord
42
+ import astropy.units as u
43
+ from astropy.table import Table
44
+ from astropy.wcs import WCS
45
+ from stdpipe.astrometry_wcs import fit_wcs_from_points
46
+
47
+
48
+ # -----------------------------
49
+ # Utilities
50
+ # -----------------------------
51
+
52
+
53
+ def _as_float_array(x) -> np.ndarray:
54
+ a = np.asarray(x)
55
+ return a.astype(np.float64, copy=False)
56
+
57
+
58
+ def _finite_mask(*cols) -> np.ndarray:
59
+ m = np.ones(len(cols[0]), dtype=bool)
60
+ for c in cols:
61
+ c = np.asarray(c)
62
+ m &= np.isfinite(c)
63
+ return m
64
+
65
+
66
+ def _robust_sigma(x: np.ndarray) -> float:
67
+ x = np.asarray(x, dtype=np.float64)
68
+ med = np.nanmedian(x)
69
+ mad = np.nanmedian(np.abs(x - med))
70
+ return 1.4826 * mad if mad > 0 else np.nanstd(x)
71
+
72
+
73
+ def mag_orderings_consistent(mags_a, mags_b, tol: float = 0.5) -> bool:
74
+ """Check that pairwise brightness orderings of two corresponding point
75
+ sets agree within a tolerance.
76
+
77
+ Only pairs whose magnitude difference exceeds ``tol`` in *both* sets are
78
+ required to agree in sign, so noise-flipped orderings of nearly
79
+ equal-brightness stars are not rejected. Insensitive to a constant
80
+ magnitude offset (instrumental vs catalogue zero point).
81
+ """
82
+ a = np.asarray(mags_a, dtype=np.float64)
83
+ b = np.asarray(mags_b, dtype=np.float64)
84
+ da = a[:, None] - a[None, :]
85
+ db = b[:, None] - b[None, :]
86
+ bad = (np.abs(da) > tol) & (np.abs(db) > tol) & (da * db < 0)
87
+ return not bool(bad.any())
88
+
89
+
90
+ # -----------------------------
91
+ # Similarity transform (Umeyama)
92
+ # -----------------------------
93
+
94
+
95
+ def estimate_similarity_2d(
96
+ A: np.ndarray, B: np.ndarray, allow_reflection: bool = True
97
+ ) -> Tuple[np.ndarray, np.ndarray, float]:
98
+ A = np.asarray(A, dtype=np.float64)
99
+ B = np.asarray(B, dtype=np.float64)
100
+ if A.shape != B.shape or A.shape[1] != 2:
101
+ raise ValueError("A and B must be (N,2) and same shape")
102
+
103
+ mu_A = A.mean(axis=0)
104
+ mu_B = B.mean(axis=0)
105
+ X = A - mu_A
106
+ Y = B - mu_B
107
+
108
+ var_A = np.sum(X**2) / A.shape[0]
109
+ if var_A <= 0:
110
+ raise ValueError("Degenerate A variance")
111
+
112
+ Sigma = (Y.T @ X) / A.shape[0]
113
+ U, D, Vt = np.linalg.svd(Sigma)
114
+
115
+ R = U @ Vt
116
+ if not allow_reflection and np.linalg.det(R) < 0:
117
+ U[:, -1] *= -1
118
+ R = U @ Vt
119
+ # If allow_reflection=True, accept the reflection as-is
120
+
121
+ s = np.sum(D) / var_A
122
+ t = mu_B - s * (R @ mu_A)
123
+ return R, t, float(s)
124
+
125
+
126
+ def apply_similarity(xy: np.ndarray, R: np.ndarray, t: np.ndarray, s: float) -> np.ndarray:
127
+ xy = np.asarray(xy, dtype=np.float64)
128
+ return (s * (xy @ R.T)) + t
129
+
130
+
131
+ # -----------------------------
132
+ # Affine transform
133
+ # -----------------------------
134
+
135
+
136
+ def estimate_affine_2d(A: np.ndarray, B: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
137
+ """Fit affine transform B = A @ M.T + t via least squares (6 DOF).
138
+
139
+ More flexible than similarity (4 DOF) - handles shear and non-square pixels.
140
+ Returns (M, t) where M is (2,2) linear part and t is (2,) translation.
141
+ """
142
+ A = np.asarray(A, dtype=np.float64)
143
+ B = np.asarray(B, dtype=np.float64)
144
+ n = A.shape[0]
145
+ if n < 3:
146
+ raise ValueError("Need at least 3 points for affine fit")
147
+ A_aug = np.hstack([A, np.ones((n, 1))]) # (n, 3)
148
+ cond = np.linalg.cond(A_aug)
149
+ if cond > 1e12:
150
+ raise ValueError(f"Degenerate affine fit (condition number {cond:.1e})")
151
+ X, _, _, _ = np.linalg.lstsq(A_aug, B, rcond=None)
152
+ M = X[:2].T # (2, 2)
153
+ t = X[2] # (2,)
154
+ if not (np.all(np.isfinite(M)) and np.all(np.isfinite(t))):
155
+ raise ValueError("Degenerate affine fit (non-finite values)")
156
+ return M, t
157
+
158
+
159
+ def apply_affine(xy: np.ndarray, M: np.ndarray, t: np.ndarray) -> np.ndarray:
160
+ """Apply affine transform: result = xy @ M.T + t
161
+
162
+ Raises ValueError if the result contains non-finite values.
163
+ """
164
+ xy = np.asarray(xy, dtype=np.float64)
165
+ with np.errstate(over="ignore", invalid="ignore", divide="ignore"):
166
+ result = xy @ M.T + t
167
+ if not np.all(np.isfinite(result)):
168
+ raise ValueError("Affine transform produced non-finite values")
169
+ return result
170
+
171
+
172
+ # -----------------------------
173
+ # Mutual nearest-neighbor matching
174
+ # -----------------------------
175
+
176
+
177
+ def _mutual_nearest_neighbor(
178
+ xy_a: np.ndarray,
179
+ xy_b: np.ndarray,
180
+ radius: float,
181
+ tree_b: Optional[cKDTree] = None,
182
+ ) -> Tuple[np.ndarray, np.ndarray]:
183
+ """Find mutual nearest-neighbor pairs between two point sets within radius.
184
+
185
+ Returns ``(idx_a, idx_b)`` arrays of matched indices where each pair is
186
+ the mutual closest match within the given radius.
187
+
188
+ Parameters
189
+ ----------
190
+ xy_a : (N, 2) ndarray
191
+ First point set.
192
+ xy_b : (M, 2) ndarray
193
+ Second point set.
194
+ radius : float
195
+ Maximum matching distance.
196
+ tree_b : cKDTree, optional
197
+ Pre-built cKDTree for ``xy_b`` (avoids rebuilding).
198
+
199
+ Returns
200
+ -------
201
+ idx_a : ndarray of int
202
+ Indices into ``xy_a`` for matched pairs.
203
+ idx_b : ndarray of int
204
+ Indices into ``xy_b`` for matched pairs.
205
+ """
206
+ if len(xy_a) == 0 or len(xy_b) == 0:
207
+ return np.array([], dtype=np.int32), np.array([], dtype=np.int32)
208
+
209
+ tree_a = cKDTree(xy_a)
210
+ if tree_b is None:
211
+ tree_b = cKDTree(xy_b)
212
+
213
+ # Forward: for each point in A, find nearest in B
214
+ dist_fwd, nn_fwd = tree_b.query(xy_a, k=1, distance_upper_bound=radius)
215
+ # Reverse: for each point in B, find nearest in A
216
+ dist_rev, nn_rev = tree_a.query(xy_b, k=1, distance_upper_bound=radius)
217
+
218
+ ok_fwd = np.isfinite(dist_fwd) & (dist_fwd < radius) & (nn_fwd < len(xy_b))
219
+ a_ids = np.nonzero(ok_fwd)[0]
220
+ b_ids = nn_fwd[a_ids].astype(int)
221
+
222
+ # Mutual check: B's nearest neighbor back to A must be the same point
223
+ mutual = nn_rev[b_ids] == a_ids
224
+ return a_ids[mutual], b_ids[mutual]
225
+
226
+
227
+ # -----------------------------
228
+ # Quad generation & descriptors
229
+ # -----------------------------
230
+
231
+
232
+ def make_local_quads(points: np.ndarray, k: int = 8) -> List[Tuple[int, int, int, int]]:
233
+ points = np.asarray(points, dtype=np.float64)
234
+ n = points.shape[0]
235
+ if n < 4:
236
+ return []
237
+ k = int(min(k, max(3, n - 1)))
238
+
239
+ tree = cKDTree(points)
240
+ _, idxs = tree.query(points, k=k + 1)
241
+ quads: List[Tuple[int, int, int, int]] = []
242
+ for i in range(n):
243
+ neigh = idxs[i, 1:]
244
+ for a in range(len(neigh) - 2):
245
+ for b in range(a + 1, len(neigh) - 1):
246
+ for c in range(b + 1, len(neigh)):
247
+ quads.append((i, int(neigh[a]), int(neigh[b]), int(neigh[c])))
248
+ return quads
249
+
250
+
251
+ def quad_descriptor(P: np.ndarray) -> Tuple[np.ndarray, float]:
252
+ """
253
+ Descriptor invariant to translation/rotation/scale.
254
+ Returns (desc[4], baseline_len).
255
+ """
256
+ P = np.asarray(P, dtype=np.float64)
257
+ descs, lens, _ = quad_descriptor_batch(P, np.arange(4, dtype=np.int32)[None, :])
258
+ return descs[0], float(lens[0])
259
+
260
+
261
+ def quad_descriptor_batch(
262
+ points: np.ndarray, quad_indices: np.ndarray
263
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
264
+ """
265
+ Vectorized canonical quad descriptor calculation for multiple quads.
266
+
267
+ The descriptor is a pure geometric invariant of the 4-point set: the
268
+ baseline endpoint A is chosen such that cx + dx <= 1 (astrometry.net
269
+ convention) and C, D are ordered lexicographically, so the same physical
270
+ quad yields the same descriptor and point order regardless of the order
271
+ in which the indices are listed.
272
+
273
+ Args:
274
+ points: (N, 2) array of point coordinates
275
+ quad_indices: (M, 4) array of quad indices into points
276
+
277
+ Returns:
278
+ descs: (M, 4) array of descriptors
279
+ lens: (M,) array of baseline lengths
280
+ order: (M, 4) array of point indices in canonical (A, B, C, D)
281
+ order (-1 for degenerate quads)
282
+ """
283
+ quad_indices = np.asarray(quad_indices)
284
+ M = quad_indices.shape[0]
285
+ descs = np.full((M, 4), np.nan, dtype=np.float64)
286
+ lens = np.full(M, np.nan, dtype=np.float64)
287
+ order = np.full((M, 4), -1, dtype=np.int64)
288
+
289
+ if M == 0:
290
+ return descs, lens, order
291
+
292
+ # Gather all quad points: (M, 4, 2)
293
+ P = points[quad_indices]
294
+
295
+ # Compute pairwise distances for all quads: (M, 4, 4)
296
+ diff = P[:, None, :, :] - P[:, :, None, :] # (M, 4, 4, 2)
297
+ d2 = np.sum(diff**2, axis=3) # (M, 4, 4)
298
+
299
+ # Find longest baseline for each quad
300
+ flat_idx = np.argmax(d2.reshape(M, -1), axis=1)
301
+ i_idx = flat_idx // 4
302
+ j_idx = flat_idx % 4
303
+
304
+ # Check for degenerate quads (i == j)
305
+ valid = i_idx != j_idx
306
+
307
+ # Process only valid quads
308
+ if not np.any(valid):
309
+ return descs, lens, order
310
+
311
+ valid_mask = np.where(valid)[0]
312
+ M_valid = len(valid_mask)
313
+
314
+ # Extract baseline points for valid quads
315
+ A = P[valid_mask, i_idx[valid_mask]] # (M_valid, 2)
316
+ B = P[valid_mask, j_idx[valid_mask]] # (M_valid, 2)
317
+
318
+ # Compute baseline vectors and lengths
319
+ base = B - A # (M_valid, 2)
320
+ L = np.hypot(base[:, 0], base[:, 1]) # (M_valid,)
321
+
322
+ # Filter out zero-length baselines
323
+ valid_L = L > 0
324
+ if not np.any(valid_L):
325
+ return descs, lens, order
326
+
327
+ final_mask = valid_mask[valid_L]
328
+ M_final = len(final_mask)
329
+
330
+ # Update for final valid quads
331
+ A = A[valid_L]
332
+ B = B[valid_L]
333
+ base = base[valid_L]
334
+ L = L[valid_L]
335
+ i_idx_final = i_idx[final_mask]
336
+ j_idx_final = j_idx[final_mask]
337
+ P_final = P[final_mask]
338
+
339
+ # Compute orthonormal basis
340
+ e1 = base / L[:, None] # (M_final, 2)
341
+ e2 = np.stack([-e1[:, 1], e1[:, 0]], axis=1) # (M_final, 2)
342
+
343
+ # Find the other two points (C, D) for each quad (vectorized)
344
+ # Create a mask for each quad indicating which indices are NOT i or j
345
+ all_indices = np.arange(4)
346
+ mask = np.ones((M_final, 4), dtype=bool)
347
+ mask[np.arange(M_final), i_idx_final] = False
348
+ mask[np.arange(M_final), j_idx_final] = False
349
+
350
+ # Find the two other indices for each quad
351
+ other_indices = np.where(mask)
352
+ # Reshape to (M_final, 2) - two "other" indices per quad
353
+ other_indices_reshaped = other_indices[1].reshape(M_final, 2)
354
+ C_indices = other_indices_reshaped[:, 0]
355
+ D_indices = other_indices_reshaped[:, 1]
356
+
357
+ C = P_final[np.arange(M_final), C_indices] # (M_final, 2)
358
+ D = P_final[np.arange(M_final), D_indices] # (M_final, 2)
359
+
360
+ # Project C and D onto basis and normalize by L
361
+ C_vec = C - A
362
+ D_vec = D - A
363
+
364
+ c_coords = (
365
+ np.stack([np.sum(C_vec * e1, axis=1), np.sum(C_vec * e2, axis=1)], axis=1) / L[:, None]
366
+ ) # (M_final, 2)
367
+
368
+ d_coords = (
369
+ np.stack([np.sum(D_vec * e1, axis=1), np.sum(D_vec * e2, axis=1)], axis=1) / L[:, None]
370
+ ) # (M_final, 2)
371
+
372
+ # Canonical A/B choice (astrometry.net convention): require cx + dx <= 1.
373
+ # Swapping A and B maps internal coordinates (x, y) -> (1 - x, -y).
374
+ swap_ab = (c_coords[:, 0] + d_coords[:, 0]) > 1.0
375
+ c_coords[swap_ab] = np.stack(
376
+ [1.0 - c_coords[swap_ab, 0], -c_coords[swap_ab, 1]], axis=1
377
+ )
378
+ d_coords[swap_ab] = np.stack(
379
+ [1.0 - d_coords[swap_ab, 0], -d_coords[swap_ab, 1]], axis=1
380
+ )
381
+ A_pos = np.where(swap_ab, j_idx_final, i_idx_final)
382
+ B_pos = np.where(swap_ab, i_idx_final, j_idx_final)
383
+
384
+ # Canonical C/D order: lexicographic by (x, y)
385
+ swap_cd = (c_coords[:, 0] > d_coords[:, 0]) | (
386
+ (c_coords[:, 0] == d_coords[:, 0]) & (c_coords[:, 1] > d_coords[:, 1])
387
+ )
388
+ c_fin = np.where(swap_cd[:, None], d_coords, c_coords)
389
+ d_fin = np.where(swap_cd[:, None], c_coords, d_coords)
390
+ C_pos = np.where(swap_cd, D_indices, C_indices)
391
+ D_pos = np.where(swap_cd, C_indices, D_indices)
392
+
393
+ descs[final_mask] = np.concatenate([c_fin, d_fin], axis=1)
394
+ lens[final_mask] = L
395
+ pos = np.stack([A_pos, B_pos, C_pos, D_pos], axis=1)
396
+ order[final_mask] = np.take_along_axis(quad_indices[final_mask], pos, axis=1)
397
+
398
+ return descs, lens, order
399
+
400
+
401
+ def quantize_desc(desc: np.ndarray, eps: float) -> Tuple[int, int, int, int]:
402
+ q = np.floor(desc / eps + 0.5).astype(np.int64)
403
+ return int(q[0]), int(q[1]), int(q[2]), int(q[3])
404
+
405
+
406
+ def reflect_desc(desc: np.ndarray) -> np.ndarray:
407
+ """Reflect quad descriptor across its baseline (flip y) with lexicographic re-ordering."""
408
+ return _reflect_desc_order(desc)[0]
409
+
410
+
411
+ def _reflect_desc_order(desc: np.ndarray) -> Tuple[np.ndarray, bool]:
412
+ """Reflect descriptor across baseline; also report whether C/D swapped."""
413
+ x1, y1, x2, y2 = (float(desc[0]), -float(desc[1]), float(desc[2]), -float(desc[3]))
414
+ if (x1 > x2) or (x1 == x2 and y1 > y2):
415
+ return np.array([x2, y2, x1, y1], dtype=np.float64), True
416
+ return np.array([x1, y1, x2, y2], dtype=np.float64), False
417
+
418
+
419
+ def _ab_swap_desc_order(desc: np.ndarray) -> Tuple[np.ndarray, bool]:
420
+ """Descriptor after swapping baseline endpoints A/B, i.e. (x, y) -> (1-x, -y),
421
+ with lexicographic re-ordering; also report whether C/D swapped."""
422
+ x1, y1, x2, y2 = (
423
+ 1.0 - float(desc[0]),
424
+ -float(desc[1]),
425
+ 1.0 - float(desc[2]),
426
+ -float(desc[3]),
427
+ )
428
+ if (x1 > x2) or (x1 == x2 and y1 > y2):
429
+ return np.array([x2, y2, x1, y1], dtype=np.float64), True
430
+ return np.array([x1, y1, x2, y2], dtype=np.float64), False
431
+
432
+
433
+ def _swap_cd(idxs: Tuple[int, int, int, int]) -> Tuple[int, int, int, int]:
434
+ return (idxs[0], idxs[1], idxs[3], idxs[2])
435
+
436
+
437
+ def multiprobe_desc_keys(
438
+ desc: np.ndarray, eps: float, threshold: float = 0.3
439
+ ) -> List[Tuple[int, int, int, int]]:
440
+ """Multi-probe hashing: return the primary quantized key plus keys for nearby
441
+ bins where the descriptor is close to a bin boundary.
442
+
443
+ Instead of searching all (2r+1)^4 = 81 neighboring bins, this probes only
444
+ the bins that the descriptor might fall into due to quantization noise.
445
+ Typically returns 1-8 keys instead of 81.
446
+
447
+ Args:
448
+ desc: 4D descriptor array
449
+ eps: quantization step size
450
+ threshold: fraction of eps within which a boundary is considered "near"
451
+ """
452
+ q = desc / eps + 0.5
453
+ base = np.floor(q).astype(np.int64)
454
+ frac = q - base # fractional part in [0, 1)
455
+
456
+ primary = tuple(int(x) for x in base)
457
+ keys = [primary]
458
+
459
+ # Find dimensions near bin boundaries
460
+ near_dims = []
461
+ offsets_per_dim = {}
462
+
463
+ for d in range(4):
464
+ if frac[d] < threshold:
465
+ near_dims.append(d)
466
+ offsets_per_dim[d] = -1
467
+ elif frac[d] > (1.0 - threshold):
468
+ near_dims.append(d)
469
+ offsets_per_dim[d] = 1
470
+
471
+ # Single-dimension probes
472
+ for d in near_dims:
473
+ alt = list(base)
474
+ alt[d] += offsets_per_dim[d]
475
+ keys.append(tuple(int(x) for x in alt))
476
+
477
+ # Two-dimension combination probes
478
+ for i in range(len(near_dims)):
479
+ for j in range(i + 1, len(near_dims)):
480
+ d1, d2 = near_dims[i], near_dims[j]
481
+ alt = list(base)
482
+ alt[d1] += offsets_per_dim[d1]
483
+ alt[d2] += offsets_per_dim[d2]
484
+ keys.append(tuple(int(x) for x in alt))
485
+
486
+ return keys
487
+
488
+
489
+ def compute_bin_edges(lengths: np.ndarray, n_bins: int) -> Optional[np.ndarray]:
490
+ """Compute quantile-based bin edges from a set of baseline lengths.
491
+
492
+ Returns array of (n_bins+1) edge values, or None if binning not possible.
493
+ """
494
+ lengths = np.asarray(lengths, dtype=np.float64)
495
+ if len(lengths) == 0 or n_bins <= 1:
496
+ return None
497
+ qs = np.quantile(lengths, q=np.linspace(0, 1, n_bins + 1))
498
+ for i in range(1, len(qs)):
499
+ if qs[i] <= qs[i - 1]:
500
+ qs[i] = np.nextafter(qs[i - 1], np.inf)
501
+ return qs
502
+
503
+
504
+ def baseline_rank_bins(
505
+ lengths: np.ndarray, n_bins: int, edges: Optional[np.ndarray] = None
506
+ ) -> np.ndarray:
507
+ """
508
+ Assign each length to a *rank* bin based on quantiles.
509
+ If edges are provided, use those instead of computing from the data.
510
+ This allows consistent binning between detection and reference sets.
511
+ """
512
+ lengths = np.asarray(lengths, dtype=np.float64)
513
+ n = len(lengths)
514
+ if n == 0:
515
+ return np.zeros(0, dtype=np.int16)
516
+ if n_bins <= 1:
517
+ return np.zeros(n, dtype=np.int16)
518
+ if edges is None:
519
+ edges = compute_bin_edges(lengths, n_bins)
520
+ if edges is None:
521
+ return np.zeros(n, dtype=np.int16)
522
+ b = np.searchsorted(edges[1:-1], lengths, side="right")
523
+ return np.clip(b, 0, n_bins - 1).astype(np.int16)
524
+
525
+
526
+ @dataclass
527
+ class QuadEntry:
528
+ idxs: Tuple[int, int, int, int]
529
+ mags: Optional[Tuple[float, float, float, float]] = None
530
+
531
+
532
+ def build_quad_hash_multiscale(
533
+ points: np.ndarray,
534
+ mags: Optional[np.ndarray],
535
+ k: int,
536
+ eps: float,
537
+ n_scale_bins: int,
538
+ allow_reflection: bool = True,
539
+ bin_edges: Optional[np.ndarray] = None,
540
+ quad_array: Optional[np.ndarray] = None,
541
+ descs: Optional[np.ndarray] = None,
542
+ lens: Optional[np.ndarray] = None,
543
+ order: Optional[np.ndarray] = None,
544
+ ) -> Dict[Tuple[int, Tuple[int, int, int, int]], List[QuadEntry]]:
545
+ """
546
+ Hash key: (scale_bin_id, quantized_descriptor)
547
+
548
+ Entries store point indices in the canonical (A, B, C, D) descriptor
549
+ order, so a hash hit directly provides the point correspondence for the
550
+ transform fit.
551
+
552
+ If bin_edges is provided, use those for baseline binning instead of
553
+ computing quantiles from the data. This enables consistent binning
554
+ between detection and reference sets.
555
+ """
556
+ if quad_array is None or descs is None or lens is None or order is None:
557
+ quads = make_local_quads(points, k=k)
558
+ if not quads:
559
+ return {}
560
+
561
+ quad_array = np.array(quads, dtype=np.int32)
562
+ descs, lens, order = quad_descriptor_batch(points, quad_array)
563
+ else:
564
+ quad_array = np.asarray(quad_array, dtype=np.int32)
565
+ descs = np.asarray(descs, dtype=np.float64)
566
+ lens = np.asarray(lens, dtype=np.float64)
567
+ order = np.asarray(order, dtype=np.int64)
568
+
569
+ if quad_array.ndim != 2 or quad_array.shape[1] != 4:
570
+ raise ValueError("quad_array must be (N, 4)")
571
+ if descs.shape != (len(quad_array), 4):
572
+ raise ValueError("descs must have shape (N, 4)")
573
+ if lens.shape != (len(quad_array),):
574
+ raise ValueError("lens must have shape (N,)")
575
+ if order.shape != (len(quad_array), 4):
576
+ raise ValueError("order must have shape (N, 4)")
577
+
578
+ ok = (
579
+ np.isfinite(descs).all(axis=1)
580
+ & np.isfinite(lens)
581
+ & (lens > 0)
582
+ & np.all(order >= 0, axis=1)
583
+ )
584
+ order_ok = order[ok]
585
+ descs_ok = descs[ok]
586
+ lens_ok = lens[ok]
587
+
588
+ if len(order_ok) == 0:
589
+ return {}
590
+
591
+ bins = baseline_rank_bins(lens_ok, n_bins=n_scale_bins, edges=bin_edges)
592
+
593
+ # Near the A/B canonicalization boundary (cx + dx == 1) noise can flip
594
+ # the canonical endpoint choice between detection and reference sets, so
595
+ # quads close to it are also inserted with the endpoints swapped.
596
+ ab_boundary = 2.0 * eps
597
+
598
+ H: Dict[Tuple[int, Tuple[int, int, int, int]], List[QuadEntry]] = {}
599
+ for i in range(len(order_ok)):
600
+ q = tuple(int(v) for v in order_ok[i])
601
+ desc = descs_ok[i]
602
+ b = int(bins[i])
603
+
604
+ variants = [(desc, q)]
605
+ if abs(desc[0] + desc[2] - 1.0) <= ab_boundary:
606
+ desc_ab, cd_swap = _ab_swap_desc_order(desc)
607
+ q_ab = (q[1], q[0], q[2], q[3])
608
+ variants.append((desc_ab, _swap_cd(q_ab) if cd_swap else q_ab))
609
+ if allow_reflection:
610
+ # Reflected counterparts of all variants to handle parity flips
611
+ for dv, qv in list(variants):
612
+ desc_ref, cd_swap = _reflect_desc_order(dv)
613
+ variants.append((desc_ref, _swap_cd(qv) if cd_swap else qv))
614
+
615
+ seen_keys = set()
616
+ for dv, qv in variants:
617
+ key = (b, quantize_desc(dv, eps))
618
+ if key in seen_keys:
619
+ continue
620
+ seen_keys.add(key)
621
+ me = None
622
+ if mags is not None:
623
+ me = tuple(float(mags[idx]) for idx in qv)
624
+ H.setdefault(key, []).append(QuadEntry(idxs=qv, mags=me))
625
+
626
+ return H
627
+
628
+
629
+ class _PatternMatchFailed(RuntimeError):
630
+ """Internal sentinel for pattern matching failure (used by adaptive retry)."""
631
+
632
+ pass
633
+
634
+
635
+ def _auto_match_resolution(det_xy: np.ndarray, ref_uv: np.ndarray) -> Optional[float]:
636
+ """Compute matching resolution from source density (SCAMP-inspired).
637
+
638
+ Uses the mean inter-source spacing as confusion limit:
639
+ matchresol = sqrt(field_area / n_sources)
640
+
641
+ Returns the confusion radius in pixels, or None if it can't be computed.
642
+ """
643
+ # Use the smaller of the two sets (like SCAMP's cross-section approach)
644
+ n = min(len(det_xy), len(ref_uv))
645
+ if n < 4:
646
+ return None
647
+
648
+ # Estimate field area from convex hull or bounding box of detections
649
+ x_range = det_xy[:, 0].max() - det_xy[:, 0].min()
650
+ y_range = det_xy[:, 1].max() - det_xy[:, 1].min()
651
+ field_area = x_range * y_range
652
+
653
+ if field_area <= 0:
654
+ return None
655
+
656
+ # Mean area per source → confusion radius
657
+ mean_area = field_area / n
658
+ confusion_radius = np.sqrt(mean_area)
659
+
660
+ return confusion_radius
661
+
662
+
663
+ # -----------------------------
664
+ # Solver
665
+ # -----------------------------
666
+
667
+
668
+ @dataclass
669
+ class AstrometryConfig:
670
+ # Selection sizes
671
+ n_det: int = 150
672
+ n_ref: int = 600
673
+
674
+ # Quads
675
+ neighbor_k: int = 10
676
+ eps_desc: float = 0.02
677
+ n_scale_bins: int = 6
678
+
679
+ allow_reflection: bool = True
680
+ use_mag_signature: bool = True
681
+ mag_tolerance: float = 0.5 # ordering disagreements below this (mag) are ignored
682
+
683
+ # Hypothesis sanity checks: both point sets live in the same pixel space
684
+ # (catalogue projected through the initial WCS), so valid transforms have
685
+ # scale close to 1 and positive parity
686
+ use_wcs_prior: bool = True
687
+ scale_tolerance: float = 0.30 # fractional, e.g. 0.30 = +/-30%
688
+ enforce_parity: bool = True
689
+
690
+ # Two-stage scoring
691
+ stage1_n_det: int = 60 # cheap scoring uses only brightest subset of selected detections
692
+ stage1_radius_arcsec: float = 20.0 # loose
693
+ stage2_radius_arcsec: float = 8.0 # tighter
694
+ top_k_hypotheses: int = 80 # keep best hypotheses from stage 1
695
+ max_quads_tested: int = 8000 # max detection quads to try
696
+ max_candidates_per_bucket: int = 60 # cap ref candidates pulled per hash bucket
697
+
698
+ # Final fit
699
+ sip_degree: int = 3
700
+ refine_clip_sigma: float = 4.0
701
+ refine_clip_sigma_start: float = 5.0 # progressive clipping: start value
702
+ refine_min_match_fraction: float = 0.5 # keep at least this fraction of initial matches
703
+ refine_max_iter: int = 5
704
+ refine_rematch_iters: int = 2 # iterative affine re-matching rounds
705
+ # Optional expanded refinement pool (use many more objects for final fit)
706
+ refine_use_all: bool = False
707
+ refine_n_det: Optional[int] = None
708
+ refine_n_ref: Optional[int] = None
709
+ refine_match_radius_arcsec: Optional[float] = None
710
+
711
+ # Auto matching resolution: compute stage radii from source density.
712
+ # Disabled by default: the density-based inflation can produce very large
713
+ # radii for sparse wide-field images; prefer pixel-scale-aware radii.
714
+ auto_match_resolution: bool = False
715
+
716
+ # Adaptive source count: retry with more sources if matching fails
717
+ adaptive_n_retry: int = 2 # number of retry doublings (0 = disabled)
718
+ adaptive_min_inliers: int = 12 # minimum inliers to accept without retry
719
+
720
+
721
+ class QuadHashAstrometry:
722
+ def __init__(self, config: Optional[AstrometryConfig] = None):
723
+ self.cfg = config if config is not None else AstrometryConfig()
724
+
725
+ def _pattern_match(
726
+ self,
727
+ det_xy: np.ndarray,
728
+ det_mag: np.ndarray,
729
+ ref_uv: np.ndarray,
730
+ ref_mag: np.ndarray,
731
+ pixel_scale_arcsec: float,
732
+ cfg: Optional['AstrometryConfig'] = None,
733
+ ) -> Tuple[list, list, dict]:
734
+ """Run quad-hash pattern matching (stages 1 & 2) + affine re-matching.
735
+
736
+ Returns (pairs, top_hyp, best) where pairs is list of (det_idx, ref_idx).
737
+ Raises _PatternMatchFailed if matching fails.
738
+ """
739
+ if cfg is None:
740
+ cfg = self.cfg
741
+ # Hypothesis sanity checks: the catalogue is projected into pixel space
742
+ # with wcs_init, so the det->ref transform must have scale close to 1
743
+ # and preserve parity, regardless of the sky orientation of the WCS.
744
+ expected_scale = None
745
+ expected_parity = None # sign of determinant
746
+ if cfg.use_wcs_prior:
747
+ expected_scale = 1.0
748
+ expected_parity = 1.0
749
+
750
+ # --- Consistent baseline binning ---
751
+ ref_qa = None
752
+ ref_descs_raw = None
753
+ ref_lens_raw = None
754
+ ref_order_raw = None
755
+ ref_bin_edges = None
756
+ ref_quads_raw = make_local_quads(ref_uv, k=cfg.neighbor_k)
757
+ if ref_quads_raw:
758
+ ref_qa = np.array(ref_quads_raw, dtype=np.int32)
759
+ ref_descs_raw, ref_lens_raw, ref_order_raw = quad_descriptor_batch(ref_uv, ref_qa)
760
+ ok_ref = np.isfinite(ref_lens_raw) & (ref_lens_raw > 0)
761
+ if np.any(ok_ref):
762
+ ref_bin_edges = compute_bin_edges(ref_lens_raw[ok_ref], cfg.n_scale_bins)
763
+
764
+ # Reference multiscale hash
765
+ ref_hash = build_quad_hash_multiscale(
766
+ ref_uv,
767
+ ref_mag,
768
+ k=cfg.neighbor_k,
769
+ eps=cfg.eps_desc,
770
+ n_scale_bins=cfg.n_scale_bins,
771
+ allow_reflection=cfg.allow_reflection,
772
+ bin_edges=ref_bin_edges,
773
+ quad_array=ref_qa,
774
+ descs=ref_descs_raw,
775
+ lens=ref_lens_raw,
776
+ order=ref_order_raw,
777
+ )
778
+
779
+ # Detection quads
780
+ det_quads = make_local_quads(det_xy, k=cfg.neighbor_k)
781
+ if not det_quads:
782
+ raise _PatternMatchFailed("Not enough detections for quad matching.")
783
+
784
+ det_quad_array = np.array(det_quads, dtype=np.int32)
785
+ det_descs, det_lens, det_order = quad_descriptor_batch(det_xy, det_quad_array)
786
+
787
+ okq = (
788
+ np.isfinite(det_descs).all(axis=1)
789
+ & np.isfinite(det_lens)
790
+ & (det_lens > 0)
791
+ & np.all(det_order >= 0, axis=1)
792
+ )
793
+ det_order = det_order[okq]
794
+ det_descs = det_descs[okq]
795
+ det_lens = det_lens[okq]
796
+
797
+ if ref_bin_edges is not None:
798
+ det_bins = baseline_rank_bins(det_lens, n_bins=cfg.n_scale_bins, edges=ref_bin_edges)
799
+ else:
800
+ det_bins = baseline_rank_bins(det_lens, n_bins=cfg.n_scale_bins)
801
+
802
+ ref_tree = cKDTree(ref_uv)
803
+
804
+ # Stage radii in pixels
805
+ r1 = cfg.stage1_radius_arcsec / pixel_scale_arcsec
806
+ r2 = cfg.stage2_radius_arcsec / pixel_scale_arcsec
807
+
808
+ # Stage1 detection subset
809
+ order_det = np.argsort(det_mag)
810
+ det_stage1_ids = order_det[: min(cfg.stage1_n_det, len(order_det))]
811
+ det_xy_stage1 = det_xy[det_stage1_ids]
812
+
813
+ top_hyp: List[Tuple[float, np.ndarray, np.ndarray, float]] = []
814
+
815
+ rng = np.random.default_rng(12345)
816
+
817
+ q_order = np.arange(len(det_order))
818
+ rng.shuffle(q_order)
819
+ q_order = q_order[: min(cfg.max_quads_tested, len(q_order))]
820
+
821
+ def _try_insert(score: float, R: np.ndarray, t: np.ndarray, s: float):
822
+ nonlocal top_hyp
823
+ if len(top_hyp) < cfg.top_k_hypotheses:
824
+ top_hyp.append((score, R, t, s))
825
+ return
826
+ worst_i = int(np.argmin([h[0] for h in top_hyp]))
827
+ if score > top_hyp[worst_i][0]:
828
+ top_hyp[worst_i] = (score, R, t, s)
829
+
830
+ # --- Stage 1 ---
831
+ for qi in q_order:
832
+ # Canonical (A, B, C, D) point order: a hash hit directly gives
833
+ # the det<->ref point correspondence for the similarity fit
834
+ q = det_order[qi]
835
+ bin_id = int(det_bins[qi])
836
+
837
+ candidates: List[QuadEntry] = []
838
+ for kdesc2 in multiprobe_desc_keys(det_descs[qi], cfg.eps_desc):
839
+ kk = (bin_id, kdesc2)
840
+ if kk in ref_hash:
841
+ candidates.extend(ref_hash[kk])
842
+
843
+ if not candidates:
844
+ continue
845
+
846
+ det_quad_mags = det_mag[q] if cfg.use_mag_signature else None
847
+ P = det_xy[q]
848
+
849
+ rng.shuffle(candidates)
850
+ for ce in candidates[: cfg.max_candidates_per_bucket]:
851
+ if det_quad_mags is not None and ce.mags is not None:
852
+ if not mag_orderings_consistent(
853
+ det_quad_mags, ce.mags, cfg.mag_tolerance
854
+ ):
855
+ continue
856
+
857
+ Q = ref_uv[list(ce.idxs)]
858
+
859
+ try:
860
+ R, t, s = estimate_similarity_2d(P, Q, allow_reflection=cfg.allow_reflection)
861
+ except Exception:
862
+ continue
863
+
864
+ if expected_scale is not None and cfg.scale_tolerance is not None:
865
+ tol = float(cfg.scale_tolerance)
866
+ if tol >= 0:
867
+ if not (expected_scale * (1 - tol) <= s <= expected_scale * (1 + tol)):
868
+ continue
869
+ if expected_parity is not None and cfg.enforce_parity and cfg.allow_reflection:
870
+ detR = float(np.linalg.det(R))
871
+ if not np.isfinite(detR) or np.sign(detR) != expected_parity:
872
+ continue
873
+
874
+ det_uv1 = apply_similarity(det_xy_stage1, R, t, s)
875
+ dist, nn = ref_tree.query(det_uv1, k=1, distance_upper_bound=r1)
876
+ ok = np.isfinite(dist) & (dist < r1) & (nn < len(ref_uv))
877
+ if not np.any(ok):
878
+ continue
879
+
880
+ weights = 1.0 - (dist[ok] / r1) ** 2
881
+ score = float(np.sum(weights))
882
+
883
+ _try_insert(score, R, t, s)
884
+
885
+ if not top_hyp:
886
+ raise _PatternMatchFailed(
887
+ "Pattern matching failed at stage 1. "
888
+ "Try increasing n_det/n_ref, eps_desc, or stage1_radius_arcsec."
889
+ )
890
+
891
+ # --- Stage 2 ---
892
+ top_hyp.sort(key=lambda x: x[0], reverse=True)
893
+
894
+ best = {"score": -1.0, "inliers": -1, "R": None, "t": None, "s": None, "pairs": None}
895
+
896
+ for score1, R, t, s in top_hyp:
897
+ # Scale/parity filters already applied in stage 1
898
+ det_uv = apply_similarity(det_xy, R, t, s)
899
+
900
+ det_ids, ref_ids = _mutual_nearest_neighbor(det_uv, ref_uv, r2, tree_b=ref_tree)
901
+
902
+ if len(det_ids) == 0:
903
+ continue
904
+
905
+ dists = np.hypot(
906
+ det_uv[det_ids, 0] - ref_uv[ref_ids, 0], det_uv[det_ids, 1] - ref_uv[ref_ids, 1]
907
+ )
908
+ weights = 1.0 - (dists / r2) ** 2
909
+ score = float(np.sum(weights))
910
+
911
+ pairs = list(zip(det_ids.tolist(), ref_ids.tolist()))
912
+
913
+ if score > best["score"]:
914
+ best = {
915
+ "score": score,
916
+ "inliers": len(pairs),
917
+ "R": R,
918
+ "t": t,
919
+ "s": s,
920
+ "pairs": pairs,
921
+ }
922
+
923
+ if best["inliers"] < 8:
924
+ raise _PatternMatchFailed(
925
+ f"Pattern matching failed at stage 2 (best inliers={best['inliers']}). "
926
+ f"Try loosening stage2_radius_arcsec or increasing top_k_hypotheses."
927
+ )
928
+
929
+ # --- Iterative affine re-matching ---
930
+ pairs = best["pairs"]
931
+ r_rematch = r2
932
+
933
+ for rematch_it in range(cfg.refine_rematch_iters):
934
+ if len(pairs) < 6:
935
+ break
936
+
937
+ pairs_arr = np.array(pairs, dtype=np.int32)
938
+ A_pts = det_xy[pairs_arr[:, 0]]
939
+ B_pts = ref_uv[pairs_arr[:, 1]]
940
+
941
+ try:
942
+ M_affine, t_affine = estimate_affine_2d(A_pts, B_pts)
943
+ det_uv_affine = apply_affine(det_xy, M_affine, t_affine)
944
+ except Exception:
945
+ break
946
+
947
+ r_iter = r_rematch * (0.8 if rematch_it > 0 else 1.0)
948
+
949
+ new_det_ids, new_ref_ids = _mutual_nearest_neighbor(
950
+ det_uv_affine, ref_uv, r_iter, tree_b=ref_tree
951
+ )
952
+
953
+ if len(new_det_ids) < max(8, int(len(pairs) * 0.6)):
954
+ break
955
+
956
+ pairs = list(zip(new_det_ids.tolist(), new_ref_ids.tolist()))
957
+
958
+ return pairs, top_hyp, best
959
+
960
+ def refine(
961
+ self,
962
+ obj: Table,
963
+ cat: Table,
964
+ wcs_init: WCS,
965
+ fit_projection: Optional[WCS] = None,
966
+ pv_deg: int = 5,
967
+ ) -> Tuple[WCS, Table, dict]:
968
+ if not all(k in obj.colnames for k in ("x", "y")):
969
+ raise ValueError("obj must contain 'x' and 'y'")
970
+ if not all(k in cat.colnames for k in ("ra", "dec", "mag")):
971
+ raise ValueError("cat must contain 'ra','dec','mag'")
972
+
973
+ x = _as_float_array(obj["x"])
974
+ y = _as_float_array(obj["y"])
975
+ if "mag" in obj.colnames:
976
+ m_obj = _as_float_array(obj["mag"])
977
+ elif "flux" in obj.colnames:
978
+ f = _as_float_array(obj["flux"])
979
+ f = np.where(f > 0, f, np.nan)
980
+ m_obj = -2.5 * np.log10(f)
981
+ else:
982
+ raise ValueError("obj must contain 'flux' or 'mag'")
983
+
984
+ ra = _as_float_array(cat["ra"])
985
+ dec = _as_float_array(cat["dec"])
986
+ m_cat = _as_float_array(cat["mag"])
987
+
988
+ m0 = _finite_mask(x, y, m_obj)
989
+ x, y, m_obj = x[m0], y[m0], m_obj[m0]
990
+
991
+ m1 = _finite_mask(ra, dec, m_cat)
992
+ ra, dec, m_cat = ra[m1], dec[m1], m_cat[m1]
993
+
994
+ # Rough sky center (used for residuals and SIP check)
995
+ try:
996
+ ra0, dec0 = float(wcs_init.wcs.crval[0]), float(wcs_init.wcs.crval[1])
997
+ if not (np.isfinite(ra0) and np.isfinite(dec0)):
998
+ raise ValueError
999
+ except Exception:
1000
+ ra0, dec0 = float(np.nanmedian(ra)), float(np.nanmedian(dec))
1001
+
1002
+ # Pixel scale for radius conversion (arcsec/pixel)
1003
+ try:
1004
+ pscales = wcs_init.proj_plane_pixel_scales()
1005
+ # proj_plane_pixel_scales() may return Quantity with units
1006
+ if hasattr(pscales[0], 'to_value'):
1007
+ pixel_scale_deg = float(np.mean([s.to_value(u.deg) for s in pscales]))
1008
+ else:
1009
+ pixel_scale_deg = float(np.mean(pscales))
1010
+ except Exception:
1011
+ pixel_scale_deg = None
1012
+ if pixel_scale_deg is None or pixel_scale_deg <= 0 or not np.isfinite(pixel_scale_deg):
1013
+ raise RuntimeError("Could not determine pixel scale from WCS.")
1014
+ pixel_scale_arcsec = pixel_scale_deg * 3600.0
1015
+
1016
+ # --- Adaptive source count retry loop (SCAMP-inspired) ---
1017
+ # Try pattern matching with increasing source counts. On each retry,
1018
+ # double n_det/n_ref to bring in more sources for matching.
1019
+
1020
+ # Pre-sort by brightness (avoids re-sorting each retry)
1021
+ obj_order = np.argsort(m_obj) # lower mag = brighter
1022
+ cat_order = np.argsort(m_cat)
1023
+
1024
+ # Project full catalog to pixel space once (slice per retry)
1025
+ all_ref_xy = np.column_stack(wcs_init.all_world2pix(ra, dec, 0))
1026
+ all_ref_finite = np.all(np.isfinite(all_ref_xy), axis=1)
1027
+
1028
+ # Local config copy so auto_match_resolution doesn't mutate self.cfg
1029
+ cfg = dataclasses.replace(self.cfg)
1030
+
1031
+ n_retries = max(0, cfg.adaptive_n_retry)
1032
+ last_error = None
1033
+ # Snapshot of the best attempt so far: pairs index into the det/ref
1034
+ # subsets of the *same* attempt, so they must be kept together
1035
+ matched = None
1036
+
1037
+ for _attempt in range(n_retries + 1):
1038
+ scale_factor = 2**_attempt
1039
+ cur_n_det = min(cfg.n_det * scale_factor, len(x))
1040
+ cur_n_ref = min(cfg.n_ref * scale_factor, len(ra))
1041
+
1042
+ # Select bright subsets (pre-sorted, just slice)
1043
+ obj_sub_idx = obj_order[:cur_n_det]
1044
+ cat_sub_idx = cat_order[:cur_n_ref]
1045
+
1046
+ det_xy = np.column_stack([x[obj_sub_idx], y[obj_sub_idx]])
1047
+ det_mag = m_obj[obj_sub_idx]
1048
+
1049
+ # Slice pre-projected catalog and filter non-finite
1050
+ ref_uv = all_ref_xy[cat_sub_idx]
1051
+ ref_mag = m_cat[cat_sub_idx]
1052
+ ref_ok = all_ref_finite[cat_sub_idx]
1053
+ if not np.all(ref_ok):
1054
+ ref_uv = ref_uv[ref_ok]
1055
+ ref_mag = ref_mag[ref_ok]
1056
+ cat_sub_idx = cat_sub_idx[ref_ok]
1057
+
1058
+ if len(det_xy) < 8 or len(ref_uv) < 8:
1059
+ last_error = _PatternMatchFailed("Not enough points after selection.")
1060
+ continue
1061
+
1062
+ # Auto matching resolution: widen stage radii from source density
1063
+ if cfg.auto_match_resolution:
1064
+ auto_r_pix = _auto_match_resolution(det_xy, ref_uv)
1065
+ if auto_r_pix is not None:
1066
+ auto_r_arcsec = auto_r_pix * pixel_scale_arcsec
1067
+ cfg = dataclasses.replace(
1068
+ cfg,
1069
+ stage1_radius_arcsec=max(cfg.stage1_radius_arcsec, auto_r_arcsec * 2.5),
1070
+ stage2_radius_arcsec=max(cfg.stage2_radius_arcsec, auto_r_arcsec),
1071
+ )
1072
+
1073
+ try:
1074
+ pairs, top_hyp, best = self._pattern_match(
1075
+ det_xy,
1076
+ det_mag,
1077
+ ref_uv,
1078
+ ref_mag,
1079
+ pixel_scale_arcsec,
1080
+ cfg=cfg,
1081
+ )
1082
+ except _PatternMatchFailed as e:
1083
+ last_error = e
1084
+ if cur_n_det < len(x) or cur_n_ref < len(ra):
1085
+ continue
1086
+ else:
1087
+ break
1088
+
1089
+ matched = {
1090
+ "pairs": pairs,
1091
+ "top_hyp": top_hyp,
1092
+ "best": best,
1093
+ "det_xy": det_xy,
1094
+ "ref_uv": ref_uv,
1095
+ "obj_sub_idx": obj_sub_idx,
1096
+ "cat_sub_idx": cat_sub_idx,
1097
+ }
1098
+
1099
+ # Check if we have enough inliers
1100
+ if len(pairs) >= cfg.adaptive_min_inliers:
1101
+ break
1102
+ # Not enough inliers — retry with more sources if possible
1103
+ last_error = _PatternMatchFailed(
1104
+ f"Only {len(pairs)} inliers (need {cfg.adaptive_min_inliers})"
1105
+ )
1106
+ if cur_n_det >= len(x) and cur_n_ref >= len(ra):
1107
+ break # can't add more sources
1108
+
1109
+ if matched is None or len(matched["pairs"]) < 8:
1110
+ raise RuntimeError(str(last_error) if last_error else "Pattern matching failed.")
1111
+
1112
+ # Restore the state of the attempt that produced the pairs (a later
1113
+ # failed attempt may have overwritten the loop variables)
1114
+ pairs = matched["pairs"]
1115
+ top_hyp = matched["top_hyp"]
1116
+ best = matched["best"]
1117
+ det_xy = matched["det_xy"]
1118
+ ref_uv = matched["ref_uv"]
1119
+ obj_sub_idx = matched["obj_sub_idx"]
1120
+ cat_sub_idx = matched["cat_sub_idx"]
1121
+
1122
+ # Build match list for final fit
1123
+ pairs_arr = np.array(pairs, dtype=np.int32)
1124
+ det_indices = pairs_arr[:, 0]
1125
+ ref_indices = pairs_arr[:, 1]
1126
+
1127
+ det_match_idx = obj_sub_idx[det_indices]
1128
+ ref_match_idx = cat_sub_idx[ref_indices]
1129
+
1130
+ seed_matches = int(len(det_match_idx))
1131
+ used_expanded = False
1132
+ refine_pool_det = int(len(det_match_idx))
1133
+ refine_pool_ref = int(len(ref_match_idx))
1134
+
1135
+ # Optional: expand refinement pool using many more detections/catalog entries
1136
+ if self.cfg.refine_use_all:
1137
+ det_pool_idx = np.arange(len(x))
1138
+ if self.cfg.refine_n_det is not None and self.cfg.refine_n_det > 0:
1139
+ det_pool_idx = np.argsort(m_obj)[: min(self.cfg.refine_n_det, len(m_obj))]
1140
+ ref_pool_idx = np.arange(len(ra))
1141
+ if self.cfg.refine_n_ref is not None and self.cfg.refine_n_ref > 0:
1142
+ ref_pool_idx = np.argsort(m_cat)[: min(self.cfg.refine_n_ref, len(m_cat))]
1143
+
1144
+ if len(det_pool_idx) >= 8 and len(ref_pool_idx) >= 8:
1145
+ det_xy_pool = np.column_stack([x[det_pool_idx], y[det_pool_idx]])
1146
+ # Project expanded catalog to pixel space using initial WCS
1147
+ ref_x_all, ref_y_all = wcs_init.all_world2pix(
1148
+ ra[ref_pool_idx], dec[ref_pool_idx], 0
1149
+ )
1150
+ ref_uv_all = np.column_stack([ref_x_all, ref_y_all])
1151
+ # Filter non-finite
1152
+ _ref_ok = np.all(np.isfinite(ref_uv_all), axis=1)
1153
+ if not np.all(_ref_ok):
1154
+ ref_uv_all = ref_uv_all[_ref_ok]
1155
+ ref_pool_idx = ref_pool_idx[_ref_ok]
1156
+
1157
+ # Use affine from last rematch iteration for better projection
1158
+ try:
1159
+ _pa = np.array(pairs, dtype=np.int32)
1160
+ _A = det_xy[_pa[:, 0]]
1161
+ _B = ref_uv[_pa[:, 1]]
1162
+ M_exp, t_exp = estimate_affine_2d(_A, _B)
1163
+ det_uv_pool = apply_affine(det_xy_pool, M_exp, t_exp)
1164
+ except Exception:
1165
+ det_uv_pool = apply_similarity(det_xy_pool, best["R"], best["t"], best["s"])
1166
+
1167
+ r_match_arcsec = self.cfg.refine_match_radius_arcsec
1168
+ if r_match_arcsec is None:
1169
+ r_match_arcsec = self.cfg.stage2_radius_arcsec
1170
+ r_match = float(r_match_arcsec) / pixel_scale_arcsec
1171
+
1172
+ # Mutual nearest-neighbor for expanded pool
1173
+ exp_det_ids, exp_ref_ids = _mutual_nearest_neighbor(
1174
+ det_uv_pool, ref_uv_all, r_match
1175
+ )
1176
+
1177
+ if len(exp_det_ids) >= 8:
1178
+ det_match_idx = det_pool_idx[exp_det_ids]
1179
+ ref_match_idx = ref_pool_idx[exp_ref_ids]
1180
+ used_expanded = True
1181
+ refine_pool_det = int(len(det_pool_idx))
1182
+ refine_pool_ref = int(len(ref_pool_idx))
1183
+
1184
+ det_x = x[det_match_idx]
1185
+ det_y = y[det_match_idx]
1186
+ ref_ra = ra[ref_match_idx]
1187
+ ref_dec = dec[ref_match_idx]
1188
+ ref_m = m_cat[ref_match_idx]
1189
+ det_m = m_obj[det_match_idx]
1190
+ refine_matches_preclip = int(len(det_match_idx))
1191
+
1192
+ # Minimum match floor (#6): don't clip below this count
1193
+ min_matches = max(8, int(refine_matches_preclip * self.cfg.refine_min_match_fraction))
1194
+
1195
+ # --- Progressive sigma-clipping refinement (#6) ---
1196
+ sky = SkyCoord(ref_ra * u.deg, ref_dec * u.deg, frame="icrs")
1197
+ xy = np.vstack([det_x, det_y]) # (2,N)
1198
+
1199
+ refined_wcs = None
1200
+ keep = np.ones(xy.shape[1], dtype=bool)
1201
+
1202
+ # Projection template for WCS fitting (may differ from wcs_init
1203
+ # which is used only for the matching phase)
1204
+ wcs_fit_proj = fit_projection if fit_projection is not None else wcs_init
1205
+
1206
+ # Progressive clipping: interpolate from start to end sigma
1207
+ clip_start = self.cfg.refine_clip_sigma_start
1208
+ clip_end = self.cfg.refine_clip_sigma
1209
+ n_iter = self.cfg.refine_max_iter
1210
+
1211
+ for it in range(n_iter):
1212
+ # Linearly interpolate clip threshold from start (loose) to end (tight)
1213
+ if n_iter > 1:
1214
+ frac = it / (n_iter - 1)
1215
+ else:
1216
+ frac = 1.0
1217
+ clip_sigma = clip_start + (clip_end - clip_start) * frac
1218
+
1219
+ xy_use = xy[:, keep]
1220
+ sky_use = sky[keep]
1221
+
1222
+ # Wrapper handles SIP for TAN, PV for ZPN, linear for others
1223
+ refined_wcs = fit_wcs_from_points(
1224
+ xy_use,
1225
+ sky_use,
1226
+ projection=wcs_fit_proj,
1227
+ sip_degree=int(self.cfg.sip_degree),
1228
+ pv_deg=pv_deg,
1229
+ )
1230
+ wcs_fit_proj = refined_wcs
1231
+
1232
+ # Residuals via spherical distance (projection-independent)
1233
+ ra_fit, dec_fit = refined_wcs.all_pix2world(xy_use[0], xy_use[1], 0)
1234
+ ref_ra_use = sky_use.ra.deg
1235
+ ref_dec_use = sky_use.dec.deg
1236
+ cos_dec = np.cos(np.deg2rad(0.5 * (dec_fit + ref_dec_use)))
1237
+ # Wrap RA difference to [-180, 180) to handle fields crossing RA=0
1238
+ dra_deg = ((ra_fit - ref_ra_use + 180.0) % 360.0 - 180.0) * cos_dec
1239
+ ddec_deg = dec_fit - ref_dec_use
1240
+ dr = np.deg2rad(np.hypot(dra_deg, ddec_deg))
1241
+
1242
+ sig = _robust_sigma(dr)
1243
+ if not np.isfinite(sig) or sig <= 0:
1244
+ break
1245
+
1246
+ thresh = clip_sigma * sig
1247
+ dr_full = np.full(keep.shape[0], np.nan, dtype=np.float64)
1248
+ dr_full[keep] = dr
1249
+ keep_new = keep & np.isfinite(dr_full) & (dr_full < thresh)
1250
+
1251
+ # Enforce minimum match floor (#6)
1252
+ if keep_new.sum() < min_matches:
1253
+ # Don't clip further if we'd go below the minimum.
1254
+ # Keep the current inlier set instead of partially applying
1255
+ # a clip that violates the configured floor.
1256
+ break
1257
+
1258
+ if keep_new.sum() == keep.sum():
1259
+ keep = keep_new
1260
+ break
1261
+ keep = keep_new
1262
+
1263
+ if refined_wcs is None:
1264
+ raise RuntimeError("WCS refinement failed in fit_wcs_from_points stage.")
1265
+
1266
+ # Output match table
1267
+ final_idx = np.nonzero(keep)[0]
1268
+ match = Table()
1269
+ match["x"] = det_x[final_idx]
1270
+ match["y"] = det_y[final_idx]
1271
+ match["obj_mag"] = det_m[final_idx]
1272
+ match["ra"] = ref_ra[final_idx]
1273
+ match["dec"] = ref_dec[final_idx]
1274
+ match["cat_mag"] = ref_m[final_idx]
1275
+
1276
+ # Residuals in arcsec using final WCS (spherical, projection-independent)
1277
+ ra_fit, dec_fit = refined_wcs.all_pix2world(match["x"], match["y"], 0)
1278
+ cos_dec_out = np.cos(np.deg2rad(0.5 * (dec_fit + _as_float_array(match["dec"]))))
1279
+ dra_out = (ra_fit - _as_float_array(match["ra"]) + 180.0) % 360.0 - 180.0
1280
+ match["du_arcsec"] = dra_out * cos_dec_out * 3600.0
1281
+ match["dv_arcsec"] = (dec_fit - _as_float_array(match["dec"])) * 3600.0
1282
+ match["dr_arcsec"] = np.hypot(match["du_arcsec"], match["dv_arcsec"])
1283
+
1284
+ diagnostics = {
1285
+ "rough_center_deg": (float(ra0), float(dec0)),
1286
+ "stage1_hypotheses_kept": int(len(top_hyp)),
1287
+ "pattern_inliers_stage2": int(best["inliers"]),
1288
+ "seed_matches": int(seed_matches),
1289
+ "refine_used_expanded": bool(used_expanded),
1290
+ "refine_pool_det": int(refine_pool_det),
1291
+ "refine_pool_ref": int(refine_pool_ref),
1292
+ "refine_matches_preclip": int(refine_matches_preclip),
1293
+ "final_matches": int(len(match)),
1294
+ "rms_dr_arcsec": float(np.sqrt(np.mean(match["dr_arcsec"] ** 2)))
1295
+ if len(match)
1296
+ else np.nan,
1297
+ "mad_dr_arcsec": float(_robust_sigma(_as_float_array(match["dr_arcsec"])))
1298
+ if len(match)
1299
+ else np.nan,
1300
+ "config": self.cfg,
1301
+ }
1302
+
1303
+ return refined_wcs, match, diagnostics
1304
+
1305
+
1306
+ def refine_wcs_quadhash(
1307
+ obj: Table,
1308
+ cat: Table,
1309
+ wcs: WCS = None,
1310
+ header=None,
1311
+ sr: float = 10 / 3600,
1312
+ order: int = 2,
1313
+ projection: str = None,
1314
+ pv_deg: int = 5,
1315
+ cat_col_ra: str = 'RAJ2000',
1316
+ cat_col_dec: str = 'DEJ2000',
1317
+ cat_col_mag: str = 'rmag',
1318
+ obj_col_mag: str = None,
1319
+ obj_col_flux: str = 'flux',
1320
+ sn: float = None,
1321
+ n_det: int = 150,
1322
+ n_ref: int = 600,
1323
+ max_quads_tested: int = 8000,
1324
+ allow_reflection: bool = True,
1325
+ use_wcs_prior: bool = True,
1326
+ scale_tolerance: float = 0.30,
1327
+ enforce_parity: bool = True,
1328
+ refine_use_all: bool = False,
1329
+ refine_n_det: Optional[int] = None,
1330
+ refine_n_ref: Optional[int] = None,
1331
+ refine_match_radius_arcsec: Optional[float] = None,
1332
+ get_header: bool = False,
1333
+ update: bool = False,
1334
+ verbose: bool = False,
1335
+ ) -> WCS:
1336
+ """Refine WCS using quad-hash pattern matching algorithm.
1337
+
1338
+ Pure Python implementation of astrometric refinement using quad-hash based
1339
+ pattern matching with no external dependencies (only numpy, scipy, astropy).
1340
+ Typically achieves sub-arcsecond accuracy.
1341
+
1342
+ Parameters
1343
+ ----------
1344
+ obj : astropy.table.Table
1345
+ List of objects on the frame, must contain at least ``x``, ``y``, and
1346
+ either ``flux`` or ``mag`` columns.
1347
+ cat : astropy.table.Table
1348
+ Reference astrometric catalogue with RA, Dec, and magnitude columns.
1349
+ wcs : astropy.wcs.WCS, optional
1350
+ Initial WCS solution (rough estimate).
1351
+ header : astropy.io.fits.Header, optional
1352
+ FITS header containing the initial astrometric solution (alternative to ``wcs``).
1353
+ sr : float, optional
1354
+ Matching radius in degrees for stage 2 matching.
1355
+ order : int, optional
1356
+ SIP polynomial order for the distortion solution (0–5).
1357
+ For TAN projection, controls the SIP polynomial degree.
1358
+ For ZPN projection, controls additional SIP corrections on top
1359
+ of the PV radial model (0 means ZPN-only, 2 is recommended for
1360
+ wide-field images). Ignored for other projections.
1361
+ projection : str or None, optional
1362
+ Target projection type for the output WCS. If ``None`` (default),
1363
+ uses the same projection as the input WCS. Supported values:
1364
+
1365
+ - ``'TAN'`` — gnomonic (TAN) with SIP distortion polynomials
1366
+ - ``'ZPN'`` — zenithal polynomial with PV radial terms (+ optional
1367
+ SIP for non-radial corrections). Best for wide-field images
1368
+ (FoV > 5°) as ZPN naturally handles radial distortion that
1369
+ TAN-SIP struggles with.
1370
+ - Any other FITS WCS projection code supported by astropy
1371
+ (``'STG'``, ``'ARC'``, ``'ZEA'``, ``'SIN'``, etc.) — linear
1372
+ WCS fit only (no distortion polynomials).
1373
+
1374
+ The initial WCS (``wcs``) is always used for the pattern matching
1375
+ phase; the projection conversion only affects the final WCS fit.
1376
+ pv_deg : int, optional
1377
+ ZPN PV polynomial degree (``PV2_0`` … ``PV2_pv_deg``). Only used
1378
+ when ``projection='ZPN'``. Default is 5, which is sufficient for
1379
+ most optical systems.
1380
+ cat_col_ra : str, optional
1381
+ Catalogue column name for Right Ascension. Default is ``'RAJ2000'``.
1382
+ cat_col_dec : str, optional
1383
+ Catalogue column name for Declination. Default is ``'DEJ2000'``.
1384
+ cat_col_mag : str, optional
1385
+ Catalogue column name for magnitude. Default is ``'rmag'``.
1386
+ obj_col_mag : str, optional
1387
+ Object list column name for magnitude. Auto-detected if not provided.
1388
+ obj_col_flux : str, optional
1389
+ Object list column name for flux. Used if magnitude column is absent.
1390
+ Default is ``'flux'``.
1391
+ sn : float, optional
1392
+ If provided, only objects with S/N exceeding this value will be used.
1393
+ n_det : int, optional
1394
+ Number of brightest detections to use for matching. Default is 150.
1395
+ n_ref : int, optional
1396
+ Number of brightest catalogue stars to use for matching. Default is 600.
1397
+ max_quads_tested : int, optional
1398
+ Maximum number of detection quads to test. Default is 8000.
1399
+ allow_reflection : bool, optional
1400
+ Allow reflected quad matches. Default is True.
1401
+ use_wcs_prior : bool, optional
1402
+ Filter transform hypotheses by scale (close to 1) and parity consistency
1403
+ in pixel space. Default is True.
1404
+ scale_tolerance : float, optional
1405
+ Fractional tolerance for scale filtering. Default is 0.30 (±30%).
1406
+ enforce_parity : bool, optional
1407
+ Enforce parity consistency with the initial WCS. Default is True.
1408
+ refine_use_all : bool, optional
1409
+ If True, expand the final refinement to a larger pool of objects/catalogue entries.
1410
+ refine_n_det : int or None, optional
1411
+ Max number of detections in the expanded refinement pool. None means all.
1412
+ refine_n_ref : int or None, optional
1413
+ Max number of catalogue stars in the expanded refinement pool. None means all.
1414
+ refine_match_radius_arcsec : float or None, optional
1415
+ Matching radius in arcsec for the expanded refinement pool.
1416
+ Defaults to the stage 2 radius.
1417
+ get_header : bool, optional
1418
+ If True, return the FITS header object instead of WCS solution.
1419
+ update : bool, optional
1420
+ If True, update object sky coordinates in-place using the refined WCS.
1421
+ verbose : bool or callable, optional
1422
+ Whether to show verbose messages. May be boolean or a ``print``-like callable.
1423
+
1424
+ Returns
1425
+ -------
1426
+ astropy.wcs.WCS or astropy.io.fits.Header or None
1427
+ Refined WCS solution, or FITS header if ``get_header=True``, or None on failure.
1428
+
1429
+ Examples
1430
+ --------
1431
+ >>> from stdpipe.astrometry_quad import refine_wcs_quadhash
1432
+ >>> wcs_refined = refine_wcs_quadhash(
1433
+ ... obj, cat, wcs_init,
1434
+ ... sr=10/3600, # 10 arcsec matching radius
1435
+ ... order=2, # quadratic SIP distortion
1436
+ ... sn=5, # use only S/N > 5 objects
1437
+ ... verbose=True
1438
+ ... )
1439
+
1440
+ For wide-field images (FoV > 5 degrees), ZPN projection with SIP
1441
+ corrections typically gives lower systematic residuals at field edges:
1442
+
1443
+ >>> wcs_refined = refine_wcs_quadhash(
1444
+ ... obj, cat, wcs_init,
1445
+ ... sr=30/3600,
1446
+ ... order=2, # SIP order on top of ZPN radial model
1447
+ ... projection='ZPN', # zenithal polynomial projection
1448
+ ... pv_deg=5, # PV radial polynomial degree
1449
+ ... verbose=True
1450
+ ... )
1451
+ """
1452
+
1453
+ # Simple wrapper around print for logging in verbose mode only
1454
+ log = (verbose if callable(verbose) else print) if verbose else lambda *args, **kwargs: None
1455
+
1456
+ # Get WCS from header if needed
1457
+ if wcs is None and header is not None:
1458
+ wcs = WCS(header)
1459
+
1460
+ if wcs is None or not wcs.is_celestial:
1461
+ log("Error: Valid initial WCS required")
1462
+ return None
1463
+
1464
+ # Prepare catalog columns
1465
+ if cat_col_ra not in cat.colnames or cat_col_dec not in cat.colnames:
1466
+ log(f"Error: Catalog must contain '{cat_col_ra}' and '{cat_col_dec}' columns")
1467
+ return None
1468
+
1469
+ if cat_col_mag not in cat.colnames:
1470
+ log(
1471
+ f"Warning: Catalog magnitude column '{cat_col_mag}' not found, using first magnitude column"
1472
+ )
1473
+ # Try to find any magnitude column
1474
+ mag_cols = [c for c in cat.colnames if 'mag' in c.lower()]
1475
+ if mag_cols:
1476
+ cat_col_mag = mag_cols[0]
1477
+ log(f" Using '{cat_col_mag}' as magnitude column")
1478
+ else:
1479
+ log("Error: No magnitude column found in catalog")
1480
+ return None
1481
+
1482
+ # Prepare object columns
1483
+ if obj_col_mag is None:
1484
+ # Auto-detect magnitude column
1485
+ if 'mag' in obj.colnames:
1486
+ obj_col_mag = 'mag'
1487
+ else:
1488
+ obj_col_mag = None
1489
+
1490
+ has_mag = obj_col_mag is not None and obj_col_mag in obj.colnames
1491
+ has_flux = obj_col_flux in obj.colnames
1492
+
1493
+ if not has_mag and not has_flux:
1494
+ log(
1495
+ f"Error: Object list must contain either '{obj_col_mag or 'mag'}' or '{obj_col_flux}' column"
1496
+ )
1497
+ return None
1498
+
1499
+ # Apply S/N filtering if requested
1500
+ obj_filtered = obj
1501
+ if sn is not None:
1502
+ if 'fluxerr' in obj.colnames or 'flux_err' in obj.colnames:
1503
+ err_col = 'fluxerr' if 'fluxerr' in obj.colnames else 'flux_err'
1504
+ flux_col = obj_col_flux if has_flux else None
1505
+
1506
+ if flux_col and flux_col in obj.colnames:
1507
+ obj_sn = obj[flux_col] / obj[err_col]
1508
+ mask = obj_sn > sn
1509
+ obj_filtered = obj[mask]
1510
+ log(f"S/N filtering: {len(obj_filtered)}/{len(obj)} objects with S/N > {sn}")
1511
+ else:
1512
+ log(f"Warning: Cannot apply S/N filter - flux column '{flux_col}' not found")
1513
+ else:
1514
+ log("Warning: Cannot apply S/N filter - no flux error column found")
1515
+
1516
+ # Create standardized tables with required columns
1517
+ cat_std = Table()
1518
+ cat_std['ra'] = cat[cat_col_ra]
1519
+ cat_std['dec'] = cat[cat_col_dec]
1520
+ cat_std['mag'] = cat[cat_col_mag]
1521
+
1522
+ obj_std = Table()
1523
+ obj_std['x'] = obj_filtered['x']
1524
+ obj_std['y'] = obj_filtered['y']
1525
+
1526
+ if has_mag:
1527
+ obj_std['mag'] = obj_filtered[obj_col_mag]
1528
+ if has_flux:
1529
+ obj_std['flux'] = obj_filtered[obj_col_flux]
1530
+
1531
+ # Configure refinement
1532
+ # Compute pixel scale from input WCS for scale-aware defaults
1533
+ try:
1534
+ _pscales = wcs.proj_plane_pixel_scales()
1535
+ if hasattr(_pscales[0], 'to_value'):
1536
+ _pixel_scale_deg = float(np.mean([s.to_value(u.deg) for s in _pscales]))
1537
+ else:
1538
+ _pixel_scale_deg = float(np.mean(_pscales))
1539
+ pixel_scale_arcsec = _pixel_scale_deg * 3600.0
1540
+ except Exception:
1541
+ pixel_scale_arcsec = 1.0 # fallback: assume 1 arcsec/pixel
1542
+ _pixel_scale_deg = pixel_scale_arcsec / 3600.0
1543
+
1544
+ # Estimate FOV diagonal in degrees for scaling source counts
1545
+ if wcs.pixel_shape:
1546
+ nx, ny = wcs.pixel_shape
1547
+ else:
1548
+ nx = ny = 2048
1549
+ log("Warning: WCS has no pixel shape, assuming 2048x2048 for FOV-based scaling")
1550
+ fov_diag_deg = np.hypot(nx, ny) * _pixel_scale_deg
1551
+
1552
+ # Scale-aware matching radii: ensure at least a few pixels even for coarse plates
1553
+ sr_arcsec = sr * 3600
1554
+ stage2_arcsec = max(sr_arcsec, 2.0 * pixel_scale_arcsec)
1555
+ stage1_arcsec = max(sr_arcsec * 2, 3.0 * pixel_scale_arcsec)
1556
+
1557
+ # Scale n_det/n_ref with FOV: for larger fields use more sources
1558
+ # Base: n_det=150, n_ref=600 for a ~0.5 deg field; scale with sqrt(area)
1559
+ fov_scale = max(1.0, fov_diag_deg / 0.5)
1560
+ scaled_n_det = min(int(n_det * np.sqrt(fov_scale)), len(obj_std))
1561
+ scaled_n_ref = min(int(n_ref * np.sqrt(fov_scale)), len(cat_std))
1562
+
1563
+ log("Starting quad-hash WCS refinement...")
1564
+ log(f" Catalog: {len(cat_std)} stars")
1565
+ log(f" Detections: {len(obj_std)} objects")
1566
+ log(f" Initial WCS center: RA={wcs.wcs.crval[0]:.6f}, Dec={wcs.wcs.crval[1]:.6f}")
1567
+ log(
1568
+ f" Pixel scale: {pixel_scale_arcsec:.2f} arcsec/pixel, FOV diagonal: {fov_diag_deg:.2f} deg"
1569
+ )
1570
+ log(
1571
+ f" Matching radii: stage1={stage1_arcsec:.1f} arcsec ({stage1_arcsec / pixel_scale_arcsec:.1f} pix), "
1572
+ f"stage2={stage2_arcsec:.1f} arcsec ({stage2_arcsec / pixel_scale_arcsec:.1f} pix)"
1573
+ )
1574
+ log(f" Source counts: n_det={scaled_n_det}, n_ref={scaled_n_ref}")
1575
+ log(f" SIP order: {order}")
1576
+
1577
+ # Build projection template for the WCS fitting phase
1578
+ fit_projection = None
1579
+ if projection is not None:
1580
+ from stdpipe.astrometry_wcs import convert_wcs_projection
1581
+
1582
+ proj_code = projection.upper().strip()
1583
+ fit_projection = convert_wcs_projection(wcs, proj_code, pv_deg=pv_deg)
1584
+ log(f" Output projection: {proj_code} (fit CTYPE: {fit_projection.wcs.ctype})")
1585
+
1586
+ if proj_code == 'ZPN':
1587
+ log(f" ZPN PV degree: {pv_deg}")
1588
+ elif proj_code != 'TAN' and order > 0:
1589
+ log(f" Note: SIP order={order} ignored for {proj_code} projection (linear fit only)")
1590
+
1591
+ config = AstrometryConfig(
1592
+ n_det=scaled_n_det,
1593
+ n_ref=scaled_n_ref,
1594
+ stage1_radius_arcsec=stage1_arcsec,
1595
+ stage2_radius_arcsec=stage2_arcsec,
1596
+ max_quads_tested=max_quads_tested,
1597
+ allow_reflection=allow_reflection,
1598
+ use_wcs_prior=use_wcs_prior,
1599
+ scale_tolerance=scale_tolerance,
1600
+ enforce_parity=enforce_parity,
1601
+ sip_degree=max(0, min(5, order)), # Clamp to 0-5
1602
+ refine_use_all=refine_use_all,
1603
+ refine_n_det=refine_n_det,
1604
+ refine_n_ref=refine_n_ref,
1605
+ refine_match_radius_arcsec=refine_match_radius_arcsec,
1606
+ )
1607
+
1608
+ try:
1609
+ solver = QuadHashAstrometry(config=config)
1610
+ wcs_refined, match, diagnostics = solver.refine(
1611
+ obj=obj_std, cat=cat_std, wcs_init=wcs,
1612
+ fit_projection=fit_projection, pv_deg=pv_deg,
1613
+ )
1614
+
1615
+ log(f"Refinement successful:")
1616
+ log(f" Pattern matches: {diagnostics['pattern_inliers_stage2']}")
1617
+ log(f" Final matches: {diagnostics['final_matches']}")
1618
+ log(f" RMS residual: {diagnostics['rms_dr_arcsec']:.3f} arcsec")
1619
+ log(f" MAD residual: {diagnostics['mad_dr_arcsec']:.3f} arcsec")
1620
+ log(
1621
+ f" Refined WCS center: RA={wcs_refined.wcs.crval[0]:.6f}, Dec={wcs_refined.wcs.crval[1]:.6f}"
1622
+ )
1623
+
1624
+ # Update object table with sky coordinates if requested
1625
+ if update and wcs_refined is not None:
1626
+ ra_obj, dec_obj = wcs_refined.all_pix2world(obj['x'], obj['y'], 0)
1627
+ obj['ra'] = ra_obj
1628
+ obj['dec'] = dec_obj
1629
+ log(f"Updated object table with ra/dec coordinates")
1630
+
1631
+ # Return header if requested
1632
+ if get_header and wcs_refined is not None:
1633
+ return wcs_refined.to_header(relax=True)
1634
+
1635
+ return wcs_refined
1636
+
1637
+ except Exception as e:
1638
+ log(f"Refinement failed: {type(e).__name__}: {e}")
1639
+ if verbose:
1640
+ import traceback
1641
+
1642
+ traceback.print_exc()
1643
+ return None