From 6fb3a17b1be3003b7fd2b8c6bf5fa8c9dda61c30 Mon Sep 17 00:00:00 2001 From: "Mathias L. Baumann" Date: Wed, 7 Oct 2026 14:28:57 +0200 Subject: [PATCH] Fix exclusion-bound target direction Signed-off-by: Mathias L. Baumann --- RELEASE_NOTES.md | 3 ++ .../_power_managing/_shifting_matryoshka.py | 14 +++++-- .../test_shifting_matryoshka.py | 40 +++++++++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index da0f457a5..1a93ffac0 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -11,3 +11,6 @@ This release updates the microgrid component graph library to v0.6.0, which stop - Producer power used to clamp each producer at zero. A producer that draws power now adds a positive value instead of zero, so the total can be positive. For example, PV producing 10 kW and a CHP drawing 2 kW used to give -10 kW and now give -8 kW. There is one exception: when the two share a meter below the grid meter, that meter sends data, and `disable_fallback_components` is off, the meter measures them together, so they gave -8 kW before too. `producer.power.min(Power.zero()).build("producer_power")` clamps the total at zero, but it still differs from the old result when one producer draws power while another produces. - With `ComponentGraphConfig(include_phantom_loads_in_consumer_formula=True)`, consumer power is unchanged: it still clamps each of its terms at zero. +## Bug Fixes + +* Make the PowerManager fall back to zero instead of changing the requested power direction when a target is snapped outside asymmetric exclusion bounds. diff --git a/src/frequenz/sdk/microgrid/_power_managing/_shifting_matryoshka.py b/src/frequenz/sdk/microgrid/_power_managing/_shifting_matryoshka.py index dfaa15075..e63cbbc90 100644 --- a/src/frequenz/sdk/microgrid/_power_managing/_shifting_matryoshka.py +++ b/src/frequenz/sdk/microgrid/_power_managing/_shifting_matryoshka.py @@ -40,9 +40,14 @@ def _get_nearest_possible_power( case (None, p) | (p, None) if p: return p case (low, high) if low and high: - if high - power < power - low: - return high - return low + # The exclusion bounds are a region where the system cannot operate. + nearest = high if high - power < power - low else low + # Never turn a request into power in the opposite direction. + if (power > Power.zero() and nearest < Power.zero()) or ( + power < Power.zero() and nearest > Power.zero() + ): + return Power.zero() + return nearest case _: return Power.zero() @@ -197,9 +202,10 @@ def _calc_targets( # pylint: disable=too-many-branches,too-many-statements if allocations: _logger.info( - "PowerManager allocations for component IDs: %s: %s", + "PowerManager allocations for component IDs: %s: %s, target power: %s", sorted(component_ids), allocations, + target_power, ) return target_power, Bounds[Power](lower=lower_bound, upper=upper_bound) diff --git a/tests/actor/_power_managing/test_shifting_matryoshka.py b/tests/actor/_power_managing/test_shifting_matryoshka.py index dad00dfe1..2d05e47a7 100644 --- a/tests/actor/_power_managing/test_shifting_matryoshka.py +++ b/tests/actor/_power_managing/test_shifting_matryoshka.py @@ -6,6 +6,7 @@ # pylint: disable=duplicate-code import asyncio +import logging import re from datetime import datetime, timedelta, timezone @@ -397,6 +398,45 @@ async def test_matryoshka_with_excl_3() -> None: tester.bounds(priority=0, expected_power=-50.0, expected_bounds=(-60.0, 5.0)) +@pytest.mark.parametrize( + ("requested_power", "exclusion_bounds", "expected_power"), + [ + (-3.0, (-35.0, 7.0), 0.0), + (3.0, (-7.0, 35.0), 0.0), + (-30.0, (-35.0, 7.0), -35.0), + ], +) +async def test_matryoshka_avoids_opposite_direction_outside_exclusion_bounds( + caplog: pytest.LogCaptureFixture, + requested_power: float, + exclusion_bounds: tuple[float, float], + expected_power: float, +) -> None: + """Avoid targets that would snap to the opposite direction.""" + batteries = frozenset({ComponentId(2), ComponentId(5)}) + system_bounds = _base_types.SystemBounds( + timestamp=datetime.now(tz=timezone.utc), + inclusion_bounds=timeseries.Bounds( + lower=Power.from_watts(-200.0), upper=Power.from_watts(200.0) + ), + exclusion_bounds=timeseries.Bounds( + lower=Power.from_watts(exclusion_bounds[0]), + upper=Power.from_watts(exclusion_bounds[1]), + ), + ) + tester = StatefulTester(batteries, system_bounds) + + with caplog.at_level(logging.INFO): + tester.tgt_power( + priority=1, + power=requested_power, + bounds=(-200.0, 200.0), + expected=expected_power, + ) + + assert f"target power: {expected_power:g} W" in caplog.messages[-1] + + async def test_matryoshka_drop_old_proposals() -> None: """Tests for the power managing actor.