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

1"""Jitter timing measurements. 

2 

3This module provides cycle-to-cycle jitter, period jitter, and 

4duty cycle distortion measurements. 

5 

6 

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") 

11 

12References: 

13 IEEE 2414-2020: Standard for Jitter and Phase Noise 

14""" 

15 

16from __future__ import annotations 

17 

18from dataclasses import dataclass 

19from typing import TYPE_CHECKING 

20 

21import numpy as np 

22 

23from tracekit.core.exceptions import InsufficientDataError 

24from tracekit.core.types import DigitalTrace, WaveformTrace 

25 

26if TYPE_CHECKING: 

27 from numpy.typing import NDArray 

28 

29 

30@dataclass 

31class CycleJitterResult: 

32 """Result of cycle-to-cycle or period jitter measurement. 

33 

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 """ 

44 

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 

53 

54 

55@dataclass 

56class DutyCycleDistortionResult: 

57 """Result of duty cycle distortion measurement. 

58 

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 """ 

68 

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 

76 

77 

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. 

83 

84 TIE is the deviation of each edge from its ideal position 

85 based on the recovered clock period. 

86 

87 Args: 

88 edge_timestamps: Array of edge timestamps in seconds. 

89 nominal_period: Expected period (computed from data if None). 

90 

91 Returns: 

92 Array of TIE values in seconds. 

93 

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) 

100 

101 # Calculate actual periods 

102 periods = np.diff(edge_timestamps) 

103 

104 # Use mean period if nominal not provided 

105 if nominal_period is None: 

106 nominal_period = np.mean(periods) 

107 

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 

112 

113 # TIE is actual - ideal 

114 tie: NDArray[np.float64] = edge_timestamps - ideal_positions 

115 

116 return tie 

117 

118 

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. 

126 

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]| 

129 

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. 

134 

135 Returns: 

136 CycleJitterResult with C2C jitter statistics. 

137 

138 Raises: 

139 InsufficientDataError: If fewer than 3 periods provided. 

140 

141 Example: 

142 >>> c2c = cycle_to_cycle_jitter(periods) 

143 >>> print(f"C2C: {c2c.c2c_rms * 1e12:.2f} ps RMS") 

144 

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 ) 

155 

156 # Remove NaN values 

157 valid_periods = periods[~np.isnan(periods)] 

158 

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 ) 

166 

167 # Calculate cycle-to-cycle differences 

168 c2c_values = np.abs(np.diff(valid_periods)) 

169 

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)) 

175 

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 

183 

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 ) 

194 

195 

196def period_jitter( 

197 periods: NDArray[np.float64], 

198 nominal_period: float | None = None, 

199) -> CycleJitterResult: 

200 """Measure period jitter (deviation from nominal period). 

201 

202 Period jitter is the deviation of each period from the ideal 

203 or nominal period. Unlike C2C jitter, it measures absolute deviation. 

204 

205 Args: 

206 periods: Array of measured clock periods in seconds. 

207 nominal_period: Expected period (uses mean if None). 

208 

209 Returns: 

210 CycleJitterResult with period jitter statistics. 

211 

212 Raises: 

213 InsufficientDataError: If fewer than 2 periods provided. 

214 

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 ) 

226 

227 valid_periods = periods[~np.isnan(periods)] 

228 

229 if nominal_period is None: 

230 nominal_period = np.mean(valid_periods) 

231 

232 # Calculate deviations from nominal 

233 deviations = valid_periods - nominal_period 

234 

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 ) 

243 

244 

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. 

252 

253 DCD measures the asymmetry between high and low times in a clock signal. 

254 DCD = |mean_high_time - mean_low_time| 

255 

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). 

260 

261 Returns: 

262 DutyCycleDistortionResult with DCD metrics. 

263 

264 Raises: 

265 InsufficientDataError: If not enough edges found. 

266 

267 Example: 

268 >>> dcd = measure_dcd(clock_trace, clock_period=1e-9) 

269 >>> print(f"DCD: {dcd.dcd_percent:.1f}%") 

270 

271 References: 

272 IEEE 2414-2020 Section 5.4 

273 """ 

274 # Get edge timestamps 

275 rising_edges, falling_edges = _find_edges(trace, threshold) 

276 

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 ) 

284 

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) 

292 

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) 

300 

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 ) 

308 

309 mean_high = float(np.mean(high_times)) 

310 mean_low = float(np.mean(low_times)) 

311 

312 # Calculate DCD 

313 dcd_seconds = abs(mean_high - mean_low) 

314 period = mean_high + mean_low 

315 

316 if clock_period is None: 

317 clock_period = period 

318 

319 dcd_percent = (dcd_seconds / clock_period) * 100 

320 duty_cycle = mean_high / period 

321 

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 ) 

331 

332 

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. 

338 

339 Args: 

340 trace: Input trace. 

341 threshold_frac: Threshold as fraction of amplitude. 

342 

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 

347 

348 sample_rate = trace.metadata.sample_rate 

349 sample_period = 1.0 / sample_rate 

350 

351 if len(data) < 3: 

352 return np.array([]), np.array([]) 

353 

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) 

358 

359 # Find crossings 

360 above = data >= threshold 

361 below = data < threshold 

362 

363 rising_indices = np.where(below[:-1] & above[1:])[0] 

364 falling_indices = np.where(above[:-1] & below[1:])[0] 

365 

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 

369 

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) 

384 

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) 

399 

400 return ( 

401 np.array(rising_edges, dtype=np.float64), 

402 np.array(falling_edges, dtype=np.float64), 

403 ) 

404 

405 

406__all__ = [ 

407 "CycleJitterResult", 

408 "DutyCycleDistortionResult", 

409 "cycle_to_cycle_jitter", 

410 "measure_dcd", 

411 "period_jitter", 

412 "tie_from_edges", 

413]