Skip to content

Commit 1c86cd5

Browse files
Cover higher order formulas and fix precedence in formula formatter
Signed-off-by: Christian Parpart <[email protected]>
1 parent 77fbc9b commit 1c86cd5

File tree

2 files changed

+89
-42
lines changed

2 files changed

+89
-42
lines changed

src/frequenz/sdk/timeseries/_formula_engine/_formula_formatter.py

Lines changed: 65 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@
2626
class OperatorPrecedence(enum.Enum):
2727
"""The precedence of an operator."""
2828

29+
ADDITION = 1
2930
SUBTRACTION = 1
30-
ADDITION = 2
31-
DIVISION = 3
32-
MULTIPLICATION = 4
31+
MULTIPLICATION = 2
32+
DIVISION = 2
3333
PRIMARY = 9
3434

3535
def __lt__(self, other: OperatorPrecedence) -> bool:
@@ -43,37 +43,66 @@ def __lt__(self, other: OperatorPrecedence) -> bool:
4343
"""
4444
return self.value < other.value
4545

46+
def __le__(self, other: OperatorPrecedence) -> bool:
47+
"""Test the precedence of this operator is less than or equal to the other operator.
48+
49+
Args:
50+
other: The other operator (on the right-hand side).
51+
52+
Returns:
53+
Whether the precedence of this operator is less than or equal to the other operator.
54+
"""
55+
return self.value <= other.value
56+
57+
58+
class Operator(enum.Enum):
59+
"""The precedence of an operator."""
60+
61+
ADDITION = "+"
62+
SUBTRACTION = "-"
63+
MULTIPLICATION = "*"
64+
DIVISION = "/"
65+
66+
@property
67+
def precedence(self) -> OperatorPrecedence:
68+
"""Return the precedence of this operator.
69+
70+
Returns:
71+
The precedence of this operator.
72+
"""
73+
match self:
74+
case Operator.SUBTRACTION:
75+
return OperatorPrecedence.SUBTRACTION
76+
case Operator.ADDITION:
77+
return OperatorPrecedence.ADDITION
78+
case Operator.DIVISION:
79+
return OperatorPrecedence.DIVISION
80+
case Operator.MULTIPLICATION:
81+
return OperatorPrecedence.MULTIPLICATION
82+
4683
def __str__(self) -> str:
4784
"""Return the string representation of the operator precedence.
4885
4986
Returns:
5087
The string representation of the operator precedence.
5188
"""
52-
match self:
53-
case OperatorPrecedence.SUBTRACTION:
54-
return "-"
55-
case OperatorPrecedence.ADDITION:
56-
return "+"
57-
case OperatorPrecedence.DIVISION:
58-
return "/"
59-
case OperatorPrecedence.MULTIPLICATION:
60-
return "*"
61-
case OperatorPrecedence.PRIMARY:
62-
return "primary"
89+
return str(self.value)
6390

6491

6592
class StackItem:
6693
"""Stack item for the formula formatter."""
6794

68-
def __init__(self, value: str, precedence: OperatorPrecedence):
95+
def __init__(self, value: str, precedence: OperatorPrecedence, num_steps: int):
6996
"""Initialize the StackItem.
7097
7198
Args:
7299
value: The value of the stack item.
73100
precedence: The precedence of the stack item.
101+
num_steps: The number of steps of the stack item.
74102
"""
75103
self.value = value
76104
self.precedence = precedence
105+
self.num_steps = num_steps
77106

78107
def __str__(self) -> str:
79108
"""Return the string representation of the stack item.
@@ -83,7 +112,7 @@ def __str__(self) -> str:
83112
Returns:
84113
str: The string representation of the stack item.
85114
"""
86-
return f'("{self.value}", {self.precedence})'
115+
return f'("{self.value}", {self.precedence}, {self.num_steps})'
87116

88117
def as_left_value(self, outer_precedence: OperatorPrecedence) -> str:
89118
"""Return the value of the stack item with parentheses if necessary.
@@ -105,12 +134,14 @@ def as_right_value(self, outer_precedence: OperatorPrecedence) -> str:
105134
Returns:
106135
str: The value of the stack item with parentheses if necessary.
107136
"""
137+
if self.num_steps > 1:
138+
return (
139+
f"({self.value})" if self.precedence <= outer_precedence else self.value
140+
)
108141
return f"({self.value})" if self.precedence < outer_precedence else self.value
109142

110143
@staticmethod
111-
def create_binary(
112-
lhs: StackItem, operator: OperatorPrecedence, rhs: StackItem
113-
) -> StackItem:
144+
def create_binary(lhs: StackItem, operator: Operator, rhs: StackItem) -> StackItem:
114145
"""Create a binary stack item.
115146
116147
Args:
@@ -121,10 +152,11 @@ def create_binary(
121152
Returns:
122153
StackItem: The binary stack item.
123154
"""
124-
pred = OperatorPrecedence(operator)
155+
pred = OperatorPrecedence(operator.precedence)
125156
return StackItem(
126157
f"{lhs.as_left_value(pred)} {operator} {rhs.as_right_value(pred)}",
127158
pred,
159+
lhs.num_steps + 1 + rhs.num_steps,
128160
)
129161

130162
@staticmethod
@@ -137,7 +169,7 @@ def create_primary(value: float) -> StackItem:
137169
Returns:
138170
StackItem: The literal stack item.
139171
"""
140-
return StackItem(str(value), OperatorPrecedence.PRIMARY)
172+
return StackItem(str(value), OperatorPrecedence.PRIMARY, 1)
141173

142174

143175
class FormulaFormatter:
@@ -174,49 +206,48 @@ def _format(self, postfix_expr: list[FormulaStep]) -> str:
174206
case ConstantValue():
175207
self._stack.append(StackItem.create_primary(step.value))
176208
case Adder():
177-
self._format_binary(OperatorPrecedence.ADDITION)
209+
self._format_binary(Operator.ADDITION)
178210
case Subtractor():
179-
self._format_binary(OperatorPrecedence.SUBTRACTION)
211+
self._format_binary(Operator.SUBTRACTION)
180212
case Multiplier():
181-
self._format_binary(OperatorPrecedence.MULTIPLICATION)
213+
self._format_binary(Operator.MULTIPLICATION)
182214
case Divider():
183-
self._format_binary(OperatorPrecedence.DIVISION)
215+
self._format_binary(Operator.DIVISION)
184216
case Averager():
185217
value = (
186218
# pylint: disable=protected-access
187219
f"avg({', '.join(self._format([f]) for f in step.fetchers)})"
188220
)
189-
self._stack.append(StackItem(value, OperatorPrecedence.PRIMARY))
221+
self._stack.append(StackItem(value, OperatorPrecedence.PRIMARY, 1))
190222
case Clipper():
191223
the_value = self._stack.pop()
192224
min_value = step.min_value if step.min_value is not None else "-inf"
193225
max_value = step.max_value if step.max_value is not None else "inf"
194226
value = f"clip({min_value}, {the_value.value}, {max_value})"
195-
self._stack.append(StackItem(value, OperatorPrecedence.PRIMARY))
227+
self._stack.append(StackItem(value, OperatorPrecedence.PRIMARY, 1))
196228
case Maximizer():
197229
left, right = self._pop_two_from_stack()
198230
value = f"max({left.value}, {right.value})"
199-
self._stack.append(StackItem(value, OperatorPrecedence.PRIMARY))
231+
self._stack.append(StackItem(value, OperatorPrecedence.PRIMARY, 1))
200232
case Minimizer():
201233
left, right = self._pop_two_from_stack()
202234
value = f"min({left.value}, {right.value})"
203-
self._stack.append(StackItem(value, OperatorPrecedence.PRIMARY))
235+
self._stack.append(StackItem(value, OperatorPrecedence.PRIMARY, 1))
204236
case MetricFetcher():
205237
metric_fetcher = step
238+
value = metric_fetcher._name # pylint: disable=protected-access
206239
if engine_reference := getattr(
207240
metric_fetcher.stream, "_engine_reference", None
208241
):
209-
value = str(engine_reference)
210-
else:
211-
value = metric_fetcher._name # pylint: disable=protected-access
212-
self._stack.append(StackItem(value, OperatorPrecedence.PRIMARY))
242+
value = f"[{value}]({str(engine_reference)})"
243+
self._stack.append(StackItem(value, OperatorPrecedence.PRIMARY, 1))
213244
case OpenParen():
214245
pass # We gently ignore this one.
215246

216247
assert len(self._stack) == 1
217248
return self._stack[0].value
218249

219-
def _format_binary(self, operator: OperatorPrecedence) -> None:
250+
def _format_binary(self, operator: Operator) -> None:
220251
"""Format a binary operation.
221252
222253
Pops the arguments of the binary expression from the stack

tests/timeseries/test_formula_formatter.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from frequenz.channels import Broadcast
1010
from pytest_mock import MockerFixture
1111

12+
from frequenz.sdk import microgrid
1213
from frequenz.sdk.timeseries import Sample
1314
from frequenz.sdk.timeseries._formula_engine._formula_engine import FormulaBuilder
1415
from frequenz.sdk.timeseries._formula_engine._formula_formatter import FormulaFormatter
@@ -22,7 +23,6 @@
2223
)
2324
from frequenz.sdk.timeseries._formula_engine._tokenizer import Tokenizer, TokenType
2425
from frequenz.sdk.timeseries._quantities import Percentage, Quantity
25-
from frequenz.sdk import microgrid
2626
from tests.timeseries.mock_microgrid import MockMicrogrid
2727

2828
LOGGER = logging.getLogger(__name__)
@@ -68,9 +68,7 @@ def reconstruct(formula: str) -> str:
6868
steps = build_formula(formula)
6969
reconstructed = FormulaFormatter.format(steps)
7070
if formula != reconstructed:
71-
LOGGER.debug(
72-
"Formula: input %s != output %s, steps: {steps}", formula, reconstructed
73-
)
71+
LOGGER.debug("Formula: input %s != output %s", formula, reconstructed)
7472
return reconstructed
7573

7674

@@ -91,6 +89,17 @@ def test_lhs_precedence(self) -> None:
9189
assert reconstruct("#2 - #3 - #4") == "#2 - #3 - #4"
9290
assert reconstruct("(#2 - #3) * #4") == "(#2 - #3) * #4"
9391

92+
def test_rhs_precedence(self) -> None:
93+
"""Test that the right-hand side of a binary operation is wrapped in parentheses if needed."""
94+
assert reconstruct("#2 + #3") == "#2 + #3"
95+
assert reconstruct("#2 - #3") == "#2 - #3"
96+
assert reconstruct("#2 + #3 + #4") == "#2 + #3 + #4"
97+
assert reconstruct("#2 - #3 - #4") == "#2 - #3 - #4"
98+
assert reconstruct("#2 - #3 * #4") == "#2 - #3 * #4"
99+
assert reconstruct("#2 - (#3 * #4)") == "#2 - #3 * #4"
100+
assert reconstruct("#2 - (#3 - #4)") == "#2 - (#3 - #4)"
101+
assert reconstruct("#2 - (#3 + #4)") == "#2 - (#3 + #4)"
102+
94103
def test_rhs_parenthesis(self) -> None:
95104
"""Test that the right-hand side of a binary operation is wrapped in parentheses."""
96105
assert reconstruct("#2 / (#3 - #4)") == "#2 / (#3 - #4)"
@@ -116,11 +125,18 @@ async def test_higher_order_formula(self, mocker: MockerFixture) -> None:
116125
mockgrid = MockMicrogrid(grid_meter=False)
117126
mockgrid.add_batteries(3)
118127
mockgrid.add_ev_chargers(1)
119-
mockgrid.add_solar_inverters(1)
128+
mockgrid.add_solar_inverters(2)
120129
await mockgrid.start(mocker)
121130

122131
logical_meter = microgrid.logical_meter()
123-
assert str(logical_meter.grid_power) == '#36 + #7 + #47 + #17 + #27'
132+
assert str(logical_meter.grid_power) == "#36 + #7 + #47 + #17 + #57 + #27"
133+
134+
composed_formula = (logical_meter.grid_power - logical_meter.pv_power).build(
135+
"grid_minus_pv"
136+
)
137+
assert (
138+
str(composed_formula)
139+
== "[grid-power](#36 + #7 + #47 + #17 + #57 + #27) - [pv-power](#57 + #47)"
140+
)
124141

125-
composed_formula = (logical_meter.grid_power - logical_meter.pv_power).build('grid_minus_pv')
126-
assert str(composed_formula) == '#36 + #7 + #47 + #17 + #27 - #47'
142+
await mockgrid.cleanup()

0 commit comments

Comments
 (0)