Files
goodwe-addon/goodwe_controller/app/peakguard.py
T
glenn schrooyenandClaude Opus 5 2d570d84ce SAFETY-07 review: defend the three wiring mutations that survived
The reviewer's finding was correct and the shape of it matters: everything the
first cut graded was in peakguard.py, and grading the module is not the same as
grading the guard. Three one-line changes to main.py's CALL SITE left all twelve
suites green while a default install parked at 0 W in the quarter that sets the
month's bill.

B2 was the serious one. My own comment described that defect - "that entity is
optional and empty on a default install, so until now the suspension could not
fire on the site that needed it" - and nothing could regress it. A comment that
documents a fix nobody can test is the same shape as a corrected comment nobody
tests, which this project has already been caught by once.

WHAT CHANGED

The suppression conjunction moved out of main.py and into
peakguard.shaping_suspended(), for the reason watchdog.loop_is_following() and
freeze.looks_frozen() live where they do: the check that grades a safety
predicate must assert on THE SHIPPED EXPRESSION, not on a transcription that can
drift apart from it in silence. Its docstring carries both terms and why neither
is sufficient alone. main.py's call site now says READ THAT FUNCTION FIRST, and
records that a reviewer mutated it back to its historical broken form with every
suite staying green.

test_peakguard.py part 7 gains a section that EXECUTES the three mutations
rather than describing them. Each one is put in place on the shipped module and
the REAL Controller.cycle() is driven with it, on the dangerous fixture: a
default install (no peak_forecast_entity, so peak_fc is None and peak_at_risk is
False forever) with maintenance in its CHARGE phase, so a P_MAINT_SHAPE `hi <= 0`
limit is live, and the guard genuinely engaged on the measurement. That the
mutations are monkeypatched onto the shipped module is itself load-bearing: if
main.py inlined either expression again the patch would do nothing and these
checks would pass vacuously.

    SHIPPED   suspended=True    loop -> 1498 W, limited by peak    +1497.8 W
    B1        suspended=False   CONTRADICTION between limits           0.0 W
    B2        suspended=False   CONTRADICTION between limits           0.0 W
    B2b (forecast term dropped instead) - caught by its own check

B3 is graded three ways, because the single-cycle form is only INFLATED and the
failure is what happens on the next cycle:

  * the value main.py actually hands decide() is recorded off the real call, with
    the measured battery at 0 W and the command standing at +3000 W;
  * the one-cycle difference is asserted to be exactly the standing command - the
    whole defect in one line, watts that were asked for and never arrived counted
    as if they had;
  * both forms are then run forward against the REAL controller's own projection
    with an inverter that never follows:

        intent-sized floor    4482 5000 5000 5000 ...  SATURATED at cycle 2
        delivered-power floor 1482 1482 1482 1482 ...  stationary

  * and the call site is graded on parsed syntax, so a rename cannot quietly
    reintroduce it: `decide(batt_w=...)` must unparse to `self.batt`.

SAFETY-24 is now named in peakguard.py, in its own section, with the number that
makes it real - 878 Wh of forced discharge on the 2026-08-20 replay - and the
statement that this must not go to a client install before that ticket lands. A
ticket nobody can find from the code is a ticket nobody reads before shipping.

RED AND GREEN

Fifteen mutations run against the shipped modules, each reverted after: the six
new wiring ones (B1, B2, B2b, B3, B3b, and deleting the SAFETY-24 pointer) and
the eight from the first round, re-run to confirm they still bite. All fifteen
turn test_peakguard.py red. Full suite green, test_price.py and TARIFF-03's
included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 23:40:39 +02:00

518 lines
28 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.
------------------------------------------------------------------------------
⚠️ WHAT THIS GUARD DOES NOT HAVE: AN ENERGY BACKSTOP. SEE SAFETY-24.
The graded branch can FORCE DISCHARGE, and nothing in this file or in the loop
below it consults the state of charge - `soc_floor` belongs to the maintenance
cycle, and freeze.py already records that for the grid-following loop "the BMS
is the sole energy backstop". On the 2026-08-20 replay the guard asks for 878 Wh
in a day. Against a pack sitting near 9 % that is not nothing, and the guard will
keep asking as long as the meter says the quarter is over the ceiling.
That is deliberate for THIS card and it is filed as SAFETY-24. It is also the
reason this must not go to a client install before SAFETY-24 lands: on the
reference site the BMS is a known quantity and an operator is present, and on a
client site neither is true.
------------------------------------------------------------------------------
AND IT READS NO PER-PHASE QUANTITY, ON PURPOSE
The sum/max/net reduction is an open product question with the owner. TARIFF-01
put it behind `billed_w()` and defaulted to NET. Everything this module consumes
- `PeakReading.peak_w`, `Projection.projected_w` - is already reduced by that one
function, so whichever way the owner answers, this file does not change and does
not need re-testing. Nothing here indexes a phase.
"""
from dataclasses import dataclass
from .arbiter import P_PEAK, Claim
from .control import peak_at_risk
from .tariff import QUARTER_S
# Why the projection may not be graded. Reasons, not booleans: the status page
# and the action log both have to say WHICH, and "blind" alone is unactionable.
BLIND_NO_BLOCK = "no open quarter - the meter has produced no sample yet"
BLIND_COVERAGE = "meter gap"
BLIND_FROZEN = "SAFETY-18: the meter is answering without changing"
# Where the ceiling came from. Only the second is a relaxation, and only an
# OBSERVED reading may produce it.
CEILING_CAP = "peak_cap_w"
CEILING_MONTH_PEAK = "the month's banked peak"
SOURCE = "peak"
@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
reason: str = ""
def explain(self) -> str:
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 decide(projection, *, batt_w, cap_w, reading, achievable_w,
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.
"""
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")
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,
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
return Claim.limit(SOURCE, P_PEAK, lo=float(decision.floor_w),
reason=("blind" if decision.blind else "quarter peak"))