From 4995b1ea877a6b9f68f8504bc44f34811472b6fd Mon Sep 17 00:00:00 2001 From: bvweerd Date: Fri, 3 Jul 2026 17:36:59 +0000 Subject: [PATCH] fix: debounce grid sensor dropouts and make the failsafe direction configurable A single 5 s sensor dropout (e.g. an MQTT reconnect) immediately reset the PID and slammed all PV limits to maximum. For zero-export installations, failing to maximum is exactly the wrong direction. - Hold the current actuator state (integrator frozen) for up to 3 consecutive unavailable grid reads before entering failsafe; the repair issue is only raised when failsafe actually engages. - New "Failsafe behaviour" option: maximize (default, unchanged behaviour, for self-consumption setups) or curtail (PV to minimum, for zero-export requirements). Applies both to grid-sensor outages and to the controller being disabled. - Reset the EWM filter on a full outage so the first cycles after recovery are not biased by stale pre-outage state. Rebased onto dev after the battery-layer (#5) and calibration-guard (#3) merges. https://claude.ai/code/session_01RUWpwxbGsgR3PoLHLq4Djz --- .../zero_grid_controller/actuator_manager.py | 22 ++- .../zero_grid_controller/config_flow.py | 15 ++ .../zero_grid_controller/const.py | 8 + .../zero_grid_controller/control_engine.py | 13 +- .../zero_grid_controller/coordinator.py | 36 +++- .../zero_grid_controller/strings.json | 18 +- .../zero_grid_controller/translations/en.json | 18 +- .../zero_grid_controller/translations/nl.json | 18 +- tests/test_actuator_manager.py | 2 +- tests/test_grid_failsafe.py | 167 ++++++++++++++++++ 10 files changed, 293 insertions(+), 24 deletions(-) create mode 100644 tests/test_grid_failsafe.py diff --git a/custom_components/zero_grid_controller/actuator_manager.py b/custom_components/zero_grid_controller/actuator_manager.py index 309fab7..87d9b23 100644 --- a/custom_components/zero_grid_controller/actuator_manager.py +++ b/custom_components/zero_grid_controller/actuator_manager.py @@ -10,7 +10,7 @@ from .array import ArrayConfig from .battery import BatteryConfig -from .const import OUTPUT_TYPE_SWITCH +from .const import DEFAULT_FAILSAFE_MODE, FAILSAFE_MODE_CURTAIL, OUTPUT_TYPE_SWITCH _LOGGER = logging.getLogger(__name__) @@ -79,16 +79,28 @@ async def enter_safe_state( arrays: list[ArrayConfig], batteries: list[BatteryConfig], current_setpoints: dict[str, float], + failsafe_mode: str = DEFAULT_FAILSAFE_MODE, ) -> None: - """Move all actuators to a neutral fail-safe state.""" + """Move all actuators to a neutral fail-safe state. + + *failsafe_mode* selects the PV direction: "maximize" (default, for + self-consumption setups) or "curtail" (for zero-export requirements). + """ for array in arrays: if array.is_switch: continue # switches are safe at their current state + target = ( + array.setpoint_min + if failsafe_mode == FAILSAFE_MODE_CURTAIL + else array.setpoint_max + ) try: - await self.write_setpoint(array, array.setpoint_max) - current_setpoints[array.name] = array.setpoint_max + await self.write_setpoint(array, target) + current_setpoints[array.name] = target except Exception as err: - _LOGGER.error("Failed to set %s to max: %s", array.name, err) + _LOGGER.error( + "Failed to set %s to safe state %s: %s", array.name, target, err + ) for battery in batteries: try: diff --git a/custom_components/zero_grid_controller/config_flow.py b/custom_components/zero_grid_controller/config_flow.py index a32521f..e4788b2 100644 --- a/custom_components/zero_grid_controller/config_flow.py +++ b/custom_components/zero_grid_controller/config_flow.py @@ -28,6 +28,7 @@ CONF_CONTROL_MODE, CONF_DEADBAND_W, CONF_EWM_ALPHA, + CONF_FAILSAFE_MODE, CONF_GRID_EXPORT_SENSORS, CONF_GRID_IMPORT_SENSORS, CONF_LOAD_ABSOLUTE_MIN_W, @@ -54,6 +55,7 @@ DEFAULT_CONTROL_MODE, DEFAULT_DEADBAND_W, DEFAULT_EWM_ALPHA, + DEFAULT_FAILSAFE_MODE, DEFAULT_LOAD_DEBOUNCE_S, DEFAULT_LOAD_PRIORITY, DEFAULT_SETPOINT_MAX, @@ -64,6 +66,8 @@ DEFAULT_SWITCH_ON_THRESHOLD_W, DEFAULT_W_PER_UNIT, DOMAIN, + FAILSAFE_MODE_CURTAIL, + FAILSAFE_MODE_MAXIMIZE, LOAD_SUBENTRY_TYPE, LOAD_TYPE_NUMERIC, LOAD_TYPE_SWITCH, @@ -145,6 +149,17 @@ def _main_schema(defaults: dict[str, Any]) -> vol.Schema: } } ), + vol.Optional( + CONF_FAILSAFE_MODE, + default=defaults.get(CONF_FAILSAFE_MODE, DEFAULT_FAILSAFE_MODE), + ): selector( + { + "select": { + "options": [FAILSAFE_MODE_MAXIMIZE, FAILSAFE_MODE_CURTAIL], + "translation_key": "failsafe_mode", + } + } + ), } ) diff --git a/custom_components/zero_grid_controller/const.py b/custom_components/zero_grid_controller/const.py index 8baaf23..bcff155 100644 --- a/custom_components/zero_grid_controller/const.py +++ b/custom_components/zero_grid_controller/const.py @@ -103,6 +103,14 @@ DEFAULT_LOAD_PRIORITY = 50 DEFAULT_LOAD_DEBOUNCE_S = 30 +# Failsafe behaviour when grid sensors are unavailable / controller disabled +CONF_FAILSAFE_MODE = "failsafe_mode" +FAILSAFE_MODE_MAXIMIZE = "maximize" # PV to max (self-consumption setups) +FAILSAFE_MODE_CURTAIL = "curtail" # PV to min (zero-export requirements) +DEFAULT_FAILSAFE_MODE = FAILSAFE_MODE_MAXIMIZE +# Consecutive unavailable grid reads tolerated (state held) before failsafe. +GRID_UNAVAILABLE_TOLERANCE_CYCLES = 3 + # Calibration constants CALIB_MAX_GRID_W = 3000.0 # Abort if |grid_w| exceeds this during calibration CALIB_MAX_TIME_S = 90 # Maximum seconds per array diff --git a/custom_components/zero_grid_controller/control_engine.py b/custom_components/zero_grid_controller/control_engine.py index 6e0e6de..e5adac4 100644 --- a/custom_components/zero_grid_controller/control_engine.py +++ b/custom_components/zero_grid_controller/control_engine.py @@ -16,6 +16,7 @@ BATTERY_WRITE_THRESHOLD_W, CONTROL_DT_MAX, CONTROL_DT_MIN, + DEFAULT_FAILSAFE_MODE, PV_RECOVERY_STEP_W, PV_RECOVERY_TRACKING_TOLERANCE_W, STATUS_ACTIVE, @@ -82,6 +83,7 @@ def __init__( ewm_alpha: float, deadband_w: float, mode: ControllerMode = ControllerMode.ZERO_GRID, + failsafe_mode: str = DEFAULT_FAILSAFE_MODE, ) -> None: self._hass = hass self._pid = pid @@ -89,6 +91,7 @@ def __init__( self._ewm_alpha = ewm_alpha self._deadband_w = deadband_w self._mode = mode + self._failsafe_mode = failsafe_mode self._filtered_w: float | None = None self._filter_sample_count: int = 0 @@ -136,12 +139,14 @@ def update_params( ewm_alpha: float, deadband_w: float, mode: ControllerMode = ControllerMode.ZERO_GRID, + failsafe_mode: str = DEFAULT_FAILSAFE_MODE, ) -> None: """Update PID and filter parameters, preserving all state dicts.""" self._pid = pid self._ewm_alpha = ewm_alpha self._deadband_w = deadband_w self._mode = mode + self._failsafe_mode = failsafe_mode def restore_filter_state( self, @@ -204,8 +209,12 @@ async def run_cycle( # 1. Grid unavailability → safe state if grid_raw is None: self._pid.reset() + # Restart the EWM warm-up on recovery so stale filter state does + # not bias the first cycles after an outage. + self._filtered_w = None + self._filter_sample_count = 0 await self._actuators.enter_safe_state( - arrays, batteries, self._current_setpoints + arrays, batteries, self._current_setpoints, self._failsafe_mode ) await self._enter_load_safe_state(loads) return ControlCycleResult( @@ -248,7 +257,7 @@ async def run_cycle( if not enabled: self._pid.reset() await self._actuators.enter_safe_state( - arrays, batteries, self._current_setpoints + arrays, batteries, self._current_setpoints, self._failsafe_mode ) await self._enter_load_safe_state(loads) return ControlCycleResult( diff --git a/custom_components/zero_grid_controller/coordinator.py b/custom_components/zero_grid_controller/coordinator.py index 4f9af2f..77ed64e 100644 --- a/custom_components/zero_grid_controller/coordinator.py +++ b/custom_components/zero_grid_controller/coordinator.py @@ -30,6 +30,7 @@ CONF_DEADBAND_W, CONF_DERIVED_MAX_POWER_W, CONF_EWM_ALPHA, + CONF_FAILSAFE_MODE, CONF_GRID_EXPORT_SENSORS, CONF_GRID_IMPORT_SENSORS, CONF_KD, @@ -45,10 +46,12 @@ DEFAULT_CONTROL_MODE, DEFAULT_DEADBAND_W, DEFAULT_EWM_ALPHA, + DEFAULT_FAILSAFE_MODE, DEFAULT_KD, DEFAULT_KI, DEFAULT_OUTPUT_MAX_W, DOMAIN, + GRID_UNAVAILABLE_TOLERANCE_CYCLES, LOAD_SUBENTRY_TYPE, ControllerMode, ) @@ -93,6 +96,7 @@ def __init__( self._actuators = ActuatorManager(hass) self._calibrator: ArrayCalibrator | None = None self._grid_sensor_unavailable: bool = False + self._grid_unavail_count: int = 0 # Set by sensor.py after entity registration self.calibration_progress_sensor: ZGCCalibrationProgressSensor | None = None @@ -182,6 +186,7 @@ def _init_from_entry(self, entry: ConfigEntry) -> None: ) self._mode = ControllerMode.ZERO_GRID + self._failsafe_mode: str = data.get(CONF_FAILSAFE_MODE, DEFAULT_FAILSAFE_MODE) self._import_sensors: list[str] = data.get(CONF_GRID_IMPORT_SENSORS, []) self._export_sensors: list[str] = data.get(CONF_GRID_EXPORT_SENSORS, []) @@ -194,11 +199,16 @@ def _init_from_entry(self, entry: ConfigEntry) -> None: ewm_alpha=self._ewm_alpha, deadband_w=self._deadband_w, mode=self._mode, + failsafe_mode=self._failsafe_mode, ) else: # Reload — update params, preserve all setpoint and filter state self._engine.update_params( - new_pid, self._ewm_alpha, self._deadband_w, self._mode + new_pid, + self._ewm_alpha, + self._deadband_w, + self._mode, + failsafe_mode=self._failsafe_mode, ) # ------------------------------------------------------------------ @@ -276,13 +286,30 @@ async def _async_update_data(self) -> ZGCResult: try: grid_raw = await self._read_grid() if grid_raw is None: + self._grid_unavail_count += 1 + if ( + self._grid_unavail_count < GRID_UNAVAILABLE_TOLERANCE_CYCLES + and self.data is not None + ): + # Transient dropout (e.g. an MQTT reconnect): hold all + # actuators and freeze the integrator instead of slamming + # PV limits around on a single bad poll. + _LOGGER.debug( + "Grid sensor(s) unavailable (%d/%d), holding state", + self._grid_unavail_count, + GRID_UNAVAILABLE_TOLERANCE_CYCLES, + ) + self._engine.pid.freeze_integrator() + return self.data if not self._grid_sensor_unavailable: self._grid_sensor_unavailable = True raise_grid_sensor_unavailable(self.hass) _LOGGER.warning("Grid sensor(s) unavailable, entering safe state") - elif self._grid_sensor_unavailable: - self._grid_sensor_unavailable = False - dismiss_grid_sensor_unavailable(self.hass) + else: + self._grid_unavail_count = 0 + if self._grid_sensor_unavailable: + self._grid_sensor_unavailable = False + dismiss_grid_sensor_unavailable(self.hass) return await self._engine.run_cycle( grid_raw=grid_raw, @@ -325,6 +352,7 @@ def set_mode(self, mode: ControllerMode) -> None: self._ewm_alpha, self._deadband_w, mode, + failsafe_mode=self._failsafe_mode, ) def reset_pid(self) -> None: diff --git a/custom_components/zero_grid_controller/strings.json b/custom_components/zero_grid_controller/strings.json index f5f7317..e7c8a83 100644 --- a/custom_components/zero_grid_controller/strings.json +++ b/custom_components/zero_grid_controller/strings.json @@ -11,13 +11,15 @@ "deadband_w": "Ignore zone (W)", "ewm_alpha": "Signal smoothing (0 = heavy, 1 = none)", "aggressiveness": "Response speed", - "control_mode": "Control mode" + "control_mode": "Control mode", + "failsafe_mode": "Failsafe behaviour" }, "data_description": { "deadband_w": "Grid swings within ±this value are ignored. Prevents constant micro-adjustments when already close to zero. Start with 20 W.", "ewm_alpha": "How quickly the controller reacts to grid changes. Lower = smoother but slower (0.1), higher = faster but jumpier (0.5). Default 0.3 suits most systems.", "aggressiveness": "How fast the controller responds after calibration. Cautious = stable, slow. Normal = balanced. Fast = quick, may overshoot.", - "control_mode": "zero_grid: prevent both import and export. zero_import: allow export, prevent import. zero_export: allow import, prevent export. maximize_export: PV max, loads off. maximize_import: PV off, loads max." + "control_mode": "zero_grid: prevent both import and export. zero_import: allow export, prevent import. zero_export: allow import, prevent export. maximize_export: PV max, loads off. maximize_import: PV off, loads max.", + "failsafe_mode": "What the controller does with PV limits when grid sensors are unavailable or the controller is disabled. Maximize = PV to full output (self-consumption setups). Curtail = PV to minimum (zero-export requirements)." } } }, @@ -229,13 +231,15 @@ "deadband_w": "Ignore zone (W)", "ewm_alpha": "Signal smoothing (0 = heavy, 1 = none)", "aggressiveness": "Response speed", - "control_mode": "Control mode" + "control_mode": "Control mode", + "failsafe_mode": "Failsafe behaviour" }, "data_description": { "deadband_w": "Grid swings within ±this value are ignored. Prevents constant micro-adjustments when already close to zero. Start with 20 W.", "ewm_alpha": "How quickly the controller reacts to grid changes. Lower = smoother but slower (0.1), higher = faster but jumpier (0.5). Default 0.3 suits most systems.", "aggressiveness": "How fast the controller responds after calibration. Cautious = stable, slow. Normal = balanced. Fast = quick, may overshoot.", - "control_mode": "zero_grid: prevent both import and export. zero_import: allow export, prevent import. zero_export: allow import, prevent export. maximize_export: PV max, loads off. maximize_import: PV off, loads max." + "control_mode": "zero_grid: prevent both import and export. zero_import: allow export, prevent import. zero_export: allow import, prevent export. maximize_export: PV max, loads off. maximize_import: PV off, loads max.", + "failsafe_mode": "What the controller does with PV limits when grid sensors are unavailable or the controller is disabled. Maximize = PV to full output (self-consumption setups). Curtail = PV to minimum (zero-export requirements)." } } } @@ -350,6 +354,12 @@ "maximize_export": "Maximize export — PV max, loads off", "maximize_import": "Maximize import — PV off, loads max" } + }, + "failsafe_mode": { + "options": { + "maximize": "Maximize — PV to full output (self-consumption)", + "curtail": "Curtail — PV to minimum (zero-export requirement)" + } } }, "issues": { diff --git a/custom_components/zero_grid_controller/translations/en.json b/custom_components/zero_grid_controller/translations/en.json index b2b85bf..fe686ff 100644 --- a/custom_components/zero_grid_controller/translations/en.json +++ b/custom_components/zero_grid_controller/translations/en.json @@ -11,13 +11,15 @@ "deadband_w": "Deadband (W)", "ewm_alpha": "EWM filter alpha", "aggressiveness": "Control aggressiveness", - "control_mode": "Control mode" + "control_mode": "Control mode", + "failsafe_mode": "Failsafe behaviour" }, "data_description": { "deadband_w": "Grid error below this value is ignored. Prevents unnecessary setpoint changes when already close to zero.", "ewm_alpha": "Smoothing factor for the grid signal (0.05 = heavy smoothing, 1.0 = no filter).", "aggressiveness": "Controls how aggressively PID gains are set after calibration.", - "control_mode": "zero_grid: prevent both import and export. zero_import: allow export, prevent import. zero_export: allow import, prevent export. maximize_export: PV max, loads off. maximize_import: PV off, loads max." + "control_mode": "zero_grid: prevent both import and export. zero_import: allow export, prevent import. zero_export: allow import, prevent export. maximize_export: PV max, loads off. maximize_import: PV off, loads max.", + "failsafe_mode": "What the controller does with PV limits when grid sensors are unavailable or the controller is disabled. Maximize = PV to full output (self-consumption setups). Curtail = PV to minimum (zero-export requirements)." } } }, @@ -228,13 +230,15 @@ "deadband_w": "Deadband (W)", "ewm_alpha": "EWM filter alpha", "aggressiveness": "Control aggressiveness", - "control_mode": "Control mode" + "control_mode": "Control mode", + "failsafe_mode": "Failsafe behaviour" }, "data_description": { "deadband_w": "Grid error below this value is ignored. Prevents unnecessary setpoint changes when already close to zero.", "ewm_alpha": "Smoothing factor for the grid signal (0.05 = heavy smoothing, 1.0 = no filter).", "aggressiveness": "Controls how aggressively PID gains are set after calibration.", - "control_mode": "zero_grid: prevent both import and export. zero_import: allow export, prevent import. zero_export: allow import, prevent export. maximize_export: PV max, loads off. maximize_import: PV off, loads max." + "control_mode": "zero_grid: prevent both import and export. zero_import: allow export, prevent import. zero_export: allow import, prevent export. maximize_export: PV max, loads off. maximize_import: PV off, loads max.", + "failsafe_mode": "What the controller does with PV limits when grid sensors are unavailable or the controller is disabled. Maximize = PV to full output (self-consumption setups). Curtail = PV to minimum (zero-export requirements)." } } } @@ -340,6 +344,12 @@ "maximize_export": "Maximize export — PV max, loads off", "maximize_import": "Maximize import — PV off, loads max" } + }, + "failsafe_mode": { + "options": { + "maximize": "Maximize — PV to full output (self-consumption)", + "curtail": "Curtail — PV to minimum (zero-export requirement)" + } } }, "issues": { diff --git a/custom_components/zero_grid_controller/translations/nl.json b/custom_components/zero_grid_controller/translations/nl.json index 7ca7d8a..1675ccf 100644 --- a/custom_components/zero_grid_controller/translations/nl.json +++ b/custom_components/zero_grid_controller/translations/nl.json @@ -11,13 +11,15 @@ "deadband_w": "Negeringszone (W)", "ewm_alpha": "Signaalfiltering (0 = zwaar, 1 = geen)", "aggressiveness": "Reactiesnelheid", - "control_mode": "Regelmodus" + "control_mode": "Regelmodus", + "failsafe_mode": "Failsafe-gedrag" }, "data_description": { "deadband_w": "Gridschommelingen binnen ±deze waarde worden genegeerd. Voorkomt constante micro-aanpassingen als het al bijna nul is. Begin met 20 W.", "ewm_alpha": "Hoe snel de controller reageert op gridwijzigingen. Lager = vloeiender maar trager (0,1), hoger = sneller maar grilliger (0,5). Standaard 0,3 is geschikt voor de meeste systemen.", "aggressiveness": "Hoe snel de controller reageert na kalibratie. Voorzichtig = stabiel, traag. Normaal = uitgebalanceerd. Snel = vlug, kan overshoot geven.", - "control_mode": "zero_grid: voorkomt zowel import als export. zero_import: staat export toe, voorkomt import. zero_export: staat import toe, voorkomt export. maximize_export: PV maximaal, belastingen uit. maximize_import: PV uit, belastingen maximaal." + "control_mode": "zero_grid: voorkomt zowel import als export. zero_import: staat export toe, voorkomt import. zero_export: staat import toe, voorkomt export. maximize_export: PV maximaal, belastingen uit. maximize_import: PV uit, belastingen maximaal.", + "failsafe_mode": "Wat de regelaar met de PV-limieten doet als de netmeting niet beschikbaar is of de regelaar is uitgeschakeld. Maximaliseren = PV naar vol vermogen (eigen verbruik). Begrenzen = PV naar minimum (nul-export-verplichting)." } } }, @@ -228,13 +230,15 @@ "deadband_w": "Negeringszone (W)", "ewm_alpha": "Signaalfiltering (0 = zwaar, 1 = geen)", "aggressiveness": "Reactiesnelheid", - "control_mode": "Regelmodus" + "control_mode": "Regelmodus", + "failsafe_mode": "Failsafe-gedrag" }, "data_description": { "deadband_w": "Gridschommelingen binnen ±deze waarde worden genegeerd. Voorkomt constante micro-aanpassingen als het al bijna nul is. Begin met 20 W.", "ewm_alpha": "Hoe snel de controller reageert op gridwijzigingen. Lager = vloeiender maar trager (0,1), hoger = sneller maar grilliger (0,5). Standaard 0,3 is geschikt voor de meeste systemen.", "aggressiveness": "Hoe snel de controller reageert na kalibratie. Voorzichtig = stabiel, traag. Normaal = uitgebalanceerd. Snel = vlug, kan overshoot geven.", - "control_mode": "zero_grid: voorkomt zowel import als export. zero_import: staat export toe, voorkomt import. zero_export: staat import toe, voorkomt export. maximize_export: PV maximaal, belastingen uit. maximize_import: PV uit, belastingen maximaal." + "control_mode": "zero_grid: voorkomt zowel import als export. zero_import: staat export toe, voorkomt import. zero_export: staat import toe, voorkomt export. maximize_export: PV maximaal, belastingen uit. maximize_import: PV uit, belastingen maximaal.", + "failsafe_mode": "Wat de regelaar met de PV-limieten doet als de netmeting niet beschikbaar is of de regelaar is uitgeschakeld. Maximaliseren = PV naar vol vermogen (eigen verbruik). Begrenzen = PV naar minimum (nul-export-verplichting)." } } } @@ -349,6 +353,12 @@ "maximize_export": "Maximale export — PV maximaal, belastingen uit", "maximize_import": "Maximale import — PV uit, belastingen maximaal" } + }, + "failsafe_mode": { + "options": { + "maximize": "Maximaliseren — PV naar vol vermogen (eigen verbruik)", + "curtail": "Begrenzen — PV naar minimum (nul-export-verplichting)" + } } }, "issues": { diff --git a/tests/test_actuator_manager.py b/tests/test_actuator_manager.py index 4b06403..81d7bae 100644 --- a/tests/test_actuator_manager.py +++ b/tests/test_actuator_manager.py @@ -154,7 +154,7 @@ async def test_enter_safe_state_logs_array_write_failure(hass_mock, caplog): await manager.enter_safe_state([array], [], {}) - assert "Failed to set Array1 to max" in caplog.text + assert "Failed to set Array1 to safe state" in caplog.text async def test_enter_safe_state_logs_battery_write_failure(hass_mock, caplog): diff --git a/tests/test_grid_failsafe.py b/tests/test_grid_failsafe.py new file mode 100644 index 0000000..e6afa85 --- /dev/null +++ b/tests/test_grid_failsafe.py @@ -0,0 +1,167 @@ +"""Tests for grid-dropout debouncing and the configurable failsafe mode.""" + +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.const import ( + DOMAIN, + FAILSAFE_MODE_CURTAIL, + GRID_UNAVAILABLE_TOLERANCE_CYCLES, + STATUS_DISABLED, +) +from custom_components.zero_grid_controller.coordinator import ZeroGridCoordinator + + +@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 _numeric_array(name="Solar") -> ArrayConfig: + return ArrayConfig( + name=name, + output_type="percent", + setpoint_entity="number.solar_limit", + w_per_unit=50.0, + calibration_confidence="estimated", + setpoint_min=0.0, + setpoint_max=100.0, + settling_time_s=0, + ) + + +async def test_grid_dropout_held_before_failsafe(hass): + """Short sensor dropouts hold state instead of jumping to failsafe.""" + entry = _make_entry() + entry.add_to_hass(hass) + coordinator = ZeroGridCoordinator(hass, entry) + coordinator.arrays = [_numeric_array()] + coordinator._engine._current_setpoints["Solar"] = 50.0 + + hass.states.async_set("sensor.grid_import", "100") + hass.states.async_set("sensor.grid_export", "0") + + with patch.object( + coordinator._actuators, "write_setpoint", new=AsyncMock() + ) as mock_write: + first = await coordinator._async_update_data() + coordinator.async_set_updated_data(first) + + hass.states.async_set("sensor.grid_import", "unavailable") + mock_write.reset_mock() + + # Cycles within the tolerance window: hold, no failsafe writes + for _ in range(GRID_UNAVAILABLE_TOLERANCE_CYCLES - 1): + held = await coordinator._async_update_data() + assert held is first, "State must be held during the dropout window" + mock_write.assert_not_awaited() + + # Tolerance exceeded → failsafe (default: PV to max) + result = await coordinator._async_update_data() + + assert result.status == STATUS_DISABLED + mock_write.assert_awaited() + assert mock_write.call_args[0][1] == 100.0 # setpoint_max + + +async def test_grid_recovery_resets_dropout_counter(hass): + """A successful read resets the dropout counter (no creeping failsafe).""" + entry = _make_entry() + entry.add_to_hass(hass) + coordinator = ZeroGridCoordinator(hass, entry) + + hass.states.async_set("sensor.grid_import", "100") + hass.states.async_set("sensor.grid_export", "0") + first = await coordinator._async_update_data() + coordinator.async_set_updated_data(first) + + hass.states.async_set("sensor.grid_import", "unavailable") + await coordinator._async_update_data() + assert coordinator._grid_unavail_count == 1 + + hass.states.async_set("sensor.grid_import", "100") + await coordinator._async_update_data() + assert coordinator._grid_unavail_count == 0 + + +async def test_failsafe_curtail_mode_moves_pv_to_min(hass): + """failsafe_mode=curtail sends PV to minimum instead of maximum.""" + entry = _make_entry(failsafe_mode=FAILSAFE_MODE_CURTAIL) + entry.add_to_hass(hass) + coordinator = ZeroGridCoordinator(hass, entry) + coordinator.arrays = [_numeric_array()] + + hass.states.async_set("sensor.grid_import", "unavailable") + hass.states.async_set("sensor.grid_export", "0") + + with patch.object( + coordinator._actuators, "write_setpoint", new=AsyncMock() + ) as mock_write: + result = await coordinator._async_update_data() + + assert result.status == STATUS_DISABLED + mock_write.assert_awaited() + assert mock_write.call_args[0][1] == 0.0 # setpoint_min + + +async def test_disabled_controller_uses_failsafe_mode(hass): + """The configured failsafe mode also applies when the controller is disabled.""" + entry = _make_entry(failsafe_mode=FAILSAFE_MODE_CURTAIL, controller_enabled=False) + entry.add_to_hass(hass) + coordinator = ZeroGridCoordinator(hass, entry) + coordinator.arrays = [_numeric_array()] + + hass.states.async_set("sensor.grid_import", "100") + hass.states.async_set("sensor.grid_export", "0") + + with patch.object( + coordinator._actuators, "write_setpoint", new=AsyncMock() + ) as mock_write: + result = await coordinator._async_update_data() + + assert result.status == STATUS_DISABLED + mock_write.assert_awaited() + assert mock_write.call_args[0][1] == 0.0 # setpoint_min + + +async def test_ewm_filter_reset_after_outage(hass): + """The EWM filter restarts after an outage instead of resuming stale state.""" + entry = _make_entry(ewm_alpha=0.3) + entry.add_to_hass(hass) + coordinator = ZeroGridCoordinator(hass, entry) + + hass.states.async_set("sensor.grid_import", "1000") + hass.states.async_set("sensor.grid_export", "0") + first = await coordinator._async_update_data() + coordinator.async_set_updated_data(first) + assert coordinator._engine.filtered_w == pytest.approx(1000.0) + + # Full outage (beyond the tolerance window) + hass.states.async_set("sensor.grid_import", "unavailable") + for _ in range(GRID_UNAVAILABLE_TOLERANCE_CYCLES): + await coordinator._async_update_data() + assert coordinator._engine.filtered_w is None + + # Recovery: the filter re-seeds from the fresh reading + hass.states.async_set("sensor.grid_import", "300") + result = await coordinator._async_update_data() + assert result.grid_filtered_w == pytest.approx(300.0)