commkit 1.0.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.
- commkit/__init__.py +74 -0
- commkit/_cuda/__init__.py +321 -0
- commkit/_cuda/compiler.py +88 -0
- commkit/_cuda/src/bps_min_d2.cu +104 -0
- commkit/_cuda/src/cs_block.cu +119 -0
- commkit/_cuda/src/selftest.cu +14 -0
- commkit/analysis/__init__.py +55 -0
- commkit/analysis/_common.py +236 -0
- commkit/analysis/allan.py +108 -0
- commkit/analysis/drift.py +213 -0
- commkit/analysis/interferometry.py +887 -0
- commkit/analysis/linewidth.py +480 -0
- commkit/analysis/trajectory.py +91 -0
- commkit/backend.py +507 -0
- commkit/coding/__init__.py +23 -0
- commkit/coding/base.py +17 -0
- commkit/coding/bch.py +6 -0
- commkit/coding/convolutional.py +7 -0
- commkit/coding/crc.py +7 -0
- commkit/coding/galois.py +8 -0
- commkit/coding/hamming.py +6 -0
- commkit/coding/interleaving.py +7 -0
- commkit/coding/ldpc.py +8 -0
- commkit/coding/polar.py +8 -0
- commkit/coding/ratematch.py +6 -0
- commkit/coding/reed_solomon.py +6 -0
- commkit/coding/turbo.py +8 -0
- commkit/core/__init__.py +32 -0
- commkit/core/frame.py +992 -0
- commkit/core/generation.py +581 -0
- commkit/core/signal.py +725 -0
- commkit/equalization/__init__.py +49 -0
- commkit/equalization/_block.py +1855 -0
- commkit/equalization/_common.py +606 -0
- commkit/equalization/_kernels_jax.py +1720 -0
- commkit/equalization/_kernels_numba.py +1704 -0
- commkit/equalization/blind.py +223 -0
- commkit/equalization/linear.py +365 -0
- commkit/equalization/polarization.py +790 -0
- commkit/equalization/result.py +191 -0
- commkit/equalization/sequential.py +2805 -0
- commkit/filtering.py +1120 -0
- commkit/frequency.py +1191 -0
- commkit/helpers.py +489 -0
- commkit/impairments/__init__.py +43 -0
- commkit/impairments/channel/__init__.py +20 -0
- commkit/impairments/channel/linear.py +310 -0
- commkit/impairments/channel/nonlinear.py +11 -0
- commkit/impairments/frontend.py +229 -0
- commkit/impairments/noise.py +105 -0
- commkit/impairments/source.py +219 -0
- commkit/io.py +308 -0
- commkit/logger.py +103 -0
- commkit/mapping/__init__.py +46 -0
- commkit/mapping/bits.py +240 -0
- commkit/mapping/constellation.py +153 -0
- commkit/mapping/gray.py +429 -0
- commkit/mapping/llr.py +253 -0
- commkit/mapping/shaping.py +218 -0
- commkit/metrics.py +949 -0
- commkit/multirate.py +476 -0
- commkit/plotting/__init__.py +78 -0
- commkit/plotting/analysis.py +627 -0
- commkit/plotting/constellation.py +483 -0
- commkit/plotting/equalizer.py +390 -0
- commkit/plotting/eye.py +388 -0
- commkit/plotting/spectral.py +575 -0
- commkit/plotting/sync.py +953 -0
- commkit/plotting/theme.py +203 -0
- commkit/plotting/waveform.py +200 -0
- commkit/py.typed +0 -0
- commkit/recovery/__init__.py +51 -0
- commkit/recovery/bps.py +337 -0
- commkit/recovery/corrections.py +751 -0
- commkit/recovery/pilots.py +803 -0
- commkit/recovery/pll.py +482 -0
- commkit/recovery/tikhonov.py +424 -0
- commkit/recovery/viterbi_viterbi.py +227 -0
- commkit/spectral.py +560 -0
- commkit/timing.py +841 -0
- commkit-1.0.0.dist-info/METADATA +145 -0
- commkit-1.0.0.dist-info/RECORD +84 -0
- commkit-1.0.0.dist-info/WHEEL +4 -0
- commkit-1.0.0.dist-info/licenses/LICENSE +21 -0
commkit/frequency.py
ADDED
|
@@ -0,0 +1,1191 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Frequency offset estimation and correction utilities.
|
|
3
|
+
|
|
4
|
+
This module provides routines for carrier frequency offset (FOE) estimation
|
|
5
|
+
and correction, including blind spectral methods, multi-lag autocorrelation,
|
|
6
|
+
pilot-aided estimation, blockwise time-varying FOE, and constant-offset
|
|
7
|
+
complex mixing correction.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from .backend import ArrayType, dispatch, to_device
|
|
15
|
+
from .logger import logger
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _modulation_power_m(modulation: str, order: int) -> int:
|
|
19
|
+
"""
|
|
20
|
+
Return the exponent M for M-th power spectral methods.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
modulation : str
|
|
25
|
+
Modulation type string (case-insensitive).
|
|
26
|
+
order : int
|
|
27
|
+
Modulation order.
|
|
28
|
+
|
|
29
|
+
Returns
|
|
30
|
+
-------
|
|
31
|
+
int
|
|
32
|
+
M = ``order`` for PSK; M = 4 for QAM and other schemes.
|
|
33
|
+
|
|
34
|
+
Notes
|
|
35
|
+
-----
|
|
36
|
+
M=4 is exact only for **square** QAM constellations (4, 16, 64, 256, ...)
|
|
37
|
+
which have perfect 4-fold rotational symmetry. For cross-QAM (32, 128,
|
|
38
|
+
512-QAM) the 4th power leaves residual modulation spurs; a warning is
|
|
39
|
+
emitted. For PAM/ASK the M-th power law does not apply; prefer
|
|
40
|
+
pilot-aided or data-aided estimators.
|
|
41
|
+
"""
|
|
42
|
+
mod = modulation.lower()
|
|
43
|
+
if "psk" in mod:
|
|
44
|
+
if order > 4:
|
|
45
|
+
logger.warning(
|
|
46
|
+
"%s-PSK: M=%sth-power raises noise variance by M² - VV/FOE reliability degrades severely for order > 4. Prefer BPS or pilot-aided CPR for 8-PSK and higher.",
|
|
47
|
+
order,
|
|
48
|
+
order,
|
|
49
|
+
)
|
|
50
|
+
return order # M-th power exactly removes M-PSK modulation
|
|
51
|
+
|
|
52
|
+
if "qam" in mod:
|
|
53
|
+
side = int(order**0.5)
|
|
54
|
+
if side * side == order:
|
|
55
|
+
return 4 # Square QAM: 4-fold rotational symmetry, 4th power is exact
|
|
56
|
+
# Cross-QAM (32, 128, 512-QAM): 4-fold symmetry is only approximate
|
|
57
|
+
logger.warning(
|
|
58
|
+
"%s-QAM is not square - 4th-power FOE/CPR will have residual modulation spurs. Prefer pilot-aided or data-aided estimation.",
|
|
59
|
+
order,
|
|
60
|
+
)
|
|
61
|
+
return 4
|
|
62
|
+
|
|
63
|
+
# PAM, ASK, or unrecognised scheme
|
|
64
|
+
logger.warning(
|
|
65
|
+
"Modulation '%s' (order %s): M=4 is a heuristic. 4th-power methods are unreliable for non-QAM/PSK formats. Prefer pilot-aided or data-aided estimation.",
|
|
66
|
+
modulation,
|
|
67
|
+
order,
|
|
68
|
+
)
|
|
69
|
+
return 4
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Lazy-compiled Numba kernel for the M&M iterative bootstrap.
|
|
73
|
+
_NUMBA_MM: dict = {}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _get_numba_mm_bootstrap():
|
|
77
|
+
"""JIT-compile and cache the Numba M&M iterative bootstrap kernel.
|
|
78
|
+
|
|
79
|
+
Returns
|
|
80
|
+
-------
|
|
81
|
+
callable
|
|
82
|
+
Numba-compiled ``_mm_bootstrap_loop``.
|
|
83
|
+
"""
|
|
84
|
+
if "mm" not in _NUMBA_MM:
|
|
85
|
+
import numba
|
|
86
|
+
|
|
87
|
+
@numba.njit(cache=True, fastmath=True, nogil=True)
|
|
88
|
+
def _mm_bootstrap_loop(theta, amp, M_val, fs):
|
|
89
|
+
"""Iterative Mengali-Morelli bootstrap compiled to machine code.
|
|
90
|
+
|
|
91
|
+
Predicts each lag's phase from the running weighted frequency
|
|
92
|
+
estimate accumulated from all previous lags, then folds it
|
|
93
|
+
into the weighted sum. Sequential data dependency prevents
|
|
94
|
+
vectorisation; Numba removes Python-interpreter overhead.
|
|
95
|
+
|
|
96
|
+
Parameters
|
|
97
|
+
----------
|
|
98
|
+
theta : (L,) float64
|
|
99
|
+
Wrapped phase of R[m] at each lag (output of np.angle).
|
|
100
|
+
amp : (L,) float64
|
|
101
|
+
Magnitude |R[m]| at each lag (output of np.abs).
|
|
102
|
+
M_val : float64
|
|
103
|
+
Modulation power (1 for data-aided/generic, order for PSK,
|
|
104
|
+
4 for QAM).
|
|
105
|
+
fs : float64
|
|
106
|
+
Sampling rate in Hz.
|
|
107
|
+
|
|
108
|
+
Returns
|
|
109
|
+
-------
|
|
110
|
+
float64
|
|
111
|
+
Estimated frequency offset in Hz.
|
|
112
|
+
"""
|
|
113
|
+
two_pi = 2.0 * np.pi
|
|
114
|
+
L = len(theta)
|
|
115
|
+
|
|
116
|
+
# Lag 1 initialisation (m=1, m²=1)
|
|
117
|
+
Theta_0 = theta[0]
|
|
118
|
+
w0 = amp[0] * amp[0] # m² · |R|² at m=1
|
|
119
|
+
f_hat = Theta_0 * fs / (two_pi * M_val)
|
|
120
|
+
w_sum = w0 if w0 > 1e-30 else 1e-30
|
|
121
|
+
wf_sum = w_sum * f_hat
|
|
122
|
+
|
|
123
|
+
for m_idx in range(1, L):
|
|
124
|
+
m_val = float(m_idx + 1)
|
|
125
|
+
predicted = two_pi * f_hat * M_val * m_val / fs
|
|
126
|
+
diff = predicted - theta[m_idx]
|
|
127
|
+
# round() is the C math round - unboxed, no Python overhead
|
|
128
|
+
correction = round(diff / two_pi)
|
|
129
|
+
Theta_m = theta[m_idx] + two_pi * correction
|
|
130
|
+
f_m = Theta_m * fs / (two_pi * m_val * M_val)
|
|
131
|
+
w_m = m_val * m_val * amp[m_idx] * amp[m_idx]
|
|
132
|
+
w_sum += w_m
|
|
133
|
+
wf_sum += w_m * f_m
|
|
134
|
+
f_hat = wf_sum / w_sum
|
|
135
|
+
|
|
136
|
+
return f_hat
|
|
137
|
+
|
|
138
|
+
_NUMBA_MM["mm"] = _mm_bootstrap_loop
|
|
139
|
+
|
|
140
|
+
return _NUMBA_MM["mm"]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def estimate_frequency_offset_mth_power(
|
|
144
|
+
samples: ArrayType,
|
|
145
|
+
sampling_rate: float,
|
|
146
|
+
modulation: str,
|
|
147
|
+
order: int,
|
|
148
|
+
search_range: tuple[float, float] | None = None,
|
|
149
|
+
nfft: int | None = None,
|
|
150
|
+
interpolation: str = "jacobsen",
|
|
151
|
+
combine_channels: bool = False,
|
|
152
|
+
debug_plot: bool = False,
|
|
153
|
+
) -> float | np.ndarray:
|
|
154
|
+
"""
|
|
155
|
+
Estimates frequency offset using the M-th power law (nonlinear spectral method).
|
|
156
|
+
|
|
157
|
+
Raises the signal to the M-th power to eliminate PSK/QAM modulation,
|
|
158
|
+
producing a tone at M·Δf. A spectral peak search with sub-bin
|
|
159
|
+
interpolation gives frequency resolution well below the FFT bin width.
|
|
160
|
+
|
|
161
|
+
Parameters
|
|
162
|
+
----------
|
|
163
|
+
samples : array_like
|
|
164
|
+
Complex IQ samples. Shape: (N,) or (C, N). For MIMO, channel
|
|
165
|
+
magnitude spectra are summed for robust peak detection, then
|
|
166
|
+
per-channel sub-bin interpolation is applied at the shared peak bin.
|
|
167
|
+
sampling_rate : float
|
|
168
|
+
Sampling rate in Hz.
|
|
169
|
+
modulation : str
|
|
170
|
+
Modulation scheme (case-insensitive): 'psk', 'qam', 'bpsk', etc.
|
|
171
|
+
order : int
|
|
172
|
+
Modulation order (2, 4, 16, 64, ...).
|
|
173
|
+
search_range : tuple of float, optional
|
|
174
|
+
``(f_min, f_max)`` in Hz to limit the frequency offset search.
|
|
175
|
+
The spectral search is mapped to ``[M·f_min, M·f_max]``.
|
|
176
|
+
Default: full spectrum.
|
|
177
|
+
nfft : int, optional
|
|
178
|
+
FFT size. Default: next power of 2 ≥ len(samples).
|
|
179
|
+
interpolation : {'jacobsen', 'parabolic'}, default 'jacobsen'
|
|
180
|
+
Sub-bin interpolation method.
|
|
181
|
+
|
|
182
|
+
* ``'jacobsen'``: uses complex FFT values around the peak bin -
|
|
183
|
+
corrects rectangular-window sinc bias analytically. More
|
|
184
|
+
accurate than parabolic for short observation windows.
|
|
185
|
+
* ``'parabolic'``: classic parabolic fit on FFT magnitudes.
|
|
186
|
+
combine_channels : bool, default False
|
|
187
|
+
For MIMO inputs (C > 1): if ``True``, return a single
|
|
188
|
+
magnitude-weighted mean estimate as ``float``; if ``False``
|
|
189
|
+
(default), return per-channel estimates as ``np.ndarray`` of
|
|
190
|
+
shape ``(C,)``. SISO inputs always return ``float``.
|
|
191
|
+
debug_plot : bool, default False
|
|
192
|
+
If ``True``, opens a diagnostic figure showing the M-th power
|
|
193
|
+
spectrum per channel with the detected peak and sub-bin result.
|
|
194
|
+
|
|
195
|
+
Returns
|
|
196
|
+
-------
|
|
197
|
+
float or np.ndarray
|
|
198
|
+
Estimated frequency offset in Hz. For SISO or when
|
|
199
|
+
``combine_channels=True``: scalar ``float``. For MIMO with
|
|
200
|
+
``combine_channels=False``: ``np.ndarray`` of shape ``(C,)``
|
|
201
|
+
with one estimate per channel.
|
|
202
|
+
|
|
203
|
+
Notes
|
|
204
|
+
-----
|
|
205
|
+
M is determined by the modulation type:
|
|
206
|
+
|
|
207
|
+
- PSK / BPSK: M = ``order`` (e.g. 4 for QPSK, 8 for 8-PSK).
|
|
208
|
+
- PSK / BPSK: M = order (e.g. 4 for QPSK, 8 for 8-PSK).
|
|
209
|
+
- QAM: M = 4 (the 4th power removes quadrature phase; residual
|
|
210
|
+
amplitude modulation is suppressed by subtracting the per-channel
|
|
211
|
+
mean of signal^M before the FFT).
|
|
212
|
+
|
|
213
|
+
Lock range: [-fs/(2M), fs/(2M)]. For QPSK at 1 GHz -> +/- 125 MHz.
|
|
214
|
+
Use search_range to reduce false-peak probability.
|
|
215
|
+
|
|
216
|
+
Jacobsen interpolation:
|
|
217
|
+
|
|
218
|
+
delta = Re[ (X[k-1] - X[k+1]) / (2*X[k] - X[k-1] - X[k+1]) ]
|
|
219
|
+
|
|
220
|
+
where X are complex FFT values at the peak bin k and its
|
|
221
|
+
neighbours. Unlike the parabolic fit (which operates on magnitudes
|
|
222
|
+
and has a sinc-function bias for small NFFT), the Jacobsen estimator
|
|
223
|
+
is unbiased for a rectangular window.
|
|
224
|
+
"""
|
|
225
|
+
samples, xp, _ = dispatch(samples)
|
|
226
|
+
was_1d = samples.ndim == 1
|
|
227
|
+
if was_1d:
|
|
228
|
+
samples = samples[None, :] # (1, N)
|
|
229
|
+
C, N = samples.shape
|
|
230
|
+
|
|
231
|
+
if N < 8:
|
|
232
|
+
raise ValueError(
|
|
233
|
+
f"Signal too short for spectral FOE (N={N}). Minimum 8 samples required."
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
M = _modulation_power_m(modulation, order)
|
|
237
|
+
mod_lower = modulation.lower()
|
|
238
|
+
|
|
239
|
+
if "qam" in mod_lower and order >= 64:
|
|
240
|
+
logger.warning(
|
|
241
|
+
"QAM order %s: M-th power lock range is ±fs/8 = ±%.0f Hz and AM spectral spreading reduces accuracy for high-order constellations. Consider estimate_frequency_offset_mengali_morelli (blind) or find_bias_tone (pilot tone).",
|
|
242
|
+
order,
|
|
243
|
+
sampling_rate / 8,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if nfft is None:
|
|
247
|
+
nfft = 1 << int(np.ceil(np.log2(N))) # guaranteed Python int
|
|
248
|
+
|
|
249
|
+
# Promote for numerical accuracy during power computation: (C, N)
|
|
250
|
+
s_c = samples.astype(
|
|
251
|
+
xp.complex128 if samples.dtype == xp.complex64 else samples.dtype
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# M-th power removes modulation -> tone at M·Δf; shape stays (C, N)
|
|
255
|
+
x_M = s_c**M
|
|
256
|
+
|
|
257
|
+
# For QAM: subtract per-channel mean to suppress DC spike from amplitude residuals
|
|
258
|
+
if M == 4 and "qam" in mod_lower:
|
|
259
|
+
x_M = x_M - xp.mean(x_M, axis=-1, keepdims=True)
|
|
260
|
+
|
|
261
|
+
# Batched FFT across all channels: single kernel call on GPU -> (C, nfft)
|
|
262
|
+
X_M = xp.fft.fft(x_M, n=nfft, axis=-1)
|
|
263
|
+
# CPU freq array; device version allocated only when search_range masking is needed.
|
|
264
|
+
freqs_np = np.fft.fftfreq(nfft, d=1.0 / sampling_rate) # (nfft,) - always on CPU
|
|
265
|
+
|
|
266
|
+
mag = xp.abs(X_M) # (C, nfft)
|
|
267
|
+
|
|
268
|
+
# Restrict search to [M·f_min, M·f_max] when search_range is given;
|
|
269
|
+
# do this BEFORE zeroing DC so the mask operates on the raw spectrum.
|
|
270
|
+
if search_range is not None:
|
|
271
|
+
tone_lo = M * min(search_range)
|
|
272
|
+
tone_hi = M * max(search_range)
|
|
273
|
+
freqs = xp.asarray(freqs_np) # device version only when mask is needed
|
|
274
|
+
mask = (freqs >= tone_lo) & (freqs <= tone_hi) # (nfft,)
|
|
275
|
+
if not bool(xp.any(mask)):
|
|
276
|
+
raise ValueError(
|
|
277
|
+
f"search_range {search_range} Hz produces an empty search "
|
|
278
|
+
f"window in the M={M} scaled spectrum."
|
|
279
|
+
)
|
|
280
|
+
mag = xp.where(mask[None, :], mag, xp.zeros_like(mag))
|
|
281
|
+
|
|
282
|
+
# Zero DC after masking (Δf = 0 is degenerate; also removes any residual
|
|
283
|
+
# QAM DC not caught by mean subtraction above).
|
|
284
|
+
mag[:, 0] = 0.0
|
|
285
|
+
|
|
286
|
+
# For MIMO, sum spectra across channels for coherent accumulation.
|
|
287
|
+
# For SISO this is a no-op (C=1).
|
|
288
|
+
mag_combined = xp.sum(mag, axis=0) # (nfft,)
|
|
289
|
+
|
|
290
|
+
k_peak = int(xp.argmax(mag_combined))
|
|
291
|
+
k_prev = (k_peak - 1) % nfft # circular - correct at Nyquist edge (k=nfft-1)
|
|
292
|
+
k_next = (k_peak + 1) % nfft
|
|
293
|
+
|
|
294
|
+
# Vectorised sub-bin interpolation across all C channels at the shared peak bin.
|
|
295
|
+
# Extract 3 neighbouring bins for all channels at once -> 1 device->host transfer
|
|
296
|
+
# instead of 3C individual scalar round-trips.
|
|
297
|
+
X_km1 = X_M[:, k_prev] # (C,) complex, on device
|
|
298
|
+
X_k0 = X_M[:, k_peak]
|
|
299
|
+
X_kp1 = X_M[:, k_next]
|
|
300
|
+
|
|
301
|
+
if interpolation == "jacobsen":
|
|
302
|
+
d_vec = 2.0 * X_k0 - X_km1 - X_kp1 # (C,) complex
|
|
303
|
+
safe_d = xp.where(xp.abs(d_vec) > 1e-30, d_vec, xp.ones_like(d_vec))
|
|
304
|
+
mu_raw = ((X_km1 - X_kp1) / safe_d).real # (C,) float
|
|
305
|
+
mu_vec = xp.clip(
|
|
306
|
+
xp.where(xp.abs(d_vec) > 1e-30, mu_raw, xp.zeros_like(mu_raw)),
|
|
307
|
+
-0.5,
|
|
308
|
+
0.5,
|
|
309
|
+
)
|
|
310
|
+
else: # parabolic - use the already-computed magnitude array
|
|
311
|
+
a_vec = mag[:, k_prev] # (C,) float
|
|
312
|
+
b_vec = mag[:, k_peak]
|
|
313
|
+
cc_vec = mag[:, k_next]
|
|
314
|
+
d_vec = a_vec - 2.0 * b_vec + cc_vec
|
|
315
|
+
safe_d = xp.where(xp.abs(d_vec) > 1e-15, d_vec, xp.ones_like(d_vec))
|
|
316
|
+
mu_raw = 0.5 * (a_vec - cc_vec) / safe_d
|
|
317
|
+
mu_vec = xp.clip(
|
|
318
|
+
xp.where(xp.abs(d_vec) > 1e-15, mu_raw, xp.zeros_like(mu_raw)),
|
|
319
|
+
-0.5,
|
|
320
|
+
0.5,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
mu_np = to_device(mu_vec, "cpu") # one transfer: (C,) floats
|
|
324
|
+
f_per_ch = [
|
|
325
|
+
float((freqs_np[k_peak] + float(mu_np[c]) * (sampling_rate / nfft)) / M)
|
|
326
|
+
for c in range(C)
|
|
327
|
+
]
|
|
328
|
+
|
|
329
|
+
logger.info(
|
|
330
|
+
"FOE (M-th power, M=%s): %s Hz [nfft=%s, interp=%s, search_range=%s]",
|
|
331
|
+
M,
|
|
332
|
+
[f"{f:.2f}" for f in f_per_ch],
|
|
333
|
+
nfft,
|
|
334
|
+
interpolation,
|
|
335
|
+
search_range,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
if debug_plot:
|
|
339
|
+
from . import plotting as _plotting
|
|
340
|
+
|
|
341
|
+
_plotting.plot_frequency_offset_spectrum(
|
|
342
|
+
mag_spectrum=to_device(mag, "cpu"),
|
|
343
|
+
freqs=freqs_np,
|
|
344
|
+
M=M,
|
|
345
|
+
k_peaks=np.array([k_peak] * C),
|
|
346
|
+
f_estimates=f_per_ch,
|
|
347
|
+
search_range=search_range,
|
|
348
|
+
show=True,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
if was_1d:
|
|
352
|
+
return float(f_per_ch[0])
|
|
353
|
+
if combine_channels:
|
|
354
|
+
weights = to_device(mag[:, k_peak], "cpu").tolist() # one batch transfer
|
|
355
|
+
combined = float(np.average(f_per_ch, weights=weights))
|
|
356
|
+
logger.info(
|
|
357
|
+
"FOE (M-th power, M=%s): combined=%.2f Hz (magnitude-weighted mean of %s channels)",
|
|
358
|
+
M,
|
|
359
|
+
combined,
|
|
360
|
+
C,
|
|
361
|
+
)
|
|
362
|
+
return combined
|
|
363
|
+
return np.array(f_per_ch)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def estimate_frequency_offset_mengali_morelli(
|
|
367
|
+
samples: ArrayType,
|
|
368
|
+
sampling_rate: float,
|
|
369
|
+
modulation: str | None = None,
|
|
370
|
+
order: int | None = None,
|
|
371
|
+
ref_signal: ArrayType | None = None,
|
|
372
|
+
max_lag: int | None = None,
|
|
373
|
+
combine_channels: bool = False,
|
|
374
|
+
debug_plot: bool = False,
|
|
375
|
+
) -> float | np.ndarray:
|
|
376
|
+
"""
|
|
377
|
+
Estimates frequency offset via the Mengali-Morelli multi-lag autocorrelation.
|
|
378
|
+
|
|
379
|
+
Uses multiple autocorrelation lags m = 1 ... L combined with MVUE weights
|
|
380
|
+
to extend the lock range to the full Nyquist interval [-fs/2, fs/2]
|
|
381
|
+
while remaining Cramér-Rao-efficient. This is the recommended estimator
|
|
382
|
+
when the frequency offset may be large (exceeding the Kay / differential
|
|
383
|
+
lock range of fs/(2M)) and a pilot or data-aided reference is
|
|
384
|
+
available for pre-processing.
|
|
385
|
+
|
|
386
|
+
Three input modes:
|
|
387
|
+
|
|
388
|
+
1. **Data-aided** (``ref_signal`` provided): derotates samples by the
|
|
389
|
+
known reference before estimating.
|
|
390
|
+
2. **Blind M-PSK/QAM** (``modulation`` + ``order``): applies M-th power
|
|
391
|
+
pre-processing. Lock range remains [-fs/2, fs/2] for all lags
|
|
392
|
+
after bootstrap unwrapping from lag 1.
|
|
393
|
+
3. **Generic blind** (no arguments): assumes a constant-envelope signal.
|
|
394
|
+
|
|
395
|
+
Parameters
|
|
396
|
+
----------
|
|
397
|
+
samples : array_like
|
|
398
|
+
Complex IQ samples. Shape: (N,) or (C, N).
|
|
399
|
+
sampling_rate : float
|
|
400
|
+
Sampling rate in Hz.
|
|
401
|
+
modulation : str, optional
|
|
402
|
+
Modulation type (case-insensitive). Required for blind M-th power mode.
|
|
403
|
+
Ignored when ``ref_signal`` is provided.
|
|
404
|
+
order : int, optional
|
|
405
|
+
Modulation order. Required with ``modulation`` for blind mode.
|
|
406
|
+
ref_signal : array_like, optional
|
|
407
|
+
Ideal transmitted waveform used to derotate ``samples`` before
|
|
408
|
+
autocorrelation. Computes ``y[n] = samples[n] * conj(ref[n])``,
|
|
409
|
+
cancelling the data modulation and leaving a tone at delta_f.
|
|
410
|
+
Must be sample-aligned with ``samples`` (call ``estimate_timing``
|
|
411
|
+
first). Shape: ``(N,)``, ``(1, N)``, or ``(C, N)``.
|
|
412
|
+
max_lag : int, optional
|
|
413
|
+
Maximum autocorrelation lag L. Default: ``N // 4``, clamped to
|
|
414
|
+
``[1, N // 2]``. Increasing L improves noise averaging at the cost
|
|
415
|
+
of using shorter sub-sequences for each lag.
|
|
416
|
+
combine_channels : bool, default False
|
|
417
|
+
For MIMO inputs (C > 1): if ``True``, return a single
|
|
418
|
+
autocorrelation-energy-weighted mean estimate as ``float``; if
|
|
419
|
+
``False`` (default), return per-channel estimates as
|
|
420
|
+
``np.ndarray`` of shape ``(C,)``.
|
|
421
|
+
SISO inputs always return ``float``.
|
|
422
|
+
debug_plot : bool, default False
|
|
423
|
+
If ``True``, opens a diagnostic figure showing per-channel
|
|
424
|
+
autocorrelation magnitude ``|R[m]|`` and wrapped phase ``∠R[m]``
|
|
425
|
+
vs lag, with the expected phase ramp overlaid.
|
|
426
|
+
|
|
427
|
+
Returns
|
|
428
|
+
-------
|
|
429
|
+
float or np.ndarray
|
|
430
|
+
Estimated frequency offset in Hz. For SISO or when
|
|
431
|
+
``combine_channels=True``: scalar ``float``. For MIMO with
|
|
432
|
+
``combine_channels=False``: ``np.ndarray`` of shape ``(C,)``
|
|
433
|
+
with one estimate per channel.
|
|
434
|
+
|
|
435
|
+
Notes
|
|
436
|
+
-----
|
|
437
|
+
Computes the unbiased autocorrelation R[m] at lags 1 ... L via the
|
|
438
|
+
Wiener-Khinchin theorem (2 FFTs), bootstraps the phase from lag 1 to
|
|
439
|
+
resolve 2*pi ambiguities at higher lags, then combines per-lag estimates
|
|
440
|
+
with m^2 * |R[m]|^2 MVUE weights.
|
|
441
|
+
|
|
442
|
+
Lock range: [-fs/(2M), fs/(2M)] for blind M-th power mode;
|
|
443
|
+
[-fs/2, fs/2] for data-aided or generic blind mode.
|
|
444
|
+
"""
|
|
445
|
+
samples, xp, _ = dispatch(samples)
|
|
446
|
+
was_1d = samples.ndim == 1
|
|
447
|
+
if was_1d:
|
|
448
|
+
samples = samples[None, :] # (1, N)
|
|
449
|
+
C, N = samples.shape
|
|
450
|
+
|
|
451
|
+
# Pre-process: data-aided, blind M-th power, or generic blind
|
|
452
|
+
if ref_signal is not None:
|
|
453
|
+
ref, _, _ = dispatch(ref_signal)
|
|
454
|
+
if ref.ndim == 1:
|
|
455
|
+
ref = ref[None, :]
|
|
456
|
+
ref = xp.asarray(ref)
|
|
457
|
+
y = samples * xp.conj(ref) # derotate -> complex tone at Δf
|
|
458
|
+
M = 1
|
|
459
|
+
elif modulation is not None and order is not None:
|
|
460
|
+
M = _modulation_power_m(modulation, order)
|
|
461
|
+
y = samples**M # removes PSK/QAM modulation
|
|
462
|
+
else:
|
|
463
|
+
y = samples
|
|
464
|
+
M = 1
|
|
465
|
+
|
|
466
|
+
# Choose max_lag L
|
|
467
|
+
L = max_lag if max_lag is not None else N // 4
|
|
468
|
+
L = max(1, min(L, N // 2))
|
|
469
|
+
|
|
470
|
+
# Autocorrelation at all lags 1..L via the Wiener-Khinchin theorem.
|
|
471
|
+
# IFFT(|FFT(y)|²)[l] = Σ_n conj(y[n]) · y[n+l] (linear, not circular,
|
|
472
|
+
# when zero-padded to nfft_r ≥ N+L).
|
|
473
|
+
# This replaces a Python loop of L GPU kernel launches with 2 FFT calls,
|
|
474
|
+
# keeping all heavy computation on the device (GPU or CPU backend).
|
|
475
|
+
nfft_r = 1 << int(np.ceil(np.log2(N + L))) # smallest power-of-2 ≥ N+L
|
|
476
|
+
Y_r = xp.fft.fft(y, n=nfft_r, axis=-1) # (C, nfft_r)
|
|
477
|
+
R_all = xp.fft.ifft(Y_r * xp.conj(Y_r), axis=-1) # (C, nfft_r) per-ch autocorr
|
|
478
|
+
|
|
479
|
+
# Unbiased per-channel autocorrelation at lags 1..L: R_per_ch[c, m-1] = R[c, m] / (N-m)
|
|
480
|
+
lags_xp = xp.arange(1, L + 1, dtype=xp.float64) # (L,) on device
|
|
481
|
+
R_per_ch = R_all[:, 1 : L + 1] / (N - lags_xp[None, :]) # (C, L)
|
|
482
|
+
|
|
483
|
+
# Transfer R_per_ch (C, L) to CPU once; run the iterative bootstrap per channel.
|
|
484
|
+
# The bootstrap is a sequential scan (each step depends on the previous),
|
|
485
|
+
# so Numba on CPU beats GPU here. Single transfer avoids repeated device round-trips.
|
|
486
|
+
R_per_ch_np = to_device(R_per_ch, "cpu") # (C, L) complex128
|
|
487
|
+
|
|
488
|
+
_mm_kernel = _get_numba_mm_bootstrap()
|
|
489
|
+
f_per_ch = []
|
|
490
|
+
for c_idx in range(C):
|
|
491
|
+
R_c_np = R_per_ch_np[c_idx] # (L,)
|
|
492
|
+
theta_c = np.angle(R_c_np)
|
|
493
|
+
amp_c = np.abs(R_c_np)
|
|
494
|
+
f_per_ch.append(
|
|
495
|
+
float(_mm_kernel(theta_c, amp_c, float(M), float(sampling_rate)))
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
mode_str = "data-aided" if ref_signal is not None else f"blind M={M}"
|
|
499
|
+
logger.info(
|
|
500
|
+
"FOE (Mengali-Morelli, %s): %s Hz [L=%s lags, N=%s]",
|
|
501
|
+
mode_str,
|
|
502
|
+
[f"{f:.2f}" for f in f_per_ch],
|
|
503
|
+
L,
|
|
504
|
+
N,
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
if debug_plot:
|
|
508
|
+
from . import plotting as _plotting
|
|
509
|
+
|
|
510
|
+
_plotting.plot_mm_autocorrelation(
|
|
511
|
+
R_np=R_per_ch_np,
|
|
512
|
+
f_est=f_per_ch,
|
|
513
|
+
sampling_rate=sampling_rate,
|
|
514
|
+
M=M,
|
|
515
|
+
show=True,
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
if was_1d:
|
|
519
|
+
return float(f_per_ch[0])
|
|
520
|
+
if combine_channels:
|
|
521
|
+
# Weight by total autocorrelation energy per channel
|
|
522
|
+
weights = [float(np.sum(np.abs(R_per_ch_np[c]) ** 2)) for c in range(C)]
|
|
523
|
+
combined = float(np.average(f_per_ch, weights=weights))
|
|
524
|
+
logger.info(
|
|
525
|
+
"FOE (Mengali-Morelli, %s): combined=%.2f Hz (autocorrelation-weighted mean of %s channels)",
|
|
526
|
+
mode_str,
|
|
527
|
+
combined,
|
|
528
|
+
C,
|
|
529
|
+
)
|
|
530
|
+
return combined
|
|
531
|
+
return np.array(f_per_ch)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def estimate_frequency_offset_pilot_symbols(
|
|
535
|
+
samples: ArrayType,
|
|
536
|
+
sampling_rate: float,
|
|
537
|
+
pilot_indices: ArrayType,
|
|
538
|
+
pilot_values: ArrayType,
|
|
539
|
+
snr_weighted: bool = True,
|
|
540
|
+
combine_channels: bool = False,
|
|
541
|
+
debug_plot: bool = False,
|
|
542
|
+
) -> float | np.ndarray:
|
|
543
|
+
"""
|
|
544
|
+
Estimates frequency offset from pilot symbols via phase slope fitting.
|
|
545
|
+
|
|
546
|
+
Extracts the received phase at each pilot position, demodulates against
|
|
547
|
+
the known pilot values to obtain the residual phase, unwraps the pilot
|
|
548
|
+
phase sequence, then fits a (optionally SNR-weighted) least-squares line
|
|
549
|
+
to the unwrapped phase as a function of pilot sample time. The slope
|
|
550
|
+
gives the frequency offset: Δf = slope / (2π).
|
|
551
|
+
|
|
552
|
+
Parameters
|
|
553
|
+
----------
|
|
554
|
+
samples : array_like
|
|
555
|
+
Received complex samples. Shape: (N,) or (C, N).
|
|
556
|
+
sampling_rate : float
|
|
557
|
+
Sampling rate in Hz.
|
|
558
|
+
pilot_indices : array_like of int
|
|
559
|
+
Sample indices of pilot positions in increasing order. Shape: (P,).
|
|
560
|
+
Must be unique and sorted. Supports any pilot arrangement:
|
|
561
|
+
|
|
562
|
+
* **Comb (scattered):** uniform grid, e.g. every 16th sample.
|
|
563
|
+
Lock range determined by comb spacing.
|
|
564
|
+
* **Block (contiguous cluster):** e.g. ``[0, 1, ..., L-1]``.
|
|
565
|
+
* **Multi-block:** e.g. a front preamble and a mid-burst pilot
|
|
566
|
+
cluster. Lock range is set by the **largest gap** between any
|
|
567
|
+
two consecutive pilot indices (see Notes).
|
|
568
|
+
pilot_values : array_like
|
|
569
|
+
Known transmitted pilot symbols at the corresponding indices.
|
|
570
|
+
Shape: (P,) for shared pilots (broadcast to all MIMO channels),
|
|
571
|
+
or (C, P) for per-channel pilots.
|
|
572
|
+
snr_weighted : bool, default True
|
|
573
|
+
If ``True``, weights each pilot by its received power ``|r|²``
|
|
574
|
+
(SNR proxy) in the least-squares phase-slope fit. This is the
|
|
575
|
+
**WLSQ** estimator and is significantly more robust when pilot SNR
|
|
576
|
+
varies across the block (e.g. due to PMD nulls or spectral ripple).
|
|
577
|
+
Set to ``False`` for the standard unweighted OLS slope.
|
|
578
|
+
combine_channels : bool, default False
|
|
579
|
+
For MIMO inputs (C > 1): if ``True``, return a single
|
|
580
|
+
pilot-power-weighted mean estimate as ``float``; if ``False``
|
|
581
|
+
(default), return per-channel estimates as ``np.ndarray`` of
|
|
582
|
+
shape ``(C,)``.
|
|
583
|
+
SISO inputs always return ``float``.
|
|
584
|
+
debug_plot : bool, default False
|
|
585
|
+
If ``True``, opens a diagnostic figure showing the unwrapped pilot
|
|
586
|
+
phase sequence and the fitted frequency-slope line.
|
|
587
|
+
|
|
588
|
+
Returns
|
|
589
|
+
-------
|
|
590
|
+
float or np.ndarray
|
|
591
|
+
Estimated frequency offset in Hz. For SISO or when
|
|
592
|
+
``combine_channels=True``: scalar ``float``. For MIMO with
|
|
593
|
+
``combine_channels=False``: ``np.ndarray`` of shape ``(C,)``
|
|
594
|
+
with one estimate per channel.
|
|
595
|
+
|
|
596
|
+
Notes
|
|
597
|
+
-----
|
|
598
|
+
The demodulated pilot phase follows:
|
|
599
|
+
|
|
600
|
+
phi_hat[k] = 2*pi * delta_f * t_k + phi_0 + noise
|
|
601
|
+
|
|
602
|
+
where t_k = pilot_indices[k] / f_s.
|
|
603
|
+
|
|
604
|
+
**Unweighted (OLS):** the minimum-variance unbiased estimator for
|
|
605
|
+
equal-noise pilots:
|
|
606
|
+
|
|
607
|
+
delta_f_hat = (1 / (2*pi)) * sum_k ((t_k - t_bar) * (phi_hat[k] - phi_bar)) / sum_k ((t_k - t_bar)^2)
|
|
608
|
+
|
|
609
|
+
**SNR-weighted (WLSQ):** pilots are weighted by received power
|
|
610
|
+
v_k = |r_k|^2 (normalised to unit mean), giving:
|
|
611
|
+
|
|
612
|
+
delta_f_hat = (1 / (2*pi)) * sum_k (v_k * (t_k - t_bar_v) * (phi_hat[k] - phi_bar_v)) / sum_k (v_k * (t_k - t_bar_v)^2)
|
|
613
|
+
|
|
614
|
+
where t_bar_v and phi_bar_v are the weighted means. This reduces variance by 30-50 % when pilot SNR
|
|
615
|
+
varies significantly across the burst.
|
|
616
|
+
|
|
617
|
+
**Lock range:** xp.unwrap bridges each gap between consecutive
|
|
618
|
+
pilot indices. The gap that limits the lock range is the largest one:
|
|
619
|
+
|
|
620
|
+
|delta_f| < f_s / (2 * max_gap)
|
|
621
|
+
|
|
622
|
+
where ``max_gap`` is the maximum spacing (in samples) between any two
|
|
623
|
+
consecutive entries of ``pilot_indices``.
|
|
624
|
+
"""
|
|
625
|
+
samples, xp, _ = dispatch(samples)
|
|
626
|
+
was_1d = samples.ndim == 1
|
|
627
|
+
if was_1d:
|
|
628
|
+
samples = samples[None, :]
|
|
629
|
+
C, N = samples.shape
|
|
630
|
+
|
|
631
|
+
pilot_indices_np = to_device(pilot_indices, "cpu").astype(np.intp)
|
|
632
|
+
pilot_indices_xp = xp.asarray(pilot_indices_np) # device copy for GPU fancy-index
|
|
633
|
+
pilot_values_xp = xp.asarray(pilot_values)
|
|
634
|
+
P = len(pilot_indices_np)
|
|
635
|
+
|
|
636
|
+
if pilot_values_xp.ndim == 1:
|
|
637
|
+
pilot_values_xp = xp.broadcast_to(pilot_values_xp[None, :], (C, P))
|
|
638
|
+
|
|
639
|
+
# Extract and demodulate: phase = angle(r · conj(s)) = 2π·Δf·t + φ₀ + noise
|
|
640
|
+
r_pilots = samples[:, pilot_indices_xp] # (C, P)
|
|
641
|
+
phi_pilots = xp.angle(r_pilots * xp.conj(pilot_values_xp)) # (C, P)
|
|
642
|
+
|
|
643
|
+
# Unwrap in float64; cp.unwrap preserves input dtype so cast before calling
|
|
644
|
+
phi_pilots_u = xp.unwrap(phi_pilots.astype(xp.float64), axis=-1) # (C, P)
|
|
645
|
+
|
|
646
|
+
t_xp = xp.asarray(pilot_indices_np.astype(np.float64)) / sampling_rate # (P,)
|
|
647
|
+
|
|
648
|
+
if snr_weighted:
|
|
649
|
+
# WLSQ: weight each pilot by received power |r|² independently per channel.
|
|
650
|
+
# Per-channel weights (C, P) handle independent spatial fading correctly;
|
|
651
|
+
# when all channels have equal SNR the weights reduce to the shared case.
|
|
652
|
+
pwr = xp.abs(r_pilots) ** 2 # (C, P)
|
|
653
|
+
pwr_mean = xp.mean(pwr, axis=-1, keepdims=True) # (C, 1)
|
|
654
|
+
v = pwr / (pwr_mean + 1e-30) # (C, P) normalised per-channel weights
|
|
655
|
+
|
|
656
|
+
# Per-channel weighted means
|
|
657
|
+
v_sum = xp.sum(v, axis=-1, keepdims=True) # (C, 1)
|
|
658
|
+
t_mean_v = xp.sum(v * t_xp[None, :], axis=-1, keepdims=True) / v_sum # (C, 1)
|
|
659
|
+
t_c = t_xp[None, :] - t_mean_v # (C, P) centred times
|
|
660
|
+
phi_mean_v = xp.sum(v * phi_pilots_u, axis=-1, keepdims=True) / v_sum # (C, 1)
|
|
661
|
+
phi_c = phi_pilots_u - phi_mean_v # (C, P)
|
|
662
|
+
|
|
663
|
+
# Per-channel weighted normal equations: slope[c] = Σ_p v[c,p]·t_c[c,p]·φ_c[c,p]
|
|
664
|
+
# / Σ_p v[c,p]·t_c[c,p]²
|
|
665
|
+
t_var_w = xp.sum(v * t_c**2, axis=-1) # (C,)
|
|
666
|
+
safe_denom = xp.where(xp.abs(t_var_w) > 1e-30, t_var_w, xp.ones_like(t_var_w))
|
|
667
|
+
slopes = xp.sum(v * phi_c * t_c, axis=-1) / safe_denom # (C,)
|
|
668
|
+
else:
|
|
669
|
+
# Unweighted OLS: centered normal equations (Tretter 1985 MVUE).
|
|
670
|
+
t_c = t_xp - xp.mean(t_xp) # (P,) centred
|
|
671
|
+
t_var = float(xp.dot(t_c, t_c)) # scalar Σ(t-t̄)²
|
|
672
|
+
phi_c = phi_pilots_u - xp.mean(phi_pilots_u, axis=-1, keepdims=True) # (C, P)
|
|
673
|
+
slopes = xp.sum(phi_c * t_c[None, :], axis=-1) / t_var # (C,)
|
|
674
|
+
|
|
675
|
+
max_gap = int(np.max(np.diff(pilot_indices_np))) if P > 1 else 0
|
|
676
|
+
lock_range = sampling_rate / (2 * max_gap) if max_gap > 0 else float("inf")
|
|
677
|
+
wt_str = "WLSQ" if snr_weighted else "OLS"
|
|
678
|
+
|
|
679
|
+
slopes_np = to_device(slopes, "cpu") # one batched D2H instead of C syncs
|
|
680
|
+
f_per_ch = [float(slopes_np[c]) / (2.0 * np.pi) for c in range(C)]
|
|
681
|
+
|
|
682
|
+
logger.info(
|
|
683
|
+
"FOE (pilots, %s): %s Hz [P=%s, max_gap=%s samples, lock_range=±%.1f Hz]",
|
|
684
|
+
wt_str,
|
|
685
|
+
[f"{f:.2f}" for f in f_per_ch],
|
|
686
|
+
P,
|
|
687
|
+
max_gap,
|
|
688
|
+
lock_range,
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
if debug_plot:
|
|
692
|
+
from . import plotting as _plotting
|
|
693
|
+
|
|
694
|
+
_plotting.plot_pilot_phase_estimate(
|
|
695
|
+
pilot_indices=pilot_indices_np,
|
|
696
|
+
phi_pilots_u=to_device(phi_pilots_u, "cpu"),
|
|
697
|
+
f_est=f_per_ch,
|
|
698
|
+
sampling_rate=sampling_rate,
|
|
699
|
+
show=True,
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
if was_1d:
|
|
703
|
+
return float(f_per_ch[0])
|
|
704
|
+
if combine_channels:
|
|
705
|
+
# Weight by mean received pilot power per channel
|
|
706
|
+
pwr_per_ch = to_device(xp.mean(xp.abs(r_pilots) ** 2, axis=-1), "cpu") # (C,)
|
|
707
|
+
weights = [float(pwr_per_ch[c]) for c in range(C)]
|
|
708
|
+
combined = float(np.average(f_per_ch, weights=weights))
|
|
709
|
+
logger.info(
|
|
710
|
+
"FOE (pilots, %s): combined=%.2f Hz (pilot-power-weighted mean of %s channels)",
|
|
711
|
+
wt_str,
|
|
712
|
+
combined,
|
|
713
|
+
C,
|
|
714
|
+
)
|
|
715
|
+
return combined
|
|
716
|
+
return np.array(f_per_ch)
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def find_bias_tone(
|
|
720
|
+
seg: ArrayType,
|
|
721
|
+
sampling_rate: float,
|
|
722
|
+
target_frequency: float | None = None,
|
|
723
|
+
search_band: float | None = None,
|
|
724
|
+
) -> float:
|
|
725
|
+
"""
|
|
726
|
+
Locate a CW pilot / bias tone in the spectrum of a 1-D complex segment.
|
|
727
|
+
|
|
728
|
+
Finds the spectral peak in ``seg`` (optionally restricted to a search
|
|
729
|
+
window) and refines its frequency via **log-parabolic sub-bin
|
|
730
|
+
interpolation** on the three bins around the argmax. The result is
|
|
731
|
+
modulation-agnostic: no nonlinear transform of the data is applied,
|
|
732
|
+
so there is no AM-induced spectral spreading regardless of constellation
|
|
733
|
+
order.
|
|
734
|
+
|
|
735
|
+
Parameters
|
|
736
|
+
----------
|
|
737
|
+
seg : array_like
|
|
738
|
+
1-D complex IQ samples. Must reside on a single backend (CPU or GPU).
|
|
739
|
+
sampling_rate : float
|
|
740
|
+
Sampling rate in Hz.
|
|
741
|
+
target_frequency : float, optional
|
|
742
|
+
Centre of the frequency search window in Hz. Must be paired with
|
|
743
|
+
``search_band``. If both are given the argmax is restricted to
|
|
744
|
+
``[target_frequency - search_band, target_frequency + search_band]``.
|
|
745
|
+
Use this after a coarse correction has placed the tone near a known
|
|
746
|
+
position; without it the wideband data signal typically wins the
|
|
747
|
+
argmax.
|
|
748
|
+
search_band : float, optional
|
|
749
|
+
Half-width of the search window in Hz. Must be paired with
|
|
750
|
+
``target_frequency``.
|
|
751
|
+
|
|
752
|
+
Returns
|
|
753
|
+
-------
|
|
754
|
+
float
|
|
755
|
+
Refined bias-tone frequency in Hz (always a Python ``float``).
|
|
756
|
+
|
|
757
|
+
Raises
|
|
758
|
+
------
|
|
759
|
+
ValueError
|
|
760
|
+
If ``seg`` is not 1-D, is shorter than 4 samples, only one of
|
|
761
|
+
``target_frequency`` / ``search_band`` is given, or the search window
|
|
762
|
+
maps to an empty set of FFT bins.
|
|
763
|
+
|
|
764
|
+
Notes
|
|
765
|
+
-----
|
|
766
|
+
Log-parabolic sub-bin interpolation fits the log-magnitude of the
|
|
767
|
+
three bins around the argmax peak k:
|
|
768
|
+
|
|
769
|
+
y_- = log(max(|X[k-1]|, epsilon))
|
|
770
|
+
y_0 = log(max(|X[k]|, epsilon))
|
|
771
|
+
y_+ = log(max(|X[k+1]|, epsilon))
|
|
772
|
+
d = y_- - 2y_0 + y_+
|
|
773
|
+
delta = 0.5 * (y_- - y_+) / d if |d| > epsilon
|
|
774
|
+
f_refined = f[k] + delta * f_s / N
|
|
775
|
+
|
|
776
|
+
Log-parabolic interpolation better matches the Gaussian shape of a
|
|
777
|
+
windowed spectral peak than standard parabolic (magnitude-domain) fits,
|
|
778
|
+
reducing estimation bias for non-integer tone frequencies.
|
|
779
|
+
"""
|
|
780
|
+
if (target_frequency is None) != (search_band is None):
|
|
781
|
+
raise ValueError(
|
|
782
|
+
"target_frequency and search_band must both be provided or both omitted."
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
seg, xp, _ = dispatch(seg)
|
|
786
|
+
if seg.ndim != 1:
|
|
787
|
+
raise ValueError(
|
|
788
|
+
f"find_bias_tone expects a 1-D segment, got shape {seg.shape}."
|
|
789
|
+
)
|
|
790
|
+
N = len(seg)
|
|
791
|
+
if N < 4:
|
|
792
|
+
raise ValueError(
|
|
793
|
+
f"Segment too short for bias tone search (N={N}). Minimum 4 samples required."
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
nfft = 1 << int(np.ceil(np.log2(N))) # next power of 2 ≥ N
|
|
797
|
+
X = xp.fft.fft(seg, n=nfft)
|
|
798
|
+
mag = xp.abs(X) # (nfft,)
|
|
799
|
+
|
|
800
|
+
freqs_np = np.fft.fftfreq(nfft, d=1.0 / sampling_rate) # (nfft,) always on CPU
|
|
801
|
+
|
|
802
|
+
if target_frequency is not None:
|
|
803
|
+
assert search_band is not None
|
|
804
|
+
lo = target_frequency - abs(search_band)
|
|
805
|
+
hi = target_frequency + abs(search_band)
|
|
806
|
+
freqs_xp = xp.asarray(freqs_np)
|
|
807
|
+
mask = (freqs_xp >= lo) & (freqs_xp <= hi)
|
|
808
|
+
if not bool(xp.any(mask)):
|
|
809
|
+
raise ValueError(
|
|
810
|
+
f"target_frequency={target_frequency} ± search_band={search_band} produces an "
|
|
811
|
+
f"empty search window for fs={sampling_rate} Hz, nfft={nfft}."
|
|
812
|
+
)
|
|
813
|
+
mag_search = xp.where(mask, mag, xp.zeros_like(mag))
|
|
814
|
+
else:
|
|
815
|
+
mag_search = mag
|
|
816
|
+
|
|
817
|
+
k = int(xp.argmax(mag_search))
|
|
818
|
+
k_prev = (k - 1) % nfft # circular - correct at spectral edges
|
|
819
|
+
k_next = (k + 1) % nfft
|
|
820
|
+
|
|
821
|
+
# Transfer 3 neighbourhood magnitudes to CPU - scalars, negligible transfer cost
|
|
822
|
+
eps = 1e-300
|
|
823
|
+
ym = np.log(max(float(mag[k_prev]), eps))
|
|
824
|
+
y0 = np.log(max(float(mag[k]), eps))
|
|
825
|
+
yp = np.log(max(float(mag[k_next]), eps))
|
|
826
|
+
|
|
827
|
+
denom = ym - 2.0 * y0 + yp
|
|
828
|
+
delta = 0.5 * (ym - yp) / denom if abs(denom) > 1e-30 else 0.0
|
|
829
|
+
delta = max(-0.5, min(0.5, delta)) # clamp to ±½ bin
|
|
830
|
+
|
|
831
|
+
f_refined = float(freqs_np[k]) + delta * (sampling_rate / nfft)
|
|
832
|
+
|
|
833
|
+
logger.debug(
|
|
834
|
+
"find_bias_tone: peak bin %s (%.2f Hz), delta=%.4f -> %.2f Hz [nfft=%s, window=%s]",
|
|
835
|
+
k,
|
|
836
|
+
freqs_np[k],
|
|
837
|
+
delta,
|
|
838
|
+
f_refined,
|
|
839
|
+
nfft,
|
|
840
|
+
"full" if target_frequency is None else f"{target_frequency}±{search_band} Hz",
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
return float(f_refined)
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _refine_tones_from_spectrum(
|
|
847
|
+
X: ArrayType,
|
|
848
|
+
sampling_rate: float,
|
|
849
|
+
targets,
|
|
850
|
+
search_band: float,
|
|
851
|
+
rows=None,
|
|
852
|
+
) -> np.ndarray:
|
|
853
|
+
"""Batched log-parabolic tone refinement on a precomputed full-record FFT.
|
|
854
|
+
|
|
855
|
+
Device-side counterpart of ``find_bias_tone`` for callers that already hold
|
|
856
|
+
the record's spectrum: refines each ``targets[t]`` by an argmax search over
|
|
857
|
+
``targets[t] ± search_band`` in the spectrum row ``rows[t]``, followed by
|
|
858
|
+
the same three-bin log-parabolic sub-bin fit. No zero-padding is applied
|
|
859
|
+
(the sub-bin fit compensates for the coarser bin grid), no extra FFTs are
|
|
860
|
+
run, and all per-tone work stays on device - the refined frequencies come
|
|
861
|
+
back in a **single** host transfer.
|
|
862
|
+
|
|
863
|
+
Parameters
|
|
864
|
+
----------
|
|
865
|
+
X : (R, N) array
|
|
866
|
+
Full FFT (``xp.fft.fft`` bin order) of the rows to search.
|
|
867
|
+
sampling_rate : float
|
|
868
|
+
Sampling rate in Hz.
|
|
869
|
+
targets : sequence of float
|
|
870
|
+
Nominal tone frequencies to refine (length T).
|
|
871
|
+
search_band : float
|
|
872
|
+
Half-width of the per-tone search window in Hz.
|
|
873
|
+
rows : sequence of int, optional
|
|
874
|
+
Spectrum row for each target. Defaults to ``range(T)`` (requires
|
|
875
|
+
``R == T``).
|
|
876
|
+
|
|
877
|
+
Returns
|
|
878
|
+
-------
|
|
879
|
+
(T,) np.ndarray
|
|
880
|
+
Refined frequencies in Hz (float64, on host).
|
|
881
|
+
"""
|
|
882
|
+
X, xp, _ = dispatch(X)
|
|
883
|
+
if X.ndim == 1:
|
|
884
|
+
X = X[None, :]
|
|
885
|
+
N = X.shape[-1]
|
|
886
|
+
df = sampling_rate / N
|
|
887
|
+
targets = [float(f) for f in targets]
|
|
888
|
+
if rows is None:
|
|
889
|
+
rows = list(range(len(targets)))
|
|
890
|
+
neigh = xp.asarray([-1, 0, 1], dtype=xp.int64)
|
|
891
|
+
|
|
892
|
+
refined = []
|
|
893
|
+
for f_t, row in zip(targets, rows):
|
|
894
|
+
# Signed candidate bins covering [f_t - band, f_t + band] on the N-grid.
|
|
895
|
+
b_lo = max(int(np.ceil((f_t - abs(search_band)) / df)), -(N // 2))
|
|
896
|
+
b_hi = min(int(np.floor((f_t + abs(search_band)) / df)), (N - 1) // 2)
|
|
897
|
+
if b_hi < b_lo:
|
|
898
|
+
raise ValueError(
|
|
899
|
+
f"target_frequency={f_t} ± search_band={search_band} produces an "
|
|
900
|
+
f"empty search window for fs={sampling_rate} Hz, N={N}."
|
|
901
|
+
)
|
|
902
|
+
cand = xp.arange(b_lo, b_hi + 1, dtype=xp.int64) # signed bins
|
|
903
|
+
spec_row = X[int(row)]
|
|
904
|
+
mag2 = xp.abs(spec_row[cand % N]) ** 2
|
|
905
|
+
kb = cand[xp.argmax(mag2)] # 0-d signed peak bin, stays on device
|
|
906
|
+
# Three-bin log-parabolic fit (|X|²: the x2 in log cancels in the ratio).
|
|
907
|
+
y = xp.log(
|
|
908
|
+
xp.maximum(xp.abs(spec_row[(kb + neigh) % N]).astype(xp.float64), 1e-300)
|
|
909
|
+
)
|
|
910
|
+
d = y[0] - 2.0 * y[1] + y[2]
|
|
911
|
+
delta = xp.where(xp.abs(d) > 1e-30, 0.5 * (y[0] - y[2]) / d, 0.0)
|
|
912
|
+
delta = xp.clip(delta, -0.5, 0.5)
|
|
913
|
+
refined.append((kb.astype(xp.float64) + delta) * df)
|
|
914
|
+
|
|
915
|
+
return to_device(xp.stack(refined), "cpu")
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def correct_frequency_offset_blockwise(
|
|
919
|
+
samples: ArrayType,
|
|
920
|
+
sampling_rate: float,
|
|
921
|
+
block_size: int,
|
|
922
|
+
overlap: float,
|
|
923
|
+
estimator: Callable[[np.ndarray, float], float],
|
|
924
|
+
combine_channels: bool = False,
|
|
925
|
+
debug_plot: bool = False,
|
|
926
|
+
) -> ArrayType:
|
|
927
|
+
"""
|
|
928
|
+
Estimate and correct a time-varying frequency offset in one call.
|
|
929
|
+
|
|
930
|
+
Divides the signal into overlapping blocks, calls an arbitrary
|
|
931
|
+
``estimator(block_1d_cpu, fs) -> float`` independently on each channel,
|
|
932
|
+
interpolates the per-block estimates with **PCHIP** (monotone, no
|
|
933
|
+
overshoot), integrates to a phase trajectory, and applies the correction
|
|
934
|
+
by complex rotation - returning corrected samples on the same device and
|
|
935
|
+
dtype as the input.
|
|
936
|
+
|
|
937
|
+
Parameters
|
|
938
|
+
----------
|
|
939
|
+
samples : array_like
|
|
940
|
+
Complex IQ samples. Shape: ``(N,)`` or ``(C, N)``.
|
|
941
|
+
sampling_rate : float
|
|
942
|
+
Sampling rate in Hz.
|
|
943
|
+
block_size : int
|
|
944
|
+
Number of samples per analysis block.
|
|
945
|
+
overlap : float
|
|
946
|
+
Fractional overlap between consecutive blocks, in ``[0, 1)``. Block
|
|
947
|
+
centres are spaced ``step = round(block_size * (1 - overlap))``
|
|
948
|
+
samples apart.
|
|
949
|
+
estimator : callable
|
|
950
|
+
Signature ``(block: np.ndarray, fs: float) -> float``. Receives a
|
|
951
|
+
1-D NumPy complex array on CPU and returns the estimated instantaneous
|
|
952
|
+
frequency offset in Hz. Additional arguments can be bound with
|
|
953
|
+
``partial``.
|
|
954
|
+
|
|
955
|
+
combine_channels : bool, default False
|
|
956
|
+
For MIMO inputs (C > 1):
|
|
957
|
+
|
|
958
|
+
* ``False`` - estimate and correct each channel independently.
|
|
959
|
+
Each channel's own per-block estimates drive a separate PCHIP
|
|
960
|
+
interpolation and phase trajectory.
|
|
961
|
+
* ``True`` - average the per-channel block estimates, run one PCHIP
|
|
962
|
+
on the average, and apply the same phase trajectory to every channel.
|
|
963
|
+
Use this for coherent MIMO (e.g. polarisation diversity) where all
|
|
964
|
+
channels share the same laser / frequency offset.
|
|
965
|
+
|
|
966
|
+
debug_plot : bool, default False
|
|
967
|
+
If ``True``, opens a diagnostic figure showing the per-block frequency
|
|
968
|
+
estimates, PCHIP-interpolated trajectory, and integrated phase
|
|
969
|
+
trajectory. For MIMO with ``combine_channels=False``, shows channel 0.
|
|
970
|
+
|
|
971
|
+
Returns
|
|
972
|
+
-------
|
|
973
|
+
array_like
|
|
974
|
+
Frequency-offset-corrected samples, **same shape and dtype as the
|
|
975
|
+
input**, on the same backend device.
|
|
976
|
+
|
|
977
|
+
Notes
|
|
978
|
+
-----
|
|
979
|
+
Pipeline (per channel):
|
|
980
|
+
|
|
981
|
+
1. Slice into overlapping blocks centred at t_k = s_k + block_size / 2.
|
|
982
|
+
2. Call estimator(block_np, fs) on each CPU block.
|
|
983
|
+
3. Interpolate estimates with scipy.interpolate.PchipInterpolator;
|
|
984
|
+
clamp to [t_0, t_{B-1}] (no extrapolation).
|
|
985
|
+
4. Integrate: theta(n) = (2 * pi / fs) * cumsum(delta_f).
|
|
986
|
+
5. Apply: y[n] = x[n] * exp(-j * theta[n]).
|
|
987
|
+
"""
|
|
988
|
+
if not (0.0 <= overlap < 1.0):
|
|
989
|
+
raise ValueError(f"overlap must be in [0, 1), got {overlap}.")
|
|
990
|
+
|
|
991
|
+
samples, xp, _ = dispatch(samples)
|
|
992
|
+
was_1d = samples.ndim == 1
|
|
993
|
+
if was_1d:
|
|
994
|
+
samples_2d = samples[None, :] # (1, N)
|
|
995
|
+
else:
|
|
996
|
+
samples_2d = samples
|
|
997
|
+
C, N = samples_2d.shape
|
|
998
|
+
|
|
999
|
+
step = max(1, round(block_size * (1.0 - overlap)))
|
|
1000
|
+
starts = list(range(0, N - block_size + 1, step))
|
|
1001
|
+
if not starts:
|
|
1002
|
+
starts = [0]
|
|
1003
|
+
_bsize = N
|
|
1004
|
+
else:
|
|
1005
|
+
_bsize = block_size
|
|
1006
|
+
|
|
1007
|
+
t_centers = np.array([s + _bsize / 2.0 for s in starts], dtype=np.float64)
|
|
1008
|
+
B = len(starts)
|
|
1009
|
+
n_grid = np.arange(N, dtype=np.float64)
|
|
1010
|
+
|
|
1011
|
+
# Per-channel per-block estimation: df_all[c, k]
|
|
1012
|
+
df_all = np.empty((C, B), dtype=np.float64)
|
|
1013
|
+
for c in range(C):
|
|
1014
|
+
sig1d = np.asarray(to_device(samples_2d[c], "cpu"))
|
|
1015
|
+
for k, s in enumerate(starts):
|
|
1016
|
+
df_all[c, k] = float(estimator(sig1d[s : s + _bsize], sampling_rate))
|
|
1017
|
+
|
|
1018
|
+
# Averaged or per-channel frequency estimates for interpolation
|
|
1019
|
+
if combine_channels and C > 1:
|
|
1020
|
+
df_for_interp = df_all.mean(axis=0, keepdims=True) # (1, B) - shared
|
|
1021
|
+
else:
|
|
1022
|
+
df_for_interp = df_all # (C, B) - per-channel
|
|
1023
|
+
|
|
1024
|
+
C_interp = df_for_interp.shape[0]
|
|
1025
|
+
|
|
1026
|
+
# Pre-import PCHIP once if multiple blocks
|
|
1027
|
+
if B > 1:
|
|
1028
|
+
from scipy.interpolate import PchipInterpolator
|
|
1029
|
+
|
|
1030
|
+
n_clamped = np.clip(n_grid, t_centers[0], t_centers[-1])
|
|
1031
|
+
|
|
1032
|
+
# PCHIP interpolation + cumulative integration -> (C_interp, N) phase array
|
|
1033
|
+
theta_np = np.empty((C_interp, N), dtype=np.float64)
|
|
1034
|
+
df_dense_plot: np.ndarray | None = None
|
|
1035
|
+
for c in range(C_interp):
|
|
1036
|
+
df_est = df_for_interp[c]
|
|
1037
|
+
if B == 1:
|
|
1038
|
+
df_dense = np.full(N, df_est[0], dtype=np.float64)
|
|
1039
|
+
else:
|
|
1040
|
+
df_dense = PchipInterpolator(t_centers, df_est)(n_clamped)
|
|
1041
|
+
theta_np[c] = (2.0 * np.pi / sampling_rate) * np.cumsum(df_dense)
|
|
1042
|
+
if debug_plot and c == 0:
|
|
1043
|
+
df_dense_plot = df_dense
|
|
1044
|
+
|
|
1045
|
+
# Broadcast averaged trajectory to all channels if needed
|
|
1046
|
+
if combine_channels and C > 1:
|
|
1047
|
+
theta_np_full = np.broadcast_to(theta_np, (C, N)).copy()
|
|
1048
|
+
else:
|
|
1049
|
+
theta_np_full = theta_np # (C, N)
|
|
1050
|
+
|
|
1051
|
+
# Apply correction on the original device
|
|
1052
|
+
theta_xp = xp.asarray(theta_np_full) # (C, N) on device
|
|
1053
|
+
two_pi = 2.0 * np.pi
|
|
1054
|
+
phase_f64 = theta_xp.astype(xp.float64)
|
|
1055
|
+
phase_wrapped = (phase_f64 - xp.round(phase_f64 / two_pi) * two_pi).astype(
|
|
1056
|
+
xp.float32
|
|
1057
|
+
)
|
|
1058
|
+
phasor = xp.exp(-1j * phase_wrapped).astype(samples_2d.dtype)
|
|
1059
|
+
corrected_2d = samples_2d * phasor
|
|
1060
|
+
|
|
1061
|
+
df_log = df_all.mean(axis=0) if (combine_channels and C > 1) else df_all[0]
|
|
1062
|
+
logger.debug(
|
|
1063
|
+
"correct_frequency_offset_blockwise: C=%s, B=%s blocks, freq range=[%.2f, %.2f] Hz, total phase drift=%.3f rad",
|
|
1064
|
+
C,
|
|
1065
|
+
B,
|
|
1066
|
+
df_log.min(),
|
|
1067
|
+
df_log.max(),
|
|
1068
|
+
float(theta_np_full[0, -1]),
|
|
1069
|
+
)
|
|
1070
|
+
logger.info(
|
|
1071
|
+
"correct_frequency_offset_blockwise: mean = %+.1f Hz, std = %.1f Hz, range = [%+.1f, %+.1f] Hz (%s segments)",
|
|
1072
|
+
df_all.mean(),
|
|
1073
|
+
df_all.std(),
|
|
1074
|
+
df_all.min(),
|
|
1075
|
+
df_all.max(),
|
|
1076
|
+
len(starts),
|
|
1077
|
+
)
|
|
1078
|
+
|
|
1079
|
+
if debug_plot and df_dense_plot is not None:
|
|
1080
|
+
from . import plotting as _plotting
|
|
1081
|
+
|
|
1082
|
+
title = (
|
|
1083
|
+
f"correct_frequency_offset_blockwise - channel 0 of {C}"
|
|
1084
|
+
if C > 1 and not combine_channels
|
|
1085
|
+
else "correct_frequency_offset_blockwise"
|
|
1086
|
+
)
|
|
1087
|
+
_plotting.plot_frequency_offset_blockwise_result(
|
|
1088
|
+
t_centers=t_centers,
|
|
1089
|
+
df_estimates=df_for_interp[0],
|
|
1090
|
+
n_grid=n_grid,
|
|
1091
|
+
df_dense=df_dense_plot,
|
|
1092
|
+
phase_trajectory=theta_np_full[0],
|
|
1093
|
+
show=True,
|
|
1094
|
+
title=title,
|
|
1095
|
+
)
|
|
1096
|
+
|
|
1097
|
+
return corrected_2d[0] if was_1d else corrected_2d
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def correct_static_frequency_offset(
|
|
1101
|
+
samples: ArrayType,
|
|
1102
|
+
sampling_rate: float,
|
|
1103
|
+
offset: float | np.ndarray,
|
|
1104
|
+
) -> ArrayType:
|
|
1105
|
+
"""
|
|
1106
|
+
Applies a **constant** frequency offset correction via exact complex mixing.
|
|
1107
|
+
|
|
1108
|
+
Multiplies ``samples`` by exp(-j * 2 * pi * delta_f * n / fs) where
|
|
1109
|
+
*n* starts at 0 at the first sample of the passed array.
|
|
1110
|
+
|
|
1111
|
+
Warning: designed for full-signal correction of a single static offset.
|
|
1112
|
+
Calling it on sub-blocks breaks phase continuity (phasor restarts at n = 0).
|
|
1113
|
+
For time-varying or block-streamed correction use ``correct_frequency_offset_blockwise``.
|
|
1114
|
+
|
|
1115
|
+
Unlike ``shift_frequency``, this function applies the
|
|
1116
|
+
correction **without bin quantization**, preserving the full precision of
|
|
1117
|
+
a sub-bin estimate (e.g. from Jacobsen interpolation in
|
|
1118
|
+
``estimate_frequency_offset_mth_power``).
|
|
1119
|
+
|
|
1120
|
+
Parameters
|
|
1121
|
+
----------
|
|
1122
|
+
samples : array_like
|
|
1123
|
+
Input signal samples. Shape: (..., N).
|
|
1124
|
+
sampling_rate : float
|
|
1125
|
+
Sampling rate in Hz.
|
|
1126
|
+
offset : float or np.ndarray
|
|
1127
|
+
Estimated frequency offset in Hz. Either a scalar (same correction
|
|
1128
|
+
applied to all channels) or a 1-D array of shape ``(C,)`` as
|
|
1129
|
+
returned by the per-channel ``estimate_frequency_offset_*``
|
|
1130
|
+
functions when ``combine_channels=False``. A per-channel array
|
|
1131
|
+
requires ``samples`` to have shape ``(C, N)``.
|
|
1132
|
+
|
|
1133
|
+
Returns
|
|
1134
|
+
-------
|
|
1135
|
+
array_like
|
|
1136
|
+
Frequency-corrected samples, same shape and dtype as input.
|
|
1137
|
+
"""
|
|
1138
|
+
samples, xp, _ = dispatch(samples)
|
|
1139
|
+
offset_arr = xp.asarray(offset)
|
|
1140
|
+
per_channel = offset_arr.ndim >= 1 and offset_arr.size > 1
|
|
1141
|
+
|
|
1142
|
+
if per_channel:
|
|
1143
|
+
logger.debug(
|
|
1144
|
+
"Applying per-channel frequency offset correction: %s Hz (sampling_rate=%.0f Hz)",
|
|
1145
|
+
[f"{f:.4f}" for f in offset_arr.flat],
|
|
1146
|
+
sampling_rate,
|
|
1147
|
+
)
|
|
1148
|
+
else:
|
|
1149
|
+
logger.debug(
|
|
1150
|
+
"Applying frequency offset correction: %.4f Hz (sampling_rate=%.0f Hz)",
|
|
1151
|
+
float(offset_arr.reshape(-1)[0]),
|
|
1152
|
+
sampling_rate,
|
|
1153
|
+
)
|
|
1154
|
+
|
|
1155
|
+
n = samples.shape[-1]
|
|
1156
|
+
t = xp.arange(n, dtype=xp.float64) / sampling_rate # float64 - correct for all N
|
|
1157
|
+
|
|
1158
|
+
if xp.iscomplexobj(samples):
|
|
1159
|
+
target_dtype = samples.dtype
|
|
1160
|
+
else:
|
|
1161
|
+
target_dtype = xp.complex64 if samples.dtype == xp.float32 else xp.complex128
|
|
1162
|
+
|
|
1163
|
+
# For complex64 targets: wrap to [-π, π] in float64, then cast to float32 before exp -
|
|
1164
|
+
# matching the correct_carrier_phase / JAX BPS pattern in recovery.py / equalization.py.
|
|
1165
|
+
# Wrapping is essential: casting a large unbounded ramp (e.g. 6000 rad) directly to
|
|
1166
|
+
# float32 causes trig argument-reduction error (~|phase|·2⁻²³); wrapping first bounds
|
|
1167
|
+
# the mantissa range to [-π, π] so float32 error is only ~3.7x10⁻⁷ rad.
|
|
1168
|
+
# Avoids the original (C, N) complex128 intermediate for complex64 targets.
|
|
1169
|
+
dtype_real = xp.float32 if target_dtype == xp.complex64 else xp.float64
|
|
1170
|
+
two_pi = 2.0 * np.pi
|
|
1171
|
+
|
|
1172
|
+
if per_channel:
|
|
1173
|
+
# Build a (C, N) mixer - one distinct tone per channel
|
|
1174
|
+
C = samples.shape[0]
|
|
1175
|
+
offsets_xp = xp.asarray(offset_arr.reshape(-1)[:C], dtype=xp.float64) # (C,)
|
|
1176
|
+
phase = -2.0 * xp.pi * offsets_xp[:, None] * t[None, :] # (C, N) float64
|
|
1177
|
+
phase_exp = (phase - xp.round(phase / two_pi) * two_pi).astype(dtype_real)
|
|
1178
|
+
mixer = xp.exp(1j * phase_exp).astype(target_dtype)
|
|
1179
|
+
else:
|
|
1180
|
+
# Scalar path - single mixer broadcast over all channels. offset_arr
|
|
1181
|
+
# may be a genuine 0-d array or a size-1 array of any shape (e.g. a
|
|
1182
|
+
# single-channel (1,) per-channel estimate) - reshape before the
|
|
1183
|
+
# scalar cast so both are accepted on every backend (recent NumPy
|
|
1184
|
+
# and CuPy both reject float() on non-0-d arrays outright).
|
|
1185
|
+
phase = -2.0 * xp.pi * float(offset_arr.reshape(-1)[0]) * t # (N,) float64
|
|
1186
|
+
phase_exp = (phase - xp.round(phase / two_pi) * two_pi).astype(dtype_real)
|
|
1187
|
+
mixer = xp.exp(1j * phase_exp).astype(target_dtype)
|
|
1188
|
+
if samples.ndim > 1:
|
|
1189
|
+
mixer = mixer.reshape((1,) * (samples.ndim - 1) + (-1,))
|
|
1190
|
+
|
|
1191
|
+
return samples * mixer
|