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.
Files changed (84) hide show
  1. commkit/__init__.py +74 -0
  2. commkit/_cuda/__init__.py +321 -0
  3. commkit/_cuda/compiler.py +88 -0
  4. commkit/_cuda/src/bps_min_d2.cu +104 -0
  5. commkit/_cuda/src/cs_block.cu +119 -0
  6. commkit/_cuda/src/selftest.cu +14 -0
  7. commkit/analysis/__init__.py +55 -0
  8. commkit/analysis/_common.py +236 -0
  9. commkit/analysis/allan.py +108 -0
  10. commkit/analysis/drift.py +213 -0
  11. commkit/analysis/interferometry.py +887 -0
  12. commkit/analysis/linewidth.py +480 -0
  13. commkit/analysis/trajectory.py +91 -0
  14. commkit/backend.py +507 -0
  15. commkit/coding/__init__.py +23 -0
  16. commkit/coding/base.py +17 -0
  17. commkit/coding/bch.py +6 -0
  18. commkit/coding/convolutional.py +7 -0
  19. commkit/coding/crc.py +7 -0
  20. commkit/coding/galois.py +8 -0
  21. commkit/coding/hamming.py +6 -0
  22. commkit/coding/interleaving.py +7 -0
  23. commkit/coding/ldpc.py +8 -0
  24. commkit/coding/polar.py +8 -0
  25. commkit/coding/ratematch.py +6 -0
  26. commkit/coding/reed_solomon.py +6 -0
  27. commkit/coding/turbo.py +8 -0
  28. commkit/core/__init__.py +32 -0
  29. commkit/core/frame.py +992 -0
  30. commkit/core/generation.py +581 -0
  31. commkit/core/signal.py +725 -0
  32. commkit/equalization/__init__.py +49 -0
  33. commkit/equalization/_block.py +1855 -0
  34. commkit/equalization/_common.py +606 -0
  35. commkit/equalization/_kernels_jax.py +1720 -0
  36. commkit/equalization/_kernels_numba.py +1704 -0
  37. commkit/equalization/blind.py +223 -0
  38. commkit/equalization/linear.py +365 -0
  39. commkit/equalization/polarization.py +790 -0
  40. commkit/equalization/result.py +191 -0
  41. commkit/equalization/sequential.py +2805 -0
  42. commkit/filtering.py +1120 -0
  43. commkit/frequency.py +1191 -0
  44. commkit/helpers.py +489 -0
  45. commkit/impairments/__init__.py +43 -0
  46. commkit/impairments/channel/__init__.py +20 -0
  47. commkit/impairments/channel/linear.py +310 -0
  48. commkit/impairments/channel/nonlinear.py +11 -0
  49. commkit/impairments/frontend.py +229 -0
  50. commkit/impairments/noise.py +105 -0
  51. commkit/impairments/source.py +219 -0
  52. commkit/io.py +308 -0
  53. commkit/logger.py +103 -0
  54. commkit/mapping/__init__.py +46 -0
  55. commkit/mapping/bits.py +240 -0
  56. commkit/mapping/constellation.py +153 -0
  57. commkit/mapping/gray.py +429 -0
  58. commkit/mapping/llr.py +253 -0
  59. commkit/mapping/shaping.py +218 -0
  60. commkit/metrics.py +949 -0
  61. commkit/multirate.py +476 -0
  62. commkit/plotting/__init__.py +78 -0
  63. commkit/plotting/analysis.py +627 -0
  64. commkit/plotting/constellation.py +483 -0
  65. commkit/plotting/equalizer.py +390 -0
  66. commkit/plotting/eye.py +388 -0
  67. commkit/plotting/spectral.py +575 -0
  68. commkit/plotting/sync.py +953 -0
  69. commkit/plotting/theme.py +203 -0
  70. commkit/plotting/waveform.py +200 -0
  71. commkit/py.typed +0 -0
  72. commkit/recovery/__init__.py +51 -0
  73. commkit/recovery/bps.py +337 -0
  74. commkit/recovery/corrections.py +751 -0
  75. commkit/recovery/pilots.py +803 -0
  76. commkit/recovery/pll.py +482 -0
  77. commkit/recovery/tikhonov.py +424 -0
  78. commkit/recovery/viterbi_viterbi.py +227 -0
  79. commkit/spectral.py +560 -0
  80. commkit/timing.py +841 -0
  81. commkit-1.0.0.dist-info/METADATA +145 -0
  82. commkit-1.0.0.dist-info/RECORD +84 -0
  83. commkit-1.0.0.dist-info/WHEEL +4 -0
  84. 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