Coverage for src / tracekit / reporting / formatting / numbers.py: 90%

122 statements  

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

1"""Number formatting utilities for signal analysis reports. 

2 

3Provides comprehensive number formatting with SI prefixes, engineering notation, 

4locale support, and specification comparison capabilities. 

5""" 

6 

7from __future__ import annotations 

8 

9import math 

10from datetime import datetime 

11from typing import ClassVar 

12 

13 

14class NumberFormatter: 

15 """Comprehensive number formatting utility with SI prefix support. 

16 

17 Attributes: 

18 sig_figs: Number of significant figures to display. 

19 auto_scale: Whether to automatically scale to appropriate SI prefix. 

20 engineering_notation: Use engineering notation (powers of 3). 

21 unicode_prefixes: Use Unicode characters for prefixes (µ vs u). 

22 

23 Examples: 

24 >>> fmt = NumberFormatter(sig_figs=3) 

25 >>> fmt.format(2.3e-9, "s") 

26 '2.30 ns' 

27 >>> fmt.format(1.5e6, "Hz") 

28 '1.50 MHz' 

29 """ 

30 

31 # SI prefixes (powers of 10) 

32 # Unicode uses Greek Small Letter Mu (U+03BC), ASCII uses 'u' 

33 SI_PREFIXES: ClassVar[dict[int, tuple[str, str]]] = { 

34 -15: ("f", "f"), # femto 

35 -12: ("p", "p"), # pico 

36 -9: ("n", "n"), # nano 

37 -6: ("\u03bc", "u"), # micro (Greek mu U+03BC, ascii 'u') 

38 -3: ("m", "m"), # milli 

39 0: ("", ""), # base 

40 3: ("k", "k"), # kilo 

41 6: ("M", "M"), # mega 

42 9: ("G", "G"), # giga 

43 12: ("T", "T"), # tera 

44 } 

45 

46 def __init__( 

47 self, 

48 sig_figs: int = 3, 

49 auto_scale: bool = True, 

50 engineering_notation: bool = True, 

51 unicode_prefixes: bool = True, 

52 precision: int | None = None, 

53 use_si: bool | None = None, 

54 ) -> None: 

55 """Initialize formatter. 

56 

57 Args: 

58 sig_figs: Number of significant figures (default 3). 

59 auto_scale: Enable automatic SI prefix scaling (default True). 

60 engineering_notation: Use engineering notation (default True). 

61 unicode_prefixes: Use Unicode prefixes like µ (default True). 

62 precision: Alias for sig_figs (backwards compatibility). 

63 use_si: Alias for auto_scale (backwards compatibility). 

64 """ 

65 # Handle backwards compatibility aliases 

66 self.sig_figs = precision if precision is not None else sig_figs 

67 self.auto_scale = use_si if use_si is not None else auto_scale 

68 self.engineering_notation = engineering_notation 

69 self.unicode_prefixes = unicode_prefixes 

70 

71 # Legacy attribute for backwards compatibility 

72 self.precision = self.sig_figs 

73 self.use_si = self.auto_scale 

74 

75 def format( 

76 self, 

77 value: float, 

78 unit: str = "", 

79 decimal_places: int | None = None, 

80 ) -> str: 

81 """Format a number with optional SI prefix. 

82 

83 Args: 

84 value: Numeric value to format. 

85 unit: Unit of measurement (e.g., "V", "Hz", "s"). 

86 decimal_places: Override number of decimal places. 

87 

88 Returns: 

89 Formatted string with value, prefix, and unit. 

90 

91 Examples: 

92 >>> fmt = NumberFormatter() 

93 >>> fmt.format(2.3e-6, "s") 

94 '2.300 µs' 

95 >>> fmt.format(1500000, "Hz") 

96 '1.500 MHz' 

97 """ 

98 # Handle special float values 

99 if math.isnan(value): 

100 return f"NaN {unit}".strip() 

101 if math.isinf(value): 

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

103 return f"{sign}Inf {unit}".strip() 

104 

105 # Determine precision 

106 places = decimal_places if decimal_places is not None else self.sig_figs 

107 

108 if not self.auto_scale: 

109 # No scaling - show full value with appropriate precision 

110 # Use more decimal places for small numbers to show actual value 

111 if abs(value) != 0 and abs(value) < 1: 111 ↛ 119line 111 didn't jump to line 119 because the condition on line 111 was always true

112 # Calculate needed decimal places to show significant figures 

113 from math import floor, log10 

114 

115 order = floor(log10(abs(value))) 

116 # For value like 0.0023, order=-3, need at least 4 decimals 

117 decimal_places_needed = max(places, abs(order) + 1) 

118 return f"{value:.{decimal_places_needed}f} {unit}".strip() 

