`want = i_w` after the integrator bound, so at the default limit == max_w the output clamp can never fire and `reason == "clamped"` had become unreachable. Observability only today - nothing gates on the string - but SAFETY-03 exists to alarm on exactly that engagement, so its hook was dead before it was built. The integrator bound now reports "i-clamped", and that is the signal SAFETY-03 must watch: it is the one that fires on a default install. "clamped" stays reachable for a configuration that lets the integrator run above the rail, where both fire and the output clamp - which describes the value actually emitted - is the one reported. Two names because the two events want different alarms: the loop winding, versus a command that came out over the rating. The real fix is the second half. The equivalence sweep compared (target_w, sat_count), which is how a dead reason survived 3024 cases. It now compares (target_w, sat_count, frozen, reason) and it catches this defect: dropping the emit turns it red. Deliberate rename aliased explicitly, so any OTHER reason divergence still fails. Result of adding reason to the tuple: 105 of 3024 cases differ, and every one of them is the i-clamped/clamped rename. Zero value divergences, `frozen` included. Nothing else surfaced. test_control.py: 41 -> 43 checks, all passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
253 lines
12 KiB
Python
253 lines
12 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
|
|
# The integrator's own bound. None means "follow max_w", which is the
|
|
# default and the recommended setting.
|
|
#
|
|
# ⚠️ DO NOT RAISE THIS ABOVE max_w without a measurement to justify it.
|
|
# Every watt of integrator above the rail is a watt of wind that has to be
|
|
# burned off before the command can start moving the other way, i.e. extra
|
|
# cycles of discharge into an already-exporting meter after every
|
|
# saturation event. Measured on the closed-loop sim, 4000 W load dropped to
|
|
# 0: at integrator_max_w == max_w the command is 1000 W two cycles later; at
|
|
# 1.5x max_w it is 1800 W. The output clamp already bounds what reaches the
|
|
# wire, so headroom here buys nothing but unwind latency.
|
|
#
|
|
# It is a separate key because it has to be able to be SMALLER than max_w,
|
|
# which is the only direction that buys anything: it caps unwind latency
|
|
# below what the rail implies. Merging it into max_w would take that away.
|
|
integrator_max_w: float | None = None
|
|
# What the meter should rest at, in W. Negative = a slight export.
|
|
# ⚠️ The deadband is a one-way ratchet: any resting point inside it holds
|
|
# forever, and the meter's IMPORT register counts every positive one with
|
|
# no export to cancel it. Resting at 0 W therefore leaks ~deadband/2 W of
|
|
# billed import all day (15 W deadband ≈ 0.2-0.35 kWh). Biasing the rest
|
|
# point below zero moves that leak into the export register, which is not
|
|
# billed. Cost is ~|bias| W of given-away export; keep it small.
|
|
target_grid_w: float = 0.0
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Decision:
|
|
target_w: float
|
|
sat_count: int
|
|
frozen: bool
|
|
reason: str
|
|
# The integrator AFTER this cycle, before the output clamp, the slew limit
|
|
# and quantisation. Carry it back in as `i_w` next cycle; that is what keeps
|
|
# it a separate quantity from the command.
|
|
i_w: float = 0.0
|
|
|
|
|
|
def compute(
|
|
prev_w: float,
|
|
grid_w: float,
|
|
actual_w: float,
|
|
tuning: Tuning,
|
|
sat_count: int = 0,
|
|
i_w: float | None = None,
|
|
) -> 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
|
|
`i_w` the carried integrator, or None to seed it from `prev_w`
|
|
"""
|
|
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.
|
|
#
|
|
# The spec states this window twice and differently: "> 10 s" (§11.2) and
|
|
# "3 samples" (§10.3). This counts CYCLES, and a cycle is not a unit of
|
|
# time: run_control() calls cycle() only when the meter value CHANGES
|
|
# (`if self.grid != last_grid`), so three cycles is three distinct meter
|
|
# readings and nothing more. At the reference P1's ~5 s update rate that is
|
|
# usually ~15 s, but there is no upper bound on it - a meter that repeats a
|
|
# value stalls the counter.
|
|
#
|
|
# That is a detection-latency limit, not a windup hazard: the same
|
|
# condition that stalls the counter stalls the whole loop, so nothing
|
|
# accumulates in the meantime either. If a wall-clock window is ever
|
|
# required, it belongs in Controller (which has a clock) and not here.
|
|
# ponytail: this function is worth keeping clockless; the ceiling is that
|
|
# saturation_cycles cannot express a guaranteed number of seconds.
|
|
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.
|
|
error = grid_w - tuning.target_grid_w
|
|
|
|
# --- the integrator ----------------------------------------------------
|
|
# This loop is in velocity form: the accumulator IS the commanded power, so
|
|
# "the integrator" and "the output" were one variable and could not be
|
|
# bounded apart. `i_w` is that accumulator made explicit; main.py carries it
|
|
# between cycles, which is what turns the two clamps into two limits.
|
|
#
|
|
# Passing i_w=None re-seeds it from the last command every cycle. With
|
|
# integrator_max_w following max_w that reduces this function to the exact
|
|
# velocity form it replaced, frozen branch included - asserted by an
|
|
# exhaustive comparison against a transcription of the old law in
|
|
# test_control.py, not by inspection. Break either the gate or the bound
|
|
# below and that test is what tells you the equivalence went with it.
|
|
if i_w is None:
|
|
i_w = float(prev_w)
|
|
limit = tuning.max_w if tuning.integrator_max_w is None else tuning.integrator_max_w
|
|
|
|
if abs(error) < tuning.deadband_w:
|
|
reason = "deadband"
|
|
else:
|
|
moved = i_w + tuning.gain * error
|
|
# ⚠️ Freeze means "may not wind FURTHER in the direction it is already
|
|
# pushing". It may fall, cross zero, or reverse outright.
|
|
#
|
|
# It must NOT be encoded as "only corrections that shrink |i_w|": that
|
|
# is unsatisfiable for BOTH signs of error whenever the correction is
|
|
# larger than twice the integrator, i.e. every time the integrator is
|
|
# near zero. The loop then sits at its last value forever, because what
|
|
# clears the freeze is the inverter tracking again and not-tracking is
|
|
# the definition of saturation. Measured on that encoding: 0 W held
|
|
# indefinitely into a 2 kW import, where this form recovers next cycle.
|
|
#
|
|
# This is the same asymmetric rule the output freeze uses below, which
|
|
# has been in service on real hardware. It is applied here as well
|
|
# because the requirement is that the INTEGRATOR stop accumulating, not
|
|
# only the command.
|
|
if not frozen:
|
|
i_w = moved
|
|
else:
|
|
i_w = min(moved, i_w) if i_w > 0 else max(moved, i_w)
|
|
|
|
# ⚠️ Applied EVERY cycle, frozen or not: the freeze is conditional, this
|
|
# bound is not. It is what makes the worst-case unwind time finite and
|
|
# knowable instead of a function of how long the error happened to stand.
|
|
bounded = max(-limit, min(limit, i_w))
|
|
if bounded != i_w:
|
|
# ⚠️ SAFETY-03 (alarm whenever the loop winds into a rail) must watch
|
|
# for THIS, not for "clamped" below. At the default limit == max_w the
|
|
# integrator bound is reached first and the command derived from it can
|
|
# then never exceed max_w, so "clamped" is unreachable on a default
|
|
# install - it survives only for a configuration that deliberately lets
|
|
# the integrator run above the rail. Two reasons rather than one
|
|
# because the two events want different alarms: "i-clamped" is the loop
|
|
# winding, "clamped" is a command that came out over the rating anyway.
|
|
reason = "i-clamped"
|
|
i_w = bounded
|
|
want = i_w
|
|
|
|
# ⚠️ Maintenance shaping (charge-only, cheap-window floor) used to live
|
|
# here. It now belongs to arbiter.py as limit claims, so that precedence
|
|
# between strategies is decided in ONE place. This function is again what it
|
|
# should be: a controller that knows only about tracking the meter.
|
|
|
|
# --- 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, float(i_w))
|
|
|
|
|
|
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
|