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
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Filter and equalizer response plots."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import matplotlib.pyplot as plt
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from ..backend import dispatch, to_device
|
|
9
|
+
from ..logger import logger
|
|
10
|
+
from .theme import _grid_figsize
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def plot_filter_response(
|
|
14
|
+
taps: Any, sps: float = 1.0, ax: Any | None = None, show: bool = False
|
|
15
|
+
) -> tuple[Any, tuple[Any, Any, Any]] | None:
|
|
16
|
+
"""
|
|
17
|
+
Plots the impulse and frequency response of a filter.
|
|
18
|
+
|
|
19
|
+
Provides a 3-panel analysis showing the filter taps in the time domain,
|
|
20
|
+
the magnitude response in dB, and the unwrapped phase response.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
taps : array_like
|
|
25
|
+
Filter taps (impulse response).
|
|
26
|
+
sps : float, default 1.0
|
|
27
|
+
Samples per symbol for time-axis normalization.
|
|
28
|
+
ax : array_like, optional
|
|
29
|
+
A list or tuple of 3 axes to plot on.
|
|
30
|
+
show : bool, default False
|
|
31
|
+
If True, calls `plt.show()`.
|
|
32
|
+
|
|
33
|
+
Returns
|
|
34
|
+
-------
|
|
35
|
+
fig : matplotlib.figure.Figure
|
|
36
|
+
The figure object.
|
|
37
|
+
axes : tuple of matplotlib.axes.Axes
|
|
38
|
+
The (impulse, magnitude, phase) axes.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
import matplotlib.ticker as ticker
|
|
42
|
+
|
|
43
|
+
# Dispatch
|
|
44
|
+
taps, xp, sp = dispatch(taps)
|
|
45
|
+
|
|
46
|
+
if ax is None:
|
|
47
|
+
logger.debug("Generating filter response plot.")
|
|
48
|
+
fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=_grid_figsize(3, 1))
|
|
49
|
+
elif isinstance(ax, (list, tuple, np.ndarray)) and len(ax) == 3:
|
|
50
|
+
fig = ax[0].figure
|
|
51
|
+
ax1, ax2, ax3 = ax
|
|
52
|
+
else:
|
|
53
|
+
logger.warning("filter_response requires 3 axes. Creating new figure.")
|
|
54
|
+
fig, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize=_grid_figsize(1, 3))
|
|
55
|
+
|
|
56
|
+
# 1. Impulse Response
|
|
57
|
+
num_taps = len(taps)
|
|
58
|
+
|
|
59
|
+
t = (xp.arange(num_taps) - (num_taps - 1) / 2) / sps
|
|
60
|
+
|
|
61
|
+
# Move to cpu for plotting
|
|
62
|
+
t_cpu = to_device(t, "cpu")
|
|
63
|
+
taps_cpu = to_device(taps, "cpu")
|
|
64
|
+
|
|
65
|
+
if xp.iscomplexobj(taps):
|
|
66
|
+
ax1.plot(t_cpu, taps_cpu.real, label="Real", color="C0")
|
|
67
|
+
ax1.plot(t_cpu, taps_cpu.imag, label="Imag", color="C1")
|
|
68
|
+
ax1.legend()
|
|
69
|
+
else:
|
|
70
|
+
ax1.plot(t_cpu, taps_cpu, color="C0")
|
|
71
|
+
|
|
72
|
+
ax1.set_title("Impulse Response")
|
|
73
|
+
ax1.set_xlabel("Time [Symbol Periods]")
|
|
74
|
+
ax1.set_ylabel("Amplitude")
|
|
75
|
+
|
|
76
|
+
# Set ticks at integer intervals (1T, 2T, etc.)
|
|
77
|
+
ax1.xaxis.set_major_locator(ticker.MultipleLocator(1))
|
|
78
|
+
|
|
79
|
+
def t_formatter(x, pos):
|
|
80
|
+
if np.isclose(x, 0):
|
|
81
|
+
return "0"
|
|
82
|
+
return f"{int(x)}T" if float(x).is_integer() else f"{x}T"
|
|
83
|
+
|
|
84
|
+
ax1.xaxis.set_major_formatter(ticker.FuncFormatter(t_formatter))
|
|
85
|
+
|
|
86
|
+
# 2. Frequency Response
|
|
87
|
+
# Compute frequency response using backend sp
|
|
88
|
+
w, h = sp.signal.freqz(taps, worN=2048)
|
|
89
|
+
|
|
90
|
+
# Normalize to Nyquist (0 to 1)
|
|
91
|
+
# w is in radians/sample (0 to pi)
|
|
92
|
+
freqs = w / (2 * xp.pi)
|
|
93
|
+
|
|
94
|
+
# Avoid log(0)
|
|
95
|
+
mag = 20 * xp.log10(xp.abs(h) + 1e-12)
|
|
96
|
+
angles = xp.unwrap(xp.angle(h))
|
|
97
|
+
|
|
98
|
+
# Move to cpu
|
|
99
|
+
freqs = to_device(freqs, "cpu")
|
|
100
|
+
mag = to_device(mag, "cpu")
|
|
101
|
+
angles = to_device(angles, "cpu")
|
|
102
|
+
|
|
103
|
+
# Magnitude
|
|
104
|
+
ax2.plot(freqs, mag, color="C2")
|
|
105
|
+
ax2.set_ylabel("Magnitude [dB]")
|
|
106
|
+
ax2.set_title("Frequency Response (Magnitude)")
|
|
107
|
+
ax2.set_xlabel("Frequency [Cycles/Sample]")
|
|
108
|
+
ax2.set_xlim(0, 0.5)
|
|
109
|
+
|
|
110
|
+
# Phase
|
|
111
|
+
ax3.plot(freqs, angles, color="C3")
|
|
112
|
+
ax3.set_ylabel("Phase [rad]")
|
|
113
|
+
ax3.set_title("Frequency Response (Phase)")
|
|
114
|
+
ax3.set_xlabel("Frequency [Cycles/Sample]")
|
|
115
|
+
ax3.set_xlim(0, 0.5)
|
|
116
|
+
|
|
117
|
+
if show:
|
|
118
|
+
plt.show()
|
|
119
|
+
return None
|
|
120
|
+
return fig, (ax1, ax2, ax3)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def plot_equalizer_result(
|
|
124
|
+
result,
|
|
125
|
+
smoothing: int = 50,
|
|
126
|
+
ax=None,
|
|
127
|
+
show: bool = False,
|
|
128
|
+
) -> tuple[Any, Any] | None:
|
|
129
|
+
"""
|
|
130
|
+
Plots adaptive equalizer diagnostics: convergence curve, tap weights, and
|
|
131
|
+
(when CPR was enabled) the recovered phase trajectory.
|
|
132
|
+
|
|
133
|
+
Parameters
|
|
134
|
+
----------
|
|
135
|
+
result : EqualizerResult
|
|
136
|
+
Equalizer output containing ``error``, ``weights``, and optionally
|
|
137
|
+
``phase_trajectory`` fields.
|
|
138
|
+
smoothing : int, default 50
|
|
139
|
+
Moving-average window length for the MSE convergence curve.
|
|
140
|
+
ax : list of Axes, optional
|
|
141
|
+
Pre-existing axes. Pass 2 axes when ``phase_trajectory`` is None, or
|
|
142
|
+
3 axes when CPR was used (``[ax_convergence, ax_taps, ax_phase]``).
|
|
143
|
+
If None, a new figure is created with the appropriate number of panels.
|
|
144
|
+
show : bool, default False
|
|
145
|
+
If True, calls ``plt.show()`` and returns None.
|
|
146
|
+
|
|
147
|
+
Returns
|
|
148
|
+
-------
|
|
149
|
+
(fig, axes) or None
|
|
150
|
+
Figure and axes array when ``show=False``, None otherwise.
|
|
151
|
+
"""
|
|
152
|
+
error = to_device(result.error, "cpu")
|
|
153
|
+
weights = to_device(result.weights, "cpu")
|
|
154
|
+
phase_traj = getattr(result, "phase_trajectory", None)
|
|
155
|
+
if phase_traj is not None:
|
|
156
|
+
phase_traj = to_device(phase_traj, "cpu")
|
|
157
|
+
|
|
158
|
+
is_mimo = error.ndim == 2
|
|
159
|
+
n_panels = 3 if phase_traj is not None else 2
|
|
160
|
+
|
|
161
|
+
if ax is None:
|
|
162
|
+
fig, axes = plt.subplots(1, n_panels, figsize=_grid_figsize(1, n_panels))
|
|
163
|
+
else:
|
|
164
|
+
axes = np.asarray(ax).flatten()[:n_panels]
|
|
165
|
+
fig = axes[0].figure
|
|
166
|
+
|
|
167
|
+
# --- Panel 1: MSE convergence ---
|
|
168
|
+
ax_conv = axes[0]
|
|
169
|
+
|
|
170
|
+
def _smooth_mse(mse, smoothing):
|
|
171
|
+
"""Return (x_coords, smoothed_mse_db) in raw-symbol-index space.
|
|
172
|
+
|
|
173
|
+
The smoothing window is clipped to at most n//3 so the curve always
|
|
174
|
+
spans at least 2/3 of the data range even for short sequences.
|
|
175
|
+
"""
|
|
176
|
+
n = len(mse)
|
|
177
|
+
effective = max(1, min(smoothing, n // 3))
|
|
178
|
+
if effective > 1 and n > effective:
|
|
179
|
+
kernel = np.ones(effective) / effective
|
|
180
|
+
mse_smooth = np.convolve(mse, kernel, mode="valid")
|
|
181
|
+
# Element k of mode="valid" output averages mse[k : k+effective].
|
|
182
|
+
# Place it at the centre of that window so the x-axis is in
|
|
183
|
+
# actual symbol-index space, not smoothed-bin-index space.
|
|
184
|
+
x = np.arange(len(mse_smooth)) + (effective - 1) / 2.0
|
|
185
|
+
else:
|
|
186
|
+
mse_smooth = mse
|
|
187
|
+
x = np.arange(n)
|
|
188
|
+
return x, 10 * np.log10(mse_smooth + 1e-30)
|
|
189
|
+
|
|
190
|
+
if is_mimo:
|
|
191
|
+
num_ch = error.shape[0]
|
|
192
|
+
for ch in range(num_ch):
|
|
193
|
+
mse = np.abs(error[ch]) ** 2
|
|
194
|
+
x_smooth, mse_db = _smooth_mse(mse, smoothing)
|
|
195
|
+
ax_conv.plot(x_smooth, mse_db, label=f"Ch {ch}")
|
|
196
|
+
ax_conv.legend()
|
|
197
|
+
else:
|
|
198
|
+
mse = np.abs(error) ** 2
|
|
199
|
+
x_smooth, mse_db = _smooth_mse(mse, smoothing)
|
|
200
|
+
ax_conv.plot(x_smooth, mse_db)
|
|
201
|
+
|
|
202
|
+
n_train = getattr(result, "num_train_symbols", 0)
|
|
203
|
+
if n_train and n_train > 0:
|
|
204
|
+
ax_conv.axvline(
|
|
205
|
+
n_train,
|
|
206
|
+
color="white",
|
|
207
|
+
linestyle="--",
|
|
208
|
+
label=f"DD start ({n_train})",
|
|
209
|
+
)
|
|
210
|
+
ax_conv.legend()
|
|
211
|
+
|
|
212
|
+
ax_conv.set_xlabel("Symbol Index")
|
|
213
|
+
ax_conv.set_ylabel("MSE [dB]")
|
|
214
|
+
ax_conv.set_title("Convergence")
|
|
215
|
+
|
|
216
|
+
# --- Panel 2: Final tap weights ---
|
|
217
|
+
ax_taps = axes[1]
|
|
218
|
+
|
|
219
|
+
if is_mimo:
|
|
220
|
+
# Butterfly weights: (C, C, num_taps) - plot magnitude of each row
|
|
221
|
+
num_ch = weights.shape[0]
|
|
222
|
+
num_taps = weights.shape[2]
|
|
223
|
+
tap_idx = np.arange(num_taps) - (num_taps // 2)
|
|
224
|
+
for i in range(num_ch):
|
|
225
|
+
for j in range(num_ch):
|
|
226
|
+
label = f"w[{i},{j}]"
|
|
227
|
+
ax_taps.plot(
|
|
228
|
+
tap_idx,
|
|
229
|
+
np.abs(weights[i, j]),
|
|
230
|
+
marker="o",
|
|
231
|
+
markersize=3,
|
|
232
|
+
label=label,
|
|
233
|
+
)
|
|
234
|
+
ax_taps.legend(ncol=2)
|
|
235
|
+
else:
|
|
236
|
+
# SISO: (num_taps,) - stem plot
|
|
237
|
+
num_taps = weights.shape[0]
|
|
238
|
+
tap_idx = np.arange(num_taps) - (num_taps // 2)
|
|
239
|
+
markerline, stemlines, _ = ax_taps.stem(tap_idx, np.abs(weights))
|
|
240
|
+
plt.setp(stemlines)
|
|
241
|
+
plt.setp(markerline, markersize=4)
|
|
242
|
+
|
|
243
|
+
ax_taps.set_xlabel("Tap Index")
|
|
244
|
+
ax_taps.set_ylabel("|w|")
|
|
245
|
+
ax_taps.set_title("Tap Weights")
|
|
246
|
+
|
|
247
|
+
# --- Panel 3: Phase trajectory (CPR only) ---
|
|
248
|
+
if phase_traj is not None:
|
|
249
|
+
ax_phase = axes[2]
|
|
250
|
+
phi = np.asarray(phase_traj)
|
|
251
|
+
if phi.ndim == 1:
|
|
252
|
+
ax_phase.plot(np.degrees(phi))
|
|
253
|
+
else:
|
|
254
|
+
for ch in range(phi.shape[0]):
|
|
255
|
+
ax_phase.plot(np.degrees(phi[ch]), label=f"Ch {ch}")
|
|
256
|
+
ax_phase.legend()
|
|
257
|
+
ax_phase.set_xlabel("Symbol Index")
|
|
258
|
+
ax_phase.set_ylabel("Phase [deg]")
|
|
259
|
+
ax_phase.set_title("CPR Phase Trajectory")
|
|
260
|
+
|
|
261
|
+
if show:
|
|
262
|
+
plt.show()
|
|
263
|
+
return None
|
|
264
|
+
return fig, axes
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# -----------------------------------------------------------------------------
|
|
268
|
+
# SYNCHRONIZATION DIAGNOSTICS
|
|
269
|
+
# -----------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def plot_zf_equalizer_response(
|
|
273
|
+
channel_estimate,
|
|
274
|
+
noise_variance: float = 0.0,
|
|
275
|
+
nfft: int = 1024,
|
|
276
|
+
sampling_rate: float = 1.0,
|
|
277
|
+
ax=None,
|
|
278
|
+
show: bool = False,
|
|
279
|
+
title: str = "ZF/MMSE Equalizer Response",
|
|
280
|
+
) -> tuple[Any, Any] | None:
|
|
281
|
+
"""
|
|
282
|
+
Plots channel, equalizer, and combined frequency responses for ZF/MMSE.
|
|
283
|
+
|
|
284
|
+
For each channel path, three panels show:
|
|
285
|
+
|
|
286
|
+
* ``|H(f)|`` - channel response in dB.
|
|
287
|
+
* ``|W(f)|`` - equalizer response (``W = H* / (|H|² + σ²)``) in dB.
|
|
288
|
+
* ``|H(f)·W(f)|`` - combined response (≈ 0 dB for ZF, soft mask for MMSE).
|
|
289
|
+
|
|
290
|
+
Parameters
|
|
291
|
+
----------
|
|
292
|
+
channel_estimate : array_like
|
|
293
|
+
Channel impulse response. Shape: ``(L,)`` for SISO or
|
|
294
|
+
``(C_rx, C_tx, L)`` for MIMO.
|
|
295
|
+
noise_variance : float, default 0.0
|
|
296
|
+
Noise variance ``σ²`` for MMSE regularization. ``0.0`` gives pure ZF.
|
|
297
|
+
nfft : int, default 1024
|
|
298
|
+
FFT size for the frequency response computation.
|
|
299
|
+
sampling_rate : float, default 1.0
|
|
300
|
+
Sampling rate in Hz for frequency-axis scaling.
|
|
301
|
+
ax : array_like of Axes, optional
|
|
302
|
+
For SISO: 3 Axes. For MIMO: ``(C_rx * C_tx, 3)`` Axes.
|
|
303
|
+
If ``None``, a new figure is created.
|
|
304
|
+
show : bool, default False
|
|
305
|
+
title : str, default "ZF/MMSE Equalizer Response"
|
|
306
|
+
|
|
307
|
+
Returns
|
|
308
|
+
-------
|
|
309
|
+
(fig, axes) or None
|
|
310
|
+
"""
|
|
311
|
+
h = to_device(channel_estimate, "cpu")
|
|
312
|
+
reg = max(noise_variance, 1e-12)
|
|
313
|
+
siso = h.ndim == 1
|
|
314
|
+
|
|
315
|
+
freqs = np.fft.fftfreq(nfft, d=1.0 / sampling_rate)
|
|
316
|
+
sort_idx = np.argsort(freqs)
|
|
317
|
+
f_sorted = freqs[sort_idx]
|
|
318
|
+
|
|
319
|
+
max_f = float(np.max(np.abs(f_sorted)))
|
|
320
|
+
if max_f >= 1e9:
|
|
321
|
+
scale, unit = 1e9, "GHz"
|
|
322
|
+
elif max_f >= 1e6:
|
|
323
|
+
scale, unit = 1e6, "MHz"
|
|
324
|
+
elif max_f >= 1e3:
|
|
325
|
+
scale, unit = 1e3, "kHz"
|
|
326
|
+
else:
|
|
327
|
+
scale, unit = 1.0, "Hz"
|
|
328
|
+
f_disp = f_sorted / scale
|
|
329
|
+
|
|
330
|
+
def _draw_triplet(H_f, ax_row, row_label=""):
|
|
331
|
+
H_s = H_f[sort_idx]
|
|
332
|
+
W_s = np.conj(H_s) / (np.abs(H_s) ** 2 + reg)
|
|
333
|
+
HW_s = H_s * W_s
|
|
334
|
+
for axi, data, lbl, col in zip(
|
|
335
|
+
ax_row,
|
|
336
|
+
[H_s, W_s, HW_s],
|
|
337
|
+
["Channel |H(f)|", "Equalizer |W(f)|", "Combined |H·W(f)|"],
|
|
338
|
+
["C0", "C1", "C2"],
|
|
339
|
+
):
|
|
340
|
+
axi.plot(f_disp, 20 * np.log10(np.abs(data) + 1e-12), color=col)
|
|
341
|
+
prefix = f"{row_label} " if row_label else ""
|
|
342
|
+
axi.set_title(f"{prefix}{lbl}")
|
|
343
|
+
axi.set_xlabel(f"Frequency [{unit}]")
|
|
344
|
+
axi.set_ylabel("Magnitude [dB]")
|
|
345
|
+
|
|
346
|
+
if siso:
|
|
347
|
+
H_f = np.fft.fft(h, n=nfft)
|
|
348
|
+
if ax is None:
|
|
349
|
+
fig, axes = plt.subplots(1, 3, figsize=_grid_figsize(1, 3))
|
|
350
|
+
else:
|
|
351
|
+
axes = list(ax) if hasattr(ax, "__len__") else [ax]
|
|
352
|
+
fig = axes[0].figure
|
|
353
|
+
_draw_triplet(H_f, axes)
|
|
354
|
+
fig.suptitle(title)
|
|
355
|
+
if show:
|
|
356
|
+
plt.show()
|
|
357
|
+
return None
|
|
358
|
+
return fig, axes
|
|
359
|
+
|
|
360
|
+
C_rx, C_tx = h.shape[0], h.shape[1]
|
|
361
|
+
n_rows = C_rx * C_tx
|
|
362
|
+
if ax is None:
|
|
363
|
+
fig, raw_axes = plt.subplots(
|
|
364
|
+
n_rows, 3, figsize=_grid_figsize(n_rows, 3), squeeze=False
|
|
365
|
+
)
|
|
366
|
+
axes = [list(raw_axes[r]) for r in range(n_rows)]
|
|
367
|
+
else:
|
|
368
|
+
if hasattr(ax, "__len__") and hasattr(ax[0], "__len__"):
|
|
369
|
+
axes = [list(row) for row in ax]
|
|
370
|
+
else:
|
|
371
|
+
flat = list(ax)
|
|
372
|
+
axes = [flat[r * 3 : (r + 1) * 3] for r in range(n_rows)]
|
|
373
|
+
fig = axes[0][0].figure
|
|
374
|
+
|
|
375
|
+
for i in range(C_rx):
|
|
376
|
+
for j in range(C_tx):
|
|
377
|
+
row = i * C_tx + j
|
|
378
|
+
H_f = np.fft.fft(h[i, j], n=nfft)
|
|
379
|
+
_draw_triplet(H_f, axes[row], row_label=f"[rx{i}$\\rightarrow$tx{j}]")
|
|
380
|
+
|
|
381
|
+
fig.suptitle(title)
|
|
382
|
+
if show:
|
|
383
|
+
plt.show()
|
|
384
|
+
return None
|
|
385
|
+
return fig, axes
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# -----------------------------------------------------------------------------
|
|
389
|
+
# CARRIER-PHASE CHARACTERIZATION DIAGNOSTICS
|
|
390
|
+
# -----------------------------------------------------------------------------
|