From e756edefbdf96f66cdca6ac88ed5867789550354 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Fri, 3 Jul 2026 17:41:26 +0000 Subject: [PATCH] fix(load): do not turn on a minimum-power load when the surplus cannot cover it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A numeric load with absolute_min_w (e.g. an EV charger that requires at least 6 A / 1380 W when on) was snapped up to that minimum for any surplus, however small. A 300 W surplus turned the charger on at 1380 W, creating over 1 kW of grid import that the controller then had to correct by curtailing PV or reducing other loads. The load now only turns on from off when the available surplus covers absolute_min_w — consistent with how switch loads already gate on their fixed power. Reducing toward off (snap down to 0) is unchanged. https://claude.ai/code/session_01RUWpwxbGsgR3PoLHLq4Djz --- .../zero_grid_controller/control_engine.py | 9 +++++ tests/test_load_control.py | 37 ++++++++++++++++--- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/custom_components/zero_grid_controller/control_engine.py b/custom_components/zero_grid_controller/control_engine.py index e5adac4..e4ff504 100644 --- a/custom_components/zero_grid_controller/control_engine.py +++ b/custom_components/zero_grid_controller/control_engine.py @@ -821,6 +821,15 @@ async def _distribute_to_numeric_loads( available_w = load.headroom_increase_w(current) if available_w <= 0: continue + # Loads with a minimum active power only turn on when the + # surplus covers that minimum — otherwise snapping up to the + # minimum would create grid import. + if ( + load.absolute_min_w is not None + and current * load.w_per_unit < load.absolute_min_w + and -remaining < load.absolute_min_w + ): + continue take_w = min(available_w, -remaining) delta_units = round(take_w / load.w_per_unit) if delta_units == 0: diff --git a/tests/test_load_control.py b/tests/test_load_control.py index b2c6b7c..394194b 100644 --- a/tests/test_load_control.py +++ b/tests/test_load_control.py @@ -489,8 +489,8 @@ async def mock_write(entity_id, value): assert pool_writes[0] > 0 -async def test_numeric_load_snap_up_over_absorbs(hass): - """Snap up to absolute_min_w can over-absorb surplus (remaining goes positive).""" +async def test_numeric_load_stays_off_when_surplus_below_minimum(hass): + """A load with absolute_min_w does not turn on for surplus below that minimum.""" entry = _make_entry(kp=1.0) entry.add_to_hass(hass) @@ -507,16 +507,41 @@ async def test_numeric_load_snap_up_over_absorbs(hass): with patch.object( coordinator._actuators, "write_numeric_entity", new=AsyncMock() ) as mock_write: - # -300 W surplus: would request 1-2A, snap up to 6A (1380W) → over-absorbs + # -300 W surplus is below the 1380 W minimum → turning on would import remaining = await coordinator._engine._distribute_to_numeric_loads( -300.0, 0.0, coordinator.loads ) + mock_write.assert_not_awaited() + assert remaining == -300.0 + + +async def test_numeric_load_turns_on_when_surplus_covers_minimum(hass): + """A load with absolute_min_w turns on once the surplus covers the minimum.""" + entry = _make_entry(kp=1.0) + entry.add_to_hass(hass) + + coordinator = ZeroGridCoordinator(hass, entry) + coordinator.loads = [ + _numeric_load( + w_per_unit=230.0, absolute_min_w=1380.0, setpoint_min=0.0, setpoint_max=16.0 + ) + ] + coordinator._engine._current_load_setpoints["EV"] = 0.0 + hass.states.async_set("number.ev", "0") + + with patch.object( + coordinator._actuators, "write_numeric_entity", new=AsyncMock() + ) as mock_write: + remaining = await coordinator._engine._distribute_to_numeric_loads( + -1500.0, 0.0, coordinator.loads + ) + mock_write.assert_awaited() written = mock_write.call_args[0][1] - assert written == 6.0 # snapped to minimum active setpoint - # remaining flips positive: EV absorbed 1380W but only 300W was requested - assert remaining > 0 + # 1500 W / 230 W/A rounds to 7 A — above the 6 A minimum, so no snap needed + assert written == 7.0 + assert remaining == -1500.0 + 7 * 230.0 async def test_numeric_load_snap_down_over_releases(hass):