diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 3dab0dcb9..c1e406ba4 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -10,7 +10,12 @@ ## New Features - +- Fallback components are used in generated formulas. If primary components is unavailable, formula will generate metric from fallback components. Fallback formulas are implemented for: + - PVPowerFormula + - ProducerPowerFormula + - BatteryPowerFormula + - ConsumerPowerFormula + - GridPowerFormula ## Bug Fixes diff --git a/src/frequenz/sdk/timeseries/battery_pool/_battery_pool.py b/src/frequenz/sdk/timeseries/battery_pool/_battery_pool.py index 12f974331..9fba7ab47 100644 --- a/src/frequenz/sdk/timeseries/battery_pool/_battery_pool.py +++ b/src/frequenz/sdk/timeseries/battery_pool/_battery_pool.py @@ -245,6 +245,7 @@ def power(self) -> FormulaEngine[Power]: BatteryPowerFormula, FormulaGeneratorConfig( component_ids=self._pool_ref_store._batteries, + allow_fallback=True, ), ) assert isinstance(engine, FormulaEngine) diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_engine.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_engine.py index 965d0ccf5..b1b3352c6 100644 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_engine.py +++ b/src/frequenz/sdk/timeseries/formula_engine/_formula_engine.py @@ -27,6 +27,7 @@ ConstantValue, Consumption, Divider, + FallbackMetricFetcher, FormulaStep, Maximizer, MetricFetcher, @@ -747,6 +748,7 @@ def push_metric( data_stream: Receiver[Sample[QuantityT]], *, nones_are_zeros: bool, + fallback: FallbackMetricFetcher[QuantityT] | None = None, ) -> None: """Push a metric receiver into the engine. @@ -755,9 +757,18 @@ def push_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) + name, + MetricFetcher( + name, + data_stream, + nones_are_zeros=nones_are_zeros, + fallback=fallback, + ), ) self._steps.append(fetcher) diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_battery_power_formula.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_battery_power_formula.py index f9f1af13c..ae1c3bd01 100644 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_battery_power_formula.py +++ b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_battery_power_formula.py @@ -3,18 +3,21 @@ """Formula generator from component graph for Grid Power.""" +import itertools import logging -from frequenz.client.microgrid import ComponentMetricId +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__) @@ -48,8 +51,8 @@ def generate( builder = self._get_builder( "battery-power", ComponentMetricId.ACTIVE_POWER, Power.from_watts ) - component_ids = self._config.component_ids - if not component_ids: + + if not self._config.component_ids: _logger.warning( "No Battery component IDs specified. " "Subscribing to the resampling actor with a non-existing " @@ -63,43 +66,109 @@ def generate( ) return builder.build() + component_ids = set(self._config.component_ids) component_graph = connection_manager.get().component_graph + inv_bat_mapping: dict[Component, set[Component]] = {} - battery_inverters = frozenset( - frozenset( + for bat_id in component_ids: + inverters = set( filter( component_graph.is_battery_inverter, component_graph.predecessors(bat_id), ) ) - for bat_id in component_ids - ) - - if not all(battery_inverters): - raise ComponentNotFound( - "All batteries must have at least one inverter as a predecessor." - ) + 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.", + ) - all_connected_batteries = set() - for inverters in battery_inverters: for inverter in inverters: - all_connected_batteries.update( - component_graph.successors(inverter.component_id) + 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. - if len(all_connected_batteries) != len(component_ids): - raise FormulaGenerationError( - "All batteries behind a set of inverters must be requested." + 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 + ), + ) ) - builder.push_oper("(") - builder.push_oper("(") - # Iterate over the flattened list of inverters - for idx, comp in enumerate( - inverter for inverters in battery_inverters for inverter in inverters - ): - if idx > 0: - builder.push_oper("+") - builder.push_component_metric(comp.component_id, nones_are_zeros=True) + generator = BatteryPowerFormula( + f"{self._namespace}_fallback_{battery_ids}", + self._channel_registry, + self._resampler_subscription_sender, + FormulaGeneratorConfig( + component_ids=battery_ids, + allow_fallback=False, + ), + ) - return builder.build() + fallback_formulas[primary_component] = FallbackFormulaMetricFetcher( + generator + ) + + return fallback_formulas diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_consumer_power_formula.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_consumer_power_formula.py index 87c65689b..67dcc582c 100644 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_consumer_power_formula.py +++ b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_consumer_power_formula.py @@ -11,11 +11,14 @@ 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__) @@ -27,6 +30,25 @@ class ConsumerPowerFormula(FormulaGenerator[Power]): 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. @@ -48,15 +70,7 @@ def generate(self) -> FormulaEngine[Power]: if not grid_successors: raise ComponentNotFound("No components found in the component graph.") - component_graph = connection_manager.get().component_graph - if 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 - ): + 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()) @@ -112,13 +126,30 @@ def non_consumer_component(component: Component) -> bool: grid_meter.component_id, nones_are_zeros=False ) - # 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, - ) + 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() @@ -175,13 +206,76 @@ def consumer_component(component: Component) -> bool: ) return builder.build() - for idx, component in enumerate(consumer_components): - if idx > 0: - builder.push_oper("+") + 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, + ) - 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, + ), ) - return builder.build() + fallback_formulas[primary_component] = FallbackFormulaMetricFetcher( + generator + ) + + return fallback_formulas diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_fallback_formula_metric_fetcher.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_fallback_formula_metric_fetcher.py new file mode 100644 index 000000000..fababba4f --- /dev/null +++ b/src/frequenz/sdk/timeseries/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/sdk/timeseries/formula_engine/_formula_generators/_formula_generator.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_formula_generator.py index 92b65fff5..1283f9e78 100644 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_formula_generator.py +++ b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_formula_generator.py @@ -49,6 +49,8 @@ class FormulaGeneratorConfig: 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.""" @@ -77,6 +79,11 @@ def __init__( 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, @@ -140,3 +147,111 @@ 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/sdk/timeseries/formula_engine/_formula_generators/_grid_power_formula.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_power_formula.py index 3d76800e3..ce6093623 100644 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_power_formula.py +++ b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_power_formula.py @@ -3,11 +3,17 @@ """Formula generator from component graph for Grid Power.""" -from frequenz.client.microgrid import ComponentCategory, ComponentMetricId +from frequenz.client.microgrid import Component, ComponentCategory, ComponentMetricId from ..._quantities import Power from .._formula_engine import FormulaEngine -from ._formula_generator import FormulaGenerator +from ._fallback_formula_metric_fetcher import FallbackFormulaMetricFetcher +from ._formula_generator import ( + ComponentNotFound, + FormulaGenerator, + FormulaGeneratorConfig, +) +from ._simple_power_formula import SimplePowerFormula class GridPowerFormula(FormulaGenerator[Power]): @@ -30,6 +36,20 @@ def generate( # noqa: DOC502 ) 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 @@ -41,28 +61,75 @@ def generate( # noqa: DOC502 # - `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 + ...))` - for idx, comp in enumerate(grid_successors): - if idx > 0: - builder.push_oper("+") - - # Ensure the device has an `ACTIVE_POWER` metric. When inverters - # 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, + if self._config.allow_fallback: + fallbacks = self._get_fallback_formulas(components) + + for idx, (primary_component, fallback_formula) in enumerate( + fallbacks.items() ): - nones_are_zeros = True - elif comp.category == ComponentCategory.METER: - nones_are_zeros = False - else: + 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 - builder.push_component_metric( - comp.component_id, nones_are_zeros=nones_are_zeros + 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, + ), ) - return builder.build() + fallback_formulas[primary_component] = FallbackFormulaMetricFetcher( + generator + ) + + return fallback_formulas diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_producer_power_formula.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_producer_power_formula.py index a0433192e..7710a69a8 100644 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_producer_power_formula.py +++ b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_producer_power_formula.py @@ -4,13 +4,20 @@ """Formula generator from component graph for Producer Power.""" import logging +from typing import Callable -from frequenz.client.microgrid import ComponentCategory, ComponentMetricId +from frequenz.client.microgrid import Component, ComponentCategory, ComponentMetricId from ....microgrid import connection_manager from ..._quantities import Power from .._formula_engine import FormulaEngine -from ._formula_generator import NON_EXISTING_COMPONENT_ID, FormulaGenerator +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__) @@ -65,13 +72,77 @@ def generate( # noqa: DOC502 ) return builder.build() - for idx, component in enumerate(producer_components): - if idx > 0: - builder.push_oper("+") + is_not_meter: Callable[[Component], bool] = ( + lambda component: component.category != ComponentCategory.METER + ) - builder.push_component_metric( - component.component_id, - nones_are_zeros=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/sdk/timeseries/formula_engine/_formula_generators/_pv_power_formula.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_pv_power_formula.py index c3e3a1004..98bd87337 100644 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_pv_power_formula.py +++ b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_pv_power_formula.py @@ -5,12 +5,17 @@ import logging -from frequenz.client.microgrid import ComponentCategory, ComponentMetricId +from frequenz.client.microgrid import Component, ComponentCategory, ComponentMetricId from ....microgrid import connection_manager from ..._quantities import Power from .._formula_engine import FormulaEngine -from ._formula_generator import NON_EXISTING_COMPONENT_ID, FormulaGenerator +from ._fallback_formula_metric_fetcher import FallbackFormulaMetricFetcher +from ._formula_generator import ( + NON_EXISTING_COMPONENT_ID, + FormulaGenerator, + FormulaGeneratorConfig, +) _logger = logging.getLogger(__name__) @@ -58,18 +63,79 @@ def generate( # noqa: DOC502 # 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 + NON_EXISTING_COMPONENT_ID, + nones_are_zeros=True, ) return builder.build() - for idx, component in enumerate(pv_components): - if idx > 0: - builder.push_oper("+") + if self._config.allow_fallback: + fallbacks = self._get_fallback_formulas(pv_components) - # should only be the case if the component is not a meter - builder.push_component_metric( - component.component_id, - nones_are_zeros=component.category != ComponentCategory.METER, - ) + 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/sdk/timeseries/formula_engine/_formula_generators/_simple_power_formula.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_simple_power_formula.py new file mode 100644 index 000000000..79b2fb305 --- /dev/null +++ b/src/frequenz/sdk/timeseries/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/sdk/timeseries/formula_engine/_formula_steps.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_steps.py index ec37ceb3c..d6c47852b 100644 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_steps.py +++ b/src/frequenz/sdk/timeseries/formula_engine/_formula_steps.py @@ -5,15 +5,18 @@ from __future__ import annotations +import logging import math from abc import ABC, abstractmethod -from typing import Generic +from typing import Any, Generic -from frequenz.channels import Receiver +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. @@ -343,6 +346,30 @@ def apply(self, eval_stack: list[float]) -> None: 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.""" @@ -352,6 +379,7 @@ def __init__( stream: Receiver[Sample[QuantityT]], *, nones_are_zeros: bool, + fallback: FallbackMetricFetcher[QuantityT] | None = None, ) -> None: """Create a `MetricFetcher` instance. @@ -359,11 +387,16 @@ def __init__( 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]]: @@ -382,6 +415,90 @@ def stream_name(self) -> str: """ 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. @@ -390,9 +507,35 @@ async def fetch_next(self) -> Sample[QuantityT] | None: Returns: The fetched Sample. """ - self._next_value = await self._stream.receive() + 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. diff --git a/src/frequenz/sdk/timeseries/formula_engine/_resampled_formula_builder.py b/src/frequenz/sdk/timeseries/formula_engine/_resampled_formula_builder.py index 939beff41..9c980eff0 100644 --- a/src/frequenz/sdk/timeseries/formula_engine/_resampled_formula_builder.py +++ b/src/frequenz/sdk/timeseries/formula_engine/_resampled_formula_builder.py @@ -15,6 +15,7 @@ from .. import Sample from .._quantities import Quantity, QuantityT from ._formula_engine import FormulaBuilder, FormulaEngine +from ._formula_steps import FallbackMetricFetcher from ._tokenizer import Tokenizer, TokenType @@ -89,7 +90,11 @@ async def subscribe(self) -> None: await self._resampler_subscription_sender.send(request) def push_component_metric( - self, component_id: int, *, nones_are_zeros: bool + self, + component_id: int, + *, + nones_are_zeros: bool, + fallback: FallbackMetricFetcher[QuantityT] | None = None, ) -> None: """Push a resampled component metric stream to the formula engine. @@ -97,9 +102,17 @@ def push_component_metric( 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) + self.push_metric( + f"#{component_id}", + receiver, + nones_are_zeros=nones_are_zeros, + fallback=fallback, + ) def from_string( self, diff --git a/tests/microgrid/test_grid.py b/tests/microgrid/test_grid.py index 166690a51..d877b8d7f 100644 --- a/tests/microgrid/test_grid.py +++ b/tests/microgrid/test_grid.py @@ -6,11 +6,13 @@ from contextlib import AsyncExitStack import frequenz.client.microgrid as client +from frequenz.client.microgrid import ComponentCategory from pytest_mock import MockerFixture import frequenz.sdk.microgrid.component_graph as gr from frequenz.sdk import microgrid from frequenz.sdk.timeseries import Current, Fuse, Power, Quantity +from tests.utils.graph_generator import GraphGenerator from ..timeseries._formula_engine.utils import equal_float_lists, get_resampled_stream from ..timeseries.mock_microgrid import MockMicrogrid @@ -318,3 +320,156 @@ async def test_consumer_power_2_grid_meters(mocker: MockerFixture) -> None: await mockgrid.mock_resampler.send_meter_power([1.0, 2.0]) assert (await grid_recv.receive()).value == Power.from_watts(3.0) + + +async def test_grid_fallback_formula_without_grid_meter(mocker: MockerFixture) -> None: + """Test the grid power formula without a grid meter.""" + gen = GraphGenerator() + mockgrid = MockMicrogrid( + graph=gen.to_graph( + ( + [ + ComponentCategory.METER, # Consumer meter + ( + ComponentCategory.METER, # meter with 2 inverters + [ + ( + ComponentCategory.INVERTER, + [ComponentCategory.BATTERY], + ), + ( + ComponentCategory.INVERTER, + [ComponentCategory.BATTERY, ComponentCategory.BATTERY], + ), + ], + ), + (ComponentCategory.INVERTER, ComponentCategory.BATTERY), + ] + ) + ), + mocker=mocker, + ) + + async with mockgrid, AsyncExitStack() as stack: + grid = microgrid.grid() + stack.push_async_callback(grid.stop) + consumer_power_receiver = grid.power.new_receiver() + + # Note: GridPowerFormula has a "nones-are-zero" rule, that says: + # * if the meter value is None, it should be treated as None. + # * for other components None is treated as 0. + + # fmt: off + expected_input_output: list[ + tuple[list[float | None], list[float | None], Power | None] + ] = [ + # ([consumer_meter, bat1_meter], [bat1_1_inv, bat1_2_inv, bat2_inv], expected_power) + ([100, -200], [-300, -300, 50], Power.from_watts(-50)), + ([500, 100], [100, 1000, -200,], Power.from_watts(400)), + # Consumer meter is invalid - consumer meter has no fallback. + # Formula should return None as defined in nones-are-zero rule. + ([None, 100], [100, 1000, -200,], None), + ([None, -50], [100, 100, -200,], None), + ([500, 100], [100, 50, -200,], Power.from_watts(400)), + # bat1_inv is invalid. + # Return None and subscribe for fallback devices. + # Next call should return formula result with pv_inv value. + ([500, None], [100, 1000, -200,], None), + ([500, None], [100, -1000, -200,], Power.from_watts(-600)), + ([500, None], [-100, 200, 50], Power.from_watts(650)), + # Second Battery inverter is invalid. This component has no fallback. + # return 0 instead of None as defined in nones-are-zero rule. + ([2000, None], [-200, 1000, None], Power.from_watts(2800)), + ([2000, 1000], [-200, 1000, None], Power.from_watts(3000)), + # battery start working + ([2000, 10], [-200, 1000, 100], Power.from_watts(2110)), + ([2000, None], [-200, 1000, 100], Power.from_watts(2900)), + ] + # fmt: on + + for idx, ( + meter_power, + bat_inv_power, + expected_power, + ) in enumerate(expected_input_output): + await mockgrid.mock_resampler.send_meter_power(meter_power) + await mockgrid.mock_resampler.send_bat_inverter_power(bat_inv_power) + mockgrid.mock_resampler.next_ts() + + result = await consumer_power_receiver.receive() + assert result.value == expected_power, ( + f"Test case {idx} failed:" + + f" meter_power: {meter_power}" + + f" bat_inverter_power {bat_inv_power}" + + f" expected_power: {expected_power}" + + f" actual_power: {result.value}" + ) + + +async def test_grid_fallback_formula_with_grid_meter(mocker: MockerFixture) -> None: + """Test the grid power formula without a grid meter.""" + gen = GraphGenerator() + mockgrid = MockMicrogrid( + graph=gen.to_graph( + ( + ComponentCategory.METER, # Grid meter + [ + ( + ComponentCategory.METER, # meter with 2 inverters + [ + ( + ComponentCategory.INVERTER, + [ComponentCategory.BATTERY], + ), + ( + ComponentCategory.INVERTER, + [ComponentCategory.BATTERY, ComponentCategory.BATTERY], + ), + ], + ), + (ComponentCategory.INVERTER, ComponentCategory.BATTERY), + ], + ) + ), + mocker=mocker, + ) + + async with mockgrid, AsyncExitStack() as stack: + grid = microgrid.grid() + stack.push_async_callback(grid.stop) + consumer_power_receiver = grid.power.new_receiver() + + # Note: GridPowerFormula has a "nones-are-zero" rule, that says: + # * if the meter value is None, it should be treated as None. + # * for other components None is treated as 0. + + # fmt: off + expected_input_output: list[ + tuple[list[float | None], list[float | None], Power | None] + ] = [ + # ([grid_meter, bat1_meter], [bat1_1_inv, bat1_2_inv, bat2_inv], expected_power) + ([100, -200], [-300, -300, 50], Power.from_watts(100)), + ([-100, 100], [100, 1000, -200,], Power.from_watts(-100)), + ([None, 100], [100, 1000, -200,], None), + ([None, -50], [100, 100, -200,], None), + ([500, 100], [100, 50, -200,], Power.from_watts(500)), + ] + # fmt: on + + for idx, ( + meter_power, + bat_inv_power, + expected_power, + ) in enumerate(expected_input_output): + await mockgrid.mock_resampler.send_meter_power(meter_power) + await mockgrid.mock_resampler.send_bat_inverter_power(bat_inv_power) + mockgrid.mock_resampler.next_ts() + + result = await consumer_power_receiver.receive() + assert result.value == expected_power, ( + f"Test case {idx} failed:" + + f" meter_power: {meter_power}" + + f" bat_inverter_power {bat_inv_power}" + + f" expected_power: {expected_power}" + + f" actual_power: {result.value}" + ) diff --git a/tests/timeseries/_battery_pool/test_battery_pool.py b/tests/timeseries/_battery_pool/test_battery_pool.py index f1bef2ced..3274efabf 100644 --- a/tests/timeseries/_battery_pool/test_battery_pool.py +++ b/tests/timeseries/_battery_pool/test_battery_pool.py @@ -11,6 +11,7 @@ import logging import math from collections.abc import AsyncIterator +from contextlib import AsyncExitStack from dataclasses import dataclass, is_dataclass, replace from datetime import datetime, timedelta, timezone from typing import Any, Generic, TypeVar @@ -490,8 +491,24 @@ async def test_battery_pool_power(mocker: MockerFixture) -> None: """Test `BatteryPool.power` method.""" mockgrid = MockMicrogrid(grid_meter=True, mocker=mocker) mockgrid.add_batteries(2) - await mockgrid.start(mocker) - await _test_battery_pool_power(mockgrid) + + async with mockgrid, AsyncExitStack() as stack: + battery_pool = microgrid.new_battery_pool(priority=5) + stack.push_async_callback(battery_pool.stop) + power_receiver = battery_pool.power.new_receiver() + + # send meter power [grid_meter, battery1_meter, battery2_meter] + await mockgrid.mock_resampler.send_meter_power([100.0, 2.0, 3.0]) + await mockgrid.mock_resampler.send_bat_inverter_power([20.0, 30.0]) + assert (await power_receiver.receive()).value == Power.from_watts(5.0) + + await mockgrid.mock_resampler.send_meter_power([100.0, -2.0, -5.0]) + await mockgrid.mock_resampler.send_bat_inverter_power([-20.0, -50.0]) + assert (await power_receiver.receive()).value == Power.from_watts(-7.0) + + await mockgrid.mock_resampler.send_meter_power([100.0, 2.0, -5.0]) + await mockgrid.mock_resampler.send_bat_inverter_power([20.0, -50.0]) + assert (await power_receiver.receive()).value == Power.from_watts(-3.0) async def test_battery_pool_power_two_inverters_per_battery( @@ -501,10 +518,29 @@ async def test_battery_pool_power_two_inverters_per_battery( gen = GraphGenerator() bat = gen.component(ComponentCategory.BATTERY) mockgrid = MockMicrogrid( - graph=gen.to_graph((ComponentCategory.METER, gen.battery_with_inverter(bat, 2))) + graph=gen.to_graph( + (ComponentCategory.METER, gen.battery_with_inverter(bat, 2)) + ), + mocker=mocker, ) - await mockgrid.start(mocker) - await _test_battery_pool_power(mockgrid) + async with mockgrid, AsyncExitStack() as stack: + battery_pool = microgrid.new_battery_pool(priority=5) + stack.push_async_callback(battery_pool.stop) + power_receiver = battery_pool.power.new_receiver() + + # send meter power [grid_meter, battery1_meter] + # Fallback formula - use only meter power, inverter and batteries are not used. + await mockgrid.mock_resampler.send_meter_power([100.0, 3.0]) + await mockgrid.mock_resampler.send_bat_inverter_power([20.0, 30.0]) + assert (await power_receiver.receive()).value == Power.from_watts(3.0) + + await mockgrid.mock_resampler.send_meter_power([100.0, -5.0]) + await mockgrid.mock_resampler.send_bat_inverter_power([-20.0, -50.0]) + assert (await power_receiver.receive()).value == Power.from_watts(-5.0) + + await mockgrid.mock_resampler.send_meter_power([100.0, -5.0]) + await mockgrid.mock_resampler.send_bat_inverter_power([20.0, -50.0]) + assert (await power_receiver.receive()).value == Power.from_watts(-5.0) async def test_batter_pool_power_two_batteries_per_inverter( @@ -515,21 +551,136 @@ async def test_batter_pool_power_two_batteries_per_inverter( mockgrid = MockMicrogrid( graph=gen.to_graph( [ - ComponentCategory.METER, ( - ComponentCategory.INVERTER, - [ComponentCategory.BATTERY, ComponentCategory.BATTERY], + ComponentCategory.METER, + ( + ComponentCategory.INVERTER, + [ComponentCategory.BATTERY, ComponentCategory.BATTERY], + ), ), - ComponentCategory.METER, ( - ComponentCategory.INVERTER, - [ComponentCategory.BATTERY, ComponentCategory.BATTERY], + ComponentCategory.METER, + ( + ComponentCategory.INVERTER, + [ComponentCategory.BATTERY, ComponentCategory.BATTERY], + ), ), ] - ) + ), + mocker=mocker, ) - await mockgrid.start(mocker) - await _test_battery_pool_power(mockgrid) + + async with mockgrid, AsyncExitStack() as stack: + battery_pool = microgrid.new_battery_pool(priority=5) + stack.push_async_callback(battery_pool.stop) + power_receiver = battery_pool.power.new_receiver() + + # send meter power [battery1_meter, battery2_meter] + # Fallback formula - use only meter power, inverter and batteries are not used. + await mockgrid.mock_resampler.send_meter_power([100.0, 3.0]) + await mockgrid.mock_resampler.send_bat_inverter_power([20.0, 30.0]) + assert (await power_receiver.receive()).value == Power.from_watts(103.0) + + await mockgrid.mock_resampler.send_meter_power([100.0, -5.0]) + await mockgrid.mock_resampler.send_bat_inverter_power([-20.0, -50.0]) + assert (await power_receiver.receive()).value == Power.from_watts(95.0) + + await mockgrid.mock_resampler.send_meter_power([3.0, -5.0]) + await mockgrid.mock_resampler.send_bat_inverter_power([20.0, -50.0]) + assert (await power_receiver.receive()).value == Power.from_watts(-2.0) + + +async def test_battery_power_fallback_formula( + mocker: MockerFixture, +) -> None: + """Test power method with two batteries per inverter.""" + gen = GraphGenerator() + mockgrid = MockMicrogrid( + graph=gen.to_graph( + ( + ComponentCategory.METER, # Grid meter - shouldn't be included in formula + [ + ( + ComponentCategory.METER, # meter with 2 inverters + [ + ( + ComponentCategory.INVERTER, + [ComponentCategory.BATTERY], + ), + ( + ComponentCategory.INVERTER, + [ComponentCategory.BATTERY, ComponentCategory.BATTERY], + ), + ], + ), + ( + # inverter without meter + ComponentCategory.INVERTER, + [ComponentCategory.BATTERY, ComponentCategory.BATTERY], + ), + ], + ) + ), + mocker=mocker, + ) + + async with mockgrid, AsyncExitStack() as stack: + battery_pool = microgrid.new_battery_pool(priority=5) + stack.push_async_callback(battery_pool.stop) + power_receiver = battery_pool.power.new_receiver() + + # Note: BatteryPowerFormula has a "nones-are-zero" rule, that says: + # * if the meter value is None, it should be treated as None. + # * for other components None is treated as 0. + + # fmt: off + expected_input_output: list[ + tuple[list[float | None], list[float | None], Power | None] + ] = [ + # ([grid_meter, bat_inv_meter], [bat_inv1, bat_inv2, bat_inv3], expected_power) + # bat_inv_meter is connected to bat_inv1 and bat_inv2 + # bat_inv3 has no meter + # Case 1: All components are available, add power form bat_inv_meter and bat_inv3 + ([-1.0, 2.0], [-100.0, -200.0, -300.0], Power.from_watts(-298.0)), + ([-1.0, -10.0], [None, None, -300.0], Power.from_watts(-310.0)), + # Case 2: Meter is unavailable (None). + # Subscribe to the fallback inverters, but return None as the result, + # according to the "nones-are-zero" rule + # Next call should add power from inverters + ([-1.0, None], [100.0, 100.0, -300.0], None), + ([-1.0, None], [100.0, 100.0, -300.0], Power.from_watts(-100.0)), + # Case 3: bat_inv_3 is unavailable (None). Return 0 from failing component + ([-1.0, None], [100.0, 100.0, None], Power.from_watts(200.0)), + # Case 4: bat_inv_meter is available, ignore fallback inverters + ([-1.0, 10], [100.0, 100.0, None], Power.from_watts(10.0)), + # Case 4: all components are unavailable (None). Return 0 according to the + # "nones-are-zero" rule. + ([-1.0, None], [None, None, None], Power.from_watts(0.0)), + # Case 5: Components becomes available + ([-1.0, None], [None, None, 100.0], Power.from_watts(100.0)), + ([-1.0, None], [None, 50.0, 100.0], Power.from_watts(150.0)), + ([-1.0, None], [-20, 50.0, 100.0], Power.from_watts(130.0)), + ([-1.0, -200], [-20, 50.0, 100.0], Power.from_watts(-100.0)), + ] + # fmt: on + + for idx, ( + meter_power, + bat_inv_power, + expected_power, + ) in enumerate(expected_input_output): + await mockgrid.mock_resampler.send_meter_power(meter_power) + await mockgrid.mock_resampler.send_bat_inverter_power(bat_inv_power) + mockgrid.mock_resampler.next_ts() + + result = await asyncio.wait_for(power_receiver.receive(), timeout=1) + assert result.value == expected_power, ( + f"Test case {idx} failed:" + + f" meter_power: {meter_power}" + + f" bat_inv_power {bat_inv_power}" + + f" expected_power: {expected_power}" + + f" actual_power: {result.value}" + ) async def test_batter_pool_power_no_batteries(mocker: MockerFixture) -> None: @@ -590,21 +741,6 @@ async def test_battery_pool_power_incomplete_bat_request(mocker: MockerFixture) assert (await power_receiver.receive()).value == Power.from_watts(2.0) -async def _test_battery_pool_power(mockgrid: MockMicrogrid) -> None: - async with mockgrid: - battery_pool = microgrid.new_battery_pool(priority=5) - power_receiver = battery_pool.power.new_receiver() - - await mockgrid.mock_resampler.send_bat_inverter_power([2.0, 3.0]) - assert (await power_receiver.receive()).value == Power.from_watts(5.0) - - await mockgrid.mock_resampler.send_bat_inverter_power([-2.0, -5.0]) - assert (await power_receiver.receive()).value == Power.from_watts(-7.0) - - await mockgrid.mock_resampler.send_bat_inverter_power([2.0, -5.0]) - assert (await power_receiver.receive()).value == Power.from_watts(-3.0) - - async def run_capacity_test( # pylint: disable=too-many-locals fake_time: time_machine.Coordinates, setup_args: SetupArgs ) -> None: diff --git a/tests/timeseries/_formula_engine/test_formula_composition.py b/tests/timeseries/_formula_engine/test_formula_composition.py index 44b0bd20b..3f4491922 100644 --- a/tests/timeseries/_formula_engine/test_formula_composition.py +++ b/tests/timeseries/_formula_engine/test_formula_composition.py @@ -130,7 +130,7 @@ async def test_formula_composition_missing_pv(self, mocker: MockerFixture) -> No inv_calc_recv = engine.new_receiver() for _ in range(10): - await mockgrid.mock_resampler.send_bat_inverter_power( + await mockgrid.mock_resampler.send_meter_power( [10.0 + count, 12.0 + count, 14.0 + count] ) await mockgrid.mock_resampler.send_non_existing_component_value() @@ -151,7 +151,7 @@ async def test_formula_composition_missing_pv(self, mocker: MockerFixture) -> No async def test_formula_composition_missing_bat(self, mocker: MockerFixture) -> None: """Test the composition of formulas with missing battery power data.""" mockgrid = MockMicrogrid(grid_meter=False, mocker=mocker) - mockgrid.add_solar_inverters(2) + mockgrid.add_solar_inverters(2, no_meter=True) count = 0 async with mockgrid, AsyncExitStack() as stack: diff --git a/tests/timeseries/mock_microgrid.py b/tests/timeseries/mock_microgrid.py index 46b0269d0..ea86b3b92 100644 --- a/tests/timeseries/mock_microgrid.py +++ b/tests/timeseries/mock_microgrid.py @@ -112,10 +112,12 @@ def __init__( # pylint: disable=too-many-arguments def filter_comp(category: ComponentCategory) -> list[int]: if graph is None: return [] - return list( - map( - lambda c: c.component_id, - graph.components(component_categories={category}), + return sorted( + list( + map( + lambda c: c.component_id, + graph.components(component_categories={category}), + ) ) ) @@ -123,13 +125,15 @@ def inverters(comp_type: InverterType) -> list[int]: if graph is None: return [] - return [ - c.component_id - for c in graph.components( - component_categories={ComponentCategory.INVERTER} - ) - if c.type == comp_type - ] + return sorted( + [ + c.component_id + for c in graph.components( + component_categories={ComponentCategory.INVERTER} + ) + if c.type == comp_type + ] + ) self.chp_ids: list[int] = filter_comp(ComponentCategory.CHP) self.battery_ids: list[int] = filter_comp(ComponentCategory.BATTERY) @@ -362,34 +366,31 @@ def add_chps(self, count: int, no_meters: bool = False) -> None: no_meters: if True, do not add a meter for each CHP. """ for _ in range(count): - meter_id = self._id_increment * 10 + self.meter_id_suffix chp_id = self._id_increment * 10 + self.chp_id_suffix - self._id_increment += 1 - - self.meter_ids.append(meter_id) self.chp_ids.append(chp_id) - - if not no_meters: - self._components.add( - Component( - meter_id, - ComponentCategory.METER, - ) - ) self._components.add( Component( chp_id, ComponentCategory.CHP, ) ) - - self._start_meter_streaming(meter_id) if no_meters: self._connections.add(Connection(self._connect_to, chp_id)) else: + meter_id = self._id_increment * 10 + self.meter_id_suffix + self.meter_ids.append(meter_id) + self._components.add( + Component( + meter_id, + ComponentCategory.METER, + ) + ) + self._start_meter_streaming(meter_id) self._connections.add(Connection(self._connect_to, meter_id)) self._connections.add(Connection(meter_id, chp_id)) + self._id_increment += 1 + def add_batteries(self, count: int, no_meter: bool = False) -> None: """Add batteries with connected inverters and meters to the microgrid. diff --git a/tests/timeseries/mock_resampler.py b/tests/timeseries/mock_resampler.py index 1a4670810..5f4507d1a 100644 --- a/tests/timeseries/mock_resampler.py +++ b/tests/timeseries/mock_resampler.py @@ -61,7 +61,7 @@ def power_senders( self._input_channels_receivers[name] = [ self._channel_registry.get_or_create( Sample[Quantity], name - ).new_receiver() + ).new_receiver(limit=1) for _ in range(namespaces) ] return senders @@ -196,6 +196,10 @@ def power_3_phase_senders( task.add_done_callback(self._handle_task_done) self._request_handler_task = task + def next_ts(self) -> None: + """Increment the timestamp.""" + self._next_ts = datetime.now() + def _handle_task_done(self, task: asyncio.Task[None]) -> None: if task.cancelled(): return diff --git a/tests/timeseries/test_consumer.py b/tests/timeseries/test_consumer.py index 919bcee3e..64d93ef63 100644 --- a/tests/timeseries/test_consumer.py +++ b/tests/timeseries/test_consumer.py @@ -66,3 +66,134 @@ async def test_consumer_power_no_grid_meter_no_consumer_meter( assert (await consumer_power_receiver.receive()).value == Power.from_watts( 0.0 ) + + async def test_consumer_power_fallback_formula_with_grid_meter( + self, mocker: MockerFixture + ) -> None: + """Test the consumer power formula with a grid meter.""" + mockgrid = MockMicrogrid(grid_meter=True, mocker=mocker) + mockgrid.add_batteries(1) + mockgrid.add_solar_inverters(1) + mockgrid.add_solar_inverters(1, no_meter=True) + + # formula is grid_meter - battery - pv1 - pv2 + + async with mockgrid, AsyncExitStack() as stack: + consumer = microgrid.consumer() + stack.push_async_callback(consumer.stop) + consumer_power_formula = consumer.power + print(consumer_power_formula) + consumer_power_receiver = consumer_power_formula.new_receiver() + + # Note: ConsumerPowerFormula has a "nones-are-zero" rule, that says: + # * if the meter value is None, it should be treated as None. + # * for other components None is treated as 0. + + # fmt: off + expected_input_output: list[ + tuple[list[float | None], list[float | None], list[float | None], Power | None] + ] = [ + # ([grid_meter, bat_meter, pv1_meter], [bat_inv], [pv1_inv, pv2_inv], expected_power) # noqa: E501 + ([100, 100, -50], [100], [-200, -300], Power.from_watts(350)), + ([500, -200, -100], [100], [-200, -100], Power.from_watts(900)), + # Case 2: The meter is unavailable (None). + # Subscribe to the fallback inverter, but return None as the result, + # according to the "nones-are-zero" rule + ([500, None, -100], [100], [-200, -100], None), + ([500, None, -100], [100], [-200, -100], Power.from_watts(600)), + # Case 3: Second meter is unavailable (None). + ([500, None, None], [100], [-200, -100], None), + ([500, None, None], [100], [-200, -100], Power.from_watts(700)), + # Case 3: pv2_inv is unavailable (None). + # It has no fallback, so return 0 as its value according to + # the "nones-are-zero" rule. + ([500, None, None], [100], [-200, None], Power.from_watts(600)), + # Case 4: Grid meter is unavailable (None). + # It has no fallback, so return None according to the "nones-are-zero" rule. + ([None, 100, -50], [100], [-200, -300], None), + ([None, 200, -50], [100], [-200, -300], None), + ([100, 100, -50], [100], [-200, -300], Power.from_watts(350)), + # Case 5: Only grid meter is working + ([100, None, None], [None], [None, None], Power.from_watts(100)), + ([-500, None, None], [None], [None, None], Power.from_watts(-500)), + # Case 6: Nothing is working + ([None, None, None], [None], [None, None], None), + ] + # fmt: on + + for idx, ( + meter_power, + bat_inv_power, + pv_inv_power, + expected_power, + ) in enumerate(expected_input_output): + await mockgrid.mock_resampler.send_meter_power(meter_power) + await mockgrid.mock_resampler.send_bat_inverter_power(bat_inv_power) + await mockgrid.mock_resampler.send_pv_inverter_power(pv_inv_power) + mockgrid.mock_resampler.next_ts() + + result = await consumer_power_receiver.receive() + assert result.value == expected_power, ( + f"Test case {idx} failed:" + + f" meter_power: {meter_power}" + + f" bat_inverter_power {bat_inv_power}" + + f" pv_inverter_power {pv_inv_power}" + + f" expected_power: {expected_power}" + + f" actual_power: {result.value}" + ) + + async def test_consumer_power_fallback_formula_without_grid_meter( + self, mocker: MockerFixture + ) -> None: + """Test the consumer power formula with a grid meter.""" + mockgrid = MockMicrogrid(grid_meter=False, mocker=mocker) + mockgrid.add_consumer_meters(2) + mockgrid.add_batteries(1) + mockgrid.add_solar_inverters(1, no_meter=True) + + # formula is sum of consumer meters + + async with mockgrid, AsyncExitStack() as stack: + consumer = microgrid.consumer() + stack.push_async_callback(consumer.stop) + consumer_power_receiver = consumer.power.new_receiver() + + # Note: ConsumerPowerFormula has a "nones-are-zero" rule, that says: + # * if the meter value is None, it should be treated as None. + # * for other components None is treated as 0. + + # fmt: off + expected_input_output: list[ + tuple[list[float | None], list[float | None], list[float | None], Power | None] + ] = [ + # ([consumer_meter1, consumer_meter2, bat_meter], [bat_inv], [pv_inv], expected_power) # noqa: E501 + ([100, 100, -50], [100], [-200,], Power.from_watts(200)), + ([500, 100, -50], [100], [-200,], Power.from_watts(600)), + # One of the meters is invalid - should return None according to none-are-zero rule + ([None, 100, -50], [100], [-200,], None), + ([None, None, -50], [100], [-200,], None), + ([500, None, -50], [100], [-200,], None), + ([2000, 1000, None], [None], [None], Power.from_watts(3000)), + ] + # fmt: on + + for idx, ( + meter_power, + bat_inv_power, + pv_inv_power, + expected_power, + ) in enumerate(expected_input_output): + await mockgrid.mock_resampler.send_meter_power(meter_power) + await mockgrid.mock_resampler.send_bat_inverter_power(bat_inv_power) + await mockgrid.mock_resampler.send_pv_inverter_power(pv_inv_power) + mockgrid.mock_resampler.next_ts() + + result = await consumer_power_receiver.receive() + assert result.value == expected_power, ( + f"Test case {idx} failed:" + + f" meter_power: {meter_power}" + + f" bat_inverter_power {bat_inv_power}" + + f" pv_inverter_power {pv_inv_power}" + + f" expected_power: {expected_power}" + + f" actual_power: {result.value}" + ) diff --git a/tests/timeseries/test_formula_formatter.py b/tests/timeseries/test_formula_formatter.py index 8b90d9dbf..3f32dc795 100644 --- a/tests/timeseries/test_formula_formatter.py +++ b/tests/timeseries/test_formula_formatter.py @@ -134,5 +134,5 @@ async def test_higher_order_formula(self, mocker: MockerFixture) -> None: composed_formula = (grid.power - pv_pool.power).build("grid_minus_pv") assert ( str(composed_formula) - == "[grid-power](#36 + #7 + #47 + #17 + #57 + #27) - [pv-power](#48 + #58)" + == "[grid-power](#36 + #7 + #47 + #17 + #57 + #27) - [pv-power](#47 + #57)" ) diff --git a/tests/timeseries/test_logical_meter.py b/tests/timeseries/test_logical_meter.py index 1d74c6715..df7aba107 100644 --- a/tests/timeseries/test_logical_meter.py +++ b/tests/timeseries/test_logical_meter.py @@ -48,7 +48,8 @@ async def test_pv_power(self, mocker: MockerFixture) -> None: stack.push_async_callback(pv_pool.stop) pv_power_receiver = pv_pool.power.new_receiver() - await mockgrid.mock_resampler.send_pv_inverter_power([-1.0, -2.0]) + await mockgrid.mock_resampler.send_meter_power([-1.0, -2.0]) + await mockgrid.mock_resampler.send_pv_inverter_power([-10.0, -20.0]) assert (await pv_power_receiver.receive()).value == Power.from_watts(-3.0) async def test_pv_power_no_meter(self, mocker: MockerFixture) -> None: @@ -76,3 +77,69 @@ async def test_pv_power_no_pv_components(self, mocker: MockerFixture) -> None: await mockgrid.mock_resampler.send_non_existing_component_value() assert (await pv_power_receiver.receive()).value == Power.zero() + + async def test_pv_power_with_failing_meter(self, mocker: MockerFixture) -> None: + """Test the pv power formula.""" + mockgrid = MockMicrogrid(grid_meter=False, mocker=mocker) + mockgrid.add_solar_inverters(2) + + async with mockgrid, AsyncExitStack() as stack: + pv_pool = microgrid.new_pv_pool(priority=5) + stack.push_async_callback(pv_pool.stop) + pv_power_receiver = pv_pool.power.new_receiver() + + # Note: PvPowerFormula has a "nones-are-zero" rule, that says: + # * if the meter value is None, it should be treated as None. + # * for other components None is treated as 0. + + expected_input_output: list[ + tuple[list[float | None], list[float | None], Power | None] + ] = [ + # ([meter_power], [pv_inverter_power], expected_power) + # + # Case 1: Both meters are available, so inverters are not used. + ([-1.0, -2.0], [None, -5.0], Power.from_watts(-3.0)), + ([-1.0, -2.0], [-10.0, -20.0], Power.from_watts(-3.0)), + # Case 2: The first meter is unavailable (None). + # Subscribe to the fallback inverter, but return None as the result, + # according to the "nones-are-zero" rule + ([None, -2.0], [-10.0, -20.0], None), + # Case 3: First meter is unavailable (None). Fallback inverter provides + # a value. + ([None, -2.0], [-10.0, -20.0], Power.from_watts(-12.0)), + ([None, -2.0], [-11.0, -20.0], Power.from_watts(-13.0)), + # Case 4: Both first meter and its fallback inverter are unavailable + # (None). Return 0 according to the "nones-are-zero" rule. + ([None, -2.0], [None, -20.0], Power.from_watts(-2.0)), + ([None, -2.0], [-11.0, -20.0], Power.from_watts(-13.0)), + # Case 5: Both meters are unavailable (None). + # Subscribe to the fallback inverter, but return None as the result, + # according "nones-are-zero" rule + ([None, None], [-5.0, -20.0], None), + # Case 6: Both meters are unavailable (None). Fallback inverter provides + # a values. + ([None, None], [-5.0, -20.0], Power.from_watts(-25.0)), + # Case 7: All components are unavailable (None). + # Return 0 according to the "nones-are-zero" rule. + ([None, None], [None, None], Power.from_watts(0.0)), + ([None, None], [-5.0, -20.0], Power.from_watts(-25.0)), + # Case 8: Meters becomes available and inverter values are not used. + ([-10.0, None], [-5.0, -20.0], Power.from_watts(-30.0)), + ([-10.0, -2.0], [-5.0, -20.0], Power.from_watts(-12.0)), + ] + + for idx, (meter_power, pv_inverter_power, expected_power) in enumerate( + expected_input_output + ): + await mockgrid.mock_resampler.send_meter_power(meter_power) + await mockgrid.mock_resampler.send_pv_inverter_power(pv_inverter_power) + mockgrid.mock_resampler.next_ts() + + result = await pv_power_receiver.receive() + assert result.value == expected_power, ( + f"Test case {idx} failed:" + + f" meter_power: {meter_power}" + + f" pv_inverter_power {pv_inverter_power}" + + f" expected_power: {expected_power}" + + f" actual_power: {result.value}" + ) diff --git a/tests/timeseries/test_producer.py b/tests/timeseries/test_producer.py index c975352af..c881ddabb 100644 --- a/tests/timeseries/test_producer.py +++ b/tests/timeseries/test_producer.py @@ -60,6 +60,7 @@ async def test_producer_power_no_pv_no_consumer_meter( producer_power_receiver = producer.power.new_receiver() await mockgrid.mock_resampler.send_chp_power([2.0]) + assert (await producer_power_receiver.receive()).value == Power.from_watts( 2.0 ) @@ -94,3 +95,83 @@ async def test_no_producer_power(self, mocker: MockerFixture) -> None: assert (await producer_power_receiver.receive()).value == Power.from_watts( 0.0 ) + + async def test_producer_fallback_formula(self, mocker: MockerFixture) -> None: + """Test the producer power formula with fallback formulas.""" + mockgrid = MockMicrogrid(grid_meter=False, mocker=mocker) + mockgrid.add_solar_inverters(2) + # CHP has no meter, so no fallback component + mockgrid.add_chps(1, no_meters=True) + + async with mockgrid, AsyncExitStack() as stack: + producer = microgrid.producer() + stack.push_async_callback(producer.stop) + producer_power_receiver = producer.power.new_receiver() + + # Note: ProducerPowerFormula has a "nones-are-zero" rule, that says: + # * if the meter value is None, it should be treated as None. + # * for other components None is treated as 0. + + # fmt: off + expected_input_output: list[ + tuple[list[float | None], list[float | None], list[float | None], Power | None] + ] = [ + # ([pv_meter_power], [pv_inverter_power], [chp_power], expected_power) + # Add power from meters and chp + ([-1.0, -2.0], [None, -200.0], [300], Power.from_watts(297.0)), + ([-1.0, -10], [-100.0, -200.0], [400], Power.from_watts(389.0)), + # Case 2: The first meter is unavailable (None). + # Subscribe to the fallback inverter, but return None as the result, + # according to the "nones-are-zero" rule + ([None, -2.0], [-100, -200.0], [400], None), + # Case 3: First meter is unavailable (None). Fallback inverter provides + # a value. + # Add second meter, first inverter and chp power + ([None, -2.0], [-100, -200.0], [400], Power.from_watts(298.0)), + ([None, -2.0], [-50, -200.0], [300], Power.from_watts(248.0)), + # Case 4: Both first meter and its fallback inverter are unavailable + # (None). Return 0 from failing component according to the + # "nones-are-zero" rule. + ([None, -2.0], [None, -200.0], [300], Power.from_watts(298.0)), + ([None, -10.0], [-20.0, -200.0], [300], Power.from_watts(270.0)), + # Case 5: CHP is unavailable. Return 0 from failing component + # according to the "nones-are-zero" rule. + ([None, -10.0], [-20.0, -200.0], [None], Power.from_watts(-30.0)), + # Case 6: Both meters are unavailable (None). Subscribe for fallback inverter + ([None, None], [-20.0, -200.0], [None], None), + ([None, None], [-20.0, -200.0], [None], Power.from_watts(-220.0)), + ([None, None], [None, -200.0], [None], Power.from_watts(-200.0)), + # Case 7: All components are unavailable (None). Return 0 according to the + # "nones-are-zero" rule. + ([None, None], [None, None], [None], Power.from_watts(0)), + ([None, None], [None, None], [None], Power.from_watts(0)), + ([None, None], [None, None], [300.0], Power.from_watts(300.0)), + ([-200.0, None], [None, -100.0], [50.0], Power.from_watts(-250.0)), + ([-200.0, -200.0], [-10.0, -20.0], [50.0], Power.from_watts(-350.0)), + # Case 8: Meter is unavailable but we already subscribed for inverter + # So don't return None in this case. Just proper formula result. + ([None, -200.0], [-10.0, -100.0], [50.0], Power.from_watts(-160.0)), + + ] + # fmt: on + + for idx, ( + meter_power, + pv_inverter_power, + chp_power, + expected_power, + ) in enumerate(expected_input_output): + await mockgrid.mock_resampler.send_chp_power(chp_power) + await mockgrid.mock_resampler.send_meter_power(meter_power) + await mockgrid.mock_resampler.send_pv_inverter_power(pv_inverter_power) + mockgrid.mock_resampler.next_ts() + + result = await producer_power_receiver.receive() + assert result.value == expected_power, ( + f"Test case {idx} failed:" + + f" meter_power: {meter_power}" + + f" pv_inverter_power {pv_inverter_power}" + + f" chp_power {chp_power}" + + f" expected_power: {expected_power}" + + f" actual_power: {result.value}" + ) diff --git a/tests/utils/graph_generator.py b/tests/utils/graph_generator.py index d35f45b0c..f4f85c224 100644 --- a/tests/utils/graph_generator.py +++ b/tests/utils/graph_generator.py @@ -11,6 +11,7 @@ ComponentCategory, ComponentType, Connection, + GridMetadata, InverterType, ) @@ -185,7 +186,7 @@ def grid() -> Component: Returns: a new grid component with default id. """ - return Component(1, ComponentCategory.GRID) + return Component(1, ComponentCategory.GRID, None, GridMetadata(None)) def to_graph(self, components: Any) -> _MicrogridComponentGraph: """Convert a list of components to a graph.