Coverage for src / tracekit / reporting / auto_report.py: 96%

175 statements  

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

1"""Automatic executive report generation. 

2 

3This module provides one-click generation of comprehensive analysis reports 

4in multiple formats (PDF, HTML, Markdown). 

5 

6 

7Example: 

8 >>> from tracekit.reporting import generate_report 

9 >>> trace = load("capture.wfm") 

10 >>> report = generate_report(trace) 

11 >>> report.save_pdf("analysis_report.pdf") 

12 

13References: 

14 TraceKit Auto-Discovery Specification 

15""" 

16 

17from __future__ import annotations 

18 

19from dataclasses import dataclass, field 

20from datetime import datetime 

21from pathlib import Path 

22from typing import TYPE_CHECKING, Any 

23 

24import numpy as np 

25 

26if TYPE_CHECKING: 

27 from tracekit.core.types import WaveformTrace 

28 

29 

30@dataclass 

31class ReportMetadata: 

32 """Report metadata. 

33 

34 Attributes: 

35 title: Report title. 

36 author: Report author. 

37 date: Report date. 

38 project: Project name. 

39 tags: List of tags. 

40 """ 

41 

42 title: str = "Signal Analysis Report" 

43 author: str = "TraceKit" 

44 date: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d")) 

45 project: str | None = None 

46 tags: list[str] = field(default_factory=list) 

47 

48 

49@dataclass 

50class Report: 

51 """Executive analysis report. 

52 

53 Attributes: 

54 sections: List of section names included. 

55 plots: List of plot types included. 

56 page_count: Estimated page count. 

57 metadata: Report metadata. 

58 content: Dictionary of section content. 

59 output_path: Path to saved report. 

60 file_size_mb: File size in MB (if saved). 

61 """ 

62 

63 sections: list[str] = field(default_factory=list) 

64 plots: list[str] = field(default_factory=list) 

65 page_count: int = 0 

66 metadata: ReportMetadata = field(default_factory=ReportMetadata) 

67 content: dict[str, str] = field(default_factory=dict) 

68 output_path: str | None = None 

69 file_size_mb: float = 0.0 

70 

71 def save_pdf(self, path: str) -> None: 

72 """Save report as PDF. 

73 

74 Args: 

75 path: Output file path. 

76 

77 Note: 

78 This is a placeholder implementation. Full PDF generation 

79 would require reportlab or similar library. 

80 """ 

81 self.output_path = path 

82 # Placeholder: would generate actual PDF here 

83 with open(path, "w") as f: 

84 f.write("PDF Report - Placeholder\n") 

85 f.write(f"Title: {self.metadata.title}\n") 

86 f.write(f"Date: {self.metadata.date}\n\n") 

87 

88 for section in self.sections: 

89 if section in self.content: 89 ↛ 88line 89 didn't jump to line 88 because the condition on line 89 was always true

90 f.write(f"\n{section.upper()}\n") 

91 f.write("=" * 60 + "\n") 

92 f.write(self.content[section] + "\n") 

93 

94 # Estimate file size 

95 self.file_size_mb = Path(path).stat().st_size / (1024 * 1024) 

96 

97 def save_html(self, path: str) -> None: 

98 """Save report as HTML. 

99 

100 Args: 

101 path: Output file path. 

102 """ 

103 self.output_path = path 

104 

105 html_content = f"""<!DOCTYPE html> 

106<html> 

107<head> 

108 <meta charset="UTF-8"> 

109 <title>{self.metadata.title}</title> 

110 <style> 

111 body {{ 

112 font-family: Arial, sans-serif; 

113 max-width: 900px; 

114 margin: 0 auto; 

115 padding: 20px; 

116 line-height: 1.6; 

117 }} 

118 h1 {{ 

119 color: #2c3e50; 

120 border-bottom: 3px solid #3498db; 

121 padding-bottom: 10px; 

122 }} 

123 h2 {{ 

124 color: #34495e; 

125 margin-top: 30px; 

126 border-bottom: 2px solid #ecf0f1; 

127 padding-bottom: 5px; 

128 }} 

129 .metadata {{ 

130 background-color: #ecf0f1; 

131 padding: 15px; 

132 border-radius: 5px; 

133 margin-bottom: 20px; 

134 }} 

135 .section {{ 

136 margin-bottom: 30px; 

137 }} 

138 .critical {{ 

139 color: #e74c3c; 

140 font-weight: bold; 

141 }} 

142 .warning {{ 

143 color: #f39c12; 

144 font-weight: bold; 

145 }} 

146 .info {{ 

147 color: #3498db; 

148 }} 

149 </style> 

150</head> 

151<body> 

152 <h1>{self.metadata.title}</h1> 

153 

154 <div class="metadata"> 

155 <p><strong>Date:</strong> {self.metadata.date}</p> 

156 <p><strong>Author:</strong> {self.metadata.author}</p> 

157""" 

