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

108 statements  

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

1"""TDR impedance extraction for TraceKit. 

2 

3This module provides impedance extraction from Time Domain Reflectometry 

4(TDR) measurements, including impedance profiling and discontinuity analysis. 

5 

6 

7Example: 

8 >>> from tracekit.component import extract_impedance 

9 >>> z0, z_profile = extract_impedance(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, Literal 

20 

21import numpy as np 

22from scipy import signal as sp_signal 

23 

24from tracekit.core.exceptions import InsufficientDataError 

25 

26if TYPE_CHECKING: 

27 from numpy.typing import NDArray 

28 

29 from tracekit.core.types import WaveformTrace 

30 

31 

32@dataclass 

33class ImpedanceProfile: 

34 """Impedance profile from TDR measurement. 

35 

36 Attributes: 

37 distance: Distance axis in meters. 

38 time: Time axis in seconds. 

39 impedance: Impedance values in ohms. 

40 z0_source: Source impedance (reference). 

41 velocity: Propagation velocity used (m/s). 

42 statistics: Additional statistics. 

43 """ 

44 

45 distance: NDArray[np.float64] 

46 time: NDArray[np.float64] 

47 impedance: NDArray[np.float64] 

48 z0_source: float 

49 velocity: float 

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

51 

52 @property 

53 def mean_impedance(self) -> float: 

54 """Mean impedance value.""" 

55 return float(np.mean(self.impedance)) 

56 

57 @property 

58 def max_impedance(self) -> float: 

59 """Maximum impedance value.""" 

60 return float(np.max(self.impedance)) 

61 

62 @property 

63 def min_impedance(self) -> float: 

64 """Minimum impedance value.""" 

65 return float(np.min(self.impedance)) 

66 

67 

68@dataclass 

69class Discontinuity: 

70 """A detected impedance discontinuity. 

71 

72 Attributes: 

73 position: Position in meters. 

74 time: Time position in seconds. 

75 impedance_before: Impedance before discontinuity. 

76 impedance_after: Impedance after discontinuity. 

77 magnitude: Magnitude of change (ohms). 

78 reflection_coeff: Reflection coefficient (rho). 

79 discontinuity_type: Type of discontinuity. 

80 """ 

81 

82 position: float 

83 time: float 

84 impedance_before: float 

85 impedance_after: float 

86 magnitude: float 

87 reflection_coeff: float 

88 discontinuity_type: Literal["capacitive", "inductive", "resistive", "unknown"] 

89 

90 

91def extract_impedance( 

92 trace: WaveformTrace, 

93 *, 

94 z0_source: float = 50.0, 

95 velocity: float | None = None, 

96 velocity_factor: float = 0.66, 

97 start_time: float | None = None, 

98 end_time: float | None = None, 

99) -> tuple[float, ImpedanceProfile]: 

100 """Extract impedance profile from TDR waveform. 

101 

102 Calculates the impedance profile from a TDR reflection measurement 

103 using the relationship between incident and reflected waves. 

104 

105 Args: 

106 trace: TDR reflection waveform. 

107 z0_source: Source/reference impedance (default 50 ohms). 

108 velocity: Propagation velocity in m/s. If None, calculated from 

109 velocity_factor. 

110 velocity_factor: Fraction of speed of light (default 0.66 for FR4). 

111 start_time: Start time for analysis window (seconds). 

112 end_time: End time for analysis window (seconds). 

113 

114 Returns: 

115 Tuple of (characteristic_impedance, impedance_profile). 

116 

117 Raises: 

118 InsufficientDataError: If trace has fewer than 10 samples. 

119 

120 Example: 

121 >>> z0, profile = extract_impedance(tdr_trace, z0_source=50) 

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

123 

124 References: 

125 IPC-TM-650 2.5.5.7 

