commkit 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. commkit/__init__.py +74 -0
  2. commkit/_cuda/__init__.py +321 -0
  3. commkit/_cuda/compiler.py +88 -0
  4. commkit/_cuda/src/bps_min_d2.cu +104 -0
  5. commkit/_cuda/src/cs_block.cu +119 -0
  6. commkit/_cuda/src/selftest.cu +14 -0
  7. commkit/analysis/__init__.py +55 -0
  8. commkit/analysis/_common.py +236 -0
  9. commkit/analysis/allan.py +108 -0
  10. commkit/analysis/drift.py +213 -0
  11. commkit/analysis/interferometry.py +887 -0
  12. commkit/analysis/linewidth.py +480 -0
  13. commkit/analysis/trajectory.py +91 -0
  14. commkit/backend.py +507 -0
  15. commkit/coding/__init__.py +23 -0
  16. commkit/coding/base.py +17 -0
  17. commkit/coding/bch.py +6 -0
  18. commkit/coding/convolutional.py +7 -0
  19. commkit/coding/crc.py +7 -0
  20. commkit/coding/galois.py +8 -0
  21. commkit/coding/hamming.py +6 -0
  22. commkit/coding/interleaving.py +7 -0
  23. commkit/coding/ldpc.py +8 -0
  24. commkit/coding/polar.py +8 -0
  25. commkit/coding/ratematch.py +6 -0
  26. commkit/coding/reed_solomon.py +6 -0
  27. commkit/coding/turbo.py +8 -0
  28. commkit/core/__init__.py +32 -0
  29. commkit/core/frame.py +992 -0
  30. commkit/core/generation.py +581 -0
  31. commkit/core/signal.py +725 -0
  32. commkit/equalization/__init__.py +49 -0
  33. commkit/equalization/_block.py +1855 -0
  34. commkit/equalization/_common.py +606 -0
  35. commkit/equalization/_kernels_jax.py +1720 -0
  36. commkit/equalization/_kernels_numba.py +1704 -0
  37. commkit/equalization/blind.py +223 -0
  38. commkit/equalization/linear.py +365 -0
  39. commkit/equalization/polarization.py +790 -0
  40. commkit/equalization/result.py +191 -0
  41. commkit/equalization/sequential.py +2805 -0
  42. commkit/filtering.py +1120 -0
  43. commkit/frequency.py +1191 -0
  44. commkit/helpers.py +489 -0
  45. commkit/impairments/__init__.py +43 -0
  46. commkit/impairments/channel/__init__.py +20 -0
  47. commkit/impairments/channel/linear.py +310 -0
  48. commkit/impairments/channel/nonlinear.py +11 -0
  49. commkit/impairments/frontend.py +229 -0
  50. commkit/impairments/noise.py +105 -0
  51. commkit/impairments/source.py +219 -0
  52. commkit/io.py +308 -0
  53. commkit/logger.py +103 -0
  54. commkit/mapping/__init__.py +46 -0
  55. commkit/mapping/bits.py +240 -0
  56. commkit/mapping/constellation.py +153 -0
  57. commkit/mapping/gray.py +429 -0
  58. commkit/mapping/llr.py +253 -0
  59. commkit/mapping/shaping.py +218 -0
  60. commkit/metrics.py +949 -0
  61. commkit/multirate.py +476 -0
  62. commkit/plotting/__init__.py +78 -0
  63. commkit/plotting/analysis.py +627 -0
  64. commkit/plotting/constellation.py +483 -0
  65. commkit/plotting/equalizer.py +390 -0
  66. commkit/plotting/eye.py +388 -0
  67. commkit/plotting/spectral.py +575 -0
  68. commkit/plotting/sync.py +953 -0
  69. commkit/plotting/theme.py +203 -0
  70. commkit/plotting/waveform.py +200 -0
  71. commkit/py.typed +0 -0
  72. commkit/recovery/__init__.py +51 -0
  73. commkit/recovery/bps.py +337 -0
  74. commkit/recovery/corrections.py +751 -0
  75. commkit/recovery/pilots.py +803 -0
  76. commkit/recovery/pll.py +482 -0
  77. commkit/recovery/tikhonov.py +424 -0
  78. commkit/recovery/viterbi_viterbi.py +227 -0
  79. commkit/spectral.py +560 -0
  80. commkit/timing.py +841 -0
  81. commkit-1.0.0.dist-info/METADATA +145 -0
  82. commkit-1.0.0.dist-info/RECORD +84 -0
  83. commkit-1.0.0.dist-info/WHEEL +4 -0
  84. commkit-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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