158 if self.metadata.project: 

159 html_content += f" <p><strong>Project:</strong> {self.metadata.project}</p>\n" 

160 

161 if self.metadata.tags: 

162 html_content += ( 

163 f" <p><strong>Tags:</strong> {', '.join(self.metadata.tags)}</p>\n" 

164 ) 

165 

166 html_content += " </div>\n\n" 

167 

168 for section in self.sections: 

169 if section in self.content: 169 ↛ 168line 169 didn't jump to line 168 because the condition on line 169 was always true

170 section_title = section.replace("_", " ").title() 

171 html_content += f""" <div class="section"> 

172 <h2>{section_title}</h2> 

173 <p>{self.content[section]}</p> 

174 </div> 

175""" 

176 html_content += """</body> 

177</html>""" 

178 with open(path, "w") as f: 

179 f.write(html_content) 

180 

181 self.file_size_mb = Path(path).stat().st_size / (1024 * 1024) 

182 

183 def save_markdown(self, path: str) -> None: 

184 """Save report as Markdown. 

185 

186 Args: 

187 path: Output file path. 

188 """ 

189 self.output_path = path 

190 

191 md_content = f"# {self.metadata.title}\n\n" 

192 md_content += f"**Date:** {self.metadata.date} \n" 

193 md_content += f"**Author:** {self.metadata.author} \n" 

194 

195 if self.metadata.project: 

196 md_content += f"**Project:** {self.metadata.project} \n" 

197 

198 if self.metadata.tags: 

199 md_content += f"**Tags:** {', '.join(self.metadata.tags)} \n" 

200 

201 md_content += "\n---\n\n" 

202 

203 for section in self.sections: 

204 if section in self.content: 204 ↛ 203line 204 didn't jump to line 203 because the condition on line 204 was always true

205 section_title = section.replace("_", " ").title() 

206 md_content += f"## {section_title}\n\n" 

207 md_content += self.content[section] + "\n\n" 

208 

209 with open(path, "w") as f: 

210 f.write(md_content) 

211 

212 self.file_size_mb = Path(path).stat().st_size / (1024 * 1024) 

213 

214 def add_section( 

215 self, 

216 title: str, 

217 content: str, 

218 position: int | None = None, 

219 ) -> None: 

220 """Add custom section to report. 

221 

222 Args: 

223 title: Section title. 

224 content: Section content. 

225 position: Insert position (None = append). 

226 """ 

227 section_key = title.lower().replace(" ", "_") 

228 

229 if position is None: 

230 self.sections.append(section_key) 

231 else: 

232 self.sections.insert(position, section_key) 

233 

234 self.content[section_key] = content 

235 

236 def include_plots(self, plot_types: list[str]) -> None: 

237 """Select which plots to include in report. 

238 

239 Args: 

240 plot_types: List of plot type names. 

241 """ 

242 self.plots = plot_types 

243 

244 def set_metadata( 

245 self, 

246 title: str | None = None, 

247 author: str | None = None, 

248 date: str | None = None, 

249 project: str | None = None, 

250 tags: list[str] | None = None, 

251 ) -> None: 

252 """Set report metadata. 

253 

254 Args: 

255 title: Report title. 

256 author: Report author. 

257 date: Report date. 

258 project: Project name. 

259 tags: List of tags. 

260 """ 

261 if title: 

262 self.metadata.title = title 

263 if author: 263 ↛ 265line 263 didn't jump to line 265 because the condition on line 263 was always true

264 self.metadata.author = author 

265 if date: 

266 self.metadata.date = date 

267 if project: 

268 self.metadata.project = project 

269 if tags: 

270 self.metadata.tags = tags 

271 

272 

273def _generate_executive_summary(trace: WaveformTrace, context: dict) -> str: # type: ignore[type-arg] 

