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.
- commkit/__init__.py +74 -0
- commkit/_cuda/__init__.py +321 -0
- commkit/_cuda/compiler.py +88 -0
- commkit/_cuda/src/bps_min_d2.cu +104 -0
- commkit/_cuda/src/cs_block.cu +119 -0
- commkit/_cuda/src/selftest.cu +14 -0
- commkit/analysis/__init__.py +55 -0
- commkit/analysis/_common.py +236 -0
- commkit/analysis/allan.py +108 -0
- commkit/analysis/drift.py +213 -0
- commkit/analysis/interferometry.py +887 -0
- commkit/analysis/linewidth.py +480 -0
- commkit/analysis/trajectory.py +91 -0
- commkit/backend.py +507 -0
- commkit/coding/__init__.py +23 -0
- commkit/coding/base.py +17 -0
- commkit/coding/bch.py +6 -0
- commkit/coding/convolutional.py +7 -0
- commkit/coding/crc.py +7 -0
- commkit/coding/galois.py +8 -0
- commkit/coding/hamming.py +6 -0
- commkit/coding/interleaving.py +7 -0
- commkit/coding/ldpc.py +8 -0
- commkit/coding/polar.py +8 -0
- commkit/coding/ratematch.py +6 -0
- commkit/coding/reed_solomon.py +6 -0
- commkit/coding/turbo.py +8 -0
- commkit/core/__init__.py +32 -0
- commkit/core/frame.py +992 -0
- commkit/core/generation.py +581 -0
- commkit/core/signal.py +725 -0
- commkit/equalization/__init__.py +49 -0
- commkit/equalization/_block.py +1855 -0
- commkit/equalization/_common.py +606 -0
- commkit/equalization/_kernels_jax.py +1720 -0
- commkit/equalization/_kernels_numba.py +1704 -0
- commkit/equalization/blind.py +223 -0
- commkit/equalization/linear.py +365 -0
- commkit/equalization/polarization.py +790 -0
- commkit/equalization/result.py +191 -0
- commkit/equalization/sequential.py +2805 -0
- commkit/filtering.py +1120 -0
- commkit/frequency.py +1191 -0
- commkit/helpers.py +489 -0
- commkit/impairments/__init__.py +43 -0
- commkit/impairments/channel/__init__.py +20 -0
- commkit/impairments/channel/linear.py +310 -0
- commkit/impairments/channel/nonlinear.py +11 -0
- commkit/impairments/frontend.py +229 -0
- commkit/impairments/noise.py +105 -0
- commkit/impairments/source.py +219 -0
- commkit/io.py +308 -0
- commkit/logger.py +103 -0
- commkit/mapping/__init__.py +46 -0
- commkit/mapping/bits.py +240 -0
- commkit/mapping/constellation.py +153 -0
- commkit/mapping/gray.py +429 -0
- commkit/mapping/llr.py +253 -0
- commkit/mapping/shaping.py +218 -0
- commkit/metrics.py +949 -0
- commkit/multirate.py +476 -0
- commkit/plotting/__init__.py +78 -0
- commkit/plotting/analysis.py +627 -0
- commkit/plotting/constellation.py +483 -0
- commkit/plotting/equalizer.py +390 -0
- commkit/plotting/eye.py +388 -0
- commkit/plotting/spectral.py +575 -0
- commkit/plotting/sync.py +953 -0
- commkit/plotting/theme.py +203 -0
- commkit/plotting/waveform.py +200 -0
- commkit/py.typed +0 -0
- commkit/recovery/__init__.py +51 -0
- commkit/recovery/bps.py +337 -0
- commkit/recovery/corrections.py +751 -0
- commkit/recovery/pilots.py +803 -0
- commkit/recovery/pll.py +482 -0
- commkit/recovery/tikhonov.py +424 -0
- commkit/recovery/viterbi_viterbi.py +227 -0
- commkit/spectral.py +560 -0
- commkit/timing.py +841 -0
- commkit-1.0.0.dist-info/METADATA +145 -0
- commkit-1.0.0.dist-info/RECORD +84 -0
- commkit-1.0.0.dist-info/WHEEL +4 -0
- 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
|
+
)
|