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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""Eye diagram metrics and measurements.
3This module provides measurements on eye diagrams including height,
4width, Q-factor, and crossing percentage.
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)
13References:
14 IEEE 802.3: Ethernet Physical Layer Specifications
15 OIF CEI: Common Electrical I/O
16"""
18from __future__ import annotations
20from dataclasses import dataclass
21from typing import TYPE_CHECKING
23import numpy as np
24from scipy import special
26if TYPE_CHECKING:
27 from numpy.typing import NDArray
29 from tracekit.analyzers.eye.diagram import EyeDiagram
32@dataclass
33class EyeMetrics:
34 """Complete eye diagram measurement results.
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 """
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
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).
73 Measures the vertical distance between logic levels at the
74 specified horizontal position within the unit interval.
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.
81 Returns:
82 Eye height in volts (or input units).
84 Example:
85 >>> height = eye_height(eye)
86 >>> print(f"Eye height: {height * 1e3:.2f} mV")
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)
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
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]
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]
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]
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)
135 if sigma_high <= 0 or sigma_low <= 0:
136 return mu_high - mu_low # type: ignore[no-any-return]
138 # Q-factor for BER
139 q = np.sqrt(2) * special.erfcinv(2 * ber)
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)
144 return max(0.0, height) # type: ignore[no-any-return]
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).
155 Measures the horizontal opening at the decision threshold level.
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.
162 Returns:
163 Eye width in UI (0.0 to 1.0).
165 Example:
166 >>> width = eye_width(eye)
167 >>> print(f"Eye width: {width:.3f} UI")
169 References:
170 IEEE 802.3 Clause 68
171 """
172 data = eye.data
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
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
184 # Calculate separation at each time point
185 separations = []
186 time_indices = []
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]
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)
200 if len(separations) == 0:
201 return np.nan # type: ignore[no-any-return]
203 # Find contiguous region with good separation
204 if len(time_indices) < 2:
205 return float(len(time_indices)) / samples_per_ui
207 # Find the widest contiguous region
208 diffs = np.diff(time_indices)
209 gaps = np.where(diffs > 1)[0]
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)
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)
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)
236 return max(0.0, min(1.0, width_ui))
239def q_factor(eye: EyeDiagram, *, position: float = 0.5) -> float:
240 """Calculate Q-factor from eye diagram.
242 Q-factor measures signal quality:
243 Q = (mu_high - mu_low) / (sigma_high + sigma_low)
245 Higher Q indicates cleaner eye with better BER margin.
247 Args:
248 eye: Eye diagram data.
249 position: Horizontal position in UI for measurement.
251 Returns:
252 Q-factor value.
254 Example:
255 >>> q = q_factor(eye)
256 >>> print(f"Q-factor: {q:.2f}")
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)
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
270 voltages = eye.data[:, position_idx]
271 high_voltages = voltages[voltages > threshold]
272 low_voltages = voltages[voltages <= threshold]
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]
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)
295 denominator = sigma_high + sigma_low
297 if denominator <= 0:
298 return np.inf if mu_high > mu_low else np.nan # type: ignore[no-any-return]
300 q = (mu_high - mu_low) / denominator
302 return q # type: ignore[no-any-return]
305def crossing_percentage(eye: EyeDiagram) -> float:
306 """Measure eye crossing percentage.
308 The crossing percentage indicates where the eye crosses
309 vertically. Ideal is 50% (equal rise/fall times).
310 Deviation indicates duty cycle distortion.
312 Args:
313 eye: Eye diagram data.
315 Returns:
316 Crossing percentage (0.0 to 100.0).
318 Example:
319 >>> xing = crossing_percentage(eye)
320 >>> print(f"Crossing: {xing:.1f}%")
322 References:
323 OIF CEI 3.0 Section 5.3
324 """
325 data = eye.data
326 samples_per_ui = eye.samples_per_ui
328 # Find voltage range
329 all_low = np.percentile(data, 5)
330 all_high = np.percentile(data, 95)
331 amplitude = all_high - all_low
333 if amplitude <= 0:
334 return np.nan # type: ignore[no-any-return]
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
340 # Extract crossing voltages (at or near 0.5 UI and 1.5 UI)
341 crossing_voltages = []
343 for trace in data:
344 # Find zero-crossings in derivative (transitions)
345 diff = np.diff(trace)
347 # Find rising crossings
348 rising_mask = (diff[:-1] > 0) & (diff[1:] > 0)
349 rising_idx = np.where(rising_mask)[0]
351 for idx in rising_idx:
352 if abs(idx - center_idx) < samples_per_ui // 4:
353 crossing_voltages.append(trace[idx])
355 # Find falling crossings
356 falling_mask = (diff[:-1] < 0) & (diff[1:] < 0)
357 falling_idx = np.where(falling_mask)[0]
359 for idx in falling_idx:
360 if abs(idx - center_idx) < samples_per_ui // 4:
361 crossing_voltages.append(trace[idx])
363 if len(crossing_voltages) < 2:
364 # Fall back to simple median crossing level
365 np.percentile(data, 50)
366 return 50.0
368 crossing_voltage = np.mean(crossing_voltages)
370 # Calculate crossing percentage
371 crossing_percent = (crossing_voltage - all_low) / amplitude * 100
373 return crossing_percent # type: ignore[no-any-return]
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.
382 Creates nested contours showing the eye opening at different
383 BER levels, useful for margin analysis.
385 Args:
386 eye: Eye diagram data.
387 ber_levels: List of BER levels (default: [1e-3, 1e-6, 1e-9, 1e-12]).
389 Returns:
390 Dictionary mapping BER to (time_ui, voltage) contour arrays.
392 Example:
393 >>> contours = eye_contour(eye)
394 >>> for ber, (t, v) in contours.items():
395 ... print(f"BER {ber:.0e}: {len(t)} points")
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]
403 contours: dict[float, tuple[NDArray[np.float64], NDArray[np.float64]]] = {}
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
412 for ber in ber_levels:
413 # Q-factor for this BER
414 q = np.sqrt(2) * special.erfcinv(2 * ber)
416 upper_times = []
417 upper_voltages = []
418 lower_times = []
419 lower_voltages = []
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]
426 # Use global threshold
427 high_v = voltages[voltages > global_threshold]
428 low_v = voltages[voltages <= global_threshold]
430 # Need reasonable number of both high and low samples
431 if len(high_v) < 2 or len(low_v) < 2:
432 continue
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
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)
444 # Upper contour: mu_high - q * sigma_high
445 upper = mu_high - q * sigma_high
447 # Lower contour: mu_low + q * sigma_low
448 lower = mu_low + q * sigma_low
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)
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 )
463 contours[ber] = (contour_times, contour_voltages)
465 return contours
468def measure_eye(
469 eye: EyeDiagram,
470 *,
471 ber: float = 1e-12,
472) -> EyeMetrics:
473 """Compute comprehensive eye diagram measurements.
475 Args:
476 eye: Eye diagram data.
477 ber: BER level for extrapolated measurements.
479 Returns:
480 EyeMetrics with all measurements.
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]
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
499 high_v = center_voltages[center_voltages > threshold]
500 low_v = center_voltages[center_voltages <= threshold]
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)]
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
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)
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
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
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 )
547__all__ = [
548 "EyeMetrics",
549 "crossing_percentage",
550 "eye_contour",
551 "eye_height",
552 "eye_width",
553 "measure_eye",
554 "q_factor",
555]