Coverage for src / tracekit / component / transmission_line.py: 88%

95 statements  

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

1"""Transmission line analysis for TraceKit. 

2 

3This module provides transmission line characterization including 

4characteristic impedance, propagation delay, and velocity factor. 

5 

6 

7Example: 

8 >>> from tracekit.component import transmission_line_analysis 

9 >>> result = transmission_line_analysis(tdr_trace) 

10 

11References: 

12 IPC-TM-650 2.5.5.7: Characteristic Impedance of Lines on PCBs 

13 IEEE 370-2020: Electrical Characterization of Interconnects 

14""" 

15 

16from __future__ import annotations 

17 

18from dataclasses import dataclass, field 

19from typing import TYPE_CHECKING 

20 

21import numpy as np 

22from scipy import signal as sp_signal 

23 

24if TYPE_CHECKING: 

25 from numpy.typing import NDArray 

26 

27 from tracekit.core.types import WaveformTrace 

28 

29 

30@dataclass 

31class TransmissionLineResult: 

32 """Transmission line characterization result. 

33 

34 Attributes: 

35 z0: Characteristic impedance in ohms. 

36 propagation_delay: Propagation delay in seconds. 

37 velocity_factor: Velocity factor (0-1). 

38 velocity: Propagation velocity in m/s. 

39 length: Estimated line length in meters. 

40 loss: Estimated loss in dB (if available). 

41 return_loss: Return loss in dB (if available). 

42 insertion_loss: Insertion loss in dB (if available). 

43 statistics: Additional measurements. 

44 """ 

45 

46 z0: float 

47 propagation_delay: float 

48 velocity_factor: float 

49 velocity: float 

50 length: float 

51 loss: float | None = None 

52 return_loss: float | None = None 

53 insertion_loss: float | None = None 

54 statistics: dict = field(default_factory=dict) # type: ignore[type-arg] 

55 

56 

57def transmission_line_analysis( 

58 trace: WaveformTrace, 

59 *, 

60 z0_source: float = 50.0, 

61 line_length: float | None = None, 

62 dielectric_constant: float | None = None, 

63) -> TransmissionLineResult: 

64 """Analyze transmission line from TDR measurement. 

65 

66 Characterizes a transmission line by extracting characteristic 

67 impedance, propagation delay, and loss parameters. 

68 

69 Args: 

70 trace: TDR reflection waveform. 

71 z0_source: Source impedance (default 50 ohms). 

72 line_length: Known line length in meters (improves accuracy). 

73 dielectric_constant: Known dielectric constant (improves velocity). 

74 

75 Returns: 

76 TransmissionLineResult with line parameters. 

77 

78 Example: 

79 >>> result = transmission_line_analysis(tdr_trace, line_length=0.1) 

80 >>> print(f"Z0 = {result.z0:.1f} ohms, delay = {result.propagation_delay*1e9:.2f} ns") 

81 """ 

82 from tracekit.component.impedance import extract_impedance 

83 

84 # Speed of light 

85 c = 299792458.0 

86 

87 # Extract impedance profile 

88 z0, profile = extract_impedance(trace, z0_source=z0_source) 

89 

90 # Estimate propagation delay from reflection 

91 data = trace.data.astype(np.float64) 

92 sample_rate = trace.metadata.sample_rate 

93 

94 # Find incident edge and first reflection 

95 incident_time, reflection_time = _find_reflection_times(data, sample_rate) 

96 round_trip_time = reflection_time - incident_time 

97 

98 # Propagation delay is half the round-trip time 

99 propagation_delay = round_trip_time / 2 

100 

101 # Calculate velocity 

102 if line_length is not None: 

103 # Use known length 

104 velocity = line_length / propagation_delay if propagation_delay > 0 else c * 0.66 

105 velocity_factor = velocity / c 

106 elif dielectric_constant is not None: 

107 # Calculate from dielectric constant 

108 velocity_factor = 1 / np.sqrt(dielectric_constant) 

109 velocity = c * velocity_factor 

110 line_length = velocity * propagation_delay 

111 else: 

112 # Estimate from typical FR4 

113 velocity_factor = 0.66 

114 velocity = c * velocity_factor 

115 line_length = velocity * propagation_delay 

116 

117 # Estimate loss from reflection amplitude decay 

118 loss = _estimate_loss(data, sample_rate, propagation_delay) 

119 

120 # Estimate return loss 

121 return_loss = _calculate_return_loss(z0, z0_source) 

122 

123 return TransmissionLineResult( 

124 z0=z0, 

125 propagation_delay=propagation_delay, 

126 velocity_factor=velocity_factor, 

127 velocity=velocity, 

128 length=line_length, 

129 loss=loss, 

130 return_loss=return_loss, 

131 statistics={ 

132 "incident_time": incident_time, 

133 "reflection_time": reflection_time, 

134 "round_trip_time": round_trip_time, 

135 "z0_std": profile.statistics.get("z0_std", 0), 

136 }, 

137 ) 

138 

139 

140def characteristic_impedance( 

141 trace: WaveformTrace, 

142 *, 

143 z0_source: float = 50.0, 

144 start_time: float | None = None, 

145 end_time: float | None = None, 

146) -> float: 

147 """Extract characteristic impedance from TDR measurement. 

148 

149 Calculates the characteristic impedance from a stable region 

150 of the TDR waveform. 

151 

152 Args: 

153 trace: TDR reflection waveform. 

154 z0_source: Source impedance. 

155 start_time: Start of analysis window (seconds). 

156 end_time: End of analysis window (seconds). 

157 

158 Returns: 

159 Characteristic impedance in ohms. 

160 

161 Example: 

162 >>> z0 = characteristic_impedance(tdr_trace) 

163 >>> print(f"Z0 = {z0:.1f} ohms") 

164 """ 

