Coverage for src / tracekit / analyzers / eye / diagram.py: 96%

131 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 23:04 +0000

1"""Eye diagram generation from serial data. 

2 

3This module generates eye diagrams by folding waveform data 

4at the unit interval boundary. 

5 

6Example: 

7 >>> from tracekit.analyzers.eye.diagram import generate_eye 

8 >>> eye = generate_eye(trace, unit_interval=1e-9) 

9 >>> print(f"Eye diagram: {eye.n_traces} traces, {eye.samples_per_ui} samples/UI") 

10 

11References: 

12 IEEE 802.3: Ethernet Physical Layer Specifications 

13""" 

14 

15from __future__ import annotations 

16 

17from dataclasses import dataclass 

18from typing import TYPE_CHECKING 

19 

20import numpy as np 

21 

22from tracekit.core.exceptions import AnalysisError, InsufficientDataError 

23 

24if TYPE_CHECKING: 

25 from numpy.typing import NDArray 

26 

27 from tracekit.core.types import WaveformTrace 

28 

29 

30@dataclass 

31class EyeDiagram: 

32 """Eye diagram data structure. 

33 

34 Attributes: 

35 data: 2D array of eye traces (n_traces x samples_per_ui). 

36 time_axis: Time axis in UI (0.0 to 2.0 for 2-UI eye). 

37 unit_interval: Unit interval in seconds. 

38 samples_per_ui: Number of samples per unit interval. 

39 n_traces: Number of overlaid traces. 

40 sample_rate: Original sample rate in Hz. 

41 histogram: Optional 2D histogram (voltage x time bins). 

42 voltage_bins: Bin edges for voltage axis. 

43 time_bins: Bin edges for time axis. 

44 """ 

45 

46 data: NDArray[np.float64] 

47 time_axis: NDArray[np.float64] 

48 unit_interval: float 

49 samples_per_ui: int 

50 n_traces: int 

51 sample_rate: float 

52 histogram: NDArray[np.float64] | None = None 

53 voltage_bins: NDArray[np.float64] | None = None 

54 time_bins: NDArray[np.float64] | None = None 

55 

56 

57def generate_eye( 

58 trace: WaveformTrace, 

59 unit_interval: float, 

60 *, 

61 n_ui: int = 2, 

62 trigger_level: float = 0.5, 

63 trigger_edge: str = "rising", 

64 max_traces: int | None = None, 

65 generate_histogram: bool = True, 

66 histogram_bins: tuple[int, int] = (100, 100), 

67) -> EyeDiagram: 

68 """Generate eye diagram from waveform data. 

69 

70 Folds the waveform at unit interval boundaries to create 

71 an overlaid eye pattern for signal quality analysis. 

72 

73 Args: 

74 trace: Input waveform trace. 

75 unit_interval: Unit interval (bit period) in seconds. 

76 n_ui: Number of unit intervals to display (1 or 2). 

77 trigger_level: Trigger level as fraction of amplitude. 

78 trigger_edge: Trigger on "rising" or "falling" edges. 

79 max_traces: Maximum number of traces to include. 

80 generate_histogram: Generate 2D histogram for persistence. 

81 histogram_bins: (voltage_bins, time_bins) for histogram. 

82 

83 Returns: 

84 EyeDiagram with overlaid traces and optional histogram. 

85 

86 Raises: 

87 AnalysisError: If unit interval is too short. 

88 InsufficientDataError: If not enough data for eye generation. 

89 

90 Example: 

91 >>> eye = generate_eye(trace, unit_interval=1e-9) 

92 >>> print(f"Generated {eye.n_traces} traces") 

93 

94 References: 

95 OIF CEI: Common Electrical I/O Eye Diagram Methodology 

96 """ 

97 data = trace.data 

98 sample_rate = trace.metadata.sample_rate 

99 1.0 / sample_rate 

100 

101 # Calculate samples per UI 

102 samples_per_ui = round(unit_interval * sample_rate) 

103 

104 if samples_per_ui < 4: 

