Skip to content

Commit 971d7cf

Browse files
authored
Parse Quantity Strings (#824)
- Fix missing number in Quantity formatting for small values - Add function to allow parsing of Quantity strings
2 parents 7161472 + 34961ec commit 971d7cf

File tree

4 files changed

+115
-6
lines changed

4 files changed

+115
-6
lines changed

RELEASE_NOTES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@
8989

9090
- The `actor.ChannelRegistry` is now type-aware.
9191

92+
- A new class method `Quantity.from_string()` has been added to allow the creation of `Quantity` objects from strings.
93+
9294
## Bug Fixes
9395

9496
- 0W power requests are now not adjusted to exclusion bounds by the `PowerManager` and `PowerDistributor`, and are sent over to the microgrid API directly.
@@ -104,3 +106,5 @@
104106
A bug made the resampler interpret zero values as `None` when generating new samples, so if the result of the resampling is zero, the resampler would just produce `None` values.
105107

106108
- The PowerManager no longer holds on to proposals from dead actors forever. If an actor hasn't sent a new proposal in 60 seconds, the available proposal from that actor is dropped.
109+
110+
- Fix `Quantity.__format__()` sometimes skipping the number for very small values.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ dev-pytest = [
9999
"async-solipsism == 0.5",
100100
# For checking docstring code examples
101101
"frequenz-sdk[dev-examples]",
102+
"hypothesis == 6.92.1",
102103
]
103104
dev = [
104105
"frequenz-sdk[dev-mkdocs,dev-flake8,dev-formatting,dev-mkdocs,dev-mypy,dev-noxfile,dev-pylint,dev-pytest]",

src/frequenz/sdk/timeseries/_quantities.py

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,42 @@ def zero(cls) -> Self:
9191
assert isinstance(_zero, cls)
9292
return _zero
9393

94+
@classmethod
95+
def from_string(cls, string: str) -> Self:
96+
"""Return a quantity from a string representation.
97+
98+
Args:
99+
string: The string representation of the quantity.
100+
101+
Returns:
102+
A quantity object with the value given in the string.
103+
104+
Raises:
105+
ValueError: If the string does not match the expected format.
106+
107+
"""
108+
split_string = string.split(" ")
109+
110+
if len(split_string) != 2:
111+
raise ValueError(
112+
f"Expected a string of the form 'value unit', got {string}"
113+
)
114+
115+
assert cls._exponent_unit_map is not None
116+
exp_map = cls._exponent_unit_map
117+
118+
for exponent, unit in exp_map.items():
119+
if unit == split_string[1]:
120+
instance = cls.__new__(cls)
121+
try:
122+
instance._base_value = float(split_string[0]) * 10**exponent
123+
except ValueError as error:
124+
raise ValueError(f"Failed to parse string '{string}'.") from error
125+
126+
return instance
127+
128+
raise ValueError(f"Unknown unit {split_string[1]}")
129+
94130
@property
95131
def base_value(self) -> float:
96132
"""Return the value of this quantity in the base unit.
@@ -163,6 +199,7 @@ def __str__(self) -> str:
163199
"""
164200
return self.__format__("")
165201

202+
# pylint: disable=too-many-branches
166203
def __format__(self, __format_spec: str) -> str:
167204
"""Return a formatted string representation of this quantity.
168205
@@ -214,8 +251,22 @@ def __format__(self, __format_spec: str) -> str:
214251
if math.isinf(self._base_value) or math.isnan(self._base_value):
215252
return f"{self._base_value} {self._exponent_unit_map[0]}"
216253

217-
abs_value = abs(self._base_value)
218-
exponent = math.floor(math.log10(abs_value)) if abs_value else 0
254+
if abs_value := abs(self._base_value):
255+
precision_pow = 10 ** (precision)
256+
# Prevent numbers like 999.999999 being rendered as 1000 V
257+
# instead of 1 kV.
258+
# This could happen because the str formatting function does
259+
# rounding as well.
260+
# This is an imperfect solution that works for _most_ cases.
261+
# isclose parameters were chosen according to the observed cases
262+
if math.isclose(abs_value, precision_pow, abs_tol=1e-4, rel_tol=0.01):
263+
# If the value is close to the precision, round it
264+
exponent = math.ceil(math.log10(precision_pow))
265+
else:
266+
exponent = math.floor(math.log10(abs_value))
267+
else:
268+
exponent = 0
269+
219270
unit_place = exponent - exponent % 3
220271
if unit_place < min(self._exponent_unit_map):
221272
unit = self._exponent_unit_map[min(self._exponent_unit_map.keys())]
@@ -225,11 +276,17 @@ def __format__(self, __format_spec: str) -> str:
225276
unit_place = max(self._exponent_unit_map)
226277
else:
227278
unit = self._exponent_unit_map[unit_place]
279+
228280
value_str = f"{self._base_value / 10 ** unit_place:.{precision}f}"
229-
stripped = value_str.rstrip("0").rstrip(".")
281+
282+
if value_str in ("-0", "0"):
283+
stripped = value_str
284+
else:
285+
stripped = value_str.rstrip("0").rstrip(".")
286+
230287
if not keep_trailing_zeros:
231288
value_str = stripped
232-
unit_str = unit if stripped != "0" else self._exponent_unit_map[0]
289+
unit_str = unit if stripped not in ("-0", "0") else self._exponent_unit_map[0]
233290
return f"{value_str} {unit_str}"
234291

235292
def __add__(self, other: Self) -> Self:

tests/timeseries/test_quantities.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55

66
from datetime import timedelta
77

8+
import hypothesis
89
import pytest
10+
from hypothesis import strategies as st
911

1012
from frequenz.sdk.timeseries._quantities import (
1113
Current,
@@ -105,8 +107,11 @@ def test_string_representation() -> None:
105107
assert (
106108
repr(Quantity(1.024445, exponent=0)) == "Quantity(value=1.024445, exponent=0)"
107109
)
110+
assert f"{Quantity(0.50001, exponent=0):.0}" == "1"
108111
assert f"{Quantity(1.024445, exponent=0)}" == "1.024"
109112
assert f"{Quantity(1.024445, exponent=0):.0}" == "1"
113+
assert f"{Quantity(0.124445, exponent=0):.0}" == "0"
114+
assert f"{Quantity(0.50001, exponent=0):.0}" == "1"
110115
assert f"{Quantity(1.024445, exponent=0):.6}" == "1.024445"
111116

112117
assert f"{Quantity(1.024445, exponent=3)}" == "1024.445"
@@ -128,7 +133,6 @@ def test_string_representation() -> None:
128133

129134
assert f"{Fz1(1.024445, exponent=6)}" == "1024.445 kHz"
130135
assert f"{Fz2(1.024445, exponent=6)}" == "1.024 MHz"
131-
132136
assert f"{Fz1(1.024445, exponent=9)}" == "1024445 kHz"
133137
assert f"{Fz2(1.024445, exponent=9)}" == "1.024 GHz"
134138

@@ -147,6 +151,12 @@ def test_string_representation() -> None:
147151
assert f"{Fz1(-20)}" == "-20 Hz"
148152
assert f"{Fz1(-20000)}" == "-20 kHz"
149153

154+
assert f"{Power.from_watts(0.000124445):.0}" == "0 W"
155+
assert f"{Energy.from_watt_hours(0.124445):.0}" == "0 Wh"
156+
assert f"{Power.from_watts(-0.0):.0}" == "-0 W"
157+
assert f"{Power.from_watts(0.0):.0}" == "0 W"
158+
assert f"{Voltage.from_volts(999.9999850988388)}" == "1 kV"
159+
150160

151161
def test_isclose() -> None:
152162
"""Test the isclose method of the quantities."""
@@ -355,7 +365,7 @@ def test_frequency() -> None:
355365
"""Test the frequency class."""
356366
freq = Frequency.from_hertz(0.0000002)
357367
assert f"{freq:.9}" == "0.0000002 Hz"
358-
freq = Frequency.from_kilohertz(600000.0)
368+
freq = Frequency.from_kilohertz(600_000.0)
359369
assert f"{freq}" == "600 MHz"
360370

361371
freq = Frequency.from_hertz(6.0)
@@ -527,3 +537,40 @@ def test_invalid_multiplications() -> None:
527537
_ = quantity * 200.0 # type: ignore
528538
with pytest.raises(TypeError):
529539
quantity *= 200.0 # type: ignore
540+
541+
542+
@pytest.mark.parametrize("quantity_type", [Power, Voltage, Current, Energy, Frequency])
543+
@pytest.mark.parametrize("exponent", [0, 3, 6, 9])
544+
@hypothesis.settings(
545+
max_examples=1000
546+
) # Set to have a decent amount of examples (default is 100)
547+
@hypothesis.seed(42) # Seed that triggers a lot of problematic edge cases
548+
@hypothesis.given(value=st.floats(min_value=-1.0, max_value=1.0))
549+
def test_to_and_from_string(
550+
quantity_type: type[Quantity], exponent: int, value: float
551+
) -> None:
552+
"""Test string parsing and formatting.
553+
554+
The parameters for this test are constructed to stay deterministic.
555+
556+
With a different (or random) seed or different max_examples the
557+
test will show failing examples.
558+
559+
Fixing those cases was considered an unreasonable amount of work
560+
at the time of writing.
561+
562+
For the future, one idea was to parse the string number after the first
563+
generation and regenerate it with the more appropriate unit and precision.
564+
"""
565+
quantity = quantity_type.__new__(quantity_type)
566+
# pylint: disable=protected-access
567+
quantity._base_value = value * 10**exponent
568+
quantity_str = f"{quantity:.{exponent}}"
569+
from_string = quantity_type.from_string(quantity_str)
570+
try:
571+
assert f"{from_string:.{exponent}}" == quantity_str
572+
except AssertionError as error:
573+
pytest.fail(
574+
f"Failed for {quantity.base_value} != from_string({from_string.base_value}) "
575+
+ f"with exponent {exponent} and source value '{value}': {error}"
576+
)

0 commit comments

Comments
 (0)