Files
goodwe-addon/goodwe_controller/app/control.py
T
glenn schrooyenandClaude Opus 5.5 4b81387908
checks / unit (pull_request) Successful in 25m35s
infra-pin / fresh (pull_request) Successful in 2s
SAFETY-85: no false 'not keeping up' while maintenance or the peak guard sets the battery
The loop's integrator bound counts only on a cycle the loop decided. A battery
another owner set (maintenance, the peak guard, a safety limit) alarms only
when it gives more than saturation_w less than told for saturation_cycles
cycles. At N>=2 the loop is bounded while maintenance holds a battery, so it
does not wind and control comes back one slew step a cycle. The status row
counts episodes, not cycles. Digests re-pinned where behaviour changed on
purpose; DECISIONS/SAFETY-85-no-false-not-keeping-up.md has the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
2026-10-01 19:05:27 +02:00

907 lines
50 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.
"""
import math
from math import inf
from dataclasses import dataclass
# ---------------------------------------------------------------------------
# SAFETY-39. THE ONE RELATIONSHIP IN THIS FILE THAT IS NOT A NUMBER.
#
# ⚠️ WHAT WENT WRONG, because the shape of it is the reason this is a ratio and
# not a constant. `slew_w: 1000` was derived on 2026-08-22 against a clamp of
# 1500 W (goodwe/NOTES.md, "Simulated 2 kW load step (gain 0.6, clamp 1500)"),
# and its stated justification was that it "puts most of the correction in the
# first cycle WHILE STILL CAPPING HOW FAR THE LOOP CAN JUMP AHEAD OF THE
# INVERTER". At 1500 W that sentence was true: 1000 W/cycle reaches the rail in
# two cycles and the rail itself bounds the excursion.
#
# The rail then moved to 5000 W on the live install and the constant did not.
# 1000 W per 2 s tick is 500 W/s, so the loop traverses its WHOLE OUTPUT RANGE
# in 10 s against a plant NOTES.md §5.10 measured at ~18 s up / ~23 s down for a
# 1.78 kW step. It commands its entire range before it can observe its answer to
# the first step. Measured on the owner's system 2026-09-05 11:43-11:46: a real
# 3.4 kW export, the setpoint ramping 380 -> 5000 in five ticks, the battery at
# -5154 W against a 5000 VA inverter, and the grid pushed from 3.4 kW of EXPORT
# to 2.8 kW of IMPORT - the loop manufactured the error it then chased.
#
# ⚠️ SO THE FIX IS NOT A NEW NUMBER. A number is what went stale. What is
# written here is the RELATIONSHIP the 2026-08-22 sentence was really asserting:
#
# THE LOOP MUST NOT TRAVERSE ITS FULL OUTPUT RANGE FASTER THAN THE PLANT
# TAKES TO SETTLE.
#
# slew_w <= max_w * LOOP_TICK_S / PLANT_SETTLE_S
#
# `max_w` here is the SITE rail (main.py builds `Tuning(max_w=site_max_w)`, the
# roster sum), so the bound moves with the roster and a second inverter cannot
# re-open this by arriving. validate.py rule 10 refuses a configuration that
# breaks it; `slew_w = 0` below means "follow the rail" and is the shipped
# default, exactly as `integrator_max_w = None` already means "follow max_w".
#
# ⚠️ BOTH NUMBERS ARE WRITTEN OUT RATHER THAN THEIR QUOTIENT, AND THAT IS NOT
# TIDINESS. The quotient is only meaningful in the regime the two were measured
# in, and one of them is NOT a property of this code:
#
# PLANT_SETTLE_S is the inverter and the pack. goodwe/NOTES.md §5.10,
# 2026-08-22, a 1.78 kW kettle step driven from HA with a
# plug that meters its own draw: ~18 s from the step to the
# deadband going up, ~23 s coming down. 18 is taken because
# it is the SHORTER of the two and therefore the LOOSER
# bound - this is a refusal boundary, and rules 5 and 6 in
# validate.py refuse the impossible case only, never "close
# to the rail".
#
# ⚠️ AND THAT CHOICE IS LOCAL TO THIS BOUND. §5.10 MEASURED THE STEP IN BOTH
# DIRECTIONS, so "the plant's settling time" is a PAIR and not a number, and
# which half is the safe one depends on which way being wrong hurts. Taking
# 18 for a REFUSAL is right: the looser number refuses less. Taking 18 for a
# question of the form "how long does the plant remain a candidate
# explanation" is WRONG, because the looser number stops believing the plant
# too early - and there the answer is a false accusation. SAFETY-13 asks that
# second question and so reads PLANT_SETTLE_DOWN_S below, deliberately not
# this constant. Anything else added here has to say which of the two it
# means and why.
#
# LOOP_TICK_S is the METER, not this loop. `run_control()` runs a cycle on
# a changed meter value or on `cycle_floor_s`, whichever comes
# first, so the tick is the meter's own update rate. 2.0 s is
# what the live install measured on 2026-09-05 (the setpoint
# stepped +1000 W every 2 s), and it is the fastest meter this
# project has ever run.
#
# ⚠️ AND THIS ONE IS NOT WRONG IN THE SAFE DIRECTION, WHICH IS WHY IT IS SAID
# HERE. A SLOWER meter makes this bound conservative - it refuses some
# configurations that would have been fine, which is visible immediately and
# costs one edit. A meter FASTER than 2 s makes it loose by exactly that ratio,
# and nothing in this add-on would say so. The tick cannot go below 1 s
# (`run_control()`'s `await asyncio.sleep(1)` bounds the loop body), so the
# worst case is a factor of two - but if a faster meter is ever fitted, this
# constant has to be RE-DERIVED and not merely re-read. Compare
# validate.FIRMWARE_INGEST_VETO_S, which carries the same kind of copy and can
# only ever be wrong the other way.
PLANT_SETTLE_S = 18.0
LOOP_TICK_S = 2.0
SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S # 0.111..., i.e. 9 ticks
# The other half of the same measurement: the descent of the same 1.78 kW step,
# ~23 s from a -1784 W export excursion back into the deadband (goodwe/NOTES.md
# §5.10, the same run, the same day, the same plug). It is slower than the ramp
# up, which is the ordinary asymmetry of a pack that is pushed one way and
# coasts the other. Nothing above this line reads it; SAFETY-13 below does.
PLANT_SETTLE_DOWN_S = 23.0
# ---------------------------------------------------------------------------
# SAFETY-13. HOW LONG THE PLANT STAYS A CANDIDATE EXPLANATION.
#
# The output freeze below stops the command growing while the inverter is not
# following. Two completely different things produce that divergence and the
# freeze cannot tell them apart on magnitude alone:
#
# (a) THE PLANT HAS NOT CAUGHT UP YET, or it is at a limit - a cold pack, a
# curtailment, an export cap. Asking for more is pointless, and the
# freeze is right to hold. It also CLEARS ITSELF: the command is pinned
# at something the inverter can do, so the inverter catches up to it and
# `saturated_now` goes false.
#
# ⚠️ AND (a) INCLUDES THE CASE `unasked_power()` BELOW CANNOT SEE AS (a):
# the command has fallen THROUGH ZERO and the pack is still coasting in
# the old direction, so the reading has the sign the command used to have
# and not the one it has now. That is this loop's own power, arriving
# late. It is the reason the time bound is what does the work here and
# the predicate alone is not enough.
#
# (b) SOMETHING ELSE IS DRIVING THE ACTUATOR - the §1.5 non-goal, two writers
# on one actuator. The freeze CANNOT clear itself here, because clearing
# it needs the inverter to track OUR command and the other writer is what
# stops it. Nothing ends this but the other writer going away.
#
# ⚠️ SO THE BOUND IS ONLY EVER A REASON TO STOP BELIEVING (a). It does not
# establish (b) and nothing below claims it does: what it says is that the
# plant has run out of alibi, and (b) is then the only explanation this loop
# has left - not the only one there is. Everything downstream of the release is
# written to be safe when that inference is wrong, because sometimes it is.
#
# Case (a) is bounded by the plant, and the plant was measured:
#
# SETTLE_CYCLES = ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S)
#
# ⚠️ THE **DOWN** NUMBER, AND THE DIRECTION IS THE WHOLE POINT. §5.10 measured
# the same step both ways - 18 s up, 23 s down - and SAFETY-39 takes 18 because
# 18 is the LOOSER bound for a REFUSAL. This is the opposite job. Here the
# question is "how long may the plant still be the explanation", and the
# conservative answer is the LONGER of the two: believe the plant for as long
# as the plant has ever been observed to take. Deriving this from 18 gives 9,
# which is three cycles SHORT of the plant's own measured down-ramp - and being
# short here does not refuse anything, it ACCUSES. Measured, single inverter,
# no second writer anywhere, 1280 closed-loop scenarios: at 9 the exit fires in
# 113 of them and at 12 in 24. The direction of that error is why the pair is
# now two named constants instead of one.
#
# ⚠️ AND IT IS NO LONGER THE RECIPROCAL OF SLEW_RAIL_FRAC. It was, at 9, and
# that coincidence read as a derivation - two different questions that happened
# to share an answer while both were being asked of the same half of a two-
# sided measurement. They are 9 and 12 now. If they ever coincide again it is
# arithmetic, not agreement.
#
# ⚠️ IT INHERITS SAFETY-39's CAVEAT ABOUT LOOP_TICK_S, in the opposite
# direction, which is why it is repeated rather than cross-referenced. A meter
# SLOWER than 2 s makes these 12 cycles cover MORE than 23 s, so the freeze
# holds longer than the plant needs: that costs energy - see the case in
# DOCS.md - and never safety. A meter FASTER than 2 s makes them cover LESS,
# so the freeze can release while the plant is genuinely still moving. That one
# is bounded twice over and by neither this constant nor luck: the INTEGRATOR
# freeze is not released with the output freeze, so the command can only reach
# the integrator's frozen value, and the output clamp bounds that in turn.
#
# ⚠️ AND A PLANT SLOWER THAN THE ONE THAT WAS MEASURED DOES THE SAME THING,
# which no constant derived from a measurement can fix. In the same 1280
# scenarios, holding the bound at 12 and lengthening only the plant's down-ramp
# past the measured 23 s, the exit starts firing again on a single inverter:
# 8/320 at 23 s, 59/320 at 34 s, 268/320 at 50 s. A re-measured plant means a
# re-derived constant here, exactly as for the two above it.
SETTLE_CYCLES = math.ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S) # 12
# The saturation counter's ceiling, one above the longest bound read from it. A
# counter capped BELOW that bound would silently make the bound unreachable -
# i.e. delete the exit condition - so it is written against SETTLE_CYCLES and
# not as a literal, and it moves when SETTLE_CYCLES moves.
#
# ⚠️ IT WAS 10, WHICH IS ALSO config.yaml's MAXIMUM FOR `saturation_cycles`,
# AND THAT COLLISION IS GONE. At a bound of 9 the largest accepted
# `saturation_cycles` (10) sat ABOVE the exit, so a legal configuration could
# push the freeze's engagement past its own release and the no-start block
# stopped existing. At 12 every accepted value 1..10 is strictly below the
# bound, so the freeze always engages before its exit is due and no accepted
# configuration can delete the block. `test_control.py` pins that at the schema
# edge rather than in prose, because it is a property of two files agreeing.
_SAT_COUNT_CAP = SETTLE_CYCLES + 1 # 13
# ---------------------------------------------------------------------------
# SAFETY-48. HOW FAR THE INTEGRATOR MAY STAND AHEAD OF THE COMMAND.
#
# ⚠️ THE MISSING CASE, AND IT IS A CASE AND NOT A CONSTANT. The freeze below
# stops the integrator accumulating, and the comment beside it says what the
# requirement is: "the INTEGRATOR stop accumulating, not only the command". But
# `frozen` is computed from ONE signal - `abs(prev_w - actual_w) >
# saturation_w`, the inverter not tracking - and SLEW-LIMITING NEVER ENTERS IT.
# When the command hits the step ceiling, the integrator goes on winding at
# `gain * error` until it reaches `integrator_max_w`.
#
# The arithmetic, and it is the whole card:
#
# the integrator moves gain * error per cycle
# the command moves at most slew per cycle
# so the integrator outruns the command whenever
#
# |error| > slew / gain
#
# At the live install's shape (gain 0.6, slew_w 400) that is 667 W. The median
# grid error at the onset of a recorded episode is 1114 W. This is why
# SAFETY-39's `slew_w` retune did not fix it: a smaller step makes the
# threshold LOWER, so the case fires more often, not less.
#
# ⚠️ WHAT IS BOUNDED IS THE LEAD, NOT THE INTEGRATOR. Two forms were measured
# over 16.0 days of the live install's own recorded disturbances
# (`tools/replay_recorded_loop.py`, and DECISIONS/SAFETY-48 has the table):
#
# (a) reuse the asymmetric freeze on a carried slew flag - "may not wind
# further in the direction it is already pushing", exactly as saturation
# does. It cuts export 13 % AND RAISES THE BILLED QUANTITY: the worst
# quarter-hour average OFFTAKE goes 3739 -> 3935 W and the mean quarter
# moves +2.7 W, in five of six plant and tuning variants, and on the
# billed quarter it is worse at every plant lag where release/1.0
# produces a peak at all (a tie at lag 0, worse at 2.5/5/7.5/10 s)
# and at every top-N cut of the quarters.
#
# ⚠️ AND THE MECHANISM IS A REDISTRIBUTION, NOT A WEAKER ANSWER. An
# earlier draft of this line said a frozen integrator undershoots by
# (1 - gain) "so a large import is answered too weakly". The aggregate
# does not show that: form A cuts total import by 5.45 kWh, and paired
# tick by tick against release/1.0 it delivers 8.56 kWh LESS offtake
# energy on the ticks release/1.0 was importing. What it moves is the
# TAIL - within quarters that already net to offtake, the worst of them
# gets worse - and the capacity tariff bills the worst quarter and
# nothing else. That is the wrong trade here and the reason this is not
# the fix that ships.
#
# (b) cap how far the integrator may LEAD the command, one slew step. Export
# 39.6 -> 34.1 kWh, wasted throughput 4.7 -> 3.2 kWh, sign reversals
# 2992 -> 2770, worst quarter-hour offtake 3739 -> 3434 W at the fitted
# plant lag (⚠️ AND AT NO SHORTER ONE: at every lag <= 5.0 s release/1.0
# is already at 3434 W and this changes it by zero - DECISIONS/SAFETY-48
# §3a), median recovery 232 -> 199 s. It is what ships.
#
# ⚠️ AND THE CAP IS NOT ZERO. `i_w = slewed` is full back-calculation, and this
# module's own docstring says what that costs: passing `i_w=None` "reduces this
# function to the exact velocity form it replaced". A zero cap makes `i_w`
# identically the command on every cycle, which is that same collapse written
# from the other end - it measures 32.0 kWh, better than (b) by 2 kWh, and it
# throws away the reason the accumulator was split out at all. One step is the
# smallest cap that is not that.
#
# WHAT THE NUMBER MEANS: the command can cover a one-step lead in ONE cycle, so
# the worst-case unwind after a reversal is one cycle instead of
# `integrator_max_w / slew_w` - 12.5 cycles at the live shape, ~60 s at the
# meter's measured 5.0 s tick.
#
# ⚠️ IT IS IN STEPS RATHER THAN IN WATTS FOR SAFETY-39's REASON, WHICH IS THE
# REASON THIS FILE KEEPS RE-LEARNING. A watt figure is a constant that outlives
# the `slew_w` it was derived against; expressed in steps it moves with
# `slew_limit_w()` and therefore with the rail.
SLEW_LEAD_STEPS = 1
# ---------------------------------------------------------------------------
# SAFETY-52. A BATTERY THAT REFUSES IS ASKED FOR 0 W, NOT HELD AT ITS LAST ASK.
#
# At the BMS cut-out the pack delivers ~0 W whatever it is asked, so the
# saturation freeze below holds the command where it stood (the recorded
# 09-06 case: +5000 W for 11.87 h) until the grid error changes sign. The
# freeze's own exit cannot help: a reading of ~0 against a large ask is inside
# the interval a limit can produce. So a refused DIRECTION is held at 0 W,
# carried between cycles like `sat_count`, and left again when the loop wants
# the other direction, when a small probe is answered, or when the caller
# resets it (stop, start, another writer). The record is DECISIONS/SAFETY-52.
#
# PROBE_W is below freshness.MOVE_W (250 W) on purpose: a probe that small can
# never arm the stale-reading gate, so it cannot start the gate's ~70 s cycle.
# test_refusal.py pins the inequality. The cadence and length are the owner's
# 2026-09-27 answer (every 15 minutes) and freshness.BATT_OBLIGED_S twice over
# (17 s is how long a healthy reading needs to show a command).
REFUSED = "battery refuses"
PROBING = "battery probe"
PROBE_W = 200.0
PROBE_ANSWER_W = PROBE_W / 2
# "Gives nothing" is below the probe's own answer, not below `saturation_w`: a
# pack derated to 100-499 W is a limit, and at 500 W it kept ~7 % of its power
# (review of #141). The capture's refusals read -4 W.
REFUSED_BELOW_W = PROBE_ANSWER_W
PROBE_EVERY_S = 900.0
PROBE_S = 34.0
# OPT-11. The loop's reason when the battery modes, not the meter, set the
# command: every battery that may move this way is at its mode's limit.
MODE_BOUND = "mode-bound"
@dataclass(frozen=True)
class Tuning:
gain: float = 0.6
max_w: float = 2000.0
# SAFETY-39. How far the command may move in one cycle. ⚠️ 0 MEANS "FOLLOW
# THE RAIL" - `SLEW_RAIL_FRAC * max_w` - and it is the default and the
# recommended setting, for the reason written at the top of this file: a
# fixed number here is a constant that outlives the rail it was derived
# against, and that is the defect measured on the live system on
# 2026-09-05. Read `slew_limit_w()`, never this field.
#
# It stays settable, because slower than the rail implies is a legitimate
# and sometimes necessary choice - the live install has run an explicit
# 400 W since 2026-09-05 12:00 - and because `slew_w` is an option an
# operator already has. FASTER than the rail implies is what validate.py
# rule 10 refuses.
slew_w: float = 0.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
# SAFETY-03. Did a clamp actually engage this cycle - i.e. did the RAW
# computed value differ from the bounded one?
#
# ⚠️ THESE EXIST BECAUSE `reason` CANNOT ANSWER THAT QUESTION. `reason` is
# one string and every later stage overwrites it: a cycle that clamps the
# integrator and is then slew limited reports "slew-limited", and one that
# clamps while saturated reports "saturated-freeze". Both ARE the winding
# case, and on the string the clamp signal is gone before anything can alarm
# on it. These two are written where the clamp happens and are never
# overwritten, so they mean exactly what they say and nothing else.
#
# `i_clamped` is the one that fires on a default install: the integrator
# bound is reached first, so `clamped` is unreachable unless
# `integrator_max_w` is deliberately set above `max_w`. Alarm on BOTH - see
# the comment at the bound below, and clamp.py.
i_clamped: bool = False
clamped: bool = False
# SAFETY-40. The alarm's numbers, taken at the clamp so they are one tick's:
# the bound the integrator reached, and the command before/after the
# rating clamp. `target_w` is this tick's emitted command.
i_bound_w: float = 0.0
requested_w: float = 0.0
clamped_w: float = 0.0
# SAFETY-52. The refused direction to carry back in (+1 discharge, -1
# charge, 0 none), or None when the caller does not carry it.
refused: int | None = None
# SAFETY-85. The `bound` held the integrator or the command this cycle,
# so the batteries' limits, not the loop, decided it.
held: bool = False
def slew_limit_w(tuning: Tuning) -> float:
"""How far the command may move this cycle, in W. Always positive.
⚠️ THE ONE READER OF `Tuning.slew_w`, and everything else must come through
here. `slew_w = 0` means "follow the rail" and resolves to
`SLEW_RAIL_FRAC * max_w`, which is the whole of SAFETY-39: the limit is a
RATIO to the rail, so raising `max_w` moves it and cannot leave it behind.
A caller that read the field directly would see 0 and apply no limit at all
- the exact opposite of the setting's meaning - which is why this exists as
a function rather than as a comment telling people to remember.
⚠️ AND IT IS NOT CLAMPED TO THE BOUND. An explicit `slew_w` above the rail
fraction is refused at STARTUP by validate.py rule 10, not silently reduced
here. Silently reducing it is the failure this whole module's docstring is
about - an operator's stated number quietly meaning something else - and
validate.py's own docstring calls it case (a).
"""
if tuning.slew_w:
return abs(float(tuning.slew_w))
return SLEW_RAIL_FRAC * abs(float(tuning.max_w))
def unasked_power(prev_w: float, actual_w: float, saturation_w: float) -> bool:
"""True when `actual_w` cannot be explained by THIS cycle's command not being met.
SAFETY-13. The one question the saturation magnitude test cannot answer:
is the inverter FAILING TO REACH what we asked, or is it doing something
the command in front of it does not account for?
⚠️ THE ANSWER IS PHYSICAL, NOT STATISTICAL, WHICH IS WHY IT IS A PREDICATE
AND NOT A THRESHOLD. A limit can only make the inverter do LESS than the
command, in the command's own direction. It cannot make it overshoot the
command, and it cannot make it produce power of the OPPOSITE sign. So the
whole set of readings a limit can produce is the closed interval spanned by
0 and `prev_w`, widened by `saturation_w` for measurement noise, ramp lag
and the inverter's own idle draw.
⚠️ WHAT IT DOES NOT SAY - AND AN EARLIER VERSION OF THIS DOCSTRING DID -
IS THAT ANYTHING OUTSIDE THE INTERVAL WAS PUT THERE BY SOMETHING OTHER THAN
THIS LOOP. THAT IS FALSE AND IT WAS MEASURED FALSE. This predicate has no
memory: `prev_w` is one cycle's command, while the plant carries every
command before it. Drive ONE inverter, with nothing else on the bus, from a
discharge down through zero, and the pack is still coasting in the old
direction after the command has crossed. Measured in the container, one
`compute()` against one plant: eight cycles after a +1990 W command the
command is 0 and the pack still reads +522.3 W, so
`unasked_power(0, 522.3, 500)` is True - and every watt of that 522.3 was
asked for by this loop. In 1280 single-inverter closed-loop scenarios with
no second writer in them at all, the exit built on this predicate fires in
24 of them.
So what it guarantees is exactly the sentence at the top: this cycle's
command does not account for this cycle's reading. What it CANNOT do is
tell "somebody else is writing" from "we are". That gap is the reason
SETTLE_CYCLES is derived from the plant's slower direction, why the caller
checks both, and why the reason string it produces is a place to start
looking rather than a finding.
Worked, at `saturation_w = 500`:
prev +2000, actual +700 -> inside [0, 2000] a limit. False.
prev -5000, actual -700 -> inside [-5000, 0] a limit. False.
prev 0, actual +700 -> outside [-500, 500] unasked. True.
prev -800, actual +700 -> outside [-1300, 500] unasked. True.
⚠️ AT `prev_w == 0` THIS IS EXACTLY `saturated_now`, and that is not a
coincidence to be tidied away - it is the reason the freeze's zero case is
the one the card was written about. With nothing commanded there is no
interval left for a LIMIT to live in, so at zero this test cannot be
answered by "the inverter is not reaching what we asked" at all. It can
still be answered by "the inverter has not finished leaving what we asked
for LAST time", which is the coast-down above and is why zero needs the
time bound as much as any other `prev_w` does.
⚠️ IT IS NOT A DETECTOR AND MUST NOT BE ALARMED ON. A saturated inverter
whose reading is stale, wrong-signed or simply noisy lands outside the
interval too, and so does our own ramp, above. All this predicate decides
is whether the output freeze gets an EXIT; being wrong about it costs at
most a bounded command that the frozen integrator and the output clamp both
still bound. SAFETY-30's `unit_sign_disagree_w` is the thing that alarms on
sign, and it is deliberately a different test with a different threshold.
"""
lo = min(0.0, float(prev_w)) - abs(float(saturation_w))
hi = max(0.0, float(prev_w)) + abs(float(saturation_w))
return not (lo <= float(actual_w) <= hi)
def compute(
prev_w: float,
grid_w: float,
actual_w: float,
tuning: Tuning,
sat_count: int = 0,
i_w: float | None = None,
refused: int | None = None,
probe: bool = False,
bound: tuple | 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`
`refused` SAFETY-52's carried refused direction, or None to leave it out
(the `i_w=None` equivalence path does)
`probe` the caller's clock says a refused direction may try PROBE_W now
`bound` OPT-11: `(lo, hi)`, what the batteries may place this tick with
their modes applied (`allocator.loop_bound()`), or None. See
MODE_BOUND below. None is the law as it was.
"""
reason = "tracking"
# OPT-11: a bound that leaves out 0 W would FORCE a direction, which no
# mode may do. It is dropped rather than obeyed (a software fault, and
# main.py never builds one: every term of `loop_bound()` holds 0).
if bound is not None and not (bound[0] <= 0.0 <= bound[1]):
bound = None
# ...and a side of it that reaches the rail is left to the rail, so that
# SAFETY-03's `i_clamped` still fires when the loop winds into it. With
# every battery on, the bound IS the rail, and the law is exactly as it was.
if bound is not None:
_rail = min(tuning.max_w, tuning.max_w if tuning.integrator_max_w is None
else tuning.integrator_max_w)
bound = (float(bound[0]) if bound[0] > -_rail else -inf,
float(bound[1]) if bound[1] < _rail else inf)
# --- 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, _SAT_COUNT_CAP) 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)
# OPT-11: the integrator does not wind past what the modes let the
# batteries place. Only a side tighter than the rail is left in `bound`
# (above), so this never takes SAFETY-03's `i_clamped` away from the rail.
held_by_bound = False
if bound is not None:
held = max(float(bound[0]), min(float(bound[1]), i_w))
if held != i_w:
i_w = held
reason = MODE_BOUND
held_by_bound = True
# ⚠️ 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))
i_clamped = bounded != i_w
if i_clamped:
# ⚠️ 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
i_bound_w = bounded if i_clamped else 0.0
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))
clamped = target != want
clamped_w = target
if clamped:
reason = "clamped"
slew = slew_limit_w(tuning)
slewed = max(prev_w - slew, min(prev_w + slew, target))
if slewed != target:
reason = "slew-limited"
# --- SAFETY-48: the step ceiling reaches the integrator too ---------
# ⚠️ INSIDE THIS BRANCH AND NOWHERE ELSE - AND THE REASON IS NOT THE
# SATURATION FREEZE. An earlier draft of this comment said that applied
# unconditionally the cap "would also pull the integrator toward a
# command the SATURATION freeze is pinning", and priced that at 118 of
# 256 250 ticks. Both halves were invented, and MUTATION.md habit 22 is
# what fell out of it. The 118 had been measured against a DIFFERENT
# pair - a band applied after quantisation, at the very end of
# `compute()`. On the pair the sentence actually names - this same cap
# moved out of this branch to just after `target = slewed` - the two
# forms are BIT-IDENTICAL on the shipped configuration: 0 of 256 246
# ticks differ over the 16-day recording. They separate only where
# `integrator_max_w` is deliberately set ABOVE `max_w`, the one
# configuration this file says can reach its `clamped` reason; there
# the integrator is meant to run above the rail and an unconditional
# cap would drag it back to the command every cycle and make the option
# inert. THAT is why the cap stays in the branch, and
# `test_telemetry.py` part 4 drives it. (The separable-state COUNT is a
# property of whatever grid was swept - 2050 of 257 400 on this card's,
# 1220 of 246 064 on the review's, 776 of 40 320 on a third - so the
# claim to carry forward is WHERE they separate, not how many.)
#
# ⚠️ AND THE HALF A READER WILL OTHERWISE ASSUME AWAY: THIS CAP DOES
# MOVE A SATURATION-FROZEN INTEGRATOR. It runs BEFORE the freeze block
# below and is not gated on `frozen`. Measured over the same recording
# at the live shape: of 79 160 frozen ticks the cap changed `i_w` on
# 1095 of them, and on 679 of those it moved it FURTHER OUT on the side
# it was already on - putting back magnitude the freeze had just let
# fall, which is the one direction the freeze's own rule ("may fall,
# never rise") exists to forbid. Worst case 2424 W, and the worked tick
# is `prev_w -3400 W, grid +4697 W, actual -4144 W, sat_count 2 -> 3`:
# the freeze leaves the integrator at -176 W, the slew binds, and the
# cap drags it to -2600 W. It never overrides a SAME-CYCLE refusal -
# the freeze clipped `moved` on 75 512 ticks and the slew bound on none
# of them - but it does not leave the frozen path alone either.
#
# ⚠️ AND THAT IS WHERE SAFETY-13's SETTLE VALUES WENT. Gating this cap
# on `not frozen` puts `test_control.py` part 5 back at release/1.0's
# -1190 W exactly; part 4(b)'s -2390 -> -1200 W does NOT come back,
# because that one moved for the other reason - the winding BEFORE the
# freeze is bounded now. The gate is refused anyway: measured the same
# way it leaves a worst observed lead of 4961 W, i.e. no bound at all
# on the quantity this card exists to bound. DECISIONS/SAFETY-48 §5
# carries the rest of the price.
#
# ⚠️ WRITTEN ONE-SIDED, AND IT IS **EQUIVALENT** TO THE TWO-SIDED BAND
# `max(slewed - cap, min(slewed + cap, i_w))` - MEASURED, NOT REASONED.
# An earlier draft of this comment claimed the two-sided spelling was a
# bug that would drag a TRAILING integrator up toward the command. That
# is false and the sweep said so: `target` is monotone in `i_w`
# (`target = clamp(clamp(i_w, +-limit), +-max_w)`) and `slewed` is
# `target` pulled toward `prev_w`, so whenever the slew binds, `i_w`
# lies on the same side of `slewed` as `target` does and the other
# clamp cannot reach. The two spellings agree on **205 920 of 205 920**
# cases across four tunings, every sign of `prev_w`, `grid_w`,
# `actual_w` and `i_w`, and the `i_w=None` path.
#
# It stays one-sided because it SAYS which side the lead is on - the
# side the command wanted to move and could not - and because a reader
# reaching for the band spelling should find the sweep rather than an
# argument. ⚠️ The "descending" case is NOT an exception to the rule: a
# command falling from +2400 W toward an integrator at +100 W has an
# integrator 2300 W ahead of it IN THE DIRECTION OF TRAVEL, and it is
# pulled back to one step beyond the emitted command exactly as a
# rising one is.
#
# ⚠️ `i_w` IS CHANGED AND `target` IS NOT. This cycle's command is
# already decided; what is bounded is what gets CARRIED. That is also
# why the exhaustive `i_w=None` equivalence sweep in test_control.py is
# untouched by this card: it compares `(target_w, sat_count, frozen,
# reason)`, none of which this moves, and the seeded path re-seeds from
# `prev_w` next cycle anyway.
_lead_cap = SLEW_LEAD_STEPS * slew
if target > slewed:
i_w = min(i_w, slewed + _lead_cap)
elif target < slewed:
i_w = max(i_w, slewed - _lead_cap)
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.
#
# --- SAFETY-13: ...AND IT IS NOT THE MOMENT FOREVER EITHER ----------
# Everything above stands. What was missing is an END. Held at
# `prev_w == 0` this is a refusal to START, and nothing in this
# function or above it clears it - not the error growing, not the
# integrator moving, not time. Measured on release/1.0 @ c8dcb38, at
# the live install's shape (max_w 5000, slew_w 400, target_grid_w -10):
# a site resting in the deadband with another writer holding the pack
# at +700 W reaches sat_count 3 with the command at 0, and 0 W is then
# a FIXED POINT - a 2 kW export arriving afterwards leaves the command
# at exactly 0.0 W on every subsequent cycle, with the integrator
# standing at -1194 W the whole time.
#
# ⚠️ AND THE ZERO CASE IS NOT SPECIAL, WHICH IS WHY THE FIX IS NOT
# SPECIAL EITHER. The same run pinned at prev_w == -800 W, against its
# own integrator standing at -2388 W, for as long as it ran.
# `prev_w == 0` is only the instance where the pinned magnitude happens
# to be zero - i.e. where the standing cost is the whole of what the
# battery could have done.
# A rule written for the zero case alone would have left the other
# instances exactly as unbounded as it found them.
#
# ⚠️ WHAT DECIDES IS NOT HOW LONG, IT IS WHETHER THE FREEZE CAN EVER
# END ON ITS OWN. See SETTLE_CYCLES and `unasked_power()` at the top:
# a divergence a LIMIT could produce clears itself once the inverter
# catches up to the pinned command, so it needs no exit and gets none -
# unbounded, exactly as shipped. A divergence a limit CANNOT produce
# does not clear itself by being waited on, so it gets one.
#
# ⚠️ AND THAT IS NOT THE SAME SENTENCE AS "THE OTHER WRITER". An
# earlier version of this comment said the two conditions below fire
# only in case (b); they do not, and the sweep says so. Our own plant
# coasting through a command that has just crossed zero satisfies
# `unasked_power()` too, because that predicate compares against ONE
# cycle's command and the pack remembers more than one - measured, a
# single inverter with nothing else on the bus reaching this branch in
# 24 of 1280 closed-loop scenarios. `SETTLE_CYCLES` is what keeps that
# rare rather than routine (113 of the same 1280 at the old bound of
# 9), and it does it by believing the plant for as long as the plant
# has ever been measured to take. It is a bound on how long we wait,
# not a proof about what we found.
#
# ⚠️ SO THE RELEASE IS WRITTEN TO BE SURVIVABLE WHEN IT IS WRONG, and
# that is the paragraph below, not this one: what a false release costs
# is one slew step per cycle toward a frozen integrator. If it ever has
# to cost less than that, the thing to change is what happens after the
# release, not the confidence of the sentence before it.
#
# ⚠️ RELEASING IS NOT UNFREEZING. The INTEGRATOR freeze above is not
# lifted, and that is what makes this bounded rather than merely
# different: the command is free to move, but only as far as an
# integrator that stopped accumulating cycles ago, through the same
# slew limit as any other cycle. It buys back the standing error, not
# the rail.
#
# `reason` is authoritative here in a way it is not elsewhere in this
# function: nothing after this line writes it.
if unasked_power(prev_w, actual_w, tuning.saturation_w) \
and sat_count >= SETTLE_CYCLES:
reason = "freeze-released"
else:
target = min(target, prev_w) if prev_w > 0 else max(target, prev_w)
reason = "saturated-freeze"
# --- SAFETY-52: a refused direction is held at 0 W ----------------------
# Engages only where the freeze has held for SETTLE_CYCLES and the unit
# delivers ~nothing: a reading below REFUSED_BELOW_W against an ask beyond
# `saturation_w` is a refusal, not a slow plant. The drop to 0 skips the slew
# limit because it only removes an ask the battery is not meeting.
if refused is not None:
if (refused == 0 and frozen and sat_count >= SETTLE_CYCLES
and abs(prev_w) > tuning.saturation_w
and abs(actual_w) < REFUSED_BELOW_W):
refused = 1 if prev_w > 0 else -1
if refused and refused * i_w < 0:
refused = 0 # the loop wants the other way
elif refused and refused * actual_w >= PROBE_ANSWER_W:
refused = 0 # it delivers: a probe answered,
# or a slow reading arriving
elif refused:
wants = refused * i_w > 0
i_w = 0.0
target = refused * PROBE_W if probe and wants else 0.0
reason = PROBING if probe and wants else REFUSED
# --- OPT-11: the command stays inside what the modes allow -------------
# Applied after the slew limit and the freeze, so a mode that closes drops
# the ask at once rather than one slew step per cycle. That moves no
# battery further than the allocator would: it places at most this bound
# when the loop decides. What it removes is the unmet part, which would
# otherwise hold `prev_w` away from `actual_w` and trip the saturation
# freeze on watts no battery was allowed to give.
if bound is not None:
inside = max(float(bound[0]), min(float(bound[1]), target))
if inside != target:
target = inside
reason = MODE_BOUND
held_by_bound = True
# SAFETY-48's lead cap above may pull the integrator back toward the
# slewed command; what is CARRIED stays inside the bound too.
i_w = max(float(bound[0]), min(float(bound[1]), i_w))
# --- 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
if bound is not None:
# OPT-11: rounding must not step outside the bound. The bound holds
# 0 W, so one step toward 0 is always inside it.
if target > bound[1]:
target -= step
elif target < bound[0]:
target += step
return Decision(float(target), sat_count, frozen, reason, float(i_w),
i_clamped, clamped, float(i_bound_w), float(want),
float(clamped_w), refused, held_by_bound)
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