Coverage for src / tracekit / analyzers / eye / metrics.py: 9%

221 statements  

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

1"""Eye diagram metrics and measurements. 

2 

3This module provides measurements on eye diagrams including height, 

4width, Q-factor, and crossing percentage. 

5 

6 

7Example: 

8 >>> from tracekit.analyzers.eye.metrics import eye_height, eye_width, q_factor 

9 >>> height = eye_height(eye_diagram) 

10 >>> width = eye_width(eye_diagram) 

11 >>> q = q_factor(eye_diagram) 

12 

13References: 

14 IEEE 802.3: Ethernet Physical Layer Specifications 

15 OIF CEI: Common Electrical I/O 

16""" 

17 

18from __future__ import annotations 

19 

20from dataclasses import dataclass 

21from typing import TYPE_CHECKING 

22 

23import numpy as np 

24from scipy import special 

25 

26if TYPE_CHECKING: 

27 from numpy.typing import NDArray 

28 

29 from tracekit.analyzers.eye.diagram import EyeDiagram 

30 

31 

32@dataclass 

33class EyeMetrics: 

34 """Complete eye diagram measurement results. 

35 

36 Attributes: 

37 height: Eye height in volts. 

38 height_at_ber: Eye height at specified BER. 

39 width: Eye width in UI. 

40 width_at_ber: Eye width at specified BER. 

41 q_factor: Signal quality factor. 

42 crossing_percent: Crossing percentage (ideal = 50%). 

43 mean_high: Mean logic high level. 

44 mean_low: Mean logic low level. 

45 sigma_high: Standard deviation of high level. 

46 sigma_low: Standard deviation of low level. 

47 snr: Signal-to-noise ratio in dB. 

48 ber_estimate: Estimated BER from Q-factor. 

49 """ 

50 

51 height: float 

52 height_at_ber: float | None 

53 width: float 

54 width_at_ber: float | None 

55 q_factor: float 

56 crossing_percent: float 

57 mean_high: float 

58 mean_low: float 

59 sigma_high: float 

60 sigma_low: float 

61 snr: float 

62 ber_estimate: float 

63 

64 

65def eye_height( 

66 eye: EyeDiagram, 

67 *, 

68 position: float = 0.5, 

69 ber: float | None = None, 

70) -> float: 

71 """Measure vertical eye opening (eye height). 

72 

73 Measures the vertical distance between logic levels at the 

74 specified horizontal position within the unit interval. 

75 

76 Args: 

77 eye: Eye diagram data. 

78 position: Horizontal position in UI (0.0 to 1.0, default 0.5 = center). 

79 ber: If specified, calculate height at this BER using Gaussian extrapolation. 

80 

81 Returns: 

82 Eye height in volts (or input units). 

83 

84 Example: 

85 >>> height = eye_height(eye) 

86 >>> print(f"Eye height: {height * 1e3:.2f} mV") 

87 

88 References: 

89 IEEE 802.3 Clause 68: 10GBASE-T PHY 

90 """ 

91 # Get samples at specified position 

92 samples_per_ui = eye.samples_per_ui 

93 position_idx = int(position * samples_per_ui) % len(eye.time_axis) 

94 

95 # Use global threshold to separate high from low logic levels 

96 all_data = eye.data.flatten() 

97 low_level = np.percentile(all_data, 10) 

98 high_level = np.percentile(all_data, 90) 

99 threshold = (low_level + high_level) / 2 

100 

101 # Extract voltage values at this position from all traces 

102 voltages = eye.data[:, position_idx] 

103 high_voltages = voltages[voltages > threshold] 

104 low_voltages = voltages[voltages <= threshold] 

105 

106 # If no eye opening at this position, search for a better position 

107 if len(high_voltages) == 0 or len(low_voltages) == 0: 

108 # Search all positions for one with both high and low samples 

109 for idx in range(len(eye.time_axis)): 

110 v = eye.data[:, idx] 

111 h_v = v[v > threshold] 

112 l_v = v[v <= threshold] 

113 if len(h_v) > 0 and len(l_v) > 0: 

114 # Found a position with eye opening 

115 high_voltages = h_v 

116 low_voltages = l_v 

