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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""Power profile visualization.
4This module provides comprehensive power visualization including
5time-domain plots, energy accumulation, and multi-channel views.
6"""
8from pathlib import Path
10import matplotlib.pyplot as plt
11import numpy as np
12from matplotlib.figure import Figure
13from numpy.typing import NDArray
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.
33 : Time-domain power visualization with average/peak markers
34 and optional energy accumulation overlay. Supports multi-channel stacked view.
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)
55 Returns:
56 Matplotlib Figure object for further customization
58 Raises:
59 ValueError: If neither sample_rate nor time_array provided
60 ValueError: If time_array length doesn't match power trace
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 ... )
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 ... )
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 ... )
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)
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
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")
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 )
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"
144 time_scaled = time_array * time_scale
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]
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)
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]
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 )
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 )
193 ax.set_ylabel(f"{name}\n(mW)")
194 ax.legend(loc="upper right", fontsize=8)
195 ax.grid(True, alpha=0.3)
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)
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)
210 else:
211 # Overlay layout or single channel
212 ax = axes[0]
214 for name, trace in channels.items():
215 ax.plot(time_scaled, trace * 1e3, linewidth=0.8, label=name)
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)
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 )
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 )
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)
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")
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 )
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")
280 plt.tight_layout()
282 # Save if requested
283 if save_path is not None:
284 fig.savefig(save_path, dpi=150, bbox_inches="tight")
286 # Show if requested
287 if show:
288 plt.show()
290 return fig