"""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. """ from dataclasses import dataclass, replace from . import messages from .arbiter import ACTUATOR_SITE, P_PEAK, Claim from .control import peak_at_risk from .tariff import QUARTER_S # Why the projection may not be graded. Reasons, not booleans: the status page # and the action log both have to say WHICH, and "blind" alone is unactionable. BLIND_NO_BLOCK = "no open 15-minute period, the meter has given no reading yet" BLIND_COVERAGE = "meter gap" BLIND_FROZEN = "the meter is answering without changing" # Where the ceiling came from. Only the second is a relaxation, and only an # OBSERVED reading may produce it. CEILING_CAP = "peak_cap_w" CEILING_MONTH_PEAK = "this month's peak" # SAFETY-24. Why the forced discharge may not be sized. Reasons, not booleans, # for BLIND_*'s reason: the status page, the action log and the operator # notification all have to say WHICH, and "floor-limited" alone is unactionable. FLOOR_SOC_UNREADABLE = ("the battery level was not read this cycle, and " "unread is not full") # ⚠️ SAFETY-25. "the peak reserve", not "the SoC floor", and the name is # load-bearing: this string is what the action log and the add-on log print, # and `peak_soc_floor` (“Peak reserve”) bounds THIS GUARD ONLY. Widening it to # "the SoC floor" would make the one sentence an operator reads at the moment # the battery stops shaving into a claim about the pack that the # grid-following loop is at that instant disproving. FLOOR_AT_SOC_FLOOR = "the battery level is at or below the peak reserve" SOURCE = "peak" # ⚠️ CFG-03. THE ONE IN-CODE MIRROR OF config.yaml FOR THIS CARD'S OPTIONS, and # it is TARIFF-02's `PRICE_OPTION_DEFAULTS` pattern for TARIFF-02's reason: the # add-on already carries option defaults in three places (config.yaml's # `options:`, config.yaml's `schema:`, and main.py's literal `.get()` # fallbacks), and a fourth is the open ticket. validate.py and main.py both # import THIS dict rather than typing a literal, and test_peakguard.py pins it # to config.yaml by READING the file - so a drift goes red instead of shipping. # # ⚠️ NOT A READ. `decide()` takes both values as arguments and this module makes # no `.get()` call at all; the dict is a default table for its two callers. PEAK_OPTION_DEFAULTS = { # The EMS-side floor on SAFETY-07's forced discharge, in percent. # # 20 is DERIVED from the two numbers this repository already has, not # chosen. The reference pack is 300 Ah at 51.2 V with DoD 90 - 13.8 kWh # usable, the figure validate.py's rule 6 note already quotes - and the # 2026-08-20 replay models 878 Wh of guard discharge in a day, which is # 6.4 % of that. A floor 10 points above the pack's own 10 % cut-out # therefore leaves the guard roughly 1.6 of its worst observed days of room # before it stops, which is the smallest margin that lets the guard do its # job on a bad day and still never reach the BMS. "peak_soc_floor": 20, # The pack's OWN cut-out, in percent: what the BMS enforces regardless of # anything here. On the reference unit this is register 0x0560, read as 10, # and it is consistent with DoD 90 on a full pack. It exists so validate.py # rule 8 can refuse a `peak_soc_floor` at or below it - see that rule. "bms_soc_floor": 10, # SAFETY-79. Watts the guard aims under `peak_cap_w`, so a quarter that # lands a little high still stays under the cap. Owner decision 2026-09-28. "peak_margin_w": 50, } # SAFETY-79. config.yaml's schema range for `peak_margin_w`, mirrored so # validate.py can refuse it in words; test_peakguard.py pins it to the file. PEAK_MARGIN_RANGE_W = (0, 500) @dataclass(frozen=True) class PeakDecision: """What the guard concluded this cycle, with everything it concluded it from. Every field is derived from a measurement taken in the same cycle. There is no `floor_w_or(default)` and there must not be one, for `PeakReading`'s reason: an accessor that turns "not measured" into a number re-creates the defect the type exists to remove. """ engaged: bool floor_w: float | None # the `lo` the claim carries; None = no claim ceiling_w: float ceiling_from: str blind: str | None = None projected_w: float | None = None excess_w: float = 0.0 wanted_w: float | None = None # the floor before `achievable_w` clamped it target_w: float | None = None # SAFETY-79: what the guard aims at saturated: bool = False # SAFETY-24. True when a forced discharge WAS wanted and the EMS-side SoC # floor refused to size it. Mutually exclusive with `saturated` by # construction: this branch asks for nothing, so it cannot have run out of # inverter. `soc_floor_reason` says WHICH of the two ways it bound. # 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()`). 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()`. held: tuple = () able_w: float | None = None def explain(self) -> str: """Idle: the status row's plain words (UX-08). Engaged: `reason`, the technical record with every figure, for the log and the action log.""" if self.floor_limited: return self.reason if not self.engaged: if self.projected_w is None: return messages.row_peak_guard_quiet() return messages.row_peak_guard_idle( self.projected_w, self.ceiling_w, month_peak=self.ceiling_from == CEILING_MONTH_PEAK) return self.reason def blind_kind(blind: str) -> str: """Which of the three kinds of blindness, as a key of `messages.BLIND_WORDS`, for the status row.""" if blind == BLIND_FROZEN: return "frozen" if blind == BLIND_NO_BLOCK: return "no_block" return "gap" def quarter_ceiling_w(cap_w, reading) -> tuple[float, str]: """The average this quarter may not exceed, and where the number came from. ONLY an OBSERVED reading may raise it. `NO_RECORD` (the store answered, this month has nothing yet) and `NOT_OBSERVED` (the store did not answer) both return the cap, which is the tighter answer. A failed read therefore makes the guard stricter, never looser - see the module note. """ cap = float(cap_w) if (reading is not None and getattr(reading, "is_observed", False) and reading.peak_w is not None and float(reading.peak_w) > cap): return float(reading.peak_w), CEILING_MONTH_PEAK return cap, CEILING_CAP def quarter_target_w(cap_w, margin_w, reading) -> float: """SAFETY-79. The average the guard AIMS at: `margin_w` under `quarter_ceiling_w()`, so under a month peak already banked as well as under the cap. Aiming AT a banked peak can bank a few W over it, which raises the bill as surely as going over the cap. Reports and the bill keep the real limit (`ceiling_w`).""" return quarter_ceiling_w(cap_w, reading)[0] - float(margin_w) def excess_w(projection, ceiling_w) -> float: """Watts that must come off the meter NOW to land the quarter on the ceiling. The whole control law, and the only place it is written down: excess = 900 * (projected - ceiling) / remaining Positive means the quarter is projected over; zero or negative means it is not. `remaining <= 0` is not a large number, it is an undefined one - a quarter with no seconds left cannot be influenced - so it answers 0.0 rather than dividing. """ remaining = QUARTER_S - float(projection.elapsed_s) if remaining <= 0.0: return 0.0 return QUARTER_S * (float(projection.projected_w) - float(ceiling_w)) / remaining def uncovered_s(projection) -> float: """Seconds of the open block the meter did not actually cover. Derived from what `Projection` already carries rather than re-measured: `covered_frac` is `elapsed / elapsed_wall`, and `elapsed_s` IS that wall. """ return max(0.0, float(projection.elapsed_s) * (1.0 - float(projection.covered_frac))) def blindness(projection, max_hold_s, frozen: bool) -> str | None: """Why this projection may not be graded, or None if it may be. Evaluated BEFORE the projection is compared to anything. Each of the three is a way for the projected number to be confident and wrong, and the first - a fresh averager's all-zero `Projection` - is confident, wrong AND under every ceiling there is. """ if frozen: return BLIND_FROZEN if projection.block_start is None: return BLIND_NO_BLOCK gap = uncovered_s(projection) if max_hold_s is not None and gap > float(max_hold_s): return (f"{BLIND_COVERAGE}, {gap:.0f} s of this 15-minute period not " f"covered, past the {float(max_hold_s):.0f} s a reading is " f"held") return None # 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. GAP_TOL_S = 1e-3 def gap_w(ceiling_w, *seen_w) -> float: """SAFETY-90: the watts a meter gap is counted at. The highest of the limit and `seen_w`, the import with the batteries at 0 W (grid + their discharge) just before and just after the gap; None entries are skipped. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md.""" return max([float(ceiling_w)] + [float(w) for w in seen_w if w is not None]) 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.""" if projection.block_start is None or max_hold_s is None: return projection, 0.0, 0.0 wall = float(projection.elapsed_s) tail = min(max(0.0, float(tail_s)), wall) if tail > float(max_hold_s): return projection, 0.0, 0.0 covered = wall - uncovered_s(projection) missing = wall - covered - tail if missing <= GAP_TOL_S: missing = 0.0 if held_ws <= 0.0: return projection, 0.0, 0.0 unseen = min(missing, max(0.0, float(unseen_s))) gap = missing - unseen add_ws = gap * float(gap_w) + unseen * float(unseen_w) + float(held_ws) done = covered + missing return (replace( projection, projected_w=float(projection.projected_w) + add_ws / QUARTER_S, elapsed_w=((float(projection.elapsed_w) * covered + add_ws) / done if done > 0.0 else float(projection.elapsed_w)), covered_frac=(done / wall if wall > 0.0 else float(projection.covered_frac))), gap, unseen) def discharge_permitted(soc, soc_floor) -> str | None: """SAFETY-24. May the guard size a forced discharge, or why may it not? Returns None when it may, and otherwise the REASON it may not - which the decision, the status page, the action log and the operator notification all carry, because "floor-limited" on its own tells nobody what to do. THE ONE PLACE THIS QUESTION IS ANSWERED, and it is public for the reason `freeze.looks_frozen()`, `watchdog.loop_is_following()` and `shaping_suspended()` are: a safety predicate transcribed into its own check is one copy plus a future disagreement about what the check means, and the copy that drifts is always the grading one. ⚠️ `soc is None` IS NOT PERMISSION. A failed read is not an observation, and a FULL BATTERY is exactly the value that would disarm this floor - so a `float(soc or 100)` here would restore the unbounded discharge this card removes, silently, on the one install whose SoC sensor is misnamed. There is deliberately no default argument on either parameter. ⚠️ AND `soc_floor is None` IS NOT PERMISSION EITHER, for the same reason one step further out: a caller that has no floor configured has not thereby been granted an unbounded one. Boundary: `<=`, not `<`. A pack sitting exactly ON the floor has already spent everything this guard was allowed to spend; permitting one more cycle there would make the floor mean "one cycle below the floor". """ if soc is None: return FLOOR_SOC_UNREADABLE if soc_floor is None: return ("no peak guard SoC floor is configured, so there is nothing to " "bound a forced discharge with") if float(soc) <= float(soc_floor): return FLOOR_AT_SOC_FLOOR return None @dataclass(frozen=True) class Pack: """SAFETY-32. ONE pack's three numbers for the floor question, NAMED. `name` the roster name, because a refusal that does not say WHICH pack stopped the site sends an operator to the wrong device - the same defect CFG-04's `unit_caps_indexed()` was written for. `soc` THIS pack's measured state of charge. None when its sensor did not answer, and None is not a full battery. `bms_floor` THIS pack's OWN cut-out percent (`bms_soc_floor`), which is a property of the hardware in the cupboard and NOT of the site. None when the roster entry did not state one - see `pack_floor_pct()` for why that is withheld and not 0 %. ⚠️ IT CARRIES THE CUT-OUT, NOT THE EMS FLOOR. `peak_soc_floor` is one number for the whole site (the depth a money-saving discharge may never reach) and stays a flat option; `bms_soc_floor` describes a battery and became a roster key here, because two packs of different models have different cut-outs and grading both against one of them is wrong in the direction that empties the shallower one. """ name: str soc: float | None bms_floor: float | None def pack_floor_pct(pack, soc_floor): """The percent THIS pack may not be spent below, or None if that is unknown. `max(the site's EMS floor, this pack's own cut-out)` - the CONJUNCTION, and the direction matters: the EMS floor is what the operator asked for and the cut-out is what the hardware will do anyway, so a pack is protected by whichever is higher. Taking the site floor alone is the shipped defect this card removes (the shallower pack graded against the deeper one's cut-out); taking the cut-out alone would throw SAFETY-24's margin away. ⚠️ AN UNSTATED CUT-OUT IS None, NOT ZERO, AND NOT THE FLAT OPTION. 0 % would be a floor that permits everything - the most permissive value there is, invented from an absence - and borrowing the flat `bms_soc_floor` is CFG-04's "nothing is inherited" broken in one line, which is this card's whole subject restated. `discharge_permitted()` already treats a None floor as NO permission, so an entry that forgot the key costs a shave rather than a pack. ⚠️ AND UNDER A rule-8-VALID CONFIG THIS IS `soc_floor`, FOR EVERY PACK. validate.py rule 8 refuses `peak_soc_floor <= bms_soc_floor` on EVERY unit, so `max()` selects the EMS floor everywhere the add-on will actually start - which is exactly why the reduction is written here rather than assumed at the call site. "Another rule refuses that config" is a claim about today's rule ordering, not a property of this function, and `validate_startup_config()` is also `sim/inject.py`'s entry point. `unit_caps_indexed()`'s docstring records the same argument, and CFG-04's index desynchronisation is what happened the last time it was left implicit. """ if soc_floor is None or pack.bms_floor is None: return None return max(float(soc_floor), float(pack.bms_floor)) def pack_permissions(packs, soc_floor) -> list[tuple[str, str | None]]: """SAFETY-32. Per pack: None if it may be spent, else the reason it may not. ONE `(name, reason)` pair per pack, in roster order, from the SAME `discharge_permitted()` the site verdict uses - a second transcription of "may this pack be spent" is a second answer to it, which is the argument that made that predicate public in the first place. ⚠️ THIS IS THE OBSERVABLE THE MIN() CANNOT PRODUCE. `Controller.pack_floor_soc` reduces the measured SoC to the emptiest pack BEFORE anything is graded, and that reduction is right for the site's one verdict - but it has thrown away which pack it came from and how far that pack was from ITS OWN floor by the time the answer exists. Two packs at 15 % against floors of 10 % and 20 % have opposite answers and are indistinguishable after the `min()`. """ return [(p.name, discharge_permitted(p.soc, pack_floor_pct(p, soc_floor))) for p in packs] def binding_soc_floor(packs, soc_floor): """The ONE floor `decide()` may grade `pack_floor_soc` against, at a roster. MAXIMUM over the packs' own floors, and it is the conservative reduction that pairs with the MINIMUM `pack_floor_soc` reduction: `min(soc) <= max(floor)` is implied by "some pack is at or below its own floor", so this verdict is never more permissive than grading each pack separately would be. The other direction is not true and is not claimed - a site can be refused here while every pack individually has headroom, which costs a shave and never a pack. None when ANY pack's floor is unknown, for `Controller.site_dev_bound()`'s reason and in the same direction: `discharge_permitted()` treats a None floor as no permission, so an unknown floor withholds rather than defaults. ⚠️ AT N=1 THIS IS `soc_floor` ITSELF under any config rule 8 accepts, and test_peakguard.py part 12 asserts that over the whole schema range rather than assuming it. """ if not packs: return None if soc_floor is None else float(soc_floor) floors = [pack_floor_pct(p, soc_floor) for p in packs] if any(f is None for f in floors): return None return max(floors) def reserve_split(packs, soc_floor) -> tuple[list, tuple]: """SAFETY-87: `(packs the guard may spend, names of those it may not)`, each pack graded on its own floor by `pack_permissions()`.""" perms = pack_permissions(packs, soc_floor) return ([p for p, (_n, why) in zip(packs, perms) if why is None], tuple(n for n, why in perms if why is not None)) def place_on_able(decision, held, *, able_w, soc, soc_floor) -> PeakDecision: """SAFETY-87: `decision` with the batteries in `held` (at or under their own reserve) giving nothing for the peak. `able_w`, `soc` and `soc_floor` describe the others, as `decide()`'s arguments do for the roster. A floor-limited decision is re-taken on them: they are asked for the need up to `able_w`, and it stays floor-limited only if that is not enough. `soc` and `soc_floor` keep the roster's verdict, as `decide()` echoed it. DECISIONS/SAFETY-87-guard-uses-able-batteries.md.""" held = tuple(held) if not decision.floor_limited or decision.wanted_w is None \ or discharge_permitted(soc, soc_floor) is not None: return replace(decision, held=held) wanted = float(decision.wanted_w) floor = min(wanted, float(able_w)) head = (f"this 15-minute period heads for {decision.projected_w:.0f} W " f"against a {decision.ceiling_w:.0f} W limit " f"({decision.ceiling_from}), aiming at {decision.target_w:.0f} W. " f"At the peak reserve, so giving nothing for the peak: " f"{messages.names(held)}. The others are asked for {floor:+.0f} W") if floor >= wanted: return replace(decision, floor_limited=False, floor_w=floor, soc_floor_reason=None, held=held, able_w=float(able_w), reason=f"PEAK_GUARD: {head}") return replace(decision, floor_w=floor, held=held, able_w=float(able_w), reason=(f"PEAK_GUARD_FLOOR_LIMITED: {head}, all they can " f"give, but {wanted:+.0f} W was needed, so this " f"period may go over the limit")) def decide(projection, *, batt_w, cap_w, margin_w, reading, achievable_w, soc, soc_floor, max_hold_s=None, frozen: bool = False) -> PeakDecision: """The whole guard. Pure: no clock, no I/O, no state carried between calls. `batt_w` MEASURED battery power, positive = discharge. None when the sensor for it is missing - and then there is nothing to size a floor from, so no floor is invented. `achievable_w` the largest discharge the arbiter could actually grant (`min(max_w, device max)`). The floor is clamped to it so this claim can NEVER produce `lo > hi` in the resolver - a contradiction there resolves to 0 W, which would park the battery in the exact minute it should be discharging hardest. `soc` SAFETY-24. MEASURED state of charge, percent. None when the sensor for it did not answer - which is floor-limited, not permission. `soc_floor` SAFETY-24. The percent below which this guard may not spend the pack for money. REQUIRED, and deliberately without a default: a default here would be CFG-03's fourth defaults site and TEST-14's quiet fallback in one line. `margin_w` SAFETY-79. Watts to aim under `cap_w` (`peak_margin_w`). REQUIRED for the same reason. `ceiling_w` stays the real limit for every report; only `excess_w` uses the target. """ ceiling, from_where = quarter_ceiling_w(cap_w, reading) target = quarter_target_w(cap_w, margin_w, reading) aim = "" if target == ceiling else f", aiming at {target:.0f} W" ceil_top = abs(float(achievable_w)) blind = blindness(projection, max_hold_s, frozen) if blind is not None: # 0.0, not a sized floor. There is no trustworthy measurement to size one # from; the claim that remains is "do not add to the offtake you cannot # see", which is one-directional and cannot cost pack energy. return PeakDecision( engaged=True, floor_w=0.0, ceiling_w=ceiling, ceiling_from=from_where, target_w=target, blind=blind, projected_w=None, reason=(f"PEAK_GUARD_BLIND: {blind}. The battery may not charge " f"while this 15-minute period cannot be measured, so it " f"is asked for no charging, floor 0 W")) projected = float(projection.projected_w) if batt_w is None: # main.py's P_SAFETY stop already owns a cycle with a missing input, at # 0 W. Filing nothing is the honest answer: a floor sized from a # measurement that is not there is a made-up number. return PeakDecision( engaged=False, floor_w=None, ceiling_w=ceiling, ceiling_from=from_where, target_w=target, projected_w=projected, reason="no battery measurement to size a floor from") if QUARTER_S - float(projection.elapsed_s) <= 0.0: # A block with no seconds left cannot be influenced. This is degenerate # rather than urgent - `excess_w` answers 0.0 there and a floor equal to # the battery's own current output would be a claim that says nothing. return PeakDecision( engaged=False, floor_w=None, ceiling_w=ceiling, ceiling_from=from_where, target_w=target, projected_w=projected, reason="the quarter has no seconds left to influence") ex = excess_w(projection, target) wanted = float(batt_w) + ex if wanted <= 0.0: # ⚠️ THE TEST IS ON THE FLOOR, NOT ON `excess > 0`, AND THE REPLAY IS WHY. # `excess` is the watts that must come off the METER, so it goes to zero # as soon as the battery is already removing them - and a guard that # disengaged there handed the actuator straight back, the offtake sprang # back to the raw load, and the next cycle engaged again. On 2026-08-20's # billed quarter that bang-bang cycled 17 times in 900 s and left the # quarter 49 W over the limit it had just spent a quarter of an hour # holding. §10.2: "an EMS that oscillates is worse than no EMS, both for # hardware wear and - per §9.1 - for the peak it is trying to avoid." # # `batt_w + excess` is the offtake that would exist with the battery at # zero, minus the level the quarter can afford. Testing THAT is the same # arithmetic read as a level rather than as a switch: the claim is filed # for as long as a positive floor is needed and decays continuously to # nothing as the load falls, so there is still no hysteresis constant - # the quantity being thresholded is simply the right one. return PeakDecision( engaged=False, floor_w=None, ceiling_w=ceiling, ceiling_from=from_where, target_w=target, projected_w=projected, excess_w=ex, wanted_w=wanted, reason="the load is under the level this quarter can afford") # SAFETY-24 - AND ONLY HERE. `wanted > 0` is now established, so a forced # discharge really is being asked for and the SoC is the quantity that # decides whether it may be sized. Asked one line earlier it would grade a # number that governs nothing on every quiet cycle of the day, and would # report "floor-limited" over a guard that was never going to discharge. denied = discharge_permitted(soc, soc_floor) if denied is not None: # 0.0, not `wanted`, and not a negative number. Zero forbids CHARGING - # one-directional, cannot cost pack energy - and forbids nothing else; # a `lo` limit structurally cannot cap the discharge the loop is already # doing. See the module note: this bounds THE GUARD's forced discharge, # not the grid-following loop, which still has no SoC guard. return PeakDecision( engaged=True, floor_w=0.0, ceiling_w=ceiling, ceiling_from=from_where, target_w=target, projected_w=projected, excess_w=ex, wanted_w=wanted, floor_limited=True, soc=soc, soc_floor=soc_floor, soc_floor_reason=denied, # UX-10: plain sentences, every figure kept (test_messages.py # part 6). The code is one token, so it is not read as shouting. reason=( f"PEAK_GUARD_FLOOR_LIMITED: this 15-minute period heads for " f"{projected:.0f} W against a {ceiling:.0f} W limit " f"({from_where}){aim}, and {wanted:+.0f} W from the battery " f"would hold it. But {denied}" + ("" if soc is None else f", at {float(soc):.0f} %") + ("" if soc is None or soc_floor is None else f" against {float(soc_floor):.0f} %") + ", so no forced discharge and no charging, and this period " "will go over the limit")) floor = min(wanted, ceil_top) saturated = floor < wanted return PeakDecision( engaged=True, floor_w=floor, ceiling_w=ceiling, ceiling_from=from_where, target_w=target, blind=None, projected_w=projected, excess_w=ex, wanted_w=wanted, saturated=saturated, soc=soc, soc_floor=soc_floor, reason=( f"PEAK_GUARD: this 15-minute period heads for {projected:.0f} W " f"against a {ceiling:.0f} W limit ({from_where}){aim}, with " f"{QUARTER_S - projection.elapsed_s:.0f} s left, so {ex:.0f} W must " f"come off the meter. The battery measured {float(batt_w):+.0f} W " f"and is asked for {floor:+.0f} W" # Saturated means floor == ceil_top: the ask IS the most the # inverters give, so one clause says both. + (f", the most the inverters give, but {wanted:+.0f} W was needed" if saturated else ""))) # SAFETY-81. A quarter must end this many W over the real limit before # "Load too high for battery" fires. Above the guard's own landing error at # margin 0 (up to +5.7 W, SAFETY-79 test check 5), which margin 0 accepts. ALARM_MISS_W = 10.0 # ...and for this long without a break, so a load that peaks for a few seconds # (a hob or heater cycling) is not read as the rest of the quarter. ALARM_HOLD_S = 10.0 def load_too_high(projection, *, batt_w, ceiling_w, achievable_w) -> bool: """SAFETY-81. May "Load too high for battery" fire? Only if, with the battery at full power from now on, the quarter still ends more than `ALARM_MISS_W` over the real limit (`ceiling_w`, not the aim). This is the miss itself, in W of the quarter average, so it does not have `decide()`'s gain (900 / remaining): near a quarter's end that gain turns a watt over the aim into thousands of W wanted. The load is the held meter reading plus `batt_w`. The caller passes the lower of this cycle's and the last cycle's battery reading, so a meter sample taken before the battery's last increase is not counted twice (DECISIONS/SAFETY-81-load-too-high.md). A real overload shows on the meter before the battery moves, so that filter costs it no cycle. """ remaining = QUARTER_S - float(projection.elapsed_s) if batt_w is None or remaining <= 0.0: return False rest = float(projection.held_w) + float(batt_w) - float(achievable_w) done_ws = (QUARTER_S * float(projection.projected_w) - max(float(projection.held_w), 0.0) * remaining) ends_w = (done_ws + max(rest, 0.0) * remaining) / QUARTER_S return ends_w - float(ceiling_w) > ALARM_MISS_W def charge_headroom_w(decision) -> float | None: """SAFETY-45. The hardest this SITE may charge without putting the billed quarter over the ceiling, in W, positive - or None when nothing measured it. THE ONE PLACE THIS QUESTION IS ANSWERED, and it is public for the reason `discharge_permitted()`, `shaping_suspended()`, `guard_state()` and `looks_frozen()` are: the check that grades it has to assert on THE SHIPPED EXPRESSION, and a predicate transcribed into its own test is one copy plus a future disagreement about what the check means. ------------------------------------------------------------------------ THE DEFECT IT PREVENTS, AND IT IS NOT A NEW IDEA IN THIS REPOSITORY `control.maintenance_charge_floor()` already caps MAINTENANCE charging at "the headroom left under the site's cap", and its docstring says why in words this card only has to widen: "A maintenance charge is the only thing this system does that is big enough and long enough to set that peak, so it is capped by the headroom left under the site's cap." ⚠️ THAT SENTENCE STOPPED BEING TRUE WHEN OPT-04 LANDED. Price arbitrage is a SECOND thing that is big enough and long enough - it commands `max_w` for a whole tariff interval, deliberately, against the meter - and it was given no such cap. Measured on TEST-23 stage 3's closed rig at the shipped `peak_cap_w: 3500` / `max_w: 2000`: 28 charge onsets in one hour, mean period 131 s, grid swinging -456 <-> +3200 W, on any house drawing over 1500 W in the cheapest quarter. §10.2: "an EMS that oscillates is worse than no EMS, both for hardware wear and - per §9.1 - for the peak it is trying to avoid." ⚠️ AND IT IS THE **MEASURED** GUARD, NOT `peak_forecast_entity`. `maintenance_charge_floor()` sizes itself from the forecast sensor, which is OPTIONAL and empty on a default install - the exact hole SAFETY-25 had to close in `shaping_suspended()`, where a suspension gated on the forecast alone could not fire on the very site that needed it. This number comes off `decide()`, which runs wherever P1 does. ------------------------------------------------------------------------ WHERE THE NUMBER COMES FROM: IT IS `wanted_w`, READ IN THE OTHER DIRECTION `decide()` already computes wanted = batt_w + excess = (the offtake there would be with the battery at zero) - (the level this quarter can still afford) as the battery DISCHARGE that would land the quarter exactly on the ceiling. A negative `wanted` is not "nothing to say" - it is a permitted CHARGE of `-wanted` watts, and throwing it away is SAFETY-45's whole defect. So this function invents no arithmetic; it reads the sign the guard already had. ⚠️ AND THE QUANTITY IS INVARIANT IN THE BATTERY, WHICH IS WHY SIZING A TRADE TO IT CANNOT OSCILLATE. Raise the charge by dW and the meter rises by dW, so `excess` rises by exactly dW while `batt_w` falls by dW: `wanted` does not move. It is a FEED-FORWARD number - a function of the house, the ceiling and the seconds already banked - so consuming it adds no feedback path around the plant. The fixed point is `charge = ceiling - load`, with the meter sitting ON the ceiling and `wanted` parked at `load - ceiling`, a whole `ceiling - load` clear of the engagement boundary rather than chattering across it. ⚠️ AND A QUARTER BOUNDARY DOES NOT HAND IT BACK AT FULL POWER, WHICH IS THE OTHER HALF OF SAFETY-45 AND IS NOT THE SAME CLAIM AS "IT DOES NOT CHANGE". It DOES change - it is `(900 / remaining) * (ceiling - projected)`, so it is largest in the last seconds of a block and collapses at the boundary. What matters is WHAT IT COLLAPSES TO: at `elapsed = 0` the projection IS the held level, so the answer is `ceiling - (load - pv)` - the house and the cap, and nothing else. A trade refused at 14:59 therefore resumes at 15:00 at the rate the new quarter can afford, not at `max_w`, and §10.1's answer survives the boundary because the arithmetic does: no timer, no backoff constant, no state carried between calls. ⚠️ AND THE AMPLIFICATION IS BOUNDED IN ITS EFFECT, NOT MERELY LARGE. Hold a charge of exactly this size for the rest of the block and the block's average lands on `ceiling` EXACTLY, wherever in the block it started: (acc + (held + headroom) * remaining) / 900 = projected + (900/remaining) * (ceiling - projected) * remaining/900 = ceiling so 162 kW of headroom with 5 s left and 900 W of it with the whole quarter to go are the same statement about the bill. `test_peakguard.py` part 13 sweeps that identity across the block rather than arguing it here. ------------------------------------------------------------------------ THE THREE ANSWERS `None` nothing measured it: the guard never ran (`meter_source: off`), or it ran with no battery reading to size a floor from, or the block had no seconds left to influence. ⚠️ None IS NOT UNLIMITED. A caller that treats it as "charge away" is `arbitrage.guard_state()`'s collapse of None into False, one layer down - a strategy creating the billed quantity while nothing measures the quarter it lands in. `0.0` measured, and the answer is no charging: the guard is BLIND (its claim already says so at `floor 0 W` and this agrees with it rather than restating it), or it is holding and `wanted >= 0`. `> 0` the watts a charge may pull this cycle. ⚠️ HOLDING IMPLIES ZERO, BY CONSTRUCTION AND NOT BY COINCIDENCE. Every state that files a claim with `engaged=True` has `wanted > 0`, `wanted is None` or `blind`, so this answers 0.0 or None there and can never hand a strategy permission the guard's own `lo` is at that instant refusing. ------------------------------------------------------------------------ ⚠️ WHY THIS IS A SIZING AND NOT A CLAIM, AND DO NOT "SIMPLIFY" IT INTO ONE The obvious tidier answer is to delete `decide()`'s `wanted <= 0` gate and let it file `lo = wanted` unconditionally - a NEGATIVE floor, meaning "you may charge, but no harder than this". The arithmetic is identical, it is continuous, and it would bind every claimant rather than only arbitrage. It was measured, considered and REJECTED, and the reason is not style: * `main.py`'s cheap-window maintenance charge files `Claim.limit("maintenance", P_MAINT_SHAPE, hi=-floor)` - a NEGATIVE `hi`. arbiter.py clause 2 applies EVERY limit at or above the winner's priority and takes the most restrictive, with no notion of one limit out-ranking another. A peak `lo = -300 W` against a shaping `hi = -1000 W` is `lo > hi`: a CONTRADICTION, which resolves to 0 W - the battery PARKED, in the one quarter of the month that sets the bill. That is the exact failure `shaping_suspended()` exists to prevent, and the only way to close it would be to key that suppression on "a claim exists", which on every site this card is about suspends the maintenance charge-only shaping PERMANENTLY. * and it would change what `engaged` MEANS at five call sites outside this module - `arbitrage.guard_state()`, `shaping_suspended()`, `claim()`, main.py's edge-logged action line and main.py's status row - each of which today reads it as "the guard is HOLDING". `control.maintenance_charge_floor()` is a sizing and not a limit for the same reason, which is why this is the shape that already fits. DECISIONS/SAFETY-45-sizing-the-trade.md §2b carries the full argument. """ if decision is None: return None if decision.blind: return 0.0 if decision.wanted_w is None: return None return max(0.0, -float(decision.wanted_w)) def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool: """Must the maintenance charge-only shaping stand down this cycle? THE ONE PLACE THIS CONJUNCTION IS WRITTEN, and it is public for the reason `watchdog.loop_is_following()` and `freeze.looks_frozen()` are: the check that grades it has to assert on THE SHIPPED EXPRESSION. A safety predicate transcribed into its own test is one copy plus a future disagreement about what the check means, and the copy that drifts is always the grading one. It was inline in `main.py` and nothing could reach it; a reviewer mutated it back to its historical broken form and all twelve suites stayed green. ⚠️ WHY SUPPRESSION AND NOT PRIORITY. The shaping is a LIMIT (`hi <= 0`) and the peak guard's claim is a LIMIT (`lo >= 0`). arbiter.py clause 2 applies EVERY limit at or above the winner's priority and takes the MOST RESTRICTIVE, with no notion of one limit outranking another - which is precisely what makes the safety bounds unconditional and must not be changed. Two crossing limits are therefore a CONTRADICTION, which resolves to 0 W: the battery PARKED, in the one quarter of the month that sets the bill. P_PEAK 50 > P_MAINT_SHAPE 40 does not save it and never could. ⚠️ AND THE FIRST TERM IS NOT ENOUGH ON ITS OWN. `peak_forecast_entity` is OPTIONAL and is empty on a default install, so `peak_forecast_w` is None there and `peak_at_risk()` answers False forever. Before this card the suspension therefore could not fire on the very site that needed it. The second term is the MEASURED guard, which is always present wherever P1 is. Deleting either term - or re-gating the whole thing on the forecast - parks a default install at 0 W, silently, in the billed quarter. test_peakguard.py part 7 drives the real cycle with each of those mutations in place. """ if peak_at_risk(peak_forecast_w, float(cap_w)): return True return decision is not None and decision.engaged def claim(decision: PeakDecision) -> Claim | None: """The decision as one arbiter claim, or None when the guard is idle. A LIMIT with only `lo` set. `hi` stays `+inf`, which is 9.1's "discharge and export are never capped by the peak guard" expressed as a fact about the object rather than as a rule somebody has to remember. """ if not decision.engaged or decision.floor_w is None: return None # Three reasons, not two. A `lo = 0 W` claim reaches the arbiter from THREE # different states now - blind, floor-limited, and a genuine floor that # happens to be zero - and the arbiter's explain() is what an operator reads # when the battery stops charging. Spelling them the same way would make the # SAFETY-24 case indistinguishable from the SAFETY-18 one on the one line # that gets read. # ⚠️ ON THE SITE ACTUATOR, EXPLICITLY. The billed quantity is the quarter's # average offtake AT THE GRID CONNECTION - one per site, whatever the site # has behind it - so this floor bounds the site target and is divided by the # allocator along with it. It is never a bound on one unit's share: SAFETY-25 # §0c's table has this `lo` at the SITE stage precisely so the deferred loop # reserve's per-inverter `hi` cannot become its counterpart in a `lo > hi`. return Claim.limit(SOURCE, P_PEAK, lo=float(decision.floor_w), reason=("blind" if decision.blind else "SoC floor" if decision.floor_limited and not decision.floor_w else "quarter peak"), actuator=ACTUATOR_SITE)