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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""Eye diagram generation from serial data.
3This module generates eye diagrams by folding waveform data
4at the unit interval boundary.
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")
11References:
12 IEEE 802.3: Ethernet Physical Layer Specifications
13"""
15from __future__ import annotations
17from dataclasses import dataclass
18from typing import TYPE_CHECKING
20import numpy as np
22from tracekit.core.exceptions import AnalysisError, InsufficientDataError
24if TYPE_CHECKING:
25 from numpy.typing import NDArray
27 from tracekit.core.types import WaveformTrace
30@dataclass
31class EyeDiagram:
32 """Eye diagram data structure.
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 """
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
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.
70 Folds the waveform at unit interval boundaries to create
71 an overlaid eye pattern for signal quality analysis.
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.
83 Returns:
84 EyeDiagram with overlaid traces and optional histogram.
86 Raises:
87 AnalysisError: If unit interval is too short.
88 InsufficientDataError: If not enough data for eye generation.
90 Example:
91 >>> eye = generate_eye(trace, unit_interval=1e-9)
92 >>> print(f"Generated {eye.n_traces} traces")
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
101 # Calculate samples per UI
102 samples_per_ui = round(unit_interval * sample_rate)
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 )
109 n_samples = len(data)
110 total_ui_samples = samples_per_ui * n_ui
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 )
120 # Find trigger points
121 low = np.percentile(data, 10)
122 high = np.percentile(data, 90)
123 threshold = low + trigger_level * (high - low)
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)
130 trigger_indices = np.where(trigger_mask)[0]
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 )
140 # Extract eye traces
141 eye_traces = []
142 half_ui = samples_per_ui // 2 # Start half UI before trigger
144 for trig_idx in trigger_indices:
145 start_idx = trig_idx - half_ui
146 end_idx = start_idx + total_ui_samples
148 if start_idx >= 0 and end_idx <= n_samples:
149 eye_traces.append(data[start_idx:end_idx])
151 if max_traces is not None and len(eye_traces) >= max_traces:
152 break
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 )
162 # Stack into 2D array
163 eye_data = np.array(eye_traces, dtype=np.float64)
165 # Generate time axis in UI
166 time_axis = np.linspace(0, n_ui, total_ui_samples, endpoint=False)
168 # Optional: Generate 2D histogram
169 histogram = None
170 voltage_bins = None
171 time_bins = None
173 if generate_histogram:
174 # Flatten for histogram
175 all_voltages = eye_data.flatten()
176 all_times = np.tile(time_axis, len(eye_traces))
178 # Create histogram
179 voltage_range = (np.min(all_voltages), np.max(all_voltages))
180 time_range = (0, n_ui)
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 )
189 voltage_bins = voltage_edges
190 time_bins = time_edges
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 )
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.
215 Uses pre-recovered clock edges for triggering, which can provide
216 more accurate alignment than threshold-based triggering.
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.
225 Returns:
226 EyeDiagram with overlaid traces.
228 Raises:
229 InsufficientDataError: If not enough edge timestamps or traces.
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
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 )
246 # Calculate unit interval from edges
247 periods = np.diff(edge_timestamps)
248 unit_interval = float(np.median(periods))
250 # Create time vector for original data
251 original_time = np.arange(len(data)) / sample_rate
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
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
263 if start_time < 0 or end_time > original_time[-1]:
264 continue
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]
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
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)
278 eye_traces.append(resampled)
280 if max_traces is not None and len(eye_traces) >= max_traces:
281 break
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 )
291 eye_data = np.array(eye_traces, dtype=np.float64)
292 time_axis = np.linspace(0, n_ui, total_samples, endpoint=False)
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 )
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.
312 Automatically centers eye diagrams on the optimal trigger point
313 and scales amplitude for maximum eye opening visibility with
314 symmetric vertical centering.
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)).
321 Returns:
322 Centered EyeDiagram with adjusted data.
324 Raises:
325 ValueError: If trigger_fraction is not in [0, 1].
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
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}")
338 data = eye.data
340 # Calculate optimal trigger point using histogram-based threshold
341 # Find median value (represents middle level)
342 np.median(data)
344 # Calculate amplitude range
345 low = np.percentile(data, 10)
346 high = np.percentile(data, 90)
347 amplitude_range = high - low
349 # Trigger threshold at specified fraction
350 threshold = low + trigger_fraction * amplitude_range
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 = []
357 for trace_idx in range(n_traces):
358 trace = data[trace_idx, :]
360 # Find zero-crossings relative to threshold
361 crossings = np.where((trace[:-1] < threshold) & (trace[1:] >= threshold))[0]
363 if len(crossings) > 0:
364 # Use first crossing in this trace
365 crossing_indices.append(crossings[0])
367 if len(crossing_indices) == 0:
368 # No crossings found, return original
369 import warnings
371 warnings.warn(
372 "No crossing points found, cannot auto-center eye diagram",
373 UserWarning,
374 stacklevel=2,
375 )
376 return eye
378 # Calculate median crossing position
379 int(np.median(crossing_indices))
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
386 for trace_idx in range(n_traces):
387 trace = data[trace_idx, :]
389 # Find crossing for this trace
390 crossings = np.where((trace[:-1] < threshold) & (trace[1:] >= threshold))[0]
392 if len(crossings) > 0:
393 crossing = crossings[0]
394 shift = target_crossing - crossing
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
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
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 )
429__all__ = [
430 "EyeDiagram",
431 "auto_center_eye_diagram",
432 "generate_eye",
433 "generate_eye_from_edges",
434]