Coverage for src / tracekit / reporting / formatting.py: 0%

154 statements  

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

1"""Number and value formatting for TraceKit reports. 

2 

3This module provides smart number formatting with SI prefixes, 

4significant figures, and contextual annotations. 

5 

6 

7Example: 

8 >>> from tracekit.reporting import format_with_units, format_with_context 

9 >>> format_with_units(0.0000023, "s") # "2.3 us" 

10 >>> format_with_context(2.3e-9, spec=5e-9) # "2.3 ns (spec <5 ns, PASS)" 

11""" 

12 

13from __future__ import annotations 

14 

15import locale as locale_module 

16from dataclasses import dataclass 

17from datetime import datetime 

18from typing import Literal 

19 

20import numpy as np 

21 

22# SI prefixes 

23SI_PREFIXES = { 

24 24: "Y", 

25 21: "Z", 

26 18: "E", 

27 15: "P", 

28 12: "T", 

29 9: "G", 

30 6: "M", 

31 3: "k", 

32 0: "", 

33 -3: "m", 

34 -6: "u", 

35 -9: "n", 

36 -12: "p", 

37 -15: "f", 

38 -18: "a", 

39 -21: "z", 

40 -24: "y", 

41} 

42 

43# Unicode SI prefixes 

44SI_PREFIXES_UNICODE = { 

45 **SI_PREFIXES, 

46 -6: "\u03bc", # Greek mu 

47} 

48 

49 

50@dataclass 

51class NumberFormatter: 

52 """Configurable number formatter. 

53 

54 Attributes: 

55 sig_figs: Significant figures (default 3). 

56 auto_scale: Use SI prefixes for scaling. 

57 engineering_notation: Use engineering notation (10^3, 10^6, etc.). 

58 unicode_prefixes: Use Unicode characters (e.g., micro symbol). 

59 min_exp: Minimum exponent before using scientific notation. 

60 max_exp: Maximum exponent before using scientific notation. 

61 """ 

62 

63 sig_figs: int = 3 

64 auto_scale: bool = True 

65 engineering_notation: bool = True 

66 unicode_prefixes: bool = True 

67 min_exp: int = -3 

68 max_exp: int = 3 

69 

70 def format( 

71 self, 

72 value: float, 

73 unit: str = "", 

74 *, 

75 decimal_places: int | None = None, 

76 ) -> str: 

77 """Format a numeric value. 

78 

79 Args: 

80 value: Value to format. 

81 unit: Unit suffix (e.g., "s", "Hz", "V"). 

82 decimal_places: Override significant figures with fixed decimals. 

83 

84 Returns: 

85 Formatted string. 

86 

87 Example: 

88 >>> fmt = NumberFormatter() 

89 >>> fmt.format(0.0000023, "s") 

90 '2.30 us' 

91 """ 

92 if not np.isfinite(value): 

93 if np.isnan(value): 

94 return "NaN" 

95 elif value > 0: 

96 return "+Inf" 

97 else: 

98 return "-Inf" 

99 

100 if value == 0: 

101 return f"0 {unit}".strip() 

102 

103 if self.auto_scale and self.engineering_notation: 

104 return self._format_engineering(value, unit, decimal_places) 

105 elif self.auto_scale: 

106 return self._format_scaled(value, unit, decimal_places) 

107 else: 

108 return self._format_plain(value, unit, decimal_places) 

109 

110 def _format_engineering( 

111 self, 

112 value: float, 

113 unit: str, 

114 decimal_places: int | None, 

115 ) -> str: 

116 """Format with engineering notation (SI prefixes).""" 

117 abs_value = abs(value) 

118 sign = "-" if value < 0 else "" 

119 

120 # Find appropriate SI prefix 

121 if abs_value == 0: 

122 exp = 0 

123 else: 

124 exp = int(np.floor(np.log10(abs_value))) 

125 # Round to nearest multiple of 3 

