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,483 @@
1
+ """Constellation diagram plots."""
2
+
3
+ from typing import Any
4
+
5
+ import matplotlib.pyplot as plt
6
+ import numpy as np
7
+
8
+ from .. import helpers
9
+ from ..backend import dispatch, to_device
10
+ from ..core.signal import Signal
11
+ from ..logger import logger
12
+ from .theme import (
13
+ _create_subplot_grid,
14
+ _grid_figsize,
15
+ _square_figsize,
16
+ )
17
+
18
+
19
+ def plot_ideal_constellation(
20
+ modulation: str,
21
+ order: int,
22
+ pmf: Any | None = None,
23
+ nu: float | None = None,
24
+ ax: Any | None = None,
25
+ title: str | None = None,
26
+ size: float | None = None,
27
+ show: bool = False,
28
+ unipolar: bool | None = None,
29
+ ) -> tuple[Any, Any] | None:
30
+ """
31
+ Plots the ideal constellation diagram for a modulation format.
32
+
33
+ Draws theoretical symbol points with their associated Gray-coded bit
34
+ sequences. Includes concentric rings and center axes for reference.
35
+
36
+ For PS-QAM, pass either ``pmf`` or ``nu`` (not both) to activate
37
+ probability-weighted rendering: each marker's **area** and **colour**
38
+ encode the symbol probability under the Maxwell-Boltzmann distribution.
39
+ Inner (more probable) points appear larger and warmer. Bit-label
40
+ annotations are suppressed to keep the plot readable at high orders.
41
+
42
+ Parameters
43
+ ----------
44
+ modulation : {"psk", "qam", "ask", "pam"}
45
+ Modulation scheme identifier.
46
+ order : int
47
+ Modulation order (e.g., 4, 16, 64).
48
+ pmf : array-like of float, optional
49
+ Symbol PMF of shape ``(M,)`` for PS-QAM (from ``maxwell_boltzmann``).
50
+ Mutually exclusive with ``nu``.
51
+ nu : float, optional
52
+ Maxwell-Boltzmann shaping parameter nu >= 0 for QAM. The PMF is
53
+ computed automatically via ``maxwell_boltzmann``.
54
+ nu = 0 gives a uniform distribution (equal-sized markers).
55
+ Mutually exclusive with ``pmf``.
56
+ ax : matplotlib.axes.Axes, optional
57
+ Target axis.
58
+ title : str, optional
59
+ Plot title.
60
+ size : float, optional
61
+ Figure size (square), in inches. Defaults to the theme's square
62
+ panel size (see ``theme._square_figsize``).
63
+ show : bool, default False
64
+ If True, calls `plt.show()`.
65
+ unipolar : bool, default False
66
+ If True, use unipolar plot_constellation (ASK/PAM).
67
+
68
+ Returns
69
+ -------
70
+ fig : matplotlib.figure.Figure
71
+ The figure object.
72
+ ax : matplotlib.axes.Axes
73
+ The plotting axis.
74
+
75
+ Raises
76
+ ------
77
+ ValueError
78
+ If both ``pmf`` and ``nu`` are provided.
79
+ """
80
+ if pmf is not None and nu is not None:
81
+ raise ValueError("Provide at most one of `pmf` or `nu`, not both.")
82
+
83
+ logger.debug("Generating ideal constellation for %s (%s-level).", modulation, order)
84
+ from ..mapping import gray_constellation, maxwell_boltzmann
85
+
86
+ if nu is not None:
87
+ pmf = maxwell_boltzmann(order, nu)
88
+
89
+ if ax is None:
90
+ figsize = (size, size) if size is not None else _square_figsize()
91
+ fig, ax = plt.subplots(figsize=figsize)
92
+ else:
93
+ fig = ax.figure
94
+
95
+ try:
96
+ # Generate constellation on backend (returns NumPy)
97
+ const = gray_constellation(modulation, order, unipolar=unipolar)
98
+ except ValueError as e:
99
+ logger.error("Error generating constellation: %s", e)
100
+ return None
101
+
102
+ # Move to cpu for plotting (already NumPy but good practice)
103
+ const = to_device(const, "cpu")
104
+
105
+ real = const.real
106
+ imag = const.imag
107
+
108
+ if pmf is not None:
109
+ # PS-QAM mode
110
+ pmf_arr = np.asarray(pmf, dtype=np.float64)
111
+ sc = ax.scatter(
112
+ real,
113
+ imag,
114
+ s=100,
115
+ c=pmf_arr,
116
+ cmap="YlOrRd",
117
+ edgecolors="black",
118
+ linewidths=0.5,
119
+ zorder=10,
120
+ )
121
+ plt.colorbar(sc, ax=ax, label="P(sₘ)")
122
+ else:
123
+ # Uniform mode
124
+ ax.scatter(real, imag, s=100, zorder=10)
125
+ n_bits = int(np.log2(order))
126
+ for i, point in enumerate(const):
127
+ x, y = point.real, point.imag
128
+ label = f"{i:0{n_bits}b} ({i})"
129
+ ax.annotate(
130
+ label,
131
+ (x, y),
132
+ xytext=(5, 5),
133
+ textcoords="offset points",
134
+ )
135
+
136
+ # Titles and Labels
137
+ if title is None:
138
+ prefix = "PS-" if pmf is not None else ""
139
+ title = f"Constellation: {prefix}{modulation.upper()} {order}"
140
+ ax.set_title(title)
141
+ ax.set_xlabel("In-Phase (I)")
142
+ ax.set_ylabel("Quadrature (Q)")
143
+
144
+ # Center lines
145
+ ax.axhline(0, color="white", alpha=0.4, zorder=0)
146
+ ax.axvline(0, color="white", alpha=0.4, zorder=0)
147
+
148
+ # Limits and Aspect
149
+ max_range = np.max(np.abs(const))
150
+ limit = max_range * 1.1 if max_range > 0 else 1
151
+ ax.set_xlim(-limit, limit)
152
+ ax.set_ylim(-limit, limit)
153
+ ax.set_aspect("equal")
154
+
155
+ ax.grid(False)
156
+
157
+ # Draw concentric circles (rings) at point magnitudes
158
+ # Find unique radii from the constellation points
159
+ radii = np.unique(np.round(np.abs(const), 6))
160
+
161
+ # Filter out zero radius (origin)
162
+ radii = radii[radii > 1e-6]
163
+
164
+ for r in radii:
165
+ circle = plt.Circle(
166
+ (0, 0),
167
+ r,
168
+ fill=False,
169
+ color="gray",
170
+ linestyle="-",
171
+ alpha=0.4,
172
+ zorder=-5,
173
+ )
174
+ ax.add_artist(circle)
175
+
176
+ if show:
177
+ plt.show()
178
+ return None
179
+ return fig, ax
180
+
181
+
182
+ def plot_constellation(
183
+ samples: Any,
184
+ bins: int = 100,
185
+ cmap: str = "inferno",
186
+ ax: Any | None = None,
187
+ overlay_ideal: bool = False,
188
+ overlay_source: bool = False,
189
+ modulation: str | None = None,
190
+ order: int | None = None,
191
+ unipolar: bool | None = None,
192
+ pmf: Any | None = None,
193
+ title: str | None = "Constellation",
194
+ vmin: float | None = None,
195
+ vmax: float | None = None,
196
+ show: bool = False,
197
+ **kwargs: Any,
198
+ ) -> tuple[Any, Any] | None:
199
+ """
200
+ Plots a constellation density diagram from received samples.
201
+
202
+ Uses high-definition 2D histograms with Gaussian smoothing to
203
+ visualize noisy or impaired signals. This is significantly more
204
+ informative than scatter plots for large sample sets.
205
+
206
+ Parameters
207
+ ----------
208
+ samples : array_like or Signal
209
+ Received complex samples. Shape: (..., N_symbols).
210
+ bins : int, default 100
211
+ Density resolution (bins per axis).
212
+ cmap : str, default "inferno"
213
+ Colormap for the density field.
214
+ ax : matplotlib.axes.Axes, optional
215
+ Target axis.
216
+ overlay_ideal : bool, default False
217
+ If True, overlays theoretical points and scales them to signal power.
218
+ modulation : str, optional
219
+ Required parameter if `overlay_ideal` is enabled.
220
+ order : int, optional
221
+ Required parameter if `overlay_ideal` is enabled.
222
+ unipolar : bool, optional
223
+ Required parameter if `overlay_ideal` is enabled.
224
+ title : str, optional
225
+ Plot title.
226
+ vmin, vmax : float, optional
227
+ Color scaling limits. Defaults to auto-range [0, 1].
228
+ show : bool, default False
229
+ If True, calls `plt.show()`.
230
+ **kwargs : Any
231
+ Additional theoretical arguments passed to `ax.imshow`.
232
+
233
+ Returns
234
+ -------
235
+ fig : matplotlib.figure.Figure
236
+ The figure object.
237
+ ax : matplotlib.axes.Axes or ndarray
238
+ The axis or array of axes used.
239
+ """
240
+ if isinstance(samples, Signal):
241
+ sig = samples
242
+ result = plot_constellation(
243
+ sig.samples,
244
+ bins=bins,
245
+ cmap=cmap,
246
+ ax=ax,
247
+ overlay_ideal=overlay_ideal,
248
+ modulation=sig.mod_scheme,
249
+ order=sig.mod_order,
250
+ unipolar=sig.mod_unipolar,
251
+ pmf=sig.ps_pmf,
252
+ title=title,
253
+ vmin=vmin,
254
+ vmax=vmax,
255
+ show=False,
256
+ **kwargs,
257
+ )
258
+
259
+ if overlay_source and sig.source_symbols is not None and result is not None:
260
+ _, axes = result
261
+ src = to_device(sig.source_symbols, "cpu")
262
+
263
+ # PS-QAM: source_symbols are on the {s_m} grid (avg power E_PS < 1)
264
+ # but received samples normalise to unit power ({s_m/sqrt(E_PS)}).
265
+ # Scale source symbols to match the received symbol scale.
266
+ if (
267
+ sig.ps_pmf is not None
268
+ and sig.mod_scheme is not None
269
+ and sig.mod_order is not None
270
+ ):
271
+ from ..mapping import gray_constellation as _gc_src
272
+
273
+ _const_src = _gc_src(sig.mod_scheme, sig.mod_order)
274
+ _pmf_src = np.asarray(sig.ps_pmf, dtype=np.float64)
275
+ _e_ps = float(np.dot(_pmf_src, np.abs(_const_src) ** 2))
276
+ if 0 < _e_ps < 1.0 - 1e-6:
277
+ src = src / np.sqrt(_e_ps)
278
+
279
+ def _scatter_source(axis, symbols):
280
+ axis.scatter(
281
+ symbols.real,
282
+ symbols.imag,
283
+ c="lime",
284
+ edgecolors="dimgray",
285
+ linewidths=1.5,
286
+ s=30,
287
+ zorder=10,
288
+ marker="o",
289
+ )
290
+
291
+ if src.ndim > 1:
292
+ ax_list = list(np.asarray(axes).flat)
293
+ for ch in range(min(src.shape[0], len(ax_list))):
294
+ _scatter_source(ax_list[ch], src[ch])
295
+ else:
296
+ _scatter_source(axes, src)
297
+
298
+ if show:
299
+ plt.show()
300
+ return None
301
+ return result
302
+
303
+ logger.debug("Generating constellation density plot.")
304
+
305
+ samples, xp, sp = dispatch(samples)
306
+
307
+ # Handle Multichannel (e.g. Dual-Pol)
308
+ # Convention: (Channels, Time)
309
+ if samples.ndim > 1:
310
+ num_channels = samples.shape[0]
311
+
312
+ if ax is None:
313
+ nrows, ncols = _create_subplot_grid(num_channels)
314
+ fig, axes = plt.subplots(
315
+ nrows,
316
+ ncols,
317
+ figsize=_grid_figsize(nrows, ncols, panel=_square_figsize()),
318
+ squeeze=False,
319
+ )
320
+ else:
321
+ if not isinstance(ax, (list, tuple, np.ndarray)):
322
+ logger.warning(
323
+ "Multiple channels detected but single axis provided. Overlaying plots."
324
+ )
325
+ axes = np.array([[ax] * num_channels])
326
+ fig = ax.figure
327
+ else:
328
+ axes = np.atleast_2d(ax)
329
+ fig = axes.flat[0].figure
330
+
331
+ for i in range(num_channels):
332
+ channel_samples = samples[i]
333
+
334
+ # Determine target axis using 2D indexing
335
+ row, col = divmod(i, axes.shape[1])
336
+ target_ax = axes[row, col] if row < axes.shape[0] else axes.flat[-1]
337
+
338
+ ch_title = f"{title} (Ch {i})" if title else f"Channel {i}"
339
+
340
+ plot_constellation(
341
+ channel_samples,
342
+ bins=bins,
343
+ cmap=cmap,
344
+ ax=target_ax,
345
+ overlay_ideal=overlay_ideal,
346
+ modulation=modulation,
347
+ order=order,
348
+ pmf=pmf,
349
+ title=ch_title,
350
+ vmin=vmin,
351
+ vmax=vmax,
352
+ show=False,
353
+ **kwargs,
354
+ )
355
+
356
+ if show:
357
+ plt.show()
358
+ return None
359
+ return fig, axes
360
+
361
+ # --- 1D Logic ---
362
+
363
+ if ax is None:
364
+ fig, ax = plt.subplots(figsize=_square_figsize())
365
+ else:
366
+ fig = ax.figure
367
+
368
+ # Ensure samples are complex
369
+ if not xp.iscomplexobj(samples):
370
+ logger.warning("Constellation plot expects complex samples. Converting.")
371
+ samples = samples.astype(xp.complex64)
372
+
373
+ # Extract I and Q
374
+ i_data = samples.real.flatten()
375
+ q_data = samples.imag.flatten()
376
+
377
+ # Move to CPU for plotting
378
+ i_data = to_device(i_data, "cpu")
379
+ q_data = to_device(q_data, "cpu")
380
+
381
+ # Compute 2D histogram
382
+ # Determine range based on RMS (robust to noise outliers)
383
+ # Using np.sqrt(np.mean(|I|² + |Q|²)) is equivalent to rms(complex_signal)
384
+ signal_rms = float(helpers.rms(i_data + 1j * q_data))
385
+ # Use ~3x RMS as limit (covers most constellation points + noise spread)
386
+ limit = signal_rms * 2.0
387
+ if limit == 0:
388
+ limit = 1.0 # Default view range for zero signal
389
+
390
+ h, xedges, yedges = np.histogram2d(
391
+ i_data, q_data, bins=bins, range=[[-limit, limit], [-limit, limit]]
392
+ )
393
+
394
+ # Transpose for imshow (rows=y, cols=x)
395
+ h = h.T
396
+
397
+ # Apply Gaussian smoothing for nicer visuals
398
+ from scipy.ndimage import gaussian_filter
399
+
400
+ h = gaussian_filter(h, sigma=1)
401
+
402
+ # Normalize histogram to [0, 1] for consistent colormap scaling
403
+ h_max = np.max(h)
404
+ if h_max > 0:
405
+ h = h / h_max
406
+
407
+ # Plot using imshow
408
+ imshow_kwargs: dict[str, Any] = {
409
+ "origin": "lower",
410
+ "extent": [-limit, limit, -limit, limit],
411
+ "aspect": "equal",
412
+ "cmap": cmap,
413
+ "interpolation": "bilinear",
414
+ }
415
+ if vmin is not None:
416
+ imshow_kwargs["vmin"] = vmin
417
+ if vmax is not None:
418
+ imshow_kwargs["vmax"] = vmax
419
+ imshow_kwargs.update(kwargs)
420
+
421
+ ax.imshow(h, **imshow_kwargs)
422
+
423
+ # Overlay ideal constellation if requested
424
+ if overlay_ideal:
425
+ if modulation is None or order is None:
426
+ logger.warning(
427
+ "Modulation and order must be provided to overlay ideal constellation."
428
+ )
429
+ else:
430
+ from ..mapping import constellation_power, gray_constellation
431
+
432
+ try:
433
+ const = gray_constellation(modulation, order, unipolar=unipolar)
434
+ const = to_device(const, "cpu")
435
+
436
+ # Scale constellation to match signal amplitude.
437
+ # For PS-QAM: use pmf-weighted RMS so ideal points land at
438
+ # {s_m / sqrt(E_PS)}, matching where the received clusters
439
+ # sit after shape_pulse normalises to E_s = 1.
440
+ if pmf is not None:
441
+ e_ps = constellation_power(const, pmf)
442
+ const_rms = float(np.sqrt(e_ps)) if e_ps > 0 else helpers.rms(const)
443
+ else:
444
+ const_rms = helpers.rms(const)
445
+ if const_rms > 0:
446
+ scale_factor = signal_rms / const_rms
447
+ const = const * scale_factor
448
+
449
+ ax.scatter(
450
+ const.real,
451
+ const.imag,
452
+ c="lime",
453
+ edgecolors="dimgray",
454
+ linewidths=1.5,
455
+ s=30,
456
+ zorder=10,
457
+ marker="o",
458
+ )
459
+ except ValueError as e:
460
+ logger.warning("Could not overlay ideal constellation: %s", e)
461
+
462
+ # Add center lines
463
+ ax.axhline(0, color="white", alpha=0.4, zorder=0)
464
+ ax.axvline(0, color="white", alpha=0.4, zorder=0)
465
+
466
+ ax.set_xlabel("In-Phase (I)")
467
+ ax.set_ylabel("Quadrature (Q)")
468
+ if title is not None:
469
+ ax.set_title(title)
470
+
471
+ ax.set_xlim(-limit, limit)
472
+ ax.set_ylim(-limit, limit)
473
+ ax.grid(False)
474
+
475
+ if show:
476
+ plt.show()
477
+ return None
478
+ return fig, ax
479
+
480
+
481
+ # -----------------------------------------------------------------------------
482
+ # EQUALIZER DIAGNOSTICS
483
+ # -----------------------------------------------------------------------------