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

1"""Eye diagram visualization for signal integrity analysis. 

2 

3This module provides eye diagram plotting with clock recovery and 

4eye opening measurements. 

5 

6 

7Example: 

8 >>> from tracekit.visualization.eye import plot_eye 

9 >>> fig = plot_eye(trace, bit_rate=1e9) 

10 >>> plt.show() 

11 

12References: 

13 IEEE 802.3 Ethernet standards for eye diagram testing 

14 JEDEC eye diagram measurement specifications 

15""" 

16 

17from __future__ import annotations 

18 

19from typing import TYPE_CHECKING, Any, Literal, cast 

20 

21import numpy as np 

22 

23try: 

24 import matplotlib.pyplot as plt 

25 from matplotlib.colors import LinearSegmentedColormap # noqa: F401 

26 

27 HAS_MATPLOTLIB = True 

28except ImportError: 

29 HAS_MATPLOTLIB = False 

30 

31from tracekit.core.exceptions import InsufficientDataError 

32 

33if TYPE_CHECKING: 

34 from matplotlib.axes import Axes 

35 from matplotlib.figure import Figure 

36 from numpy.typing import NDArray 

37 

38 from tracekit.core.types import WaveformTrace 

39 

40 

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. 

55 

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. 

59 

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. 

71 

72 Returns: 

73 Matplotlib Figure object. 

74 

75 Raises: 

76 ImportError: If matplotlib is not available. 

77 InsufficientDataError: If trace is too short for analysis. 

78 ValueError: If clock recovery failed. 

79 

80 Example: 

81 >>> # With known bit rate 

82 >>> fig = plot_eye(trace, bit_rate=1e9) # 1 Gbps 

83 >>> plt.show() 

84 

85 >>> # Auto-recover clock 

86 >>> fig = plot_eye(trace, clock_recovery="fft") 

87 >>> plt.show() 

88 

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") 

95 

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 ) 

103 

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 ) 

110 

111 result = recover_clock_fft(trace) if clock_recovery == "fft" else recover_clock_edge(trace) 

112 

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") 

115 

116 bit_rate = result.frequency 

117 bit_period = result.period 

118 else: 

119 bit_period = 1.0 / bit_rate 

120 

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) 

124 

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 ) 

132 

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) 

141 

142 # Extract overlaid bit periods 

143 data = trace.data 

144 n_bits = len(data) // samples_per_bit 

145 

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 ) 

153 

154 # Time axis for one bit period (normalized to UI - Unit Interval) 

155 time_ui = np.linspace(0, 1, samples_per_bit) 

156 

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]] = [] 

162 

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]) 

169 

170 # Create 2D histogram 

171 h, xedges, yedges = np.histogram2d( 

172 all_times, 

173 all_voltages, 

174 bins=[200, 200], 

175 ) 

176 

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 ) 

187 

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 ) 

203 

204 # Labels and formatting 

205 ax.set_xlabel("Time (UI)") 

206 ax.set_ylabel("Voltage (V)") 

207 ax.set_xlim(0, 1) 

208 

209 if title: 

210 ax.set_title(title) 

211 else: 

212 ax.set_title(f"Eye Diagram @ {bit_rate / 1e6:.1f} Mbps") 

213 

214 ax.grid(True, alpha=0.3) 

215 

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) 

220 

221 fig.tight_layout() 

222 return fig 

223 

224 

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. 

231 

232 Args: 

233 data: Waveform data. 

234 samples_per_bit: Samples per bit period. 

235 n_bits: Number of complete bit periods. 

236 

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 

247 

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]) 

255 

256 center_samples = np.array(center_samples_list) 

257 

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 } 

265 

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 

269 

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:]) 

274 

275 v_low = bin_centers[low_peak_idx] 

276 v_high = bin_centers[high_peak_idx] 

277 

278 # Crossing voltage (midpoint) 

279 v_cross = (v_low + v_high) / 2 

280 

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] 

285 

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 

292 

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 

296 

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 

300 

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 } 

307 

308 

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. 

315 

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}%") 

331 

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 ) 

343 

344 

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. 

354 

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. 

358 

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. 

365 

366 Returns: 

367 Matplotlib Figure object. 

368 

369 Raises: 

370 ImportError: If matplotlib is not available. 

371 ValueError: If axes has no associated figure. 

372 

373 Example: 

374 >>> fig = plot_bathtub(trace, bit_rate=1e9, ber_target=1e-12) 

375 

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") 

381 

382 # Placeholder implementation for bathtub curve 

383 # Full implementation would require statistical analysis of jitter 

384 # and noise distributions 

385 

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) 

393 

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) 

398 

399 ax.semilogy(ui, ber, linewidth=2, color="C0") 

400 ax.axhline(ber_target, color="red", linestyle="--", label=f"BER Target: {ber_target:.0e}") 

401 

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() 

407 

408 if title: 

409 ax.set_title(title) 

410 else: 

411 ax.set_title("Bathtub Curve") 

412 

413 fig.tight_layout() 

414 return fig 

415 

416 

417__all__ = [ 

418 "plot_bathtub", 

419 "plot_eye", 

420]