105 raise AnalysisError( 

106 f"Unit interval too short: {samples_per_ui} samples/UI. Need at least 4 samples per UI." 

107 ) 

108 

109 n_samples = len(data) 

110 total_ui_samples = samples_per_ui * n_ui 

111 

112 if n_samples < total_ui_samples * 2: 

113 raise InsufficientDataError( 

114 f"Need at least {total_ui_samples * 2} samples for eye diagram", 

115 required=total_ui_samples * 2, 

116 available=n_samples, 

117 analysis_type="eye_diagram_generation", 

118 ) 

119 

120 # Find trigger points 

121 low = np.percentile(data, 10) 

122 high = np.percentile(data, 90) 

123 threshold = low + trigger_level * (high - low) 

124 

125 if trigger_edge == "rising": 

126 trigger_mask = (data[:-1] < threshold) & (data[1:] >= threshold) 

127 else: 

128 trigger_mask = (data[:-1] >= threshold) & (data[1:] < threshold) 

129 

130 trigger_indices = np.where(trigger_mask)[0] 

131 

132 if len(trigger_indices) < 2: 

133 raise InsufficientDataError( 

134 "Not enough trigger events for eye diagram", 

135 required=2, 

136 available=len(trigger_indices), 

137 analysis_type="eye_diagram_generation", 

138 ) 

139 

140 # Extract eye traces 

141 eye_traces = [] 

142 half_ui = samples_per_ui // 2 # Start half UI before trigger 

143 

144 for trig_idx in trigger_indices: 

145 start_idx = trig_idx - half_ui 

146 end_idx = start_idx + total_ui_samples 

147 

148 if start_idx >= 0 and end_idx <= n_samples: 

149 eye_traces.append(data[start_idx:end_idx]) 

150 

151 if max_traces is not None and len(eye_traces) >= max_traces: 

152 break 

153 

154 if len(eye_traces) == 0: 154 ↛ 155line 154 didn't jump to line 155 because the condition on line 154 was never true

155 raise InsufficientDataError( 

156 "Could not extract any complete eye traces", 

157 required=1, 

158 available=0, 

159 analysis_type="eye_diagram_generation", 

160 ) 

161 

162 # Stack into 2D array 

163 eye_data = np.array(eye_traces, dtype=np.float64) 

164 

165 # Generate time axis in UI 

166 time_axis = np.linspace(0, n_ui, total_ui_samples, endpoint=False) 

167 

168 # Optional: Generate 2D histogram 

169 histogram = None 

170 voltage_bins = None 

171 time_bins = None 

172 

173 if generate_histogram: 

174 # Flatten for histogram 

175 all_voltages = eye_data.flatten() 

176 all_times = np.tile(time_axis, len(eye_traces)) 

177 

178 # Create histogram 

179 voltage_range = (np.min(all_voltages), np.max(all_voltages)) 

180 time_range = (0, n_ui) 

181 

182 histogram, voltage_edges, time_edges = np.histogram2d( 

183 all_voltages, 

184 all_times, 

185 bins=histogram_bins, 

186 range=[voltage_range, time_range], 

187 ) 

188 

189 voltage_bins = voltage_edges 

190 time_bins = time_edges 

191 

192 return EyeDiagram( 

193 data=eye_data, 

194 time_axis=time_axis, 

195 unit_interval=unit_interval, 

196 samples_per_ui=samples_per_ui, 

197 n_traces=len(eye_traces), 

198 sample_rate=sample_rate, 

199 histogram=histogram, 

200 voltage_bins=voltage_bins, 

201 time_bins=time_bins, 

202 ) 

203 

204 

205def generate_eye_from_edges( 

206 trace: WaveformTrace, 

207 edge_timestamps: NDArray[np.float64], 

208 *, 

209 n_ui: int = 2, 

210 samples_per_ui: int = 100, 

211 max_traces: int | None = None, 

212) -> EyeDiagram: 

