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,1855 @@
1
+ """Block / frequency-domain equalizer engine (block_lms, FDAF)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+
10
+ from ..backend import ArrayType, _get_jax, dispatch, to_device, to_jax
11
+ from ..logger import logger
12
+ from ._common import (
13
+ _build_padded_samples,
14
+ _cpr_symmetry,
15
+ _init_butterfly_weights_numpy,
16
+ _normalize_inputs,
17
+ _sq_qam_slicer_params,
18
+ _unpack_result_jax,
19
+ _unpack_result_numpy,
20
+ _validate_sps,
21
+ _validate_w_init,
22
+ )
23
+ from ._kernels_jax import _get_jax_cma_block, _get_jax_lms_block, _get_jax_rde_block
24
+ from ._kernels_numba import _get_numba_cs_block
25
+ from .result import CPRState, EqualizerResult, _log_equalizer_exit
26
+
27
+
28
+ def _block_eq_xp(
29
+ kind,
30
+ x_padded,
31
+ W,
32
+ mu,
33
+ D,
34
+ stride,
35
+ *,
36
+ constellation=None,
37
+ n_train=0,
38
+ training=None,
39
+ r2=1.0,
40
+ radii=None,
41
+ pref=None,
42
+ pmask=None,
43
+ sq_side=0,
44
+ sq_lev_min=0.0,
45
+ sq_d_grid=1.0,
46
+ ):
47
+ """Array-native (NumPy/CuPy) block-update butterfly equalizer.
48
+
49
+ A plain Python loop over ``ceil(n_sym/D)`` chunks: the per-chunk work is
50
+ matmul-sized so interpreter overhead is amortised, and the same code runs
51
+ on NumPy (CPU) and CuPy (GPU) via the array module of ``x_padded``. The
52
+ forward/gradient einsums promote to ``complex128`` per CLAUDE.md, with
53
+ ``complex64`` weight storage.
54
+
55
+ Parameters mirror the per-symbol kernels; ``kind`` is ``'lms'``/``'cma'``/
56
+ ``'rde'`` and pilots (``pref``/``pmask``) invert the error to ``y - pref``.
57
+
58
+ Returns ``(y_out (n_sym, C), e_out (n_sym, C), W)`` on the input's backend.
59
+ """
60
+ x, xp, _ = dispatch(x_padded)
61
+ num_ch = W.shape[0]
62
+ num_taps = W.shape[2]
63
+ n_pad_samples = x.shape[1]
64
+ n_sym = (n_pad_samples - num_taps) // stride + 1
65
+
66
+ y_out = xp.empty((n_sym, num_ch), dtype=xp.complex64)
67
+ e_out = xp.empty((n_sym, num_ch), dtype=xp.complex64)
68
+
69
+ has_pilots = pref is not None and pmask is not None
70
+ if radii is not None:
71
+ radii = xp.asarray(radii).astype(xp.float64)
72
+ if constellation is not None:
73
+ constellation = xp.asarray(constellation).astype(xp.complex64)
74
+
75
+ for start in range(0, n_sym, D):
76
+ stop = min(start + D, n_sym)
77
+ d = stop - start # actual chunk length (D, or shorter for the tail)
78
+ # Window gather for this chunk -> (d, C, T)
79
+ base = (start + xp.arange(d))[:, None] * stride + xp.arange(num_taps)[None, :]
80
+ X_chunk = xp.transpose(x[:, base], (1, 0, 2)) # (d, C, T)
81
+ X64 = X_chunk.astype(xp.complex128)
82
+ Y = xp.einsum("ijt,djt->di", xp.conj(W).astype(xp.complex128), X64) # (d, C)
83
+ Y = Y.astype(xp.complex64)
84
+
85
+ if kind == "lms":
86
+ dd = _slice_block_xp(Y, xp, constellation, sq_side, sq_lev_min, sq_d_grid)
87
+ sym_idx = start + xp.arange(d)
88
+ use_train = (sym_idx < n_train)[:, None]
89
+ tr = training[:, start:stop].T if training is not None else xp.zeros_like(Y)
90
+ dsym = xp.where(use_train, tr, dd)
91
+ E = Y - dsym
92
+ elif kind == "cma":
93
+ E = Y * (xp.real(Y * xp.conj(Y)) - xp.float32(r2))
94
+ elif kind == "rde":
95
+ abs_y2 = xp.real(Y * xp.conj(Y))
96
+ abs_y = xp.sqrt(abs_y2)
97
+ rd = radii[
98
+ xp.argmin(xp.abs(abs_y[..., None] - radii[None, None, :]), axis=-1)
99
+ ]
100
+ E = Y * (abs_y2 - rd.astype(xp.float32) ** 2)
101
+ else:
102
+ raise ValueError(f"unknown block kind {kind!r}")
103
+
104
+ if has_pilots:
105
+ pm = pmask[start:stop].astype(bool)[:, None]
106
+ pr = pref[:, start:stop].T
107
+ E = xp.where(pm, Y - pr, E)
108
+
109
+ grad = xp.einsum(
110
+ "di,djt->ijt", xp.conj(E).astype(xp.complex128), X64
111
+ ) # (C, C, T)
112
+ W = (W - (xp.complex128(mu) * grad)).astype(xp.complex64)
113
+
114
+ y_out[start:stop] = Y
115
+ e_out[start:stop] = E
116
+
117
+ return y_out, e_out, W
118
+
119
+
120
+ def _slice_block_xp(Y, xp, constellation, sq_side, sq_lev_min, sq_d_grid):
121
+ """Vectorised nearest-constellation slicer for the array-native block path."""
122
+ if sq_side > 0:
123
+ ir = xp.clip(
124
+ xp.round((Y.real - sq_lev_min) / sq_d_grid).astype(xp.int32),
125
+ 0,
126
+ sq_side - 1,
127
+ )
128
+ ii = xp.clip(
129
+ xp.round((Y.imag - sq_lev_min) / sq_d_grid).astype(xp.int32),
130
+ 0,
131
+ sq_side - 1,
132
+ )
133
+ nr = sq_lev_min + ir.astype(xp.float32) * xp.float32(sq_d_grid)
134
+ ni = sq_lev_min + ii.astype(xp.float32) * xp.float32(sq_d_grid)
135
+ return (nr + 1j * ni).astype(xp.complex64)
136
+ d2 = xp.abs(Y[..., None] - constellation) ** 2 # (d, C, M)
137
+ return constellation[xp.argmin(d2, axis=-1)]
138
+
139
+
140
+ def _validate_block_mode(
141
+ update_mode, block_len, backend, *, cpr_type=None, store_weights=False
142
+ ):
143
+ """Validate ``update_mode``/``block_len`` and the block-mode constraints.
144
+
145
+ No-op for ``update_mode='sequential'``. For ``'block'`` it enforces the
146
+ following: ``backend in {'jax', 'xp'}`` (``'numba'`` is a pointless
147
+ combination), a positive ``block_len``, and no ``cpr_type``/``store_weights``
148
+ (unsupported in v1).
149
+ """
150
+ if update_mode not in ("sequential", "block"):
151
+ raise ValueError(
152
+ f"update_mode must be 'sequential' or 'block'. Got {update_mode!r}."
153
+ )
154
+ if update_mode == "sequential":
155
+ return
156
+ if block_len < 1:
157
+ raise ValueError(f"block_len must be >= 1. Got {block_len}.")
158
+ if backend not in ("jax", "xp"):
159
+ raise ValueError(
160
+ "update_mode='block' requires backend='jax' (chunked scan) or "
161
+ f"backend='xp' (array-native NumPy/CuPy). Got backend={backend!r}; "
162
+ "backend='numba' is a pointless combination for block updates."
163
+ )
164
+ if cpr_type is not None:
165
+ raise ValueError(
166
+ "cpr_type is not supported with update_mode='block' (v1). Run CPR "
167
+ "as a separate stage, or use update_mode='sequential'."
168
+ )
169
+ if store_weights:
170
+ raise ValueError(
171
+ "store_weights is not supported with update_mode='block' (v1)."
172
+ )
173
+
174
+
175
+ def _resolve_jax_platform(x_jax, device):
176
+ """Resolve the JAX placement platform string for a transferred input."""
177
+ try:
178
+ if device is not None:
179
+ return device.lower()
180
+ if hasattr(x_jax, "device"):
181
+ return x_jax.device.platform
182
+ return list(x_jax.devices())[0].platform
183
+ except Exception:
184
+ return "cpu"
185
+
186
+
187
+ def _build_slicer_constellation(modulation, order, unipolar, training_np, pmf):
188
+ """Build the NumPy slicer constellation for the DD/block paths.
189
+
190
+ Mirrors the per-symbol kernels: a Gray constellation when ``modulation``
191
+ and ``order`` are given (PS-QAM scaled to unit power when ``pmf`` is
192
+ supplied), otherwise the unique rounded training symbols.
193
+ """
194
+ if modulation is not None and order is not None:
195
+ from ..mapping import constellation_power, gray_constellation
196
+
197
+ reference_constellation = gray_constellation(
198
+ modulation, order, unipolar=unipolar
199
+ )
200
+ constellation_np = (
201
+ to_device(reference_constellation, "cpu").flatten().astype(np.complex64)
202
+ )
203
+ elif training_np is not None:
204
+ constellation_np = np.unique(np.round(training_np.reshape(-1), decimals=8))
205
+ else:
206
+ raise ValueError("modulation and order must be provided for DD mode.")
207
+
208
+ if pmf is not None and modulation is not None and order is not None:
209
+ _e_ps = constellation_power(constellation_np, pmf)
210
+ if _e_ps < 1.0 - 1e-6:
211
+ constellation_np = (
212
+ constellation_np * np.float32(1.0 / np.sqrt(_e_ps))
213
+ ).astype(np.complex64)
214
+ return constellation_np
215
+
216
+
217
+ def _prep_blind_block_inputs(
218
+ samples,
219
+ *,
220
+ sps,
221
+ stride,
222
+ pad_left,
223
+ pad_right,
224
+ samples_prefix,
225
+ pad_mode,
226
+ input_norm_factor,
227
+ use_pilots,
228
+ pilot_gain_db,
229
+ pilot_mask,
230
+ pilot_ref,
231
+ c_ps,
232
+ num_ch,
233
+ num_taps,
234
+ center_tap,
235
+ w_init,
236
+ ):
237
+ """Normalise/pad inputs and build pilot arrays for blind block CMA/RDE.
238
+
239
+ Mirrors the per-symbol ``cma``/``rde`` numba prep: pilot deboost before the
240
+ global RMS normalisation, overlap padding, initial butterfly weights, and
241
+ the dense pilot reference/mask (PS-QAM-scaled). Returns NumPy arrays for
242
+ the backend-agnostic block dispatch.
243
+ """
244
+ samples_np = np.ascontiguousarray(to_device(samples, "cpu"), dtype=np.complex64)
245
+ if use_pilots and pilot_gain_db != 0.0:
246
+ _amp = np.float32(10.0 ** (pilot_gain_db / 20.0))
247
+ _smask = np.repeat(pilot_mask.astype(bool), stride)
248
+ samples_np[..., _smask] /= _amp
249
+ samples_np, _, eq_norm = _normalize_inputs(
250
+ samples_np, None, sps, input_norm_factor=input_norm_factor
251
+ )
252
+ samples_padded_np = _build_padded_samples(
253
+ samples_np, pad_left, pad_right, samples_prefix, pad_mode, eq_norm, sps
254
+ )
255
+ if w_init is not None:
256
+ w_arr = _validate_w_init(
257
+ np.ascontiguousarray(to_device(w_init, "cpu"), dtype=np.complex64),
258
+ num_ch,
259
+ num_taps,
260
+ )
261
+ else:
262
+ w_arr = _init_butterfly_weights_numpy(num_ch, num_taps, center_tap=center_tap)
263
+ pref_np = pmask_np = None
264
+ if use_pilots:
265
+ pref_np = np.ascontiguousarray(to_device(pilot_ref, "cpu"), dtype=np.complex64)
266
+ if c_ps is not None:
267
+ pref_np = (pref_np * c_ps).astype(np.complex64)
268
+ pmask_np = np.ascontiguousarray(pilot_mask, dtype=np.uint8)
269
+ return samples_padded_np, w_arr, eq_norm, pref_np, pmask_np
270
+
271
+
272
+ def _run_block_equalizer(
273
+ kind,
274
+ *,
275
+ samples_padded_np,
276
+ w_arr,
277
+ num_ch,
278
+ num_taps,
279
+ n_sym,
280
+ stride,
281
+ block_len,
282
+ step_size,
283
+ backend,
284
+ device,
285
+ was_1d,
286
+ xp,
287
+ eq_norm,
288
+ name,
289
+ debug_plot=False,
290
+ plot_smoothing=50,
291
+ check_convergence=False,
292
+ constellation_np=None,
293
+ train_full=None,
294
+ n_train_aligned=0,
295
+ sq_side=0,
296
+ sq_lev_min=0.0,
297
+ sq_d_grid=1.0,
298
+ r2=1.0,
299
+ radii_np=None,
300
+ pref_np=None,
301
+ pmask_np=None,
302
+ ):
303
+ """Backend dispatch shared by the ``update_mode='block'`` path of the
304
+ time-domain equalizers (``lms``/``cma``/``rde``).
305
+
306
+ ``backend='xp'`` runs the array-native loop on the input's module
307
+ (NumPy/CuPy); ``backend='jax'`` runs the chunked ``lax.scan``. Returns a
308
+ finalised ``EqualizerResult`` (via ``_log_equalizer_exit``).
309
+ """
310
+ D = int(block_len)
311
+ has_pilots = pref_np is not None and pmask_np is not None
312
+
313
+ if backend == "xp":
314
+ x_pad = xp.asarray(samples_padded_np)
315
+ W0 = xp.asarray(w_arr)
316
+ kwargs = {}
317
+ if kind == "lms":
318
+ kwargs.update(
319
+ constellation=xp.asarray(constellation_np),
320
+ n_train=int(n_train_aligned),
321
+ training=xp.asarray(train_full),
322
+ sq_side=int(sq_side),
323
+ sq_lev_min=float(sq_lev_min),
324
+ sq_d_grid=float(sq_d_grid),
325
+ )
326
+ elif kind == "cma":
327
+ kwargs.update(r2=float(r2))
328
+ elif kind == "rde":
329
+ kwargs.update(radii=xp.asarray(radii_np))
330
+ if has_pilots:
331
+ kwargs.update(pref=xp.asarray(pref_np), pmask=xp.asarray(pmask_np))
332
+ y_out, e_out, W_final = _block_eq_xp(
333
+ kind, x_pad, W0, float(step_size), D, stride, **kwargs
334
+ )
335
+ result = _unpack_result_numpy(
336
+ y_out,
337
+ e_out,
338
+ W_final,
339
+ np.empty((1, num_ch, num_ch, num_taps), dtype=np.complex64),
340
+ was_1d,
341
+ False,
342
+ n_sym=None,
343
+ xp=xp,
344
+ num_train_symbols=int(n_train_aligned),
345
+ input_norm_factor=eq_norm,
346
+ )
347
+ return _log_equalizer_exit(
348
+ result,
349
+ name=name,
350
+ debug_plot=debug_plot,
351
+ check_convergence=check_convergence,
352
+ plot_smoothing=plot_smoothing,
353
+ )
354
+
355
+ # backend == "jax"
356
+ jax, jnp, _ = _get_jax()
357
+ if jax is None or jnp is None:
358
+ raise ImportError("JAX is required for backend='jax'.")
359
+ x_jax = to_jax(samples_padded_np, device=device) # (C, N_pad)
360
+ platform = _resolve_jax_platform(x_jax, device)
361
+ W_jax = to_jax(w_arr, device=platform)
362
+ mu_jax = to_jax(jnp.float32(step_size), device=platform)
363
+
364
+ if kind == "lms":
365
+ const_jax = to_jax(constellation_np, device=platform)
366
+ train_jax = to_jax(train_full, device=platform)
367
+ n_train_jax = to_jax(jnp.int32(n_train_aligned), device=platform)
368
+ run = _get_jax_lms_block(
369
+ num_taps,
370
+ stride,
371
+ len(constellation_np),
372
+ num_ch,
373
+ n_sym,
374
+ D,
375
+ int(sq_side),
376
+ float(sq_lev_min),
377
+ float(sq_d_grid),
378
+ )
379
+ y_jax, e_jax, W_out, _ = run(
380
+ x_jax, train_jax, const_jax, W_jax, mu_jax, n_train_jax
381
+ )
382
+ else:
383
+ if has_pilots:
384
+ pref_jax = to_jax(pref_np, device=platform) # (C, n_sym)
385
+ pmask_jax = to_jax(pmask_np.astype(bool), device=platform) # (n_sym,)
386
+ else:
387
+ pref_jax = to_jax(np.zeros((num_ch, n_sym), np.complex64), device=platform)
388
+ pmask_jax = to_jax(np.zeros((n_sym,), bool), device=platform)
389
+ if kind == "cma":
390
+ r2_jax = to_jax(jnp.float32(r2), device=platform)
391
+ run = _get_jax_cma_block(num_taps, stride, num_ch, n_sym, D, has_pilots)
392
+ y_jax, e_jax, W_out, _ = run(
393
+ x_jax, W_jax, mu_jax, r2_jax, pref_jax, pmask_jax
394
+ )
395
+ else: # rde
396
+ radii_jax = to_jax(np.asarray(radii_np, np.float32), device=platform)
397
+ run = _get_jax_rde_block(
398
+ num_taps, stride, len(radii_np), num_ch, n_sym, D, has_pilots
399
+ )
400
+ y_jax, e_jax, W_out, _ = run(
401
+ x_jax, W_jax, mu_jax, radii_jax, pref_jax, pmask_jax
402
+ )
403
+
404
+ result = _unpack_result_jax(
405
+ y_jax,
406
+ e_jax,
407
+ W_out,
408
+ None,
409
+ was_1d,
410
+ False,
411
+ n_sym=None,
412
+ xp=xp,
413
+ num_train_symbols=int(n_train_aligned),
414
+ input_norm_factor=eq_norm,
415
+ )
416
+ return _log_equalizer_exit(
417
+ result,
418
+ name=name,
419
+ debug_plot=debug_plot,
420
+ check_convergence=check_convergence,
421
+ plot_smoothing=plot_smoothing,
422
+ )
423
+
424
+
425
+ def block_lms(
426
+ samples: ArrayType,
427
+ training_symbols: ArrayType | None = None,
428
+ num_taps: int = 21,
429
+ sps: int = 2,
430
+ step_size: float = 2e-4,
431
+ block_size: int = 256,
432
+ modulation: str | None = None,
433
+ order: int | None = None,
434
+ unipolar: bool = False,
435
+ store_weights: bool = False,
436
+ w_init: ArrayType | None = None,
437
+ pmf: Any | None = None,
438
+ cpr_type: str | None = None,
439
+ cpr_bps_test_phases: int = 64,
440
+ cpr_bps_block_size: int = 32,
441
+ cpr_joint_channels: bool = False,
442
+ cpr_cycle_slip_correction: bool = False,
443
+ cpr_cycle_slip_history: int = 100,
444
+ cpr_cycle_slip_threshold: float = np.pi / 4,
445
+ debug_plot: bool = False,
446
+ plot_smoothing: int = 50,
447
+ cpr_state: CPRState | None = None,
448
+ input_norm_factor: float | np.ndarray | None = None,
449
+ samples_prefix: ArrayType | None = None,
450
+ pad_mode: str = "zeros",
451
+ cuda_graph: bool = True,
452
+ ) -> EqualizerResult:
453
+ """Block LMS equalizer with frequency-domain gradient accumulation.
454
+
455
+ Processes the signal in fixed-size blocks of ``block_size`` symbols.
456
+ Within each block the filter is held frozen, all ``block_size`` errors are
457
+ accumulated into a single frequency-domain gradient, and the weights are
458
+ updated once per block. This amortises the FFT overhead over many symbols,
459
+ making it significantly more efficient than per-symbol LMS on GPU for large
460
+ MIMO configurations (C ≥ 4) or long sequences.
461
+
462
+ The primary target is **GPU** via CuPy. On CPU, per-symbol LMS with the
463
+ Numba backend (``lms(..., backend='numba')``) is typically faster because
464
+ the block-FFT overhead outweighs the gradient-accumulation saving for
465
+ small channel counts.
466
+
467
+ ``block_lms`` is the **trained / decision-directed** frequency-domain
468
+ equalizer. Its blind siblings share the same overlap-save engine but use a
469
+ phase-blind error: :func:`block_cma` (Godard constant-modulus) and
470
+ :func:`block_rde` (ring-directed, for multi-ring QAM). For a time-domain
471
+ block update with shorter adaptation lag (fast dynamics) use
472
+ ``lms(..., update_mode='block')``.
473
+
474
+ Algorithm (per block b)
475
+ -----------------------
476
+ 1. **Forward pass** - frequency-domain butterfly filter::
477
+
478
+ Y_fd[i] = sum_j conj(H_fd[i,j]) * X_fd[j]
479
+
480
+ where ``H_fd = FFT(h, n=F)`` and ``X_fd = FFT(x_block, n=F)``.
481
+ Output symbols are extracted at decimated positions ``y[n] = y_time[n*sps]``.
482
+
483
+ 2. **BPS phase recovery** (if ``cpr_type='bps'``) - for each symbol in the
484
+ block, averages the min-distance metric over a causal trailing window of
485
+ ``cpr_bps_block_size`` symbols and picks the minimum-metric candidate
486
+ rotation. This produces one phase estimate ``phi_n`` per symbol
487
+ (not one per block), so ``cpr_bps_block_size`` and ``block_size`` are
488
+ independent parameters: ``block_size`` controls FFT/gradient efficiency
489
+ while ``cpr_bps_block_size`` controls phase noise suppression. The raw
490
+ ``[0, pi/2)`` argmin is converted to full-range radians by a causal 4-fold
491
+ unwrap, and stored in a float64 accumulator in ``phase_trajectory``.
492
+
493
+ 3. **Cycle-slip correction** (if ``cpr_cycle_slip_correction=True``) - for
494
+ each symbol of the per-symbol BPS phase tensor ``phi_n`` (shape
495
+ ``(C, B)``) the phase is compared to a linear-regression prediction
496
+ built from a circular buffer of ``cpr_cycle_slip_history`` past
497
+ corrected phases (identical algorithm to ``lms`` with
498
+ ``cpr_type='bps'``). If ``|phi_n - phi_pred| > cpr_cycle_slip_threshold``
499
+ the nearest ``2*pi/symmetry`` quantum is subtracted and the corrected
500
+ value is stored in the history buffer. On GPU the detector runs as a
501
+ small sequential CUDA kernel on device-resident buffers (no host
502
+ round-trip); when that kernel is unavailable the ``(C, B)`` float64
503
+ block is transferred device->host, corrected by the Numba/Python
504
+ detector, and written back - one D->H + H->D round-trip per block.
505
+
506
+ 4. **Error** - training or DD slicer on CPR-corrected output; back-rotated
507
+ to the tap plane using the block-average phase ``phi_b``::
508
+
509
+ e_taps[n] = e_clean[n] * exp(+j*phi_b)
510
+
511
+ 5. **Gradient** - scatter ``e_taps`` to sample positions, then::
512
+
513
+ dH_fd[i,j] = conj(E_fd[i]) * X_fd[j]
514
+ h += mu * IFFT(dH_fd)[...:T]
515
+
516
+ ``mu`` is applied to the **summed** block gradient (all B per-symbol
517
+ contributions). This sum is exactly what a frozen-weight per-symbol LMS
518
+ would accumulate over the same B symbols, so ``step_size`` is on the
519
+ **same scale as** ``lms``: the same ``mu`` yields the same
520
+ convergence and steady-state MSE (see ``step_size`` below). Only the
521
+ *stability ceiling* is Bx lower - the operating step that matches
522
+ ``lms`` is unchanged.
523
+
524
+ Parameters
525
+ ----------
526
+ samples : array_like
527
+ Input signal samples. Shape: ``(N_samples,)`` for SISO or
528
+ ``(C, N_samples)`` for MIMO butterfly equalization.
529
+ Typically at 2 samples/symbol for fractionally-spaced equalization.
530
+ training_symbols : array_like, optional
531
+ Known transmitted symbols at 1 SPS.
532
+ Shape: ``(N_train,)`` for SISO or ``(C, N_train)`` for MIMO.
533
+ num_taps : int, default 21
534
+ Number of taps per FIR filter (tap count in samples).
535
+ sps : int, default 2
536
+ Samples per symbol. ``sps=2`` (T/2-spaced) is the default.
537
+ step_size : float, default 2e-4
538
+ LMS step size μ, on the **same scale as** ``lms``. Use the same
539
+ value you would use for ``lms``: because the block update is the
540
+ summed gradient over all B symbols - exactly what a frozen-weight
541
+ per-symbol LMS accumulates over those symbols - the same μ produces the
542
+ same convergence speed and steady-state MSE, independent of
543
+ ``block_size``. **Do not** divide by ``block_size``; doing so
544
+ under-adapts the filter by that factor.
545
+
546
+ The only ``block_size`` dependence is the *stability ceiling*: because
547
+ the weights are frozen across the block, the maximum stable μ is
548
+ ``2/(B·C·T·P_x)`` - roughly ``block_size`` times lower than
549
+ ``lms``. Reduce μ below your ``lms`` value **only if** it
550
+ exceeds this ceiling (i.e. the run raises the divergence error); the
551
+ default ``2e-4`` is conservative and safe for ``block_size`` up to a
552
+ few thousand.
553
+ block_size : int, default 256
554
+ Number of output symbols per LMS gradient accumulation block. Larger
555
+ values increase GPU efficiency but reduce adaptation speed. Independent
556
+ of the BPS averaging window (see ``cpr_bps_block_size``).
557
+ modulation : str, optional
558
+ Modulation scheme (e.g., ``'qam'``, ``'psk'``). Required when
559
+ ``training_symbols`` is ``None``.
560
+ order : int, optional
561
+ Modulation order (e.g., 16, 64).
562
+ unipolar : bool, default False
563
+ Unipolar PAM flag.
564
+ store_weights : bool, default False
565
+ If ``True``, stores the weight tensor at every block start in
566
+ ``EqualizerResult.weights_history``.
567
+ w_init : array_like, optional
568
+ Initial tap weights, shape ``(C, C, T)`` or SISO short-hands.
569
+ pmf : array_like, optional
570
+ Probability mass function for PS-QAM constellation scaling.
571
+ cpr_type : {'bps', None}, default None
572
+ Inline carrier phase recovery. Only ``'bps'`` is supported; PLL is
573
+ not available because its per-symbol PI integration does not fit the
574
+ block gradient model.
575
+
576
+ **BPS dual-path design:** within each equalizer block, the wrapped
577
+ float32 phase estimate rotates the pre-CPR symbol ``y_raw`` to
578
+ produce the weight-update error, while the unwrapped float64
579
+ accumulator is written to ``phase_trajectory``. This separation
580
+ prevents float32 rounding errors from accumulating over long signals.
581
+
582
+ **4-fold causal unwrap:** the BPS ``argmin`` is in ``[0, π/2)``.
583
+ A per-symbol causal tracker adds or subtracts ``π/2`` multiples to
584
+ maintain continuity, then scales to full-range radians.
585
+ cpr_bps_test_phases : int, default 64
586
+ Number of BPS candidate angles in ``[0, π/2)``. 32-64 is
587
+ sufficient for ≤ 16-QAM; use 64-128 for 64-QAM.
588
+ cpr_bps_block_size : int, default 32
589
+ Trailing-window length (symbols) summed before the BPS ``argmin``.
590
+ This is evaluated per symbol (not per equalizer block), so it is
591
+ independent of ``block_size``. Larger values reduce phase-noise
592
+ variance at the cost of increased tracking latency.
593
+ ``cpr_bps_block_size=1`` gives single-symbol BPS.
594
+ cpr_joint_channels : bool, default False
595
+ For MIMO inputs (C > 1): if ``True``, the BPS distance metrics are
596
+ summed across all C channels before ``argmin``, producing one shared
597
+ phase estimate broadcast to all channels. Reduces estimation
598
+ variance by ~√C for shared-LO transmitters. When ``False``, each
599
+ channel estimates its phase independently. Ignored for SISO inputs.
600
+ cpr_cycle_slip_correction : bool, default False
601
+ Enable per-symbol cycle-slip detection and correction using the same
602
+ algorithm as ``lms``: after each BPS block every symbol phase is
603
+ compared to a regression prediction, corrected if a slip is detected,
604
+ and added to the circular history buffer. On GPU the detector runs
605
+ on-device (custom CUDA kernel); without it each block costs one
606
+ ``(C, B)`` float64 D->H + H->D round-trip through the CPU detector.
607
+ cpr_cycle_slip_history : int, default 100
608
+ Length of the per-symbol phase history buffer used for the linear
609
+ regression predictor. Same semantics as in ``lms``: one entry
610
+ per symbol, so ``100`` means 100 past corrected symbol phases.
611
+ Ignored when ``cpr_cycle_slip_correction=False``.
612
+ cpr_cycle_slip_threshold : float, default π/4
613
+ Maximum phase step (radians) between adjacent symbols before a
614
+ cycle slip is declared. Set to half the constellation's angular
615
+ symmetry quantum (``π/4`` for QPSK/QAM). Ignored when
616
+ ``cpr_cycle_slip_correction=False``.
617
+ debug_plot : bool, default False
618
+ Show a convergence + phase diagnostic plot on exit.
619
+ plot_smoothing : int, default 50
620
+ Moving-average window for the MSE curve in the debug plot.
621
+ cpr_state : CPRState, optional
622
+ Warm-start BPS CPR state from a previous ``block_lms()`` call.
623
+ When provided, the BPS 4-fold unwrap accumulators (``bps_prev4``,
624
+ ``bps_offset4``) and the block-distance history matrix
625
+ (``bps_d2_hist``, shape ``(B, C, K-1)``) are restored from the
626
+ previous block boundary. This prevents the BPS from re-converging
627
+ its phase estimate at each block boundary, which otherwise causes
628
+ a ~``cpr_bps_block_size``-symbol transient of increased phase error.
629
+ Pass ``None`` (default) to cold-start. Only BPS state is used;
630
+ PLL/cycle-slip fields are ignored.
631
+ input_norm_factor : float or ndarray, optional
632
+ Pre-computed RMS normalization factor. See ``lms()`` for the full
633
+ description; behaviour is identical.
634
+ samples_prefix : array_like, optional
635
+ Signal history from the end of the previous block. See ``lms()``
636
+ for the full description; behaviour is identical.
637
+ pad_mode : {'zeros', 'edge'}, default 'zeros'
638
+ Padding strategy when ``samples_prefix`` is ``None``. See
639
+ ``lms()`` for the full description; behaviour is identical.
640
+ cuda_graph : bool, default True
641
+ On the GPU (CuPy) backend, capture the per-block compute into a CUDA
642
+ graph and replay it once per block, collapsing the ~30-50 per-block
643
+ kernel launches into a single launch. This removes the launch-overhead
644
+ floor that dominates small/medium ``block_size`` runs. Only full blocks
645
+ in the decision-directed region are captured; the training and final
646
+ partial blocks always run eagerly. Has no effect on CPU, when
647
+ ``store_weights=True``, or when cycle-slip correction is enabled without
648
+ the ``cs_block`` CUDA kernel; capture failures fall back to the eager
649
+ loop with a warning, so output is unaffected either way. Set ``False``
650
+ to force the eager loop (e.g. for debugging or profiling).
651
+
652
+ Returns
653
+ -------
654
+ EqualizerResult
655
+ Same fields as ``lms``, plus:
656
+
657
+ * ``input_norm_factor`` - RMS factor used to normalize inputs.
658
+ * ``cpr_state`` - ``CPRState`` with BPS accumulators after the last
659
+ block. ``None`` when ``cpr_type=None``.
660
+
661
+ ``phase_trajectory`` is populated when ``cpr_type='bps'``; shape
662
+ ``(N_sym,)`` SISO or ``(C, N_sym)`` MIMO, one estimate per symbol.
663
+
664
+ Warnings
665
+ --------
666
+ **GPU throughput - use large block_size:** On GPU (CuPy) each Python
667
+ loop iteration launches ~10-20 CUDA kernels (FFT, einsum, IFFT, BPS
668
+ rotations, ...). At ``block_size=64`` and 100k symbols that is ~1 500
669
+ blocks x kernel-launch overhead; at ``block_size=2048`` it drops to
670
+ ~49 blocks. Throughput improves markedly once the cuFFT/cuBLAS work
671
+ per block dominates the Python overhead. On GPU prefer
672
+ ``block_size`` ≥ 512, ideally 1024-4096.
673
+
674
+ **BPS cycle-slip correction (``cpr_cycle_slip_correction=True``):**
675
+ On GPU the detector runs as a sequential CUDA kernel on device-resident
676
+ history buffers, so enabling it adds one extra kernel launch per block
677
+ and no host synchronization. Only when that kernel is unavailable
678
+ (no custom-kernel support) does each block fall back to a synchronous
679
+ ``(C, block_size)`` float64 device->host round-trip through the CPU
680
+ detector, which serialises the GPU pipeline.
681
+
682
+ **CPU (NumPy) backend:** even slower than GPU because the Python loop
683
+ dominates at any practical block size. Use ``lms(backend='numba')``
684
+ or ``lms(backend='jax')`` for CPU workloads instead.
685
+
686
+ **Stability / overflow:** ``step_size`` is applied to the **summed**
687
+ gradient over all ``block_size`` symbols (not averaged). This keeps μ on
688
+ the same scale as ``lms`` (same μ -> same convergence and steady-state
689
+ MSE), but it also means the *stability ceiling* -
690
+ ``0 < μ < 2/(block_size·C·T·P_x)`` - is roughly ``block_size`` times lower
691
+ than per-symbol LMS, because the weights are frozen across the block. So
692
+ start from the **same** ``step_size`` you use for ``lms``; if a large
693
+ ``block_size`` pushes that value above the ceiling the run diverges (NaN
694
+ weights, detected at end of run), in which case reduce μ until stable -
695
+ do **not** routinely divide by ``block_size`` (that under-adapts the
696
+ filter by the same factor).
697
+ """
698
+ if cpr_type is not None and cpr_type != "bps":
699
+ raise ValueError(
700
+ f"block_lms only supports cpr_type='bps' or None. Got {cpr_type!r}. "
701
+ "PLL is not available for block processing."
702
+ )
703
+
704
+ num_taps = int(num_taps)
705
+ sps = int(sps)
706
+ block_size = int(block_size)
707
+
708
+ _validate_sps(sps, num_taps)
709
+
710
+ samples, xp, _ = dispatch(samples)
711
+ if xp is np:
712
+ logger.warning(
713
+ "block_lms is running on CPU (NumPy). "
714
+ "For CPU workloads lms(..., backend='numba') is typically 2-10x faster. "
715
+ "Move samples to GPU (CuPy) to benefit from block-FFT acceleration."
716
+ )
717
+
718
+ was_1d = samples.ndim == 1
719
+ if was_1d:
720
+ samples = samples[np.newaxis, :]
721
+
722
+ C = samples.shape[0]
723
+ N = samples.shape[1]
724
+ n_sym = N // sps
725
+
726
+ if training_symbols is not None:
727
+ training_symbols, _, _ = dispatch(training_symbols)
728
+ if training_symbols.ndim == 1:
729
+ training_symbols = training_symbols[np.newaxis, :]
730
+
731
+ samples, training_symbols, eq_norm = _normalize_inputs(
732
+ samples, training_symbols, sps, input_norm_factor=input_norm_factor
733
+ )
734
+
735
+ # -- Constellation ---------------------------------------------------------
736
+ if modulation is not None and order is not None:
737
+ from ..mapping import gray_constellation
738
+
739
+ reference_constellation = gray_constellation(
740
+ modulation, order, unipolar=unipolar
741
+ )
742
+ constellation_np = (
743
+ to_device(reference_constellation, "cpu").flatten().astype(np.complex64)
744
+ )
745
+ elif training_symbols is not None:
746
+ train_flat = to_device(training_symbols, "cpu").reshape(-1)
747
+ constellation_np = np.unique(np.round(train_flat, decimals=8)).astype(
748
+ np.complex64
749
+ )
750
+ else:
751
+ raise ValueError("Provide modulation+order or training_symbols for DD slicer.")
752
+
753
+ if pmf is not None and modulation is not None and order is not None:
754
+ _pmf_arr = np.asarray(pmf, dtype=np.float64)
755
+ _e_ps = float(
756
+ np.dot(_pmf_arr, np.abs(constellation_np).astype(np.float64) ** 2)
757
+ )
758
+ if _e_ps < 1.0 - 1e-6:
759
+ constellation_np = (constellation_np / np.sqrt(_e_ps)).astype(np.complex64)
760
+
761
+ constellation = xp.asarray(constellation_np) # (M,) on device
762
+ _sq_side, _sq_lev_min_f, _sq_d_grid_f = _sq_qam_slicer_params(constellation_np)
763
+ _sq_lev_min = float(_sq_lev_min_f)
764
+ _sq_d_grid = float(_sq_d_grid_f)
765
+ _sq_m1 = _sq_side - 1 # clip upper bound (0 when sq_side==0 - never used)
766
+
767
+ # -- Training alignment ----------------------------------------------------
768
+ if training_symbols is not None:
769
+ n_train_aligned = min(int(training_symbols.shape[-1]), n_sym)
770
+ else:
771
+ n_train_aligned = 0
772
+
773
+ # -- Weight initialisation -------------------------------------------------
774
+ if w_init is not None:
775
+ w_arr = np.ascontiguousarray(to_device(w_init, "cpu"), dtype=np.complex64)
776
+ w_arr = _validate_w_init(w_arr, C, num_taps)
777
+ h = xp.asarray(w_arr.copy())
778
+ else:
779
+ h = xp.asarray(_init_butterfly_weights_numpy(C, num_taps)) # (C, C, T)
780
+
781
+ # -- BPS setup -------------------------------------------------------------
782
+ if cpr_type == "bps":
783
+ symmetry = _cpr_symmetry(modulation, order)
784
+ P = int(cpr_bps_test_phases)
785
+ bps_angles_np = np.linspace(
786
+ 0.0, np.pi / 2.0, P, endpoint=False, dtype=np.float32
787
+ )
788
+ bps_phases_neg = xp.asarray(
789
+ np.exp(-1j * bps_angles_np).astype(np.complex64)
790
+ ) # (P,)
791
+ bps_angles = xp.asarray(bps_angles_np) # (P,)
792
+ quantum = np.float64(2.0 * np.pi / symmetry)
793
+ _two_pi = xp.float64(2.0 * np.pi)
794
+ # Cross-block 4-fold unwrap state (one float64 per channel)
795
+ _cs_H = min(int(cpr_cycle_slip_history), n_sym)
796
+ _bps_K = int(cpr_bps_block_size)
797
+ _bps_hist_len = max(0, _bps_K - 1)
798
+ _st = cpr_state
799
+ _st_ok = (
800
+ _st is not None
801
+ and _st.cpr_type == cpr_type
802
+ and _st.num_ch == C
803
+ and _st.cs_H == _cs_H
804
+ and _st.bps_P == P
805
+ and _st.bps_K == _bps_K
806
+ and _st.bps_prev4 is not None
807
+ )
808
+ if _st_ok:
809
+ assert _st is not None
810
+ assert _st.bps_prev4 is not None
811
+ assert _st.bps_offset4 is not None
812
+ assert _st.cs_buf_x is not None
813
+ assert _st.cs_buf_y is not None
814
+ assert _st.cs_buf_ptr is not None
815
+ assert _st.cs_buf_n is not None
816
+ assert _st.cs_stats is not None
817
+ bps_prev4 = _st.bps_prev4.copy()
818
+ bps_offset4 = _st.bps_offset4.copy()
819
+ cs_buf_x = _st.cs_buf_x.copy()
820
+ cs_buf_y = _st.cs_buf_y.copy()
821
+ cs_buf_ptr = _st.cs_buf_ptr.copy()
822
+ cs_buf_n = _st.cs_buf_n.copy()
823
+ cs_stats = _st.cs_stats.copy()
824
+ # xp.array (not asarray): the history buffer is updated in place
825
+ # inside the block loop, so it must never alias the caller's state.
826
+ bps_d2_hist = (
827
+ xp.array(_st.bps_d2_hist, dtype=xp.float32)
828
+ if _st.bps_d2_hist is not None
829
+ else xp.zeros((P, C, _bps_hist_len), dtype=xp.float32)
830
+ )
831
+ else:
832
+ bps_prev4 = np.zeros(C, dtype=np.float64)
833
+ bps_offset4 = np.zeros(C, dtype=np.float64)
834
+ cs_buf_x = np.zeros((C, _cs_H), dtype=np.float64)
835
+ cs_buf_y = np.zeros((C, _cs_H), dtype=np.float64)
836
+ cs_buf_ptr = np.zeros(C, dtype=np.int64)
837
+ cs_buf_n = np.zeros(C, dtype=np.int64)
838
+ cs_stats = np.zeros((C, 4), dtype=np.float64)
839
+ bps_d2_hist = xp.zeros((P, C, _bps_hist_len), dtype=xp.float32)
840
+ # Promote the unwrap carries to the active device once - every
841
+ # per-block update then runs on-device, with no per-block H2D/D2H
842
+ # sync. The CPRState CPU-NumPy contract is honoured at the API
843
+ # boundary only: ingested above, exported back via to_device() at
844
+ # state export. Cycle-slip buffers stay CPU-resident because the
845
+ # slip kernel itself still runs on the host.
846
+ bps_prev4 = xp.asarray(bps_prev4)
847
+ bps_offset4 = xp.asarray(bps_offset4)
848
+
849
+ # -- Fused CUDA kernels (CuPy only; None => xp fallback) -------------------
850
+ # _k_bps: per-block inline-BPS min-distance metric (GRID for square QAM,
851
+ # TABLE otherwise). _k_dd: nearest-point search for the non-square DD
852
+ # slicer (TABLE + argmin, called with a single unit phasor). _k_cs: the
853
+ # sequential cycle-slip detector (one thread per channel) operating on
854
+ # device-resident history buffers.
855
+ _k_bps = None
856
+ _k_dd = None
857
+ _k_cs = None
858
+ _dd_phasor = None
859
+ if xp is not np:
860
+ from .. import _cuda
861
+
862
+ _M_const = int(constellation_np.size)
863
+ if cpr_type == "bps" and P <= 128:
864
+ if _sq_side > 0:
865
+ _k_bps = _cuda.get_kernel("bps_min_d2", mode="grid")
866
+ elif _M_const <= 1024:
867
+ _k_bps = _cuda.get_kernel("bps_min_d2", mode="table")
868
+ if n_train_aligned < n_sym and _sq_side == 0 and _M_const <= 1024:
869
+ _k_dd = _cuda.get_kernel("bps_min_d2", mode="table", return_argmin=True)
870
+ if _k_dd is not None:
871
+ _dd_phasor = xp.ones(1, dtype=xp.complex64)
872
+ if cpr_type == "bps" and cpr_cycle_slip_correction and C <= 1024:
873
+ _k_cs = _cuda.get_kernel("cs_block")
874
+ if _k_cs is not None:
875
+ # Cycle-slip state lives in device memory for the whole block loop;
876
+ # the kernel mutates it in place. Exported back to CPU NumPy at
877
+ # state export, preserving the CPRState contract. cs_buf_x is
878
+ # unused by the detector and stays on CPU.
879
+ cs_buf_y = xp.asarray(cs_buf_y)
880
+ cs_buf_ptr = xp.asarray(cs_buf_ptr)
881
+ cs_buf_n = xp.asarray(cs_buf_n)
882
+ cs_stats = xp.asarray(cs_stats)
883
+
884
+ # -- OLS block size --------------------------------------------------------
885
+ # fftsize must be >= block_size * sps + num_taps - 1 (linear OLS condition)
886
+ _ols_min = block_size * sps + num_taps - 1
887
+ fftsize = 1 << (_ols_min - 1).bit_length() # next power of 2
888
+
889
+ _cpr_info = ""
890
+ if cpr_type == "bps":
891
+ _cs = (
892
+ f", cs_corr=True(thr={cpr_cycle_slip_threshold:.3f})"
893
+ if cpr_cycle_slip_correction
894
+ else ", cs_corr=False"
895
+ )
896
+ _joint = ", joint" if cpr_joint_channels and C > 1 else ""
897
+ _cpr_info = (
898
+ f", cpr=bps(P={cpr_bps_test_phases}, K={cpr_bps_block_size}{_joint}{_cs})"
899
+ )
900
+ logger.info(
901
+ "Block-LMS: C=%s, num_taps=%s, sps=%s, block_size=%s, fftsize=%s, mu=%s, n_sym=%s%s",
902
+ C,
903
+ num_taps,
904
+ sps,
905
+ block_size,
906
+ fftsize,
907
+ step_size,
908
+ n_sym,
909
+ _cpr_info,
910
+ )
911
+
912
+ # -- Padding - matches lms() convention -----------------------------------
913
+ c_tap = num_taps // 2
914
+ pad_total = max(0, n_sym * sps - N + num_taps - 1)
915
+ pad_left = min(c_tap, pad_total)
916
+ pad_right = pad_total - pad_left
917
+ if samples_prefix is not None or xp is np or pad_mode != "zeros":
918
+ _samp_cpu_blms = to_device(samples, "cpu").astype(np.complex64)
919
+ x_padded = xp.asarray(
920
+ _build_padded_samples(
921
+ _samp_cpu_blms,
922
+ pad_left,
923
+ pad_right,
924
+ samples_prefix,
925
+ pad_mode,
926
+ eq_norm,
927
+ sps,
928
+ )
929
+ ) # (C, N_pad)
930
+ else:
931
+ # Fast on-device path: avoid D->H->D round-trip when there is no prefix.
932
+ # _normalize_inputs already normalized samples; eq_norm is only needed
933
+ # by _build_padded_samples to normalize the samples_prefix, which is
934
+ # handled by the branch above.
935
+ _samp_f32 = (
936
+ samples if samples.dtype == xp.complex64 else samples.astype(xp.complex64)
937
+ )
938
+ _left = xp.zeros((C, pad_left), dtype=xp.complex64)
939
+ _right = (
940
+ xp.zeros((C, pad_right), dtype=xp.complex64)
941
+ if pad_right > 0
942
+ else xp.empty((C, 0), dtype=xp.complex64)
943
+ )
944
+ x_padded = xp.concatenate([_left, _samp_f32, _right], axis=1) # (C, N_pad)
945
+ N_padded = x_padded.shape[1]
946
+
947
+ # -- Output buffers --------------------------------------------------------
948
+ y_all = xp.empty((C, n_sym), dtype=xp.complex64)
949
+ e_all = xp.empty((C, n_sym), dtype=xp.complex64)
950
+ w_hist = (
951
+ xp.empty((n_sym, C, C, num_taps), dtype=xp.complex64) if store_weights else None
952
+ )
953
+ phi_all = xp.zeros((C, n_sym), dtype=xp.float32) if cpr_type == "bps" else None
954
+
955
+ # Pre-allocate scratch buffers - reused every block to avoid per-block heap
956
+ # pressure, and (on GPU) to give the CUDA-graph capture stable pointers.
957
+ x_win = xp.zeros((C, fftsize), dtype=xp.complex64)
958
+ e_scatter = xp.zeros((C, fftsize), dtype=xp.complex64)
959
+ # Fixed-width output workspaces (block_size columns). The block body always
960
+ # writes its per-symbol outputs here; the driver copies the valid [:, :B]
961
+ # slice into the full-length result arrays at the right offset. Routing
962
+ # through fixed buffers (rather than writing y_all[:, b_start:b_end]
963
+ # directly) is what lets the body be captured once and replayed: the only
964
+ # thing that varies per block is the eager input fill and output copy.
965
+ y_rot_ws = xp.empty((C, block_size), dtype=xp.complex64)
966
+ e_clean_ws = xp.empty((C, block_size), dtype=xp.complex64)
967
+ phi_ws = xp.empty((C, block_size), dtype=xp.float32) if cpr_type == "bps" else None
968
+
969
+ n_blocks = (n_sym + block_size - 1) // block_size
970
+ # On-device divergence flag - accumulates with |= inside the loop, zero D->H
971
+ # syncs during iteration. Checked once with bool() after the loop exits.
972
+ _div_flag = xp.zeros(1, dtype=xp.bool_)
973
+
974
+ def _run_block(B, b_start, n_train_blk):
975
+ """Compute one block: forward filter, CPR, error, gradient, weight update.
976
+
977
+ Reads the input window from ``x_win`` (filled by the caller), reads and
978
+ updates the persistent filter/CPR state in place, and writes the
979
+ per-symbol outputs into the fixed workspaces ``y_rot_ws``/``e_clean_ws``/
980
+ ``phi_ws`` (columns ``[:, :B]``). Issues no host synchronization, so on
981
+ GPU it is safe to capture into a CUDA graph and replay.
982
+ """
983
+ nonlocal h, bps_prev4, bps_offset4, _div_flag
984
+
985
+ # -- Forward pass (frequency-domain butterfly) ---------------------
986
+ X_fd = xp.fft.fft(x_win, axis=-1) # (C, F)
987
+ H_fd = xp.fft.fft(h, n=fftsize, axis=-1) # (C, C, F)
988
+ # Butterfly contraction Y[i,k] = Σ_j conj(H[i,j,k]) X[j,k]. Written as a
989
+ # broadcast-multiply + reduction (not einsum) for two reasons: einsum
990
+ # dispatches to cuBLAS, which cannot be called inside a CUDA-graph stream
991
+ # capture; and the reduction is accumulated in complex128 before the
992
+ # complex64 downcast, per the CLAUDE.md filter-dot-product precision rule.
993
+ Y_fd = (
994
+ (xp.conj(H_fd).astype(xp.complex128) * X_fd.astype(xp.complex128)[None])
995
+ .sum(axis=1)
996
+ .astype(xp.complex64)
997
+ ) # (C, F)
998
+ y_time = xp.fft.ifft(Y_fd, axis=-1) # (C, F)
999
+ y_block = y_time[:, : B * sps : sps].astype(xp.complex64) # (C, B)
1000
+
1001
+ # -- BPS phase recovery --------------------------------------------
1002
+ if cpr_type == "bps":
1003
+ # min_d2: (P, C, B) - min squared distance to constellation over
1004
+ # all candidate rotations of all block symbols.
1005
+ if _k_bps is not None:
1006
+ # Fused kernel: single pass over y_block, no (P, C, B[, M])
1007
+ # rotated/distance intermediates.
1008
+ if _sq_side > 0:
1009
+ min_d2 = _k_bps(
1010
+ y_block,
1011
+ bps_phases_neg,
1012
+ lev_min=_sq_lev_min,
1013
+ d_grid=_sq_d_grid,
1014
+ side=_sq_side,
1015
+ )
1016
+ else:
1017
+ min_d2 = _k_bps(
1018
+ y_block, bps_phases_neg, constellation=constellation
1019
+ )
1020
+ else:
1021
+ # rotated: (P, C, B) - all candidate rotations for all block symbols
1022
+ rotated = bps_phases_neg[:, None, None] * y_block[None, :, :]
1023
+ # O(1) square-QAM: snap I/Q independently to nearest level grid point.
1024
+ if _sq_side > 0:
1025
+ _nr = (
1026
+ _sq_lev_min
1027
+ + xp.clip(
1028
+ xp.round((rotated.real - _sq_lev_min) / _sq_d_grid),
1029
+ 0,
1030
+ _sq_m1,
1031
+ )
1032
+ * _sq_d_grid
1033
+ )
1034
+ _ni = (
1035
+ _sq_lev_min
1036
+ + xp.clip(
1037
+ xp.round((rotated.imag - _sq_lev_min) / _sq_d_grid),
1038
+ 0,
1039
+ _sq_m1,
1040
+ )
1041
+ * _sq_d_grid
1042
+ )
1043
+ min_d2 = (
1044
+ (rotated.real - _nr) ** 2 + (rotated.imag - _ni) ** 2
1045
+ ).astype(xp.float32)
1046
+ else:
1047
+ d2_all = (
1048
+ xp.abs(rotated[..., None] - constellation[None, None, None, :])
1049
+ ** 2
1050
+ ).real
1051
+ min_d2 = xp.min(d2_all, axis=-1).astype(xp.float32)
1052
+
1053
+ # Causal sliding-window average of width K along the B (symbol) axis.
1054
+ # win_sum[:,:,n] = sum of min_d2[:,:, n-K+1..n] (with K-1 samples from
1055
+ # previous block as prefix so the window is full from symbol 0).
1056
+ K = min(_bps_K, B)
1057
+ hist_prefix = (
1058
+ bps_d2_hist[:, :, -(K - 1) :]
1059
+ if K > 1
1060
+ else xp.empty((P, C, 0), dtype=xp.float32)
1061
+ )
1062
+ cat_d2 = xp.concatenate([hist_prefix, min_d2], axis=2) # (P, C, K-1+B)
1063
+ cs_d2 = xp.concatenate(
1064
+ [xp.zeros((P, C, 1), dtype=xp.float32), cat_d2.cumsum(axis=2)], axis=2
1065
+ ) # (P, C, K+B)
1066
+ win_sum = cs_d2[:, :, K:] - cs_d2[:, :, :-K] # (P, C, B)
1067
+ metric = win_sum / xp.float32(K) # (P, C, B) - always full K-sample window
1068
+
1069
+ # Per-symbol argmin over P phases -> raw (C, B) in [0, π/2)
1070
+ if cpr_joint_channels and C > 1:
1071
+ best_k = xp.argmin(metric.sum(axis=1), axis=0) # (B,)
1072
+ phi_raw = xp.broadcast_to(
1073
+ bps_angles[best_k][None, :], (C, B)
1074
+ ).copy() # (C, B)
1075
+ else:
1076
+ best_k = xp.argmin(metric, axis=0) # (C, B)
1077
+ phi_raw = bps_angles[best_k] # (C, B)
1078
+
1079
+ # Slide BPS distance history across block boundary (for next block's
1080
+ # prefix). In-place write into the persistent buffer (not a rebind)
1081
+ # so the captured graph reads/writes the same address every replay.
1082
+ if _bps_hist_len > 0:
1083
+ combined_hist = xp.concatenate([bps_d2_hist, min_d2], axis=2)
1084
+ bps_d2_hist[...] = combined_hist[:, :, -_bps_hist_len:]
1085
+
1086
+ # 4-fold causal unwrap: equivalent to np.unwrap(phi*4)/4 via
1087
+ # diff->wrap[-π,π]->cumsum. CPU: np.unwrap directly (no transfer cost).
1088
+ # GPU: fully on-device - the carries are device arrays, so the
1089
+ # block loop issues no host sync for the unwrap state.
1090
+ if xp is np:
1091
+ raw4 = phi_raw.astype(np.float64) * 4.0 # (C, B)
1092
+ extended = np.concatenate(
1093
+ [bps_prev4[:, np.newaxis], raw4], axis=1
1094
+ ) # (C, B+1)
1095
+ unwrapped_ext = np.unwrap(extended, axis=1) # (C, B+1)
1096
+ _cumul_cpu = unwrapped_ext[:, 1:] - unwrapped_ext[:, 0:1] # (C, B)
1097
+ _phi_f64 = (bps_offset4[:, np.newaxis] + _cumul_cpu) / 4.0 # (C, B)
1098
+ bps_prev4[:] = unwrapped_ext[:, -1]
1099
+ bps_offset4 += _cumul_cpu[:, -1]
1100
+ else:
1101
+ raw4_dev = phi_raw.astype(xp.float64) * xp.float64(4.0) # (C, B)
1102
+ ext_dev = xp.concatenate(
1103
+ [bps_prev4[:, None], raw4_dev], axis=1
1104
+ ) # (C, B+1)
1105
+ _two_pi_d = xp.float64(2.0 * np.pi)
1106
+ d4 = ext_dev[:, 1:] - ext_dev[:, :-1] # (C, B)
1107
+ d4 -= xp.round(d4 / _two_pi_d) * _two_pi_d # wrap to [-π, π]
1108
+ _cumul_dev = xp.cumsum(d4, axis=1) # (C, B)
1109
+ _phi_f64 = (bps_offset4[:, None] + _cumul_dev) / xp.float64(4.0)
1110
+ bps_prev4 += _cumul_dev[:, -1]
1111
+ bps_offset4 += _cumul_dev[:, -1]
1112
+ # -- Per-symbol cycle-slip correction --------------------------
1113
+ # Identical algorithm to per-symbol lms: for each symbol in the
1114
+ # block, compare its BPS phase to the regression prediction, snap
1115
+ # to the nearest quantum if |diff| > threshold, then add the
1116
+ # corrected (x, y) pair to the circular history buffer.
1117
+ # GPU with the cs_block CUDA kernel: one launch per block on the
1118
+ # device-resident history buffers - zero host syncs. Fallback
1119
+ # (CPU, or kernel unavailable): D->H transfer of the full (C, B)
1120
+ # float64 phase block, CPU detector (Numba or Python), H->D
1121
+ # write-back; cost is O(C·B) per block.
1122
+ if cpr_cycle_slip_correction:
1123
+ if _k_cs is not None:
1124
+ _phi_corr_dev = xp.empty_like(_phi_f64)
1125
+ _k_cs(
1126
+ _phi_f64,
1127
+ _phi_corr_dev,
1128
+ cs_buf_y,
1129
+ cs_buf_ptr,
1130
+ cs_buf_n,
1131
+ cs_stats,
1132
+ float(quantum),
1133
+ float(cpr_cycle_slip_threshold),
1134
+ _cs_H,
1135
+ )
1136
+ else:
1137
+ phi_blk_np = to_device(_phi_f64, "cpu").astype(np.float64) # (C, B)
1138
+ phi_corr_np = phi_blk_np.copy()
1139
+
1140
+ _cs_kernel = _get_numba_cs_block()
1141
+ if _cs_kernel is not None:
1142
+ _cs_kernel(
1143
+ phi_blk_np,
1144
+ phi_corr_np,
1145
+ cs_buf_x,
1146
+ cs_buf_y,
1147
+ cs_buf_ptr,
1148
+ cs_buf_n,
1149
+ cs_stats,
1150
+ b_start,
1151
+ float(quantum),
1152
+ float(cpr_cycle_slip_threshold),
1153
+ _cs_H,
1154
+ )
1155
+ else:
1156
+ _H_f = float(_cs_H)
1157
+ for ci in range(C):
1158
+ for i in range(B):
1159
+ y_b = phi_blk_np[ci, i]
1160
+ n_b = int(cs_buf_n[ci])
1161
+ ptr = int(cs_buf_ptr[ci])
1162
+
1163
+ if n_b == 0:
1164
+ phi_expected = y_b
1165
+ elif n_b < 10:
1166
+ last_pos = (ptr - 1 + _cs_H) % _cs_H
1167
+ phi_expected = cs_buf_y[ci, last_pos]
1168
+ else:
1169
+ sy = cs_stats[ci, 0]
1170
+ sxy = cs_stats[ci, 1]
1171
+ n_f = float(n_b)
1172
+ if n_b < _cs_H:
1173
+ Sx_c = n_f * (n_f - 1.0) / 2.0
1174
+ Sxx_c = (
1175
+ n_f * (n_f - 1.0) * (2.0 * n_f - 1.0) / 6.0
1176
+ )
1177
+ denom = n_f * Sxx_c - Sx_c * Sx_c
1178
+ else:
1179
+ Sx_c = _H_f * (_H_f - 1.0) / 2.0
1180
+ Sxx_c = (
1181
+ _H_f
1182
+ * (_H_f - 1.0)
1183
+ * (2.0 * _H_f - 1.0)
1184
+ / 6.0
1185
+ )
1186
+ denom = _H_f * Sxx_c - Sx_c * Sx_c
1187
+ if abs(denom) > 1e-30:
1188
+ slope = (n_f * sxy - Sx_c * sy) / denom
1189
+ intercept = (sy - slope * Sx_c) / n_f
1190
+ else:
1191
+ slope = 0.0
1192
+ intercept = sy / n_f
1193
+ phi_expected = slope * n_f + intercept
1194
+
1195
+ diff = y_b - phi_expected
1196
+ k_slip = int(round(diff / quantum))
1197
+ if (
1198
+ abs(diff) > float(cpr_cycle_slip_threshold)
1199
+ and k_slip != 0
1200
+ ):
1201
+ y_b -= float(k_slip) * quantum
1202
+ phi_corr_np[ci, i] = y_b
1203
+
1204
+ # Update circular buffer - relative coords, only y needed
1205
+ write_pos = ptr % _cs_H
1206
+ if n_b == _cs_H:
1207
+ old_y = cs_buf_y[ci, write_pos]
1208
+ old_sy = cs_stats[ci, 0]
1209
+ # Sxy_new = Sxy_old - Sy_old + y_old + (H-1)*y_new
1210
+ cs_stats[ci, 1] = (
1211
+ cs_stats[ci, 1]
1212
+ - old_sy
1213
+ + old_y
1214
+ + (_H_f - 1.0) * y_b
1215
+ )
1216
+ cs_stats[ci, 0] = old_sy - old_y + y_b
1217
+ else:
1218
+ cs_stats[ci, 1] += float(n_b) * y_b
1219
+ cs_stats[ci, 0] += y_b
1220
+ cs_buf_y[ci, write_pos] = y_b
1221
+ cs_buf_ptr[ci] = ptr + 1
1222
+ if n_b < _cs_H:
1223
+ cs_buf_n[ci] = n_b + 1
1224
+
1225
+ _phi_corr_dev = xp.asarray(phi_corr_np)
1226
+
1227
+ # Carry the net slip correction into bps_offset4 so that
1228
+ # subsequent blocks do not re-detect the same slip.
1229
+ # bps_offset4 tracks the 4x-domain accumulated phase; the slip
1230
+ # quantum is π/2 -> 2π in 4x, which is a multiple of 2π and
1231
+ # therefore transparent to the 4-fold unwrap of bps_prev4.
1232
+ # Vectorized on-device op - a zero net slip adds 0.0 for free,
1233
+ # so no per-channel host-side != 0.0 test is needed.
1234
+ bps_offset4 += (_phi_corr_dev[:, -1] - _phi_f64[:, -1]) * 4.0
1235
+ _phi_f64 = _phi_corr_dev
1236
+
1237
+ # Wrap unbounded float64 phase to [-π, π] before float32 cast so that
1238
+ # the GPU exp() argument is bounded (dual-path: wrapped for rotation,
1239
+ # unwrapped for trajectory storage).
1240
+ phi_c_dev = (_phi_f64 - xp.round(_phi_f64 / _two_pi) * _two_pi).astype(
1241
+ xp.float32
1242
+ )
1243
+ phi_c_traj = _phi_f64.astype(xp.float32) # unwrapped, for output trajectory
1244
+
1245
+ phi_c = phi_c_dev # wrapped float32, for rotation
1246
+ y_rot = y_block * xp.exp(-1j * phi_c.astype(xp.complex64)) # (C, B)
1247
+ assert phi_ws is not None
1248
+ phi_ws[:, :B] = phi_c_traj # unwrapped float32, for trajectory
1249
+ else:
1250
+ y_rot = y_block
1251
+
1252
+ # -- Error computation (training or DD slicer) ---------------------
1253
+ e_clean = xp.empty((C, B), dtype=xp.complex64)
1254
+
1255
+ if n_train_blk > 0:
1256
+ d_train = training_symbols[:, b_start : b_start + n_train_blk]
1257
+ e_clean[:, :n_train_blk] = d_train - y_rot[:, :n_train_blk]
1258
+
1259
+ if n_train_blk < B:
1260
+ y_dd = y_rot[:, n_train_blk:]
1261
+ if _sq_side > 0:
1262
+ _dd_r = (
1263
+ _sq_lev_min
1264
+ + xp.clip(
1265
+ xp.round((y_dd.real - _sq_lev_min) / _sq_d_grid), 0, _sq_m1
1266
+ )
1267
+ * _sq_d_grid
1268
+ )
1269
+ _dd_i = (
1270
+ _sq_lev_min
1271
+ + xp.clip(
1272
+ xp.round((y_dd.imag - _sq_lev_min) / _sq_d_grid), 0, _sq_m1
1273
+ )
1274
+ * _sq_d_grid
1275
+ )
1276
+ d_dd = xp.empty(y_dd.shape, dtype=xp.complex64)
1277
+ d_dd.real[:] = _dd_r
1278
+ d_dd.imag[:] = _dd_i
1279
+ elif _k_dd is not None:
1280
+ # Fused nearest-point search: (1, C, B_dd) argmin indices with
1281
+ # a unit phasor, replacing the (C, B_dd, M) distance tensor.
1282
+ _, _dd_idx = _k_dd(y_dd, _dd_phasor, constellation=constellation)
1283
+ d_dd = constellation[_dd_idx[0]]
1284
+ else:
1285
+ d2_sl = (
1286
+ xp.abs(y_dd[:, :, None] - constellation[None, None, :]) ** 2
1287
+ ).real
1288
+ d_dd = constellation[xp.argmin(d2_sl, axis=-1)]
1289
+ e_clean[:, n_train_blk:] = d_dd - y_dd
1290
+
1291
+ # -- Store per-symbol outputs into the fixed workspaces ------------
1292
+ y_rot_ws[:, :B] = y_rot
1293
+ e_clean_ws[:, :B] = e_clean
1294
+ if store_weights:
1295
+ assert w_hist is not None
1296
+ w_hist[b_start : b_start + B] = h[None, :, :, :]
1297
+
1298
+ # -- Back-rotate error to tap plane and compute gradient -----------
1299
+ if cpr_type == "bps":
1300
+ e_taps = e_clean * xp.exp(1j * phi_c.astype(xp.complex64)) # (C, B)
1301
+ else:
1302
+ e_taps = e_clean
1303
+
1304
+ # Scatter e_taps to sample positions within the block window
1305
+ e_scatter.fill(0)
1306
+ e_scatter[:, : B * sps : sps] = e_taps
1307
+
1308
+ # Frequency-domain gradient: dH_fd[i,j,k] = conj(E_fd[i,k]) * X_fd[j,k].
1309
+ # Outer product over the channel axes - no contracted index, hence no
1310
+ # accumulation (complex64 is exact enough) and no cuBLAS, so the
1311
+ # broadcast form is both capture-safe and replaces the einsum directly.
1312
+ E_fd = xp.fft.fft(e_scatter, axis=-1) # (C, F)
1313
+ dH_fd = xp.conj(E_fd)[:, None, :] * X_fd[None, :, :] # (C, C, F)
1314
+ dh = xp.fft.ifft(dH_fd, axis=-1)[:, :, :num_taps] # (C, C, T)
1315
+
1316
+ # In-place weight update so the captured graph reads/writes one buffer.
1317
+ h += xp.float32(step_size) * dh
1318
+
1319
+ # Accumulate divergence flag on-device - no D->H sync here.
1320
+ _div_flag |= ~xp.isfinite(h).all()
1321
+
1322
+ # CUDA-graph invariant: _fill_x_win and _store_outputs run eagerly *between*
1323
+ # graph replays, so they MUST NOT allocate from the device memory pool. The
1324
+ # captured graph's intermediates were freed back to the pool after capture;
1325
+ # any allocation here could hand those exact blocks out and clobber the
1326
+ # pointers the replay reads/writes (intermittent garbage / divergence). Both
1327
+ # helpers touch only pre-allocated buffers (x_win, y_all/e_all/phi_all,
1328
+ # *_ws) via fill/slice-assign - keep them allocation-free.
1329
+ def _fill_x_win(b_start):
1330
+ """Eager (per-block, varying offset) load of the input window into x_win.
1331
+
1332
+ Kept outside ``_run_block`` because the source offset changes every
1333
+ block - a varying-pointer copy cannot live inside the captured graph.
1334
+ """
1335
+ x_start = b_start * sps
1336
+ x_win.fill(0)
1337
+ available = min(fftsize, N_padded - x_start)
1338
+ if available > 0:
1339
+ x_win[:, :available] = x_padded[:, x_start : x_start + available]
1340
+
1341
+ def _store_outputs(b_start, b_end, B):
1342
+ """Eager copy of the fixed workspaces into the result arrays."""
1343
+ y_all[:, b_start:b_end] = y_rot_ws[:, :B]
1344
+ e_all[:, b_start:b_end] = e_clean_ws[:, :B]
1345
+ if cpr_type == "bps":
1346
+ assert phi_all is not None and phi_ws is not None
1347
+ phi_all[:, b_start:b_end] = phi_ws[:, :B]
1348
+
1349
+ # -- CUDA-graph eligibility ------------------------------------------------
1350
+ # The block-loop body is host-sync-free, so on GPU it can
1351
+ # be captured once and replayed per block, collapsing ~30-50 kernel launches
1352
+ # into one. Only full blocks (B == block_size) lying entirely in the
1353
+ # decision-directed region (n_train_blk == 0) share one fixed control flow
1354
+ # and shape, so only those are captured; the training/straddle blocks and
1355
+ # the final partial block always run eagerly. Cycle-slip correction is
1356
+ # capturable only via the cs_block CUDA kernel (the CPU fallback syncs).
1357
+ _first_dd_full = ((n_train_aligned + block_size - 1) // block_size) * block_size
1358
+ _n_dd_full = max(0, (n_sym - _first_dd_full) // block_size)
1359
+ _use_graph = (
1360
+ cuda_graph
1361
+ and xp is not np
1362
+ and not store_weights
1363
+ and (not cpr_cycle_slip_correction or _k_cs is not None)
1364
+ and _n_dd_full >= 2 # need ≥1 warmup block + ≥1 captured block to pay off
1365
+ )
1366
+ try:
1367
+ import cupy as _cp_graph
1368
+
1369
+ _graph_stream = _cp_graph.cuda.Stream(non_blocking=True) if _use_graph else None
1370
+ except Exception:
1371
+ _use_graph = False
1372
+ _graph_stream = None
1373
+
1374
+ # All loop work - eager blocks, input fills, output copies, and graph
1375
+ # capture/replay - runs on a single stream so the shared state buffers
1376
+ # (h, bps_*, cs_*) stay ordered across the eager<->replay boundary. On the
1377
+ # eager (CPU or graph-disabled) path this is a no-op context.
1378
+ _loop_stream_ctx: Any
1379
+ if _use_graph:
1380
+ assert _graph_stream is not None # set together with _use_graph above
1381
+ # _graph_stream is non-blocking, so it does NOT implicitly serialize
1382
+ # with the default stream that produced x_padded, h, the scratch
1383
+ # buffers, and the BPS/cs constants above. Without an explicit join the
1384
+ # first block (and the graph capture) races those still-in-flight setup
1385
+ # writes - intermittently reading/capturing garbage, which surfaces as
1386
+ # divergence or bad convergence that "fixes itself" on rerun (the race
1387
+ # is timing-dependent). Make the loop stream wait for that setup work.
1388
+ _setup_done = _cp_graph.cuda.Event()
1389
+ _setup_done.record() # records on the current (default) stream
1390
+ _graph_stream.wait_event(_setup_done)
1391
+ _loop_stream_ctx = _graph_stream
1392
+ else:
1393
+ _loop_stream_ctx = contextlib.nullcontext()
1394
+
1395
+ # -- Block loop ------------------------------------------------------------
1396
+ _graph = None # captured CUDA graph, built lazily on the 2nd full DD block
1397
+ _graph_warmed = False # True once one full DD block has primed the mem pool
1398
+ with _loop_stream_ctx:
1399
+ for b in range(n_blocks):
1400
+ b_start = b * block_size
1401
+ b_end = min(b_start + block_size, n_sym)
1402
+ B = b_end - b_start # symbols this block (may be < block_size for last)
1403
+ n_train_blk = max(0, min(n_train_aligned - b_start, B))
1404
+ _capturable = _use_graph and block_size == B and n_train_blk == 0
1405
+
1406
+ _fill_x_win(b_start)
1407
+
1408
+ if not _capturable:
1409
+ _run_block(B, b_start, n_train_blk) # eager
1410
+ elif _graph is not None:
1411
+ _graph.launch() # replay (current stream == _graph_stream)
1412
+ elif not _graph_warmed:
1413
+ _run_block(B, b_start, 0) # eager warmup - primes the memory pool
1414
+ _graph_warmed = True
1415
+ else:
1416
+ # Capture the body once. Stream capture records the kernel
1417
+ # sequence without executing it (pool allocations reuse the
1418
+ # blocks the warmup freed, so no cudaMalloc occurs); launch()
1419
+ # then executes this block. On any capture failure, fall back
1420
+ # to running the remaining blocks eagerly.
1421
+ assert (
1422
+ _graph_stream is not None
1423
+ ) # _capturable => _use_graph => stream set
1424
+ try:
1425
+ _graph_stream.begin_capture()
1426
+ _run_block(B, b_start, 0)
1427
+ _graph = _graph_stream.end_capture()
1428
+ _graph.launch()
1429
+ except Exception as exc: # pragma: no cover - hw/version dependent
1430
+ with contextlib.suppress(Exception):
1431
+ _graph_stream.end_capture()
1432
+ _graph = None
1433
+ _use_graph = False
1434
+ logger.warning(
1435
+ "block_lms CUDA-graph capture failed (%s); "
1436
+ "continuing with the eager block loop.",
1437
+ exc,
1438
+ )
1439
+ _run_block(B, b_start, 0) # ensure this block runs once
1440
+
1441
+ _store_outputs(b_start, b_end, B)
1442
+
1443
+ if _graph_stream is not None:
1444
+ _graph_stream.synchronize()
1445
+
1446
+ # Single D->H sync after the full loop to check for divergence.
1447
+ if bool(_div_flag[0]):
1448
+ raise RuntimeError(
1449
+ f"block_lms diverged (step_size={step_size}, block_size={block_size}). "
1450
+ f"step_size is on the same scale as lms(), but because the weights are "
1451
+ f"frozen across the block the stability ceiling is ~{block_size}x lower "
1452
+ f"than per-symbol LMS. Reduce step_size until stable (e.g. try "
1453
+ f"{step_size / 2:.2e}, then keep halving) rather than dividing by "
1454
+ f"block_size, which would under-adapt the filter by that factor."
1455
+ )
1456
+
1457
+ # -- Pack result -----------------------------------------------------------
1458
+ if was_1d:
1459
+ y_out = y_all[0]
1460
+ e_out = e_all[0]
1461
+ W_out = h[0, 0]
1462
+ if store_weights and w_hist is not None:
1463
+ w_history = w_hist[:, 0, 0, :]
1464
+ else:
1465
+ w_history = None
1466
+
1467
+ if cpr_type == "bps" and phi_all is not None:
1468
+ phase_traj = phi_all[0]
1469
+ else:
1470
+ phase_traj = None
1471
+ else:
1472
+ y_out = y_all
1473
+ e_out = e_all
1474
+ W_out = h
1475
+ w_history = w_hist if store_weights else None
1476
+ phase_traj = phi_all if cpr_type == "bps" else None
1477
+
1478
+ result = EqualizerResult(
1479
+ y_hat=y_out,
1480
+ weights=W_out,
1481
+ error=e_out,
1482
+ weights_history=w_history,
1483
+ num_train_symbols=n_train_aligned,
1484
+ input_norm_factor=eq_norm,
1485
+ phase_trajectory=phase_traj,
1486
+ )
1487
+ if cpr_type == "bps":
1488
+ result.cpr_state = CPRState(
1489
+ bps_prev4=to_device(bps_prev4, "cpu").copy(),
1490
+ bps_offset4=to_device(bps_offset4, "cpu").copy(),
1491
+ bps_d2_hist=to_device(bps_d2_hist, "cpu"),
1492
+ cs_buf_x=cs_buf_x.copy(),
1493
+ cs_buf_y=to_device(cs_buf_y, "cpu").copy(),
1494
+ cs_buf_ptr=to_device(cs_buf_ptr, "cpu").copy(),
1495
+ cs_buf_n=to_device(cs_buf_n, "cpu").copy(),
1496
+ cs_stats=to_device(cs_stats, "cpu").copy(),
1497
+ cpr_type=cpr_type,
1498
+ num_ch=C,
1499
+ symmetry=_cpr_symmetry(modulation, order),
1500
+ bps_P=P,
1501
+ bps_K=_bps_K,
1502
+ cs_H=_cs_H,
1503
+ )
1504
+ return _log_equalizer_exit(
1505
+ result, name="Block-LMS", debug_plot=debug_plot, plot_smoothing=plot_smoothing
1506
+ )
1507
+
1508
+
1509
+ # -----------------------------------------------------------------------------
1510
+ # BLIND FREQUENCY-DOMAIN EQUALIZERS (block_cma / block_rde)
1511
+ # -----------------------------------------------------------------------------
1512
+ #
1513
+ # block_cma / block_rde are the blind, phase-directed siblings of block_lms:
1514
+ # the same overlap-save frequency-domain adaptive filter (FDAF), but with the
1515
+ # Godard / ring-radius error instead of the trained/DD slicer. They share the
1516
+ # two FDAF primitives below (forward butterfly + frequency-domain gradient);
1517
+ # block_lms keeps its own CPR/cycle-slip/CUDA-graph-optimised body.
1518
+ #
1519
+ # All three use the same additive update ``h += mu * conj(E_fd) * X_fd`` with
1520
+ # the gradient correlation in the frequency domain, so the blind error is cast
1521
+ # into the trained convention's sign: CMA ``E = y*(R2 - |y|^2)`` and RDE
1522
+ # ``E = y*(R_d^2 - |y|^2)`` (the negated Godard/ring gradient), and pilot
1523
+ # positions use the LMS residual ``E = pilot_ref - y`` directly.
1524
+
1525
+
1526
+ def _fdaf_forward(h, x_win, fftsize, sps, B, xp):
1527
+ """One overlap-save FDAF forward block.
1528
+
1529
+ Returns ``(y_block (C, B), X_fd (C, F))`` - the decimated output symbols and
1530
+ the input spectrum (reused by the gradient). The butterfly contraction is a
1531
+ complex128-accumulated broadcast-reduce (CLAUDE.md filter-dot-product rule),
1532
+ matching ``block_lms``'s capture-safe form.
1533
+ """
1534
+ X_fd = xp.fft.fft(x_win, axis=-1) # (C, F)
1535
+ H_fd = xp.fft.fft(h, n=fftsize, axis=-1) # (C, C, F)
1536
+ Y_fd = (
1537
+ (xp.conj(H_fd).astype(xp.complex128) * X_fd.astype(xp.complex128)[None])
1538
+ .sum(axis=1)
1539
+ .astype(xp.complex64)
1540
+ ) # (C, F)
1541
+ y_time = xp.fft.ifft(Y_fd, axis=-1)
1542
+ y_block = y_time[:, : B * sps : sps].astype(xp.complex64) # (C, B)
1543
+ return y_block, X_fd
1544
+
1545
+
1546
+ def _fdaf_gradient_update(h, X_fd, e_block, e_scatter, sps, B, num_taps, mu, xp):
1547
+ """Scatter ``e_block`` to sample positions, form the frequency-domain
1548
+ gradient ``dH[i,j] = conj(E_fd[i]) * X_fd[j]``, and apply the in-place
1549
+ update ``h += mu * IFFT(dH)[..., :num_taps]``."""
1550
+ e_scatter.fill(0)
1551
+ e_scatter[:, : B * sps : sps] = e_block
1552
+ E_fd = xp.fft.fft(e_scatter, axis=-1) # (C, F)
1553
+ dH_fd = xp.conj(E_fd)[:, None, :] * X_fd[None, :, :] # (C, C, F)
1554
+ dh = xp.fft.ifft(dH_fd, axis=-1)[:, :, :num_taps] # (C, C, T)
1555
+ h += xp.float32(mu) * dh
1556
+
1557
+
1558
+ def _block_fdaf_blind(
1559
+ kind,
1560
+ samples,
1561
+ *,
1562
+ num_taps,
1563
+ sps,
1564
+ step_size,
1565
+ block_size,
1566
+ r2,
1567
+ radii_np,
1568
+ w_init,
1569
+ input_norm_factor,
1570
+ samples_prefix,
1571
+ pad_mode,
1572
+ pilot_ref,
1573
+ pilot_mask,
1574
+ pilot_gain_db,
1575
+ c_ps,
1576
+ cuda_graph,
1577
+ debug_plot,
1578
+ plot_smoothing,
1579
+ name,
1580
+ ):
1581
+ """Shared overlap-save FDAF engine for ``block_cma``/``block_rde``.
1582
+
1583
+ Blind, phase-directed adaptation with no CPR - the per-block error is the
1584
+ Godard (``kind='cma'``) or nearest-ring (``kind='rde'``) gradient, with
1585
+ pilot positions overridden by the LMS residual. ``r2`` is the Godard radius
1586
+ (CMA) and ``radii_np`` the unique ring radii (RDE).
1587
+ """
1588
+ num_taps = int(num_taps)
1589
+ sps = int(sps)
1590
+ block_size = int(block_size)
1591
+ _validate_sps(sps, num_taps)
1592
+
1593
+ samples, xp, _ = dispatch(samples)
1594
+ if xp is np:
1595
+ logger.warning(
1596
+ "%s is running on CPU (NumPy). For CPU workloads %s(..., backend='numba') is typically faster. Move samples to GPU (CuPy) to benefit from block-FFT acceleration.",
1597
+ name,
1598
+ kind,
1599
+ )
1600
+
1601
+ was_1d = samples.ndim == 1
1602
+ if was_1d:
1603
+ samples = samples[np.newaxis, :]
1604
+ C = samples.shape[0]
1605
+ N = samples.shape[1]
1606
+ n_sym = N // sps
1607
+
1608
+ use_pilots = pilot_ref is not None and pilot_mask is not None
1609
+ # Deboost pilot positions before normalisation so boosted pilots don't
1610
+ # inflate the RMS and bias the Godard target (matches cma/rde).
1611
+ if use_pilots and pilot_gain_db != 0.0:
1612
+ amp = xp.float32(10.0 ** (pilot_gain_db / 20.0))
1613
+ smask = xp.asarray(np.repeat(np.asarray(pilot_mask).astype(bool), sps))
1614
+ samples = samples.copy()
1615
+ samples[..., smask] /= amp
1616
+ samples, _, eq_norm = _normalize_inputs(
1617
+ samples, None, sps, input_norm_factor=input_norm_factor
1618
+ )
1619
+
1620
+ # -- OLS block size + padding (matches block_lms) -----------------------
1621
+ _ols_min = block_size * sps + num_taps - 1
1622
+ fftsize = 1 << (_ols_min - 1).bit_length()
1623
+ logger.info(
1624
+ "%s: C=%s, num_taps=%s, sps=%s, block_size=%s, fftsize=%s, mu=%s, n_sym=%s, pilot_aided=%s",
1625
+ name,
1626
+ C,
1627
+ num_taps,
1628
+ sps,
1629
+ block_size,
1630
+ fftsize,
1631
+ step_size,
1632
+ n_sym,
1633
+ use_pilots,
1634
+ )
1635
+ c_tap = num_taps // 2
1636
+ pad_total = max(0, n_sym * sps - N + num_taps - 1)
1637
+ pad_left = min(c_tap, pad_total)
1638
+ pad_right = pad_total - pad_left
1639
+ if samples_prefix is not None or xp is np or pad_mode != "zeros":
1640
+ _cpu = to_device(samples, "cpu").astype(np.complex64)
1641
+ x_padded = xp.asarray(
1642
+ _build_padded_samples(
1643
+ _cpu, pad_left, pad_right, samples_prefix, pad_mode, eq_norm, sps
1644
+ )
1645
+ )
1646
+ else:
1647
+ _f32 = (
1648
+ samples if samples.dtype == xp.complex64 else samples.astype(xp.complex64)
1649
+ )
1650
+ _l = xp.zeros((C, pad_left), dtype=xp.complex64)
1651
+ _r = (
1652
+ xp.zeros((C, pad_right), dtype=xp.complex64)
1653
+ if pad_right > 0
1654
+ else xp.empty((C, 0), dtype=xp.complex64)
1655
+ )
1656
+ x_padded = xp.concatenate([_l, _f32, _r], axis=1)
1657
+ N_padded = x_padded.shape[1]
1658
+
1659
+ # -- Weight initialisation ----------------------------------------------
1660
+ if w_init is not None:
1661
+ w_arr = _validate_w_init(
1662
+ np.ascontiguousarray(to_device(w_init, "cpu"), dtype=np.complex64),
1663
+ C,
1664
+ num_taps,
1665
+ )
1666
+ h = xp.asarray(w_arr.copy())
1667
+ else:
1668
+ h = xp.asarray(_init_butterfly_weights_numpy(C, num_taps)) # (C, C, T)
1669
+
1670
+ # -- Pilot / radii device arrays ----------------------------------------
1671
+ if use_pilots:
1672
+ pref = xp.asarray(
1673
+ np.ascontiguousarray(to_device(pilot_ref, "cpu"), dtype=np.complex64)
1674
+ )
1675
+ if pref.ndim == 1:
1676
+ pref = xp.tile(pref[None, :], (C, 1))
1677
+ if c_ps is not None:
1678
+ pref = (pref * xp.complex64(c_ps)).astype(xp.complex64)
1679
+ pmask_dev = xp.asarray(np.asarray(pilot_mask).astype(bool))
1680
+ if kind == "rde":
1681
+ radii = xp.asarray(np.asarray(radii_np, dtype=np.float64))
1682
+
1683
+ # -- Scratch + output buffers -------------------------------------------
1684
+ # Pre-allocated and reused every block: avoids per-block heap pressure and
1685
+ # (on GPU) gives the CUDA-graph capture stable read/write pointers.
1686
+ x_win = xp.zeros((C, fftsize), dtype=xp.complex64)
1687
+ e_scatter = xp.zeros((C, fftsize), dtype=xp.complex64)
1688
+ y_all = xp.empty((C, n_sym), dtype=xp.complex64)
1689
+ e_all = xp.empty((C, n_sym), dtype=xp.complex64)
1690
+ # Fixed-width (block_size) output workspaces. The capturable body always
1691
+ # writes its per-symbol outputs here; the eager driver copies the valid
1692
+ # [:, :B] slice into the full-length arrays at the right offset. Routing
1693
+ # through fixed buffers (rather than writing y_all[:, b_start:b_end]
1694
+ # directly) is what lets the body be captured once and replayed: only the
1695
+ # input fill and output copy vary per block.
1696
+ y_ws = xp.empty((C, block_size), dtype=xp.complex64)
1697
+ e_ws = xp.empty((C, block_size), dtype=xp.complex64)
1698
+ n_blocks = (n_sym + block_size - 1) // block_size
1699
+
1700
+ def _run_block(B, b_start):
1701
+ """Compute one block: forward FDAF, blind error, gradient + in-place
1702
+ weight update. Reads the window from ``x_win`` (filled by the caller),
1703
+ updates ``h`` in place, and writes outputs into ``y_ws``/``e_ws`` (cols
1704
+ ``[:, :B]``). Issues no host sync, so on GPU it is capturable. The
1705
+ ``b_start``-varying pilot slice is only touched on the (non-captured)
1706
+ pilot-aided path."""
1707
+ nonlocal h
1708
+ y_block, X_fd = _fdaf_forward(h, x_win, fftsize, sps, B, xp)
1709
+
1710
+ abs2 = xp.real(y_block * xp.conj(y_block)) # (C, B) strict-real |y|^2
1711
+ if kind == "cma":
1712
+ e = y_block * (xp.float32(r2) - abs2)
1713
+ else: # rde - nearest ring radius per symbol
1714
+ abs_y = xp.sqrt(abs2)
1715
+ rd = radii[
1716
+ xp.argmin(xp.abs(abs_y[:, :, None] - radii[None, None, :]), axis=-1)
1717
+ ]
1718
+ e = y_block * (rd.astype(xp.float32) ** 2 - abs2)
1719
+ if use_pilots:
1720
+ pm = pmask_dev[b_start : b_start + B][None, :]
1721
+ e = xp.where(pm, pref[:, b_start : b_start + B] - y_block, e)
1722
+
1723
+ y_ws[:, :B] = y_block
1724
+ e_ws[:, :B] = e
1725
+ _fdaf_gradient_update(h, X_fd, e, e_scatter, sps, B, num_taps, step_size, xp)
1726
+
1727
+ # CUDA-graph invariant: _fill_x_win / _store_outputs run eagerly *between*
1728
+ # replays and MUST NOT allocate from the device memory pool (a fresh
1729
+ # allocation could hand out the blocks the captured graph's freed
1730
+ # intermediates still reference). Both touch only pre-allocated buffers.
1731
+ def _fill_x_win(b_start):
1732
+ """Eager (per-block, varying offset) load of the input window."""
1733
+ x_start = b_start * sps
1734
+ x_win.fill(0)
1735
+ avail = min(fftsize, N_padded - x_start)
1736
+ if avail > 0:
1737
+ x_win[:, :avail] = x_padded[:, x_start : x_start + avail]
1738
+
1739
+ def _store_outputs(b_start, b_end, B):
1740
+ """Eager copy of the fixed workspaces into the result arrays."""
1741
+ y_all[:, b_start:b_end] = y_ws[:, :B]
1742
+ e_all[:, b_start:b_end] = e_ws[:, :B]
1743
+
1744
+ # -- CUDA-graph eligibility ----------------------------------------------
1745
+ # The block body is host-sync-free, so on GPU it can be captured once and
1746
+ # replayed per block, collapsing the per-block kernel launches into one.
1747
+ # Only full blocks (B == block_size) share a fixed shape and control flow,
1748
+ # so only those are captured; the final partial block runs eagerly. The
1749
+ # pilot-aided path reads b_start-varying pilot slices inside the body and is
1750
+ # therefore not capturable - it always runs the eager loop.
1751
+ n_full = n_sym // block_size
1752
+ _use_graph = (
1753
+ cuda_graph
1754
+ and xp is not np
1755
+ and not use_pilots
1756
+ and n_full >= 2 # need ≥1 warmup block + ≥1 captured block to pay off
1757
+ )
1758
+ try:
1759
+ import cupy as _cp_graph
1760
+
1761
+ _graph_stream = _cp_graph.cuda.Stream(non_blocking=True) if _use_graph else None
1762
+ except Exception:
1763
+ _use_graph = False
1764
+ _graph_stream = None
1765
+
1766
+ # Run the whole loop on one stream so the shared state (h) stays ordered
1767
+ # across the eager<->replay boundary. The non-blocking graph stream does not
1768
+ # implicitly serialize with the default stream that produced x_padded/h/the
1769
+ # scratch buffers, so join it to that setup work before the first block to
1770
+ # avoid capturing still-in-flight garbage.
1771
+ _loop_stream_ctx: Any
1772
+ if _use_graph:
1773
+ assert _graph_stream is not None
1774
+ _setup_done = _cp_graph.cuda.Event()
1775
+ _setup_done.record()
1776
+ _graph_stream.wait_event(_setup_done)
1777
+ _loop_stream_ctx = _graph_stream
1778
+ else:
1779
+ _loop_stream_ctx = contextlib.nullcontext()
1780
+
1781
+ # -- Block loop ----------------------------------------------------------
1782
+ _graph = None # captured CUDA graph, built lazily on the 2nd full block
1783
+ _graph_warmed = False # True once one full block has primed the mem pool
1784
+ with _loop_stream_ctx:
1785
+ for b in range(n_blocks):
1786
+ b_start = b * block_size
1787
+ b_end = min(b_start + block_size, n_sym)
1788
+ B = b_end - b_start
1789
+ _capturable = _use_graph and block_size == B
1790
+
1791
+ _fill_x_win(b_start)
1792
+
1793
+ if not _capturable:
1794
+ _run_block(B, b_start) # eager (partial block, or graph off)
1795
+ elif _graph is not None:
1796
+ _graph.launch() # replay (current stream == _graph_stream)
1797
+ elif not _graph_warmed:
1798
+ _run_block(B, b_start) # eager warmup - primes the memory pool
1799
+ _graph_warmed = True
1800
+ else:
1801
+ # Capture the body once; pool allocations reuse the blocks the
1802
+ # warmup freed, so no cudaMalloc occurs during capture. On any
1803
+ # failure, fall back to the eager loop for the rest of the run.
1804
+ assert _graph_stream is not None
1805
+ try:
1806
+ _graph_stream.begin_capture()
1807
+ _run_block(B, b_start)
1808
+ _graph = _graph_stream.end_capture()
1809
+ _graph.launch()
1810
+ except Exception as exc: # pragma: no cover - hw/version dependent
1811
+ with contextlib.suppress(Exception):
1812
+ _graph_stream.end_capture()
1813
+ _graph = None
1814
+ _use_graph = False
1815
+ logger.warning(
1816
+ "%s CUDA-graph capture failed (%s); "
1817
+ "continuing with the eager block loop.",
1818
+ name,
1819
+ exc,
1820
+ )
1821
+ _run_block(B, b_start) # ensure this block runs once
1822
+
1823
+ _store_outputs(b_start, b_end, B)
1824
+
1825
+ if _graph_stream is not None:
1826
+ _graph_stream.synchronize()
1827
+
1828
+ # Single D->H sync to surface divergence after the whole loop.
1829
+ if not bool(xp.isfinite(h).all()):
1830
+ raise RuntimeError(
1831
+ f"{name} diverged (step_size={step_size}, block_size={block_size}). "
1832
+ f"step_size is on the same scale as {kind}(); because the weights are "
1833
+ f"frozen across the block the stability ceiling is ~{block_size}x lower. "
1834
+ f"Reduce step_size (e.g. {step_size / 2:.2e}, then keep halving)."
1835
+ )
1836
+
1837
+ if was_1d:
1838
+ y_out, e_out, W_out = y_all[0], e_all[0], h[0, 0]
1839
+ else:
1840
+ y_out, e_out, W_out = y_all, e_all, h
1841
+ result = EqualizerResult(
1842
+ y_hat=y_out,
1843
+ weights=W_out,
1844
+ error=e_out,
1845
+ weights_history=None,
1846
+ num_train_symbols=0,
1847
+ input_norm_factor=eq_norm,
1848
+ )
1849
+ return _log_equalizer_exit(
1850
+ result,
1851
+ name=name,
1852
+ debug_plot=debug_plot,
1853
+ check_convergence=True,
1854
+ plot_smoothing=plot_smoothing,
1855
+ )