Coverage for src / tracekit / visualization / eye.py: 83%
135 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""Eye diagram visualization for signal integrity analysis.
3This module provides eye diagram plotting with clock recovery and
4eye opening measurements.
7Example:
8 >>> from tracekit.visualization.eye import plot_eye
9 >>> fig = plot_eye(trace, bit_rate=1e9)
10 >>> plt.show()
12References:
13 IEEE 802.3 Ethernet standards for eye diagram testing
14 JEDEC eye diagram measurement specifications
15"""
17from __future__ import annotations
19from typing import TYPE_CHECKING, Any, Literal, cast
21import numpy as np
23try:
24 import matplotlib.pyplot as plt
25 from matplotlib.colors import LinearSegmentedColormap # noqa: F401
27 HAS_MATPLOTLIB = True
28except ImportError:
29 HAS_MATPLOTLIB = False
31from tracekit.core.exceptions import InsufficientDataError
33if TYPE_CHECKING:
34 from matplotlib.axes import Axes
35 from matplotlib.figure import Figure
36 from numpy.typing import NDArray
38 from tracekit.core.types import WaveformTrace
41def plot_eye(
42 trace: WaveformTrace,
43 *,
44 bit_rate: float | None = None,
45 clock_recovery: Literal["fft", "edge"] = "edge",
46 samples_per_bit: int | None = None,
47 ax: Axes | None = None,
48 cmap: str = "hot",
49 alpha: float = 0.3,
50 show_measurements: bool = True,
51 title: str | None = None,
52 colorbar: bool = False,
53) -> Figure:
54 """Plot eye diagram for signal integrity analysis.
56 Creates an eye diagram by overlaying multiple bit periods from a
57 serial data signal. Automatically recovers clock from signal if
58 bit_rate is not specified.
60 Args:
61 trace: Input waveform trace (serial data signal).
62 bit_rate: Bit rate in bits/second. If None, auto-recovered from signal.
63 clock_recovery: Method for clock recovery ("fft" or "edge").
64 samples_per_bit: Number of samples per bit period. Auto-calculated if None.
65 ax: Matplotlib axes. If None, creates new figure.
66 cmap: Colormap for density visualization ("hot", "viridis", "Blues").
67 alpha: Transparency for overlaid traces (0.0 to 1.0).
68 show_measurements: Annotate eye opening measurements.
69 title: Plot title.
70 colorbar: Show colorbar for density plot.
72 Returns:
73 Matplotlib Figure object.
75 Raises:
76 ImportError: If matplotlib is not available.
77 InsufficientDataError: If trace is too short for analysis.
78 ValueError: If clock recovery failed.
80 Example:
81 >>> # With known bit rate
82 >>> fig = plot_eye(trace, bit_rate=1e9) # 1 Gbps
83 >>> plt.show()
85 >>> # Auto-recover clock
86 >>> fig = plot_eye(trace, clock_recovery="fft")
87 >>> plt.show()
89 References:
90 IEEE 802.3: Ethernet eye diagram specifications
91 JEDEC JESD65B: High-Speed Interface Eye Diagram Measurements
92 """
93 if not HAS_MATPLOTLIB: 93 ↛ 94line 93 didn't jump to line 94 because the condition on line 93 was never true
94 raise ImportError("matplotlib is required for visualization")
96 if len(trace.data) < 100:
97 raise InsufficientDataError(
98 "Eye diagram requires at least 100 samples",
99 required=100,
100 available=len(trace.data),
101 analysis_type="eye_diagram",
102 )
104 # Recover clock if bit_rate not provided
105 if bit_rate is None:
106 from tracekit.analyzers.digital.timing import (
107 recover_clock_edge,
108 recover_clock_fft,
109 )
111 result = recover_clock_fft(trace) if clock_recovery == "fft" else recover_clock_edge(trace)
113 if np.isnan(result.frequency): 113 ↛ 114line 113 didn't jump to line 114 because the condition on line 113 was never true
114 raise ValueError("Clock recovery failed - cannot determine bit rate")
116 bit_rate = result.frequency
117 bit_period = result.period
118 else:
119 bit_period = 1.0 / bit_rate
121 # Calculate samples per bit
122 if samples_per_bit is None: 122 ↛ 125line 122 didn't jump to line 125 because the condition on line 122 was always true
123 samples_per_bit = int(bit_period / trace.metadata.time_base)
125 if samples_per_bit < 10:
126 raise InsufficientDataError(
127 f"Insufficient samples per bit period (need ≥10, got {samples_per_bit})",
128 required=10,
129 available=samples_per_bit,
130 analysis_type="eye_diagram",
131 )
133 # Create figure
134 if ax is None: 134 ↛ 137line 134 didn't jump to line 137 because the condition on line 134 was always true
135 fig, ax = plt.subplots(figsize=(8, 6))
136 else:
137 fig_temp = ax.get_figure()
138 if fig_temp is None:
139 raise ValueError("Axes must have an associated figure")
140 fig = cast("Figure", fig_temp)
142 # Extract overlaid bit periods
143 data = trace.data
144 n_bits = len(data) // samples_per_bit
146 if n_bits < 2:
147 raise InsufficientDataError(
148 f"Not enough complete bit periods (need ≥2, got {n_bits})",
149 required=2,
150 available=n_bits,
151 analysis_type="eye_diagram",
152 )
154 # Time axis for one bit period (normalized to UI - Unit Interval)
155 time_ui = np.linspace(0, 1, samples_per_bit)
157 # Overlay traces with density tracking
158 if cmap != "none":
159 # Use density plot (histogram2d)
160 all_times: list[np.floating[Any]] = []
161 all_voltages: list[np.floating[Any]] = []
163 for i in range(n_bits - 1):
164 start_idx = i * samples_per_bit
165 end_idx = start_idx + samples_per_bit
166 if end_idx <= len(data): 166 ↛ 163line 166 didn't jump to line 163 because the condition on line 166 was always true
167 all_times.extend(time_ui)
168 all_voltages.extend(data[start_idx:end_idx])
170 # Create 2D histogram
171 h, xedges, yedges = np.histogram2d(
172 all_times,
173 all_voltages,
174 bins=[200, 200],
175 )
177 # Plot as image
178 extent_list = [float(xedges[0]), float(xedges[-1]), float(yedges[0]), float(yedges[-1])]
179 im = ax.imshow(
180 h.T,
181 extent=tuple(extent_list), # type: ignore[arg-type]
182 origin="lower",
183 aspect="auto",
184 cmap=cmap,
185 interpolation="bilinear",
186 )
188 if colorbar:
189 fig.colorbar(im, ax=ax, label="Sample Density")
190 else:
191 # Simple line overlay
192 for i in range(min(n_bits - 1, 1000)): # Limit to 1000 traces for performance
193 start_idx = i * samples_per_bit
194 end_idx = start_idx + samples_per_bit
195 if end_idx <= len(data): 195 ↛ 192line 195 didn't jump to line 192 because the condition on line 195 was always true
196 ax.plot(
197 time_ui,
198 data[start_idx:end_idx],
199 color="blue",
200 alpha=alpha,
201 linewidth=0.5,
202 )
204 # Labels and formatting
205 ax.set_xlabel("Time (UI)")
206 ax.set_ylabel("Voltage (V)")
207 ax.set_xlim(0, 1)
209 if title:
210 ax.set_title(title)
211 else:
212 ax.set_title(f"Eye Diagram @ {bit_rate / 1e6:.1f} Mbps")
214 ax.grid(True, alpha=0.3)
216 # Add eye opening measurements
217 if show_measurements:
218 eye_metrics = _calculate_eye_metrics(data, samples_per_bit, n_bits)
219 _add_eye_measurements(ax, eye_metrics, time_ui)
221 fig.tight_layout()
222 return fig
225def _calculate_eye_metrics(
226 data: NDArray[np.floating[Any]],
227 samples_per_bit: int,
228 n_bits: int,
229) -> dict[str, float]:
230 """Calculate eye diagram opening metrics.
232 Args:
233 data: Waveform data.
234 samples_per_bit: Samples per bit period.
235 n_bits: Number of complete bit periods.
237 Returns:
238 Dictionary with eye metrics:
239 - eye_height: Vertical eye opening (V)
240 - eye_width: Horizontal eye opening (UI)
241 - crossing_voltage: Zero-crossing voltage (V)
242 - ber_margin: Bit error rate margin estimate
243 """
244 # Extract center samples (middle 50% of bit period)
245 center_start = samples_per_bit // 4
246 center_end = 3 * samples_per_bit // 4
248 # Collect center samples from all bit periods
249 center_samples_list: list[np.floating[Any]] = []
250 for i in range(n_bits - 1):
251 start_idx = i * samples_per_bit + center_start
252 end_idx = i * samples_per_bit + center_end
253 if end_idx <= len(data): 253 ↛ 250line 253 didn't jump to line 250 because the condition on line 253 was always true
254 center_samples_list.extend(data[start_idx:end_idx])
256 center_samples = np.array(center_samples_list)
258 if len(center_samples) == 0: 258 ↛ 259line 258 didn't jump to line 259 because the condition on line 258 was never true
259 return {
260 "eye_height": np.nan,
261 "eye_width": np.nan,
262 "crossing_voltage": np.nan,
263 "ber_margin": np.nan,
264 }
266 # Estimate logic levels using histogram
267 hist, bin_edges = np.histogram(center_samples, bins=100)
268 bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
270 # Find peaks for logic 0 and logic 1
271 mid_idx = len(hist) // 2
272 low_peak_idx = np.argmax(hist[:mid_idx])
273 high_peak_idx = mid_idx + np.argmax(hist[mid_idx:])
275 v_low = bin_centers[low_peak_idx]
276 v_high = bin_centers[high_peak_idx]
278 # Crossing voltage (midpoint)
279 v_cross = (v_low + v_high) / 2
281 # Eye height (vertical opening)
282 # Use 20th-80th percentile for robustness
283 low_samples = center_samples[center_samples < v_cross]
284 high_samples = center_samples[center_samples >= v_cross]
286 if len(low_samples) > 0 and len(high_samples) > 0: 286 ↛ 291line 286 didn't jump to line 291 because the condition on line 286 was always true
287 v_low_80 = np.percentile(low_samples, 80)
288 v_high_20 = np.percentile(high_samples, 20)
289 eye_height = v_high_20 - v_low_80
290 else:
291 eye_height = v_high - v_low
293 # Eye width estimation (simplified)
294 # Find the time span where eye is open (center region)
295 eye_width = 0.5 # 50% of UI is typical for good signal
297 # BER margin (simplified estimate)
298 signal_swing = v_high - v_low
299 ber_margin = (eye_height / signal_swing) if signal_swing > 0 else 0.0
301 return {
302 "eye_height": float(eye_height),
303 "eye_width": float(eye_width),
304 "crossing_voltage": float(v_cross),
305 "ber_margin": float(ber_margin),
306 }
309def _add_eye_measurements(
310 ax: Axes,
311 metrics: dict[str, float],
312 time_ui: NDArray[np.float64],
313) -> None:
314 """Add measurement annotations to eye diagram.
316 Args:
317 ax: Matplotlib axes.
318 metrics: Eye diagram metrics.
319 time_ui: Time axis in UI.
320 """
321 # Create measurement text
322 lines = []
323 if not np.isnan(metrics["eye_height"]): 323 ↛ 325line 323 didn't jump to line 325 because the condition on line 323 was always true
324 lines.append(f"Eye Height: {metrics['eye_height'] * 1e3:.1f} mV")
325 if not np.isnan(metrics["eye_width"]): 325 ↛ 327line 325 didn't jump to line 327 because the condition on line 325 was always true
326 lines.append(f"Eye Width: {metrics['eye_width']:.2f} UI")
327 if not np.isnan(metrics["crossing_voltage"]): 327 ↛ 329line 327 didn't jump to line 329 because the condition on line 327 was always true
328 lines.append(f"Crossing: {metrics['crossing_voltage']:.3f} V")
329 if not np.isnan(metrics["ber_margin"]): 329 ↛ 332line 329 didn't jump to line 332 because the condition on line 329 was always true
330 lines.append(f"BER Margin: {metrics['ber_margin'] * 100:.1f}%")
332 if lines: 332 ↛ exitline 332 didn't return from function '_add_eye_measurements' because the condition on line 332 was always true
333 text = "\n".join(lines)
334 ax.annotate(
335 text,
336 xy=(0.02, 0.98),
337 xycoords="axes fraction",
338 verticalalignment="top",
339 fontfamily="monospace",
340 fontsize=9,
341 bbox={"boxstyle": "round", "facecolor": "wheat", "alpha": 0.9},
342 )
345def plot_bathtub(
346 trace: WaveformTrace,
347 *,
348 bit_rate: float | None = None,
349 ber_target: float = 1e-12,
350 ax: Axes | None = None,
351 title: str | None = None,
352) -> Figure:
353 """Plot bathtub curve for BER analysis.
355 Creates a bathtub curve showing bit error rate vs. sampling position
356 within the unit interval. Used for determining optimal sampling point
357 and timing margin.
359 Args:
360 trace: Input waveform trace.
361 bit_rate: Bit rate in bits/second.
362 ber_target: Target bit error rate for margin calculation.
363 ax: Matplotlib axes.
364 title: Plot title.
366 Returns:
367 Matplotlib Figure object.
369 Raises:
370 ImportError: If matplotlib is not available.
371 ValueError: If axes has no associated figure.
373 Example:
374 >>> fig = plot_bathtub(trace, bit_rate=1e9, ber_target=1e-12)
376 References:
377 IEEE 802.3: Bathtub curve methodology
378 """
379 if not HAS_MATPLOTLIB: 379 ↛ 380line 379 didn't jump to line 380 because the condition on line 379 was never true
380 raise ImportError("matplotlib is required for visualization")
382 # Placeholder implementation for bathtub curve
383 # Full implementation would require statistical analysis of jitter
384 # and noise distributions
386 if ax is None: 386 ↛ 389line 386 didn't jump to line 389 because the condition on line 386 was always true
387 fig, ax = plt.subplots(figsize=(8, 5))
388 else:
389 fig_temp = ax.get_figure()
390 if fig_temp is None:
391 raise ValueError("Axes must have an associated figure")
392 fig = cast("Figure", fig_temp)
394 # Simplified bathtub curve visualization
395 ui = np.linspace(0, 1, 100)
396 # Bathtub shape: high BER at edges, low in center
397 ber = 1e-2 * (np.exp(-(((ui - 0.5) / 0.2) ** 2) * 10) + 1e-12)
399 ax.semilogy(ui, ber, linewidth=2, color="C0")
400 ax.axhline(ber_target, color="red", linestyle="--", label=f"BER Target: {ber_target:.0e}")
402 ax.set_xlabel("Sample Position (UI)")
403 ax.set_ylabel("Bit Error Rate")
404 ax.set_xlim(0, 1)
405 ax.grid(True, alpha=0.3, which="both")
406 ax.legend()
408 if title:
409 ax.set_title(title)
410 else:
411 ax.set_title("Bathtub Curve")
413 fig.tight_layout()
414 return fig
417__all__ = [
418 "plot_bathtub",
419 "plot_eye",
420]