From 38f2c5d5b2bb7af8fec158993cf760a4100c5b1a Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Wed, 25 Feb 2026 22:55:15 +0100 Subject: [PATCH 1/5] Raise instead of treating string return values as errors Signed-off-by: Sahas Subramanian --- .../_component_managers/_battery_manager.py | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/frequenz/sdk/microgrid/_power_distributing/_component_managers/_battery_manager.py b/src/frequenz/sdk/microgrid/_power_distributing/_component_managers/_battery_manager.py index 616e715ee..1dffe0c43 100644 --- a/src/frequenz/sdk/microgrid/_power_distributing/_component_managers/_battery_manager.py +++ b/src/frequenz/sdk/microgrid/_power_distributing/_component_managers/_battery_manager.py @@ -226,13 +226,10 @@ async def _get_distribution(self, request: Request) -> DistributionResult | Resu Returns: Distribution of the batteries. """ - match self._get_components_data(request.component_ids): - case str() as err: - return Error(request=request, msg=err) - case list() as pairs_data: - pass - case unexpected: - typing.assert_never(unexpected) + try: + pairs_data = self._get_components_data(request.component_ids) + except RuntimeError as err: + return Error(request=request, msg=str(err)) if not pairs_data: error_msg = ( @@ -524,15 +521,17 @@ def nan_metric_in_list(data: list[DataType], metrics: list[str]) -> bool: def _get_components_data( self, batteries: collections.abc.Set[ComponentId] - ) -> list[InvBatPair] | str: + ) -> list[InvBatPair]: """Get data for the given batteries and adjacent inverters. Args: batteries: Batteries that needs data. Returns: - Pairs of battery and adjacent inverter data or an error message if there was - an error while getting the data. + Pairs of battery and adjacent inverter data. + + Raises: + RuntimeError: If there was an error while getting the data. """ inverter_ids: collections.abc.Set[ComponentId] pairs_data: list[InvBatPair] = [] @@ -543,7 +542,7 @@ def _get_components_data( for battery_id in working_batteries: if battery_id not in self._battery_caches: - return ( + raise RuntimeError( f"No battery {battery_id}, " f"available batteries: {self._str_ids(self._battery_caches.keys())}" ) @@ -559,7 +558,7 @@ def _get_components_data( if batteries_from_inverters != batteries: extra_batteries = batteries_from_inverters - batteries inverter_ids = _get_all_from_map(self._bat_invs_map, extra_batteries) - return ( + raise RuntimeError( f"Inverter(s) ({self._str_ids(inverter_ids)}) are connected to " f"battery(ies) ({self._str_ids(extra_batteries)}) that were not requested" ) From 8c4f3b0af43ab3f36700e3c630e1415b28893c3c Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Wed, 4 Mar 2026 16:00:42 +0100 Subject: [PATCH 2/5] Package `InvBatPair`s with unreachable power in BatteryComponentsData This is just a place-holder for unreachable power. It will be calculated and used in subsequent commits. Signed-off-by: Sahas Subramanian --- .../_component_managers/_battery_manager.py | 18 +++++++++++------- .../_distribution_algorithm/__init__.py | 2 ++ .../_battery_distribution_algorithm.py | 15 +++++++++++++++ 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/frequenz/sdk/microgrid/_power_distributing/_component_managers/_battery_manager.py b/src/frequenz/sdk/microgrid/_power_distributing/_component_managers/_battery_manager.py index 1dffe0c43..c9270257d 100644 --- a/src/frequenz/sdk/microgrid/_power_distributing/_component_managers/_battery_manager.py +++ b/src/frequenz/sdk/microgrid/_power_distributing/_component_managers/_battery_manager.py @@ -23,6 +23,7 @@ from .._component_status import BatteryStatusTracker, ComponentPoolStatus from .._distribution_algorithm import ( AggregatedBatteryData, + BatteryComponentsData, BatteryDistributionAlgorithm, DistributionResult, InvBatPair, @@ -227,23 +228,25 @@ async def _get_distribution(self, request: Request) -> DistributionResult | Resu Distribution of the batteries. """ try: - pairs_data = self._get_components_data(request.component_ids) + components_data = self._get_components_data(request.component_ids) except RuntimeError as err: return Error(request=request, msg=str(err)) - if not pairs_data: + if not components_data.inv_bat_pairs: error_msg = ( "No data for at least one of the given batteries: " + self._str_ids(request.component_ids) ) return Error(request=request, msg=str(error_msg)) - error = self._check_request(request, pairs_data) + error = self._check_request(request, components_data.inv_bat_pairs) if error: return error try: - distribution = self._get_power_distribution(request, pairs_data) + distribution = self._get_power_distribution( + request, components_data.inv_bat_pairs + ) except ValueError as err: _logger.exception("Couldn't distribute power") error_msg = f"Couldn't distribute power, error: {str(err)}" @@ -521,14 +524,15 @@ def nan_metric_in_list(data: list[DataType], metrics: list[str]) -> bool: def _get_components_data( self, batteries: collections.abc.Set[ComponentId] - ) -> list[InvBatPair]: + ) -> BatteryComponentsData: """Get data for the given batteries and adjacent inverters. Args: batteries: Batteries that needs data. Returns: - Pairs of battery and adjacent inverter data. + Battery component data including pairs of battery and adjacent inverter + data, and unreachable power. Raises: RuntimeError: If there was an error while getting the data. @@ -581,7 +585,7 @@ def _get_components_data( assert len(data.inverter) > 0 pairs_data.append(data) - return pairs_data + return BatteryComponentsData(pairs_data, None) def _str_ids(self, ids: collections.abc.Set[ComponentId]) -> str: return ", ".join(str(cid) for cid in sorted(ids)) diff --git a/src/frequenz/sdk/microgrid/_power_distributing/_distribution_algorithm/__init__.py b/src/frequenz/sdk/microgrid/_power_distributing/_distribution_algorithm/__init__.py index 31794dd24..fbe6537a1 100644 --- a/src/frequenz/sdk/microgrid/_power_distributing/_distribution_algorithm/__init__.py +++ b/src/frequenz/sdk/microgrid/_power_distributing/_distribution_algorithm/__init__.py @@ -5,6 +5,7 @@ from ._battery_distribution_algorithm import ( AggregatedBatteryData, + BatteryComponentsData, BatteryDistributionAlgorithm, DistributionResult, InvBatPair, @@ -15,4 +16,5 @@ "DistributionResult", "InvBatPair", "AggregatedBatteryData", + "BatteryComponentsData", ] diff --git a/src/frequenz/sdk/microgrid/_power_distributing/_distribution_algorithm/_battery_distribution_algorithm.py b/src/frequenz/sdk/microgrid/_power_distributing/_distribution_algorithm/_battery_distribution_algorithm.py index 6fb71d0e6..2eb7faf6a 100644 --- a/src/frequenz/sdk/microgrid/_power_distributing/_distribution_algorithm/_battery_distribution_algorithm.py +++ b/src/frequenz/sdk/microgrid/_power_distributing/_distribution_algorithm/_battery_distribution_algorithm.py @@ -153,6 +153,21 @@ class InvBatPair(NamedTuple): """The inverter data.""" +@dataclass +class BatteryComponentsData: + """Container for battery component data and unreachable power.""" + + inv_bat_pairs: list[InvBatPair] + """The battery and inverter data pairs.""" + + unreachable_power: Power | None + """Power from batteries with unreachable inverters. + + This power needs to be excluded from the distributed power, because the + inverters producing it are unreachable and cannot be controlled. + """ + + @dataclass class AvailabilityRatio: """Availability ratio for a battery-inverter pair.""" From 3941ae32729c54f439b9746fdf87221b4e15f1ca Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Wed, 4 Mar 2026 16:26:22 +0100 Subject: [PATCH 3/5] Exclude existing unreachable power from power to be distributed Signed-off-by: Sahas Subramanian --- .../_component_managers/_battery_manager.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/frequenz/sdk/microgrid/_power_distributing/_component_managers/_battery_manager.py b/src/frequenz/sdk/microgrid/_power_distributing/_component_managers/_battery_manager.py index c9270257d..e27489127 100644 --- a/src/frequenz/sdk/microgrid/_power_distributing/_component_managers/_battery_manager.py +++ b/src/frequenz/sdk/microgrid/_power_distributing/_component_managers/_battery_manager.py @@ -244,9 +244,7 @@ async def _get_distribution(self, request: Request) -> DistributionResult | Resu return error try: - distribution = self._get_power_distribution( - request, components_data.inv_bat_pairs - ) + distribution = self._get_power_distribution(request, components_data) except ValueError as err: _logger.exception("Couldn't distribute power") error_msg = f"Couldn't distribute power, error: {str(err)}" @@ -591,17 +589,22 @@ def _str_ids(self, ids: collections.abc.Set[ComponentId]) -> str: return ", ".join(str(cid) for cid in sorted(ids)) def _get_power_distribution( - self, request: Request, inv_bat_pairs: list[InvBatPair] + self, + request: Request, + components: BatteryComponentsData, ) -> DistributionResult: """Get power distribution result for the batteries in the request. Args: request: the power request to process. - inv_bat_pairs: the battery and adjacent inverter data pairs. + components: the battery component data including pairs of battery + and adjacent inverter data, and unreachable power. Returns: the power distribution result. """ + inv_bat_pairs = components.inv_bat_pairs + available_bat_ids = _get_all_from_map( self._bat_bats_map, {pair.battery.component_id for pair in inv_bat_pairs} ) @@ -614,8 +617,12 @@ def _get_power_distribution( ]: unavailable_inv_ids = unavailable_inv_ids | inverter_ids + power_to_distribute = request.power + if components.unreachable_power is not None: + power_to_distribute = power_to_distribute - components.unreachable_power + result = self._distribution_algorithm.distribute_power( - request.power, inv_bat_pairs + power_to_distribute, inv_bat_pairs ) return result From e8a7d15ce358d30c8666a7586aa0241e7b61e63a Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Tue, 10 Mar 2026 18:32:41 +0100 Subject: [PATCH 4/5] Track power metrics of unreachable battery inverters When a battery is unusable because its inverter is unreachable, we start a formula (with fallback from the component graph) to track its power. If the inverter is still functioning but only not reachable because of a network issue, its power will be excluded from the power to be distributed. This makes sure that we don't end up setting more power than what was requested. Signed-off-by: Sahas Subramanian --- .../_component_managers/_battery_manager.py | 116 +++++++++++++++++- 1 file changed, 113 insertions(+), 3 deletions(-) diff --git a/src/frequenz/sdk/microgrid/_power_distributing/_component_managers/_battery_manager.py b/src/frequenz/sdk/microgrid/_power_distributing/_component_managers/_battery_manager.py index e27489127..5dca305b0 100644 --- a/src/frequenz/sdk/microgrid/_power_distributing/_component_managers/_battery_manager.py +++ b/src/frequenz/sdk/microgrid/_power_distributing/_component_managers/_battery_manager.py @@ -14,9 +14,11 @@ from frequenz.client.common.microgrid.components import ComponentId from frequenz.client.microgrid import ApiClientError, OperationOutOfRange from frequenz.client.microgrid.component import Battery, Inverter +from frequenz.client.microgrid.metrics import Metric from frequenz.quantities import Power from typing_extensions import override +from ....timeseries import Sample from ... import connection_manager from ..._old_component_data import BatteryData, InverterData from .._component_pool_status_tracker import ComponentPoolStatusTracker @@ -161,6 +163,20 @@ def __init__( self._battery_caches: dict[ComponentId, LatestValueCache[BatteryData]] = {} self._inverter_caches: dict[ComponentId, LatestValueCache[InverterData]] = {} + self._unreachable_battery_powers: dict[ + frozenset[ComponentId], + tuple[collections.abc.Set[ComponentId], LatestValueCache[Sample[Power]]], + ] = {} + """Map from battery sets to data from inaccessible battery subset. + + When batteries are inaccessible, for example, due to network issues and can't be + controlled, they might still be producing or consuming power. This power needs + to be considered when distributing power to the other batteries. + + So for each battery set, we track of the inaccessible subset of this battery set + and the result of the power formulas for the inaccessible subset here. + """ + self._component_pool_status_tracker = ComponentPoolStatusTracker( component_ids=set(self._battery_ids), component_status_sender=component_pool_status_sender, @@ -228,7 +244,7 @@ async def _get_distribution(self, request: Request) -> DistributionResult | Resu Distribution of the batteries. """ try: - components_data = self._get_components_data(request.component_ids) + components_data = await self._get_components_data(request.component_ids) except RuntimeError as err: return Error(request=request, msg=str(err)) @@ -520,7 +536,78 @@ def nan_metric_in_list(data: list[DataType], metrics: list[str]) -> bool: return InvBatPair(AggregatedBatteryData(battery_data), inverter_data) - def _get_components_data( + async def _subscribe_to_unreachable_battery_power( + self, + requested_battery_ids: collections.abc.Set[ComponentId], + working_battery_ids: collections.abc.Set[ComponentId], + ) -> None: + requested_battery_ids = frozenset(requested_battery_ids) + unreachable_battery_ids = requested_battery_ids - working_battery_ids + + if not unreachable_battery_ids: + if unreachable_power := self._unreachable_battery_powers.pop( + requested_battery_ids, None + ): + _logger.debug( + "All batteries are reachable, stopping unreachable battery power " + + "subscription for batteries %s", + self._str_ids(requested_battery_ids), + ) + await unreachable_power[1].stop() + return + + if unreachable_power := self._unreachable_battery_powers.get( + requested_battery_ids + ): + if unreachable_power[0] == unreachable_battery_ids: + return + _logger.debug( + "Unreachable battery set for batteries %s changed from %s to %s, " + + "restarting subscription", + self._str_ids(requested_battery_ids), + self._str_ids(unreachable_power[0]), + self._str_ids(unreachable_battery_ids), + ) + await unreachable_power[1].stop() + + formula = connection_manager.get().component_graph.battery_formula( + unreachable_battery_ids + ) + _logger.debug( + "Subscribing to unreachable battery power for batteries %s with formula: %s", + self._str_ids(unreachable_battery_ids), + formula, + ) + + # This is to avoid a circular import. Pylint doesn't detect that this + # is not a circular import, so we need to disable the warning also. + # + # pylint: disable-next=import-outside-toplevel,cyclic-import + from ... import ( + logical_meter, + ) + + formula_cache = LatestValueCache( + logical_meter() + .start_formula(formula, Metric.AC_ACTIVE_POWER) + .new_receiver() + .map( + lambda sample: Sample[Power]( + sample.timestamp, + ( + Power.from_watts(sample.value.base_value) + if sample.value is not None + else None + ), + ) + ) + ) + self._unreachable_battery_powers[requested_battery_ids] = ( + unreachable_battery_ids, + formula_cache, + ) + + async def _get_components_data( self, batteries: collections.abc.Set[ComponentId] ) -> BatteryComponentsData: """Get data for the given batteries and adjacent inverters. @@ -542,6 +629,11 @@ def _get_components_data( batteries ) + await self._subscribe_to_unreachable_battery_power( + batteries, + working_batteries, + ) + for battery_id in working_batteries: if battery_id not in self._battery_caches: raise RuntimeError( @@ -583,7 +675,18 @@ def _get_components_data( assert len(data.inverter) > 0 pairs_data.append(data) - return BatteryComponentsData(pairs_data, None) + + unreachable_power: Power | None = None + if unreachable_power_cache := self._unreachable_battery_powers.get( + frozenset(batteries) + ): + if unreachable_power_cache[1].has_value(): + unreachable_power = unreachable_power_cache[1].get().value + + return BatteryComponentsData( + inv_bat_pairs=pairs_data, + unreachable_power=unreachable_power, + ) def _str_ids(self, ids: collections.abc.Set[ComponentId]) -> str: return ", ".join(str(cid) for cid in sorted(ids)) @@ -620,6 +723,13 @@ def _get_power_distribution( power_to_distribute = request.power if components.unreachable_power is not None: power_to_distribute = power_to_distribute - components.unreachable_power + _logger.debug( + "Excluding %s measured on unreachable batteries: %s, from power to " + + "distribute on working batteries: %s", + components.unreachable_power, + unavailable_bat_ids, + available_bat_ids, + ) result = self._distribution_algorithm.distribute_power( power_to_distribute, inv_bat_pairs From c6f03e9248a263fe6d42d39383cde659866e7db8 Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Tue, 10 Mar 2026 18:40:55 +0100 Subject: [PATCH 5/5] Update release notes Signed-off-by: Sahas Subramanian --- RELEASE_NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 5f93b7d7b..d0f4b3108 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -15,3 +15,5 @@ ## Bug Fixes - Improved formula validation: Consistent error messages for invalid formulas and conventional span semantics. + +- This fixes a rare power distributor bug where some battery inverters becoming unreachable because of network outages would lead to excess power values getting set. This is fixed by measuring the power of the unreachable inverters through their fallback meters and excluding that power from what is distributed to the other inverters.