From 23e0cd3fbfbb146c3e8495f685477cf52226d6ed Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Thu, 20 Nov 2025 17:55:26 +0100 Subject: [PATCH 01/23] Add AST nodes for the new tree walking Formula implementation Signed-off-by: Sahas Subramanian --- .../sdk/timeseries/formulas/__init__.py | 4 + src/frequenz/sdk/timeseries/formulas/_ast.py | 205 ++++++++++++++++++ .../sdk/timeseries/formulas/_functions.py | 97 +++++++++ 3 files changed, 306 insertions(+) create mode 100644 src/frequenz/sdk/timeseries/formulas/__init__.py create mode 100644 src/frequenz/sdk/timeseries/formulas/_ast.py create mode 100644 src/frequenz/sdk/timeseries/formulas/_functions.py diff --git a/src/frequenz/sdk/timeseries/formulas/__init__.py b/src/frequenz/sdk/timeseries/formulas/__init__.py new file mode 100644 index 000000000..3b9e7a10a --- /dev/null +++ b/src/frequenz/sdk/timeseries/formulas/__init__.py @@ -0,0 +1,4 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Formulas on telemetry streams.""" diff --git a/src/frequenz/sdk/timeseries/formulas/_ast.py b/src/frequenz/sdk/timeseries/formulas/_ast.py new file mode 100644 index 000000000..829532546 --- /dev/null +++ b/src/frequenz/sdk/timeseries/formulas/_ast.py @@ -0,0 +1,205 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Formula AST nodes and evaluation logic.""" + +from __future__ import annotations + +import abc +import logging +import math +from collections.abc import AsyncIterator +from dataclasses import dataclass +from typing import Generic + +from typing_extensions import override + +from ..._internal._math import is_close_to_zero +from .._base_types import QuantityT, Sample +from ._functions import Function + +_logger = logging.getLogger(__name__) + + +@dataclass +class Node(abc.ABC): + """An abstract syntax tree node representing a formula expression.""" + + span: tuple[int, int] | None + + @abc.abstractmethod + def evaluate(self) -> float | None: + """Evaluate the expression and return its numerical value.""" + + @abc.abstractmethod + def format(self, wrap: bool = False) -> str: + """Return a string representation of the node.""" + + @override + def __str__(self) -> str: + """Return the string representation of the node.""" + return self.format() + + +@dataclass +class TelemetryStream(Node, Generic[QuantityT]): + """A AST node that retrieves values from a component's telemetry stream.""" + + source: str + stream: AsyncIterator[Sample[QuantityT]] + _latest_sample: Sample[QuantityT] | None = None + + @property + def latest_sample(self) -> Sample[QuantityT] | None: + """Return the latest fetched sample for this component.""" + return self._latest_sample + + @override + def evaluate(self) -> float | None: + """Return the base value of the latest sample for this component.""" + if self._latest_sample is None: + raise ValueError("Next value has not been fetched yet.") + if self._latest_sample.value is None: + return None + return self._latest_sample.value.base_value + + @override + def format(self, wrap: bool = False) -> str: + """Return a string representation of the telemetry stream node.""" + return f"{self.source}" + + async def fetch_next(self) -> None: + """Fetch the next value for this component and store it internally.""" + self._latest_sample = await anext(self.stream) + + +@dataclass +class FunCall(Node): + """A function call in the formula.""" + + function: Function + args: list[Node] + + @override + def evaluate(self) -> float | None: + """Evaluate the function call with its arguments.""" + return self.function(arg.evaluate() for arg in self.args) + + @override + def format(self, wrap: bool = False) -> str: + """Return a string representation of the function call node.""" + args_str = ", ".join(str(arg) for arg in self.args) + return f"{self.function.name}({args_str})" + + +@dataclass +class Constant(Node): + """A constant numerical value in the formula.""" + + value: float + + @override + def evaluate(self) -> float | None: + """Return the constant value.""" + return self.value + + @override + def format(self, wrap: bool = False) -> str: + """Return a string representation of the constant node.""" + return str(self.value) + + +@dataclass +class Add(Node): + """Addition operation node.""" + + left: Node + right: Node + + @override + def evaluate(self) -> float | None: + """Evaluate the addition of the left and right nodes.""" + left = self.left.evaluate() + right = self.right.evaluate() + if left is None or right is None: + return None + return left + right + + @override + def format(self, wrap: bool = False) -> str: + """Return a string representation of the addition node.""" + expr = f"{self.left} + {self.right}" + if wrap: + expr = f"({expr})" + return expr + + +@dataclass +class Sub(Node): + """Subtraction operation node.""" + + left: Node + right: Node + + @override + def evaluate(self) -> float | None: + """Evaluate the subtraction of the right node from the left node.""" + left = self.left.evaluate() + right = self.right.evaluate() + if left is None or right is None: + return None + return left - right + + @override + def format(self, wrap: bool = False) -> str: + """Return a string representation of the subtraction node.""" + expr = f"{self.left} - {self.right.format(True)}" + if wrap: + expr = f"({expr})" + return expr + + +@dataclass +class Mul(Node): + """Multiplication operation node.""" + + left: Node + right: Node + + @override + def evaluate(self) -> float | None: + """Evaluate the multiplication of the left and right nodes.""" + left = self.left.evaluate() + right = self.right.evaluate() + if left is None or right is None: + return None + return left * right + + @override + def format(self, wrap: bool = False) -> str: + """Return a string representation of the multiplication node.""" + return f"{self.left.format(True)} * {self.right.format(True)}" + + +@dataclass +class Div(Node): + """Division operation node.""" + + left: Node + right: Node + + @override + def evaluate(self) -> float | None: + """Evaluate the division of the left node by the right node.""" + left = self.left.evaluate() + right = self.right.evaluate() + if left is None or right is None: + return None + if is_close_to_zero(right): + return math.nan + return left / right + + @override + def format(self, wrap: bool = False) -> str: + """Return a string representation of the division node.""" + return f"{self.left.format(True)} / {self.right.format(True)}" diff --git a/src/frequenz/sdk/timeseries/formulas/_functions.py b/src/frequenz/sdk/timeseries/formulas/_functions.py new file mode 100644 index 000000000..4ff4e5cee --- /dev/null +++ b/src/frequenz/sdk/timeseries/formulas/_functions.py @@ -0,0 +1,97 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Function implementations for evaluating formulas.""" + +from __future__ import annotations + +import abc +from collections.abc import Iterable + +from typing_extensions import override + + +class Function(abc.ABC): + """A function that can be called in a formula expression.""" + + @property + @abc.abstractmethod + def name(self) -> str: + """Return the name of the function.""" + + @abc.abstractmethod + def __call__(self, args: Iterable[float | None]) -> float | None: + """Call the function with the given arguments.""" + + @classmethod + def from_string(cls, name: str) -> Function: + """Create a function instance from its name.""" + match name.upper(): + case "COALESCE": + return Coalesce() + case "MAX": + return Max() + case "MIN": + return Min() + case _: + raise ValueError(f"Unknown function name: {name}") + + +class Coalesce(Function): + """A function that returns the first non-None argument.""" + + @property + @override + def name(self) -> str: + """Return the name of the function.""" + return "COALESCE" + + @override + def __call__(self, args: Iterable[float | None]) -> float | None: + """Return the first non-None argument.""" + for arg in args: + if arg is not None: + return arg + return None + + +class Max(Function): + """A function that returns the maximum of the arguments.""" + + @property + @override + def name(self) -> str: + """Return the name of the function.""" + return "MAX" + + @override + def __call__(self, args: Iterable[float | None]) -> float | None: + """Return the maximum of the arguments.""" + max_value: float | None = None + for arg in args: + if arg is None: + return None + if max_value is None or arg > max_value: + max_value = arg + return max_value + + +class Min(Function): + """A function that returns the minimum of the arguments.""" + + @property + @override + def name(self) -> str: + """Return the name of the function.""" + return "MIN" + + @override + def __call__(self, args: Iterable[float | None]) -> float | None: + """Return the minimum of the arguments.""" + min_value: float | None = None + for arg in args: + if arg is None: + return None + if min_value is None or arg < min_value: + min_value = arg + return min_value From a67e0b3ffc832d84a7c3c42b84a69a119a1f216e Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Thu, 20 Nov 2025 18:00:42 +0100 Subject: [PATCH 02/23] Introduce a `Peekable` wrapper around `Iterator` Signed-off-by: Sahas Subramanian --- .../sdk/timeseries/formulas/_peekable.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/frequenz/sdk/timeseries/formulas/_peekable.py diff --git a/src/frequenz/sdk/timeseries/formulas/_peekable.py b/src/frequenz/sdk/timeseries/formulas/_peekable.py new file mode 100644 index 000000000..4617a523d --- /dev/null +++ b/src/frequenz/sdk/timeseries/formulas/_peekable.py @@ -0,0 +1,53 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""A peekable iterator implementation.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Generic, TypeVar + +from typing_extensions import override + +_T = TypeVar("_T") + + +class Peekable(Generic[_T], Iterator[_T]): + """Create a Peekable iterator from an existing iterator.""" + + def __init__(self, iterator: Iterator[_T]): + """Initialize this instance. + + Args: + iterator: The underlying iterator to wrap. + """ + self._iterator: Iterator[_T] = iterator + self._buffer: _T | None = None + + @override + def __iter__(self) -> Peekable[_T]: + """Return the iterator itself.""" + return self + + @override + def __next__(self) -> _T: + """Return the next item from the iterator.""" + if self._buffer is not None: + item = self._buffer + self._buffer = None + return item + return next(self._iterator) + + def peek(self) -> _T | None: + """Return the next item without advancing the iterator. + + Returns: + The next item, or `None` if the iterator is exhausted. + """ + if self._buffer is None: + try: + self._buffer = next(self._iterator) + except StopIteration: + return None + return self._buffer From 25dfc0b70869f4f26d09dfd08917c7b06b1c5122 Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Thu, 20 Nov 2025 17:58:37 +0100 Subject: [PATCH 03/23] Implement a lexer for the new component graph formulas Signed-off-by: Sahas Subramanian --- .../sdk/timeseries/formulas/_lexer.py | 126 ++++++++++++++++++ .../sdk/timeseries/formulas/_token.py | 71 ++++++++++ tests/timeseries/_formulas/test_lexer.py | 68 ++++++++++ 3 files changed, 265 insertions(+) create mode 100644 src/frequenz/sdk/timeseries/formulas/_lexer.py create mode 100644 src/frequenz/sdk/timeseries/formulas/_token.py create mode 100644 tests/timeseries/_formulas/test_lexer.py diff --git a/src/frequenz/sdk/timeseries/formulas/_lexer.py b/src/frequenz/sdk/timeseries/formulas/_lexer.py new file mode 100644 index 000000000..95d952e27 --- /dev/null +++ b/src/frequenz/sdk/timeseries/formulas/_lexer.py @@ -0,0 +1,126 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""A lexer for formula strings.""" + + +from __future__ import annotations + +from collections.abc import Iterator + +from typing_extensions import override + +from . import _token +from ._peekable import Peekable + + +class Lexer(Iterator[_token.Token]): + """A lexer for formula strings.""" + + def __init__(self, formula: str): + """Initialize this instance. + + Args: + formula: The formula string to lex. + """ + self._formula: str = formula + self._iter: Peekable[tuple[int, str]] = Peekable(enumerate(iter(formula))) + + def _read_integer(self) -> str: + num_str = "" + peek = self._iter.peek() + while peek is not None and peek[1].isdigit(): + _, char = next(self._iter) + num_str += char + peek = self._iter.peek() + return num_str + + def _read_number(self) -> str: + num_str = "" + peek = self._iter.peek() + while peek is not None and (peek[1].isdigit() or peek[1] == "."): + _, char = next(self._iter) + num_str += char + peek = self._iter.peek() + return num_str + + def _read_symbol(self) -> str: + word_str = "" + peek = self._iter.peek() + while peek is not None and peek[1].isalnum(): + _, char = next(self._iter) + word_str += char + peek = self._iter.peek() + return word_str + + @override + def __iter__(self) -> Lexer: + """Return the iterator itself.""" + return self + + @override + def __next__(self) -> _token.Token: # pylint: disable=too-many-branches + """Return the next token from the formula string.""" + peek = self._iter.peek() + while peek is not None and peek[1].isspace(): + _ = next(self._iter) + peek = self._iter.peek() + + if peek is None: + raise StopIteration + + pos, char = peek + if char == "#": + _ = next(self._iter) # consume '#' + comp_id = self._read_integer() + if not comp_id: + raise ValueError(f"Expected integer after '#' at position {pos}") + end_pos = pos + len(comp_id) + return _token.Component( + span=( + pos + 1, + end_pos + 1, # account for '#' + ), + id=comp_id, + value=self._formula[pos:end_pos], + ) + + if char == "+": + _, char = next(self._iter) # consume operator + return _token.Plus(span=(pos + 1, pos + 1), value=char) + + if char == "-": + _, char = next(self._iter) + return _token.Minus(span=(pos + 1, pos + 1), value=char) + + if char == "*": + _, char = next(self._iter) + return _token.Mul(span=(pos + 1, pos + 1), value=char) + + if char == "/": + _, char = next(self._iter) + return _token.Div(span=(pos + 1, pos + 1), value=char) + + if char == "(": + _, char = next(self._iter) + return _token.OpenParen(span=(pos + 1, pos + 1), value=char) + + if char == ")": + _, char = next(self._iter) + return _token.CloseParen(span=(pos + 1, pos + 1), value=char) + + if char == ",": + _, char = next(self._iter) + return _token.Comma(span=(pos + 1, pos + 1), value=char) + + if char.isdigit(): + num = self._read_number() + end_pos = pos + len(num) + return _token.Number(span=(pos + 1, end_pos), value=num) + + if char.isalpha(): + symbol = self._read_symbol() + end_pos = pos + len(symbol) + return _token.Symbol(span=(pos + 1, end_pos), value=symbol) + + raise ValueError(f"Unexpected character '{char}' at position {pos}") diff --git a/src/frequenz/sdk/timeseries/formulas/_token.py b/src/frequenz/sdk/timeseries/formulas/_token.py new file mode 100644 index 000000000..fd638f75a --- /dev/null +++ b/src/frequenz/sdk/timeseries/formulas/_token.py @@ -0,0 +1,71 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Formula tokens.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class Token: + """Base class for all tokens.""" + + span: tuple[int, int] + """The span (start, end) of the token in the input string.""" + + value: str + """The string value of the token.""" + + +@dataclass +class Component(Token): + """An electrical component token.""" + + id: str + + +@dataclass +class Plus(Token): + """A plus operator token.""" + + +@dataclass +class Minus(Token): + """A minus operator token.""" + + +@dataclass +class Mul(Token): + """A multiplication operator token.""" + + +@dataclass +class Div(Token): + """A division operator token.""" + + +@dataclass +class Number(Token): + """A number token.""" + + +@dataclass +class Symbol(Token): + """A symbol token.""" + + +@dataclass +class OpenParen(Token): + """An open parenthesis token.""" + + +@dataclass +class CloseParen(Token): + """A close parenthesis token.""" + + +@dataclass +class Comma(Token): + """A comma token.""" diff --git a/tests/timeseries/_formulas/test_lexer.py b/tests/timeseries/_formulas/test_lexer.py new file mode 100644 index 000000000..5d07f265d --- /dev/null +++ b/tests/timeseries/_formulas/test_lexer.py @@ -0,0 +1,68 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for the formula lexer.""" + +from frequenz.sdk.timeseries.formulas import _token +from frequenz.sdk.timeseries.formulas._lexer import Lexer + + +def test_lexer() -> None: + """Test the Lexer reading integer tokens.""" + formula = "#123 + coalesce(#1 / 10.0, 0.0)" + lexer = Lexer(formula) + component = next(lexer) + assert isinstance(component, _token.Component) + assert component.id == "123" + assert component.span == (1, 4) + + plus_op = next(lexer) + assert isinstance(plus_op, _token.Plus) + assert plus_op.value == "+" + assert plus_op.span == (6, 6) + + number = next(lexer) + assert isinstance(number, _token.Symbol) + assert number.value == "coalesce" + assert number.span == (8, 15) + + open_paren = next(lexer) + assert isinstance(open_paren, _token.OpenParen) + assert open_paren.value == "(" + assert open_paren.span == (16, 16) + + component = next(lexer) + assert isinstance(component, _token.Component) + assert component.id == "1" + assert component.span == (17, 18) + + div_op = next(lexer) + assert isinstance(div_op, _token.Div) + assert div_op.value == "/" + assert div_op.span == (20, 20) + + number = next(lexer) + assert isinstance(number, _token.Number) + assert number.value == "10.0" + assert number.span == (22, 25) + + comma = next(lexer) + assert isinstance(comma, _token.Comma) + assert comma.value == "," + assert comma.span == (26, 26) + + number = next(lexer) + assert isinstance(number, _token.Number) + assert number.value == "0.0" + assert number.span == (28, 30) + + close_paren = next(lexer) + assert isinstance(close_paren, _token.CloseParen) + assert close_paren.value == ")" + assert close_paren.span == (31, 31) + + try: + _ = next(lexer) + assert False, "Expected StopIteration" + except StopIteration: + pass From 9366cfc10a5f9059a7bc5d9e8ec8167cd28fdb09 Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Thu, 20 Nov 2025 18:02:14 +0100 Subject: [PATCH 04/23] Implement a `ResampledStreamFetcher` It sends requests to the resampler and fetches resampled telemetry streams. Signed-off-by: Sahas Subramanian --- .../formulas/_resampled_stream_fetcher.py | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 src/frequenz/sdk/timeseries/formulas/_resampled_stream_fetcher.py diff --git a/src/frequenz/sdk/timeseries/formulas/_resampled_stream_fetcher.py b/src/frequenz/sdk/timeseries/formulas/_resampled_stream_fetcher.py new file mode 100644 index 000000000..d45fbe7ca --- /dev/null +++ b/src/frequenz/sdk/timeseries/formulas/_resampled_stream_fetcher.py @@ -0,0 +1,74 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Fetches telemetry streams for components.""" + +from frequenz.channels import Receiver, Sender +from frequenz.client.common.microgrid.components import ComponentId +from frequenz.quantities import Quantity + +from frequenz.sdk.timeseries import Sample + +from ..._internal._channels import ChannelRegistry +from ...microgrid._data_sourcing import ComponentMetricRequest, Metric +from ...microgrid._old_component_data import TransitionalMetric + + +class ResampledStreamFetcher: + """Fetches telemetry streams for components.""" + + def __init__( + self, + namespace: str, + channel_registry: ChannelRegistry, + resampler_subscription_sender: Sender[ComponentMetricRequest], + metric: Metric | TransitionalMetric, + ): + """Initialize this instance. + + Args: + namespace: The unique namespace to allow reuse of streams in the data + pipeline. + channel_registry: The channel registry instance shared with the resampling + and the data sourcing actors. + resampler_subscription_sender: A sender to send metric requests to the + resampling actor. + metric: The metric to fetch for all components in this formula. + """ + self._namespace: str = namespace + self._channel_registry: ChannelRegistry = channel_registry + self._resampler_subscription_sender: Sender[ComponentMetricRequest] = ( + resampler_subscription_sender + ) + self._metric: Metric | TransitionalMetric = metric + + self._pending_requests: list[ComponentMetricRequest] = [] + + def fetch_stream( + self, + component_id: ComponentId, + ) -> Receiver[Sample[Quantity]]: + """Get a receiver with the resampled data for the given component id. + + Args: + component_id: The component id for which to get a resampled data stream. + + Returns: + A receiver to stream resampled data for the given component id. + """ + request = ComponentMetricRequest( + self._namespace, + component_id, + self._metric, + None, + ) + self._pending_requests.append(request) + return self._channel_registry.get_or_create( + Sample[Quantity], request.get_channel_name() + ).new_receiver() + + async def subscribe(self) -> None: + """Subscribe to all resampled component metric streams.""" + for request in self._pending_requests: + await self._resampler_subscription_sender.send(request) + self._pending_requests.clear() From c32c852740daa73ecae3bb8bae7e03556f524631 Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Thu, 20 Nov 2025 18:05:22 +0100 Subject: [PATCH 05/23] Implement a `FormulaEvaluatingActor` Waits for new values from the input data streams. When there's one new value from each of them, evaluates the AST and sends the calculated value out. Signed-off-by: Sahas Subramanian --- .../timeseries/formulas/_formula_evaluator.py | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 src/frequenz/sdk/timeseries/formulas/_formula_evaluator.py diff --git a/src/frequenz/sdk/timeseries/formulas/_formula_evaluator.py b/src/frequenz/sdk/timeseries/formulas/_formula_evaluator.py new file mode 100644 index 000000000..d597f54d1 --- /dev/null +++ b/src/frequenz/sdk/timeseries/formulas/_formula_evaluator.py @@ -0,0 +1,129 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""An evaluator for a formula represented as an AST.""" + + +import asyncio +import logging +from collections.abc import Callable +from datetime import datetime +from typing import Generic + +from frequenz.channels import Broadcast, ReceiverStoppedError, Sender +from typing_extensions import override + +from ...actor import Actor +from .._base_types import QuantityT, Sample +from . import _ast +from ._resampled_stream_fetcher import ResampledStreamFetcher + +_logger = logging.getLogger(__name__) + + +class FormulaEvaluatingActor(Generic[QuantityT], Actor): + """An evaluator for a formula represented as an AST.""" + + def __init__( # pylint: disable=too-many-arguments + self, + *, + root: _ast.Node, + components: list[_ast.TelemetryStream[QuantityT]], + create_method: Callable[[float], QuantityT], + output_channel: Broadcast[Sample[QuantityT]], + metric_fetcher: ResampledStreamFetcher | None = None, + ) -> None: + """Create a `FormulaEvaluatingActor` instance. + + Args: + root: The root node of the formula AST. + components: The telemetry streams that the formula depends on. + create_method: A method to generate the output values with. If the + formula is for generating power values, this would be + `Power.from_watts`, for example. + output_channel: The channel to send evaluated samples to. + metric_fetcher: An optional metric fetcher that needs to be started + before the formula can be evaluated. + """ + super().__init__() + + self._root: _ast.Node = root + self._components: list[_ast.TelemetryStream[QuantityT]] = components + self._create_method: Callable[[float], QuantityT] = create_method + self._metric_fetcher: ResampledStreamFetcher | None = metric_fetcher + self._output_channel: Broadcast[Sample[QuantityT]] = output_channel + + self._output_sender: Sender[Sample[QuantityT]] = output_channel.new_sender() + + @override + async def _run(self) -> None: + """Run the formula evaluator actor.""" + if self._metric_fetcher is not None: + await self._metric_fetcher.subscribe() + await synchronize_receivers(self._components) + + while True: + try: + timestamp = next( + comp.latest_sample.timestamp + for comp in self._components + if comp.latest_sample is not None + ) + + res = self._root.evaluate() + next_sample = Sample( + timestamp, None if res is None else self._create_method(res) + ) + await self._output_sender.send(next_sample) + except (StopAsyncIteration, StopIteration): + _logger.debug( + "No more input samples available; stopping formula evaluator. (%s)", + self._root, + ) + await self._output_channel.close() + return + except Exception as e: # pylint: disable=broad-except + _logger.error( + "Error evaluating formula %s: %s", self._root, e, exc_info=True + ) + await self._output_channel.close() + return + + fetch_results = await asyncio.gather( + *(comp.fetch_next() for comp in self._components), + return_exceptions=True, + ) + if ex := next((e for e in fetch_results if isinstance(e, Exception)), None): + if isinstance(ex, (StopAsyncIteration, ReceiverStoppedError)): + _logger.debug( + "input streams closed; stopping formula evaluator. (%s)", + self._root, + ) + await self._output_channel.close() + return + raise ex + + +async def synchronize_receivers( + components: list[_ast.TelemetryStream[QuantityT]], +) -> None: + """Synchronize the given telemetry stream receivers.""" + _ = await asyncio.gather( + *(comp.fetch_next() for comp in components), + ) + latest_ts: datetime | None = None + for comp in components: + if comp.latest_sample is not None and ( + latest_ts is None or comp.latest_sample.timestamp > latest_ts + ): + latest_ts = comp.latest_sample.timestamp + if latest_ts is None: + _logger.debug("No samples available to synchronize receivers.") + return + for comp in components: + if comp.latest_sample is None: + raise RuntimeError("Can't synchronize receivers.") + ctr = 0 + while ctr < 10 and comp.latest_sample.timestamp < latest_ts: + await comp.fetch_next() + ctr += 1 From 7e2ad89511475f85dea82c749e479ba26bdf927f Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Thu, 20 Nov 2025 18:08:54 +0100 Subject: [PATCH 06/23] Implement the `Formula` type This is a wrapper around the FormulaEvaluatingActor, with methods for composing multiple formulas. Signed-off-by: Sahas Subramanian --- .../sdk/timeseries/formulas/_formula.py | 383 ++++++++++++++++++ 1 file changed, 383 insertions(+) create mode 100644 src/frequenz/sdk/timeseries/formulas/_formula.py diff --git a/src/frequenz/sdk/timeseries/formulas/_formula.py b/src/frequenz/sdk/timeseries/formulas/_formula.py new file mode 100644 index 000000000..668f3cbbc --- /dev/null +++ b/src/frequenz/sdk/timeseries/formulas/_formula.py @@ -0,0 +1,383 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""A composable formula represented as an AST.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from typing import Generic + +from frequenz.channels import Broadcast, Receiver +from typing_extensions import override + +from frequenz.sdk.timeseries.formulas._resampled_stream_fetcher import ( + ResampledStreamFetcher, +) + +from ...actor import BackgroundService +from .. import ReceiverFetcher, Sample +from .._base_types import QuantityT +from . import _ast +from ._formula_evaluator import FormulaEvaluatingActor +from ._functions import Coalesce, Max, Min + +_logger = logging.getLogger(__name__) + + +class Formula(BackgroundService, ReceiverFetcher[Sample[QuantityT]]): + """A formula represented as an AST.""" + + def __init__( # pylint: disable=too-many-arguments + self, + *, + name: str, + root: _ast.Node, + create_method: Callable[[float], QuantityT], + streams: list[_ast.TelemetryStream[QuantityT]], + sub_formulas: list[Formula[QuantityT]] | None = None, + metric_fetcher: ResampledStreamFetcher | None = None, + ) -> None: + """Create a `Formula` instance. + + Args: + name: The name of the formula. + root: The root node of the formula AST. + create_method: A method to generate the output values with. If the + formula is for generating power values, this would be + `Power.from_watts`, for example. + streams: The telemetry streams that the formula depends on. + sub_formulas: Any sub-formulas that this formula depends on. + metric_fetcher: An optional metric fetcher that needs to be started + before the formula can be evaluated. + """ + BackgroundService.__init__(self) + self._name: str = name + self._root: _ast.Node = root + self._components: list[_ast.TelemetryStream[QuantityT]] = streams + self._create_method: Callable[[float], QuantityT] = create_method + self._sub_formulas: list[Formula[QuantityT]] = sub_formulas or [] + + self._channel: Broadcast[Sample[QuantityT]] = Broadcast( + name=f"{self}", + resend_latest=True, + ) + self._evaluator: FormulaEvaluatingActor[QuantityT] = FormulaEvaluatingActor( + root=self._root, + components=self._components, + create_method=self._create_method, + output_channel=self._channel, + metric_fetcher=metric_fetcher, + ) + + @override + def __str__(self) -> str: + """Return a string representation of the formula.""" + return f"[{self._name}]({self._root})" + + @override + def new_receiver(self, *, limit: int = 50) -> Receiver[Sample[QuantityT]]: + """Subscribe to the formula evaluator to get evaluated samples.""" + if not self._evaluator.is_running: + # raise RuntimeError( + # f"Formula evaluator for '{self._root}' is not running. Please " + # + "call `start()` on the formula before using it.", + # ) + # _logger.warning( + # "Formula evaluator for '%s' is not running. Starting it. " + # + "Please call `start()` on the formula before using it." + # self._root, + # ) + self.start() + return self._channel.new_receiver(limit=limit) + + @override + def start(self) -> None: + """Start the formula evaluator.""" + for sub_formula in self._sub_formulas: + sub_formula.start() + self._evaluator.start() + + @override + async def stop(self, msg: str | None = None) -> None: + """Stop the formula evaluator.""" + await BackgroundService.stop(self, msg) + for sub_formula in self._sub_formulas: + await sub_formula.stop(msg) + await self._evaluator.stop(msg) + + def __add__( + self, other: FormulaBuilder[QuantityT] | QuantityT | Formula[QuantityT] + ) -> FormulaBuilder[QuantityT]: + """Create an addition operation node.""" + return FormulaBuilder(self, self._create_method) + other + + def __sub__( + self, other: FormulaBuilder[QuantityT] | QuantityT | Formula[QuantityT] + ) -> FormulaBuilder[QuantityT]: + """Create a subtraction operation node.""" + return FormulaBuilder(self, self._create_method) - other + + def __mul__(self, other: float) -> FormulaBuilder[QuantityT]: + """Create a multiplication operation node.""" + return FormulaBuilder(self, self._create_method) * other + + def __truediv__(self, other: float) -> FormulaBuilder[QuantityT]: + """Create a division operation node.""" + return FormulaBuilder(self, self._create_method) / other + + def coalesce( + self, + other: list[FormulaBuilder[QuantityT] | QuantityT | Formula[QuantityT]], + ) -> FormulaBuilder[QuantityT]: + """Create a coalesce operation node.""" + return FormulaBuilder(self, self._create_method).coalesce(other) + + def min( + self, + other: list[FormulaBuilder[QuantityT] | QuantityT | Formula[QuantityT]], + ) -> FormulaBuilder[QuantityT]: + """Create a min operation node.""" + return FormulaBuilder(self, self._create_method).min(other) + + def max( + self, + other: list[FormulaBuilder[QuantityT] | QuantityT | Formula[QuantityT]], + ) -> FormulaBuilder[QuantityT]: + """Create a max operation node.""" + return FormulaBuilder(self, self._create_method).max(other) + + +class FormulaBuilder(Generic[QuantityT]): + """A builder for higher-order formulas represented as ASTs.""" + + def __init__( + self, + formula: Formula[QuantityT] | _ast.Node, + create_method: Callable[[float], QuantityT], + streams: list[_ast.TelemetryStream[QuantityT]] | None = None, + sub_formulas: list[Formula[QuantityT]] | None = None, + ) -> None: + """Create a `FormulaBuilder` instance. + + Args: + formula: The initial formula to build upon. + create_method: A method to generate the output values with. If the + formula is for generating power values, this would be + `Power.from_watts`, for example. + streams: The telemetry streams that the formula depends on. + sub_formulas: Any sub-formulas that this formula depends on. + """ + self._create_method: Callable[[float], QuantityT] = create_method + self._streams: list[_ast.TelemetryStream[QuantityT]] = streams or [] + """Input streams that need to be synchronized before evaluation.""" + self._sub_formulas: list[Formula[QuantityT]] = sub_formulas or [] + """Sub-formulas whose lifetimes are managed by this formula.""" + + if isinstance(formula, Formula): + self.root: _ast.Node = _ast.TelemetryStream( + None, + str(formula), + formula.new_receiver(), + ) + self._streams.append(self.root) + self._sub_formulas.append(formula) + else: + self.root = formula + + def __add__( + self, + other: FormulaBuilder[QuantityT] | QuantityT | Formula[QuantityT], + ) -> FormulaBuilder[QuantityT]: + """Create an addition operation node.""" + if isinstance(other, FormulaBuilder): + right_node = other.root + self._streams.extend(other._streams) + elif isinstance(other, Formula): + right_node = _ast.TelemetryStream(None, str(other), other.new_receiver()) + self._streams.append(right_node) + self._sub_formulas.append(other) + else: + right_node = _ast.Constant(None, other.base_value) + + new_root = _ast.Add(None, self.root, right_node) + return FormulaBuilder( + new_root, + self._create_method, + self._streams, + self._sub_formulas, + ) + + def __sub__( + self, + other: FormulaBuilder[QuantityT] | QuantityT | Formula[QuantityT], + ) -> FormulaBuilder[QuantityT]: + """Create a subtraction operation node.""" + if isinstance(other, FormulaBuilder): + right_node = other.root + self._streams.extend(other._streams) + elif isinstance(other, Formula): + right_node = _ast.TelemetryStream(None, str(other), other.new_receiver()) + self._streams.append(right_node) + self._sub_formulas.append(other) + else: + right_node = _ast.Constant(None, other.base_value) + + new_root = _ast.Sub(None, self.root, right_node) + return FormulaBuilder( + new_root, + self._create_method, + self._streams, + self._sub_formulas, + ) + + def __mul__(self, other: float) -> FormulaBuilder[QuantityT]: + """Create a multiplication operation node.""" + right_node = _ast.Constant(None, other) + new_root = _ast.Mul(None, self.root, right_node) + return FormulaBuilder( + new_root, + self._create_method, + self._streams, + self._sub_formulas, + ) + + def __truediv__( + self, + other: float, + ) -> FormulaBuilder[QuantityT]: + """Create a division operation node.""" + right_node = _ast.Constant(None, other) + new_root = _ast.Div(None, self.root, right_node) + return FormulaBuilder( + new_root, + self._create_method, + self._streams, + self._sub_formulas, + ) + + def coalesce( + self, + other: list[FormulaBuilder[QuantityT] | QuantityT | Formula[QuantityT]], + ) -> FormulaBuilder[QuantityT]: + """Create a coalesce operation node.""" + right_nodes: list[_ast.Node] = [] + for item in other: + if isinstance(item, FormulaBuilder): + right_nodes.append(item.root) + self._streams.extend(item._streams) # pylint: disable=protected-access + elif isinstance(item, Formula): + right_node = _ast.TelemetryStream( + None, + str(item), + item.new_receiver(), + ) + right_nodes.append(right_node) + self._streams.append(right_node) + self._sub_formulas.append(item) + else: + right_nodes.append(_ast.Constant(None, item.base_value)) + + new_root = _ast.FunCall( + None, + Coalesce(), + [self.root] + right_nodes, + ) + + return FormulaBuilder( + new_root, + self._create_method, + self._streams, + self._sub_formulas, + ) + + def min( + self, + other: list[FormulaBuilder[QuantityT] | QuantityT | Formula[QuantityT]], + ) -> FormulaBuilder[QuantityT]: + """Create a min operation node.""" + right_nodes: list[_ast.Node] = [] + for item in other: + if isinstance(item, FormulaBuilder): + right_nodes.append(item.root) + self._streams.extend(item._streams) # pylint: disable=protected-access + elif isinstance(item, Formula): + right_node = _ast.TelemetryStream( + None, + str(item), + item.new_receiver(), + ) + right_nodes.append(right_node) + self._streams.append(right_node) + self._sub_formulas.append(item) + else: + right_nodes.append(_ast.Constant(None, item.base_value)) + + new_root = _ast.FunCall( + None, + Min(), + [self.root] + right_nodes, + ) + + return FormulaBuilder( + new_root, + self._create_method, + self._streams, + self._sub_formulas, + ) + + def max( + self, + other: list[FormulaBuilder[QuantityT] | QuantityT | Formula[QuantityT]], + ) -> FormulaBuilder[QuantityT]: + """Create a max operation node.""" + right_nodes: list[_ast.Node] = [] + for item in other: + if isinstance(item, FormulaBuilder): + right_nodes.append(item.root) + self._streams.extend(item._streams) # pylint: disable=protected-access + elif isinstance(item, Formula): + right_node = _ast.TelemetryStream( + None, + str(item), + item.new_receiver(), + ) + right_nodes.append(right_node) + self._streams.append(right_node) + self._sub_formulas.append(item) + else: + right_nodes.append(_ast.Constant(None, item.base_value)) + + new_root = _ast.FunCall( + None, + Max(), + [self.root] + right_nodes, + ) + + return FormulaBuilder( + new_root, + self._create_method, + self._streams, + self._sub_formulas, + ) + + def build( + self, + name: str, + ) -> Formula[QuantityT]: + """Build a `Formula` instance. + + Args: + name: The name of the formula. + + Returns: + A `Formula` instance. + """ + return Formula( + name=name, + root=self.root, + create_method=self._create_method, + streams=self._streams, + sub_formulas=self._sub_formulas, + ) From 56a437784c98e409ffc3d383b5593a95d59bea6b Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Thu, 20 Nov 2025 18:10:19 +0100 Subject: [PATCH 07/23] Implement a parser for string formulas Signed-off-by: Sahas Subramanian --- .../sdk/timeseries/formulas/_parser.py | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 src/frequenz/sdk/timeseries/formulas/_parser.py diff --git a/src/frequenz/sdk/timeseries/formulas/_parser.py b/src/frequenz/sdk/timeseries/formulas/_parser.py new file mode 100644 index 000000000..0a8731ece --- /dev/null +++ b/src/frequenz/sdk/timeseries/formulas/_parser.py @@ -0,0 +1,216 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Parser for formulas.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Generic, cast + +from frequenz.client.common.microgrid.components import ComponentId + +from frequenz.sdk.timeseries._base_types import QuantityT + +from . import _ast, _token +from ._formula import Formula +from ._functions import Function +from ._lexer import Lexer +from ._peekable import Peekable +from ._resampled_stream_fetcher import ResampledStreamFetcher + + +def parse( + *, + name: str, + formula: str, + telemetry_fetcher: ResampledStreamFetcher, + create_method: Callable[[float], QuantityT], +) -> Formula[QuantityT]: + """Parse a formula string into an AST. + + Args: + name: The name of the formula. + formula: The formula string to parse. + telemetry_fetcher: The telemetry fetcher to get component streams. + create_method: A method to create the corresponding QuantityT from a + float, based on the metric. + + Returns: + The parsed formula AST. + """ + return _Parser( + name=name, + formula=formula, + telemetry_fetcher=telemetry_fetcher, + create_method=create_method, + ).parse() + + +class _Parser(Generic[QuantityT]): + def __init__( + self, + *, + name: str, + formula: str, + telemetry_fetcher: ResampledStreamFetcher, + create_method: Callable[[float], QuantityT], + ): + """Initialize the parser.""" + self._name: str = name + self._lexer: Peekable[_token.Token] = Peekable(Lexer(formula)) + self._telemetry_fetcher: ResampledStreamFetcher = telemetry_fetcher + self._components: list[_ast.TelemetryStream[QuantityT]] = [] + self._create_method: Callable[[float], QuantityT] = create_method + + def _parse_term(self) -> _ast.Node | None: + factor = self._parse_factor() + if factor is None: + return None + + token: _token.Token | None = self._lexer.peek() + while token is not None and isinstance(token, (_token.Plus, _token.Minus)): + token = next(self._lexer) + next_factor = self._parse_factor() + + if next_factor is None: + raise ValueError( + f"Expected factor after operator at span: {token.span}" + ) + + if isinstance(token, _token.Plus): + factor = _ast.Add(span=token.span, left=factor, right=next_factor) + elif isinstance(token, _token.Minus): + factor = _ast.Sub(span=token.span, left=factor, right=next_factor) + + token = self._lexer.peek() + + return factor + + def _parse_factor(self) -> _ast.Node | None: + unary = self._parse_unary() + + if unary is None: + return None + + token: _token.Token | None = self._lexer.peek() + while token is not None and isinstance(token, (_token.Mul, _token.Div)): + token = next(self._lexer) + next_unary = self._parse_unary() + if next_unary is None: + raise ValueError(f"Expected unary after operator at span: {token.span}") + + if isinstance(token, _token.Mul): + unary = _ast.Mul(span=token.span, left=unary, right=next_unary) + elif isinstance(token, _token.Div): + unary = _ast.Div(span=token.span, left=unary, right=next_unary) + + token = self._lexer.peek() + + return unary + + def _parse_unary(self) -> _ast.Node | None: + token: _token.Token | None = self._lexer.peek() + if token is not None and isinstance(token, _token.Minus): + token = next(self._lexer) + primary: _ast.Node | None = self._parse_primary() + if primary is None: + raise ValueError( + f"Expected primary expression after unary '-' at position {token.span}" + ) + + zero_const = _ast.Constant(span=token.span, value=0.0) + return _ast.Sub(span=token.span, left=zero_const, right=primary) + + return self._parse_primary() + + def _parse_bracketed(self) -> _ast.Node | None: + oparen = next(self._lexer) # consume '(' + assert isinstance(oparen, _token.OpenParen) + + expr: _ast.Node | None = self._parse_term() + if expr is None: + raise ValueError(f"Expected expression after '(' at position {oparen.span}") + + token: _token.Token | None = self._lexer.peek() + if token is None or not isinstance(token, _token.CloseParen): + raise ValueError(f"Expected ')' after expression at position {expr.span}") + + _ = next(self._lexer) # consume ')' + + return expr + + def _parse_function_call(self) -> _ast.Node | None: + fn_name: _token.Token = next(self._lexer) + args: list[_ast.Node] = [] + + token: _token.Token | None = self._lexer.peek() + if token is None or not isinstance(token, _token.OpenParen): + raise ValueError( + f"Expected '(' after function name at position {fn_name.span}" + ) + + _ = next(self._lexer) # consume '(' + while True: + arg = self._parse_term() + if arg is None: + raise ValueError( + f"Expected argument in function call at position {fn_name.span}" + ) + args.append(arg) + + token = self._lexer.peek() + if token is not None and isinstance(token, _token.Comma): + _ = next(self._lexer) # consume ',' + continue + if token is not None and isinstance(token, _token.CloseParen): + _ = next(self._lexer) # consume ')' + break + raise ValueError( + f"Expected ',' or ')' in function call at position {fn_name.span}" + ) + + return _ast.FunCall( + span=fn_name.span, + function=Function.from_string(fn_name.value), + args=args, + ) + + def _parse_primary(self) -> _ast.Node | None: + token: _token.Token | None = self._lexer.peek() + if token is None: + return None + + if isinstance(token, _token.Component): + _ = next(self._lexer) # consume token + comp = _ast.TelemetryStream( + span=token.span, + source=f"#{token.id}", + stream=self._telemetry_fetcher.fetch_stream(ComponentId(int(token.id))), + ) + self._components.append(cast(_ast.TelemetryStream[QuantityT], comp)) + return comp + + if isinstance(token, _token.Number): + _ = next(self._lexer) + return _ast.Constant(span=token.span, value=float(token.value)) + + if isinstance(token, _token.OpenParen): + return self._parse_bracketed() + + if isinstance(token, _token.Symbol): + return self._parse_function_call() + + return None + + def parse(self) -> Formula[QuantityT]: + expr = self._parse_term() + if expr is None: + raise ValueError("Empty formula.") + return Formula( + name=self._name, + root=expr, + create_method=self._create_method, + streams=self._components, + metric_fetcher=self._telemetry_fetcher, + ) From 194b74213267d41e83721b7ea13f2404235ac5f9 Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Thu, 20 Nov 2025 18:17:56 +0100 Subject: [PATCH 08/23] Add tests for formulas Signed-off-by: Sahas Subramanian --- tests/timeseries/_formulas/test_formulas.py | 651 ++++++++++++++++++++ 1 file changed, 651 insertions(+) create mode 100644 tests/timeseries/_formulas/test_formulas.py diff --git a/tests/timeseries/_formulas/test_formulas.py b/tests/timeseries/_formulas/test_formulas.py new file mode 100644 index 000000000..d9584a698 --- /dev/null +++ b/tests/timeseries/_formulas/test_formulas.py @@ -0,0 +1,651 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for the Formula implementation.""" + +import asyncio +from collections import OrderedDict +from collections.abc import Callable +from datetime import datetime +from unittest.mock import MagicMock + +from frequenz.channels import Broadcast, Receiver +from frequenz.quantities import Quantity + +from frequenz.sdk.timeseries import Sample +from frequenz.sdk.timeseries.formulas._formula import Formula, FormulaBuilder +from frequenz.sdk.timeseries.formulas._parser import parse +from frequenz.sdk.timeseries.formulas._resampled_stream_fetcher import ( + ResampledStreamFetcher, +) + + +class TestFormulas: + """Tests for Formula.""" + + async def run_test( # pylint: disable=too-many-locals + self, + formula_str: str, + expected: str, + io_pairs: list[tuple[list[float | None], float | None]], + ) -> None: + """Run a formula test.""" + channels: OrderedDict[int, Broadcast[Sample[Quantity]]] = OrderedDict() + + def stream_recv(comp_id: int) -> Receiver[Sample[Quantity]]: + if comp_id not in channels: + channels[comp_id] = Broadcast(name=f"chan-#{comp_id}") + return channels[comp_id].new_receiver() + + telem_fetcher = MagicMock(spec=ResampledStreamFetcher) + telem_fetcher.fetch_stream = MagicMock(side_effect=stream_recv) + formula = parse( + name="f", + formula=formula_str, + create_method=Quantity, + telemetry_fetcher=telem_fetcher, + ) + assert str(formula) == expected + + async with formula as formula: + results_rx = formula.new_receiver() + now = datetime.now() + tests_passed = 0 + for io_pair in io_pairs: + io_input, io_output = io_pair + _ = await asyncio.gather( + *[ + chan.new_sender().send( + Sample(now, None if not value else Quantity(value)) + ) + for chan, value in zip(channels.values(), io_input) + ] + ) + next_val = await results_rx.receive() + if io_output is None: + assert next_val.value is None + else: + assert ( + next_val.value is not None + and next_val.value.base_value == io_output + ) + tests_passed += 1 + assert tests_passed == len(io_pairs) + + async def test_simple(self) -> None: + """Test simple formulas.""" + await self.run_test( + "#2 - #4 + #5", + "[f](#2 - #4 + #5)", + [ + ([10.0, 12.0, 15.0], 13.0), + ([15.0, 17.0, 20.0], 18.0), + ], + ) + await self.run_test( + "#2 + #4 - #5", + "[f](#2 + #4 - #5)", + [ + ([10.0, 12.0, 15.0], 7.0), + ([15.0, 17.0, 20.0], 12.0), + ], + ) + await self.run_test( + "#2 * #4 + #5", + "[f](#2 * #4 + #5)", + [ + ([10.0, 12.0, 15.0], 135.0), + ([15.0, 17.0, 20.0], 275.0), + ], + ) + await self.run_test( + "#2 * #4 / #5", + "[f](#2 * #4 / #5)", + [ + ([10.0, 12.0, 15.0], 8.0), + ([15.0, 17.0, 20.0], 12.75), + ], + ) + await self.run_test( + "#2 / #4 - #5", + "[f](#2 / #4 - #5)", + [ + ([6.0, 12.0, 15.0], -14.5), + ([15.0, 20.0, 20.0], -19.25), + ], + ) + await self.run_test( + "#2 - #4 - #5", + "[f](#2 - #4 - #5)", + [ + ([6.0, 12.0, 15.0], -21.0), + ([15.0, 20.0, 20.0], -25.0), + ], + ) + await self.run_test( + "#2 + #4 + #5", + "[f](#2 + #4 + #5)", + [ + ([6.0, 12.0, 15.0], 33.0), + ([15.0, 20.0, 20.0], 55.0), + ], + ) + await self.run_test( + "#2 / #4 / #5", + "[f](#2 / #4 / #5)", + [ + ([30.0, 3.0, 5.0], 2.0), + ([15.0, 3.0, 2.0], 2.5), + ], + ) + + async def test_compound(self) -> None: + """Test compound formulas.""" + await self.run_test( + "#2 + #4 - #5 * #6", + "[f](#2 + #4 - #5 * #6)", + [ + ([10.0, 12.0, 15.0, 2.0], -8.0), + ([15.0, 17.0, 20.0, 1.5], 2.0), + ], + ) + await self.run_test( + "#2 + (#4 - #5) * #6", + "[f](#2 + (#4 - #5) * #6)", + [ + ([10.0, 12.0, 15.0, 2.0], 4.0), + ([15.0, 17.0, 20.0, 1.5], 10.5), + ], + ) + await self.run_test( + "#2 + (#4 - #5 * #6)", + "[f](#2 + #4 - #5 * #6)", + [ + ([10.0, 12.0, 15.0, 2.0], -8.0), + ([15.0, 17.0, 20.0, 1.5], 2.0), + ], + ) + await self.run_test( + "#2 + (#4 - #5 - #6)", + "[f](#2 + #4 - #5 - #6)", + [ + ([10.0, 12.0, 15.0, 2.0], 5.0), + ([15.0, 17.0, 20.0, 1.5], 10.5), + ], + ) + await self.run_test( + "#2 + #4 - #5 - #6", + "[f](#2 + #4 - #5 - #6)", + [ + ([10.0, 12.0, 15.0, 2.0], 5.0), + ([15.0, 17.0, 20.0, 1.5], 10.5), + ], + ) + await self.run_test( + "#2 + #4 - (#5 - #6)", + "[f](#2 + #4 - (#5 - #6))", + [ + ([10.0, 12.0, 15.0, 2.0], 9.0), + ([15.0, 17.0, 20.0, 1.5], 13.5), + ], + ) + await self.run_test( + "(#2 + #4 - #5) * #6", + "[f]((#2 + #4 - #5) * #6)", + [ + ([10.0, 12.0, 15.0, 2.0], 14.0), + ([15.0, 17.0, 20.0, 1.5], 18.0), + ], + ) + await self.run_test( + "(#2 + #4 - #5) / #6", + "[f]((#2 + #4 - #5) / #6)", + [ + ([10.0, 12.0, 15.0, 2.0], 3.5), + ([15.0, 17.0, 20.0, 1.5], 8.0), + ], + ) + await self.run_test( + "#2 + #4 - (#5 / #6)", + "[f](#2 + #4 - #5 / #6)", + [ + ([10.0, 12.0, 15.0, 2.0], 14.5), + ([15.0, 17.0, 20.0, 5.0], 28.0), + ], + ) + + await self.run_test( + "#2 - #4 + #5", + "[f](#2 - #4 + #5)", + [ + ([10.0, 12.0, 15.0], 13.0), + ([None, 12.0, 15.0], None), + ([10.0, None, 15.0], None), + ([15.0, 17.0, 20.0], 18.0), + ([15.0, None, None], None), + ], + ) + + await self.run_test( + "#2 + #4 - (#5 * #6)", + "[f](#2 + #4 - #5 * #6)", + [ + ([10.0, 12.0, 15.0, 2.0], -8.0), + ([10.0, 12.0, 15.0, None], None), + ([10.0, None, 15.0, 2.0], None), + ([15.0, 17.0, 20.0, 5.0], -68.0), + ([15.0, 17.0, None, 5.0], None), + ], + ) + + async def test_max_min_coalesce(self) -> None: + """Test max, min and coalesce functions.""" + await self.run_test( + "#2 + MAX(#4, #5)", + "[f](#2 + MAX(#4, #5))", + [ + ([10.0, 12.0, 15.0], 25.0), + ], + ) + await self.run_test( + "MIN(#2, #4) + COALESCE(#5, 0.0)", + "[f](MIN(#2, #4) + COALESCE(#5, 0.0))", + [ + ([4.0, 6.0, 5.0], 9.0), + ([-2.0, 1.0, 5.0], 3.0), + ([-2.0, 15.0, None], -2.0), + ([None, 15.0, None], None), + ], + ) + await self.run_test( + "MIN(#23, 0.0) + COALESCE(MAX(#24 - #25, 0.0), 0.0)", + "[f](MIN(#23, 0.0) + COALESCE(MAX(#24 - #25, 0.0), 0.0))", + [ + ([4.0, 6.0, 5.0], 1.0), + ([-2.0, 1.0, 5.0], -2.0), + ([-2.0, 15.0, 1.0], 12.0), + ([None, 15.0, 1.0], None), + ([None, None, 1.0], None), + ([None, None, None], None), + ([-2.0, None, None], -2.0), + ([-2.0, None, 5.0], -2.0), + ([-2.0, 15.0, None], -2.0), + ], + ) + + +class TestFormulaComposition: + """Tests for formula channels.""" + + async def run_test( # pylint: disable=too-many-locals + self, + num_items: int, + make_builder: ( + Callable[[Formula[Quantity]], FormulaBuilder[Quantity]] + | Callable[[Formula[Quantity], Formula[Quantity]], FormulaBuilder[Quantity]] + | Callable[ + [Formula[Quantity], Formula[Quantity], Formula[Quantity]], + FormulaBuilder[Quantity], + ] + | Callable[ + [ + Formula[Quantity], + Formula[Quantity], + Formula[Quantity], + Formula[Quantity], + ], + FormulaBuilder[Quantity], + ] + ), + expected: str, + io_pairs: list[tuple[list[float | None], float | None]], + ) -> None: + """Run a test with the specs provided.""" + channels: OrderedDict[int, Broadcast[Sample[Quantity]]] = OrderedDict() + + def stream_recv(comp_id: int) -> Receiver[Sample[Quantity]]: + if comp_id not in channels: + channels[comp_id] = Broadcast(name=f"chan-#{comp_id}") + return channels[comp_id].new_receiver() + + telem_fetcher = MagicMock(spec=ResampledStreamFetcher) + telem_fetcher.fetch_stream = MagicMock(side_effect=stream_recv) + l1_formulas = [ + parse( + name=str(ctr), + formula=f"#{ctr}", + create_method=Quantity, + telemetry_fetcher=telem_fetcher, + ) + for ctr in range(num_items) + ] + builder = make_builder(*l1_formulas) + formula = builder.build("l2") + + assert str(formula) == expected + + result_chan = formula.new_receiver() + now = datetime.now() + tests_passed = 0 + for io_pair in io_pairs: + io_input, io_output = io_pair + _ = await asyncio.gather( + *[ + chan.new_sender().send( + Sample(now, None if not value else Quantity(value)) + ) + for chan, value in zip(channels.values(), io_input) + ] + ) + next_val = await result_chan.receive() + if io_output is None: + assert next_val.value is None + else: + assert ( + next_val.value is not None + and next_val.value.base_value == io_output + ) + tests_passed += 1 + await formula.stop() + assert tests_passed == len(io_pairs) + + async def test_simple(self) -> None: + """Test simple formulas.""" + await self.run_test( + 3, + lambda c2, c4, c5: c2 - c4 + c5, + "[l2]([0](#0) - [1](#1) + [2](#2))", + [ + ([10.0, 12.0, 15.0], 13.0), + ([15.0, 17.0, 20.0], 18.0), + ], + ) + await self.run_test( + 3, + lambda c2, c4, c5: c2 + c4 - c5, + "[l2]([0](#0) + [1](#1) - [2](#2))", + [ + ([10.0, 12.0, 15.0], 7.0), + ([15.0, 17.0, 20.0], 12.0), + ], + ) + await self.run_test( + 2, + lambda c4, c5: (c4 + c5) * 2.0, + "[l2](([0](#0) + [1](#1)) * 2.0)", + [ + ([10.0, 15.0], 50.0), + ([15.0, 20.0], 70.0), + ], + ) + await self.run_test( + 2, + lambda c4, c5: ((c4 - c5) / 2.0) * 3.0, + "[l2](([0](#0) - [1](#1)) / 2.0 * 3.0)", + [ + ([10.0, 15.0], -7.5), + ([15.0, 25.0], -15.0), + ], + ) + await self.run_test( + 2, + lambda c2, c5: c2 / 2.0 - c5, + "[l2]([0](#0) / 2.0 - [1](#1))", + [ + ([6.0, 15.0], -12.0), + ([15.0, 20.0], -12.5), + ], + ) + await self.run_test( + 3, + lambda c2, c4, c5: c2 - c4 - c5, + "[l2]([0](#0) - [1](#1) - [2](#2))", + [ + ([6.0, 12.0, 15.0], -21.0), + ([15.0, 20.0, 20.0], -25.0), + ], + ) + await self.run_test( + 3, + lambda c2, c4, c5: c2 + c4 + c5, + "[l2]([0](#0) + [1](#1) + [2](#2))", + [ + ([6.0, 12.0, 15.0], 33.0), + ([15.0, 20.0, 20.0], 55.0), + ], + ) + await self.run_test( + 1, + lambda c2: c2 / 2.0 / 5.0, + "[l2]([0](#0) / 2.0 / 5.0)", + [ + ([30.0], 3.0), + ([150.0], 15.0), + ], + ) + + await self.run_test( + 3, + lambda c2, c4, c5: c2 - c4 + c5, + "[l2]([0](#0) - [1](#1) + [2](#2))", + [ + ([10.0, 12.0, 15.0], 13.0), + ([None, 12.0, 15.0], None), + ([10.0, None, 15.0], None), + ([15.0, 17.0, 20.0], 18.0), + ([15.0, None, None], None), + ], + ) + + async def test_max(self) -> None: + """Test the max function.""" + await self.run_test( + 3, + lambda c2, c4, c5: c2 + c4.max([c5]), + "[l2]([0](#0) + MAX([1](#1), [2](#2)))", + [ + ([10.0, 12.0, 15.0], 25.0), + ], + ) + await self.run_test( + 3, + lambda c2, c4, c5: (c2 + c4).max([c5]), + "[l2](MAX([0](#0) + [1](#1), [2](#2)))", + [ + ([10.0, 12.0, 15.0], 22.0), + ], + ) + await self.run_test( + 3, + lambda c2, c4, c5: (c2 + c4).max([c5]), + "[l2](MAX([0](#0) + [1](#1), [2](#2)))", + [ + ([10.0, 12.0, 15.0], 22.0), + ], + ) + await self.run_test( + 3, + lambda c2, c4, c5: c2 + c4.max([c5]), + "[l2]([0](#0) + MAX([1](#1), [2](#2)))", + [ + ([10.0, 12.0, 15.0], 25.0), + ], + ) + + async def test_min(self) -> None: + """Test the min function.""" + await self.run_test( + 3, + lambda c2, c4, c5: (c2 + c4).min([c5]), + "[l2](MIN([0](#0) + [1](#1), [2](#2)))", + [ + ([10.0, 12.0, 15.0], 15.0), + ], + ) + await self.run_test( + 3, + lambda c2, c4, c5: c2 + c4.min([c5]), + "[l2]([0](#0) + MIN([1](#1), [2](#2)))", + [ + ([10.0, 12.0, 15.0], 22.0), + ], + ) + await self.run_test( + 3, + lambda c2, c4, c5: (c2 + c4).min([c5]), + "[l2](MIN([0](#0) + [1](#1), [2](#2)))", + [ + ([10.0, 2.0, 15.0], 12.0), + ], + ) + await self.run_test( + 3, + lambda c2, c4, c5: c2 + c4.min([c5]), + "[l2]([0](#0) + MIN([1](#1), [2](#2)))", + [ + ([10.0, 12.0, 15.0], 22.0), + ], + ) + + async def test_coalesce(self) -> None: + """Test the coalesce function.""" + await self.run_test( + 3, + lambda c2, c4, c5: c2.coalesce([c4, c5]), + "[l2](COALESCE([0](#0), [1](#1), [2](#2)))", + [ + ([None, 12.0, 15.0], 12.0), + ([None, None, 15.0], 15.0), + ([10.0, None, 15.0], 10.0), + ([None, None, None], None), + ], + ) + + await self.run_test( + 3, + lambda c2, c4, c5: (c2 * 5.0).coalesce([c4 / 2.0, c5]), + "[l2](COALESCE([0](#0) * 5.0, [1](#1) / 2.0, [2](#2)))", + [ + ([None, 12.0, 15.0], 6.0), + ([None, None, 15.0], 15.0), + ([10.0, None, 15.0], 50.0), + ([None, None, None], None), + ], + ) + + async def test_min_max_coalesce(self) -> None: + """Test min and max functions in combination.""" + await self.run_test( + 3, + lambda c2, c4, c5: c2.min([c4]).max([c5]), + "[l2](MAX(MIN([0](#0), [1](#1)), [2](#2)))", + [ + ([4.0, 6.0, 5.0], 5.0), + ], + ) + + await self.run_test( + 3, + lambda c2, c4, c5: c2.min([Quantity(0.0)]) + + (c4 - c5).max([Quantity(0.0)]).coalesce([Quantity(0.0)]), + "[l2](MIN([0](#0), 0.0) + COALESCE(MAX([1](#1) - [2](#2), 0.0), 0.0))", + [ + ([4.0, 6.0, 5.0], 1.0), + ([-2.0, 1.0, 5.0], -2.0), + ([-2.0, 15.0, 1.0], 12.0), + ([None, 15.0, 1.0], None), + ([None, None, 1.0], None), + ([None, None, None], None), + ([-2.0, None, None], -2.0), + ([-2.0, None, 5.0], -2.0), + ([-2.0, 15.0, None], -2.0), + ], + ) + + async def test_compound(self) -> None: + """Test compound formulas.""" + await self.run_test( + 4, + lambda c2, c4, c5, c6: c2 + c4 - c5 + c6 * 2.0, + "[l2]([0](#0) + [1](#1) - [2](#2) + [3](#3) * 2.0)", + [ + ([10.0, 12.0, 15.0, 2.0], 11.0), + ([15.0, 17.0, 20.0, 1.5], 15.0), + ], + ) + await self.run_test( + 4, + lambda c2, c4, c5, c6: c2 + (c4 - c5) - c6 * 2.0, + "[l2]([0](#0) + [1](#1) - [2](#2) - [3](#3) * 2.0)", + [ + ([10.0, 12.0, 15.0, 2.0], 3.0), + ([15.0, 17.0, 20.0, 1.5], 9.0), + ], + ) + await self.run_test( + 4, + lambda c2, c4, c5, c6: c2 + (c4 - c5 + c6) * 2.0, + "[l2]([0](#0) + ([1](#1) - [2](#2) + [3](#3)) * 2.0)", + [ + ([10.0, 12.0, 15.0, 2.0], 8.0), + ([15.0, 17.0, 20.0, 1.5], 12.0), + ], + ) + await self.run_test( + 4, + lambda c2, c4, c5, c6: c2 + (c4 - c5 - c6), + "[l2]([0](#0) + [1](#1) - [2](#2) - [3](#3))", + [ + ([10.0, 12.0, 15.0, 2.0], 5.0), + ([15.0, 17.0, 20.0, 1.5], 10.5), + ], + ) + await self.run_test( + 4, + lambda c2, c4, c5, c6: c2 + c4 - c5 - c6, + "[l2]([0](#0) + [1](#1) - [2](#2) - [3](#3))", + [ + ([10.0, 12.0, 15.0, 2.0], 5.0), + ([15.0, 17.0, 20.0, 1.5], 10.5), + ], + ) + await self.run_test( + 4, + lambda c2, c4, c5, c6: c2 + c4 - (c5 - c6), + "[l2]([0](#0) + [1](#1) - ([2](#2) - [3](#3)))", + [ + ([10.0, 12.0, 15.0, 2.0], 9.0), + ([15.0, 17.0, 20.0, 1.5], 13.5), + ], + ) + await self.run_test( + 4, + lambda c2, c4, c5, c6: (c2 + c4 - c5) - c6 * 2.0, + "[l2]([0](#0) + [1](#1) - [2](#2) - [3](#3) * 2.0)", + [ + ([10.0, 12.0, 15.0, 2.0], 3.0), + ([15.0, 17.0, 20.0, 1.5], 9.0), + ], + ) + await self.run_test( + 3, + lambda c2, c4, c5: (c2 + c4 - c5) / 2.0, + "[l2](([0](#0) + [1](#1) - [2](#2)) / 2.0)", + [ + ([10.0, 15.0, 2.0], 11.5), + ([15.0, 20.0, 1.5], 16.75), + ], + ) + + await self.run_test( + 4, + lambda c2, c4, c5, c6: c2 + c4 - (c5 - c6 / 2.0), + "[l2]([0](#0) + [1](#1) - ([2](#2) - [3](#3) / 2.0))", + [ + ([10.0, 12.0, 15.0, 2.0], 8.0), + ([10.0, 12.0, 15.0, None], None), + ([10.0, None, 15.0, 2.0], None), + ([15.0, 17.0, 20.0, 5.0], 14.5), + ([15.0, 17.0, None, 5.0], None), + ], + ) From 0454451502ed6500edcf22cd4a3aef622201b3cd Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Thu, 20 Nov 2025 18:18:39 +0100 Subject: [PATCH 09/23] Add a 3-phase formula type that wraps 3 1-phase formulas This also adds an evaluator and tests for the 3-phase formulas. Signed-off-by: Sahas Subramanian --- .../sdk/timeseries/formulas/__init__.py | 5 + .../timeseries/formulas/_formula_3_phase.py | 507 ++++++++++++++++++ .../formulas/_formula_3_phase_evaluator.py | 108 ++++ .../_formulas/test_formulas_3_phase.py | 153 ++++++ 4 files changed, 773 insertions(+) create mode 100644 src/frequenz/sdk/timeseries/formulas/_formula_3_phase.py create mode 100644 src/frequenz/sdk/timeseries/formulas/_formula_3_phase_evaluator.py create mode 100644 tests/timeseries/_formulas/test_formulas_3_phase.py diff --git a/src/frequenz/sdk/timeseries/formulas/__init__.py b/src/frequenz/sdk/timeseries/formulas/__init__.py index 3b9e7a10a..46b6938ab 100644 --- a/src/frequenz/sdk/timeseries/formulas/__init__.py +++ b/src/frequenz/sdk/timeseries/formulas/__init__.py @@ -2,3 +2,8 @@ # Copyright © 2025 Frequenz Energy-as-a-Service GmbH """Formulas on telemetry streams.""" + +from ._formula import Formula +from ._formula_3_phase import Formula3Phase + +__all__ = ["Formula", "Formula3Phase"] diff --git a/src/frequenz/sdk/timeseries/formulas/_formula_3_phase.py b/src/frequenz/sdk/timeseries/formulas/_formula_3_phase.py new file mode 100644 index 000000000..7dfc166a9 --- /dev/null +++ b/src/frequenz/sdk/timeseries/formulas/_formula_3_phase.py @@ -0,0 +1,507 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""A composite formula for three-phase metrics.""" + +# Temporary disable strict private usage checking for pyright +# pyright: strict, reportPrivateUsage=false + +from __future__ import annotations + +from collections.abc import Callable +from typing import Generic + +from frequenz.channels import Broadcast, Receiver +from typing_extensions import override + +from ..._internal._channels import ReceiverFetcher +from ...actor import BackgroundService +from .._base_types import QuantityT, Sample3Phase +from ._formula import Formula, FormulaBuilder +from ._formula_3_phase_evaluator import ( + Formula3PhaseEvaluatingActor, +) + + +class Formula3Phase(BackgroundService, ReceiverFetcher[Sample3Phase[QuantityT]]): + """A composite formula for three-phase metrics.""" + + def __init__( # pylint: disable=too-many-arguments + self, + *, + name: str, + phase_1: Formula[QuantityT], + phase_2: Formula[QuantityT], + phase_3: Formula[QuantityT], + sub_formulas: list[Formula3Phase[QuantityT]] | None = None, + ) -> None: + """Initialize this instance. + + Args: + name: The name of the formula. + phase_1: The formula for phase 1. + phase_2: The formula for phase 2. + phase_3: The formula for phase 3. + sub_formulas: Sub-formulas that need to be started before this formula. + """ + BackgroundService.__init__(self) + self._formula_p1: Formula[QuantityT] = phase_1 + self._formula_p2: Formula[QuantityT] = phase_2 + self._formula_p3: Formula[QuantityT] = phase_3 + self._create_method: Callable[[float], QuantityT] = phase_1._create_method + + self._channel: Broadcast[Sample3Phase[QuantityT]] = Broadcast( + name=f"[Formula3Phase:{name}]({phase_1.name})" + ) + self._sub_formulas: list[Formula3Phase[QuantityT]] = sub_formulas or [] + self._evaluator: Formula3PhaseEvaluatingActor[QuantityT] = ( + Formula3PhaseEvaluatingActor(phase_1, phase_2, phase_3, self._channel) + ) + + @override + def new_receiver(self, *, limit: int = 50) -> Receiver[Sample3Phase[QuantityT]]: + """Subscribe to the output of this formula.""" + if not self._evaluator.is_running: + self.start() + return self._channel.new_receiver(limit=limit) + + @override + def start(self) -> None: + """Start the per-phase and sub formulas.""" + for sub_formula in self._sub_formulas: + sub_formula.start() + self._formula_p1.start() + self._formula_p2.start() + self._formula_p3.start() + self._evaluator.start() + + @override + async def stop(self, msg: str | None = None) -> None: + """Stop the formula.""" + await BackgroundService.stop(self, msg) + for sub_formula in self._sub_formulas: + await sub_formula.stop(msg) + await self._formula_p1.stop(msg) + await self._formula_p2.stop(msg) + await self._formula_p3.stop(msg) + await self._evaluator.stop(msg) + + def __add__( + self, + other: Formula3PhaseBuilder[QuantityT] | Formula3Phase[QuantityT], + ) -> Formula3PhaseBuilder[QuantityT]: + """Add two three-phase formulas.""" + return Formula3PhaseBuilder(self, create_method=self._create_method) + other + + def __sub__( + self, + other: Formula3PhaseBuilder[QuantityT] | Formula3Phase[QuantityT], + ) -> Formula3PhaseBuilder[QuantityT]: + """Subtract two three-phase formulas.""" + return Formula3PhaseBuilder(self, create_method=self._create_method) - other + + def __mul__( + self, + scalar: float, + ) -> Formula3PhaseBuilder[QuantityT]: + """Multiply the three-phase formula by a scalar.""" + return Formula3PhaseBuilder(self, create_method=self._create_method) * scalar + + def __truediv__( + self, + scalar: float, + ) -> Formula3PhaseBuilder[QuantityT]: + """Divide the three-phase formula by a scalar.""" + return Formula3PhaseBuilder(self, create_method=self._create_method) / scalar + + def coalesce( + self, + other: list[ + Formula3PhaseBuilder[QuantityT] + | Formula3Phase[QuantityT] + | tuple[QuantityT, QuantityT, QuantityT] + ], + ) -> Formula3PhaseBuilder[QuantityT]: + """Coalesce the three-phase formula with a default value.""" + return Formula3PhaseBuilder(self, create_method=self._create_method).coalesce( + other + ) + + def min( + self, + other: list[ + Formula3PhaseBuilder[QuantityT] + | Formula3Phase[QuantityT] + | tuple[QuantityT, QuantityT, QuantityT] + ], + ) -> Formula3PhaseBuilder[QuantityT]: + """Get the minimum of the three-phase formula with other formulas.""" + return Formula3PhaseBuilder(self, create_method=self._create_method).min(other) + + def max( + self, + other: list[ + Formula3PhaseBuilder[QuantityT] + | Formula3Phase[QuantityT] + | tuple[QuantityT, QuantityT, QuantityT] + ], + ) -> Formula3PhaseBuilder[QuantityT]: + """Get the maximum of the three-phase formula with other formulas.""" + return Formula3PhaseBuilder(self, create_method=self._create_method).max(other) + + +class Formula3PhaseBuilder(Generic[QuantityT]): + """Builder for three-phase formulas.""" + + def __init__( + self, + formula: ( + Formula3Phase[QuantityT] + | tuple[ + FormulaBuilder[QuantityT], + FormulaBuilder[QuantityT], + FormulaBuilder[QuantityT], + ] + ), + create_method: Callable[[float], QuantityT], + sub_formulas: list[Formula3Phase[QuantityT]] | None = None, + ) -> None: + """Initialize this instance.""" + self._create_method: Callable[[float], QuantityT] = create_method + self._sub_formulas: list[Formula3Phase[QuantityT]] = sub_formulas or [] + + if isinstance(formula, Formula3Phase): + self._sub_formulas.append(formula) + self.root: tuple[ + FormulaBuilder[QuantityT], + FormulaBuilder[QuantityT], + FormulaBuilder[QuantityT], + ] = ( + FormulaBuilder(formula._formula_p1, create_method=self._create_method), + FormulaBuilder(formula._formula_p2, create_method=self._create_method), + FormulaBuilder(formula._formula_p3, create_method=self._create_method), + ) + else: + self.root = formula + + def __add__( + self, + other: Formula3PhaseBuilder[QuantityT] | Formula3Phase[QuantityT], + ) -> Formula3PhaseBuilder[QuantityT]: + """Add two three-phase formulas. + + Args: + other: The other formula to add. + + Returns: + A new three-phase formula builder representing the sum. + """ + if isinstance(other, Formula3Phase): + other = Formula3PhaseBuilder( + other, + create_method=self._create_method, + ) + new_sub_formulas = self._sub_formulas + other._sub_formulas + return Formula3PhaseBuilder( + ( + self.root[0] + other.root[0], + self.root[1] + other.root[1], + self.root[2] + other.root[2], + ), + create_method=self._create_method, + sub_formulas=new_sub_formulas, + ) + + def __sub__( + self, + other: Formula3PhaseBuilder[QuantityT] | Formula3Phase[QuantityT], + ) -> Formula3PhaseBuilder[QuantityT]: + """Subtract two three-phase formulas. + + Args: + other: The other formula to subtract. + + Returns: + A new three-phase formula builder representing the difference. + """ + if isinstance(other, Formula3Phase): + other = Formula3PhaseBuilder( + other, + create_method=self._create_method, + ) + new_sub_formulas = self._sub_formulas + other._sub_formulas + return Formula3PhaseBuilder( + ( + self.root[0] - other.root[0], + self.root[1] - other.root[1], + self.root[2] - other.root[2], + ), + create_method=self._create_method, + sub_formulas=new_sub_formulas, + ) + + def __mul__( + self, + scalar: float, + ) -> Formula3PhaseBuilder[QuantityT]: + """Multiply the three-phase formula by a scalar. + + Args: + scalar: The scalar to multiply by. + + Returns: + A new three-phase formula builder representing the product. + """ + return Formula3PhaseBuilder( + ( + self.root[0] * scalar, + self.root[1] * scalar, + self.root[2] * scalar, + ), + create_method=self._create_method, + sub_formulas=self._sub_formulas, + ) + + def __truediv__( + self, + scalar: float, + ) -> Formula3PhaseBuilder[QuantityT]: + """Divide the three-phase formula by a scalar. + + Args: + scalar: The scalar to divide by. + + Returns: + A new three-phase formula builder representing the quotient. + """ + return Formula3PhaseBuilder( + ( + self.root[0] / scalar, + self.root[1] / scalar, + self.root[2] / scalar, + ), + create_method=self._create_method, + sub_formulas=self._sub_formulas, + ) + + def coalesce( + self, + others: list[ + Formula3PhaseBuilder[QuantityT] + | Formula3Phase[QuantityT] + | tuple[QuantityT, QuantityT, QuantityT] + ], + ) -> Formula3PhaseBuilder[QuantityT]: + """Coalesce the three-phase formula with a default value. + + Args: + others: The default value to use when the formula evaluates to None. + + Returns: + A new three-phase formula builder representing the coalesced formula. + """ + right_nodes_phase_1: list[ + Formula[QuantityT] | QuantityT | FormulaBuilder[QuantityT] + ] = [] + right_nodes_phase_2: list[ + Formula[QuantityT] | QuantityT | FormulaBuilder[QuantityT] + ] = [] + right_nodes_phase_3: list[ + Formula[QuantityT] | QuantityT | FormulaBuilder[QuantityT] + ] = [] + sub_formulas: list[Formula3Phase[QuantityT]] = [] + sub_formulas.extend(self._sub_formulas) + + for item in others: + if isinstance(item, tuple): + right_nodes_phase_1.append(item[0]) + right_nodes_phase_2.append(item[1]) + right_nodes_phase_3.append(item[2]) + elif isinstance(item, Formula3Phase): + right_nodes_phase_1.append( + FormulaBuilder( + item._formula_p1, # pylint: disable=protected-access + create_method=self._create_method, + ) + ) + right_nodes_phase_2.append( + FormulaBuilder( + item._formula_p2, # pylint: disable=protected-access + create_method=self._create_method, + ) + ) + right_nodes_phase_3.append( + FormulaBuilder( + item._formula_p3, # pylint: disable=protected-access + create_method=self._create_method, + ) + ) + sub_formulas.append(item) + else: + right_nodes_phase_1.append(item.root[0]) + right_nodes_phase_2.append(item.root[1]) + right_nodes_phase_3.append(item.root[2]) + return Formula3PhaseBuilder( + ( + self.root[0].coalesce(right_nodes_phase_1), + self.root[1].coalesce(right_nodes_phase_2), + self.root[2].coalesce(right_nodes_phase_3), + ), + create_method=self._create_method, + sub_formulas=sub_formulas, + ) + + def min( + self, + others: list[ + Formula3PhaseBuilder[QuantityT] + | Formula3Phase[QuantityT] + | tuple[QuantityT, QuantityT, QuantityT] + ], + ) -> Formula3PhaseBuilder[QuantityT]: + """Get the minimum of the three-phase formula with other formulas. + + Args: + others: The other formulas to compare with. + + Returns: + A new three-phase formula builder representing the minimum. + """ + right_nodes_phase_1: list[ + Formula[QuantityT] | QuantityT | FormulaBuilder[QuantityT] + ] = [] + right_nodes_phase_2: list[ + Formula[QuantityT] | QuantityT | FormulaBuilder[QuantityT] + ] = [] + right_nodes_phase_3: list[ + Formula[QuantityT] | QuantityT | FormulaBuilder[QuantityT] + ] = [] + sub_formulas: list[Formula3Phase[QuantityT]] = [] + sub_formulas.extend(self._sub_formulas) + + for item in others: + if isinstance(item, tuple): + right_nodes_phase_1.append(item[0]) + right_nodes_phase_2.append(item[1]) + right_nodes_phase_3.append(item[2]) + elif isinstance(item, Formula3Phase): + right_nodes_phase_1.append( + FormulaBuilder( + item._formula_p1, # pylint: disable=protected-access + create_method=self._create_method, + ) + ) + right_nodes_phase_2.append( + FormulaBuilder( + item._formula_p2, # pylint: disable=protected-access + create_method=self._create_method, + ) + ) + right_nodes_phase_3.append( + FormulaBuilder( + item._formula_p3, # pylint: disable=protected-access + create_method=self._create_method, + ) + ) + sub_formulas.append(item) + else: + right_nodes_phase_1.append(item.root[0]) + right_nodes_phase_2.append(item.root[1]) + right_nodes_phase_3.append(item.root[2]) + return Formula3PhaseBuilder( + ( + self.root[0].min(right_nodes_phase_1), + self.root[1].min(right_nodes_phase_2), + self.root[2].min(right_nodes_phase_3), + ), + create_method=self._create_method, + sub_formulas=sub_formulas, + ) + + def max( + self, + others: list[ + Formula3PhaseBuilder[QuantityT] + | Formula3Phase[QuantityT] + | tuple[QuantityT, QuantityT, QuantityT] + ], + ) -> Formula3PhaseBuilder[QuantityT]: + """Get the maximum of the three-phase formula with other formulas. + + Args: + others: The other formulas to compare with. + + Returns: + A new three-phase formula builder representing the maximum. + """ + right_nodes_phase_1: list[ + Formula[QuantityT] | QuantityT | FormulaBuilder[QuantityT] + ] = [] + right_nodes_phase_2: list[ + Formula[QuantityT] | QuantityT | FormulaBuilder[QuantityT] + ] = [] + right_nodes_phase_3: list[ + Formula[QuantityT] | QuantityT | FormulaBuilder[QuantityT] + ] = [] + sub_formulas: list[Formula3Phase[QuantityT]] = [] + sub_formulas.extend(self._sub_formulas) + + for item in others: + if isinstance(item, tuple): + right_nodes_phase_1.append(item[0]) + right_nodes_phase_2.append(item[1]) + right_nodes_phase_3.append(item[2]) + elif isinstance(item, Formula3Phase): + right_nodes_phase_1.append( + FormulaBuilder( + item._formula_p1, # pylint: disable=protected-access + create_method=self._create_method, + ) + ) + right_nodes_phase_2.append( + FormulaBuilder( + item._formula_p2, # pylint: disable=protected-access + create_method=self._create_method, + ) + ) + right_nodes_phase_3.append( + FormulaBuilder( + item._formula_p3, # pylint: disable=protected-access + create_method=self._create_method, + ) + ) + sub_formulas.append(item) + else: + right_nodes_phase_1.append(item.root[0]) + right_nodes_phase_2.append(item.root[1]) + right_nodes_phase_3.append(item.root[2]) + return Formula3PhaseBuilder( + ( + self.root[0].max(right_nodes_phase_1), + self.root[1].max(right_nodes_phase_2), + self.root[2].max(right_nodes_phase_3), + ), + create_method=self._create_method, + sub_formulas=sub_formulas, + ) + + def build(self, name: str) -> Formula3Phase[QuantityT]: + """Build the three-phase formula. + + Args: + name: The name of the formula. + + Returns: + The built three-phase formula. + """ + phase_1_formula = self.root[0].build(name + "_phase_1") + phase_2_formula = self.root[1].build(name + "_phase_2") + phase_3_formula = self.root[2].build(name + "_phase_3") + + return Formula3Phase( + name=name, + phase_1=phase_1_formula, + phase_2=phase_2_formula, + phase_3=phase_3_formula, + sub_formulas=self._sub_formulas, + ) diff --git a/src/frequenz/sdk/timeseries/formulas/_formula_3_phase_evaluator.py b/src/frequenz/sdk/timeseries/formulas/_formula_3_phase_evaluator.py new file mode 100644 index 000000000..8726dc461 --- /dev/null +++ b/src/frequenz/sdk/timeseries/formulas/_formula_3_phase_evaluator.py @@ -0,0 +1,108 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""An evaluator for three-phase formulas.""" + +import asyncio +import logging +from typing import Generic + +from frequenz.channels import Broadcast, ReceiverStoppedError, Sender +from typing_extensions import override + +from ...actor import Actor +from .._base_types import QuantityT, Sample3Phase +from . import _ast +from ._formula import Formula +from ._formula_evaluator import synchronize_receivers + +_logger = logging.getLogger(__name__) + + +class Formula3PhaseEvaluatingActor(Generic[QuantityT], Actor): + """An evaluator for three-phase formulas.""" + + def __init__( + self, + phase_1: Formula[QuantityT], + phase_2: Formula[QuantityT], + phase_3: Formula[QuantityT], + output_channel: Broadcast[Sample3Phase[QuantityT]], + ) -> None: + """Initialize this instance. + + Args: + phase_1: The formula for phase 1 + phase_2: The formula for phase 2. + phase_3: The formula for phase 3. + output_channel: The channel to send evaluated samples to. + """ + super().__init__() + + self._phase_1_formula: Formula[QuantityT] = phase_1 + self._phase_2_formula: Formula[QuantityT] = phase_2 + self._phase_3_formula: Formula[QuantityT] = phase_3 + self._components: list[_ast.TelemetryStream[QuantityT]] = [ + _ast.TelemetryStream( + None, + "phase_1", + phase_1.new_receiver(), + ), + _ast.TelemetryStream( + None, + "phase_2", + phase_2.new_receiver(), + ), + _ast.TelemetryStream( + None, + "phase_3", + phase_3.new_receiver(), + ), + ] + self._output_channel: Broadcast[Sample3Phase[QuantityT]] = output_channel + self._output_sender: Sender[Sample3Phase[QuantityT]] = ( + self._output_channel.new_sender() + ) + + @override + async def _run(self) -> None: + """Run the three-phase formula evaluator actor.""" + await synchronize_receivers(self._components) + + while True: + phase_1_sample = self._components[0].latest_sample + phase_2_sample = self._components[1].latest_sample + phase_3_sample = self._components[2].latest_sample + + if ( + phase_1_sample is None + or phase_2_sample is None + or phase_3_sample is None + ): + _logger.debug( + "One of the three phase samples is None, stopping the evaluator." + ) + await self._output_channel.close() + return + + sample_3phase = Sample3Phase( + timestamp=phase_1_sample.timestamp, + value_p1=phase_1_sample.value, + value_p2=phase_2_sample.value, + value_p3=phase_3_sample.value, + ) + + await self._output_sender.send(sample_3phase) + + fetch_results = await asyncio.gather( + *(comp.fetch_next() for comp in self._components), + return_exceptions=True, + ) + if e := next((e for e in fetch_results if isinstance(e, Exception)), None): + if isinstance(e, (StopAsyncIteration, ReceiverStoppedError)): + _logger.debug( + "input streams closed; stopping three-phase formula evaluator." + ) + await self._output_channel.close() + return + raise e diff --git a/tests/timeseries/_formulas/test_formulas_3_phase.py b/tests/timeseries/_formulas/test_formulas_3_phase.py new file mode 100644 index 000000000..c4f832620 --- /dev/null +++ b/tests/timeseries/_formulas/test_formulas_3_phase.py @@ -0,0 +1,153 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for three phase formulas.""" + + +import asyncio +from collections import OrderedDict +from collections.abc import Callable +from datetime import datetime +from unittest.mock import MagicMock + +from frequenz.channels import Broadcast, Receiver +from frequenz.quantities import Quantity + +from frequenz.sdk.timeseries import Sample +from frequenz.sdk.timeseries.formulas._formula_3_phase import ( + Formula3Phase, + Formula3PhaseBuilder, +) +from frequenz.sdk.timeseries.formulas._parser import parse +from frequenz.sdk.timeseries.formulas._resampled_stream_fetcher import ( + ResampledStreamFetcher, +) + + +class TestFormula3Phase: + """Tests for 3-phase formulas.""" + + async def run_test( + self, + io_pairs: list[ + tuple[ + list[tuple[float | None, float | None, float | None]], + tuple[float | None, float | None, float | None], + ] + ], + make_builder: Callable[ + [Formula3Phase[Quantity], Formula3Phase[Quantity]], + Formula3PhaseBuilder[Quantity], + ], + ) -> None: + """Run a test for 3-phase formulas.""" + channels: OrderedDict[int, Broadcast[Sample[Quantity]]] = OrderedDict() + + def stream_recv(comp_id: int) -> Receiver[Sample[Quantity]]: + comp_id = int(comp_id) + if comp_id not in channels: + channels[comp_id] = Broadcast(name=f"chan-#{comp_id}") + return channels[comp_id].new_receiver() + + telem_fetcher = MagicMock(spec=ResampledStreamFetcher) + telem_fetcher.fetch_stream = MagicMock(side_effect=stream_recv) + l1_formulas = [ + parse( + name=str(ctr), + formula=f"#{ctr}", + create_method=Quantity, + telemetry_fetcher=telem_fetcher, + ) + for ctr in range(6) + ] + p3_formula_1 = Formula3Phase( + name="P3-1", + phase_1=l1_formulas[0], + phase_2=l1_formulas[1], + phase_3=l1_formulas[2], + ) + p3_formula_2 = Formula3Phase( + name="P3-2", + phase_1=l1_formulas[3], + phase_2=l1_formulas[4], + phase_3=l1_formulas[5], + ) + builder = make_builder(p3_formula_1, p3_formula_2) + formula = builder.build("l2") + receiver = formula.new_receiver() + + now = datetime.now() + for inputs, expected_output in io_pairs: + _ = await asyncio.gather( + *[ + channels[formula_id * 3 + phase_idx] + .new_sender() + .send( + Sample( + timestamp=now, + value=( + None if input_value is None else Quantity(input_value) + ), + ) + ) + for formula_id, input_values in enumerate(inputs) + for phase_idx, input_value in enumerate(input_values) + ] + ) + output = list(iter(await receiver.receive())) + for phase_idx, expected_value in enumerate(expected_output): + if expected_value is None: + assert output[phase_idx] is None + else: + phase_value = output[phase_idx] + assert phase_value is not None + assert phase_value.base_value == expected_value + + async def test_composition(self) -> None: + """Test a simple 3-phase formula.""" + await self.run_test( + [ + ([(1.0, 2.0, 3.0), (2.0, 3.0, 4.0)], (6.0, 10.0, 14.0)), + ([(-5.0, 10.0, 15.0), (10.0, 15.0, -20.0)], (10.0, 50.0, -10.0)), + ([(None, 2.0, 3.0), (2.0, None, 4.0)], (None, None, 14.0)), + ], + lambda f1, f2: (f1 + f2) * 2.0, + ) + + await self.run_test( + [ + ([(4.0, 9.0, 16.0), (2.0, 3.0, 4.0)], (1.0, 3.0, 6.0)), + ([(-5.0, 10.0, 15.0), (10.0, 5.0, -20.0)], (-7.5, 2.5, 17.5)), + ([(None, 2.0, 3.0), (2.0, None, 4.0)], (None, None, -0.5)), + ], + lambda f1, f2: (f1 - f2) / 2.0, + ) + + await self.run_test( + [ + ([(4.0, 9.0, 16.0), (2.0, 13.0, 4.0)], (4.0, 13.0, 16.0)), + ([(-5.0, 10.0, 15.0), (10.0, 5.0, -20.0)], (10.0, 10.0, 15.0)), + ([(None, 2.0, 3.0), (2.0, None, 4.0)], (None, None, 4.0)), + ], + lambda f1, f2: f1.max([f2]), + ) + + await self.run_test( + [ + ([(4.0, 9.0, 16.0), (2.0, 13.0, 4.0)], (2.0, 9.0, 4.0)), + ([(-5.0, 10.0, 15.0), (10.0, 5.0, -20.0)], (-5.0, 5.0, -20.0)), + ([(None, 2.0, 3.0), (2.0, None, 4.0)], (None, None, 3.0)), + ], + lambda f1, f2: f1.min([f2]), + ) + + await self.run_test( + [ + ([(4.0, 9.0, 16.0), (2.0, 13.0, 4.0)], (4.0, 9.0, 16.0)), + ([(-5.0, 10.0, None), (10.0, 5.0, None)], (-5.0, 10.0, 0.0)), + ([(None, 2.0, 3.0), (2.0, None, 4.0)], (2.0, 2.0, 3.0)), + ], + lambda f1, f2: f1.coalesce( + [f2, (Quantity.zero(), Quantity.zero(), Quantity.zero())] + ), + ) From e23af0a3c0559e6d81ca9daedc0ba1f15d549f24 Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Thu, 20 Nov 2025 18:19:38 +0100 Subject: [PATCH 10/23] Add a formula pool for storing and reusing formulas Signed-off-by: Sahas Subramanian --- .../sdk/timeseries/formulas/_formula_pool.py | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 src/frequenz/sdk/timeseries/formulas/_formula_pool.py diff --git a/src/frequenz/sdk/timeseries/formulas/_formula_pool.py b/src/frequenz/sdk/timeseries/formulas/_formula_pool.py new file mode 100644 index 000000000..ca6a3cf7a --- /dev/null +++ b/src/frequenz/sdk/timeseries/formulas/_formula_pool.py @@ -0,0 +1,282 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""A formula pool for helping with tracking running formulas.""" + + +import logging +import sys + +from frequenz.channels import Sender +from frequenz.client.microgrid.metrics import Metric +from frequenz.quantities import Current, Power, Quantity, ReactivePower + +from frequenz.sdk.timeseries.formulas._resampled_stream_fetcher import ( + ResampledStreamFetcher, +) + +from ..._internal._channels import ChannelRegistry +from ...microgrid._data_sourcing import ComponentMetricRequest +from ._formula import Formula +from ._formula_3_phase import Formula3Phase +from ._parser import parse + +_logger = logging.getLogger(__name__) + + +NON_EXISTING_COMPONENT_ID = sys.maxsize +"""The component ID for non-existent components in the components graph. + +The non-existing component ID is commonly used in scenarios where a formula +engine requires a component ID but there are no available components in the +graph to associate with it. Thus, the non-existing component ID is subscribed +instead so that the formula engine can send `None` or `0` values at the same +frequency as the other streams. +""" + + +class FormulaPool: + """Creates and owns formula engines from string formulas, or formula generators. + + If an engine already exists with a given name, it is reused instead. + """ + + def __init__( + self, + namespace: str, + channel_registry: ChannelRegistry, + resampler_subscription_sender: Sender[ComponentMetricRequest], + ) -> None: + """Create a new instance. + + Args: + namespace: namespace to use with the data pipeline. + channel_registry: A channel registry instance shared with the resampling + actor. + resampler_subscription_sender: A sender for sending metric requests to the + resampling actor. + """ + self._namespace: str = namespace + self._channel_registry: ChannelRegistry = channel_registry + self._resampler_subscription_sender: Sender[ComponentMetricRequest] = ( + resampler_subscription_sender + ) + + self._string_engines: dict[str, Formula[Quantity]] = {} + self._power_engines: dict[str, Formula[Power]] = {} + self._reactive_power_engines: dict[str, Formula[ReactivePower]] = {} + self._current_engines: dict[str, Formula3Phase[Current]] = {} + + self._power_3_phase_engines: dict[str, Formula3Phase[Power]] = {} + self._current_3_phase_engines: dict[str, Formula3Phase[Current]] = {} + + def from_string( + self, + formula_str: str, + metric: Metric, + ) -> Formula[Quantity]: + """Get a receiver for a manual formula. + + Args: + formula_str: formula to execute. + metric: The metric to use when fetching receivers from the resampling + actor. + + Returns: + A Formula engine that streams values with the formulas applied. + """ + channel_key = formula_str + str(metric.value) + if channel_key in self._string_engines: + return self._string_engines[channel_key] + formula = parse( + name=channel_key, + formula=formula_str, + telemetry_fetcher=self._telemetry_fetcher(metric), + create_method=Quantity, + ) + self._string_engines[channel_key] = formula + return formula + + def from_power_formula(self, channel_key: str, formula_str: str) -> Formula[Power]: + """Get a receiver from the formula represented by the given strings. + + Args: + channel_key: A string to uniquely identify the formula. This + usually includes the formula itself and the metric. + formula_str: The formula string. + + Returns: + A formula engine that evaluates the given formula. + """ + if channel_key in self._power_engines: + return self._power_engines[channel_key] + + if formula_str == "0.0": + formula_str = f"coalesce(#{NON_EXISTING_COMPONENT_ID}, 0.0)" + + formula = parse( + name=channel_key, + formula=formula_str, + telemetry_fetcher=self._telemetry_fetcher(Metric.AC_POWER_ACTIVE), + create_method=Power.from_watts, + ) + self._power_engines[channel_key] = formula + + return formula + + def from_reactive_power_formula( + self, channel_key: str, formula_str: str + ) -> Formula[ReactivePower]: + """Get a receiver from the formula represented by the given strings. + + Args: + channel_key: A string to uniquely identify the formula. This + usually includes the formula itself and the metric. + formula_str: The formula string. + + Returns: + A formula engine that evaluates the given formula. + """ + if channel_key in self._power_engines: + return self._reactive_power_engines[channel_key] + + if formula_str == "0.0": + formula_str = f"coalesce(#{NON_EXISTING_COMPONENT_ID}, 0.0)" + + formula = parse( + name=channel_key, + formula=formula_str, + telemetry_fetcher=self._telemetry_fetcher(Metric.AC_POWER_REACTIVE), + create_method=ReactivePower.from_volt_amperes_reactive, + ) + self._reactive_power_engines[channel_key] = formula + + return formula + + def from_power_3_phase_formula( + self, channel_key: str, formula_str: str + ) -> Formula3Phase[Power]: + """Get a receiver from the 3-phase power formula represented by the given strings. + + Args: + channel_key: A string to uniquely identify the formula. This + usually includes the formula itself. + formula_str: The formula string. + + Returns: + A formula engine that evaluates the given formula. + """ + if channel_key in self._power_3_phase_engines: + return self._power_3_phase_engines[channel_key] + + if formula_str == "0.0": + formula_str = f"coalesce(#{NON_EXISTING_COMPONENT_ID}, 0.0)" + + formula = Formula3Phase( + name=channel_key, + phase_1=parse( + name=channel_key + "_phase_1", + formula=formula_str, + telemetry_fetcher=self._telemetry_fetcher( + Metric.AC_POWER_ACTIVE_PHASE_1 + ), + create_method=Power.from_watts, + ), + phase_2=parse( + name=channel_key + "_phase_2", + formula=formula_str, + telemetry_fetcher=self._telemetry_fetcher( + Metric.AC_POWER_ACTIVE_PHASE_2 + ), + create_method=Power.from_watts, + ), + phase_3=parse( + name=channel_key + "_phase_3", + formula=formula_str, + telemetry_fetcher=self._telemetry_fetcher( + Metric.AC_POWER_ACTIVE_PHASE_3 + ), + create_method=Power.from_watts, + ), + ) + self._power_3_phase_engines[channel_key] = formula + + return formula + + def from_current_3_phase_formula( + self, channel_key: str, formula_str: str + ) -> Formula3Phase[Current]: + """Get a receiver from the 3-phase current formula represented by the given strings. + + Args: + channel_key: A string to uniquely identify the formula. This + usually includes the formula itself. + formula_str: The formula string. + + Returns: + A formula engine that evaluates the given formula. + """ + if channel_key in self._current_3_phase_engines: + return self._current_3_phase_engines[channel_key] + + if formula_str == "0.0": + formula_str = f"coalesce(#{NON_EXISTING_COMPONENT_ID}, 0.0)" + + formula = Formula3Phase( + name=channel_key, + phase_1=parse( + name=channel_key + "_phase_1", + formula=formula_str, + telemetry_fetcher=self._telemetry_fetcher(Metric.AC_CURRENT_PHASE_1), + create_method=Current.from_amperes, + ), + phase_2=parse( + name=channel_key + "_phase_2", + formula=formula_str, + telemetry_fetcher=self._telemetry_fetcher(Metric.AC_CURRENT_PHASE_2), + create_method=Current.from_amperes, + ), + phase_3=parse( + name=channel_key + "_phase_3", + formula=formula_str, + telemetry_fetcher=self._telemetry_fetcher(Metric.AC_CURRENT_PHASE_3), + create_method=Current.from_amperes, + ), + ) + self._current_3_phase_engines[channel_key] = formula + + return formula + + async def stop(self) -> None: + """Stop all formula engines.""" + for pf in self._power_engines.values(): + await pf.stop() + self._power_engines.clear() + + for rpf in self._reactive_power_engines.values(): + await rpf.stop() + self._reactive_power_engines.clear() + + for p3pf in self._power_3_phase_engines.values(): + await p3pf.stop() + self._power_3_phase_engines.clear() + + for c3pf in self._current_3_phase_engines.values(): + await c3pf.stop() + self._current_3_phase_engines.clear() + + def _telemetry_fetcher(self, metric: Metric) -> ResampledStreamFetcher: + """Create a ResampledStreamFetcher for the given metric. + + Args: + metric: The metric to fetch. + + Returns: + A ResampledStreamFetcher for the given metric. + """ + return ResampledStreamFetcher( + self._namespace, + self._channel_registry, + self._resampler_subscription_sender, + metric, + ) From 465f914939f88e2653f8b4ab54193d7d8ffc5f5d Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Thu, 20 Nov 2025 19:14:32 +0100 Subject: [PATCH 11/23] Add `frequenz-microgrid-component-graph` as a dependency This will replace the SDK's old component graph and the formula generators. This commit also adds some graph traversal methods that are not provided by the component graph library yet. Signed-off-by: Sahas Subramanian --- pyproject.toml | 3 +- .../sdk/_internal/_graph_traversal.py | 241 ++++++++++++++++++ 2 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 src/frequenz/sdk/_internal/_graph_traversal.py diff --git a/pyproject.toml b/pyproject.toml index ef1aa0573..330c4eaff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,12 +30,13 @@ dependencies = [ # changing the version # (plugins.mkdocstrings.handlers.python.import) "frequenz-client-microgrid >= 0.18.0, < 0.19.0", + "frequenz-microgrid-component-graph >= 0.2.0, < 0.3", "frequenz-client-common >= 0.3.6, < 0.4.0", "frequenz-channels >= 1.6.1, < 2.0.0", "frequenz-quantities[marshmallow] >= 1.0.0, < 2.0.0", "networkx >= 2.8, < 4", "numpy >= 2.1.0, < 3", - "typing_extensions >= 4.13.0, < 5", + "typing_extensions >= 4.14.1, < 5", "marshmallow >= 3.19.0, < 5", "marshmallow_dataclass >= 8.7.1, < 9", ] diff --git a/src/frequenz/sdk/_internal/_graph_traversal.py b/src/frequenz/sdk/_internal/_graph_traversal.py new file mode 100644 index 000000000..0eb70fc81 --- /dev/null +++ b/src/frequenz/sdk/_internal/_graph_traversal.py @@ -0,0 +1,241 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Graph traversal helpers.""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Callable + +from frequenz.client.common.microgrid.components import ComponentId +from frequenz.client.microgrid.component import ( + BatteryInverter, + Chp, + Component, + ComponentConnection, + EvCharger, + GridConnectionPoint, + SolarInverter, +) +from frequenz.microgrid_component_graph import ComponentGraph, InvalidGraphError + + +def is_pv_inverter(component: Component) -> bool: + """Check if the component is a PV inverter. + + Args: + component: The component to check. + + Returns: + `True` if the component is a PV inverter, `False` otherwise. + """ + return isinstance(component, SolarInverter) + + +def is_battery_inverter(component: Component) -> bool: + """Check if the component is a battery inverter. + + Args: + component: The component to check. + + Returns: + `True` if the component is a battery inverter, `False` otherwise. + """ + return isinstance(component, BatteryInverter) + + +def is_chp(component: Component) -> bool: + """Check if the component is a CHP. + + Args: + component: The component to check. + + Returns: + `True` if the component is a CHP, `False` otherwise. + """ + return isinstance(component, Chp) + + +def is_ev_charger(component: Component) -> bool: + """Check if the component is an EV charger. + + Args: + component: The component to check. + + Returns: + `True` if the component is an EV charger, `False` otherwise. + """ + return isinstance(component, EvCharger) + + +def is_battery_chain( + graph: ComponentGraph[Component, ComponentConnection, ComponentId], + component: Component, +) -> bool: + """Check if the specified component is part of a battery chain. + + A component is part of a battery chain if it is either a battery inverter or a + battery meter. + + Args: + graph: The component graph. + component: component to check. + + Returns: + Whether the specified component is part of a battery chain. + """ + return is_battery_inverter(component) or graph.is_battery_meter(component.id) + + +def is_pv_chain( + graph: ComponentGraph[Component, ComponentConnection, ComponentId], + component: Component, +) -> bool: + """Check if the specified component is part of a PV chain. + + A component is part of a PV chain if it is either a PV inverter or a PV + meter. + + Args: + graph: The component graph. + component: component to check. + + Returns: + Whether the specified component is part of a PV chain. + """ + return is_pv_inverter(component) or graph.is_pv_meter(component.id) + + +def is_ev_charger_chain( + graph: ComponentGraph[Component, ComponentConnection, ComponentId], + component: Component, +) -> bool: + """Check if the specified component is part of an EV charger chain. + + A component is part of an EV charger chain if it is either an EV charger or an + EV charger meter. + + Args: + graph: The component graph. + component: component to check. + + Returns: + Whether the specified component is part of an EV charger chain. + """ + return is_ev_charger(component) or graph.is_ev_charger_meter(component.id) + + +def is_chp_chain( + graph: ComponentGraph[Component, ComponentConnection, ComponentId], + component: Component, +) -> bool: + """Check if the specified component is part of a CHP chain. + + A component is part of a CHP chain if it is either a CHP or a CHP meter. + + Args: + graph: The component graph. + component: component to check. + + Returns: + Whether the specified component is part of a CHP chain. + """ + return is_chp(component) or graph.is_chp_meter(component.id) + + +def dfs( + graph: ComponentGraph[Component, ComponentConnection, ComponentId], + current_node: Component, + visited: set[Component], + condition: Callable[[Component], bool], +) -> set[Component]: + """ + Search for components that fulfill the condition in the Graph. + + DFS is used for searching the graph. The graph traversal is stopped + once a component fulfills the condition. + + Args: + graph: The component graph. + current_node: The current node to search from. + visited: The set of visited nodes. + condition: The condition function to check for. + + Returns: + A set of component ids where the corresponding components fulfill + the condition function. + """ + if current_node in visited: + return set() + + visited.add(current_node) + + if condition(current_node): + return {current_node} + + component: set[Component] = set() + + for successor in graph.successors(current_node.id): + component.update(dfs(graph, successor, visited, condition)) + + return component + + +def find_first_descendant_component( + graph: ComponentGraph[Component, ComponentConnection, ComponentId], + *, + descendants: Iterable[type[Component]], +) -> Component: + """Find the first descendant component given root and descendant categories. + + This method looks for the first descendant component from the GRID + component, considering only the immediate descendants. + + The priority of the component to search for is determined by the order + of the descendant categories, with the first category having the + highest priority. + + Args: + graph: The component graph to search. + descendants: The descendant classes to search for the first + descendant component in. + + Returns: + The first descendant component found in the component graph, + considering the specified `descendants` categories. + + Raises: + InvalidGraphError: When no GRID component is found in the graph. + ValueError: When no component is found in the given categories. + """ + # We always sort by component ID to ensure consistent results + + def sorted_by_id(components: Iterable[Component]) -> Iterable[Component]: + return sorted(components, key=lambda c: c.id) + + root_component = next( + iter(sorted_by_id(graph.components(matching_types={GridConnectionPoint}))), + None, + ) + if root_component is None: + raise InvalidGraphError( + "No GridConnectionPoint component found in the component graph!" + ) + + successors = sorted_by_id(graph.successors(root_component.id)) + + def find_component(component_class: type[Component]) -> Component | None: + return next( + (comp for comp in successors if isinstance(comp, component_class)), + None, + ) + + # Find the first component that matches the given descendant categories + # in the order of the categories list. + component = next(filter(None, map(find_component, descendants)), None) + + if component is None: + raise ValueError("Component not found in any of the descendant categories.") + + return component From 7b0fedfcb2eca829da9c3ff5c989cefc386d82da Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Thu, 20 Nov 2025 19:32:18 +0100 Subject: [PATCH 12/23] Remove test for island-mode Graphs in island mode are not supported yet. Earlier, this test was using an `Unspecified` component category as a junction node, to start traversing from, which is inaccurate. The `Unspecified` component should always be an error, to identify an unfilled category field, due to a bug. Signed-off-by: Sahas Subramanian --- tests/microgrid/test_grid.py | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/tests/microgrid/test_grid.py b/tests/microgrid/test_grid.py index 31c724797..5453d84bf 100644 --- a/tests/microgrid/test_grid.py +++ b/tests/microgrid/test_grid.py @@ -29,32 +29,6 @@ _MICROGRID_ID = MicrogridId(1) -async def test_grid_1(mocker: MockerFixture) -> None: - """Test the grid connection module.""" - # The tests here need to be in this exact sequence, because the grid connection - # is a singleton. Once it gets created, it stays in memory for the duration of - # the tests, unless we explicitly delete it. - - # validate that islands with no grid connection are accepted. - unspec_1 = UnspecifiedComponent(id=ComponentId(1), microgrid_id=_MICROGRID_ID) - meter_2 = Meter(id=ComponentId(2), microgrid_id=_MICROGRID_ID) - components = {unspec_1, meter_2} - connections = {ComponentConnection(source=unspec_1.id, destination=meter_2.id)} - - graph = gr._MicrogridComponentGraph( # pylint: disable=protected-access - components=components, connections=connections - ) - - async with MockMicrogrid(graph=graph, mocker=mocker), AsyncExitStack() as stack: - grid = microgrid.grid() - assert grid is not None - stack.push_async_callback(grid.stop) - - assert grid - assert grid.fuse - assert grid.fuse.max_current == Current.from_amperes(0.0) - - async def test_grid_2(mocker: MockerFixture) -> None: """Validate that microgrids with one grid connection are accepted.""" grid_1 = GridConnectionPoint( From b37b5f5e1cdc85f183675c9c67ad5d5186d0db6d Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Thu, 20 Nov 2025 19:40:29 +0100 Subject: [PATCH 13/23] Switch to use the external component graph Signed-off-by: Sahas Subramanian --- .../sdk/microgrid/connection_manager.py | 35 +++++++++++++++---- .../sdk/timeseries/_grid_frequency.py | 4 ++- .../sdk/timeseries/_voltage_streamer.py | 4 ++- .../_battery_power_formula.py | 5 +-- .../_consumer_power_formula.py | 35 +++++++++++-------- .../_formula_generators/_formula_generator.py | 26 ++++++++------ .../_producer_power_formula.py | 8 +++-- .../_formula_generators/_pv_power_formula.py | 6 ++-- tests/microgrid/fixtures.py | 15 +++++--- tests/microgrid/test_grid.py | 8 +++-- tests/timeseries/mock_microgrid.py | 8 +++-- tests/utils/component_graph_utils.py | 7 ++-- tests/utils/graph_generator.py | 13 +++---- tests/utils/mock_microgrid_client.py | 15 ++++---- 14 files changed, 122 insertions(+), 67 deletions(-) diff --git a/src/frequenz/sdk/microgrid/connection_manager.py b/src/frequenz/sdk/microgrid/connection_manager.py index 33fe3fc50..7cb39d16f 100644 --- a/src/frequenz/sdk/microgrid/connection_manager.py +++ b/src/frequenz/sdk/microgrid/connection_manager.py @@ -8,13 +8,19 @@ component graph. """ +import asyncio import logging from abc import ABC, abstractmethod from frequenz.client.common.microgrid import MicrogridId -from frequenz.client.microgrid import Location, MicrogridApiClient, MicrogridInfo - -from .component_graph import ComponentGraph, _MicrogridComponentGraph +from frequenz.client.common.microgrid.components import ComponentId +from frequenz.client.microgrid import ( + Location, + MicrogridApiClient, + MicrogridInfo, +) +from frequenz.client.microgrid.component import Component, ComponentConnection +from frequenz.microgrid_component_graph import ComponentGraph _logger = logging.getLogger(__name__) @@ -51,7 +57,9 @@ def api_client(self) -> MicrogridApiClient: @property @abstractmethod - def component_graph(self) -> ComponentGraph: + def component_graph( + self, + ) -> ComponentGraph[Component, ComponentConnection, ComponentId]: """Get component graph. Returns: @@ -101,7 +109,9 @@ def __init__(self, server_url: str) -> None: self._client = MicrogridApiClient(server_url) # To create graph from the API client we need await. # So create empty graph here, and update it in `run` method. - self._graph = _MicrogridComponentGraph() + self._graph: ( + ComponentGraph[Component, ComponentConnection, ComponentId] | None + ) = None self._microgrid: MicrogridInfo """The microgrid information.""" @@ -130,12 +140,19 @@ def location(self) -> Location | None: return self._microgrid.location @property - def component_graph(self) -> ComponentGraph: + def component_graph( + self, + ) -> ComponentGraph[Component, ComponentConnection, ComponentId]: """Get component graph. Returns: component graph + + Raises: + RuntimeError: If the microgrid is not initialized yet. """ + if self._graph is None: + raise RuntimeError("Microgrid not initialized yet.") return self._graph async def _update_client(self, server_url: str) -> None: @@ -156,7 +173,11 @@ async def _update_client(self, server_url: str) -> None: async def _initialize(self) -> None: self._microgrid = await self._client.get_microgrid_info() - await self._graph.refresh_from_client(self._client) + components, connections = await asyncio.gather( + self._client.list_components(), + self._client.list_connections(), + ) + self._graph = ComponentGraph(set(components), set(connections)) _CONNECTION_MANAGER: ConnectionManager | None = None diff --git a/src/frequenz/sdk/timeseries/_grid_frequency.py b/src/frequenz/sdk/timeseries/_grid_frequency.py index c3316a112..774b2a02b 100644 --- a/src/frequenz/sdk/timeseries/_grid_frequency.py +++ b/src/frequenz/sdk/timeseries/_grid_frequency.py @@ -15,6 +15,7 @@ from frequenz.quantities import Frequency, Quantity from .._internal._channels import ChannelRegistry +from .._internal._graph_traversal import find_first_descendant_component from ..microgrid import connection_manager from ..microgrid._data_sourcing import ComponentMetricRequest from ..timeseries._base_types import Sample @@ -54,7 +55,8 @@ def __init__( """ if not source: component_graph = connection_manager.get().component_graph - source = component_graph.find_first_descendant_component( + source = find_first_descendant_component( + component_graph, descendants=[Meter, Inverter, EvCharger], ) diff --git a/src/frequenz/sdk/timeseries/_voltage_streamer.py b/src/frequenz/sdk/timeseries/_voltage_streamer.py index 4dca53b92..4759b362f 100644 --- a/src/frequenz/sdk/timeseries/_voltage_streamer.py +++ b/src/frequenz/sdk/timeseries/_voltage_streamer.py @@ -19,6 +19,7 @@ from frequenz.quantities import Quantity, Voltage from .._internal._channels import ChannelRegistry +from .._internal._graph_traversal import find_first_descendant_component from ..timeseries._base_types import Sample, Sample3Phase if TYPE_CHECKING: @@ -81,7 +82,8 @@ def __init__( if not source_component: component_graph = connection_manager.get().component_graph - source_component = component_graph.find_first_descendant_component( + source_component = find_first_descendant_component( + component_graph, descendants=[Meter, Inverter, EvCharger], ) diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_battery_power_formula.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_battery_power_formula.py index 73d8e6d62..d806a73ee 100644 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_battery_power_formula.py +++ b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_battery_power_formula.py @@ -10,6 +10,7 @@ from frequenz.client.microgrid.metrics import Metric from frequenz.quantities import Power +from ...._internal._graph_traversal import is_battery_inverter from ....microgrid import connection_manager from ...formula_engine import FormulaEngine from ._fallback_formula_metric_fetcher import FallbackFormulaMetricFetcher @@ -73,7 +74,7 @@ def generate( for bat_id in component_ids: inverters = set( filter( - component_graph.is_battery_inverter, + is_battery_inverter, component_graph.predecessors(bat_id), ) ) @@ -84,7 +85,7 @@ def generate( ) for inverter in inverters: - all_connected_batteries = component_graph.successors(inverter.id) + all_connected_batteries = set(component_graph.successors(inverter.id)) battery_ids = set( map(lambda battery: battery.id, all_connected_batteries) ) diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_consumer_power_formula.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_consumer_power_formula.py index 23c842f15..d07b36edf 100644 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_consumer_power_formula.py +++ b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_consumer_power_formula.py @@ -9,6 +9,13 @@ from frequenz.client.microgrid.metrics import Metric from frequenz.quantities import Power +from ...._internal._graph_traversal import ( + dfs, + is_battery_chain, + is_chp_chain, + is_ev_charger_chain, + is_pv_chain, +) from ....microgrid import connection_manager from .._formula_engine import FormulaEngine from .._resampled_formula_builder import ResampledFormulaBuilder @@ -43,10 +50,10 @@ def _are_grid_meters(self, grid_successors: set[Component]) -> bool: component_graph = connection_manager.get().component_graph return all( isinstance(successor, Meter) - and not component_graph.is_battery_chain(successor) - and not component_graph.is_chp_chain(successor) - and not component_graph.is_pv_chain(successor) - and not component_graph.is_ev_charger_chain(successor) + and not is_battery_chain(component_graph, successor) + and not is_chp_chain(component_graph, successor) + and not is_pv_chain(component_graph, successor) + and not is_ev_charger_chain(component_graph, successor) for successor in grid_successors ) @@ -107,17 +114,17 @@ def non_consumer_component(component: Component) -> bool: # If the component graph supports additional types of grid successors in the # future, additional checks need to be added here. return ( - component_graph.is_battery_chain(component) - or component_graph.is_chp_chain(component) - or component_graph.is_pv_chain(component) - or component_graph.is_ev_charger_chain(component) + is_battery_chain(component_graph, component) + or is_chp_chain(component_graph, component) + or is_pv_chain(component_graph, component) + or is_ev_charger_chain(component_graph, component) ) # join all non consumer components reachable from the different grid meters non_consumer_components: set[Component] = set() for grid_meter in grid_meters: non_consumer_components = non_consumer_components.union( - component_graph.dfs(grid_meter, set(), non_consumer_component) + dfs(component_graph, grid_meter, set(), non_consumer_component) ) # push all grid meters @@ -180,14 +187,14 @@ def consumer_component(component: Component) -> bool: # future, additional checks need to be added here. return ( isinstance(component, (Meter, Inverter)) - and not component_graph.is_battery_chain(component) - and not component_graph.is_chp_chain(component) - and not component_graph.is_pv_chain(component) - and not component_graph.is_ev_charger_chain(component) + and not is_battery_chain(component_graph, component) + and not is_chp_chain(component_graph, component) + and not is_pv_chain(component_graph, component) + and not is_ev_charger_chain(component_graph, component) ) component_graph = connection_manager.get().component_graph - consumer_components = component_graph.dfs(grid, set(), consumer_component) + consumer_components = dfs(component_graph, grid, set(), consumer_component) if not consumer_components: _logger.warning( diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_formula_generator.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_formula_generator.py index b217f5436..04d9c2512 100644 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_formula_generator.py +++ b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_formula_generator.py @@ -18,6 +18,12 @@ from frequenz.client.microgrid.metrics import Metric from ...._internal._channels import ChannelRegistry +from ...._internal._graph_traversal import ( + is_battery_inverter, + is_chp, + is_ev_charger, + is_pv_inverter, +) from ....microgrid import connection_manager from ....microgrid._data_sourcing import ComponentMetricRequest from ..._base_types import QuantityT @@ -138,7 +144,7 @@ def _get_grid_component_successors(self) -> set[Component]: if not grid_successors: raise ComponentNotFound("No components found in the component graph.") - return grid_successors + return set(grid_successors) @abstractmethod def generate( @@ -183,7 +189,7 @@ def _get_metric_fallback_components( if isinstance(component, Meter): fallbacks[component] = self._get_meter_fallback_components(component) else: - predecessors = graph.predecessors(component.id) + predecessors = set(graph.predecessors(component.id)) if len(predecessors) == 1: predecessor = predecessors.pop() if self._is_primary_fallback_pair(predecessor, component): @@ -213,11 +219,11 @@ def _get_meter_fallback_components(self, meter: Component) -> set[Component]: # All fallbacks has to be of the same type and category. if ( - all(graph.is_pv_inverter(c) for c in successors) - or all(graph.is_battery_inverter(c) for c in successors) - or all(graph.is_ev_charger(c) for c in successors) + all(is_pv_inverter(c) for c in successors) + or all(is_battery_inverter(c) for c in successors) + or all(is_ev_charger(c) for c in successors) ): - return successors + return set(successors) return set() def _is_primary_fallback_pair( @@ -246,9 +252,9 @@ def _is_primary_fallback_pair( # fmt: off return ( - graph.is_pv_inverter(fallback) and graph.is_pv_meter(primary) - or graph.is_chp(fallback) and graph.is_chp_meter(primary) - or graph.is_ev_charger(fallback) and graph.is_ev_charger_meter(primary) - or graph.is_battery_inverter(fallback) and graph.is_battery_meter(primary) + is_pv_inverter(fallback) and graph.is_pv_meter(primary.id) + or is_chp(fallback) and graph.is_chp_meter(primary.id) + or is_ev_charger(fallback) and graph.is_ev_charger_meter(primary.id) + or is_battery_inverter(fallback) and graph.is_battery_meter(primary.id) ) # fmt: on diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_producer_power_formula.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_producer_power_formula.py index d66f62650..7c79cc5b3 100644 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_producer_power_formula.py +++ b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_producer_power_formula.py @@ -10,6 +10,7 @@ from frequenz.client.microgrid.metrics import Metric from frequenz.quantities import Power +from ...._internal._graph_traversal import dfs, is_chp_chain, is_pv_chain from ....microgrid import connection_manager from .._formula_engine import FormulaEngine from ._fallback_formula_metric_fetcher import FallbackFormulaMetricFetcher @@ -52,11 +53,12 @@ def generate( # noqa: DOC502 component_graph = connection_manager.get().component_graph # if in the future we support additional producers, we need to add them to the lambda - producer_components = component_graph.dfs( + producer_components = dfs( + component_graph, self._get_grid_component(), set(), - lambda component: component_graph.is_pv_chain(component) - or component_graph.is_chp_chain(component), + lambda component: is_pv_chain(component_graph, component) + or is_chp_chain(component_graph, component), ) if not producer_components: diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_pv_power_formula.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_pv_power_formula.py index 8aeabf4e7..732eb236f 100644 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_pv_power_formula.py +++ b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_pv_power_formula.py @@ -9,6 +9,7 @@ from frequenz.client.microgrid.metrics import Metric from frequenz.quantities import Power +from ...._internal._graph_traversal import dfs, is_pv_chain from ....microgrid import connection_manager from .._formula_engine import FormulaEngine from ._fallback_formula_metric_fetcher import FallbackFormulaMetricFetcher @@ -48,10 +49,11 @@ def generate( # noqa: DOC502 if component_ids: pv_components = component_graph.components(set(component_ids)) else: - pv_components = component_graph.dfs( + pv_components = dfs( + component_graph, self._get_grid_component(), set(), - component_graph.is_pv_chain, + lambda c: is_pv_chain(component_graph, c), ) if not pv_components: diff --git a/tests/microgrid/fixtures.py b/tests/microgrid/fixtures.py index b3365d752..f1c6b0f12 100644 --- a/tests/microgrid/fixtures.py +++ b/tests/microgrid/fixtures.py @@ -12,12 +12,17 @@ from typing import AsyncIterator from frequenz.channels import Sender -from frequenz.client.microgrid.component import ComponentCategory +from frequenz.client.common.microgrid.components import ComponentId +from frequenz.client.microgrid.component import ( + Component, + ComponentCategory, + ComponentConnection, +) +from frequenz.microgrid_component_graph import ComponentGraph from pytest_mock import MockerFixture from frequenz.sdk import microgrid from frequenz.sdk.microgrid._power_distributing import ComponentPoolStatus -from frequenz.sdk.microgrid.component_graph import _MicrogridComponentGraph from frequenz.sdk.timeseries import ResamplerConfig2 from ..timeseries.mock_microgrid import MockMicrogrid @@ -42,7 +47,9 @@ async def new( cls, component_category: ComponentCategory, mocker: MockerFixture, - graph: _MicrogridComponentGraph | None = None, + graph: ( + ComponentGraph[Component, ComponentConnection, ComponentId] | None + ) = None, grid_meter: bool | None = None, ) -> _Mocks: """Initialize the mocks.""" @@ -95,7 +102,7 @@ async def _mocks( mocker: MockerFixture, component_category: ComponentCategory, *, - graph: _MicrogridComponentGraph | None = None, + graph: ComponentGraph[Component, ComponentConnection, ComponentId] | None = None, grid_meter: bool | None = None, ) -> AsyncIterator[_Mocks]: """Initialize the mocks.""" diff --git a/tests/microgrid/test_grid.py b/tests/microgrid/test_grid.py index 5453d84bf..d23f0c2bd 100644 --- a/tests/microgrid/test_grid.py +++ b/tests/microgrid/test_grid.py @@ -5,20 +5,20 @@ from contextlib import AsyncExitStack +import frequenz.microgrid_component_graph as gr from frequenz.client.common.microgrid import MicrogridId from frequenz.client.common.microgrid.components import ComponentId from frequenz.client.microgrid.component import ( + Component, ComponentCategory, ComponentConnection, GridConnectionPoint, Meter, - UnspecifiedComponent, ) from frequenz.client.microgrid.metrics import Metric from frequenz.quantities import Current, Power, Quantity, ReactivePower from pytest_mock import MockerFixture -import frequenz.sdk.microgrid.component_graph as gr from frequenz.sdk import microgrid from frequenz.sdk.timeseries import Fuse from tests.utils.graph_generator import GraphGenerator @@ -38,7 +38,9 @@ async def test_grid_2(mocker: MockerFixture) -> None: components = {grid_1, meter_2} connections = {ComponentConnection(source=grid_1.id, destination=meter_2.id)} - graph = gr._MicrogridComponentGraph( # pylint: disable=protected-access + graph = gr.ComponentGraph[ + Component, ComponentConnection, ComponentId + ]( # pylint: disable=protected-access components=components, connections=connections ) diff --git a/tests/timeseries/mock_microgrid.py b/tests/timeseries/mock_microgrid.py index 5774997c1..d58c6e201 100644 --- a/tests/timeseries/mock_microgrid.py +++ b/tests/timeseries/mock_microgrid.py @@ -28,13 +28,13 @@ Meter, SolarInverter, ) +from frequenz.microgrid_component_graph import ComponentGraph from pytest_mock import MockerFixture from frequenz.sdk import microgrid from frequenz.sdk._internal._asyncio import cancel_and_await from frequenz.sdk.microgrid import _data_pipeline from frequenz.sdk.microgrid._old_component_data import ComponentData -from frequenz.sdk.microgrid.component_graph import _MicrogridComponentGraph from frequenz.sdk.timeseries import ResamplerConfig2 from ..utils import MockMicrogridClient @@ -72,7 +72,9 @@ def __init__( # pylint: disable=too-many-arguments,too-many-positional-argument sample_rate_s: float = 0.01, num_namespaces: int = 1, rated_fuse_current: int = 10_000, - graph: _MicrogridComponentGraph | None = None, + graph: ( + ComponentGraph[Component, ComponentConnection, ComponentId] | None + ) = None, mocker: MockerFixture | None = None, ): """Create a new instance. @@ -112,7 +114,7 @@ def __init__( # pylint: disable=too-many-arguments,too-many-positional-argument ) self._connections: set[ComponentConnection] = ( - set() if graph is None else graph.connections() + set() if graph is None else set(graph.connections()) ) self._id_increment = 0 if graph is None else len(self._components) diff --git a/tests/utils/component_graph_utils.py b/tests/utils/component_graph_utils.py index f03457cb8..da91d3ae9 100644 --- a/tests/utils/component_graph_utils.py +++ b/tests/utils/component_graph_utils.py @@ -18,8 +18,7 @@ Meter, SolarInverter, ) - -from frequenz.sdk.microgrid.component_graph import ComponentGraph +from frequenz.microgrid_component_graph import ComponentGraph @dataclass @@ -111,7 +110,9 @@ def create_component_graph_structure( return components, connections -def component_graph_to_mermaid(comp_graph: ComponentGraph) -> str: +def component_graph_to_mermaid( + comp_graph: ComponentGraph[Component, ComponentConnection, ComponentId], +) -> str: """Return a string representation of the component graph in Mermaid format.""" def component_to_mermaid(component: Component) -> str: diff --git a/tests/utils/graph_generator.py b/tests/utils/graph_generator.py index 82e4bcf99..38db01866 100644 --- a/tests/utils/graph_generator.py +++ b/tests/utils/graph_generator.py @@ -27,8 +27,7 @@ SolarInverter, UnspecifiedInverter, ) - -from frequenz.sdk.microgrid.component_graph import _MicrogridComponentGraph +from frequenz.microgrid_component_graph import ComponentGraph _MICROGRID_ID = MicrogridId(1) @@ -231,7 +230,9 @@ def grid() -> GridConnectionPoint: rated_fuse_current=1_000_000, ) - def to_graph(self, components: Any) -> _MicrogridComponentGraph: + def to_graph( + self, components: Any + ) -> ComponentGraph[Component, ComponentConnection, ComponentId]: """Convert a list of components to a graph. GRID will be added and connected as the first component. @@ -293,7 +294,7 @@ def to_graph(self, components: Any) -> _MicrogridComponentGraph: a tuple containing the components and connections of the graph. """ graph = self._to_graph(self.grid(), components) - return _MicrogridComponentGraph(set(graph[0]), set(graph[1])) + return ComponentGraph(set(graph[0]), set(graph[1])) def _to_graph( self, parent: Component, children: Any @@ -424,8 +425,6 @@ def test_graph_generator_simple() -> None: assert len(graph.components(matching_types=Battery)) == 2 assert len(graph.components(matching_types=EvCharger)) == 1 - graph.validate() - def test_graph_generator_no_grid_meter() -> None: """Test a graph without a grid side meter and a list of components at the top.""" @@ -463,5 +462,3 @@ def test_graph_generator_no_grid_meter() -> None: assert len(graph.successors(inverters[1].id)) == 1 assert len(graph.components(matching_types=Battery)) == 2 - - graph.validate() diff --git a/tests/utils/mock_microgrid_client.py b/tests/utils/mock_microgrid_client.py index 002c62065..742efe594 100644 --- a/tests/utils/mock_microgrid_client.py +++ b/tests/utils/mock_microgrid_client.py @@ -17,12 +17,11 @@ ComponentDataSamples, ) from frequenz.client.microgrid.metrics import Metric -from pytest_mock import MockerFixture - -from frequenz.sdk.microgrid.component_graph import ( +from frequenz.microgrid_component_graph import ( ComponentGraph, - _MicrogridComponentGraph, ) +from pytest_mock import MockerFixture + from frequenz.sdk.microgrid.connection_manager import ConnectionManager @@ -60,7 +59,9 @@ def __init__( microgrid_id: the ID of the microgrid location: the location of the microgrid """ - self._component_graph = _MicrogridComponentGraph(components, connections) + self._component_graph: ComponentGraph[ + Component, ComponentConnection, ComponentId + ] = ComponentGraph(components, connections) self._components = components self._connections = connections self._component_data_channels: dict[ @@ -102,7 +103,9 @@ def mock_microgrid(self) -> ConnectionManager: return self._mock_microgrid @property - def component_graph(self) -> ComponentGraph: + def component_graph( + self, + ) -> ComponentGraph[Component, ComponentConnection, ComponentId]: """Return microgrid component graph. Component graph is not mocked. From edd8617c748ea9f56dba82848502879c00bb0e6c Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Fri, 28 Nov 2025 14:51:54 +0100 Subject: [PATCH 14/23] Delete the old component graph Signed-off-by: Sahas Subramanian --- pyproject.toml | 1 - src/frequenz/sdk/microgrid/component_graph.py | 1135 ----------- tests/microgrid/test_graph.py | 1701 ----------------- 3 files changed, 2837 deletions(-) delete mode 100644 src/frequenz/sdk/microgrid/component_graph.py delete mode 100644 tests/microgrid/test_graph.py diff --git a/pyproject.toml b/pyproject.toml index 330c4eaff..344b3d1e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,6 @@ dependencies = [ "frequenz-client-common >= 0.3.6, < 0.4.0", "frequenz-channels >= 1.6.1, < 2.0.0", "frequenz-quantities[marshmallow] >= 1.0.0, < 2.0.0", - "networkx >= 2.8, < 4", "numpy >= 2.1.0, < 3", "typing_extensions >= 4.14.1, < 5", "marshmallow >= 3.19.0, < 5", diff --git a/src/frequenz/sdk/microgrid/component_graph.py b/src/frequenz/sdk/microgrid/component_graph.py deleted file mode 100644 index 9eead86f2..000000000 --- a/src/frequenz/sdk/microgrid/component_graph.py +++ /dev/null @@ -1,1135 +0,0 @@ -# License: MIT -# Copyright © 2022 Frequenz Energy-as-a-Service GmbH - -"""Defines a graph representation of how microgrid components are connected. - -The component graph is an approximate representation of the microgrid circuit, -abstracted to a level appropriate for higher-level monitoring and control. -Common use cases include: - -* Combining component measurements to compute grid power or onsite load by using - the graph structure to determine which measurements to aggregate - -* Identifying which inverter(s) need to be engaged to charge or discharge - a particular battery based on their connectivity in the graph - -* Understanding which power flows in the microgrid are derived from green vs - grey sources based on the component connectivity - -The graph deliberately does not include all pieces of hardware placed in the microgrid, -instead limiting itself to just those that are needed to monitor and control the -flow of power. -""" - -import asyncio -import logging -from abc import ABC, abstractmethod -from collections.abc import Callable, Iterable - -import networkx as nx -from frequenz.client.common.microgrid.components import ComponentId -from frequenz.client.microgrid import MicrogridApiClient -from frequenz.client.microgrid.component import ( - Battery, - BatteryInverter, - Chp, - Component, - ComponentCategory, - ComponentConnection, - EvCharger, - GridConnectionPoint, - Inverter, - Meter, - MismatchedCategoryComponent, - SolarInverter, - UnrecognizedComponent, - UnspecifiedComponent, -) -from typing_extensions import override - -_logger = logging.getLogger(__name__) - -# pylint: disable=too-many-lines - - -# Constant to store the actual objects as data attached to the graph nodes and edges -_DATA_KEY = "data" - - -class InvalidGraphError(Exception): - """Exception type that will be thrown if graph data is not valid.""" - - -class ComponentGraph(ABC): - """Interface for component graph implementations.""" - - @abstractmethod - def components( - self, - matching_ids: Iterable[ComponentId] | ComponentId | None = None, - matching_types: Iterable[type[Component]] | type[Component] | None = None, - ) -> set[Component]: - """Fetch the components of the microgrid. - - Args: - matching_ids: The component IDs that the components must match. - matching_types: The component types that the components must match. - - Returns: - The set of components currently connected to the microgrid, filtered by - the provided `matching_ids` and `matching_types` values. - """ - - @abstractmethod - def connections( - self, - matching_sources: Iterable[ComponentId] | ComponentId | None = None, - matching_destinations: Iterable[ComponentId] | ComponentId | None = None, - ) -> set[ComponentConnection]: - """Fetch the connections between microgrid components. - - Args: - matching_sources: The component IDs the connections' source must match. - matching_destinations: The component IDs the connections' destination must match. - - Returns: - The set of connections between components in the microgrid, filtered by - the provided `matching_sources` and `matching_destinations` choices. - """ - - @abstractmethod - def predecessors(self, component_id: ComponentId) -> set[Component]: - """Fetch the graph predecessors of the specified component. - - Args: - component_id: The IDs of the components whose predecessors should be - fetched. - - Returns: - The set of components that are predecessors of `component_id`, i.e. for - which there is a connection from each of these components to - `component_id`. - - Raises: - KeyError: If the specified `component_id` is not in the graph. - """ - - @abstractmethod - def successors(self, component_id: ComponentId) -> set[Component]: - """Fetch the graph successors of the specified component. - - Args: - component_id: The IDs of the components whose successors should be fetched. - - Returns: - The set of components that are successors of `component_id`, i.e. for - which there is a connection from `component_id` to each of these - components. - - Raises: - KeyError: If the specified `component_id` is not in the graph - """ - - @abstractmethod - def is_grid_meter(self, component: Component) -> bool: - """Check if the specified component is a grid meter. - - This is done by checking if the component is the only successor to the `Grid` - component. - - Args: - component: The component to check. - - Returns: - Whether the specified component is a grid meter. - """ - - @abstractmethod - def is_pv_inverter(self, component: Component) -> bool: - """Check if the specified component is a PV inverter. - - Args: - component: The component to check. - - Returns: - Whether the specified component is a PV inverter. - """ - - @abstractmethod - def is_pv_meter(self, component: Component) -> bool: - """Check if the specified component is a PV meter. - - This is done by checking if the component has only PV inverters as its - successors. - - Args: - component: The component to check. - - Returns: - Whether the specified component is a PV meter. - """ - - @abstractmethod - def is_pv_chain(self, component: Component) -> bool: - """Check if the specified component is part of a PV chain. - - A component is part of a PV chain if it is a PV meter or a PV inverter. - - Args: - component: The component to check. - - Returns: - Whether the specified component is part of a PV chain. - """ - - @abstractmethod - def is_battery_inverter(self, component: Component) -> bool: - """Check if the specified component is a battery inverter. - - Args: - component: The component to check. - - Returns: - Whether the specified component is a battery inverter. - """ - - @abstractmethod - def is_battery_meter(self, component: Component) -> bool: - """Check if the specified component is a battery meter. - - This is done by checking if the component has only battery inverters as its - predecessors. - - Args: - component: The component to check. - - Returns: - Whether the specified component is a battery meter. - """ - - @abstractmethod - def is_battery_chain(self, component: Component) -> bool: - """Check if the specified component is part of a battery chain. - - A component is part of a battery chain if it is a battery meter or a battery - inverter. - - Args: - component: The component to check. - - Returns: - Whether the specified component is part of a battery chain. - """ - - @abstractmethod - def is_ev_charger(self, component: Component) -> bool: - """Check if the specified component is an EV charger. - - Args: - component: The component to check. - - Returns: - Whether the specified component is an EV charger. - """ - - @abstractmethod - def is_ev_charger_meter(self, component: Component) -> bool: - """Check if the specified component is an EV charger meter. - - This is done by checking if the component has only EV chargers as its - successors. - - Args: - component: The component to check. - - Returns: - Whether the specified component is an EV charger meter. - """ - - @abstractmethod - def is_ev_charger_chain(self, component: Component) -> bool: - """Check if the specified component is part of an EV charger chain. - - A component is part of an EV charger chain if it is an EV charger meter or an - EV charger. - - Args: - component: The component to check. - - Returns: - Whether the specified component is part of an EV charger chain. - """ - - @abstractmethod - def is_chp(self, component: Component) -> bool: - """Check if the specified component is a CHP. - - Args: - component: The component to check. - - Returns: - Whether the specified component is a CHP. - """ - - @abstractmethod - def is_chp_meter(self, component: Component) -> bool: - """Check if the specified component is a CHP meter. - - This is done by checking if the component has only CHPs as its successors. - - Args: - component: The component to check. - - Returns: - Whether the specified component is a CHP meter. - """ - - @abstractmethod - def is_chp_chain(self, component: Component) -> bool: - """Check if the specified component is part of a CHP chain. - - A component is part of a CHP chain if it is a CHP meter or a CHP. - - Args: - component: The component to check. - - Returns: - Whether the specified component is part of a CHP chain. - """ - - @abstractmethod - def dfs( - self, - current_node: Component, - visited: set[Component], - condition: Callable[[Component], bool], - ) -> set[Component]: - """Search for components that fulfill the condition in the Graph. - - DFS is used for searching the graph. The graph traversal is stopped - once a component fulfills the condition. - - Args: - current_node: The current node to search from. - visited: The set of visited nodes. - condition: The condition function to check for. - - Returns: - A set of component IDs where the corresponding components fulfill - the `condition` function. - """ - - @abstractmethod - def find_first_descendant_component( - self, - *, - descendants: Iterable[type[Component]], - ) -> Component: - """Find the first descendant component given root and descendant categories. - - This method looks for the first descendant component from the GRID - component, considering only the immediate descendants. - - The priority of the component to search for is determined by the order - of the descendant categories, with the first category having the - highest priority. - - Args: - descendants: The descendant classes to search for the first - descendant component in. - - Returns: - The first descendant component found in the component graph, - considering the specified `descendants` categories. - """ - - -class _MicrogridComponentGraph( - ComponentGraph -): # pylint: disable=too-many-public-methods - """ComponentGraph implementation designed to work with the microgrid API. - - For internal-only use of the `microgrid` package. - """ - - def __init__( - self, - components: set[Component] | None = None, - connections: set[ComponentConnection] | None = None, - ) -> None: - """Initialize the component graph. - - Args: - components: The components to initialize the graph with. If set, must - provide `connections` as well. - connections: The connections to initialize the graph with. If set, must - provide `components` as well. - - Raises: - InvalidGraphError: If `components` and `connections` are not both `None` - and either of them is either `None` or empty. - """ - self._graph: nx.DiGraph[ComponentId] = nx.DiGraph() - - if components is None and connections is None: - return - - if components is None or len(components) == 0: - raise InvalidGraphError("Must provide components as well as connections") - - if connections is None or len(connections) == 0: - raise InvalidGraphError("Must provide connections as well as components") - - self.refresh_from(components, connections) - self.validate() - - @override - def components( - self, - matching_ids: Iterable[ComponentId] | ComponentId | None = None, - matching_types: Iterable[type[Component]] | type[Component] | None = None, - ) -> set[Component]: - """Fetch the components of the microgrid. - - Args: - matching_ids: The component IDs that the components must match. - matching_types: The component types that the components must match. - - Returns: - The set of components currently connected to the microgrid, filtered by - the provided `matching_ids` and `matching_types` values. - """ - matching_ids = _comp_ids_to_iter(matching_ids) - if isinstance(matching_types, type): - matching_types = {matching_types} - - selection: Iterable[Component] - selection_ids = ( - self._graph.nodes - if matching_ids is None - else set(matching_ids) & self._graph.nodes - ) - selection = (self._graph.nodes[i][_DATA_KEY] for i in selection_ids) - - if matching_types is not None: - selection = filter( - lambda c: isinstance(c, tuple(matching_types)), selection - ) - - return set(selection) - - @override - def connections( - self, - matching_sources: Iterable[ComponentId] | ComponentId | None = None, - matching_destinations: Iterable[ComponentId] | ComponentId | None = None, - ) -> set[ComponentConnection]: - """Fetch the connections between microgrid components. - - Args: - matching_sources: The component IDs that the connections' source must match. - matching_destinations: The component IDs that the connections' destination - must match. - - Returns: - The set of connections between components in the microgrid, filtered by - the provided `matching_sources` and `matching_destinations` choices. - """ - matching_sources = _comp_ids_to_iter(matching_sources) - matching_destinations = _comp_ids_to_iter(matching_destinations) - selection: Iterable[tuple[ComponentId, ComponentId]] - - match (matching_sources, matching_destinations): - case (None, None): - selection = self._graph.edges - case (None, _): - selection = self._graph.in_edges(matching_destinations) - case (_, None): - selection = self._graph.out_edges(matching_sources) - case (_, _): - source_edges = self._graph.out_edges(matching_sources) - destination_edges = self._graph.in_edges(matching_destinations) - selection = set(source_edges).intersection(destination_edges) - - return set(self._graph.edges[i][_DATA_KEY] for i in selection) - - @override - def predecessors(self, component_id: ComponentId) -> set[Component]: - """Fetch the graph predecessors of the specified component. - - Args: - component_id: The IDs of the components whose predecessors should be - fetched. - - Returns: - The set of components that are predecessors of `component_id`, i.e. for - which there is a connection from each of these components to - `component_id`. - - Raises: - KeyError: If the specified `component_id` is not in the graph. - """ - if component_id not in self._graph: - raise KeyError( - f"Component {component_id} not in graph, cannot get predecessors!" - ) - - predecessors_ids = self._graph.predecessors(component_id) - - return set(map(lambda idx: self._graph.nodes[idx][_DATA_KEY], predecessors_ids)) - - @override - def successors(self, component_id: ComponentId) -> set[Component]: - """Fetch the graph successors of the specified component. - - Args: - component_id: The IDs of the components whose successors should be fetched. - - Returns: - The set of components that are successors of `component_id`, i.e. for - which there is a connection from `component_id` to each of these - components. - - Raises: - KeyError: If the specified `component_id` is not in the graph - """ - if component_id not in self._graph: - raise KeyError( - f"Component {component_id} not in graph, cannot get successors!" - ) - - successors_ids = self._graph.successors(component_id) - - return set(map(lambda idx: self._graph.nodes[idx][_DATA_KEY], successors_ids)) - - def refresh_from( - self, - components: set[Component], - connections: set[ComponentConnection], - correct_errors: Callable[["_MicrogridComponentGraph"], None] | None = None, - ) -> None: - """Refresh the graph from the provided list of components and connections. - - This will completely overwrite the current graph data with the provided - components and connections. - - Args: - components: The components to initialize the graph with. If set, must - provide `connections` as well. - connections: The connections to initialize the graph with. If set, must - provide `components` as well. - correct_errors: The callback that, if set, will be invoked if the - provided graph data is in any way invalid (it will attempt to - correct the errors by inferring what the correct data should be). - - Raises: - InvalidGraphError: If the provided `components` and `connections` - do not form a valid component graph and `correct_errors` does - not fix it. - """ - issues: list[str] = [] - - for connection in connections: - issues.extend((self._validate_connection(connection))) - for component in components: - issues.extend((self._validate_component(component))) - - if issues: - raise InvalidGraphError(f"Invalid component data: {', '.join(issues)}") - - new_graph: nx.DiGraph[ComponentId] = nx.DiGraph() - new_graph.add_nodes_from( - (component.id, {_DATA_KEY: component}) for component in components - ) - - # Store the original connection object in the edge data (third item in the - # tuple) so that we can retrieve it later. - new_graph.add_edges_from( - (connection.source, connection.destination, {_DATA_KEY: connection}) - for connection in connections - ) - - # check if we can construct a valid ComponentGraph - # from the new NetworkX graph data - _provisional = _MicrogridComponentGraph() - _provisional._graph = new_graph # pylint: disable=protected-access - if correct_errors is not None: - try: - _provisional.validate() - except InvalidGraphError as err: - _logger.warning("Attempting to fix invalid component data: %s", err) - correct_errors(_provisional) - - try: - _provisional.validate() - except Exception as err: - _logger.error("Failed to parse component graph: %s", err) - raise InvalidGraphError( - "Cannot populate component graph from provided input!" - ) from err - - old_graph = self._graph - self._graph = new_graph - old_graph.clear() # just in case any references remain, but should not - - def _validate_connection(self, connection: ComponentConnection) -> list[str]: - """Check that the connection is valid. - - Args: - connection: connection to validate. - - Returns: - List of issues found with the connection. - """ - issues: list[str] = [] - if connection.source == connection.destination: - issues.append(f"Connection {connection} has same source and destination!") - return issues - - def _validate_component(self, component: Component) -> list[str]: - """Check that the component is valid. - - Args: - component: component to validate. - - Returns: - List of issues found with the component. - """ - issues: list[str] = [] - if isinstance(component, UnspecifiedComponent): - _logger.warning("Component %r has an unspecified category!", component) - if isinstance(component, UnrecognizedComponent): - issues.append(f"Component {component!r} has an unrecognized category!") - if isinstance(component, MismatchedCategoryComponent): - _logger.warning("Component %r has a mismatched category!", component) - - return issues - - async def refresh_from_client( - self, - client: MicrogridApiClient, - correct_errors: Callable[["_MicrogridComponentGraph"], None] | None = None, - ) -> None: - """Refresh the contents of a component graph from the remote API. - - Args: - client: The API client from which to fetch graph data - correct_errors: The callback that, if set, will be invoked if the - provided graph data is in any way invalid (it will attempt to - correct the errors by inferring what the correct data should be). - """ - components, connections = await asyncio.gather( - client.list_components(), - client.list_connections(), - ) - - self.refresh_from(set(components), set(connections), correct_errors) - - def validate(self) -> None: - """Check that the component graph contains valid microgrid data.""" - self._validate_graph() - self._validate_graph_root() - self._validate_grid_endpoint() - self._validate_intermediary_components() - self._validate_leaf_components() - - @override - def is_grid_meter(self, component: Component) -> bool: - """Check if the specified component is a grid meter. - - This is done by checking if the component is the only successor to the `Grid` - component. - - Args: - component: The component to check. - - Returns: - Whether the specified component is a grid meter. - """ - if component.category != ComponentCategory.METER: - return False - - predecessors = self.predecessors(component.id) - if len(predecessors) != 1: - return False - - predecessor = next(iter(predecessors)) - if predecessor.category != ComponentCategory.GRID: - return False - - grid_successors = self.successors(predecessor.id) - return len(grid_successors) == 1 - - @override - def is_pv_inverter(self, component: Component) -> bool: - """Check if the specified component is a PV inverter. - - Args: - component: The component to check. - - Returns: - Whether the specified component is a PV inverter. - """ - return isinstance(component, SolarInverter) - - @override - def is_pv_meter(self, component: Component) -> bool: - """Check if the specified component is a PV meter. - - This is done by checking if the component has only PV inverters as its - successors. - - Args: - component: The component to check. - - Returns: - Whether the specified component is a PV meter. - """ - successors = self.successors(component.id) - return ( - isinstance(component, Meter) - and not self.is_grid_meter(component) - and len(successors) > 0 - and all( - self.is_pv_inverter(successor) - for successor in self.successors(component.id) - ) - ) - - @override - def is_pv_chain(self, component: Component) -> bool: - """Check if the specified component is part of a PV chain. - - A component is part of a PV chain if it is either a PV inverter or a PV - meter. - - Args: - component: The component to check. - - Returns: - Whether the specified component is part of a PV chain. - """ - return self.is_pv_inverter(component) or self.is_pv_meter(component) - - @override - def is_ev_charger(self, component: Component) -> bool: - """Check if the specified component is an EV charger. - - Args: - component: The component to check. - - Returns: - Whether the specified component is an EV charger. - """ - return isinstance(component, EvCharger) - - @override - def is_ev_charger_meter(self, component: Component) -> bool: - """Check if the specified component is an EV charger meter. - - This is done by checking if the component has only EV chargers as its - successors. - - Args: - component: The component to check. - - Returns: - Whether the specified component is an EV charger meter. - """ - successors = self.successors(component.id) - return ( - isinstance(component, Meter) - and not self.is_grid_meter(component) - and len(successors) > 0 - and all(self.is_ev_charger(successor) for successor in successors) - ) - - @override - def is_ev_charger_chain(self, component: Component) -> bool: - """Check if the specified component is part of an EV charger chain. - - A component is part of an EV charger chain if it is either an EV charger or an - EV charger meter. - - Args: - component: The component to check. - - Returns: - Whether the specified component is part of an EV charger chain. - """ - return self.is_ev_charger(component) or self.is_ev_charger_meter(component) - - @override - def is_battery_inverter(self, component: Component) -> bool: - """Check if the specified component is a battery inverter. - - Args: - component: The component to check. - - Returns: - Whether the specified component is a battery inverter. - """ - return isinstance(component, BatteryInverter) - - @override - def is_battery_meter(self, component: Component) -> bool: - """Check if the specified component is a battery meter. - - This is done by checking if the component has only battery inverters as - its successors. - - Args: - component: The component to check. - - Returns: - Whether the specified component is a battery meter. - """ - successors = self.successors(component.id) - return ( - isinstance(component, Meter) - and not self.is_grid_meter(component) - and len(successors) > 0 - and all(self.is_battery_inverter(successor) for successor in successors) - ) - - @override - def is_battery_chain(self, component: Component) -> bool: - """Check if the specified component is part of a battery chain. - - A component is part of a battery chain if it is either a battery inverter or a - battery meter. - - Args: - component: The component to check. - - Returns: - Whether the specified component is part of a battery chain. - """ - return self.is_battery_inverter(component) or self.is_battery_meter(component) - - @override - def is_chp(self, component: Component) -> bool: - """Check if the specified component is a CHP. - - Args: - component: The component to check. - - Returns: - Whether the specified component is a CHP. - """ - return isinstance(component, Chp) - - @override - def is_chp_meter(self, component: Component) -> bool: - """Check if the specified component is a CHP meter. - - This is done by checking if the component has only CHPs as its - successors. - - Args: - component: The component to check. - - Returns: - Whether the specified component is a CHP meter. - """ - successors = self.successors(component.id) - return ( - isinstance(component, Meter) - and not self.is_grid_meter(component) - and len(successors) > 0 - and all(self.is_chp(successor) for successor in successors) - ) - - @override - def is_chp_chain(self, component: Component) -> bool: - """Check if the specified component is part of a CHP chain. - - A component is part of a CHP chain if it is either a CHP or a CHP meter. - - Args: - component: The component to check. - - Returns: - Whether the specified component is part of a CHP chain. - """ - return self.is_chp(component) or self.is_chp_meter(component) - - @override - def dfs( - self, - current_node: Component, - visited: set[Component], - condition: Callable[[Component], bool], - ) -> set[Component]: - """Search for components that fulfill the condition in the Graph. - - DFS is used for searching the graph. The graph traversal is stopped - once a component fulfills the condition. - - Args: - current_node: The current node to search from. - visited: The set of visited nodes. - condition: The condition function to check for. - - Returns: - A set of component IDs where the corresponding components fulfill - the condition function. - """ - if current_node in visited: - return set() - - visited.add(current_node) - - if condition(current_node): - return {current_node} - - component: set[Component] = set() - - for successor in self.successors(current_node.id): - component.update(self.dfs(successor, visited, condition)) - - return component - - @override - def find_first_descendant_component( - self, - *, - descendants: Iterable[type[Component]], - ) -> Component: - """Find the first descendant component given root and descendant categories. - - This method looks for the first descendant component from the GRID - component, considering only the immediate descendants. - - The priority of the component to search for is determined by the order - of the descendant categories, with the first category having the - highest priority. - - Args: - descendants: The descendant classes to search for the first - descendant component in. - - Returns: - The first descendant component found in the component graph, - considering the specified `descendants` categories. - - Raises: - InvalidGraphError: When no GRID component is found in the graph. - ValueError: When no component is found in the given categories. - """ - # We always sort by component ID to ensure consistent results - - def sorted_by_id(components: Iterable[Component]) -> Iterable[Component]: - return sorted(components, key=lambda c: c.id) - - root_component = next( - iter(sorted_by_id(self.components(matching_types={GridConnectionPoint}))), - None, - ) - if root_component is None: - raise InvalidGraphError( - "No GridConnectionPoint component found in the component graph!" - ) - - successors = sorted_by_id(self.successors(root_component.id)) - - def find_component(component_class: type[Component]) -> Component | None: - return next( - (comp for comp in successors if isinstance(comp, component_class)), - None, - ) - - # Find the first component that matches the given descendant categories - # in the order of the categories list. - component = next(filter(None, map(find_component, descendants)), None) - - if component is None: - raise ValueError("Component not found in any of the descendant categories.") - - return component - - def _validate_graph(self) -> None: - """Check that the underlying graph data is valid. - - Raises: - InvalidGraphError: If: - - There are no components. - - There are no connections. - - The graph is not a tree. - - Any node lacks its associated component data. - """ - if self._graph.number_of_nodes() == 0: - raise InvalidGraphError("No components in graph!") - - if self._graph.number_of_edges() == 0: - raise InvalidGraphError("No connections in component graph!") - - if not nx.is_directed_acyclic_graph(self._graph): - raise InvalidGraphError("Component graph is not a tree!") - - # This check doesn't seem to have much sense, it only search for nodes without - # data associated with them. We leave it here for now, but we should consider - # removing it in the future. - if undefined := [ - node[0] for node in self._graph.nodes(data=True) if len(node[1]) == 0 - ]: - undefined_str = ", ".join(map(str, map(int, sorted(undefined)))) - raise InvalidGraphError( - "Some component IDs found in connections are missing a " - f"component definition: {undefined_str}" - ) - - # should be true as a consequence of checks above - if sum(1 for _ in self.components()) <= 0: - raise InvalidGraphError("Graph must have a least one component!") - if sum(1 for _ in self.connections()) <= 0: - raise InvalidGraphError("Graph must have a least one connection!") - - # should be true as a consequence of the tree property: - # there should be no unconnected components - unconnected = filter(lambda c: self._graph.degree(c.id) == 0, self.components()) - if sum(1 for _ in unconnected) != 0: - raise InvalidGraphError( - "Every component must have at least one connection!" - ) - - def _validate_graph_root(self) -> None: - """Check that there is exactly one node without predecessors, of valid type. - - Raises: - InvalidGraphError: If there is more than one node without predecessors, - or if there is a single such node that is not one of NONE or GRID. - """ - no_predecessors = filter( - lambda c: self._graph.in_degree(c.id) == 0, - self.components(), - ) - - valid_roots = list( - filter( - lambda c: isinstance(c, (GridConnectionPoint, UnspecifiedComponent)), - no_predecessors, - ) - ) - - if len(valid_roots) == 0: - raise InvalidGraphError("No valid root nodes of component graph!") - - if len(valid_roots) > 1: - root_nodes = ", ".join(map(str, sorted(valid_roots, key=lambda c: c.id))) - raise InvalidGraphError(f"Multiple potential root nodes: {root_nodes}") - - root = valid_roots[0] - if self._graph.out_degree(root.id) == 0: - raise InvalidGraphError(f"Graph root {root} has no successors!") - - def _validate_grid_endpoint(self) -> None: - """Check that the grid endpoint is configured correctly in the graph. - - Raises: - InvalidGraphError: If there is more than one grid endpoint in the - graph, or if the grid endpoint has predecessors (if it exists, - then it should be the root of the component-graph tree), or if - it has no successors in the graph (i.e. it is not connected to - anything). - """ - grid = list(self.components(matching_types={GridConnectionPoint})) - - if len(grid) == 0: - # it's OK to not have a grid endpoint as long as other properties - # (checked by other `_validate...` methods) hold - return - - if len(grid) > 1: - raise InvalidGraphError( - f"Multiple grid endpoints in component graph: {grid}" - ) - - grid_id = grid[0].id - if self._graph.in_degree(grid_id) > 0: - pred = ", ".join( - map(str, sorted(self.predecessors(grid_id), key=lambda c: c.id)) - ) - raise InvalidGraphError(f"Grid endpoint {grid_id} has predecessors: {pred}") - - if self._graph.out_degree(grid_id) == 0: - raise InvalidGraphError(f"Grid endpoint {grid_id} has no graph successors!") - - def _validate_intermediary_components(self) -> None: - """Check that intermediary components (e.g. meters) are configured correctly. - - Intermediary components are components that should have both predecessors and - successors in the component graph, such as METER, or INVERTER. - - Raises: - InvalidGraphError: If any intermediary component has zero predecessors - or zero successors. - """ - intermediary_components = list(self.components(matching_types={Inverter})) - - missing_predecessors = list( - filter( - lambda c: sum(1 for _ in self.predecessors(c.id)) == 0, - intermediary_components, - ) - ) - if len(missing_predecessors) > 0: - raise InvalidGraphError( - "Intermediary components without graph predecessors: " - f"{list(map(str, missing_predecessors))}" - ) - - def _validate_leaf_components(self) -> None: - """Check that leaf components (e.g. batteries) are configured correctly. - - Leaf components are components that should be leaves of the component-graph - tree, such as LOAD, BATTERY or EV_CHARGER. These should have only incoming - connections and no outgoing connections. - - Raises: - InvalidGraphError: If any leaf component in the graph has 0 predecessors, - or has > 0 successors. - """ - leaf_components = list( - self.components( - matching_types={ - Battery, - EvCharger, - } - ) - ) - - missing_predecessors = list( - filter( - lambda c: sum(1 for _ in self.predecessors(c.id)) == 0, - leaf_components, - ) - ) - if len(missing_predecessors) > 0: - raise InvalidGraphError( - f"Leaf components without graph predecessors: {missing_predecessors}" - ) - - with_successors = list( - filter( - lambda c: sum(1 for _ in self.successors(c.id)) > 0, - leaf_components, - ) - ) - if len(with_successors) > 0: - raise InvalidGraphError( - f"Leaf components with graph successors: {with_successors}" - ) - - @override - def __repr__(self) -> str: - """Return a string representation of the component graph.""" - return f"ComponentGraph({self._graph!r})" - - -def _comp_ids_to_iter( - ids: Iterable[ComponentId] | ComponentId | None, -) -> Iterable[ComponentId] | None: - if isinstance(ids, ComponentId): - return (ids,) - return ids diff --git a/tests/microgrid/test_graph.py b/tests/microgrid/test_graph.py deleted file mode 100644 index f7ad6a4d9..000000000 --- a/tests/microgrid/test_graph.py +++ /dev/null @@ -1,1701 +0,0 @@ -# License: MIT -# Copyright © 2022 Frequenz Energy-as-a-Service GmbH - -"""Tests for the microgrid component graph.""" - -# pylint: disable=too-many-lines,use-implicit-booleaness-not-comparison -# pylint: disable=invalid-name,missing-function-docstring,too-many-statements -# pylint: disable=too-many-lines,protected-access - -import re -from unittest import mock - -import pytest -from frequenz.client.common.microgrid import MicrogridId -from frequenz.client.common.microgrid.components import ComponentId -from frequenz.client.microgrid import MicrogridApiClient -from frequenz.client.microgrid.component import ( - Battery, - BatteryInverter, - Chp, - Component, - ComponentConnection, - EvCharger, - GridConnectionPoint, - Inverter, - Meter, - SolarInverter, - UnrecognizedComponent, - UnspecifiedBattery, - UnspecifiedComponent, - UnspecifiedEvCharger, - UnspecifiedInverter, -) - -import frequenz.sdk.microgrid.component_graph as gr - -_MICROGRID_ID = MicrogridId(1) - - -def _add_components(graph: gr._MicrogridComponentGraph, *components: Component) -> None: - """Add components to the test graph. - - Args: - graph: The graph to add the components to. - *components: The components to add. - """ - graph._graph.add_nodes_from((c.id, {gr._DATA_KEY: c}) for c in components) - - -def _add_connections( - graph: gr._MicrogridComponentGraph, *connections: ComponentConnection -) -> None: - """Add connections to the test graph. - - Args: - graph: The graph to add the connections to. - *connections: The connections to add. - """ - graph._graph.add_edges_from( - (c.source, c.destination, {gr._DATA_KEY: c}) for c in connections - ) - - -def _check_predecessors_and_successors(graph: gr.ComponentGraph) -> None: - expected_predecessors: dict[ComponentId, set[Component]] = {} - expected_successors: dict[ComponentId, set[Component]] = {} - - components: dict[ComponentId, Component] = { - component.id: component for component in graph.components() - } - - for conn in graph.connections(): - if conn.destination not in expected_predecessors: - expected_predecessors[conn.destination] = set() - expected_predecessors[conn.destination].add(components[conn.source]) - - if conn.source not in expected_successors: - expected_successors[conn.source] = set() - expected_successors[conn.source].add(components[conn.destination]) - - for component_id in components.keys(): - assert set(graph.predecessors(component_id)) == expected_predecessors.get( - component_id, set() - ) - assert set(graph.successors(component_id)) == expected_successors.get( - component_id, set() - ) - - -class TestComponentGraph: - """Test cases for the public ComponentGraph interface. - - The _MicrogridComponentGraph implementation is used with these tests, - but the only methods tested are those exposed by ComponentGraph, i.e. - those to query graph properties rather than set them. - """ - - @pytest.fixture() - def sample_input_components(self) -> set[Component]: - """Create a sample set of components for testing purposes.""" - return { - GridConnectionPoint( - id=ComponentId(11), - microgrid_id=_MICROGRID_ID, - rated_fuse_current=10_000, - ), - Meter(id=ComponentId(21), microgrid_id=_MICROGRID_ID), - Meter(id=ComponentId(41), microgrid_id=_MICROGRID_ID), - BatteryInverter(id=ComponentId(51), microgrid_id=_MICROGRID_ID), - UnspecifiedBattery(id=ComponentId(61), microgrid_id=_MICROGRID_ID), - } - - @pytest.fixture() - def sample_input_connections(self) -> set[ComponentConnection]: - """Create a sample set of connections for testing purposes.""" - return { - ComponentConnection(source=ComponentId(11), destination=ComponentId(21)), - ComponentConnection(source=ComponentId(21), destination=ComponentId(41)), - ComponentConnection(source=ComponentId(41), destination=ComponentId(51)), - ComponentConnection(source=ComponentId(51), destination=ComponentId(61)), - } - - @pytest.fixture() - def sample_graph( - self, - sample_input_components: set[Component], - sample_input_connections: set[ComponentConnection], - ) -> gr.ComponentGraph: - """Create a sample graph for testing purposes.""" - _graph_implementation = gr._MicrogridComponentGraph( - components=sample_input_components, - connections=sample_input_connections, - ) - return _graph_implementation - - def test_without_filters(self) -> None: - """Test the graph component query without filters.""" - _graph_implementation = gr._MicrogridComponentGraph() - graph: gr.ComponentGraph = _graph_implementation - - assert graph.components() == set() - assert graph.connections() == set() - with pytest.raises( - KeyError, - match="Component CID1 not in graph, cannot get predecessors!", - ): - graph.predecessors(ComponentId(1)) - with pytest.raises( - KeyError, - match="Component CID1 not in graph, cannot get successors!", - ): - graph.successors(ComponentId(1)) - - expected_connection = ComponentConnection( - source=ComponentId(1), destination=ComponentId(3) - ) - - expected_components = [ - GridConnectionPoint( - id=ComponentId(1), microgrid_id=_MICROGRID_ID, rated_fuse_current=10_000 - ), - Meter(id=ComponentId(3), microgrid_id=_MICROGRID_ID), - ] - # simplest valid microgrid: a grid endpoint and a meter - _graph_implementation.refresh_from( - components=set(expected_components), - connections={expected_connection}, - ) - assert len(graph.components()) == len(expected_components) - assert graph.components() == set(expected_components) - assert graph.connections() == {expected_connection} - - assert graph.predecessors(ComponentId(1)) == set() - assert graph.successors(ComponentId(1)) == {expected_components[1]} - assert graph.predecessors(ComponentId(3)) == {expected_components[0]} - assert graph.successors(ComponentId(3)) == set() - with pytest.raises( - KeyError, - match="Component CID2 not in graph, cannot get predecessors!", - ): - graph.predecessors(ComponentId(2)) - with pytest.raises( - KeyError, - match="Component CID2 not in graph, cannot get successors!", - ): - graph.successors(ComponentId(2)) - - input_components = { - ComponentId(101): GridConnectionPoint( - id=ComponentId(101), - microgrid_id=_MICROGRID_ID, - rated_fuse_current=10_000, - ), - ComponentId(102): Meter(id=ComponentId(102), microgrid_id=_MICROGRID_ID), - ComponentId(104): Meter(id=ComponentId(104), microgrid_id=_MICROGRID_ID), - ComponentId(105): BatteryInverter( - id=ComponentId(105), microgrid_id=_MICROGRID_ID - ), - ComponentId(106): UnspecifiedBattery( - id=ComponentId(106), microgrid_id=_MICROGRID_ID - ), - } - input_connections = { - ComponentConnection(source=ComponentId(101), destination=ComponentId(102)), - ComponentConnection(source=ComponentId(102), destination=ComponentId(104)), - ComponentConnection(source=ComponentId(104), destination=ComponentId(105)), - ComponentConnection(source=ComponentId(105), destination=ComponentId(106)), - } - - # more complex microgrid: grid endpoint, load, grid-side meter, - # and meter/inverter/battery setup - _graph_implementation.refresh_from( - components=set(input_components.values()), - connections=input_connections, - ) - - assert len(graph.components()) == len(input_components.values()) - assert set(graph.components()) == set(input_components.values()) - assert graph.connections() == input_connections - - _check_predecessors_and_successors(graph=graph) - - with pytest.raises( - KeyError, - match="Component CID9 not in graph, cannot get predecessors!", - ): - graph.predecessors(ComponentId(9)) - with pytest.raises( - KeyError, - match="Component CID99 not in graph, cannot get successors!", - ): - graph.successors(ComponentId(99)) - - @pytest.mark.parametrize( - "int_ids, expected", - [ - ({1}, set()), - ({1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, set()), - ( - {11}, - { - GridConnectionPoint( - id=ComponentId(11), - microgrid_id=_MICROGRID_ID, - rated_fuse_current=10_000, - ) - }, - ), - ({21}, {Meter(id=ComponentId(21), microgrid_id=_MICROGRID_ID)}), - ({41}, {Meter(id=ComponentId(41), microgrid_id=_MICROGRID_ID)}), - ({51}, {BatteryInverter(id=ComponentId(51), microgrid_id=_MICROGRID_ID)}), - ( - {61}, - {UnspecifiedBattery(id=ComponentId(61), microgrid_id=_MICROGRID_ID)}, - ), - ( - {11, 61}, - { - GridConnectionPoint( - id=ComponentId(11), - microgrid_id=_MICROGRID_ID, - rated_fuse_current=10_000, - ), - UnspecifiedBattery(id=ComponentId(61), microgrid_id=_MICROGRID_ID), - }, - ), - ( - {9, 51, 41, 21, 101}, - { - Meter(id=ComponentId(41), microgrid_id=_MICROGRID_ID), - BatteryInverter(id=ComponentId(51), microgrid_id=_MICROGRID_ID), - Meter(id=ComponentId(21), microgrid_id=_MICROGRID_ID), - }, - ), - ], - ) - def test_matching_ids( - self, - sample_graph: gr.ComponentGraph, - int_ids: set[int], - expected: set[Component], - ) -> None: - """Test the graph component query with component ID filter.""" - components = sample_graph.components( - matching_ids=(ComponentId(id) for id in int_ids) - ) - assert components == expected - - @pytest.mark.parametrize( - "types, expected", - [ - ({EvCharger}, set()), - ( - {Battery, EvCharger}, - {UnspecifiedBattery(id=ComponentId(61), microgrid_id=_MICROGRID_ID)}, - ), - ( - {GridConnectionPoint}, - { - GridConnectionPoint( - id=ComponentId(11), - microgrid_id=_MICROGRID_ID, - rated_fuse_current=10_000, - ) - }, - ), - ( - {Meter}, - { - Meter(id=ComponentId(21), microgrid_id=_MICROGRID_ID), - Meter(id=ComponentId(41), microgrid_id=_MICROGRID_ID), - }, - ), - ( - {BatteryInverter}, - {BatteryInverter(id=ComponentId(51), microgrid_id=_MICROGRID_ID)}, - ), - ( - {Battery}, - {UnspecifiedBattery(id=ComponentId(61), microgrid_id=_MICROGRID_ID)}, - ), - ( - {GridConnectionPoint, Battery}, - { - GridConnectionPoint( - id=ComponentId(11), - microgrid_id=_MICROGRID_ID, - rated_fuse_current=10_000, - ), - UnspecifiedBattery(id=ComponentId(61), microgrid_id=_MICROGRID_ID), - }, - ), - ( - {Meter, Battery, EvCharger}, - { - Meter(id=ComponentId(21), microgrid_id=_MICROGRID_ID), - Meter(id=ComponentId(41), microgrid_id=_MICROGRID_ID), - UnspecifiedBattery(id=ComponentId(61), microgrid_id=_MICROGRID_ID), - }, - ), - ], - ) - def test_matching_types( - self, - sample_graph: gr.ComponentGraph, - types: set[type[Component]], - expected: set[Component], - ) -> None: - """Test the graph component query with component type filter.""" - assert sample_graph.components(matching_types=types) == expected - - @pytest.mark.parametrize( - "int_ids, types, expected", - [ - ( - {11}, - {GridConnectionPoint}, - { - GridConnectionPoint( - id=ComponentId(11), - microgrid_id=_MICROGRID_ID, - rated_fuse_current=10_000, - ) - }, - ), - ({31}, {GridConnectionPoint}, set()), - ( - {61}, - {Battery}, - {UnspecifiedBattery(id=ComponentId(61), microgrid_id=_MICROGRID_ID)}, - ), - ( - {11, 21, 31, 61}, - {Meter, Battery}, - { - UnspecifiedBattery(id=ComponentId(61), microgrid_id=_MICROGRID_ID), - Meter(id=ComponentId(21), microgrid_id=_MICROGRID_ID), - }, - ), - ], - ) - def test_matching_ids_and_types( - self, - sample_graph: gr.ComponentGraph, - int_ids: set[int], - types: set[type[Component]], - expected: set[Component], - ) -> None: - """Test the graph component query with composite filter.""" - # when both filters are applied, they are combined via AND logic, i.e. - # the component must have one of the specified IDs and be of one of - # the specified types - components = sample_graph.components( - matching_ids=(ComponentId(id) for id in int_ids), matching_types=types - ) - assert components == expected - - def test_components_without_filters( - self, sample_input_components: set[Component], sample_graph: gr.ComponentGraph - ) -> None: - """Test the graph component query without filters.""" - # without any filter applied, we get back all the components in the graph - assert len(sample_graph.components()) == len(sample_input_components) - assert sample_graph.components() == sample_input_components - - def test_connection_filters(self) -> None: # pylint: disable=too-many-locals - """Test the graph connection query with filters.""" - # Components - grid_1 = GridConnectionPoint( - id=ComponentId(1), microgrid_id=_MICROGRID_ID, rated_fuse_current=10_000 - ) - meter_2 = Meter(id=ComponentId(2), microgrid_id=_MICROGRID_ID) - meter_3 = Meter(id=ComponentId(3), microgrid_id=_MICROGRID_ID) - charger_4 = UnspecifiedEvCharger(id=ComponentId(4), microgrid_id=_MICROGRID_ID) - charger_5 = UnspecifiedEvCharger(id=ComponentId(5), microgrid_id=_MICROGRID_ID) - charger_6 = UnspecifiedEvCharger(id=ComponentId(6), microgrid_id=_MICROGRID_ID) - - components = {grid_1, meter_2, meter_3, charger_4, charger_5, charger_6} - - # Connections - conn_1_2 = ComponentConnection(source=grid_1.id, destination=meter_2.id) - conn_1_3 = ComponentConnection(source=grid_1.id, destination=meter_3.id) - conn_2_4 = ComponentConnection(source=meter_2.id, destination=charger_4.id) - conn_2_5 = ComponentConnection(source=meter_2.id, destination=charger_5.id) - conn_2_6 = ComponentConnection(source=meter_2.id, destination=charger_6.id) - - connections = {conn_1_2, conn_1_3, conn_2_4, conn_2_5, conn_2_6} - _graph_implementation = gr._MicrogridComponentGraph( - components=components, - connections=connections, - ) - graph: gr.ComponentGraph = _graph_implementation - - # without any filter applied, we get back all the connections in the graph - assert graph.connections() == connections - - # with start filter applied, we get back only connections whose `start` - # component matches one of the provided IDs - assert graph.connections(matching_sources=ComponentId(8)) == set() - assert graph.connections(matching_sources=ComponentId(7)) == set() - assert graph.connections(matching_sources={charger_6.id}) == set() - assert graph.connections(matching_sources={charger_5.id}) == set() - assert graph.connections(matching_sources={charger_4.id}) == set() - assert graph.connections(matching_sources={meter_3.id}) == set() - assert graph.connections(matching_sources={meter_2.id}) == { - conn_2_4, - conn_2_5, - conn_2_6, - } - assert graph.connections(matching_sources={grid_1.id}) == {conn_1_2, conn_1_3} - assert graph.connections( - matching_sources={grid_1.id, meter_3.id, charger_5.id} - ) == {conn_1_2, conn_1_3} - assert graph.connections( - matching_sources={grid_1.id, meter_2.id, charger_5.id, charger_6.id} - ) == {conn_1_2, conn_1_3, conn_2_4, conn_2_5, conn_2_6} - - # with end filter applied, we get back only connections whose `end` - # component matches one of the provided IDs - assert graph.connections(matching_destinations=ComponentId(8)) == set() - assert graph.connections(matching_destinations={charger_6.id}) == {conn_2_6} - assert graph.connections(matching_destinations={charger_5.id}) == {conn_2_5} - assert graph.connections(matching_destinations={charger_4.id}) == {conn_2_4} - assert graph.connections(matching_destinations={meter_3.id}) == {conn_1_3} - assert graph.connections(matching_destinations={meter_2.id}) == {conn_1_2} - assert graph.connections(matching_destinations={grid_1.id}) == set() - assert graph.connections( - matching_destinations={grid_1.id, meter_2.id, meter_3.id} - ) == { - conn_1_2, - conn_1_3, - } - assert graph.connections( - matching_destinations={charger_4.id, charger_5.id, charger_6.id} - ) == {conn_2_4, conn_2_5, conn_2_6} - - assert graph.connections( - matching_destinations={ - meter_2.id, - charger_4.id, - charger_6.id, - ComponentId(8), - } - ) == {conn_1_2, conn_2_4, conn_2_6} - assert graph.connections(matching_destinations={grid_1.id}) == set() - - # when both filters are applied, they are combined via AND logic, i.e. - # a connection must have its `start` matching one of the provided start - # values, and its `end` matching one of the provided end values - assert graph.connections( - matching_sources={grid_1.id}, matching_destinations={meter_2.id} - ) == {conn_1_2} - assert ( - graph.connections( - matching_sources={meter_2.id}, matching_destinations={meter_3.id} - ) - == set() - ) - assert graph.connections( - matching_sources={grid_1.id, meter_2.id}, - matching_destinations={meter_3.id, charger_4.id}, - ) == { - conn_1_3, - conn_2_4, - } - assert graph.connections( - matching_sources={meter_2.id, meter_3.id}, - matching_destinations={charger_5.id, charger_6.id, ComponentId(7)}, - ) == { - conn_2_5, - conn_2_6, - } - - def test_dfs_search_two_grid_meters(self) -> None: - """Test DFS searching PV components in a graph with two grid meters.""" - grid_1 = GridConnectionPoint( - id=ComponentId(1), microgrid_id=_MICROGRID_ID, rated_fuse_current=10_000 - ) - solar_inverter_4 = SolarInverter(id=ComponentId(4), microgrid_id=_MICROGRID_ID) - solar_inverter_5 = SolarInverter(id=ComponentId(5), microgrid_id=_MICROGRID_ID) - meter_2 = Meter(id=ComponentId(2), microgrid_id=_MICROGRID_ID) - meter_3 = Meter(id=ComponentId(3), microgrid_id=_MICROGRID_ID) - - graph = gr._MicrogridComponentGraph( - components={grid_1, meter_2, meter_3, solar_inverter_4, solar_inverter_5}, - connections={ - ComponentConnection(source=grid_1.id, destination=meter_2.id), - ComponentConnection(source=grid_1.id, destination=meter_3.id), - ComponentConnection(source=meter_2.id, destination=solar_inverter_4.id), - ComponentConnection(source=meter_2.id, destination=solar_inverter_5.id), - }, - ) - - result = graph.dfs(grid_1, set(), graph.is_pv_inverter) - assert result == {solar_inverter_4, solar_inverter_5} - - def test_dfs_search_grid_meter(self) -> None: - """Test DFS searching PV components in a graph with a single grid meter.""" - grid_1 = GridConnectionPoint( - id=ComponentId(1), microgrid_id=_MICROGRID_ID, rated_fuse_current=10_000 - ) - meter_2 = Meter(id=ComponentId(2), microgrid_id=_MICROGRID_ID) - solar_meter_3 = Meter(id=ComponentId(3), microgrid_id=_MICROGRID_ID) - solar_meter_4 = Meter(id=ComponentId(4), microgrid_id=_MICROGRID_ID) - solar_inverter_5 = SolarInverter(id=ComponentId(5), microgrid_id=_MICROGRID_ID) - solar_inverter_6 = SolarInverter(id=ComponentId(6), microgrid_id=_MICROGRID_ID) - - solar_meters = {solar_meter_3, solar_meter_4} - - graph = gr._MicrogridComponentGraph( - components={ - grid_1, - meter_2, - *solar_meters, - solar_inverter_5, - solar_inverter_6, - }, - connections={ - ComponentConnection(source=grid_1.id, destination=meter_2.id), - ComponentConnection(source=meter_2.id, destination=solar_meter_3.id), - ComponentConnection(source=meter_2.id, destination=solar_meter_4.id), - ComponentConnection( - source=solar_meter_3.id, destination=solar_inverter_5.id - ), - ComponentConnection( - source=solar_meter_4.id, destination=solar_inverter_6.id - ), - }, - ) - - result = graph.dfs(grid_1, set(), graph.is_pv_chain) - assert result == solar_meters - - def test_dfs_search_grid_meter_no_pv_meter(self) -> None: - """Test DFS searching PV components in a graph with a single grid meter.""" - grid_1 = GridConnectionPoint( - id=ComponentId(1), microgrid_id=_MICROGRID_ID, rated_fuse_current=10_000 - ) - meter_2 = Meter(id=ComponentId(2), microgrid_id=_MICROGRID_ID) - solar_inverter_3 = SolarInverter(id=ComponentId(3), microgrid_id=_MICROGRID_ID) - solar_inverter_4 = SolarInverter(id=ComponentId(4), microgrid_id=_MICROGRID_ID) - solar_inverters = {solar_inverter_3, solar_inverter_4} - - graph = gr._MicrogridComponentGraph( - components={grid_1, meter_2, *solar_inverters}, - connections={ - ComponentConnection(source=grid_1.id, destination=meter_2.id), - ComponentConnection(source=meter_2.id, destination=solar_inverter_3.id), - ComponentConnection(source=meter_2.id, destination=solar_inverter_4.id), - }, - ) - - result = graph.dfs(grid_1, set(), graph.is_pv_chain) - assert result == solar_inverters - - def test_dfs_search_no_grid_meter(self) -> None: - """Test DFS searching PV components in a graph with no grid meter.""" - grid_1 = GridConnectionPoint( - id=ComponentId(1), microgrid_id=_MICROGRID_ID, rated_fuse_current=10_000 - ) - meter_2 = Meter(id=ComponentId(2), microgrid_id=_MICROGRID_ID) - solar_meter_3 = Meter(id=ComponentId(3), microgrid_id=_MICROGRID_ID) - solar_meter_4 = Meter(id=ComponentId(4), microgrid_id=_MICROGRID_ID) - solar_meters = {solar_meter_3, solar_meter_4} - solar_inverter_5 = SolarInverter(id=ComponentId(5), microgrid_id=_MICROGRID_ID) - solar_inverter_6 = SolarInverter(id=ComponentId(6), microgrid_id=_MICROGRID_ID) - - graph = gr._MicrogridComponentGraph( - components={ - grid_1, - meter_2, - *solar_meters, - solar_inverter_5, - solar_inverter_6, - }, - connections={ - ComponentConnection(source=grid_1.id, destination=meter_2.id), - ComponentConnection(source=grid_1.id, destination=solar_meter_3.id), - ComponentConnection(source=grid_1.id, destination=solar_meter_4.id), - ComponentConnection( - source=solar_meter_3.id, destination=solar_inverter_5.id - ), - ComponentConnection( - source=solar_meter_4.id, destination=solar_inverter_6.id - ), - }, - ) - - result = graph.dfs(grid_1, set(), graph.is_pv_chain) - assert result == solar_meters - - def test_dfs_search_nested_components(self) -> None: - """Test DFS searching PV components in a graph with nested components.""" - grid_1 = GridConnectionPoint( - id=ComponentId(1), microgrid_id=_MICROGRID_ID, rated_fuse_current=10_000 - ) - meter_2 = Meter(id=ComponentId(2), microgrid_id=_MICROGRID_ID) - meter_3 = Meter(id=ComponentId(3), microgrid_id=_MICROGRID_ID) - meter_4 = Meter(id=ComponentId(4), microgrid_id=_MICROGRID_ID) - meter_5 = Meter(id=ComponentId(5), microgrid_id=_MICROGRID_ID) - battery_inverter_6 = BatteryInverter( - id=ComponentId(6), microgrid_id=_MICROGRID_ID - ) - battery_inverter_7 = BatteryInverter( - id=ComponentId(7), microgrid_id=_MICROGRID_ID - ) - battery_inverter_8 = BatteryInverter( - id=ComponentId(8), microgrid_id=_MICROGRID_ID - ) - battery_components = {meter_4, meter_5, battery_inverter_6} - - graph = gr._MicrogridComponentGraph( - components={ - grid_1, - meter_2, - meter_3, - battery_inverter_7, - battery_inverter_8, - }.union(battery_components), - connections={ - ComponentConnection(source=grid_1.id, destination=meter_2.id), - ComponentConnection(source=meter_2.id, destination=meter_3.id), - ComponentConnection( - source=meter_2.id, destination=battery_inverter_6.id - ), - ComponentConnection(source=meter_3.id, destination=meter_4.id), - ComponentConnection(source=meter_3.id, destination=meter_5.id), - ComponentConnection( - source=meter_4.id, destination=battery_inverter_7.id - ), - ComponentConnection( - source=meter_5.id, destination=battery_inverter_8.id - ), - }, - ) - - assert set() == graph.dfs(grid_1, set(), graph.is_pv_chain) - assert battery_components == graph.dfs(grid_1, set(), graph.is_battery_chain) - - def test_find_first_descendant_component(self) -> None: - """Test scenarios for finding the first descendant component.""" - grid_1 = GridConnectionPoint( - id=ComponentId(1), microgrid_id=_MICROGRID_ID, rated_fuse_current=10_000 - ) - meter_2 = Meter(id=ComponentId(2), microgrid_id=_MICROGRID_ID) - meter_3 = Meter(id=ComponentId(3), microgrid_id=_MICROGRID_ID) - battery_inverter_4 = BatteryInverter( - id=ComponentId(4), microgrid_id=_MICROGRID_ID - ) - solar_inverter_5 = SolarInverter(id=ComponentId(5), microgrid_id=_MICROGRID_ID) - ev_charger_6 = UnspecifiedEvCharger( - id=ComponentId(6), microgrid_id=_MICROGRID_ID - ) - - graph = gr._MicrogridComponentGraph( - components={ - grid_1, - meter_2, - meter_3, - battery_inverter_4, - solar_inverter_5, - ev_charger_6, - }, - connections={ - ComponentConnection(source=grid_1.id, destination=meter_2.id), - ComponentConnection(source=meter_2.id, destination=meter_3.id), - ComponentConnection( - source=meter_2.id, destination=battery_inverter_4.id - ), - ComponentConnection(source=meter_2.id, destination=solar_inverter_5.id), - ComponentConnection(source=meter_3.id, destination=ev_charger_6.id), - }, - ) - - # Find the first descendant component of the grid endpoint. - result = graph.find_first_descendant_component( - descendants=[Meter], - ) - assert result == meter_2 - - # Find the first descendant component of the grid, - # considering meter or inverter categories. - result = graph.find_first_descendant_component( - descendants=[Meter, Inverter], - ) - assert result == meter_2 - - # Find the first descendant component of the grid, - # considering only meter category - should return the first meter. - result = graph.find_first_descendant_component( - descendants=[Meter], - ) - assert result == meter_2 - - # Verify behavior when component is not found in immediate descendant - # categories for the first meter. - with pytest.raises(ValueError): - graph.find_first_descendant_component( - descendants=[EvCharger, Battery], - ) - - # Verify behavior when component is not found in immediate descendant - # categories from the grid component as root. - with pytest.raises(ValueError): - graph.find_first_descendant_component( - descendants=[Inverter], - ) - - -class Test_MicrogridComponentGraph: - """Test cases for the package-internal implementation of the ComponentGraph. - - The _MicrogridComponentGraph class is internal to the `microgrid` package, and - defines functionality intended to allow the graph to be (re)populated from the - microgrid API. These test cases cover those package internals. - """ - - def test___init__(self) -> None: - """Test the constructor.""" - # it is possible to instantiate an empty graph, but - # it will not be considered valid until it has been - # populated with components and connections - empty_graph = gr._MicrogridComponentGraph() - assert set(empty_graph.components()) == set() - assert list(empty_graph.connections()) == [] - with pytest.raises(gr.InvalidGraphError): - empty_graph.validate() - - grid_1 = GridConnectionPoint( - id=ComponentId(1), microgrid_id=_MICROGRID_ID, rated_fuse_current=10_000 - ) - meter_2 = Meter(id=ComponentId(2), microgrid_id=_MICROGRID_ID) - unrecognized_3 = UnrecognizedComponent( - id=ComponentId(3), microgrid_id=_MICROGRID_ID, category=666 - ) - conn_1_2 = ComponentConnection(source=grid_1.id, destination=meter_2.id) - conn_1_3 = ComponentConnection(source=grid_1.id, destination=unrecognized_3.id) - - # if components and connections are provided, - # must provide both non-empty, not one or the - # other - with pytest.raises(gr.InvalidGraphError): - gr._MicrogridComponentGraph(components={grid_1}) - - with pytest.raises(gr.InvalidGraphError): - gr._MicrogridComponentGraph(connections={conn_1_2}) - - # if both are provided, the graph data must itself - # be valid (we give just a couple of cases of each - # here: a comprehensive set of the different kinds - # of invalid graph data are provided in test cases - # for the different `_validate*` methods) - - # minimal valid microgrid data: a grid endpoint - # connected to a meter - grid_and_meter = gr._MicrogridComponentGraph( - components={grid_1, meter_2}, connections={conn_1_2} - ) - expected = {grid_1, meter_2} - assert len(grid_and_meter.components()) == len(expected) - assert set(grid_and_meter.components()) == expected - assert list(grid_and_meter.connections()) == [conn_1_2] - grid_and_meter.validate() - - # invalid graph data: unknown component category - with pytest.raises(gr.InvalidGraphError): - gr._MicrogridComponentGraph( - components={grid_1, meter_2, unrecognized_3}, - connections={conn_1_2, conn_1_3}, - ) - - # invalid graph data: a connection between components that do not exist - with pytest.raises(gr.InvalidGraphError): - gr._MicrogridComponentGraph( - components={grid_1, meter_2}, - connections={conn_1_2, conn_1_3}, - ) - - def test_refresh_from(self) -> None: # pylint: disable=too-many-locals - """Test the refresh_from method.""" - graph = gr._MicrogridComponentGraph() - assert set(graph.components()) == set() - assert list(graph.connections()) == [] - with pytest.raises(gr.InvalidGraphError): - graph.validate() - - # both connections and components must be non-empty - with pytest.raises(gr.InvalidGraphError): - graph.refresh_from(set(), set()) - assert set(graph.components()) == set() - assert list(graph.connections()) == [] - with pytest.raises(gr.InvalidGraphError): - graph.validate() - - grid_1 = GridConnectionPoint( - id=ComponentId(1), microgrid_id=_MICROGRID_ID, rated_fuse_current=10_000 - ) - meter_2 = Meter(id=ComponentId(2), microgrid_id=_MICROGRID_ID) - meter_3 = Meter(id=ComponentId(3), microgrid_id=_MICROGRID_ID) - meter_4 = Meter(id=ComponentId(4), microgrid_id=_MICROGRID_ID) - inverter_5 = UnspecifiedInverter(id=ComponentId(5), microgrid_id=_MICROGRID_ID) - battery_6 = UnspecifiedBattery(id=ComponentId(6), microgrid_id=_MICROGRID_ID) - grid_7 = GridConnectionPoint( - id=ComponentId(7), microgrid_id=_MICROGRID_ID, rated_fuse_current=10_000 - ) - meter_8 = Meter(id=ComponentId(8), microgrid_id=_MICROGRID_ID) - inverter_9 = UnspecifiedInverter(id=ComponentId(9), microgrid_id=_MICROGRID_ID) - grid_10 = GridConnectionPoint( - id=ComponentId(10), microgrid_id=_MICROGRID_ID, rated_fuse_current=10_000 - ) - meter_11 = Meter(id=ComponentId(11), microgrid_id=_MICROGRID_ID) - - conn_1_2 = ComponentConnection(source=grid_1.id, destination=meter_2.id) - conn_2_3 = ComponentConnection(source=meter_2.id, destination=meter_3.id) - conn_2_4 = ComponentConnection(source=meter_2.id, destination=meter_4.id) - conn_4_5 = ComponentConnection(source=meter_4.id, destination=inverter_5.id) - conn_5_6 = ComponentConnection(source=inverter_5.id, destination=battery_6.id) - conn_7_8 = ComponentConnection(source=meter_3.id, destination=meter_4.id) - conn_8_9 = ComponentConnection(source=meter_4.id, destination=inverter_5.id) - conn_9_8 = ComponentConnection(source=inverter_5.id, destination=meter_3.id) - conn_9_7 = ComponentConnection(source=inverter_5.id, destination=grid_7.id) - conn_10_11 = ComponentConnection(source=grid_10.id, destination=meter_11.id) - - with pytest.raises(gr.InvalidGraphError): - graph.refresh_from(set(), {conn_1_2}) - assert set(graph.components()) == set() - assert list(graph.connections()) == [] - with pytest.raises(gr.InvalidGraphError): - graph.validate() - - with pytest.raises(gr.InvalidGraphError): - graph.refresh_from({grid_1}, set()) - assert set(graph.components()) == set() - assert list(graph.connections()) == [] - with pytest.raises(gr.InvalidGraphError): - graph.validate() - - # if both are provided, valid graph data must be present - - # invalid component - with pytest.raises(ValueError, match=r"ComponentId can't be negative."): - graph.refresh_from( - components={ - GridConnectionPoint( - id=ComponentId(-1), - microgrid_id=_MICROGRID_ID, - rated_fuse_current=10_000, - ), - meter_2, - meter_3, - }, - connections={conn_1_2}, - ) - assert set(graph.components()) == set() - assert list(graph.connections()) == [] - with pytest.raises(gr.InvalidGraphError): - graph.validate() - - # invalid connection - with pytest.raises( - ValueError, match=r"Source and destination components must be different" - ): - graph.refresh_from( - components={grid_1, meter_2, meter_3}, - connections={ - ComponentConnection(source=grid_1.id, destination=grid_1.id), - conn_2_3, - }, - ) - assert set(graph.components()) == set() - assert list(graph.connections()) == [] - with pytest.raises(gr.InvalidGraphError): - graph.validate() - - expected_components = {grid_1, meter_2, meter_4, inverter_5, battery_6} - expected_connections = {conn_1_2, conn_2_4, conn_4_5, conn_5_6} - # valid graph with both load and battery setup - graph.refresh_from( - components=expected_components, - connections=expected_connections, - ) - assert len(graph.components()) == len(expected_components) - assert set(graph.components()) == expected_components - assert graph.connections() == expected_connections - graph.validate() - - # if invalid graph data is provided (in this case, the graph - # is not a tree), then the existing contents of the component - # graph will remain unchanged - with pytest.raises(gr.InvalidGraphError): - graph.refresh_from( - components={grid_7, meter_8, inverter_9}, - connections={conn_7_8, conn_8_9, conn_9_8}, - ) - - assert len(graph.components()) == len(expected_components) - assert graph.components() == expected_components - assert graph.connections() == expected_connections - graph.validate() - - # confirm that if `correct_errors` callback is not `None`, - # it will be invoked when graph data is invalid - error_correction = False - - def pretend_to_correct_errors(_g: gr._MicrogridComponentGraph) -> None: - nonlocal error_correction - error_correction = True - - with pytest.raises(gr.InvalidGraphError): - graph.refresh_from( - components={ - grid_7, - inverter_9, - }, - connections={conn_9_7}, - correct_errors=pretend_to_correct_errors, - ) - - assert error_correction is True - - # if valid graph data is provided, then the existing graph - # contents will be overwritten - expected_components = { - grid_10, - meter_11, - } - graph.refresh_from( - components=expected_components, - connections={conn_10_11}, - ) - assert len(graph.components()) == len(expected_components) - assert set(graph.components()) == expected_components - assert graph.connections() == {conn_10_11} - graph.validate() - - async def test_refresh_from_client(self) -> None: - """Test the refresh_from_client method.""" - graph = gr._MicrogridComponentGraph() - assert graph.components() == set() - assert graph.connections() == set() - with pytest.raises(gr.InvalidGraphError): - graph.validate() - - client = mock.MagicMock(name="client", spec=MicrogridApiClient) - client.list_components = mock.AsyncMock( - name="client.list_components()", return_value=[] - ) - client.list_connections = mock.AsyncMock( - name="client.list_connections()", return_value=[] - ) - - # both components and connections must be non-empty - with pytest.raises(gr.InvalidGraphError): - await graph.refresh_from_client(client) - assert graph.components() == set() - assert graph.connections() == set() - with pytest.raises(gr.InvalidGraphError): - graph.validate() - - client.list_components.return_value = [ - GridConnectionPoint( - id=ComponentId(1), microgrid_id=_MICROGRID_ID, rated_fuse_current=10_000 - ) - ] - with pytest.raises(gr.InvalidGraphError): - await graph.refresh_from_client(client) - assert graph.components() == set() - assert graph.connections() == set() - with pytest.raises(gr.InvalidGraphError): - graph.validate() - - client.list_components.return_value = [] - client.list_connections.return_value = [ - ComponentConnection(source=ComponentId(1), destination=ComponentId(2)) - ] - with pytest.raises(gr.InvalidGraphError): - await graph.refresh_from_client(client) - assert graph.components() == set() - assert graph.connections() == set() - with pytest.raises(gr.InvalidGraphError): - graph.validate() - - # if both are provided, valid graph data must be present - - # valid graph with meter, and EV charger - grid_101 = GridConnectionPoint( - id=ComponentId(101), microgrid_id=_MICROGRID_ID, rated_fuse_current=0 - ) - meter_111 = Meter(id=ComponentId(111), microgrid_id=_MICROGRID_ID) - charger_131 = UnspecifiedEvCharger( - id=ComponentId(131), microgrid_id=_MICROGRID_ID - ) - expected_components = [grid_101, meter_111, charger_131] - expected_connections = [ - ComponentConnection(source=grid_101.id, destination=meter_111.id), - ComponentConnection(source=meter_111.id, destination=charger_131.id), - ] - client.list_components.return_value = expected_components - client.list_connections.return_value = expected_connections - await graph.refresh_from_client(client) - - # Note: we need to add GriMetadata as a dict here, because that's what - # the ComponentGraph does too, and we need to be able to compare the - # two graphs. - assert graph.components() == set(expected_components) - assert graph.connections() == set(expected_connections) - graph.validate() - - # if valid graph data is provided, then the existing graph - # contents will be overwritten - grid_707 = GridConnectionPoint( - id=ComponentId(707), microgrid_id=_MICROGRID_ID, rated_fuse_current=0 - ) - meter_717 = Meter(id=ComponentId(717), microgrid_id=_MICROGRID_ID) - inverter_727 = UnspecifiedInverter( - id=ComponentId(727), microgrid_id=_MICROGRID_ID - ) - battery_737 = UnspecifiedBattery( - id=ComponentId(737), microgrid_id=_MICROGRID_ID - ) - meter_747 = Meter(id=ComponentId(747), microgrid_id=_MICROGRID_ID) - expected_components = [ - grid_707, - meter_717, - inverter_727, - battery_737, - meter_747, - ] - expected_connections = [ - ComponentConnection(source=grid_707.id, destination=meter_717.id), - ComponentConnection(source=meter_717.id, destination=inverter_727.id), - ComponentConnection(source=inverter_727.id, destination=battery_737.id), - ComponentConnection(source=meter_717.id, destination=meter_747.id), - ] - client.list_components.return_value = expected_components - client.list_connections.return_value = expected_connections - await graph.refresh_from_client(client) - - assert graph.components() == set(expected_components) - assert graph.connections() == set(expected_connections) - graph.validate() - - def test_validate(self) -> None: - """Test the validate method.""" - # `validate` will fail if any of the following are the case: - # - # * the graph data is not valid - # * there is not a valid graph root - # * a grid endpoint is present but not set up correctly - # * intermediary components are not set up correctly - # * junctions are not set up correctly - # * leaf components are not set up correctly - # - # Full coverage of the details of how that can happen is left - # to the individual `test__validate_*` cases below: for this - # level, we just check one case of each. - # - # To ensure clean testing of the method, we cheat by setting - # underlying graph data directly. - - graph = gr._MicrogridComponentGraph() - - # graph data is not valid: no components or connections - graph._graph.clear() - with pytest.raises(gr.InvalidGraphError, match="No components in graph!"): - graph.validate() - - # graph root is not valid: multiple potential root nodes - graph._graph.clear() - grid_1 = GridConnectionPoint( - id=ComponentId(1), microgrid_id=_MICROGRID_ID, rated_fuse_current=10_000 - ) - unspecified_2 = UnspecifiedComponent( - id=ComponentId(2), microgrid_id=_MICROGRID_ID - ) - meter_3 = Meter(id=ComponentId(3), microgrid_id=_MICROGRID_ID) - _add_components(graph, grid_1, unspecified_2, meter_3) - _add_connections( - graph, - ComponentConnection(source=grid_1.id, destination=meter_3.id), - ComponentConnection(source=unspecified_2.id, destination=meter_3.id), - ) - with pytest.raises(gr.InvalidGraphError, match="Multiple potential root nodes"): - graph.validate() - - # grid endpoint is not set up correctly: multiple grid endpoints - graph._graph.clear() - grid_2 = GridConnectionPoint( - id=ComponentId(2), microgrid_id=_MICROGRID_ID, rated_fuse_current=10_000 - ) - _add_components(graph, grid_1, grid_2, meter_3) - _add_connections( - graph, - ComponentConnection(source=grid_1.id, destination=meter_3.id), - ComponentConnection(source=grid_2.id, destination=meter_3.id), - ) - with pytest.raises( - gr.InvalidGraphError, - match=re.escape( - r"Multiple potential root nodes: CID1, " - r"CID2" - ), - ): - graph.validate() - - # leaf components are not set up correctly: a battery has - # a successor in the graph - graph._graph.clear() - battery_2 = UnspecifiedBattery(id=ComponentId(2), microgrid_id=_MICROGRID_ID) - _add_components(graph, grid_1, battery_2, meter_3) - _add_connections( - graph, - ComponentConnection(source=grid_1.id, destination=battery_2.id), - ComponentConnection(source=battery_2.id, destination=meter_3.id), - ) - with pytest.raises( - gr.InvalidGraphError, match="Leaf components with graph successors" - ): - graph.validate() - - def test__validate_graph(self) -> None: - """Test the _validate_graph method.""" - # to ensure clean testing of the individual method, - # we cheat by setting underlying graph data directly - - graph = gr._MicrogridComponentGraph() - assert set(graph.components()) == set() - assert list(graph.connections()) == [] - - # graph has no nodes (i.e. no components) - with pytest.raises(gr.InvalidGraphError, match="No components in graph!"): - graph._validate_graph() - - # graph has no connections - graph._graph.clear() - grid_1 = GridConnectionPoint( - id=ComponentId(1), microgrid_id=_MICROGRID_ID, rated_fuse_current=10_000 - ) - _add_components(graph, grid_1) - with pytest.raises( - gr.InvalidGraphError, match="No connections in component graph!" - ): - graph._validate_graph() - - # graph is not a tree - graph._graph.clear() - inverter_2 = UnspecifiedInverter(id=ComponentId(2), microgrid_id=_MICROGRID_ID) - meter_3 = Meter(id=ComponentId(3), microgrid_id=_MICROGRID_ID) - _add_components(graph, grid_1, inverter_2, meter_3) - _add_connections( - graph, - ComponentConnection(source=grid_1.id, destination=inverter_2.id), - ComponentConnection(source=inverter_2.id, destination=meter_3.id), - ComponentConnection(source=meter_3.id, destination=inverter_2.id), - ) - with pytest.raises( - gr.InvalidGraphError, match="Component graph is not a tree!" - ): - graph._validate_graph() - - # at least one node is completely unconnected - # (this violates the tree property): - meter_2 = Meter(id=ComponentId(2), microgrid_id=_MICROGRID_ID) - unspecified_3 = UnspecifiedComponent( - id=ComponentId(3), microgrid_id=_MICROGRID_ID - ) - _add_components(graph, grid_1, meter_2, unspecified_3) - _add_connections( - graph, - ComponentConnection(source=grid_1.id, destination=meter_2.id), - ) - with pytest.raises( - gr.InvalidGraphError, match="Component graph is not a tree!" - ): - graph._validate_graph() - - def test__validate_graph_root(self) -> None: - """Test the _validate_graph_root method.""" - # to ensure clean testing of the individual method, - # we cheat by setting underlying graph data directly - - graph = gr._MicrogridComponentGraph() - assert set(graph.components()) == set() - assert list(graph.connections()) == [] - - # no node without predecessors (this should already - # get caught by `_validate_graph` but let's confirm - # that `_validate_graph_root` also catches it) - graph._graph.clear() - meter_1 = Meter(id=ComponentId(1), microgrid_id=_MICROGRID_ID) - meter_2 = Meter(id=ComponentId(2), microgrid_id=_MICROGRID_ID) - meter_3 = Meter(id=ComponentId(3), microgrid_id=_MICROGRID_ID) - _add_components(graph, meter_1, meter_2, meter_3) - _add_connections( - graph, - ComponentConnection(source=meter_1.id, destination=meter_2.id), - ComponentConnection(source=meter_2.id, destination=meter_3.id), - ComponentConnection(source=meter_3.id, destination=meter_1.id), - ) - with pytest.raises( - gr.InvalidGraphError, match="No valid root nodes of component graph!" - ): - graph._validate_graph_root() - - # there are nodes without predecessors, but not of - # the valid type(s) NONE, GRID, or JUNCTION - graph._graph.clear() - inverter_2 = UnspecifiedInverter(id=ComponentId(2), microgrid_id=_MICROGRID_ID) - battery_3 = UnspecifiedBattery(id=ComponentId(3), microgrid_id=_MICROGRID_ID) - _add_components(graph, meter_1, inverter_2, battery_3) - _add_connections( - graph, - ComponentConnection(source=meter_1.id, destination=inverter_2.id), - ComponentConnection(source=inverter_2.id, destination=battery_3.id), - ) - with pytest.raises( - gr.InvalidGraphError, match="No valid root nodes of component graph!" - ): - graph._validate_graph_root() - - # there are multiple different potentially valid - # root notes - graph._graph.clear() - unspecified_1 = UnspecifiedComponent( - id=ComponentId(1), microgrid_id=_MICROGRID_ID - ) - grid_2 = GridConnectionPoint( - id=ComponentId(2), microgrid_id=_MICROGRID_ID, rated_fuse_current=10_000 - ) - _add_components(graph, unspecified_1, grid_2, meter_3) - _add_connections( - graph, - ComponentConnection(source=unspecified_1.id, destination=meter_3.id), - ComponentConnection(source=grid_2.id, destination=meter_3.id), - ) - with pytest.raises(gr.InvalidGraphError, match="Multiple potential root nodes"): - graph._validate_graph_root() - - graph._graph.clear() - grid_1 = GridConnectionPoint( - id=ComponentId(1), microgrid_id=_MICROGRID_ID, rated_fuse_current=10_000 - ) - _add_components(graph, grid_1, grid_2, meter_3) - _add_connections( - graph, - ComponentConnection(source=grid_1.id, destination=meter_3.id), - ComponentConnection(source=grid_2.id, destination=meter_3.id), - ) - with pytest.raises(gr.InvalidGraphError, match="Multiple potential root nodes"): - graph._validate_graph_root() - - # there is just one potential root node but it has no successors - graph._graph.clear() - _add_components(graph, unspecified_1) - with pytest.raises( - gr.InvalidGraphError, match="Graph root .*CID1.* has no successors!" - ): - graph._validate_graph_root() - - graph._graph.clear() - _add_components(graph, grid_2) - with pytest.raises( - gr.InvalidGraphError, match="Graph root .*CID2.* has no successors!" - ): - graph._validate_graph_root() - - graph._graph.clear() - grid_3 = GridConnectionPoint( - id=ComponentId(3), microgrid_id=_MICROGRID_ID, rated_fuse_current=10_000 - ) - _add_components(graph, grid_3) - with pytest.raises( - gr.InvalidGraphError, - match=r"Graph root CID3 has no successors!", - ): - graph._validate_graph_root() - - # there is exactly one potential root node and it has successors - graph._graph.clear() - _add_components(graph, unspecified_1, meter_2) - _add_connections( - graph, - ComponentConnection(source=unspecified_1.id, destination=meter_2.id), - ) - graph._validate_graph_root() - - graph._graph.clear() - _add_components(graph, grid_1, meter_2) - _add_connections( - graph, - ComponentConnection(source=grid_1.id, destination=meter_2.id), - ) - graph._validate_graph_root() - - graph._graph.clear() - _add_components(graph, grid_1, meter_2) - _add_connections( - graph, - ComponentConnection(source=grid_1.id, destination=meter_2.id), - ) - graph._validate_graph_root() - - def test__validate_grid_endpoint(self) -> None: - """Test the _validate_grid_endpoint method.""" - # to ensure clean testing of the individual method, - # we cheat by setting underlying graph data directly - - graph = gr._MicrogridComponentGraph() - assert set(graph.components()) == set() - assert list(graph.connections()) == [] - - # missing grid endpoint is OK as the graph might have - # another kind of root - graph._graph.clear() - meter_2 = Meter(id=ComponentId(2), microgrid_id=_MICROGRID_ID) - _add_components(graph, meter_2) - graph._validate_grid_endpoint() - - # multiple grid endpoints - graph._graph.clear() - grid_1 = GridConnectionPoint( - id=ComponentId(1), microgrid_id=_MICROGRID_ID, rated_fuse_current=10_000 - ) - grid_3 = GridConnectionPoint( - id=ComponentId(3), microgrid_id=_MICROGRID_ID, rated_fuse_current=10_000 - ) - _add_components(graph, grid_1, meter_2, grid_3) - _add_connections( - graph, - ComponentConnection(source=grid_1.id, destination=meter_2.id), - ComponentConnection(source=grid_3.id, destination=meter_2.id), - ) - with pytest.raises( - gr.InvalidGraphError, - match="Multiple grid endpoints in component graph", - ): - graph._validate_grid_endpoint() - - # grid endpoint has predecessors - graph._graph.clear() - meter_99 = Meter(id=ComponentId(99), microgrid_id=_MICROGRID_ID) - _add_components(graph, grid_1, meter_99) - _add_connections( - graph, - ComponentConnection(source=meter_99.id, destination=grid_1.id), - ) - with pytest.raises( - gr.InvalidGraphError, - match=re.escape(r"Grid endpoint CID1 has predecessors: CID99"), - ): - graph._validate_grid_endpoint() - - # grid endpoint has no successors - graph._graph.clear() - grid_101 = GridConnectionPoint( - id=ComponentId(101), microgrid_id=_MICROGRID_ID, rated_fuse_current=10_000 - ) - _add_components(graph, grid_101) - with pytest.raises( - gr.InvalidGraphError, - match="Grid endpoint CID101 has no graph successors!", - ): - graph._validate_grid_endpoint() - - # valid grid endpoint with at least one successor - graph._graph.clear() - _add_components( - graph, - grid_1, - meter_2, - ) - _add_connections( - graph, - ComponentConnection(source=grid_1.id, destination=meter_2.id), - ) - graph._validate_grid_endpoint() - - def test__validate_intermediary_components(self) -> None: - """Test the _validate_intermediary_components method.""" - # to ensure clean testing of the individual method, - # we cheat by setting underlying graph data directly - - graph = gr._MicrogridComponentGraph() - assert set(graph.components()) == set() - assert list(graph.connections()) == [] - - # missing predecessor for at least one intermediary node - graph._graph.clear() - inverter_3 = UnspecifiedInverter(id=ComponentId(3), microgrid_id=_MICROGRID_ID) - _add_components(graph, inverter_3) - with pytest.raises( - gr.InvalidGraphError, - match="Intermediary components without graph predecessors", - ): - graph._validate_intermediary_components() - - graph._graph.clear() - grid_1 = GridConnectionPoint( - id=ComponentId(1), microgrid_id=_MICROGRID_ID, rated_fuse_current=10_000 - ) - _add_components(graph, grid_1, inverter_3) - _add_connections( - graph, - ComponentConnection(source=grid_1.id, destination=inverter_3.id), - ) - graph._validate_intermediary_components() - - graph._graph.clear() - meter_2 = Meter(id=ComponentId(2), microgrid_id=_MICROGRID_ID) - _add_components(graph, grid_1, meter_2, inverter_3) - _add_connections( - graph, - ComponentConnection(source=grid_1.id, destination=meter_2.id), - ComponentConnection(source=meter_2.id, destination=inverter_3.id), - ) - graph._validate_intermediary_components() - - # all intermediary nodes have at least one predecessor - # and at least one successor - graph._graph.clear() - battery_4 = UnspecifiedBattery(id=ComponentId(4), microgrid_id=_MICROGRID_ID) - _add_components(graph, grid_1, meter_2, inverter_3, battery_4) - _add_connections( - graph, - ComponentConnection(source=grid_1.id, destination=meter_2.id), - ComponentConnection(source=meter_2.id, destination=inverter_3.id), - ComponentConnection(source=inverter_3.id, destination=battery_4.id), - ) - graph._validate_intermediary_components() - - def test__validate_leaf_components(self) -> None: - """Test the _validate_leaf_components method.""" - # to ensure clean testing of the individual method, - # we cheat by setting underlying graph data directly - - graph = gr._MicrogridComponentGraph() - assert set(graph.components()) == set() - assert list(graph.connections()) == [] - - # missing predecessor for at least one leaf node - graph._graph.clear() - battery_3 = UnspecifiedBattery(id=ComponentId(3), microgrid_id=_MICROGRID_ID) - _add_components(graph, battery_3) - with pytest.raises( - gr.InvalidGraphError, match="Leaf components without graph predecessors" - ): - graph._validate_leaf_components() - - graph._graph.clear() - charger_4 = UnspecifiedEvCharger(id=ComponentId(4), microgrid_id=_MICROGRID_ID) - _add_components(graph, charger_4) - with pytest.raises( - gr.InvalidGraphError, match="Leaf components without graph predecessors" - ): - graph._validate_leaf_components() - - # successors present for at least one leaf node - graph._graph.clear() - grid_1 = GridConnectionPoint( - id=ComponentId(1), microgrid_id=_MICROGRID_ID, rated_fuse_current=10_000 - ) - charger_2 = UnspecifiedEvCharger(id=ComponentId(2), microgrid_id=_MICROGRID_ID) - _add_components(graph, grid_1, charger_2, battery_3) - _add_connections( - graph, - ComponentConnection(source=grid_1.id, destination=charger_2.id), - ComponentConnection(source=charger_2.id, destination=battery_3.id), - ) - with pytest.raises( - gr.InvalidGraphError, match="Leaf components with graph successors" - ): - graph._validate_leaf_components() - - graph._graph.clear() - _add_components(graph, grid_1, battery_3, charger_4) - _add_connections( - graph, - ComponentConnection(source=grid_1.id, destination=battery_3.id), - ComponentConnection(source=battery_3.id, destination=charger_4.id), - ) - with pytest.raises( - gr.InvalidGraphError, match="Leaf components with graph successors" - ): - graph._validate_leaf_components() - - # all leaf nodes have at least one predecessor - # and no successors - graph._graph.clear() - meter_2 = Meter(id=ComponentId(2), microgrid_id=_MICROGRID_ID) - _add_components(graph, grid_1, meter_2, battery_3, charger_4) - _add_connections( - graph, - ComponentConnection(source=grid_1.id, destination=meter_2.id), - ComponentConnection(source=grid_1.id, destination=battery_3.id), - ComponentConnection(source=grid_1.id, destination=charger_4.id), - ) - graph._validate_leaf_components() - - -class TestComponentTypeIdentification: - """Test the component type identification methods in the component graph.""" - - def test_no_comp_meters_pv(self) -> None: - """Test the case where there are no meters in the graph.""" - grid = GridConnectionPoint( - id=ComponentId(1), microgrid_id=_MICROGRID_ID, rated_fuse_current=10_000 - ) - grid_meter = Meter(id=ComponentId(2), microgrid_id=_MICROGRID_ID) - solar_inv_3 = SolarInverter(id=ComponentId(3), microgrid_id=_MICROGRID_ID) - solar_inv_4 = SolarInverter(id=ComponentId(4), microgrid_id=_MICROGRID_ID) - - graph = gr._MicrogridComponentGraph( - components={grid, grid_meter, solar_inv_3, solar_inv_4}, - connections={ - ComponentConnection(source=grid.id, destination=grid_meter.id), - ComponentConnection(source=grid_meter.id, destination=solar_inv_3.id), - ComponentConnection(source=grid_meter.id, destination=solar_inv_4.id), - }, - ) - - assert graph.is_grid_meter(grid_meter) - assert not graph.is_pv_meter(grid_meter) - assert not graph.is_pv_chain(grid_meter) - - assert graph.is_pv_inverter(solar_inv_3) and graph.is_pv_chain(solar_inv_3) - assert graph.is_pv_inverter(solar_inv_4) and graph.is_pv_chain(solar_inv_4) - - def test_no_comp_meters_mixed(self) -> None: - """Test the case where there are no meters in the graph.""" - grid_1 = GridConnectionPoint( - id=ComponentId(1), microgrid_id=_MICROGRID_ID, rated_fuse_current=10_000 - ) - grid_meter_2 = Meter(id=ComponentId(2), microgrid_id=_MICROGRID_ID) - solar_inv_3 = SolarInverter(id=ComponentId(3), microgrid_id=_MICROGRID_ID) - battery_inv_4 = BatteryInverter(id=ComponentId(4), microgrid_id=_MICROGRID_ID) - battery_5 = UnspecifiedBattery(id=ComponentId(5), microgrid_id=_MICROGRID_ID) - - graph = gr._MicrogridComponentGraph( - components={ - grid_1, - grid_meter_2, - solar_inv_3, - battery_inv_4, - battery_5, - }, - connections={ - ComponentConnection(source=grid_1.id, destination=grid_meter_2.id), - ComponentConnection(source=grid_meter_2.id, destination=solar_inv_3.id), - ComponentConnection( - source=grid_meter_2.id, destination=battery_inv_4.id - ), - ComponentConnection(source=battery_inv_4.id, destination=battery_5.id), - }, - ) - - assert graph.is_grid_meter(grid_meter_2) - assert not graph.is_pv_meter(grid_meter_2) - assert not graph.is_pv_chain(grid_meter_2) - - assert graph.is_pv_inverter(solar_inv_3) and graph.is_pv_chain(solar_inv_3) - assert not graph.is_battery_inverter( - solar_inv_3 - ) and not graph.is_battery_chain(solar_inv_3) - - assert graph.is_battery_inverter(battery_inv_4) and graph.is_battery_chain( - battery_inv_4 - ) - assert not graph.is_pv_inverter(battery_inv_4) and not graph.is_pv_chain( - battery_inv_4 - ) - - def test_with_meters(self) -> None: - """Test the case where there are meters in the graph.""" - grid_1 = GridConnectionPoint( - id=ComponentId(1), microgrid_id=_MICROGRID_ID, rated_fuse_current=10_000 - ) - grid_meter_2 = Meter(id=ComponentId(2), microgrid_id=_MICROGRID_ID) - pv_meter_3 = Meter(id=ComponentId(3), microgrid_id=_MICROGRID_ID) - pv_inv_4 = SolarInverter(id=ComponentId(4), microgrid_id=_MICROGRID_ID) - battery_meter_5 = Meter(id=ComponentId(5), microgrid_id=_MICROGRID_ID) - battery_inv_6 = BatteryInverter(id=ComponentId(6), microgrid_id=_MICROGRID_ID) - battery_7 = UnspecifiedBattery(id=ComponentId(7), microgrid_id=_MICROGRID_ID) - - graph = gr._MicrogridComponentGraph( - components={ - grid_1, - grid_meter_2, - pv_meter_3, - pv_inv_4, - battery_meter_5, - battery_inv_6, - battery_7, - }, - connections={ - ComponentConnection(source=grid_1.id, destination=grid_meter_2.id), - ComponentConnection(source=grid_meter_2.id, destination=pv_meter_3.id), - ComponentConnection(source=pv_meter_3.id, destination=pv_inv_4.id), - ComponentConnection( - source=grid_meter_2.id, destination=battery_meter_5.id - ), - ComponentConnection( - source=battery_meter_5.id, destination=battery_inv_6.id - ), - ComponentConnection(source=battery_inv_6.id, destination=battery_7.id), - }, - ) - - assert graph.is_grid_meter(grid_meter_2) - assert not graph.is_pv_meter(grid_meter_2) - assert not graph.is_pv_chain(grid_meter_2) - - assert graph.is_pv_meter(pv_meter_3) - assert graph.is_pv_chain(pv_meter_3) - assert graph.is_pv_chain(pv_inv_4) - assert graph.is_pv_inverter(pv_inv_4) - - assert graph.is_battery_meter(battery_meter_5) - assert graph.is_battery_chain(battery_meter_5) - assert graph.is_battery_chain(battery_inv_6) - assert graph.is_battery_inverter(battery_inv_6) - - def test_without_grid_meters(self) -> None: - """Test the case where there are no grid meters in the graph.""" - grid_1 = GridConnectionPoint( - id=ComponentId(1), microgrid_id=_MICROGRID_ID, rated_fuse_current=10_000 - ) - ev_meter_2 = Meter(id=ComponentId(2), microgrid_id=_MICROGRID_ID) - ev_charger_3 = UnspecifiedEvCharger( - id=ComponentId(3), microgrid_id=_MICROGRID_ID - ) - chp_meter_4 = Meter(id=ComponentId(4), microgrid_id=_MICROGRID_ID) - chp_5 = Chp(id=ComponentId(5), microgrid_id=_MICROGRID_ID) - - graph = gr._MicrogridComponentGraph( - components={ - grid_1, - ev_meter_2, - ev_charger_3, - chp_meter_4, - chp_5, - }, - connections={ - ComponentConnection(source=grid_1.id, destination=ev_meter_2.id), - ComponentConnection(source=ev_meter_2.id, destination=ev_charger_3.id), - ComponentConnection(source=grid_1.id, destination=chp_meter_4.id), - ComponentConnection(source=chp_meter_4.id, destination=chp_5.id), - }, - ) - - assert not graph.is_grid_meter(ev_meter_2) - assert not graph.is_grid_meter(chp_meter_4) - - assert graph.is_ev_charger_meter(ev_meter_2) - assert graph.is_ev_charger(ev_charger_3) - assert graph.is_ev_charger_chain(ev_meter_2) - assert graph.is_ev_charger_chain(ev_charger_3) - - assert graph.is_chp_meter(chp_meter_4) - assert graph.is_chp(chp_5) - assert graph.is_chp_chain(chp_meter_4) - assert graph.is_chp_chain(chp_5) From 678c78a1c6e5cfc56bf29a6f1455e265a74a6d98 Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Thu, 20 Nov 2025 19:48:30 +0100 Subject: [PATCH 15/23] Replace FormulaEngine with the new Formula Signed-off-by: Sahas Subramanian --- .../timeseries/battery_pool/_battery_pool.py | 19 +- .../_battery_pool_reference_store.py | 4 +- src/frequenz/sdk/timeseries/consumer.py | 18 +- .../ev_charger_pool/_ev_charger_pool.py | 38 ++-- .../_ev_charger_pool_reference_store.py | 4 +- src/frequenz/sdk/timeseries/grid.py | 51 ++--- .../logical_meter/_logical_meter.py | 32 ++-- src/frequenz/sdk/timeseries/producer.py | 18 +- .../sdk/timeseries/pv_pool/_pv_pool.py | 15 +- .../pv_pool/_pv_pool_reference_store.py | 4 +- tests/microgrid/test_grid.py | 2 +- .../_battery_pool/test_battery_pool.py | 38 ++-- tests/timeseries/_formulas/__init__.py | 4 + .../test_formula_composition.py | 180 ++++++++++-------- .../_formulas/test_formulas_3_phase.py | 2 +- .../{_formula_engine => _formulas}/utils.py | 11 +- tests/timeseries/mock_resampler.py | 4 +- tests/timeseries/test_frequency_streaming.py | 2 +- 18 files changed, 222 insertions(+), 224 deletions(-) create mode 100644 tests/timeseries/_formulas/__init__.py rename tests/timeseries/{_formula_engine => _formulas}/test_formula_composition.py (77%) rename tests/timeseries/{_formula_engine => _formulas}/utils.py (86%) diff --git a/src/frequenz/sdk/timeseries/battery_pool/_battery_pool.py b/src/frequenz/sdk/timeseries/battery_pool/_battery_pool.py index dd0a4c798..fd1315bea 100644 --- a/src/frequenz/sdk/timeseries/battery_pool/_battery_pool.py +++ b/src/frequenz/sdk/timeseries/battery_pool/_battery_pool.py @@ -17,14 +17,10 @@ from ... import timeseries from ..._internal._channels import MappingReceiverFetcher, ReceiverFetcher -from ...microgrid import _power_distributing, _power_managing +from ...microgrid import _power_distributing, _power_managing, connection_manager from ...timeseries import Sample from .._base_types import SystemBounds -from ..formula_engine import FormulaEngine -from ..formula_engine._formula_generators import ( - BatteryPowerFormula, - FormulaGeneratorConfig, -) +from ..formulas._formula import Formula from ._battery_pool_reference_store import BatteryPoolReferenceStore from ._methods import SendOnUpdate from ._metric_calculator import ( @@ -195,7 +191,7 @@ def component_ids(self) -> abc.Set[ComponentId]: return self._pool_ref_store._batteries @property - def power(self) -> FormulaEngine[Power]: + def power(self) -> Formula[Power]: """Fetch the total power of the batteries in the pool. This formula produces values that are in the Passive Sign Convention (PSC). @@ -210,15 +206,12 @@ def power(self) -> FormulaEngine[Power]: A FormulaEngine that will calculate and stream the total power of all batteries in the pool. """ - engine = self._pool_ref_store._formula_pool.from_power_formula_generator( + engine = self._pool_ref_store._formula_pool.from_power_formula( "battery_pool_power", - BatteryPowerFormula, - FormulaGeneratorConfig( - component_ids=self._pool_ref_store._batteries, - allow_fallback=True, + connection_manager.get().component_graph.battery_formula( + self._pool_ref_store._batteries ), ) - assert isinstance(engine, FormulaEngine) return engine @property diff --git a/src/frequenz/sdk/timeseries/battery_pool/_battery_pool_reference_store.py b/src/frequenz/sdk/timeseries/battery_pool/_battery_pool_reference_store.py index 3154b47b6..d24b866d4 100644 --- a/src/frequenz/sdk/timeseries/battery_pool/_battery_pool_reference_store.py +++ b/src/frequenz/sdk/timeseries/battery_pool/_battery_pool_reference_store.py @@ -21,7 +21,7 @@ from ...microgrid._power_distributing import Result from ...microgrid._power_distributing._component_status import ComponentPoolStatus from ...microgrid._power_managing._base_classes import Proposal, ReportRequest -from ..formula_engine._formula_engine_pool import FormulaEnginePool +from ..formulas._formula_pool import FormulaPool from ._methods import MetricAggregator @@ -117,7 +117,7 @@ def __init__( # pylint: disable=too-many-arguments self._power_dist_results_fetcher: ReceiverFetcher[Result] = ( power_distribution_results_fetcher ) - self._formula_pool: FormulaEnginePool = FormulaEnginePool( + self._formula_pool: FormulaPool = FormulaPool( self._namespace, self._channel_registry, resampler_subscription_sender, diff --git a/src/frequenz/sdk/timeseries/consumer.py b/src/frequenz/sdk/timeseries/consumer.py index 71f29e511..53274e24f 100644 --- a/src/frequenz/sdk/timeseries/consumer.py +++ b/src/frequenz/sdk/timeseries/consumer.py @@ -9,10 +9,10 @@ from frequenz.quantities import Power from .._internal._channels import ChannelRegistry +from ..microgrid import connection_manager from ..microgrid._data_sourcing import ComponentMetricRequest -from .formula_engine import FormulaEngine -from .formula_engine._formula_engine_pool import FormulaEnginePool -from .formula_engine._formula_generators import ConsumerPowerFormula +from .formulas._formula import Formula +from .formulas._formula_pool import FormulaPool class Consumer: @@ -52,7 +52,7 @@ class Consumer: ``` """ - _formula_pool: FormulaEnginePool + _formula_pool: FormulaPool """The formula engine pool to generate consumer metrics.""" def __init__( @@ -67,14 +67,14 @@ def __init__( resampler_subscription_sender: The sender to use for resampler subscriptions. """ namespace = f"consumer-{uuid.uuid4()}" - self._formula_pool = FormulaEnginePool( + self._formula_pool = FormulaPool( namespace, channel_registry, resampler_subscription_sender, ) @property - def power(self) -> FormulaEngine[Power]: + def power(self) -> Formula[Power]: """Fetch the consumer power for the microgrid. This formula produces values that are in the Passive Sign Convention (PSC). @@ -88,12 +88,10 @@ def power(self) -> FormulaEngine[Power]: Returns: A FormulaEngine that will calculate and stream consumer power. """ - engine = self._formula_pool.from_power_formula_generator( + return self._formula_pool.from_power_formula( "consumer_power", - ConsumerPowerFormula, + connection_manager.get().component_graph.consumer_formula(), ) - assert isinstance(engine, FormulaEngine) - return engine async def stop(self) -> None: """Stop all formula engines.""" diff --git a/src/frequenz/sdk/timeseries/ev_charger_pool/_ev_charger_pool.py b/src/frequenz/sdk/timeseries/ev_charger_pool/_ev_charger_pool.py index e54f7c49e..b74b037c5 100644 --- a/src/frequenz/sdk/timeseries/ev_charger_pool/_ev_charger_pool.py +++ b/src/frequenz/sdk/timeseries/ev_charger_pool/_ev_charger_pool.py @@ -12,15 +12,11 @@ from frequenz.quantities import Current, Power from ..._internal._channels import MappingReceiverFetcher, ReceiverFetcher -from ...microgrid import _power_distributing, _power_managing +from ...microgrid import _power_distributing, _power_managing, connection_manager from ...timeseries import Bounds from .._base_types import SystemBounds -from ..formula_engine import FormulaEngine, FormulaEngine3Phase -from ..formula_engine._formula_generators import ( - EVChargerCurrentFormula, - EVChargerPowerFormula, - FormulaGeneratorConfig, -) +from ..formulas._formula import Formula +from ..formulas._formula_3_phase import Formula3Phase from ._ev_charger_pool_reference_store import EVChargerPoolReferenceStore from ._result_types import EVChargerPoolReport @@ -118,7 +114,7 @@ def component_ids(self) -> abc.Set[ComponentId]: return self._pool_ref_store.component_ids @property - def current_per_phase(self) -> FormulaEngine3Phase[Current]: + def current_per_phase(self) -> Formula3Phase[Current]: """Fetch the total current for the EV Chargers in the pool. This formula produces values that are in the Passive Sign Convention (PSC). @@ -133,20 +129,15 @@ def current_per_phase(self) -> FormulaEngine3Phase[Current]: A FormulaEngine that will calculate and stream the total current of all EV Chargers. """ - engine = ( - self._pool_ref_store.formula_pool.from_3_phase_current_formula_generator( - "ev_charger_total_current", - EVChargerCurrentFormula, - FormulaGeneratorConfig( - component_ids=self._pool_ref_store.component_ids - ), - ) + return self._pool_ref_store.formula_pool.from_current_3_phase_formula( + "ev_charger_total_current", + connection_manager.get().component_graph.ev_charger_formula( + self._pool_ref_store.component_ids + ), ) - assert isinstance(engine, FormulaEngine3Phase) - return engine @property - def power(self) -> FormulaEngine[Power]: + def power(self) -> Formula[Power]: """Fetch the total power for the EV Chargers in the pool. This formula produces values that are in the Passive Sign Convention (PSC). @@ -161,15 +152,12 @@ def power(self) -> FormulaEngine[Power]: A FormulaEngine that will calculate and stream the total power of all EV Chargers. """ - engine = self._pool_ref_store.formula_pool.from_power_formula_generator( + return self._pool_ref_store.formula_pool.from_power_formula( "ev_charger_power", - EVChargerPowerFormula, - FormulaGeneratorConfig( - component_ids=self._pool_ref_store.component_ids, + connection_manager.get().component_graph.ev_charger_formula( + self._pool_ref_store.component_ids ), ) - assert isinstance(engine, FormulaEngine) - return engine @property def power_status(self) -> ReceiverFetcher[EVChargerPoolReport]: diff --git a/src/frequenz/sdk/timeseries/ev_charger_pool/_ev_charger_pool_reference_store.py b/src/frequenz/sdk/timeseries/ev_charger_pool/_ev_charger_pool_reference_store.py index cfbe72d03..b621e0791 100644 --- a/src/frequenz/sdk/timeseries/ev_charger_pool/_ev_charger_pool_reference_store.py +++ b/src/frequenz/sdk/timeseries/ev_charger_pool/_ev_charger_pool_reference_store.py @@ -17,7 +17,7 @@ from ...microgrid._power_distributing import ComponentPoolStatus, Result from ...microgrid._power_managing._base_classes import Proposal, ReportRequest from .._base_types import SystemBounds -from ..formula_engine._formula_engine_pool import FormulaEnginePool +from ..formulas._formula_pool import FormulaPool from ._system_bounds_tracker import EVCSystemBoundsTracker @@ -82,7 +82,7 @@ def __init__( # pylint: disable=too-many-arguments self.power_bounds_subs: dict[str, asyncio.Task[None]] = {} self.namespace: str = f"ev-charger-pool-{uuid.uuid4()}" - self.formula_pool = FormulaEnginePool( + self.formula_pool = FormulaPool( self.namespace, self.channel_registry, self.resampler_subscription_sender, diff --git a/src/frequenz/sdk/timeseries/grid.py b/src/frequenz/sdk/timeseries/grid.py index 855fae84f..5381751dc 100644 --- a/src/frequenz/sdk/timeseries/grid.py +++ b/src/frequenz/sdk/timeseries/grid.py @@ -14,21 +14,15 @@ from frequenz.channels import Sender from frequenz.client.microgrid.component import GridConnectionPoint -from frequenz.client.microgrid.metrics import Metric from frequenz.quantities import Current, Power, ReactivePower from .._internal._channels import ChannelRegistry from ..microgrid import connection_manager from ..microgrid._data_sourcing import ComponentMetricRequest from ._fuse import Fuse -from .formula_engine import FormulaEngine, FormulaEngine3Phase -from .formula_engine._formula_engine_pool import FormulaEnginePool -from .formula_engine._formula_generators import ( - GridCurrentFormula, - GridPower3PhaseFormula, - GridPowerFormula, - GridReactivePowerFormula, -) +from .formulas._formula import Formula +from .formulas._formula_3_phase import Formula3Phase +from .formulas._formula_pool import FormulaPool _logger = logging.getLogger(__name__) @@ -72,11 +66,11 @@ class Grid: lacks information about the fuse. """ - _formula_pool: FormulaEnginePool + _formula_pool: FormulaPool """The formula engine pool to generate grid metrics.""" @property - def power(self) -> FormulaEngine[Power]: + def power(self) -> Formula[Power]: """Fetch the grid power for the microgrid. This formula produces values that are in the Passive Sign Convention (PSC). @@ -90,15 +84,13 @@ def power(self) -> FormulaEngine[Power]: Returns: A FormulaEngine that will calculate and stream grid power. """ - engine = self._formula_pool.from_power_formula_generator( + return self._formula_pool.from_power_formula( "grid_power", - GridPowerFormula, + connection_manager.get().component_graph.grid_formula(), ) - assert isinstance(engine, FormulaEngine) - return engine @property - def reactive_power(self) -> FormulaEngine[ReactivePower]: + def reactive_power(self) -> Formula[ReactivePower]: """Fetch the grid reactive power for the microgrid. This formula produces values that are in the Passive Sign Convention (PSC). @@ -112,15 +104,13 @@ def reactive_power(self) -> FormulaEngine[ReactivePower]: Returns: A FormulaEngine that will calculate and stream grid reactive power. """ - engine = self._formula_pool.from_reactive_power_formula_generator( - f"grid-{Metric.AC_REACTIVE_POWER.value}", - GridReactivePowerFormula, + return self._formula_pool.from_reactive_power_formula( + "grid_reactive_power", + connection_manager.get().component_graph.grid_formula(), ) - assert isinstance(engine, FormulaEngine) - return engine @property - def _power_per_phase(self) -> FormulaEngine3Phase[Power]: + def _power_per_phase(self) -> Formula3Phase[Power]: """Fetch the per-phase grid power for the microgrid. This formula produces values that are in the Passive Sign Convention (PSC). @@ -131,14 +121,13 @@ def _power_per_phase(self) -> FormulaEngine3Phase[Power]: Returns: A FormulaEngine that will calculate and stream grid 3-phase power. """ - engine = self._formula_pool.from_power_3_phase_formula_generator( - "grid_power_3_phase", GridPower3PhaseFormula + return self._formula_pool.from_power_3_phase_formula( + "grid_power_3_phase", + connection_manager.get().component_graph.grid_formula(), ) - assert isinstance(engine, FormulaEngine3Phase) - return engine @property - def current_per_phase(self) -> FormulaEngine3Phase[Current]: + def current_per_phase(self) -> Formula3Phase[Current]: """Fetch the per-phase grid current for the microgrid. This formula produces values that are in the Passive Sign Convention (PSC). @@ -152,12 +141,10 @@ def current_per_phase(self) -> FormulaEngine3Phase[Current]: Returns: A FormulaEngine that will calculate and stream grid current. """ - engine = self._formula_pool.from_3_phase_current_formula_generator( + return self._formula_pool.from_current_3_phase_formula( "grid_current", - GridCurrentFormula, + connection_manager.get().component_graph.grid_formula(), ) - assert isinstance(engine, FormulaEngine3Phase) - return engine async def stop(self) -> None: """Stop all formula engines.""" @@ -215,7 +202,7 @@ def initialize( ) namespace = f"grid-{uuid.uuid4()}" - formula_pool = FormulaEnginePool( + formula_pool = FormulaPool( namespace, channel_registry, resampler_subscription_sender, diff --git a/src/frequenz/sdk/timeseries/logical_meter/_logical_meter.py b/src/frequenz/sdk/timeseries/logical_meter/_logical_meter.py index 9f1ea1fc0..5c0466d2d 100644 --- a/src/frequenz/sdk/timeseries/logical_meter/_logical_meter.py +++ b/src/frequenz/sdk/timeseries/logical_meter/_logical_meter.py @@ -10,11 +10,12 @@ from frequenz.client.microgrid.metrics import Metric from frequenz.quantities import Power, Quantity +from frequenz.sdk.microgrid import connection_manager + from ..._internal._channels import ChannelRegistry from ...microgrid._data_sourcing import ComponentMetricRequest -from ..formula_engine import FormulaEngine -from ..formula_engine._formula_engine_pool import FormulaEnginePool -from ..formula_engine._formula_generators import CHPPowerFormula +from ..formulas._formula import Formula +from ..formulas._formula_pool import FormulaPool class LogicalMeter: @@ -79,8 +80,8 @@ def __init__( # Use a randomly generated uuid to create a unique namespace name for the local # meter to use when communicating with the resampling actor. - self._namespace = f"logical-meter-{uuid.uuid4()}" - self._formula_pool = FormulaEnginePool( + self._namespace: str = f"logical-meter-{uuid.uuid4()}" + self._formula_pool: FormulaPool = FormulaPool( self._namespace, self._channel_registry, self._resampler_subscription_sender, @@ -90,11 +91,11 @@ def start_formula( self, formula: str, metric: Metric, - *, - nones_are_zeros: bool = False, - ) -> FormulaEngine[Quantity]: + ) -> Formula[Quantity]: """Start execution of the given formula. + TODO: link to formula engine for formula syntax. + Formulas can have Component IDs that are preceeded by a pound symbol("#"), and these operators: +, -, *, /, (, ). @@ -104,18 +105,14 @@ def start_formula( Args: formula: formula to execute. metric: The metric to use when fetching receivers from the resampling actor. - nones_are_zeros: Whether to treat None values from the stream as 0s. If - False, the returned value will be a None. Returns: A FormulaEngine that applies the formula and streams values. """ - return self._formula_pool.from_string( - formula, metric, nones_are_zeros=nones_are_zeros - ) + return self._formula_pool.from_string(formula, metric) @property - def chp_power(self) -> FormulaEngine[Power]: + def chp_power(self) -> Formula[Power]: """Fetch the CHP power production in the microgrid. This formula produces values that are in the Passive Sign Convention (PSC). @@ -129,11 +126,10 @@ def chp_power(self) -> FormulaEngine[Power]: Returns: A FormulaEngine that will calculate and stream CHP power production. """ - engine = self._formula_pool.from_power_formula_generator( - "chp_power", - CHPPowerFormula, + engine = self._formula_pool.from_power_formula( + channel_key="chp_power", + formula_str=connection_manager.get().component_graph.chp_formula(None), ) - assert isinstance(engine, FormulaEngine) return engine async def stop(self) -> None: diff --git a/src/frequenz/sdk/timeseries/producer.py b/src/frequenz/sdk/timeseries/producer.py index 0e65f5711..a87b146a0 100644 --- a/src/frequenz/sdk/timeseries/producer.py +++ b/src/frequenz/sdk/timeseries/producer.py @@ -9,10 +9,10 @@ from frequenz.quantities import Power from .._internal._channels import ChannelRegistry +from ..microgrid import connection_manager from ..microgrid._data_sourcing import ComponentMetricRequest -from .formula_engine import FormulaEngine -from .formula_engine._formula_engine_pool import FormulaEnginePool -from .formula_engine._formula_generators import ProducerPowerFormula +from .formulas._formula import Formula +from .formulas._formula_pool import FormulaPool class Producer: @@ -52,7 +52,7 @@ class Producer: ``` """ - _formula_pool: FormulaEnginePool + _formula_pool: FormulaPool """The formula engine pool to generate producer metrics.""" def __init__( @@ -67,14 +67,14 @@ def __init__( resampler_subscription_sender: The sender to use for resampler subscriptions. """ namespace = f"producer-{uuid.uuid4()}" - self._formula_pool = FormulaEnginePool( + self._formula_pool = FormulaPool( namespace, channel_registry, resampler_subscription_sender, ) @property - def power(self) -> FormulaEngine[Power]: + def power(self) -> Formula[Power]: """Fetch the producer power for the microgrid. This formula produces values that are in the Passive Sign Convention (PSC). @@ -88,12 +88,10 @@ def power(self) -> FormulaEngine[Power]: Returns: A FormulaEngine that will calculate and stream producer power. """ - engine = self._formula_pool.from_power_formula_generator( + return self._formula_pool.from_power_formula( "producer_power", - ProducerPowerFormula, + connection_manager.get().component_graph.producer_formula(), ) - assert isinstance(engine, FormulaEngine) - return engine async def stop(self) -> None: """Stop all formula engines.""" diff --git a/src/frequenz/sdk/timeseries/pv_pool/_pv_pool.py b/src/frequenz/sdk/timeseries/pv_pool/_pv_pool.py index 00a75577a..0407ce143 100644 --- a/src/frequenz/sdk/timeseries/pv_pool/_pv_pool.py +++ b/src/frequenz/sdk/timeseries/pv_pool/_pv_pool.py @@ -10,12 +10,13 @@ from frequenz.client.common.microgrid.components import ComponentId from frequenz.quantities import Power +from frequenz.sdk.microgrid import connection_manager + from ..._internal._channels import MappingReceiverFetcher, ReceiverFetcher from ...microgrid import _power_distributing, _power_managing from ...timeseries import Bounds from .._base_types import SystemBounds -from ..formula_engine import FormulaEngine -from ..formula_engine._formula_generators import FormulaGeneratorConfig, PVPowerFormula +from ..formulas._formula import Formula from ._pv_pool_reference_store import PVPoolReferenceStore from ._result_types import PVPoolReport @@ -108,7 +109,7 @@ def component_ids(self) -> abc.Set[ComponentId]: return self._pool_ref_store.component_ids @property - def power(self) -> FormulaEngine[Power]: + def power(self) -> Formula[Power]: """Fetch the total power for the PV Inverters in the pool. This formula produces values that are in the Passive Sign Convention (PSC). @@ -123,14 +124,12 @@ def power(self) -> FormulaEngine[Power]: A FormulaEngine that will calculate and stream the total power of all PV Inverters. """ - engine = self._pool_ref_store.formula_pool.from_power_formula_generator( + engine = self._pool_ref_store.formula_pool.from_power_formula( "pv_power", - PVPowerFormula, - FormulaGeneratorConfig( - component_ids=self._pool_ref_store.component_ids, + connection_manager.get().component_graph.pv_formula( + self._pool_ref_store.component_ids ), ) - assert isinstance(engine, FormulaEngine) return engine @property diff --git a/src/frequenz/sdk/timeseries/pv_pool/_pv_pool_reference_store.py b/src/frequenz/sdk/timeseries/pv_pool/_pv_pool_reference_store.py index f572587ab..43bca134b 100644 --- a/src/frequenz/sdk/timeseries/pv_pool/_pv_pool_reference_store.py +++ b/src/frequenz/sdk/timeseries/pv_pool/_pv_pool_reference_store.py @@ -18,7 +18,7 @@ from ...microgrid._power_distributing import ComponentPoolStatus, Result from ...microgrid._power_managing._base_classes import Proposal, ReportRequest from .._base_types import SystemBounds -from ..formula_engine._formula_engine_pool import FormulaEnginePool +from ..formulas._formula_pool import FormulaPool from ._system_bounds_tracker import PVSystemBoundsTracker @@ -83,7 +83,7 @@ def __init__( # pylint: disable=too-many-arguments self.power_bounds_subs: dict[str, asyncio.Task[None]] = {} self.namespace: str = f"pv-pool-{uuid.uuid4()}" - self.formula_pool = FormulaEnginePool( + self.formula_pool = FormulaPool( self.namespace, self.channel_registry, self.resampler_subscription_sender, diff --git a/tests/microgrid/test_grid.py b/tests/microgrid/test_grid.py index d23f0c2bd..da4073d07 100644 --- a/tests/microgrid/test_grid.py +++ b/tests/microgrid/test_grid.py @@ -23,7 +23,7 @@ from frequenz.sdk.timeseries import Fuse from tests.utils.graph_generator import GraphGenerator -from ..timeseries._formula_engine.utils import equal_float_lists, get_resampled_stream +from ..timeseries._formulas.utils import equal_float_lists, get_resampled_stream from ..timeseries.mock_microgrid import MockMicrogrid _MICROGRID_ID = MicrogridId(1) diff --git a/tests/timeseries/_battery_pool/test_battery_pool.py b/tests/timeseries/_battery_pool/test_battery_pool.py index 9232d69a6..36f3ca9e6 100644 --- a/tests/timeseries/_battery_pool/test_battery_pool.py +++ b/tests/timeseries/_battery_pool/test_battery_pool.py @@ -21,7 +21,16 @@ import time_machine from frequenz.channels import Receiver, Sender from frequenz.client.common.microgrid.components import ComponentId -from frequenz.client.microgrid.component import Battery, Component, ComponentCategory +from frequenz.client.microgrid.component import ( + Battery, + Component, + ComponentCategory, + InverterType, +) +from frequenz.microgrid_component_graph import ( + FormulaGenerationError, + InvalidGraphError, +) from frequenz.quantities import Energy, Percentage, Power, Temperature from pytest_mock import MockerFixture @@ -37,9 +46,6 @@ from frequenz.sdk.timeseries import Bounds, ResamplerConfig2, Sample from frequenz.sdk.timeseries._base_types import SystemBounds from frequenz.sdk.timeseries.battery_pool import BatteryPool -from frequenz.sdk.timeseries.formula_engine._formula_generators._formula_generator import ( - FormulaGenerationError, -) from tests.utils.graph_generator import GraphGenerator from ...timeseries.mock_microgrid import MockMicrogrid @@ -75,7 +81,7 @@ def get_components( return { component.id for component in mock_microgrid.component_graph.components( - matching_types=component_type + matching_types=[component_type] ) } @@ -696,11 +702,15 @@ async def test_battery_power_fallback_formula( async def test_batter_pool_power_no_batteries(mocker: MockerFixture) -> None: """Test power method with no batteries.""" + graph_gen = GraphGenerator() mockgrid = MockMicrogrid( - graph=GraphGenerator().to_graph( + graph=graph_gen.to_graph( ( ComponentCategory.METER, - [ComponentCategory.INVERTER, ComponentCategory.INVERTER], + [ + graph_gen.component(ComponentCategory.INVERTER, InverterType.SOLAR), + graph_gen.component(ComponentCategory.INVERTER, InverterType.SOLAR), + ], ) ) ) @@ -714,15 +724,13 @@ async def test_batter_pool_power_no_batteries(mocker: MockerFixture) -> None: async def test_battery_pool_power_with_no_inverters(mocker: MockerFixture) -> None: """Test power method with no inverters.""" - mockgrid = MockMicrogrid( - graph=GraphGenerator().to_graph( - (ComponentCategory.METER, ComponentCategory.BATTERY) + with pytest.raises(InvalidGraphError): + mockgrid = MockMicrogrid( + graph=GraphGenerator().to_graph( + (ComponentCategory.METER, ComponentCategory.BATTERY) + ) ) - ) - await mockgrid.start(mocker) - - with pytest.raises(RuntimeError): - microgrid.new_battery_pool(priority=5) + await mockgrid.start(mocker) async def test_battery_pool_power_incomplete_bat_request(mocker: MockerFixture) -> None: diff --git a/tests/timeseries/_formulas/__init__.py b/tests/timeseries/_formulas/__init__.py new file mode 100644 index 000000000..a1fa5e4a5 --- /dev/null +++ b/tests/timeseries/_formulas/__init__.py @@ -0,0 +1,4 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for the formula implementation.""" diff --git a/tests/timeseries/_formula_engine/test_formula_composition.py b/tests/timeseries/_formulas/test_formula_composition.py similarity index 77% rename from tests/timeseries/_formula_engine/test_formula_composition.py rename to tests/timeseries/_formulas/test_formula_composition.py index be0614a8c..feb80a9ca 100644 --- a/tests/timeseries/_formula_engine/test_formula_composition.py +++ b/tests/timeseries/_formulas/test_formula_composition.py @@ -51,62 +51,71 @@ async def test_formula_composition( # pylint: disable=too-many-locals Metric.AC_ACTIVE_POWER, Power.from_watts, ) - grid_power_recv = grid.power.new_receiver() - battery_power_recv = battery_pool.power.new_receiver() - pv_power_recv = pv_pool.power.new_receiver() - engine = (pv_pool.power + battery_pool.power).build("inv_power") - stack.push_async_callback(engine.stop) - inv_calc_recv = engine.new_receiver() - await mockgrid.mock_resampler.send_bat_inverter_power([10.0, 12.0, 14.0]) - await mockgrid.mock_resampler.send_meter_power( - [100.0, 10.0, 12.0, 14.0, -100.0, -200.0] - ) - await mockgrid.mock_resampler.send_pv_inverter_power([-100.0, -200.0]) + async with ( + pv_pool.power as pv_power, + battery_pool.power as battery_power, + engine, + ): + grid_power_recv = grid.power.new_receiver() + battery_power_recv = battery_power.new_receiver() + pv_power_recv = pv_power.new_receiver() - grid_pow = await grid_power_recv.receive() - pv_pow = await pv_power_recv.receive() - bat_pow = await battery_power_recv.receive() - main_pow = await grid_meter_recv.receive() - inv_calc_pow = await inv_calc_recv.receive() + inv_calc_recv = engine.new_receiver() - assert ( - grid_pow is not None - and grid_pow.value is not None - and math.isclose(grid_pow.value.base_value, -164.0) - ) # 100 + 10 + 12 + 14 + -100 + -200 - assert ( - bat_pow is not None - and bat_pow.value is not None - and math.isclose(bat_pow.value.base_value, 36.0) - ) # 10 + 12 + 14 - assert ( - pv_pow is not None - and pv_pow.value is not None - and math.isclose(pv_pow.value.base_value, -300.0) - ) # -100 + -200 - assert ( - inv_calc_pow is not None - and inv_calc_pow.value is not None - and math.isclose(inv_calc_pow.value.base_value, -264.0) # -300 + 36 - ) - assert ( - main_pow is not None - and main_pow.value is not None - and math.isclose(main_pow.value.base_value, 100.0) - ) + await mockgrid.mock_resampler.send_bat_inverter_power( + [10.0, 12.0, 14.0] + ) + await mockgrid.mock_resampler.send_meter_power( + [100.0, 10.0, 12.0, 14.0, -100.0, -200.0] + ) + await mockgrid.mock_resampler.send_pv_inverter_power([-100.0, -200.0]) - assert math.isclose( - inv_calc_pow.value.base_value, - pv_pow.value.base_value + bat_pow.value.base_value, - ) - assert math.isclose( - grid_pow.value.base_value, - inv_calc_pow.value.base_value + main_pow.value.base_value, - ) + grid_pow = await grid_power_recv.receive() + pv_pow = await pv_power_recv.receive() + bat_pow = await battery_power_recv.receive() + main_pow = await grid_meter_recv.receive() + inv_calc_pow = await inv_calc_recv.receive() + + assert ( + grid_pow is not None + and grid_pow.value is not None + and math.isclose(grid_pow.value.base_value, -164.0) + ) # 100 + 10 + 12 + 14 + -100 + -200 + assert ( + bat_pow is not None + and bat_pow.value is not None + and math.isclose(bat_pow.value.base_value, 36.0) + ) # 10 + 12 + 14 + assert ( + pv_pow is not None + and pv_pow.value is not None + and math.isclose(pv_pow.value.base_value, -300.0) + ) # -100 + -200 + assert ( + inv_calc_pow is not None + and inv_calc_pow.value is not None + and math.isclose(inv_calc_pow.value.base_value, -264.0) # -300 + 36 + ) + assert ( + main_pow is not None + and main_pow.value is not None + and math.isclose(main_pow.value.base_value, 100.0) + ) - async def test_formula_composition_missing_pv(self, mocker: MockerFixture) -> None: + assert math.isclose( + inv_calc_pow.value.base_value, + pv_pow.value.base_value + bat_pow.value.base_value, + ) + assert math.isclose( + grid_pow.value.base_value, + inv_calc_pow.value.base_value + main_pow.value.base_value, + ) + + async def test_formula_composition_missing_pv( # pylint: disable=too-many-locals + self, mocker: MockerFixture + ) -> None: """Test the composition of formulas with missing PV power data.""" mockgrid = MockMicrogrid(grid_meter=False, mocker=mocker) mockgrid.add_batteries(3) @@ -122,29 +131,37 @@ async def test_formula_composition_missing_pv(self, mocker: MockerFixture) -> No logical_meter = microgrid.logical_meter() stack.push_async_callback(logical_meter.stop) - battery_power_recv = battery_pool.power.new_receiver() - pv_power_recv = pv_pool.power.new_receiver() engine = (pv_pool.power + battery_pool.power).build("inv_power") stack.push_async_callback(engine.stop) - inv_calc_recv = engine.new_receiver() - - for _ in range(10): - await mockgrid.mock_resampler.send_meter_power( - [10.0 + count, 12.0 + count, 14.0 + count] - ) - await mockgrid.mock_resampler.send_non_existing_component_value() - - bat_pow = await battery_power_recv.receive() - pv_pow = await pv_power_recv.receive() - inv_pow = await inv_calc_recv.receive() - - assert inv_pow == bat_pow - assert ( - pv_pow.timestamp == inv_pow.timestamp - and pv_pow.value == Power.from_watts(0.0) - ) - count += 1 + async with ( + engine, + battery_pool.power as battery_power, + pv_pool.power as pv_power, + ): + inv_calc_recv = engine.new_receiver() + battery_power_recv = battery_power.new_receiver() + pv_power_recv = pv_power.new_receiver() + + for _ in range(10): + await mockgrid.mock_resampler.send_meter_power( + [10.0 + count, 12.0 + count, 14.0 + count] + ) + await mockgrid.mock_resampler.send_bat_inverter_power( + [10.0 + count, 12.0 + count, 14.0 + count] + ) + await mockgrid.mock_resampler.send_non_existing_component_value() + + bat_pow = await battery_power_recv.receive() + pv_pow = await pv_power_recv.receive() + inv_pow = await inv_calc_recv.receive() + + assert inv_pow == bat_pow + assert ( + pv_pow.timestamp == inv_pow.timestamp + and pv_pow.value == Power.from_watts(0.0) + ) + count += 1 assert count == 10 @@ -201,15 +218,20 @@ async def test_formula_composition_min_max(self, mocker: MockerFixture) -> None: grid = microgrid.grid() stack.push_async_callback(grid.stop) - engine_min = grid.power.min(logical_meter.chp_power).build("grid_power_min") + engine_min = grid.power.min([logical_meter.chp_power]).build( + "grid_power_min" + ) stack.push_async_callback(engine_min.stop) engine_min_rx = engine_min.new_receiver() - engine_max = grid.power.max(logical_meter.chp_power).build("grid_power_max") + engine_max = grid.power.max([logical_meter.chp_power]).build( + "grid_power_max" + ) stack.push_async_callback(engine_max.stop) engine_max_rx = engine_max.new_receiver() await mockgrid.mock_resampler.send_meter_power([100.0, 200.0]) + await mockgrid.mock_resampler.send_chp_power([None]) # Test min min_pow = await engine_min_rx.receive() @@ -228,6 +250,7 @@ async def test_formula_composition_min_max(self, mocker: MockerFixture) -> None: ) await mockgrid.mock_resampler.send_meter_power([-100.0, -200.0]) + await mockgrid.mock_resampler.send_chp_power([None]) # Test min min_pow = await engine_min_rx.receive() @@ -259,11 +282,11 @@ async def test_formula_composition_min_max_const( grid = microgrid.grid() stack.push_async_callback(grid.stop) - engine_min = grid.power.min(Power.zero()).build("grid_power_min") + engine_min = grid.power.min([Power.zero()]).build("grid_power_min") stack.push_async_callback(engine_min.stop) engine_min_rx = engine_min.new_receiver() - engine_max = grid.power.max(Power.zero()).build("grid_power_max") + engine_max = grid.power.max([Power.zero()]).build("grid_power_max") stack.push_async_callback(engine_max.stop) engine_max_rx = engine_max.new_receiver() @@ -387,7 +410,7 @@ async def test_formula_composition_constant( # pylint: disable=too-many-locals await engine_assert.new_receiver().receive() # Test addition with a float - with pytest.raises(RuntimeError): + with pytest.raises(AttributeError): engine_assert = (grid.power + 2.0).build( # type: ignore "grid_power_multiplication" ) @@ -429,6 +452,13 @@ async def test_3_phase_formulas(self, mocker: MockerFixture) -> None: [10.0, 12.0, 14.0], ] ) + await mockgrid.mock_resampler.send_bat_inverter_current( + [ + [10.0, 12.0, 14.0], + [10.0, 12.0, 14.0], + [10.0, 12.0, 14.0], + ] + ) await mockgrid.mock_resampler.send_evc_current( [[10.0 + count, 12.0 + count, 14.0 + count]] ) diff --git a/tests/timeseries/_formulas/test_formulas_3_phase.py b/tests/timeseries/_formulas/test_formulas_3_phase.py index c4f832620..5937255be 100644 --- a/tests/timeseries/_formulas/test_formulas_3_phase.py +++ b/tests/timeseries/_formulas/test_formulas_3_phase.py @@ -27,7 +27,7 @@ class TestFormula3Phase: """Tests for 3-phase formulas.""" - async def run_test( + async def run_test( # pylint: disable=too-many-locals self, io_pairs: list[ tuple[ diff --git a/tests/timeseries/_formula_engine/utils.py b/tests/timeseries/_formulas/utils.py similarity index 86% rename from tests/timeseries/_formula_engine/utils.py rename to tests/timeseries/_formulas/utils.py index 599a8eacb..d75d72b7e 100644 --- a/tests/timeseries/_formula_engine/utils.py +++ b/tests/timeseries/_formulas/utils.py @@ -13,8 +13,8 @@ from frequenz.sdk.microgrid import _data_pipeline from frequenz.sdk.timeseries._base_types import QuantityT, Sample -from frequenz.sdk.timeseries.formula_engine._resampled_formula_builder import ( - ResampledFormulaBuilder, +from frequenz.sdk.timeseries.formulas._resampled_stream_fetcher import ( + ResampledStreamFetcher, ) @@ -29,19 +29,16 @@ def get_resampled_stream( # `_get_resampled_receiver` function implementation. # pylint: disable=protected-access - builder = ResampledFormulaBuilder( + builder = ResampledStreamFetcher( namespace=namespace, - formula_name="", channel_registry=_data_pipeline._get()._channel_registry, resampler_subscription_sender=_data_pipeline._get()._resampling_request_sender(), metric=metric, - create_method=create_method, ) # Resampled data is always `Quantity` type, so we need to convert it to the desired # output type. - return builder._get_resampled_receiver( + return builder.fetch_stream( comp_id, - metric, ).map( lambda sample: Sample( sample.timestamp, diff --git a/tests/timeseries/mock_resampler.py b/tests/timeseries/mock_resampler.py index 591b57473..47d9697d4 100644 --- a/tests/timeseries/mock_resampler.py +++ b/tests/timeseries/mock_resampler.py @@ -18,7 +18,7 @@ from frequenz.sdk.microgrid._data_pipeline import _DataPipeline from frequenz.sdk.microgrid._data_sourcing import ComponentMetricRequest from frequenz.sdk.timeseries import ResamplerConfig2, Sample -from frequenz.sdk.timeseries.formula_engine._formula_generators._formula_generator import ( +from frequenz.sdk.timeseries.formulas._formula_pool import ( NON_EXISTING_COMPONENT_ID, ) @@ -80,7 +80,7 @@ def metric_senders( self._chp_power_senders = metric_senders(chp_ids, Metric.AC_ACTIVE_POWER) self._meter_power_senders = metric_senders(meter_ids, Metric.AC_ACTIVE_POWER) self._non_existing_component_sender = metric_senders( - [NON_EXISTING_COMPONENT_ID], Metric.AC_ACTIVE_POWER + [ComponentId(NON_EXISTING_COMPONENT_ID)], Metric.AC_ACTIVE_POWER )[0] # Frequency senders diff --git a/tests/timeseries/test_frequency_streaming.py b/tests/timeseries/test_frequency_streaming.py index 190df9419..25077c3b9 100644 --- a/tests/timeseries/test_frequency_streaming.py +++ b/tests/timeseries/test_frequency_streaming.py @@ -13,7 +13,7 @@ from frequenz.sdk import microgrid from tests.utils import component_data_wrapper -from ._formula_engine.utils import equal_float_lists +from ._formulas.utils import equal_float_lists from .mock_microgrid import MockMicrogrid # pylint: disable=protected-access From a69ba9edbe6c81d93b7bdefbe00412b23f882860 Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Thu, 20 Nov 2025 22:22:10 +0100 Subject: [PATCH 16/23] Remove tests for the old fallback mechanism Signed-off-by: Sahas Subramanian --- tests/microgrid/test_grid.py | 157 ------------------ .../_battery_pool/test_battery_pool.py | 94 ----------- tests/timeseries/test_consumer.py | 132 --------------- tests/timeseries/test_logical_meter.py | 66 -------- tests/timeseries/test_producer.py | 92 ---------- 5 files changed, 541 deletions(-) diff --git a/tests/microgrid/test_grid.py b/tests/microgrid/test_grid.py index da4073d07..ec94ae5ea 100644 --- a/tests/microgrid/test_grid.py +++ b/tests/microgrid/test_grid.py @@ -10,7 +10,6 @@ from frequenz.client.common.microgrid.components import ComponentId from frequenz.client.microgrid.component import ( Component, - ComponentCategory, ComponentConnection, GridConnectionPoint, Meter, @@ -21,7 +20,6 @@ from frequenz.sdk import microgrid from frequenz.sdk.timeseries import Fuse -from tests.utils.graph_generator import GraphGenerator from ..timeseries._formulas.utils import equal_float_lists, get_resampled_stream from ..timeseries.mock_microgrid import MockMicrogrid @@ -371,158 +369,3 @@ async def test_consumer_power_2_grid_meters(mocker: MockerFixture) -> None: await mockgrid.mock_resampler.send_meter_power([1.0, 2.0]) assert (await grid_recv.receive()).value == Power.from_watts(3.0) - - -async def test_grid_fallback_formula_without_grid_meter(mocker: MockerFixture) -> None: - """Test the grid power formula without a grid meter.""" - gen = GraphGenerator() - mockgrid = MockMicrogrid( - graph=gen.to_graph( - ( - [ - ComponentCategory.METER, # Consumer meter - ( - ComponentCategory.METER, # meter with 2 inverters - [ - ( - ComponentCategory.INVERTER, - [ComponentCategory.BATTERY], - ), - ( - ComponentCategory.INVERTER, - [ComponentCategory.BATTERY, ComponentCategory.BATTERY], - ), - ], - ), - (ComponentCategory.INVERTER, ComponentCategory.BATTERY), - ] - ) - ), - mocker=mocker, - ) - - async with mockgrid, AsyncExitStack() as stack: - grid = microgrid.grid() - stack.push_async_callback(grid.stop) - consumer_power_receiver = grid.power.new_receiver() - - # Note: GridPowerFormula has a "nones-are-zero" rule, that says: - # * if the meter value is None, it should be treated as None. - # * for other components None is treated as 0. - - # fmt: off - expected_input_output: list[ - tuple[list[float | None], list[float | None], Power | None] - ] = [ - # ([consumer_meter, bat1_meter], [bat1_1_inv, bat1_2_inv, bat2_inv], expected_power) - ([100, -200], [-300, -300, 50], Power.from_watts(-50)), - ([500, 100], [100, 1000, -200,], Power.from_watts(400)), - # Consumer meter is invalid - consumer meter has no fallback. - # Formula should return None as defined in nones-are-zero rule. - ([None, 100], [100, 1000, -200,], None), - ([None, -50], [100, 100, -200,], None), - ([500, 100], [100, 50, -200,], Power.from_watts(400)), - # bat1_inv is invalid. - # Return None and subscribe for fallback devices. - # Next call should return formula result with pv_inv value. - ([500, None], [100, 1000, -200,], None), - ([500, None], [100, -1000, -200,], Power.from_watts(-600)), - ([500, None], [-100, 200, 50], Power.from_watts(650)), - # Second Battery inverter is invalid. This component has no fallback. - # return 0 instead of None as defined in nones-are-zero rule. - ([2000, None], [-200, 1000, None], Power.from_watts(2800)), - ([2000, 1000], [-200, 1000, None], Power.from_watts(3000)), - # battery start working - ([2000, 10], [-200, 1000, 100], Power.from_watts(2110)), - # No primary value, start fallback formula - ([2000, None], [-200, 1000, 100], None), - ([2000, None], [-200, 1000, 100], Power.from_watts(2900)), - ] - # fmt: on - - for idx, ( - meter_power, - bat_inv_power, - expected_power, - ) in enumerate(expected_input_output): - await mockgrid.mock_resampler.send_meter_power(meter_power) - await mockgrid.mock_resampler.send_bat_inverter_power(bat_inv_power) - mockgrid.mock_resampler.next_ts() - - result = await consumer_power_receiver.receive() - assert result.value == expected_power, ( - f"Test case {idx} failed:" - + f" meter_power: {meter_power}" - + f" bat_inverter_power {bat_inv_power}" - + f" expected_power: {expected_power}" - + f" actual_power: {result.value}" - ) - - -async def test_grid_fallback_formula_with_grid_meter(mocker: MockerFixture) -> None: - """Test the grid power formula without a grid meter.""" - gen = GraphGenerator() - mockgrid = MockMicrogrid( - graph=gen.to_graph( - ( - ComponentCategory.METER, # Grid meter - [ - ( - ComponentCategory.METER, # meter with 2 inverters - [ - ( - ComponentCategory.INVERTER, - [ComponentCategory.BATTERY], - ), - ( - ComponentCategory.INVERTER, - [ComponentCategory.BATTERY, ComponentCategory.BATTERY], - ), - ], - ), - (ComponentCategory.INVERTER, ComponentCategory.BATTERY), - ], - ) - ), - mocker=mocker, - ) - - async with mockgrid, AsyncExitStack() as stack: - grid = microgrid.grid() - stack.push_async_callback(grid.stop) - consumer_power_receiver = grid.power.new_receiver() - - # Note: GridPowerFormula has a "nones-are-zero" rule, that says: - # * if the meter value is None, it should be treated as None. - # * for other components None is treated as 0. - - # fmt: off - expected_input_output: list[ - tuple[list[float | None], list[float | None], Power | None] - ] = [ - # ([grid_meter, bat1_meter], [bat1_1_inv, bat1_2_inv, bat2_inv], expected_power) - ([100, -200], [-300, -300, 50], Power.from_watts(100)), - ([-100, 100], [100, 1000, -200,], Power.from_watts(-100)), - ([None, 100], [100, 1000, -200,], None), - ([None, -50], [100, 100, -200,], None), - ([500, 100], [100, 50, -200,], Power.from_watts(500)), - ] - # fmt: on - - for idx, ( - meter_power, - bat_inv_power, - expected_power, - ) in enumerate(expected_input_output): - await mockgrid.mock_resampler.send_meter_power(meter_power) - await mockgrid.mock_resampler.send_bat_inverter_power(bat_inv_power) - mockgrid.mock_resampler.next_ts() - - result = await consumer_power_receiver.receive() - assert result.value == expected_power, ( - f"Test case {idx} failed:" - + f" meter_power: {meter_power}" - + f" bat_inverter_power {bat_inv_power}" - + f" expected_power: {expected_power}" - + f" actual_power: {result.value}" - ) diff --git a/tests/timeseries/_battery_pool/test_battery_pool.py b/tests/timeseries/_battery_pool/test_battery_pool.py index 36f3ca9e6..3061978a9 100644 --- a/tests/timeseries/_battery_pool/test_battery_pool.py +++ b/tests/timeseries/_battery_pool/test_battery_pool.py @@ -606,100 +606,6 @@ async def test_batter_pool_power_two_batteries_per_inverter( assert (await power_receiver.receive()).value == Power.from_watts(-2.0) -async def test_battery_power_fallback_formula( - mocker: MockerFixture, -) -> None: - """Test power method with two batteries per inverter.""" - gen = GraphGenerator() - mockgrid = MockMicrogrid( - graph=gen.to_graph( - ( - ComponentCategory.METER, # Grid meter - shouldn't be included in formula - [ - ( - ComponentCategory.METER, # meter with 2 inverters - [ - ( - ComponentCategory.INVERTER, - [ComponentCategory.BATTERY], - ), - ( - ComponentCategory.INVERTER, - [ComponentCategory.BATTERY, ComponentCategory.BATTERY], - ), - ], - ), - ( - # inverter without meter - ComponentCategory.INVERTER, - [ComponentCategory.BATTERY, ComponentCategory.BATTERY], - ), - ], - ) - ), - mocker=mocker, - ) - - async with mockgrid, AsyncExitStack() as stack: - battery_pool = microgrid.new_battery_pool(priority=5) - stack.push_async_callback(battery_pool.stop) - power_receiver = battery_pool.power.new_receiver() - - # Note: BatteryPowerFormula has a "nones-are-zero" rule, that says: - # * if the meter value is None, it should be treated as None. - # * for other components None is treated as 0. - - # fmt: off - expected_input_output: list[ - tuple[list[float | None], list[float | None], Power | None] - ] = [ - # ([grid_meter, bat_inv_meter], [bat_inv1, bat_inv2, bat_inv3], expected_power) - # bat_inv_meter is connected to bat_inv1 and bat_inv2 - # bat_inv3 has no meter - # Case 1: All components are available, add power form bat_inv_meter and bat_inv3 - ([-1.0, 2.0], [-100.0, -200.0, -300.0], Power.from_watts(-298.0)), - ([-1.0, -10.0], [None, None, -300.0], Power.from_watts(-310.0)), - # Case 2: Meter is unavailable (None). - # Subscribe to the fallback inverters, but return None as the result, - # according to the "nones-are-zero" rule - # Next call should add power from inverters - ([-1.0, None], [100.0, 100.0, -300.0], None), - ([-1.0, None], [100.0, 100.0, -300.0], Power.from_watts(-100.0)), - # Case 3: bat_inv_3 is unavailable (None). Return 0 from failing component - ([-1.0, None], [100.0, 100.0, None], Power.from_watts(200.0)), - # Case 4: bat_inv_meter is available, ignore fallback inverters - ([-1.0, 10], [100.0, 100.0, None], Power.from_watts(10.0)), - # Case 4: all components are unavailable (None). Start fallback formula. - # Returned power = 0 according to the "nones-are-zero" rule. - ([-1.0, None], [None, None, None], None), - ([-1.0, None], [None, None, None], Power.from_watts(0.0)), - # Case 5: Components becomes available - ([-1.0, None], [None, None, 100.0], Power.from_watts(100.0)), - ([-1.0, None], [None, 50.0, 100.0], Power.from_watts(150.0)), - ([-1.0, None], [-20, 50.0, 100.0], Power.from_watts(130.0)), - ([-1.0, -200], [-20, 50.0, 100.0], Power.from_watts(-100.0)), - ] - # fmt: on - - for idx, ( - meter_power, - bat_inv_power, - expected_power, - ) in enumerate(expected_input_output): - await mockgrid.mock_resampler.send_meter_power(meter_power) - await mockgrid.mock_resampler.send_bat_inverter_power(bat_inv_power) - mockgrid.mock_resampler.next_ts() - - result = await asyncio.wait_for(power_receiver.receive(), timeout=1) - assert result.value == expected_power, ( - f"Test case {idx} failed:" - + f" meter_power: {meter_power}" - + f" bat_inv_power {bat_inv_power}" - + f" expected_power: {expected_power}" - + f" actual_power: {result.value}" - ) - - async def test_batter_pool_power_no_batteries(mocker: MockerFixture) -> None: """Test power method with no batteries.""" graph_gen = GraphGenerator() diff --git a/tests/timeseries/test_consumer.py b/tests/timeseries/test_consumer.py index 6ce229665..c208e6b16 100644 --- a/tests/timeseries/test_consumer.py +++ b/tests/timeseries/test_consumer.py @@ -66,135 +66,3 @@ async def test_consumer_power_no_grid_meter_no_consumer_meter( assert (await consumer_power_receiver.receive()).value == Power.from_watts( 0.0 ) - - async def test_consumer_power_fallback_formula_with_grid_meter( - self, mocker: MockerFixture - ) -> None: - """Test the consumer power formula with a grid meter.""" - mockgrid = MockMicrogrid(grid_meter=True, mocker=mocker) - mockgrid.add_batteries(1) - mockgrid.add_solar_inverters(1) - mockgrid.add_solar_inverters(1, no_meter=True) - - # formula is grid_meter - battery - pv1 - pv2 - - async with mockgrid, AsyncExitStack() as stack: - consumer = microgrid.consumer() - stack.push_async_callback(consumer.stop) - consumer_power_formula = consumer.power - print(consumer_power_formula) - consumer_power_receiver = consumer_power_formula.new_receiver() - - # Note: ConsumerPowerFormula has a "nones-are-zero" rule, that says: - # * if the meter value is None, it should be treated as None. - # * for other components None is treated as 0. - - # fmt: off - expected_input_output: list[ - tuple[list[float | None], list[float | None], list[float | None], Power | None] - ] = [ - # ([grid_meter, bat_meter, pv1_meter], [bat_inv], [pv1_inv, pv2_inv], expected_power) # noqa: E501 - ([100, 100, -50], [100], [-200, -300], Power.from_watts(350)), - ([500, -200, -100], [100], [-200, -100], Power.from_watts(900)), - # Case 2: The meter is unavailable (None). - # Subscribe to the fallback inverter, but return None as the result, - # according to the "nones-are-zero" rule - ([500, None, -100], [100], [-200, -100], None), - ([500, None, -100], [100], [-200, -100], Power.from_watts(600)), - # Case 3: Second meter is unavailable (None). - ([500, None, None], [100], [-200, -100], None), - ([500, None, None], [100], [-200, -100], Power.from_watts(700)), - # Case 3: pv2_inv is unavailable (None). - # It has no fallback, so return 0 as its value according to - # the "nones-are-zero" rule. - ([500, None, None], [100], [-200, None], Power.from_watts(600)), - # Case 4: Grid meter is unavailable (None). - # It has no fallback, so return None according to the "nones-are-zero" rule. - ([None, 100, -50], [100], [-200, -300], None), - ([None, 200, -50], [100], [-200, -300], None), - ([100, 100, -50], [100], [-200, -300], Power.from_watts(350)), - # Case 5: Only grid meter is working, subscribe for fallback formula - ([100, None, None], [None], [None, None], None), - ([100, None, None], [None], [None, None], Power.from_watts(100)), - ([-500, None, None], [None], [None, None], Power.from_watts(-500)), - # Case 6: Nothing is working - ([None, None, None], [None], [None, None], None), - ] - # fmt: on - - for idx, ( - meter_power, - bat_inv_power, - pv_inv_power, - expected_power, - ) in enumerate(expected_input_output): - await mockgrid.mock_resampler.send_meter_power(meter_power) - await mockgrid.mock_resampler.send_bat_inverter_power(bat_inv_power) - await mockgrid.mock_resampler.send_pv_inverter_power(pv_inv_power) - mockgrid.mock_resampler.next_ts() - - result = await consumer_power_receiver.receive() - assert result.value == expected_power, ( - f"Test case {idx} failed:" - + f" meter_power: {meter_power}" - + f" bat_inverter_power {bat_inv_power}" - + f" pv_inverter_power {pv_inv_power}" - + f" expected_power: {expected_power}" - + f" actual_power: {result.value}" - ) - - async def test_consumer_power_fallback_formula_without_grid_meter( - self, mocker: MockerFixture - ) -> None: - """Test the consumer power formula with a grid meter.""" - mockgrid = MockMicrogrid(grid_meter=False, mocker=mocker) - mockgrid.add_consumer_meters(2) - mockgrid.add_batteries(1) - mockgrid.add_solar_inverters(1, no_meter=True) - - # formula is sum of consumer meters - - async with mockgrid, AsyncExitStack() as stack: - consumer = microgrid.consumer() - stack.push_async_callback(consumer.stop) - consumer_power_receiver = consumer.power.new_receiver() - - # Note: ConsumerPowerFormula has a "nones-are-zero" rule, that says: - # * if the meter value is None, it should be treated as None. - # * for other components None is treated as 0. - - # fmt: off - expected_input_output: list[ - tuple[list[float | None], list[float | None], list[float | None], Power | None] - ] = [ - # ([consumer_meter1, consumer_meter2, bat_meter], [bat_inv], [pv_inv], expected_power) # noqa: E501 - ([100, 100, -50], [100], [-200,], Power.from_watts(200)), - ([500, 100, -50], [100], [-200,], Power.from_watts(600)), - # One of the meters is invalid - should return None according to none-are-zero rule - ([None, 100, -50], [100], [-200,], None), - ([None, None, -50], [100], [-200,], None), - ([500, None, -50], [100], [-200,], None), - ([2000, 1000, None], [None], [None], Power.from_watts(3000)), - ] - # fmt: on - - for idx, ( - meter_power, - bat_inv_power, - pv_inv_power, - expected_power, - ) in enumerate(expected_input_output): - await mockgrid.mock_resampler.send_meter_power(meter_power) - await mockgrid.mock_resampler.send_bat_inverter_power(bat_inv_power) - await mockgrid.mock_resampler.send_pv_inverter_power(pv_inv_power) - mockgrid.mock_resampler.next_ts() - - result = await consumer_power_receiver.receive() - assert result.value == expected_power, ( - f"Test case {idx} failed:" - + f" meter_power: {meter_power}" - + f" bat_inverter_power {bat_inv_power}" - + f" pv_inverter_power {pv_inv_power}" - + f" expected_power: {expected_power}" - + f" actual_power: {result.value}" - ) diff --git a/tests/timeseries/test_logical_meter.py b/tests/timeseries/test_logical_meter.py index b33e18248..d97ed3c99 100644 --- a/tests/timeseries/test_logical_meter.py +++ b/tests/timeseries/test_logical_meter.py @@ -77,69 +77,3 @@ async def test_pv_power_no_pv_components(self, mocker: MockerFixture) -> None: await mockgrid.mock_resampler.send_non_existing_component_value() assert (await pv_power_receiver.receive()).value == Power.zero() - - async def test_pv_power_with_failing_meter(self, mocker: MockerFixture) -> None: - """Test the pv power formula.""" - mockgrid = MockMicrogrid(grid_meter=False, mocker=mocker) - mockgrid.add_solar_inverters(2) - - async with mockgrid, AsyncExitStack() as stack: - pv_pool = microgrid.new_pv_pool(priority=5) - stack.push_async_callback(pv_pool.stop) - pv_power_receiver = pv_pool.power.new_receiver() - - # Note: PvPowerFormula has a "nones-are-zero" rule, that says: - # * if the meter value is None, it should be treated as None. - # * for other components None is treated as 0. - - expected_input_output: list[ - tuple[list[float | None], list[float | None], Power | None] - ] = [ - # ([meter_power], [pv_inverter_power], expected_power) - # - # Case 1: Both meters are available, so inverters are not used. - ([-1.0, -2.0], [None, -5.0], Power.from_watts(-3.0)), - ([-1.0, -2.0], [-10.0, -20.0], Power.from_watts(-3.0)), - # Case 2: The first meter is unavailable (None). - # Subscribe to the fallback inverter, but return None as the result, - # according to the "nones-are-zero" rule - ([None, -2.0], [-10.0, -20.0], None), - # Case 3: First meter is unavailable (None). Fallback inverter provides - # a value. - ([None, -2.0], [-10.0, -20.0], Power.from_watts(-12.0)), - ([None, -2.0], [-11.0, -20.0], Power.from_watts(-13.0)), - # Case 4: Both first meter and its fallback inverter are unavailable - # (None). Return 0 according to the "nones-are-zero" rule. - ([None, -2.0], [None, -20.0], Power.from_watts(-2.0)), - ([None, -2.0], [-11.0, -20.0], Power.from_watts(-13.0)), - # Case 5: Both meters are unavailable (None). - # Subscribe to the fallback inverter, but return None as the result, - # according "nones-are-zero" rule - ([None, None], [-5.0, -20.0], None), - # Case 6: Both meters are unavailable (None). Fallback inverter provides - # a values. - ([None, None], [-5.0, -20.0], Power.from_watts(-25.0)), - # Case 7: All components are unavailable (None). - # Return 0 according to the "nones-are-zero" rule. - ([None, None], [None, None], Power.from_watts(0.0)), - ([None, None], [-5.0, -20.0], Power.from_watts(-25.0)), - # Case 8: Meters becomes available and inverter values are not used. - ([-10.0, None], [-5.0, -20.0], Power.from_watts(-30.0)), - ([-10.0, -2.0], [-5.0, -20.0], Power.from_watts(-12.0)), - ] - - for idx, (meter_power, pv_inverter_power, expected_power) in enumerate( - expected_input_output - ): - await mockgrid.mock_resampler.send_meter_power(meter_power) - await mockgrid.mock_resampler.send_pv_inverter_power(pv_inverter_power) - mockgrid.mock_resampler.next_ts() - - result = await pv_power_receiver.receive() - assert result.value == expected_power, ( - f"Test case {idx} failed:" - + f" meter_power: {meter_power}" - + f" pv_inverter_power {pv_inverter_power}" - + f" expected_power: {expected_power}" - + f" actual_power: {result.value}" - ) diff --git a/tests/timeseries/test_producer.py b/tests/timeseries/test_producer.py index 747082e2a..297e47183 100644 --- a/tests/timeseries/test_producer.py +++ b/tests/timeseries/test_producer.py @@ -95,95 +95,3 @@ async def test_no_producer_power(self, mocker: MockerFixture) -> None: assert (await producer_power_receiver.receive()).value == Power.from_watts( 0.0 ) - - async def test_producer_fallback_formula(self, mocker: MockerFixture) -> None: - """Test the producer power formula with fallback formulas.""" - mockgrid = MockMicrogrid(grid_meter=False, mocker=mocker) - mockgrid.add_solar_inverters(2) - # CHP has no meter, so no fallback component - mockgrid.add_chps(1, no_meters=True) - - async with mockgrid, AsyncExitStack() as stack: - producer = microgrid.producer() - stack.push_async_callback(producer.stop) - producer_power_receiver = producer.power.new_receiver() - - # Note: ProducerPowerFormula has a "nones-are-zero" rule, that says: - # * if the meter value is None, it should be treated as None. - # * for other components None is treated as 0. - - expected_input_output: list[ - tuple[ - float, - list[float | None], - list[float | None], - list[float | None], - Power | None, - ] - ] - # fmt: off - expected_input_output = [ - # (test number, [pv_meter_power], [pv_inverter_power], [chp_power], expected_power) - # Step 1: All components are available - # Add power from meters and chp - (1.1, [-1.0, -2.0], [None, -200.0], [300], Power.from_watts(297.0)), - (1.2, [-1.0, -10], [-100.0, -200.0], [400], Power.from_watts(389.0)), - # Step 2: The first meter is unavailable (None). - # Subscribe to the fallback inverter, but return None as the result, - # according to the "nones-are-zero" rule - (2.1, [None, -2.0], [-100, -200.0], [400], None), - # Step 3: First meter is unavailable (None). Fallback inverter provides - # a value. - # Add second meter, first inverter and chp power - (3.1, [None, -2.0], [-100, -200.0], [400], Power.from_watts(298.0)), - (3.2, [None, -2.0], [-50, -200.0], [300], Power.from_watts(248.0)), - # Step 4: Both first meter and its fallback inverter are unavailable - # (None). Return 0 from failing component according to the - # "nones-are-zero" rule. - (4.1, [None, -2.0], [None, -200.0], [300], Power.from_watts(298.0)), - (4.2, [None, -10.0], [-20.0, -200.0], [300], Power.from_watts(270.0)), - # Step 5: CHP is unavailable. Return 0 from failing component - # according to the "nones-are-zero" rule. - (5.1, [None, -10.0], [-20.0, -200.0], [None], Power.from_watts(-30.0)), - # Step 6: Both meters are unavailable (None). Subscribe for fallback inverter - (6.1, [None, None], [-20.0, -200.0], [None], None), - (6.2, [None, None], [-20.0, -200.0], [None], Power.from_watts(-220.0)), - (6.3, [None, None], [None, -200.0], [None], Power.from_watts(-200.0)), - # Step 7: All components are unavailable (None). Return 0 according to the - # "nones-are-zero" rule. - (7.1, [None, None], [None, None], [None], Power.from_watts(0)), - (7.2, [None, None], [None, None], [None], Power.from_watts(0)), - (7.3, [None, None], [None, None], [300.0], Power.from_watts(300.0)), - (7.4, [-200.0, None], [None, -100.0], [50.0], Power.from_watts(-250.0)), - (7.5, [-200.0, -200.0], [-10.0, -20.0], [50.0], Power.from_watts(-350.0)), - # Step 8: Meter is unavailable, start fallback formula. - (8.1, [None, -200.0], [-10.0, -100.0], [50.0], None), - (8.2, [None, -200.0], [-10.0, -100.0], [50.0], Power.from_watts(-160)), - - ] - # fmt: on - - for ( - idx, - meter_power, - pv_inverter_power, - chp_power, - expected_power, - ) in expected_input_output: - print("----------------------------------------------------") - print(f" Test step {idx}") - print("----------------------------------------------------") - await mockgrid.mock_resampler.send_chp_power(chp_power) - await mockgrid.mock_resampler.send_meter_power(meter_power) - await mockgrid.mock_resampler.send_pv_inverter_power(pv_inverter_power) - mockgrid.mock_resampler.next_ts() - - result = await producer_power_receiver.receive() - assert result.value == expected_power, ( - f"Test step {idx} failed:" - + f" meter_power: {meter_power}" - + f" pv_inverter_power {pv_inverter_power}" - + f" chp_power {chp_power}" - + f" expected_power: {expected_power}" - + f" actual_power: {result.value}" - ) From 0a843ea0210b71d30cd06897cb8780cba3c07d64 Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Thu, 20 Nov 2025 22:36:06 +0100 Subject: [PATCH 17/23] Send test data from secondary components This won't be necessary as soon as the coalesce AST node is able to subscribe to secondary streams only if primary streams are not available. But for now, coalesce nodes need all their input streams to have data. Signed-off-by: Sahas Subramanian --- tests/microgrid/test_grid.py | 10 ++++++++++ .../_ev_charger_pool/test_ev_charger_pool.py | 2 ++ tests/timeseries/mock_resampler.py | 10 ++++++++++ tests/timeseries/test_consumer.py | 7 +++++++ tests/timeseries/test_logical_meter.py | 2 ++ tests/timeseries/test_producer.py | 20 +++++++++++-------- 6 files changed, 43 insertions(+), 8 deletions(-) diff --git a/tests/microgrid/test_grid.py b/tests/microgrid/test_grid.py index ec94ae5ea..1f5df9b34 100644 --- a/tests/microgrid/test_grid.py +++ b/tests/microgrid/test_grid.py @@ -125,6 +125,7 @@ async def test_grid_power_2(mocker: MockerFixture) -> None: for count in range(10): await mockgrid.mock_resampler.send_meter_power([20.0 + count, 12.0, -13.0]) await mockgrid.mock_resampler.send_bat_inverter_power([0.0, -5.0]) + await mockgrid.mock_resampler.send_pv_inverter_power([0.0]) meter_sum = 0.0 for recv in component_receivers: val = await recv.receive() @@ -220,6 +221,7 @@ async def test_grid_reactive_power_2(mocker: MockerFixture) -> None: await mockgrid.mock_resampler.send_meter_reactive_power( [20.0 + count, 12.0, -13.0] ) + await mockgrid.mock_resampler.send_pv_inverter_reactive_power([-13.0]) await mockgrid.mock_resampler.send_bat_inverter_reactive_power([0.0, -5.0]) meter_sum = 0.0 for recv in component_receivers: @@ -326,9 +328,13 @@ async def test_grid_production_consumption_power_consumer_meter( grid_recv = grid.power.new_receiver() await mockgrid.mock_resampler.send_meter_power([1.0, 2.0, 3.0, 4.0]) + await mockgrid.mock_resampler.send_bat_inverter_power([2.0, 3.0]) + await mockgrid.mock_resampler.send_pv_inverter_power([4.0]) assert (await grid_recv.receive()).value == Power.from_watts(10.0) await mockgrid.mock_resampler.send_meter_power([1.0, 2.0, -3.0, -4.0]) + await mockgrid.mock_resampler.send_bat_inverter_power([2.0, -3.0]) + await mockgrid.mock_resampler.send_pv_inverter_power([-4.0]) assert (await grid_recv.receive()).value == Power.from_watts(-4.0) @@ -348,9 +354,13 @@ async def test_grid_production_consumption_power_no_grid_meter( grid_recv = grid.power.new_receiver() await mockgrid.mock_resampler.send_meter_power([2.5, 3.5, 4.0]) + await mockgrid.mock_resampler.send_bat_inverter_power([2.5, 3.5]) + await mockgrid.mock_resampler.send_pv_inverter_power([4.0]) assert (await grid_recv.receive()).value == Power.from_watts(10.0) await mockgrid.mock_resampler.send_meter_power([3.0, -3.0, -4.0]) + await mockgrid.mock_resampler.send_bat_inverter_power([3.0, -3.0]) + await mockgrid.mock_resampler.send_pv_inverter_power([-4.0]) assert (await grid_recv.receive()).value == Power.from_watts(-4.0) diff --git a/tests/timeseries/_ev_charger_pool/test_ev_charger_pool.py b/tests/timeseries/_ev_charger_pool/test_ev_charger_pool.py index ac6e0b7ef..c76ba7fb6 100644 --- a/tests/timeseries/_ev_charger_pool/test_ev_charger_pool.py +++ b/tests/timeseries/_ev_charger_pool/test_ev_charger_pool.py @@ -26,8 +26,10 @@ async def test_ev_power( # pylint: disable=too-many-locals ev_pool = microgrid.new_ev_charger_pool(priority=5) power_receiver = ev_pool.power.new_receiver() + await mockgrid.mock_resampler.send_meter_power([None]) await mockgrid.mock_resampler.send_evc_power([2.0, 4.0, 10.0]) assert (await power_receiver.receive()).value == Power.from_watts(16.0) + await mockgrid.mock_resampler.send_meter_power([None]) await mockgrid.mock_resampler.send_evc_power([2.0, 4.0, -10.0]) assert (await power_receiver.receive()).value == Power.from_watts(-4.0) diff --git a/tests/timeseries/mock_resampler.py b/tests/timeseries/mock_resampler.py index 47d9697d4..8ae6b0920 100644 --- a/tests/timeseries/mock_resampler.py +++ b/tests/timeseries/mock_resampler.py @@ -75,6 +75,9 @@ def metric_senders( self._pv_inverter_power_senders = metric_senders( pv_inverter_ids, Metric.AC_ACTIVE_POWER ) + self._pv_inverter_reactive_power_senders = metric_senders( + pv_inverter_ids, Metric.AC_REACTIVE_POWER + ) self._ev_power_senders = metric_senders(evc_ids, Metric.AC_ACTIVE_POWER) self._chp_power_senders = metric_senders(chp_ids, Metric.AC_ACTIVE_POWER) @@ -287,6 +290,13 @@ async def send_pv_inverter_power(self, values: list[float | None]) -> None: sample = self.make_sample(value) await chan.send(sample) + async def send_pv_inverter_reactive_power(self, values: list[float | None]) -> None: + """Send the given values as resampler output for PV Inverter power.""" + assert len(values) == len(self._pv_inverter_reactive_power_senders) + for chan, value in zip(self._pv_inverter_reactive_power_senders, values): + sample = self.make_sample(value) + await chan.send(sample) + async def send_meter_frequency(self, values: list[float | None]) -> None: """Send the given values as resampler output for meter frequency.""" assert len(values) == len(self._meter_frequency_senders) diff --git a/tests/timeseries/test_consumer.py b/tests/timeseries/test_consumer.py index c208e6b16..cca746228 100644 --- a/tests/timeseries/test_consumer.py +++ b/tests/timeseries/test_consumer.py @@ -28,6 +28,8 @@ async def test_consumer_power_grid_meter(self, mocker: MockerFixture) -> None: consumer_power_receiver = consumer.power.new_receiver() await mockgrid.mock_resampler.send_meter_power([20.0, 2.0, 3.0, 4.0, 5.0]) + await mockgrid.mock_resampler.send_bat_inverter_power([2.0, 3.0]) + await mockgrid.mock_resampler.send_pv_inverter_power([4.0, 5.0]) assert (await consumer_power_receiver.receive()).value == Power.from_watts( 6.0 ) @@ -45,6 +47,8 @@ async def test_consumer_power_no_grid_meter(self, mocker: MockerFixture) -> None consumer_power_receiver = consumer.power.new_receiver() await mockgrid.mock_resampler.send_meter_power([20.0, 2.0, 3.0, 4.0, 5.0]) + await mockgrid.mock_resampler.send_bat_inverter_power([2.0, 3.0]) + await mockgrid.mock_resampler.send_pv_inverter_power([4.0, 5.0]) assert (await consumer_power_receiver.receive()).value == Power.from_watts( 20.0 ) @@ -63,6 +67,9 @@ async def test_consumer_power_no_grid_meter_no_consumer_meter( consumer_power_receiver = consumer.power.new_receiver() await mockgrid.mock_resampler.send_non_existing_component_value() + await mockgrid.mock_resampler.send_meter_power([2.0, 3.0, 4.0, 5.0]) + await mockgrid.mock_resampler.send_bat_inverter_power([2.0, 3.0]) + await mockgrid.mock_resampler.send_pv_inverter_power([4.0, 5.0]) assert (await consumer_power_receiver.receive()).value == Power.from_watts( 0.0 ) diff --git a/tests/timeseries/test_logical_meter.py b/tests/timeseries/test_logical_meter.py index d97ed3c99..ee1961dee 100644 --- a/tests/timeseries/test_logical_meter.py +++ b/tests/timeseries/test_logical_meter.py @@ -33,9 +33,11 @@ async def test_chp_power(self, mocker: MockerFixture) -> None: chp_power_receiver = logical_meter.chp_power.new_receiver() await mockgrid.mock_resampler.send_meter_power([2.0, 3.0, 4.0]) + await mockgrid.mock_resampler.send_chp_power([2.0]) assert (await chp_power_receiver.receive()).value == Power.from_watts(2.0) await mockgrid.mock_resampler.send_meter_power([-12.0, None, 10.2]) + await mockgrid.mock_resampler.send_chp_power([-12.0]) assert (await chp_power_receiver.receive()).value == Power.from_watts(-12.0) async def test_pv_power(self, mocker: MockerFixture) -> None: diff --git a/tests/timeseries/test_producer.py b/tests/timeseries/test_producer.py index 297e47183..006fe41bd 100644 --- a/tests/timeseries/test_producer.py +++ b/tests/timeseries/test_producer.py @@ -27,9 +27,11 @@ async def test_producer_power(self, mocker: MockerFixture) -> None: stack.push_async_callback(producer.stop) producer_power_receiver = producer.power.new_receiver() - await mockgrid.mock_resampler.send_meter_power([2.0, 3.0, 4.0, 5.0]) + await mockgrid.mock_resampler.send_meter_power([-2.0, -3.0, -4.0, -5.0]) + await mockgrid.mock_resampler.send_pv_inverter_power([-2.0, -3.0]) + await mockgrid.mock_resampler.send_chp_power([-4.0, -5.0]) assert (await producer_power_receiver.receive()).value == Power.from_watts( - 14.0 + -14.0 ) async def test_producer_power_no_chp(self, mocker: MockerFixture) -> None: @@ -42,9 +44,10 @@ async def test_producer_power_no_chp(self, mocker: MockerFixture) -> None: stack.push_async_callback(producer.stop) producer_power_receiver = producer.power.new_receiver() - await mockgrid.mock_resampler.send_meter_power([2.0, 3.0]) + await mockgrid.mock_resampler.send_meter_power([-2.0, -3.0]) + await mockgrid.mock_resampler.send_pv_inverter_power([-2.0, -3.0]) assert (await producer_power_receiver.receive()).value == Power.from_watts( - 5.0 + -5.0 ) async def test_producer_power_no_pv_no_consumer_meter( @@ -59,10 +62,10 @@ async def test_producer_power_no_pv_no_consumer_meter( stack.push_async_callback(producer.stop) producer_power_receiver = producer.power.new_receiver() - await mockgrid.mock_resampler.send_chp_power([2.0]) + await mockgrid.mock_resampler.send_chp_power([-2.0]) assert (await producer_power_receiver.receive()).value == Power.from_watts( - 2.0 + -2.0 ) async def test_producer_power_no_pv(self, mocker: MockerFixture) -> None: @@ -76,9 +79,10 @@ async def test_producer_power_no_pv(self, mocker: MockerFixture) -> None: stack.push_async_callback(producer.stop) producer_power_receiver = producer.power.new_receiver() - await mockgrid.mock_resampler.send_meter_power([20.0, 2.0]) + await mockgrid.mock_resampler.send_meter_power([20.0, -2.0]) + await mockgrid.mock_resampler.send_chp_power([-2.0]) assert (await producer_power_receiver.receive()).value == Power.from_watts( - 2.0 + -2.0 ) async def test_no_producer_power(self, mocker: MockerFixture) -> None: From d70a64797450771cbf1bacbbee7af6851716f631 Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Thu, 20 Nov 2025 22:45:02 +0100 Subject: [PATCH 18/23] Test priority of component powers in formulas over meter powers The new formulas use component metrics as primary and only when those are not available, uses any corresponding meters as fallback. Signed-off-by: Sahas Subramanian --- .../_battery_pool/test_battery_pool.py | 20 +++++++++---------- tests/timeseries/test_logical_meter.py | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/timeseries/_battery_pool/test_battery_pool.py b/tests/timeseries/_battery_pool/test_battery_pool.py index 3061978a9..465be8924 100644 --- a/tests/timeseries/_battery_pool/test_battery_pool.py +++ b/tests/timeseries/_battery_pool/test_battery_pool.py @@ -516,15 +516,15 @@ async def test_battery_pool_power(mocker: MockerFixture) -> None: # send meter power [grid_meter, battery1_meter, battery2_meter] await mockgrid.mock_resampler.send_meter_power([100.0, 2.0, 3.0]) await mockgrid.mock_resampler.send_bat_inverter_power([20.0, 30.0]) - assert (await power_receiver.receive()).value == Power.from_watts(5.0) + assert (await power_receiver.receive()).value == Power.from_watts(50.0) await mockgrid.mock_resampler.send_meter_power([100.0, -2.0, -5.0]) await mockgrid.mock_resampler.send_bat_inverter_power([-20.0, -50.0]) - assert (await power_receiver.receive()).value == Power.from_watts(-7.0) + assert (await power_receiver.receive()).value == Power.from_watts(-70.0) await mockgrid.mock_resampler.send_meter_power([100.0, 2.0, -5.0]) await mockgrid.mock_resampler.send_bat_inverter_power([20.0, -50.0]) - assert (await power_receiver.receive()).value == Power.from_watts(-3.0) + assert (await power_receiver.receive()).value == Power.from_watts(-30.0) async def test_battery_pool_power_two_inverters_per_battery( @@ -546,17 +546,17 @@ async def test_battery_pool_power_two_inverters_per_battery( # send meter power [grid_meter, battery1_meter] # Fallback formula - use only meter power, inverter and batteries are not used. - await mockgrid.mock_resampler.send_meter_power([100.0, 3.0]) + await mockgrid.mock_resampler.send_meter_power([100.0, 2.0]) await mockgrid.mock_resampler.send_bat_inverter_power([20.0, 30.0]) - assert (await power_receiver.receive()).value == Power.from_watts(3.0) + assert (await power_receiver.receive()).value == Power.from_watts(50.0) await mockgrid.mock_resampler.send_meter_power([100.0, -5.0]) await mockgrid.mock_resampler.send_bat_inverter_power([-20.0, -50.0]) - assert (await power_receiver.receive()).value == Power.from_watts(-5.0) + assert (await power_receiver.receive()).value == Power.from_watts(-70.0) await mockgrid.mock_resampler.send_meter_power([100.0, -5.0]) await mockgrid.mock_resampler.send_bat_inverter_power([20.0, -50.0]) - assert (await power_receiver.receive()).value == Power.from_watts(-5.0) + assert (await power_receiver.receive()).value == Power.from_watts(-30.0) async def test_batter_pool_power_two_batteries_per_inverter( @@ -595,15 +595,15 @@ async def test_batter_pool_power_two_batteries_per_inverter( # Fallback formula - use only meter power, inverter and batteries are not used. await mockgrid.mock_resampler.send_meter_power([100.0, 3.0]) await mockgrid.mock_resampler.send_bat_inverter_power([20.0, 30.0]) - assert (await power_receiver.receive()).value == Power.from_watts(103.0) + assert (await power_receiver.receive()).value == Power.from_watts(50.0) await mockgrid.mock_resampler.send_meter_power([100.0, -5.0]) await mockgrid.mock_resampler.send_bat_inverter_power([-20.0, -50.0]) - assert (await power_receiver.receive()).value == Power.from_watts(95.0) + assert (await power_receiver.receive()).value == Power.from_watts(-70.0) await mockgrid.mock_resampler.send_meter_power([3.0, -5.0]) await mockgrid.mock_resampler.send_bat_inverter_power([20.0, -50.0]) - assert (await power_receiver.receive()).value == Power.from_watts(-2.0) + assert (await power_receiver.receive()).value == Power.from_watts(-30.0) async def test_batter_pool_power_no_batteries(mocker: MockerFixture) -> None: diff --git a/tests/timeseries/test_logical_meter.py b/tests/timeseries/test_logical_meter.py index ee1961dee..7f803935d 100644 --- a/tests/timeseries/test_logical_meter.py +++ b/tests/timeseries/test_logical_meter.py @@ -52,7 +52,7 @@ async def test_pv_power(self, mocker: MockerFixture) -> None: await mockgrid.mock_resampler.send_meter_power([-1.0, -2.0]) await mockgrid.mock_resampler.send_pv_inverter_power([-10.0, -20.0]) - assert (await pv_power_receiver.receive()).value == Power.from_watts(-3.0) + assert (await pv_power_receiver.receive()).value == Power.from_watts(-30.0) async def test_pv_power_no_meter(self, mocker: MockerFixture) -> None: """Test the pv power formula.""" From 898c9762cf52c05da41013cd55fbd7f13703af46 Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Fri, 28 Nov 2025 14:45:42 +0100 Subject: [PATCH 19/23] Increase number of active namespaces for formula test The new formulas use the CHP meter in the grid formula, and in the CHP formula, both of which are in different namespaces. This change makes the same data available from two separate streams from the mock_resampler. Signed-off-by: Sahas Subramanian --- tests/timeseries/_formulas/test_formula_composition.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/timeseries/_formulas/test_formula_composition.py b/tests/timeseries/_formulas/test_formula_composition.py index feb80a9ca..1387bbff5 100644 --- a/tests/timeseries/_formulas/test_formula_composition.py +++ b/tests/timeseries/_formulas/test_formula_composition.py @@ -208,7 +208,7 @@ async def test_formula_composition_missing_bat(self, mocker: MockerFixture) -> N async def test_formula_composition_min_max(self, mocker: MockerFixture) -> None: """Test the composition of formulas with the min and max.""" - mockgrid = MockMicrogrid(grid_meter=True, mocker=mocker) + mockgrid = MockMicrogrid(grid_meter=True, mocker=mocker, num_namespaces=2) mockgrid.add_chps(1) async with mockgrid, AsyncExitStack() as stack: From c9719e54d47b20f4516b25318e9f8ac1aee08b74 Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Thu, 20 Nov 2025 22:59:07 +0100 Subject: [PATCH 20/23] Drop old formula engine Signed-off-by: Sahas Subramanian --- .../sdk/timeseries/formula_engine/__init__.py | 119 -- .../timeseries/formula_engine/_exceptions.py | 8 - .../formula_engine/_formula_engine.py | 1120 ----------------- .../formula_engine/_formula_engine_pool.py | 242 ---- .../formula_engine/_formula_evaluator.py | 133 -- .../formula_engine/_formula_formatter.py | 265 ---- .../_formula_generators/__init__.py | 52 - .../_battery_power_formula.py | 171 --- .../_formula_generators/_chp_power_formula.py | 97 -- .../_consumer_power_formula.py | 283 ----- .../_ev_charger_current_formula.py | 77 -- .../_ev_charger_power_formula.py | 50 - .../_fallback_formula_metric_fetcher.py | 89 -- .../_formula_generators/_formula_generator.py | 260 ---- .../_grid_current_formula.py | 70 -- .../_grid_power_3_phase_formula.py | 90 -- .../_grid_power_formula.py | 82 -- .../_grid_power_formula_base.py | 97 -- .../_grid_reactive_power_formula.py | 82 -- .../_producer_power_formula.py | 151 --- .../_formula_generators/_pv_power_formula.py | 142 --- .../_formula_generators/_simple_formula.py | 106 -- .../formula_engine/_formula_steps.py | 601 --------- .../_resampled_formula_builder.py | 160 --- .../timeseries/formula_engine/_tokenizer.py | 178 --- tests/timeseries/_formula_engine/__init__.py | 4 - tests/timeseries/test_formula_engine.py | 930 -------------- tests/timeseries/test_formula_formatter.py | 140 --- 28 files changed, 5799 deletions(-) delete mode 100644 src/frequenz/sdk/timeseries/formula_engine/__init__.py delete mode 100644 src/frequenz/sdk/timeseries/formula_engine/_exceptions.py delete mode 100644 src/frequenz/sdk/timeseries/formula_engine/_formula_engine.py delete mode 100644 src/frequenz/sdk/timeseries/formula_engine/_formula_engine_pool.py delete mode 100644 src/frequenz/sdk/timeseries/formula_engine/_formula_evaluator.py delete mode 100644 src/frequenz/sdk/timeseries/formula_engine/_formula_formatter.py delete mode 100644 src/frequenz/sdk/timeseries/formula_engine/_formula_generators/__init__.py delete mode 100644 src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_battery_power_formula.py delete mode 100644 src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_chp_power_formula.py delete mode 100644 src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_consumer_power_formula.py delete mode 100644 src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_ev_charger_current_formula.py delete mode 100644 src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_ev_charger_power_formula.py delete mode 100644 src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_fallback_formula_metric_fetcher.py delete mode 100644 src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_formula_generator.py delete mode 100644 src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_current_formula.py delete mode 100644 src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_power_3_phase_formula.py delete mode 100644 src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_power_formula.py delete mode 100644 src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_power_formula_base.py delete mode 100644 src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_reactive_power_formula.py delete mode 100644 src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_producer_power_formula.py delete mode 100644 src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_pv_power_formula.py delete mode 100644 src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_simple_formula.py delete mode 100644 src/frequenz/sdk/timeseries/formula_engine/_formula_steps.py delete mode 100644 src/frequenz/sdk/timeseries/formula_engine/_resampled_formula_builder.py delete mode 100644 src/frequenz/sdk/timeseries/formula_engine/_tokenizer.py delete mode 100644 tests/timeseries/_formula_engine/__init__.py delete mode 100644 tests/timeseries/test_formula_engine.py delete mode 100644 tests/timeseries/test_formula_formatter.py diff --git a/src/frequenz/sdk/timeseries/formula_engine/__init__.py b/src/frequenz/sdk/timeseries/formula_engine/__init__.py deleted file mode 100644 index 761d6db91..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/__init__.py +++ /dev/null @@ -1,119 +0,0 @@ -# License: MIT -# Copyright © 2023 Frequenz Energy-as-a-Service GmbH - -"""Provides a way for the SDK to apply formulas on resampled data streams. - -# Formula Engine - -[`FormulaEngine`][frequenz.sdk.timeseries.formula_engine.FormulaEngine]s are used in the -SDK to calculate and stream metrics like -[`grid_power`][frequenz.sdk.timeseries.grid.Grid.power], -[`consumer_power`][frequenz.sdk.timeseries.consumer.Consumer.power], etc., which are -building blocks of the [Frequenz SDK Microgrid -Model][frequenz.sdk.microgrid--frequenz-sdk-microgrid-model]. - -The SDK creates the formulas by analysing the configuration of components in the -{{glossary("Component Graph")}}. - -## Streaming Interface - -The -[`FormulaEngine.new_receiver()`][frequenz.sdk.timeseries.formula_engine.FormulaEngine.new_receiver] -method can be used to create a [Receiver][frequenz.channels.Receiver] that streams the -[Sample][frequenz.sdk.timeseries.Sample]s calculated by the formula engine. - -```python -from frequenz.sdk import microgrid - -battery_pool = microgrid.new_battery_pool(priority=5) - -async for power in battery_pool.power.new_receiver(): - print(f"{power=}") -``` - -## Composition - -Composite `FormulaEngine`s can be built using arithmetic operations on -`FormulaEngine`s streaming the same type of data. - -For example, if you're interested in a particular composite metric that can be -calculated by subtracting -[`new_battery_pool().power`][frequenz.sdk.timeseries.battery_pool.BatteryPool.power] and -[`new_ev_charger_pool().power`][frequenz.sdk.timeseries.ev_charger_pool.EVChargerPool] -from the -[`grid().power`][frequenz.sdk.timeseries.grid.Grid.power], -we can build a `FormulaEngine` that provides a stream of this calculated metric as -follows: - -```python -from frequenz.sdk import microgrid - -battery_pool = microgrid.new_battery_pool(priority=5) -ev_charger_pool = microgrid.new_ev_charger_pool(priority=5) -grid = microgrid.grid() - -# apply operations on formula engines to create a formula engine that would -# apply these operations on the corresponding data streams. -net_power = ( - grid.power - (battery_pool.power + ev_charger_pool.power) -).build("net_power") - -async for power in net_power.new_receiver(): - print(f"{power=}") -``` - -# Formula Engine 3-Phase - -A [`FormulaEngine3Phase`][frequenz.sdk.timeseries.formula_engine.FormulaEngine3Phase] -is similar to a -[`FormulaEngine`][frequenz.sdk.timeseries.formula_engine.FormulaEngine], except that -they stream [3-phase samples][frequenz.sdk.timeseries.Sample3Phase]. All the -current formulas (like -[`Grid.current_per_phase`][frequenz.sdk.timeseries.grid.Grid.current_per_phase], -[`EVChargerPool.current_per_phase`][frequenz.sdk.timeseries.ev_charger_pool.EVChargerPool.current_per_phase], -etc.) are implemented as per-phase formulas. - -## Streaming Interface - -The -[`FormulaEngine3Phase.new_receiver()`][frequenz.sdk.timeseries.formula_engine.FormulaEngine3Phase.new_receiver] -method can be used to create a [Receiver][frequenz.channels.Receiver] that streams the -[Sample3Phase][frequenz.sdk.timeseries.Sample3Phase] values -calculated by the formula engine. - -```python -from frequenz.sdk import microgrid - -ev_charger_pool = microgrid.new_ev_charger_pool(priority=5) - -async for sample in ev_charger_pool.current_per_phase.new_receiver(): - print(f"Current: {sample}") -``` - -## Composition - -`FormulaEngine3Phase` instances can be composed together, just like `FormulaEngine` -instances. - -```python -from frequenz.sdk import microgrid - -ev_charger_pool = microgrid.new_ev_charger_pool(priority=5) -grid = microgrid.grid() - -# Calculate grid consumption current that's not used by the EV chargers -other_current = (grid.current_per_phase - ev_charger_pool.current_per_phase).build( - "other_current" -) - -async for sample in other_current.new_receiver(): - print(f"Other current: {sample}") -``` -""" - -from ._formula_engine import FormulaEngine, FormulaEngine3Phase - -__all__ = [ - "FormulaEngine", - "FormulaEngine3Phase", -] diff --git a/src/frequenz/sdk/timeseries/formula_engine/_exceptions.py b/src/frequenz/sdk/timeseries/formula_engine/_exceptions.py deleted file mode 100644 index 71f5575ec..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_exceptions.py +++ /dev/null @@ -1,8 +0,0 @@ -# License: MIT -# Copyright © 2022 Frequenz Energy-as-a-Service GmbH - -"""Formula Engine Exceptions.""" - - -class FormulaEngineError(Exception): - """An error occurred while fetching metrics or applying the formula on them.""" diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_engine.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_engine.py deleted file mode 100644 index ab84055a7..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_engine.py +++ /dev/null @@ -1,1120 +0,0 @@ -# License: MIT -# Copyright © 2022 Frequenz Energy-as-a-Service GmbH - -# pylint: disable=too-many-lines - -"""A formula engine that can apply formulas on streaming data.""" - -from __future__ import annotations - -import asyncio -import logging -from abc import ABC -from collections import deque -from collections.abc import Callable -from typing import Any, Generic, Self, TypeVar - -from frequenz.channels import Broadcast, Receiver -from frequenz.quantities import Quantity - -from ..._internal._asyncio import cancel_and_await -from .._base_types import QuantityT, Sample, Sample3Phase -from ._formula_evaluator import FormulaEvaluator -from ._formula_formatter import format_formula -from ._formula_steps import ( - Adder, - Clipper, - ConstantValue, - Consumption, - Divider, - FallbackMetricFetcher, - FormulaStep, - Maximizer, - MetricFetcher, - Minimizer, - Multiplier, - OpenParen, - Production, - Subtractor, -) -from ._tokenizer import TokenType - -_logger = logging.Logger(__name__) - -_operator_precedence = { - "max": 0, - "min": 1, - "consumption": 2, - "production": 3, - "(": 4, - "/": 5, - "*": 6, - "-": 7, - "+": 8, - ")": 9, -} -"""The dictionary of operator precedence for the shunting yard algorithm.""" - - -class FormulaEngine(Generic[QuantityT]): - """An engine to apply formulas on resampled data streams. - - Please refer to the [module documentation][frequenz.sdk.timeseries.formula_engine] - for more information on how formula engines are used throughout the SDK. - - Example: Streaming the power of a battery pool. - ```python - from frequenz.sdk import microgrid - - battery_pool = microgrid.new_battery_pool(priority=5) - - async for power in battery_pool.power.new_receiver(): - print(f"{power=}") - ``` - - Example: Composition of formula engines. - ```python - from frequenz.sdk import microgrid - - battery_pool = microgrid.new_battery_pool(priority=5) - ev_charger_pool = microgrid.new_ev_charger_pool(priority=5) - grid = microgrid.grid() - - # apply operations on formula engines to create a formula engine that would - # apply these operations on the corresponding data streams. - net_power = ( - grid.power - (battery_pool.power + ev_charger_pool.power) - ).build("net_power") - - async for power in net_power.new_receiver(): - print(f"{power=}") - ``` - """ - - def __init__( - self, - builder: FormulaBuilder[QuantityT], - create_method: Callable[[float], QuantityT], - ) -> None: - """Create a `FormulaEngine` instance. - - Args: - builder: A `FormulaBuilder` instance to get the formula steps and metric - fetchers from. - create_method: A method to generate the output `Sample` value with. If the - formula is for generating power values, this would be - `Power.from_watts`, for example. - """ - self._higher_order_builder = HigherOrderFormulaBuilder - self._name: str = builder.name - self._builder: FormulaBuilder[QuantityT] = builder - self._create_method: Callable[[float], QuantityT] = create_method - self._channel: Broadcast[Sample[QuantityT]] = Broadcast(name=self._name) - self._task: asyncio.Task[None] | None = None - - async def stop(self) -> None: - """Stop a running formula engine.""" - if self._task is None: - return - await cancel_and_await(self._task) - - _, fetchers = self._builder.finalize() - for fetcher in fetchers.values(): - await fetcher.stop() - - @classmethod - def from_receiver( - cls, - name: str, - receiver: Receiver[Sample[QuantityT]], - create_method: Callable[[float], QuantityT], - *, - nones_are_zeros: bool = False, - ) -> FormulaEngine[QuantityT]: - """ - Create a formula engine from a receiver. - - Can be used to compose a formula engine with a receiver. When composing - the new engine with other engines, make sure that receiver gets data - from the same resampler and that the `create_method`s match. - - Example: - ```python - from frequenz.sdk import microgrid - from frequenz.quantities import Power - - async def run() -> None: - producer_power_engine = microgrid.producer().power - consumer_power_recv = microgrid.consumer().power.new_receiver() - - excess_power_recv = ( - ( - producer_power_engine - + FormulaEngine.from_receiver( - "consumer power", - consumer_power_recv, - Power.from_watts, - ) - ) - .build("excess power") - .new_receiver() - ) - - asyncio.run(run()) - ``` - - Args: - name: A name for the formula engine. - receiver: A receiver that streams `Sample`s. - create_method: A method to generate the output `Sample` value with, - e.g. `Power.from_watts`. - nones_are_zeros: If `True`, `None` values in the receiver are treated as 0. - - Returns: - A formula engine that streams the `Sample`s from the receiver. - """ - builder = FormulaBuilder(name, create_method) - builder.push_metric(name, receiver, nones_are_zeros=nones_are_zeros) - return cls(builder, create_method) - - def __add__( - self, - other: ( - FormulaEngine[QuantityT] | HigherOrderFormulaBuilder[QuantityT] | QuantityT - ), - ) -> HigherOrderFormulaBuilder[QuantityT]: - """Return a formula builder that adds (data in) `other` to `self`. - - Args: - other: A formula receiver, or a formula builder instance corresponding to a - sub-expression. - - Returns: - A formula builder that can take further expressions, or can be built - into a formula engine. - """ - return HigherOrderFormulaBuilder(self, self._create_method) + other - - def __sub__( - self, - other: ( - FormulaEngine[QuantityT] | HigherOrderFormulaBuilder[QuantityT] | QuantityT - ), - ) -> HigherOrderFormulaBuilder[QuantityT]: - """Return a formula builder that subtracts (data in) `other` from `self`. - - Args: - other: A formula receiver, or a formula builder instance corresponding to a - sub-expression. - - Returns: - A formula builder that can take further expressions, or can be built - into a formula engine. - """ - return HigherOrderFormulaBuilder(self, self._create_method) - other - - def __mul__( - self, - other: FormulaEngine[QuantityT] | HigherOrderFormulaBuilder[QuantityT] | float, - ) -> HigherOrderFormulaBuilder[QuantityT]: - """Return a formula builder that multiplies (data in) `self` with `other`. - - Args: - other: A formula receiver, or a formula builder instance corresponding to a - sub-expression. - - Returns: - A formula builder that can take further expressions, or can be built - into a formula engine. - """ - return HigherOrderFormulaBuilder(self, self._create_method) * other - - def __truediv__( - self, - other: FormulaEngine[QuantityT] | HigherOrderFormulaBuilder[QuantityT] | float, - ) -> HigherOrderFormulaBuilder[QuantityT]: - """Return a formula builder that divides (data in) `self` by `other`. - - Args: - other: A formula receiver, or a formula builder instance corresponding to a - sub-expression. - - Returns: - A formula builder that can take further expressions, or can be built - into a formula engine. - """ - return HigherOrderFormulaBuilder(self, self._create_method) / other - - def max( - self, - other: ( - FormulaEngine[QuantityT] | HigherOrderFormulaBuilder[QuantityT] | QuantityT - ), - ) -> HigherOrderFormulaBuilder[QuantityT]: - """Return a formula engine that outputs the maximum of `self` and `other`. - - Args: - other: A formula receiver, a formula builder or a QuantityT instance - corresponding to a sub-expression. - - Returns: - A formula builder that can take further expressions, or can be built - into a formula engine. - """ - return HigherOrderFormulaBuilder(self, self._create_method).max(other) - - def min( - self, - other: ( - FormulaEngine[QuantityT] | HigherOrderFormulaBuilder[QuantityT] | QuantityT - ), - ) -> HigherOrderFormulaBuilder[QuantityT]: - """Return a formula engine that outputs the minimum of `self` and `other`. - - Args: - other: A formula receiver, a formula builder or a QuantityT instance - corresponding to a sub-expression. - - Returns: - A formula builder that can take further expressions, or can be built - into a formula engine. - """ - return HigherOrderFormulaBuilder(self, self._create_method).min(other) - - def consumption( - self, - ) -> HigherOrderFormulaBuilder[QuantityT]: - """ - Return a formula builder that applies the consumption operator on `self`. - - The consumption operator returns either the identity if the power value is - positive or 0. - """ - return HigherOrderFormulaBuilder(self, self._create_method).consumption() - - def production( - self, - ) -> HigherOrderFormulaBuilder[QuantityT]: - """ - Return a formula builder that applies the production operator on `self`. - - The production operator returns either the absolute value if the power value is - negative or 0. - """ - return HigherOrderFormulaBuilder(self, self._create_method).production() - - async def _run(self) -> None: - await self._builder.subscribe() - steps, metric_fetchers = self._builder.finalize() - evaluator = FormulaEvaluator[QuantityT]( - self._name, steps, metric_fetchers, self._create_method - ) - sender = self._channel.new_sender() - while True: - try: - msg = await evaluator.apply() - except asyncio.CancelledError: - _logger.debug("FormulaEngine task cancelled: %s", self._name) - raise - except Exception as err: # pylint: disable=broad-except - _logger.warning( - "Formula application failed: %s. Error: %s", self._name, err - ) - else: - await sender.send(msg) - - def __str__(self) -> str: - """Return a string representation of the formula. - - Returns: - A string representation of the formula. - """ - steps = ( - self._builder._build_stack - if len(self._builder._build_stack) > 0 - else self._builder._steps - ) - return format_formula(steps) - - def new_receiver( - self, name: str | None = None, max_size: int = 50 - ) -> Receiver[Sample[QuantityT]]: - """Create a new receiver that streams the output of the formula engine. - - Args: - name: An optional name for the receiver. - max_size: The size of the receiver's buffer. - - Returns: - A receiver that streams output `Sample`s from the formula engine. - """ - if self._task is None: - self._task = asyncio.create_task(self._run()) - - recv = self._channel.new_receiver(name=name, limit=max_size) - - # This is a hack to ensure that the lifetime of the engine is tied to the - # lifetime of the receiver. This is necessary because the engine is a task that - # runs forever, and in cases where higher order built for example with the below - # idiom, the user would hold no references to the engine and it could get - # garbage collected before the receiver. This behaviour is explained in the - # `asyncio.create_task` docs here: - # https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task - # - # formula = (grid_power_engine + bat_power_engine).build().new_receiver() - recv._engine_reference = self # type: ignore # pylint: disable=protected-access - return recv - - -class FormulaEngine3Phase(Generic[QuantityT]): - """An engine to apply formulas on 3-phase resampled data streams. - - Please refer to the [module documentation][frequenz.sdk.timeseries.formula_engine] - for more information on how formula engines are used throughout the SDK. - - Example: Streaming the current of an EV charger pool. - ```python - from frequenz.sdk import microgrid - - ev_charger_pool = microgrid.new_ev_charger_pool(priority=5) - - async for sample in ev_charger_pool.current_per_phase.new_receiver(): - print(f"Current: {sample}") - ``` - - Example: Composition of formula engines. - ```python - from frequenz.sdk import microgrid - - ev_charger_pool = microgrid.new_ev_charger_pool(priority=5) - grid = microgrid.grid() - - # Calculate grid consumption current that's not used by the EV chargers - other_current = (grid.current_per_phase - ev_charger_pool.current_per_phase).build( - "other_current" - ) - - async for sample in other_current.new_receiver(): - print(f"Other current: {sample}") - ``` - """ - - def __init__( - self, - name: str, - create_method: Callable[[float], QuantityT], - phase_streams: tuple[ - FormulaEngine[QuantityT], - FormulaEngine[QuantityT], - FormulaEngine[QuantityT], - ], - ) -> None: - """Create a `FormulaEngine3Phase` instance. - - Args: - name: A name for the formula. - create_method: A method to generate the output `Sample` value with. If the - formula is for generating power values, this would be - `Power.from_watts`, for example. - phase_streams: output streams of formula engines running per-phase formulas. - """ - self._higher_order_builder = HigherOrderFormulaBuilder3Phase - self._name: str = name - self._create_method: Callable[[float], QuantityT] = create_method - self._channel: Broadcast[Sample3Phase[QuantityT]] = Broadcast(name=self._name) - self._task: asyncio.Task[None] | None = None - self._streams: tuple[ - FormulaEngine[QuantityT], - FormulaEngine[QuantityT], - FormulaEngine[QuantityT], - ] = phase_streams - - async def stop(self) -> None: - """Stop a running formula engine.""" - if self._task is None: - return - await cancel_and_await(self._task) - - def __add__( - self, - other: ( - FormulaEngine3Phase[QuantityT] | HigherOrderFormulaBuilder3Phase[QuantityT] - ), - ) -> HigherOrderFormulaBuilder3Phase[QuantityT]: - """Return a formula builder that adds (data in) `other` to `self`. - - Args: - other: A formula receiver, or a formula builder instance corresponding to a - sub-expression. - - Returns: - A formula builder that can take further expressions, or can be built - into a formula engine. - """ - return HigherOrderFormulaBuilder3Phase(self, self._create_method) + other - - def __sub__( - self: FormulaEngine3Phase[QuantityT], - other: ( - FormulaEngine3Phase[QuantityT] | HigherOrderFormulaBuilder3Phase[QuantityT] - ), - ) -> HigherOrderFormulaBuilder3Phase[QuantityT]: - """Return a formula builder that subtracts (data in) `other` from `self`. - - Args: - other: A formula receiver, or a formula builder instance corresponding to a - sub-expression. - - Returns: - A formula builder that can take further expressions, or can be built - into a formula engine. - """ - return HigherOrderFormulaBuilder3Phase(self, self._create_method) - other - - def __mul__( - self: FormulaEngine3Phase[QuantityT], - other: ( - FormulaEngine3Phase[QuantityT] | HigherOrderFormulaBuilder3Phase[QuantityT] - ), - ) -> HigherOrderFormulaBuilder3Phase[QuantityT]: - """Return a formula builder that multiplies (data in) `self` with `other`. - - Args: - other: A formula receiver, or a formula builder instance corresponding to a - sub-expression. - - Returns: - A formula builder that can take further expressions, or can be built - into a formula engine. - """ - return HigherOrderFormulaBuilder3Phase(self, self._create_method) * other - - def __truediv__( - self: FormulaEngine3Phase[QuantityT], - other: ( - FormulaEngine3Phase[QuantityT] | HigherOrderFormulaBuilder3Phase[QuantityT] - ), - ) -> HigherOrderFormulaBuilder3Phase[QuantityT]: - """Return a formula builder that divides (data in) `self` by `other`. - - Args: - other: A formula receiver, or a formula builder instance corresponding to a - sub-expression. - - Returns: - A formula builder that can take further expressions, or can be built - into a formula engine. - """ - return HigherOrderFormulaBuilder3Phase(self, self._create_method) / other - - def max( - self: FormulaEngine3Phase[QuantityT], - other: ( - FormulaEngine3Phase[QuantityT] | HigherOrderFormulaBuilder3Phase[QuantityT] - ), - ) -> HigherOrderFormulaBuilder3Phase[QuantityT]: - """Return a formula engine that outputs the maximum of `self` and `other`. - - Args: - other: A formula receiver, a formula builder or a QuantityT instance - corresponding to a sub-expression. - - Returns: - A formula builder that can take further expressions, or can be built - into a formula engine. - """ - return HigherOrderFormulaBuilder3Phase(self, self._create_method).max(other) - - def min( - self: FormulaEngine3Phase[QuantityT], - other: ( - FormulaEngine3Phase[QuantityT] | HigherOrderFormulaBuilder3Phase[QuantityT] - ), - ) -> HigherOrderFormulaBuilder3Phase[QuantityT]: - """Return a formula engine that outputs the minimum of `self` and `other`. - - Args: - other: A formula receiver, a formula builder or a QuantityT instance - corresponding to a sub-expression. - - Returns: - A formula builder that can take further expressions, or can be built - into a formula engine. - """ - return HigherOrderFormulaBuilder3Phase(self, self._create_method).min(other) - - def consumption( - self: FormulaEngine3Phase[QuantityT], - ) -> HigherOrderFormulaBuilder3Phase[QuantityT]: - """ - Return a formula builder that applies the consumption operator on `self`. - - The consumption operator returns either the identity if the power value is - positive or 0. - """ - return HigherOrderFormulaBuilder3Phase(self, self._create_method).consumption() - - def production( - self: FormulaEngine3Phase[QuantityT], - ) -> HigherOrderFormulaBuilder3Phase[QuantityT]: - """ - Return a formula builder that applies the production operator on `self`. - - The production operator returns either the absolute value if the power value is - negative or 0. - """ - return HigherOrderFormulaBuilder3Phase(self, self._create_method).production() - - async def _run(self) -> None: - sender = self._channel.new_sender() - phase_1_rx = self._streams[0].new_receiver() - phase_2_rx = self._streams[1].new_receiver() - phase_3_rx = self._streams[2].new_receiver() - - while True: - try: - phase_1 = await phase_1_rx.receive() - phase_2 = await phase_2_rx.receive() - phase_3 = await phase_3_rx.receive() - msg = Sample3Phase( - phase_1.timestamp, - phase_1.value, - phase_2.value, - phase_3.value, - ) - except asyncio.CancelledError: - _logger.debug("FormulaEngine task cancelled: %s", self._name) - raise - await sender.send(msg) - - def new_receiver( - self, name: str | None = None, max_size: int = 50 - ) -> Receiver[Sample3Phase[QuantityT]]: - """Create a new receiver that streams the output of the formula engine. - - Args: - name: An optional name for the receiver. - max_size: The size of the receiver's buffer. - - Returns: - A receiver that streams output `Sample`s from the formula engine. - """ - if self._task is None: - self._task = asyncio.create_task(self._run()) - - recv = self._channel.new_receiver(name=name, limit=max_size) - - # This is a hack to ensure that the lifetime of the engine is tied to the - # lifetime of the receiver. This is necessary because the engine is a task that - # runs forever, and in cases where higher order built for example with the below - # idiom, the user would hold no references to the engine and it could get - # garbage collected before the receiver. This behaviour is explained in the - # `asyncio.create_task` docs here: - # https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task - # - # formula = (grid_power_engine + bat_power_engine).build().new_receiver() - recv._engine_reference = self # type: ignore # pylint: disable=protected-access - return recv - - -class FormulaBuilder(Generic[QuantityT]): - """Builds a post-fix formula engine that operates on `Sample` receivers. - - Operators and metrics need to be pushed in in-fix order, and they get rearranged - into post-fix order. This is done using the [Shunting yard - algorithm](https://en.wikipedia.org/wiki/Shunting_yard_algorithm). - - Example: - To create an engine that adds the latest entries from two receivers, the - following calls need to be made: - - ```python - from frequenz.quantities import Power - - channel = Broadcast[Sample[Power]](name="channel") - receiver_1 = channel.new_receiver(name="receiver_1") - receiver_2 = channel.new_receiver(name="receiver_2") - builder = FormulaBuilder("addition", Power) - builder.push_metric("metric_1", receiver_1, nones_are_zeros=True) - builder.push_oper("+") - builder.push_metric("metric_2", receiver_2, nones_are_zeros=True) - engine = builder.build() - ``` - - and then every call to `engine.apply()` would fetch a value from each receiver, - add the values and return the result. - """ - - def __init__(self, name: str, create_method: Callable[[float], QuantityT]) -> None: - """Create a `FormulaBuilder` instance. - - Args: - name: A name for the formula being built. - create_method: A method to generate the output `Sample` value with. If the - formula is for generating power values, this would be - `Power.from_watts`, for example. - """ - self._name = name - self._create_method: Callable[[float], QuantityT] = create_method - self._build_stack: list[FormulaStep] = [] - self._steps: list[FormulaStep] = [] - self._metric_fetchers: dict[str, MetricFetcher[QuantityT]] = {} - - def push_oper(self, oper: str) -> None: # pylint: disable=too-many-branches - """Push an operator into the engine. - - Args: - oper: One of these strings - "+", "-", "*", "/", "(", ")" - """ - if self._build_stack and oper != "(": - op_prec = _operator_precedence[oper] - while self._build_stack: - prev_step = self._build_stack[-1] - if op_prec < _operator_precedence[repr(prev_step)]: - break - if oper == ")" and repr(prev_step) == "(": - self._build_stack.pop() - break - if repr(prev_step) == "(": - break - self._steps.append(prev_step) - self._build_stack.pop() - - if oper == "+": - self._build_stack.append(Adder()) - elif oper == "-": - self._build_stack.append(Subtractor()) - elif oper == "*": - self._build_stack.append(Multiplier()) - elif oper == "/": - self._build_stack.append(Divider()) - elif oper == "(": - self._build_stack.append(OpenParen()) - elif oper == "max": - self._build_stack.append(Maximizer()) - elif oper == "min": - self._build_stack.append(Minimizer()) - elif oper == "consumption": - self._build_stack.append(Consumption()) - elif oper == "production": - self._build_stack.append(Production()) - - def push_metric( - self, - name: str, - data_stream: Receiver[Sample[QuantityT]], - *, - nones_are_zeros: bool, - fallback: FallbackMetricFetcher[QuantityT] | None = None, - ) -> None: - """Push a metric receiver into the engine. - - Args: - name: A name for the metric. - data_stream: A receiver to fetch this metric from. - nones_are_zeros: Whether to treat None values from the stream as 0s. If - False, the returned value will be a None. - fallback: Metric fetcher to use if primary one start sending - invalid data (e.g. due to a component stop). If None, the data from - primary metric fetcher will be used. - """ - fetcher = self._metric_fetchers.get(name) - if fetcher is None: - fetcher = MetricFetcher( - name, - data_stream, - nones_are_zeros=nones_are_zeros, - fallback=fallback, - ) - self._metric_fetchers[name] = fetcher - self._steps.append(fetcher) - - def push_constant(self, value: float) -> None: - """Push a constant value into the engine. - - Args: - value: The constant value to push. - """ - self._steps.append(ConstantValue(value)) - - def push_clipper(self, min_value: float | None, max_value: float | None) -> None: - """Push a clipper step into the engine. - - The clip will be applied on the last value available on the evaluation stack, - before the clip step is called. - - So if an entire expression needs to be clipped, the expression should be - enclosed in parentheses, before the clip step is added. - - For example, this clips the output of the entire expression: - - ```python - from frequenz.quantities import Power - - builder = FormulaBuilder("example", Power) - channel = Broadcast[Sample[Power]](name="channel") - receiver_1 = channel.new_receiver(name="receiver_1") - receiver_2 = channel.new_receiver(name="receiver_2") - - builder.push_oper("(") - builder.push_metric("metric_1", receiver_1, nones_are_zeros=True) - builder.push_oper("+") - builder.push_metric("metric_2", receiver_2, nones_are_zeros=True) - builder.push_oper(")") - builder.push_clipper(min_value=0.0, max_value=None) - ``` - - And this clips the output of metric_2 only, and not the final result: - - ```python - from frequenz.quantities import Power - - builder = FormulaBuilder("example", Power) - channel = Broadcast[Sample[Power]](name="channel") - receiver_1 = channel.new_receiver(name="receiver_1") - receiver_2 = channel.new_receiver(name="receiver_2") - - builder.push_metric("metric_1", receiver_1, nones_are_zeros=True) - builder.push_oper("+") - builder.push_metric("metric_2", receiver_2, nones_are_zeros=True) - builder.push_clipper(min_value=0.0, max_value=None) - ``` - - Args: - min_value: The minimum value to clip to. - max_value: The maximum value to clip to. - """ - self._steps.append(Clipper(min_value, max_value)) - - @property - def name(self) -> str: - """Return the name of the formula being built. - - Returns: - The name of the formula being built. - """ - return self._name - - async def subscribe(self) -> None: - """Subscribe to metrics if needed. - - This is a no-op for the `FormulaBuilder` class, but is used by the - `ResampledFormulaBuilder` class. - """ - - def finalize( - self, - ) -> tuple[list[FormulaStep], dict[str, MetricFetcher[QuantityT]]]: - """Finalize and return the steps and fetchers for the formula. - - Returns: - A tuple of the steps and fetchers for the formula. - """ - while self._build_stack: - self._steps.append(self._build_stack.pop()) - - return self._steps, self._metric_fetchers - - def __str__(self) -> str: - """Return a string representation of the formula. - - Returns: - A string representation of the formula. - """ - steps = self._steps if len(self._steps) > 0 else self._build_stack - return format_formula(steps) - - def build(self) -> FormulaEngine[QuantityT]: - """Create a formula engine with the steps and fetchers that have been pushed. - - Returns: - A `FormulaEngine` instance. - """ - self.finalize() - return FormulaEngine(self, create_method=self._create_method) - - -FormulaEngineT = TypeVar( - "FormulaEngineT", bound=(FormulaEngine[Any] | FormulaEngine3Phase[Any]) -) - - -class _BaseHOFormulaBuilder(ABC, Generic[FormulaEngineT, QuantityT]): - """Provides a way to build formulas from the outputs of other formulas.""" - - def __init__( - self, - engine: FormulaEngineT, - create_method: Callable[[float], QuantityT], - ) -> None: - """Create a `GenericHigherOrderFormulaBuilder` instance. - - Args: - engine: A first input stream to create a builder with, so that python - operators `+, -, *, /` can be used directly on newly created instances. - create_method: A method to generate the output `Sample` value with. If the - formula is for generating power values, this would be - `Power.from_watts`, for example. - """ - self._steps: deque[ - tuple[ - TokenType, - FormulaEngine[QuantityT] - | FormulaEngine3Phase[QuantityT] - | Quantity - | float - | str, - ] - ] = deque() - self._steps.append((TokenType.COMPONENT_METRIC, engine)) - self._create_method: Callable[[float], QuantityT] = create_method - - def _push( - self, - oper: str, - other: Self | FormulaEngineT | QuantityT | float, - ) -> Self: - self._steps.appendleft((TokenType.OPER, "(")) - self._steps.append((TokenType.OPER, ")")) - self._steps.append((TokenType.OPER, oper)) - - if isinstance(other, (FormulaEngine, FormulaEngine3Phase)): - self._steps.append((TokenType.COMPONENT_METRIC, other)) - elif isinstance(other, (Quantity, float, int)): - match oper: - case "+" | "-" | "max" | "min": - if not isinstance(other, Quantity): - raise RuntimeError( - "A Quantity must be provided for addition," - f" subtraction, min or max to {other}" - ) - case "*" | "/": - if not isinstance(other, (float, int)): - raise RuntimeError( - f"A float must be provided for scalar multiplication to {other}" - ) - self._steps.append((TokenType.CONSTANT, other)) - elif isinstance(other, _BaseHOFormulaBuilder): - self._steps.append((TokenType.OPER, "(")) - self._steps.extend(other._steps) # pylint: disable=protected-access - self._steps.append((TokenType.OPER, ")")) - else: - raise RuntimeError(f"Can't build a formula from: {other}") - return self - - def __add__( - self, - other: Self | FormulaEngineT | QuantityT, - ) -> Self: - """Return a formula builder that adds (data in) `other` to `self`. - - Args: - other: A formula receiver, or a formula builder instance corresponding to a - sub-expression. - - Returns: - A formula builder that can take further expressions, or can be built - into a formula engine. - """ - return self._push("+", other) - - def __sub__( - self, - other: Self | FormulaEngineT | QuantityT, - ) -> Self: - """Return a formula builder that subtracts (data in) `other` from `self`. - - Args: - other: A formula receiver, or a formula builder instance corresponding to a - sub-expression. - - Returns: - A formula builder that can take further expressions, or can be built - into a formula engine. - """ - return self._push("-", other) - - def __mul__( - self, - other: Self | FormulaEngineT | float, - ) -> Self: - """Return a formula builder that multiplies (data in) `self` with `other`. - - Args: - other: A formula receiver, or a formula builder instance corresponding to a - sub-expression. - - Returns: - A formula builder that can take further expressions, or can be built - into a formula engine. - """ - return self._push("*", other) - - def __truediv__( - self, - other: Self | FormulaEngineT | float, - ) -> Self: - """Return a formula builder that divides (data in) `self` by `other`. - - Args: - other: A formula receiver, or a formula builder instance corresponding to a - sub-expression. - - Returns: - A formula builder that can take further expressions, or can be built - into a formula engine. - """ - return self._push("/", other) - - def max( - self, - other: Self | FormulaEngineT | QuantityT, - ) -> Self: - """Return a formula builder that calculates the maximum of `self` and `other`. - - Args: - other: A formula receiver, or a formula builder instance corresponding to a - sub-expression. - - Returns: - A formula builder that can take further expressions, or can be built - into a formula engine. - """ - return self._push("max", other) - - def min( - self, - other: Self | FormulaEngineT | QuantityT, - ) -> Self: - """Return a formula builder that calculates the minimum of `self` and `other`. - - Args: - other: A formula receiver, or a formula builder instance corresponding to a - sub-expression. - - Returns: - A formula builder that can take further expressions, or can be built - into a formula engine. - """ - return self._push("min", other) - - def consumption( - self, - ) -> Self: - """Apply the Consumption Operator. - - The consumption operator returns either the identity if the power value is - positive or 0. - - Returns: - A formula builder that can take further expressions, or can be built - into a formula engine. - """ - self._steps.appendleft((TokenType.OPER, "(")) - self._steps.append((TokenType.OPER, ")")) - self._steps.append((TokenType.OPER, "consumption")) - return self - - def production( - self, - ) -> Self: - """Apply the Production Operator. - - The production operator returns either the absolute value if the power value is - negative or 0. - - Returns: - A formula builder that can take further expressions, or can be built - into a formula engine. - """ - self._steps.appendleft((TokenType.OPER, "(")) - self._steps.append((TokenType.OPER, ")")) - self._steps.append((TokenType.OPER, "production")) - return self - - -class HigherOrderFormulaBuilder( - Generic[QuantityT], _BaseHOFormulaBuilder[FormulaEngine[QuantityT], QuantityT] -): - """A specialization of the _BaseHOFormulaBuilder for `FormulaReceiver`.""" - - def build( - self, name: str, *, nones_are_zeros: bool = False - ) -> FormulaEngine[QuantityT]: - """Build a `FormulaEngine` instance from the builder. - - Args: - name: A name for the newly generated formula. - nones_are_zeros: whether `None` values in any of the input streams should be - treated as zeros. - - Returns: - A `FormulaEngine` instance. - """ - builder = FormulaBuilder(name, self._create_method) - for typ, value in self._steps: - if typ == TokenType.COMPONENT_METRIC: - assert isinstance(value, FormulaEngine) - builder.push_metric( - value._name, # pylint: disable=protected-access - value.new_receiver(), - nones_are_zeros=nones_are_zeros, - ) - elif typ == TokenType.OPER: - assert isinstance(value, str) - builder.push_oper(value) - elif typ == TokenType.CONSTANT: - assert isinstance(value, (Quantity, float)) - builder.push_constant( - value.base_value if isinstance(value, Quantity) else value - ) - return builder.build() - - -class HigherOrderFormulaBuilder3Phase( - Generic[QuantityT], _BaseHOFormulaBuilder[FormulaEngine3Phase[QuantityT], QuantityT] -): - """A specialization of the _BaseHOFormulaBuilder for `FormulaReceiver3Phase`.""" - - def build( - self, name: str, *, nones_are_zeros: bool = False - ) -> FormulaEngine3Phase[QuantityT]: - """Build a `FormulaEngine3Phase` instance from the builder. - - Args: - name: A name for the newly generated formula. - nones_are_zeros: whether `None` values in any of the input streams should be - treated as zeros. - - Returns: - A `FormulaEngine3Phase` instance. - """ - builders = [ - FormulaBuilder(name, self._create_method), - FormulaBuilder(name, self._create_method), - FormulaBuilder(name, self._create_method), - ] - for typ, value in self._steps: - if typ == TokenType.COMPONENT_METRIC: - assert isinstance(value, FormulaEngine3Phase) - for phase in range(3): - builders[phase].push_metric( - f"{value._name}-{phase+1}", # pylint: disable=protected-access - value._streams[ # pylint: disable=protected-access - phase - ].new_receiver(), - nones_are_zeros=nones_are_zeros, - ) - elif typ == TokenType.OPER: - assert isinstance(value, str) - for phase in range(3): - builders[phase].push_oper(value) - return FormulaEngine3Phase( - name, - self._create_method, - ( - builders[0].build(), - builders[1].build(), - builders[2].build(), - ), - ) diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_engine_pool.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_engine_pool.py deleted file mode 100644 index 4ef59fe6a..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_engine_pool.py +++ /dev/null @@ -1,242 +0,0 @@ -# License: MIT -# Copyright © 2023 Frequenz Energy-as-a-Service GmbH - -"""A formula pool for helping with tracking running formula engines.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from frequenz.channels import Sender -from frequenz.client.microgrid.metrics import Metric -from frequenz.quantities import Current, Power, Quantity, ReactivePower - -from ..._internal._channels import ChannelRegistry -from ...microgrid._data_sourcing import ComponentMetricRequest -from ._formula_generators._formula_generator import ( - FormulaGenerator, - FormulaGeneratorConfig, -) -from ._resampled_formula_builder import ResampledFormulaBuilder - -if TYPE_CHECKING: - # Break circular import - from ..formula_engine import FormulaEngine, FormulaEngine3Phase - - -class FormulaEnginePool: - """Creates and owns formula engines from string formulas, or formula generators. - - If an engine already exists with a given name, it is reused instead. - """ - - def __init__( - self, - namespace: str, - channel_registry: ChannelRegistry, - resampler_subscription_sender: Sender[ComponentMetricRequest], - ) -> None: - """Create a new instance. - - Args: - namespace: namespace to use with the data pipeline. - channel_registry: A channel registry instance shared with the resampling - actor. - resampler_subscription_sender: A sender for sending metric requests to the - resampling actor. - """ - self._namespace: str = namespace - self._channel_registry: ChannelRegistry = channel_registry - self._resampler_subscription_sender: Sender[ComponentMetricRequest] = ( - resampler_subscription_sender - ) - self._string_engines: dict[str, FormulaEngine[Quantity]] = {} - self._power_engines: dict[str, FormulaEngine[Power]] = {} - self._power_3_phase_engines: dict[str, FormulaEngine3Phase[Power]] = {} - self._current_engines: dict[str, FormulaEngine3Phase[Current]] = {} - self._reactive_power_engines: dict[str, FormulaEngine[ReactivePower]] = {} - - def from_string( - self, - formula: str, - metric: Metric, - *, - nones_are_zeros: bool = False, - ) -> FormulaEngine[Quantity]: - """Get a receiver for a manual formula. - - Args: - formula: formula to execute. - metric: The metric to use when fetching receivers from the resampling - actor. - nones_are_zeros: Whether to treat None values from the stream as 0s. If - False, the returned value will be a None. - - Returns: - A FormulaReceiver that streams values with the formulas applied. - """ - channel_key = formula + str(metric.value) - if channel_key in self._string_engines: - return self._string_engines[channel_key] - - builder = ResampledFormulaBuilder( - namespace=self._namespace, - formula_name=formula, - channel_registry=self._channel_registry, - resampler_subscription_sender=self._resampler_subscription_sender, - metric=metric, - create_method=Quantity, - ) - formula_engine = builder.from_string(formula, nones_are_zeros=nones_are_zeros) - self._string_engines[channel_key] = formula_engine - - return formula_engine - - def from_reactive_power_formula_generator( - self, - channel_key: str, - generator: type[FormulaGenerator[ReactivePower]], - config: FormulaGeneratorConfig = FormulaGeneratorConfig(), - ) -> FormulaEngine[ReactivePower]: - """Get a receiver for a formula from a generator. - - Args: - channel_key: A string to uniquely identify the formula. - generator: A formula generator. - config: config to initialize the formula generator with. - - Returns: - A FormulaReceiver or a FormulaReceiver3Phase instance based on what the - FormulaGenerator returns. - """ - from ._formula_engine import ( # pylint: disable=import-outside-toplevel - FormulaEngine, - ) - - if channel_key in self._reactive_power_engines: - return self._reactive_power_engines[channel_key] - - engine = generator( - self._namespace, - self._channel_registry, - self._resampler_subscription_sender, - config, - ).generate() - assert isinstance(engine, FormulaEngine) - self._reactive_power_engines[channel_key] = engine - return engine - - def from_power_formula_generator( - self, - channel_key: str, - generator: type[FormulaGenerator[Power]], - config: FormulaGeneratorConfig = FormulaGeneratorConfig(), - ) -> FormulaEngine[Power]: - """Get a receiver for a formula from a generator. - - Args: - channel_key: A string to uniquely identify the formula. - generator: A formula generator. - config: config to initialize the formula generator with. - - Returns: - A FormulaReceiver or a FormulaReceiver3Phase instance based on what the - FormulaGenerator returns. - """ - from ._formula_engine import ( # pylint: disable=import-outside-toplevel - FormulaEngine, - ) - - if channel_key in self._power_engines: - return self._power_engines[channel_key] - - engine = generator( - self._namespace, - self._channel_registry, - self._resampler_subscription_sender, - config, - ).generate() - assert isinstance(engine, FormulaEngine) - self._power_engines[channel_key] = engine - return engine - - def from_power_3_phase_formula_generator( - self, - channel_key: str, - generator: type[FormulaGenerator[Power]], - config: FormulaGeneratorConfig = FormulaGeneratorConfig(), - ) -> FormulaEngine3Phase[Power]: - """Get a formula engine that streams 3-phase power values. - - Args: - channel_key: The string to uniquely identify the formula. - generator: The formula generator. - config: The config to initialize the formula generator with. - - Returns: - A formula engine that streams [3-phase][frequenz.sdk.timeseries.Sample3Phase] - power values. - """ - from ._formula_engine import ( # pylint: disable=import-outside-toplevel - FormulaEngine3Phase, - ) - - if channel_key in self._power_3_phase_engines: - return self._power_3_phase_engines[channel_key] - - engine = generator( - self._namespace, - self._channel_registry, - self._resampler_subscription_sender, - config, - ).generate() - assert isinstance(engine, FormulaEngine3Phase) - self._power_3_phase_engines[channel_key] = engine - return engine - - def from_3_phase_current_formula_generator( - self, - channel_key: str, - generator: type[FormulaGenerator[Current]], - config: FormulaGeneratorConfig = FormulaGeneratorConfig(), - ) -> FormulaEngine3Phase[Current]: - """Get a receiver for a formula from a generator. - - Args: - channel_key: A string to uniquely identify the formula. - generator: A formula generator. - config: config to initialize the formula generator with. - - Returns: - A FormulaReceiver or a FormulaReceiver3Phase instance based on what the - FormulaGenerator returns. - """ - from ._formula_engine import ( # pylint: disable=import-outside-toplevel - FormulaEngine3Phase, - ) - - if channel_key in self._current_engines: - return self._current_engines[channel_key] - - engine = generator( - self._namespace, - self._channel_registry, - self._resampler_subscription_sender, - config, - ).generate() - assert isinstance(engine, FormulaEngine3Phase) - self._current_engines[channel_key] = engine - return engine - - async def stop(self) -> None: - """Stop all formula engines in the pool.""" - for string_engine in self._string_engines.values(): - await string_engine.stop() - for power_engine in self._power_engines.values(): - await power_engine.stop() - for power_3_phase_engine in self._power_3_phase_engines.values(): - await power_3_phase_engine.stop() - for current_engine in self._current_engines.values(): - await current_engine.stop() - for reactive_power_engine in self._reactive_power_engines.values(): - await reactive_power_engine.stop() diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_evaluator.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_evaluator.py deleted file mode 100644 index aa26430ed..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_evaluator.py +++ /dev/null @@ -1,133 +0,0 @@ -# License: MIT -# Copyright © 2022 Frequenz Energy-as-a-Service GmbH - -"""A post-fix formula evaluator that operates on `Sample` receivers.""" - -import asyncio -from collections.abc import Callable -from datetime import datetime -from math import isinf, isnan -from typing import Generic - -from .._base_types import QuantityT, Sample -from ._formula_steps import FormulaStep, MetricFetcher - - -class FormulaEvaluator(Generic[QuantityT]): - """A post-fix formula evaluator that operates on `Sample` receivers.""" - - def __init__( - self, - name: str, - steps: list[FormulaStep], - metric_fetchers: dict[str, MetricFetcher[QuantityT]], - create_method: Callable[[float], QuantityT], - ) -> None: - """Create a `FormulaEngine` instance. - - Args: - name: A name for the formula. - steps: Steps for the engine to execute, in post-fix order. - metric_fetchers: Fetchers for each metric stream the formula depends on. - create_method: A method to generate the output `Sample` value with. If the - formula is for generating power values, this would be - `Power.from_watts`, for example. - """ - self._name = name - self._steps = steps - self._metric_fetchers: dict[str, MetricFetcher[QuantityT]] = metric_fetchers - self._first_run = True - self._create_method: Callable[[float], QuantityT] = create_method - - async def _synchronize_metric_timestamps( - self, metrics: set[asyncio.Task[Sample[QuantityT] | None]] - ) -> datetime: - """Synchronize the metric streams. - - For synchronised streams like data from the `ComponentMetricsResamplingActor`, - this a call to this function is required only once, before the first set of - inputs are fetched. - - Args: - metrics: The finished tasks from the first `fetch_next` calls to all the - `MetricFetcher`s. - - Returns: - The timestamp of the latest metric value. - - Raises: - RuntimeError: when some streams have no value, or when the synchronization - of timestamps fails. - """ - metrics_by_ts: dict[datetime, list[str]] = {} - for metric in metrics: - result = metric.result() - name = metric.get_name() - if result is None: - raise RuntimeError(f"Stream closed for component: {name}") - metrics_by_ts.setdefault(result.timestamp, []).append(name) - latest_ts = max(metrics_by_ts) - - # fetch the metrics with non-latest timestamps again until we have the values - # for the same ts for all metrics. - for metric_ts, names in metrics_by_ts.items(): - if metric_ts == latest_ts: - continue - while metric_ts < latest_ts: - for name in names: - fetcher = self._metric_fetchers[name] - next_val = await fetcher.fetch_next() - assert next_val is not None - metric_ts = next_val.timestamp - if metric_ts > latest_ts: - raise RuntimeError( - "Unable to synchronize resampled metric timestamps, " - f"for formula: {self._name}" - ) - self._first_run = False - return latest_ts - - async def apply(self) -> Sample[QuantityT]: - """Fetch the latest metrics, apply the formula once and return the result. - - Returns: - The result of the formula. - - Raises: - RuntimeError: if some samples didn't arrive, or if formula application - failed. - """ - eval_stack: list[float] = [] - ready_metrics, pending = await asyncio.wait( - [ - asyncio.create_task(fetcher.fetch_next(), name=name) - for name, fetcher in self._metric_fetchers.items() - ], - return_when=asyncio.ALL_COMPLETED, - ) - - if pending or any(res.result() is None for res in iter(ready_metrics)): - raise RuntimeError( - f"Some resampled metrics didn't arrive, for formula: {self._name}" - ) - - if self._first_run: - metric_ts = await self._synchronize_metric_timestamps(ready_metrics) - else: - sample = next(iter(ready_metrics)).result() - assert sample is not None - metric_ts = sample.timestamp - - for step in self._steps: - step.apply(eval_stack) - - # if all steps were applied and the formula was correct, there should only be a - # single value in the evaluation stack, and that would be the formula result. - if len(eval_stack) != 1: - raise RuntimeError(f"Formula application failed: {self._name}") - - res = eval_stack.pop() - if isnan(res) or isinf(res): - return Sample(metric_ts, None) - - return Sample(metric_ts, self._create_method(res)) diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_formatter.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_formatter.py deleted file mode 100644 index f5637b5c6..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_formatter.py +++ /dev/null @@ -1,265 +0,0 @@ -# License: MIT -# Copyright © 2023 Frequenz Energy-as-a-Service GmbH - -"""Formatter for the formula.""" - -from __future__ import annotations - -import enum - -from ._formula_steps import ( - Adder, - Clipper, - ConstantValue, - Divider, - FormulaStep, - Maximizer, - MetricFetcher, - Minimizer, - Multiplier, - OpenParen, - Subtractor, -) - - -class OperatorPrecedence(enum.Enum): - """The precedence of an operator.""" - - ADDITION = 1 - SUBTRACTION = 1 - MULTIPLICATION = 2 - DIVISION = 2 - PRIMARY = 9 - - def __lt__(self, other: OperatorPrecedence) -> bool: - """Test the precedence of this operator is less than the precedence of the other operator. - - Args: - other: The other operator (on the right-hand side). - - Returns: - Whether the precedence of this operator is less than the other operator. - """ - return self.value < other.value - - def __le__(self, other: OperatorPrecedence) -> bool: - """Test the precedence of this operator is less than or equal to the other operator. - - Args: - other: The other operator (on the right-hand side). - - Returns: - Whether the precedence of this operator is less than or equal to the other operator. - """ - return self.value <= other.value - - -class Operator(enum.Enum): - """The precedence of an operator.""" - - ADDITION = "+" - SUBTRACTION = "-" - MULTIPLICATION = "*" - DIVISION = "/" - - @property - def precedence(self) -> OperatorPrecedence: - """Return the precedence of this operator. - - Returns: - The precedence of this operator. - """ - match self: - case Operator.SUBTRACTION: - return OperatorPrecedence.SUBTRACTION - case Operator.ADDITION: - return OperatorPrecedence.ADDITION - case Operator.DIVISION: - return OperatorPrecedence.DIVISION - case Operator.MULTIPLICATION: - return OperatorPrecedence.MULTIPLICATION - - def __str__(self) -> str: - """Return the string representation of the operator precedence. - - Returns: - The string representation of the operator precedence. - """ - return str(self.value) - - -class StackItem: - """Stack item for the formula formatter.""" - - def __init__(self, value: str, precedence: OperatorPrecedence, num_steps: int): - """Initialize the StackItem. - - Args: - value: The value of the stack item. - precedence: The precedence of the stack item. - num_steps: The number of steps of the stack item. - """ - self.value = value - self.precedence = precedence - self.num_steps = num_steps - - def __str__(self) -> str: - """Return the string representation of the stack item. - - This is used for debugging purposes. - - Returns: - str: The string representation of the stack item. - """ - return f'("{self.value}", {self.precedence}, {self.num_steps})' - - def as_left_value(self, outer_precedence: OperatorPrecedence) -> str: - """Return the value of the stack item with parentheses if necessary. - - Args: - outer_precedence: The precedence of the outer stack item. - - Returns: - str: The value of the stack item with parentheses if necessary. - """ - return f"({self.value})" if self.precedence < outer_precedence else self.value - - def as_right_value(self, outer_precedence: OperatorPrecedence) -> str: - """Return the value of the stack item with parentheses if necessary. - - Args: - outer_precedence: The precedence of the outer stack item. - - Returns: - str: The value of the stack item with parentheses if necessary. - """ - if self.num_steps > 1: - return ( - f"({self.value})" if self.precedence <= outer_precedence else self.value - ) - return f"({self.value})" if self.precedence < outer_precedence else self.value - - @staticmethod - def create_binary(lhs: StackItem, operator: Operator, rhs: StackItem) -> StackItem: - """Create a binary stack item. - - Args: - lhs: The left-hand side of the binary operation. - operator: The operator of the binary operation. - rhs: The right-hand side of the binary operation. - - Returns: - StackItem: The binary stack item. - """ - pred = OperatorPrecedence(operator.precedence) - return StackItem( - f"{lhs.as_left_value(pred)} {operator} {rhs.as_right_value(pred)}", - pred, - lhs.num_steps + 1 + rhs.num_steps, - ) - - @staticmethod - def create_primary(value: float) -> StackItem: - """Create a stack item for literal values or function calls (primary expressions). - - Args: - value: The value of the literal. - - Returns: - StackItem: The literal stack item. - """ - return StackItem(str(value), OperatorPrecedence.PRIMARY, 1) - - -class FormulaFormatter: - """Formats a formula into a human readable string in infix-notation.""" - - def __init__(self) -> None: - """Initialize the FormulaFormatter.""" - self._stack = list[StackItem]() - - def format(self, postfix_expr: list[FormulaStep]) -> str: - """Format the postfix expression to infix notation. - - Args: - postfix_expr: The steps of the formula in postfix notation order. - - Returns: - str: The formula in infix notation. - """ - for step in postfix_expr: - match step: - case ConstantValue(): - self._stack.append(StackItem.create_primary(step.value)) - case Adder(): - self._format_binary(Operator.ADDITION) - case Subtractor(): - self._format_binary(Operator.SUBTRACTION) - case Multiplier(): - self._format_binary(Operator.MULTIPLICATION) - case Divider(): - self._format_binary(Operator.DIVISION) - case Clipper(): - the_value = self._stack.pop() - min_value = step.min_value if step.min_value is not None else "-inf" - max_value = step.max_value if step.max_value is not None else "inf" - value = f"clip({min_value}, {the_value.value}, {max_value})" - self._stack.append(StackItem(value, OperatorPrecedence.PRIMARY, 1)) - case Maximizer(): - left, right = self._pop_two_from_stack() - value = f"max({left.value}, {right.value})" - self._stack.append(StackItem(value, OperatorPrecedence.PRIMARY, 1)) - case Minimizer(): - left, right = self._pop_two_from_stack() - value = f"min({left.value}, {right.value})" - self._stack.append(StackItem(value, OperatorPrecedence.PRIMARY, 1)) - case MetricFetcher(): - metric_fetcher = step - value = str(metric_fetcher) - if engine_reference := getattr( - metric_fetcher.stream, "_engine_reference", None - ): - value = f"[{value}]({str(engine_reference)})" - self._stack.append(StackItem(value, OperatorPrecedence.PRIMARY, 1)) - case OpenParen(): - pass # We gently ignore this one. - - assert ( - len(self._stack) == 1 - ), f"The formula {postfix_expr} is not valid. Evaluation stack left-over: {self._stack}" - return self._stack[0].value - - def _format_binary(self, operator: Operator) -> None: - """Format a binary operation. - - Pops the arguments of the binary expression from the stack - and pushes the string representation of the binary operation to the stack. - - Args: - operator: The operator of the binary operation. - """ - left, right = self._pop_two_from_stack() - self._stack.append(StackItem.create_binary(left, operator, right)) - - def _pop_two_from_stack(self) -> tuple[StackItem, StackItem]: - """Pop two items from the stack. - - Returns: - The two items popped from the stack. - """ - right = self._stack.pop() - left = self._stack.pop() - return left, right - - -def format_formula(postfix_expr: list[FormulaStep]) -> str: - """Return the formula as a string in infix notation. - - Args: - postfix_expr: The steps of the formula in postfix notation order. - - Returns: - str: The formula in infix notation. - """ - formatter = FormulaFormatter() - return formatter.format(postfix_expr) diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/__init__.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/__init__.py deleted file mode 100644 index 3a402dee4..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/__init__.py +++ /dev/null @@ -1,52 +0,0 @@ -# License: MIT -# Copyright © 2022 Frequenz Energy-as-a-Service GmbH - -"""Generators for formulas from component graphs.""" - -from ._battery_power_formula import BatteryPowerFormula -from ._chp_power_formula import CHPPowerFormula -from ._consumer_power_formula import ConsumerPowerFormula -from ._ev_charger_current_formula import EVChargerCurrentFormula -from ._ev_charger_power_formula import EVChargerPowerFormula -from ._formula_generator import ( - ComponentNotFound, - FormulaGenerationError, - FormulaGenerator, - FormulaGeneratorConfig, -) -from ._grid_current_formula import GridCurrentFormula -from ._grid_power_3_phase_formula import GridPower3PhaseFormula -from ._grid_power_formula import GridPowerFormula -from ._grid_reactive_power_formula import GridReactivePowerFormula -from ._producer_power_formula import ProducerPowerFormula -from ._pv_power_formula import PVPowerFormula - -__all__ = [ - # - # Base class - # - "FormulaGenerator", - "FormulaGeneratorConfig", - # - # Power Formula generators - # - "CHPPowerFormula", - "ConsumerPowerFormula", - "GridPower3PhaseFormula", - "GridPowerFormula", - "GridReactivePowerFormula", - "BatteryPowerFormula", - "EVChargerPowerFormula", - "PVPowerFormula", - "ProducerPowerFormula", - # - # Current formula generators - # - "GridCurrentFormula", - "EVChargerCurrentFormula", - # - # Exceptions - # - "ComponentNotFound", - "FormulaGenerationError", -] diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_battery_power_formula.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_battery_power_formula.py deleted file mode 100644 index d806a73ee..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_battery_power_formula.py +++ /dev/null @@ -1,171 +0,0 @@ -# License: MIT -# Copyright © 2022 Frequenz Energy-as-a-Service GmbH - -"""Formula generator from component graph for Grid Power.""" - -import itertools -import logging - -from frequenz.client.microgrid.component import Component, Meter -from frequenz.client.microgrid.metrics import Metric -from frequenz.quantities import Power - -from ...._internal._graph_traversal import is_battery_inverter -from ....microgrid import connection_manager -from ...formula_engine import FormulaEngine -from ._fallback_formula_metric_fetcher import FallbackFormulaMetricFetcher -from ._formula_generator import ( - NON_EXISTING_COMPONENT_ID, - ComponentNotFound, - FormulaGenerationError, - FormulaGenerator, - FormulaGeneratorConfig, -) - -_logger = logging.getLogger(__name__) - - -class BatteryPowerFormula(FormulaGenerator[Power]): - """Creates a formula engine from the component graph for calculating battery power.""" - - def generate( - self, - ) -> FormulaEngine[Power]: - """Make a formula for the cumulative AC battery power of a microgrid. - - The calculation is performed by adding the Active Powers of all the inverters - that are attached to batteries. - - If there's no data coming from an inverter, that inverter's power will be - treated as 0. - - Returns: - A formula engine that will calculate cumulative battery power values. - - Raises: - ComponentNotFound: if there are no batteries in the component graph, or if - they don't have an inverter as a predecessor. - FormulaGenerationError: If a battery has a non-inverter predecessor - in the component graph, or if not all batteries behind a set of - inverters have been requested. - """ - builder = self._get_builder( - "battery-power", Metric.AC_ACTIVE_POWER, Power.from_watts - ) - - if not self._config.component_ids: - _logger.warning( - "No Battery component IDs specified. " - "Subscribing to the resampling actor with a non-existing " - "component id, so that `0` values are sent from the formula." - ) - # If there are no Batteries, we have to send 0 values as the same - # frequency as the other streams. So we subscribe with a non-existing - # component id, just to get a `None` message at the resampling interval. - builder.push_component_metric( - NON_EXISTING_COMPONENT_ID, nones_are_zeros=True - ) - return builder.build() - - component_ids = set(self._config.component_ids) - component_graph = connection_manager.get().component_graph - inv_bat_mapping: dict[Component, set[Component]] = {} - - for bat_id in component_ids: - inverters = set( - filter( - is_battery_inverter, - component_graph.predecessors(bat_id), - ) - ) - if len(inverters) == 0: - raise ComponentNotFound( - "All batteries must have at least one inverter as a predecessor." - f"Battery ID {bat_id} has no inverter as a predecessor.", - ) - - for inverter in inverters: - all_connected_batteries = set(component_graph.successors(inverter.id)) - battery_ids = set( - map(lambda battery: battery.id, all_connected_batteries) - ) - if not battery_ids.issubset(component_ids): - raise FormulaGenerationError( - f"Not all batteries behind {inverter} " - f"are requested. Missing: {battery_ids - component_ids}" - ) - - inv_bat_mapping[inverter] = all_connected_batteries - - if self._config.allow_fallback: - fallbacks = self._get_fallback_formulas(inv_bat_mapping) - - for idx, (primary_component, fallback_formula) in enumerate( - fallbacks.items() - ): - if idx > 0: - builder.push_oper("+") - - builder.push_component_metric( - primary_component.id, - nones_are_zeros=not isinstance(primary_component, Meter), - fallback=fallback_formula, - ) - else: - for idx, comp in enumerate(inv_bat_mapping.keys()): - if idx > 0: - builder.push_oper("+") - builder.push_component_metric(comp.id, nones_are_zeros=True) - - return builder.build() - - def _get_fallback_formulas( - self, inv_bat_mapping: dict[Component, set[Component]] - ) -> dict[Component, FallbackFormulaMetricFetcher[Power] | None]: - """Find primary and fallback components and create fallback formulas. - - The primary component is the one that will be used to calculate the battery power. - If it is not available, the fallback formula will be used instead. - Fallback formulas calculate the battery power using the fallback components. - Fallback formulas are wrapped in `FallbackFormulaMetricFetcher`. - - Args: - inv_bat_mapping: A mapping from inverter to connected batteries. - - Returns: - A dictionary mapping primary components to their FallbackFormulaMetricFetcher. - """ - fallbacks = self._get_metric_fallback_components(set(inv_bat_mapping.keys())) - - fallback_formulas: dict[ - Component, FallbackFormulaMetricFetcher[Power] | None - ] = {} - for primary_component, fallback_components in fallbacks.items(): - if len(fallback_components) == 0: - fallback_formulas[primary_component] = None - continue - - battery_ids = set( - map( - lambda battery: battery.id, - itertools.chain.from_iterable( - inv_bat_mapping[inv] for inv in fallback_components - ), - ) - ) - - generator = BatteryPowerFormula( - f"{self._namespace}_fallback_{battery_ids}", - self._channel_registry, - self._resampler_subscription_sender, - FormulaGeneratorConfig( - component_ids=battery_ids, - allow_fallback=False, - ), - ) - - fallback_formulas[primary_component] = FallbackFormulaMetricFetcher( - generator - ) - - return fallback_formulas diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_chp_power_formula.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_chp_power_formula.py deleted file mode 100644 index b91a4cb04..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_chp_power_formula.py +++ /dev/null @@ -1,97 +0,0 @@ -# License: MIT -# Copyright © 2023 Frequenz Energy-as-a-Service GmbH - -"""Formula generator from component graph for CHP Power.""" - - -import logging -from collections import abc - -from frequenz.client.common.microgrid.components import ComponentId -from frequenz.client.microgrid.component import Chp, Meter -from frequenz.client.microgrid.metrics import Metric -from frequenz.quantities import Power - -from ....microgrid import connection_manager -from ...formula_engine import FormulaEngine -from ._formula_generator import ( - NON_EXISTING_COMPONENT_ID, - FormulaGenerationError, - FormulaGenerator, -) - -_logger = logging.getLogger(__name__) - - -class CHPPowerFormula(FormulaGenerator[Power]): - """Formula generator for CHP Power.""" - - def generate( # noqa: DOC502 (FormulaGenerationError is raised indirectly by _get_chp_meters) - self, - ) -> FormulaEngine[Power]: - """Make a formula for the cumulative CHP power of a microgrid. - - The calculation is performed by adding the active power measurements from - dedicated meters attached to CHPs. - - Returns: - A formula engine that will calculate cumulative CHP power values. - - Raises: - FormulaGenerationError: If there's no dedicated meter attached to every CHP. - - """ - builder = self._get_builder( - "chp-power", Metric.AC_ACTIVE_POWER, Power.from_watts - ) - - chp_meter_ids = self._get_chp_meters() - if not chp_meter_ids: - _logger.warning("No CHPs found in the component graph.") - builder.push_component_metric( - NON_EXISTING_COMPONENT_ID, nones_are_zeros=True - ) - return builder.build() - - for idx, chp_meter_id in enumerate(chp_meter_ids): - if idx > 0: - builder.push_oper("+") - builder.push_component_metric(chp_meter_id, nones_are_zeros=False) - - return builder.build() - - def _get_chp_meters(self) -> abc.Set[ComponentId]: - """Get the meter IDs of the CHPs from the component graph. - - Returns: - A set of meter IDs of the CHPs in the component graph. If no CHPs are - found, None is returned. - - Raises: - FormulaGenerationError: If there's no dedicated meter attached to every CHP. - """ - component_graph = connection_manager.get().component_graph - chps = component_graph.components(matching_types=Chp) - - chp_meters: set[ComponentId] = set() - for chp in chps: - predecessors = component_graph.predecessors(chp.id) - if len(predecessors) != 1: - raise FormulaGenerationError( - f"CHP {chp.id} has {len(predecessors)} predecessors. " - " Expected exactly one." - ) - meter = next(iter(predecessors)) - if not isinstance(meter, Meter): - raise FormulaGenerationError( - f"CHP {chp.id} has a predecessor of category " - f"{meter.category}. Expected ComponentCategory.METER." - ) - meter_successors = component_graph.successors(meter.id) - if not all(successor in chps for successor in meter_successors): - raise FormulaGenerationError( - f"Meter {meter.id} connected to CHP {chp.id}" - "has non-chp successors." - ) - chp_meters.add(meter.id) - return chp_meters diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_consumer_power_formula.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_consumer_power_formula.py deleted file mode 100644 index d07b36edf..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_consumer_power_formula.py +++ /dev/null @@ -1,283 +0,0 @@ -# License: MIT -# Copyright © 2022 Frequenz Energy-as-a-Service GmbH - -"""Formula generator from component graph for Consumer Power.""" - -import logging - -from frequenz.client.microgrid.component import Component, Inverter, Meter -from frequenz.client.microgrid.metrics import Metric -from frequenz.quantities import Power - -from ...._internal._graph_traversal import ( - dfs, - is_battery_chain, - is_chp_chain, - is_ev_charger_chain, - is_pv_chain, -) -from ....microgrid import connection_manager -from .._formula_engine import FormulaEngine -from .._resampled_formula_builder import ResampledFormulaBuilder -from ._fallback_formula_metric_fetcher import FallbackFormulaMetricFetcher -from ._formula_generator import ( - NON_EXISTING_COMPONENT_ID, - ComponentNotFound, - FormulaGenerator, - FormulaGeneratorConfig, -) -from ._simple_formula import SimplePowerFormula - -_logger = logging.getLogger(__name__) - - -class ConsumerPowerFormula(FormulaGenerator[Power]): - """Formula generator from component graph for calculating the Consumer Power. - - The consumer power is calculated by summing up the power of all components that - are not part of a battery, CHP, PV or EV charger chain. - """ - - def _are_grid_meters(self, grid_successors: set[Component]) -> bool: - """Check if the grid successors are grid meters. - - Args: - grid_successors: The successors of the grid component. - - Returns: - True if the provided components are grid meters, False otherwise. - """ - component_graph = connection_manager.get().component_graph - return all( - isinstance(successor, Meter) - and not is_battery_chain(component_graph, successor) - and not is_chp_chain(component_graph, successor) - and not is_pv_chain(component_graph, successor) - and not is_ev_charger_chain(component_graph, successor) - for successor in grid_successors - ) - - # We need the noqa here because `RuntimeError` is raised indirectly - def generate(self) -> FormulaEngine[Power]: # noqa: DOC503 - """Generate formula for calculating consumer power from the component graph. - - Returns: - A formula engine that will calculate the consumer power. - - Raises: - ComponentNotFound: If the component graph does not contain a consumer power - component. - RuntimeError: If the grid component has a single successor that is not a - meter. - """ - builder = self._get_builder( - "consumer-power", Metric.AC_ACTIVE_POWER, Power.from_watts - ) - - grid_successors = self._get_grid_component_successors() - - if not grid_successors: - raise ComponentNotFound("No components found in the component graph.") - - if self._are_grid_meters(grid_successors): - return self._gen_with_grid_meter(builder, grid_successors) - - return self._gen_without_grid_meter(builder, self._get_grid_component()) - - def _gen_with_grid_meter( - self, - builder: ResampledFormulaBuilder[Power], - grid_meters: set[Component], - ) -> FormulaEngine[Power]: - """Generate formula for calculating consumer power with grid meter. - - Args: - builder: The formula engine builder. - grid_meters: The grid meter component. - - Returns: - A formula engine that will calculate the consumer power. - """ - assert grid_meters - component_graph = connection_manager.get().component_graph - - def non_consumer_component(component: Component) -> bool: - """ - Check if a component is not a consumer component. - - Args: - component: The component to check. - - Returns: - True if the component is not a consumer component, False otherwise. - """ - # If the component graph supports additional types of grid successors in the - # future, additional checks need to be added here. - return ( - is_battery_chain(component_graph, component) - or is_chp_chain(component_graph, component) - or is_pv_chain(component_graph, component) - or is_ev_charger_chain(component_graph, component) - ) - - # join all non consumer components reachable from the different grid meters - non_consumer_components: set[Component] = set() - for grid_meter in grid_meters: - non_consumer_components = non_consumer_components.union( - dfs(component_graph, grid_meter, set(), non_consumer_component) - ) - - # push all grid meters - for idx, grid_meter in enumerate(grid_meters): - if idx > 0: - builder.push_oper("+") - builder.push_component_metric(grid_meter.id, nones_are_zeros=False) - - if self._config.allow_fallback: - fallbacks = self._get_fallback_formulas(non_consumer_components) - - for idx, (primary_component, fallback_formula) in enumerate( - fallbacks.items() - ): - builder.push_oper("-") - - # should only be the case if the component is not a meter - builder.push_component_metric( - primary_component.id, - nones_are_zeros=not isinstance(primary_component, Meter), - fallback=fallback_formula, - ) - else: - # push all non consumer components and subtract them from the grid meters - for component in non_consumer_components: - builder.push_oper("-") - builder.push_component_metric( - component.id, - nones_are_zeros=not isinstance(component, Meter), - ) - - return builder.build() - - def _gen_without_grid_meter( - self, - builder: ResampledFormulaBuilder[Power], - grid: Component, - ) -> FormulaEngine[Power]: - """Generate formula for calculating consumer power without a grid meter. - - Args: - builder: The formula engine builder. - grid: The grid component. - - Returns: - A formula engine that will calculate the consumer power. - """ - - def consumer_component(component: Component) -> bool: - """ - Check if a component is a consumer component. - - Args: - component: The component to check. - - Returns: - True if the component is a consumer component, False otherwise. - """ - # If the component graph supports additional types of grid successors in the - # future, additional checks need to be added here. - return ( - isinstance(component, (Meter, Inverter)) - and not is_battery_chain(component_graph, component) - and not is_chp_chain(component_graph, component) - and not is_pv_chain(component_graph, component) - and not is_ev_charger_chain(component_graph, component) - ) - - component_graph = connection_manager.get().component_graph - consumer_components = dfs(component_graph, grid, set(), consumer_component) - - if not consumer_components: - _logger.warning( - "Unable to find any consumers in the component graph. " - "Subscribing to the resampling actor with a non-existing " - "component id, so that `0` values are sent from the formula." - ) - # If there are no consumer components, we have to send 0 values at the same - # frequency as the other streams. So we subscribe with a non-existing - # component id, just to get a `None` message at the resampling interval. - builder.push_component_metric( - NON_EXISTING_COMPONENT_ID, nones_are_zeros=True - ) - return builder.build() - - if self._config.allow_fallback: - fallbacks = self._get_fallback_formulas(consumer_components) - - for idx, (primary_component, fallback_formula) in enumerate( - fallbacks.items() - ): - if idx > 0: - builder.push_oper("+") - - # should only be the case if the component is not a meter - builder.push_component_metric( - primary_component.id, - nones_are_zeros=not isinstance(primary_component, Meter), - fallback=fallback_formula, - ) - else: - for idx, component in enumerate(consumer_components): - if idx > 0: - builder.push_oper("+") - - builder.push_component_metric( - component.id, - nones_are_zeros=not isinstance(component, Meter), - ) - - return builder.build() - - def _get_fallback_formulas( - self, components: set[Component] - ) -> dict[Component, FallbackFormulaMetricFetcher[Power] | None]: - """Find primary and fallback components and create fallback formulas. - - The primary component is the one that will be used to calculate the consumer power. - However, if it is not available, the fallback formula will be used instead. - Fallback formulas calculate the consumer power using the fallback components. - Fallback formulas are wrapped in `FallbackFormulaMetricFetcher` to allow - for lazy initialization. - - Args: - components: The producer components. - - Returns: - A dictionary mapping primary components to their FallbackFormulaMetricFetcher. - """ - fallbacks = self._get_metric_fallback_components(components) - - fallback_formulas: dict[ - Component, FallbackFormulaMetricFetcher[Power] | None - ] = {} - - for primary_component, fallback_components in fallbacks.items(): - if len(fallback_components) == 0: - fallback_formulas[primary_component] = None - continue - - fallback_ids = [c.id for c in fallback_components] - generator = SimplePowerFormula( - f"{self._namespace}_fallback_{fallback_ids}", - self._channel_registry, - self._resampler_subscription_sender, - FormulaGeneratorConfig( - component_ids=set(fallback_ids), - allow_fallback=False, - ), - ) - - fallback_formulas[primary_component] = FallbackFormulaMetricFetcher( - generator - ) - - return fallback_formulas diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_ev_charger_current_formula.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_ev_charger_current_formula.py deleted file mode 100644 index 3f08be43a..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_ev_charger_current_formula.py +++ /dev/null @@ -1,77 +0,0 @@ -# License: MIT -# Copyright © 2022 Frequenz Energy-as-a-Service GmbH - -"""Formula generator from component graph for 3-phase Grid Current.""" - - -import logging -from collections import abc - -from frequenz.client.common.microgrid.components import ComponentId -from frequenz.client.microgrid.metrics import Metric -from frequenz.quantities import Current - -from .._formula_engine import FormulaEngine, FormulaEngine3Phase -from ._formula_generator import NON_EXISTING_COMPONENT_ID, FormulaGenerator - -_logger = logging.getLogger(__name__) - - -class EVChargerCurrentFormula(FormulaGenerator[Current]): - """Create a formula engine from the component graph for calculating grid current.""" - - def generate(self) -> FormulaEngine3Phase[Current]: - """Generate a formula for calculating total EV current for given component ids. - - Returns: - A formula engine that calculates total 3-phase EV Charger current values. - """ - component_ids = self._config.component_ids - - if not component_ids: - _logger.warning( - "No EV Charger component IDs specified. " - "Subscribing to the resampling actor with a non-existing " - "component id, so that `0` values are sent from the formula." - ) - # If there are no EV Chargers, we have to send 0 values as the same - # frequency as the other streams. So we subscribe with a non-existing - # component id, just to get a `None` message at the resampling interval. - builder = self._get_builder( - "ev-current", Metric.AC_ACTIVE_POWER, Current.from_amperes - ) - builder.push_component_metric( - NON_EXISTING_COMPONENT_ID, nones_are_zeros=True - ) - engine = builder.build() - return FormulaEngine3Phase( - "ev-current", - Current.from_amperes, - (engine, engine, engine), - ) - - return FormulaEngine3Phase( - "ev-current", - Current.from_amperes, - ( - (self._gen_phase_formula(component_ids, Metric.AC_CURRENT_PHASE_1)), - (self._gen_phase_formula(component_ids, Metric.AC_CURRENT_PHASE_2)), - (self._gen_phase_formula(component_ids, Metric.AC_CURRENT_PHASE_3)), - ), - ) - - def _gen_phase_formula( - self, - component_ids: abc.Set[ComponentId], - metric: Metric, - ) -> FormulaEngine[Current]: - builder = self._get_builder("ev-current", metric, Current.from_amperes) - - # generate a formula that just adds values from all EV Chargers. - for idx, component_id in enumerate(component_ids): - if idx > 0: - builder.push_oper("+") - - builder.push_component_metric(component_id, nones_are_zeros=True) - - return builder.build() diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_ev_charger_power_formula.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_ev_charger_power_formula.py deleted file mode 100644 index 2290457c0..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_ev_charger_power_formula.py +++ /dev/null @@ -1,50 +0,0 @@ -# License: MIT -# Copyright © 2023 Frequenz Energy-as-a-Service GmbH - -"""Formula generator from component graph for Grid Power.""" - -import logging - -from frequenz.client.microgrid.metrics import Metric -from frequenz.quantities import Power - -from .._formula_engine import FormulaEngine -from ._formula_generator import NON_EXISTING_COMPONENT_ID, FormulaGenerator - -_logger = logging.getLogger(__name__) - - -class EVChargerPowerFormula(FormulaGenerator[Power]): - """Create a formula engine from the component graph for calculating grid power.""" - - def generate(self) -> FormulaEngine[Power]: - """Generate a formula for calculating total EV power for given component ids. - - Returns: - A formula engine that calculates total EV Charger power values. - """ - builder = self._get_builder( - "ev-power", Metric.AC_ACTIVE_POWER, Power.from_watts - ) - - component_ids = self._config.component_ids - if not component_ids: - _logger.warning( - "No EV Charger component IDs specified. " - "Subscribing to the resampling actor with a non-existing " - "component id, so that `0` values are sent from the formula." - ) - # If there are no EV Chargers, we have to send 0 values as the same - # frequency as the other streams. So we subscribe with a non-existing - # component id, just to get a `None` message at the resampling interval. - builder.push_component_metric( - NON_EXISTING_COMPONENT_ID, nones_are_zeros=True - ) - return builder.build() - - for idx, component_id in enumerate(component_ids): - if idx > 0: - builder.push_oper("+") - builder.push_component_metric(component_id, nones_are_zeros=True) - - return builder.build() diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_fallback_formula_metric_fetcher.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_fallback_formula_metric_fetcher.py deleted file mode 100644 index f438ff7cc..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_fallback_formula_metric_fetcher.py +++ /dev/null @@ -1,89 +0,0 @@ -# License: MIT -# Copyright © 2024 Frequenz Energy-as-a-Service GmbH - -"""FallbackMetricFetcher implementation that uses formula generator.""" - -from frequenz.channels import Receiver - -from ..._base_types import QuantityT, Sample -from .. import FormulaEngine -from .._formula_steps import FallbackMetricFetcher -from ._formula_generator import FormulaGenerator - - -# This is done as a separate module to avoid circular imports. -class FallbackFormulaMetricFetcher(FallbackMetricFetcher[QuantityT]): - """A metric fetcher that uses a formula generator. - - The formula engine is generated lazily, meaning it is created only when - the `start` or `fetch_next` method is called for the first time. - Once the formula engine is initialized, it subscribes to its components - and begins calculating and sending the formula results. - """ - - def __init__(self, formula_generator: FormulaGenerator[QuantityT]): - """Create a `FallbackFormulaMetricFetcher` instance. - - Args: - formula_generator: A formula generator that generates - a formula engine with fallback components. - """ - super().__init__() - self._name = formula_generator.namespace - self._formula_generator: FormulaGenerator[QuantityT] = formula_generator - self._formula_engine: FormulaEngine[QuantityT] | None = None - self._receiver: Receiver[Sample[QuantityT]] | None = None - - @property - def name(self) -> str: - """Get the name of the fetcher.""" - return self._name - - @property - def is_running(self) -> bool: - """Check whether the formula engine is running.""" - return self._receiver is not None - - def start(self) -> None: - """Initialize the formula engine and start fetching samples.""" - engine = self._formula_generator.generate() - # We need this assert because generate() can return a FormulaEngine - # or FormulaEngine3Phase, but in this case we know it will return a - # FormulaEngine. This helps to silence `mypy` and also to verify our - # assumptions are still true at runtime - assert isinstance(engine, FormulaEngine) - self._formula_engine = engine - self._receiver = self._formula_engine.new_receiver() - - async def ready(self) -> bool: - """Wait until the receiver is ready with a message or an error. - - Once a call to `ready()` has finished, the message should be read with - a call to `consume()` (`receive()` or iterated over). - - Returns: - Whether the receiver is still active. - """ - if self._receiver is None: - self.start() - - assert self._receiver is not None - return await self._receiver.ready() - - def consume(self) -> Sample[QuantityT]: - """Return the latest message once `ready()` is complete.""" - assert ( - self._receiver is not None - ), f"Fallback metric fetcher: {self.name} was not started" - - return self._receiver.consume() - - async def stop(self) -> None: - """Stop fallback metric fetcher, by closing the connection.""" - if self._formula_engine is not None: - await self._formula_engine.stop() - self._formula_engine = None - - if self._receiver is not None: - self._receiver.close() - self._receiver = None diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_formula_generator.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_formula_generator.py deleted file mode 100644 index 04d9c2512..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_formula_generator.py +++ /dev/null @@ -1,260 +0,0 @@ -# License: MIT -# Copyright © 2022 Frequenz Energy-as-a-Service GmbH - -"""Base class for formula generators that use the component graphs.""" - -from __future__ import annotations - -import sys -from abc import ABC, abstractmethod -from collections import abc -from collections.abc import Callable -from dataclasses import dataclass -from typing import Generic - -from frequenz.channels import Sender -from frequenz.client.common.microgrid.components import ComponentId -from frequenz.client.microgrid.component import Component, GridConnectionPoint, Meter -from frequenz.client.microgrid.metrics import Metric - -from ...._internal._channels import ChannelRegistry -from ...._internal._graph_traversal import ( - is_battery_inverter, - is_chp, - is_ev_charger, - is_pv_inverter, -) -from ....microgrid import connection_manager -from ....microgrid._data_sourcing import ComponentMetricRequest -from ..._base_types import QuantityT -from .._formula_engine import FormulaEngine, FormulaEngine3Phase -from .._resampled_formula_builder import ResampledFormulaBuilder - - -class FormulaGenerationError(Exception): - """An error encountered during formula generation from the component graph.""" - - -class ComponentNotFound(FormulaGenerationError): - """Indicates that a component required for generating a formula is not found.""" - - -NON_EXISTING_COMPONENT_ID = ComponentId(sys.maxsize) -"""The component ID for non-existent components in the components graph. - -The non-existing component ID is commonly used in scenarios where a formula -engine requires a component ID but there are no available components in the -graph to associate with it. Thus, the non-existing component ID is subscribed -instead so that the formula engine can send `None` or `0` values at the same -frequency as the other streams. -""" - - -@dataclass(frozen=True) -class FormulaGeneratorConfig: - """Config for formula generators.""" - - component_ids: abc.Set[ComponentId] | None = None - """The component IDs to use for generating the formula.""" - - allow_fallback: bool = True - - -class FormulaGenerator(ABC, Generic[QuantityT]): - """A class for generating formulas from the component graph.""" - - def __init__( - self, - namespace: str, - channel_registry: ChannelRegistry, - resampler_subscription_sender: Sender[ComponentMetricRequest], - config: FormulaGeneratorConfig, - ) -> None: - """Create a `FormulaGenerator` instance. - - Args: - namespace: A namespace to use with the data-pipeline. - channel_registry: A channel registry instance shared with the resampling - actor. - resampler_subscription_sender: A sender for sending metric requests to the - resampling actor. - config: configs for the formula generator. - """ - self._channel_registry: ChannelRegistry = channel_registry - self._resampler_subscription_sender: Sender[ComponentMetricRequest] = ( - resampler_subscription_sender - ) - self._namespace: str = namespace - self._config: FormulaGeneratorConfig = config - - @property - def namespace(self) -> str: - """Get the namespace for the formula generator.""" - return self._namespace - - def _get_builder( - self, - name: str, - metric: Metric, - create_method: Callable[[float], QuantityT], - ) -> ResampledFormulaBuilder[QuantityT]: - builder = ResampledFormulaBuilder( - namespace=self._namespace, - formula_name=name, - channel_registry=self._channel_registry, - resampler_subscription_sender=self._resampler_subscription_sender, - metric=metric, - create_method=create_method, - ) - return builder - - def _get_grid_component(self) -> Component: - """ - Get the grid component in the component graph. - - Returns: - The first grid component found in the graph. - - Raises: - ComponentNotFound: If the grid component is not found in the component graph. - """ - component_graph = connection_manager.get().component_graph - grid_component = next( - iter(component_graph.components(matching_types=GridConnectionPoint)), - None, - ) - if grid_component is None: - raise ComponentNotFound("Grid component not found in the component graph.") - - return grid_component - - def _get_grid_component_successors(self) -> set[Component]: - """Get the set of grid component successors in the component graph. - - Returns: - A set of grid component successors. - - Raises: - ComponentNotFound: If no successor components are found in the component graph. - """ - grid_component = self._get_grid_component() - component_graph = connection_manager.get().component_graph - grid_successors = component_graph.successors(grid_component.id) - - if not grid_successors: - raise ComponentNotFound("No components found in the component graph.") - - return set(grid_successors) - - @abstractmethod - def generate( - self, - ) -> FormulaEngine[QuantityT] | FormulaEngine3Phase[QuantityT]: - """Generate a formula engine, based on the component graph.""" - - def _get_metric_fallback_components( - self, components: set[Component] - ) -> dict[Component, set[Component]]: - """Get primary and fallback components within a given set of components. - - When a meter is positioned before one or more components of the same type (e.g., inverters), - it is considered the primary component, and the components that follow are treated - as fallback components. - If the non-meter component has no meter in front of it, then it is the primary component - and has no fallbacks. - - The method iterates through the provided components and assesses their roles as primary - or fallback components. - If a component: - * can act as a primary component (e.g., a meter), then it finds its - fallback components and pairs them together. - * can act as a fallback (e.g., an inverter or EV charger), then it finds - the primary component for it (usually a meter) and pairs them together. - * has no fallback (e.g., an inverter that has no meter attached), then it - returns an empty set for that component. This means that the component - is a primary component and has no fallbacks. - - Args: - components: The components to be analyzed. - - Returns: - A dictionary where: - * The keys are primary components. - * The values are sets of fallback components. - """ - graph = connection_manager.get().component_graph - fallbacks: dict[Component, set[Component]] = {} - - for component in components: - if isinstance(component, Meter): - fallbacks[component] = self._get_meter_fallback_components(component) - else: - predecessors = set(graph.predecessors(component.id)) - if len(predecessors) == 1: - predecessor = predecessors.pop() - if self._is_primary_fallback_pair(predecessor, component): - # predecessor is primary component and the component is one of the - # fallbacks components. - fallbacks.setdefault(predecessor, set()).add(component) - continue - - # This component is primary component with no fallbacks. - fallbacks[component] = set() - return fallbacks - - def _get_meter_fallback_components(self, meter: Component) -> set[Component]: - """Get the fallback components for a given meter. - - Args: - meter: The meter to find the fallback components for. - - Returns: - A set of fallback components for the given meter. - An empty set is returned if the meter has no fallbacks. - """ - assert isinstance(meter, Meter) - - graph = connection_manager.get().component_graph - successors = graph.successors(meter.id) - - # All fallbacks has to be of the same type and category. - if ( - all(is_pv_inverter(c) for c in successors) - or all(is_battery_inverter(c) for c in successors) - or all(is_ev_charger(c) for c in successors) - ): - return set(successors) - return set() - - def _is_primary_fallback_pair( - self, - primary_candidate: Component, - fallback_candidate: Component, - ) -> bool: - """Determine if a given component can act as a primary-fallback pair. - - This method checks: - * whether the `fallback_candidate` is of a type that can have the `primary_candidate`, - * if `primary_candidate` is the primary measuring point of the `fallback_candidate`. - - Args: - primary_candidate: The component to be checked as a primary measuring device. - fallback_candidate: The component to be checked as a fallback measuring device. - - Returns: - bool: True if the provided components are a primary-fallback pair, False otherwise. - """ - graph = connection_manager.get().component_graph - - # reassign to decrease the length of the line and make code readable - fallback = fallback_candidate - primary = primary_candidate - - # fmt: off - return ( - is_pv_inverter(fallback) and graph.is_pv_meter(primary.id) - or is_chp(fallback) and graph.is_chp_meter(primary.id) - or is_ev_charger(fallback) and graph.is_ev_charger_meter(primary.id) - or is_battery_inverter(fallback) and graph.is_battery_meter(primary.id) - ) - # fmt: on diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_current_formula.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_current_formula.py deleted file mode 100644 index c990f349d..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_current_formula.py +++ /dev/null @@ -1,70 +0,0 @@ -# License: MIT -# Copyright © 2022 Frequenz Energy-as-a-Service GmbH - -"""Formula generator from component graph for 3-phase Grid Current.""" - -from frequenz.client.microgrid.component import Component, EvCharger, Inverter, Meter -from frequenz.client.microgrid.metrics import Metric -from frequenz.quantities import Current - -from .._formula_engine import FormulaEngine, FormulaEngine3Phase -from ._formula_generator import FormulaGenerator - - -class GridCurrentFormula(FormulaGenerator[Current]): - """Create a formula engine from the component graph for calculating grid current.""" - - def generate( # noqa: DOC502 - # ComponentNotFound is raised indirectly by _get_grid_component_successors - self, - ) -> FormulaEngine3Phase[Current]: - """Generate a formula for calculating grid current from the component graph. - - Returns: - A formula engine that will calculate 3-phase grid current values. - - Raises: - ComponentNotFound: when the component graph doesn't have a `GRID` component. - """ - grid_successors = self._get_grid_component_successors() - - return FormulaEngine3Phase( - "grid-current", - Current.from_amperes, - ( - self._gen_phase_formula(grid_successors, Metric.AC_CURRENT_PHASE_1), - self._gen_phase_formula(grid_successors, Metric.AC_CURRENT_PHASE_2), - self._gen_phase_formula(grid_successors, Metric.AC_CURRENT_PHASE_3), - ), - ) - - def _gen_phase_formula( - self, - grid_successors: set[Component], - metric: Metric, - ) -> FormulaEngine[Current]: - builder = self._get_builder("grid-current", metric, Current.from_amperes) - - # generate a formula that just adds values from all components that are - # directly connected to the grid. - for idx, comp in enumerate(grid_successors): - # When inverters or ev chargers produce `None` samples, those - # inverters are excluded from the calculation by treating their - # `None` values as `0`s. - # - # This is not possible for Meters, so when they produce `None` - # values, those values get propagated as the output. - match comp: - case Inverter() | EvCharger(): - nones_are_zeros = True - case Meter(): - nones_are_zeros = False - case _: - continue - - if idx > 0: - builder.push_oper("+") - - builder.push_component_metric(comp.id, nones_are_zeros=nones_are_zeros) - - return builder.build() diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_power_3_phase_formula.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_power_3_phase_formula.py deleted file mode 100644 index 14dcb4452..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_power_3_phase_formula.py +++ /dev/null @@ -1,90 +0,0 @@ -# License: MIT -# Copyright © 2024 Frequenz Energy-as-a-Service GmbH - -"""Formula generator from component graph for 3-phase Grid Power.""" - -from frequenz.client.microgrid.component import Component, EvCharger, Inverter, Meter -from frequenz.client.microgrid.metrics import Metric -from frequenz.quantities import Power - -from .._formula_engine import FormulaEngine, FormulaEngine3Phase -from ._formula_generator import FormulaGenerator - - -class GridPower3PhaseFormula(FormulaGenerator[Power]): - """Create a formula engine for calculating the grid 3-phase power.""" - - def generate( # noqa: DOC502 - # ComponentNotFound is raised indirectly by _get_grid_component_successors - self, - ) -> FormulaEngine3Phase[Power]: - """Generate a formula for calculating grid 3-phase power. - - Raises: - ComponentNotFound: when the component graph doesn't have a `GRID` component. - - Returns: - A formula engine that will calculate grid 3-phase power values. - """ - grid_successors = self._get_grid_component_successors() - - return FormulaEngine3Phase( - "grid-power-3-phase", - Power.from_watts, - ( - self._gen_phase_formula( - grid_successors, Metric.AC_ACTIVE_POWER_PHASE_1 - ), - self._gen_phase_formula( - grid_successors, Metric.AC_ACTIVE_POWER_PHASE_2 - ), - self._gen_phase_formula( - grid_successors, Metric.AC_ACTIVE_POWER_PHASE_3 - ), - ), - ) - - def _gen_phase_formula( - self, - grid_successors: set[Component], - metric: Metric, - ) -> FormulaEngine[Power]: - """Generate a formula for calculating grid 3-phase power from the component graph. - - Generate a formula that adds values from all components that are directly - connected to the grid. - - Args: - grid_successors: The set of components that are directly connected to the grid. - metric: The metric to use for the formula. - - Returns: - A formula engine that will calculate grid 3-phase power values. - """ - formula_builder = self._get_builder( - "grid-power-3-phase", metric, Power.from_watts - ) - - for idx, comp in enumerate(grid_successors): - # When inverters or EV chargers produce `None` samples, they are - # excluded from the calculation by treating their `None` values - # as `0`s. - # - # This is not possible for Meters, so when they produce `None` - # values, those values get propagated as the output. - match comp: - case Inverter() | EvCharger(): - nones_are_zeros = True - case Meter(): - nones_are_zeros = False - case _: - continue - - if idx > 0: - formula_builder.push_oper("+") - - formula_builder.push_component_metric( - comp.id, nones_are_zeros=nones_are_zeros - ) - - return formula_builder.build() diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_power_formula.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_power_formula.py deleted file mode 100644 index 470309b5d..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_power_formula.py +++ /dev/null @@ -1,82 +0,0 @@ -# License: MIT -# Copyright © 2022 Frequenz Energy-as-a-Service GmbH - -"""Formula generator from component graph for Grid Power.""" - - -from frequenz.client.microgrid.component import Component -from frequenz.client.microgrid.metrics import Metric -from frequenz.quantities import Power - -from .._formula_engine import FormulaEngine -from ._fallback_formula_metric_fetcher import FallbackFormulaMetricFetcher -from ._formula_generator import FormulaGeneratorConfig -from ._grid_power_formula_base import GridPowerFormulaBase -from ._simple_formula import SimplePowerFormula - - -class GridPowerFormula(GridPowerFormulaBase[Power]): - """Creates a formula engine from the component graph for calculating grid power.""" - - def generate( # noqa: DOC502 - # * ComponentNotFound is raised indirectly by _get_grid_component_successors - self, - ) -> FormulaEngine[Power]: - """Generate a formula for calculating grid power from the component graph. - - Returns: - A formula engine that will calculate grid power values. - - Raises: - ComponentNotFound: when the component graph doesn't have a `GRID` component. - """ - builder = self._get_builder( - "grid-power", - Metric.AC_ACTIVE_POWER, - Power.from_watts, - ) - return self._generate(builder) - - def _get_fallback_formulas( - self, components: set[Component] - ) -> dict[Component, FallbackFormulaMetricFetcher[Power] | None]: - """Find primary and fallback components and create fallback formulas. - - The primary component is the one that will be used to calculate the grid power. - If it is not available, the fallback formula will be used instead. - Fallback formulas calculate the grid power using the fallback components. - Fallback formulas are wrapped in `FallbackFormulaMetricFetcher`. - - Args: - components: The producer components. - - Returns: - A dictionary mapping primary components to their FallbackFormulaMetricFetcher. - """ - fallbacks = self._get_metric_fallback_components(components) - - fallback_formulas: dict[ - Component, FallbackFormulaMetricFetcher[Power] | None - ] = {} - - for primary_component, fallback_components in fallbacks.items(): - if len(fallback_components) == 0: - fallback_formulas[primary_component] = None - continue - - fallback_ids = [c.id for c in fallback_components] - generator = SimplePowerFormula( - f"{self._namespace}_fallback_{fallback_ids}", - self._channel_registry, - self._resampler_subscription_sender, - FormulaGeneratorConfig( - component_ids=set(fallback_ids), - allow_fallback=False, - ), - ) - - fallback_formulas[primary_component] = FallbackFormulaMetricFetcher( - generator - ) - - return fallback_formulas diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_power_formula_base.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_power_formula_base.py deleted file mode 100644 index 76b537829..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_power_formula_base.py +++ /dev/null @@ -1,97 +0,0 @@ -# License: MIT -# Copyright © 2024 Frequenz Energy-as-a-Service GmbH - -"""Base formula generator from component graph for Grid Power.""" - -from abc import ABC, abstractmethod - -from frequenz.client.microgrid.component import Component, EvCharger, Inverter, Meter - -from ..._base_types import QuantityT -from .._formula_engine import FormulaEngine -from .._resampled_formula_builder import ResampledFormulaBuilder -from ._fallback_formula_metric_fetcher import FallbackFormulaMetricFetcher -from ._formula_generator import ComponentNotFound, FormulaGenerator - - -class GridPowerFormulaBase(FormulaGenerator[QuantityT], ABC): - """Base class for grid power formula generators.""" - - def _generate( - self, builder: ResampledFormulaBuilder[QuantityT] - ) -> FormulaEngine[QuantityT]: - """Generate a formula for calculating grid power from the component graph. - - Args: - builder: The builder to use to create the formula. - - Returns: - A formula engine that will calculate grid power values. - - Raises: - ComponentNotFound: when the component graph doesn't have a `GRID` component. - """ - grid_successors = self._get_grid_component_successors() - - components: set[Component] = { - c for c in grid_successors if isinstance(c, (Inverter, EvCharger, Meter)) - } - - if not components: - raise ComponentNotFound("No grid successors found") - - # generate a formula that just adds values from all components that are - # directly connected to the grid. If the requested formula type is - # `PASSIVE_SIGN_CONVENTION`, there is nothing more to do. If the requested - # formula type is `PRODUCTION`, the formula output is negated, then clipped to - # 0. If the requested formula type is `CONSUMPTION`, the formula output is - # already positive, so it is just clipped to 0. - # - # So the formulas would look like: - # - `PASSIVE_SIGN_CONVENTION`: `(grid-successor-1 + grid-successor-2 + ...)` - # - `PRODUCTION`: `max(0, -(grid-successor-1 + grid-successor-2 + ...))` - # - `CONSUMPTION`: `max(0, (grid-successor-1 + grid-successor-2 + ...))` - if self._config.allow_fallback: - fallbacks = self._get_fallback_formulas(components) - - for idx, (primary_component, fallback_formula) in enumerate( - fallbacks.items() - ): - if idx > 0: - builder.push_oper("+") - - # should only be the case if the component is not a meter - builder.push_component_metric( - primary_component.id, - nones_are_zeros=not isinstance(primary_component, Meter), - fallback=fallback_formula, - ) - else: - for idx, comp in enumerate(components): - if idx > 0: - builder.push_oper("+") - - builder.push_component_metric( - comp.id, - nones_are_zeros=not isinstance(comp, Meter), - ) - - return builder.build() - - @abstractmethod - def _get_fallback_formulas( - self, components: set[Component] - ) -> dict[Component, FallbackFormulaMetricFetcher[QuantityT] | None]: - """Find primary and fallback components and create fallback formulas. - - The primary component is the one that will be used to calculate the producer power. - If it is not available, the fallback formula will be used instead. - Fallback formulas calculate the grid power using the fallback components. - Fallback formulas are wrapped in `FallbackFormulaMetricFetcher`. - - Args: - components: The producer components. - - Returns: - A dictionary mapping primary components to their FallbackFormulaMetricFetcher. - """ diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_reactive_power_formula.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_reactive_power_formula.py deleted file mode 100644 index 21de43622..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_reactive_power_formula.py +++ /dev/null @@ -1,82 +0,0 @@ -# License: MIT -# Copyright © 2024 Frequenz Energy-as-a-Service GmbH - -"""Formula generator from component graph for Grid Reactive Power.""" - - -from frequenz.client.microgrid.component import Component -from frequenz.client.microgrid.metrics import Metric -from frequenz.quantities import ReactivePower - -from .._formula_engine import FormulaEngine -from ._fallback_formula_metric_fetcher import FallbackFormulaMetricFetcher -from ._formula_generator import FormulaGeneratorConfig -from ._grid_power_formula_base import GridPowerFormulaBase -from ._simple_formula import SimpleReactivePowerFormula - - -class GridReactivePowerFormula(GridPowerFormulaBase[ReactivePower]): - """Creates a formula engine from the component graph for calculating grid reactive power.""" - - def generate( # noqa: DOC502 - # * ComponentNotFound is raised indirectly by _get_grid_component_successors - self, - ) -> FormulaEngine[ReactivePower]: - """Generate a formula for calculating grid reactive power from the component graph. - - Returns: - A formula engine that will calculate grid reactive power values. - - Raises: - ComponentNotFound: when the component graph doesn't have a `GRID` component. - """ - builder = self._get_builder( - "grid_reactive_power_formula", - Metric.AC_REACTIVE_POWER, - ReactivePower.from_volt_amperes_reactive, - ) - return self._generate(builder) - - def _get_fallback_formulas( - self, components: set[Component] - ) -> dict[Component, FallbackFormulaMetricFetcher[ReactivePower] | None]: - """Find primary and fallback components and create fallback formulas. - - The primary component is the one that will be used to calculate the grid reactive power. - If it is not available, the fallback formula will be used instead. - Fallback formulas calculate the grid power using the fallback components. - Fallback formulas are wrapped in `FallbackFormulaMetricFetcher`. - - Args: - components: The producer components. - - Returns: - A dictionary mapping primary components to their FallbackFormulaMetricFetcher. - """ - fallbacks = self._get_metric_fallback_components(components) - - fallback_formulas: dict[ - Component, FallbackFormulaMetricFetcher[ReactivePower] | None - ] = {} - - for primary_component, fallback_components in fallbacks.items(): - if len(fallback_components) == 0: - fallback_formulas[primary_component] = None - continue - - fallback_ids = [c.id for c in fallback_components] - generator = SimpleReactivePowerFormula( - f"{self._namespace}_fallback_{fallback_ids}", - self._channel_registry, - self._resampler_subscription_sender, - FormulaGeneratorConfig( - component_ids=set(fallback_ids), - allow_fallback=False, - ), - ) - - fallback_formulas[primary_component] = FallbackFormulaMetricFetcher( - generator - ) - - return fallback_formulas diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_producer_power_formula.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_producer_power_formula.py deleted file mode 100644 index 7c79cc5b3..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_producer_power_formula.py +++ /dev/null @@ -1,151 +0,0 @@ -# License: MIT -# Copyright © 2022 Frequenz Energy-as-a-Service GmbH - -"""Formula generator from component graph for Producer Power.""" - -import logging -from typing import Callable - -from frequenz.client.microgrid.component import Component, Meter -from frequenz.client.microgrid.metrics import Metric -from frequenz.quantities import Power - -from ...._internal._graph_traversal import dfs, is_chp_chain, is_pv_chain -from ....microgrid import connection_manager -from .._formula_engine import FormulaEngine -from ._fallback_formula_metric_fetcher import FallbackFormulaMetricFetcher -from ._formula_generator import ( - NON_EXISTING_COMPONENT_ID, - FormulaGenerator, - FormulaGeneratorConfig, -) -from ._simple_formula import SimplePowerFormula - -_logger = logging.getLogger(__name__) - - -class ProducerPowerFormula(FormulaGenerator[Power]): - """Formula generator from component graph for calculating the Producer Power. - - The producer power is calculated by summing up the power of all power producers, - which are CHP and PV. - """ - - def generate( # noqa: DOC502 - # * ComponentNotFound is raised indirectly by _get_grid_component() - # * RuntimeError is raised indirectly by connection_manager.get() - self, - ) -> FormulaEngine[Power]: - """Generate formula for calculating producer power from the component graph. - - Returns: - A formula engine that will calculate the producer power. - - Raises: - ComponentNotFound: If the component graph does not contain a producer power - component. - RuntimeError: If the grid component has a single successor that is not a - meter. - """ - builder = self._get_builder( - "producer_power", Metric.AC_ACTIVE_POWER, Power.from_watts - ) - - component_graph = connection_manager.get().component_graph - # if in the future we support additional producers, we need to add them to the lambda - producer_components = dfs( - component_graph, - self._get_grid_component(), - set(), - lambda component: is_pv_chain(component_graph, component) - or is_chp_chain(component_graph, component), - ) - - if not producer_components: - _logger.warning( - "Unable to find any producer components in the component graph. " - "Subscribing to the resampling actor with a non-existing " - "component id, so that `0` values are sent from the formula." - ) - # If there are no producer components, we have to send 0 values at the same - # frequency as the other streams. So we subscribe with a non-existing - # component id, just to get a `None` message at the resampling interval. - builder.push_component_metric( - NON_EXISTING_COMPONENT_ID, nones_are_zeros=True - ) - return builder.build() - - is_not_meter: Callable[[Component], bool] = lambda component: not isinstance( - component, Meter - ) - - if self._config.allow_fallback: - fallbacks = self._get_fallback_formulas(producer_components) - - for idx, (primary_component, fallback_formula) in enumerate( - fallbacks.items() - ): - if idx > 0: - builder.push_oper("+") - - # should only be the case if the component is not a meter - builder.push_component_metric( - primary_component.id, - nones_are_zeros=is_not_meter(primary_component), - fallback=fallback_formula, - ) - else: - for idx, component in enumerate(producer_components): - if idx > 0: - builder.push_oper("+") - - builder.push_component_metric( - component.id, - nones_are_zeros=is_not_meter(component), - ) - - return builder.build() - - def _get_fallback_formulas( - self, components: set[Component] - ) -> dict[Component, FallbackFormulaMetricFetcher[Power] | None]: - """Find primary and fallback components and create fallback formulas. - - The primary component is the one that will be used to calculate the producer power. - However, if it is not available, the fallback formula will be used instead. - Fallback formulas calculate the producer power using the fallback components. - Fallback formulas are wrapped in `FallbackFormulaMetricFetcher`. - - Args: - components: The producer components. - - Returns: - A dictionary mapping primary components to their FallbackFormulaMetricFetcher. - """ - fallbacks = self._get_metric_fallback_components(components) - - fallback_formulas: dict[ - Component, FallbackFormulaMetricFetcher[Power] | None - ] = {} - - for primary_component, fallback_components in fallbacks.items(): - if len(fallback_components) == 0: - fallback_formulas[primary_component] = None - continue - - fallback_ids = [c.id for c in fallback_components] - generator = SimplePowerFormula( - f"{self._namespace}_fallback_{fallback_ids}", - self._channel_registry, - self._resampler_subscription_sender, - FormulaGeneratorConfig( - component_ids=set(fallback_ids), - allow_fallback=False, - ), - ) - - fallback_formulas[primary_component] = FallbackFormulaMetricFetcher( - generator - ) - - return fallback_formulas diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_pv_power_formula.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_pv_power_formula.py deleted file mode 100644 index 732eb236f..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_pv_power_formula.py +++ /dev/null @@ -1,142 +0,0 @@ -# License: MIT -# Copyright © 2022 Frequenz Energy-as-a-Service GmbH - -"""Formula generator for PV Power, from the component graph.""" - -import logging - -from frequenz.client.microgrid.component import Component, Meter -from frequenz.client.microgrid.metrics import Metric -from frequenz.quantities import Power - -from ...._internal._graph_traversal import dfs, is_pv_chain -from ....microgrid import connection_manager -from .._formula_engine import FormulaEngine -from ._fallback_formula_metric_fetcher import FallbackFormulaMetricFetcher -from ._formula_generator import ( - NON_EXISTING_COMPONENT_ID, - FormulaGenerator, - FormulaGeneratorConfig, -) - -_logger = logging.getLogger(__name__) - - -class PVPowerFormula(FormulaGenerator[Power]): - """Creates a formula engine for calculating the PV power production.""" - - def generate( # noqa: DOC502 - # * ComponentNotFound is raised indirectly by _get_pv_power_components - # * RuntimeError is also raised indirectly by _get_pv_power_components - self, - ) -> FormulaEngine[Power]: - """Make a formula for the PV power production of a microgrid. - - Returns: - A formula engine that will calculate PV power production values. - - Raises: - ComponentNotFound: if there is a problem finding the needed components. - RuntimeError: if the grid component has no PV inverters or meters as - successors. - """ - builder = self._get_builder( - "pv-power", Metric.AC_ACTIVE_POWER, Power.from_watts - ) - - component_graph = connection_manager.get().component_graph - component_ids = self._config.component_ids - if component_ids: - pv_components = component_graph.components(set(component_ids)) - else: - pv_components = dfs( - component_graph, - self._get_grid_component(), - set(), - lambda c: is_pv_chain(component_graph, c), - ) - - if not pv_components: - _logger.warning( - "Unable to find any PV components in the component graph. " - "Subscribing to the resampling actor with a non-existing " - "component id, so that `0` values are sent from the formula." - ) - # If there are no PV components, we have to send 0 values at the same - # frequency as the other streams. So we subscribe with a non-existing - # component id, just to get a `None` message at the resampling interval. - builder.push_component_metric( - NON_EXISTING_COMPONENT_ID, - nones_are_zeros=True, - ) - return builder.build() - - if self._config.allow_fallback: - fallbacks = self._get_fallback_formulas(pv_components) - - for idx, (primary_component, fallback_formula) in enumerate( - fallbacks.items() - ): - if idx > 0: - builder.push_oper("+") - - builder.push_component_metric( - primary_component.id, - nones_are_zeros=not isinstance(primary_component, Meter), - fallback=fallback_formula, - ) - else: - for idx, component in enumerate(pv_components): - if idx > 0: - builder.push_oper("+") - - builder.push_component_metric( - component.id, - nones_are_zeros=not isinstance(component, Meter), - ) - - return builder.build() - - def _get_fallback_formulas( - self, components: set[Component] - ) -> dict[Component, FallbackFormulaMetricFetcher[Power] | None]: - """Find primary and fallback components and create fallback formulas. - - The primary component is the one that will be used to calculate the PV power. - If it is not available, the fallback formula will be used instead. - Fallback formulas calculate the PV power using the fallback components. - Fallback formulas are wrapped in `FallbackFormulaMetricFetcher`. - - Args: - components: The PV components. - - Returns: - A dictionary mapping primary components to their corresponding - FallbackFormulaMetricFetcher. - """ - fallbacks = self._get_metric_fallback_components(components) - - fallback_formulas: dict[ - Component, FallbackFormulaMetricFetcher[Power] | None - ] = {} - for primary_component, fallback_components in fallbacks.items(): - if len(fallback_components) == 0: - fallback_formulas[primary_component] = None - continue - fallback_ids = [c.id for c in fallback_components] - - generator = PVPowerFormula( - f"{self._namespace}_fallback_{fallback_ids}", - self._channel_registry, - self._resampler_subscription_sender, - FormulaGeneratorConfig( - component_ids=set(fallback_ids), - allow_fallback=False, - ), - ) - - fallback_formulas[primary_component] = FallbackFormulaMetricFetcher( - generator - ) - - return fallback_formulas diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_simple_formula.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_simple_formula.py deleted file mode 100644 index 57aa29ea6..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_simple_formula.py +++ /dev/null @@ -1,106 +0,0 @@ -# License: MIT -# Copyright © 2024 Frequenz Energy-as-a-Service GmbH - -"""Formula generator from component graph.""" - -from frequenz.client.microgrid.component import Meter -from frequenz.client.microgrid.metrics import Metric -from frequenz.quantities import Power, ReactivePower - -from ....microgrid import connection_manager -from ..._base_types import QuantityT -from .._formula_engine import FormulaEngine -from .._resampled_formula_builder import ResampledFormulaBuilder -from ._formula_generator import FormulaGenerator - - -class SimpleFormulaBase(FormulaGenerator[QuantityT]): - """Base class for simple formula generators.""" - - def _generate( - self, builder: ResampledFormulaBuilder[QuantityT] - ) -> FormulaEngine[QuantityT]: - """Generate formula for calculating quantity from the component graph. - - Args: - builder: The builder to use for generating the formula. - - Returns: - A formula engine that will calculate the quantity. - - Raises: - RuntimeError: If components ids in config are not specified - or component graph does not contain all specified components. - """ - component_graph = connection_manager.get().component_graph - if self._config.component_ids is None: - raise RuntimeError("Power formula without component ids is not supported.") - - components = component_graph.components(matching_ids=self._config.component_ids) - - not_found_components = self._config.component_ids - {c.id for c in components} - if not_found_components: - raise RuntimeError( - f"Unable to find {not_found_components} components in the component graph. ", - ) - - for idx, component in enumerate(components): - if idx > 0: - builder.push_oper("+") - - builder.push_component_metric( - component.id, - nones_are_zeros=not isinstance(component, Meter), - ) - - return builder.build() - - -class SimplePowerFormula(SimpleFormulaBase[Power]): - """Formula generator from component graph for calculating sum of Power.""" - - def generate( # noqa: DOC502 - # * ComponentNotFound is raised indirectly by _get_grid_component() - # * RuntimeError is raised indirectly by connection_manager.get() - self, - ) -> FormulaEngine[Power]: - """Generate formula for calculating sum of power from the component graph. - - Returns: - A formula engine that will calculate the power. - - Raises: - RuntimeError: If components ids in config are not specified - or component graph does not contain all specified components. - """ - builder = self._get_builder( - "simple_power_formula", - Metric.AC_ACTIVE_POWER, - Power.from_watts, - ) - return self._generate(builder) - - -class SimpleReactivePowerFormula(SimpleFormulaBase[ReactivePower]): - """Formula generator from component graph for calculating sum of reactive power.""" - - def generate( # noqa: DOC502 - # * ComponentNotFound is raised indirectly by _get_grid_component() - # * RuntimeError is raised indirectly by connection_manager.get() - self, - ) -> FormulaEngine[ReactivePower]: - """Generate formula for calculating sum of reactive power from the component graph. - - Returns: - A formula engine that will calculate the power. - - Raises: - RuntimeError: If components ids in config are not specified - or component graph does not contain all specified components. - """ - builder = self._get_builder( - "simple_reactive_power_formula", - Metric.AC_REACTIVE_POWER, - ReactivePower.from_volt_amperes_reactive, - ) - return self._generate(builder) diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_steps.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_steps.py deleted file mode 100644 index 24ae94dbe..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_steps.py +++ /dev/null @@ -1,601 +0,0 @@ -# License: MIT -# Copyright © 2022 Frequenz Energy-as-a-Service GmbH - -"""Steps for building formula engines with.""" - -from __future__ import annotations - -import logging -import math -from abc import ABC, abstractmethod -from typing import Generic - -from frequenz.channels import Receiver, ReceiverError, ReceiverStoppedError - -from .._base_types import QuantityT, Sample - -_logger = logging.getLogger(__name__) - - -class FormulaStep(ABC): - """Represents an individual step/stage in a formula. - - Each step, when applied on to an evaluation stack, would pop its input parameters - from the stack and push its result back in. - """ - - @abstractmethod - def __repr__(self) -> str: - """Return a string representation of the step. - - Returns: - A string representation of the step. - """ - - @abstractmethod - def apply(self, eval_stack: list[float]) -> None: - """Apply a formula operation on the eval_stack. - - Args: - eval_stack: An evaluation stack, to apply the formula step on. - """ - - -class Adder(FormulaStep): - """A formula step for adding two values.""" - - def __repr__(self) -> str: - """Return a string representation of the step. - - Returns: - A string representation of the step. - """ - return "+" - - def apply(self, eval_stack: list[float]) -> None: - """Extract two values from the stack, add them, push the result back in. - - Args: - eval_stack: An evaluation stack, to apply the formula step on. - """ - val2 = eval_stack.pop() - val1 = eval_stack.pop() - res = val1 + val2 - eval_stack.append(res) - - -class Subtractor(FormulaStep): - """A formula step for subtracting one value from another.""" - - def __repr__(self) -> str: - """Return a string representation of the step. - - Returns: - A string representation of the step. - """ - return "-" - - def apply(self, eval_stack: list[float]) -> None: - """Extract two values from the stack, subtract them, push the result back in. - - Args: - eval_stack: An evaluation stack, to apply the formula step on. - """ - val2 = eval_stack.pop() - val1 = eval_stack.pop() - res = val1 - val2 - eval_stack.append(res) - - -class Multiplier(FormulaStep): - """A formula step for multiplying two values.""" - - def __repr__(self) -> str: - """Return a string representation of the step. - - Returns: - A string representation of the step. - """ - return "*" - - def apply(self, eval_stack: list[float]) -> None: - """Extract two values from the stack, multiply them, push the result back in. - - Args: - eval_stack: An evaluation stack, to apply the formula step on. - """ - val2 = eval_stack.pop() - val1 = eval_stack.pop() - res = val1 * val2 - eval_stack.append(res) - - -class Divider(FormulaStep): - """A formula step for dividing one value by another.""" - - def __repr__(self) -> str: - """Return a string representation of the step. - - Returns: - A string representation of the step. - """ - return "/" - - def apply(self, eval_stack: list[float]) -> None: - """Extract two values from the stack, divide them, push the result back in. - - Args: - eval_stack: An evaluation stack, to apply the formula step on. - """ - val2 = eval_stack.pop() - val1 = eval_stack.pop() - res = val1 / val2 - eval_stack.append(res) - - -class Maximizer(FormulaStep): - """A formula step that represents the max function.""" - - def __repr__(self) -> str: - """Return a string representation of the step. - - Returns: - A string representation of the step. - """ - return "max" - - def apply(self, eval_stack: list[float]) -> None: - """Extract two values from the stack and pushes back the maximum. - - Args: - eval_stack: An evaluation stack, to apply the formula step on. - """ - val2 = eval_stack.pop() - val1 = eval_stack.pop() - res = max(val1, val2) - eval_stack.append(res) - - -class Minimizer(FormulaStep): - """A formula step that represents the min function.""" - - def __repr__(self) -> str: - """Return a string representation of the step. - - Returns: - A string representation of the step. - """ - return "min" - - def apply(self, eval_stack: list[float]) -> None: - """Extract two values from the stack and pushes back the minimum. - - Args: - eval_stack: An evaluation stack, to apply the formula step on. - """ - val2 = eval_stack.pop() - val1 = eval_stack.pop() - res = min(val1, val2) - eval_stack.append(res) - - -class Consumption(FormulaStep): - """A formula step that represents the consumption operator. - - The consumption operator is the maximum of the value on top - of the evaluation stack and 0. - """ - - def __repr__(self) -> str: - """Return a string representation of the step. - - Returns: - A string representation of the step. - """ - return "consumption" - - def apply(self, eval_stack: list[float]) -> None: - """ - Apply the consumption formula. - - Replace the top of the eval eval_stack with the same value if the value - is positive or 0. - - Args: - eval_stack: An evaluation stack, to apply the formula step on. - """ - val = eval_stack.pop() - eval_stack.append(max(val, 0)) - - -class Production(FormulaStep): - """A formula step that represents the production operator. - - The production operator is the maximum of the value times minus one on top - of the evaluation stack and 0. - """ - - def __repr__(self) -> str: - """Return a string representation of the step. - - Returns: - A string representation of the step. - """ - return "production" - - def apply(self, eval_stack: list[float]) -> None: - """ - Apply the production formula. - - Replace the top of the eval eval_stack with its absolute value if the - value is negative or 0. - - Args: - eval_stack: An evaluation stack, to apply the formula step on. - """ - val = eval_stack.pop() - eval_stack.append(max(-val, 0)) - - -class OpenParen(FormulaStep): - """A no-op formula step used while building a prefix formula engine. - - Any OpenParen steps would get removed once a formula is built. - """ - - def __repr__(self) -> str: - """Return a string representation of the step. - - Returns: - A string representation of the step. - """ - return "(" - - def apply(self, _: list[float]) -> None: - """No-op.""" - - -class ConstantValue(FormulaStep): - """A formula step for inserting a constant value.""" - - def __init__(self, value: float) -> None: - """Create a `ConstantValue` instance. - - Args: - value: The constant value. - """ - self._value = value - - @property - def value(self) -> float: - """Return the constant value. - - Returns: - The constant value. - """ - return self._value - - def __repr__(self) -> str: - """Return a string representation of the step. - - Returns: - A string representation of the step. - """ - return str(self._value) - - def apply(self, eval_stack: list[float]) -> None: - """Push the constant value to the eval_stack. - - Args: - eval_stack: An evaluation stack, to append the constant value to. - """ - eval_stack.append(self._value) - - -class Clipper(FormulaStep): - """A formula step for clipping a value between a minimum and maximum.""" - - def __init__(self, min_val: float | None, max_val: float | None) -> None: - """Create a `Clipper` instance. - - Args: - min_val: The minimum value. - max_val: The maximum value. - """ - self._min_val = min_val - self._max_val = max_val - - @property - def min_value(self) -> float | None: - """Return the minimum value. - - Returns: - The minimum value. - """ - return self._min_val - - @property - def max_value(self) -> float | None: - """Return the maximum value. - - Returns: - The maximum value. - """ - return self._max_val - - def __repr__(self) -> str: - """Return a string representation of the step. - - Returns: - A string representation of the step. - """ - return f"clip({self._min_val}, {self._max_val})" - - def apply(self, eval_stack: list[float]) -> None: - """Clip the value at the top of the eval_stack. - - Args: - eval_stack: An evaluation stack, to apply the formula step on. - """ - val = eval_stack.pop() - if self._min_val is not None: - val = max(val, self._min_val) - if self._max_val is not None: - val = min(val, self._max_val) - eval_stack.append(val) - - -class FallbackMetricFetcher(Receiver[Sample[QuantityT]], Generic[QuantityT]): - """A fallback metric fetcher for formula engines. - - Generates a metric value from the fallback components if the primary metric - is invalid. - - This class starts running when the primary MetricFetcher starts receiving invalid data. - """ - - @property - @abstractmethod - def name(self) -> str: - """Get the name of the fetcher.""" - - @property - @abstractmethod - def is_running(self) -> bool: - """Check whether the metric fetcher is running.""" - - @abstractmethod - def start(self) -> None: - """Initialize the metric fetcher and start fetching samples.""" - - @abstractmethod - async def stop(self) -> None: - """Stope the fetcher if is running.""" - - -class MetricFetcher(Generic[QuantityT], FormulaStep): - """A formula step for fetching a value from a metric Receiver.""" - - def __init__( - self, - name: str, - stream: Receiver[Sample[QuantityT]], - *, - nones_are_zeros: bool, - fallback: FallbackMetricFetcher[QuantityT] | None = None, - ) -> None: - """Create a `MetricFetcher` instance. - - Args: - name: The name of the metric. - stream: A channel receiver from which to fetch samples. - nones_are_zeros: Whether to treat None values from the stream as 0s. - fallback: Metric fetcher to use if primary one start sending - invalid data (e.g. due to a component stop). If None, the data from - primary metric fetcher will be used. - """ - self._name = name - self._stream: Receiver[Sample[QuantityT]] = stream - self._next_value: Sample[QuantityT] | None = None - self._nones_are_zeros = nones_are_zeros - self._fallback: FallbackMetricFetcher[QuantityT] | None = fallback - self._latest_fallback_sample: Sample[QuantityT] | None = None - self._is_stopped = False - - @property - def stream(self) -> Receiver[Sample[QuantityT]]: - """Return the stream from which to fetch values. - - Returns: - The stream from which to fetch values. - """ - return self._stream - - async def stop(self) -> None: - """Stop metric fetcher. - - If metric fetcher is stopped, it can't be started again. - There is no use-case now to start it again. - """ - self._is_stopped = True - self.stream.close() - if self._fallback: - await self._fallback.stop() - - def stream_name(self) -> str: - """Return the name of the stream. - - Returns: - The name of the stream. - """ - return str(self._stream.__doc__) - - def _is_value_valid(self, value: QuantityT | None) -> bool: - return not (value is None or value.isnan() or value.isinf()) - - async def _fetch_from_fallback( - self, fallback_fetcher: FallbackMetricFetcher[QuantityT] - ) -> Sample[QuantityT] | None: - try: - return await fallback_fetcher.receive() - except ReceiverStoppedError: - if self._is_stopped: - _logger.debug( - "Stream for fallback metric fetcher %s closed.", - fallback_fetcher.name, - ) - else: - _logger.error( - "Failed to fetch next value from %s. Fallback stream closed.", - self._name, - ) - return None - except ReceiverError as err: - _logger.error( - "Failed to fetch next value from fallback stream %s: %s", - self._name, - err, - ) - return None - - async def _synchronize_and_fetch_fallback( - self, - primary_fetcher_value: Sample[QuantityT] | None, - fallback_fetcher: FallbackMetricFetcher[QuantityT], - ) -> Sample[QuantityT] | None: - """Synchronize the fallback fetcher and return the fallback value. - - Args: - primary_fetcher_value: The sample fetched from the primary fetcher. - fallback_fetcher: The fallback metric fetcher. - - Returns: - The value from the synchronized stream. Returns None if the primary - fetcher sample is older than the latest sample from the fallback - fetcher or if the fallback fetcher fails to fetch the next value. - """ - # We need to save value, because - # primary_fetcher_value.timestamp < self._latest_fallback_sample.timestamp - # In that case we should wait for our time window. - if self._latest_fallback_sample is None: - self._latest_fallback_sample = await self._fetch_from_fallback( - fallback_fetcher - ) - - if primary_fetcher_value is None or self._latest_fallback_sample is None: - return self._latest_fallback_sample - - if primary_fetcher_value.timestamp < self._latest_fallback_sample.timestamp: - return None - - # Synchronize the fallback fetcher with primary one - while primary_fetcher_value.timestamp > self._latest_fallback_sample.timestamp: - self._latest_fallback_sample = await self._fetch_from_fallback( - fallback_fetcher - ) - if self._latest_fallback_sample is None: - break - return self._latest_fallback_sample - - async def fetch_next(self) -> Sample[QuantityT] | None: - """Fetch the next value from the stream. - - To be called before each call to `apply`. - - Returns: - The fetched Sample. - """ - if self._is_stopped: - _logger.error( - "Metric fetcher %s stopped. Can't fetch new value.", self._name - ) - return None - - self._next_value = await self._fetch_next() - return self._next_value - - async def _fetch_next(self) -> Sample[QuantityT] | None: - # First fetch from primary stream - primary_value: Sample[QuantityT] | None = None - try: - primary_value = await self._stream.receive() - except ReceiverStoppedError: - if self._is_stopped: - _logger.debug("Stream for metric fetcher %s closed.", self._name) - return None - _logger.error( - "Failed to fetch next value from %s. Primary stream closed.", - self._name, - ) - except ReceiverError as err: - _logger.error("Failed to fetch next value from %s: %s", self._name, err) - - # We have no fallback, so we just return primary value even if it is not correct. - if self._fallback is None: - return primary_value - - is_primary_value_valid = primary_value is not None and self._is_value_valid( - primary_value.value - ) - - if is_primary_value_valid: - # Primary stream is good again, so we can stop fallback and return primary_value. - if self._fallback.is_running: - _logger.info( - "Primary metric %s is good again, stopping fallback metric fetcher %s", - self._name, - self._fallback.name, - ) - await self._fallback.stop() - return primary_value - - if not self._fallback.is_running: - _logger.warning( - "Primary metric %s is invalid. Running fallback metric fetcher: %s", - self._name, - self._fallback.name, - ) - # We started fallback, but it has to subscribe. - # We will receive fallback values since the next time window. - self._fallback.start() - return primary_value - - return await self._synchronize_and_fetch_fallback(primary_value, self._fallback) - - @property - def value(self) -> Sample[QuantityT] | None: - """Get the next value in the stream. - - Returns: - Next value in the stream. - """ - return self._next_value - - def __repr__(self) -> str: - """Return a string representation of the step. - - Returns: - A string representation of the step. - """ - return self._name - - def apply(self, eval_stack: list[float]) -> None: - """Push the latest value from the stream into the evaluation stack. - - Args: - eval_stack: An evaluation stack, to apply the formula step on. - - Raises: - RuntimeError: No next value available to append. - """ - if self._next_value is None: - raise RuntimeError("No next value available to append.") - - next_value = self._next_value.value - if next_value is None or next_value.isnan() or next_value.isinf(): - if self._nones_are_zeros: - eval_stack.append(0.0) - else: - eval_stack.append(math.nan) - else: - eval_stack.append(next_value.base_value) diff --git a/src/frequenz/sdk/timeseries/formula_engine/_resampled_formula_builder.py b/src/frequenz/sdk/timeseries/formula_engine/_resampled_formula_builder.py deleted file mode 100644 index e1ec29cc1..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_resampled_formula_builder.py +++ /dev/null @@ -1,160 +0,0 @@ -# License: MIT -# Copyright © 2022 Frequenz Energy-as-a-Service GmbH - -"""A builder for creating formula engines that operate on resampled component metrics.""" - -from __future__ import annotations - -from collections.abc import Callable - -from frequenz.channels import Receiver, Sender -from frequenz.client.common.microgrid.components import ComponentId -from frequenz.client.microgrid.metrics import Metric -from frequenz.quantities import Quantity - -from frequenz.sdk.microgrid._old_component_data import TransitionalMetric - -from ..._internal._channels import ChannelRegistry -from ...microgrid._data_sourcing import ComponentMetricRequest -from .._base_types import QuantityT, Sample -from ._formula_engine import FormulaBuilder, FormulaEngine -from ._formula_steps import FallbackMetricFetcher -from ._tokenizer import Tokenizer, TokenType - - -class ResampledFormulaBuilder(FormulaBuilder[QuantityT]): - """Provides a way to build a FormulaEngine from resampled data streams.""" - - def __init__( # pylint: disable=too-many-arguments - self, - *, - namespace: str, - formula_name: str, - channel_registry: ChannelRegistry, - resampler_subscription_sender: Sender[ComponentMetricRequest], - metric: Metric | TransitionalMetric, - create_method: Callable[[float], QuantityT], - ) -> None: - """Create a `ResampledFormulaBuilder` instance. - - Args: - namespace: The unique namespace to allow reuse of streams in the data - pipeline. - formula_name: A name for the formula. - channel_registry: The channel registry instance shared with the resampling - and the data sourcing actors. - resampler_subscription_sender: A sender to send metric requests to the - resampling actor. - metric: The metric to fetch for all components in this formula. - create_method: A method to generate the output `Sample` value with. If the - formula is for generating power values, this would be - `Power.from_watts`, for example. - """ - self._channel_registry: ChannelRegistry = channel_registry - self._resampler_subscription_sender: Sender[ComponentMetricRequest] = ( - resampler_subscription_sender - ) - self._namespace: str = namespace - self._metric: Metric | TransitionalMetric = metric - self._resampler_requests: list[ComponentMetricRequest] = [] - super().__init__(formula_name, create_method) - - def _get_resampled_receiver( - self, - component_id: ComponentId, - metric: Metric | TransitionalMetric, - ) -> Receiver[Sample[QuantityT]]: - """Get a receiver with the resampled data for the given component id. - - Args: - component_id: The component id for which to get a resampled data receiver. - metric: The metric to fetch for all components in this formula. - - Returns: - A receiver to stream resampled data for the given component id. - """ - request = ComponentMetricRequest(self._namespace, component_id, metric, None) - self._resampler_requests.append(request) - resampled_channel = self._channel_registry.get_or_create( - Sample[Quantity], request.get_channel_name() - ) - resampled_receiver = resampled_channel.new_receiver().map( - lambda sample: Sample( - sample.timestamp, - ( - self._create_method(sample.value.base_value) - if sample.value is not None - else None - ), - ) - ) - return resampled_receiver - - async def subscribe(self) -> None: - """Subscribe to all resampled component metric streams.""" - for request in self._resampler_requests: - await self._resampler_subscription_sender.send(request) - - def push_component_metric( - self, - component_id: ComponentId, - *, - nones_are_zeros: bool, - fallback: FallbackMetricFetcher[QuantityT] | None = None, - ) -> None: - """Push a resampled component metric stream to the formula engine. - - Args: - component_id: The component id for which to push a metric fetcher. - nones_are_zeros: Whether to treat None values from the stream as 0s. If - False, the returned value will be a None. - fallback: Metric fetcher to use if primary one start sending - invalid data (e.g. due to a component stop). If None the data from - primary metric fetcher will be returned. - """ - receiver = self._get_resampled_receiver(component_id, self._metric) - self.push_metric( - f"#{int(component_id)}", - receiver, - nones_are_zeros=nones_are_zeros, - fallback=fallback, - ) - - def from_string( - self, - formula: str, - *, - nones_are_zeros: bool, - ) -> FormulaEngine[QuantityT]: - """Construct a `FormulaEngine` from the given formula string. - - Formulas can have Component IDs that are preceeded by a pound symbol("#"), and - these operators: +, -, *, /, (, ). - - For example, the input string: "#20 + #5" is a formula for adding metrics from - two components with ids 20 and 5. - - Args: - formula: A string formula. - nones_are_zeros: Whether to treat None values from the stream as 0s. If - False, the returned value will be a None. - - Returns: - A FormulaEngine instance corresponding to the given formula. - - Raises: - ValueError: when there is an unknown token type. - """ - tokenizer = Tokenizer(formula) - - for token in tokenizer: - if token.type == TokenType.COMPONENT_METRIC: - self.push_component_metric( - ComponentId(int(token.value)), nones_are_zeros=nones_are_zeros - ) - elif token.type == TokenType.OPER: - self.push_oper(token.value) - else: - raise ValueError(f"Unknown token type: {token}") - - return self.build() diff --git a/src/frequenz/sdk/timeseries/formula_engine/_tokenizer.py b/src/frequenz/sdk/timeseries/formula_engine/_tokenizer.py deleted file mode 100644 index 330168990..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_tokenizer.py +++ /dev/null @@ -1,178 +0,0 @@ -# License: MIT -# Copyright © 2022 Frequenz Energy-as-a-Service GmbH - -"""A tokenizer for data pipeline formulas.""" - -from __future__ import annotations - -from dataclasses import dataclass -from enum import Enum - - -class StringIter: - """An iterator for reading characters from a string.""" - - def __init__(self, raw: str) -> None: - """Create a `StringIter` instance. - - Args: - raw: The raw string to create the iterator out of. - """ - self._raw = raw - self._max = len(raw) - self._pos = 0 - - @property - def pos(self) -> int: - """Return the position of the iterator in the raw string. - - Returns: - The position of the iterator. - """ - return self._pos - - @property - def raw(self) -> str: - """Return the raw string the iterator is created with. - - Returns: - The base string of the iterator. - """ - return self._raw - - def __iter__(self) -> StringIter: - """Return an iterator to this class. - - Returns: - self. - """ - return self - - def __next__(self) -> str: - """Return the next character in the raw string, and move forward. - - Returns: - The next character. - - Raises: - StopIteration: when there are no more characters in the string. - """ - if self._pos < self._max: - char = self._raw[self._pos] - self._pos += 1 - return char - raise StopIteration() - - def peek(self) -> str | None: - """Return the next character in the raw string, without consuming it. - - Returns: - The next character. - """ - if self._pos < self._max: - return self._raw[self._pos] - return None - - -class TokenType(Enum): - """Represents the types of tokens the Tokenizer can return.""" - - COMPONENT_METRIC = 0 - """A component metric ID.""" - - CONSTANT = 1 - """A constant value.""" - - OPER = 2 - """An operator.""" - - -@dataclass -class Token: - """Represents a Token returned by the Tokenizer.""" - - type: TokenType - """The type of the token.""" - - value: str - """The value associated to the token.""" - - -class Tokenizer: - """A Tokenizer for breaking down a string formula into individual tokens. - - Every instance is an iterator that allows us to iterate over the individual tokens - in the given formula. - - Formulas can have Component IDs that are preceeded by a pound symbol("#"), and these - operators: +, -, *, /, (, ). - - For example, the input string: "#20 + #5" would produce three tokens: - - COMPONENT_METRIC: 20 - - OPER: + - - COMPONENT_METRIC: 5 - """ - - def __init__(self, formula: str) -> None: - """Create a `Tokenizer` instance. - - Args: - formula: The string formula to tokenize. - """ - self._formula = StringIter(formula) - - def _read_unsigned_int(self) -> str: - """Read an unsigned int from the current position in the input string. - - Returns: - A string containing the read unsigned int value. - - Raises: - ValueError: when there is no unsigned int at the current position. - """ - first_char = True - result = "" - - while char := self._formula.peek(): - if not char.isdigit(): - if first_char: - raise ValueError( - f"Expected an integer. got '{char}', " - f"at pos {self._formula.pos} in formula {self._formula.raw}" - ) - break - first_char = False - result += char - next(self._formula) - return result - - def __iter__(self) -> Tokenizer: - """Return an iterator to this class. - - Returns: - self. - """ - return self - - def __next__(self) -> Token: - """Return the next token in the input string. - - Returns: - The next token. - - Raises: - ValueError: when there are unknown tokens in the input string. - StopIteration: when there are no more tokens in the input string. - """ - for char in self._formula: - if char in (" ", "\n", "\r", "\t"): - continue - if char in ("+", "-", "*", "/", "(", ")"): - return Token(TokenType.OPER, char) - if char == "#": - return Token(TokenType.COMPONENT_METRIC, self._read_unsigned_int()) - raise ValueError( - f"Unable to parse character '{char}' at pos: {self._formula.pos}" - f" in formula: {self._formula.raw}" - ) - raise StopIteration() diff --git a/tests/timeseries/_formula_engine/__init__.py b/tests/timeseries/_formula_engine/__init__.py deleted file mode 100644 index 472566a2e..000000000 --- a/tests/timeseries/_formula_engine/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# License: MIT -# Copyright © 2023 Frequenz Energy-as-a-Service GmbH - -"""Formula engine tests.""" diff --git a/tests/timeseries/test_formula_engine.py b/tests/timeseries/test_formula_engine.py deleted file mode 100644 index 0155a7bd9..000000000 --- a/tests/timeseries/test_formula_engine.py +++ /dev/null @@ -1,930 +0,0 @@ -# License: MIT -# Copyright © 2022 Frequenz Energy-as-a-Service GmbH - -"""Tests for the FormulaEngine and the Tokenizer.""" - -import asyncio -from collections.abc import Callable -from datetime import datetime - -from frequenz.channels import Broadcast, Receiver -from frequenz.quantities import Power, Quantity - -from frequenz.sdk.timeseries import Sample -from frequenz.sdk.timeseries.formula_engine._formula_engine import ( - FormulaBuilder, - FormulaEngine, - HigherOrderFormulaBuilder, -) -from frequenz.sdk.timeseries.formula_engine._tokenizer import ( - Token, - Tokenizer, - TokenType, -) - - -class TestTokenizer: - """Tests for the Tokenizer.""" - - def test_1(self) -> None: - """Test the tokenization of the formula: "#10 + #20 - (#5 * #4)".""" - tokens = [] - lexer = Tokenizer("#10 + #20 - (#5 * #4)") - for token in lexer: - tokens.append(token) - assert tokens == [ - Token(TokenType.COMPONENT_METRIC, "10"), - Token(TokenType.OPER, "+"), - Token(TokenType.COMPONENT_METRIC, "20"), - Token(TokenType.OPER, "-"), - Token(TokenType.OPER, "("), - Token(TokenType.COMPONENT_METRIC, "5"), - Token(TokenType.OPER, "*"), - Token(TokenType.COMPONENT_METRIC, "4"), - Token(TokenType.OPER, ")"), - ] - - -class TestFormulaEngine: - """Tests for the FormulaEngine.""" - - async def run_test( # pylint: disable=too-many-locals - self, - formula: str, - postfix: str, - io_pairs: list[tuple[list[float | None], float | None]], - nones_are_zeros: bool = False, - ) -> None: - """Run a formula test.""" - channels: dict[str, Broadcast[Sample[Quantity]]] = {} - builder = FormulaBuilder("test_formula", Quantity) - for token in Tokenizer(formula): - if token.type == TokenType.COMPONENT_METRIC: - if token.value not in channels: - channels[token.value] = Broadcast(name=token.value) - builder.push_metric( - f"#{token.value}", - channels[token.value].new_receiver(), - nones_are_zeros=nones_are_zeros, - ) - elif token.type == TokenType.OPER: - builder.push_oper(token.value) - engine = builder.build() - results_rx = engine.new_receiver() - - assert repr(builder._steps) == postfix # pylint: disable=protected-access - - now = datetime.now() - tests_passed = 0 - for io_pair in io_pairs: - io_input, io_output = io_pair - await asyncio.gather( - *[ - chan.new_sender().send( - Sample(now, None if not value else Quantity(value)) - ) - for chan, value in zip(channels.values(), io_input) - ] - ) - next_val = await results_rx.receive() - if io_output is None: - assert next_val.value is None - else: - assert ( - next_val.value is not None - and next_val.value.base_value == io_output - ) - tests_passed += 1 - await engine.stop() - assert tests_passed == len(io_pairs) - - async def test_simple(self) -> None: - """Test simple formulas.""" - await self.run_test( - "#2 - #4 + #5", - "[#2, #4, -, #5, +]", - [ - ([10.0, 12.0, 15.0], 13.0), - ([15.0, 17.0, 20.0], 18.0), - ], - ) - await self.run_test( - "#2 + #4 - #5", - "[#2, #4, #5, -, +]", - [ - ([10.0, 12.0, 15.0], 7.0), - ([15.0, 17.0, 20.0], 12.0), - ], - ) - await self.run_test( - "#2 * #4 + #5", - "[#2, #4, *, #5, +]", - [ - ([10.0, 12.0, 15.0], 135.0), - ([15.0, 17.0, 20.0], 275.0), - ], - ) - await self.run_test( - "#2 * #4 / #5", - "[#2, #4, #5, /, *]", - [ - ([10.0, 12.0, 15.0], 8.0), - ([15.0, 17.0, 20.0], 12.75), - ], - ) - await self.run_test( - "#2 / #4 - #5", - "[#2, #4, /, #5, -]", - [ - ([6.0, 12.0, 15.0], -14.5), - ([15.0, 20.0, 20.0], -19.25), - ], - ) - await self.run_test( - "#2 - #4 - #5", - "[#2, #4, -, #5, -]", - [ - ([6.0, 12.0, 15.0], -21.0), - ([15.0, 20.0, 20.0], -25.0), - ], - ) - await self.run_test( - "#2 + #4 + #5", - "[#2, #4, +, #5, +]", - [ - ([6.0, 12.0, 15.0], 33.0), - ([15.0, 20.0, 20.0], 55.0), - ], - ) - await self.run_test( - "#2 / #4 / #5", - "[#2, #4, /, #5, /]", - [ - ([30.0, 3.0, 5.0], 2.0), - ([15.0, 3.0, 2.0], 2.5), - ], - ) - - async def test_compound(self) -> None: - """Test compound formulas.""" - await self.run_test( - "#2 + #4 - #5 * #6", - "[#2, #4, #5, #6, *, -, +]", - [ - ([10.0, 12.0, 15.0, 2.0], -8.0), - ([15.0, 17.0, 20.0, 1.5], 2.0), - ], - ) - await self.run_test( - "#2 + (#4 - #5) * #6", - "[#2, #4, #5, -, #6, *, +]", - [ - ([10.0, 12.0, 15.0, 2.0], 4.0), - ([15.0, 17.0, 20.0, 1.5], 10.5), - ], - ) - await self.run_test( - "#2 + (#4 - #5 * #6)", - "[#2, #4, #5, #6, *, -, +]", - [ - ([10.0, 12.0, 15.0, 2.0], -8.0), - ([15.0, 17.0, 20.0, 1.5], 2.0), - ], - ) - await self.run_test( - "#2 + (#4 - #5 - #6)", - "[#2, #4, #5, -, #6, -, +]", - [ - ([10.0, 12.0, 15.0, 2.0], 5.0), - ([15.0, 17.0, 20.0, 1.5], 10.5), - ], - ) - await self.run_test( - "#2 + #4 - #5 - #6", - "[#2, #4, #5, -, #6, -, +]", - [ - ([10.0, 12.0, 15.0, 2.0], 5.0), - ([15.0, 17.0, 20.0, 1.5], 10.5), - ], - ) - await self.run_test( - "#2 + #4 - (#5 - #6)", - "[#2, #4, #5, #6, -, -, +]", - [ - ([10.0, 12.0, 15.0, 2.0], 9.0), - ([15.0, 17.0, 20.0, 1.5], 13.5), - ], - ) - await self.run_test( - "(#2 + #4 - #5) * #6", - "[#2, #4, #5, -, +, #6, *]", - [ - ([10.0, 12.0, 15.0, 2.0], 14.0), - ([15.0, 17.0, 20.0, 1.5], 18.0), - ], - ) - await self.run_test( - "(#2 + #4 - #5) / #6", - "[#2, #4, #5, -, +, #6, /]", - [ - ([10.0, 12.0, 15.0, 2.0], 3.5), - ([15.0, 17.0, 20.0, 1.5], 8.0), - ], - ) - await self.run_test( - "#2 + #4 - (#5 / #6)", - "[#2, #4, #5, #6, /, -, +]", - [ - ([10.0, 12.0, 15.0, 2.0], 14.5), - ([15.0, 17.0, 20.0, 5.0], 28.0), - ], - ) - - async def test_nones_are_zeros(self) -> None: - """Test that `None`s are treated as zeros when configured.""" - await self.run_test( - "#2 - #4 + #5", - "[#2, #4, -, #5, +]", - [ - ([10.0, 12.0, 15.0], 13.0), - ([None, 12.0, 15.0], 3.0), - ([10.0, None, 15.0], 25.0), - ([15.0, 17.0, 20.0], 18.0), - ([15.0, None, None], 15.0), - ], - True, - ) - - await self.run_test( - "#2 + #4 - (#5 * #6)", - "[#2, #4, #5, #6, *, -, +]", - [ - ([10.0, 12.0, 15.0, 2.0], -8.0), - ([10.0, 12.0, 15.0, None], 22.0), - ([10.0, None, 15.0, 2.0], -20.0), - ([15.0, 17.0, 20.0, 5.0], -68.0), - ([15.0, 17.0, None, 5.0], 32.0), - ], - True, - ) - - async def test_nones_are_not_zeros(self) -> None: - """Test that calculated values are `None` on input `None`s.""" - await self.run_test( - "#2 - #4 + #5", - "[#2, #4, -, #5, +]", - [ - ([10.0, 12.0, 15.0], 13.0), - ([None, 12.0, 15.0], None), - ([10.0, None, 15.0], None), - ([15.0, 17.0, 20.0], 18.0), - ([15.0, None, None], None), - ], - False, - ) - - await self.run_test( - "#2 + #4 - (#5 * #6)", - "[#2, #4, #5, #6, *, -, +]", - [ - ([10.0, 12.0, 15.0, 2.0], -8.0), - ([10.0, 12.0, 15.0, None], None), - ([10.0, None, 15.0, 2.0], None), - ([15.0, 17.0, 20.0, 5.0], -68.0), - ([15.0, 17.0, None, 5.0], None), - ], - False, - ) - - -class TestFormulaEngineComposition: - """Tests for formula channels.""" - - def make_engine( - self, stream_id: int, data: Receiver[Sample[Quantity]] - ) -> FormulaEngine[Quantity]: - """Make a basic FormulaEngine.""" - name = f"#{stream_id}" - builder = FormulaBuilder(name, create_method=Quantity) - builder.push_metric( - name, - data, - nones_are_zeros=False, - ) - return FormulaEngine(builder, create_method=Quantity) - - async def run_test( # pylint: disable=too-many-locals - self, - num_items: int, - make_builder: ( - Callable[ - [ - FormulaEngine[Quantity], - ], - HigherOrderFormulaBuilder[Quantity], - ] - | Callable[ - [ - FormulaEngine[Quantity], - FormulaEngine[Quantity], - ], - HigherOrderFormulaBuilder[Quantity], - ] - | Callable[ - [ - FormulaEngine[Quantity], - FormulaEngine[Quantity], - FormulaEngine[Quantity], - ], - HigherOrderFormulaBuilder[Quantity], - ] - | Callable[ - [ - FormulaEngine[Quantity], - FormulaEngine[Quantity], - FormulaEngine[Quantity], - FormulaEngine[Quantity], - ], - HigherOrderFormulaBuilder[Quantity], - ] - ), - io_pairs: list[tuple[list[float | None], float | None]], - nones_are_zeros: bool = False, - ) -> None: - """Run a test with the specs provided.""" - channels = [ - Broadcast[Sample[Quantity]](name=str(ctr)) for ctr in range(num_items) - ] - l1_engines = [ - self.make_engine(ctr, channels[ctr].new_receiver()) - for ctr in range(num_items) - ] - builder = make_builder(*l1_engines) - engine = builder.build("l2 formula", nones_are_zeros=nones_are_zeros) - result_chan = engine.new_receiver() - - now = datetime.now() - tests_passed = 0 - for io_pair in io_pairs: - io_input, io_output = io_pair - await asyncio.gather( - *[ - chan.new_sender().send( - Sample(now, None if not value else Quantity(value)) - ) - for chan, value in zip(channels, io_input) - ] - ) - next_val = await result_chan.receive() - if io_output is None: - assert next_val.value is None - else: - assert ( - next_val.value is not None - and next_val.value.base_value == io_output - ) - tests_passed += 1 - await engine.stop() - assert tests_passed == len(io_pairs) - - async def test_simple(self) -> None: - """Test simple formulas.""" - await self.run_test( - 3, - lambda c2, c4, c5: c2 - c4 + c5, - [ - ([10.0, 12.0, 15.0], 13.0), - ([15.0, 17.0, 20.0], 18.0), - ], - ) - await self.run_test( - 3, - lambda c2, c4, c5: c2 + c4 - c5, - [ - ([10.0, 12.0, 15.0], 7.0), - ([15.0, 17.0, 20.0], 12.0), - ], - ) - await self.run_test( - 3, - lambda c2, c4, c5: c2 * c4 + c5, - [ - ([10.0, 12.0, 15.0], 135.0), - ([15.0, 17.0, 20.0], 275.0), - ], - ) - await self.run_test( - 3, - lambda c2, c4, c5: c2 * c4 / c5, - [ - ([10.0, 12.0, 15.0], 8.0), - ([15.0, 17.0, 20.0], 12.75), - ], - ) - await self.run_test( - 3, - lambda c2, c4, c5: c2 / c4 - c5, - [ - ([6.0, 12.0, 15.0], -14.5), - ([15.0, 20.0, 20.0], -19.25), - ], - ) - await self.run_test( - 3, - lambda c2, c4, c5: c2 - c4 - c5, - [ - ([6.0, 12.0, 15.0], -21.0), - ([15.0, 20.0, 20.0], -25.0), - ], - ) - await self.run_test( - 3, - lambda c2, c4, c5: c2 + c4 + c5, - [ - ([6.0, 12.0, 15.0], 33.0), - ([15.0, 20.0, 20.0], 55.0), - ], - ) - await self.run_test( - 3, - lambda c2, c4, c5: c2 / c4 / c5, - [ - ([30.0, 3.0, 5.0], 2.0), - ([15.0, 3.0, 2.0], 2.5), - ], - ) - - async def test_min_max(self) -> None: - """Test min and max functions in combination.""" - await self.run_test( - 3, - lambda c2, c4, c5: c2.min(c4).max(c5), - [ - ([4.0, 6.0, 5.0], 5.0), - ], - ) - - async def test_max(self) -> None: - """Test the max function.""" - await self.run_test( - 3, - lambda c2, c4, c5: c2 * c4.max(c5), - [ - ([10.0, 12.0, 15.0], 150.0), - ], - ) - await self.run_test( - 3, - lambda c2, c4, c5: (c2 * c4).max(c5), - [ - ([10.0, 12.0, 15.0], 120.0), - ], - ) - await self.run_test( - 3, - lambda c2, c4, c5: (c2 + c4).max(c5), - [ - ([10.0, 12.0, 15.0], 22.0), - ], - ) - await self.run_test( - 3, - lambda c2, c4, c5: c2 + c4.max(c5), - [ - ([10.0, 12.0, 15.0], 25.0), - ], - ) - - async def test_min(self) -> None: - """Test the min function.""" - await self.run_test( - 3, - lambda c2, c4, c5: (c2 * c4).min(c5), - [ - ([10.0, 12.0, 15.0], 15.0), - ], - ) - await self.run_test( - 3, - lambda c2, c4, c5: c2 * c4.min(c5), - [ - ([10.0, 12.0, 15.0], 120.0), - ], - ) - await self.run_test( - 3, - lambda c2, c4, c5: (c2 + c4).min(c5), - [ - ([10.0, 2.0, 15.0], 12.0), - ], - ) - await self.run_test( - 3, - lambda c2, c4, c5: c2 + c4.min(c5), - [ - ([10.0, 12.0, 15.0], 22.0), - ], - ) - - async def test_compound(self) -> None: - """Test compound formulas.""" - await self.run_test( - 4, - lambda c2, c4, c5, c6: c2 + c4 - c5 * c6, - [ - ([10.0, 12.0, 15.0, 2.0], -8.0), - ([15.0, 17.0, 20.0, 1.5], 2.0), - ], - ) - await self.run_test( - 4, - lambda c2, c4, c5, c6: c2 + (c4 - c5) * c6, - [ - ([10.0, 12.0, 15.0, 2.0], 4.0), - ([15.0, 17.0, 20.0, 1.5], 10.5), - ], - ) - await self.run_test( - 4, - lambda c2, c4, c5, c6: c2 + (c4 - c5 * c6), - [ - ([10.0, 12.0, 15.0, 2.0], -8.0), - ([15.0, 17.0, 20.0, 1.5], 2.0), - ], - ) - await self.run_test( - 4, - lambda c2, c4, c5, c6: c2 + (c4 - c5 - c6), - [ - ([10.0, 12.0, 15.0, 2.0], 5.0), - ([15.0, 17.0, 20.0, 1.5], 10.5), - ], - ) - await self.run_test( - 4, - lambda c2, c4, c5, c6: c2 + c4 - c5 - c6, - [ - ([10.0, 12.0, 15.0, 2.0], 5.0), - ([15.0, 17.0, 20.0, 1.5], 10.5), - ], - ) - await self.run_test( - 4, - lambda c2, c4, c5, c6: c2 + c4 - (c5 - c6), - [ - ([10.0, 12.0, 15.0, 2.0], 9.0), - ([15.0, 17.0, 20.0, 1.5], 13.5), - ], - ) - await self.run_test( - 4, - lambda c2, c4, c5, c6: (c2 + c4 - c5) * c6, - [ - ([10.0, 12.0, 15.0, 2.0], 14.0), - ([15.0, 17.0, 20.0, 1.5], 18.0), - ], - ) - await self.run_test( - 4, - lambda c2, c4, c5, c6: (c2 + c4 - c5) / c6, - [ - ([10.0, 12.0, 15.0, 2.0], 3.5), - ([15.0, 17.0, 20.0, 1.5], 8.0), - ], - ) - await self.run_test( - 4, - lambda c2, c4, c5, c6: c2 + c4 - (c5 / c6), - [ - ([10.0, 12.0, 15.0, 2.0], 14.5), - ([15.0, 17.0, 20.0, 5.0], 28.0), - ], - ) - - async def test_consumption(self) -> None: - """Test the consumption operator.""" - await self.run_test( - 1, - lambda c1: c1.consumption(), - [ - ([10.0], 10.0), - ([-10.0], 0.0), - ], - ) - - async def test_production(self) -> None: - """Test the production operator.""" - await self.run_test( - 1, - lambda c1: c1.production(), - [ - ([10.0], 0.0), - ([-10.0], 10.0), - ], - ) - - async def test_consumption_production(self) -> None: - """Test the consumption and production operator combined.""" - await self.run_test( - 2, - lambda c1, c2: c1.consumption() + c2.production(), - [ - ([10.0, 12.0], 10.0), - ([-12.0, -10.0], 10.0), - ], - ) - await self.run_test( - 2, - lambda c1, c2: c1.consumption() + c2.consumption(), - [ - ([10.0, -12.0], 10.0), - ([-10.0, 12.0], 12.0), - ], - ) - await self.run_test( - 2, - lambda c1, c2: c1.production() + c2.production(), - [ - ([10.0, -12.0], 12.0), - ([-10.0, 12.0], 10.0), - ], - ) - await self.run_test( - 2, - lambda c1, c2: c1.min(c2).consumption(), - [ - ([10.0, -12.0], 0.0), - ([10.0, 12.0], 10.0), - ], - ) - await self.run_test( - 2, - lambda c1, c2: c1.max(c2).consumption(), - [ - ([10.0, -12.0], 10.0), - ([-10.0, -12.0], 0.0), - ], - ) - await self.run_test( - 2, - lambda c1, c2: c1.min(c2).production(), - [ - ([10.0, -12.0], 12.0), - ([10.0, 12.0], 0.0), - ], - ) - await self.run_test( - 2, - lambda c1, c2: c1.max(c2).production(), - [ - ([10.0, -12.0], 0.0), - ([-10.0, -12.0], 10.0), - ], - ) - await self.run_test( - 2, - lambda c1, c2: c1.production() + c2, - [ - ([10.0, -12.0], -12.0), - ([-10.0, -12.0], -2.0), - ], - ) - - async def test_nones_are_zeros(self) -> None: - """Test that `None`s are treated as zeros when configured.""" - await self.run_test( - 3, - lambda c2, c4, c5: c2 - c4 + c5, - [ - ([10.0, 12.0, 15.0], 13.0), - ([None, 12.0, 15.0], 3.0), - ([10.0, None, 15.0], 25.0), - ([15.0, 17.0, 20.0], 18.0), - ([15.0, None, None], 15.0), - ], - True, - ) - - await self.run_test( - 4, - lambda c2, c4, c5, c6: c2 + c4 - (c5 * c6), - [ - ([10.0, 12.0, 15.0, 2.0], -8.0), - ([10.0, 12.0, 15.0, None], 22.0), - ([10.0, None, 15.0, 2.0], -20.0), - ([15.0, 17.0, 20.0, 5.0], -68.0), - ([15.0, 17.0, None, 5.0], 32.0), - ], - True, - ) - - async def test_nones_are_not_zeros(self) -> None: - """Test that calculated values are `None` on input `None`s.""" - await self.run_test( - 3, - lambda c2, c4, c5: c2 - c4 + c5, - [ - ([10.0, 12.0, 15.0], 13.0), - ([None, 12.0, 15.0], None), - ([10.0, None, 15.0], None), - ([15.0, 17.0, 20.0], 18.0), - ([15.0, None, None], None), - ], - False, - ) - - await self.run_test( - 4, - lambda c2, c4, c5, c6: c2 + c4 - (c5 * c6), - [ - ([10.0, 12.0, 15.0, 2.0], -8.0), - ([10.0, 12.0, 15.0, None], None), - ([10.0, None, 15.0, 2.0], None), - ([15.0, 17.0, 20.0, 5.0], -68.0), - ([15.0, 17.0, None, 5.0], None), - ], - False, - ) - - -class TestConstantValue: - """Tests for the constant value step.""" - - async def test_constant_value(self) -> None: - """Test using constant values in formulas.""" - channel_1 = Broadcast[Sample[Quantity]](name="channel_1") - channel_2 = Broadcast[Sample[Quantity]](name="channel_2") - - sender_1 = channel_1.new_sender() - sender_2 = channel_2.new_sender() - - builder = FormulaBuilder("test_constant_value", create_method=Quantity) - builder.push_metric( - "channel_1", channel_1.new_receiver(), nones_are_zeros=False - ) - builder.push_oper("+") - builder.push_constant(2.0) - builder.push_oper("*") - builder.push_metric( - "channel_2", channel_2.new_receiver(), nones_are_zeros=False - ) - - engine = builder.build() - - results_rx = engine.new_receiver() - - now = datetime.now() - await sender_1.send(Sample(now, Quantity(10.0))) - await sender_2.send(Sample(now, Quantity(15.0))) - assert (await results_rx.receive()).value == Quantity(40.0) - - await sender_1.send(Sample(now, Quantity(-10.0))) - await sender_2.send(Sample(now, Quantity(15.0))) - assert (await results_rx.receive()).value == Quantity(20.0) - - builder = FormulaBuilder("test_constant_value", create_method=Quantity) - builder.push_oper("(") - builder.push_metric( - "channel_1", channel_1.new_receiver(), nones_are_zeros=False - ) - builder.push_oper("+") - builder.push_constant(2.0) - builder.push_oper(")") - builder.push_oper("*") - builder.push_metric( - "channel_2", channel_2.new_receiver(), nones_are_zeros=False - ) - - engine = builder.build() - - results_rx = engine.new_receiver() - - now = datetime.now() - await sender_1.send(Sample(now, Quantity(10.0))) - await sender_2.send(Sample(now, Quantity(15.0))) - assert (await results_rx.receive()).value == Quantity(180.0) - - await sender_1.send(Sample(now, Quantity(-10.0))) - await sender_2.send(Sample(now, Quantity(15.0))) - assert (await results_rx.receive()).value == Quantity(-120.0) - - -class TestClipper: - """Tests for the clipper step.""" - - async def test_clipper(self) -> None: - """Test the usage of clipper in formulas.""" - channel_1 = Broadcast[Sample[Quantity]](name="channel_1") - channel_2 = Broadcast[Sample[Quantity]](name="channel_2") - - sender_1 = channel_1.new_sender() - sender_2 = channel_2.new_sender() - - builder = FormulaBuilder("test_clipper", create_method=Quantity) - builder.push_metric( - "channel_1", channel_1.new_receiver(), nones_are_zeros=False - ) - builder.push_oper("+") - builder.push_metric( - "channel_2", channel_2.new_receiver(), nones_are_zeros=False - ) - builder.push_clipper(0.0, 100.0) - engine = builder.build() - - results_rx = engine.new_receiver() - - now = datetime.now() - await sender_1.send(Sample(now, Quantity(10.0))) - await sender_2.send(Sample(now, Quantity(150.0))) - assert (await results_rx.receive()).value == Quantity(110.0) - - await sender_1.send(Sample(now, Quantity(200.0))) - await sender_2.send(Sample(now, Quantity(-10.0))) - assert (await results_rx.receive()).value == Quantity(200.0) - - await sender_1.send(Sample(now, Quantity(200.0))) - await sender_2.send(Sample(now, Quantity(10.0))) - assert (await results_rx.receive()).value == Quantity(210.0) - - builder = FormulaBuilder("test_clipper", create_method=Quantity) - builder.push_oper("(") - builder.push_metric( - "channel_1", channel_1.new_receiver(), nones_are_zeros=False - ) - builder.push_oper("+") - builder.push_metric( - "channel_2", channel_2.new_receiver(), nones_are_zeros=False - ) - builder.push_oper(")") - builder.push_clipper(0.0, 100.0) - engine = builder.build() - - results_rx = engine.new_receiver() - - now = datetime.now() - await sender_1.send(Sample(now, Quantity(10.0))) - await sender_2.send(Sample(now, Quantity(150.0))) - assert (await results_rx.receive()).value == Quantity(100.0) - - await sender_1.send(Sample(now, Quantity(200.0))) - await sender_2.send(Sample(now, Quantity(-10.0))) - assert (await results_rx.receive()).value == Quantity(100.0) - - await sender_1.send(Sample(now, Quantity(25.0))) - await sender_2.send(Sample(now, Quantity(-10.0))) - assert (await results_rx.receive()).value == Quantity(15.0) - - -class TestFormulaOutputTyping: - """Tests for the typing of the output of formulas.""" - - async def test_types(self) -> None: - """Test the typing of the output of formulas.""" - channel_1 = Broadcast[Sample[Power]](name="channel_1") - channel_2 = Broadcast[Sample[Power]](name="channel_2") - - sender_1 = channel_1.new_sender() - sender_2 = channel_2.new_sender() - - builder = FormulaBuilder("test_typing", create_method=Power.from_watts) - builder.push_metric( - "channel_1", channel_1.new_receiver(), nones_are_zeros=False - ) - builder.push_oper("+") - builder.push_metric( - "channel_2", channel_2.new_receiver(), nones_are_zeros=False - ) - engine = builder.build() - - results_rx = engine.new_receiver() - - now = datetime.now() - await sender_1.send(Sample(now, Power.from_watts(10.0))) - await sender_2.send(Sample(now, Power.from_watts(150.0))) - result = await results_rx.receive() - assert result is not None and result.value is not None - assert result.value.as_watts() == 160.0 - - -class TestFromReceiver: - """Test creating a formula engine from a receiver.""" - - async def test_from_receiver(self) -> None: - """Test creating a formula engine from a receiver.""" - channel = Broadcast[Sample[Power]](name="channel_1") - sender = channel.new_sender() - - builder = FormulaBuilder("test_from_receiver", create_method=Power.from_watts) - builder.push_metric("channel_1", channel.new_receiver(), nones_are_zeros=False) - engine = builder.build() - - engine_from_receiver = FormulaEngine.from_receiver( - "test_from_receiver", engine.new_receiver(), create_method=Power.from_watts - ) - - results_rx = engine_from_receiver.new_receiver() - - await sender.send(Sample(datetime.now(), Power.from_watts(10.0))) - result = await results_rx.receive() - assert result is not None and result.value is not None - assert result.value.as_watts() == 10.0 diff --git a/tests/timeseries/test_formula_formatter.py b/tests/timeseries/test_formula_formatter.py deleted file mode 100644 index b7b736cb5..000000000 --- a/tests/timeseries/test_formula_formatter.py +++ /dev/null @@ -1,140 +0,0 @@ -# License: MIT -# Copyright © 2023 Frequenz Energy-as-a-Service GmbH - -"""Tests for the FormulaFormatter.""" - - -from frequenz.channels import Broadcast -from frequenz.quantities import Quantity - -from frequenz.sdk.timeseries import Sample -from frequenz.sdk.timeseries.formula_engine._formula_engine import FormulaBuilder -from frequenz.sdk.timeseries.formula_engine._formula_formatter import format_formula -from frequenz.sdk.timeseries.formula_engine._formula_steps import ( - Clipper, - ConstantValue, - FormulaStep, - Maximizer, - Minimizer, -) -from frequenz.sdk.timeseries.formula_engine._tokenizer import Tokenizer, TokenType - - -def build_formula(formula: str) -> list[FormulaStep]: - """Parse the formula and returns the steps. - - Args: - formula: The formula in infix notation. - - Returns: - The formula in postfix notation steps. - """ - channels: dict[str, Broadcast[Sample[Quantity]]] = {} - builder = FormulaBuilder("test_formula", Quantity) - nones_are_zeros = True - - for token in Tokenizer(formula): - if token.type == TokenType.COMPONENT_METRIC: - if token.value not in channels: - channels[token.value] = Broadcast(name=token.value) - builder.push_metric( - name=f"#{token.value}", - data_stream=channels[token.value].new_receiver(), - nones_are_zeros=nones_are_zeros, - ) - elif token.type == TokenType.OPER: - builder.push_oper(token.value) - steps, _ = builder.finalize() - return steps - - -def reconstruct(formula: str) -> str: - """Parse the formula and reconstructs it from the steps. - - Args: - formula: The formula in infix notation. - - Returns: - The reconstructed formula in infix notation. - """ - steps = build_formula(formula) - reconstructed = format_formula(steps) - if formula != reconstructed: - print(f"Formula: input {formula} != output {reconstructed}") - return reconstructed - - -class TestFormulaFormatter: - """Tests for the FormulaFormatter.""" - - async def test_basic_precedence(self) -> None: - """Test that the formula is wrapped in parentheses for operators of different precedence.""" - assert reconstruct("#2 + #3 * #4") == "#2 + #3 * #4" - - def test_all_same_precedence(self) -> None: - """Test that the formula is not wrapped in parentheses for operators of same precedence.""" - assert reconstruct("#2 + #3 + #4") == "#2 + #3 + #4" - - def test_lhs_precedence(self) -> None: - """Test that the left-hand side of a binary operation is wrapped in parentheses.""" - assert reconstruct("(#2 - #3) - #4") == "#2 - #3 - #4" - assert reconstruct("#2 - #3 - #4") == "#2 - #3 - #4" - assert reconstruct("(#2 - #3) * #4") == "(#2 - #3) * #4" - - def test_rhs_precedence(self) -> None: - """Test that the right-hand side of a binary operation is wrapped in parentheses if needed.""" - assert reconstruct("#2 + #3") == "#2 + #3" - assert reconstruct("#2 - #3") == "#2 - #3" - assert reconstruct("#2 + #3 + #4") == "#2 + #3 + #4" - assert reconstruct("#2 - #3 - #4") == "#2 - #3 - #4" - assert reconstruct("#2 - #3 * #4") == "#2 - #3 * #4" - assert reconstruct("#2 - (#3 * #4)") == "#2 - #3 * #4" - assert reconstruct("#2 - (#3 - #4)") == "#2 - (#3 - #4)" - assert reconstruct("#2 - (#3 + #4)") == "#2 - (#3 + #4)" - - def test_rhs_parenthesis(self) -> None: - """Test that the right-hand side of a binary operation is wrapped in parentheses.""" - assert reconstruct("#2 / (#3 - #4)") == "#2 / (#3 - #4)" - - def test_functions(self) -> None: - """Test that the functions are formatted correctly.""" - # For simplicity, we only test with constant values. - # fmt: off - # flake8: noqa: E501 - assert format_formula([ConstantValue(2), ConstantValue(3), Minimizer()]) == "min(2, 3)" - assert format_formula([ConstantValue(2), ConstantValue(3), Maximizer()]) == "max(2, 3)" - assert format_formula([ConstantValue(3.5), Clipper(0.0, 1.0)]) == "clip(0.0, 3.5, 1.0)" - # flake8: enable - # fmt: on - - async def test_higher_order_formula(self) -> None: - """Test that higher-order formulas (formulas combining other formulas) are formatted correctly.""" - # Create two base formulas - builder1 = FormulaBuilder("test_formula1", Quantity) - builder2 = FormulaBuilder("test_formula2", Quantity) - - # Push metrics directly to the builders - channel1 = Broadcast[Sample[Quantity]](name="channel1") - channel2 = Broadcast[Sample[Quantity]](name="channel2") - builder1.push_metric("#1", channel1.new_receiver(), nones_are_zeros=True) - builder1.push_oper("+") - builder1.push_metric("#2", channel2.new_receiver(), nones_are_zeros=True) - - channel3 = Broadcast[Sample[Quantity]](name="channel3") - channel4 = Broadcast[Sample[Quantity]](name="channel4") - builder2.push_metric("#3", channel3.new_receiver(), nones_are_zeros=True) - builder2.push_oper("+") - builder2.push_metric("#4", channel4.new_receiver(), nones_are_zeros=True) - - # Build individual formula engines first - engine1 = builder1.build() - engine2 = builder2.build() - - # Combine them into a higher-order formula - composed_formula = (engine1 - engine2).build("higher_order_formula") - - # Check the string representation - assert ( - str(composed_formula) - == "[test_formula1](#1 + #2) - [test_formula2](#3 + #4)" - ) From b1832515ae70592e0bd9bee0950a28dd6ff80bbb Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Thu, 20 Nov 2025 23:16:20 +0100 Subject: [PATCH 21/23] Document the new Formula implementation Signed-off-by: Sahas Subramanian --- .../sdk/timeseries/formulas/__init__.py | 109 +++++++++++++++++- 1 file changed, 108 insertions(+), 1 deletion(-) diff --git a/src/frequenz/sdk/timeseries/formulas/__init__.py b/src/frequenz/sdk/timeseries/formulas/__init__.py index 46b6938ab..f00065592 100644 --- a/src/frequenz/sdk/timeseries/formulas/__init__.py +++ b/src/frequenz/sdk/timeseries/formulas/__init__.py @@ -1,7 +1,114 @@ # License: MIT # Copyright © 2025 Frequenz Energy-as-a-Service GmbH -"""Formulas on telemetry streams.""" +"""Provides a way for the SDK to apply formulas on resampled data streams. + +# Formulas + +[`Formula`][frequenz.sdk.timeseries.formulas.Formula]s are used in the SDK to +calculate and stream metrics like +[`grid_power`][frequenz.sdk.timeseries.grid.Grid.power], +[`consumer_power`][frequenz.sdk.timeseries.consumer.Consumer.power], etc., which +are building blocks of the [Frequenz SDK Microgrid +Model][frequenz.sdk.microgrid--frequenz-sdk-microgrid-model]. + +The SDK creates the formulas by analysing the configuration of components in the +{{glossary("Component Graph")}}. + +## Streaming Interface + +The +[`Formula.new_receiver()`][frequenz.sdk.timeseries.formulas.Formula.new_receiver] +method can be used to create a [Receiver][frequenz.channels.Receiver] that streams the +[Sample][frequenz.sdk.timeseries.Sample]s calculated by the evaluation of the formula. + +```python +from frequenz.sdk import microgrid + +battery_pool = microgrid.new_battery_pool(priority=5) + +async for power in battery_pool.power.new_receiver(): + print(f"{power=}") +``` + +## Composition + +Composite `Formula`s can be built using arithmetic operations on `Formula`s +streaming the same type of data. + +For example, if you're interested in a particular composite metric that can be +calculated by subtracting +[`new_battery_pool().power`][frequenz.sdk.timeseries.battery_pool.BatteryPool.power] +and +[`new_ev_charger_pool().power`][frequenz.sdk.timeseries.ev_charger_pool.EVChargerPool] +from the [`grid().power`][frequenz.sdk.timeseries.grid.Grid.power], we can build +a `Formula` that provides a stream of this calculated metric as follows: + +```python +from frequenz.sdk import microgrid + +battery_pool = microgrid.new_battery_pool(priority=5) +ev_charger_pool = microgrid.new_ev_charger_pool(priority=5) +grid = microgrid.grid() + +# apply operations on formulas to create a new formula that would +# apply these operations on the corresponding data streams. +net_power = ( + grid.power - (battery_pool.power + ev_charger_pool.power) +).build("net_power") + +async for power in net_power.new_receiver(): + print(f"{power=}") +``` + +# 3-Phase Formulas + +A [`Formula3Phase`][frequenz.sdk.timeseries.formulas.Formula3Phase] is similar +to a [`Formula`][frequenz.sdk.timeseries.formulas.Formula], except that it +streams [3-phase samples][frequenz.sdk.timeseries.Sample3Phase]. All the +current formulas (like +[`Grid.current_per_phase`][frequenz.sdk.timeseries.grid.Grid.current_per_phase], +[`EVChargerPool.current_per_phase`][frequenz.sdk.timeseries.ev_charger_pool.EVChargerPool.current_per_phase], +etc.) are implemented as 3-phase formulas. + +## Streaming Interface + +The +[`Formula3Phase.new_receiver()`][frequenz.sdk.timeseries.formulas.Formula3Phase.new_receiver] +method can be used to create a [Receiver][frequenz.channels.Receiver] that +streams the [Sample3Phase][frequenz.sdk.timeseries.Sample3Phase] values +calculated by 3-phase formulas. + +```python +from frequenz.sdk import microgrid + +ev_charger_pool = microgrid.new_ev_charger_pool(priority=5) + +async for sample in ev_charger_pool.current_per_phase.new_receiver(): + print(f"Current: {sample}") +``` + +## Composition + +`Formula3Phase` instances can be composed together, just like `Formula` +instances. + +```python +from frequenz.sdk import microgrid + +ev_charger_pool = microgrid.new_ev_charger_pool(priority=5) +grid = microgrid.grid() + +# Calculate grid consumption current that's not used by the EV chargers +other_current = (grid.current_per_phase - ev_charger_pool.current_per_phase).build( + "other_current" +) + +async for sample in other_current.new_receiver(): + print(f"Other current: {sample}") +``` +""" + from ._formula import Formula from ._formula_3_phase import Formula3Phase From 2470c10ece36c8aee08e7a0f658a78aef2eaba8b Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Thu, 20 Nov 2025 23:33:52 +0100 Subject: [PATCH 22/23] Remove all remaining references to FormulaEngines Signed-off-by: Sahas Subramanian --- .../{formula-engine.md => formulas.md} | 4 +- src/frequenz/sdk/microgrid/__init__.py | 2 +- .../timeseries/battery_pool/_battery_pool.py | 13 ++- .../_battery_pool_reference_store.py | 2 +- src/frequenz/sdk/timeseries/consumer.py | 10 +- .../ev_charger_pool/_ev_charger_pool.py | 12 +-- .../_ev_charger_pool_reference_store.py | 2 +- .../sdk/timeseries/formulas/_formula_pool.py | 82 +++++++------- src/frequenz/sdk/timeseries/grid.py | 28 ++--- .../logical_meter/_logical_meter.py | 15 ++- src/frequenz/sdk/timeseries/producer.py | 14 +-- .../sdk/timeseries/pv_pool/_pv_pool.py | 9 +- .../pv_pool/_pv_pool_reference_store.py | 2 +- .../_formulas/test_formula_composition.py | 102 +++++++++--------- tests/timeseries/_formulas/utils.py | 2 +- 15 files changed, 148 insertions(+), 151 deletions(-) rename docs/user-guide/{formula-engine.md => formulas.md} (76%) diff --git a/docs/user-guide/formula-engine.md b/docs/user-guide/formulas.md similarity index 76% rename from docs/user-guide/formula-engine.md rename to docs/user-guide/formulas.md index 5054544fc..5bead07cc 100644 --- a/docs/user-guide/formula-engine.md +++ b/docs/user-guide/formulas.md @@ -1,6 +1,6 @@ -# Formula Engine +# Formulas -::: frequenz.sdk.timeseries.formula_engine +::: frequenz.sdk.timeseries.formulas options: members: None show_bases: false diff --git a/src/frequenz/sdk/microgrid/__init__.py b/src/frequenz/sdk/microgrid/__init__.py index 5c10c0d00..8bc3d0bd5 100644 --- a/src/frequenz/sdk/microgrid/__init__.py +++ b/src/frequenz/sdk/microgrid/__init__.py @@ -153,7 +153,7 @@ ## Streaming component data All pools have a `power` property, which is a -[`FormulaEngine`][frequenz.sdk.timeseries.formula_engine.FormulaEngine] that can +[`Formula`][frequenz.sdk.timeseries.formulas.Formula] that can - provide a stream of resampled power values, which correspond to the sum of the power measured from all the components in the pool together. diff --git a/src/frequenz/sdk/timeseries/battery_pool/_battery_pool.py b/src/frequenz/sdk/timeseries/battery_pool/_battery_pool.py index fd1315bea..3be021a85 100644 --- a/src/frequenz/sdk/timeseries/battery_pool/_battery_pool.py +++ b/src/frequenz/sdk/timeseries/battery_pool/_battery_pool.py @@ -4,8 +4,8 @@ """An external interface for the BatteryPool. Allows for actors interested in operating on the same set of batteries to share -underlying formula engine and metric calculator instances, but without having to specify -their individual priorities with each request. +underlying formula and metric calculator instances, but without having to +specify their individual priorities with each request. """ import asyncio @@ -196,23 +196,22 @@ def power(self) -> Formula[Power]: This formula produces values that are in the Passive Sign Convention (PSC). - If a formula engine to calculate this metric is not already running, it will be + If a formula to calculate this metric is not already running, it will be started. - A receiver from the formula engine can be obtained by calling the `new_receiver` + A receiver from the formula can be obtained by calling the `new_receiver` method. Returns: - A FormulaEngine that will calculate and stream the total power of all + A Formula that will calculate and stream the total power of all batteries in the pool. """ - engine = self._pool_ref_store._formula_pool.from_power_formula( + return self._pool_ref_store._formula_pool.from_power_formula( "battery_pool_power", connection_manager.get().component_graph.battery_formula( self._pool_ref_store._batteries ), ) - return engine @property def soc(self) -> ReceiverFetcher[Sample[Percentage]]: diff --git a/src/frequenz/sdk/timeseries/battery_pool/_battery_pool_reference_store.py b/src/frequenz/sdk/timeseries/battery_pool/_battery_pool_reference_store.py index d24b866d4..382809082 100644 --- a/src/frequenz/sdk/timeseries/battery_pool/_battery_pool_reference_store.py +++ b/src/frequenz/sdk/timeseries/battery_pool/_battery_pool_reference_store.py @@ -29,7 +29,7 @@ class BatteryPoolReferenceStore: # pylint: disable=too-many-instance-attributes """A class for maintaining the shared state/tasks for a set of pool of batteries. This includes ownership of - - the formula engine pool and metric calculators. + - the formula pool and metric calculators. - the tasks for updating the battery status for the metric calculators. These are independent of the priority of the actors and can be shared between diff --git a/src/frequenz/sdk/timeseries/consumer.py b/src/frequenz/sdk/timeseries/consumer.py index 53274e24f..0376c0444 100644 --- a/src/frequenz/sdk/timeseries/consumer.py +++ b/src/frequenz/sdk/timeseries/consumer.py @@ -53,7 +53,7 @@ class Consumer: """ _formula_pool: FormulaPool - """The formula engine pool to generate consumer metrics.""" + """The formula pool to generate consumer metrics.""" def __init__( self, @@ -79,14 +79,14 @@ def power(self) -> Formula[Power]: This formula produces values that are in the Passive Sign Convention (PSC). - It will start the formula engine to calculate consumer power if it is + It will start the formula to calculate consumer power if it is not already running. - A receiver from the formula engine can be created using the + A receiver from the formula can be created using the `new_receiver` method. Returns: - A FormulaEngine that will calculate and stream consumer power. + A Formula that will calculate and stream consumer power. """ return self._formula_pool.from_power_formula( "consumer_power", @@ -94,5 +94,5 @@ def power(self) -> Formula[Power]: ) async def stop(self) -> None: - """Stop all formula engines.""" + """Stop all formulas.""" await self._formula_pool.stop() diff --git a/src/frequenz/sdk/timeseries/ev_charger_pool/_ev_charger_pool.py b/src/frequenz/sdk/timeseries/ev_charger_pool/_ev_charger_pool.py index b74b037c5..77327eb7d 100644 --- a/src/frequenz/sdk/timeseries/ev_charger_pool/_ev_charger_pool.py +++ b/src/frequenz/sdk/timeseries/ev_charger_pool/_ev_charger_pool.py @@ -119,14 +119,14 @@ def current_per_phase(self) -> Formula3Phase[Current]: This formula produces values that are in the Passive Sign Convention (PSC). - If a formula engine to calculate EV Charger current is not already running, it + If a formula to calculate EV Charger current is not already running, it will be started. - A receiver from the formula engine can be created using the `new_receiver` + A receiver from the formula can be created using the `new_receiver` method. Returns: - A FormulaEngine that will calculate and stream the total current of all EV + A Formula that will calculate and stream the total current of all EV Chargers. """ return self._pool_ref_store.formula_pool.from_current_3_phase_formula( @@ -142,14 +142,14 @@ def power(self) -> Formula[Power]: This formula produces values that are in the Passive Sign Convention (PSC). - If a formula engine to calculate EV Charger power is not already running, it + If a formula to calculate EV Charger power is not already running, it will be started. - A receiver from the formula engine can be created using the `new_receiver` + A receiver from the formula can be created using the `new_receiver` method. Returns: - A FormulaEngine that will calculate and stream the total power of all EV + A Formula that will calculate and stream the total power of all EV Chargers. """ return self._pool_ref_store.formula_pool.from_power_formula( diff --git a/src/frequenz/sdk/timeseries/ev_charger_pool/_ev_charger_pool_reference_store.py b/src/frequenz/sdk/timeseries/ev_charger_pool/_ev_charger_pool_reference_store.py index b621e0791..0e4542aa7 100644 --- a/src/frequenz/sdk/timeseries/ev_charger_pool/_ev_charger_pool_reference_store.py +++ b/src/frequenz/sdk/timeseries/ev_charger_pool/_ev_charger_pool_reference_store.py @@ -25,7 +25,7 @@ class EVChargerPoolReferenceStore: """A class for maintaining the shared state/tasks for a set of pool of EV chargers. This includes ownership of - - the formula engine pool and metric calculators. + - the formula pool and metric calculators. - the tasks for calculating system bounds for the EV chargers. These are independent of the priority of the actors and can be shared between diff --git a/src/frequenz/sdk/timeseries/formulas/_formula_pool.py b/src/frequenz/sdk/timeseries/formulas/_formula_pool.py index ca6a3cf7a..d0887c7e2 100644 --- a/src/frequenz/sdk/timeseries/formulas/_formula_pool.py +++ b/src/frequenz/sdk/timeseries/formulas/_formula_pool.py @@ -28,17 +28,17 @@ """The component ID for non-existent components in the components graph. The non-existing component ID is commonly used in scenarios where a formula -engine requires a component ID but there are no available components in the -graph to associate with it. Thus, the non-existing component ID is subscribed -instead so that the formula engine can send `None` or `0` values at the same -frequency as the other streams. +requires a component ID but there are no available components in the graph to +associate with it. Thus, the non-existing component ID is subscribed instead so +that the formula can send `None` or `0` values at the same frequency as the +other streams. """ class FormulaPool: - """Creates and owns formula engines from string formulas, or formula generators. + """Creates and owns formulas from string formulas. - If an engine already exists with a given name, it is reused instead. + If a formula already exists with a given name, it is reused instead. """ def __init__( @@ -62,13 +62,13 @@ def __init__( resampler_subscription_sender ) - self._string_engines: dict[str, Formula[Quantity]] = {} - self._power_engines: dict[str, Formula[Power]] = {} - self._reactive_power_engines: dict[str, Formula[ReactivePower]] = {} - self._current_engines: dict[str, Formula3Phase[Current]] = {} + self._string_formulas: dict[str, Formula[Quantity]] = {} + self.power_formulas: dict[str, Formula[Power]] = {} + self._reactive_power_formulas: dict[str, Formula[ReactivePower]] = {} + self._current_formulas: dict[str, Formula3Phase[Current]] = {} - self._power_3_phase_engines: dict[str, Formula3Phase[Power]] = {} - self._current_3_phase_engines: dict[str, Formula3Phase[Current]] = {} + self._power_3_phase_formulas: dict[str, Formula3Phase[Power]] = {} + self._current_3_phase_formulas: dict[str, Formula3Phase[Current]] = {} def from_string( self, @@ -83,18 +83,18 @@ def from_string( actor. Returns: - A Formula engine that streams values with the formulas applied. + A Formula that streams values with the formulas applied. """ channel_key = formula_str + str(metric.value) - if channel_key in self._string_engines: - return self._string_engines[channel_key] + if channel_key in self._string_formulas: + return self._string_formulas[channel_key] formula = parse( name=channel_key, formula=formula_str, telemetry_fetcher=self._telemetry_fetcher(metric), create_method=Quantity, ) - self._string_engines[channel_key] = formula + self._string_formulas[channel_key] = formula return formula def from_power_formula(self, channel_key: str, formula_str: str) -> Formula[Power]: @@ -106,10 +106,10 @@ def from_power_formula(self, channel_key: str, formula_str: str) -> Formula[Powe formula_str: The formula string. Returns: - A formula engine that evaluates the given formula. + A formula that evaluates the given formula. """ - if channel_key in self._power_engines: - return self._power_engines[channel_key] + if channel_key in self.power_formulas: + return self.power_formulas[channel_key] if formula_str == "0.0": formula_str = f"coalesce(#{NON_EXISTING_COMPONENT_ID}, 0.0)" @@ -120,7 +120,7 @@ def from_power_formula(self, channel_key: str, formula_str: str) -> Formula[Powe telemetry_fetcher=self._telemetry_fetcher(Metric.AC_POWER_ACTIVE), create_method=Power.from_watts, ) - self._power_engines[channel_key] = formula + self.power_formulas[channel_key] = formula return formula @@ -135,10 +135,10 @@ def from_reactive_power_formula( formula_str: The formula string. Returns: - A formula engine that evaluates the given formula. + A formula that evaluates the given formula. """ - if channel_key in self._power_engines: - return self._reactive_power_engines[channel_key] + if channel_key in self.power_formulas: + return self._reactive_power_formulas[channel_key] if formula_str == "0.0": formula_str = f"coalesce(#{NON_EXISTING_COMPONENT_ID}, 0.0)" @@ -149,7 +149,7 @@ def from_reactive_power_formula( telemetry_fetcher=self._telemetry_fetcher(Metric.AC_POWER_REACTIVE), create_method=ReactivePower.from_volt_amperes_reactive, ) - self._reactive_power_engines[channel_key] = formula + self._reactive_power_formulas[channel_key] = formula return formula @@ -164,10 +164,10 @@ def from_power_3_phase_formula( formula_str: The formula string. Returns: - A formula engine that evaluates the given formula. + A formula that evaluates the given formula. """ - if channel_key in self._power_3_phase_engines: - return self._power_3_phase_engines[channel_key] + if channel_key in self._power_3_phase_formulas: + return self._power_3_phase_formulas[channel_key] if formula_str == "0.0": formula_str = f"coalesce(#{NON_EXISTING_COMPONENT_ID}, 0.0)" @@ -199,7 +199,7 @@ def from_power_3_phase_formula( create_method=Power.from_watts, ), ) - self._power_3_phase_engines[channel_key] = formula + self._power_3_phase_formulas[channel_key] = formula return formula @@ -214,10 +214,10 @@ def from_current_3_phase_formula( formula_str: The formula string. Returns: - A formula engine that evaluates the given formula. + A formula that evaluates the given formula. """ - if channel_key in self._current_3_phase_engines: - return self._current_3_phase_engines[channel_key] + if channel_key in self._current_3_phase_formulas: + return self._current_3_phase_formulas[channel_key] if formula_str == "0.0": formula_str = f"coalesce(#{NON_EXISTING_COMPONENT_ID}, 0.0)" @@ -243,27 +243,27 @@ def from_current_3_phase_formula( create_method=Current.from_amperes, ), ) - self._current_3_phase_engines[channel_key] = formula + self._current_3_phase_formulas[channel_key] = formula return formula async def stop(self) -> None: - """Stop all formula engines.""" - for pf in self._power_engines.values(): + """Stop all formulas.""" + for pf in self.power_formulas.values(): await pf.stop() - self._power_engines.clear() + self.power_formulas.clear() - for rpf in self._reactive_power_engines.values(): + for rpf in self._reactive_power_formulas.values(): await rpf.stop() - self._reactive_power_engines.clear() + self._reactive_power_formulas.clear() - for p3pf in self._power_3_phase_engines.values(): + for p3pf in self._power_3_phase_formulas.values(): await p3pf.stop() - self._power_3_phase_engines.clear() + self._power_3_phase_formulas.clear() - for c3pf in self._current_3_phase_engines.values(): + for c3pf in self._current_3_phase_formulas.values(): await c3pf.stop() - self._current_3_phase_engines.clear() + self._current_3_phase_formulas.clear() def _telemetry_fetcher(self, metric: Metric) -> ResampledStreamFetcher: """Create a ResampledStreamFetcher for the given metric. diff --git a/src/frequenz/sdk/timeseries/grid.py b/src/frequenz/sdk/timeseries/grid.py index 5381751dc..b79a690f9 100644 --- a/src/frequenz/sdk/timeseries/grid.py +++ b/src/frequenz/sdk/timeseries/grid.py @@ -67,7 +67,7 @@ class Grid: """ _formula_pool: FormulaPool - """The formula engine pool to generate grid metrics.""" + """The formula pool to generate grid metrics.""" @property def power(self) -> Formula[Power]: @@ -75,14 +75,14 @@ def power(self) -> Formula[Power]: This formula produces values that are in the Passive Sign Convention (PSC). - If a formula engine to calculate grid power is not already running, it will be + If a formula to calculate grid power is not already running, it will be started. - A receiver from the formula engine can be created using the `new_receiver` + A receiver from the formula can be created using the `new_receiver` method. Returns: - A FormulaEngine that will calculate and stream grid power. + A Formula that will calculate and stream grid power. """ return self._formula_pool.from_power_formula( "grid_power", @@ -95,14 +95,14 @@ def reactive_power(self) -> Formula[ReactivePower]: This formula produces values that are in the Passive Sign Convention (PSC). - If a formula engine to calculate grid power is not already running, it will be + If a formula to calculate grid power is not already running, it will be started. - A receiver from the formula engine can be created using the `new_receiver` + A receiver from the formula can be created using the `new_receiver` method. Returns: - A FormulaEngine that will calculate and stream grid reactive power. + A Formula that will calculate and stream grid reactive power. """ return self._formula_pool.from_reactive_power_formula( "grid_reactive_power", @@ -115,11 +115,11 @@ def _power_per_phase(self) -> Formula3Phase[Power]: This formula produces values that are in the Passive Sign Convention (PSC). - A receiver from the formula engine can be created using the + A receiver from the formula can be created using the `new_receiver`method. Returns: - A FormulaEngine that will calculate and stream grid 3-phase power. + A Formula that will calculate and stream grid 3-phase power. """ return self._formula_pool.from_power_3_phase_formula( "grid_power_3_phase", @@ -132,14 +132,14 @@ def current_per_phase(self) -> Formula3Phase[Current]: This formula produces values that are in the Passive Sign Convention (PSC). - If a formula engine to calculate grid current is not already running, it will be - started. + If a formula to calculate grid current is not already running, it will + be started. - A receiver from the formula engine can be created using the `new_receiver` + A receiver from the formula can be created using the `new_receiver` method. Returns: - A FormulaEngine that will calculate and stream grid current. + A Formula that will calculate and stream grid current. """ return self._formula_pool.from_current_3_phase_formula( "grid_current", @@ -147,7 +147,7 @@ def current_per_phase(self) -> Formula3Phase[Current]: ) async def stop(self) -> None: - """Stop all formula engines.""" + """Stop all formulas.""" await self._formula_pool.stop() diff --git a/src/frequenz/sdk/timeseries/logical_meter/_logical_meter.py b/src/frequenz/sdk/timeseries/logical_meter/_logical_meter.py index 5c0466d2d..376219281 100644 --- a/src/frequenz/sdk/timeseries/logical_meter/_logical_meter.py +++ b/src/frequenz/sdk/timeseries/logical_meter/_logical_meter.py @@ -94,7 +94,7 @@ def start_formula( ) -> Formula[Quantity]: """Start execution of the given formula. - TODO: link to formula engine for formula syntax. + TODO: link to formula syntax. Formulas can have Component IDs that are preceeded by a pound symbol("#"), and these operators: +, -, *, /, (, ). @@ -107,7 +107,7 @@ def start_formula( metric: The metric to use when fetching receivers from the resampling actor. Returns: - A FormulaEngine that applies the formula and streams values. + A Formula that applies the formula and streams values. """ return self._formula_pool.from_string(formula, metric) @@ -117,21 +117,20 @@ def chp_power(self) -> Formula[Power]: This formula produces values that are in the Passive Sign Convention (PSC). - If a formula engine to calculate CHP power production is not already running, it + If a formula to calculate CHP power production is not already running, it will be started. - A receiver from the formula engine can be created using the `new_receiver` + A receiver from the formula can be created using the `new_receiver` method. Returns: - A FormulaEngine that will calculate and stream CHP power production. + A Formula that will calculate and stream CHP power production. """ - engine = self._formula_pool.from_power_formula( + return self._formula_pool.from_power_formula( channel_key="chp_power", formula_str=connection_manager.get().component_graph.chp_formula(None), ) - return engine async def stop(self) -> None: - """Stop all formula engines.""" + """Stop all formulas.""" await self._formula_pool.stop() diff --git a/src/frequenz/sdk/timeseries/producer.py b/src/frequenz/sdk/timeseries/producer.py index a87b146a0..0c82ae16c 100644 --- a/src/frequenz/sdk/timeseries/producer.py +++ b/src/frequenz/sdk/timeseries/producer.py @@ -53,7 +53,7 @@ class Producer: """ _formula_pool: FormulaPool - """The formula engine pool to generate producer metrics.""" + """The formula pool to generate producer metrics.""" def __init__( self, @@ -79,14 +79,14 @@ def power(self) -> Formula[Power]: This formula produces values that are in the Passive Sign Convention (PSC). - It will start the formula engine to calculate producer power if it is - not already running. + It will start the formula to calculate producer power if it is not + already running. - A receiver from the formula engine can be created using the - `new_receiver` method. + A receiver from the formula can be created using the `new_receiver` + method. Returns: - A FormulaEngine that will calculate and stream producer power. + A Formula that will calculate and stream producer power. """ return self._formula_pool.from_power_formula( "producer_power", @@ -94,5 +94,5 @@ def power(self) -> Formula[Power]: ) async def stop(self) -> None: - """Stop all formula engines.""" + """Stop all formulas.""" await self._formula_pool.stop() diff --git a/src/frequenz/sdk/timeseries/pv_pool/_pv_pool.py b/src/frequenz/sdk/timeseries/pv_pool/_pv_pool.py index 0407ce143..6bbb91e15 100644 --- a/src/frequenz/sdk/timeseries/pv_pool/_pv_pool.py +++ b/src/frequenz/sdk/timeseries/pv_pool/_pv_pool.py @@ -114,23 +114,22 @@ def power(self) -> Formula[Power]: This formula produces values that are in the Passive Sign Convention (PSC). - If a formula engine to calculate PV Inverter power is not already running, it + If a formula to calculate PV Inverter power is not already running, it will be started. - A receiver from the formula engine can be created using the `new_receiver` + A receiver from the formula can be created using the `new_receiver` method. Returns: - A FormulaEngine that will calculate and stream the total power of all PV + A Formula that will calculate and stream the total power of all PV Inverters. """ - engine = self._pool_ref_store.formula_pool.from_power_formula( + return self._pool_ref_store.formula_pool.from_power_formula( "pv_power", connection_manager.get().component_graph.pv_formula( self._pool_ref_store.component_ids ), ) - return engine @property def power_status(self) -> ReceiverFetcher[PVPoolReport]: diff --git a/src/frequenz/sdk/timeseries/pv_pool/_pv_pool_reference_store.py b/src/frequenz/sdk/timeseries/pv_pool/_pv_pool_reference_store.py index 43bca134b..fbba10561 100644 --- a/src/frequenz/sdk/timeseries/pv_pool/_pv_pool_reference_store.py +++ b/src/frequenz/sdk/timeseries/pv_pool/_pv_pool_reference_store.py @@ -26,7 +26,7 @@ class PVPoolReferenceStore: """A class for maintaining the shared state/tasks for a set of pool of PV inverters. This includes ownership of - - the formula engine pool and metric calculators. + - the formula pool and metric calculators. - the tasks for calculating system bounds for the PV inverters. These are independent of the priority of the actors and can be shared between diff --git a/tests/timeseries/_formulas/test_formula_composition.py b/tests/timeseries/_formulas/test_formula_composition.py index 1387bbff5..24a099a58 100644 --- a/tests/timeseries/_formulas/test_formula_composition.py +++ b/tests/timeseries/_formulas/test_formula_composition.py @@ -51,18 +51,18 @@ async def test_formula_composition( # pylint: disable=too-many-locals Metric.AC_ACTIVE_POWER, Power.from_watts, ) - engine = (pv_pool.power + battery_pool.power).build("inv_power") + formula = (pv_pool.power + battery_pool.power).build("inv_power") async with ( pv_pool.power as pv_power, battery_pool.power as battery_power, - engine, + formula, ): grid_power_recv = grid.power.new_receiver() battery_power_recv = battery_power.new_receiver() pv_power_recv = pv_power.new_receiver() - inv_calc_recv = engine.new_receiver() + inv_calc_recv = formula.new_receiver() await mockgrid.mock_resampler.send_bat_inverter_power( [10.0, 12.0, 14.0] @@ -131,15 +131,15 @@ async def test_formula_composition_missing_pv( # pylint: disable=too-many-local logical_meter = microgrid.logical_meter() stack.push_async_callback(logical_meter.stop) - engine = (pv_pool.power + battery_pool.power).build("inv_power") - stack.push_async_callback(engine.stop) + formula = (pv_pool.power + battery_pool.power).build("inv_power") + stack.push_async_callback(formula.stop) async with ( - engine, + formula, battery_pool.power as battery_power, pv_pool.power as pv_power, ): - inv_calc_recv = engine.new_receiver() + inv_calc_recv = formula.new_receiver() battery_power_recv = battery_power.new_receiver() pv_power_recv = pv_power.new_receiver() @@ -183,10 +183,10 @@ async def test_formula_composition_missing_bat(self, mocker: MockerFixture) -> N battery_power_recv = battery_pool.power.new_receiver() pv_power_recv = pv_pool.power.new_receiver() - engine = (pv_pool.power + battery_pool.power).build("inv_power") - stack.push_async_callback(engine.stop) + formula = (pv_pool.power + battery_pool.power).build("inv_power") + stack.push_async_callback(formula.stop) - inv_calc_recv = engine.new_receiver() + inv_calc_recv = formula.new_receiver() for _ in range(10): await mockgrid.mock_resampler.send_pv_inverter_power( @@ -218,23 +218,23 @@ async def test_formula_composition_min_max(self, mocker: MockerFixture) -> None: grid = microgrid.grid() stack.push_async_callback(grid.stop) - engine_min = grid.power.min([logical_meter.chp_power]).build( + formula_min = grid.power.min([logical_meter.chp_power]).build( "grid_power_min" ) - stack.push_async_callback(engine_min.stop) - engine_min_rx = engine_min.new_receiver() + stack.push_async_callback(formula_min.stop) + formula_min_rx = formula_min.new_receiver() - engine_max = grid.power.max([logical_meter.chp_power]).build( + formula_max = grid.power.max([logical_meter.chp_power]).build( "grid_power_max" ) - stack.push_async_callback(engine_max.stop) - engine_max_rx = engine_max.new_receiver() + stack.push_async_callback(formula_max.stop) + formula_max_rx = formula_max.new_receiver() await mockgrid.mock_resampler.send_meter_power([100.0, 200.0]) await mockgrid.mock_resampler.send_chp_power([None]) # Test min - min_pow = await engine_min_rx.receive() + min_pow = await formula_min_rx.receive() assert ( min_pow and min_pow.value @@ -242,7 +242,7 @@ async def test_formula_composition_min_max(self, mocker: MockerFixture) -> None: ) # Test max - max_pow = await engine_max_rx.receive() + max_pow = await formula_max_rx.receive() assert ( max_pow and max_pow.value @@ -253,7 +253,7 @@ async def test_formula_composition_min_max(self, mocker: MockerFixture) -> None: await mockgrid.mock_resampler.send_chp_power([None]) # Test min - min_pow = await engine_min_rx.receive() + min_pow = await formula_min_rx.receive() assert ( min_pow and min_pow.value @@ -261,7 +261,7 @@ async def test_formula_composition_min_max(self, mocker: MockerFixture) -> None: ) # Test max - max_pow = await engine_max_rx.receive() + max_pow = await formula_max_rx.receive() assert ( max_pow and max_pow.value @@ -282,22 +282,22 @@ async def test_formula_composition_min_max_const( grid = microgrid.grid() stack.push_async_callback(grid.stop) - engine_min = grid.power.min([Power.zero()]).build("grid_power_min") - stack.push_async_callback(engine_min.stop) - engine_min_rx = engine_min.new_receiver() + formula_min = grid.power.min([Power.zero()]).build("grid_power_min") + stack.push_async_callback(formula_min.stop) + formula_min_rx = formula_min.new_receiver() - engine_max = grid.power.max([Power.zero()]).build("grid_power_max") - stack.push_async_callback(engine_max.stop) - engine_max_rx = engine_max.new_receiver() + formula_max = grid.power.max([Power.zero()]).build("grid_power_max") + stack.push_async_callback(formula_max.stop) + formula_max_rx = formula_max.new_receiver() await mockgrid.mock_resampler.send_meter_power([100.0]) # Test min - min_pow = await engine_min_rx.receive() + min_pow = await formula_min_rx.receive() assert min_pow and min_pow.value and min_pow.value.isclose(Power.zero()) # Test max - max_pow = await engine_max_rx.receive() + max_pow = await formula_max_rx.receive() assert ( max_pow and max_pow.value @@ -307,7 +307,7 @@ async def test_formula_composition_min_max_const( await mockgrid.mock_resampler.send_meter_power([-100.0]) # Test min - min_pow = await engine_min_rx.receive() + min_pow = await formula_min_rx.receive() assert ( min_pow and min_pow.value @@ -315,7 +315,7 @@ async def test_formula_composition_min_max_const( ) # Test max - max_pow = await engine_max_rx.receive() + max_pow = await formula_max_rx.receive() assert max_pow and max_pow.value and max_pow.value.isclose(Power.zero()) async def test_formula_composition_constant( # pylint: disable=too-many-locals @@ -331,20 +331,20 @@ async def test_formula_composition_constant( # pylint: disable=too-many-locals stack.push_async_callback(logical_meter.stop) grid = microgrid.grid() stack.push_async_callback(grid.stop) - engine_add = (grid.power + Power.from_watts(50)).build( + formula_add = (grid.power + Power.from_watts(50)).build( "grid_power_addition" ) - stack.push_async_callback(engine_add.stop) - engine_sub = (grid.power - Power.from_watts(100)).build( + stack.push_async_callback(formula_add.stop) + formula_sub = (grid.power - Power.from_watts(100)).build( "grid_power_subtraction" ) - stack.push_async_callback(engine_sub.stop) - engine_mul = (grid.power * 2.0).build("grid_power_multiplication") - stack.push_async_callback(engine_mul.stop) - engine_div = (grid.power / 2.0).build("grid_power_division") - stack.push_async_callback(engine_div.stop) + stack.push_async_callback(formula_sub.stop) + formula_mul = (grid.power * 2.0).build("grid_power_multiplication") + stack.push_async_callback(formula_mul.stop) + formula_div = (grid.power / 2.0).build("grid_power_division") + stack.push_async_callback(formula_div.stop) - engine_composite = ( + formula_composite = ( ( (grid.power + Power.from_watts(50.0)) / 2.0 + grid.power @@ -357,7 +357,7 @@ async def test_formula_composition_constant( # pylint: disable=too-many-locals # Test addition grid_power_addition: Sample[Power] = ( - await engine_add.new_receiver().receive() + await formula_add.new_receiver().receive() ) assert grid_power_addition.value is not None assert math.isclose( @@ -367,7 +367,7 @@ async def test_formula_composition_constant( # pylint: disable=too-many-locals # Test subtraction grid_power_subtraction: Sample[Power] = ( - await engine_sub.new_receiver().receive() + await formula_sub.new_receiver().receive() ) assert grid_power_subtraction.value is not None assert math.isclose( @@ -377,7 +377,7 @@ async def test_formula_composition_constant( # pylint: disable=too-many-locals # Test multiplication grid_power_multiplication: Sample[Power] = ( - await engine_mul.new_receiver().receive() + await formula_mul.new_receiver().receive() ) assert grid_power_multiplication.value is not None assert math.isclose( @@ -387,7 +387,7 @@ async def test_formula_composition_constant( # pylint: disable=too-many-locals # Test division grid_power_division: Sample[Power] = ( - await engine_div.new_receiver().receive() + await formula_div.new_receiver().receive() ) assert grid_power_division.value is not None assert math.isclose( @@ -397,24 +397,24 @@ async def test_formula_composition_constant( # pylint: disable=too-many-locals # Test composite formula grid_power_composite: Sample[Power] = ( - await engine_composite.new_receiver().receive() + await formula_composite.new_receiver().receive() ) assert grid_power_composite.value is not None assert math.isclose(grid_power_composite.value.as_watts(), 310.0) # Test multiplication with a Quantity with pytest.raises(RuntimeError): - engine_assert = (grid.power * Power.from_watts(2.0)).build( # type: ignore + formula_assert = (grid.power * Power.from_watts(2.0)).build( # type: ignore "grid_power_multiplication" ) - await engine_assert.new_receiver().receive() + await formula_assert.new_receiver().receive() # Test addition with a float with pytest.raises(AttributeError): - engine_assert = (grid.power + 2.0).build( # type: ignore + formula_assert = (grid.power + 2.0).build( # type: ignore "grid_power_multiplication" ) - await engine_assert.new_receiver().receive() + await formula_assert.new_receiver().receive() async def test_3_phase_formulas(self, mocker: MockerFixture) -> None: """Test 3 phase formulas current formulas and their composition.""" @@ -438,11 +438,11 @@ async def test_3_phase_formulas(self, mocker: MockerFixture) -> None: grid_current_recv = grid.current_per_phase.new_receiver() ev_current_recv = ev_pool.current_per_phase.new_receiver() - engine = (grid.current_per_phase - ev_pool.current_per_phase).build( + formula = (grid.current_per_phase - ev_pool.current_per_phase).build( "net_current" ) - stack.push_async_callback(engine.stop) - net_current_recv = engine.new_receiver() + stack.push_async_callback(formula.stop) + net_current_recv = formula.new_receiver() for _ in range(10): await mockgrid.mock_resampler.send_meter_current( diff --git a/tests/timeseries/_formulas/utils.py b/tests/timeseries/_formulas/utils.py index d75d72b7e..d154f5053 100644 --- a/tests/timeseries/_formulas/utils.py +++ b/tests/timeseries/_formulas/utils.py @@ -1,7 +1,7 @@ # License: MIT # Copyright © 2023 Frequenz Energy-as-a-Service GmbH -"""Utils for testing formula engines.""" +"""Utils for testing formulas.""" from collections.abc import Callable From 5f644c869f5bfecd03a599d1bd9de8d8aea208c2 Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Thu, 4 Dec 2025 13:34:16 +0100 Subject: [PATCH 23/23] Update release notes Signed-off-by: Sahas Subramanian --- RELEASE_NOTES.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 63e3eda47..7bcd17565 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -26,6 +26,10 @@ [common-v0.7]: https://github.com/frequenz-floss/frequenz-api-common/releases/tag/v0.7.0 [common-v0.8]: https://github.com/frequenz-floss/frequenz-api-common/releases/tag/v0.8.0 +- The `FormulaEngine` is now replaced by a newly implemented `Formula` type. This doesn't affect the high level interfaces. + +- The `ComponentGraph` has been replaced by the `frequenz-microgrid-component-graph` package, which provides python bindings for the rust implementation. + ## New Features