diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 2b413800f..439777ee2 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -18,6 +18,8 @@ and adapt your imports if you are using these types. - `force_polling`: Whether to force file polling to check for changes. Default is `True`. - `polling_interval`: The interval to check for changes. Only relevant if polling is enabled. Default is 1 second. +- Add a new method `microgrid.grid().reactive_power` to stream reactive power at the grid connection point. + ## Bug Fixes - Many long running async tasks including metric streamers in the BatteryPool now have automatic recovery in case of exceptions. diff --git a/pyproject.toml b/pyproject.toml index 8284e9835..d5e1e0577 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ # (plugins.mkdocstrings.handlers.python.import) "frequenz-client-microgrid >= 0.5.1, < 0.6.0", "frequenz-channels >= 1.2.0, < 2.0.0", - "frequenz-quantities == 1.0.0rc1", + "frequenz-quantities == 1.0.0rc2", "networkx >= 2.8, < 4", "numpy >= 1.26.4, < 2", "typing_extensions >= 4.6.1, < 5", diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_engine_pool.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_engine_pool.py index 9d26f0118..95ed22c59 100644 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_engine_pool.py +++ b/src/frequenz/sdk/timeseries/formula_engine/_formula_engine_pool.py @@ -9,7 +9,7 @@ from frequenz.channels import Sender from frequenz.client.microgrid import ComponentMetricId -from frequenz.quantities import Current, Power, Quantity +from frequenz.quantities import Current, Power, Quantity, ReactivePower from ..._internal._channels import ChannelRegistry from ...microgrid._data_sourcing import ComponentMetricRequest @@ -54,6 +54,7 @@ def __init__( self._power_engines: dict[str, FormulaEngine[Power]] = {} self._power_3_phase_engines: dict[str, FormulaEngine3Phase[Power]] = {} self._current_engines: dict[str, FormulaEngine3Phase[Current]] = {} + self._reactive_power_engines: dict[str, FormulaEngine[ReactivePower]] = {} def from_string( self, @@ -91,6 +92,40 @@ def from_string( return formula_engine + def from_reactive_power_formula_generator( + self, + channel_key: str, + generator: type[FormulaGenerator[ReactivePower]], + config: FormulaGeneratorConfig = FormulaGeneratorConfig(), + ) -> FormulaEngine[ReactivePower]: + """Get a receiver for a formula from a generator. + + Args: + channel_key: A string to uniquely identify the formula. + generator: A formula generator. + config: config to initialize the formula generator with. + + Returns: + A FormulaReceiver or a FormulaReceiver3Phase instance based on what the + FormulaGenerator returns. + """ + from ._formula_engine import ( # pylint: disable=import-outside-toplevel + FormulaEngine, + ) + + if channel_key in self._reactive_power_engines: + return self._reactive_power_engines[channel_key] + + engine = generator( + self._namespace, + self._channel_registry, + self._resampler_subscription_sender, + config, + ).generate() + assert isinstance(engine, FormulaEngine) + self._reactive_power_engines[channel_key] = engine + return engine + def from_power_formula_generator( self, channel_key: str, @@ -203,3 +238,5 @@ async def stop(self) -> None: 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 + for reactive_power_engine in self._reactive_power_engines.values(): + await reactive_power_engine._stop() # pylint: disable=protected-access diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/__init__.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/__init__.py index e2f92928c..3a402dee4 100644 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/__init__.py +++ b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/__init__.py @@ -17,6 +17,7 @@ from ._grid_current_formula import GridCurrentFormula from ._grid_power_3_phase_formula import GridPower3PhaseFormula from ._grid_power_formula import GridPowerFormula +from ._grid_reactive_power_formula import GridReactivePowerFormula from ._producer_power_formula import ProducerPowerFormula from ._pv_power_formula import PVPowerFormula @@ -33,6 +34,7 @@ "ConsumerPowerFormula", "GridPower3PhaseFormula", "GridPowerFormula", + "GridReactivePowerFormula", "BatteryPowerFormula", "EVChargerPowerFormula", "PVPowerFormula", 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 f2dcb2016..618b0f928 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 @@ -18,7 +18,7 @@ FormulaGenerator, FormulaGeneratorConfig, ) -from ._simple_power_formula import SimplePowerFormula +from ._simple_formula import SimplePowerFormula _logger = logging.getLogger(__name__) 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 47a21c502..da0b4fb4f 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,20 +3,18 @@ """Formula generator from component graph for Grid Power.""" -from frequenz.client.microgrid import Component, ComponentCategory, ComponentMetricId + +from frequenz.client.microgrid import Component, ComponentMetricId from frequenz.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 +from ._formula_generator import FormulaGeneratorConfig +from ._grid_power_formula_base import GridPowerFormulaBase +from ._simple_formula import SimplePowerFormula -class GridPowerFormula(FormulaGenerator[Power]): +class GridPowerFormula(GridPowerFormulaBase[Power]): """Creates a formula engine from the component graph for calculating grid power.""" def generate( # noqa: DOC502 @@ -32,70 +30,18 @@ def generate( # noqa: DOC502 ComponentNotFound: when the component graph doesn't have a `GRID` component. """ builder = self._get_builder( - "grid-power", ComponentMetricId.ACTIVE_POWER, Power.from_watts + "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() + return self._generate(builder) def _get_fallback_formulas( self, components: set[Component] ) -> dict[Component, FallbackFormulaMetricFetcher[Power] | None]: """Find primary and fallback components and create fallback formulas. - The primary component is the one that will be used to calculate the producer power. + The primary component is the one that will be used to calculate the grid power. If it is not available, the fallback formula will be used instead. Fallback formulas calculate the grid power using the fallback components. Fallback formulas are wrapped in `FallbackFormulaMetricFetcher`. diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_power_formula_base.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_power_formula_base.py new file mode 100644 index 000000000..d0344913e --- /dev/null +++ b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_power_formula_base.py @@ -0,0 +1,106 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Base formula generator from component graph for Grid Power.""" + +from abc import ABC, abstractmethod + +from frequenz.client.microgrid import Component, ComponentCategory + +from ..._base_types import QuantityT +from .._formula_engine import FormulaEngine +from .._resampled_formula_builder import ResampledFormulaBuilder +from ._fallback_formula_metric_fetcher import FallbackFormulaMetricFetcher +from ._formula_generator import ComponentNotFound, FormulaGenerator + + +class GridPowerFormulaBase(FormulaGenerator[QuantityT], ABC): + """Base class for grid power formula generators.""" + + def _generate( + self, builder: ResampledFormulaBuilder[QuantityT] + ) -> FormulaEngine[QuantityT]: + """Generate a formula for calculating grid power from the component graph. + + Args: + builder: The builder to use to create the formula. + + Returns: + A formula engine that will calculate grid power values. + + Raises: + ComponentNotFound: when the component graph doesn't have a `GRID` component. + """ + grid_successors = self._get_grid_component_successors() + + components = { + 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() + + @abstractmethod + def _get_fallback_formulas( + self, components: set[Component] + ) -> dict[Component, FallbackFormulaMetricFetcher[QuantityT] | None]: + """Find primary and fallback components and create fallback formulas. + + The primary component is the one that will be used to calculate the producer power. + If it is not available, the fallback formula will be used instead. + Fallback formulas calculate the grid power using the fallback components. + Fallback formulas are wrapped in `FallbackFormulaMetricFetcher`. + + Args: + components: The producer components. + + Returns: + A dictionary mapping primary components to their FallbackFormulaMetricFetcher. + """ diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_reactive_power_formula.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_reactive_power_formula.py new file mode 100644 index 000000000..381169e24 --- /dev/null +++ b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_grid_reactive_power_formula.py @@ -0,0 +1,81 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Formula generator from component graph for Grid Reactive Power.""" + + +from frequenz.client.microgrid import Component, ComponentMetricId +from frequenz.quantities import ReactivePower + +from .._formula_engine import FormulaEngine +from ._fallback_formula_metric_fetcher import FallbackFormulaMetricFetcher +from ._formula_generator import FormulaGeneratorConfig +from ._grid_power_formula_base import GridPowerFormulaBase +from ._simple_formula import SimpleReactivePowerFormula + + +class GridReactivePowerFormula(GridPowerFormulaBase[ReactivePower]): + """Creates a formula engine from the component graph for calculating grid reactive power.""" + + def generate( # noqa: DOC502 + # * ComponentNotFound is raised indirectly by _get_grid_component_successors + self, + ) -> FormulaEngine[ReactivePower]: + """Generate a formula for calculating grid reactive power from the component graph. + + Returns: + A formula engine that will calculate grid reactive power values. + + Raises: + ComponentNotFound: when the component graph doesn't have a `GRID` component. + """ + builder = self._get_builder( + "grid_reactive_power_formula", + ComponentMetricId.REACTIVE_POWER, + ReactivePower.from_volt_amperes_reactive, + ) + return self._generate(builder) + + def _get_fallback_formulas( + self, components: set[Component] + ) -> dict[Component, FallbackFormulaMetricFetcher[ReactivePower] | None]: + """Find primary and fallback components and create fallback formulas. + + The primary component is the one that will be used to calculate the grid reactive power. + If it is not available, the fallback formula will be used instead. + Fallback formulas calculate the grid power using the fallback components. + Fallback formulas are wrapped in `FallbackFormulaMetricFetcher`. + + Args: + components: The producer components. + + Returns: + A dictionary mapping primary components to their FallbackFormulaMetricFetcher. + """ + fallbacks = self._get_metric_fallback_components(components) + + fallback_formulas: dict[ + Component, FallbackFormulaMetricFetcher[ReactivePower] | None + ] = {} + + for primary_component, fallback_components in fallbacks.items(): + if len(fallback_components) == 0: + fallback_formulas[primary_component] = None + continue + + fallback_ids = [c.component_id for c in fallback_components] + generator = SimpleReactivePowerFormula( + f"{self._namespace}_fallback_{fallback_ids}", + self._channel_registry, + self._resampler_subscription_sender, + FormulaGeneratorConfig( + component_ids=set(fallback_ids), + allow_fallback=False, + ), + ) + + fallback_formulas[primary_component] = FallbackFormulaMetricFetcher( + generator + ) + + return fallback_formulas diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_producer_power_formula.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_producer_power_formula.py index df952883a..240cf9efa 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 @@ -17,7 +17,7 @@ FormulaGenerator, FormulaGeneratorConfig, ) -from ._simple_power_formula import SimplePowerFormula +from ._simple_formula import SimplePowerFormula _logger = logging.getLogger(__name__) diff --git a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_simple_formula.py b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_simple_formula.py new file mode 100644 index 000000000..86fd7b60b --- /dev/null +++ b/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_simple_formula.py @@ -0,0 +1,109 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Formula generator from component graph.""" + +from frequenz.client.microgrid import ComponentCategory, ComponentMetricId +from frequenz.quantities import Power, ReactivePower + +from ....microgrid import connection_manager +from ..._base_types import QuantityT +from .._formula_engine import FormulaEngine +from .._resampled_formula_builder import ResampledFormulaBuilder +from ._formula_generator import FormulaGenerator + + +class SimpleFormulaBase(FormulaGenerator[QuantityT]): + """Base class for simple formula generators.""" + + def _generate( + self, builder: ResampledFormulaBuilder[QuantityT] + ) -> FormulaEngine[QuantityT]: + """Generate formula for calculating quantity from the component graph. + + Args: + builder: The builder to use for generating the formula. + + Returns: + A formula engine that will calculate the quantity. + + Raises: + RuntimeError: If components ids in config are not specified + or component graph does not contain all specified components. + """ + component_graph = connection_manager.get().component_graph + if self._config.component_ids is None: + raise RuntimeError("Power formula without component ids is not supported.") + + components = component_graph.components( + 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() + + +class SimplePowerFormula(SimpleFormulaBase[Power]): + """Formula generator from component graph for calculating sum of Power.""" + + def generate( # noqa: DOC502 + # * ComponentNotFound is raised indirectly by _get_grid_component() + # * RuntimeError is raised indirectly by connection_manager.get() + self, + ) -> FormulaEngine[Power]: + """Generate formula for calculating sum of power from the component graph. + + Returns: + A formula engine that will calculate the power. + + Raises: + RuntimeError: If components ids in config are not specified + or component graph does not contain all specified components. + """ + builder = self._get_builder( + "simple_power_formula", + ComponentMetricId.ACTIVE_POWER, + Power.from_watts, + ) + return self._generate(builder) + + +class SimpleReactivePowerFormula(SimpleFormulaBase[ReactivePower]): + """Formula generator from component graph for calculating sum of reactive power.""" + + def generate( # noqa: DOC502 + # * ComponentNotFound is raised indirectly by _get_grid_component() + # * RuntimeError is raised indirectly by connection_manager.get() + self, + ) -> FormulaEngine[ReactivePower]: + """Generate formula for calculating sum of reactive power from the component graph. + + Returns: + A formula engine that will calculate the power. + + Raises: + RuntimeError: If components ids in config are not specified + or component graph does not contain all specified components. + """ + builder = self._get_builder( + "simple_reactive_power_formula", + ComponentMetricId.REACTIVE_POWER, + ReactivePower.from_volt_amperes_reactive, + ) + return self._generate(builder) 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 deleted file mode 100644 index c41bdabc8..000000000 --- a/src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_simple_power_formula.py +++ /dev/null @@ -1,67 +0,0 @@ -# License: MIT -# Copyright © 2024 Frequenz Energy-as-a-Service GmbH - -"""Formula generator from component graph.""" - -from frequenz.client.microgrid import ComponentCategory, ComponentMetricId -from frequenz.quantities import Power - -from ....microgrid import connection_manager -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/grid.py b/src/frequenz/sdk/timeseries/grid.py index a9b0abe00..b0cb9a2a2 100644 --- a/src/frequenz/sdk/timeseries/grid.py +++ b/src/frequenz/sdk/timeseries/grid.py @@ -13,8 +13,8 @@ from dataclasses import dataclass from frequenz.channels import Sender -from frequenz.client.microgrid._component import ComponentCategory -from frequenz.quantities import Current, Power +from frequenz.client.microgrid._component import ComponentCategory, ComponentMetricId +from frequenz.quantities import Current, Power, ReactivePower from .._internal._channels import ChannelRegistry from ..microgrid import connection_manager @@ -26,6 +26,7 @@ GridCurrentFormula, GridPower3PhaseFormula, GridPowerFormula, + GridReactivePowerFormula, ) _logger = logging.getLogger(__name__) @@ -95,6 +96,28 @@ def power(self) -> FormulaEngine[Power]: assert isinstance(engine, FormulaEngine) return engine + @property + def reactive_power(self) -> FormulaEngine[ReactivePower]: + """Fetch the grid reactive power for the microgrid. + + This formula produces values that are in the Passive Sign Convention (PSC). + + If a formula engine to calculate grid power is not already running, it will be + started. + + A receiver from the formula engine can be created using the `new_receiver` + method. + + Returns: + A FormulaEngine that will calculate and stream grid reactive power. + """ + engine = self._formula_pool.from_reactive_power_formula_generator( + f"grid-{ComponentMetricId.REACTIVE_POWER.value}", + GridReactivePowerFormula, + ) + assert isinstance(engine, FormulaEngine) + return engine + @property def _power_per_phase(self) -> FormulaEngine3Phase[Power]: """Fetch the per-phase grid power for the microgrid. diff --git a/tests/microgrid/test_grid.py b/tests/microgrid/test_grid.py index 357bc008b..933d9695d 100644 --- a/tests/microgrid/test_grid.py +++ b/tests/microgrid/test_grid.py @@ -7,7 +7,7 @@ import frequenz.client.microgrid as client from frequenz.client.microgrid import ComponentCategory -from frequenz.quantities import Current, Power, Quantity +from frequenz.quantities import Current, Power, Quantity, ReactivePower from pytest_mock import MockerFixture import frequenz.sdk.microgrid.component_graph as gr @@ -192,6 +192,102 @@ async def test_grid_power_2(mocker: MockerFixture) -> None: assert equal_float_lists(results, meter_sums) +async def test_grid_reactive_power_1(mocker: MockerFixture) -> None: + """Test the grid power formula with a grid side meter.""" + mockgrid = MockMicrogrid(grid_meter=True, mocker=mocker) + mockgrid.add_batteries(2) + mockgrid.add_solar_inverters(1) + + results = [] + grid_meter_data = [] + async with mockgrid, AsyncExitStack() as stack: + grid = microgrid.grid() + assert grid, "Grid is not initialized" + stack.push_async_callback(grid.stop) + + grid_power_recv = grid.reactive_power.new_receiver() + + grid_meter_recv = get_resampled_stream( + grid._formula_pool._namespace, # pylint: disable=protected-access + mockgrid.meter_ids[0], + client.ComponentMetricId.REACTIVE_POWER, + ReactivePower.from_volt_amperes_reactive, + ) + + for count in range(10): + await mockgrid.mock_resampler.send_meter_reactive_power( + [20.0 + count, 12.0, -13.0, -5.0] + ) + val = await grid_meter_recv.receive() + assert ( + val is not None + and val.value is not None + and val.value.as_volt_amperes_reactive() != 0.0 + ) + grid_meter_data.append(val.value) + + val = await grid_power_recv.receive() + assert val is not None and val.value is not None + results.append(val.value) + + assert equal_float_lists(results, grid_meter_data) + + +async def test_grid_reactive_power_2(mocker: MockerFixture) -> None: + """Test the grid power formula without a grid side meter.""" + mockgrid = MockMicrogrid(grid_meter=False, mocker=mocker) + mockgrid.add_consumer_meters(1) + mockgrid.add_batteries(1, no_meter=False) + mockgrid.add_batteries(1, no_meter=True) + mockgrid.add_solar_inverters(1) + + results: list[Quantity] = [] + meter_sums: list[Quantity] = [] + async with mockgrid, AsyncExitStack() as stack: + grid = microgrid.grid() + assert grid, "Grid is not initialized" + stack.push_async_callback(grid.stop) + + grid_power_recv = grid.reactive_power.new_receiver() + + component_receivers = [ + get_resampled_stream( + grid._formula_pool._namespace, # pylint: disable=protected-access + component_id, + client.ComponentMetricId.REACTIVE_POWER, + ReactivePower.from_volt_amperes_reactive, + ) + for component_id in [ + *mockgrid.meter_ids, + # The last battery has no meter, so we get the power from the inverter + mockgrid.battery_inverter_ids[-1], + ] + ] + + for count in range(10): + await mockgrid.mock_resampler.send_meter_reactive_power( + [20.0 + count, 12.0, -13.0] + ) + await mockgrid.mock_resampler.send_bat_inverter_reactive_power([0.0, -5.0]) + meter_sum = 0.0 + for recv in component_receivers: + val = await recv.receive() + assert ( + val is not None + and val.value is not None + and val.value.as_volt_amperes_reactive() != 0.0 + ) + meter_sum += val.value.as_volt_amperes_reactive() + + val = await grid_power_recv.receive() + assert val is not None and val.value is not None + results.append(val.value) + meter_sums.append(Quantity(meter_sum)) + + assert len(results) == 10 + assert equal_float_lists(results, meter_sums) + + async def test_grid_power_3_phase_side_meter(mocker: MockerFixture) -> None: """Test the grid 3-phase power with a grid side meter.""" mockgrid = MockMicrogrid(grid_meter=True, mocker=mocker) diff --git a/tests/timeseries/mock_microgrid.py b/tests/timeseries/mock_microgrid.py index ea86b3b92..2fb5cca12 100644 --- a/tests/timeseries/mock_microgrid.py +++ b/tests/timeseries/mock_microgrid.py @@ -275,6 +275,7 @@ def _start_meter_streaming(self, meter_id: int) -> None: lambda value, ts: MeterDataWrapper( component_id=meter_id, timestamp=ts, + reactive_power=2 * value, active_power=value, current_per_phase=(value + 100.0, value + 101.0, value + 102.0), voltage_per_phase=(value + 200.0, value + 199.8, value + 200.2), @@ -307,7 +308,10 @@ def _start_inverter_streaming(self, inv_id: int) -> None: self._comp_data_send_task( inv_id, lambda value, ts: InverterDataWrapper( - component_id=inv_id, timestamp=ts, active_power=value + component_id=inv_id, + timestamp=ts, + active_power=value, + reactive_power=2 * value, ), ), ) @@ -325,6 +329,7 @@ def _start_ev_charger_streaming(self, evc_id: int) -> None: component_id=evc_id, timestamp=ts, active_power=value, + reactive_power=2 * value, current_per_phase=(value + 10.0, value + 11.0, value + 12.0), component_state=self.evc_component_states[evc_id], cable_state=self.evc_cable_states[evc_id], diff --git a/tests/timeseries/mock_resampler.py b/tests/timeseries/mock_resampler.py index 9c5765999..68e9cada3 100644 --- a/tests/timeseries/mock_resampler.py +++ b/tests/timeseries/mock_resampler.py @@ -47,12 +47,13 @@ def __init__( # pylint: disable=too-many-arguments ) self._input_channels_receivers: dict[str, list[Receiver[Sample[Quantity]]]] = {} - def power_senders( + def metric_senders( comp_ids: list[int], + metric_id: ComponentMetricId, ) -> list[Sender[Sample[Quantity]]]: senders: list[Sender[Sample[Quantity]]] = [] for comp_id in comp_ids: - name = f"{comp_id}:{ComponentMetricId.ACTIVE_POWER}" + name = f"{comp_id}:{metric_id}" senders.append( self._channel_registry.get_or_create( Sample[Quantity], name @@ -66,36 +67,44 @@ def power_senders( ] return senders - def frequency_senders( - comp_ids: list[int], - ) -> list[Sender[Sample[Quantity]]]: - senders: list[Sender[Sample[Quantity]]] = [] - for comp_id in comp_ids: - name = f"{comp_id}:{ComponentMetricId.FREQUENCY}" - senders.append( - self._channel_registry.get_or_create( - Sample[Quantity], name - ).new_sender() - ) - self._input_channels_receivers[name] = [ - self._channel_registry.get_or_create( - Sample[Quantity], name - ).new_receiver(name=name) - for _ in range(namespaces) - ] - return senders + # Active power senders + self._bat_inverter_power_senders = metric_senders( + bat_inverter_ids, ComponentMetricId.ACTIVE_POWER + ) + self._pv_inverter_power_senders = metric_senders( + pv_inverter_ids, ComponentMetricId.ACTIVE_POWER + ) + self._ev_power_senders = metric_senders(evc_ids, ComponentMetricId.ACTIVE_POWER) - self._bat_inverter_power_senders = power_senders(bat_inverter_ids) - self._bat_inverter_frequency_senders = frequency_senders(bat_inverter_ids) - self._pv_inverter_power_senders = power_senders(pv_inverter_ids) - self._ev_power_senders = power_senders(evc_ids) - self._chp_power_senders = power_senders(chp_ids) - self._meter_power_senders = power_senders(meter_ids) - self._meter_frequency_senders = frequency_senders(meter_ids) - self._non_existing_component_sender = power_senders( - [NON_EXISTING_COMPONENT_ID] + self._chp_power_senders = metric_senders( + chp_ids, ComponentMetricId.ACTIVE_POWER + ) + self._meter_power_senders = metric_senders( + meter_ids, ComponentMetricId.ACTIVE_POWER + ) + self._non_existing_component_sender = metric_senders( + [NON_EXISTING_COMPONENT_ID], ComponentMetricId.ACTIVE_POWER )[0] + # Frequency senders + self._bat_inverter_frequency_senders = metric_senders( + bat_inverter_ids, ComponentMetricId.FREQUENCY + ) + self._meter_frequency_senders = metric_senders( + meter_ids, ComponentMetricId.FREQUENCY + ) + + # Reactive power senders + self._meter_reactive_power_senders = metric_senders( + meter_ids, ComponentMetricId.REACTIVE_POWER + ) + self._bat_inverter_reactive_power_senders = metric_senders( + bat_inverter_ids, ComponentMetricId.REACTIVE_POWER + ) + self._ev_reactive_power_senders = metric_senders( + evc_ids, ComponentMetricId.REACTIVE_POWER + ) + def multi_phase_senders( ids: list[int], metrics: tuple[ComponentMetricId, ComponentMetricId, ComponentMetricId], @@ -307,6 +316,29 @@ async def send_bat_inverter_power(self, values: list[float | None]) -> None: sample = self.make_sample(value) await chan.send(sample) + async def send_meter_reactive_power(self, values: list[float | None]) -> None: + """Send the given values as resampler output for meter reactive power.""" + assert len(values) == len(self._meter_reactive_power_senders) + for chan, value in zip(self._meter_reactive_power_senders, values): + sample = self.make_sample(value) + await chan.send(sample) + + async def send_bat_inverter_reactive_power( + self, values: list[float | None] + ) -> None: + """Send the given values as resampler output for battery inverter reactive power.""" + assert len(values) == len(self._bat_inverter_reactive_power_senders) + for chan, value in zip(self._bat_inverter_reactive_power_senders, values): + sample = self.make_sample(value) + await chan.send(sample) + + async def send_evc_reactive_power(self, values: list[float | None]) -> None: + """Send the given values as resampler output for EV Charger reactive power.""" + assert len(values) == len(self._ev_reactive_power_senders) + for chan, value in zip(self._ev_reactive_power_senders, values): + sample = self.make_sample(value) + await chan.send(sample) + async def send_non_existing_component_value(self) -> None: """Send a value for a non existing component.""" sample = self.make_sample(None)