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,219 @@
1
+ """Optical/electronic source impairments (laser/oscillator phase noise)."""
2
+
3
+ import math
4
+
5
+ import numpy as np
6
+
7
+ from ..backend import ArrayType, dispatch, is_cupy_available, to_device
8
+ from ..logger import logger
9
+
10
+ __all__ = ["apply_phase_noise", "generate_phase_noise"]
11
+
12
+
13
+ def _phase_trajectory(
14
+ shape: tuple[int, int],
15
+ sampling_rate: float,
16
+ linewidth: float,
17
+ flicker: float,
18
+ flicker_f_min: float | None,
19
+ rng: np.random.Generator,
20
+ ) -> np.ndarray:
21
+ """
22
+ NumPy float64 phase trajectories with one-sided FM-noise PSD
23
+
24
+ S_f(f) = linewidth / pi + flicker / f [Hz^2/Hz].
25
+
26
+ The white-FM part is generated exactly as a discrete Wiener walk
27
+ (per-sample increments N(0, 2*pi*linewidth/f_s)); the flicker part by
28
+ spectral shaping of white frequency noise. Generated on the CPU so a
29
+ given seed yields the identical trajectory on every backend.
30
+ """
31
+ num_samples = shape[-1]
32
+ phi = np.zeros(shape, dtype=np.float64)
33
+
34
+ if linewidth > 0.0:
35
+ std = math.sqrt(2.0 * math.pi * linewidth / sampling_rate)
36
+ phi += np.cumsum(rng.normal(0.0, std, shape), axis=-1)
37
+
38
+ if flicker > 0.0:
39
+ f = np.fft.rfftfreq(num_samples, 1.0 / sampling_rate)
40
+ f_min = (
41
+ flicker_f_min if flicker_f_min is not None else sampling_rate / num_samples
42
+ )
43
+ # A unit-variance white input has one-sided PSD 2/f_s, so shaping to
44
+ # S_f = flicker/f requires the amplitude gain sqrt(flicker/f * f_s/2).
45
+ gain = np.sqrt(flicker / np.maximum(f, f_min)) * math.sqrt(sampling_rate / 2.0)
46
+ spec = np.fft.rfft(rng.normal(0.0, 1.0, shape), axis=-1)
47
+ df = np.fft.irfft(spec * gain, num_samples, axis=-1)
48
+ phi += 2.0 * math.pi * np.cumsum(df, axis=-1) / sampling_rate
49
+
50
+ return phi
51
+
52
+
53
+ def generate_phase_noise(
54
+ num_samples: int,
55
+ sampling_rate: float,
56
+ linewidth: float = 0.0,
57
+ flicker: float = 0.0,
58
+ flicker_f_min: float | None = None,
59
+ num_streams: int = 1,
60
+ seed: int | None = None,
61
+ ) -> ArrayType:
62
+ """
63
+ Generates laser/oscillator phase-noise trajectories phi[n] in radians.
64
+
65
+ The instantaneous-frequency (FM) noise follows the standard power-law
66
+ model with a white and a flicker component:
67
+
68
+ S_f(f) = linewidth / pi + flicker / f [Hz^2/Hz, one-sided]
69
+
70
+ * ``linewidth`` is the Lorentzian (white-FM / Wiener) linewidth
71
+ delta_nu: the phase performs a random walk with per-sample increments
72
+ N(0, 2*pi*delta_nu / f_s) and the field spectrum is a Lorentzian of
73
+ FWHM delta_nu.
74
+ * ``flicker`` is the 1/f FM coefficient h_-1: technical noise (current
75
+ source, temperature, acoustics) that dominates below the corner
76
+ frequency f_c = pi * h_-1 / delta_nu where the two terms cross.
77
+
78
+ Returning the trajectory itself (rather than a rotated signal) makes the
79
+ ground truth available for estimator validation; apply it with
80
+ ``samples * xp.exp(1j * phi)`` or via :func:`apply_phase_noise`.
81
+
82
+ Parameters
83
+ ----------
84
+ num_samples : int
85
+ Trajectory length per stream.
86
+ sampling_rate : float
87
+ Sampling rate in Hz.
88
+ linewidth : float, default 0.0
89
+ White-FM (Lorentzian) linewidth delta_nu in Hz.
90
+ flicker : float, default 0.0
91
+ Flicker-FM coefficient h_-1 in Hz^2 (one-sided ``S_f = h_-1 / f``).
92
+ flicker_f_min : float, optional
93
+ Frequency below which the flicker shaping is held flat (the 1/f
94
+ divergence must be capped). Defaults to the record resolution
95
+ ``sampling_rate / num_samples``.
96
+ num_streams : int, default 1
97
+ Number of independent trajectories.
98
+ seed : int, optional
99
+ Random seed for reproducible trajectories.
100
+
101
+ Returns
102
+ -------
103
+ array_like
104
+ Phase in radians, ``float64``, on the active device (GPU when CuPy
105
+ is available). Shape ``(num_samples,)`` for ``num_streams=1``,
106
+ else ``(num_streams, num_samples)``.
107
+
108
+ Notes
109
+ -----
110
+ The trajectory is always generated with NumPy's ``default_rng`` and then
111
+ transferred, so a given seed produces the identical trajectory on CPU
112
+ and GPU (same convention as :func:`~commkit.helpers.generate_bits`).
113
+ """
114
+ logger.info(
115
+ "Generating phase noise (linewidth=%.3g Hz, flicker=%.3g Hz², %s stream(s)).",
116
+ linewidth,
117
+ flicker,
118
+ num_streams,
119
+ )
120
+
121
+ rng = np.random.default_rng(seed)
122
+ phi = _phase_trajectory(
123
+ (num_streams, num_samples),
124
+ sampling_rate,
125
+ linewidth,
126
+ flicker,
127
+ flicker_f_min,
128
+ rng,
129
+ )
130
+ if num_streams == 1:
131
+ phi = phi[0]
132
+ if is_cupy_available():
133
+ phi = to_device(phi, "gpu")
134
+ return phi
135
+
136
+
137
+ def apply_phase_noise(
138
+ samples: ArrayType,
139
+ sampling_rate: float,
140
+ linewidth: float,
141
+ flicker: float = 0.0,
142
+ flicker_f_min: float | None = None,
143
+ seed: int | None = None,
144
+ shared_lo: bool = False,
145
+ ) -> ArrayType:
146
+ """
147
+ Adds laser / oscillator phase noise to a signal.
148
+
149
+ Each sample is rotated by an accumulated phase drawn from the power-law
150
+ FM-noise model of :func:`generate_phase_noise` (white-FM Wiener walk
151
+ plus optional 1/f flicker):
152
+
153
+ r[n] = s[n] * exp(j * phi[n])
154
+
155
+ Parameters
156
+ ----------
157
+ samples : array_like
158
+ Complex baseband signal. Shape: ``(N,)`` (SISO) or ``(C, N)`` (MIMO).
159
+ sampling_rate : float
160
+ Sampling rate in Hz.
161
+ linewidth : float
162
+ Combined transmitter + receiver laser linewidth delta_nu in Hz.
163
+ Typical values: 100 kHz (narrow-linewidth laser) to 10 MHz (DFB).
164
+ flicker : float, default 0.0
165
+ Flicker-FM coefficient h_-1 in Hz^2 (one-sided ``S_f = h_-1 / f``).
166
+ flicker_f_min : float, optional
167
+ Low-frequency cap for the flicker shaping; see
168
+ :func:`generate_phase_noise`.
169
+ seed : int, optional
170
+ Random seed for reproducible noise.
171
+ shared_lo : bool, default False
172
+ When ``False`` (default), each channel receives independent phase noise
173
+ (separate oscillators / lasers per TX-RX path).
174
+ When ``True``, a single phase noise trajectory is shared across all
175
+ channels (common local oscillator in a coherent system).
176
+
177
+ Returns
178
+ -------
179
+ array_like
180
+ Phase-noise-impaired signal, same shape, dtype, and backend as input.
181
+
182
+ Examples
183
+ --------
184
+ >>> noisy = apply_phase_noise(sig.samples, linewidth=100e3,
185
+ ... sampling_rate=sig.sampling_rate)
186
+ """
187
+ logger.info(
188
+ "Applying phase noise (linewidth=%.3g Hz, flicker=%.3g Hz², shared_lo=%s).",
189
+ linewidth,
190
+ flicker,
191
+ shared_lo,
192
+ )
193
+
194
+ samples, xp, _ = dispatch(samples)
195
+ was_1d = samples.ndim == 1
196
+ if was_1d:
197
+ samples = samples[None, :] # (1, N)
198
+ C, N = samples.shape
199
+
200
+ rng = np.random.default_rng(seed)
201
+ num_trajectories = 1 if shared_lo else C
202
+ phase = xp.asarray(
203
+ _phase_trajectory(
204
+ (num_trajectories, N),
205
+ sampling_rate,
206
+ linewidth,
207
+ flicker,
208
+ flicker_f_min,
209
+ rng,
210
+ )
211
+ )
212
+ result = samples * xp.exp(1j * phase) # (1, N) broadcasts across channels
213
+
214
+ if result.dtype != samples.dtype:
215
+ result = result.astype(samples.dtype)
216
+
217
+ if was_1d:
218
+ return result[0]
219
+ return result
commkit/io.py ADDED
@@ -0,0 +1,308 @@
1
+ """
2
+ Signal I/O and file management utilities.
3
+
4
+ File layout
5
+ -----------
6
+ The .npz file contains the following named entries:
7
+
8
+ ``samples`` - IQ sample array (always present)
9
+ ``source_bits`` - source bit array (omitted if None)
10
+ ``source_symbols`` - source symbol array (omitted if None)
11
+ ``resolved_symbols`` - cached symbol array (only with include_cache=True)
12
+ ``resolved_bits`` - cached bit array (only with include_cache=True)
13
+ ``__metadata__`` - zero-d object array holding a YAML string with all
14
+ scalar fields.
15
+ ``__frame_metadata__`` - zero-d object array holding a YAML string with the
16
+ serialised SingleCarrierFrame fields (omitted when
17
+ the signal was not generated from a frame).
18
+ ``frame_payload_symbols`` - frame payload symbols array (omitted if no frame)
19
+ ``frame_pilot_symbols`` - frame pilot symbols array (omitted if no frame/pilots)
20
+ ``frame_payload_bits`` - frame payload bits array (omitted if no frame)
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from pathlib import Path
26
+ from typing import TYPE_CHECKING, Any
27
+
28
+ import numpy as np
29
+ import yaml
30
+
31
+ from . import backend as _backend
32
+
33
+ if TYPE_CHECKING:
34
+ from .core import Signal
35
+
36
+ # -----------------------------------------------------------------------------
37
+ # Internal constants
38
+ # -----------------------------------------------------------------------------
39
+
40
+ # Scalar / primitive metadata fields to round-trip through YAML
41
+ _META_FIELDS: tuple[str, ...] = (
42
+ "sampling_rate",
43
+ "symbol_rate",
44
+ "signal_type",
45
+ "mod_scheme",
46
+ "mod_order",
47
+ "mod_unipolar",
48
+ "mod_rz",
49
+ "pulse_shape",
50
+ "filter_span",
51
+ "rrc_rolloff",
52
+ "rc_rolloff",
53
+ "duty_cycle",
54
+ "rise_time",
55
+ "spectral_domain",
56
+ "physical_domain",
57
+ "center_frequency",
58
+ "digital_frequency_offset",
59
+ "ps_nu",
60
+ )
61
+
62
+ # Optional array fields (not always present). Pilot metadata is stored here -
63
+ # as native npz arrays, exactly like the sample/symbol arrays - rather than in
64
+ # the YAML meta block, so no array-to-list conversion is ever needed.
65
+ _OPTIONAL_ARRAY_FIELDS: tuple[str, ...] = (
66
+ "source_bits",
67
+ "source_symbols",
68
+ "ps_pmf",
69
+ "pilot_tone_frequency",
70
+ "pilot_tone_power_ratio_db",
71
+ )
72
+
73
+ # Derived / cached array fields (only written when include_cache=True)
74
+ _CACHE_FIELDS: tuple[str, ...] = ("resolved_symbols", "resolved_bits")
75
+
76
+
77
+ # -----------------------------------------------------------------------------
78
+ # Public API
79
+ # -----------------------------------------------------------------------------
80
+
81
+
82
+ def save_npz(
83
+ signal: Signal,
84
+ path: str | Path,
85
+ *,
86
+ compressed: bool = True,
87
+ include_cache: bool = False,
88
+ ) -> None:
89
+ """
90
+ Save a ``Signal`` to a NumPy archive (.npz).
91
+
92
+ Parameters
93
+ ----------
94
+ signal : Signal
95
+ The signal to persist.
96
+ path : str or Path
97
+ Destination path. A ``.npz`` extension is appended automatically
98
+ if absent.
99
+ compressed : bool, default True
100
+ Use ``savez_compressed`` (zlib). Set to ``False`` to use
101
+ the uncompressed ``savez`` (faster write, larger file).
102
+ include_cache : bool, default False
103
+ Also save ``resolved_symbols`` and ``resolved_bits`` if present.
104
+ These can be recomputed from the signal, so they are omitted by
105
+ default to keep file sizes small.
106
+
107
+ Notes
108
+ -----
109
+ When the signal was generated by ``to_signal``,
110
+ the originating frame is serialised into the archive
111
+ (``__frame_metadata__`` + ``frame_payload_symbols`` etc.). On load,
112
+ ``load_npz`` reconstructs the frame and re-attaches it so that
113
+ ``correct_timing`` and frame-aware equalizer workflows work
114
+ identically to a freshly generated signal.
115
+
116
+ Examples
117
+ --------
118
+ >>> save_npz(sig, "capture.npz")
119
+ >>> save_npz(sig, "capture", compressed=False, include_cache=True)
120
+ """
121
+ path = Path(path)
122
+ if path.suffix != ".npz":
123
+ path = path.with_suffix(".npz")
124
+
125
+ # -------------------------------------------------------------------------
126
+ # Collect arrays
127
+ # -------------------------------------------------------------------------
128
+ arrays: dict[str, Any] = {"samples": _backend.to_device(signal.samples, "CPU")}
129
+
130
+ for field in _OPTIONAL_ARRAY_FIELDS:
131
+ arr = getattr(signal, field, None)
132
+ if arr is not None:
133
+ arrays[field] = _backend.to_device(arr, "CPU")
134
+
135
+ if include_cache:
136
+ for field in _CACHE_FIELDS:
137
+ arr = getattr(signal, field, None)
138
+ if arr is not None:
139
+ arrays[field] = _backend.to_device(arr, "CPU")
140
+
141
+ # -------------------------------------------------------------------------
142
+ # Serialise originating SingleCarrierFrame (if present)
143
+ # -------------------------------------------------------------------------
144
+ # The frame holds the complete transmit-side description (structure map,
145
+ # pilot masks, preamble sequence, generated payload/pilot symbols).
146
+ # Saving it ensures that correct_timing() and pilot extraction all work
147
+ # after a save/load round-trip without requiring the caller to keep a
148
+ # reference to the original frame object.
149
+ frame = signal.frame
150
+ if frame is not None:
151
+ # All public fields are JSON-serializable primitives; nested Preamble
152
+ # is a Pydantic model and is also captured by model_dump().
153
+ # _frame_type stores the class name so load_npz can reconstruct the
154
+ # correct type when multiple frame classes exist (SingleCarrierFrame,
155
+ # future OFDMFrame, etc.) without hardcoding the class.
156
+ frame_dict = frame.model_dump(mode="json")
157
+ frame_dict["_frame_type"] = type(frame).__name__
158
+ yaml_frame = yaml.dump(frame_dict, default_flow_style=False, allow_unicode=True)
159
+ arrays["__frame_metadata__"] = np.array(yaml_frame, dtype=object)
160
+
161
+ # Save the generated symbol/bit arrays that live in PrivateAttrs and
162
+ # are NOT reproduced by model_dump(). Payload symbols and bits are
163
+ # random; pilot symbols are deterministic but cheap to cache anyway.
164
+ for npz_key, frame_attr in (
165
+ ("frame_payload_symbols", "payload_symbols"),
166
+ ("frame_pilot_symbols", "pilot_symbols"),
167
+ ("frame_payload_bits", "payload_bits"),
168
+ ):
169
+ arr = getattr(frame, frame_attr, None)
170
+ if arr is not None:
171
+ arrays[npz_key] = _backend.to_device(arr, "CPU")
172
+
173
+ # -------------------------------------------------------------------------
174
+ # Build metadata dict and serialise to YAML
175
+ # -------------------------------------------------------------------------
176
+ meta: dict = {f: getattr(signal, f) for f in _META_FIELDS}
177
+ yaml_str = yaml.dump(meta, default_flow_style=False, allow_unicode=True)
178
+
179
+ # Store as a zero-d object array so np.savez treats it as a single entry
180
+ arrays["__metadata__"] = np.array(yaml_str, dtype=object)
181
+
182
+ # -------------------------------------------------------------------------
183
+ # Write
184
+ # -------------------------------------------------------------------------
185
+ if compressed:
186
+ np.savez_compressed(path, **arrays) # type: ignore[arg-type]
187
+ else:
188
+ np.savez(path, **arrays) # type: ignore[arg-type]
189
+
190
+
191
+ def load_npz(
192
+ path: str | Path,
193
+ *,
194
+ device: str = "auto",
195
+ ) -> Signal:
196
+ """
197
+ Load a ``Signal`` from a .npz archive.
198
+
199
+ Parameters
200
+ ----------
201
+ path : str or Path
202
+ Path to the ``.npz`` file. A ``.npz`` extension is appended
203
+ automatically if absent.
204
+ device : {"auto", "cpu", "gpu"}, default "auto"
205
+ Target device after loading. ``"auto"`` moves to GPU when CuPy is
206
+ available, otherwise stays on CPU.
207
+
208
+ Returns
209
+ -------
210
+ Signal
211
+
212
+ Notes
213
+ -----
214
+ If the archive contains ``__frame_metadata__`` (written by
215
+ ``save_npz`` when the signal originated from a frame), the
216
+ originating frame is reconstructed (class determined by the
217
+ ``_frame_type`` key embedded in the metadata) and re-attached as
218
+ ``sig.frame``. This makes ``correct_timing`` and
219
+ frame-aware equalizer workflows work without any extra arguments.
220
+
221
+ Examples
222
+ --------
223
+ >>> sig = load_npz("capture.npz") # auto: GPU if available
224
+ >>> sig_cpu = load_npz("capture.npz", device="cpu")
225
+ >>> sig_gpu = load_npz("capture.npz", device="gpu")
226
+ """
227
+ from .core import Signal
228
+
229
+ path = Path(path)
230
+ if path.suffix != ".npz":
231
+ path = path.with_suffix(".npz")
232
+
233
+ # allow_pickle=True is required to read the zero-d object array that
234
+ # holds the YAML string; no arbitrary Python objects are loaded.
235
+ data = np.load(path, allow_pickle=True)
236
+
237
+ # -------------------------------------------------------------------------
238
+ # Parse YAML metadata
239
+ # -------------------------------------------------------------------------
240
+ yaml_str = str(data["__metadata__"])
241
+ meta: dict = yaml.safe_load(yaml_str)
242
+
243
+ # Build Signal constructor kwargs
244
+ # -------------------------------------------------------------------------
245
+ kwargs: dict = {f: meta.get(f) for f in _META_FIELDS}
246
+ kwargs["samples"] = data["samples"]
247
+
248
+ for field in _OPTIONAL_ARRAY_FIELDS:
249
+ if field in data:
250
+ kwargs[field] = data[field]
251
+
252
+ sig = Signal(**kwargs)
253
+
254
+ # -------------------------------------------------------------------------
255
+ # Restore cached arrays (bypass re-computation if present in file)
256
+ # -------------------------------------------------------------------------
257
+ for field in _CACHE_FIELDS:
258
+ if field in data:
259
+ setattr(sig, field, data[field])
260
+
261
+ # -------------------------------------------------------------------------
262
+ # Reconstruct originating frame (if serialised)
263
+ # -------------------------------------------------------------------------
264
+ if "__frame_metadata__" in data:
265
+ from . import core as _core
266
+
267
+ frame_dict = yaml.safe_load(str(data["__frame_metadata__"]))
268
+ frame_type_name = frame_dict.pop("_frame_type", "SingleCarrierFrame")
269
+
270
+ # Registry of known frame classes - extend here as new frame types land.
271
+ _FRAME_CLASSES = {
272
+ "SingleCarrierFrame": _core.SingleCarrierFrame,
273
+ }
274
+ frame_cls = _FRAME_CLASSES.get(frame_type_name)
275
+ if frame_cls is None:
276
+ raise ValueError(
277
+ f"Cannot reconstruct frame of type {frame_type_name!r}: "
278
+ "unknown frame class. Extend _FRAME_CLASSES in io.py."
279
+ )
280
+ frame = frame_cls(**frame_dict)
281
+
282
+ # Inject the cached symbol/bit arrays back into the frame's PrivateAttrs
283
+ # so that frame.payload_symbols, frame.pilot_symbols, frame.payload_bits
284
+ # return the original generated data without re-randomising.
285
+ if "frame_payload_symbols" in data:
286
+ frame._payload_symbols = data["frame_payload_symbols"]
287
+ if "frame_pilot_symbols" in data:
288
+ frame._pilot_symbols = data["frame_pilot_symbols"]
289
+ if "frame_payload_bits" in data:
290
+ frame._payload_bits = data["frame_payload_bits"]
291
+
292
+ sig.frame = frame
293
+
294
+ # _payload_ps_pmf is a PrivateAttr set during _ensure_payload_generated().
295
+ # When _payload_bits is restored above, that method returns early and never
296
+ # sets _payload_ps_pmf. sig.ps_pmf was saved via _OPTIONAL_ARRAY_FIELDS and
297
+ # is already loaded, so restore from it directly.
298
+ if sig.ps_pmf is not None:
299
+ frame._payload_ps_pmf = sig.ps_pmf
300
+
301
+ # -------------------------------------------------------------------------
302
+ # Move to target device
303
+ # -------------------------------------------------------------------------
304
+ target = device.lower()
305
+ if target == "auto":
306
+ target = "gpu" if _backend.is_cupy_available() else "cpu"
307
+ sig = sig.to(target)
308
+ return sig
commkit/logger.py ADDED
@@ -0,0 +1,103 @@
1
+ """
2
+ Logging utilities for the CommKit library.
3
+
4
+ This module provides a unified, colorized logging interface for monitoring
5
+ signal processing workflows and debugging complex system failures.
6
+ """
7
+
8
+ import logging
9
+ import sys
10
+
11
+
12
+ class ColorFormatter(logging.Formatter):
13
+ """
14
+ Custom logging formatter providing ANSI-colored output based on log levels.
15
+
16
+ This formatter enhances readability by using distinct colors for different
17
+ severities (e.g., Cyan for DEBUG, Red for ERROR).
18
+ """
19
+
20
+ GREY = "\x1b[38;20m"
21
+ CYAN = "\x1b[36;20m"
22
+ GREEN = "\x1b[32;20m"
23
+ YELLOW = "\x1b[33;20m"
24
+ RED = "\x1b[31;20m"
25
+ BOLD_RED = "\x1b[31;1m"
26
+ RESET = "\x1b[0m"
27
+ FORMAT = "%(asctime)s [%(levelname)s] [%(name)s/%(filename)s] %(message)s"
28
+
29
+ LEVEL_COLORS = {
30
+ logging.DEBUG: CYAN,
31
+ logging.INFO: GREEN,
32
+ logging.WARNING: YELLOW,
33
+ logging.ERROR: RED,
34
+ logging.CRITICAL: BOLD_RED,
35
+ }
36
+
37
+ def format(self, record: logging.LogRecord) -> str:
38
+ """
39
+ Formats the log record with ANSI color codes.
40
+
41
+ Parameters
42
+ ----------
43
+ record : logging.LogRecord
44
+ The log record containing the message and metadata.
45
+
46
+ Returns
47
+ -------
48
+ str
49
+ The formatted log message with embedded ANSI escape sequences.
50
+ """
51
+ log_color = self.LEVEL_COLORS.get(record.levelno, self.RESET)
52
+ formatter = logging.Formatter(
53
+ f"{log_color}{self.FORMAT}{self.RESET}",
54
+ datefmt="%Y-%m-%d %H:%M:%S",
55
+ )
56
+ return formatter.format(record)
57
+
58
+
59
+ def get_logger(name: str = "commkit") -> logging.Logger:
60
+ """
61
+ Retrieves and configures a logger instance for the library.
62
+
63
+ If the requested logger has no handlers, a `StreamHandler` with the
64
+ `ColorFormatter` is automatically attached to ensure immediate visibility.
65
+
66
+ Parameters
67
+ ----------
68
+ name : str, default "commkit"
69
+ The namespace for the logger.
70
+
71
+ Returns
72
+ -------
73
+ logging.Logger
74
+ A configured logger instance.
75
+ """
76
+ logger = logging.getLogger(name)
77
+
78
+ if not logger.handlers:
79
+ logger.setLevel(logging.INFO)
80
+ handler = logging.StreamHandler(sys.stdout)
81
+ handler.setFormatter(ColorFormatter())
82
+ logger.addHandler(handler)
83
+
84
+ return logger
85
+
86
+
87
+ # Create a default logger for the package
88
+ logger = get_logger()
89
+
90
+
91
+ def set_log_level(level):
92
+ """
93
+ Sets the global log level for the CommKit library.
94
+
95
+ Parameters
96
+ ----------
97
+ level : int or str
98
+ The logging level to apply. Accepts standard `logging` constants
99
+ (e.g., `logging.DEBUG`) or string identifiers (e.g., "DEBUG", "INFO").
100
+ """
101
+ if isinstance(level, str):
102
+ level = getattr(logging, level.upper())
103
+ logger.setLevel(level)
@@ -0,0 +1,46 @@
1
+ """
2
+ Symbol mapping, demapping, and constellation management.
3
+
4
+ This package provides high-performance routines for the transition between
5
+ digital bits and physical IQ symbols. It is organised by mathematical concern:
6
+
7
+ - :mod:`~commkit.mapping.gray` - constellation geometry and Gray labelling.
8
+ - :mod:`~commkit.mapping.bits` - hard bit mapping / demapping.
9
+ - :mod:`~commkit.mapping.llr` - soft-decision (LLR) demapping.
10
+ - :mod:`~commkit.mapping.shaping` - probabilistic shaping (PS-QAM).
11
+ - :mod:`~commkit.mapping.constellation` - the :class:`Constellation` value
12
+ object bundling points + Gray labels + optional shaping pmf.
13
+
14
+ The public import surface is stable: every name previously importable from the
15
+ flat ``commkit.mapping`` module is re-exported here. ``Constellation`` is an
16
+ additive convenience over the existing loose-array free functions.
17
+
18
+ Note: codes and constellations are generated using NumPy (host-side).
19
+ """
20
+
21
+ from .bits import demap_symbols_hard, map_bits
22
+ from .constellation import Constellation
23
+ from .gray import gray_code, gray_constellation, gray_to_binary
24
+ from .llr import compute_llr
25
+ from .shaping import (
26
+ constellation_power,
27
+ maxwell_boltzmann,
28
+ optimal_nu,
29
+ ps_entropy,
30
+ sample_ps_symbols,
31
+ )
32
+
33
+ __all__ = [
34
+ "Constellation",
35
+ "compute_llr",
36
+ "constellation_power",
37
+ "demap_symbols_hard",
38
+ "gray_code",
39
+ "gray_constellation",
40
+ "gray_to_binary",
41
+ "map_bits",
42
+ "maxwell_boltzmann",
43
+ "optimal_nu",
44
+ "ps_entropy",
45
+ "sample_ps_symbols",
46
+ ]