Files
goodwe-addon/goodwe_controller/app/peakguard.py
T
glenn schrooyenandClaude Opus 5 ab6fbe2f9e
checks / unit (pull_request) Successful in 3m43s
SAFETY-25: record the owner's decision - option a, with alloc-01 as the revisit trigger
The product owner answered SAFETY-25's §1 on 2026-09-02: OPTION A. The BMS
cut-out is the DELIBERATE and only backstop for demand-driven discharge on the
grid-following loop, and Option B - a loop-side reserve floor - is DEFERRED
rather than rejected.

Option A is the shipped behaviour, so this is a recording, not a change. `app/`
is touched in exactly two places and both are comments that claimed the question
was still open (freeze.py's finding, peakguard.py's point 2). No setpoint, no
claim, no option, no default moved. The 17-suite run is green on the same
numbers as before.

WHY DEFERRING IS THE SAFE DIRECTION. Option B's proof - bms_soc_floor <
loop_soc_floor <= peak_soc_floor, run through the real arbiter in
test_peakguard.py part 10d - has an unstated premise: the reserve predicate and
discharge_permitted() read the SAME state of charge. With two packs they read
different numbers. The reserve binds on the emptier pack and files hi = 0; the
peak guard, reading the other pack or an average, files lo = excess > 0; lo > hi
is arbiter clause 3 and the answer is 0 W - the battery parked in the billed
quarter, which is the failure the memo itself marks fatal. And no config
validator can catch it: it is a runtime divergence between two readings, not a
config ordering, so CFG-01's shape of check passes on the exact tree that parks
the battery. Option A's cost is energy; Option B's cost built too early is the
one quarter of the month that sets the bill.

THE REVISIT TRIGGER IS ALLOC-01, recorded in §0c. Under ARCH-01's host-side
allocator the two claims stop sharing a list: the loop floor becomes a
per-inverter `hi` consumed as an INTERVAL at stage A, while the peak floor stays
a site `lo` at stage B. Different claim lists, different stages, structurally
unable to cross - a stronger argument than the ordering rule, because it makes
the crossing unreachable rather than harmless. That is the shape in which Option
B becomes safe to build.

WHAT HAS TO BE RE-EARNED AT N=2 is stated plainly in §0d rather than left for
someone to discover. SAFETY-27 now names five floors across two packs, so "the
SoC" stops being a scalar the config can bound. And part 10f - the pin that made
SAFETY-25 mergeable - CANNOT EXPRESS TWO PACKS: its dynamic half is a scalar
0-100 % SoC sweep against a single-series capture, and a two-pack exposure lives
in the DIFFERENCE between two readings, which that sweep visits at no
resolution. Its static half, the AST scan pinning the eight claim sources, still
bites at N=2; the dynamic half goes silent. Merging a two-pack reserve on 10f's
current green would be merging on a check that cannot see the failure.

The options analysis is kept intact on purpose - §1-§7 stand exactly as written,
including §5's "(recommended)" on Option B and §6's recommendation, with a new
§0 saying what was decided and §6 marked as answered. A memo that recorded the
verdict and deleted the reasoning is the failure this DECISIONS/ directory
exists to prevent.

Part 10e's check moved with the memo: it used to pin the honest "Status: OPEN"
line and now pins that the memo names the option, the date and the trigger AND
still carries both options and the recommendation. The tripwire itself stays -
a ninth claim source in Controller.cycle() still takes 10f red on the line it is
written, and "deferred" is precisely the state in which someone builds Option B
by accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 00:46:54 +02:00

751 lines
42 KiB
Python

