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,203 @@
1
+ """Shared plotting theme, layout, and formatting helpers."""
2
+
3
+ import matplotlib as mpl
4
+ import matplotlib.font_manager as fm
5
+ import matplotlib.pyplot as plt
6
+ import numpy as np
7
+
8
+ from ..backend import to_device
9
+ from ..logger import logger
10
+
11
+
12
+ def apply_default_theme() -> None:
13
+ """
14
+ Applies the library's default visual theme to Matplotlib.
15
+
16
+ This theme configures publication-quality defaults, including:
17
+ - Roboto or standard sans-serif typography.
18
+ - Optimized figure dimensions and DPI.
19
+ - Consistent grid styling and axis formatting.
20
+ - Improved LaTeX math rendering.
21
+
22
+ Notes
23
+ -----
24
+ This function modifies `matplotlib.rcParams` globally. It is recommended
25
+ to call this at the start of a script or notebook for consistent styling.
26
+ """
27
+ logger.debug("Applying default plotting theme.")
28
+ try:
29
+ font_prop = fm.FontProperties(family="Roboto", weight="regular")
30
+ fm.findfont(font_prop, fallback_to_default=False)
31
+ font_name = "Roboto"
32
+ except ValueError:
33
+ font_name = "sans"
34
+ logger.warning("Roboto font not found, falling back to default sans-serif.")
35
+
36
+ plt.style.use("dark_background")
37
+
38
+ mpl.rcParams.update(
39
+ {
40
+ "figure.figsize": (7, 4.9),
41
+ "font.family": font_name,
42
+ "font.size": 12,
43
+ "legend.fontsize": 10,
44
+ "lines.linewidth": 2,
45
+ "axes.linewidth": 1,
46
+ "axes.grid": True,
47
+ "grid.alpha": 0.4,
48
+ "axes.titleweight": "bold",
49
+ "figure.constrained_layout.use": True,
50
+ "savefig.dpi": 300,
51
+ "xtick.direction": "in",
52
+ "ytick.direction": "in",
53
+ "xtick.major.width": 1,
54
+ "ytick.major.width": 1,
55
+ "xtick.major.size": 4,
56
+ "ytick.major.size": 4,
57
+ "xtick.minor.width": 1,
58
+ "ytick.minor.width": 1,
59
+ "xtick.minor.size": 2,
60
+ "ytick.minor.size": 2,
61
+ "xtick.top": True,
62
+ "ytick.right": True,
63
+ }
64
+ )
65
+
66
+ plt.rcParams["mathtext.fontset"] = "custom"
67
+ plt.rcParams["mathtext.rm"] = font_name
68
+ plt.rcParams["mathtext.it"] = f"{font_name}:italic"
69
+ plt.rcParams["mathtext.bf"] = f"{font_name}:bold"
70
+
71
+
72
+ def _create_subplot_grid(num_axes: int, max_cols: int = 2) -> tuple[int, int]:
73
+ """
74
+ Computes a grid layout (rows, cols) for a given number of axes.
75
+
76
+ Limits the maximum number of columns to prevent excessively wide figures.
77
+
78
+ Parameters
79
+ ----------
80
+ num_axes : int
81
+ Total number of subplots required.
82
+ max_cols : int, default 2
83
+ Maximum allowed number of columns.
84
+
85
+ Returns
86
+ -------
87
+ nrows : int
88
+ Number of rows in the grid.
89
+ ncols : int
90
+ Number of columns in the grid.
91
+ """
92
+ if num_axes <= max_cols:
93
+ return 1, num_axes
94
+ ncols = max_cols
95
+ nrows = (num_axes + ncols - 1) // ncols # Ceiling division
96
+ return nrows, ncols
97
+
98
+
99
+ def _grid_figsize(
100
+ nrows: int, ncols: int, panel: tuple[float, float] | None = None
101
+ ) -> tuple[float, float]:
102
+ """
103
+ Figsize for a subplot grid that preserves the default single-panel size.
104
+
105
+ Tiles ``panel`` (default: ``rcParams["figure.figsize"]``) across the
106
+ grid, so a single row keeps the default height, a single column keeps
107
+ the default width, and an NxN grid keeps the default size per panel.
108
+
109
+ Parameters
110
+ ----------
111
+ nrows, ncols : int
112
+ Grid shape.
113
+ panel : (float, float), optional
114
+ Per-panel ``(width, height)`` in inches. Defaults to the theme's
115
+ ``figure.figsize``.
116
+
117
+ Returns
118
+ -------
119
+ (width, height) : tuple of float
120
+ """
121
+ w, h = panel if panel is not None else mpl.rcParams["figure.figsize"]
122
+ return (w * ncols, h * nrows)
123
+
124
+
125
+ def _square_figsize(n: int = 1) -> tuple[float, float]:
126
+ """
127
+ Square panel size derived from the default figure height.
128
+
129
+ Used for constellation-style plots that require an equal aspect ratio,
130
+ where the default (non-square) ``figure.figsize`` doesn't apply directly.
131
+ Tiling ``n`` panels preserves the same per-panel size as ``_grid_figsize``.
132
+ """
133
+ _, h = mpl.rcParams["figure.figsize"]
134
+ return (h * n, h * n)
135
+
136
+
137
+ def _decimate_minmax(
138
+ x: np.ndarray, y: np.ndarray, max_points: int = 4000
139
+ ) -> tuple[np.ndarray, np.ndarray]:
140
+ """Down-sample a line for fast plotting while preserving its envelope.
141
+
142
+ Buckets the data and keeps the per-bucket min **and** max (emitted in
143
+ x-order), so sharp features - spectral peaks, window edges, glitches -
144
+ survive, unlike plain striding. Matplotlib renders every vertex, so for
145
+ long oversampled records (N ≳ 10⁵) plotting the raw trace is the dominant
146
+ cost; reducing to a few thousand points is visually identical but orders of
147
+ magnitude faster.
148
+
149
+ Parameters
150
+ ----------
151
+ x, y : np.ndarray
152
+ 1-D arrays of equal length.
153
+ max_points : int, default 4000
154
+ Approximate cap on the number of plotted points. Returned length is
155
+ ``≈ max_points`` (``2`` per bucket). Pass ``<= 0`` to disable.
156
+
157
+ Returns
158
+ -------
159
+ (x_dec, y_dec) : tuple of np.ndarray
160
+ Decimated arrays, or the inputs unchanged when already short enough.
161
+ """
162
+ n = len(y)
163
+ if max_points <= 0 or n <= max_points:
164
+ return x, y
165
+
166
+ n_buckets = max(1, max_points // 2)
167
+ bucket = n // n_buckets
168
+ trimmed = n_buckets * bucket
169
+ yr = y[:trimmed].reshape(n_buckets, bucket)
170
+ xr = x[:trimmed].reshape(n_buckets, bucket)
171
+
172
+ rows = np.arange(n_buckets)
173
+ i_min = yr.argmin(axis=1)
174
+ i_max = yr.argmax(axis=1)
175
+ # Emit the two extrema per bucket in their original x-order so the line
176
+ # does not zig-zag backwards.
177
+ first_is_min = i_min <= i_max
178
+ x_out = np.empty(n_buckets * 2, dtype=x.dtype)
179
+ y_out = np.empty(n_buckets * 2, dtype=y.dtype)
180
+ x_out[0::2] = np.where(first_is_min, xr[rows, i_min], xr[rows, i_max])
181
+ y_out[0::2] = np.where(first_is_min, yr[rows, i_min], yr[rows, i_max])
182
+ x_out[1::2] = np.where(first_is_min, xr[rows, i_max], xr[rows, i_min])
183
+ y_out[1::2] = np.where(first_is_min, yr[rows, i_max], yr[rows, i_min])
184
+
185
+ if trimmed < n: # keep the tail the reshape dropped
186
+ x_out = np.concatenate([x_out, x[trimmed:]])
187
+ y_out = np.concatenate([y_out, y[trimmed:]])
188
+ return x_out, y_out
189
+
190
+
191
+ def _set_eng_formatter(ax, which: str, unit: str) -> None:
192
+ """Apply an engineering (SI-prefix) tick formatter to an axis."""
193
+ fmt = mpl.ticker.EngFormatter(unit=unit, sep=" ")
194
+ if which in ("x", "both"):
195
+ ax.xaxis.set_major_formatter(fmt)
196
+ if which in ("y", "both"):
197
+ ax.yaxis.set_major_formatter(fmt)
198
+
199
+
200
+ def _as_channels(arr) -> np.ndarray:
201
+ """Bring to CPU and promote ``(N,)`` -> ``(1, N)``."""
202
+ arr = np.atleast_2d(np.asarray(to_device(arr, "cpu")))
203
+ return arr
@@ -0,0 +1,200 @@
1
+ """Time-domain waveform plots."""
2
+
3
+ from typing import Any
4
+
5
+ import matplotlib.pyplot as plt
6
+ import numpy as np
7
+
8
+ from ..backend import dispatch, to_device
9
+ from ..core.signal import Signal
10
+ from ..logger import logger
11
+ from .theme import (
12
+ _create_subplot_grid,
13
+ _grid_figsize,
14
+ )
15
+
16
+
17
+ def plot_time_domain(
18
+ samples: Any,
19
+ sampling_rate: float = 1.0,
20
+ start_symbol: int = 0,
21
+ num_symbols: int | None = None,
22
+ sps: float | None = None,
23
+ ax: Any | None = None,
24
+ title: str | None = "Waveform",
25
+ show: bool = False,
26
+ **kwargs: Any,
27
+ ) -> tuple[Any, Any] | None:
28
+ """
29
+ Plots the time-domain representation of the signal.
30
+
31
+ For complex signals, both In-Phase (I) and Quadrature (Q) components
32
+ are plotted. Handles SI scaling (s, ms, us, etc.) for the time axis.
33
+
34
+ Parameters
35
+ ----------
36
+ samples : array_like or Signal
37
+ Input signal samples. Shape: (..., N_samples).
38
+ sampling_rate : float, default 1.0
39
+ Sampling rate in Hz.
40
+ start_symbol : int, default 0
41
+ The starting symbol to plot.
42
+ num_symbols : int, optional
43
+ Limit plot to a specific number of symbol periods. Requires `sps`.
44
+ sps : float, optional
45
+ Samples per symbol (required if `num_symbols` is used).
46
+ ax : matplotlib.axes.Axes, optional
47
+ Existing axis to plot on.
48
+ title : str, optional
49
+ Plot title. Defaults to "Waveform".
50
+ show : bool, default False
51
+ If True, calls `plt.show()`.
52
+ **kwargs : Any
53
+ Additional keyword arguments passed to `ax.plot`.
54
+
55
+ Returns
56
+ -------
57
+ fig : matplotlib.figure.Figure
58
+ The figure object.
59
+ ax : matplotlib.axes.Axes or ndarray
60
+ The axis or array of axes used for the plot.
61
+ """
62
+ if isinstance(samples, Signal):
63
+ sig = samples
64
+ return plot_time_domain(
65
+ sig.samples,
66
+ sampling_rate=sig.sampling_rate,
67
+ start_symbol=start_symbol,
68
+ num_symbols=num_symbols,
69
+ sps=sig.sps,
70
+ ax=ax,
71
+ title=title,
72
+ show=show,
73
+ **kwargs,
74
+ )
75
+
76
+ logger.debug("Generating time-domain plot.")
77
+
78
+ samples, xp, _ = dispatch(samples)
79
+
80
+ # Handle Multichannel
81
+ # Convention: (Channels, Time)
82
+ if samples.ndim > 1:
83
+ num_channels = samples.shape[0]
84
+
85
+ if ax is None:
86
+ nrows, ncols = _create_subplot_grid(num_channels)
87
+ fig, axes = plt.subplots(
88
+ nrows, ncols, figsize=_grid_figsize(nrows, ncols), squeeze=False
89
+ )
90
+ else:
91
+ if not isinstance(ax, (list, tuple, np.ndarray)):
92
+ logger.warning(
93
+ "Multiple channels detected but single axis provided. Overlaying plots."
94
+ )
95
+ axes = np.array([[ax] * num_channels])
96
+ fig = ax.figure
97
+ else:
98
+ axes = np.atleast_2d(ax)
99
+ fig = axes.flat[0].figure
100
+
101
+ for i in range(num_channels):
102
+ channel_samples = samples[i]
103
+
104
+ # Determine target axis using 2D indexing
105
+ row, col = divmod(i, axes.shape[1])
106
+ target_ax = axes[row, col] if row < axes.shape[0] else axes.flat[-1]
107
+
108
+ ch_title = f"{title} (Ch {i})" if title else f"Channel {i}"
109
+
110
+ plot_time_domain(
111
+ channel_samples,
112
+ sampling_rate=sampling_rate,
113
+ start_symbol=start_symbol,
114
+ num_symbols=num_symbols,
115
+ sps=sps,
116
+ ax=target_ax,
117
+ title=ch_title,
118
+ show=False,
119
+ **kwargs,
120
+ )
121
+
122
+ if show:
123
+ plt.show()
124
+ return None
125
+ return fig, axes
126
+
127
+ # --- 1D Logic ---
128
+
129
+ if ax is None:
130
+ fig, ax = plt.subplots()
131
+ else:
132
+ fig = ax.figure
133
+
134
+ samples = to_device(samples, "cpu")
135
+
136
+ start_idx = int(start_symbol * sps) if sps is not None else int(start_symbol)
137
+
138
+ if num_symbols is not None and sps is not None:
139
+ limit = start_idx + int(num_symbols * sps)
140
+ if limit > len(samples):
141
+ limit = len(samples)
142
+ logger.warning(
143
+ "Limit exceeds number of symbols. Plotting up to last symbol."
144
+ )
145
+ plot_samples = samples[start_idx:limit]
146
+ else:
147
+ plot_samples = samples[start_idx:]
148
+
149
+ time_axis = np.arange(len(plot_samples)) / sampling_rate
150
+
151
+ # Auto-scale time axis
152
+ max_time = time_axis[-1] if len(time_axis) > 0 else 0
153
+ if max_time < 1e-9:
154
+ scale_factor = 1e12
155
+ unit = "ps"
156
+ elif max_time < 1e-6:
157
+ scale_factor = 1e9
158
+ unit = "ns"
159
+ elif max_time < 1e-3:
160
+ scale_factor = 1e6
161
+ unit = "µs"
162
+ elif max_time < 1:
163
+ scale_factor = 1e3
164
+ unit = "ms"
165
+ else:
166
+ scale_factor = 1.0
167
+ unit = "s"
168
+
169
+ time_axis = time_axis * scale_factor
170
+ xlabel = f"Time [{unit}]"
171
+
172
+ if np.iscomplexobj(plot_samples):
173
+ ax.plot(
174
+ time_axis,
175
+ plot_samples.real,
176
+ label="I",
177
+ **kwargs,
178
+ )
179
+ ax.plot(
180
+ time_axis,
181
+ plot_samples.imag,
182
+ label="Q",
183
+ **kwargs,
184
+ )
185
+ ax.legend()
186
+ else:
187
+ ax.plot(
188
+ time_axis,
189
+ plot_samples,
190
+ **kwargs,
191
+ )
192
+ ax.set_xlabel(xlabel)
193
+ ax.set_ylabel("Amplitude")
194
+ if title is not None:
195
+ ax.set_title(title)
196
+
197
+ if show:
198
+ plt.show()
199
+ return None
200
+ return fig, ax
commkit/py.typed ADDED
File without changes
@@ -0,0 +1,51 @@
1
+ """
2
+ Carrier phase recovery utilities.
3
+
4
+ This package provides routines for carrier phase recovery (CPR), including
5
+ streaming decision-directed PLL, block-based Viterbi-Viterbi, Blind Phase
6
+ Search, MAP Tikhonov-RTS, and pilot-aided methods, along with cycle-slip
7
+ correction and phase ambiguity resolution.
8
+
9
+ The public API is unchanged from when this was a single module:
10
+ ``from commkit.recovery import recover_carrier_phase_bps, ...`` continues to
11
+ work.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ # Re-exported so ``patch("commkit.recovery.logger...")`` and similar
17
+ # attribute access on the package namespace keep working.
18
+ from ..logger import logger
19
+ from .bps import recover_carrier_phase_bps
20
+ from .corrections import (
21
+ correct_carrier_phase,
22
+ correct_cycle_slips,
23
+ correct_phase_rotation,
24
+ resolve_channel_permutation,
25
+ resolve_phase_ambiguity,
26
+ smooth_phase_wiener,
27
+ )
28
+ from .pilots import (
29
+ recover_carrier_phase_pilot_symbols,
30
+ recover_carrier_phase_pilot_tone,
31
+ recover_carrier_phase_pilot_tones,
32
+ )
33
+ from .pll import recover_carrier_phase_pll
34
+ from .tikhonov import recover_carrier_phase_tikhonov
35
+ from .viterbi_viterbi import recover_carrier_phase_viterbi_viterbi
36
+
37
+ __all__ = [
38
+ "correct_carrier_phase",
39
+ "correct_cycle_slips",
40
+ "correct_phase_rotation",
41
+ "recover_carrier_phase_bps",
42
+ "recover_carrier_phase_pilot_tone",
43
+ "recover_carrier_phase_pilot_tones",
44
+ "recover_carrier_phase_pilot_symbols",
45
+ "recover_carrier_phase_pll",
46
+ "recover_carrier_phase_tikhonov",
47
+ "recover_carrier_phase_viterbi_viterbi",
48
+ "resolve_channel_permutation",
49
+ "resolve_phase_ambiguity",
50
+ "smooth_phase_wiener",
51
+ ]