Skip to content

Commit 1123f46

Browse files
committed
Add function to allow parsing of Quantity strings
Signed-off-by: Mathias L. Baumann <[email protected]>
1 parent cc10827 commit 1123f46

File tree

4 files changed

+80
-1
lines changed

4 files changed

+80
-1
lines changed

RELEASE_NOTES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@
7272

7373
- The `actor.ChannelRegistry` is now type-aware.
7474

75+
- A new class method `Quantity.from_string()` has been added to allow the creation of `Quantity` objects from strings.
76+
7577
## Bug Fixes
7678

7779
- 0W power requests are now not adjusted to exclusion bounds by the `PowerManager` and `PowerDistributor`, and are sent over to the microgrid API directly.
@@ -87,4 +89,5 @@
8789
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.
8890

8991
- 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.
92+
9093
- 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: 36 additions & 0 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.

tests/timeseries/test_quantities.py

Lines changed: 40 additions & 1 deletion
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,
@@ -359,7 +361,7 @@ def test_frequency() -> None:
359361
"""Test the frequency class."""
360362
freq = Frequency.from_hertz(0.0000002)
361363
assert f"{freq:.9}" == "0.0000002 Hz"
362-
freq = Frequency.from_kilohertz(600000.0)
364+
freq = Frequency.from_kilohertz(600_000.0)
363365
assert f"{freq}" == "600 MHz"
364366

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

0 commit comments

Comments
 (0)