117 break 

118 else: 

119 # No position found with eye opening 

120 return np.nan # type: ignore[no-any-return] 

121 

122 if ber is None: 

123 # Simple min-max eye height 

124 min_high = np.min(high_voltages) 

125 max_low = np.max(low_voltages) 

126 return max(0.0, min_high - max_low) # type: ignore[no-any-return] 

127 

128 else: 

129 # BER-extrapolated eye height 

130 mu_high = np.mean(high_voltages) 

131 mu_low = np.mean(low_voltages) 

132 sigma_high = np.std(high_voltages) 

133 sigma_low = np.std(low_voltages) 

134 

135 if sigma_high <= 0 or sigma_low <= 0: 

136 return mu_high - mu_low # type: ignore[no-any-return] 

137 

138 # Q-factor for BER 

139 q = np.sqrt(2) * special.erfcinv(2 * ber) 

140 

141 # Eye height at BER = (mu_high - q*sigma_high) - (mu_low + q*sigma_low) 

142 height = (mu_high - q * sigma_high) - (mu_low + q * sigma_low) 

143 

144 return max(0.0, height) # type: ignore[no-any-return] 

145 

146 

147def eye_width( 

148 eye: EyeDiagram, 

149 *, 

150 level: float = 0.5, 

151 ber: float | None = None, 

152) -> float: 

153 """Measure horizontal eye opening (eye width). 

154 

155 Measures the horizontal opening at the decision threshold level. 

156 

157 Args: 

158 eye: Eye diagram data. 

159 level: Vertical level as fraction (0.0 = low, 1.0 = high, default 0.5). 

160 ber: If specified, calculate width at this BER. 

161 

162 Returns: 

163 Eye width in UI (0.0 to 1.0). 

164 

165 Example: 

166 >>> width = eye_width(eye) 

167 >>> print(f"Eye width: {width:.3f} UI") 

168 

169 References: 

170 IEEE 802.3 Clause 68 

171 """ 

172 data = eye.data 

173 

174 # Calculate global threshold to separate logic levels 

175 all_data = data.flatten() 

176 low_level = np.percentile(all_data, 10) 

177 high_level = np.percentile(all_data, 90) 

178 global_threshold = (low_level + high_level) / 2 

179 

180 # For a 2-UI eye, we need to find the eye opening across all time positions 

181 # We look for the widest region where all traces are separated 

182 samples_per_ui = eye.samples_per_ui 

183 

184 # Calculate separation at each time point 

185 separations = [] 

186 time_indices = [] 

187 

188 for i in range(len(eye.time_axis)): 

189 voltages = data[:, i] 

190 high_v = voltages[voltages > global_threshold] 

191 low_v = voltages[voltages <= global_threshold] 

192 

193 if len(high_v) > 0 and len(low_v) > 0: 

194 # Measure separation between distributions 

195 separation = np.min(high_v) - np.max(low_v) 

196 if separation > 0: 

197 separations.append(separation) 

198 time_indices.append(i) 

199 

200 if len(separations) == 0: 

201 return np.nan # type: ignore[no-any-return] 

202 

203 # Find contiguous region with good separation 

204 if len(time_indices) < 2: 

205 return float(len(time_indices)) / samples_per_ui 

206 

207 # Find the widest contiguous region 

208 diffs = np.diff(time_indices) 

209 gaps = np.where(diffs > 1)[0] 

210 

211 if len(gaps) == 0: 

212 # All contiguous 

213 width_samples = len(time_indices) 

214 else: 

215 # Find longest contiguous segment 

216 segments = [] 

217 start = 0 

218 for gap in gaps: 

219 segments.append(gap + 1 - start) 

220 start = gap + 1 

221 segments.append(len(time_indices) - start) 

222 width_samples = max(segments) 

223 

224 # Width in UI (can be > 1.0 for 2-UI eyes) 

225 width_ui = width_samples / samples_per_ui 

226 # Clamp to 1.0 for single UI measurement 

227 width_ui = min(1.0, width_ui) 

228 

229 # Apply BER margin if requested 

230 if ber is not None and width_ui > 0: 

231 q = np.sqrt(2) * special.erfcinv(2 * ber) 

