Skip to content

Commit 5df1db5

Browse files
authored
Make sure base_value is always float (frequenz-floss#874)
In Python `float` is a bit weird, because `isinstance(1, float)` is `False` but still `int` is a subtype of `float` in terms of the type system, so `int` will be accepted when a `float` is specified in arguments for example. This leads to quantities that can have `int` as base value if it was constructed with a `int` instead of a `float`, which is unintuitive when the `base_value` type hint is `float` and can potentially cause problems, in particular when addressing frequenz-floss#821.
2 parents 1a9eb74 + 15c0d14 commit 5df1db5

File tree

3 files changed

+120
-63
lines changed

3 files changed

+120
-63
lines changed

RELEASE_NOTES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@
1515
## Bug Fixes
1616

1717
- Fix grid current formula generator to add the operator `+` to the engine only when the component category is handled.
18+
- Fix bug where sometimes the `base_value` of a `Quantity` could be of a different type than `float`.

src/frequenz/sdk/timeseries/_quantities.py

Lines changed: 38 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,22 @@ def __init__(self, value: float, exponent: int = 0) -> None:
4848
value: The value of this quantity in a given exponent of the base unit.
4949
exponent: The exponent of the base unit the given value is in.
5050
"""
51-
self._base_value = value * 10**exponent
51+
self._base_value = value * 10.0**exponent
52+
53+
@classmethod
54+
def _new(cls, value: float, *, exponent: int = 0) -> Self:
55+
"""Instantiate a new quantity subclass instance.
56+
57+
Args:
58+
value: The value of this quantity in a given exponent of the base unit.
59+
exponent: The exponent of the base unit the given value is in.
60+
61+
Returns:
62+
A new quantity subclass instance.
63+
"""
64+
self = cls.__new__(cls)
65+
self._base_value = value * 10.0**exponent
66+
return self
5267

5368
def __init_subclass__(cls, exponent_unit_map: dict[int, str]) -> None:
5469
"""Initialize a new subclass of Quantity.
@@ -78,15 +93,15 @@ def __init_subclass__(cls, exponent_unit_map: dict[int, str]) -> None:
7893

7994
@classmethod
8095
def zero(cls) -> Self:
81-
"""Return a quantity with value 0.
96+
"""Return a quantity with value 0.0.
8297
8398
Returns:
84-
A quantity with value 0.
99+
A quantity with value 0.0.
85100
"""
86101
_zero = cls._zero_cache.get(cls, None)
87102
if _zero is None:
88103
_zero = cls.__new__(cls)
89-
_zero._base_value = 0
104+
_zero._base_value = 0.0
90105
cls._zero_cache[cls] = _zero
91106
assert isinstance(_zero, cls)
92107
return _zero
@@ -464,9 +479,7 @@ def from_celsius(cls, value: float) -> Self:
464479
Returns:
465480
A new temperature quantity.
466481
"""
467-
power = cls.__new__(cls)
468-
power._base_value = value
469-
return power
482+
return cls._new(value)
470483

471484
def as_celsius(self) -> float:
472485
"""Return the temperature in degrees Celsius.
@@ -508,9 +521,7 @@ def from_watts(cls, watts: float) -> Self:
508521
Returns:
509522
A new power quantity.
510523
"""
511-
power = cls.__new__(cls)
512-
power._base_value = watts
513-
return power
524+
return cls._new(watts)
514525

515526
@classmethod
516527
def from_milliwatts(cls, milliwatts: float) -> Self:
@@ -522,9 +533,7 @@ def from_milliwatts(cls, milliwatts: float) -> Self:
522533
Returns:
523534
A new power quantity.
524535
"""
525-
power = cls.__new__(cls)
526-
power._base_value = milliwatts * 10**-3
527-
return power
536+
return cls._new(milliwatts, exponent=-3)
528537

529538
@classmethod
530539
def from_kilowatts(cls, kilowatts: float) -> Self:
@@ -536,9 +545,7 @@ def from_kilowatts(cls, kilowatts: float) -> Self:
536545
Returns:
537546
A new power quantity.
538547
"""
539-
power = cls.__new__(cls)
540-
power._base_value = kilowatts * 10**3
541-
return power
548+
return cls._new(kilowatts, exponent=3)
542549

543550
@classmethod
544551
def from_megawatts(cls, megawatts: float) -> Self:
@@ -550,9 +557,7 @@ def from_megawatts(cls, megawatts: float) -> Self:
550557
Returns:
551558
A new power quantity.
552559
"""
553-
power = cls.__new__(cls)
554-
power._base_value = megawatts * 10**6
555-
return power
560+
return cls._new(megawatts, exponent=6)
556561

557562
def as_watts(self) -> float:
558563
"""Return the power in watts.
@@ -690,9 +695,7 @@ def from_amperes(cls, amperes: float) -> Self:
690695
Returns:
691696
A new current quantity.
692697
"""
693-
current = cls.__new__(cls)
694-
current._base_value = amperes
695-
return current
698+
return cls._new(amperes)
696699

697700
@classmethod
698701
def from_milliamperes(cls, milliamperes: float) -> Self:
@@ -704,9 +707,7 @@ def from_milliamperes(cls, milliamperes: float) -> Self:
704707
Returns:
705708
A new current quantity.
706709
"""
707-
current = cls.__new__(cls)
708-
current._base_value = milliamperes * 10**-3
709-
return current
710+
return cls._new(milliamperes, exponent=-3)
710711

711712
def as_amperes(self) -> float:
712713
"""Return the current in amperes.
@@ -789,9 +790,7 @@ def from_volts(cls, volts: float) -> Self:
789790
Returns:
790791
A new voltage quantity.
791792
"""
792-
voltage = cls.__new__(cls)
793-
voltage._base_value = volts
794-
return voltage
793+
return cls._new(volts)
795794

796795
@classmethod
797796
def from_millivolts(cls, millivolts: float) -> Self:
@@ -803,9 +802,7 @@ def from_millivolts(cls, millivolts: float) -> Self:
803802
Returns:
804803
A new voltage quantity.
805804
"""
806-
voltage = cls.__new__(cls)
807-
voltage._base_value = millivolts * 10**-3
808-
return voltage
805+
return cls._new(millivolts, exponent=-3)
809806

810807
@classmethod
811808
def from_kilovolts(cls, kilovolts: float) -> Self:
@@ -817,9 +814,7 @@ def from_kilovolts(cls, kilovolts: float) -> Self:
817814
Returns:
818815
A new voltage quantity.
819816
"""
820-
voltage = cls.__new__(cls)
821-
voltage._base_value = kilovolts * 10**3
822-
return voltage
817+
return cls._new(kilovolts, exponent=3)
823818

824819
def as_volts(self) -> float:
825820
"""Return the voltage in volts.
@@ -914,9 +909,7 @@ def from_watt_hours(cls, watt_hours: float) -> Self:
914909
Returns:
915910
A new energy quantity.
916911
"""
917-
energy = cls.__new__(cls)
918-
energy._base_value = watt_hours
919-
return energy
912+
return cls._new(watt_hours)
920913

921914
@classmethod
922915
def from_kilowatt_hours(cls, kilowatt_hours: float) -> Self:
@@ -928,9 +921,7 @@ def from_kilowatt_hours(cls, kilowatt_hours: float) -> Self:
928921
Returns:
929922
A new energy quantity.
930923
"""
931-
energy = cls.__new__(cls)
932-
energy._base_value = kilowatt_hours * 10**3
933-
return energy
924+
return cls._new(kilowatt_hours, exponent=3)
934925

935926
@classmethod
936927
def from_megawatt_hours(cls, megawatt_hours: float) -> Self:
@@ -942,9 +933,7 @@ def from_megawatt_hours(cls, megawatt_hours: float) -> Self:
942933
Returns:
943934
A new energy quantity.
944935
"""
945-
energy = cls.__new__(cls)
946-
energy._base_value = megawatt_hours * 10**6
947-
return energy
936+
return cls._new(megawatt_hours, exponent=6)
948937

949938
def as_watt_hours(self) -> float:
950939
"""Return the energy in watt hours.
@@ -1039,9 +1028,7 @@ def from_hertz(cls, hertz: float) -> Self:
10391028
Returns:
10401029
A new frequency quantity.
10411030
"""
1042-
frequency = cls.__new__(cls)
1043-
frequency._base_value = hertz
1044-
return frequency
1031+
return cls._new(hertz)
10451032

10461033
@classmethod
10471034
def from_kilohertz(cls, kilohertz: float) -> Self:
@@ -1053,9 +1040,7 @@ def from_kilohertz(cls, kilohertz: float) -> Self:
10531040
Returns:
10541041
A new frequency quantity.
10551042
"""
1056-
frequency = cls.__new__(cls)
1057-
frequency._base_value = kilohertz * 10**3
1058-
return frequency
1043+
return cls._new(kilohertz, exponent=3)
10591044

10601045
@classmethod
10611046
def from_megahertz(cls, megahertz: float) -> Self:
@@ -1067,9 +1052,7 @@ def from_megahertz(cls, megahertz: float) -> Self:
10671052
Returns:
10681053
A new frequency quantity.
10691054
"""
1070-
frequency = cls.__new__(cls)
1071-
frequency._base_value = megahertz * 10**6
1072-
return frequency
1055+
return cls._new(megahertz, exponent=6)
10731056

10741057
@classmethod
10751058
def from_gigahertz(cls, gigahertz: float) -> Self:
@@ -1081,9 +1064,7 @@ def from_gigahertz(cls, gigahertz: float) -> Self:
10811064
Returns:
10821065
A new frequency quantity.
10831066
"""
1084-
frequency = cls.__new__(cls)
1085-
frequency._base_value = gigahertz * 10**9
1086-
return frequency
1067+
return cls._new(gigahertz, exponent=9)
10871068

10881069
def as_hertz(self) -> float:
10891070
"""Return the frequency in hertz.
@@ -1152,9 +1133,7 @@ def from_percent(cls, percent: float) -> Self:
11521133
Returns:
11531134
A new percentage quantity.
11541135
"""
1155-
percentage = cls.__new__(cls)
1156-
percentage._base_value = percent
1157-
return percentage
1136+
return cls._new(percent)
11581137

11591138
@classmethod
11601139
def from_fraction(cls, fraction: float) -> Self:
@@ -1166,9 +1145,7 @@ def from_fraction(cls, fraction: float) -> Self:
11661145
Returns:
11671146
A new percentage quantity.
11681147
"""
1169-
percentage = cls.__new__(cls)
1170-
percentage._base_value = fraction * 100
1171-
return percentage
1148+
return cls._new(fraction * 100)
11721149

11731150
def as_percent(self) -> float:
11741151
"""Return this quantity as a percentage.

tests/timeseries/test_quantities.py

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@
33

44
"""Tests for quantity types."""
55

6+
import inspect
67
from datetime import timedelta
8+
from typing import Callable
79

810
import hypothesis
911
import pytest
1012
from hypothesis import strategies as st
1113

14+
from frequenz.sdk.timeseries import _quantities
1215
from frequenz.sdk.timeseries._quantities import (
1316
Current,
1417
Energy,
@@ -45,6 +48,48 @@ class Fz2(
4548
"""Frequency quantity with broad exponent unit map."""
4649

4750

51+
_CtorType = Callable[[float], Quantity]
52+
53+
# This is the current number of subclasses. This probably will get outdated, but it will
54+
# provide at least some safety against something going really wrong and end up testing
55+
# an empty list. With this we should at least make sure we are not testing less classes
56+
# than before. We don't get the actual number using len(_QUANTITY_SUBCLASSES) because it
57+
# would defeat the purpose of the test.
58+
_SANITFY_NUM_CLASSES = 7
59+
60+
_QUANTITY_SUBCLASSES = [
61+
cls
62+
for _, cls in inspect.getmembers(
63+
_quantities,
64+
lambda m: inspect.isclass(m) and issubclass(m, Quantity) and m is not Quantity,
65+
)
66+
]
67+
68+
# A very basic sanity check that are messing up the introspection
69+
assert len(_QUANTITY_SUBCLASSES) >= _SANITFY_NUM_CLASSES
70+
71+
_QUANTITY_BASE_UNIT_STRINGS = [
72+
cls._new(0).base_unit # pylint: disable=protected-access
73+
for cls in _QUANTITY_SUBCLASSES
74+
]
75+
for unit in _QUANTITY_BASE_UNIT_STRINGS:
76+
assert unit is not None
77+
78+
_QUANTITY_CTORS = [
79+
method
80+
for cls in _QUANTITY_SUBCLASSES
81+
for _, method in inspect.getmembers(
82+
cls,
83+
lambda m: inspect.ismethod(m)
84+
and m.__name__.startswith("from_")
85+
and m.__name__ != ("from_string"),
86+
)
87+
]
88+
# A very basic sanity check that are messing up the introspection. There are actually
89+
# many more constructors than classes, but this still works as a very basic check.
90+
assert len(_QUANTITY_CTORS) >= _SANITFY_NUM_CLASSES
91+
92+
4893
def test_zero() -> None:
4994
"""Test the zero value for quantity."""
5095
assert Quantity(0.0) == Quantity.zero()
@@ -101,6 +146,31 @@ def test_zero() -> None:
101146
assert Percentage.zero() is Percentage.zero() # It is a "singleton"
102147

103148

149+
@pytest.mark.parametrize("quantity_ctor", _QUANTITY_CTORS)
150+
def test_base_value_from_ctor_is_float(quantity_ctor: _CtorType) -> None:
151+
"""Test that the base value always is a float."""
152+
quantity = quantity_ctor(1)
153+
assert isinstance(quantity.base_value, float)
154+
155+
156+
@pytest.mark.parametrize("quantity_type", _QUANTITY_SUBCLASSES + [Quantity])
157+
def test_base_value_from_zero_is_float(quantity_type: type[Quantity]) -> None:
158+
"""Test that the base value always is a float."""
159+
quantity = quantity_type.zero()
160+
assert isinstance(quantity.base_value, float)
161+
162+
163+
@pytest.mark.parametrize(
164+
"quantity_type, unit", zip(_QUANTITY_SUBCLASSES, _QUANTITY_BASE_UNIT_STRINGS)
165+
)
166+
def test_base_value_from_string_is_float(
167+
quantity_type: type[Quantity], unit: str
168+
) -> None:
169+
"""Test that the base value always is a float."""
170+
quantity = quantity_type.from_string(f"1 {unit}")
171+
assert isinstance(quantity.base_value, float)
172+
173+
104174
def test_string_representation() -> None:
105175
"""Test the string representation of the quantities."""
106176
assert str(Quantity(1.024445, exponent=0)) == "1.024"
@@ -539,6 +609,9 @@ def test_invalid_multiplications() -> None:
539609
quantity *= 200.0 # type: ignore
540610

541611

612+
# We can't use _QUANTITY_TYPES here, because it will break the tests, as hypothesis
613+
# will generate more values, some of which are unsupported by the quantities. See the
614+
# test comment for more details.
542615
@pytest.mark.parametrize("quantity_type", [Power, Voltage, Current, Energy, Frequency])
543616
@pytest.mark.parametrize("exponent", [0, 3, 6, 9])
544617
@hypothesis.settings(
@@ -563,8 +636,14 @@ def test_to_and_from_string(
563636
generation and regenerate it with the more appropriate unit and precision.
564637
"""
565638
quantity = quantity_type.__new__(quantity_type)
566-
# pylint: disable=protected-access
567-
quantity._base_value = value * 10**exponent
639+
quantity._base_value = value * 10**exponent # pylint: disable=protected-access
640+
# The above should be replaced with:
641+
# quantity = quantity_type._new( # pylint: disable=protected-access
642+
# value, exponent=exponent
643+
# )
644+
# But we can't do that now, because, you guessed it, it will also break the tests
645+
# (_new() will use 10.0**exponent instead of 10**exponent, which seems to have some
646+
# effect on the tests.
568647
quantity_str = f"{quantity:.{exponent}}"
569648
from_string = quantity_type.from_string(quantity_str)
570649
try:

0 commit comments

Comments
 (0)