Files
goodwe-addon/goodwe_controller/app/control.py
T
adminandClaude Opus 5 6c4565659b
checks / unit (pull_request) Successful in 5m30s
SAFETY-13: the output freeze gets an exit, and only where it needs one
While the loop is frozen the command's magnitude may fall but never rise. At
prev_w == 0 there is no magnitude left to fall, so a charge cannot START. That
refusal is deliberate and it is kept: the inverter reporting power we did not
command means another writer is on the actuator, which is the §1.5 non-goal.
What it did not have was an END - nothing cleared it but the other writer
leaving.

Reproduced on release/1.0 @ c8dcb38, at the live install's shape (max_w 5000,
slew_w 400, target_grid_w -10), with another writer holding the pack at +700 W.
The route is not the obvious one: entering from an export, the slew limit moves
the command off zero before the counter reaches 3. It is entered from the
DEADBAND - a site resting at its target counts saturated cycles while nothing is
wrong - 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.

And prev_w == 0 is not where it stops. The same tuning pins at -800 W against
its own integrator standing at -2388 W, with the same absence of an exit. So the
change is written against the output freeze, not against a special case of it.

THE DECISION IS WHICH FREEZES GET AN EXIT, NOT HOW LONG THE BLOCK LASTS.

  - A divergence a LIMIT could have produced - the inverter doing less than the
    command, in the command's own direction - clears itself, because the command
    is pinned at something the inverter can reach. No exit. release/1.0
    unchanged, and asserted so: a curtailed inverter is never released over 30
    cycles.
  - A divergence a limit could NOT have produced - overshooting the command, or
    the opposite sign - cannot clear itself, because clearing it needs the
    inverter to track OUR command. That one releases.

unasked_power() is that predicate and it is physical, not statistical: a limit
can only make the inverter do less, in the command's own direction, so
everything a limit can produce lies in the interval spanned by 0 and prev_w
widened by saturation_w. At prev_w == 0 it IS the saturation test, which is
exactly why the zero case is the one that strands.

The bound is not a new number. SETTLE_CYCLES = ceil(PLANT_SETTLE_S /
LOOP_TICK_S) = 9, SAFETY-39's two measured constants and the reciprocal of
SLEW_RAIL_FRAC: a divergence still standing after the plant's own settling time
is not this plant catching up. _SAT_COUNT_CAP is written as SETTLE_CYCLES + 1;
its value is unchanged at 10.

RELEASING THE OUTPUT FREEZE DOES NOT UNFREEZE THE LOOP, which is what makes this
bounded. The integrator freeze is untouched, so a released cycle can only move
the command toward an accumulator that stopped accumulating, by at most one slew
step, and never past it - swept over 107 625 released states. On the run above
the command starts on the ninth saturated cycle and settles at -1190 W against a
frozen integrator of -1194 W, not at the 5000 W rail.

Graded against a transcription of release/1.0 over the 3024-case sweep, which
was NOT taught the new rule: 2592 cases bit-identical, 432 releases, and 27 of
those change the command - every one a charge from prev_w == 0 that release/1.0
refused to start.

SAFETY-04's deliberate assertion is kept rather than inverted: its fixture sits
below the bound, so it still asserts the block, and the release is asserted
beside it on the other side of the bound.

Coverage audit re-measured, same mutation form, __pycache__ purged: output
freeze 2 -> 11 (2 reproduced on a pristine c8dcb38 first), the exit 4,
unasked_power 6.

saturation_cycles at 9 or 10 removes the block entirely, because the freeze
cannot engage before its own exit is due. Not refused at startup; asserted in
the tests and written in DOCS.md.

Nothing was measured on two inverters: inverter B does not physically exist
(NOTES.md §1.1b) and sim/plantsim.py:216 INVERTER_B is INVERTER_A copied with
measured=False. The N=2 escalation is stated from the code (actual_w is
site_batt_w, the SUM) and is labelled as such.

22/22 test files pass in goodwe-addon-ci, mounted :ro, bytecode purged first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 13:56:26 +02:00

537 lines
28 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 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".
#
# 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
# ---------------------------------------------------------------------------
# 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.
#
# (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 BELONGS TO (b) AND ONLY TO (b), and this constant is what
# separates them in TIME rather than by guessing. Case (a) is bounded by the
# plant: PLANT_SETTLE_S is how long this plant takes to settle, measured, and
# after that the plant has run out of alibi. A divergence still standing after
# PLANT_SETTLE_S is not this plant catching up.
#
# SETTLE_CYCLES = ceil(PLANT_SETTLE_S / LOOP_TICK_S)
#
# ⚠️ THE SAME TWO MEASURED NUMBERS SAFETY-39 USES, AND DELIBERATELY NOT A NEW
# ONE. It is the reciprocal of SLEW_RAIL_FRAC - the slew limit takes exactly
# this many ticks to traverse the rail, for exactly this reason. A new constant
# here would be a second thing to re-derive when the plant is re-measured, and
# a number that outlives its derivation is the defect SAFETY-39 was about.
#
# ⚠️ AND 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 9 cycles cover MORE than 18 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.
SETTLE_CYCLES = math.ceil(PLANT_SETTLE_S / LOOP_TICK_S) # 9
# The saturation counter's ceiling. It has always been 10 and still is; it is
# written against SETTLE_CYCLES rather than as a literal so that a counter
# capped BELOW the longest bound read from it - which would silently make that
# bound unreachable, i.e. delete the exit condition - cannot happen by editing
# one of the two.
_SAT_COUNT_CAP = SETTLE_CYCLES + 1 # 10
@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
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 the 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 we
never asked for at all?
⚠️ 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. Anything outside that interval was put
there by something other than this loop.
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 every saturated cycle at zero is a
cycle in which the actuator is being driven by someone else.
⚠️ 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. 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,
) -> 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, _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)
# ⚠️ 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
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
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"
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 is
# another writer, which by construction stops our command ever being
# tracked, so it can only end when the other writer leaves. That is the
# one this releases, and only after the divergence has outlasted the
# plant that would otherwise have explained 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"
# --- 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),
i_clamped, clamped)
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