From a9d6ba960c96426afd5daf6619a47d4e0b4a136f Mon Sep 17 00:00:00 2001 From: bvweerd Date: Sat, 4 Jul 2026 09:40:45 +0000 Subject: [PATCH] fix(load): add switch-load off-hysteresis and remove phantom first-cycle feedforward MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two switch-load correctness fixes: 1. Switch loads turned off at residual >= 0. After turn-on the residual sits at ~0 W by construction (the load consumes the surplus that triggered it), which is exactly the off threshold — any noise beyond the deadband flapped the load off again, up to two switch actions per minute with the default 30 s debounce. The off threshold is now 10% of the load's fixed power (SWITCH_LOAD_OFF_FRACTION), giving a real hysteresis band. 2. The numeric-load absorption fed forward to switch decisions was computed from a snapshot taken before loads are initialised from their entity state. On the first cycle that a running load is seen (e.g. an EV already charging at 16 A after a restart), the snapshot default of setpoint_min produced a fictitious absorption of thousands of watts, suppressing or mis-triggering switch loads. Absorption is now derived from the distribution return values, so initialisation cannot leak into the feedforward. https://claude.ai/code/session_01RUWpwxbGsgR3PoLHLq4Djz --- .../zero_grid_controller/const.py | 6 ++ .../zero_grid_controller/control_engine.py | 25 ++--- tests/test_load_control.py | 92 +++++++++++++++++++ 3 files changed, 105 insertions(+), 18 deletions(-) diff --git a/custom_components/zero_grid_controller/const.py b/custom_components/zero_grid_controller/const.py index bcff155..3a98877 100644 --- a/custom_components/zero_grid_controller/const.py +++ b/custom_components/zero_grid_controller/const.py @@ -103,6 +103,12 @@ DEFAULT_LOAD_PRIORITY = 50 DEFAULT_LOAD_DEBOUNCE_S = 30 +# Switch loads: once on, only turn off when grid import exceeds this fraction +# of the load's fixed power. After turn-on the residual sits at ~0 W, which +# is exactly the old off-threshold — any noise beyond the deadband flapped +# the load off again. +SWITCH_LOAD_OFF_FRACTION = 0.1 + # Failsafe behaviour when grid sensors are unavailable / controller disabled CONF_FAILSAFE_MODE = "failsafe_mode" FAILSAFE_MODE_MAXIMIZE = "maximize" # PV to max (self-consumption setups) diff --git a/custom_components/zero_grid_controller/control_engine.py b/custom_components/zero_grid_controller/control_engine.py index 65cee40..1847063 100644 --- a/custom_components/zero_grid_controller/control_engine.py +++ b/custom_components/zero_grid_controller/control_engine.py @@ -22,6 +22,7 @@ STATUS_ACTIVE, STATUS_DEADBAND, STATUS_DISABLED, + SWITCH_LOAD_OFF_FRACTION, ControllerMode, ControllerStatus, ) @@ -469,21 +470,15 @@ async def run_cycle( self._pid.set_integral(0.0) pid_output = p_only - # 7b. Numeric correction - _load_sp_before = { - ld.name: self._current_load_setpoints.get(ld.name, ld.setpoint_min) - for ld in loads_active - if not ld.is_switch - } - _load_w_per_unit = { - ld.name: ld.w_per_unit for ld in loads_active if not ld.is_switch - } - + # 7b. Numeric correction; absorption is derived from the + # distribution return values so a load initialised from its + # entity state mid-cycle does not produce a phantom feedforward. if pid_output < 0: # Exporting surplus → increase loads, then curtail arrays after_loads = await self._distribute_to_numeric_loads( pid_output, now, loads_active ) + load_absorbed_w = after_loads - pid_output final_remaining = await self._distribute_to_numeric_arrays( after_loads, now, arrays_active ) @@ -497,6 +492,7 @@ async def run_cycle( final_remaining = await self._distribute_to_numeric_loads( after_arrays, now, loads_active ) + load_absorbed_w = final_remaining - after_arrays # Saturation anti-windup: freeze integrator when actuators cannot # absorb the requested correction (settling timers or at physical @@ -504,13 +500,6 @@ async def run_cycle( if abs(pid_output) > 1.0 and abs(final_remaining) >= 0.95 * abs(pid_output): self._pid.freeze_integrator() - # Actual watts absorbed by numeric loads in this cycle - load_absorbed_w = sum( - (self._current_load_setpoints.get(name, before) - before) - * _load_w_per_unit[name] - for name, before in _load_sp_before.items() - ) - # 7c. Switch loads: feedforward of numeric load AND array changes # so switch decisions do not double-take corrections made this cycle residual = await self._apply_load_switch_hysteresis( @@ -986,7 +975,7 @@ async def _apply_load_switch_hysteresis( self._current_load_setpoints[load.name] = 1.0 self._load_settling_until[load.name] = now + load.switch_debounce_s residual += load.power_w - elif is_on and residual >= 0: + elif is_on and residual >= SWITCH_LOAD_OFF_FRACTION * load.power_w: try: await self._actuators.write_switch_entity( load.setpoint_entity, False diff --git a/tests/test_load_control.py b/tests/test_load_control.py index 394194b..5368f58 100644 --- a/tests/test_load_control.py +++ b/tests/test_load_control.py @@ -853,3 +853,95 @@ async def test_full_cycle_numeric_load_reduces_on_import(hass): mock_num.assert_awaited() written = mock_num.call_args[0][1] assert written < 10.0 + + +# --------------------------------------------------------------------------- +# Switch load off-hysteresis and feedforward regressions +# --------------------------------------------------------------------------- + + +async def test_switch_load_stays_on_within_off_margin(hass): + """A small import below the off-margin must not flap the load off. + + After turn-on the residual sits at ~0 W — exactly the old off-threshold — + so any noise beyond the deadband toggled the load off again (up to two + switch actions per minute with the default 30 s debounce). + """ + entry = _make_entry(kp=1.0) + entry.add_to_hass(hass) + coordinator = ZeroGridCoordinator(hass, entry) + coordinator.loads = [_switch_load(power_w=2000.0, switch_debounce_s=0)] + coordinator._engine._current_load_setpoints["Boiler"] = 1.0 # on + hass.states.async_set("switch.boiler", "on") + + # 150 W import: beyond the deadband but well below 10% of 2000 W + hass.states.async_set("sensor.grid_import", "150") + hass.states.async_set("sensor.grid_export", "0") + + with patch.object( + coordinator._actuators, "write_switch_entity", new=AsyncMock() + ) as mock_switch: + await coordinator._async_update_data() + + mock_switch.assert_not_awaited() + assert coordinator._engine._current_load_setpoints["Boiler"] == 1.0 + + +async def test_switch_load_turns_off_beyond_off_margin(hass): + """Import beyond the off-margin still turns the load off.""" + entry = _make_entry(kp=1.0) + entry.add_to_hass(hass) + coordinator = ZeroGridCoordinator(hass, entry) + coordinator.loads = [_switch_load(power_w=2000.0, switch_debounce_s=0)] + coordinator._engine._current_load_setpoints["Boiler"] = 1.0 # on + hass.states.async_set("switch.boiler", "on") + + # 400 W import: beyond 10% of 2000 W → turn off + hass.states.async_set("sensor.grid_import", "400") + hass.states.async_set("sensor.grid_export", "0") + + with patch.object( + coordinator._actuators, "write_switch_entity", new=AsyncMock() + ) as mock_switch: + await coordinator._async_update_data() + + mock_switch.assert_awaited() + assert coordinator._engine._current_load_setpoints["Boiler"] == 0.0 + + +async def test_no_phantom_feedforward_on_first_cycle(hass): + """A load initialised from its entity state mid-cycle must not produce a + phantom feedforward that blocks switch-load decisions. + + Scenario: the EV is already charging at 16 A (entity state) but the + engine has not seen it yet. With the old snapshot-based accounting the + 'before' value defaulted to setpoint_min, producing a fictitious + +3680 W absorption that suppressed the boiler turn-on. + """ + entry = _make_entry(kp=1.0) + entry.add_to_hass(hass) + coordinator = ZeroGridCoordinator(hass, entry) + coordinator.loads = [ + _numeric_load( + w_per_unit=230.0, setpoint_min=0.0, setpoint_max=16.0, settling_time_s=0 + ), + _switch_load(power_w=2000.0, switch_debounce_s=0, priority=2), + ] + # EV entity is at max already; engine state is uninitialised + hass.states.async_set("number.ev", "16") + hass.states.async_set("switch.boiler", "off") + + # 2500 W export: EV cannot take more (at max), boiler (2000 W) must turn on + hass.states.async_set("sensor.grid_import", "0") + hass.states.async_set("sensor.grid_export", "2500") + + with ( + patch.object(coordinator._actuators, "write_numeric_entity", new=AsyncMock()), + patch.object( + coordinator._actuators, "write_switch_entity", new=AsyncMock() + ) as mock_switch, + ): + await coordinator._async_update_data() + + mock_switch.assert_awaited() + assert coordinator._engine._current_load_setpoints["Boiler"] == 1.0