213 """Generate eye diagram using recovered clock edges. 

214 

215 Uses pre-recovered clock edges for triggering, which can provide 

216 more accurate alignment than threshold-based triggering. 

217 

218 Args: 

219 trace: Input waveform trace. 

220 edge_timestamps: Array of clock edge timestamps in seconds. 

221 n_ui: Number of unit intervals to display. 

222 samples_per_ui: Samples per UI in resampled eye. 

223 max_traces: Maximum traces to include. 

224 

225 Returns: 

226 EyeDiagram with overlaid traces. 

227 

228 Raises: 

229 InsufficientDataError: If not enough edge timestamps or traces. 

230 

231 Example: 

232 >>> edges = recover_clock_edges(trace) 

233 >>> eye = generate_eye_from_edges(trace, edges) 

234 """ 

235 data = trace.data 

236 sample_rate = trace.metadata.sample_rate 

237 

238 if len(edge_timestamps) < 3: 

239 raise InsufficientDataError( 

240 "Need at least 3 edge timestamps", 

241 required=3, 

242 available=len(edge_timestamps), 

243 analysis_type="eye_diagram_generation", 

244 ) 

245 

246 # Calculate unit interval from edges 

247 periods = np.diff(edge_timestamps) 

248 unit_interval = float(np.median(periods)) 

249 

250 # Create time vector for original data 

251 original_time = np.arange(len(data)) / sample_rate 

252 

253 # Extract and resample traces around each edge 

254 eye_traces = [] 

255 total_samples = samples_per_ui * n_ui 

256 half_ui = unit_interval / 2 

257 

258 for edge_time in edge_timestamps: 

259 # Define window around edge 

260 start_time = edge_time - half_ui 

261 end_time = start_time + unit_interval * n_ui 

262 

263 if start_time < 0 or end_time > original_time[-1]: 

264 continue 

265 

266 # Find samples within window 

267 mask = (original_time >= start_time) & (original_time <= end_time) 

268 window_time = original_time[mask] - start_time 

269 window_data = data[mask] 

270 

271 if len(window_data) < 4: 271 ↛ 272line 271 didn't jump to line 272 because the condition on line 271 was never true

272 continue 

273 

274 # Resample to consistent samples_per_ui 

275 resample_time = np.linspace(0, unit_interval * n_ui, total_samples) 

276 resampled = np.interp(resample_time, window_time, window_data) 

277 

278 eye_traces.append(resampled) 

279 

280 if max_traces is not None and len(eye_traces) >= max_traces: 

281 break 

282 

283 if len(eye_traces) == 0: 

284 raise InsufficientDataError( 

285 "Could not extract any eye traces", 

286 required=1, 

287 available=0, 

288 analysis_type="eye_diagram_generation", 

289 ) 

290 

291 eye_data = np.array(eye_traces, dtype=np.float64) 

292 time_axis = np.linspace(0, n_ui, total_samples, endpoint=False) 

293 

294 return EyeDiagram( 

295 data=eye_data, 

296 time_axis=time_axis, 

297 unit_interval=unit_interval, 

298 samples_per_ui=samples_per_ui, 

299 n_traces=len(eye_traces), 

300 sample_rate=sample_rate, 

301 ) 

302 

303 

304def auto_center_eye_diagram( 

305 eye: EyeDiagram, 

306 *, 

307 trigger_fraction: float = 0.5, 

308 symmetric_range: bool = True, 

309) -> EyeDiagram: 

310 """Auto-center eye diagram on optimal crossing point. 

311 

312 Automatically centers eye diagrams on the optimal trigger point 

313 and scales amplitude for maximum eye opening visibility with 

314 symmetric vertical centering. 

315 

316 Args: 

317 eye: Input EyeDiagram to center. 

318 trigger_fraction: Trigger level as fraction of amplitude (default 0.5 = 50%). 

319 symmetric_range: Use symmetric amplitude range ±max(abs(signal)). 

320 

321 Returns: 

322 Centered EyeDiagram with adjusted data. 

323 

324 Raises: 

325 ValueError: If trigger_fraction is not in [0, 1]. 

326 

327 Example: 

328 >>> eye = generate_eye(trace, unit_interval=1e-9) 

329 >>> centered = auto_center_eye_diagram(eye) 

330 >>> # Centered at 50% crossing with symmetric amplitude 

331 

332 References: 

333 VIS-021: Eye Diagram Auto-Centering 

334 """ 

