Coverage for src / tracekit / analyzers / jitter / spectrum.py: 100%

60 statements  

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

1"""Jitter spectrum analysis. 

2 

3This module provides FFT-based analysis of TIE data to identify 

4periodic jitter sources and their frequencies. 

5 

6 

7Example: 

8 >>> from tracekit.analyzers.jitter.spectrum import jitter_spectrum 

9 >>> result = jitter_spectrum(tie_data, sample_rate=1e9) 

10 >>> print(f"Dominant frequency: {result.dominant_frequency / 1e3:.1f} kHz") 

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 

22from scipy.signal import find_peaks 

23 

24if TYPE_CHECKING: 

25 from numpy.typing import NDArray 

26 

27 

28@dataclass 

29class JitterSpectrumResult: 

30 """Result of jitter spectrum analysis. 

31 

32 Attributes: 

33 frequencies: Frequency array in Hz. 

34 magnitude: Magnitude spectrum in seconds. 

35 magnitude_db: Magnitude spectrum in dB (relative to 1 ps). 

36 dominant_frequency: Frequency of largest component in Hz. 

37 dominant_magnitude: Magnitude at dominant frequency in seconds. 

38 noise_floor: Estimated noise floor in seconds. 

39 peaks: List of (frequency, magnitude) tuples for detected peaks. 

40 """ 

41 

42 frequencies: NDArray[np.float64] 

43 magnitude: NDArray[np.float64] 

44 magnitude_db: NDArray[np.float64] 

45 dominant_frequency: float | None 

46 dominant_magnitude: float | None 

47 noise_floor: float 

48 peaks: list[tuple[float, float]] 

49 

50 

51def jitter_spectrum( 

52 tie_data: NDArray[np.float64], 

53 sample_rate: float, 

54 *, 

55 window: str = "hann", 

56 detrend: bool = True, 

57 n_peaks: int = 10, 

58) -> JitterSpectrumResult: 

59 """Compute FFT of TIE data to identify jitter frequency components. 

60 

61 Identifies periodic jitter sources by frequency, useful for 

62 debugging EMI-induced jitter and power supply noise. 

63 

64 Args: 

65 tie_data: Time Interval Error data in seconds. 

66 sample_rate: Sample rate of TIE data (edges per second). 

67 window: Window function ("hann", "hamming", "blackman", "none"). 

68 detrend: Remove linear trend from data before FFT. 

69 n_peaks: Number of peaks to identify. 

70 

71 Returns: 

72 JitterSpectrumResult with frequency analysis. 

73 

74 Example: 

75 >>> result = jitter_spectrum(tie_data, sample_rate=1e9) 

76 >>> for freq, mag in result.peaks: 

77 ... print(f"{freq/1e3:.1f} kHz: {mag*1e12:.2f} ps") 

78 

79 References: 

80 IEEE 2414-2020 Section 6.8 

81 """ 

82 valid_data = tie_data[~np.isnan(tie_data)] 

83 n = len(valid_data) 

84 

85 if n < 16: 

86 return JitterSpectrumResult( 

87 frequencies=np.array([]), 

88 magnitude=np.array([]), 

89 magnitude_db=np.array([]), 

90 dominant_frequency=None, 

91 dominant_magnitude=None, 

92 noise_floor=0.0, 

93 peaks=[], 

94 ) 

95 

96 # Detrend if requested 

97 if detrend: 

98 # Remove linear trend 

99 x = np.arange(n) 

100 slope, intercept = np.polyfit(x, valid_data, 1) 

101 data_detrended = valid_data - (slope * x + intercept) 

102 else: 

103 data_detrended = valid_data - np.mean(valid_data) 

104 

105 # Apply window 

106 if window == "hann": 

107 win = np.hanning(n) 

108 elif window == "hamming": 

109 win = np.hamming(n) 

110 elif window == "blackman": 

111 win = np.blackman(n) 

112 else: 

113 win = np.ones(n) 

114 

115 # Compensate for window power loss 

116 window_factor = np.sqrt(np.mean(win**2)) 

117 data_windowed = data_detrended * win 

118 

119 # Zero-pad to next power of 2 

120 nfft = int(2 ** np.ceil(np.log2(n))) 

121 

122 # Compute FFT 

123 spectrum = np.fft.rfft(data_windowed, n=nfft) 

124 frequencies = np.fft.rfftfreq(nfft, d=1.0 / sample_rate) 

125 

126 # Calculate magnitude spectrum 

127 # Scale for proper amplitude: 2/N for single-sided, compensate for window 

128 magnitude = np.abs(spectrum) * 2 / n / window_factor 

129 

130 # Convert to dB (relative to 1 ps = 1e-12 s) 

131 reference = 1e-12 # 1 ps 

132 magnitude_db = 20 * np.log10(magnitude / reference + 1e-20) 

133 

134 # Estimate noise floor (median of spectrum) 

135 noise_floor = float(np.median(magnitude)) 

136 

137 # Find peaks 

138 peaks = identify_periodic_components( 

139 frequencies, 

140 magnitude, 

141 n_peaks=n_peaks, 

142 min_height=noise_floor * 3, 

143 ) 

144 

145 # Get dominant component 

146 if len(peaks) > 0: 

147 dominant_frequency = peaks[0][0] 

148 dominant_magnitude = peaks[0][1] 

149 else: 

150 dominant_frequency = None 

151 dominant_magnitude = None 

152 

153 return JitterSpectrumResult( 

154 frequencies=frequencies, 

155 magnitude=magnitude, 

156 magnitude_db=magnitude_db, 

157 dominant_frequency=dominant_frequency, 

158 dominant_magnitude=dominant_magnitude, 

159 noise_floor=noise_floor, 

160 peaks=peaks, 

161 ) 

162 

163 

164def identify_periodic_components( 

165 frequencies: NDArray[np.float64], 

166 magnitude: NDArray[np.float64], 

167 *, 

168 n_peaks: int = 10, 

169 min_height: float | None = None, 

170 min_distance: int = 3, 

171) -> list[tuple[float, float]]: 

172 """Identify periodic components from jitter spectrum. 

173 

174 Finds peaks in the magnitude spectrum that indicate periodic 

175 jitter sources. 

176 

177 Args: 

178 frequencies: Frequency array in Hz. 

179 magnitude: Magnitude spectrum. 

180 n_peaks: Maximum number of peaks to return. 

181 min_height: Minimum peak height (default: 3x median). 

182 min_distance: Minimum distance between peaks in bins. 

183 

184 Returns: 

185 List of (frequency, magnitude) tuples, sorted by magnitude. 

186 """ 

187 if len(magnitude) < 3: 

188 return [] 

189 

190 min_height_value: float 

191 min_height_value = float(np.median(magnitude) * 3) if min_height is None else min_height 

192 

193 # Find peaks 

194 peak_indices, _properties = find_peaks( 

195 magnitude, 

196 height=min_height_value, 

197 distance=min_distance, 

198 ) 

199 

200 if len(peak_indices) == 0: 

201 return [] 

202 

203 # Get peak heights 

204 peak_heights = magnitude[peak_indices] 

205 

206 # Sort by magnitude (descending) 

207 sorted_order = np.argsort(peak_heights)[::-1] 

208 sorted_indices = peak_indices[sorted_order][:n_peaks] 

209 

210 # Build result list 

211 peaks = [(float(frequencies[idx]), float(magnitude[idx])) for idx in sorted_indices] 

212 

213 return peaks 

214 

215 

216__all__ = [ 

217 "JitterSpectrumResult", 

218 "identify_periodic_components", 

219 "jitter_spectrum", 

220]