Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions custom_components/zero_grid_controller/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 7 additions & 18 deletions custom_components/zero_grid_controller/control_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
STATUS_ACTIVE,
STATUS_DEADBAND,
STATUS_DISABLED,
SWITCH_LOAD_OFF_FRACTION,
ControllerMode,
ControllerStatus,
)
Expand Down Expand Up @@ -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
)
Expand All @@ -497,20 +492,14 @@ 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
# limits). Prevents further wind-up while the system is saturated.
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(
Expand Down Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions tests/test_load_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading