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
commkit/core/signal.py
ADDED
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core signal processing abstractions and data containers.
|
|
3
|
+
|
|
4
|
+
This module defines the primary data structures used throughout the library.
|
|
5
|
+
It provides high-level abstractions for handling raw IQ samples, physical
|
|
6
|
+
layer metadata, and complex frame structures.
|
|
7
|
+
|
|
8
|
+
All core classes are built on Pydantic for robust validation and support
|
|
9
|
+
transparent backend switching between CPU (NumPy) and GPU (CuPy).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import types
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from pydantic import (
|
|
17
|
+
BaseModel,
|
|
18
|
+
ConfigDict,
|
|
19
|
+
Field,
|
|
20
|
+
field_validator,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
import cupy as cp
|
|
25
|
+
|
|
26
|
+
_CUPY_AVAILABLE = True
|
|
27
|
+
except ImportError:
|
|
28
|
+
cp = None
|
|
29
|
+
_CUPY_AVAILABLE = False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
from .. import helpers
|
|
33
|
+
from ..backend import (
|
|
34
|
+
ArrayType,
|
|
35
|
+
from_jax,
|
|
36
|
+
get_array_module,
|
|
37
|
+
get_scipy_module,
|
|
38
|
+
is_cupy_available,
|
|
39
|
+
to_device,
|
|
40
|
+
to_jax,
|
|
41
|
+
)
|
|
42
|
+
from ..logger import logger
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Signal(BaseModel):
|
|
46
|
+
"""
|
|
47
|
+
Primary container for digital baseband or RF signals.
|
|
48
|
+
|
|
49
|
+
The `Signal` class encapsulates complex-valued IQ samples along with the
|
|
50
|
+
physical layer metadata (sampling rate, modulation, etc.) required for
|
|
51
|
+
comprehensive Digital Signal Processing (DSP) pipelines. It supports
|
|
52
|
+
seamless switching between CPU (NumPy) and GPU (CuPy) backends.
|
|
53
|
+
|
|
54
|
+
Attributes
|
|
55
|
+
----------
|
|
56
|
+
samples : array_like
|
|
57
|
+
The complex IQ samples.
|
|
58
|
+
Shape: (N_samples,) for SISO or (N_channels, N_samples) for MIMO.
|
|
59
|
+
The last dimension is always assumed to be Time.
|
|
60
|
+
sampling_rate : float
|
|
61
|
+
Sampling frequency in Hertz (Hz). Must be > 0.
|
|
62
|
+
symbol_rate : float
|
|
63
|
+
Symbol frequency (Baud rate) in Hertz (Hz). Must be > 0.
|
|
64
|
+
mod_scheme : str, optional
|
|
65
|
+
Identifier for the modulation format (e.g., 'QPSK', '16QAM').
|
|
66
|
+
For frame-generated signals this is ``None``; modulation is carried by
|
|
67
|
+
``frame.payload_mod_scheme`` instead.
|
|
68
|
+
mod_order : int, optional
|
|
69
|
+
The modulation order. Similar to `mod_scheme`, it might be `None`
|
|
70
|
+
if multiple modes are present within a frame. See
|
|
71
|
+
``frame.payload_mod_order``.
|
|
72
|
+
mod_unipolar : bool, optional
|
|
73
|
+
If True, uses a unipolar constellation (e.g., 0 to M-1).
|
|
74
|
+
mod_rz : bool, optional
|
|
75
|
+
If True, uses Return-to-Zero (RZ) signaling.
|
|
76
|
+
source_bits : array_like, optional
|
|
77
|
+
The original binary data that generated the signal (full wire order).
|
|
78
|
+
Populated by ``Signal.generate`` and the factory methods.
|
|
79
|
+
For frame-generated signals this is ``None``; extract the payload
|
|
80
|
+
segment via ``frame.get_structure_map()`` and construct a plain
|
|
81
|
+
``Signal`` with the relevant ``source_bits`` for per-segment metrics.
|
|
82
|
+
source_symbols : array_like, optional
|
|
83
|
+
The mapped constellation symbols before pulse shaping (full wire
|
|
84
|
+
order). Same scoping note as ``source_bits``.
|
|
85
|
+
ps_pmf : array_like of float, optional
|
|
86
|
+
Maxwell-Boltzmann PMF of shape ``(M,)`` for PS-QAM signals.
|
|
87
|
+
Set automatically by ``generate_psqam``. When present, the
|
|
88
|
+
normalization of ``source_symbols`` is skipped (PS symbols have
|
|
89
|
+
intentionally lower average energy than uniform QAM), and
|
|
90
|
+
``mi``, ``gmi``, and ``plot_constellation`` use the
|
|
91
|
+
non-uniform prior automatically. ``None`` for all other modulations.
|
|
92
|
+
ps_nu : float, optional
|
|
93
|
+
Maxwell-Boltzmann shaping parameter ν ≥ 0. Set automatically
|
|
94
|
+
alongside ``ps_pmf`` by ``generate_psqam`` and
|
|
95
|
+
``SingleCarrierFrame.to_signal``. ν = 0 is uniform QAM (never
|
|
96
|
+
stored; ``ps_nu`` is ``None`` for non-PS signals). When the
|
|
97
|
+
signal was specified via ``entropy``, ν is the numerically solved
|
|
98
|
+
value returned by ``mapping.optimal_nu``.
|
|
99
|
+
pulse_shape : str, optional
|
|
100
|
+
Name of the pulse shaping filter (e.g., ``'rrc'``, ``'rect'``,
|
|
101
|
+
``'gaussian'``).
|
|
102
|
+
filter_span : int
|
|
103
|
+
Span of the pulse-shaping filter in symbols.
|
|
104
|
+
rrc_rolloff : float
|
|
105
|
+
Roll-off factor for the Root-Raised Cosine (RRC) filter.
|
|
106
|
+
rc_rolloff : float
|
|
107
|
+
Roll-off factor for the Raised Cosine (RC) filter.
|
|
108
|
+
duty_cycle : float
|
|
109
|
+
Pulse width in symbol periods. Meaning depends on pulse shape:
|
|
110
|
+
``rect``/``smoothrect`` - on-time fraction (incl. ramps);
|
|
111
|
+
``gaussian`` - FWHM. For NRZ signals this is always 1.0 internally;
|
|
112
|
+
only meaningful when ``mod_rz=True``. Stored so
|
|
113
|
+
``generate_shaping_taps()`` can reconstruct the correct taps.
|
|
114
|
+
rise_time : float
|
|
115
|
+
Edge transition duration in symbol periods for ``rect`` and
|
|
116
|
+
``smoothrect``. For ``rect``: linear ramp duration (flat top =
|
|
117
|
+
``duty_cycle - 2 * rise_time``). For ``smoothrect``: 10%-90%
|
|
118
|
+
erf-edge duration. Ignored for all other pulse shapes.
|
|
119
|
+
spectral_domain : {"BASEBAND", "PASSBAND", "INTERMEDIATE"}
|
|
120
|
+
The signal's current placement in the frequency spectrum.
|
|
121
|
+
physical_domain : {"DIG", "RF", "OPT"}
|
|
122
|
+
The physical transmission domain: ``'DIG'`` (Digital), ``'RF'``
|
|
123
|
+
(Radio), ``'OPT'`` (Optical).
|
|
124
|
+
center_frequency : float
|
|
125
|
+
The carrier or center frequency in Hz.
|
|
126
|
+
digital_frequency_offset : float
|
|
127
|
+
Cumulative digital frequency shift applied to the signal in Hz.
|
|
128
|
+
pilot_tone_frequency : numpy.ndarray
|
|
129
|
+
Per-channel pilot-tone frequencies in Hz, as a 1-D ``float64`` array
|
|
130
|
+
(one entry per channel; length 1 for a SISO / shared tone). Any scalar
|
|
131
|
+
or sequence assigned is coerced to this array form, so the field is
|
|
132
|
+
handled uniformly like the other array fields. Set by
|
|
133
|
+
``add_pilot_tone``; distinct per-channel tones enable e.g. tone-based
|
|
134
|
+
polarization demultiplexing.
|
|
135
|
+
pilot_tone_power_ratio_db : numpy.ndarray
|
|
136
|
+
Per-channel pilot-to-signal power ratio (PSR) in dB of the added
|
|
137
|
+
tone(s), in the same 1-D ``float64`` array form as
|
|
138
|
+
``pilot_tone_frequency``. Set by ``add_pilot_tone``; travels with the
|
|
139
|
+
signal through save/load.
|
|
140
|
+
signal_type : {"Single-Carrier Frame", "OFDM Frame", "Preamble"}, optional
|
|
141
|
+
Human-readable label for the signal structure. Informational only.
|
|
142
|
+
frame : Frame, optional
|
|
143
|
+
The frame that generated the signal.
|
|
144
|
+
resolved_symbols : array_like, optional
|
|
145
|
+
Symbols at 1 SPS, normalised to unit average power.
|
|
146
|
+
Populated by ``resolve_symbols()``. Call only on plain signals
|
|
147
|
+
(non-frame); frame signals contain mixed preamble/pilot/payload that
|
|
148
|
+
may have different modulations or gains - resolve after splitting.
|
|
149
|
+
resolved_bits : array_like, optional
|
|
150
|
+
Hard-decision bits demapped from ``resolved_symbols``.
|
|
151
|
+
Populated by ``demap_symbols_hard()``.
|
|
152
|
+
|
|
153
|
+
Notes
|
|
154
|
+
-----
|
|
155
|
+
**Frame-generated signals**: ``SingleCarrierFrame.to_signal`` sets
|
|
156
|
+
``self.frame`` but leaves ``source_symbols`` and
|
|
157
|
+
``source_bits`` as ``None``. The receive workflow is:
|
|
158
|
+
|
|
159
|
+
1. Run timing / FOE / CPR / equalization on the frame signal.
|
|
160
|
+
2. Use ``frame.get_structure_map()`` to slice sample/symbol indices.
|
|
161
|
+
3. Extract each segment and build a plain ``Signal`` with the appropriate
|
|
162
|
+
``source_symbols``/``source_bits`` before calling ``resolve_symbols()``,
|
|
163
|
+
``evm()``, ``ber()``, etc.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
model_config = ConfigDict(
|
|
167
|
+
arbitrary_types_allowed=True, validate_assignment=True, extra="forbid"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
samples: Any
|
|
171
|
+
sampling_rate: float = Field(..., gt=0)
|
|
172
|
+
symbol_rate: float = Field(..., gt=0)
|
|
173
|
+
|
|
174
|
+
mod_scheme: str | None = None
|
|
175
|
+
mod_order: int | None = None
|
|
176
|
+
mod_unipolar: bool | None = None
|
|
177
|
+
mod_rz: bool | None = None
|
|
178
|
+
|
|
179
|
+
source_bits: Any | None = None
|
|
180
|
+
source_symbols: Any | None = None
|
|
181
|
+
ps_pmf: Any | None = None # (M,) PMF over constellation; set only for PS-QAM
|
|
182
|
+
ps_nu: float | None = None # MB shaping parameter ν; set only for PS-QAM
|
|
183
|
+
|
|
184
|
+
pulse_shape: str | None = None
|
|
185
|
+
filter_span: int = Field(default=10, ge=1)
|
|
186
|
+
rrc_rolloff: float = Field(default=0.35, ge=0, le=1)
|
|
187
|
+
rc_rolloff: float = Field(default=0.35, ge=0, le=1)
|
|
188
|
+
duty_cycle: float = Field(default=1.0, gt=0, le=1)
|
|
189
|
+
rise_time: float = Field(default=0.0, ge=0)
|
|
190
|
+
|
|
191
|
+
spectral_domain: Literal["BASEBAND", "PASSBAND", "INTERMEDIATE"] = "BASEBAND"
|
|
192
|
+
physical_domain: Literal["DIG", "RF", "OPT"] = "DIG"
|
|
193
|
+
|
|
194
|
+
center_frequency: float = Field(default=0, ge=0)
|
|
195
|
+
digital_frequency_offset: float | None = None
|
|
196
|
+
pilot_tone_frequency: Any | None = None
|
|
197
|
+
pilot_tone_power_ratio_db: Any | None = None
|
|
198
|
+
|
|
199
|
+
# Human-readable label for the signal structure
|
|
200
|
+
signal_type: Literal["Single-Carrier Frame", "OFDM Frame", "Preamble"] | None = None
|
|
201
|
+
|
|
202
|
+
# Back-reference to the SingleCarrierFrame that generated this signal (set by
|
|
203
|
+
# SingleCarrierFrame.to_signal()). Enables frame-aware convenience methods
|
|
204
|
+
# (correct_timing, frame-aware equalizers) without requiring the caller to re-supply
|
|
205
|
+
# the frame object. Excluded from serialisation (numpy arrays inside frame
|
|
206
|
+
# duplicate samples data and are not JSON-serialisable).
|
|
207
|
+
frame: Any | None = Field(default=None, exclude=True, repr=False)
|
|
208
|
+
|
|
209
|
+
# Resolved data from processing (1 SPS, normalized - populated by resolve_symbols())
|
|
210
|
+
resolved_symbols: Any | None = Field(default=None, repr=False)
|
|
211
|
+
resolved_bits: Any | None = Field(default=None, repr=False)
|
|
212
|
+
|
|
213
|
+
# -------------------------------------------------------------------------
|
|
214
|
+
# Validators and Post-Initialization Hooks
|
|
215
|
+
# -------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
@field_validator("pilot_tone_frequency", "pilot_tone_power_ratio_db", mode="before")
|
|
218
|
+
@classmethod
|
|
219
|
+
def _coerce_pilot_field(cls, v: Any) -> Any:
|
|
220
|
+
"""Coerce pilot metadata to a 1-D per-channel ``float64`` array.
|
|
221
|
+
|
|
222
|
+
``None`` passes through; anything else (scalar, sequence, or array)
|
|
223
|
+
becomes a 1-D ``float64`` ``np.ndarray`` - one value per channel - so the
|
|
224
|
+
field is handled uniformly like the other array fields (always an array,
|
|
225
|
+
never a scalar or list). A scalar becomes a length-1 array.
|
|
226
|
+
"""
|
|
227
|
+
if v is None:
|
|
228
|
+
return None
|
|
229
|
+
return np.asarray(v, dtype=np.float64).reshape(-1)
|
|
230
|
+
|
|
231
|
+
@field_validator("samples", mode="before")
|
|
232
|
+
@classmethod
|
|
233
|
+
def validate_samples(cls, v: Any) -> Any:
|
|
234
|
+
"""
|
|
235
|
+
Validates and coerces the samples input into a backend-compatible array.
|
|
236
|
+
|
|
237
|
+
This validator ensures that the input is converted to a NumPy or CuPy array
|
|
238
|
+
and enforces a (Channels, Time) shape convention for multidimensional inputs.
|
|
239
|
+
|
|
240
|
+
Parameters
|
|
241
|
+
----------
|
|
242
|
+
v : array_like
|
|
243
|
+
Input samples (list, tuple, NumPy array, CuPy array, or JAX array).
|
|
244
|
+
|
|
245
|
+
Returns
|
|
246
|
+
-------
|
|
247
|
+
array_like
|
|
248
|
+
The validated samples as a NumPy or CuPy array.
|
|
249
|
+
|
|
250
|
+
Raises
|
|
251
|
+
------
|
|
252
|
+
ValueError
|
|
253
|
+
If the input cannot be converted to a supported array type or has
|
|
254
|
+
unsupported dimensions (> 2).
|
|
255
|
+
|
|
256
|
+
Notes
|
|
257
|
+
-----
|
|
258
|
+
The library enforces a **Time-Last** convention: (N_channels, N_samples)
|
|
259
|
+
or simply (N_samples,) for 1D signals. This aligns with C-contiguous
|
|
260
|
+
memory layout which is generally more performant for time-axis operations.
|
|
261
|
+
"""
|
|
262
|
+
arr = helpers.validate_array(v, name="samples")
|
|
263
|
+
|
|
264
|
+
# Check shape conventions
|
|
265
|
+
# We enforce Time-Last convention: (Channels, Time) or (Time,) for 1D.
|
|
266
|
+
# This aligns better with C-contiguous operations on the time axis (last axis)
|
|
267
|
+
# which is critical for CuPy performance/stability.
|
|
268
|
+
|
|
269
|
+
if arr.ndim > 2:
|
|
270
|
+
raise ValueError(
|
|
271
|
+
f"Samples array has {arr.ndim} dimensions. "
|
|
272
|
+
"Only 1D (SISO) or 2D (MIMO/Dual-Pol) arrays are supported."
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
if arr.ndim == 2:
|
|
276
|
+
# Check dimensions to guess orientation
|
|
277
|
+
s0, s1 = arr.shape
|
|
278
|
+
# If dim0 (rows) > dim1 (cols) and dim0 >> 10, it's likely (Time, Channels)
|
|
279
|
+
# We want (Channels, Time).
|
|
280
|
+
if s0 > s1 and s0 > 32: # Heuristic: Time dim usually > 32
|
|
281
|
+
logger.warning(
|
|
282
|
+
"Samples shape is %s. Converting to Time-Last convention (N_channels=%s, N_samples=%s). Please provide input as (N_channels, N_samples) for MIMO signals.",
|
|
283
|
+
arr.shape,
|
|
284
|
+
s1,
|
|
285
|
+
s0,
|
|
286
|
+
)
|
|
287
|
+
arr = arr.T # Transpose to (Channels, Time)
|
|
288
|
+
|
|
289
|
+
# If shape is (2, 2), ambiguous but assumes (Channels, Time)
|
|
290
|
+
# If s1 > s0, likely already correct.
|
|
291
|
+
|
|
292
|
+
return arr
|
|
293
|
+
|
|
294
|
+
def model_post_init(self, __context: Any) -> None:
|
|
295
|
+
"""
|
|
296
|
+
Post-initialization hook to handle metadata derivation and device placement.
|
|
297
|
+
|
|
298
|
+
This method automatically derives `source_symbols` from `source_bits` if
|
|
299
|
+
modulation parameters are present, and moves the signal samples to
|
|
300
|
+
the GPU if a compatible device is available.
|
|
301
|
+
"""
|
|
302
|
+
# Bit-first: derive symbols from bits if not provided
|
|
303
|
+
if self.source_bits is not None and self.source_symbols is None:
|
|
304
|
+
if self.mod_scheme and self.mod_order:
|
|
305
|
+
from .. import mapping
|
|
306
|
+
|
|
307
|
+
self.source_symbols = mapping.map_bits(
|
|
308
|
+
self.source_bits,
|
|
309
|
+
self.mod_scheme,
|
|
310
|
+
self.mod_order,
|
|
311
|
+
unipolar=self.mod_unipolar or False,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# Ensure source_symbols are normalized to unit average power for consistent metrics.
|
|
315
|
+
# For MIMO (multichannel), we normalize per-stream (axis=-1) to ensure each stream
|
|
316
|
+
# independently adheres to E_s=1, facilitating per-stream metric calculation.
|
|
317
|
+
# Skip for PS-QAM: symbols are exact constellation points whose sample average
|
|
318
|
+
# power is intentionally < 1 (MB weights inner points more). Scaling them would
|
|
319
|
+
# break the correspondence with ps_pmf.
|
|
320
|
+
if self.source_symbols is not None and self.ps_pmf is None:
|
|
321
|
+
self.source_symbols = helpers.normalize(
|
|
322
|
+
self.source_symbols, mode="average_power", axis=-1
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# Default to GPU if available and supported
|
|
326
|
+
if is_cupy_available():
|
|
327
|
+
self.to("gpu")
|
|
328
|
+
|
|
329
|
+
# -------------------------------------------------------------------------
|
|
330
|
+
# Utilities
|
|
331
|
+
# -------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
def print_info(self) -> None:
|
|
334
|
+
"""
|
|
335
|
+
Prints a formatted summary of the signal's physical and digital properties.
|
|
336
|
+
|
|
337
|
+
In Jupyter/IPython environments, this renders as an HTML table. In standard
|
|
338
|
+
shells, it outputs a plain-text table via the logger.
|
|
339
|
+
|
|
340
|
+
Sections
|
|
341
|
+
--------
|
|
342
|
+
**Signal** - always shown: type, waveform, rate, shape, backend.
|
|
343
|
+
**Frame structure** - shown when ``frame`` carries frame metadata
|
|
344
|
+
(preamble, payload, pilots, guard).
|
|
345
|
+
**Reference data** - shows which symbol/bit arrays and frame object are
|
|
346
|
+
attached (determines which of ``ber()``, ``evm()``
|
|
347
|
+
can be called without extra arguments).
|
|
348
|
+
"""
|
|
349
|
+
|
|
350
|
+
# -- helpers ----------------------------------------------------------
|
|
351
|
+
def _yn(v) -> str:
|
|
352
|
+
return "yes" if v is not None else "no"
|
|
353
|
+
|
|
354
|
+
# Modulation: frame signals store it on the frame.
|
|
355
|
+
frame = getattr(self, "frame", None)
|
|
356
|
+
mod_scheme = self.mod_scheme or (getattr(frame, "payload_mod_scheme", None))
|
|
357
|
+
mod_order = self.mod_order or (getattr(frame, "payload_mod_order", None))
|
|
358
|
+
mod_unipolar = self.mod_unipolar or (
|
|
359
|
+
getattr(frame, "payload_mod_unipolar", False)
|
|
360
|
+
)
|
|
361
|
+
mod_str = (
|
|
362
|
+
f"{mod_scheme or 'None'} / {mod_order or 'None'}"
|
|
363
|
+
f"{' (UNIPOL)' if mod_unipolar else ''}"
|
|
364
|
+
f"{' (RZ)' if self.mod_rz else ''}"
|
|
365
|
+
)
|
|
366
|
+
bit_rate = (
|
|
367
|
+
helpers.format_si(self.symbol_rate * np.log2(mod_order), "bps")
|
|
368
|
+
if mod_order
|
|
369
|
+
else "None"
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
# -- Section 1: Signal ---------------------------------------------
|
|
373
|
+
sig_type_label = self.signal_type or "Signal"
|
|
374
|
+
|
|
375
|
+
rows: list[tuple[str, str]] = [
|
|
376
|
+
("Signal type", sig_type_label),
|
|
377
|
+
("Spectral domain", self.spectral_domain),
|
|
378
|
+
("Physical domain", self.physical_domain),
|
|
379
|
+
("Modulation", mod_str),
|
|
380
|
+
("Symbol rate", helpers.format_si(self.symbol_rate, "Baud")),
|
|
381
|
+
("Bit rate", bit_rate),
|
|
382
|
+
("Sampling rate", helpers.format_si(self.sampling_rate, "Hz")),
|
|
383
|
+
("Samples per symbol", f"{self.sps:.2f}"),
|
|
384
|
+
("Pulse shape", self.pulse_shape.upper() if self.pulse_shape else "None"),
|
|
385
|
+
]
|
|
386
|
+
|
|
387
|
+
if self.ps_pmf is not None and mod_order:
|
|
388
|
+
rows.append(
|
|
389
|
+
(
|
|
390
|
+
"PS shaping (ν)",
|
|
391
|
+
f"{self.ps_nu:.4f}" if self.ps_nu is not None else "unknown",
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
rows += [
|
|
396
|
+
("Duration", helpers.format_si(self.duration, "s")),
|
|
397
|
+
("Center frequency", helpers.format_si(self.center_frequency, "Hz")),
|
|
398
|
+
]
|
|
399
|
+
|
|
400
|
+
if self.digital_frequency_offset is not None:
|
|
401
|
+
rows.append(
|
|
402
|
+
(
|
|
403
|
+
"Frequency offset",
|
|
404
|
+
helpers.format_si(self.digital_frequency_offset, "Hz"),
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
def _pilot_row(value, fmt) -> str:
|
|
409
|
+
# value is a 1-D per-channel array (validator-coerced).
|
|
410
|
+
return ", ".join(fmt(float(x)) for x in value)
|
|
411
|
+
|
|
412
|
+
if self.pilot_tone_frequency is not None:
|
|
413
|
+
rows.append(
|
|
414
|
+
(
|
|
415
|
+
"Pilot tone frequency",
|
|
416
|
+
_pilot_row(
|
|
417
|
+
self.pilot_tone_frequency,
|
|
418
|
+
lambda x: helpers.format_si(x, "Hz"),
|
|
419
|
+
),
|
|
420
|
+
)
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
if self.pilot_tone_power_ratio_db is not None:
|
|
424
|
+
rows.append(
|
|
425
|
+
(
|
|
426
|
+
"Pilot tone power",
|
|
427
|
+
_pilot_row(self.pilot_tone_power_ratio_db, lambda x: f"{x:.1f} dB"),
|
|
428
|
+
)
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
rows += [
|
|
432
|
+
("Backend", self.backend.upper()),
|
|
433
|
+
(
|
|
434
|
+
"Configuration",
|
|
435
|
+
"SISO" if self.num_streams == 1 else f"MIMO ({self.num_streams}x)",
|
|
436
|
+
),
|
|
437
|
+
("Samples shape", str(self.samples.shape)),
|
|
438
|
+
]
|
|
439
|
+
|
|
440
|
+
# -- Section 2: Structure info (content varies by signal_type) -------
|
|
441
|
+
if self.signal_type == "Preamble" and self.frame is not None:
|
|
442
|
+
preamble = getattr(self.frame, "preamble", None)
|
|
443
|
+
if preamble is not None:
|
|
444
|
+
rows.append(("--- Preamble info", ""))
|
|
445
|
+
preamble_str = (
|
|
446
|
+
f"{preamble.sequence_type.upper()} len={preamble.length}"
|
|
447
|
+
)
|
|
448
|
+
if preamble.sequence_type == "zc":
|
|
449
|
+
preamble_str += f" root={preamble.root}"
|
|
450
|
+
rows.append(("Sequence", preamble_str))
|
|
451
|
+
|
|
452
|
+
elif self.signal_type == "Single-Carrier Frame" and self.frame is not None:
|
|
453
|
+
frame = self.frame
|
|
454
|
+
rows.append(("--- Frame structure", ""))
|
|
455
|
+
|
|
456
|
+
if hasattr(frame, "preamble") and frame.preamble is not None:
|
|
457
|
+
p = frame.preamble
|
|
458
|
+
preamble_str = f"{p.sequence_type.upper()} len={p.length}"
|
|
459
|
+
if p.sequence_type == "zc":
|
|
460
|
+
preamble_str += f" root={p.root}"
|
|
461
|
+
rows.append(("Preamble", preamble_str))
|
|
462
|
+
else:
|
|
463
|
+
rows.append(("Preamble", "none"))
|
|
464
|
+
|
|
465
|
+
if hasattr(frame, "payload_len") and frame.payload_len is not None:
|
|
466
|
+
rows.append(("Payload length", f"{frame.payload_len} symbols"))
|
|
467
|
+
|
|
468
|
+
pilot_pattern = getattr(frame, "pilot_pattern", "none")
|
|
469
|
+
if pilot_pattern != "none":
|
|
470
|
+
mask, _ = frame._generate_pilot_mask()
|
|
471
|
+
pilot_count = (
|
|
472
|
+
int(np.sum(mask))
|
|
473
|
+
if hasattr(frame, "_generate_pilot_mask")
|
|
474
|
+
else None
|
|
475
|
+
)
|
|
476
|
+
pilot_period = getattr(frame, "pilot_period", None)
|
|
477
|
+
pilot_gain_db = getattr(frame, "pilot_gain_db", None)
|
|
478
|
+
|
|
479
|
+
pilot_str = (
|
|
480
|
+
f"{pilot_pattern}"
|
|
481
|
+
+ (f" count={pilot_count}" if pilot_count else "")
|
|
482
|
+
+ (f" period={pilot_period}" if pilot_period else "")
|
|
483
|
+
+ (
|
|
484
|
+
f" gain={pilot_gain_db} dB"
|
|
485
|
+
if pilot_gain_db is not None
|
|
486
|
+
else ""
|
|
487
|
+
)
|
|
488
|
+
)
|
|
489
|
+
rows.append(("Pilots", pilot_str))
|
|
490
|
+
else:
|
|
491
|
+
rows.append(("Pilots", "none"))
|
|
492
|
+
|
|
493
|
+
if hasattr(frame, "guard_len") and frame.guard_len:
|
|
494
|
+
rows.append(("Guard", f"{frame.guard_type} len={frame.guard_len}"))
|
|
495
|
+
else:
|
|
496
|
+
rows.append(("Guard", "none"))
|
|
497
|
+
|
|
498
|
+
# -- Section 3: Reference data -------------------------------------
|
|
499
|
+
rows.append(("--- Reference data", ""))
|
|
500
|
+
rows.append(("source_symbols", _yn(self.source_symbols)))
|
|
501
|
+
rows.append(("source_bits", _yn(self.source_bits)))
|
|
502
|
+
rows.append(("ps_pmf", _yn(self.ps_pmf)))
|
|
503
|
+
rows.append(("frame attached", _yn(self.frame)))
|
|
504
|
+
|
|
505
|
+
# -- Section 4: Resolved data --------------------------------------
|
|
506
|
+
rows.append(("--- Resolved data", ""))
|
|
507
|
+
rows.append(("resolved_symbols", _yn(self.resolved_symbols)))
|
|
508
|
+
rows.append(("resolved_bits", _yn(self.resolved_bits)))
|
|
509
|
+
|
|
510
|
+
# -- Render --------------------------------------------------------
|
|
511
|
+
# Rich HTML table in Jupyter; plain-text table everywhere else. IPython
|
|
512
|
+
# is an optional dependency (the ``notebook`` extra) - degrade
|
|
513
|
+
# gracefully to the plain-text path when it is not installed.
|
|
514
|
+
try:
|
|
515
|
+
from IPython import get_ipython
|
|
516
|
+
from IPython.display import HTML, display
|
|
517
|
+
|
|
518
|
+
ipy = get_ipython()
|
|
519
|
+
in_kernel = ipy is not None and "IPKernelApp" in ipy.config
|
|
520
|
+
except ImportError:
|
|
521
|
+
in_kernel = False
|
|
522
|
+
|
|
523
|
+
if in_kernel:
|
|
524
|
+
body = "".join(
|
|
525
|
+
f"<tr><td><b>{prop}</b></td><td>{val}</td></tr>" for prop, val in rows
|
|
526
|
+
)
|
|
527
|
+
display(HTML(f"<table>{body}</table>"))
|
|
528
|
+
else:
|
|
529
|
+
width = max(len(prop) for prop, _ in rows)
|
|
530
|
+
text = "\n".join(f"{prop.ljust(width)} {val}" for prop, val in rows)
|
|
531
|
+
logger.info("\n%s", text)
|
|
532
|
+
|
|
533
|
+
def copy(self) -> "Signal": # type: ignore[override]
|
|
534
|
+
"""
|
|
535
|
+
Creates a deep copy of the `Signal` instance.
|
|
536
|
+
|
|
537
|
+
Returns
|
|
538
|
+
-------
|
|
539
|
+
Signal
|
|
540
|
+
A new signal object with identical data and metadata.
|
|
541
|
+
"""
|
|
542
|
+
return self.model_copy(deep=True)
|
|
543
|
+
|
|
544
|
+
def to(self, device: str) -> "Signal":
|
|
545
|
+
"""
|
|
546
|
+
Transfers signal data to the target device (CPU or GPU).
|
|
547
|
+
|
|
548
|
+
Parameters
|
|
549
|
+
----------
|
|
550
|
+
device : {"CPU", "GPU"}
|
|
551
|
+
The target device. Case-insensitive.
|
|
552
|
+
|
|
553
|
+
Returns
|
|
554
|
+
-------
|
|
555
|
+
Signal
|
|
556
|
+
Returns self for method chaining.
|
|
557
|
+
|
|
558
|
+
Raises
|
|
559
|
+
------
|
|
560
|
+
ImportError
|
|
561
|
+
If GPU is requested but CuPy is not installed/functional.
|
|
562
|
+
"""
|
|
563
|
+
self.samples = to_device(self.samples, device)
|
|
564
|
+
return self
|
|
565
|
+
|
|
566
|
+
def export_samples_to_jax(self, device: str | None = None) -> Any:
|
|
567
|
+
"""
|
|
568
|
+
Exports the signal samples to a JAX array.
|
|
569
|
+
|
|
570
|
+
Ensures zero-copy transfer to JAX when possible, preserving the
|
|
571
|
+
device affinity of the underlying samples unless otherwise specified.
|
|
572
|
+
|
|
573
|
+
Parameters
|
|
574
|
+
----------
|
|
575
|
+
device : {"CPU", "GPU", "TPU"}, optional
|
|
576
|
+
Target JAX device. If None, it targets the device matching the
|
|
577
|
+
signal's current backend (CPU or GPU).
|
|
578
|
+
|
|
579
|
+
Returns
|
|
580
|
+
-------
|
|
581
|
+
jax.Array
|
|
582
|
+
JAX array containing signal samples.
|
|
583
|
+
Shape: (N_channels, N_samples) or (N_samples,).
|
|
584
|
+
"""
|
|
585
|
+
# If device is not explicitly requested, use the signal's backend
|
|
586
|
+
target_device = device if device is not None else self.backend
|
|
587
|
+
return to_jax(self.samples, device=target_device)
|
|
588
|
+
|
|
589
|
+
def update_samples_from_jax(self, jax_array: Any) -> "Signal":
|
|
590
|
+
"""
|
|
591
|
+
Updates signal samples from a JAX array.
|
|
592
|
+
|
|
593
|
+
Converts the JAX array back to the signal's original backend (NumPy
|
|
594
|
+
or CuPy) to maintain consistent state.
|
|
595
|
+
|
|
596
|
+
Parameters
|
|
597
|
+
----------
|
|
598
|
+
jax_array : jax.Array
|
|
599
|
+
Input JAX array. Shape must match signal's expected shape.
|
|
600
|
+
|
|
601
|
+
Returns
|
|
602
|
+
-------
|
|
603
|
+
Signal
|
|
604
|
+
Returns self for method chaining.
|
|
605
|
+
"""
|
|
606
|
+
original_backend = self.backend
|
|
607
|
+
# Convert JAX array to backend-compatible array
|
|
608
|
+
# from_jax will return NumPy (for CPU/TPU) or CuPy (for GPU)
|
|
609
|
+
new_samples = from_jax(jax_array)
|
|
610
|
+
|
|
611
|
+
# Ensure we move the data back to the original backend if it differs
|
|
612
|
+
# (e.g., if signal was GPU but jax_array was on CPU/TPU)
|
|
613
|
+
self.samples = to_device(new_samples, original_backend)
|
|
614
|
+
|
|
615
|
+
return self
|
|
616
|
+
|
|
617
|
+
def time_axis(self) -> ArrayType:
|
|
618
|
+
"""
|
|
619
|
+
Generates the time vector associated with signal samples.
|
|
620
|
+
|
|
621
|
+
Returns
|
|
622
|
+
-------
|
|
623
|
+
array_like
|
|
624
|
+
Time axis in seconds, starting at 0.
|
|
625
|
+
Shape: (N_samples,).
|
|
626
|
+
"""
|
|
627
|
+
n_samples = self.samples.shape[-1]
|
|
628
|
+
return self.xp.arange(0, n_samples) / self.sampling_rate
|
|
629
|
+
|
|
630
|
+
# -------------------------------------------------------------------------
|
|
631
|
+
# Properties
|
|
632
|
+
# -------------------------------------------------------------------------
|
|
633
|
+
|
|
634
|
+
@property
|
|
635
|
+
def xp(self) -> types.ModuleType:
|
|
636
|
+
"""
|
|
637
|
+
Access the active array backend (NumPy or CuPy).
|
|
638
|
+
|
|
639
|
+
This property allows for backend-agnostic code by returning the
|
|
640
|
+
appropriate module based on where the samples currently reside.
|
|
641
|
+
|
|
642
|
+
Returns
|
|
643
|
+
-------
|
|
644
|
+
module
|
|
645
|
+
`numpy` if data is on CPU, `cupy` if on GPU.
|
|
646
|
+
"""
|
|
647
|
+
return get_array_module(self.samples)
|
|
648
|
+
|
|
649
|
+
@property
|
|
650
|
+
def sp(self) -> types.ModuleType:
|
|
651
|
+
"""
|
|
652
|
+
Access the signal processing module (`scipy` or `cupyx.scipy`).
|
|
653
|
+
|
|
654
|
+
Returns
|
|
655
|
+
-------
|
|
656
|
+
module
|
|
657
|
+
Appropriate signal processing library for the current backend.
|
|
658
|
+
"""
|
|
659
|
+
return get_scipy_module(self.xp)
|
|
660
|
+
|
|
661
|
+
@property
|
|
662
|
+
def backend(self) -> str:
|
|
663
|
+
"""
|
|
664
|
+
Returns the current computational backend name.
|
|
665
|
+
|
|
666
|
+
Returns
|
|
667
|
+
-------
|
|
668
|
+
{"CPU", "GPU"}
|
|
669
|
+
A string indicating the device location of samples.
|
|
670
|
+
"""
|
|
671
|
+
return "GPU" if self.xp == cp else "CPU"
|
|
672
|
+
|
|
673
|
+
@property
|
|
674
|
+
def num_streams(self) -> int:
|
|
675
|
+
"""
|
|
676
|
+
Returns the number of spatial or polarization streams.
|
|
677
|
+
|
|
678
|
+
Returns
|
|
679
|
+
-------
|
|
680
|
+
int
|
|
681
|
+
1 for SISO signals, N for MIMO/Dual-Pol signals.
|
|
682
|
+
"""
|
|
683
|
+
if self.samples.ndim == 1:
|
|
684
|
+
return 1
|
|
685
|
+
return self.samples.shape[0]
|
|
686
|
+
|
|
687
|
+
@property
|
|
688
|
+
def duration(self) -> float:
|
|
689
|
+
"""
|
|
690
|
+
Returns the total duration of the signal.
|
|
691
|
+
|
|
692
|
+
Returns
|
|
693
|
+
-------
|
|
694
|
+
float
|
|
695
|
+
Duration in seconds.
|
|
696
|
+
"""
|
|
697
|
+
if self.samples.ndim == 1:
|
|
698
|
+
return self.samples.shape[0] / self.sampling_rate
|
|
699
|
+
return self.samples.shape[-1] / self.sampling_rate
|
|
700
|
+
|
|
701
|
+
@property
|
|
702
|
+
def sps(self) -> float:
|
|
703
|
+
"""
|
|
704
|
+
Samples per symbol.
|
|
705
|
+
|
|
706
|
+
Returns
|
|
707
|
+
-------
|
|
708
|
+
float
|
|
709
|
+
Ratio of sampling rate to symbol rate.
|
|
710
|
+
"""
|
|
711
|
+
return self.sampling_rate / self.symbol_rate
|
|
712
|
+
|
|
713
|
+
@property
|
|
714
|
+
def bits_per_symbol(self) -> int | None:
|
|
715
|
+
"""
|
|
716
|
+
Bits per symbol for the active modulation scheme.
|
|
717
|
+
|
|
718
|
+
Returns
|
|
719
|
+
-------
|
|
720
|
+
int or None
|
|
721
|
+
Calculated as ``log2(modulation_order)``.
|
|
722
|
+
"""
|
|
723
|
+
if self.mod_order:
|
|
724
|
+
return int(np.log2(self.mod_order))
|
|
725
|
+
return None
|