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

1"""EMC compliance report generation. 

2 

3This module provides compliance report generation in multiple formats. 

4 

5 

6Example: 

7 >>> from tracekit.compliance import test_compliance, generate_compliance_report 

8 >>> result = test_compliance(trace, mask) 

9 >>> generate_compliance_report(result, 'report.html') 

10 

11References: 

12 ANSI C63.4 (Test Methods) 

13""" 

14 

15from __future__ import annotations 

16 

17from datetime import datetime 

18from enum import Enum 

19from pathlib import Path 

20from typing import TYPE_CHECKING 

21 

22if TYPE_CHECKING: 

23 from tracekit.compliance.testing import ComplianceResult 

24 

25 

26class ComplianceReportFormat(Enum): 

27 """Compliance report output formats.""" 

28 

29 HTML = "html" 

30 PDF = "pdf" 

31 MARKDOWN = "markdown" 

32 JSON = "json" 

33 

34 

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. 

46 

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. 

55 

56 Returns: 

57 Path to generated report. 

58 

59 Raises: 

60 ValueError: If format is unknown. 

61 

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) 

72 

73 # Handle format 

74 if isinstance(format, str): 

75 format = ComplianceReportFormat(format.lower()) 

76 

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}") 

109 

110 return output_path 

111 

112 

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" 

126 

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) 

171 

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>" 

176 

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> 

266 

267 <div class="status-badge">{status_text}</div> 

268 

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> 

288 

289 {dut_section} 

290 

291 {violations_section} 

292 

293 {plot_section} 

294 

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) 

305 

306 

307def _generate_plot_html(result: ComplianceResult) -> str: 

308 """Generate inline SVG plot for HTML report.""" 

309 import numpy as np 

310 

311 # Simple ASCII-style data representation for inline embedding 

312 # In production, would use matplotlib to generate SVG 

313 

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 

318 

319 # Create SVG plot 

320 width, height = 800, 400 

321 padding = 60 

322 

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 ) 

328 

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] 

331 

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 ) 

338 

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 ) 

344 

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"/> 

351 

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> 

357 

358 <!-- Limit line (red dashed) --> 

359 <polyline points="{limit_points}" 

360 fill="none" stroke="#dc3545" stroke-width="2" stroke-dasharray="5,3"/> 

361 

362 <!-- Spectrum line (blue) --> 

363 <polyline points="{spectrum_points}" 

364 fill="none" stroke="#007bff" stroke-width="1.5"/> 

365 

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> 

374 

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 

383 

384 

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" 

395 

396 md = f"""# {title} 

397 

398## Test Result: **{status}** 

399 

400## Summary 

401 

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" 

417 

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" 

425 

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) 

433 

434 

435def _generate_json_report(result: ComplianceResult, output_path: Path) -> None: 

436 """Generate JSON compliance report.""" 

437 import json 

438 

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 } 

460 

461 with open(output_path, "w") as f: 

462 json.dump(data, f, indent=2) 

463 

464 

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] 

470 

471 HTML(str(html_path)).write_pdf(str(pdf_path)) 

472 except ImportError: 

473 # Fall back to copying HTML 

474 import shutil 

475 

476 shutil.copy(html_path, pdf_path.with_suffix(".html")) 

477 # Could also try pdfkit, wkhtmltopdf, etc. 

478 

479 

480__all__ = [ 

481 "ComplianceReportFormat", 

482 "generate_compliance_report", 

483]