232 # Reduce width by jitter margin (rough approximation) 

233 jitter_reduction = 0.1 * q / 7.0 # Scale by Q/7 (Q=7 is ~1e-12 BER) 

234 width_ui = max(0.0, width_ui - jitter_reduction) 

235 

236 return max(0.0, min(1.0, width_ui)) 

237 

238 

239def q_factor(eye: EyeDiagram, *, position: float = 0.5) -> float: 

240 """Calculate Q-factor from eye diagram. 

241 

242 Q-factor measures signal quality: 

243 Q = (mu_high - mu_low) / (sigma_high + sigma_low) 

244 

245 Higher Q indicates cleaner eye with better BER margin. 

246 

247 Args: 

248 eye: Eye diagram data. 

249 position: Horizontal position in UI for measurement. 

250 

251 Returns: 

252 Q-factor value. 

253 

254 Example: 

255 >>> q = q_factor(eye) 

256 >>> print(f"Q-factor: {q:.2f}") 

257 

258 References: 

259 IEEE 802.3 Clause 52 

260 """ 

261 samples_per_ui = eye.samples_per_ui 

262 position_idx = int(position * samples_per_ui) % len(eye.time_axis) 

263 

264 # Use global threshold to separate high from low logic levels 

265 all_data = eye.data.flatten() 

266 low_level = np.percentile(all_data, 10) 

267 high_level = np.percentile(all_data, 90) 

268 threshold = (low_level + high_level) / 2 

269 

270 voltages = eye.data[:, position_idx] 

271 high_voltages = voltages[voltages > threshold] 

272 low_voltages = voltages[voltages <= threshold] 

273 

274 # If no eye opening at this position, search for a better position 

275 if len(high_voltages) < 2 or len(low_voltages) < 2: 

276 # Search all positions for one with both high and low samples 

277 for idx in range(len(eye.time_axis)): 

278 v = eye.data[:, idx] 

279 h_v = v[v > threshold] 

280 l_v = v[v <= threshold] 

281 if len(h_v) >= 2 and len(l_v) >= 2: 

282 # Found a position with eye opening 

283 high_voltages = h_v 

284 low_voltages = l_v 

285 break 

286 else: 

287 # No position found with eye opening 

288 return np.nan # type: ignore[no-any-return] 

289 

290 mu_high = np.mean(high_voltages) 

291 mu_low = np.mean(low_voltages) 

292 sigma_high = np.std(high_voltages) 

293 sigma_low = np.std(low_voltages) 

294 

295 denominator = sigma_high + sigma_low 

296 

297 if denominator <= 0: 

298 return np.inf if mu_high > mu_low else np.nan # type: ignore[no-any-return] 

299 

300 q = (mu_high - mu_low) / denominator 

301 

302 return q # type: ignore[no-any-return] 

303 

304 

305def crossing_percentage(eye: EyeDiagram) -> float: 

306 """Measure eye crossing percentage. 

307 

308 The crossing percentage indicates where the eye crosses 

309 vertically. Ideal is 50% (equal rise/fall times). 

310 Deviation indicates duty cycle distortion. 

311 

312 Args: 

313 eye: Eye diagram data. 

314 

315 Returns: 

316 Crossing percentage (0.0 to 100.0). 

317 

318 Example: 

319 >>> xing = crossing_percentage(eye) 

320 >>> print(f"Crossing: {xing:.1f}%") 

321 

322 References: 

323 OIF CEI 3.0 Section 5.3 

324 """ 

325 data = eye.data 

326 samples_per_ui = eye.samples_per_ui 

327 

328 # Find voltage range 

329 all_low = np.percentile(data, 5) 

330 all_high = np.percentile(data, 95) 

331 amplitude = all_high - all_low 

332 

333 if amplitude <= 0: 

334 return np.nan # type: ignore[no-any-return] 

335 

336 # Find crossing points (where traces cross the center time) 

337 # Look at the rising and falling edges 

338 center_idx = samples_per_ui // 2 

339 

340 # Extract crossing voltages (at or near 0.5 UI and 1.5 UI) 

341 crossing_voltages = [] 

342 

343 for trace in data: 