126 """ 

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

128 sample_rate = trace.metadata.sample_rate 

129 dt = 1.0 / sample_rate 

130 

131 if len(data) < 10: 

132 raise InsufficientDataError( 

133 "TDR analysis requires at least 10 samples", 

134 required=10, 

135 available=len(data), 

136 analysis_type="tdr_impedance", 

137 ) 

138 

139 # Calculate propagation velocity 

140 c = 299792458.0 # Speed of light in m/s 

141 if velocity is None: 

142 velocity = c * velocity_factor 

143 

144 # Create time and distance axes 

145 time_axis = np.arange(len(data)) * dt 

146 # TDR: distance is velocity * time / 2 (round trip) 

147 distance_axis = velocity * time_axis / 2.0 

148 

149 # Apply time window if specified 

150 start_idx = 0 

151 end_idx = len(data) 

152 if start_time is not None: 

153 start_idx = int(start_time * sample_rate) 

154 if end_time is not None: 

155 end_idx = int(end_time * sample_rate) 

156 

157 start_idx = max(0, min(start_idx, len(data) - 1)) 

158 end_idx = max(start_idx + 1, min(end_idx, len(data))) 

159 

160 # Find the incident step level in TDR data 

161 # For TDR with a matched load (Z = Z0), the steady-state voltage is V_source/2 

162 incident_level = _find_incident_level(data) 

163 

164 # Calculate reflection coefficient from TDR waveform 

165 # For TDR: V_measured = V_incident * (1 + rho) 

166 # where rho is the reflection coefficient 

167 # So: rho = (V_measured / V_incident) - 1 

168 

169 if incident_level > 0: 169 ↛ 173line 169 didn't jump to line 173 because the condition on line 169 was always true

170 rho = (data / incident_level) - 1.0 

171 else: 

172 # Fallback: assume data is already normalized 

173 rho = data - 1.0 

174 

175 # Calculate impedance from reflection coefficient 

176 # Z = Z0 * (1 + rho) / (1 - rho) 

177 with np.errstate(divide="ignore", invalid="ignore"): 

178 impedance = z0_source * (1 + rho) / (1 - rho) 

179 # Clip unreasonable values 

180 impedance = np.clip(impedance, 1.0, 10000.0) 

181 

182 # Extract characteristic impedance from stable region 

183 stable_region = impedance[start_idx:end_idx] 

184 z0 = float(np.median(stable_region)) 

185 

186 # Create profile 

187 profile = ImpedanceProfile( 

188 distance=distance_axis, 

189 time=time_axis, 

190 impedance=impedance, 

191 z0_source=z0_source, 

192 velocity=velocity, 

193 statistics={ 

194 "z0_measured": z0, 

195 "z0_std": float(np.std(stable_region)), 

196 "z0_min": float(np.min(stable_region)), 

197 "z0_max": float(np.max(stable_region)), 

198 "analysis_start_m": float(distance_axis[start_idx]), 

199 "analysis_end_m": float(distance_axis[end_idx - 1]), 

200 }, 

201 ) 

202 

203 return z0, profile 

204 

205 

206def impedance_profile( 

207 trace: WaveformTrace, 

208 *, 

209 z0_source: float = 50.0, 

210 velocity_factor: float = 0.66, 

211 smooth_window: int = 0, 

212) -> ImpedanceProfile: 

213 """Get impedance profile from TDR waveform. 

214 

215 Convenience function that returns just the impedance profile. 

216 

217 Args: 

218 trace: TDR reflection waveform. 

219 z0_source: Source/reference impedance. 

220 velocity_factor: Fraction of speed of light. 

221 smooth_window: Smoothing window size (0 = no smoothing). 

222 

223 Returns: 

224 ImpedanceProfile object. 

225 """ 

226 _, profile = extract_impedance( 

227 trace, 

228 z0_source=z0_source, 

229 velocity_factor=velocity_factor, 

230 ) 

231 

232 if smooth_window > 0: 

233 # Apply smoothing 

234 kernel = np.ones(smooth_window) / smooth_window 

235 profile.impedance = np.convolve(profile.impedance, kernel, mode="same") 

236 

237 return profile 

238 

239 

240def discontinuity_analysis( 

241 trace: WaveformTrace, 

242 *, 

243 z0_source: float = 50.0, 

244 velocity_factor: float = 0.66, 

245 threshold: float = 5.0, 

246 min_separation: float = 1e-12, 

247) -> list[Discontinuity]: 

248 """Analyze impedance discontinuities in TDR waveform. 

249 

250 Detects and characterizes impedance discontinuities along a 

251 transmission line from TDR measurements. 

252 

253 Args: 

254 trace: TDR reflection waveform. 

255 z0_source: Source/reference impedance. 

256 velocity_factor: Fraction of speed of light. 

