commkit 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. commkit/__init__.py +74 -0
  2. commkit/_cuda/__init__.py +321 -0
  3. commkit/_cuda/compiler.py +88 -0
  4. commkit/_cuda/src/bps_min_d2.cu +104 -0
  5. commkit/_cuda/src/cs_block.cu +119 -0
  6. commkit/_cuda/src/selftest.cu +14 -0
  7. commkit/analysis/__init__.py +55 -0
  8. commkit/analysis/_common.py +236 -0
  9. commkit/analysis/allan.py +108 -0
  10. commkit/analysis/drift.py +213 -0
  11. commkit/analysis/interferometry.py +887 -0
  12. commkit/analysis/linewidth.py +480 -0
  13. commkit/analysis/trajectory.py +91 -0
  14. commkit/backend.py +507 -0
  15. commkit/coding/__init__.py +23 -0
  16. commkit/coding/base.py +17 -0
  17. commkit/coding/bch.py +6 -0
  18. commkit/coding/convolutional.py +7 -0
  19. commkit/coding/crc.py +7 -0
  20. commkit/coding/galois.py +8 -0
  21. commkit/coding/hamming.py +6 -0
  22. commkit/coding/interleaving.py +7 -0
  23. commkit/coding/ldpc.py +8 -0
  24. commkit/coding/polar.py +8 -0
  25. commkit/coding/ratematch.py +6 -0
  26. commkit/coding/reed_solomon.py +6 -0
  27. commkit/coding/turbo.py +8 -0
  28. commkit/core/__init__.py +32 -0
  29. commkit/core/frame.py +992 -0
  30. commkit/core/generation.py +581 -0
  31. commkit/core/signal.py +725 -0
  32. commkit/equalization/__init__.py +49 -0
  33. commkit/equalization/_block.py +1855 -0
  34. commkit/equalization/_common.py +606 -0
  35. commkit/equalization/_kernels_jax.py +1720 -0
  36. commkit/equalization/_kernels_numba.py +1704 -0
  37. commkit/equalization/blind.py +223 -0
  38. commkit/equalization/linear.py +365 -0
  39. commkit/equalization/polarization.py +790 -0
  40. commkit/equalization/result.py +191 -0
  41. commkit/equalization/sequential.py +2805 -0
  42. commkit/filtering.py +1120 -0
  43. commkit/frequency.py +1191 -0
  44. commkit/helpers.py +489 -0
  45. commkit/impairments/__init__.py +43 -0
  46. commkit/impairments/channel/__init__.py +20 -0
  47. commkit/impairments/channel/linear.py +310 -0
  48. commkit/impairments/channel/nonlinear.py +11 -0
  49. commkit/impairments/frontend.py +229 -0
  50. commkit/impairments/noise.py +105 -0
  51. commkit/impairments/source.py +219 -0
  52. commkit/io.py +308 -0
  53. commkit/logger.py +103 -0
  54. commkit/mapping/__init__.py +46 -0
  55. commkit/mapping/bits.py +240 -0
  56. commkit/mapping/constellation.py +153 -0
  57. commkit/mapping/gray.py +429 -0
  58. commkit/mapping/llr.py +253 -0
  59. commkit/mapping/shaping.py +218 -0
  60. commkit/metrics.py +949 -0
  61. commkit/multirate.py +476 -0
  62. commkit/plotting/__init__.py +78 -0
  63. commkit/plotting/analysis.py +627 -0
  64. commkit/plotting/constellation.py +483 -0
  65. commkit/plotting/equalizer.py +390 -0
  66. commkit/plotting/eye.py +388 -0
  67. commkit/plotting/spectral.py +575 -0
  68. commkit/plotting/sync.py +953 -0
  69. commkit/plotting/theme.py +203 -0
  70. commkit/plotting/waveform.py +200 -0
  71. commkit/py.typed +0 -0
  72. commkit/recovery/__init__.py +51 -0
  73. commkit/recovery/bps.py +337 -0
  74. commkit/recovery/corrections.py +751 -0
  75. commkit/recovery/pilots.py +803 -0
  76. commkit/recovery/pll.py +482 -0
  77. commkit/recovery/tikhonov.py +424 -0
  78. commkit/recovery/viterbi_viterbi.py +227 -0
  79. commkit/spectral.py +560 -0
  80. commkit/timing.py +841 -0
  81. commkit-1.0.0.dist-info/METADATA +145 -0
  82. commkit-1.0.0.dist-info/RECORD +84 -0
  83. commkit-1.0.0.dist-info/WHEEL +4 -0
  84. commkit-1.0.0.dist-info/licenses/LICENSE +21 -0
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
+ )