checks / unit (pull_request) Successful in 5m30s
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 pristinec8dcb38first), 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
537 lines
28 KiB
Python
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
|