Skip to content

Commit 82084a6

Browse files
authored
Add zero() constructor for Quantities (#535)
It is a very common case which becomes very verbose, and it includes unit information that is completely irrelevant (zero is zero in any unit).
2 parents 5deb7d1 + 361bcb0 commit 82084a6

File tree

3 files changed

+99
-54
lines changed

3 files changed

+99
-54
lines changed

RELEASE_NOTES.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,15 @@
1313

1414
## New Features
1515

16-
- Add `abs()` support for quantities.
17-
- Add quantity class `Frequency` for frequency values.
18-
- Quantities can now be multiplied with `Percentage` types.
19-
- `FormulaEngine` arithmetics now supports scalar multiplication with floats and addition with Quantities.
20-
- Add a `isclose()` method on quantities to compare them to other values of the same type. Because `Quantity` types are just wrappers around `float`s, direct comparison might not always be desirable.
16+
- Quantities
17+
18+
* Add `abs()`.
19+
* Add a `isclose()` method on quantities to compare them to other values of the same type. Because `Quantity` types are just wrappers around `float`s, direct comparison might not always be desirable.
20+
* Add `zero()` constructor (which returns a singleton) to easily get a zero value.
21+
* Add multiplication by `Percentage` types.
22+
* Add a new quantity class `Frequency` for frequency values.
23+
24+
- `FormulaEngine` arithmetics now supports scalar multiplication with floats and addition with Quantities
2125

2226
## Bug Fixes
2327

src/frequenz/sdk/timeseries/_quantities.py

Lines changed: 34 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@
2424

2525

2626
class Quantity:
27-
"""A quantity with a unit."""
27+
"""A quantity with a unit.
28+
29+
Quantities try to behave like float and are also immutable.
30+
"""
2831

2932
_base_value: float
3033
"""The value of this quantity in the base unit."""
@@ -62,6 +65,30 @@ def __init_subclass__(cls, exponent_unit_map: dict[int, str]) -> None:
6265
cls._exponent_unit_map = exponent_unit_map
6366
super().__init_subclass__()
6467

68+
_zero_cache: dict[type, Quantity] = {}
69+
"""Cache for zero singletons.
70+
71+
This is a workaround for mypy getting confused when using @functools.cache and
72+
@classmethod combined with returning Self. It believes the resulting type of this
73+
method is Self and complains that members of the actual class don't exist in Self,
74+
so we need to implement the cache ourselves.
75+
"""
76+
77+
@classmethod
78+
def zero(cls) -> Self:
79+
"""Return a quantity with value 0.
80+
81+
Returns:
82+
A quantity with value 0.
83+
"""
84+
_zero = cls._zero_cache.get(cls, None)
85+
if _zero is None:
86+
_zero = cls.__new__(cls)
87+
_zero._base_value = 0
88+
cls._zero_cache[cls] = _zero
89+
assert isinstance(_zero, cls)
90+
return _zero
91+
6592
@property
6693
def base_value(self) -> float:
6794
"""Return the value of this quantity in the base unit.
@@ -249,48 +276,6 @@ def __mul__(self, percent: Percentage) -> Self:
249276
product._base_value = self._base_value * percent.as_fraction()
250277
return product
251278

252-
def __iadd__(self, other: Self) -> Self:
253-
"""Add another quantity to this one.
254-
255-
Args:
256-
other: The other quantity.
257-
258-
Returns:
259-
This quantity.
260-
"""
261-
if not type(other) is type(self):
262-
return NotImplemented
263-
self._base_value += other._base_value
264-
return self
265-
266-
def __isub__(self, other: Self) -> Self:
267-
"""Subtract another quantity from this one.
268-
269-
Args:
270-
other: The other quantity.
271-
272-
Returns:
273-
This quantity.
274-
"""
275-
if not type(other) is type(self):
276-
return NotImplemented
277-
self._base_value -= other._base_value
278-
return self
279-
280-
def __imul__(self, percent: Percentage) -> Self:
281-
"""Multiply this quantity by a percentage.
282-
283-
Args:
284-
percent: The percentage.
285-
286-
Returns:
287-
This quantity.
288-
"""
289-
if not isinstance(percent, Percentage):
290-
return NotImplemented
291-
self._base_value *= percent.as_fraction()
292-
return self
293-
294279
def __gt__(self, other: Self) -> bool:
295280
"""Return whether this quantity is greater than another.
296281
@@ -413,7 +398,7 @@ class Power(
413398
):
414399
"""A power quantity.
415400
416-
Objects of this type are wrappers around `float` values.
401+
Objects of this type are wrappers around `float` values and are immutable.
417402
418403
The constructors accept a single `float` value, the `as_*()` methods return a
419404
`float` value, and each of the arithmetic operators supported by this type are
@@ -586,7 +571,7 @@ class Current(
586571
):
587572
"""A current quantity.
588573
589-
Objects of this type are wrappers around `float` values.
574+
Objects of this type are wrappers around `float` values and are immutable.
590575
591576
The constructors accept a single `float` value, the `as_*()` methods return a
592577
`float` value, and each of the arithmetic operators supported by this type are
@@ -682,7 +667,7 @@ class Voltage(
682667
):
683668
"""A voltage quantity.
684669
685-
Objects of this type are wrappers around `float` values.
670+
Objects of this type are wrappers around `float` values and are immutable.
686671
687672
The constructors accept a single `float` value, the `as_*()` methods return a
688673
`float` value, and each of the arithmetic operators supported by this type are
@@ -804,7 +789,7 @@ class Energy(
804789
):
805790
"""An energy quantity.
806791
807-
Objects of this type are wrappers around `float` values.
792+
Objects of this type are wrappers around `float` values and are immutable.
808793
809794
The constructors accept a single `float` value, the `as_*()` methods return a
810795
`float` value, and each of the arithmetic operators supported by this type are
@@ -923,7 +908,7 @@ class Frequency(
923908
):
924909
"""A frequency quantity.
925910
926-
Objects of this type are wrappers around `float` values.
911+
Objects of this type are wrappers around `float` values and are immutable.
927912
928913
The constructors accept a single `float` value, the `as_*()` methods return a
929914
`float` value, and each of the arithmetic operators supported by this type are
@@ -1036,7 +1021,7 @@ class Percentage(
10361021
):
10371022
"""A percentage quantity.
10381023
1039-
Objects of this type are wrappers around `float` values.
1024+
Objects of this type are wrappers around `float` values and are immutable.
10401025
10411026
The constructors accept a single `float` value, the `as_*()` methods return a
10421027
`float` value, and each of the arithmetic operators supported by this type are

tests/timeseries/test_quantities.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,62 @@ class Fz2(
4242
"""Frequency quantity with broad exponent unit map."""
4343

4444

45+
def test_zero() -> None:
46+
"""Test the zero value for quantity."""
47+
assert Quantity(0.0) == Quantity.zero()
48+
assert Quantity(0.0, exponent=100) == Quantity.zero()
49+
assert Quantity.zero() is Quantity.zero() # It is a "singleton"
50+
assert Quantity.zero().base_value == 0.0
51+
52+
# Test the singleton is immutable
53+
one = Quantity.zero()
54+
one += Quantity(1.0)
55+
assert one != Quantity.zero()
56+
assert Quantity.zero() == Quantity(0.0)
57+
58+
assert Power.from_watts(0.0) == Power.zero()
59+
assert Power.from_kilowatts(0.0) == Power.zero()
60+
assert isinstance(Power.zero(), Power)
61+
assert Power.zero().as_watts() == 0.0
62+
assert Power.zero().as_kilowatts() == 0.0
63+
assert Power.zero() is Power.zero() # It is a "singleton"
64+
65+
assert Current.from_amperes(0.0) == Current.zero()
66+
assert Current.from_milliamperes(0.0) == Current.zero()
67+
assert isinstance(Current.zero(), Current)
68+
assert Current.zero().as_amperes() == 0.0
69+
assert Current.zero().as_milliamperes() == 0.0
70+
assert Current.zero() is Current.zero() # It is a "singleton"
71+
72+
assert Voltage.from_volts(0.0) == Voltage.zero()
73+
assert Voltage.from_kilovolts(0.0) == Voltage.zero()
74+
assert isinstance(Voltage.zero(), Voltage)
75+
assert Voltage.zero().as_volts() == 0.0
76+
assert Voltage.zero().as_kilovolts() == 0.0
77+
assert Voltage.zero() is Voltage.zero() # It is a "singleton"
78+
79+
assert Energy.from_kilowatt_hours(0.0) == Energy.zero()
80+
assert Energy.from_megawatt_hours(0.0) == Energy.zero()
81+
assert isinstance(Energy.zero(), Energy)
82+
assert Energy.zero().as_kilowatt_hours() == 0.0
83+
assert Energy.zero().as_megawatt_hours() == 0.0
84+
assert Energy.zero() is Energy.zero() # It is a "singleton"
85+
86+
assert Frequency.from_hertz(0.0) == Frequency.zero()
87+
assert Frequency.from_megahertz(0.0) == Frequency.zero()
88+
assert isinstance(Frequency.zero(), Frequency)
89+
assert Frequency.zero().as_hertz() == 0.0
90+
assert Frequency.zero().as_megahertz() == 0.0
91+
assert Frequency.zero() is Frequency.zero() # It is a "singleton"
92+
93+
assert Percentage.from_percent(0.0) == Percentage.zero()
94+
assert Percentage.from_fraction(0.0) == Percentage.zero()
95+
assert isinstance(Percentage.zero(), Percentage)
96+
assert Percentage.zero().as_percent() == 0.0
97+
assert Percentage.zero().as_fraction() == 0.0
98+
assert Percentage.zero() is Percentage.zero() # It is a "singleton"
99+
100+
45101
def test_string_representation() -> None:
46102
"""Test the string representation of the quantities."""
47103
assert str(Quantity(1.024445, exponent=0)) == "1.024"

0 commit comments

Comments
 (0)