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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""Number and value formatting for TraceKit reports.
3This module provides smart number formatting with SI prefixes,
4significant figures, and contextual annotations.
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"""
13from __future__ import annotations
15import locale as locale_module
16from dataclasses import dataclass
17from datetime import datetime
18from typing import Literal
20import numpy as np
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}
43# Unicode SI prefixes
44SI_PREFIXES_UNICODE = {
45 **SI_PREFIXES,
46 -6: "\u03bc", # Greek mu
47}
50@dataclass
51class NumberFormatter:
52 """Configurable number formatter.
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 """
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
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.
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.
84 Returns:
85 Formatted string.
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"
100 if value == 0:
101 return f"0 {unit}".strip()
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)
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 ""
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
128 # Clamp to available prefixes
129 exp = max(-24, min(24, exp))
131 # Get prefix
132 prefixes = SI_PREFIXES_UNICODE if self.unicode_prefixes else SI_PREFIXES
133 prefix = prefixes.get(exp, "")
135 # Scale value
136 scaled = abs_value / (10**exp)
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}"
153 return f"{formatted} {prefix}{unit}".strip()
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)
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}"
176 return f"{formatted} {unit}".strip()
178 def format_percentage(self, value: float, *, decimals: int = 1) -> str:
179 """Format as percentage.
181 Args:
182 value: Value (0-1 or 0-100).
183 decimals: Decimal places.
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}%"
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.
202 Args:
203 min_val: Minimum value.
204 typ_val: Typical value.
205 max_val: Maximum value.
206 unit: Unit suffix.
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))
216 prefixes = SI_PREFIXES_UNICODE if self.unicode_prefixes else SI_PREFIXES
217 prefix = prefixes.get(exp, "")
219 scale = 10**exp
220 decimals = max(0, self.sig_figs - 1)
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}"
226 return f"min/typ/max: {min_s} / {typ_s} / {max_s} {prefix}{unit}".strip()
229# Default formatter
230_default_formatter = NumberFormatter()
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.
241 Args:
242 value: Value to format.
243 unit: Unit suffix.
244 sig_figs: Significant figures.
246 Returns:
247 Formatted string.
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)
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.
265 Args:
266 value: Value to format.
267 unit: Base unit (e.g., "s", "Hz", "V").
268 sig_figs: Significant figures.
270 Returns:
271 Formatted string with SI prefix.
273 Example:
274 >>> format_with_units(2300000, "Hz")
275 '2.30 MHz'
276 """
277 return format_value(value, unit, sig_figs=sig_figs)
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.
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.
299 Returns:
300 Formatted string with context.
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)
309 if spec is None:
310 return value_str
312 spec_str = formatter.format(spec, unit)
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 = "="
325 status_char = "\u2713" if passed else "\u2717" # Check/X marks
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
337 margin_str = f" {margin:.0f}%"
339 return f"{value_str} (spec {spec_prefix}{spec_str}, {status_char}{margin_str})"
342def format_pass_fail(passed: bool, *, with_symbol: bool = True) -> str:
343 """Format pass/fail status.
345 Args:
346 passed: True for pass, False for fail.
347 with_symbol: Include Unicode symbol.
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"
361def format_margin(
362 value: float,
363 limit: float,
364 *,
365 limit_type: Literal["upper", "lower"] = "upper",
366) -> str:
367 """Format margin to limit.
369 Args:
370 value: Measured value.
371 limit: Limit value.
372 limit_type: Whether limit is upper or lower bound.
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
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"
394 return f"margin: {margin_pct:.1f}% ({status})"
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.
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).
411 Returns:
412 Formatted string with locale-specific separators and formats.
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'
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
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")
440 # Format number
441 if value is None:
442 return ""
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 = " "
458 # Format with 2 decimal places
459 formatted = f"{value:,.2f}"
461 # Replace separators
462 formatted = formatted.replace(",", "TEMP")
463 formatted = formatted.replace(".", decimal_sep)
464 formatted = formatted.replace("TEMP", thousands_sep)
466 return formatted