Coverage for src / tracekit / visualization / power.py: 95%

106 statements  

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

1"""Power profile visualization. 

2 

3 

4This module provides comprehensive power visualization including 

5time-domain plots, energy accumulation, and multi-channel views. 

6""" 

7 

8from pathlib import Path 

9 

10import matplotlib.pyplot as plt 

11import numpy as np 

12from matplotlib.figure import Figure 

13from numpy.typing import NDArray 

14 

15 

16def plot_power_profile( 

17 power: NDArray[np.float64] | dict[str, NDArray[np.float64]], 

18 *, 

19 sample_rate: float | None = None, 

20 time_array: NDArray[np.float64] | None = None, 

21 statistics: dict[str, float] | None = None, 

22 show_average: bool = True, 

23 show_peak: bool = True, 

24 show_energy: bool = True, 

25 multi_channel_layout: str = "stacked", 

26 title: str | None = None, 

27 figsize: tuple[float, float] = (12, 6), 

28 save_path: str | Path | None = None, 

29 show: bool = True, 

30) -> Figure: 

31 """Generate power profile plot with annotations. 

32 

33 : Time-domain power visualization with average/peak markers 

34 and optional energy accumulation overlay. Supports multi-channel stacked view. 

35 

36 Args: 

37 power: Power trace in watts. Can be: 

38 - Array: Single channel power trace 

39 - Dict: Multiple channels {name: trace} 

40 sample_rate: Sample rate in Hz (required if time_array not provided) 

41 time_array: Optional explicit time array (overrides sample_rate) 

42 statistics: Optional pre-computed statistics dict from power_statistics() 

43 If provided, used for annotations. Otherwise computed automatically. 

44 show_average: Show average power horizontal line (default: True) 

45 show_peak: Show peak power marker (default: True) 

46 show_energy: Show cumulative energy overlay (default: True) 

47 multi_channel_layout: Layout for multiple channels: 

48 - 'stacked': Separate subplots stacked vertically (default) 

49 - 'overlay': All channels on same plot 

50 title: Plot title (default: "Power Profile") 

51 figsize: Figure size as (width, height) in inches 

52 save_path: Optional path to save figure 

53 show: Display the figure (default: True) 

54 

55 Returns: 

56 Matplotlib Figure object for further customization 

57 

58 Raises: 

59 ValueError: If neither sample_rate nor time_array provided 

60 ValueError: If time_array length doesn't match power trace 

61 

62 Examples: 

63 >>> # Simple power profile plot 

64 >>> import numpy as np 

65 >>> power = np.random.rand(1000) * 0.5 + 0.3 # 300-800 mW 

66 >>> fig = plot_power_profile( 

67 ... power, 

68 ... sample_rate=1e6, 

69 ... title="Device Power Consumption" 

70 ... ) 

71 

72 >>> # With pre-computed statistics 

73 >>> from tracekit.analyzers.power import power_statistics 

74 >>> stats = power_statistics(power, sample_rate=1e6) 

75 >>> fig = plot_power_profile( 

76 ... power, 

77 ... sample_rate=1e6, 

78 ... statistics=stats, 

79 ... show_energy=True 

80 ... ) 

81 

82 >>> # Multi-channel stacked view 

83 >>> power_channels = { 

84 ... 'VDD_CORE': np.random.rand(1000) * 0.5, 

85 ... 'VDD_IO': np.random.rand(1000) * 0.3, 

86 ... 'VDD_ANALOG': np.random.rand(1000) * 0.2, 

87 ... } 

88 >>> fig = plot_power_profile( 

89 ... power_channels, 

90 ... sample_rate=1e6, 

91 ... multi_channel_layout='stacked' 

92 ... ) 

93 

94 Notes: 

95 - Energy accumulation computed via cumulative sum 

96 - Multiple channels can be overlaid or stacked 

97 - Annotations include average, peak, and total energy 

98 - Time axis auto-scaled to appropriate units (ns/µs/ms/s) 

99 

100 References: 

101 PWR-004: Power Profile Visualization 

102 """ 

