Coverage for src / tracekit / compliance / reporting.py: 69%
97 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"""EMC compliance report generation.
3This module provides compliance report generation in multiple formats.
6Example:
7 >>> from tracekit.compliance import test_compliance, generate_compliance_report
8 >>> result = test_compliance(trace, mask)
9 >>> generate_compliance_report(result, 'report.html')
11References:
12 ANSI C63.4 (Test Methods)
13"""
15from __future__ import annotations
17from datetime import datetime
18from enum import Enum
19from pathlib import Path
20from typing import TYPE_CHECKING
22if TYPE_CHECKING:
23 from tracekit.compliance.testing import ComplianceResult
26class ComplianceReportFormat(Enum):
27 """Compliance report output formats."""
29 HTML = "html"
30 PDF = "pdf"
31 MARKDOWN = "markdown"
32 JSON = "json"
35def generate_compliance_report(
36 result: ComplianceResult,
37 output_path: str | Path,
38 *,
39 format: ComplianceReportFormat | str = ComplianceReportFormat.HTML,
40 include_plot: bool = True,
41 title: str | None = None,
42 company_name: str | None = None,
43 dut_info: dict[str, str] | None = None,
44) -> Path:
45 """Generate EMC compliance report.
47 Args:
48 result: ComplianceResult from test_compliance().
49 output_path: Output file path.
50 format: Report format ('html', 'pdf', 'markdown', 'json').
51 include_plot: Include spectrum/limit plot in report.
52 title: Report title (default: "EMC Compliance Report").
53 company_name: Company name for header.
54 dut_info: Device Under Test information dict.
56 Returns:
57 Path to generated report.
59 Raises:
60 ValueError: If format is unknown.
62 Example:
63 >>> result = test_compliance(trace, mask)
64 >>> report_path = generate_compliance_report(
65 ... result,
66 ... 'compliance_report.html',
67 ... title="Product X EMC Test",
68 ... dut_info={'model': 'XYZ-100', 'serial': '12345'}
69 ... )
70 """
71 output_path = Path(output_path)
73 # Handle format
74 if isinstance(format, str):
75 format = ComplianceReportFormat(format.lower())
77 if format == ComplianceReportFormat.HTML:
78 _generate_html_report(
79 result,
80 output_path,
81 include_plot=include_plot,
82 title=title,
83 company_name=company_name,
84 dut_info=dut_info,
85 )
86 elif format == ComplianceReportFormat.MARKDOWN:
87 _generate_markdown_report(
88 result,
89 output_path,
90 title=title,
91 dut_info=dut_info,
92 )
93 elif format == ComplianceReportFormat.JSON: 93 ↛ 95line 93 didn't jump to line 95 because the condition on line 93 was always true
94 _generate_json_report(result, output_path)
95 elif format == ComplianceReportFormat.PDF:
96 # Generate HTML first, then convert to PDF
97 html_path = output_path.with_suffix(".html")
98 _generate_html_report(
99 result,
100 html_path,
101 include_plot=include_plot,
102 title=title,
103 company_name=company_name,
104 dut_info=dut_info,
105 )
106 _convert_html_to_pdf(html_path, output_path)
107 else:
108 raise ValueError(f"Unknown format: {format}")
110 return output_path
113def _generate_html_report(
114 result: ComplianceResult,
115 output_path: Path,
116 *,
117 include_plot: bool = True,
118 title: str | None = None,
119 company_name: str | None = None,
120 dut_info: dict[str, str] | None = None,
121) -> None:
122 """Generate HTML compliance report."""
123 title = title or "EMC Compliance Report"
124 status_color = "#28a745" if result.passed else "#dc3545"
125 status_text = "PASS" if result.passed else "FAIL"
127 # Build DUT info section
128 dut_section = ""
129 if dut_info:
130 dut_rows = "".join(f"<tr><td>{k}</td><td>{v}</td></tr>" for k, v in dut_info.items())
131 dut_section = f"""
132 <h3>Device Under Test</h3>
133 <table class="info-table">
134 {dut_rows}
135 </table>
136 """
137 # Build violations table
138 violations_section = ""
139 if result.violations: 139 ↛ 140line 139 didn't jump to line 140 because the condition on line 139 was never true
140 violation_rows = ""
141 for v in result.violations:
142 freq_mhz = v.frequency / 1e6
143 violation_rows += f"""
144 <tr>
145 <td>{freq_mhz:.3f}</td>
146 <td>{v.measured_level:.1f}</td>
147 <td>{v.limit_level:.1f}</td>
148 <td style="color: red;">{v.excess_db:.1f}</td>
149 </tr>
150 """
151 violations_section = f"""
152 <h3>Violations ({len(result.violations)})</h3>
153 <table class="data-table">
154 <thead>
155 <tr>
156 <th>Frequency (MHz)</th>
157 <th>Measured ({result.metadata.get("unit", "dBuV")})</th>
158 <th>Limit ({result.metadata.get("unit", "dBuV")})</th>
159 <th>Excess (dB)</th>
160 </tr>
161 </thead>
162 <tbody>
163 {violation_rows}
164 </tbody>
165 </table>
166 """
167 # Build plot section
168 plot_section = ""
169 if include_plot and len(result.spectrum_freq) > 0: 169 ↛ 173line 169 didn't jump to line 173 because the condition on line 169 was always true
170 plot_section = _generate_plot_html(result)
172 # Company header
173 company_header = ""
174 if company_name: 174 ↛ 175line 174 didn't jump to line 175 because the condition on line 174 was never true
175 company_header = f"<div class='company-name'>{company_name}</div>"
177 html = f"""<!DOCTYPE html>
178<html>
179<head>
180 <meta charset="UTF-8">
181 <title>{title}</title>
182 <style>
183 body {{
184 font-family: 'Segoe UI', Arial, sans-serif;
185 max-width: 1200px;
186 margin: 0 auto;
187 padding: 20px;
188 line-height: 1.6;
189 }}
190 .company-name {{
191 font-size: 12px;
192 color: #666;
193 text-align: right;
194 }}
195 h1 {{
196 color: #333;
197 border-bottom: 2px solid #007bff;
198 padding-bottom: 10px;
199 }}
200 .status-badge {{
201 display: inline-block;
202 padding: 10px 30px;
203 font-size: 24px;
204 font-weight: bold;
205 color: white;
206 background-color: {status_color};
207 border-radius: 5px;
208 margin: 20px 0;
209 }}
210 .summary-grid {{
211 display: grid;
212 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
213 gap: 20px;
214 margin: 20px 0;
215 }}
216 .summary-card {{
217 background: #f8f9fa;
218 padding: 15px;
219 border-radius: 5px;
220 border-left: 4px solid #007bff;
221 }}
222 .summary-card h4 {{
223 margin: 0 0 5px 0;
224 color: #666;
225 font-size: 12px;
226 text-transform: uppercase;
227 }}
228 .summary-card .value {{
229 font-size: 24px;
230 font-weight: bold;
231 color: #333;
232 }}
233 .info-table, .data-table {{
234 width: 100%;
235 border-collapse: collapse;
236 margin: 10px 0;
237 }}
238 .info-table td, .data-table th, .data-table td {{
239 padding: 10px;
240 border: 1px solid #ddd;
241 text-align: left;
242 }}
243 .data-table th {{
244 background: #f8f9fa;
245 font-weight: bold;
246 }}
247 .data-table tbody tr:nth-child(even) {{
248 background: #f8f9fa;
249 }}
250 .plot-container {{
251 margin: 20px 0;
252 text-align: center;
253 }}
254 .footer {{
255 margin-top: 40px;
256 padding-top: 20px;
257 border-top: 1px solid #ddd;
258 color: #666;
259 font-size: 12px;
260 }}
261 </style>
262</head>
263<body>
264 {company_header}
265 <h1>{title}</h1>
267 <div class="status-badge">{status_text}</div>
269 <h2>Test Summary</h2>
270 <div class="summary-grid">
271 <div class="summary-card">
272 <h4>Standard</h4>
273 <div class="value">{result.mask_name}</div>
274 </div>
275 <div class="summary-card">
276 <h4>Margin to Limit</h4>
277 <div class="value">{result.margin_to_limit:.1f} dB</div>
278 </div>
279 <div class="summary-card">
280 <h4>Worst Frequency</h4>
281 <div class="value">{result.worst_frequency / 1e6:.3f} MHz</div>
282 </div>
283 <div class="summary-card">
284 <h4>Violations</h4>
285 <div class="value">{len(result.violations)}</div>
286 </div>
287 </div>
289 {dut_section}
291 {violations_section}
293 {plot_section}
295 <div class="footer">
296 <p>Report generated: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</p>
297 <p>Detector: {result.detector} | Distance: {result.metadata.get("distance", "N/A")}m</p>
298 <p>Generated by TraceKit EMC Compliance Module</p>
299 </div>
300</body>
301</html>
302"""
303 with open(output_path, "w") as f:
304 f.write(html)
307def _generate_plot_html(result: ComplianceResult) -> str:
308 """Generate inline SVG plot for HTML report."""
309 import numpy as np
311 # Simple ASCII-style data representation for inline embedding
312 # In production, would use matplotlib to generate SVG
314 freq_mhz = result.spectrum_freq / 1e6
315 f_min, f_max = freq_mhz.min(), freq_mhz.max()
316 level_min = min(result.spectrum_level.min(), result.limit_level.min()) - 5
317 level_max = max(result.spectrum_level.max(), result.limit_level.max()) + 5
319 # Create SVG plot
320 width, height = 800, 400
321 padding = 60
323 # Scale functions
324 def x_scale(f: float) -> float:
325 return padding + (np.log10(f) - np.log10(f_min)) / (np.log10(f_max) - np.log10(f_min)) * ( # type: ignore[no-any-return]
326 width - 2 * padding
327 )
329 def y_scale(l: float) -> float: # noqa: E741
330 return height - padding - (l - level_min) / (level_max - level_min) * (height - 2 * padding) # type: ignore[no-any-return]
332 # Build spectrum path (downsample for SVG)
333 step = max(1, len(freq_mhz) // 500)
334 spectrum_points = " ".join(
335 f"{x_scale(freq_mhz[i]):.1f},{y_scale(result.spectrum_level[i]):.1f}"
336 for i in range(0, len(freq_mhz), step)
337 )
339 # Build limit path
340 limit_points = " ".join(
341 f"{x_scale(freq_mhz[i]):.1f},{y_scale(result.limit_level[i]):.1f}"
342 for i in range(0, len(freq_mhz), step)
343 )
345 svg = f"""
346 <div class="plot-container">
347 <h3>Spectrum vs Limit</h3>
348 <svg width="{width}" height="{height}" xmlns="http://www.w3.org/2000/svg">
349 <!-- Background -->
350 <rect width="100%" height="100%" fill="#f8f9fa"/>
352 <!-- Grid -->
353 <g stroke="#ddd" stroke-width="1">
354 <line x1="{padding}" y1="{padding}" x2="{padding}" y2="{height - padding}"/>
355 <line x1="{padding}" y1="{height - padding}" x2="{width - padding}" y2="{height - padding}"/>
356 </g>
358 <!-- Limit line (red dashed) -->
359 <polyline points="{limit_points}"
360 fill="none" stroke="#dc3545" stroke-width="2" stroke-dasharray="5,3"/>
362 <!-- Spectrum line (blue) -->
363 <polyline points="{spectrum_points}"
364 fill="none" stroke="#007bff" stroke-width="1.5"/>
366 <!-- Legend -->
367 <g transform="translate({width - 150}, 20)">
368 <rect width="130" height="50" fill="white" stroke="#ddd"/>
369 <line x1="10" y1="20" x2="40" y2="20" stroke="#007bff" stroke-width="2"/>
370 <text x="50" y="24" font-size="12">Spectrum</text>
371 <line x1="10" y1="40" x2="40" y2="40" stroke="#dc3545" stroke-width="2" stroke-dasharray="5,3"/>
372 <text x="50" y="44" font-size="12">Limit</text>
373 </g>
375 <!-- Axis labels -->
376 <text x="{width / 2}" y="{height - 10}" text-anchor="middle" font-size="12">Frequency (MHz)</text>
377 <text x="15" y="{height / 2}" text-anchor="middle" font-size="12"
378 transform="rotate(-90, 15, {height / 2})">Level ({result.metadata.get("unit", "dBuV")})</text>
379 </svg>
380 </div>
381 """
382 return svg
385def _generate_markdown_report(
386 result: ComplianceResult,
387 output_path: Path,
388 *,
389 title: str | None = None,
390 dut_info: dict[str, str] | None = None,
391) -> None:
392 """Generate Markdown compliance report."""
393 title = title or "EMC Compliance Report"
394 status = "PASS" if result.passed else "FAIL"
396 md = f"""# {title}
398## Test Result: **{status}**
400## Summary
402| Parameter | Value |
403|-----------|-------|
404| Standard | {result.mask_name} |
405| Margin to Limit | {result.margin_to_limit:.1f} dB |
406| Worst Frequency | {result.worst_frequency / 1e6:.3f} MHz |
407| Worst Margin | {result.worst_margin:.1f} dB |
408| Violations | {len(result.violations)} |
409| Detector | {result.detector} |
410"""
411 if dut_info: 411 ↛ 412line 411 didn't jump to line 412 because the condition on line 411 was never true
412 md += "## Device Under Test\n\n"
413 md += "| Field | Value |\n|-------|-------|\n"
414 for k, v in dut_info.items():
415 md += f"| {k} | {v} |\n"
416 md += "\n"
418 if result.violations: 418 ↛ 419line 418 didn't jump to line 419 because the condition on line 418 was never true
419 md += f"## Violations ({len(result.violations)})\n\n"
420 md += "| Frequency (MHz) | Measured | Limit | Excess (dB) |\n"
421 md += "|-----------------|----------|-------|-------------|\n"
422 for v in result.violations: # type: ignore[assignment]
423 md += f"| {v.frequency / 1e6:.3f} | {v.measured_level:.1f} | {v.limit_level:.1f} | {v.excess_db:.1f} |\n" # type: ignore[attr-defined]
424 md += "\n"
426 md += f"""
427---
428*Report generated: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}*
429*Generated by TraceKit EMC Compliance Module*
430"""
431 with open(output_path, "w") as f:
432 f.write(md)
435def _generate_json_report(result: ComplianceResult, output_path: Path) -> None:
436 """Generate JSON compliance report."""
437 import json
439 data = {
440 "status": result.status,
441 "mask_name": result.mask_name,
442 "margin_to_limit": result.margin_to_limit,
443 "worst_frequency": result.worst_frequency,
444 "worst_margin": result.worst_margin,
445 "detector": result.detector,
446 "violation_count": len(result.violations),
447 "violations": [
448 {
449 "frequency_hz": v.frequency,
450 "frequency_mhz": v.frequency / 1e6,
451 "measured_level": v.measured_level,
452 "limit_level": v.limit_level,
453 "excess_db": v.excess_db,
454 }
455 for v in result.violations
456 ],
457 "metadata": result.metadata,
458 "generated_at": datetime.now().isoformat(),
459 }
461 with open(output_path, "w") as f:
462 json.dump(data, f, indent=2)
465def _convert_html_to_pdf(html_path: Path, pdf_path: Path) -> None:
466 """Convert HTML to PDF using available tools."""
467 try:
468 # Try weasyprint first
469 from weasyprint import HTML # type: ignore[import-not-found]
471 HTML(str(html_path)).write_pdf(str(pdf_path))
472 except ImportError:
473 # Fall back to copying HTML
474 import shutil
476 shutil.copy(html_path, pdf_path.with_suffix(".html"))
477 # Could also try pdfkit, wkhtmltopdf, etc.
480__all__ = [
481 "ComplianceReportFormat",
482 "generate_compliance_report",
483]