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,606 @@
|
|
|
1
|
+
"""Shared helpers for the equalization package (normalization, padding, weight init, validation)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from ..backend import dispatch, from_jax, to_device
|
|
8
|
+
from ..logger import logger
|
|
9
|
+
from .result import CPRState, EqualizerResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _sq_qam_slicer_params(constellation_np):
|
|
13
|
+
"""Return (sq_side, lev_min_f32, d_grid_f32) for O(1) square-QAM slicing.
|
|
14
|
+
|
|
15
|
+
For a square M-QAM constellation the nearest symbol can be found by
|
|
16
|
+
independently snapping the real and imaginary components to the nearest
|
|
17
|
+
level - no O(M) search required. Returns sq_side=0 for non-square
|
|
18
|
+
constellations (PSK, PAM, shaped QAM) to indicate the O(M) fallback
|
|
19
|
+
should be used.
|
|
20
|
+
"""
|
|
21
|
+
M = len(constellation_np)
|
|
22
|
+
sq_side = int(M**0.5)
|
|
23
|
+
if sq_side * sq_side != M:
|
|
24
|
+
return 0, np.float32(0.0), np.float32(1.0)
|
|
25
|
+
levels = np.unique(np.round(constellation_np.real, 6))
|
|
26
|
+
if len(levels) != sq_side:
|
|
27
|
+
return 0, np.float32(0.0), np.float32(1.0)
|
|
28
|
+
lev_min = np.float32(levels[0])
|
|
29
|
+
d_grid = np.float32(levels[1] - levels[0]) if sq_side > 1 else np.float32(1.0)
|
|
30
|
+
return sq_side, lev_min, d_grid
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _cpr_state_to_jax_inits(state: CPRState, num_ch: int, KB: int, H: int):
|
|
34
|
+
"""Extract CPR carry init arrays from a CPRState for JAX warm-start.
|
|
35
|
+
|
|
36
|
+
Returns CPU NumPy arrays with correct dtypes/shapes for the JAX carry.
|
|
37
|
+
Missing fields are zero-initialised. Caller converts to JAX arrays via
|
|
38
|
+
``to_jax(..., device=platform)``.
|
|
39
|
+
|
|
40
|
+
Returns (pll_phi, pll_freq, bps_buf, bps_buf_ptr, bps_prev4,
|
|
41
|
+
cs_buf_x, cs_buf_y, cs_buf_ptr) - all NumPy.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def _get(val, shape, dtype):
|
|
45
|
+
return (
|
|
46
|
+
np.asarray(val, dtype=dtype)
|
|
47
|
+
if val is not None
|
|
48
|
+
else np.zeros(shape, dtype=dtype)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
pll_phi = _get(state.pll_phi, (num_ch,), np.float64)
|
|
52
|
+
pll_freq = _get(state.pll_freq, (num_ch,), np.float64)
|
|
53
|
+
bps_buf = _get(state.jax_bps_buf, (KB, num_ch), np.complex64)
|
|
54
|
+
bps_buf_ptr = np.int32(
|
|
55
|
+
state.jax_bps_buf_ptr if state.jax_bps_buf_ptr is not None else 0
|
|
56
|
+
)
|
|
57
|
+
bps_prev4 = _get(state.bps_prev4, (num_ch,), np.float64)
|
|
58
|
+
cs_buf_x = _get(state.cs_buf_x, (num_ch, H), np.float64)
|
|
59
|
+
cs_buf_y = _get(state.cs_buf_y, (num_ch, H), np.float64)
|
|
60
|
+
cs_buf_ptr = _get(state.cs_buf_ptr, (num_ch,), np.int32)
|
|
61
|
+
return (
|
|
62
|
+
pll_phi,
|
|
63
|
+
pll_freq,
|
|
64
|
+
bps_buf,
|
|
65
|
+
bps_buf_ptr,
|
|
66
|
+
bps_prev4,
|
|
67
|
+
cs_buf_x,
|
|
68
|
+
cs_buf_y,
|
|
69
|
+
cs_buf_ptr,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# -----------------------------------------------------------------------------
|
|
74
|
+
# SHARED HELPERS
|
|
75
|
+
# -----------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _normalize_inputs(samples, training_symbols, sps, input_norm_factor=None):
|
|
79
|
+
"""Scale samples and training symbols to a common unit symbol-power reference.
|
|
80
|
+
|
|
81
|
+
For fractionally-spaced equalization (sps > 1) the fractional timing phase
|
|
82
|
+
is unknown. Strided power measurement ``samples[..., ::sps]`` is unsafe
|
|
83
|
+
because it can land on zero-crossings of the Nyquist pulse, severely
|
|
84
|
+
underestimating signal power and destabilising adaptation.
|
|
85
|
+
|
|
86
|
+
Instead the *wideband* (all-sample) power over the full signal is used.
|
|
87
|
+
This is the most accurate power estimate and is independent of whether
|
|
88
|
+
training symbols are provided.
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
samples : (C, N) or (N,) complex, any backend (NumPy / CuPy)
|
|
93
|
+
training_symbols : (C, K) or (K,) or None - always at 1 sps
|
|
94
|
+
sps : int - samples per symbol
|
|
95
|
+
input_norm_factor : float or np.ndarray, optional
|
|
96
|
+
When supplied, skip the RMS computation and divide samples by this
|
|
97
|
+
factor instead. Pass ``EqualizerResult.input_norm_factor`` from a
|
|
98
|
+
previous call to keep successive blocks on the same power scale.
|
|
99
|
+
``float`` for SISO, ``(C,)`` array for MIMO.
|
|
100
|
+
|
|
101
|
+
Returns
|
|
102
|
+
-------
|
|
103
|
+
samples : unit symbol-power, same shape/backend
|
|
104
|
+
training_symbols : unit average-power, same shape/backend (or None)
|
|
105
|
+
input_norm_factor : float or np.ndarray
|
|
106
|
+
Per-channel normalization factor(s) ``rms(ch) * sqrt(sps)`` applied
|
|
107
|
+
to *samples* before this function returned. ``float`` for SISO,
|
|
108
|
+
``np.ndarray`` of shape ``(C,)`` for MIMO. Stored in
|
|
109
|
+
``EqualizerResult.input_norm_factor`` so callers can reconstruct
|
|
110
|
+
the physical power scale of a different capture (e.g. vacuum noise)
|
|
111
|
+
passed through the same frozen taps. See ``EqualizerResult`` docs.
|
|
112
|
+
"""
|
|
113
|
+
if input_norm_factor is not None:
|
|
114
|
+
# Caller supplies scale - skip RMS and just apply it.
|
|
115
|
+
nf = input_norm_factor
|
|
116
|
+
if samples.ndim == 1:
|
|
117
|
+
samples = samples / float(nf)
|
|
118
|
+
else:
|
|
119
|
+
nf_arr = np.asarray(nf, dtype=np.float64).ravel()
|
|
120
|
+
if nf_arr.shape[0] != samples.shape[0]:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"input_norm_factor shape {nf_arr.shape} does not match "
|
|
123
|
+
f"samples channel count {samples.shape[0]}."
|
|
124
|
+
)
|
|
125
|
+
_, xp_loc, _ = dispatch(samples)
|
|
126
|
+
nf_dev = xp_loc.asarray(nf_arr)[..., None] # (C, 1) on same device
|
|
127
|
+
samples = samples / nf_dev
|
|
128
|
+
if training_symbols is not None:
|
|
129
|
+
from commkit.helpers import normalize as c_normalize
|
|
130
|
+
|
|
131
|
+
training_symbols = c_normalize(training_symbols, "average_power", axis=-1)
|
|
132
|
+
return samples, training_symbols, input_norm_factor
|
|
133
|
+
|
|
134
|
+
from commkit.helpers import rms as _rms
|
|
135
|
+
|
|
136
|
+
ref_samples = samples
|
|
137
|
+
|
|
138
|
+
norm_vec = _rms(ref_samples, axis=-1) * (sps**0.5)
|
|
139
|
+
if samples.ndim == 1:
|
|
140
|
+
input_norm_factor = float(norm_vec)
|
|
141
|
+
samples = samples / float(norm_vec)
|
|
142
|
+
else:
|
|
143
|
+
input_norm_factor = to_device(norm_vec, "cpu")
|
|
144
|
+
# Broadcast (C,) divisor over last axis
|
|
145
|
+
samples = samples / norm_vec[..., None]
|
|
146
|
+
|
|
147
|
+
if training_symbols is not None:
|
|
148
|
+
from commkit.helpers import normalize as c_normalize
|
|
149
|
+
|
|
150
|
+
# Training symbols are at 1 sps; "average_power" == "symbol_power" at sps=1.
|
|
151
|
+
training_symbols = c_normalize(training_symbols, "average_power", axis=-1)
|
|
152
|
+
|
|
153
|
+
return samples, training_symbols, input_norm_factor
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _build_padded_samples(
|
|
157
|
+
samples_np, pad_left, pad_right, samples_prefix, pad_mode, eq_norm, sps
|
|
158
|
+
):
|
|
159
|
+
"""Construct the padded input array for the equalizer.
|
|
160
|
+
|
|
161
|
+
When ``samples_prefix`` is supplied its last ``pad_left`` samples replace the
|
|
162
|
+
leading zero-pad, eliminating the warm-start transient. Otherwise the
|
|
163
|
+
leading edge is filled according to ``pad_mode``.
|
|
164
|
+
|
|
165
|
+
Backend-generic: runs on whichever device ``samples_np`` lives on (NumPy
|
|
166
|
+
callers see unchanged behavior; ``apply_taps`` can pad GPU-resident
|
|
167
|
+
signals without a host round trip).
|
|
168
|
+
"""
|
|
169
|
+
samples_arr, xp, _ = dispatch(samples_np)
|
|
170
|
+
if samples_prefix is not None:
|
|
171
|
+
# Normalize prefix by the same factor used for the main block.
|
|
172
|
+
prefix = xp.ascontiguousarray(
|
|
173
|
+
to_device(samples_prefix, "cpu" if xp is np else "gpu"),
|
|
174
|
+
dtype=xp.complex64,
|
|
175
|
+
)
|
|
176
|
+
if prefix.ndim == 1:
|
|
177
|
+
prefix = prefix[None, :]
|
|
178
|
+
if prefix.shape[-1] < pad_left:
|
|
179
|
+
raise ValueError(
|
|
180
|
+
f"samples_prefix last axis length {prefix.shape[-1]} is less than "
|
|
181
|
+
f"pad_left={pad_left}. Provide at least pad_left samples."
|
|
182
|
+
)
|
|
183
|
+
if eq_norm is not None:
|
|
184
|
+
nf_arr = np.asarray(to_device(eq_norm, "cpu"), dtype=np.float64).ravel()
|
|
185
|
+
if prefix.shape[0] == 1:
|
|
186
|
+
prefix = prefix / float(nf_arr[0])
|
|
187
|
+
else:
|
|
188
|
+
prefix = prefix / xp.asarray(nf_arr)[:, None]
|
|
189
|
+
left_pad = prefix[:, -pad_left:]
|
|
190
|
+
if samples_arr.ndim == 1:
|
|
191
|
+
samples_2d = samples_arr[None, :]
|
|
192
|
+
else:
|
|
193
|
+
samples_2d = samples_arr
|
|
194
|
+
right_zero = xp.zeros((samples_2d.shape[0], pad_right), dtype=xp.complex64)
|
|
195
|
+
padded = xp.concatenate([left_pad, samples_2d, right_zero], axis=-1)
|
|
196
|
+
return padded
|
|
197
|
+
if pad_mode == "zeros":
|
|
198
|
+
if samples_arr.ndim == 1:
|
|
199
|
+
return xp.pad(samples_arr, (pad_left, pad_right))[None, :]
|
|
200
|
+
return xp.pad(samples_arr, ((0, 0), (pad_left, pad_right)))
|
|
201
|
+
if pad_mode in ("edge", "reflect"):
|
|
202
|
+
if samples_arr.ndim == 1:
|
|
203
|
+
return xp.pad(samples_arr, (pad_left, pad_right), mode=pad_mode)[None, :]
|
|
204
|
+
return xp.pad(samples_arr, ((0, 0), (pad_left, pad_right)), mode=pad_mode)
|
|
205
|
+
raise ValueError(
|
|
206
|
+
f"pad_mode must be 'zeros', 'edge', or 'reflect'. Got {pad_mode!r}."
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _init_butterfly_weights_jax(num_ch, num_taps, jnp, center_tap=None):
|
|
211
|
+
"""Build center-tap identity butterfly weight matrix as a JAX array.
|
|
212
|
+
|
|
213
|
+
Initializes a ``(C, C, num_taps)`` complex64 array where
|
|
214
|
+
``W[i, i, center] = 1+0j`` for each channel ``i`` and all other entries
|
|
215
|
+
are zero. This is the canonical identity starting point: at time 0 the
|
|
216
|
+
equalizer passes each channel straight through with unit gain and zero
|
|
217
|
+
delay relative to the center tap.
|
|
218
|
+
|
|
219
|
+
Parameters
|
|
220
|
+
----------
|
|
221
|
+
num_ch : int - number of input/output channels C
|
|
222
|
+
num_taps : int - FIR filter length T
|
|
223
|
+
jnp : JAX numpy module (passed as argument to avoid importing at
|
|
224
|
+
module level when JAX is unavailable)
|
|
225
|
+
center_tap : int or None - tap index for unit initialization;
|
|
226
|
+
defaults to ``num_taps // 2``
|
|
227
|
+
|
|
228
|
+
Returns
|
|
229
|
+
-------
|
|
230
|
+
W : (C, C, num_taps) complex64 JAX array
|
|
231
|
+
"""
|
|
232
|
+
W = jnp.zeros((num_ch, num_ch, num_taps), dtype="complex64")
|
|
233
|
+
center = center_tap if center_tap is not None else num_taps // 2
|
|
234
|
+
W = W.at[jnp.arange(num_ch), jnp.arange(num_ch), center].set(1.0 + 0j)
|
|
235
|
+
return W
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _init_butterfly_weights_numpy(num_ch, num_taps, center_tap=None):
|
|
239
|
+
"""Build center-tap identity butterfly weight matrix as a NumPy array.
|
|
240
|
+
|
|
241
|
+
NumPy counterpart of ``_init_butterfly_weights_jax`` for use with the
|
|
242
|
+
Numba backend. Same semantics and output shape; no JAX dependency.
|
|
243
|
+
|
|
244
|
+
Parameters
|
|
245
|
+
----------
|
|
246
|
+
num_ch : int - number of input/output channels C
|
|
247
|
+
num_taps : int - FIR filter length T
|
|
248
|
+
center_tap : int or None - tap index for unit initialization;
|
|
249
|
+
defaults to ``num_taps // 2``
|
|
250
|
+
|
|
251
|
+
Returns
|
|
252
|
+
-------
|
|
253
|
+
W : (C, C, num_taps) complex64 NumPy array
|
|
254
|
+
"""
|
|
255
|
+
W = np.zeros((num_ch, num_ch, num_taps), dtype=np.complex64)
|
|
256
|
+
center = center_tap if center_tap is not None else num_taps // 2
|
|
257
|
+
for i in range(num_ch):
|
|
258
|
+
W[i, i, center] = 1.0 + 0j
|
|
259
|
+
return W
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _validate_w_init(w: np.ndarray, num_ch: int, num_taps: int) -> np.ndarray:
|
|
263
|
+
"""Validate w_init shape and return it in butterfly layout ``(C, C, T)``.
|
|
264
|
+
|
|
265
|
+
The library's unpack helpers squeeze SISO weights from ``(1, 1, T)`` to
|
|
266
|
+
``(T,)`` in ``EqualizerResult.weights`` for user convenience. This means
|
|
267
|
+
a weight array produced by one SISO equalizer stage and passed as ``w_init``
|
|
268
|
+
to the next stage arrives here as ``(T,)``; that shape must be accepted.
|
|
269
|
+
|
|
270
|
+
Parameters
|
|
271
|
+
----------
|
|
272
|
+
w : np.ndarray - candidate w_init array (already cast to NumPy)
|
|
273
|
+
num_ch : int - expected number of channels C
|
|
274
|
+
num_taps : int - expected number of FIR taps T
|
|
275
|
+
|
|
276
|
+
Returns
|
|
277
|
+
-------
|
|
278
|
+
np.ndarray - w reshaped to ``(num_ch, num_ch, num_taps)`` if needed.
|
|
279
|
+
|
|
280
|
+
Raises
|
|
281
|
+
------
|
|
282
|
+
ValueError if the shape cannot be mapped to ``(num_ch, num_ch, num_taps)``.
|
|
283
|
+
"""
|
|
284
|
+
expected = (num_ch, num_ch, num_taps)
|
|
285
|
+
if w.shape == expected:
|
|
286
|
+
return w
|
|
287
|
+
# SISO: accept the squeezed shapes emitted by _unpack_result_*:
|
|
288
|
+
# (T,) - both channel dims collapsed (_unpack: W[0, 0])
|
|
289
|
+
# (1, T) - one channel dim collapsed
|
|
290
|
+
if num_ch == 1 and w.shape in ((num_taps,), (1, num_taps)):
|
|
291
|
+
return w.reshape(1, 1, num_taps)
|
|
292
|
+
raise ValueError(
|
|
293
|
+
f"w_init shape {tuple(w.shape)} does not match expected "
|
|
294
|
+
f"(num_ch={num_ch}, num_ch={num_ch}, num_taps={num_taps}) = {expected}."
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _prepare_training_jax(
|
|
299
|
+
training_symbols,
|
|
300
|
+
num_ch,
|
|
301
|
+
n_sym,
|
|
302
|
+
):
|
|
303
|
+
"""Build the zero-padded training array expected by the JAX scan kernels.
|
|
304
|
+
|
|
305
|
+
The kernels index ``training_padded[:, sym_idx]`` at every symbol,
|
|
306
|
+
conditioned on ``sym_idx < n_train``. Symbols beyond ``n_train_aligned``
|
|
307
|
+
are zero - the kernel ignores them (DD slicer is used instead).
|
|
308
|
+
|
|
309
|
+
If ``training_symbols`` is 1-D it is broadcast to all ``num_ch`` channels.
|
|
310
|
+
Any extra training symbols beyond ``n_sym`` are silently clamped.
|
|
311
|
+
The array is kept on the same device as ``training_symbols`` to avoid
|
|
312
|
+
unnecessary CPU round-trips before the ``to_jax()`` transfer.
|
|
313
|
+
|
|
314
|
+
Parameters
|
|
315
|
+
----------
|
|
316
|
+
training_symbols : array or None - (K,) or (C, K), any backend
|
|
317
|
+
num_ch : int - C
|
|
318
|
+
n_sym : int - padded symbol count (columns of output array)
|
|
319
|
+
|
|
320
|
+
Returns
|
|
321
|
+
-------
|
|
322
|
+
train_full : (C, n_sym) complex64 on same backend as input (or NumPy)
|
|
323
|
+
n_train_aligned : int - effective number of data-aided symbols
|
|
324
|
+
"""
|
|
325
|
+
if training_symbols is not None:
|
|
326
|
+
# Keep training data on its original device
|
|
327
|
+
train_arr, xp, _ = dispatch(training_symbols)
|
|
328
|
+
train_arr = train_arr.astype("complex64")
|
|
329
|
+
if train_arr.ndim == 1:
|
|
330
|
+
train_arr = (
|
|
331
|
+
xp.tile(train_arr[None, :], (num_ch, 1))
|
|
332
|
+
if num_ch > 1
|
|
333
|
+
else train_arr[None, :]
|
|
334
|
+
)
|
|
335
|
+
n_raw = train_arr.shape[1]
|
|
336
|
+
n_train_aligned = max(0, min(n_raw, n_sym))
|
|
337
|
+
|
|
338
|
+
train_full = xp.zeros((num_ch, n_sym), dtype="complex64")
|
|
339
|
+
if n_train_aligned > 0:
|
|
340
|
+
train_full[:, :n_train_aligned] = train_arr[:, :n_train_aligned]
|
|
341
|
+
else:
|
|
342
|
+
n_train_aligned = 0
|
|
343
|
+
train_full = np.zeros((num_ch, n_sym), dtype="complex64")
|
|
344
|
+
|
|
345
|
+
return train_full, n_train_aligned
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _prepare_training_numpy(
|
|
349
|
+
training_symbols,
|
|
350
|
+
num_ch,
|
|
351
|
+
n_sym,
|
|
352
|
+
):
|
|
353
|
+
"""Build the zero-padded training array for the Numba scan kernels.
|
|
354
|
+
|
|
355
|
+
Pure NumPy implementation - no JAX, CuPy, or ``dispatch`` dependencies.
|
|
356
|
+
The caller must ensure ``training_symbols`` is already a NumPy array
|
|
357
|
+
(use ``to_device(training_symbols, "cpu")`` before calling).
|
|
358
|
+
|
|
359
|
+
Parameters
|
|
360
|
+
----------
|
|
361
|
+
training_symbols : (K,) or (C, K) complex64 NumPy array, or None
|
|
362
|
+
num_ch : int - C
|
|
363
|
+
n_sym : int - symbol count (columns of output array)
|
|
364
|
+
|
|
365
|
+
Returns
|
|
366
|
+
-------
|
|
367
|
+
train_full : (C, n_sym) complex64 NumPy array
|
|
368
|
+
n_train_aligned : int - effective number of data-aided symbols
|
|
369
|
+
"""
|
|
370
|
+
if training_symbols is not None:
|
|
371
|
+
train_arr = np.asarray(training_symbols, dtype=np.complex64)
|
|
372
|
+
if train_arr.ndim == 1:
|
|
373
|
+
train_arr = (
|
|
374
|
+
np.tile(train_arr[None, :], (num_ch, 1))
|
|
375
|
+
if num_ch > 1
|
|
376
|
+
else train_arr[None, :]
|
|
377
|
+
)
|
|
378
|
+
n_raw = train_arr.shape[1]
|
|
379
|
+
n_train_aligned = max(0, min(n_raw, n_sym))
|
|
380
|
+
|
|
381
|
+
train_full = np.zeros((num_ch, n_sym), dtype=np.complex64)
|
|
382
|
+
if n_train_aligned > 0:
|
|
383
|
+
train_full[:, :n_train_aligned] = train_arr[:, :n_train_aligned]
|
|
384
|
+
else:
|
|
385
|
+
n_train_aligned = 0
|
|
386
|
+
train_full = np.zeros((num_ch, n_sym), dtype=np.complex64)
|
|
387
|
+
|
|
388
|
+
return train_full, n_train_aligned
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _unpack_result_jax(
|
|
392
|
+
y_hat_jax,
|
|
393
|
+
errors_jax,
|
|
394
|
+
W_final_jax,
|
|
395
|
+
w_hist_jax,
|
|
396
|
+
was_1d,
|
|
397
|
+
store_weights,
|
|
398
|
+
n_sym=None,
|
|
399
|
+
xp=np,
|
|
400
|
+
num_train_symbols=0,
|
|
401
|
+
input_norm_factor=1.0,
|
|
402
|
+
):
|
|
403
|
+
"""Convert JAX scan outputs into an ``EqualizerResult``.
|
|
404
|
+
|
|
405
|
+
Transfers JAX device arrays back to NumPy/CuPy via ``from_jax``, then:
|
|
406
|
+
1. Transposes from ``(N_sym, C)`` scan layout to ``(C, N_sym)`` convention.
|
|
407
|
+
2. Truncates to ``n_sym`` when provided (e.g. RLS early-halt boundary).
|
|
408
|
+
3. Squeezes the channel dimension for 1-D SISO inputs (``was_1d=True``).
|
|
409
|
+
4. Optionally keeps the weight-trajectory array.
|
|
410
|
+
|
|
411
|
+
Parameters
|
|
412
|
+
----------
|
|
413
|
+
y_hat_jax : (N_sym, C) JAX array - equalized symbols
|
|
414
|
+
errors_jax : (N_sym, C) JAX array - complex errors
|
|
415
|
+
W_final_jax : (C, C, num_taps) JAX array - final weights
|
|
416
|
+
w_hist_jax : (N_sym, C, C, num_taps) JAX array - weight history
|
|
417
|
+
was_1d : bool - squeeze C=1 dimension for SISO inputs
|
|
418
|
+
store_weights : bool - if False, ``weights_history`` is None
|
|
419
|
+
n_sym : int or None - truncation length (None = no truncation)
|
|
420
|
+
xp : output array module (np or cp)
|
|
421
|
+
num_train_symbols: int - stored in the result for caller reference
|
|
422
|
+
|
|
423
|
+
Returns
|
|
424
|
+
-------
|
|
425
|
+
EqualizerResult
|
|
426
|
+
"""
|
|
427
|
+
# ``from_jax`` follows the JAX *compute* device (CuPy if the scan ran on
|
|
428
|
+
# GPU, NumPy on CPU), which may differ from the input's module ``xp`` - e.g.
|
|
429
|
+
# NumPy input with ``device='gpu'``. Coerce to the input's device so the
|
|
430
|
+
# result honours the "output on the input's device" convention (same
|
|
431
|
+
# pattern as the Point 8 CPR-state unpacking).
|
|
432
|
+
_tgt = "cpu" if xp is np else "gpu"
|
|
433
|
+
y_hat = xp.asarray(to_device(from_jax(y_hat_jax), _tgt).T) # (N,C) -> (C,N)
|
|
434
|
+
errors = xp.asarray(to_device(from_jax(errors_jax), _tgt).T)
|
|
435
|
+
W_final = xp.asarray(to_device(from_jax(W_final_jax), _tgt))
|
|
436
|
+
|
|
437
|
+
if n_sym is not None:
|
|
438
|
+
y_hat = y_hat[..., :n_sym]
|
|
439
|
+
errors = errors[..., :n_sym]
|
|
440
|
+
|
|
441
|
+
if was_1d:
|
|
442
|
+
y_hat = y_hat[0]
|
|
443
|
+
errors = errors[0]
|
|
444
|
+
W_final = W_final[0, 0]
|
|
445
|
+
|
|
446
|
+
w_history = None
|
|
447
|
+
if store_weights:
|
|
448
|
+
w_history = xp.asarray(to_device(from_jax(w_hist_jax), _tgt))
|
|
449
|
+
if was_1d:
|
|
450
|
+
w_history = w_history[:, 0, 0, :]
|
|
451
|
+
|
|
452
|
+
return EqualizerResult(
|
|
453
|
+
y_hat=y_hat,
|
|
454
|
+
weights=W_final,
|
|
455
|
+
error=errors,
|
|
456
|
+
weights_history=w_history,
|
|
457
|
+
num_train_symbols=num_train_symbols,
|
|
458
|
+
input_norm_factor=input_norm_factor,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _unpack_result_numpy(
|
|
463
|
+
y_out,
|
|
464
|
+
e_out,
|
|
465
|
+
W_final,
|
|
466
|
+
w_hist,
|
|
467
|
+
was_1d,
|
|
468
|
+
store_weights,
|
|
469
|
+
n_sym=None,
|
|
470
|
+
xp=np,
|
|
471
|
+
num_train_symbols=0,
|
|
472
|
+
input_norm_factor=1.0,
|
|
473
|
+
):
|
|
474
|
+
"""Convert Numba kernel outputs (plain NumPy) into an ``EqualizerResult``.
|
|
475
|
+
|
|
476
|
+
No ``from_jax`` calls - all inputs are already NumPy arrays produced by
|
|
477
|
+
the Numba kernels. Same post-processing as ``_unpack_result_jax`` but
|
|
478
|
+
operates directly on NumPy memory without any device transfer overhead.
|
|
479
|
+
|
|
480
|
+
Parameters
|
|
481
|
+
----------
|
|
482
|
+
y_out : (N_sym, C) complex64 NumPy array - equalized symbols
|
|
483
|
+
e_out : (N_sym, C) complex64 NumPy array - complex errors
|
|
484
|
+
W_final : (C, C, num_taps) complex64 NumPy array - final weights
|
|
485
|
+
w_hist : (N_sym or 1, C, C, num_taps) NumPy - weight history
|
|
486
|
+
was_1d : bool - squeeze C=1 dimension for SISO inputs
|
|
487
|
+
store_weights : bool - if False, ``weights_history`` is None
|
|
488
|
+
n_sym : int or None - truncation length (None = no truncation)
|
|
489
|
+
xp : output array module (np or cp)
|
|
490
|
+
num_train_symbols: int - stored in result for caller reference
|
|
491
|
+
|
|
492
|
+
Returns
|
|
493
|
+
-------
|
|
494
|
+
EqualizerResult
|
|
495
|
+
"""
|
|
496
|
+
y_hat = xp.asarray(y_out.T) # (N_sym, C) -> (C, N_sym)
|
|
497
|
+
errors = xp.asarray(e_out.T)
|
|
498
|
+
W = xp.asarray(W_final)
|
|
499
|
+
|
|
500
|
+
if n_sym is not None:
|
|
501
|
+
y_hat = y_hat[..., :n_sym]
|
|
502
|
+
errors = errors[..., :n_sym]
|
|
503
|
+
|
|
504
|
+
if was_1d:
|
|
505
|
+
y_hat = y_hat[0]
|
|
506
|
+
errors = errors[0]
|
|
507
|
+
W = W[0, 0]
|
|
508
|
+
|
|
509
|
+
w_history = None
|
|
510
|
+
if store_weights:
|
|
511
|
+
w_history = xp.asarray(w_hist)
|
|
512
|
+
if was_1d:
|
|
513
|
+
w_history = w_history[:, 0, 0, :]
|
|
514
|
+
|
|
515
|
+
return EqualizerResult(
|
|
516
|
+
y_hat=y_hat,
|
|
517
|
+
weights=W,
|
|
518
|
+
error=errors,
|
|
519
|
+
weights_history=w_history,
|
|
520
|
+
num_train_symbols=num_train_symbols,
|
|
521
|
+
input_norm_factor=input_norm_factor,
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _cpr_symmetry(modulation: str | None, order: int | None) -> int:
|
|
526
|
+
"""Return the rotational symmetry order used for cycle-slip correction.
|
|
527
|
+
|
|
528
|
+
QAM and most practical CPR algorithms exploit 4-fold (π/2) symmetry.
|
|
529
|
+
BPSK is the only exception (2-fold).
|
|
530
|
+
|
|
531
|
+
Parameters
|
|
532
|
+
----------
|
|
533
|
+
modulation : str or None
|
|
534
|
+
order : int or None
|
|
535
|
+
|
|
536
|
+
Returns
|
|
537
|
+
-------
|
|
538
|
+
int - 4 (default/QAM/PSK M≥4) or 2 (BPSK/PAM)
|
|
539
|
+
"""
|
|
540
|
+
if modulation is None:
|
|
541
|
+
return 4
|
|
542
|
+
m = modulation.lower().strip()
|
|
543
|
+
if m in ("pam",):
|
|
544
|
+
return 2
|
|
545
|
+
if m in ("psk", "bpsk") and order == 2:
|
|
546
|
+
return 2
|
|
547
|
+
return 4
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _validate_sps(sps, num_taps):
|
|
551
|
+
"""Validate sps; warn about unusual values, check tap count minimum."""
|
|
552
|
+
if sps < 1:
|
|
553
|
+
raise ValueError(f"sps must be >= 1. Got sps={sps}.")
|
|
554
|
+
if sps == 1:
|
|
555
|
+
logger.info(
|
|
556
|
+
"sps=1: symbol-spaced equalizer mode. "
|
|
557
|
+
"No fractional-spacing benefit; suitable for residual ISI correction "
|
|
558
|
+
"after a prior FSE stage + FOE + CPR."
|
|
559
|
+
)
|
|
560
|
+
elif sps > 2:
|
|
561
|
+
logger.warning(
|
|
562
|
+
"sps=%s: non-standard oversampling ratio for adaptive equalization. T/2-spaced (sps=2) is the industry standard. Higher values give marginal benefit but require proportionally more taps.",
|
|
563
|
+
sps,
|
|
564
|
+
)
|
|
565
|
+
if num_taps < 2 * sps:
|
|
566
|
+
logger.warning(
|
|
567
|
+
"num_taps=%s is small for sps=%s. Recommend num_taps >= %s for fractionally-spaced equalization.",
|
|
568
|
+
num_taps,
|
|
569
|
+
sps,
|
|
570
|
+
4 * sps + 1,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _godard_radius(modulation, order, unipolar, pmf):
|
|
575
|
+
"""Godard dispersion radius R2 and PS-QAM pilot scale (mirrors ``cma``)."""
|
|
576
|
+
if modulation is None or order is None:
|
|
577
|
+
return 1.0, None
|
|
578
|
+
from ..mapping import Constellation
|
|
579
|
+
|
|
580
|
+
c = Constellation.gray(modulation, order, unipolar=unipolar, pmf=pmf)
|
|
581
|
+
const = c.points
|
|
582
|
+
if pmf is not None:
|
|
583
|
+
_pmf = np.asarray(pmf, dtype=np.float64)
|
|
584
|
+
_e_ps = c.power() # Σ P(s_m)|s_m|² - single source of truth for E_PS
|
|
585
|
+
r2 = float(np.dot(_pmf, np.abs(const) ** 4)) / (_e_ps**2)
|
|
586
|
+
c_ps = np.float32(1.0 / np.sqrt(_e_ps)) if _e_ps < 1.0 - 1e-6 else None
|
|
587
|
+
return r2, c_ps
|
|
588
|
+
r2 = float(np.mean(np.abs(const) ** 4) / np.mean(np.abs(const) ** 2))
|
|
589
|
+
return r2, None
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _rde_ring_radii(modulation, order, unipolar, pmf):
|
|
593
|
+
"""Unique constellation ring radii and PS-QAM pilot scale (mirrors ``rde``)."""
|
|
594
|
+
if modulation is None or order is None:
|
|
595
|
+
return np.array([1.0], dtype=np.float32), None
|
|
596
|
+
from ..mapping import Constellation
|
|
597
|
+
|
|
598
|
+
c = Constellation.gray(modulation, order, unipolar=unipolar, pmf=pmf)
|
|
599
|
+
raw = np.abs(c.points).astype(np.float32)
|
|
600
|
+
c_ps = None
|
|
601
|
+
if pmf is not None:
|
|
602
|
+
_e_ps = c.power() # Σ P(s_m)|s_m|² - single source of truth for E_PS
|
|
603
|
+
if _e_ps < 1.0 - 1e-6:
|
|
604
|
+
c_ps = np.float32(1.0 / np.sqrt(_e_ps))
|
|
605
|
+
raw = (raw * c_ps).astype(np.float32)
|
|
606
|
+
return np.unique(np.round(raw, 6)), c_ps
|