Skip to content

Commit 2c8d7df

Browse files
Add consumtion and production operations to Formula Engine (#746)
In order to simplify usability but don't loose functionality we decided to remove the consumption and production power formulas, i.e. the formulas that are having a production or consumption attached to their name, and replace them with operators that can be called on a formula engine. The consumption operator returns either the identity if the power value is positive or 0 and the production operator returns either the identity if the power value is negative or 0. As an example we can now create the following formula: ```python grid_consumption = logical_meter.grid_power().consumption().build() ``` This would return the consumption part of the grid_power stream. Removing the old consumption and production power formulas will happen in the follow up PR #697.
2 parents 862ebe4 + 0d1b06f commit 2c8d7df

File tree

4 files changed

+242
-7
lines changed

4 files changed

+242
-7
lines changed

RELEASE_NOTES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ This version ships an experimental version of the **Power Manager**, adds prelim
6262

6363
- Allow configuration of the `resend_latest` flag in channels owned by the `ChannelRegistry`.
6464

65+
- Add consumption and production operators that will replace the logical meters production and consumption function variants.
66+
6567
## Bug Fixes
6668

6769
- Fix rendering of diagrams in the documentation.

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

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@
2525
Adder,
2626
Clipper,
2727
ConstantValue,
28+
Consumption,
2829
Divider,
2930
FormulaStep,
3031
Maximizer,
3132
MetricFetcher,
3233
Minimizer,
3334
Multiplier,
3435
OpenParen,
36+
Production,
3537
Subtractor,
3638
)
3739
from ._tokenizer import TokenType
@@ -41,12 +43,14 @@
4143
_operator_precedence = {
4244
"max": 0,
4345
"min": 1,
44-
"(": 2,
45-
"/": 3,
46-
"*": 4,
47-
"-": 5,
48-
"+": 6,
49-
")": 7,
46+
"consumption": 2,
47+
"production": 3,
48+
"(": 4,
49+
"/": 5,
50+
"*": 6,
51+
"-": 7,
52+
"+": 8,
53+
")": 9,
5054
}
5155
"""The dictionary of operator precedence for the shunting yard algorithm."""
5256

