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/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