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
468 lines
25 KiB
Python
468 lines
25 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.
|
|
|
|
------------------------------------------------------------------------------
|
|
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 .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 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"))
|