checks / unit (pull_request) Successful in 3m29s
SAFETY-07 can command a discharge to hold a billed quarter down, and nothing below it consulted the state of charge. On the 2026-08-20 replay it asks for 878 Wh in a day. Relying on the BMS is not absurd - it is a real floor and it works - but it is the pack's protection rather than the operator's, and a month of aggressive quarters leaves the battery repeatedly at that cut-out with no EMS-side record of why. The first symptom is a pack never available when wanted, which is indistinguishable from the SoC drift MAINT-01 exists to correct. decide() now takes soc and soc_floor, both required and neither defaulted, and discharge_permitted() is the one place the question is answered. Above the floor nothing changes. At or below it the guard files a 0 W floor instead of a sized one - one-directional, so charging stays blocked and no discharge is capped - and reports floor_limited with the reason it bound. It is not a trip: every stop in 11.2's table still outranks the claim, and the floor is never negative, so 9.1's asymmetry survives as a property of the object (0 negative floors over 1296 swept combinations, SoC included). The SoC is graded only after `wanted > 0` is established, so it is never evaluated where it governs nothing. An unreadable SoC is floor-limited, not a full battery - a full battery is exactly the value that would disarm the floor, and this project shipped that defaulting once already. Failing to shave is not silent: one notification per episode (goodwe_peak_soc_floor), distinct from the saturation alarm because the two ask for opposite operator actions, plus its own status row and an action-log line. Verified by replay. The 2026-08-20 capture is driven against a modelled pack sized from the day's own measured appetite, so it genuinely exhausts: without the floor it reaches the BMS cut-out and holds there for 1227 guard cycles; with it the pack stops at 20.0 % and never touches the BMS. The energy withheld equals the energy between the two floors to within one cycle at the rail. The honest half is reported too - the worst quarter goes 4338 W -> 5030 W, because a floor-limited guard shaves less on purpose. Two new options, mirrored once in peakguard.PEAK_OPTION_DEFAULTS (TARIFF-02's pattern), imported by validate.py and main.py rather than typed as literals and pinned to config.yaml by a check that reads the file - so CFG-03 still tracks three places. validate.py rule 8 refuses peak_soc_floor <= bms_soc_floor at startup, in CFG-01/MAINT-04's manner. maintenance_soc_floor is deliberately not reused and not renamed: it is a drain target, not a spend limit, and the rename is recorded as a decision to revisit if a third floor appears. Eleven mutations of the shipped source were run against the checks; all eleven go red and there are no survivors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
726 lines
40 KiB
Python
726 lines
40 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.
|
|
|
|
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")
|
|
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"))
|