119 elif abs(value) >= 1e6: 

120 return f"{value:.{places}e} {unit}".strip() 

121 return f"{value:.{places}f} {unit}".strip() 

122 

123 # Find appropriate SI prefix 

124 if value == 0: 

125 return f"0.{'0' * places} {unit}".strip() 

126 

127 abs_val = abs(value) 

128 

129 # Determine the appropriate power of 10 

130 if abs_val < 1e-15: 130 ↛ 131line 130 didn't jump to line 131 because the condition on line 130 was never true

131 return f"{value:.{places}e} {unit}".strip() 

132 elif abs_val < 1e-12: 

133 scaled, exp = value * 1e15, -15 

134 elif abs_val < 1e-9: 

135 scaled, exp = value * 1e12, -12 

136 elif abs_val < 1e-6: 

137 scaled, exp = value * 1e9, -9 

138 elif abs_val < 1e-3: 

139 scaled, exp = value * 1e6, -6 

140 elif abs_val < 1: 

141 scaled, exp = value * 1e3, -3 

142 elif abs_val < 1e3: 

143 scaled, exp = value, 0 

144 elif abs_val < 1e6: 

145 scaled, exp = value / 1e3, 3 

146 elif abs_val < 1e9: 

147 scaled, exp = value / 1e6, 6 

148 elif abs_val < 1e12: 

149 scaled, exp = value / 1e9, 9 

150 elif abs_val < 1e15: 

151 scaled, exp = value / 1e12, 12 

152 else: 

153 return f"{value:.{places}e} {unit}".strip() 

154 

155 # Get prefix (unicode or ascii) 

156 prefix_idx = 0 if self.unicode_prefixes else 1 

157 prefix = self.SI_PREFIXES.get(exp, ("", ""))[prefix_idx] 

158 

159 return f"{scaled:.{places}f} {prefix}{unit}".strip() 

160 

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

162 """Format a value as a percentage. 

163 

164 Args: 

165 value: Value as decimal (0.543 = 54.3%) or already percentage. 

166 decimals: Number of decimal places. 

167 

168 Returns: 

169 Formatted percentage string. 

170 

171 Examples: 

172 >>> fmt = NumberFormatter() 

173 >>> fmt.format_percentage(0.543) 

174 '54.3%' 

175 """ 

176 # If value > 1, assume it's already a percentage 

177 if abs(value) > 1: 

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

179 return f"{value * 100:.{decimals}f}%" 

180 

181 def format_range( 

182 self, 

183 min_val: float, 

184 typ_val: float, 

185 max_val: float, 

186 unit: str = "", 

187 ) -> str: 

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

189 

190 Args: 

191 min_val: Minimum value. 

192 typ_val: Typical value. 

193 max_val: Maximum value. 

194 unit: Unit of measurement. 

195 

196 Returns: 

197 Formatted range string. 

198 

199 Examples: 

200 >>> fmt = NumberFormatter() 

201 >>> fmt.format_range(1e-6, 2e-6, 3e-6, "s") 

202 'min=1.000 µs typ=2.000 µs max=3.000 µs' 

203 """ 

204 return ( 

205 f"min={self.format(min_val, unit)} " 

206 f"typ={self.format(typ_val, unit)} " 

207 f"max={self.format(max_val, unit)}" 

208 ) 

209 

210 

211def format_value( 

212 value: float, 

213 unit_or_precision: str | int = 3, 

214 unit: str = "", 

215 sig_figs: int | None = None, 

216) -> str: 

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

218 

219 Args: 

220 value: Value to format. 

221 unit_or_precision: Either unit (str) or precision (int). 

222 unit: Unit string (if precision specified first). 

223 sig_figs: Number of significant figures (alternative to precision arg). 

224 

225 Returns: 

226 Formatted value with appropriate SI prefix. 

227 

228 Examples: 

229 >>> format_value(2.3e-9, "s") 

230 '2.300 ns' 

231 >>> format_value(1.5e6, 4, "Hz") 

232 '1.5000 MHz' 

233 """ 

234 # Handle flexible arguments 

235 if isinstance(unit_or_precision, str): 

236 precision = sig_figs if sig_figs is not None else 3 

237 actual_unit = unit_or_precision 

238 else: 

239 precision = sig_figs if sig_figs is not None else unit_or_precision 

240 actual_unit = unit 

241 

242 fmt = NumberFormatter(sig_figs=precision) 

243 return fmt.format(value, actual_unit) 

244 

245 

246def format_with_units(value: float, unit: str, sig_figs: int = 3) -> str: 

247 """Format value with units and SI prefix. 

248 

249 Args: 

250 value: Numeric value. 

251 unit: Unit of measurement. 

252 sig_figs: Number of significant figures. 

253 

254 Returns: 

255 Formatted string with value and unit. 

256 """ 