165 from tracekit.component.impedance import extract_impedance 

166 

167 z0, _ = extract_impedance( 

168 trace, 

169 z0_source=z0_source, 

170 start_time=start_time, 

171 end_time=end_time, 

172 ) 

173 return z0 

174 

175 

176def propagation_delay( 

177 trace: WaveformTrace, 

178 *, 

179 threshold: float = 0.5, 

180) -> float: 

181 """Measure propagation delay from TDR waveform. 

182 

183 Calculates the one-way propagation delay from the incident edge 

184 to the first reflection. 

185 

186 Args: 

187 trace: TDR reflection waveform. 

188 threshold: Threshold level for edge detection (normalized). 

189 

190 Returns: 

191 Propagation delay in seconds. 

192 

193 Example: 

194 >>> delay = propagation_delay(tdr_trace) 

195 >>> print(f"Delay = {delay * 1e9:.2f} ns") 

196 """ 

197 data = trace.data.astype(np.float64) 

198 sample_rate = trace.metadata.sample_rate 

199 

200 incident_time, reflection_time = _find_reflection_times(data, sample_rate, threshold) 

201 

202 return (reflection_time - incident_time) / 2 

203 

204 

205def velocity_factor( 

206 trace: WaveformTrace, 

207 line_length: float, 

208) -> float: 

209 """Calculate velocity factor from TDR and known length. 

210 

211 Determines the propagation velocity as a fraction of the 

212 speed of light. 

213 

214 Args: 

215 trace: TDR reflection waveform. 

216 line_length: Known line length in meters. 

217 

218 Returns: 

219 Velocity factor (0 to 1). 

220 

221 Example: 

222 >>> vf = velocity_factor(tdr_trace, line_length=0.1) 

223 >>> print(f"Velocity factor = {vf:.2f}") 

224 """ 

225 c = 299792458.0 

226 delay = propagation_delay(trace) 

227 

228 if delay > 0: 228 ↛ 231line 228 didn't jump to line 231 because the condition on line 228 was always true

229 velocity = line_length / delay 

230 return float(min(1.0, velocity / c)) 

231 return 0.66 # Default for FR4 

232 

233 

234def _find_reflection_times( 

235 data: NDArray[np.float64], 

236 sample_rate: float, 

237 threshold: float = 0.5, 

238) -> tuple[float, float]: 

239 """Find incident and reflection edge times.""" 

240 # Normalize data 

241 data_norm = (data - np.min(data)) / (np.ptp(data) + 1e-10) 

242 

243 # Calculate derivative to find edges 

244 derivative = np.abs(np.diff(data_norm)) 

245 

246 # Find peaks in derivative 

247 peaks, _ = sp_signal.find_peaks(derivative, height=0.1 * np.max(derivative)) 

248 

249 if len(peaks) < 2: 

250 # Fallback: use threshold crossing 

251 above_thresh = data_norm > threshold 

252 crossings = np.where(np.diff(above_thresh.astype(int)))[0] 

253 

254 if len(crossings) >= 2: 254 ↛ 255line 254 didn't jump to line 255 because the condition on line 254 was never true

255 incident_idx = crossings[0] 

256 reflection_idx = crossings[1] 

257 else: 

258 # Can't find edges 

259 incident_idx = 0 

260 reflection_idx = len(data) // 2 

261 else: 

262 incident_idx = peaks[0] 

263 reflection_idx = peaks[1] 

264 

265 incident_time = incident_idx / sample_rate 

266 reflection_time = reflection_idx / sample_rate 

267 

268 return incident_time, reflection_time 

269 

270 

271def _estimate_loss( 

272 data: NDArray[np.float64], 

273 sample_rate: float, 

274 delay: float, 

275) -> float | None: 

276 """Estimate transmission line loss from reflection amplitudes.""" 

277 # Find incident and reflected amplitudes 

278 incident_region = data[: int(delay * sample_rate * 0.5)] 

279 if len(incident_region) == 0: 279 ↛ 280line 279 didn't jump to line 280 because the condition on line 279 was never true

280 return None 

281 

282 incident_amp = np.max(incident_region) - np.min(incident_region) 

283 

284 # Find reflected amplitude (after round-trip) 

285 reflection_start = int(delay * 2 * sample_rate) 

286 if reflection_start >= len(data): 286 ↛ 287line 286 didn't jump to line 287 because the condition on line 286 was never true

287 return None 

288 

289 reflected_region = data[reflection_start:] 

290 if len(reflected_region) == 0: 290 ↛ 291line 290 didn't jump to line 291 because the condition on line 290 was never true

291 return None 

292 

293 reflected_amp = np.max(reflected_region) - np.min(reflected_region) 

294 

295 if incident_amp > 0: 

296 # Loss in dB = 20 * log10(reflected / incident) 

297 # But this is round-trip, so divide by 2 for one-way 

298 ratio = reflected_amp / incident_amp 

299 if ratio > 0 and ratio < 1: 299 ↛ 302line 299 didn't jump to line 302 because the condition on line 299 was always true

300 return float(-20 * np.log10(ratio) / 2) 

301 

302 return None 

303 

304 

305def _calculate_return_loss(z0: float, z0_source: float) -> float: 

306 """Calculate return loss from impedance mismatch.""" 

307 if z0 + z0_source > 0: 307 ↛ 312line 307 didn't jump to line 312 because the condition on line 307 was always true

308 rho = abs((z0 - z0_source) / (z0 + z0_source)) 

309 if rho > 0: 

310 return float(-20 * np.log10(rho)) 

311 return float("inf") # Perfect match 

312 return 0.0