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/frame.py
ADDED
|
@@ -0,0 +1,992 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Frame containers: structured preamble and single-carrier frame models.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from pydantic import (
|
|
9
|
+
BaseModel,
|
|
10
|
+
ConfigDict,
|
|
11
|
+
Field,
|
|
12
|
+
PrivateAttr,
|
|
13
|
+
model_validator,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import cupy as cp
|
|
18
|
+
|
|
19
|
+
_CUPY_AVAILABLE = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
cp = None
|
|
22
|
+
_CUPY_AVAILABLE = False
|
|
23
|
+
|
|
24
|
+
from .. import helpers
|
|
25
|
+
from ..backend import ArrayType, is_cupy_available, to_device
|
|
26
|
+
from ..logger import logger
|
|
27
|
+
from . import generation
|
|
28
|
+
from .signal import Signal
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Preamble(BaseModel):
|
|
32
|
+
"""
|
|
33
|
+
Structured container for frame synchronization sequences (preambles).
|
|
34
|
+
|
|
35
|
+
Preambles are automatically generated based on the specified sequence type
|
|
36
|
+
and length. Manual overrides for bits or symbols are not supported to
|
|
37
|
+
ensure consistency within the processing pipeline.
|
|
38
|
+
|
|
39
|
+
Attributes
|
|
40
|
+
----------
|
|
41
|
+
sequence_type : {"barker", "zc"}, default "barker"
|
|
42
|
+
The synchronization sequence algorithm.
|
|
43
|
+
length : int
|
|
44
|
+
Total length of the preamble in symbols.
|
|
45
|
+
For "barker": length must be from the set {2, 3, 4, 5, 7, 11, 13}.
|
|
46
|
+
For "zc": length must be a prime number.
|
|
47
|
+
root : int, default 1
|
|
48
|
+
ZC root index (only meaningful for ``sequence_type='zc'``).
|
|
49
|
+
Must satisfy ``1 <= root < length``.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True)
|
|
53
|
+
|
|
54
|
+
sequence_type: Literal["barker", "zc"] = "barker"
|
|
55
|
+
length: int
|
|
56
|
+
root: int = Field(
|
|
57
|
+
default=1,
|
|
58
|
+
ge=1,
|
|
59
|
+
description="ZC root index. Only meaningful for ``sequence_type='zc'``; "
|
|
60
|
+
"ignored for Barker sequences. Must satisfy ``1 <= root < length``; "
|
|
61
|
+
"for prime ``length`` every root in this range yields a valid CAZAC sequence.",
|
|
62
|
+
)
|
|
63
|
+
num_streams: int = Field(
|
|
64
|
+
default=1,
|
|
65
|
+
ge=1,
|
|
66
|
+
description="Number of TX streams. For ZC preambles each stream gets a "
|
|
67
|
+
"unique root derived via ``helpers.zc_mimo_root``. "
|
|
68
|
+
"For Barker the same sequence is broadcast to all streams.",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Internal state managed during post-init
|
|
72
|
+
_symbols: Any = PrivateAttr(default=None)
|
|
73
|
+
|
|
74
|
+
# -------------------------------------------------------------------------
|
|
75
|
+
# Validators and Post-Initialization Hooks
|
|
76
|
+
# -------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def model_post_init(self, __context: Any) -> None:
|
|
79
|
+
"""
|
|
80
|
+
Post-initialization hook to automate symbol generation and device placement.
|
|
81
|
+
|
|
82
|
+
This ensures that standard sequences are generated correctly according
|
|
83
|
+
to the requested sequence properties.
|
|
84
|
+
|
|
85
|
+
For ``num_streams == 1`` the internal ``_symbols`` shape is ``(length,)``.
|
|
86
|
+
For ``num_streams > 1`` it becomes ``(num_streams, length)``:
|
|
87
|
+
- ZC: each row uses the unique root from ``helpers.zc_mimo_root``.
|
|
88
|
+
- Barker: the same sequence is tiled across all streams.
|
|
89
|
+
"""
|
|
90
|
+
from .. import timing
|
|
91
|
+
|
|
92
|
+
stype = self.sequence_type.lower()
|
|
93
|
+
|
|
94
|
+
if stype == "barker":
|
|
95
|
+
# Barker symbols (-1, +1)
|
|
96
|
+
base = timing.barker_sequence(self.length)
|
|
97
|
+
elif stype in ("zc", "zadoff_chu"):
|
|
98
|
+
# ZC complex symbols - use the named 'root' field directly.
|
|
99
|
+
base = timing.zadoff_chu_sequence(self.length, root=self.root)
|
|
100
|
+
else:
|
|
101
|
+
base = None
|
|
102
|
+
|
|
103
|
+
if base is not None and self.num_streams > 1:
|
|
104
|
+
if stype in ("zc", "zadoff_chu"):
|
|
105
|
+
rows = [
|
|
106
|
+
timing.zadoff_chu_sequence(
|
|
107
|
+
self.length,
|
|
108
|
+
root=helpers.zc_mimo_root(k, self.root, self.length),
|
|
109
|
+
)
|
|
110
|
+
for k in range(self.num_streams)
|
|
111
|
+
]
|
|
112
|
+
self._symbols = np.stack(rows, axis=0) # (num_streams, length)
|
|
113
|
+
else:
|
|
114
|
+
self._symbols = np.tile(base[None, :], (self.num_streams, 1))
|
|
115
|
+
else:
|
|
116
|
+
self._symbols = base
|
|
117
|
+
|
|
118
|
+
# Move to GPU if available
|
|
119
|
+
if is_cupy_available():
|
|
120
|
+
if self._symbols is not None:
|
|
121
|
+
self._symbols = to_device(self._symbols, "gpu")
|
|
122
|
+
|
|
123
|
+
# Ensure consistent internal dtype (complex64)
|
|
124
|
+
if self._symbols is not None:
|
|
125
|
+
self._symbols = self._symbols.astype("complex64")
|
|
126
|
+
|
|
127
|
+
# -------------------------------------------------------------------------
|
|
128
|
+
# Properties
|
|
129
|
+
# -------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def symbols(self) -> Any:
|
|
133
|
+
"""The IQ symbols of the preamble."""
|
|
134
|
+
return self._symbols
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def num_symbols(self) -> int:
|
|
138
|
+
"""Total number of symbols in the preamble."""
|
|
139
|
+
return self.length
|
|
140
|
+
|
|
141
|
+
# -------------------------------------------------------------------------
|
|
142
|
+
# Signal Generation
|
|
143
|
+
# -------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
def to_signal(
|
|
146
|
+
self,
|
|
147
|
+
sps: int,
|
|
148
|
+
symbol_rate: float,
|
|
149
|
+
pulse_shape: str = "rrc",
|
|
150
|
+
filter_span: int = 10,
|
|
151
|
+
rrc_rolloff: float = 0.35,
|
|
152
|
+
rc_rolloff: float = 0.35,
|
|
153
|
+
rise_time: float = 0.0,
|
|
154
|
+
duty_cycle: float = 1.0,
|
|
155
|
+
) -> Signal:
|
|
156
|
+
"""
|
|
157
|
+
Generates a shaped waveform from the preamble sequence.
|
|
158
|
+
|
|
159
|
+
Parameters
|
|
160
|
+
----------
|
|
161
|
+
sps : int
|
|
162
|
+
Samples per symbol.
|
|
163
|
+
symbol_rate : float
|
|
164
|
+
Symbol rate in Hz.
|
|
165
|
+
pulse_shape : str, default "rrc"
|
|
166
|
+
The pulse shaping type to apply.
|
|
167
|
+
filter_span : int, default 10
|
|
168
|
+
Filter span in symbols.
|
|
169
|
+
rrc_rolloff : float, default 0.35
|
|
170
|
+
Roll-off factor for RRC filter.
|
|
171
|
+
rc_rolloff : float, default 0.35
|
|
172
|
+
Roll-off factor for RC filter.
|
|
173
|
+
rise_time : float, default 0.22
|
|
174
|
+
10%-90% edge transition duration in symbol periods (smoothrect only).
|
|
175
|
+
duty_cycle : float, default 1.0
|
|
176
|
+
FWHM of the Gaussian pulse in symbol periods (gaussian only).
|
|
177
|
+
duty_cycle : float, default 1.0
|
|
178
|
+
Fraction of the symbol period occupied by the pulse (rect/smoothrect).
|
|
179
|
+
|
|
180
|
+
Returns
|
|
181
|
+
-------
|
|
182
|
+
Signal
|
|
183
|
+
A `Signal` object with the shaped preamble.
|
|
184
|
+
"""
|
|
185
|
+
from ..filtering import shape_pulse
|
|
186
|
+
|
|
187
|
+
if sps != int(sps) or sps < 1:
|
|
188
|
+
logger.warning(
|
|
189
|
+
"sps=%r is not a positive integer. Non-integer sps causes sample buffer / sampling_rate metadata mismatch.",
|
|
190
|
+
sps,
|
|
191
|
+
)
|
|
192
|
+
raise ValueError(
|
|
193
|
+
f"sps must be a positive integer for signal generation, got {sps!r}."
|
|
194
|
+
)
|
|
195
|
+
sps = int(sps)
|
|
196
|
+
|
|
197
|
+
samples = shape_pulse(
|
|
198
|
+
self.symbols,
|
|
199
|
+
sps=sps,
|
|
200
|
+
pulse_shape=pulse_shape,
|
|
201
|
+
filter_span=filter_span,
|
|
202
|
+
rrc_rolloff=rrc_rolloff,
|
|
203
|
+
rc_rolloff=rc_rolloff,
|
|
204
|
+
rise_time=rise_time,
|
|
205
|
+
duty_cycle=duty_cycle,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
return Signal(
|
|
209
|
+
samples=samples,
|
|
210
|
+
sampling_rate=symbol_rate * sps,
|
|
211
|
+
symbol_rate=symbol_rate,
|
|
212
|
+
mod_scheme=None,
|
|
213
|
+
mod_order=None,
|
|
214
|
+
source_symbols=None,
|
|
215
|
+
pulse_shape=pulse_shape,
|
|
216
|
+
duty_cycle=duty_cycle,
|
|
217
|
+
filter_span=filter_span,
|
|
218
|
+
rrc_rolloff=rrc_rolloff,
|
|
219
|
+
rc_rolloff=rc_rolloff,
|
|
220
|
+
rise_time=rise_time,
|
|
221
|
+
signal_type="Preamble",
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class SingleCarrierFrame(BaseModel):
|
|
226
|
+
"""
|
|
227
|
+
Represents a structured single-carrier frame with Preamble, Pilots, Payload, and Guard Interval.
|
|
228
|
+
|
|
229
|
+
This class provides a high-level abstraction for constructing frames
|
|
230
|
+
used in digital communication systems (1/10/100 GbE, 5G, etc.).
|
|
231
|
+
It supports various pilot patterns for channel estimation and guard
|
|
232
|
+
intervals for multi-path mitigation.
|
|
233
|
+
|
|
234
|
+
Attributes
|
|
235
|
+
----------
|
|
236
|
+
payload_len : int, default 1000
|
|
237
|
+
Number of data symbols per spatial stream.
|
|
238
|
+
payload_mod_scheme : str, default "PSK"
|
|
239
|
+
Modulation for payload data (e.g., 'QAM').
|
|
240
|
+
payload_mod_order : int, default 4
|
|
241
|
+
Modulation order for payload (e.g., 16 for 16-QAM).
|
|
242
|
+
payload_seed : int, default 42
|
|
243
|
+
Seed for reproducible payload data generation.
|
|
244
|
+
payload_nu : float, optional
|
|
245
|
+
Maxwell-Boltzmann shaping parameter nu >= 0 for a
|
|
246
|
+
probabilistically shaped QAM payload. Mutually exclusive with
|
|
247
|
+
``payload_entropy``. Requires ``payload_mod_scheme`` to contain
|
|
248
|
+
``"qam"`` (case-insensitive). nu = 0 -> uniform QAM.
|
|
249
|
+
payload_entropy : float, optional
|
|
250
|
+
Target entropy in bits per symbol for a PS-QAM payload. The
|
|
251
|
+
optimal nu is solved numerically via ``mapping.optimal_nu``.
|
|
252
|
+
Mutually exclusive with ``payload_nu``. Same QAM-only constraint.
|
|
253
|
+
preamble : Preamble, optional
|
|
254
|
+
Structured preamble for synchronization. For MIMO with ZC sequences,
|
|
255
|
+
each TX stream automatically receives a unique root via
|
|
256
|
+
``helpers.zc_mimo_root``.
|
|
257
|
+
pilot_pattern : {"none", "block", "comb"}, default "none"
|
|
258
|
+
"none": No pilots.
|
|
259
|
+
"block": A block of symbols at the start of the frame body.
|
|
260
|
+
"comb": Single pilot symbols interleaved every `pilot_period`.
|
|
261
|
+
pilot_period : int, default 0
|
|
262
|
+
The period of pilot insertion in symbols.
|
|
263
|
+
pilot_block_len : int, default 0
|
|
264
|
+
Length of the pilot block (mode="block") in symbols.
|
|
265
|
+
pilot_seed : int, default 1337
|
|
266
|
+
Seed for pilot symbol generation.
|
|
267
|
+
pilot_mod_scheme : str, default "PSK"
|
|
268
|
+
Modulation for pilots.
|
|
269
|
+
pilot_mod_order : int, default 4
|
|
270
|
+
Modulation order for pilots.
|
|
271
|
+
pilot_gain_db : float, default 0.0
|
|
272
|
+
Pilot boosting in dB relative to the payload power.
|
|
273
|
+
guard_type : {"zero", "cp"}, default "zero"
|
|
274
|
+
"zero": Zero-padding at the end of the frame.
|
|
275
|
+
"cp": Cyclic Prefix prepended to the frame.
|
|
276
|
+
guard_len : int, default 0
|
|
277
|
+
Length of the guard interval in symbols.
|
|
278
|
+
num_streams : int, default 1
|
|
279
|
+
Number of independent spatial streams (MIMO).
|
|
280
|
+
|
|
281
|
+
Notes
|
|
282
|
+
-----
|
|
283
|
+
**PS-QAM payload**: set either ``payload_nu`` or ``payload_entropy`` (not
|
|
284
|
+
both) together with a QAM ``payload_mod_scheme``. The MB distribution is
|
|
285
|
+
solved once and cached; access the resulting PMF via the read-only
|
|
286
|
+
``payload_ps_pmf`` property after the frame has been generated.
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True)
|
|
290
|
+
|
|
291
|
+
payload_len: int = Field(default=1000, gt=0)
|
|
292
|
+
payload_seed: int = 42
|
|
293
|
+
payload_mod_scheme: str = "PSK"
|
|
294
|
+
payload_mod_order: int = Field(default=4, ge=1)
|
|
295
|
+
payload_mod_unipolar: bool = False
|
|
296
|
+
payload_nu: float | None = Field(default=None, ge=0)
|
|
297
|
+
payload_entropy: float | None = Field(default=None, gt=0)
|
|
298
|
+
|
|
299
|
+
preamble: Preamble | None = None
|
|
300
|
+
|
|
301
|
+
pilot_pattern: Literal["none", "block", "comb"] = "none"
|
|
302
|
+
pilot_period: int = Field(default=0, ge=0)
|
|
303
|
+
pilot_block_len: int = Field(default=0, ge=0)
|
|
304
|
+
pilot_seed: int = 1337
|
|
305
|
+
pilot_mod_scheme: str = "PSK"
|
|
306
|
+
pilot_mod_order: int = Field(default=4, ge=1)
|
|
307
|
+
pilot_mod_unipolar: bool = False
|
|
308
|
+
pilot_gain_db: float = 0.0
|
|
309
|
+
|
|
310
|
+
guard_type: Literal["zero", "cp"] = "zero"
|
|
311
|
+
guard_len: int = Field(default=0, ge=0)
|
|
312
|
+
|
|
313
|
+
num_streams: int = Field(default=1, ge=1)
|
|
314
|
+
|
|
315
|
+
# Internal cache
|
|
316
|
+
_payload_bits: Any | None = PrivateAttr(default=None)
|
|
317
|
+
_payload_symbols: Any | None = PrivateAttr(default=None)
|
|
318
|
+
_payload_ps_pmf: Any | None = PrivateAttr(default=None)
|
|
319
|
+
_pilot_bits: Any | None = PrivateAttr(default=None)
|
|
320
|
+
_pilot_symbols: Any | None = PrivateAttr(default=None)
|
|
321
|
+
|
|
322
|
+
# -------------------------------------------------------------------------
|
|
323
|
+
# Validators and Post-Initialization Hooks
|
|
324
|
+
# -------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
def model_post_init(self, __context: Any) -> None:
|
|
327
|
+
"""
|
|
328
|
+
Post-initialization hook.
|
|
329
|
+
|
|
330
|
+
Validates that payload_len is evenly divisible by the per-period or
|
|
331
|
+
per-block data count implied by the pilot parameters. If not, snaps
|
|
332
|
+
payload_len up to the next valid multiple and emits a warning so the
|
|
333
|
+
frame structure always satisfies:
|
|
334
|
+
num_pilot_periods == num_data_periods (comb)
|
|
335
|
+
num_pilot_blocks == num_data_blocks (block)
|
|
336
|
+
"""
|
|
337
|
+
import math
|
|
338
|
+
|
|
339
|
+
if self.pilot_pattern == "comb" and self.pilot_period > 1:
|
|
340
|
+
data_per_period = self.pilot_period - 1
|
|
341
|
+
if self.payload_len % data_per_period != 0:
|
|
342
|
+
snapped = (
|
|
343
|
+
math.ceil(self.payload_len / data_per_period) * data_per_period
|
|
344
|
+
)
|
|
345
|
+
logger.warning(
|
|
346
|
+
"SingleCarrierFrame (comb): payload_len=%s is not divisible by data_per_period=%s (pilot_period=%s). Snapping payload_len %s -> %s so that num_pilot_periods == num_data_periods == %s.",
|
|
347
|
+
self.payload_len,
|
|
348
|
+
data_per_period,
|
|
349
|
+
self.pilot_period,
|
|
350
|
+
self.payload_len,
|
|
351
|
+
snapped,
|
|
352
|
+
snapped // data_per_period,
|
|
353
|
+
)
|
|
354
|
+
self.payload_len = snapped
|
|
355
|
+
|
|
356
|
+
elif (
|
|
357
|
+
self.pilot_pattern == "block"
|
|
358
|
+
and self.pilot_period > self.pilot_block_len > 0
|
|
359
|
+
):
|
|
360
|
+
data_per_block = self.pilot_period - self.pilot_block_len
|
|
361
|
+
if self.payload_len % data_per_block != 0:
|
|
362
|
+
snapped = math.ceil(self.payload_len / data_per_block) * data_per_block
|
|
363
|
+
logger.warning(
|
|
364
|
+
"SingleCarrierFrame (block): payload_len=%s is not divisible by data_per_block=%s (pilot_period=%s, pilot_block_len=%s). Snapping payload_len %s -> %s so that num_pilot_blocks == num_data_blocks == %s.",
|
|
365
|
+
self.payload_len,
|
|
366
|
+
data_per_block,
|
|
367
|
+
self.pilot_period,
|
|
368
|
+
self.pilot_block_len,
|
|
369
|
+
self.payload_len,
|
|
370
|
+
snapped,
|
|
371
|
+
snapped // data_per_block,
|
|
372
|
+
)
|
|
373
|
+
self.payload_len = snapped
|
|
374
|
+
|
|
375
|
+
@model_validator(mode="after")
|
|
376
|
+
def _check_psqam_fields(self) -> "SingleCarrierFrame":
|
|
377
|
+
if self.payload_nu is not None and self.payload_entropy is not None:
|
|
378
|
+
raise ValueError(
|
|
379
|
+
"payload_nu and payload_entropy are mutually exclusive - specify one or neither."
|
|
380
|
+
)
|
|
381
|
+
if self.payload_nu is not None or self.payload_entropy is not None:
|
|
382
|
+
if "qam" not in self.payload_mod_scheme.lower():
|
|
383
|
+
raise ValueError(
|
|
384
|
+
f"payload_nu / payload_entropy require a QAM payload modulation, "
|
|
385
|
+
f"got payload_mod_scheme='{self.payload_mod_scheme}'."
|
|
386
|
+
)
|
|
387
|
+
return self
|
|
388
|
+
|
|
389
|
+
@model_validator(mode="after")
|
|
390
|
+
def _check_preamble_streams(self) -> "SingleCarrierFrame":
|
|
391
|
+
if self.preamble is not None and self.preamble.num_streams > 1:
|
|
392
|
+
if self.preamble.num_streams != self.num_streams:
|
|
393
|
+
raise ValueError(
|
|
394
|
+
f"preamble.num_streams={self.preamble.num_streams} does not match "
|
|
395
|
+
f"frame.num_streams={self.num_streams}"
|
|
396
|
+
)
|
|
397
|
+
return self
|
|
398
|
+
|
|
399
|
+
# -------------------------------------------------------------------------
|
|
400
|
+
# Mask Generation and Internal Data Preparation Methods
|
|
401
|
+
# -------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
def _generate_pilot_mask(self) -> tuple[ArrayType, int]:
|
|
404
|
+
"""
|
|
405
|
+
Calculates the pilot placement mask and total frame length.
|
|
406
|
+
|
|
407
|
+
Returns
|
|
408
|
+
-------
|
|
409
|
+
mask : array_like (bool)
|
|
410
|
+
Boolean mask where True indicates a pilot symbol location.
|
|
411
|
+
body_length : int
|
|
412
|
+
Total number of symbols in the frame body (payload + pilots).
|
|
413
|
+
"""
|
|
414
|
+
xp = cp if is_cupy_available() else np
|
|
415
|
+
|
|
416
|
+
# No pilots: simple payload mapping
|
|
417
|
+
if self.pilot_pattern == "none":
|
|
418
|
+
body_length = self.payload_len
|
|
419
|
+
mask = xp.zeros(body_length, dtype=bool)
|
|
420
|
+
return mask, body_length
|
|
421
|
+
|
|
422
|
+
# Comb pattern: single pilot every N symbols
|
|
423
|
+
if self.pilot_pattern == "comb":
|
|
424
|
+
if self.pilot_period <= 1:
|
|
425
|
+
raise ValueError("pilot_period must be > 1 for 'comb' pattern.")
|
|
426
|
+
data_per_period = self.pilot_period - 1
|
|
427
|
+
num_full_periods = self.payload_len // data_per_period
|
|
428
|
+
remainder = self.payload_len % data_per_period
|
|
429
|
+
|
|
430
|
+
total_length = num_full_periods * self.pilot_period + remainder
|
|
431
|
+
# If we have a remainder, we need one more pilot at the start of the partial period
|
|
432
|
+
if remainder > 0:
|
|
433
|
+
total_length += 1
|
|
434
|
+
|
|
435
|
+
mask = xp.zeros(total_length, dtype=bool)
|
|
436
|
+
mask[:: self.pilot_period] = True
|
|
437
|
+
return mask, total_length
|
|
438
|
+
|
|
439
|
+
# Block pattern: block of pilots followed by block of data
|
|
440
|
+
if self.pilot_pattern == "block":
|
|
441
|
+
if self.pilot_period <= self.pilot_block_len:
|
|
442
|
+
raise ValueError(
|
|
443
|
+
"pilot_period must be > pilot_block_len for 'block' pattern."
|
|
444
|
+
)
|
|
445
|
+
data_per_block = self.pilot_period - self.pilot_block_len
|
|
446
|
+
num_blocks = int(xp.ceil(self.payload_len / data_per_block))
|
|
447
|
+
|
|
448
|
+
# Create a single block pattern [P P ... P D D ... D]
|
|
449
|
+
block_pattern = xp.zeros(self.pilot_period, dtype=bool)
|
|
450
|
+
block_pattern[: self.pilot_block_len] = True
|
|
451
|
+
|
|
452
|
+
# Repeat the pattern for all blocks
|
|
453
|
+
mask = xp.tile(block_pattern, num_blocks)
|
|
454
|
+
|
|
455
|
+
# Truncation: Find the exact index where the required payload ends
|
|
456
|
+
false_indices = xp.where(~mask)[0]
|
|
457
|
+
last_idx = false_indices[self.payload_len - 1]
|
|
458
|
+
mask = mask[: last_idx + 1]
|
|
459
|
+
return mask, len(mask)
|
|
460
|
+
|
|
461
|
+
return xp.zeros(self.payload_len, dtype=bool), self.payload_len
|
|
462
|
+
|
|
463
|
+
def _ensure_payload_generated(self) -> None:
|
|
464
|
+
"""
|
|
465
|
+
Generates and caches payload bits and symbols via the appropriate Signal factory.
|
|
466
|
+
|
|
467
|
+
Dispatches to ``generate_psqam``, ``generate_qam``, ``generate_psk``,
|
|
468
|
+
or ``generate_pam`` based on ``payload_mod_scheme`` and the PS
|
|
469
|
+
parameters. Using the factories as the single source of generation
|
|
470
|
+
logic avoids duplicating bit/symbol generation code here.
|
|
471
|
+
"""
|
|
472
|
+
if self._payload_bits is not None:
|
|
473
|
+
return
|
|
474
|
+
|
|
475
|
+
scheme = self.payload_mod_scheme.lower()
|
|
476
|
+
is_ps = self.payload_nu is not None or self.payload_entropy is not None
|
|
477
|
+
|
|
478
|
+
common: dict[str, Any] = dict(
|
|
479
|
+
num_symbols=self.payload_len,
|
|
480
|
+
sps=1,
|
|
481
|
+
symbol_rate=1.0,
|
|
482
|
+
pulse_shape="none",
|
|
483
|
+
num_streams=self.num_streams,
|
|
484
|
+
seed=self.payload_seed,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
if is_ps:
|
|
488
|
+
sig = generation.generate_psqam(
|
|
489
|
+
order=self.payload_mod_order,
|
|
490
|
+
nu=self.payload_nu,
|
|
491
|
+
entropy=self.payload_entropy,
|
|
492
|
+
**common,
|
|
493
|
+
)
|
|
494
|
+
self._payload_ps_pmf = sig.ps_pmf
|
|
495
|
+
elif "qam" in scheme:
|
|
496
|
+
sig = generation.generate_qam(
|
|
497
|
+
order=self.payload_mod_order,
|
|
498
|
+
unipolar=self.payload_mod_unipolar,
|
|
499
|
+
**common,
|
|
500
|
+
)
|
|
501
|
+
elif "psk" in scheme:
|
|
502
|
+
sig = generation.generate_psk(
|
|
503
|
+
order=self.payload_mod_order,
|
|
504
|
+
**common,
|
|
505
|
+
)
|
|
506
|
+
elif "pam" in scheme or "ask" in scheme:
|
|
507
|
+
sig = generation.generate_pam(
|
|
508
|
+
order=self.payload_mod_order,
|
|
509
|
+
unipolar=self.payload_mod_unipolar,
|
|
510
|
+
**common,
|
|
511
|
+
)
|
|
512
|
+
else:
|
|
513
|
+
sig = generation.generate(
|
|
514
|
+
modulation=self.payload_mod_scheme,
|
|
515
|
+
order=self.payload_mod_order,
|
|
516
|
+
unipolar=self.payload_mod_unipolar,
|
|
517
|
+
**common,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
self._payload_bits = sig.source_bits
|
|
521
|
+
self._payload_symbols = sig.source_symbols
|
|
522
|
+
|
|
523
|
+
def _ensure_pilot_generated(self) -> None:
|
|
524
|
+
"""
|
|
525
|
+
Generates and caches pilot bits and symbols via the appropriate Signal factory.
|
|
526
|
+
|
|
527
|
+
Pilots are always generated with a uniform distribution - PS on pilots
|
|
528
|
+
would destroy the known-reference property required for channel estimation.
|
|
529
|
+
"""
|
|
530
|
+
if self._pilot_bits is not None or self.pilot_pattern == "none":
|
|
531
|
+
return
|
|
532
|
+
|
|
533
|
+
xp = cp if is_cupy_available() else np
|
|
534
|
+
mask, _ = self._generate_pilot_mask()
|
|
535
|
+
pilot_count = int(xp.sum(mask))
|
|
536
|
+
if pilot_count == 0:
|
|
537
|
+
return
|
|
538
|
+
|
|
539
|
+
scheme = self.pilot_mod_scheme.lower()
|
|
540
|
+
|
|
541
|
+
common: dict[str, Any] = dict(
|
|
542
|
+
num_symbols=pilot_count,
|
|
543
|
+
sps=1,
|
|
544
|
+
symbol_rate=1.0,
|
|
545
|
+
pulse_shape="none",
|
|
546
|
+
num_streams=self.num_streams,
|
|
547
|
+
seed=self.pilot_seed,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
if "qam" in scheme:
|
|
551
|
+
sig = generation.generate_qam(
|
|
552
|
+
order=self.pilot_mod_order,
|
|
553
|
+
unipolar=self.pilot_mod_unipolar,
|
|
554
|
+
**common,
|
|
555
|
+
)
|
|
556
|
+
elif "psk" in scheme:
|
|
557
|
+
sig = generation.generate_psk(
|
|
558
|
+
order=self.pilot_mod_order,
|
|
559
|
+
**common,
|
|
560
|
+
)
|
|
561
|
+
elif "pam" in scheme or "ask" in scheme:
|
|
562
|
+
sig = generation.generate_pam(
|
|
563
|
+
order=self.pilot_mod_order,
|
|
564
|
+
unipolar=self.pilot_mod_unipolar,
|
|
565
|
+
**common,
|
|
566
|
+
)
|
|
567
|
+
else:
|
|
568
|
+
sig = generation.generate(
|
|
569
|
+
modulation=self.pilot_mod_scheme,
|
|
570
|
+
order=self.pilot_mod_order,
|
|
571
|
+
unipolar=self.pilot_mod_unipolar,
|
|
572
|
+
**common,
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
self._pilot_bits = sig.source_bits
|
|
576
|
+
self._pilot_symbols = sig.source_symbols
|
|
577
|
+
|
|
578
|
+
# -------------------------------------------------------------------------
|
|
579
|
+
# Properties for Accessing Payload and Pilot Data
|
|
580
|
+
# -------------------------------------------------------------------------
|
|
581
|
+
|
|
582
|
+
@property
|
|
583
|
+
def payload_bits(self) -> ArrayType:
|
|
584
|
+
"""
|
|
585
|
+
Returns the raw payload bits.
|
|
586
|
+
|
|
587
|
+
Returns
|
|
588
|
+
-------
|
|
589
|
+
array_like
|
|
590
|
+
Binary bits (0s and 1s).
|
|
591
|
+
"""
|
|
592
|
+
self._ensure_payload_generated()
|
|
593
|
+
return self._payload_bits
|
|
594
|
+
|
|
595
|
+
@property
|
|
596
|
+
def payload_symbols(self) -> ArrayType:
|
|
597
|
+
"""
|
|
598
|
+
Returns the modulated payload symbols.
|
|
599
|
+
|
|
600
|
+
Returns
|
|
601
|
+
-------
|
|
602
|
+
array_like
|
|
603
|
+
IQ symbols.
|
|
604
|
+
"""
|
|
605
|
+
self._ensure_payload_generated()
|
|
606
|
+
return self._payload_symbols
|
|
607
|
+
|
|
608
|
+
@property
|
|
609
|
+
def payload_ps_pmf(self) -> Any | None:
|
|
610
|
+
"""
|
|
611
|
+
Returns the Maxwell-Boltzmann PMF used for PS-QAM payload generation.
|
|
612
|
+
|
|
613
|
+
``None`` for uniform (non-PS) payloads. Pass this to
|
|
614
|
+
``metrics.mi`` and ``compute_llr`` after frame equalization
|
|
615
|
+
to compute PS-aware capacity and soft-decision metrics.
|
|
616
|
+
|
|
617
|
+
Returns
|
|
618
|
+
-------
|
|
619
|
+
np.ndarray or None
|
|
620
|
+
PMF array of shape ``(payload_mod_order,)`` summing to 1, or ``None``.
|
|
621
|
+
"""
|
|
622
|
+
self._ensure_payload_generated()
|
|
623
|
+
return self._payload_ps_pmf
|
|
624
|
+
|
|
625
|
+
@property
|
|
626
|
+
def pilot_bits(self) -> ArrayType | None:
|
|
627
|
+
"""
|
|
628
|
+
Returns the raw pilot bits, if pilots are enabled.
|
|
629
|
+
|
|
630
|
+
Returns
|
|
631
|
+
-------
|
|
632
|
+
array_like or None
|
|
633
|
+
Binary bits if `pilot_pattern` is not "none".
|
|
634
|
+
"""
|
|
635
|
+
if self.pilot_pattern == "none":
|
|
636
|
+
return None
|
|
637
|
+
self._ensure_pilot_generated()
|
|
638
|
+
return self._pilot_bits
|
|
639
|
+
|
|
640
|
+
@property
|
|
641
|
+
def pilot_symbols(self) -> ArrayType | None:
|
|
642
|
+
"""
|
|
643
|
+
Returns the modulated pilot symbols.
|
|
644
|
+
|
|
645
|
+
Returns
|
|
646
|
+
-------
|
|
647
|
+
array_like or None
|
|
648
|
+
IQ symbols if `pilot_pattern` is not "none".
|
|
649
|
+
"""
|
|
650
|
+
if self.pilot_pattern == "none":
|
|
651
|
+
return None
|
|
652
|
+
self._ensure_pilot_generated()
|
|
653
|
+
return self._pilot_symbols
|
|
654
|
+
|
|
655
|
+
@property
|
|
656
|
+
def body_symbols(self) -> ArrayType:
|
|
657
|
+
"""
|
|
658
|
+
Returns the interleaved payload and pilot symbols.
|
|
659
|
+
|
|
660
|
+
WARNING: Pilot gain is applied if `pilot_gain_db` is not zero,
|
|
661
|
+
so these are not "clean" symbols but scaled relatively.
|
|
662
|
+
|
|
663
|
+
Returns
|
|
664
|
+
-------
|
|
665
|
+
array_like
|
|
666
|
+
Determined by `pilot_pattern` and `pilot_period`.
|
|
667
|
+
"""
|
|
668
|
+
xp = cp if is_cupy_available() else np
|
|
669
|
+
mask, body_length = self._generate_pilot_mask()
|
|
670
|
+
|
|
671
|
+
if self.num_streams > 1:
|
|
672
|
+
# Shape: (Channels, Time)
|
|
673
|
+
body = xp.zeros((self.num_streams, body_length), dtype="complex64")
|
|
674
|
+
|
|
675
|
+
if self.pilot_pattern != "none":
|
|
676
|
+
pilot_symbols = self.pilot_symbols
|
|
677
|
+
assert pilot_symbols is not None
|
|
678
|
+
# Apply pilot boosting/gain (dB to linear)
|
|
679
|
+
if self.pilot_gain_db != 0.0:
|
|
680
|
+
pilot_symbols = pilot_symbols * (10 ** (self.pilot_gain_db / 20))
|
|
681
|
+
|
|
682
|
+
body[:, mask] = pilot_symbols
|
|
683
|
+
|
|
684
|
+
body[:, ~mask] = self.payload_symbols
|
|
685
|
+
else:
|
|
686
|
+
body = xp.zeros(body_length, dtype="complex64")
|
|
687
|
+
if self.pilot_pattern != "none":
|
|
688
|
+
pilot_symbols = self.pilot_symbols
|
|
689
|
+
assert pilot_symbols is not None
|
|
690
|
+
# Apply pilot boosting/gain (dB to linear)
|
|
691
|
+
if self.pilot_gain_db != 0.0:
|
|
692
|
+
pilot_symbols = pilot_symbols * (10 ** (self.pilot_gain_db / 20))
|
|
693
|
+
|
|
694
|
+
body[mask] = pilot_symbols
|
|
695
|
+
body[~mask] = self.payload_symbols
|
|
696
|
+
|
|
697
|
+
return body
|
|
698
|
+
|
|
699
|
+
# -------------------------------------------------------------------------
|
|
700
|
+
# Frame Structure Mapping
|
|
701
|
+
# -------------------------------------------------------------------------
|
|
702
|
+
|
|
703
|
+
def get_structure_map(
|
|
704
|
+
self,
|
|
705
|
+
unit: Literal["symbols", "samples"] = "symbols",
|
|
706
|
+
sps: int = 1,
|
|
707
|
+
include_preamble: bool = True,
|
|
708
|
+
) -> dict[str, ArrayType]:
|
|
709
|
+
"""
|
|
710
|
+
Generates boolean masks identifying the segments of the frame.
|
|
711
|
+
|
|
712
|
+
Parameters
|
|
713
|
+
----------
|
|
714
|
+
unit : {"symbols", "samples"}, default "symbols"
|
|
715
|
+
The scale of the returned masks.
|
|
716
|
+
sps : int, default 1
|
|
717
|
+
Samples per symbol (required if unit="samples").
|
|
718
|
+
include_preamble : bool, default True
|
|
719
|
+
If True, returns masks for the full frame including preamble and
|
|
720
|
+
guard intervals. If False, returns masks only for the segments
|
|
721
|
+
after the preamble (and after CP removal if guard_type='cp').
|
|
722
|
+
|
|
723
|
+
Returns
|
|
724
|
+
-------
|
|
725
|
+
dict
|
|
726
|
+
Dictionary containing boolean masks for:
|
|
727
|
+
- 'preamble' (only if include_preamble=True)
|
|
728
|
+
- 'pilots'
|
|
729
|
+
- 'payload'
|
|
730
|
+
- 'guard' (only if include_preamble=True OR guard_type='zero')
|
|
731
|
+
"""
|
|
732
|
+
xp = cp if is_cupy_available() else np
|
|
733
|
+
mask, body_length = self._generate_pilot_mask()
|
|
734
|
+
|
|
735
|
+
preamble_len = self.preamble.num_symbols if self.preamble else 0
|
|
736
|
+
|
|
737
|
+
if include_preamble:
|
|
738
|
+
total_len = preamble_len + body_length + self.guard_len
|
|
739
|
+
|
|
740
|
+
preamble_bool = xp.zeros(total_len, dtype=bool)
|
|
741
|
+
pilot_bool = xp.zeros(total_len, dtype=bool)
|
|
742
|
+
payload_bool = xp.zeros(total_len, dtype=bool)
|
|
743
|
+
guard_bool = xp.zeros(total_len, dtype=bool)
|
|
744
|
+
|
|
745
|
+
if self.guard_type == "cp":
|
|
746
|
+
g_slice = slice(0, self.guard_len)
|
|
747
|
+
p_slice = slice(self.guard_len, self.guard_len + preamble_len)
|
|
748
|
+
b_slice = slice(self.guard_len + preamble_len, total_len)
|
|
749
|
+
else:
|
|
750
|
+
p_slice = slice(0, preamble_len)
|
|
751
|
+
b_slice = slice(preamble_len, preamble_len + body_length)
|
|
752
|
+
g_slice = slice(preamble_len + body_length, total_len)
|
|
753
|
+
|
|
754
|
+
if preamble_len > 0:
|
|
755
|
+
preamble_bool[p_slice] = True
|
|
756
|
+
|
|
757
|
+
pilot_bool[b_slice] = mask
|
|
758
|
+
payload_bool[b_slice] = ~mask
|
|
759
|
+
|
|
760
|
+
if self.guard_len > 0:
|
|
761
|
+
guard_bool[g_slice] = True
|
|
762
|
+
|
|
763
|
+
res = {
|
|
764
|
+
"preamble": preamble_bool,
|
|
765
|
+
"pilots": pilot_bool,
|
|
766
|
+
"payload": payload_bool,
|
|
767
|
+
"guard": guard_bool,
|
|
768
|
+
}
|
|
769
|
+
else:
|
|
770
|
+
# Preamble removed.
|
|
771
|
+
# If CP, guard is at the start and is typically removed with preamble.
|
|
772
|
+
# If ZERO, guard is at the end and remains part of the signal.
|
|
773
|
+
if self.guard_type == "cp":
|
|
774
|
+
total_len = body_length
|
|
775
|
+
pilot_bool = mask
|
|
776
|
+
payload_bool = ~mask
|
|
777
|
+
res = {
|
|
778
|
+
"pilots": pilot_bool,
|
|
779
|
+
"payload": payload_bool,
|
|
780
|
+
}
|
|
781
|
+
else:
|
|
782
|
+
total_len = body_length + self.guard_len
|
|
783
|
+
pilot_bool = xp.zeros(total_len, dtype=bool)
|
|
784
|
+
payload_bool = xp.zeros(total_len, dtype=bool)
|
|
785
|
+
guard_bool = xp.zeros(total_len, dtype=bool)
|
|
786
|
+
|
|
787
|
+
b_slice = slice(0, body_length)
|
|
788
|
+
g_slice = slice(body_length, total_len)
|
|
789
|
+
|
|
790
|
+
pilot_bool[b_slice] = mask
|
|
791
|
+
payload_bool[b_slice] = ~mask
|
|
792
|
+
guard_bool[g_slice] = True
|
|
793
|
+
|
|
794
|
+
res = {
|
|
795
|
+
"pilots": pilot_bool,
|
|
796
|
+
"payload": payload_bool,
|
|
797
|
+
"guard": guard_bool,
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
if unit == "samples":
|
|
801
|
+
for k in res:
|
|
802
|
+
res[k] = xp.repeat(res[k], int(sps))
|
|
803
|
+
|
|
804
|
+
return res
|
|
805
|
+
|
|
806
|
+
# -------------------------------------------------------------------------
|
|
807
|
+
# Signal Generation
|
|
808
|
+
# -------------------------------------------------------------------------
|
|
809
|
+
|
|
810
|
+
def to_signal(
|
|
811
|
+
self,
|
|
812
|
+
sps: int = 4,
|
|
813
|
+
symbol_rate: float = 1e6,
|
|
814
|
+
pulse_shape: str = "rrc",
|
|
815
|
+
filter_span: int = 10,
|
|
816
|
+
rrc_rolloff: float = 0.35,
|
|
817
|
+
rc_rolloff: float = 0.35,
|
|
818
|
+
rise_time: float = 0.0,
|
|
819
|
+
duty_cycle: float = 1.0,
|
|
820
|
+
) -> Signal:
|
|
821
|
+
"""
|
|
822
|
+
Generates a shaped, oversampled waveform from the frame description.
|
|
823
|
+
|
|
824
|
+
This is the primary method for moving from a logical frame to
|
|
825
|
+
physical IQ samples. It handles upsampling, pulse shaping,
|
|
826
|
+
guard interval insertion, and metadata population.
|
|
827
|
+
|
|
828
|
+
Parameters
|
|
829
|
+
----------
|
|
830
|
+
sps : int, default 4
|
|
831
|
+
Samples per symbol (oversampling factor).
|
|
832
|
+
symbol_rate : float, default 1e6
|
|
833
|
+
Symbol rate in Hz.
|
|
834
|
+
pulse_shape : str, default "rrc"
|
|
835
|
+
Pulse shaping filter type.
|
|
836
|
+
filter_span : int, default 10
|
|
837
|
+
Filter span in symbols.
|
|
838
|
+
rrc_rolloff : float, default 0.35
|
|
839
|
+
Roll-off factor for RRC filter.
|
|
840
|
+
rc_rolloff : float, default 0.35
|
|
841
|
+
Roll-off factor for RC filter.
|
|
842
|
+
rise_time : float, default 0.22
|
|
843
|
+
10%-90% edge transition duration in symbol periods (smoothrect only).
|
|
844
|
+
duty_cycle : float, default 1.0
|
|
845
|
+
FWHM of the Gaussian pulse in symbol periods (gaussian only).
|
|
846
|
+
duty_cycle : float, default 1.0
|
|
847
|
+
Fraction of the symbol period occupied by the pulse (rect/smoothrect).
|
|
848
|
+
|
|
849
|
+
Returns
|
|
850
|
+
-------
|
|
851
|
+
Signal
|
|
852
|
+
A `Signal` object containing the IQ samples and metadata.
|
|
853
|
+
|
|
854
|
+
Notes
|
|
855
|
+
-----
|
|
856
|
+
Each section (preamble and body) is independently I/Q component peak-normalised
|
|
857
|
+
so both occupy the full DAC range regardless of their modulation format.
|
|
858
|
+
After concatenation the full frame is normalised to **unit symbol power
|
|
859
|
+
(Es = 1)**, meaning average sample power = 1/sps. This matches the
|
|
860
|
+
convention used by ``shape_pulse`` and ``apply_awgn``.
|
|
861
|
+
Pilot/payload power ratios set by `pilot_gain_db` are preserved throughout.
|
|
862
|
+
"""
|
|
863
|
+
xp = cp if is_cupy_available() else np
|
|
864
|
+
from .. import mapping
|
|
865
|
+
from ..filtering import shape_pulse
|
|
866
|
+
|
|
867
|
+
if sps != int(sps) or sps < 1:
|
|
868
|
+
logger.warning(
|
|
869
|
+
"sps=%r is not a positive integer. Non-integer sps causes sample buffer / sampling_rate metadata mismatch.",
|
|
870
|
+
sps,
|
|
871
|
+
)
|
|
872
|
+
raise ValueError(
|
|
873
|
+
f"sps must be a positive integer for signal generation, got {sps!r}."
|
|
874
|
+
)
|
|
875
|
+
sps = int(sps)
|
|
876
|
+
|
|
877
|
+
# 1. Shape Body (Payload + Pilots)
|
|
878
|
+
body_symbols = self.body_symbols
|
|
879
|
+
body_samples = shape_pulse(
|
|
880
|
+
symbols=body_symbols,
|
|
881
|
+
sps=sps,
|
|
882
|
+
pulse_shape=pulse_shape,
|
|
883
|
+
filter_span=filter_span,
|
|
884
|
+
rrc_rolloff=rrc_rolloff,
|
|
885
|
+
rc_rolloff=rc_rolloff,
|
|
886
|
+
rise_time=rise_time,
|
|
887
|
+
duty_cycle=duty_cycle,
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
# Normalise body per-channel by max(peak_|I|, peak_|Q|) - a single scale
|
|
891
|
+
# factor that brings the dominant component to 1.0 while preserving the I/Q
|
|
892
|
+
# ratio. Complex-envelope peak normalisation (used in the DSP chain) divides
|
|
893
|
+
# by max(|sample|) instead, leaving components at ≤ 1/√2 ≈ 0.707 for square
|
|
894
|
+
# QAM/PSK whose envelope peak sits at 45°. Applied per-section (body and
|
|
895
|
+
# preamble separately) so each segment uses the full DAC range regardless of
|
|
896
|
+
# modulation type or constellation phase geometry.
|
|
897
|
+
max_iq = xp.maximum(
|
|
898
|
+
xp.max(xp.abs(body_samples.real), axis=-1, keepdims=True),
|
|
899
|
+
xp.max(xp.abs(body_samples.imag), axis=-1, keepdims=True),
|
|
900
|
+
)
|
|
901
|
+
max_iq = xp.where(max_iq == 0, xp.ones_like(max_iq), max_iq)
|
|
902
|
+
body_samples = body_samples / max_iq
|
|
903
|
+
|
|
904
|
+
# 2. Shape Preamble (if present)
|
|
905
|
+
if self.preamble is not None:
|
|
906
|
+
# Use Preamble's to_signal for shaping to reuse logic,
|
|
907
|
+
# but we only need the samples.
|
|
908
|
+
# CRITICAL: Must use EXACT same shaping parameters as body.
|
|
909
|
+
preamble_signal = self.preamble.to_signal(
|
|
910
|
+
sps=sps,
|
|
911
|
+
symbol_rate=symbol_rate,
|
|
912
|
+
pulse_shape=pulse_shape,
|
|
913
|
+
filter_span=filter_span,
|
|
914
|
+
rrc_rolloff=rrc_rolloff,
|
|
915
|
+
rc_rolloff=rc_rolloff,
|
|
916
|
+
rise_time=rise_time,
|
|
917
|
+
duty_cycle=duty_cycle,
|
|
918
|
+
)
|
|
919
|
+
preamble_samples = xp.asarray(preamble_signal.samples)
|
|
920
|
+
# (L*sps,) for SISO or (num_streams, L*sps) for MIMO - shape driven by preamble.num_streams
|
|
921
|
+
|
|
922
|
+
# I/Q peak normalisation - axis=-1, keepdims=True works for both 1-D and 2-D
|
|
923
|
+
max_iq_p = xp.maximum(
|
|
924
|
+
xp.max(xp.abs(preamble_samples.real), axis=-1, keepdims=True),
|
|
925
|
+
xp.max(xp.abs(preamble_samples.imag), axis=-1, keepdims=True),
|
|
926
|
+
)
|
|
927
|
+
max_iq_p = xp.where(max_iq_p == 0, xp.ones_like(max_iq_p), max_iq_p)
|
|
928
|
+
preamble_samples = preamble_samples / max_iq_p
|
|
929
|
+
|
|
930
|
+
# Concatenate Preamble + Body
|
|
931
|
+
samples = xp.concatenate([preamble_samples, body_samples], axis=-1)
|
|
932
|
+
else:
|
|
933
|
+
samples = body_samples
|
|
934
|
+
|
|
935
|
+
# 3. Apply Guard Interval at sample level
|
|
936
|
+
if self.guard_len > 0:
|
|
937
|
+
guard_len_samples = int(self.guard_len * sps)
|
|
938
|
+
if self.guard_type == "zero":
|
|
939
|
+
if self.num_streams > 1:
|
|
940
|
+
zeros = xp.zeros(
|
|
941
|
+
(self.num_streams, guard_len_samples), dtype="complex64"
|
|
942
|
+
)
|
|
943
|
+
else:
|
|
944
|
+
zeros = xp.zeros(guard_len_samples, dtype="complex64")
|
|
945
|
+
samples = xp.concatenate([samples, zeros], axis=-1)
|
|
946
|
+
elif self.guard_type == "cp":
|
|
947
|
+
cp_slice = samples[..., -guard_len_samples:]
|
|
948
|
+
samples = xp.concatenate([cp_slice, samples], axis=-1)
|
|
949
|
+
|
|
950
|
+
# 4. Normalize assembled frame to unit average power.
|
|
951
|
+
# Each section (preamble, body) was independently I/Q peak-normalised so
|
|
952
|
+
# that both use the full DAC range irrespective of their modulation format.
|
|
953
|
+
# After concatenation the sections may differ in average power, so a final
|
|
954
|
+
# global normalization brings the frame to unit symbol power (Es = 1),
|
|
955
|
+
# i.e. average sample power = 1/sps. Pilot/payload power ratios within
|
|
956
|
+
# the body are preserved because every section's samples are scaled by the
|
|
957
|
+
# same factor. Guard zeros remain zero after scaling.
|
|
958
|
+
samples = helpers.normalize(samples, "symbol_power", sps=sps, axis=-1)
|
|
959
|
+
|
|
960
|
+
# Resolve ν: payload_nu is set directly; for entropy-specified frames call optimal_nu.
|
|
961
|
+
# payload_ps_pmf is already computed above (body_symbols triggers _ensure_payload_generated).
|
|
962
|
+
if self.payload_nu is not None:
|
|
963
|
+
ps_nu_val: float | None = self.payload_nu
|
|
964
|
+
elif self.payload_entropy is not None:
|
|
965
|
+
ps_nu_val, _ = mapping.optimal_nu(
|
|
966
|
+
self.payload_mod_order, self.payload_entropy
|
|
967
|
+
)
|
|
968
|
+
else:
|
|
969
|
+
ps_nu_val = None
|
|
970
|
+
|
|
971
|
+
return Signal(
|
|
972
|
+
samples=samples,
|
|
973
|
+
sampling_rate=symbol_rate * sps,
|
|
974
|
+
symbol_rate=symbol_rate,
|
|
975
|
+
mod_scheme=None,
|
|
976
|
+
mod_order=None,
|
|
977
|
+
mod_unipolar=None,
|
|
978
|
+
mod_rz=None,
|
|
979
|
+
source_bits=None, # extract via frame.get_structure_map() after equalization
|
|
980
|
+
source_symbols=None, # samples include full frame (preamble + body);
|
|
981
|
+
# extract payload segment via frame.get_structure_map() explicitly.
|
|
982
|
+
ps_pmf=self.payload_ps_pmf,
|
|
983
|
+
ps_nu=ps_nu_val,
|
|
984
|
+
pulse_shape=pulse_shape,
|
|
985
|
+
duty_cycle=duty_cycle,
|
|
986
|
+
filter_span=filter_span,
|
|
987
|
+
rrc_rolloff=rrc_rolloff,
|
|
988
|
+
rc_rolloff=rc_rolloff,
|
|
989
|
+
rise_time=rise_time,
|
|
990
|
+
signal_type="Single-Carrier Frame",
|
|
991
|
+
frame=self,
|
|
992
|
+
)
|