Coverage for src / tracekit / onboarding / wizard.py: 87%

224 statements  

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

1"""Interactive analysis wizard for guided signal analysis. 

2 

3This module provides step-by-step guided analysis wizards that help 

4non-expert users analyze their signals with intelligent recommendations. 

5 

6 - Step-by-step guidance 

7 - Intelligent defaults 

8 - Context-aware recommendations 

9 - Result interpretation 

10 

11Example: 

12 >>> from tracekit.onboarding import run_wizard 

13 >>> run_wizard(trace) 

14 Analysis Wizard 

15 Step 1: What type of signal is this? 

16 ... 

17""" 

18 

19from __future__ import annotations 

20 

21from dataclasses import dataclass, field 

22from enum import Enum 

23from typing import TYPE_CHECKING, Any 

24 

25if TYPE_CHECKING: 

26 from collections.abc import Callable 

27 

28 

29class WizardAction(Enum): 

30 """Actions the wizard can perform.""" 

31 

32 MEASURE = "measure" 

33 CHARACTERIZE = "characterize" 

34 DECODE = "decode" 

35 FILTER = "filter" 

36 SPECTRAL = "spectral" 

37 COMPARE = "compare" 

38 

39 

40@dataclass 

41class WizardStep: 

42 """A step in the analysis wizard. 

43 

44 Attributes: 

45 title: Step title 

46 question: Question to ask user 

47 options: Available options 

48 action: Action to perform based on choice 

49 help_text: Additional help for this step 

50 """ 

51 

52 title: str 

53 question: str 

54 options: list[str] 

55 action: Callable[[int], None] | None = None 

56 help_text: str = "" 

57 skip_condition: Callable[[Any], bool] | None = None 

58 

59 

60@dataclass 

61class WizardResult: 

62 """Result from wizard analysis. 

63 

64 Attributes: 

65 steps_completed: Number of steps completed 

66 measurements: Collected measurements 

67 recommendations: Analysis recommendations 

68 summary: Human-readable summary 

69 """ 

70 

71 steps_completed: int = 0 

72 measurements: dict[str, Any] = field(default_factory=dict) 

73 recommendations: list[str] = field(default_factory=list) 

74 summary: str = "" 

75 

76 

77class AnalysisWizard: 

78 """Interactive analysis wizard. 

79 

80 Guides users through signal analysis with intelligent 

81 recommendations and plain English explanations. 

82 """ 

83 

84 def __init__(self, trace: Any) -> None: 

85 """Initialize wizard with a trace. 

86 

87 Args: 

88 trace: WaveformTrace or DigitalTrace to analyze 

89 """ 

90 self.trace = trace 

91 self.result = WizardResult() 

92 self.steps: list[WizardStep] = self._build_steps() 

93 self.current_step = 0 

94 

95 def _build_steps(self) -> list[WizardStep]: 

96 """Build the wizard steps based on trace characteristics.""" 

97 steps = [ 

98 WizardStep( 

99 title="Signal Type Detection", 

100 question="What type of analysis do you want to perform?", 

101 options=[ 

102 "Auto-detect (let TraceKit figure it out)", 

103 "Digital signal analysis", 

104 "Analog/waveform analysis", 

105 "Protocol decoding", 

106 "Power analysis", 

107 ], 

108 action=self._handle_signal_type, 

109 help_text="Not sure? Choose 'Auto-detect' and we'll analyze your signal.", 

110 ), 

111 WizardStep( 

112 title="Basic Measurements", 

113 question="Would you like to run basic measurements?", 

114 options=[ 

115 "Yes, run all standard measurements", 

116 "Yes, but only timing measurements", 

117 "Yes, but only amplitude measurements", 

118 "No, skip this step", 

119 ], 

120 action=self._handle_measurements, 

121 help_text="Basic measurements give you an overview of your signal.", 

122 ), 

123 WizardStep( 

124 title="Spectral Analysis", 

125 question="Would you like to analyze the frequency content?", 

126 options=[ 

127 "Yes, compute FFT spectrum", 

128 "Yes, compute power spectral density", 

129 "Yes, both FFT and PSD", 

130 "No, skip spectral analysis", 

131 ], 

132 action=self._handle_spectral, 

133 help_text="Spectral analysis shows what frequencies are in your signal.", 

134 ), 

135 WizardStep( 

136 title="Signal Quality", 

137 question="Would you like to assess signal quality?", 

138 options=[ 

139 "Yes, measure THD and SNR", 

140 "Yes, check for anomalies", 

141 "Yes, full quality assessment", 

142 "No, skip quality check", 

143 ], 

144 action=self._handle_quality, 

145 help_text="Quality metrics help identify issues with your signal.", 

146 ), 

147 ] 

