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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""Automatic executive report generation.
3This module provides one-click generation of comprehensive analysis reports
4in multiple formats (PDF, HTML, Markdown).
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")
13References:
14 TraceKit Auto-Discovery Specification
15"""
17from __future__ import annotations
19from dataclasses import dataclass, field
20from datetime import datetime
21from pathlib import Path
22from typing import TYPE_CHECKING, Any
24import numpy as np
26if TYPE_CHECKING:
27 from tracekit.core.types import WaveformTrace
30@dataclass
31class ReportMetadata:
32 """Report metadata.
34 Attributes:
35 title: Report title.
36 author: Report author.
37 date: Report date.
38 project: Project name.
39 tags: List of tags.
40 """
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)
49@dataclass
50class Report:
51 """Executive analysis report.
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 """
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
71 def save_pdf(self, path: str) -> None:
72 """Save report as PDF.
74 Args:
75 path: Output file path.
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")
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")
94 # Estimate file size
95 self.file_size_mb = Path(path).stat().st_size / (1024 * 1024)
97 def save_html(self, path: str) -> None:
98 """Save report as HTML.
100 Args:
101 path: Output file path.
102 """
103 self.output_path = path
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>
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"
161 if self.metadata.tags:
162 html_content += (
163 f" <p><strong>Tags:</strong> {', '.join(self.metadata.tags)}</p>\n"
164 )
166 html_content += " </div>\n\n"
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)
181 self.file_size_mb = Path(path).stat().st_size / (1024 * 1024)
183 def save_markdown(self, path: str) -> None:
184 """Save report as Markdown.
186 Args:
187 path: Output file path.
188 """
189 self.output_path = path
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"
195 if self.metadata.project:
196 md_content += f"**Project:** {self.metadata.project} \n"
198 if self.metadata.tags:
199 md_content += f"**Tags:** {', '.join(self.metadata.tags)} \n"
201 md_content += "\n---\n\n"
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"
209 with open(path, "w") as f:
210 f.write(md_content)
212 self.file_size_mb = Path(path).stat().st_size / (1024 * 1024)
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.
222 Args:
223 title: Section title.
224 content: Section content.
225 position: Insert position (None = append).
226 """
227 section_key = title.lower().replace(" ", "_")
229 if position is None:
230 self.sections.append(section_key)
231 else:
232 self.sections.insert(position, section_key)
234 self.content[section_key] = content
236 def include_plots(self, plot_types: list[str]) -> None:
237 """Select which plots to include in report.
239 Args:
240 plot_types: List of plot type names.
241 """
242 self.plots = plot_types
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.
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
273def _generate_executive_summary(trace: WaveformTrace, context: dict) -> str: # type: ignore[type-arg]
274 """Generate executive summary section.
276 Args:
277 trace: Waveform to analyze.
278 context: Analysis context.
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))
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. "
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}. "
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}. "
303 summary += "Detailed findings and recommendations are provided in the sections below."
305 return summary
308def _generate_key_findings(trace: WaveformTrace, context: dict) -> str: # type: ignore[type-arg]
309 """Generate key findings section.
311 Args:
312 trace: Waveform to analyze.
313 context: Analysis context.
315 Returns:
316 Key findings text.
317 """
318 findings = []
320 # Basic signal characteristics
321 v_range = np.ptp(trace.data)
322 findings.append(f"Signal swing: {v_range:.3f}V")
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")
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")
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"
340 return findings_text
343def _generate_methodology(trace: WaveformTrace, context: dict[str, Any]) -> str:
344 """Generate methodology section.
346 Args:
347 trace: Waveform to analyze.
348 context: Analysis context.
350 Returns:
351 Methodology description.
352 """
353 methodology = "Analysis methodology:\n\n"
355 methodology += "Signal characterization: Automated signal type detection using "
356 methodology += "statistical analysis and pattern recognition algorithms.\n\n"
358 methodology += "Quality assessment: Signal-to-noise ratio, clipping detection, "
359 methodology += "and sample rate validation.\n\n"
361 if "anomalies" in context:
362 methodology += "Anomaly detection: Automated detection of glitches, dropouts, "
363 methodology += "noise spikes, and timing violations.\n\n"
365 if "decode" in context:
366 methodology += "Protocol decode: Automatic parameter detection and "
367 methodology += "frame extraction with confidence scoring.\n\n"
369 return methodology
372def _generate_detailed_results(trace: WaveformTrace, context: dict[str, Any]) -> str:
373 """Generate detailed results section.
375 Args:
376 trace: Waveform to analyze.
377 context: Analysis context.
379 Returns:
380 Detailed results text.
381 """
382 results = "Detailed measurement results:\n\n"
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"
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"
397 return results
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.
410 Creates a professional report with executive summary, key findings,
411 methodology, and detailed results. Auto-includes relevant plots.
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)
426 Returns:
427 Report object with content and save methods.
429 Example:
430 >>> report = generate_report(trace)
431 >>> report.save_pdf("analysis.pdf")
432 >>> print(f"Generated {report.page_count} page report")
434 References:
435 DISC-005: Automatic Executive Report
436 """
437 context = context or {}
438 options = options or {}
440 # Determine sections to include
441 default_sections = [
442 "executive_summary",
443 "key_findings",
444 "methodology",
445 "detailed_results",
446 ]
448 sections = options.get("select_sections", default_sections)
450 # Generate content for each section
451 content = {}
453 if "executive_summary" in sections or "summary" in sections:
454 content["executive_summary"] = _generate_executive_summary(trace, context)
456 if "key_findings" in sections or "findings" in sections:
457 content["key_findings"] = _generate_key_findings(trace, context)
459 if "methodology" in sections:
460 content["methodology"] = _generate_methodology(trace, context)
462 if "detailed_results" in sections or "results" in sections:
463 content["detailed_results"] = _generate_detailed_results(trace, context)
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 )
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"]
479 # Add spectral if signal looks periodic
480 if len(trace.data) > 100:
481 plot_types.append("fft_spectrum")
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
488 # Create report object
489 report = Report(
490 sections=list(sections),
491 plots=plot_types,
492 page_count=page_count,
493 content=content,
494 )
496 # Set custom metadata if provided
497 if "custom_header" in options:
498 report.metadata.title = options["custom_header"]
500 return report
503__all__ = [
504 "Report",
505 "ReportMetadata",
506 "generate_report",
507]