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

1"""Safe Operating Area (SOA) analysis for TraceKit. 

2 

3Provides SOA checking and visualization for power semiconductor devices. 

4 

5 

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""" 

15 

16from __future__ import annotations 

17 

18from dataclasses import dataclass 

19from typing import TYPE_CHECKING, Any 

20 

21import numpy as np 

22 

23if TYPE_CHECKING: 

24 from matplotlib.figure import Figure 

25 

26 from tracekit.core.types import WaveformTrace 

27 

28 

29@dataclass 

30class SOALimit: 

31 """Defines a point on the SOA boundary. 

32 

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. 

35 

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 """ 

43 

44 v_max: float 

45 i_max: float 

46 pulse_width: float = np.inf 

47 name: str = "" 

48 

49 

50@dataclass 

51class SOAViolation: 

52 """Information about an SOA violation. 

53 

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 """ 

62 

63 timestamp: float 

64 sample_index: int 

65 voltage: float 

66 current: float 

67 limit: SOALimit 

68 margin: float 

69 

70 

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. 

79 

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. 

86 

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 

95 

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 

103 

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 

109 

110 # Select applicable limits based on pulse width 

111 if pulse_width is None: 

112 pulse_width = np.inf 

113 

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 

117 

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 

121 

122 violations: list[SOAViolation] = [] 

123 margins: list[float] = [] 

124 

125 for i in range(len(v_data)): 

126 v = abs(v_data[i]) 

127 current_i = abs(i_data[i]) 

128 

129 # Find applicable limit (linear interpolation in log-log space) 

130 i_limit = _interpolate_soa_limit(v, sorted_limits) 

131 

132 margin = i_limit - current_i 

133 margins.append(margin) 

134 

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 

150 

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 } 

159 

160 

161def _interpolate_soa_limit(voltage: float, limits: list[SOALimit]) -> float: 

162 """Interpolate SOA current limit at given voltage. 

163 

164 Uses log-log interpolation between limit points. 

165 

166 Args: 

167 voltage: Voltage at which to interpolate current limit 

168 limits: List of SOALimit points defining the boundary 

169 

170 Returns: 

171 Interpolated current limit in Amps 

172 """ 

173 if len(limits) == 0: 

174 return np.inf # type: ignore[no-any-return] 

175 

176 if len(limits) == 1: 

177 if voltage <= limits[0].v_max: 

178 return limits[0].i_max 

179 return 0.0 

180 

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 

187 

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) 

192 

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) 

197 

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] 

201 

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 

206 

207 

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. 

214 

215 Convenience function that just returns violations. 

216 

217 Args: 

218 voltage: Voltage waveform. 

219 current: Current waveform. 

220 limits: SOA limits. 

221 

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] 

227 

228 

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. 

239 

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. 

247 

248 Returns: 

249 Matplotlib Figure object. 

250 

251 Example: 

252 >>> fig = plot_soa(v_ds, i_d, soa_limits) 

253 >>> plt.show() 

254 """ 

255 import matplotlib.pyplot as plt 

256 

257 result = soa_analysis(voltage, current, limits) 

258 

259 fig, ax = plt.subplots(figsize=figsize) 

260 

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 

265 

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] 

269 

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) 

272 

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") 

277 

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 ) 

290 

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() 

297 

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}") 

303 

304 plt.tight_layout() 

305 return fig 

306 

307 

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. 

316 

317 Generates typical SOA boundary from datasheet parameters. 

318 

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. 

324 

325 Returns: 

326 List of SOALimit objects defining the boundary. 

327 

328 Example: 

329 >>> limits = create_mosfet_soa(v_ds_max=100, i_d_max=50, p_d_max=150) 

330 """ 

331 limits = [] 

332 

333 # Current limit (horizontal line at I_max) 

334 limits.append(SOALimit(v_max=1.0, i_max=i_d_max, name="I_max")) 

335 

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")) 

341 

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")) 

346 

347 # Voltage limit 

348 limits.append(SOALimit(v_max=v_ds_max, i_max=0.1, name="V_max")) 

349 

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 ) 

361 

362 return limits 

363 

364 

365__all__ = [ 

366 "SOALimit", 

367 "SOAViolation", 

368 "check_soa_violations", 

369 "create_mosfet_soa", 

370 "plot_soa", 

371 "soa_analysis", 

372]