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
22 changes: 17 additions & 5 deletions custom_components/zero_grid_controller/actuator_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions custom_components/zero_grid_controller/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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",
}
}
),
}
)

Expand Down
8 changes: 8 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,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
Expand Down
13 changes: 11 additions & 2 deletions custom_components/zero_grid_controller/control_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -82,13 +83,15 @@ 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
self._actuators = actuators
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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
36 changes: 32 additions & 4 deletions custom_components/zero_grid_controller/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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, [])

Expand All @@ -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,
)

# ------------------------------------------------------------------
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
18 changes: 14 additions & 4 deletions custom_components/zero_grid_controller/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)."
}
}
},
Expand Down Expand Up @@ -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)."
}
}
}
Expand Down Expand Up @@ -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": {
Expand Down
18 changes: 14 additions & 4 deletions custom_components/zero_grid_controller/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)."
}
}
},
Expand Down Expand Up @@ -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)."
}
}
}
Expand Down Expand Up @@ -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": {
Expand Down
Loading
Loading