commkit 1.0.0__py3-none-any.whl

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