103 # Handle multi-channel input 

104 if isinstance(power, dict): 

105 channels = power 

106 is_multi = True 

107 else: 

108 channels = {"Power": np.asarray(power, dtype=np.float64)} 

109 is_multi = False 

110 

111 # Validate inputs 

112 if time_array is None and sample_rate is None: 

113 raise ValueError("Either time_array or sample_rate must be provided") 

114 

115 # Generate time array 

116 first_trace = next(iter(channels.values())) 

117 if time_array is None: 

118 if sample_rate is None: 118 ↛ 119line 118 didn't jump to line 119 because the condition on line 118 was never true

119 raise ValueError("sample_rate is required when time_array is not provided") 

120 time_array = np.arange(len(first_trace)) / sample_rate 

121 else: 

122 time_array = np.asarray(time_array, dtype=np.float64) 

123 if len(time_array) != len(first_trace): 

124 raise ValueError( 

125 f"time_array length {len(time_array)} doesn't match " 

126 f"power trace length {len(first_trace)}" 

127 ) 

128 

129 # Determine time scale and units 

130 time_max = time_array[-1] 

131 if time_max < 1e-6: 

132 time_scale = 1e9 

133 time_unit = "ns" 

134 elif time_max < 1e-3: 

135 time_scale = 1e6 

136 time_unit = "µs" 

137 elif time_max < 1: 

138 time_scale = 1e3 

139 time_unit = "ms" 

140 else: 

141 time_scale = 1 

142 time_unit = "s" 

143 

144 time_scaled = time_array * time_scale 

145 

146 # Create figure 

147 if is_multi and multi_channel_layout == "stacked": 

148 n_channels = len(channels) 

149 n_plots = n_channels + (1 if show_energy else 0) 

150 fig, axes = plt.subplots(n_plots, 1, figsize=figsize, sharex=True) 

151 if n_plots == 1: 151 ↛ 152line 151 didn't jump to line 152 because the condition on line 151 was never true

152 axes = [axes] 

153 else: 

154 fig, ax_power = plt.subplots(figsize=figsize) 

155 axes = [ax_power] 

156 

157 # Plot each channel 

158 if is_multi and multi_channel_layout == "stacked": 

159 # Stacked layout - one subplot per channel 

160 for idx, (name, trace) in enumerate(channels.items()): 

161 ax = axes[idx] 

162 ax.plot(time_scaled, trace * 1e3, linewidth=0.8, label=name) 

163 

164 # Compute or use statistics 

165 if statistics is None or name not in statistics: 

166 avg = np.mean(trace) 

167 peak = np.max(trace) 

168 else: 

169 avg = statistics[name]["average"] # type: ignore[index] 

170 peak = statistics[name]["peak"] # type: ignore[index] 

171 

172 # Annotations 

173 if show_average: 173 ↛ 183line 173 didn't jump to line 183 because the condition on line 173 was always true

174 ax.axhline( 

175 avg * 1e3, 

176 color="r", 

177 linestyle="--", 

178 linewidth=1, 

179 alpha=0.7, 

180 label=f"Avg: {avg * 1e3:.2f} mW", 

181 ) 

182 

183 if show_peak: 183 ↛ 193line 183 didn't jump to line 193 because the condition on line 183 was always true

184 peak_idx = np.argmax(trace) 

185 ax.plot( 

186 time_scaled[peak_idx], 

187 peak * 1e3, 

188 "rv", 

189 markersize=8, 

190 label=f"Peak: {peak * 1e3:.2f} mW", 

191 ) 

192 

193 ax.set_ylabel(f"{name}\n(mW)") 

194 ax.legend(loc="upper right", fontsize=8) 

195 ax.grid(True, alpha=0.3) 