274 """Generate executive summary section. 

275 

276 Args: 

277 trace: Waveform to analyze. 

278 context: Analysis context. 

279 

280 Returns: 

281 Executive summary text (≤200 words). 

282 """ 

283 sample_rate = trace.metadata.sample_rate 

284 duration_ms = len(trace.data) / sample_rate * 1000 

285 v_min = float(np.min(trace.data)) 

286 v_max = float(np.max(trace.data)) 

287 

288 summary = "This report presents analysis of a signal capture taken at " 

289 summary += f"{sample_rate / 1e6:.1f} MS/s sample rate over {duration_ms:.2f} milliseconds. " 

290 summary += f"The signal ranges from {v_min:.3f}V to {v_max:.3f}V. " 

291 

292 # Add context-specific information 

293 if "characterization" in context: 

294 char = context["characterization"] 

295 if hasattr(char, "signal_type"): 295 ↛ 298line 295 didn't jump to line 298 because the condition on line 295 was always true

296 summary += f"The signal was identified as {char.signal_type}. " 

297 

298 if "quality" in context: 

299 quality = context["quality"] 

300 if hasattr(quality, "status"): 300 ↛ 303line 300 didn't jump to line 303 because the condition on line 300 was always true

301 summary += f"Data quality assessment: {quality.status}. " 

302 

303 summary += "Detailed findings and recommendations are provided in the sections below." 

304 

305 return summary 

306 

307 

308def _generate_key_findings(trace: WaveformTrace, context: dict) -> str: # type: ignore[type-arg] 

309 """Generate key findings section. 

310 

311 Args: 

312 trace: Waveform to analyze. 

313 context: Analysis context. 

314 

315 Returns: 

316 Key findings text. 

317 """ 

318 findings = [] 

319 

320 # Basic signal characteristics 

321 v_range = np.ptp(trace.data) 

322 findings.append(f"Signal swing: {v_range:.3f}V") 

323 

324 # Add context-specific findings 

325 if "anomalies" in context: 

326 anomalies = context["anomalies"] 

327 if hasattr(anomalies, "__len__"): 327 ↛ 330line 327 didn't jump to line 330 because the condition on line 327 was always true

328 findings.append(f"Detected {len(anomalies)} anomalies in signal") 

329 

330 if "decode" in context: 

331 decode = context["decode"] 

332 if hasattr(decode, "data") and hasattr(decode.data, "__len__"): 332 ↛ 336line 332 didn't jump to line 336 because the condition on line 332 was always true

333 findings.append(f"Successfully decoded {len(decode.data)} bytes") 

334 

335 # Format findings 

336 findings_text = "Key findings from signal analysis:\n\n" 

337 for i, finding in enumerate(findings, 1): 

338 findings_text += f"{i}. {finding}\n" 

339 

340 return findings_text 

341 

342 

343def _generate_methodology(trace: WaveformTrace, context: dict[str, Any]) -> str: 

344 """Generate methodology section. 

345 

346 Args: 

347 trace: Waveform to analyze. 

348 context: Analysis context. 

349 

350 Returns: 

351 Methodology description. 

352 """ 

353 methodology = "Analysis methodology:\n\n" 

354 

355 methodology += "Signal characterization: Automated signal type detection using " 

356 methodology += "statistical analysis and pattern recognition algorithms.\n\n" 

357 

358 methodology += "Quality assessment: Signal-to-noise ratio, clipping detection, " 

359 methodology += "and sample rate validation.\n\n" 

360 

361 if "anomalies" in context: 

362 methodology += "Anomaly detection: Automated detection of glitches, dropouts, " 

363 methodology += "noise spikes, and timing violations.\n\n" 

364 

365 if "decode" in context: 

366 methodology += "Protocol decode: Automatic parameter detection and " 

367 methodology += "frame extraction with confidence scoring.\n\n" 

368 

369 return methodology 

370 

371 

372def _generate_detailed_results(trace: WaveformTrace, context: dict[str, Any]) -> str: 

373 """Generate detailed results section. 

374 

375 Args: 

376 trace: Waveform to analyze. 

377 context: Analysis context. 

378 

379 Returns: 

380 Detailed results text. 

381 """ 

382 results = "Detailed measurement results:\n\n" 

383 

384 # Basic statistics 

385 data = trace.data.astype(np.float64) 

