From a6838a73577cef3e17043685f456b058f079821e Mon Sep 17 00:00:00 2001 From: cwasicki <126617870+cwasicki@users.noreply.github.com> Date: Mon, 23 Sep 2024 21:28:52 +0200 Subject: [PATCH 1/9] Copy component graph from SDK v1.0.0-rc901 --- .../client/reporting/component_graph.py | 1092 +++++++++++++++++ 1 file changed, 1092 insertions(+) create mode 100644 src/frequenz/client/reporting/component_graph.py diff --git a/src/frequenz/client/reporting/component_graph.py b/src/frequenz/client/reporting/component_graph.py new file mode 100644 index 0000000..52c5169 --- /dev/null +++ b/src/frequenz/client/reporting/component_graph.py @@ -0,0 +1,1092 @@ +# 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. +Examples of use-cases would be: + + * using the graph structure to infer which component measurements + need to be combined to obtain grid power or onsite load + + * identifying which inverter(s) need to be engaged to (dis)charge + a particular battery + + * understanding which power flows in the microgrid are derived from + green and grey sources + +It 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 dataclasses +import logging +from abc import ABC, abstractmethod +from collections.abc import Callable, Iterable +from dataclasses import asdict + +import networkx as nx +from frequenz.client.microgrid import ( + ApiClient, + Component, + ComponentCategory, + Connection, + InverterType, +) + +_logger = logging.getLogger(__name__) + +# pylint: disable=too-many-lines + + +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, + component_ids: set[int] | None = None, + component_categories: set[ComponentCategory] | None = None, + ) -> set[Component]: + """Fetch the components of the microgrid. + + Args: + component_ids: filter out any components not matching one of the provided IDs + component_categories: filter out any components not matching one of the + provided types + + Returns: + Set of the components currently connected to the microgrid, filtered by + the provided `component_ids` and `component_categories` values. + """ + + @abstractmethod + def connections( + self, + start: set[int] | None = None, + end: set[int] | None = None, + ) -> set[Connection]: + """Fetch the connections between microgrid components. + + Args: + start: filter out any connections whose `start` does not match one of these + component IDs + end: filter out any connections whose `end` does not match one of these + component IDs + + Returns: + Set of the connections between components in the microgrid, filtered by + the provided `start`/`end` choices. + """ + + @abstractmethod + def predecessors(self, component_id: int) -> set[Component]: + """Fetch the graph predecessors of the specified component. + + Args: + component_id: numerical ID of the component whose predecessors should be + fetched + + Returns: + Set of IDs of the 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: int) -> set[Component]: + """Fetch the graph successors of the specified component. + + Args: + component_id: numerical ID of the component whose successors should be + fetched + + Returns: + Set of IDs of the 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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, + *, + root_category: ComponentCategory, + descendant_categories: Iterable[ComponentCategory], + ) -> Component: + """Find the first descendant component given root and descendant categories. + + This method searches for the root component within the provided root + category. If multiple components share the same root category, the + first found one is considered as the root component. + + Subsequently, it looks for the first descendant component from the root + 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: + root_category: The category of the root component to search for. + descendant_categories: The descendant categories to search for the + first descendant component in. + + Returns: + The first descendant component found in the component graph, + considering the specified root and descendant 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[Connection] | None = None, + ) -> None: + """Initialize the component graph. + + Args: + components: components with which to first initialize the graph, + provided as pairs of the form `(component_id, + component_category)`; if set, must provide `connections` as well + connections: connections with which to initialize the graph, + provided as pairs of component IDs describing the start and end + of the connection; 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 = 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() + + def components( + self, + component_ids: set[int] | None = None, + component_categories: set[ComponentCategory] | None = None, + ) -> set[Component]: + """Fetch the components of the microgrid. + + Args: + component_ids: filter out any components not matching one of the provided IDs + component_categories: filter out any components not matching one of the + provided types + + Returns: + Set of the components currently connected to the microgrid, filtered by + the provided `component_ids` and `component_categories` values. + """ + if component_ids is None: + # If any node has not node[1], then it will not pass validations step. + selection: Iterable[Component] = map( + lambda node: Component(**(node[1])), self._graph.nodes(data=True) + ) + else: + valid_ids = filter(self._graph.has_node, component_ids) + selection = map(lambda idx: Component(**self._graph.nodes[idx]), valid_ids) + + if component_categories is not None: + types: set[ComponentCategory] = component_categories + selection = filter(lambda c: c.category in types, selection) + + return set(selection) + + def connections( + self, + start: set[int] | None = None, + end: set[int] | None = None, + ) -> set[Connection]: + """Fetch the connections between microgrid components. + + Args: + start: filter out any connections whose `start` does not match one of these + component IDs + end: filter out any connections whose `end` does not match one of these + component IDs + + Returns: + Set of the connections between components in the microgrid, filtered by + the provided `start`/`end` choices. + """ + if start is None: + if end is None: + selection = self._graph.edges + else: + selection = self._graph.in_edges(end) + + else: + selection = self._graph.out_edges(start) + if end is not None: + end_ids: set[int] = end + selection = filter(lambda c: c[1] in end_ids, selection) + + return set(map(lambda c: Connection(c[0], c[1]), selection)) + + def predecessors(self, component_id: int) -> set[Component]: + """Fetch the graph predecessors of the specified component. + + Args: + component_id: numerical ID of the component whose predecessors should be + fetched + + Returns: + Set of IDs of the 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: Component(**self._graph.nodes[idx]), predecessors_ids) + ) + + def successors(self, component_id: int) -> set[Component]: + """Fetch the graph successors of the specified component. + + Args: + component_id: numerical ID of the component whose successors should be + fetched + + Returns: + Set of IDs of the 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: Component(**self._graph.nodes[idx]), successors_ids)) + + def refresh_from( + self, + components: set[Component], + connections: set[Connection], + 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: components to include in the graph, provided as pairs of + the form `(component_id, component_category)` + connections: connections to include in the graph, provided as pairs + of component IDs describing the start and end of the connection + correct_errors: 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. + """ + if not all(component.is_valid() for component in components): + raise InvalidGraphError(f"Invalid components in input: {components}") + if not all(connection.is_valid() for connection in connections): + raise InvalidGraphError(f"Invalid connections in input: {connections}") + + new_graph = nx.DiGraph() + for component in components: + new_graph.add_node(component.component_id, **asdict(component)) + + new_graph.add_edges_from(dataclasses.astuple(c) for c 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 + + async def refresh_from_api( + self, + api: ApiClient, + correct_errors: Callable[["_MicrogridComponentGraph"], None] | None = None, + ) -> None: + """Refresh the contents of a component graph from the remote API. + + Args: + api: API client from which to fetch graph data + correct_errors: 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( + api.components(), + api.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() + + 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: component to check. + + Returns: + Whether the specified component is a grid meter. + """ + if component.category != ComponentCategory.METER: + return False + + predecessors = self.predecessors(component.component_id) + if len(predecessors) != 1: + return False + + predecessor = next(iter(predecessors)) + if predecessor.category != ComponentCategory.GRID: + return False + + grid_successors = self.successors(predecessor.component_id) + return len(grid_successors) == 1 + + def is_pv_inverter(self, component: Component) -> bool: + """Check if the specified component is a PV inverter. + + Args: + component: component to check. + + Returns: + Whether the specified component is a PV inverter. + """ + return ( + component.category == ComponentCategory.INVERTER + and component.type == InverterType.SOLAR + ) + + 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: component to check. + + Returns: + Whether the specified component is a PV meter. + """ + successors = self.successors(component.component_id) + return ( + component.category == ComponentCategory.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.component_id) + ) + ) + + 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: 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) + + def is_ev_charger(self, component: Component) -> bool: + """Check if the specified component is an EV charger. + + Args: + component: component to check. + + Returns: + Whether the specified component is an EV charger. + """ + return component.category == ComponentCategory.EV_CHARGER + + 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: component to check. + + Returns: + Whether the specified component is an EV charger meter. + """ + successors = self.successors(component.component_id) + return ( + component.category == ComponentCategory.METER + and not self.is_grid_meter(component) + and len(successors) > 0 + and all(self.is_ev_charger(successor) for successor in successors) + ) + + 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: 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) + + def is_battery_inverter(self, component: Component) -> bool: + """Check if the specified component is a battery inverter. + + Args: + component: component to check. + + Returns: + Whether the specified component is a battery inverter. + """ + return ( + component.category == ComponentCategory.INVERTER + and component.type == InverterType.BATTERY + ) + + 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: component to check. + + Returns: + Whether the specified component is a battery meter. + """ + successors = self.successors(component.component_id) + return ( + component.category == ComponentCategory.METER + and not self.is_grid_meter(component) + and len(successors) > 0 + and all(self.is_battery_inverter(successor) for successor in successors) + ) + + 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: 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) + + def is_chp(self, component: Component) -> bool: + """Check if the specified component is a CHP. + + Args: + component: component to check. + + Returns: + Whether the specified component is a CHP. + """ + return component.category == ComponentCategory.CHP + + 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: component to check. + + Returns: + Whether the specified component is a CHP meter. + """ + successors = self.successors(component.component_id) + return ( + component.category == ComponentCategory.METER + and not self.is_grid_meter(component) + and len(successors) > 0 + and all(self.is_chp(successor) for successor in successors) + ) + + 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: 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) + + 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.component_id): + component.update(self.dfs(successor, visited, condition)) + + return component + + def find_first_descendant_component( + self, + *, + root_category: ComponentCategory, + descendant_categories: Iterable[ComponentCategory], + ) -> Component: + """Find the first descendant component given root and descendant categories. + + This method searches for the root component within the provided root + category. If multiple components share the same root category, the + first found one is considered as the root component. + + Subsequently, it looks for the first descendant component from the root + 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: + root_category: The category of the root component to search for. + descendant_categories: The descendant categories to search for the + first descendant component in. + + Raises: + ValueError: when the root component is not found in the component + graph or when no component is found in the given categories. + + Returns: + The first descendant component found in the component graph, + considering the specified root and descendant categories. + """ + root_component = next( + (comp for comp in self.components(component_categories={root_category})), + None, + ) + + if root_component is None: + raise ValueError(f"Root component not found for {root_category.name}") + + # Sort by component ID to ensure consistent results. + successors = sorted( + self.successors(root_component.component_id), + key=lambda comp: comp.component_id, + ) + + def find_component(component_category: ComponentCategory) -> Component | None: + return next( + (comp for comp in successors if comp.category == component_category), + 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, descendant_categories)), 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, or no connections, or + the graph is not a tree, or if any component lacks type 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!") + + # node[0] is required by the graph definition + # If any node has not node[1], then it will not pass validations step. + undefined: list[int] = [ + node[0] for node in self._graph.nodes(data=True) if len(node[1]) == 0 + ] + + if len(undefined) > 0: + raise InvalidGraphError( + f"Missing definition for graph components: {undefined}" + ) + # 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.component_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, GRID, or + JUNCTION + """ + no_predecessors = filter( + lambda c: self._graph.in_degree(c.component_id) == 0, + self.components(), + ) + + valid_root_types = { + ComponentCategory.NONE, + ComponentCategory.GRID, + } + + valid_roots = list( + filter(lambda c: c.category in valid_root_types, no_predecessors) + ) + + if len(valid_roots) == 0: + raise InvalidGraphError("No valid root nodes of component graph!") + + if len(valid_roots) > 1: + raise InvalidGraphError(f"Multiple potential root nodes: {valid_roots}") + + root = valid_roots[0] + if self._graph.out_degree(root.component_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(component_categories={ComponentCategory.GRID})) + + 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].component_id + if self._graph.in_degree(grid_id) > 0: + grid_predecessors = list(self.predecessors(grid_id)) + raise InvalidGraphError( + f"Grid endpoint {grid_id} has graph predecessors: {grid_predecessors}" + ) + + 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(component_categories={ComponentCategory.INVERTER}) + ) + + missing_predecessors = list( + filter( + lambda c: sum(1 for _ in self.predecessors(c.component_id)) == 0, + intermediary_components, + ) + ) + if len(missing_predecessors) > 0: + raise InvalidGraphError( + "Intermediary components without graph predecessors: " + f"{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( + component_categories={ + ComponentCategory.BATTERY, + ComponentCategory.EV_CHARGER, + } + ) + ) + + missing_predecessors = list( + filter( + lambda c: sum(1 for _ in self.predecessors(c.component_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.component_id)) > 0, + leaf_components, + ) + ) + if len(with_successors) > 0: + raise InvalidGraphError( + f"Leaf components with graph successors: {with_successors}" + ) + + +def _correct_graph_errors(graph: _MicrogridComponentGraph) -> None: + """Attempt to correct errors in component graph data. + + For now, this handles just the special case of graph data that is missing an + explicit grid endpoint, but has an implicit one due to one or more + components having node 0 as their parent. + + Args: + graph: the graph whose data to correct (will be updated in place) + """ + # Check if there is an implicit grid endpoint with id == 0. + # This is an expected case from the API: that no explicit + # grid endpoint will be provided, but that components connected + # to the grid endpoint will have node 0 as their predecessor. + # pylint: disable=protected-access + if ( + graph._graph.has_node(0) + and graph._graph.in_degree(0) == 0 + and graph._graph.out_degree(0) > 0 + and "type" not in graph._graph.nodes[0] + ): + graph._graph.add_node(0, **asdict(Component(0, ComponentCategory.GRID))) + # pylint: enable=protected-access From 710a52389ad9e6a15ab183e00c0519ae3929fb7a Mon Sep 17 00:00:00 2001 From: cwasicki <126617870+cwasicki@users.noreply.github.com> Date: Mon, 23 Sep 2024 21:31:09 +0200 Subject: [PATCH 2/9] Add networkx to pyproject --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index e91b06f..d15bfb1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "grpcio-tools >= 1.54.2, < 2", "protobuf >= 4.25.3, < 6", "frequenz-client-base >= 0.6.0, < 0.7.0", + "networkx", ] dynamic = ["version"] From 27f69fbe1d836fd830a49c65684b876466b6aefc Mon Sep 17 00:00:00 2001 From: cwasicki <126617870+cwasicki@users.noreply.github.com> Date: Mon, 23 Sep 2024 21:17:24 +0200 Subject: [PATCH 3/9] Adapt component graph --- .../client/reporting/component_graph.py | 362 +++--------------- 1 file changed, 57 insertions(+), 305 deletions(-) diff --git a/src/frequenz/client/reporting/component_graph.py b/src/frequenz/client/reporting/component_graph.py index 52c5169..15b8b96 100644 --- a/src/frequenz/client/reporting/component_graph.py +++ b/src/frequenz/client/reporting/component_graph.py @@ -26,324 +26,96 @@ import logging from abc import ABC, abstractmethod from collections.abc import Callable, Iterable -from dataclasses import asdict - +from dataclasses import asdict, dataclass +from enum import Enum import networkx as nx -from frequenz.client.microgrid import ( - ApiClient, - Component, - ComponentCategory, - Connection, - InverterType, -) _logger = logging.getLogger(__name__) # pylint: disable=too-many-lines +class ComponentMetricId(Enum): + ACTIVE_POWER = "ACTIVE_POWER" -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, - component_ids: set[int] | None = None, - component_categories: set[ComponentCategory] | None = None, - ) -> set[Component]: - """Fetch the components of the microgrid. - - Args: - component_ids: filter out any components not matching one of the provided IDs - component_categories: filter out any components not matching one of the - provided types - - Returns: - Set of the components currently connected to the microgrid, filtered by - the provided `component_ids` and `component_categories` values. - """ - - @abstractmethod - def connections( - self, - start: set[int] | None = None, - end: set[int] | None = None, - ) -> set[Connection]: - """Fetch the connections between microgrid components. - - Args: - start: filter out any connections whose `start` does not match one of these - component IDs - end: filter out any connections whose `end` does not match one of these - component IDs - - Returns: - Set of the connections between components in the microgrid, filtered by - the provided `start`/`end` choices. - """ - - @abstractmethod - def predecessors(self, component_id: int) -> set[Component]: - """Fetch the graph predecessors of the specified component. - - Args: - component_id: numerical ID of the component whose predecessors should be - fetched - - Returns: - Set of IDs of the 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: int) -> set[Component]: - """Fetch the graph successors of the specified component. - - Args: - component_id: numerical ID of the component whose successors should be - fetched - - Returns: - Set of IDs of the 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. +@dataclass(frozen=True) +class Connection: + """Metadata for a connection between microgrid components.""" - Args: - component: component to check. + start: int + """The component ID that represents the start component of the connection.""" - Returns: - Whether the specified component is a grid meter. - """ + end: int + """The component ID that represents the end component of the connection.""" - @abstractmethod - def is_pv_inverter(self, component: Component) -> bool: - """Check if the specified component is a PV inverter. - Args: - component: component to check. + def is_valid(self) -> bool: + """Check if this instance contains valid data. Returns: - Whether the specified component is a PV inverter. + `True` if `start >= 0`, `end > 0`, and `start != end`, `False` + otherwise. """ + return self.start >= 0 and self.end > 0 and self.start != self.end - @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: 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. +class ComponentCategory(Enum): + """Possible types of microgrid component.""" - A component is part of a PV chain if it is a PV meter or a PV inverter. + NONE = "NONE" + GRID = "COMPONENT_CATEGORY_GRID" + METER = "COMPONENT_CATEGORY_METER" + INVERTER = "COMPONENT_CATEGORY_INVERTER" + BATTERY = "COMPONENT_CATEGORY_BATTERY" + EV_CHARGER = "COMPONENT_CATEGORY_EV_CHARGER" + CHP = "COMPONENT_CATEGORY_CHP" - Args: - component: component to check. +class InverterType(Enum): + """Enum representing inverter types.""" - Returns: - Whether the specified component is part of a PV chain. - """ + NONE = "NONE" + BATTERY = "INVERTER_TYPE_BATTERY" + SOLAR = "INVERTER_TYPE_SOLAR" + HYBRID = "INVERTER_TYPE_HYBRID" - @abstractmethod - def is_battery_inverter(self, component: Component) -> bool: - """Check if the specified component is a battery inverter. +@dataclass(frozen=True) +class Component: + """Metadata for a single microgrid component.""" - Args: - component: component to check. + component_id: int + """The ID of this component.""" - Returns: - Whether the specified component is a battery inverter. - """ + category: ComponentCategory + """The category of this component.""" - @abstractmethod - def is_battery_meter(self, component: Component) -> bool: - """Check if the specified component is a battery meter. + type: InverterType | None = None + """The type of this component.""" - This is done by checking if the component has only battery inverters as its - predecessors. - Args: - component: component to check. + def is_valid(self) -> bool: + """Check if this instance contains valid data. Returns: - Whether the specified component is a battery meter. + `True` if `id > 0` and `type` is a valid `ComponentCategory`, or if `id + == 0` and `type` is `GRID`, `False` otherwise """ + return ( + self.component_id > 0 and any(t == self.category for t in ComponentCategory) + ) or (self.component_id == 0 and self.category == ComponentCategory.GRID) - @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: 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: 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: 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: 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: 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: 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: 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. + def __hash__(self) -> int: + """Compute a hash of this instance, obtained by hashing the `component_id` field. Returns: - A set of component ids where the corresponding components fulfill - the condition function. + Hash of this instance. """ + return hash(self.component_id) - @abstractmethod - def find_first_descendant_component( - self, - *, - root_category: ComponentCategory, - descendant_categories: Iterable[ComponentCategory], - ) -> Component: - """Find the first descendant component given root and descendant categories. - - This method searches for the root component within the provided root - category. If multiple components share the same root category, the - first found one is considered as the root component. - - Subsequently, it looks for the first descendant component from the root - 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: - root_category: The category of the root component to search for. - descendant_categories: The descendant categories to search for the - first descendant component in. - - Returns: - The first descendant component found in the component graph, - considering the specified root and descendant categories. - """ +class InvalidGraphError(Exception): + """Exception type that will be thrown if graph data is not valid.""" -class _MicrogridComponentGraph( - ComponentGraph -): # pylint: disable=too-many-public-methods +class ComponentGraph: # pylint: disable=too-many-public-methods """ComponentGraph implementation designed to work with the microgrid API. For internal-only use of the `microgrid` package. @@ -498,7 +270,7 @@ def refresh_from( self, components: set[Component], connections: set[Connection], - correct_errors: Callable[["_MicrogridComponentGraph"], None] | None = None, + correct_errors: Callable[["ComponentGraph"], None] | None = None, ) -> None: """Refresh the graph from the provided list of components and connections. @@ -532,7 +304,7 @@ def refresh_from( # check if we can construct a valid ComponentGraph # from the new NetworkX graph data - _provisional = _MicrogridComponentGraph() + _provisional = ComponentGraph() _provisional._graph = new_graph # pylint: disable=protected-access if correct_errors is not None: try: @@ -553,26 +325,6 @@ def refresh_from( self._graph = new_graph old_graph.clear() # just in case any references remain, but should not - async def refresh_from_api( - self, - api: ApiClient, - correct_errors: Callable[["_MicrogridComponentGraph"], None] | None = None, - ) -> None: - """Refresh the contents of a component graph from the remote API. - - Args: - api: API client from which to fetch graph data - correct_errors: 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( - api.components(), - api.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() @@ -1067,7 +819,7 @@ def _validate_leaf_components(self) -> None: ) -def _correct_graph_errors(graph: _MicrogridComponentGraph) -> None: +def _correct_graph_errors(graph: ComponentGraph) -> None: """Attempt to correct errors in component graph data. For now, this handles just the special case of graph data that is missing an From 93c2eb5bf6060ce271a9e34bbac59058da1dd5c0 Mon Sep 17 00:00:00 2001 From: cwasicki <126617870+cwasicki@users.noreply.github.com> Date: Mon, 23 Sep 2024 21:29:18 +0200 Subject: [PATCH 4/9] Add main test script --- main.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 main.py diff --git a/main.py b/main.py new file mode 100644 index 0000000..6e34343 --- /dev/null +++ b/main.py @@ -0,0 +1,44 @@ +from frequenz.client.reporting.component_graph import ComponentGraph, Component, Connection, ComponentCategory, InverterType +import json + + +def build_graph(json_data: dict) -> ComponentGraph: + components = [] + connections = [] + for component in json_data["components"]: + component_id = int(component["id"]) + category = ComponentCategory(component["category"]) + component_type = None + if category == ComponentCategory.INVERTER and "inverter" in component and "type" in component["inverter"]: + component_type = InverterType(component["inverter"]["type"]) + + components.append( + Component( + component_id=component_id, + category=category, + type=component_type, + ) + ) + for connection in json_data["connections"]: + connections.append( + Connection( + start=int(connection["start"]), + end=int(connection["end"]), + ) + ) + return ComponentGraph(components, connections) + + +def main(): + # Read JSON data from file + with open("comps13.json", "r") as file: + data = json.load(file) + + # Build component graph + component_graph = build_graph(data) + + # Print component graph + print(component_graph) + +if __name__ == "__main__": + main() From fe79d5f68ffded09a8dba3bf5727e57be69a9519 Mon Sep 17 00:00:00 2001 From: cwasicki <126617870+cwasicki@users.noreply.github.com> Date: Mon, 23 Sep 2024 21:25:59 +0200 Subject: [PATCH 5/9] Copy quantities and base types from SDK v1.0.0-rc901 --- src/frequenz/client/reporting/_base_types.py | 223 +++ src/frequenz/client/reporting/_quantities.py | 1341 ++++++++++++++++++ 2 files changed, 1564 insertions(+) create mode 100644 src/frequenz/client/reporting/_base_types.py create mode 100644 src/frequenz/client/reporting/_quantities.py diff --git a/src/frequenz/client/reporting/_base_types.py b/src/frequenz/client/reporting/_base_types.py new file mode 100644 index 0000000..f6732fb --- /dev/null +++ b/src/frequenz/client/reporting/_base_types.py @@ -0,0 +1,223 @@ +# License: MIT +# Copyright © 2022 Frequenz Energy-as-a-Service GmbH + +"""Timeseries basic types.""" + +import dataclasses +import functools +from collections.abc import Callable, Iterator +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Generic, Protocol, Self, TypeVar, cast, overload + +from ._quantities import Power, QuantityT + +UNIX_EPOCH = datetime.fromtimestamp(0.0, tz=timezone.utc) +"""The UNIX epoch (in UTC).""" + + +@dataclass(frozen=True, order=True) +class Sample(Generic[QuantityT]): + """A measurement taken at a particular point in time. + + The `value` could be `None` if a component is malfunctioning or data is + lacking for another reason, but a sample still needs to be sent to have a + coherent view on a group of component metrics for a particular timestamp. + """ + + timestamp: datetime + """The time when this sample was generated.""" + + value: QuantityT | None = None + """The value of this sample.""" + + +@dataclass(frozen=True) +class Sample3Phase(Generic[QuantityT]): + """A 3-phase measurement made at a particular point in time. + + Each of the `value` fields could be `None` if a component is malfunctioning + or data is lacking for another reason, but a sample still needs to be sent + to have a coherent view on a group of component metrics for a particular + timestamp. + """ + + timestamp: datetime + """The time when this sample was generated.""" + value_p1: QuantityT | None + """The value of the 1st phase in this sample.""" + + value_p2: QuantityT | None + """The value of the 2nd phase in this sample.""" + + value_p3: QuantityT | None + """The value of the 3rd phase in this sample.""" + + def __iter__(self) -> Iterator[QuantityT | None]: + """Return an iterator that yields values from each of the phases. + + Yields: + Per-phase measurements one-by-one. + """ + yield self.value_p1 + yield self.value_p2 + yield self.value_p3 + + @overload + def max(self, default: QuantityT) -> QuantityT: ... + + @overload + def max(self, default: None = None) -> QuantityT | None: ... + + def max(self, default: QuantityT | None = None) -> QuantityT | None: + """Return the max value among all phases, or default if they are all `None`. + + Args: + default: value to return if all phases are `None`. + + Returns: + Max value among all phases, if available, default value otherwise. + """ + if not any(self): + return default + value: QuantityT = functools.reduce( + lambda x, y: x if x > y else y, + filter(None, self), + ) + return value + + @overload + def min(self, default: QuantityT) -> QuantityT: ... + + @overload + def min(self, default: None = None) -> QuantityT | None: ... + + def min(self, default: QuantityT | None = None) -> QuantityT | None: + """Return the min value among all phases, or default if they are all `None`. + + Args: + default: value to return if all phases are `None`. + + Returns: + Min value among all phases, if available, default value otherwise. + """ + if not any(self): + return default + value: QuantityT = functools.reduce( + lambda x, y: x if x < y else y, + filter(None, self), + ) + return value + + def map( + self, + function: Callable[[QuantityT], QuantityT], + default: QuantityT | None = None, + ) -> Self: + """Apply the given function on each of the phase values and return the result. + + If a phase value is `None`, replace it with `default` instead. + + Args: + function: The function to apply on each of the phase values. + default: The value to apply if a phase value is `None`. + + Returns: + A new instance, with the given function applied on values for each of the + phases. + """ + return self.__class__( + timestamp=self.timestamp, + value_p1=default if self.value_p1 is None else function(self.value_p1), + value_p2=default if self.value_p2 is None else function(self.value_p2), + value_p3=default if self.value_p3 is None else function(self.value_p3), + ) + + +class Comparable(Protocol): + """A protocol that requires the implementation of comparison methods. + + This protocol is used to ensure that types can be compared using + the less than or equal to (`<=`) and greater than or equal to (`>=`) + operators. + """ + + def __le__(self, other: Any, /) -> bool: + """Return whether this instance is less than or equal to `other`.""" + + def __ge__(self, other: Any, /) -> bool: + """Return whether this instance is greater than or equal to `other`.""" + + +_T = TypeVar("_T", bound=Comparable | None) + + +@dataclass(frozen=True) +class Bounds(Generic[_T]): + """Lower and upper bound values.""" + + lower: _T + """Lower bound.""" + + upper: _T + """Upper bound.""" + + def __contains__(self, item: _T) -> bool: + """ + Check if the value is within the range of the container. + + Args: + item: The value to check. + + Returns: + bool: True if value is within the range, otherwise False. + """ + if self.lower is None and self.upper is None: + return True + if self.lower is None: + return item <= self.upper + if self.upper is None: + return self.lower <= item + + return cast(Comparable, self.lower) <= item <= cast(Comparable, self.upper) + + +@dataclass(frozen=True, kw_only=True) +class SystemBounds: + """Internal representation of system bounds for groups of components.""" + + # compare = False tells the dataclass to not use name for comparison methods + timestamp: datetime = dataclasses.field(compare=False) + """Timestamp of the metrics.""" + + inclusion_bounds: Bounds[Power] | None + """Total inclusion power bounds for all components of a pool. + + This is the range within which power requests would be allowed by the pool. + + When exclusion bounds are present, they will exclude a subset of the inclusion + bounds. + """ + + exclusion_bounds: Bounds[Power] | None + """Total exclusion power bounds for all components of a pool. + + This is the range within which power requests are NOT allowed by the pool. + If present, they will be a subset of the inclusion bounds. + """ + + def __contains__(self, item: Power) -> bool: + """ + Check if the value is within the range of the container. + + Args: + item: The value to check. + + Returns: + bool: True if value is within the range, otherwise False. + """ + if not self.inclusion_bounds or item not in self.inclusion_bounds: + return False + if self.exclusion_bounds and item in self.exclusion_bounds: + return False + return True diff --git a/src/frequenz/client/reporting/_quantities.py b/src/frequenz/client/reporting/_quantities.py new file mode 100644 index 0000000..1e82451 --- /dev/null +++ b/src/frequenz/client/reporting/_quantities.py @@ -0,0 +1,1341 @@ +# License: MIT +# Copyright © 2022 Frequenz Energy-as-a-Service GmbH + +"""Types for holding quantities with units.""" + +# pylint: disable=too-many-lines + +from __future__ import annotations + +import math +from datetime import timedelta +from typing import Any, NoReturn, Self, TypeVar, overload + +QuantityT = TypeVar( + "QuantityT", + "Quantity", + "Power", + "Current", + "Voltage", + "Energy", + "Frequency", + "Percentage", + "Temperature", +) +"""Type variable for representing various quantity types.""" + + +class Quantity: + """A quantity with a unit. + + Quantities try to behave like float and are also immutable. + """ + + _base_value: float + """The value of this quantity in the base unit.""" + + _exponent_unit_map: dict[int, str] | None = None + """A mapping from the exponent of the base unit to the unit symbol. + + If None, this quantity has no unit. None is possible only when using the base + class. Sub-classes must define this. + """ + + def __init__(self, value: float, exponent: int = 0) -> None: + """Initialize a new quantity. + + Args: + value: The value of this quantity in a given exponent of the base unit. + exponent: The exponent of the base unit the given value is in. + """ + self._base_value = value * 10.0**exponent + + @classmethod + def _new(cls, value: float, *, exponent: int = 0) -> Self: + """Instantiate a new quantity subclass instance. + + Args: + value: The value of this quantity in a given exponent of the base unit. + exponent: The exponent of the base unit the given value is in. + + Returns: + A new quantity subclass instance. + """ + self = cls.__new__(cls) + self._base_value = value * 10.0**exponent + return self + + def __init_subclass__(cls, exponent_unit_map: dict[int, str]) -> None: + """Initialize a new subclass of Quantity. + + Args: + exponent_unit_map: A mapping from the exponent of the base unit to the unit + symbol. + + Raises: + TypeError: If the given exponent_unit_map is not a dict. + ValueError: If the given exponent_unit_map does not contain a base unit + (exponent 0). + """ + if 0 not in exponent_unit_map: + raise ValueError("Expected a base unit for the type (for exponent 0)") + cls._exponent_unit_map = exponent_unit_map + super().__init_subclass__() + + _zero_cache: dict[type, Quantity] = {} + """Cache for zero singletons. + + This is a workaround for mypy getting confused when using @functools.cache and + @classmethod combined with returning Self. It believes the resulting type of this + method is Self and complains that members of the actual class don't exist in Self, + so we need to implement the cache ourselves. + """ + + @classmethod + def zero(cls) -> Self: + """Return a quantity with value 0.0. + + Returns: + A quantity with value 0.0. + """ + _zero = cls._zero_cache.get(cls, None) + if _zero is None: + _zero = cls.__new__(cls) + _zero._base_value = 0.0 + cls._zero_cache[cls] = _zero + assert isinstance(_zero, cls) + return _zero + + @classmethod + def from_string(cls, string: str) -> Self: + """Return a quantity from a string representation. + + Args: + string: The string representation of the quantity. + + Returns: + A quantity object with the value given in the string. + + Raises: + ValueError: If the string does not match the expected format. + + """ + split_string = string.split(" ") + + if len(split_string) != 2: + raise ValueError( + f"Expected a string of the form 'value unit', got {string}" + ) + + assert cls._exponent_unit_map is not None + exp_map = cls._exponent_unit_map + + for exponent, unit in exp_map.items(): + if unit == split_string[1]: + instance = cls.__new__(cls) + try: + instance._base_value = float(split_string[0]) * 10**exponent + except ValueError as error: + raise ValueError(f"Failed to parse string '{string}'.") from error + + return instance + + raise ValueError(f"Unknown unit {split_string[1]}") + + @property + def base_value(self) -> float: + """Return the value of this quantity in the base unit. + + Returns: + The value of this quantity in the base unit. + """ + return self._base_value + + @property + def base_unit(self) -> str | None: + """Return the base unit of this quantity. + + None if this quantity has no unit. + + Returns: + The base unit of this quantity. + """ + if not self._exponent_unit_map: + return None + return self._exponent_unit_map[0] + + def isnan(self) -> bool: + """Return whether this quantity is NaN. + + Returns: + Whether this quantity is NaN. + """ + return math.isnan(self._base_value) + + def isinf(self) -> bool: + """Return whether this quantity is infinite. + + Returns: + Whether this quantity is infinite. + """ + return math.isinf(self._base_value) + + def isclose(self, other: Self, rel_tol: float = 1e-9, abs_tol: float = 0.0) -> bool: + """Return whether this quantity is close to another. + + Args: + other: The quantity to compare to. + rel_tol: The relative tolerance. + abs_tol: The absolute tolerance. + + Returns: + Whether this quantity is close to another. + """ + return math.isclose( + self._base_value, + other._base_value, # pylint: disable=protected-access + rel_tol=rel_tol, + abs_tol=abs_tol, + ) + + def __repr__(self) -> str: + """Return a representation of this quantity. + + Returns: + A representation of this quantity. + """ + return f"{type(self).__name__}(value={self._base_value}, exponent=0)" + + def __str__(self) -> str: + """Return a string representation of this quantity. + + Returns: + A string representation of this quantity. + """ + return self.__format__("") + + # pylint: disable=too-many-branches + def __format__(self, __format_spec: str) -> str: + """Return a formatted string representation of this quantity. + + If specified, must be of this form: `[0].{precision}`. If a 0 is not given, the + trailing zeros will be omitted. If no precision is given, the default is 3. + + The returned string will use the unit that will result in the maximum precision, + based on the magnitude of the value. + + Example: + ```python + from frequenz.sdk.timeseries import Current + c = Current.from_amperes(0.2345) + assert f"{c:.2}" == "234.5 mA" + c = Current.from_amperes(1.2345) + assert f"{c:.2}" == "1.23 A" + c = Current.from_milliamperes(1.2345) + assert f"{c:.6}" == "1.2345 mA" + ``` + + Args: + __format_spec: The format specifier. + + Returns: + A string representation of this quantity. + + Raises: + ValueError: If the given format specifier is invalid. + """ + keep_trailing_zeros = False + if __format_spec != "": + fspec_parts = __format_spec.split(".") + if ( + len(fspec_parts) != 2 + or fspec_parts[0] not in ("", "0") + or not fspec_parts[1].isdigit() + ): + raise ValueError( + "Invalid format specifier. Must be empty or `[0].{precision}`" + ) + if fspec_parts[0] == "0": + keep_trailing_zeros = True + precision = int(fspec_parts[1]) + else: + precision = 3 + if not self._exponent_unit_map: + return f"{self._base_value:.{precision}f}" + + if math.isinf(self._base_value) or math.isnan(self._base_value): + return f"{self._base_value} {self._exponent_unit_map[0]}" + + if abs_value := abs(self._base_value): + precision_pow = 10 ** (precision) + # Prevent numbers like 999.999999 being rendered as 1000 V + # instead of 1 kV. + # This could happen because the str formatting function does + # rounding as well. + # This is an imperfect solution that works for _most_ cases. + # isclose parameters were chosen according to the observed cases + if math.isclose(abs_value, precision_pow, abs_tol=1e-4, rel_tol=0.01): + # If the value is close to the precision, round it + exponent = math.ceil(math.log10(precision_pow)) + else: + exponent = math.floor(math.log10(abs_value)) + else: + exponent = 0 + + unit_place = exponent - exponent % 3 + if unit_place < min(self._exponent_unit_map): + unit = self._exponent_unit_map[min(self._exponent_unit_map.keys())] + unit_place = min(self._exponent_unit_map) + elif unit_place > max(self._exponent_unit_map): + unit = self._exponent_unit_map[max(self._exponent_unit_map.keys())] + unit_place = max(self._exponent_unit_map) + else: + unit = self._exponent_unit_map[unit_place] + + value_str = f"{self._base_value / 10 ** unit_place:.{precision}f}" + + if value_str in ("-0", "0"): + stripped = value_str + else: + stripped = value_str.rstrip("0").rstrip(".") + + if not keep_trailing_zeros: + value_str = stripped + unit_str = unit if stripped not in ("-0", "0") else self._exponent_unit_map[0] + return f"{value_str} {unit_str}" + + def __add__(self, other: Self) -> Self: + """Return the sum of this quantity and another. + + Args: + other: The other quantity. + + Returns: + The sum of this quantity and another. + """ + if not type(other) is type(self): + return NotImplemented + summe = type(self).__new__(type(self)) + summe._base_value = self._base_value + other._base_value + return summe + + def __sub__(self, other: Self) -> Self: + """Return the difference of this quantity and another. + + Args: + other: The other quantity. + + Returns: + The difference of this quantity and another. + """ + if not type(other) is type(self): + return NotImplemented + difference = type(self).__new__(type(self)) + difference._base_value = self._base_value - other._base_value + return difference + + @overload + def __mul__(self, scalar: float, /) -> Self: + """Scale this quantity by a scalar. + + Args: + scalar: The scalar by which to scale this quantity. + + Returns: + The scaled quantity. + """ + + @overload + def __mul__(self, percent: Percentage, /) -> Self: + """Scale this quantity by a percentage. + + Args: + percent: The percentage by which to scale this quantity. + + Returns: + The scaled quantity. + """ + + def __mul__(self, value: float | Percentage, /) -> Self: + """Scale this quantity by a scalar or percentage. + + Args: + value: The scalar or percentage by which to scale this quantity. + + Returns: + The scaled quantity. + """ + match value: + case float(): + return type(self)._new(self._base_value * value) + case Percentage(): + return type(self)._new(self._base_value * value.as_fraction()) + case _: + return NotImplemented + + @overload + def __truediv__(self, other: float, /) -> Self: + """Divide this quantity by a scalar. + + Args: + other: The scalar or percentage to divide this quantity by. + + Returns: + The divided quantity. + """ + + @overload + def __truediv__(self, other: Self, /) -> float: + """Return the ratio of this quantity to another. + + Args: + other: The other quantity. + + Returns: + The ratio of this quantity to another. + """ + + def __truediv__(self, value: float | Self, /) -> Self | float: + """Divide this quantity by a scalar or another quantity. + + Args: + value: The scalar or quantity to divide this quantity by. + + Returns: + The divided quantity or the ratio of this quantity to another. + """ + match value: + case float(): + return type(self)._new(self._base_value / value) + case Quantity() if type(value) is type(self): + return self._base_value / value._base_value + case _: + return NotImplemented + + def __gt__(self, other: Self) -> bool: + """Return whether this quantity is greater than another. + + Args: + other: The other quantity. + + Returns: + Whether this quantity is greater than another. + """ + if not type(other) is type(self): + return NotImplemented + return self._base_value > other._base_value + + def __ge__(self, other: Self) -> bool: + """Return whether this quantity is greater than or equal to another. + + Args: + other: The other quantity. + + Returns: + Whether this quantity is greater than or equal to another. + """ + if not type(other) is type(self): + return NotImplemented + return self._base_value >= other._base_value + + def __lt__(self, other: Self) -> bool: + """Return whether this quantity is less than another. + + Args: + other: The other quantity. + + Returns: + Whether this quantity is less than another. + """ + if not type(other) is type(self): + return NotImplemented + return self._base_value < other._base_value + + def __le__(self, other: Self) -> bool: + """Return whether this quantity is less than or equal to another. + + Args: + other: The other quantity. + + Returns: + Whether this quantity is less than or equal to another. + """ + if not type(other) is type(self): + return NotImplemented + return self._base_value <= other._base_value + + def __eq__(self, other: object) -> bool: + """Return whether this quantity is equal to another. + + Args: + other: The other quantity. + + Returns: + Whether this quantity is equal to another. + """ + if not type(other) is type(self): + return NotImplemented + # The above check ensures that both quantities are the exact same type, because + # `isinstance` returns true for subclasses and superclasses. But the above check + # doesn't help mypy identify the type of other, so the below line is necessary. + assert isinstance(other, self.__class__) + return self._base_value == other._base_value + + def __neg__(self) -> Self: + """Return the negation of this quantity. + + Returns: + The negation of this quantity. + """ + negation = type(self).__new__(type(self)) + negation._base_value = -self._base_value + return negation + + def __abs__(self) -> Self: + """Return the absolute value of this quantity. + + Returns: + The absolute value of this quantity. + """ + absolute = type(self).__new__(type(self)) + absolute._base_value = abs(self._base_value) + return absolute + + +class _NoDefaultConstructible(type): + """A metaclass that disables the default constructor.""" + + def __call__(cls, *_args: Any, **_kwargs: Any) -> NoReturn: + """Raise a TypeError when the default constructor is called. + + Args: + *_args: ignored positional arguments. + **_kwargs: ignored keyword arguments. + + Raises: + TypeError: Always. + """ + raise TypeError( + "Use of default constructor NOT allowed for " + f"{cls.__module__}.{cls.__qualname__}, " + f"use one of the `{cls.__name__}.from_*()` methods instead." + ) + + +class Temperature( + Quantity, + metaclass=_NoDefaultConstructible, + exponent_unit_map={ + 0: "°C", + }, +): + """A temperature quantity (in degrees Celsius).""" + + @classmethod + def from_celsius(cls, value: float) -> Self: + """Initialize a new temperature quantity. + + Args: + value: The temperature in degrees Celsius. + + Returns: + A new temperature quantity. + """ + return cls._new(value) + + def as_celsius(self) -> float: + """Return the temperature in degrees Celsius. + + Returns: + The temperature in degrees Celsius. + """ + return self._base_value + + +class Power( + Quantity, + metaclass=_NoDefaultConstructible, + exponent_unit_map={ + -3: "mW", + 0: "W", + 3: "kW", + 6: "MW", + }, +): + """A power quantity. + + Objects of this type are wrappers around `float` values and are immutable. + + The constructors accept a single `float` value, the `as_*()` methods return a + `float` value, and each of the arithmetic operators supported by this type are + actually implemented using floating-point arithmetic. + + So all considerations about floating-point arithmetic apply to this type as well. + """ + + @classmethod + def from_watts(cls, watts: float) -> Self: + """Initialize a new power quantity. + + Args: + watts: The power in watts. + + Returns: + A new power quantity. + """ + return cls._new(watts) + + @classmethod + def from_milliwatts(cls, milliwatts: float) -> Self: + """Initialize a new power quantity. + + Args: + milliwatts: The power in milliwatts. + + Returns: + A new power quantity. + """ + return cls._new(milliwatts, exponent=-3) + + @classmethod + def from_kilowatts(cls, kilowatts: float) -> Self: + """Initialize a new power quantity. + + Args: + kilowatts: The power in kilowatts. + + Returns: + A new power quantity. + """ + return cls._new(kilowatts, exponent=3) + + @classmethod + def from_megawatts(cls, megawatts: float) -> Self: + """Initialize a new power quantity. + + Args: + megawatts: The power in megawatts. + + Returns: + A new power quantity. + """ + return cls._new(megawatts, exponent=6) + + def as_watts(self) -> float: + """Return the power in watts. + + Returns: + The power in watts. + """ + return self._base_value + + def as_kilowatts(self) -> float: + """Return the power in kilowatts. + + Returns: + The power in kilowatts. + """ + return self._base_value / 1e3 + + def as_megawatts(self) -> float: + """Return the power in megawatts. + + Returns: + The power in megawatts. + """ + return self._base_value / 1e6 + + # We need the ignore here because otherwise mypy will give this error: + # > Overloaded operator methods can't have wider argument types in overrides + # The problem seems to be when the other type implements an **incompatible** + # __rmul__ method, which is not the case here, so we should be safe. + # Please see this example: + # https://github.com/python/mypy/blob/c26f1297d4f19d2d1124a30efc97caebb8c28616/test-data/unit/check-overloading.test#L4738C1-L4769C55 + # And a discussion in a mypy issue here: + # https://github.com/python/mypy/issues/4985#issuecomment-389692396 + @overload # type: ignore[override] + def __mul__(self, scalar: float, /) -> Self: + """Scale this power by a scalar. + + Args: + scalar: The scalar by which to scale this power. + + Returns: + The scaled power. + """ + + @overload + def __mul__(self, percent: Percentage, /) -> Self: + """Scale this power by a percentage. + + Args: + percent: The percentage by which to scale this power. + + Returns: + The scaled power. + """ + + @overload + def __mul__(self, other: timedelta, /) -> Energy: + """Return an energy from multiplying this power by the given duration. + + Args: + other: The duration to multiply by. + + Returns: + The calculated energy. + """ + + def __mul__(self, other: float | Percentage | timedelta, /) -> Self | Energy: + """Return a power or energy from multiplying this power by the given value. + + Args: + other: The scalar, percentage or duration to multiply by. + + Returns: + A power or energy. + """ + match other: + case float() | Percentage(): + return super().__mul__(other) + case timedelta(): + return Energy._new(self._base_value * other.total_seconds() / 3600.0) + case _: + return NotImplemented + + # See the comment for Power.__mul__ for why we need the ignore here. + @overload # type: ignore[override] + def __truediv__(self, other: float, /) -> Self: + """Divide this power by a scalar. + + Args: + other: The scalar to divide this power by. + + Returns: + The divided power. + """ + + @overload + def __truediv__(self, other: Self, /) -> float: + """Return the ratio of this power to another. + + Args: + other: The other power. + + Returns: + The ratio of this power to another. + """ + + @overload + def __truediv__(self, current: Current, /) -> Voltage: + """Return a voltage from dividing this power by the given current. + + Args: + current: The current to divide by. + + Returns: + A voltage from dividing this power by the a current. + """ + + @overload + def __truediv__(self, voltage: Voltage, /) -> Current: + """Return a current from dividing this power by the given voltage. + + Args: + voltage: The voltage to divide by. + + Returns: + A current from dividing this power by a voltage. + """ + + def __truediv__( + self, other: float | Self | Current | Voltage, / + ) -> Self | float | Voltage | Current: + """Return a current or voltage from dividing this power by the given value. + + Args: + other: The scalar, power, current or voltage to divide by. + + Returns: + A current or voltage from dividing this power by the given value. + """ + match other: + case float(): + return super().__truediv__(other) + case Power(): + return self._base_value / other._base_value + case Current(): + return Voltage._new(self._base_value / other._base_value) + case Voltage(): + return Current._new(self._base_value / other._base_value) + case _: + return NotImplemented + + +class Current( + Quantity, + metaclass=_NoDefaultConstructible, + exponent_unit_map={ + -3: "mA", + 0: "A", + }, +): + """A current quantity. + + Objects of this type are wrappers around `float` values and are immutable. + + The constructors accept a single `float` value, the `as_*()` methods return a + `float` value, and each of the arithmetic operators supported by this type are + actually implemented using floating-point arithmetic. + + So all considerations about floating-point arithmetic apply to this type as well. + """ + + @classmethod + def from_amperes(cls, amperes: float) -> Self: + """Initialize a new current quantity. + + Args: + amperes: The current in amperes. + + Returns: + A new current quantity. + """ + return cls._new(amperes) + + @classmethod + def from_milliamperes(cls, milliamperes: float) -> Self: + """Initialize a new current quantity. + + Args: + milliamperes: The current in milliamperes. + + Returns: + A new current quantity. + """ + return cls._new(milliamperes, exponent=-3) + + def as_amperes(self) -> float: + """Return the current in amperes. + + Returns: + The current in amperes. + """ + return self._base_value + + def as_milliamperes(self) -> float: + """Return the current in milliamperes. + + Returns: + The current in milliamperes. + """ + return self._base_value * 1e3 + + # See comment for Power.__mul__ for why we need the ignore here. + @overload # type: ignore[override] + def __mul__(self, scalar: float, /) -> Self: + """Scale this current by a scalar. + + Args: + scalar: The scalar by which to scale this current. + + Returns: + The scaled current. + """ + + @overload + def __mul__(self, percent: Percentage, /) -> Self: + """Scale this current by a percentage. + + Args: + percent: The percentage by which to scale this current. + + Returns: + The scaled current. + """ + + @overload + def __mul__(self, other: Voltage, /) -> Power: + """Multiply the current by a voltage to get a power. + + Args: + other: The voltage. + + Returns: + The calculated power. + """ + + def __mul__(self, other: float | Percentage | Voltage, /) -> Self | Power: + """Return a current or power from multiplying this current by the given value. + + Args: + other: The scalar, percentage or voltage to multiply by. + + Returns: + A current or power. + """ + match other: + case float() | Percentage(): + return super().__mul__(other) + case Voltage(): + return Power._new(self._base_value * other._base_value) + case _: + return NotImplemented + + +class Voltage( + Quantity, + metaclass=_NoDefaultConstructible, + exponent_unit_map={0: "V", -3: "mV", 3: "kV"}, +): + """A voltage quantity. + + Objects of this type are wrappers around `float` values and are immutable. + + The constructors accept a single `float` value, the `as_*()` methods return a + `float` value, and each of the arithmetic operators supported by this type are + actually implemented using floating-point arithmetic. + + So all considerations about floating-point arithmetic apply to this type as well. + """ + + @classmethod + def from_volts(cls, volts: float) -> Self: + """Initialize a new voltage quantity. + + Args: + volts: The voltage in volts. + + Returns: + A new voltage quantity. + """ + return cls._new(volts) + + @classmethod + def from_millivolts(cls, millivolts: float) -> Self: + """Initialize a new voltage quantity. + + Args: + millivolts: The voltage in millivolts. + + Returns: + A new voltage quantity. + """ + return cls._new(millivolts, exponent=-3) + + @classmethod + def from_kilovolts(cls, kilovolts: float) -> Self: + """Initialize a new voltage quantity. + + Args: + kilovolts: The voltage in kilovolts. + + Returns: + A new voltage quantity. + """ + return cls._new(kilovolts, exponent=3) + + def as_volts(self) -> float: + """Return the voltage in volts. + + Returns: + The voltage in volts. + """ + return self._base_value + + def as_millivolts(self) -> float: + """Return the voltage in millivolts. + + Returns: + The voltage in millivolts. + """ + return self._base_value * 1e3 + + def as_kilovolts(self) -> float: + """Return the voltage in kilovolts. + + Returns: + The voltage in kilovolts. + """ + return self._base_value / 1e3 + + # See comment for Power.__mul__ for why we need the ignore here. + @overload # type: ignore[override] + def __mul__(self, scalar: float, /) -> Self: + """Scale this voltage by a scalar. + + Args: + scalar: The scalar by which to scale this voltage. + + Returns: + The scaled voltage. + """ + + @overload + def __mul__(self, percent: Percentage, /) -> Self: + """Scale this voltage by a percentage. + + Args: + percent: The percentage by which to scale this voltage. + + Returns: + The scaled voltage. + """ + + @overload + def __mul__(self, other: Current, /) -> Power: + """Multiply the voltage by the current to get the power. + + Args: + other: The current to multiply the voltage with. + + Returns: + The calculated power. + """ + + def __mul__(self, other: float | Percentage | Current, /) -> Self | Power: + """Return a voltage or power from multiplying this voltage by the given value. + + Args: + other: The scalar, percentage or current to multiply by. + + Returns: + The calculated voltage or power. + """ + match other: + case float() | Percentage(): + return super().__mul__(other) + case Current(): + return Power._new(self._base_value * other._base_value) + case _: + return NotImplemented + + +class Energy( + Quantity, + metaclass=_NoDefaultConstructible, + exponent_unit_map={ + 0: "Wh", + 3: "kWh", + 6: "MWh", + }, +): + """An energy quantity. + + Objects of this type are wrappers around `float` values and are immutable. + + The constructors accept a single `float` value, the `as_*()` methods return a + `float` value, and each of the arithmetic operators supported by this type are + actually implemented using floating-point arithmetic. + + So all considerations about floating-point arithmetic apply to this type as well. + """ + + @classmethod + def from_watt_hours(cls, watt_hours: float) -> Self: + """Initialize a new energy quantity. + + Args: + watt_hours: The energy in watt hours. + + Returns: + A new energy quantity. + """ + return cls._new(watt_hours) + + @classmethod + def from_kilowatt_hours(cls, kilowatt_hours: float) -> Self: + """Initialize a new energy quantity. + + Args: + kilowatt_hours: The energy in kilowatt hours. + + Returns: + A new energy quantity. + """ + return cls._new(kilowatt_hours, exponent=3) + + @classmethod + def from_megawatt_hours(cls, megawatt_hours: float) -> Self: + """Initialize a new energy quantity. + + Args: + megawatt_hours: The energy in megawatt hours. + + Returns: + A new energy quantity. + """ + return cls._new(megawatt_hours, exponent=6) + + def as_watt_hours(self) -> float: + """Return the energy in watt hours. + + Returns: + The energy in watt hours. + """ + return self._base_value + + def as_kilowatt_hours(self) -> float: + """Return the energy in kilowatt hours. + + Returns: + The energy in kilowatt hours. + """ + return self._base_value / 1e3 + + def as_megawatt_hours(self) -> float: + """Return the energy in megawatt hours. + + Returns: + The energy in megawatt hours. + """ + return self._base_value / 1e6 + + def __mul__(self, other: float | Percentage) -> Self: + """Scale this energy by a percentage. + + Args: + other: The percentage by which to scale this energy. + + Returns: + The scaled energy. + """ + match other: + case float(): + return self._new(self._base_value * other) + case Percentage(): + return self._new(self._base_value * other.as_fraction()) + case _: + return NotImplemented + + # See the comment for Power.__mul__ for why we need the ignore here. + @overload # type: ignore[override] + def __truediv__(self, other: float, /) -> Self: + """Divide this energy by a scalar. + + Args: + other: The scalar to divide this energy by. + + Returns: + The divided energy. + """ + + @overload + def __truediv__(self, other: Self, /) -> float: + """Return the ratio of this energy to another. + + Args: + other: The other energy. + + Returns: + The ratio of this energy to another. + """ + + @overload + def __truediv__(self, duration: timedelta, /) -> Power: + """Return a power from dividing this energy by the given duration. + + Args: + duration: The duration to divide by. + + Returns: + A power from dividing this energy by the given duration. + """ + + @overload + def __truediv__(self, power: Power, /) -> timedelta: + """Return a duration from dividing this energy by the given power. + + Args: + power: The power to divide by. + + Returns: + A duration from dividing this energy by the given power. + """ + + def __truediv__( + self, other: float | Self | timedelta | Power, / + ) -> Self | float | Power | timedelta: + """Return a power or duration from dividing this energy by the given value. + + Args: + other: The scalar, energy, power or duration to divide by. + + Returns: + A power or duration from dividing this energy by the given value. + """ + match other: + case float(): + return super().__truediv__(other) + case Energy(): + return self._base_value / other._base_value + case timedelta(): + return Power._new(self._base_value / (other.total_seconds() / 3600.0)) + case Power(): + return timedelta( + seconds=(self._base_value / other._base_value) * 3600.0 + ) + case _: + return NotImplemented + + +class Frequency( + Quantity, + metaclass=_NoDefaultConstructible, + exponent_unit_map={0: "Hz", 3: "kHz", 6: "MHz", 9: "GHz"}, +): + """A frequency quantity. + + Objects of this type are wrappers around `float` values and are immutable. + + The constructors accept a single `float` value, the `as_*()` methods return a + `float` value, and each of the arithmetic operators supported by this type are + actually implemented using floating-point arithmetic. + + So all considerations about floating-point arithmetic apply to this type as well. + """ + + @classmethod + def from_hertz(cls, hertz: float) -> Self: + """Initialize a new frequency quantity. + + Args: + hertz: The frequency in hertz. + + Returns: + A new frequency quantity. + """ + return cls._new(hertz) + + @classmethod + def from_kilohertz(cls, kilohertz: float) -> Self: + """Initialize a new frequency quantity. + + Args: + kilohertz: The frequency in kilohertz. + + Returns: + A new frequency quantity. + """ + return cls._new(kilohertz, exponent=3) + + @classmethod + def from_megahertz(cls, megahertz: float) -> Self: + """Initialize a new frequency quantity. + + Args: + megahertz: The frequency in megahertz. + + Returns: + A new frequency quantity. + """ + return cls._new(megahertz, exponent=6) + + @classmethod + def from_gigahertz(cls, gigahertz: float) -> Self: + """Initialize a new frequency quantity. + + Args: + gigahertz: The frequency in gigahertz. + + Returns: + A new frequency quantity. + """ + return cls._new(gigahertz, exponent=9) + + def as_hertz(self) -> float: + """Return the frequency in hertz. + + Returns: + The frequency in hertz. + """ + return self._base_value + + def as_kilohertz(self) -> float: + """Return the frequency in kilohertz. + + Returns: + The frequency in kilohertz. + """ + return self._base_value / 1e3 + + def as_megahertz(self) -> float: + """Return the frequency in megahertz. + + Returns: + The frequency in megahertz. + """ + return self._base_value / 1e6 + + def as_gigahertz(self) -> float: + """Return the frequency in gigahertz. + + Returns: + The frequency in gigahertz. + """ + return self._base_value / 1e9 + + def period(self) -> timedelta: + """Return the period of the frequency. + + Returns: + The period of the frequency. + """ + return timedelta(seconds=1.0 / self._base_value) + + +class Percentage( + Quantity, + metaclass=_NoDefaultConstructible, + exponent_unit_map={0: "%"}, +): + """A percentage quantity. + + Objects of this type are wrappers around `float` values and are immutable. + + The constructors accept a single `float` value, the `as_*()` methods return a + `float` value, and each of the arithmetic operators supported by this type are + actually implemented using floating-point arithmetic. + + So all considerations about floating-point arithmetic apply to this type as well. + """ + + @classmethod + def from_percent(cls, percent: float) -> Self: + """Initialize a new percentage quantity from a percent value. + + Args: + percent: The percent value, normally in the 0.0-100.0 range. + + Returns: + A new percentage quantity. + """ + return cls._new(percent) + + @classmethod + def from_fraction(cls, fraction: float) -> Self: + """Initialize a new percentage quantity from a fraction. + + Args: + fraction: The fraction, normally in the 0.0-1.0 range. + + Returns: + A new percentage quantity. + """ + return cls._new(fraction * 100) + + def as_percent(self) -> float: + """Return this quantity as a percentage. + + Returns: + This quantity as a percentage. + """ + return self._base_value + + def as_fraction(self) -> float: + """Return this quantity as a fraction. + + Returns: + This quantity as a fraction. + """ + return self._base_value / 100 From 0882dafa49036d278d871547e87a668a5e2fc349 Mon Sep 17 00:00:00 2001 From: cwasicki <126617870+cwasicki@users.noreply.github.com> Date: Mon, 23 Sep 2024 21:26:04 +0200 Subject: [PATCH 6/9] Add sdk bridge --- main.py | 32 +++++++- .../client/reporting/sdk_reporting_bridge.py | 81 +++++++++++++++++++ 2 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 src/frequenz/client/reporting/sdk_reporting_bridge.py diff --git a/main.py b/main.py index 6e34343..cc76703 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,11 @@ from frequenz.client.reporting.component_graph import ComponentGraph, Component, Connection, ComponentCategory, InverterType import json +from frequenz.client.reporting import ReportingApiClient +from frequenz.client.reporting.sdk_reporting_bridge import list_microgrid_components_data_receiver +import asyncio +from datetime import datetime - +from frequenz.client.common.metric import Metric def build_graph(json_data: dict) -> ComponentGraph: components = [] connections = [] @@ -29,7 +33,7 @@ def build_graph(json_data: dict) -> ComponentGraph: return ComponentGraph(components, connections) -def main(): +async def main(): # Read JSON data from file with open("comps13.json", "r") as file: data = json.load(file) @@ -40,5 +44,27 @@ def main(): # Print component graph print(component_graph) + key = open("key.txt", "r").read().strip() + client = ReportingApiClient(server_url="grpc://reporting.api.frequenz.com:443?ssl=true", key=key) + + microgrid_id = 13 + component_ids = [256, 258] + microgrid_components = [ + (microgrid_id, component_ids), + ] + start_dt = datetime(2024, 9, 17) + end_dt = datetime(2024, 9, 18) + resolution = 900 + + async for sample in list_microgrid_components_data_receiver( + client, + microgrid_components=microgrid_components, + metrics=[Metric.AC_ACTIVE_POWER], + start_dt=start_dt, + end_dt=end_dt, + resolution=resolution, + ): + print(sample) + if __name__ == "__main__": - main() + asyncio.run(main()) diff --git a/src/frequenz/client/reporting/sdk_reporting_bridge.py b/src/frequenz/client/reporting/sdk_reporting_bridge.py new file mode 100644 index 0000000..3a8d67f --- /dev/null +++ b/src/frequenz/client/reporting/sdk_reporting_bridge.py @@ -0,0 +1,81 @@ +from frequenz.client.reporting import ReportingApiClient +from frequenz.client.common.metric import Metric + +from frequenz.channels import Receiver, ReceiverStoppedError +from frequenz.client.reporting._base_types import Sample +from frequenz.client.reporting._quantities import Quantity +from datetime import datetime + +class _BatchReceiver(Receiver[Sample[Quantity]]): + + def __init__(self, stream): + self._stream = stream + self._batch_iter = None + self._latest_sample = None + self._no_more_data = False + + async def ready(self) -> bool: + # If ready is called multiple times, we should return the same result + # so we don't loose any data + if self._latest_sample is not None: + return True + + while True: + # If we have a batch iterator, try to get the next sample + if self._batch_iter is not None: + try: + metric_sample = self._batch_iter.__next__() + self._latest_sample = Sample( + timestamp=metric_sample.timestamp, + value=Quantity(value=metric_sample.value), + ) + return True + # If the batch is done, set the batch iterator to None + except StopIteration: + self._batch_iter = None + + # If we don't have a batch iterator, try to get the next batch + try: + batch = await anext(self._stream) + self._batch_iter = batch.__iter__() + # If the stream is done, return False + except StopAsyncIteration: + self._no_more_data = True + return False + + def consume(self) -> Sample[Quantity]: + + sample = self._latest_sample + if sample is None: + if self._no_more_data: + raise ReceiverStoppedError(self) + raise(RuntimeError("Weird: consume called before ready")) + self._latest_sample = None + return sample + + +def list_microgrid_components_data_receiver( + client: ReportingApiClient, + *, + microgrid_components: list[tuple[int, list[int]]], + metrics: Metric | list[Metric], + start_dt: datetime, + end_dt: datetime, + resolution: int | None, + include_states: bool = False, + include_bounds: bool = False, +) -> Receiver[Sample[Quantity]]: + + stream = client._list_microgrid_components_data_batch( + microgrid_components=microgrid_components, + metrics=metrics, + start_dt=start_dt, + end_dt=end_dt, + resolution=resolution, + include_states=include_states, + include_bounds=include_bounds, + ) + return _BatchReceiver(stream) + + + From 318f236dee41543c30b290b03260a58246931eea Mon Sep 17 00:00:00 2001 From: cwasicki <126617870+cwasicki@users.noreply.github.com> Date: Mon, 23 Sep 2024 21:35:45 +0200 Subject: [PATCH 7/9] Copy formula engine from SDK v1.0.0-rc901 --- .../reporting/formula_engine/__init__.py | 119 ++ .../reporting/formula_engine/_exceptions.py | 8 + .../formula_engine/_formula_engine.py | 1117 +++++++++++++++++ .../formula_engine/_formula_engine_pool.py | 205 +++ .../formula_engine/_formula_evaluator.py | 134 ++ .../formula_engine/_formula_formatter.py | 265 ++++ .../_formula_generators/__init__.py | 50 + .../_battery_power_formula.py | 174 +++ .../_formula_generators/_chp_power_formula.py | 99 ++ .../_consumer_power_formula.py | 281 +++++ .../_ev_charger_current_formula.py | 88 ++ .../_ev_charger_power_formula.py | 50 + .../_fallback_formula_metric_fetcher.py | 80 ++ .../_formula_generators/_formula_generator.py | 257 ++++ .../_grid_current_formula.py | 79 ++ .../_grid_power_3_phase_formula.py | 91 ++ .../_grid_power_formula.py | 135 ++ .../_producer_power_formula.py | 148 +++ .../_formula_generators/_pv_power_formula.py | 141 +++ .../_simple_power_formula.py | 67 + .../formula_engine/_formula_steps.py | 575 +++++++++ .../_resampled_formula_builder.py | 154 +++ .../reporting/formula_engine/_tokenizer.py | 178 +++ 23 files changed, 4495 insertions(+) create mode 100644 src/frequenz/client/reporting/formula_engine/__init__.py create mode 100644 src/frequenz/client/reporting/formula_engine/_exceptions.py create mode 100644 src/frequenz/client/reporting/formula_engine/_formula_engine.py create mode 100644 src/frequenz/client/reporting/formula_engine/_formula_engine_pool.py create mode 100644 src/frequenz/client/reporting/formula_engine/_formula_evaluator.py create mode 100644 src/frequenz/client/reporting/formula_engine/_formula_formatter.py create mode 100644 src/frequenz/client/reporting/formula_engine/_formula_generators/__init__.py create mode 100644 src/frequenz/client/reporting/formula_engine/_formula_generators/_battery_power_formula.py create mode 100644 src/frequenz/client/reporting/formula_engine/_formula_generators/_chp_power_formula.py create mode 100644 src/frequenz/client/reporting/formula_engine/_formula_generators/_consumer_power_formula.py create mode 100644 src/frequenz/client/reporting/formula_engine/_formula_generators/_ev_charger_current_formula.py create mode 100644 src/frequenz/client/reporting/formula_engine/_formula_generators/_ev_charger_power_formula.py create mode 100644 src/frequenz/client/reporting/formula_engine/_formula_generators/_fallback_formula_metric_fetcher.py create mode 100644 src/frequenz/client/reporting/formula_engine/_formula_generators/_formula_generator.py create mode 100644 src/frequenz/client/reporting/formula_engine/_formula_generators/_grid_current_formula.py create mode 100644 src/frequenz/client/reporting/formula_engine/_formula_generators/_grid_power_3_phase_formula.py create mode 100644 src/frequenz/client/reporting/formula_engine/_formula_generators/_grid_power_formula.py create mode 100644 src/frequenz/client/reporting/formula_engine/_formula_generators/_producer_power_formula.py create mode 100644 src/frequenz/client/reporting/formula_engine/_formula_generators/_pv_power_formula.py create mode 100644 src/frequenz/client/reporting/formula_engine/_formula_generators/_simple_power_formula.py create mode 100644 src/frequenz/client/reporting/formula_engine/_formula_steps.py create mode 100644 src/frequenz/client/reporting/formula_engine/_resampled_formula_builder.py create mode 100644 src/frequenz/client/reporting/formula_engine/_tokenizer.py diff --git a/src/frequenz/client/reporting/formula_engine/__init__.py b/src/frequenz/client/reporting/formula_engine/__init__.py new file mode 100644 index 0000000..761d6db --- /dev/null +++ b/src/frequenz/client/reporting/formula_engine/__init__.py @@ -0,0 +1,119 @@ +# License: MIT +# Copyright © 2023 Frequenz Energy-as-a-Service GmbH + +"""Provides a way for the SDK to apply formulas on resampled data streams. + +# Formula Engine + +[`FormulaEngine`][frequenz.sdk.timeseries.formula_engine.FormulaEngine]s are used in the +SDK to calculate and stream metrics like +[`grid_power`][frequenz.sdk.timeseries.grid.Grid.power], +[`consumer_power`][frequenz.sdk.timeseries.consumer.Consumer.power], etc., which are +building blocks of the [Frequenz SDK Microgrid +Model][frequenz.sdk.microgrid--frequenz-sdk-microgrid-model]. + +The SDK creates the formulas by analysing the configuration of components in the +{{glossary("Component Graph")}}. + +## Streaming Interface + +The +[`FormulaEngine.new_receiver()`][frequenz.sdk.timeseries.formula_engine.FormulaEngine.new_receiver] +method can be used to create a [Receiver][frequenz.channels.Receiver] that streams the +[Sample][frequenz.sdk.timeseries.Sample]s calculated by the formula engine. + +```python +from frequenz.sdk import microgrid + +battery_pool = microgrid.new_battery_pool(priority=5) + +async for power in battery_pool.power.new_receiver(): + print(f"{power=}") +``` + +## Composition + +Composite `FormulaEngine`s can be built using arithmetic operations on +`FormulaEngine`s streaming the same type of data. + +For example, if you're interested in a particular composite metric that can be +calculated by subtracting +[`new_battery_pool().power`][frequenz.sdk.timeseries.battery_pool.BatteryPool.power] and +[`new_ev_charger_pool().power`][frequenz.sdk.timeseries.ev_charger_pool.EVChargerPool] +from the +[`grid().power`][frequenz.sdk.timeseries.grid.Grid.power], +we can build a `FormulaEngine` that provides a stream of this calculated metric as +follows: + +```python +from frequenz.sdk import microgrid + +battery_pool = microgrid.new_battery_pool(priority=5) +ev_charger_pool = microgrid.new_ev_charger_pool(priority=5) +grid = microgrid.grid() + +# apply operations on formula engines to create a formula engine that would +# apply these operations on the corresponding data streams. +net_power = ( + grid.power - (battery_pool.power + ev_charger_pool.power) +).build("net_power") + +async for power in net_power.new_receiver(): + print(f"{power=}") +``` + +# Formula Engine 3-Phase + +A [`FormulaEngine3Phase`][frequenz.sdk.timeseries.formula_engine.FormulaEngine3Phase] +is similar to a +[`FormulaEngine`][frequenz.sdk.timeseries.formula_engine.FormulaEngine], except that +they stream [3-phase samples][frequenz.sdk.timeseries.Sample3Phase]. All the +current formulas (like +[`Grid.current_per_phase`][frequenz.sdk.timeseries.grid.Grid.current_per_phase], +[`EVChargerPool.current_per_phase`][frequenz.sdk.timeseries.ev_charger_pool.EVChargerPool.current_per_phase], +etc.) are implemented as per-phase formulas. + +## Streaming Interface + +The +[`FormulaEngine3Phase.new_receiver()`][frequenz.sdk.timeseries.formula_engine.FormulaEngine3Phase.new_receiver] +method can be used to create a [Receiver][frequenz.channels.Receiver] that streams the +[Sample3Phase][frequenz.sdk.timeseries.Sample3Phase] values +calculated by the formula engine. + +```python +from frequenz.sdk import microgrid + +ev_charger_pool = microgrid.new_ev_charger_pool(priority=5) + +async for sample in ev_charger_pool.current_per_phase.new_receiver(): + print(f"Current: {sample}") +``` + +## Composition + +`FormulaEngine3Phase` instances can be composed together, just like `FormulaEngine` +instances. + +```python +from frequenz.sdk import microgrid + +ev_charger_pool = microgrid.new_ev_charger_pool(priority=5) +grid = microgrid.grid() + +# Calculate grid consumption current that's not used by the EV chargers +other_current = (grid.current_per_phase - ev_charger_pool.current_per_phase).build( + "other_current" +) + +async for sample in other_current.new_receiver(): + print(f"Other current: {sample}") +``` +""" + +from ._formula_engine import FormulaEngine, FormulaEngine3Phase + +__all__ = [ + "FormulaEngine", + "FormulaEngine3Phase", +] diff --git a/src/frequenz/client/reporting/formula_engine/_exceptions.py b/src/frequenz/client/reporting/formula_engine/_exceptions.py new file mode 100644 index 0000000..71f5575 --- /dev/null +++ b/src/frequenz/client/reporting/formula_engine/_exceptions.py @@ -0,0 +1,8 @@ +# 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/client/reporting/formula_engine/_formula_engine.py b/src/frequenz/client/reporting/formula_engine/_formula_engine.py new file mode 100644 index 0000000..eabd83e --- /dev/null +++ b/src/frequenz/client/reporting/formula_engine/_formula_engine.py @@ -0,0 +1,1117 @@ +# 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 ..._internal._asyncio import cancel_and_await +from .. import Sample, Sample3Phase +from .._quantities import Quantity, QuantityT +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) + + @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.sdk.timeseries 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.exception("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.exception("FormulaEngine task cancelled: %s", self._name) + break + else: + 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.sdk.timeseries 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.setdefault( + name, + MetricFetcher( + name, + data_stream, + nones_are_zeros=nones_are_zeros, + fallback=fallback, + ), + ) + 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.sdk.timeseries 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.sdk.timeseries 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] + | QuantityT + | 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/client/reporting/formula_engine/_formula_engine_pool.py b/src/frequenz/client/reporting/formula_engine/_formula_engine_pool.py new file mode 100644 index 0000000..5658af2 --- /dev/null +++ b/src/frequenz/client/reporting/formula_engine/_formula_engine_pool.py @@ -0,0 +1,205 @@ +# 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 import ComponentMetricId + +from ..._internal._channels import ChannelRegistry +from ...microgrid._data_sourcing import ComponentMetricRequest +from .._quantities import Current, Power, Quantity +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]] = {} + + def from_string( + self, + formula: str, + component_metric_id: ComponentMetricId, + *, + nones_are_zeros: bool = False, + ) -> FormulaEngine[Quantity]: + """Get a receiver for a manual formula. + + Args: + formula: formula to execute. + component_metric_id: The metric ID 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 + component_metric_id.value + if channel_key in self._string_engines: + return self._string_engines[channel_key] + + builder = ResampledFormulaBuilder( + self._namespace, + formula, + self._channel_registry, + self._resampler_subscription_sender, + component_metric_id, + 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_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() # pylint: disable=protected-access + for power_engine in self._power_engines.values(): + await power_engine._stop() # pylint: disable=protected-access + for power_3_phase_engine in self._power_3_phase_engines.values(): + await power_3_phase_engine._stop() # pylint: disable=protected-access + for current_engine in self._current_engines.values(): + await current_engine._stop() # pylint: disable=protected-access diff --git a/src/frequenz/client/reporting/formula_engine/_formula_evaluator.py b/src/frequenz/client/reporting/formula_engine/_formula_evaluator.py new file mode 100644 index 0000000..3e32f9d --- /dev/null +++ b/src/frequenz/client/reporting/formula_engine/_formula_evaluator.py @@ -0,0 +1,134 @@ +# 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 .. import Sample +from .._quantities import QuantityT +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/client/reporting/formula_engine/_formula_formatter.py b/src/frequenz/client/reporting/formula_engine/_formula_formatter.py new file mode 100644 index 0000000..8a246fb --- /dev/null +++ b/src/frequenz/client/reporting/formula_engine/_formula_formatter.py @@ -0,0 +1,265 @@ +# 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 = metric_fetcher._name # pylint: disable=protected-access + 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/client/reporting/formula_engine/_formula_generators/__init__.py b/src/frequenz/client/reporting/formula_engine/_formula_generators/__init__.py new file mode 100644 index 0000000..e2f9292 --- /dev/null +++ b/src/frequenz/client/reporting/formula_engine/_formula_generators/__init__.py @@ -0,0 +1,50 @@ +# 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 ._producer_power_formula import ProducerPowerFormula +from ._pv_power_formula import PVPowerFormula + +__all__ = [ + # + # Base class + # + "FormulaGenerator", + "FormulaGeneratorConfig", + # + # Power Formula generators + # + "CHPPowerFormula", + "ConsumerPowerFormula", + "GridPower3PhaseFormula", + "GridPowerFormula", + "BatteryPowerFormula", + "EVChargerPowerFormula", + "PVPowerFormula", + "ProducerPowerFormula", + # + # Current formula generators + # + "GridCurrentFormula", + "EVChargerCurrentFormula", + # + # Exceptions + # + "ComponentNotFound", + "FormulaGenerationError", +] diff --git a/src/frequenz/client/reporting/formula_engine/_formula_generators/_battery_power_formula.py b/src/frequenz/client/reporting/formula_engine/_formula_generators/_battery_power_formula.py new file mode 100644 index 0000000..ae1c3bd --- /dev/null +++ b/src/frequenz/client/reporting/formula_engine/_formula_generators/_battery_power_formula.py @@ -0,0 +1,174 @@ +# 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 import Component, ComponentCategory, ComponentMetricId + +from ....microgrid import connection_manager +from ..._quantities import Power +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 grid 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. + FormulaGenerationError: If not all batteries behind a set of inverters + have been requested. + """ + builder = self._get_builder( + "battery-power", ComponentMetricId.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.component_id + ) + battery_ids = set( + map(lambda battery: battery.component_id, all_connected_batteries) + ) + if not battery_ids.issubset(component_ids): + raise FormulaGenerationError( + f"Not all batteries behind inverter {inverter.component_id} " + 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.component_id, + nones_are_zeros=( + primary_component.category != ComponentCategory.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.component_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.component_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/client/reporting/formula_engine/_formula_generators/_chp_power_formula.py b/src/frequenz/client/reporting/formula_engine/_formula_generators/_chp_power_formula.py new file mode 100644 index 0000000..755022d --- /dev/null +++ b/src/frequenz/client/reporting/formula_engine/_formula_generators/_chp_power_formula.py @@ -0,0 +1,99 @@ +# 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.microgrid import ComponentCategory, ComponentMetricId + +from ....microgrid import connection_manager +from ..._quantities import Power +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", ComponentMetricId.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[int]: + """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 = list( + comp + for comp in component_graph.components() + if comp.category == ComponentCategory.CHP + ) + + chp_meters: set[int] = set() + for chp in chps: + predecessors = component_graph.predecessors(chp.component_id) + if len(predecessors) != 1: + raise FormulaGenerationError( + f"CHP {chp.component_id} has {len(predecessors)} predecessors. " + " Expected exactly one." + ) + meter = next(iter(predecessors)) + if meter.category != ComponentCategory.METER: + raise FormulaGenerationError( + f"CHP {chp.component_id} has a predecessor of category " + f"{meter.category}. Expected ComponentCategory.METER." + ) + meter_successors = component_graph.successors(meter.component_id) + if not all(successor in chps for successor in meter_successors): + raise FormulaGenerationError( + f"Meter {meter.component_id} connected to CHP {chp.component_id}" + "has non-chp successors." + ) + chp_meters.add(meter.component_id) + return chp_meters diff --git a/src/frequenz/client/reporting/formula_engine/_formula_generators/_consumer_power_formula.py b/src/frequenz/client/reporting/formula_engine/_formula_generators/_consumer_power_formula.py new file mode 100644 index 0000000..67dcc58 --- /dev/null +++ b/src/frequenz/client/reporting/formula_engine/_formula_generators/_consumer_power_formula.py @@ -0,0 +1,281 @@ +# License: MIT +# Copyright © 2022 Frequenz Energy-as-a-Service GmbH + +"""Formula generator from component graph for Consumer Power.""" + +import logging + +from frequenz.client.microgrid import Component, ComponentCategory, ComponentMetricId + +from ....microgrid import connection_manager +from ..._quantities import Power +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_power_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( + successor.category == ComponentCategory.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 + ) + + def generate(self) -> FormulaEngine[Power]: + """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", ComponentMetricId.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.component_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.component_id, + nones_are_zeros=( + primary_component.category != ComponentCategory.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.component_id, + nones_are_zeros=component.category != ComponentCategory.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 ( + component.category + in {ComponentCategory.METER, ComponentCategory.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.component_id, + nones_are_zeros=( + primary_component.category != ComponentCategory.METER + ), + fallback=fallback_formula, + ) + else: + for idx, component in enumerate(consumer_components): + if idx > 0: + builder.push_oper("+") + + builder.push_component_metric( + component.component_id, + nones_are_zeros=component.category != ComponentCategory.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.component_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/client/reporting/formula_engine/_formula_generators/_ev_charger_current_formula.py b/src/frequenz/client/reporting/formula_engine/_formula_generators/_ev_charger_current_formula.py new file mode 100644 index 0000000..bbcd176 --- /dev/null +++ b/src/frequenz/client/reporting/formula_engine/_formula_generators/_ev_charger_current_formula.py @@ -0,0 +1,88 @@ +# 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.microgrid import ComponentMetricId + +from ..._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", ComponentMetricId.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, ComponentMetricId.CURRENT_PHASE_1 + ) + ), + ( + self._gen_phase_formula( + component_ids, ComponentMetricId.CURRENT_PHASE_2 + ) + ), + ( + self._gen_phase_formula( + component_ids, ComponentMetricId.CURRENT_PHASE_3 + ) + ), + ), + ) + + def _gen_phase_formula( + self, + component_ids: abc.Set[int], + metric_id: ComponentMetricId, + ) -> FormulaEngine[Current]: + builder = self._get_builder("ev-current", metric_id, 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/client/reporting/formula_engine/_formula_generators/_ev_charger_power_formula.py b/src/frequenz/client/reporting/formula_engine/_formula_generators/_ev_charger_power_formula.py new file mode 100644 index 0000000..80ce5c6 --- /dev/null +++ b/src/frequenz/client/reporting/formula_engine/_formula_generators/_ev_charger_power_formula.py @@ -0,0 +1,50 @@ +# License: MIT +# Copyright © 2023 Frequenz Energy-as-a-Service GmbH + +"""Formula generator from component graph for Grid Power.""" + +import logging + +from frequenz.client.microgrid import ComponentMetricId + +from ..._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", ComponentMetricId.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/client/reporting/formula_engine/_formula_generators/_fallback_formula_metric_fetcher.py b/src/frequenz/client/reporting/formula_engine/_formula_generators/_fallback_formula_metric_fetcher.py new file mode 100644 index 0000000..fababba --- /dev/null +++ b/src/frequenz/client/reporting/formula_engine/_formula_generators/_fallback_formula_metric_fetcher.py @@ -0,0 +1,80 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""FallbackMetricFetcher implementation that uses formula generator.""" + +from frequenz.channels import Receiver + +from ... import Sample +from ..._quantities import QuantityT +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() diff --git a/src/frequenz/client/reporting/formula_engine/_formula_generators/_formula_generator.py b/src/frequenz/client/reporting/formula_engine/_formula_generators/_formula_generator.py new file mode 100644 index 0000000..1283f9e --- /dev/null +++ b/src/frequenz/client/reporting/formula_engine/_formula_generators/_formula_generator.py @@ -0,0 +1,257 @@ +# 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.microgrid import Component, ComponentCategory, ComponentMetricId + +from ...._internal._channels import ChannelRegistry +from ....microgrid import connection_manager +from ....microgrid._data_sourcing import ComponentMetricRequest +from ..._quantities 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 = 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[int] | 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, + component_metric_id: ComponentMetricId, + create_method: Callable[[float], QuantityT], + ) -> ResampledFormulaBuilder[QuantityT]: + builder = ResampledFormulaBuilder( + self._namespace, + name, + self._channel_registry, + self._resampler_subscription_sender, + component_metric_id, + 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( + component_categories={ComponentCategory.GRID} + ) + ), + 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.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 component.category == ComponentCategory.METER: + fallbacks[component] = self._get_meter_fallback_components(component) + else: + predecessors = graph.predecessors(component.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 meter.category == ComponentCategory.METER + + graph = connection_manager.get().component_graph + successors = graph.successors(meter.component_id) + + # All fallbacks has to be of the same type and category. + if ( + all(graph.is_chp(c) for c in successors) + or 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/client/reporting/formula_engine/_formula_generators/_grid_current_formula.py b/src/frequenz/client/reporting/formula_engine/_formula_generators/_grid_current_formula.py new file mode 100644 index 0000000..59e3730 --- /dev/null +++ b/src/frequenz/client/reporting/formula_engine/_formula_generators/_grid_current_formula.py @@ -0,0 +1,79 @@ +# License: MIT +# Copyright © 2022 Frequenz Energy-as-a-Service GmbH + +"""Formula generator from component graph for 3-phase Grid Current.""" + +from frequenz.client.microgrid import Component, ComponentCategory, ComponentMetricId + +from ..._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, ComponentMetricId.CURRENT_PHASE_1 + ), + self._gen_phase_formula( + grid_successors, ComponentMetricId.CURRENT_PHASE_2 + ), + self._gen_phase_formula( + grid_successors, ComponentMetricId.CURRENT_PHASE_3 + ), + ), + ) + + def _gen_phase_formula( + self, + grid_successors: set[Component], + metric_id: ComponentMetricId, + ) -> FormulaEngine[Current]: + builder = self._get_builder("grid-current", metric_id, 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. + if comp.category in ( + ComponentCategory.INVERTER, + ComponentCategory.EV_CHARGER, + ): + nones_are_zeros = True + elif comp.category == ComponentCategory.METER: + nones_are_zeros = False + else: + continue + + if idx > 0: + builder.push_oper("+") + + builder.push_component_metric( + comp.component_id, nones_are_zeros=nones_are_zeros + ) + + return builder.build() diff --git a/src/frequenz/client/reporting/formula_engine/_formula_generators/_grid_power_3_phase_formula.py b/src/frequenz/client/reporting/formula_engine/_formula_generators/_grid_power_3_phase_formula.py new file mode 100644 index 0000000..e6fd279 --- /dev/null +++ b/src/frequenz/client/reporting/formula_engine/_formula_generators/_grid_power_3_phase_formula.py @@ -0,0 +1,91 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Formula generator from component graph for 3-phase Grid Power.""" + +from frequenz.client.microgrid import Component, ComponentCategory, ComponentMetricId + +from ..._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, ComponentMetricId.ACTIVE_POWER_PHASE_1 + ), + self._gen_phase_formula( + grid_successors, ComponentMetricId.ACTIVE_POWER_PHASE_2 + ), + self._gen_phase_formula( + grid_successors, ComponentMetricId.ACTIVE_POWER_PHASE_3 + ), + ), + ) + + def _gen_phase_formula( + self, + grid_successors: set[Component], + metric_id: ComponentMetricId, + ) -> 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_id: 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_id, 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. + if comp.category in ( + ComponentCategory.INVERTER, + ComponentCategory.EV_CHARGER, + ): + nones_are_zeros = True + elif comp.category == ComponentCategory.METER: + nones_are_zeros = False + else: + continue + + if idx > 0: + formula_builder.push_oper("+") + + formula_builder.push_component_metric( + comp.component_id, nones_are_zeros=nones_are_zeros + ) + + return formula_builder.build() diff --git a/src/frequenz/client/reporting/formula_engine/_formula_generators/_grid_power_formula.py b/src/frequenz/client/reporting/formula_engine/_formula_generators/_grid_power_formula.py new file mode 100644 index 0000000..ce60936 --- /dev/null +++ b/src/frequenz/client/reporting/formula_engine/_formula_generators/_grid_power_formula.py @@ -0,0 +1,135 @@ +# License: MIT +# Copyright © 2022 Frequenz Energy-as-a-Service GmbH + +"""Formula generator from component graph for Grid Power.""" + +from frequenz.client.microgrid import Component, ComponentCategory, ComponentMetricId + +from ..._quantities import Power +from .._formula_engine import FormulaEngine +from ._fallback_formula_metric_fetcher import FallbackFormulaMetricFetcher +from ._formula_generator import ( + ComponentNotFound, + FormulaGenerator, + FormulaGeneratorConfig, +) +from ._simple_power_formula import SimplePowerFormula + + +class GridPowerFormula(FormulaGenerator[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", ComponentMetricId.ACTIVE_POWER, Power.from_watts + ) + grid_successors = self._get_grid_component_successors() + + components = { + c + for c in grid_successors + if c.category + in { + ComponentCategory.INVERTER, + ComponentCategory.EV_CHARGER, + ComponentCategory.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.component_id, + nones_are_zeros=( + primary_component.category != ComponentCategory.METER + ), + fallback=fallback_formula, + ) + else: + for idx, comp in enumerate(components): + if idx > 0: + builder.push_oper("+") + + builder.push_component_metric( + comp.component_id, + nones_are_zeros=(comp.category != ComponentCategory.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 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. + """ + 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.component_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/client/reporting/formula_engine/_formula_generators/_producer_power_formula.py b/src/frequenz/client/reporting/formula_engine/_formula_generators/_producer_power_formula.py new file mode 100644 index 0000000..7710a69 --- /dev/null +++ b/src/frequenz/client/reporting/formula_engine/_formula_generators/_producer_power_formula.py @@ -0,0 +1,148 @@ +# 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 import Component, ComponentCategory, ComponentMetricId + +from ....microgrid import connection_manager +from ..._quantities import Power +from .._formula_engine import FormulaEngine +from ._fallback_formula_metric_fetcher import FallbackFormulaMetricFetcher +from ._formula_generator import ( + NON_EXISTING_COMPONENT_ID, + FormulaGenerator, + FormulaGeneratorConfig, +) +from ._simple_power_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", ComponentMetricId.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: component.category != ComponentCategory.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.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.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.component_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/client/reporting/formula_engine/_formula_generators/_pv_power_formula.py b/src/frequenz/client/reporting/formula_engine/_formula_generators/_pv_power_formula.py new file mode 100644 index 0000000..98bd873 --- /dev/null +++ b/src/frequenz/client/reporting/formula_engine/_formula_generators/_pv_power_formula.py @@ -0,0 +1,141 @@ +# 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 import Component, ComponentCategory, ComponentMetricId + +from ....microgrid import connection_manager +from ..._quantities import Power +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", ComponentMetricId.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.component_id, + nones_are_zeros=( + primary_component.category != ComponentCategory.METER + ), + fallback=fallback_formula, + ) + else: + for idx, component in enumerate(pv_components): + if idx > 0: + builder.push_oper("+") + + builder.push_component_metric( + component.component_id, + nones_are_zeros=component.category != ComponentCategory.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.component_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/client/reporting/formula_engine/_formula_generators/_simple_power_formula.py b/src/frequenz/client/reporting/formula_engine/_formula_generators/_simple_power_formula.py new file mode 100644 index 0000000..79b2fb3 --- /dev/null +++ b/src/frequenz/client/reporting/formula_engine/_formula_generators/_simple_power_formula.py @@ -0,0 +1,67 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Formula generator from component graph.""" + +from frequenz.client.microgrid import ComponentCategory, ComponentMetricId + +from ....microgrid import connection_manager +from ..._quantities import Power +from .._formula_engine import FormulaEngine +from ._formula_generator import FormulaGenerator + + +class SimplePowerFormula(FormulaGenerator[Power]): + """Formula generator from component graph for calculating sum of Power. + + Raises: + RuntimeError: If no components are defined in the config or if any + component is not found in the component graph. + """ + + 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( + "simple_power_formula", ComponentMetricId.ACTIVE_POWER, Power.from_watts + ) + + 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( + component_ids=set(self._config.component_ids) + ) + + not_found_components = self._config.component_ids - { + c.component_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.component_id, + nones_are_zeros=component.category != ComponentCategory.METER, + ) + + return builder.build() diff --git a/src/frequenz/client/reporting/formula_engine/_formula_steps.py b/src/frequenz/client/reporting/formula_engine/_formula_steps.py new file mode 100644 index 0000000..d6c4785 --- /dev/null +++ b/src/frequenz/client/reporting/formula_engine/_formula_steps.py @@ -0,0 +1,575 @@ +# 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 Any, Generic + +from frequenz.channels import Receiver, ReceiverError + +from .. import Sample +from .._quantities import QuantityT + +_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.""" + + +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 + + @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 + + 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 _synchronize_and_fetch_fallback( + self, + primary_fetcher_sample: Sample[QuantityT], + fallback_fetcher: FallbackMetricFetcher[QuantityT], + ) -> Sample[QuantityT] | None: + """Synchronize the fallback fetcher and return the fallback value. + + Args: + primary_fetcher_sample: 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. + """ + # fallback_fetcher was not used, yet. We need to fetch first value. + if self._latest_fallback_sample is None: + try: + self._latest_fallback_sample = await fallback_fetcher.receive() + except ReceiverError[Any] as err: + _logger.error( + "Fallback metric fetcher %s failed to fetch next value: %s." + "Using primary metric fetcher.", + fallback_fetcher.name, + err, + ) + return None + + if primary_fetcher_sample.timestamp < self._latest_fallback_sample.timestamp: + return None + + # Synchronize the fallback fetcher with primary one + while primary_fetcher_sample.timestamp > self._latest_fallback_sample.timestamp: + try: + self._latest_fallback_sample = await fallback_fetcher.receive() + except ReceiverError[Any] as err: + _logger.error( + "Fallback metric fetcher %s failed to fetch next value: %s." + "Using primary metric fetcher.", + fallback_fetcher.name, + err, + ) + return None + + return self._latest_fallback_sample + + async def fetch_next_with_fallback( + self, fallback_fetcher: FallbackMetricFetcher[QuantityT] + ) -> Sample[QuantityT]: + """Fetch the next value from the primary and fallback streams. + + Return the value from the stream that returns a valid value. + If any stream raises an exception, then return the value from + the other stream. + + Args: + fallback_fetcher: The fallback metric fetcher. + + Returns: + The value fetched from either the primary or fallback stream. + """ + try: + primary = await self._stream.receive() + except ReceiverError[Any] as err: + _logger.error( + "Primary metric fetcher %s failed to fetch next value: %s." + "Using fallback metric fetcher.", + self._name, + err, + ) + return await fallback_fetcher.receive() + + fallback = await self._synchronize_and_fetch_fallback(primary, fallback_fetcher) + if fallback is None: + return primary + + if self._is_value_valid(primary.value): + return primary + return fallback + + 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. + """ + self._next_value = await self._fetch_next() + return self._next_value + + async def _fetch_next(self) -> Sample[QuantityT] | None: + if self._fallback is None: + return await self._stream.receive() + + if self._fallback.is_running: + return await self.fetch_next_with_fallback(self._fallback) + + next_value = None + try: + next_value = await self._stream.receive() + except ReceiverError[Any] as err: + _logger.error("Failed to fetch next value from %s: %s", self._name, err) + else: + if self._is_value_valid(next_value.value): + return next_value + + _logger.warning( + "Primary metric %s is invalid. Running fallback metric fetcher: %s", + self._name, + self._fallback.name, + ) + # start fallback formula but don't wait for it because it has to + # synchronize. Just return invalid value. + self._fallback.start() + return next_value + + @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/client/reporting/formula_engine/_resampled_formula_builder.py b/src/frequenz/client/reporting/formula_engine/_resampled_formula_builder.py new file mode 100644 index 0000000..9c980ef --- /dev/null +++ b/src/frequenz/client/reporting/formula_engine/_resampled_formula_builder.py @@ -0,0 +1,154 @@ +# 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.microgrid import ComponentMetricId + +from ..._internal._channels import ChannelRegistry +from ...microgrid._data_sourcing import ComponentMetricRequest +from .. import Sample +from .._quantities import Quantity, QuantityT +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_id: ComponentMetricId, + 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_id: A metric ID 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_id: ComponentMetricId = metric_id + self._resampler_requests: list[ComponentMetricRequest] = [] + super().__init__(formula_name, create_method) # type: ignore[arg-type] + + def _get_resampled_receiver( + self, component_id: int, metric_id: ComponentMetricId + ) -> 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_id: A metric ID 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_id, 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: int, + *, + 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_id) + self.push_metric( + f"#{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( + 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/client/reporting/formula_engine/_tokenizer.py b/src/frequenz/client/reporting/formula_engine/_tokenizer.py new file mode 100644 index 0000000..3301689 --- /dev/null +++ b/src/frequenz/client/reporting/formula_engine/_tokenizer.py @@ -0,0 +1,178 @@ +# 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() From 18a52b245397e200cc27f7ef4b7ee15bd1e32a51 Mon Sep 17 00:00:00 2001 From: cwasicki <126617870+cwasicki@users.noreply.github.com> Date: Mon, 23 Sep 2024 21:35:57 +0200 Subject: [PATCH 8/9] Adapt formula engine --- src/frequenz/client/reporting/__init__.py | 2 +- .../formula_engine/_formula_engine.py | 8 ++- .../_formula_generators/__init__.py | 36 ++++++------ .../_formula_generators/_formula_generator.py | 52 ++++++++++------- .../_formula_generators/_pv_power_formula.py | 18 +++--- .../_resampled_formula_builder.py | 57 ++++++------------- 6 files changed, 79 insertions(+), 94 deletions(-) diff --git a/src/frequenz/client/reporting/__init__.py b/src/frequenz/client/reporting/__init__.py index 43a52d4..228fba3 100644 --- a/src/frequenz/client/reporting/__init__.py +++ b/src/frequenz/client/reporting/__init__.py @@ -6,7 +6,7 @@ This package provides a low-level interface for interacting with the reporting API. """ - +from ._base_types import * from ._client import ReportingApiClient __all__ = ["ReportingApiClient"] diff --git a/src/frequenz/client/reporting/formula_engine/_formula_engine.py b/src/frequenz/client/reporting/formula_engine/_formula_engine.py index eabd83e..8b5dde1 100644 --- a/src/frequenz/client/reporting/formula_engine/_formula_engine.py +++ b/src/frequenz/client/reporting/formula_engine/_formula_engine.py @@ -16,8 +16,8 @@ from frequenz.channels import Broadcast, Receiver -from ..._internal._asyncio import cancel_and_await -from .. import Sample, Sample3Phase +#from ..._internal._asyncio import cancel_and_await +from .._base_types import Sample, Sample3Phase from .._quantities import Quantity, QuantityT from ._formula_evaluator import FormulaEvaluator from ._formula_formatter import format_formula @@ -316,6 +316,7 @@ async def _run(self) -> None: _logger.warning( "Formula application failed: %s. Error: %s", self._name, err ) + raise else: await sender.send(msg) @@ -429,7 +430,8 @@ async def _stop(self) -> None: """Stop a running formula engine.""" if self._task is None: return - await cancel_and_await(self._task) + # FIXME + #await cancel_and_await(self._task) def __add__( self, diff --git a/src/frequenz/client/reporting/formula_engine/_formula_generators/__init__.py b/src/frequenz/client/reporting/formula_engine/_formula_generators/__init__.py index e2f9292..a252832 100644 --- a/src/frequenz/client/reporting/formula_engine/_formula_generators/__init__.py +++ b/src/frequenz/client/reporting/formula_engine/_formula_generators/__init__.py @@ -3,21 +3,21 @@ """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 ._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 ._producer_power_formula import ProducerPowerFormula +#from ._grid_current_formula import GridCurrentFormula +#from ._grid_power_3_phase_formula import GridPower3PhaseFormula +#from ._grid_power_formula import GridPowerFormula +#from ._producer_power_formula import ProducerPowerFormula from ._pv_power_formula import PVPowerFormula __all__ = [ @@ -29,19 +29,19 @@ # # Power Formula generators # - "CHPPowerFormula", - "ConsumerPowerFormula", - "GridPower3PhaseFormula", - "GridPowerFormula", - "BatteryPowerFormula", - "EVChargerPowerFormula", +# "CHPPowerFormula", +# "ConsumerPowerFormula", +# "GridPower3PhaseFormula", +# "GridPowerFormula", +# "BatteryPowerFormula", +# "EVChargerPowerFormula", "PVPowerFormula", - "ProducerPowerFormula", +# "ProducerPowerFormula", # # Current formula generators # - "GridCurrentFormula", - "EVChargerCurrentFormula", +# "GridCurrentFormula", +# "EVChargerCurrentFormula", # # Exceptions # diff --git a/src/frequenz/client/reporting/formula_engine/_formula_generators/_formula_generator.py b/src/frequenz/client/reporting/formula_engine/_formula_generators/_formula_generator.py index 1283f9e..900ca30 100644 --- a/src/frequenz/client/reporting/formula_engine/_formula_generators/_formula_generator.py +++ b/src/frequenz/client/reporting/formula_engine/_formula_generators/_formula_generator.py @@ -13,11 +13,11 @@ from typing import Generic from frequenz.channels import Sender -from frequenz.client.microgrid import Component, ComponentCategory, ComponentMetricId +from frequenz.client.reporting.component_graph import Component, ComponentCategory, ComponentMetricId -from ...._internal._channels import ChannelRegistry -from ....microgrid import connection_manager -from ....microgrid._data_sourcing import ComponentMetricRequest +#from ...._internal._channels import ChannelRegistry +#from ....microgrid import connection_manager +#from ....microgrid._data_sourcing import ComponentMetricRequest from ..._quantities import QuantityT from .._formula_engine import FormulaEngine, FormulaEngine3Phase from .._resampled_formula_builder import ResampledFormulaBuilder @@ -57,10 +57,12 @@ class FormulaGenerator(ABC, Generic[QuantityT]): def __init__( self, - namespace: str, - channel_registry: ChannelRegistry, - resampler_subscription_sender: Sender[ComponentMetricRequest], + get_receiver: Callable[[int, ComponentMetricId], Receiver[Sample[Quantity]]], + #namespace: str, + #channel_registry: ChannelRegistry, + #resampler_subscription_sender: Sender[ComponentMetricRequest], config: FormulaGeneratorConfig, + component_graph: ComponentGraph, ) -> None: """Create a `FormulaGenerator` instance. @@ -72,17 +74,19 @@ def __init__( 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._channel_registry: ChannelRegistry = channel_registry + #self._resampler_subscription_sender: Sender[ComponentMetricRequest] = ( + # resampler_subscription_sender + #) + #self._namespace: str = namespace + self._get_receiver = get_receiver self._config: FormulaGeneratorConfig = config + self._component_graph: ComponentGraph = component_graph @property def namespace(self) -> str: """Get the namespace for the formula generator.""" - return self._namespace + return "bla" #self._namespace def _get_builder( self, @@ -91,10 +95,11 @@ def _get_builder( create_method: Callable[[float], QuantityT], ) -> ResampledFormulaBuilder[QuantityT]: builder = ResampledFormulaBuilder( - self._namespace, + #self._namespace, name, - self._channel_registry, - self._resampler_subscription_sender, + #self._channel_registry, + #self._resampler_subscription_sender, + self._get_receiver, component_metric_id, create_method, ) @@ -110,7 +115,8 @@ def _get_grid_component(self) -> Component: Raises: ComponentNotFound: If the grid component is not found in the component graph. """ - component_graph = connection_manager.get().component_graph + #component_graph = connection_manager.get().component_graph + component_graph = self._component_graph grid_component = next( iter( component_graph.components( @@ -134,7 +140,8 @@ def _get_grid_component_successors(self) -> set[Component]: ComponentNotFound: If no successor components are found in the component graph. """ grid_component = self._get_grid_component() - component_graph = connection_manager.get().component_graph + #component_graph = connection_manager.get().component_graph + component_graph = self._component_graph grid_successors = component_graph.successors(grid_component.component_id) if not grid_successors: @@ -178,7 +185,8 @@ def _get_metric_fallback_components( * The keys are primary components. * The values are sets of fallback components. """ - graph = connection_manager.get().component_graph + #graph = connection_manager.get().component_graph + graph = self._component_graph fallbacks: dict[Component, set[Component]] = {} for component in components: @@ -210,7 +218,8 @@ def _get_meter_fallback_components(self, meter: Component) -> set[Component]: """ assert meter.category == ComponentCategory.METER - graph = connection_manager.get().component_graph + #graph = connection_manager.get().component_graph + graph = self._component_graph successors = graph.successors(meter.component_id) # All fallbacks has to be of the same type and category. @@ -241,7 +250,8 @@ def _is_primary_fallback_pair( Returns: bool: True if the provided components are a primary-fallback pair, False otherwise. """ - graph = connection_manager.get().component_graph + #graph = connection_manager.get().component_graph + graph = self._component_graph # reassign to decrease the length of the line and make code readable fallback = fallback_candidate diff --git a/src/frequenz/client/reporting/formula_engine/_formula_generators/_pv_power_formula.py b/src/frequenz/client/reporting/formula_engine/_formula_generators/_pv_power_formula.py index 98bd873..0e670d5 100644 --- a/src/frequenz/client/reporting/formula_engine/_formula_generators/_pv_power_formula.py +++ b/src/frequenz/client/reporting/formula_engine/_formula_generators/_pv_power_formula.py @@ -5,9 +5,9 @@ import logging -from frequenz.client.microgrid import Component, ComponentCategory, ComponentMetricId +from frequenz.client.reporting.component_graph import Component, ComponentCategory, ComponentMetricId -from ....microgrid import connection_manager +#from ....microgrid import connection_manager from ..._quantities import Power from .._formula_engine import FormulaEngine from ._fallback_formula_metric_fetcher import FallbackFormulaMetricFetcher @@ -27,6 +27,7 @@ def generate( # noqa: DOC502 # * ComponentNotFound is raised indirectly by _get_pv_power_components # * RuntimeError is also raised indirectly by _get_pv_power_components self, + #component_graph: "ComponentGraph", ) -> FormulaEngine[Power]: """Make a formula for the PV power production of a microgrid. @@ -42,7 +43,8 @@ def generate( # noqa: DOC502 "pv-power", ComponentMetricId.ACTIVE_POWER, Power.from_watts ) - component_graph = connection_manager.get().component_graph + #component_graph = connection_manager.get().component_graph + component_graph = self._component_graph component_ids = self._config.component_ids if component_ids: pv_components = component_graph.components(set(component_ids)) @@ -125,13 +127,9 @@ def _get_fallback_formulas( fallback_ids = [c.component_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, - ), + self._get_receiver, + self._config, + self._component_graph, ) fallback_formulas[primary_component] = FallbackFormulaMetricFetcher( diff --git a/src/frequenz/client/reporting/formula_engine/_resampled_formula_builder.py b/src/frequenz/client/reporting/formula_engine/_resampled_formula_builder.py index 9c980ef..d914262 100644 --- a/src/frequenz/client/reporting/formula_engine/_resampled_formula_builder.py +++ b/src/frequenz/client/reporting/formula_engine/_resampled_formula_builder.py @@ -8,10 +8,10 @@ from collections.abc import Callable from frequenz.channels import Receiver, Sender -from frequenz.client.microgrid import ComponentMetricId +from frequenz.client.reporting.component_graph import ComponentMetricId -from ..._internal._channels import ChannelRegistry -from ...microgrid._data_sourcing import ComponentMetricRequest +#from ..._internal._channels import ChannelRegistry +#from ...microgrid._data_sourcing import ComponentMetricRequest from .. import Sample from .._quantities import Quantity, QuantityT from ._formula_engine import FormulaBuilder, FormulaEngine @@ -24,10 +24,11 @@ class ResampledFormulaBuilder(FormulaBuilder[QuantityT]): def __init__( # pylint: disable=too-many-arguments self, - namespace: str, + #namespace: str, formula_name: str, - channel_registry: ChannelRegistry, - resampler_subscription_sender: Sender[ComponentMetricRequest], + #channel_registry: ChannelRegistry, + #resampler_subscription_sender: Sender[ComponentMetricRequest], + get_receiver: Callable[[int, ComponentMetricId], Receiver[Sample[Quantity]]], metric_id: ComponentMetricId, create_method: Callable[[float], QuantityT], ) -> None: @@ -46,44 +47,18 @@ def __init__( # pylint: disable=too-many-arguments 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._channel_registry: ChannelRegistry = channel_registry + #self._resampler_subscription_sender: Sender[ComponentMetricRequest] = ( + # resampler_subscription_sender + #) + #self._namespace: str = namespace + self._get_resampled_receiver: Callable[ + [int, ComponentMetricId], Receiver[Sample[Quantity]] + ] = get_receiver self._metric_id: ComponentMetricId = metric_id - self._resampler_requests: list[ComponentMetricRequest] = [] + self._resampler_requests: list = [] # list[ComponentMetricRequest] = [] super().__init__(formula_name, create_method) # type: ignore[arg-type] - def _get_resampled_receiver( - self, component_id: int, metric_id: ComponentMetricId - ) -> 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_id: A metric ID 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_id, 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: From 3ebdbfbc4d5092605fcb9392107c00edc680795f Mon Sep 17 00:00:00 2001 From: cwasicki <126617870+cwasicki@users.noreply.github.com> Date: Mon, 23 Sep 2024 21:36:24 +0200 Subject: [PATCH 9/9] Update main test script --- main.py | 49 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/main.py b/main.py index cc76703..5fea177 100644 --- a/main.py +++ b/main.py @@ -5,6 +5,8 @@ import asyncio from datetime import datetime +from frequenz.client.reporting.formula_engine._formula_generators._pv_power_formula import PVPowerFormula, FormulaGeneratorConfig + from frequenz.client.common.metric import Metric def build_graph(json_data: dict) -> ComponentGraph: components = [] @@ -47,23 +49,38 @@ async def main(): key = open("key.txt", "r").read().strip() client = ReportingApiClient(server_url="grpc://reporting.api.frequenz.com:443?ssl=true", key=key) - microgrid_id = 13 - component_ids = [256, 258] - microgrid_components = [ - (microgrid_id, component_ids), - ] - start_dt = datetime(2024, 9, 17) - end_dt = datetime(2024, 9, 18) - resolution = 900 + def get_receiver(component_id, metric_id): + microgrid_id = 13 + component_ids = [component_id] + microgrid_components = [ + (microgrid_id, component_ids), + ] + + start_dt = datetime(2024, 9, 17) + end_dt = datetime(2024, 9, 18) + resolution = 900 + receiver = list_microgrid_components_data_receiver( + client, + microgrid_components=microgrid_components, + metrics=[Metric.AC_ACTIVE_POWER], + start_dt=start_dt, + end_dt=end_dt, + resolution=resolution, + ) + return receiver + + #async for sample in get_receiver(256, 4711): + # print(sample) + + formula = PVPowerFormula( + get_receiver=get_receiver, + config=FormulaGeneratorConfig(), + component_graph=component_graph, + ) - async for sample in list_microgrid_components_data_receiver( - client, - microgrid_components=microgrid_components, - metrics=[Metric.AC_ACTIVE_POWER], - start_dt=start_dt, - end_dt=end_dt, - resolution=resolution, - ): + engine = formula.generate() + recv = engine.new_receiver() + async for sample in recv: print(sample) if __name__ == "__main__":