344 # Find zero-crossings in derivative (transitions) 

345 diff = np.diff(trace) 

346 

347 # Find rising crossings 

348 rising_mask = (diff[:-1] > 0) & (diff[1:] > 0) 

349 rising_idx = np.where(rising_mask)[0] 

350 

351 for idx in rising_idx: 

352 if abs(idx - center_idx) < samples_per_ui // 4: 

353 crossing_voltages.append(trace[idx]) 

354 

355 # Find falling crossings 

356 falling_mask = (diff[:-1] < 0) & (diff[1:] < 0) 

357 falling_idx = np.where(falling_mask)[0] 

358 

359 for idx in falling_idx: 

360 if abs(idx - center_idx) < samples_per_ui // 4: 

361 crossing_voltages.append(trace[idx]) 

362 

363 if len(crossing_voltages) < 2: 

364 # Fall back to simple median crossing level 

365 np.percentile(data, 50) 

366 return 50.0 

367 

368 crossing_voltage = np.mean(crossing_voltages) 

369 

370 # Calculate crossing percentage 

371 crossing_percent = (crossing_voltage - all_low) / amplitude * 100 

372 

373 return crossing_percent # type: ignore[no-any-return] 

374 

375 

376def eye_contour( 

377 eye: EyeDiagram, 

378 ber_levels: list[float] | None = None, 

379) -> dict[float, tuple[NDArray[np.float64], NDArray[np.float64]]]: 

380 """Generate eye contour polygons at various BER levels. 

381 

382 Creates nested contours showing the eye opening at different 

383 BER levels, useful for margin analysis. 

384 

385 Args: 

386 eye: Eye diagram data. 

387 ber_levels: List of BER levels (default: [1e-3, 1e-6, 1e-9, 1e-12]). 

388 

389 Returns: 

390 Dictionary mapping BER to (time_ui, voltage) contour arrays. 

391 

392 Example: 

393 >>> contours = eye_contour(eye) 

394 >>> for ber, (t, v) in contours.items(): 

395 ... print(f"BER {ber:.0e}: {len(t)} points") 

396 

397 References: 

398 OIF CEI: Eye Contour Methodology 

399 """ 

400 if ber_levels is None: 

401 ber_levels = [1e-3, 1e-6, 1e-9, 1e-12] 

402 

403 contours: dict[float, tuple[NDArray[np.float64], NDArray[np.float64]]] = {} 

404 

405 # Use global threshold to separate high from low logic levels 

406 # Use mean of 10th and 90th percentiles to handle skewed distributions 

407 all_data = eye.data.flatten() 

408 low_level = np.percentile(all_data, 10) 

409 high_level = np.percentile(all_data, 90) 

410 global_threshold = (low_level + high_level) / 2 

411 

412 for ber in ber_levels: 

413 # Q-factor for this BER 

414 q = np.sqrt(2) * special.erfcinv(2 * ber) 

415 

416 upper_times = [] 

417 upper_voltages = [] 

418 lower_times = [] 

419 lower_voltages = [] 

420 

421 # Calculate eye opening at each time position across all UIs 

422 for i in range(len(eye.time_axis)): 

423 t_ui = eye.time_axis[i] 

424 voltages = eye.data[:, i] 

425 

426 # Use global threshold 

427 high_v = voltages[voltages > global_threshold] 

428 low_v = voltages[voltages <= global_threshold] 

429 

430 # Need reasonable number of both high and low samples 

431 if len(high_v) < 2 or len(low_v) < 2: 

432 continue 

433 

434 # Skip if distribution is too skewed (likely transition region) 

435 total = len(high_v) + len(low_v) 

436 if len(high_v) < total * 0.2 or len(low_v) < total * 0.2: 

437 continue 

438 

439 mu_high = np.mean(high_v) 

440 sigma_high = np.std(high_v) 

441 mu_low = np.mean(low_v) 

442 sigma_low = np.std(low_v) 

443 

444 # Upper contour: mu_high - q * sigma_high 

445 upper = mu_high - q * sigma_high 

446 

447 # Lower contour: mu_low + q * sigma_low 

448 lower = mu_low + q * sigma_low 

449 

450 if upper > lower: 

