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
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
|