Coverage for src / tracekit / analyzers / power / soa.py: 96%
116 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"""Safe Operating Area (SOA) analysis for TraceKit.
3Provides SOA checking and visualization for power semiconductor devices.
6Example:
7 >>> from tracekit.analyzers.power.soa import soa_analysis, SOALimit
8 >>> limits = [
9 ... SOALimit(v_max=100, i_max=50, pulse_width=1e-6),
10 ... SOALimit(v_max=80, i_max=100, pulse_width=10e-6),
11 ... ]
12 >>> result = soa_analysis(v_trace, i_trace, limits)
13 >>> print(f"SOA violations: {len(result['violations'])}")
14"""
16from __future__ import annotations
18from dataclasses import dataclass
19from typing import TYPE_CHECKING, Any
21import numpy as np
23if TYPE_CHECKING:
24 from matplotlib.figure import Figure
26 from tracekit.core.types import WaveformTrace
29@dataclass
30class SOALimit:
31 """Defines a point on the SOA boundary.
33 The SOA is typically defined by a piecewise linear boundary in
34 log-log V-I space, with different limits for different pulse widths.
36 Attributes:
37 v_max: Maximum voltage at this limit point (Volts).
38 i_max: Maximum current at this limit point (Amps).
39 pulse_width: Pulse duration for this limit (seconds).
40 Use np.inf for DC limit.
41 name: Optional name for this limit point.
42 """
44 v_max: float
45 i_max: float
46 pulse_width: float = np.inf
47 name: str = ""
50@dataclass
51class SOAViolation:
52 """Information about an SOA violation.
54 Attributes:
55 timestamp: Time of violation (seconds).
56 sample_index: Sample index of violation.
57 voltage: Voltage at violation point.
58 current: Current at violation point.
59 limit: The SOA limit that was violated.
60 margin: How far inside (negative) or outside (positive) the limit.
61 """
63 timestamp: float
64 sample_index: int
65 voltage: float
66 current: float
67 limit: SOALimit
68 margin: float
71def soa_analysis(
72 voltage: WaveformTrace,
73 current: WaveformTrace,
74 limits: list[SOALimit],
75 *,
76 pulse_width: float | None = None,
77) -> dict[str, Any]:
78 """Analyze voltage-current trajectory against SOA limits.
80 Args:
81 voltage: Voltage waveform trace.
82 current: Current waveform trace.
83 limits: List of SOA limit points.
84 pulse_width: Pulse width to use for limit selection.
85 If None, uses DC limits.
87 Returns:
88 Dictionary with:
89 - passed: True if no violations
90 - violations: List of SOAViolation objects
91 - v_trajectory: Voltage values
92 - i_trajectory: Current values
93 - min_margin: Minimum margin to SOA boundary (negative = inside)
94 - applicable_limits: Limits used for this pulse width
96 Example:
97 >>> result = soa_analysis(v_ds, i_d, soa_limits)
98 >>> if not result['passed']:
99 ... print(f"SOA violated at {result['violations'][0].timestamp}s")
100 """
101 v_data = voltage.data
102 i_data = current.data
104 # Ensure same length
105 min_len = min(len(v_data), len(i_data))
106 v_data = v_data[:min_len]
107 i_data = i_data[:min_len]
108 sample_period = voltage.metadata.time_base
110 # Select applicable limits based on pulse width
111 if pulse_width is None:
112 pulse_width = np.inf
114 applicable_limits = [l for l in limits if l.pulse_width >= pulse_width] # noqa: E741
115 if not applicable_limits:
116 applicable_limits = limits # Use all if none match
118 # Build SOA boundary (interpolate between limit points)
119 # Sort by voltage
120 sorted_limits = sorted(applicable_limits, key=lambda l: l.v_max) # noqa: E741
122 violations: list[SOAViolation] = []
123 margins: list[float] = []
125 for i in range(len(v_data)):
126 v = abs(v_data[i])
127 current_i = abs(i_data[i])
129 # Find applicable limit (linear interpolation in log-log space)
130 i_limit = _interpolate_soa_limit(v, sorted_limits)
132 margin = i_limit - current_i
133 margins.append(margin)
135 if current_i > i_limit:
136 # Find which specific limit was violated
137 for limit in sorted_limits:
138 if v <= limit.v_max and current_i > limit.i_max:
139 violations.append(
140 SOAViolation(
141 timestamp=i * sample_period,
142 sample_index=i,
143 voltage=float(v_data[i]),
144 current=float(i_data[i]),
145 limit=limit,
146 margin=-margin,
147 )
148 )
149 break
151 return {
152 "passed": len(violations) == 0,
153 "violations": violations,
154 "v_trajectory": v_data,
155 "i_trajectory": i_data,
156 "min_margin": float(np.min(margins)) if margins else 0.0,
157 "applicable_limits": applicable_limits,
158 }
161def _interpolate_soa_limit(voltage: float, limits: list[SOALimit]) -> float:
162 """Interpolate SOA current limit at given voltage.
164 Uses log-log interpolation between limit points.
166 Args:
167 voltage: Voltage at which to interpolate current limit
168 limits: List of SOALimit points defining the boundary
170 Returns:
171 Interpolated current limit in Amps
172 """
173 if len(limits) == 0:
174 return np.inf # type: ignore[no-any-return]
176 if len(limits) == 1:
177 if voltage <= limits[0].v_max:
178 return limits[0].i_max
179 return 0.0
181 # Find bracketing points
182 for i in range(len(limits) - 1):
183 if limits[i].v_max <= voltage <= limits[i + 1].v_max:
184 # Log-log interpolation
185 v1, v2 = limits[i].v_max, limits[i + 1].v_max
186 i1, i2 = limits[i].i_max, limits[i + 1].i_max
188 if v1 <= 0 or v2 <= 0 or i1 <= 0 or i2 <= 0: 188 ↛ 190line 188 didn't jump to line 190 because the condition on line 188 was never true
189 # Linear interpolation fallback
190 t = (voltage - v1) / (v2 - v1)
191 return i1 + t * (i2 - i1)
193 # Log-log
194 log_v = np.log10(voltage)
195 log_v1, log_v2 = np.log10(v1), np.log10(v2)
196 log_i1, log_i2 = np.log10(i1), np.log10(i2)
198 t = (log_v - log_v1) / (log_v2 - log_v1)
199 log_i = log_i1 + t * (log_i2 - log_i1)
200 return 10**log_i # type: ignore[no-any-return]
202 # Beyond limits
203 if voltage < limits[0].v_max: 203 ↛ 205line 203 didn't jump to line 205 because the condition on line 203 was always true
204 return limits[0].i_max
205 return 0.0
208def check_soa_violations(
209 voltage: WaveformTrace,
210 current: WaveformTrace,
211 limits: list[SOALimit],
212) -> list[SOAViolation]:
213 """Check for SOA violations and return list of violations.
215 Convenience function that just returns violations.
217 Args:
218 voltage: Voltage waveform.
219 current: Current waveform.
220 limits: SOA limits.
222 Returns:
223 List of SOA violations (empty if all within limits).
224 """
225 result = soa_analysis(voltage, current, limits)
226 return result["violations"] # type: ignore[no-any-return]
229def plot_soa(
230 voltage: WaveformTrace,
231 current: WaveformTrace,
232 limits: list[SOALimit],
233 *,
234 figsize: tuple[float, float] = (10, 8),
235 title: str | None = None,
236 show_violations: bool = True,
237) -> Figure:
238 """Plot SOA diagram with trajectory and limits.
240 Args:
241 voltage: Voltage waveform.
242 current: Current waveform.
243 limits: SOA limits to plot.
244 figsize: Figure size in inches.
245 title: Plot title.
246 show_violations: If True, highlight violations.
248 Returns:
249 Matplotlib Figure object.
251 Example:
252 >>> fig = plot_soa(v_ds, i_d, soa_limits)
253 >>> plt.show()
254 """
255 import matplotlib.pyplot as plt
257 result = soa_analysis(voltage, current, limits)
259 fig, ax = plt.subplots(figsize=figsize)
261 # Plot SOA boundary
262 sorted_limits = sorted(limits, key=lambda l: l.v_max) # noqa: E741
263 v_boundary = [l.v_max for l in sorted_limits] # noqa: E741
264 i_boundary = [l.i_max for l in sorted_limits] # noqa: E741
266 # Add corner points for closed boundary
267 v_boundary = [0, *v_boundary, v_boundary[-1], 0]
268 i_boundary = [i_boundary[0], *i_boundary, 0, 0]
270 ax.fill(v_boundary, i_boundary, alpha=0.2, color="green", label="Safe Operating Area")
271 ax.plot(v_boundary, i_boundary, "g-", linewidth=2)
273 # Plot trajectory
274 v_traj = np.abs(result["v_trajectory"])
275 i_traj = np.abs(result["i_trajectory"])
276 ax.plot(v_traj, i_traj, "b-", linewidth=1, alpha=0.7, label="Operating trajectory")
278 # Highlight violations
279 if show_violations and result["violations"]:
280 v_viol = [v.voltage for v in result["violations"]]
281 i_viol = [v.current for v in result["violations"]]
282 ax.scatter(
283 np.abs(v_viol),
284 np.abs(i_viol),
285 c="red",
286 s=50,
287 marker="x",
288 label=f"Violations ({len(result['violations'])})",
289 )
291 ax.set_xlabel("Voltage (V)")
292 ax.set_ylabel("Current (A)")
293 ax.set_xlim(0, None)
294 ax.set_ylim(0, None)
295 ax.grid(True, alpha=0.3)
296 ax.legend()
298 if title:
299 ax.set_title(title)
300 else:
301 status = "PASS" if result["passed"] else "FAIL"
302 ax.set_title(f"Safe Operating Area Analysis - {status}")
304 plt.tight_layout()
305 return fig
308def create_mosfet_soa(
309 v_ds_max: float,
310 i_d_max: float,
311 p_d_max: float,
312 *,
313 pulse_limits: dict[float, float] | None = None,
314) -> list[SOALimit]:
315 """Create SOA limits for a MOSFET.
317 Generates typical SOA boundary from datasheet parameters.
319 Args:
320 v_ds_max: Maximum drain-source voltage.
321 i_d_max: Maximum continuous drain current.
322 p_d_max: Maximum power dissipation.
323 pulse_limits: Optional dict of {pulse_width: i_max} for pulsed limits.
325 Returns:
326 List of SOALimit objects defining the boundary.
328 Example:
329 >>> limits = create_mosfet_soa(v_ds_max=100, i_d_max=50, p_d_max=150)
330 """
331 limits = []
333 # Current limit (horizontal line at I_max)
334 limits.append(SOALimit(v_max=1.0, i_max=i_d_max, name="I_max"))
336 # Power limit (hyperbola P = V * I)
337 # Find intersection with I_max line
338 v_at_imax = p_d_max / i_d_max
339 if v_at_imax < v_ds_max: 339 ↛ 348line 339 didn't jump to line 348 because the condition on line 339 was always true
340 limits.append(SOALimit(v_max=v_at_imax, i_max=i_d_max, name="P_max_start"))
342 # Add points along power hyperbola
343 for v in np.geomspace(v_at_imax, v_ds_max * 0.9, 5)[1:]:
344 i = p_d_max / v
345 limits.append(SOALimit(v_max=v, i_max=i, name="P_max"))
347 # Voltage limit
348 limits.append(SOALimit(v_max=v_ds_max, i_max=0.1, name="V_max"))
350 # Add pulsed limits if provided
351 if pulse_limits:
352 for pw, i_max in pulse_limits.items():
353 limits.append(
354 SOALimit(
355 v_max=v_ds_max,
356 i_max=i_max,
357 pulse_width=pw,
358 name=f"Pulse_{pw * 1e6:.0f}us",
359 )
360 )
362 return limits
365__all__ = [
366 "SOALimit",
367 "SOAViolation",
368 "check_soa_violations",
369 "create_mosfet_soa",
370 "plot_soa",
371 "soa_analysis",
372]