`bms_soc_floor` describes A BATTERY, not a site. At two packs of different models the cut-outs differ, and one number for both graded the shallower pack against the deeper one's BMS - so a site with cut-outs of 10 % and 30 % under `peak_soc_floor: 20` STARTED, and the guard would command a forced discharge that pack B's BMS, not the operator's floor, is the thing that stops. That is rule 8's own failure mode, reached through rule 8. Scoped to the card's third bullet, per its PM PROOFREAD: the aggregate anti-windup (DEV-02) and per-instance `achievable_w` (CFG-04) were verified as already shipped and are untouched. * config.yaml: `bms_soc_floor: int(0,50)` REQUIRED per `inverters:` entry. Nothing is inherited; the flat key stays the single-inverter shape's own cut-out. A per-entry DEFAULT was declined - a default is one number for every pack, which is this card's defect - and the decision is recorded. * inverter.py: `Inverter.bms_soc_floor`; an entry that states none gets None, not a borrowed number and not 0.0 (the most permissive value the key has). * peakguard.py: `Pack`, `pack_floor_pct()`, `pack_permissions()`, `binding_soc_floor()`. `discharge_permitted()` is unchanged and is still the one place the question is answered. * validate.py: `unit_bms_floors_indexed()` carries the ROSTER index; rule 8 grades EVERY unit and NAMES the one it refuses. Graded with the offender SECOND and with the same two numbers in the other order. * main.py: `Controller.packs`; `decide(soc_floor=...)` is the per-pack reduction of the same option; the floor-limited notification names the pack that stopped the site. `pack_floor_soc` - the emptiest pack, and its None-on-missing behaviour - is untouched. * tools/supervisor_shape_probe.py: RE-RUN and repaired. The required key broke §C and left §D printing PASS on a refusal about a different key. §D is now an isolated counterfactual over an accepted merged baseline, one key varied, asserting the key name. `all cases passed` on 2026.08.0. Stated rather than hidden: under any config rule 8 accepts the per-pack FLOOR reduces to `peak_soc_floor` for every pack. The reduction is written for `unit_caps_indexed()`'s reason and graded on both sides - 1775 rule-8-valid pairs answer the option, the pair rule 8 refuses answers the cut-out. `peak_soc_floor` is still one number for the site, so the "deeper pack needlessly withheld" half is filed as an open question, not closed. Full 19-file suite green. Twelve source mutations through tools/mutation_run.sh: eleven red, one argued survivor. The battery also found `_flat_bms_floor()` defaulting in two places, each covering for the other. No measurement of inverter B appears anywhere: it does not exist yet. Every second-pack figure is simulated and labelled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
885 lines
49 KiB
Python
885 lines
49 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.
|
|
|
|
------------------------------------------------------------------------------
|
|
SAFETY-32: ONE `bms_soc_floor` FOR TWO PACKS GRADES ONE AGAINST THE OTHER'S BMS
|
|
|
|
`bms_soc_floor` above describes A PACK, not a site, and it was a flat option
|
|
because there was one pack. At two, two batteries of different models have
|
|
different cut-outs, and one number for both is wrong in the direction that
|
|
empties a battery: the SHALLOWER pack (the higher cut-out) is protected by a
|
|
floor that belongs to the DEEPER one. So it became a roster key - `Pack` carries
|
|
each unit's own value, `pack_floor_pct()` conjoins it with the site's EMS floor,
|
|
and validate.py rule 8 grades EVERY unit and NAMES the one it refuses.
|
|
|
|
⚠️ `peak_soc_floor` DID NOT MOVE, AND THE RESIDUAL IS RECORDED RATHER THAN
|
|
HIDDEN. It is one number for the site, so with cut-outs of 10 % and 18 % the
|
|
operator must set it above 18 and the deeper pack is then withheld above its own
|
|
need. That half - a per-pack EMS floor - is NOT this card and must not be
|
|
smuggled in with it; DECISIONS/SAFETY-32-per-pack-bms-floor.md states it as the
|
|
open question it is.
|
|
|
|
⚠️ AND THE MIN() OVER MEASURED SoC IS STILL RIGHT. `Controller.pack_floor_soc`
|
|
reduces to the emptiest pack and this card does not touch it. What changed is
|
|
the FLOOR it is graded against, and the addition of a per-pack answer beside the
|
|
site's one verdict - see `pack_permissions()`.
|
|
|
|
------------------------------------------------------------------------------
|
|
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 ACTUATOR_SITE, 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
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Pack:
|
|
"""SAFETY-32. ONE pack's three numbers for the floor question, NAMED.
|
|
|
|
`name` the roster name, because a refusal that does not say WHICH pack
|
|
stopped the site sends an operator to the wrong device - the
|
|
same defect CFG-04's `unit_caps_indexed()` was written for.
|
|
`soc` THIS pack's measured state of charge. None when its sensor did
|
|
not answer, and None is not a full battery.
|
|
`bms_floor` THIS pack's OWN cut-out percent (`bms_soc_floor`), which is a
|
|
property of the hardware in the cupboard and NOT of the site.
|
|
None when the roster entry did not state one - see
|
|
`pack_floor_pct()` for why that is withheld and not 0 %.
|
|
|
|
⚠️ IT CARRIES THE CUT-OUT, NOT THE EMS FLOOR. `peak_soc_floor` is one number
|
|
for the whole site (the depth a money-saving discharge may never reach) and
|
|
stays a flat option; `bms_soc_floor` describes a battery and became a roster
|
|
key here, because two packs of different models have different cut-outs and
|
|
grading both against one of them is wrong in the direction that empties the
|
|
shallower one.
|
|
"""
|
|
|
|
name: str
|
|
soc: float | None
|
|
bms_floor: float | None
|
|
|
|
|
|
def pack_floor_pct(pack, soc_floor):
|
|
"""The percent THIS pack may not be spent below, or None if that is unknown.
|
|
|
|
`max(the site's EMS floor, this pack's own cut-out)` - the CONJUNCTION, and
|
|
the direction matters: the EMS floor is what the operator asked for and the
|
|
cut-out is what the hardware will do anyway, so a pack is protected by
|
|
whichever is higher. Taking the site floor alone is the shipped defect this
|
|
card removes (the shallower pack graded against the deeper one's cut-out);
|
|
taking the cut-out alone would throw SAFETY-24's margin away.
|
|
|
|
⚠️ AN UNSTATED CUT-OUT IS None, NOT ZERO, AND NOT THE FLAT OPTION. 0 % would
|
|
be a floor that permits everything - the most permissive value there is,
|
|
invented from an absence - and borrowing the flat `bms_soc_floor` is CFG-04's
|
|
"nothing is inherited" broken in one line, which is this card's whole subject
|
|
restated. `discharge_permitted()` already treats a None floor as NO
|
|
permission, so an entry that forgot the key costs a shave rather than a pack.
|
|
|
|
⚠️ AND UNDER A rule-8-VALID CONFIG THIS IS `soc_floor`, FOR EVERY PACK.
|
|
validate.py rule 8 refuses `peak_soc_floor <= bms_soc_floor` on EVERY unit,
|
|
so `max()` selects the EMS floor everywhere the add-on will actually start -
|
|
which is exactly why the reduction is written here rather than assumed at the
|
|
call site. "Another rule refuses that config" is a claim about today's rule
|
|
ordering, not a property of this function, and `validate_startup_config()` is
|
|
also `sim/inject.py`'s entry point. `unit_caps_indexed()`'s docstring records
|
|
the same argument, and CFG-04's index desynchronisation is what happened the
|
|
last time it was left implicit.
|
|
"""
|
|
if soc_floor is None or pack.bms_floor is None:
|
|
return None
|
|
return max(float(soc_floor), float(pack.bms_floor))
|
|
|
|
|
|
def pack_permissions(packs, soc_floor) -> list[tuple[str, str | None]]:
|
|
"""SAFETY-32. Per pack: None if it may be spent, else the reason it may not.
|
|
|
|
ONE `(name, reason)` pair per pack, in roster order, from the SAME
|
|
`discharge_permitted()` the site verdict uses - a second transcription of
|
|
"may this pack be spent" is a second answer to it, which is the argument
|
|
that made that predicate public in the first place.
|
|
|
|
⚠️ THIS IS THE OBSERVABLE THE MIN() CANNOT PRODUCE. `Controller.pack_floor_soc`
|
|
reduces the measured SoC to the emptiest pack BEFORE anything is graded, and
|
|
that reduction is right for the site's one verdict - but it has thrown away
|
|
which pack it came from and how far that pack was from ITS OWN floor by the
|
|
time the answer exists. Two packs at 15 % against floors of 10 % and 20 %
|
|
have opposite answers and are indistinguishable after the `min()`.
|
|
"""
|
|
return [(p.name, discharge_permitted(p.soc, pack_floor_pct(p, soc_floor)))
|
|
for p in packs]
|
|
|
|
|
|
def binding_soc_floor(packs, soc_floor):
|
|
"""The ONE floor `decide()` may grade `pack_floor_soc` against, at a roster.
|
|
|
|
MAXIMUM over the packs' own floors, and it is the conservative reduction that
|
|
pairs with the MINIMUM `pack_floor_soc` reduction: `min(soc) <= max(floor)`
|
|
is implied by "some pack is at or below its own floor", so this verdict is
|
|
never more permissive than grading each pack separately would be. The other
|
|
direction is not true and is not claimed - a site can be refused here while
|
|
every pack individually has headroom, which costs a shave and never a pack.
|
|
|
|
None when ANY pack's floor is unknown, for `Controller.site_dev_bound()`'s
|
|
reason and in the same direction: `discharge_permitted()` treats a None floor
|
|
as no permission, so an unknown floor withholds rather than defaults.
|
|
|
|
⚠️ AT N=1 THIS IS `soc_floor` ITSELF under any config rule 8 accepts, and
|
|
test_peakguard.py part 12 asserts that over the whole schema range rather
|
|
than assuming it.
|
|
"""
|
|
if not packs:
|
|
return None if soc_floor is None else float(soc_floor)
|
|
floors = [pack_floor_pct(p, soc_floor) for p in packs]
|
|
if any(f is None for f in floors):
|
|
return None
|
|
return max(floors)
|
|
|
|
|
|
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.
|
|
# ⚠️ ON THE SITE ACTUATOR, EXPLICITLY. The billed quantity is the quarter's
|
|
# average offtake AT THE GRID CONNECTION - one per site, whatever the site
|
|
# has behind it - so this floor bounds the site target and is divided by the
|
|
# allocator along with it. It is never a bound on one unit's share: SAFETY-25
|
|
# §0c's table has this `lo` at the SITE stage precisely so the deferred loop
|
|
# reserve's per-inverter `hi` cannot become its counterpart in a `lo > hi`.
|
|
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"),
|
|
actuator=ACTUATOR_SITE)
|