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/helpers.py
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
"""General library utility functions."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from .backend import ArrayType, dispatch, get_array_module, is_cupy_available, to_device
|
|
8
|
+
from .logger import logger
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import cupy as cp
|
|
12
|
+
except ImportError:
|
|
13
|
+
cp = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def generate_bits(length: int, seed: int | None = None) -> ArrayType:
|
|
17
|
+
"""
|
|
18
|
+
Generates a sequence of random binary bits (0s and 1s).
|
|
19
|
+
|
|
20
|
+
Uses `numpy.random.default_rng()` for consistent seed behavior across
|
|
21
|
+
different platforms and backends.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
length : int
|
|
26
|
+
Total number of bits to generate.
|
|
27
|
+
seed : int, optional
|
|
28
|
+
Random seed for reproducibility.
|
|
29
|
+
|
|
30
|
+
Returns
|
|
31
|
+
-------
|
|
32
|
+
array_like
|
|
33
|
+
Array of bits (0 or 1). Shape: (length,).
|
|
34
|
+
Data type is `int8`.
|
|
35
|
+
"""
|
|
36
|
+
logger.debug("Generating %s random bits (seed=%s).", length, seed)
|
|
37
|
+
rng = np.random.default_rng(seed)
|
|
38
|
+
bits = rng.integers(0, 2, size=length, dtype="int8")
|
|
39
|
+
|
|
40
|
+
if is_cupy_available():
|
|
41
|
+
bits = to_device(bits, "gpu")
|
|
42
|
+
|
|
43
|
+
return bits
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def generate_symbols(
|
|
47
|
+
num_symbols: int,
|
|
48
|
+
modulation: str,
|
|
49
|
+
order: int,
|
|
50
|
+
seed: int | None = None,
|
|
51
|
+
unipolar: bool = False,
|
|
52
|
+
) -> ArrayType:
|
|
53
|
+
"""
|
|
54
|
+
Generates a sequence of random modulation symbols.
|
|
55
|
+
|
|
56
|
+
This is a high-level utility that combines bit generation and mapping
|
|
57
|
+
to produce synthetic symbol sequences.
|
|
58
|
+
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
num_symbols : int
|
|
62
|
+
Number of symbols to generate.
|
|
63
|
+
modulation : {"psk", "qam", "ask"}
|
|
64
|
+
The modulation scheme identifier.
|
|
65
|
+
order : int
|
|
66
|
+
Modulation order (e.g., 4, 16, 64).
|
|
67
|
+
seed : int, optional
|
|
68
|
+
Random seed for reproducible results.
|
|
69
|
+
unipolar : bool, default False
|
|
70
|
+
If True, use unipolar constellation (ASK/PAM).
|
|
71
|
+
|
|
72
|
+
Returns
|
|
73
|
+
-------
|
|
74
|
+
array_like
|
|
75
|
+
Array of symbols on the active device (CPU or GPU).
|
|
76
|
+
Dtype is ``complex64`` for PSK/QAM, ``float32`` for ASK/PAM.
|
|
77
|
+
"""
|
|
78
|
+
from . import mapping
|
|
79
|
+
|
|
80
|
+
k = int(np.log2(order))
|
|
81
|
+
bits = generate_bits(num_symbols * k, seed=seed)
|
|
82
|
+
return mapping.map_bits(bits, modulation, order, unipolar=unipolar)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def rms(x: ArrayType, axis: int | None = None, keepdims: bool = False) -> ArrayType:
|
|
86
|
+
"""
|
|
87
|
+
Computes the Root-Mean-Square (RMS) value of an array.
|
|
88
|
+
|
|
89
|
+
RMS is defined as: sqrt(E[|x|^2]).
|
|
90
|
+
|
|
91
|
+
Parameters
|
|
92
|
+
----------
|
|
93
|
+
x : array_like
|
|
94
|
+
Input array.
|
|
95
|
+
axis : int, optional
|
|
96
|
+
Axis along which to compute the RMS. If None, computes global RMS.
|
|
97
|
+
keepdims : bool, default False
|
|
98
|
+
If True, the reduced axes are left in the result as dimensions with size one.
|
|
99
|
+
|
|
100
|
+
Returns
|
|
101
|
+
-------
|
|
102
|
+
array_like or float
|
|
103
|
+
The RMS value of the input.
|
|
104
|
+
"""
|
|
105
|
+
x, xp, _ = dispatch(x)
|
|
106
|
+
# RMS = ||x||₂ / √N -> linalg.norm routes through BLAS (DZNRM2/SNRM2),
|
|
107
|
+
# eliminating the abs(x)**2 and mean() intermediate allocations.
|
|
108
|
+
n = x.size if axis is None else x.shape[axis]
|
|
109
|
+
# xp.sqrt(Python int) returns float64; cast n to x's real dtype so that
|
|
110
|
+
# float32 norms are not silently promoted to float64.
|
|
111
|
+
return xp.linalg.norm(x, axis=axis, keepdims=keepdims) / xp.sqrt(
|
|
112
|
+
xp.asarray(n, dtype=x.real.dtype)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def normalize(
|
|
117
|
+
x: ArrayType, mode: str = "unity_gain", axis: int | None = None, sps: int = 1
|
|
118
|
+
) -> ArrayType:
|
|
119
|
+
"""
|
|
120
|
+
Normalizes an array according to the specified strategy.
|
|
121
|
+
|
|
122
|
+
Parameters
|
|
123
|
+
----------
|
|
124
|
+
x : array_like
|
|
125
|
+
Input signal or filter taps.
|
|
126
|
+
mode : {"unity_gain", "unit_energy", "peak", "average_power", "symbol_power"}, default "unity_gain"
|
|
127
|
+
Normalization strategy:
|
|
128
|
+
- "unity_gain": Sum of elements is 1.0 (DC gain normalization).
|
|
129
|
+
Preserves signal levels (e.g., 5V -> 5V). Used for general filters.
|
|
130
|
+
- "unit_energy": L2-norm is 1.0 (sum(|x|^2) = 1).
|
|
131
|
+
Preserves total energy/noise power. Used for pulse shaping and matched filters.
|
|
132
|
+
- "peak": Peak complex envelope is 1.0 (max_n |x[n]| = 1).
|
|
133
|
+
For complex signals this normalizes by the maximum instantaneous magnitude,
|
|
134
|
+
so |x[n]| <= 1 for all n. This bound is invariant under any
|
|
135
|
+
unit-magnitude operation (frequency shifts, phase rotations, equalization),
|
|
136
|
+
making it the correct choice for DSP chains. For real signals the behavior
|
|
137
|
+
is identical: max_n |x[n]| = 1.
|
|
138
|
+
- "average_power": Mean sample power is 1.0 (E[|x|^2] = 1 per sample).
|
|
139
|
+
Normalizes the composite complex signal power at the sample level.
|
|
140
|
+
Used for symbol constellations at 1 sps and for display/plotting.
|
|
141
|
+
**Not suitable for oversampled waveforms**: for a Nyquist pulse with
|
|
142
|
+
unit-energy taps at ``sps`` samples/symbol the natural average sample
|
|
143
|
+
power is ``Es/sps``, so ``"average_power"`` would inflate all samples
|
|
144
|
+
by ``√sps`` and break Es/N0 calibration.
|
|
145
|
+
- "symbol_power": Unit symbol energy regardless of oversampling factor.
|
|
146
|
+
Norm factor is ``rms(x) * √sps``, so the output satisfies
|
|
147
|
+
``E[|x|²] * sps = 1`` (i.e. average sample power = 1/sps).
|
|
148
|
+
This is the correct mode for pulse-shaped waveforms: all pulse types
|
|
149
|
+
(zero-stuffed, rect, RRC, Gaussian, ...) end up at the same power level
|
|
150
|
+
and ``apply_awgn`` can use ``Es = signal_power * sps = 1`` directly.
|
|
151
|
+
Requires ``sps`` parameter. At ``sps=1`` it is identical to
|
|
152
|
+
``"average_power"``.
|
|
153
|
+
axis : int, optional
|
|
154
|
+
The axis along which to compute the normalization factor.
|
|
155
|
+
If `None`, normalizes the entire array globally.
|
|
156
|
+
sps : int, default 1
|
|
157
|
+
Samples per symbol. Only used by the ``"symbol_power"`` mode.
|
|
158
|
+
|
|
159
|
+
Returns
|
|
160
|
+
-------
|
|
161
|
+
array_like
|
|
162
|
+
The normalized array.
|
|
163
|
+
"""
|
|
164
|
+
logger.debug("Normalizing array (mode: %s, axis=%s, sps=%s).", mode, axis, sps)
|
|
165
|
+
x, xp, _ = dispatch(x)
|
|
166
|
+
|
|
167
|
+
# keepdims for proper broadcasting when axis is specified
|
|
168
|
+
keepdims = axis is not None
|
|
169
|
+
|
|
170
|
+
if mode == "unity_gain":
|
|
171
|
+
# DC gain = 1: H(0) = sum(h) = 1
|
|
172
|
+
# Use case: filter taps where you want unity passband gain
|
|
173
|
+
norm_factor = xp.sum(x, axis=axis, keepdims=keepdims)
|
|
174
|
+
|
|
175
|
+
elif mode == "unit_energy":
|
|
176
|
+
# L2 norm = 1: ||x||₂ = 1
|
|
177
|
+
# Use case: matched filter taps (preserves SNR after correlation)
|
|
178
|
+
# linalg.norm routes through BLAS (DNRM2/DZNRM2 on CPU, cuBLAS on GPU):
|
|
179
|
+
# numerically superior (compensated summation) and avoids intermediate allocations.
|
|
180
|
+
norm_factor = xp.linalg.norm(x, axis=axis, keepdims=keepdims)
|
|
181
|
+
|
|
182
|
+
elif mode == "peak":
|
|
183
|
+
# Complex envelope peak: max(|x[n]|) = 1.
|
|
184
|
+
# For complex signals this is the instantaneous magnitude, not the
|
|
185
|
+
# per-component max. The bound is invariant under frequency shifts and
|
|
186
|
+
# phase rotations, unlike per-component (I/Q) normalization which can
|
|
187
|
+
# allow |x[n]| up to sqrt(2) and therefore violate bounds after rotation.
|
|
188
|
+
norm_factor = xp.max(xp.abs(x), axis=axis, keepdims=keepdims)
|
|
189
|
+
|
|
190
|
+
elif mode == "average_power":
|
|
191
|
+
# RMS = 1: sqrt(mean(|x|²)) = 1, so mean(|x|²) = 1
|
|
192
|
+
# Use case: 1-sps symbol sequences and constellation normalization.
|
|
193
|
+
norm_factor = rms(x, axis=axis, keepdims=keepdims)
|
|
194
|
+
|
|
195
|
+
elif mode == "symbol_power":
|
|
196
|
+
# Symbol-power norm: rms(x) * √sps = 1 -> mean(|x|²) * sps = 1
|
|
197
|
+
# Equivalent to average_power at 1 sps; at higher sps it accounts for
|
|
198
|
+
# the 1/sps dilution produced by Nyquist pulse shaping with unit-energy
|
|
199
|
+
# taps, leaving Es = 1 per symbol for all pulse shapes.
|
|
200
|
+
# This is the same correction used in the equalizer's _normalize_inputs:
|
|
201
|
+
# sym_rms = global_rms * √sps
|
|
202
|
+
norm_factor = rms(x, axis=axis, keepdims=keepdims) * xp.asarray(
|
|
203
|
+
sps**0.5, dtype=x.real.dtype
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
else:
|
|
207
|
+
raise ValueError(f"Unknown normalization mode: {mode}")
|
|
208
|
+
|
|
209
|
+
# Handle division by zero safely for both NumPy and CuPy.
|
|
210
|
+
# Avoid control flow based on data values to prevent host-device synchronization.
|
|
211
|
+
# Use ones_like instead of the literal 1.0 (float64) to preserve float32 dtype.
|
|
212
|
+
safe_norm = xp.where(norm_factor == 0, xp.ones_like(norm_factor), norm_factor)
|
|
213
|
+
result = x / safe_norm
|
|
214
|
+
|
|
215
|
+
# If norm_factor is 0, the input was all zeros -> output should also be zeros
|
|
216
|
+
return xp.where(norm_factor == 0, xp.zeros(x.shape, dtype=x.dtype), result)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def format_si(value: float | None, unit: str = "Hz") -> str:
|
|
220
|
+
"""
|
|
221
|
+
Formats a numeric value into a human-readable string with SI prefixes.
|
|
222
|
+
|
|
223
|
+
Automatically selects the appropriate SI prefix (e.g., k, M, G, m, u, n)
|
|
224
|
+
based on the magnitude of the value. Supports a wide range from
|
|
225
|
+
femto (10^-15) to Peta (10^15).
|
|
226
|
+
|
|
227
|
+
Parameters
|
|
228
|
+
----------
|
|
229
|
+
value : float or None
|
|
230
|
+
The numeric value to format. If `None`, returns "None".
|
|
231
|
+
unit : str, default "Hz"
|
|
232
|
+
The unit suffix to append (e.g., 'Hz', 'Baud', 's', 'W').
|
|
233
|
+
|
|
234
|
+
Returns
|
|
235
|
+
-------
|
|
236
|
+
str
|
|
237
|
+
The formatted string (e.g., '10.00 MHz', '50.00 ns').
|
|
238
|
+
"""
|
|
239
|
+
if value is None:
|
|
240
|
+
return "None"
|
|
241
|
+
|
|
242
|
+
if abs(value) == 0:
|
|
243
|
+
return f"0.00 {unit}"
|
|
244
|
+
|
|
245
|
+
# Standard SI prefixes
|
|
246
|
+
si_units = {
|
|
247
|
+
-5: "f",
|
|
248
|
+
-4: "p",
|
|
249
|
+
-3: "n",
|
|
250
|
+
-2: "µ",
|
|
251
|
+
-1: "m",
|
|
252
|
+
0: "",
|
|
253
|
+
1: "k",
|
|
254
|
+
2: "M",
|
|
255
|
+
3: "G",
|
|
256
|
+
4: "T",
|
|
257
|
+
5: "P",
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
rank = int(np.floor(np.log10(abs(value)) / 3))
|
|
261
|
+
# clamp to supported range
|
|
262
|
+
rank = max(min(si_units.keys()), min(rank, max(si_units.keys())))
|
|
263
|
+
|
|
264
|
+
scaled = value / (1000.0**rank)
|
|
265
|
+
return f"{scaled:.2f} {si_units[rank]}{unit}"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def validate_array(
|
|
269
|
+
v: Any, name: str = "array", complex_only: bool = False
|
|
270
|
+
) -> ArrayType:
|
|
271
|
+
"""
|
|
272
|
+
Validates and coerces input data into a numeric array.
|
|
273
|
+
|
|
274
|
+
Existing NumPy or CuPy arrays are passed through unchanged (preserving
|
|
275
|
+
device placement). All other inputs (Python scalars, lists, tuples) are
|
|
276
|
+
coerced to NumPy via ``np.asarray``; there is no automatic promotion to
|
|
277
|
+
CuPy for non-array inputs. Optionally enforces complex-valued dtype.
|
|
278
|
+
|
|
279
|
+
Parameters
|
|
280
|
+
----------
|
|
281
|
+
v : array_like or any
|
|
282
|
+
Input data to validate.
|
|
283
|
+
name : str, default "array"
|
|
284
|
+
Variable name used in error messages.
|
|
285
|
+
complex_only : bool, default False
|
|
286
|
+
If True, ensures the resulting array is complex-valued.
|
|
287
|
+
|
|
288
|
+
Returns
|
|
289
|
+
-------
|
|
290
|
+
array_like
|
|
291
|
+
NumPy or CuPy array (CuPy only when ``v`` was already a CuPy array).
|
|
292
|
+
|
|
293
|
+
Raises
|
|
294
|
+
------
|
|
295
|
+
ValueError
|
|
296
|
+
If the input cannot be converted to a supported array type.
|
|
297
|
+
"""
|
|
298
|
+
if v is None:
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
# Coerce lists/tuples or other array-likes to numpy arrays initially
|
|
302
|
+
if not isinstance(v, (np.ndarray, getattr(cp, "ndarray", type(None)))):
|
|
303
|
+
try:
|
|
304
|
+
v = np.asarray(v)
|
|
305
|
+
except Exception as err:
|
|
306
|
+
raise ValueError(
|
|
307
|
+
f"Could not convert {name} of type {type(v)} to array."
|
|
308
|
+
) from err
|
|
309
|
+
|
|
310
|
+
# Ensure it's a numeric array (not object, string, etc.)
|
|
311
|
+
if v.dtype.kind not in "biufc":
|
|
312
|
+
raise ValueError(
|
|
313
|
+
f"Expected numeric array for {name}, got dtype {v.dtype} (kind {v.dtype.kind})"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
if complex_only and not np.iscomplexobj(v):
|
|
317
|
+
xp = get_array_module(v)
|
|
318
|
+
# Preserve single-precision: float32 -> complex64, everything else -> complex128
|
|
319
|
+
complex_dtype = xp.complex64 if v.dtype == xp.float32 else xp.complex128
|
|
320
|
+
v = v.astype(complex_dtype)
|
|
321
|
+
|
|
322
|
+
return v
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def cross_correlate_fft(
|
|
326
|
+
samples: ArrayType,
|
|
327
|
+
template: ArrayType,
|
|
328
|
+
mode: str = "full",
|
|
329
|
+
) -> ArrayType:
|
|
330
|
+
"""
|
|
331
|
+
Vectorized FFT-based cross-correlation.
|
|
332
|
+
|
|
333
|
+
Computes the cross-correlation of ``samples`` with ``template`` using
|
|
334
|
+
the frequency-domain multiplication approach. Handles 1D and 2D
|
|
335
|
+
(multichannel) inputs natively via ``axis=-1`` broadcasting - no
|
|
336
|
+
Python loops over channels.
|
|
337
|
+
|
|
338
|
+
Parameters
|
|
339
|
+
----------
|
|
340
|
+
samples : array_like
|
|
341
|
+
Input samples. Shape: ``(N,)`` or ``(C, N)``.
|
|
342
|
+
template : array_like
|
|
343
|
+
Reference sequence. Shape: ``(L,)`` or ``(C, L)``.
|
|
344
|
+
If ``(1, L)`` and samples is ``(C, N)``, the template is
|
|
345
|
+
broadcast across all channels.
|
|
346
|
+
mode : {"full", "same", "valid", "positive_lags"}, default "full"
|
|
347
|
+
Output size:
|
|
348
|
+
- ``"full"``: length ``N + L - 1``.
|
|
349
|
+
- ``"same"``: length ``N`` (centered).
|
|
350
|
+
- ``"valid"``: length ``max(N, L) - min(N, L) + 1``.
|
|
351
|
+
- ``"positive_lags"``: length ``N`` (lags 0 ... N-1 only). Returns a
|
|
352
|
+
zero-copy view of the raw circular-correlation output - no
|
|
353
|
+
``concatenate`` and no reordering. Use this when negative lags are
|
|
354
|
+
not needed (e.g. frame timing search within a bounded window).
|
|
355
|
+
|
|
356
|
+
Returns
|
|
357
|
+
-------
|
|
358
|
+
array_like
|
|
359
|
+
Complex cross-correlation with shape matching the input
|
|
360
|
+
dimensionality and the selected ``mode``.
|
|
361
|
+
"""
|
|
362
|
+
samples, xp, _ = dispatch(samples)
|
|
363
|
+
template = xp.asarray(template)
|
|
364
|
+
|
|
365
|
+
was_1d = samples.ndim == 1
|
|
366
|
+
if was_1d:
|
|
367
|
+
samples = samples[None, :]
|
|
368
|
+
if template.ndim == 1:
|
|
369
|
+
template = template[None, :]
|
|
370
|
+
|
|
371
|
+
N = samples.shape[-1]
|
|
372
|
+
L = template.shape[-1]
|
|
373
|
+
full_len = N + L - 1
|
|
374
|
+
|
|
375
|
+
# Smallest power-of-2 >= full_len for FFT efficiency.
|
|
376
|
+
# `(full_len - 1).bit_length()` is the canonical integer-only formula;
|
|
377
|
+
# `full_len.bit_length()` would round up even when full_len is already a power of 2.
|
|
378
|
+
n_fft = 1 << (full_len - 1).bit_length()
|
|
379
|
+
|
|
380
|
+
# FFT-based correlation: R[k] = IFFT(FFT(samples) * conj(FFT(template)))
|
|
381
|
+
# Circular correlation places positive lags at 0..N-1 and negative lags
|
|
382
|
+
# wrap to n_fft-(L-1)..n_fft-1. Rearrange to match scipy layout:
|
|
383
|
+
# lags [-(L-1), ..., -1, 0, 1, ..., N-1] (total = N + L - 1).
|
|
384
|
+
SIG = xp.fft.fft(samples, n_fft, axis=-1)
|
|
385
|
+
TPL = xp.fft.fft(template, n_fft, axis=-1)
|
|
386
|
+
corr_circ = xp.fft.ifft(SIG * xp.conj(TPL), axis=-1)
|
|
387
|
+
|
|
388
|
+
# Gather negative lags (indices n_fft-(L-1) .. n_fft-1) then positive (0 .. N-1)
|
|
389
|
+
neg_lags = corr_circ[..., n_fft - L + 1 :] # length L-1
|
|
390
|
+
pos_lags = corr_circ[..., :N] # length N
|
|
391
|
+
corr = xp.concatenate([neg_lags, pos_lags], axis=-1) # length N+L-1
|
|
392
|
+
|
|
393
|
+
# Apply mode trimming
|
|
394
|
+
if mode == "positive_lags":
|
|
395
|
+
corr = corr_circ[..., :N] # zero-copy view; lags 0 ... N-1
|
|
396
|
+
elif mode == "same":
|
|
397
|
+
start = (L - 1) // 2
|
|
398
|
+
corr = corr[..., start : start + N]
|
|
399
|
+
elif mode == "valid":
|
|
400
|
+
valid_len = max(N, L) - min(N, L) + 1
|
|
401
|
+
start = min(N, L) - 1
|
|
402
|
+
corr = corr[..., start : start + valid_len]
|
|
403
|
+
# mode == "full": no trimming needed
|
|
404
|
+
|
|
405
|
+
if was_1d:
|
|
406
|
+
return corr[0]
|
|
407
|
+
return corr
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def zc_mimo_root(stream_idx: int, base_root: int, length: int) -> int:
|
|
411
|
+
"""
|
|
412
|
+
Returns the Zadoff-Chu root for TX stream ``stream_idx`` in a MIMO preamble.
|
|
413
|
+
|
|
414
|
+
Assigns a deterministic unique root to each TX stream by cycling through
|
|
415
|
+
distinct roots starting from ``base_root``, wrapping in the range
|
|
416
|
+
``[1, length-1]``. For prime ``length`` all roots are valid CAZAC
|
|
417
|
+
sequences; any two distinct roots are near-orthogonal with cross-correlation
|
|
418
|
+
magnitude ``1/sqrt(length)`` at every lag.
|
|
419
|
+
|
|
420
|
+
Parameters
|
|
421
|
+
----------
|
|
422
|
+
stream_idx : int
|
|
423
|
+
TX stream index (0-based).
|
|
424
|
+
base_root : int
|
|
425
|
+
ZC root assigned to stream 0. Must be in ``[1, length-1]``.
|
|
426
|
+
length : int
|
|
427
|
+
Sequence length (should be prime for the CAZAC property).
|
|
428
|
+
|
|
429
|
+
Returns
|
|
430
|
+
-------
|
|
431
|
+
int
|
|
432
|
+
ZC root for stream ``stream_idx``, guaranteed in ``[1, length-1]``.
|
|
433
|
+
|
|
434
|
+
Examples
|
|
435
|
+
--------
|
|
436
|
+
>>> [zc_mimo_root(k, 1, 13) for k in range(4)]
|
|
437
|
+
[1, 2, 3, 4]
|
|
438
|
+
>>> [zc_mimo_root(k, 10, 13) for k in range(4)]
|
|
439
|
+
[10, 11, 12, 1]
|
|
440
|
+
"""
|
|
441
|
+
return ((base_root - 1 + stream_idx) % (length - 1)) + 1
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def cpr_pll_gains(bandwidth: float):
|
|
445
|
+
"""Convert normalised loop bandwidth to PI gains (mu, beta).
|
|
446
|
+
|
|
447
|
+
Uses the standard 2nd-order loop approximation for a critically-damped
|
|
448
|
+
(ζ = 1) PI loop: μ ≈ 4·B_L, β ≈ 4·B_L². (With ``ωₙT = √β = 2B`` and
|
|
449
|
+
``ζ = μ/(2√β) = 1``.)
|
|
450
|
+
|
|
451
|
+
Parameters
|
|
452
|
+
----------
|
|
453
|
+
bandwidth : float
|
|
454
|
+
Normalised one-sided loop bandwidth as a fraction of the symbol rate,
|
|
455
|
+
e.g. ``1e-3`` for a narrow loop.
|
|
456
|
+
|
|
457
|
+
Returns
|
|
458
|
+
-------
|
|
459
|
+
mu, beta : float32
|
|
460
|
+
"""
|
|
461
|
+
mu = np.float32(4.0 * bandwidth)
|
|
462
|
+
beta = np.float32(4.0 * bandwidth**2)
|
|
463
|
+
return mu, beta
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def resolve_pll_gains(bandwidth: float, mu: float | None, beta: float | None):
|
|
467
|
+
"""Resolve decision-directed PLL PI gains from a raw/bandwidth parameterization.
|
|
468
|
+
|
|
469
|
+
Shared by the inline equalizer PLL (``lms``/``rls`` with ``cpr_type='pll'``)
|
|
470
|
+
and the standalone ``recover_carrier_phase_pll``, so
|
|
471
|
+
the bandwidth->gain mapping is defined in exactly one place.
|
|
472
|
+
|
|
473
|
+
Precedence
|
|
474
|
+
----------
|
|
475
|
+
* ``mu`` given -> raw PI gains; ``beta`` defaults to ``0.0`` (1st-order loop).
|
|
476
|
+
* ``mu`` is ``None`` -> derive critically-damped (ζ=1) gains ``μ=4B, β=4B²``
|
|
477
|
+
from ``bandwidth`` via ``cpr_pll_gains``.
|
|
478
|
+
|
|
479
|
+
``beta`` without ``mu`` is ambiguous and raises ``ValueError``.
|
|
480
|
+
|
|
481
|
+
Returns
|
|
482
|
+
-------
|
|
483
|
+
mu, beta : float
|
|
484
|
+
"""
|
|
485
|
+
if mu is not None:
|
|
486
|
+
return float(mu), float(beta if beta is not None else 0.0)
|
|
487
|
+
if beta is not None: # beta without mu is ambiguous
|
|
488
|
+
raise ValueError("beta requires mu to be set (or use the bandwidth shortcut).")
|
|
489
|
+
return cpr_pll_gains(bandwidth)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Channel impairments and signal degradation models.
|
|
3
|
+
|
|
4
|
+
This package provides routines for simulating physical layer impairments,
|
|
5
|
+
enabling the evaluation of receiver performance under realistic channel
|
|
6
|
+
conditions. The impairments are grouped by where in the link the effect
|
|
7
|
+
originates:
|
|
8
|
+
|
|
9
|
+
- :mod:`~commkit.impairments.noise` - additive measurement noise (AWGN).
|
|
10
|
+
- :mod:`~commkit.impairments.source` - laser/oscillator phase noise.
|
|
11
|
+
- :mod:`~commkit.impairments.frontend` - transceiver IQ imbalance
|
|
12
|
+
(application + blind compensation).
|
|
13
|
+
- :mod:`~commkit.impairments.channel` - fiber-channel effects (linear:
|
|
14
|
+
chromatic dispersion, PMD, polarization mixing; nonlinear: placeholder).
|
|
15
|
+
|
|
16
|
+
The public import surface is stable: ``from commkit.impairments import
|
|
17
|
+
apply_awgn`` (and every other ``apply_*`` / ``compensate_*`` name) is unchanged.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .channel import (
|
|
21
|
+
apply_chromatic_dispersion,
|
|
22
|
+
apply_pmd,
|
|
23
|
+
apply_polarization_mixing,
|
|
24
|
+
)
|
|
25
|
+
from .frontend import (
|
|
26
|
+
apply_iq_imbalance,
|
|
27
|
+
compensate_iq_imbalance_gram_schmidt,
|
|
28
|
+
compensate_iq_imbalance_lowdin,
|
|
29
|
+
)
|
|
30
|
+
from .noise import apply_awgn
|
|
31
|
+
from .source import apply_phase_noise, generate_phase_noise
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"apply_awgn",
|
|
35
|
+
"apply_chromatic_dispersion",
|
|
36
|
+
"apply_iq_imbalance",
|
|
37
|
+
"apply_phase_noise",
|
|
38
|
+
"apply_pmd",
|
|
39
|
+
"apply_polarization_mixing",
|
|
40
|
+
"compensate_iq_imbalance_gram_schmidt",
|
|
41
|
+
"compensate_iq_imbalance_lowdin",
|
|
42
|
+
"generate_phase_noise",
|
|
43
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fiber-channel impairments.
|
|
3
|
+
|
|
4
|
+
Linear effects (chromatic dispersion, PMD, polarization mixing) live in
|
|
5
|
+
:mod:`~commkit.impairments.channel.linear`; nonlinear propagation
|
|
6
|
+
(split-step Kerr) is reserved for
|
|
7
|
+
:mod:`~commkit.impairments.channel.nonlinear` (placeholder).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .linear import (
|
|
11
|
+
apply_chromatic_dispersion,
|
|
12
|
+
apply_pmd,
|
|
13
|
+
apply_polarization_mixing,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"apply_chromatic_dispersion",
|
|
18
|
+
"apply_pmd",
|
|
19
|
+
"apply_polarization_mixing",
|
|
20
|
+
]
|