Skip to content

Commit 7bb970a

Browse files
Add format_formula (#613)
refs #436 Okay, I think I'd like to split #436 into TWO PRs, because the second part of that ticket implies working on other parts of the SDK that I am not sure how to deal with just yet and that will be more intrusive as well. The first part is considered done.
2 parents 97ecf34 + ca36082 commit 7bb970a

File tree

5 files changed

+491
-1
lines changed

5 files changed

+491
-1
lines changed

RELEASE_NOTES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424

2525
- NaN values are treated as missing when gaps are determined in the `OrderedRingBuffer`.
2626

27+
- Now when printing `FormulaEngine` for debugging purposes the the formula will be shown in infix notation, which should be easier to read.
28+
2729
## Bug Fixes
2830

2931
<!-- Here goes notable bug fixes that are worth a special mention or explanation -->

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# License: MIT
22
# Copyright © 2022 Frequenz Energy-as-a-Service GmbH
33

4+
# pylint: disable=too-many-lines
5+
46
"""A formula engine that can apply formulas on streaming data."""
57

68
from __future__ import annotations
@@ -18,6 +20,7 @@
1820
from .. import Sample, Sample3Phase
1921
from .._quantities import Quantity, QuantityT
2022
from ._formula_evaluator import FormulaEvaluator
23+
from ._formula_formatter import format_formula
2124
from ._formula_steps import (
2225
Adder,
2326
Averager,
@@ -304,6 +307,19 @@ async def _run(self) -> None:
304307
else:
305308
await sender.send(msg)
306309

310+
def __str__(self) -> str:
311+
"""Return a string representation of the formula.
312+
313+
Returns:
314+
A string representation of the formula.
315+
"""
316+
steps = (
317+
self._builder._build_stack
318+
if len(self._builder._build_stack) > 0
319+
else self._builder._steps
320+
)
321+
return format_formula(steps)
322+
307323
def new_receiver(
308324
self, name: str | None = None, max_size: int = 50
309325
) -> Receiver[Sample[QuantityT]]:
@@ -619,6 +635,15 @@ def finalize(
619635

620636
return self._steps, self._metric_fetchers
621637

638+
def __str__(self) -> str:
639+
"""Return a string representation of the formula.
640+
641+
Returns:
642+
A string representation of the formula.
643+
"""
644+
steps = self._steps if len(self._steps) > 0 else self._build_stack
645+
return format_formula(steps)
646+
622647
def build(self) -> FormulaEngine[QuantityT]:
623648
"""Create a formula engine with the steps and fetchers that have been pushed.
624649
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
# License: MIT
2+
# Copyright © 2023 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Formatter for the formula."""
5+
6+
from __future__ import annotations
7+
8+
import enum
9+
10+
from ._formula_steps import (
11+
Adder,
12+
Averager,
13+
Clipper,
14+
ConstantValue,
15+
Divider,
16+
FormulaStep,
17+
Maximizer,
18+
MetricFetcher,
19+
Minimizer,
20+
Multiplier,
21+
OpenParen,
22+
Subtractor,
23+
)
24+
25+
26+
class OperatorPrecedence(enum.Enum):
27+
"""The precedence of an operator."""
28+
29+
ADDITION = 1
30+
SUBTRACTION = 1
31+
MULTIPLICATION = 2
32+
DIVISION = 2
33+
PRIMARY = 9
34+
35+
def __lt__(self, other: OperatorPrecedence) -> bool:
36+
"""Test the precedence of this operator is less than the precedence of the other operator.
37+
38+
Args:
39+
other: The other operator (on the right-hand side).
40+
41+
Returns:
42+
Whether the precedence of this operator is less than the other operator.
43+
"""
44+
return self.value < other.value
45+
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+
83+
def __str__(self) -> str:
84+
"""Return the string representation of the operator precedence.
85+
86+
Returns:
87+
The string representation of the operator precedence.
88+
"""
89+
return str(self.value)
90+
91+
92+
class StackItem:
93+
"""Stack item for the formula formatter."""
94+
95+
def __init__(self, value: str, precedence: OperatorPrecedence, num_steps: int):
96+
"""Initialize the StackItem.
97+
98+
Args:
99+
value: The value of the stack item.
100+
precedence: The precedence of the stack item.
101+
num_steps: The number of steps of the stack item.
102+
"""
103+
self.value = value
104+
self.precedence = precedence
105+
self.num_steps = num_steps
106+
107+
def __str__(self) -> str:
108+
"""Return the string representation of the stack item.
109+
110+
This is used for debugging purposes.
111+
112+
Returns:
113+
str: The string representation of the stack item.
114+
"""
115+
return f'("{self.value}", {self.precedence}, {self.num_steps})'
116+
117+
def as_left_value(self, outer_precedence: OperatorPrecedence) -> str:
118+
"""Return the value of the stack item with parentheses if necessary.
119+
120+
Args:
121+
outer_precedence: The precedence of the outer stack item.
122+
123+
Returns:
124+
str: The value of the stack item with parentheses if necessary.
125+
"""
126+
return f"({self.value})" if self.precedence < outer_precedence else self.value
127+
128+
def as_right_value(self, outer_precedence: OperatorPrecedence) -> str:
129+
"""Return the value of the stack item with parentheses if necessary.
130+
131+
Args:
132+
outer_precedence: The precedence of the outer stack item.
133+
134+
Returns:
135+
str: The value of the stack item with parentheses if necessary.
136+
"""
137+
if self.num_steps > 1:
138+
return (
139+
f"({self.value})" if self.precedence <= outer_precedence else self.value
140+
)
141+
return f"({self.value})" if self.precedence < outer_precedence else self.value
142+
143+
@staticmethod
144+
def create_binary(lhs: StackItem, operator: Operator, rhs: StackItem) -> StackItem:
145+
"""Create a binary stack item.
146+
147+
Args:
148+
lhs: The left-hand side of the binary operation.
149+
operator: The operator of the binary operation.
150+
rhs: The right-hand side of the binary operation.
151+
152+
Returns:
153+
StackItem: The binary stack item.
154+
"""
155+
pred = OperatorPrecedence(operator.precedence)
156+
return StackItem(
157+
f"{lhs.as_left_value(pred)} {operator} {rhs.as_right_value(pred)}",
158+
pred,
159+
lhs.num_steps + 1 + rhs.num_steps,
160+
)
161+
162+
@staticmethod
163+
def create_primary(value: float) -> StackItem:
164+
"""Create a stack item for literal values or function calls (primary expressions).
165+
166+
Args:
167+
value: The value of the literal.
168+
169+
Returns:
170+
StackItem: The literal stack item.
171+
"""
172+
return StackItem(str(value), OperatorPrecedence.PRIMARY, 1)
173+
174+
175+
class FormulaFormatter:
176+
"""Formats a formula into a human readable string in infix-notation."""
177+
178+
def __init__(self) -> None:
179+
"""Initialize the FormulaFormatter."""
180+
self._stack = list[StackItem]()
181+
182+
def format(self, postfix_expr: list[FormulaStep]) -> str:
183+
"""Format the postfix expression to infix notation.
184+
185+
Args:
186+
postfix_expr: The steps of the formula in postfix notation order.
187+
188+
Returns:
189+
str: The formula in infix notation.
190+
"""
191+
for step in postfix_expr:
192+
match step:
193+
case ConstantValue():
194+
self._stack.append(StackItem.create_primary(step.value))
195+
case Adder():
196+
self._format_binary(Operator.ADDITION)
197+
case Subtractor():
198+
self._format_binary(Operator.SUBTRACTION)
199+
case Multiplier():
200+
self._format_binary(Operator.MULTIPLICATION)
201+
case Divider():
202+
self._format_binary(Operator.DIVISION)
203+
case Averager():
204+
value = (
205+
# pylint: disable=protected-access
206+
f"avg({', '.join(self.format([f]) for f in step.fetchers)})"
207+
)
208+
self._stack.append(StackItem(value, OperatorPrecedence.PRIMARY, 1))
209+
case Clipper():
210+
the_value = self._stack.pop()
211+
min_value = step.min_value if step.min_value is not None else "-inf"
212+
max_value = step.max_value if step.max_value is not None else "inf"
213+
value = f"clip({min_value}, {the_value.value}, {max_value})"
214+
self._stack.append(StackItem(value, OperatorPrecedence.PRIMARY, 1))
215+
case Maximizer():
216+
left, right = self._pop_two_from_stack()
217+
value = f"max({left.value}, {right.value})"
218+
self._stack.append(StackItem(value, OperatorPrecedence.PRIMARY, 1))
219+
case Minimizer():
220+
left, right = self._pop_two_from_stack()
221+
value = f"min({left.value}, {right.value})"
222+
self._stack.append(StackItem(value, OperatorPrecedence.PRIMARY, 1))
223+
case MetricFetcher():
224+
metric_fetcher = step
225+
value = metric_fetcher._name # pylint: disable=protected-access
226+
if engine_reference := getattr(
227+
metric_fetcher.stream, "_engine_reference", None
228+
):
229+
value = f"[{value}]({str(engine_reference)})"
230+
self._stack.append(StackItem(value, OperatorPrecedence.PRIMARY, 1))
231+
case OpenParen():
232+
pass # We gently ignore this one.
233+
234+
assert (
235+
len(self._stack) == 1
236+
), f"The formula {postfix_expr} is not valid. Evaluation stack left-over: {self._stack}"
237+
return self._stack[0].value
238+
239+
def _format_binary(self, operator: Operator) -> None:
240+
"""Format a binary operation.
241+
242+
Pops the arguments of the binary expression from the stack
243+
and pushes the string representation of the binary operation to the stack.
244+
245+
Args:
246+
operator: The operator of the binary operation.
247+
"""
248+
left, right = self._pop_two_from_stack()
249+
self._stack.append(StackItem.create_binary(left, operator, right))
250+
251+
def _pop_two_from_stack(self) -> tuple[StackItem, StackItem]:
252+
"""Pop two items from the stack.
253+
254+
Returns:
255+
The two items popped from the stack.
256+
"""
257+
right = self._stack.pop()
258+
left = self._stack.pop()
259+
return left, right
260+
261+
262+
def format_formula(postfix_expr: list[FormulaStep]) -> str:
263+
"""Return the formula as a string in infix notation.
264+
265+
Args:
266+
postfix_expr: The steps of the formula in postfix notation order.
267+
268+
Returns:
269+
str: The formula in infix notation.
270+
"""
271+
formatter = FormulaFormatter()
272+
return formatter.format(postfix_expr)

0 commit comments

Comments
 (0)