"""SAFETY-07 - the quarter-peak projection guard, and the only thing in this
add-on that is allowed to spend the loop's freedom to save money.
WHAT IT IS. TARIFF-01 built the INSTRUMENT: `tariff.py` measures the number the
Belgian capaciteitstarief bills on - the highest 15-minute average offtake of the
calendar month - and banks it where a restart cannot reach. It files no claim and
commands nothing, and says so in its own first paragraph. This module is the
CONSUMER it named. It turns that measurement into one arbiter claim and nothing
else: no writes, no I/O, no clock, no state between calls. Given a projection and
two measured numbers it returns a decision; `main.py` files it.
------------------------------------------------------------------------------
THE HAZARD, WITH THE EURO FIGURE
specs.md:583 records a measured case: ONE 4998 W quarter moved a monthly peak
from 4832 W to 5013 W and billed the whole month at it. The capacity tariff has
no averaging and no forgiveness - the month's single worst quarter is the bill.
SAFETY-18 shipped the ALARM for the way this system produces such a quarter by
itself: a meter that answers 200 OK forever with a document that never changes,
which every freshness mechanism in this stack reads as healthy while the loop
integrates a constant error to the rail and holds it there. freeze.py's own
table: at the live install's `max_w: 5000`, a meter frozen at >= 3000 W rails the
command in 75 s. SAFETY-18 notifies at `meter_freeze_s` (300 s default), against
a 900 s billing quarter. That is about nine minutes of human reaction time and
nothing else - freeze.py says so in the words "THIS CUT DOES NOT CAP THAT."
This is the cap. It is deliberately NOT a second freeze detector: it consumes
freeze.py's shipped predicate rather than transcribing it.
------------------------------------------------------------------------------
THE MECHANISM: ONE `lo` LIMIT AT `P_PEAK`, AND WHY THAT SHAPE
specs.md 10.1 row 2 asks for exactly two behaviours - "pre-emptive discharge as
the projected quarter average approaches the soft limit; hard clamp on charging
above it" - and 9.1 constrains the lever: "Discharge and export are never capped
by the peak guard; only charging and import are."
A LOWER BOUND on the setpoint is both of those in one object, and it is the only
shape that is:
* incapable of capping discharge - a floor never stops the loop discharging
HARDER, so 9.1's asymmetry is structural here rather than remembered;
* incapable of capping export, for the same reason;
* a hard clamp on charging whenever the floor is above the standing charge;
* pre-emptive discharge whenever the floor is above zero.
It is a LIMIT, not a SET, and that matters at both ends of the priority table.
`P_PEAK` (50) is above `P_LOOP` (10), so it binds the grid-following loop. It is
BELOW `P_MAINTENANCE` (60) and `P_SAFETY` (100), so a maintenance phase that owns
the actuator and every layer-1 stop still outrank it: arbiter.py's clause 2 only
applies limits whose priority is >= the WINNING SET's, so a P_SAFETY 0 W stop is
not dragged off zero by this floor. That is the correct order - a controller
pushing power the wrong way is wrong however much money it is trying to save.
⚠️ AND A LIMIT DOES NOT OUT-VOTE ANOTHER LIMIT. This was assumed, and it is
false: arbiter.py clause 2 applies EVERY limit at or above the winner's priority
and takes the MOST RESTRICTIVE, with no notion of priority between them - which
is exactly what makes the safety bounds unconditional and must not be changed.
So this floor (`lo >= 0`) and the maintenance charge-only shaping (`hi <= 0`) do
not resolve in P_PEAK's favour when both are filed. They resolve to `lo > hi`, a
CONTRADICTION, which arbiter.py answers with 0 W - the battery parked in the one
quarter of the month it should be shaving hardest. The rule for two limits that
can cross is therefore SUPPRESSION, not priority: main.py drops the maintenance
shaping for as long as this guard is engaged, which is the same thing that branch
already did for `peak_forecast_entity` and is why 9.1's "money outranks the
maintenance schedule" reads as a suppression there rather than as a priority
here. The floor's clamp to `achievable_w` closes the only other pair that could
cross - the P_SAFETY rating bounds.
AND IT IS WHY OPT-04 NEEDS NO CHANGE HERE. 9.1 requires peak shaving to outrank
arbitrage. Arbitrage does not exist yet, but it will arrive as a SET below
P_PEAK, and arbiter.py clause 2 will bind it with this floor on the day it lands,
with no edit to this file. Expressing the priority as a claim rather than as an
if/else in main.py is the whole reason that holds.
------------------------------------------------------------------------------
THE FLOOR IS DERIVED, AND EVERY TERM OF IT IS MEASURED
Let `ceiling_w` be the quarter average that must not be exceeded and `remaining`
the seconds left in the open block. `project()` gives
projected = (acc + held * remaining) / 900
so the held level that would land the quarter exactly on the ceiling is
`held + 900 * (ceiling - projected) / remaining`, and the watts that must come
off the meter are the difference between the two:
excess_w = 900 * (projected - ceiling) / remaining
That single expression carries the whole control law, and two properties of it
are the argument for this design:
1. THE CLAIM IS FILED WHILE THE FLOOR IS POSITIVE, NOT WHILE THE EXCESS IS.
`excess` is what must come off the METER, so it falls to zero the moment the
battery is already taking it - and a guard that disengaged there would hand
the actuator back, watch the offtake spring straight to the raw load, and
engage again next cycle. That is not a hypothetical: it is what the first
cut of this file did to 2026-08-20's billed quarter on replay - seventeen
engage/release cycles in 900 s, and the quarter still finished 49 W over the
limit it had spent a quarter of an hour holding. 10.2 names the cost
exactly: "an EMS that oscillates is worse than no EMS, both for hardware
wear and - per 9.1 - for the peak it is trying to avoid."
`batt_w + excess` IS the quantity to threshold: the offtake there would be
with the battery at zero, less the level this quarter can still afford. Read
as a LEVEL rather than as a switch, the same arithmetic gives a claim that
is filed for as long as a positive floor is needed and decays continuously
to nothing as the load falls. So there is still no hysteresis constant in
this file - not because oscillation was ruled out by argument, but because
the thresholded quantity is the one that does not oscillate.
2. THE GAIN IS 900/remaining, WHICH IS 1.0 AT THE START OF THE QUARTER AND
GROWS. This is the answer to the degeneracy at the top of a block, where the
projection is an extrapolation from almost no evidence: the guard is at its
GENTLEST exactly when its evidence is weakest, and becomes aggressive only
once most of the quarter is behind it and the projection is nearly a
measurement. The formula supplies that; no minimum-evidence threshold was
invented, because inventing one would delay the guard in the case it exists
for - a sustained rail from second zero.
The floor itself is then
floor_w = batt_w + excess_w
`batt_w` IS THE MEASURED BATTERY POWER, NOT THE STANDING COMMAND. Both were
available. The measured one is right for two independent reasons. First, 9.1's
settled constraint is that the utility bills what FLOWED - the arithmetic
`grid = load - batt` is true of delivered power and false of intent. Second, and
concretely: when the inverter cannot follow (the saturation case control.py
already freezes the integrator for), a floor built on the command asks for
`command + excess` every cycle while the command never arrives, and winds itself
to the rail; a floor built on the measurement asks for `delivered + excess`,
which stops rising the moment delivery stops rising. The delivered form cannot
wind up. The intent form can.
------------------------------------------------------------------------------
THE CEILING IS `peak_cap_w`, RAISED ONLY BY AN OBSERVATION
11.2's row is "Quarter peak | Projected quarter average approaches THE MONTHLY
PEAK | Clamp the command", and 9.1's soft limit is `peak_cap_w`. Both matter,
because the money is spent on `max(month's peak so far, this quarter)`:
ceiling_w = max(peak_cap_w, the month's banked peak)
Once a month's peak already stands at 5013 W, holding every later quarter to
3500 W buys nothing - the bill for that month is already 5013 W - while the
holding itself costs real charging the site wanted. Raising the ceiling to a peak
that has already been paid for is what stops one bad quarter from parking the
battery's charging for three weeks.
AND THAT RELAXATION IS GRANTED ONLY BY AN `OBSERVED` READING. `NO_RECORD` and
`NOT_OBSERVED` both leave the ceiling at `peak_cap_w`, the tighter of the two, so
a peak store that cannot be read makes this guard STRICTER and never looser. That
direction is the whole point: tariff.py's `PeakReading` exists because a database
that did not answer must not become a number, and the number it would have become
here - a zero peak - is exactly the one that reads as "no peak recorded, full
freedom". It is also why this module does NOT consume `headroom_w()`, whose
disarming failure it would otherwise inherit: a peak read as 0 W returns FULL
headroom, and full headroom is a guard that is not there.
------------------------------------------------------------------------------
BLINDNESS COMES FIRST, AND IT IS A DIFFERENT ACTION
A projection is evidence only if the quantity under it was actually measured.
Three ways it is not, all of them checked BEFORE the projection is graded,
because grading a stimulus where the quantity it grades is degenerate is how a
guard passes its own tests while being switched off:
* NO OPEN BLOCK. `project()` answers a fresh averager with
`Projection(0.0, 0.0, 0.0, 0.0, False, None, 0.0)` - a ZERO projection, which
compares as comfortably under any ceiling. That is not "the quarter is fine",
it is "there is no quarter". Graded as a number it silently disarms the guard
on exactly the startup path where nothing else is watching.
* COVERAGE. A meter gap longer than one `max_hold_s` window leaves seconds that
`QuarterAverager` deliberately does not accumulate (p1.py: holding a dead
meter's last value forward is how a fabricated peak gets onto a permanent
record). Those seconds make the projection read LOW, which is the
under-guarding direction. The threshold is `max_hold_s` itself - the point
p1.py already stops trusting a held sample - not a new number.
* FROZEN. SAFETY-18's conjunction, evaluated through `freeze.looks_frozen()`
rather than re-written here. A frozen meter is the worst case of all: the
samples keep ARRIVING, so coverage stays perfect and the projection is
confident - and if the stuck value is an export, the projection is a
confident ZERO while the real meter runs at the rail. This is the case
SAFETY-07 exists to backstop, and it is the case a projection cannot see.
THE BLIND ACTION IS `floor_w = 0.0`: the battery may not IMPORT while the billed
quantity is unobservable. It is the minimal action that closes the hazard, and it
is one-directional - forbidding charging can only ever lower delivered offtake,
never raise it - so a false blindness costs a charge the site wanted and cannot
cost energy the pack did not want to give. Contrast the graded branch, which can
force discharge; blindness deliberately does not earn that, because there is by
definition no measurement to size it from.
------------------------------------------------------------------------------
WHY THIS ONE TRIPS WHERE SAFETY-18 ONLY NOTIFIES
SAFETY-18 shipped notification-first, and config.yaml says why in one line: "A
false trip parks the battery; a false alarm costs an alert." That reasoning is
right for a DETECTOR whose negative case had to be established on a rig run. It
does not carry to this guard, and the two committed captures say so:
* MEASURED FALSE-ENGAGEMENT RATE ON THE HEALTHY DAY: zero. 2026-08-23's worst
quarter is a few hundred watts against a 3500 W ceiling and nothing in its 95
quarters comes near engaging. test_peakguard.py re-measures that every run
from the committed capture rather than quoting it here.
* THE ACTION IS LEVEL-TRIGGERED AND UNLATCHED. It is re-derived from a fresh
projection every cycle and releases the instant the projection drops back
under the ceiling. There is no state to clear and no operator to wake. A
false engagement costs one cycle of discharge, not a parked battery - which
is precisely the cost SAFETY-18 could not bound and this one can.
* THE ACTION IS A LIMIT, NOT A SET. Every safety stop in 11.2's table outranks
it, so the worst thing a bug in this file can do is fail to relax a floor -
and a floor is bounded above by `achievable_w`, which main.py takes from the
same `max_w` and device rating the arbiter already enforces.
So it trips. It is also the only guard here whose trigger IS the billed quantity
itself, measured from parsed P1 telegrams, rather than a proxy for it.
------------------------------------------------------------------------------
SATURATION IS REPORTED, NOT ALARMED AS A BUG
10.3's doctrine - "treat an out-of-range computed setpoint as a bug signal, clamp
it AND alarm" - is SAFETY-03's, and it is right there: a loop computing 14 768 W
is a winding integrator, i.e. a defect. A peak floor above the inverter's rating
is NOT a defect. It is the ordinary physics of a 5900 W house load on a 5000 W
inverter, which is literally what 2026-08-20 15:45 was. So the floor is clamped
to `achievable_w` and the decision carries `saturated`, and main.py raises ONE
notification on that - not because the controller is broken, but because it is
the one case where the money WILL be spent and the controller has already done
everything it can. An engagement that is not saturated is an ordinary control
action and goes to the action log, like the clamp's engagements do.
------------------------------------------------------------------------------
NO NEW OPTION. CFG-03 STILL TRACKS THREE PLACES.
`peak_cap_w` already ships, is already validated `int(500,15000)`, and is already
read by `control.peak_at_risk()`. `max_hold_s` comes off the averager the caller
already owns; `achievable_w` off `max_w` and the device rating the arbiter
already enforces; the freeze predicate off `meter_freeze_s`. config.yaml and
validate.py are untouched by this ticket, exactly as TARIFF-01 left them, and
14's `capacity_peak_limit_kw` stays declined - a kW key beside a W key is the
mismatch trap 14's own warning box describes.
------------------------------------------------------------------------------
SAFETY-24: THE ENERGY BACKSTOP THIS GUARD SHIPPED WITHOUT
SAFETY-07 left this file saying, in its own words, that the graded branch can
FORCE DISCHARGE and that nothing below it consults the state of charge - "the
BMS is the sole energy backstop" (freeze.py). On the 2026-08-20 replay the guard
asks for 878 Wh in a day, and it keeps asking for as long as the meter says the
quarter is over the ceiling. That is the behaviour working as designed, and it
is unbounded from below by anything in the EMS.
⚠️ STATE THE RISK FAIRLY, because overstating it is how a real one gets ignored.
Relying on the BMS is not absurd: it is a genuine floor and it works. But it is
THE PACK's protection, not the OPERATOR's. A month of aggressive quarters can
leave the battery repeatedly parked at that floor with no EMS-side record of
why, and the first symptom - a pack that is never available when it is wanted -
is indistinguishable from the SoC drift MAINT-01 exists to correct.
So `decide()` now takes `soc` and `soc_floor`, and `discharge_permitted()` is
the one place the question is answered. Four things about the shape of it:
1. IT IS NOT A TRIP, AND IT MUST NOT BECOME ONE. SAFETY-07's claim is a LIMIT
and every stop in 11.2's table still outranks it. This floor is about
ENERGY - how far down the guard may spend the pack for money - not about
last-resort safety. The action when it binds is therefore to stop ASKING
for more discharge, not to command anything.
2. THE ACTION IS `floor_w = 0.0`, WHICH IS THE BLIND BRANCH'S ACTION FOR THE
BLIND BRANCH'S REASON. Zero is one-directional: it forbids CHARGING, which
can only ever lower delivered offtake, and it forbids nothing else. It
cannot pull the loop off a discharge it is already doing, because a `lo`
limit structurally cannot cap discharge - the property this module's whole
shape rests on, and one a NEGATIVE floor would destroy. Nothing here can
produce a negative floor: the only two values `floor_w` ever takes are 0.0
and `min(wanted, achievable)` with `wanted > 0` already established.
⚠️ AND IT DOES NOT BOUND THE LOOP. Self-consumption may still drain the
pack past this floor, exactly as SAFETY-18 recorded. That is not fixed
here and must not be claimed: this card bounds THE PEAK GUARD's OWN forced
discharge, which is the thing SAFETY-07 introduced. A loop-side SoC guard
is a different lever with a different right value.
⚠️ SAFETY-25 MEASURED HOW FAR PAST, so this is no longer a caveat that has
to be taken on trust. On the committed 2026-08-20 capture, 19:00-22:00 UTC
- three hours of house load after the sun has gone, 830 W mean, zero export
- the 13.8 kWh reference pack started one point ABOVE `peak_soc_floor`
ends the evening at 10.0 %, i.e. at `bms_soc_floor`, having delivered
1525 Wh; 343 of 587 control cycles deliver discharge with the pack already
under this floor, and the guard files no floor at all on any of them. The
same replay at `peak_soc_floor` = 5, 11, 20 and 60 ends at the SAME 10.0 %,
which is the finding stated as sharply as it can be: THE EXPOSURE IS A
PROPERTY OF THE CONTROL LAW, NOT A VALUE ANYONE CAN CONFIGURE. And on the
same day's high-load block the two states coexist - 49 cycles reporting
PEAK GUARD FLOOR-LIMITED while the arbiter grants the loop up to 2000 W of
discharge in every one of them. test_peakguard.py part 10 is the replay;
DECISIONS/SAFETY-25-loop-soc-floor.md carries the options and the
recommendation, and the product owner DECIDED it on 2026-09-02: Option A,
the BMS stays the only backstop for demand-driven discharge, with a
loop-side reserve DEFERRED to ALLOC-01. This floor's scope is therefore
settled rather than provisional - it bounds the peak guard, on purpose.
3. IT IS GRADED ONLY WHERE IT IS NOT DEGENERATE. The SoC question is asked
AFTER `wanted > 0` is established - i.e. only where a forced discharge is
actually being asked for. Asked earlier it would grade a quantity that
governs nothing, and would report "floor-limited" on a quiet afternoon
where the guard was never going to discharge anyway.
4. AN UNREADABLE SoC IS NOT A FULL BATTERY. `soc=None` is floor-limited, not
permitted. This is `PeakReading`'s lesson in the one place it bites
hardest: a full battery is exactly the value that would disarm this floor,
so a failed read defaulting to one would silently restore the unbounded
discharge this card exists to remove. Both new arguments are REQUIRED
keyword-only and neither has a default, which is TEST-14 read as a rule
rather than as a story: a required field that gains a default is how three
separate files in this repo shipped a quiet fallback.
AND FAILING TO SHAVE MUST NOT BE SILENT. The whole point of the guard is the
euro figure, so a floor-limited cycle carries `floor_limited=True` and main.py
raises ONE notification on the edge, saying the quarter will be exceeded - the
same shape, and the same argument, as `saturated`. The two are mutually
exclusive by construction: a floor-limited decision asks for nothing, so it
cannot have run out of inverter.
------------------------------------------------------------------------------
CFG-03: TWO NEW OPTIONS, MIRRORED ONCE, AND WHY BOTH ARE NEEDED
SAFETY-07 added none and said so. This card cannot: an EMS-side floor is a
number only the site knows, and refusing one set below the pack's own cut-out
means knowing that cut-out too.
* `peak_soc_floor` - the EMS floor. ⚠️ NOT `maintenance_soc_floor`. That is
the maintenance cycle's DRAIN TARGET: the depth a monthly recalibration
deliberately goes to, once, under supervision. This is the depth a
money-saving discharge may never reach, on any day of the month. Sharing
one key would couple two features whose right values differ and would widen
CFG-03 rather than narrow it.
* `bms_soc_floor` - the pack's own cut-out (register 0x0560 on the reference
unit, 10 %). It is here so validate.py rule 8 can REFUSE a `peak_soc_floor`
at or below it. That configuration is CFG-01 rule (a): the operator states
a floor, the BMS enforces a different one, and the status page reports an
energy backstop that can never bind.
Both defaults are mirrored ONCE, in `PEAK_OPTION_DEFAULTS` below, which is
TARIFF-02's `PRICE_OPTION_DEFAULTS` pattern - imported by validate.py and by
main.py, pinned to config.yaml by test_peakguard.py rather than transcribed.
This module still reads no option itself: `decide()` takes both as ARGUMENTS,
makes no `.get()` call, and the mirror is a default table, not a read.
⚠️ A DECISION RECORDED, NOT A RENAME DONE. Now that a second SoC floor exists,
`maintenance_soc_floor` is the ambiguous name of the two - it reads like "the
SoC floor" and is not. Renaming it is an option migration on a live install for
a clarity gain, so the decision recorded here is: KEEP IT, and carry the
distinction in DOCS.md and in this comment. Revisit if a THIRD floor appears -
a loop-side guard would be the third - at which point the migration buys enough
to be worth its cost.
------------------------------------------------------------------------------
AND IT READS NO PER-PHASE QUANTITY, ON PURPOSE
The sum/max/net reduction is an open product question with the owner. TARIFF-01
put it behind `billed_w()` and defaulted to NET. Everything this module consumes
- `PeakReading.peak_w`, `Projection.projected_w` - is already reduced by that one
function, so whichever way the owner answers, this file does not change and does
not need re-testing. Nothing here indexes a phase.
"""
from dataclasses import dataclass
from .arbiter import P_PEAK, Claim
from .control import peak_at_risk
from .tariff import QUARTER_S
# Why the projection may not be graded. Reasons, not booleans: the status page
# and the action log both have to say WHICH, and "blind" alone is unactionable.
BLIND_NO_BLOCK = "no open quarter - the meter has produced no sample yet"
BLIND_COVERAGE = "meter gap"
BLIND_FROZEN = "SAFETY-18: the meter is answering without changing"
# Where the ceiling came from. Only the second is a relaxation, and only an
# OBSERVED reading may produce it.
CEILING_CAP = "peak_cap_w"
CEILING_MONTH_PEAK = "the month's banked peak"
# SAFETY-24. Why the forced discharge may not be sized. Reasons, not booleans,
# for BLIND_*'s reason: the status page, the action log and the operator
# notification all have to say WHICH, and "floor-limited" alone is unactionable.
FLOOR_SOC_UNREADABLE = ("the battery state of charge was not measured this "
"cycle, and an unreadable SoC is not a full battery")
# ⚠️ SAFETY-25. "the peak guard's SoC floor", not "the SoC floor", and the
# possessive is load-bearing: this string is what the status page, the action log
# and the operator notification all print, and `peak_soc_floor` bounds THIS GUARD
# ONLY. Widening it to "the SoC floor" would make the one sentence an operator
# reads at the moment the battery stops shaving into a claim about the pack that
# the grid-following loop is at that instant disproving.
FLOOR_AT_SOC_FLOOR = "state of charge is at or below the peak guard's SoC floor"
SOURCE = "peak"
# ⚠️ CFG-03. THE ONE IN-CODE MIRROR OF config.yaml FOR THIS CARD'S OPTIONS, and
# it is TARIFF-02's `PRICE_OPTION_DEFAULTS` pattern for TARIFF-02's reason: the
# add-on already carries option defaults in three places (config.yaml's
# `options:`, config.yaml's `schema:`, and main.py's literal `.get()`
# fallbacks), and a fourth is the open ticket. validate.py and main.py both
# import THIS dict rather than typing a literal, and test_peakguard.py pins it
# to config.yaml by READING the file - so a drift goes red instead of shipping.
#
# ⚠️ NOT A READ. `decide()` takes both values as arguments and this module makes
# no `.get()` call at all; the dict is a default table for its two callers.
PEAK_OPTION_DEFAULTS = {
# The EMS-side floor on SAFETY-07's forced discharge, in percent.
#
# 20 is DERIVED from the two numbers this repository already has, not
# chosen. The reference pack is 300 Ah at 51.2 V with DoD 90 - 13.8 kWh
# usable, the figure validate.py's rule 6 note already quotes - and the
# 2026-08-20 replay models 878 Wh of guard discharge in a day, which is
# 6.4 % of that. A floor 10 points above the pack's own 10 % cut-out
# therefore leaves the guard roughly 1.6 of its worst observed days of room
# before it stops, which is the smallest margin that lets the guard do its
# job on a bad day and still never reach the BMS.
"peak_soc_floor": 20,
# The pack's OWN cut-out, in percent: what the BMS enforces regardless of
# anything here. On the reference unit this is register 0x0560, read as 10,
# and it is consistent with DoD 90 on a full pack. It exists so validate.py
# rule 8 can refuse a `peak_soc_floor` at or below it - see that rule.
"bms_soc_floor": 10,
}
@dataclass(frozen=True)
class PeakDecision:
"""What the guard concluded this cycle, with everything it concluded it from.
Every field is derived from a measurement taken in the same cycle. There is
no `floor_w_or(default)` and there must not be one, for `PeakReading`'s
reason: an accessor that turns "not measured" into a number re-creates the
defect the type exists to remove.
"""
engaged: bool
floor_w: float | None # the `lo` the claim carries; None = no claim
ceiling_w: float
ceiling_from: str
blind: str | None = None
projected_w: float | None = None
excess_w: float = 0.0
wanted_w: float | None = None # the floor before `achievable_w` clamped it
saturated: bool = False
# SAFETY-24. True when a forced discharge WAS wanted and the EMS-side SoC
# floor refused to size it. Mutually exclusive with `saturated` by
# construction: this branch asks for nothing, so it cannot have run out of
# inverter. `soc_floor_reason` says WHICH of the two ways it bound.
floor_limited: bool = False
soc: float | None = None
soc_floor: float | None = None
soc_floor_reason: str | None = None
reason: str = ""
def explain(self) -> str:
if self.floor_limited:
return self.reason
if not self.engaged:
if self.projected_w is None:
return f"peak guard idle - {self.reason}"
return (f"peak guard idle - quarter projects "
f"{self.projected_w:.0f} W under a {self.ceiling_w:.0f} W "
f"ceiling ({self.ceiling_from})")
return self.reason
def quarter_ceiling_w(cap_w, reading) -> tuple[float, str]:
"""The average this quarter may not exceed, and where the number came from.
ONLY an OBSERVED reading may raise it. `NO_RECORD` (the store answered, this
month has nothing yet) and `NOT_OBSERVED` (the store did not answer) both
return the cap, which is the tighter answer. A failed read therefore makes
the guard stricter, never looser - see the module note.
"""
cap = float(cap_w)
if (reading is not None and getattr(reading, "is_observed", False)
and reading.peak_w is not None and float(reading.peak_w) > cap):
return float(reading.peak_w), CEILING_MONTH_PEAK
return cap, CEILING_CAP
def excess_w(projection, ceiling_w) -> float:
"""Watts that must come off the meter NOW to land the quarter on the ceiling.
The whole control law, and the only place it is written down:
excess = 900 * (projected - ceiling) / remaining
Positive means the quarter is projected over; zero or negative means it is
not. `remaining <= 0` is not a large number, it is an undefined one - a
quarter with no seconds left cannot be influenced - so it answers 0.0 rather
than dividing.
"""
remaining = QUARTER_S - float(projection.elapsed_s)
if remaining <= 0.0:
return 0.0
return QUARTER_S * (float(projection.projected_w) - float(ceiling_w)) / remaining
def uncovered_s(projection) -> float:
"""Seconds of the open block the meter did not actually cover.
Derived from what `Projection` already carries rather than re-measured:
`covered_frac` is `elapsed / elapsed_wall`, and `elapsed_s` IS that wall.
"""
return max(0.0, float(projection.elapsed_s)
* (1.0 - float(projection.covered_frac)))
def blindness(projection, max_hold_s, frozen: bool) -> str | None:
"""Why this projection may not be graded, or None if it may be.
Evaluated BEFORE the projection is compared to anything. Each of the three is
a way for the projected number to be confident and wrong, and the first - a
fresh averager's all-zero `Projection` - is confident, wrong AND under every
ceiling there is.
"""
if frozen:
return BLIND_FROZEN
if projection.block_start is None:
return BLIND_NO_BLOCK
gap = uncovered_s(projection)
if max_hold_s is not None and gap > float(max_hold_s):
return (f"{BLIND_COVERAGE}: {gap:.0f} s of this quarter uncovered, past "
f"the {float(max_hold_s):.0f} s the averager will hold a sample")
return None
def discharge_permitted(soc, soc_floor) -> str | None:
"""SAFETY-24. May the guard size a forced discharge, or why may it not?
Returns None when it may, and otherwise the REASON it may not - which the
decision, the status page, the action log and the operator notification all
carry, because "floor-limited" on its own tells nobody what to do.
THE ONE PLACE THIS QUESTION IS ANSWERED, and it is public for the reason
`freeze.looks_frozen()`, `watchdog.loop_is_following()` and
`shaping_suspended()` are: a safety predicate transcribed into its own check
is one copy plus a future disagreement about what the check means, and the
copy that drifts is always the grading one.
⚠️ `soc is None` IS NOT PERMISSION. A failed read is not an observation, and
a FULL BATTERY is exactly the value that would disarm this floor - so a
`float(soc or 100)` here would restore the unbounded discharge this card
removes, silently, on the one install whose SoC sensor is misnamed. There is
deliberately no default argument on either parameter.
⚠️ AND `soc_floor is None` IS NOT PERMISSION EITHER, for the same reason one
step further out: a caller that has no floor configured has not thereby been
granted an unbounded one.
Boundary: `<=`, not `<`. A pack sitting exactly ON the floor has already
spent everything this guard was allowed to spend; permitting one more cycle
there would make the floor mean "one cycle below the floor".
"""
if soc is None:
return FLOOR_SOC_UNREADABLE
if soc_floor is None:
return ("no peak guard SoC floor is configured, so there is nothing to "
"bound a forced discharge with")
if float(soc) <= float(soc_floor):
return FLOOR_AT_SOC_FLOOR
return None
def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
max_hold_s=None, frozen: bool = False) -> PeakDecision:
"""The whole guard. Pure: no clock, no I/O, no state carried between calls.
`batt_w` MEASURED battery power, positive = discharge. None when the
sensor for it is missing - and then there is nothing to size a
floor from, so no floor is invented.
`achievable_w` the largest discharge the arbiter could actually grant
(`min(max_w, device max)`). The floor is clamped to it so this
claim can NEVER produce `lo > hi` in the resolver - a
contradiction there resolves to 0 W, which would park the
battery in the exact minute it should be discharging hardest.
`soc` SAFETY-24. MEASURED state of charge, percent. None when the
sensor for it did not answer - which is floor-limited, not
permission.
`soc_floor` SAFETY-24. The percent below which this guard may not spend
the pack for money. REQUIRED, and deliberately without a
default: a default here would be CFG-03's fourth defaults
site and TEST-14's quiet fallback in one line.
"""
ceiling, from_where = quarter_ceiling_w(cap_w, reading)
ceil_top = abs(float(achievable_w))
blind = blindness(projection, max_hold_s, frozen)
if blind is not None:
# 0.0, not a sized floor. There is no trustworthy measurement to size one
# from; the claim that remains is "do not add to the offtake you cannot
# see", which is one-directional and cannot cost pack energy.
return PeakDecision(
engaged=True, floor_w=0.0, ceiling_w=ceiling, ceiling_from=from_where,
blind=blind, projected_w=None,
reason=(f"PEAK GUARD BLIND ({blind}) - the battery may not import "
f"while the billed quarter cannot be measured; commanding no "
f"charging (floor 0 W)"))
projected = float(projection.projected_w)
if batt_w is None:
# main.py's P_SAFETY stop already owns a cycle with a missing input, at
# 0 W. Filing nothing is the honest answer: a floor sized from a
# measurement that is not there is a made-up number.
return PeakDecision(
engaged=False, floor_w=None, ceiling_w=ceiling,
ceiling_from=from_where, projected_w=projected,
reason="no battery measurement to size a floor from")
if QUARTER_S - float(projection.elapsed_s) <= 0.0:
# A block with no seconds left cannot be influenced. This is degenerate
# rather than urgent - `excess_w` answers 0.0 there and a floor equal to
# the battery's own current output would be a claim that says nothing.
return PeakDecision(
engaged=False, floor_w=None, ceiling_w=ceiling,
ceiling_from=from_where, projected_w=projected,
reason="the quarter has no seconds left to influence")
ex = excess_w(projection, ceiling)
wanted = float(batt_w) + ex
if wanted <= 0.0:
# ⚠️ THE TEST IS ON THE FLOOR, NOT ON `excess > 0`, AND THE REPLAY IS WHY.
# `excess` is the watts that must come off the METER, so it goes to zero
# as soon as the battery is already removing them - and a guard that
# disengaged there handed the actuator straight back, the offtake sprang
# back to the raw load, and the next cycle engaged again. On 2026-08-20's
# billed quarter that bang-bang cycled 17 times in 900 s and left the
# quarter 49 W over the limit it had just spent a quarter of an hour
# holding. §10.2: "an EMS that oscillates is worse than no EMS, both for
# hardware wear and - per §9.1 - for the peak it is trying to avoid."
#
# `batt_w + excess` is the offtake that would exist with the battery at
# zero, minus the level the quarter can afford. Testing THAT is the same
# arithmetic read as a level rather than as a switch: the claim is filed
# for as long as a positive floor is needed and decays continuously to
# nothing as the load falls, so there is still no hysteresis constant -
# the quantity being thresholded is simply the right one.
return PeakDecision(
engaged=False, floor_w=None, ceiling_w=ceiling,
ceiling_from=from_where, projected_w=projected, excess_w=ex,
wanted_w=wanted,
reason="the load is under the level this quarter can afford")
# SAFETY-24 - AND ONLY HERE. `wanted > 0` is now established, so a forced
# discharge really is being asked for and the SoC is the quantity that
# decides whether it may be sized. Asked one line earlier it would grade a
# number that governs nothing on every quiet cycle of the day, and would
# report "floor-limited" over a guard that was never going to discharge.
denied = discharge_permitted(soc, soc_floor)
if denied is not None:
# 0.0, not `wanted`, and not a negative number. Zero forbids CHARGING -
# one-directional, cannot cost pack energy - and forbids nothing else;
# a `lo` limit structurally cannot cap the discharge the loop is already
# doing. See the module note: this bounds THE GUARD's forced discharge,
# not the grid-following loop, which still has no SoC guard.
return PeakDecision(
engaged=True, floor_w=0.0, ceiling_w=ceiling,
ceiling_from=from_where, projected_w=projected, excess_w=ex,
wanted_w=wanted, floor_limited=True, soc=soc, soc_floor=soc_floor,
soc_floor_reason=denied,
reason=(
f"PEAK GUARD FLOOR-LIMITED: the quarter projects "
f"{projected:.0f} W against a {ceiling:.0f} W ceiling "
f"({from_where}) and {wanted:+.0f} W of discharge would hold "
f"it, but {denied}"
+ ("" if soc is None else f" ({float(soc):.0f} %")
+ ("" if soc is None or soc_floor is None
else f" vs {float(soc_floor):.0f} %")
+ ("" if soc is None else ")")
+ " - commanding no forced discharge, so THIS QUARTER WILL GO "
"OVER THE CEILING. Charging stays blocked (floor 0 W)"))
floor = min(wanted, ceil_top)
saturated = floor < wanted
return PeakDecision(
engaged=True, floor_w=floor, ceiling_w=ceiling, ceiling_from=from_where,
blind=None, projected_w=projected, excess_w=ex, wanted_w=wanted,
saturated=saturated, soc=soc, soc_floor=soc_floor,
reason=(
f"PEAK GUARD: quarter projects {projected:.0f} W against a "
f"{ceiling:.0f} W ceiling ({from_where}) with "
f"{QUARTER_S - projection.elapsed_s:.0f} s left, so {ex:.0f} W must "
f"come off the meter; battery measured {float(batt_w):+.0f} W, "
f"floor {floor:+.0f} W"
+ (f" - SATURATED, {wanted:+.0f} W was needed and the inverter can "
f"only give {ceil_top:.0f} W" if saturated else "")))
def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool:
"""Must the maintenance charge-only shaping stand down this cycle?
THE ONE PLACE THIS CONJUNCTION IS WRITTEN, and it is public for the reason
`watchdog.loop_is_following()` and `freeze.looks_frozen()` are: the check
that grades it has to assert on THE SHIPPED EXPRESSION. A safety predicate
transcribed into its own test is one copy plus a future disagreement about
what the check means, and the copy that drifts is always the grading one.
It was inline in `main.py` and nothing could reach it; a reviewer mutated it
back to its historical broken form and all twelve suites stayed green.
⚠️ WHY SUPPRESSION AND NOT PRIORITY. The shaping is a LIMIT (`hi <= 0`) and
the peak guard's claim is a LIMIT (`lo >= 0`). arbiter.py clause 2 applies
EVERY limit at or above the winner's priority and takes the MOST RESTRICTIVE,
with no notion of one limit outranking another - which is precisely what
makes the safety bounds unconditional and must not be changed. Two crossing
limits are therefore a CONTRADICTION, which resolves to 0 W: the battery
PARKED, in the one quarter of the month that sets the bill. P_PEAK 50 >
P_MAINT_SHAPE 40 does not save it and never could.
⚠️ AND THE FIRST TERM IS NOT ENOUGH ON ITS OWN. `peak_forecast_entity` is
OPTIONAL and is empty on a default install, so `peak_forecast_w` is None
there and `peak_at_risk()` answers False forever. Before this card the
suspension therefore could not fire on the very site that needed it. The
second term is the MEASURED guard, which is always present wherever P1 is.
Deleting either term - or re-gating the whole thing on the forecast - parks
a default install at 0 W, silently, in the billed quarter. test_peakguard.py
part 7 drives the real cycle with each of those mutations in place.
"""
if peak_at_risk(peak_forecast_w, float(cap_w)):
return True
return decision is not None and decision.engaged
def claim(decision: PeakDecision) -> Claim | None:
"""The decision as one arbiter claim, or None when the guard is idle.
A LIMIT with only `lo` set. `hi` stays `+inf`, which is 9.1's "discharge and
export are never capped by the peak guard" expressed as a fact about the
object rather than as a rule somebody has to remember.
"""
if not decision.engaged or decision.floor_w is None:
return None
# Three reasons, not two. A `lo = 0 W` claim reaches the arbiter from THREE
# different states now - blind, floor-limited, and a genuine floor that
# happens to be zero - and the arbiter's explain() is what an operator reads
# when the battery stops charging. Spelling them the same way would make the
# SAFETY-24 case indistinguishable from the SAFETY-18 one on the one line
# that gets read.
return Claim.limit(SOURCE, P_PEAK, lo=float(decision.floor_w),
reason=("blind" if decision.blind
else "SoC floor" if decision.floor_limited
else "quarter peak"))