335 if not 0 <= trigger_fraction <= 1: 

336 raise ValueError(f"trigger_fraction must be in [0, 1], got {trigger_fraction}") 

337 

338 data = eye.data 

339 

340 # Calculate optimal trigger point using histogram-based threshold 

341 # Find median value (represents middle level) 

342 np.median(data) 

343 

344 # Calculate amplitude range 

345 low = np.percentile(data, 10) 

346 high = np.percentile(data, 90) 

347 amplitude_range = high - low 

348 

349 # Trigger threshold at specified fraction 

350 threshold = low + trigger_fraction * amplitude_range 

351 

352 # Find crossing points for each trace 

353 # A crossing is where signal crosses threshold 

354 n_traces, samples_per_trace = data.shape 

355 crossing_indices = [] 

356 

357 for trace_idx in range(n_traces): 

358 trace = data[trace_idx, :] 

359 

360 # Find zero-crossings relative to threshold 

361 crossings = np.where((trace[:-1] < threshold) & (trace[1:] >= threshold))[0] 

362 

363 if len(crossings) > 0: 

364 # Use first crossing in this trace 

365 crossing_indices.append(crossings[0]) 

366 

367 if len(crossing_indices) == 0: 

368 # No crossings found, return original 

369 import warnings 

370 

371 warnings.warn( 

372 "No crossing points found, cannot auto-center eye diagram", 

373 UserWarning, 

374 stacklevel=2, 

375 ) 

376 return eye 

377 

378 # Calculate median crossing position 

379 int(np.median(crossing_indices)) 

380 

381 # Align all traces to common crossing point 

382 # This requires resampling/shifting each trace 

383 aligned_data = np.zeros_like(data) 

384 target_crossing = samples_per_trace // 2 # Center of trace 

385 

386 for trace_idx in range(n_traces): 

387 trace = data[trace_idx, :] 

388 

389 # Find crossing for this trace 

390 crossings = np.where((trace[:-1] < threshold) & (trace[1:] >= threshold))[0] 

391 

392 if len(crossings) > 0: 

393 crossing = crossings[0] 

394 shift = target_crossing - crossing 

395 

396 # Shift trace by interpolation 

397 if shift != 0: 397 ↛ 401line 397 didn't jump to line 401 because the condition on line 397 was always true

398 # Simple roll (circular shift) 

399 aligned_data[trace_idx, :] = np.roll(trace, shift) 

400 else: 

401 aligned_data[trace_idx, :] = trace 

402 else: 

403 # No crossing, keep original 

404 aligned_data[trace_idx, :] = trace 

405 

406 # Scale amplitude to symmetric range if requested 

407 if symmetric_range: 

408 max_abs = np.max(np.abs(aligned_data)) 

409 if max_abs > 0: 409 ↛ 416line 409 didn't jump to line 416 because the condition on line 409 was always true

410 # Center on zero 

411 aligned_data = aligned_data - np.mean(aligned_data) 

412 # Scale to ±max for symmetric range 

413 # No additional scaling needed, data already centered 

414 

415 # Create centered eye diagram 

416 return EyeDiagram( 

417 data=aligned_data, 

418 time_axis=eye.time_axis, 

419 unit_interval=eye.unit_interval, 

420 samples_per_ui=eye.samples_per_ui, 

421 n_traces=eye.n_traces, 

422 sample_rate=eye.sample_rate, 

423 histogram=None, # Invalidate histogram after centering 

424 voltage_bins=None, 

425 time_bins=None, 

426 ) 

427 

428 

429__all__ = [ 

430 "EyeDiagram", 

431 "auto_center_eye_diagram", 

432 "generate_eye", 

433 "generate_eye_from_edges", 

434]