257 fmt = NumberFormatter(sig_figs=sig_figs) 

258 return fmt.format(value, unit) 

259 

260 

261def format_with_context( 

262 value: float, 

263 context: str = "", 

264 spec: float | None = None, 

265 unit: str = "", 

266 spec_type: str = "max", 

267 show_margin: bool = True, 

268) -> str: 

269 """Format value with context and specification comparison. 

270 

271 Args: 

272 value: Value to format. 

273 context: Context string for display. 

274 spec: Specification limit for comparison. 

275 unit: Unit of measurement. 

276 spec_type: Type of specification ("max", "min", or "exact"). 

277 show_margin: Whether to show margin percentage. 

278 

279 Returns: 

280 Formatted string with pass/fail indication if spec provided. 

281 

282 Examples: 

283 >>> format_with_context(2.3e-9, spec=5e-9, unit="s", spec_type="max") 

284 '2.300 ns ✓' 

285 """ 

286 formatted = format_value(value, unit) 

287 

288 if spec is not None: 

289 # Determine pass/fail 

290 if spec_type == "max": 

291 passed = value <= spec 

292 margin = ((spec - value) / spec * 100) if spec != 0 else 0 

293 elif spec_type == "min": 

294 passed = value >= spec 

295 margin = ((value - spec) / spec * 100) if spec != 0 else 0 

296 else: # exact 

297 tolerance = abs(spec * 0.01) # 1% tolerance 

298 passed = abs(value - spec) <= tolerance 

299 margin = 100 - abs((value - spec) / spec * 100) if spec != 0 else 100 

300 

301 # Unicode checkmark/cross 

302 status = "\u2713" if passed else "\u2717" 

303 

304 if show_margin and passed: 

305 return f"{formatted} {status} ({margin:.1f}% margin)" 

306 return f"{formatted} {status}" 

307 

308 if context: 308 ↛ 309line 308 didn't jump to line 309 because the condition on line 308 was never true

309 return f"{formatted} ({context})" 

310 return formatted 

311 

312 

313def format_percentage(value: float, decimals: int = 1) -> str: 

314 """Format as percentage. 

315 

316 Args: 

317 value: Value as decimal (0.5 = 50%) or already percentage (>1). 

318 decimals: Number of decimal places. 

319 

320 Returns: 

321 Formatted percentage string. 

322 """ 

323 if abs(value) > 1: 

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

325 return f"{value * 100:.{decimals}f}%" 

326 

327 

328def format_range(min_val: float, max_val: float, unit: str = "") -> str: 

329 """Format a range of values. 

330 

331 Args: 

332 min_val: Minimum value. 

333 max_val: Maximum value. 

334 unit: Unit of measurement. 

335 

336 Returns: 

337 Formatted range string. 

338 """ 

339 return f"{format_value(min_val, unit)} to {format_value(max_val, unit)}" 

340 

341 

342def format_with_locale( 

343 value: float | None = None, 

344 locale: str = "en_US", 

345 date_value: float | None = None, 

346) -> str: 

347 """Format value with locale-specific formatting. 

348 

349 Args: 

350 value: Numeric value to format. 

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

352 date_value: Unix timestamp for date formatting. 

353 

354 Returns: 

355 Formatted string with locale-specific separators. 

356 Returns empty string if value is None and no date_value. 

357 

358 Examples: 

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

360 '1,234.56' 

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

362 '1.234,56' 

363 """ 

364 # Handle date formatting 

365 if date_value is not None: 

366 dt = datetime.fromtimestamp(date_value) 

367 if locale.startswith("en"): 

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

369 elif locale.startswith("de"): 

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

371 elif locale.startswith("fr"): 

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

373 else: 

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

375 

376 # Handle None value gracefully 

377 if value is None: 

378 return "" 

379 

380 # Handle numeric formatting by locale 

381 if locale.startswith("en"): 

382 return f"{value:,.2f}" 

383 elif locale.startswith("de"): 

384 # German: 1.234,56 

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

386 return formatted.replace(",", "X").replace(".", ",").replace("X", ".") 

387 elif locale.startswith("fr"): 387 ↛ 392line 387 didn't jump to line 392 because the condition on line 387 was always true

388 # French: 1 234,56 (narrow no-break space for thousands) 

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

390 return formatted.replace(",", " ").replace(".", ",") 

391 else: 

392 return f"{value:,.2f}" 

393 

394 

395__all__ = [ 

396 "NumberFormatter", 

397 "format_percentage", 

398 "format_range", 

399 "format_value", 

400 "format_with_context", 

401 "format_with_locale", 

402 "format_with_units", 

403]