- The alarm must hold for peakguard.ALARM_HOLD_S (10 s) on the projection's
clock. A load that tops cap + battery for ~5 s every 30 s alarmed at every
quarter start while the quarter landed under the aim (sine4500_fast).
- A cycle with no battery reading leaves the hold alone.
- Notified once per quarter, but the ERROR line is written per episode.
- New sim scenarios (sine4500_fast, twice7000, step6500_gap) with 5a4cdc2
digests; control still byte-identical. Unit checks for export and the
hold across a missing reading. test_peakguard's saturated fixture moves
the hold start back instead of waiting it out.
- DECISIONS and DOCS updated: costs of the hold, tolerance reasoning, the
double count in control left to a follow-up.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1102 lines
61 KiB
Python
1102 lines
61 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)
|
|
|
|
SAFETY-79: the guard AIMS `peak_margin_w` under that ceiling
|
|
(`quarter_target_w()`), so a quarter that lands a little high still stays under
|
|
it. `ceiling_w` stays the real limit for reports and the bill.
|
|
|
|
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 . import messages
|
|
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 15-minute period, the meter has given no reading yet"
|
|
BLIND_COVERAGE = "meter gap"
|
|
BLIND_FROZEN = "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 = "this month's 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 level was not read this cycle, and "
|
|
"unread is not full")
|
|
# ⚠️ SAFETY-25. "the peak reserve", not "the SoC floor", and the name is
|
|
# load-bearing: this string is what the action log and the add-on log print,
|
|
# and `peak_soc_floor` (“Peak reserve”) 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 = "the battery level is at or below the peak reserve"
|
|
|
|
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,
|
|
# SAFETY-79. Watts the guard aims under `peak_cap_w`, so a quarter that
|
|
# lands a little high still stays under the cap. Owner decision 2026-09-28.
|
|
"peak_margin_w": 50,
|
|
}
|
|
# SAFETY-79. config.yaml's schema range for `peak_margin_w`, mirrored so
|
|
# validate.py can refuse it in words; test_peakguard.py pins it to the file.
|
|
PEAK_MARGIN_RANGE_W = (0, 500)
|
|
|
|
|
|
@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
|
|
target_w: float | None = None # SAFETY-79: what the guard aims at
|
|
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:
|
|
"""Idle: the status row's plain words (UX-08). Engaged: `reason`, the
|
|
technical record with every figure, for the log and the action log."""
|
|
if self.floor_limited:
|
|
return self.reason
|
|
if not self.engaged:
|
|
if self.projected_w is None:
|
|
return messages.row_peak_guard_quiet()
|
|
return messages.row_peak_guard_idle(
|
|
self.projected_w, self.ceiling_w,
|
|
month_peak=self.ceiling_from == CEILING_MONTH_PEAK)
|
|
return self.reason
|
|
|
|
|
|
def blind_kind(blind: str) -> str:
|
|
"""Which of the three kinds of blindness, as a key of
|
|
`messages.BLIND_WORDS`, for the status row."""
|
|
if blind == BLIND_FROZEN:
|
|
return "frozen"
|
|
if blind == BLIND_NO_BLOCK:
|
|
return "no_block"
|
|
return "gap"
|
|
|
|
|
|
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 quarter_target_w(cap_w, margin_w, reading) -> float:
|
|
"""SAFETY-79. The average the guard AIMS at: `margin_w` under
|
|
`quarter_ceiling_w()`, so under a month peak already banked as well as
|
|
under the cap. Aiming AT a banked peak can bank a few W over it, which
|
|
raises the bill as surely as going over the cap. Reports and the bill keep
|
|
the real limit (`ceiling_w`)."""
|
|
return quarter_ceiling_w(cap_w, reading)[0] - float(margin_w)
|
|
|
|
|
|
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 15-minute period not "
|
|
f"covered, past the {float(max_hold_s):.0f} s a reading is "
|
|
f"held")
|
|
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, margin_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.
|
|
`margin_w` SAFETY-79. Watts to aim under `cap_w` (`peak_margin_w`).
|
|
REQUIRED for the same reason. `ceiling_w` stays the real
|
|
limit for every report; only `excess_w` uses the target.
|
|
"""
|
|
ceiling, from_where = quarter_ceiling_w(cap_w, reading)
|
|
target = quarter_target_w(cap_w, margin_w, reading)
|
|
aim = "" if target == ceiling else f", aiming at {target:.0f} W"
|
|
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,
|
|
target_w=target, blind=blind, projected_w=None,
|
|
reason=(f"PEAK_GUARD_BLIND: {blind}. The battery may not charge "
|
|
f"while this 15-minute period cannot be measured, so it "
|
|
f"is asked for no 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, target_w=target, 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, target_w=target, projected_w=projected,
|
|
reason="the quarter has no seconds left to influence")
|
|
|
|
ex = excess_w(projection, target)
|
|
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, target_w=target, 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, target_w=target, projected_w=projected,
|
|
excess_w=ex,
|
|
wanted_w=wanted, floor_limited=True, soc=soc, soc_floor=soc_floor,
|
|
soc_floor_reason=denied,
|
|
# UX-10: plain sentences, every figure kept (test_messages.py
|
|
# part 6). The code is one token, so it is not read as shouting.
|
|
reason=(
|
|
f"PEAK_GUARD_FLOOR_LIMITED: this 15-minute period heads for "
|
|
f"{projected:.0f} W against a {ceiling:.0f} W limit "
|
|
f"({from_where}){aim}, and {wanted:+.0f} W from the battery "
|
|
f"would hold it. But {denied}"
|
|
+ ("" if soc is None else f", at {float(soc):.0f} %")
|
|
+ ("" if soc is None or soc_floor is None
|
|
else f" against {float(soc_floor):.0f} %")
|
|
+ ", so no forced discharge and no charging, and this period "
|
|
"will go over the limit"))
|
|
|
|
floor = min(wanted, ceil_top)
|
|
saturated = floor < wanted
|
|
return PeakDecision(
|
|
engaged=True, floor_w=floor, ceiling_w=ceiling, ceiling_from=from_where,
|
|
target_w=target, blind=None, projected_w=projected, excess_w=ex,
|
|
wanted_w=wanted, saturated=saturated, soc=soc, soc_floor=soc_floor,
|
|
reason=(
|
|
f"PEAK_GUARD: this 15-minute period heads for {projected:.0f} W "
|
|
f"against a {ceiling:.0f} W limit ({from_where}){aim}, with "
|
|
f"{QUARTER_S - projection.elapsed_s:.0f} s left, so {ex:.0f} W must "
|
|
f"come off the meter. The battery measured {float(batt_w):+.0f} W "
|
|
f"and is asked for {floor:+.0f} W"
|
|
# Saturated means floor == ceil_top: the ask IS the most the
|
|
# inverters give, so one clause says both.
|
|
+ (f", the most the inverters give, but {wanted:+.0f} W was needed"
|
|
if saturated else "")))
|
|
|
|
|
|
# SAFETY-81. A quarter must end this many W over the real limit before
|
|
# "Load too high for battery" fires. Above the guard's own landing error at
|
|
# margin 0 (up to +5.7 W, SAFETY-79 test check 5), which margin 0 accepts.
|
|
ALARM_MISS_W = 10.0
|
|
# ...and for this long without a break, so a load that peaks for a few seconds
|
|
# (a hob or heater cycling) is not read as the rest of the quarter.
|
|
ALARM_HOLD_S = 10.0
|
|
|
|
|
|
def load_too_high(projection, *, batt_w, ceiling_w, achievable_w) -> bool:
|
|
"""SAFETY-81. May "Load too high for battery" fire? Only if, with the
|
|
battery at full power from now on, the quarter still ends more than
|
|
`ALARM_MISS_W` over the real limit (`ceiling_w`, not the aim).
|
|
|
|
This is the miss itself, in W of the quarter average, so it does not have
|
|
`decide()`'s gain (900 / remaining): near a quarter's end that gain turns
|
|
a watt over the aim into thousands of W wanted.
|
|
|
|
The load is the held meter reading plus `batt_w`. The caller passes the
|
|
lower of this cycle's and the last cycle's battery reading, so a meter
|
|
sample taken before the battery's last increase is not counted twice
|
|
(DECISIONS/SAFETY-81-load-too-high.md). A real overload shows on the meter
|
|
before the battery moves, so that filter costs it no cycle.
|
|
"""
|
|
remaining = QUARTER_S - float(projection.elapsed_s)
|
|
if batt_w is None or remaining <= 0.0:
|
|
return False
|
|
rest = float(projection.held_w) + float(batt_w) - float(achievable_w)
|
|
done_ws = (QUARTER_S * float(projection.projected_w)
|
|
- max(float(projection.held_w), 0.0) * remaining)
|
|
ends_w = (done_ws + max(rest, 0.0) * remaining) / QUARTER_S
|
|
return ends_w - float(ceiling_w) > ALARM_MISS_W
|
|
|
|
|
|
def charge_headroom_w(decision) -> float | None:
|
|
"""SAFETY-45. The hardest this SITE may charge without putting the billed
|
|
quarter over the ceiling, in W, positive - or None when nothing measured it.
|
|
|
|
THE ONE PLACE THIS QUESTION IS ANSWERED, and it is public for the reason
|
|
`discharge_permitted()`, `shaping_suspended()`, `guard_state()` and
|
|
`looks_frozen()` are: the check that grades it has to assert on THE SHIPPED
|
|
EXPRESSION, and a predicate transcribed into its own test is one copy plus a
|
|
future disagreement about what the check means.
|
|
|
|
------------------------------------------------------------------------
|
|
THE DEFECT IT PREVENTS, AND IT IS NOT A NEW IDEA IN THIS REPOSITORY
|
|
|
|
`control.maintenance_charge_floor()` already caps MAINTENANCE charging at
|
|
"the headroom left under the site's cap", and its docstring says why in
|
|
words this card only has to widen:
|
|
|
|
"A maintenance charge is the only thing this system does that is big
|
|
enough and long enough to set that peak, so it is capped by the
|
|
headroom left under the site's cap."
|
|
|
|
⚠️ THAT SENTENCE STOPPED BEING TRUE WHEN OPT-04 LANDED. Price arbitrage is a
|
|
SECOND thing that is big enough and long enough - it commands `max_w` for a
|
|
whole tariff interval, deliberately, against the meter - and it was given no
|
|
such cap. Measured on TEST-23 stage 3's closed rig at the shipped
|
|
`peak_cap_w: 3500` / `max_w: 2000`: 28 charge onsets in one hour, mean period
|
|
131 s, grid swinging -456 <-> +3200 W, on any house drawing over 1500 W in
|
|
the cheapest quarter. §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."
|
|
|
|
⚠️ AND IT IS THE **MEASURED** GUARD, NOT `peak_forecast_entity`.
|
|
`maintenance_charge_floor()` sizes itself from the forecast sensor, which is
|
|
OPTIONAL and empty on a default install - the exact hole SAFETY-25 had to
|
|
close in `shaping_suspended()`, where a suspension gated on the forecast
|
|
alone could not fire on the very site that needed it. This number comes off
|
|
`decide()`, which runs wherever P1 does.
|
|
|
|
------------------------------------------------------------------------
|
|
WHERE THE NUMBER COMES FROM: IT IS `wanted_w`, READ IN THE OTHER DIRECTION
|
|
|
|
`decide()` already computes
|
|
|
|
wanted = batt_w + excess = (the offtake there would be with the battery
|
|
at zero) - (the level this quarter can still afford)
|
|
|
|
as the battery DISCHARGE that would land the quarter exactly on the ceiling.
|
|
A negative `wanted` is not "nothing to say" - it is a permitted CHARGE of
|
|
`-wanted` watts, and throwing it away is SAFETY-45's whole defect. So this
|
|
function invents no arithmetic; it reads the sign the guard already had.
|
|
|
|
⚠️ AND THE QUANTITY IS INVARIANT IN THE BATTERY, WHICH IS WHY SIZING A TRADE
|
|
TO IT CANNOT OSCILLATE. Raise the charge by dW and the meter rises by dW, so
|
|
`excess` rises by exactly dW while `batt_w` falls by dW: `wanted` does not
|
|
move. It is a FEED-FORWARD number - a function of the house, the ceiling and
|
|
the seconds already banked - so consuming it adds no feedback path around the
|
|
plant. The fixed point is `charge = ceiling - load`, with the meter sitting
|
|
ON the ceiling and `wanted` parked at `load - ceiling`, a whole `ceiling -
|
|
load` clear of the engagement boundary rather than chattering across it.
|
|
|
|
⚠️ AND A QUARTER BOUNDARY DOES NOT HAND IT BACK AT FULL POWER, WHICH IS THE
|
|
OTHER HALF OF SAFETY-45 AND IS NOT THE SAME CLAIM AS "IT DOES NOT CHANGE".
|
|
It DOES change - it is `(900 / remaining) * (ceiling - projected)`, so it is
|
|
largest in the last seconds of a block and collapses at the boundary. What
|
|
matters is WHAT IT COLLAPSES TO: at `elapsed = 0` the projection IS the held
|
|
level, so the answer is `ceiling - (load - pv)` - the house and the cap, and
|
|
nothing else. A trade refused at 14:59 therefore resumes at 15:00 at the rate
|
|
the new quarter can afford, not at `max_w`, and §10.1's answer survives the
|
|
boundary because the arithmetic does: no timer, no backoff constant, no state
|
|
carried between calls.
|
|
|
|
⚠️ AND THE AMPLIFICATION IS BOUNDED IN ITS EFFECT, NOT MERELY LARGE. Hold a
|
|
charge of exactly this size for the rest of the block and the block's average
|
|
lands on `ceiling` EXACTLY, wherever in the block it started:
|
|
|
|
(acc + (held + headroom) * remaining) / 900
|
|
= projected + (900/remaining) * (ceiling - projected) * remaining/900
|
|
= ceiling
|
|
|
|
so 162 kW of headroom with 5 s left and 900 W of it with the whole quarter to
|
|
go are the same statement about the bill. `test_peakguard.py` part 13 sweeps
|
|
that identity across the block rather than arguing it here.
|
|
|
|
------------------------------------------------------------------------
|
|
THE THREE ANSWERS
|
|
|
|
`None` nothing measured it: the guard never ran (`meter_source: off`), or
|
|
it ran with no battery reading to size a floor from, or the block
|
|
had no seconds left to influence. ⚠️ None IS NOT UNLIMITED. A caller
|
|
that treats it as "charge away" is `arbitrage.guard_state()`'s
|
|
collapse of None into False, one layer down - a strategy creating
|
|
the billed quantity while nothing measures the quarter it lands in.
|
|
`0.0` measured, and the answer is no charging: the guard is BLIND (its
|
|
claim already says so at `floor 0 W` and this agrees with it rather
|
|
than restating it), or it is holding and `wanted >= 0`.
|
|
`> 0` the watts a charge may pull this cycle.
|
|
|
|
⚠️ HOLDING IMPLIES ZERO, BY CONSTRUCTION AND NOT BY COINCIDENCE. Every state
|
|
that files a claim with `engaged=True` has `wanted > 0`, `wanted is None` or
|
|
`blind`, so this answers 0.0 or None there and can never hand a strategy
|
|
permission the guard's own `lo` is at that instant refusing.
|
|
|
|
------------------------------------------------------------------------
|
|
⚠️ WHY THIS IS A SIZING AND NOT A CLAIM, AND DO NOT "SIMPLIFY" IT INTO ONE
|
|
|
|
The obvious tidier answer is to delete `decide()`'s `wanted <= 0` gate and
|
|
let it file `lo = wanted` unconditionally - a NEGATIVE floor, meaning "you
|
|
may charge, but no harder than this". The arithmetic is identical, it is
|
|
continuous, and it would bind every claimant rather than only arbitrage. It
|
|
was measured, considered and REJECTED, and the reason is not style:
|
|
|
|
* `main.py`'s cheap-window maintenance charge files
|
|
`Claim.limit("maintenance", P_MAINT_SHAPE, hi=-floor)` - a NEGATIVE `hi`.
|
|
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 out-ranking
|
|
another. A peak `lo = -300 W` against a shaping `hi = -1000 W` is
|
|
`lo > hi`: a CONTRADICTION, which resolves to 0 W - the battery PARKED,
|
|
in the one quarter of the month that sets the bill. That is the exact
|
|
failure `shaping_suspended()` exists to prevent, and the only way to
|
|
close it would be to key that suppression on "a claim exists", which on
|
|
every site this card is about suspends the maintenance charge-only
|
|
shaping PERMANENTLY.
|
|
* and it would change what `engaged` MEANS at five call sites outside this
|
|
module - `arbitrage.guard_state()`, `shaping_suspended()`, `claim()`,
|
|
main.py's edge-logged action line and main.py's status row - each of
|
|
which today reads it as "the guard is HOLDING".
|
|
|
|
`control.maintenance_charge_floor()` is a sizing and not a limit for the same
|
|
reason, which is why this is the shape that already fits.
|
|
DECISIONS/SAFETY-45-sizing-the-trade.md §2b carries the full argument.
|
|
"""
|
|
if decision is None:
|
|
return None
|
|
if decision.blind:
|
|
return 0.0
|
|
if decision.wanted_w is None:
|
|
return None
|
|
return max(0.0, -float(decision.wanted_w))
|
|
|
|
|
|
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)
|