Coverage for src / tracekit / reporting / tables.py: 95%

182 statements  

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

1"""Table generation and formatting for TraceKit reports. 

2 

3This module provides utilities for creating and formatting measurement 

4summary tables with professional appearance. 

5 

6 

7Example: 

8 >>> from tracekit.reporting.tables import create_measurement_table 

9 >>> table = create_measurement_table(measurements, format="markdown") 

10""" 

11 

12from __future__ import annotations 

13 

14from typing import TYPE_CHECKING, Any, Literal 

15 

16import numpy as np 

17 

18from tracekit.reporting.formatting import NumberFormatter 

19 

20if TYPE_CHECKING: 

21 from numpy.typing import NDArray 

22 

23 

24def create_measurement_table( 

25 measurements: dict[str, Any], 

26 *, 

27 format: Literal["dict", "markdown", "html", "csv"] = "dict", 

28 show_spec: bool = True, 

29 show_margin: bool = True, 

30 show_status: bool = True, 

31 sort_by: str | None = None, 

32) -> dict[str, Any] | str: 

33 """Create formatted measurement summary table. 

34 

35 Args: 

36 measurements: Dictionary of measurement name -> measurement data. 

37 format: Output format (dict, markdown, html, csv). 

38 show_spec: Include specification column. 

39 show_margin: Include margin column. 

40 show_status: Include pass/fail status column. 

41 sort_by: Column to sort by (None for original order). 

42 

43 Returns: 

44 Formatted table as dictionary or string depending on format. 

45 

46 Raises: 

47 ValueError: If format is unknown. 

48 

49 Example: 

50 >>> measurements = { 

51 ... "rise_time": {"value": 2.3e-9, "spec": 5e-9, "unit": "s"}, 

52 ... "fall_time": {"value": 1.8e-9, "spec": 5e-9, "unit": "s"}, 

53 ... } 

54 >>> table = create_measurement_table(measurements, format="markdown") 

55 

56 References: 

57 REPORT-004, REPORT-006 

58 """ 

59 # Build table headers 

60 headers = ["Parameter", "Value"] 

61 if show_spec: 

62 headers.append("Specification") 

63 if show_margin: 63 ↛ 65line 63 didn't jump to line 65 because the condition on line 63 was always true

64 headers.append("Margin") 

65 if show_status: 65 ↛ 69line 65 didn't jump to line 69 because the condition on line 65 was always true

66 headers.append("Status") 

67 

68 # Build table rows 

69 rows = [] 

70 formatter = NumberFormatter(sig_figs=3) 

71 

72 for name, meas in measurements.items(): 

73 row = [name] 

74 

75 # Value 

76 value = meas.get("value") 

77 unit = meas.get("unit", "") 

78 if value is not None: 

79 row.append(formatter.format(value, unit)) 

80 else: 

81 row.append("N/A") 

82 

83 # Specification 

84 if show_spec: 

85 spec = meas.get("spec") 

86 spec_type = meas.get("spec_type", "max") 

87 if spec is not None: 

88 prefix = "<" if spec_type == "max" else ">" if spec_type == "min" else "=" 

89 row.append(f"{prefix}{formatter.format(spec, unit)}") 

90 else: 

91 row.append("-") 

92 

93 # Margin 

94 if show_margin: 94 ↛ 107line 94 didn't jump to line 107 because the condition on line 94 was always true

95 spec = meas.get("spec") 

96 if value is not None and spec is not None and spec != 0: 

97 spec_type = meas.get("spec_type", "max") 

98 if spec_type == "max": 

99 margin = (spec - value) / spec * 100 

100 else: 

101 margin = (value - spec) / spec * 100 

102 row.append(f"{margin:.1f}%") 

103 else: 

104 row.append("-") 

105 

106 # Status 

107 if show_status: 107 ↛ 114line 107 didn't jump to line 114 because the condition on line 107 was always true

108 passed = meas.get("passed", True) 

109 if value is None: 

110 row.append("N/A") 

111 else: 

112 row.append("✓ PASS" if passed else "✗ FAIL") 

113 

114 rows.append(row) 

115 

116 # Sort if requested 

117 if sort_by and sort_by in headers: 

118 col_idx = headers.index(sort_by) 

119 rows.sort(key=lambda r: r[col_idx]) 

120 

121 # Format output 

122 if format == "dict": 

123 return {"type": "table", "headers": headers, "data": rows} 

124 elif format == "markdown": 

125 return _format_markdown_table(headers, rows) 

126 elif format == "html": 

127 return _format_html_table(headers, rows) 

128 elif format == "csv": 

129 return _format_csv_table(headers, rows) 

130 else: 