451 upper_times.append(t_ui) 

452 upper_voltages.append(upper) 

453 lower_times.append(t_ui) 

454 lower_voltages.append(lower) 

455 

456 if len(upper_times) > 0: 

457 # Create closed contour: upper trace forward, lower trace backward 

458 contour_times = np.concatenate([np.array(upper_times), np.array(lower_times[::-1])]) 

459 contour_voltages = np.concatenate( 

460 [np.array(upper_voltages), np.array(lower_voltages[::-1])] 

461 ) 

462 

463 contours[ber] = (contour_times, contour_voltages) 

464 

465 return contours 

466 

467 

468def measure_eye( 

469 eye: EyeDiagram, 

470 *, 

471 ber: float = 1e-12, 

472) -> EyeMetrics: 

473 """Compute comprehensive eye diagram measurements. 

474 

475 Args: 

476 eye: Eye diagram data. 

477 ber: BER level for extrapolated measurements. 

478 

479 Returns: 

480 EyeMetrics with all measurements. 

481 

482 Example: 

483 >>> metrics = measure_eye(eye) 

484 >>> print(f"Height: {metrics.height * 1e3:.2f} mV") 

485 >>> print(f"Width: {metrics.width:.3f} UI") 

486 >>> print(f"Q-factor: {metrics.q_factor:.2f}") 

487 """ 

488 # Get samples at center 

489 samples_per_ui = eye.samples_per_ui 

490 center_idx = samples_per_ui // 2 

491 center_voltages = eye.data[:, center_idx] 

492 

493 # Use global threshold to separate logic levels 

494 all_data = eye.data.flatten() 

495 low_level = np.percentile(all_data, 10) 

496 high_level = np.percentile(all_data, 90) 

497 threshold = (low_level + high_level) / 2 

498 

499 high_v = center_voltages[center_voltages > threshold] 

500 low_v = center_voltages[center_voltages <= threshold] 

501 

502 if len(high_v) < 2: 

503 high_v = center_voltages[center_voltages >= np.percentile(center_voltages, 75)] 

504 if len(low_v) < 2: 

505 low_v = center_voltages[center_voltages <= np.percentile(center_voltages, 25)] 

506 

507 mean_high = float(np.mean(high_v)) if len(high_v) > 0 else np.nan 

508 mean_low = float(np.mean(low_v)) if len(low_v) > 0 else np.nan 

509 sigma_high = float(np.std(high_v)) if len(high_v) > 0 else np.nan 

510 sigma_low = float(np.std(low_v)) if len(low_v) > 0 else np.nan 

511 

512 # Calculate metrics 

513 height = eye_height(eye) 

514 height_at_ber = eye_height(eye, ber=ber) 

515 width = eye_width(eye) 

516 width_at_ber = eye_width(eye, ber=ber) 

517 q = q_factor(eye) 

518 xing = crossing_percentage(eye) 

519 

520 # SNR 

521 amplitude = mean_high - mean_low 

522 noise_rms = np.sqrt((sigma_high**2 + sigma_low**2) / 2) 

523 if noise_rms > 0 and amplitude > 0: 

524 snr = 20 * np.log10(amplitude / noise_rms) 

525 else: 

526 snr = np.inf if amplitude > 0 else np.nan 

527 

528 # BER estimate from Q-factor 

529 ber_estimate = 0.5 * special.erfc(q / np.sqrt(2)) if q > 0 and np.isfinite(q) else 0.5 

530 

531 return EyeMetrics( 

532 height=height, 

533 height_at_ber=height_at_ber, 

534 width=width, 

535 width_at_ber=width_at_ber, 

536 q_factor=q, 

537 crossing_percent=xing, 

538 mean_high=mean_high, 

539 mean_low=mean_low, 

540 sigma_high=sigma_high, 

541 sigma_low=sigma_low, 

542 snr=snr, 

543 ber_estimate=ber_estimate, 

544 ) 

545 

546 

547__all__ = [ 

548 "EyeMetrics", 

549 "crossing_percentage", 

550 "eye_contour", 

551 "eye_height", 

552 "eye_width", 

553 "measure_eye", 

554 "q_factor", 

555]