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.
- commkit/__init__.py +74 -0
- commkit/_cuda/__init__.py +321 -0
- commkit/_cuda/compiler.py +88 -0
- commkit/_cuda/src/bps_min_d2.cu +104 -0
- commkit/_cuda/src/cs_block.cu +119 -0
- commkit/_cuda/src/selftest.cu +14 -0
- commkit/analysis/__init__.py +55 -0
- commkit/analysis/_common.py +236 -0
- commkit/analysis/allan.py +108 -0
- commkit/analysis/drift.py +213 -0
- commkit/analysis/interferometry.py +887 -0
- commkit/analysis/linewidth.py +480 -0
- commkit/analysis/trajectory.py +91 -0
- commkit/backend.py +507 -0
- commkit/coding/__init__.py +23 -0
- commkit/coding/base.py +17 -0
- commkit/coding/bch.py +6 -0
- commkit/coding/convolutional.py +7 -0
- commkit/coding/crc.py +7 -0
- commkit/coding/galois.py +8 -0
- commkit/coding/hamming.py +6 -0
- commkit/coding/interleaving.py +7 -0
- commkit/coding/ldpc.py +8 -0
- commkit/coding/polar.py +8 -0
- commkit/coding/ratematch.py +6 -0
- commkit/coding/reed_solomon.py +6 -0
- commkit/coding/turbo.py +8 -0
- commkit/core/__init__.py +32 -0
- commkit/core/frame.py +992 -0
- commkit/core/generation.py +581 -0
- commkit/core/signal.py +725 -0
- commkit/equalization/__init__.py +49 -0
- commkit/equalization/_block.py +1855 -0
- commkit/equalization/_common.py +606 -0
- commkit/equalization/_kernels_jax.py +1720 -0
- commkit/equalization/_kernels_numba.py +1704 -0
- commkit/equalization/blind.py +223 -0
- commkit/equalization/linear.py +365 -0
- commkit/equalization/polarization.py +790 -0
- commkit/equalization/result.py +191 -0
- commkit/equalization/sequential.py +2805 -0
- commkit/filtering.py +1120 -0
- commkit/frequency.py +1191 -0
- commkit/helpers.py +489 -0
- commkit/impairments/__init__.py +43 -0
- commkit/impairments/channel/__init__.py +20 -0
- commkit/impairments/channel/linear.py +310 -0
- commkit/impairments/channel/nonlinear.py +11 -0
- commkit/impairments/frontend.py +229 -0
- commkit/impairments/noise.py +105 -0
- commkit/impairments/source.py +219 -0
- commkit/io.py +308 -0
- commkit/logger.py +103 -0
- commkit/mapping/__init__.py +46 -0
- commkit/mapping/bits.py +240 -0
- commkit/mapping/constellation.py +153 -0
- commkit/mapping/gray.py +429 -0
- commkit/mapping/llr.py +253 -0
- commkit/mapping/shaping.py +218 -0
- commkit/metrics.py +949 -0
- commkit/multirate.py +476 -0
- commkit/plotting/__init__.py +78 -0
- commkit/plotting/analysis.py +627 -0
- commkit/plotting/constellation.py +483 -0
- commkit/plotting/equalizer.py +390 -0
- commkit/plotting/eye.py +388 -0
- commkit/plotting/spectral.py +575 -0
- commkit/plotting/sync.py +953 -0
- commkit/plotting/theme.py +203 -0
- commkit/plotting/waveform.py +200 -0
- commkit/py.typed +0 -0
- commkit/recovery/__init__.py +51 -0
- commkit/recovery/bps.py +337 -0
- commkit/recovery/corrections.py +751 -0
- commkit/recovery/pilots.py +803 -0
- commkit/recovery/pll.py +482 -0
- commkit/recovery/tikhonov.py +424 -0
- commkit/recovery/viterbi_viterbi.py +227 -0
- commkit/spectral.py +560 -0
- commkit/timing.py +841 -0
- commkit-1.0.0.dist-info/METADATA +145 -0
- commkit-1.0.0.dist-info/RECORD +84 -0
- commkit-1.0.0.dist-info/WHEEL +4 -0
- 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
|
+
]
|