Skip to content

Commit 1519cb3

Browse files
committed
Add a base Quantity class
Signed-off-by: Sahas Subramanian <[email protected]>
1 parent 3dc2187 commit 1519cb3

File tree

2 files changed

+418
-0
lines changed

2 files changed

+418
-0
lines changed
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
# License: MIT
2+
# Copyright © 2022 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Types for holding quantities with units."""
5+
6+
import math
7+
from typing import Self
8+
9+
10+
class Quantity:
11+
"""A quantity with a unit."""
12+
13+
_base_value: float
14+
"""The value of this quantity in the base unit."""
15+
16+
_exponent_unit_map: dict[int, str] | None = None
17+
"""A mapping from the exponent of the base unit to the unit symbol.
18+
19+
If None, this quantity has no unit. None is possible only when using the base
20+
class. Sub-classes must define this.
21+
"""
22+
23+
def __init__(self, value: float, exponent: int = 0) -> None:
24+
"""Initialize a new quantity.
25+
26+
Args:
27+
value: The value of this quantity in a given exponent of the base unit.
28+
exponent: The exponent of the base unit the given value is in.
29+
"""
30+
self._base_value = value * 10**exponent
31+
32+
def __init_subclass__(cls, exponent_unit_map: dict[int, str]) -> None:
33+
"""Initialize a new subclass of Quantity.
34+
35+
Args:
36+
exponent_unit_map: A mapping from the exponent of the base unit to the unit
37+
symbol.
38+
39+
Raises:
40+
TypeError: If the given exponent_unit_map is not a dict.
41+
ValueError: If the given exponent_unit_map does not contain a base unit
42+
(exponent 0).
43+
"""
44+
if not 0 in exponent_unit_map:
45+
raise ValueError("Expected a base unit for the type (for exponent 0)")
46+
cls._exponent_unit_map = exponent_unit_map
47+
super().__init_subclass__()
48+
49+
@property
50+
def base_value(self) -> float:
51+
"""Return the value of this quantity in the base unit.
52+
53+
Returns:
54+
The value of this quantity in the base unit.
55+
"""
56+
return self._base_value
57+
58+
@property
59+
def base_unit(self) -> str | None:
60+
"""Return the base unit of this quantity.
61+
62+
None if this quantity has no unit.
63+
64+
Returns:
65+
The base unit of this quantity.
66+
"""
67+
if not self._exponent_unit_map:
68+
return None
69+
return self._exponent_unit_map[0]
70+
71+
def isnan(self) -> bool:
72+
"""Return whether this quantity is NaN.
73+
74+
Returns:
75+
Whether this quantity is NaN.
76+
"""
77+
return math.isnan(self._base_value)
78+
79+
def isinf(self) -> bool:
80+
"""Return whether this quantity is infinite.
81+
82+
Returns:
83+
Whether this quantity is infinite.
84+
"""
85+
return math.isinf(self._base_value)
86+
87+
def __hash__(self) -> int:
88+
"""Return a hash of this object.
89+
90+
Returns:
91+
A hash of this object.
92+
"""
93+
return hash((type(self), self._base_value))
94+
95+
def __repr__(self) -> str:
96+
"""Return a representation of this quantity.
97+
98+
Returns:
99+
A representation of this quantity.
100+
"""
101+
return f"{type(self).__name__}(value={self._base_value}, exponent=0)"
102+
103+
def __str__(self) -> str:
104+
"""Return a string representation of this quantity.
105+
106+
Returns:
107+
A string representation of this quantity.
108+
"""
109+
return self.__format__("")
110+
111+
def __format__(self, __format_spec: str) -> str:
112+
"""Return a formatted string representation of this quantity.
113+
114+
If specified, must be of this form: `[0].{precision}`. If a 0 is not given, the
115+
trailing zeros will be omitted. If no precision is given, the default is 3.
116+
117+
The returned string will use the unit that will result in the maxium precision,
118+
based on the magnitude of the value.
119+
120+
Example:
121+
```python
122+
from frequenz.sdk.timeseries import Current
123+
c = Current.from_amperes(0.2345)
124+
assert f"{c:.2}" == "234.5 mA"
125+
c = Current.from_amperes(1.2345)
126+
assert f"{c:.2}" == "1.23 A"
127+
c = Current.from_milliamperes(1.2345)
128+
assert f"{c:.6}" == "1.2345 mA"
129+
```
130+
131+
Args:
132+
__format_spec: The format specifier.
133+
134+
Returns:
135+
A string representation of this quantity.
136+
137+
Raises:
138+
ValueError: If the given format specifier is invalid.
139+
"""
140+
keep_trailing_zeros = False
141+
if __format_spec != "":
142+
fspec_parts = __format_spec.split(".")
143+
if (
144+
len(fspec_parts) != 2
145+
or fspec_parts[0] not in ("", "0")
146+
or not fspec_parts[1].isdigit()
147+
):
148+
raise ValueError(
149+
"Invalid format specifier. Must be empty or `[0].{precision}`"
150+
)
151+
if fspec_parts[0] == "0":
152+
keep_trailing_zeros = True
153+
precision = int(fspec_parts[1])
154+
else:
155+
precision = 3
156+
if not self._exponent_unit_map:
157+
return f"{self._base_value:.{precision}f}"
158+
159+
abs_value = abs(self._base_value)
160+
exponent = math.floor(math.log10(abs_value))
161+
unit_place = exponent - exponent % 3
162+
if unit_place < min(self._exponent_unit_map):
163+
unit = self._exponent_unit_map[min(self._exponent_unit_map.keys())]
164+
unit_place = min(self._exponent_unit_map)
165+
elif unit_place > max(self._exponent_unit_map):
166+
unit = self._exponent_unit_map[max(self._exponent_unit_map.keys())]
167+
unit_place = max(self._exponent_unit_map)
168+
else:
169+
unit = self._exponent_unit_map[unit_place]
170+
value_str = f"{self._base_value / 10 ** unit_place:.{precision}f}"
171+
stripped = value_str.rstrip("0").rstrip(".")
172+
if not keep_trailing_zeros:
173+
value_str = stripped
174+
unit_str = unit if stripped != "0" else self._exponent_unit_map[0]
175+
return f"{value_str} {unit_str}"
176+
177+
def __add__(self, other: Self) -> Self:
178+
"""Return the sum of this quantity and another.
179+
180+
Args:
181+
other: The other quantity.
182+
183+
Returns:
184+
The sum of this quantity and another.
185+
"""
186+
if not type(other) is type(self):
187+
return NotImplemented
188+
return type(self)(self._base_value + other._base_value)
189+
190+
def __sub__(self, other: Self) -> Self:
191+
"""Return the difference of this quantity and another.
192+
193+
Args:
194+
other: The other quantity.
195+
196+
Returns:
197+
The difference of this quantity and another.
198+
"""
199+
if not type(other) is type(self):
200+
return NotImplemented
201+
return type(self)(self._base_value - other._base_value)
202+
203+
def __gt__(self, other: Self) -> bool:
204+
"""Return whether this quantity is greater than another.
205+
206+
Args:
207+
other: The other quantity.
208+
209+
Returns:
210+
Whether this quantity is greater than another.
211+
"""
212+
if not type(other) is type(self):
213+
return NotImplemented
214+
return self._base_value > other._base_value
215+
216+
def __ge__(self, other: Self) -> bool:
217+
"""Return whether this quantity is greater than or equal to another.
218+
219+
Args:
220+
other: The other quantity.
221+
222+
Returns:
223+
Whether this quantity is greater than or equal to another.
224+
"""
225+
if not type(other) is type(self):
226+
return NotImplemented
227+
return self._base_value >= other._base_value
228+
229+
def __lt__(self, other: Self) -> bool:
230+
"""Return whether this quantity is less than another.
231+
232+
Args:
233+
other: The other quantity.
234+
235+
Returns:
236+
Whether this quantity is less than another.
237+
"""
238+
if not type(other) is type(self):
239+
return NotImplemented
240+
return self._base_value < other._base_value
241+
242+
def __le__(self, other: Self) -> bool:
243+
"""Return whether this quantity is less than or equal to another.
244+
245+
Args:
246+
other: The other quantity.
247+
248+
Returns:
249+
Whether this quantity is less than or equal to another.
250+
"""
251+
if not type(other) is type(self):
252+
return NotImplemented
253+
return self._base_value <= other._base_value
254+
255+
def __eq__(self, other: object) -> bool:
256+
"""Return whether this quantity is equal to another.
257+
258+
Args:
259+
other: The other quantity.
260+
261+
Returns:
262+
Whether this quantity is equal to another.
263+
"""
264+
if not type(other) is type(self):
265+
return NotImplemented
266+
# The above check ensures that both quantities are the exact same type, because
267+
# `isinstance` returns true for subclasses and superclasses. But the above check
268+
# doesn't help mypy identify the type of other, so the below line is necessary.
269+
assert isinstance(other, self.__class__)
270+
return self._base_value == other._base_value

0 commit comments

Comments
 (0)