@@ -188,12 +192,31 @@ def min(
188192
other: A formula receiver, a formula builder or a QuantityT instance
189193
corresponding to a sub-expression.
190194
195+
191196
Returns:
192197
A formula builder that can take further expressions, or can be built
193198
into a formula engine.
194199
"""
195200
return self._higher_order_builder(self, self._create_method).min(other) # type: ignore
196201

202+
def consumption(self) -> _GenericHigherOrderBuilder:
203+
"""
204+
Return a formula builder that applies the consumption operator on `self`.
205+
206+
The consumption operator returns either the identity if the power value is
207+
positive or 0.
208+
"""
209+
return self._higher_order_builder(self, self._create_method).consumption() # type: ignore
210+
211+
def production(self) -> _GenericHigherOrderBuilder:
212+
"""
213+
Return a formula builder that applies the production operator on `self`.
214+
215+
The production operator returns either the absolute value if the power value is
216+
negative or 0.
217+
"""
218+
return self._higher_order_builder(self, self._create_method).production() # type: ignore
219+
197220

198221
class FormulaEngine(
199222
Generic[QuantityT],
@@ -574,7 +597,7 @@ def __init__(self, name: str, create_method: Callable[[float], QuantityT]) -> No
574597
self._steps: list[FormulaStep] = []
575598
self._metric_fetchers: dict[str, MetricFetcher[QuantityT]] = {}
576599

577-
def push_oper(self, oper: str) -> None:
600+
def push_oper(self, oper: str) -> None: # pylint: disable=too-many-branches
578601
"""Push an operator into the engine.
579602
580603
Args:
@@ -608,6 +631,10 @@ def push_oper(self, oper: str) -> None:
608631
self._build_stack.append(Maximizer())
609632
elif oper == "min":
610633
self._build_stack.append(Minimizer())
634+
elif oper == "consumption":
635+
self._build_stack.append(Consumption())
636+
elif oper == "production":
637+
self._build_stack.append(Production())
611638

612639
def push_metric(
613640
self,
@@ -996,6 +1023,52 @@ def min(
9961023
"""
9971024
return self._push("min", other)
9981025

1026+
def consumption(
1027+
self,
1028+
) -> (
1029+
HigherOrderFormulaBuilder[QuantityT]
1030+
| HigherOrderFormulaBuilder3Phase[QuantityT]
1031+
):
1032+
"""Apply the Consumption Operator.
1033+
1034+
The consumption operator returns either the identity if the power value is
1035+
positive or 0.
1036+
1037+
Returns:
1038+
A formula builder that can take further expressions, or can be built
1039+
into a formula engine.
1040+
"""
1041+
self._steps.appendleft((TokenType.OPER, "("))
1042+
self._steps.append((TokenType.OPER, ")"))
1043+
self._steps.append((TokenType.OPER, "consumption"))
1044+
assert isinstance(
1045+
self, (HigherOrderFormulaBuilder, HigherOrderFormulaBuilder3Phase)
1046+
)
1047+
return self
1048+
1049+
def production(
1050+
self,
1051+
) -> (
1052+
HigherOrderFormulaBuilder[QuantityT]
1053+
| HigherOrderFormulaBuilder3Phase[QuantityT]
1054+
):
1055+
"""Apply the Production Operator.
1056+
1057+
The production operator returns either the absolute value if the power value is
1058+
negative or 0.
1059+
1060+
Returns:
1061+
A formula builder that can take further expressions, or can be built
1062+
into a formula engine.
1063+
"""
1064+
self._steps.appendleft((TokenType.OPER, "("))
1065+
self._steps.append((TokenType.OPER, ")"))
1066+
self._steps.append((TokenType.OPER, "production"))
1067+
assert isinstance(
1068+
self, (HigherOrderFormulaBuilder, HigherOrderFormulaBuilder3Phase)
1069+
)
1070+
return self
1071+
9991072

10001073
class HigherOrderFormulaBuilder(Generic[QuantityT], _BaseHOFormulaBuilder[QuantityT]):
10011074
"""A specialization of the _BaseHOFormulaBuilder for `FormulaReceiver`."""

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,64 @@ def apply(self, eval_stack: list[float]) -> None:
177177
eval_stack.append(res)
178178

179179

180+
class Consumption(FormulaStep):
181+
"""A formula step that represents the consumption operator.
182+
183+
The consumption operator is the maximum of the value on top
184+
of the evaluation stack and 0.
185+
"""
186+
187+
def __repr__(self) -> str:
188+
"""Return a string representation of the step.
189+
190+
Returns:
191+
A string representation of the step.
192+
"""
193+
return "consumption"
194+
195+
def apply(self, eval_stack: list[float]) -> None:
196+
"""
197+
Apply the consumption formula.
198+
199+
Replace the top of the eval eval_stack with the same value if the value
200+
is positive or 0.
201+
202+
Args:
203+
eval_stack: An evaluation stack, to apply the formula step on.
204+
"""
205+
val = eval_stack.pop()
206+
eval_stack.append(max(val, 0))
207+
208+
209+
class Production(FormulaStep):
210+
"""A formula step that represents the production operator.
211+
212+
The production operator is the maximum of the value times minus one on top
213+
of the evaluation stack and 0.
214+
"""
215+
216+
def __repr__(self) -> str:
217+
"""Return a string representation of the step.
218+
219+
Returns:
220+
A string representation of the step.
221+
"""
222+
return "production"
223+
224+
def apply(self, eval_stack: list[float]) -> None:
225+
"""
226+
Apply the production formula.
227+
228+
Replace the top of the eval eval_stack with its absolute value if the
229+
value is negative or 0.
230+
231+
Args:
232+
eval_stack: An evaluation stack, to apply the formula step on.
233+
"""
234+
val = eval_stack.pop()
235+
eval_stack.append(max(-val, 0))
236+
237+
180238
class OpenParen(FormulaStep):
181239
"""A no-op formula step used while building a prefix formula engine.
182240

tests/timeseries/test_formula_engine.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,19 @@ async def run_test( # pylint: disable=too-many-locals
318318
num_items: int,
319319
make_builder: (
320320
Callable[
321+
[
322+
FormulaEngine[Quantity],
323+
],
324+
HigherOrderFormulaBuilder[Quantity],
325+
]
326+
| Callable[
327+
[
328+
FormulaEngine[Quantity],
329+
FormulaEngine[Quantity],
330+
],
331+
HigherOrderFormulaBuilder[Quantity],
332+
]
333+
| Callable[
321334
[
322335
FormulaEngine[Quantity],
323336
FormulaEngine[Quantity],
@@ -586,6 +599,95 @@ async def test_compound(self) -> None:
586599
],
587600
)
588601

602+
async def test_consumption(self) -> None:
603+
"""Test the consumption operator."""
604+
await self.run_test(
605+
1,
606+
lambda c1: c1.consumption(),
607+
[
608+
([10.0], 10.0),
609+
([-10.0], 0.0),
610+
],
611+
)
612+
613+
async def test_production(self) -> None:
614+
"""Test the production operator."""
615+
await self.run_test(
616+
1,
617+
lambda c1: c1.production(),
618+
[
619+
([10.0], 0.0),
620+
([-10.0], 10.0),
621+
],
622+
)
623+
624+
async def test_consumption_production(self) -> None:
625+
"""Test the consumption and production operator combined."""
626+
await self.run_test(
627+
2,
628+
lambda c1, c2: c1.consumption() + c2.production(),
629+
[
630+
([10.0, 12.0], 10.0),
631+
([-12.0, -10.0], 10.0),
632+
],
633+
)
634+
await self.run_test(
635+
2,
636+
lambda c1, c2: c1.consumption() + c2.consumption(),
637+
[
638+
([10.0, -12.0], 10.0),
639+
([-10.0, 12.0], 12.0),
640+
],
641+
)
642+
await self.run_test(
643+
2,
644+
lambda c1, c2: c1.production() + c2.production(),
645+
[
646+
([10.0, -12.0], 12.0),
647+
([-10.0, 12.0], 10.0),
648+
],
649+
)
650+
await self.run_test(
651+
2,
652+
lambda c1, c2: c1.min(c2).consumption(),
653+
[
654+
([10.0, -12.0], 0.0),
655+
([10.0, 12.0], 10.0),
656+
],
657+
)
658+
await self.run_test(
659+
2,
660+
lambda c1, c2: c1.max(c2).consumption(),
661+
[
662+
([10.0, -12.0], 10.0),
663+
([-10.0, -12.0], 0.0),
664+
],
665+
)
666+
await self.run_test(
667+
2,
668+
lambda c1, c2: c1.min(c2).production(),
669+
[
670+
([10.0, -12.0], 12.0),
671+
([10.0, 12.0], 0.0),
672+
],
673+
)
674+
await self.run_test(
675+
2,
676+
lambda c1, c2: c1.max(c2).production(),
677+
[
678+
([10.0, -12.0], 0.0),
679+
([-10.0, -12.0], 10.0),
680+
],
681+
)
682+
await self.run_test(
683+
2,
684+
lambda c1, c2: c1.production() + c2,
685+
[
686+
([10.0, -12.0], -12.0),
687+
([-10.0, -12.0], -2.0),
688+
],
689+
)
690+
589691
async def test_nones_are_zeros(self) -> None:
590692
"""Test that `None`s are treated as zeros when configured."""
591693
await self.run_test(

0 commit comments

Comments
 (0)