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
@@ -0,0 +1,1704 @@
1
+ """Numba-compiled sequential equalizer kernels (CPU hot loops)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+ # -----------------------------------------------------------------------------
8
+ # NUMBA LAZY LOADER
9
+ # -----------------------------------------------------------------------------
10
+
11
+ _NUMBA_CACHE: dict = {}
12
+
13
+
14
+ def _get_numba():
15
+ """Lazy loader for Numba.
16
+
17
+ Returns the ``numba`` module if installed, else ``None``.
18
+ """
19
+ if "numba" not in _NUMBA_CACHE:
20
+ try:
21
+ import numba
22
+
23
+ _NUMBA_CACHE["numba"] = numba
24
+ except ImportError:
25
+ _NUMBA_CACHE["numba"] = None
26
+ return _NUMBA_CACHE.get("numba")
27
+
28
+
29
+ # -----------------------------------------------------------------------------
30
+ # NUMBA KERNELS - ADAPTIVE EQUALIZER LOOPS
31
+ # -----------------------------------------------------------------------------
32
+ #
33
+ # Each factory lazily compiles a @njit kernel on first call and caches it in
34
+ # _NUMBA_KERNELS. Numba specialises over argument *types* (all complex64/
35
+ # float32/int32/bool), so a single compiled version handles any (C, T, M).
36
+ #
37
+ # All kernels share the same calling conventions:
38
+ # - x_padded : (C, N_pad) complex64, C-contiguous
39
+ # - W : (C, C, T) complex64, modified **in-place** - caller owns
40
+ # - y_out : (N_sym, C) complex64, pre-allocated output
41
+ # - e_out : (N_sym, C) complex64, pre-allocated output
42
+ # - w_hist_out : (N_sym, C, C, T) if store_weights=True, else (1, C, C, T)
43
+ #
44
+ # Performance tricks:
45
+ # - fastmath=True - LLVM unsafe-math: enables SIMD vectorisation of
46
+ # inner tap loops (AVX-512 on modern CPUs)
47
+ # - cache=True - compiled LLVM IR cached to __pycache__ (*.nbi/*.nbc)
48
+ # - Pre-allocated temporaries (X_wins, y, e, x_bar, Px ...) - no heap
49
+ # allocation inside the hot symbol loop
50
+ # - Manual argmin slicer - explicit loop over M; LLVM can SIMD-vectorise
51
+ # the distance computation for small constellations
52
+ # - In-place W update - no copy per step, minimal working-set pressure
53
+ # - np.conj() on scalars - valid in Numba 0.64+
54
+
55
+ _NUMBA_KERNELS: dict = {}
56
+
57
+
58
+ def _get_numba_lms():
59
+ """JIT-compile and cache the Numba LMS butterfly loop kernel.
60
+
61
+ Returns
62
+ -------
63
+ lms_loop : numba-compiled callable
64
+ See kernel source for argument shapes and semantics.
65
+ """
66
+ if "lms" not in _NUMBA_KERNELS:
67
+ numba_mod = _get_numba()
68
+ if numba_mod is None:
69
+ raise ImportError("Numba is required for backend='numba'.")
70
+
71
+ @numba_mod.njit(cache=True, fastmath=True, nogil=True)
72
+ def lms_loop(
73
+ x_padded,
74
+ training,
75
+ constellation,
76
+ W,
77
+ step_size,
78
+ n_train,
79
+ stride,
80
+ store_weights,
81
+ y_out,
82
+ e_out,
83
+ w_hist_out,
84
+ sq_lev_min,
85
+ sq_d_grid,
86
+ sq_side,
87
+ ):
88
+ # x_padded : (C, N_pad) complex64
89
+ # training : (C, N_sym) complex64
90
+ # constellation : (M,) complex64
91
+ # W : (C, C, num_taps) complex64 - modified in-place
92
+ # step_size : float32 - LMS step size μ; stable when 0 < μ < 2/(C·num_taps·P_x)
93
+ # n_train : int32 - training/DD boundary
94
+ # stride : int - sps (== 2 for T/2-spaced)
95
+ # store_weights : bool
96
+ # y_out : (N_sym, C) complex64 - pre-allocated
97
+ # e_out : (N_sym, C) complex64 - pre-allocated
98
+ # w_hist_out : (N_sym or 1, C, C, T) complex64 - pre-allocated
99
+ C = W.shape[0]
100
+ num_taps = W.shape[2]
101
+ n_sym = y_out.shape[0]
102
+ M = len(constellation)
103
+
104
+ X_wins = np.empty((C, num_taps), dtype=np.complex64)
105
+ y = np.empty(C, dtype=np.complex64)
106
+ e = np.empty(C, dtype=np.complex64)
107
+
108
+ for idx in range(n_sym):
109
+ sample_idx = idx * stride
110
+
111
+ # Window extraction
112
+ for c in range(C):
113
+ for t in range(num_taps):
114
+ X_wins[c, t] = x_padded[c, sample_idx + t]
115
+
116
+ # Butterfly forward pass: y[i] = Σ_j conj(W[i,j]) · X_wins[j]
117
+ for i in range(C):
118
+ acc_re = 0.0
119
+ acc_im = 0.0
120
+ for j in range(C):
121
+ for t in range(num_taps):
122
+ w = W[i, j, t]
123
+ x = X_wins[j, t]
124
+ acc_re += np.float64(w.real) * np.float64(
125
+ x.real
126
+ ) + np.float64(w.imag) * np.float64(x.imag)
127
+ acc_im += np.float64(w.real) * np.float64(
128
+ x.imag
129
+ ) - np.float64(w.imag) * np.float64(x.real)
130
+ y[i] = acc_re + 1j * acc_im
131
+ y_out[idx, i] = acc_re + 1j * acc_im
132
+
133
+ # Desired symbol: training or decision-directed slicer
134
+ for i in range(C):
135
+ if idx < n_train:
136
+ d_i = training[i, idx]
137
+ elif sq_side > np.int32(0):
138
+ ir = np.int32(np.round((y[i].real - sq_lev_min) / sq_d_grid))
139
+ if ir < np.int32(0):
140
+ ir = np.int32(0)
141
+ if ir >= sq_side:
142
+ ir = sq_side - np.int32(1)
143
+ ii = np.int32(np.round((y[i].imag - sq_lev_min) / sq_d_grid))
144
+ if ii < np.int32(0):
145
+ ii = np.int32(0)
146
+ if ii >= sq_side:
147
+ ii = sq_side - np.int32(1)
148
+ nr = sq_lev_min + np.float32(ir) * sq_d_grid
149
+ ni = sq_lev_min + np.float32(ii) * sq_d_grid
150
+ d_i = np.complex64(nr + ni * np.complex64(1j))
151
+ else:
152
+ min_dist = np.float32(1e38)
153
+ min_idx = 0
154
+ for k in range(M):
155
+ diff = y[i] - constellation[k]
156
+ dist = diff.real * diff.real + diff.imag * diff.imag
157
+ if dist < min_dist:
158
+ min_dist = dist
159
+ min_idx = k
160
+ d_i = constellation[min_idx]
161
+ e[i] = d_i - y[i]
162
+ e_out[idx, i] = e[i]
163
+
164
+ # Weight update: W[i,j,t] += μ * conj(e[i]) * X_wins[j,t]
165
+ for i in range(C):
166
+ ce_i = np.conj(e[i])
167
+ for j in range(C):
168
+ for t in range(num_taps):
169
+ W[i, j, t] = W[i, j, t] + step_size * ce_i * X_wins[j, t]
170
+
171
+ if store_weights:
172
+ for i in range(C):
173
+ for j in range(C):
174
+ for t in range(num_taps):
175
+ w_hist_out[idx, i, j, t] = W[i, j, t]
176
+
177
+ _NUMBA_KERNELS["lms"] = lms_loop
178
+ return _NUMBA_KERNELS["lms"]
179
+
180
+
181
+ def _get_numba_rls():
182
+ """JIT-compile and cache the Numba Leaky-RLS butterfly loop kernel.
183
+
184
+ Returns
185
+ -------
186
+ rls_loop : numba-compiled callable
187
+ See kernel source for argument shapes and semantics.
188
+ """
189
+ if "rls" not in _NUMBA_KERNELS:
190
+ numba_mod = _get_numba()
191
+ if numba_mod is None:
192
+ raise ImportError("Numba is required for backend='numba'.")
193
+
194
+ @numba_mod.njit(cache=True, fastmath=True, nogil=True)
195
+ def rls_loop(
196
+ x_padded,
197
+ training,
198
+ constellation,
199
+ W,
200
+ P,
201
+ lam,
202
+ leakage,
203
+ n_train,
204
+ n_update_halt,
205
+ stride,
206
+ store_weights,
207
+ y_out,
208
+ e_out,
209
+ w_hist_out,
210
+ sq_lev_min,
211
+ sq_d_grid,
212
+ sq_side,
213
+ ):
214
+ # x_padded : (C, N_pad) complex64
215
+ # training : (C, N_sym) complex64
216
+ # constellation : (M,) complex64
217
+ # W : (C, C, num_taps) complex64 - in-place
218
+ # P : (C*num_taps, C*num_taps) complex128 - in-place
219
+ # lam : float32 - forgetting factor λ ∈ (0, 1]
220
+ # leakage : float32 - weight-decay coefficient γ ∈ [0, 1)
221
+ # n_train : int32 - training/DD boundary
222
+ # n_update_halt : int32 - freeze W,P beyond this index
223
+ # stride : int - sps
224
+ # store_weights : bool
225
+ # y_out : (N_sym, C) complex64
226
+ # e_out : (N_sym, C) complex64
227
+ # w_hist_out : (N_sym or 1, C, C, T) complex64
228
+ C = W.shape[0]
229
+ num_taps = W.shape[2]
230
+ n_sym = y_out.shape[0]
231
+ N = C * num_taps # regressor dimension
232
+ M = len(constellation)
233
+
234
+ x_bar = np.empty(N, dtype=np.complex128)
235
+ Px = np.empty(N, dtype=np.complex128)
236
+ xH_P = np.empty(N, dtype=np.complex128)
237
+ k = np.empty(N, dtype=np.complex128)
238
+ y = np.empty(C, dtype=np.complex64)
239
+ e = np.empty(C, dtype=np.complex64)
240
+
241
+ lam_f64 = np.float64(lam)
242
+ leak_term = np.float32(1.0) - np.float32(leakage)
243
+
244
+ for idx in range(n_sym):
245
+ sample_idx = idx * stride
246
+
247
+ # Flatten regressor directly from input
248
+ for j in range(C):
249
+ for t in range(num_taps):
250
+ x_bar[j * num_taps + t] = x_padded[j, sample_idx + t]
251
+
252
+ # Butterfly forward pass
253
+ for i in range(C):
254
+ acc_re = 0.0
255
+ acc_im = 0.0
256
+ for j in range(C):
257
+ for t in range(num_taps):
258
+ w = W[i, j, t]
259
+ x = x_bar[j * num_taps + t]
260
+ acc_re += np.float64(w.real) * np.float64(
261
+ x.real
262
+ ) + np.float64(w.imag) * np.float64(x.imag)
263
+ acc_im += np.float64(w.real) * np.float64(
264
+ x.imag
265
+ ) - np.float64(w.imag) * np.float64(x.real)
266
+ y[i] = acc_re + 1j * acc_im
267
+ y_out[idx, i] = acc_re + 1j * acc_im
268
+
269
+ # Desired and error
270
+ for i in range(C):
271
+ if idx < n_train:
272
+ d_i = training[i, idx]
273
+ elif sq_side > np.int32(0):
274
+ ir = np.int32(np.round((y[i].real - sq_lev_min) / sq_d_grid))
275
+ if ir < np.int32(0):
276
+ ir = np.int32(0)
277
+ if ir >= sq_side:
278
+ ir = sq_side - np.int32(1)
279
+ ii = np.int32(np.round((y[i].imag - sq_lev_min) / sq_d_grid))
280
+ if ii < np.int32(0):
281
+ ii = np.int32(0)
282
+ if ii >= sq_side:
283
+ ii = sq_side - np.int32(1)
284
+ nr = sq_lev_min + np.float32(ir) * sq_d_grid
285
+ ni = sq_lev_min + np.float32(ii) * sq_d_grid
286
+ d_i = np.complex64(nr + ni * np.complex64(1j))
287
+ else:
288
+ min_dist = np.float32(1e38)
289
+ min_idx = 0
290
+ for kk in range(M):
291
+ diff = y[i] - constellation[kk]
292
+ dist = diff.real * diff.real + diff.imag * diff.imag
293
+ if dist < min_dist:
294
+ min_dist = dist
295
+ min_idx = kk
296
+ d_i = constellation[min_idx]
297
+ e[i] = d_i - y[i]
298
+ e_out[idx, i] = e[i]
299
+
300
+ # Kalman gain: Px = P @ x_bar (in-place, no heap allocation)
301
+ for ii in range(N):
302
+ acc_re = 0.0
303
+ acc_im = 0.0
304
+ for jj in range(N):
305
+ p_val = P[ii, jj]
306
+ x_val = x_bar[jj]
307
+ acc_re += p_val.real * x_val.real - p_val.imag * x_val.imag
308
+ acc_im += p_val.real * x_val.imag + p_val.imag * x_val.real
309
+ Px[ii] = acc_re + 1j * acc_im
310
+
311
+ # denom = λ + real(conj(x_bar) · Px)
312
+ denom = lam_f64
313
+ for jj in range(N):
314
+ denom = denom + (np.conj(x_bar[jj]) * Px[jj]).real
315
+
316
+ # k = Px / denom
317
+ inv_denom = np.float64(1.0) / denom
318
+ for ii in range(N):
319
+ k[ii] = Px[ii] * inv_denom
320
+
321
+ if idx < n_update_halt:
322
+ # Leaky W update: W[i,j,t] = (1-γ)W[i,j,t] + k[j*T+t]*conj(e[i])
323
+ for i in range(C):
324
+ ce_i = np.conj(e[i])
325
+ for j in range(C):
326
+ for t in range(num_taps):
327
+ W[i, j, t] = np.complex64(
328
+ leak_term * W[i, j, t] + k[j * num_taps + t] * ce_i
329
+ )
330
+
331
+ # Exploit Hermitian symmetry P = P^H: for each j, (x^H P)[j] = conj((Px)[j]).
332
+ # Reuse the already-computed Px to avoid a second O(N²) mat-vec.
333
+ for jj in range(N):
334
+ xH_P[jj] = np.conj(Px[jj])
335
+
336
+ # Riccati + Hermitian re-symmetrization in one pass (upper
337
+ # triangle only). Halves memory traffic vs. two separate
338
+ # O(N²) loops; correctness is identical.
339
+ for ii in range(N):
340
+ for jj in range(ii, N):
341
+ val = (P[ii, jj] - k[ii] * xH_P[jj]) / lam_f64
342
+ if ii == jj:
343
+ P[ii, ii] = np.complex128(val.real)
344
+ else:
345
+ conj_val = (P[jj, ii] - k[jj] * xH_P[ii]) / lam_f64
346
+ avg = (val + np.conj(conj_val)) * np.float64(0.5)
347
+ P[ii, jj] = avg
348
+ P[jj, ii] = np.conj(avg)
349
+
350
+ if store_weights:
351
+ for i in range(C):
352
+ for j in range(C):
353
+ for t in range(num_taps):
354
+ w_hist_out[idx, i, j, t] = W[i, j, t]
355
+
356
+ _NUMBA_KERNELS["rls"] = rls_loop
357
+ return _NUMBA_KERNELS["rls"]
358
+
359
+
360
+ def _get_numba_lms_cpr():
361
+ """JIT-compile and cache the Numba LMS+CPR butterfly loop kernel.
362
+
363
+ Extends the baseline LMS kernel with an inline carrier phase tracker
364
+ (PLL or single-shot BPS) and a causal cycle-slip corrector.
365
+
366
+ cpr_mode 1 = PLL (DD second-order PI loop).
367
+ cpr_mode 2 = BPS (block-averaged Blind Phase Search, window=bps_block_size).
368
+
369
+ Returns
370
+ -------
371
+ lms_cpr_loop : numba-compiled callable
372
+ """
373
+ if "lms_cpr" not in _NUMBA_KERNELS:
374
+ numba_mod = _get_numba()
375
+ if numba_mod is None:
376
+ raise ImportError("Numba is required for backend='numba'.")
377
+
378
+ @numba_mod.njit(cache=True, fastmath=False, nogil=True)
379
+ def lms_cpr_loop(
380
+ x_padded,
381
+ training,
382
+ constellation,
383
+ bps_phases_neg,
384
+ bps_angles,
385
+ bps_block_size,
386
+ bps_joint_channels,
387
+ W,
388
+ step_size,
389
+ n_train,
390
+ stride,
391
+ store_weights,
392
+ cpr_mode,
393
+ pll_mu,
394
+ pll_beta,
395
+ symmetry,
396
+ cs_enabled,
397
+ cs_threshold,
398
+ pll_phi,
399
+ pll_freq,
400
+ cs_buf_x,
401
+ cs_buf_y,
402
+ cs_buf_ptr,
403
+ cs_buf_n,
404
+ cs_stats,
405
+ bps_prev4,
406
+ y_out,
407
+ e_out,
408
+ phase_out,
409
+ w_hist_out,
410
+ sq_lev_min,
411
+ sq_d_grid,
412
+ sq_side,
413
+ ):
414
+ # x_padded : (C, N_pad) complex64
415
+ # training : (C, N_sym) complex64
416
+ # constellation : (M,) complex64
417
+ # bps_phases_neg : (B,) complex64 exp(-j*theta_k)
418
+ # bps_angles : (B,) float32 theta_k
419
+ # bps_block_size : int32 BPS averaging window (≥1)
420
+ # bps_joint_channels: bool True -> joint metric across C channels
421
+ # W : (C, C, T) complex64 - in-place
422
+ # step_size : float32
423
+ # n_train : int32
424
+ # stride : int
425
+ # store_weights : bool
426
+ # cpr_mode : int32 1=pll 2=bps
427
+ # pll_mu : float32
428
+ # pll_beta : float32
429
+ # symmetry : int32 - cycle-slip quantum = 2π/symmetry
430
+ # cs_enabled : bool
431
+ # cs_threshold : float32
432
+ # pll_phi : (C,) float64 - in-place
433
+ # pll_freq : (C,) float64 - in-place
434
+ # cs_buf_x : (C, H) float64 - in-place
435
+ # cs_buf_y : (C, H) float64 - in-place
436
+ # cs_buf_ptr : (C,) int64 - in-place
437
+ # cs_buf_n : (C,) int64 - in-place
438
+ # cs_stats : (C, 4) float64 [Sx,Sy,Sxx,Sxy] - in-place
439
+ # bps_prev4 : (C,) float64 - causal 4-fold unwrap state - in-place
440
+ # y_out : (N_sym, C) complex64
441
+ # e_out : (N_sym, C) complex64
442
+ # phase_out : (N_sym, C) float32
443
+ # w_hist_out : (N_sym or 1, C, C, T) complex64
444
+ C = W.shape[0]
445
+ num_taps = W.shape[2]
446
+ n_sym = y_out.shape[0]
447
+ M = len(constellation)
448
+ B = len(bps_phases_neg)
449
+ H = cs_buf_x.shape[1]
450
+ two_pi = np.float64(2.0 * np.pi)
451
+ quantum = two_pi / np.float64(symmetry)
452
+
453
+ X_wins = np.empty((C, num_taps), dtype=np.complex64)
454
+ y_raw = np.empty(C, dtype=np.complex64)
455
+ y_fin = np.empty(C, dtype=np.complex64)
456
+ phi_c = np.empty(C, dtype=np.float64)
457
+ d_sym = np.empty(C, dtype=np.complex64)
458
+ e_clean = np.empty(C, dtype=np.complex64)
459
+ e_eq = np.empty(C, dtype=np.complex64)
460
+ phi_hat_bps = np.zeros(C, dtype=np.float64)
461
+ # BPS: incremental running-sum (avoids O(K·B·M) per-symbol rescan)
462
+ bps_dist_buf = np.zeros((C, bps_block_size, B), dtype=np.float32)
463
+ bps_running_sum = np.zeros((C, B), dtype=np.float64)
464
+ bps_dist_ptr = np.int64(0)
465
+
466
+ for idx in range(n_sym):
467
+ sample_idx = idx * stride
468
+
469
+ # Window extraction
470
+ for c in range(C):
471
+ for t in range(num_taps):
472
+ X_wins[c, t] = x_padded[c, sample_idx + t]
473
+
474
+ # Butterfly forward pass: y_raw[i] = Σ_j conj(W[i,j]) · X
475
+ for i in range(C):
476
+ acc_re = 0.0
477
+ acc_im = 0.0
478
+ for j in range(C):
479
+ for t in range(num_taps):
480
+ w = W[i, j, t]
481
+ x = X_wins[j, t]
482
+ acc_re += np.float64(w.real) * np.float64(
483
+ x.real
484
+ ) + np.float64(w.imag) * np.float64(x.imag)
485
+ acc_im += np.float64(w.real) * np.float64(
486
+ x.imag
487
+ ) - np.float64(w.imag) * np.float64(x.real)
488
+ y_raw[i] = acc_re + 1j * acc_im
489
+
490
+ # -- CPR: Phase estimation ----------------------------------
491
+
492
+ # BPS: incremental running-sum over a causal window of K symbols.
493
+ # For each new symbol, compute min-dist for all B candidates,
494
+ # subtract the oldest slot from the running sum and add the new
495
+ # one - O(B·M·C) per symbol, independent of window size K.
496
+ # O(1) square-QAM path: snap real/imag to nearest level grid point.
497
+ if cpr_mode == 2:
498
+ slot = bps_dist_ptr % np.int64(bps_block_size)
499
+ for i in range(C):
500
+ for k in range(B):
501
+ y_rot = y_raw[i] * bps_phases_neg[k]
502
+ if sq_side > np.int32(0):
503
+ ir = np.int32(
504
+ np.round((y_rot.real - sq_lev_min) / sq_d_grid)
505
+ )
506
+ if ir < np.int32(0):
507
+ ir = np.int32(0)
508
+ if ir >= sq_side:
509
+ ir = sq_side - np.int32(1)
510
+ ii = np.int32(
511
+ np.round((y_rot.imag - sq_lev_min) / sq_d_grid)
512
+ )
513
+ if ii < np.int32(0):
514
+ ii = np.int32(0)
515
+ if ii >= sq_side:
516
+ ii = sq_side - np.int32(1)
517
+ nr = sq_lev_min + np.float32(ir) * sq_d_grid
518
+ ni = sq_lev_min + np.float32(ii) * sq_d_grid
519
+ d2_min = (y_rot.real - nr) ** 2 + (y_rot.imag - ni) ** 2
520
+ else:
521
+ d2_min = np.float32(1e38)
522
+ for m in range(M):
523
+ dv = y_rot - constellation[m]
524
+ d2 = dv.real * dv.real + dv.imag * dv.imag
525
+ if d2 < d2_min:
526
+ d2_min = d2
527
+ # Subtract evicted slot, add new slot
528
+ bps_running_sum[i, k] = (
529
+ bps_running_sum[i, k]
530
+ - bps_dist_buf[i, slot, k]
531
+ + d2_min
532
+ )
533
+ bps_dist_buf[i, slot, k] = d2_min
534
+ bps_dist_ptr = bps_dist_ptr + np.int64(1)
535
+
536
+ if bps_joint_channels:
537
+ best_k_joint = np.int32(0)
538
+ min_tot_joint = np.float64(1e300)
539
+ for k in range(B):
540
+ metric_k = np.float64(0.0)
541
+ for i in range(C):
542
+ metric_k = metric_k + bps_running_sum[i, k]
543
+ if metric_k < min_tot_joint:
544
+ min_tot_joint = metric_k
545
+ best_k_joint = k
546
+ # Raw argmin in [0, π/2); apply 4-fold causal unwrap
547
+ raw4 = np.float64(bps_angles[best_k_joint]) * np.float64(4.0)
548
+ for i in range(C):
549
+ diff4 = raw4 - bps_prev4[i]
550
+ diff4 = diff4 - np.float64(2.0 * np.pi) * np.round(
551
+ diff4 / (np.float64(2.0 * np.pi))
552
+ )
553
+ bps_prev4[i] = bps_prev4[i] + diff4
554
+ phi_hat_bps[i] = bps_prev4[i] / np.float64(4.0)
555
+ else:
556
+ for i in range(C):
557
+ best_k = np.int32(0)
558
+ min_tot = np.float64(1e300)
559
+ for k in range(B):
560
+ if bps_running_sum[i, k] < min_tot:
561
+ min_tot = bps_running_sum[i, k]
562
+ best_k = k
563
+ # Raw argmin in [0, π/2); apply 4-fold causal unwrap
564
+ raw4 = np.float64(bps_angles[best_k]) * np.float64(4.0)
565
+ diff4 = raw4 - bps_prev4[i]
566
+ diff4 = diff4 - np.float64(2.0 * np.pi) * np.round(
567
+ diff4 / (np.float64(2.0 * np.pi))
568
+ )
569
+ bps_prev4[i] = bps_prev4[i] + diff4
570
+ phi_hat_bps[i] = bps_prev4[i] / np.float64(4.0)
571
+
572
+ for i in range(C):
573
+ if cpr_mode == 1: # PLL: read current integrator state
574
+ phi_hat = pll_phi[i]
575
+ else: # BPS: unwrapped phase estimate
576
+ phi_hat = phi_hat_bps[i]
577
+
578
+ # -- Cycle-slip correction ------------------------------
579
+ if cs_enabled:
580
+ n_b = cs_buf_n[i]
581
+ ptr = cs_buf_ptr[i]
582
+ y_b = np.float64(phi_hat)
583
+
584
+ if n_b == 0:
585
+ phi_corr = phi_hat
586
+ else:
587
+ if n_b < np.int64(10):
588
+ last_pos = (ptr - np.int64(1) + np.int64(H)) % np.int64(
589
+ H
590
+ )
591
+ phi_expected = cs_buf_y[i, last_pos]
592
+ else:
593
+ # cs_stats layout: [0]=Sy, [1]=Sxy (relative coords)
594
+ Sy = cs_stats[i, 0]
595
+ Sxy = cs_stats[i, 1]
596
+ n_f = np.float64(n_b)
597
+ if n_b < np.int64(H):
598
+ Sx_c = (
599
+ n_f * (n_f - np.float64(1.0)) / np.float64(2.0)
600
+ )
601
+ Sxx_c = (
602
+ n_f
603
+ * (n_f - np.float64(1.0))
604
+ * (np.float64(2.0) * n_f - np.float64(1.0))
605
+ / np.float64(6.0)
606
+ )
607
+ denom = n_f * Sxx_c - Sx_c * Sx_c
608
+ else:
609
+ H_f = np.float64(H)
610
+ Sx_c = (
611
+ H_f * (H_f - np.float64(1.0)) / np.float64(2.0)
612
+ )
613
+ Sxx_c = (
614
+ H_f
615
+ * (H_f - np.float64(1.0))
616
+ * (np.float64(2.0) * H_f - np.float64(1.0))
617
+ / np.float64(6.0)
618
+ )
619
+ denom = H_f * Sxx_c - Sx_c * Sx_c
620
+ if np.abs(denom) > np.float64(1e-30):
621
+ slope = (n_f * Sxy - Sx_c * Sy) / denom
622
+ intercept = (Sy - slope * Sx_c) / n_f
623
+ else:
624
+ slope = np.float64(0.0)
625
+ intercept = Sy / n_f
626
+ phi_expected = slope * n_f + intercept
627
+
628
+ diff = y_b - phi_expected
629
+ k_slip = np.int64(np.round(diff / quantum))
630
+ if np.abs(diff) > np.float64(
631
+ cs_threshold
632
+ ) and k_slip != np.int64(0):
633
+ y_b = y_b - np.float64(k_slip) * quantum
634
+ phi_corr = y_b
635
+
636
+ # Update circular buffer - relative coords, only y needed
637
+ write_pos = ptr % np.int64(H)
638
+ if n_b == np.int64(H):
639
+ old_y = cs_buf_y[i, write_pos]
640
+ H_f = np.float64(H)
641
+ old_Sy = cs_stats[i, 0]
642
+ # Sxy_new = Sxy_old - Sy_old + y_old + (H-1)*y_new
643
+ cs_stats[i, 1] = (
644
+ cs_stats[i, 1]
645
+ - old_Sy
646
+ + old_y
647
+ + (H_f - np.float64(1.0)) * phi_corr
648
+ )
649
+ cs_stats[i, 0] = old_Sy - old_y + phi_corr
650
+ else:
651
+ n_b_f = np.float64(n_b)
652
+ cs_stats[i, 1] += n_b_f * phi_corr
653
+ cs_stats[i, 0] += phi_corr
654
+ cs_buf_y[i, write_pos] = phi_corr
655
+ cs_buf_ptr[i] = ptr + np.int64(1)
656
+ if n_b < np.int64(H):
657
+ cs_buf_n[i] = n_b + np.int64(1)
658
+ else:
659
+ phi_corr = np.float64(phi_hat)
660
+
661
+ phi_c[i] = phi_corr
662
+ phase_out[idx, i] = phi_corr
663
+
664
+ # y_final = y_raw * exp(-j * phi_corr)
665
+ cos_p = np.cos(phi_corr)
666
+ sin_p = np.sin(phi_corr)
667
+ yr = y_raw[i].real * np.float32(cos_p) + y_raw[i].imag * np.float32(
668
+ sin_p
669
+ )
670
+ yi = -y_raw[i].real * np.float32(sin_p) + y_raw[
671
+ i
672
+ ].imag * np.float32(cos_p)
673
+ y_fin[i] = np.complex64(yr + yi * np.complex64(1j))
674
+ y_out[idx, i] = y_fin[i]
675
+
676
+ # -- Slicer and error ---------------------------------------
677
+ for i in range(C):
678
+ if idx < n_train:
679
+ d_i = training[i, idx]
680
+ elif sq_side > np.int32(0):
681
+ ir = np.int32(
682
+ np.round((y_fin[i].real - sq_lev_min) / sq_d_grid)
683
+ )
684
+ if ir < np.int32(0):
685
+ ir = np.int32(0)
686
+ if ir >= sq_side:
687
+ ir = sq_side - np.int32(1)
688
+ ii = np.int32(
689
+ np.round((y_fin[i].imag - sq_lev_min) / sq_d_grid)
690
+ )
691
+ if ii < np.int32(0):
692
+ ii = np.int32(0)
693
+ if ii >= sq_side:
694
+ ii = sq_side - np.int32(1)
695
+ nr = sq_lev_min + np.float32(ir) * sq_d_grid
696
+ ni = sq_lev_min + np.float32(ii) * sq_d_grid
697
+ d_i = np.complex64(nr + ni * np.complex64(1j))
698
+ else:
699
+ min_dist = np.float32(1e38)
700
+ min_idx = 0
701
+ for k in range(M):
702
+ dv = y_fin[i] - constellation[k]
703
+ dist = dv.real * dv.real + dv.imag * dv.imag
704
+ if dist < min_dist:
705
+ min_dist = dist
706
+ min_idx = k
707
+ d_i = constellation[min_idx]
708
+ d_sym[i] = d_i
709
+ e_clean[i] = d_i - y_fin[i]
710
+ e_out[idx, i] = e_clean[i]
711
+
712
+ # -- PLL state update ---------------------------------------
713
+ if cpr_mode == 1:
714
+ if bps_joint_channels:
715
+ # Average phase error across channels (shared LO)
716
+ e_ph_sum = np.float32(0.0)
717
+ for i in range(C):
718
+ e_ph_sum = e_ph_sum + (
719
+ y_fin[i].imag * d_sym[i].real
720
+ - y_fin[i].real * d_sym[i].imag
721
+ )
722
+ e_ph_avg = e_ph_sum / np.float32(C)
723
+ for i in range(C):
724
+ pll_phi[i] = pll_phi[i] + pll_mu * e_ph_avg + pll_freq[i]
725
+ pll_freq[i] = pll_freq[i] + pll_beta * e_ph_avg
726
+ else:
727
+ for i in range(C):
728
+ # Cross-product phase detector: Im(y_fin · conj(d))
729
+ e_ph = (
730
+ y_fin[i].imag * d_sym[i].real
731
+ - y_fin[i].real * d_sym[i].imag
732
+ )
733
+ pll_phi[i] = (
734
+ pll_phi[i] + pll_mu * np.float32(e_ph) + pll_freq[i]
735
+ )
736
+ pll_freq[i] = pll_freq[i] + pll_beta * np.float32(e_ph)
737
+
738
+ # -- De-rotate error and weight update ----------------------
739
+ for i in range(C):
740
+ # e_eq = e_clean * exp(+j * phi_corr) (back to pre-rotation plane)
741
+ cos_p = np.cos(phi_c[i])
742
+ sin_p = np.sin(phi_c[i])
743
+ er = e_clean[i].real * np.float32(cos_p) - e_clean[
744
+ i
745
+ ].imag * np.float32(sin_p)
746
+ ei = e_clean[i].real * np.float32(sin_p) + e_clean[
747
+ i
748
+ ].imag * np.float32(cos_p)
749
+ e_eq[i] = np.complex64(er + ei * np.complex64(1j))
750
+
751
+ for i in range(C):
752
+ ce_i = np.conj(e_eq[i])
753
+ for j in range(C):
754
+ for t in range(num_taps):
755
+ W[i, j, t] = W[i, j, t] + step_size * ce_i * X_wins[j, t]
756
+
757
+ if store_weights:
758
+ for i in range(C):
759
+ for j in range(C):
760
+ for t in range(num_taps):
761
+ w_hist_out[idx, i, j, t] = W[i, j, t]
762
+
763
+ _NUMBA_KERNELS["lms_cpr"] = lms_cpr_loop
764
+ return _NUMBA_KERNELS["lms_cpr"]
765
+
766
+
767
+ def _get_numba_rls_cpr():
768
+ """JIT-compile and cache the Numba RLS+CPR butterfly loop kernel.
769
+
770
+ Combines the Leaky-RLS Riccati update with the same inline CPR tracker
771
+ used by the LMS CPR kernel.
772
+
773
+ Returns
774
+ -------
775
+ rls_cpr_loop : numba-compiled callable
776
+ """
777
+ if "rls_cpr" not in _NUMBA_KERNELS:
778
+ numba_mod = _get_numba()
779
+ if numba_mod is None:
780
+ raise ImportError("Numba is required for backend='numba'.")
781
+
782
+ @numba_mod.njit(cache=True, fastmath=False, nogil=True)
783
+ def rls_cpr_loop(
784
+ x_padded,
785
+ training,
786
+ constellation,
787
+ bps_phases_neg,
788
+ bps_angles,
789
+ bps_block_size,
790
+ bps_joint_channels,
791
+ W,
792
+ P,
793
+ lam,
794
+ leakage,
795
+ n_train,
796
+ n_update_halt,
797
+ stride,
798
+ store_weights,
799
+ cpr_mode,
800
+ pll_mu,
801
+ pll_beta,
802
+ symmetry,
803
+ cs_enabled,
804
+ cs_threshold,
805
+ pll_phi,
806
+ pll_freq,
807
+ cs_buf_x,
808
+ cs_buf_y,
809
+ cs_buf_ptr,
810
+ cs_buf_n,
811
+ cs_stats,
812
+ bps_prev4,
813
+ y_out,
814
+ e_out,
815
+ phase_out,
816
+ w_hist_out,
817
+ sq_lev_min,
818
+ sq_d_grid,
819
+ sq_side,
820
+ ):
821
+ # Same shapes as rls_loop plus CPR state - see lms_cpr_loop header.
822
+ C = W.shape[0]
823
+ num_taps = W.shape[2]
824
+ n_sym = y_out.shape[0]
825
+ N = C * num_taps
826
+ M = len(constellation)
827
+ B = len(bps_phases_neg)
828
+ H = cs_buf_x.shape[1]
829
+ two_pi = np.float64(2.0 * np.pi)
830
+ quantum = two_pi / np.float64(symmetry)
831
+
832
+ x_bar = np.empty(N, dtype=np.complex128)
833
+ Px = np.empty(N, dtype=np.complex128)
834
+ xH_P = np.empty(N, dtype=np.complex128)
835
+ k_gain = np.empty(N, dtype=np.complex128)
836
+ y_raw = np.empty(C, dtype=np.complex64)
837
+ y_fin = np.empty(C, dtype=np.complex64)
838
+ phi_c = np.empty(C, dtype=np.float64)
839
+ d_sym = np.empty(C, dtype=np.complex64)
840
+ e_clean = np.empty(C, dtype=np.complex64)
841
+ e_eq = np.empty(C, dtype=np.complex64)
842
+ phi_hat_bps = np.zeros(C, dtype=np.float64)
843
+ bps_dist_buf = np.zeros((C, bps_block_size, B), dtype=np.float32)
844
+ bps_running_sum = np.zeros((C, B), dtype=np.float64)
845
+ bps_dist_ptr = np.int64(0)
846
+
847
+ lam_f64 = np.float64(lam)
848
+ leak_term = np.float32(1.0) - np.float32(leakage)
849
+
850
+ for idx in range(n_sym):
851
+ sample_idx = idx * stride
852
+
853
+ for j in range(C):
854
+ for t in range(num_taps):
855
+ x_bar[j * num_taps + t] = x_padded[j, sample_idx + t]
856
+
857
+ # Butterfly forward pass
858
+ for i in range(C):
859
+ acc_re = 0.0
860
+ acc_im = 0.0
861
+ for j in range(C):
862
+ for t in range(num_taps):
863
+ w = W[i, j, t]
864
+ x = x_bar[j * num_taps + t]
865
+ acc_re += np.float64(w.real) * np.float64(
866
+ x.real
867
+ ) + np.float64(w.imag) * np.float64(x.imag)
868
+ acc_im += np.float64(w.real) * np.float64(
869
+ x.imag
870
+ ) - np.float64(w.imag) * np.float64(x.real)
871
+ y_raw[i] = acc_re + 1j * acc_im
872
+
873
+ # -- CPR: Phase estimation ----------------------------------
874
+
875
+ # BPS: incremental running-sum (O(B·M·C) per symbol, independent of K)
876
+ # O(1) square-QAM path: snap real/imag to nearest level grid point.
877
+ if cpr_mode == 2:
878
+ slot = bps_dist_ptr % np.int64(bps_block_size)
879
+ for i in range(C):
880
+ for k in range(B):
881
+ y_rot = y_raw[i] * bps_phases_neg[k]
882
+ if sq_side > np.int32(0):
883
+ ir = np.int32(
884
+ np.round((y_rot.real - sq_lev_min) / sq_d_grid)
885
+ )
886
+ if ir < np.int32(0):
887
+ ir = np.int32(0)
888
+ if ir >= sq_side:
889
+ ir = sq_side - np.int32(1)
890
+ ii = np.int32(
891
+ np.round((y_rot.imag - sq_lev_min) / sq_d_grid)
892
+ )
893
+ if ii < np.int32(0):
894
+ ii = np.int32(0)
895
+ if ii >= sq_side:
896
+ ii = sq_side - np.int32(1)
897
+ nr = sq_lev_min + np.float32(ir) * sq_d_grid
898
+ ni = sq_lev_min + np.float32(ii) * sq_d_grid
899
+ d2_min = (y_rot.real - nr) ** 2 + (y_rot.imag - ni) ** 2
900
+ else:
901
+ d2_min = np.float32(1e38)
902
+ for m in range(M):
903
+ dv = y_rot - constellation[m]
904
+ d2 = dv.real * dv.real + dv.imag * dv.imag
905
+ if d2 < d2_min:
906
+ d2_min = d2
907
+ bps_running_sum[i, k] = (
908
+ bps_running_sum[i, k]
909
+ - bps_dist_buf[i, slot, k]
910
+ + d2_min
911
+ )
912
+ bps_dist_buf[i, slot, k] = d2_min
913
+ bps_dist_ptr = bps_dist_ptr + np.int64(1)
914
+
915
+ if bps_joint_channels:
916
+ best_k_joint = np.int32(0)
917
+ min_tot_joint = np.float64(1e300)
918
+ for k in range(B):
919
+ metric_k = np.float64(0.0)
920
+ for i in range(C):
921
+ metric_k = metric_k + bps_running_sum[i, k]
922
+ if metric_k < min_tot_joint:
923
+ min_tot_joint = metric_k
924
+ best_k_joint = k
925
+ raw4 = np.float64(bps_angles[best_k_joint]) * np.float64(4.0)
926
+ for i in range(C):
927
+ diff4 = raw4 - bps_prev4[i]
928
+ diff4 = diff4 - np.float64(2.0 * np.pi) * np.round(
929
+ diff4 / (np.float64(2.0 * np.pi))
930
+ )
931
+ bps_prev4[i] = bps_prev4[i] + diff4
932
+ phi_hat_bps[i] = bps_prev4[i] / np.float64(4.0)
933
+ else:
934
+ for i in range(C):
935
+ best_k = np.int32(0)
936
+ min_tot = np.float64(1e300)
937
+ for k in range(B):
938
+ if bps_running_sum[i, k] < min_tot:
939
+ min_tot = bps_running_sum[i, k]
940
+ best_k = k
941
+ raw4 = np.float64(bps_angles[best_k]) * np.float64(4.0)
942
+ diff4 = raw4 - bps_prev4[i]
943
+ diff4 = diff4 - np.float64(2.0 * np.pi) * np.round(
944
+ diff4 / (np.float64(2.0 * np.pi))
945
+ )
946
+ bps_prev4[i] = bps_prev4[i] + diff4
947
+ phi_hat_bps[i] = bps_prev4[i] / np.float64(4.0)
948
+
949
+ for i in range(C):
950
+ if cpr_mode == 1:
951
+ phi_hat = pll_phi[i]
952
+ else:
953
+ phi_hat = phi_hat_bps[i]
954
+
955
+ # -- Cycle-slip correction ------------------------------
956
+ if cs_enabled:
957
+ n_b = cs_buf_n[i]
958
+ ptr = cs_buf_ptr[i]
959
+ y_b = np.float64(phi_hat)
960
+
961
+ if n_b == 0:
962
+ phi_corr = phi_hat
963
+ else:
964
+ if n_b < np.int64(10):
965
+ last_pos = (ptr - np.int64(1) + np.int64(H)) % np.int64(
966
+ H
967
+ )
968
+ phi_expected = cs_buf_y[i, last_pos]
969
+ else:
970
+ # cs_stats layout: [0]=Sy, [1]=Sxy (relative coords)
971
+ Sy = cs_stats[i, 0]
972
+ Sxy = cs_stats[i, 1]
973
+ n_f = np.float64(n_b)
974
+ if n_b < np.int64(H):
975
+ Sx_c = (
976
+ n_f * (n_f - np.float64(1.0)) / np.float64(2.0)
977
+ )
978
+ Sxx_c = (
979
+ n_f
980
+ * (n_f - np.float64(1.0))
981
+ * (np.float64(2.0) * n_f - np.float64(1.0))
982
+ / np.float64(6.0)
983
+ )
984
+ denom = n_f * Sxx_c - Sx_c * Sx_c
985
+ else:
986
+ H_f = np.float64(H)
987
+ Sx_c = (
988
+ H_f * (H_f - np.float64(1.0)) / np.float64(2.0)
989
+ )
990
+ Sxx_c = (
991
+ H_f
992
+ * (H_f - np.float64(1.0))
993
+ * (np.float64(2.0) * H_f - np.float64(1.0))
994
+ / np.float64(6.0)
995
+ )
996
+ denom = H_f * Sxx_c - Sx_c * Sx_c
997
+ if np.abs(denom) > np.float64(1e-30):
998
+ slope = (n_f * Sxy - Sx_c * Sy) / denom
999
+ intercept = (Sy - slope * Sx_c) / n_f
1000
+ else:
1001
+ slope = np.float64(0.0)
1002
+ intercept = Sy / n_f
1003
+ phi_expected = slope * n_f + intercept
1004
+
1005
+ diff = y_b - phi_expected
1006
+ k_slip = np.int64(np.round(diff / quantum))
1007
+ if np.abs(diff) > np.float64(
1008
+ cs_threshold
1009
+ ) and k_slip != np.int64(0):
1010
+ y_b = y_b - np.float64(k_slip) * quantum
1011
+ phi_corr = y_b
1012
+
1013
+ # Update circular buffer - relative coords, only y needed
1014
+ write_pos = ptr % np.int64(H)
1015
+ if n_b == np.int64(H):
1016
+ old_y = cs_buf_y[i, write_pos]
1017
+ H_f = np.float64(H)
1018
+ old_Sy = cs_stats[i, 0]
1019
+ # Sxy_new = Sxy_old - Sy_old + y_old + (H-1)*y_new
1020
+ cs_stats[i, 1] = (
1021
+ cs_stats[i, 1]
1022
+ - old_Sy
1023
+ + old_y
1024
+ + (H_f - np.float64(1.0)) * phi_corr
1025
+ )
1026
+ cs_stats[i, 0] = old_Sy - old_y + phi_corr
1027
+ else:
1028
+ n_b_f = np.float64(n_b)
1029
+ cs_stats[i, 1] += n_b_f * phi_corr
1030
+ cs_stats[i, 0] += phi_corr
1031
+ cs_buf_y[i, write_pos] = phi_corr
1032
+ cs_buf_ptr[i] = ptr + np.int64(1)
1033
+ if n_b < np.int64(H):
1034
+ cs_buf_n[i] = n_b + np.int64(1)
1035
+ else:
1036
+ phi_corr = np.float64(phi_hat)
1037
+
1038
+ phi_c[i] = phi_corr
1039
+ phase_out[idx, i] = phi_corr
1040
+
1041
+ cos_p = np.cos(phi_corr)
1042
+ sin_p = np.sin(phi_corr)
1043
+ yr = y_raw[i].real * np.float32(cos_p) + y_raw[i].imag * np.float32(
1044
+ sin_p
1045
+ )
1046
+ yi = -y_raw[i].real * np.float32(sin_p) + y_raw[
1047
+ i
1048
+ ].imag * np.float32(cos_p)
1049
+ y_fin[i] = np.complex64(yr + yi * np.complex64(1j))
1050
+ y_out[idx, i] = y_fin[i]
1051
+
1052
+ # -- Slicer and error ---------------------------------------
1053
+ for i in range(C):
1054
+ if idx < n_train:
1055
+ d_i = training[i, idx]
1056
+ elif sq_side > np.int32(0):
1057
+ ir = np.int32(
1058
+ np.round((y_fin[i].real - sq_lev_min) / sq_d_grid)
1059
+ )
1060
+ if ir < np.int32(0):
1061
+ ir = np.int32(0)
1062
+ if ir >= sq_side:
1063
+ ir = sq_side - np.int32(1)
1064
+ ii = np.int32(
1065
+ np.round((y_fin[i].imag - sq_lev_min) / sq_d_grid)
1066
+ )
1067
+ if ii < np.int32(0):
1068
+ ii = np.int32(0)
1069
+ if ii >= sq_side:
1070
+ ii = sq_side - np.int32(1)
1071
+ nr = sq_lev_min + np.float32(ir) * sq_d_grid
1072
+ ni = sq_lev_min + np.float32(ii) * sq_d_grid
1073
+ d_i = np.complex64(nr + ni * np.complex64(1j))
1074
+ else:
1075
+ min_dist = np.float32(1e38)
1076
+ min_idx = 0
1077
+ for kk in range(M):
1078
+ dv = y_fin[i] - constellation[kk]
1079
+ dist = dv.real * dv.real + dv.imag * dv.imag
1080
+ if dist < min_dist:
1081
+ min_dist = dist
1082
+ min_idx = kk
1083
+ d_i = constellation[min_idx]
1084
+ d_sym[i] = d_i
1085
+ e_clean[i] = d_i - y_fin[i]
1086
+ e_out[idx, i] = e_clean[i]
1087
+
1088
+ # -- PLL state update ---------------------------------------
1089
+ if cpr_mode == 1:
1090
+ if bps_joint_channels:
1091
+ e_ph_sum = np.float32(0.0)
1092
+ for i in range(C):
1093
+ e_ph_sum = e_ph_sum + (
1094
+ y_fin[i].imag * d_sym[i].real
1095
+ - y_fin[i].real * d_sym[i].imag
1096
+ )
1097
+ e_ph_avg = e_ph_sum / np.float32(C)
1098
+ for i in range(C):
1099
+ pll_phi[i] = pll_phi[i] + pll_mu * e_ph_avg + pll_freq[i]
1100
+ pll_freq[i] = pll_freq[i] + pll_beta * e_ph_avg
1101
+ else:
1102
+ for i in range(C):
1103
+ e_ph = (
1104
+ y_fin[i].imag * d_sym[i].real
1105
+ - y_fin[i].real * d_sym[i].imag
1106
+ )
1107
+ pll_phi[i] = (
1108
+ pll_phi[i] + pll_mu * np.float32(e_ph) + pll_freq[i]
1109
+ )
1110
+ pll_freq[i] = pll_freq[i] + pll_beta * np.float32(e_ph)
1111
+
1112
+ # -- De-rotate error ----------------------------------------
1113
+ for i in range(C):
1114
+ cos_p = np.cos(phi_c[i])
1115
+ sin_p = np.sin(phi_c[i])
1116
+ er = e_clean[i].real * np.float32(cos_p) - e_clean[
1117
+ i
1118
+ ].imag * np.float32(sin_p)
1119
+ ei = e_clean[i].real * np.float32(sin_p) + e_clean[
1120
+ i
1121
+ ].imag * np.float32(cos_p)
1122
+ e_eq[i] = np.complex64(er + ei * np.complex64(1j))
1123
+
1124
+ # -- Kalman gain --------------------------------------------
1125
+ for ii in range(N):
1126
+ acc_re = 0.0
1127
+ acc_im = 0.0
1128
+ for jj in range(N):
1129
+ p_val = P[ii, jj]
1130
+ x_val = x_bar[jj]
1131
+ acc_re += p_val.real * x_val.real - p_val.imag * x_val.imag
1132
+ acc_im += p_val.real * x_val.imag + p_val.imag * x_val.real
1133
+ Px[ii] = acc_re + 1j * acc_im
1134
+ denom_k = lam_f64
1135
+ for jj in range(N):
1136
+ denom_k = denom_k + (np.conj(x_bar[jj]) * Px[jj]).real
1137
+ inv_denom = np.float64(1.0) / denom_k
1138
+ for ii in range(N):
1139
+ k_gain[ii] = Px[ii] * inv_denom
1140
+
1141
+ if idx < n_update_halt:
1142
+ for i in range(C):
1143
+ ce_i = np.conj(e_eq[i])
1144
+ for j in range(C):
1145
+ for t in range(num_taps):
1146
+ W[i, j, t] = np.complex64(
1147
+ leak_term * W[i, j, t]
1148
+ + k_gain[j * num_taps + t] * ce_i
1149
+ )
1150
+ for jj in range(N):
1151
+ xH_P[jj] = np.conj(Px[jj])
1152
+ for ii in range(N):
1153
+ for jj in range(ii, N):
1154
+ val = (P[ii, jj] - k_gain[ii] * xH_P[jj]) / lam_f64
1155
+ if ii == jj:
1156
+ P[ii, ii] = np.complex128(val.real)
1157
+ else:
1158
+ conj_val = (P[jj, ii] - k_gain[jj] * xH_P[ii]) / lam_f64
1159
+ avg = (val + np.conj(conj_val)) * np.float64(0.5)
1160
+ P[ii, jj] = avg
1161
+ P[jj, ii] = np.conj(avg)
1162
+
1163
+ if store_weights:
1164
+ for i in range(C):
1165
+ for j in range(C):
1166
+ for t in range(num_taps):
1167
+ w_hist_out[idx, i, j, t] = W[i, j, t]
1168
+
1169
+ _NUMBA_KERNELS["rls_cpr"] = rls_cpr_loop
1170
+ return _NUMBA_KERNELS["rls_cpr"]
1171
+
1172
+
1173
+ def _get_numba_cma():
1174
+ """JIT-compile and cache the Numba CMA butterfly loop kernel.
1175
+
1176
+ Returns
1177
+ -------
1178
+ cma_loop : numba-compiled callable
1179
+ See kernel source for argument shapes and semantics.
1180
+ """
1181
+ if "cma" not in _NUMBA_KERNELS:
1182
+ numba_mod = _get_numba()
1183
+ if numba_mod is None:
1184
+ raise ImportError("Numba is required for backend='numba'.")
1185
+
1186
+ @numba_mod.njit(cache=True, fastmath=True, nogil=True)
1187
+ def cma_loop(
1188
+ x_padded,
1189
+ W,
1190
+ step_size,
1191
+ r2,
1192
+ stride,
1193
+ store_weights,
1194
+ y_out,
1195
+ e_out,
1196
+ w_hist_out,
1197
+ ):
1198
+ # x_padded : (C, N_pad) complex64
1199
+ # W : (C, C, num_taps) complex64 - modified in-place
1200
+ # step_size : float32 - fixed CMA gradient step μ
1201
+ # r2 : float32 - Godard radius R² = E[|s|⁴]/E[|s|²]
1202
+ # stride : int - sps
1203
+ # store_weights : bool
1204
+ # y_out : (N_sym, C) complex64
1205
+ # e_out : (N_sym, C) complex64
1206
+ # w_hist_out : (N_sym or 1, C, C, T) complex64
1207
+ C = W.shape[0]
1208
+ num_taps = W.shape[2]
1209
+ n_sym = y_out.shape[0]
1210
+
1211
+ X_wins = np.empty((C, num_taps), dtype=np.complex64)
1212
+ y = np.empty(C, dtype=np.complex64)
1213
+ e = np.empty(C, dtype=np.complex64)
1214
+
1215
+ for idx in range(n_sym):
1216
+ sample_idx = idx * stride
1217
+
1218
+ # Window extraction
1219
+ for c in range(C):
1220
+ for t in range(num_taps):
1221
+ X_wins[c, t] = x_padded[c, sample_idx + t]
1222
+
1223
+ # Butterfly forward pass
1224
+ for i in range(C):
1225
+ acc_re = 0.0
1226
+ acc_im = 0.0
1227
+ for j in range(C):
1228
+ for t in range(num_taps):
1229
+ w = W[i, j, t]
1230
+ x = X_wins[j, t]
1231
+ acc_re += np.float64(w.real) * np.float64(
1232
+ x.real
1233
+ ) + np.float64(w.imag) * np.float64(x.imag)
1234
+ acc_im += np.float64(w.real) * np.float64(
1235
+ x.imag
1236
+ ) - np.float64(w.imag) * np.float64(x.real)
1237
+ y[i] = acc_re + 1j * acc_im
1238
+ y_out[idx, i] = acc_re + 1j * acc_im
1239
+
1240
+ # CMA error: e[i] = y[i] * (|y[i]|² - R²)
1241
+ # Real-valued |y|² prevents imaginary leakage from FP noise
1242
+ # from causing a parasitic phase rotation via multiplication by y.
1243
+ for i in range(C):
1244
+ mod2 = y[i].real * y[i].real + y[i].imag * y[i].imag
1245
+ e[i] = y[i] * np.float32(mod2 - r2)
1246
+ e_out[idx, i] = e[i]
1247
+
1248
+ # Weight update (gradient descent): W -= mu * conj(e[i]) * X_wins[j,t]
1249
+ for i in range(C):
1250
+ ce_i = np.conj(e[i])
1251
+ for j in range(C):
1252
+ for t in range(num_taps):
1253
+ W[i, j, t] = W[i, j, t] - step_size * ce_i * X_wins[j, t]
1254
+
1255
+ if store_weights:
1256
+ for i in range(C):
1257
+ for j in range(C):
1258
+ for t in range(num_taps):
1259
+ w_hist_out[idx, i, j, t] = W[i, j, t]
1260
+
1261
+ _NUMBA_KERNELS["cma"] = cma_loop
1262
+ return _NUMBA_KERNELS["cma"]
1263
+
1264
+
1265
+ def _get_numba_rde():
1266
+ """JIT-compile and cache the Numba RDE butterfly loop kernel.
1267
+
1268
+ RDE (Radius Directed Equalizer) is a CMA variant that selects a
1269
+ per-symbol target radius from the set of unique constellation radii
1270
+ rather than using a single fixed Godard radius. This makes it
1271
+ converge correctly on multi-ring constellations (16-QAM, 64-QAM).
1272
+
1273
+ Returns
1274
+ -------
1275
+ rde_loop : numba-compiled callable
1276
+ See kernel source for argument shapes and semantics.
1277
+ """
1278
+ if "rde" not in _NUMBA_KERNELS:
1279
+ numba_mod = _get_numba()
1280
+ if numba_mod is None:
1281
+ raise ImportError("Numba is required for backend='numba'.")
1282
+
1283
+ @numba_mod.njit(cache=True, fastmath=True, nogil=True)
1284
+ def rde_loop(
1285
+ x_padded,
1286
+ W,
1287
+ step_size,
1288
+ radii,
1289
+ stride,
1290
+ store_weights,
1291
+ y_out,
1292
+ e_out,
1293
+ w_hist_out,
1294
+ ):
1295
+ # x_padded : (C, N_pad) complex64
1296
+ # W : (C, C, num_taps) complex64 - modified in-place
1297
+ # step_size : float32 - fixed gradient step μ
1298
+ # radii : (K,) float32 - unique |c| radii, sorted ascending
1299
+ # stride : int - sps
1300
+ # store_weights : bool
1301
+ # y_out : (N_sym, C) complex64
1302
+ # e_out : (N_sym, C) complex64
1303
+ # w_hist_out : (N_sym or 1, C, C, T) complex64
1304
+ C = W.shape[0]
1305
+ num_taps = W.shape[2]
1306
+ n_sym = y_out.shape[0]
1307
+ K = radii.shape[0]
1308
+
1309
+ X_wins = np.empty((C, num_taps), dtype=np.complex64)
1310
+ y = np.empty(C, dtype=np.complex64)
1311
+ e = np.empty(C, dtype=np.complex64)
1312
+
1313
+ for idx in range(n_sym):
1314
+ sample_idx = idx * stride
1315
+
1316
+ # Window extraction
1317
+ for c in range(C):
1318
+ for t in range(num_taps):
1319
+ X_wins[c, t] = x_padded[c, sample_idx + t]
1320
+
1321
+ # Butterfly forward pass
1322
+ for i in range(C):
1323
+ acc_re = 0.0
1324
+ acc_im = 0.0
1325
+ for j in range(C):
1326
+ for t in range(num_taps):
1327
+ w = W[i, j, t]
1328
+ x = X_wins[j, t]
1329
+ acc_re += np.float64(w.real) * np.float64(
1330
+ x.real
1331
+ ) + np.float64(w.imag) * np.float64(x.imag)
1332
+ acc_im += np.float64(w.real) * np.float64(
1333
+ x.imag
1334
+ ) - np.float64(w.imag) * np.float64(x.real)
1335
+ y[i] = acc_re + 1j * acc_im
1336
+ y_out[idx, i] = acc_re + 1j * acc_im
1337
+
1338
+ # RDE error: e[i] = y[i] * (|y[i]|² - R_d²)
1339
+ # R_d is the magnitude of the nearest constellation ring.
1340
+ # Linear scan over K unique radii is cheap (K ≤ 8 for typical QAM).
1341
+ for i in range(C):
1342
+ mod2 = y[i].real * y[i].real + y[i].imag * y[i].imag
1343
+ abs_y = np.sqrt(mod2)
1344
+
1345
+ best_rd = radii[0]
1346
+ best_dist = abs(abs_y - radii[0])
1347
+ for k in range(1, K):
1348
+ dist_k = abs(abs_y - radii[k])
1349
+ if dist_k < best_dist:
1350
+ best_dist = dist_k
1351
+ best_rd = radii[k]
1352
+
1353
+ e[i] = y[i] * np.float32(mod2 - best_rd * best_rd)
1354
+ e_out[idx, i] = e[i]
1355
+
1356
+ # Weight update: W -= mu * conj(e[i]) * X_wins[j,t]
1357
+ for i in range(C):
1358
+ ce_i = np.conj(e[i])
1359
+ for j in range(C):
1360
+ for t in range(num_taps):
1361
+ W[i, j, t] = W[i, j, t] - step_size * ce_i * X_wins[j, t]
1362
+
1363
+ if store_weights:
1364
+ for i in range(C):
1365
+ for j in range(C):
1366
+ for t in range(num_taps):
1367
+ w_hist_out[idx, i, j, t] = W[i, j, t]
1368
+
1369
+ _NUMBA_KERNELS["rde"] = rde_loop
1370
+ return _NUMBA_KERNELS["rde"]
1371
+
1372
+
1373
+ # -----------------------------------------------------------------------------
1374
+ # HYBRID DA / BLIND KERNELS (internal - used by cma/rde when pilot_ref supplied)
1375
+ # -----------------------------------------------------------------------------
1376
+ #
1377
+ # These kernels process every symbol with a per-step error-function switch:
1378
+ #
1379
+ # pilot_mask[idx] == 1 -> DA-LMS error: e[i] = pilot_ref[i, idx] - y[i]
1380
+ # pilot_mask[idx] == 0 -> blind error: e[i] = CMA or RDE formula
1381
+ #
1382
+ # The weight update rule is identical to the blind case. This achieves
1383
+ # phase-resolved convergence at pilot/preamble positions while preserving
1384
+ # correct blind tracking at data positions - all in a single kernel pass.
1385
+
1386
+
1387
+ def _get_numba_pa_cma():
1388
+ """JIT-compile and cache the Numba pilot-aided CMA butterfly loop kernel.
1389
+
1390
+ Hybrid CMA: LMS error at pilot positions (pilot_mask==1), standard
1391
+ Godard CMA error at data positions (pilot_mask==0). "Pilot-Aided" (PA)
1392
+ is the standard telecom term for this mixed DA/blind adaptation strategy.
1393
+
1394
+ Returns
1395
+ -------
1396
+ pa_cma_loop : numba-compiled callable
1397
+ """
1398
+ if "pa_cma" not in _NUMBA_KERNELS:
1399
+ numba_mod = _get_numba()
1400
+ if numba_mod is None:
1401
+ raise ImportError("Numba is required for backend='numba'.")
1402
+
1403
+ @numba_mod.njit(cache=True, fastmath=True, nogil=True)
1404
+ def pa_cma_loop(
1405
+ x_padded,
1406
+ W,
1407
+ step_size,
1408
+ r2,
1409
+ stride,
1410
+ store_weights,
1411
+ y_out,
1412
+ e_out,
1413
+ w_hist_out,
1414
+ pilot_ref,
1415
+ pilot_mask,
1416
+ ):
1417
+ # x_padded : (C, N_pad) complex64
1418
+ # W : (C, C, num_taps) complex64 - modified in-place
1419
+ # step_size : float32
1420
+ # r2 : float32 - Godard radius R²
1421
+ # stride : int
1422
+ # store_weights : bool
1423
+ # y_out : (N_sym, C) complex64
1424
+ # e_out : (N_sym, C) complex64
1425
+ # w_hist_out : (N_sym or 1, C, C, T) complex64
1426
+ # pilot_ref : (C, N_sym) complex64 - known symbol; 0 at data positions
1427
+ # pilot_mask : (N_sym,) uint8 - 1 at pilot/preamble, 0 at data
1428
+ C = W.shape[0]
1429
+ num_taps = W.shape[2]
1430
+ n_sym = y_out.shape[0]
1431
+
1432
+ X_wins = np.empty((C, num_taps), dtype=np.complex64)
1433
+ y = np.empty(C, dtype=np.complex64)
1434
+ e = np.empty(C, dtype=np.complex64)
1435
+
1436
+ for idx in range(n_sym):
1437
+ sample_idx = idx * stride
1438
+
1439
+ # Window extraction
1440
+ for c in range(C):
1441
+ for t in range(num_taps):
1442
+ X_wins[c, t] = x_padded[c, sample_idx + t]
1443
+
1444
+ # Butterfly forward pass
1445
+ for i in range(C):
1446
+ acc_re = 0.0
1447
+ acc_im = 0.0
1448
+ for j in range(C):
1449
+ for t in range(num_taps):
1450
+ w = W[i, j, t]
1451
+ x = X_wins[j, t]
1452
+ acc_re += np.float64(w.real) * np.float64(
1453
+ x.real
1454
+ ) + np.float64(w.imag) * np.float64(x.imag)
1455
+ acc_im += np.float64(w.real) * np.float64(
1456
+ x.imag
1457
+ ) - np.float64(w.imag) * np.float64(x.real)
1458
+ y[i] = acc_re + 1j * acc_im
1459
+ y_out[idx, i] = acc_re + 1j * acc_im
1460
+
1461
+ # Error: DA-LMS at pilots, CMA Godard at data
1462
+ for i in range(C):
1463
+ if pilot_mask[idx]:
1464
+ e[i] = (
1465
+ y[i] - pilot_ref[i, idx]
1466
+ ) # inverted to match blind subtractive update
1467
+ else:
1468
+ mod2 = y[i].real * y[i].real + y[i].imag * y[i].imag
1469
+ e[i] = y[i] * np.float32(mod2 - r2)
1470
+ e_out[idx, i] = e[i]
1471
+
1472
+ # Weight update
1473
+ for i in range(C):
1474
+ ce_i = np.conj(e[i])
1475
+ for j in range(C):
1476
+ for t in range(num_taps):
1477
+ W[i, j, t] = W[i, j, t] - step_size * ce_i * X_wins[j, t]
1478
+
1479
+ if store_weights:
1480
+ for i in range(C):
1481
+ for j in range(C):
1482
+ for t in range(num_taps):
1483
+ w_hist_out[idx, i, j, t] = W[i, j, t]
1484
+
1485
+ _NUMBA_KERNELS["pa_cma"] = pa_cma_loop
1486
+ return _NUMBA_KERNELS["pa_cma"]
1487
+
1488
+
1489
+ def _get_numba_pa_rde():
1490
+ """JIT-compile and cache the Numba pilot-aided RDE butterfly loop kernel.
1491
+
1492
+ Hybrid RDE: LMS error at pilot positions (pilot_mask==1), standard
1493
+ ring-directed RDE error at data positions (pilot_mask==0). "Pilot-Aided"
1494
+ (PA) is the standard telecom term for this mixed DA/blind adaptation strategy.
1495
+
1496
+ Returns
1497
+ -------
1498
+ pa_rde_loop : numba-compiled callable
1499
+ """
1500
+ if "pa_rde" not in _NUMBA_KERNELS:
1501
+ numba_mod = _get_numba()
1502
+ if numba_mod is None:
1503
+ raise ImportError("Numba is required for backend='numba'.")
1504
+
1505
+ @numba_mod.njit(cache=True, fastmath=True, nogil=True)
1506
+ def pa_rde_loop(
1507
+ x_padded,
1508
+ W,
1509
+ step_size,
1510
+ radii,
1511
+ stride,
1512
+ store_weights,
1513
+ y_out,
1514
+ e_out,
1515
+ w_hist_out,
1516
+ pilot_ref,
1517
+ pilot_mask,
1518
+ ):
1519
+ # x_padded : (C, N_pad) complex64
1520
+ # W : (C, C, num_taps) complex64 - modified in-place
1521
+ # step_size : float32
1522
+ # radii : (K,) float32 - unique |c| radii, sorted
1523
+ # stride : int
1524
+ # store_weights : bool
1525
+ # y_out : (N_sym, C) complex64
1526
+ # e_out : (N_sym, C) complex64
1527
+ # w_hist_out : (N_sym or 1, C, C, T) complex64
1528
+ # pilot_ref : (C, N_sym) complex64 - 0 at data positions
1529
+ # pilot_mask : (N_sym,) uint8 - 1 at pilot/preamble
1530
+ C = W.shape[0]
1531
+ num_taps = W.shape[2]
1532
+ n_sym = y_out.shape[0]
1533
+ K = radii.shape[0]
1534
+
1535
+ X_wins = np.empty((C, num_taps), dtype=np.complex64)
1536
+ y = np.empty(C, dtype=np.complex64)
1537
+ e = np.empty(C, dtype=np.complex64)
1538
+
1539
+ for idx in range(n_sym):
1540
+ sample_idx = idx * stride
1541
+
1542
+ # Window extraction
1543
+ for c in range(C):
1544
+ for t in range(num_taps):
1545
+ X_wins[c, t] = x_padded[c, sample_idx + t]
1546
+
1547
+ # Butterfly forward pass
1548
+ for i in range(C):
1549
+ acc_re = 0.0
1550
+ acc_im = 0.0
1551
+ for j in range(C):
1552
+ for t in range(num_taps):
1553
+ w = W[i, j, t]
1554
+ x = X_wins[j, t]
1555
+ acc_re += np.float64(w.real) * np.float64(
1556
+ x.real
1557
+ ) + np.float64(w.imag) * np.float64(x.imag)
1558
+ acc_im += np.float64(w.real) * np.float64(
1559
+ x.imag
1560
+ ) - np.float64(w.imag) * np.float64(x.real)
1561
+ y[i] = acc_re + 1j * acc_im
1562
+ y_out[idx, i] = acc_re + 1j * acc_im
1563
+
1564
+ # Error: DA-LMS at pilots, RDE ring-directed at data
1565
+ for i in range(C):
1566
+ if pilot_mask[idx]:
1567
+ e[i] = (
1568
+ y[i] - pilot_ref[i, idx]
1569
+ ) # inverted to match blind subtractive update
1570
+ else:
1571
+ mod2 = y[i].real * y[i].real + y[i].imag * y[i].imag
1572
+ abs_y = np.sqrt(mod2)
1573
+ best_rd = radii[0]
1574
+ best_dist = abs(abs_y - radii[0])
1575
+ for k in range(1, K):
1576
+ dist_k = abs(abs_y - radii[k])
1577
+ if dist_k < best_dist:
1578
+ best_dist = dist_k
1579
+ best_rd = radii[k]
1580
+ e[i] = y[i] * np.float32(mod2 - best_rd * best_rd)
1581
+ e_out[idx, i] = e[i]
1582
+
1583
+ # Weight update
1584
+ for i in range(C):
1585
+ ce_i = np.conj(e[i])
1586
+ for j in range(C):
1587
+ for t in range(num_taps):
1588
+ W[i, j, t] = W[i, j, t] - step_size * ce_i * X_wins[j, t]
1589
+
1590
+ if store_weights:
1591
+ for i in range(C):
1592
+ for j in range(C):
1593
+ for t in range(num_taps):
1594
+ w_hist_out[idx, i, j, t] = W[i, j, t]
1595
+
1596
+ _NUMBA_KERNELS["pa_rde"] = pa_rde_loop
1597
+ return _NUMBA_KERNELS["pa_rde"]
1598
+
1599
+
1600
+ def _get_numba_cs_block():
1601
+ """Lazy-compile a Numba JIT kernel for the block_lms cycle-slip correction loop.
1602
+
1603
+ Replaces the Python ``for ci in range(C): for i in range(B)`` loop with
1604
+ compiled native code. D->H/H->D transfers (few KB) still happen, but the
1605
+ loop body itself runs at native speed, eliminating the dominant overhead
1606
+ when ``cpr_cycle_slip_correction=True`` on GPU.
1607
+ """
1608
+ if "cs_block" not in _NUMBA_KERNELS:
1609
+ numba_mod = _get_numba()
1610
+ if numba_mod is None:
1611
+ return None
1612
+
1613
+ @numba_mod.njit(cache=True, fastmath=True, nogil=True)
1614
+ def cs_block(
1615
+ phi_blk,
1616
+ phi_corr,
1617
+ cs_buf_x,
1618
+ cs_buf_y,
1619
+ cs_buf_ptr,
1620
+ cs_buf_n,
1621
+ cs_stats,
1622
+ b_start,
1623
+ quantum,
1624
+ threshold,
1625
+ cs_H,
1626
+ ):
1627
+ """Per-symbol cycle-slip correction for one equalizer block.
1628
+
1629
+ Parameters
1630
+ ----------
1631
+ phi_blk : (C, B) float64 - BPS phase before correction (input)
1632
+ phi_corr : (C, B) float64 - corrected phase (output, pre-allocated)
1633
+ cs_buf_x : (C, H) float64 - unused (retained for call-site compat)
1634
+ cs_buf_y : (C, H) float64 - circular buffer of past corrected phases
1635
+ cs_buf_ptr: (C,) int64 - write pointer (monotonically increasing)
1636
+ cs_buf_n : (C,) int64 - number of valid entries (≤ H)
1637
+ cs_stats : (C, 4) float64 - [0]=Sy, [1]=Sxy (relative coords); [2..3] unused
1638
+ b_start : int - global symbol index of block start (unused)
1639
+ quantum : float64 - slip quantum (2π / symmetry)
1640
+ threshold : float64 - |diff| threshold for declaring a slip
1641
+ cs_H : int - circular buffer length
1642
+ """
1643
+ C = phi_blk.shape[0]
1644
+ B = phi_blk.shape[1]
1645
+ H_f = float(cs_H)
1646
+ Sx_full = H_f * (H_f - 1.0) / 2.0
1647
+ Sxx_full = H_f * (H_f - 1.0) * (2.0 * H_f - 1.0) / 6.0
1648
+ denom_full = H_f * Sxx_full - Sx_full * Sx_full
1649
+ for ci in range(C):
1650
+ for i in range(B):
1651
+ y_b = phi_blk[ci, i]
1652
+ n_b = cs_buf_n[ci]
1653
+ ptr = cs_buf_ptr[ci]
1654
+
1655
+ if n_b == 0:
1656
+ phi_expected = y_b
1657
+ elif n_b < 10:
1658
+ last_pos = (ptr - 1 + cs_H) % cs_H
1659
+ phi_expected = cs_buf_y[ci, last_pos]
1660
+ else:
1661
+ sy = cs_stats[ci, 0]
1662
+ sxy = cs_stats[ci, 1]
1663
+ n_f = float(n_b)
1664
+ if n_b < cs_H:
1665
+ Sx_c = n_f * (n_f - 1.0) / 2.0
1666
+ Sxx_c = n_f * (n_f - 1.0) * (2.0 * n_f - 1.0) / 6.0
1667
+ denom = n_f * Sxx_c - Sx_c * Sx_c
1668
+ else:
1669
+ Sx_c = Sx_full
1670
+ denom = denom_full
1671
+ if abs(denom) > 1e-30:
1672
+ slope = (n_f * sxy - Sx_c * sy) / denom
1673
+ intercept = (sy - slope * Sx_c) / n_f
1674
+ else:
1675
+ slope = 0.0
1676
+ intercept = sy / n_f
1677
+ phi_expected = slope * n_f + intercept
1678
+
1679
+ diff = y_b - phi_expected
1680
+ k_slip = int(round(diff / quantum))
1681
+ if abs(diff) > threshold and k_slip != 0:
1682
+ y_b -= float(k_slip) * quantum
1683
+ phi_corr[ci, i] = y_b
1684
+
1685
+ # Update circular buffer - relative coords, only y needed
1686
+ write_pos = ptr % cs_H
1687
+ if n_b == cs_H:
1688
+ old_y = cs_buf_y[ci, write_pos]
1689
+ old_sy = cs_stats[ci, 0]
1690
+ # Sxy_new = Sxy_old - Sy_old + y_old + (H-1)*y_new
1691
+ cs_stats[ci, 1] = (
1692
+ cs_stats[ci, 1] - old_sy + old_y + (H_f - 1.0) * y_b
1693
+ )
1694
+ cs_stats[ci, 0] = old_sy - old_y + y_b
1695
+ else:
1696
+ cs_stats[ci, 1] += float(n_b) * y_b
1697
+ cs_stats[ci, 0] += y_b
1698
+ cs_buf_y[ci, write_pos] = y_b
1699
+ cs_buf_ptr[ci] = ptr + 1
1700
+ if n_b < cs_H:
1701
+ cs_buf_n[ci] = n_b + 1
1702
+
1703
+ _NUMBA_KERNELS["cs_block"] = cs_block
1704
+ return _NUMBA_KERNELS["cs_block"]