148 return steps 

149 

150 def run(self, interactive: bool = True) -> WizardResult: 

151 """Run the analysis wizard. 

152 

153 Args: 

154 interactive: If True, prompt for user input 

155 

156 Returns: 

157 WizardResult with all collected data 

158 """ 

159 print("\n" + "=" * 60) 

160 print("TraceKit Analysis Wizard") 

161 print("=" * 60) 

162 print("Let's analyze your signal step by step.\n") 

163 

164 # Show trace summary 

165 self._show_trace_summary() 

166 

167 for i, step in enumerate(self.steps): 

168 # Check skip condition 

169 if step.skip_condition and step.skip_condition(self.result): 169 ↛ 170line 169 didn't jump to line 170 because the condition on line 169 was never true

170 continue 

171 

172 print(f"\n{'=' * 60}") 

173 print(f"Step {i + 1}/{len(self.steps)}: {step.title}") 

174 print("=" * 60) 

175 

176 if step.help_text: 176 ↛ 179line 176 didn't jump to line 179 because the condition on line 176 was always true

177 print(f"Tip: {step.help_text}\n") 

178 

179 print(step.question) 

180 for j, option in enumerate(step.options, 1): 

181 print(f" {j}. {option}") 

182 

183 if interactive: 183 ↛ 184line 183 didn't jump to line 184 because the condition on line 183 was never true

184 choice = self._get_user_choice(len(step.options)) 

185 else: 

186 choice = 1 # Auto-select first option 

187 

188 if step.action: 188 ↛ 191line 188 didn't jump to line 191 because the condition on line 188 was always true

189 step.action(choice) 

190 

191 self.result.steps_completed = i + 1 

192 

193 # Generate summary 

194 self._generate_summary() 

195 

196 print("\n" + "=" * 60) 

197 print("Analysis Complete!") 

198 print("=" * 60) 

199 print(self.result.summary) 

200 

201 if self.result.recommendations: 

202 print("\nRecommendations:") 

203 for rec in self.result.recommendations: 

204 print(f" - {rec}") 

205 

206 return self.result 

207 

208 def _show_trace_summary(self) -> None: 

209 """Show a summary of the loaded trace.""" 

210 trace = self.trace 

211 print("Loaded trace summary:") 

212 

213 if hasattr(trace, "data"): 213 ↛ 216line 213 didn't jump to line 216 because the condition on line 213 was always true

214 print(f" Samples: {len(trace.data):,}") 

215 

216 if hasattr(trace, "metadata"): 

217 meta = trace.metadata 

218 if hasattr(meta, "sample_rate") and meta.sample_rate: 218 ↛ 227line 218 didn't jump to line 227 because the condition on line 218 was always true

219 rate = meta.sample_rate 

220 if rate >= 1e9: 

221 print(f" Sample rate: {rate / 1e9:.3f} GSa/s") 

222 elif rate >= 1e6: 

223 print(f" Sample rate: {rate / 1e6:.3f} MSa/s") 

224 else: 

225 print(f" Sample rate: {rate / 1e3:.3f} kSa/s") 

226 

227 if hasattr(meta, "channel_name") and meta.channel_name: 227 ↛ 230line 227 didn't jump to line 230 because the condition on line 227 was always true

228 print(f" Channel: {meta.channel_name}") 

229 

230 if hasattr(trace, "data"): 230 ↛ exitline 230 didn't return from function '_show_trace_summary' because the condition on line 230 was always true

231 import numpy as np 

232 

233 data = trace.data 

234 print(f" Value range: {np.min(data):.4g} to {np.max(data):.4g}") 

235 

236 def _get_user_choice(self, max_options: int) -> int: 

237 """Get user's choice with validation.""" 

238 while True: 

239 try: 

240 choice_str = input(f"\nEnter choice (1-{max_options}): ") 

241 choice = int(choice_str) 

242 if 1 <= choice <= max_options: 

243 return choice 

244 print(f"Please enter a number between 1 and {max_options}") 

245 except ValueError: 

246 print("Please enter a valid number") 

247 

248 def _handle_signal_type(self, choice: int) -> None: 

249 """Handle signal type selection.""" 

250 if choice == 1: # Auto-detect 

251 print("\nAuto-detecting signal type...") 

252 try: 

253 from tracekit.discovery import characterize_signal 

254 

255 result = characterize_signal(self.trace) 

