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,483 @@
|
|
|
1
|
+
"""Constellation diagram plots."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import matplotlib.pyplot as plt
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from .. import helpers
|
|
9
|
+
from ..backend import dispatch, to_device
|
|
10
|
+
from ..core.signal import Signal
|
|
11
|
+
from ..logger import logger
|
|
12
|
+
from .theme import (
|
|
13
|
+
_create_subplot_grid,
|
|
14
|
+
_grid_figsize,
|
|
15
|
+
_square_figsize,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def plot_ideal_constellation(
|
|
20
|
+
modulation: str,
|
|
21
|
+
order: int,
|
|
22
|
+
pmf: Any | None = None,
|
|
23
|
+
nu: float | None = None,
|
|
24
|
+
ax: Any | None = None,
|
|
25
|
+
title: str | None = None,
|
|
26
|
+
size: float | None = None,
|
|
27
|
+
show: bool = False,
|
|
28
|
+
unipolar: bool | None = None,
|
|
29
|
+
) -> tuple[Any, Any] | None:
|
|
30
|
+
"""
|
|
31
|
+
Plots the ideal constellation diagram for a modulation format.
|
|
32
|
+
|
|
33
|
+
Draws theoretical symbol points with their associated Gray-coded bit
|
|
34
|
+
sequences. Includes concentric rings and center axes for reference.
|
|
35
|
+
|
|
36
|
+
For PS-QAM, pass either ``pmf`` or ``nu`` (not both) to activate
|
|
37
|
+
probability-weighted rendering: each marker's **area** and **colour**
|
|
38
|
+
encode the symbol probability under the Maxwell-Boltzmann distribution.
|
|
39
|
+
Inner (more probable) points appear larger and warmer. Bit-label
|
|
40
|
+
annotations are suppressed to keep the plot readable at high orders.
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
modulation : {"psk", "qam", "ask", "pam"}
|
|
45
|
+
Modulation scheme identifier.
|
|
46
|
+
order : int
|
|
47
|
+
Modulation order (e.g., 4, 16, 64).
|
|
48
|
+
pmf : array-like of float, optional
|
|
49
|
+
Symbol PMF of shape ``(M,)`` for PS-QAM (from ``maxwell_boltzmann``).
|
|
50
|
+
Mutually exclusive with ``nu``.
|
|
51
|
+
nu : float, optional
|
|
52
|
+
Maxwell-Boltzmann shaping parameter nu >= 0 for QAM. The PMF is
|
|
53
|
+
computed automatically via ``maxwell_boltzmann``.
|
|
54
|
+
nu = 0 gives a uniform distribution (equal-sized markers).
|
|
55
|
+
Mutually exclusive with ``pmf``.
|
|
56
|
+
ax : matplotlib.axes.Axes, optional
|
|
57
|
+
Target axis.
|
|
58
|
+
title : str, optional
|
|
59
|
+
Plot title.
|
|
60
|
+
size : float, optional
|
|
61
|
+
Figure size (square), in inches. Defaults to the theme's square
|
|
62
|
+
panel size (see ``theme._square_figsize``).
|
|
63
|
+
show : bool, default False
|
|
64
|
+
If True, calls `plt.show()`.
|
|
65
|
+
unipolar : bool, default False
|
|
66
|
+
If True, use unipolar plot_constellation (ASK/PAM).
|
|
67
|
+
|
|
68
|
+
Returns
|
|
69
|
+
-------
|
|
70
|
+
fig : matplotlib.figure.Figure
|
|
71
|
+
The figure object.
|
|
72
|
+
ax : matplotlib.axes.Axes
|
|
73
|
+
The plotting axis.
|
|
74
|
+
|
|
75
|
+
Raises
|
|
76
|
+
------
|
|
77
|
+
ValueError
|
|
78
|
+
If both ``pmf`` and ``nu`` are provided.
|
|
79
|
+
"""
|
|
80
|
+
if pmf is not None and nu is not None:
|
|
81
|
+
raise ValueError("Provide at most one of `pmf` or `nu`, not both.")
|
|
82
|
+
|
|
83
|
+
logger.debug("Generating ideal constellation for %s (%s-level).", modulation, order)
|
|
84
|
+
from ..mapping import gray_constellation, maxwell_boltzmann
|
|
85
|
+
|
|
86
|
+
if nu is not None:
|
|
87
|
+
pmf = maxwell_boltzmann(order, nu)
|
|
88
|
+
|
|
89
|
+
if ax is None:
|
|
90
|
+
figsize = (size, size) if size is not None else _square_figsize()
|
|
91
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
92
|
+
else:
|
|
93
|
+
fig = ax.figure
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
# Generate constellation on backend (returns NumPy)
|
|
97
|
+
const = gray_constellation(modulation, order, unipolar=unipolar)
|
|
98
|
+
except ValueError as e:
|
|
99
|
+
logger.error("Error generating constellation: %s", e)
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
# Move to cpu for plotting (already NumPy but good practice)
|
|
103
|
+
const = to_device(const, "cpu")
|
|
104
|
+
|
|
105
|
+
real = const.real
|
|
106
|
+
imag = const.imag
|
|
107
|
+
|
|
108
|
+
if pmf is not None:
|
|
109
|
+
# PS-QAM mode
|
|
110
|
+
pmf_arr = np.asarray(pmf, dtype=np.float64)
|
|
111
|
+
sc = ax.scatter(
|
|
112
|
+
real,
|
|
113
|
+
imag,
|
|
114
|
+
s=100,
|
|
115
|
+
c=pmf_arr,
|
|
116
|
+
cmap="YlOrRd",
|
|
117
|
+
edgecolors="black",
|
|
118
|
+
linewidths=0.5,
|
|
119
|
+
zorder=10,
|
|
120
|
+
)
|
|
121
|
+
plt.colorbar(sc, ax=ax, label="P(sₘ)")
|
|
122
|
+
else:
|
|
123
|
+
# Uniform mode
|
|
124
|
+
ax.scatter(real, imag, s=100, zorder=10)
|
|
125
|
+
n_bits = int(np.log2(order))
|
|
126
|
+
for i, point in enumerate(const):
|
|
127
|
+
x, y = point.real, point.imag
|
|
128
|
+
label = f"{i:0{n_bits}b} ({i})"
|
|
129
|
+
ax.annotate(
|
|
130
|
+
label,
|
|
131
|
+
(x, y),
|
|
132
|
+
xytext=(5, 5),
|
|
133
|
+
textcoords="offset points",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Titles and Labels
|
|
137
|
+
if title is None:
|
|
138
|
+
prefix = "PS-" if pmf is not None else ""
|
|
139
|
+
title = f"Constellation: {prefix}{modulation.upper()} {order}"
|
|
140
|
+
ax.set_title(title)
|
|
141
|
+
ax.set_xlabel("In-Phase (I)")
|
|
142
|
+
ax.set_ylabel("Quadrature (Q)")
|
|
143
|
+
|
|
144
|
+
# Center lines
|
|
145
|
+
ax.axhline(0, color="white", alpha=0.4, zorder=0)
|
|
146
|
+
ax.axvline(0, color="white", alpha=0.4, zorder=0)
|
|
147
|
+
|
|
148
|
+
# Limits and Aspect
|
|
149
|
+
max_range = np.max(np.abs(const))
|
|
150
|
+
limit = max_range * 1.1 if max_range > 0 else 1
|
|
151
|
+
ax.set_xlim(-limit, limit)
|
|
152
|
+
ax.set_ylim(-limit, limit)
|
|
153
|
+
ax.set_aspect("equal")
|
|
154
|
+
|
|
155
|
+
ax.grid(False)
|
|
156
|
+
|
|
157
|
+
# Draw concentric circles (rings) at point magnitudes
|
|
158
|
+
# Find unique radii from the constellation points
|
|
159
|
+
radii = np.unique(np.round(np.abs(const), 6))
|
|
160
|
+
|
|
161
|
+
# Filter out zero radius (origin)
|
|
162
|
+
radii = radii[radii > 1e-6]
|
|
163
|
+
|
|
164
|
+
for r in radii:
|
|
165
|
+
circle = plt.Circle(
|
|
166
|
+
(0, 0),
|
|
167
|
+
r,
|
|
168
|
+
fill=False,
|
|
169
|
+
color="gray",
|
|
170
|
+
linestyle="-",
|
|
171
|
+
alpha=0.4,
|
|
172
|
+
zorder=-5,
|
|
173
|
+
)
|
|
174
|
+
ax.add_artist(circle)
|
|
175
|
+
|
|
176
|
+
if show:
|
|
177
|
+
plt.show()
|
|
178
|
+
return None
|
|
179
|
+
return fig, ax
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def plot_constellation(
|
|
183
|
+
samples: Any,
|
|
184
|
+
bins: int = 100,
|
|
185
|
+
cmap: str = "inferno",
|
|
186
|
+
ax: Any | None = None,
|
|
187
|
+
overlay_ideal: bool = False,
|
|
188
|
+
overlay_source: bool = False,
|
|
189
|
+
modulation: str | None = None,
|
|
190
|
+
order: int | None = None,
|
|
191
|
+
unipolar: bool | None = None,
|
|
192
|
+
pmf: Any | None = None,
|
|
193
|
+
title: str | None = "Constellation",
|
|
194
|
+
vmin: float | None = None,
|
|
195
|
+
vmax: float | None = None,
|
|
196
|
+
show: bool = False,
|
|
197
|
+
**kwargs: Any,
|
|
198
|
+
) -> tuple[Any, Any] | None:
|
|
199
|
+
"""
|
|
200
|
+
Plots a constellation density diagram from received samples.
|
|
201
|
+
|
|
202
|
+
Uses high-definition 2D histograms with Gaussian smoothing to
|
|
203
|
+
visualize noisy or impaired signals. This is significantly more
|
|
204
|
+
informative than scatter plots for large sample sets.
|
|
205
|
+
|
|
206
|
+
Parameters
|
|
207
|
+
----------
|
|
208
|
+
samples : array_like or Signal
|
|
209
|
+
Received complex samples. Shape: (..., N_symbols).
|
|
210
|
+
bins : int, default 100
|
|
211
|
+
Density resolution (bins per axis).
|
|
212
|
+
cmap : str, default "inferno"
|
|
213
|
+
Colormap for the density field.
|
|
214
|
+
ax : matplotlib.axes.Axes, optional
|
|
215
|
+
Target axis.
|
|
216
|
+
overlay_ideal : bool, default False
|
|
217
|
+
If True, overlays theoretical points and scales them to signal power.
|
|
218
|
+
modulation : str, optional
|
|
219
|
+
Required parameter if `overlay_ideal` is enabled.
|
|
220
|
+
order : int, optional
|
|
221
|
+
Required parameter if `overlay_ideal` is enabled.
|
|
222
|
+
unipolar : bool, optional
|
|
223
|
+
Required parameter if `overlay_ideal` is enabled.
|
|
224
|
+
title : str, optional
|
|
225
|
+
Plot title.
|
|
226
|
+
vmin, vmax : float, optional
|
|
227
|
+
Color scaling limits. Defaults to auto-range [0, 1].
|
|
228
|
+
show : bool, default False
|
|
229
|
+
If True, calls `plt.show()`.
|
|
230
|
+
**kwargs : Any
|
|
231
|
+
Additional theoretical arguments passed to `ax.imshow`.
|
|
232
|
+
|
|
233
|
+
Returns
|
|
234
|
+
-------
|
|
235
|
+
fig : matplotlib.figure.Figure
|
|
236
|
+
The figure object.
|
|
237
|
+
ax : matplotlib.axes.Axes or ndarray
|
|
238
|
+
The axis or array of axes used.
|
|
239
|
+
"""
|
|
240
|
+
if isinstance(samples, Signal):
|
|
241
|
+
sig = samples
|
|
242
|
+
result = plot_constellation(
|
|
243
|
+
sig.samples,
|
|
244
|
+
bins=bins,
|
|
245
|
+
cmap=cmap,
|
|
246
|
+
ax=ax,
|
|
247
|
+
overlay_ideal=overlay_ideal,
|
|
248
|
+
modulation=sig.mod_scheme,
|
|
249
|
+
order=sig.mod_order,
|
|
250
|
+
unipolar=sig.mod_unipolar,
|
|
251
|
+
pmf=sig.ps_pmf,
|
|
252
|
+
title=title,
|
|
253
|
+
vmin=vmin,
|
|
254
|
+
vmax=vmax,
|
|
255
|
+
show=False,
|
|
256
|
+
**kwargs,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
if overlay_source and sig.source_symbols is not None and result is not None:
|
|
260
|
+
_, axes = result
|
|
261
|
+
src = to_device(sig.source_symbols, "cpu")
|
|
262
|
+
|
|
263
|
+
# PS-QAM: source_symbols are on the {s_m} grid (avg power E_PS < 1)
|
|
264
|
+
# but received samples normalise to unit power ({s_m/sqrt(E_PS)}).
|
|
265
|
+
# Scale source symbols to match the received symbol scale.
|
|
266
|
+
if (
|
|
267
|
+
sig.ps_pmf is not None
|
|
268
|
+
and sig.mod_scheme is not None
|
|
269
|
+
and sig.mod_order is not None
|
|
270
|
+
):
|
|
271
|
+
from ..mapping import gray_constellation as _gc_src
|
|
272
|
+
|
|
273
|
+
_const_src = _gc_src(sig.mod_scheme, sig.mod_order)
|
|
274
|
+
_pmf_src = np.asarray(sig.ps_pmf, dtype=np.float64)
|
|
275
|
+
_e_ps = float(np.dot(_pmf_src, np.abs(_const_src) ** 2))
|
|
276
|
+
if 0 < _e_ps < 1.0 - 1e-6:
|
|
277
|
+
src = src / np.sqrt(_e_ps)
|
|
278
|
+
|
|
279
|
+
def _scatter_source(axis, symbols):
|
|
280
|
+
axis.scatter(
|
|
281
|
+
symbols.real,
|
|
282
|
+
symbols.imag,
|
|
283
|
+
c="lime",
|
|
284
|
+
edgecolors="dimgray",
|
|
285
|
+
linewidths=1.5,
|
|
286
|
+
s=30,
|
|
287
|
+
zorder=10,
|
|
288
|
+
marker="o",
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
if src.ndim > 1:
|
|
292
|
+
ax_list = list(np.asarray(axes).flat)
|
|
293
|
+
for ch in range(min(src.shape[0], len(ax_list))):
|
|
294
|
+
_scatter_source(ax_list[ch], src[ch])
|
|
295
|
+
else:
|
|
296
|
+
_scatter_source(axes, src)
|
|
297
|
+
|
|
298
|
+
if show:
|
|
299
|
+
plt.show()
|
|
300
|
+
return None
|
|
301
|
+
return result
|
|
302
|
+
|
|
303
|
+
logger.debug("Generating constellation density plot.")
|
|
304
|
+
|
|
305
|
+
samples, xp, sp = dispatch(samples)
|
|
306
|
+
|
|
307
|
+
# Handle Multichannel (e.g. Dual-Pol)
|
|
308
|
+
# Convention: (Channels, Time)
|
|
309
|
+
if samples.ndim > 1:
|
|
310
|
+
num_channels = samples.shape[0]
|
|
311
|
+
|
|
312
|
+
if ax is None:
|
|
313
|
+
nrows, ncols = _create_subplot_grid(num_channels)
|
|
314
|
+
fig, axes = plt.subplots(
|
|
315
|
+
nrows,
|
|
316
|
+
ncols,
|
|
317
|
+
figsize=_grid_figsize(nrows, ncols, panel=_square_figsize()),
|
|
318
|
+
squeeze=False,
|
|
319
|
+
)
|
|
320
|
+
else:
|
|
321
|
+
if not isinstance(ax, (list, tuple, np.ndarray)):
|
|
322
|
+
logger.warning(
|
|
323
|
+
"Multiple channels detected but single axis provided. Overlaying plots."
|
|
324
|
+
)
|
|
325
|
+
axes = np.array([[ax] * num_channels])
|
|
326
|
+
fig = ax.figure
|
|
327
|
+
else:
|
|
328
|
+
axes = np.atleast_2d(ax)
|
|
329
|
+
fig = axes.flat[0].figure
|
|
330
|
+
|
|
331
|
+
for i in range(num_channels):
|
|
332
|
+
channel_samples = samples[i]
|
|
333
|
+
|
|
334
|
+
# Determine target axis using 2D indexing
|
|
335
|
+
row, col = divmod(i, axes.shape[1])
|
|
336
|
+
target_ax = axes[row, col] if row < axes.shape[0] else axes.flat[-1]
|
|
337
|
+
|
|
338
|
+
ch_title = f"{title} (Ch {i})" if title else f"Channel {i}"
|
|
339
|
+
|
|
340
|
+
plot_constellation(
|
|
341
|
+
channel_samples,
|
|
342
|
+
bins=bins,
|
|
343
|
+
cmap=cmap,
|
|
344
|
+
ax=target_ax,
|
|
345
|
+
overlay_ideal=overlay_ideal,
|
|
346
|
+
modulation=modulation,
|
|
347
|
+
order=order,
|
|
348
|
+
pmf=pmf,
|
|
349
|
+
title=ch_title,
|
|
350
|
+
vmin=vmin,
|
|
351
|
+
vmax=vmax,
|
|
352
|
+
show=False,
|
|
353
|
+
**kwargs,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
if show:
|
|
357
|
+
plt.show()
|
|
358
|
+
return None
|
|
359
|
+
return fig, axes
|
|
360
|
+
|
|
361
|
+
# --- 1D Logic ---
|
|
362
|
+
|
|
363
|
+
if ax is None:
|
|
364
|
+
fig, ax = plt.subplots(figsize=_square_figsize())
|
|
365
|
+
else:
|
|
366
|
+
fig = ax.figure
|
|
367
|
+
|
|
368
|
+
# Ensure samples are complex
|
|
369
|
+
if not xp.iscomplexobj(samples):
|
|
370
|
+
logger.warning("Constellation plot expects complex samples. Converting.")
|
|
371
|
+
samples = samples.astype(xp.complex64)
|
|
372
|
+
|
|
373
|
+
# Extract I and Q
|
|
374
|
+
i_data = samples.real.flatten()
|
|
375
|
+
q_data = samples.imag.flatten()
|
|
376
|
+
|
|
377
|
+
# Move to CPU for plotting
|
|
378
|
+
i_data = to_device(i_data, "cpu")
|
|
379
|
+
q_data = to_device(q_data, "cpu")
|
|
380
|
+
|
|
381
|
+
# Compute 2D histogram
|
|
382
|
+
# Determine range based on RMS (robust to noise outliers)
|
|
383
|
+
# Using np.sqrt(np.mean(|I|² + |Q|²)) is equivalent to rms(complex_signal)
|
|
384
|
+
signal_rms = float(helpers.rms(i_data + 1j * q_data))
|
|
385
|
+
# Use ~3x RMS as limit (covers most constellation points + noise spread)
|
|
386
|
+
limit = signal_rms * 2.0
|
|
387
|
+
if limit == 0:
|
|
388
|
+
limit = 1.0 # Default view range for zero signal
|
|
389
|
+
|
|
390
|
+
h, xedges, yedges = np.histogram2d(
|
|
391
|
+
i_data, q_data, bins=bins, range=[[-limit, limit], [-limit, limit]]
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
# Transpose for imshow (rows=y, cols=x)
|
|
395
|
+
h = h.T
|
|
396
|
+
|
|
397
|
+
# Apply Gaussian smoothing for nicer visuals
|
|
398
|
+
from scipy.ndimage import gaussian_filter
|
|
399
|
+
|
|
400
|
+
h = gaussian_filter(h, sigma=1)
|
|
401
|
+
|
|
402
|
+
# Normalize histogram to [0, 1] for consistent colormap scaling
|
|
403
|
+
h_max = np.max(h)
|
|
404
|
+
if h_max > 0:
|
|
405
|
+
h = h / h_max
|
|
406
|
+
|
|
407
|
+
# Plot using imshow
|
|
408
|
+
imshow_kwargs: dict[str, Any] = {
|
|
409
|
+
"origin": "lower",
|
|
410
|
+
"extent": [-limit, limit, -limit, limit],
|
|
411
|
+
"aspect": "equal",
|
|
412
|
+
"cmap": cmap,
|
|
413
|
+
"interpolation": "bilinear",
|
|
414
|
+
}
|
|
415
|
+
if vmin is not None:
|
|
416
|
+
imshow_kwargs["vmin"] = vmin
|
|
417
|
+
if vmax is not None:
|
|
418
|
+
imshow_kwargs["vmax"] = vmax
|
|
419
|
+
imshow_kwargs.update(kwargs)
|
|
420
|
+
|
|
421
|
+
ax.imshow(h, **imshow_kwargs)
|
|
422
|
+
|
|
423
|
+
# Overlay ideal constellation if requested
|
|
424
|
+
if overlay_ideal:
|
|
425
|
+
if modulation is None or order is None:
|
|
426
|
+
logger.warning(
|
|
427
|
+
"Modulation and order must be provided to overlay ideal constellation."
|
|
428
|
+
)
|
|
429
|
+
else:
|
|
430
|
+
from ..mapping import constellation_power, gray_constellation
|
|
431
|
+
|
|
432
|
+
try:
|
|
433
|
+
const = gray_constellation(modulation, order, unipolar=unipolar)
|
|
434
|
+
const = to_device(const, "cpu")
|
|
435
|
+
|
|
436
|
+
# Scale constellation to match signal amplitude.
|
|
437
|
+
# For PS-QAM: use pmf-weighted RMS so ideal points land at
|
|
438
|
+
# {s_m / sqrt(E_PS)}, matching where the received clusters
|
|
439
|
+
# sit after shape_pulse normalises to E_s = 1.
|
|
440
|
+
if pmf is not None:
|
|
441
|
+
e_ps = constellation_power(const, pmf)
|
|
442
|
+
const_rms = float(np.sqrt(e_ps)) if e_ps > 0 else helpers.rms(const)
|
|
443
|
+
else:
|
|
444
|
+
const_rms = helpers.rms(const)
|
|
445
|
+
if const_rms > 0:
|
|
446
|
+
scale_factor = signal_rms / const_rms
|
|
447
|
+
const = const * scale_factor
|
|
448
|
+
|
|
449
|
+
ax.scatter(
|
|
450
|
+
const.real,
|
|
451
|
+
const.imag,
|
|
452
|
+
c="lime",
|
|
453
|
+
edgecolors="dimgray",
|
|
454
|
+
linewidths=1.5,
|
|
455
|
+
s=30,
|
|
456
|
+
zorder=10,
|
|
457
|
+
marker="o",
|
|
458
|
+
)
|
|
459
|
+
except ValueError as e:
|
|
460
|
+
logger.warning("Could not overlay ideal constellation: %s", e)
|
|
461
|
+
|
|
462
|
+
# Add center lines
|
|
463
|
+
ax.axhline(0, color="white", alpha=0.4, zorder=0)
|
|
464
|
+
ax.axvline(0, color="white", alpha=0.4, zorder=0)
|
|
465
|
+
|
|
466
|
+
ax.set_xlabel("In-Phase (I)")
|
|
467
|
+
ax.set_ylabel("Quadrature (Q)")
|
|
468
|
+
if title is not None:
|
|
469
|
+
ax.set_title(title)
|
|
470
|
+
|
|
471
|
+
ax.set_xlim(-limit, limit)
|
|
472
|
+
ax.set_ylim(-limit, limit)
|
|
473
|
+
ax.grid(False)
|
|
474
|
+
|
|
475
|
+
if show:
|
|
476
|
+
plt.show()
|
|
477
|
+
return None
|
|
478
|
+
return fig, ax
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# -----------------------------------------------------------------------------
|
|
482
|
+
# EQUALIZER DIAGNOSTICS
|
|
483
|
+
# -----------------------------------------------------------------------------
|