B's power reading shows -95 to -118 W at idle (standby draw), so the |reading| < 100 W refusal band never held a refusing B: never at N=2, and since SAFETY-94's shared rule not at N=1 either (review F1). Each unit now learns its idle reading (app/idle.py): only while control runs, the unit is not refused/stale/tripped and its command has stayed within 50 W for 120 s; first value a 120 s mean, then a 30 min follow; samples beyond 250 W dropped, value clamped to 250 W; saved per unit and restored. The shared refusal evidence, compute()'s N=1 engage and end, the unit refusal ends, the stale-path refusal and SAFETY-42's "parked" test are centred on it. Until learned it is 0 W, the rule as it was. Refusing B caught at N=1 28/28/65 s and N=2 24/24/60 s (1/2/5 s meters), before never; refusing A unchanged. Healthy B with its offset, SAFETY-94's scenarios, 96 runs: 0 stale, 0 refused. Live replay: 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
921 lines
51 KiB
Python
921 lines
51 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,
|
|
gave_nothing: bool | None = None,
|
|
idle_w: float = 0.0,
|
|
) -> 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.
|
|
`gave_nothing` SAFETY-94: the caller's span check - the reading stayed
|
|
under REFUSED_BELOW_W, one way, for SETTLE_CYCLES spanning
|
|
REFUSED_SPAN_S. A refusal engages only with it. None leaves it
|
|
out (compute() alone, as before).
|
|
`idle_w` SAFETY-97: what `actual_w` reads while the batteries give
|
|
nothing (app/idle.py). The refusal's band and its end are
|
|
centred on it. 0 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:
|
|
# SAFETY-94: and only when the reading has given nothing for the
|
|
# whole span, not just now. `sat_count` also counts the cycles a
|
|
# healthy battery spends reversing through its lag (-2000 W towards
|
|
# +800 W): at a 1 s meter its 12 cycles end as the reading passes 0.
|
|
if (refused == 0 and frozen and sat_count >= SETTLE_CYCLES
|
|
and abs(prev_w) > tuning.saturation_w
|
|
and abs(actual_w - idle_w) < REFUSED_BELOW_W
|
|
and gave_nothing is not False):
|
|
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 - idle_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
|