checks / unit (pull_request) Successful in 3m43s
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>
751 lines
42 KiB
Python
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"))
|