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/filtering.py ADDED
@@ -0,0 +1,1120 @@
1
+ """
2
+ Digital filtering and pulse shaping.
3
+
4
+ This module provides routines for design and application of digital filters
5
+ commonly used in communication systems. It supports both standard FIR filters
6
+ and specialized pulse-shaping filters, with high-performance execution on
7
+ both CPU and GPU backends.
8
+ """
9
+
10
+ import numpy as np
11
+ import scipy
12
+
13
+ from .backend import ArrayType, dispatch, to_device
14
+ from .core.signal import Signal
15
+ from .helpers import normalize
16
+ from .logger import logger
17
+ from .multirate import expand
18
+
19
+ # -----------------------------------------------------------------------------
20
+ # FILTER DESIGN - TAP GENERATORS
21
+ # -----------------------------------------------------------------------------
22
+ # rect_taps: Hard or trapezoidal rectangular pulse taps
23
+ # gaussian_taps: Gaussian filter taps
24
+ # smoothrect_taps: Gaussian-smoothed rectangular pulse taps
25
+ # rrc_taps: Root Raised Cosine filter taps
26
+ # rc_taps: Raised Cosine filter taps
27
+ # lowpass_taps, highpass_taps, bandpass_taps, bandstop_taps: FIR filters
28
+
29
+
30
+ def rect_taps(sps: int, duty_cycle: float = 1.0, rise_time: float = 0.0) -> np.ndarray:
31
+ """
32
+ Generates rectangular or trapezoidal pulse-shaping filter taps.
33
+
34
+ With ``rise_time=0`` (default) the output is a hard rectangular pulse of
35
+ width ``duty_cycle`` symbol periods. With ``rise_time > 0`` the leading
36
+ and trailing edges are replaced by linear ramps, producing an isosceles
37
+ trapezoidal pulse that models a slew-rate-limited driver or modulator.
38
+
39
+ Parameters
40
+ ----------
41
+ sps : int
42
+ Samples per symbol.
43
+ duty_cycle : float, default 1.0
44
+ Total pulse width in symbol periods, including both ramps.
45
+ Must be in the range ``(0, 1]``.
46
+ rise_time : float, default 0.0
47
+ Duration of each linear ramp (10%->90% of amplitude is the full ramp
48
+ here; the ramp spans the full ``rise_time``) in symbol periods.
49
+ Must satisfy ``rise_time <= duty_cycle / 2``; otherwise the ramps
50
+ overlap and no flat top exists.
51
+
52
+ Returns
53
+ -------
54
+ ndarray
55
+ Pulse taps (unnormalized). Shape: ``(N_taps,)``.
56
+
57
+ Raises
58
+ ------
59
+ ValueError
60
+ If ``rise_time > duty_cycle / 2``.
61
+ """
62
+ if rise_time > duty_cycle / 2:
63
+ raise ValueError(
64
+ f"rise_time ({rise_time}) must be <= duty_cycle / 2 ({duty_cycle / 2:.3f}); "
65
+ "ramps would overlap with no flat top."
66
+ )
67
+
68
+ n_total = int(round(sps * duty_cycle))
69
+ if n_total < 1:
70
+ n_total = 1
71
+
72
+ if rise_time == 0.0:
73
+ h = np.ones(n_total)
74
+ else:
75
+ n_ramp = int(round(sps * rise_time))
76
+ n_flat = n_total - 2 * n_ramp
77
+ if n_flat < 0:
78
+ n_flat = 0
79
+ ramp_up = np.linspace(0.0, 1.0, n_ramp, endpoint=False)
80
+ ramp_dn = np.linspace(1.0, 0.0, n_ramp, endpoint=False)
81
+ flat = np.ones(n_flat)
82
+ h = np.concatenate([ramp_up, flat, ramp_dn])
83
+
84
+ logger.debug(
85
+ "Generating Rect taps: sps=%s, duty_cycle=%s, rise_time=%s, n_taps=%s",
86
+ sps,
87
+ duty_cycle,
88
+ rise_time,
89
+ len(h),
90
+ )
91
+ return h
92
+
93
+
94
+ def gaussian_taps(sps: float, span: int = 4, duty_cycle: float = 1.0) -> np.ndarray:
95
+ """
96
+ Generates Gaussian pulse-shaping filter taps.
97
+
98
+ The Gaussian filter is typically used in GMSK/GFSK modulation to minimize
99
+ occupied bandwidth while introducing controlled Inter-Symbol Interference (ISI).
100
+
101
+ Parameters
102
+ ----------
103
+ sps : float
104
+ Samples per symbol.
105
+ span : int, default 4
106
+ Total filter span in symbols. The number of taps will be ``span * sps + 1``
107
+ to ensure symmetry.
108
+ duty_cycle : float, default 1.0
109
+ Full-Width at Half-Maximum (FWHM) of the Gaussian pulse in symbol periods.
110
+ Smaller values produce a narrower pulse (lower ISI but wider bandwidth).
111
+ The Bandwidth-Time product is derived internally as
112
+ ``bt = √2·ln(2) / (π·duty_cycle)``.
113
+
114
+ Returns
115
+ -------
116
+ ndarray
117
+ Gaussian filter taps normalized to unit energy.
118
+ Shape: (N_taps,).
119
+ """
120
+ # Convert duty_cycle (FWHM in symbol periods) to BT product.
121
+ # FWHM of h(t) = exp(-(π·t/α)²) is α·√(ln2)/π = √(ln2/2)/B·√(ln2)/π = ln2/(π·B).
122
+ # Wait - using the standard relation:
123
+ # FWHM = √(2·ln2) · σ_freq, where B = 1/(2π·σ_freq) -> BT = √(ln2/2)/π
124
+ # More directly: FWHM_time = √(ln2/2) / (π·B) which gives BT = √(ln2/2)/π·(1/FWHM)
125
+ # Rearranged: bt = √2·ln(2) / (π·duty_cycle)
126
+ bt = np.sqrt(2) * np.log(2) / (np.pi * duty_cycle)
127
+ logger.debug(
128
+ "Generating Gaussian taps: sps=%s, span=%s, duty_cycle=%s (bt=%.4f)",
129
+ sps,
130
+ span,
131
+ duty_cycle,
132
+ bt,
133
+ )
134
+ # Ensure odd number of taps to have a center peak
135
+ num_taps = int(span * sps)
136
+ if num_taps % 2 == 0:
137
+ num_taps += 1
138
+
139
+ t = np.linspace(-span / 2, span / 2, num_taps)
140
+
141
+ # Gaussian function
142
+ # h(t) = (sqrt(pi)/alpha) * exp(-(pi*t/alpha)^2)
143
+ # where alpha = sqrt(ln(2)/2)/B
144
+ alpha = np.sqrt(np.log(2) / 2) / bt
145
+ h = (np.sqrt(np.pi) / alpha) * np.exp(-((np.pi * t / alpha) ** 2))
146
+
147
+ return normalize(h, "unit_energy")
148
+
149
+
150
+ def smoothrect_taps(
151
+ sps: int, span: int, rise_time: float = 0.22, duty_cycle: float = 1.0
152
+ ) -> ArrayType:
153
+ """
154
+ Generates a perfectly centered Gaussian-smoothed rectangular pulse.
155
+
156
+ This method uses the analytical closed-form solution (Error Function)
157
+ to avoid the 0.5 sample shift artifact typically caused by convolving
158
+ odd/even discrete arrays.
159
+
160
+ Parameters
161
+ ----------
162
+ sps : int
163
+ Samples per symbol.
164
+ span : int
165
+ Filter span in symbols. The number of taps will be approximately ``span * sps``.
166
+ rise_time : float, default 0.22
167
+ 10%-90% edge transition duration in symbol periods. Smaller values produce
168
+ sharper edges (closer to a hard rect); larger values yield softer transitions
169
+ (approaching a Gaussian pulse). Converted internally to the Gaussian sigma via
170
+ ``σ = rise_time / (2·√2·erfinv(0.8))``.
171
+ duty_cycle : float, default 1.0
172
+ Width of the underlying rectangular pulse in symbol periods. Use 1.0 for NRZ
173
+ and 0.5 for RZ signaling.
174
+
175
+ Returns
176
+ -------
177
+ ndarray
178
+ Gaussian-smoothed rectangular pulse taps normalized to unit energy.
179
+ Shape: (N_taps,).
180
+ """
181
+ logger.debug(
182
+ "Generating SmoothRect taps: sps=%s, span=%s, rise_time=%s, duty_cycle=%s",
183
+ sps,
184
+ span,
185
+ rise_time,
186
+ duty_cycle,
187
+ )
188
+ # Ensure odd number of taps to have a center peak
189
+ num_taps = int(span * sps)
190
+ if num_taps % 2 == 0:
191
+ num_taps += 1
192
+
193
+ t = np.linspace(-span / 2, span / 2, num_taps)
194
+
195
+ # Convert rise_time to Gaussian sigma.
196
+ # rise_time (10%-90%) = 2·√2·erfinv(0.8)·σ -> σ = rise_time / (2·√2·erfinv(0.8))
197
+ sigma = rise_time / (2 * np.sqrt(2) * float(scipy.special.erfinv(0.8)))
198
+
199
+ # Analytical Formula (Convolved Rect and Gaussian)
200
+ # The underlying rect spans [-duty_cycle/2, +duty_cycle/2].
201
+ # Convolution of rect with Gaussian = difference of error functions.
202
+ w_half = duty_cycle / 2.0
203
+ h = 0.5 * (
204
+ scipy.special.erf((t + w_half) / (sigma * np.sqrt(2)))
205
+ - scipy.special.erf((t - w_half) / (sigma * np.sqrt(2)))
206
+ )
207
+
208
+ return normalize(h, "unit_energy")
209
+
210
+
211
+ def rrc_taps(sps: float, rolloff: float = 0.35, span: int = 8) -> np.ndarray:
212
+ """
213
+ Generates Root Raised Cosine (RRC) filter taps.
214
+
215
+ RRC filters are used at both the transmitter (pulse shaping) and
216
+ receiver (matched filtering) to satisfy the Nyquist ISI criterion.
217
+
218
+ Parameters
219
+ ----------
220
+ sps : float
221
+ Samples per symbol.
222
+ rolloff : float, default 0.35
223
+ Roll-off factor (alpha), range [0, 1].
224
+ span : int, default 8
225
+ Filter span in symbols.
226
+
227
+ Returns
228
+ -------
229
+ ndarray
230
+ RRC filter taps normalized to unit energy.
231
+ Shape: (N_taps,).
232
+ """
233
+ logger.debug("Generating RRC taps: sps=%s, rolloff=%s, span=%s", sps, rolloff, span)
234
+ # Ensure odd number of taps
235
+ num_taps = int(span * sps)
236
+ if num_taps % 2 == 0:
237
+ num_taps += 1
238
+
239
+ t = np.linspace(-span / 2, span / 2, num_taps)
240
+
241
+ # Avoid division by zero
242
+ # 1. t = 0
243
+ # 2. t = +/- 1/(4*rolloff)
244
+
245
+ # Initialize array
246
+ h = np.zeros_like(t)
247
+
248
+ # Case 1: t = 0
249
+ idx_0 = np.isclose(t, 0)
250
+ h = np.where(idx_0, 1.0 - rolloff + (4 * rolloff / np.pi), h)
251
+
252
+ # Case 2: t = +/- 1/(4*rolloff)
253
+ if rolloff > 0:
254
+ idx_singularity = np.isclose(np.abs(t), 1 / (4 * rolloff))
255
+ h = np.where(
256
+ idx_singularity,
257
+ (rolloff / np.sqrt(2))
258
+ * (
259
+ (1 + 2 / np.pi) * np.sin(np.pi / (4 * rolloff))
260
+ + (1 - 2 / np.pi) * np.cos(np.pi / (4 * rolloff))
261
+ ),
262
+ h,
263
+ )
264
+ else:
265
+ idx_singularity = np.zeros_like(t, dtype=bool)
266
+
267
+ # Case 3: General case
268
+ idx_general = ~(idx_0 | idx_singularity)
269
+
270
+ numer = np.sin(np.pi * t * (1 - rolloff)) + 4 * rolloff * t * np.cos(
271
+ np.pi * t * (1 + rolloff)
272
+ )
273
+ denom = np.pi * t * (1 - (4 * rolloff * t) ** 2)
274
+
275
+ # Avoid invalid value warning by making den safe
276
+ denom_safe = np.where(idx_general, denom, 1.0)
277
+ h = np.where(idx_general, numer / denom_safe, h)
278
+
279
+ return normalize(h, "unit_energy")
280
+
281
+
282
+ def rc_taps(sps: float, rolloff: float = 0.35, span: int = 8) -> ArrayType:
283
+ """
284
+ Generates Raised Cosine (RC) filter taps.
285
+
286
+ Parameters
287
+ ----------
288
+ sps : float
289
+ Samples per symbol.
290
+ rolloff : float, default 0.35
291
+ Roll-off factor (alpha), range [0, 1].
292
+ span : int, default 8
293
+ Filter span in symbols.
294
+
295
+ Returns
296
+ -------
297
+ ndarray
298
+ RC filter taps normalized to unit energy.
299
+ Shape: (N_taps,).
300
+ """
301
+ logger.debug("Generating RC taps: sps=%s, rolloff=%s, span=%s", sps, rolloff, span)
302
+ # Ensure odd number of taps
303
+ num_taps = int(span * sps)
304
+ if num_taps % 2 == 0:
305
+ num_taps += 1
306
+
307
+ t = np.linspace(-span / 2, span / 2, num_taps)
308
+
309
+ # Avoid division by zero
310
+ # Singularities at t = +/- 1 / (2 * rolloff)
311
+
312
+ # Initialize array
313
+ h = np.zeros_like(t)
314
+
315
+ # General case mask
316
+ # Denominator: 1 - (2 * rolloff * t)**2
317
+ # Singularity when 2 * rolloff * |t| = 1 => |t| = 1 / (2 * rolloff)
318
+
319
+ if rolloff > 0:
320
+ idx_singularity = np.isclose(np.abs(t), 1 / (2 * rolloff))
321
+ # Value at singularity: (pi / 4) * sinc(1 / (2 * rolloff))
322
+ # sinc(x) = sin(pi * x) / (pi * x)
323
+ # arg = 1 / (2 * rolloff)
324
+ # val = (pi / 4) * sin(pi * arg) / (pi * arg)
325
+ # = (pi / 4) * sin(pi / (2 * rolloff)) * (2 * rolloff / pi)
326
+ # = (rolloff / 2) * sin(pi / (2 * rolloff))
327
+ val_singularity = (rolloff / 2) * np.sin(np.pi / (2 * rolloff))
328
+ h = np.where(idx_singularity, val_singularity, h)
329
+ else:
330
+ idx_singularity = np.zeros_like(t, dtype=bool)
331
+
332
+ idx_general = ~idx_singularity
333
+
334
+ # h(t) = sinc(t) * cos(pi * alpha * t) / (1 - (2 * alpha * t)^2)
335
+ # sinc(t) = sin(pi * t) / (pi * t) (normalized sinc)
336
+
337
+ # To avoid t=0 in sinc division, use np.sinc which handles 0 safely
338
+ sinc_t = np.sinc(t)
339
+ cos_t = np.cos(np.pi * rolloff * t)
340
+ denom = 1 - (2 * rolloff * t) ** 2
341
+
342
+ # We masked out where denom is 0, so safe to divide where idx_general is true
343
+ # However we compute everywhere then mask, so denom should not be 0 to avoid warning/NaN if backend evals strict
344
+ # backend.where usually evals both branches
345
+ # So we set denom to 1 where it is 0
346
+ denom_safe = np.where(idx_singularity, 1.0, denom)
347
+
348
+ res = sinc_t * cos_t / denom_safe
349
+ h = np.where(idx_general, res, h)
350
+
351
+ return normalize(h, "unit_energy")
352
+
353
+
354
+ def lowpass_taps(
355
+ sampling_rate: float,
356
+ num_taps: int,
357
+ cutoff: float,
358
+ window: str = "hamming",
359
+ ) -> ArrayType:
360
+ """
361
+ Design Lowpass FIR filter using the window method.
362
+
363
+ Parameters
364
+ ----------
365
+ sampling_rate : float
366
+ The sampling rate of the signal in Hz.
367
+ num_taps : int
368
+ Number of filter coefficients.
369
+ cutoff : float
370
+ Cutoff frequency in Hz.
371
+ window : str, default "hamming"
372
+ Type of window function to apply (e.g., 'hamming', 'blackman').
373
+
374
+ Returns
375
+ -------
376
+ ndarray
377
+ Filter taps with 0 dB passband gain.
378
+ Shape: (num_taps,).
379
+ """
380
+ logger.debug("Designing Lowpass FIR: cutoff=%s Hz, taps=%s", cutoff, num_taps)
381
+ h = scipy.signal.firwin(
382
+ num_taps, cutoff, window=window, fs=sampling_rate, pass_zero=True
383
+ )
384
+ return h
385
+
386
+
387
+ def highpass_taps(
388
+ sampling_rate: float,
389
+ num_taps: int,
390
+ cutoff: float,
391
+ window: str = "hamming",
392
+ ) -> ArrayType:
393
+ """
394
+ Design Highpass FIR filter using the window method.
395
+
396
+ Parameters
397
+ ----------
398
+ sampling_rate : float
399
+ The sampling rate of the signal in Hz.
400
+ num_taps : int
401
+ Number of filter coefficients. For highpass filters, this should
402
+ typically be an odd integer to avoid a zero at the Nyquist frequency.
403
+ cutoff : float
404
+ Cutoff frequency in Hz.
405
+ window : str, default "hamming"
406
+ Type of window function to apply.
407
+
408
+ Returns
409
+ -------
410
+ ndarray
411
+ Filter taps with 0 dB passband gain.
412
+ Shape: (num_taps,).
413
+ """
414
+ logger.debug("Designing Highpass FIR: cutoff=%s Hz, taps=%s", cutoff, num_taps)
415
+ # pass_zero=False for highpass
416
+ h = scipy.signal.firwin(
417
+ num_taps, cutoff, window=window, fs=sampling_rate, pass_zero=False
418
+ )
419
+ return h
420
+
421
+
422
+ def bandpass_taps(
423
+ sampling_rate: float,
424
+ num_taps: int,
425
+ low_cutoff: float,
426
+ high_cutoff: float,
427
+ window: str = "hamming",
428
+ ) -> ArrayType:
429
+ """
430
+ Design Bandpass FIR filter using the window method.
431
+
432
+ Parameters
433
+ ----------
434
+ sampling_rate : float
435
+ The sampling rate of the signal in Hz.
436
+ num_taps : int
437
+ Number of filter coefficients.
438
+ low_cutoff : float
439
+ Lower cutoff frequency in Hz.
440
+ high_cutoff : float
441
+ Upper cutoff frequency in Hz.
442
+ window : str, default "hamming"
443
+ Type of window function to apply.
444
+
445
+ Returns
446
+ -------
447
+ ndarray
448
+ Filter taps with 0 dB passband gain.
449
+ Shape: (num_taps,).
450
+ """
451
+ logger.debug(
452
+ "Designing Bandpass FIR: range=[%s, %s] Hz, taps=%s",
453
+ low_cutoff,
454
+ high_cutoff,
455
+ num_taps,
456
+ )
457
+ # pass_zero=False for bandpass
458
+ h = scipy.signal.firwin(
459
+ num_taps,
460
+ [low_cutoff, high_cutoff],
461
+ window=window,
462
+ fs=sampling_rate,
463
+ pass_zero=False,
464
+ )
465
+ return h
466
+
467
+
468
+ def bandstop_taps(
469
+ sampling_rate: float,
470
+ num_taps: int,
471
+ low_cutoff: float,
472
+ high_cutoff: float,
473
+ window: str = "hamming",
474
+ ) -> ArrayType:
475
+ """
476
+ Design Bandstop FIR filter using the window method.
477
+
478
+ Parameters
479
+ ----------
480
+ sampling_rate : float
481
+ The sampling rate of the signal in Hz.
482
+ num_taps : int
483
+ Number of filter coefficients. Should typically be odd.
484
+ low_cutoff : float
485
+ Lower cutoff frequency in Hz.
486
+ high_cutoff : float
487
+ Upper cutoff frequency in Hz.
488
+ window : str, default "hamming"
489
+ Type of window function to apply.
490
+
491
+ Returns
492
+ -------
493
+ ndarray
494
+ Filter taps with 0 dB passband gain.
495
+ Shape: (num_taps,).
496
+ """
497
+ logger.debug(
498
+ "Designing Bandstop FIR: range=[%s, %s] Hz, taps=%s",
499
+ low_cutoff,
500
+ high_cutoff,
501
+ num_taps,
502
+ )
503
+ # pass_zero=True for bandstop
504
+ h = scipy.signal.firwin(
505
+ num_taps,
506
+ [low_cutoff, high_cutoff],
507
+ window=window,
508
+ fs=sampling_rate,
509
+ pass_zero=True,
510
+ )
511
+ return h
512
+
513
+
514
+ # -----------------------------------------------------------------------------
515
+ # FILTERING OPERATIONS
516
+ # -----------------------------------------------------------------------------
517
+ # _ols_forward: OLS block windowing + batch FFT (shared scaffold)
518
+ # _ols_backward: OLS batch IFFT + symmetric discard + reshape (shared scaffold)
519
+ # ols_fir_filter: Public OLS FIR convolution (long-tap / memory-bounded)
520
+ # fir_filter: Generic FIR filtering operation (short-to-medium taps)
521
+ # matched_filter: Apply matched filter (time-reversed conjugate of pulse shape)
522
+ # shape_pulse: Apply pulse shaping to symbols
523
+
524
+
525
+ def _ols_forward(samples: ArrayType, N_fft: int):
526
+ """
527
+ Overlap-and-save forward pass: block windowing and batch FFT.
528
+
529
+ This is the shared OLS scaffolding used by both ``ols_fir_filter`` (SISO
530
+ scalar convolution) and ``zf_equalizer`` (MIMO per-bin matrix multiply).
531
+ It should be called on samples that have already been dispatched to the
532
+ correct backend and shaped as ``(num_ch, N)``.
533
+
534
+ Parameters
535
+ ----------
536
+ samples : array_like
537
+ Input samples. Shape: ``(num_ch, N)``. Must be 2-D.
538
+ N_fft : int
539
+ FFT block size. Must be a power of 2 and satisfy
540
+ ``N_fft // 4 >= filter_length`` so the causal/anti-causal guard
541
+ regions fully contain the filter transients.
542
+
543
+ Returns
544
+ -------
545
+ Y : array_like
546
+ Batch FFT of all OLS windows. Shape: ``(num_ch, num_blocks, N_fft)``.
547
+ meta : dict
548
+ Scaffold parameters required by ``_ols_backward``:
549
+ ``{'N': int, 'B': int, 'discard': int, 'num_blocks': int}``.
550
+ """
551
+ _, xp, _ = dispatch(samples)
552
+ num_ch, N = samples.shape
553
+ B = N_fft // 2 # 50 % hop - maximises block reuse
554
+ discard = N_fft // 4 # symmetric guard: absorbs causal & anti-causal transients
555
+ num_blocks = (N + B - 1) // B
556
+
557
+ # Pre-pad by discard so the first valid output aligns with sample 0.
558
+ # Post-pad to fill the last block window completely.
559
+ pad_left = discard
560
+ pad_right = num_blocks * B - N + discard
561
+ samples_padded = xp.pad(samples, ((0, 0), (pad_left, pad_right)))
562
+
563
+ # Zero-copy window extraction via as_strided (view, not copy).
564
+ stride = samples_padded.strides
565
+ windows = xp.lib.stride_tricks.as_strided(
566
+ samples_padded,
567
+ shape=(num_ch, num_blocks, N_fft),
568
+ strides=(stride[0], B * stride[1], stride[1]),
569
+ )
570
+
571
+ Y = xp.fft.fft(windows, n=N_fft, axis=-1) # (num_ch, num_blocks, N_fft)
572
+ meta = {"N": N, "B": B, "discard": discard, "num_blocks": num_blocks}
573
+ return Y, meta
574
+
575
+
576
+ def _ols_backward(X_hat_f: ArrayType, meta: dict) -> ArrayType:
577
+ """
578
+ Overlap-and-save backward pass: batch IFFT, symmetric discard, reshape.
579
+
580
+ Parameters
581
+ ----------
582
+ X_hat_f : array_like
583
+ Frequency-domain blocks after per-bin processing.
584
+ Shape: ``(num_ch, num_blocks, N_fft)``.
585
+ meta : dict
586
+ Scaffold parameters returned by ``_ols_forward``.
587
+
588
+ Returns
589
+ -------
590
+ array_like
591
+ Time-domain output trimmed to the original signal length ``N``.
592
+ Shape: ``(num_ch, N)``.
593
+ """
594
+ _, xp, _ = dispatch(X_hat_f)
595
+ N = meta["N"]
596
+ B = meta["B"]
597
+ discard = meta["discard"]
598
+ N_fft = X_hat_f.shape[-1]
599
+ num_ch = X_hat_f.shape[0]
600
+
601
+ x_hat = xp.fft.ifft(X_hat_f, n=N_fft, axis=-1)
602
+ # Keep the center B samples of each block (symmetric discard of guard regions).
603
+ valid = x_hat[:, :, discard : discard + B]
604
+ out = valid.reshape(num_ch, -1)[:, :N]
605
+ return out
606
+
607
+
608
+ def ols_fir_filter(
609
+ samples: ArrayType,
610
+ taps: ArrayType,
611
+ N_fft: int | None = None,
612
+ center: bool = True,
613
+ ) -> ArrayType:
614
+ """
615
+ Overlap-and-save FIR filter for long-tap or large-signal convolution.
616
+
617
+ Implements the overlap-and-save (OLS) block-processing algorithm, which
618
+ processes the signal in fixed-size FFT blocks. This makes it suitable
619
+ for filters with long impulse responses (e.g., chromatic dispersion
620
+ compensation, group-delay equalizers) where a single full-signal FFT
621
+ would be memory-prohibitive on GPU.
622
+
623
+ For short filters on moderate-length signals, ``fir_filter`` (which
624
+ uses scipy's FFT convolution) is equally efficient and simpler.
625
+
626
+ Parameters
627
+ ----------
628
+ samples : array_like
629
+ Input signal. Shape: ``(N,)`` for SISO or ``(C, N)`` for
630
+ multi-channel.
631
+ taps : array_like
632
+ FIR filter coefficients. Shape: ``(L,)``.
633
+ N_fft : int, optional
634
+ FFT block size. Must be a power of 2. Defaults to
635
+ ``max(1024, next_power_of_2(4 * L))`` so that the 25 % guard
636
+ region is at least ``L`` samples long.
637
+ center : bool, default True
638
+ When ``True`` (default), the output alignment matches
639
+ ``fir_filter`` (scipy ``mode='same'``, center-aligned at tap
640
+ ``L // 2``). The output at position ``n`` equals
641
+ ``sum_k x[n + L//2 - k] * taps[k]``, which is correct for
642
+ pulse-shaped signals where the filter group delay must be
643
+ compensated before symbol sampling.
644
+
645
+ When ``False``, the output is the causal linear convolution
646
+ ``y[n] = sum_k x[n-k] * taps[k]`` (equivalent to
647
+ ``numpy.convolve(x, taps, mode='full')[:N]``). Use this when
648
+ you need the raw causal impulse response (e.g. measuring filter
649
+ step response) or when writing CD/dispersion compensation where
650
+ the two-sided inverse filter alignment is handled externally.
651
+
652
+ Returns
653
+ -------
654
+ array_like
655
+ Filtered signal, same shape as ``samples``.
656
+
657
+ Notes
658
+ -----
659
+ A symmetric guard of ``N_fft // 4`` samples is discarded from each
660
+ block edge, so ``N_fft // 4 >= len(taps)`` must hold.
661
+
662
+ The ``center=True`` path post-pads the input by ``L // 2`` zeros
663
+ before OLS processing and trims the same number of leading output
664
+ samples - a zero-copy shift that costs one extra OLS block at most.
665
+ """
666
+ samples, xp, _ = dispatch(samples)
667
+ taps = xp.asarray(taps)
668
+ is_real = not xp.iscomplexobj(samples) and not xp.iscomplexobj(taps)
669
+ out_dtype = samples.dtype # capture before any reshape
670
+
671
+ # Signal drives precision: cast taps to match signal so float64 tap
672
+ # generators do not silently upcast complex64 signals via FFT multiply.
673
+ target_tap_dtype = (
674
+ samples.real.dtype if not xp.iscomplexobj(taps) else samples.dtype
675
+ )
676
+ if taps.dtype != target_tap_dtype:
677
+ taps = taps.astype(target_tap_dtype)
678
+
679
+ L = len(taps)
680
+ half = L // 2
681
+
682
+ was_1d = samples.ndim == 1
683
+ if was_1d:
684
+ samples = samples[None, :]
685
+
686
+ N = samples.shape[-1]
687
+
688
+ if N_fft is None:
689
+ N_fft = max(1024, 1 << (max(1, 4 * L) - 1).bit_length())
690
+
691
+ logger.debug(
692
+ "ols_fir_filter: L=%s, N=%s, N_fft=%s, num_ch=%s, center=%s",
693
+ L,
694
+ N,
695
+ N_fft,
696
+ samples.shape[0],
697
+ center,
698
+ )
699
+
700
+ H = xp.fft.fft(taps, n=N_fft) # frequency response of the filter
701
+
702
+ if center:
703
+ # Post-pad by half so the OLS can compute full_conv[half : half+N].
704
+ # This matches scipy's mode='same' (center-aligned, group-delay compensated),
705
+ # which is required for correct eye-opening after pulse-shaped filtering.
706
+ samples_ext = xp.pad(samples, ((0, 0), (0, half)))
707
+ Y, meta = _ols_forward(samples_ext, N_fft)
708
+ X_hat_f = Y * H
709
+ out_ext = _ols_backward(X_hat_f, meta) # shape: (num_ch, N + half)
710
+ out = out_ext[:, half:] # trim leading half -> shape: (num_ch, N)
711
+ else:
712
+ Y, meta = _ols_forward(samples, N_fft)
713
+ X_hat_f = Y * H
714
+ out = _ols_backward(X_hat_f, meta)
715
+
716
+ if is_real:
717
+ out = out.real # strip IFFT imaginary noise for real inputs
718
+ elif out.dtype != out_dtype:
719
+ out = out.astype(
720
+ out_dtype
721
+ ) # guard complex inputs (e.g. complex64 -> complex128)
722
+ return out[0] if was_1d else out
723
+
724
+
725
+ def shaping_filter_taps(sig: Signal) -> ArrayType:
726
+ """
727
+ Compute pulse-shaping filter taps from a :class:`Signal`'s metadata.
728
+
729
+ Reconstructs the transmit pulse-shaping taps from ``pulse_shape`` and the
730
+ associated parameters (``sps``, ``filter_span``, roll-offs, ``duty_cycle``,
731
+ ``rise_time``) stored on the signal. The taps are returned on the signal's
732
+ current backend.
733
+
734
+ Parameters
735
+ ----------
736
+ sig : Signal
737
+ Signal carrying valid ``pulse_shape`` metadata.
738
+
739
+ Returns
740
+ -------
741
+ array_like
742
+ Generated filter taps on the signal's device.
743
+
744
+ Raises
745
+ ------
746
+ ValueError
747
+ If ``pulse_shape`` is missing or unsupported.
748
+ """
749
+ if not sig.pulse_shape or sig.pulse_shape == "none":
750
+ raise ValueError("No pulse shape defined for this signal.")
751
+ logger.info("Generating shaping filter taps (shape: %s).", sig.pulse_shape)
752
+
753
+ # Use stored duty_cycle for RZ; NRZ always uses the full symbol period.
754
+ duty_cycle = sig.duty_cycle if sig.mod_rz else 1.0
755
+
756
+ if sig.pulse_shape == "rect":
757
+ taps = rect_taps(int(sig.sps), duty_cycle=duty_cycle, rise_time=sig.rise_time)
758
+ elif sig.pulse_shape == "smoothrect":
759
+ taps = smoothrect_taps(
760
+ sps=int(sig.sps),
761
+ span=sig.filter_span,
762
+ rise_time=sig.rise_time,
763
+ duty_cycle=duty_cycle,
764
+ )
765
+ elif sig.pulse_shape == "gaussian":
766
+ taps = gaussian_taps(
767
+ sps=sig.sps, span=sig.filter_span, duty_cycle=sig.duty_cycle
768
+ )
769
+ elif sig.pulse_shape == "rrc":
770
+ taps = rrc_taps(sps=sig.sps, span=sig.filter_span, rolloff=sig.rrc_rolloff)
771
+ elif sig.pulse_shape == "rc":
772
+ taps = rc_taps(sps=sig.sps, span=sig.filter_span, rolloff=sig.rc_rolloff)
773
+ else:
774
+ raise ValueError(f"Unknown pulse shape: {sig.pulse_shape}")
775
+
776
+ return to_device(taps, sig.backend)
777
+
778
+
779
+ def fir_filter(
780
+ samples: ArrayType | Signal, taps: ArrayType, axis: int = -1
781
+ ) -> ArrayType | Signal:
782
+ """
783
+ Apply a Finite Impulse Response (FIR) filter to signal samples.
784
+
785
+ The filter is applied via FFT-based convolution for high throughput,
786
+ efficiently handling both CPU and GPU backends.
787
+
788
+ Parameters
789
+ ----------
790
+ samples : array_like or Signal
791
+ Input signal samples. Shape: (..., N_samples). A :class:`Signal`
792
+ returns a new filtered :class:`Signal`.
793
+ taps : array_like
794
+ FIR filter coefficients (impulse response). Shape: (N_taps,).
795
+ axis : int, default -1
796
+ The axis along which the filter is applied (typically the Time axis).
797
+
798
+ Returns
799
+ -------
800
+ array_like or Signal
801
+ Filtered samples with the same shape as `samples` (mode='same').
802
+ """
803
+ if isinstance(samples, Signal):
804
+ sig = samples
805
+ new = sig.copy()
806
+ new.samples = fir_filter(sig.samples, taps, axis=-1)
807
+ return new
808
+
809
+ logger.debug(
810
+ "Applying FIR filter via convolution (%s taps, axis=%s).", len(taps), axis
811
+ )
812
+ samples, xp, sp = dispatch(samples)
813
+
814
+ # Ensure taps are on the correct backend
815
+ taps = xp.asarray(taps)
816
+
817
+ # Signal drives precision: cast taps to match signal dtype so numpy/scipy
818
+ # type-promotion rules do not silently upcast float32/complex64 signals.
819
+ target_tap_dtype = (
820
+ samples.real.dtype if not xp.iscomplexobj(taps) else samples.dtype
821
+ )
822
+ if taps.dtype != target_tap_dtype:
823
+ taps = taps.astype(target_tap_dtype)
824
+
825
+ if samples.ndim > 1:
826
+ # Ensure axis is positive
827
+ axis = axis % samples.ndim
828
+
829
+ new_shape = [1] * samples.ndim
830
+ new_shape[axis] = len(taps)
831
+ taps_nd = taps.reshape(new_shape)
832
+
833
+ result = sp.signal.convolve(samples, taps_nd, mode="same", method="fft")
834
+ else:
835
+ # 1D case
836
+ result = sp.signal.convolve(samples, taps, mode="same", method="fft")
837
+
838
+ # Belt-and-suspenders: scipy may still promote internally (version-dependent)
839
+ if result.dtype != samples.dtype:
840
+ result = result.astype(samples.dtype)
841
+ return result
842
+
843
+
844
+ def shape_pulse(
845
+ symbols: ArrayType,
846
+ sps: float,
847
+ pulse_shape: str = "none",
848
+ *,
849
+ duty_cycle: float = 1.0,
850
+ rise_time: float = 0.0,
851
+ filter_span: int = 10,
852
+ rrc_rolloff: float = 0.35,
853
+ rc_rolloff: float = 0.35,
854
+ rz: bool = False,
855
+ ) -> ArrayType:
856
+ """
857
+ Applies pulse shaping to a symbol sequence.
858
+
859
+ Parameters
860
+ ----------
861
+ symbols : array_like
862
+ Input symbol sequence. Shape: (..., N_symbols).
863
+ sps : float
864
+ Samples per symbol (upsampling factor).
865
+ pulse_shape : {"none", "rect", "smoothrect", "gaussian", "rrc", "rc", "sinc"}, default "none"
866
+ Identifier for the pulse shaping filter type.
867
+ duty_cycle : float, default 1.0
868
+ Pulse width in symbol periods, in the range ``(0, 1]``.
869
+
870
+ - ``"rect"``, ``"smoothrect"``: total on-time of the pulse (including
871
+ ramps for rect, underlying rect width for smoothrect).
872
+ - ``"gaussian"``: Full-Width at Half-Maximum (FWHM) of the Gaussian.
873
+ - NRZ signals always use 1.0; use 0.5 for canonical RZ.
874
+ rise_time : float, default 0.0
875
+ Edge transition duration in symbol periods. Applies to ``"rect"`` and
876
+ ``"smoothrect"`` only; ignored for all other pulse types.
877
+
878
+ - ``"rect"``: duration of each linear ramp. The flat top width is
879
+ ``duty_cycle - 2 * rise_time``. Must satisfy
880
+ ``rise_time <= duty_cycle / 2``.
881
+ - ``"smoothrect"``: 10%-90% erf-edge duration. Smaller values give
882
+ sharper edges; larger values give softer Gaussian-like transitions.
883
+ - ``0.0`` (default): hard rectangular edges for ``"rect"``.
884
+ filter_span : int, default 10
885
+ Filter span in symbols for FIR tap generators
886
+ (``"smoothrect"``, ``"gaussian"``, ``"rrc"``, ``"rc"``, ``"sinc"``).
887
+ rrc_rolloff : float, default 0.35
888
+ Roll-off factor for the Root-Raised-Cosine filter (``"rrc"``). Range [0, 1].
889
+ rc_rolloff : float, default 0.35
890
+ Roll-off factor for the Raised-Cosine filter (``"rc"``). Range [0, 1].
891
+ rz : bool, default False
892
+ Convenience flag for Return-to-Zero signaling. When ``True``, overrides
893
+ ``duty_cycle`` to 0.5 (if not already set below 1.0) and converts
894
+ ``pulse_shape="none"`` to ``"rect"`` automatically.
895
+
896
+ Returns
897
+ -------
898
+ array_like
899
+ The pulse-shaped waveform at rate ``sps * symbol_rate``, normalized to
900
+ **unit symbol power** (Es = 1). Average sample power = 1/sps.
901
+
902
+ Notes
903
+ -----
904
+ All pulse types produce output satisfying E[|x|²] * sps = 1 (symbol-power
905
+ convention). For peak-normalized samples (e.g. eye diagrams), apply
906
+ ``normalize(..., "peak")`` after.
907
+ """
908
+ logger.debug("Applying pulse shaping: %s", pulse_shape)
909
+
910
+ if rz:
911
+ duty_cycle = 0.5
912
+
913
+ symbols, xp, sp = dispatch(symbols)
914
+
915
+ if pulse_shape == "none":
916
+ if rz:
917
+ logger.debug("RZ signaling requested, using rect pulse shape")
918
+ pulse_shape = "rect"
919
+ else:
920
+ logger.debug("Pulse shaping disabled, expanding symbols by sps")
921
+ return normalize(
922
+ expand(symbols, int(sps), axis=-1),
923
+ "symbol_power",
924
+ sps=int(sps),
925
+ axis=-1,
926
+ )
927
+
928
+ if pulse_shape == "rect":
929
+ h = rect_taps(int(sps), duty_cycle=duty_cycle, rise_time=rise_time)
930
+ elif pulse_shape == "smoothrect":
931
+ h = smoothrect_taps(
932
+ int(sps), span=filter_span, rise_time=rise_time, duty_cycle=duty_cycle
933
+ )
934
+ elif pulse_shape == "gaussian":
935
+ h = gaussian_taps(sps, span=filter_span, duty_cycle=duty_cycle)
936
+ elif pulse_shape == "rrc":
937
+ h = rrc_taps(sps, span=filter_span, rolloff=rrc_rolloff)
938
+ elif pulse_shape == "rc":
939
+ h = rc_taps(sps, span=filter_span, rolloff=rc_rolloff)
940
+ elif pulse_shape == "sinc":
941
+ # Sinc pulse shaping is equivalent to RRC with rolloff=0
942
+ h = rrc_taps(sps, span=filter_span, rolloff=0.0)
943
+ else:
944
+ raise ValueError(f"Not implemented pulse shape: {pulse_shape}")
945
+
946
+ # Ensure h is on the correct backend and matches symbol precision.
947
+ # Tap generators return float64; casting here prevents scipy's resample_poly
948
+ # from promoting complex64 symbols to complex128.
949
+ h = xp.asarray(h).astype(symbols.real.dtype)
950
+
951
+ # Apply Pulse Shaping via Polyphase Resampling
952
+ res = sp.signal.resample_poly(symbols, int(sps), 1, window=h, axis=-1)
953
+ if res.dtype != symbols.dtype:
954
+ res = res.astype(symbols.dtype)
955
+
956
+ return normalize(res, "symbol_power", sps=int(sps), axis=-1)
957
+
958
+
959
+ def matched_filter(
960
+ samples: ArrayType | Signal,
961
+ pulse_taps: ArrayType | None = None,
962
+ taps_normalization: str = "unit_energy",
963
+ axis: int = -1,
964
+ ) -> ArrayType | Signal:
965
+ """
966
+ Applies a matched filter to the received signal.
967
+
968
+ The matched filter is the time-reversed complex conjugate of the pulse
969
+ shaping filter. It maximizes the Signal-to-Noise Ratio (SNR) in the
970
+ presence of AWGN.
971
+
972
+ Parameters
973
+ ----------
974
+ samples : array_like or Signal
975
+ Input received samples. Shape: (..., N_samples). A :class:`Signal`
976
+ returns a new matched-filtered :class:`Signal`; when ``pulse_taps`` is
977
+ omitted, the taps are derived from the signal's ``pulse_shape`` metadata
978
+ via :func:`shaping_filter_taps`.
979
+ pulse_taps : array_like, optional
980
+ Taps of the pulse-shaping filter used at the transmitter.
981
+ Shape: (N_taps,). Required for raw-array input.
982
+ taps_normalization : {"unit_energy", "unity_gain"}, default "unit_energy"
983
+ Designates how the matched filter taps are normalized.
984
+ axis : int, default -1
985
+ The axis along which to apply the filter.
986
+
987
+ Returns
988
+ -------
989
+ array_like or Signal
990
+ Matched filtered samples. Shape: (..., N_samples).
991
+ """
992
+ if isinstance(samples, Signal):
993
+ sig = samples
994
+ taps = pulse_taps
995
+ if taps is None:
996
+ try:
997
+ taps = shaping_filter_taps(sig)
998
+ except ValueError as e:
999
+ logger.error("Cannot apply matched filter: %s", e)
1000
+ return sig.copy()
1001
+ new = sig.copy()
1002
+ new.samples = matched_filter(
1003
+ sig.samples, taps, taps_normalization=taps_normalization, axis=-1
1004
+ )
1005
+ return new
1006
+
1007
+ if pulse_taps is None:
1008
+ raise ValueError("matched_filter() requires pulse_taps for array input.")
1009
+
1010
+ logger.debug("Applying Matched Filter (taps length=%s).", len(pulse_taps))
1011
+ samples, xp, _ = dispatch(samples)
1012
+
1013
+ # Matched filter is conjugate and time-reversed version of pulse
1014
+ # Ensure pulse_taps is on correct backend
1015
+ pulse_taps = xp.asarray(pulse_taps)
1016
+ matched_taps = xp.conj(pulse_taps[::-1])
1017
+
1018
+ if taps_normalization == "unity_gain":
1019
+ matched_taps = normalize(matched_taps, mode="unity_gain")
1020
+ elif taps_normalization == "unit_energy":
1021
+ matched_taps = normalize(matched_taps, mode="unit_energy")
1022
+ else:
1023
+ raise ValueError(
1024
+ f"Unknown taps_normalization: {taps_normalization!r}. "
1025
+ "Use 'unity_gain' or 'unit_energy'."
1026
+ )
1027
+
1028
+ return fir_filter(samples, matched_taps, axis=axis)
1029
+
1030
+
1031
+ # -----------------------------------------------------------------------------
1032
+ # CHROMATIC DISPERSION
1033
+ # -----------------------------------------------------------------------------
1034
+
1035
+
1036
+ def compensate_chromatic_dispersion(
1037
+ samples: ArrayType,
1038
+ sampling_rate: float,
1039
+ dispersion_ps_nm_km: float,
1040
+ fiber_length_km: float,
1041
+ center_wavelength_nm: float,
1042
+ ) -> ArrayType:
1043
+ """
1044
+ Electronic dispersion compensation (EDC) for chromatic dispersion.
1045
+
1046
+ Applies the inverse of the CD frequency-domain transfer function to remove
1047
+ chromatic dispersion accumulated over a fiber link:
1048
+
1049
+ H_EDC(f) = exp(j/2 * beta_2 * (2*pi*f)^2 * L)
1050
+
1051
+ where
1052
+
1053
+ beta_2 = -D * lambda^2 / (2*pi*c)
1054
+
1055
+ and D is the dispersion parameter, lambda is the center wavelength,
1056
+ c is the speed of light, and L is the fiber length.
1057
+
1058
+ Parameters
1059
+ ----------
1060
+ samples : array_like
1061
+ Complex baseband signal. Shape: ``(N,)`` (SISO) or ``(C, N)`` (MIMO).
1062
+ sampling_rate : float
1063
+ Sampling rate in Hz.
1064
+ dispersion_ps_nm_km : float
1065
+ Fiber dispersion parameter D in ps / (nm * km).
1066
+ Standard SMF-28: 17 ps/(nm*km) at 1550 nm.
1067
+ fiber_length_km : float
1068
+ Fiber span length in km.
1069
+ center_wavelength_nm : float
1070
+ Center wavelength in nm (e.g. 1550 for C-band).
1071
+
1072
+ Returns
1073
+ -------
1074
+ array_like
1075
+ CD-compensated signal, same shape, dtype, and backend as input.
1076
+
1077
+ See Also
1078
+ --------
1079
+ commkit.impairments.apply_chromatic_dispersion :
1080
+ Apply the forward CD impairment (use before this function in simulation).
1081
+
1082
+ Examples
1083
+ --------
1084
+ >>> cd_free = compensate_chromatic_dispersion(
1085
+ ... received, dispersion_ps_nm_km=17.0, fiber_length_km=80.0,
1086
+ ... center_wavelength_nm=1550.0, sampling_rate=fs)
1087
+ """
1088
+ logger.info(
1089
+ "Compensating CD (D=%s ps/nm/km, L=%s km, λ=%s nm).",
1090
+ dispersion_ps_nm_km,
1091
+ fiber_length_km,
1092
+ center_wavelength_nm,
1093
+ )
1094
+
1095
+ samples, xp, _ = dispatch(samples)
1096
+ was_1d = samples.ndim == 1
1097
+ if was_1d:
1098
+ samples = samples[None, :]
1099
+ C, N = samples.shape
1100
+
1101
+ # Convert to SI
1102
+ D = dispersion_ps_nm_km * 1e-12 / (1e-9 * 1e3) # s / m²
1103
+ lam = center_wavelength_nm * 1e-9 # m
1104
+ c = 2.998e8 # m/s
1105
+ L = fiber_length_km * 1e3 # m
1106
+ beta2 = -(D * lam**2) / (2.0 * np.pi * c) * L # s² (β₂·L product)
1107
+
1108
+ omega = 2.0 * np.pi * xp.fft.fftfreq(N, d=1.0 / sampling_rate)
1109
+ H = xp.exp(1j * (beta2 / 2.0) * omega**2)
1110
+
1111
+ S_F = xp.fft.fft(samples, axis=-1)
1112
+ out_F = S_F * H[None, :]
1113
+ result = xp.fft.ifft(out_F, axis=-1)
1114
+
1115
+ if result.dtype != samples.dtype:
1116
+ result = result.astype(samples.dtype)
1117
+
1118
+ if was_1d:
1119
+ return result[0]
1120
+ return result