257 threshold: Minimum impedance change to detect (ohms). 

258 min_separation: Minimum time between discontinuities (seconds). 

259 

260 Returns: 

261 List of detected Discontinuity objects. 

262 

263 Example: 

264 >>> disconts = discontinuity_analysis(tdr_trace) 

265 >>> for d in disconts: 

266 ... print(f"{d.position*1000:.1f}mm: {d.magnitude:.1f} ohms") 

267 """ 

268 # Get impedance profile 

269 _, profile = extract_impedance( 

270 trace, 

271 z0_source=z0_source, 

272 velocity_factor=velocity_factor, 

273 ) 

274 

275 impedance = profile.impedance 

276 time_axis = profile.time 

277 distance_axis = profile.distance 

278 

279 # Find discontinuities using derivative 

280 derivative = np.abs(np.diff(impedance)) 

281 

282 # Smooth derivative 

283 if len(derivative) > 5: 283 ↛ 288line 283 didn't jump to line 288 because the condition on line 283 was always true

284 kernel = np.ones(5) / 5 

285 derivative = np.convolve(derivative, kernel, mode="same") 

286 

287 # Find peaks in derivative (discontinuities) 

288 sample_rate = trace.metadata.sample_rate 

289 min_samples = int(min_separation * sample_rate) 

290 

291 peaks, _properties = sp_signal.find_peaks( 

292 derivative, 

293 height=threshold, 

294 distance=max(1, min_samples), 

295 ) 

296 

297 # Analyze each discontinuity 

298 discontinuities = [] 

299 for peak_idx in peaks: 

300 if peak_idx < 1 or peak_idx >= len(impedance) - 1: 300 ↛ 301line 300 didn't jump to line 301 because the condition on line 300 was never true

301 continue 

302 

303 z_before = float(np.mean(impedance[max(0, peak_idx - 5) : peak_idx])) 

304 z_after = float(np.mean(impedance[peak_idx + 1 : min(len(impedance), peak_idx + 6)])) 

305 

306 magnitude = z_after - z_before 

307 position = float(distance_axis[peak_idx]) 

308 time_pos = float(time_axis[peak_idx]) 

309 

310 # Calculate reflection coefficient 

311 rho = (z_after - z_before) / (z_after + z_before) if z_before + z_after > 0 else 0.0 

312 

313 # Determine discontinuity type 

314 if magnitude > 0: 314 ↛ 321line 314 didn't jump to line 321 because the condition on line 314 was always true

315 # Increasing impedance 

316 if abs(magnitude) > 20: 316 ↛ 319line 316 didn't jump to line 319 because the condition on line 316 was always true

317 disc_type: Literal["capacitive", "inductive", "resistive", "unknown"] = "inductive" 

318 else: 

319 disc_type = "resistive" 

320 # Decreasing impedance 

321 elif abs(magnitude) > 20: 

322 disc_type = "capacitive" 

323 else: 

324 disc_type = "resistive" 

325 

326 discontinuities.append( 

327 Discontinuity( 

328 position=position, 

329 time=time_pos, 

330 impedance_before=z_before, 

331 impedance_after=z_after, 

332 magnitude=magnitude, 

333 reflection_coeff=float(rho), 

334 discontinuity_type=disc_type, 

335 ) 

336 ) 

337 

338 return discontinuities 

339 

340 

341def _find_incident_level(data: NDArray[np.float64]) -> float: 

342 """Find the incident step level in TDR data. 

343 

344 Looks for the stable level after the initial edge and before 

345 any reflections return. 

346 

347 Args: 

348 data: TDR waveform data array. 

349 

350 Returns: 

351 Median voltage level in the incident region. 

352 """ 

353 if len(data) < 10: 353 ↛ 354line 353 didn't jump to line 354 because the condition on line 353 was never true

354 return float(np.max(data)) 

355 

356 # Look at first 10-20% of data for incident level 

357 search_end = len(data) // 5 

358 search_start = len(data) // 20 

359 

360 if search_end <= search_start: 360 ↛ 361line 360 didn't jump to line 361 because the condition on line 360 was never true

361 return float(np.max(data[:search_end])) 

362 

363 # Find stable region using variance 

364 stable_data = data[search_start:search_end] 

365 return float(np.median(stable_data))