Coverage for src / tracekit / exporters / markdown_export.py: 46%
190 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"""Markdown report export for TraceKit.
3This module provides Markdown report generation with measurement tables,
4plot references, and configurable sections.
7Example:
8 >>> from tracekit.exporters.markdown_export import export_markdown
9 >>> export_markdown(measurements, "report.md", title="Analysis Report")
10"""
12from __future__ import annotations
14import base64
15from datetime import datetime
16from io import BytesIO
17from pathlib import Path
18from typing import Any
21def export_markdown(
22 data: dict[str, Any],
23 path: str | Path,
24 *,
25 title: str = "TraceKit Analysis Report",
26 author: str | None = None,
27 include_plots: bool = True,
28 embed_images: bool = True,
29 sections: list[str] | None = None,
30) -> None:
31 """Export measurement results to Markdown format.
33 Args:
34 data: Dictionary containing measurement results, plots, and metadata.
35 Expected keys:
36 - "measurements": dict of name -> value pairs
37 - "plots": list of matplotlib figures or paths
38 - "metadata": optional dict of metadata
39 - "summary": optional executive summary text
40 path: Output file path.
41 title: Report title.
42 author: Author name (optional).
43 include_plots: Include plots in report.
44 embed_images: Embed images as base64 (True) or save separately (False).
45 sections: List of sections to include. If None, includes all available.
46 Options: "metadata", "summary", "measurements", "plots", "conclusions"
48 References:
49 EXP-006
50 """
51 lines: list[str] = []
53 # Header
54 lines.append(f"# {title}\n")
55 lines.append("")
57 # Metadata section
58 if sections is None or "metadata" in sections:
59 lines.extend(_generate_metadata_section(data, author))
61 # Executive summary
62 if (sections is None or "summary" in sections) and "summary" in data:
63 lines.append("## Executive Summary\n")
64 lines.append(data["summary"])
65 lines.append("")
67 # Measurements table
68 if (sections is None or "measurements" in sections) and "measurements" in data: 68 ↛ 72line 68 didn't jump to line 72 because the condition on line 68 was always true
69 lines.extend(_generate_measurements_section(data["measurements"]))
71 # Plots
72 if include_plots and (sections is None or "plots" in sections) and "plots" in data: 72 ↛ 73line 72 didn't jump to line 73 because the condition on line 72 was never true
73 lines.extend(_generate_plots_section(data["plots"], path, embed_images))
75 # Conclusions
76 if (sections is None or "conclusions" in sections) and "conclusions" in data:
77 lines.append("## Conclusions\n")
78 lines.append(data["conclusions"])
79 lines.append("")
81 # Write to file
82 content = "\n".join(lines)
83 Path(path).write_text(content, encoding="utf-8")
86def _generate_metadata_section(data: dict[str, Any], author: str | None) -> list[str]:
87 """Generate metadata section."""
88 lines = ["## Report Information\n", ""]
90 metadata = data.get("metadata", {})
92 lines.append(f"- **Generated**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
94 if author: 94 ↛ 95line 94 didn't jump to line 95 because the condition on line 94 was never true
95 lines.append(f"- **Author**: {author}")
97 if "filename" in metadata:
98 lines.append(f"- **Source File**: `{metadata['filename']}`")
100 if "sample_rate" in metadata:
101 sr = metadata["sample_rate"]
102 if sr >= 1e9: 102 ↛ 104line 102 didn't jump to line 104 because the condition on line 102 was always true
103 sr_str = f"{sr / 1e9:.3f} GS/s"
104 elif sr >= 1e6:
105 sr_str = f"{sr / 1e6:.3f} MS/s"
106 elif sr >= 1e3:
107 sr_str = f"{sr / 1e3:.3f} kS/s"
108 else:
109 sr_str = f"{sr:.3f} S/s"
110 lines.append(f"- **Sample Rate**: {sr_str}")
112 if "samples" in metadata:
113 lines.append(f"- **Samples**: {metadata['samples']:,}")
115 if "duration" in metadata: 115 ↛ 116line 115 didn't jump to line 116 because the condition on line 115 was never true
116 dur = metadata["duration"]
117 if dur >= 1.0:
118 dur_str = f"{dur:.3f} s"
119 elif dur >= 1e-3:
120 dur_str = f"{dur * 1e3:.3f} ms"
121 elif dur >= 1e-6:
122 dur_str = f"{dur * 1e6:.3f} us"
123 else:
124 dur_str = f"{dur * 1e9:.3f} ns"
125 lines.append(f"- **Duration**: {dur_str}")
127 lines.append("")
128 return lines
131def _generate_measurements_section(measurements: dict[str, Any]) -> list[str]:
132 """Generate measurements table section."""
133 lines = ["## Measurement Results\n", ""]
135 if not measurements: 135 ↛ 136line 135 didn't jump to line 136 because the condition on line 135 was never true
136 lines.append("*No measurements available.*\n")
137 return lines
139 # Create table header
140 lines.append("| Parameter | Value | Unit | Status |")
141 lines.append("|-----------|-------|------|--------|")
143 for name, value in measurements.items():
144 if isinstance(value, dict):
145 # Structured measurement with value, unit, status
146 val = value.get("value", "N/A")
147 unit = value.get("unit", "")
148 status = value.get("status", "")
150 # Format value
151 val_str = _format_value(val, unit) if isinstance(val, float) else str(val)
153 # Format status with emoji
154 if status.upper() == "PASS": 154 ↛ 156line 154 didn't jump to line 156 because the condition on line 154 was always true
155 status_str = "PASS"
156 elif status.upper() == "FAIL":
157 status_str = "FAIL"
158 elif status.upper() == "WARNING":
159 status_str = "WARNING"
160 else:
161 status_str = status
163 lines.append(f"| {name} | {val_str} | {unit} | {status_str} |")
164 else:
165 # Simple value
166 val_str = f"{value:.6g}" if isinstance(value, float) else str(value)
167 lines.append(f"| {name} | {val_str} | - | - |")
169 lines.append("")
170 return lines
173def _format_value(value: float, unit: str) -> str:
174 """Format value with appropriate SI prefix."""
175 if value == 0: 175 ↛ 176line 175 didn't jump to line 176 because the condition on line 175 was never true
176 return "0"
178 abs_val = abs(value)
180 # Time units
181 if unit in ("s", "sec", "seconds"):
182 if abs_val >= 1.0: 182 ↛ 183line 182 didn't jump to line 183 because the condition on line 182 was never true
183 return f"{value:.6g}"
184 elif abs_val >= 1e-3: 184 ↛ 185line 184 didn't jump to line 185 because the condition on line 184 was never true
185 return f"{value * 1e3:.6g} m"
186 elif abs_val >= 1e-6: 186 ↛ 187line 186 didn't jump to line 187 because the condition on line 186 was never true
187 return f"{value * 1e6:.6g} u"
188 elif abs_val >= 1e-9: 188 ↛ 191line 188 didn't jump to line 191 because the condition on line 188 was always true
189 return f"{value * 1e9:.6g} n"
190 else:
191 return f"{value * 1e12:.6g} p"
193 # Frequency units
194 if unit in ("Hz", "hz"):
195 if abs_val >= 1e9: 195 ↛ 196line 195 didn't jump to line 196 because the condition on line 195 was never true
196 return f"{value / 1e9:.6g} G"
197 elif abs_val >= 1e6: 197 ↛ 199line 197 didn't jump to line 199 because the condition on line 197 was always true
198 return f"{value / 1e6:.6g} M"
199 elif abs_val >= 1e3:
200 return f"{value / 1e3:.6g} k"
201 else:
202 return f"{value:.6g}"
204 # Voltage units
205 if unit in ("V", "v", "volts"): 205 ↛ 216line 205 didn't jump to line 216 because the condition on line 205 was always true
206 if abs_val >= 1.0: 206 ↛ 208line 206 didn't jump to line 208 because the condition on line 206 was always true
207 return f"{value:.6g}"
208 elif abs_val >= 1e-3:
209 return f"{value * 1e3:.6g} m"
210 elif abs_val >= 1e-6:
211 return f"{value * 1e6:.6g} u"
212 else:
213 return f"{value * 1e9:.6g} n"
215 # Default formatting
216 return f"{value:.6g}"
219def _generate_plots_section(
220 plots: list[Any],
221 report_path: str | Path,
222 embed_images: bool,
223) -> list[str]:
224 """Generate plots section."""
225 lines = ["## Plots and Visualizations\n", ""]
227 report_path = Path(report_path)
228 plots_dir = report_path.parent / f"{report_path.stem}_plots"
230 for i, plot in enumerate(plots, start=1):
231 if isinstance(plot, dict):
232 # Plot with metadata
233 fig = plot.get("figure")
234 caption = plot.get("caption", f"Figure {i}")
235 alt_text = plot.get("alt_text", caption)
236 else:
237 fig = plot
238 caption = f"Figure {i}"
239 alt_text = caption
241 if fig is None:
242 continue
244 if isinstance(fig, str | Path):
245 # Path to existing image
246 if embed_images:
247 # Read and embed as base64
248 try:
249 img_data = Path(fig).read_bytes()
250 img_ext = Path(fig).suffix.lower()
251 mime_type = {
252 ".png": "image/png",
253 ".jpg": "image/jpeg",
254 ".jpeg": "image/jpeg",
255 ".svg": "image/svg+xml",
256 }.get(img_ext, "image/png")
258 b64 = base64.b64encode(img_data).decode("utf-8")
259 lines.append(f"### {caption}\n")
260 lines.append(f"\n")
261 except Exception:
262 lines.append(f"### {caption}\n")
263 lines.append(f"*Unable to embed image: {fig}*\n")
264 else:
265 lines.append(f"### {caption}\n")
266 lines.append(f"\n")
267 else:
268 # Matplotlib figure
269 try:
270 if embed_images:
271 # Embed as base64 PNG
272 buf = BytesIO()
273 fig.savefig(buf, format="png", dpi=150, bbox_inches="tight")
274 buf.seek(0)
275 b64 = base64.b64encode(buf.read()).decode("utf-8")
276 lines.append(f"### {caption}\n")
277 lines.append(f"\n")
278 else:
279 # Save to separate file
280 plots_dir.mkdir(exist_ok=True)
281 plot_path = plots_dir / f"figure_{i}.png"
282 fig.savefig(plot_path, format="png", dpi=150, bbox_inches="tight")
283 rel_path = plot_path.relative_to(report_path.parent)
284 lines.append(f"### {caption}\n")
285 lines.append(f"\n")
286 except Exception as e:
287 lines.append(f"### {caption}\n")
288 lines.append(f"*Unable to render figure: {e}*\n")
290 lines.append("")
292 return lines
295def generate_markdown_report(
296 data: dict[str, Any],
297 *,
298 title: str = "TraceKit Analysis Report",
299 author: str | None = None,
300 include_plots: bool = True,
301 embed_images: bool = True,
302 sections: list[str] | None = None,
303) -> str:
304 """Generate Markdown report as string.
306 Args:
307 data: Dictionary containing measurement results, plots, and metadata.
308 title: Report title.
309 author: Author name (optional).
310 include_plots: Include plots in report.
311 embed_images: Embed images as base64.
312 sections: List of sections to include.
314 Returns:
315 Markdown content as string.
317 References:
318 EXP-006
319 """
320 lines: list[str] = []
322 # Header
323 lines.append(f"# {title}\n")
324 lines.append("")
326 # Metadata section
327 if sections is None or "metadata" in sections: 327 ↛ 331line 327 didn't jump to line 331 because the condition on line 327 was always true
328 lines.extend(_generate_metadata_section(data, author))
330 # Executive summary
331 if (sections is None or "summary" in sections) and "summary" in data: 331 ↛ 332line 331 didn't jump to line 332 because the condition on line 331 was never true
332 lines.append("## Executive Summary\n")
333 lines.append(data["summary"])
334 lines.append("")
336 # Measurements table
337 if (sections is None or "measurements" in sections) and "measurements" in data: 337 ↛ 341line 337 didn't jump to line 341 because the condition on line 337 was always true
338 lines.extend(_generate_measurements_section(data["measurements"]))
340 # For string generation, only include plots if embed_images is True
341 if include_plots and embed_images and (sections is None or "plots" in sections): 341 ↛ 356line 341 didn't jump to line 356 because the condition on line 341 was always true
342 if "plots" in data: 342 ↛ 344line 342 didn't jump to line 344 because the condition on line 342 was never true
343 # Simplified plot handling for string output
344 lines.append("## Plots and Visualizations\n")
345 lines.append("")
346 for i, plot in enumerate(data["plots"], start=1):
347 if isinstance(plot, dict):
348 caption = plot.get("caption", f"Figure {i}")
349 else:
350 caption = f"Figure {i}"
351 lines.append(f"### {caption}\n")
352 lines.append("*[Embedded plot - save to file to view]*\n")
353 lines.append("")
355 # Conclusions
356 if (sections is None or "conclusions" in sections) and "conclusions" in data: 356 ↛ 357line 356 didn't jump to line 357 because the condition on line 356 was never true
357 lines.append("## Conclusions\n")
358 lines.append(data["conclusions"])
359 lines.append("")
361 return "\n".join(lines)
364__all__ = [
365 "export_markdown",
366 "generate_markdown_report",
367]