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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""Number formatting utilities for signal analysis reports.
3Provides comprehensive number formatting with SI prefixes, engineering notation,
4locale support, and specification comparison capabilities.
5"""
7from __future__ import annotations
9import math
10from datetime import datetime
11from typing import ClassVar
14class NumberFormatter:
15 """Comprehensive number formatting utility with SI prefix support.
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).
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 """
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 }
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.
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
71 # Legacy attribute for backwards compatibility
72 self.precision = self.sig_figs
73 self.use_si = self.auto_scale
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.
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.
88 Returns:
89 Formatted string with value, prefix, and unit.
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()
105 # Determine precision
106 places = decimal_places if decimal_places is not None else self.sig_figs
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
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()
123 # Find appropriate SI prefix
124 if value == 0:
125 return f"0.{'0' * places} {unit}".strip()
127 abs_val = abs(value)
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()
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]
159 return f"{scaled:.{places}f} {prefix}{unit}".strip()
161 def format_percentage(self, value: float, decimals: int = 1) -> str:
162 """Format a value as a percentage.
164 Args:
165 value: Value as decimal (0.543 = 54.3%) or already percentage.
166 decimals: Number of decimal places.
168 Returns:
169 Formatted percentage string.
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}%"
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.
190 Args:
191 min_val: Minimum value.
192 typ_val: Typical value.
193 max_val: Maximum value.
194 unit: Unit of measurement.
196 Returns:
197 Formatted range string.
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 )
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.
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).
225 Returns:
226 Formatted value with appropriate SI prefix.
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
242 fmt = NumberFormatter(sig_figs=precision)
243 return fmt.format(value, actual_unit)
246def format_with_units(value: float, unit: str, sig_figs: int = 3) -> str:
247 """Format value with units and SI prefix.
249 Args:
250 value: Numeric value.
251 unit: Unit of measurement.
252 sig_figs: Number of significant figures.
254 Returns:
255 Formatted string with value and unit.
256 """
257 fmt = NumberFormatter(sig_figs=sig_figs)
258 return fmt.format(value, unit)
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.
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.
279 Returns:
280 Formatted string with pass/fail indication if spec provided.
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)
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
301 # Unicode checkmark/cross
302 status = "\u2713" if passed else "\u2717"
304 if show_margin and passed:
305 return f"{formatted} {status} ({margin:.1f}% margin)"
306 return f"{formatted} {status}"
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
313def format_percentage(value: float, decimals: int = 1) -> str:
314 """Format as percentage.
316 Args:
317 value: Value as decimal (0.5 = 50%) or already percentage (>1).
318 decimals: Number of decimal places.
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}%"
328def format_range(min_val: float, max_val: float, unit: str = "") -> str:
329 """Format a range of values.
331 Args:
332 min_val: Minimum value.
333 max_val: Maximum value.
334 unit: Unit of measurement.
336 Returns:
337 Formatted range string.
338 """
339 return f"{format_value(min_val, unit)} to {format_value(max_val, unit)}"
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.
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.
354 Returns:
355 Formatted string with locale-specific separators.
356 Returns empty string if value is None and no date_value.
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")
376 # Handle None value gracefully
377 if value is None:
378 return ""
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}"
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]