256 self.result.measurements["signal_type"] = result.signal_type 

257 self.result.measurements["signal_confidence"] = result.confidence 

258 print(f"Detected: {result.signal_type} (confidence: {result.confidence:.0%})") 

259 

260 if result.confidence < 0.8: 

261 self.result.recommendations.append( 

262 f"Signal type detection has low confidence. " 

263 f"Consider alternatives: {[a.signal_type for a in result.alternatives[:2]]}" # type: ignore[attr-defined] 

264 ) 

265 except Exception as e: 

266 print(f"Auto-detection failed: {e}") 

267 self.result.measurements["signal_type"] = "unknown" 

268 

269 elif choice == 2: # Digital 

270 self.result.measurements["signal_type"] = "digital" 

271 print("\nDigital analysis mode selected.") 

272 

273 elif choice == 3: # Analog 

274 self.result.measurements["signal_type"] = "analog" 

275 print("\nAnalog/waveform analysis mode selected.") 

276 

277 elif choice == 4: # Protocol 

278 self.result.measurements["signal_type"] = "protocol" 

279 print("\nProtocol decoding mode selected.") 

280 self.result.recommendations.append( 

281 "For protocol decoding, try: decode_uart(), decode_spi(), decode_i2c()" 

282 ) 

283 

284 elif choice == 5: # Power 284 ↛ exitline 284 didn't return from function '_handle_signal_type' because the condition on line 284 was always true

285 self.result.measurements["signal_type"] = "power" 

286 print("\nPower analysis mode selected.") 

287 

288 def _handle_measurements(self, choice: int) -> None: 

289 """Handle measurement selection.""" 

290 import tracekit as tk 

291 

292 if choice == 4: # Skip 

293 print("\nSkipping measurements.") 

294 return 

295 

296 print("\nRunning measurements...") 

297 

298 try: 

299 if choice == 1: # All 299 ↛ 301line 299 didn't jump to line 301 because the condition on line 299 was always true

300 results = tk.measure(self.trace) 

301 elif choice == 2: # Timing only 

302 results = { 

303 "rise_time": tk.rise_time(self.trace), 

304 "fall_time": tk.fall_time(self.trace), 

305 "frequency": tk.frequency(self.trace), 

306 "period": tk.period(self.trace), 

307 "duty_cycle": tk.duty_cycle(self.trace), 

308 } 

309 elif choice == 3: # Amplitude only 

310 results = { 

311 "amplitude": tk.amplitude(self.trace), 

312 "rms": tk.rms(self.trace), 

313 "mean": tk.mean(self.trace), 

314 "overshoot": tk.overshoot(self.trace), 

315 "undershoot": tk.undershoot(self.trace), 

316 } 

317 

318 self.result.measurements.update(results) 

319 

320 print("\nMeasurement results:") 

321 for name, value in results.items(): 

322 if isinstance(value, float): 322 ↛ 323line 322 didn't jump to line 323 because the condition on line 322 was never true

323 print(f" {name}: {value:.4g}") 

324 else: 

325 print(f" {name}: {value}") 

326 

327 except Exception as e: 

328 print(f"Measurement error: {e}") 

329 

330 def _handle_spectral(self, choice: int) -> None: 

331 """Handle spectral analysis selection.""" 

332 import numpy as np 

333 

334 import tracekit as tk 

335 

336 if choice == 4: # Skip 

337 print("\nSkipping spectral analysis.") 

338 return 

339 

340 print("\nRunning spectral analysis...") 

341 

342 try: 

343 if choice in (1, 3): # FFT 343 ↛ 350line 343 didn't jump to line 350 because the condition on line 343 was always true

344 freq, mag = tk.fft(self.trace) # type: ignore[misc] 

345 peak_idx = np.argmax(mag) 

346 self.result.measurements["fft_peak_freq"] = freq[peak_idx] 

347 self.result.measurements["fft_peak_mag"] = mag[peak_idx] 

348 print(f" FFT peak: {freq[peak_idx] / 1e6:.3f} MHz at {mag[peak_idx]:.1f} dB") 

349 

350 if choice in (2, 3): # PSD 350 ↛ 351line 350 didn't jump to line 351 because the condition on line 350 was never true

351 freq, _psd_vals = tk.psd(self.trace) 

352 self.result.measurements["psd_computed"] = True 

353 print(f" PSD computed over {len(freq)} frequency bins") 

354 

355 except Exception as e: 

356 print(f"Spectral analysis error: {e}") 

357 

358 def _handle_quality(self, choice: int) -> None: 

