Signal Integrity Eye Diagram — Pygal

An eye diagram visualizes signal integrity by overlaying many periods of a digital signal onto a single time window spanning 1-2 unit intervals (UI). The overlapping traces form a characteristic eye-shaped opening whose height, width, and clarity reveal signal quality metrics such as jitter, noise, and inter-symbol interference (ISI). A wide-open eye indicates clean signal transmission, while a closed or blurred eye signals degradation.

Signal Integrity Eye Diagram rendered with Pygal

Python source (Pygal)

""" anyplot.ai
eye-diagram-basic: Signal Integrity Eye Diagram
Library: pygal 3.1.0 | Python 3.13.13
Quality: 86/100 | Updated: 2026-06-18
"""

import importlib
import os
import sys

import numpy as np


# Import pygal avoiding name collision with this filename
_cwd = sys.path[0]
sys.path[:] = [p for p in sys.path if p != _cwd]
pygal = importlib.import_module("pygal")
Style = importlib.import_module("pygal.style").Style
sys.path.insert(0, _cwd)

# Theme tokens (Imprint palette)
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
ANYPLOT_AMBER = "#DDCC77"  # annotation / measurement marker

# Imprint sequential colormap: brand green → blue for density heatmap
# imprint_seq: #009E73 (low density) → #4467A3 (high density)
N_BANDS = 8
_seq_t = np.linspace(0, 1, N_BANDS)
_seq_start = np.array([0x00, 0x9E, 0x73])  # #009E73
_seq_end = np.array([0x44, 0x67, 0xA3])  # #4467A3
density_colors = tuple(
    "#" + "".join(f"{int(round(c)):02X}" for c in (_seq_start + (_seq_end - _seq_start) * t)) for t in _seq_t
)
band_names = ["Rare", "Low", "Low-Med", "Medium", "Med-High", "High", "V.High", "Peak"]

# Data — simulated NRZ eye diagram
np.random.seed(42)

n_traces = 400
samples_per_ui = 200
total_samples = samples_per_ui * 2  # 2 UI window
time_ui = np.linspace(0, 2, total_samples)
transition_width = 0.07  # UI, controls bandwidth-limited rise/fall

all_voltages = []
for _ in range(n_traces):
    bits = np.random.randint(0, 2, 6)
    extended_time = np.linspace(-1, 3, samples_per_ui * 4)
    voltage = np.zeros_like(extended_time)

    for bit_idx in range(1, len(bits)):
        boundary = bit_idx - 2
        voltage += (bits[bit_idx] - bits[bit_idx - 1]) / (1.0 + np.exp(-(extended_time - boundary) / transition_width))
    voltage += bits[0]
    voltage = np.clip(voltage, -0.2, 1.2)

    mask = (extended_time >= 0) & (extended_time <= 2)
    trace_voltage = voltage[mask][:total_samples]

    trace_voltage = trace_voltage + np.random.normal(0, 0.05, len(trace_voltage))
    jittered_time = time_ui + np.random.normal(0, 0.03)
    all_voltages.append((jittered_time, trace_voltage))

# Build 2D density histogram
n_xbins, n_ybins = 240, 140
x_edges = np.linspace(-0.05, 2.05, n_xbins + 1)
y_edges = np.linspace(-0.25, 1.25, n_ybins + 1)
density = np.zeros((n_ybins, n_xbins))

for jittered_time, trace_voltage in all_voltages:
    xi = ((jittered_time - x_edges[0]) / (x_edges[-1] - x_edges[0]) * n_xbins).astype(int)
    yi = ((trace_voltage - y_edges[0]) / (y_edges[-1] - y_edges[0]) * n_ybins).astype(int)
    valid = (xi >= 0) & (xi < n_xbins) & (yi >= 0) & (yi < n_ybins)
    for x, y in zip(xi[valid], yi[valid], strict=True):
        density[y, x] += 1

# 4-pass 3×3 box filter for smoother density gradients
for _ in range(4):
    padded = np.pad(density, 1, mode="edge")
    density = (
        padded[:-2, :-2]
        + padded[:-2, 1:-1]
        + padded[:-2, 2:]
        + padded[1:-1, :-2]
        + padded[1:-1, 1:-1]
        + padded[1:-1, 2:]
        + padded[2:, :-2]
        + padded[2:, 1:-1]
        + padded[2:, 2:]
    ) / 9.0

max_density = density.max()

# Eye opening metrics for annotations
center_col = int(0.5 * n_xbins / 2.1)
col_density = density[:, center_col]
y_centers = (y_edges[:-1] + y_edges[1:]) / 2

threshold = max_density * 0.15
low_indices = np.where(col_density < threshold)[0]
eye_region = low_indices[(y_centers[low_indices] > 0.15) & (y_centers[low_indices] < 0.85)]
if len(eye_region) > 2:
    eye_bottom = y_centers[eye_region[0]]
    eye_top = y_centers[eye_region[-1]]
    eye_height = round(eye_top - eye_bottom, 3)
else:
    eye_bottom, eye_top, eye_height = 0.2, 0.8, 0.6

