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
commkit/metrics.py ADDED
@@ -0,0 +1,949 @@
1
+ """
2
+ Performance metrics for digital communications.
3
+
4
+ This module provides high-performance routines for evaluating signal quality,
5
+ error rates, and reliability across different computational backends.
6
+ """
7
+
8
+ import logging
9
+
10
+ import numpy as np
11
+
12
+ from .backend import ArrayType, dispatch, to_device
13
+ from .core.signal import Signal
14
+ from .logger import logger
15
+
16
+ # Modulation-aware frame signals (those generated by ``SingleCarrierFrame``)
17
+ # carry mixed preamble/pilot/payload segments with potentially different
18
+ # modulations or gains; whole-signal metrics on them are meaningless. The
19
+ # Signal dispatch branches below warn and return ``None`` for such inputs,
20
+ # matching the previous ``Signal.evm``/``snr``/``ber``/``ser`` behaviour.
21
+
22
+
23
+ def _ps_unit_power_rescale(rx, xp, modulation, order, pmf, noise_var):
24
+ """Rescale unit-avg-power PS-QAM symbols ``{c·s_m}`` back to ``{s_m}``.
25
+
26
+ ``resolve_symbols`` normalises the receive constellation to unit average
27
+ power, placing PS-QAM symbols on ``{s_m / sqrt(E_PS)}`` (``c = 1/sqrt(E_PS)``).
28
+ The MI/LLR machinery expects symbols on the ``gray_constellation`` grid
29
+ ``{s_m}``. This helper applies the exact deterministic correction -
30
+ ``rx -> rx·sqrt(E_PS)`` and ``noise_var -> noise_var·E_PS`` - and is a no-op
31
+ for uniform modulations (``pmf is None``) or when ``E_PS ≈ 1``.
32
+
33
+ Returns ``(rx_scaled, noise_var_scaled)``.
34
+ """
35
+ if pmf is None:
36
+ return rx, noise_var
37
+ from .mapping import Constellation
38
+
39
+ e_ps = Constellation.gray(modulation, order, pmf=pmf).power()
40
+ if e_ps < 1.0 - 1e-6:
41
+ rx = rx * xp.asarray(np.sqrt(e_ps), dtype=rx.real.dtype)
42
+ noise_var = noise_var * e_ps
43
+ return rx, noise_var
44
+
45
+
46
+ def evm(
47
+ rx_symbols: ArrayType | Signal,
48
+ tx_symbols: ArrayType | None = None,
49
+ *,
50
+ num_train_symbols: int | None = None,
51
+ mode: str = "data_aided",
52
+ modulation: str | None = None,
53
+ order: int | None = None,
54
+ pmf: np.ndarray | None = None,
55
+ ) -> tuple[float | ArrayType, float | ArrayType] | None:
56
+ """
57
+ Computes Error Vector Magnitude (EVM).
58
+
59
+ Two modes are supported:
60
+
61
+ * ``"data_aided"`` *(default)* - uses known transmitted symbols as the
62
+ reference (classical simulation metric, most accurate).
63
+ * ``"blind"`` - decision-directed: the nearest ideal constellation point
64
+ to each received sample is used as the reference. No knowledge of the
65
+ transmitted sequence is required. Equivalent to measuring the *blob
66
+ size* around each constellation cluster, as a spectrum analyser would.
67
+ At very low SNR the estimate is optimistically biased because erroneous
68
+ decisions pull the centroid toward the wrong cluster. The caller is
69
+ responsible for passing a gain-corrected signal; no implicit gain
70
+ normalisation is applied (``gray_constellation`` always returns
71
+ unit-power constellations).
72
+
73
+ Calculation is performed per-channel (independent gain normalisation) to
74
+ properly handle MIMO/multichannel signals.
75
+
76
+ Parameters
77
+ ----------
78
+ rx_symbols : array_like
79
+ Received symbols. Shape: ``(..., N_symbols)``.
80
+ tx_symbols : array_like, optional
81
+ Ideal reference (transmitted) symbols. Shape: ``(..., N_symbols)``.
82
+ Required when ``mode="data_aided"``, ignored when ``mode="blind"``.
83
+ mode : {"data_aided", "blind"}, default "data_aided"
84
+ Estimation mode (keyword-only).
85
+ modulation : str, optional
86
+ Modulation type (``"psk"``, ``"qam"``, ``"ask"``).
87
+ Required when ``mode="blind"``.
88
+ order : int, optional
89
+ Modulation order *M*. Required when ``mode="blind"``.
90
+ pmf : np.ndarray, optional
91
+ Symbol PMF of shape ``(order,)`` for PS-QAM in ``mode="blind"``.
92
+ When provided, ``rx_symbols`` are rescaled by ``sqrt(E_PS)`` before
93
+ the nearest-neighbour search so unit-avg-power resolved symbols
94
+ line up with the ``{s_m}`` grid returned by
95
+ ``gray_constellation``. Has no effect for uniform modulations
96
+ or for ``mode="data_aided"`` (which is scale-invariant by
97
+ per-channel power normalisation).
98
+
99
+ Returns
100
+ -------
101
+ evm_percent : float or ndarray
102
+ EVM as a percentage. Scalar for SISO, array ``(N_ch,)`` for MIMO.
103
+ evm_db : float or ndarray
104
+ EVM in decibels (dB).
105
+
106
+ Raises
107
+ ------
108
+ ValueError
109
+ If ``mode="data_aided"`` and ``tx_symbols`` is ``None``, or
110
+ ``mode="blind"`` and ``modulation``/``order`` are missing, or
111
+ an unknown mode string is given.
112
+
113
+ Notes
114
+ -----
115
+ For MIMO, metrics are calculated independently for each stream (row).
116
+
117
+ When ``rx_symbols`` is a :class:`Signal`, ``resolved_symbols`` is used as the
118
+ received sequence, the reference falls back to ``source_symbols`` (and
119
+ ``modulation``/``order``/``pmf`` to the signal's metadata), and
120
+ ``num_train_symbols`` leading symbols are discarded. Frame-generated
121
+ signals return ``None`` with a warning.
122
+ """
123
+ if isinstance(rx_symbols, Signal):
124
+ sig = rx_symbols
125
+ if sig.signal_type is not None:
126
+ logger.warning(
127
+ "evm() called on a frame-generated signal. Extract the payload "
128
+ "(or pilot) segment via frame.get_structure_map() and build a "
129
+ "plain Signal first."
130
+ )
131
+ return None
132
+ if sig.resolved_symbols is None:
133
+ raise ValueError(
134
+ "No resolved symbols available. Call resolve_symbols(sig) first."
135
+ )
136
+ if mode == "blind":
137
+ return evm(
138
+ sig.resolved_symbols,
139
+ num_train_symbols=num_train_symbols,
140
+ mode="blind",
141
+ modulation=modulation or sig.mod_scheme,
142
+ order=order or sig.mod_order,
143
+ pmf=sig.ps_pmf,
144
+ )
145
+ ref = tx_symbols if tx_symbols is not None else sig.source_symbols
146
+ if ref is None:
147
+ raise ValueError(
148
+ "No reference available. Provide tx_symbols or ensure "
149
+ "source_symbols is set on the Signal."
150
+ )
151
+ return evm(
152
+ sig.resolved_symbols, ref, num_train_symbols=num_train_symbols, pmf=pmf
153
+ )
154
+
155
+ from . import helpers
156
+
157
+ _VALID_MODES = ("data_aided", "blind")
158
+ if mode not in _VALID_MODES:
159
+ raise ValueError(f"Unknown mode '{mode}'. Choose from {_VALID_MODES}.")
160
+
161
+ rx, xp, _ = dispatch(rx_symbols)
162
+ axis = -1
163
+
164
+ # Discard leading training symbols (e.g. equalizer warm-up). Available to
165
+ # array callers directly; the Signal branch forwards num_train_symbols here.
166
+ trim = num_train_symbols or 0
167
+ if trim > 0:
168
+ if mode == "blind" or tx_symbols is None:
169
+ rx = rx[..., trim:]
170
+ else:
171
+ tx_symbols = xp.asarray(tx_symbols)
172
+ n = min(rx.shape[-1], tx_symbols.shape[-1])
173
+ rx = rx[..., trim:n]
174
+ tx_symbols = tx_symbols[..., trim:n]
175
+
176
+ def _is_normalized(arr, ax):
177
+ pwr = xp.mean(xp.abs(arr) ** 2, axis=ax)
178
+ return xp.allclose(pwr, 1.0, atol=1e-3)
179
+
180
+ # --- Build reference ---
181
+ if mode == "data_aided":
182
+ if tx_symbols is None:
183
+ raise ValueError(
184
+ "mode='data_aided' requires tx_symbols. "
185
+ "Pass tx_symbols or use mode='blind'."
186
+ )
187
+ tx = xp.asarray(tx_symbols)
188
+ if rx.shape != tx.shape:
189
+ raise ValueError(f"Shape mismatch: rx {rx.shape} != tx {tx.shape}")
190
+
191
+ if not _is_normalized(rx, axis):
192
+ rx = helpers.normalize(rx, axis=axis, mode="average_power")
193
+ if not _is_normalized(tx, axis):
194
+ tx = helpers.normalize(tx, axis=axis, mode="average_power")
195
+
196
+ else: # blind
197
+ if modulation is None or order is None:
198
+ raise ValueError(
199
+ "mode='blind' requires modulation and order. "
200
+ "Example: evm(rx, mode='blind', modulation='qam', order=16)."
201
+ )
202
+ from .mapping import Constellation
203
+
204
+ # gray_constellation always returns unit-average-power constellations.
205
+ # No gain correction of rx is applied here - the caller is responsible
206
+ # for passing a gain-corrected signal at the expected constellation power.
207
+ c = Constellation.gray(modulation, order, pmf=pmf)
208
+ constellation_np = c.points
209
+ constellation = xp.asarray(constellation_np) # (M,) unit-avg-power
210
+
211
+ # PS-QAM: receive-path symbols at unit average power live on the
212
+ # ``{s_m/sqrt(E_PS)}`` grid. Rescale rx by ``sqrt(E_PS)`` so the
213
+ # nearest-neighbour decision against ``{s_m}`` is exact.
214
+ if pmf is not None:
215
+ e_ps = c.power()
216
+ if e_ps < 1.0 - 1e-6:
217
+ rx = rx * xp.asarray(np.sqrt(e_ps), dtype=rx.real.dtype)
218
+
219
+ # ML hard decision: nearest constellation point per symbol.
220
+ # rx shape (..., N) -> (..., N, 1) vs (M,) -> (..., N, M)
221
+ dist = xp.abs(rx[..., None] - constellation) ** 2
222
+ tx = constellation[xp.argmin(dist, axis=-1)] # same shape as rx
223
+
224
+ # --- EVM computation (shared) ---
225
+ ref_pwr = xp.mean(xp.abs(tx) ** 2, axis=axis)
226
+ low_pwr_mask = ref_pwr < 1e-20
227
+ if xp.any(low_pwr_mask):
228
+ logger.warning("Reference signal power near zero in one or more channels.")
229
+
230
+ error = rx - tx
231
+ error_power = xp.mean(xp.abs(error) ** 2, axis=axis)
232
+ evm_ratio = xp.sqrt(error_power) # ref power is 1.0 after normalisation
233
+
234
+ if evm_ratio.ndim == 0:
235
+ if low_pwr_mask:
236
+ return float("inf"), float("inf")
237
+ evm_percent = float(evm_ratio * 100.0)
238
+ with np.errstate(divide="ignore"):
239
+ evm_db = (
240
+ float(20.0 * xp.log10(evm_ratio)) if evm_ratio > 0 else float("-inf")
241
+ )
242
+ logger.info("EVM [%s]: %.2f%% (%.2f dB)", mode, evm_percent, evm_db)
243
+ return evm_percent, evm_db
244
+
245
+ evm_percent = evm_ratio * 100.0
246
+ with np.errstate(divide="ignore"):
247
+ evm_db = 20.0 * xp.log10(evm_ratio)
248
+
249
+ evm_percent[low_pwr_mask] = float("inf")
250
+ evm_db[low_pwr_mask] = float("inf")
251
+
252
+ if logger.isEnabledFor(logging.INFO):
253
+ # One batched D2H per array (and none at all when INFO is off).
254
+ evm_pct_np = to_device(evm_percent, "cpu")
255
+ evm_db_np = to_device(evm_db, "cpu")
256
+ for ch in range(evm_pct_np.shape[0]):
257
+ logger.info(
258
+ "EVM [%s] Ch%s: %.2f%% (%.2f dB)",
259
+ mode,
260
+ ch,
261
+ float(evm_pct_np[ch]),
262
+ float(evm_db_np[ch]),
263
+ )
264
+
265
+ return evm_percent, evm_db
266
+
267
+
268
+ def snr(
269
+ rx_symbols: ArrayType | Signal,
270
+ tx_symbols: ArrayType | None = None,
271
+ *,
272
+ num_train_symbols: int | None = None,
273
+ ) -> float | ArrayType | None:
274
+ """
275
+ Estimates SNR from received symbols using a known reference (Data-Aided).
276
+
277
+ SNR is computed as the ratio of average signal power to noise (error)
278
+ power:
279
+ SNR_linear ≈ 1.0 / E[|rx - tx|^2]
280
+
281
+ Calculation is performed per-channel (independent gain normalization).
282
+
283
+ Parameters
284
+ ----------
285
+ rx_symbols : array_like or Signal
286
+ Received noisy symbols. Shape: (..., N_symbols).
287
+ tx_symbols : array_like or Signal
288
+ Known transmitted symbols. Shape: (..., N_symbols).
289
+
290
+ Returns
291
+ -------
292
+ float or ndarray
293
+ Estimated SNR in dB. Returns array if input is multichannel.
294
+
295
+ When ``rx_symbols`` is a :class:`Signal`, ``resolved_symbols`` is used, the
296
+ reference falls back to ``source_symbols``, and ``num_train_symbols`` leading
297
+ symbols are discarded. Frame-generated signals return ``None``.
298
+ """
299
+ if isinstance(rx_symbols, Signal):
300
+ sig = rx_symbols
301
+ if sig.signal_type is not None:
302
+ logger.warning(
303
+ "snr() called on a frame-generated signal. Extract the payload "
304
+ "segment via frame.get_structure_map() and build a plain Signal first."
305
+ )
306
+ return None
307
+ ref = tx_symbols if tx_symbols is not None else sig.source_symbols
308
+ if ref is None:
309
+ raise ValueError(
310
+ "No reference available. Provide tx_symbols or ensure "
311
+ "source_symbols is set on the Signal."
312
+ )
313
+ if sig.resolved_symbols is None:
314
+ raise ValueError(
315
+ "No resolved symbols available. Call resolve_symbols(sig) first."
316
+ )
317
+ return snr(sig.resolved_symbols, ref, num_train_symbols=num_train_symbols)
318
+
319
+ from . import helpers
320
+
321
+ if tx_symbols is None:
322
+ raise ValueError("snr() requires tx_symbols for array input.")
323
+
324
+ rx, xp, _ = dispatch(rx_symbols)
325
+ tx = xp.asarray(tx_symbols)
326
+
327
+ axis = -1
328
+
329
+ # Discard leading training symbols, clamping to the shorter sequence.
330
+ trim = num_train_symbols or 0
331
+ if trim > 0:
332
+ n = min(rx.shape[-1], tx.shape[-1])
333
+ rx = rx[..., trim:n]
334
+ tx = tx[..., trim:n]
335
+
336
+ if rx.shape != tx.shape:
337
+ raise ValueError(f"Shape mismatch: rx {rx.shape} != tx {tx.shape}")
338
+
339
+ def _is_normalized(arr, ax):
340
+ pwr = xp.mean(xp.abs(arr) ** 2, axis=ax)
341
+ return xp.allclose(pwr, 1.0, atol=1e-3)
342
+
343
+ # Normalize per channel if needed
344
+ if not _is_normalized(rx, axis):
345
+ rx = helpers.normalize(rx, axis=axis, mode="average_power")
346
+
347
+ if not _is_normalized(tx, axis):
348
+ tx = helpers.normalize(tx, axis=axis, mode="average_power")
349
+
350
+ # Check for zero power ref for SNR too
351
+ ref_pwr = xp.mean(xp.abs(tx) ** 2, axis=axis)
352
+ low_pwr_mask = ref_pwr < 1e-20
353
+
354
+ # Signal power (reference) is 1.0 per channel effectively, BUT
355
+ # if original ref was zero, SNR is 0 (-inf dB).
356
+ signal_power = 1.0
357
+
358
+ # Noise power (error)
359
+ error = rx - tx
360
+ noise_power = xp.mean(xp.abs(error) ** 2, axis=axis)
361
+
362
+ # Calculate SNR
363
+ # Handle zeros in noise_power
364
+ with np.errstate(divide="ignore"):
365
+ snr_linear = signal_power / noise_power
366
+ snr_db = 10.0 * xp.log10(snr_linear)
367
+
368
+ # Handle zero reference case
369
+ # If noise is zero, SNR is Inf (perfect signal), even if ref was zero.
370
+ # If noise is present but ref was zero, SNR is -Inf (0 linear).
371
+ zero_noise_mask = noise_power < 1e-20
372
+
373
+ if snr_db.ndim == 0:
374
+ if zero_noise_mask:
375
+ return float("inf")
376
+ if low_pwr_mask:
377
+ return float("-inf")
378
+ snr_val = float(snr_db)
379
+ logger.info("SNR: %.2f dB", snr_val)
380
+ return snr_val
381
+
382
+ # Array case: Overwrite with -inf where (low_pwr_mask AND NOT zero_noise_mask)
383
+ # Note: where zero_noise_mask is True, snr_db is already 'inf' from division
384
+ mask_to_neg_inf = low_pwr_mask & (~zero_noise_mask)
385
+ snr_db[mask_to_neg_inf] = float("-inf")
386
+
387
+ if logger.isEnabledFor(logging.INFO):
388
+ snr_db_np = to_device(snr_db, "cpu")
389
+ for ch in range(snr_db_np.shape[0]):
390
+ logger.info("SNR Ch%s: %.2f dB", ch, float(snr_db_np[ch]))
391
+
392
+ return snr_db
393
+
394
+
395
+ def ber(
396
+ bits_rx: ArrayType | Signal,
397
+ bits_tx: ArrayType | None = None,
398
+ *,
399
+ num_train_symbols: int | None = None,
400
+ ) -> float | ArrayType | None:
401
+ """
402
+ Computes the Bit Error Rate (BER) between two bit sequences.
403
+
404
+ Calculation is performed per-channel (along ``axis=-1``) to properly
405
+ handle MIMO/multichannel signals.
406
+
407
+ Parameters
408
+ ----------
409
+ bits_rx : array_like
410
+ Received bit sequence (binary 0/1). Shape: (..., N).
411
+ bits_tx : array_like
412
+ Original transmitted bit sequence (binary 0/1). Shape: (..., N).
413
+
414
+ Returns
415
+ -------
416
+ float or ndarray
417
+ BER as a ratio in [0, 1]. Scalar for 1D input, array for multichannel.
418
+
419
+ Notes
420
+ -----
421
+ The calculation is backend-agnostic and supports comparison of massive
422
+ bit sequences on both CPU and GPU.
423
+
424
+ When ``bits_rx`` is a :class:`Signal`, ``resolved_bits`` is used, the
425
+ reference falls back to ``source_bits``, and ``num_train_symbols`` (in
426
+ *symbols*, converted to bits via ``bits_per_symbol``) leading bits are
427
+ discarded. Frame-generated signals return ``None``.
428
+ """
429
+ if isinstance(bits_rx, Signal):
430
+ sig = bits_rx
431
+ if sig.signal_type is not None:
432
+ logger.warning(
433
+ "ber() called on a frame-generated signal. Extract the payload "
434
+ "segment via frame.get_structure_map() and build a plain Signal first."
435
+ )
436
+ return None
437
+ ref = bits_tx if bits_tx is not None else sig.source_bits
438
+ if ref is None:
439
+ raise ValueError(
440
+ "No reference bits available. Provide bits_tx or ensure "
441
+ "source_bits is set on the Signal."
442
+ )
443
+ if sig.resolved_bits is None:
444
+ raise ValueError(
445
+ "No resolved bits available. Call demap_symbols_hard(sig) first."
446
+ )
447
+ y = sig.resolved_bits
448
+ r = ref
449
+ trim = num_train_symbols or 0
450
+ if trim > 0 and sig.mod_order is not None:
451
+ bps = sig.bits_per_symbol
452
+ assert bps is not None
453
+ bit_trim = trim * bps
454
+ n = min(y.shape[-1], r.shape[-1])
455
+ y = y[..., bit_trim:n]
456
+ r = r[..., bit_trim:n]
457
+ return ber(y, r)
458
+
459
+ if bits_tx is None:
460
+ raise ValueError("ber() requires bits_tx for array input.")
461
+
462
+ bits_rx, xp, _ = dispatch(bits_rx)
463
+ bits_tx = xp.asarray(bits_tx)
464
+
465
+ if bits_rx.shape != bits_tx.shape:
466
+ raise ValueError(f"Shape mismatch: rx {bits_rx.shape} != tx {bits_tx.shape}")
467
+
468
+ if bits_rx.size == 0:
469
+ return 0.0
470
+
471
+ # Per-stream error count along time axis
472
+ errors = xp.sum(bits_rx != bits_tx, axis=-1)
473
+ total = bits_rx.shape[-1]
474
+ ber_values = errors / total
475
+
476
+ if ber_values.ndim == 0:
477
+ ber_value = float(ber_values)
478
+ logger.info("BER: %.2e (%s/%s errors)", ber_value, int(errors), total)
479
+ return ber_value
480
+
481
+ if logger.isEnabledFor(logging.INFO):
482
+ ber_np = to_device(ber_values, "cpu")
483
+ errors_np = to_device(errors, "cpu")
484
+ for ch in range(ber_np.shape[0]):
485
+ logger.info(
486
+ "BER Ch%s: %.2e (%s/%s errors)",
487
+ ch,
488
+ float(ber_np[ch]),
489
+ int(errors_np[ch]),
490
+ total,
491
+ )
492
+ return ber_values
493
+
494
+
495
+ def ser(
496
+ rx_symbols: ArrayType | Signal,
497
+ tx_symbols: ArrayType | None = None,
498
+ modulation: str | None = None,
499
+ order: int | None = None,
500
+ *,
501
+ num_train_symbols: int | None = None,
502
+ pmf: np.ndarray | None = None,
503
+ ) -> float | ArrayType | None:
504
+ """
505
+ Computes the Symbol Error Rate (SER) using ML hard decisions.
506
+
507
+ Each received symbol is decided to its nearest constellation point
508
+ (minimum Euclidean distance). The decision is then compared to the
509
+ corresponding transmitted symbol. SER measures symbol-level errors
510
+ before bit demapping and does not assume Gray coding, making it
511
+ distinct from BER.
512
+
513
+ Computation is fully vectorised and backend-agnostic (NumPy / CuPy).
514
+
515
+ Parameters
516
+ ----------
517
+ rx_symbols : array_like
518
+ Received (noisy) symbols. Shape: ``(..., N_symbols)``.
519
+ tx_symbols : array_like
520
+ Transmitted (ideal) symbols. Shape: ``(..., N_symbols)``.
521
+ modulation : str
522
+ Modulation type: ``"psk"``, ``"qam"``, or ``"ask"``.
523
+ order : int
524
+ Modulation order *M*.
525
+ pmf : np.ndarray, optional
526
+ Symbol PMF of shape ``(order,)`` for PS-QAM. When provided, the
527
+ comparison constellation is scaled by ``1/sqrt(E_PS)`` so the
528
+ nearest-neighbour search matches the scale of unit-avg-power
529
+ received symbols. Both ``rx_symbols`` and ``tx_symbols`` are
530
+ decided against the same scaled constellation. Has no effect for
531
+ uniform modulations.
532
+
533
+ Returns
534
+ -------
535
+ float or ndarray
536
+ SER as a ratio in ``[0, 1]``. Scalar for SISO input,
537
+ array of shape ``(N_ch,)`` for MIMO input.
538
+
539
+ Notes
540
+ -----
541
+ Both ``rx_symbols`` and ``tx_symbols`` are decided against the same
542
+ constellation so the function is robust to any floating-point rounding
543
+ in the reference values.
544
+
545
+ When ``rx_symbols`` is a :class:`Signal`, ``resolved_symbols`` is used, the
546
+ reference falls back to ``source_symbols``, ``modulation``/``order``/``pmf``
547
+ fall back to the signal's metadata, and ``num_train_symbols`` leading
548
+ symbols are discarded. Frame-generated signals return ``None``.
549
+ """
550
+ if isinstance(rx_symbols, Signal):
551
+ sig = rx_symbols
552
+ if sig.signal_type is not None:
553
+ logger.warning(
554
+ "ser() called on a frame-generated signal. Extract the payload "
555
+ "segment via frame.get_structure_map() and build a plain Signal first."
556
+ )
557
+ return None
558
+ ref = tx_symbols if tx_symbols is not None else sig.source_symbols
559
+ if ref is None:
560
+ raise ValueError(
561
+ "No reference available. Provide tx_symbols or ensure "
562
+ "source_symbols is set on the Signal."
563
+ )
564
+ if sig.resolved_symbols is None:
565
+ raise ValueError(
566
+ "No resolved symbols available. Call resolve_symbols(sig) first."
567
+ )
568
+ mod = modulation or sig.mod_scheme
569
+ ord_ = order or sig.mod_order
570
+ if mod is None or ord_ is None:
571
+ raise ValueError(
572
+ "SER requires modulation and order. Pass them explicitly or ensure "
573
+ "mod_scheme/mod_order are set on the Signal."
574
+ )
575
+ return ser(
576
+ sig.resolved_symbols,
577
+ ref,
578
+ mod,
579
+ ord_,
580
+ num_train_symbols=num_train_symbols,
581
+ pmf=sig.ps_pmf if pmf is None else pmf,
582
+ )
583
+
584
+ if modulation is None or order is None:
585
+ raise ValueError("ser() requires modulation and order for array input.")
586
+ if tx_symbols is None:
587
+ raise ValueError("ser() requires tx_symbols for array input.")
588
+
589
+ from .mapping import Constellation
590
+
591
+ rx, xp, _ = dispatch(rx_symbols)
592
+ tx = xp.asarray(tx_symbols)
593
+
594
+ trim = num_train_symbols or 0
595
+ if trim > 0:
596
+ n = min(rx.shape[-1], tx.shape[-1])
597
+ rx = rx[..., trim:n]
598
+ tx = tx[..., trim:n]
599
+
600
+ if rx.shape != tx.shape:
601
+ raise ValueError(f"Shape mismatch: rx {rx.shape} != tx {tx.shape}")
602
+
603
+ c = Constellation.gray(modulation, order, pmf=pmf)
604
+ constellation_np = c.points
605
+ constellation = xp.asarray(constellation_np) # (M,)
606
+
607
+ # PS-QAM: ``rx_symbols`` from ``resolved_symbols``
608
+ # are normalised to unit average power, placing them on the
609
+ # ``{s_m/sqrt(E_PS)}`` grid. ``tx_symbols`` (``source_symbols``) live on
610
+ # the un-rescaled ``{s_m}`` grid (their average power is ``E_PS < 1``).
611
+ # Rescale ``rx`` by ``sqrt(E_PS)`` so the nearest-neighbour search against
612
+ # ``gray_constellation`` is correct for both rx and tx. Has no effect on
613
+ # uniform modulations.
614
+ if pmf is not None:
615
+ e_ps = c.power()
616
+ if e_ps < 1.0 - 1e-6:
617
+ rx = rx * xp.asarray(np.sqrt(e_ps), dtype=rx.real.dtype)
618
+
619
+ # Broadcast: rx/tx (..., N) -> (..., N, 1) vs constellation (M,) -> (..., N, M)
620
+ dist_rx = xp.abs(rx[..., None] - constellation) ** 2 # (..., N, M)
621
+ dist_tx = xp.abs(tx[..., None] - constellation) ** 2 # (..., N, M)
622
+
623
+ dec_rx = xp.argmin(dist_rx, axis=-1) # (..., N) - index into constellation
624
+ dec_tx = xp.argmin(dist_tx, axis=-1) # (..., N)
625
+
626
+ errors = xp.sum(dec_rx != dec_tx, axis=-1)
627
+ total = rx.shape[-1]
628
+ ser_values = errors / total
629
+
630
+ if ser_values.ndim == 0:
631
+ ser_val = float(ser_values)
632
+ logger.info("SER: %.2e (%s/%s errors)", ser_val, int(errors), total)
633
+ return ser_val
634
+
635
+ if logger.isEnabledFor(logging.INFO):
636
+ ser_np = to_device(ser_values, "cpu")
637
+ errors_np = to_device(errors, "cpu")
638
+ for ch in range(ser_np.shape[0]):
639
+ logger.info(
640
+ "SER Ch%s: %.2e (%s/%s errors)",
641
+ ch,
642
+ float(ser_np[ch]),
643
+ int(errors_np[ch]),
644
+ total,
645
+ )
646
+ return ser_values
647
+
648
+
649
+ def gmi(
650
+ llrs: ArrayType | Signal,
651
+ tx_bits: ArrayType | None = None,
652
+ *,
653
+ noise_var: float | None = None,
654
+ modulation: str | None = None,
655
+ order: int | None = None,
656
+ pmf: np.ndarray | None = None,
657
+ method: str = "maxlog",
658
+ ) -> float:
659
+ r"""
660
+ Computes the Generalised Mutual Information (GMI) from per-bit LLRs.
661
+
662
+ GMI is the achievable information rate under bit-interleaved coded
663
+ modulation (BICM) and is the standard capacity metric for coded optical /
664
+ wireless systems. It uses pre-computed LLRs so the modulation format
665
+ does not need to be known by this function.
666
+
667
+ GMI = sum_{b=0..k-1} ( 1 - (1/N) * sum_{n=1..N} log2(1 + exp(-LLR_b[n] * (1 - 2 * c_b[n]))) )
668
+
669
+ where LLR_b[n] is the LLR for bit b of symbol n, and c_b[n] (0 or 1) is
670
+ the transmitted bit.
671
+
672
+ The per-term softplus is computed in a numerically stable form:
673
+
674
+ log2(1 + exp(-x)) = (1 / ln 2) * (log(1 + exp(-|x|)) + max(0, -x))
675
+
676
+ which avoids overflow for large |x|.
677
+
678
+ Parameters
679
+ ----------
680
+ llrs : array_like
681
+ Per-bit LLRs. Accepted shapes:
682
+
683
+ * ``(N * k,)`` - flat, all bits concatenated.
684
+ * ``(N, k)`` - symbols along axis 0, bits along axis 1.
685
+
686
+ where *N* is the number of symbols and *k = log₂(M)* bits per symbol.
687
+ Positive LLR -> bit 0 more likely; negative -> bit 1 more likely.
688
+ LLRs from ``compute_llr`` match this convention.
689
+ tx_bits : array_like
690
+ Transmitted bits (0/1 integers), same shape as ``llrs``.
691
+
692
+ Returns
693
+ -------
694
+ float
695
+ GMI in bits per channel use (b/cu). In the range ``[0, log₂(M)]``;
696
+ equals ``log₂(M)`` at infinite SNR and approaches 0 at very low SNR.
697
+
698
+ Examples
699
+ --------
700
+ >>> llrs = compute_llr(rx_symbols, "qam", 16, noise_var=0.1, output='numpy')
701
+ >>> gmi_value = gmi(llrs, tx_bits)
702
+ >>> gmi_value = gmi(sig, noise_var=0.1) # Signal input: LLRs computed
703
+
704
+ When the first argument is a :class:`Signal`, LLRs are computed internally
705
+ from ``resolved_symbols`` (applying the PS-QAM scale correction, see
706
+ :func:`_ps_unit_power_rescale`) via ``compute_llr`` with ``noise_var``/
707
+ ``method``/``pmf``, and GMI is evaluated against ``source_bits``.
708
+ """
709
+ if isinstance(llrs, Signal):
710
+ sig = llrs
711
+ if sig.resolved_symbols is None:
712
+ raise ValueError(
713
+ "No resolved symbols available. Call resolve_symbols(sig) first."
714
+ )
715
+ if sig.source_bits is None:
716
+ raise ValueError(
717
+ "GMI requires source_bits. Ensure the Signal was created via a "
718
+ "factory (e.g. generate_qam(), generate_psqam())."
719
+ )
720
+ mod = modulation or sig.mod_scheme
721
+ ord_ = order or sig.mod_order
722
+ if mod is None or ord_ is None:
723
+ raise ValueError(
724
+ "GMI requires modulation and order. Ensure mod_scheme/mod_order "
725
+ "are set on the Signal."
726
+ )
727
+ if noise_var is None:
728
+ raise ValueError("gmi() requires noise_var.")
729
+
730
+ from .mapping import compute_llr
731
+
732
+ eff_pmf = sig.ps_pmf if pmf is None else pmf
733
+ rx, xp_, _ = dispatch(sig.resolved_symbols)
734
+ resolved, adj_noise_var = _ps_unit_power_rescale(
735
+ rx, xp_, mod, ord_, eff_pmf, noise_var
736
+ )
737
+ computed = compute_llr(
738
+ resolved,
739
+ mod,
740
+ ord_,
741
+ adj_noise_var,
742
+ method=method,
743
+ pmf=eff_pmf,
744
+ output="numpy",
745
+ )
746
+ src_bits = to_device(sig.source_bits, "cpu")
747
+ # compute_llr outputs (N*k,) / (C, N*k); reshape to (N_total, k) so the
748
+ # array core infers bits-per-symbol from the trailing axis.
749
+ k_bits = int(np.log2(ord_))
750
+ llrs_arr = np.asarray(computed)
751
+ bits_arr = np.asarray(src_bits)
752
+ n_bits = llrs_arr.size
753
+ return gmi(
754
+ llrs_arr.reshape(n_bits // k_bits, k_bits),
755
+ bits_arr.reshape(n_bits // k_bits, k_bits),
756
+ )
757
+
758
+ if tx_bits is None:
759
+ raise ValueError("gmi() requires tx_bits for array (LLR) input.")
760
+
761
+ llrs_arr, xp, _ = dispatch(llrs)
762
+ llrs_arr = llrs_arr.astype(xp.float64)
763
+ bits_arr = xp.asarray(tx_bits, dtype=xp.float64)
764
+
765
+ # Detect k (bits per symbol) from 2D shape before flattening.
766
+ # For (N, k) input: sum over k bit positions -> GMI ∈ [0, k].
767
+ # For 1D (N·k,) input: k=1, returns per-bit GMI ∈ [0, 1].
768
+ k = llrs_arr.shape[1] if llrs_arr.ndim == 2 else 1
769
+
770
+ llrs_flat = llrs_arr.ravel()
771
+ bits_flat = bits_arr.ravel()
772
+
773
+ if llrs_flat.shape != bits_flat.shape:
774
+ raise ValueError(
775
+ f"llrs and tx_bits must have the same number of elements. "
776
+ f"Got {llrs_flat.shape} vs {bits_flat.shape}."
777
+ )
778
+
779
+ # Signed LLR: positive when transmitted bit is 0, negative when bit is 1.
780
+ # The argument to log2(1 + exp(·)) is: -LLR * (1 - 2*bit)
781
+ # = -LLR when bit=0 (should be positive -> small loss)
782
+ # = +LLR when bit=1 (should be negative -> small loss at high SNR)
783
+ x = -llrs_flat * (1.0 - 2.0 * bits_flat)
784
+
785
+ # Numerically stable softplus: log2(1 + exp(x)) = log1p(exp(-|x|))/ln2 + max(0,x)/ln2
786
+ ln2 = xp.log(xp.asarray(2.0, dtype=xp.float64))
787
+ softplus = (
788
+ xp.log1p(xp.exp(-xp.abs(x))) + xp.maximum(xp.asarray(0.0, dtype=xp.float64), x)
789
+ ) / ln2
790
+
791
+ # GMI = Σ_{b=0}^{k-1} (1 - mean_n[softplus_b]) = k * (1 - mean_all[softplus])
792
+ gmi_value = float(k * (1.0 - xp.mean(softplus)))
793
+ logger.info("GMI: %.4f b/cu", gmi_value)
794
+ return gmi_value
795
+
796
+
797
+ def mi(
798
+ symbols_rx: ArrayType | Signal,
799
+ modulation: str | None = None,
800
+ order: int | None = None,
801
+ noise_var: float | None = None,
802
+ *,
803
+ pmf: np.ndarray | None = None,
804
+ rescale_unit_power: bool = False,
805
+ ) -> float:
806
+ r"""
807
+ Estimates the Mutual Information (MI) under a Gaussian channel assumption.
808
+
809
+ MI is the Shannon capacity of the discrete-input continuous-output (DCMC)
810
+ AWGN channel. It upper-bounds GMI and is achievable with optimal
811
+ (non-binary) coding.
812
+
813
+ Uses the Monte-Carlo estimator with Gaussian likelihoods:
814
+
815
+ p(s_m | r) is proportional to exp(-|r - s_m|^2 / sigma^2)
816
+
817
+ MI = log2(M) + (1/N) * sum_{n=1..N} sum_{m=1..M} ( p(s_m | r_n) * log2(p(s_m | r_n)) )
818
+
819
+ Computed via the log-sum-exp trick for numerical stability.
820
+
821
+ Parameters
822
+ ----------
823
+ symbols_rx : array_like
824
+ Received noisy symbols. Shape: ``(N,)``.
825
+ modulation : str
826
+ Modulation type: ``'psk'``, ``'qam'``, or ``'ask'``.
827
+ order : int
828
+ Modulation order M.
829
+ noise_var : float
830
+ Complex noise variance sigma^2 of the AWGN model
831
+ **referenced to the normalised constellation** (unit average power
832
+ under the uniform distribution, i.e. the same scale as
833
+ ``gray_constellation``).
834
+ For unit-power symbols at Es/N0 (dB):
835
+ sigma^2 = 10^(-Es/N0 / 10).
836
+ pmf : np.ndarray, optional
837
+ Symbol PMF of shape ``(M,)`` for PS-QAM. When provided, the
838
+ non-uniform prior ``P(sₘ)`` is incorporated into the likelihood
839
+ computation and the result is clipped to ``[0, H(X)]`` instead of
840
+ ``[0, log₂M]``. Pass ``signal.ps_pmf`` for PS-QAM signals.
841
+ ``None`` (default) assumes uniform prior.
842
+ rescale_unit_power : bool, default False
843
+ When ``True`` and ``pmf`` is given, rescale ``symbols_rx`` from the
844
+ unit-average-power grid ``{c·s_m}`` back to ``{s_m}`` (and
845
+ ``noise_var`` by ``E_PS``) before estimation - required when the input
846
+ comes from ``resolve_symbols``. See :func:`_ps_unit_power_rescale`.
847
+ The Signal dispatch path sets this automatically.
848
+
849
+ Returns
850
+ -------
851
+ float
852
+ MI in bits per channel use (b/cu). In the range ``[0, H(X)]``
853
+ where ``H(X) = log₂M`` for uniform and ``H(X) < log₂M`` for PS.
854
+
855
+ Warning - PS-QAM scale convention: ``symbols_rx`` must be on the same
856
+ scale as ``gray_constellation`` (unit average power under the uniform
857
+ distribution). Samples from ``apply_awgn`` are already on the correct
858
+ scale. However, after ``resolve_symbols`` the receiver re-normalises,
859
+ shifting PS symbols from {s_m} to {c * s_m} (c = 1 / sqrt(E_PS)). Either
860
+ pass a :class:`Signal` (the scale correction is applied automatically) or
861
+ set ``rescale_unit_power=True`` when passing ``resolved_symbols`` arrays.
862
+
863
+ Examples
864
+ --------
865
+ >>> mi_value = mi(rx_symbols, "qam", 64, noise_var=0.05)
866
+ >>> mi_value = mi(sig, noise_var=0.05) # Signal input
867
+ """
868
+ if isinstance(symbols_rx, Signal):
869
+ sig = symbols_rx
870
+ if sig.resolved_symbols is None:
871
+ raise ValueError(
872
+ "No resolved symbols available. Call resolve_symbols(sig) first."
873
+ )
874
+ mod = modulation or sig.mod_scheme
875
+ ord_ = order or sig.mod_order
876
+ if mod is None or ord_ is None:
877
+ raise ValueError(
878
+ "MI requires modulation and order. Ensure mod_scheme/mod_order "
879
+ "are set on the Signal."
880
+ )
881
+ if noise_var is None:
882
+ raise ValueError("mi() requires noise_var.")
883
+ return mi(
884
+ sig.resolved_symbols,
885
+ mod,
886
+ ord_,
887
+ noise_var,
888
+ pmf=sig.ps_pmf if pmf is None else pmf,
889
+ rescale_unit_power=True,
890
+ )
891
+
892
+ if modulation is None or order is None:
893
+ raise ValueError("mi() requires modulation and order for array input.")
894
+ if noise_var is None:
895
+ raise ValueError("mi() requires noise_var.")
896
+
897
+ from .mapping import gray_constellation
898
+
899
+ rx, xp, _ = dispatch(symbols_rx)
900
+ rx = rx.ravel().astype(xp.complex128) # (N,)
901
+ if rescale_unit_power:
902
+ rx, noise_var = _ps_unit_power_rescale(
903
+ rx, xp, modulation, order, pmf, noise_var
904
+ )
905
+ constellation = xp.asarray(
906
+ gray_constellation(modulation, order), dtype=xp.complex128
907
+ ) # (M,)
908
+ M = len(constellation)
909
+ ln2 = float(xp.log(xp.asarray(2.0, dtype=xp.float64)))
910
+ log2_M = float(xp.log2(xp.asarray(float(M), dtype=xp.float64)))
911
+
912
+ # Prior: uniform or PS-shaped
913
+ if pmf is not None:
914
+ pmf_arr = np.asarray(pmf, dtype=np.float64).clip(1e-300, None)
915
+ log_prior = xp.asarray(np.log(pmf_arr), dtype=xp.float64) # (M,) nats
916
+ nz = pmf_arr > 0
917
+ h_x_bits = float(-np.sum(pmf_arr[nz] * np.log2(pmf_arr[nz])))
918
+ else:
919
+ log_prior = xp.full(
920
+ M, -xp.log(xp.asarray(float(M), dtype=xp.float64)), dtype=xp.float64
921
+ ) # log(1/M)
922
+ h_x_bits = log2_M
923
+
924
+ # Log-joint: log P(sₘ) + log p(r | sₘ) (drop noise-normalisation constant)
925
+ # Chunk over N to bound the (chunk, M) complex128/float64 intermediates -
926
+ # a full-N allocation is ~4 GB at N=1e6, M=256. The per-row math is
927
+ # unchanged; chunk sums accumulate on device (single sync at the end).
928
+ N_sym = rx.shape[0]
929
+ CHUNK_N = 65536
930
+ acc = xp.zeros((), dtype=xp.float64)
931
+ for n0 in range(0, N_sym, CHUNK_N):
932
+ rx_c = rx[n0 : n0 + CHUNK_N]
933
+ diff = rx_c[:, None] - constellation[None, :] # (chunk, M)
934
+ log_liks = log_prior[None, :] - (diff.real**2 + diff.imag**2) / noise_var
935
+
936
+ # log p(s_m | r) via log-sum-exp for numerical stability
937
+ lse_shift = log_liks.max(axis=1, keepdims=True) # (chunk, 1)
938
+ log_sum = xp.log(xp.sum(xp.exp(log_liks - lse_shift), axis=1)) + lse_shift[:, 0]
939
+
940
+ log_posterior = log_liks - log_sum[:, None] # (chunk, M), log base e
941
+ posterior = xp.exp(log_posterior) # (chunk, M), sum-to-1 per row
942
+
943
+ # MI = H(X) + (1/N) Σ_n Σ_m p(s_m|r_n) · log2 p(s_m|r_n)
944
+ acc += xp.sum(posterior * log_posterior)
945
+ mi_nats = float(acc) / N_sym
946
+ mi_value = float(np.clip(h_x_bits + mi_nats / ln2, 0.0, h_x_bits))
947
+
948
+ logger.info("MI: %.4f b/cu (max %.2f b/cu)", mi_value, h_x_bits)
949
+ return mi_value