nufftcf 0.1.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.
nufftcf/__init__.py ADDED
@@ -0,0 +1,89 @@
1
+ """
2
+ nufftcf: fast ACF estimation for irregularly- AND regularly-sampled time series.
3
+
4
+ Three estimation families are provided, sharing the same (lags, t, x, bin_width)
5
+ calling convention:
6
+
7
+ - `compute_acf_*_nufft` : NUFFT + Wiener-Khinchin, O(n log n)-ish, fastest
8
+ for long IRREGULAR series, ~1-3% residual
9
+ amplitude bias on strongly periodic signals
10
+ (see module docs).
11
+ - `compute_acf_*_realspace` : direct real-space weighted sum, O(n) per lag,
12
+ artifact-free reference / alternative, works
13
+ for irregular AND regular sampling.
14
+ - `compute_acf_*_fft` : classic FFT correlation, O(n log n), for
15
+ REGULARLY-sampled data only -- faster than
16
+ `_nufft` (no NUFFT overhead) and faster than
17
+ `_realspace` (no numba two-pointer scan) when
18
+ sampling happens to be regular. Also adds a
19
+ `regular` (no-kernel) variant matching
20
+ Pastas' `bin_method="regular"`.
21
+
22
+ All families come in `gaussian` and `rectangle` kernel variants; `_fft` also
23
+ has `regular` (no smoothing kernel).
24
+
25
+ Example
26
+ -------
27
+ >>> import numpy as np, pandas as pd
28
+ >>> from nufftcf import compute_acf_gaussian_nufft, t_numeric_of
29
+ >>> idx = pd.date_range("2020-01-01", periods=2000, freq="D")
30
+ >>> x = pd.Series(np.random.randn(2000), index=idx)
31
+ >>> lags = np.arange(0.0, 366.0)
32
+ >>> t = t_numeric_of(x)
33
+ >>> c, b = compute_acf_gaussian_nufft(lags, t, x.to_numpy(), bin_width=0.5)
34
+
35
+ >>> # regularly-sampled data -> use the faster classic-FFT path instead:
36
+ >>> from nufftcf import compute_acf_gaussian_fft, compute_acf_regular_fft
37
+ >>> c, b = compute_acf_gaussian_fft(lags, t, x.to_numpy(), bin_width=0.5)
38
+ >>> c, b = compute_acf_regular_fft(lags, t, x.to_numpy()) # no kernel
39
+ """
40
+
41
+ from .kernels import (
42
+ compute_b_gaussian,
43
+ compute_b_rectangle,
44
+ compute_c_gaussian,
45
+ compute_c_rectangle,
46
+ )
47
+ from .nufft_acf import compute_acf_gaussian_nufft, compute_acf_rectangle_nufft
48
+ from .realspace_acf import (
49
+ compute_acf_gaussian_realspace,
50
+ compute_acf_rectangle_realspace,
51
+ )
52
+ from .nufft_ccf import compute_ccf_gaussian_nufft, compute_ccf_rectangle_nufft
53
+ from .realspace_ccf import (
54
+ compute_ccf_gaussian_realspace,
55
+ compute_ccf_rectangle_realspace,
56
+ )
57
+
58
+ from .fft_acf import (
59
+ compute_acf_regular_fft,
60
+ compute_acf_rectangle_fft,
61
+ compute_acf_gaussian_fft,
62
+ )
63
+ from .utils import t_numeric_of, standardize
64
+
65
+ __version__ = "0.1.0"
66
+
67
+ __all__ = [
68
+ "compute_acf_gaussian_nufft",
69
+ "compute_acf_rectangle_nufft",
70
+ "compute_acf_gaussian_realspace",
71
+ "compute_acf_rectangle_realspace",
72
+ "compute_acf_regular_fft",
73
+ "compute_acf_rectangle_fft",
74
+ "compute_acf_gaussian_fft",
75
+ "compute_ccf_gaussian_nufft",
76
+ "compute_ccf_rectangle_nufft",
77
+ "compute_ccf_gaussian_realspace",
78
+ "compute_ccf_rectangle_realspace",
79
+ "compute_b_gaussian",
80
+ "compute_b_rectangle",
81
+ "compute_c_gaussian",
82
+ "compute_c_rectangle",
83
+ "compute_b_gaussian_cross",
84
+ "compute_b_rectangle_cross",
85
+ "compute_c_gaussian_cross",
86
+ "compute_c_rectangle_cross",
87
+ "t_numeric_of",
88
+ "standardize",
89
+ ]
nufftcf/fft_acf.py ADDED
@@ -0,0 +1,221 @@
1
+ """
2
+ ACF estimation for REGULARLY-sampled series, via classic FFT correlation
3
+ (`scipy.signal.correlate`) instead of NUFFT.
4
+
5
+ This is a fast-path companion to `nufft_acf.py` / `realspace_acf.py`: when
6
+ the data happens to be on a uniform grid, there is no need to pay for NUFFT
7
+ (or for the O(n)-per-lag numba two-pointer scan) -- a plain FFT correlation
8
+ plus a cheap smoothing pass gives the *exact same* gaussian/rectangle
9
+ estimator, faster and with no numba/finufft dependency in the hot path.
10
+
11
+ Three estimators are provided, all sharing the same (lags, t, x) calling
12
+ convention as the rest of the package:
13
+
14
+ - `compute_acf_regular_fft` : no smoothing kernel at all -- the windowed
15
+ Pearson correlation Pastas uses for its
16
+ "regular" bin_method (regular data only).
17
+ Scales ~O(n) (it is NOT a quadratic method,
18
+ unlike Pastas' gaussian/rectangle bin
19
+ methods -- see benchmark/).
20
+ - `compute_acf_rectangle_fft` : same rectangular-kernel definition as
21
+ `compute_acf_rectangle_nufft`/`_realspace`.
22
+ - `compute_acf_gaussian_fft` : same gaussian-kernel definition as
23
+ `compute_acf_gaussian_nufft`/`_realspace`.
24
+
25
+ All three require `t` to be regularly spaced (checked, raises otherwise);
26
+ use the `nufft` or `realspace` estimators for irregular sampling.
27
+
28
+ Implementation note on the `b` (pair-count) denominator
29
+ ---------------------------------------------------------
30
+ For `gaussian`, `b` is obtained by applying the *exact same* smoothing
31
+ filter (`gaussian_filter1d`, same sigma, same boundary mode) to the
32
+ triangular "raw pair count" ramp `n - |lag|` as is applied to the raw
33
+ correlation numerator. This isn't just convenient: computing numerator and
34
+ denominator through the same discrete kernel makes any discretization
35
+ artifact of that kernel cancel exactly in the ratio, which is what lets
36
+ this estimator match `compute_acf_gaussian_realspace` to ~1e-6 without any
37
+ extra renormalization step. `mode="mirror"` is required (not scipy's
38
+ default `"reflect"`) for this cancellation to hold all the way to lag=0,
39
+ since the true pair-count ramp is symmetric *through* lag=0 (whole-sample
40
+ symmetry), not around the edge *between* lag=-1 and lag=0 (half-sample
41
+ symmetry, which is what `"reflect"` assumes).
42
+
43
+ For `rectangle`, the unsmoothed ramp `b = n - lag` is already exact as-is
44
+ -- no filtering needed -- because `uniform_filter1d` already normalizes by
45
+ its own window size internally.
46
+ """
47
+
48
+ import numpy as np
49
+ from scipy.signal import correlate
50
+ from scipy.ndimage import gaussian_filter1d, uniform_filter1d
51
+
52
+ from .utils import standardize
53
+
54
+
55
+ def _check_regular_grid(t):
56
+ t = np.asarray(t, dtype=float)
57
+ if len(t) < 2:
58
+ return 1.0
59
+ dt = np.diff(t)
60
+ dt0 = dt[0]
61
+ if not np.allclose(dt, dt0, rtol=1e-6):
62
+ raise ValueError(
63
+ "compute_acf_*_fft requires a regularly-sampled `t` "
64
+ "(use the `nufft` or `realspace` estimators for irregular data)."
65
+ )
66
+ return dt0
67
+
68
+
69
+ def compute_acf_regular_fft(lags, t, x):
70
+ """ACF estimate with no smoothing kernel, for regularly-sampled data --
71
+ matches Pastas' `bin_method="regular"` (windowed Pearson correlation)
72
+ to numerical precision, but vectorized instead of one `np.corrcoef`
73
+ call per lag.
74
+
75
+ Parameters
76
+ ----------
77
+ lags : array_like
78
+ Lags at which to evaluate the ACF (same units as `t`).
79
+ t : array_like
80
+ Regularly-spaced sample times, sorted ascending.
81
+ x : array_like
82
+ Sample values, same length as `t`.
83
+
84
+ Returns
85
+ -------
86
+ c, b : ndarray
87
+ ACF estimate and pair count, both shape (len(lags),).
88
+ """
89
+ t = np.asarray(t, dtype=float)
90
+ x = np.asarray(x, dtype=float)
91
+ lags = np.asarray(lags, dtype=float)
92
+ dt = _check_regular_grid(t)
93
+ n = len(x)
94
+
95
+ lag_idx = np.round(lags / dt).astype(int)
96
+ b = np.where(n - lag_idx <= 0, 1e-16, (n - lag_idx).astype(float))
97
+
98
+ # Cumulative first/second moments -> exact windowed mean & std in O(1)
99
+ # per lag (this is the *exact* expansion Var = E[x^2] - E[x]^2, not an
100
+ # incremental re-centered sum -- the latter looks similar but silently
101
+ # drifts by ~0.1-0.3% away from a true windowed std).
102
+ s1 = np.concatenate(([0.0], np.cumsum(x)))
103
+ s2 = np.concatenate(([0.0], np.cumsum(x * x)))
104
+
105
+ def _windowed_mean_std(lo, hi):
106
+ cnt = np.maximum((hi - lo).astype(float), 1.0)
107
+ mean = (s1[hi] - s1[lo]) / cnt
108
+ var = np.maximum((s2[hi] - s2[lo]) / cnt - mean**2, 0.0)
109
+ return mean, np.sqrt(var)
110
+
111
+ n_lags = len(lag_idx)
112
+ hi_y = np.clip(n - lag_idx, 0, n)
113
+ lo_x = np.clip(lag_idx, 0, n)
114
+ y_mean, y_std = _windowed_mean_std(np.zeros(n_lags, dtype=int), hi_y)
115
+ x_mean, x_std = _windowed_mean_std(lo_x, np.full(n_lags, n))
116
+
117
+ c_raw = correlate(x, x, mode="full")[n - 1 : 2 * n - 1]
118
+ valid = (lag_idx >= 0) & (lag_idx < n)
119
+ c_raw_at = np.where(valid, c_raw[np.clip(lag_idx, 0, n - 1)], np.nan)
120
+
121
+ with np.errstate(divide="ignore", invalid="ignore"):
122
+ cov = (
123
+ c_raw_at / np.where(n - lag_idx > 0, n - lag_idx, np.nan) - y_mean * x_mean
124
+ )
125
+ denom = y_std * x_std
126
+ c = np.where(denom > 1e-12, cov / denom, np.nan)
127
+ c = np.where(valid, c, np.nan)
128
+ return c, b
129
+
130
+
131
+ def compute_acf_rectangle_fft(lags, t, x, bin_width=0.5):
132
+ """ACF estimate via FFT correlation + rectangular smoothing, for
133
+ regularly-sampled data. Same kernel definition (and -- on the same
134
+ data -- numerically equivalent result) as `compute_acf_rectangle_nufft`
135
+ / `compute_acf_rectangle_realspace`, just computed via plain FFT
136
+ correlation instead of NUFFT / a numba two-pointer scan.
137
+
138
+ Parameters
139
+ ----------
140
+ lags : array_like
141
+ t : array_like
142
+ Regularly-spaced sample times, sorted ascending.
143
+ x : array_like
144
+ bin_width : float
145
+ Rectangular half-width (same units as `t`).
146
+
147
+ Returns
148
+ -------
149
+ c, b : ndarray
150
+ """
151
+ t = np.asarray(t, dtype=float)
152
+ x = standardize(np.asarray(x, dtype=float))
153
+ lags = np.asarray(lags, dtype=float)
154
+ dt = _check_regular_grid(t)
155
+ n = len(x)
156
+
157
+ lag_idx = np.round(lags / dt).astype(int)
158
+ # Forcing an ODD kernel size (2*k+1) is deliberate.
159
+ # scipy's uniform_filter1d centers an EVEN-sized window a half
160
+ # -sample off from the true symmetric [-bin_width, +bin_width] interval
161
+ # kernels.py's two-pointer scan evaluates exactly, which otherwise
162
+ # introduces a small but systematic (~0.3-0.5%, not just at lag=0)
163
+ # bias at every lag -- confirmed empirically, see tests/test_fft_acf.py.
164
+ kernel_size = 2 * int(round(bin_width / dt)) + 1
165
+
166
+ c_raw = correlate(x, x, mode="full")[n - 1 : 2 * n - 1]
167
+ c_smoothed = uniform_filter1d(c_raw, size=kernel_size, mode="nearest")
168
+
169
+ valid = (lag_idx >= 0) & (lag_idx < n)
170
+ c_at = np.where(valid, c_smoothed[np.clip(lag_idx, 0, n - 1)], np.nan)
171
+ b = np.where(valid, np.maximum(n - lag_idx, 1e-16), 1e-16)
172
+
173
+ with np.errstate(divide="ignore", invalid="ignore"):
174
+ c = c_at / b
175
+ return c, b
176
+
177
+
178
+ def compute_acf_gaussian_fft(lags, t, x, bin_width=0.5):
179
+ """ACF estimate via FFT correlation + gaussian smoothing, for
180
+ regularly-sampled data. Same kernel definition as
181
+ `compute_acf_gaussian_nufft` / `compute_acf_gaussian_realspace`.
182
+
183
+ Parameters
184
+ ----------
185
+ lags : array_like
186
+ t : array_like
187
+ Regularly-spaced sample times, sorted ascending.
188
+ x : array_like
189
+ bin_width : float
190
+ Gaussian standard deviation (same units as `t`).
191
+
192
+ Returns
193
+ -------
194
+ c, b : ndarray
195
+ """
196
+ t = np.asarray(t, dtype=float)
197
+ x = standardize(np.asarray(x, dtype=float))
198
+ lags = np.asarray(lags, dtype=float)
199
+ dt = _check_regular_grid(t)
200
+ n = len(x)
201
+ sigma = bin_width / dt
202
+
203
+ lag_idx = np.round(lags / dt).astype(int)
204
+ max_idx = int(np.max(np.abs(lag_idx))) if len(lag_idx) else 0
205
+ n_eval = max(n, max_idx + 1)
206
+
207
+ c_raw = correlate(x, x, mode="full")[n - 1 : 2 * n - 1]
208
+ c_raw = np.pad(c_raw, (0, max(0, n_eval - n)), constant_values=0.0)
209
+ c_smoothed = gaussian_filter1d(c_raw, sigma=sigma, mode="mirror")
210
+
211
+ b_raw = np.maximum(n - np.arange(n_eval, dtype=float), 0.0)
212
+ b_smoothed = gaussian_filter1d(b_raw, sigma=sigma, mode="mirror")
213
+
214
+ valid = (lag_idx >= 0) & (lag_idx < n)
215
+ idx = np.clip(lag_idx, 0, n_eval - 1)
216
+ c_at = np.where(valid, c_smoothed[idx], np.nan)
217
+ b_at = np.where(valid, b_smoothed[idx], 1e-16)
218
+
219
+ with np.errstate(divide="ignore", invalid="ignore"):
220
+ c = c_at / b_at
221
+ return c, b_at
nufftcf/kernels.py ADDED
@@ -0,0 +1,265 @@
1
+ """
2
+ Numba-jitted, two-pointer-optimized kernels.
3
+
4
+ All functions here require `t` (time, in days) sorted in ascending order.
5
+ Complexity is O(n) per lag (instead of the naive O(n^2)), thanks to the
6
+ two-pointer technique: as the lag-shifted window center increases
7
+ monotonically with j (since t is sorted), the window bounds [lo, hi) only
8
+ ever advance, never retreat.
9
+
10
+ `b_*` functions compute the kernel-weighted (or counted) number of
11
+ contributing pairs per lag -- the normalization denominator.
12
+
13
+ `c_*` functions compute the kernel-weighted sum of x_i * x_j per lag -- the
14
+ correlation numerator, for the "real-space" (exact, non-NUFFT) ACF estimator.
15
+ """
16
+
17
+ import math
18
+ import numpy as np
19
+ from numba import njit, prange
20
+
21
+
22
+ @njit(parallel=True, nogil=True, cache=True, fastmath=True)
23
+ def compute_b_gaussian(t, lags, sigma):
24
+ """Gaussian-kernel weighted pair count per lag."""
25
+ n = len(t)
26
+ nlags = len(lags)
27
+ b = np.zeros(nlags)
28
+ den1 = -2 * sigma**2
29
+ den2 = math.sqrt(2 * math.pi) * sigma
30
+ six_den2 = 6 * den2
31
+ for k in prange(nlags):
32
+ lag = lags[k]
33
+ b_sum = 0.0
34
+ lo = 0
35
+ hi = 0
36
+ for j in range(n):
37
+ center = t[j] + lag
38
+ while lo < n and t[lo] < center - six_den2:
39
+ lo += 1
40
+ while hi < n and t[hi] < center + six_den2:
41
+ hi += 1
42
+ for i in range(lo, hi):
43
+ dtlag = t[i] - center
44
+ b_sum += math.exp(dtlag**2 / den1) / den2
45
+ b[k] = b_sum
46
+ if b[k] <= 0:
47
+ b[k] = 1e-16
48
+ return b
49
+
50
+
51
+ @njit(parallel=True, nogil=True, cache=True)
52
+ def compute_b_rectangle(t, lags, bin_width):
53
+ """Rectangular-kernel pair count per lag (direct count, no inner loop)."""
54
+ n = len(t)
55
+ nlags = len(lags)
56
+ b = np.zeros(nlags)
57
+ for k in prange(nlags):
58
+ lag = lags[k]
59
+ b_sum = 0.0
60
+ lo = 0
61
+ hi = 0
62
+ for j in range(n):
63
+ center = t[j] + lag
64
+ while lo < n and t[lo] < center - bin_width:
65
+ lo += 1
66
+ if hi < lo:
67
+ hi = lo
68
+ while hi < n and t[hi] <= center + bin_width:
69
+ hi += 1
70
+ b_sum += hi - lo
71
+ b[k] = b_sum
72
+ if b[k] <= 0:
73
+ b[k] = 1e-16
74
+ return b
75
+
76
+
77
+ @njit(parallel=True, nogil=True, cache=True, fastmath=True)
78
+ def compute_c_gaussian(t, lags, x, sigma):
79
+ """Gaussian-kernel weighted sum of x_i*x_j per lag (correlation numerator)."""
80
+ n = len(t)
81
+ nlags = len(lags)
82
+ c = np.zeros(nlags)
83
+ den1 = -2 * sigma**2
84
+ den2 = math.sqrt(2 * math.pi) * sigma
85
+ six_den2 = 6 * den2
86
+ for k in prange(nlags):
87
+ lag = lags[k]
88
+ c_sum = 0.0
89
+ lo = 0
90
+ hi = 0
91
+ for j in range(n):
92
+ center = t[j] + lag
93
+ while lo < n and t[lo] < center - six_den2:
94
+ lo += 1
95
+ while hi < n and t[hi] < center + six_den2:
96
+ hi += 1
97
+ xj = x[j]
98
+ for i in range(lo, hi):
99
+ dtlag = t[i] - center
100
+ w = math.exp(dtlag**2 / den1) / den2
101
+ c_sum += w * x[i] * xj
102
+ c[k] = c_sum
103
+ return c
104
+
105
+
106
+ @njit(parallel=True, nogil=True, cache=True)
107
+ def compute_c_rectangle(t, lags, x, bin_width):
108
+ """Rectangular-kernel sum of x_i*x_j per lag, via prefix sums (O(1) per j,
109
+ since the rectangle kernel weight is uniform inside the window: the
110
+ window sum of x is looked up directly from a precomputed cumulative sum,
111
+ rather than re-summed element by element)."""
112
+ n = len(t)
113
+ nlags = len(lags)
114
+ c = np.zeros(nlags)
115
+ cumsum_x = np.zeros(n + 1)
116
+ for idx in range(n):
117
+ cumsum_x[idx + 1] = cumsum_x[idx] + x[idx]
118
+ for k in prange(nlags):
119
+ lag = lags[k]
120
+ c_sum = 0.0
121
+ lo = 0
122
+ hi = 0
123
+ for j in range(n):
124
+ center = t[j] + lag
125
+ while lo < n and t[lo] < center - bin_width:
126
+ lo += 1
127
+ if hi < lo:
128
+ hi = lo
129
+ while hi < n and t[hi] <= center + bin_width:
130
+ hi += 1
131
+ window_sum = cumsum_x[hi] - cumsum_x[lo]
132
+ c_sum += x[j] * window_sum
133
+ c[k] = c_sum
134
+ return c
135
+
136
+
137
+ # ── Cross-correlation kernel functions ────────────────────────────────────────
138
+ # Same two-pointer logic as the ACF kernels above, adapted for two DIFFERENT
139
+ # time arrays t (for signal x) and s (for signal y). The CCF pair condition
140
+ # is s_j - t_i ≈ lag (whereas for ACF it was t_i - t_j ≈ lag).
141
+ # As j increases (s[j] increases), center = s[j] - lag increases
142
+ # monotonically, so lo/hi over t still only advance → O(n_t + n_s) per lag.
143
+
144
+
145
+ @njit(parallel=True, nogil=True, cache=True, fastmath=True)
146
+ def compute_b_gaussian_cross(t, s, lags, sigma):
147
+ """Gaussian-kernel weighted pair count for CCF.
148
+
149
+ Counts pairs (t_i, s_j) with s_j − t_i ≈ lag, weighted by the
150
+ Gaussian kernel. `t` and `s` must both be sorted ascending.
151
+ """
152
+ n_t = len(t)
153
+ n_s = len(s)
154
+ nlags = len(lags)
155
+ b = np.zeros(nlags)
156
+ den1 = -2 * sigma**2
157
+ den2 = math.sqrt(2 * math.pi) * sigma
158
+ six_den2 = 6 * den2
159
+ for k in prange(nlags):
160
+ lag = lags[k]
161
+ b_sum = 0.0
162
+ lo = 0
163
+ hi = 0
164
+ for j in range(n_s):
165
+ center = s[j] - lag # t_i near here → s_j − t_i ≈ lag
166
+ while lo < n_t and t[lo] < center - six_den2:
167
+ lo += 1
168
+ while hi < n_t and t[hi] < center + six_den2:
169
+ hi += 1
170
+ for i in range(lo, hi):
171
+ dtlag = t[i] - center
172
+ b_sum += math.exp(dtlag**2 / den1) / den2
173
+ b[k] = b_sum if b_sum > 0 else 1e-16
174
+ return b
175
+
176
+
177
+ @njit(parallel=True, nogil=True, cache=True)
178
+ def compute_b_rectangle_cross(t, s, lags, bin_width):
179
+ """Rectangle-kernel pair count for CCF.
180
+
181
+ Counts pairs (t_i, s_j) with |s_j − t_i − lag| ≤ bin_width.
182
+ """
183
+ n_t = len(t)
184
+ n_s = len(s)
185
+ nlags = len(lags)
186
+ b = np.zeros(nlags)
187
+ for k in prange(nlags):
188
+ lag = lags[k]
189
+ b_sum = 0.0
190
+ lo = 0
191
+ hi = 0
192
+ for j in range(n_s):
193
+ center = s[j] - lag
194
+ while lo < n_t and t[lo] < center - bin_width:
195
+ lo += 1
196
+ if hi < lo:
197
+ hi = lo
198
+ while hi < n_t and t[hi] <= center + bin_width:
199
+ hi += 1
200
+ b_sum += hi - lo
201
+ b[k] = b_sum if b_sum > 0 else 1e-16
202
+ return b
203
+
204
+
205
+ @njit(parallel=True, nogil=True, cache=True, fastmath=True)
206
+ def compute_c_gaussian_cross(t, x, s, y, lags, sigma):
207
+ """Gaussian-kernel weighted sum of x_i * y_j for CCF.
208
+
209
+ For each lag, accumulates x(t_i) * y(s_j) for pairs where
210
+ s_j − t_i ≈ lag, weighted by the Gaussian kernel w(s_j − t_i − lag).
211
+ """
212
+ n_t = len(t)
213
+ n_s = len(s)
214
+ nlags = len(lags)
215
+ c = np.zeros(nlags)
216
+ den1 = -2 * sigma**2
217
+ den2 = math.sqrt(2 * math.pi) * sigma
218
+ six_den2 = 6 * den2
219
+ for k in prange(nlags):
220
+ lag = lags[k]
221
+ c_sum = 0.0
222
+ lo = 0
223
+ hi = 0
224
+ for j in range(n_s):
225
+ center = s[j] - lag
226
+ while lo < n_t and t[lo] < center - six_den2:
227
+ lo += 1
228
+ while hi < n_t and t[hi] < center + six_den2:
229
+ hi += 1
230
+ yj = y[j]
231
+ for i in range(lo, hi):
232
+ dtlag = t[i] - center
233
+ w = math.exp(dtlag**2 / den1) / den2
234
+ c_sum += w * x[i] * yj
235
+ c[k] = c_sum
236
+ return c
237
+
238
+
239
+ @njit(parallel=True, nogil=True, cache=True)
240
+ def compute_c_rectangle_cross(t, x, s, y, lags, bin_width):
241
+ """Rectangle-kernel sum of x_i * y_j for CCF, via prefix sums."""
242
+ n_t = len(t)
243
+ n_s = len(s)
244
+ nlags = len(lags)
245
+ c = np.zeros(nlags)
246
+ cumsum_x = np.zeros(n_t + 1)
247
+ for idx in range(n_t):
248
+ cumsum_x[idx + 1] = cumsum_x[idx] + x[idx]
249
+ for k in prange(nlags):
250
+ lag = lags[k]
251
+ c_sum = 0.0
252
+ lo = 0
253
+ hi = 0
254
+ for j in range(n_s):
255
+ center = s[j] - lag
256
+ while lo < n_t and t[lo] < center - bin_width:
257
+ lo += 1
258
+ if hi < lo:
259
+ hi = lo
260
+ while hi < n_t and t[hi] <= center + bin_width:
261
+ hi += 1
262
+ window_sum = cumsum_x[hi] - cumsum_x[lo]
263
+ c_sum += y[j] * window_sum
264
+ c[k] = c_sum
265
+ return c
nufftcf/nufft_acf.py ADDED
@@ -0,0 +1,118 @@
1
+ """
2
+ ACF estimation via NUFFT (non-uniform FFT) + Wiener-Khinchin theorem.
3
+
4
+ These estimators compute the power spectrum of the (irregularly-sampled)
5
+ signal via a type-1 NUFFT, then evaluate the implied autocorrelation at the
6
+ requested lags via a type-2 NUFFT. This scales roughly as O(n log n),
7
+ dramatically faster than the O(n^2) real-space approach for long series --
8
+ but it carries a small, known limitation (see README): because it relies on
9
+ a finite-domain Fourier representation, irregular/gappy sampling acts as a
10
+ "spectral window" that slightly distorts narrowband (e.g. periodic) signals
11
+ more than broadband ones. Empirically this is a ~1-3% relative bias in the
12
+ ACF amplitude once N1 is large enough (see N1 note below); for an
13
+ artifact-free reference, use the `realspace` module instead.
14
+
15
+ `N1 = 32 * n` was empirically validated (against the exact real-space
16
+ estimator) to bring the NUFFT result into close agreement for both gaussian
17
+ and rectangle kernels; pushing higher gives a marginal further
18
+ improvement for gaussian on strongly periodic signals, at negligible extra
19
+ cost.
20
+ """
21
+
22
+ import numpy as np
23
+ import finufft
24
+ from scipy.ndimage import gaussian_filter1d, uniform_filter1d
25
+
26
+ from .kernels import compute_b_gaussian, compute_b_rectangle
27
+ from .utils import standardize
28
+
29
+
30
+ def _nufft_power_spectrum_at_lags(t, x, lags, N1, eps):
31
+ """Shared first stage: NUFFT type-1 (time -> frequency) then type-2
32
+ (frequency -> [0.0] + lags), implementing Wiener-Khinchin. Always
33
+ evaluates an extra point at lag=0 (regardless of whether 0 is already
34
+ in `lags`), used downstream to normalize the result -- the NUFFT pipeline
35
+ has its own internal scale convention that has nothing to do with the
36
+ `b` denominator's scale, so dividing by `b` alone does *not* yield a
37
+ properly normalized correlation in [-1, 1]. Returns the raw (unsmoothed)
38
+ correlation at [0.0] + lags, i.e. length len(lags) + 1.
39
+ """
40
+ x_normalized = standardize(x)
41
+ xc = np.complex128(x_normalized)
42
+ n = len(xc)
43
+ t_min, t_max = t.min(), t.max()
44
+ t_norm = (t - t_min) / (t_max - t_min) * (2 * np.pi)
45
+ lags_norm = (lags - 0) / (t_max - t_min) * (2 * np.pi)
46
+ if N1 is None:
47
+ N1 = 32 * n
48
+ f1 = finufft.nufft1d1(t_norm, xc, (N1,), eps=eps)
49
+ mul = f1 * np.conj(f1)
50
+ c_positive = finufft.nufft1d2(lags_norm, mul, eps=eps).real
51
+ return c_positive # index 0 is lag=0, indices [1:] correspond to `lags`
52
+
53
+
54
+ def compute_acf_gaussian_nufft(lags, t, x, bin_width=0.5, N1=None, eps=1e-9):
55
+ """ACF estimate via NUFFT + gaussian smoothing.
56
+
57
+ Parameters
58
+ ----------
59
+ lags : array_like
60
+ Lags at which to evaluate the ACF (same units as `t`, typically days).
61
+ t : array_like
62
+ Sample times, sorted ascending (same units as `lags`).
63
+ x : array_like
64
+ Sample values, same length as `t`.
65
+ bin_width : float
66
+ Gaussian kernel standard deviation (same units as `t`).
67
+ N1 : int, optional
68
+ NUFFT frequency-grid size. Defaults to 32*len(x) in _nufft_power_spectrum_at_lags
69
+ eps : float
70
+ NUFFT requested precision.
71
+
72
+ Returns
73
+ -------
74
+ c, b : ndarray
75
+ ACF estimate and effective pair count, both shape (len(lags),).
76
+ """
77
+ t = np.asarray(t, dtype=float)
78
+ x = np.asarray(x, dtype=float)
79
+ lags = np.asarray(lags, dtype=float)
80
+ lags_eval = np.concatenate(([0.0], lags))
81
+
82
+ c_positive = _nufft_power_spectrum_at_lags(t, x, lags_eval, N1, eps)
83
+ c_smoothed = gaussian_filter1d(c_positive, sigma=bin_width)
84
+ b_eval = compute_b_gaussian(t, lags_eval, bin_width)
85
+ with np.errstate(divide="ignore", invalid="ignore", over="ignore"):
86
+ c_eval = c_smoothed / b_eval
87
+ c = c_eval[1:] / c_eval[0] # normalize by the (always computed) lag=0 value
88
+ b = b_eval[1:]
89
+ return c, b
90
+
91
+
92
+ def compute_acf_rectangle_nufft(lags, t, x, bin_width=0.5, N1=None, eps=1e-9):
93
+ """ACF estimate via NUFFT + rectangular (box) smoothing.
94
+
95
+ Same parameters and return values as `compute_acf_gaussian_nufft`.
96
+ The smoothing window size (in samples) is derived from `bin_width` and
97
+ the average lag spacing; with the common default bin_width=0.5 and a
98
+ 1-day lag spacing, this resolves to a 1-sample window (i.e. no-op),
99
+ matching the gaussian kernel's "non-overlapping bins" behavior at the
100
+ same bin_width.
101
+ """
102
+ t = np.asarray(t, dtype=float)
103
+ x = np.asarray(x, dtype=float)
104
+ lags = np.asarray(lags, dtype=float)
105
+ lags_eval = np.concatenate(([0.0], lags))
106
+
107
+ c_positive = _nufft_power_spectrum_at_lags(t, x, lags_eval, N1, eps)
108
+
109
+ dlag = np.mean(np.diff(lags)) if len(lags) > 1 else 1.0
110
+ window_size = max(1, int(round(2 * bin_width / dlag)))
111
+ c_smoothed = uniform_filter1d(c_positive, size=window_size, mode="nearest")
112
+
113
+ b_eval = compute_b_rectangle(t, lags_eval, bin_width)
114
+ with np.errstate(divide="ignore", invalid="ignore", over="ignore"):
115
+ c_eval = c_smoothed / b_eval
116
+ c = c_eval[1:] / c_eval[0] # normalize by the (always computed) lag=0 value
117
+ b = b_eval[1:]
118
+ return c, b