131 raise ValueError(f"Unknown format: {format}") 

132 

133 

134def create_comparison_table( 

135 baseline: dict[str, Any], 

136 current: dict[str, Any], 

137 *, 

138 format: Literal["dict", "markdown", "html"] = "dict", 

139 show_delta: bool = True, 

140 show_percent_change: bool = True, 

141) -> dict[str, Any] | str: 

142 """Create comparison table between baseline and current measurements. 

143 

144 Args: 

145 baseline: Baseline measurements. 

146 current: Current measurements. 

147 format: Output format. 

148 show_delta: Show absolute difference. 

149 show_percent_change: Show percentage change. 

150 

151 Returns: 

152 Formatted comparison table. 

153 

154 Raises: 

155 ValueError: If format is unknown. 

156 

157 References: 

158 REPORT-008 (Comparison Reports) 

159 """ 

160 headers = ["Parameter", "Baseline", "Current"] 

161 if show_delta: 161 ↛ 163line 161 didn't jump to line 163 because the condition on line 161 was always true

162 headers.append("Delta") 

163 if show_percent_change: 163 ↛ 166line 163 didn't jump to line 166 because the condition on line 163 was always true

164 headers.append("% Change") 

165 

166 rows = [] 

167 formatter = NumberFormatter(sig_figs=3) 

168 

169 # Get all parameter names 

170 all_params = set(baseline.keys()) | set(current.keys()) 

171 

172 for name in sorted(all_params): 

173 row = [name] 

174 

175 base_meas = baseline.get(name, {}) 

176 curr_meas = current.get(name, {}) 

177 

178 base_val = base_meas.get("value") 

179 curr_val = curr_meas.get("value") 

180 unit = base_meas.get("unit", curr_meas.get("unit", "")) 

181 

182 # Baseline value 

183 if base_val is not None: 

184 row.append(formatter.format(base_val, unit)) 

185 else: 

186 row.append("-") 

187 

188 # Current value 

189 if curr_val is not None: 

190 row.append(formatter.format(curr_val, unit)) 

191 else: 

192 row.append("-") 

193 

194 # Delta 

195 if show_delta: 195 ↛ 203line 195 didn't jump to line 203 because the condition on line 195 was always true

196 if base_val is not None and curr_val is not None: 

197 delta = curr_val - base_val 

198 row.append(formatter.format(delta, unit)) 

199 else: 

200 row.append("-") 

201 

202 # Percent change 

203 if show_percent_change: 203 ↛ 210line 203 didn't jump to line 210 because the condition on line 203 was always true

204 if base_val is not None and curr_val is not None and base_val != 0: 

205 pct_change = (curr_val - base_val) / base_val * 100 

206 row.append(f"{pct_change:+.1f}%") 

207 else: 

208 row.append("-") 

209 

210 rows.append(row) 

211 

212 if format == "dict": 

213 return {"type": "table", "headers": headers, "data": rows} 

214 elif format == "markdown": 

215 return _format_markdown_table(headers, rows) 

216 elif format == "html": 216 ↛ 219line 216 didn't jump to line 219 because the condition on line 216 was always true

217 return _format_html_table(headers, rows) 

218 else: 

219 raise ValueError(f"Unknown format: {format}") 

220 

221 

222def create_statistics_table( 

223 data: dict[str, NDArray[np.float64]], 

224 *, 

225 format: Literal["dict", "markdown", "html"] = "dict", 

226 statistics: list[str] | None = None, 

227) -> dict[str, Any] | str: 

228 """Create statistics summary table. 

229 

230 Args: 

231 data: Dictionary of parameter name -> data array. 

232 format: Output format. 

233 statistics: List of statistics to include (mean, std, min, max, median). 

234 

235 Returns: 

236 Formatted statistics table. 

237 

238 Raises: 

239 ValueError: If format is unknown. 

240 

241 References: 

242 REPORT-004 

243 """ 

244 if statistics is None: 

245 statistics = ["mean", "std", "min", "max", "median"] 

246 

247 headers = ["Parameter"] + [stat.capitalize() for stat in statistics] 

248 rows = [] 

249 

250 for name, values in data.items(): 

251 row = [name] 

252 

253 for stat in statistics: 

254 if stat == "mean": 

255 row.append(f"{np.mean(values):.3g}") 

256 elif stat == "std": 

257 row.append(f"{np.std(values):.3g}") 

258 elif stat == "min": 

259 row.append(f"{np.min(values):.3g}") 

260 elif stat == "max": 

261 row.append(f"{np.max(values):.3g}") 

262 elif stat == "median": 

263 row.append(f"{np.median(values):.3g}") 

264 else: 

