commkit 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- commkit/__init__.py +74 -0
- commkit/_cuda/__init__.py +321 -0
- commkit/_cuda/compiler.py +88 -0
- commkit/_cuda/src/bps_min_d2.cu +104 -0
- commkit/_cuda/src/cs_block.cu +119 -0
- commkit/_cuda/src/selftest.cu +14 -0
- commkit/analysis/__init__.py +55 -0
- commkit/analysis/_common.py +236 -0
- commkit/analysis/allan.py +108 -0
- commkit/analysis/drift.py +213 -0
- commkit/analysis/interferometry.py +887 -0
- commkit/analysis/linewidth.py +480 -0
- commkit/analysis/trajectory.py +91 -0
- commkit/backend.py +507 -0
- commkit/coding/__init__.py +23 -0
- commkit/coding/base.py +17 -0
- commkit/coding/bch.py +6 -0
- commkit/coding/convolutional.py +7 -0
- commkit/coding/crc.py +7 -0
- commkit/coding/galois.py +8 -0
- commkit/coding/hamming.py +6 -0
- commkit/coding/interleaving.py +7 -0
- commkit/coding/ldpc.py +8 -0
- commkit/coding/polar.py +8 -0
- commkit/coding/ratematch.py +6 -0
- commkit/coding/reed_solomon.py +6 -0
- commkit/coding/turbo.py +8 -0
- commkit/core/__init__.py +32 -0
- commkit/core/frame.py +992 -0
- commkit/core/generation.py +581 -0
- commkit/core/signal.py +725 -0
- commkit/equalization/__init__.py +49 -0
- commkit/equalization/_block.py +1855 -0
- commkit/equalization/_common.py +606 -0
- commkit/equalization/_kernels_jax.py +1720 -0
- commkit/equalization/_kernels_numba.py +1704 -0
- commkit/equalization/blind.py +223 -0
- commkit/equalization/linear.py +365 -0
- commkit/equalization/polarization.py +790 -0
- commkit/equalization/result.py +191 -0
- commkit/equalization/sequential.py +2805 -0
- commkit/filtering.py +1120 -0
- commkit/frequency.py +1191 -0
- commkit/helpers.py +489 -0
- commkit/impairments/__init__.py +43 -0
- commkit/impairments/channel/__init__.py +20 -0
- commkit/impairments/channel/linear.py +310 -0
- commkit/impairments/channel/nonlinear.py +11 -0
- commkit/impairments/frontend.py +229 -0
- commkit/impairments/noise.py +105 -0
- commkit/impairments/source.py +219 -0
- commkit/io.py +308 -0
- commkit/logger.py +103 -0
- commkit/mapping/__init__.py +46 -0
- commkit/mapping/bits.py +240 -0
- commkit/mapping/constellation.py +153 -0
- commkit/mapping/gray.py +429 -0
- commkit/mapping/llr.py +253 -0
- commkit/mapping/shaping.py +218 -0
- commkit/metrics.py +949 -0
- commkit/multirate.py +476 -0
- commkit/plotting/__init__.py +78 -0
- commkit/plotting/analysis.py +627 -0
- commkit/plotting/constellation.py +483 -0
- commkit/plotting/equalizer.py +390 -0
- commkit/plotting/eye.py +388 -0
- commkit/plotting/spectral.py +575 -0
- commkit/plotting/sync.py +953 -0
- commkit/plotting/theme.py +203 -0
- commkit/plotting/waveform.py +200 -0
- commkit/py.typed +0 -0
- commkit/recovery/__init__.py +51 -0
- commkit/recovery/bps.py +337 -0
- commkit/recovery/corrections.py +751 -0
- commkit/recovery/pilots.py +803 -0
- commkit/recovery/pll.py +482 -0
- commkit/recovery/tikhonov.py +424 -0
- commkit/recovery/viterbi_viterbi.py +227 -0
- commkit/spectral.py +560 -0
- commkit/timing.py +841 -0
- commkit-1.0.0.dist-info/METADATA +145 -0
- commkit-1.0.0.dist-info/RECORD +84 -0
- commkit-1.0.0.dist-info/WHEEL +4 -0
- commkit-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Linear fiber-channel impairments: chromatic dispersion, PMD, SOP mixing."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from ...backend import ArrayType, dispatch
|
|
8
|
+
from ...logger import logger
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"apply_chromatic_dispersion",
|
|
12
|
+
"apply_pmd",
|
|
13
|
+
"apply_polarization_mixing",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def apply_pmd(
|
|
18
|
+
samples: ArrayType,
|
|
19
|
+
sampling_rate: float,
|
|
20
|
+
dgd: float,
|
|
21
|
+
theta: float = 0.0,
|
|
22
|
+
) -> ArrayType:
|
|
23
|
+
"""
|
|
24
|
+
Applies first-order Polarization Mode Dispersion (PMD) to a dual-pol signal.
|
|
25
|
+
|
|
26
|
+
Models an uncompensated channel segment as a frequency-dependent Jones matrix:
|
|
27
|
+
|
|
28
|
+
H(f) = R(+theta) * diag(e^(-j*pi*f*tau), e^(+j*pi*f*tau)) * R(-theta)
|
|
29
|
+
|
|
30
|
+
where tau is the differential group delay (DGD), theta is the PSP orientation
|
|
31
|
+
angle, and R(theta) is the 2x2 Jones rotation matrix.
|
|
32
|
+
|
|
33
|
+
The DGD is applied in the *principal states of polarisation* (PSP) frame:
|
|
34
|
+
R(-theta) projects the signal onto the PSPs, the differential delay +/- pi*f*tau
|
|
35
|
+
is applied to each PSP, then R(+theta) rotates back to the lab frame. The two PSPs
|
|
36
|
+
experience equal and opposite group delays, giving a total differential delay of
|
|
37
|
+
tau seconds.
|
|
38
|
+
|
|
39
|
+
theta is **not** a separate bulk rotation - it is the PSP orientation angle that
|
|
40
|
+
is intrinsic to the PMD model. For a frequency-independent polarization rotation
|
|
41
|
+
with no DGD use ``apply_polarization_mixing`` instead.
|
|
42
|
+
|
|
43
|
+
The operation is fully vectorised in the frequency domain and
|
|
44
|
+
backend-agnostic (NumPy / CuPy).
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
samples : array_like
|
|
49
|
+
Dual-polarization signal. Shape: ``(2, N_samples)``.
|
|
50
|
+
sampling_rate : float
|
|
51
|
+
Sampling rate in Hz.
|
|
52
|
+
dgd : float
|
|
53
|
+
Differential group delay tau in seconds.
|
|
54
|
+
Set to ``0`` to apply pure SOP rotation with no delay (equivalent
|
|
55
|
+
to ``apply_polarization_mixing``).
|
|
56
|
+
theta : float, default 0.0
|
|
57
|
+
PSP orientation angle theta in radians. Determines how
|
|
58
|
+
much energy couples between X and Y polarisations.
|
|
59
|
+
theta = 0 -> PSPs aligned with lab axes (no cross-coupling);
|
|
60
|
+
theta = pi/4 -> maximum coupling.
|
|
61
|
+
|
|
62
|
+
Returns
|
|
63
|
+
-------
|
|
64
|
+
array_like
|
|
65
|
+
PMD-distorted signal, same backend/shape as input.
|
|
66
|
+
|
|
67
|
+
Raises
|
|
68
|
+
------
|
|
69
|
+
ValueError
|
|
70
|
+
If input is not 2-dimensional with first axis == 2.
|
|
71
|
+
|
|
72
|
+
Examples
|
|
73
|
+
--------
|
|
74
|
+
>>> samples = sig.samples # shape (2, N), dual-pol
|
|
75
|
+
>>> distorted = apply_pmd(samples, sig.sampling_rate, dgd=5e-12, theta=np.pi/5)
|
|
76
|
+
"""
|
|
77
|
+
logger.info("Applying PMD (DGD=%.2e s, theta=%.3f rad).", dgd, theta)
|
|
78
|
+
|
|
79
|
+
samples, xp, _ = dispatch(samples)
|
|
80
|
+
|
|
81
|
+
if samples.ndim != 2 or samples.shape[0] != 2:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"apply_pmd requires dual-pol input with shape (2, N). "
|
|
84
|
+
f"Got shape {samples.shape}."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
N = samples.shape[1]
|
|
88
|
+
freqs = xp.fft.fftfreq(N, d=1.0 / sampling_rate)
|
|
89
|
+
|
|
90
|
+
c = math.cos(theta)
|
|
91
|
+
s = math.sin(theta)
|
|
92
|
+
# H(f) = R(+θ) · diag(D) · R(-θ)
|
|
93
|
+
# R(-θ): rotate INTO the principal-state-of-polarisation (PSP) frame
|
|
94
|
+
Rfwd = xp.array([[c, s], [-s, c]], dtype=samples.dtype)
|
|
95
|
+
# R(+θ): rotate back to the lab frame
|
|
96
|
+
Rinv = xp.array([[c, -s], [s, c]], dtype=samples.dtype)
|
|
97
|
+
|
|
98
|
+
phase = xp.pi * freqs * dgd
|
|
99
|
+
D = xp.stack([xp.exp(-1j * phase), xp.exp(1j * phase)]) # (2, N)
|
|
100
|
+
|
|
101
|
+
S_F = xp.fft.fft(samples, axis=-1) # (2, N)
|
|
102
|
+
|
|
103
|
+
# Apply: rotate to PSP frame -> DGD delay -> rotate back to lab frame
|
|
104
|
+
out_F = Rinv @ (D * (Rfwd @ S_F))
|
|
105
|
+
|
|
106
|
+
result = xp.fft.ifft(out_F, axis=-1)
|
|
107
|
+
|
|
108
|
+
# Preserve input dtype (ifft may produce complex128 from complex64 input)
|
|
109
|
+
if result.dtype != samples.dtype:
|
|
110
|
+
result = result.astype(samples.dtype)
|
|
111
|
+
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def apply_polarization_mixing(
|
|
116
|
+
samples: ArrayType,
|
|
117
|
+
theta: float | ArrayType,
|
|
118
|
+
drift_rate_rad_per_sym: float = 0.0,
|
|
119
|
+
) -> ArrayType:
|
|
120
|
+
"""
|
|
121
|
+
Applies a static or time-varying polarization rotation (pure SOP mixing).
|
|
122
|
+
|
|
123
|
+
Models a frequency-independent 2x2 Jones rotation matrix:
|
|
124
|
+
|
|
125
|
+
[Ex'[n]; Ey'[n]] = R(theta[n]) * [Ex[n]; Ey[n]]
|
|
126
|
+
|
|
127
|
+
where R(theta) = [[cos(theta), -sin(theta)]; [sin(theta), cos(theta)]]
|
|
128
|
+
|
|
129
|
+
Unlike ``apply_pmd``, there is no differential group delay - this is a
|
|
130
|
+
pure bulk polarization rotation. Useful for testing polarization-diverse
|
|
131
|
+
receivers and modelling slow SOP drift.
|
|
132
|
+
|
|
133
|
+
Parameters
|
|
134
|
+
----------
|
|
135
|
+
samples : array_like
|
|
136
|
+
Dual-polarization signal. Shape: ``(2, N_samples)``.
|
|
137
|
+
theta : float or array_like of shape ``(N_samples,)``
|
|
138
|
+
Rotation angle(s) in radians.
|
|
139
|
+
|
|
140
|
+
* **Scalar** - static rotation: the same R(theta) is applied to every sample.
|
|
141
|
+
* **Array of shape** ``(N,)`` - time-varying SOP: one angle per sample,
|
|
142
|
+
applied sample-by-sample via vectorised broadcasting.
|
|
143
|
+
|
|
144
|
+
When ``theta`` is a scalar and ``drift_rate_rad_per_sym != 0``, the
|
|
145
|
+
trajectory is extended as a linear ramp:
|
|
146
|
+
``theta[n] = theta + drift_rate_rad_per_sym * n``.
|
|
147
|
+
drift_rate_rad_per_sym : float, default 0.0
|
|
148
|
+
Linear SOP drift rate in radians per sample. Only used when ``theta``
|
|
149
|
+
is a scalar. Ignored when ``theta`` is an array.
|
|
150
|
+
|
|
151
|
+
Returns
|
|
152
|
+
-------
|
|
153
|
+
array_like
|
|
154
|
+
Rotated dual-polarization signal, same shape, dtype, and backend as
|
|
155
|
+
input.
|
|
156
|
+
|
|
157
|
+
Raises
|
|
158
|
+
------
|
|
159
|
+
ValueError
|
|
160
|
+
If input is not 2-dimensional with first axis == 2.
|
|
161
|
+
|
|
162
|
+
Examples
|
|
163
|
+
--------
|
|
164
|
+
>>> # Static 45° rotation
|
|
165
|
+
>>> rotated = apply_polarization_mixing(samples, theta=np.pi / 4)
|
|
166
|
+
|
|
167
|
+
>>> # Slow linear SOP drift: 1 mrad per symbol
|
|
168
|
+
>>> drifted = apply_polarization_mixing(samples, theta=0.0,
|
|
169
|
+
... drift_rate_rad_per_sym=1e-3)
|
|
170
|
+
"""
|
|
171
|
+
logger.info(
|
|
172
|
+
"Applying polarization mixing (theta=%s, drift=%.3g rad/sym).",
|
|
173
|
+
theta if np.ndim(theta) == 0 else "array",
|
|
174
|
+
drift_rate_rad_per_sym,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
samples, xp, _ = dispatch(samples)
|
|
178
|
+
|
|
179
|
+
if samples.ndim != 2 or samples.shape[0] != 2:
|
|
180
|
+
raise ValueError(
|
|
181
|
+
f"apply_polarization_mixing requires dual-pol input with shape (2, N). "
|
|
182
|
+
f"Got shape {samples.shape}."
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
N = samples.shape[1]
|
|
186
|
+
|
|
187
|
+
# Build angle trajectory
|
|
188
|
+
if np.ndim(theta) == 0:
|
|
189
|
+
scalar_theta = float(theta)
|
|
190
|
+
if drift_rate_rad_per_sym != 0.0:
|
|
191
|
+
angles = (
|
|
192
|
+
xp.arange(N, dtype=xp.float64) * drift_rate_rad_per_sym + scalar_theta
|
|
193
|
+
)
|
|
194
|
+
else:
|
|
195
|
+
# Static: scalar path - avoid building (N,) array
|
|
196
|
+
c = math.cos(scalar_theta)
|
|
197
|
+
s = math.sin(scalar_theta)
|
|
198
|
+
R = xp.array([[c, -s], [s, c]], dtype=samples.dtype)
|
|
199
|
+
result = R @ samples
|
|
200
|
+
if result.dtype != samples.dtype:
|
|
201
|
+
result = result.astype(samples.dtype)
|
|
202
|
+
return result
|
|
203
|
+
else:
|
|
204
|
+
angles = xp.asarray(theta, dtype=xp.float64)
|
|
205
|
+
if angles.shape != (N,):
|
|
206
|
+
raise ValueError(
|
|
207
|
+
f"theta array must have shape (N,)={(N,)}, got {angles.shape}."
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Time-varying: vectorised per-sample rotation via broadcasting
|
|
211
|
+
# R(θ[n]) applied to each column of samples
|
|
212
|
+
cos_t = xp.cos(angles) # (N,)
|
|
213
|
+
sin_t = xp.sin(angles) # (N,)
|
|
214
|
+
|
|
215
|
+
Ex, Ey = samples[0], samples[1]
|
|
216
|
+
Ex_out = cos_t * Ex - sin_t * Ey
|
|
217
|
+
Ey_out = sin_t * Ex + cos_t * Ey
|
|
218
|
+
|
|
219
|
+
result = xp.stack([Ex_out, Ey_out]) # (2, N)
|
|
220
|
+
|
|
221
|
+
if result.dtype != samples.dtype:
|
|
222
|
+
result = result.astype(samples.dtype)
|
|
223
|
+
|
|
224
|
+
return result
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def apply_chromatic_dispersion(
|
|
228
|
+
samples: ArrayType,
|
|
229
|
+
sampling_rate: float,
|
|
230
|
+
dispersion_ps_nm_km: float,
|
|
231
|
+
fiber_length_km: float,
|
|
232
|
+
center_wavelength_nm: float,
|
|
233
|
+
) -> ArrayType:
|
|
234
|
+
"""
|
|
235
|
+
Applies chromatic dispersion (CD) to a signal in the frequency domain.
|
|
236
|
+
|
|
237
|
+
Multiplies the signal spectrum by the CD transfer function:
|
|
238
|
+
|
|
239
|
+
H_CD(f) = exp(-j/2 * beta_2 * (2*pi*f)^2 * L)
|
|
240
|
+
|
|
241
|
+
where
|
|
242
|
+
|
|
243
|
+
beta_2 = -D * lambda^2 / (2*pi*c)
|
|
244
|
+
|
|
245
|
+
and D is the dispersion parameter, lambda is the center wavelength,
|
|
246
|
+
c is the speed of light, and L is the fiber length.
|
|
247
|
+
|
|
248
|
+
Parameters
|
|
249
|
+
----------
|
|
250
|
+
samples : array_like
|
|
251
|
+
Complex baseband signal. Shape: ``(N,)`` (SISO) or ``(C, N)`` (MIMO).
|
|
252
|
+
sampling_rate : float
|
|
253
|
+
Sampling rate in Hz.
|
|
254
|
+
dispersion_ps_nm_km : float
|
|
255
|
+
Fiber dispersion parameter D in ps / (nm * km).
|
|
256
|
+
Standard SMF-28: 17 ps/(nm*km) at 1550 nm.
|
|
257
|
+
fiber_length_km : float
|
|
258
|
+
Fiber span length in km.
|
|
259
|
+
center_wavelength_nm : float
|
|
260
|
+
Center wavelength in nm (e.g. 1550 for C-band).
|
|
261
|
+
|
|
262
|
+
Returns
|
|
263
|
+
-------
|
|
264
|
+
array_like
|
|
265
|
+
CD-impaired signal, same shape, dtype, and backend as input.
|
|
266
|
+
|
|
267
|
+
See Also
|
|
268
|
+
--------
|
|
269
|
+
commkit.filtering.compensate_chromatic_dispersion :
|
|
270
|
+
Remove CD in the receiver (electronic dispersion compensation).
|
|
271
|
+
|
|
272
|
+
Examples
|
|
273
|
+
--------
|
|
274
|
+
>>> distorted = apply_chromatic_dispersion(
|
|
275
|
+
... sig.samples, dispersion_ps_nm_km=17.0, fiber_length_km=80.0,
|
|
276
|
+
... center_wavelength_nm=1550.0, sampling_rate=sig.sampling_rate)
|
|
277
|
+
"""
|
|
278
|
+
logger.info(
|
|
279
|
+
"Applying CD (D=%s ps/nm/km, L=%s km, λ=%s nm).",
|
|
280
|
+
dispersion_ps_nm_km,
|
|
281
|
+
fiber_length_km,
|
|
282
|
+
center_wavelength_nm,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
samples, xp, _ = dispatch(samples)
|
|
286
|
+
was_1d = samples.ndim == 1
|
|
287
|
+
if was_1d:
|
|
288
|
+
samples = samples[None, :]
|
|
289
|
+
_, N = samples.shape
|
|
290
|
+
|
|
291
|
+
# Convert to SI
|
|
292
|
+
D = dispersion_ps_nm_km * 1e-12 / (1e-9 * 1e3) # s / m²
|
|
293
|
+
lam = center_wavelength_nm * 1e-9 # m
|
|
294
|
+
c = 2.998e8 # m/s
|
|
295
|
+
L = fiber_length_km * 1e3 # m
|
|
296
|
+
beta2 = -(D * lam**2) / (2.0 * np.pi * c) * L # s² (β₂·L product)
|
|
297
|
+
|
|
298
|
+
omega = 2.0 * np.pi * xp.fft.fftfreq(N, d=1.0 / sampling_rate)
|
|
299
|
+
H = xp.exp(-1j * (beta2 / 2.0) * omega**2)
|
|
300
|
+
|
|
301
|
+
S_F = xp.fft.fft(samples, axis=-1)
|
|
302
|
+
out_F = S_F * H[None, :]
|
|
303
|
+
result = xp.fft.ifft(out_F, axis=-1)
|
|
304
|
+
|
|
305
|
+
if result.dtype != samples.dtype:
|
|
306
|
+
result = result.astype(samples.dtype)
|
|
307
|
+
|
|
308
|
+
if was_1d:
|
|
309
|
+
return result[0]
|
|
310
|
+
return result
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Nonlinear fiber-propagation impairments (placeholder).
|
|
3
|
+
|
|
4
|
+
Scope: Kerr-effect nonlinearities (self-phase modulation, cross-phase
|
|
5
|
+
modulation, four-wave mixing) and the split-step Fourier method that
|
|
6
|
+
propagates them jointly with chromatic dispersion. This is the most likely
|
|
7
|
+
next addition to the channel model and lives here so it lands as a new file
|
|
8
|
+
rather than an append to the linear channel module. No implementation yet.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__all__: list[str] = []
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Transceiver front-end IQ-imbalance application and compensation.
|
|
2
|
+
|
|
3
|
+
Application and blind compensation are kept together because they are one
|
|
4
|
+
device model (the widely-linear I/Q mixing) and are read as a pair.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
|
|
9
|
+
from ..backend import ArrayType, dispatch
|
|
10
|
+
from ..logger import logger
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"apply_iq_imbalance",
|
|
14
|
+
"compensate_iq_imbalance_gram_schmidt",
|
|
15
|
+
"compensate_iq_imbalance_lowdin",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def apply_iq_imbalance(
|
|
20
|
+
samples: ArrayType,
|
|
21
|
+
amplitude_imbalance_db: float,
|
|
22
|
+
phase_imbalance_deg: float,
|
|
23
|
+
) -> ArrayType:
|
|
24
|
+
"""
|
|
25
|
+
Applies IQ imbalance to a complex baseband signal.
|
|
26
|
+
|
|
27
|
+
Models the widely linear mixing that occurs when the I and Q branches of a
|
|
28
|
+
receiver have mismatched gain and/or non-orthogonal phase:
|
|
29
|
+
|
|
30
|
+
r[n] = K1 * s[n] + K2 * s*[n]
|
|
31
|
+
|
|
32
|
+
where
|
|
33
|
+
|
|
34
|
+
K1 = (1 + g * e^(j*phi)) / 2,
|
|
35
|
+
K2 = (1 - g * e^(-j*phi)) / 2
|
|
36
|
+
|
|
37
|
+
and g = 10^(A / 20) is the I/Q amplitude ratio and phi is the phase error in radians.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
samples : array_like
|
|
42
|
+
Complex baseband signal. Shape: ``(N,)`` (SISO) or ``(C, N)`` (MIMO).
|
|
43
|
+
amplitude_imbalance_db : float
|
|
44
|
+
Amplitude imbalance between I and Q branches in dB. Positive values
|
|
45
|
+
mean Q has higher gain than I. Use ``0.0`` for no amplitude mismatch.
|
|
46
|
+
phase_imbalance_deg : float
|
|
47
|
+
Phase error between I and Q branches in degrees. Use ``0.0`` for no
|
|
48
|
+
phase mismatch.
|
|
49
|
+
|
|
50
|
+
Returns
|
|
51
|
+
-------
|
|
52
|
+
array_like
|
|
53
|
+
Imbalanced signal, same shape and dtype as input.
|
|
54
|
+
|
|
55
|
+
Examples
|
|
56
|
+
--------
|
|
57
|
+
>>> r = apply_iq_imbalance(s, amplitude_imbalance_db=1.0, phase_imbalance_deg=3.0)
|
|
58
|
+
"""
|
|
59
|
+
logger.info(
|
|
60
|
+
"Applying IQ imbalance (amplitude=%.2f dB, phase=%.2f deg).",
|
|
61
|
+
amplitude_imbalance_db,
|
|
62
|
+
phase_imbalance_deg,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
samples, xp, _ = dispatch(samples)
|
|
66
|
+
|
|
67
|
+
g = 10.0 ** (amplitude_imbalance_db / 20.0)
|
|
68
|
+
phi = math.radians(phase_imbalance_deg)
|
|
69
|
+
|
|
70
|
+
# Mixing coefficients: r = K1*s + K2*conj(s)
|
|
71
|
+
K1 = complex(0.5 * (1.0 + g * math.cos(phi)), 0.5 * g * math.sin(phi))
|
|
72
|
+
K2 = complex(0.5 * (1.0 - g * math.cos(phi)), -0.5 * g * math.sin(phi))
|
|
73
|
+
|
|
74
|
+
result = K1 * samples + K2 * xp.conj(samples)
|
|
75
|
+
|
|
76
|
+
if result.dtype != samples.dtype:
|
|
77
|
+
result = result.astype(samples.dtype)
|
|
78
|
+
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def compensate_iq_imbalance_lowdin(samples: ArrayType) -> ArrayType:
|
|
83
|
+
"""
|
|
84
|
+
Blind IQ imbalance compensation via Löwdin symmetric orthogonalisation.
|
|
85
|
+
|
|
86
|
+
Treats the I and Q components as a 2-D real vector and applies the
|
|
87
|
+
symmetric whitening transform W = M^(-1/2) (where M is the 2x2 second-moment matrix)
|
|
88
|
+
so that the corrected I and Q channels have equal power and zero cross-correlation.
|
|
89
|
+
Unlike Gram-Schmidt, the transform is symmetric: both branches are adjusted
|
|
90
|
+
equally, minimising the total distortion introduced.
|
|
91
|
+
|
|
92
|
+
The output power equals the input power.
|
|
93
|
+
|
|
94
|
+
Parameters
|
|
95
|
+
----------
|
|
96
|
+
samples : array_like
|
|
97
|
+
Complex baseband signal. Shape: ``(N,)`` (SISO) or ``(C, N)`` (MIMO).
|
|
98
|
+
|
|
99
|
+
Returns
|
|
100
|
+
-------
|
|
101
|
+
array_like
|
|
102
|
+
IQ-corrected signal, same shape and dtype as input.
|
|
103
|
+
|
|
104
|
+
Notes
|
|
105
|
+
-----
|
|
106
|
+
Per channel: forms the 2x2 second-moment matrix M = X*X.T/N from X = [I; Q],
|
|
107
|
+
then applies whitening W = M^(-1/2) via symmetric eigendecomposition.
|
|
108
|
+
|
|
109
|
+
Examples
|
|
110
|
+
--------
|
|
111
|
+
>>> r = apply_iq_imbalance(s, amplitude_imbalance_db=1.5, phase_imbalance_deg=4.0)
|
|
112
|
+
>>> s_hat = compensate_iq_imbalance_lowdin(r)
|
|
113
|
+
"""
|
|
114
|
+
logger.info("Applying Löwdin IQ imbalance compensation.")
|
|
115
|
+
|
|
116
|
+
samples, xp, _ = dispatch(samples)
|
|
117
|
+
|
|
118
|
+
was_1d = samples.ndim == 1
|
|
119
|
+
if was_1d:
|
|
120
|
+
samples = samples[xp.newaxis, :] # (1, N)
|
|
121
|
+
|
|
122
|
+
C, N = samples.shape
|
|
123
|
+
result = xp.empty_like(samples)
|
|
124
|
+
|
|
125
|
+
for ch in range(C):
|
|
126
|
+
r = samples[ch] # (N,)
|
|
127
|
+
P_in = xp.mean(xp.abs(r) ** 2)
|
|
128
|
+
|
|
129
|
+
# 2xN real data matrix: rows = [I, Q]
|
|
130
|
+
X = xp.stack([r.real, r.imag]) # (2, N)
|
|
131
|
+
|
|
132
|
+
# 2x2 second-moment matrix
|
|
133
|
+
M = (X @ X.T) / N # (2, 2)
|
|
134
|
+
|
|
135
|
+
# Symmetric whitening: W = M^{-1/2} = V @ diag(1/sqrt(lam)) @ V.T
|
|
136
|
+
lam, V = xp.linalg.eigh(M) # lam: (2,), V: (2, 2)
|
|
137
|
+
W = (V * (1.0 / xp.sqrt(lam))) @ V.T # (2, 2)
|
|
138
|
+
|
|
139
|
+
# Apply whitening - X_corr has identity second-moment matrix
|
|
140
|
+
X_corr = W @ X # (2, N)
|
|
141
|
+
|
|
142
|
+
# Restore input power: E[|s_hat|^2] = P_in
|
|
143
|
+
s_corr = (X_corr[0] + 1j * X_corr[1]) * xp.sqrt(P_in / 2.0)
|
|
144
|
+
|
|
145
|
+
if s_corr.dtype != samples.dtype:
|
|
146
|
+
s_corr = s_corr.astype(samples.dtype)
|
|
147
|
+
|
|
148
|
+
result[ch] = s_corr
|
|
149
|
+
|
|
150
|
+
if was_1d:
|
|
151
|
+
return result[0]
|
|
152
|
+
return result
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def compensate_iq_imbalance_gram_schmidt(samples: ArrayType) -> ArrayType:
|
|
156
|
+
"""
|
|
157
|
+
Blind IQ imbalance compensation via Gram-Schmidt sequential orthogonalisation.
|
|
158
|
+
|
|
159
|
+
Uses the I branch as the reference axis. The Q branch is orthogonalised
|
|
160
|
+
against I and both are normalised to unit RMS before being recombined.
|
|
161
|
+
This is the classical GSOP approach used in analogue front-end calibration.
|
|
162
|
+
|
|
163
|
+
The output power equals the input power.
|
|
164
|
+
|
|
165
|
+
Parameters
|
|
166
|
+
----------
|
|
167
|
+
samples : array_like
|
|
168
|
+
Complex baseband signal. Shape: ``(N,)`` (SISO) or ``(C, N)`` (MIMO).
|
|
169
|
+
|
|
170
|
+
Returns
|
|
171
|
+
-------
|
|
172
|
+
array_like
|
|
173
|
+
IQ-corrected signal, same shape and dtype as input.
|
|
174
|
+
|
|
175
|
+
Notes
|
|
176
|
+
-----
|
|
177
|
+
*Algorithm* (per channel):
|
|
178
|
+
|
|
179
|
+
1. Normalize I to unit RMS: I_hat = I / sigma_I.
|
|
180
|
+
2. Remove I-projection from Q: Q_perp = Q - <I_hat, Q> * I_hat.
|
|
181
|
+
3. Normalize Q_perp to unit RMS: Q_hat = Q_perp / sigma_Q_perp.
|
|
182
|
+
4. Recombine and rescale to preserve input power.
|
|
183
|
+
|
|
184
|
+
Examples
|
|
185
|
+
--------
|
|
186
|
+
>>> r = apply_iq_imbalance(s, amplitude_imbalance_db=1.5, phase_imbalance_deg=4.0)
|
|
187
|
+
>>> s_hat = compensate_iq_imbalance_gram_schmidt(r)
|
|
188
|
+
"""
|
|
189
|
+
logger.info("Applying Gram-Schmidt IQ imbalance compensation.")
|
|
190
|
+
|
|
191
|
+
samples, xp, _ = dispatch(samples)
|
|
192
|
+
|
|
193
|
+
was_1d = samples.ndim == 1
|
|
194
|
+
if was_1d:
|
|
195
|
+
samples = samples[xp.newaxis, :] # (1, N)
|
|
196
|
+
|
|
197
|
+
C, N = samples.shape
|
|
198
|
+
result = xp.empty_like(samples)
|
|
199
|
+
|
|
200
|
+
for ch in range(C):
|
|
201
|
+
r = samples[ch] # (N,)
|
|
202
|
+
P_in = xp.mean(xp.abs(r) ** 2)
|
|
203
|
+
|
|
204
|
+
I = r.real # noqa: E741
|
|
205
|
+
Q = r.imag
|
|
206
|
+
|
|
207
|
+
# Step 1: Normalise I (reference branch)
|
|
208
|
+
sigma_I = xp.sqrt(xp.mean(I**2))
|
|
209
|
+
I_norm = I / sigma_I
|
|
210
|
+
|
|
211
|
+
# Step 2: Orthogonalise Q against I
|
|
212
|
+
rho = xp.mean(I_norm * Q) # scalar projection coefficient
|
|
213
|
+
Q_orth = Q - rho * I_norm
|
|
214
|
+
|
|
215
|
+
# Step 3: Normalise orthogonalised Q
|
|
216
|
+
sigma_Q = xp.sqrt(xp.mean(Q_orth**2))
|
|
217
|
+
Q_norm = Q_orth / sigma_Q
|
|
218
|
+
|
|
219
|
+
# Step 4: Recombine and restore input power
|
|
220
|
+
s_corr = (I_norm + 1j * Q_norm) * xp.sqrt(P_in / 2.0)
|
|
221
|
+
|
|
222
|
+
if s_corr.dtype != samples.dtype:
|
|
223
|
+
s_corr = s_corr.astype(samples.dtype)
|
|
224
|
+
|
|
225
|
+
result[ch] = s_corr
|
|
226
|
+
|
|
227
|
+
if was_1d:
|
|
228
|
+
return result[0]
|
|
229
|
+
return result
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Additive measurement noise (ASE / thermal) impairments."""
|
|
2
|
+
|
|
3
|
+
from ..backend import ArrayType, dispatch
|
|
4
|
+
from ..logger import logger
|
|
5
|
+
|
|
6
|
+
__all__ = ["apply_awgn"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def apply_awgn(
|
|
10
|
+
samples: ArrayType,
|
|
11
|
+
sps: float,
|
|
12
|
+
esn0_db: float,
|
|
13
|
+
seed: int | None = None,
|
|
14
|
+
signal_power: float | None = None,
|
|
15
|
+
) -> ArrayType:
|
|
16
|
+
"""
|
|
17
|
+
Adds Additive White Gaussian Noise (AWGN) to a signal based on Es/N0.
|
|
18
|
+
|
|
19
|
+
Uses the standard communications definition where Es/N0 is the ratio of
|
|
20
|
+
symbol energy to noise spectral density. This accounts for oversampling
|
|
21
|
+
so the specified Es/N0 matches what you'd measure in the signal bandwidth.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
samples : array_like
|
|
26
|
+
The input signal samples. Shape: (..., N_samples)
|
|
27
|
+
sps : float
|
|
28
|
+
Samples per symbol.
|
|
29
|
+
esn0_db : float
|
|
30
|
+
Symbol energy to noise spectral density ratio (Es/N0) in dB.
|
|
31
|
+
seed : int, optional
|
|
32
|
+
Random seed for reproducible noise generation. When ``None`` (default),
|
|
33
|
+
the global RNG state is used.
|
|
34
|
+
signal_power : float, optional
|
|
35
|
+
Reference signal power for the noise scaling. Defaults to the
|
|
36
|
+
measured mean power of ``samples``. Pass an explicit value to add
|
|
37
|
+
noise at an absolute level - e.g. ``signal_power=1.0`` on a dark
|
|
38
|
+
(signal-blocked) capture adds the noise a unit-power signal would
|
|
39
|
+
see, which the record's own (near-zero) power cannot define.
|
|
40
|
+
|
|
41
|
+
Returns
|
|
42
|
+
-------
|
|
43
|
+
array_like
|
|
44
|
+
The noisy signal with the same type and backend as the input.
|
|
45
|
+
|
|
46
|
+
Notes
|
|
47
|
+
-----
|
|
48
|
+
- For symbol-rate signals (sps=1), Es/N0 equals the sample-level SNR.
|
|
49
|
+
- For oversampled signals, noise power is scaled by `sps` to maintain
|
|
50
|
+
the correct Es/N0 in the signal bandwidth.
|
|
51
|
+
- For complex signals, noise power is split equally between I and Q.
|
|
52
|
+
|
|
53
|
+
Examples
|
|
54
|
+
--------
|
|
55
|
+
>>> sig = generate_pam(order=4, num_symbols=1000, sps=4, symbol_rate=1e6)
|
|
56
|
+
>>> noisy = apply_awgn(sig.samples, esn0_db=20, sps=sig.sps)
|
|
57
|
+
"""
|
|
58
|
+
logger.info("Adding AWGN (Es/N0 target: %.2f dB).", esn0_db)
|
|
59
|
+
|
|
60
|
+
samples, xp, _ = dispatch(samples)
|
|
61
|
+
|
|
62
|
+
# === Es/N0 to sample-level SNR conversion ===
|
|
63
|
+
#
|
|
64
|
+
# Es/N0 = Symbol Energy / Noise Spectral Density
|
|
65
|
+
#
|
|
66
|
+
# For oversampled signals:
|
|
67
|
+
# - Symbol energy Es = sps * (average sample power) [sum over sps samples]
|
|
68
|
+
# - Noise power in full bandwidth = N0 * fs = N0 * (sps * symbol_rate)
|
|
69
|
+
# - Noise power per sample = N0 * symbol_rate
|
|
70
|
+
#
|
|
71
|
+
# Therefore:
|
|
72
|
+
# Es/N0 = (sps * P_sample) / (N0 * symbol_rate)
|
|
73
|
+
# Sample_SNR = P_sample / P_noise_per_sample = P_sample / (N0 * symbol_rate)
|
|
74
|
+
#
|
|
75
|
+
# Relationship: Sample_SNR = Es/N0 / sps
|
|
76
|
+
#
|
|
77
|
+
# Or equivalently: P_noise = P_signal * sps / Es_N0_linear
|
|
78
|
+
|
|
79
|
+
if signal_power is None:
|
|
80
|
+
signal_power = xp.mean(xp.abs(samples) ** 2)
|
|
81
|
+
esn0_linear = 10 ** (esn0_db / 10)
|
|
82
|
+
|
|
83
|
+
# Noise power accounting for oversampling
|
|
84
|
+
if esn0_linear <= 1e-20:
|
|
85
|
+
noise_power = signal_power * sps / 1e-20
|
|
86
|
+
else:
|
|
87
|
+
noise_power = signal_power * sps / esn0_linear
|
|
88
|
+
|
|
89
|
+
# Handle complex signals (split power between I and Q)
|
|
90
|
+
is_complex = xp.iscomplexobj(samples)
|
|
91
|
+
|
|
92
|
+
rng = xp.random.RandomState(seed) if seed is not None else xp.random
|
|
93
|
+
if is_complex:
|
|
94
|
+
noise_std_component = xp.sqrt(noise_power / 2)
|
|
95
|
+
real_dtype = samples.real.dtype
|
|
96
|
+
noise = rng.normal(0, noise_std_component, samples.shape).astype(
|
|
97
|
+
real_dtype
|
|
98
|
+
) + 1j * rng.normal(0, noise_std_component, samples.shape).astype(real_dtype)
|
|
99
|
+
else:
|
|
100
|
+
noise_std = xp.sqrt(noise_power)
|
|
101
|
+
noise = rng.normal(0, noise_std, samples.shape).astype(samples.dtype)
|
|
102
|
+
|
|
103
|
+
noisy_samples = samples + noise
|
|
104
|
+
|
|
105
|
+
return noisy_samples
|