commkit 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- commkit/__init__.py +74 -0
- commkit/_cuda/__init__.py +321 -0
- commkit/_cuda/compiler.py +88 -0
- commkit/_cuda/src/bps_min_d2.cu +104 -0
- commkit/_cuda/src/cs_block.cu +119 -0
- commkit/_cuda/src/selftest.cu +14 -0
- commkit/analysis/__init__.py +55 -0
- commkit/analysis/_common.py +236 -0
- commkit/analysis/allan.py +108 -0
- commkit/analysis/drift.py +213 -0
- commkit/analysis/interferometry.py +887 -0
- commkit/analysis/linewidth.py +480 -0
- commkit/analysis/trajectory.py +91 -0
- commkit/backend.py +507 -0
- commkit/coding/__init__.py +23 -0
- commkit/coding/base.py +17 -0
- commkit/coding/bch.py +6 -0
- commkit/coding/convolutional.py +7 -0
- commkit/coding/crc.py +7 -0
- commkit/coding/galois.py +8 -0
- commkit/coding/hamming.py +6 -0
- commkit/coding/interleaving.py +7 -0
- commkit/coding/ldpc.py +8 -0
- commkit/coding/polar.py +8 -0
- commkit/coding/ratematch.py +6 -0
- commkit/coding/reed_solomon.py +6 -0
- commkit/coding/turbo.py +8 -0
- commkit/core/__init__.py +32 -0
- commkit/core/frame.py +992 -0
- commkit/core/generation.py +581 -0
- commkit/core/signal.py +725 -0
- commkit/equalization/__init__.py +49 -0
- commkit/equalization/_block.py +1855 -0
- commkit/equalization/_common.py +606 -0
- commkit/equalization/_kernels_jax.py +1720 -0
- commkit/equalization/_kernels_numba.py +1704 -0
- commkit/equalization/blind.py +223 -0
- commkit/equalization/linear.py +365 -0
- commkit/equalization/polarization.py +790 -0
- commkit/equalization/result.py +191 -0
- commkit/equalization/sequential.py +2805 -0
- commkit/filtering.py +1120 -0
- commkit/frequency.py +1191 -0
- commkit/helpers.py +489 -0
- commkit/impairments/__init__.py +43 -0
- commkit/impairments/channel/__init__.py +20 -0
- commkit/impairments/channel/linear.py +310 -0
- commkit/impairments/channel/nonlinear.py +11 -0
- commkit/impairments/frontend.py +229 -0
- commkit/impairments/noise.py +105 -0
- commkit/impairments/source.py +219 -0
- commkit/io.py +308 -0
- commkit/logger.py +103 -0
- commkit/mapping/__init__.py +46 -0
- commkit/mapping/bits.py +240 -0
- commkit/mapping/constellation.py +153 -0
- commkit/mapping/gray.py +429 -0
- commkit/mapping/llr.py +253 -0
- commkit/mapping/shaping.py +218 -0
- commkit/metrics.py +949 -0
- commkit/multirate.py +476 -0
- commkit/plotting/__init__.py +78 -0
- commkit/plotting/analysis.py +627 -0
- commkit/plotting/constellation.py +483 -0
- commkit/plotting/equalizer.py +390 -0
- commkit/plotting/eye.py +388 -0
- commkit/plotting/spectral.py +575 -0
- commkit/plotting/sync.py +953 -0
- commkit/plotting/theme.py +203 -0
- commkit/plotting/waveform.py +200 -0
- commkit/py.typed +0 -0
- commkit/recovery/__init__.py +51 -0
- commkit/recovery/bps.py +337 -0
- commkit/recovery/corrections.py +751 -0
- commkit/recovery/pilots.py +803 -0
- commkit/recovery/pll.py +482 -0
- commkit/recovery/tikhonov.py +424 -0
- commkit/recovery/viterbi_viterbi.py +227 -0
- commkit/spectral.py +560 -0
- commkit/timing.py +841 -0
- commkit-1.0.0.dist-info/METADATA +145 -0
- commkit-1.0.0.dist-info/RECORD +84 -0
- commkit-1.0.0.dist-info/WHEEL +4 -0
- commkit-1.0.0.dist-info/licenses/LICENSE +21 -0
commkit/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
|