Coverage for muutils/misc/numerical.py: 96%
68 statements
« prev ^ index » next coverage.py v7.6.1, created at 2025-04-04 03:33 -0600
« prev ^ index » next coverage.py v7.6.1, created at 2025-04-04 03:33 -0600
1from __future__ import annotations
4_SHORTEN_MAP: dict[int | float, str] = {
5 1e3: "K",
6 1e6: "M",
7 1e9: "B",
8 1e12: "t",
9 1e15: "q",
10 1e18: "Q",
11}
13_SHORTEN_TUPLES: list[tuple[int | float, str]] = sorted(
14 ((val, suffix) for val, suffix in _SHORTEN_MAP.items()),
15 key=lambda x: -x[0],
16)
19_REVERSE_SHORTEN_MAP: dict[str, int | float] = {v: k for k, v in _SHORTEN_MAP.items()}
22def shorten_numerical_to_str(
23 num: int | float,
24 small_as_decimal: bool = True,
25 precision: int = 1,
26) -> str:
27 """shorten a large numerical value to a string
28 1234 -> 1K
30 precision guaranteed to 1 in 10, but can be higher. reverse of `str_to_numeric`
31 """
33 # small values are returned as is
34 num_abs: float = abs(num)
35 if num_abs < 1e3:
36 return str(num)
38 # iterate over suffixes from largest to smallest
39 for i, (val, suffix) in enumerate(_SHORTEN_TUPLES):
40 if num_abs > val or i == len(_SHORTEN_TUPLES) - 1:
41 if (num_abs < val * 10) and small_as_decimal:
42 return f"{num / val:.{precision}f}{suffix}"
43 elif num_abs < val * 1e3:
44 return f"{int(round(num / val))}{suffix}"
46 return f"{num:.{precision}f}"
49def str_to_numeric(
50 quantity: str,
51 mapping: None | bool | dict[str, int | float] = True,
52) -> int | float:
53 """Convert a string representing a quantity to a numeric value.
55 The string can represent an integer, python float, fraction, or shortened via `shorten_numerical_to_str`.
57 # Examples:
58 ```
59 >>> str_to_numeric("5")
60 5
61 >>> str_to_numeric("0.1")
62 0.1
63 >>> str_to_numeric("1/5")
64 0.2
65 >>> str_to_numeric("-1K")
66 -1000.0
67 >>> str_to_numeric("1.5M")
68 1500000.0
69 >>> str_to_numeric("1.2e2")
70 120.0
71 ```
73 """
75 # check is string
76 if not isinstance(quantity, str):
77 raise TypeError(
78 f"quantity must be a string, got '{type(quantity) = }' '{quantity = }'"
79 )
81 # basic int conversion
82 try:
83 quantity_int: int = int(quantity)
84 return quantity_int
85 except ValueError:
86 pass
88 # basic float conversion
89 try:
90 quantity_float: float = float(quantity)
91 return quantity_float
92 except ValueError:
93 pass
95 # mapping
96 _mapping: dict[str, int | float]
97 if mapping is True or mapping is None:
98 _mapping = _REVERSE_SHORTEN_MAP
99 else:
100 _mapping = mapping # type: ignore[assignment]
102 quantity_original: str = quantity
104 quantity = quantity.strip()
106 result: int | float
107 multiplier: int | float = 1
109 # detect if it has a suffix
110 suffixes_detected: list[bool] = [suffix in quantity for suffix in _mapping]
111 n_suffixes_detected: int = sum(suffixes_detected)
112 if n_suffixes_detected == 0:
113 # no suffix
114 pass
115 elif n_suffixes_detected == 1:
116 # find multiplier
117 for suffix, mult in _mapping.items():
118 if quantity.endswith(suffix):
119 # remove suffix, store multiplier, and break
120 quantity = quantity[: -len(suffix)].strip()
121 multiplier = mult
122 break
123 else:
124 raise ValueError(f"Invalid suffix in {quantity_original}")
125 else:
126 # multiple suffixes
127 raise ValueError(f"Multiple suffixes detected in {quantity_original}")
129 # fractions
130 if "/" in quantity:
131 try:
132 assert quantity.count("/") == 1, "too many '/'"
133 # split and strip
134 num, den = quantity.split("/")
135 num = num.strip()
136 den = den.strip()
137 num_sign: int = 1
138 # negative numbers
139 if num.startswith("-"):
140 num_sign = -1
141 num = num[1:]
142 # assert that both are digits
143 assert (
144 num.isdigit() and den.isdigit()
145 ), "numerator and denominator must be digits"
146 # return the fraction
147 result = num_sign * (
148 int(num) / int(den)
149 ) # this allows for fractions with suffixes, which is weird, but whatever
150 except AssertionError as e:
151 raise ValueError(f"Invalid fraction {quantity_original}: {e}") from e
153 # decimals
154 else:
155 try:
156 result = int(quantity)
157 except ValueError:
158 try:
159 result = float(quantity)
160 except ValueError as e:
161 raise ValueError(
162 f"Invalid quantity {quantity_original} ({quantity})"
163 ) from e
165 return result * multiplier