mid_row = int((0.5 - y_edges[0]) / (y_edges[-1] - y_edges[0]) * n_ybins)
mid_row = np.clip(mid_row, 0, n_ybins - 1)
row_density = density[mid_row, :]
x_centers = (x_edges[:-1] + x_edges[1:]) / 2
low_x_indices = np.where(row_density < threshold)[0]
eye_x_region = low_x_indices[(x_centers[low_x_indices] > 0.2) & (x_centers[low_x_indices] < 0.8)]
if len(eye_x_region) > 2:
    eye_left = x_centers[eye_x_region[0]]
    eye_right = x_centers[eye_x_region[-1]]
    eye_width = round(eye_right - eye_left, 3)
else:
    eye_left, eye_right, eye_width = 0.3, 0.7, 0.4

# Assign density cells to Imprint sequential color bands
band_data = [[] for _ in range(N_BANDS)]
for yi in range(n_ybins):
    for xi in range(n_xbins):
        val = density[yi, xi]
        if val < 0.2:
            continue
        t = min(0.999, val / (max_density * 0.55))
        band_idx = min(int(t * N_BANDS), N_BANDS - 1)
        x_center = round((x_edges[xi] + x_edges[xi + 1]) / 2, 3)
        y_center = round((y_edges[yi] + y_edges[yi + 1]) / 2, 3)
        pct = round(val / max_density * 100, 1)
        band_data[band_idx].append(
            {"value": (x_center, y_center), "label": f"{x_center:.2f} UI, {y_center:.3f} V — {pct}%"}
        )

# Eye measurement annotation series — amber for high visibility against density field
eye_height_line = [
    {"value": (0.5, round(eye_bottom, 3)), "label": f"Eye Height: {eye_height:.3f} V"},
    {"value": (0.5, round(eye_top, 3)), "label": f"Eye Height: {eye_height:.3f} V"},
]
eye_width_line = [
    {"value": (round(eye_left, 3), 0.5), "label": f"Eye Width: {eye_width:.3f} UI"},
    {"value": (round(eye_right, 3), 0.5), "label": f"Eye Width: {eye_width:.3f} UI"},
]
# Rectangle bounding the eye aperture — closes the box so the open eye is the clear focal point
eye_aperture_box = [
    {"value": (round(eye_left, 3), round(eye_bottom, 3)), "label": "Eye Aperture"},
    {"value": (round(eye_left, 3), round(eye_top, 3)), "label": "Eye Aperture"},
    {"value": (round(eye_right, 3), round(eye_top, 3)), "label": "Eye Aperture"},
    {"value": (round(eye_right, 3), round(eye_bottom, 3)), "label": "Eye Aperture"},
    {"value": (round(eye_left, 3), round(eye_bottom, 3)), "label": "Eye Aperture"},
]

# pygal Style — theme-adaptive with Imprint sequential palette
custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    colors=density_colors + (ANYPLOT_AMBER, ANYPLOT_AMBER, ANYPLOT_AMBER),
    title_font_size=66,
    label_font_size=56,
    major_label_font_size=44,
    legend_font_size=44,
    value_font_size=36,
    tooltip_font_size=32,
    stroke_width=0,
    font_family="'Courier New', monospace",
    title_font_family="'Courier New', monospace",
    label_font_family="'Courier New', monospace",
    legend_font_family="'Courier New', monospace",
    value_font_family="'Courier New', monospace",
)

# XY scatter chart — 3200×1800 landscape canvas
chart = pygal.XY(
    width=3200,
    height=1800,
    style=custom_style,
    title="eye-diagram-basic · python · pygal · anyplot.ai",
    x_title="Time (UI)",
    y_title="Voltage (V)",
    stroke=False,
    dots_size=12,
    show_legend=True,
    legend_at_bottom=True,
    legend_at_bottom_columns=6,
    legend_box_size=22,
    show_x_guides=False,
    show_y_guides=False,
    x_labels=[0.0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0],
    range=(-0.25, 1.25),
    x_label_rotation=0,
    truncate_label=-1,
    print_values=False,
    show_dots=True,
    margin=30,
    margin_bottom=100,
    spacing=15,
    js=[],
)

for i in range(N_BANDS):
    chart.add(band_names[i], band_data[i] if band_data[i] else [], allow_interruptions=True)

chart.add(
    f"Eye H {eye_height:.2f}V",
    eye_height_line,
    stroke=True,
    show_dots=True,
    dots_size=10,
    stroke_style={"width": 4, "dasharray": "10, 5"},
    allow_interruptions=False,
)
chart.add(
    f"Eye W {eye_width:.2f}UI",
    eye_width_line,
    stroke=True,
    show_dots=True,
    dots_size=10,
    stroke_style={"width": 4, "dasharray": "10, 5"},
    allow_interruptions=False,
)
chart.add(
    "Eye Aperture",
    eye_aperture_box,
    stroke=True,
    show_dots=False,
    stroke_style={"width": 3, "dasharray": "6, 4"},
    allow_interruptions=False,
)

# Save PNG and interactive HTML
chart.render_to_png(f"plot-{THEME}.png")

svg_content = chart.render(is_unicode=True)
html_content = f"""<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>eye-diagram-basic · python · pygal · anyplot.ai</title>
    <style>
        body {{ margin: 0; display: flex; justify-content: center; align-items: center;
               min-height: 100vh; background: {PAGE_BG}; }}
        .chart {{ max-width: 100%; height: auto; }}
    </style>
</head>
<body>
    <figure class="chart">
        {svg_content}
    </figure>
</body>
</html>
"""

with open(f"plot-{THEME}.html", "w", encoding="utf-8") as fout:
    fout.write(html_content)

Part of Signal Integrity Eye Diagram on anyplot.ai.

Other implementations