126 exp = (exp // 3) * 3 

127 

128 # Clamp to available prefixes 

129 exp = max(-24, min(24, exp)) 

130 

131 # Get prefix 

132 prefixes = SI_PREFIXES_UNICODE if self.unicode_prefixes else SI_PREFIXES 

133 prefix = prefixes.get(exp, "") 

134 

135 # Scale value 

136 scaled = abs_value / (10**exp) 

137 

138 # Format with significant figures 

139 if decimal_places is not None: 

140 formatted = f"{sign}{scaled:.{decimal_places}f}" 

141 else: 

142 # Calculate decimal places from significant figures 

143 if scaled >= 100: 

144 decimals = max(0, self.sig_figs - 3) 

145 elif scaled >= 10: 

146 decimals = max(0, self.sig_figs - 2) 

147 elif scaled >= 1: 

148 decimals = max(0, self.sig_figs - 1) 

149 else: 

150 decimals = self.sig_figs 

151 formatted = f"{sign}{scaled:.{decimals}f}" 

152 

153 return f"{formatted} {prefix}{unit}".strip() 

154 

155 def _format_scaled( 

156 self, 

157 value: float, 

158 unit: str, 

159 decimal_places: int | None, 

160 ) -> str: 

161 """Format with auto-scaling but not necessarily engineering notation.""" 

162 return self._format_engineering(value, unit, decimal_places) 

163 

164 def _format_plain( 

165 self, 

166 value: float, 

167 unit: str, 

168 decimal_places: int | None, 

169 ) -> str: 

170 """Format without scaling.""" 

171 if decimal_places is not None: 

172 formatted = f"{value:.{decimal_places}f}" 

173 else: 

174 formatted = f"{value:.{self.sig_figs}g}" 

175 

176 return f"{formatted} {unit}".strip() 

177 

178 def format_percentage(self, value: float, *, decimals: int = 1) -> str: 

179 """Format as percentage. 

180 

181 Args: 

182 value: Value (0-1 or 0-100). 

183 decimals: Decimal places. 

184 

185 Returns: 

186 Percentage string. 

187 """ 

188 # Assume value is already in percent if > 1 

189 if abs(value) <= 1: 

190 value = value * 100 

191 return f"{value:.{decimals}f}%" 

192 

193 def format_range( 

194 self, 

195 min_val: float, 

196 typ_val: float, 

197 max_val: float, 

198 unit: str = "", 

199 ) -> str: 

200 """Format min/typ/max range. 

201 

202 Args: 

203 min_val: Minimum value. 

204 typ_val: Typical value. 

205 max_val: Maximum value. 

206 unit: Unit suffix. 

207 

208 Returns: 

209 Formatted range string. 

210 """ 

211 # Use same scaling for all values 

212 abs_max = max(abs(min_val), abs(typ_val), abs(max_val)) 

213 exp = 0 if abs_max == 0 else int(np.floor(np.log10(abs_max))) // 3 * 3 

214 exp = max(-24, min(24, exp)) 

215 

216 prefixes = SI_PREFIXES_UNICODE if self.unicode_prefixes else SI_PREFIXES 

217 prefix = prefixes.get(exp, "") 

218 

219 scale = 10**exp 

220 decimals = max(0, self.sig_figs - 1) 

221 

222 min_s = f"{min_val / scale:.{decimals}f}" 

223 typ_s = f"{typ_val / scale:.{decimals}f}" 

224 max_s = f"{max_val / scale:.{decimals}f}" 

225 

226 return f"min/typ/max: {min_s} / {typ_s} / {max_s} {prefix}{unit}".strip() 

227 

228 

229# Default formatter 

230_default_formatter = NumberFormatter() 

231 

232 

233def format_value( 

234 value: float, 

235 unit: str = "", 

236 *, 

237 sig_figs: int = 3, 

238) -> str: 

239 """Format a numeric value with SI prefix. 

240 

241 Args: 

242 value: Value to format. 

243 unit: Unit suffix. 

244 sig_figs: Significant figures. 

245 

246 Returns: 

247 Formatted string. 

248 

249 Example: 

250 >>> format_value(0.0000023, "s") 

251 '2.30 us' 

252 """ 

253 formatter = NumberFormatter(sig_figs=sig_figs) 

254 return formatter.format(value, unit) 

255 

256 

257def format_with_units( 

258 value: float, 

259 unit: str, 

260 *, 

261 sig_figs: int = 3, 

262) -> str: 

263 """Format value with automatic SI prefix scaling. 

264 

265 Args: 

266 value: Value to format. 

267 unit: Base unit (e.g., "s", "Hz", "V"). 

268 sig_figs: Significant figures. 

269 

270 Returns: 

271 Formatted string with SI prefix. 

272 

273 Example: 

274 >>> format_with_units(2300000, "Hz") 

275 '2.30 MHz' 

276 """ 

277 return format_value(value, unit, sig_figs=sig_figs) 

278 

279 

280def format_with_context( 

281 value: float, 

282 *, 

283 spec: float | None = None, 

284 spec_type: Literal["max", "min", "exact"] = "max", 

285 unit: str = "", 

286 sig_figs: int = 3, 

287 show_margin: bool = True, 

288) -> str: 

289 """Format value with specification context. 

290 

291 Args: 

292 value: Measured value. 

293 spec: Specification limit. 

294 spec_type: Type of specification (max, min, exact). 

295 unit: Unit suffix. 

296 sig_figs: Significant figures. 

297 show_margin: Show margin percentage. 

298 

299 Returns: 

300 Formatted string with context. 

301 

302 Example: 

303 >>> format_with_context(2.3e-9, spec=5e-9, unit="s") 

304 '2.30 ns (spec <5.00 ns, PASS 54%)' 

305 """ 

306 formatter = NumberFormatter(sig_figs=sig_figs) 

307 value_str = formatter.format(value, unit) 

308 

309 if spec is None: 

310 return value_str 

311 

312 spec_str = formatter.format(spec, unit) 

313 

314 # Determine pass/fail 

315 if spec_type == "max": 

316 passed = value <= spec 

317 spec_prefix = "<" 

318 elif spec_type == "min": 

319 passed = value >= spec 

320 spec_prefix = ">" 

321 else: # exact 

322 passed = abs(value - spec) < spec * 0.01 # 1% tolerance 

323 spec_prefix = "=" 

324 

325 status_char = "\u2713" if passed else "\u2717" # Check/X marks 

326 

327 # Calculate margin 

328 margin_str = "" 

329 if show_margin and spec != 0: 

330 if spec_type == "max": 

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

332 elif spec_type == "min": 

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

334 else: 

335 margin = (1 - abs(value - spec) / spec) * 100 

336 

337 margin_str = f" {margin:.0f}%" 

338 

339 return f"{value_str} (spec {spec_prefix}{spec_str}, {status_char}{margin_str})" 

340 

341 

342def format_pass_fail(passed: bool, *, with_symbol: bool = True) -> str: 

343 """Format pass/fail status. 

344 

345 Args: 

346 passed: True for pass, False for fail. 

347 with_symbol: Include Unicode symbol. 

348 

349 Returns: 

350 Formatted status string. 

351 """ 

352 if with_symbol: 

353 if passed: 

354 return "\u2713 PASS" # Check mark 

355 else: 

356 return "\u2717 FAIL" # X mark 

357 else: 

358 return "PASS" if passed else "FAIL" 

359 

360 

361def format_margin( 

362 value: float, 

363 limit: float, 

364 *, 

365 limit_type: Literal["upper", "lower"] = "upper", 

366) -> str: 

367 """Format margin to limit. 

368 

369 Args: 

370 value: Measured value. 

371 limit: Limit value. 

372 limit_type: Whether limit is upper or lower bound. 

373 

374 Returns: 

375 Margin string with status indicator. 

376 """ 

377 if limit_type == "upper": 

378 margin = limit - value 

379 margin_pct = (margin / limit * 100) if limit != 0 else 0 

380 else: 

381 margin = value - limit 

382 margin_pct = (margin / limit * 100) if limit != 0 else 0 

383 

384 # Status based on margin 

385 if margin_pct > 20: 

386 status = "good" 

387 elif margin_pct > 10: 

388 status = "ok" 

389 elif margin_pct > 0: 

390 status = "marginal" 

391 else: 

392 status = "violation" 

393 

394 return f"margin: {margin_pct:.1f}% ({status})" 

395 

396 

397def format_with_locale( 

398 value: float | None = None, 

399 locale: str | None = None, 

400 *, 

401 date_value: float | None = None, 

402) -> str: 

403 """Format numbers/dates with locale-aware formatting. 

404 

405 Args: 

406 value: Numeric value to format (mutually exclusive with date_value). 

407 locale: Locale string (e.g., 'en_US', 'de_DE', 'fr_FR'). 

408 If None, uses system locale. 

409 date_value: Timestamp to format as date (mutually exclusive with value). 

410 

411 Returns: 

412 Formatted string with locale-specific separators and formats. 

413 

414 Example: 

415 >>> format_with_locale(1234.56, locale="en_US") 

416 '1,234.56' 

417 >>> format_with_locale(1234.56, locale="de_DE") 

418 '1.234,56' 

419 >>> format_with_locale(1234.56, locale="fr_FR") 

420 '1 234,56' 

421 

422 References: 

423 REPORT-026: Locale-aware Formatting 

424 """ 

425 # Determine locale 

426 current_locale = locale_module.getlocale()[0] or "en_US" if locale is None else locale 

427 

428 # Format date if date_value provided 

429 if date_value is not None: 

430 dt = datetime.fromtimestamp(date_value) 

431 if current_locale.startswith("en_US"): 

432 return dt.strftime("%m/%d/%Y") 

433 elif current_locale.startswith("de_DE"): 

434 return dt.strftime("%d.%m.%Y") 

435 elif current_locale.startswith("fr_FR"): 

436 return dt.strftime("%d/%m/%Y") 

437 else: # ISO format as fallback 

438 return dt.strftime("%Y-%m-%d") 

439 

440 # Format number 

441 if value is None: 

442 return "" 

443 

444 # Locale-specific decimal and thousands separators 

445 if current_locale.startswith("en_US"): 

446 decimal_sep = "." 

447 thousands_sep = "," 

448 elif current_locale.startswith("de_DE"): 

449 decimal_sep = "," 

450 thousands_sep = "." 

451 elif current_locale.startswith("fr_FR"): 

452 decimal_sep = "," 

453 thousands_sep = " " 

454 else: # SI standard (space for thousands) 

455 decimal_sep = "." 

456 thousands_sep = " " 

457 

458 # Format with 2 decimal places 

459 formatted = f"{value:,.2f}" 

460 

461 # Replace separators 

462 formatted = formatted.replace(",", "TEMP") 

463 formatted = formatted.replace(".", decimal_sep) 

464 formatted = formatted.replace("TEMP", thousands_sep) 

465 

466 return formatted