Skip to content

Commit 77fbc9b

Browse files
Add pretty-printing FormulaFormatter
Signed-off-by: Christian Parpart <[email protected]>
1 parent 01b8d9d commit 77fbc9b

File tree

5 files changed

+446
-1
lines changed

5 files changed

+446
-1
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
"""A formula engine for applying formulas."""
55
from ._formula_engine import FormulaEngine, FormulaEngine3Phase
66
from ._formula_engine_pool import FormulaEnginePool
7+
from ._formula_formatter import FormulaFormatter
78
from ._resampled_formula_builder import ResampledFormulaBuilder
89

910
__all__ = [
1011
"FormulaEngine",
1112
"FormulaEngine3Phase",
1213
"FormulaEnginePool",
14+
"FormulaFormatter",
1315
"ResampledFormulaBuilder",
1416
]

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
@@ -28,6 +30,7 @@
2830
from .. import Sample, Sample3Phase
2931
from .._quantities import Quantity, QuantityT
3032
from ._formula_evaluator import FormulaEvaluator
33+
from ._formula_formatter import FormulaFormatter
3134
from ._formula_steps import (
3235
Adder,
3336
Averager,
@@ -314,6 +317,19 @@ async def _run(self) -> None:
314317
else:
315318
await sender.send(msg)
316319

320+
def __str__(self) -> str:
321+
"""Return a string representation of the formula.
322+
323+
Returns:
324+
A string representation of the formula.
325+
"""
326+
steps = (
327+
self._builder._build_stack
328+
if len(self._builder._build_stack) > 0
329+
else self._builder._steps
330+
)
331+
return FormulaFormatter.format(steps)
332+
317333
def new_receiver(
318334
self, name: Optional[str] = None, max_size: int = 50
319335
) -> Receiver[Sample[QuantityT]]:
@@ -629,6 +645,15 @@ def finalize(
629645

630646
return self._steps, self._metric_fetchers
631647

648+
def __str__(self) -> str:
649+
"""Return a string representation of the formula.
650+
651+
Returns:
652+
A string representation of the formula.
653+
"""
654+
steps = self._steps if len(self._steps) > 0 else self._build_stack
655+
return FormulaFormatter.format(steps)
656+
632657
def build(self) -> FormulaEngine[QuantityT]:
633658
"""Create a formula engine with the steps and fetchers that have been pushed.
634659
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
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+
SUBTRACTION = 1
30+
ADDITION = 2
31+
DIVISION = 3
32+
MULTIPLICATION = 4
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 __str__(self) -> str:
47+
"""Return the string representation of the operator precedence.
48+
49+
Returns:
50+
The string representation of the operator precedence.
51+
"""
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"
63+
64+
65+
class StackItem:
66+
"""Stack item for the formula formatter."""
67+
68+
def __init__(self, value: str, precedence: OperatorPrecedence):
69+
"""Initialize the StackItem.
70+
71+
Args:
72+
value: The value of the stack item.
73+
precedence: The precedence of the stack item.
74+
"""
75+
self.value = value
76+
self.precedence = precedence
77+
78+
def __str__(self) -> str:
79+
"""Return the string representation of the stack item.
80+
81+
This is used for debugging purposes.
82+
83+
Returns:
84+
str: The string representation of the stack item.
85+
"""
86+
return f'("{self.value}", {self.precedence})'
87+
88+
def as_left_value(self, outer_precedence: OperatorPrecedence) -> str:
89+
"""Return the value of the stack item with parentheses if necessary.
90+
91+
Args:
92+
outer_precedence: The precedence of the outer stack item.
93+
94+
Returns:
95+
str: The value of the stack item with parentheses if necessary.
96+
"""
97+
return f"({self.value})" if self.precedence < outer_precedence else self.value
98+
99+
def as_right_value(self, outer_precedence: OperatorPrecedence) -> str:
100+
"""Return the value of the stack item with parentheses if necessary.
101+
102+
Args:
103+
outer_precedence: The precedence of the outer stack item.
104+
105+
Returns:
106+
str: The value of the stack item with parentheses if necessary.
107+
"""
108+
return f"({self.value})" if self.precedence < outer_precedence else self.value
109+
110+
@staticmethod
111+
def create_binary(
112+
lhs: StackItem, operator: OperatorPrecedence, rhs: StackItem
113+
) -> StackItem:
114+
"""Create a binary stack item.
115+
116+
Args:
117+
lhs: The left-hand side of the binary operation.
118+
operator: The operator of the binary operation.
119+
rhs: The right-hand side of the binary operation.
120+
121+
Returns:
122+
StackItem: The binary stack item.
123+
"""
124+
pred = OperatorPrecedence(operator)
125+
return StackItem(
126+
f"{lhs.as_left_value(pred)} {operator} {rhs.as_right_value(pred)}",
127+
pred,
128+
)
129+
130+
@staticmethod
131+
def create_primary(value: float) -> StackItem:
132+
"""Create a stack item for literal values or function calls (primary expressions).
133+
134+
Args:
135+
value: The value of the literal.
136+
137+
Returns:
138+
StackItem: The literal stack item.
139+
"""
140+
return StackItem(str(value), OperatorPrecedence.PRIMARY)
141+
142+
143+
class FormulaFormatter:
144+
"""Formats a formula into a human readable string in infix-notation."""
145+
146+
def __init__(self) -> None:
147+
"""Initialize the FormulaFormatter."""
148+
self._stack = list[StackItem]()
149+
150+
@classmethod
151+
def format(cls, postfix_expr: list[FormulaStep]) -> str:
152+
"""Return the formula as a string in infix notation.
153+
154+
Args:
155+
postfix_expr: The steps of the formula in postfix notation order.
156+
157+
Returns:
158+
str: The formula in infix notation.
159+
"""
160+
formatter = FormulaFormatter()
161+
return formatter._format(postfix_expr)
162+
163+
def _format(self, postfix_expr: list[FormulaStep]) -> str:
164+
"""Format the postfix expression to infix notation.
165+
166+
Args:
167+
postfix_expr: The steps of the formula in postfix notation order.
168+
169+
Returns:
170+
str: The formula in infix notation.
171+
"""
172+
for step in postfix_expr:
173+
match step:
174+
case ConstantValue():
175+
self._stack.append(StackItem.create_primary(step.value))
176+
case Adder():
177+
self._format_binary(OperatorPrecedence.ADDITION)
178+
case Subtractor():
179+
self._format_binary(OperatorPrecedence.SUBTRACTION)
180+
case Multiplier():
181+
self._format_binary(OperatorPrecedence.MULTIPLICATION)
182+
case Divider():
183+
self._format_binary(OperatorPrecedence.DIVISION)
184+
case Averager():
185+
value = (
186+
# pylint: disable=protected-access
187+
f"avg({', '.join(self._format([f]) for f in step.fetchers)})"
188+
)
189+
self._stack.append(StackItem(value, OperatorPrecedence.PRIMARY))
190+
case Clipper():
191+
the_value = self._stack.pop()
192+
min_value = step.min_value if step.min_value is not None else "-inf"
193+
max_value = step.max_value if step.max_value is not None else "inf"
194+
value = f"clip({min_value}, {the_value.value}, {max_value})"
195+
self._stack.append(StackItem(value, OperatorPrecedence.PRIMARY))
196+
case Maximizer():
197+
left, right = self._pop_two_from_stack()
198+
value = f"max({left.value}, {right.value})"
199+
self._stack.append(StackItem(value, OperatorPrecedence.PRIMARY))
200+
case Minimizer():
201+
left, right = self._pop_two_from_stack()
202+
value = f"min({left.value}, {right.value})"
203+
self._stack.append(StackItem(value, OperatorPrecedence.PRIMARY))
204+
case MetricFetcher():
205+
metric_fetcher = step
206+
if engine_reference := getattr(
207+
metric_fetcher.stream, "_engine_reference", None
208+
):
209+
value = str(engine_reference)
210+
else:
211+
value = metric_fetcher._name # pylint: disable=protected-access
212+
self._stack.append(StackItem(value, OperatorPrecedence.PRIMARY))
213+
case OpenParen():
214+
pass # We gently ignore this one.
215+
216+
assert len(self._stack) == 1
217+
return self._stack[0].value
218+
219+
def _format_binary(self, operator: OperatorPrecedence) -> None:
220+
"""Format a binary operation.
221+
222+
Pops the arguments of the binary expression from the stack
223+
and pushes the string representation of the binary operation to the stack.
224+
225+
Args:
226+
operator: The operator of the binary operation.
227+
"""
228+
left, right = self._pop_two_from_stack()
229+
self._stack.append(StackItem.create_binary(left, operator, right))
230+
231+
def _pop_two_from_stack(self) -> tuple[StackItem, StackItem]:
232+
"""Pop two items from the stack.
233+
234+
Returns:
235+
The two items popped from the stack.
236+
"""
237+
right = self._stack.pop()
238+
left = self._stack.pop()
239+
return left, right

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

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import math
99
from abc import ABC, abstractmethod
10-
from typing import Generic, List, Optional
10+
from typing import Generic, List, Optional, Sequence
1111

1212
from frequenz.channels import Receiver
1313

@@ -207,6 +207,15 @@ def __init__(self, fetchers: List[MetricFetcher[QuantityT]]) -> None:
207207
"""
208208
self._fetchers: list[MetricFetcher[QuantityT]] = fetchers
209209

210+
@property
211+
def fetchers(self) -> Sequence[MetricFetcher[QuantityT]]:
212+
"""Return the metric fetchers.
213+
214+
Returns:
215+
The metric fetchers.
216+
"""
217+
return self._fetchers
218+
210219
def __repr__(self) -> str:
211220
"""Return a string representation of the step.
212221
@@ -255,6 +264,15 @@ def __init__(self, value: float) -> None:
255264
"""
256265
self._value = value
257266

267+
@property
268+
def value(self) -> float:
269+
"""Return the constant value.
270+
271+
Returns:
272+
The constant value.
273+
"""
274+
return self._value
275+
258276
def __repr__(self) -> str:
259277
"""Return a string representation of the step.
260278
@@ -285,6 +303,24 @@ def __init__(self, min_val: float | None, max_val: float | None) -> None:
285303
self._min_val = min_val
286304
self._max_val = max_val
287305

306+
@property
307+
def min_value(self) -> float | None:
308+
"""Return the minimum value.
309+
310+
Returns:
311+
The minimum value.
312+
"""
313+
return self._min_val
314+
315+
@property
316+
def max_value(self) -> float | None:
317+
"""Return the maximum value.
318+
319+
Returns:
320+
The maximum value.
321+
"""
322+
return self._max_val
323+
288324
def __repr__(self) -> str:
289325
"""Return a string representation of the step.
290326
@@ -329,6 +365,23 @@ def __init__(
329365
self._next_value: Optional[Sample[QuantityT]] = None
330366
self._nones_are_zeros = nones_are_zeros
331367

368+
@property
369+
def stream(self) -> Receiver[Sample[QuantityT]]:
370+
"""Return the stream from which to fetch values.
371+
372+
Returns:
373+
The stream from which to fetch values.
374+
"""
375+
return self._stream
376+
377+
def stream_name(self) -> str:
378+
"""Return the name of the stream.
379+
380+
Returns:
381+
The name of the stream.
382+
"""
383+
return str(self._stream.__doc__)
384+
332385
async def fetch_next(self) -> Optional[Sample[QuantityT]]:
333386
"""Fetch the next value from the stream.
334387

0 commit comments

Comments
 (0)