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
@@ -0,0 +1,581 @@
1
+ """
2
+ Signal generation factories.
3
+
4
+ Free functions that construct :class:`Signal` instances for the supported
5
+ modulation formats (``generate`` plus the ``generate_pam``/``generate_psk``/
6
+ ``generate_qam``/``generate_psqam`` wrappers); they are re-exported at the
7
+ package top level (``commkit.generate_qam(...)`` etc.).
8
+
9
+ All factories follow a bit-first architecture: random bits are generated, mapped
10
+ to symbols, upsampled, and pulse-shaped, with samples normalized to unit symbol
11
+ power (Es = 1, average sample power = 1/sps).
12
+ """
13
+
14
+ from typing import Literal, cast
15
+
16
+ import numpy as np
17
+
18
+ from .. import filtering, helpers, mapping
19
+ from ..backend import ArrayType, is_cupy_available, to_device
20
+ from ..logger import logger
21
+ from .signal import Signal
22
+
23
+
24
+ def generate(
25
+ num_symbols: int,
26
+ sps: int,
27
+ symbol_rate: float,
28
+ modulation: str,
29
+ order: int,
30
+ unipolar: bool = False,
31
+ rz: bool = False,
32
+ pulse_shape: str = "none",
33
+ num_streams: int = 1,
34
+ seed: int | None = None,
35
+ duty_cycle: float = 1.0,
36
+ filter_span: int = 10,
37
+ rrc_rolloff: float = 0.35,
38
+ rc_rolloff: float = 0.35,
39
+ rise_time: float = 0.0,
40
+ ) -> "Signal":
41
+ """
42
+ Generates a generic baseband waveform with specified modulation.
43
+
44
+ This is the primary factory method for creating synthetic signals.
45
+ It follows a bit-first architecture: random bits are generated,
46
+ mapped to symbols, upsampled, and pulse-shaped.
47
+
48
+ Parameters
49
+ ----------
50
+ num_symbols : int
51
+ Number of symbols to generate per stream.
52
+ sps : float
53
+ Samples per symbol.
54
+ symbol_rate : float
55
+ Symbol rate in symbols per second (Baud).
56
+ modulation : {"psk", "qam", "ask"}
57
+ The modulation scheme identifier.
58
+ order : int
59
+ Modulation order (e.g., 4, 16, 64).
60
+ unipolar : bool, default False
61
+ If True, uses a unipolar constellation.
62
+ rz : bool, default False
63
+ If True, uses Return-to-Zero signaling.
64
+ pulse_shape : str, default "none"
65
+ Pulse shaping filter type (e.g., ``'rrc'``, ``'rect'``).
66
+ num_streams : int, default 1
67
+ Number of independent streams (MIMO).
68
+ seed : int, optional
69
+ Seed for reproducible random generation.
70
+ duty_cycle : float, default 1.0
71
+ Fraction of the symbol period occupied by the pulse (rect/smoothrect).
72
+ Overridden to 0.5 when ``rz=True``.
73
+ filter_span : int, default 10
74
+ Filter span in symbols for smoothrect/gaussian/rrc/rc/sinc.
75
+ rrc_rolloff : float, default 0.35
76
+ Roll-off factor for the RRC filter.
77
+ rc_rolloff : float, default 0.35
78
+ Roll-off factor for the RC filter.
79
+ rise_time : float, default 0.22
80
+ 10%-90% edge transition duration in symbol periods for smoothrect.
81
+ duty_cycle : float, default 1.0
82
+ FWHM of the Gaussian pulse in symbol periods.
83
+
84
+ Returns
85
+ -------
86
+ Signal
87
+ A new `Signal` instance.
88
+
89
+ Notes
90
+ -----
91
+ Samples are normalized to unit symbol power (Es = 1, average sample power = 1/sps).
92
+ Call ``resolve_symbols()`` before demapping or computing metrics.
93
+ """
94
+
95
+ if sps != int(sps) or sps < 1:
96
+ logger.warning(
97
+ "sps=%r is not a positive integer. Non-integer sps is valid for captured/resampled signals but not for generation: resample_poly requires an integer upsampling factor, so the sample buffer would not match the stored sampling_rate metadata. To generate at a fractional sps, generate at an integer sps then call Signal.resample(up=..., down=...).",
98
+ sps,
99
+ )
100
+ raise ValueError(
101
+ f"sps must be a positive integer for signal generation, got {sps!r}."
102
+ )
103
+ sps = int(sps)
104
+
105
+ # When rz=True and the caller hasn't specified a custom duty_cycle,
106
+ # default to 50% (canonical RZ). Explicit duty_cycle values are preserved.
107
+ if rz and duty_cycle == 1.0:
108
+ duty_cycle = 0.5
109
+
110
+ # Bit-first architecture: generate bits -> map to symbols
111
+ k = int(np.log2(order)) # bits per symbol
112
+ total_symbols = num_symbols * num_streams
113
+ total_bits = total_symbols * k
114
+
115
+ # Generate source bits
116
+ bits = helpers.generate_bits(total_bits, seed=seed)
117
+
118
+ # Map bits to symbols
119
+ symbols_flat = mapping.map_bits(bits, modulation, order, unipolar)
120
+
121
+ if num_streams > 1:
122
+ # Shape: (Channels, Time)
123
+ symbols = symbols_flat.reshape(num_streams, num_symbols)
124
+ bits = bits.reshape(num_streams, num_symbols * k)
125
+ else:
126
+ symbols = symbols_flat
127
+
128
+ if is_cupy_available():
129
+ symbols = to_device(symbols, "gpu")
130
+ bits = to_device(bits, "gpu")
131
+
132
+ # Apply pulse shaping
133
+ # shape_pulse defaults to axis=-1 (Time) which is correct for (C, T)
134
+ samples = filtering.shape_pulse(
135
+ symbols=symbols,
136
+ sps=sps,
137
+ pulse_shape=pulse_shape,
138
+ rz=rz,
139
+ duty_cycle=duty_cycle,
140
+ filter_span=filter_span,
141
+ rrc_rolloff=rrc_rolloff,
142
+ rc_rolloff=rc_rolloff,
143
+ rise_time=rise_time,
144
+ )
145
+
146
+ logger.info(
147
+ "Generated %s-%s signal: %s symbols x %s stream(s), sps=%s, pulse_shape=%s, %s samples/stream @ %.3g Sa/s.",
148
+ modulation.upper(),
149
+ order,
150
+ num_symbols,
151
+ num_streams,
152
+ sps,
153
+ pulse_shape,
154
+ samples.shape[-1],
155
+ symbol_rate * sps,
156
+ )
157
+
158
+ return Signal(
159
+ samples=samples,
160
+ sampling_rate=symbol_rate * sps,
161
+ symbol_rate=symbol_rate,
162
+ mod_scheme=modulation.upper(),
163
+ mod_order=order,
164
+ mod_unipolar=unipolar,
165
+ mod_rz=rz,
166
+ source_bits=bits,
167
+ source_symbols=symbols,
168
+ pulse_shape=pulse_shape,
169
+ filter_span=filter_span,
170
+ rrc_rolloff=rrc_rolloff,
171
+ rc_rolloff=rc_rolloff,
172
+ rise_time=rise_time,
173
+ duty_cycle=duty_cycle,
174
+ )
175
+
176
+
177
+ def generate_pam(
178
+ num_symbols: int,
179
+ sps: int,
180
+ symbol_rate: float,
181
+ order: int,
182
+ unipolar: bool = False,
183
+ rz: bool = False,
184
+ pulse_shape: Literal["rect", "smoothrect"] = "rect",
185
+ num_streams: int = 1,
186
+ seed: int | None = None,
187
+ duty_cycle: float = 1.0,
188
+ filter_span: int = 10,
189
+ rise_time: float = 0.0,
190
+ ) -> "Signal":
191
+ """
192
+ Generates a Pulse Amplitude Modulation (PAM) baseband waveform.
193
+
194
+ Supports both NRZ (Non-Return-to-Zero) and RZ (Return-to-Zero)
195
+ signaling, with configurable pulse shaping and bipolar/unipolar
196
+ constellations.
197
+
198
+ Parameters
199
+ ----------
200
+ num_symbols : int
201
+ Total number of symbols to generate per stream.
202
+ sps : int
203
+ Samples per symbol. For RZ mode, this must be an even integer.
204
+ symbol_rate : float
205
+ Symbol rate in symbols per second (Baud).
206
+ order : int
207
+ Modulation order (e.g., 2, 4, 8).
208
+ unipolar : bool, default False
209
+ If True, uses a unipolar constellation starting from 0 (e.g., 0, 1).
210
+ If False, uses a symmetric bipolar constellation (e.g., -1, +1).
211
+ rz : bool, default False
212
+ If True, uses Return-to-Zero signaling.
213
+ pulse_shape : {"rect", "smoothrect"}, default "rect"
214
+ Pulse shaping filter type. Default is "rect" for PAM.
215
+ num_streams : int, default 1
216
+ Number of independent streams (channels) to generate.
217
+ seed : int, optional
218
+ Random seed for reproducible bit and symbol generation.
219
+ duty_cycle : float, default 1.0
220
+ Fraction of the symbol period occupied by the pulse. Overridden to
221
+ 0.5 when ``rz=True``.
222
+ filter_span : int, default 10
223
+ Filter span in symbols (smoothrect only).
224
+ rise_time : float, default 0.22
225
+ 10%-90% edge transition duration in symbol periods (smoothrect only).
226
+
227
+ Returns
228
+ -------
229
+ Signal
230
+ A `Signal` object containing the generated PAM waveform.
231
+
232
+ Notes
233
+ -----
234
+ Samples are normalized to unit symbol power (Es = 1, average sample power = 1/sps).
235
+ Call ``resolve_symbols()`` before demapping or computing metrics.
236
+ """
237
+ if rz:
238
+ if sps % 2 != 0:
239
+ raise ValueError("For correct RZ duty cycle, `sps` must be even")
240
+
241
+ allowed_rz_pulses = ["rect", "smoothrect"]
242
+ if pulse_shape not in allowed_rz_pulses:
243
+ raise ValueError(
244
+ f"Pulse shape '{pulse_shape}' is not allowed for RZ PAM. "
245
+ f"Allowed: {allowed_rz_pulses}"
246
+ )
247
+
248
+ return generate(
249
+ num_symbols=num_symbols,
250
+ sps=sps,
251
+ symbol_rate=symbol_rate,
252
+ modulation="PAM",
253
+ order=order,
254
+ unipolar=unipolar,
255
+ rz=rz,
256
+ pulse_shape=pulse_shape,
257
+ num_streams=num_streams,
258
+ seed=seed,
259
+ filter_span=filter_span,
260
+ rise_time=rise_time,
261
+ duty_cycle=duty_cycle,
262
+ )
263
+
264
+
265
+ def generate_psk(
266
+ num_symbols: int,
267
+ sps: int,
268
+ symbol_rate: float,
269
+ order: int,
270
+ unipolar: bool = False,
271
+ rz: bool = False,
272
+ pulse_shape: str = "rrc",
273
+ num_streams: int = 1,
274
+ seed: int | None = None,
275
+ filter_span: int = 10,
276
+ rrc_rolloff: float = 0.35,
277
+ rc_rolloff: float = 0.35,
278
+ rise_time: float = 0.0,
279
+ duty_cycle: float = 1.0,
280
+ ) -> "Signal":
281
+ """
282
+ Generates a Phase Shift Keying (PSK) baseband waveform.
283
+
284
+ Parameters
285
+ ----------
286
+ num_symbols : int
287
+ Total number of symbols to generate per stream.
288
+ sps : float
289
+ Samples per symbol.
290
+ symbol_rate : float
291
+ Symbol rate in symbols per second (Baud).
292
+ order : int
293
+ Modulation order (e.g., 2 for BPSK, 4 for QPSK, 8 for 8-PSK).
294
+ unipolar : bool, default False
295
+ If True, uses a unipolar constellation.
296
+ rz : bool, default False
297
+ If True, uses Return-to-Zero signaling.
298
+ pulse_shape : str, default "rrc"
299
+ Pulse shaping filter type.
300
+ num_streams : int, default 1
301
+ Number of independent streams (channels) to generate.
302
+ seed : int, optional
303
+ Random seed for bit and symbol generation.
304
+ duty_cycle : float, default 1.0
305
+ Fraction of the symbol period occupied by the pulse (rect/smoothrect).
306
+ Only meaningful when ``rz=True``.
307
+ filter_span : int, default 10
308
+ Filter span in symbols.
309
+ rrc_rolloff : float, default 0.35
310
+ Roll-off factor for the RRC filter.
311
+ rc_rolloff : float, default 0.35
312
+ Roll-off factor for the RC filter.
313
+ rise_time : float, default 0.22
314
+ 10%-90% edge transition duration in symbol periods (smoothrect only).
315
+ duty_cycle : float, default 1.0
316
+ FWHM of the Gaussian pulse in symbol periods (gaussian only).
317
+
318
+ Returns
319
+ -------
320
+ Signal
321
+ A `Signal` object containing the PSK waveform.
322
+
323
+ Notes
324
+ -----
325
+ Samples are normalized to unit symbol power (Es = 1, average sample power = 1/sps).
326
+ Call ``resolve_symbols()`` before demapping or computing metrics.
327
+ """
328
+ return generate(
329
+ modulation="psk",
330
+ order=order,
331
+ num_symbols=num_symbols,
332
+ sps=sps,
333
+ symbol_rate=symbol_rate,
334
+ pulse_shape=pulse_shape,
335
+ num_streams=num_streams,
336
+ seed=seed,
337
+ unipolar=unipolar,
338
+ rz=rz,
339
+ filter_span=filter_span,
340
+ rrc_rolloff=rrc_rolloff,
341
+ rc_rolloff=rc_rolloff,
342
+ rise_time=rise_time,
343
+ duty_cycle=duty_cycle,
344
+ )
345
+
346
+
347
+ def generate_qam(
348
+ num_symbols: int,
349
+ sps: int,
350
+ symbol_rate: float,
351
+ order: int,
352
+ unipolar: bool = False,
353
+ rz: bool = False,
354
+ pulse_shape: str = "rrc",
355
+ num_streams: int = 1,
356
+ seed: int | None = None,
357
+ filter_span: int = 10,
358
+ rrc_rolloff: float = 0.35,
359
+ rc_rolloff: float = 0.35,
360
+ rise_time: float = 0.0,
361
+ duty_cycle: float = 1.0,
362
+ ) -> "Signal":
363
+ """
364
+ Generates a Quadrature Amplitude Modulation (QAM) baseband waveform.
365
+
366
+ Parameters
367
+ ----------
368
+ num_symbols : int
369
+ Number of symbols to generate per stream.
370
+ sps : float
371
+ Samples per symbol.
372
+ symbol_rate : float
373
+ Symbol rate in symbols per second (Baud).
374
+ order : int
375
+ Modulation order (e.g., 16, 64, 256).
376
+ unipolar : bool, default False
377
+ If True, uses a unipolar constellation.
378
+ rz : bool, default False
379
+ If True, uses Return-to-Zero signaling.
380
+ pulse_shape : str, default "rrc"
381
+ Pulse shaping filter type.
382
+ num_streams : int, default 1
383
+ Number of MIMO streams.
384
+ seed : int, optional
385
+ Seed for random generation.
386
+ duty_cycle : float, default 1.0
387
+ Fraction of the symbol period occupied by the pulse (rect/smoothrect).
388
+ Only meaningful when ``rz=True``.
389
+ filter_span : int, default 10
390
+ Filter span in symbols.
391
+ rrc_rolloff : float, default 0.35
392
+ Roll-off factor for the RRC filter.
393
+ rc_rolloff : float, default 0.35
394
+ Roll-off factor for the RC filter.
395
+ rise_time : float, default 0.22
396
+ 10%-90% edge transition duration in symbol periods (smoothrect only).
397
+ duty_cycle : float, default 1.0
398
+ FWHM of the Gaussian pulse in symbol periods (gaussian only).
399
+
400
+ Returns
401
+ -------
402
+ Signal
403
+ A `Signal` object containing the QAM waveform.
404
+
405
+ Notes
406
+ -----
407
+ Samples are normalized to unit symbol power (Es = 1, average sample power = 1/sps).
408
+ Call ``resolve_symbols()`` before demapping or computing metrics.
409
+ """
410
+ return generate(
411
+ modulation="qam",
412
+ order=order,
413
+ num_symbols=num_symbols,
414
+ sps=sps,
415
+ symbol_rate=symbol_rate,
416
+ pulse_shape=pulse_shape,
417
+ num_streams=num_streams,
418
+ seed=seed,
419
+ unipolar=unipolar,
420
+ rz=rz,
421
+ filter_span=filter_span,
422
+ rrc_rolloff=rrc_rolloff,
423
+ rc_rolloff=rc_rolloff,
424
+ rise_time=rise_time,
425
+ duty_cycle=duty_cycle,
426
+ )
427
+
428
+
429
+ def generate_psqam(
430
+ num_symbols: int,
431
+ sps: int,
432
+ symbol_rate: float,
433
+ order: int,
434
+ *,
435
+ nu: float | None = None,
436
+ entropy: float | None = None,
437
+ pulse_shape: str = "rrc",
438
+ num_streams: int = 1,
439
+ seed: int | None = None,
440
+ filter_span: int = 10,
441
+ rrc_rolloff: float = 0.35,
442
+ rc_rolloff: float = 0.35,
443
+ duty_cycle: float = 1.0,
444
+ ) -> "Signal":
445
+ """
446
+ Generates a Probabilistically Shaped QAM (PS-QAM) baseband waveform.
447
+
448
+ Symbols are drawn from a Maxwell-Boltzmann (MB) distribution over the
449
+ normalized QAM constellation, giving inner (low-energy) points higher
450
+ probability. This recovers up to 1.53 dB shaping gain over uniform QAM.
451
+
452
+ Exactly one of ``nu`` or ``entropy`` must be specified.
453
+
454
+ Parameters
455
+ ----------
456
+ num_symbols : int
457
+ Number of symbols to generate per stream.
458
+ sps : float
459
+ Samples per symbol.
460
+ symbol_rate : float
461
+ Symbol rate in symbols per second (Baud).
462
+ order : int
463
+ QAM modulation order (e.g. 16, 64, 256).
464
+ nu : float, optional
465
+ MB shaping parameter nu >= 0. nu = 0 is uniform QAM.
466
+ Larger values apply stronger shaping (lower entropy, lower power).
467
+ entropy : float, optional
468
+ Target per-symbol entropy in bits, in the range (0, log2(order)].
469
+ optimal_nu is called to solve for the corresponding nu.
470
+ pulse_shape : str, default "rrc"
471
+ Pulse shaping filter type.
472
+ num_streams : int, default 1
473
+ Number of independent streams (MIMO).
474
+ seed : int, optional
475
+ Random seed for reproducible symbol generation.
476
+ filter_span : int, default 10
477
+ Filter span in symbols.
478
+ rrc_rolloff : float, default 0.35
479
+ Roll-off factor for the RRC filter.
480
+ rc_rolloff : float, default 0.35
481
+ Roll-off factor for the RC filter.
482
+
483
+ Returns
484
+ -------
485
+ Signal
486
+ A ``Signal`` with ``mod_scheme="PS-QAM"``, ``ps_pmf`` set to the MB
487
+ distribution, and both ``source_symbols`` and ``source_bits`` populated.
488
+
489
+ Notes
490
+ -----
491
+ ``source_bits`` carry the non-uniform MB statistics (correct for BER/GMI
492
+ estimation, not a full coded PAS transmitter). Average symbol energy is
493
+ below 1 for nu > 0; pass ``pmf=signal.ps_pmf`` to ``metrics.mi`` and
494
+ ``compute_llr`` for correct soft-demapping.
495
+
496
+ Examples
497
+ --------
498
+ >>> sig = generate_psqam(10000, sps=4, symbol_rate=32e9, order=64, entropy=6.0)
499
+ >>> sig = generate_psqam(10000, sps=4, symbol_rate=32e9, order=64, nu=0.3)
500
+ """
501
+
502
+ if sps != int(sps) or sps < 1:
503
+ logger.warning(
504
+ "sps=%r is not a positive integer. Non-integer sps is valid for captured/resampled signals but not for generation: resample_poly requires an integer upsampling factor, so the sample buffer would not match the stored sampling_rate metadata. To generate at a fractional sps, generate at an integer sps then call Signal.resample(up=..., down=...).",
505
+ sps,
506
+ )
507
+ raise ValueError(
508
+ f"sps must be a positive integer for signal generation, got {sps!r}."
509
+ )
510
+ sps = int(sps)
511
+
512
+ if (nu is None) == (entropy is None):
513
+ raise ValueError("Exactly one of `nu` or `entropy` must be specified.")
514
+
515
+ if entropy is not None:
516
+ nu_val, _ = mapping.optimal_nu(order, entropy)
517
+ else:
518
+ assert nu is not None
519
+ nu_val = float(nu)
520
+ if nu_val < 0:
521
+ raise ValueError("`nu` must be non-negative.")
522
+
523
+ pmf = mapping.maxwell_boltzmann(order, nu_val)
524
+ k = int(np.log2(order))
525
+ total_symbols = num_symbols * num_streams
526
+
527
+ # Sample symbols from MB distribution (NumPy, CPU)
528
+ symbols_flat = mapping.sample_ps_symbols(total_symbols, order, pmf, seed=seed)
529
+
530
+ # Derive source bits by demapping noiseless shaped symbols (lossless).
531
+ # Array input -> array output (the Signal-dispatch branch is not taken).
532
+ bits_flat = cast(ArrayType, mapping.demap_symbols_hard(symbols_flat, "qam", order))
533
+
534
+ if num_streams > 1:
535
+ symbols = symbols_flat.reshape(num_streams, num_symbols)
536
+ bits = bits_flat.reshape(num_streams, num_symbols * k)
537
+ else:
538
+ symbols = symbols_flat
539
+ bits = bits_flat
540
+
541
+ if is_cupy_available():
542
+ symbols = to_device(symbols, "gpu")
543
+ bits = to_device(bits, "gpu")
544
+
545
+ samples = filtering.shape_pulse(
546
+ symbols=symbols,
547
+ sps=sps,
548
+ pulse_shape=pulse_shape,
549
+ filter_span=filter_span,
550
+ rrc_rolloff=rrc_rolloff,
551
+ rc_rolloff=rc_rolloff,
552
+ duty_cycle=duty_cycle,
553
+ )
554
+
555
+ _ps_tag = f"entropy={entropy:.3g}" if entropy is not None else f"ν={nu_val:.3g}"
556
+ logger.info(
557
+ "Generated PS-QAM-%s signal: %s symbols x %s stream(s), sps=%s, %s, pulse_shape=%s, %s samples/stream @ %.3g Sa/s.",
558
+ order,
559
+ num_symbols,
560
+ num_streams,
561
+ sps,
562
+ _ps_tag,
563
+ pulse_shape,
564
+ samples.shape[-1],
565
+ symbol_rate * sps,
566
+ )
567
+
568
+ return Signal(
569
+ samples=samples,
570
+ sampling_rate=symbol_rate * sps,
571
+ symbol_rate=symbol_rate,
572
+ mod_scheme="PS-QAM",
573
+ mod_order=order,
574
+ source_bits=bits,
575
+ source_symbols=symbols,
576
+ pulse_shape=pulse_shape,
577
+ ps_pmf=pmf,
578
+ ps_nu=nu_val,
579
+ filter_span=filter_span,
580
+ rrc_rolloff=rrc_rolloff,
581
+ )