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 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/pyproject.toml b/pyproject.toml index ef1aa0573..344b3d1e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,12 +30,12 @@ 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 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/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/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/battery_pool/_battery_pool.py b/src/frequenz/sdk/timeseries/battery_pool/_battery_pool.py index dd0a4c798..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 @@ -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,31 +191,27 @@ 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). - 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_generator( + return 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 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 3154b47b6..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 @@ -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 @@ -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 @@ -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..0376c0444 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,8 +52,8 @@ class Consumer: ``` """ - _formula_pool: FormulaEnginePool - """The formula engine pool to generate consumer metrics.""" + _formula_pool: FormulaPool + """The formula pool to generate consumer metrics.""" def __init__( self, @@ -67,34 +67,32 @@ 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). - 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. """ - 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.""" + """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 e54f7c49e..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 @@ -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,58 +114,50 @@ 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). - 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. """ - 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). - 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. """ - 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..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 @@ -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 @@ -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 @@ -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/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 73d8e6d62..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_battery_power_formula.py +++ /dev/null @@ -1,170 +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 ....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( - component_graph.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 = 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 23c842f15..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_consumer_power_formula.py +++ /dev/null @@ -1,276 +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 ....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 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) - 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 ( - 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) - ) - - # 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) - ) - - # 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 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) - ) - - component_graph = connection_manager.get().component_graph - consumer_components = component_graph.dfs(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 b217f5436..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_formula_generator.py +++ /dev/null @@ -1,254 +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 ....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 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 = 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(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) - ): - return 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 ( - 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) - ) - # 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 d66f62650..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_producer_power_formula.py +++ /dev/null @@ -1,149 +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 ....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 = component_graph.dfs( - self._get_grid_component(), - set(), - lambda component: component_graph.is_pv_chain(component) - or component_graph.is_chp_chain(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 8aeabf4e7..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_pv_power_formula.py +++ /dev/null @@ -1,140 +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 ....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 = component_graph.dfs( - self._get_grid_component(), - set(), - component_graph.is_pv_chain, - ) - - 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/src/frequenz/sdk/timeseries/formula_engine/__init__.py b/src/frequenz/sdk/timeseries/formulas/__init__.py similarity index 59% rename from src/frequenz/sdk/timeseries/formula_engine/__init__.py rename to src/frequenz/sdk/timeseries/formulas/__init__.py index 761d6db91..f00065592 100644 --- a/src/frequenz/sdk/timeseries/formula_engine/__init__.py +++ b/src/frequenz/sdk/timeseries/formulas/__init__.py @@ -1,15 +1,15 @@ # License: MIT -# Copyright © 2023 Frequenz Energy-as-a-Service GmbH +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH """Provides a way for the SDK to apply formulas on resampled data streams. -# Formula Engine +# Formulas -[`FormulaEngine`][frequenz.sdk.timeseries.formula_engine.FormulaEngine]s are used in the -SDK to calculate and stream metrics like +[`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 +[`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 @@ -18,9 +18,9 @@ ## Streaming Interface The -[`FormulaEngine.new_receiver()`][frequenz.sdk.timeseries.formula_engine.FormulaEngine.new_receiver] +[`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 formula engine. +[Sample][frequenz.sdk.timeseries.Sample]s calculated by the evaluation of the formula. ```python from frequenz.sdk import microgrid @@ -33,17 +33,16 @@ ## Composition -Composite `FormulaEngine`s can be built using arithmetic operations on -`FormulaEngine`s streaming the same type of data. +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_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: +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 @@ -52,7 +51,7 @@ 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 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) @@ -62,24 +61,23 @@ print(f"{power=}") ``` -# Formula Engine 3-Phase +# 3-Phase Formulas -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 +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 per-phase formulas. +etc.) are implemented as 3-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. +[`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 @@ -92,7 +90,7 @@ ## Composition -`FormulaEngine3Phase` instances can be composed together, just like `FormulaEngine` +`Formula3Phase` instances can be composed together, just like `Formula` instances. ```python @@ -111,9 +109,8 @@ ``` """ -from ._formula_engine import FormulaEngine, FormulaEngine3Phase -__all__ = [ - "FormulaEngine", - "FormulaEngine3Phase", -] +from ._formula import Formula +from ._formula_3_phase import Formula3Phase + +__all__ = ["Formula", "Formula3Phase"] 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/_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, + ) 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/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 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..d0887c7e2 --- /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 +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 formulas from string formulas. + + If a formula 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_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_formulas: dict[str, Formula3Phase[Power]] = {} + self._current_3_phase_formulas: 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 that streams values with the formulas applied. + """ + channel_key = formula_str + str(metric.value) + 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_formulas[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 that evaluates the given formula. + """ + 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)" + + formula = parse( + name=channel_key, + formula=formula_str, + telemetry_fetcher=self._telemetry_fetcher(Metric.AC_POWER_ACTIVE), + create_method=Power.from_watts, + ) + self.power_formulas[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 that evaluates the given formula. + """ + 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)" + + 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_formulas[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 that evaluates the given formula. + """ + 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)" + + 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_formulas[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 that evaluates the given formula. + """ + 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)" + + 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_formulas[channel_key] = formula + + return formula + + async def stop(self) -> None: + """Stop all formulas.""" + for pf in self.power_formulas.values(): + await pf.stop() + self.power_formulas.clear() + + for rpf in self._reactive_power_formulas.values(): + await rpf.stop() + self._reactive_power_formulas.clear() + + for p3pf in self._power_3_phase_formulas.values(): + await p3pf.stop() + self._power_3_phase_formulas.clear() + + for c3pf in self._current_3_phase_formulas.values(): + await c3pf.stop() + self._current_3_phase_formulas.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, + ) 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 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/_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, + ) 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 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() 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/src/frequenz/sdk/timeseries/grid.py b/src/frequenz/sdk/timeseries/grid.py index 855fae84f..b79a690f9 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,95 +66,88 @@ class Grid: lacks information about the fuse. """ - _formula_pool: FormulaEnginePool - """The formula engine pool to generate grid metrics.""" + _formula_pool: FormulaPool + """The formula 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). - 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. """ - 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). - 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. """ - 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). - 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. """ - 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). - 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. """ - 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.""" + """Stop all formulas.""" await self._formula_pool.stop() @@ -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..376219281 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 syntax. + Formulas can have Component IDs that are preceeded by a pound symbol("#"), and these operators: +, -, *, /, (, ). @@ -104,38 +105,32 @@ 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. + A Formula 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). - 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_generator( - "chp_power", - CHPPowerFormula, + return 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: - """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 0e65f5711..0c82ae16c 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,8 +52,8 @@ class Producer: ``` """ - _formula_pool: FormulaEnginePool - """The formula engine pool to generate producer metrics.""" + _formula_pool: FormulaPool + """The formula pool to generate producer metrics.""" def __init__( self, @@ -67,34 +67,32 @@ 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). - 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. """ - 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.""" + """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 00a75577a..6bbb91e15 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,30 +109,27 @@ 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). - 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_generator( + return 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 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 f572587ab..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 @@ -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 @@ -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 @@ -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/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_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) diff --git a/tests/microgrid/test_grid.py b/tests/microgrid/test_grid.py index 31c724797..1f5df9b34 100644 --- a/tests/microgrid/test_grid.py +++ b/tests/microgrid/test_grid.py @@ -5,56 +5,28 @@ 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 ( - ComponentCategory, + Component, 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 -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) -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( @@ -64,7 +36,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 ) @@ -151,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() @@ -246,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: @@ -352,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) @@ -374,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) @@ -395,158 +379,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 9232d69a6..465be8924 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] ) } @@ -510,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( @@ -540,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( @@ -589,118 +595,28 @@ 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) - - -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}" - ) + assert (await power_receiver.receive()).value == Power.from_watts(-30.0) 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 +630,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/_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/_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/_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 64% rename from tests/timeseries/_formula_engine/test_formula_composition.py rename to tests/timeseries/_formulas/test_formula_composition.py index be0614a8c..24a099a58 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() + formula = (pv_pool.power + battery_pool.power).build("inv_power") - engine = (pv_pool.power + battery_pool.power).build("inv_power") - stack.push_async_callback(engine.stop) - inv_calc_recv = engine.new_receiver() + async with ( + pv_pool.power as pv_power, + battery_pool.power as battery_power, + formula, + ): + grid_power_recv = grid.power.new_receiver() + battery_power_recv = battery_power.new_receiver() + pv_power_recv = pv_power.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]) + inv_calc_recv = formula.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() + 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 ( - 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) - ) + 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 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, - ) + 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) + ) + + 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(self, mocker: MockerFixture) -> None: + 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 + formula = (pv_pool.power + battery_pool.power).build("inv_power") + stack.push_async_callback(formula.stop) + + async with ( + formula, + battery_pool.power as battery_power, + pv_pool.power as pv_power, + ): + inv_calc_recv = formula.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 @@ -166,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( @@ -191,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: @@ -201,18 +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("grid_power_min") - stack.push_async_callback(engine_min.stop) - engine_min_rx = engine_min.new_receiver() + formula_min = grid.power.min([logical_meter.chp_power]).build( + "grid_power_min" + ) + stack.push_async_callback(formula_min.stop) + formula_min_rx = formula_min.new_receiver() - 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() + formula_max = grid.power.max([logical_meter.chp_power]).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, 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 @@ -220,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 @@ -228,9 +250,10 @@ 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() + min_pow = await formula_min_rx.receive() assert ( min_pow and min_pow.value @@ -238,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 @@ -259,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 @@ -284,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 @@ -292,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 @@ -308,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 @@ -334,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( @@ -344,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( @@ -354,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( @@ -364,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( @@ -374,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(RuntimeError): - engine_assert = (grid.power + 2.0).build( # type: ignore + with pytest.raises(AttributeError): + 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.""" @@ -415,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( @@ -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.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), + ], + ) 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..5937255be --- /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( # pylint: disable=too-many-locals + 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())] + ), + ) 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 diff --git a/tests/timeseries/_formula_engine/utils.py b/tests/timeseries/_formulas/utils.py similarity index 84% rename from tests/timeseries/_formula_engine/utils.py rename to tests/timeseries/_formulas/utils.py index 599a8eacb..d154f5053 100644 --- a/tests/timeseries/_formula_engine/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 @@ -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_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/timeseries/mock_resampler.py b/tests/timeseries/mock_resampler.py index 591b57473..8ae6b0920 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, ) @@ -75,12 +75,15 @@ 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) 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 @@ -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 6ce229665..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,138 +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 ) - - 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_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)" - ) 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 diff --git a/tests/timeseries/test_logical_meter.py b/tests/timeseries/test_logical_meter.py index b33e18248..7f803935d 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: @@ -50,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.""" @@ -77,69 +79,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..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: @@ -95,95 +99,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}" - ) 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.