Turns the reference RS485 controller into something a technician can install at a client site: a typed config form instead of YAML, an ingress UI that names misconfiguration in words, and persistent state that cannot be broken by a timezone. Why an add-on rather than YAML packages or blueprints: - Blueprints cannot create helpers, and the maintenance cycle is a state machine whose phase and completion date must survive restarts. - YAML packages need filesystem access, a configuration.yaml edit and a restart - none of which belong in a client install. - Add-ons authenticate with SUPERVISOR_TOKEN, so there is no long-lived token to generate, store or leak on someone else's machine. - Requires HA OS/Supervised. Container and Core installs cannot run add-ons at all, which is a market decision, not an oversight. The control law and the maintenance machine are pure functions with no Home Assistant imports, and both ship with runnable checks (22 and 22 assertions). Every assertion corresponds to a rule whose absence caused an observed failure on hardware - the saturation duration term, the clamp-before-slew ordering, the deadband, the sign convention. One behaviour deliberately differs from the implementation it replaces: when its inputs go missing this commands 0 W rather than replaying the last setpoint. The reference version kept replaying, which the hardware watchdog cannot catch - from the ESP32's side, Home Assistant is still talking to it. Includes the ESPHome firmware (now parameterised: node name, inverter rating, watchdog timeout) and the optional RS485 e-stop. FIELD-GUIDE.md carries the commissioning gates, all judged on the wire rather than on how Home Assistant looks, plus the written statement a site without an e-stop needs signed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016NckgXecasQb2eSsPYNSW6
163 lines
6.3 KiB
Python
163 lines
6.3 KiB
Python
"""The grid-following control law.
|
|
|
|
Pure functions on purpose: this is the part that moves real power, so it must be
|
|
testable without Home Assistant, without MQTT and without an inverter. See
|
|
test_control.py, and run it before shipping any change to this file.
|
|
|
|
Sign convention, used everywhere in this add-on:
|
|
|
|
grid > 0 = IMPORTING from the grid
|
|
target > 0 = inverter should DISCHARGE
|
|
target < 0 = inverter should CHARGE
|
|
|
|
Every constant here was measured on real hardware, not chosen for elegance.
|
|
The reasoning lives in the field guide under "Why the tuning is what it is";
|
|
the short version is in the comments below. Do not "clean this up" - each rule
|
|
exists because its absence produced a specific, observed failure.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Tuning:
|
|
gain: float = 0.6
|
|
max_w: float = 2000.0
|
|
slew_w: float = 1000.0
|
|
deadband_w: float = 15.0
|
|
step_w: int = 10
|
|
saturation_w: float = 500.0
|
|
saturation_cycles: int = 3
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Decision:
|
|
target_w: float
|
|
sat_count: int
|
|
frozen: bool
|
|
reason: str
|
|
|
|
|
|
def compute(
|
|
prev_w: float,
|
|
grid_w: float,
|
|
actual_w: float,
|
|
tuning: Tuning,
|
|
sat_count: int = 0,
|
|
*,
|
|
charge_only: bool = False,
|
|
charge_floor_w: float = 0.0,
|
|
) -> Decision:
|
|
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
|
|
|
|
`prev_w` what we last commanded
|
|
`grid_w` net grid power, + = importing
|
|
`actual_w` what the inverter reports it is doing, + = discharging
|
|
"""
|
|
reason = "tracking"
|
|
|
|
# --- saturation, WITH the duration term -------------------------------
|
|
# Command and actual diverging means the inverter cannot follow - it is at
|
|
# a limit. Then the magnitude may fall but never rise, which is the
|
|
# anti-windup that the vendor controller lacked: it once commanded
|
|
# -14547 W against an inverter reporting -5250 W and kept climbing.
|
|
#
|
|
# ⚠️ The duration term is not optional. Tested instantaneously, this fires
|
|
# on EVERY large correction, because the plant itself needs 3-6 s to settle
|
|
# while a cycle is ~5 s. Requiring N consecutive saturated cycles is what
|
|
# lets slew be larger than saturation_w.
|
|
saturated_now = abs(prev_w - actual_w) > tuning.saturation_w
|
|
sat_count = min(sat_count + 1, 10) if saturated_now else 0
|
|
frozen = sat_count >= tuning.saturation_cycles
|
|
|
|
# --- deadband ----------------------------------------------------------
|
|
# Inside the meter's own noise, hold. Measured on the reference install
|
|
# while regulating: mean |error| 15.4 W, max 27 W. A 10 W deadband makes
|
|
# ~69 % of cycles act and the command never rests; 15 W leaves a
|
|
# recognisable resting state, which is worth more than it looks - "flat for
|
|
# 70 s" is how a healthy loop is recognised at a glance, and a command
|
|
# frozen where it should not be is how two real bugs were caught.
|
|
if abs(grid_w) < tuning.deadband_w:
|
|
want = prev_w
|
|
reason = "deadband"
|
|
else:
|
|
want = prev_w + tuning.gain * grid_w
|
|
|
|
# --- maintenance charge shaping ---------------------------------------
|
|
# Applied BEFORE clamp and slew so a forced charge is still rate-limited
|
|
# like any other demand.
|
|
if charge_only:
|
|
want = min(want, 0.0)
|
|
reason = "charge-only"
|
|
if charge_floor_w > 0:
|
|
want = min(want, -charge_floor_w)
|
|
reason = "charge-floor"
|
|
|
|
# --- ORDER MATTERS: clamp -> slew -> freeze ----------------------------
|
|
# An early draft applied a floor after the clamp and let demand escape it.
|
|
target = max(-tuning.max_w, min(tuning.max_w, want))
|
|
if target != want:
|
|
reason = "clamped"
|
|
|
|
slewed = max(prev_w - tuning.slew_w, min(prev_w + tuning.slew_w, target))
|
|
if slewed != target:
|
|
reason = "slew-limited"
|
|
target = slewed
|
|
|
|
if frozen:
|
|
# Magnitude may fall, never rise.
|
|
# ⚠️ At prev == 0 this forbids charging while saturated, because
|
|
# max(t, 0) wins. That is deliberate and matches the reference
|
|
# implementation: commanding 0 while the inverter reports >500 W means
|
|
# something else is driving the bus, and that is not the moment to
|
|
# start pushing power the other way.
|
|
target = min(target, prev_w) if prev_w > 0 else max(target, prev_w)
|
|
reason = "saturated-freeze"
|
|
|
|
# --- quantise ----------------------------------------------------------
|
|
# 10 W. The register is 1 W and the inverter reports at 1 W, but its
|
|
# response lands on a coarser ladder (~17.6 W measured at ~900 W, consistent
|
|
# with a fixed DC-side current step). So sub-17 W precision is nominal;
|
|
# 10 W simply avoids a visible staircase on dashboards.
|
|
step = max(1, int(tuning.step_w))
|
|
target = round(target / step) * step
|
|
|
|
return Decision(float(target), sat_count, frozen, reason)
|
|
|
|
|
|
def maintenance_charge_floor(
|
|
charge_w: float,
|
|
peak_forecast_w: float | None,
|
|
peak_cap_w: float,
|
|
) -> float:
|
|
"""How hard a maintenance charge may pull, in W (positive number).
|
|
|
|
⚠️ CAPACITY TARIFF. In Belgium (capaciteitstarief) and similar markets the
|
|
bill carries the month's worst quarter-hour AVERAGE OFFTAKE. A maintenance
|
|
charge is the only thing this system does that is big enough and long
|
|
enough to set that peak, so it is capped by the headroom left under the
|
|
site's cap. Discharge is never capped this way: export is not offtake.
|
|
|
|
`peak_forecast_w is None` means the site has no capacity tariff (or no
|
|
forecast sensor) - then there is nothing to protect and the configured
|
|
rate is used as-is.
|
|
"""
|
|
if peak_forecast_w is None:
|
|
return max(0.0, charge_w)
|
|
headroom = max(0.0, peak_cap_w - peak_forecast_w)
|
|
return max(0.0, min(charge_w, headroom))
|
|
|
|
|
|
def peak_at_risk(peak_forecast_w: float | None, peak_cap_w: float) -> bool:
|
|
"""True when the quarter-hour projection is already over the site's cap.
|
|
|
|
⚠️ MONEY OUTRANKS THE MAINTENANCE SCHEDULE. While charging, the battery is
|
|
clamped out of discharging and therefore cannot shave a peak - and one oven
|
|
during a charge phase can cost more than the whole cycle saves. When this
|
|
is True the charge-only clamp is dropped and normal grid-following resumes;
|
|
the charge picks up again afterwards.
|
|
"""
|
|
if peak_forecast_w is None:
|
|
return False
|
|
return peak_forecast_w > peak_cap_w
|