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,337 @@
1
+ """Blind Phase Search (BPS) carrier phase recovery."""
2
+
3
+ import logging
4
+
5
+ import numpy as np
6
+
7
+ from ..backend import ArrayType, dispatch, to_device
8
+ from ..logger import logger
9
+ from .corrections import correct_cycle_slips
10
+
11
+
12
+ def recover_carrier_phase_bps(
13
+ symbols: ArrayType,
14
+ modulation: str,
15
+ order: int,
16
+ num_test_phases: int = 64,
17
+ block_size: int = 32,
18
+ joint_channels: bool = False,
19
+ cycle_slip_correction: bool = False,
20
+ cycle_slip_history: int = 100,
21
+ cycle_slip_threshold: float = np.pi / 4,
22
+ pmf: np.ndarray | None = None,
23
+ debug_plot: bool = False,
24
+ ) -> ArrayType:
25
+ """
26
+ Carrier phase recovery via Blind Phase Search (BPS).
27
+
28
+ Tests ``num_test_phases`` candidate rotation angles over ``[0, π/2)``
29
+ (exploiting 4-fold QAM symmetry), selects the candidate that minimises
30
+ the block-averaged sum of minimum squared distances to the reference
31
+ constellation, and interpolates to per-symbol resolution.
32
+
33
+ Parameters
34
+ ----------
35
+ symbols : array_like
36
+ 1-SPS complex symbols after matched filter. Shape: (N,) or (C, N).
37
+ modulation : str
38
+ Modulation scheme (case-insensitive). Used to fetch the reference
39
+ constellation via
40
+ ``gray_constellation``.
41
+ order : int
42
+ Modulation order.
43
+ num_test_phases : int, default 64
44
+ Number of candidate phase offsets B. Resolution is ``π/(2B)``
45
+ rad per step. More candidates improve accuracy at higher compute cost.
46
+ block_size : int, default 32
47
+ Number of symbols per block for error-metric averaging.
48
+ Very small values (< 4) make the 4-fold phase unwrap unreliable
49
+ because noise on a single-symbol metric causes the best-candidate
50
+ index to jump between non-adjacent phase bins between consecutive
51
+ blocks, triggering false unwrap corrections. Recommended
52
+ minimum: ``block_size ≥ 4``.
53
+ joint_channels : bool, default False
54
+ For MIMO inputs (C > 1): if ``True``, sum the distance metrics
55
+ across all channels before selecting the best phase candidate.
56
+ The resulting single phase trajectory is broadcast to all C rows
57
+ of the output (all channels identical before ambiguity resolution).
58
+ Reduces phase estimation variance by ~√C for shared-LO systems.
59
+ Has no effect for SISO (C = 1).
60
+ cycle_slip_correction : bool, default False
61
+ If ``True``, apply cycle-slip detection and correction
62
+ (``correct_cycle_slips``) to the block-phase trajectory
63
+ after 4-fold unwrap, before interpolation.
64
+ cycle_slip_history : int, default 100
65
+ ``history_length`` passed to ``correct_cycle_slips``.
66
+ Number of past corrected blocks used for linear extrapolation.
67
+ cycle_slip_threshold : float, default π/4
68
+ ``threshold`` passed to ``correct_cycle_slips`` (radians).
69
+ pmf : np.ndarray, optional
70
+ Symbol PMF of shape ``(order,)`` for PS-QAM. When provided, the
71
+ reference constellation is scaled by ``1/sqrt(E_PS)`` (where
72
+ ``E_PS = Σ P(s_m) |s_m|²`` on the normalised grid) so the
73
+ nearest-neighbour distance metric matches the scale of the
74
+ unit-avg-power input. Without this, mid-shell PS points cross
75
+ decision boundaries in the BPS metric and bias the phase estimate.
76
+ No-op for uniform modulations.
77
+ debug_plot : bool, default False
78
+ If ``True``, opens a diagnostic figure showing the per-symbol phase
79
+ trajectory alongside the block-phase estimates.
80
+
81
+ Returns
82
+ -------
83
+ array_like
84
+ Per-symbol phase estimate in radians. Shape matches ``symbols``.
85
+ Same backend as input.
86
+
87
+ Notes
88
+ -----
89
+ Tests B candidate rotations over [0, pi/2), selects the one minimising
90
+ block-averaged minimum Euclidean distance, then 4-fold unwraps. A global
91
+ pi/2 ambiguity remains - resolve via a pilot or preamble reference.
92
+
93
+ Memory: the distance tensor scales as N * B * M * 8 bytes; reduce
94
+ ``num_test_phases`` or segment length for high-order constellations.
95
+ """
96
+ from ..helpers import normalize
97
+ from ..mapping import constellation_power, gray_constellation
98
+
99
+ symbols, xp, _ = dispatch(symbols)
100
+ was_1d = symbols.ndim == 1
101
+ if was_1d:
102
+ symbols = symbols[None, :]
103
+ C, N = symbols.shape
104
+
105
+ # Normalise each channel to unit average power so the metric is computed at
106
+ # the same scale as the reference constellation (gray_constellation returns
107
+ # unit-average-power points). BPS is a phase estimator; it must be
108
+ # amplitude-agnostic.
109
+ symbols = normalize(symbols, mode="average_power", axis=-1)
110
+
111
+ # Reference constellation on the same device
112
+ const_np = gray_constellation(modulation, order)
113
+
114
+ # PS-QAM: unit-avg-power input lives on the ``{s_m/sqrt(E_PS)}`` grid.
115
+ # Rescale the comparison constellation to the same grid so the nearest-
116
+ # neighbour distance metric is correct. Skip on uniform PMF.
117
+ if pmf is not None:
118
+ e_ps = constellation_power(const_np, pmf)
119
+ if e_ps < 1.0 - 1e-6:
120
+ const_np = const_np / np.sqrt(e_ps)
121
+
122
+ const_xp = xp.asarray(const_np) # (M_const,)
123
+
124
+ # Candidate test phases over [0, π/2)
125
+ B = num_test_phases
126
+ candidates = xp.arange(B, dtype=symbols.real.dtype) * (np.pi / 2.0 / B) # (B,)
127
+
128
+ N_trunc = (N // block_size) * block_size
129
+ N_blocks = N_trunc // block_size
130
+
131
+ if N_blocks == 0:
132
+ raise ValueError(
133
+ f"Signal length {N} is shorter than block_size={block_size}. "
134
+ "Reduce block_size or use a longer symbol sequence."
135
+ )
136
+
137
+ # Very small block_size makes the 4-fold phase unwrap unreliable: with only
138
+ # one or two symbols per block the noise on the distance-metric argmin causes
139
+ # large candidate-index jumps between consecutive blocks, triggering false
140
+ # 4-fold unwrap corrections. Warn early so users diagnose this easily.
141
+ if block_size < 4:
142
+ logger.warning(
143
+ "CPR (BPS): block_size=%s is very small. Averaging the distance metric over only %s symbol(s) per block makes the 4-fold phase unwrap unreliable. Recommended minimum: block_size ≥ 4.",
144
+ block_size,
145
+ block_size,
146
+ )
147
+
148
+ # block_centers[b] = b * block_size + block_size/2 (consistent with VV)
149
+ block_centers = xp.arange(N_blocks, dtype=xp.float64) * block_size + block_size / 2
150
+
151
+ all_positions = xp.arange(N, dtype=xp.float64)
152
+
153
+ # Pre-compute interpolation indices and weights (identical for every channel).
154
+ # block b is "to the left" of position n when its centre b*bs + bs/2 <= n
155
+ # => b <= (n - bs/2) / bs => idx_left = floor((n - bs/2) / bs)
156
+ idx_left = xp.clip(
157
+ xp.floor((all_positions - block_size / 2) / block_size).astype(xp.int64),
158
+ 0,
159
+ N_blocks - 2,
160
+ ) # (N,)
161
+ idx_right = idx_left + 1 # (N,)
162
+ t_interp = xp.clip(
163
+ (all_positions - block_centers[idx_left]) / block_size, 0.0, 1.0
164
+ ) # (N,)
165
+
166
+ # Pre-compute phasors for all B candidates once (avoid redundant exp per channel)
167
+ dtype_c = xp.complex64 if symbols.dtype == xp.complex64 else xp.complex128
168
+ phasors = xp.exp(-1j * candidates.astype(xp.float64)).astype(dtype_c) # (B,)
169
+
170
+ # For square QAM (order a perfect square): the nearest constellation point
171
+ # can be found in O(1) per symbol via per-component rounding, eliminating
172
+ # the (CHUNK, B, M_const) distance tensor entirely.
173
+ side = int(order**0.5)
174
+ is_sq_qam = ("qam" in modulation.lower()) and (side * side == order)
175
+ if is_sq_qam:
176
+ # Sorted unique real levels of the constellation (shape: (side,))
177
+ levels = xp.sort(xp.unique(const_xp.real))
178
+ d_grid = float(levels[1] - levels[0]) # uniform grid spacing
179
+ lev_min = float(levels[0])
180
+
181
+ float_dtype = xp.float32 if symbols.dtype == xp.complex64 else xp.float64
182
+
183
+ # Fused CUDA kernel (CuPy + complex64 only): computes the per-symbol
184
+ # min-distance metric for all B candidate phases and all C channels in a
185
+ # single pass, avoiding the materialized (CHUNK, B[, M]) intermediates of
186
+ # the xp path. None => fall back to the xp implementation below.
187
+ _kern = None
188
+ if xp is not np and symbols.dtype == xp.complex64 and B <= 128:
189
+ if is_sq_qam or const_xp.size <= 1024:
190
+ from .. import _cuda
191
+
192
+ _kern = _cuda.get_kernel(
193
+ "bps_min_d2", mode="grid" if is_sq_qam else "table"
194
+ )
195
+
196
+ # Chunk size for N axis: bounds peak memory of the distance tensor.
197
+ # Always a multiple of block_size so each chunk covers a whole number of
198
+ # blocks exactly. Rounded up to the nearest multiple ≥ 1024.
199
+ CHUNK_N = max(block_size, ((1024 + block_size - 1) // block_size) * block_size)
200
+
201
+ phi_full = xp.zeros((C, N), dtype=xp.float64)
202
+ phi_blocks = xp.zeros((C, N_blocks), dtype=xp.float64)
203
+
204
+ # Accumulate per-channel distance metrics (N_blocks, B) for all channels.
205
+ metrics_all = xp.zeros((C, N_blocks, B), dtype=float_dtype)
206
+
207
+ if _kern is not None:
208
+ # One kernel call per chunk covering all C channels; output (B, C, n)
209
+ # is block-summed and transposed into metrics_all's (C, n_b, B)
210
+ # layout. The kernel writes only the minima, so the chunk can be far
211
+ # larger than the tensor-bounded CHUNK_N of the xp path.
212
+ chunk_gpu = ((131072 + block_size - 1) // block_size) * block_size
213
+ const_c64 = None if is_sq_qam else const_xp.astype(xp.complex64)
214
+ for n0 in range(0, N_trunc, chunk_gpu):
215
+ n1 = min(n0 + chunk_gpu, N_trunc)
216
+ if is_sq_qam:
217
+ md = _kern(
218
+ symbols[:, n0:n1],
219
+ phasors,
220
+ lev_min=lev_min,
221
+ d_grid=d_grid,
222
+ side=side,
223
+ )
224
+ else:
225
+ md = _kern(symbols[:, n0:n1], phasors, constellation=const_c64)
226
+ b0 = n0 // block_size
227
+ n_b = (n1 - n0) // block_size
228
+ metrics_all[:, b0 : b0 + n_b] = (
229
+ md.reshape(B, C, n_b, block_size).sum(axis=3).transpose(1, 2, 0)
230
+ )
231
+
232
+ else:
233
+ for ch in range(C):
234
+ sym = symbols[ch, :N_trunc] # (N_trunc,)
235
+
236
+ for n0 in range(0, N_trunc, CHUNK_N):
237
+ n1 = min(n0 + CHUNK_N, N_trunc)
238
+ x_rot = sym[n0:n1, None] * phasors[None, :] # (CHUNK, B)
239
+
240
+ if is_sq_qam:
241
+ # O(1) nearest-point: round each component to the nearest grid level
242
+ r_idx = xp.clip(
243
+ xp.round((x_rot.real - lev_min) / d_grid).astype(xp.int64),
244
+ 0,
245
+ side - 1,
246
+ )
247
+ i_idx = xp.clip(
248
+ xp.round((x_rot.imag - lev_min) / d_grid).astype(xp.int64),
249
+ 0,
250
+ side - 1,
251
+ )
252
+ r_near = levels[r_idx] # (CHUNK, B)
253
+ i_near = levels[i_idx] # (CHUNK, B)
254
+ chunk_min_d = (
255
+ (x_rot.real - r_near) ** 2 + (x_rot.imag - i_near) ** 2
256
+ ).astype(float_dtype)
257
+ else:
258
+ # General: (CHUNK, B, M_const) - bounded by CHUNK_N
259
+ d_sq = xp.abs(x_rot[:, :, None] - const_xp[None, None, :]) ** 2
260
+ chunk_min_d = xp.min(d_sq, axis=-1).astype(float_dtype)
261
+
262
+ b0 = n0 // block_size
263
+ n_b = (n1 - n0) // block_size
264
+ metrics_all[ch, b0 : b0 + n_b] = chunk_min_d.reshape(
265
+ n_b, block_size, B
266
+ ).sum(axis=1)
267
+
268
+ # Phase estimation: joint (sum metrics across channels) or independent per channel.
269
+ if joint_channels and C > 1:
270
+ metric_joint = xp.sum(metrics_all, axis=0) # (N_blocks, B)
271
+ best_k_joint = xp.argmin(metric_joint, axis=-1) # (N_blocks,)
272
+ phi_b_joint = candidates[best_k_joint] # (N_blocks,)
273
+ phi_u_joint = xp.unwrap(phi_b_joint.astype(xp.float64) * 4, axis=-1) / 4
274
+ if cycle_slip_correction:
275
+ phi_u_joint_np = correct_cycle_slips(
276
+ to_device(phi_u_joint, "cpu"),
277
+ 4,
278
+ cycle_slip_history,
279
+ cycle_slip_threshold,
280
+ )
281
+ phi_u_joint = xp.asarray(phi_u_joint_np)
282
+ for ch in range(C):
283
+ phi_full[ch] = (
284
+ phi_u_joint[idx_left] * (1.0 - t_interp)
285
+ + phi_u_joint[idx_right] * t_interp
286
+ )
287
+ phi_blocks[ch] = phi_u_joint
288
+ else:
289
+ for ch in range(C):
290
+ metric = metrics_all[ch] # (N_blocks, B)
291
+ best_k = xp.argmin(metric, axis=-1) # (N_blocks,)
292
+ phi_b = candidates[best_k] # (N_blocks,)
293
+ phi_u = xp.unwrap(phi_b.astype(xp.float64) * 4, axis=-1) / 4
294
+ if cycle_slip_correction:
295
+ phi_u_np = correct_cycle_slips(
296
+ to_device(phi_u, "cpu"), 4, cycle_slip_history, cycle_slip_threshold
297
+ )
298
+ phi_u = xp.asarray(phi_u_np)
299
+ phi_full[ch] = (
300
+ phi_u[idx_left] * (1.0 - t_interp) + phi_u[idx_right] * t_interp
301
+ )
302
+ phi_blocks[ch] = phi_u
303
+
304
+ # Host copy of the full phase trajectory is needed only for the INFO summary
305
+ # below and the optional debug plot - skip the transfer + reductions when
306
+ # neither is active (the device phi_full drives the actual correction).
307
+ _want_log = logger.isEnabledFor(logging.INFO)
308
+ if _want_log or debug_plot:
309
+ phi_full_np = to_device(phi_full, "cpu")
310
+ if _want_log:
311
+ phi_mean_deg = float(np.mean(phi_full_np)) * 180.0 / np.pi
312
+ phi_std_deg = float(np.std(phi_full_np)) * 180.0 / np.pi
313
+ mode_str = "joint" if (joint_channels and C > 1) else "independent"
314
+ logger.info(
315
+ "CPR (BPS, B=%s, %s): phase mean=%.2f°, std=%.2f° [%s blocks x %s symbols, C=%s, cycle_slip_correction=%s]",
316
+ B,
317
+ mode_str,
318
+ phi_mean_deg,
319
+ phi_std_deg,
320
+ N_blocks,
321
+ block_size,
322
+ C,
323
+ cycle_slip_correction,
324
+ )
325
+
326
+ if debug_plot:
327
+ from .. import plotting as _plotting
328
+
329
+ _plotting.plot_carrier_phase_trajectory(
330
+ phi_full=phi_full_np,
331
+ show=True,
332
+ title="CPR - Blind Phase Search",
333
+ )
334
+
335
+ if was_1d:
336
+ return phi_full[0]
337
+ return phi_full