run_control() ran cycle() only when the meter value differed from the previous poll. A meter that repeats a value therefore ran no control cycles at all - while the heartbeat kept re-emitting the last command and the firmware kept putting it on the wire every 2 s. Layer 1 stayed fed and every indicator read healthy, so a command computed from an old reading went on being executed through whatever the load did next. TEL-05 measured it: 70 s at a literally constant 350.0 W, 14 arrivals, zero cycles. cycle_floor_s (default 15 s) is a deadline on the GAP between cycles, reset by every cycle whichever trigger fired - not a second scheduler. On a healthy meter it never elapses, so the cycle rate and the commanded sequence are unchanged; test_loop.py asserts that by driving the real loop twice on one binary (a huge cycle_floor_s reproduces the old behaviour) and diffing the writes. 15 s is bracketed: above the ~11 s worst gap on a merely-unlucky healthy meter (~5 s arrivals, 1 s detection, one dropped poll retried on the next tick), below meter_max_age_s and the firmware's 30 s ingest veto, and equal to stale_input_s. A floor cycle runs the full law. Freshness is already decided upstream - past meter_max_age_s the reading is None, which is a P_SAFETY 0 W claim - and the integrator advances per cycle, not per second, so bounding the gap bounds the accumulation. Not a freeze detector: nothing here decides whether a repeated reading is true. DOCS.md's "accepted deviation" passage is qualified rather than deleted - the window is now bounded at 3 x cycle_floor_s instead of open-ended, and its "nothing accumulates while it is stalled" argument is called out as having covered windup and not staleness. Same correction in control.py's comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
276 lines
13 KiB
Python
276 lines
13 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 still not a unit
|
|
# of time - but it is now a BOUNDED one. run_control() runs a cycle on a
|
|
# changed meter value OR on `cycle_floor_s` elapsing, whichever comes first
|
|
# (SAFETY-12), so three cycles is at most 3 x cycle_floor_s = 45 s at the
|
|
# defaults, and usually ~15 s at the reference P1's ~5 s update rate.
|
|
#
|
|
# ⚠️ THIS COMMENT USED TO CLAIM there was no upper bound and that it did not
|
|
# matter, "because the same condition that stalls the counter stalls the
|
|
# whole loop, so nothing accumulates in the meantime". The first clause was
|
|
# true and the second was the bug: a stalled loop does not stop commanding,
|
|
# it goes on emitting its last command through the heartbeat. Windup was
|
|
# never the hazard; staleness was.
|
|
#
|
|
# The wall-clock window lives in Controller, which has a clock, exactly as
|
|
# this comment always said it should. ponytail: this function stays
|
|
# clockless; the remaining ceiling is that saturation_cycles expresses a
|
|
# bound in seconds rather than an exact number of them.
|
|
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.
|
|
#
|
|
# ⚠️ EXACTLY ZERO IS ITS OWN CASE, and it must be handled explicitly
|
|
# rather than falling into one of the two branches. "May not wind
|
|
# further in the direction it is already pushing" has no referent at
|
|
# zero: nothing is wound, and neither direction is "further". Writing
|
|
# this as `if i_w > 0 ... else ...` silently files zero under
|
|
# rising-only and permanently blocks the first push toward charging -
|
|
# the same deadlock as the shrink-only encoding above, mirrored in sign,
|
|
# and reachable because main.py resets i_w to exactly 0.0 on every stop
|
|
# and every reseed. Measured before the fix: 12 800 of 25 920 frozen
|
|
# ticks at i_w == 0.0 held the integrator, 8 304 of them changing the
|
|
# emitted command, worst case abandoning a 2 kW charge into a 4 kW
|
|
# export.
|
|
#
|
|
# Freezing at zero would also be pointless: the freeze exists to stop
|
|
# accumulation running away, and a first step from zero is bounded by
|
|
# the gain, the output clamp and the slew limit like any other.
|
|
if not frozen or i_w == 0.0:
|
|
i_w = moved
|
|
elif i_w > 0:
|
|
i_w = min(moved, i_w)
|
|
else:
|
|
i_w = 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
|