Skip to content

Commit 37dad62

Browse files
feat(letsplot): implement eye-diagram-basic (#4974)
## Implementation: `eye-diagram-basic` - letsplot Implements the **letsplot** version of `eye-diagram-basic`. **File:** `plots/eye-diagram-basic/implementations/letsplot.py` **Parent Issue:** #4561 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/23221365710)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 9af2f0a commit 37dad62

File tree

2 files changed

+424
-0
lines changed

2 files changed

+424
-0
lines changed
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
""" pyplots.ai
2+
eye-diagram-basic: Signal Integrity Eye Diagram
3+
Library: letsplot 4.9.0 | Python 3.14.3
4+
Quality: 91/100 | Created: 2026-03-17
5+
"""
6+
7+
import numpy as np
8+
import pandas as pd
9+
from lets_plot import (
10+
LetsPlot,
11+
aes,
12+
element_blank,
13+
element_rect,
14+
element_text,
15+
geom_raster,
16+
geom_segment,
17+
geom_text,
18+
ggplot,
19+
ggsize,
20+
guide_colorbar,
21+
labs,
22+
layer_tooltips,
23+
scale_fill_gradientn,
24+
scale_x_continuous,
25+
scale_y_continuous,
26+
theme,
27+
)
28+
from lets_plot.export import ggsave
29+
30+
31+
LetsPlot.setup_html()
32+
33+
# Data - Simulated NRZ eye diagram
34+
np.random.seed(42)
35+
n_traces = 400
36+
samples_per_ui = 150
37+
n_bits = 3
38+
n_samples = samples_per_ui * n_bits
39+
time_full = np.linspace(0, n_bits, n_samples, endpoint=False)
40+
41+
# Signal parameters
42+
amplitude = 1.0
43+
noise_sigma = 0.05 * amplitude
44+
jitter_sigma = 0.03
45+
46+
# Sigmoid steepness for bandwidth-limited edges (lower = smoother S-curves)
47+
steepness = 8.0 / 0.7
48+
49+
# Generate overlaid traces
50+
all_time = []
51+
all_voltage = []
52+
53+
for _ in range(n_traces):
54+
bits = np.random.randint(0, 2, n_bits + 1)
55+
voltage = np.ones(n_samples) * bits[0] * amplitude
56+
57+
for bit_idx in range(1, n_bits + 1):
58+
transition_time = bit_idx + np.random.normal(0, jitter_sigma)
59+
if bits[bit_idx] != bits[bit_idx - 1]:
60+
direction = (bits[bit_idx] - bits[bit_idx - 1]) * amplitude
61+
voltage = voltage + direction / (1.0 + np.exp(-steepness * (time_full - transition_time)))
62+
63+
voltage += np.random.normal(0, noise_sigma, n_samples)
64+
65+
# Extract 2 UI window centered on the pattern (from 0.5 to 2.5 UI)
66+
mask = (time_full >= 0.5) & (time_full < 2.5)
67+
t_window = time_full[mask] - 0.5
68+
v_window = voltage[mask]
69+
70+
all_time.extend(t_window)
71+
all_voltage.extend(v_window)
72+
all_time = np.array(all_time)
73+
all_voltage = np.array(all_voltage)
74+
75+
# Create 2D density heatmap by binning (high resolution for smooth rendering)
76+
n_time_bins = 300
77+
n_voltage_bins = 180
78+
time_edges = np.linspace(0, 2.0, n_time_bins + 1)
79+
voltage_edges = np.linspace(-0.3, 1.3, n_voltage_bins + 1)
80+
81+
density, _, _ = np.histogram2d(all_time, all_voltage, bins=[time_edges, voltage_edges])
82+
83+
# Normalize density
84+
density = density / density.max()
85+
86+
# Build long-form DataFrame
87+
time_centers = (time_edges[:-1] + time_edges[1:]) / 2
88+
voltage_centers = (voltage_edges[:-1] + voltage_edges[1:]) / 2
89+
time_grid, voltage_grid = np.meshgrid(time_centers, voltage_centers, indexing="ij")
90+
91+
df = pd.DataFrame({"time_ui": time_grid.ravel(), "voltage": voltage_grid.ravel(), "density": density.ravel()})
92+
93+
# Filter out zero-density cells for cleaner rendering
94+
df = df[df["density"] > 0].reset_index(drop=True)
95+
96+
# Inferno-inspired perceptually uniform colormap for density
97+
inferno_colors = [
98+
"#0d0887",
99+
"#2d0594",
100+
"#46039f",
101+
"#6a00a8",
102+
"#8f0da4",
103+
"#b12a90",
104+
"#cc4778",
105+
"#e16462",
106+
"#f1844b",
107+
"#fca636",
108+
"#fcce25",
109+
"#f0f921",
110+
]
111+
112+
# Measure eye opening at center (1.0 UI) for annotations
113+
center_col = n_time_bins // 2
114+
center_density = density[center_col, :]
115+
threshold = 0.05
116+
low_density_mask = center_density < threshold
117+
voltage_center_vals = voltage_centers[low_density_mask]
118+
eye_region = voltage_center_vals[(voltage_center_vals > 0.15) & (voltage_center_vals < 0.85)]
119+
eye_bottom = eye_region.min() if len(eye_region) > 0 else 0.25
120+
eye_top = eye_region.max() if len(eye_region) > 0 else 0.75
121+
eye_height = eye_top - eye_bottom
122+
eye_mid_v = (eye_top + eye_bottom) / 2
123+
124+
# Measure eye width at mid-voltage level
125+
mid_row = np.argmin(np.abs(voltage_centers - eye_mid_v))
126+
row_density = density[:, mid_row]
127+
low_density_time = time_centers[row_density < threshold]
128+
eye_time_region = low_density_time[(low_density_time > 0.6) & (low_density_time < 1.4)]
129+
eye_left = eye_time_region.min() if len(eye_time_region) > 0 else 0.75
130+
eye_right = eye_time_region.max() if len(eye_time_region) > 0 else 1.25
131+
eye_width = eye_right - eye_left
132+
133+
# Annotation DataFrames
134+
ann_color = "#00e5ff"
135+
height_x = 1.32
136+
height_seg = pd.DataFrame({"x": [height_x], "y": [eye_bottom], "xend": [height_x], "yend": [eye_top]})
137+
width_seg = pd.DataFrame({"x": [eye_left], "y": [eye_mid_v], "xend": [eye_right], "yend": [eye_mid_v]})
138+
height_label = pd.DataFrame({"x": [height_x + 0.04], "y": [eye_mid_v], "label": [f"Eye Height: {eye_height:.2f} V"]})
139+
width_label = pd.DataFrame(
140+
{"x": [(eye_left + eye_right) / 2], "y": [eye_mid_v - 0.09], "label": [f"Eye Width: {eye_width:.2f} UI"]}
141+
)
142+
143+
# Plot
144+
plot = (
145+
ggplot(df, aes(x="time_ui", y="voltage", fill="density"))
146+
+ geom_raster(
147+
tooltips=layer_tooltips()
148+
.format("@time_ui", ".2f")
149+
.format("@voltage", ".2f")
150+
.format("@density", ".3f")
151+
.line("Time: @time_ui UI")
152+
.line("Voltage: @voltage V")
153+
.line("Density: @density")
154+
)
155+
+ geom_segment(
156+
aes(x="x", y="y", xend="xend", yend="yend"), data=height_seg, color=ann_color, size=1.2, inherit_aes=False
157+
)
158+
+ geom_segment(
159+
aes(x="x", y="y", xend="xend", yend="yend"), data=width_seg, color=ann_color, size=1.2, inherit_aes=False
160+
)
161+
+ geom_text(
162+
aes(x="x", y="y", label="label"), data=height_label, color=ann_color, size=11, hjust=0, inherit_aes=False
163+
)
164+
+ geom_text(
165+
aes(x="x", y="y", label="label"), data=width_label, color=ann_color, size=11, hjust=0.5, inherit_aes=False
166+
)
167+
+ scale_fill_gradientn(
168+
colors=inferno_colors, name="Trace\nDensity", guide=guide_colorbar(barwidth=14, barheight=260, nbin=256)
169+
)
170+
+ scale_x_continuous(name="Time (UI)", breaks=[0.0, 0.5, 1.0, 1.5, 2.0], expand=[0, 0])
171+
+ scale_y_continuous(name="Voltage (V)", breaks=[0.0, 0.5, 1.0], labels=["0.0", "0.5", "1.0"], expand=[0, 0])
172+
+ labs(title="eye-diagram-basic · letsplot · pyplots.ai")
173+
+ theme(
174+
plot_title=element_text(size=28, face="bold", color="#e0e0e0", margin=[0, 0, 10, 0]),
175+
axis_title_x=element_text(size=20, color="#cccccc", margin=[10, 0, 0, 0]),
176+
axis_title_y=element_text(size=20, color="#cccccc", margin=[0, 10, 0, 0]),
177+
axis_text_x=element_text(size=16, color="#aaaaaa"),
178+
axis_text_y=element_text(size=16, color="#aaaaaa"),
179+
axis_ticks=element_blank(),
180+
axis_line=element_blank(),
181+
legend_text=element_text(size=14, color="#cccccc"),
182+
legend_title=element_text(size=16, face="bold", color="#cccccc"),
183+
panel_grid=element_blank(),
184+
panel_background=element_rect(fill="#000004", color="#000004"),
185+
plot_background=element_rect(fill="#0d0d2b", color="#0d0d2b"),
186+
plot_margin=[40, 30, 20, 20],
187+
legend_background=element_rect(fill="#0d0d2b", color="#0d0d2b"),
188+
)
189+
+ ggsize(1600, 900)
190+
)
191+
192+
# Save
193+
ggsave(plot, "plot.png", path=".", scale=3)
194+
ggsave(plot, "plot.html", path=".")

0 commit comments

Comments
 (0)