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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""TDR impedance extraction for TraceKit.
3This module provides impedance extraction from Time Domain Reflectometry
4(TDR) measurements, including impedance profiling and discontinuity analysis.
7Example:
8 >>> from tracekit.component import extract_impedance
9 >>> z0, z_profile = extract_impedance(tdr_trace)
11References:
12 IPC-TM-650 2.5.5.7: Characteristic Impedance of Lines on PCBs
13 IEEE 370-2020: Electrical Characterization of Interconnects
14"""
16from __future__ import annotations
18from dataclasses import dataclass, field
19from typing import TYPE_CHECKING, Literal
21import numpy as np
22from scipy import signal as sp_signal
24from tracekit.core.exceptions import InsufficientDataError
26if TYPE_CHECKING:
27 from numpy.typing import NDArray
29 from tracekit.core.types import WaveformTrace
32@dataclass
33class ImpedanceProfile:
34 """Impedance profile from TDR measurement.
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 """
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]
52 @property
53 def mean_impedance(self) -> float:
54 """Mean impedance value."""
55 return float(np.mean(self.impedance))
57 @property
58 def max_impedance(self) -> float:
59 """Maximum impedance value."""
60 return float(np.max(self.impedance))
62 @property
63 def min_impedance(self) -> float:
64 """Minimum impedance value."""
65 return float(np.min(self.impedance))
68@dataclass
69class Discontinuity:
70 """A detected impedance discontinuity.
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 """
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"]
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.
102 Calculates the impedance profile from a TDR reflection measurement
103 using the relationship between incident and reflected waves.
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).
114 Returns:
115 Tuple of (characteristic_impedance, impedance_profile).
117 Raises:
118 InsufficientDataError: If trace has fewer than 10 samples.
120 Example:
121 >>> z0, profile = extract_impedance(tdr_trace, z0_source=50)
122 >>> print(f"Z0 = {z0:.1f} ohms")
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
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 )
139 # Calculate propagation velocity
140 c = 299792458.0 # Speed of light in m/s
141 if velocity is None:
142 velocity = c * velocity_factor
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
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)
157 start_idx = max(0, min(start_idx, len(data) - 1))
158 end_idx = max(start_idx + 1, min(end_idx, len(data)))
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)
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
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
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)
182 # Extract characteristic impedance from stable region
183 stable_region = impedance[start_idx:end_idx]
184 z0 = float(np.median(stable_region))
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 )
203 return z0, profile
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.
215 Convenience function that returns just the impedance profile.
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).
223 Returns:
224 ImpedanceProfile object.
225 """
226 _, profile = extract_impedance(
227 trace,
228 z0_source=z0_source,
229 velocity_factor=velocity_factor,
230 )
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")
237 return profile
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.
250 Detects and characterizes impedance discontinuities along a
251 transmission line from TDR measurements.
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).
260 Returns:
261 List of detected Discontinuity objects.
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 )
275 impedance = profile.impedance
276 time_axis = profile.time
277 distance_axis = profile.distance
279 # Find discontinuities using derivative
280 derivative = np.abs(np.diff(impedance))
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")
287 # Find peaks in derivative (discontinuities)
288 sample_rate = trace.metadata.sample_rate
289 min_samples = int(min_separation * sample_rate)
291 peaks, _properties = sp_signal.find_peaks(
292 derivative,
293 height=threshold,
294 distance=max(1, min_samples),
295 )
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
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)]))
306 magnitude = z_after - z_before
307 position = float(distance_axis[peak_idx])
308 time_pos = float(time_axis[peak_idx])
310 # Calculate reflection coefficient
311 rho = (z_after - z_before) / (z_after + z_before) if z_before + z_after > 0 else 0.0
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"
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 )
338 return discontinuities
341def _find_incident_level(data: NDArray[np.float64]) -> float:
342 """Find the incident step level in TDR data.
344 Looks for the stable level after the initial edge and before
345 any reflections return.
347 Args:
348 data: TDR waveform data array.
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))
356 # Look at first 10-20% of data for incident level
357 search_end = len(data) // 5
358 search_start = len(data) // 20
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]))
363 # Find stable region using variance
364 stable_data = data[search_start:search_end]
365 return float(np.median(stable_data))