196 

197 # Energy accumulation plot 

198 if show_energy: 

199 ax_energy = axes[-1] 

200 for name, trace in channels.items(): 

201 if sample_rate is not None: 201 ↛ 200line 201 didn't jump to line 200 because the condition on line 201 was always true

202 energy = np.cumsum(trace) / sample_rate * 1e6 # µJ 

203 ax_energy.plot(time_scaled, energy, linewidth=0.8, label=name) 

204 

205 ax_energy.set_ylabel("Cumulative\nEnergy (µJ)") 

206 ax_energy.set_xlabel(f"Time ({time_unit})") 

207 ax_energy.legend(loc="upper left", fontsize=8) 

208 ax_energy.grid(True, alpha=0.3) 

209 

210 else: 

211 # Overlay layout or single channel 

212 ax = axes[0] 

213 

214 for name, trace in channels.items(): 

215 ax.plot(time_scaled, trace * 1e3, linewidth=0.8, label=name) 

216 

217 # Statistics for first channel (or combined if overlay) 

218 first_trace = next(iter(channels.values())) 

219 if statistics is None: 

220 avg_val = float(np.mean(first_trace)) 

221 peak_val = float(np.max(first_trace)) 

222 total_energy_val: float | None = ( 

223 float(np.sum(first_trace) / sample_rate) if sample_rate else None 

224 ) 

225 else: 

226 avg_val = float(statistics.get("average", float(np.mean(first_trace)))) 

227 peak_val = float(statistics.get("peak", float(np.max(first_trace)))) 

228 total_energy_val = statistics.get("energy", None) 

229 

230 # Annotations 

231 if show_average: 

232 ax.axhline( 

233 avg_val * 1e3, 

234 color="r", 

235 linestyle="--", 

236 linewidth=1, 

237 alpha=0.7, 

238 label=f"Avg: {avg_val * 1e3:.2f} mW", 

239 ) 

240 

241 if show_peak: 

242 peak_idx = np.argmax(first_trace) 

243 ax.plot( 

244 time_scaled[peak_idx], 

245 peak_val * 1e3, 

246 "rv", 

247 markersize=8, 

248 label=f"Peak: {peak_val * 1e3:.2f} mW", 

249 ) 

250 

251 ax.set_ylabel("Power (mW)") 

252 ax.set_xlabel(f"Time ({time_unit})") 

253 ax.legend(loc="upper right") 

254 ax.grid(True, alpha=0.3) 

255 

256 # Energy overlay on secondary y-axis 

257 if show_energy and sample_rate is not None: 

258 ax2 = ax.twinx() 

259 energy = np.cumsum(first_trace) / sample_rate * 1e6 # µJ 

260 ax2.plot(time_scaled, energy, "g--", linewidth=1.5, alpha=0.6) 

261 ax2.set_ylabel("Cumulative Energy (µJ)", color="g") 

262 ax2.tick_params(axis="y", labelcolor="g") 

263 

264 if total_energy_val is not None: 264 ↛ 276line 264 didn't jump to line 276 because the condition on line 264 was always true

265 ax2.text( 

266 0.98, 

267 0.98, 

268 f"Total: {total_energy_val * 1e6:.2f} µJ", 

269 transform=ax.transAxes, 

270 ha="right", 

271 va="top", 

272 bbox={"boxstyle": "round", "facecolor": "wheat", "alpha": 0.5}, 

273 ) 

274 

275 # Set title 

276 if title is None: 

277 title = "Power Profile" + (" (Multi-Channel)" if is_multi else "") 

278 fig.suptitle(title, fontsize=14, fontweight="bold") 

279 

280 plt.tight_layout() 

281 

282 # Save if requested 

283 if save_path is not None: 

284 fig.savefig(save_path, dpi=150, bbox_inches="tight") 

285 

286 # Show if requested 

287 if show: 

288 plt.show() 

289 

290 return fig