Skip to content

Commit 5d1f7d7

Browse files
Add min and max operations to formula engine (#561)
2 parents 5b971b0 + 7445465 commit 5d1f7d7

File tree

4 files changed

+206
-12
lines changed

4 files changed

+206
-12
lines changed

RELEASE_NOTES.md

+6
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ This release replaces the `@actor` decorator with a new `Actor` class.
102102

103103
- `Actor`: This new class inherits from `BackgroundService` and it replaces the `@actor` decorator.
104104

105+
- Newly added `min` and `max` functions for Formulas. They can be used as follows:
106+
107+
```python
108+
formula1.min(formula2)
109+
```
110+
105111
## Bug Fixes
106112

107113
- Fixes a bug in the ring buffer updating the end timestamp of gaps when they are outdated.

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

+109-12
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@
3535
ConstantValue,
3636
Divider,
3737
FormulaStep,
38+
Maximizer,
3839
MetricFetcher,
40+
Minimizer,
3941
Multiplier,
4042
OpenParen,
4143
Subtractor,
@@ -45,12 +47,14 @@
4547
_logger = logging.Logger(__name__)
4648

4749
_operator_precedence = {
48-
"(": 0,
49-
"/": 1,
50-
"*": 2,
51-
"-": 3,
52-
"+": 4,
53-
")": 5,
50+
"max": 0,
51+
"min": 1,
52+
"(": 2,
53+
"/": 3,
54+
"*": 4,
55+
"-": 5,
56+
"+": 6,
57+
")": 7,
5458
}
5559

5660

@@ -168,6 +172,36 @@ def __truediv__(
168172
"""
169173
return self._higher_order_builder(self, self._create_method) / other # type: ignore
170174

175+
def _max(
176+
self, other: _GenericEngine | _GenericHigherOrderBuilder | QuantityT
177+
) -> _GenericHigherOrderBuilder:
178+
"""Return a formula engine that outputs the maximum of `self` and `other`.
179+
180+
Args:
181+
other: A formula receiver, a formula builder or a QuantityT instance
182+
corresponding to a sub-expression.
183+
184+
Returns:
185+
A formula builder that can take further expressions, or can be built
186+
into a formula engine.
187+
"""
188+
return self._higher_order_builder(self, self._create_method).max(other) # type: ignore
189+
190+
def _min(
191+
self, other: _GenericEngine | _GenericHigherOrderBuilder | QuantityT
192+
) -> _GenericHigherOrderBuilder:
193+
"""Return a formula engine that outputs the minimum of `self` and `other`.
194+
195+
Args:
196+
other: A formula receiver, a formula builder or a QuantityT instance
197+
corresponding to a sub-expression.
198+
199+
Returns:
200+
A formula builder that can take further expressions, or can be built
201+
into a formula engine.
202+
"""
203+
return self._higher_order_builder(self, self._create_method).min(other) # type: ignore
204+
171205

172206
class FormulaEngine(
173207
Generic[QuantityT],
@@ -467,6 +501,10 @@ def push_oper(self, oper: str) -> None:
467501
self._build_stack.append(Divider())
468502
elif oper == "(":
469503
self._build_stack.append(OpenParen())
504+
elif oper == "max":
505+
self._build_stack.append(Maximizer())
506+
elif oper == "min":
507+
self._build_stack.append(Minimizer())
470508

471509
def push_metric(
472510
self,
@@ -653,15 +691,15 @@ def _push(
653691
self._steps.append((TokenType.OPER, ")"))
654692
self._steps.append((TokenType.OPER, oper))
655693

656-
# pylint: disable=protected-access
657694
if isinstance(other, (FormulaEngine, FormulaEngine3Phase)):
658695
self._steps.append((TokenType.COMPONENT_METRIC, other))
659-
elif isinstance(other, (Quantity, float)):
696+
elif isinstance(other, (Quantity, float, int)):
660697
match oper:
661-
case "+" | "-":
698+
case "+" | "-" | "max" | "min":
662699
if not isinstance(other, Quantity):
663700
raise RuntimeError(
664-
f"A Quantity must be provided for addition or subtraction to {other}"
701+
"A Quantity must be provided for addition,"
702+
f" subtraction, min or max to {other}"
665703
)
666704
case "*" | "/":
667705
if not isinstance(other, (float, int)):
@@ -671,9 +709,8 @@ def _push(
671709
self._steps.append((TokenType.CONSTANT, other))
672710
elif isinstance(other, _BaseHOFormulaBuilder):
673711
self._steps.append((TokenType.OPER, "("))
674-
self._steps.extend(other._steps)
712+
self._steps.extend(other._steps) # pylint: disable=protected-access
675713
self._steps.append((TokenType.OPER, ")"))
676-
# pylint: enable=protected-access
677714
else:
678715
raise RuntimeError(f"Can't build a formula from: {other}")
679716
assert isinstance(
@@ -804,6 +841,66 @@ def __truediv__(
804841
"""
805842
return self._push("/", other)
806843

844+
@overload
845+
def max(
846+
self, other: _CompositionType1Phase
847+
) -> HigherOrderFormulaBuilder[QuantityT]:
848+
...
849+
850+
@overload
851+
def max(
852+
self, other: _CompositionType3Phase | QuantityT
853+
) -> HigherOrderFormulaBuilder3Phase[QuantityT]:
854+
...
855+
856+
def max(
857+
self, other: _CompositionType | QuantityT
858+
) -> (
859+
HigherOrderFormulaBuilder[QuantityT]
860+
| HigherOrderFormulaBuilder3Phase[QuantityT]
861+
):
862+
"""Return a formula builder that calculates the maximum of `self` and `other`.
863+
864+
Args:
865+
other: A formula receiver, or a formula builder instance corresponding to a
866+
sub-expression.
867+
868+
Returns:
869+
A formula builder that can take further expressions, or can be built
870+
into a formula engine.
871+
"""
872+
return self._push("max", other)
873+
874+
@overload
875+
def min(
876+
self, other: _CompositionType1Phase
877+
) -> HigherOrderFormulaBuilder[QuantityT]:
878+
...
879+
880+
@overload
881+
def min(
882+
self, other: _CompositionType3Phase | QuantityT
883+
) -> HigherOrderFormulaBuilder3Phase[QuantityT]:
884+
...
885+
886+
def min(
887+
self, other: _CompositionType | QuantityT
888+
) -> (
889+
HigherOrderFormulaBuilder[QuantityT]
890+
| HigherOrderFormulaBuilder3Phase[QuantityT]
891+
):
892+
"""Return a formula builder that calculates the minimum of `self` and `other`.
893+
894+
Args:
895+
other: A formula receiver, or a formula builder instance corresponding to a
896+
sub-expression.
897+
898+
Returns:
899+
A formula builder that can take further expressions, or can be built
900+
into a formula engine.
901+
"""
902+
return self._push("min", other)
903+
807904

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

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

+46
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,52 @@ def apply(self, eval_stack: List[float]) -> None:
132132
eval_stack.append(res)
133133

134134

135+
class Maximizer(FormulaStep):
136+
"""A formula step that represents the max function."""
137+
138+
def __repr__(self) -> str:
139+
"""Return a string representation of the step.
140+
141+
Returns:
142+
A string representation of the step.
143+
"""
144+
return "max"
145+
146+
def apply(self, eval_stack: List[float]) -> None:
147+
"""Extract two values from the stack and pushes back the maximum.
148+
149+
Args:
150+
eval_stack: An evaluation stack, to apply the formula step on.
151+
"""
152+
val2 = eval_stack.pop()
153+
val1 = eval_stack.pop()
154+
res = max(val1, val2)
155+
eval_stack.append(res)
156+
157+
158+
class Minimizer(FormulaStep):
159+
"""A formula step that represents the min function."""
160+
161+
def __repr__(self) -> str:
162+
"""Return a string representation of the step.
163+
164+
Returns:
165+
A string representation of the step.
166+
"""
167+
return "min"
168+
169+
def apply(self, eval_stack: List[float]) -> None:
170+
"""Extract two values from the stack and pushes back the minimum.
171+
172+
Args:
173+
eval_stack: An evaluation stack, to apply the formula step on.
174+
"""
175+
val2 = eval_stack.pop()
176+
val1 = eval_stack.pop()
177+
res = min(val1, val2)
178+
eval_stack.append(res)
179+
180+
135181
class OpenParen(FormulaStep):
136182
"""A no-op formula step used while building a prefix formula engine.
137183

tests/timeseries/_formula_engine/test_formula_composition.py

+45
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,51 @@ async def test_formula_composition_missing_bat(self, mocker: MockerFixture) -> N
170170

171171
assert count == 10
172172

173+
async def test_formula_composition_min_max(self, mocker: MockerFixture) -> None:
174+
"""Test the composition of formulas with min/max values."""
175+
mockgrid = MockMicrogrid(grid_meter=True)
176+
await mockgrid.start(mocker)
177+
178+
logical_meter = microgrid.logical_meter()
179+
engine_min = logical_meter.grid_power._min( # pylint: disable=protected-access
180+
Power.zero()
181+
).build("grid_power_min")
182+
engine_min_rx = engine_min.new_receiver()
183+
engine_max = logical_meter.grid_power._max( # pylint: disable=protected-access
184+
Power.zero()
185+
).build("grid_power_max")
186+
engine_max_rx = engine_max.new_receiver()
187+
188+
await mockgrid.mock_resampler.send_meter_power([100.0])
189+
190+
# Test min
191+
min_pow = await engine_min_rx.receive()
192+
assert min_pow and min_pow.value and min_pow.value.isclose(Power.zero())
193+
194+
# Test max
195+
max_pow = await engine_max_rx.receive()
196+
assert (
197+
max_pow and max_pow.value and max_pow.value.isclose(Power.from_watts(100.0))
198+
)
199+
200+
await mockgrid.mock_resampler.send_meter_power([-100.0])
201+
202+
# Test min
203+
min_pow = await engine_min_rx.receive()
204+
assert (
205+
min_pow
206+
and min_pow.value
207+
and min_pow.value.isclose(Power.from_watts(-100.0))
208+
)
209+
210+
# Test max
211+
max_pow = await engine_max_rx.receive()
212+
assert max_pow and max_pow.value and max_pow.value.isclose(Power.zero())
213+
214+
await engine_min._stop() # pylint: disable=protected-access
215+
await mockgrid.cleanup()
216+
await logical_meter.stop()
217+
173218
async def test_formula_composition_constant(self, mocker: MockerFixture) -> None:
174219
"""Test the composition of formulas with constant values."""
175220
mockgrid = MockMicrogrid(grid_meter=True)

0 commit comments

Comments
 (0)