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,2805 @@
1
+ """Public sequential adaptive equalizers: lms, rls, cma, rde."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+
9
+ from ..backend import ArrayType, _get_jax, dispatch, from_jax, to_device, to_jax
10
+ from ..helpers import resolve_pll_gains
11
+ from ..logger import logger
12
+ from ._block import (
13
+ _build_slicer_constellation,
14
+ _prep_blind_block_inputs,
15
+ _run_block_equalizer,
16
+ _validate_block_mode,
17
+ )
18
+ from ._common import (
19
+ _build_padded_samples,
20
+ _cpr_state_to_jax_inits,
21
+ _cpr_symmetry,
22
+ _init_butterfly_weights_jax,
23
+ _init_butterfly_weights_numpy,
24
+ _normalize_inputs,
25
+ _prepare_training_jax,
26
+ _prepare_training_numpy,
27
+ _sq_qam_slicer_params,
28
+ _unpack_result_jax,
29
+ _unpack_result_numpy,
30
+ _validate_sps,
31
+ _validate_w_init,
32
+ )
33
+ from ._kernels_jax import (
34
+ _get_jax_cma,
35
+ _get_jax_lms,
36
+ _get_jax_lms_cpr,
37
+ _get_jax_pa_cma,
38
+ _get_jax_pa_rde,
39
+ _get_jax_rde,
40
+ _get_jax_rls,
41
+ _get_jax_rls_cpr,
42
+ )
43
+ from ._kernels_numba import (
44
+ _get_numba,
45
+ _get_numba_cma,
46
+ _get_numba_lms,
47
+ _get_numba_lms_cpr,
48
+ _get_numba_pa_cma,
49
+ _get_numba_pa_rde,
50
+ _get_numba_rde,
51
+ _get_numba_rls,
52
+ _get_numba_rls_cpr,
53
+ )
54
+ from .result import CPRState, EqualizerResult, _log_equalizer_exit
55
+
56
+ # -----------------------------------------------------------------------------
57
+ # ADAPTIVE equalization
58
+ # -----------------------------------------------------------------------------
59
+
60
+
61
+ def lms(
62
+ samples: ArrayType,
63
+ training_symbols: ArrayType | None = None,
64
+ num_taps: int = 21,
65
+ sps: int = 2,
66
+ step_size: float = 0.01,
67
+ modulation: str | None = None,
68
+ order: int | None = None,
69
+ unipolar: bool = False,
70
+ store_weights: bool = False,
71
+ device: str | None = "cpu",
72
+ center_tap: int | None = None,
73
+ backend: str = "numba",
74
+ w_init: ArrayType | None = None,
75
+ pmf: Any | None = None,
76
+ cpr_type: str | None = None,
77
+ cpr_pll_bandwidth: float = 1e-3,
78
+ cpr_pll_mu: float | None = None,
79
+ cpr_pll_beta: float | None = None,
80
+ cpr_bps_test_phases: int = 64,
81
+ cpr_bps_block_size: int = 32,
82
+ cpr_joint_channels: bool = False,
83
+ cpr_cycle_slip_correction: bool = False,
84
+ cpr_cycle_slip_history: int = 100,
85
+ cpr_cycle_slip_threshold: float = np.pi / 4,
86
+ debug_plot: bool = False,
87
+ plot_smoothing: int = 50,
88
+ cpr_state: CPRState | None = None,
89
+ input_norm_factor: float | np.ndarray | None = None,
90
+ samples_prefix: ArrayType | None = None,
91
+ pad_mode: str = "zeros",
92
+ update_mode: str = "sequential",
93
+ block_len: int = 16,
94
+ ) -> EqualizerResult:
95
+ """
96
+ Least Mean Squares adaptive equalizer with butterfly MIMO support.
97
+
98
+ Update modes
99
+ ------------
100
+ The weight-update cadence is selected by ``update_mode`` - the equalizer
101
+ math, regressor windows, slicer, and error are otherwise identical:
102
+
103
+ +-----------------------+--------+----------------+----------------------+
104
+ | Mode | Domain | Adaptation lag | Use case |
105
+ +=======================+========+================+======================+
106
+ | ``'sequential'`` | time | 1 symbol | fastest dynamics, |
107
+ | (default) | | | CPU (Numba) |
108
+ +-----------------------+--------+----------------+----------------------+
109
+ | ``'block'``, | time | ``block_len`` | fast dynamics on GPU |
110
+ | ``block_len`` 8-32 | | symbols | (JAX/CuPy) |
111
+ +-----------------------+--------+----------------+----------------------+
112
+ | ``block_lms`` | freq. | ``block_size`` | throughput king, |
113
+ | (separate function) | | (~256) | slow/static channels |
114
+ +-----------------------+--------+----------------+----------------------+
115
+
116
+ With ``update_mode='block'`` the weights are frozen over ``block_len``
117
+ symbols and one aggregated gradient - exactly the sum of what a
118
+ frozen-weight per-symbol LMS would accumulate over those symbols - is
119
+ applied per chunk, turning ``block_len`` rank-1 updates into a single matrix
120
+ product the GPU can occupy. ``step_size`` is therefore on the **same scale
121
+ as** ``update_mode='sequential'``: the same ``mu`` yields the same
122
+ convergence and steady-state floor (do **not** divide by ``block_len``).
123
+ Only the *stability ceiling* is ~``block_len``x lower (the weights are
124
+ frozen across the chunk); reduce ``mu`` below your sequential value only if
125
+ the run diverges. Block mode requires ``backend='jax'`` (chunked
126
+ ``lax.scan``) or ``backend='xp'`` (array-native NumPy/CuPy loop);
127
+ ``backend='numba'`` and ``cpr_type``/``store_weights`` are not supported.
128
+
129
+ Supports data-aided (training) and decision-directed (DD) modes.
130
+ When ``training_symbols`` are provided, the equalizer uses them for the
131
+ initial convergence phase, then switches to DD mode using the
132
+ ``modulation`` and ``order`` parameters, or a constellation auto-inferred
133
+ from the training symbols for hard-decision slicing.
134
+
135
+ For MIMO inputs ``(C, N)``, a butterfly ``(C, C, num_taps)`` filter
136
+ structure is used so each output is a weighted sum of all input streams,
137
+ enabling cross-channel interference cancellation.
138
+
139
+ Algorithm (per symbol n)
140
+ ------------------------
141
+ 1. **Sliding input window** - the length-T tap vector for output channel c
142
+ is drawn from the padded input at the strided sample position::
143
+
144
+ x_{c',n} = [x_{c'}[n*sps - T_c], ..., x_{c'}[n*sps - T_c + T - 1]]
145
+
146
+ where ``T_c`` = ``center_tap`` (default ``T // 2``). The causal delay
147
+ ``T_c`` is absorbed into the tap vector so the filter can model both
148
+ pre- and post-cursor ISI.
149
+
150
+ 2. **Butterfly filter** - cross-correlate conjugate weights with the input
151
+ across all C input channels::
152
+
153
+ y_c_raw[n] = sum_{c'} w_{c,c'}^H * x_{c',n}
154
+
155
+ 3. **Carrier phase recovery** (if ``cpr_type`` is set):
156
+
157
+ * **PLL** - cross-product phase detector
158
+ ``phi_err = Im(y_raw * conj(d_prev))``
159
+ drives a PI integrator with gains ``K_p``, ``K_i``; accumulated
160
+ phase ``phi_n`` is applied as ``y[n] = y_raw * exp(-j*phi_n)``.
161
+ * **BPS** - ``B`` candidate rotations ``exp(-j*k*pi/(2*B))`` are
162
+ tested; the one minimising the summed nearest-constellation distance
163
+ over the trailing ``K`` = ``cpr_bps_block_size`` symbols is chosen.
164
+ A causal 4-fold unwrap converts the ``[0, pi/2)`` argmin to full-range
165
+ ``phi_n`` stored in a float64 accumulator.
166
+
167
+ 4. **Decision** - training symbol ``d[n]`` (DA phase, while
168
+ ``n < len(training_symbols)``) or nearest-constellation hard decision on
169
+ ``y[n]`` (DD phase thereafter).
170
+
171
+ 5. **Error and tap-plane back-rotation**::
172
+
173
+ e_clean[n] = d[n] - y[n]
174
+ e_taps[n] = e_clean[n] * exp(+j*phi_n)
175
+
176
+ The back-rotation undoes the CPR correction so the gradient operates in
177
+ the original tap space.
178
+
179
+ 6. **LMS weight update** (plain, no input-power normalisation)::
180
+
181
+ w_{c,c'} += mu * conj(e_taps_c[n]) * x_{c',n}
182
+
183
+ Stability bound: ``0 < mu < 2 / (C * T * P_x)`` where ``P_x`` is the
184
+ mean per-tap input power. The equalizer normalises inputs to unit
185
+ symbol-rate power before adaptation, so ``P_x ≈ 1``.
186
+
187
+ 7. **Cycle-slip correction** (if ``cpr_cycle_slip_correction=True``) - a
188
+ circular buffer of ``cpr_cycle_slip_history`` past phase values is
189
+ maintained per channel. An online least-squares linear fit over the
190
+ buffer predicts ``phi_pred_n``. If
191
+ ``|phi_n - phi_pred_n| > cpr_cycle_slip_threshold``,
192
+ ``phi_n`` is snapped to the nearest ``2*pi/sym``
193
+ multiple (``sym`` = constellation symmetry order, 4 for QAM/QPSK); the
194
+ corrected value replaces ``phi_n`` in steps 5 and 6 and is written into
195
+ the history buffer.
196
+
197
+ Parameters
198
+ ----------
199
+ samples : array_like
200
+ Input signal samples. Shape: ``(N_samples,)`` for SISO or
201
+ ``(C, N_samples)`` for MIMO butterfly equalization.
202
+ Typically at 2 samples/symbol for fractionally-spaced equalization.
203
+ training_symbols : array_like, optional
204
+ Known transmitted symbols (at symbol rate, 1 SPS).
205
+ Shape: ``(N_train,)`` for SISO or ``(C, N_train)`` for MIMO.
206
+ If None, pure DD mode (requires ``modulation`` and ``order``).
207
+ num_taps : int, default 21
208
+ Number of equalizer taps per FIR filter. For fractionally-spaced
209
+ equalization (sps > 1), use at least ``4 * sps`` taps.
210
+ sps : int, default 2
211
+ Samples per symbol at the input. Use ``sps=2`` (T/2-spaced, default)
212
+ for the first equalization stage. ``sps=1`` is valid for a second
213
+ symbol-spaced stage after FOE + CPR; use a short filter (3-11 taps)
214
+ for residual ISI cleanup. ``sps > 2`` is accepted but uncommon.
215
+ The equalizer decimates by ``sps`` to produce one output symbol per
216
+ input stride.
217
+ step_size : float, default 0.01
218
+ Plain LMS step size (mu). The gradient is applied directly without
219
+ input-power normalization, matching the convention in Haykin's
220
+ *Adaptive Filter Theory* and most published papers. Stability
221
+ requires ``0 < mu < 2 / (C * num_taps * P_x)`` where ``P_x`` is the
222
+ mean per-tap input power. Because inputs are normalized to unit
223
+ symbol-rate power by default, a safe starting range for typical
224
+ settings is ``1e-4`` to ``1e-2``. Values closer to the upper bound
225
+ converge faster but produce higher steady-state misadjustment.
226
+ modulation : str, optional
227
+ Modulation scheme (e.g., 'psk', 'qam', 'pam') for DD slicing.
228
+ Required if ``training_symbols`` is None.
229
+ order : int, optional
230
+ Modulation order (e.g., 4, 16).
231
+ unipolar : bool, default False
232
+ If True, indicates the modulation is unipolar (e.g., unipolar PAM).
233
+ store_weights : bool, default False
234
+ If True, stores weight trajectory in ``weights_history``.
235
+ device : str, optional
236
+ Target device for JAX computations (e.g., 'cpu', 'gpu', 'tpu').
237
+ Default is 'cpu'. Ignored when ``backend='numba'``.
238
+ center_tap : int, optional
239
+ Index of the center tap. If None, defaults to ``num_taps // 2``.
240
+ backend : str, default 'numba'
241
+ Execution backend. ``'numba'`` compiles the sequential loop with LLVM
242
+ via Numba ``@njit``; typically 2-5x faster than JAX on CPU for
243
+ SISO/small-MIMO signals (no scan serialization overhead).
244
+ ``'jax'`` uses ``jax.lax.scan`` and supports GPU placement and
245
+ automatic differentiation through the equalizer.
246
+ ``'xp'`` is valid only with ``update_mode='block'``: it runs the
247
+ array-native NumPy/CuPy block loop on the input's device (no JAX).
248
+ w_init : array_like, optional
249
+ Initial tap weights. Shape: ``(C, C, num_taps)`` complex64, or the
250
+ SISO short-hand ``(num_taps,)`` / ``(1, num_taps)`` as returned by
251
+ ``EqualizerResult.weights`` for single-channel equalizers.
252
+ If provided, the equalizer warm-starts from these weights instead of
253
+ the default center-tap identity matrix. Useful for weight handoff from
254
+ a prior stage (e.g. preamble LMS -> payload LMS).
255
+ Raises ``ValueError`` if the shape does not match.
256
+ pmf : array_like of float, optional
257
+ Probability mass function over the constellation for probabilistically
258
+ shaped QAM (PS-QAM). When provided together with ``modulation`` and
259
+ ``order``, the DD slicer constellation is scaled by ``1/sqrt(E_PS)``
260
+ (where ``E_PS = sum_m P(s_m)|s_m|^2`` on the normalised grid) so it
261
+ matches the unit-power normalised equaliser input. Training symbols
262
+ are left untouched - ``_normalize_inputs`` already brings them to unit
263
+ average power. Has no effect for uniform modulations.
264
+ cpr_type : {'pll', 'bps', None}, default None
265
+ Inline carrier phase recovery algorithm applied jointly with weight
266
+ updates at every symbol. ``None`` disables CPR (default, bit-exact
267
+ with the legacy behaviour).
268
+
269
+ * ``'pll'`` - 2nd-order decision-directed phase-locked loop. The
270
+ cross-product phase detector ``Im(y · conj(d))`` drives a PI loop
271
+ with gains derived from ``cpr_pll_bandwidth``. Low noise floor;
272
+ recommended for QPSK through 64-QAM.
273
+ * ``'bps'`` - Blind Phase Search over ``cpr_bps_test_phases`` candidate
274
+ angles in ``[0, π/2)`` (exploiting 4-fold QAM symmetry), averaged
275
+ over a causal window of ``cpr_bps_block_size`` past y_raw samples.
276
+ Preferred for burst/packet modes where PLL pull-in is impractical.
277
+
278
+ **Dual-path output:** the wrapped float32 phase estimate rotates
279
+ ``y_raw`` for the weight update, while the unwrapped float64
280
+ accumulator is stored in ``phase_trajectory``. The two paths are
281
+ kept separate to prevent float32 rounding errors from accumulating
282
+ in the trajectory over thousands of symbols.
283
+
284
+ **4-fold causal unwrap:** the raw BPS ``argmin`` lives in
285
+ ``[0, π/2)``. A causal unwrap tracks the argmin evolution symbol
286
+ by symbol, adding or subtracting ``π/2`` multiples as needed to
287
+ keep the estimate continuous, then converting to full-range radians.
288
+ cpr_pll_bandwidth : float, default 1e-3
289
+ Normalised loop bandwidth ``B_L · T_s`` for the PLL. Gains are
290
+ computed as ``K_p = 4 B_L``, ``K_i = 4 B_L²`` (critically-damped
291
+ approximation, ζ=1). Typical range: ``5e-4`` (low phase noise) to
292
+ ``5e-3`` (high phase noise / fast drift). Used only when
293
+ ``cpr_pll_mu is None`` (the bandwidth shortcut) and ``cpr_type == 'pll'``.
294
+ cpr_pll_mu : float, optional
295
+ Raw proportional PLL gain ``μ``. If given, overrides
296
+ ``cpr_pll_bandwidth`` and uses raw PI gains directly; ``cpr_pll_beta``
297
+ then defaults to ``0.0`` (a 1st-order loop). Leave ``None`` to derive
298
+ critically-damped gains from ``cpr_pll_bandwidth``. Interchangeable
299
+ with the ``mu`` of ``recover_carrier_phase_pll``.
300
+ cpr_pll_beta : float, optional
301
+ Raw integral PLL gain ``β``. ``β=0`` => 1st-order loop (no frequency
302
+ integrator); ``β>0`` => 2nd-order loop. Requires ``cpr_pll_mu`` to be
303
+ set (passing ``cpr_pll_beta`` alone raises ``ValueError``). The
304
+ ``(μ,β) <-> (B_L,ζ)`` mapping is ``ωₙT = √β``, ``ζ = μ/(2√β)``.
305
+ cpr_bps_test_phases : int, default 64
306
+ Number of candidate phase angles for the BPS search in ``[0, π/2)``.
307
+ Higher values improve phase resolution at the cost of ``B`` extra
308
+ distance evaluations per symbol. 32-64 is sufficient for ≤ 16-QAM;
309
+ use 64-128 for 64-QAM. Ignored when ``cpr_type != 'bps'``.
310
+ cpr_bps_block_size : int, default 32
311
+ Number of past y_raw samples whose min-distance metrics are summed
312
+ before the BPS ``argmin``. Larger values reduce noise on the phase
313
+ estimate at the cost of increased latency (``K-1`` symbols).
314
+ ``cpr_bps_block_size=1`` recovers the degenerate single-symbol BPS.
315
+ Ignored when ``cpr_type != 'bps'``.
316
+ cpr_joint_channels : bool, default False
317
+ For MIMO inputs (C > 1): if ``True``, the per-symbol phase estimate
318
+ is computed jointly across all C channels before being applied.
319
+
320
+ * **BPS** - the per-candidate distance metrics are summed across
321
+ channels before ``argmin``, giving one shared estimate broadcast
322
+ to all channels. Reduces estimation variance by ~√C for
323
+ shared-LO transmitter setups.
324
+ * **PLL** - the cross-product phase-error signal
325
+ ``Im(y · conj(d))`` is averaged across channels before the PI
326
+ integrator, so all channels share one phase trajectory.
327
+
328
+ When ``False``, each channel runs its own independent estimator.
329
+ Ignored for SISO inputs (C == 1).
330
+ cpr_cycle_slip_correction : bool, default False
331
+ Enable causal cycle-slip detection and correction. A circular
332
+ buffer of ``cpr_cycle_slip_history`` past phase estimates is
333
+ maintained per channel. Before each symbol, an online linear
334
+ regression over the buffer predicts the current phase; if the new
335
+ estimate deviates from the prediction by more than
336
+ ``cpr_cycle_slip_threshold``, the estimate is snapped to the
337
+ nearest ``2π/symmetry`` quantum (e.g. π/2 for QPSK/QAM) and the
338
+ buffer is updated with the corrected value. Disable for
339
+ deterministic parity checks or when the channel is known to be
340
+ slip-free.
341
+ cpr_cycle_slip_history : int, default 100
342
+ Length of the phase-history buffer used for cycle-slip prediction.
343
+ Longer buffers produce a more stable linear-trend fit but are
344
+ slower to adapt when the true carrier frequency drifts. Ignored
345
+ when ``cpr_cycle_slip_correction=False``.
346
+ cpr_cycle_slip_threshold : float, default π/4
347
+ Maximum tolerated deviation (radians) between the predicted and
348
+ observed phase before a slip is declared. Should be set to half
349
+ the constellation's angular symmetry quantum (``π/4`` for
350
+ QPSK/QAM). Ignored when ``cpr_cycle_slip_correction=False``.
351
+ debug_plot : bool, default False
352
+ If True, display a convergence + tap-weight diagnostic plot on exit.
353
+ plot_smoothing : int, default 50
354
+ Moving-average window (symbols) for the MSE convergence curve in the
355
+ debug plot.
356
+ cpr_state : CPRState, optional
357
+ Warm-start CPR state from a previous ``lms()`` call (obtained via
358
+ ``EqualizerResult.cpr_state``). When provided and the CPR type and
359
+ channel count match, the PLL integrators, BPS unwrap accumulators,
360
+ and cycle-slip buffers are pre-loaded rather than zero-initialized.
361
+ This eliminates the ~5-10 k symbol CPR convergence transient that
362
+ occurs at every block boundary in streaming pipelines. Pass
363
+ ``None`` (default) to cold-start the CPR from zero. Ignored when
364
+ ``cpr_type=None`` or when the stored state is incompatible (mismatched
365
+ ``cpr_type``, channel count, or history depth), in which case the
366
+ equalizer falls back to cold-start silently.
367
+
368
+ Note: JAX backend: ``cpr_state`` warm-start is not yet supported;
369
+ passing a non-``None`` value raises ``NotImplementedError``.
370
+ input_norm_factor : float or ndarray, optional
371
+ Pre-computed RMS normalization factor from a previous call (obtained
372
+ via ``EqualizerResult.input_norm_factor``). When provided, the
373
+ ``_normalize_inputs`` step is skipped and this value is used directly
374
+ to scale the input samples and training symbols. This ensures that
375
+ warm-started weight vectors see the same amplitude regime as the
376
+ block on which they were trained, preventing a gradient scale mismatch
377
+ when signal power drifts slowly between blocks.
378
+ Pass ``None`` (default) to recompute the RMS from the current block.
379
+ samples_prefix : array_like, optional
380
+ Signal history from the end of the previous block, used to eliminate
381
+ the zero-padded leading transient at each block boundary. Shape:
382
+ ``(≥ pad_left,)`` SISO or ``(C, ≥ pad_left)`` MIMO, where
383
+ ``pad_left = min(center_tap, max(0, num_taps - 1))``. The last
384
+ ``pad_left`` samples of ``samples_prefix`` replace the leading zeros
385
+ in the tap window so that the first output symbol sees a fully
386
+ populated, real-signal tap vector. The prefix is normalized by the
387
+ same ``input_norm_factor`` as the main block before being prepended.
388
+ Pass ``None`` (default) for standard zero-padding. Raises
389
+ ``ValueError`` if the prefix length is less than ``pad_left``.
390
+ pad_mode : {'zeros', 'edge'}, default 'zeros'
391
+ Padding strategy for the leading tap window when ``samples_prefix``
392
+ is ``None``. ``'zeros'`` (default) prepends ``pad_left`` complex
393
+ zeros, which is the standard causal initialisation. ``'edge'``
394
+ replicates the first sample of the current block, which can reduce
395
+ the initial amplitude jump at cold start. Has no effect when
396
+ ``samples_prefix`` is provided.
397
+ update_mode : {'sequential', 'block'}, default 'sequential'
398
+ Weight-update cadence (see the *Update modes* section above).
399
+ ``'sequential'`` updates the weights every symbol (the default; the
400
+ only mode supported by ``backend='numba'``). ``'block'`` freezes the
401
+ weights over ``block_len`` symbols and applies one aggregated gradient
402
+ per chunk - a matrix product that occupies the GPU - and requires
403
+ ``backend='jax'`` (chunked ``lax.scan``) or ``backend='xp'``
404
+ (array-native NumPy/CuPy loop). ``cpr_type`` and ``store_weights`` are
405
+ not supported with ``'block'`` (both raise).
406
+ block_len : int, default 16
407
+ Number of symbols per frozen-weight update chunk when
408
+ ``update_mode='block'`` (typically 8-32; ignored otherwise). Larger
409
+ values amortise launch overhead further but reduce the tracking
410
+ bandwidth proportionally. ``step_size`` stays on the same scale as
411
+ sequential mode (same ``mu`` => same steady-state floor); only the
412
+ stability ceiling is ~``block_len``x lower.
413
+
414
+ Returns
415
+ -------
416
+ EqualizerResult
417
+ Result container with the following fields:
418
+
419
+ * ``y_hat`` - equalized symbol estimates, shape ``(N_sym,)`` SISO
420
+ or ``(C, N_sym)`` MIMO, at 1 SPS (symbol rate).
421
+ * ``weights`` - final tap-weight tensor, shape ``(num_taps,)`` SISO
422
+ or ``(C, C, num_taps)`` MIMO.
423
+ * ``error`` - complex error signal ``e[n] = d[n] - y[n]``, same
424
+ shape as ``y_hat``.
425
+ * ``weights_history`` - tap weights recorded at each symbol (only
426
+ when ``store_weights=True``); shape ``(N_sym, num_taps)`` SISO or
427
+ ``(N_sym, C, C, num_taps)`` MIMO. ``None`` otherwise.
428
+ * ``phase_trajectory`` - accumulated per-symbol phase estimates,
429
+ shape ``(N_sym,)`` SISO or ``(C, N_sym)`` MIMO. For BPS, this
430
+ is the causal 4-fold-unwrapped float64 phase. For PLL, it is
431
+ the PI integrator state accumulated over all symbols. ``None``
432
+ when ``cpr_type=None``.
433
+ * ``num_train_symbols`` - number of training symbols consumed
434
+ (data-aided phase).
435
+ * ``input_norm_factor`` - the RMS factor used to normalize inputs
436
+ (float). Store and pass as ``input_norm_factor`` on the next call
437
+ to keep weight magnitudes consistent across block boundaries.
438
+ * ``cpr_state`` - ``CPRState`` snapshot of PLL/BPS/cycle-slip
439
+ integrators after the last symbol. Pass as ``cpr_state`` on the
440
+ next call to resume CPR without a re-convergence transient.
441
+ ``None`` when ``cpr_type=None``.
442
+
443
+ Arrays reside on the same device as the input (NumPy CPU or CuPy
444
+ GPU).
445
+
446
+ Warnings
447
+ --------
448
+ **JAX GPU mode is typically slower than CPU for adaptive equalization.**
449
+ LMS is inherently sequential: each symbol's weight update depends on the
450
+ previous weights, so ``lax.scan`` serializes execution even on GPU.
451
+ The per-step arithmetic (a ``num_taps``-length dot product) is far too
452
+ small to saturate GPU compute units, while kernel-launch and
453
+ device-memory-transfer overhead dominate. In practice, ``device='cpu'``
454
+ is 2-10x faster for typical SISO sequences up to ~100 k symbols. Use
455
+ ``device='gpu'`` only when the number of MIMO channels (``num_ch``) is
456
+ large enough to amortize GPU launch costs, or when batching many
457
+ independent signals externally. For CPU-optimal throughput use
458
+ ``backend='numba'`` (the default).
459
+ """
460
+ if cpr_type is not None and cpr_type not in ("pll", "bps"):
461
+ raise ValueError(f"cpr_type must be 'pll', 'bps', or None. Got {cpr_type!r}.")
462
+ _validate_block_mode(
463
+ update_mode,
464
+ block_len,
465
+ backend,
466
+ cpr_type=cpr_type,
467
+ store_weights=store_weights,
468
+ )
469
+
470
+ n_train_log = training_symbols.shape[-1] if training_symbols is not None else 0
471
+ logger.info(
472
+ "LMS equalizer: num_taps=%s, mu=%s, sps=%s, backend=%s, n_train=%s%s",
473
+ num_taps,
474
+ step_size,
475
+ sps,
476
+ backend,
477
+ n_train_log,
478
+ f", cpr={cpr_type}" if cpr_type else "",
479
+ )
480
+ if sps > 1:
481
+ logger.warning(
482
+ "LMS output y_hat is at 1 SPS (symbol rate). "
483
+ "Update sampling_rate = symbol_rate after applying this equalizer."
484
+ )
485
+
486
+ samples, xp, _ = dispatch(samples)
487
+ stride = int(sps)
488
+ _validate_sps(sps, num_taps)
489
+
490
+ if training_symbols is not None:
491
+ training_symbols, _, _ = dispatch(training_symbols)
492
+
493
+ # Shape calcs - independent of normalization
494
+ was_1d = samples.ndim == 1
495
+ num_ch = 1 if was_1d else samples.shape[0]
496
+ n_samples = samples.shape[0] if was_1d else samples.shape[1]
497
+ n_sym = n_samples // stride
498
+
499
+ if training_symbols is not None and training_symbols.shape[-1] > n_sym:
500
+ logger.warning(
501
+ "training_symbols length (%s) exceeds available symbol count (%s); excess training symbols will be ignored.",
502
+ training_symbols.shape[-1],
503
+ n_sym,
504
+ )
505
+
506
+ c_tap = center_tap if center_tap is not None else num_taps // 2
507
+ pad_total = max(0, n_sym * stride - n_samples + num_taps - 1)
508
+ pad_left = min(c_tap, pad_total)
509
+ pad_right = pad_total - pad_left
510
+
511
+ if update_mode == "block":
512
+ samples_np = np.ascontiguousarray(to_device(samples, "cpu"), dtype=np.complex64)
513
+ training_np = (
514
+ to_device(training_symbols, "cpu").astype(np.complex64)
515
+ if training_symbols is not None
516
+ else None
517
+ )
518
+ samples_np, training_np, eq_norm = _normalize_inputs(
519
+ samples_np, training_np, sps, input_norm_factor=input_norm_factor
520
+ )
521
+ samples_padded_np = _build_padded_samples(
522
+ samples_np, pad_left, pad_right, samples_prefix, pad_mode, eq_norm, sps
523
+ )
524
+ constellation_np = _build_slicer_constellation(
525
+ modulation, order, unipolar, training_np, pmf
526
+ )
527
+ train_full, n_train_aligned = _prepare_training_numpy(
528
+ training_np, num_ch, n_sym
529
+ )
530
+ _sq_side, _sq_lev_min, _sq_d_grid = _sq_qam_slicer_params(constellation_np)
531
+ if w_init is not None:
532
+ w_arr = _validate_w_init(
533
+ np.ascontiguousarray(to_device(w_init, "cpu"), dtype=np.complex64),
534
+ num_ch,
535
+ num_taps,
536
+ )
537
+ else:
538
+ w_arr = _init_butterfly_weights_numpy(
539
+ num_ch, num_taps, center_tap=center_tap
540
+ )
541
+ return _run_block_equalizer(
542
+ "lms",
543
+ samples_padded_np=samples_padded_np,
544
+ w_arr=w_arr,
545
+ num_ch=num_ch,
546
+ num_taps=num_taps,
547
+ n_sym=n_sym,
548
+ stride=stride,
549
+ block_len=block_len,
550
+ step_size=step_size,
551
+ backend=backend,
552
+ device=device,
553
+ was_1d=was_1d,
554
+ xp=xp,
555
+ eq_norm=eq_norm,
556
+ name="LMS(block)",
557
+ debug_plot=debug_plot,
558
+ plot_smoothing=plot_smoothing,
559
+ constellation_np=constellation_np,
560
+ train_full=train_full,
561
+ n_train_aligned=n_train_aligned,
562
+ sq_side=_sq_side,
563
+ sq_lev_min=_sq_lev_min,
564
+ sq_d_grid=_sq_d_grid,
565
+ )
566
+
567
+ if backend == "numba":
568
+ # Convert to plain NumPy (no-op for CPU NumPy; downloads for CuPy)
569
+ samples_np = np.ascontiguousarray(to_device(samples, "cpu"), dtype=np.complex64)
570
+ training_np = (
571
+ to_device(training_symbols, "cpu").astype(np.complex64)
572
+ if training_symbols is not None
573
+ else None
574
+ )
575
+ samples_np, training_np, eq_norm = _normalize_inputs(
576
+ samples_np, training_np, sps, input_norm_factor=input_norm_factor
577
+ )
578
+ # Pad (NumPy)
579
+ samples_padded = _build_padded_samples(
580
+ samples_np, pad_left, pad_right, samples_prefix, pad_mode, eq_norm, sps
581
+ )
582
+ # Constellation (NumPy)
583
+ if modulation is not None and order is not None:
584
+ from ..mapping import gray_constellation
585
+
586
+ reference_constellation = gray_constellation(
587
+ modulation, order, unipolar=unipolar
588
+ )
589
+ constellation_np = (
590
+ to_device(reference_constellation, "cpu").flatten().astype(np.complex64)
591
+ )
592
+ elif training_np is not None:
593
+ train_flat = training_np.reshape(-1)
594
+ constellation_np = np.unique(np.round(train_flat, decimals=8))
595
+ else:
596
+ raise ValueError("modulation and order must be provided for DD mode.")
597
+ # PS-QAM: scale slicer constellation to unit-power {s_m/sqrt(E_PS)} so it
598
+ # matches the normalised equaliser input. Training is already at unit power
599
+ # after _normalize_inputs - only the constellation reference needs scaling.
600
+ if pmf is not None and modulation is not None and order is not None:
601
+ _pmf_arr = np.asarray(pmf, dtype=np.float64)
602
+ _e_ps = float(
603
+ np.dot(_pmf_arr, np.abs(constellation_np).astype(np.float64) ** 2)
604
+ )
605
+ if _e_ps < 1.0 - 1e-6:
606
+ _c_ps = np.float32(1.0 / np.sqrt(_e_ps))
607
+ constellation_np = (constellation_np * _c_ps).astype(np.complex64)
608
+ train_full, n_train_aligned = _prepare_training_numpy(
609
+ training_np,
610
+ num_ch,
611
+ n_sym,
612
+ )
613
+ _sq_side, _sq_lev_min, _sq_d_grid = _sq_qam_slicer_params(constellation_np)
614
+ if w_init is not None:
615
+ w_arr = np.ascontiguousarray(to_device(w_init, "cpu"), dtype=np.complex64)
616
+ w_arr = _validate_w_init(w_arr, num_ch, num_taps)
617
+ W = w_arr.copy()
618
+ else:
619
+ W = _init_butterfly_weights_numpy(num_ch, num_taps, center_tap=center_tap)
620
+ y_out = np.empty((n_sym, num_ch), dtype=np.complex64)
621
+ e_out = np.empty((n_sym, num_ch), dtype=np.complex64)
622
+ w_hist_buf = (
623
+ np.empty((n_sym, num_ch, num_ch, num_taps), dtype=np.complex64)
624
+ if store_weights
625
+ else np.empty((1, num_ch, num_ch, num_taps), dtype=np.complex64)
626
+ )
627
+ if cpr_type is None:
628
+ _get_numba_lms()(
629
+ samples_padded,
630
+ train_full,
631
+ constellation_np,
632
+ W,
633
+ np.float32(step_size),
634
+ np.int32(n_train_aligned),
635
+ stride,
636
+ store_weights,
637
+ y_out,
638
+ e_out,
639
+ w_hist_buf,
640
+ _sq_lev_min,
641
+ _sq_d_grid,
642
+ np.int32(_sq_side),
643
+ )
644
+ result = _unpack_result_numpy(
645
+ y_out,
646
+ e_out,
647
+ W,
648
+ w_hist_buf,
649
+ was_1d,
650
+ store_weights,
651
+ n_sym=None,
652
+ xp=xp,
653
+ num_train_symbols=int(n_train_aligned),
654
+ input_norm_factor=eq_norm,
655
+ )
656
+ else:
657
+ pll_mu, pll_beta = resolve_pll_gains(
658
+ cpr_pll_bandwidth, cpr_pll_mu, cpr_pll_beta
659
+ )
660
+ symmetry = _cpr_symmetry(modulation, order)
661
+ B = int(cpr_bps_test_phases)
662
+ bps_angles_np = np.linspace(
663
+ 0.0, np.pi / 2.0, B, endpoint=False, dtype=np.float32
664
+ )
665
+ bps_phases_neg_np = np.exp(-1j * bps_angles_np).astype(np.complex64)
666
+ H = int(cpr_cycle_slip_history)
667
+ _st = cpr_state
668
+ _st_ok = (
669
+ _st is not None
670
+ and _st.cpr_type == cpr_type
671
+ and _st.num_ch == num_ch
672
+ and _st.cs_H == H
673
+ and _st.pll_phi is not None
674
+ )
675
+ if _st_ok:
676
+ assert _st is not None
677
+ assert _st.pll_phi is not None
678
+ assert _st.pll_freq is not None
679
+ assert _st.cs_buf_x is not None
680
+ assert _st.cs_buf_y is not None
681
+ assert _st.cs_buf_ptr is not None
682
+ assert _st.cs_buf_n is not None
683
+ assert _st.cs_stats is not None
684
+ pll_phi = _st.pll_phi.copy()
685
+ pll_freq = _st.pll_freq.copy()
686
+ cs_buf_x = _st.cs_buf_x.copy()
687
+ cs_buf_y = _st.cs_buf_y.copy()
688
+ cs_buf_ptr = _st.cs_buf_ptr.copy()
689
+ cs_buf_n = _st.cs_buf_n.copy()
690
+ cs_stats = _st.cs_stats.copy()
691
+ bps_prev4 = (
692
+ _st.bps_prev4.copy()
693
+ if _st.bps_prev4 is not None
694
+ else np.zeros(num_ch, dtype=np.float64)
695
+ )
696
+ else:
697
+ pll_phi = np.zeros(num_ch, dtype=np.float64)
698
+ pll_freq = np.zeros(num_ch, dtype=np.float64)
699
+ cs_buf_x = np.zeros((num_ch, H), dtype=np.float64)
700
+ cs_buf_y = np.zeros((num_ch, H), dtype=np.float64)
701
+ cs_buf_ptr = np.zeros(num_ch, dtype=np.int64)
702
+ cs_buf_n = np.zeros(num_ch, dtype=np.int64)
703
+ cs_stats = np.zeros((num_ch, 4), dtype=np.float64)
704
+ bps_prev4 = np.zeros(num_ch, dtype=np.float64)
705
+ phase_out = np.empty((n_sym, num_ch), dtype=np.float64)
706
+ cpr_mode_int = np.int32(1 if cpr_type == "pll" else 2)
707
+ _get_numba_lms_cpr()(
708
+ samples_padded,
709
+ train_full,
710
+ constellation_np,
711
+ bps_phases_neg_np,
712
+ bps_angles_np,
713
+ np.int32(cpr_bps_block_size),
714
+ bool(cpr_joint_channels),
715
+ W,
716
+ np.float32(step_size),
717
+ np.int32(n_train_aligned),
718
+ stride,
719
+ store_weights,
720
+ cpr_mode_int,
721
+ pll_mu,
722
+ pll_beta,
723
+ np.int32(symmetry),
724
+ bool(cpr_cycle_slip_correction),
725
+ np.float32(cpr_cycle_slip_threshold),
726
+ pll_phi,
727
+ pll_freq,
728
+ cs_buf_x,
729
+ cs_buf_y,
730
+ cs_buf_ptr,
731
+ cs_buf_n,
732
+ cs_stats,
733
+ bps_prev4,
734
+ y_out,
735
+ e_out,
736
+ phase_out,
737
+ w_hist_buf,
738
+ _sq_lev_min,
739
+ _sq_d_grid,
740
+ np.int32(_sq_side),
741
+ )
742
+ result = _unpack_result_numpy(
743
+ y_out,
744
+ e_out,
745
+ W,
746
+ w_hist_buf,
747
+ was_1d,
748
+ store_weights,
749
+ n_sym=None,
750
+ xp=xp,
751
+ num_train_symbols=int(n_train_aligned),
752
+ input_norm_factor=eq_norm,
753
+ )
754
+ phi_t = xp.asarray(phase_out.T) # (C, N_sym)
755
+ result.phase_trajectory = phi_t[0] if was_1d else phi_t
756
+ result.cpr_state = CPRState(
757
+ pll_phi=pll_phi.copy(),
758
+ pll_freq=pll_freq.copy(),
759
+ bps_prev4=bps_prev4.copy(),
760
+ cs_buf_x=cs_buf_x.copy(),
761
+ cs_buf_y=cs_buf_y.copy(),
762
+ cs_buf_ptr=cs_buf_ptr.copy(),
763
+ cs_buf_n=cs_buf_n.copy(),
764
+ cs_stats=cs_stats.copy(),
765
+ cpr_type=cpr_type,
766
+ num_ch=num_ch,
767
+ symmetry=symmetry,
768
+ bps_P=B,
769
+ bps_K=int(cpr_bps_block_size),
770
+ cs_H=H,
771
+ )
772
+ return _log_equalizer_exit(
773
+ result, name="LMS", debug_plot=debug_plot, plot_smoothing=plot_smoothing
774
+ )
775
+
776
+ # JAX backend
777
+ jax, jnp, _ = _get_jax()
778
+ if jax is None or jnp is None:
779
+ raise ImportError("JAX is required for backend='jax'.")
780
+
781
+ samples, training_symbols, eq_norm = _normalize_inputs(
782
+ samples, training_symbols, sps, input_norm_factor=input_norm_factor
783
+ )
784
+ # Pad - use _build_padded_samples (returns CPU NumPy); convert to xp array after
785
+ _samp_cpu = to_device(samples, "cpu").astype(np.complex64)
786
+ samples_padded_np = _build_padded_samples(
787
+ _samp_cpu, pad_left, pad_right, samples_prefix, pad_mode, eq_norm, sps
788
+ )
789
+ samples_padded = (
790
+ xp.asarray(samples_padded_np)
791
+ if not was_1d
792
+ else xp.asarray(samples_padded_np[0])
793
+ )
794
+ # Constellation
795
+ if modulation is not None and order is not None:
796
+ from ..mapping import gray_constellation
797
+
798
+ reference_constellation = gray_constellation(
799
+ modulation, order, unipolar=unipolar
800
+ )
801
+ elif training_symbols is not None:
802
+ _, _xp, _ = dispatch(training_symbols)
803
+ train_flat = _xp.reshape(training_symbols, (-1,))
804
+ reference_constellation = _xp.unique(_xp.round(train_flat, decimals=8))
805
+ else:
806
+ raise ValueError("modulation and order must be provided for DD mode.")
807
+ constellation_np = (
808
+ to_device(reference_constellation, "cpu").flatten().astype("complex64")
809
+ )
810
+ # PS-QAM: scale slicer constellation to unit-power {s_m/sqrt(E_PS)}.
811
+ if pmf is not None and modulation is not None and order is not None:
812
+ _pmf_arr = np.asarray(pmf, dtype=np.float64)
813
+ _e_ps = float(
814
+ np.dot(_pmf_arr, np.abs(constellation_np).astype(np.float64) ** 2)
815
+ )
816
+ if _e_ps < 1.0 - 1e-6:
817
+ constellation_np = (
818
+ constellation_np * np.float32(1.0 / np.sqrt(_e_ps))
819
+ ).astype(np.complex64)
820
+ train_full, n_train_aligned = _prepare_training_jax(
821
+ training_symbols,
822
+ num_ch,
823
+ n_sym,
824
+ )
825
+ x_jax = to_jax(samples_padded, device=device)
826
+ if was_1d:
827
+ x_jax = x_jax[None, :]
828
+
829
+ try:
830
+ platform = (
831
+ device.lower()
832
+ if device is not None
833
+ else (
834
+ x_jax.device.platform
835
+ if hasattr(x_jax, "device")
836
+ else list(x_jax.devices())[0].platform
837
+ )
838
+ )
839
+ except Exception:
840
+ platform = "cpu"
841
+
842
+ train_jax = to_jax(train_full, device=platform)
843
+ const_jax = to_jax(constellation_np, device=platform)
844
+ if w_init is not None:
845
+ w_arr = np.ascontiguousarray(to_device(w_init, "cpu"), dtype=np.complex64)
846
+ w_arr = _validate_w_init(w_arr, num_ch, num_taps)
847
+ W_jax = to_jax(w_arr, device=platform)
848
+ else:
849
+ W_jax = _init_butterfly_weights_jax(
850
+ num_ch, num_taps, jnp, center_tap=center_tap
851
+ )
852
+ W_jax = to_jax(W_jax, device=platform)
853
+ mu_jax = to_jax(jnp.float32(step_size), device=platform)
854
+ n_train_jax = to_jax(jnp.int32(n_train_aligned), device=platform)
855
+
856
+ if cpr_type is None:
857
+ _sq_side_j, _sq_lev_min_j, _sq_d_grid_j = _sq_qam_slicer_params(
858
+ constellation_np
859
+ )
860
+ scan_fn = _get_jax_lms(
861
+ num_taps,
862
+ stride,
863
+ len(constellation_np),
864
+ num_ch,
865
+ int(_sq_side_j),
866
+ float(_sq_lev_min_j),
867
+ float(_sq_d_grid_j),
868
+ )
869
+ y_jax, e_jax, W_jax, wh_jax = scan_fn(
870
+ x_jax, train_jax, const_jax, W_jax, mu_jax, n_train_jax
871
+ )
872
+ result = _unpack_result_jax(
873
+ y_jax,
874
+ e_jax,
875
+ W_jax,
876
+ wh_jax,
877
+ was_1d,
878
+ store_weights,
879
+ n_sym=None,
880
+ xp=xp,
881
+ num_train_symbols=int(n_train_aligned),
882
+ input_norm_factor=eq_norm,
883
+ )
884
+ else:
885
+ x64_enabled = (
886
+ jax.config.jax_enable_x64
887
+ if hasattr(jax.config, "jax_enable_x64")
888
+ else jax.config.read("jax_enable_x64")
889
+ )
890
+ if not x64_enabled:
891
+ raise RuntimeError(
892
+ "JAX x64 mode must be enabled for CPR phase tracking: "
893
+ "call jax.config.update('jax_enable_x64', True) before using "
894
+ "backend='jax' with cpr_type set."
895
+ )
896
+ pll_mu, pll_beta = resolve_pll_gains(
897
+ cpr_pll_bandwidth, cpr_pll_mu, cpr_pll_beta
898
+ )
899
+ symmetry = _cpr_symmetry(modulation, order)
900
+ B = int(cpr_bps_test_phases)
901
+ H = int(cpr_cycle_slip_history)
902
+ bps_angles_np = np.linspace(
903
+ 0.0, np.pi / 2.0, B, endpoint=False, dtype=np.float32
904
+ )
905
+ bps_phases_neg_np = np.exp(-1j * bps_angles_np).astype(np.complex64)
906
+ bps_pn_jax = to_jax(bps_phases_neg_np, device=platform)
907
+ bps_ang_jax = to_jax(bps_angles_np, device=platform)
908
+ _sq_side_j, _sq_lev_min_j, _sq_d_grid_j = _sq_qam_slicer_params(
909
+ constellation_np
910
+ )
911
+ scan_fn = _get_jax_lms_cpr(
912
+ num_taps,
913
+ stride,
914
+ len(constellation_np),
915
+ num_ch,
916
+ cpr_type,
917
+ B,
918
+ int(cpr_bps_block_size),
919
+ bool(cpr_joint_channels),
920
+ H,
921
+ int(symmetry),
922
+ int(_sq_side_j),
923
+ float(_sq_lev_min_j),
924
+ float(_sq_d_grid_j),
925
+ )
926
+ KB = int(cpr_bps_block_size)
927
+ if cpr_state is not None:
928
+ _pi, _pf, _bb, _bbp, _bp4, _cx, _cy, _cp = _cpr_state_to_jax_inits(
929
+ cpr_state, num_ch, KB, H
930
+ )
931
+ else:
932
+ _pi = np.zeros(num_ch, dtype=np.float64)
933
+ _pf = np.zeros(num_ch, dtype=np.float64)
934
+ _bb = np.zeros((KB, num_ch), dtype=np.complex64)
935
+ _bbp = np.int32(0)
936
+ _bp4 = np.zeros(num_ch, dtype=np.float64)
937
+ _cx = np.zeros((num_ch, H), dtype=np.float64)
938
+ _cy = np.zeros((num_ch, H), dtype=np.float64)
939
+ _cp = np.zeros(num_ch, dtype=np.int32)
940
+ (
941
+ y_jax,
942
+ e_jax,
943
+ W_jax,
944
+ wh_jax,
945
+ phi_jax,
946
+ pll_phi_f,
947
+ pll_freq_f,
948
+ bps_buf_f,
949
+ bps_buf_ptr_f,
950
+ bps_prev4_f,
951
+ cs_buf_x_f,
952
+ cs_buf_y_f,
953
+ cs_buf_ptr_f,
954
+ ) = scan_fn(
955
+ x_jax,
956
+ train_jax,
957
+ const_jax,
958
+ bps_pn_jax,
959
+ bps_ang_jax,
960
+ W_jax,
961
+ mu_jax,
962
+ n_train_jax,
963
+ to_jax(jnp.float64(pll_mu), device=platform),
964
+ to_jax(jnp.float64(pll_beta), device=platform),
965
+ to_jax(jnp.float64(cpr_cycle_slip_threshold), device=platform),
966
+ to_jax(jnp.bool_(cpr_cycle_slip_correction), device=platform),
967
+ to_jax(_pi, device=platform),
968
+ to_jax(_pf, device=platform),
969
+ to_jax(_bb, device=platform),
970
+ to_jax(_bbp, device=platform),
971
+ to_jax(_bp4, device=platform),
972
+ to_jax(_cx, device=platform),
973
+ to_jax(_cy, device=platform),
974
+ to_jax(_cp, device=platform),
975
+ )
976
+ result = _unpack_result_jax(
977
+ y_jax,
978
+ e_jax,
979
+ W_jax,
980
+ wh_jax,
981
+ was_1d,
982
+ store_weights,
983
+ n_sym=None,
984
+ xp=xp,
985
+ num_train_symbols=int(n_train_aligned),
986
+ input_norm_factor=eq_norm,
987
+ )
988
+ # from_jax returns CuPy for a GPU-resident JAX array; coerce to host
989
+ # NumPy via to_device (CuPy.get / NumPy passthrough) so CPRState stays
990
+ # host-side and np.asarray never sees a CuPy array.
991
+ phi_np = to_device(from_jax(phi_jax), "cpu") # (N_sym, C)
992
+ phi_t = xp.asarray(phi_np.T) # (C, N_sym)
993
+ result.phase_trajectory = phi_t[0] if was_1d else phi_t
994
+ result.cpr_state = CPRState(
995
+ pll_phi=to_device(from_jax(pll_phi_f), "cpu"),
996
+ pll_freq=to_device(from_jax(pll_freq_f), "cpu"),
997
+ bps_prev4=to_device(from_jax(bps_prev4_f), "cpu"),
998
+ jax_bps_buf=to_device(from_jax(bps_buf_f), "cpu"),
999
+ jax_bps_buf_ptr=int(to_device(from_jax(bps_buf_ptr_f), "cpu")),
1000
+ cs_buf_x=to_device(from_jax(cs_buf_x_f), "cpu"),
1001
+ cs_buf_y=to_device(from_jax(cs_buf_y_f), "cpu"),
1002
+ cs_buf_ptr=to_device(from_jax(cs_buf_ptr_f), "cpu"),
1003
+ cpr_type=cpr_type,
1004
+ num_ch=num_ch,
1005
+ symmetry=symmetry,
1006
+ bps_P=B,
1007
+ bps_K=KB,
1008
+ cs_H=H,
1009
+ )
1010
+ return _log_equalizer_exit(result, name="LMS", debug_plot=debug_plot)
1011
+
1012
+
1013
+ def _check_rls_divergence(weights, xp, forgetting_factor, delta):
1014
+ """Raise if RLS produced non-finite weights (silent divergence guard).
1015
+
1016
+ Mirrors the ``_div_flag`` check in ``block_lms``: a single device->host sync on
1017
+ the assembled weights catches loss of positive-definiteness in the inverse
1018
+ correlation matrix P (which surfaces as NaN/Inf taps) and converts it into an
1019
+ actionable error instead of returning garbage weights.
1020
+ """
1021
+ if not bool(xp.isfinite(weights).all()):
1022
+ raise RuntimeError(
1023
+ f"RLS equalizer diverged (forgetting_factor={forgetting_factor}, "
1024
+ f"delta={delta}). RLS requires a positive-definite correlation matrix. "
1025
+ "Try increasing regularization 'delta', reducing 'forgetting_factor', "
1026
+ "or adding 'leakage' (e.g. 1e-4) to stabilize fractionally-spaced inputs."
1027
+ )
1028
+
1029
+
1030
+ def rls(
1031
+ samples: ArrayType,
1032
+ training_symbols: ArrayType | None = None,
1033
+ num_taps: int = 21,
1034
+ sps: int = 1,
1035
+ forgetting_factor: float = 0.99,
1036
+ delta: float = 0.01,
1037
+ leakage: float = 0.0,
1038
+ modulation: str | None = None,
1039
+ order: int | None = None,
1040
+ unipolar: bool = False,
1041
+ store_weights: bool = False,
1042
+ device: str | None = "cpu",
1043
+ center_tap: int | None = None,
1044
+ backend: str = "numba",
1045
+ w_init: ArrayType | None = None,
1046
+ pmf: Any | None = None,
1047
+ cpr_type: str | None = None,
1048
+ cpr_pll_bandwidth: float = 1e-3,
1049
+ cpr_pll_mu: float | None = None,
1050
+ cpr_pll_beta: float | None = None,
1051
+ cpr_bps_test_phases: int = 64,
1052
+ cpr_bps_block_size: int = 32,
1053
+ cpr_joint_channels: bool = False,
1054
+ cpr_cycle_slip_correction: bool = False,
1055
+ cpr_cycle_slip_history: int = 100,
1056
+ cpr_cycle_slip_threshold: float = np.pi / 4,
1057
+ debug_plot: bool = False,
1058
+ plot_smoothing: int = 50,
1059
+ cpr_state: CPRState | None = None,
1060
+ input_norm_factor: float | np.ndarray | None = None,
1061
+ samples_prefix: ArrayType | None = None,
1062
+ pad_mode: str = "zeros",
1063
+ ) -> EqualizerResult:
1064
+ """
1065
+ Recursive Least Squares adaptive equalizer with butterfly MIMO support.
1066
+
1067
+ RLS converges faster than LMS at the cost of higher per-symbol
1068
+ complexity (O(num_taps²) for the rank-1 Riccati update vs O(num_taps)
1069
+ for LMS). It maintains an inverse correlation matrix P per output stream.
1070
+
1071
+ Algorithm (per symbol n)
1072
+ ------------------------
1073
+ Steps 1-5 and 7 are identical to ``lms`` (input windowing, butterfly
1074
+ filter output, carrier phase recovery, decision, error + tap-plane
1075
+ back-rotation, and cycle-slip correction). Step 6 replaces the plain LMS
1076
+ gradient with a rank-1 Riccati update:
1077
+
1078
+ 6. **RLS weight update** - for each output channel c, maintaining the
1079
+ inverse input auto-correlation matrix ``P_c`` of shape ``(T, T)``::
1080
+
1081
+ k_c = (P_c @ x_{c,n}) / (lambda + x_{c,n}^H @ P_c @ x_{c,n})
1082
+ P_c = (P_c - k_c @ x_{c,n}^H @ P_c) / lambda
1083
+ w_{c,c'} += k_c * conj(e_taps_c[n])
1084
+
1085
+ where ``lambda`` = ``forgetting_factor``. ``P_c`` is initialised to
1086
+ ``(1/delta) * I`` (``delta`` parameter). With ``leakage`` ``gamma > 0``
1087
+ the weight update becomes
1088
+ ``w = (1 - gamma) * w + k * conj(e_taps)``, which exponentially
1089
+ suppresses tap energy in frequency-null subspaces and prevents the
1090
+ eigenvalue blow-up that afflicts ``P`` for fractionally-spaced
1091
+ (sps > 1) inputs.
1092
+
1093
+ Parameters
1094
+ ----------
1095
+ samples : array_like
1096
+ Input signal samples. Shape: ``(N_samples,)`` or ``(C, N_samples)``.
1097
+ training_symbols : array_like, optional
1098
+ Known symbols for data-aided adaptation (at symbol rate, 1 SPS).
1099
+ num_taps : int, default 21
1100
+ Number of equalizer taps per FIR filter.
1101
+ sps : int, default 1
1102
+ Samples per symbol at the input.
1103
+ forgetting_factor : float, default 0.99
1104
+ RLS forgetting factor (lambda). Range: (0, 1].
1105
+ Values close to 1 give longer memory.
1106
+ delta : float, default 0.01
1107
+ Tikhonov regularisation coefficient that seeds the inverse correlation
1108
+ matrix as ``P₀ = (1/delta) · I``.
1109
+
1110
+ **Physical interpretation.** RLS recursively refines a running estimate
1111
+ of ``Rxx⁻¹``, where ``Rxx = E[x xᴴ]`` is the input auto-correlation
1112
+ matrix. Before any data have been observed, ``P`` must be initialised
1113
+ to some positive-definite matrix. Choosing ``P₀ = (1/delta) · I``
1114
+ is equivalent to assuming a fictitious prior with `delta` units of
1115
+ regularisation energy per tap - a textbook Tikhonov (ridge) prior on
1116
+ the tap vector with regularisation parameter ``delta``.
1117
+
1118
+ **Effect on convergence.**
1119
+
1120
+ * **Large delta** (e.g. 1.0): ``P₀`` is small -> the first Kalman gain
1121
+ vectors ``k = P x / (λ + xᴴ P x)`` are small -> the equalizer adapts
1122
+ sluggishly over the first tens of symbols. Once enough data are seen
1123
+ the bias disappears, so this is safe when a long training sequence is
1124
+ available and numerical robustness is the priority.
1125
+ * **Small delta** (e.g. 1e-4): ``P₀ = (1/delta) · I`` is a large matrix
1126
+ -> ``k`` is large for the first symbols -> the equalizer converges in
1127
+ very few symbols but the weight update is dominated by noise on those
1128
+ first few observations, potentially requiring more symbols to settle.
1129
+ Extremely small values (< 1e-5) risk numerical overflow of ``P``
1130
+ before the Riccati update can contract it.
1131
+
1132
+ **Sensitivity to signal power.** The code normalises input samples to
1133
+ unit symbol-rate power before running the Riccati recursion, so
1134
+ ``delta`` is expressed in normalised units (≈ noise variance scale) and
1135
+ is not sensitive to the raw signal amplitude.
1136
+
1137
+ **Practical guidelines.**
1138
+
1139
+ * **Training-aided mode** (``training_symbols`` provided): the default
1140
+ ``delta=0.01`` works well for most symbol rates and SNR regimes.
1141
+ Increase toward 1.0 if tap weights oscillate wildly during the first
1142
+ training symbols; decrease toward 1e-3 if convergence is slow and
1143
+ your training block is short.
1144
+ * **Decision-directed (DD) only**: prefer ``delta=1.0`` and rely on the
1145
+ forgetting factor to drive convergence, keeping ``P`` bounded.
1146
+ * **Fractionally-spaced signals** (``sps=2``, with ``leakage > 0``):
1147
+ larger ``delta`` (0.1-1.0) helps counteract the positive-feedback
1148
+ tendency of the unbounded ``P`` eigenvalues in the null sub-space.
1149
+ Pair with ``leakage=1e-4`` for structural stability.
1150
+ leakage : float, default 0.0
1151
+ Weight-decay coefficient (γ) applied to the tap vector at every step::
1152
+
1153
+ W <- (1 - γ)·W + k·ē # leaky weight update
1154
+ P <- (P - k·x̄ᴴP) / λ # standard Riccati (unchanged)
1155
+
1156
+ Weight decay exponentially suppresses tap weights in the null subspace -
1157
+ noise-only frequency bands that arise in T/2-spaced (sps=2) signals -
1158
+ without inflating ``P``'s eigenvalues.
1159
+ A value of ``0.0`` (default) gives standard RLS.
1160
+ For fractionally-spaced equalization start with ``leakage=1e-4`` and
1161
+ increase if steady-state EVM remains high.
1162
+ modulation : str, optional
1163
+ Modulation scheme (e.g., 'psk', 'qam', 'pam') for DD slicing.
1164
+ Required if ``training_symbols`` is None.
1165
+ order : int, optional
1166
+ Modulation order (e.g., 4, 16).
1167
+ unipolar : bool, default False
1168
+ If True, indicates the modulation is unipolar (e.g., unipolar PAM).
1169
+ store_weights : bool, default False
1170
+ If True, stores weight trajectory.
1171
+ device : str, optional
1172
+ Target device for JAX computations (e.g., 'cpu', 'gpu', 'tpu').
1173
+ Default is 'cpu'. Ignored when ``backend='numba'``.
1174
+ center_tap : int, optional
1175
+ Index of the center tap. If None, defaults to ``num_taps // 2``.
1176
+ backend : str, default 'numba'
1177
+ Execution backend. ``'numba'`` uses Numba ``@njit``; LLVM-compiled,
1178
+ typically fastest on CPU, particularly for the O(num_taps²) Riccati
1179
+ update. ``'jax'`` uses ``jax.lax.scan`` (XLA-compiled, GPU-capable).
1180
+ pmf : array_like of float, optional
1181
+ Probability mass function for PS-QAM. Scales the DD slicer
1182
+ constellation by ``1/sqrt(E_PS)`` to match the unit-power normalised
1183
+ equaliser input. Requires ``modulation`` and ``order``.
1184
+ cpr_type : {'pll', 'bps', None}, default None
1185
+ Inline carrier phase recovery applied jointly with weight updates at
1186
+ every symbol. ``None`` disables CPR (default).
1187
+
1188
+ * ``'pll'`` - 2nd-order decision-directed PLL. The cross-product
1189
+ detector ``Im(y · conj(d))`` drives a PI loop with gains from
1190
+ ``cpr_pll_bandwidth``. Low noise floor; suited to QPSK-64-QAM.
1191
+ * ``'bps'`` - Blind Phase Search over ``cpr_bps_test_phases``
1192
+ candidate angles in ``[0, π/2)``, averaged over a causal window
1193
+ of ``cpr_bps_block_size`` samples. Uses a **dual-path** design:
1194
+ the wrapped float32 estimate rotates ``y_raw`` for the Riccati
1195
+ update; the unwrapped float64 accumulator populates
1196
+ ``phase_trajectory``. A **causal 4-fold unwrap** converts the
1197
+ ``[0, π/2)`` argmin to full-range radians symbol by symbol.
1198
+ cpr_pll_bandwidth : float, default 1e-3
1199
+ Normalised loop bandwidth ``B_L · T_s`` for the PLL. Gains are
1200
+ ``K_p = 4 B_L``, ``K_i = 4 B_L²`` (critically-damped, ζ=1). Typical
1201
+ range: ``5e-4`` (low phase noise) to ``5e-3`` (high drift).
1202
+ Used only when ``cpr_pll_mu is None`` and ``cpr_type == 'pll'``.
1203
+ cpr_pll_mu : float, optional
1204
+ Raw proportional PLL gain ``μ``. If given, overrides
1205
+ ``cpr_pll_bandwidth``; ``cpr_pll_beta`` then defaults to ``0.0``
1206
+ (1st-order). Leave ``None`` for critically-damped gains from
1207
+ ``cpr_pll_bandwidth``. Interchangeable with the ``mu`` of
1208
+ ``recover_carrier_phase_pll``.
1209
+ cpr_pll_beta : float, optional
1210
+ Raw integral PLL gain ``β``. ``β=0`` => 1st-order, ``β>0`` => 2nd-order.
1211
+ Requires ``cpr_pll_mu`` to be set. Mapping: ``ωₙT = √β``,
1212
+ ``ζ = μ/(2√β)``.
1213
+ cpr_bps_test_phases : int, default 64
1214
+ Number of BPS candidate phase angles in ``[0, π/2)``. 32-64 is
1215
+ sufficient for ≤ 16-QAM; use 64-128 for 64-QAM. Ignored when
1216
+ ``cpr_type != 'bps'``.
1217
+ cpr_bps_block_size : int, default 32
1218
+ Trailing-window length (symbols) summed before the BPS ``argmin``.
1219
+ Larger values reduce phase-noise variance at the cost of latency.
1220
+ ``cpr_bps_block_size=1`` gives single-symbol BPS. Ignored when
1221
+ ``cpr_type != 'bps'``.
1222
+ cpr_joint_channels : bool, default False
1223
+ For MIMO inputs (C > 1): share the phase estimate across channels.
1224
+
1225
+ * **BPS** - distance metrics are summed across channels before
1226
+ ``argmin``. Reduces estimation variance by ~√C for shared-LO
1227
+ systems.
1228
+ * **PLL** - the phase-error signal is averaged across channels
1229
+ before the PI integrator.
1230
+
1231
+ When ``False``, each channel has an independent estimator.
1232
+ Ignored for SISO inputs.
1233
+ cpr_cycle_slip_correction : bool, default False
1234
+ Enable causal cycle-slip detection and correction. A circular
1235
+ buffer of ``cpr_cycle_slip_history`` past phase estimates is kept
1236
+ per channel. An online linear regression predicts the next phase;
1237
+ if the new estimate deviates by more than
1238
+ ``cpr_cycle_slip_threshold``, it is snapped to the nearest
1239
+ ``2π/symmetry`` quantum and the buffer is updated. Disable for
1240
+ parity checks or slip-free channels.
1241
+ cpr_cycle_slip_history : int, default 100
1242
+ Phase-history buffer length for slip prediction. Longer buffers
1243
+ give a more stable trend estimate but adapt more slowly to genuine
1244
+ frequency steps. Ignored when ``cpr_cycle_slip_correction=False``.
1245
+ cpr_cycle_slip_threshold : float, default π/4
1246
+ Maximum tolerated deviation (radians) before a slip is declared.
1247
+ Set to half the constellation's angular quantum (``π/4`` for
1248
+ QPSK/QAM). Ignored when ``cpr_cycle_slip_correction=False``.
1249
+ debug_plot : bool, default False
1250
+ Display convergence + tap-weight diagnostic plot on exit.
1251
+ plot_smoothing : int, default 50
1252
+ MSE moving-average window for the debug plot.
1253
+ cpr_state : CPRState, optional
1254
+ Warm-start CPR state from a previous ``rls()`` call. See
1255
+ ``lms()`` for the full description; behaviour is identical.
1256
+ input_norm_factor : float or ndarray, optional
1257
+ Pre-computed RMS normalization factor from a previous call. See
1258
+ ``lms()`` for the full description; behaviour is identical.
1259
+ samples_prefix : array_like, optional
1260
+ Signal history from the end of the previous block. See ``lms()``
1261
+ for the full description; behaviour is identical.
1262
+ pad_mode : {'zeros', 'edge'}, default 'zeros'
1263
+ Padding strategy when ``samples_prefix`` is ``None``. See
1264
+ ``lms()`` for the full description; behaviour is identical.
1265
+
1266
+ Returns
1267
+ -------
1268
+ EqualizerResult
1269
+ Result container with the following fields:
1270
+
1271
+ * ``y_hat`` - equalized symbol estimates, shape ``(N_sym,)`` SISO
1272
+ or ``(C, N_sym)`` MIMO, at 1 SPS.
1273
+ * ``weights`` - final tap-weight tensor, shape ``(num_taps,)`` SISO
1274
+ or ``(C, C, num_taps)`` MIMO.
1275
+ * ``error`` - complex error signal ``e[n] = d[n] - y[n]``, same
1276
+ shape as ``y_hat``.
1277
+ * ``weights_history`` - tap weights at each symbol when
1278
+ ``store_weights=True``; ``None`` otherwise.
1279
+ * ``phase_trajectory`` - per-symbol phase estimates, shape
1280
+ ``(N_sym,)`` SISO or ``(C, N_sym)`` MIMO. BPS: causal
1281
+ 4-fold-unwrapped float64. PLL: PI integrator state. ``None``
1282
+ when ``cpr_type=None``.
1283
+ * ``num_train_symbols`` - number of data-aided training symbols.
1284
+ * ``input_norm_factor`` - RMS factor used to normalize inputs.
1285
+ * ``cpr_state`` - CPRState snapshot after the last symbol; ``None``
1286
+ when ``cpr_type=None``.
1287
+
1288
+ Warnings
1289
+ --------
1290
+ **Fractional Spacing Singularity:**
1291
+ Applying RLS to fractionally-spaced signals (sps > 1) is not recommended.
1292
+ Fractional spacing bounds the signal energy within a subset of the Nyquist
1293
+ bandwidth. The unexcited frequency bands contain strictly thermal noise,
1294
+ rendering the input correlation matrix mathematically singular. RLS
1295
+ attempts to invert these near-zero eigenvalues, exponentially amplifying
1296
+ high-frequency noise and causing severe tap weight bloat. Normalized LMS
1297
+ is the structurally stable alternative.
1298
+
1299
+ ``w_init`` warms-start the tap weights; the inverse correlation matrix ``P``
1300
+ always begins at ``(1/delta) · I`` regardless of ``w_init``.
1301
+ """
1302
+ if sps > 1:
1303
+ logger.warning(
1304
+ "RLS is mathematically ill-conditioned for fractionally-spaced signals (sps=%s). The noise-only null-subspace creates a singular correlation matrix, causing tap bloat. Use LMS for fractionally-spaced equalization unless heavy Tikhonov regularization is applied.",
1305
+ sps,
1306
+ )
1307
+
1308
+ if cpr_type is not None and cpr_type not in ("pll", "bps"):
1309
+ raise ValueError(f"cpr_type must be 'pll', 'bps', or None. Got {cpr_type!r}.")
1310
+
1311
+ n_train_log = training_symbols.shape[-1] if training_symbols is not None else 0
1312
+ logger.info(
1313
+ "RLS equalizer: num_taps=%s, forgetting_factor=%s, delta=%.2e, leakage=%.2e, sps=%s, backend=%s, n_train=%s%s",
1314
+ num_taps,
1315
+ forgetting_factor,
1316
+ delta,
1317
+ leakage,
1318
+ sps,
1319
+ backend,
1320
+ n_train_log,
1321
+ f", cpr={cpr_type}" if cpr_type else "",
1322
+ )
1323
+ if sps > 1:
1324
+ logger.warning(
1325
+ "RLS output y_hat is at 1 SPS (symbol rate). "
1326
+ "Update sampling_rate = symbol_rate after applying this equalizer."
1327
+ )
1328
+
1329
+ samples, xp, _ = dispatch(samples)
1330
+ stride = int(sps)
1331
+
1332
+ if training_symbols is not None:
1333
+ training_symbols, _, _ = dispatch(training_symbols)
1334
+
1335
+ was_1d = samples.ndim == 1
1336
+ if was_1d:
1337
+ num_ch = 1
1338
+ n_samples = samples.shape[0]
1339
+ else:
1340
+ num_ch, n_samples = samples.shape
1341
+
1342
+ n_sym = n_samples // stride
1343
+
1344
+ if training_symbols is not None and training_symbols.shape[-1] > n_sym:
1345
+ logger.warning(
1346
+ "training_symbols length (%s) exceeds available symbol count (%s); excess training symbols will be ignored.",
1347
+ training_symbols.shape[-1],
1348
+ n_sym,
1349
+ )
1350
+
1351
+ # Early-halt boundary: freeze W and P once the sliding window reaches the
1352
+ # right zero-padding (last num_taps//2 symbols have contaminated windows).
1353
+ n_update_halt = max(0, n_sym - num_taps // 2)
1354
+ tail_trim = num_taps // 2
1355
+ if tail_trim > 0:
1356
+ logger.warning(
1357
+ "RLS tail trim: last %s symbols removed from y_hat (zero-padding contamination zone). Trim reference arrays to match: source_symbols = source_symbols[..., :-result.tail_trim], source_bits = source_bits[..., :-result.tail_trim * bits_per_symbol].",
1358
+ tail_trim,
1359
+ )
1360
+
1361
+ c_tap = center_tap if center_tap is not None else num_taps // 2
1362
+ pad_total = max(0, n_sym * stride - n_samples + num_taps - 1)
1363
+ pad_left = min(c_tap, pad_total)
1364
+ pad_right = pad_total - pad_left
1365
+
1366
+ if backend == "numba":
1367
+ numba = _get_numba()
1368
+ if numba is None:
1369
+ raise ImportError("Numba is required for backend='numba'.")
1370
+
1371
+ samples_np = np.ascontiguousarray(to_device(samples, "cpu"), dtype=np.complex64)
1372
+ training_np = (
1373
+ to_device(training_symbols, "cpu").astype(np.complex64)
1374
+ if training_symbols is not None
1375
+ else None
1376
+ )
1377
+ samples_np, training_np, eq_norm = _normalize_inputs(
1378
+ samples_np, training_np, sps, input_norm_factor=input_norm_factor
1379
+ )
1380
+
1381
+ x_np = _build_padded_samples(
1382
+ samples_np, pad_left, pad_right, samples_prefix, pad_mode, eq_norm, sps
1383
+ )
1384
+ x_np = np.ascontiguousarray(x_np)
1385
+
1386
+ if modulation is not None and order is not None:
1387
+ from ..mapping import gray_constellation
1388
+
1389
+ reference_constellation = gray_constellation(
1390
+ modulation, order, unipolar=unipolar
1391
+ )
1392
+ constellation_np = (
1393
+ to_device(reference_constellation, "cpu").flatten().astype(np.complex64)
1394
+ )
1395
+ elif training_np is not None:
1396
+ train_flat = training_np.reshape(-1)
1397
+ constellation_np = np.unique(np.round(train_flat, decimals=8)).astype(
1398
+ "complex64"
1399
+ )
1400
+ else:
1401
+ raise ValueError("modulation and order must be provided for DD mode.")
1402
+ # PS-QAM: scale slicer constellation to unit-power {s_m/sqrt(E_PS)}.
1403
+ if pmf is not None and modulation is not None and order is not None:
1404
+ _pmf_arr = np.asarray(pmf, dtype=np.float64)
1405
+ _e_ps = float(
1406
+ np.dot(_pmf_arr, np.abs(constellation_np).astype(np.float64) ** 2)
1407
+ )
1408
+ if _e_ps < 1.0 - 1e-6:
1409
+ _c_ps = np.float32(1.0 / np.sqrt(_e_ps))
1410
+ constellation_np = (constellation_np * _c_ps).astype(np.complex64)
1411
+
1412
+ train_full, n_train_aligned = _prepare_training_numpy(
1413
+ training_np,
1414
+ num_ch,
1415
+ n_sym,
1416
+ )
1417
+ _sq_side, _sq_lev_min, _sq_d_grid = _sq_qam_slicer_params(constellation_np)
1418
+ if w_init is not None:
1419
+ w_arr = np.ascontiguousarray(to_device(w_init, "cpu"), dtype=np.complex64)
1420
+ w_arr = _validate_w_init(w_arr, num_ch, num_taps)
1421
+ W = w_arr.copy()
1422
+ else:
1423
+ W = _init_butterfly_weights_numpy(num_ch, num_taps, center_tap=center_tap)
1424
+ regressor_dim = num_ch * num_taps
1425
+ P = np.eye(regressor_dim, dtype=np.complex128) / np.float64(delta)
1426
+ y_out = np.empty((n_sym, num_ch), dtype=np.complex64)
1427
+ e_out = np.empty((n_sym, num_ch), dtype=np.complex64)
1428
+ w_hist_buf = (
1429
+ np.empty((n_sym, num_ch, num_ch, num_taps), dtype=np.complex64)
1430
+ if store_weights
1431
+ else np.empty((1, num_ch, num_ch, num_taps), dtype=np.complex64)
1432
+ )
1433
+ if cpr_type is None:
1434
+ _get_numba_rls()(
1435
+ x_np,
1436
+ train_full,
1437
+ constellation_np,
1438
+ W,
1439
+ P,
1440
+ np.float32(forgetting_factor),
1441
+ np.float32(leakage),
1442
+ np.int32(n_train_aligned),
1443
+ np.int32(n_update_halt),
1444
+ stride,
1445
+ store_weights,
1446
+ y_out,
1447
+ e_out,
1448
+ w_hist_buf,
1449
+ _sq_lev_min,
1450
+ _sq_d_grid,
1451
+ np.int32(_sq_side),
1452
+ )
1453
+ result = _unpack_result_numpy(
1454
+ y_out,
1455
+ e_out,
1456
+ W,
1457
+ w_hist_buf,
1458
+ was_1d,
1459
+ store_weights,
1460
+ n_sym=n_update_halt,
1461
+ xp=xp,
1462
+ num_train_symbols=int(n_train_aligned),
1463
+ input_norm_factor=eq_norm,
1464
+ )
1465
+ else:
1466
+ pll_mu, pll_beta = resolve_pll_gains(
1467
+ cpr_pll_bandwidth, cpr_pll_mu, cpr_pll_beta
1468
+ )
1469
+ symmetry = _cpr_symmetry(modulation, order)
1470
+ B = int(cpr_bps_test_phases)
1471
+ bps_angles_np = np.linspace(
1472
+ 0.0, np.pi / 2.0, B, endpoint=False, dtype=np.float32
1473
+ )
1474
+ bps_phases_neg_np = np.exp(-1j * bps_angles_np).astype(np.complex64)
1475
+ H = int(cpr_cycle_slip_history)
1476
+ _st = cpr_state
1477
+ _st_ok = (
1478
+ _st is not None
1479
+ and _st.cpr_type == cpr_type
1480
+ and _st.num_ch == num_ch
1481
+ and _st.cs_H == H
1482
+ and _st.pll_phi is not None
1483
+ )
1484
+ if _st_ok:
1485
+ assert _st is not None
1486
+ assert _st.pll_phi is not None
1487
+ assert _st.pll_freq is not None
1488
+ assert _st.cs_buf_x is not None
1489
+ assert _st.cs_buf_y is not None
1490
+ assert _st.cs_buf_ptr is not None
1491
+ assert _st.cs_buf_n is not None
1492
+ assert _st.cs_stats is not None
1493
+ pll_phi = _st.pll_phi.copy()
1494
+ pll_freq = _st.pll_freq.copy()
1495
+ cs_buf_x = _st.cs_buf_x.copy()
1496
+ cs_buf_y = _st.cs_buf_y.copy()
1497
+ cs_buf_ptr = _st.cs_buf_ptr.copy()
1498
+ cs_buf_n = _st.cs_buf_n.copy()
1499
+ cs_stats = _st.cs_stats.copy()
1500
+ bps_prev4 = (
1501
+ _st.bps_prev4.copy()
1502
+ if _st.bps_prev4 is not None
1503
+ else np.zeros(num_ch, dtype=np.float64)
1504
+ )
1505
+ else:
1506
+ pll_phi = np.zeros(num_ch, dtype=np.float64)
1507
+ pll_freq = np.zeros(num_ch, dtype=np.float64)
1508
+ cs_buf_x = np.zeros((num_ch, H), dtype=np.float64)
1509
+ cs_buf_y = np.zeros((num_ch, H), dtype=np.float64)
1510
+ cs_buf_ptr = np.zeros(num_ch, dtype=np.int64)
1511
+ cs_buf_n = np.zeros(num_ch, dtype=np.int64)
1512
+ cs_stats = np.zeros((num_ch, 4), dtype=np.float64)
1513
+ bps_prev4 = np.zeros(num_ch, dtype=np.float64)
1514
+ phase_out = np.empty((n_sym, num_ch), dtype=np.float64)
1515
+ cpr_mode_int = np.int32(1 if cpr_type == "pll" else 2)
1516
+ _get_numba_rls_cpr()(
1517
+ x_np,
1518
+ train_full,
1519
+ constellation_np,
1520
+ bps_phases_neg_np,
1521
+ bps_angles_np,
1522
+ np.int32(cpr_bps_block_size),
1523
+ bool(cpr_joint_channels),
1524
+ W,
1525
+ P,
1526
+ np.float32(forgetting_factor),
1527
+ np.float32(leakage),
1528
+ np.int32(n_train_aligned),
1529
+ np.int32(n_update_halt),
1530
+ stride,
1531
+ store_weights,
1532
+ cpr_mode_int,
1533
+ pll_mu,
1534
+ pll_beta,
1535
+ np.int32(symmetry),
1536
+ bool(cpr_cycle_slip_correction),
1537
+ np.float32(cpr_cycle_slip_threshold),
1538
+ pll_phi,
1539
+ pll_freq,
1540
+ cs_buf_x,
1541
+ cs_buf_y,
1542
+ cs_buf_ptr,
1543
+ cs_buf_n,
1544
+ cs_stats,
1545
+ bps_prev4,
1546
+ y_out,
1547
+ e_out,
1548
+ phase_out,
1549
+ w_hist_buf,
1550
+ _sq_lev_min,
1551
+ _sq_d_grid,
1552
+ np.int32(_sq_side),
1553
+ )
1554
+ result = _unpack_result_numpy(
1555
+ y_out,
1556
+ e_out,
1557
+ W,
1558
+ w_hist_buf,
1559
+ was_1d,
1560
+ store_weights,
1561
+ n_sym=n_update_halt,
1562
+ xp=xp,
1563
+ num_train_symbols=int(n_train_aligned),
1564
+ input_norm_factor=eq_norm,
1565
+ )
1566
+ phi_t = xp.asarray(phase_out[:n_update_halt].T) # (C, n_update_halt)
1567
+ result.phase_trajectory = phi_t[0] if was_1d else phi_t
1568
+ result.cpr_state = CPRState(
1569
+ pll_phi=pll_phi.copy(),
1570
+ pll_freq=pll_freq.copy(),
1571
+ bps_prev4=bps_prev4.copy(),
1572
+ cs_buf_x=cs_buf_x.copy(),
1573
+ cs_buf_y=cs_buf_y.copy(),
1574
+ cs_buf_ptr=cs_buf_ptr.copy(),
1575
+ cs_buf_n=cs_buf_n.copy(),
1576
+ cs_stats=cs_stats.copy(),
1577
+ cpr_type=cpr_type,
1578
+ num_ch=num_ch,
1579
+ symmetry=symmetry,
1580
+ bps_P=B,
1581
+ bps_K=int(cpr_bps_block_size),
1582
+ cs_H=H,
1583
+ )
1584
+ # Truncate last num_taps//2 symbols (zero-padding contamination).
1585
+ result = _log_equalizer_exit(
1586
+ result, name="RLS", debug_plot=debug_plot, plot_smoothing=plot_smoothing
1587
+ )
1588
+ result.tail_trim = tail_trim
1589
+ _check_rls_divergence(result.weights, xp, forgetting_factor, delta)
1590
+ return result
1591
+
1592
+ # JAX backend
1593
+ jax, jnp, _ = _get_jax()
1594
+ if jax is None or jnp is None:
1595
+ raise ImportError("JAX is required for backend='jax'.")
1596
+ x64_enabled = (
1597
+ jax.config.jax_enable_x64
1598
+ if hasattr(jax.config, "jax_enable_x64")
1599
+ else jax.config.read("jax_enable_x64")
1600
+ )
1601
+ if not x64_enabled:
1602
+ raise RuntimeError(
1603
+ "JAX x64 mode must be enabled for RLS: the P (Riccati) matrix requires "
1604
+ "complex128 precision to remain positive-definite. "
1605
+ "Call jax.config.update('jax_enable_x64', True) before using backend='jax'."
1606
+ )
1607
+
1608
+ samples, training_symbols, eq_norm = _normalize_inputs(
1609
+ samples, training_symbols, sps, input_norm_factor=input_norm_factor
1610
+ )
1611
+
1612
+ _samp_cpu_rls = to_device(samples, "cpu").astype(np.complex64)
1613
+ samples_padded_np_rls = _build_padded_samples(
1614
+ _samp_cpu_rls, pad_left, pad_right, samples_prefix, pad_mode, eq_norm, sps
1615
+ )
1616
+ samples_padded = (
1617
+ xp.asarray(samples_padded_np_rls)
1618
+ if not was_1d
1619
+ else xp.asarray(samples_padded_np_rls[0])
1620
+ )
1621
+
1622
+ if modulation is not None and order is not None:
1623
+ from ..mapping import gray_constellation
1624
+
1625
+ reference_constellation = gray_constellation(
1626
+ modulation, order, unipolar=unipolar
1627
+ )
1628
+ elif training_symbols is not None:
1629
+ _, _xp, _ = dispatch(training_symbols)
1630
+ train_flat = _xp.reshape(training_symbols, (-1,))
1631
+ reference_constellation = _xp.unique(_xp.round(train_flat, decimals=8))
1632
+ else:
1633
+ raise ValueError("modulation and order must be provided for DD mode.")
1634
+
1635
+ constellation_np = (
1636
+ to_device(reference_constellation, "cpu").flatten().astype("complex64")
1637
+ )
1638
+ # PS-QAM: scale slicer constellation to unit-power {s_m/sqrt(E_PS)}.
1639
+ if pmf is not None and modulation is not None and order is not None:
1640
+ _pmf_arr = np.asarray(pmf, dtype=np.float64)
1641
+ _e_ps = float(
1642
+ np.dot(_pmf_arr, np.abs(constellation_np).astype(np.float64) ** 2)
1643
+ )
1644
+ if _e_ps < 1.0 - 1e-6:
1645
+ constellation_np = (
1646
+ constellation_np * np.float32(1.0 / np.sqrt(_e_ps))
1647
+ ).astype(np.complex64)
1648
+
1649
+ logger.debug(
1650
+ "RLS internals: n_sym=%s, n_train=%s, n_update_halt=%s, leakage=%.2e, delta=%.2e",
1651
+ n_sym,
1652
+ n_train_log,
1653
+ n_update_halt,
1654
+ leakage,
1655
+ delta,
1656
+ )
1657
+
1658
+ train_full, n_train_aligned = _prepare_training_jax(
1659
+ training_symbols,
1660
+ num_ch,
1661
+ n_sym,
1662
+ )
1663
+ x_jax = to_jax(samples_padded, device=device)
1664
+ if was_1d:
1665
+ x_jax = x_jax[None, :]
1666
+
1667
+ try:
1668
+ platform = (
1669
+ device.lower()
1670
+ if device is not None
1671
+ else (
1672
+ x_jax.device.platform
1673
+ if hasattr(x_jax, "device")
1674
+ else list(x_jax.devices())[0].platform
1675
+ )
1676
+ )
1677
+ except Exception:
1678
+ platform = "cpu"
1679
+
1680
+ train_jax = to_jax(train_full, device=platform)
1681
+ const_jax = to_jax(constellation_np, device=platform)
1682
+ if w_init is not None:
1683
+ w_arr = np.ascontiguousarray(to_device(w_init, "cpu"), dtype=np.complex64)
1684
+ w_arr = _validate_w_init(w_arr, num_ch, num_taps)
1685
+ W_jax = to_jax(w_arr, device=platform)
1686
+ else:
1687
+ W_jax = _init_butterfly_weights_jax(
1688
+ num_ch, num_taps, jnp, center_tap=center_tap
1689
+ )
1690
+ W_jax = to_jax(W_jax, device=platform)
1691
+
1692
+ regressor_dim = num_ch * num_taps
1693
+ P_init = jnp.eye(regressor_dim, dtype="complex128") / delta
1694
+ P_init = to_jax(P_init, device=platform)
1695
+ lam_jax = to_jax(jnp.float32(forgetting_factor), device=platform)
1696
+ n_train_jax = to_jax(jnp.int32(n_train_aligned), device=platform)
1697
+ leakage_jax = to_jax(jnp.float32(leakage), device=platform)
1698
+ n_update_halt_jax = to_jax(jnp.int32(n_update_halt), device=platform)
1699
+
1700
+ if cpr_type is None:
1701
+ _sq_side_j, _sq_lev_min_j, _sq_d_grid_j = _sq_qam_slicer_params(
1702
+ constellation_np
1703
+ )
1704
+ scan_fn = _get_jax_rls(
1705
+ num_taps,
1706
+ stride,
1707
+ len(constellation_np),
1708
+ num_ch,
1709
+ int(_sq_side_j),
1710
+ float(_sq_lev_min_j),
1711
+ float(_sq_d_grid_j),
1712
+ )
1713
+ y_jax, e_jax, W_jax, wh_jax = scan_fn(
1714
+ x_jax,
1715
+ train_jax,
1716
+ const_jax,
1717
+ W_jax,
1718
+ P_init,
1719
+ lam_jax,
1720
+ n_train_jax,
1721
+ leakage_jax,
1722
+ n_update_halt_jax,
1723
+ )
1724
+ result = _unpack_result_jax(
1725
+ y_jax,
1726
+ e_jax,
1727
+ W_jax,
1728
+ wh_jax,
1729
+ was_1d,
1730
+ store_weights,
1731
+ n_sym=n_update_halt,
1732
+ xp=xp,
1733
+ num_train_symbols=int(n_train_aligned),
1734
+ input_norm_factor=eq_norm,
1735
+ )
1736
+ else:
1737
+ pll_mu, pll_beta = resolve_pll_gains(
1738
+ cpr_pll_bandwidth, cpr_pll_mu, cpr_pll_beta
1739
+ )
1740
+ symmetry = _cpr_symmetry(modulation, order)
1741
+ B = int(cpr_bps_test_phases)
1742
+ H = int(cpr_cycle_slip_history)
1743
+ bps_angles_np = np.linspace(
1744
+ 0.0, np.pi / 2.0, B, endpoint=False, dtype=np.float32
1745
+ )
1746
+ bps_phases_neg_np = np.exp(-1j * bps_angles_np).astype(np.complex64)
1747
+ bps_pn_jax = to_jax(bps_phases_neg_np, device=platform)
1748
+ bps_ang_jax = to_jax(bps_angles_np, device=platform)
1749
+ _sq_side_j, _sq_lev_min_j, _sq_d_grid_j = _sq_qam_slicer_params(
1750
+ constellation_np
1751
+ )
1752
+ scan_fn = _get_jax_rls_cpr(
1753
+ num_taps,
1754
+ stride,
1755
+ len(constellation_np),
1756
+ num_ch,
1757
+ cpr_type,
1758
+ B,
1759
+ int(cpr_bps_block_size),
1760
+ bool(cpr_joint_channels),
1761
+ H,
1762
+ int(symmetry),
1763
+ int(_sq_side_j),
1764
+ float(_sq_lev_min_j),
1765
+ float(_sq_d_grid_j),
1766
+ )
1767
+ KB = int(cpr_bps_block_size)
1768
+ if cpr_state is not None:
1769
+ _pi, _pf, _bb, _bbp, _bp4, _cx, _cy, _cp = _cpr_state_to_jax_inits(
1770
+ cpr_state, num_ch, KB, H
1771
+ )
1772
+ else:
1773
+ _pi = np.zeros(num_ch, dtype=np.float64)
1774
+ _pf = np.zeros(num_ch, dtype=np.float64)
1775
+ _bb = np.zeros((KB, num_ch), dtype=np.complex64)
1776
+ _bbp = np.int32(0)
1777
+ _bp4 = np.zeros(num_ch, dtype=np.float64)
1778
+ _cx = np.zeros((num_ch, H), dtype=np.float64)
1779
+ _cy = np.zeros((num_ch, H), dtype=np.float64)
1780
+ _cp = np.zeros(num_ch, dtype=np.int32)
1781
+ (
1782
+ y_jax,
1783
+ e_jax,
1784
+ W_jax,
1785
+ wh_jax,
1786
+ phi_jax,
1787
+ pll_phi_f,
1788
+ pll_freq_f,
1789
+ bps_buf_f,
1790
+ bps_buf_ptr_f,
1791
+ bps_prev4_f,
1792
+ cs_buf_x_f,
1793
+ cs_buf_y_f,
1794
+ cs_buf_ptr_f,
1795
+ ) = scan_fn(
1796
+ x_jax,
1797
+ train_jax,
1798
+ const_jax,
1799
+ bps_pn_jax,
1800
+ bps_ang_jax,
1801
+ W_jax,
1802
+ P_init,
1803
+ lam_jax,
1804
+ n_train_jax,
1805
+ leakage_jax,
1806
+ n_update_halt_jax,
1807
+ to_jax(jnp.float64(pll_mu), device=platform),
1808
+ to_jax(jnp.float64(pll_beta), device=platform),
1809
+ to_jax(jnp.float64(cpr_cycle_slip_threshold), device=platform),
1810
+ to_jax(jnp.bool_(cpr_cycle_slip_correction), device=platform),
1811
+ to_jax(_pi, device=platform),
1812
+ to_jax(_pf, device=platform),
1813
+ to_jax(_bb, device=platform),
1814
+ to_jax(_bbp, device=platform),
1815
+ to_jax(_bp4, device=platform),
1816
+ to_jax(_cx, device=platform),
1817
+ to_jax(_cy, device=platform),
1818
+ to_jax(_cp, device=platform),
1819
+ )
1820
+ result = _unpack_result_jax(
1821
+ y_jax,
1822
+ e_jax,
1823
+ W_jax,
1824
+ wh_jax,
1825
+ was_1d,
1826
+ store_weights,
1827
+ n_sym=n_update_halt,
1828
+ xp=xp,
1829
+ num_train_symbols=int(n_train_aligned),
1830
+ input_norm_factor=eq_norm,
1831
+ )
1832
+ # from_jax returns CuPy for a GPU-resident JAX array; coerce to host
1833
+ # NumPy via to_device (CuPy.get / NumPy passthrough) so CPRState stays
1834
+ # host-side and np.asarray never sees a CuPy array.
1835
+ phi_np = to_device(from_jax(phi_jax), "cpu") # (N_sym, C)
1836
+ phi_t = xp.asarray(phi_np[:n_update_halt].T) # (C, n_update_halt)
1837
+ result.phase_trajectory = phi_t[0] if was_1d else phi_t
1838
+ result.cpr_state = CPRState(
1839
+ pll_phi=to_device(from_jax(pll_phi_f), "cpu"),
1840
+ pll_freq=to_device(from_jax(pll_freq_f), "cpu"),
1841
+ bps_prev4=to_device(from_jax(bps_prev4_f), "cpu"),
1842
+ jax_bps_buf=to_device(from_jax(bps_buf_f), "cpu"),
1843
+ jax_bps_buf_ptr=int(to_device(from_jax(bps_buf_ptr_f), "cpu")),
1844
+ cs_buf_x=to_device(from_jax(cs_buf_x_f), "cpu"),
1845
+ cs_buf_y=to_device(from_jax(cs_buf_y_f), "cpu"),
1846
+ cs_buf_ptr=to_device(from_jax(cs_buf_ptr_f), "cpu"),
1847
+ cpr_type=cpr_type,
1848
+ num_ch=num_ch,
1849
+ symmetry=symmetry,
1850
+ bps_P=B,
1851
+ bps_K=KB,
1852
+ cs_H=H,
1853
+ )
1854
+ # Truncate last num_taps//2 symbols (zero-padding contamination).
1855
+ result = _log_equalizer_exit(result, name="RLS", debug_plot=debug_plot)
1856
+ result.tail_trim = tail_trim
1857
+ _check_rls_divergence(result.weights, xp, forgetting_factor, delta)
1858
+ return result
1859
+
1860
+
1861
+ def cma(
1862
+ samples: ArrayType,
1863
+ num_taps: int = 21,
1864
+ sps: int = 2,
1865
+ step_size: float = 1e-3,
1866
+ modulation: str | None = None,
1867
+ order: int | None = None,
1868
+ unipolar: bool = False,
1869
+ store_weights: bool = False,
1870
+ device: str | None = "cpu",
1871
+ center_tap: int | None = None,
1872
+ backend: str = "numba",
1873
+ w_init: ArrayType | None = None,
1874
+ pilot_ref: ArrayType | None = None,
1875
+ pilot_mask: np.ndarray | None = None,
1876
+ pilot_gain_db: float = 0.0,
1877
+ pmf: Any | None = None,
1878
+ debug_plot: bool = False,
1879
+ plot_smoothing: int = 50,
1880
+ input_norm_factor: float | np.ndarray | None = None,
1881
+ samples_prefix: ArrayType | None = None,
1882
+ pad_mode: str = "zeros",
1883
+ update_mode: str = "sequential",
1884
+ block_len: int = 16,
1885
+ ) -> EqualizerResult:
1886
+ """
1887
+ Constant Modulus Algorithm blind equalizer with butterfly MIMO support.
1888
+
1889
+ ``update_mode='block'`` (``block_len`` 8-32) freezes the weights over
1890
+ ``block_len`` symbols and applies one aggregated Godard gradient per chunk,
1891
+ turning the per-symbol update into a matrix product the GPU can occupy. It
1892
+ requires ``backend='jax'`` (chunked ``lax.scan``) or ``backend='xp'``
1893
+ (array-native NumPy/CuPy); ``backend='numba'`` and ``store_weights`` are not
1894
+ supported. ``step_size`` is on the **same scale as** sequential mode (the
1895
+ aggregated gradient is the sum over the chunk): the same ``mu`` gives the
1896
+ same floor - only the stability ceiling is ~``block_len``x lower, so reduce
1897
+ ``mu`` only if the run diverges. Pilot-aided masking carries over unchanged.
1898
+
1899
+ CMA minimizes the Godard dispersion criterion and requires no training
1900
+ symbols. It is the standard blind equalizer for constant-modulus signals
1901
+ (PSK) and near-constant-modulus signals (low-order QAM).
1902
+
1903
+ CMA recovers the signal up to a phase ambiguity. A phase recovery step
1904
+ (e.g. Viterbi-Viterbi, pilot-aided) is typically needed after CMA.
1905
+
1906
+ When ``pilot_ref`` and ``pilot_mask`` are both supplied the equalizer
1907
+ switches to a **pilot-aided hybrid** mode: the standard Godard CMA error
1908
+ is used at data positions while an LMS residual error
1909
+ (``pilot_ref - y``) is used at every pilot position. This resolves the
1910
+ phase ambiguity at pilot locations while preserving blind adaptation
1911
+ elsewhere. Build the dense arrays with ``build_pilot_ref``.
1912
+
1913
+ Algorithm (per symbol n)
1914
+ ------------------------
1915
+ Steps 1 and 2 are identical to ``lms`` (sliding input window and
1916
+ butterfly filter output ``y_raw[n]``). There is **no CPR step** -
1917
+ CMA's cost surface is phase-invariant; no radial error can drive a
1918
+ phase rotator (see Notes below).
1919
+
1920
+ 3. **Godard error** - third-order radial gradient of the dispersion
1921
+ cost ``J = E[(|y|^2 - R^2)^2]``::
1922
+
1923
+ e[n] = (|y[n]|^2 - R^2) * y[n]
1924
+
1925
+ The Godard radius ``R^2 = E[|s|^4] / E[|s|^2]`` is computed once
1926
+ from the normalised constellation (defaults to 1 if ``modulation``
1927
+ is not given). The error is purely radial: any constant phase
1928
+ rotation of ``y`` leaves ``|y|^2`` and therefore ``e`` unchanged
1929
+ up to the same rotation, so CMA cannot resolve the phase ambiguity
1930
+ it introduces.
1931
+
1932
+ 4. **Weight update** - steepest descent on the Godard criterion (note
1933
+ the minus sign, opposite to LMS)::
1934
+
1935
+ w_{c,c'} -= mu * conj(e_c[n]) * x_{c',n}
1936
+
1937
+ **Pilot-aided hybrid** (when ``pilot_ref`` and ``pilot_mask`` are set):
1938
+ at pilot positions the Godard error is replaced by the LMS pilot error
1939
+ ``e_p[n] = pilot_ref[n] - y[n]``, and the weight update sign flips to
1940
+ ``+mu`` (standard LMS gradient ascent toward the reference). This
1941
+ resolves the phase ambiguity at pilot locations while CMA handles data
1942
+ positions blindly.
1943
+
1944
+ Notes
1945
+ -----
1946
+ **Why joint CMA + CPR is not supported:**
1947
+ PLL requires a phase-coherent decision ``d[n]`` (nearest constellation
1948
+ point) to form the cross-product error ``Im(y * conj(d))``; but CMA
1949
+ output has an unknown phase rotation, so the decision is unreliable.
1950
+ BPS is blind, but CMA weights converge to one of four equally-valid
1951
+ 90° rotations and slowly drift between them - BPS would track that
1952
+ drift, but the next CMA gradient step would fight the correction. Use
1953
+ the sequential pipeline instead: CMA ->
1954
+ ``correct_carrier_phase`` (BPS or
1955
+ Viterbi-Viterbi) -> optional ``lms`` fine-tune.
1956
+
1957
+ Parameters
1958
+ ----------
1959
+ samples : array_like
1960
+ Input signal samples. Shape: ``(N_samples,)`` or ``(C, N_samples)``.
1961
+ Typically at 2 samples/symbol for fractionally-spaced equalization.
1962
+ num_taps : int, default 21
1963
+ Number of equalizer taps per FIR filter.
1964
+ sps : int, default 2
1965
+ Samples per symbol at the input. Use ``sps=2`` (T/2-spaced, default)
1966
+ for the standard first-stage blind equalization. ``sps=1`` enables
1967
+ symbol-spaced CMA, useful when input is already decimated but phase
1968
+ ambiguity resolution is still needed.
1969
+ step_size : float, default 1e-3
1970
+ CMA step size (mu). Unlike LMS, CMA's cost surface is non-convex and
1971
+ higher-order, so input-power normalization distorts the gradient geometry.
1972
+ Use a fixed step size in the range 1e-5 to 1e-3 for stability.
1973
+ modulation : str, optional
1974
+ Modulation type for auto-computing Godard radius R2 (e.g. ``"psk"``, ``"qam"``).
1975
+ If None, defaults to R2=1.0.
1976
+ order : int, optional
1977
+ Modulation order for auto-computing R2.
1978
+ unipolar : bool, default False
1979
+ Use unipolar constellation for auto-computing R2.
1980
+ store_weights : bool, default False
1981
+ If True, stores weight trajectory.
1982
+ device : str, optional
1983
+ Target device for JAX computations (e.g., 'cpu', 'gpu', 'tpu').
1984
+ Default is 'cpu'. Ignored when ``backend='numba'``.
1985
+ center_tap : int, optional
1986
+ Index of the center tap. If None, defaults to ``num_taps // 2``.
1987
+ backend : str, default 'numba'
1988
+ Execution backend. ``'numba'`` uses Numba ``@njit``; LLVM-compiled,
1989
+ typically fastest on CPU. ``'jax'`` uses ``jax.lax.scan``
1990
+ (XLA-compiled, GPU-capable). ``'xp'`` is valid only with
1991
+ ``update_mode='block'`` - the array-native NumPy/CuPy block loop.
1992
+ w_init : array_like, optional
1993
+ Initial tap weights. Shape: ``(C, C, num_taps)`` complex64, or the
1994
+ SISO short-hand ``(num_taps,)`` / ``(1, num_taps)`` as returned by
1995
+ ``EqualizerResult.weights`` for single-channel equalizers.
1996
+ Warm-starts blind equalization from pre-converged weights (e.g. from
1997
+ a prior ``lms()`` call on the preamble). Raises ``ValueError`` on
1998
+ shape mismatch.
1999
+ pilot_ref : (C, N_sym) complex64 array, optional
2000
+ Dense pilot reference array - zeros at data positions, known symbols
2001
+ at pilot positions. Build with ``build_pilot_ref``.
2002
+ Must be provided together with ``pilot_mask``.
2003
+ pilot_mask : (N_sym,) uint8 array, optional
2004
+ Pilot position mask - ``1`` at pilot positions, ``0`` elsewhere.
2005
+ Build with ``build_pilot_ref``.
2006
+ pilot_gain_db : float, default 0.0
2007
+ Pilot boosting in dB relative to payload power, matching
2008
+ ``SingleCarrierFrame.pilot_gain_db``. When non-zero, the received
2009
+ signal at pilot positions is attenuated by the inverse of the boost
2010
+ factor before the global RMS normalisation. This prevents boosted
2011
+ pilots from inflating the RMS estimate and biasing the Godard
2012
+ convergence target at data positions. Set to ``0.0`` when pilots
2013
+ are not boosted.
2014
+ pmf : array_like of float, optional
2015
+ Probability mass function for PS-QAM. When provided with ``modulation``
2016
+ and ``order``, the Godard R2 is computed for the unit-power PS
2017
+ distribution ``{s_m/sqrt(E_PS)}``:
2018
+ ``R2 = E_PS[|s_m|^4] / E_PS^2``. Pilot references are also scaled
2019
+ by ``1/sqrt(E_PS)`` so pilot-aided and blind sections converge to the
2020
+ same unit-power target.
2021
+ input_norm_factor : float or ndarray, optional
2022
+ Pre-computed RMS normalization factor from a previous call. See
2023
+ ``lms()`` for the full description; behaviour is identical.
2024
+ samples_prefix : array_like, optional
2025
+ Signal history from the end of the previous block. See ``lms()``
2026
+ for the full description; behaviour is identical.
2027
+ pad_mode : {'zeros', 'edge'}, default 'zeros'
2028
+ Padding strategy when ``samples_prefix`` is ``None``. See
2029
+ ``lms()`` for the full description; behaviour is identical.
2030
+ update_mode : {'sequential', 'block'}, default 'sequential'
2031
+ Weight-update cadence. ``'sequential'`` updates every symbol (the
2032
+ default; the only mode for ``backend='numba'``). ``'block'`` freezes
2033
+ the weights over ``block_len`` symbols and applies one aggregated Godard
2034
+ gradient per chunk (a GPU-occupying matrix product); requires
2035
+ ``backend='jax'`` or ``backend='xp'`` and is incompatible with
2036
+ ``store_weights``. Pilot-aided masking carries over unchanged.
2037
+ block_len : int, default 16
2038
+ Symbols per frozen-weight chunk when ``update_mode='block'`` (typically
2039
+ 8-32; ignored otherwise). ``step_size`` stays on the same scale as
2040
+ sequential mode; only the stability ceiling is ~``block_len``x lower.
2041
+
2042
+ Returns
2043
+ -------
2044
+ EqualizerResult
2045
+ Equalized symbols, final weights, CMA error history, and optionally
2046
+ weight trajectory. ``input_norm_factor`` field is populated.
2047
+
2048
+ Warnings
2049
+ --------
2050
+ **JAX GPU mode is typically slower than CPU for adaptive equalization.**
2051
+ CMA is inherently sequential: each weight update depends on the previous
2052
+ weights, so ``lax.scan`` serializes execution even on GPU. Use
2053
+ ``device='cpu'`` for typical SISO sequences, or ``backend='numba'`` for
2054
+ CPU-optimal throughput.
2055
+ """
2056
+ use_pilots = pilot_ref is not None and pilot_mask is not None
2057
+ _validate_block_mode(update_mode, block_len, backend, store_weights=store_weights)
2058
+ logger.info(
2059
+ "CMA equalizer: num_taps=%s, mu=%s, sps=%s, backend=%s, pilot_aided=%s, pilot_gain_db=%s",
2060
+ num_taps,
2061
+ step_size,
2062
+ sps,
2063
+ backend,
2064
+ use_pilots,
2065
+ pilot_gain_db,
2066
+ )
2067
+ if sps > 1:
2068
+ logger.warning(
2069
+ "CMA output y_hat is at 1 SPS (symbol rate). "
2070
+ "Update sampling_rate = symbol_rate after applying this equalizer."
2071
+ )
2072
+
2073
+ samples, xp, _ = dispatch(samples)
2074
+ stride = int(sps)
2075
+ _validate_sps(sps, num_taps)
2076
+
2077
+ was_1d = samples.ndim == 1
2078
+ if was_1d:
2079
+ num_ch = 1
2080
+ n_samples = samples.shape[0]
2081
+ else:
2082
+ num_ch, n_samples = samples.shape
2083
+
2084
+ # Compute R2 and PS-QAM scale factor from the Godard constellation.
2085
+ _c_ps = None # 1/sqrt(E_PS) scale factor; None for uniform modulation
2086
+ if modulation is not None and order is not None:
2087
+ from ..mapping import gray_constellation
2088
+
2089
+ const = gray_constellation(modulation, order, unipolar=unipolar)
2090
+ if pmf is not None:
2091
+ # PS-QAM: R2 for the unit-power distribution {s_m/sqrt(E_PS)}:
2092
+ # R2 = E_PS[|s_m/sqrt(E_PS)|^4] / E_PS[|s_m/sqrt(E_PS)|^2]
2093
+ # = (E_PS[|s_m|^4] / E_PS^2) / 1
2094
+ # = E_PS[|s_m|^4] / E_PS^2
2095
+ _pmf_arr = np.asarray(pmf, dtype=np.float64)
2096
+ _abs2 = np.abs(const) ** 2
2097
+ _e_ps = float(np.dot(_pmf_arr, _abs2))
2098
+ r2 = float(np.dot(_pmf_arr, np.abs(const) ** 4)) / (_e_ps**2)
2099
+ if _e_ps < 1.0 - 1e-6:
2100
+ _c_ps = np.float32(1.0 / np.sqrt(_e_ps))
2101
+ logger.debug(
2102
+ "CMA R2 (PS-QAM pmf-weighted, %s-%s): %.4f",
2103
+ modulation.upper(),
2104
+ order,
2105
+ r2,
2106
+ )
2107
+ else:
2108
+ r2 = float(np.mean(np.abs(const) ** 4) / np.mean(np.abs(const) ** 2))
2109
+ logger.debug("CMA R2 from %s-%s: %.4f", modulation.upper(), order, r2)
2110
+ else:
2111
+ r2 = 1.0
2112
+
2113
+ n_sym = n_samples // stride
2114
+
2115
+ c_tap = center_tap if center_tap is not None else num_taps // 2
2116
+ pad_total = max(0, n_sym * stride - n_samples + num_taps - 1)
2117
+ pad_left = min(c_tap, pad_total)
2118
+ pad_right = pad_total - pad_left
2119
+
2120
+ if update_mode == "block":
2121
+ samples_padded_np, w_arr, eq_norm, pref_np, pmask_np = _prep_blind_block_inputs(
2122
+ samples,
2123
+ sps=sps,
2124
+ stride=stride,
2125
+ pad_left=pad_left,
2126
+ pad_right=pad_right,
2127
+ samples_prefix=samples_prefix,
2128
+ pad_mode=pad_mode,
2129
+ input_norm_factor=input_norm_factor,
2130
+ use_pilots=use_pilots,
2131
+ pilot_gain_db=pilot_gain_db,
2132
+ pilot_mask=pilot_mask,
2133
+ pilot_ref=pilot_ref,
2134
+ c_ps=_c_ps,
2135
+ num_ch=num_ch,
2136
+ num_taps=num_taps,
2137
+ center_tap=center_tap,
2138
+ w_init=w_init,
2139
+ )
2140
+ return _run_block_equalizer(
2141
+ "cma",
2142
+ samples_padded_np=samples_padded_np,
2143
+ w_arr=w_arr,
2144
+ num_ch=num_ch,
2145
+ num_taps=num_taps,
2146
+ n_sym=n_sym,
2147
+ stride=stride,
2148
+ block_len=block_len,
2149
+ step_size=step_size,
2150
+ backend=backend,
2151
+ device=device,
2152
+ was_1d=was_1d,
2153
+ xp=xp,
2154
+ eq_norm=eq_norm,
2155
+ name="CMA(block)" if not use_pilots else "CMA(PA,block)",
2156
+ debug_plot=debug_plot,
2157
+ plot_smoothing=plot_smoothing,
2158
+ check_convergence=True,
2159
+ r2=r2,
2160
+ pref_np=pref_np,
2161
+ pmask_np=pmask_np,
2162
+ )
2163
+
2164
+ if backend == "numba":
2165
+ numba = _get_numba()
2166
+ if numba is None:
2167
+ raise ImportError("Numba is required for backend='numba'.")
2168
+
2169
+ samples_np = np.ascontiguousarray(to_device(samples, "cpu"), dtype=np.complex64)
2170
+ # Deboost pilot positions before global normalisation so boosted pilots
2171
+ # don't inflate the RMS estimate and bias the Godard convergence target.
2172
+ if use_pilots and pilot_gain_db != 0.0:
2173
+ assert pilot_mask is not None
2174
+ _amp = np.float32(10.0 ** (pilot_gain_db / 20.0))
2175
+ _smask = np.repeat(pilot_mask.astype(bool), stride) # (N_samples,)
2176
+ samples_np[..., _smask] /= _amp
2177
+ # RMS-normalize samples to unit symbol-rate power (CMA has no training)
2178
+ samples_np, _, eq_norm = _normalize_inputs(
2179
+ samples_np, None, sps, input_norm_factor=input_norm_factor
2180
+ )
2181
+
2182
+ x_np = _build_padded_samples(
2183
+ samples_np, pad_left, pad_right, samples_prefix, pad_mode, eq_norm, sps
2184
+ )
2185
+ x_np = np.ascontiguousarray(x_np)
2186
+
2187
+ if w_init is not None:
2188
+ w_arr = np.ascontiguousarray(to_device(w_init, "cpu"), dtype=np.complex64)
2189
+ w_arr = _validate_w_init(w_arr, num_ch, num_taps)
2190
+ W = w_arr.copy()
2191
+ else:
2192
+ W = _init_butterfly_weights_numpy(num_ch, num_taps, center_tap=center_tap)
2193
+ y_out = np.empty((n_sym, num_ch), dtype=np.complex64)
2194
+ e_out = np.empty((n_sym, num_ch), dtype=np.complex64)
2195
+ w_hist_buf = (
2196
+ np.empty((n_sym, num_ch, num_ch, num_taps), dtype=np.complex64)
2197
+ if store_weights
2198
+ else np.empty((1, num_ch, num_ch, num_taps), dtype=np.complex64)
2199
+ )
2200
+ if use_pilots:
2201
+ pref = np.ascontiguousarray(to_device(pilot_ref, "cpu"), dtype=np.complex64)
2202
+ if _c_ps is not None:
2203
+ pref = (pref * _c_ps).astype(np.complex64)
2204
+ pmask = np.ascontiguousarray(pilot_mask, dtype=np.uint8)
2205
+ _get_numba_pa_cma()(
2206
+ x_np,
2207
+ W,
2208
+ np.float32(step_size),
2209
+ np.float32(r2),
2210
+ stride,
2211
+ store_weights,
2212
+ y_out,
2213
+ e_out,
2214
+ w_hist_buf,
2215
+ pref,
2216
+ pmask,
2217
+ )
2218
+ else:
2219
+ _get_numba_cma()(
2220
+ x_np,
2221
+ W,
2222
+ np.float32(step_size),
2223
+ np.float32(r2),
2224
+ stride,
2225
+ store_weights,
2226
+ y_out,
2227
+ e_out,
2228
+ w_hist_buf,
2229
+ )
2230
+ return _log_equalizer_exit(
2231
+ _unpack_result_numpy(
2232
+ y_out,
2233
+ e_out,
2234
+ W,
2235
+ w_hist_buf,
2236
+ was_1d,
2237
+ store_weights,
2238
+ n_sym=None,
2239
+ xp=xp,
2240
+ input_norm_factor=eq_norm,
2241
+ ),
2242
+ name="CMA" if not use_pilots else "CMA(PA)",
2243
+ debug_plot=debug_plot,
2244
+ check_convergence=True,
2245
+ plot_smoothing=plot_smoothing,
2246
+ )
2247
+
2248
+ # JAX backend
2249
+ jax, jnp, _ = _get_jax()
2250
+ if jax is None or jnp is None:
2251
+ raise ImportError("JAX is required for backend='jax'.")
2252
+
2253
+ # Deboost pilot positions before global normalisation so boosted pilots
2254
+ # don't inflate the RMS estimate and bias the Godard convergence target.
2255
+ if use_pilots and pilot_gain_db != 0.0:
2256
+ assert pilot_mask is not None
2257
+ _amp_jax = float(10.0 ** (pilot_gain_db / 20.0))
2258
+ _smask_jax = xp.asarray(np.repeat(pilot_mask.astype(bool), stride))
2259
+ samples = samples.copy()
2260
+ samples[..., _smask_jax] /= xp.float32(_amp_jax)
2261
+ # RMS-normalize samples to unit symbol-rate power (CMA has no training)
2262
+ samples, _, eq_norm = _normalize_inputs(
2263
+ samples, None, sps, input_norm_factor=input_norm_factor
2264
+ )
2265
+
2266
+ _samp_cpu_cma = to_device(samples, "cpu").astype(np.complex64)
2267
+ samples_padded_np_cma = _build_padded_samples(
2268
+ _samp_cpu_cma, pad_left, pad_right, samples_prefix, pad_mode, eq_norm, sps
2269
+ )
2270
+ samples_padded = (
2271
+ xp.asarray(samples_padded_np_cma)
2272
+ if not was_1d
2273
+ else xp.asarray(samples_padded_np_cma[0])
2274
+ )
2275
+
2276
+ x_jax = to_jax(samples_padded, device=device)
2277
+ if was_1d:
2278
+ x_jax = x_jax[None, :]
2279
+
2280
+ try:
2281
+ platform = (
2282
+ device.lower()
2283
+ if device is not None
2284
+ else (
2285
+ x_jax.device.platform
2286
+ if hasattr(x_jax, "device")
2287
+ else list(x_jax.devices())[0].platform
2288
+ )
2289
+ )
2290
+ except Exception:
2291
+ platform = "cpu"
2292
+
2293
+ if w_init is not None:
2294
+ w_arr = np.ascontiguousarray(to_device(w_init, "cpu"), dtype=np.complex64)
2295
+ w_arr = _validate_w_init(w_arr, num_ch, num_taps)
2296
+ W_jax = to_jax(w_arr, device=platform)
2297
+ else:
2298
+ W_jax = _init_butterfly_weights_jax(
2299
+ num_ch, num_taps, jnp, center_tap=center_tap
2300
+ )
2301
+ W_jax = to_jax(W_jax, device=platform)
2302
+ mu_jax = to_jax(jnp.float32(step_size), device=platform)
2303
+ r2_jax = to_jax(jnp.float32(r2), device=platform)
2304
+
2305
+ if use_pilots:
2306
+ pref_np = np.ascontiguousarray(to_device(pilot_ref, "cpu"), dtype=np.complex64)
2307
+ if _c_ps is not None:
2308
+ pref_np = (pref_np * _c_ps).astype(np.complex64)
2309
+ pmask_np = np.ascontiguousarray(pilot_mask, dtype=np.uint8)
2310
+ # pilot_ref: (C, n_sym) -> (n_sym, C) for scan xs
2311
+ pref_jax = to_jax(pref_np.T, device=platform)
2312
+ pmask_jax = to_jax(pmask_np.astype(bool), device=platform)
2313
+ scan_fn = _get_jax_pa_cma(num_taps, stride, num_ch)
2314
+ y_jax, e_jax, W_jax, wh_jax = scan_fn(
2315
+ x_jax, W_jax, mu_jax, r2_jax, pref_jax, pmask_jax, n_sym
2316
+ )
2317
+ else:
2318
+ scan_fn = _get_jax_cma(num_taps, stride, num_ch)
2319
+ y_jax, e_jax, W_jax, wh_jax = scan_fn(x_jax, W_jax, mu_jax, r2_jax, n_sym)
2320
+ return _log_equalizer_exit(
2321
+ _unpack_result_jax(
2322
+ y_jax,
2323
+ e_jax,
2324
+ W_jax,
2325
+ wh_jax,
2326
+ was_1d,
2327
+ store_weights,
2328
+ n_sym=None,
2329
+ xp=xp,
2330
+ input_norm_factor=eq_norm,
2331
+ ),
2332
+ name="CMA" if not use_pilots else "CMA(PA)",
2333
+ debug_plot=debug_plot,
2334
+ check_convergence=True,
2335
+ )
2336
+
2337
+
2338
+ def rde(
2339
+ samples: ArrayType,
2340
+ num_taps: int = 21,
2341
+ sps: int = 2,
2342
+ step_size: float = 1e-3,
2343
+ modulation: str | None = None,
2344
+ order: int | None = None,
2345
+ unipolar: bool = False,
2346
+ store_weights: bool = False,
2347
+ device: str | None = "cpu",
2348
+ center_tap: int | None = None,
2349
+ backend: str = "numba",
2350
+ w_init: ArrayType | None = None,
2351
+ pilot_ref: ArrayType | None = None,
2352
+ pilot_mask: np.ndarray | None = None,
2353
+ pilot_gain_db: float = 0.0,
2354
+ pmf: Any | None = None,
2355
+ debug_plot: bool = False,
2356
+ plot_smoothing: int = 50,
2357
+ input_norm_factor: float | np.ndarray | None = None,
2358
+ samples_prefix: ArrayType | None = None,
2359
+ pad_mode: str = "zeros",
2360
+ update_mode: str = "sequential",
2361
+ block_len: int = 16,
2362
+ ) -> EqualizerResult:
2363
+ """
2364
+ Radius Directed Equalizer (RDE) - blind equalizer for multi-ring constellations.
2365
+
2366
+ ``update_mode='block'`` (``block_len`` 8-32) freezes the weights over
2367
+ ``block_len`` symbols and applies one aggregated ring-directed gradient per
2368
+ chunk. It requires ``backend='jax'`` (chunked ``lax.scan``) or
2369
+ ``backend='xp'`` (array-native NumPy/CuPy); ``backend='numba'`` and
2370
+ ``store_weights`` are not supported. ``step_size`` is on the **same scale
2371
+ as** sequential mode (the aggregated gradient is the sum over the chunk):
2372
+ the same ``mu`` gives the same floor - only the stability ceiling is
2373
+ ~``block_len``x lower, so reduce ``mu`` only if the run diverges.
2374
+ Pilot-aided masking carries over to block mode unchanged.
2375
+
2376
+ RDE is a CMA variant that replaces the single Godard dispersion radius with
2377
+ per-symbol radius selection from the set of unique constellation ring radii.
2378
+ This corrects CMA's fundamental weakness on higher-order QAM: CMA forces
2379
+ all symbols toward a single average circle, severely degrading convergence
2380
+ when the constellation spans multiple rings (e.g. inner, middle, outer rings
2381
+ of 16-QAM). RDE instead drives each symbol toward its *nearest* ring,
2382
+ producing a gradient surface that matches the true constellation geometry.
2383
+
2384
+ Like CMA, RDE is fully blind (no training symbols) and recovers the channel
2385
+ up to a **phase ambiguity**. A carrier-phase recovery step is needed after
2386
+ convergence; see ``cma`` Notes for why joint CPR is not supported.
2387
+
2388
+ Algorithm (per symbol n)
2389
+ ------------------------
2390
+ Steps 1 and 2 are identical to ``lms`` (sliding input window and
2391
+ butterfly filter output ``y[n]``). Like ``cma``, there is no
2392
+ CPR step.
2393
+
2394
+ 3. **Ring selection** - choose the constellation ring radius closest to
2395
+ the current output magnitude::
2396
+
2397
+ R_d[n] = argmin_{r in R_set} |r - |y[n]||
2398
+ R_set = {|c| : c in constellation}
2399
+
2400
+ ``R_set`` is the set of unique ring radii extracted once from the
2401
+ normalised Gray constellation. For 16-QAM this yields three radii
2402
+ rather than the single CMA average, eliminating the inward/outward
2403
+ pull that degrades CMA convergence on higher-order QAM.
2404
+
2405
+ 4. **RDE error** - same third-order form as ``cma`` but using the
2406
+ per-symbol ring radius::
2407
+
2408
+ e[n] = (|y[n]|^2 - R_d[n]^2) * y[n]
2409
+
2410
+ 5. **Weight update** - steepest descent (same sign convention as CMA)::
2411
+
2412
+ w_{c,c'} -= mu * conj(e_c[n]) * x_{c',n}
2413
+
2414
+ **Pilot-aided hybrid** (when ``pilot_ref`` and ``pilot_mask`` are set):
2415
+ identical to ``cma`` - at pilot positions the RDE error is replaced
2416
+ by ``e_p[n] = pilot_ref[n] - y[n]`` and the sign flips to ``+mu``,
2417
+ resolving the phase ambiguity at those locations.
2418
+
2419
+ Parameters
2420
+ ----------
2421
+ samples : array_like
2422
+ Input signal samples. Shape: ``(N_samples,)`` or ``(C, N_samples)``.
2423
+ Typically at 2 samples/symbol for fractionally-spaced equalization.
2424
+ num_taps : int, default 21
2425
+ Number of equalizer taps per FIR filter.
2426
+ sps : int, default 2
2427
+ Samples per symbol at the input. Use ``sps=2`` (T/2-spaced, default)
2428
+ for standard blind equalization. ``sps=1`` is accepted.
2429
+ step_size : float, default 1e-3
2430
+ RDE step size (mu). Same non-convex gradient geometry as CMA; use a
2431
+ fixed step in the range 1e-5 to 1e-3 for stability.
2432
+ modulation : str, optional
2433
+ Modulation type for constellation construction (``"psk"``, ``"qam"``).
2434
+ Required to extract unique ring radii. If ``None``, falls back to a
2435
+ single unit radius (identical to CMA with ``R²=1``).
2436
+ order : int, optional
2437
+ Modulation order (e.g. 4, 16, 64).
2438
+ unipolar : bool, default False
2439
+ Use unipolar constellation for radius extraction.
2440
+ store_weights : bool, default False
2441
+ If True, stores weight trajectory in ``result.weights_history``.
2442
+ device : str, optional
2443
+ Target JAX device (``'cpu'``, ``'gpu'``). Ignored for ``backend='numba'``.
2444
+ center_tap : int, optional
2445
+ Index of the center tap. Defaults to ``num_taps // 2``.
2446
+ backend : str, default 'numba'
2447
+ ``'numba'`` uses Numba ``@njit``; ``'jax'`` uses ``jax.lax.scan``.
2448
+ ``'xp'`` is valid only with ``update_mode='block'`` - the array-native
2449
+ NumPy/CuPy block loop.
2450
+ w_init : array_like, optional
2451
+ Initial tap weights. Shape: ``(C, C, num_taps)`` complex64, or the
2452
+ SISO short-hand ``(num_taps,)`` / ``(1, num_taps)`` as returned by
2453
+ ``EqualizerResult.weights`` for single-channel equalizers.
2454
+ Warm-starts blind equalization from pre-converged weights (e.g. from
2455
+ a prior ``lms()`` or ``cma()`` call). Raises ``ValueError`` on shape
2456
+ mismatch.
2457
+ pilot_ref : (C, N_sym) complex64 array, optional
2458
+ Dense pilot reference array - zeros at data positions, known symbols
2459
+ at pilot positions. Build with ``build_pilot_ref``.
2460
+ Must be provided together with ``pilot_mask``.
2461
+ pilot_mask : (N_sym,) uint8 array, optional
2462
+ Pilot position mask - ``1`` at pilot positions, ``0`` elsewhere.
2463
+ Build with ``build_pilot_ref``.
2464
+ pilot_gain_db : float, default 0.0
2465
+ Pilot boosting in dB relative to payload power, matching
2466
+ ``SingleCarrierFrame.pilot_gain_db``. When non-zero, the received
2467
+ signal at pilot positions is attenuated by the inverse of the boost
2468
+ factor before the global RMS normalisation. This prevents boosted
2469
+ pilots from inflating the RMS estimate and biasing the ring-radius
2470
+ convergence targets at data positions. Set to ``0.0`` when pilots
2471
+ are not boosted.
2472
+ pmf : array_like of float, optional
2473
+ Probability mass function for PS-QAM. When provided with ``modulation``
2474
+ and ``order``, the ring radii are scaled by ``1/sqrt(E_PS)`` to target
2475
+ the unit-power constellation ``{|s_m|/sqrt(E_PS)}``. Pilot references
2476
+ are also scaled accordingly. Requires ``modulation`` and ``order``.
2477
+ input_norm_factor : float or ndarray, optional
2478
+ Pre-computed RMS normalization factor from a previous call. See
2479
+ ``lms()`` for the full description; behaviour is identical.
2480
+ samples_prefix : array_like, optional
2481
+ Signal history from the end of the previous block. See ``lms()``
2482
+ for the full description; behaviour is identical.
2483
+ pad_mode : {'zeros', 'edge'}, default 'zeros'
2484
+ Padding strategy when ``samples_prefix`` is ``None``. See
2485
+ ``lms()`` for the full description; behaviour is identical.
2486
+ update_mode : {'sequential', 'block'}, default 'sequential'
2487
+ Weight-update cadence. ``'sequential'`` updates every symbol (the
2488
+ default; the only mode for ``backend='numba'``). ``'block'`` freezes
2489
+ the weights over ``block_len`` symbols and applies one aggregated
2490
+ ring-directed gradient per chunk (a GPU-occupying matrix product);
2491
+ requires ``backend='jax'`` or ``backend='xp'`` and is incompatible with
2492
+ ``store_weights``. Pilot-aided masking carries over unchanged.
2493
+ block_len : int, default 16
2494
+ Symbols per frozen-weight chunk when ``update_mode='block'`` (typically
2495
+ 8-32; ignored otherwise). ``step_size`` stays on the same scale as
2496
+ sequential mode; only the stability ceiling is ~``block_len``x lower.
2497
+
2498
+ Returns
2499
+ -------
2500
+ EqualizerResult
2501
+ Equalized symbols, final weights, RDE error history, and optionally
2502
+ weight trajectory. ``input_norm_factor`` field is populated.
2503
+
2504
+ Notes
2505
+ -----
2506
+ **Why RDE outperforms CMA on high-order QAM:**
2507
+
2508
+ For 16-QAM the Godard radius ``R² = E[|s|⁴]/E[|s|²] ≈ 1.32`` (normalized).
2509
+ This single target is a poor proxy for the three distinct rings at
2510
+ ``|c| ≈ {0.45, 1.00, 1.34}`` (normalized unit-average-power 16-QAM).
2511
+ CMA pulls inner-ring symbols outward and outer-ring symbols inward,
2512
+ creating a persistent gradient that opposes correct convergence.
2513
+ RDE eliminates this bias entirely: each symbol is only attracted to its
2514
+ own ring, so the steady-state gradient vanishes at the correct solution.
2515
+
2516
+ **Phase ambiguity:** Both CMA and RDE share the same 90°-symmetric cost
2517
+ surface for QAM/PSK. Use a phase recovery algorithm after blind equalization.
2518
+
2519
+ **GPU note:** RDE is inherently sequential (each weight update depends on
2520
+ previous weights), so ``lax.scan`` serializes execution even on GPU.
2521
+ Use ``device='cpu'`` for typical SISO sequences, or ``backend='numba'``
2522
+ for CPU-optimal throughput.
2523
+ """
2524
+ use_pilots = pilot_ref is not None and pilot_mask is not None
2525
+ _validate_block_mode(update_mode, block_len, backend, store_weights=store_weights)
2526
+ logger.info(
2527
+ "RDE equalizer: num_taps=%s, mu=%s, sps=%s, backend=%s, pilot_aided=%s, pilot_gain_db=%s",
2528
+ num_taps,
2529
+ step_size,
2530
+ sps,
2531
+ backend,
2532
+ use_pilots,
2533
+ pilot_gain_db,
2534
+ )
2535
+ if sps > 1:
2536
+ logger.warning(
2537
+ "RDE output y_hat is at 1 SPS (symbol rate). "
2538
+ "Update sampling_rate = symbol_rate after applying this equalizer."
2539
+ )
2540
+
2541
+ samples, xp, _ = dispatch(samples)
2542
+ stride = int(sps)
2543
+ _validate_sps(sps, num_taps)
2544
+
2545
+ was_1d = samples.ndim == 1
2546
+ if was_1d:
2547
+ num_ch = 1
2548
+ n_samples = samples.shape[0]
2549
+ else:
2550
+ num_ch, n_samples = samples.shape
2551
+
2552
+ # Compute unique ring radii from constellation.
2553
+ # For constant-modulus signals (PSK) this degenerates to a single radius,
2554
+ # making RDE identical to CMA.
2555
+ _c_ps = None # 1/sqrt(E_PS) scale factor; None for uniform modulation
2556
+ if modulation is not None and order is not None:
2557
+ from ..mapping import gray_constellation
2558
+
2559
+ const = gray_constellation(modulation, order, unipolar=unipolar)
2560
+ raw_radii = np.abs(const).astype(np.float32)
2561
+ if pmf is not None:
2562
+ # PS-QAM: scale radii to unit-power targets {|s_m|/sqrt(E_PS)}
2563
+ _pmf_arr = np.asarray(pmf, dtype=np.float64)
2564
+ _e_ps = float(np.dot(_pmf_arr, raw_radii.astype(np.float64) ** 2))
2565
+ if _e_ps < 1.0 - 1e-6:
2566
+ _c_ps = np.float32(1.0 / np.sqrt(_e_ps))
2567
+ raw_radii = (raw_radii * _c_ps).astype(np.float32)
2568
+ # Round to 6 significant digits to merge numerically identical radii
2569
+ radii = np.unique(np.round(raw_radii, 6))
2570
+ logger.debug(
2571
+ "RDE radii from %s-%s: %s",
2572
+ modulation.upper(),
2573
+ order,
2574
+ ", ".join(f"{r:.4f}" for r in radii),
2575
+ )
2576
+ else:
2577
+ radii = np.array([1.0], dtype=np.float32)
2578
+ logger.debug("RDE: no modulation provided, using single unit radius (≡ CMA)")
2579
+
2580
+ n_sym = n_samples // stride
2581
+ num_radii = len(radii)
2582
+
2583
+ c_tap = center_tap if center_tap is not None else num_taps // 2
2584
+ pad_total = max(0, n_sym * stride - n_samples + num_taps - 1)
2585
+ pad_left = min(c_tap, pad_total)
2586
+ pad_right = pad_total - pad_left
2587
+
2588
+ if update_mode == "block":
2589
+ samples_padded_np, w_arr, eq_norm, pref_np, pmask_np = _prep_blind_block_inputs(
2590
+ samples,
2591
+ sps=sps,
2592
+ stride=stride,
2593
+ pad_left=pad_left,
2594
+ pad_right=pad_right,
2595
+ samples_prefix=samples_prefix,
2596
+ pad_mode=pad_mode,
2597
+ input_norm_factor=input_norm_factor,
2598
+ use_pilots=use_pilots,
2599
+ pilot_gain_db=pilot_gain_db,
2600
+ pilot_mask=pilot_mask,
2601
+ pilot_ref=pilot_ref,
2602
+ c_ps=_c_ps,
2603
+ num_ch=num_ch,
2604
+ num_taps=num_taps,
2605
+ center_tap=center_tap,
2606
+ w_init=w_init,
2607
+ )
2608
+ return _run_block_equalizer(
2609
+ "rde",
2610
+ samples_padded_np=samples_padded_np,
2611
+ w_arr=w_arr,
2612
+ num_ch=num_ch,
2613
+ num_taps=num_taps,
2614
+ n_sym=n_sym,
2615
+ stride=stride,
2616
+ block_len=block_len,
2617
+ step_size=step_size,
2618
+ backend=backend,
2619
+ device=device,
2620
+ was_1d=was_1d,
2621
+ xp=xp,
2622
+ eq_norm=eq_norm,
2623
+ name="RDE(block)" if not use_pilots else "RDE(PA,block)",
2624
+ debug_plot=debug_plot,
2625
+ plot_smoothing=plot_smoothing,
2626
+ check_convergence=True,
2627
+ radii_np=radii,
2628
+ pref_np=pref_np,
2629
+ pmask_np=pmask_np,
2630
+ )
2631
+
2632
+ if backend == "numba":
2633
+ numba = _get_numba()
2634
+ if numba is None:
2635
+ raise ImportError("Numba is required for backend='numba'.")
2636
+
2637
+ samples_np = np.ascontiguousarray(to_device(samples, "cpu"), dtype=np.complex64)
2638
+ # Deboost pilot positions before global normalisation so boosted pilots
2639
+ # don't inflate the RMS estimate and bias the ring-radius convergence targets.
2640
+ if use_pilots and pilot_gain_db != 0.0:
2641
+ assert pilot_mask is not None
2642
+ _amp = np.float32(10.0 ** (pilot_gain_db / 20.0))
2643
+ _smask = np.repeat(pilot_mask.astype(bool), stride) # (N_samples,)
2644
+ samples_np[..., _smask] /= _amp
2645
+ samples_np, _, eq_norm = _normalize_inputs(
2646
+ samples_np, None, sps, input_norm_factor=input_norm_factor
2647
+ )
2648
+
2649
+ x_np = _build_padded_samples(
2650
+ samples_np, pad_left, pad_right, samples_prefix, pad_mode, eq_norm, sps
2651
+ )
2652
+ x_np = np.ascontiguousarray(x_np)
2653
+
2654
+ # Normalize radii to match the unit-power-normalized samples
2655
+ # (constellation is unit-average-power after gray_constellation)
2656
+ radii_np = np.ascontiguousarray(radii, dtype=np.float32)
2657
+
2658
+ if w_init is not None:
2659
+ w_arr = np.ascontiguousarray(to_device(w_init, "cpu"), dtype=np.complex64)
2660
+ w_arr = _validate_w_init(w_arr, num_ch, num_taps)
2661
+ W = w_arr.copy()
2662
+ else:
2663
+ W = _init_butterfly_weights_numpy(num_ch, num_taps, center_tap=center_tap)
2664
+ y_out = np.empty((n_sym, num_ch), dtype=np.complex64)
2665
+ e_out = np.empty((n_sym, num_ch), dtype=np.complex64)
2666
+ w_hist_buf = (
2667
+ np.empty((n_sym, num_ch, num_ch, num_taps), dtype=np.complex64)
2668
+ if store_weights
2669
+ else np.empty((1, num_ch, num_ch, num_taps), dtype=np.complex64)
2670
+ )
2671
+ if use_pilots:
2672
+ pref = np.ascontiguousarray(to_device(pilot_ref, "cpu"), dtype=np.complex64)
2673
+ if _c_ps is not None:
2674
+ pref = (pref * _c_ps).astype(np.complex64)
2675
+ pmask = np.ascontiguousarray(pilot_mask, dtype=np.uint8)
2676
+ _get_numba_pa_rde()(
2677
+ x_np,
2678
+ W,
2679
+ np.float32(step_size),
2680
+ radii_np,
2681
+ stride,
2682
+ store_weights,
2683
+ y_out,
2684
+ e_out,
2685
+ w_hist_buf,
2686
+ pref,
2687
+ pmask,
2688
+ )
2689
+ else:
2690
+ _get_numba_rde()(
2691
+ x_np,
2692
+ W,
2693
+ np.float32(step_size),
2694
+ radii_np,
2695
+ stride,
2696
+ store_weights,
2697
+ y_out,
2698
+ e_out,
2699
+ w_hist_buf,
2700
+ )
2701
+ return _log_equalizer_exit(
2702
+ _unpack_result_numpy(
2703
+ y_out,
2704
+ e_out,
2705
+ W,
2706
+ w_hist_buf,
2707
+ was_1d,
2708
+ store_weights,
2709
+ n_sym=None,
2710
+ xp=xp,
2711
+ input_norm_factor=eq_norm,
2712
+ ),
2713
+ name="RDE" if not use_pilots else "RDE(PA)",
2714
+ debug_plot=debug_plot,
2715
+ check_convergence=True,
2716
+ plot_smoothing=plot_smoothing,
2717
+ )
2718
+
2719
+ # JAX backend
2720
+ jax, jnp, _ = _get_jax()
2721
+ if jax is None or jnp is None:
2722
+ raise ImportError("JAX is required for backend='jax'.")
2723
+
2724
+ # Deboost pilot positions before global normalisation so boosted pilots
2725
+ # don't inflate the RMS estimate and bias the ring-radius convergence targets.
2726
+ if use_pilots and pilot_gain_db != 0.0:
2727
+ assert pilot_mask is not None
2728
+ _amp_jax = float(10.0 ** (pilot_gain_db / 20.0))
2729
+ _smask_jax = xp.asarray(np.repeat(pilot_mask.astype(bool), stride))
2730
+ samples = samples.copy()
2731
+ samples[..., _smask_jax] /= xp.float32(_amp_jax)
2732
+ samples, _, eq_norm = _normalize_inputs(
2733
+ samples, None, sps, input_norm_factor=input_norm_factor
2734
+ )
2735
+
2736
+ _samp_cpu_rde = to_device(samples, "cpu").astype(np.complex64)
2737
+ samples_padded_np_rde = _build_padded_samples(
2738
+ _samp_cpu_rde, pad_left, pad_right, samples_prefix, pad_mode, eq_norm, sps
2739
+ )
2740
+ samples_padded = (
2741
+ xp.asarray(samples_padded_np_rde)
2742
+ if not was_1d
2743
+ else xp.asarray(samples_padded_np_rde[0])
2744
+ )
2745
+
2746
+ x_jax = to_jax(samples_padded, device=device)
2747
+ if was_1d:
2748
+ x_jax = x_jax[None, :]
2749
+
2750
+ try:
2751
+ platform = (
2752
+ device.lower()
2753
+ if device is not None
2754
+ else (
2755
+ x_jax.device.platform
2756
+ if hasattr(x_jax, "device")
2757
+ else list(x_jax.devices())[0].platform
2758
+ )
2759
+ )
2760
+ except Exception:
2761
+ platform = "cpu"
2762
+
2763
+ if w_init is not None:
2764
+ w_arr = np.ascontiguousarray(to_device(w_init, "cpu"), dtype=np.complex64)
2765
+ w_arr = _validate_w_init(w_arr, num_ch, num_taps)
2766
+ W_jax = to_jax(w_arr, device=platform)
2767
+ else:
2768
+ W_jax = _init_butterfly_weights_jax(
2769
+ num_ch, num_taps, jnp, center_tap=center_tap
2770
+ )
2771
+ W_jax = to_jax(W_jax, device=platform)
2772
+ mu_jax = to_jax(jnp.float32(step_size), device=platform)
2773
+ radii_jax = to_jax(jnp.asarray(radii, dtype=jnp.float32), device=platform)
2774
+
2775
+ if use_pilots:
2776
+ pref_np = np.ascontiguousarray(to_device(pilot_ref, "cpu"), dtype=np.complex64)
2777
+ if _c_ps is not None:
2778
+ pref_np = (pref_np * _c_ps).astype(np.complex64)
2779
+ pmask_np = np.ascontiguousarray(pilot_mask, dtype=np.uint8)
2780
+ # pilot_ref: (C, n_sym) -> (n_sym, C) for scan xs
2781
+ pref_jax = to_jax(pref_np.T, device=platform)
2782
+ pmask_jax = to_jax(pmask_np.astype(bool), device=platform)
2783
+ scan_fn = _get_jax_pa_rde(num_taps, stride, num_radii, num_ch)
2784
+ y_jax, e_jax, W_jax, wh_jax = scan_fn(
2785
+ x_jax, W_jax, mu_jax, radii_jax, pref_jax, pmask_jax, n_sym
2786
+ )
2787
+ else:
2788
+ scan_fn = _get_jax_rde(num_taps, stride, num_radii, num_ch)
2789
+ y_jax, e_jax, W_jax, wh_jax = scan_fn(x_jax, W_jax, mu_jax, radii_jax, n_sym)
2790
+ return _log_equalizer_exit(
2791
+ _unpack_result_jax(
2792
+ y_jax,
2793
+ e_jax,
2794
+ W_jax,
2795
+ wh_jax,
2796
+ was_1d,
2797
+ store_weights,
2798
+ n_sym=None,
2799
+ xp=xp,
2800
+ input_norm_factor=eq_norm,
2801
+ ),
2802
+ name="RDE" if not use_pilots else "RDE(PA)",
2803
+ debug_plot=debug_plot,
2804
+ check_convergence=True,
2805
+ )