386 results += f"Minimum voltage: {np.min(data):.6f}V\n" 

387 results += f"Maximum voltage: {np.max(data):.6f}V\n" 

388 results += f"Mean voltage: {np.mean(data):.6f}V\n" 

389 results += f"Standard deviation: {np.std(data):.6f}V\n" 

390 results += f"Peak-to-peak: {np.ptp(data):.6f}V\n\n" 

391 

392 # Sample info 

393 results += f"Sample count: {len(data):,}\n" 

394 results += f"Sample rate: {trace.metadata.sample_rate / 1e6:.3f} MS/s\n" 

395 results += f"Duration: {len(data) / trace.metadata.sample_rate * 1000:.3f} ms\n\n" 

396 

397 return results 

398 

399 

400def generate_report( 

401 trace: WaveformTrace, 

402 *, 

403 format: str = "pdf", 

404 template: str | None = None, 

405 context: dict[str, Any] | None = None, 

406 options: dict[str, Any] | None = None, 

407) -> Report: 

408 """Generate comprehensive executive analysis report. 

409 

410 Creates a professional report with executive summary, key findings, 

411 methodology, and detailed results. Auto-includes relevant plots. 

412 

413 Args: 

414 trace: Waveform to analyze. 

415 format: Output format ("pdf", "html", "markdown"). 

416 template: Optional template file path. 

417 context: Pre-computed analysis results (characterization, anomalies, etc.). 

418 options: Report customization options: 

419 - select_sections: List of sections to include 

420 - custom_header: Custom header text 

421 - custom_footer: Custom footer text 

422 - page_orientation: "portrait" or "landscape" 

423 - include_raw_data: Include raw data table 

424 - plot_dpi: Plot resolution (default 300) 

425 

426 Returns: 

427 Report object with content and save methods. 

428 

429 Example: 

430 >>> report = generate_report(trace) 

431 >>> report.save_pdf("analysis.pdf") 

432 >>> print(f"Generated {report.page_count} page report") 

433 

434 References: 

435 DISC-005: Automatic Executive Report 

436 """ 

437 context = context or {} 

438 options = options or {} 

439 

440 # Determine sections to include 

441 default_sections = [ 

442 "executive_summary", 

443 "key_findings", 

444 "methodology", 

445 "detailed_results", 

446 ] 

447 

448 sections = options.get("select_sections", default_sections) 

449 

450 # Generate content for each section 

451 content = {} 

452 

453 if "executive_summary" in sections or "summary" in sections: 

454 content["executive_summary"] = _generate_executive_summary(trace, context) 

455 

456 if "key_findings" in sections or "findings" in sections: 

457 content["key_findings"] = _generate_key_findings(trace, context) 

458 

459 if "methodology" in sections: 

460 content["methodology"] = _generate_methodology(trace, context) 

461 

462 if "detailed_results" in sections or "results" in sections: 

463 content["detailed_results"] = _generate_detailed_results(trace, context) 

464 

465 if "recommendations" in sections: 

466 content["recommendations"] = ( 

467 "Recommendations based on analysis:\n\n" 

468 "1. Signal quality is acceptable for analysis\n" 

469 "2. Consider additional captures for verification\n" 

470 "3. Review anomalies if present\n" 

471 ) 

472 

473 # Determine plot types to include 

474 plot_types = options.get("plot_types", []) 

475 if not plot_types: 475 ↛ 484line 475 didn't jump to line 484 because the condition on line 475 was always true

476 # Auto-select based on signal characteristics 

477 plot_types = ["time_domain_waveform"] 

478 

479 # Add spectral if signal looks periodic 

480 if len(trace.data) > 100: 

481 plot_types.append("fft_spectrum") 

482 

483 # Estimate page count (rough estimate) 

484 page_count = 1 # Title page 

485 page_count += len(sections) # One page per section 

486 page_count += (len(plot_types) + 1) // 2 # 2 plots per page 

487 

488 # Create report object 

489 report = Report( 

490 sections=list(sections), 

491 plots=plot_types, 

492 page_count=page_count, 

493 content=content, 

494 ) 

495 

496 # Set custom metadata if provided 

497 if "custom_header" in options: 

498 report.metadata.title = options["custom_header"] 

499 

500 return report 

501 

502 

503__all__ = [ 

504 "Report", 

505 "ReportMetadata", 

506 "generate_report", 

507]