265 row.append("-") 

266 

267 rows.append(row) 

268 

269 if format == "dict": 

270 return {"type": "table", "headers": headers, "data": rows} 

271 elif format == "markdown": 

272 return _format_markdown_table(headers, rows) 

273 elif format == "html": 273 ↛ 276line 273 didn't jump to line 276 because the condition on line 273 was always true

274 return _format_html_table(headers, rows) 

275 else: 

276 raise ValueError(f"Unknown format: {format}") 

277 

278 

279def _format_markdown_table(headers: list[str], rows: list[list[Any]]) -> str: 

280 """Format table as Markdown.""" 

281 lines = [] 

282 

283 # Header row 

284 lines.append("| " + " | ".join(str(h) for h in headers) + " |") 

285 lines.append("| " + " | ".join("---" for _ in headers) + " |") 

286 

287 # Data rows 

288 for row in rows: 

289 lines.append("| " + " | ".join(str(cell) for cell in row) + " |") 

290 

291 return "\n".join(lines) 

292 

293 

294def _format_html_table(headers: list[str], rows: list[list[Any]]) -> str: 

295 """Format table as HTML.""" 

296 lines = ['<table class="measurement-table">'] 

297 

298 # Header 

299 lines.append("<thead><tr>") 

300 for h in headers: 

301 lines.append(f"<th>{h}</th>") 

302 lines.append("</tr></thead>") 

303 

304 # Body 

305 lines.append("<tbody>") 

306 for row in rows: 

307 lines.append("<tr>") 

308 for cell in row: 

309 cell_str = str(cell) 

310 # Apply CSS classes for status 

311 if "PASS" in cell_str: 

312 lines.append(f'<td class="pass">{cell}</td>') 

313 elif "FAIL" in cell_str: 

314 lines.append(f'<td class="fail">{cell}</td>') 

315 else: 

316 lines.append(f"<td>{cell}</td>") 

317 lines.append("</tr>") 

318 lines.append("</tbody>") 

319 

320 lines.append("</table>") 

321 return "\n".join(lines) 

322 

323 

324def _format_csv_table(headers: list[str], rows: list[list[Any]]) -> str: 

325 """Format table as CSV.""" 

326 import csv 

327 from io import StringIO 

328 

329 output = StringIO() 

330 writer = csv.writer(output) 

331 

332 writer.writerow(headers) 

333 for row in rows: 

334 writer.writerow(row) 

335 

336 return output.getvalue() 

337 

338 

339def format_batch_summary_table( 

340 batch_results: list[dict[str, Any]], 

341 *, 

342 format: Literal["dict", "markdown", "html"] = "dict", 

343) -> dict[str, Any] | str: 

344 """Create batch summary table for multi-DUT testing. 

345 

346 Args: 

347 batch_results: List of result dictionaries, one per DUT. 

348 format: Output format. 

349 

350 Returns: 

351 Formatted batch summary table. 

352 

353 Raises: 

354 ValueError: If format is unknown. 

355 

356 References: 

357 REPORT-009 (Batch Report Aggregation) 

358 """ 

359 if not batch_results: 

360 return {"type": "table", "headers": [], "data": []} 

361 

362 headers = ["DUT ID", "Total Tests", "Passed", "Failed", "Yield"] 

363 rows = [] 

364 

365 for i, result in enumerate(batch_results): 

366 dut_id = result.get("dut_id", f"DUT-{i + 1}") 

367 total = result.get("total_count", 0) 

368 passed = result.get("pass_count", 0) 

369 failed = total - passed 

370 yield_pct = (passed / total * 100) if total > 0 else 0 

371 

372 rows.append([dut_id, total, passed, failed, f"{yield_pct:.1f}%"]) 

373 

374 # Add summary row 

375 total_tests = sum(r.get("total_count", 0) for r in batch_results) 

376 total_passed = sum(r.get("pass_count", 0) for r in batch_results) 

377 total_failed = total_tests - total_passed 

378 overall_yield = (total_passed / total_tests * 100) if total_tests > 0 else 0 

379 

380 rows.append( 

381 [ 

382 "TOTAL", 

383 total_tests, 

384 total_passed, 

385 total_failed, 

386 f"{overall_yield:.1f}%", 

387 ] 

388 ) 

389 

390 if format == "dict": 

391 return {"type": "table", "headers": headers, "data": rows} 

392 elif format == "markdown": 

393 return _format_markdown_table(headers, rows) 

394 elif format == "html": 394 ↛ 397line 394 didn't jump to line 397 because the condition on line 394 was always true

395 return _format_html_table(headers, rows) 

396 else: 

397 raise ValueError(f"Unknown format: {format}")