SAFETY-07: cap the loop before it sets the month's billed capacity peak
checks / unit (pull_request) Successful in 3m7s
checks / unit (pull_request) Successful in 3m7s
TARIFF-01 measured the quarter and said the consumer would be this card. It is
one new module, app/peakguard.py, and one new claim.
THE MECHANISM. Every cycle the guard projects the open 900 s block to its
end-of-interval average - from P1 telegrams only, never from the command - and
compares it to a ceiling. Over the ceiling it files ONE arbiter claim: a lower
bound on the setpoint at P_PEAK. That single shape is both behaviours 10.1 asks
for, a hard clamp on charging and pre-emptive discharge, and it cannot cap
discharge or export, because a floor cannot. 9.1's asymmetry becomes a property
of the object instead of a rule to remember.
excess = 900 x (projected - ceiling) / seconds remaining
floor = MEASURED battery power + excess
The 900/remaining gain is 1.0 at the top of the quarter and grows, so the guard
is gentlest where its projection rests on the least evidence. The floor is sized
from delivered battery power, not the standing command: a floor built on intent
re-adds its own unmet ask every cycle and winds to the rail whenever the inverter
fails to follow, and test_peakguard.py part 1 shows the two diverging.
IT TRIPS, AND THE CAPTURES DECIDED THAT. On the healthy reference day it engages
on zero of 95 quarters. The action is level-triggered, unlatched and re-derived
every cycle, so a false engagement costs one cycle of discharge rather than a
parked battery - the cost SAFETY-18 could not bound and this one can. Replayed on
2026-08-20 the billed quarter goes from 5029.8 W, above the inverter's own 5000 W
rating, to 3485 W, under the soft limit and without saturating. Quarters under
the limit across both committed days: 189/190 -> 190/190. Two days is not thirty
and AC 3 remains TARIFF-01's open item.
BLINDNESS IS CHECKED BEFORE THE PROJECTION IS GRADED, and it is a different
action. A fresh averager projects a confident 0 W, which is under every ceiling
there is; a meter gap longer than meter_max_age_s makes the projection read LOW;
a frozen meter reading export projects 0 W at full coverage. All three become a
floor of 0 W - no importing while the billed quantity is unobservable - which is
one-directional and cannot cost pack energy. That is the backstop SAFETY-18 could
not be: its alarm arrives at 300 s of a 900 s quarter and commands nothing. The
frozen term is freeze.looks_frozen() itself, on this cycle's standing command.
THE CEILING IS peak_cap_w, RAISED ONLY BY AN OBSERVED READING. NO_RECORD and
NOT_OBSERVED both leave it at the cap, so an unreadable peak store makes the
guard stricter and never looser. The gate is the reading's STATE, not whether a
number happens to be attached: today a `peak_w is not None` test behaves
identically, and depending on that coincidence is how a future last-known-peak
field would raise the ceiling off a read that failed. headroom_w() is
deliberately not consumed - cap minus peak turns a zero peak into FULL freedom.
TWO DEFECTS FOUND BUILDING IT, both in the interaction rather than the
arithmetic, and both caught by running the thing rather than by reading it:
* A LIMIT does not out-vote another LIMIT. arbiter.resolve() applies every
limit at or above the winner's priority and takes the most restrictive, so
the peak floor (lo >= 0) and the maintenance charge-only shaping (hi <= 0)
resolved to a CONTRADICTION and 0 W - the battery parked in the exact quarter
it should shave. Fixed by suppression, not priority: main.py drops the
maintenance shaping while the guard is engaged, which is what that branch
already did for the optional peak_forecast_entity - a key empty on a default
install, so until now the suspension could not fire on the site that needed
it.
* Thresholding `excess > 0` released the claim the instant it began working,
producing seventeen engage/release cycles in one 900 s quarter and leaving
that quarter 49 W over the limit it had spent fifteen minutes holding. That
is 10.2's oscillation. The threshold is now the floor itself, which is the
same arithmetic read as a level rather than as a switch.
PeakTracker.project() no longer reads a private name with a default.
getattr(averager, "_last_net", 0.0) failed quiet AND disarming - a rename credits
0 W held, the projection reads low, the money guard does not engage.
QuarterAverager.held_net_w is a documented public property now, read without a
default, and Projection carries it. TEST-13 item 4.
NO NEW OPTION AND NO FOURTH DEFAULTS SITE. The ceiling is peak_cap_w, the
coverage threshold is meter_max_age_s, the freeze predicate is meter_freeze_s,
and the achievable discharge comes from max_w and the device rating. config.yaml
and validate.py are untouched; 14's capacity_peak_limit_kw stays declined. On
meter_source: off there are no telegrams, so the guard does not run at all and
the status page says NOT COVERED - running it there blinded it permanently and
stopped every legacy install from charging, which is what the first cut did until
test_clamp.py's export case caught it.
test_peakguard.py: eight parts, all figures derived from the shipped config.yaml
and the committed captures. Part 8 runs seven one-line defeats of the guard and
reports which check bites; six do, and the seventh is reported as NOT caught,
because max(cap, peak) makes a zero peak the strictest ceiling there is and there
is nothing left to catch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
This commit is contained in:
co-authored by
Claude Opus 5
parent
0dbd485b5a
commit
616b62d3e9
@@ -2,6 +2,73 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
**The grid-following loop is now capped against the month's billed capacity peak
|
||||
(SAFETY-07).** New `app/peakguard.py`. TARIFF-01 measured the quarter; this is the
|
||||
consumer it was built for. Every cycle the guard projects the open quarter,
|
||||
compares it to a ceiling and files ONE arbiter claim: a lower bound on the
|
||||
setpoint at `P_PEAK`. That shape is both behaviours §10.1 asks for — a hard clamp
|
||||
on charging and pre-emptive discharge — and it cannot cap discharge or export,
|
||||
because a floor cannot. `excess = 900 × (projected − ceiling) ÷ remaining`, and
|
||||
the floor is the MEASURED battery power plus that: a floor built on the standing
|
||||
command winds itself to the rail whenever the inverter fails to follow, and one
|
||||
built on delivered power cannot.
|
||||
|
||||
**It trips where SAFETY-18 only notifies, and the captures decided that.** On the
|
||||
healthy reference day it engages on zero of 95 quarters; the action is
|
||||
level-triggered, unlatched and re-derived every cycle, so a false engagement costs
|
||||
one cycle of discharge rather than a parked battery. Replayed on 2026-08-20 the
|
||||
billed quarter goes from **5029.8 W — above the inverter's own 5000 W rating — to
|
||||
3485 W**, under the soft limit and without saturating. Quarters under the limit
|
||||
across both committed days: 189/190 → 190/190. Two days is not thirty; AC 3 is
|
||||
still TARIFF-01's open item and this does not claim it.
|
||||
|
||||
**Blindness forbids importing, and it is checked before the projection is
|
||||
graded.** A fresh averager projects a confident 0 W, which is under every ceiling
|
||||
there is; a meter gap longer than `meter_max_age_s` makes the projection read LOW;
|
||||
and a frozen meter reading export projects 0 W at full coverage. All three become
|
||||
a floor of 0 W, which is one-directional and cannot cost pack energy. That is the
|
||||
backstop SAFETY-18 could not be — its alarm arrives at 300 s of a 900 s quarter
|
||||
and commands nothing. The frozen term is `freeze.looks_frozen()` itself, evaluated
|
||||
on this cycle's standing command, not a second copy of the conjunction.
|
||||
|
||||
**The ceiling is `peak_cap_w`, raised only by an OBSERVED reading.** `NO_RECORD`
|
||||
and `NOT_OBSERVED` both leave it at the cap, so an unreadable peak store makes the
|
||||
guard STRICTER. The gate is the reading's STATE, not whether a number happens to
|
||||
be attached to it: today a `peak_w is not None` test behaves identically, and
|
||||
depending on that coincidence is how a future last-known-peak field would silently
|
||||
raise the ceiling off a read that failed. `headroom_w()` is deliberately not
|
||||
consumed — `cap − peak` turns a zero peak into FULL freedom.
|
||||
|
||||
**Two defects found building it, both in the interaction rather than the
|
||||
arithmetic.** A LIMIT does not out-vote another LIMIT — `arbiter.resolve()` applies
|
||||
every limit at or above the winner's priority and takes the most restrictive — so
|
||||
the peak floor (`lo ≥ 0`) and the maintenance charge-only shaping (`hi ≤ 0`)
|
||||
resolved to a CONTRADICTION and 0 W, parking the battery in the exact quarter it
|
||||
should shave. Fixed by suppression rather than priority: main.py drops the
|
||||
maintenance shaping while the guard is engaged, which is what that branch already
|
||||
did for the optional `peak_forecast_entity` — a key that is empty on a default
|
||||
install, so until now the suspension could not fire on the site that needed it.
|
||||
And thresholding `excess > 0` released the claim the instant it started working,
|
||||
producing seventeen engage/release cycles in one 900 s quarter (§10.2's
|
||||
oscillation) and leaving that quarter 49 W over; the threshold is now the floor
|
||||
itself, which is the same arithmetic read as a level rather than as a switch.
|
||||
|
||||
**`PeakTracker.project()` no longer reads a private name with a default.**
|
||||
`getattr(averager, "_last_net", 0.0)` failed quiet AND in the disarming direction:
|
||||
a rename credits 0 W held, the projection reads low, and the money guard does not
|
||||
engage. `QuarterAverager.held_net_w` is now a documented public property, read
|
||||
without a default, and `Projection` carries it. TEST-13 item 4.
|
||||
|
||||
**No new option, and no fourth defaults site.** The ceiling is `peak_cap_w`, the
|
||||
coverage threshold is `meter_max_age_s`, the freeze predicate is `meter_freeze_s`,
|
||||
and the achievable discharge comes from `max_w` and the device rating.
|
||||
`config.yaml` and `validate.py` are untouched; §14's `capacity_peak_limit_kw`
|
||||
stays declined as the kW/W mismatch trap it is. On `meter_source: off` there are
|
||||
no telegrams, so the guard does not run at all and the status page says NOT
|
||||
COVERED — running it there would have blinded it permanently and stopped every
|
||||
legacy install from charging, which is what the first cut did until
|
||||
`test_clamp.py`'s export case caught it.
|
||||
|
||||
**The capacity-tariff peak is now measured, banked and survives a restart
|
||||
(TARIFF-01).** New `app/tariff.py`. The 15-minute block average itself was
|
||||
already built and unwired - `p1.QuarterAverager` computes clock-aligned 900 s
|
||||
|
||||
Reference in New Issue
Block a user