From 96ca66fc38f4f54e63fe4c0dea3dc8350aac8582 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sun, 1 Nov 2026 01:13:48 +0100 Subject: [PATCH] MAINT-20 part 1: peakguard.py comments and docstrings cut to a line and a pointer Co-Authored-By: Claude --- goodwe_controller/app/peakguard.py | 966 +++-------------------------- 1 file changed, 96 insertions(+), 870 deletions(-) diff --git a/goodwe_controller/app/peakguard.py b/goodwe_controller/app/peakguard.py index 48f1971..a565954 100644 --- a/goodwe_controller/app/peakguard.py +++ b/goodwe_controller/app/peakguard.py @@ -1,403 +1,7 @@ -"""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. - SAFETY-90: once readings are back, main.py counts those seconds - (`fill_gaps()`), so this holds only while the gap lasts. - - * 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. -""" +"""SAFETY-07 - the quarter-peak projection guard: one arbiter `lo` limit at +`P_PEAK` from tariff.py's measured month peak. Pure: no I/O, clock or state. +Why a limit, the floor's derivation, blindness, the SoC reserve and the +options: DECISIONS/SAFETY-07-peak-guard.md.""" from dataclasses import dataclass, replace @@ -406,77 +10,46 @@ 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. +# Why the projection may not be graded; the status page and log say which. 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. +# Where the ceiling came from. Only an OBSERVED reading may relax 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. +# SAFETY-24. Why a forced discharge may not be sized. 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. +# The words are "the peak reserve", not "the SoC floor": `peak_soc_floor` +# bounds this guard only (SAFETY-25). 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. +# Mirror of config.yaml's peak options, pinned to the file by +# test_peakguard.py. +# Not a read: `decide()` takes them as arguments. 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. + # Percent a forced discharge may not go below; see + # DECISIONS/SAFETY-07-peak-guard.md. "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. + # The pack's own cut-out in percent; rule 8 of validate.py refuses a + # `peak_soc_floor` at or below it. "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. + # SAFETY-79. Watts the guard aims under `peak_cap_w`. "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. +# SAFETY-79. Range of `peak_margin_w` in config.yaml, pinned by +# test_peakguard.py. 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. - """ + """What the guard concluded this cycle. No `floor_w_or(default)`: turning "not + measured" into a number is the defect `PeakReading` removes.""" engaged: bool floor_w: float | None # the `lo` the claim carries; None = no claim @@ -488,26 +61,21 @@ class PeakDecision: 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. - # SAFETY-87: at N>=2 it may ask the batteries above their reserve for - # `able_w`, all they have, when that is not enough (`place_on_able()`). + # SAFETY-24: a forced discharge was wanted and the peak reserve refused it; + # excludes `saturated`. SAFETY-87: at N>=2 the others may be asked up to + # `able_w`. floor_limited: bool = False soc: float | None = None soc_floor: float | None = None soc_floor_reason: str | None = None reason: str = "" - # SAFETY-87, N>=2 only: the batteries at or under their own reserve, - # which give nothing for the peak, and what the others can give (None - # when they are not asked). See `place_on_able()`. + # SAFETY-87, N>=2: batteries at or under their reserve, and what the others + # can give. held: tuple = () able_w: float | None = None - # SAFETY-126: the guard wants power, but with the batteries at 0 W the - # quarter still ends within ALARM_MISS_W of the aim (`misses_aim()`), as - # for a landing error in its last second. Set by main.py. No alarm and no - # "Why" peak while it holds; the floor and the claim are unchanged. + # SAFETY-126: power is wanted but the quarter still ends within + # ALARM_MISS_W + # of the aim with the batteries at 0 W (`misses_aim()`). Set by main.py. within_aim: bool = False def explain(self) -> str: @@ -535,13 +103,8 @@ def blind_kind(blind: str) -> str: 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. - """ + """The average this quarter may not exceed, and where it came from. + Only an OBSERVED reading raises it; a failed read keeps the cap (stricter).""" 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): @@ -551,25 +114,13 @@ def quarter_ceiling_w(cap_w, reading) -> tuple[float, str]: 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`).""" + `quarter_ceiling_w()`. 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. - """ + """Watts that must come off the meter now to land the quarter on the ceiling: + `900 * (projected - ceiling) / remaining`; 0.0 when `remaining <= 0`.""" remaining = QUARTER_S - float(projection.elapsed_s) if remaining <= 0.0: return 0.0 @@ -577,23 +128,14 @@ def excess_w(projection, ceiling_w) -> float: 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. - """ + """Seconds of the open block the meter did not cover (`covered_frac` of `elapsed_s`).""" 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. - """ + """Why this projection may not be graded, or None. Checked first: a fresh + averager's all-zero `Projection` sits under every ceiling.""" if frozen: return BLIND_FROZEN if projection.block_start is None: @@ -606,8 +148,7 @@ def blindness(projection, max_hold_s, frozen: bool) -> str | None: return None -# SAFETY-90. Seconds of uncounted time below which nothing is filled: float -# noise between the averager's sums and the wall clock, never a real gap. +# SAFETY-90. Uncounted seconds below this are float noise, never a gap. GAP_TOL_S = 1e-3 @@ -621,17 +162,11 @@ def gap_w(ceiling_w, *seen_w) -> float: def fill_gaps(projection, *, tail_s, max_hold_s, unseen_s, gap_w, unseen_w, held_ws=0.0): - """SAFETY-90: `projection` with the seconds the averager did not count - (a meter gap past `max_hold_s`) counted at `gap_w`, and those from before - this run's first reading (`unseen_s`) at `unseen_w`. `held_ws` is added - too: what the gap's first `max_hold_s`, credited at the reading before - it, lacks to reach `gap_w`. Returns - `(projection, gap seconds filled, unseen seconds filled)`. - - `tail_s` is the age of the held reading: up to `max_hold_s` of it is left - as before (the averager credits it once the next reading lands). Past - that the gap is still open, nothing is filled and `blindness()` answers - as before. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md.""" + """SAFETY-90: `projection` with the meter-gap seconds the averager did not + count filled at `gap_w` (those before the first reading, `unseen_s`, at + `unseen_w`), plus `held_ws`. Returns `(projection, gap s, unseen s)`. + `tail_s`: up to `max_hold_s` of the held reading is left as before; past + that nothing is filled. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md.""" if projection.block_start is None or max_hold_s is None: return projection, 0.0, 0.0 wall = float(projection.elapsed_s) @@ -658,32 +193,9 @@ def fill_gaps(projection, *, tail_s, max_hold_s, unseen_s, gap_w, unseen_w, 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". - """ + """SAFETY-24. May the guard size a forced discharge? None if so, else the reason. + `None` for `soc` or `soc_floor` is NOT permission, and neither has a default. + `<=`: a pack exactly on the floor has spent what the guard may spend.""" if soc is None: return FLOOR_SOC_UNREADABLE if soc_floor is None: @@ -696,25 +208,8 @@ def discharge_permitted(soc, soc_floor) -> str | 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. - """ + """SAFETY-32. One pack's numbers for the floor question. + `bms_floor` is its own cut-out percent; None when the roster states none.""" name: str soc: float | None @@ -722,74 +217,26 @@ class Pack: 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. - """ + """The percent THIS pack may not be spent below, or None if unknown: + `max(site EMS floor, its own cut-out)`. An unstated cut-out is None, not 0 + (`discharge_permitted()` reads None as no permission). The reduction is + written out because it must not depend on validate.py rule 8.""" 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()`. - """ + """SAFETY-32. Per pack, in roster order: None if it may be spent, else why not. + Unlike the `min()` over SoC, it keeps which pack and which floor.""" 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. - """ + """The one floor `decide()` grades `pack_floor_soc` against: the MAXIMUM of the + packs' floors, so never more permissive than grading each pack. None if any + floor is unknown. At N=1 it is `soc_floor` (test_peakguard.py part 12).""" 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] @@ -839,25 +286,10 @@ 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. - """ + `batt_w` measured battery power, positive = discharge; None invents no floor. + `achievable_w` largest discharge the arbiter could grant; the floor is + clamped to it so the claim never produces `lo > hi` (resolves to 0 W). + `soc` percent; None is floor-limited. `soc_floor`, `margin_w`: required.""" 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" @@ -865,9 +297,8 @@ def decide(projection, *, batt_w, cap_w, margin_w, reading, achievable_w, soc, 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. + # 0.0: nothing trustworthy to size a floor from; forbid charging, which + # costs no 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, @@ -877,17 +308,14 @@ def decide(projection, *, batt_w, cap_w, margin_w, reading, achievable_w, soc, 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. + # main.py's P_SAFETY stop already owns a missing input; file nothing. 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. + # A block with no seconds left cannot be influenced (`excess_w` answers + # 0.0). return PeakDecision( engaged=False, floor_w=None, ceiling_w=ceiling, ceiling_from=from_where, target_w=target, projected_w=projected, @@ -896,22 +324,10 @@ def decide(projection, *, batt_w, cap_w, margin_w, reading, achievable_w, soc, 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. + # The test is on the floor (`batt_w + excess`), not on `excess > 0`: + # excess falls to zero once the battery is removing it, and releasing + # there cycled the guard 17 times in one quarter (2026-08-20). + # DECISIONS/SAFETY-07-peak-guard.md. return PeakDecision( engaged=False, floor_w=None, ceiling_w=ceiling, ceiling_from=from_where, target_w=target, projected_w=projected, @@ -919,26 +335,18 @@ def decide(projection, *, batt_w, cap_w, margin_w, reading, achievable_w, soc, 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. + # SAFETY-24, only here: `wanted > 0`, so a forced discharge is really asked + # for. 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. + # 0.0, never negative: forbids charging only. It bounds the guard, not + # the loop. 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 " @@ -962,36 +370,21 @@ def decide(projection, *, batt_w, cap_w, margin_w, reading, achievable_w, soc, 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. +# SAFETY-81. A quarter must end this many W over the real limit (and for +# ALARM_HOLD_S, so a hob cycling does not count) before "Load too high" fires. 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. - """ + """SAFETY-81. May "Load too high for battery" fire? Only if, with the battery at + full power, the quarter still ends over the real limit by `ALARM_MISS_W`. + No `decide()` gain; `batt_w` is the lower of two cycles' readings. + DECISIONS/SAFETY-81-load-too-high.md.""" remaining = QUARTER_S - float(projection.elapsed_s) if batt_w is None or remaining <= 0.0: return False @@ -1003,147 +396,21 @@ def load_too_high(projection, *, batt_w, ceiling_w, achievable_w) -> bool: def misses_aim(projection, *, batt_w, target_w) -> bool: - """SAFETY-126. With the batteries at 0 W from now on, does the quarter - end more than `ALARM_MISS_W` over the aim (`target_w`)? `decide()`'s - `wanted > 0` asks the same question with no margin and with its gain, so a - 1 W landing error in a quarter's last second reads as tens of kW. A grid - charge the add-on runs counts as stoppable: it is in `batt_w`. The caller - passes the lower of two cycles' battery readings, as for - `load_too_high()`. DECISIONS/SAFETY-126-landing-error-is-not-an-overrun.md.""" + """SAFETY-126. With the batteries at 0 W from now on, does the quarter end more + than `ALARM_MISS_W` over the aim? + DECISIONS/SAFETY-126-landing-error-is-not-an-overrun.md.""" return load_too_high(projection, batt_w=batt_w, ceiling_w=target_w, achievable_w=0.0) 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. + quarter over the ceiling, in W; None when nothing measured it (not unlimited). - 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. - """ + It is `wanted_w` read the other way, independent of the battery, so sizing a + trade to it cannot oscillate. 0.0 when blind or holding. A SIZING, NOT A + CLAIM: a negative `lo` would cross the maintenance shaping's negative `hi` + (0 W, battery parked). DECISIONS/SAFETY-45-sizing-the-trade.md section 2b.""" if decision is None: return None if decision.blind: @@ -1154,10 +421,6 @@ def charge_headroom_w(decision) -> float | None: def charge_room_w(decision, projection) -> float | None: - """SAFETY-125: `charge_headroom_w()` with the seconds since the last - meter reading counted at that reading, so a late reading near a - quarter's end is not read as room for a grid charge (SAFETY-99). - DECISIONS/SAFETY-125-grid-charge-under-the-month-peak.md.""" room = charge_headroom_w(decision) if not room or projection is None or projection.block_start is None: return room @@ -1170,59 +433,22 @@ def charge_room_w(decision, projection) -> float | None: 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. - """ + Crossing limits resolve to 0 W, so the rule is suppression, not priority. + Both terms are needed: the forecast entity is empty on a default install, + where the measured guard fires. Dropping either parks the battery in the + billed quarter (test_peakguard.py part 7). DECISIONS/SAFETY-07-peak-guard.md.""" 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. - """ + """The decision as one arbiter claim (`lo` only, `hi` stays `+inf`), or None.""" 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`. + # Blind, floor-limited and a genuine zero floor all file `lo = 0 W`; each + # gets its own reason. On the SITE actuator: the billed quantity is one per + # site. return Claim.limit(SOURCE, P_PEAK, lo=float(decision.floor_w), reason=("blind" if decision.blind else "SoC floor" if decision.floor_limited