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/timing.py ADDED
@@ -0,0 +1,841 @@
1
+ """
2
+ Timing synchronization utilities.
3
+
4
+ This module provides routines for time synchronization, including the
5
+ generation of optimal synchronization sequences (Barker, Zadoff-Chu),
6
+ robust integer timing offset estimation via cross-correlation, and
7
+ fractional timing offset estimation and correction.
8
+ """
9
+
10
+ import logging
11
+ from typing import Union
12
+
13
+ import numpy as np
14
+
15
+ from .backend import ArrayType, dispatch, is_cupy_available, to_device
16
+ from .core import Preamble
17
+ from .logger import logger
18
+
19
+ # Window length for DFT-upsampling in estimate_fractional_delay()
20
+ _DFT_WINDOW = 33
21
+
22
+ # Standard Barker codes
23
+ _BARKER_SEQUENCES = {
24
+ 2: [1, -1],
25
+ 3: [1, 1, -1],
26
+ 4: [1, 1, -1, 1],
27
+ 5: [1, 1, 1, -1, 1],
28
+ 7: [1, 1, 1, -1, -1, 1, -1],
29
+ 11: [1, 1, 1, -1, -1, -1, 1, -1, -1, 1, -1],
30
+ 13: [1, 1, 1, 1, 1, -1, -1, 1, 1, -1, 1, -1, 1],
31
+ }
32
+
33
+
34
+ def barker_sequence(length: int) -> ArrayType:
35
+ """
36
+ Generates a Barker sequence of the specified length.
37
+
38
+ Barker sequences are binary sequences (+1, -1) with optimal cyclic
39
+ auto-correlation properties, where the sidelobes are at most 1. They are
40
+ widely used for frame synchronization and pulse compression.
41
+
42
+ Parameters
43
+ ----------
44
+ length : {2, 3, 4, 5, 7, 11, 13}
45
+ Total length of the Barker sequence.
46
+
47
+ Returns
48
+ -------
49
+ array_like
50
+ BPSK symbols (+1.0, -1.0). Shape: (length,).
51
+ Backend depends on system availability (CuPy if available, else NumPy).
52
+
53
+ Raises
54
+ ------
55
+ ValueError
56
+ If the requested length is not a valid Barker length.
57
+
58
+ Examples
59
+ --------
60
+ >>> barker_sequence(7)
61
+ array([ 1., 1., 1., -1., -1., 1., -1.], dtype=float32)
62
+ """
63
+ if length not in _BARKER_SEQUENCES:
64
+ valid = sorted(_BARKER_SEQUENCES.keys())
65
+ raise ValueError(f"No Barker sequence of length {length}. Valid: {valid}")
66
+
67
+ seq = np.array(_BARKER_SEQUENCES[length], dtype="float32")
68
+
69
+ if is_cupy_available():
70
+ seq = to_device(seq, "gpu")
71
+
72
+ logger.debug("Generated Barker-%s sequence.", length)
73
+ return seq
74
+
75
+
76
+ def zadoff_chu_sequence(length: int, root: int = 1) -> ArrayType:
77
+ r"""
78
+ Generates a Zadoff-Chu (ZC) synchronization sequence.
79
+
80
+ ZC sequences are Complex-valued, Constant Amplitude Zero
81
+ Auto-Correlation (CAZAC) sequences. They possess the unique property
82
+ that their periodic auto-correlation is zero at all non-zero lags,
83
+ and their DFT is also a ZC sequence. This makes them ideal for
84
+ timing and frequency synchronization in systems like LTE and 5G NR.
85
+
86
+ Parameters
87
+ ----------
88
+ length : int
89
+ The sequence length (N_ZC). For optimal cross-correlation
90
+ properties, this should be a prime number.
91
+ root : int, default 1
92
+ The root index (u). Must be relatively prime to `length`.
93
+
94
+ Returns
95
+ -------
96
+ array_like
97
+ Complex Zadoff-Chu symbols of unit magnitude.
98
+ Shape: (length,). Data type: `complex64`.
99
+
100
+ Notes
101
+ -----
102
+ - For odd lengths: x[n] = exp(-j * pi * u * n * (n + 1) / N_ZC)
103
+ - For even lengths: x[n] = exp(-j * pi * u * n^2 / N_ZC)
104
+ - ZC sequences have exceptionally low Peak-to-Average Power Ratio (PAPR).
105
+ """
106
+ if length < 1:
107
+ raise ValueError("Length must be positive.")
108
+ if root < 1 or root >= length:
109
+ raise ValueError(f"Root must be in [1, {length - 1}].")
110
+
111
+ # Determine backend
112
+ if is_cupy_available():
113
+ import cupy as cp
114
+
115
+ xp = cp
116
+ else:
117
+ xp = np
118
+
119
+ # ZC formula: x[n] = exp(-j * pi * u * n * (n+1) / N)
120
+ n = xp.arange(length)
121
+ if length % 2 == 0:
122
+ # Even length: x[n] = exp(-j * pi * u * n^2 / N)
123
+ seq = xp.exp(-1j * xp.pi * root * n * n / length)
124
+ else:
125
+ # Odd length: x[n] = exp(-j * pi * u * n * (n+1) / N)
126
+ seq = xp.exp(-1j * xp.pi * root * n * (n + 1) / length)
127
+
128
+ seq = seq.astype(xp.complex64)
129
+
130
+ logger.debug("Generated ZC sequence: length=%s, root=%s.", length, root)
131
+ return seq
132
+
133
+
134
+ def estimate_fractional_delay(
135
+ correlation: ArrayType,
136
+ peak_indices: ArrayType,
137
+ dft_upsample: int = 1,
138
+ method: str = "log-parabolic",
139
+ ) -> ArrayType:
140
+ """
141
+ Estimates sub-sample timing offset via parabolic interpolation.
142
+
143
+ Given a correlation array and the integer peak positions, fits a
144
+ parabola (or Gaussian) through the three points around each peak to
145
+ estimate the fractional offset with sub-sample precision.
146
+
147
+ When the input is **complex**, the peak is phase-rotated to the real
148
+ axis before fitting. This preserves the true peak shape (unlike
149
+ fitting to ``|R|``) and improves accuracy by 2-5x for typical
150
+ matched-filter outputs.
151
+
152
+ If ``dft_upsample > 1``, performs a "Zoom FFT" (DFT zero-padding)
153
+ around the peak to interpolate the correlation function onto a finer
154
+ grid before fitting. This significantly reduces bias error.
155
+
156
+ Parameters
157
+ ----------
158
+ correlation : array_like
159
+ Correlation values - complex or real magnitude.
160
+ Shape: ``(N,)`` or ``(C, N)``.
161
+ peak_indices : array_like
162
+ Integer peak positions. Shape: scalar or ``(C,)``.
163
+ dft_upsample : int, default 1
164
+ Upsampling factor for DFT-based interpolation.
165
+ Values > 1 perform zero-padded FFT interpolation on a window
166
+ around the peak (typically 33 samples).
167
+ method : {'parabolic', 'log-parabolic'}, default 'log-parabolic'
168
+ Fitting method:
169
+ - 'parabolic': Standard parabolic fit. Good for general peaks.
170
+ - 'log-parabolic': Fits a parabola to log(y), equivalent to a
171
+ Gaussian fit. Often more accurate for bandlimited pulses.
172
+
173
+ Returns
174
+ -------
175
+ array_like
176
+ Fractional offset per channel, in [-0.5, 0.5). Shape: ``(C,)`` or scalar.
177
+ """
178
+ correlation, xp, _ = dispatch(correlation)
179
+ peak_indices = xp.asarray(peak_indices)
180
+ scalar_input = peak_indices.ndim == 0
181
+
182
+ if correlation.ndim == 1:
183
+ correlation = correlation[None, :] # (1, N)
184
+ if peak_indices.ndim == 0:
185
+ peak_indices = peak_indices[None] # (1,)
186
+
187
+ N = correlation.shape[-1]
188
+ C = correlation.shape[0]
189
+ ch_idx = xp.arange(C)
190
+
191
+ k = peak_indices.astype(int)
192
+ mu = xp.zeros(C, dtype=correlation.real.dtype)
193
+
194
+ def _calculate_mu(r_prev, r_curr, r_next, xp, method):
195
+ if xp.iscomplexobj(r_curr):
196
+ phase = xp.exp(-1j * xp.angle(r_curr))
197
+ alpha = (r_prev * phase).real
198
+ beta = (r_curr * phase).real
199
+ gamma = (r_next * phase).real
200
+ else:
201
+ alpha = r_prev
202
+ beta = r_curr
203
+ gamma = r_next
204
+
205
+ if method == "log-parabolic":
206
+ eps = 1e-12
207
+ alpha = xp.maximum(alpha, eps)
208
+ beta = xp.maximum(beta, eps)
209
+ gamma = xp.maximum(gamma, eps)
210
+ alpha, beta, gamma = xp.log(alpha), xp.log(beta), xp.log(gamma)
211
+
212
+ denom = 2.0 * (alpha - 2.0 * beta + gamma)
213
+ safe_denom = xp.where(xp.abs(denom) > 1e-12, denom, xp.ones_like(denom))
214
+ mu_val = (alpha - gamma) / safe_denom
215
+
216
+ # Mask invalid fits
217
+ valid = xp.abs(denom) > 1e-12
218
+ mu_val = xp.where(valid, mu_val, xp.zeros_like(mu_val))
219
+ return xp.clip(mu_val, -0.5, 0.5)
220
+
221
+ half_W = _DFT_WINDOW // 2
222
+ interior_mask = (k >= half_W) & (k < N - half_W)
223
+
224
+ # -------------------------------------------------------------------------
225
+ # Path A: DFT Upsampling
226
+ # -------------------------------------------------------------------------
227
+ if dft_upsample > 1:
228
+ if xp.any(interior_mask):
229
+ valid_indices = xp.where(interior_mask)[0]
230
+ k_valid = k[interior_mask]
231
+
232
+ offsets = xp.arange(-half_W, half_W + 1)
233
+ gather_idx = k_valid[:, None] + offsets[None, :]
234
+ windows = correlation[valid_indices[:, None], gather_idx]
235
+
236
+ specs = xp.fft.fft(windows, axis=-1)
237
+ pos_limit = (_DFT_WINDOW + 1) // 2
238
+ neg_len = _DFT_WINDOW - pos_limit
239
+ target_len = _DFT_WINDOW * dft_upsample
240
+ padded_specs = xp.zeros((len(valid_indices), target_len), dtype=specs.dtype)
241
+ padded_specs[:, :pos_limit] = specs[:, :pos_limit]
242
+ padded_specs[:, -neg_len:] = specs[:, pos_limit:]
243
+
244
+ upsampled = xp.fft.ifft(padded_specs, axis=-1) * dft_upsample
245
+ up_mag = xp.abs(upsampled)
246
+ k_up = xp.argmax(up_mag, axis=-1)
247
+ k_up_safe = xp.clip(k_up, 1, target_len - 2)
248
+
249
+ row_idx = xp.arange(len(valid_indices))
250
+ r_prev = upsampled[row_idx, k_up_safe - 1]
251
+ r_curr = upsampled[row_idx, k_up_safe]
252
+ r_next = upsampled[row_idx, k_up_safe + 1]
253
+
254
+ mu_up = _calculate_mu(r_prev, r_curr, r_next, xp, method)
255
+
256
+ # Position relative to window start: k_up + mu_up
257
+ # Center of window is at index (half_W * dft_upsample)?
258
+ # No, standard ZoomFFT mapping.
259
+ # Time axis of upsampled is 0 to W*M-1.
260
+ # Original sample k corresponds to center of window.
261
+ # Window covers [k - half_W, k + half_W].
262
+ # Index 0 of upsampled corresponds to k - half_W.
263
+ # So offset from k is: -half_W + (k_up + mu_up) / M.
264
+ offset_samples = -half_W + (k_up_safe + mu_up) / dft_upsample
265
+ mu[interior_mask] = offset_samples
266
+
267
+ # -------------------------------------------------------------------------
268
+ # Path B: Standard (Primary when M=1, fallback for edge channels when M>1)
269
+ # -------------------------------------------------------------------------
270
+ # The trigger is determined purely by which channels still need a result:
271
+ # - M=1: all channels (Path A was skipped entirely)
272
+ # - M>1: only ~interior_mask channels (edges Path A cannot process)
273
+ # Do NOT use `mu == 0` as a proxy - zero is a valid fractional offset and
274
+ # would wrongly overwrite DFT results for on-centre correlation peaks.
275
+ if dft_upsample == 1:
276
+ calc_mask = xp.ones(C, dtype=bool)
277
+ else:
278
+ calc_mask = ~interior_mask # only edge channels need the fallback
279
+
280
+ if xp.any(calc_mask):
281
+ interior_all = (k >= 1) & (k < N - 1)
282
+ k_all_safe = xp.clip(k, 1, N - 2)
283
+
284
+ r_prev = correlation[ch_idx, k_all_safe - 1]
285
+ r_curr = correlation[ch_idx, k_all_safe]
286
+ r_next = correlation[ch_idx, k_all_safe + 1]
287
+
288
+ mu_std = _calculate_mu(r_prev, r_curr, r_next, xp, method)
289
+ mu_std = xp.where(interior_all, mu_std, xp.zeros_like(mu_std))
290
+
291
+ if dft_upsample == 1:
292
+ mu = mu_std
293
+ else:
294
+ # Merge: DFT result for interior channels, standard for edge channels
295
+ mu = xp.where(interior_mask, mu, mu_std)
296
+
297
+ if scalar_input:
298
+ return mu[0]
299
+ return mu
300
+
301
+
302
+ def fft_fractional_delay(
303
+ samples: ArrayType,
304
+ delay: float | ArrayType,
305
+ ) -> ArrayType:
306
+ """
307
+ Applies fractional sample delay using FFT-based frequency-domain method.
308
+
309
+ This is the mathematically ideal method for bandlimited signals. It
310
+ applies the delay as a phase shift in the frequency domain, which is
311
+ equivalent to ideal sinc interpolation in the time domain. Unlike
312
+ polynomial interpolators (e.g., Farrow), this method perfectly preserves
313
+ signal power and has no bandwidth limitations.
314
+
315
+ Parameters
316
+ ----------
317
+ samples : array_like
318
+ Input signal. Shape: (N,) or (C, N).
319
+ delay : float or array_like
320
+ Fractional delay in samples. Positive = delay (shift right).
321
+ Scalar applies the same delay to all channels.
322
+ Array of shape (C,) applies per-channel delays.
323
+
324
+ Returns
325
+ -------
326
+ array_like
327
+ Delayed signal with the same shape as input.
328
+
329
+ Notes
330
+ -----
331
+ Applies Y(f) = X(f) * exp(-j * 2*pi * f * delay / N) - equivalent to
332
+ ideal sinc interpolation with perfect power preservation.
333
+ """
334
+ samples, xp, _ = dispatch(samples)
335
+ was_1d = samples.ndim == 1
336
+ if was_1d:
337
+ samples = samples[None, :] # (1, N)
338
+
339
+ C, N = samples.shape
340
+
341
+ # Convert delay to array
342
+ if isinstance(delay, (int, float)):
343
+ delay_arr = xp.full(C, delay, dtype=samples.real.dtype)
344
+ else:
345
+ delay_arr = xp.asarray(delay, dtype=samples.real.dtype)
346
+ if delay_arr.ndim == 0:
347
+ delay_arr = delay_arr[None]
348
+
349
+ # FFT
350
+ spec = xp.fft.fft(samples, axis=-1)
351
+
352
+ # Frequency axis: normalized frequencies in cycles/sample
353
+ freqs = xp.fft.fftfreq(N, d=1.0)
354
+
355
+ # Phase shift: exp(-j * 2 * pi * f * delay)
356
+ # Positive delay -> phase ramp that shifts signal to the right.
357
+ # Computed at float64 accuracy, then cast to match spec's dtype to prevent
358
+ # -2j * xp.pi (complex128) from promoting complex64 spectra.
359
+ phase_shift = xp.exp(-2j * xp.pi * freqs[None, :] * delay_arr[:, None])
360
+ phase_shift = phase_shift.astype(spec.dtype)
361
+
362
+ # Apply phase shift
363
+ spec_delayed = spec * phase_shift
364
+
365
+ # IFFT
366
+ result = xp.fft.ifft(spec_delayed, axis=-1)
367
+
368
+ # Dtype restoration: mirrors the impairments.py pattern.
369
+ if not xp.iscomplexobj(samples):
370
+ # Real input: fractional delay is a real-valued operation
371
+ result = result.real
372
+ elif result.dtype != samples.dtype:
373
+ # Complex input: ifft may return complex128 from complex64 input
374
+ result = result.astype(samples.dtype)
375
+
376
+ if was_1d:
377
+ return result[0]
378
+ return result
379
+
380
+
381
+ def estimate_timing(
382
+ samples: ArrayType,
383
+ reference: Union[ArrayType, "Preamble"] | None = None,
384
+ threshold: float = 3.0,
385
+ sps: int | None = None,
386
+ pulse_shape: str | None = None,
387
+ filter_params: dict | None = None,
388
+ search_range: tuple[int, int] | None = None,
389
+ dft_upsample: int = 1,
390
+ fractional_method: str = "log-parabolic",
391
+ debug_plot: bool = False,
392
+ ) -> tuple[ArrayType, ArrayType]:
393
+ """
394
+ Estimates integer and fractional timing offsets via cross-correlation.
395
+
396
+ Slides a cross-correlation between the received signal and a known reference
397
+ to find the integer-sample timing offset per channel, then estimates the
398
+ fractional offset via parabolic interpolation on the correlation peak.
399
+
400
+ For MIMO references with shape ``(C_tx, L)``, every template is correlated
401
+ against every RX channel; each channel takes the strongest peak across all
402
+ (template, lag) pairs. This preserves per-channel hardware skew and is
403
+ robust to polarization swap or mixing.
404
+
405
+ Parameters
406
+ ----------
407
+ samples : array_like
408
+ Received signal samples. Shape: ``(N,)`` or ``(C, N)``.
409
+ reference : array_like or Preamble
410
+ Reference for correlation. A Preamble object is reconstructed via
411
+ ``Preamble.to_signal()`` using ``sps`` and ``pulse_shape``; a raw
412
+ array (shape ``(L,)`` or ``(C_tx, L)``) is used directly.
413
+ threshold : float, default 3.0
414
+ Peak-to-average power ratio threshold for peak detection.
415
+ sps : int, optional
416
+ Samples per symbol. Required when ``reference`` is a Preamble.
417
+ pulse_shape : str, optional
418
+ Pulse shaping filter type used when ``reference`` is a Preamble.
419
+ Defaults to ``'rrc'``.
420
+ filter_params : dict, optional
421
+ Extra pulse shaper parameters when ``reference`` is a Preamble.
422
+ search_range : tuple of int, optional
423
+ ``(start, end)`` sample range to restrict the search.
424
+ dft_upsample : int, default 1
425
+ DFT-based upsampling factor for high-precision fractional estimation.
426
+ fractional_method : {'parabolic', 'log-parabolic'}, default 'log-parabolic'
427
+ Fitting method for fractional delay estimation.
428
+ debug_plot : bool, default False
429
+ If ``True``, plots the correlation magnitude for debugging.
430
+
431
+ Returns
432
+ -------
433
+ integer_offsets : ArrayType
434
+ Integer sample offsets where the reference sequence begins,
435
+ per RX channel. Shape: ``(N_channels,)``. Each channel's
436
+ offset is estimated independently so hardware skew between
437
+ channels is preserved.
438
+ fractional_offsets : ArrayType
439
+ Sub-sample timing offsets in [-0.5, 0.5) per channel.
440
+ Shape: ``(N_channels,)``.
441
+
442
+ Raises
443
+ ------
444
+ ValueError
445
+ If no correlation peak is found that satisfies the threshold
446
+ criteria, or if ``reference`` is not provided.
447
+
448
+ Notes
449
+ -----
450
+ The returned integer offset is the sample index of the first reference
451
+ sample. For oversampled signals, pass a shaped reference at the same sps
452
+ for best timing SNR.
453
+ """
454
+ from .helpers import cross_correlate_fft
455
+
456
+ # 1. Resolve Inputs & Metadata
457
+ if filter_params is None:
458
+ filter_params = {}
459
+
460
+ if not hasattr(samples, "ndim"):
461
+ raise TypeError(
462
+ f"estimate_timing() expects an array of samples, got {type(samples).__name__}."
463
+ )
464
+
465
+ sig_array, xp, _ = dispatch(samples)
466
+
467
+ # 2. Build reference waveform
468
+ ref_waveform = None
469
+
470
+ if isinstance(reference, Preamble):
471
+ # Preamble object: reconstruct the shaped waveform at the target SPS.
472
+ if sps is None:
473
+ raise ValueError("SPS must be provided when using a Preamble object.")
474
+
475
+ ref_waveform = xp.asarray(
476
+ reference.to_signal(
477
+ sps=sps,
478
+ symbol_rate=1.0,
479
+ pulse_shape=pulse_shape or "rrc",
480
+ **filter_params,
481
+ ).samples
482
+ )
483
+ # ensure 2-D (C_tx, L*sps) for the correlation engine
484
+ if ref_waveform.ndim == 1:
485
+ ref_waveform = ref_waveform[None, :]
486
+
487
+ elif reference is not None:
488
+ # Raw array: use directly as the correlation template (e.g. the known
489
+ # TX waveform or a slice of the payload itself).
490
+ ref_waveform = xp.asarray(reference)
491
+ else:
492
+ raise ValueError(
493
+ "A 'reference' sequence must be provided (Preamble object or raw array)."
494
+ )
495
+
496
+ # 3. Correlation Strategy
497
+ # Signal: (C, N) or (N,)
498
+ # Reference: (C, L) or (L,) or (1, L)
499
+
500
+ # Ensure dimensions match for broadcast/multichannel correlation
501
+ if sig_array.ndim == 1:
502
+ sig_array = sig_array[None, :] # Treat as 1 channel
503
+
504
+ if ref_waveform.ndim == 1:
505
+ ref_waveform = ref_waveform[None, :] # Treat as 1 template
506
+
507
+ # Ensure reference is on the same device as the signal.
508
+ ref_waveform = to_device(ref_waveform, "cpu" if xp is np else "gpu")
509
+
510
+ num_sig_ch = sig_array.shape[0]
511
+
512
+ # Apply search range
513
+ offset = 0
514
+ if search_range is not None:
515
+ start, end = search_range
516
+ sig_processing = sig_array[:, start:end]
517
+ offset = start
518
+ else:
519
+ sig_processing = sig_array
520
+
521
+ # === Vectorized Correlation (FFT) via shared helper ===
522
+ L = ref_waveform.shape[-1]
523
+ C_tx = ref_waveform.shape[0]
524
+
525
+ if C_tx > 1:
526
+ # Correlate every TX template against every RX channel.
527
+ # corr_all[rx, tx, lag] - shape (C_rx, C_tx, N_lag)
528
+ corr_all = xp.stack(
529
+ [
530
+ cross_correlate_fft(
531
+ sig_processing, ref_waveform[t : t + 1], mode="positive_lags"
532
+ )
533
+ for t in range(C_tx)
534
+ ],
535
+ axis=1,
536
+ )
537
+
538
+ # === Greedy per-channel peak finding ===
539
+ # Each RX channel takes its strongest peak across all (template, lag).
540
+ # The lag is an independent per-channel argmax, so hardware skew is
541
+ # preserved; the template choice only selects which peak height is read,
542
+ # not its position (all streams share the symbol clock). Robust to a
543
+ # polarization swap, mixing, or a missing/weak stream. Polarization
544
+ # identity is not resolved here (see recovery.resolve_channel_permutation).
545
+ corr_all_mag = xp.abs(corr_all) # (C_rx, C_tx, N_lag)
546
+ best_tx = xp.argmax(xp.max(corr_all_mag, axis=-1), axis=-1) # (C_rx,)
547
+ corr = xp.take_along_axis(corr_all, best_tx[:, None, None], axis=1)[
548
+ :, 0
549
+ ] # (C_rx, N_lag) complex - on-device gather, no per-channel host sync
550
+ corr_incoherent = xp.abs(corr) # (C_rx, N_lag)
551
+ peak_indices = xp.argmax(corr_incoherent, axis=-1) # (C_rx,)
552
+ else:
553
+ corr = cross_correlate_fft(sig_processing, ref_waveform, mode="positive_lags")
554
+
555
+ # Magnitude
556
+ corr_mag = xp.abs(corr)
557
+
558
+ # === Per-Channel Analysis ===
559
+ # For SISO / broadcast single template: find peak from magnitude
560
+ if C_tx == 1:
561
+ peak_indices = xp.argmax(corr_mag, axis=-1) # Shape (C,)
562
+
563
+ # === Per-Channel Normalization ===
564
+ # Normalized correlation metric: use local signal energy around each
565
+ # detected peak (L-sample window) instead of global mean. This gives
566
+ # metric ≈ 1.0 for a perfectly matched reference regardless of the
567
+ # noise/data content elsewhere in the signal.
568
+ e_ref = xp.sum(xp.abs(ref_waveform) ** 2, axis=-1) # (C_tx,) or scalar
569
+ if e_ref.ndim == 0:
570
+ e_ref = xp.full(num_sig_ch, e_ref)
571
+ else:
572
+ e_ref = xp.full(num_sig_ch, xp.mean(e_ref))
573
+
574
+ # Local signal energy: sum |sig|² in the L-sample window at each peak.
575
+ # Batch the peak indices to host once instead of one sync per channel.
576
+ N_sig = sig_processing.shape[-1]
577
+ peaks_np = to_device(peak_indices, "cpu")
578
+ e_s = xp.empty(num_sig_ch, dtype=sig_processing.real.dtype)
579
+ for ch in range(num_sig_ch):
580
+ pk = int(peaks_np[ch])
581
+ end_idx = min(pk + L, N_sig)
582
+ e_s[ch] = xp.sum(xp.abs(sig_processing[ch, pk:end_idx]) ** 2)
583
+
584
+ norm_factors = xp.sqrt(e_ref * e_s)
585
+ norm_factors = xp.maximum(norm_factors, 1e-12)
586
+
587
+ # Calculate per-channel coherence (diagnostic mathematically absolute bound, [0, 1])
588
+ if C_tx > 1:
589
+ peak_vals = xp.max(
590
+ corr_incoherent, axis=-1
591
+ ) # (C,) incoherent - matches peak detection
592
+ mean_vals = xp.mean(corr_incoherent, axis=-1)
593
+ else:
594
+ peak_vals = xp.max(corr_mag, axis=-1) # (C,)
595
+ mean_vals = xp.mean(corr_mag, axis=-1)
596
+
597
+ coherence = peak_vals / norm_factors
598
+ coherence = xp.clip(coherence, 0.0, 1.0)
599
+
600
+ # Primary timing metric: PAPR (Peak-to-Average magnitude)
601
+ # This evaluates visual prominence against the noise floor, ensuring
602
+ # extreme robustness to CFO phase-rotation and pulse-shape mismatch.
603
+ mean_vals = xp.maximum(mean_vals, 1e-12)
604
+ metrics = peak_vals / mean_vals
605
+
606
+ # Log coherence diagnostics - one batched D2H per array, not per channel
607
+ coherence_np = to_device(coherence, "cpu")
608
+ metrics_np = to_device(metrics, "cpu")
609
+ for ch in range(num_sig_ch):
610
+ c_val = float(coherence_np[ch])
611
+ p_val = float(metrics_np[ch])
612
+ if c_val < 0.5 and p_val >= threshold:
613
+ logger.warning(
614
+ "Channel %s: Peak phase coherence is very low (%.2f), but peak is visually prominent (PAPR=%.1f >= %s). This suggests strong Carrier Frequency Offset (CFO) or uncompensated dispersion destroying phase alignment over the sequence length.",
615
+ ch,
616
+ c_val,
617
+ p_val,
618
+ threshold,
619
+ )
620
+ else:
621
+ logger.debug(
622
+ "Channel %s: Peak prominence = %.1f, coherence = %.2f", ch, p_val, c_val
623
+ )
624
+
625
+ # === Threshold Check === (reuses the host copy - no further syncs)
626
+ max_metric = float(metrics_np.max())
627
+ if max_metric < threshold:
628
+ raise ValueError(
629
+ f"No correlation peak above threshold {threshold} (max: {max_metric:.3f})"
630
+ )
631
+ for _ch in range(num_sig_ch):
632
+ _m = float(metrics_np[_ch])
633
+ if _m < threshold:
634
+ logger.warning(
635
+ "Channel %s: correlation metric %.3f is below threshold %s. Integer offset for this channel may be unreliable.",
636
+ _ch,
637
+ _m,
638
+ threshold,
639
+ )
640
+
641
+ # === Skew Check (Robust) ===
642
+ if num_sig_ch > 1:
643
+ valid_mask = metrics > threshold
644
+
645
+ if xp.sum(valid_mask) > 1:
646
+ valid_peaks = peak_indices[valid_mask]
647
+ spread = int(xp.max(valid_peaks) - xp.min(valid_peaks))
648
+
649
+ if spread > 0:
650
+ logger.warning(
651
+ "Skew detected among valid channels! Valid Peaks: %s. Spread: %s samples.",
652
+ valid_peaks.tolist(),
653
+ spread,
654
+ )
655
+ else:
656
+ logger.info("Channels aligned (spread %s).", spread)
657
+
658
+ # Each channel's peak is found independently so hardware skew is preserved.
659
+ integer_offsets = xp.maximum(0, peak_indices + offset)
660
+
661
+ if debug_plot:
662
+ from . import plotting as _plotting
663
+
664
+ _plotting.plot_timing_correlation(
665
+ corr_mag=to_device(corr_mag, "cpu"),
666
+ peak_indices=to_device(peak_indices, "cpu"),
667
+ norm_factors=to_device(mean_vals, "cpu"),
668
+ threshold=threshold,
669
+ offset=offset,
670
+ show=True,
671
+ )
672
+
673
+ # === Fractional Timing (Parabolic Interpolation) ===
674
+ fractional_offsets = estimate_fractional_delay(
675
+ corr, peak_indices, dft_upsample=dft_upsample, method=fractional_method
676
+ )
677
+
678
+ if logger.isEnabledFor(logging.INFO):
679
+ # Three .tolist() host syncs, only for this summary line.
680
+ logger.info(
681
+ "Timing estimated. Integer: %s, Fractional: %s, Metrics: %s",
682
+ integer_offsets.tolist(),
683
+ fractional_offsets.tolist(),
684
+ metrics.tolist(),
685
+ )
686
+
687
+ return integer_offsets, fractional_offsets
688
+
689
+
690
+ def correct_timing(
691
+ samples: ArrayType,
692
+ integer_offset: int | ArrayType,
693
+ fractional_offset: float | ArrayType = 0.0,
694
+ mode: str = "circular",
695
+ ) -> ArrayType:
696
+ """
697
+ Combined integer and fractional timing correction.
698
+
699
+ Applies an integer sample shift followed by fractional sample
700
+ interpolation using FFT-based frequency-domain delay.
701
+
702
+ Parameters
703
+ ----------
704
+ samples : array_like
705
+ Input signal. Shape: (N,) or (C, N).
706
+ integer_offset : int or array_like
707
+ Integer sample offset(s) to correct. Positive values shift
708
+ the signal left (i.e., remove leading samples).
709
+ Scalar or shape (C,) for per-channel offsets.
710
+ fractional_offset : float or array_like, default 0.0
711
+ Fractional sample delay(s) in [-0.5, 0.5) to correct via
712
+ FFT-based interpolation. Scalar or shape (C,).
713
+ mode : {'circular', 'zero', 'slice'}, default 'circular'
714
+ How to handle boundary samples after the integer shift:
715
+
716
+ - ``'circular'``: Wrap-around (``xp.roll``). Output has the
717
+ same length as input. Correct for periodic signals or
718
+ unit tests; **not** suitable for burst frames.
719
+ - ``'zero'``: Shift left, fill trailing samples with zeros.
720
+ Same output shape as input. Correct for burst reception
721
+ where tail wrapping would corrupt the payload.
722
+ - ``'slice'``: Discard leading samples; no tail artifact.
723
+ For a scalar offset the output length is ``N - offset``.
724
+ For per-channel offsets the output length is
725
+ ``N - max(offset)`` so all channels are aligned to the
726
+ same common overlap region - the correct approach for
727
+ offline MIMO timing-skew correction.
728
+
729
+ Returns
730
+ -------
731
+ array_like
732
+ Timing-corrected signal. Same shape as input for
733
+ ``'circular'`` and ``'zero'``; shorter for ``'slice'``.
734
+
735
+ Notes
736
+ -----
737
+ The reference sequence is not stripped from the output; sample 0 is the
738
+ first reference sample. Fractional delay uses FFT-based phase shift
739
+ (ideal for bandlimited signals; no power loss). For ``mode='slice'``,
740
+ fractional delay is applied to the full buffer before slicing so the
741
+ wrap-around stays at the buffer ends.
742
+ """
743
+ samples, xp, _ = dispatch(samples)
744
+ was_1d = samples.ndim == 1
745
+ if was_1d:
746
+ samples = samples[None, :]
747
+
748
+ num_ch = samples.shape[0]
749
+ N = samples.shape[-1]
750
+
751
+ # Pre-evaluate the fractional-correction flag so mode='slice' can apply the
752
+ # delay before the slice. Same logic that used to live just before the
753
+ # post-integer fractional call below - relocated, not changed.
754
+ if isinstance(fractional_offset, (int, float)):
755
+ apply_frac = abs(fractional_offset) > 1e-9
756
+ else:
757
+ fractional_offset = xp.asarray(fractional_offset)
758
+ apply_frac = bool(xp.any(xp.abs(fractional_offset) > 1e-9))
759
+
760
+ # mode='slice': apply fractional delay on the *full* pre-slice buffer.
761
+ # fft_fractional_delay treats its input as circular; applying it after the
762
+ # slice would wrap the slice's trailing edge back into its new sample 0 -
763
+ # exactly the frame boundary we just aligned to. Applying first puts the
764
+ # wrap at the physical buffer ends, the leading one of which is then
765
+ # discarded by the slice; only the tail of the slice carries any residual
766
+ # ~sinc-tail artefact, well away from the equalizer training start.
767
+ if mode == "slice" and apply_frac:
768
+ samples = fft_fractional_delay(samples, -fractional_offset)
769
+
770
+ # === Integer correction (integer shift) ===
771
+ integer_offset = xp.asarray(integer_offset)
772
+
773
+ if integer_offset.ndim == 0:
774
+ # --- Scalar: same shift for all channels ---
775
+ shift = int(integer_offset)
776
+ if mode == "circular":
777
+ samples = xp.roll(samples, -shift, axis=-1)
778
+ elif mode == "zero":
779
+ result = xp.zeros_like(samples)
780
+ if shift > 0:
781
+ result[..., : N - shift] = samples[..., shift:]
782
+ elif shift < 0:
783
+ result[..., -shift:] = samples[..., : N + shift]
784
+ samples = result
785
+ elif mode == "slice":
786
+ samples = samples[..., shift:]
787
+ else:
788
+ raise ValueError(
789
+ f"Unknown mode {mode!r}. Choose 'circular', 'zero', or 'slice'."
790
+ )
791
+
792
+ else:
793
+ # --- Per-channel: vectorized gather (avoids one GPU->CPU sync per channel) ---
794
+ integer_shift = integer_offset.astype(xp.int64) # (C,) on device
795
+ col_base = xp.arange(N, dtype=xp.int64)[None, :] # (1, N)
796
+ row_idx = xp.arange(num_ch)[:, None] # (C, 1)
797
+
798
+ if mode == "circular":
799
+ col_idx = (col_base + integer_shift[:, None]) % N # (C, N)
800
+ samples = samples[row_idx, col_idx]
801
+
802
+ elif mode == "zero":
803
+ col_raw = col_base + integer_shift[:, None] # (C, N)
804
+ gathered = samples[row_idx, xp.clip(col_raw, 0, N - 1)]
805
+ samples = xp.where(col_raw < N, gathered, xp.zeros_like(gathered))
806
+
807
+ elif mode == "slice":
808
+ # Align all channels to common overlap: N - max(offset) samples
809
+ max_shift = int(xp.max(integer_shift)) # one GPU sync
810
+ common_len = N - max_shift
811
+ col_idx_s = (
812
+ xp.arange(common_len, dtype=xp.int64)[None, :] + integer_shift[:, None]
813
+ ) # (C, common_len)
814
+ samples = samples[row_idx, col_idx_s]
815
+
816
+ else:
817
+ raise ValueError(
818
+ f"Unknown mode {mode!r}. Choose 'circular', 'zero', or 'slice'."
819
+ )
820
+
821
+ # === Fractional correction (via FFT) ===
822
+ # For mode='slice' this was already applied above on the full pre-slice
823
+ # buffer; here we only handle 'circular' / 'zero', whose output length
824
+ # equals the input length so the wrap location is unchanged either way.
825
+ if apply_frac and mode != "slice":
826
+ samples = fft_fractional_delay(samples, -fractional_offset)
827
+
828
+ if mode == "slice":
829
+ logger.warning(
830
+ "correct_timing(mode='slice'): output is shorter than input (trimmed by %s samples). Signal length metadata (e.g. duration) will no longer match the original.",
831
+ int(xp.max(xp.asarray(integer_offset))),
832
+ )
833
+ logger.info(
834
+ "Timing corrected: integer=applied, fractional=%s, mode=%r.",
835
+ "applied" if apply_frac else "skipped",
836
+ mode,
837
+ )
838
+
839
+ if was_1d:
840
+ return samples[0]
841
+ return samples