359 """Handle quality assessment selection.""" 

360 import tracekit as tk 

361 

362 if choice == 4: # Skip 

363 print("\nSkipping quality assessment.") 

364 return 

365 

366 print("\nAssessing signal quality...") 

367 

368 try: 

369 if choice in (1, 3): # THD and SNR 369 ↛ 387line 369 didn't jump to line 387 because the condition on line 369 was always true

370 thd_val = tk.thd(self.trace) 

371 snr_val = tk.snr(self.trace) 

372 self.result.measurements["thd"] = thd_val 

373 self.result.measurements["snr"] = snr_val 

374 print(f" THD: {thd_val:.1f} dB") 

375 print(f" SNR: {snr_val:.1f} dB") 

376 

377 # Recommendations based on quality 

378 if thd_val > -40: 

379 self.result.recommendations.append( 

380 f"THD is {thd_val:.1f} dB - consider filtering to reduce distortion" 

381 ) 

382 if snr_val < 40: 

383 self.result.recommendations.append( 

384 f"SNR is {snr_val:.1f} dB - signal is noisy, try averaging or filtering" 

385 ) 

386 

387 if choice in (2, 3): # Anomalies 387 ↛ 388line 387 didn't jump to line 388 because the condition on line 387 was never true

388 from tracekit.discovery import find_anomalies 

389 

390 anomalies = find_anomalies(self.trace) 

391 self.result.measurements["anomaly_count"] = len(anomalies) 

392 print(f" Found {len(anomalies)} anomalies") 

393 

394 if anomalies: 

395 self.result.recommendations.append( 

396 f"Found {len(anomalies)} anomalies - review the anomaly list for issues" 

397 ) 

398 

399 except Exception as e: 

400 print(f"Quality assessment error: {e}") 

401 

402 def _generate_summary(self) -> None: 

403 """Generate a human-readable summary of the analysis.""" 

404 lines = ["Analysis Summary:"] 

405 

406 if "signal_type" in self.result.measurements: 

407 lines.append(f" Signal type: {self.result.measurements['signal_type']}") 

408 

409 if "frequency" in self.result.measurements: 

410 freq = self.result.measurements["frequency"] 

411 # Handle dict format from measure() 

412 if isinstance(freq, dict): 

413 freq = freq.get("value", 0) 

414 

415 if freq >= 1e6: 

416 lines.append(f" Frequency: {freq / 1e6:.3f} MHz") 

417 elif freq >= 1e3: 417 ↛ 418line 417 didn't jump to line 418 because the condition on line 417 was never true

418 lines.append(f" Frequency: {freq / 1e3:.3f} kHz") 

419 else: 

420 lines.append(f" Frequency: {freq:.1f} Hz") 

421 

422 if "rise_time" in self.result.measurements: 

423 rt = self.result.measurements["rise_time"] 

424 # Handle dict format from measure() 

425 if isinstance(rt, dict): 

426 rt = rt.get("value", 0) 

427 

428 lines.append(f" Rise time: {rt * 1e9:.2f} ns") 

429 

430 if "thd" in self.result.measurements: 

431 thd = self.result.measurements["thd"] 

432 # Handle dict format 

433 if isinstance(thd, dict): 433 ↛ 434line 433 didn't jump to line 434 because the condition on line 433 was never true

434 thd = thd.get("value", 0) 

435 lines.append(f" THD: {thd:.1f} dB") 

436 

437 if "snr" in self.result.measurements: 

438 snr = self.result.measurements["snr"] 

439 # Handle dict format 

440 if isinstance(snr, dict): 440 ↛ 441line 440 didn't jump to line 441 because the condition on line 440 was never true

441 snr = snr.get("value", 0) 

442 lines.append(f" SNR: {snr:.1f} dB") 

443 

444 self.result.summary = "\n".join(lines) 

445 

446 

447def run_wizard(trace: Any, interactive: bool = True) -> WizardResult: 

448 """Run the analysis wizard on a trace. 

449 

450 This is the main entry point for guided analysis. 

451 

452 Args: 

453 trace: WaveformTrace or DigitalTrace to analyze 

454 interactive: If True, prompt for user input 

455 

456 Returns: 

457 WizardResult with measurements, recommendations, and summary 

458 

459 Example: 

460 >>> import tracekit as tk 

461 >>> from tracekit.onboarding import run_wizard 

462 >>> trace = tk.load("signal.csv") 

463 >>> result = run_wizard(trace) 

464 """ 

465 wizard = AnalysisWizard(trace) 

466 return wizard.run(interactive=interactive)