Coverage for src / tracekit / analyzers / jitter / measurements.py: 96%
121 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"""Jitter timing measurements.
3This module provides cycle-to-cycle jitter, period jitter, and
4duty cycle distortion measurements.
7Example:
8 >>> from tracekit.analyzers.jitter.measurements import cycle_to_cycle_jitter
9 >>> c2c = cycle_to_cycle_jitter(periods)
10 >>> print(f"C2C RMS: {c2c.c2c_rms * 1e12:.2f} ps")
12References:
13 IEEE 2414-2020: Standard for Jitter and Phase Noise
14"""
16from __future__ import annotations
18from dataclasses import dataclass
19from typing import TYPE_CHECKING
21import numpy as np
23from tracekit.core.exceptions import InsufficientDataError
24from tracekit.core.types import DigitalTrace, WaveformTrace
26if TYPE_CHECKING:
27 from numpy.typing import NDArray
30@dataclass
31class CycleJitterResult:
32 """Result of cycle-to-cycle or period jitter measurement.
34 Attributes:
35 c2c_rms: Cycle-to-cycle jitter RMS in seconds.
36 c2c_pp: Cycle-to-cycle jitter peak-to-peak in seconds.
37 c2c_values: Array of individual C2C jitter values.
38 period_mean: Mean period in seconds.
39 period_std: Standard deviation of periods in seconds.
40 n_cycles: Number of cycles analyzed.
41 histogram: Histogram of C2C values.
42 bin_centers: Bin centers for histogram.
43 """
45 c2c_rms: float
46 c2c_pp: float
47 c2c_values: NDArray[np.float64]
48 period_mean: float
49 period_std: float
50 n_cycles: int
51 histogram: NDArray[np.float64] | None = None
52 bin_centers: NDArray[np.float64] | None = None
55@dataclass
56class DutyCycleDistortionResult:
57 """Result of duty cycle distortion measurement.
59 Attributes:
60 dcd_seconds: DCD in seconds.
61 dcd_percent: DCD as percentage of period.
62 mean_high_time: Mean high time in seconds.
63 mean_low_time: Mean low time in seconds.
64 duty_cycle: Actual duty cycle as fraction (0.0 to 1.0).
65 period: Mean period in seconds.
66 n_cycles: Number of cycles analyzed.
67 """
69 dcd_seconds: float
70 dcd_percent: float
71 mean_high_time: float
72 mean_low_time: float
73 duty_cycle: float
74 period: float
75 n_cycles: int
78def tie_from_edges(
79 edge_timestamps: NDArray[np.float64],
80 nominal_period: float | None = None,
81) -> NDArray[np.float64]:
82 """Calculate Time Interval Error from edge timestamps.
84 TIE is the deviation of each edge from its ideal position
85 based on the recovered clock period.
87 Args:
88 edge_timestamps: Array of edge timestamps in seconds.
89 nominal_period: Expected period (computed from data if None).
91 Returns:
92 Array of TIE values in seconds.
94 Example:
95 >>> tie = tie_from_edges(rising_edges, nominal_period=1e-9)
96 >>> print(f"TIE range: {np.ptp(tie) * 1e12:.2f} ps")
97 """
98 if len(edge_timestamps) < 3:
99 return np.array([], dtype=np.float64)
101 # Calculate actual periods
102 periods = np.diff(edge_timestamps)
104 # Use mean period if nominal not provided
105 if nominal_period is None:
106 nominal_period = np.mean(periods)
108 # Calculate ideal edge positions
109 n_edges = len(edge_timestamps)
110 start_time = edge_timestamps[0]
111 ideal_positions = start_time + np.arange(n_edges) * nominal_period
113 # TIE is actual - ideal
114 tie: NDArray[np.float64] = edge_timestamps - ideal_positions
116 return tie
119def cycle_to_cycle_jitter(
120 periods: NDArray[np.float64],
121 *,
122 include_histogram: bool = True,
123 n_bins: int = 50,
124) -> CycleJitterResult:
125 """Measure cycle-to-cycle jitter for clock quality analysis.
127 Cycle-to-cycle jitter measures the variation in period from
128 one clock cycle to the next: C2C[n] = |Period[n] - Period[n-1]|
130 Args:
131 periods: Array of measured clock periods in seconds.
132 include_histogram: Include histogram in result.
133 n_bins: Number of histogram bins.
135 Returns:
136 CycleJitterResult with C2C jitter statistics.
138 Raises:
139 InsufficientDataError: If fewer than 3 periods provided.
141 Example:
142 >>> c2c = cycle_to_cycle_jitter(periods)
143 >>> print(f"C2C: {c2c.c2c_rms * 1e12:.2f} ps RMS")
145 References:
146 IEEE 2414-2020 Section 5.3
147 """
148 if len(periods) < 3:
149 raise InsufficientDataError(
150 "Cycle-to-cycle jitter requires at least 3 periods",
151 required=3,
152 available=len(periods),
153 analysis_type="cycle_to_cycle_jitter",
154 )
156 # Remove NaN values
157 valid_periods = periods[~np.isnan(periods)]
159 if len(valid_periods) < 3:
160 raise InsufficientDataError(
161 "Cycle-to-cycle jitter requires at least 3 valid periods",
162 required=3,
163 available=len(valid_periods),
164 analysis_type="cycle_to_cycle_jitter",
165 )
167 # Calculate cycle-to-cycle differences
168 c2c_values = np.abs(np.diff(valid_periods))
170 # Statistics
171 c2c_rms = float(np.sqrt(np.mean(c2c_values**2)))
172 c2c_pp = float(np.max(c2c_values) - np.min(c2c_values))
173 period_mean = float(np.mean(valid_periods))
174 period_std = float(np.std(valid_periods))
176 # Optional histogram
177 if include_histogram and len(c2c_values) > 10:
178 hist, bin_edges = np.histogram(c2c_values, bins=n_bins, density=True)
179 bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
180 else:
181 hist = None
182 bin_centers = None
184 return CycleJitterResult(
185 c2c_rms=c2c_rms,
186 c2c_pp=c2c_pp,
187 c2c_values=c2c_values,
188 period_mean=period_mean,
189 period_std=period_std,
190 n_cycles=len(valid_periods),
191 histogram=hist,
192 bin_centers=bin_centers,
193 )
196def period_jitter(
197 periods: NDArray[np.float64],
198 nominal_period: float | None = None,
199) -> CycleJitterResult:
200 """Measure period jitter (deviation from nominal period).
202 Period jitter is the deviation of each period from the ideal
203 or nominal period. Unlike C2C jitter, it measures absolute deviation.
205 Args:
206 periods: Array of measured clock periods in seconds.
207 nominal_period: Expected period (uses mean if None).
209 Returns:
210 CycleJitterResult with period jitter statistics.
212 Raises:
213 InsufficientDataError: If fewer than 2 periods provided.
215 Example:
216 >>> pj = period_jitter(periods, nominal_period=1e-9)
217 >>> print(f"Period jitter: {pj.c2c_rms * 1e12:.2f} ps RMS")
218 """
219 if len(periods) < 2:
220 raise InsufficientDataError(
221 "Period jitter requires at least 2 periods",
222 required=2,
223 available=len(periods),
224 analysis_type="period_jitter",
225 )
227 valid_periods = periods[~np.isnan(periods)]
229 if nominal_period is None:
230 nominal_period = np.mean(valid_periods)
232 # Calculate deviations from nominal
233 deviations = valid_periods - nominal_period
235 return CycleJitterResult(
236 c2c_rms=float(np.std(valid_periods)), # RMS of period variation
237 c2c_pp=float(np.max(valid_periods) - np.min(valid_periods)),
238 c2c_values=np.abs(deviations),
239 period_mean=float(np.mean(valid_periods)),
240 period_std=float(np.std(valid_periods)),
241 n_cycles=len(valid_periods),
242 )
245def measure_dcd(
246 trace: WaveformTrace | DigitalTrace,
247 clock_period: float | None = None,
248 *,
249 threshold: float = 0.5,
250) -> DutyCycleDistortionResult:
251 """Measure duty cycle distortion.
253 DCD measures the asymmetry between high and low times in a clock signal.
254 DCD = |mean_high_time - mean_low_time|
256 Args:
257 trace: Input waveform or digital trace.
258 clock_period: Expected clock period (computed if None).
259 threshold: Threshold level as fraction of amplitude (0.0-1.0).
261 Returns:
262 DutyCycleDistortionResult with DCD metrics.
264 Raises:
265 InsufficientDataError: If not enough edges found.
267 Example:
268 >>> dcd = measure_dcd(clock_trace, clock_period=1e-9)
269 >>> print(f"DCD: {dcd.dcd_percent:.1f}%")
271 References:
272 IEEE 2414-2020 Section 5.4
273 """
274 # Get edge timestamps
275 rising_edges, falling_edges = _find_edges(trace, threshold)
277 if len(rising_edges) < 2 or len(falling_edges) < 2:
278 raise InsufficientDataError(
279 "DCD measurement requires at least 2 rising and 2 falling edges",
280 required=4,
281 available=len(rising_edges) + len(falling_edges),
282 analysis_type="dcd_measurement",
283 )
285 # Measure high times (rising to falling)
286 high_times = []
287 for r_edge in rising_edges:
288 # Find next falling edge
289 next_falling = falling_edges[falling_edges > r_edge]
290 if len(next_falling) > 0:
291 high_times.append(next_falling[0] - r_edge)
293 # Measure low times (falling to rising)
294 low_times = []
295 for f_edge in falling_edges:
296 # Find next rising edge
297 next_rising = rising_edges[rising_edges > f_edge]
298 if len(next_rising) > 0:
299 low_times.append(next_rising[0] - f_edge)
301 if len(high_times) < 1 or len(low_times) < 1: 301 ↛ 302line 301 didn't jump to line 302 because the condition on line 301 was never true
302 raise InsufficientDataError(
303 "Could not measure high/low times",
304 required=2,
305 available=0,
306 analysis_type="dcd_measurement",
307 )
309 mean_high = float(np.mean(high_times))
310 mean_low = float(np.mean(low_times))
312 # Calculate DCD
313 dcd_seconds = abs(mean_high - mean_low)
314 period = mean_high + mean_low
316 if clock_period is None:
317 clock_period = period
319 dcd_percent = (dcd_seconds / clock_period) * 100
320 duty_cycle = mean_high / period
322 return DutyCycleDistortionResult(
323 dcd_seconds=dcd_seconds,
324 dcd_percent=dcd_percent,
325 mean_high_time=mean_high,
326 mean_low_time=mean_low,
327 duty_cycle=duty_cycle,
328 period=period,
329 n_cycles=min(len(high_times), len(low_times)),
330 )
333def _find_edges(
334 trace: WaveformTrace | DigitalTrace,
335 threshold_frac: float,
336) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
337 """Find rising and falling edge timestamps with sub-sample interpolation.
339 Args:
340 trace: Input trace.
341 threshold_frac: Threshold as fraction of amplitude.
343 Returns:
344 Tuple of (rising_edges, falling_edges) arrays in seconds.
345 """
346 data = trace.data.astype(np.float64) if isinstance(trace, DigitalTrace) else trace.data
348 sample_rate = trace.metadata.sample_rate
349 sample_period = 1.0 / sample_rate
351 if len(data) < 3:
352 return np.array([]), np.array([])
354 # Find amplitude levels - use more extreme percentiles for better accuracy
355 low = np.percentile(data, 5)
356 high = np.percentile(data, 95)
357 threshold = low + threshold_frac * (high - low)
359 # Find crossings
360 above = data >= threshold
361 below = data < threshold
363 rising_indices = np.where(below[:-1] & above[1:])[0]
364 falling_indices = np.where(above[:-1] & below[1:])[0]
366 # Convert to timestamps with linear interpolation
367 # For a crossing between samples i and i+1:
368 # time = i * dt + (threshold - v[i]) / (v[i+1] - v[i]) * dt
370 rising_edges = []
371 for idx in rising_indices:
372 v1, v2 = data[idx], data[idx + 1]
373 dv = v2 - v1
374 if abs(dv) > 1e-12: 374 ↛ 382line 374 didn't jump to line 382 because the condition on line 374 was always true
375 # Linear interpolation to find exact crossing time
376 frac = (threshold - v1) / dv
377 # Clamp to [0, 1] to handle numerical errors
378 frac = max(0.0, min(1.0, frac))
379 t_offset = frac * sample_period
380 else:
381 # Values are equal, use midpoint
382 t_offset = sample_period / 2
383 rising_edges.append(idx * sample_period + t_offset)
385 falling_edges = []
386 for idx in falling_indices:
387 v1, v2 = data[idx], data[idx + 1]
388 dv = v2 - v1
389 if abs(dv) > 1e-12: 389 ↛ 397line 389 didn't jump to line 397 because the condition on line 389 was always true
390 # Linear interpolation to find exact crossing time
391 frac = (threshold - v1) / dv
392 # Clamp to [0, 1] to handle numerical errors
393 frac = max(0.0, min(1.0, frac))
394 t_offset = frac * sample_period
395 else:
396 # Values are equal, use midpoint
397 t_offset = sample_period / 2
398 falling_edges.append(idx * sample_period + t_offset)
400 return (
401 np.array(rising_edges, dtype=np.float64),
402 np.array(falling_edges, dtype=np.float64),
403 )
406__all__ = [
407 "CycleJitterResult",
408 "DutyCycleDistortionResult",
409 "cycle_to_cycle_jitter",
410 "measure_dcd",
411 "period_jitter",
412 "tie_from_edges",
413]