stdpipe 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stdpipe/__init__.py +9 -0
- stdpipe/artefacts.py +370 -0
- stdpipe/astrometry.py +2001 -0
- stdpipe/astrometry_quad.py +1643 -0
- stdpipe/astrometry_wcs.py +1141 -0
- stdpipe/catalogs.py +942 -0
- stdpipe/cutouts.py +570 -0
- stdpipe/data/legacysurvey_bricks.fits.gz +0 -0
- stdpipe/data/ps1skycells.txt +199708 -0
- stdpipe/db.py +130 -0
- stdpipe/fringe_removal.py +1086 -0
- stdpipe/lcs.py +440 -0
- stdpipe/photometry.py +2340 -0
- stdpipe/photometry_background.py +998 -0
- stdpipe/photometry_iraf.py +913 -0
- stdpipe/photometry_measure.py +2268 -0
- stdpipe/photometry_model.py +931 -0
- stdpipe/photometry_psf.py +1124 -0
- stdpipe/photometry_quality.py +185 -0
- stdpipe/pipeline.py +1303 -0
- stdpipe/plots.py +1582 -0
- stdpipe/psf.py +1542 -0
- stdpipe/realbogus.py +971 -0
- stdpipe/realbogus_features.py +1657 -0
- stdpipe/reproject.py +943 -0
- stdpipe/resolve.py +267 -0
- stdpipe/sfft.py +1077 -0
- stdpipe/simulation.py +2131 -0
- stdpipe/smoothing.py +488 -0
- stdpipe/subtraction.py +1324 -0
- stdpipe/templates.py +929 -0
- stdpipe/utils.py +365 -0
- stdpipe-0.4.0.dist-info/METADATA +171 -0
- stdpipe-0.4.0.dist-info/RECORD +37 -0
- stdpipe-0.4.0.dist-info/WHEEL +5 -0
- stdpipe-0.4.0.dist-info/licenses/LICENSE.md +21 -0
- stdpipe-0.4.0.dist-info/top_level.txt +1 -0
stdpipe/smoothing.py
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Callable, Optional, Tuple, Union
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from scipy.interpolate import RegularGridInterpolator
|
|
8
|
+
from scipy.ndimage import gaussian_filter
|
|
9
|
+
from scipy.spatial import cKDTree
|
|
10
|
+
from sklearn.neighbors import NearestNeighbors
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _tukey_bisquare(u: np.ndarray) -> np.ndarray:
|
|
14
|
+
"""
|
|
15
|
+
Tukey bisquare weights for standardized residuals u = r / (c * s).
|
|
16
|
+
Returns weights in [0,1].
|
|
17
|
+
"""
|
|
18
|
+
w = np.zeros_like(u, dtype=float)
|
|
19
|
+
m = np.abs(u) < 1.0
|
|
20
|
+
t = 1.0 - u[m] ** 2
|
|
21
|
+
w[m] = t**2
|
|
22
|
+
return w
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _mad_sigma(x: np.ndarray) -> float:
|
|
26
|
+
"""Robust sigma estimate via MAD (consistent for normal)."""
|
|
27
|
+
x = np.asarray(x)
|
|
28
|
+
med = np.nanmedian(x)
|
|
29
|
+
mad = np.nanmedian(np.abs(x - med))
|
|
30
|
+
return 1.4826 * mad + 1e-12
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ApproxLoessRegressor:
|
|
35
|
+
"""
|
|
36
|
+
Approximate LOESS/LOWESS via kNN local linear regression in D dimensions.
|
|
37
|
+
|
|
38
|
+
Supports:
|
|
39
|
+
- adaptive bandwidth (h = dist to k-th neighbor)
|
|
40
|
+
- Gaussian kernel
|
|
41
|
+
- robust IRLS using Tukey bisquare (weights computed once during fit
|
|
42
|
+
via leave-one-out self-prediction at the training points)
|
|
43
|
+
|
|
44
|
+
Samples with non-finite positions, values or weights are dropped during
|
|
45
|
+
fit; queries with non-finite positions predict NaN.
|
|
46
|
+
|
|
47
|
+
Typical use: model smooth trend y = f(x, y, mag) and subtract.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
k: int = 300
|
|
51
|
+
scales: tuple[float, ...] | None = None # per-dimension scaling for distance metric
|
|
52
|
+
kernel: str = "gaussian" # currently only gaussian
|
|
53
|
+
robust_iters: int = 2
|
|
54
|
+
robust_c: float = 4.685 # Tukey tuning constant
|
|
55
|
+
min_bandwidth: float = 1e-6
|
|
56
|
+
ridge: float = 1e-10 # tiny Tikhonov for numerical stability
|
|
57
|
+
leaf_size: int = 40
|
|
58
|
+
n_jobs: int | None = None
|
|
59
|
+
|
|
60
|
+
def fit(self, X: np.ndarray, y: np.ndarray, sample_weight: np.ndarray | None = None):
|
|
61
|
+
"""
|
|
62
|
+
Fit stores training set and builds NN index.
|
|
63
|
+
X: (N, D)
|
|
64
|
+
y: (N,)
|
|
65
|
+
sample_weight: optional base weights (e.g., inverse variance). Robust weights are applied on top.
|
|
66
|
+
"""
|
|
67
|
+
X = np.asarray(X, float)
|
|
68
|
+
y = np.asarray(y, float).reshape(-1)
|
|
69
|
+
if X.ndim != 2:
|
|
70
|
+
raise ValueError("X must be (N, D)")
|
|
71
|
+
if y.shape[0] != X.shape[0]:
|
|
72
|
+
raise ValueError("y must have length N")
|
|
73
|
+
|
|
74
|
+
D = X.shape[1]
|
|
75
|
+
if self.scales is None:
|
|
76
|
+
scales = np.ones(D, dtype=float)
|
|
77
|
+
else:
|
|
78
|
+
scales = np.asarray(self.scales, float)
|
|
79
|
+
if len(scales) != D:
|
|
80
|
+
raise ValueError(f"scales must have length D={D}")
|
|
81
|
+
|
|
82
|
+
base_w = (
|
|
83
|
+
np.ones_like(y)
|
|
84
|
+
if sample_weight is None
|
|
85
|
+
else np.asarray(sample_weight, float).reshape(-1)
|
|
86
|
+
)
|
|
87
|
+
if base_w.shape[0] != X.shape[0]:
|
|
88
|
+
raise ValueError("sample_weight must have length N")
|
|
89
|
+
if np.any(base_w < 0):
|
|
90
|
+
raise ValueError("sample_weight must be non-negative")
|
|
91
|
+
|
|
92
|
+
# Drop samples with non-finite positions, values or weights; the NN
|
|
93
|
+
# index cannot handle them, and they carry no usable information
|
|
94
|
+
good = np.all(np.isfinite(X), axis=1) & np.isfinite(y) & np.isfinite(base_w)
|
|
95
|
+
if not np.all(good):
|
|
96
|
+
X, y, base_w = X[good], y[good], base_w[good]
|
|
97
|
+
if X.shape[0] == 0:
|
|
98
|
+
raise ValueError("No finite samples to fit")
|
|
99
|
+
|
|
100
|
+
self.X_ = X
|
|
101
|
+
self.y_ = y
|
|
102
|
+
self.base_w_ = base_w
|
|
103
|
+
self.scales_ = scales
|
|
104
|
+
self.Xs_ = self.X_ / self.scales_ # scaled for distance computations
|
|
105
|
+
self.n_samples_ = self.X_.shape[0]
|
|
106
|
+
self.k_ = min(self.k, self.n_samples_)
|
|
107
|
+
|
|
108
|
+
self.nn_ = NearestNeighbors(
|
|
109
|
+
n_neighbors=self.k_,
|
|
110
|
+
algorithm="auto",
|
|
111
|
+
leaf_size=self.leaf_size,
|
|
112
|
+
n_jobs=self.n_jobs,
|
|
113
|
+
)
|
|
114
|
+
self.nn_.fit(self.Xs_)
|
|
115
|
+
|
|
116
|
+
# Robust IRLS weights from leave-one-out self-prediction at the
|
|
117
|
+
# training points, to downweight artifacts in the training set.
|
|
118
|
+
# They depend on the training data only, so they are computed once
|
|
119
|
+
# here rather than on every predict() call
|
|
120
|
+
robust_w = np.ones_like(self.y_)
|
|
121
|
+
for _ in range(self.robust_iters):
|
|
122
|
+
yfit = self._predict_core(self.X_, self.Xs_, robust_w, exclude_self=True)
|
|
123
|
+
resid = self.y_ - yfit
|
|
124
|
+
# Residual scale from points actually contributing to the fit
|
|
125
|
+
weighted = self.base_w_ > 0
|
|
126
|
+
s = _mad_sigma(resid[weighted] if np.any(weighted) else resid)
|
|
127
|
+
u = resid / (self.robust_c * s)
|
|
128
|
+
robust_w = _tukey_bisquare(u)
|
|
129
|
+
# Keep points whose self-prediction failed instead of treating
|
|
130
|
+
# them as outliers
|
|
131
|
+
robust_w[~np.isfinite(resid)] = 1.0
|
|
132
|
+
self.robust_w_ = robust_w
|
|
133
|
+
|
|
134
|
+
return self
|
|
135
|
+
|
|
136
|
+
def predict(self, Xq: np.ndarray, chunk: int = 4096) -> np.ndarray:
|
|
137
|
+
"""
|
|
138
|
+
Predict yhat for query points Xq using local linear LOESS.
|
|
139
|
+
|
|
140
|
+
Queries with non-finite coordinates return NaN. Robust weights for
|
|
141
|
+
the training points were pre-computed during :meth:`fit`.
|
|
142
|
+
"""
|
|
143
|
+
Xq = np.asarray(Xq, float)
|
|
144
|
+
if Xq.ndim != 2:
|
|
145
|
+
raise ValueError("Xq must be (M, D)")
|
|
146
|
+
if Xq.shape[1] != self.X_.shape[1]:
|
|
147
|
+
raise ValueError("Xq must have same D as training X")
|
|
148
|
+
|
|
149
|
+
yhat = np.full(Xq.shape[0], np.nan, dtype=float)
|
|
150
|
+
|
|
151
|
+
# Non-finite queries would crash the NN search; predict NaN for them
|
|
152
|
+
good = np.all(np.isfinite(Xq), axis=1)
|
|
153
|
+
if np.any(good):
|
|
154
|
+
Xg = Xq[good]
|
|
155
|
+
yhat[good] = self._predict_core(
|
|
156
|
+
Xg, Xg / self.scales_, self.robust_w_, chunk=chunk
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return yhat
|
|
160
|
+
|
|
161
|
+
def _predict_core(
|
|
162
|
+
self,
|
|
163
|
+
Xq: np.ndarray,
|
|
164
|
+
Xqs: np.ndarray,
|
|
165
|
+
robust_w_train: np.ndarray,
|
|
166
|
+
chunk: int = 4096,
|
|
167
|
+
exclude_self: bool = False,
|
|
168
|
+
) -> np.ndarray:
|
|
169
|
+
"""
|
|
170
|
+
Core prediction with fixed robust weights for training points.
|
|
171
|
+
Uses local linear regression with weights = kernel(distance/h) * base_w * robust_w.
|
|
172
|
+
If exclude_self is True, the nearest self-neighbor is removed where possible.
|
|
173
|
+
"""
|
|
174
|
+
M, D = Xq.shape
|
|
175
|
+
out = np.empty(M, float)
|
|
176
|
+
|
|
177
|
+
# Query neighbors
|
|
178
|
+
for start in range(0, M, chunk):
|
|
179
|
+
end = min(M, start + chunk)
|
|
180
|
+
k_base = self.k_
|
|
181
|
+
k_query = k_base + 1 if exclude_self and self.n_samples_ > k_base else k_base
|
|
182
|
+
dists, idxs = self.nn_.kneighbors(
|
|
183
|
+
Xqs[start:end], n_neighbors=k_query, return_distance=True
|
|
184
|
+
) # (m, k_query)
|
|
185
|
+
if exclude_self and k_query > k_base:
|
|
186
|
+
# Drop one zero-distance (self) neighbour per row where present
|
|
187
|
+
has_self = (dists[:, 0] == 0.0)[:, None]
|
|
188
|
+
dists = np.where(has_self, dists[:, 1:], dists[:, :k_base])
|
|
189
|
+
idxs = np.where(has_self, idxs[:, 1:], idxs[:, :k_base])
|
|
190
|
+
# adaptive bandwidth per query point: h = distance to furthest neighbor
|
|
191
|
+
h = np.maximum(dists[:, -1], self.min_bandwidth) # (m,)
|
|
192
|
+
# kernel weights
|
|
193
|
+
if self.kernel != "gaussian":
|
|
194
|
+
raise NotImplementedError("Only gaussian kernel is implemented")
|
|
195
|
+
|
|
196
|
+
# Gaussian kernel with adaptive bandwidth:
|
|
197
|
+
# w_kernel = exp(-0.5*(d/h)^2)
|
|
198
|
+
z = dists / h[:, None]
|
|
199
|
+
w_kernel = np.exp(-0.5 * z * z)
|
|
200
|
+
if exclude_self and k_query == k_base:
|
|
201
|
+
# k covers the whole training set; zero out one zero-distance
|
|
202
|
+
# (self) neighbour per row, consistent with the branch above
|
|
203
|
+
w_kernel[dists[:, 0] == 0.0, 0] = 0.0
|
|
204
|
+
|
|
205
|
+
# Pull neighbor values
|
|
206
|
+
Yn = self.y_[idxs] # (m, k)
|
|
207
|
+
# Combine weights: base * robust * kernel
|
|
208
|
+
w = w_kernel * (self.base_w_[idxs] * robust_w_train[idxs]) # (m, k)
|
|
209
|
+
|
|
210
|
+
# Where base/robust weights rejected every neighbour, fall back
|
|
211
|
+
# to plain kernel weights instead of silently predicting 0 from
|
|
212
|
+
# the ridge-only system; if even those are all zero, predict NaN
|
|
213
|
+
rejected = ~(np.sum(w, axis=1) > 0)
|
|
214
|
+
if np.any(rejected):
|
|
215
|
+
w[rejected] = w_kernel[rejected]
|
|
216
|
+
unusable = ~(np.sum(w, axis=1) > 0)
|
|
217
|
+
|
|
218
|
+
# Local linear regression around each query:
|
|
219
|
+
# y ≈ b0 + b1*(x-xq) + b2*(y-yq) + b3*(mag-magq) ...
|
|
220
|
+
# Design matrix per query: A = [1, dX] with shape (k, 1+D)
|
|
221
|
+
Xn = self.X_[idxs] # (m, k, D)
|
|
222
|
+
dX = Xn - Xq[start:end, None, :] # (m, k, D)
|
|
223
|
+
|
|
224
|
+
# Build weighted normal equations per query:
|
|
225
|
+
# Beta = (A^T W A + ridge*I)^(-1) (A^T W y)
|
|
226
|
+
# where A = [1, dX]
|
|
227
|
+
m = end - start
|
|
228
|
+
P = 1 + D
|
|
229
|
+
# A: (m, k, P)
|
|
230
|
+
A = np.empty((m, k_base, P), float)
|
|
231
|
+
A[:, :, 0] = 1.0
|
|
232
|
+
A[:, :, 1:] = dX
|
|
233
|
+
|
|
234
|
+
# Compute ATA and ATy with vectorized einsum
|
|
235
|
+
# Apply weights by multiplying rows of A and y by sqrt(w)
|
|
236
|
+
sw = np.sqrt(np.maximum(w, 0.0))
|
|
237
|
+
Aw = A * sw[:, :, None] # (m, k, P)
|
|
238
|
+
yw = Yn * sw # (m, k)
|
|
239
|
+
|
|
240
|
+
ATA = np.einsum("mkp,mkq->mpq", Aw, Aw) # (m, P, P)
|
|
241
|
+
ATy = np.einsum("mkp,mk->mp", Aw, yw) # (m, P)
|
|
242
|
+
|
|
243
|
+
# Ridge for numerical stability (especially in sparse regions);
|
|
244
|
+
# ATA + ridge*I is symmetric positive definite, so the batched
|
|
245
|
+
# solve cannot encounter an exactly singular system
|
|
246
|
+
ATA[:, range(P), range(P)] += self.ridge
|
|
247
|
+
|
|
248
|
+
# Batched solve of the per-query small linear systems.
|
|
249
|
+
# We need b0 only (intercept) because dX=0 at query point.
|
|
250
|
+
b0 = np.linalg.solve(ATA, ATy[:, :, None])[:, 0, 0]
|
|
251
|
+
b0[unusable] = np.nan
|
|
252
|
+
out[start:end] = b0
|
|
253
|
+
|
|
254
|
+
return out
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _fit_loess_field_2d(x, y, dx, dy, scales, k, **kwargs):
|
|
258
|
+
pos = np.column_stack([np.asarray(x, float), np.asarray(y, float)])
|
|
259
|
+
model_dx = ApproxLoessRegressor(k=k, scales=scales, **kwargs)
|
|
260
|
+
model_dx.fit(pos, np.asarray(dx, float))
|
|
261
|
+
if dy is None:
|
|
262
|
+
def predict(xq, yq):
|
|
263
|
+
q = np.column_stack([np.asarray(xq, float), np.asarray(yq, float)])
|
|
264
|
+
return model_dx.predict(q)
|
|
265
|
+
return predict
|
|
266
|
+
model_dy = ApproxLoessRegressor(k=k, scales=scales, **kwargs)
|
|
267
|
+
model_dy.fit(pos, np.asarray(dy, float))
|
|
268
|
+
def predict(xq, yq):
|
|
269
|
+
q = np.column_stack([np.asarray(xq, float), np.asarray(yq, float)])
|
|
270
|
+
return model_dx.predict(q), model_dy.predict(q)
|
|
271
|
+
return predict
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _fill_grid_nearest(values, valid, x_centers, y_centers):
|
|
275
|
+
filled = np.array(values, copy=True)
|
|
276
|
+
if np.all(valid):
|
|
277
|
+
return filled
|
|
278
|
+
yy, xx = np.meshgrid(y_centers, x_centers, indexing="ij")
|
|
279
|
+
pts_valid = np.c_[xx[valid], yy[valid]]
|
|
280
|
+
pts_missing = np.c_[xx[~valid], yy[~valid]]
|
|
281
|
+
tree = cKDTree(pts_valid)
|
|
282
|
+
_, idx = tree.query(pts_missing, k=1)
|
|
283
|
+
filled[~valid] = values[valid][idx]
|
|
284
|
+
return filled
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _smooth_grid(values, weights, sigma):
|
|
288
|
+
if sigma <= 0:
|
|
289
|
+
return values
|
|
290
|
+
num = gaussian_filter(values * weights, sigma=sigma, mode="nearest")
|
|
291
|
+
den = gaussian_filter(weights, sigma=sigma, mode="nearest")
|
|
292
|
+
out = np.array(values, copy=True)
|
|
293
|
+
good = den > 0
|
|
294
|
+
out[good] = num[good] / den[good]
|
|
295
|
+
return out
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _fit_grid_one(x, y, vals, x_edges, y_edges, min_per_cell, smooth_sigma):
|
|
299
|
+
x = np.asarray(x, float)
|
|
300
|
+
y = np.asarray(y, float)
|
|
301
|
+
vals = np.asarray(vals, float)
|
|
302
|
+
|
|
303
|
+
# Non-finite positions would be silently binned into the last cell by
|
|
304
|
+
# digitize+clip, and non-finite values would poison cell medians
|
|
305
|
+
good = np.isfinite(x) & np.isfinite(y) & np.isfinite(vals)
|
|
306
|
+
if not np.all(good):
|
|
307
|
+
x, y, vals = x[good], y[good], vals[good]
|
|
308
|
+
|
|
309
|
+
nx = len(x_edges) - 1
|
|
310
|
+
ny = len(y_edges) - 1
|
|
311
|
+
x_centers = 0.5 * (x_edges[:-1] + x_edges[1:])
|
|
312
|
+
y_centers = 0.5 * (y_edges[:-1] + y_edges[1:])
|
|
313
|
+
ix = np.clip(np.digitize(x, x_edges) - 1, 0, nx - 1)
|
|
314
|
+
iy = np.clip(np.digitize(y, y_edges) - 1, 0, ny - 1)
|
|
315
|
+
|
|
316
|
+
grid = np.full((ny, nx), np.nan, dtype=float)
|
|
317
|
+
counts = np.zeros((ny, nx), dtype=float)
|
|
318
|
+
threshold = max(1, int(min_per_cell))
|
|
319
|
+
valid = None
|
|
320
|
+
while True:
|
|
321
|
+
grid.fill(np.nan)
|
|
322
|
+
counts.fill(0.0)
|
|
323
|
+
# Group sample indices by 1-D bin id, then per-bin nanmedian
|
|
324
|
+
bin_id = iy * nx + ix
|
|
325
|
+
order = np.argsort(bin_id, kind="stable")
|
|
326
|
+
bin_sorted = bin_id[order]
|
|
327
|
+
vals_sorted = vals[order]
|
|
328
|
+
starts = np.searchsorted(bin_sorted, np.arange(ny * nx))
|
|
329
|
+
ends = np.searchsorted(bin_sorted, np.arange(ny * nx) + 1)
|
|
330
|
+
for b, (s, e) in enumerate(zip(starts, ends)):
|
|
331
|
+
if e - s >= threshold:
|
|
332
|
+
jy, jx = divmod(b, nx)
|
|
333
|
+
grid[jy, jx] = np.nanmedian(vals_sorted[s:e])
|
|
334
|
+
counts[jy, jx] = e - s
|
|
335
|
+
valid = np.isfinite(grid) & (counts > 0)
|
|
336
|
+
if np.any(valid) or threshold == 1:
|
|
337
|
+
break
|
|
338
|
+
threshold = max(1, threshold // 2)
|
|
339
|
+
|
|
340
|
+
if not np.any(valid):
|
|
341
|
+
raise RuntimeError("No grid cells have any samples")
|
|
342
|
+
|
|
343
|
+
grid = _fill_grid_nearest(grid, valid, x_centers, y_centers)
|
|
344
|
+
weights = np.where(valid, counts, 0.0)
|
|
345
|
+
grid = _smooth_grid(grid, weights, smooth_sigma)
|
|
346
|
+
interp = RegularGridInterpolator(
|
|
347
|
+
(y_centers, x_centers), grid,
|
|
348
|
+
bounds_error=False, fill_value=None,
|
|
349
|
+
)
|
|
350
|
+
return interp, grid, counts, threshold
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _fit_grid_field_2d(
|
|
354
|
+
x, y, dx, dy, image_shape, grid_shape, min_per_cell, smooth_sigma,
|
|
355
|
+
):
|
|
356
|
+
x = np.asarray(x, float); y = np.asarray(y, float)
|
|
357
|
+
dx = np.asarray(dx, float)
|
|
358
|
+
if grid_shape[0] < 2 or grid_shape[1] < 2:
|
|
359
|
+
# RegularGridInterpolator needs at least 2 points per dimension
|
|
360
|
+
raise ValueError("grid_shape dimensions must be at least 2")
|
|
361
|
+
if image_shape is None:
|
|
362
|
+
finite = np.isfinite(x) & np.isfinite(y)
|
|
363
|
+
if not np.any(finite):
|
|
364
|
+
raise ValueError("No finite sample positions")
|
|
365
|
+
H = float(np.max(y[finite])) - float(np.min(y[finite]))
|
|
366
|
+
W = float(np.max(x[finite])) - float(np.min(x[finite]))
|
|
367
|
+
if W <= 0 or H <= 0:
|
|
368
|
+
raise ValueError(
|
|
369
|
+
"Degenerate x/y extent; provide image_shape explicitly"
|
|
370
|
+
)
|
|
371
|
+
x0, y0 = float(np.min(x[finite])), float(np.min(y[finite]))
|
|
372
|
+
x_edges = np.linspace(x0, x0 + W, grid_shape[0] + 1)
|
|
373
|
+
y_edges = np.linspace(y0, y0 + H, grid_shape[1] + 1)
|
|
374
|
+
else:
|
|
375
|
+
x_edges = np.linspace(0.0, image_shape[1], grid_shape[0] + 1)
|
|
376
|
+
y_edges = np.linspace(0.0, image_shape[0], grid_shape[1] + 1)
|
|
377
|
+
|
|
378
|
+
interp_dx, _, _, _ = _fit_grid_one(
|
|
379
|
+
x, y, dx, x_edges, y_edges, min_per_cell, smooth_sigma,
|
|
380
|
+
)
|
|
381
|
+
if dy is None:
|
|
382
|
+
def predict(xq, yq):
|
|
383
|
+
pts = np.c_[np.asarray(yq, float), np.asarray(xq, float)]
|
|
384
|
+
return np.asarray(interp_dx(pts), float)
|
|
385
|
+
return predict
|
|
386
|
+
interp_dy, _, _, _ = _fit_grid_one(
|
|
387
|
+
x, y, np.asarray(dy, float), x_edges, y_edges, min_per_cell, smooth_sigma,
|
|
388
|
+
)
|
|
389
|
+
def predict(xq, yq):
|
|
390
|
+
pts = np.c_[np.asarray(yq, float), np.asarray(xq, float)]
|
|
391
|
+
return (np.asarray(interp_dx(pts), float),
|
|
392
|
+
np.asarray(interp_dy(pts), float))
|
|
393
|
+
return predict
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def fit_vector_field_2d(
|
|
397
|
+
x: np.ndarray,
|
|
398
|
+
y: np.ndarray,
|
|
399
|
+
dx: np.ndarray,
|
|
400
|
+
dy: Optional[np.ndarray] = None,
|
|
401
|
+
*,
|
|
402
|
+
backend: str = "loess",
|
|
403
|
+
scales: Optional[Tuple[float, float]] = None,
|
|
404
|
+
k: int = 200,
|
|
405
|
+
image_shape: Optional[Tuple[int, int]] = None,
|
|
406
|
+
grid_shape: Tuple[int, int] = (12, 8),
|
|
407
|
+
min_per_cell: int = 6,
|
|
408
|
+
smooth_sigma: float = 1.0,
|
|
409
|
+
**kwargs,
|
|
410
|
+
) -> Callable[[np.ndarray, np.ndarray],
|
|
411
|
+
Union[np.ndarray, Tuple[np.ndarray, np.ndarray]]]:
|
|
412
|
+
"""Fit a smooth scalar or vector 2-D field to scattered samples.
|
|
413
|
+
|
|
414
|
+
Reconstructs a 2-D field (e.g. astrometric (dx, dy) residuals) from
|
|
415
|
+
per-source positions and per-source measurements and returns a callable
|
|
416
|
+
that evaluates the smoothed field at arbitrary positions.
|
|
417
|
+
|
|
418
|
+
Two backends are available with the same return interface:
|
|
419
|
+
|
|
420
|
+
* ``backend="loess"`` (default) wraps :class:`ApproxLoessRegressor`.
|
|
421
|
+
High-quality local-linear smoothing with adaptive bandwidth and
|
|
422
|
+
optional robust IRLS. Best when prediction is needed at modest
|
|
423
|
+
numbers of points (a few times the fit size).
|
|
424
|
+
* ``backend="grid"`` bins the samples on a regular ``grid_shape`` grid,
|
|
425
|
+
takes per-cell medians, fills empty cells from the nearest filled
|
|
426
|
+
cell, optionally Gaussian-smooths in cell units, and returns a
|
|
427
|
+
bilinear interpolator. ~600–1000× faster at prediction than LOESS,
|
|
428
|
+
at the cost of cell-scale resolution and blockier output.
|
|
429
|
+
|
|
430
|
+
Parameters
|
|
431
|
+
----------
|
|
432
|
+
x, y : array-like, shape (N,)
|
|
433
|
+
Sample positions.
|
|
434
|
+
dx : array-like, shape (N,)
|
|
435
|
+
Sample values, or first component of a vector field if ``dy`` is
|
|
436
|
+
also provided.
|
|
437
|
+
dy : array-like, shape (N,), optional
|
|
438
|
+
Second component of a vector field. When given, the returned
|
|
439
|
+
``predict`` callable evaluates both components at once.
|
|
440
|
+
backend : {"loess", "grid"}
|
|
441
|
+
Smoothing backend.
|
|
442
|
+
scales : (sx, sy) tuple, optional
|
|
443
|
+
LOESS only. Per-axis distance scaling forwarded to
|
|
444
|
+
:class:`ApproxLoessRegressor`. Defaults to ``(1.0, 1.0)``.
|
|
445
|
+
k : int
|
|
446
|
+
LOESS only. Neighbour count for each local linear fit.
|
|
447
|
+
image_shape : (H, W) tuple, optional
|
|
448
|
+
Grid only. Image shape used to lay out the grid edges. If omitted,
|
|
449
|
+
the bounding box of the input ``(x, y)`` is used.
|
|
450
|
+
grid_shape : (nx, ny) tuple
|
|
451
|
+
Grid only. Number of cells in x and y.
|
|
452
|
+
min_per_cell : int
|
|
453
|
+
Grid only. Minimum sample count per cell required for a valid
|
|
454
|
+
median; cells below the threshold are filled from the nearest
|
|
455
|
+
valid neighbour. Threshold is halved automatically until at least
|
|
456
|
+
one cell is valid.
|
|
457
|
+
smooth_sigma : float
|
|
458
|
+
Grid only. Gaussian smoothing sigma in cell units applied to the
|
|
459
|
+
gridded medians (count-weighted).
|
|
460
|
+
**kwargs :
|
|
461
|
+
LOESS only. Additional keyword arguments forwarded to
|
|
462
|
+
:class:`ApproxLoessRegressor` (``robust_iters``, ``robust_c``, ...).
|
|
463
|
+
|
|
464
|
+
Returns
|
|
465
|
+
-------
|
|
466
|
+
predict : callable
|
|
467
|
+
``predict(xq, yq)`` returns a single ``ndarray`` for a scalar
|
|
468
|
+
field, or a ``(dx_pred, dy_pred)`` tuple for a vector field.
|
|
469
|
+
|
|
470
|
+
Notes
|
|
471
|
+
-----
|
|
472
|
+
Samples with non-finite positions or values are ignored by both
|
|
473
|
+
backends, and queries at non-finite positions return NaN.
|
|
474
|
+
"""
|
|
475
|
+
if backend == "loess":
|
|
476
|
+
if scales is None:
|
|
477
|
+
scales = (1.0, 1.0)
|
|
478
|
+
return _fit_loess_field_2d(x, y, dx, dy, scales, k, **kwargs)
|
|
479
|
+
elif backend == "grid":
|
|
480
|
+
if kwargs:
|
|
481
|
+
raise TypeError(
|
|
482
|
+
f"unexpected kwargs for grid backend: {sorted(kwargs)}"
|
|
483
|
+
)
|
|
484
|
+
return _fit_grid_field_2d(
|
|
485
|
+
x, y, dx, dy, image_shape, grid_shape, min_per_cell, smooth_sigma,
|
|
486
|
+
)
|
|
487
|
+
else:
|
|
488
|
+
raise ValueError(f"unknown backend {backend!r}; use 'loess' or 'grid'")
|