commkit 1.0.0__py3-none-any.whl

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