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
49 changes: 41 additions & 8 deletions custom_components/zero_grid_controller/control_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ def __init__(
self._load_settling_until: dict[str, float] = {}
self._battery_pending_until: dict[str, float] = {}
self._schedule_unavailable: set[str] = set()
self._soc_unavailable: set[str] = set()

# ------------------------------------------------------------------
# Public API
Expand Down Expand Up @@ -450,21 +451,22 @@ async def run_cycle(
pid_output = self._pid.compute(-residual, dt)

# Back-calculation anti-windup (sign contradiction):
# When the integral has wound up so far that it opposes the actual
# grid direction, immediately reset it and use the P-only output for
# When the integral has wound up so far that it opposes the residual
# the PID acts on, immediately reset it and use the P-only output for
# this cycle. Threshold of 3×deadband avoids triggering on normal
# near-zero oscillation.
if abs(pid_output) > 3 * self._deadband_w:
wrong_direction = (pid_output < 0 and filtered > self._deadband_w) or (
pid_output > 0 and filtered < -self._deadband_w
wrong_direction = (pid_output < 0 and residual > self._deadband_w) or (
pid_output > 0 and residual < -self._deadband_w
)
if wrong_direction:
p_only = self._pid.kp * residual
_LOGGER.info(
"Integrator wind-up corrected: output %.0f W → %.0f W"
" (grid %.1f W)",
" (residual %.1f W, grid %.1f W)",
pid_output,
p_only,
residual,
filtered,
)
self._pid.set_integral(0.0)
Expand Down Expand Up @@ -575,6 +577,18 @@ def _read_battery_states(self, batteries: list[BatteryConfig]) -> BatteryState:
if battery.soc_sensor_entity
else None
)
if battery.soc_sensor_entity and soc is None:
if battery.name not in self._soc_unavailable:
self._soc_unavailable.add(battery.name)
_LOGGER.warning(
"SoC sensor %s for battery %s is unavailable — "
"SoC limits are not enforced until it recovers",
battery.soc_sensor_entity,
battery.name,
)
elif battery.name in self._soc_unavailable:
self._soc_unavailable.discard(battery.name)
_LOGGER.info("SoC sensor for battery %s recovered", battery.name)
state.charge_caps[battery.name] = (
0.0
if soc is not None and soc >= battery.max_soc
Expand All @@ -587,6 +601,22 @@ def _read_battery_states(self, batteries: list[BatteryConfig]) -> BatteryState:
)
return state

def _load_level(self, load: LoadConfig) -> float:
"""Current load level, reading the entity when not yet initialised.

Prevents a load that is physically running (e.g. an EV charging
right after a restart) from being treated as off before the engine
has seen it, which would allow battery discharge into a reducible
load.
"""
known = self._current_load_setpoints.get(load.name)
if known is not None:
return known
if load.is_switch:
return 1.0 if self.entity_state(load.setpoint_entity) == "on" else 0.0
value = self.read_sensor_safe(load.setpoint_entity)
return value if value is not None else load.setpoint_min

def _discharge_allowed(
self, arrays: list[ArrayConfig], loads: list[LoadConfig]
) -> bool:
Expand All @@ -597,8 +627,7 @@ def _discharge_allowed(
for a in numeric_arrays
)
loads_off = all(
self._current_load_setpoints.get(ld.name, 0.0)
<= (0.0 if ld.is_switch else ld.setpoint_min)
self._load_level(ld) <= (0.0 if ld.is_switch else ld.setpoint_min)
for ld in loads
)
return arrays_maxed and loads_off
Expand Down Expand Up @@ -1023,7 +1052,11 @@ async def _apply_switch_hysteresis(
else:
continue

await self._actuators.write_setpoint(array, new_sp)
try:
await self._actuators.write_setpoint(array, new_sp)
except Exception:
_LOGGER.exception("Failed to toggle switch array %s", array.name)
continue
self._current_setpoints[array.name] = new_sp
self._settling_until[array.name] = now + array.switch_debounce_s
residual += residual_delta
Expand Down
144 changes: 144 additions & 0 deletions tests/test_robustness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""Robustness tests: write failures, windup consistency, init edge cases."""

from __future__ import annotations

from unittest.mock import AsyncMock, patch

import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry

from custom_components.zero_grid_controller.array import ArrayConfig
from custom_components.zero_grid_controller.battery import BatteryConfig
from custom_components.zero_grid_controller.const import (
DOMAIN,
LOAD_TYPE_NUMERIC,
STATUS_ACTIVE,
)
from custom_components.zero_grid_controller.coordinator import ZeroGridCoordinator
from custom_components.zero_grid_controller.load import LoadConfig


@pytest.fixture(autouse=True)
def auto_enable_custom_integrations(enable_custom_integrations):
return


def _make_entry(**extra) -> MockConfigEntry:
data = {
"name": "Test ZGC",
"grid_import_sensors": ["sensor.grid_import"],
"grid_export_sensors": ["sensor.grid_export"],
"kp": 1.0,
"ki": 0.0,
"deadband_w": 10.0,
"ewm_alpha": 1.0,
**extra,
}
return MockConfigEntry(domain=DOMAIN, title="Test", data=data, options={})


def _switch_array() -> ArrayConfig:
return ArrayConfig(
name="SwitchArray",
output_type="switch",
setpoint_entity="switch.solar_sw",
w_per_unit=10.0,
calibration_confidence="estimated",
setpoint_min=0.0,
setpoint_max=1.0,
settling_time_s=0,
switch_on_threshold_w=100.0,
switch_off_threshold_w=50.0,
switch_debounce_s=0,
)


def _make_battery(**kwargs) -> BatteryConfig:
return BatteryConfig(
subentry_id="bat-1",
name="Battery",
sensor_entity="sensor.battery_power",
max_charge_w=5000.0,
max_discharge_w=5000.0,
setpoint_entity="number.battery_sp",
**kwargs,
)


async def test_switch_array_write_failure_does_not_abort_cycle(hass):
"""A service exception while toggling a switch array is contained."""
entry = _make_entry()
entry.add_to_hass(hass)
coordinator = ZeroGridCoordinator(hass, entry)
coordinator.arrays = [_switch_array()]
coordinator._engine._current_setpoints["SwitchArray"] = 0.0 # off

hass.states.async_set("sensor.grid_import", "300")
hass.states.async_set("sensor.grid_export", "0")
hass.states.async_set("switch.solar_sw", "off")

with patch.object(
coordinator._actuators,
"write_setpoint",
new=AsyncMock(side_effect=RuntimeError("boom")),
):
result = await coordinator._async_update_data()

assert result.status == STATUS_ACTIVE
# State not updated on failure: the toggle will be retried next cycle
assert coordinator._engine._current_setpoints["SwitchArray"] == 0.0


async def test_discharge_blocked_by_running_but_uninitialised_load(hass):
"""A load running at restart (unseen by the engine) blocks battery
discharge until it has been reduced — discharge is the last resort."""
entry = _make_entry()
entry.add_to_hass(hass)
coordinator = ZeroGridCoordinator(hass, entry)
coordinator.batteries = [_make_battery()]
coordinator.loads = [
LoadConfig(
name="EV",
load_type=LOAD_TYPE_NUMERIC,
setpoint_entity="number.ev",
priority=1,
setpoint_min=0.0,
setpoint_max=16.0,
w_per_unit=230.0,
settling_time_s=300, # still settling → not reducible this cycle
)
]
coordinator._engine._load_settling_until["EV"] = 10_000_000.0
# EV is charging at 16 A but the engine has not initialised it yet
hass.states.async_set("number.ev", "16")
hass.states.async_set("sensor.grid_import", "1000")
hass.states.async_set("sensor.grid_export", "0")

with patch.object(coordinator._actuators, "write_numeric_entity", new=AsyncMock()):
result = await coordinator._async_update_data()

assert result.battery_setpoints.get("Battery", 0.0) <= 0.0, (
"Battery must not discharge while a reducible load is still running"
)


async def test_soc_sensor_unavailable_logs_warning_once(hass, caplog):
"""A configured-but-unavailable SoC sensor logs one warning (fail-open)."""
entry = _make_entry()
entry.add_to_hass(hass)
coordinator = ZeroGridCoordinator(hass, entry)
coordinator.batteries = [_make_battery(soc_sensor_entity="sensor.battery_soc")]
# SoC sensor never set → unavailable; grid exporting → charge attempt
hass.states.async_set("sensor.grid_import", "0")
hass.states.async_set("sensor.grid_export", "500")

with patch.object(coordinator._actuators, "write_numeric_entity", new=AsyncMock()):
await coordinator._async_update_data()
await coordinator._async_update_data()

warnings = [
r
for r in caplog.records
if r.levelname == "WARNING" and "SoC sensor" in r.message
]
assert len(warnings) == 1, "SoC-unavailable warning must be logged exactly once"
Loading