From 6889667fcdc1114f551af83293b2cde49400f52a Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sun, 1 Nov 2026 03:32:54 +0100 Subject: [PATCH] MAINT-20 part 2: main.py comments and docstrings cut to a line and a pointer; DECISIONS MAIN-orchestration and its archive Co-Authored-By: Claude --- .../DECISIONS/MAIN-orchestration-archive.md | 2599 +++++++++ .../DECISIONS/MAIN-orchestration.md | 51 + goodwe_controller/app/main.py | 5026 ++++------------- 3 files changed, 3895 insertions(+), 3781 deletions(-) create mode 100644 goodwe_controller/DECISIONS/MAIN-orchestration-archive.md create mode 100644 goodwe_controller/DECISIONS/MAIN-orchestration.md diff --git a/goodwe_controller/DECISIONS/MAIN-orchestration-archive.md b/goodwe_controller/DECISIONS/MAIN-orchestration-archive.md new file mode 100644 index 0000000..b1bddc5 --- /dev/null +++ b/goodwe_controller/DECISIONS/MAIN-orchestration-archive.md @@ -0,0 +1,2599 @@ +# MAIN archive - comments cut from app/main.py, word for word + +Written by MAINT-20 part 2. Each section is one comment block or docstring as it +stood in `app/main.py` at release/1.0 `d4c01da`, keyed by its first line number +there. It is a record, not a design: the live rule is the one-line comment now in +the code, and the curated reasoning is in [[MAIN-orchestration]]. Comment blocks +have their `#` removed; docstrings their quotes. + +### L1080 Set by amain() once the transport is built, so the status page can sho + +Set by amain() once the transport is built, so the status page can show +what only the transport knows (homewizard_local's unchanged_s). Stays +None when P1 is off, or under a transport that has no such counter. + +### L2302 TARIFF-24: no forecast now, but a run planned with one is under + +TARIFF-24: no forecast now, but a run planned with one is under +way: it keeps its gap to its end. The wider gap applies from the next +run. OPT-34: a night charge that keeps its level (`_latch_charge()`) +keeps the gap too, run after run, or the next slice of it would be +refused. Pure; Home asks the same (`homeview.trade_verdict()`). + +### L2313 The kept run plan, rebuilt at most once per price slot (15 min) or + +The kept run plan, rebuilt at most once per price slot (15 min) or +when the prices or a battery's choice change; a run under way is +kept to its end (`priceruns.keep_started()`). None with no fresh +firm prices: then no battery holds or trades (design section 5). + +### L2366 OPT-11 P4: the day plan (`dayplan.plan()`) on the runs just made, + +OPT-11 P4: the day plan (`dayplan.plan()`) on the runs just made, +its levels held by the hysteresis (`dayplan.hold()`), then the runs +made again with each battery's shortfall as the charge, so the cheap +run is only as long as the shortfall needs. With no plan (no or stale +forecast, no house profile yet) the levels are dropped: P3 option A. +Returns the runs to keep. + +### L2405 OPT-34, once per cycle after `modetrade.decide()`. A night grid + +OPT-34, once per cycle after `modetrade.decide()`. A night grid +charge that has started keeps its planned level when the forecast goes +mid-charge (the kept figure ends and the sensors are still silent). + +While there is a forecast, fresh or kept, the latch is rebuilt each +cycle from the plan: a battery that charges from the grid in the cheap +run comes in with its planned level, and stays in it at whatever level +the plan gives it, up or down as a fresh figure moves it (also when it +has stopped, at a lowered level), until it leaves the plan. While there +is none, `plan_targets()` gives the latched levels, so the charge goes +on to them, and `weather_held()` keeps the narrow price gap. The latch +lasts through every cheap run on and one price slot after the last +(`CHARGE_LATCH_GRACE_S`), but a run is carried on only if it starts +by the bell start of the night the latch began in (`_night_limit()`; +the run under way then finishes). It is dropped when `now` passes its +end whatever the run state, and a battery that is off or no longer in +a mode with a planned level (`packs`) leaves it. A charge that never +started has none. + +### L2463 OPT-11 P3: each battery's role and range this tick. Grid charging + +OPT-11 P3: each battery's role and range this tick. Grid charging +and selling need a fresh plan that `TariffPlan.arbitrage()` allows +with the MEASURED peak guard (None refuses: nothing measures the +quarter), exactly the gate `arbitrage.decide()` has. + +### L2535 A charge the guard or the headroom stopped stays off this period. + +A charge the guard or the headroom stopped stays off this period. +OPT-15: only a MEASURED stop latches; the first unmeasured headroom +(None) in a quarter stops this tick only, so a gap cannot cost the +whole quarter. A second one latches, so gaps cannot flip the charge. + +### L2565 OPT-11 P3: file the site trade the roles ask for, sized inside the + +OPT-11 P3: file the site trade the roles ask for, sized inside the +modes and the hard limits (the loop bound) and, for a charge, the +billed quarter's headroom (SAFETY-45, aim cap - margin, SAFETY-79). +So no battery that its mode or a refusal holds is asked for watts it +may not give (P1's unmet share). Returns True when a trade is filed. + + +### L2575 OPT-20: the ask carries PROBE_W for exactly the probes whose pin + +OPT-20: the ask carries PROBE_W for exactly the probes whose pin +is filed this tick. `decide()` ran before the pin was (or the +window closed since), so the two can differ by one cycle; left +alone, the allocator takes the pin's watts out of another +battery's share, or hands them to one it can reach. + +### L2631 OPT-11 P3: a mode trade just ended, so the loop takes over from a + +OPT-11 P3: a mode trade just ended, so the loop takes over from a +command no loop chose. Start it at the house's own point E, the +command that brings the grid to its target now (`site battery + grid +- target`, inside the loop bound), instead of at the trade's watts. +OPT-15: then HOLD it at E (`_edge_grid()`) until the meter shows the +step or `EDGE_HOLD_S` passes. The readings still describe the trade for +a second or two (meter and battery lag), and stepping on them sent +E 810 -> 1370 -> 1920 W on TEST-23: -1120 W export at a charge end. +Measured: DECISIONS/OPT-11-battery-modes.md. + + +### L2656 OPT-15: the grid reading the loop acts on this tick. While a run + +OPT-15: the grid reading the loop acts on this tick. While a run +edge is held, the target itself (no error, the command stays at E). +The hold ends when the meter reaches the target in the step's +direction (or passes it), on `end` (a trade started, or another claim +moved the command), or after `EDGE_HOLD_S` - later, up to twice that, +while the battery reading is still moving towards E. + +### L2680 SAFETY-93, N>=2: a charge ends when its battery leaves CHARGE or can + +SAFETY-93, N>=2: a charge ends when its battery leaves CHARGE or can +no longer be placed (stale link, refusal). Kept until the loop runs. +SAFETY-95: a drain likewise; its refusal (SAFETY-73) outranks the pin +without a contradiction, so it is read from the battery (+1, will not +give; a battery that will not take still drains). + +### L2701 SAFETY-93, N>=2: when a battery's maintenance charge ends, the loop + +SAFETY-93, N>=2: when a battery's maintenance charge ends, the loop +starts from what the others stood at plus that battery's new command +(its HOLD pin, or its last discharge if it is free; a charge stops). +Else the charge's watts stay in the site target and land on the +others: B 0 -> -1600 W in one cycle. DECISIONS/SAFETY-93-charge-end.md. + + +### L2724 SAFETY-95, N>=2: when a battery's maintenance drain ends, its + +SAFETY-95, N>=2: when a battery's maintenance drain ends, its +drain leaves the loop's start point, as a charge does (SAFETY-93): the +start is what the others stood at plus its new command (its pin, else +0 W). Where the house needs more, the start is the house's own point +(the live batteries' readings + grid - target), never above last +cycle's start. The loop then moves one slew step a cycle. Else the +drain's floor stays in the target and lands on the others: B 0 -> ++1600 W in one cycle, exporting. DECISIONS/SAFETY-95-drain-end.md. + + +### L2757 SAFETY-95: the grid the loop regulates. While a battery settles + +SAFETY-95: the grid the loop regulates. While a battery settles +from its ended drain (`_held_out_w`: until within the deadband, at +most PLANT_SETTLE_DOWN_S), the meter still shows the drain's tail and +the others' climb, so the loop would chase them (B down, then up +again). Meanwhile the batteries count at the site command: the error +is the house's own point minus the loop's start. + +SAFETY-103: a reading can be up to a register period (10 s) older +than the meter, so the house point is held, and moved only as far as +the meter proves: to the nearest value the batteries can be at (each +between its last changed reading and its commands since; a reading +that changed this cycle, or the side a battery refuses, is taken as +it reads). DECISIONS/SAFETY-103-old-readings-after-a-drain.md. + + +### L2814 SAFETY-96, N>=2: a battery that drops out of the split (stale + +SAFETY-96, N>=2: a battery that drops out of the split (stale +link) leaves the loop's start point, so the others take over at one +slew step a cycle instead of taking its share at once. One that comes +back enters it at its reading, and every battery settles while the +split is made again (`_held_out_w`). +DECISIONS/SAFETY-96-dropped-battery.md. + + +### L2841 SAFETY-96, N>=2: what to add to the site sum so that a battery + +SAFETY-96, N>=2: what to add to the site sum so that a battery +the loop cannot move right now counts at its command, not at its +reading. That is a battery out of the split, one whose writes are +refused, or one settling after a step the loop did not make (a charge +end, a rejoin) until its reading is within the deadband of its +command or PLANT_SETTLE_DOWN_S has passed. Its real power still +reaches the loop through the meter, so it is neither lost nor counted +twice; only the saturation freeze stops seeing it as the loop's. + + +### L2869 OPT-11: say once, per battery, that its mode was dropped as a fault. + +OPT-11: say once, per battery, that its mode was dropped as a fault. + +A mode range without 0 W would force a direction, which no mode may do, +so it is never applied (allocator.unit_interval()). main.py never +builds one; this is the line that says so if it ever does. + + +### L2889 OPT-11: switch one battery on or off, set its mode, or (P3) turn + +OPT-11: switch one battery on or off, set its mode, or (P3) turn +its selling on or off. + +Store state, not an option, so it applies on the next cycle with no +restart. Written to the action log. Switching a battery off also stops +its maintenance cycle (the owner's 2026-09-27 answer). Refused, with a +plain reason, while battery modes are off or for an unknown battery or +value. + + +### L2937 MAINT-11: start or abort the maintenance cycle of ONE named battery. + +MAINT-11: start or abort the maintenance cycle of ONE named battery. + +Returns `{"error", "code", "unit"}` for a refusal and changes nothing. +The site keeps its one maintenance slot: a start while any battery +holds it is refused (no queue), see DECISIONS/MAINT-11. The phase +machine is not touched beyond `force_start()` and `abort()`, the same +two calls the bare button makes. + + +### L2989 OPT-11 P2: one command from a battery's HA switch or mode list. + +OPT-11 P2: one command from a battery's HA switch or mode list. + +It goes through handle_action(), the page's /action path, so the +checks and the store write are the same. A retained command (one the +broker replays on subscribe, e.g. after a restart) is never applied: +the store already holds the last choice. A refusal is logged once per +topic and reason, at most 32, and further refusals are counted in the +log at 1, 10, 100 and so on. Never raises: MQTT must not reach control. + + +### L3069 SAFETY-87: at N>=2, `decision` placed on the batteries that can + +SAFETY-87: at N>=2, `decision` placed on the batteries that can +still give: live, not refusing, above their own reserve. `able_w` is +their rating plus the drain a held battery gives anyway (its +maintenance pin). Unchanged when no battery is held back. +DECISIONS/SAFETY-87-guard-uses-able-batteries.md. + +### L3099 SAFETY-126: `decision` marked `within_aim` while the guard wants + +SAFETY-126: `decision` marked `within_aim` while the guard wants +power but the batteries at 0 W would end the quarter within +`ALARM_MISS_W` of the aim (`peakguard.misses_aim()`). Once a real miss +is seen, the need stands until the guard lets go. The claim and the +commands are unchanged; only the alarms and the "Why" read it. +DECISIONS/SAFETY-126-landing-error-is-not-an-overrun.md. + +### L3128 SAFETY-91, N>=2: the batteries at or under their own peak reserve + +SAFETY-91, N>=2: the batteries at or under their own peak reserve +while another can still give (live, not refusing). They weigh 0 in +every split where the site gives, so they give last (SAFETY-25 +option A: still drawn once the others are full). One held back +stays so until its SoC reads more than 1 point above its reserve, so +a reading flipping across it does not swap the split each cycle. + +### L3153 SAFETY-91, N>=2: while a battery is held back (`_held_back()`), a + +SAFETY-91, N>=2: while a battery is held back (`_held_back()`), a +draining battery's pin becomes a floor at its drain rate, so it rises +towards its rating before the held one gives. A site stop still +decides alone. DECISIONS/SAFETY-91-held-battery-last.md. + +### L3276 SAFETY-87, N>=2: while a battery is held at its peak reserve, a + +SAFETY-87, N>=2: while a battery is held at its peak reserve, a +draining battery that may still give is raised from its drain rate +towards its rating for the peak floor, after the others. Otherwise +the floor's rest lands on the held battery (its weight 0 only orders +the split; the drain pin binds). A site stop still decides alone. + +### L3376 SAFETY-84: a cheap-window charge pulls no more than the quarter's + +SAFETY-84: a cheap-window charge pulls no more than the quarter's +room under the peak aim (`charge_room_w()`), so it yields just enough +to the peak guard; at N>=2 nothing under a site stop (its 0 W would +need the other pack to give). SAFETY-125: at N=1 too, owner +2026-10-29; the stop rule stays N>=2 only. + +### L3653 PRICE-01. A provider with fallbacks gives the maintenance gate + +PRICE-01. A provider with fallbacks gives the maintenance gate +its own prices: the current 15-minute price against today's +average, as the Home Assistant pair did. Still no I/O here - the +series is the one the background refresh stored. + +### L4222 PRICE-01: `cheap`, the gate and the gap from the standing plan. + +PRICE-01: `cheap`, the gate and the gap from the standing plan. + +The gate exists whenever the provider does: no price for now is a +closed gate, so the charge waits and does not run unpriced. The gap +flag stays False until the first refresh has finished, so a start-up +is not a gap. + + +### L4264 MAINT-13: may this pack charge now, for the maintenance timeout? + +MAINT-13: may this pack charge now, for the maintenance timeout? + +True when no price gate is set up (no price entities, no fixed tariff: +nothing holds the charge back), in a cheap window, or while the pack is +really taking charge (sun outside cheap hours). Closed only when a gate +exists, it is shut, and the pack is not charging. + + +### L4277 Gather claims, resolve precedence, write the winner. + +Gather claims, resolve precedence, write the winner. + +Nothing here decides who wins - arbiter.py does, by one rule. This +method's only job is to state honestly what each strategy wants. + + +### L4588 ⚠️ `is_live()` AT ITS OWN DEFAULT WINDOW, which is + +⚠️ `is_live()` AT ITS OWN DEFAULT WINDOW, which is +`LINK_STALE_S` - the SAME call `live_units()` makes for +the allocator. It has to be: what this branch means is +"the allocator has already dropped this unit and said so", +and a different window here would be this check answering +a question about a roster the allocator did not build. +It is the ONE number here with no option behind it, so +"graded at a changed config" is not available for it; +what part 6 grades instead is that WIDENING the window +changes the answer. + +### L4607 ⚠️ THIS UNIT'S OWN CUT-OUT. Not the shipped 10 %, and not + +⚠️ THIS UNIT'S OWN CUT-OUT. Not the shipped 10 %, and not +the site's `peak_soc_floor` either - that one bounds a +money-saving discharge only, and grading a pack against it +explains a wedged unit away in a band an ordinary install +has ten points of. + +### L4619 REASON FIRST, FLAG SECOND, and each `set` writes the file - + +REASON FIRST, FLAG SECOND, and each `set` writes the file - +the same atomicity ordering the three trips above use: a crash +between the two must not persist a latch with no explanation +for the operator who has to decide about clearing it. + +⚠️ AND THERE IS NO FLAT MIRROR OF THIS PAIR, WHERE SAFETY-02 +HAS ONE. store.py's rule 2 keeps the flat keys "so a rollback +still finds its latch", and the version being rolled back TO +drives exactly ONE actuator - a roster at which this check is +inert by construction. A flat mirror would hand a downgraded +add-on a latch taken on evidence about a roster it cannot have. + +### L4650 ⚠️ `any`, NOT the sole unit's. The hold exists so a single missed poll + +⚠️ `any`, NOT the sole unit's. The hold exists so a single missed poll +does not become a fault; a LATCHED inverter must still reach the claim +list below, because its `failsafe` SET is what parks it. One tripped +unit therefore takes the whole roster past the hold, which is the +conservative direction: every other unit's claims are re-filed too. +⚠️ AND SAFETY-30's SITE LATCH TAKES THE ROSTER PAST THE HOLD TOO, for +the same reason: its `sign_site` SET is what parks every inverter, and +a latched site that returned early here would leave each unit's last +share standing for the heartbeat to re-emit - i.e. the trip would stop +acting the moment a poll was missed. + +### L4668 UX-10: the age is rendered when the page asks, not frozen at + +UX-10: the age is rendered when the page asks, not frozen at +this cycle's. With the meter reading gone the grid value +stops changing, so the next cycle is up to cycle_floor_s +away and the line used to read "0 seconds" all that time. + +### L4678 safety limits: these bind EVERY strategy, always + +safety limits: these bind EVERY strategy, always +Defence in depth: write_setpoint() clamps to the device range too. The +duplication is deliberate - one of them is the policy, the other is the +last thing between a bug and the hardware. +⚠️ PER-INVERTER, ONE PAIR PER UNIT, FROM THAT UNIT'S OWN NUMBERS. +This loop is the difference DEV-02 makes to the claim list: the +numbers are ONE inverter's own reported min/max and ONE inverter's own +supervised cap, and there is no such thing as a site rating. Before +this card there was one pair, read from one setpoint entity, filed +once - so a second inverter's rating was never read and never bound. +See arbiter.CLAIM_SCOPE. + +### L4697 OPT-09: WHAT THIS PACK'S STATE OF CHARGE FORBIDS + +OPT-09: WHAT THIS PACK'S STATE OF CHARGE FORBIDS +⚠️ THE POINT OF THE CARD, AND IT IS AN ENVELOPE RATHER THAN A +WEIGHT. Weighting an empty pack down does NOT stop it being +commanded: `allocator._fill()` runs a deliberate last-resort pass +over the zero-weight units when the preferred ones saturate, so +that a preference can never manufacture a standing error at the +meter. Only a closed envelope stops the ask, and `cap` is obeyed +absolutely. So a pack at or under its own BMS cut-out gets +`hi = 0` and a pack at 100 % gets `lo = 0`, and the site target +goes to the sibling instead of being asked of a battery that +cannot answer it. + +⚠️ AT A ROSTER OF ONE THIS IS NEVER FILED, AND THAT IS THE SAME +IDENTITY `sign_site` IS GUARDED BY RATHER THAN A SPECIAL CASE. +`actuator_for()` routes every source to the SITE at N=1 - with one +inverter the fabricated grid reading and the battery setpoint are +the same number - so this interval would bound THE SITE TARGET +there, not a share. That is a behaviour change on the live +single-inverter install and it is the shape SAFETY-25 decided +against on 2026-09-02 (Option A): ordinary self-consumption is not +given an SoC floor by this card. There is also nothing for it to +do at N=1 - no sibling to move the share to - so the whole of AC 4 +("at N=1 allocation is byte-identical to today") is this `if`. + +WHAT IT COSTS, SAID PLAINLY: at N=1 nothing here stops the loop +discharging into the BMS cut-out. That is unchanged, it is +SAFETY-25's deferred Option B, and the BMS itself is what stops it. + +### L4734 safety stops + +safety stops +⚠️ SAFETY-02 is claimed FIRST and unconditionally while latched. It is +a SET at P_SAFETY, so it outranks maintenance, peak shaving and the +loop alike - a controller pushing power the wrong way is wrong +whatever it thinks it is doing. Re-filed EVERY cycle: claims are not +sticky, and one filed once would lapse the next cycle and hand the +actuator silently back to the loop that was driving the wrong way. + +⚠️ AND IT IS PER-INVERTER, THOUGH ITS EVIDENCE IS SITE-WIDE. The +watchdog compares P1 - one meter, one site - against `standing`, THE +COMMAND THIS CONTROLLER PUT ON THIS ACTUATOR, and what it concludes is +"the unit I am driving is pushing power the wrong way". At N>=2 that +evidence cannot be pinned on a sibling this controller never commanded, +so a site-wide stop would park an inverter this watchdog never graded. +See arbiter.CLAIM_SCOPE. + +### L4755 SAFETY-30: THE SITE SIGN TRIP, AND IT IS A SITE SET + +SAFETY-30: THE SITE SIGN TRIP, AND IT IS A SITE SET +⚠️ THE TWIN OF THE LOOP ABOVE, NOT A DUPLICATE OF IT. That loop files +one `failsafe` per unit on the evidence of THAT unit's own measured +power, and parks that unit. This files ONE claim on the SITE, because +what tripped is the SUM of every standing command against the meter - +a fact about the grid connection, whose offending number is the site +target and which no single inverter owns. Parking one unit on it would +leave the rest of the roster pushing the same way with the guard +satisfied. See arbiter.CLAIM_SCOPE, which classifies `sign_site` and +`failsafe` side by side and says why they differ. + +⚠️ AND THE `n_units > 1` GUARD IS NOT REDUNDANT, EVEN THOUGH `cycle()` +never arms this detector at a roster of one. The latch is RESTORED +FROM THE STORE, so an install that ran with two inverters, tripped, +and was then reconfigured back to one would come up with +`site_sign_tripped: true` and would otherwise file a claim on evidence +about a roster it no longer has. Guarded, that install comes up +driving - which is right: the fault it latched on is not a statement +about the site it is now. + +### L4779 SAFETY-31: WHAT "ACTED ON" MEANS, AND WHAT IT HONESTLY CANNOT + +SAFETY-31: WHAT "ACTED ON" MEANS, AND WHAT IT HONESTLY CANNOT +⚠️ A SITE SET AT P_SAFETY: STOP COMMANDING, EVERYWHERE. Read the next +paragraph before changing it, because the obvious objection is right +and is answered rather than ignored. + +THE OBJECTION. HW-01 established that commanding a wedged inverter +achieves nothing - on 2026-09-02 the comms subsystem failed and the +power stage went on executing its last instruction for 6 h 25 m, +through every command this add-on sent it. If the cause of the +circulation is a unit that has stopped listening, then a command is +not a remedy and pretending otherwise is theatre. + +THE ANSWER, AND IT IS WHY THIS IS STILL AN ACTION RATHER THAN A +NOTIFICATION. A circulating loop needs TWO participants. In every +cause on this card's list, exactly one of them is a unit that has +stopped listening and THE OTHER ONE IS THIS CONTROLLER - faithfully +commanding the sibling to a share computed to sum with a number the +wedged unit is not actually producing. Stopping the commanding half +does two things a notification cannot: + + 1. IT HALVES THE LOOP. The unit that is still listening stops being + driven into the one that is not, so the round-tripping stops on + the side this controller owns. That is real energy and real + cycles off a real pack, and it needs no cooperation from the + wedged unit to take effect. + 2. IT MAKES THE RESIDUE VISIBLE. This is the more important half. + Circulating power hides because it NETS TO ZERO AT THE METER. + With this controller's contribution removed, whatever the wedged + unit is still doing stops being cancelled and appears at P1 as a + plain net flow - where SAFETY-02's sign watchdog (which gates on + `abs(grid_w) > 300` and could never open at a netted zero), the + peak guard and the capacity tariff can all see it. The fault + stops being invisible. Measured on the TEST-19 rig: the card's + own scenario reads +0 W at the meter with 6000 W going round, and + the shipped SignWatchdog's predicate is false on every one of + those cycles. + +WHAT IT DOES NOT DO, SAID PLAINLY: it does not stop the wedged +inverter. Nothing this add-on can send will. If the loop persists at +the meter after this trip, the remaining actuator is a human at the +isolator, which is what the notification says in those words. + +⚠️ AND IT IS A SITE CLAIM. `arbiter.CLAIM_SCOPE` carries the argument: +circulating power is defined over the roster and is identically zero +for any one unit, so there is no attribution to file this per-unit on - +and parking the wrong one of the two would leave the loop running with +the guard satisfied. + +### L4831 SITE, both of them, and the two reasons are why. "stopped" is the + +SITE, both of them, and the two reasons are why. "stopped" is the +operator switching control off for the whole site; "inputs missing" is +the SITE's own telemetry gone - the meter this controller regulates +against. Neither is about a pack, and both must stop every inverter. +⚠️ SAFETY-10: `and not self.shadow`, AND IT IS THE ONE FAILSAFE THIS +CARD CHANGES. `P_SAFETY` out-ranks every other claim, so leaving the +stop claim in while shadow mode is engaged would make the recorded +decision "stopped" on every cycle - a shadow run that answers the +wrong question, which is the same as no shadow run. The actuator ends +at the same number either way, and the substitution in `_write_unit()` +that replaces it is STRICTLY STRONGER than this claim: a claim can be +out-ranked by a higher priority, and the wire gate cannot be +out-ranked by anything. + +### L4854 SAFETY-07: the quarter-peak projection guard + +SAFETY-07: the quarter-peak projection guard +ONE `lo` limit at P_PEAK, and peakguard.py carries the whole argument. +What is decided HERE and nowhere else is what the guard is allowed to +see and what it is allowed to ask for: + +⚠️ `frozen` IS EVALUATED ON THIS CYCLE'S STANDING COMMAND, NOT ON +`self.freeze_wd.active`. The watchdog object is advanced at the END of +cycle() (check_meter_frozen), so reading its level here would grade +this quarter against the PREVIOUS cycle's conjunction - one cycle of +lag, in a guard whose entire job is to act inside a 900 s quarter. +`standing` is the value SAFETY-02 already computed above for exactly +this meaning: the command on the wire that this meter reading is a +response to. The predicate itself is freeze.py's shipped +`looks_frozen()`, not a copy of it - see loop_is_following() on what a +transcribed safety predicate costs. + +⚠️ `achievable_w` BOUNDS THE FLOOR SO THIS CLAIM CANNOT CONTRADICT THE +SAFETY LIMITS ABOVE. `lo > hi` in the arbiter is a CONTRADICTION and +resolves to 0 W - the battery parked, in the one minute of the month it +should be discharging hardest. maintenance_charge_w had to learn the +same lesson (config.yaml, validate.py rule 6); this claim is built so +the config cannot express it. + +⚠️ AND IT IS FILED EVERY CYCLE, unconditionally, like SAFETY-02's. A +claim is not sticky. Filed once and skipped next cycle, the floor +lapses and the actuator goes silently back to whatever was pushing the +peak. Which of the higher-priority claims outranks it is arbiter.py's +business, not this block's: a maintenance DRAIN or HOLD owns the +setpoint at P_MAINTENANCE, and a maintenance CHARGE is only ever a +P_MAINT_SHAPE limit (40), bound by this floor. A floor above the drain +rate still wins (SAFETY-78 at N>=2, `_peak_over_drain()` at N=1), and +so does a floor over a HOLD (`_peak_over_hold()`, SAFETY-86). + +⚠️ AND IT IS NOT RUN AT ALL WITHOUT P1. `meter_source: off` is the +legacy single-entity path: there is no telegram stream, so +`p1.averager` has no open block, so the guard would be BLIND on every +cycle forever - and blind means "no charging". That is not a +conservative default, it is a controller that silently stops charging +on every install that has not opted in to P1 yet; test_clamp.py's +export case caught it doing exactly that. The billed quantity simply +is not measured on that path, and the honest report is NOT COVERED - +the same answer, for the same reason, that the status page already +gives for SAFETY-18 on a transport that cannot see `unchanged_s`. +OPT-15: with battery modes on, the quarter `now` is in, even before +its first sample (DECISIONS/OPT-11-battery-modes.md). + +### L4918 ⚠️ THE SITE SUM. The guard sizes a floor from what the battery is + +⚠️ THE SITE SUM. The guard sizes a floor from what the battery is +ALREADY removing from the meter, and the meter sees every +inverter. One unit's power here would size the floor from a +fraction of the discharge already happening. + +### L4926 ⚠️ SUMMED FROM THE PER-UNIT CONJUNCTION, WHICH IS AC 4. Each + +⚠️ SUMMED FROM THE PER-UNIT CONJUNCTION, WHICH IS AC 4. Each +unit contributes `min(its max_w, |its own device bounds|)`, so +inverter B's rating bounds inverter B. Conjoining the SUMS instead +would let one unit's device range cap the whole site. + +### L4931 ⚠️ SAFETY-24. `pack_floor_soc` IS A MEASURED READING AND MAY BE None, + +⚠️ SAFETY-24. `pack_floor_soc` IS A MEASURED READING AND MAY BE None, +and it is handed over as None rather than defaulted here. An early +draft of the stage-5 loop read SoC as `| float(100)` and thereby +invented a full battery (NOTES.md 4.4b); a full battery is +precisely the value that disarms this floor. decide() treats None +as floor-limited, which is the direction that costs a shave rather +than the pack. +⚠️ THE EMPTIEST PACK, not an average - see `pack_floor_soc`. A +floor that grades a discharge permission against the mean lets the +emptier pack be driven below the level this option exists to +protect. None stays None: `decide()` treats it as floor-limited, +which costs a shave rather than a pack. + +### L4944 ⚠️ SAFETY-32. THE SAME OPTION, REDUCED OVER THE PACKS' OWN + +⚠️ SAFETY-32. THE SAME OPTION, REDUCED OVER THE PACKS' OWN +CUT-OUTS. `peak_soc_floor` is still one site-wide EMS floor and is +still read from that one key with CFG-03's one mirror behind it; +what `binding_soc_floor()` adds is the conjunction with each pack's +OWN `bms_soc_floor`, so the floor this site is graded against can +never be below the deepest cut-out on the roster. MAXIMUM here +pairs with the MINIMUM in `pack_floor_soc`: both reductions move +the verdict towards refusing a shave and away from spending a pack. +Under any config rule 8 accepts it IS the option, for every pack - +asserted over the whole schema range in test_peakguard.py part 12 +rather than assumed, because "another rule refuses that" is a claim +about rule ordering and sim/inject.py does not run rule 8 at all. + +### L4960 ⚠️ SAFETY-20. The device range goes in TOO, and this call site is + +⚠️ SAFETY-20. The device range goes in TOO, and this call site is +the one that needed it most: `standing` is `last_write`, i.e. the +value AFTER `clamp_to_rating()` applied `[dev_min, dev_max]`, so +grading it against a rail derived from bare `max_w` compares a +device-clipped number to a ceiling the device cannot reach. + +### L4976 SITE, and peakguard.claim() says so on the object it builds. The + +SITE, and peakguard.claim() says so on the object it builds. The +quantity is the 15-minute average OFFTAKE AT THE GRID CONNECTION, +which is what the capacity tariff bills; there is one quarter-hour +peak per site, not one per pack. ⚠️ IT IS ALSO THE `lo` HALF OF +SAFETY-25 §0c's table - a site `lo` at this stage, which is what the +deferred loop reserve's per-inverter `hi` must never be able to cross. + +### L4986 ⚠️ EDGE-LOGGED, not level-logged. The action log holds 40 lines; a + +⚠️ EDGE-LOGGED, not level-logged. The action log holds 40 lines; a +guard that appended one every 15 s for a whole quarter would push the +cause of the event off the end of the log that is meant to explain it. +SAFETY-03 logs every engagement because a clamp engagement IS the +count that matters there; here the count is the seconds, and the +engagement's own reason line already carries them. + +### L5004 OPT-04: price arbitrage (§10.1 priority 3) + +OPT-04: price arbitrage (§10.1 priority 3) +ONE `set` claim at P_ARBITRAGE, and app/arbitrage.py carries the whole +argument. What is decided HERE and nowhere else is what the strategy +is allowed to see: + +⚠️ IT IS PLACED AFTER THE PEAK BLOCK BECAUSE IT READS ITS CONCLUSION. +`engaged_now` is this cycle's peak verdict, computed ~20 lines up. +Evaluated before it, this would hand the strategy the PREVIOUS cycle's +answer - one cycle of lag on the interlock that implements §9.1's +"peak shaving outranks arbitrage", inside a 900 s quarter. + +⚠️ AND THE THIRD STATE IS NOT A TIDINESS. Without P1 the guard is not +CLEAR, it never RAN, and `None` says so. arbitrage.decide() refuses on +it, because charging at max_w creates the billed quantity while +nothing is measuring the quarter it lands in. `False` here would be +this line asserting a measurement that was never taken - the defect +`PeakReading` and `quarter_ceiling_w()` both exist to remove. + +⚠️ THE THREE-STATE COLLAPSE LIVES IN arbitrage.guard_state(), NOT +HERE, and that is not tidiness. It is the same lesson +`shaping_suspended()` records: inline, this expression was reachable +by no check, and mutating it back to a bare `engaged_now` left every +suite in this repository green while a `meter_source: off` install +traded blind. Read guard_state() before touching this line. + +⚠️ NO FETCH ON THIS PATH. `fresh_tariff_plan()` is an attribute read +and an age comparison; the HTTPS call lives in run_tariff_refresh(). +See TARIFF_REFRESH_S: a supplier's TLS timeout here would be a +supplier able to push this add-on towards layer 1's 30 s veto. + +⚠️ AND THE UNCERTAINTY SET IS NOT EMPTY BY DEFAULT. §11.4's +no-weather row widens the threshold, and this add-on carries no +weather forecast at all - §8.1's PV forecast is not built. Passing +`frozenset()` would be main.py asserting a forecast is healthy when +there is not one, which is exactly what static_tariff's `uncertainty` +argument has no default in order to prevent. +⚠️ TARIFF-11: BOTH ATTRIBUTES, AND `arb_at` IS WHAT MAKES THE ROW +HONEST. Reaching this line is the only proof that this cycle got as +far as the arbitrage step; the early return at the input hold above +leaves BOTH standing at whatever the last completed cycle left, and +`arb_at` is then the age the status row reports rather than a +decision presented as current. + +### L5054 SAFETY-47. HOISTED OUT OF THE CALL, NOT COPIED. One evaluation of + +SAFETY-47. HOISTED OUT OF THE CALL, NOT COPIED. One evaluation of +the shipped expression feeds both the sizing below and +`arbitrage.sized_from_meter()` in `_shadow_settle()`, so the +caveat on the recorded watts can never be answered from a +different cycle's guard than the watts were. +SAFETY-125: a late meter reading's seconds count, as the bill. + +### L5068 SAFETY-45. THE SAME GUARD'S SECOND ANSWER, AND THE CONTINUOUS + +SAFETY-45. THE SAME GUARD'S SECOND ANSWER, AND THE CONTINUOUS +ONE. `guard_state()` is a switch and it was the whole loop: +the guard engaged on a 5 W floor, the trade was refused +outright, the loop took the actuator, the meter fell, the +guard released and the trade came back AT max_w. Measured on +TEST-23 stage 3 at the shipped peak_cap_w/max_w: 28 charge +onsets an hour, grid -456 <-> +3200 W. This is the watts the +billed quarter can still afford, so the buy leg is CLAMPED +instead - the shape control.maintenance_charge_floor() has +always given a maintenance charge, off the MEASURED guard +rather than off the optional peak_forecast_entity. +⚠️ SAME ORDERING ARGUMENT AS THE LINE ABOVE: this reads the +peak block's conclusion, ~50 lines up, so it must stay after +it. Evaluated earlier it would size this quarter's trade from +the previous cycle's quarter. + +### L5093 OPT-09. PER-PACK SIZING. Not `max_w` - the site rating - on + +OPT-09. PER-PACK SIZING. Not `max_w` - the site rating - on +both sides any more: the trade is sized to the packs whose own +state of charge permits that direction, so it can never ask +for more than the roster can take. `trade_power_w()` carries +the argument, including which of the two SoC bounds belongs +here and which belongs on the envelope. ⚠️ AT N=1 BOTH SIDES +ARE `max_w` EXACTLY, whenever the trade is permitted at all: +the one pack that passed the gate is the whole roster. +⚠️ AND ON THE SELL SIDE THIS IS A NO-OP TODAY on any roster, +because `soc=` below is the EMPTIEST pack, so a sell is +refused outright whenever any pack is at or under the floor - +i.e. whenever this sum could be smaller than the site rating. +It becomes reachable the day that gate becomes per-pack; +`test_packshare.py` part 9 grades it directly rather than +letting an unreachable branch go ungraded. + +### L5117 Edge-logged, for the peak guard's reason: the action log holds 40 + +Edge-logged, for the peak guard's reason: the action log holds 40 +lines and a trade lasts a whole hour, so a line per cycle would +flush the context that explains it. The transition is what an +operator needs - "it started buying at 03:00", not 240 copies. + +### L5128 maintenance + +maintenance +⚠️ ONE PASS PER PACK, AND `arbitrate()` IS WHY THE LIST STAYS SHORT. +At most one machine is out of IDLE on any tick, so at most one of +these branches fires and the claim list is the shape it has always +been. The loop is not a widening of what maintenance may ask for; it +is what makes the ONE claim it does file land on the pack that is +actually being recalibrated, whichever roster slot that is. + +### L5137 PER-INVERTER. §12's monthly recalibration is a PACK procedure - + +PER-INVERTER. §12's monthly recalibration is a PACK procedure - +drain THIS pack to its floor, charge THIS pack full, +recalibrate THIS pack's coulomb counter. Two packs recalibrate +on two schedules. See arbiter.CLAIM_SCOPE. + +⚠️ FILED ON THE PACK WHOSE STATE MACHINE PRODUCED IT - `inv`, +the roster entry this `result` was ticked from - and that is +the whole of MAINT-05 at this line. It used to be +`self.maint_unit`, which was `inverters[0]` flatly, because +there was one FSM and its history had nowhere else to live. +Pack B's drain filed on pack A is one pack being commanded for +another's procedure while the pack that is actually draining +stays under the loop: the site would move the right total and +recalibrate nothing. + +⚠️ AND IT STAYS `SCOPE_UNIT`. Two FSMs mean two claims on two +actuators, which the allocator consumes as per-unit intervals; +ARCH-01 is explicit that this must never become a site claim, +because per-inverter constraints filed site-wide are the +cross-inverter `lo > hi` contradiction at arbiter.py:114. + +### L5162 Money outranks the maintenance schedule: while the quarter-hour + +Money outranks the maintenance schedule: while the quarter-hour +projection is over the cap, the charge-only shaping is simply not +claimed, so the loop can discharge and shave the peak. When a +dedicated peak-shaving strategy arrives it will claim SET at +P_PEAK and outrank this limit without any code here changing. +⚠️ AND SAFETY-07 IS THE OTHER HALF OF THIS CONDITION, NOT A +SECOND MECHANISM. The shaping is `hi <= 0`; the peak guard's +claim is `lo >= 0`. Both are limits, arbiter.py applies EVERY +limit at or above the winner's priority, and most restrictive +wins - so a peak event during a maintenance charge window files +`lo > hi`, which is a CONTRADICTION and resolves to 0 W. The +battery would park in the exact quarter it should be shaving. +Priority does not save it: clause 2 has no notion of a +higher-priority limit overriding a lower one, and it should not - +"most restrictive" is what makes the safety bounds unconditional. + +So the higher-priority claimant SUPPRESSES the lower one rather +than out-voting it, which is what this branch already did for +`peak_forecast_entity`. That entity is optional and empty on a +default install, so until now the suspension could not fire on the +site that needed it. `self.peak_guard` is measured from the meter +and is always there. + +⚠️ THE CONJUNCTION LIVES IN peakguard.py, NOT HERE, and that is +not tidiness. It was inline, and a reviewer mutated it back to its +historical broken form - re-gated on `peak_forecast_entity` alone +- and every suite in this repository stayed green while a default +install parked at 0 W in the quarter that sets the month's bill. +Read shaping_suspended() before touching this: both terms are +there, and why neither is sufficient alone. + +⚠️ ONE SUSPENSION FLAG FOR THE SITE, NOT ONE PER PACK, AND THE +INTERLOCK IS WHAT MAKES THAT TRUE. The quantity it edge-logs is +a peak event at the grid connection, and `arbitrate()` allows +at most one pack to be in CHARGE at a time, so this branch can +run at most once per tick. Were two packs ever allowed to +charge together, this flag would have to become per-pack - and +so would the reasoning above it. + +### L5216 ⚠️ CFG-03. `self.maint.cfg.charge_w`, NOT A SECOND READ + +⚠️ CFG-03. `self.maint.cfg.charge_w`, NOT A SECOND READ +OF `self.o`. This line used to re-open the options dict +with its own literal fallback, so the rate the shaping +asked for and the rate the state machine drained at +could be different numbers from the same install - and, +with the key absent, they were. The Controller already +parsed this once in __init__; asking the object that +owns it cannot drift from it. Every pack's machine +holds the same `MaintConfig` instance, so this is the +rate the pack in front of us is charging at. + +### L5239 OPT-11: each battery's mode, a soft unit limit at P_MODE + +OPT-11: each battery's mode, a soft unit limit at P_MODE +Filed only with battery modes on, and never on a battery in any +maintenance phase: maintenance at 60/40 outranks the mode. The range +always holds 0 W (packmode.mode_range()). pre_resolve() keeps it +apart from the hard limits, and it gives way to the peak guard's +site floor (P_PEAK) only as far as the floor needs, so the two never +meet in a `lo > hi` (arbiter._unit_modes()). +P3: the range is the switch's, narrowed by the battery's role in +the price runs this tick (`modetrade.decide()`); both hold 0 W. + +### L5261 the grid-following loop + +the grid-following loop +⚠️ SAFETY-10: `or self.shadow`. The loop is the leg a shadow run most +needs and the one that is switched off by `auto_start: false`, so +without this an install in `auto` shadow mode would record every +strategy's decision and a permanent silence where the grid-following +loop should be. It is safe because the loop's `prev_w` is +`self.target`, which the end of this method re-seeds to what is +actually standing on the wire. +SAFETY-52: a refusal learned while control ran does not survive a +stop or a start (shadow mode keeps the loop running when stopped). + +### L5285 SAFETY-73: each refusing unit's own P_SAFETY claim + +SAFETY-73: each refusing unit's own P_SAFETY claim +A hard unit limit that always intersects (OPT-11's P_MODE layer sits +inside it): closed in the refused direction, so the allocator hands +the share to the others within their own limits. During its probe it +is a pin at PROBE_W and the others carry the rest. Filed here, before +the loop, so OPT-11's loop bound sees it. Unit claims only, and still +the last unit claims filed, so every unit's claim order is as before. + +### L5343 ⚠️ prev is the ARBITER's last output, not the loop's own last + +⚠️ prev is the ARBITER's last output, not the loop's own last +wish. If something outranked the loop, that is what the hardware +actually did, and the controller must track reality or it jumps +the moment it regains control. + +⚠️ The integrator has to track the same reality, and it is no +longer prev_w, so it needs saying out loud: if the arbiter did not +give the loop what it asked for last cycle, the loop's +accumulated error belongs to a command that never happened. +Re-seed from what the hardware was actually told. This is the +failsafe case too - every layer-1 stop resolves to 0 W, so +entering failsafe re-seeds the integrator to zero and the first +cycle after release starts from zero instead of dumping the whole +stale period as power. + +### L5366 SAFETY-52: another writer overrode the loop, so a refusal learned + +SAFETY-52: another writer overrode the loop, so a refusal learned +against the loop's own ask may no longer describe the wire. A gap +(`loop_w` None: blind or stopped) keeps it: the stale-reading stop +is what seeds it, and dropping it there restarts the ~70 s cycle. +SAFETY-107: it ends only if compute() no longer finds it against +that writer's ask (`_refusal_update()`). A held refusal makes the +loop ask 0 W, so any claim that binds differs from it every cycle. + +### L5388 ⚠️ `actual_w` IS THE SITE SUM AND THAT IS DEV-02's OTHER HALF. + +⚠️ `actual_w` IS THE SITE SUM AND THAT IS DEV-02's OTHER HALF. +The anti-windup test inside compute() is +`abs(prev_w - actual_w) > saturation_w`, and `prev_w` is the SITE +target. Handed ONE inverter's power at a roster of two it compares +a site number against a unit number, permanently trips the +saturation freeze and leaves a standing grid error - measured on +admin/goodwe's two-master rig at up to 510 W of split-dependence +(gain 0.2; 250 W at 0.4, 50 W at 0.6) while the allocator's shares +summed to S in every run. With the sum, every split is +bit-identical at every gain the schema accepts. That is why the +per-unit readings became per-instance and THIS one became site +level: the two halves of §6.2's identity break in opposite +directions. See `site_batt_w` and test_alloc.py part 8. + +### L5408 TEL-10. ⚠️ ONE NAME FOR THE COMMAND THE LOOP WAS MEASURED + +TEL-10. ⚠️ ONE NAME FOR THE COMMAND THE LOOP WAS MEASURED +AGAINST. `self.target` is reassigned further down this method, so +a `decision.i_w - self.target` written at publish time would +subtract a DIFFERENT cycle's command and call the answer a lead. +The published lead is built from the value `compute()` was +actually handed, on the line that hands it over. + +### L5459 TEL-10 + +TEL-10 +⚠️ THE INTEGRATOR HAS NEVER LEFT THIS PROCESS, AND THAT IS WHAT +THE CARD IS. `self.i_w` is carried between cycles and published +nowhere - no entity, no attribute, no log line - so the only way +anyone has ever asked what it was doing is to RECONSTRUCT it from +the grid trace and a replay. That reconstruction reproduced about +a third of the engagements it was checked against and was thrown +away; a card that has to be argued from a discarded reconstruction +is a card that cannot be argued. + +Three rows, and the third is not derivable from the first two by +anyone reading the dashboard afterwards: + + `loop_i` the integrator AFTER this cycle - `Decision.i_w`, + which its own docstring says is the value BEFORE + the output clamp, the slew limit and quantisation. + It is therefore NOT `setpoint` and must never be + published from it. + `loop_lead` how far the integrator stands AHEAD of the command + it was computed against. This is the quantity + SAFETY-48 bounds, and `integrator_max_w / slew_w` + is the number of cycles it takes to unwind. + `loop_reason` `compute()`'s OWN reason string - `deadband`, + `i-clamped`, `clamped`, `slew-limited`, + `saturated-freeze`, `freeze-released`, `tracking`. + +⚠️ AND `loop_reason` IS NOT `self.reason`. `self.reason` is the +ARBITER's sentence (`resolution.explain()`, e.g. "loop -> 1200 W") +and it does not carry the loop's own word at all: the Claim filed +below takes `decision.reason` and `Resolution.explain()` prints the +winner's NAME, not the winner's reason. The status page's "Why" +row is the arbiter's sentence and stays that way. + +### L5494 SAFETY-03 + +SAFETY-03 +⚠️ READ THE BOOLEANS, NOT `decision.reason`. On a default install +the integrator bound is what bites and the output clamp is +unreachable (control.py says so), and `reason` is then overwritten +by whichever of slew-limiting or the saturation freeze ran after +it - so a string test is silent on the two cases a winding loop +most often presents as. These flags are set where the clamp +happens and nothing downstream touches them. +SAFETY-40: every number comes from THIS decision, at clamp time, +never from `self.target` after slew/freeze/arbiter rewrote it. + +### L5512 SITE. compute() regulates `grid_w` - the P1 measurement - to + +SITE. compute() regulates `grid_w` - the P1 measurement - to +zero. Its subject is the meter, and §6.2's architecture is ONE +integrator on P1 whose output is then SPLIT. This is the claim the +allocator exists to divide. + +### L5543 ⚠️ `weights=` IS OPT-09, AND ITS ABSENCE WAS THE CARD. ARCH-01 left th + +⚠️ `weights=` IS OPT-09, AND ITS ABSENCE WAS THE CARD. ARCH-01 left the +allocator one free parameter and ALLOC-01 built the machinery that +consumes it (`allocator._fill`); this line is the first thing that has +ever passed one. Without it every unit weighed 1.0 and the split was +a fixed 50/50 regardless of what either pack could do. + +⚠️ A METHOD, NOT A DICT. The weighting is by ENERGY HEADROOM and is +therefore direction-dependent, and the direction is the sign of the +site target - which stage 2 chooses inside `resolve()`. See +`pack_weights()` and `packshare.weights()`; the arbiter calls it once, +between stage 2 and stage 3. + +⚠️ AND IT IS A PREFERENCE, NOT A PERMISSION. Nothing about the site's +ability to DELIVER rests on this argument - a weight cannot stop a +unit being commanded and cannot move a failing unit's load to its +sibling. The `pack` claim filed above is the half that constrains. + +### L5564 SAFETY-73: a probe pin stands only while the resolved site target + +SAFETY-73: a probe pin stands only while the resolved site target +asks at least PROBE_W its way. Below that a sibling would carry +the opposite direction (circulation), so the pin goes back to the +closed limit and the split is resolved once more. + +### L5581 THE TWO REFUSALS, AND THEY ARE NOT THE SAME REFUSAL + +THE TWO REFUSALS, AND THEY ARE NOT THE SAME REFUSAL +Both do the same thing - REPLACE the resolution with a 0 W failsafe +which is then written like any other cycle at +`write_setpoint(self.target)` below. Nothing is skipped. 0 W is the +fail-toward-inaction direction: the setpoint is commanded to zero +rather than left wherever the last cycle put it. They differ in WHO +refused and therefore in what an operator should do about it, so they +are two branches with two messages and not one with a vague one. + +⚠️ AND NEITHER MAY BE LEFT UNGRADED. PR #40's reviewer replaced the +first of these with `if False:` and the whole 17-file suite stayed +green, because at the shipped roster of one it was unreachable. +test_peakguard.py part 10h grades both, on real cycles. + +1. THE ALLOCATOR REFUSED. Before ALLOC-01 this fired on every N>=2 + cycle, because there was no allocator and the obvious placeholder - + hand the site target to each inverter - IS the ARCH-01 fault. There + is an allocator now and that trigger is gone; the branch is not, + because refusing is still right when no safe split exists at all: a + mis-routed claim (so the number to divide may itself be wrong), no + inverter left in the envelope set, or a split that could only be + made by commanding one pack to charge while another discharges. + `allocator.plan()` carries the argument for each. + +### L5614 2. THE ALLOCATOR SUCCEEDED AND THIS CONTROLLER CANNOT DELIVER IT. + +2. THE ALLOCATOR SUCCEEDED AND THIS CONTROLLER CANNOT DELIVER IT. + ⚠️ RE-AIMED BY DEV-02, NOT DELETED, AND THE DIFFERENCE MATTERS. + Under ALLOC-01 this fired whenever the split named any inverter but + the single one this controller could write, because there was one + `setpoint_entity`. There is a writer per inverter now, so that + trigger is gone - and the INSTINCT is not, because a roster entry + can still have no writer: an inverter configured with no + `setpoint_entity` of its own is an inverter this controller cannot + put a number on. Writing the site target to the ones it CAN reach + would over-command them by exactly the missing unit's share - + not the broadcast fault, but the same shape of over-command. + Commanding a partial site is a worse instruction than commanding + nothing, and "nothing" here means 0 W and says so. + + ⚠️ IT IS NOT THE SAME EVENT AS A DEAD LINK, WHICH IS WHY IT IS NOT + FOLDED INTO `live_units()`. A unit whose writes are being rejected + is DROPPED and its share is carried by the survivors - a real, and + announced, change in what the site can deliver. A unit with no + writer configured at all is a CONFIGURATION that cannot be + delivered, and the site stops rather than silently running at part + strength on a roster somebody wrote down wrong. + +### L5643 ⚠️ `shares={}`, NOT THE SHARES IT REFUSED TO DELIVER, AND SINCE + +⚠️ `shares={}`, NOT THE SHARES IT REFUSED TO DELIVER, AND SINCE +DEV-02 THAT IS LOAD-BEARING RATHER THAN TIDY. The write loop below +commands each inverter `shares.get(name, 0.0)`, so carrying the +split through a refusal would write the very numbers this branch +just declined - and, worse, would write NOTHING at all when they +equal what is already standing, because an unchanged value is not +re-sent. The record the operator needs survives on +`self.allocation`, which is assigned above and untouched here. + +### L5658 3. AC 6: A DROPPED INVERTER IS NEVER SILENT. The survivors carry its + +3. AC 6: A DROPPED INVERTER IS NEVER SILENT. The survivors carry its + share, which is a real change in what the site can deliver, and an + operator who is not told reads a working site. Edge-triggered on + the SET of dropped units, not level-triggered, because the action + log is a log of events (§15) and a stale link would otherwise write + a line every second for as long as it lasts. + +### L5676 3b. OPT-09 / IF-04: WATTS NOBODY COULD TAKE ARE NEVER SILENT EITHER. + +3b. OPT-09 / IF-04: WATTS NOBODY COULD TAKE ARE NEVER SILENT EITHER. + `unmet_w` is the site target the envelopes could not absorb. It is + NOT a broken allocation - `Allocation`'s own docstring says why + fusing it with `ok` would let one saturated inverter park the whole + site - but it IS the site failing to deliver what the loop asked + for, and before this card nothing anywhere reported it. It is the + state a closed envelope produces when BOTH packs are at their + limits: the ask has nowhere left to go, and an operator watching a + standing grid error needs the reason rather than the symptom. + Edge-triggered on the VALUE, for `_dropped` reason. + +### L5698 A claim reached the arbiter on an actuator its source's scope does + +A claim reached the arbiter on an actuator its source's scope does +not permit. It is a BUG in the filing site, not a state to survive: +a per-inverter constraint in the site list is the `lo > hi` this +card removes, and a site constraint on one unit is a bound the +other unit never gets. Loud, every cycle, and it does not change +the resolution - the arbiter already routed by `actuator`. + +### L5709 SAFETY-02's arming gate + +SAFETY-02's arming gate +Was the number about to go on the wire chosen by the grid-following +loop, or by a strategy that commands against the meter on purpose? +Only the former is expected to agree in sign with P1 - without this, +every maintenance grid charge trips the failsafe within three minutes. + +⚠️ THE EXPRESSION LIVES IN watchdog.py, NOT HERE, and that is not +tidiness. It was inline, test_watchdog.py held a transcription of it, +and the P_PEAK case meant to force whoever lands peak shaving to come +and change an expectation did not: editing this line alone to +`winner in ("loop", "peak")` left every suite green while the watchdog +went blind for every peak event. Read loop_is_following() before +touching the gate - the forward hazard and the reasoning are there. + +### L5725 UX-08: the same answer in plain words, for the page's "Why" line. + +UX-08: the same answer in plain words, for the page's "Why" line. +`reason` stays the technical record the log and the checks read. +UX-10: `why_key()` maps a safety winner to its claim's reason, and +every key it can return has words in messages.WHY_WINNER. + +### L5739 DEV-02: EACH INVERTER IS TOLD ITS OWN SHARE + +DEV-02: EACH INVERTER IS TOLD ITS OWN SHARE +⚠️ ITS OWN SHARE, NEVER THE SITE TARGET. `resolution.shares` is the +allocator's answer and `sum(shares) + unmet_w == round(target_w)` is +an integer identity, so handing each unit its entry is what makes the +gain at the meter a CONVEX COMBINATION of the per-unit gains. Handing +each unit `self.target` is the ARCH-01 fault: each supplies it in +full, the gain becomes N times the per-unit gain, and on the +two-master rig that is 4000 W peak-to-peak forever at gain 1.0 with +not one guard firing. + +⚠️ AT N=1 THIS IS BYTE-IDENTICAL. The allocator's pass-through returns +`{inverter-a: target_w}` as an unrounded float, so the number written +is `self.target` exactly, as it always was. + +⚠️ AND A UNIT NOT IN `shares` IS COMMANDED 0 W, not left standing. +That is the dropped-link case: the inverter holds its last command +FOREVER, so "not in the split" must mean zero on the wire, not +silence. The write is the same clamped, budgeted path as every other. +SAFETY-85, before the writes: the wire still holds last cycle's. + +### L5765 SAFETY-03, and its boundary is the WRITE + +SAFETY-03, and its boundary is the WRITE +⚠️ NOT THE SAME BOUNDARY AS SAFETY-18's BELOW, THOUGH THE CALLS ABUT. +This one reads `self.clamp_last`, which is None until write_setpoint() +has actually run, so it has to follow the write. The one below grades +`self.target`, which is assigned further up - see check_output_clamp() +and check_meter_frozen() for each. Two guards, two orderings, adjacent +lines: do not "tidy" them into one rule. + +Moving it BELOW the other guards is harmless - nothing between here and +the end of cycle() writes `self.clamp_last`. Moving it above the write +is not, and test_clamp.py part 6 fails on exactly that: it grades the +evidence, not the line number, so a rename or a move into a helper that +still runs after the write keeps it green. (TEST-11.) + +### L5780 SAFETY-18, and its boundary is the ASSIGNMENT + +SAFETY-18, and its boundary is the ASSIGNMENT +⚠️ THE BOUNDARY IS `self.target = resolution.target_w` ABOVE, NOT +write_setpoint(). This comment used to say "the command that has just +gone on the wire", which is wrong twice over and was corrected by +TEST-06's reviewer: `check_meter_frozen()` grades `self.target` - the +arbiter's resolved intent - and `write_setpoint()` clamps only a local +`value` and can skip the write entirely, so that number may never have +gone anywhere. + +What both terms of the conjunction must describe is the SAME moment: +the command this cycle resolved, against how long the meter that chose +it has been standing still. Evaluated before +`self.target = resolution.target_w` it would grade the PREVIOUS +cycle's command against THIS cycle's meter - the one combination that +was true of neither moment. Moving it between the assignment and +write_setpoint() is therefore harmless; moving it above the assignment +is not, and test_freeze.py part 5 fails on exactly that. + +### L5805 SAFETY-31's alarm, and its boundary is the LATCH, not a number + +SAFETY-31's alarm, and its boundary is the LATCH, not a number +Unconstrained where the two above are not: it grades +`self.circ_wd.tripped`, which was advanced near the top of this method +beside SAFETY-02 and which nothing since has touched. It is called on +EVERY cycle rather than only on the cycle that trips, because its +second job is re-raising the alarm after a restart into an existing +trip - see its docstring on why that shares one code path with the +fresh trip instead of getting its own. + +### L5815 SAFETY-42's alarm, and its boundary is the LATCH, like SAFETY-31's + +SAFETY-42's alarm, and its boundary is the LATCH, like SAFETY-31's +It grades `inv.delivery_wd.tripped`, which was advanced near the top +of this method beside SAFETY-02 and SAFETY-31 and which nothing since +has touched. Called on EVERY cycle rather than only on the cycle that +trips, because its second job is re-raising the alarm after a restart +into an existing trip - see its docstring on why that shares one code +path with the fresh trip instead of getting its own. + +### L5829 SAFETY-24's alarm, and it is the OTHER way the money gets spent + +SAFETY-24's alarm, and it is the OTHER way the money gets spent +Same boundary argument as check_peak_saturated(): it grades +`self.peak_guard`, which cycle() assigned above and nothing since has +touched. Kept as its own method rather than folded into that one, +because the two states are mutually exclusive and want OPPOSITE +operator actions - saturation says "shed load, the inverter is already +flat out"; this says "the pack is at its floor and this quarter is not +being shaved at all". + +### L5839 TARIFF-01, and it is LAST on purpose + +TARIFF-01, and it is LAST on purpose +Banking a quarter is bookkeeping about a moment that has already +happened; a slow SD card must never sit between the meter and the +setpoint. Its position is unconstrained in the way SAFETY-03's and +SAFETY-18's are not - it grades neither `target` nor `standing` and +reads only `self.p1`, which no line in this method writes. + +### L5847 SAFETY-10, AND IT IS LAST FOR A REASON THAT IS NOT TIDINESS + +SAFETY-10, AND IT IS LAST FOR A REASON THAT IS NOT TIDINESS +Every guard above has now read `self.target` - the resolved intent - +and SAFETY-18 in particular grades exactly that. Re-seeding before +them would hand each one a 0 W that no decision produced. Re-seeding +AFTER them hands the NEXT cycle, and the sign watchdogs, what is +actually standing on the wire. See `_shadow_settle()`. + +### L5856 SAFETY-10. Record the decision, then re-seed the carried command. + +SAFETY-10. Record the decision, then re-seed the carried command. + +TWO JOBS, AND THEY ARE IN THIS ORDER BECAUSE THE SECOND DESTROYS THE +FIRST'S EVIDENCE. + +1. THE RECORD. A shadow run is only worth running if it answers "what +would it have done, and why". `self.shadow_would_w` and +`Resolution.explain()` are that answer, and the action log gets one +line per CHANGE OF DECISION rather than one per cycle - the log +holds 40 rows, and a level-logged shadow run would flush the context +of its own findings inside four minutes at a 15 s cycle floor. The +same edge rule the peak guard and the arbitrage strategy already +use, for the same reason. + +2. THE RE-SEED. `self.target` and each `inv.target` are set back to +`shadow.WIRE_W` - what the write gate actually put on the wire. + +⚠️ WHY THE RE-SEED IS A SAFETY PROPERTY AND NOT BOOKKEEPING. Three +machines read the carried command as a statement about the HARDWARE: + +* `compute(prev_w=self.target, ...)` - the loop's slew limiter and +its integrator. Left holding a shadow decision, the first cycle +after shadow mode is turned off would slew from a base that never +existed: up to a full `slew_w` step onto a battery sitting at 0 W. +Re-seeded, the exit is safe BY CONSTRUCTION and needs no separate +transition code - the carried command already equals the standing +one. +* `Inverter.standing_w()`, which is what SAFETY-02/30's sign +watchdogs grade. Fed a shadow decision they would compare a number +against a meter that never saw it, disagree for as long as the +decision asked for power, and LATCH A TRIP THAT SURVIVES INTO LIVE +OPERATION AND A RESTART. Shadow mode would manufacture the failsafe +it exists to help debug. ⚠️ THIS ONE IS ALREADY HELD BY THE WIRE +GATE - `standing_w()` prefers `last_write`, and that is the 0.0 W +the gate accepted - so the re-seed is the SECOND rung, covering +the fallback before any write has been accepted. Two rungs +answering one question is MUTATION.md's masking shape, and the +mutation that reproduces the hazard has to defeat BOTH: driven +singly, dropping this line leaves the detector inert and names +nothing about the latch. Measured. +* SAFETY-42's delivery watchdog compares `inv.target` against what +the pack is producing. A share nothing was told to produce is a +delinquency report on every cycle of every shadow run. + +⚠️ AND EVERY CYCLE'S DECISION IS THEREFORE COMPUTED FROM WHERE THE +PLANT REALLY IS, not from a counterfactual plant that drifts further +from reality every tick. "What would this controller command NOW, +given this meter and a battery at 0 W" is a true statement about the +present; "what would the eleventh cycle of a run that never happened +have commanded" is not a statement about anything. + + +### L5909 ⚠️ `first` EXISTS SO A LIVE INSTALL'S FIRST CYCLE IS SILENT. The + +⚠️ `first` EXISTS SO A LIVE INSTALL'S FIRST CYCLE IS SILENT. The +edge is against `None` on cycle one, and an install that comes up +CONTROLLING has nothing to report - a "shadow mode off" line at +every start is one more row of boilerplate in a 40-row log, and +it was measured pushing a real clamp episode's context out of +test_clamp.py part 2's ring. Coming up IN shadow mode is the +other direction and it is always worth a line: it is the log an +operator reads to find out why nothing is being commanded. + +### L5945 SAFETY-47. ⚠️ NO LOGIC HERE, DELIBERATELY. All three rungs - the + +SAFETY-47. ⚠️ NO LOGIC HERE, DELIBERATELY. All three rungs - the +arbiter's winner, the leg being a CHARGE, and the headroom having +been MEASURED - live inside `arbitrage.sized_from_meter()`, which +says why, including why it is NOT keyed on whether the trade was +capped. A rung written at a call site is a rung no check can reach +with the other two clear, which is TEST-16's rule. + +### L5953 ⚠️ THE WINNER AND THE ROUNDED WATTS, NOT THE WATTS ALONE. A decision + +⚠️ THE WINNER AND THE ROUNDED WATTS, NOT THE WATTS ALONE. A decision +that moves from the loop to the peak guard at the same number is a +different decision and the operator needs the line; a loop target +that wanders by a watt is not, and 15 s of those would be the whole +log. +⚠️ SAFETY-47 ADDS THE CAVEAT FLAG TO THE EDGE. The same winner at the +same watts with and without the caveat are two different claims about +the same number, and an edge key blind to that would print the first +one and silently keep it standing. It cannot flood: the flag only +moves when arbitrage starts or stops owning the resolution, or when +the guard starts or stops measuring the quarter, and both of those +move the winner or the watts as well on every cycle measured. + +### L5979 SAFETY-85. The loop's clamp counts only on a cycle the loop decided + +SAFETY-85. The loop's clamp counts only on a cycle the loop decided +(a rating may cut it). A battery another owner set to discharge counts +when it gave less than it was told for `saturation_cycles` cycles in a +row. DECISIONS/SAFETY-85-no-false-not-keeping-up.md. + +### L6025 SAFETY-119: does the loop's bound on this cycle count? Not within + +SAFETY-119: does the loop's bound on this cycle count? Not within +a settle after another owner handed the wire back: its integrator is +re-seeded from that owner's command and kicked by a meter that does +not show it yet. SAFETY-121: the settle is HANDBACK_CYCLES cycles and +SHORT_SPAN_S after the hand-back, so a bound that outlasts it counts +at any cycle length; losing the wire restarts it. Across all +hand-backs the bound is hidden on at most ROLLING_BOUND_CYCLES cycles +in any ROLLING_S, so a bound that never outlasts a settle is counted +too. DECISIONS/SAFETY-121-bound-every-quarter.md. + +### L6060 SAFETY-03. Alarm whenever a clamp actually engaged this cycle. + +SAFETY-03. Alarm whenever a clamp actually engaged this cycle. + +Runs AFTER write_setpoint() for the same reason SAFETY-18 does: the +clamp is arithmetic done inside the write, so before the write there is +nothing yet to report. + +⚠️ PUBLISHING IS NOT IN THE WRITE PATH. The clamp itself is synchronous +inside write_setpoint(); this - the awaits, the notification, anything +that can block on Home Assistant - is out here. A stalled supervisor must +never be able to delay or drop a frame. + +⚠️ THIS IS NOT A TRIP. No claim is filed, nothing is latched, 0 W is not +commanded and `publish()` still reports "running". §11.2's action for +this row is "clamp and alarm", and the clamped value is written and the +loop keeps running. Turning this into a trip means coming here and +changing that deliberately, not discovering it in the field. + + +### L6077 ⚠️ THE EVIDENCE IS PER UNIT AND THE ALARM IS NOT. `clamp_last` moved + +⚠️ THE EVIDENCE IS PER UNIT AND THE ALARM IS NOT. `clamp_last` moved +onto the Inverter because a clamp on B says nothing about A; the +ClampWatchdog stayed one per controller because clamp.py's own +docstring says so - it is an anti-flood counter over an EVENT BUS, not +a property of a device, and one notification per inverter per cycle is +the flood it exists to remove. So: any unit that clamped is an +engagement, and the reason NAMES the unit. + +### L6107 THE ACTION LOG, AND IT IS COALESCED PER EPISODE + +THE ACTION LOG, AND IT IS COALESCED PER EPISODE +Every engagement is recorded, but consecutive ones are recorded by +UPDATING one row's count rather than inserting a row each. + +⚠️ THAT IS NOT COSMETIC, AND IT WAS FOUND THE HARD WAY. `log_event()` +keeps 40 rows; a winding loop clamps on every cycle, so a row per +engagement flushes the whole ring within 40 cycles - and the first +thing it evicted, in test_freeze.py, was SAFETY-18's "METER FROZEN?" +row. A guard whose log drowns out the other guards' logs has moved the +flood rather than removed it. The count here is `clamp_wd.engagements`, +so nothing is lost: the row says exactly how many, and the number is +read off the counter rather than off the number of rows. + +This lives here rather than only in write_setpoint() because on a +default install the actuator-side clamp is unreachable - compute() has +already bounded the integrator - so the only engagements that exist are +loop-side ones. Measured: 60 cycles of the §10.3 runaway produce 0 +write-path engagements and 2 loop-side ones. + +### L6162 A fixed notification_id, for the reason check_meter_frozen() gives - + +A fixed notification_id, for the reason check_meter_frozen() gives - +and a DIFFERENT one from every other alarm this add-on raises, because +a clamp is not a frozen meter and neither is a saturation freeze. +UX-08: the words are messages.output_clamp()'s; the numbers are the +ones `reason` was written from (ClampWatchdog.facts), asked first. + +### L6172 SAFETY-07. Alarm ONLY when the guard has run out of inverter. + +SAFETY-07. Alarm ONLY when the guard has run out of inverter. + +⚠️ AN ENGAGEMENT IS NOT AN ALARM, AND THE DIFFERENCE IS §10.3's DOCTRINE +READ CAREFULLY. "Treat an out-of-range computed setpoint as a bug signal +- clamp it, then alarm" is SAFETY-03's rule and it is right there: the +loop computing 14 768 W is a WINDING INTEGRATOR, a defect, and clipping +it silently is what let it keep climbing behind a dashboard full of legal +numbers. A peak floor above the inverter's rating is not a defect. It is +a house load bigger than the inverter - 2026-08-20 15:45 ran ~5900 W +against a 5000 W unit - and alarming on the ordinary case is how an +operator learns to dismiss the notification that matters. So an +engagement is an ordinary control action: it goes to the §15 action log +and the status page, and no further. + +What DOES earn the operator's attention is saturation: the guard asked +for more discharge than the inverter can give, so the quarter is going +over the ceiling anyway and the controller has already done everything +available to it. That is the one state where the money is going to be +spent and a human is the only remaining actuator. + +SAFETY-81: `g.saturated` alone is not enough. It is graded against the +aim, and a 1 s meter spike, noise or the last seconds of a quarter set +it while the quarter lands on target; `peakguard.load_too_high()` must +agree too, for `ALARM_HOLD_S`. The battery reading it gets is the lower +of this cycle's and the last one's - see there. + +Edge-triggered, and de-asserting arms it again - the same shape as +FreezeWatchdog's `evaluate()`, and for the same reason: one notification +per episode, not one per cycle. SAFETY-81: the notification and action +log line at most once per quarter; the ERROR line once per episode. + + +### L6254 SAFETY-24. Alarm when the EMS SoC floor stops the guard shaving. + +SAFETY-24. Alarm when the EMS SoC floor stops the guard shaving. + +⚠️ FAILING TO SHAVE MUST NOT BE SILENT, AND THAT IS THE WHOLE POINT. +The guard exists for one euro figure - the capaciteitstarief bills the +month's single worst quarter - so a cycle in which it CHOSE not to shave +is a cycle in which money is being spent on purpose. Under-shaving +quietly would leave the operator with a bill and a status page full of +green. + +The distinction from check_peak_saturated() is the operator's action, +which is why these are two notifications and not one: + +saturated the guard asked for everything the inverter has and it +was not enough. SHED LOAD - the controller has already +done all it can, and it is still discharging. +floor-limited the guard asked for NOTHING, because the pack is at or +under the EMS floor, or its SoC could not be read. +Nothing is being discharged for this quarter at all. +(SAFETY-87, N>=2: or the batteries above their +reserve give all they can and it is not enough.) + +They cannot both be true: peakguard.decide()'s floor-limited branch +returns before the `achievable_w` clamp that sets `saturated`. + +⚠️ SAFETY-25 - AND THE MESSAGE SAYS WHAT THE FLOOR IS NOT. Read on its +own, "the capacity-peak guard has run out of battery" invites exactly +one wrong inference: that the pack is now being held at +`peak_soc_floor`. It is not. `peak_soc_floor` bounds THIS GUARD only; +the grid-following loop has no SoC floor at all and, on the committed +2026-08-20 replay, is being granted discharge in EVERY cycle that raises +this notification. An operator who reads the alarm and relaxes because +"the EMS has a 20 % reserve" has been misled by us, so the alarm carries +the correction itself rather than leaving it to DOCS.md. + +Edge-triggered, and de-asserting arms it again - FreezeWatchdog's shape, +for FreezeWatchdog's reason: one notification per episode, not one per +cycle for a quarter of an hour. + + +### L6304 No prefix: `reason` already opens with PEAK_GUARD_FLOOR_LIMITED, and + +No prefix: `reason` already opens with PEAK_GUARD_FLOOR_LIMITED, and +a second copy is the kind of stutter that makes a 40-line action log +read like boilerplate. The saturated path needs its prefix because its +reason carries the word in the middle of a sentence, not at the front. + +### L6313 A fixed notification_id, and a DIFFERENT one from every other alarm + +A fixed notification_id, and a DIFFERENT one from every other alarm +here - check_meter_frozen() gives the reasoning. A battery at its +energy floor is not a saturated inverter and is not a frozen meter, +and an operator who has to dismiss three identical-looking cards +learns to dismiss the one that mattered. +⚠️ SAFETY-25's sentence must render on the unreadable-SoC branch too, +where `soc_floor` is a number but naming it would invite the reader to +think the pack is AT it. `decide()` guards its own reason string the +same way; a f-string that formats a None here would turn the one +notification that matters into a traceback. +SAFETY-32/87: which battery is at its reserve, each graded on its own +floor (`g.held`). At a roster of one no battery is named. +⚠️ SAFETY-25's sentence (the reserve binds the peak guard only) is +part of messages.peak_reserve() on every branch, the unreadable-SoC +one included. + +### L6360 SAFETY-31 + SAFETY-34. The alarm, and it must survive a restart. + +SAFETY-31 + SAFETY-34. The alarm, and it must survive a restart. + +⚠️ THE LATCH IS NOT THE ALARM. `circ_tripped` in `/data/state.json` +survives everything, but nothing looks at a JSON file at 3 a.m. What the +operator sees is a `persistent_notification`, and THAT IS IN MEMORY: a +Home Assistant restart deletes it while the fault runs on. This is not a +hypothetical - it is precisely how the 2026-09-02 outage went unseen for +6 h 25 m with the detector correct the whole time (SAFETY-34). + +So the durable half and the visible half are joined HERE, and the join +is `_circ_announced`: a per-PROCESS flag, deliberately not persisted. +The latch says "this site is tripped"; the flag says "this process has +put that in front of a human". A restart clears the flag and keeps the +latch, so the notification is RE-RAISED on the first cycle after every +add-on start. A Core-only restart does NOT restart the add-on, so it is +also re-asserted every REANNOUNCE_S (SAFETY-34). + +⚠️ ONE CODE PATH FOR BOTH, ON PURPOSE. A fresh trip and a restart into +an existing trip raise the alarm through these same lines. Two paths +would be two chances for the restart one to rot unnoticed, and the +restart one is the case nobody exercises by hand. + + +### L6386 Set BEFORE the await. `hass.call()` can raise or hang, and a retry + +Set BEFORE the await. `hass.call()` can raise or hang, and a retry +every cycle for a broker that is down would be an alarm storm on the +event bus on top of a site that is already stopped. The latch is +already durable and the log line above already fired; this surface is +best-effort by design. + +### L6410 SAFETY-42 + SAFETY-34. The alarm, and it must survive a restart. + +SAFETY-42 + SAFETY-34. The alarm, and it must survive a restart. + +⚠️ THE LATCH IS NOT THE ALARM, and this guard has nothing BUT the alarm. +`unit//delivery_tripped` in `/data/state.json` survives everything, +but nothing looks at a JSON file at 3 a.m. What the operator sees is a +`persistent_notification`, and THAT IS IN MEMORY: a Home Assistant +restart deletes it while the fault runs on. That is precisely how the +2026-09-02 outage went unseen for 6 h 25 m with the detector correct the +whole time (SAFETY-34), and it matters more here than anywhere else in +this file, because SAFETY-02 and SAFETY-31 at least leave a stopped site +for somebody to notice. This one leaves a site that looks like it is +working and is quietly delivering one unit less than it has. + +So the durable half and the visible half are joined HERE, and the join +is `_delivery_announced`: a per-PROCESS set of unit names, deliberately +not persisted. The latch says "this unit is tripped"; membership says +"this process has put that in front of a human". A restart empties the +set and keeps the latch, so the notification is RE-RAISED on the first +cycle after every add-on start, and re-asserted every REANNOUNCE_S +because a Core-only restart does not restart the add-on (SAFETY-34). + +⚠️ ONE CODE PATH FOR BOTH, ON PURPOSE - `check_circulating()`'s +argument. A fresh trip and a restart into an existing trip raise the +alarm through these same lines; two paths would be two chances for the +restart one to rot unnoticed, and the restart one is the case nobody +exercises by hand. + +⚠️ AND ONE NOTIFICATION ID PER UNIT. Every other alarm in this file has +a single fixed id because its subject is the site; this one's subject is +a named box, and two wedged inverters must produce two cards rather than +one replacing the other. The id is built from the same `unit_slugs()` +TEL-07 announces the MQTT entities under, so the card and the entity an +operator correlates it with carry the same token. + + +### L6452 Added BEFORE the await, for `check_circulating()`'s reason: + +Added BEFORE the await, for `check_circulating()`'s reason: +`hass.call()` can raise or hang, and a retry every cycle for a +broker that is down would be an alarm storm on the event bus. The +latch is already durable and the log line already fired; this +surface is best-effort by design. + +### L6463 PRICE-01. One notification when no price covers now, gone when one doe + +PRICE-01. One notification when no price covers now, gone when one does. + +`price_gap` is worked out on the tick from the stored plan. A gap +raises the notification once: the flag is set BEFORE the await, for +`check_circulating()`'s reason (a Home Assistant call can raise or +hang, and a retry every cycle would be a storm). When the gap ends the +same notification is taken down. Control is not touched either way: the +maintenance charge waits because its gate is shut (`_provider_gate`), +and nothing here decides that. + + +### L6483 PRICE-01 G1. At the first cycle only: one INFO line when a + +PRICE-01 G1. At the first cycle only: one INFO line when a +`static_profile` is set beside `price_service` (it is the plan's last +fallback, not a fault, so no notification), and a dismiss of the card an +earlier build raised, which is harmless when absent. + +### L6507 SAFETY-18. Alarm on a meter that answers forever without changing. + +SAFETY-18. Alarm on a meter that answers forever without changing. + +Notification only - no claim, no latch, no 0 W. freeze.py carries the +reasoning and the euro figure; DOCS.md carries the exposure this does +NOT close. + +⚠️ getattr, not an attribute access: `unchanged_s` exists only on +`homewizard_local`, and `p1_source` is None until amain() builds the +transport. Every other meter_source hands the predicate None and gets +False, which is the honest answer - see the transport note in freeze.py. + + +### L6551 The stored plan if it is still current, else None. NEVER a stale one. + +The stored plan if it is still current, else None. NEVER a stale one. + +⚠️ THIS IS THE ONE PLACE THE AGE IS JUDGED, and it is public for +`loop_is_following()`'s reason: a check has to be able to assert on the +shipped expression rather than on a transcription that drifts. Both +terms are required - a plan with no timestamp is one nothing stored, and +a timestamped plan older than TARIFF_MAX_AGE_S is a reading from a task +that has stopped answering. + + +### L6567 Resolve the tariff off the control path, forever. NEVER on the tick. + +Resolve the tariff off the control path, forever. NEVER on the tick. + +⚠️ IT CATCHES EVERYTHING AND KEEPS GOING, for MqttPublisher's reason: +a supplier's outage, a certificate expiry, a malformed feed - all of it +is pricing, and the battery does not care. What it must never do is +raise out of here and take the task down, because then the plan freezes +at its last value and looks current forever. That is what +`fresh_tariff_plan()`'s age bound is the backstop for, and this is the +half that keeps the task alive. ⚠️ THE BOUND IS NO LONGER A BACKSTOP +ONLY: since TARIFF-12 it is also the serve-stale window an outage runs +on, and it is consulted below on every refresh. + +⚠️ AND A FAILURE DOES NOT CLEAR THE PLAN - WHICH IS A CLAIM ABOUT WHAT +`resolve_tariff()` RETURNS, NOT ABOUT WHAT IT RAISES (TARIFF-12). This +paragraph was enforced by the `except` below alone, and the `except` +below is the one path a supplier outage never takes. `load_prices()` +says so in its own words - it "RETURNS, NEVER RAISES, for every failure +a site can actually have" - because a degradation arriving as a +traceback into the planning tick would stop the controller regulating +over an expired certificate. So a dead feed is a RETURN: the `try` +succeeds, `_degrade()` hands back a §11.4 plan, and the assignment used +to overwrite the good one unconditionally. Measured in a 40 h soak with +`static_profile` unset (the shipped default, and the normal state of a +dynamic-tariff site): the FIRST failed fetch replaced a complete 48 h +plan with `TariffPlan(no_data, None, "none")` - 0 points - 27 minutes +after the last good one, and `TARIFF_MAX_AGE_S` governed nothing. A +transient blip cost the same as a 90-minute outage. + +⚠️ SO THE QUESTION IS ASKED ONCE, AFTER THE ATTEMPT, FROM THE EVIDENCE +THAT EXISTS - FCST-06's answer, in this file. The evidence is +`plan.from_feed` (did this attempt observe the feed) and +`fresh_tariff_plan(now)` (is an observation still current). A +degradation replaces a standing observation only once that observation +has aged out, so `TARIFF_MAX_AGE_S` is now the serve-stale window it +reads like rather than a bound nothing could reach. + +⚠️ AND A §11.4 STATIC FALLBACK DOES NOT REPLACE A FRESH PROVIDER PLAN +EITHER, although it carries real prices. Every point `_degrade()` +builds is `provisional=True`, so `firm_points()` is empty, +`arbitrage()` refuses and `cheap_now()` is False - everything a +consumer may act on is already off. A day-ahead price for a given hour +does not change once published, so a plan fetched half an hour ago is +exactly as true about that hour as one fetched now. The fallback is +less actionable and no more current; it takes over when the observation +retires, which is what §11.4 is for. The reverse is not symmetrical: a +standing plan that is ITSELF a degradation is replaced by a fresh one, +or an outage would freeze the operator's static profile at the window +it was generated for. + +⚠️ AND NOTHING HERE READS `arbitrage_enabled`, WHICH TARIFF-10 MAKES +WORTH SAYING. Since that card this task also runs on a site with a +provider configured and trading DISARMED, so "keep the plan" is a +decision about what the add-on has LOOKED UP, not about what it may +trade on. It is also strictly downstream of `resolve_tariff()`: the +§8.3 carry into `price_history` happens on the `series` branch inside +that call, before this decision exists, so which of two plans is kept +cannot add or lose a recorded day. + +⚠️ AND `tariff_at` DOES NOT MOVE ON A KEPT PLAN. It is the moment the +fetch that produced the standing plan happened, and +`fresh_tariff_plan()` judges the age from it - re-stamping it here +would be this task asserting the feed answered when it did not, and the +plan would "freeze at its last value and look current forever", which +is the hazard the paragraph above this one names. An unmoved +`tariff_at` is also the only thing that tells a kept plan from a +freshly fetched one, so it is what the check asserts. + + +### L6658 ⚠️ NOT THE OUTAGE PATH - see the docstring. `load_prices()` + +⚠️ NOT THE OUTAGE PATH - see the docstring. `load_prices()` +returns on every failure a site can have, so what reaches +here is something nobody expected, and the plan is kept for +the same reason and on the same terms. + +### L6672 ⚠️ EACH INVERTER'S RATING FROM ITS OWN ENTITY, WHICH IS AC 4. There + +⚠️ EACH INVERTER'S RATING FROM ITS OWN ENTITY, WHICH IS AC 4. There +was one read of one `setpoint_entity` here, so a second inverter's +rating was never read at all and its clamp, its freeze rail and the +peak guard's `achievable_w` were all the first one's numbers. +Concurrently, for `read_inputs()`'s reason: a slow ESPHome on one unit +must not delay the controller's start on every other. + +### L6684 ⚠️ THIS USED TO WARN AND CARRY ON, and that was the bug CFG-01 + +⚠️ THIS USED TO WARN AND CARRY ON, and that was the bug CFG-01 +exists to remove: a max_w above the device's rating meant +write_setpoint() silently clamped and the configured ceiling was +never the real one. + +amain() already ran this gate before anything started. It runs +AGAIN here for one case the gate cannot cover: ESPHome is often +not up yet when the add-on boots, so hass.limits() returned +(None, None) and the range checks were skipped rather than +guessed at. This is the first moment the device's real rating is +known. If it conflicts, refuse now - stopping deterministically +through the normal shutdown, which commands 0 W on the way out. + +⚠️⚠️ AND THE `if entity:` THAT USED TO WRAP THE WHOLE BLOCK WAS A +READER THAT FAILED PERMISSIVE - CFG-04's own hazard, in this file. +`entity` is the FLAT `setpoint_entity`, which a per-inverter config +leaves blank (validate rule 9(e) requires it), so on the very config +this add-on now supports the entire re-check was SKIPPED: no +inverter's `max_w` was ever graded against the device it is written +to, and the skip was silent. The flat gate still guards the flat +rules, because they read flat options and one device's range; the +per-unit rule runs unconditionally, off the roster, and grades each +unit against ITS OWN ESPHome. At one inverter `validate_unit_ranges()` +skips the unit the flat call already graded, so nothing is reported +twice and the N=1 path is unchanged. + +### L6729 ⚠️ SAFETY-12. The floor is a DEADLINE ON THE GAP BETWEEN CYCLES, not a + +⚠️ SAFETY-12. The floor is a DEADLINE ON THE GAP BETWEEN CYCLES, not a +second scheduler running alongside the arrival trigger. Every cycle +resets it, whichever trigger fired, so on a healthy changing meter it +never elapses and not one existing cycle moves - and therefore no +change to the per-cycle gain the tuning was measured against. + +That is true twice over, and the second reason is the sturdier one: +the combined cycle rate is max(arrival rate, 1/floor), never the sum. +At the defaults 1/15 s is strictly slower than the ~1/5 s arrival +rate, so even a floor that raced the arrivals could not push the loop +past its nominal healthy rate. The re-tune is unreachable by +construction, not merely by the reset. ⚠️ That argument holds only +while floor > the meter's update interval - which is why the schema +minimum of 5 s deserves the second look validate.py has not given it +yet: 5 s IS the reference meter's interval, i.e. the one configurable +value that would double the cycle rate. + +Why it has to exist, and it is not only about latency: `self.grid != +last_grid` alone means a meter that repeats a value runs NO control +cycles at all. The heartbeat below keeps re-emitting the last command +and the firmware keeps putting it on the wire, so every indicator +reads healthy - and, worse, the `inputs missing -> 0 W` claim lives +INSIDE cycle(), so on a repeating meter THAT SAFETY CLAIM CANNOT FIRE. +Demonstrated in test_loop.py: constant meter, SoC entity dies at t=4 s, +and the pre-fix controller holds 220 W through the whole outage with +the status line still reading "loop -> 220 W". Layer 1 does not cover +it on a `meter_source: off` install either - no P1 age sensor is +published, so the firmware has nothing to veto on. Measured on the rig +during TEL-05: 70 s at a literally constant 350.0 W, 14 arrivals, zero +cycles. + +This is NOT a freeze detector, and deliberately so - see +HomeWizardLocalSource.unchanged_s for why one cannot be built here (at +the converged -10 W target a 1 Wh register needs ~6 minutes to move, +so any freeze threshold false-trips at the exact operating point we +run at). Nothing here decides whether a repeated reading is true. It +only stops "the number is the same" from meaning "do not think". + +⚠️ THE 15 s DEFAULT HAS A CEILING IT DOES NOT OWN. It is under +validate.FIRMWARE_INGEST_VETO_S (30 s) and under meter_max_age_s on +purpose, so the loop re-decides at least twice inside any window in +which layer 1 might otherwise be the only thing deciding. If the +firmware's veto ever moves, this default moves with it - it is not +independently justified at 15. + +### L6784 ⚠️ The FULL cycle, not a degraded one. A floor cycle runs the + +⚠️ The FULL cycle, not a degraded one. A floor cycle runs the +same law on the same inputs as an arrival cycle, because in P1 +mode the freshness question is already answered upstream and +answered harder: past `meter_max_age_s` (30 s) p1.net_w is +None, which is "inputs missing", which becomes a P_SAFETY 0 W +claim. So a floor cycle cannot run the PI on a 60 s old +figure, because at 60 s there is no figure. + +⚠️ THAT GUARANTEE IS TRANSPORT-CONDITIONAL, and saying so +matters more than the guarantee: with `meter_source: off`, +read_inputs() takes self.grid straight from hass.number() with +NO age gate at all, so a Home Assistant entity that has gone +quiet reads as a current value on that path with or without +this change. Running the full law is still the right choice +there - the alternative is a degraded second law reasoning +about an age nobody measured - but the floor does not make +that path fresh, and nothing here should be read as claiming +it does. And the integrator +advances per CYCLE, not per second (control.py), so bounding +the gap bounds the accumulation: at most one extra +gain x error per floor interval, under the same +integrator_max_w as every other cycle. A second, gentler +control law for floor cycles would be a second set of +dynamics nobody can test on this hardware. + +### L6809 ⚠️ The heartbeat is not an optimisation. The ESP32 treats silence + +⚠️ The heartbeat is not an optimisation. The ESP32 treats silence +longer than ~30 s as "the controller is gone" and zeroes the +inverter. Refreshing the SAME value is what proves we are alive. + +⚠️ ONE DEADLINE PER INVERTER, AND THE REFRESHES GO OUT TOGETHER. +This was ONE `last_beat` and ONE serialised write, so a hung +`hass.call()` to inverter A consumed inverter B's ~30 s layer-1 +budget and the firmware zeroed a healthy sibling. Now each unit +owns its own deadline, each write is dispatched CONCURRENTLY and +each is bounded by WRITE_BUDGET_S - so the worst a hung A can do +to B is delay B's NEXT beat by the budget, which is why the budget +plus the schema's largest `heartbeat_s` has to stay under layer +1's veto. That inequality is graded, not asserted. + +⚠️ AND EACH UNIT REFRESHES ITS OWN SHARE, `inv.target`, never the +site target. A heartbeat that re-emitted the site number would be +the broadcast fault arriving through the back door, once every +`heartbeat_s`, on a path with no allocator in it. + +### L6834 Refresh every inverter whose own deadline has come. AC 6 lives here. + +Refresh every inverter whose own deadline has come. AC 6 lives here. + +⚠️ IT IS A METHOD RATHER THAN FIVE LINES INSIDE `run_control()` FOR ONE +REASON: the acceptance criterion is "a hung write to A cannot consume +B's layer-1 budget - verified by injecting the hang", and a check cannot +inject a hang into the middle of a `while not self.stopping` loop that +also sleeps, reads inputs and cycles. Inline, the only thing a test +could grade was its OWN gather of `write_setpoint()` calls - which is a +check on the fixture, and a serialised shipped loop survived it. +Measured: the mutation that replaced the concurrent dispatch with a +`for` loop passed the whole suite while this block was inline. + +`loop_now` is the caller's monotonic clock, the same discipline every +watchdog in this add-on takes its clock by. + + +### L6853 ⚠️ THE DEADLINE MOVES BEFORE THE WRITE, NOT AFTER IT. Moved after, a + +⚠️ THE DEADLINE MOVES BEFORE THE WRITE, NOT AFTER IT. Moved after, a +unit whose write hangs would have its deadline advanced late every +time and would drift into being re-sent on every tick; and a unit +whose write RAISED would never have it advanced at all. + +### L6859 ⚠️ CONCURRENTLY. In series, every inverter behind a hung one waits out + +⚠️ CONCURRENTLY. In series, every inverter behind a hung one waits out +that one's whole budget before its refresh is even attempted - and +before this card, with no budget at all, it waited out the HTTP +stack's timeout. That is one inverter's transport fault pushing a +healthy sibling towards layer 1's ~30 s veto. + +### L6869 ⚠️ SITE TOTALS, AND THE KEYS DO NOT MOVE. These entities are already + +⚠️ SITE TOTALS, AND THE KEYS DO NOT MOVE. These entities are already +discovered and already on every dashboard (TEL-07 keeps their +`unique_id`s), so `battery` stays the number the meter sees the +battery doing - the SUM - and `soc` stays the one a floor is graded +against - the EMPTIEST pack. At the shipped roster of one both are +inverter A's own readings, unchanged. Per-inverter entities are +TEL-07's, not a key rename here. + +### L6882 ⚠️ SAFETY-02's alarm surface, and it is deliberately an existing + +⚠️ SAFETY-02's alarm surface, and it is deliberately an existing +one. IF-01/IF-02 own `ems/event/failsafe` and the `failsafe active` +binary sensor and neither is built yet; inventing a bespoke topic +here would be a surface to migrate later. This sensor is already +discovered, already on every dashboard, and "failsafe" is +additive to its vocabulary rather than a change to it. +SAFETY-30 adds the site sign latch to the same vocabulary, for +the reason the note above gives: "failsafe" is additive to a +sensor that already exists and is already on every dashboard, and +a P_SAFETY stop that did not reach it would be a site at 0 W +reporting "running". + +### L6898 SAFETY-10: "shadow" is its own value and not a + +SAFETY-10: "shadow" is its own value and not a +flavour of "stopped". A fleet dashboard reporting a +shadow install as `running` would be calling a +battery nobody is controlling healthy; one reporting +it as `stopped` would lose the fact that the decision +path is live and being recorded. ⚠️ BELOW `failsafe`: +a latch that is up is up, and shadow does not clear it. + +### L6907 SAFETY-31. ⚠️ MEASURED, not the allocation record - see + +SAFETY-31. ⚠️ MEASURED, not the allocation record - see +`site_circulating_w`. `publish()` drops None values, so a cycle +with a missing per-unit reading leaves the last figure standing +rather than publishing a zero that would read as health. + +### L6913 TEL-10. The loop's own three, written from the fields `cycle()` + +TEL-10. The loop's own three, written from the fields `cycle()` +recorded and not re-derived here - the page and the publisher run +on their own tasks and `self.target` has moved on by the time +either of them looks (SAFETY-47's note on `sized_from_meter`, one +field up, is the same hazard). + +### L6922 TEL-11 + +TEL-11 +⚠️ THE KEY IS PRESENT WITH THE VALUE `None` WHEN THERE IS NO READING, +AND ABSENT ENTIRELY WHEN THERE IS NO SOURCE. Those are two different +states and the publisher treats them as two: `mqtt.ABSENT_AS_UNKNOWN` +turns the first into HA's own `None` payload, so the entity's state +goes to `unknown` and the recorder records a GAP - which is how a +reader of a week of history tells "no PV reported" from "PV reported +0 W". The second never reaches the broker at all, because an install +that never set `pv_entity` or `pv_entities` has no such entity +announced. ⚠️ TEL-12: AND THE ROW IS STILL ONE NUMBER. At a roster of +three it is the SITE total, `None` unless EVERY entity answered - see +`pv.total()`, whose note is where the decision not to publish a +partial sum is argued. This line did not have to change for that, and +that is the point: TEL-11's `None`-is-an-absence rule already covered +it, one layer up. + +⚠️ AND THE GATE IS `optional_keys()`, THE SAME EXPRESSION THE +ANNOUNCEMENT USES. Written as a second predicate here the two could +disagree, and both directions are bad - an announced entity nothing +feeds, or a topic nothing declared. `announced_units()` two blocks +down is the same discipline for the roster. + +### L6946 ⚠️ ONLY when P1 ingestion is actually running. The ESP32's stale-input + +⚠️ ONLY when P1 ingestion is actually running. The ESP32's stale-input +watchdog subscribes to sensor.goodwe_p1_sample_age - see the entity-id +note in mqtt.py; the composed name is not the object_id - and forces layer-1 +failsafe once it reaches max_age_s. With meter_source off there is no +ingester feeding it, so published_age_s would be time-since-startup +climbing without bound - i.e. every existing install would cross the +threshold within 30 s and pin its inverter at 0 W forever. Publishing +nothing leaves the entity non-existent, which is the status quo and +what has_state() in the firmware is checking for. + +### L6958 TEL-07. THE PER-UNIT ROWS, AND THE EMPTY LIST IS THE GATE. + +TEL-07. THE PER-UNIT ROWS, AND THE EMPTY LIST IS THE GATE. + +⚠️ THERE IS NO `if len(self.inverters) > 1` HERE, DELIBERATELY. +`announced_units()` answers `[]` at a roster of one, `zip` then yields +nothing, and the values dict comes out byte-identical to what shipped - +so the condition that decides what is ANNOUNCED is the same expression +that decides what is PUBLISHED. Written as a second predicate, the two +could disagree, and both directions are bad: an announced entity +nothing feeds (the hazard `omit` exists for) or, at N=1, a published +topic nothing announced, which AC 4 forbids outright. + +⚠️ AND THE SITE ROWS ABOVE DO NOT MOVE. `battery` stays the SUM and +`soc` stays the EMPTIEST pack; these are additions beside them, not a +re-pointing of them, because those two ids are on the live install's +dashboards and in its automations. + +### L6992 Every inverter to 0 W, and every inverter's TARGET to 0 W with it. + +Every inverter to 0 W, and every inverter's TARGET to 0 W with it. + +⚠️ THE SECOND HALF IS NOT BOOKKEEPING, AND IT IS A HAZARD DEV-02 +CREATED. Before this card the heartbeat re-emitted `self.target`, so +zeroing that one scalar was enough: the stop button set it and the next +refresh carried the zero. The heartbeat now re-emits `inv.target` - it +has to, or it would broadcast the site number - so a stop that zeroed +only `self.target` would leave each unit's LAST SHARE standing, and the +very next heartbeat (up to `heartbeat_s` later, and before any cycle has +run) would put it back on the wire. The operator would have pressed +stop, watched the inverter go to zero, and seen it resume seconds later. +Both must move together, which is why this is one method with a reason +and not two lines repeated at three call sites. + +⚠️ AND 0 W IS THE ONE VALUE THAT MAY BE WRITTEN SITE-WIDE. It means the +same thing to every inverter; a non-zero site-wide write is the ARCH-01 +broadcast fault, and `write_setpoint()` refuses it. + + +### L7017 The status rows. `when` is the reference time for anything billed by + +The status rows. `when` is the reference time for anything billed by +the calendar - today's month when it is None, which is what the live +page means; `status_payload()` calls this with no argument. + +⚠️ IT EXISTS SO THE PEAK ROW CAN BE GRADED (TEST-17). Every other peak +query already takes it - `PeakStore.peak()`, `PeakTracker.peak()`, +`headroom_w()`, `warning_frac()` - and the row built from them was the +one thing that could only be read against `datetime.now()`. A suite +that banks an August quarter then asks this page what the peak is goes +green in August and red in September, which is what happened at 00:00 +on 2026-09-01: TARIFF-01's own section 10, red on a calendar boundary, +on a commit nobody had touched for a day. + + +### L7058 UX-10: no reading yet since the start, still inside the + +UX-10: no reading yet since the start, still inside the +limit. Amber, not red: it is not a fault yet, but control +waits for it. Red at 0 s contradicted the limit the row +prints. Past the limit it is the red row below, as before. + +### L7071 ⚠️ unchanged_s is REPORTED, never thresholded and never folded + +⚠️ unchanged_s is REPORTED, never thresholded and never folded +into the age - see HomeWizardLocalSource.unchanged_s for why +(at the converged -10 W this controller aims for, a 1 Wh +register needs ~6 minutes to move, so any limit false-trips at +the exact operating point we target). The whole argument for +leaving it unthresholded is that a human interprets it, which +requires a human being able to see it - so here it is. getattr: +only homewizard_local has one, and p1_source is None until +amain() builds the transport. + +### L7085 SAFETY-18. Reported in both states, for the reason SAFETY-02 is: + +SAFETY-18. Reported in both states, for the reason SAFETY-02 is: +a detector nobody can see is armed is a detector nobody trusts +when it fires. ok=False/warn=False so the banner goes red - this +is an alarm, and the banner is the loudest surface we own. It +changes NOTHING about control; see check_meter_frozen(). + +### L7104 SAFETY-20, and THE SECOND REASON TO USE THIS ROW RATHER THAN A + +SAFETY-20, and THE SECOND REASON TO USE THIS ROW RATHER THAN A +NEW MECHANISM. The transport case above and this one are the +same statement - "the conjunction cannot be evaluated here" - +and an operator does not need to learn two vocabularies for +it. What differs is only which term is missing: there, +`unchanged_s`; here, a rail the command could ever stand at. + +### L7116 ⚠️ SAFETY-20. THE RAIL PRINTED HERE IS THE RAIL THE PREDICATE + +⚠️ SAFETY-20. THE RAIL PRINTED HERE IS THE RAIL THE PREDICATE +USES, device range included. This row used to interpolate +`rail_w(max_w)` while `looks_frozen()` graded a command the +device had already clipped below it, so on any install whose +inverter is smaller than its configured ceiling the page named +a number the command could never reach and called it `armed`. +Both the row and the predicate now read the same three inputs. + +### L7126 And when the device is what set that number, SAY SO - the + +And when the device is what set that number, SAY SO - the +operator configured `max_w` and is owed the reason the guard +is armed somewhere else. +⚠️ Either bound may be None on its own - `hass.limits()` +reads `min` and `max` independently and returns None for +whichever attribute is missing or unparseable - so this +renders whatever is there rather than formatting a None. +UX-08: "the inverter's own limit" says the device set it; which +inverter and its range are DOCS and the settings, not entity ids. + +### L7140 TARIFF-01. The month's billed peak, and - the load-bearing half - + +TARIFF-01. The month's billed peak, and - the load-bearing half - +a DIFFERENT row when the store could not answer. "no peak yet" is +a normal state on the 1st; "unreadable" is a broken instrument, and +a status page that spelled them the same way would let a 0 W read +look like a quiet month right up until SAFETY-07 shaved nothing. +UX-08: in plain words. The store's state at start is on the INFO +line `capacity-tariff peak store: ...` and `_pk.explain()` on the +log; the coverage figure (TARIFF-13) is a row of its own. + +### L7168 SAFETY-07. Three rows, not one, because "idle" and "cannot see" + +SAFETY-07. Three rows, not one, because "idle" and "cannot see" +are opposite states and a page that spelled them the same way +would show a green line over a guard that is switched off. `None` +is a fourth state on purpose: before the first cycle the guard has +concluded nothing, and saying "idle" then would be a claim about a +decision that has not been taken. + +### L7185 SAFETY-24. NOT ok, like saturation: in both states the money + +SAFETY-24. NOT ok, like saturation: in both states the money +is going to be spent and a human is the only actuator left. A +separate row from saturation because the two ask for opposite +things - see check_peak_floor_limited(). + +⚠️ SAFETY-25. THE SCOPE SENTENCE IS PART OF THE ROW, NOT A +DOCS FOOTNOTE. This is the one line an operator reads at the +exact moment the guard stops discharging, and read alone it +says "the battery is being held at peak_soc_floor" - which is +false, and measurably so: on the committed evening replay the +loop goes on discharging in EVERY cycle this row is showing. +A floor that binds one consumer and not the other is worse +than no floor if the page does not say which. + +⚠️ AND IT CARRIES ITS OWN `banner`. status_payload() renders +the FIRST not-ok/not-warn row as the page's headline, so +without this the loudest element on the page would be a +550-character paragraph that the row three lines below +repeats verbatim. The banner is the short form; the row keeps +the whole sentence, because the row is where an operator +reads what the floor does and does not bound. Shortening the +ROW instead would be weakening AC 3. +UX-08: the same three branches, and the same pack naming, as +check_peak_floor_limited()'s notification. The figures stay in +`_pg.reason`, on the log and the event list. +SAFETY-87: `runs`, control goes on (status_payload()). + +### L7248 ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the point. + +⚠️ OUTSIDE the `p1_enabled` block, and that placement is the point. +SAFETY-07 is NOT COVERED on the legacy single-entity path - the +quarter-hour average is measured from P1 telegrams and this path +has none - and a row that only rendered where the guard runs would +leave the one install that is unprotected showing nothing at all. +Same answer, same reason, as the freeze detector's NOT COVERED row +a few lines up. + +### L7258 ⚠️ ONE PAIR OF ROWS PER INVERTER, NAMED. A single "battery SoC" row on + +⚠️ ONE PAIR OF ROWS PER INVERTER, NAMED. A single "battery SoC" row on +a site with two packs is a page that shows one pack and calls it the +site. The label keeps its exact wording at a roster of one, because +that is what an existing install reads. + +### L7280 ⚠️ SAFETY-56. THIS ROW ASSERTED A FALSE FACT ABOUT A NAMED + +⚠️ SAFETY-56. THIS ROW ASSERTED A FALSE FACT ABOUT A NAMED +ENTITY, WHICH IS THE WORST THING A DIAGNOSTIC CAN DO. It read +" is missing or not numeric" for ANY None, and one of +the ways this value becomes None is SAFETY-37's freshness gate +withholding a reading that is present, numeric and updating - +`Freshness.reading()` returns None BY DESIGN there. On the +live site on 2026-09-21 the operator opened Developer Tools on +the entity this row named, found it working, and had every +reason to conclude the diagnostics were broken. + +⚠️ AND IT IS FIXED BY SAYING LESS, NOT BY LOOKING HARDER. The +row could ask `inv.batt_fresh.stale` - it is on the same +object at the same moment - but this loop also renders the +grid and SoC rows, which have no gate, and a branch per row +would be a second place where "why is this None" is decided. +The gate already has a surface of its own: `stale_note()` +rides `liveness_notes()` a few rows below with the whole +STALE_READING sentence in it, so the operator's discriminator +is "is that row there?" and this row's job is only to stop +pre-empting the answer. Same reason the wording is not shared +with `freshness._grade()`: that one is a log line about a +BEHAVIOUR, this one is a banner about an ENTITY. + +### L7322 DEV-02. Whether this controller can still put a number on each + +DEV-02. Whether this controller can still put a number on each +inverter, and it is reported in the ONE state that matters: a unit +`live_units()` has stopped listing has had its share handed to its +siblings, or - at a roster of one - has taken the site to 0 W. A +dropped link that shows nothing on the page reads as a healthy site. +Red only for a unit that is no longer live; the rest are amber. +The same notes, in the same order, as `liveness_notes()`. + +### L7337 SAFETY-03. Red while an over-rating clamp is engaged (amber for the + +SAFETY-03. Red while an over-rating clamp is engaged (amber for the +integrator bound, SAFETY-40), and the engagement COUNT is shown even +after it clears - a row that vanishes with the condition takes the +evidence with it. Reported only when there is something to report: +unlike the freeze detector this guard has no "not covered" state, it +runs on every write on every transport. +UX-07 item 1 / UX-08: each count is shown under ITS alarm's name - +over-rating cuts as "Power request too high", integrator-bound cycles +as "Battery is not keeping up" - never the two summed under one. + +### L7380 SAFETY-02. Reported in both states: a watchdog nobody can see is armed + +SAFETY-02. Reported in both states: a watchdog nobody can see is armed +is a watchdog nobody trusts when it fires. +⚠️ ONE ROW PER INVERTER, because there is one watchdog per inverter +and a trip on B leaves A commanding. A single row would have to pick +one of them to describe, and the honest answer at a roster of two is +both. At a roster of one the wording is unchanged. +⚠️ AND SINCE SAFETY-30 THE ROW SAYS WHAT THE UNIT WAS COMPARED +AGAINST, because at a roster of two that is no longer the meter and a +row that still said "the command opposed the meter" would send an +operator to look at P1 for a fault that is visible only on one +inverter's own power sensor. `sign_wd.against` is the same string the +trip sentence uses, so the two cannot drift apart. + +### L7394 ⚠️ ADDED ONLY WHEN THERE IS SOMETHING TO DISAMBIGUATE. At the + +⚠️ ADDED ONLY WHEN THERE IS SOMETHING TO DISAMBIGUATE. At the +shipped roster of one the two sentences below are the ones that +have always been on the page, word for word - AC 8 is about what +an operator sees as much as about what the detector computes, and +"opposed a meter reading" would be a gratuitous change to a line +somebody has learned to recognise. At N>=2 the noun is load- +bearing: the row would otherwise send them to look at P1 for a +fault that is visible only on one inverter's own power sensor. +UX-08: at N>=2 `unit` is set, and the row says "its own power". +The figures stay in `sign_wd.reason`, on the log and event list. + +### L7413 SAFETY-30: the SITE row + +SAFETY-30: the SITE row +⚠️ ONLY AT N>=2, and the absence at N=1 is the honest report rather +than a missing row. With one inverter the site comparison and the row +above are the same sentence about the same actuator; printing it twice +would advertise two guards where there is one, which is how an +operator learns to skim the list. + +### L7429 SAFETY-31 + +SAFETY-31 +⚠️ SITE-LEVEL, WHERE THE ROWS ABOVE ARE PER-INVERTER, and the row says +so. There is one circulating figure for the site; naming an inverter +here would invite the operator to clear "the other one". + +### L7450 SAFETY-42 + +SAFETY-42 +⚠️ PER INVERTER, AND WARN RATHER THAN BAD, WHICH IS THE ONE PLACE THE +"reports, does not act" DECISION IS VISIBLE TO AN OPERATOR. Every +other latched row in this list means the site is at 0 W and a red +cross is the truth. This one means the site is STILL RUNNING and is +quietly short by one unit's share; rendering it as a hard failure +would say the controller had stopped, which it has not. It is not "ok" +either - `ok: False` with `warn: True` is this list's caution, and a +caution is what a fault nobody is acting on actually is. + +### L7471 AC 5, and the same honesty `circulating power: n/a` uses one block + +AC 5, and the same honesty `circulating power: n/a` uses one block +up. At one inverter the reduction this check is has no proper +subset to find, for EVERY input, so "armed" would be a claim to be +watching something that cannot happen. + +### L7505 TEL-11 + +TEL-11 +⚠️ NO ROW AT ALL WHEN THE ROSTER IS EMPTY, AND THAT IS A DELIBERATE +DIFFERENCE FROM THE PEAK-FORECAST CAUTION DIRECTLY ABOVE. That one +nags because an unset forecast entity leaves a MONEY GUARD without an +input; this one is pure instrumentation, and a permanent caution on +every install that never asked for a PV channel is exactly the +"no warning spam, no degraded status row" the card forbids. + +⚠️ AND WHEN IT IS SET, A FAILED READ IS `ok=False, warn=True` - a +CAUTION, never `bad`. `status_payload()` builds "NOT READY" from rows +that are neither ok nor warn, and a telemetry channel that could put a +battery site into NOT READY would be observability with a foot in the +control path. The reason comes from `pv.watts()` rather than being +re-derived here, so the page cannot disagree with the reading. + +TEL-12 kept 0.11.0's single-entity sentences word for word. UX-08 +replaced them on purpose (DECISIONS/UX-08-short-notifications.md), so +a `tools/tel11_pv_trace.py` compare against a pre-UX-08 tree shows +these rows as changed telemetry; the control documents stay equal. + +### L7547 ⚠️ AND WHEN BOTH KEYS ARE SET, WHICH ONE WON IS STATED. `pv.py`'s + +⚠️ AND WHEN BOTH KEYS ARE SET, WHICH ONE WON IS STATED. `pv.py`'s +`entities()` makes `pv_entities` REPLACE `pv_entity`, which is the +rule an operator upgrading from 0.11.0 is most likely to trip +over. A CAUTION and never a `bad` row: telemetry must not stop +control. + +### L7578 SAFETY-10, AND IT IS FIRST IN THE CASCADE + +SAFETY-10, AND IT IS FIRST IN THE CASCADE +"The battery is NOT being controlled" is the one sentence on this +page that may never be displaced by another condition. A banner that +can be out-ranked does not STATE a thing plainly; it states it +whenever nothing else is happening, which is the half of the time +nobody needs telling. + +⚠️ AND IT CARRIES A STANDING LATCH RATHER THAN HIDING ONE. The three +`bad` branches below all say some version of "held at 0 W", which is +still true in shadow mode - but an operator who turns shadow mode off +with a latch up gets a battery that will not move and a page that +never mentioned it. `shadow.banner()` names the latch and goes red +for it, so nothing this cascade would have said is lost. + +### L7596 ⚠️ ABOVE THE SIGN FAILSAFE, and the order is a decision. Both are + +⚠️ ABOVE THE SIGN FAILSAFE, and the order is a decision. Both are +latched P_SAFETY stops, so the site is at 0 W either way and the +banner can only carry one sentence. This one goes first because it +is the fault the operator has NO OTHER WAY of learning about: a +sign disagreement is visible at the meter and on every dashboard +in the house, and circulating power is visible nowhere at all. + +### L7604 SAFETY-30's site half, ABOVE the per-unit rows below and below + +SAFETY-30's site half, ABOVE the per-unit rows below and below +circulating power above, and the ordering is the same argument +both times: the banner carries one sentence, so it carries the one +that says the widest true thing. A site sign trip means EVERY +inverter is parked and the fault is in what the site was asking +for, not in one device - naming a unit here would send an operator +to swap the wrong box. + +### L7613 Outranks "Not ready": nothing is missing, something went wrong. + +Outranks "Not ready": nothing is missing, something went wrong. +At a roster of one the unit was graded against the meter; at N>=2 +against its own power, so the banner says so - see checks() on +why the noun is load-bearing rather than cosmetic. + +### L7620 ⚠️ `banner` IF THE ROW OFFERS ONE, `text` OTHERWISE. A row may + +⚠️ `banner` IF THE ROW OFFERS ONE, `text` OTHERWISE. A row may +need to say more to an operator reading the checks list than fits +in the one line at the top of the page - SAFETY-25's floor-limited +row is the case that forced this - and a headline that is a full +paragraph, repeated verbatim by the row underneath it, is a +headline nobody reads. UX-08: without the key, the banner takes +the row's first sentence, so it stays a headline. + +### L7639 ⚠️ SAFETY-42, AND IT IS A `warn` BANNER RATHER THAN A `bad` ONE, + +⚠️ SAFETY-42, AND IT IS A `warn` BANNER RATHER THAN A `bad` ONE, +WHICH IS THE WHOLE "reports, does not act" DECISION IN ONE LINE. +Every `bad` banner above means the site is at 0 W; this one means +the site is STILL RUNNING and quietly short by one unit's share, +and a red headline would say the controller had stopped. + +⚠️ BUT IT MUST DISPLACE "Running — holding grid at zero", AND THAT +IS NOT COSMETIC. That sentence is a claim the site cannot make +while a unit is absent - the loop is saturated and the meter is +short - and "the page reads healthy while the fault runs" is +precisely the shape this whole card exists to remove. It sits +BELOW "Stopped" because a stopped site is the more urgent fact and +the banner carries one sentence. + +### L7655 ⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE. At a roster + +⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE. At a roster +of one this is the string it has always been, character for +character; at two, "Maintenance: drain" without a name is a +banner an operator cannot act on, because the remedy is at one +device and the site has two. + +### L7669 ⚠️ THE EARLIEST ACROSS THE PACKS, NOT THE FIRST PACK'S. One date on a + +⚠️ THE EARLIEST ACROSS THE PACKS, NOT THE FIRST PACK'S. One date on a +site with two schedules has to be the one that comes first, or the row +says "2026-10-01" while a pack recalibrates tomorrow. `None` means +"never run, due now" and has to win over any date, so it is tested for +rather than sorted with. At a roster of one this is that pack's own +date, unchanged. + +### L7682 SAFETY-31, and it belongs DIRECTLY UNDER the two figures it is not + +SAFETY-31, and it belongs DIRECTLY UNDER the two figures it is not +derivable from. "Grid 0 W, Battery 0 W, Circulating 6000 W" is the +whole card in three rows: both of the numbers an operator normally +reads say the site is idle, and the third says 6 kW is going round +between the packs. Only shown at N>=2 - at one inverter it is +identically zero and a permanent "0 W" row would be noise. + +### L7690 TEL-11, and ONLY when a source is configured - see `checks()`. + +TEL-11, and ONLY when a source is configured - see `checks()`. +`w(None)` renders "—", which is how this row says "no reading" +rather than "0 W". It sits beside Grid and Battery because the +question it answers is the one those two cannot: at "Grid 0 W, +Battery 0 W" the site is either becalmed or throwing away every +watt it makes, and only this row separates them. + +### L7735 FCST-04. WHAT §8.3 HAS TO CARRY FORWARD, which is the difference + +FCST-04. WHAT §8.3 HAS TO CARRY FORWARD, which is the difference +between a 48-hour plan and a 12-hour one on a weekday morning. +It is its own row rather than a clause of "Tariff" because it +answers a different question: "Tariff" says what the plan IS, and +this says what the plan could still be filled from if the feed is +late again tomorrow. + +### L7742 OPT-04. The strategy's own sentence, not a re-derivation of it - + +OPT-04. The strategy's own sentence, not a re-derivation of it - +every refusal it can make already carries a reason a human can +act on, and "Arbitrage: no" would throw all of them away. + +⚠️ TARIFF-10 RE-READ THIS STRING AND LEFT IT ALONE UNDER A +COMMENT THAT WAS FALSE, AND TARIFF-11 IS THAT CORRECTION. The +claim was that `self.arb is None` **if and only if** +`arbitrage.enabled(self.o)` was False. Only the forward direction +holds: `cycle()` returns at the input hold (`bad_for < +stale_input_s`, default 15 s) BEFORE it reaches `self.arb = None` +at all, so on an armed site `self.arb` is `__init__`'s None at +startup and the PREVIOUS cycle's decision during a later hold - +and the row rendered `off (arbitrage_enabled)` on a site whose +`arbitrage_enabled` is `true`. `arbitrage_row()` reads the option +instead of inferring it, and separates "trading is off" from "no +decision was reached this cycle"; `cycle_at` and `arb_at` are what +make the second one an observation rather than a guess. +OPT-11 P3: with battery modes on the modes trade, whatever the +switch says, so the row reports their decision. + +### L7768 UX-46. The rows were built in English with their Dutch beside them + +UX-46. The rows were built in English with their Dutch beside them +(`messages.Bi`); the page's language is chosen once, here, for the +Home sentences, the checks list, the rows that carry one and the +banner. A plain str (no Dutch twin) stays as it is. + +### L7778 The action log itself, newest first. The page does not show it + +The action log itself, newest first. The page does not show it +(UX-08 2d), but an installer can still read it in the `status` +JSON, because several events (a dropped inverter's reason, the +peak guard's projection) are written nowhere else. + +### L7827 ⚠️ THE ONLY WAY OUT OF A SIGN-DISAGREEMENT TRIP, on purpose. + +⚠️ THE ONLY WAY OUT OF A SIGN-DISAGREEMENT TRIP, on purpose. +It clears on a deliberate human start, not on the condition +going away - the condition ALWAYS goes away, because the trip +commands 0 W and a 0 W command cannot disagree with anything. +An operator pressing start has, at minimum, looked at the +banner that says why. + +⚠️ IT CLEARS EVERY INVERTER'S LATCH, AND SAYS SO PER INVERTER. +One button is what the UI has and one button is what §15's +banner tells the operator to press; what must not happen is a +press that clears A while B stays latched with nothing on the +page changing. Each latch is cleared under its OWN key, so an +install that had only B tripped comes back with only B's +history cleared. + +### L7851 ⚠️ AND THE PRE-DEV-02 FLAT KEYS GO WITH THEM, UNCONDITIONALLY. + +⚠️ AND THE PRE-DEV-02 FLAT KEYS GO WITH THEM, UNCONDITIONALLY. +This is what makes `flat True + namespaced False` MEAN +something, and it is the reason `_grade_store_migration()` can +run on every start instead of only on the start that migrates. + +Without it the flat key is a fossil that says `true` forever +on any install that ever tripped, so a guard comparing against +it would re-arm the failsafe on every restart for the rest of +the install's life - which is why that guard used to be gated +on `rep.ran`, and why the gate then swallowed the rollback +ROUND TRIP: migrate, downgrade, trip during the downgrade +window (the old code writes only the flat key), upgrade again +-> `rep.ran` is False, the guard never runs, and the +controller comes up UNLATCHED while /data/state.json says +`sign_tripped: true`. That is store.py's own header sentence, +rebuilt by the version marker. + +Cleared here, the two spellings can only disagree one way: the +flat key is newer than the namespaced one, which means it was +written by a version that did not know about namespaces - i.e. +a trip inside a downgrade window. Unconditional rather than +inside the loop because an operator pressing START is +declaring the whole site clear, and the fossil belongs to no +unit in particular. + +### L7877 SAFETY-30's SITE latch, cleared by the SAME press and for the + +SAFETY-30's SITE latch, cleared by the SAME press and for the +same reason the per-unit ones are: the condition always goes +away on its own, because the trip commands 0 W everywhere and +a site commanding zero cannot oppose anything. Only a human +who has read the banner can say the site is clear. + +⚠️ UNCONDITIONAL, LIKE THE FLAT PAIR ABOVE, AND NOT GUARDED ON +THE ROSTER. An install that ran with two inverters, tripped, +and was then reconfigured back to one would otherwise carry +`site_sign_tripped: true` in its file forever, with no roster +that can reach the code which clears it - and would re-arm the +moment a second inverter was configured again. + +### L7896 SAFETY-31, cleared by the SAME press and for the same reason: + +SAFETY-31, cleared by the SAME press and for the same reason: +the condition always goes away on its own, because the trip +commands 0 W everywhere and two inverters at zero cannot +oppose. Only a human who has read the banner can say the site +is clear. + +⚠️ AND `_circ_announced` GOES WITH IT. It is the "has a human +been shown this" flag; leaving it True through a clear would +mean the NEXT trip in this process latched, stopped the site, +and raised no notification at all. + +### L7913 SAFETY-42, cleared by the SAME press, and for a reason that is + +SAFETY-42, cleared by the SAME press, and for a reason that is +NOT the one above. The other three latches are cleared here +because their trip removed its own evidence - a site commanding +0 W cannot oppose anything - so only a human can say the fault +is gone. This one commands nothing, so its evidence is still +standing and it could in principle clear itself. It does not: +a unit that stopped delivering for minutes and then resumed is +an INTERMITTENT fault, and a detector that erased its own +record of that would be the least useful thing in this file. +⚠️ AND `_delivery_announced` GOES WITH IT, like +`_circ_announced`: leaving a name in it through a clear would +mean the NEXT trip on that unit latched and raised no +notification at all. + +### L7938 ⚠️ THE WHOLE SITE, TARGETS INCLUDED - see command_site_zero(). + +⚠️ THE WHOLE SITE, TARGETS INCLUDED - see command_site_zero(). +Zeroing `self.target` alone would leave each unit's last share +standing for its next heartbeat to re-emit, which an operator +would read as the stop button not working. + +### L7949 ⚠️ WHICH PACK, BECAUSE THERE ARE NOW SEVERAL AND THE BUTTON HAS + +⚠️ WHICH PACK, BECAUSE THERE ARE NOW SEVERAL AND THE BUTTON HAS +ONE LABEL. `_maint_button()` answers it: the pack that holds the +site's maintenance slot if one does - which is what "start" +already meant at a roster of one, where pressing it mid-drain +re-entered DRAIN - and otherwise the pack that has gone longest +without a cycle, which is the same pack `arbitrate()` would have +picked on its own. At N=1 both branches are `maints[0]` and this +is the shipped call, byte for byte. + +This is the bare request (no unit). A request naming a battery +was handled above by `_maint_unit_action()` (MAINT-11). + +### L7991 OPT-11 P2: `(modes, retract)` for the MQTT publisher at start-up. + +OPT-11 P2: `(modes, retract)` for the MQTT publisher at start-up. + +The config topics announced are kept in the store, so turning modes off +removes exactly those (`mqtt.mode_configs()`). An install that never +turned modes on gets nothing, and its store is not written. + + +### L8025 `{slug, ha_version}` from the Supervisor, each None when unreadable. + +`{slug, ha_version}` from the Supervisor, each None when unreadable. + +`GET /addons/self/info` and `GET /info` are on the Supervisor's bypass +list for an add-on's own token, so they need no role (see selfconfig.py). +Asked together with a 3 s budget: a link may never hold up control start. + + +### L8117 Startup self-check: prove we can actually reach the Core API before + +Startup self-check: prove we can actually reach the Core API before +anything tries to control an inverter with it. A 401 here is a +permissions/token problem, not a configuration mistake, and saying so +explicitly saves an installer from re-checking entity ids for an hour. + +### L8134 UX-09: the Details link in every notification needs this add-on's + +UX-09: the Details link in every notification needs this add-on's +slug and Home Assistant's version (see messages.docs_url()). Both +are best effort: without them the link falls back to the hostname, +or to naming the section only. + +### L8140 ⚠️ THE STARTUP GATE (CFG-01). Nothing below this line may run on a + +⚠️ THE STARTUP GATE (CFG-01). Nothing below this line may run on a +configuration validate.py rejects - specs §1.5: a config the system +cannot honour is refused at startup, not survived at runtime. + +It sits HERE, before the broker, before the web server and before any +task exists, so a refusal cannot leave half a controller running with +a UI in front of it. Reading the device range is the one thing the +validator cannot do for itself; (None, None) when ESPHome has not come +up yet is not a refusal - run_control() re-checks once the real rating +is known. See validate.py for where the line is drawn and why. + +### L8156 UX-15: a new install names no inverter. When that is the ONLY + +UX-15: a new install names no inverter. When that is the ONLY +refusal it serves the setup wizard in setup-only mode, which builds +nothing that commands and returns when stopped. Any other refusal +still exits 78 (DECISIONS/UX-15-setup-only-mode.md). + +### L8169 ⚠️ Status publishing must NEVER be able to stop the controller. A + +⚠️ Status publishing must NEVER be able to stop the controller. A +broken broker, a missing library, an API change in paho - all of it is +observability, and the battery does not care. Caught broadly and on +purpose: this crashed the add-on once already (paho 1.x vs 2.x) and +took the control loop down with it. + +### L8186 TEL-11. ⚠️ THE SHIPPED PREDICATE, NOT A SECOND READING OF THE + +TEL-11. ⚠️ THE SHIPPED PREDICATE, NOT A SECOND READING OF THE +OPTION - `Controller.publish()` gates the row on this same +call. An install with no PV roster announces exactly +what it announced before: no topic, no entity, no row. + +### L8191 TEL-07. ⚠️ THE NAMES, NOT A ROSTER. `build_roster()` here + +TEL-07. ⚠️ THE NAMES, NOT A ROSTER. `build_roster()` here +would construct a SECOND set of `Inverter`s off the same +Store, minutes before `Controller.__init__` constructs the +real ones; `roster_names()` is the naming rule on its own and +is what `build_roster()` itself reads. At a roster of one this +announces nothing new - see `announced_units()`. + +### L8231 OPT-04. The tariff refresh runs as its OWN long-lived task, for the + +OPT-04. The tariff refresh runs as its OWN long-lived task, for the +reason P1 ingestion does and then some: `resolve_tariff()` may open an +HTTPS connection to a supplier, and the control tick is what feeds the +ESP32 heartbeat. A fetch on that path is a supplier's TLS timeout able +to push this add-on towards layer 1's 30 s veto. See TARIFF_REFRESH_S. + +⚠️ STARTED FOR A PROVIDER, FOR ARMED TRADING OR FOR BATTERY MODES: +see `tariff_refresh_wanted()` above and DECISIONS/TARIFF-10 and +TARIFF-24 for why each term is there. + +⚠️ IT DOES NOT ARM ANYTHING. Fetching a price and trading on one are +two decisions; this is the first, and `cycle()`'s +`if arbitrage.enabled(self.o):` is still the only gate on the second. + +### L8282 ⚠️ No traceback, on purpose. The banner refuse() already printed says + +⚠️ No traceback, on purpose. The banner refuse() already printed says +everything an operator needs, and a stack trace on top of it would say +"this crashed" - the exact confusion AC3 is about. 78 is sysexits(3) +EX_CONFIG, so even a script reading the exit status can tell a bad +config from a bug. diff --git a/goodwe_controller/DECISIONS/MAIN-orchestration.md b/goodwe_controller/DECISIONS/MAIN-orchestration.md new file mode 100644 index 0000000..6567cbb --- /dev/null +++ b/goodwe_controller/DECISIONS/MAIN-orchestration.md @@ -0,0 +1,51 @@ +# MAIN - why app/main.py is built the way it is + +**Status: SHIPPED.** Written down by MAINT-20 part 2 from the long comments and +docstrings of `app/main.py`, so the module can keep one line and a pointer. The +comments that were shortened but not rewritten here are kept word for word in +[[MAIN-orchestration-archive]], by their line in `app/main.py` at release/1.0 +`d4c01da`. Related: [[SAFETY-07-peak-guard]], [[FCST-04-the-day-that-is-carried]], +[[SAFETY-30-per-unit-sign]], [[SAFETY-73-refusal-per-inverter]], +[[SAFETY-121-bound-every-quarter]], [[TARIFF-10-fetching-is-not-trading]], +[[TARIFF-24-modes-need-the-tariff-task]], [[OPT-34-the-night-charge-keeps-its-level]]. +## Controller construction + +- **Per-write budget (DEV-02).** Each write is dispatched concurrently and bounded, so one hung `hass.call()` cannot eat a sibling's layer-1 budget (the ESP32 zeroes after about 30 s, and a fault on A could zero a healthy B). The number is derived: `heartbeat_s` max 25 s, layer-1 veto `validate.FIRMWARE_INGEST_VETO_S` 30 s, so 25 + 4 < 30. test_inverter.py grades the inequality, so raising the heartbeat ceiling goes red instead of taking an inverter to zero. Below hass.py's 10 s HTTP timeout on purpose: abandon the write and re-issue it next tick, since `last_write` moves only on an accepted write. +- **Tariff refresh (OPT-04).** `resolve_tariff()` may do an HTTPS fetch. The tick feeds the ESP32 heartbeat, so a supplier's TLS timeout on the tick would push the add-on into layer 1. The tick never awaits a fetch. The 30 min cadence is not about price change (day-ahead publishes once at 14-16 h CET and 8.3's carried hours go firm then); it picks the switch up within the hour at 48 fetches a day. The age bound answers another question: a failed refresh still stores a plan (the 11.4 static fallback), the bound covers the refresh task dying while a plan from hours ago stands as current. Three missed refreshes, for `p1.max_age_s`'s reason. +- **Status rows (TARIFF-11).** The Tariff row used to be `static_status()`'s sentence alone and never saw the provider plan (read 'no static profile' at all 802 samples of a 40 h soak with a live feed). `tick_words` and `plan` are two clocks and the row says so; the plan is stamped with its own age from `tariff_at` (TARIFF-12 does not move it on a kept plan), and `age_s` is the caller's measurement. The Arbitrage row used to be `"off (arbitrage_enabled)" if self.arb is None`, under a comment saying `arb is None` iff trading was off; false, since `cycle()` returns at the input hold before `self.arb = None`: `arbitrage_enabled: true` plus a sensor answering None rendered 'off (arbitrage_enabled)', naming an option that is on, and past the first cycle the previous decision stood as current. Three states: off, armed with no decision this cycle, a decision with its age (always printed: a row that reports staleness only once stale cannot be told from one that never does). +- **`stop` is required (review catch).** `stop=None` made a Controller that cannot stop itself the default: on the late-refusal path in `run_control()` it logged the banner, returned and left the process alive forever with no loop and no output. +- **Action log first.** The store migration can log to it (15: how an operator finds a latch was re-armed); an AttributeError on the way to saying so would stop the add-on booting. +- **Roster and migrations (DEV-02, SAFETY-44, MAINT-05).** Every per-device value lives on the roster objects (app/inverter.py); a `Tuning` and an integrator are deliberately not per device (6.2: one integrator on P1 above the split; a second regulating the same meter is the two-writers failure). The SAFETY-02 key migration runs before the first `Inverter` reads its latch, else a tripped install comes up armed with a green banner. SAFETY-44: it is handed `roster_names(opts)[0]`, never `UNIT_A` (on a declared roster the flat latch landed under `unit/inverter-a/`, read back under the name it was handed, stamping the marker); `roster_names()` returns `[UNIT_A]` on the flat shape so today's path is byte-identical. `misdirected` is unreachable from that call site (`unit` is a roster member by construction; dropping the `roster` argument survives 28/28), so the claim that the call site buys the refusal is gone; the argument stays for new callers and is graded in test_maintenance.py. The maintenance migration targets `self.inverters[0].name`, not `UNIT_A`: with `UNIT_A` the history would sit under a name on no roster (a nameless entry falls back to a positional `inverter-1`). Every two-pack fixture once named its first entry `inverter-a`, so swapping them left 19 files green; `migrate_maint_keys()` now grades the namespace it wrote against the names the machines read, and test_maintenance.py sections 9 and 11 use `["garage", "shed"]`. +- **CFG-04 disclosure.** A declared `inverters:` roster ignores `soc_entity`, `batt_entity`, `batt_invert` and `max_w`, which the UI still shows. Rule 9(e) refuses the dangerous one (a flat `setpoint_entity`); the rest cannot be refused (the schema has no blank value) so one action-log line at every start names the roster and the ignored keys. +- **Site bound.** Summed from the roster, not `opts["max_w"]`: it is the integrator bound and rail of the site loop, then split. Identical at N=1; a per-unit `max_w` binds each share through its `supervised` claim. +- **SAFETY-39 slew fallback** is 0 ("follow the rail"); it was 1000, a stale third copy. `control.slew_limit_w()` is the one reader. +- **CFG-03 in main.py.** Eight `opts.get(key, )` calls used to live in the maintenance setup and two disagreed with config.yaml (2500 against 2000). `MaintConfig.from_options()` reads them. A ninth key goes in config.yaml `options:`/`schema:` and `maintenance.MAINT_OPTION_DEFAULTS` only. +- **Watchdog thresholds (SAFETY-30).** The per-unit pair is re-derived at two inverters, not carried (it grades a command against one unit's own power). The site pair is used only at N>=2: the 300 W threshold carries (meter noise, deadband), the window does not (110 s at one inverter, 215 s at two, against a 180 s hold). +- **Latches (SAFETY-02, -31, -34, -42, -18).** SAFETY-02: one per actuator, restored from namespaced keys; graded on every start (a latch that failed to move namespace, or was taken by an older add-on in a rollback, is a disarmed guard, so a disagreement re-arms by hand and shouts). SAFETY-31: site-level, flat keys (circulating power is defined over the roster), restored because it acts. SAFETY-42's delivery latch does not act, so it is restored only so the ALARM survives (the 2026-09-02 outage went unseen 6 h 25 m with the detector correct). SAFETY-18 is not restored (does not act). SAFETY-30's site check is armed only at N>=2. +- **PV (TEL-11).** Instrumentation only; wiring it in would be an unrequested behaviour change and would destroy the measurement. +- **Peak guard setup (SAFETY-07, TARIFF-01, FCST-04).** See SAFETY-07-peak-guard.md and FCST-04. The windowing caveat for FCST-04: hours already elapsed leave the spread, so a cheap night that has gone no longer sets the day's cheapest hour. + +## Cycle state and latch repair + +- **Allocation record (SAFETY-28).** The tick that mints a record increments once per resolution, not per loop iteration. A cycle with no resolution (the "inputs missing, not yet stale" hold) must not bump it: that hold returns before the allocator so one missed poll does not zero the inverter, and the heartbeat re-affirms `self.target` through the write path; bumping would make that heartbeat unauthorised and drop the site to 0 W. The record is superseded, never expired. +- **`self.arb` (TARIFF-11).** See "Status rows" above; `arb_at < cycle_at` is the derived "the latest cycle did not reach the arbitrage step". +- **Shadow (SAFETY-10, SAFETY-47).** `shadow_would_w` is what a live controller would have put on the wire; `self.target` is what is standing on the wire (held at `shadow.WIRE_W`, re-seeded each shadow cycle). Folding them shows an operator a number nothing is doing. The "sized from the billed-quarter meter" flag is recorded where the decision was taken (the page runs on its own task); False, not None, before the first shadow cycle. +- **ARB-03.** `units` is a property derived from the roster so a site cannot claim two actuators while holding one writer. At N=1 (6.2) the grid connection and the only inverter are one actuator; a second entry makes per-inverter claims stop being site claims (ALLOC-01). +- **DEV-02 latch guard (`_check_sign_latch...`).** Runs on every start: gating on `rep.ran` swallowed store.py's rule-2 round trip (upgrade, downgrade with a trip in the window, upgrade; the controller came up untripped while state.json said `sign_tripped: true`; a firmware rollback path was performed on this site on 2026-09-02). The gate could go because `handle_action()` clears the flat keys with the namespaced ones and the trip mirrors them. SAFETY-44 second half: the question is whether a namespaced latch THAT A MACHINE READS is set. An earlier version accepted any `unit/*/sign_tripped`, which blinded the guard on exactly the installs the broken migration had put the latch under `unit/inverter-a/` (a key nothing reads). The first blunt fix (every off-roster latch is litter) re-armed a healthy A off B's mirrored flat key (test_inverter.py DEV-02 restart case). `UNIT_A` is the discriminator: the only name the broken migration wrote. A single flag written for two intents was defeated by one removed unit's key (roster ['garage','shed'] with `unit/old-shed/sign_tripped` drove on a state file reading `sign_tripped: true`; `tools/rehearse_roster.py` exit 1); the fixtures now hold both arrangements. Residual: a first unit really named `inverter-a`, latched and removed from the roster is re-armed on `inverters[0]` (conservative). The printed reason is the flat mirror's (whatever tripped last), so on a multi-unit site one inverter's evidence may appear under another's name; test_maintenance.py's check that the reason is not the removed unit's depends on that fixture's flat string, not on this code. +- **Maintenance views (MAINT-05).** `maints` is one FSM per roster entry; `maint`, `maint_unit` and the button target are views of the one that holds the slot (`maintenance.arbitrate()` allows at most one out of IDLE). validate.py rule 6 grades `maintenance_charge_w` against the weakest cap on the roster. +- **N=1 views.** Properties, not fields, and they refuse at N>=2 (6.2's identity is false there). Nothing inside main.py may read them (test_inverter.py AST scan). +- **`_arm_sign_watchdogs` (SAFETY-30).** Called every cycle because fixtures append an `Inverter` after construction; a detector frozen at construction would be graded against the meter at a roster of two. `arm()` touches no timer or latch. +- **Per-inverter refusal (SAFETY-73).** The probe ask is PROBE_W, not `saturation_w`: a unit's share is often small (a 400 W night load split two ways) and SAFETY-43 would name the empty battery as not delivering. The site target is restarted from the watts on the wire when a unit reopens (phase A: -1059 W). + +## Write path and cycle + +- **The write gate (SAFETY-28).** specs 1.5 makes two writers on one actuator a non-goal. `len(validate.ACTUATOR_WRITE_KEYS) == 1` is a fact about the options schema, not the writes, and goes false when CFG-04 adds per-inverter config. So the enforcement is on the write: a number reaches the wire only if the single allocator placed it, on this tick, for this unit (`Allocation.authorises()`); a second writer must file a Claim. 0 W is always authorised and the test for zero comes first and reads nothing: the failsafe branches write 0 W while the allocator has refused (`Allocation.ok` False, no shares), and a gate asking its permission would deadlock the failsafe behind the component it answers; at S = 0 broadcast and allocation coincide, so there is no over-command (the hazard is N-times loop gain from broadcast, TEST-19's `N=2 gain 1.0` limit cycle). The predicate is `value == 0.0`, not a `failsafe=True` flag, which a second writer would pass; -0.0 == 0.0 and a nan never gets here. DEV-02: the unit is an argument, not the N=1 view; the default refuses at N>=2. The refusal sits above the clamp and below the non-finite coercion. +- **Clamp (SAFETY-03)** is on the only path to the wire, so no optimizer, maintenance or manual path routes around it. A non-finite value is loss of input (11.1), coerced to 0 W. Every engagement goes to the action log undeduplicated; the notification is deduplicated. +- **Shadow (SAFETY-10)** suppresses in the same method, below the gate and the clamp, as a substitution not a return. It is not a second writer. +- **Reads (DEV-02, TEL-12).** Each inverter's readings are gathered concurrently across units and sequentially within one; PV entities ride in the same gather (a slow sensor must not push a healthy battery toward layer 1's veto). P1 has no inverter-side fallback: the inverter's AC power correlates 0.998 with battery power and 0.09 with the real meter. +- **Stale reading (SAFETY-37/56/57).** The power reading goes through the freshness gate, the SoC does not (the power feeds the sign and delivery checks, which a frozen register fools). SAFETY-57 rewrote the escape clause: "it clears itself when the register reports a different value" was false for the state announced, because the 0 W it announces is what stops the value changing. The release is reported, never silent (it withdraws a SITE STOP). +- **TARIFF-03.** The one shiftable load is the maintenance cycle's cheap-window top-up; the static window is synchronous and I/O-free, and validate.py refuses `static_profile` together with the price entities. +- **Cycle ordering (SAFETY-02/31/42).** The watchdog evaluations run every cycle, before the input hold, so a run of held cycles cannot leave their timers resuming from a start that predates the gap. Reason first, flag second (each `set` writes the file): a crash between persists a reason nothing reads, not a latch with no explanation. The per-unit trip mirrors the legacy flat keys (store.py rule 2 covered only the latch present at migration time). The site check is flat-keyed, armed only at N>=2. SAFETY-31's input is MEASURED power (`inv.batt`), never the allocation record, which has sign-coherence by construction. +- **Maintenance FSMs (MAINT-05).** Each pack is ticked with its own SoC (a minimum would abort a cycle on a sibling's state, an average would declare a pack full that is not); `arbitrate()` runs first. A deferral is carried to the status page because the edge-logged line scrolls out of the 40-row log. +- **Aggregates.** SUM for the site bound, achievable rating and the battery power the site loop regulates against (`compute()`'s saturation test compares against the site target; one unit's power trips the freeze permanently, up to 510 W of split-dependence at gain 0.2, test_alloc.py part 8). MINIMUM for the discharge permission (the emptiest pack). MEASURED power for circulating power. Unknown bounds stay unknown when any unit is silent. +- **Trade sizing (OPT-09).** `trade_sizing` uses arbitrage's own SoC bounds, not the BMS cut-out (SAFETY-25): sizing is the trade's business, the pack envelope is the pack's. Weights are a preference, so a pack between its arbitrage floor and its cut-out can still take part of a sell; fixing it needs a per-pack envelope depending on which claim won the tick. diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 35fced1..2ae7903 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -1,27 +1,22 @@ """GoodWe RS485 Controller add-on - entry point and orchestration. -WHAT THIS THING DOES, in one paragraph: it reads the house's net grid power from -Home Assistant, decides how hard the battery should charge or discharge to hold -that at zero, and writes that figure to an ESP32 which puts it on the inverter's -RS485 EMS port. Once a month it runs a full low->full battery cycle so the BMS -can balance cells and recalibrate its coulomb counter. +It reads the house's net grid power from Home Assistant, decides how hard the +battery should charge or discharge to hold that at zero, and writes the figure +to an ESP32 that puts it on the inverter's RS485 EMS port. Monthly it runs a +full low->full cycle so the BMS can balance cells and recalibrate. -⚠️ THE ONE THING TO UNDERSTAND BEFORE CHANGING ANY OF THIS: the inverter holds -the last command it understood FOREVER. It has no meter-timeout of its own. -Measured on real hardware: a controller died mid-command and the inverter held -5 kW of discharge for 113 s until a human noticed. Every failsafe in this system -exists because of that one fact: +THE ONE FACT BEHIND EVERY FAILSAFE: the inverter holds the last command it +understood FOREVER (measured: 5 kW of discharge held for 113 s after a +controller died). So when in doubt this process stops writing and the hardware +takes the battery to zero. Never "hold the last value to be safe". - layer 1 the ESP32's own watchdog - if we stop refreshing for ~30 s it - commands 0 W and KEEPS WRITING it. Stopping is the failure, not the - fix. This add-on's heartbeat is what feeds it. + layer 1 the ESP32 watchdog: no refresh for ~30 s -> 0 W, and it KEEPS + WRITING it. This add-on's heartbeat feeds it. layer 2 wind-down before a firmware update, in the ESP32. - layer 3 the optional RS485 e-stop, which writes 0 W after 30 s of total bus - silence. It is the ONLY thing that covers this add-on's host dying. + layer 3 the optional RS485 e-stop: 0 W after 30 s of bus silence; the only + cover for this add-on's host dying. -So: when in doubt, this process stops writing, and the hardware takes the -battery to zero on its own. Never "hold the last value to be safe". -""" +Per-section reasoning: DECISIONS/MAIN-orchestration.md.""" import asyncio import contextlib @@ -71,9 +66,8 @@ from .freeze import ( FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w, ) from .hass import CORE_API, OUTAGE, HomeAssistant -# ⚠️ `LINK_STALE_S` IS RE-EXPORTED DELIBERATELY. `live_units()` reasons about it -# and two checks read `app.main.LINK_STALE_S` to build a stale link, so the -# window an operator would look up here is the one the roster actually uses. +# `LINK_STALE_S` is re-exported deliberately: `live_units()` and two checks +# read `app.main.LINK_STALE_S`. from .inverter import (LINK_STALE_S, UNIT_A, build_roster, roster_entries, roster_names) from .maintenance import ( @@ -110,80 +104,51 @@ OPTIONS_PATH = "/data/options.json" INGRESS_PORT = 8099 _LOG = logging.getLogger("goodwe") -# ⚠️ `UNIT_A` NOW LIVES IN app/inverter.py AND IS RE-EXPORTED HERE, because the -# roster does. It stays importable from this module: four checks read -# `app.main.UNIT_A` and it is still the name of the inverter this add-on drives. -# DEV-02 is what turned "the one inverter" into a list; `build_roster()` is the -# one function CFG-04 changes to put a second entry in it. +# `UNIT_A` lives in app/inverter.py and is re-exported here because the roster +# does and four checks read `app.main.UNIT_A`. `build_roster()` is the one +# function CFG-04 changes. __all__ = ["UNIT_A", "Controller", "amain", "main"] -# --- DEV-02: THE PER-WRITE BUDGET, AND WHY IT IS SMALLER THAN A HEARTBEAT ---- -# Every write to every inverter is dispatched CONCURRENTLY and each one is -# bounded by this. Both halves are the acceptance criterion, not an -# optimisation: one hung `hass.call()` used to sit on the single serialised -# write path and eat a SIBLING'S layer-1 budget - the ESP32 commands 0 W after -# ~30 s without a refresh, so a transport fault on inverter A could zero a -# perfectly healthy inverter B. -# -# ⚠️ THE NUMBER IS DERIVED, NOT CHOSEN. `heartbeat_s`'s schema maximum is 25 s -# (config.yaml) and layer 1's veto is `validate.FIRMWARE_INGEST_VETO_S` (30 s), -# so the budget has to fit in what is left: 25 + 4 < 30. That is the whole -# argument, and test_inverter.py grades the inequality rather than the literal, -# so raising the heartbeat's schema ceiling takes this red instead of taking an -# inverter to zero. It is also below `hass.py`'s own 10 s HTTP timeout on -# purpose - we would rather abandon a write and re-issue it on the next tick -# (one second later, and `last_write` is only updated on an ACCEPTED write, so -# the re-issue happens by itself) than hold the control loop for ten seconds. +# DEV-02: the per-write budget. Every write to every inverter is dispatched +# concurrently, each bounded by this, so one hung `hass.call()` cannot eat a +# sibling's layer-1 budget. +# DERIVED, NOT CHOSEN: `heartbeat_s` max is 25 s and layer 1's veto is +# `validate.FIRMWARE_INGEST_VETO_S` (30 s), so 25 + 4 < 30. test_inverter.py +# grades the inequality, not the literal. It is below hass.py's 10 s HTTP +# timeout on purpose: abandon and re-issue next tick (`last_write` moves only +# on an ACCEPTED write). DECISIONS/MAIN-orchestration.md. WRITE_BUDGET_S = 4.0 -# SAFETY-34: a Core-only restart wipes persistent_notification and does NOT -# restart this add-on (measured on TEST-23), so a latched alarm is re-asserted -# this often. Why: goodwe docs/DECISIONS.md, SAFETY-34. +# SAFETY-34: a Core-only restart wipes persistent_notification without +# restarting this add-on, so a latched alarm is re-asserted this often. REANNOUNCE_S = 300.0 -# UX-11 review: a quiet meter logs at most one WARNING this often, because on -# `ha_signed` a healthy meter under a flat load can go 42-97 s between changes. +# UX-11: a quiet meter logs at most one WARNING this often (on `ha_signed` a +# healthy meter can go 42-97 s between changes). METER_QUIET_REPEAT_S = 600.0 _mono = time.monotonic # patched by the checks; asyncio keeps the real one -# --------------------------------------------------------------------------- # -# OPT-04: THE BACKGROUND TARIFF REFRESH, AND WHY IT IS NOT OPTIONAL -# -# `static_tariff.resolve_tariff()` may perform an HTTPS fetch. The control tick -# is what feeds the ESP32 heartbeat, and layer 1 commands 0 W if that heartbeat -# stops for ~30 s - so a supplier's TLS timeout on the tick is a SUPPLIER able -# to push this add-on into the failsafe. Every module in the price stack says -# so in its own words (static_tariff.static_plan, resolve_tariff, main's -# read_inputs) and this is the task those notes were waiting for. -# -# The tick therefore never awaits a fetch. It reads `self.tariff_plan`, an -# attribute this task assigns, and nothing else. -# -# ⚠️ THE CADENCE IS NOT A GUESS AT HOW OFTEN PRICES CHANGE. A day-ahead feed -# publishes ONCE, at 14-16 h CET, and §8.3's carried hours become firm at that -# moment; 30 minutes is short enough that the switch is picked up inside the -# hour it happens and long enough to be a polite 48 fetches a day. Nothing in -# the control law depends on it. +# OPT-04: the background tariff refresh. `static_tariff.resolve_tariff()` may +# do an HTTPS fetch, and a supplier's TLS timeout on the control tick could +# starve the ESP32 heartbeat into layer 1. So the tick NEVER awaits a fetch: it +# reads `self.tariff_plan`, which this task assigns. +# The cadence is not about how often prices change (day-ahead publishes once, +# 14-16 h CET; 30 min picks it up inside the hour with 48 fetches a day). +# DECISIONS/MAIN-orchestration.md. TARIFF_REFRESH_S = 1800.0 -# ⚠️ AND THE AGE BOUND IS NOT THE SAME QUESTION. A refresh that FAILS still -# stores a plan - resolve_tariff() degrades to the §11.4 static fallback, which -# is provisional and refuses arbitrage on its own. This bound covers the other -# case: the refresh task itself dying, leaving a plan that was true hours ago -# standing as if it were current. Three missed refreshes is the threshold, for -# `p1.max_age_s`'s reason - one missed poll is not a fault, a run of them is. -# A failed read is not an observation, and neither is a stale one. +# The age bound is a different question from the refresh: a failed refresh +# still stores a plan (the 11.4 static fallback, provisional). The bound covers +# the refresh task dying and a stale plan standing as current. Three missed +# refreshes, for `p1.max_age_s`'s reason. TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S def tariff_refresh_wanted(opts: dict) -> bool: - """Does this install need the background tariff refresh? (TARIFF-10, TARIFF-24) - - Yes with a price feed, with trading on price armed, or with battery modes - on: the modes read `self.tariff_plan` for their price runs. With - `price_provider` off the refresh is pure arithmetic on `static_profile`, so - no term here adds an HTTPS call. It decides whether prices are looked up, - never whether energy moves. See DECISIONS/TARIFF-10-fetching-is-not-trading.md - and DECISIONS/TARIFF-24-modes-need-the-tariff-task.md. - """ + """Does this install need the background tariff refresh? (TARIFF-10, + TARIFF-24) + Yes with a price feed, trading armed, or battery modes on. It decides + whether prices are looked up, never whether energy moves. + DECISIONS/TARIFF-10-fetching-is-not-trading.md and + DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.""" return (provider_configured(opts) or arbitrage.enabled(opts) or packmode.enabled(opts)) @@ -199,24 +164,23 @@ def load_options() -> dict: # OPT-11 P3: how long the last cheap run stays the price stored energy is -# valued at (`priceruns.plan(ref_eur_kwh=)`): the rest of that day. +# valued at: the rest of that day. REF_CHEAP_KEEP = timedelta(hours=12) # OPT-15: the longest a run edge holds the loop at E waiting for the meter to -# show the step: the plant's measured step time (control.PLANT_SETTLE_S). +# show the step (control.PLANT_SETTLE_S). EDGE_HOLD_S = _ctl.PLANT_SETTLE_S -# OPT-11 P4: the store key of the house profile (dayplan.HouseProfile), and -# how often the forecast sensors are read (they change hourly at most). +# OPT-11 P4: store key of the house profile, and how often the forecast sensors +# are read (hourly at most). HOUSE_PROFILE_KEY = "house_profile" # OPT-27: the forecast sensors' last fresh readings (`solarfc.Hold`), so an -# evening restart keeps the figure the night plan uses. +# evening restart keeps the night plan's figure. FC_HOLD_KEY = "solar_fc_hold" # OPT-34: the level of a night grid charge that has started, so it does not # drop when the forecast goes (`Controller._latch_charge()`). CHARGE_LATCH_KEY = "night_charge_latch" -# OPT-34: a latch outlives the cheap run it was last used in by one price -# slot, because the next run is planned at the slot edge, in the cycle after -# the old one ended. +# OPT-34: a latch outlives its last cheap run by one price slot, since the next +# run is planned at the slot edge. CHARGE_LATCH_GRACE_S = 900.0 FORECAST_READ_S = 60.0 @@ -261,44 +225,31 @@ def why_raised(resolution, claims) -> bool: def pack_energy_of(inv): """OPT-09. ONE roster entry as the view `packshare` consumes. - - ⚠️ ONE BUILDER, TWO CALLERS, AND THAT IS THE POINT. `Controller.pack_energy` - needs the whole roster (for the weights and the trade sizing) and - `Controller.cycle()` needs one entry at a time (for that unit's `pack` - claim). Written out twice, the two would drift - and the direction they - would drift in is a unit whose envelope is computed from a different set of - numbers than its weight, which is precisely the class of defect DEV-02 spent - a card removing. It is a module function rather than a method on `Inverter` - so that `inverter.py` does not have to import `packshare`. - """ + One builder for two callers (`pack_energy` needs the roster, `cycle()` one + entry): written twice they would drift. A module function so inverter.py + need not import `packshare`.""" return packshare.PackEnergy(inv.name, inv.soc, inv.bms_soc_floor, inv.capacity_kwh, inv.max_w) -# SAFETY-73 review: the evidence must also span this long, not only -# SETTLE_CYCLES cycles. SETTLE_CYCLES was derived at the 2 s meter tick (22 s -# from its first to its twelfth cycle). A 1 s meter makes 12 cycles only 11 s, -# shorter than a healthy battery reading's lag (BATT_OBLIGED_S, 17 s, plus the -# plant's dead time), and both inverters of a site were then refused after -# ordinary load steps. At 2 s and slower this changes nothing. +# SAFETY-73: the evidence must also span this long, not only SETTLE_CYCLES +# cycles: a 1 s meter makes 12 cycles only 11 s, shorter than a healthy battery +# reading's lag, and both inverters were refused after ordinary load steps. REFUSED_SPAN_S = (_ctl.SETTLE_CYCLES - 1) * _ctl.LOOP_TICK_S -# SAFETY-94: the evidence restarts when the reading has moved this far towards -# the ask since the evidence began. A refusing battery reads flat (-4 W live, -# 2026-09-06); live readings wander by under 25 W at rest (B's standby, -95 to -# -118 W, 2026-10-03). A battery crossing the 100 W band on a first-order lag -# gains 50 W in at most tau x ln(151/101) = 0.4 tau, so the evidence cannot -# span REFUSED_SPAN_S unless the lag is over 54 s. +# SAFETY-94: evidence restarts when the reading has moved this far toward the +# ask. A refusing battery reads flat; live readings wander under 25 W at rest; +# a first-order lag gains at most 0.4 tau x 100 W across the band, so the +# evidence cannot span REFUSED_SPAN_S unless the lag is over 54 s. REFUSED_PROGRESS_W = _ctl.PROBE_ANSWER_W / 2 -# SAFETY-85 review: a battery another owner set is graded against the least it -# was told over the same span as above (the plant's measured settle time), so -# a healthy battery still settling on a new command is not short. +# SAFETY-85: a battery another owner set is graded against the least it was +# told over the plant's settle time, so a battery still settling is not short. SHORT_CYCLES, SHORT_SPAN_S = _ctl.SETTLE_CYCLES, REFUSED_SPAN_S -# SAFETY-119/121: after a hand-back the loop's bound is hidden until -# HANDBACK_CYCLES cycles and SHORT_SPAN_S have passed; across all hand-backs -# on at most ROLLING_BOUND_CYCLES cycles in any ROLLING_S seconds. Measured in -# DECISIONS/SAFETY-121-bound-every-quarter.md. Without a monotonic clock the +# SAFETY-119/121: after a hand-back the loop's bound is hidden for +# HANDBACK_CYCLES / SHORT_SPAN_S, and across hand-backs on at most +# ROLLING_BOUND_CYCLES cycles per ROLLING_S +# (DECISIONS/SAFETY-121-bound-every-quarter.md). Without a monotonic clock the # budget never refills. HANDBACK_CYCLES = 4 ROLLING_S = 900.0 @@ -311,13 +262,10 @@ def _refused_interval(sign: int) -> tuple: def why_key(winner: str, claims) -> str: - """The key of `messages.WHY_WINNER` for a resolution's winner. - - "safety" and "failsafe" each stand for several causes, so for those the - winning claim's own reason is the key ("stopped", "inputs missing", "sign - disagreement"). A failsafe with no claim of its own is the arbiter's "no - claim was made", or main.py's refusal to split the power ("unsplit"). - """ + """The key of `messages.WHY_WINNER` for a resolution's winner. "safety" and + "failsafe" stand for several causes, so their own claim reason is the + key ("stopped", "inputs missing", "sign disagreement", or "unsplit" when + this module refuses to split the power).""" if winner == "loop": # SAFETY-52: a loop holding a refused direction says so. return next((c.reason for c in claims if c.source == "loop" @@ -332,43 +280,18 @@ def why_key(winner: str, claims) -> str: def tariff_plan_row(plan, tick_words: str, *, age_s, horizon_h: float, provider: bool, max_age_s: float) -> str: - """The *Tariff* status row: the 30 s tick's sentence AND the provider plan. - - THE DEFECT THIS PREVENTS (TARIFF-11). `origin`, `product`, the horizon, - the firm/carried split and the spacing appeared in NO status row. The row - was `static_status()`'s sentence alone, computed on the 30 s I/O-free tick - from its own static plan, and it NEVER SAW the provider plan - it read - `'no static profile'` at all 802 samples of a 40-hour soak on a site whose - feed was answering. The only external evidence a plan existed at all was - one `_LOG.info` line, and that line carried the point<->hour mislabel - `TariffPlan.words` is fixed for. - - ⚠️ TWO SOURCES, TWO CLOCKS, AND THE ROW SAYS SO IN WORDS. These sentences - are NOT computed at the same moment and presenting them as though they - were would be a new version of the defect being fixed: - - `tick_words` static_status() / the Home Assistant price entities, - regenerated on THIS tick, every 30 s, I/O-free; - `plan` the background refresh's resolution, up to - TARIFF_REFRESH_S old before it is retried and up to - TARIFF_MAX_AGE_S (5400 s since TARIFF-12) old before - `fresh_tariff_plan()` retires it. - - So the plan half is stamped with its OWN age in seconds, measured from - `tariff_at` - the moment of the fetch that produced it, which TARIFF-12 - deliberately does not move on a kept plan. - - ⚠️ AND `age_s` IS THE CALLER'S MEASUREMENT, NOT THIS FUNCTION'S. A clock - read in here would be a second clock disagreeing with the one that decided - the plan was fresh enough to pass at all. - - ⚠️ "NO PLAN" IS SAID OUT LOUD ON A SITE THAT CONFIGURED A PROVIDER. A - dynamic site whose refresh has never completed, or whose plan has aged - past `max_age_s`, is the exact state the soak measured; rendering it as - the bare static sentence is how it stayed invisible for 802 samples. - """ - # UX-08: plain words, every figure kept. The tick's sentence is "now"; - # the plan carries its own age. + """The *Tariff* status row: the 30 s tick's sentence AND the provider plan + (TARIFF-11). + Two sources, two clocks, and the row says so: `tick_words` is regenerated + on this I/O-free tick; `plan` is the refresh's resolution, stamped with its + own age from `tariff_at` (not moved on a kept plan, TARIFF-12), up to + TARIFF_MAX_AGE_S (5400 s) before `fresh_tariff_plan()` retires it. `age_s` + is the caller's measurement: a clock read here would disagree with the one + that judged freshness. "No plan" is said aloud on a site that configured a + provider (a soak read 'no static profile' at all 802 samples). + DECISIONS/MAIN-orchestration.md.""" + # UX-08: plain words, every figure kept. The tick's sentence is "now"; the + # plan carries its own age. if plan is None: if not provider: return tick_words @@ -380,39 +303,13 @@ def tariff_plan_row(plan, tick_words: str, *, age_s, horizon_h: float, def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now, why: str) -> str: - """The *Arbitrage* status row: THREE states, which used to render as two. - - THE DEFECT THIS PREVENTS (TARIFF-11). The row was - `"off (arbitrage_enabled)" if self.arb is None else self.arb.explain()`, - under a comment asserting that `self.arb is None` if and only if - `arbitrage.enabled(self.o)` was False. IT IS NOT: `cycle()` returns at the - input hold - inputs missing, `bad_for < stale_input_s`, default 15 s - - BEFORE it reaches `self.arb = None`. Driven: `arbitrage_enabled: true` - plus a sensor answering `None` gives `arbitrage.enabled(opts) = True`, - `self.arb = None` (its `__init__` value, untouched) and a row reading - `off (arbitrage_enabled)` - NAMING AN OPTION THAT IS ON. Past the first - completed cycle the same early return leaves the PREVIOUS decision - standing instead, which the row then rendered as a current one. - - THE THREE FACTS, and they are three because they have three remedies: - - trading is off the operator has not armed it. Nothing is - wrong and nothing will happen. - no decision this cycle it is armed, and the cycle did not get as far - as the arbitrage step. `why` is the control - loop's own reason - usually "inputs missing". - a decision the strategy's own sentence, with the age of - the cycle that produced it. - - ⚠️ `on` IS READ FROM THE OPTIONS, NOT INFERRED FROM `decision is None`. - Inferring it is the whole defect: the option is the only thing that - answers "is trading armed", and it is available on every render. - - ⚠️ THE AGE IS ALWAYS PRINTED, INCLUDING WHEN IT IS ZERO. A row that - reports staleness only once it is stale cannot be told, on any single - render, from one that never reports it - and the bound TARIFF-10's - comment claimed did not exist is ~15 s of it. - """ + """The *Arbitrage* status row: three states (TARIFF-11): off (not armed), + armed with no decision this cycle (`why` is the loop's reason), or a + decision with its age. + `on` is read from the options, never inferred from `decision is None`: + `cycle()` returns at the input hold before `self.arb = None`, so the old + row named an option that was ON. The age is always printed, even 0. + DECISIONS/MAIN-orchestration.md.""" # UX-08: plain words; the setting is named by its label. if not on: return (f"Off, “{messages.LABELS['arbitrage_enabled']}” is not " @@ -437,7 +334,7 @@ class Controller: price_history_path: str = "/data/price_history.json", site_tz=None): # UX-15: a read-only HomeAssistant belongs to setup-only mode, which - # builds no controller. Refused here so no controller can hold one. + # builds no controller. if getattr(hass, "read_only", False) is True: raise ValueError("a controller cannot run on a read-only " "HomeAssistant (setup-only mode)") @@ -445,69 +342,43 @@ class Controller: self.hass = hass self.store = store self.mqtt = mqtt_pub - # ⚠️ REQUIRED, AND KEYWORD-ONLY, AFTER A REVIEW CATCH. This started life - # as `stop=None`, which made a Controller that CANNOT STOP ITSELF the - # default one. On the late-refusal path in run_control() that default - # meant: log the banner, return, and leave the process alive forever - # with no control loop, no exit and no further output - the exact - # "inert but looks healthy" failure this whole module exists to refuse. - # amain() always passed a real Event, so it was latent, but test_p1.py - # was already constructing Controllers without one. No default now, so - # the broken shape does not typecheck. + # REQUIRED and keyword-only: a default `stop=None` made a Controller + # that cannot stop itself (inert but looks healthy on the late-refusal + # path in run_control()). DECISIONS/MAIN-orchestration.md. self.stop_event = stop self.refused: list[str] | None = None - # ⚠️ FIRST, BECAUSE THE STORE MIGRATION BELOW CAN LOG TO IT. §15's - # action log is how an operator finds out that a latch was re-armed on - # their behalf, and a guard that raises AttributeError on the way to - # saying so is a guard that stops the add-on booting. + # First, because the store migration below can log to it: 15's action + # log is how an operator learns a latch was re-armed, and an + # AttributeError here would stop the add-on booting. self.events: list[str] = [] # UX-08: the last event in plain words, for the page. See log_event(). self.hint = "" - # --- DEV-02: THE ROSTER, BUILT BEFORE ANYTHING THAT READS A RATING --- - # ⚠️ EVERY PER-DEVICE VALUE IN THIS CONTROLLER LIVES ON ONE OF THESE - # OBJECTS AND NOWHERE ELSE. app/inverter.py carries the argument for - # each one, including the two it deliberately does NOT hold - a `Tuning` - # and an integrator, because §6.2's stability property is ONE integrator - # on P1 above the split and a second one regulating the same meter is - # the two-writers failure the arbiter exists to prevent. - # - # ⚠️ AND THE STORE MIGRATION RUNS HERE, BEFORE THE FIRST `Inverter` READS - # ITS LATCH. `Inverter.__init__` restores SAFETY-02 from - # `unit//sign_tripped`; on an install written before DEV-02 that - # key does not exist yet and the latch is under the flat one. Migrate - # first or every existing tripped install comes up ARMED, into the - # fault, with a green banner - which is the exact failure the latch was - # persisted to prevent, rebuilt by a key rename. - # SAFETY-02's pair, and since SAFETY-30 it is THE SITE COMPARISON's - # pair - which at the shipped roster of one is the same thing under a - # more careful name. It is what `Inverter.sign_wd` is armed with at N=1, - # byte for byte, and what the site detector is armed with at N>=2. + # DEV-02: the roster, built before anything reads a rating. Every + # per-device value lives on these objects and nowhere else + # (app/inverter.py); a `Tuning` and an integrator are deliberately NOT + # per device (6.2: one integrator on P1 above the split). The store + # migration runs here, BEFORE the first `Inverter` reads its SAFETY-02 + # latch: else an existing tripped install comes up ARMED with a green + # banner. SAFETY-02's pair is the site comparison's pair (SAFETY-30), + # identical at N=1. DECISIONS/MAIN-orchestration.md. self.sign_cfg = sign_cfg = SignWatchdogConfig( min_w=float(opts.get("sign_disagree_w", 300)), hold_s=float(opts.get("sign_disagree_s", 180)), ) - # SAFETY-30. THE PER-UNIT COMPARISON'S OWN PAIR, RE-DERIVED AT TWO - # INVERTERS AND NOT CARRIED ACROSS. `sign_disagree_w` / `sign_disagree_s` - # were measured on two SINGLE-INVERTER bus captures against a command - # opposed to THE METER; this comparison is a command against ONE UNIT'S - # OWN POWER, where the magnitudes, the transient and the worst - # legitimate episode are all different quantities. watchdog.py's - # SIGN_OPTION_DEFAULTS carries the campaign that produced these, and - # DECISIONS/SAFETY-30-per-unit-sign.md carries the tables. + # SAFETY-30: the per-unit comparison's own pair, re-derived at two + # inverters, not carried across (a command against one unit's own power + # is a different quantity). DECISIONS/SAFETY-30-per-unit-sign.md; + # watchdog.SIGN_OPTION_DEFAULTS. self.unit_sign_cfg = SignWatchdogConfig( min_w=float(opts.get("unit_sign_disagree_w", SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"])), hold_s=float(opts.get("unit_sign_disagree_s", SIGN_OPTION_DEFAULTS["unit_sign_disagree_s"])), ) - # SAFETY-30. THE SITE COMPARISON'S OWN PAIR, USED ONLY AT N>=2. The - # threshold is the shipped 300 W - carried, because the argument behind - # it is about the meter's noise floor and the deadband, neither of which - # the roster touches - and the WINDOW is not, because the transient it - # rejects is the loop crossing zero at `slew_w` per cycle across a rail - # that IS the roster sum. Measured at both rosters on the same stimuli: - # 110 s at one inverter, 215 s at two, against a 180 s hold. See + # SAFETY-30: the site comparison's own pair, used only at N>=2. The 300 + # W threshold is carried (meter noise and deadband); the window is not + # (110 s at one inverter, 215 s at two, against a 180 s hold). See # watchdog.SIGN_OPTION_DEFAULTS. self.site_sign_cfg = SignWatchdogConfig( min_w=float(opts.get("site_sign_disagree_w", @@ -515,61 +386,29 @@ class Controller: hold_s=float(opts.get("site_sign_disagree_s", SIGN_OPTION_DEFAULTS["site_sign_disagree_s"])), ) - # SAFETY-42. THE WINDOW A UNIT MAY BE SHORT OF ITS SHARE BEFORE IT IS - # NAMED. There is no second key: the WATTS half of that check is - # `Tuning.saturation_w`, read at runtime rather than copied - see - # `delivery.MIN_W_IS_SATURATION_W`, and CFG-03 on why a fourth copy of a - # default is worth avoiding. + # SAFETY-42: the window a unit may be short of its share before it is + # named. No second key: the watts half is `Tuning.saturation_w`, read + # at runtime (`delivery.MIN_W_IS_SATURATION_W`, CFG-03). self.delivery_cfg = delivery_cfg = DeliveryConfig( hold_s=float(opts.get("delivery_hold_s", DELIVERY_OPTION_DEFAULTS["delivery_hold_s"])), ) - # SAFETY-44. THE NAMES THE MACHINES READ, HANDED TO THE MIGRATION THAT - # WRITES FOR THEM. This line used to read `migrate_unit_keys(store, - # UNIT_A)`, and the constant was wrong in exactly the way the - # `maint_migration` call below says the SAME constant would be wrong - - # ⚠️ this comment used to cite "line 315" and "two hundred lines - # below", and both were wrong. The correction I wrote for them was - # wrong too: it said "~54 lines down", which was review's distance to - # the WRONG line, carried to a new referent without re-measuring - - # inside the sentence apologising for a wrong number. There is no - # distance here now, because a distance is a coordinate and the - # sentence above already names the thing. On a declared roster the flat - # latch landed under - # `unit/inverter-a/`, which is on no roster, was read back under the - # name it was handed, and stamped the marker. `roster_names()` is the - # naming rule itself and returns `[UNIT_A]` on the flat shape, so the - # upgrade path every install runs today is byte-identical. - # - # ⚠️ AND `misdirected` IS UNREACHABLE FROM **THIS** CALL SITE, WHICH THE - # COMMIT MESSAGE ORIGINALLY CLAIMED AS A BENEFIT. `unit` is - # `roster_names(opts)[0]` and `build_roster()` names its entries from - # the same `roster_names()` list, so `unit` is a member of `roster` by - # construction and the refusal can never fire here. Review measured it: - # dropping the `roster` argument from the `_verify_moves()` call inside - # `migrate_unit_keys()` survives 28/28. - # - # THE ARGUMENT STAYS ANYWAY, and the reason is the one `_verify_moves()` - # already gives for keeping its own `roster=None` branch: it is what a - # NEW caller gets, and the grading it enables is measured at the - # function's own level in test_maintenance.py rather than through here. - # What is NOT true is that this call site buys the refusal, so that - # claim is gone rather than restated. + # SAFETY-44: hand the migration the names the machines read: + # `roster_names(opts)[0]`, never `UNIT_A` (on a declared roster the + # flat latch landed under `unit/inverter-a/`, on no roster, and stamped + # the marker). `roster_names()` returns `[UNIT_A]` on the flat shape, + # so today's upgrade path is byte-identical. `misdirected` is + # unreachable from THIS call site; the `roster` argument stays for new + # callers and is graded in test_maintenance.py. + # DECISIONS/MAIN-orchestration.md. _roster_names = roster_names(opts) self.store_migration = migrate_unit_keys( store, _roster_names[0], _roster_names) self.inverters = build_roster(opts, store, sign_cfg, delivery_cfg) - # ⚠️ CFG-04. SAY WHICH SHAPE IS IN FORCE, BECAUSE THE OTHER ONE'S KEYS - # ARE STILL SITTING IN THE UI. A declared `inverters:` roster reads none - # of `soc_entity`, `batt_entity`, `batt_invert` or `max_w`, and an - # operator looking at the Configuration page sees all four with values - # in them. validate.py rule 9(e) refuses the one that is genuinely - # dangerous - a flat `setpoint_entity` naming an actuator nothing will - # write - and the rest cannot be refused (the schema gives them no blank - # value), so they are DISCLOSED instead: one line in §15's action log, - # at every start, naming the roster it built and the keys it ignored. - # Silence here is how "the site is running at half the rating I typed" - # takes a week to notice. + # CFG-04: say which shape is in force. A declared `inverters:` roster + # ignores `soc_entity`, `batt_entity`, `batt_invert` and `max_w`, which + # the UI still shows; validate.py rule 9(e) refuses only the dangerous + # one, so the rest are DISCLOSED in one action-log line at every start. if roster_entries(opts): self.log_event( "roster: %d inverters from `inverters:` - %s. The flat " @@ -581,97 +420,63 @@ class Controller: self.tuning = Tuning( gain=float(opts.get("gain", 0.6)), - # ⚠️ THE SITE BOUND, SUMMED FROM THE ROSTER, NOT `opts["max_w"]`. - # This is the integrator bound and the output rail of the SITE loop, - # whose output is then SPLIT across the inverters - so the number it - # may reach is what the site can deliver, which is the sum of what - # each unit may be written. At the shipped roster of one that is - # `max_w` exactly, byte for byte; at N>=2 a per-unit `max_w` still - # binds each unit's own share through its own `supervised` claim, so - # nothing here can over-command a device. + # The SITE bound is summed from the roster, not `opts["max_w"]`: it + # is the integrator bound and rail of the site loop, split across + # inverters. Identical to `max_w` at N=1; a per-unit `max_w` still + # binds each share through its `supervised` claim. max_w=self.site_max_w, - # ⚠️ SAFETY-39: THE FALLBACK IS 0 AND 0 MEANS "FOLLOW THE RAIL", - # which is what config.yaml ships and what `Tuning`'s own default - # is. It used to be 1000 - a third copy of a constant that had - # already gone stale against the rail in the other two - and rule - # 6's comment in validate.py documents what that costs when the - # copies disagree. `control.slew_limit_w()` is the one reader. + # SAFETY-39: the fallback is 0, meaning "follow the rail" + # (config.yaml and `Tuning`). It was 1000, a stale third copy. + # `control.slew_limit_w()` is the one reader. slew_w=float(opts.get("slew_w", 0) or 0), deadband_w=float(opts.get("deadband_w", 15)), target_grid_w=float(opts.get("target_grid_w", 0)), step_w=int(opts.get("step_w", 10)), saturation_w=float(opts.get("saturation_w", 500)), saturation_cycles=int(opts.get("saturation_cycles", 3)), - # 0 / unset means "follow max_w", which is the recommended - # value. Read the note in control.py before raising it above - # max_w: every watt above the rail is unwind latency. + # 0 / unset means "follow max_w", the recommended value. Read the + # note in control.py before raising it above max_w: every watt + # above the rail is unwind latency. integrator_max_w=(float(opts["integrator_max_w"]) if opts.get("integrator_max_w") else None), ) - # ⚠️ CFG-03. EIGHT `opts.get(key, )` CALLS USED TO LIVE HERE, - # and two of those literals disagreed with config.yaml - 2500 against a - # shipped 2000, which is a config validate.py refuses. The read now - # belongs to `MaintConfig.from_options()`, next to the one in-code - # mirror of config.yaml it falls back to, so this file states no opinion - # at all about what a missing maintenance key means. Read - # maintenance.MAINT_OPTION_DEFAULTS before adding a ninth key: it goes - # in config.yaml's `options:` and `schema:` blocks and in that dict, and - # nowhere else. - # --- MAINT-05: ONE `Maintenance` PER ROSTER ENTRY -------------------- - # ⚠️ AND THE MIGRATION RUNS FIRST, FOR THE REASON THE SAFETY-02 ONE - # DOES. These objects READ their history in their constructors' wake - - # `phase`, `last_completed` - so a history still sitting under the flat - # spelling when they are built is a pack that comes up believing it has - # never been recalibrated, on a site whose state file says otherwise. - # It targets `self.inverters[0].name` and NOT `UNIT_A`: the flat keys - # belonged to whichever pack the one FSM was driving, which is the first - # roster entry, and a declared `inverters:` roster names its own entries - # (`inverter.build_roster()` says so, and its fallback for a nameless - # entry is a POSITIONAL `inverter-1`, so `inverter-a` is not even the - # default there). Writing `UNIT_A` here would deposit an install's whole - # maintenance history under a name that is not on the roster at all. - # - # ⚠️ AND THAT SENTENCE USED TO BE A COMMENT NOTHING COULD GRADE, WHICH - # IS WHY THE ROSTER IS PASSED RATHER THAN A COUNT. Every two-pack - # fixture in the suite named its first entry `inverter-a`, so - # `self.inverters[0].name` and `UNIT_A` were the same string in every - # check that existed and swapping them left all 19 files green - while - # on a roster of `["garage", "shed"]` it silently loses the history and - # stamps the marker, so the next start skips the migration too. - # `migrate_maint_keys()` now grades the namespace it wrote against the - # names the machines read; test_maintenance.py sections 9 and 11 drive - # a `["garage", "shed"]` roster, which is what makes the mutation red - # instead of invisible. + # CFG-03: no `opts.get(key, )` for maintenance here; + # `MaintConfig.from_options()` reads them next to + # `maintenance.MAINT_OPTION_DEFAULTS` (two literals once disagreed with + # config.yaml). A new key goes in config.yaml `options:`/`schema:` and + # that dict only. + # MAINT-05: one `Maintenance` per roster entry. The history migration + # runs first (else a pack believes it was never recalibrated), and + # targets `self.inverters[0].name`, NOT `UNIT_A`: the flat keys + # belonged to the first roster entry. The roster is passed, not a + # count, so `migrate_maint_keys()` grades the namespace it wrote + # (test_maintenance.py sections 9 and 11 use `["garage", "shed"]`). + # DECISIONS/MAIN-orchestration.md. self.maint_migration = migrate_maint_keys( store, self.inverters[0].name, [inv.name for inv in self.inverters]) _LOG.info("maintenance history: %s", self.maint_migration.explain()) if not self.maint_migration.ok: - # Not a disarmed guard - history, not a latch - so it does not - # re-arm anything. It is still the difference between a pack that is - # recalibrated on schedule and one that silently is not, which is - # §12's opening sentence, so it is said where an operator looks. + # Not a disarmed guard (history, not a latch), but it is the + # difference between a pack recalibrated on schedule and one + # silently not (12), so it is said where an operator looks. self.log_event("maintenance history did not move over cleanly: " + self.maint_migration.explain(), hint=messages.hint("maint_history")) - # ⚠️ ONE CONFIG, N MACHINES. The `maintenance_*` options describe the - # PROCEDURE - how hard to drain, how full to charge, how long to hold - - # and the procedure is the same on every pack in the roster; what is per - # pack is the SCHEDULE and the HISTORY, which live in the store. A - # per-entry config block would be a second shape for CFG-04's roster to - # carry and a fifth place for a `maintenance_charge_w` to disagree with - # config.yaml. If a site ever needs two different procedures, that is a - # roster-schema card and it starts here. + # MAINT-05: one config, N machines: the `maintenance_*` options + # describe the PROCEDURE (same on every pack); the SCHEDULE and HISTORY + # are per pack, in the store. A per-entry config block would be a + # second shape for CFG-04 and a fifth place for `maintenance_charge_w` + # to disagree with config.yaml; two procedures would be a roster-schema + # card. _maint_cfg = MaintConfig.from_options(opts) _one_pack = len(self.inverters) <= 1 self.maints = [ Maintenance( _maint_cfg, store, - # ⚠️ `None` AT A ROSTER OF ONE IS THE FLAT LAYOUT, NOT A MISSING - # NAME. §6.2's identity: one pack, one history, and the keys it - # has been written under since the add-on shipped. See - # `maintenance.UnitHistory` and `store.migrate_maint_keys()` - - # this is the line that keeps a one-inverter state file - # byte-identical. + # `None` at a roster of one is the FLAT layout, not a missing + # name (6.2 identity): it keeps a one-inverter state file + # byte-identical (`maintenance.UnitHistory`, + # `store.migrate_maint_keys()`). unit=None if _one_pack else inv.name, name=inv.name, # The rollback view, and only ever the first pack's: the flat @@ -680,59 +485,39 @@ class Controller: tz=site_tz) # MAINT-10: the start hour's zone, from amain() for i, inv in enumerate(self.inverters)] - # SAFETY-02. ONE INSTANCE PER ACTUATOR - which is what watchdog.py's own - # docstring has always said and what this Controller did not do. Each - # `Inverter` builds its own, restored from ITS OWN namespaced store keys, - # so a trip caused by B latches B and leaves A commanding. - # - # ⚠️ RESTORED FROM THE STORE, and that is the difference between a latch - # and a pause. A trip held only in memory is cleared by the one action an - # operator staring at a FAILSAFE banner is most likely to take - restart - # the add-on - and the controller then comes back armed, into the fault, - # with nobody involved. - # - # ⚠️ AND THE LATCH IS GRADED HERE RATHER THAN TRUSTED, ON EVERY START. A - # latch that silently failed to move into its namespace - or that was - # taken by an OLDER add-on while this install was rolled back, which is - # the round trip store.py's rule 2 exists for - is a DISARMED SAFETY - # GUARD on an install whose state file still says `sign_tripped: true`. - # So the flat value is compared against what the roster actually came up - # with, and a disagreement is not a log line: it re-arms the latch by - # hand and shouts, because the fail-safe direction for a latch is ON. + # SAFETY-02: one watchdog per actuator, each restored from ITS OWN + # namespaced store keys (a trip on B latches B, A keeps commanding). + # Restored, because a latch held only in memory is cleared by the + # restart an operator staring at a FAILSAFE banner tries first. Graded + # on every start: a latch that failed to move namespace, or was taken + # by an older add-on during a rollback, is a disarmed guard, so a + # disagreement with the flat value re-arms by hand and shouts (the + # fail-safe direction is ON). DECISIONS/MAIN-orchestration.md. self._grade_store_migration(store) for inv in self.inverters: if inv.sign_wd.tripped: - # UX-11: the plain sentence and the code. The saved reason, - # with its figures, is on the DEBUG line beside it. + # UX-11: the plain sentence and the code; the saved reason with + # figures is on the DEBUG line. _LOG.error("%s stays at 0 W after the restart because its " "power went the wrong way, so find out why before " "you start control: %s.", inv.name, messages.reason_code(inv.sign_wd.reason)) _LOG.debug("Evidence for %s: %s.", inv.name, inv.sign_wd.reason or "(reason not recorded)") - # UX-11 review: `events` starts empty and DEBUG is off by - # default, so the saved figures go on the action log too. + # UX-11: `events` starts empty and DEBUG is off by default, so + # the saved figures go on the action log too. saved = inv.sign_wd.reason or messages.NO_REASON_SAVED self.log_event(f"FAILSAFE {inv.name}, saved before the " f"restart: {saved}", hint=messages.hint("stop_restored", unit=inv.name)) - # --- SAFETY-42 + SAFETY-34 ------------------------------------------- - # ⚠️ A LATCH THAT DOES NOT ACT, WHICH IS THE ONE ASYMMETRY WITH EVERY - # OTHER RESTORED LATCH ON THIS OBJECT, AND IT IS DELIBERATE. SAFETY-02's - # and SAFETY-31's latches survive a restart because they command 0 W and - # an add-on that came back armed would drive straight into the fault - # again. This one commands nothing at all: it is restored so that the - # ALARM survives, which is the whole of SAFETY-34 and is exactly how the - # 2026-09-02 outage went unseen for 6 h 25 m with the detector correct - # the entire time. `delivery.py` carries the argument for reporting - # rather than acting, with the rig measurements on both sides of it. - # - # `_delivery_announced` is the per-PROCESS half - see `_circ_announced`, - # which is the same pattern for the same reason. The store says "this - # unit is tripped"; the flag says "this process has put that in front of - # a human". A restart clears the flag and keeps the latch. + # SAFETY-42 + SAFETY-34: a latch that does NOT act, unlike SAFETY-02's + # and SAFETY-31's (which command 0 W). It is restored so the ALARM + # survives (2026-09-02: an outage unseen for 6 h 25 m with the detector + # correct); `delivery.py` argues reporting rather than acting. + # `_delivery_announced` is per process (as `_circ_announced`): a + # restart clears the flag and keeps the latch. self._delivery_announced = set() for inv in self.inverters: if inv.delivery_wd.tripped: @@ -749,27 +534,12 @@ class Controller: hint=messages.hint("not_delivering", unit=inv.name)) - # --- SAFETY-30: THE SITE HALF OF THE SIGN CHECK ---------------------- - # ⚠️ ONE PER SITE, BESIDE THE ONE PER INVERTER, AND ARMED ONLY AT N>=2. - # The per-unit watchdogs above ask "is THIS actuator doing what I told - # it", graded on that unit's own measured power. This one asks "is the - # SITE driving the wrong way", graded on the SUM of the standing - # commands against P1 - the comparison SAFETY-02 shipped, re-scoped to - # the quantity it was always actually about. - # - # ⚠️ AT A ROSTER OF ONE IT IS CONSTRUCTED AND NEVER EVALUATED, and that - # is the whole compatibility promise of this card rather than an - # oversight. With one inverter the sum of the commands IS that - # inverter's command, so arming this would be arming a duplicate of - # `inverters[0].sign_wd`: two latches for one fault, two banners, and a - # false-positive record measured on ONE comparison being spent twice. - # `cycle()` is where that is enforced and says so at the line. - # - # Restored from the store like SAFETY-02's and SAFETY-31's latches, for - # the reason store.py gives: "restart the add-on" is the first thing an - # operator looking at a FAILSAFE banner tries, and a latch held only in - # memory brings the controller back armed, into the fault, unattended. - # FLAT keys, not namespaced - store.py carries that argument too. + # SAFETY-30: the SITE half of the sign check, one per site, armed only + # at N>=2: the SUM of the standing commands against P1. At a roster of + # one it is constructed and never evaluated (arming it would duplicate + # `inverters[0].sign_wd`: two latches, two banners, a false-positive + # record spent twice); `cycle()` enforces it. Restored from the store + # with FLAT keys (store.py). DECISIONS/SAFETY-30-per-unit-sign.md. self.site_sign_wd = SignWatchdog( self.site_sign_cfg, tripped=bool(store.data.get("site_sign_tripped", False)), @@ -785,36 +555,21 @@ class Controller: + (self.site_sign_wd.reason or messages.NO_REASON_SAVED), hint=messages.hint("site_direction")) - # And the per-unit detectors are told which question they are asking, - # here as well as in `cycle()`. `checks()` and the status page read - # `inv.sign_wd.cfg` and can be called before the first cycle has run, so - # a roster of two would otherwise advertise the site thresholds on a - # detector that is about to be graded against a unit's own power. + # The per-unit detectors are told which question they ask here as well + # as in `cycle()`: `checks()` and the status page read + # `inv.sign_wd.cfg` before the first cycle. self._arm_sign_watchdogs() - # SAFETY-18. The frozen-but-fresh meter detector - see freeze.py for why - # it is a conjunction and not an `unchanged_s` threshold, and for the - # measurements behind the default. - # - # ⚠️ NOT restored from the store, unlike SAFETY-02's latch, and the - # asymmetry is the point: this one does not latch because it does not - # act. A restart re-derives its state from the meter within one - # `meter_freeze_s` window, and until then the honest report is "not - # alarming yet" rather than an alarm nobody can clear. + # SAFETY-18: the frozen-but-fresh meter detector (freeze.py). NOT + # restored from the store: it does not act, so it does not latch; a + # restart re-derives it within one `meter_freeze_s`. self.freeze_wd = FreezeWatchdog( FreezeWatchdogConfig(hold_s=float(opts.get("meter_freeze_s", 300)))) - # SAFETY-31. ONE PER SITE, and restored from the store like SAFETY-02's - # latch rather than defaulted like SAFETY-18's - the asymmetry is the - # same one the freeze watchdog's own note draws, read the other way. - # This one ACTS: a trip files a P_SAFETY SET that stops every inverter, - # so a restart that cleared it would come back up straight into the loop - # it had just stopped, which is SAFETY-02's measured three-minutes-at-a- - # time failure with a different quantity in front of it. - # - # ⚠️ SITE-LEVEL KEYS, NOT NAMESPACED. store.py carries the argument: - # circulating power is defined over the roster and is identically zero - # for any one unit, so there is no inverter to hang the latch on. + # SAFETY-31: one per site, RESTORED (unlike SAFETY-18): a trip files a + # P_SAFETY SET that stops every inverter, and a cleared latch would + # restart straight into the loop it stopped. SITE-level keys, not + # namespaced: circulating power is defined over the roster (store.py). self.circ_wd = CirculationWatchdog( CirculationConfig( min_w=float(opts.get("circulation_w", @@ -823,14 +578,13 @@ class Controller: CIRC_OPTION_DEFAULTS["circulation_hold_s"]))), tripped=bool(store.data.get("circ_tripped", False)), reason=str(store.data.get("circ_reason", "") or "")) - # SAFETY-34. Has the durable alarm been put back in front of the - # operator since this process started? `persistent_notification` is in - # memory and does NOT survive the Home Assistant restart that the store - # does, so the latch alone is not the alarm - re-raising it is. - # `run_control()` does that, once, before the first cycle. + # SAFETY-34: has the durable alarm been put back in front of the + # operator since this process started? `persistent_notification` does + # not survive the HA restart the store does; `run_control()` re-raises + # it once before the first cycle. self._circ_announced = False # SAFETY-34: when each latched alarm was last put in front of HA, by - # notification_id - see REANNOUNCE_S. + # notification_id (REANNOUNCE_S). self._announced_mono = {} if self.circ_wd.tripped: _LOG.error( @@ -845,112 +599,58 @@ class Controller: or messages.NO_REASON_SAVED), hint=messages.hint("circulating")) - # SAFETY-03. The output clamp's alarm - see clamp.py for why the clamp - # itself is the cheap half. - # - # ⚠️ NO CONFIG OF ITS OWN, on purpose. The bound it clamps to is `max_w`, - # which is already an option, already validated against the device's - # rating at startup, and already used by Tuning and the arbiter. Giving - # this a second key would be a fourth place a limit has to be kept in - # step (CFG-03 already tracks three) and, worse, would recreate exactly - # the two-numbers-that-disagree failure the §14 warning box describes. - # The anti-flood cadence is a module constant for the same reason - # freeze.RAIL_FRAC is: it describes the event bus, not the plant. + # SAFETY-03: the output clamp's alarm (clamp.py). No config of its own: + # the bound is `max_w` (already validated against the device rating), + # and a second key would be another place a limit must be kept in step + # (CFG-03). The anti-flood cadence is a constant, like + # freeze.RAIL_FRAC. self.clamp_wd = ClampWatchdog() # P1 ingestion (TEL-01). `meter_source: off` keeps the original - # single-entity meter_entity path, so an existing install is unchanged - # until it opts in. + # single-entity `meter_entity` path, so an existing install is + # unchanged until it opts in. self.p1 = P1Ingest(phases=int(opts.get("meter_phases", 1)), max_age_s=float(opts.get("meter_max_age_s", 30))) self.p1_enabled = is_enabled(opts) - # TARIFF-01. The capacity-tariff peak: measured from the meter, banked - # in SQLite, restored across a restart. It is an INSTRUMENT - it files no - # claim and clamps nothing. SAFETY-07 is what will read it. - # - # ⚠️ NO NEW OPTION. The soft limit is `peak_cap_w`, already shipped, - # already validated, already read by control.peak_at_risk(). CFG-03 - # tracks three places option defaults live; a fourth, for a limit that - # already exists, is the mistake the clamp watchdog declined to make ~30 - # lines up. config.yaml and validate.py are untouched by this ticket. - # - # ⚠️ AND NO SECOND PEAK ENTITY. `peak_forecast_entity` is the existing - # HA-side delegation and it stays the input to maintenance_charge_floor(): - # repointing that consumer at this measurement CHANGES WHAT MOVES ENERGY - # during a maintenance charge, which belongs to SAFETY-07 with its own - # acceptance, not to a measurement ticket. What this adds is the MEASURED - # MONTH PEAK - a different quantity from a forecast entity, published - # nowhere as a duplicate of it. + # TARIFF-01: the capacity-tariff peak, measured from the meter, banked + # in SQLite, restored across a restart. An INSTRUMENT: it files no + # claim and clamps nothing (SAFETY-07 reads it). No new option + # (`peak_cap_w`) and no second peak entity: `peak_forecast_entity` + # stays the input to maintenance_charge_floor(), and repointing it + # would change what moves energy in a maintenance charge. + # DECISIONS/SAFETY-07-peak-guard.md. self.peak_store = PeakStore(peak_db_path) self.peak = PeakTracker(self.peak_store, cap_w=float(opts.get("peak_cap_w", 3500))) self.peak.restore(self.p1.averager) _LOG.info("Month peak store: %s", self.peak.restore_state) - # FCST-04. The §8.3 carry-forward store: the published prices of the - # last few Brussels days, so the hours a day-ahead feed has not printed - # yet can be filled from yesterday's profile and FLAGGED, instead of - # being absent from the series entirely. - # - # ⚠️ IT IS AN INSTRUMENT'S BACKING FILE, NOT A STRATEGY. It commands - # nothing and claims nothing; what it changes is how many hours the - # plan covers. Before this, a dynamic site between midnight and the - # 14-16 h CET publication had a series covering only the rest of today - # - typically 10-24 of the 48 hours `price_horizon_h` asked for - and - # `TariffPlan.arbitrage()` took its spread over that truncated window - # without anything reporting that it had. Every carried hour is - # provisional, so refusal 3 drops it from the trade set: CARRYING a - # price widens what the optimiser can SEE, never what it may act on. - # - # ⚠️ AND THE CAVEAT THAT BELONGS WITH THAT SENTENCE, because this is - # the one place a maintainer reads it on its own. THE OTHER HALF OF - # THIS CHANGE DOES MOVE ENERGY, and it is the windowing rather than the - # carrying: the filled series covers `[since, until)`, so hours that - # have ALREADY ELAPSED are no longer in the spread. A cheap night that - # has gone used to sit in the series as the day's cheapest hour, and no - # cycle can buy in an hour that is past - so the trade was quietly - # never taken. `resolve_tariff()`'s docstring, DOCS.md and the CHANGELOG - # all carry this beside the guarantee; it may not appear here without it. - # - # ⚠️ A BROKEN OR ABSENT STORE IS NOT AN ERROR HERE. It answers `no_data`, - # the fill carries nothing, and the plan is the pre-FCST-04 one minus - # the elapsed hours - short, but honest and still refusing on the same - # rules. It must not be an error in the other sense either: a day - # record this build cannot read is DROPPED by `PriceHistory._load()` - # and reported in `.words`, because both the constructor above and the - # `.words` read below are unguarded, and a cache that raises here is an - # add-on that does not start. + # FCST-04: the 8.3 carry-forward store (published prices of the last + # few Brussels days) so unprinted hours can be filled from yesterday + # and FLAGGED. An instrument's backing file, not a strategy. Carrying + # widens what the optimiser SEES, never what it may act on (carried + # hours are provisional). The windowing DOES move energy: hours already + # elapsed leave the spread, so a past cheap night no longer sets the + # day's cheapest hour; this caveat must always travel with the + # guarantee (`resolve_tariff()`, DOCS.md, CHANGELOG). A broken or + # absent store answers `no_data`; `PriceHistory._load()` drops + # unreadable records (the constructor and `.words` are unguarded). + # DECISIONS/FCST-04-the-day-that-is-carried.md. self.price_history = PriceHistory(price_history_path) _LOG.info("Price history: %s", self.price_history.words) - # SAFETY-07. The guard TARIFF-01 was built for. It is stateless by - # design (peakguard.py holds no history between calls), so the only - # state here is what an EDGE needs: what the last cycle concluded, so - # the action log and the notification fire on transitions rather than - # once per cycle for a quarter of an hour. - # - # ⚠️ THE CEILING HAS NO CONFIG OF ITS OWN, for the reason the clamp - # watchdog gives ~90 lines up and TARIFF-01 repeats: it is `peak_cap_w`, - # already shipped and already validated, and every other term of the - # projection is read off something the arbiter or the averager already - # owns. - # - # ⚠️ SAFETY-24'S FLOOR DOES HAVE ONE, AND IT COULD NOT NOT HAVE. How far - # down the guard may spend the pack for money is a number only the site - # knows, and refusing one set below the pack's own cut-out means knowing - # that cut-out too. So this card adds exactly two keys and reads their - # defaults from `peakguard.PEAK_OPTION_DEFAULTS` - TARIFF-02's mirror - # pattern - rather than typing literals here. THAT is what kept this - # card from widening: the divergence validate.py's rule 6 used to - # document (`maintenance_charge_w` 2000 in config.yaml, 2500 in this - # file) is exactly what a literal here would have repeated. CFG-03 has - # since closed that one too - `maintenance.MAINT_OPTION_DEFAULTS`, - # read through `MaintConfig.from_options()` - so the rule now holds for - # every option family this file builds: no numeric fallback in main.py - # that config.yaml has not shipped. + # SAFETY-07: the guard is stateless (peakguard.py); the only state here + # is what an EDGE needs, so the log and notification fire on + # transitions. The ceiling has no config of its own (`peak_cap_w`). + # SAFETY-24's floor does: exactly two keys, defaults from + # `peakguard.PEAK_OPTION_DEFAULTS` (no literals here, as a literal + # repeated the 2000-vs-2500 `maintenance_charge_w` divergence of + # validate.py rule 6; closed by `maintenance.MAINT_OPTION_DEFAULTS`). + # The rule: no numeric fallback in main.py that config.yaml has not + # shipped. DECISIONS/SAFETY-07-peak-guard.md. self.peak_guard = None - # SAFETY-79. The guard aims this far under `peak_cap_w`; the bill and + # SAFETY-79: the guard aims this far under `peak_cap_w`; the bill and # every report keep the real cap (`self.peak.cap_w`). self.peak_margin_w = float(opts.get( "peak_margin_w", peakguard.PEAK_OPTION_DEFAULTS["peak_margin_w"])) @@ -986,17 +686,16 @@ class Controller: self._land_batt_prev = None self._peak_needed = False self._maint_shape_suspended = False - # OPT-11. Battery modes, off by default so an install that has not - # turned them on behaves exactly as before (SAFETY-10 section 1). Each + # OPT-11: battery modes, off by default (SAFETY-10 section 1). Each # battery's mode and on/off switch live in the store, not in options, - # so a change applies at once (`pack_use`, `pack_mode` actions). + # so a change applies at once (`pack_use`, `pack_mode`). self.modes_on = packmode.enabled(opts) # The units whose mode fault is logged already (edge-logged). self._mode_faults = set() - # OPT-11 P3: the price runs (`priceruns.RunPlan`, or None), the key - # they were built for, this tick's roles (`modetrade.Tick`), the - # batteries that left a run (full, or at the floor) keyed by the run, - # and whether last tick filed a mode trade (a run edge reseeds). + # OPT-11 P3: the price runs (`priceruns.RunPlan` or None), the key they + # were built for, this tick's roles (`modetrade.Tick`), batteries that + # left a run keyed by the run, and whether last tick filed a mode trade + # (a run edge reseeds). self.mode_runs = None self._runs_key = None # TARIFF-24: the end of the run that was under way while a forecast @@ -1015,11 +714,10 @@ class Controller: # OPT-15: a run edge the loop holds at E: (E, step sign, until, the # last battery reading). self._edge = None - # OPT-11 P4: the solar forecast and the day plan. Only with battery - # modes on AND a forecast for today set; otherwise none of this runs - # and every mode acts as in P3 option A. OPT-17: each day may name - # several entities, summed (`solarfc.entities()`); `fc_entity` is - # the first, and None means no forecast. + # OPT-11 P4/OPT-17: the solar forecast and day plan, only with battery + # modes on AND a forecast for today set; each day may name several + # entities, summed (`solarfc.entities()`); `fc_entity` is the first, + # None means no forecast. self.fc_entities = (solarfc.entities(opts) if self.modes_on else ()) self.fc_tomorrow_entities = solarfc.entities(opts, tomorrow=True) @@ -1048,11 +746,11 @@ class Controller: # OPT-11 P2: (topic, reason) of MQTT commands refused and logged once. self._mqtt_refused = set() self._mqtt_refused_more = 0 - # [(pack name, why)] for packs that are due and waiting their turn. - # Empty at a roster of one, where nothing can defer anything. + # [(pack name, why)] for packs due and waiting their turn. Empty at a + # roster of one. self._maint_deferred = [] - # SAFETY-78: names of draining packs that cannot give power now, so - # the "waits" line is logged once, not every cycle. + # SAFETY-78: names of draining packs that cannot give power now, so the + # "waits" line logs once. self._drain_waits = set() # SAFETY-91: last cycle's held-back batteries (`_held_back()`). self._held_was = frozenset() @@ -1077,101 +775,69 @@ class Controller: self._live_was = None self._loop_start = {} self._settle = {} - # Set by amain() once the transport is built, so the status page can show - # what only the transport knows (homewizard_local's unchanged_s). Stays - # None when P1 is off, or under a transport that has no such counter. + # Set by amain() once the transport is built, so the status page can + # show what only the transport knows (homewizard_local's unchanged_s). + # (DECISIONS/MAIN-orchestration-archive.md) self.p1_source = None - # --- ARB-03: which actuators this site has --------------------------- - # ⚠️ THE ROSTER IS `self.inverters` AND `units` IS ITS NAMES, DERIVED. - # Every claim below is filed on an actuator, and `arbiter.actuator_for()` - # needs to know how many there are: at N=1 §6.2's identity holds - the - # fabricated grid reading and the battery setpoint are the same number - - # so the grid connection and the only inverter ARE one actuator and every - # claim routes to the site. With a second entry the per-inverter claims - # stop being site claims on their own, which is the seam ALLOC-01 - # occupies and this card fills. - # - # ⚠️ IT IS A PROPERTY, NOT A FIELD, AND THAT IS THE POINT OF THE CARD. - # `self.units = (...)` was a tuple somebody could set beside a roster of - # one Inverter object, i.e. a site that claims two actuators and has one - # writer. Deriving it means the roster and the actuator list cannot - # disagree - to add an inverter you add an Inverter. + # ARB-03: which actuators this site has. The roster is `self.inverters` + # and `units` is its names, DERIVED (a property, not a field): a + # settable tuple beside a roster of one Inverter would be two actuators + # and one writer. At N=1 (6.2) the grid connection and the only + # inverter are ONE actuator and every claim routes to the site; a + # second entry makes per-inverter claims stop being site claims (the + # ALLOC-01 seam). DECISIONS/MAIN-orchestration.md. # live state self.auto = bool(store.data.get("auto", opts.get("auto_start", False))) - # --- SAFETY-10 ------------------------------------------------------- - # What the decision path reached on the last shadow cycle, in W, or - # None before the first one. ⚠️ IT IS KEPT SEPARATELY FROM - # `self.target` BECAUSE THEY ARE DIFFERENT FACTS: `self.target` is what - # is STANDING ON THE WIRE - held at `shadow.WIRE_W` for the whole of a - # shadow run, and re-seeded to it at the end of every shadow cycle so - # the loop and the sign watchdogs track reality - and this is what a - # live controller WOULD have put there. Folding them into one field is - # how a shadow run starts telling an operator a number nothing is + # SAFETY-10: what the decision path reached on the last shadow cycle, + # in W, or None. Kept apart from `self.target` (STANDING ON THE WIRE: + # held at `shadow.WIRE_W`, re-seeded each shadow cycle): different + # facts, and folding them would show an operator a number nothing is # doing. self.shadow_would_w = None - # SAFETY-47. Was the number above SIZED FROM THE BILLED-QUARTER METER - # on the cycle it was recorded? ⚠️ IT IS RECORDED WHERE THE DECISION - # WAS TAKEN, NOT RE-DERIVED WHERE THE PAGE IS RENDERED. The status page - # runs on its own task and `self.peak_guard` has moved on by then, so a - # page that asked the question again would be answering it about a - # different cycle from the one whose watts it is displaying - which is - # the same two-facts-one-field defect the comment above this one is - # about. ⚠️ False AND NOT None BEFORE THE FIRST SHADOW CYCLE, unlike - # the field above: this one is a claim ABOUT a number, and there is no - # number yet, so the honest value is "nothing to disclose". The row's - # `would_w is None` branch renders no watts at all there. + # SAFETY-47: was the number above SIZED FROM THE BILLED-QUARTER METER + # on the cycle it was recorded? Recorded where the decision was taken, + # not re-derived at render (the page runs on its own task). False, not + # None, before the first shadow cycle: it is a claim about a number. self.shadow_sized_from_meter = False - # Edge state for the action log, both of them. `_shadow_was` is None - # until the first cycle, so an install that comes up IN shadow mode - # says so in the log it will be read from rather than leaving the - # operator to infer it from a page nobody is looking at yet. Coming up - # LIVE is silent - see `_shadow_settle()`, which says why. + # Edge state for the action log. `_shadow_was` is None until the first + # cycle so an install that comes up IN shadow mode says so; coming up + # LIVE is silent (`_shadow_settle()`). self._shadow_was = None self._shadow_winner = None self.target = 0.0 self.sat_count = 0 self.i_w = 0.0 # the loop's integrator, carried between cycles - # SAFETY-52. The direction the battery refuses (+1 / -1 / 0), carried + # SAFETY-52: the direction the battery refuses (+1 / -1 / 0), carried # like `i_w`, and when the refusal (or its last probe) started. self.batt_refused = 0 self.batt_refused_at = None self._now_mono = None self._refusal_auto = False - # TEL-10. What the last cycle's loop did, kept for publication. Seeded - # to the SAME values the "stopped or blind" branch of `cycle()` writes, - # because before the first cycle the loop has not run - which is the - # thing that branch is for. + # TEL-10: what the last cycle's loop did, for publication; seeded to + # the values the "stopped or blind" branch of `cycle()` writes. self.loop_i_w = 0.0 self.loop_i_lead = 0.0 self.loop_reason = "not-running" self.loop_w = None # what the loop asked for last cycle, or None - # --- TEL-11: the site's PV production, and NOTHING READS IT ---------- - # ⚠️ INSTRUMENTATION. It is published, it is shown on the status page, - # and it reaches no decision: not `inputs_ok`, not the allocator, not - # the arbiter, not a claim, not a watchdog. Wiring it into the control - # law would be an unrequested behaviour change on a system driving a - # real battery AND would destroy the measurement, because the loop would - # then react to the quantity being measured. `tools/tel11_pv_trace.py` - # is the instrument that says so, on both trees and both settings. - # - # ⚠️ `None` IS A READING THAT DOES NOT EXIST, AND IT IS SEEDED AS ONE. - # Nothing here ever becomes 0.0 by default; see `app/pv.py`. + # TEL-11: the site's PV production is INSTRUMENTATION and nothing reads + # it: not `inputs_ok`, the allocator, the arbiter, a claim or a + # watchdog. Wiring it in would change behaviour and destroy the + # measurement (`tools/tel11_pv_trace.py`). `None` is a reading that + # does not exist and is seeded as one (`app/pv.py`). self.pv_w = None - # Why there is no reading, in words the page prints - or, on a good - # read, the UNIT the value came in. Never empty. + # Why there is no reading, in words the page prints, or on a good read + # the UNIT the value came in. Never empty. self.pv_why = ("not configured" if not self.pv_roster else "not read yet") - # TEL-12. EVERY configured entity's own answer, `(entity, watts, why)`, - # in roster order - kept because `checks()` has to be able to name the - # sensor that is missing, and `self.pv_w` alone cannot. Empty before the - # first read, which is what "no entity has answered yet" is. + # TEL-12: every configured entity's own answer `(entity, watts, why)` + # in roster order, so `checks()` can name the missing sensor. Empty + # before the first read. self.pv_parts = [] - # Did the grid-following loop actually win the LAST arbitration? That is - # the question SAFETY-02 has to ask about the command standing on the - # wire right now, so it is recorded when the arbitration happens rather - # than re-derived a cycle later. Starts False: nothing is on the wire. + # Did the grid-following loop win the LAST arbitration? SAFETY-02 asks + # it about the command on the wire, so it is recorded at arbitration. + # Starts False. self.following = False self.reason = "starting" self.why_words = "Starting" @@ -1180,46 +846,31 @@ class Controller: # ...when the last WARNING was, and whether this episode logged one. self._meter_quiet_warned = None self._meter_quiet_loud = False - # ALLOC-01. The allocation record from the last resolution, or None - # before the first cycle. ⚠️ IT IS KEPT SO THAT SOMETHING CAN GATE ON - # IT: SAFETY-28 puts the write path behind this record, and DEV-02 reads - # `shares` off it to drive a second inverter. It is REPLACED every cycle - # and never merged - a record from a tick whose envelopes have moved is - # the cached envelope this card exists to forbid, wearing a different - # hat. + # ALLOC-01: the allocation record from the last resolution, or None. + # REPLACED every cycle, never merged (a record from a tick whose + # envelopes moved is a cached envelope). SAFETY-28 gates the write path + # on it and DEV-02 reads `shares` off it. self.allocation = None - # SAFETY-28. THE TICK THE RECORD ABOVE BELONGS TO, and the counter that - # mints it. It is incremented ONCE PER RESOLUTION - not once per loop - # iteration - and that distinction is load-bearing in both directions: - # - # * a NEW resolution makes the previous record stale IMMEDIATELY, so a - # record cannot be replayed into a later tick after the envelopes it - # describes have moved. That is the replay half of the gate. - # * a cycle that produced NO resolution does not bump it. The "inputs - # missing, not yet stale" hold above returns before the allocator - # runs precisely so that a single missed poll does not zero the - # inverter, and the heartbeat re-affirms `self.target` through the - # write path while it does. Bumping here would make that heartbeat - # unauthorised and drop the site to 0 W on the first dropped poll - - # turning a hold into the outage it exists to prevent. - # - # So the record is SUPERSEDED, never expired: it stands until the - # allocator produces its successor, and nothing else has moved in - # between that it does not still describe. + # SAFETY-28: the tick the record above belongs to, incremented ONCE PER + # RESOLUTION, not per loop iteration. A new resolution makes the + # previous record stale at once (no replay into a later tick). A cycle + # with NO resolution does not bump it: the "inputs missing, not yet + # stale" hold returns before the allocator so one missed poll does not + # zero the inverter, and the heartbeat re-affirms `self.target` while + # it does; bumping would make that heartbeat unauthorised and drop the + # site to 0 W. The record is SUPERSEDED, never expired. + # DECISIONS/MAIN-orchestration.md. self.alloc_tick = 0 - # What the last cycle's allocator dropped, so the log line above is - # edge-triggered. Starts as the empty tuple - nothing has been dropped - # yet and nothing has been said about it. + # What the last cycle's allocator dropped, so the log line is + # edge-triggered. Starts empty. self._dropped_last = () - # OPT-09. The last cycle's `unmet_w`, so the line that reports it is - # edge-triggered for `_dropped_last`'s reason: a site that cannot place - # every watt stays that way for as long as the pack is empty, and a line - # a second would flush the 40-row action log of the context explaining - # it. 0 is "every watt was placed", which is the state at boot. + # OPT-09: the last cycle's `unmet_w`, so its line is edge-triggered (a + # second-by-second line would flush the 40-row action log). 0 is "every + # watt placed", the boot state. self._unmet_last = 0 - # ⚠️ THE SITE MEASUREMENT, AND THE ONLY ONE LEFT ON THIS OBJECT. `soc` - # and `batt` were per-device scalars and now live on each `Inverter`; - # `grid` is P1, one meter, one connection, and belongs here. + # The site measurement, the only one left on this object: `soc` and + # `batt` live on each `Inverter`; `grid` is P1, one meter, one + # connection. self.grid = None self.peak_fc = None # SAFETY-118: the unit fault last logged for the peak forecast (None = @@ -1227,11 +878,11 @@ class Controller: self._peak_fc_fault = None self.cheap = False self._price_gated = False # MAINT-13: is a price gate set up - # PRICE-01. Which step of the price chain the prices come from (logged - # once per change), the last series that was published (so a gap can say - # since when), and the gap itself: True while a refresh has finished and - # no published price covers now. The notification is raised once per - # gap and taken down when it ends. + # PRICE-01: which step of the price chain the prices come from (logged + # once per change), the last published series (so a gap can say since + # when), and the gap itself: True while a refresh has finished and no + # published price covers now. Notified once per gap, taken down when it + # ends. self.price_watch = PriceWatch() self._last_published = None self.price_gap = False @@ -1239,32 +890,26 @@ class Controller: self._price_gap_announced = False self._fixed_note_said = False self._gated_key = None # MAINT-13: (options, hour) of the cache - # TARIFF-03. One line of prose for the status page saying WHICH tariff - # window we are in and why it is or is not cheap - so "Cheap window: - # no" is an evidenced answer rather than a bare no. + # TARIFF-03: one line of prose for the status page saying which tariff + # window we are in and why it is or is not cheap. self.tariff_words = "No fixed tariff set" - # OPT-04. The tariff plan the BACKGROUND task last resolved, and when. - # cycle() reads these two attributes and never awaits a fetch - see - # TARIFF_REFRESH_S. `None` means no refresh has completed yet, which is - # not "no prices" and certainly not "free": arbitrage.decide() reports - # it as its own reason and does nothing. + # OPT-04: the tariff plan the BACKGROUND task last resolved, and when. + # cycle() reads these and never awaits a fetch (TARIFF_REFRESH_S). + # `None` means no refresh has completed: not "no prices" and certainly + # not "free". self.tariff_plan = None self.tariff_at = None self.arb = None - # TARIFF-11. WHEN the standing arbitrage decision was taken, or None if - # this controller has never reached the arbitrage step. ⚠️ `self.arb is - # None` DOES NOT MEAN "TRADING IS OFF" AND NEVER DID - see - # `arbitrage_row()`. It also means "cycle() returned before the step - # ran", and at that point `self.arb` is either this attribute's initial - # None (startup) or the PREVIOUS cycle's decision (an input hold), and - # the status row rendered all three identically. + # TARIFF-11: WHEN the standing arbitrage decision was taken, or None. + # `self.arb is None` does NOT mean "trading is off": it also means + # cycle() returned before the step, so `self.arb` is None (startup) or + # the PREVIOUS cycle's decision (input hold). See `arbitrage_row()`. self.arb_at = None - # SAFETY-45/47. The billed-quarter charging headroom the arbitrage step - # was handed on the cycle above, or None when nothing measured it. It - # is CARRIED rather than recomputed for `arbitrage.sized_from_meter()`, - # because `self.peak_guard` is a different cycle's decision by the time - # the status page renders. + # SAFETY-45/47: the billed-quarter charging headroom the arbitrage step + # was handed, or None. CARRIED, not recomputed for + # `arbitrage.sized_from_meter()`: `self.peak_guard` is a different + # cycle's by the time the page renders. self.arb_headroom_w = None # SAFETY-125: a grid charge sized to the quarter's room this cycle. self.room_charge = False @@ -1274,42 +919,38 @@ class Controller: # UX-54: the site's measured solar per hour, for the Forecast tab. # In memory, display only; nothing reads it back into a decision. self.solar_today = SolarToday() - # TARIFF-11. When the most recent cycle() STARTED. `arb_at < cycle_at` - # is the exact, derived statement of "the latest cycle did not get as - # far as the arbitrage step", which is the fact the *Arbitrage* row - # used to render identically to "trading is switched off". + # TARIFF-11: when the most recent cycle() STARTED. `arb_at < cycle_at` + # is the derived statement "the latest cycle did not reach the + # arbitrage step". self.cycle_at = None self._arb_action = None self.inputs_bad_since = None - # SAFETY-03. What the loop's OWN bounds did last cycle - a - # (engaged, note) pair so the alarm can say WHICH bound bit without - # re-deriving it from a string. ⚠️ THIS ONE STAYS SITE-LEVEL, and it is - # the counterpart to `clamp_last` moving onto the Inverter: the integrator - # that wound is the SITE integrator, of which there is exactly one, while - # the value a clamp bit is a value written to ONE device. - # (engaged, note, over_rating) - SAFETY-40 splits the last one out. + # SAFETY-03: what the loop's OWN bounds did last cycle, `(engaged, + # note, over_rating)` (SAFETY-40 splits the last out). Site-level: the + # integrator that wound is the one SITE integrator; a clamp bit a value + # written to ONE device (`clamp_last` is on the Inverter). self.loop_clamped: tuple[bool, str, bool] = (False, "", False) # UX-08: the numbers of a loop-side over-rating, for the notification. self.loop_clamp_facts: tuple = () - # SAFETY-85: this cycle's loop Decision, each battery's recent - # standing commands, and how many cycles in a row a battery set by - # another owner gave less than asked. + # SAFETY-85: this cycle's loop Decision, each battery's recent standing + # commands, and how many cycles in a row a battery set by another owner + # gave less than asked. self._loop_decision = None self._asked_hist: dict = {} self._short_run = 0 # SAFETY-119: did another owner set the wire last cycle; the window - # after a hand-back (monotonic s, the loop's cycles in a row); the - # monotonic s of the bound cycles hidden in the last ROLLING_S (a - # stop does not clear them). + # after a hand-back (monotonic s, loop cycles in a row); monotonic s of + # the bound cycles hidden in the last ROLLING_S (a stop does not clear + # them). self._wire_lost = False self._handback = None self._hidden = () self.stopping = False - # SAFETY-61. time.monotonic() at the end of the last run_control() pass. + # SAFETY-61. time.monotonic() at the end of the last run_control() + # pass. self.loop_pass_mono = None - # -- helpers ------------------------------------------------------------- - # --- SAFETY-52: a battery that refuses is asked for 0 W ------------------ + # helpers; SAFETY-52: a battery that refuses is asked for 0 W def _refusal_probe(self) -> bool: """True while a refused direction may try `PROBE_W` (every 15 min).""" if (not self.batt_refused or self.batt_refused_at is None @@ -1343,17 +984,14 @@ class Controller: def _refusal_update(self, decision, asked_w, batt_w, probe, rejudged: int = 0) -> None: """Follow `compute()`'s carried refused direction, and say so. - `rejudged`: the refusal held while another writer had the wire, so - `compute()` judged it afresh against that writer's ask (SAFETY-107). - """ + `compute()` judged it afresh against that writer's ask (SAFETY-107).""" now = decision.refused or 0 if rejudged: if now == rejudged: # Still refused against the ask on the wire: the same episode. - # The probe clock restarts, as it did when this was an end and - # a new start: a probe is the loop's ask, and it is not on the - # wire while another writer is. + # The probe clock restarts: a probe is the loop's ask and is + # not on the wire while another writer is. self.batt_refused_at = self._now_mono return gave = (rejudged * (batt_w - self._site_idle_w()) @@ -1373,10 +1011,8 @@ class Controller: def _refusal_from_stale(self, inv, now_mono) -> None: """A stale-reading stop while the loop is frozen on ~0 W is a refusal. - - Only at one inverter: with several, one empty battery must not stop the - site (OPT-09's pack limit moves its share to the others). - """ + Only at one inverter: with several, one empty battery must not stop + the site (OPT-09 moves its share).""" t = self.tuning standing = inv.standing_w() if len(self.inverters) > 1: @@ -1405,14 +1041,13 @@ class Controller: f"stale reading {inv.batt_fresh.held:+.0f} W " f"while asked {standing:+.0f} W") - # --- SAFETY-73: the same refusal, per inverter, at N>1 ------------------- - # SAFETY-52's decisions applied to one unit of several: a unit that gives - # < REFUSED_BELOW_W for SETTLE_CYCLES while asked > PROBE_W is closed in - # that direction by a P_SAFETY unit limit, so the allocator hands its share - # to the others; it is probed at PROBE_W every PROBE_EVERY_S, one unit at a - # time. The ask is PROBE_W, not `saturation_w`: a unit's share is often - # small (a 400 W night load split two ways) and SAFETY-43 would otherwise - # name the empty battery as not delivering. DECISIONS/SAFETY-73. + # SAFETY-73: SAFETY-52's decisions per inverter at N>1: a unit giving < + # REFUSED_BELOW_W for SETTLE_CYCLES while asked > PROBE_W is closed in that + # direction by a P_SAFETY unit limit, so the allocator hands its share on; + # probed at PROBE_W every PROBE_EVERY_S, one unit at a time. The ask is + # PROBE_W, not `saturation_w`: a unit's share is often small and SAFETY-43 + # would name the empty battery as not delivering. + # DECISIONS/SAFETY-73-refusal-per-inverter.md. def _unit_refusal_start(self, inv, sign: int, evidence: str) -> None: inv.refused, inv.refused_at = sign, self._now_mono inv.probe_from, inv.ref_count = None, 0 @@ -1422,7 +1057,7 @@ class Controller: "other inverters take over: BATTERY_REFUSES.", inv.name) _LOG.debug("Evidence for %s: BATTERY_REFUSES: %s.", inv.name, evidence) # Only a refusal to GIVE: a full battery that stops taking charge is - # above its cut-out level by definition and says nothing about it. + # above its cut-out level by definition. if (sign > 0 and inv.soc is not None and inv.bms_soc_floor is not None and float(inv.soc) > float(inv.bms_soc_floor)): # Owner decision 3: a refusal above the configured cut-out level @@ -1445,7 +1080,7 @@ class Controller: "%s.", inv.name, why) # The site target may stand above what was placed (the refused share # was unmet). Restart it from the watts on the wire, or the reopened - # unit is handed the parked remainder at once (phase A: -1059 W). + # unit gets the parked remainder at once (phase A: -1059 W). placed = float(sum(i.standing_w() for i in self.inverters)) self.target = self.i_w = placed self.sat_count = 0 @@ -1480,9 +1115,8 @@ class Controller: else "the battery gives power") continue # The other direction: the battery takes PROBE_ANSWER_W or more - # the other way (charge at the floor) for SETTLE_CYCLES. Counted - # on its own reading, so one cycle of the loop overshooting - # through zero does not hand it its share back. + # the other way for SETTLE_CYCLES, counted on its own reading + # so one overshoot through zero does not hand its share back. inv.ref_count = (inv.ref_count + 1 if b is not None and -inv.refused * (b - inv.idle_w) >= _ctl.PROBE_ANSWER_W else 0) @@ -1491,10 +1125,9 @@ class Controller: inv, "the battery takes power the other way") continue if inv.rejected_since is not None: - # SAFETY-96: its writes are refused, so its board may have - # gone to 0 W on its own; that is no refusal by the battery. - # The count is kept, as across a gap, so a link that refuses - # now and then does not hide a battery that does refuse. + # SAFETY-96: its writes are refused, so its board may have gone + # to 0 W on its own; that is no refusal by the battery. The + # count is kept, as across a gap. continue sign = self._gave_nothing(inv) if sign: @@ -1507,20 +1140,20 @@ class Controller: """The refusal evidence, one cycle of it: the sign `inv` has given under REFUSED_BELOW_W while asked over PROBE_W, one way, for SETTLE_CYCLES spanning REFUSED_SPAN_S, else 0. SAFETY-73 at N>1; - SAFETY-94 adds it to the N=1 engage in compute(). SAFETY-97: the band - is centred on the unit's idle reading.""" + SAFETY-94 adds it to the N=1 engage in compute(). SAFETY-97: the + band is centred on the unit's idle reading.""" b = inv.batt if b is None: return 0 # a gap keeps the count: the gate's trip seeds standing = inv.standing_w() sign = 1 if standing > 0 else -1 - # One direction throughout: a command that swaps sign every cycle - # (a flapping load) reads ~0 through the plant's own lag, and that - # is a battery following, not refusing (TEST-19 flapping family). + # One direction throughout: a command that swaps sign every cycle reads + # ~0 through the plant's lag, which is a battery following, not + # refusing (TEST-19). if (abs(standing) > _ctl.PROBE_W and abs(b - inv.idle_w) < _ctl.REFUSED_BELOW_W): # SAFETY-94: a reading still moving towards the ask is a battery - # catching up through its lag, not one refusing: start again. + # catching up through its lag: start again. if (sign != inv.ref_sign or not inv.ref_count or sign * (b - inv.ref_from) >= REFUSED_PROGRESS_W): inv.ref_count, inv.ref_since, inv.ref_from = ( @@ -1558,57 +1191,22 @@ class Controller: def _grade_store_migration(self, store) -> None: """DEV-02. Did SAFETY-02's latch actually survive the key rename? - ⚠️ THIS IS THE HALF THE CARD CALLS RISKY, AND IT IS GRADED RATHER THAN - PERFORMED. Namespacing `sign_tripped` is right - flat, a trip on B - latches A - but a latch that silently fails to move is a DISARMED SAFETY - GUARD ON AN INSTALL THAT BELIEVES IT IS PROTECTED: the state file still - reads `sign_tripped: true`, the banner is green, and the controller - resumes commanding into the fault that tripped it. That is strictly - worse than never having persisted the latch, because now there is - evidence that says it is fine. + Graded, not performed: a latch that silently fails to move is a + DISARMED SAFETY GUARD on an install that believes it is protected + (state file says `sign_tripped: true`, banner green, controller resumes + into the fault). The pre-DEV-02 flat value is compared with what the + roster came up with; a disagreement re-arms the latch by hand from the + flat value and writes the store. The fail-safe direction for a latch is + ON. - So the pre-DEV-02 flat value is compared against what the roster - ACTUALLY came up with, and a disagreement is not reported and survived - - the latch is re-armed by hand, from the flat value, and the store is - written so the next start does not need this branch. The fail-safe - direction for a latch is ON: a spurious trip costs a 0 W hold an - operator clears with one button, and a missing one costs whatever - SAFETY-02 was watching for. - - ⚠️ AND IT RUNS ON EVERY START, NOT ONLY ON THE ONE THAT MIGRATES. It - used to return early on `not rep.ran`, for a real reason: while nothing - cleared the flat key, an install that had ever tripped carried - `sign_tripped: true` in its file forever, and a guard reading it would - have re-armed the failsafe on every restart for the rest of the - install's life. - - THAT GATE SWALLOWED THE EXACT ROUND TRIP store.py's RULE 2 EXISTS FOR, - and it is not hypothetical - a firmware version change with a rollback - path was performed on this site on 2026-09-02: - - 1. the install migrates -> `schema_version = 1`, - `unit/inverter-a/sign_tripped = False` - 2. the operator DOWNGRADES, and SAFETY-02 trips inside the downgrade - window. The old code knows only the flat key, so the file gains - `sign_tripped: true` and nothing namespaced - 3. the operator upgrades again. `rep.ran` is False - the store is - already at schema 1 - so BOTH the migration and this guard are - skipped, `Inverter.__init__` reads the namespaced key, and the - controller comes up with `sign_wd.tripped = False` while - /data/state.json says `sign_tripped: true` - - Which is this module's own sentence, rebuilt by the version marker: the - file still says tripped, the banner is green, and the controller resumes - driving the wrong way. - - The gate is gone because the reason for it is gone. `handle_action()` - now clears the flat keys alongside the namespaced ones when the operator - clears the latch, and the trip mirrors them, so the two spellings can - disagree in exactly one direction: FLAT SAYS TRIPPED AND NO UNIT CAME UP - TRIPPED means the flat key was written by a version that did not know - about namespaces - a downgrade-window trip - or a migration that lost - the latch. Both want the same answer, which is the one below. - """ + It runs on EVERY start, not only the migrating one: gating on `rep.ran` + swallowed store.py's rule-2 round trip (upgrade, DOWNGRADE with a trip + in the window, upgrade again: the namespaced key says untripped, the + flat key says tripped; performed on this site 2026-09-02). The gate + could go because `handle_action()` now clears the flat keys with the + namespaced ones and the trip mirrors them, so FLAT TRIPPED AND NO UNIT + TRIPPED means a downgrade-window trip or a lost migration. + DECISIONS/MAIN-orchestration.md.""" rep = self.store_migration if rep.ran and not rep.ok: _LOG.error("The saved safety stops did not copy over cleanly, so " @@ -1618,101 +1216,25 @@ class Controller: legacy_tripped = bool(data.get("sign_tripped", False)) if not legacy_tripped or not self.inverters: return - # ⚠️ THE QUESTION IS "IS ANY NAMESPACED LATCH SET **THAT A MACHINE - # READS**", AND THE LAST CLAUSE IS SAFETY-44's SECOND HALF. At a roster - # of two a trip on B leaves A untripped, so re-arming A off a flat key - # that B's own trip mirrored would latch a healthy inverter on every - # restart - the flat-key defect DEV-02 removed, coming back through its - # own guard. That much stands, and it is why the question is asked of - # the STORE's keys rather than of the roster's objects. - # - # ⚠️ BUT IT USED TO ACCEPT **ANY** `unit/*/sign_tripped`, and the - # paragraph here defended that: the store "holds every one this install - # has ever written, including one belonging to a unit that is not in - # TODAY's roster ... whereas the roster only knows what is configured - # right now". THE PREMISE IS TRUE AND IT ARGUES THE WRONG WAY. A latch - # under a name no unit answers to is not protection an operator has - it - # is a key nothing reads, which is the definition of the state this - # method exists to repair. Counting it as protection made the guard - # return early on precisely the install that needed it, and that is not - # hypothetical: it is where the migration above USED to put the latch - # on every declared roster, so the guard was blinded by its own upgrade. - # - # THE MIGRATION FIX DOES NOT REACH THOSE INSTALLS. A store stamped by - # the shipped version already has the marker, so `migrate_unit_keys()` - # returns at the version check and never runs again. This guard is the - # ONLY repair path for a site that upgraded before this card, and it - # re-arms onto `inverters[0]` - which lands the latch back under a name - # the roster reads. - # ⚠️ AND "OFF THE ROSTER" IS NOT ENOUGH ON ITS OWN - IT HAS TO BE THE - # MIGRATION'S OWN FINGERPRINT, WHICH IS `UNIT_A`. The first draft of - # this treated EVERY off-roster latch as litter, and test_inverter.py's - # DEV-02 restart case caught it: there the store carries - # `unit/inverter-b/sign_tripped = True` from B's own trip while the - # roster being graded holds only A, and the blunt rule re-armed a - # HEALTHY A off the flat key that B's trip had mirrored - DEV-02's - # defect rebuilt inside its own guard, which is exactly what the - # paragraph above was right to be afraid of. - # - # `UNIT_A` is the discriminator because it is the ONLY name the broken - # migration ever wrote. `roster_names()` falls back to a POSITIONAL - # `inverter-N`, so `inverter-a` is not a name this code hands to anyone - # else. A latch stranded under any OTHER name is a real unit that is - # out of today's config - a roster is configuration and it changes - - # and it still counts as protection. - # - # ⚠️ THE RESIDUAL, STATED RATHER THAN LEFT TO BE FOUND: a site whose - # own first unit is NAMED `inverter-a`, latched, and then taken out of - # the roster gets re-armed on `inverters[0]`. That is a healthy unit - # held at 0 W until an operator clears it, which is the conservative - # direction; SAFETY-44's is a site DRIVING on a state file that says it - # is latched. - # - # ⚠️ AND THE SECOND HALF OF THAT RESIDUAL IS THE **SENTENCE**, NOT THE - # BOOLEAN. The reason re-armed from is the FLAT mirror, which carries - # whatever tripped LAST - so on a multi-unit site this path can print - # one inverter's evidence under another inverter's name. The latch is - # right and the explanation may not be. It is disclosed here because the - # banner is what an operator acts on, and `test_maintenance.py`'s check - # that the reason is not the removed unit's is guaranteed by that - # fixture's choice of flat string, NOT by this code. - # ⚠️ AND THE THREE CASES ARE THREE VARIABLES, NOT ONE SHARED FLAG. - # THIS IS WHERE THE FIRST VERSION OF THIS FIX WAS DEFEATED, BY ONE KEY. - # It wrote `namespaced_latched = True` for BOTH "on the roster" and "off - # the roster but not UNIT_A", so a single latched key belonging to a - # unit the operator had removed from `inverters:` vetoed the repair of - # the misdirected `inverter-a` latch sitting beside it. Measured on the - # shipped build, roster ['garage', 'shed'], store stamped so the - # migration cannot reach it: - # - # sign_tripped + unit/inverter-a/sign_tripped - # -> [('garage', True), ('shed', False)] repaired - # ...AND unit/old-shed/sign_tripped - # -> [('garage', False), ('shed', False)] ⚠️ DRIVING - # - # A site DRIVING on a state file that reads `sign_tripped: true`, which - # is the sentence this whole card exists to make false - and - # `tools/rehearse_roster.py` said so, on the unmodified tree, exit 1. - # - # ⚠️ IT IS ALSO INSTANCE #17 OF HABIT 4, IN THE EXTENSION MUTATION.md - # ADDED TWO DAYS AGO. The two fixtures written for this card each hold - # ONE arrangement - `_staged_store` has only `inverter-a`, `_other_store` - # has only `inverter-b` - and the flag is written twice with opposite - # intents only when BOTH are present. A per-key check whose keys have - # never disagreed WITHIN ONE STORE is a check over whichever key came - # last. The fixture below now holds both. - # - # THE THREE CASES, AND WHY THE ANSWER DIFFERS FOR EACH: - # on the roster a machine reads it: real protection, stop. - # off-roster, `UNIT_A` the broken migration's fingerprint - this - # IS the flat latch, misdirected. Repair. - # off-roster, other name a real unit out of today's config. Its - # latch is unread too, but it is NOT the flat - # key's misdirection, and re-arming a healthy - # sibling off it is DEV-02's defect. Stop - - # UNLESS a stranded `UNIT_A` latch is also - # present, because then the flat key IS - # accounted for and the repair is the point. + # The question is "is any namespaced latch set THAT A MACHINE READS" + # (SAFETY-44, second half), asked of the STORE's keys, not the roster's + # objects: re-arming A off a flat key that B's own trip mirrored would + # latch a healthy inverter on every restart (DEV-02's defect). + # Three cases: on the roster, a machine reads it, stop. Off-roster and + # named `UNIT_A`, the broken migration's fingerprint (the only name it + # wrote; `roster_names()` never hands out `inverter-a` otherwise): the + # flat latch misdirected, repair onto `inverters[0]`. Off-roster, other + # name: a real unit out of today's config; stop, unless a stranded + # `UNIT_A` latch is also present. Three variables, not one shared flag + # (one key defeated the first fix; MUTATION.md habit 4, instance 17). + # This guard is the ONLY repair for sites upgraded before the fix, as + # `migrate_unit_keys()` returns at the version check. + # Residual: a first unit really NAMED `inverter-a`, latched, then + # removed from the roster is re-armed on `inverters[0]` (a healthy unit + # held at 0 W; the conservative direction). The reason shown is the + # flat mirror's, which carries whatever tripped LAST, so on a + # multi-unit site one inverter's evidence may print under another's + # name. DECISIONS/MAIN-orchestration.md. on_roster = {inv.name for inv in self.inverters} stranded, on_roster_latched, other_off_roster = [], False, False for k, v in data.items(): @@ -1720,8 +1242,8 @@ class Controller: continue if not bool(v): continue - # `unit_key()` builds `unit//`, so the unit is field 1 - - # split through the writer's own spelling rather than re-deriving. + # `unit_key()` builds `unit//`, so the unit is field 1: + # split through the writer's own spelling. unit = k.split("/")[1] if unit in on_roster: on_roster_latched = True @@ -1733,9 +1255,9 @@ class Controller: for inv in self.inverters): return if other_off_roster and not stranded: - # A removed unit's own latch, and no misdirected one. The flat key - # is that unit's mirror, so it carries no new information and - # re-arming `inverters[0]` from it would latch a healthy pack. + # A removed unit's own latch and no misdirected one: the flat key + # is that unit's mirror, so re-arming `inverters[0]` from it would + # latch a healthy pack. return if stranded: _LOG.error( @@ -1766,25 +1288,17 @@ class Controller: + first.sign_wd.reason, hint=messages.hint("stop_restored", unit=first.name)) - # -- the roster, and the N=1 views of it --------------------------------- + # the roster, and the N=1 views of it @property def units(self) -> tuple: - """The actuator names, DERIVED from the roster. Never assignable. - - See the note in `__init__`: a settable `units` is a site that can claim - two actuators while holding one writer, which is a half-driven site with - nothing to say so. - """ + """The actuator names, DERIVED from the roster. Never assignable (see + ARB-03 in `__init__`).""" return tuple(inv.name for inv in self.inverters) @property def maint_index(self) -> int: - """Which roster slot holds the site's ONE maintenance slot right now. - - The pack that is mid-cycle if there is one, otherwise the first - which - at a roster of one is always 0, always was, and is the whole of this - property's N=1 behaviour. - """ + """Which roster slot holds the site's ONE maintenance slot: the pack + mid-cycle if any, otherwise the first (always 0 at a roster of one).""" for i, m in enumerate(self.maints): if m.phase in POWER_MOVING: return i @@ -1792,61 +1306,29 @@ class Controller: @property def maint(self): - """The `Maintenance` the site's one maintenance slot belongs to. - - ⚠️ MAINT-05 TURNED THIS FROM AN ATTRIBUTE INTO A VIEW, AND THE THING IT - IS A VIEW OF CHANGED WITH IT. There are now `self.maints` - one FSM per - roster entry, each with its own schedule, phase and history - and every - SITE-level surface that used to read `self.maint` (the `phase` MQTT - value, the status banner, the operator's start/abort buttons) wants THE - ONE THAT IS RECALIBRATING, not "the first one". `maintenance.arbitrate()` - guarantees at most one pack is ever out of IDLE, so "the one that is - recalibrating" is well defined; when none is, this is `maints[0]` and - the surfaces read exactly what they read before. - - ⚠️ AT A ROSTER OF ONE IT IS `maints[0]` ON EVERY TICK, in both branches, - which is the object that used to be assigned here. Nothing downstream - can tell the difference - that is AC 6, and test_maintenance.py part 9 - asserts it rather than trusting this paragraph. - """ + """The `Maintenance` the site's one maintenance slot belongs to + (MAINT-05): a VIEW over `self.maints` (one FSM per roster entry). + Site-level surfaces (`phase` MQTT value, banner, start/abort + buttons) want the one that is recalibrating; + `maintenance.arbitrate()` guarantees at most one is out of IDLE. + None active gives `maints[0]`, as at a roster of one on every tick + (AC 6, test_maintenance.py part 9).""" return self.maints[self.maint_index] @property def maint_unit(self): - """The pack the site's one maintenance slot is currently on. - - ⚠️ THIS USED TO BE `self.inverters[0]`, FLATLY, AND THE COMMENT THAT - STOOD HERE EXPLAINED WHY: there was exactly one `Maintenance`, its - history lived under the store's un-namespaced `phase` / `last_completed` - keys, so the pack it drove could only be the first in the roster. That - sentence is what MAINT-05 removed. There is now one FSM and one - namespaced history PER ROSTER ENTRY, so the pack being recalibrated is - whichever one holds the site's maintenance slot, and `maint_index` - - not the roster's order - is what answers it. - - ⚠️ AND `validate.py` RULE 6 MOVED WITH IT, exactly as its own comment - said it must ("when `maint_unit` stops being `inverters[0]`, this line - moves with it"). It graded `maintenance_charge_w` against the FIRST - entry's cap on the argument that the cycle never touches the others; - that argument is now false, so it grades against the weakest cap on the - roster, like rule 5. See the ⚠️ block there. - """ + """The pack the site's one maintenance slot is currently on: whichever + holds the slot, by `maint_index`, not roster order (MAINT-05 removed + the flat `inverters[0]`). validate.py rule 6 moved with it: it + grades `maintenance_charge_w` against the weakest cap on the roster, + like rule 5.""" return self.inverters[self.maint_index] def _maint_button(self, now): - """The pack an operator's one maintenance button acts on. - - The pack holding the site's maintenance slot when one holds it, so - "abort" stops the cycle that is running and "start" restarts it - - exactly what both buttons meant while there was one pack, and the reason - this is not simply "the longest-waiting one". When every pack is idle it - IS the longest-waiting one, which is `arbitrate()`'s own clause 2, so a - forced start and a scheduled start choose the same pack rather than two - different ones. - - ⚠️ AT A ROSTER OF ONE BOTH BRANCHES ARE `maints[0]`. There is no third - case: `max()` over a one-element list is that element. - """ + """The pack an operator's one maintenance button acts on: the pack + holding the slot, so "abort" stops the running cycle and "start" + restarts it; when all are idle, the longest-waiting (`arbitrate()` + clause 2), so forced and scheduled starts choose the same pack.""" if self.maints[self.maint_index].phase in POWER_MOVING: return self.maints[self.maint_index] return max(enumerate(self.maints), @@ -1865,14 +1347,10 @@ class Controller: return inv is not None and inv.has_writer() def _only(self): - """The sole inverter, or an error naming why the caller cannot have one. - - ⚠️ THIS IS WHAT MAKES THE N=1 VIEWS BELOW SAFE. §6.2's identity - one - inverter means the site and the unit are the same thing - is true at N=1 - and false at N>=2, so a view built on it must not quietly answer for - "the first one" once the roster grows. Every one of them refuses instead, - loudly, naming the per-instance attribute to use. - """ + """The sole inverter, or an error naming why the caller cannot have + one. 6.2's identity (one inverter means site and unit are the same + thing) is false at N>=2, so every N=1 view refuses loudly instead of + answering for "the first one".""" if len(self.inverters) != 1: raise AttributeError( f"this site has {len(self.inverters)} inverters, so there is no " @@ -1881,23 +1359,13 @@ class Controller: f"`for inv in controller.inverters: inv.`") return self.inverters[0] - # ⚠️ PROPERTIES, NOT FIELDS, AND EVERY ONE OF THEM IS A COMPATIBILITY VIEW. - # These are the scalars this card removed. They survive as read/write views - # of the SOLE inverter so that an N=1 install, and every replay in this - # repository that constructs one, is byte-identical - which is AC 8. They - # are properties rather than attributes for two reasons that are the card: - # - # * an assignment cannot re-create the scalar. `self.soc = 4` now writes - # inverter A's own SoC; there is no second copy to drift. - # * they REFUSE at N>=2 rather than answering for the first unit, so a - # consumer that has not been made per-instance fails on the line that - # reads it instead of grading inverter B against inverter A's number. - # That is the whole defect, and this is the tripwire on it. - # - # ⚠️ NOTHING INSIDE THIS FILE MAY READ THEM. main.py's own paths iterate the - # roster; test_inverter.py's grader scans this module's AST and goes red on - # a `self.` inside any method of `Controller`. A view that main.py used - # would be the scalar back with a longer name. + # Compatibility views, as properties not fields: the scalars removed by + # ARB-03/DEV-02 survive as read/write views of the SOLE inverter so an N=1 + # install and every replay stay byte-identical (AC 8), and they REFUSE at + # N>=2 so a consumer not yet made per-instance fails on the line that reads + # it. NOTHING INSIDE THIS FILE MAY READ THEM: test_inverter.py scans this + # module's AST and goes red on a `self.` inside any `Controller` + # method. @property def unit(self) -> str: return self._only().name @@ -1958,123 +1426,66 @@ class Controller: def sign_wd(self): return self._only().sign_wd - # -- SAFETY-30: which comparison each sign detector is making ------------- + # SAFETY-30: which comparison each sign detector is making def _arm_sign_watchdogs(self) -> None: """Tell every sign detector which of SAFETY-30's two questions it asks. - - ⚠️ A PURE FUNCTION OF `len(self.inverters)`, AND THE ONLY PLACE THE - ROSTER DECIDES ANYTHING ABOUT THIS CHECK. Written as one method rather - than as a conditional at the two call sites for `loop_is_following()`'s - reason: the arrangement that does not keep two things in step is the one - where they are written down twice. - - AT ONE INVERTER - §6.2's identity, and the whole of AC 8. The single - unit's detector is armed against THE METER with the shipped - `sign_disagree_w` / `sign_disagree_s`, which is what it has always been - armed against, and the site detector is not armed at all. A one-inverter - install therefore makes the same comparison, at the same thresholds, - writes the same reason string, latches under the same keys and files the - same `failsafe` claim as it did before this card. - - AT TWO OR MORE - the identity is gone and the meter stops being a view - of any one actuator. Each unit's detector is armed against ITS OWN - MEASURED POWER (`Inverter.batt`, register 0x0518) under the re-derived - per-unit pair, and the site detector is armed against the meter under - the site pair. Two questions, two remedies, two latches. - - ⚠️ CALLED AGAIN AT THE TOP OF EVERY `cycle()`, not only from - `__init__`. The roster is re-read every cycle for liveness and for the - allocation, and this repository's two-inverter fixtures append an - `Inverter` AFTER the Controller is constructed - a detector frozen at - construction would then be graded against the meter at a roster of two, - which is the defect this card exists to remove, wearing a fixture's - name. `arm()` touches no timer and no latch, so calling it every cycle - cannot disturb an episode in progress. - """ + A pure function of `len(self.inverters)` and the only place the + roster decides anything about this check (one method, not a + conditional at two call sites). + At ONE inverter (6.2, AC 8) the unit's detector is armed against THE + METER with the shipped pair and the site detector is not armed: the + same comparison, thresholds, reason string, keys and `failsafe` claim + as before. At TWO OR MORE each unit is armed against ITS OWN measured + power (`Inverter.batt`, register 0x0518) under the per-unit pair, and + the site detector against the meter under the site pair. + Called again at the top of EVERY `cycle()`: fixtures append an + `Inverter` after construction, and a detector frozen at construction + would be graded against the meter at a roster of two. `arm()` touches + no timer or latch, so repeating it is safe.""" one = len(self.inverters) <= 1 for inv in self.inverters: inv.sign_wd.arm(self.sign_cfg if one else self.unit_sign_cfg, AGAINST_METER if one else AGAINST_OWN_POWER) self.site_sign_wd.arm(self.site_sign_cfg, AGAINST_METER) - # -- the site aggregates, each one named for the question it answers ------ - # ⚠️ THESE ARE NOT "THE SCALARS WITH A SUM IN FRONT". Each one picks a - # DIFFERENT reduction, because the questions are different, and getting the - # reduction wrong is how a pack gets driven below its own floor. The - # reduction is written at the definition, with its reason, rather than - # inline at four call sites. + # The site aggregates, each named for the question it answers. Each picks a + # DIFFERENT reduction (the wrong one drives a pack below its own floor); + # the reason is written at each definition. @property def site_max_w(self) -> float: - """The supervised bound on the SITE target: what the roster can deliver. - - SUM. Each unit's own `supervised` claim still binds its own share, so - this cannot over-command a device; what it must not do is bound the site - loop at ONE unit's rating, which would waste the second inverter - entirely. At N=1 it is `max_w`, exactly. - """ + """The supervised bound on the SITE target: what the roster can + deliver. SUM: each unit's own `supervised` claim still binds its + share, and bounding the site loop at ONE unit's rating would waste + the second inverter. `max_w` exactly at N=1.""" return float(sum(inv.max_w for inv in self.inverters)) def trade_power_w(self, floor_pct, ceiling_pct) -> tuple: - """OPT-09. `(charge_w, discharge_w)` - how hard a trade may be SIZED. - - The site rating is what the site can push; this is what the site can - push AND STILL HAVE SOMEWHERE TO PUT IT. `arbitrage.decide()` used to be - handed `site_max_w` on both sides, so on a roster of two the trade was - sized at the sum of the ratings whatever either pack's state of charge - was - the site would buy at 9600 W with only one pack able to absorb it, - and the watts that could not be placed would come back as `unmet_w` for - the site integrator to chase for the rest of the hour. - - ⚠️ THE BOUNDS ARE ARBITRAGE'S OWN (`arbitrage_soc_floor` / - `arbitrage_soc_ceiling`) AND NOT THE PACK'S BMS CUT-OUT, because SIZING - A TRADE IS THE TRADE'S BUSINESS. This method answers "how much may this - strategy ask for"; the ENVELOPE - `packshare.envelope()`, filed as a - `pack` claim - answers "what may this pack be commanded at all", and - that one is the pack's own physical cut-out for the reason SAFETY-25 - gives. Sizing with the physical floor would let the strategy ask for - watts its own reserve forbids; bounding the envelope with the trade's - reserve would let a trade preference stop the grid-following loop. - THEY ARE DIFFERENT QUESTIONS AND THEY GET DIFFERENT NUMBERS. - - ⚠️ AND IT SIZES THE ASK, IT DOES NOT ROUTE IT. Which pack ends up - carrying the trade is the allocator's answer, and the only per-pack - instrument it has for a TRADE reserve is the weights - which are a - preference, not a permission (`allocator._fill()` will hand watts to a - zero-weighted unit rather than leave them unmet). So a pack between its - arbitrage floor and its BMS cut-out can still take part of a sell. That - is stated rather than fixed: fixing it needs a per-pack envelope that - depends on WHICH claim won the tick, which the arbiter's partition does - not express, and is a card of its own. - - A pack whose SoC did not read is counted on NEITHER side. It is not - reachable with control running - `inputs_ok` stops the site when any - pack's SoC is missing - and withholding is the direction that cannot - over-size. - - ⚠️ NEITHER SIDE CAN BE ZERO ON A TICK THAT ACTUALLY TRADES, and it is - not left to that coincidence: `arbitrage.decide()` refuses a 0 W sizing - by name (`IDLE_NO_SIZE`), because a 0 W arbitrage claim is a park order - that outranks the loop. - """ - # ⚠️ THE REDUCTION ITSELF LIVES IN `packshare`, NOT HERE, and this - # method is the roster view it is applied to. A transcription of the - # arithmetic into a check is one copy plus a future disagreement about - # what it means, and the copy that drifts is always the grading one - - # so `test_packshare.py` part 9 calls the SAME function this does. + """OPT-09. `(charge_w, discharge_w)`: how hard a trade may be SIZED: + what the site can push AND STILL HAVE SOMEWHERE TO PUT (a roster of + two used to size at the sum of ratings, and unplaceable watts came + back as `unmet_w` for the integrator to chase). + The bounds are arbitrage's own (`arbitrage_soc_floor` / `_ceiling`), + NOT the BMS cut-out: sizing a trade is the trade's business; the + `packshare.envelope()` pack claim uses the physical cut-out + (SAFETY-25). Different questions, different numbers. It sizes the ask, + it does not route it: weights are a preference, so a pack between its + arbitrage floor and its cut-out can still take part of a sell (a card + of its own). A pack whose SoC did not read counts on neither side. + Neither side may be zero on a trading tick (`arbitrage.decide()` + refuses `IDLE_NO_SIZE`: a 0 W claim is a park order). + DECISIONS/MAIN-orchestration.md.""" + # The reduction lives in `packshare`; `test_packshare.py` part 9 calls + # the SAME function (a transcription in a check is a copy that drifts). return packshare.trade_power_w(self.pack_energy, floor_pct, ceiling_pct) @property def site_rating_w(self) -> float: - """The largest magnitude the SITE can actually be commanded, in W. - - SUM of each unit's own `min(max_w, |dev_min|, |dev_max|)`. This is the - peak guard's `achievable_w` and the freeze detector's rail, and summing - the PER-UNIT conjunction rather than conjoining the sums is what makes - inverter B's rating bind inverter B (AC 4). A unit whose device range has - not been read yet contributes its `max_w`, which is what the shipped - single-inverter path already did with (None, None). - """ + """The largest magnitude the SITE can be commanded, in W: SUM of each + unit's own `min(max_w, |dev_min|, |dev_max|)` (the peak guard's + `achievable_w` and the freeze detector's rail). Summing the per-unit + conjunction makes B's rating bind B (AC 4). An unread device range + contributes `max_w`.""" return float(sum(inv.rating_w() for inv in self.inverters)) def peak_aim_w(self) -> float: @@ -2084,13 +1495,9 @@ class Controller: def site_dev_bound(self, which: str): """The roster's summed device floor / ceiling, or None if unknown. - - `freeze.looks_frozen()` and `rail_unreachable()` take a device range and - SKIP a bound they were handed None for - "ESPHome is not up yet", not - "the device can do nothing". So a bound is summed only when EVERY unit - has answered; one silent unit makes the site bound unknown rather than - making it smaller, which is the same direction the shipped code took. - """ + `freeze.looks_frozen()` and `rail_unreachable()` SKIP a bound handed + None, so a bound is summed only when EVERY unit has answered: one + silent unit makes it unknown, not smaller.""" vals = [getattr(inv, which) for inv in self.inverters] if not vals or any(v is None for v in vals): return None @@ -2098,19 +1505,12 @@ class Controller: @property def site_batt_w(self): - """SUM. The battery power the SITE loop is regulating against. - - ⚠️ THIS IS THE FINDING ALLOC-01'S REVIEW HANDED THIS CARD, AND IT GOES - THE OPPOSITE WAY TO EVERYTHING ELSE HERE. `compute()`'s anti-windup test - is `abs(prev_w - actual_w) > saturation_w`, where `prev_w` is the SITE - target. Fed ONE inverter's power at a roster of two it compares a site - number against a unit number, trips the saturation freeze permanently and - leaves a standing grid error - measured on admin/goodwe's two-master rig - as up to 510 W of split-dependence at gain 0.2 and 250 W at gain 0.4, - with the allocator's shares summing to S in every one of those runs. The - counterfactual is the proof: with this sum every split is bit-identical - at every gain. See test_alloc.py part 8. - """ + """SUM. The battery power the SITE loop regulates against. Opposite to + the rest here (ALLOC-01 review): `compute()`'s anti-windup test + `abs(prev_w - actual_w) > saturation_w` compares the SITE target, so + one unit's power at N=2 trips the saturation freeze permanently (up + to 510 W of split-dependence at gain 0.2 on the two-master rig; with + the sum every split is bit-identical, test_alloc.py part 8).""" vals = [inv.batt for inv in self.inverters] if not vals or any(v is None for v in vals): return None @@ -2118,49 +1518,25 @@ class Controller: @property def site_circulating_w(self): - """SAFETY-31. `sum(|B_i|) - |sum(B_i)|` over MEASURED per-unit power. - - ⚠️ `inv.batt`, NOT `self.allocation`. THIS IS THE WHOLE CARD, and the - wrong version is the tempting one: the same formula over the allocation - record would be simpler, would never be None, and would be worth - nothing. ALLOC-01's sign-coherence gate (`allocator.REFUSE_SIGN`) already - refuses an allocation whose shares carry two signs, so a record-derived - sensor reads zero BY CONSTRUCTION, forever, and can only ever confirm - that the allocator agrees with itself. Every fault this card is for is a - divergence between what was commanded and what the hardware is doing - a - unit ignoring or lagging its share, a stale share still being executed, - or HW-01's power stage running its last instruction for 6 h 25 m with - the comms dead and the allocation record impeccable throughout. - Measured on the TEST-19 rig: with one unit wedged, the record stays - `ok` with both shares at -1250 W - a record-derived sensor reads 0 W - - while this one reads 2000 W. - - `batt` became per-instance in DEV-02; before that there was one scalar - on the Controller and this property could not have been written. - - None when any unit has not answered, and NOT zero - circulation.py says - why at length. The short version: zero would be this sensor reporting - health from an absence, which is the 2026-09-02 failure itself. - Identically 0.0 at the shipped roster of one (AC 5). - """ + """SAFETY-31. `sum(|B_i|) - |sum(B_i)|` over MEASURED per-unit power: + `inv.batt`, NOT `self.allocation`. The allocation record already has + sign-coherence (`REFUSE_SIGN`), so a record-derived sensor reads + zero by construction and can only confirm the allocator agrees with + itself; every fault this card covers is a divergence between command + and hardware (TEST-19 rig: one unit wedged, record `ok` at -1250 W + each, record-derived 0 W, this 2000 W; HW-01's 6 h 25 m). None when + any unit has not answered, never zero (circulation.py): zero would + report health from an absence. 0.0 at a roster of one (AC 5).""" return circulating_w([inv.batt for inv in self.inverters]) @property def pack_floor_soc(self): - """MINIMUM. The SoC that a discharge PERMISSION must be graded against. - - SAFETY-24's floor asks "may this site spend the pack for money", and the - honest answer at N>=2 is the EMPTIEST pack: an average lets it be driven - below its own floor, which is the failure the option exists to prevent. - None if any unit did not answer - `peakguard.decide()` treats None as - floor-limited, which costs a shave rather than a pack. - - ⚠️ AND IT IS NOT THE RIGHT REDUCTION FOR A CHARGE CEILING, which is the - FULLEST pack. Nothing in the add-on files a per-pack charge ceiling yet - (arbitrage's `soc_ceiling` is the only consumer and it takes one number); - that is CFG-04/SPIKE-04's per-pack bound, and it is named here so the - next reader knows this reduction was chosen rather than assumed. - """ + """MINIMUM. The SoC a discharge PERMISSION is graded against: the + EMPTIEST pack (an average lets one be driven below its floor). None + if any unit did not answer (`peakguard.decide()` reads None as + floor-limited). Not the right reduction for a charge ceiling (the + FULLEST pack); nothing files a per-pack ceiling yet + (CFG-04/SPIKE-04).""" vals = [inv.soc for inv in self.inverters] if not vals or any(v is None for v in vals): return None @@ -2168,66 +1544,30 @@ class Controller: @property def packs(self) -> list: - """SAFETY-32. Each pack as its own THREE numbers, in roster order. - - `pack_floor_soc` above is the site's ONE verdict and stays exactly what - DEV-02 made it - the emptiest measured pack. This is the thing that - reduction cannot produce: by the time `min()` has answered, WHICH pack - the number came from and how far that pack was from ITS OWN cut-out have - both been thrown away, and two packs at 15 % against cut-outs of 10 % and - 20 % have opposite answers that are indistinguishable afterwards. - - ⚠️ EVERY unit, live or not. Same reason as `inputs_ok`: a pack whose SoC - did not answer is a pack nothing can grade, and dropping it from this - list would let the site spend the packs that DID answer while one sat - unmeasured. `peakguard.discharge_permitted()` treats a None SoC as no - permission, which is where that must be decided. - """ + """SAFETY-32. Each pack as its own THREE numbers in roster order, + beside `pack_floor_soc` (the site's one verdict, the emptiest pack). + EVERY unit, live or not, as `inputs_ok` does.""" return [peakguard.Pack(inv.name, inv.soc, inv.bms_soc_floor) for inv in self.inverters] @property def pack_energy(self) -> list: - """OPT-09. Each pack as the FOUR numbers a split needs, in roster order. - - The fourth is `capacity_kwh`, and it is why this is not `packs` above. - That list answers the FLOOR question - may this pack be spent - and its - consumers (`pack_permissions`, `binding_soc_floor`) have no use for a - capacity and would have to default one. This list answers the SPLIT - question - which pack should carry more of the site target - and cannot - be asked without one. - - ⚠️ EVERY unit, live or not, for `packs`'s reason. The allocator drops - what `live_units()` does not list and `plan()` ignores a weight for a - unit it dropped, so filtering here would only move the same decision to - a worse place - and `packshare.weights()` needs to see a pack that did - not answer in order to withhold its opinion about the whole roster. - """ + """OPT-09. Each pack as the FOUR numbers a split needs; the fourth is + `capacity_kwh`, which is why this is not `packs`. EVERY unit, live + or not, for `packs`'s reason (the allocator drops dead ones itself).""" return [pack_energy_of(inv) for inv in self.inverters] def pack_weights(self, target_w): - """OPT-09. The comfort weights for a site target of `target_w`. - - ⚠️ IT IS A METHOD HANDED TO `resolve()`, NOT A DICT COMPUTED BEFORE IT, - and that is not indirection for its own sake. An energy-headroom - weighting is direction-dependent - room above the floor when the site is - discharging, room below full when it is charging - and the two give - opposite answers on the same roster. The direction is the SIGN OF THE - SITE TARGET, which is stage 2's output, so nothing here can know it - until `resolve()` has run stage 2. `arbiter.resolve()` calls this once, - between stage 2 and stage 3, with the number it just chose. - - ⚠️ AND IT IS READ FRESH ON EVERY CALL, NEVER CACHED, for the reason the - allocator refuses to cache an envelope: a pack's SoC moves for reasons - that have nothing to do with the allocator, and a weighting held from - construction prefers a pack that has since emptied. - """ + """OPT-09. The comfort weights for a site target of `target_w`. A + method handed to `resolve()`, not a dict computed before it (energy + headroom depends on the target), and read fresh on every call, never + cached.""" base = packshare.weights(self.pack_energy, target_w) if not self.modes_on: return base # OPT-11: the solar order. While the site charges, Leftover solar - # batteries weigh 0, so Solar first batteries fill first. A comfort - # choice like the rest of this method: permission is the mode claim. + # batteries weigh 0, so Solar first fill first (comfort, like the rest + # here; permission is the mode claim). w = packmode.charge_weights(base, self.pack_modes(), target_w) # P3: in a price run, the batteries whose run it is carry it first. tick = self.mode_tick @@ -2242,14 +1582,11 @@ class Controller: return {inv.name: self.pack_mode(inv) for inv in self.inverters} def loop_bound(self, claims): - """OPT-11. `(lo, hi)`: what the batteries may place if the loop decides. - - The sum over live units of each unit's hard limits with its mode - applied (`allocator.loop_bound()`), from THIS cycle's unit claims. At - one inverter every claim is on the site, so it is the mode range - itself, or None when no mode is filed (maintenance) or the range - leaves out 0 W (a fault `resolve()` reports). - """ + """OPT-11. `(lo, hi)`: what the batteries may place if the loop + decides: the sum over live units of each unit's hard limits with its + mode applied (`allocator.loop_bound()`), from THIS cycle's unit + claims. None when no mode is filed (maintenance) or the range leaves + out 0 W (a fault `resolve()` reports).""" if len(self.inverters) <= 1: modes = [c for c in claims if c.source == "mode"] if not modes: @@ -2299,11 +1636,9 @@ class Controller: self._wx_free_to = max(ends) if ends else None def weather_held(self, now) -> bool: - """TARIFF-24: no forecast now, but a run planned with one is under - way: it keeps its gap to its end. The wider gap applies from the next - run. OPT-34: a night charge that keeps its level (`_latch_charge()`) - keeps the gap too, run after run, or the next slice of it would be - refused. Pure; Home asks the same (`homeview.trade_verdict()`).""" + """TARIFF-24: no forecast now, but a run planned with one is under way: + it keeps its gap to its end. The wider gap applies from the next + run. (DECISIONS/MAIN-orchestration-archive.md)""" return self.solar_fc is None and ( (self._wx_free_to is not None and now < self._wx_free_to) or (self._charge_latch is not None @@ -2312,8 +1647,8 @@ class Controller: def _price_runs(self, now, packs, floor_pct, ceiling_pct): """The kept run plan, rebuilt at most once per price slot (15 min) or when the prices or a battery's choice change; a run under way is - kept to its end (`priceruns.keep_started()`). None with no fresh - firm prices: then no battery holds or trades (design section 5).""" + kept to its end (`priceruns.keep_started()`). + (DECISIONS/MAIN-orchestration-archive.md)""" plan = self.fresh_tariff_plan(now) if plan is None or not plan.has_prices: self.mode_runs, self._runs_key = None, None @@ -2366,9 +1701,8 @@ class Controller: """OPT-11 P4: the day plan (`dayplan.plan()`) on the runs just made, its levels held by the hysteresis (`dayplan.hold()`), then the runs made again with each battery's shortfall as the charge, so the cheap - run is only as long as the shortfall needs. With no plan (no or stale - forecast, no house profile yet) the levels are dropped: P3 option A. - Returns the runs to keep.""" + run is only as long as the shortfall needs. + (DECISIONS/MAIN-orchestration-archive.md)""" dp = dayplan.plan( self.solar_fc, None if self.house_profile is None @@ -2402,24 +1736,8 @@ class Controller: return self._plan_targets def _latch_charge(self, now, runs, targets, packs=None) -> None: - """OPT-34, once per cycle after `modetrade.decide()`. A night grid - charge that has started keeps its planned level when the forecast goes - mid-charge (the kept figure ends and the sensors are still silent). - - While there is a forecast, fresh or kept, the latch is rebuilt each - cycle from the plan: a battery that charges from the grid in the cheap - run comes in with its planned level, and stays in it at whatever level - the plan gives it, up or down as a fresh figure moves it (also when it - has stopped, at a lowered level), until it leaves the plan. While there - is none, `plan_targets()` gives the latched levels, so the charge goes - on to them, and `weather_held()` keeps the narrow price gap. The latch - lasts through every cheap run on and one price slot after the last - (`CHARGE_LATCH_GRACE_S`), but a run is carried on only if it starts - by the bell start of the night the latch began in (`_night_limit()`; - the run under way then finishes). It is dropped when `now` passes its - end whatever the run state, and a battery that is off or no longer in - a mode with a planned level (`packs`) leaves it. A charge that never - started has none.""" + """OPT-34, once per cycle after `modetrade.decide()`. + (DECISIONS/MAIN-orchestration-archive.md)""" old = lt = self._charge_latch tick = self.mode_tick run = None if runs is None else runs.cheap @@ -2460,10 +1778,8 @@ class Controller: "born": lt[2].isoformat()}) def _mode_roles(self, now, maint_results): - """OPT-11 P3: each battery's role and range this tick. Grid charging - and selling need a fresh plan that `TariffPlan.arbitrage()` allows - with the MEASURED peak guard (None refuses: nothing measures the - quarter), exactly the gate `arbitrage.decide()` has.""" + """OPT-11 P3: each battery's role and range this tick. + (DECISIONS/MAIN-orchestration-archive.md)""" floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o) packs = self._mode_packs(maint_results) self._wx_track(now) @@ -2533,9 +1849,7 @@ class Controller: draining=any(r.phase == DRAIN for r in maint_results or ())) self._latch_charge(now, runs, targets, packs) # A charge the guard or the headroom stopped stays off this period. - # OPT-15: only a MEASURED stop latches; the first unmeasured headroom - # (None) in a quarter stops this tick only, so a gap cannot cost the - # whole quarter. A second one latches, so gaps cannot flip the charge. + # (DECISIONS/MAIN-orchestration-archive.md) if was and self.mode_tick.action != modetrade.CHARGE: why = self.mode_tick.why gap = why == "headroom" and self.arb_headroom_w is None @@ -2565,18 +1879,13 @@ class Controller: """OPT-11 P3: file the site trade the roles ask for, sized inside the modes and the hard limits (the loop bound) and, for a charge, the billed quarter's headroom (SAFETY-45, aim cap - margin, SAFETY-79). - So no battery that its mode or a refusal holds is asked for watts it - may not give (P1's unmet share). Returns True when a trade is filed. - """ + (DECISIONS/MAIN-orchestration-archive.md)""" tick = self.mode_tick if tick is None: return False if tick.action == modetrade.CHARGE: # OPT-20: the ask carries PROBE_W for exactly the probes whose pin - # is filed this tick. `decide()` ran before the pin was (or the - # window closed since), so the two can differ by one cycle; left - # alone, the allocator takes the pin's watts out of another - # battery's share, or hands them to one it can reach. + # is filed this tick. (DECISIONS/MAIN-orchestration-archive.md) pins = _ctl.PROBE_W * sum( 1 for c in claims if c.source == "refused" and c.kind == "set" and c.value < 0) @@ -2629,15 +1938,7 @@ class Controller: def _reseed_run_edge(self, claims, now) -> None: """OPT-11 P3: a mode trade just ended, so the loop takes over from a - command no loop chose. Start it at the house's own point E, the - command that brings the grid to its target now (`site battery + grid - - target`, inside the loop bound), instead of at the trade's watts. - OPT-15: then HOLD it at E (`_edge_grid()`) until the meter shows the - step or `EDGE_HOLD_S` passes. The readings still describe the trade for - a second or two (meter and battery lag), and stepping on them sent - E 810 -> 1370 -> 1920 W on TEST-23: -1120 W export at a charge end. - Measured: DECISIONS/OPT-11-battery-modes.md. - """ + command no loop chose. (DECISIONS/MAIN-orchestration-archive.md)""" self._edge = None batt = self.site_batt_w if batt is None or self.grid is None: @@ -2655,10 +1956,7 @@ class Controller: def _edge_grid(self, end, now): """OPT-15: the grid reading the loop acts on this tick. While a run edge is held, the target itself (no error, the command stays at E). - The hold ends when the meter reaches the target in the step's - direction (or passes it), on `end` (a trade started, or another claim - moved the command), or after `EDGE_HOLD_S` - later, up to twice that, - while the battery reading is still moving towards E.""" + (DECISIONS/MAIN-orchestration-archive.md)""" e = self._edge if e is None: return self.grid @@ -2679,9 +1977,7 @@ class Controller: def _charge_track(self, claims, maint_results) -> None: """SAFETY-93, N>=2: a charge ends when its battery leaves CHARGE or can no longer be placed (stale link, refusal). Kept until the loop runs. - SAFETY-95: a drain likewise; its refusal (SAFETY-73) outranks the pin - without a contradiction, so it is read from the battery (+1, will not - give; a battery that will not take still drains).""" + (DECISIONS/MAIN-orchestration-archive.md)""" if len(self.inverters) < 2: return live = set(self.live_units()) @@ -2701,9 +1997,7 @@ class Controller: """SAFETY-93, N>=2: when a battery's maintenance charge ends, the loop starts from what the others stood at plus that battery's new command (its HOLD pin, or its last discharge if it is free; a charge stops). - Else the charge's watts stay in the site target and land on the - others: B 0 -> -1600 W in one cycle. DECISIONS/SAFETY-93-charge-end.md. - """ + (DECISIONS/MAIN-orchestration-archive.md)""" ended, self._charge_ended = self._charge_ended, frozenset() if len(self.inverters) < 2 or not ended: return @@ -2721,15 +2015,10 @@ class Controller: self.i_w = self.target def _reseed_drain_end(self, maint_results, live) -> None: - """SAFETY-95, N>=2: when a battery's maintenance drain ends, its - drain leaves the loop's start point, as a charge does (SAFETY-93): the - start is what the others stood at plus its new command (its pin, else - 0 W). Where the house needs more, the start is the house's own point - (the live batteries' readings + grid - target), never above last - cycle's start. The loop then moves one slew step a cycle. Else the - drain's floor stays in the target and lands on the others: B 0 -> - +1600 W in one cycle, exporting. DECISIONS/SAFETY-95-drain-end.md. - """ + """SAFETY-95, N>=2: when a battery's maintenance drain ends, its drain + leaves the loop's start point, as a charge does (SAFETY-93): the + start is what the others stood at plus its new command (its pin, + else 0 W). (DECISIONS/MAIN-orchestration-archive.md)""" ended, self._drain_ended = self._drain_ended, frozenset() if len(self.inverters) < 2 or not ended: return @@ -2754,20 +2043,8 @@ class Controller: self.target = self.i_w = min(hi, max(lo, here)) def _drain_grid_w(self, live) -> float: - """SAFETY-95: the grid the loop regulates. While a battery settles - from its ended drain (`_held_out_w`: until within the deadband, at - most PLANT_SETTLE_DOWN_S), the meter still shows the drain's tail and - the others' climb, so the loop would chase them (B down, then up - again). Meanwhile the batteries count at the site command: the error - is the house's own point minus the loop's start. - - SAFETY-103: a reading can be up to a register period (10 s) older - than the meter, so the house point is held, and moved only as far as - the meter proves: to the nearest value the batteries can be at (each - between its last changed reading and its commands since; a reading - that changed this cycle, or the side a battery refuses, is taken as - it reads). DECISIONS/SAFETY-103-old-readings-after-a-drain.md. - """ + """SAFETY-95: the grid the loop regulates. + (DECISIONS/MAIN-orchestration-archive.md)""" grid = float(self.grid) self._drain_tail &= set(self._settle) & set(live) if not self._drain_tail: @@ -2811,13 +2088,10 @@ class Controller: self._read_span[inv.name] = (min(lo, cmd), max(hi, cmd), since) def _reseed_drop(self, live) -> None: - """SAFETY-96, N>=2: a battery that drops out of the split (stale - link) leaves the loop's start point, so the others take over at one - slew step a cycle instead of taking its share at once. One that comes - back enters it at its reading, and every battery settles while the - split is made again (`_held_out_w`). - DECISIONS/SAFETY-96-dropped-battery.md. - """ + """SAFETY-96, N>=2: a battery that drops out of the split (stale link) + leaves the loop's start point, so the others take over at one slew + step a cycle instead of taking its share at once. + (DECISIONS/MAIN-orchestration-archive.md)""" was, self._live_was = self._live_was, frozenset(live) if len(self.inverters) < 2 or was is None or was == live: return @@ -2838,15 +2112,9 @@ class Controller: self.i_w = self.target def _held_out_w(self, live) -> float: - """SAFETY-96, N>=2: what to add to the site sum so that a battery - the loop cannot move right now counts at its command, not at its - reading. That is a battery out of the split, one whose writes are - refused, or one settling after a step the loop did not make (a charge - end, a rejoin) until its reading is within the deadband of its - command or PLANT_SETTLE_DOWN_S has passed. Its real power still - reaches the loop through the meter, so it is neither lost nor counted - twice; only the saturation freeze stops seeing it as the loop's. - """ + """SAFETY-96, N>=2: what to add to the site sum so that a battery the + loop cannot move right now counts at its command, not at its + reading. (DECISIONS/MAIN-orchestration-archive.md)""" if len(self.inverters) < 2: return 0.0 out = 0.0 @@ -2867,11 +2135,7 @@ class Controller: def _mode_fault_log(self, resolution) -> None: """OPT-11: say once, per battery, that its mode was dropped as a fault. - - A mode range without 0 W would force a direction, which no mode may do, - so it is never applied (allocator.unit_interval()). main.py never - builds one; this is the line that says so if it ever does. - """ + (DECISIONS/MAIN-orchestration-archive.md)""" alloc = resolution.allocation now = {u for u, _ in getattr(alloc, "mode_faults", ())} if resolution.mode_fault is not None and self.inverters: @@ -2886,15 +2150,8 @@ class Controller: self._mode_faults = now def _pack_action(self, what, unit, value, now) -> dict: - """OPT-11: switch one battery on or off, set its mode, or (P3) turn - its selling on or off. - - Store state, not an option, so it applies on the next cycle with no - restart. Written to the action log. Switching a battery off also stops - its maintenance cycle (the owner's 2026-09-27 answer). Refused, with a - plain reason, while battery modes are off or for an unknown battery or - value. - """ + """OPT-11: switch one battery on or off, set its mode, or (P3) turn its + selling on or off. (DECISIONS/MAIN-orchestration-archive.md)""" if not self.modes_on: return {"error": messages.pack_refused("off")} inv = next((i for i in self.inverters if i.name == unit), None) @@ -2935,13 +2192,7 @@ class Controller: async def _maint_unit_action(self, what, unit, now) -> dict: """MAINT-11: start or abort the maintenance cycle of ONE named battery. - - Returns `{"error", "code", "unit"}` for a refusal and changes nothing. - The site keeps its one maintenance slot: a start while any battery - holds it is refused (no queue), see DECISIONS/MAINT-11. The phase - machine is not touched beyond `force_start()` and `abort()`, the same - two calls the bare button makes. - """ + (DECISIONS/MAIN-orchestration-archive.md)""" inv = next((i for i in self.inverters if i.name == unit), None) if inv is None: return self._maint_refusal("unit") @@ -2987,14 +2238,7 @@ class Controller: async def mqtt_command(self, topic, payload, retained) -> None: """OPT-11 P2: one command from a battery's HA switch or mode list. - - It goes through handle_action(), the page's /action path, so the - checks and the store write are the same. A retained command (one the - broker replays on subscribe, e.g. after a restart) is never applied: - the store already holds the last choice. A refusal is logged once per - topic and reason, at most 32, and further refusals are counted in the - log at 1, 10, 100 and so on. Never raises: MQTT must not reach control. - """ + (DECISIONS/MAIN-orchestration-archive.md)""" try: slug, action, value, why = parse_command( topic, payload, retained, packmode.MODES) @@ -3030,18 +2274,13 @@ class Controller: _LOG.exception("Home Assistant command could not be handled") def _drain_floor(self, claims, maint_results): - """SAFETY-78: the site `lo` for a pinned maintenance drain, or None. - - At N>=2 the drain pins its pack and the site target is split around - the pin. Below the pin a sibling would have to charge (opposite - directions, refused, 0 W, and the drain never moves). So the site - target may not go below what the pin places: the drained pack covers - the house up to its drain rate and exports the rest, as at N=1, and - a sibling only ever adds power in the same direction. At N=1 the - drain is the site SET itself, so nothing is filed. - A draining pack whose own limits leave it nothing to give (a refusal, - a stale link) adds no floor, and that is logged once. - """ + """SAFETY-78: the site `lo` for a pinned maintenance drain, or None. At + N>=2 the drain pins its pack and the site target is split around the + pin; below the pin a sibling would have to charge (opposite + directions, refused, 0 W, the drain never moves), so the site target + may not go below what the pin places. At N=1 the drain is the site + SET itself: nothing filed. A draining pack with nothing to give adds + no floor (logged once).""" if len(self.inverters) < 2: return None live = set(self.live_units()) @@ -3067,10 +2306,8 @@ class Controller: def _peak_on_able(self, decision, maint_results): """SAFETY-87: at N>=2, `decision` placed on the batteries that can - still give: live, not refusing, above their own reserve. `able_w` is - their rating plus the drain a held battery gives anyway (its - maintenance pin). Unchanged when no battery is held back. - DECISIONS/SAFETY-87-guard-uses-able-batteries.md.""" + still give: live, not refusing, above their own reserve. + (DECISIONS/MAIN-orchestration-archive.md)""" if len(self.inverters) < 2: return decision floor = float(self.o.get( @@ -3098,10 +2335,8 @@ class Controller: def _peak_landing(self, decision): """SAFETY-126: `decision` marked `within_aim` while the guard wants power but the batteries at 0 W would end the quarter within - `ALARM_MISS_W` of the aim (`peakguard.misses_aim()`). Once a real miss - is seen, the need stands until the guard lets go. The claim and the - commands are unchanged; only the alarms and the "Why" read it. - DECISIONS/SAFETY-126-landing-error-is-not-an-overrun.md.""" + `ALARM_MISS_W` of the aim (`peakguard.misses_aim()`). + (DECISIONS/MAIN-orchestration-archive.md)""" batt, prev = self.site_batt_w, self._land_batt_prev self._land_batt_prev = batt if not (decision.engaged and decision.blind is None @@ -3126,11 +2361,8 @@ class Controller: def _held_back(self) -> frozenset: """SAFETY-91, N>=2: the batteries at or under their own peak reserve - while another can still give (live, not refusing). They weigh 0 in - every split where the site gives, so they give last (SAFETY-25 - option A: still drawn once the others are full). One held back - stays so until its SoC reads more than 1 point above its reserve, so - a reading flipping across it does not swap the split each cycle.""" + while another can still give (live, not refusing). + (DECISIONS/MAIN-orchestration-archive.md)""" if len(self.inverters) < 2: return frozenset() floor = float(self.o.get( @@ -3151,9 +2383,9 @@ class Controller: def _drain_rise(self, claims, maint_results) -> None: """SAFETY-91, N>=2: while a battery is held back (`_held_back()`), a - draining battery's pin becomes a floor at its drain rate, so it rises - towards its rating before the held one gives. A site stop still - decides alone. DECISIONS/SAFETY-91-held-battery-last.md.""" + draining battery's pin becomes a floor at its drain rate, so it + rises towards its rating before the held one gives. + (DECISIONS/MAIN-orchestration-archive.md)""" held = self._held_back() if not held or any(c.kind == SET and c.actuator == ACTUATOR_SITE and c.priority > P_PEAK for c in claims): @@ -3245,15 +2477,11 @@ class Controller: return out def _peak_over_drain(self, claims, maint_results) -> None: - """SAFETY-83: at N=1 a peak floor above the drain rate outranks it. - - The drain is the site SET at P_MAINTENANCE, above the peak floor - (P_PEAK), so the floor did not bind and the house imported what the - drain left. Where the floor asks for more than the drain rate, it is - refiled at P_MAINTENANCE and wins; nothing else changes. Safety claims - (P_SAFETY) still outrank it. At N>=2 the `drain` floor does this, and - `_drain_over_reserve()` when a battery is held at its reserve. - """ + """SAFETY-83: at N=1 a peak floor above the drain rate outranks it: the + drain is the site SET at P_MAINTENANCE, above P_PEAK, so the floor + did not bind. Refiled at P_MAINTENANCE where it asks for more; + P_SAFETY still outranks. N>=2: the `drain` floor and + `_drain_over_reserve()`.""" if len(self.inverters) != 1: self._drain_over_reserve(claims, maint_results) return @@ -3275,9 +2503,8 @@ class Controller: def _drain_over_reserve(self, claims, maint_results) -> None: """SAFETY-87, N>=2: while a battery is held at its peak reserve, a draining battery that may still give is raised from its drain rate - towards its rating for the peak floor, after the others. Otherwise - the floor's rest lands on the held battery (its weight 0 only orders - the split; the drain pin binds). A site stop still decides alone.""" + towards its rating for the peak floor, after the others. + (DECISIONS/MAIN-orchestration-archive.md)""" reserve = self._peak_reserve() floor = max((c.lo for c in claims if c.source == peakguard.SOURCE), default=0.0) @@ -3319,14 +2546,11 @@ class Controller: claims[i] = dataclasses.replace(c, value=up) def _peak_over_hold(self, claims, maint_results): - """SAFETY-86: a peak floor outranks a maintenance HOLD (0 W). - - At N=1 the floor is refiled at P_MAINTENANCE, as for a drain. At N>=2 - the holding pack gives only what the others cannot: its pin is raised - to the floor minus what they may place, and the floor is returned as - the site `hold` ceiling (else None). A site stop still decides alone. - DECISIONS/SAFETY-86-peak-over-hold.md. - """ + """SAFETY-86: a peak floor outranks a maintenance HOLD (0 W). N=1: + refiled at P_MAINTENANCE as for a drain. N>=2: the holding pack's + pin is raised to the floor minus what the others may place, and the + floor is returned as the site `hold` ceiling. A site stop still + decides alone. DECISIONS/SAFETY-86-peak-over-hold.md.""" hold = {self.actuator_of("maintenance", inv) for inv, res in zip(self.inverters, maint_results) if res.phase == HOLD and res.owns_setpoint} @@ -3374,10 +2598,10 @@ class Controller: def _charge_headroom(self, floor: float, claims) -> float: """SAFETY-84: a cheap-window charge pulls no more than the quarter's - room under the peak aim (`charge_room_w()`), so it yields just enough - to the peak guard; at N>=2 nothing under a site stop (its 0 W would - need the other pack to give). SAFETY-125: at N=1 too, owner - 2026-10-29; the stop rule stays N>=2 only.""" + room under the peak aim (`charge_room_w()`), so it yields just + enough to the peak guard; at N>=2 nothing under a site stop (its 0 W + would need the other pack to give). + (DECISIONS/MAIN-orchestration-archive.md)""" if len(self.inverters) > 1 and any( c.kind == SET and c.actuator == ACTUATOR_SITE and c.priority > P_MAINT_SHAPE for c in claims): @@ -3388,14 +2612,10 @@ class Controller: return floor def _charge_ceiling(self, claims): - """SAFETY-84: the site `hi` for a cheap-window charge at N>=2, or None. - - The charging pack's own `hi` (P_MAINT_SHAPE) makes it charge; above - that the other pack would have to give (opposite directions, refused, - 0 W). So the site target stays at or below it. Not filed at N=1, nor - when the pack's own limits cancel the charge (a refusal, a stale link). - DECISIONS/SAFETY-84-charge-at-two.md. - """ + """SAFETY-84: the site `hi` for a cheap-window charge at N>=2, or None: + the charging pack's own `hi` (P_MAINT_SHAPE) makes it charge; above + that the other pack would have to give (opposite directions, + refused, 0 W). Not filed at N=1.""" if len(self.inverters) < 2: return None live = set(self.live_units()) @@ -3433,15 +2653,10 @@ class Controller: @property def inputs_ok(self) -> bool: - """The site meter, and EVERY pack's own two readings. - - ⚠️ EVERY pack, not the live ones: a unit whose SoC did not answer is a - unit nothing can grade the maintenance FSM or the pack floor against, and - §11.1 rule 1 for a missing input is command zero and keep commanding. - Dropping it from the SPLIT instead would hand its share to a sibling - while the site went on regulating - see `Inverter.is_live()` on why - liveness is about the WRITE path and this is not. - """ + """The site meter, and EVERY pack's own two readings: every pack, not + the live ones. A unit whose SoC did not answer cannot be graded for + the maintenance FSM or the pack floor, and 11.1 rule 1 for a missing + input is command zero and keep commanding.""" if self.grid is None or not self.inverters: return False return all(inv.soc is not None and inv.batt is not None @@ -3462,49 +2677,21 @@ class Controller: # -- io ------------------------------------------------------------------ @property def pv_roster(self) -> tuple: - """Every configured PV production entity, or `()`. TEL-11/TEL-12. - - ⚠️ ONE READING OF THE OPTIONS, and it is `pv.entities()` - the SAME call - `mqtt.optional_keys()` makes. The thing that decides whether the row is - ANNOUNCED, the thing that decides whether it is READ and the thing that - decides WHAT IS SUMMED have to be one predicate, or the add-on ends up - with an entity nothing feeds, a topic nothing declared, or a total taken - over a different list from the announced one. `announced_units()` in - mqtt.py is the same argument for the inverter roster, in its own words. - - ⚠️ IT REPLACED A `pv_entity` PROPERTY RATHER THAN JOINING ONE. Two - properties where one is a subset of the other is the second spelling - arriving by the back door; every gate in this file reads this one. - """ + """Every configured PV production entity, or `()` (TEL-11/TEL-12). ONE + reading of the options, `pv.entities()`, the same call + `mqtt.optional_keys()` makes, so what is ANNOUNCED, READ and CHECKED + cannot disagree; it replaced a `pv_entity` property.""" return pv_read.entities(self.o) async def _read_pv(self) -> None: - """This cycle's SITE PV production, or an ABSENCE with a reason. - - TEL-11 for one entity, TEL-12 for a roster of them. `pv.total()` is where - "one of three is absent" becomes an absence of the SITE figure rather - than the sum of the two that answered, and its docstring is the argument. - - ⚠️ IT ASSIGNS ON EVERY CALL, INCLUDING THE FAILING ONES. Leaving the - last good number standing through an outage is what `circulation` does - on purpose - there, a stale figure beats a zero that reads as health - - and it is exactly wrong here: this row is read back as HISTORY, and a - value that persists through an outage is indistinguishable from a real - one at the moment somebody is trying to work out whether the sun was - shining. - - ⚠️ `hass.state()` RATHER THAN `hass.number()`, because the UNIT is half - the reading. See `pv.watts()`: a PV integration may publish kW, and - there is no magnitude heuristic that can separate 30 W of dusk from - 30 kW of noon. - """ - # ⚠️ TEL-12: EVERY ENTITY IN ONE GATHER, for the reason the outer - # gather exists (see `read_inputs()`). Read serially, a three-inverter - # roster with one slow sensor would add up to three of `hass.state()`'s - # 10 s timeouts to a cycle that feeds the ESP32 heartbeat - i.e. the - # telemetry channel would be able to push a healthy battery towards - # layer 1's veto, which is exactly the foot in the control path this - # card is not allowed to have. Gathered, N entities cost what one does. + """This cycle's SITE PV production, or an ABSENCE with a reason + (TEL-11/12). `pv.total()` makes "one of three absent" an absence of + the SITE figure. It assigns on EVERY call including failing ones (a + stale value must not survive a failure), and uses `hass.state()` not + `hass.number()` because the UNIT is half of the answer.""" + # TEL-12: every entity in ONE gather. Read serially, three slow sensors + # would add three 10 s timeouts to a cycle that feeds the ESP32 + # heartbeat; telemetry must have no foot in the control path. roster = self.pv_roster states = await asyncio.gather( *(self.hass.state(entity) for entity in roster)) @@ -3571,57 +2758,31 @@ class Controller: async def read_inputs(self, now_mono: float | None = None) -> None: """Every input this cycle needs, and SAFETY-37's freshness verdict. - - `now_mono` the monotonic clock the per-unit freshness records are - stamped on. Defaulted rather than read inside `_read_unit()` - so that ONE instant covers every unit in a gather - two - units stamped from two clock reads would carry a freshness - skew nobody could reproduce - and so that a check can drive - real seconds through the gate without sleeping through them. - """ + `now_mono` is defaulted rather than read inside `_read_unit()` so + ONE instant covers every unit in a gather.""" o = self.o now_mono = freshness.monotonic() if now_mono is None else float(now_mono) self._now_mono = now_mono if self.p1_enabled: - # ⚠️ P1 is the only authoritative measurement of what the utility - # sees (§5.1). When it is stale this is None, which falls into the - # existing "inputs missing -> command 0 W" path below. There is - # deliberately NO fallback to an inverter-side figure: the - # inverter's own AC power correlates 0.998 with battery power and - # 0.09 with the real meter, so a controller that failed over to it - # would be regulating against its own output. + # P1 is the only authoritative measurement of what the utility sees + # (5.1); when stale this is None -> "inputs missing -> command 0 + # W". NO fallback to an inverter-side figure: its AC power + # correlates 0.998 with battery power and 0.09 with the real meter + # (a controller would regulate against its own output). self.grid = self.p1.net_w else: self.grid = await self.hass.number(o.get("meter_entity", ""), bool(o.get("meter_invert"))) - # --- DEV-02: EACH INVERTER'S OWN TWO READINGS, FROM ITS OWN ENTITIES -- - # ⚠️ CONCURRENTLY ACROSS UNITS, SEQUENTIALLY WITHIN ONE. Read serially, - # a unit whose Home Assistant entity is slow delays every unit after it - # on the path that feeds the ESP32 heartbeat - the same defect AC 6 - # names for the WRITE path, one function up. Within a unit the order is - # unchanged (SoC, then battery power), so at the shipped roster of one - # this is the same two calls in the same order. - # - # --- TEL-11: AND THE PV READ RIDES IN THIS SAME GATHER --------------- - # ⚠️ IN THE GATHER RATHER THAN ON A LINE OF ITS OWN, AND THAT IS THE - # WHOLE "OBSERVABILITY MUST NOT BE ABLE TO DELAY CONTROL" DECISION. - # This coroutine feeds the ESP32 heartbeat; a sequential read would add - # its own worst case - `hass.state()`'s 10 s timeout - to every cycle, - # so a hung PV sensor at a client site would push a healthy battery - # towards layer 1's ~30 s veto for the sake of a telemetry row. - # Gathered, its latency OVERLAPS reads that already happen and the - # added worst case is zero. `peak_forecast_entity` below is read - # serially and stays that way because it is a CONTROL input (the - # capacity guard sizes off it), so the site is entitled to wait for it. - # - # ⚠️ AND AN EMPTY PV ROSTER APPENDS NOTHING. Not a coroutine, not an - # HTTP call, not a log line: at the shipped default this gather is the - # same list of the same coroutines it has always been. + # DEV-02: each inverter's own two readings from its own entities, + # CONCURRENTLY across units and sequentially within one (a slow entity + # must not delay the heartbeat path). The PV reads ride in the same + # gather and an empty PV roster appends nothing (no coroutine). + # DECISIONS/MAIN-orchestration.md. _reads = [self._read_unit(inv, now_mono) for inv in self.inverters] if self.pv_roster: _reads.append(self._read_pv()) - # OPT-11 P4: the solar forecast, in the same gather (a slow sensor - # must not delay control), at most once per FORECAST_READ_S. + # OPT-11 P4: the solar forecast, in the same gather (a slow sensor must + # not delay control), at most once per FORECAST_READ_S. if self.fc_entity is not None and ( self._fc_read_mono is None or now_mono - self._fc_read_mono >= FORECAST_READ_S @@ -3630,9 +2791,9 @@ class Controller: _reads.append(self._read_forecast()) await asyncio.gather(*_reads) if o.get("peak_forecast_entity"): - # SAFETY-118: read in its own unit, like the PV path. One state - # read, as `hass.number()` made. No unit or a unit that is not - # power: not used, as if the option were empty. + # SAFETY-118: read in its own unit like the PV path, one state + # read. No unit or a non-power unit: not used, as if the option + # were empty. st = await self.hass.state(o["peak_forecast_entity"]) self.peak_fc, _why = pv_read.watts(st) # A missing reading leaves the unit-fault state as it was. @@ -3652,44 +2813,25 @@ class Controller: elif uses_chain(o): # PRICE-01. A provider with fallbacks gives the maintenance gate # its own prices: the current 15-minute price against today's - # average, as the Home Assistant pair did. Still no I/O here - the - # series is the one the background refresh stored. + # average, as the Home Assistant pair did. + # (DECISIONS/MAIN-orchestration-archive.md) self._provider_gate(datetime.now(timezone.utc)) else: - # TARIFF-03. §9.2 makes a static day/night tariff a first-class - # product, and its required EMS handling is TIME-WINDOW SHIFTING. - # The add-on has exactly one shiftable load in 0.5.x - the - # maintenance cycle's cheap-window grid top-up, ~15 lines below in - # cycle() - so this is where the window reaches something that moves - # energy. With `static_profile` empty this returns False and the - # behaviour is bit-for-bit what it has always been: "fixed-tariff - # site, never force a paid grid top-up, wait for sun". - # - # ⚠️ SYNCHRONOUS AND I/O-FREE, ON PURPOSE. The provider-backed - # resolver (static_tariff.resolve_tariff) can perform an HTTP fetch, - # and this coroutine is on the path that feeds the ESP32 heartbeat - - # a supplier's TLS timeout here would be a supplier able to push the - # add-on towards layer 1's 30 s veto. Generation from a static - # profile is pure arithmetic, so §2.3's "regenerate on the tick" - # applies and there is nothing to block on. Putting a fetched series - # on this path needs a BACKGROUND refresh and belongs with OPT-04. - # - # ⚠️ AND IT CANNOT BE BOTH. validate.py refuses static_profile - # alongside the two Home Assistant entities, because this `if` picks - # one and the loser is silent. + # TARIFF-03: 9.2 makes a static day/night tariff a first-class + # product and its EMS handling TIME-WINDOW SHIFTING; the one + # shiftable load is the maintenance cycle's cheap-window grid + # top-up. Synchronous and I/O-free on purpose (the provider-backed + # plan is the background task's); validate.py refuses + # `static_profile` with the price entities, so the two cannot both + # be set. _now = datetime.now(timezone.utc) self.cheap, self.tariff_words = static_status(o, _now) self._price_gated = self._static_gated(o, _now) - # ⚠️ TARIFF-11. THE PROVIDER PLAN REACHES THE *Tariff* ROW HERE, AND - # THIS IS THE ONLY PLACE THE TWO SOURCES MEET. Everything above is the - # 30 s I/O-free tick; `fresh_tariff_plan()` is an attribute read and an - # age comparison against TARIFF_MAX_AGE_S - still no fetch on this path, - # which is the rule the whole branch above exists to keep. What the row - # gains is the plan's SHAPE (origin, product, horizon, the firm/carried - # split, the spacing) and what it must never gain is the suggestion - # that the two halves were computed at the same instant, so the plan - # half carries its own age in seconds. See `tariff_plan_row()`. + # TARIFF-11: the provider plan reaches the *Tariff* row here, the only + # place the two sources meet. `fresh_tariff_plan()` is an attribute + # read and an age comparison against TARIFF_MAX_AGE_S: still no fetch + # on this path. _tick = datetime.now(timezone.utc) _standing = self.fresh_tariff_plan(_tick) self.tariff_words = tariff_plan_row( @@ -3699,11 +2841,10 @@ class Controller: horizon_h=float(price_option(o, "price_horizon_h")), provider=provider_configured(o), max_age_s=TARIFF_MAX_AGE_S) - # UX-11: the red meter row says "the add-on log shows why", so a meter - # that goes quiet gets one WARNING per episode, not one per cycle. It - # is a log line only: no notification, and nothing it decides. - # A flapping meter logs at most one WARNING per METER_QUIET_REPEAT_S, - # and the episodes in between go to DEBUG. + # UX-11: the red meter row says "the add-on log shows why", so a quiet + # meter gets one WARNING per episode (at most one per + # METER_QUIET_REPEAT_S; the rest DEBUG). Log only: no notification, + # decides nothing. if self.p1_enabled: if (self.p1.stale and not self._meter_quiet_logged and self.p1.published_age_s > self.p1.max_age_s): @@ -3737,32 +2878,18 @@ class Controller: async def _read_unit(self, inv, now_mono: float) -> None: """One inverter's own SoC and battery power, from ITS OWN entities. - - ⚠️ SAFETY-37: THE POWER READING GOES THROUGH THE FRESHNESS GATE AND THE - SoC READING DOES NOT, AND THE ASYMMETRY IS DELIBERATE. `batt` feeds the - two consumers this card is about - SAFETY-30's per-unit sign check and - SAFETY-31's circulating-power sensor - and its obligation term is - command travel, which this method already has. SoC's obligation term is - ENERGY through the pack and the constant it needs has never been - measured on this site; wiring it here with a guessed constant would be a - gate that fires on a rested pack, which is TEL-08's whole hazard. - `freshness.BOUNDS["soc"]` states the bound and what TEL-08 owes. - - ⚠️ THE STANDING COMMAND IS ASKED OF THE UNIT, NOT RECOMPUTED. `cycle()` - grades the sign watchdog against `inv.standing_w()` and this gate arms - off the same method, because the gate exists to protect that check and a - second spelling of "what is standing" would let the two disagree about - the number they are both reasoning about. - """ + SAFETY-37: the POWER reading goes through the freshness gate and the + SoC does not, deliberately: `batt` feeds SAFETY-30's per-unit sign + check and the delivery check, which a frozen register would fool. + The standing command is asked of the unit, not recomputed.""" inv.soc = await self.hass.number(inv.soc_entity) raw = await self.hass.number(inv.batt_entity, inv.batt_invert) was_stale = inv.batt_fresh.stale was_releases = inv.batt_fresh.releases inv.batt_fresh.submit(raw, inv.standing_w(), now_mono) - # ⚠️ `inv.batt` IS THE GATED VALUE. A stale register is a MISSING input, - # not a measurement - `inputs_ok` sees the None, the site holds for - # `stale_input_s` and then commands 0 W and keeps commanding, which is - # §11.1 rule 1 and is already what a missing reading does here. + # `inv.batt` is the GATED value: a stale register is a MISSING input, + # so the site holds for `stale_input_s` then commands 0 W and keeps + # commanding (11.1 rule 1). inv.batt = inv.batt_fresh.reading() # SAFETY-97: learn this unit's idle reading, only while control runs # and nothing says the unit is refusing, stale or faulty. @@ -3776,37 +2903,12 @@ class Controller: self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason, hint=messages.hint("battery_stuck", unit=inv.name)) self._refusal_from_stale(inv, now_mono) - # ⚠️ SAFETY-57 REWROTE THE LAST SENTENCE OF THIS LINE AND NOTHING - # ELSE IN IT. The escape clause used to read "It clears itself the - # moment the register reports a different value", full stop - and - # that was FALSE for the state this same line announces, because the - # 0 W it announces in the sentence before is exactly what stops the - # register ever being asked to report anything different. The live - # site sat in it for 790 s on 2026-09-21 and was freed by a human. - # ⚠️ SAFETY-56 REWROTE THE DIAGNOSIS HALF, WHICH SAFETY-57 LEFT - # STANDING ON PURPOSE. It used to open "battery-power register is - # not following its own command" - an accusation against a named - # piece of hardware, and FALSE on the very episode above. The - # pack read 11 % for the whole hold, every command in it was a - # DISCHARGE, and the register answered the first CHARGE command - # three seconds later - so the BMS was refusing, the register was - # publishing that refusal correctly, and the operator was sent to - # look at a working sensor. All three numbers are measured off the - # committed capture by test_freshness.py part 11a. The gate's - # conjunction is right and is NOT touched; only its conclusion was. - # - # ⚠️ AND THE TWO EXPLANATIONS ARE NAMED ONCE, IN `%s`, NOT TWICE. - # This line's own words are the OBSERVATION plus the one thing a - # log reader has to be told before acting - that the observation is - # not yet a verdict - and `freshness._grade()`'s sentence, which - # this line already interpolates, is where the pair is spelled out. - # Saying it in both halves of one sentence is how an operator - # learns to skim it. - # UX-08 2d: plain, with SAFETY-57's escape clause kept as an - # ordered pair (time, then power), and the reason still in `%s`. - # UX-11: one sentence and the code. The reason, with SAFETY-56's - # two explanations and its figures, and the limits are on the - # DEBUG lines below and in the action log. + # SAFETY-56/57: the stale-reading line names two explanations once, + # in `%s`. The escape clause "clears itself when the register + # reports a different value" was false for the state this line + # announces (the 0 W it announces is what stops the value + # changing), so SAFETY-57 rewrote it and SAFETY-56 the diagnosis. + # DECISIONS/SAFETY-57-the-exit-from-a-stale-verdict.md. _LOG.error("%s: battery power reading stopped changing, set to " "0 W until it changes, or %.0f s after the command " "drops below %.0f W: %s.", @@ -3821,16 +2923,15 @@ class Controller: inv.batt_fresh.bound.move_w, inv.batt_fresh.bound.move_w) elif inv.batt_fresh.releases > was_releases: - # ⚠️ SAFETY-57. REPORTED, NEVER SILENT. A latch that lets go on a - # timer and says nothing is a latch no operator can audit - and this - # one is withdrawing a SITE STOP. `release_note()` is written to say - # that nothing has been learned about the register, because nothing - # has. + # SAFETY-57: reported, never silent: a latch that lets go on a + # timer and says nothing cannot be audited, and this one withdraws + # a SITE STOP. `release_note()` says nothing has been learned about + # the register. self.log_event(f"STALE WITHDRAWN {inv.name}", hint=messages.hint("battery_back", unit=inv.name)) # Not "recovered": nothing is known about the reading yet, so the - # line gives the value it still holds and the window it is - # rechecked over (SAFETY-57). + # line gives the value it holds and the window it is rechecked over + # (SAFETY-57). _LOG.warning("%s: the command stayed below %.0f W for %.0f s, so " "battery power reading %+.0f W is used again and " "rechecked over %.0f s.", inv.name, @@ -3841,30 +2942,17 @@ class Controller: @property def shadow(self) -> bool: - """SAFETY-10. Is the battery being left alone right now? - - ⚠️ A PROPERTY, NOT A FIELD, AND FOR THE REASON `self.units` IS ONE. - Three surfaces ask this question at three different moments - the - cycle, the write, and the status page rendered between two cycles - and - a cached flag is a fourth answer that can disagree with the option and - the button it is derived from. Derived, they cannot. - - ⚠️ AND THE WRITE GATE ASKS THIS, NOT A PARAMETER PASSED DOWN TO IT. A - suppression that had to be threaded through `write_setpoint()` would be - absent from every call site that forgot it - the heartbeat, the stop - button, `shutdown()` - which is exactly the shape of hole this add-on's - single choke point exists to close. - """ + """SAFETY-10. Is the battery being left alone right now? A property, + not a field (as `self.units`): the cycle, the write and the status + page ask at three moments, and the write gate asks THIS, not a + parameter passed down.""" return shadow.engaged(self.o, control_started=self.auto) def shadow_latches(self) -> tuple: - """The failsafe latches that are UP, named, for the shadow banner. - - A latch that is standing while the controller is inert is standing when - it stops being inert. `shadow.banner()` carries them so an operator who - turns shadow mode off is not handed a battery that will not move and a - page that never mentioned why. - """ + """The failsafe latches that are UP, named, for the shadow banner: a + standing latch is standing when the controller stops being inert, so + an operator who turns shadow off is not handed a battery that will + not move with no reason shown.""" out = [] if self.circ_wd.tripped: out.append("circulating power") @@ -3876,67 +2964,26 @@ class Controller: return tuple(out) def write_refusal(self, value: float, inv=None) -> str | None: - """Why `value` may NOT be written to `inv` right now, or None if it may. + """Why `value` may NOT be written to `inv` right now, or None if it + may. - --- SAFETY-28: THE WRITE PATH IS GATED ON THE ALLOCATION RECORD -------- - specs §1.5 makes "any configuration in which two writers can reach one - actuator" a non-goal. Until this method that non-goal was enforced by - `len(validate.ACTUATOR_WRITE_KEYS) == 1` - there is one option key, so a - second writer is not expressible. True, and a statement about the SHAPE - OF THE OPTIONS SCHEMA rather than about the writes. Add CFG-04's - per-inverter config and there are two real writers with that length - still 1: the check stays green while the property it protects is gone. - That is this repository's named failure mode - a check that observes - something other than what it claims to observe - so the enforcement - moved here, onto the writes themselves. - - The rule is: A NUMBER REACHES THE WIRE ONLY IF THE SINGLE ALLOCATOR - PLACED IT, ON THIS TICK, FOR THIS UNIT. A second writer with arithmetic - of its own cannot satisfy that - its number is not in any record - so it - has to file a Claim and go through `resolve()`, which is the one - allocator. "Exactly one allocator per shared resource" stops being a - convention somebody can forget and becomes a thing the write path - checks. `Allocation.authorises()` holds the per-record half of the - argument. - - ⚠️ 0 W IS ALWAYS AUTHORISED, AND THAT IS THE ANSWER TO "WHAT IF THE - ALLOCATOR IS THE THING THAT FAILED". This is not a convenience exit; it - is the one value whose correctness does not depend on a split existing: - - * The hazard the non-goal names is N-times loop gain from BROADCAST - - handing every inverter the same target so each supplies it in full - (see allocator.py's opening argument, and TEST-19's measured - `N=2 gain 1.0` limit cycle). At S = 0 the broadcast and the correct - allocation COINCIDE: a_i = 1 for every unit gives every unit 0 W, - and 0 W is exactly its share. There is no over-command to make. - * It is the fail-toward-inaction direction §11.1 rule 1 already - names, and it is what layer 2 commands. `cycle()`'s two refusal - branches replace the resolution with a 0 W failsafe and then write - it through here like any other cycle; `shutdown()` and the - operator's stop button do the same. Every one of those runs while - the allocator has REFUSED - `Allocation.ok` is False and there are - no shares - so a gate that asked the allocator's permission to - command zero would deadlock the failsafe behind the very component - whose failure the failsafe exists to answer. - - A gate that can block the safety write is worse than the hole it closes, - so it cannot block it: the test for zero is first and reads nothing. - - ⚠️ AND IT IS `value == 0.0`, THE REQUESTED NUMBER - not a `failsafe=True` - argument. A flag is an assertion by the caller, which is precisely what - a second writer would pass; the value is a fact about what is going to - be written. -0.0 == 0.0 in Python, and a nan never reaches here (the - caller has already turned it into 0.0), so the predicate is total. - - ⚠️ DEV-02: THE UNIT IS AN ARGUMENT, NOT THE CONTROLLER'S N=1 VIEW OF - IT. The record holds one share PER UNIT, so "did the allocator place - this number" is a question about a NAMED actuator. Asking it with the - first inverter's name at a roster of two would authorise A's share on - B's wire and refuse B's own - the site/unit confusion this card - removes, wearing the gate's name. `_write_unit()` always passes the - unit it is about; the default is for the N=1 callers and REFUSES at - N>=2 (see `_only()`) rather than answering for the first inverter. - """ + SAFETY-28: a number reaches the wire only if THE SINGLE ALLOCATOR + placed it, on this tick, for THIS unit (`Allocation.authorises()`). + specs 1.5 makes two writers on one actuator a non-goal; + `len(validate.ACTUATOR_WRITE_KEYS) == 1` is a statement about the + options schema, not the writes. A second writer must file a Claim and + go through `resolve()`. + 0 W is ALWAYS authorised and the test for zero is first and reads + nothing: the failsafe writes run while the allocator has REFUSED, so a + gate asking its permission to command zero would deadlock the failsafe + behind the component whose failure it answers; and at S = 0 broadcast + and allocation coincide (no over-command). It tests `value == 0.0`, not + a `failsafe=True` flag (a flag is an assertion by the caller, which is + what a second writer would pass). + DEV-02: the unit is an ARGUMENT: the record holds one share per unit, + and the first inverter's name at a roster of two would authorise A's + share on B's wire. The default is for N=1 callers and REFUSES at N>=2 + (`_only()`). DECISIONS/MAIN-orchestration.md.""" if value == 0.0: return None inv = self._only() if inv is None else inv @@ -3948,26 +2995,15 @@ class Controller: async def write_setpoint(self, value: float, *, force: bool = False, unit=None) -> None: - """Put `value` on ONE inverter, or - with `unit=None` - on all of them. - - ⚠️ `unit=None` IS FOR A NUMBER THAT IS NOT A SHARE. The stop button, - `shutdown()` and `maint_abort()` all command 0 W to the whole site, and - 0 W is the one value that means the same thing to every inverter. A - NON-ZERO value broadcast to a roster of two IS the ARCH-01 fault - each - unit supplies it in full and the loop gain at the meter becomes N times - the per-unit gain - so it is refused below rather than trusted to the - caller. The cycle writes SHARES, one unit at a time, through the - allocator. - - ⚠️ AND THE WRITES ARE CONCURRENT, EACH UNDER ITS OWN BUDGET. See - WRITE_BUDGET_S: one hung `hass.call()` on a serialised path used to eat - a sibling's layer-1 refresh window. - """ + """Put `value` on ONE inverter, or with `unit=None` on all. `unit=None` + is for a number that is not a share (stop button, `shutdown()`, + `maint_abort()`: 0 W means the same to every inverter). Writes are + concurrent, each under its own budget (DEV-02).""" if unit is None: targets = [i for i in self.inverters] if len(targets) > 1 and is_commandable(value) and float(value) != 0.0: - # Loud, and it commands zero rather than the value asked for - - # fail toward inaction, exactly as every other refusal here. + # Loud, and it commands zero rather than the value asked for: + # fail toward inaction, as every other refusal here. self.log_event( f"refusing to broadcast {float(value):+.0f} W to " f"{len(targets)} inverters - that is the ARCH-01 fault; " @@ -3994,19 +3030,10 @@ class Controller: if not entity: return - # --- SAFETY-03: THE CLAMP, AND IT IS ON THE ONLY PATH TO THE WIRE ----- - # Every write in this add-on comes through here - the cycle, the - # heartbeat, the operator's stop button and shutdown() - so no optimizer, - # maintenance or manual path can route around it. That placement is the - # first acceptance criterion, and it is why the guard is not in - # control.py: a guard the control law owns is a guard every other claimer - # bypasses. - # - # ⚠️ A NON-FINITE VALUE IS LOSS OF INPUT, NOT AN OUT-OF-RANGE ONE. §11.1 - # rule 1: command zero and keep commanding. It must not reach the clamp - # arithmetic - see is_commandable() for what a nan does to it - and it - # must not be counted as a clamp engagement, or a transport bug lands in - # the winding-loop counter. + # SAFETY-03: the clamp is on the ONLY path to the wire (cycle, + # heartbeat, stop button, shutdown(); no optimizer, maintenance or + # manual path routes around it). A non-finite value is loss of input, + # not an out-of-range one (11.1): coerced to 0 W. if not is_commandable(value): self.log_event(f"non-finite setpoint {value!r} - commanding 0 W", hint=messages.hint("bad_value")) @@ -4015,35 +3042,10 @@ class Controller: "not a limit.", value) value = 0.0 - # --- SAFETY-28: AND IT IS REFUSED HERE, NOT LOGGED SOMEWHERE ---------- - # ⚠️ ABOVE THE CLAMP AND BELOW THE NON-FINITE COERCION, and both halves - # of that placement are decisions. - # - # Below the coercion, because a nan is loss of input rather than an - # unauthorised write: rule 1 has already turned it into 0 W, which this - # gate then authorises unconditionally, so a transport bug does not get - # a second, differently-worded refusal. - # - # Above the clamp, because the question is WHO CHOSE THIS NUMBER and the - # answer is only meaningful about the number that was asked for. The - # clamp then narrows what was authorised, always toward safety, and it - # stays the last thing between a bug and the hardware. Gating the - # CLAMPED value would be worse than useless: on every cycle the clamp - # engages, the emitted watts differ from the allocator's share by - # construction, so the gate would refuse exactly the cycles SAFETY-03 - # exists for. - # - # The refusal is a SUBSTITUTION, not a return: 0 W goes on the wire in - # place of the number nothing stands behind. Returning instead would - # leave the inverter latched on its last command with the ESP32's - # heartbeat still being fed - holding an unauthorised setpoint quietly, - # which is the outcome this gate exists to prevent. - # - # ⚠️ AND IT IS ASKED ABOUT `inv`, NOT ABOUT THE SITE. The record holds - # one share per unit, so the question "did the single allocator place - # this number" is only answerable about a named actuator; DEV-02 made - # this method per-unit, and passing the first inverter here would - # authorise A's share on B's wire. + # SAFETY-28: the refusal is HERE, above the clamp and below the + # non-finite coercion (a nan is loss of input, not an unauthorised + # number), and asked about `inv`, not the site. + # DECISIONS/MAIN-orchestration.md. refusal = self.write_refusal(value, inv) if refusal is not None: self.log_event( @@ -4054,52 +3056,29 @@ class Controller: value, inv.name, refusal) value = 0.0 - # ⚠️ THIS UNIT'S OWN THREE NUMBERS. `inv.max_w` is the supervised cap on - # what may be written to THIS setpoint entity and `inv.dev_min/dev_max` - # are what THIS device reported about itself - which is the whole of "the - # clamp for B uses B's numbers". Read off `self.o` these were one - # inverter's rating applied to every inverter. + # This unit's own three numbers: `inv.max_w` (supervised cap on THIS + # setpoint entity) and `inv.dev_min/dev_max` (what THIS device + # reported). Read off `self.o` they were one inverter's rating applied + # to every inverter. result = clamp_to_rating(value, inv.max_w, inv.dev_min, inv.dev_max) inv.clamp_last = result if result.engaged: - # ⚠️ EVERY engagement, never deduplicated: this is the action log - # (§15), and it is a different surface from the notification, which - # IS deduplicated. The count is what says how hard the loop pushed; - # the notification only says that it did. + # EVERY engagement is logged, never deduplicated: the action log + # (15) is a different surface from the deduplicated notification. + # The count says how hard the loop pushed. self.log_event(f"{inv.name}: {result.describe()}", hint=messages.hint("write_cut", unit=inv.name)) value = result.applied_w - # --- SAFETY-10: AND THIS IS THE LAST THING BEFORE THE WIRE ----------- - # Shadow mode runs the whole decision path and commands nothing. The - # suppression is HERE, in the one method every write in this add-on - # reaches - the cycle, the heartbeat, the stop button, shutdown() and - # maint_abort() alike - so there is no call site that can route round - # it. app/shadow.py carries the whole argument; three things about the - # placement belong here: - # - # ⚠️ IT IS A SUBSTITUTION, NOT A RETURN, exactly like the two refusals - # above it. Returning would leave the inverter LATCHED ON ITS LAST - # COMMAND with the ESP32's heartbeat still being fed - a battery held - # at whatever it was doing when shadow mode was switched on, under a - # status page saying nothing is being commanded. That is the failure - # this add-on keeps finding, and shadow mode must not be a new way to - # reach it. `shadow.WIRE_W` is 0.0 W and it is REFRESHED on the - # ordinary heartbeat, which is also what firmware/goodwe-master.yaml's - # 30 s stale-input watchdog requires of us. - # - # ⚠️ IT IS BELOW SAFETY-28's GATE AND BELOW SAFETY-03's CLAMP, so both - # still grade the number the decision path actually produced. A shadow - # run that could not say "it would have hit the rating here" would be - # answering a different question from the one it is for. - # - # ⚠️ IT IS NOT A SECOND WRITER. No claim is filed, no transport is - # opened and no arithmetic of its own reaches the wire: one actuator, - # one writer, and this is that writer emitting the value it emits for - # every other refusal. + # SAFETY-10: shadow mode runs the whole decision path and commands + # nothing, and the suppression is HERE, the last thing before the wire, + # in the one method every write reaches (so no call site routes round + # it). A substitution, not a return, like the two refusals above; below + # SAFETY-28's gate and SAFETY-03's clamp; not a second writer (no + # claim, no transport call). if self.shadow and value != shadow.WIRE_W: - # UX-10: DEBUG, because it repeats every cycle in shadow mode. The - # INFO line when shadow mode comes on says the same once. + # UX-10: DEBUG, because it repeats every cycle in shadow mode; the + # INFO line when shadow comes on says it once. _LOG.debug("Shadow mode: would send %+.0f W to %s, and holds " "%+.0f W. Nothing was sent to the inverter.", value, inv.name, shadow.WIRE_W) @@ -4108,12 +3087,10 @@ class Controller: changed = (inv.last_write is None or value != inv.last_write) if not (changed or force): return - # ⚠️ BOUNDED, AND THE BUDGET IS AC 6. Without it one inverter's hung - # transport holds this coroutine - and therefore the whole control loop, - # and therefore every OTHER inverter's heartbeat - for as long as the - # HTTP stack allows. A write abandoned here is re-issued on the next - # tick by itself: `last_write` is updated only on an ACCEPTED write, so - # `changed` is still true. + # BOUNDED, and the budget is AC 6: one hung transport would hold the + # coroutine, the whole loop and every OTHER inverter's heartbeat. An + # abandoned write is re-issued next tick (`last_write` moves only on an + # ACCEPTED write). try: ok = await asyncio.wait_for(self.hass.set_number(entity, value), timeout=WRITE_BUDGET_S) @@ -4133,60 +3110,20 @@ class Controller: # -- the cycle ----------------------------------------------------------- def actuator_of(self, source: str, inv=None) -> str: - """Which actuator a claim from `source` is filed on this cycle. - - `inv` is the roster entry the claim is ABOUT, for a per-inverter source. - Left out it is the first inverter, which is what every per-inverter claim - meant while there was only one. ⚠️ A per-inverter claim built inside the - per-unit loop MUST pass it, or inverter B's rating is filed on inverter A - and the arbiter bounds the wrong share - the whole defect, wearing this - method's name. - - ⚠️ THE CLASSIFICATION IS NOT HERE. `arbiter.CLAIM_SCOPE` decides whether - a source is about the grid connection or about one pack, and it carries - the reasoning for all eight; this method only supplies the roster, which - is the one thing the arbiter cannot know. Routing a claim by hand - - writing `actuator=` at a construction site - bypasses the KeyError that - stops a ninth, unclassified source being filed, so do not. - """ + """Which actuator a claim from `source` is filed on this cycle. `inv` + is the roster entry the claim is ABOUT; left out it is the first + inverter (what every per-inverter claim meant at N=1). The + classification is not here: `arbiter.CLAIM_SCOPE` decides.""" return actuator_for(source, (inv or self.inverters[0]).name, self.units) def live_units(self) -> tuple: - """Which inverters may be commanded THIS TICK, for the allocator. - - ⚠️ THE ALLOCATOR DROPS WHAT THIS DOES NOT LIST, and the survivors then - carry the whole site target. That is a real change in what the site can - deliver, so it is reported rather than absorbed - `Allocation.dropped` - carries it and the cycle logs the transition below. A wrong answer here - is a site target divided among fewer inverters than exist, which is why - it is a method with a reason and not a field somebody can set. - - ⚠️ AND IT IS ANSWERED PER TICK, NEVER CACHED. A master goes stale - between one cycle and the next; an envelope read once at construction - over-allocates into a unit that cannot take it. - - ⚠️ AND IT ANSWERS ONE QUESTION: CAN THIS CONTROLLER PUT A NUMBER ON - THIS INVERTER RIGHT NOW. `Inverter.is_live()` carries the two ways it - cannot - no `setpoint_entity`, so there is no writer at all; or every - write REJECTED for a whole `LINK_STALE_S` window, which is layer 1's own - veto period, i.e. the point at which the firmware is about to take the - inverter away from this controller anyway. - - ⚠️ IT IS DELIBERATELY NOT "ITS SENSORS ARE MISSING". A pack whose SoC did - not answer is a different fault with a different remedy - `inputs_ok` - already stops the whole SITE at P_SAFETY for that - and dropping the unit - from the SPLIT instead would hand its share to a sibling while the site - went on regulating, which is the opposite of what a missing measurement - should cause. - - ⚠️ AT A ROSTER OF ONE, ANSWERING FALSE IS A 0 W SITE. The allocator has - nobody left in the envelope set, refuses (REFUSE_EMPTY), and main.py - substitutes the failsafe. That is the honest answer and not a - regression: the only way to reach it is a setpoint entity that has been - REJECTING every write for 30 s, on which the previous code went on - computing setpoints for an inverter it could not reach while layer 1 was - already winding it down. It says so in the action log instead. - """ + """Which inverters may be commanded THIS TICK, for the allocator. The + allocator DROPS what this does not list and the survivors carry the + whole site target, so it is reported (`Allocation.dropped`). + Answered per tick, never cached (a master goes stale). It answers + ONE question: can this controller put a number on this unit's wire. + Deliberately not "its sensors are missing". At a roster of ONE, + False is a 0 W site.""" now = datetime.now(timezone.utc) live = [] for inv in self.inverters: @@ -4195,22 +3132,16 @@ class Controller: live.append(inv.name) else: # Level-logged at DEBUG only: `Allocation.dropped` is the - # operator-facing surface and cycle() edge-logs it. A line here - # every second would flush the 40-row action log of the context - # that explains it. + # operator-facing surface (edge-logged in cycle()); a line + # every second would flush the 40-row action log. _LOG.debug("%s is not commandable this tick: %s", inv.name, why) return tuple(live) def liveness_notes(self) -> list: """Why each unit is or is not commandable, for the status page. - - ⚠️ SAFETY-37's STALE-READING LINE RIDES HERE TOO, AND IT IS A DIFFERENT - FAULT WITH A DIFFERENT REMEDY. A unit that is not commandable is dropped - and its share is carried by the survivors; a unit whose power register - has stopped following its command stops the SITE. They share this list - because they share one place on the status page - the operator asking - "why is nothing happening" needs both - and nothing downstream reads it. - """ + SAFETY-37's stale-reading line rides here too: a different fault, a + different remedy (a dropped unit is carried by the survivors; a + stale power register is a missing input).""" now = datetime.now(timezone.utc) return [n for n in [x for inv in self.inverters @@ -4220,12 +3151,7 @@ class Controller: def _provider_gate(self, now: datetime) -> None: """PRICE-01: `cheap`, the gate and the gap from the standing plan. - - The gate exists whenever the provider does: no price for now is a - closed gate, so the charge waits and does not run unpriced. The gap - flag stays False until the first refresh has finished, so a start-up - is not a gap. - """ + (DECISIONS/MAIN-orchestration-archive.md)""" plan = self.fresh_tariff_plan(now) published = None if plan is None else plan.published if published is not None: @@ -4262,12 +3188,7 @@ class Controller: def _charge_gate_open(self, inv) -> bool: """MAINT-13: may this pack charge now, for the maintenance timeout? - - True when no price gate is set up (no price entities, no fixed tariff: - nothing holds the charge back), in a cheap window, or while the pack is - really taking charge (sun outside cheap hours). Closed only when a gate - exists, it is shut, and the pack is not charging. - """ + (DECISIONS/MAIN-orchestration-archive.md)""" if not self._price_gated or self.cheap: return True batt = inv.batt @@ -4275,36 +3196,17 @@ class Controller: async def cycle(self) -> None: """Gather claims, resolve precedence, write the winner. - - Nothing here decides who wins - arbiter.py does, by one rule. This - method's only job is to state honestly what each strategy wants. - """ + (DECISIONS/MAIN-orchestration-archive.md)""" now = datetime.now(timezone.utc) - # ⚠️ BEFORE THE FIRST `return` OF THIS METHOD, ON PURPOSE (TARIFF-11). - # It stamps that a cycle RAN, which is a different fact from any of the - # decisions the cycle may or may not reach, and the status row needs - # the two separately. + # BEFORE the first `return` of this method on purpose (TARIFF-11): it + # stamps that a cycle RAN, a different fact from the decisions it may + # reach. self.cycle_at = now - # ⚠️ EACH PACK ON ITS OWN READING, NOT "the SoC". §12's recalibration - # drains and refills ONE pack and grades ONE coulomb counter, so each - # state machine is ticked with the SoC of the inverter it belongs to. A - # minimum across packs would abort a cycle on a sibling's state, and an - # average would declare a pack full that is not. The pairing is - # positional and both lists are built from the roster in `__init__`, so - # a pack cannot be graded on its neighbour's meter. - # - # ⚠️ AND `arbitrate()` RUNS FIRST, ONCE, OVER ALL OF THEM. It is the - # site's one maintenance slot - see maintenance.py - and a pack that - # does not hold it is told so by name rather than left to discover it. - # Calling `tick()` without the answer this produced is the - # two-packs-draining-at-once defect with the interlock present but not - # wired, which is why the plan is built here and not inside the loop. - # OPT-11: a battery switched off starts no cycle and is left out of the - # slot, so it cannot hold the slot from a battery that is on. One that - # is mid-cycle is stopped, with a log line (the owner's 2026-09-27 - # answer). The switch itself stops it at once (`pack_use`); this is - # the same rule for a store that says Off at start-up. + # Each pack on ITS OWN reading, not "the SoC": 12's recalibration + # drains and refills ONE pack and grades ONE coulomb counter; a minimum + # would abort on a sibling's state and an average would declare a pack + # full that is not. `arbitrate()` runs first, once, over all of them. _off = ({inv.name for inv in self.inverters if not self.pack_mode(inv).use} if self.modes_on else set()) for inv, machine in zip(self.inverters, self.maints): @@ -4340,95 +3242,41 @@ class Controller: res.failed, FAIL_BACKOFF_DAYS, unit=inv.name if multi else "", slug=slug).data()) maint_results.append(res) - # ⚠️ CARRIED TO THE STATUS PAGE, NOT LEFT IN THE 40-LINE ACTION LOG. The - # deferral is edge-logged (it lasts as long as the other pack's cycle), - # so by the time an operator asks "why has pack B not recalibrated" the - # line that said so has scrolled off. §12's whole failure mode is a - # schedule that silently stops firing; a pack that is due, idle and - # WAITING has to be able to say so at the moment it is looked at. + # Carried to the status page, not left in the 40-line action log: the + # deferral is edge-logged, and a pack that is due, idle and WAITING + # must be able to say so when looked at (12's failure mode is a + # schedule that silently stops firing). self._maint_deferred = [(m.name, r.deferred) for m, r in zip(self.maints, maint_results) if r.deferred] - # --- SAFETY-02, and it runs BEFORE the early return below ------------- - # ⚠️ ORDER MATTERS HERE. If this were evaluated after the "inputs missing - # but not yet stale" hold, a run of held cycles would leave - # `disagree_since` untouched across the gap and the timer would resume - # from a start time that predates it. It is evaluated every cycle, - # unconditionally, so a missing meter CLEARS the window - see watchdog.py. - # - # The value compared is the command STANDING ON THE WIRE, which is what - # this cycle's fresh meter reading is a response to. `last_write` is that - # value after the device clamp and confirmed accepted; `target` covers - # the case where nothing has been written yet (and, deliberately, the - # case where the last write was REJECTED - the inverter is then still - # latched on `last_write`, which is exactly what `last_write` holds). - # ⚠️ ONE WATCHDOG PER INVERTER, EVALUATED ON THAT INVERTER'S OWN - # STANDING COMMAND. What the watchdog concludes is "THE UNIT I AM - # DRIVING is pushing power the wrong way", and that evidence cannot be - # pinned on a sibling this controller commanded differently. - # `inv.target` is that unit's share from the last resolution, which at - # N=1 is the site target itself. - # - # ⚠️ SAFETY-30: AND WHAT IT IS COMPARED AGAINST IS DECIDED BY THE ROSTER. - # This block used to hand every unit's detector `self.grid`. There is - # ONE P1, so at N>=2 that compares a number this controller put on ONE - # actuator against a measurement that answers for ALL of them, and it is - # wrong in both directions: - # - # FALSE TRIP inverter A commanded -1500 W while the meter reads - # +1500 W is evidence of nothing when inverter B is - # discharging 3000 W exactly as instructed. Nothing is - # faulty and the highest-value check in the safety table - # fires - on a record of zero false positives. - # MISSED the converse, and it is worse. A unit commanded +1500 W - # and MEASURED at -1500 W, beside a sibling at +1500 W, - # leaves P1 exactly where a healthy site leaves it. The - # sum destroys the evidence; no threshold recovers it. - # - # So at N>=2 each unit is graded against ITS OWN measured power - - # `inv.batt`, register 0x0518, per-instance since DEV-02 - which is the - # only instrument that can answer "is THIS actuator doing what I told - # it". At N=1 §6.2's identity holds, the meter IS the view of that one - # unit, and the comparison stays the meter, byte for byte. The site's - # own question has not gone away; it is asked below, once, of the sum. - # `_arm_sign_watchdogs()` states the choice and its docstring argues it. + # SAFETY-02, and it runs BEFORE the early return below: evaluated every + # cycle, else a run of held cycles leaves `disagree_since` untouched + # and the timer resumes from a start time that predates the gap. One + # watchdog per inverter on that inverter's own reading; what it is + # compared against is decided by the roster (SAFETY-30). self._arm_sign_watchdogs() n_units = len(self.inverters) standing = {} for inv in self.inverters: was_tripped = inv.sign_wd.tripped - # ⚠️ `Inverter.standing_w()`, NOT THE EXPRESSION IT REPLACES. - # SAFETY-37's freshness gate arms off the same method, and this used - # to be the only spelling of it. See that method's docstring. + # `Inverter.standing_w()`, not the expression it replaces: + # SAFETY-37's freshness gate arms off the same method. stood = inv.standing_w() standing[inv.name] = stood against = self.grid if n_units <= 1 else inv.batt if inv.sign_wd.evaluate(stood, against, self.following, now) \ and not was_tripped: - # ⚠️ REASON FIRST, FLAG SECOND, and each `set` writes the file. - # The ordering is the whole atomicity story: a crash between the - # two persists a reason nothing reads, which is harmless, where - # the other order persists a latch with no explanation for the - # operator who has to decide whether to clear it. - # - # ⚠️ AND BOTH KEYS ARE NAMESPACED TO THIS UNIT. Flat, a trip - # caused by B latched A across every restart until somebody - # pressed start, and one press cleared both. See store.py. + # REASON first, FLAG second (each `set` writes the file): a + # crash between the two persists a reason nothing reads, not a + # latch with no explanation. Both keys are namespaced to this + # unit. inv.store_set("sign_reason", inv.sign_wd.reason) inv.store_set("sign_tripped", True) - # ⚠️ AND THE LEGACY FLAT KEYS ARE MIRRORED, WHICH IS THE OTHER - # HALF OF store.py's RULE 2. That rule keeps the flat keys "so a - # rollback still finds its latch", and it only ever covered the - # latch that existed AT MIGRATION TIME: a trip that happens - # AFTER the migration wrote nothing flat, so an operator who - # downgraded would find `sign_tripped: false` and come up - # DRIVING, into the fault. store.py used to claim the staleness - # could only fail toward a spurious latch; it could not, and - # this is what makes that sentence true. The pair is - # site-level on purpose - the version being rolled back TO - # drives exactly one actuator - and it is cleared by the same - # operator press that clears the namespaced ones. + # The legacy flat keys are MIRRORED, the other half of store.py + # rule 2: that rule only covered the latch existing at + # migration time, so a trip after it wrote nothing flat and a + # rollback would find no latch. self.store.set("sign_reason", inv.sign_wd.reason) self.store.set("sign_tripped", True) self.log_event(f"FAILSAFE {inv.name}: " + inv.sign_wd.reason, @@ -4438,47 +3286,24 @@ class Controller: inv.name, messages.reason_code(inv.sign_wd.reason)) _LOG.debug("Evidence for %s: %s.", inv.name, inv.sign_wd.reason) - # ⚠️ THE SITE'S OWN STANDING COMMAND, WHICH IS THE SUM OF THE UNITS'. - # SAFETY-07 and SAFETY-18 both grade a SITE quantity - the quarter-hour - # average at the grid connection, and a meter that has stopped moving - # while the site pushes near its rail - so what they need is the total - # this controller currently has standing on the wire, not one unit's - # share of it. At the shipped roster of one the two are the same number. + # The site's own standing command is the SUM of the units': SAFETY-07 + # and SAFETY-18 grade a SITE quantity (the quarter average at the grid + # connection; a meter not moving while the site pushes near its rail). + # Same number at a roster of one. site_standing = float(sum(standing.values())) - # --- SAFETY-30: THE SITE HALF OF THE SIGN CHECK ---------------------- - # The comparison SAFETY-02 shipped, asked of the quantity it was always - # about: the TOTAL this controller has standing on the wire, against the - # meter that total is answerable for. What it catches is a fault no - # per-unit check can see, because every unit is faithfully executing its - # share of a site target that is itself pointing the wrong way - an - # inverted gain, an integrator wound past the rail, a strategy on the - # wrong side of the meter. That is the 2026-08-20 fault day, and it is - # the reason the site question survives rather than being replaced. - # - # ⚠️ ONLY AT N>=2, AND THIS BRANCH IS AC 8. At one inverter the sum of - # the commands IS that inverter's command and the loop above has already - # made exactly this comparison, at exactly these thresholds, on the - # detector whose latch, store keys, claim and banner an install has had - # since SAFETY-02 shipped. Arming this as well would be two latches for - # one fault, two banners, and the same false-positive record spent - # twice. A shipped install does not reach this block at all. + # SAFETY-30: the site half of the sign check: the TOTAL standing on the + # wire against the meter, catching a fault no per-unit check sees + # (every unit faithfully executing the wrong total). Only at N>=2 (at + # one inverter it would duplicate `inverters[0].sign_wd`; AC 8). Flat + # keys, not namespaced. if n_units > 1: was_site = self.site_sign_wd.tripped if self.site_sign_wd.evaluate(site_standing, self.grid, self.following, now) and not was_site: - # REASON FIRST, FLAG SECOND - the same atomicity ordering the - # per-unit trip uses above and SAFETY-31 uses below: a crash - # between the two must not persist a latch with no explanation - # for the operator who has to decide about clearing it. - # - # ⚠️ AND THE KEYS ARE FLAT, NOT NAMESPACED, AND NOT THE - # PRE-DEV-02 PAIR EITHER. `sign_tripped` / `sign_reason` are the - # ROLLBACK VIEW of a UNIT latch (store.py rule 2) and mirroring - # a site trip onto them would hand a downgraded add-on - which - # drives exactly one actuator - a latch taken on evidence about - # a roster it cannot have. `site_sign_*` is its own pair, for - # the same reason `circ_*` is. + # REASON first, FLAG second, as the per-unit trip and + # SAFETY-31. The keys are flat, not namespaced, and not the + # per-unit pair (store.py). self.store.set("site_sign_reason", self.site_sign_wd.reason) self.store.set("site_sign_tripped", True) self.log_event("FAILSAFE site: " + self.site_sign_wd.reason, @@ -4490,29 +3315,16 @@ class Controller: _LOG.debug("Evidence for the site: %s.", self.site_sign_wd.reason) - # --- SAFETY-31, AND IT RUNS BESIDE SAFETY-02 FOR THE SAME REASON ----- - # Evaluated EVERY cycle, unconditionally, above the "inputs missing but - # not yet stale" hold below. A run of held cycles would otherwise leave - # `over_since` untouched across the gap and the window would resume from - # a start time that predates it, which is the ordering bug the sign - # watchdog's own note describes. - # - # ⚠️ THE INPUT IS MEASURED POWER, WHICH IS THE ENTIRE POINT OF THE CARD. - # `site_circulating_w` reads each `Inverter.batt` - that unit's own - # meter, holding register 0x0518. It is NOT `self.allocation`, and the - # property's docstring carries the argument: ALLOC-01's sign-coherence - # gate already makes the allocation record incapable of expressing - # circulating power, so a record-derived sensor is zero by construction - # and detects nothing. What this catches is circulation the allocator - # NEVER COMMANDED - a unit ignoring or lagging its share, a stale share - # still being executed, or HW-01's wedged power stage. + # SAFETY-31, beside SAFETY-02 for the same reason: evaluated EVERY + # cycle, unconditionally, above the input hold (else `over_since` + # resumes from a start that predates the gap). The input is MEASURED + # power, never commanded: a unit ignoring or lagging its share, a stale + # share still executing. circ_w = self.site_circulating_w was_circ = self.circ_wd.tripped if self.circ_wd.evaluate(circ_w, now) and not was_circ: - # REASON FIRST, FLAG SECOND, and each `set` writes the file - the - # same atomicity ordering SAFETY-02 uses two blocks up, and for the - # same reason: a crash between the two must not persist a latch with - # no explanation for the operator who has to decide about it. + # REASON first, FLAG second (each `set` writes the file): a crash + # between must not persist a latch with no explanation. self.store.set("circ_reason", self.circ_wd.reason) self.store.set("circ_tripped", True) self._circ_announced = False @@ -4524,62 +3336,17 @@ class Controller: messages.reason_code(self.circ_wd.reason)) _LOG.debug("Evidence for the site: %s.", self.circ_wd.reason) - # --- SAFETY-42, AND IT RUNS HERE FOR SAFETY-02's AND SAFETY-31's REASON - # Evaluated EVERY cycle, unconditionally, ABOVE the "inputs missing but - # not yet stale" hold below. A run of held cycles would otherwise leave - # each unit's `since` untouched across the gap and its window would - # resume from a start time that predates it - the ordering bug the sign - # watchdog's own note describes, in a third detector. - # - # ⚠️ THE OPERANDS ARE `standing` AND `inv.batt`, THE SAME PAIR SAFETY-30 - # GRADES, AND THE CONCLUSION IS A DIFFERENT ONE. SAFETY-30 asks whether - # they OPPOSE; this asks whether the second one ARRIVED. A unit sitting - # at 0 W against a +2000 W share opposes nothing - it is not pushing the - # wrong way, it is absent - so the sign gate never opens, the site sum - # still agrees with the meter in direction, circulating power is - # identically zero, and the only trace left is `reason == - # "saturated-freeze"`, which clamp.py:93-96 documents as the alarm an - # operator learns to ignore. That is this card. - # - # ⚠️ AND `delinquent()` IS CALLED ONCE, OVER THE WHOLE ROSTER, BECAUSE - # THE CHECK IS A REDUCTION ACROSS UNITS AND NOT A PER-UNIT PREDICATE. - # What separates a wedged box from a legitimately saturated site is that - # the site's shortfall is a PROPER SUBSET of the units asked to deliver. - # Called per unit it would be a shortfall test, which is the saturation - # signal again. delivery.py carries the measurement: a pack at its own - # cut-out and a wedged unit produce IDENTICAL traces at the unit and at - # the meter, so nothing computed from one unit alone can tell them - # apart. At a roster of one the reduction is empty for every input. - # - # ⚠️ EVERY NUMBER BELOW IS THE CONFIGURED ONE, NEVER A LITERAL THAT - # HAPPENS TO EQUAL IT TODAY, AND THAT IS A SHIPPED PROMISE RATHER THAN - # A STYLE RULE. `delivery.MIN_W_IS_SATURATION_W` and config.yaml's - # `delivery_hold_s` block both tell an operator, in prose, that there is - # no fourth copy of a threshold to keep in step and no way for the two - # to drift - an operator who raises `saturation_w` has told the loop a - # bigger gap is ordinary tracking, and this check moves with them BY - # CONSTRUCTION. A literal here breaks exactly that promise, silently. - # - # Both of these were once literals that no check in this repository - # could see, because every fixture sat at the shipped value: - # - # self.tuning.saturation_w -> 250.0 (-19 B) - a survivor of all - # 23 suites, because part 4's - # gaps are far above BOTH - # bms_floor=inv.bms_soc_floor -> 10.0 (-13 B) - a survivor of all - # 23 suites, because every - # fixture's cut-out WAS 10 % - # - # test_delivery.py part 4b is what stands between this call and both of - # them: it grades this reduction at a `saturation_w` and at two - # `bms_soc_floor`s that are NOT the shipped ones, and its arms are - # answered opposite ways at two configured values, so no constant can - # pass. Change an argument here to a number and read that part before - # you decide the red is wrong. + # SAFETY-42, for SAFETY-02's and SAFETY-31's reason: evaluated every + # cycle above the input hold. The operands are `standing` and + # `inv.batt`, the pair SAFETY-30 uses. `delinquent()` is called once + # over the whole roster, and every number is the configured one, never + # a literal that could drift (`self.delivery_cfg`, not + # `DeliveryConfig()`: part 4b grades a hold that is not the shipped 185 + # s). for inv in self.inverters: - # ⚠️ `self.delivery_cfg`, NOT `DeliveryConfig()`. Same promise: the - # window is `delivery_hold_s`, and part 4b's neighbours already - # grade this one at a hold that is not the shipped 185 s. + # `self.delivery_cfg`, not `DeliveryConfig()`: the window is + # `delivery_hold_s`, and part 4b grades a hold that is not the + # shipped 185 s. inv.delivery_wd.arm(self.delivery_cfg) _delinquent = delinquent( [UnitDelivery( @@ -4587,14 +3354,7 @@ class Controller: delivery_account( # ⚠️ `is_live()` AT ITS OWN DEFAULT WINDOW, which is # `LINK_STALE_S` - the SAME call `live_units()` makes for - # the allocator. It has to be: what this branch means is - # "the allocator has already dropped this unit and said so", - # and a different window here would be this check answering - # a question about a roster the allocator did not build. - # It is the ONE number here with no option behind it, so - # "graded at a changed config" is not available for it; - # what part 6 grades instead is that WIDENING the window - # changes the answer. + # the allocator. (DECISIONS/MAIN-orchestration-archive.md) live=inv.is_live(now)[0], # SAFETY-73: a refusing unit is asked 0 W on purpose, and # one asked under PROBE_W that gives under 100 W is a @@ -4604,11 +3364,8 @@ class Controller: and abs(float(standing[inv.name])) <= _ctl.PROBE_W and abs(float(inv.batt) - inv.idle_w) < _ctl.REFUSED_BELOW_W), - # ⚠️ THIS UNIT'S OWN CUT-OUT. Not the shipped 10 %, and not - # the site's `peak_soc_floor` either - that one bounds a - # money-saving discharge only, and grading a pack against it - # explains a wedged unit away in a band an ordinary install - # has ten points of. + # ⚠️ THIS UNIT'S OWN CUT-OUT. + # (DECISIONS/MAIN-orchestration-archive.md) soc=inv.soc, bms_floor=inv.bms_soc_floor)) for inv in self.inverters], self.tuning.saturation_w) @@ -4617,16 +3374,15 @@ class Controller: if inv.delivery_wd.evaluate(_delinquent.get(inv.name), now) \ and not was_short: # REASON FIRST, FLAG SECOND, and each `set` writes the file - - # the same atomicity ordering the three trips above use: a crash - # between the two must not persist a latch with no explanation - # for the operator who has to decide about clearing it. - # - # ⚠️ AND THERE IS NO FLAT MIRROR OF THIS PAIR, WHERE SAFETY-02 - # HAS ONE. store.py's rule 2 keeps the flat keys "so a rollback + # the same atomicity ordering the three trips above use: a + # crash between the two must not persist a latch with no + # explanation for the operator who has to decide about clearing + # it. + # AND THERE IS NO FLAT MIRROR OF THIS PAIR, WHERE SAFETY-02 HAS + # ONE. store.py's rule 2 keeps the flat keys "so a rollback # still finds its latch", and the version being rolled back TO - # drives exactly ONE actuator - a roster at which this check is - # inert by construction. A flat mirror would hand a downgraded - # add-on a latch taken on evidence about a roster it cannot have. + # drives exactly ONE... + # (DECISIONS/MAIN-orchestration-archive.md) inv.store_set("delivery_reason", inv.delivery_wd.reason) inv.store_set("delivery_tripped", True) self._delivery_announced.discard(inv.name) @@ -4647,16 +3403,10 @@ class Controller: "away a partly delivering unit's output.") stale_after = float(self.o.get("stale_input_s", 15)) - # ⚠️ `any`, NOT the sole unit's. The hold exists so a single missed poll - # does not become a fault; a LATCHED inverter must still reach the claim - # list below, because its `failsafe` SET is what parks it. One tripped - # unit therefore takes the whole roster past the hold, which is the - # conservative direction: every other unit's claims are re-filed too. - # ⚠️ AND SAFETY-30's SITE LATCH TAKES THE ROSTER PAST THE HOLD TOO, for - # the same reason: its `sign_site` SET is what parks every inverter, and - # a latched site that returned early here would leave each unit's last - # share standing for the heartbeat to re-emit - i.e. the trip would stop - # acting the moment a poll was missed. + # ⚠️ `any`, NOT the sole unit's. The hold exists so a single missed + # poll does not become a fault; a LATCHED inverter must still reach the + # claim list below, because its `failsafe` SET is what parks it. + # (DECISIONS/MAIN-orchestration-archive.md) if not self.inputs_ok and not any(i.sign_wd.tripped for i in self.inverters) \ and not (n_units > 1 and self.site_sign_wd.tripped): @@ -4666,26 +3416,16 @@ class Controller: # going, and give it a few seconds to come back. self.reason = f"inputs missing {bad_for:.0f}s" # UX-10: the age is rendered when the page asks, not frozen at - # this cycle's. With the meter reading gone the grid value - # stops changing, so the next cycle is up to cycle_floor_s - # away and the line used to read "0 seconds" all that time. + # this cycle's. (DECISIONS/MAIN-orchestration-archive.md) self.why_words = messages.why_waiting(bad_for) self._why_waiting_since = now - timedelta(seconds=bad_for) return claims = [] - # --- safety limits: these bind EVERY strategy, always ---------------- - # Defence in depth: write_setpoint() clamps to the device range too. The - # duplication is deliberate - one of them is the policy, the other is the - # last thing between a bug and the hardware. - # ⚠️ PER-INVERTER, ONE PAIR PER UNIT, FROM THAT UNIT'S OWN NUMBERS. - # This loop is the difference DEV-02 makes to the claim list: the - # numbers are ONE inverter's own reported min/max and ONE inverter's own - # supervised cap, and there is no such thing as a site rating. Before - # this card there was one pair, read from one setpoint entity, filed - # once - so a second inverter's rating was never read and never bound. - # See arbiter.CLAIM_SCOPE. + # safety limits: these bind EVERY strategy, always Defence in depth: + # write_setpoint() clamps to the device range too. + # (DECISIONS/MAIN-orchestration-archive.md) for inv in self.inverters: if inv.dev_min is not None and inv.dev_max is not None: claims.append(Claim.limit("device", P_SAFETY, inv.dev_min, inv.dev_max, "rating", @@ -4694,33 +3434,11 @@ class Controller: # setpoint entity (§11.3), not a bound on the grid connection. claims.append(Claim.limit("supervised", P_SAFETY, -inv.max_w, inv.max_w, "max_w", actuator=self.actuator_of("supervised", inv))) - # --- OPT-09: WHAT THIS PACK'S STATE OF CHARGE FORBIDS ------------- - # ⚠️ THE POINT OF THE CARD, AND IT IS AN ENVELOPE RATHER THAN A - # WEIGHT. Weighting an empty pack down does NOT stop it being - # commanded: `allocator._fill()` runs a deliberate last-resort pass - # over the zero-weight units when the preferred ones saturate, so - # that a preference can never manufacture a standing error at the - # meter. Only a closed envelope stops the ask, and `cap` is obeyed - # absolutely. So a pack at or under its own BMS cut-out gets - # `hi = 0` and a pack at 100 % gets `lo = 0`, and the site target - # goes to the sibling instead of being asked of a battery that - # cannot answer it. - # - # ⚠️ AT A ROSTER OF ONE THIS IS NEVER FILED, AND THAT IS THE SAME + # OPT-09: WHAT THIS PACK'S STATE OF CHARGE FORBIDS ⚠️ THE POINT OF + # THE CARD, AND IT IS AN ENVELOPE RATHER THAN A WEIGHT. + # AT A ROSTER OF ONE THIS IS NEVER FILED, AND THAT IS THE SAME # IDENTITY `sign_site` IS GUARDED BY RATHER THAN A SPECIAL CASE. - # `actuator_for()` routes every source to the SITE at N=1 - with one - # inverter the fabricated grid reading and the battery setpoint are - # the same number - so this interval would bound THE SITE TARGET - # there, not a share. That is a behaviour change on the live - # single-inverter install and it is the shape SAFETY-25 decided - # against on 2026-09-02 (Option A): ordinary self-consumption is not - # given an SoC floor by this card. There is also nothing for it to - # do at N=1 - no sibling to move the share to - so the whole of AC 4 - # ("at N=1 allocation is byte-identical to today") is this `if`. - # - # WHAT IT COSTS, SAID PLAINLY: at N=1 nothing here stops the loop - # discharging into the BMS cut-out. That is unchanged, it is - # SAFETY-25's deferred Option B, and the BMS itself is what stops it. + # (DECISIONS/MAIN-orchestration-archive.md) if n_units > 1: _pe = packshare.envelope(pack_energy_of(inv)) if _pe.binding: @@ -4731,116 +3449,39 @@ class Controller: # sum: what this site can deliver, not what one of its inverters can. max_w = self.site_max_w - # --- safety stops ---------------------------------------------------- - # ⚠️ SAFETY-02 is claimed FIRST and unconditionally while latched. It is - # a SET at P_SAFETY, so it outranks maintenance, peak shaving and the - # loop alike - a controller pushing power the wrong way is wrong - # whatever it thinks it is doing. Re-filed EVERY cycle: claims are not - # sticky, and one filed once would lapse the next cycle and hand the - # actuator silently back to the loop that was driving the wrong way. - # - # ⚠️ AND IT IS PER-INVERTER, THOUGH ITS EVIDENCE IS SITE-WIDE. The - # watchdog compares P1 - one meter, one site - against `standing`, THE - # COMMAND THIS CONTROLLER PUT ON THIS ACTUATOR, and what it concludes is - # "the unit I am driving is pushing power the wrong way". At N>=2 that - # evidence cannot be pinned on a sibling this controller never commanded, - # so a site-wide stop would park an inverter this watchdog never graded. - # See arbiter.CLAIM_SCOPE. + # safety stops ⚠️ SAFETY-02 is claimed FIRST and unconditionally while + # latched. + # AND IT IS PER-INVERTER, THOUGH ITS EVIDENCE IS SITE-WIDE. + # (DECISIONS/MAIN-orchestration-archive.md) for inv in self.inverters: if inv.sign_wd.tripped: claims.append(Claim.set("failsafe", P_SAFETY, 0.0, "sign disagreement", actuator=self.actuator_of("failsafe", inv))) - # --- SAFETY-30: THE SITE SIGN TRIP, AND IT IS A SITE SET ------------ - # ⚠️ THE TWIN OF THE LOOP ABOVE, NOT A DUPLICATE OF IT. That loop files - # one `failsafe` per unit on the evidence of THAT unit's own measured - # power, and parks that unit. This files ONE claim on the SITE, because - # what tripped is the SUM of every standing command against the meter - - # a fact about the grid connection, whose offending number is the site - # target and which no single inverter owns. Parking one unit on it would - # leave the rest of the roster pushing the same way with the guard - # satisfied. See arbiter.CLAIM_SCOPE, which classifies `sign_site` and - # `failsafe` side by side and says why they differ. - # - # ⚠️ AND THE `n_units > 1` GUARD IS NOT REDUNDANT, EVEN THOUGH `cycle()` - # never arms this detector at a roster of one. The latch is RESTORED - # FROM THE STORE, so an install that ran with two inverters, tripped, - # and was then reconfigured back to one would come up with - # `site_sign_tripped: true` and would otherwise file a claim on evidence - # about a roster it no longer has. Guarded, that install comes up - # driving - which is right: the fault it latched on is not a statement - # about the site it is now. + # SAFETY-30: THE SITE SIGN TRIP, AND IT IS A SITE SET ⚠️ THE TWIN OF + # THE LOOP ABOVE, NOT A DUPLICATE OF IT. + # AND THE `n_units > 1` GUARD IS NOT REDUNDANT, EVEN THOUGH `cycle()` + # never arms this detector at a roster of one. + # (DECISIONS/MAIN-orchestration-archive.md) if n_units > 1 and self.site_sign_wd.tripped: claims.append(Claim.set("sign_site", P_SAFETY, 0.0, "site sign disagreement", actuator=self.actuator_of("sign_site"))) - # --- SAFETY-31: WHAT "ACTED ON" MEANS, AND WHAT IT HONESTLY CANNOT -- - # ⚠️ A SITE SET AT P_SAFETY: STOP COMMANDING, EVERYWHERE. Read the next - # paragraph before changing it, because the obvious objection is right - # and is answered rather than ignored. - # - # THE OBJECTION. HW-01 established that commanding a wedged inverter - # achieves nothing - on 2026-09-02 the comms subsystem failed and the - # power stage went on executing its last instruction for 6 h 25 m, - # through every command this add-on sent it. If the cause of the - # circulation is a unit that has stopped listening, then a command is - # not a remedy and pretending otherwise is theatre. - # - # THE ANSWER, AND IT IS WHY THIS IS STILL AN ACTION RATHER THAN A - # NOTIFICATION. A circulating loop needs TWO participants. In every - # cause on this card's list, exactly one of them is a unit that has - # stopped listening and THE OTHER ONE IS THIS CONTROLLER - faithfully - # commanding the sibling to a share computed to sum with a number the - # wedged unit is not actually producing. Stopping the commanding half - # does two things a notification cannot: - # - # 1. IT HALVES THE LOOP. The unit that is still listening stops being - # driven into the one that is not, so the round-tripping stops on - # the side this controller owns. That is real energy and real - # cycles off a real pack, and it needs no cooperation from the - # wedged unit to take effect. - # 2. IT MAKES THE RESIDUE VISIBLE. This is the more important half. - # Circulating power hides because it NETS TO ZERO AT THE METER. - # With this controller's contribution removed, whatever the wedged - # unit is still doing stops being cancelled and appears at P1 as a - # plain net flow - where SAFETY-02's sign watchdog (which gates on - # `abs(grid_w) > 300` and could never open at a netted zero), the - # peak guard and the capacity tariff can all see it. The fault - # stops being invisible. Measured on the TEST-19 rig: the card's - # own scenario reads +0 W at the meter with 6000 W going round, and - # the shipped SignWatchdog's predicate is false on every one of - # those cycles. - # - # WHAT IT DOES NOT DO, SAID PLAINLY: it does not stop the wedged - # inverter. Nothing this add-on can send will. If the loop persists at - # the meter after this trip, the remaining actuator is a human at the - # isolator, which is what the notification says in those words. - # - # ⚠️ AND IT IS A SITE CLAIM. `arbiter.CLAIM_SCOPE` carries the argument: - # circulating power is defined over the roster and is identically zero - # for any one unit, so there is no attribution to file this per-unit on - - # and parking the wrong one of the two would leave the loop running with - # the guard satisfied. + # SAFETY-31: WHAT "ACTED ON" MEANS, AND WHAT IT HONESTLY CANNOT ⚠️ A + # SITE SET AT P_SAFETY: STOP COMMANDING, EVERYWHERE. + # THE OBJECTION. + # (DECISIONS/MAIN-orchestration-archive.md) if self.circ_wd.tripped: claims.append(Claim.set("circulation", P_SAFETY, 0.0, "circulating power", actuator=self.actuator_of("circulation"))) # SITE, both of them, and the two reasons are why. "stopped" is the - # operator switching control off for the whole site; "inputs missing" is - # the SITE's own telemetry gone - the meter this controller regulates - # against. Neither is about a pack, and both must stop every inverter. - # ⚠️ SAFETY-10: `and not self.shadow`, AND IT IS THE ONE FAILSAFE THIS - # CARD CHANGES. `P_SAFETY` out-ranks every other claim, so leaving the - # stop claim in while shadow mode is engaged would make the recorded - # decision "stopped" on every cycle - a shadow run that answers the - # wrong question, which is the same as no shadow run. The actuator ends - # at the same number either way, and the substitution in `_write_unit()` - # that replaces it is STRICTLY STRONGER than this claim: a claim can be - # out-ranked by a higher priority, and the wire gate cannot be - # out-ranked by anything. + # operator switching control off for the whole site; "inputs missing" + # is the SITE's own telemetry gone - the meter this controller + # regulates against. (DECISIONS/MAIN-orchestration-archive.md) if not self.auto and not self.shadow: claims.append(Claim.set("safety", P_SAFETY, 0.0, "stopped", actuator=self.actuator_of("safety"))) @@ -4851,51 +3492,11 @@ class Controller: claims.append(Claim.set("safety", P_SAFETY, 0.0, "inputs missing", actuator=self.actuator_of("safety"))) - # --- SAFETY-07: the quarter-peak projection guard -------------------- - # ONE `lo` limit at P_PEAK, and peakguard.py carries the whole argument. - # What is decided HERE and nowhere else is what the guard is allowed to - # see and what it is allowed to ask for: - # - # ⚠️ `frozen` IS EVALUATED ON THIS CYCLE'S STANDING COMMAND, NOT ON - # `self.freeze_wd.active`. The watchdog object is advanced at the END of - # cycle() (check_meter_frozen), so reading its level here would grade - # this quarter against the PREVIOUS cycle's conjunction - one cycle of - # lag, in a guard whose entire job is to act inside a 900 s quarter. - # `standing` is the value SAFETY-02 already computed above for exactly - # this meaning: the command on the wire that this meter reading is a - # response to. The predicate itself is freeze.py's shipped - # `looks_frozen()`, not a copy of it - see loop_is_following() on what a - # transcribed safety predicate costs. - # - # ⚠️ `achievable_w` BOUNDS THE FLOOR SO THIS CLAIM CANNOT CONTRADICT THE - # SAFETY LIMITS ABOVE. `lo > hi` in the arbiter is a CONTRADICTION and - # resolves to 0 W - the battery parked, in the one minute of the month it - # should be discharging hardest. maintenance_charge_w had to learn the - # same lesson (config.yaml, validate.py rule 6); this claim is built so - # the config cannot express it. - # - # ⚠️ AND IT IS FILED EVERY CYCLE, unconditionally, like SAFETY-02's. A - # claim is not sticky. Filed once and skipped next cycle, the floor - # lapses and the actuator goes silently back to whatever was pushing the - # peak. Which of the higher-priority claims outranks it is arbiter.py's - # business, not this block's: a maintenance DRAIN or HOLD owns the - # setpoint at P_MAINTENANCE, and a maintenance CHARGE is only ever a - # P_MAINT_SHAPE limit (40), bound by this floor. A floor above the drain - # rate still wins (SAFETY-78 at N>=2, `_peak_over_drain()` at N=1), and - # so does a floor over a HOLD (`_peak_over_hold()`, SAFETY-86). - # - # ⚠️ AND IT IS NOT RUN AT ALL WITHOUT P1. `meter_source: off` is the - # legacy single-entity path: there is no telegram stream, so - # `p1.averager` has no open block, so the guard would be BLIND on every - # cycle forever - and blind means "no charging". That is not a - # conservative default, it is a controller that silently stops charging - # on every install that has not opted in to P1 yet; test_clamp.py's - # export case caught it doing exactly that. The billed quantity simply - # is not measured on that path, and the honest report is NOT COVERED - - # the same answer, for the same reason, that the status page already - # gives for SAFETY-18 on a transport that cannot see `unchanged_s`. - # OPT-15: with battery modes on, the quarter `now` is in, even before - # its first sample (DECISIONS/OPT-11-battery-modes.md). + # SAFETY-07: the quarter-peak projection guard ONE `lo` limit at + # P_PEAK, and peakguard.py carries the whole argument. + # `frozen` IS EVALUATED ON THIS CYCLE'S STANDING COMMAND, NOT ON + # `self.freeze_wd.active`. + # (DECISIONS/MAIN-orchestration-archive.md) self._peak_proj = (None if not self.p1_enabled else self.peak.project(self.p1.averager, now, roll=self.modes_on)) @@ -4917,51 +3518,27 @@ class Controller: self._peak_proj, # ⚠️ THE SITE SUM. The guard sizes a floor from what the battery is # ALREADY removing from the meter, and the meter sees every - # inverter. One unit's power here would size the floor from a - # fraction of the discharge already happening. + # inverter. (DECISIONS/MAIN-orchestration-archive.md) batt_w=self.site_batt_w, cap_w=float(self.o.get("peak_cap_w", 3500)), margin_w=self.peak_margin_w, reading=_reading, - # ⚠️ SUMMED FROM THE PER-UNIT CONJUNCTION, WHICH IS AC 4. Each - # unit contributes `min(its max_w, |its own device bounds|)`, so - # inverter B's rating bounds inverter B. Conjoining the SUMS instead - # would let one unit's device range cap the whole site. + # ⚠️ SUMMED FROM THE PER-UNIT CONJUNCTION, WHICH IS AC 4. Each unit + # contributes `min(its max_w, |its own device bounds|)`, so + # inverter B's rating bounds inverter B. + # (DECISIONS/MAIN-orchestration-archive.md) achievable_w=self.site_rating_w, - # ⚠️ SAFETY-24. `pack_floor_soc` IS A MEASURED READING AND MAY BE None, - # and it is handed over as None rather than defaulted here. An early - # draft of the stage-5 loop read SoC as `| float(100)` and thereby - # invented a full battery (NOTES.md 4.4b); a full battery is - # precisely the value that disarms this floor. decide() treats None - # as floor-limited, which is the direction that costs a shave rather - # than the pack. - # ⚠️ THE EMPTIEST PACK, not an average - see `pack_floor_soc`. A - # floor that grades a discharge permission against the mean lets the - # emptier pack be driven below the level this option exists to - # protect. None stays None: `decide()` treats it as floor-limited, - # which costs a shave rather than a pack. + # ⚠️ SAFETY-24. `pack_floor_soc` IS A MEASURED READING AND MAY BE + # None, and it is handed over as None rather than defaulted here. + # (DECISIONS/MAIN-orchestration-archive.md) soc=self.pack_floor_soc, # ⚠️ SAFETY-32. THE SAME OPTION, REDUCED OVER THE PACKS' OWN - # CUT-OUTS. `peak_soc_floor` is still one site-wide EMS floor and is - # still read from that one key with CFG-03's one mirror behind it; - # what `binding_soc_floor()` adds is the conjunction with each pack's - # OWN `bms_soc_floor`, so the floor this site is graded against can - # never be below the deepest cut-out on the roster. MAXIMUM here - # pairs with the MINIMUM in `pack_floor_soc`: both reductions move - # the verdict towards refusing a shave and away from spending a pack. - # Under any config rule 8 accepts it IS the option, for every pack - - # asserted over the whole schema range in test_peakguard.py part 12 - # rather than assumed, because "another rule refuses that" is a claim - # about rule ordering and sim/inject.py does not run rule 8 at all. + # CUT-OUTS. (DECISIONS/MAIN-orchestration-archive.md) soc_floor=peakguard.binding_soc_floor(self.packs, float(self.o.get( "peak_soc_floor", peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"]))), max_hold_s=self.p1.averager.max_hold_s, - # ⚠️ SAFETY-20. The device range goes in TOO, and this call site is - # the one that needed it most: `standing` is `last_write`, i.e. the - # value AFTER `clamp_to_rating()` applied `[dev_min, dev_max]`, so - # grading it against a rail derived from bare `max_w` compares a - # device-clipped number to a ceiling the device cannot reach. + # ⚠️ SAFETY-20. (DECISIONS/MAIN-orchestration-archive.md) frozen=looks_frozen(getattr(self.p1_source, "unchanged_s", None), site_standing, max_w, self.freeze_wd.cfg, dev_min=self.site_dev_bound("dev_min"), @@ -4973,22 +3550,14 @@ class Controller: self.peak_guard = self._peak_on_able(self.peak_guard, maint_results) self.peak_guard = self._peak_landing(self.peak_guard) - # SITE, and peakguard.claim() says so on the object it builds. The - # quantity is the 15-minute average OFFTAKE AT THE GRID CONNECTION, - # which is what the capacity tariff bills; there is one quarter-hour - # peak per site, not one per pack. ⚠️ IT IS ALSO THE `lo` HALF OF - # SAFETY-25 §0c's table - a site `lo` at this stage, which is what the - # deferred loop reserve's per-inverter `hi` must never be able to cross. + # SITE, and peakguard.claim() says so on the object it builds. + # (DECISIONS/MAIN-orchestration-archive.md) peak_claim = (peakguard.claim(self.peak_guard) if self.peak_guard is not None else None) if peak_claim is not None: claims.append(peak_claim) - # ⚠️ EDGE-LOGGED, not level-logged. The action log holds 40 lines; a - # guard that appended one every 15 s for a whole quarter would push the - # cause of the event off the end of the log that is meant to explain it. - # SAFETY-03 logs every engagement because a clamp engagement IS the - # count that matters there; here the count is the seconds, and the - # engagement's own reason line already carries them. + # ⚠️ EDGE-LOGGED, not level-logged. + # (DECISIONS/MAIN-orchestration-archive.md) engaged_now = self.peak_guard is not None and self.peak_guard.engaged if engaged_now != self._peak_engaged: self._peak_engaged = engaged_now @@ -5001,48 +3570,10 @@ class Controller: "peak_reserve" if _pg.floor_limited else "peak_saturated" if _pg.saturated else "peak_on")) - # --- OPT-04: price arbitrage (§10.1 priority 3) ---------------------- - # ONE `set` claim at P_ARBITRAGE, and app/arbitrage.py carries the whole - # argument. What is decided HERE and nowhere else is what the strategy - # is allowed to see: - # - # ⚠️ IT IS PLACED AFTER THE PEAK BLOCK BECAUSE IT READS ITS CONCLUSION. - # `engaged_now` is this cycle's peak verdict, computed ~20 lines up. - # Evaluated before it, this would hand the strategy the PREVIOUS cycle's - # answer - one cycle of lag on the interlock that implements §9.1's - # "peak shaving outranks arbitrage", inside a 900 s quarter. - # - # ⚠️ AND THE THIRD STATE IS NOT A TIDINESS. Without P1 the guard is not - # CLEAR, it never RAN, and `None` says so. arbitrage.decide() refuses on - # it, because charging at max_w creates the billed quantity while - # nothing is measuring the quarter it lands in. `False` here would be - # this line asserting a measurement that was never taken - the defect - # `PeakReading` and `quarter_ceiling_w()` both exist to remove. - # - # ⚠️ THE THREE-STATE COLLAPSE LIVES IN arbitrage.guard_state(), NOT - # HERE, and that is not tidiness. It is the same lesson - # `shaping_suspended()` records: inline, this expression was reachable - # by no check, and mutating it back to a bare `engaged_now` left every - # suite in this repository green while a `meter_source: off` install - # traded blind. Read guard_state() before touching this line. - # - # ⚠️ NO FETCH ON THIS PATH. `fresh_tariff_plan()` is an attribute read - # and an age comparison; the HTTPS call lives in run_tariff_refresh(). - # See TARIFF_REFRESH_S: a supplier's TLS timeout here would be a - # supplier able to push this add-on towards layer 1's 30 s veto. - # - # ⚠️ AND THE UNCERTAINTY SET IS NOT EMPTY BY DEFAULT. §11.4's - # no-weather row widens the threshold, and this add-on carries no - # weather forecast at all - §8.1's PV forecast is not built. Passing - # `frozenset()` would be main.py asserting a forecast is healthy when - # there is not one, which is exactly what static_tariff's `uncertainty` - # argument has no default in order to prevent. - # ⚠️ TARIFF-11: BOTH ATTRIBUTES, AND `arb_at` IS WHAT MAKES THE ROW - # HONEST. Reaching this line is the only proof that this cycle got as - # far as the arbitrage step; the early return at the input hold above - # leaves BOTH standing at whatever the last completed cycle left, and - # `arb_at` is then the age the status row reports rather than a - # decision presented as current. + # OPT-04: price arbitrage (§10.1 priority 3) ONE `set` claim at + # P_ARBITRAGE, and app/arbitrage.py carries the whole argument. + # IT IS PLACED AFTER THE PEAK BLOCK BECAUSE IT READS ITS CONCLUSION. + # (DECISIONS/MAIN-orchestration-archive.md) self.arb, self.arb_at = None, now self.arb_headroom_w = None self.room_charge = False @@ -5051,12 +3582,8 @@ class Controller: # same headroom), and this site-wide trade is not run. site_trade = arbitrage.enabled(self.o) and not self.modes_on if site_trade or self.modes_on: - # SAFETY-47. HOISTED OUT OF THE CALL, NOT COPIED. One evaluation of - # the shipped expression feeds both the sizing below and - # `arbitrage.sized_from_meter()` in `_shadow_settle()`, so the - # caveat on the recorded watts can never be answered from a - # different cycle's guard than the watts were. - # SAFETY-125: a late meter reading's seconds count, as the bill. + # SAFETY-47. HOISTED OUT OF THE CALL, NOT COPIED. + # (DECISIONS/MAIN-orchestration-archive.md) self.arb_headroom_w = peakguard.charge_room_w(self.peak_guard, self._peak_proj) if site_trade: @@ -5066,20 +3593,7 @@ class Controller: self.fresh_tariff_plan(now), now, peak_guard_active=arbitrage.guard_state(self.peak_guard), # SAFETY-45. THE SAME GUARD'S SECOND ANSWER, AND THE CONTINUOUS - # ONE. `guard_state()` is a switch and it was the whole loop: - # the guard engaged on a 5 W floor, the trade was refused - # outright, the loop took the actuator, the meter fell, the - # guard released and the trade came back AT max_w. Measured on - # TEST-23 stage 3 at the shipped peak_cap_w/max_w: 28 charge - # onsets an hour, grid -456 <-> +3200 W. This is the watts the - # billed quarter can still afford, so the buy leg is CLAMPED - # instead - the shape control.maintenance_charge_floor() has - # always given a maintenance charge, off the MEASURED guard - # rather than off the optional peak_forecast_entity. - # ⚠️ SAME ORDERING ARGUMENT AS THE LINE ABOVE: this reads the - # peak block's conclusion, ~50 lines up, so it must stay after - # it. Evaluated earlier it would size this quarter's trade from - # the previous cycle's quarter. + # ONE. (DECISIONS/MAIN-orchestration-archive.md) charge_headroom_w=self.arb_headroom_w, # The emptiest pack again, and for the sell side that is the # right reduction. ⚠️ THE BUY SIDE WANTS THE FULLEST, and there @@ -5090,25 +3604,13 @@ class Controller: soc=self.pack_floor_soc, min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o), uncertainty=frozenset({Uncertainty.NO_WEATHER}), - # OPT-09. PER-PACK SIZING. Not `max_w` - the site rating - on - # both sides any more: the trade is sized to the packs whose own - # state of charge permits that direction, so it can never ask - # for more than the roster can take. `trade_power_w()` carries - # the argument, including which of the two SoC bounds belongs - # here and which belongs on the envelope. ⚠️ AT N=1 BOTH SIDES - # ARE `max_w` EXACTLY, whenever the trade is permitted at all: - # the one pack that passed the gate is the whole roster. - # ⚠️ AND ON THE SELL SIDE THIS IS A NO-OP TODAY on any roster, - # because `soc=` below is the EMPTIEST pack, so a sell is - # refused outright whenever any pack is at or under the floor - - # i.e. whenever this sum could be smaller than the site rating. - # It becomes reachable the day that gate becomes per-pack; - # `test_packshare.py` part 9 grades it directly rather than - # letting an unreachable branch go ungraded. + # OPT-09. PER-PACK SIZING. + # (DECISIONS/MAIN-orchestration-archive.md) charge_w=charge_w, discharge_w=discharge_w, soc_floor_pct=floor_pct, soc_ceiling_pct=ceiling_pct) # SITE, and arbitrage.claim() says so on the object it builds. It - # buys and sells at the grid against a day-ahead price: the price is + # buys and sells at the grid against a day-ahead price: the price + # is # a property of the connection and the energy crosses the meter. arb_claim = arbitrage.claim(self.arb) if arb_claim is not None: @@ -5116,8 +3618,8 @@ class Controller: self.room_charge = self.arb.action == arbitrage.CHARGE # Edge-logged, for the peak guard's reason: the action log holds 40 # lines and a trade lasts a whole hour, so a line per cycle would - # flush the context that explains it. The transition is what an - # operator needs - "it started buying at 03:00", not 240 copies. + # flush the context that explains it. + # (DECISIONS/MAIN-orchestration-archive.md) if self.arb.action != self._arb_action: self._arb_action = self.arb.action self.log_event(self.arb.explain(), hint=messages.hint( @@ -5125,82 +3627,35 @@ class Controller: "trade_give" if self.arb.action == arbitrage.DISCHARGE else "trade_off")) - # --- maintenance ----------------------------------------------------- - # ⚠️ ONE PASS PER PACK, AND `arbitrate()` IS WHY THE LIST STAYS SHORT. - # At most one machine is out of IDLE on any tick, so at most one of - # these branches fires and the claim list is the shape it has always - # been. The loop is not a widening of what maintenance may ask for; it - # is what makes the ONE claim it does file land on the pack that is - # actually being recalibrated, whichever roster slot that is. + # maintenance ⚠️ ONE PASS PER PACK, AND `arbitrate()` IS WHY THE LIST + # STAYS SHORT. (DECISIONS/MAIN-orchestration-archive.md) for inv, result in zip(self.inverters, maint_results): if result.owns_setpoint: - # PER-INVERTER. §12's monthly recalibration is a PACK procedure - - # drain THIS pack to its floor, charge THIS pack full, - # recalibrate THIS pack's coulomb counter. Two packs recalibrate - # on two schedules. See arbiter.CLAIM_SCOPE. - # - # ⚠️ FILED ON THE PACK WHOSE STATE MACHINE PRODUCED IT - `inv`, + # PER-INVERTER. §12's monthly recalibration is a PACK procedure + # - drain THIS pack to its floor, charge THIS pack full, + # recalibrate THIS pack's coulomb counter. Two packs + # recalibrate on two schedules. + # FILED ON THE PACK WHOSE STATE MACHINE PRODUCED IT - `inv`, # the roster entry this `result` was ticked from - and that is - # the whole of MAINT-05 at this line. It used to be - # `self.maint_unit`, which was `inverters[0]` flatly, because - # there was one FSM and its history had nowhere else to live. - # Pack B's drain filed on pack A is one pack being commanded for - # another's procedure while the pack that is actually draining - # stays under the loop: the site would move the right total and - # recalibrate nothing. - # - # ⚠️ AND IT STAYS `SCOPE_UNIT`. Two FSMs mean two claims on two - # actuators, which the allocator consumes as per-unit intervals; - # ARCH-01 is explicit that this must never become a site claim, - # because per-inverter constraints filed site-wide are the - # cross-inverter `lo > hi` contradiction at arbiter.py:114. + # the whole of MAINT-05 at this line. + # (DECISIONS/MAIN-orchestration-archive.md) claims.append(Claim.set("maintenance", P_MAINTENANCE, float(result.setpoint_w or 0.0), result.phase, actuator=self.actuator_of("maintenance", inv))) self.sat_count = 0 elif result.charge_only: - # Money outranks the maintenance schedule: while the quarter-hour - # projection is over the cap, the charge-only shaping is simply not - # claimed, so the loop can discharge and shave the peak. When a - # dedicated peak-shaving strategy arrives it will claim SET at - # P_PEAK and outrank this limit without any code here changing. - # ⚠️ AND SAFETY-07 IS THE OTHER HALF OF THIS CONDITION, NOT A - # SECOND MECHANISM. The shaping is `hi <= 0`; the peak guard's - # claim is `lo >= 0`. Both are limits, arbiter.py applies EVERY - # limit at or above the winner's priority, and most restrictive - # wins - so a peak event during a maintenance charge window files - # `lo > hi`, which is a CONTRADICTION and resolves to 0 W. The - # battery would park in the exact quarter it should be shaving. - # Priority does not save it: clause 2 has no notion of a - # higher-priority limit overriding a lower one, and it should not - - # "most restrictive" is what makes the safety bounds unconditional. - # - # So the higher-priority claimant SUPPRESSES the lower one rather - # than out-voting it, which is what this branch already did for - # `peak_forecast_entity`. That entity is optional and empty on a - # default install, so until now the suspension could not fire on the - # site that needed it. `self.peak_guard` is measured from the meter - # and is always there. - # - # ⚠️ THE CONJUNCTION LIVES IN peakguard.py, NOT HERE, and that is - # not tidiness. It was inline, and a reviewer mutated it back to its - # historical broken form - re-gated on `peak_forecast_entity` alone - # - and every suite in this repository stayed green while a default - # install parked at 0 W in the quarter that sets the month's bill. - # Read shaping_suspended() before touching this: both terms are - # there, and why neither is sufficient alone. - # - # ⚠️ ONE SUSPENSION FLAG FOR THE SITE, NOT ONE PER PACK, AND THE - # INTERLOCK IS WHAT MAKES THAT TRUE. The quantity it edge-logs is - # a peak event at the grid connection, and `arbitrate()` allows - # at most one pack to be in CHARGE at a time, so this branch can - # run at most once per tick. Were two packs ever allowed to - # charge together, this flag would have to become per-pack - and - # so would the reasoning above it. + # Money outranks the maintenance schedule: while the + # quarter-hour projection is over the cap, the charge-only + # shaping is simply not claimed, so the loop can discharge and + # shave the peak. + # THE CONJUNCTION LIVES IN peakguard.py, NOT HERE, and that is + # not tidiness. + # (DECISIONS/MAIN-orchestration-archive.md) if peakguard.shaping_suspended( self.peak_guard, self.peak_fc, self.peak_aim_w()): # Edge-logged: level-logging here would write one line per - # cycle for as long as the event lasts and flush the 40-line + # cycle for as long as the event lasts and flush the + # 40-line # action log of the very context that explains it. if not self._maint_shape_suspended: self._maint_shape_suspended = True @@ -5213,38 +3668,28 @@ class Controller: hi = 0.0 reason = "charge-only" if self.cheap: - # ⚠️ CFG-03. `self.maint.cfg.charge_w`, NOT A SECOND READ - # OF `self.o`. This line used to re-open the options dict - # with its own literal fallback, so the rate the shaping - # asked for and the rate the state machine drained at - # could be different numbers from the same install - and, - # with the key absent, they were. The Controller already - # parsed this once in __init__; asking the object that - # owns it cannot drift from it. Every pack's machine - # holds the same `MaintConfig` instance, so this is the - # rate the pack in front of us is charging at. + # ⚠️ CFG-03. `self.maint.cfg.charge_w`, NOT A SECOND + # READ OF `self.o`. + # (DECISIONS/MAIN-orchestration-archive.md) floor = maintenance_charge_floor( self.maint.cfg.charge_w, self.peak_fc, self.peak_aim_w()) floor = self._charge_headroom(floor, claims) if floor > 0: hi, reason = -floor, "cheap-window charge" - # PER-INVERTER, like the SET above and for the same reason - - # the shaping is about the pack being charged, which is `inv`. + # PER-INVERTER, like the SET above and for the same reason + # - + # the shaping is about the pack being charged, which is + # `inv`. claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi, reason=reason, actuator=self.actuator_of("maintenance", inv))) - # --- OPT-11: each battery's mode, a soft unit limit at P_MODE --------- - # Filed only with battery modes on, and never on a battery in any - # maintenance phase: maintenance at 60/40 outranks the mode. The range - # always holds 0 W (packmode.mode_range()). pre_resolve() keeps it - # apart from the hard limits, and it gives way to the peak guard's - # site floor (P_PEAK) only as far as the floor needs, so the two never - # meet in a `lo > hi` (arbiter._unit_modes()). - # P3: the range is the switch's, narrowed by the battery's role in - # the price runs this tick (`modetrade.decide()`); both hold 0 W. + # OPT-11: each battery's mode, a soft unit limit at P_MODE Filed only + # with battery modes on, and never on a battery in any maintenance + # phase: maintenance at 60/40 outranks the mode. + # (DECISIONS/MAIN-orchestration-archive.md) if self.modes_on: tick = self._mode_roles(now, maint_results) for inv, result in zip(self.inverters, maint_results): @@ -5258,16 +3703,8 @@ class Controller: actuator=self.actuator_of("mode", inv))) - # --- the grid-following loop ----------------------------------------- - # ⚠️ SAFETY-10: `or self.shadow`. The loop is the leg a shadow run most - # needs and the one that is switched off by `auto_start: false`, so - # without this an install in `auto` shadow mode would record every - # strategy's decision and a permanent silence where the grid-following - # loop should be. It is safe because the loop's `prev_w` is - # `self.target`, which the end of this method re-seeds to what is - # actually standing on the wire. - # SAFETY-52: a refusal learned while control ran does not survive a - # stop or a start (shadow mode keeps the loop running when stopped). + # the grid-following loop ⚠️ SAFETY-10: `or self.shadow`. + # (DECISIONS/MAIN-orchestration-archive.md) if bool(self.auto) != self._refusal_auto: self._refusal_auto = bool(self.auto) self._refusal_end("control started" if self.auto @@ -5282,13 +3719,11 @@ class Controller: self._unit_refusals() _probing = {inv.name: self._unit_probe(inv) for inv in self.inverters if inv.refused} - # --- SAFETY-73: each refusing unit's own P_SAFETY claim --------------- - # A hard unit limit that always intersects (OPT-11's P_MODE layer sits - # inside it): closed in the refused direction, so the allocator hands - # the share to the others within their own limits. During its probe it - # is a pin at PROBE_W and the others carry the rest. Filed here, before - # the loop, so OPT-11's loop bound sees it. Unit claims only, and still - # the last unit claims filed, so every unit's claim order is as before. + # SAFETY-73: each refusing unit's own P_SAFETY claim A hard unit limit + # that always intersects (OPT-11's P_MODE layer sits inside it): closed + # in the refused direction, so the allocator hands the share to the + # others within their own limits. + # (DECISIONS/MAIN-orchestration-archive.md) for inv in self.inverters: if not inv.refused: continue @@ -5312,7 +3747,8 @@ class Controller: reason="maintenance drain", actuator=self.actuator_of("drain"))) self._peak_over_drain(claims, maint_results) - # --- SAFETY-86: while a HOLD gives, the site stays at the peak floor -- + # --- SAFETY-86: while a HOLD gives, the site stays at the peak floor + # -- # So the loop never asks for watts the hold keeps back. hold_hi = self._peak_over_hold(claims, maint_results) if hold_hi is not None: @@ -5341,19 +3777,12 @@ class Controller: self._loop_start = {} if (self.auto or self.shadow) and self.inputs_ok: # ⚠️ prev is the ARBITER's last output, not the loop's own last - # wish. If something outranked the loop, that is what the hardware - # actually did, and the controller must track reality or it jumps - # the moment it regains control. - # - # ⚠️ The integrator has to track the same reality, and it is no - # longer prev_w, so it needs saying out loud: if the arbiter did not - # give the loop what it asked for last cycle, the loop's - # accumulated error belongs to a command that never happened. - # Re-seed from what the hardware was actually told. This is the - # failsafe case too - every layer-1 stop resolves to 0 W, so - # entering failsafe re-seeds the integrator to zero and the first - # cycle after release starts from zero instead of dumping the whole - # stale period as power. + # wish. + # The integrator has to track the same reality, and it is no longer + # prev_w, so it needs saying out loud: if the arbiter did not give + # the loop what it asked for last cycle, the loop's accumulated + # error... + # (DECISIONS/MAIN-orchestration-archive.md) moved = self.loop_w is None or self.target != self.loop_w if moved: self.i_w = self.target @@ -5364,12 +3793,8 @@ class Controller: moved = False loop_grid = self._edge_grid(mode_trade or moved, now) # SAFETY-52: another writer overrode the loop, so a refusal learned - # against the loop's own ask may no longer describe the wire. A gap - # (`loop_w` None: blind or stopped) keeps it: the stale-reading stop - # is what seeds it, and dropping it there restarts the ~70 s cycle. - # SAFETY-107: it ends only if compute() no longer finds it against - # that writer's ask (`_refusal_update()`). A held refusal makes the - # loop ask 0 W, so any claim that binds differs from it every cycle. + # against the loop's own ask may no longer describe the wire. + # (DECISIONS/MAIN-orchestration-archive.md) rejudged = 0 if self.loop_w is not None and self.target != self.loop_w: rejudged = self.batt_refused @@ -5381,23 +3806,15 @@ class Controller: # SAFETY-95: then a drain that ended, last (it reads the start # the others left). self._reseed_drain_end(maint_results, _live96) - # No probe while it is judged afresh (the loop does not hold the wire). + # No probe while it is judged afresh (the loop does not hold the + # wire). probe = False if rejudged else self._refusal_probe() if len(self.inverters) > 1: probe = any(_probing.values()) # ⚠️ `actual_w` IS THE SITE SUM AND THAT IS DEV-02's OTHER HALF. - # The anti-windup test inside compute() is - # `abs(prev_w - actual_w) > saturation_w`, and `prev_w` is the SITE - # target. Handed ONE inverter's power at a roster of two it compares - # a site number against a unit number, permanently trips the - # saturation freeze and leaves a standing grid error - measured on - # admin/goodwe's two-master rig at up to 510 W of split-dependence - # (gain 0.2; 250 W at 0.4, 50 W at 0.6) while the allocator's shares - # summed to S in every run. With the sum, every split is - # bit-identical at every gain the schema accepts. That is why the - # per-unit readings became per-instance and THIS one became site - # level: the two halves of §6.2's identity break in opposite - # directions. See `site_batt_w` and test_alloc.py part 8. + # The anti-windup test inside compute() is `abs(prev_w - actual_w) + # > saturation_w`, and `prev_w` is the SITE target. + # (DECISIONS/MAIN-orchestration-archive.md) site_batt = self.site_batt_w # SAFETY-103: what each battery can be doing (its reading may be # older than the meter). @@ -5406,11 +3823,7 @@ class Controller: # so its own power does not freeze the loop (the meter has it). site_batt += self._held_out_w(_live96) # TEL-10. ⚠️ ONE NAME FOR THE COMMAND THE LOOP WAS MEASURED - # AGAINST. `self.target` is reassigned further down this method, so - # a `decision.i_w - self.target` written at publish time would - # subtract a DIFFERENT cycle's command and call the answer a lead. - # The published lead is built from the value `compute()` was - # actually handed, on the line that hands it over. + # AGAINST. (DECISIONS/MAIN-orchestration-archive.md) loop_prev = self.target decision = compute( prev_w=loop_prev, @@ -5456,51 +3869,15 @@ class Controller: if len(self.inverters) == 1: self._refusal_update(decision, loop_prev, site_batt, probe, rejudged) - # --- TEL-10 ------------------------------------------------------ - # ⚠️ THE INTEGRATOR HAS NEVER LEFT THIS PROCESS, AND THAT IS WHAT - # THE CARD IS. `self.i_w` is carried between cycles and published - # nowhere - no entity, no attribute, no log line - so the only way - # anyone has ever asked what it was doing is to RECONSTRUCT it from - # the grid trace and a replay. That reconstruction reproduced about - # a third of the engagements it was checked against and was thrown - # away; a card that has to be argued from a discarded reconstruction - # is a card that cannot be argued. - # - # Three rows, and the third is not derivable from the first two by - # anyone reading the dashboard afterwards: - # - # `loop_i` the integrator AFTER this cycle - `Decision.i_w`, - # which its own docstring says is the value BEFORE - # the output clamp, the slew limit and quantisation. - # It is therefore NOT `setpoint` and must never be - # published from it. - # `loop_lead` how far the integrator stands AHEAD of the command - # it was computed against. This is the quantity - # SAFETY-48 bounds, and `integrator_max_w / slew_w` - # is the number of cycles it takes to unwind. - # `loop_reason` `compute()`'s OWN reason string - `deadband`, - # `i-clamped`, `clamped`, `slew-limited`, - # `saturated-freeze`, `freeze-released`, `tracking`. - # - # ⚠️ AND `loop_reason` IS NOT `self.reason`. `self.reason` is the - # ARBITER's sentence (`resolution.explain()`, e.g. "loop -> 1200 W") - # and it does not carry the loop's own word at all: the Claim filed - # below takes `decision.reason` and `Resolution.explain()` prints the - # winner's NAME, not the winner's reason. The status page's "Why" - # row is the arbiter's sentence and stays that way. + # TEL-10 ⚠️ THE INTEGRATOR HAS NEVER LEFT THIS PROCESS, AND THAT + # IS WHAT THE CARD IS. + # AND `loop_reason` IS NOT `self.reason`. + # (DECISIONS/MAIN-orchestration-archive.md) self.loop_i_w = decision.i_w self.loop_i_lead = decision.i_w - loop_prev self.loop_reason = decision.reason - # --- SAFETY-03 --------------------------------------------------- - # ⚠️ READ THE BOOLEANS, NOT `decision.reason`. On a default install - # the integrator bound is what bites and the output clamp is - # unreachable (control.py says so), and `reason` is then overwritten - # by whichever of slew-limiting or the saturation freeze ran after - # it - so a string test is silent on the two cases a winding loop - # most often presents as. These flags are set where the clamp - # happens and nothing downstream touches them. - # SAFETY-40: every number comes from THIS decision, at clamp time, - # never from `self.target` after slew/freeze/arbiter rewrote it. + # SAFETY-03 ⚠️ READ THE BOOLEANS, NOT `decision.reason`. + # (DECISIONS/MAIN-orchestration-archive.md) self.loop_clamped = ( decision.i_clamped or decision.clamped, loop_clamp_note(decision), @@ -5511,8 +3888,8 @@ class Controller: "rating"),) if decision.clamped else ()) # SITE. compute() regulates `grid_w` - the P1 measurement - to # zero. Its subject is the meter, and §6.2's architecture is ONE - # integrator on P1 whose output is then SPLIT. This is the claim the - # allocator exists to divide. + # integrator on P1 whose output is then SPLIT. + # (DECISIONS/MAIN-orchestration-archive.md) claims.append(Claim.set("loop", P_LOOP, decision.target_w, decision.reason, actuator=self.actuator_of("loop"))) else: @@ -5537,34 +3914,21 @@ class Controller: self._loop_decision = None # SAFETY-28. A new resolution is a new tick, and the previous record is - # stale from this line onwards. Incremented BEFORE resolve() so that the + # stale from this line onwards. Incremented BEFORE resolve() so that + # the # record resolve() builds is stamped with the tick it is for. self.alloc_tick += 1 - # ⚠️ `weights=` IS OPT-09, AND ITS ABSENCE WAS THE CARD. ARCH-01 left the - # allocator one free parameter and ALLOC-01 built the machinery that - # consumes it (`allocator._fill`); this line is the first thing that has - # ever passed one. Without it every unit weighed 1.0 and the split was - # a fixed 50/50 regardless of what either pack could do. - # - # ⚠️ A METHOD, NOT A DICT. The weighting is by ENERGY HEADROOM and is - # therefore direction-dependent, and the direction is the sign of the - # site target - which stage 2 chooses inside `resolve()`. See - # `pack_weights()` and `packshare.weights()`; the arbiter calls it once, - # between stage 2 and stage 3. - # - # ⚠️ AND IT IS A PREFERENCE, NOT A PERMISSION. Nothing about the site's - # ability to DELIVER rests on this argument - a weight cannot stop a - # unit being commanded and cannot move a failing unit's load to its - # sibling. The `pack` claim filed above is the half that constrains. + # ⚠️ `weights=` IS OPT-09, AND ITS ABSENCE WAS THE CARD. + # A METHOD, NOT A DICT. + # (DECISIONS/MAIN-orchestration-archive.md) _live = self.live_units() while True: resolution = resolve(claims, units=self.units, live=_live, weights=self.pack_weights, tick=self.alloc_tick, reserve=self._held_back()) # SAFETY-73: a probe pin stands only while the resolved site target - # asks at least PROBE_W its way. Below that a sibling would carry - # the opposite direction (circulation), so the pin goes back to the - # closed limit and the split is resolved once more. + # asks at least PROBE_W its way. + # (DECISIONS/MAIN-orchestration-archive.md) _void = [k for k, c in enumerate(claims) if c.source == "refused" and c.kind == "set" and c.value * resolution.target_w < _ctl.PROBE_W ** 2] @@ -5578,29 +3942,11 @@ class Controller: self.allocation = resolution.allocation if self.modes_on: self._mode_fault_log(resolution) - # --- THE TWO REFUSALS, AND THEY ARE NOT THE SAME REFUSAL -------------- - # Both do the same thing - REPLACE the resolution with a 0 W failsafe - # which is then written like any other cycle at - # `write_setpoint(self.target)` below. Nothing is skipped. 0 W is the - # fail-toward-inaction direction: the setpoint is commanded to zero - # rather than left wherever the last cycle put it. They differ in WHO - # refused and therefore in what an operator should do about it, so they - # are two branches with two messages and not one with a vague one. - # - # ⚠️ AND NEITHER MAY BE LEFT UNGRADED. PR #40's reviewer replaced the - # first of these with `if False:` and the whole 17-file suite stayed - # green, because at the shipped roster of one it was unreachable. - # test_peakguard.py part 10h grades both, on real cycles. - # - # 1. THE ALLOCATOR REFUSED. Before ALLOC-01 this fired on every N>=2 - # cycle, because there was no allocator and the obvious placeholder - - # hand the site target to each inverter - IS the ARCH-01 fault. There - # is an allocator now and that trigger is gone; the branch is not, - # because refusing is still right when no safe split exists at all: a - # mis-routed claim (so the number to divide may itself be wrong), no - # inverter left in the envelope set, or a split that could only be - # made by commanding one pack to charge while another discharges. - # `allocator.plan()` carries the argument for each. + # THE TWO REFUSALS, AND THEY ARE NOT THE SAME REFUSAL Both do the same + # thing - REPLACE the resolution with a 0 W failsafe which is then + # written like any other cycle at `write_setpoint(self.target)` below. + # AND NEITHER MAY BE LEFT UNGRADED. + # (DECISIONS/MAIN-orchestration-archive.md) if not resolution.allocated: self.log_event("ARBITER: " + resolution.allocation.explain(), hint=messages.hint("split")) @@ -5612,26 +3958,8 @@ class Controller: allocation=resolution.allocation, winner_priority=resolution.winner_priority) # 2. THE ALLOCATOR SUCCEEDED AND THIS CONTROLLER CANNOT DELIVER IT. - # ⚠️ RE-AIMED BY DEV-02, NOT DELETED, AND THE DIFFERENCE MATTERS. - # Under ALLOC-01 this fired whenever the split named any inverter but - # the single one this controller could write, because there was one - # `setpoint_entity`. There is a writer per inverter now, so that - # trigger is gone - and the INSTINCT is not, because a roster entry - # can still have no writer: an inverter configured with no - # `setpoint_entity` of its own is an inverter this controller cannot - # put a number on. Writing the site target to the ones it CAN reach - # would over-command them by exactly the missing unit's share - - # not the broadcast fault, but the same shape of over-command. - # Commanding a partial site is a worse instruction than commanding - # nothing, and "nothing" here means 0 W and says so. - # - # ⚠️ IT IS NOT THE SAME EVENT AS A DEAD LINK, WHICH IS WHY IT IS NOT - # FOLDED INTO `live_units()`. A unit whose writes are being rejected - # is DROPPED and its share is carried by the survivors - a real, and - # announced, change in what the site can deliver. A unit with no - # writer configured at all is a CONFIGURATION that cannot be - # delivered, and the site stops rather than silently running at part - # strength on a roster somebody wrote down wrong. + # ⚠️ RE-AIMED BY DEV-02, NOT DELETED, AND THE DIFFERENCE MATTERS. + # (DECISIONS/MAIN-orchestration-archive.md) elif {u for u in resolution.shares if not self._can_write(u)}: undeliverable = ", ".join( sorted(u for u in resolution.shares if not self._can_write(u))) @@ -5641,13 +3969,8 @@ class Controller: f"setpoint entity for {undeliverable} - refusing to command a " f"partial site", hint=messages.hint("no_setpoint")) # ⚠️ `shares={}`, NOT THE SHARES IT REFUSED TO DELIVER, AND SINCE - # DEV-02 THAT IS LOAD-BEARING RATHER THAN TIDY. The write loop below - # commands each inverter `shares.get(name, 0.0)`, so carrying the - # split through a refusal would write the very numbers this branch - # just declined - and, worse, would write NOTHING at all when they - # equal what is already standing, because an unchanged value is not - # re-sent. The record the operator needs survives on - # `self.allocation`, which is assigned above and untouched here. + # DEV-02 THAT IS LOAD-BEARING RATHER THAN TIDY. + # (DECISIONS/MAIN-orchestration-archive.md) resolution = Resolution(0.0, "failsafe", None, False, resolution.considered, unit_bounds=resolution.unit_bounds, @@ -5656,11 +3979,9 @@ class Controller: allocation=resolution.allocation, winner_priority=resolution.winner_priority) # 3. AC 6: A DROPPED INVERTER IS NEVER SILENT. The survivors carry its - # share, which is a real change in what the site can deliver, and an - # operator who is not told reads a working site. Edge-triggered on - # the SET of dropped units, not level-triggered, because the action - # log is a log of events (§15) and a stale link would otherwise write - # a line every second for as long as it lasts. + # share, which is a real change in what the site can deliver, and an + # operator who is not told reads a working site. + # (DECISIONS/MAIN-orchestration-archive.md) _dropped = tuple(u for u, _why in (self.allocation.dropped if self.allocation else ())) if _dropped != self._dropped_last: @@ -5674,15 +3995,8 @@ class Controller: hint=messages.hint("units_back")) self._dropped_last = _dropped # 3b. OPT-09 / IF-04: WATTS NOBODY COULD TAKE ARE NEVER SILENT EITHER. - # `unmet_w` is the site target the envelopes could not absorb. It is - # NOT a broken allocation - `Allocation`'s own docstring says why - # fusing it with `ok` would let one saturated inverter park the whole - # site - but it IS the site failing to deliver what the loop asked - # for, and before this card nothing anywhere reported it. It is the - # state a closed envelope produces when BOTH packs are at their - # limits: the ask has nowhere left to go, and an operator watching a - # standing grid error needs the reason rather than the symptom. - # Edge-triggered on the VALUE, for `_dropped` reason. + # `unmet_w` is the site target the envelopes could not absorb. + # (DECISIONS/MAIN-orchestration-archive.md) _unmet = int(self.allocation.unmet_w if self.allocation else 0) if _unmet != self._unmet_last: if _unmet: @@ -5695,37 +4009,25 @@ class Controller: hint=messages.hint("unmet_gone")) self._unmet_last = _unmet if resolution.scope_errors: - # A claim reached the arbiter on an actuator its source's scope does - # not permit. It is a BUG in the filing site, not a state to survive: - # a per-inverter constraint in the site list is the `lo > hi` this - # card removes, and a site constraint on one unit is a bound the - # other unit never gets. Loud, every cycle, and it does not change - # the resolution - the arbiter already routed by `actuator`. + # A claim reached the arbiter on an actuator its source's scope + # does not permit. (DECISIONS/MAIN-orchestration-archive.md) _LOG.error("A limit was filed on the wrong inverter, which is a " "software fault. %s.", " / ".join(resolution.scope_errors)) if resolution.contradiction: self.log_event("ARBITER CONTRADICTION - commanding 0 W, see the log", hint=messages.hint("clash")) - # --- SAFETY-02's arming gate ------------------------------------------ - # Was the number about to go on the wire chosen by the grid-following - # loop, or by a strategy that commands against the meter on purpose? - # Only the former is expected to agree in sign with P1 - without this, - # every maintenance grid charge trips the failsafe within three minutes. - # - # ⚠️ THE EXPRESSION LIVES IN watchdog.py, NOT HERE, and that is not - # tidiness. It was inline, test_watchdog.py held a transcription of it, - # and the P_PEAK case meant to force whoever lands peak shaving to come - # and change an expectation did not: editing this line alone to - # `winner in ("loop", "peak")` left every suite green while the watchdog - # went blind for every peak event. Read loop_is_following() before - # touching the gate - the forward hazard and the reasoning are there. + # SAFETY-02's arming gate Was the number about to go on the wire + # chosen by the grid-following loop, or by a strategy that commands + # against the meter on purpose? + # THE EXPRESSION LIVES IN watchdog.py, NOT HERE, and that is not + # tidiness. + # (DECISIONS/MAIN-orchestration-archive.md) self.following = loop_is_following(resolution, self.loop_w) self.target = resolution.target_w self.reason = resolution.explain() # UX-08: the same answer in plain words, for the page's "Why" line. # `reason` stays the technical record the log and the checks read. - # UX-10: `why_key()` maps a safety winner to its claim's reason, and - # every key it can return has words in messages.WHY_WINNER. + # (DECISIONS/MAIN-orchestration-archive.md) _why = why_key(resolution.winner, claims) if _why == "inputs missing" and not resolution.contradiction: # UX-11: past the hold, say which reading is missing. @@ -5736,25 +4038,10 @@ class Controller: _why, resolution.target_w, resolution.bound_by, contradiction=resolution.contradiction, raised=why_raised(resolution, claims)) - # --- DEV-02: EACH INVERTER IS TOLD ITS OWN SHARE --------------------- - # ⚠️ ITS OWN SHARE, NEVER THE SITE TARGET. `resolution.shares` is the - # allocator's answer and `sum(shares) + unmet_w == round(target_w)` is - # an integer identity, so handing each unit its entry is what makes the - # gain at the meter a CONVEX COMBINATION of the per-unit gains. Handing - # each unit `self.target` is the ARCH-01 fault: each supplies it in - # full, the gain becomes N times the per-unit gain, and on the - # two-master rig that is 4000 W peak-to-peak forever at gain 1.0 with - # not one guard firing. - # - # ⚠️ AT N=1 THIS IS BYTE-IDENTICAL. The allocator's pass-through returns - # `{inverter-a: target_w}` as an unrounded float, so the number written - # is `self.target` exactly, as it always was. - # - # ⚠️ AND A UNIT NOT IN `shares` IS COMMANDED 0 W, not left standing. - # That is the dropped-link case: the inverter holds its last command - # FOREVER, so "not in the split" must mean zero on the wire, not - # silence. The write is the same clamped, budgeted path as every other. - # SAFETY-85, before the writes: the wire still holds last cycle's. + # DEV-02: EACH INVERTER IS TOLD ITS OWN SHARE ⚠️ ITS OWN SHARE, NEVER + # THE SITE TARGET. + # AT N=1 THIS IS BYTE-IDENTICAL. + # (DECISIONS/MAIN-orchestration-archive.md) self._loop_alarm_input(resolution) writes = [] for inv in self.inverters: @@ -5762,158 +4049,69 @@ class Controller: writes.append(self.write_setpoint(inv.target, unit=inv)) await asyncio.gather(*writes) - # --- SAFETY-03, and its boundary is the WRITE ------------------------ - # ⚠️ NOT THE SAME BOUNDARY AS SAFETY-18's BELOW, THOUGH THE CALLS ABUT. - # This one reads `self.clamp_last`, which is None until write_setpoint() - # has actually run, so it has to follow the write. The one below grades - # `self.target`, which is assigned further up - see check_output_clamp() - # and check_meter_frozen() for each. Two guards, two orderings, adjacent - # lines: do not "tidy" them into one rule. - # - # Moving it BELOW the other guards is harmless - nothing between here and - # the end of cycle() writes `self.clamp_last`. Moving it above the write - # is not, and test_clamp.py part 6 fails on exactly that: it grades the - # evidence, not the line number, so a rename or a move into a helper that - # still runs after the write keeps it green. (TEST-11.) + # SAFETY-03, and its boundary is the WRITE ⚠️ NOT THE SAME BOUNDARY AS + # SAFETY-18's BELOW, THOUGH THE CALLS ABUT. + # (DECISIONS/MAIN-orchestration-archive.md) await self.check_output_clamp() - # --- SAFETY-18, and its boundary is the ASSIGNMENT -------------------- - # ⚠️ THE BOUNDARY IS `self.target = resolution.target_w` ABOVE, NOT - # write_setpoint(). This comment used to say "the command that has just - # gone on the wire", which is wrong twice over and was corrected by - # TEST-06's reviewer: `check_meter_frozen()` grades `self.target` - the - # arbiter's resolved intent - and `write_setpoint()` clamps only a local - # `value` and can skip the write entirely, so that number may never have - # gone anywhere. - # - # What both terms of the conjunction must describe is the SAME moment: - # the command this cycle resolved, against how long the meter that chose - # it has been standing still. Evaluated before - # `self.target = resolution.target_w` it would grade the PREVIOUS - # cycle's command against THIS cycle's meter - the one combination that - # was true of neither moment. Moving it between the assignment and - # write_setpoint() is therefore harmless; moving it above the assignment - # is not, and test_freeze.py part 5 fails on exactly that. + # SAFETY-18, and its boundary is the ASSIGNMENT ⚠️ THE BOUNDARY IS + # `self.target = resolution.target_w` ABOVE, NOT write_setpoint(). + # (DECISIONS/MAIN-orchestration-archive.md) await self.check_meter_frozen() - # --- PRICE-01: no price for now, said once and taken down once -------- + # --- PRICE-01: no price for now, said once and taken down once + # -------- # Not a safety check, so it is one of the cheap ones: an attribute test # and, on a change only, one Home Assistant call. await self.check_price_gap() await self.check_fixed_note() - # --- SAFETY-31's alarm, and its boundary is the LATCH, not a number --- + # SAFETY-31's alarm, and its boundary is the LATCH, not a number # Unconstrained where the two above are not: it grades - # `self.circ_wd.tripped`, which was advanced near the top of this method - # beside SAFETY-02 and which nothing since has touched. It is called on - # EVERY cycle rather than only on the cycle that trips, because its - # second job is re-raising the alarm after a restart into an existing - # trip - see its docstring on why that shares one code path with the - # fresh trip instead of getting its own. + # `self.circ_wd.tripped`, which was advanced near the top of this + # method beside SAFETY-02 and which nothing since has touched. + # (DECISIONS/MAIN-orchestration-archive.md) await self.check_circulating() - # --- SAFETY-42's alarm, and its boundary is the LATCH, like SAFETY-31's - # It grades `inv.delivery_wd.tripped`, which was advanced near the top - # of this method beside SAFETY-02 and SAFETY-31 and which nothing since - # has touched. Called on EVERY cycle rather than only on the cycle that - # trips, because its second job is re-raising the alarm after a restart - # into an existing trip - see its docstring on why that shares one code - # path with the fresh trip instead of getting its own. + # SAFETY-42's alarm, and its boundary is the LATCH, like SAFETY-31's It + # grades `inv.delivery_wd.tripped`, which was advanced near the top of + # this method beside SAFETY-02 and SAFETY-31 and which nothing since + # has touched. (DECISIONS/MAIN-orchestration-archive.md) await self.check_not_delivering() - # --- SAFETY-07's alarm, and it is NOT the engagement ------------------ - # Its boundary is unconstrained: it grades `self.peak_guard`, which this + # --- SAFETY-07's alarm, and it is NOT the engagement + # ------------------ + # Its boundary is unconstrained: it grades `self.peak_guard`, which + # this # method assigned above and nothing since has touched. await self.check_peak_saturated() - # --- SAFETY-24's alarm, and it is the OTHER way the money gets spent -- - # Same boundary argument as check_peak_saturated(): it grades + # SAFETY-24's alarm, and it is the OTHER way the money gets spent Same + # boundary argument as check_peak_saturated(): it grades # `self.peak_guard`, which cycle() assigned above and nothing since has - # touched. Kept as its own method rather than folded into that one, - # because the two states are mutually exclusive and want OPPOSITE - # operator actions - saturation says "shed load, the inverter is already - # flat out"; this says "the pack is at its floor and this quarter is not - # being shaved at all". + # touched. (DECISIONS/MAIN-orchestration-archive.md) await self.check_peak_floor_limited() - # --- TARIFF-01, and it is LAST on purpose ----------------------------- - # Banking a quarter is bookkeeping about a moment that has already - # happened; a slow SD card must never sit between the meter and the - # setpoint. Its position is unconstrained in the way SAFETY-03's and - # SAFETY-18's are not - it grades neither `target` nor `standing` and - # reads only `self.p1`, which no line in this method writes. + # TARIFF-01, and it is LAST on purpose Banking a quarter is + # bookkeeping about a moment that has already happened; a slow SD card + # must never sit between the meter and the setpoint. + # (DECISIONS/MAIN-orchestration-archive.md) self.peak.sync(self.p1) - # --- SAFETY-10, AND IT IS LAST FOR A REASON THAT IS NOT TIDINESS ----- - # Every guard above has now read `self.target` - the resolved intent - - # and SAFETY-18 in particular grades exactly that. Re-seeding before - # them would hand each one a 0 W that no decision produced. Re-seeding - # AFTER them hands the NEXT cycle, and the sign watchdogs, what is - # actually standing on the wire. See `_shadow_settle()`. + # SAFETY-10, AND IT IS LAST FOR A REASON THAT IS NOT TIDINESS Every + # guard above has now read `self.target` - the resolved intent - and + # SAFETY-18 in particular grades exactly that. + # (DECISIONS/MAIN-orchestration-archive.md) self._shadow_settle(resolution) def _shadow_settle(self, resolution) -> None: """SAFETY-10. Record the decision, then re-seed the carried command. - - TWO JOBS, AND THEY ARE IN THIS ORDER BECAUSE THE SECOND DESTROYS THE - FIRST'S EVIDENCE. - - 1. THE RECORD. A shadow run is only worth running if it answers "what - would it have done, and why". `self.shadow_would_w` and - `Resolution.explain()` are that answer, and the action log gets one - line per CHANGE OF DECISION rather than one per cycle - the log - holds 40 rows, and a level-logged shadow run would flush the context - of its own findings inside four minutes at a 15 s cycle floor. The - same edge rule the peak guard and the arbitrage strategy already - use, for the same reason. - - 2. THE RE-SEED. `self.target` and each `inv.target` are set back to - `shadow.WIRE_W` - what the write gate actually put on the wire. - - ⚠️ WHY THE RE-SEED IS A SAFETY PROPERTY AND NOT BOOKKEEPING. Three - machines read the carried command as a statement about the HARDWARE: - - * `compute(prev_w=self.target, ...)` - the loop's slew limiter and - its integrator. Left holding a shadow decision, the first cycle - after shadow mode is turned off would slew from a base that never - existed: up to a full `slew_w` step onto a battery sitting at 0 W. - Re-seeded, the exit is safe BY CONSTRUCTION and needs no separate - transition code - the carried command already equals the standing - one. - * `Inverter.standing_w()`, which is what SAFETY-02/30's sign - watchdogs grade. Fed a shadow decision they would compare a number - against a meter that never saw it, disagree for as long as the - decision asked for power, and LATCH A TRIP THAT SURVIVES INTO LIVE - OPERATION AND A RESTART. Shadow mode would manufacture the failsafe - it exists to help debug. ⚠️ THIS ONE IS ALREADY HELD BY THE WIRE - GATE - `standing_w()` prefers `last_write`, and that is the 0.0 W - the gate accepted - so the re-seed is the SECOND rung, covering - the fallback before any write has been accepted. Two rungs - answering one question is MUTATION.md's masking shape, and the - mutation that reproduces the hazard has to defeat BOTH: driven - singly, dropping this line leaves the detector inert and names - nothing about the latch. Measured. - * SAFETY-42's delivery watchdog compares `inv.target` against what - the pack is producing. A share nothing was told to produce is a - delinquency report on every cycle of every shadow run. - - ⚠️ AND EVERY CYCLE'S DECISION IS THEREFORE COMPUTED FROM WHERE THE - PLANT REALLY IS, not from a counterfactual plant that drifts further - from reality every tick. "What would this controller command NOW, - given this meter and a battery at 0 W" is a true statement about the - present; "what would the eleventh cycle of a run that never happened - have commanded" is not a statement about anything. - """ + WHY THE RE-SEED IS A SAFETY PROPERTY AND NOT BOOKKEEPING. + (DECISIONS/MAIN-orchestration-archive.md)""" engaged = self.shadow if engaged != self._shadow_was: - # ⚠️ `first` EXISTS SO A LIVE INSTALL'S FIRST CYCLE IS SILENT. The - # edge is against `None` on cycle one, and an install that comes up - # CONTROLLING has nothing to report - a "shadow mode off" line at - # every start is one more row of boilerplate in a 40-row log, and - # it was measured pushing a real clamp episode's context out of - # test_clamp.py part 2's ring. Coming up IN shadow mode is the - # other direction and it is always worth a line: it is the log an - # operator reads to find out why nothing is being commanded. + # ⚠️ `first` EXISTS SO A LIVE INSTALL'S FIRST CYCLE IS SILENT. + # (DECISIONS/MAIN-orchestration-archive.md) first = self._shadow_was is None self._shadow_was = engaged if engaged: @@ -5942,26 +4140,12 @@ class Controller: self._shadow_winner = None return self.shadow_would_w = float(resolution.target_w) - # SAFETY-47. ⚠️ NO LOGIC HERE, DELIBERATELY. All three rungs - the - # arbiter's winner, the leg being a CHARGE, and the headroom having - # been MEASURED - live inside `arbitrage.sized_from_meter()`, which - # says why, including why it is NOT keyed on whether the trade was - # capped. A rung written at a call site is a rung no check can reach - # with the other two clear, which is TEST-16's rule. + # SAFETY-47. ⚠️ NO LOGIC HERE, DELIBERATELY. + # (DECISIONS/MAIN-orchestration-archive.md) self.shadow_sized_from_meter = arbitrage.sized_from_meter( self.arb, self.arb_headroom_w, winner=resolution.winner) - # ⚠️ THE WINNER AND THE ROUNDED WATTS, NOT THE WATTS ALONE. A decision - # that moves from the loop to the peak guard at the same number is a - # different decision and the operator needs the line; a loop target - # that wanders by a watt is not, and 15 s of those would be the whole - # log. - # ⚠️ SAFETY-47 ADDS THE CAVEAT FLAG TO THE EDGE. The same winner at the - # same watts with and without the caveat are two different claims about - # the same number, and an edge key blind to that would print the first - # one and silently keep it standing. It cannot flood: the flag only - # moves when arbitrage starts or stops owning the resolution, or when - # the guard starts or stops measuring the quarter, and both of those - # move the winner or the watts as well on every cycle measured. + # ⚠️ THE WINNER AND THE ROUNDED WATTS, NOT THE WATTS ALONE. + # (DECISIONS/MAIN-orchestration-archive.md) edge = (resolution.winner, round(float(resolution.target_w)), self.shadow_sized_from_meter) if edge != self._shadow_winner: @@ -5977,9 +4161,7 @@ class Controller: def _loop_alarm_input(self, resolution) -> None: """SAFETY-85. The loop's clamp counts only on a cycle the loop decided - (a rating may cut it). A battery another owner set to discharge counts - when it gave less than it was told for `saturation_cycles` cycles in a - row. DECISIONS/SAFETY-85-no-false-not-keeping-up.md.""" + (a rating may cut it). (DECISIONS/MAIN-orchestration-archive.md)""" d = self._loop_decision if d is None: self._short_run = 0 @@ -6022,15 +4204,8 @@ class Controller: self.loop_clamp_facts = () def _settling(self, owns, now) -> bool: - """SAFETY-119: does the loop's bound on this cycle count? Not within - a settle after another owner handed the wire back: its integrator is - re-seeded from that owner's command and kicked by a meter that does - not show it yet. SAFETY-121: the settle is HANDBACK_CYCLES cycles and - SHORT_SPAN_S after the hand-back, so a bound that outlasts it counts - at any cycle length; losing the wire restarts it. Across all - hand-backs the bound is hidden on at most ROLLING_BOUND_CYCLES cycles - in any ROLLING_S, so a bound that never outlasts a settle is counted - too. DECISIONS/SAFETY-121-bound-every-quarter.md.""" + """SAFETY-119: does the loop's bound on this cycle count? + (DECISIONS/MAIN-orchestration-archive.md)""" def aged(t0, span): return now is None or t0 is None or now - t0 >= span @@ -6058,29 +4233,10 @@ class Controller: async def check_output_clamp(self) -> None: """SAFETY-03. Alarm whenever a clamp actually engaged this cycle. - - Runs AFTER write_setpoint() for the same reason SAFETY-18 does: the - clamp is arithmetic done inside the write, so before the write there is - nothing yet to report. - - ⚠️ PUBLISHING IS NOT IN THE WRITE PATH. The clamp itself is synchronous - inside write_setpoint(); this - the awaits, the notification, anything - that can block on Home Assistant - is out here. A stalled supervisor must - never be able to delay or drop a frame. - - ⚠️ THIS IS NOT A TRIP. No claim is filed, nothing is latched, 0 W is not - commanded and `publish()` still reports "running". §11.2's action for - this row is "clamp and alarm", and the clamped value is written and the - loop keeps running. Turning this into a trip means coming here and - changing that deliberately, not discovering it in the field. - """ - # ⚠️ THE EVIDENCE IS PER UNIT AND THE ALARM IS NOT. `clamp_last` moved - # onto the Inverter because a clamp on B says nothing about A; the - # ClampWatchdog stayed one per controller because clamp.py's own - # docstring says so - it is an anti-flood counter over an EVENT BUS, not - # a property of a device, and one notification per inverter per cycle is - # the flood it exists to remove. So: any unit that clamped is an - # engagement, and the reason NAMES the unit. + PUBLISHING IS NOT IN THE WRITE PATH. + (DECISIONS/MAIN-orchestration-archive.md)""" + # ⚠️ THE EVIDENCE IS PER UNIT AND THE ALARM IS NOT. + # (DECISIONS/MAIN-orchestration-archive.md) clamped_units = [inv for inv in self.inverters if inv.clamp_last is not None and inv.clamp_last.engaged] engaged_write = bool(clamped_units) @@ -6104,24 +4260,11 @@ class Controller: fired = self.clamp_wd.note(engaged, reason, datetime.now(timezone.utc), bug=bug, facts=facts) - # --- THE ACTION LOG, AND IT IS COALESCED PER EPISODE ------------------ - # Every engagement is recorded, but consecutive ones are recorded by - # UPDATING one row's count rather than inserting a row each. - # - # ⚠️ THAT IS NOT COSMETIC, AND IT WAS FOUND THE HARD WAY. `log_event()` - # keeps 40 rows; a winding loop clamps on every cycle, so a row per - # engagement flushes the whole ring within 40 cycles - and the first - # thing it evicted, in test_freeze.py, was SAFETY-18's "METER FROZEN?" - # row. A guard whose log drowns out the other guards' logs has moved the - # flood rather than removed it. The count here is `clamp_wd.engagements`, - # so nothing is lost: the row says exactly how many, and the number is - # read off the counter rather than off the number of rows. - # - # This lives here rather than only in write_setpoint() because on a - # default install the actuator-side clamp is unreachable - compute() has - # already bounded the integrator - so the only engagements that exist are - # loop-side ones. Measured: 60 cycles of the §10.3 runaway produce 0 - # write-path engagements and 2 loop-side ones. + # THE ACTION LOG, AND IT IS COALESCED PER EPISODE Every engagement is + # recorded, but consecutive ones are recorded by UPDATING one row's + # count rather than inserting a row each. + # THAT IS NOT COSMETIC, AND IT WAS FOUND THE HARD WAY. + # (DECISIONS/MAIN-orchestration-archive.md) if engaged and not engaged_write: tag = f"{CLAMP_REASON} episode {self.clamp_wd.episodes}" row = (f"{tag}: " @@ -6160,46 +4303,19 @@ class Controller: "dashboard read a legal number while the thing producing it goes " "on climbing. This is an alarm, not a trip.") # A fixed notification_id, for the reason check_meter_frozen() gives - - # and a DIFFERENT one from every other alarm this add-on raises, because - # a clamp is not a frozen meter and neither is a saturation freeze. - # UX-08: the words are messages.output_clamp()'s; the numbers are the - # ones `reason` was written from (ClampWatchdog.facts), asked first. + # and a DIFFERENT one from every other alarm this add-on raises, + # because a clamp is not a frozen meter and neither is a saturation + # freeze. (DECISIONS/MAIN-orchestration-archive.md) await self.hass.call("persistent_notification", "create", messages.output_clamp(self.clamp_wd.facts, shadow=self.shadow).data()) async def check_peak_saturated(self) -> None: """SAFETY-07. Alarm ONLY when the guard has run out of inverter. - - ⚠️ AN ENGAGEMENT IS NOT AN ALARM, AND THE DIFFERENCE IS §10.3's DOCTRINE - READ CAREFULLY. "Treat an out-of-range computed setpoint as a bug signal - - clamp it, then alarm" is SAFETY-03's rule and it is right there: the - loop computing 14 768 W is a WINDING INTEGRATOR, a defect, and clipping - it silently is what let it keep climbing behind a dashboard full of legal - numbers. A peak floor above the inverter's rating is not a defect. It is - a house load bigger than the inverter - 2026-08-20 15:45 ran ~5900 W - against a 5000 W unit - and alarming on the ordinary case is how an - operator learns to dismiss the notification that matters. So an - engagement is an ordinary control action: it goes to the §15 action log - and the status page, and no further. - - What DOES earn the operator's attention is saturation: the guard asked - for more discharge than the inverter can give, so the quarter is going - over the ceiling anyway and the controller has already done everything - available to it. That is the one state where the money is going to be - spent and a human is the only remaining actuator. - - SAFETY-81: `g.saturated` alone is not enough. It is graded against the - aim, and a 1 s meter spike, noise or the last seconds of a quarter set - it while the quarter lands on target; `peakguard.load_too_high()` must - agree too, for `ALARM_HOLD_S`. The battery reading it gets is the lower - of this cycle's and the last one's - see there. - - Edge-triggered, and de-asserting arms it again - the same shape as - FreezeWatchdog's `evaluate()`, and for the same reason: one notification - per episode, not one per cycle. SAFETY-81: the notification and action - log line at most once per quarter; the ERROR line once per episode. - """ + AN ENGAGEMENT IS NOT AN ALARM, AND THE DIFFERENCE IS §10.3's DOCTRINE + READ CAREFULLY. "Treat an out-of-range computed setpoint as a bug + signal - clamp it, then alarm" is SAFETY-03's rule and it is... + (DECISIONS/MAIN-orchestration-archive.md)""" g = self.peak_guard batt, prev = self.site_batt_w, self._peak_batt_prev if batt is None: @@ -6244,7 +4360,8 @@ class Controller: self.log_event("PEAK_GUARD_SATURATED - " + g.reason, hint=messages.hint("peak_saturated")) # A fixed notification_id, and a DIFFERENT one from every other alarm - # here - check_meter_frozen() gives the reasoning. A saturated peak guard + # here - check_meter_frozen() gives the reasoning. A saturated peak + # guard # is not a frozen meter and is not an out-of-range setpoint. await self.hass.call("persistent_notification", "create", messages.peak_saturated( @@ -6252,43 +4369,8 @@ class Controller: async def check_peak_floor_limited(self) -> None: """SAFETY-24. Alarm when the EMS SoC floor stops the guard shaving. - - ⚠️ FAILING TO SHAVE MUST NOT BE SILENT, AND THAT IS THE WHOLE POINT. - The guard exists for one euro figure - the capaciteitstarief bills the - month's single worst quarter - so a cycle in which it CHOSE not to shave - is a cycle in which money is being spent on purpose. Under-shaving - quietly would leave the operator with a bill and a status page full of - green. - - The distinction from check_peak_saturated() is the operator's action, - which is why these are two notifications and not one: - - saturated the guard asked for everything the inverter has and it - was not enough. SHED LOAD - the controller has already - done all it can, and it is still discharging. - floor-limited the guard asked for NOTHING, because the pack is at or - under the EMS floor, or its SoC could not be read. - Nothing is being discharged for this quarter at all. - (SAFETY-87, N>=2: or the batteries above their - reserve give all they can and it is not enough.) - - They cannot both be true: peakguard.decide()'s floor-limited branch - returns before the `achievable_w` clamp that sets `saturated`. - - ⚠️ SAFETY-25 - AND THE MESSAGE SAYS WHAT THE FLOOR IS NOT. Read on its - own, "the capacity-peak guard has run out of battery" invites exactly - one wrong inference: that the pack is now being held at - `peak_soc_floor`. It is not. `peak_soc_floor` bounds THIS GUARD only; - the grid-following loop has no SoC floor at all and, on the committed - 2026-08-20 replay, is being granted discharge in EVERY cycle that raises - this notification. An operator who reads the alarm and relaxes because - "the EMS has a 20 % reserve" has been misled by us, so the alarm carries - the correction itself rather than leaving it to DOCS.md. - - Edge-triggered, and de-asserting arms it again - FreezeWatchdog's shape, - for FreezeWatchdog's reason: one notification per episode, not one per - cycle for a quarter of an hour. - """ + FAILING TO SHAVE MUST NOT BE SILENT, AND THAT IS THE WHOLE POINT. + (DECISIONS/MAIN-orchestration-archive.md)""" g = self.peak_guard limited = bool(g is not None and g.floor_limited) if limited and (g.floor_w or 0.0) > 0.0: @@ -6303,28 +4385,15 @@ class Controller: return # No prefix: `reason` already opens with PEAK_GUARD_FLOOR_LIMITED, and # a second copy is the kind of stutter that makes a 40-line action log - # read like boilerplate. The saturated path needs its prefix because its - # reason carries the word in the middle of a sentence, not at the front. + # read like boilerplate. (DECISIONS/MAIN-orchestration-archive.md) self.log_event(g.reason, hint=messages.hint("peak_reserve")) _LOG.error( "Switch off large loads, the peak guard may not draw the battery " "lower: %s.", messages.reason_code(g.reason)) _LOG.debug("Evidence: %s.", g.reason) # A fixed notification_id, and a DIFFERENT one from every other alarm - # here - check_meter_frozen() gives the reasoning. A battery at its - # energy floor is not a saturated inverter and is not a frozen meter, - # and an operator who has to dismiss three identical-looking cards - # learns to dismiss the one that mattered. - # ⚠️ SAFETY-25's sentence must render on the unreadable-SoC branch too, - # where `soc_floor` is a number but naming it would invite the reader to - # think the pack is AT it. `decide()` guards its own reason string the - # same way; a f-string that formats a None here would turn the one - # notification that matters into a traceback. - # SAFETY-32/87: which battery is at its reserve, each graded on its own - # floor (`g.held`). At a roster of one no battery is named. - # ⚠️ SAFETY-25's sentence (the reserve binds the peak guard only) is - # part of messages.peak_reserve() on every branch, the unreadable-SoC - # one included. + # here - check_meter_frozen() gives the reasoning. + # (DECISIONS/MAIN-orchestration-archive.md) await self.hass.call("persistent_notification", "create", messages.peak_reserve( g.ceiling_w, soc_unread=g.soc is None, @@ -6358,36 +4427,16 @@ class Controller: async def check_circulating(self) -> None: """SAFETY-31 + SAFETY-34. The alarm, and it must survive a restart. - - ⚠️ THE LATCH IS NOT THE ALARM. `circ_tripped` in `/data/state.json` - survives everything, but nothing looks at a JSON file at 3 a.m. What the - operator sees is a `persistent_notification`, and THAT IS IN MEMORY: a - Home Assistant restart deletes it while the fault runs on. This is not a - hypothetical - it is precisely how the 2026-09-02 outage went unseen for - 6 h 25 m with the detector correct the whole time (SAFETY-34). - - So the durable half and the visible half are joined HERE, and the join - is `_circ_announced`: a per-PROCESS flag, deliberately not persisted. - The latch says "this site is tripped"; the flag says "this process has - put that in front of a human". A restart clears the flag and keeps the - latch, so the notification is RE-RAISED on the first cycle after every - add-on start. A Core-only restart does NOT restart the add-on, so it is - also re-asserted every REANNOUNCE_S (SAFETY-34). - - ⚠️ ONE CODE PATH FOR BOTH, ON PURPOSE. A fresh trip and a restart into - an existing trip raise the alarm through these same lines. Two paths - would be two chances for the restart one to rot unnoticed, and the - restart one is the case nobody exercises by hand. - """ + THE LATCH IS NOT THE ALARM. + (DECISIONS/MAIN-orchestration-archive.md)""" if not self.circ_wd.tripped: return if self._circ_announced and not self._reannounce_due("goodwe_circulating_power"): return # Set BEFORE the await. `hass.call()` can raise or hang, and a retry # every cycle for a broker that is down would be an alarm storm on the - # event bus on top of a site that is already stopped. The latch is - # already durable and the log line above already fired; this surface is - # best-effort by design. + # event bus on top of a site that is already stopped. + # (DECISIONS/MAIN-orchestration-archive.md) self._circ_announced = True self._announced_mono["goodwe_circulating_power"] = _mono() names = self.units @@ -6408,39 +4457,8 @@ class Controller: async def check_not_delivering(self) -> None: """SAFETY-42 + SAFETY-34. The alarm, and it must survive a restart. - - ⚠️ THE LATCH IS NOT THE ALARM, and this guard has nothing BUT the alarm. - `unit//delivery_tripped` in `/data/state.json` survives everything, - but nothing looks at a JSON file at 3 a.m. What the operator sees is a - `persistent_notification`, and THAT IS IN MEMORY: a Home Assistant - restart deletes it while the fault runs on. That is precisely how the - 2026-09-02 outage went unseen for 6 h 25 m with the detector correct the - whole time (SAFETY-34), and it matters more here than anywhere else in - this file, because SAFETY-02 and SAFETY-31 at least leave a stopped site - for somebody to notice. This one leaves a site that looks like it is - working and is quietly delivering one unit less than it has. - - So the durable half and the visible half are joined HERE, and the join - is `_delivery_announced`: a per-PROCESS set of unit names, deliberately - not persisted. The latch says "this unit is tripped"; membership says - "this process has put that in front of a human". A restart empties the - set and keeps the latch, so the notification is RE-RAISED on the first - cycle after every add-on start, and re-asserted every REANNOUNCE_S - because a Core-only restart does not restart the add-on (SAFETY-34). - - ⚠️ ONE CODE PATH FOR BOTH, ON PURPOSE - `check_circulating()`'s - argument. A fresh trip and a restart into an existing trip raise the - alarm through these same lines; two paths would be two chances for the - restart one to rot unnoticed, and the restart one is the case nobody - exercises by hand. - - ⚠️ AND ONE NOTIFICATION ID PER UNIT. Every other alarm in this file has - a single fixed id because its subject is the site; this one's subject is - a named box, and two wedged inverters must produce two cards rather than - one replacing the other. The id is built from the same `unit_slugs()` - TEL-07 announces the MQTT entities under, so the card and the entity an - operator correlates it with carry the same token. - """ + THE LATCH IS NOT THE ALARM, and this guard has nothing BUT the alarm. + (DECISIONS/MAIN-orchestration-archive.md)""" names = self.units slugs = unit_slugs(names) for inv, slug in zip(self.inverters, slugs): @@ -6451,25 +4469,16 @@ class Controller: continue # Added BEFORE the await, for `check_circulating()`'s reason: # `hass.call()` can raise or hang, and a retry every cycle for a - # broker that is down would be an alarm storm on the event bus. The - # latch is already durable and the log line already fired; this - # surface is best-effort by design. + # broker that is down would be an alarm storm on the event bus. + # (DECISIONS/MAIN-orchestration-archive.md) self._delivery_announced.add(inv.name) self._announced_mono[nid] = _mono() await self.hass.call("persistent_notification", "create", messages.not_delivering(inv.name, slug).data()) async def check_price_gap(self) -> None: - """PRICE-01. One notification when no price covers now, gone when one does. - - `price_gap` is worked out on the tick from the stored plan. A gap - raises the notification once: the flag is set BEFORE the await, for - `check_circulating()`'s reason (a Home Assistant call can raise or - hang, and a retry every cycle would be a storm). When the gap ends the - same notification is taken down. Control is not touched either way: the - maintenance charge waits because its gate is shut (`_provider_gate`), - and nothing here decides that. - """ + """PRICE-01. One notification when no price covers now, gone when one + does. (DECISIONS/MAIN-orchestration-archive.md)""" if self.price_gap and not self._price_gap_announced: self._price_gap_announced = True await self.hass.call("persistent_notification", "create", @@ -6480,10 +4489,7 @@ class Controller: messages.prices_back().data()) async def check_fixed_note(self) -> None: - """PRICE-01 G1. At the first cycle only: one INFO line when a - `static_profile` is set beside `price_service` (it is the plan's last - fallback, not a fault, so no notification), and a dismiss of the card an - earlier build raised, which is harmless when absent.""" + """PRICE-01 G1. (DECISIONS/MAIN-orchestration-archive.md)""" if self._fixed_note_said: return self._fixed_note_said = True @@ -6505,20 +4511,15 @@ class Controller: async def check_meter_frozen(self) -> None: """SAFETY-18. Alarm on a meter that answers forever without changing. - - Notification only - no claim, no latch, no 0 W. freeze.py carries the - reasoning and the euro figure; DOCS.md carries the exposure this does - NOT close. - - ⚠️ getattr, not an attribute access: `unchanged_s` exists only on + getattr, not an attribute access: `unchanged_s` exists only on `homewizard_local`, and `p1_source` is None until amain() builds the - transport. Every other meter_source hands the predicate None and gets - False, which is the honest answer - see the transport note in freeze.py. - """ + transport. + (DECISIONS/MAIN-orchestration-archive.md)""" unchanged = getattr(self.p1_source, "unchanged_s", None) # SITE, all three inputs: one meter, the SITE target the arbiter # resolved, and the rail the SITE could stand at - which is the summed - # per-unit conjunction, so a small inverter B lowers the site rail rather + # per-unit conjunction, so a small inverter B lowers the site rail + # rather # than being graded against inverter A's. fired = self.freeze_wd.evaluate(unchanged, self.target, self.site_max_w, @@ -6548,15 +4549,12 @@ class Controller: # -- tasks --------------------------------------------------------------- # -- OPT-04: the background tariff refresh ------------------------------- def fresh_tariff_plan(self, now): - """The stored plan if it is still current, else None. NEVER a stale one. - - ⚠️ THIS IS THE ONE PLACE THE AGE IS JUDGED, and it is public for + """The stored plan if it is still current, else None. NEVER a stale + one. + THIS IS THE ONE PLACE THE AGE IS JUDGED, and it is public for `loop_is_following()`'s reason: a check has to be able to assert on the - shipped expression rather than on a transcription that drifts. Both - terms are required - a plan with no timestamp is one nothing stored, and - a timestamped plan older than TARIFF_MAX_AGE_S is a reading from a task - that has stopped answering. - """ + shipped expression rather than on a transcription that drifts. + (DECISIONS/MAIN-orchestration-archive.md)""" if self.tariff_plan is None or self.tariff_at is None: return None if (now - self.tariff_at).total_seconds() > TARIFF_MAX_AGE_S: @@ -6565,72 +4563,10 @@ class Controller: async def run_tariff_refresh(self) -> None: """Resolve the tariff off the control path, forever. NEVER on the tick. - - ⚠️ IT CATCHES EVERYTHING AND KEEPS GOING, for MqttPublisher's reason: - a supplier's outage, a certificate expiry, a malformed feed - all of it - is pricing, and the battery does not care. What it must never do is - raise out of here and take the task down, because then the plan freezes - at its last value and looks current forever. That is what - `fresh_tariff_plan()`'s age bound is the backstop for, and this is the - half that keeps the task alive. ⚠️ THE BOUND IS NO LONGER A BACKSTOP - ONLY: since TARIFF-12 it is also the serve-stale window an outage runs - on, and it is consulted below on every refresh. - - ⚠️ AND A FAILURE DOES NOT CLEAR THE PLAN - WHICH IS A CLAIM ABOUT WHAT - `resolve_tariff()` RETURNS, NOT ABOUT WHAT IT RAISES (TARIFF-12). This - paragraph was enforced by the `except` below alone, and the `except` - below is the one path a supplier outage never takes. `load_prices()` - says so in its own words - it "RETURNS, NEVER RAISES, for every failure - a site can actually have" - because a degradation arriving as a - traceback into the planning tick would stop the controller regulating - over an expired certificate. So a dead feed is a RETURN: the `try` - succeeds, `_degrade()` hands back a §11.4 plan, and the assignment used - to overwrite the good one unconditionally. Measured in a 40 h soak with - `static_profile` unset (the shipped default, and the normal state of a - dynamic-tariff site): the FIRST failed fetch replaced a complete 48 h - plan with `TariffPlan(no_data, None, "none")` - 0 points - 27 minutes - after the last good one, and `TARIFF_MAX_AGE_S` governed nothing. A - transient blip cost the same as a 90-minute outage. - - ⚠️ SO THE QUESTION IS ASKED ONCE, AFTER THE ATTEMPT, FROM THE EVIDENCE - THAT EXISTS - FCST-06's answer, in this file. The evidence is - `plan.from_feed` (did this attempt observe the feed) and - `fresh_tariff_plan(now)` (is an observation still current). A - degradation replaces a standing observation only once that observation - has aged out, so `TARIFF_MAX_AGE_S` is now the serve-stale window it - reads like rather than a bound nothing could reach. - - ⚠️ AND A §11.4 STATIC FALLBACK DOES NOT REPLACE A FRESH PROVIDER PLAN - EITHER, although it carries real prices. Every point `_degrade()` - builds is `provisional=True`, so `firm_points()` is empty, - `arbitrage()` refuses and `cheap_now()` is False - everything a - consumer may act on is already off. A day-ahead price for a given hour - does not change once published, so a plan fetched half an hour ago is - exactly as true about that hour as one fetched now. The fallback is - less actionable and no more current; it takes over when the observation - retires, which is what §11.4 is for. The reverse is not symmetrical: a - standing plan that is ITSELF a degradation is replaced by a fresh one, - or an outage would freeze the operator's static profile at the window - it was generated for. - - ⚠️ AND NOTHING HERE READS `arbitrage_enabled`, WHICH TARIFF-10 MAKES - WORTH SAYING. Since that card this task also runs on a site with a - provider configured and trading DISARMED, so "keep the plan" is a - decision about what the add-on has LOOKED UP, not about what it may - trade on. It is also strictly downstream of `resolve_tariff()`: the - §8.3 carry into `price_history` happens on the `series` branch inside - that call, before this decision exists, so which of two plans is kept - cannot add or lose a recorded day. - - ⚠️ AND `tariff_at` DOES NOT MOVE ON A KEPT PLAN. It is the moment the - fetch that produced the standing plan happened, and - `fresh_tariff_plan()` judges the age from it - re-stamping it here - would be this task asserting the feed answered when it did not, and the - plan would "freeze at its last value and look current forever", which - is the hazard the paragraph above this one names. An unmoved - `tariff_at` is also the only thing that tells a kept plan from a - freshly fetched one, so it is what the check asserts. - """ + IT CATCHES EVERYTHING AND KEEPS GOING, for MqttPublisher's reason: a + supplier's outage, a certificate expiry, a malformed feed - all of it + is pricing, and the battery does not care. + (DECISIONS/MAIN-orchestration-archive.md)""" horizon = float(price_option(self.o, "price_horizon_h")) while not self.stopping: now = datetime.now(timezone.utc) @@ -6655,10 +4591,8 @@ class Controller: self.tariff_plan, self.tariff_at = plan, now _LOG.info("tariff refreshed: %s", plan.words) except Exception as err: # noqa: BLE001 - # ⚠️ NOT THE OUTAGE PATH - see the docstring. `load_prices()` - # returns on every failure a site can have, so what reaches - # here is something nobody expected, and the plan is kept for - # the same reason and on the same terms. + # ⚠️ NOT THE OUTAGE PATH - see the docstring. + # (DECISIONS/MAIN-orchestration-archive.md) _LOG.warning( "The price update failed (%s), so the plan from %s is kept " "until it is %s old, and trading stops then.", @@ -6669,43 +4603,20 @@ class Controller: timeout=TARIFF_REFRESH_S) async def run_control(self) -> None: - # ⚠️ EACH INVERTER'S RATING FROM ITS OWN ENTITY, WHICH IS AC 4. There - # was one read of one `setpoint_entity` here, so a second inverter's - # rating was never read at all and its clamp, its freeze rail and the - # peak guard's `achievable_w` were all the first one's numbers. - # Concurrently, for `read_inputs()`'s reason: a slow ESPHome on one unit - # must not delay the controller's start on every other. + # ⚠️ EACH INVERTER'S RATING FROM ITS OWN ENTITY, WHICH IS AC 4. + # (DECISIONS/MAIN-orchestration-archive.md) async def _limits(inv): if inv.setpoint_entity: inv.dev_min, inv.dev_max, _ = await self.hass.limits( inv.setpoint_entity) await asyncio.gather(*(_limits(inv) for inv in self.inverters)) entity = self.o.get("setpoint_entity", "") - # ⚠️ THIS USED TO WARN AND CARRY ON, and that was the bug CFG-01 - # exists to remove: a max_w above the device's rating meant - # write_setpoint() silently clamped and the configured ceiling was - # never the real one. - # - # amain() already ran this gate before anything started. It runs - # AGAIN here for one case the gate cannot cover: ESPHome is often - # not up yet when the add-on boots, so hass.limits() returned - # (None, None) and the range checks were skipped rather than - # guessed at. This is the first moment the device's real rating is - # known. If it conflicts, refuse now - stopping deterministically - # through the normal shutdown, which commands 0 W on the way out. - # - # ⚠️⚠️ AND THE `if entity:` THAT USED TO WRAP THE WHOLE BLOCK WAS A + # ⚠️ THIS USED TO WARN AND CARRY ON, and that was the bug CFG-01 exists + # to remove: a max_w above the device's rating meant write_setpoint() + # silently clamped and the configured ceiling was never the real one. + # ⚠️ AND THE `if entity:` THAT USED TO WRAP THE WHOLE BLOCK WAS A # READER THAT FAILED PERMISSIVE - CFG-04's own hazard, in this file. - # `entity` is the FLAT `setpoint_entity`, which a per-inverter config - # leaves blank (validate rule 9(e) requires it), so on the very config - # this add-on now supports the entire re-check was SKIPPED: no - # inverter's `max_w` was ever graded against the device it is written - # to, and the skip was silent. The flat gate still guards the flat - # rules, because they read flat options and one device's range; the - # per-unit rule runs unconditionally, off the roster, and grades each - # unit against ITS OWN ESPHome. At one inverter `validate_unit_ranges()` - # skips the unit the flat call already graded, so nothing is reported - # twice and the N=1 path is unchanged. + # (DECISIONS/MAIN-orchestration-archive.md) problems = [] if entity: _first = self.inverters[0] @@ -6726,104 +4637,34 @@ class Controller: last_grid = object() heartbeat = float(self.o.get("heartbeat_s", 10)) - # ⚠️ SAFETY-12. The floor is a DEADLINE ON THE GAP BETWEEN CYCLES, not a - # second scheduler running alongside the arrival trigger. Every cycle - # resets it, whichever trigger fired, so on a healthy changing meter it - # never elapses and not one existing cycle moves - and therefore no - # change to the per-cycle gain the tuning was measured against. - # - # That is true twice over, and the second reason is the sturdier one: - # the combined cycle rate is max(arrival rate, 1/floor), never the sum. - # At the defaults 1/15 s is strictly slower than the ~1/5 s arrival - # rate, so even a floor that raced the arrivals could not push the loop - # past its nominal healthy rate. The re-tune is unreachable by - # construction, not merely by the reset. ⚠️ That argument holds only - # while floor > the meter's update interval - which is why the schema - # minimum of 5 s deserves the second look validate.py has not given it - # yet: 5 s IS the reference meter's interval, i.e. the one configurable - # value that would double the cycle rate. - # - # Why it has to exist, and it is not only about latency: `self.grid != - # last_grid` alone means a meter that repeats a value runs NO control - # cycles at all. The heartbeat below keeps re-emitting the last command - # and the firmware keeps putting it on the wire, so every indicator - # reads healthy - and, worse, the `inputs missing -> 0 W` claim lives - # INSIDE cycle(), so on a repeating meter THAT SAFETY CLAIM CANNOT FIRE. - # Demonstrated in test_loop.py: constant meter, SoC entity dies at t=4 s, - # and the pre-fix controller holds 220 W through the whole outage with - # the status line still reading "loop -> 220 W". Layer 1 does not cover - # it on a `meter_source: off` install either - no P1 age sensor is - # published, so the firmware has nothing to veto on. Measured on the rig - # during TEL-05: 70 s at a literally constant 350.0 W, 14 arrivals, zero - # cycles. - # - # This is NOT a freeze detector, and deliberately so - see - # HomeWizardLocalSource.unchanged_s for why one cannot be built here (at - # the converged -10 W target a 1 Wh register needs ~6 minutes to move, - # so any freeze threshold false-trips at the exact operating point we - # run at). Nothing here decides whether a repeated reading is true. It - # only stops "the number is the same" from meaning "do not think". - # - # ⚠️ THE 15 s DEFAULT HAS A CEILING IT DOES NOT OWN. It is under - # validate.FIRMWARE_INGEST_VETO_S (30 s) and under meter_max_age_s on - # purpose, so the loop re-decides at least twice inside any window in - # which layer 1 might otherwise be the only thing deciding. If the - # firmware's veto ever moves, this default moves with it - it is not - # independently justified at 15. + # ⚠️ SAFETY-12. The floor is a DEADLINE ON THE GAP BETWEEN CYCLES, not + # a second scheduler running alongside the arrival trigger. + # THE 15 s DEFAULT HAS A CEILING IT DOES NOT OWN. + # (DECISIONS/MAIN-orchestration-archive.md) floor = float(self.o.get("cycle_floor_s", 15)) last_cycle = None while not self.stopping: await self.read_inputs() now_mono = asyncio.get_running_loop().time() - # A cycle is one meter update, exactly as on the reference install - + # A cycle is one meter update, exactly as on the reference install + # - # or the floor elapsing, whichever comes first. overdue = last_cycle is None or (now_mono - last_cycle) >= floor if self.grid != last_grid or overdue: last_grid = self.grid last_cycle = now_mono - # ⚠️ The FULL cycle, not a degraded one. A floor cycle runs the - # same law on the same inputs as an arrival cycle, because in P1 - # mode the freshness question is already answered upstream and - # answered harder: past `meter_max_age_s` (30 s) p1.net_w is - # None, which is "inputs missing", which becomes a P_SAFETY 0 W - # claim. So a floor cycle cannot run the PI on a 60 s old - # figure, because at 60 s there is no figure. - # - # ⚠️ THAT GUARANTEE IS TRANSPORT-CONDITIONAL, and saying so + # ⚠️ The FULL cycle, not a degraded one. + # THAT GUARANTEE IS TRANSPORT-CONDITIONAL, and saying so # matters more than the guarantee: with `meter_source: off`, - # read_inputs() takes self.grid straight from hass.number() with - # NO age gate at all, so a Home Assistant entity that has gone - # quiet reads as a current value on that path with or without - # this change. Running the full law is still the right choice - # there - the alternative is a degraded second law reasoning - # about an age nobody measured - but the floor does not make - # that path fresh, and nothing here should be read as claiming - # it does. And the integrator - # advances per CYCLE, not per second (control.py), so bounding - # the gap bounds the accumulation: at most one extra - # gain x error per floor interval, under the same - # integrator_max_w as every other cycle. A second, gentler - # control law for floor cycles would be a second set of - # dynamics nobody can test on this hardware. + # read_inputs() takes self.grid straight from hass.number() + # with NO age gate at all, so... + # (DECISIONS/MAIN-orchestration-archive.md) await self.cycle() # ⚠️ The heartbeat is not an optimisation. The ESP32 treats silence # longer than ~30 s as "the controller is gone" and zeroes the # inverter. Refreshing the SAME value is what proves we are alive. - # - # ⚠️ ONE DEADLINE PER INVERTER, AND THE REFRESHES GO OUT TOGETHER. - # This was ONE `last_beat` and ONE serialised write, so a hung - # `hass.call()` to inverter A consumed inverter B's ~30 s layer-1 - # budget and the firmware zeroed a healthy sibling. Now each unit - # owns its own deadline, each write is dispatched CONCURRENTLY and - # each is bounded by WRITE_BUDGET_S - so the worst a hung A can do - # to B is delay B's NEXT beat by the budget, which is why the budget - # plus the schema's largest `heartbeat_s` has to stay under layer - # 1's veto. That inequality is graded, not asserted. - # - # ⚠️ AND EACH UNIT REFRESHES ITS OWN SHARE, `inv.target`, never the - # site target. A heartbeat that re-emitted the site number would be - # the broadcast fault arriving through the back door, once every - # `heartbeat_s`, on a path with no allocator in it. + # ONE DEADLINE PER INVERTER, AND THE REFRESHES GO OUT TOGETHER. + # (DECISIONS/MAIN-orchestration-archive.md) await self.send_heartbeats(asyncio.get_running_loop().time(), heartbeat) self.publish() @@ -6832,47 +4673,26 @@ class Controller: async def send_heartbeats(self, loop_now: float, heartbeat_s: float) -> None: """Refresh every inverter whose own deadline has come. AC 6 lives here. - - ⚠️ IT IS A METHOD RATHER THAN FIVE LINES INSIDE `run_control()` FOR ONE + IT IS A METHOD RATHER THAN FIVE LINES INSIDE `run_control()` FOR ONE REASON: the acceptance criterion is "a hung write to A cannot consume - B's layer-1 budget - verified by injecting the hang", and a check cannot - inject a hang into the middle of a `while not self.stopping` loop that - also sleeps, reads inputs and cycles. Inline, the only thing a test - could grade was its OWN gather of `write_setpoint()` calls - which is a - check on the fixture, and a serialised shipped loop survived it. - Measured: the mutation that replaced the concurrent dispatch with a - `for` loop passed the whole suite while this block was inline. - - `loop_now` is the caller's monotonic clock, the same discipline every - watchdog in this add-on takes its clock by. - """ + B's layer-1 budget - verified by injecting the hang", and a... + (DECISIONS/MAIN-orchestration-archive.md)""" due = [inv for inv in self.inverters if loop_now - inv.last_beat >= heartbeat_s] if not due: return - # ⚠️ THE DEADLINE MOVES BEFORE THE WRITE, NOT AFTER IT. Moved after, a - # unit whose write hangs would have its deadline advanced late every - # time and would drift into being re-sent on every tick; and a unit - # whose write RAISED would never have it advanced at all. + # ⚠️ THE DEADLINE MOVES BEFORE THE WRITE, NOT AFTER IT. + # (DECISIONS/MAIN-orchestration-archive.md) for inv in due: inv.last_beat = loop_now - # ⚠️ CONCURRENTLY. In series, every inverter behind a hung one waits out - # that one's whole budget before its refresh is even attempted - and - # before this card, with no budget at all, it waited out the HTTP - # stack's timeout. That is one inverter's transport fault pushing a - # healthy sibling towards layer 1's ~30 s veto. + # ⚠️ CONCURRENTLY. (DECISIONS/MAIN-orchestration-archive.md) await asyncio.gather(*( self.write_setpoint(inv.target, force=True, unit=inv) for inv in due)) def publish(self) -> None: - # ⚠️ SITE TOTALS, AND THE KEYS DO NOT MOVE. These entities are already - # discovered and already on every dashboard (TEL-07 keeps their - # `unique_id`s), so `battery` stays the number the meter sees the - # battery doing - the SUM - and `soc` stays the one a floor is graded - # against - the EMPTIEST pack. At the shipped roster of one both are - # inverter A's own readings, unchanged. Per-inverter entities are - # TEL-07's, not a key rename here. + # ⚠️ SITE TOTALS, AND THE KEYS DO NOT MOVE. + # (DECISIONS/MAIN-orchestration-archive.md) values = { "setpoint": self.target, "grid": self.grid, @@ -6880,96 +4700,42 @@ class Controller: "soc": self.pack_floor_soc, "phase": self.maint.phase, # ⚠️ SAFETY-02's alarm surface, and it is deliberately an existing - # one. IF-01/IF-02 own `ems/event/failsafe` and the `failsafe active` - # binary sensor and neither is built yet; inventing a bespoke topic - # here would be a surface to migrate later. This sensor is already - # discovered, already on every dashboard, and "failsafe" is - # additive to its vocabulary rather than a change to it. - # SAFETY-30 adds the site sign latch to the same vocabulary, for - # the reason the note above gives: "failsafe" is additive to a - # sensor that already exists and is already on every dashboard, and - # a P_SAFETY stop that did not reach it would be a site at 0 W - # reporting "running". + # one. (DECISIONS/MAIN-orchestration-archive.md) "status": ("failsafe" if any(i.sign_wd.tripped for i in self.inverters) or (self.site_sign_wd.tripped and len(self.inverters) > 1) or self.circ_wd.tripped - # SAFETY-10: "shadow" is its own value and not a - # flavour of "stopped". A fleet dashboard reporting a - # shadow install as `running` would be calling a - # battery nobody is controlling healthy; one reporting - # it as `stopped` would lose the fact that the decision - # path is live and being recorded. ⚠️ BELOW `failsafe`: - # a latch that is up is up, and shadow does not clear it. + # SAFETY-10: "shadow" is its own value and not a flavour + # of "stopped". + # (DECISIONS/MAIN-orchestration-archive.md) else "shadow" if self.shadow else "running" if self.auto else "stopped"), # SAFETY-31. ⚠️ MEASURED, not the allocation record - see - # `site_circulating_w`. `publish()` drops None values, so a cycle - # with a missing per-unit reading leaves the last figure standing - # rather than publishing a zero that would read as health. + # `site_circulating_w`. (DECISIONS/MAIN-orchestration-archive.md) "circulation": (None if self.site_circulating_w is None else round(self.site_circulating_w)), - # TEL-10. The loop's own three, written from the fields `cycle()` - # recorded and not re-derived here - the page and the publisher run - # on their own tasks and `self.target` has moved on by the time - # either of them looks (SAFETY-47's note on `sized_from_meter`, one - # field up, is the same hazard). + # TEL-10. (DECISIONS/MAIN-orchestration-archive.md) "loop_i": round(self.loop_i_w, 1), "loop_lead": round(self.loop_i_lead, 1), "loop_reason": self.loop_reason, } - # --- TEL-11 ---------------------------------------------------------- - # ⚠️ THE KEY IS PRESENT WITH THE VALUE `None` WHEN THERE IS NO READING, - # AND ABSENT ENTIRELY WHEN THERE IS NO SOURCE. Those are two different - # states and the publisher treats them as two: `mqtt.ABSENT_AS_UNKNOWN` - # turns the first into HA's own `None` payload, so the entity's state - # goes to `unknown` and the recorder records a GAP - which is how a - # reader of a week of history tells "no PV reported" from "PV reported - # 0 W". The second never reaches the broker at all, because an install - # that never set `pv_entity` or `pv_entities` has no such entity - # announced. ⚠️ TEL-12: AND THE ROW IS STILL ONE NUMBER. At a roster of - # three it is the SITE total, `None` unless EVERY entity answered - see - # `pv.total()`, whose note is where the decision not to publish a - # partial sum is argued. This line did not have to change for that, and - # that is the point: TEL-11's `None`-is-an-absence rule already covered - # it, one layer up. - # - # ⚠️ AND THE GATE IS `optional_keys()`, THE SAME EXPRESSION THE - # ANNOUNCEMENT USES. Written as a second predicate here the two could - # disagree, and both directions are bad - an announced entity nothing - # feeds, or a topic nothing declared. `announced_units()` two blocks - # down is the same discipline for the roster. + # TEL-11 ⚠️ THE KEY IS PRESENT WITH THE VALUE `None` WHEN THERE IS NO + # READING, AND ABSENT ENTIRELY WHEN THERE IS NO SOURCE. + # AND THE GATE IS `optional_keys()`, THE SAME EXPRESSION THE + # ANNOUNCEMENT USES. + # (DECISIONS/MAIN-orchestration-archive.md) if "pv" in optional_keys(self.o): values["pv"] = (None if self.pv_w is None else round(self.pv_w, 1)) - # ⚠️ ONLY when P1 ingestion is actually running. The ESP32's stale-input - # watchdog subscribes to sensor.goodwe_p1_sample_age - see the entity-id - # note in mqtt.py; the composed name is not the object_id - and forces layer-1 - # failsafe once it reaches max_age_s. With meter_source off there is no - # ingester feeding it, so published_age_s would be time-since-startup - # climbing without bound - i.e. every existing install would cross the - # threshold within 30 s and pin its inverter at 0 W forever. Publishing - # nothing leaves the entity non-existent, which is the status quo and - # what has_state() in the firmware is checking for. + # ⚠️ ONLY when P1 ingestion is actually running. + # (DECISIONS/MAIN-orchestration-archive.md) if self.p1_enabled: # Recomputed here, once a second, on purpose - see P1Ingest. values["p1_age"] = round(self.p1.published_age_s, 1) # TEL-07. THE PER-UNIT ROWS, AND THE EMPTY LIST IS THE GATE. - # - # ⚠️ THERE IS NO `if len(self.inverters) > 1` HERE, DELIBERATELY. - # `announced_units()` answers `[]` at a roster of one, `zip` then yields - # nothing, and the values dict comes out byte-identical to what shipped - - # so the condition that decides what is ANNOUNCED is the same expression - # that decides what is PUBLISHED. Written as a second predicate, the two - # could disagree, and both directions are bad: an announced entity - # nothing feeds (the hazard `omit` exists for) or, at N=1, a published - # topic nothing announced, which AC 4 forbids outright. - # - # ⚠️ AND THE SITE ROWS ABOVE DO NOT MOVE. `battery` stays the SUM and - # `soc` stays the EMPTIEST pack; these are additions beside them, not a - # re-pointing of them, because those two ids are on the live install's - # dashboards and in its automations. + # THERE IS NO `if len(self.inverters) > 1` HERE, DELIBERATELY. + # (DECISIONS/MAIN-orchestration-archive.md) names = announced_units([inv.name for inv in self.inverters]) for inv, slug in zip(self.inverters, unit_slugs(names)): values[unit_state_key(slug, "setpoint")] = inv.target @@ -6977,7 +4743,8 @@ class Controller: values[unit_state_key(slug, "soc")] = inv.soc values[unit_state_key(slug, "sign_latch")] = ( "tripped" if inv.sign_wd.tripped else "clear") - # OPT-11 P2: each battery's mode and switch; nothing while modes are off. + # OPT-11 P2: each battery's mode and switch; nothing while modes are + # off. values.update(self.mode_values()) self.mqtt.publish(values) @@ -6990,23 +4757,8 @@ class Controller: async def command_site_zero(self) -> None: """Every inverter to 0 W, and every inverter's TARGET to 0 W with it. - - ⚠️ THE SECOND HALF IS NOT BOOKKEEPING, AND IT IS A HAZARD DEV-02 - CREATED. Before this card the heartbeat re-emitted `self.target`, so - zeroing that one scalar was enough: the stop button set it and the next - refresh carried the zero. The heartbeat now re-emits `inv.target` - it - has to, or it would broadcast the site number - so a stop that zeroed - only `self.target` would leave each unit's LAST SHARE standing, and the - very next heartbeat (up to `heartbeat_s` later, and before any cycle has - run) would put it back on the wire. The operator would have pressed - stop, watched the inverter go to zero, and seen it resume seconds later. - Both must move together, which is why this is one method with a reason - and not two lines repeated at three call sites. - - ⚠️ AND 0 W IS THE ONE VALUE THAT MAY BE WRITTEN SITE-WIDE. It means the - same thing to every inverter; a non-zero site-wide write is the ARCH-01 - broadcast fault, and `write_setpoint()` refuses it. - """ + THE SECOND HALF IS NOT BOOKKEEPING, AND IT IS A HAZARD DEV-02 CREATED. + (DECISIONS/MAIN-orchestration-archive.md)""" self.target = 0.0 for inv in self.inverters: inv.target = 0.0 @@ -7017,16 +4769,8 @@ class Controller: """The status rows. `when` is the reference time for anything billed by the calendar - today's month when it is None, which is what the live page means; `status_payload()` calls this with no argument. - - ⚠️ IT EXISTS SO THE PEAK ROW CAN BE GRADED (TEST-17). Every other peak - query already takes it - `PeakStore.peak()`, `PeakTracker.peak()`, - `headroom_w()`, `warning_frac()` - and the row built from them was the - one thing that could only be read against `datetime.now()`. A suite - that banks an August quarter then asks this page what the peak is goes - green in August and red in September, which is what happened at 00:00 - on 2026-09-01: TARIFF-01's own section 10, red on a calendar boundary, - on a commit nobody had touched for a day. - """ + IT EXISTS SO THE PEAK ROW CAN BE GRADED (TEST-17). + (DECISIONS/MAIN-orchestration-archive.md)""" o = self.o out = [] # SAFETY-61. First, so a stalled loop is the headline: the heartbeat is @@ -7058,7 +4802,7 @@ class Controller: # UX-10: no reading yet since the start, still inside the # limit. Amber, not red: it is not a fault yet, but control # waits for it. Red at 0 s contradicted the limit the row - # prints. Past the limit it is the red row below, as before. + # prints. (DECISIONS/MAIN-orchestration-archive.md) out.append({"ok": False, "warn": True, "id": "meter", "text": messages.row_meter_waiting( self.p1.max_age_s)}) @@ -7068,32 +4812,26 @@ class Controller: "text": messages.row_meter_stale( age, self.p1.max_age_s)}) else: - # ⚠️ unchanged_s is REPORTED, never thresholded and never folded - # into the age - see HomeWizardLocalSource.unchanged_s for why - # (at the converged -10 W this controller aims for, a 1 Wh - # register needs ~6 minutes to move, so any limit false-trips at - # the exact operating point we target). The whole argument for - # leaving it unthresholded is that a human interprets it, which - # requires a human being able to see it - so here it is. getattr: - # only homewizard_local has one, and p1_source is None until - # amain() builds the transport. + # ⚠️ unchanged_s is REPORTED, never thresholded and never + # folded into the age - see HomeWizardLocalSource.unchanged_s + # for why (at the converged -10 W this controller aims for, a 1 + # Wh register needs ~6 minutes to move, so any limit + # false-trips at the exact operating point we target). + # (DECISIONS/MAIN-orchestration-archive.md) unchanged = getattr(self.p1_source, "unchanged_s", None) out.append({"ok": True, "warn": False, "id": "meter", "text": messages.row_meter( self.p1.net_w, age, self.p1.parse_errors, unchanged)}) - # SAFETY-18. Reported in both states, for the reason SAFETY-02 is: - # a detector nobody can see is armed is a detector nobody trusts - # when it fires. ok=False/warn=False so the banner goes red - this - # is an alarm, and the banner is the loudest surface we own. It - # changes NOTHING about control; see check_meter_frozen(). + # SAFETY-18. (DECISIONS/MAIN-orchestration-archive.md) if self.freeze_wd.active: # The figures are in `freeze_wd.reason`, on the log and the # event list (check_meter_frozen()). out.append({"ok": False, "warn": False, "id": "meter_frozen", "text": messages.row_meter_frozen()}) elif getattr(self.p1_source, "unchanged_s", None) is None: - # Not "armed". Every transport but homewizard_local is invisible + # Not "armed". Every transport but homewizard_local is + # invisible # to this check, and saying "armed" there would be a lie the # status page tells on behalf of a guard that cannot run. out.append({"ok": False, "warn": True, "id": "freeze_check", @@ -7101,12 +4839,8 @@ class Controller: elif rail_unreachable(self.site_max_w, dev_min=self.site_dev_bound("dev_min"), dev_max=self.site_dev_bound("dev_max")): - # SAFETY-20, and THE SECOND REASON TO USE THIS ROW RATHER THAN A - # NEW MECHANISM. The transport case above and this one are the - # same statement - "the conjunction cannot be evaluated here" - - # and an operator does not need to learn two vocabularies for - # it. What differs is only which term is missing: there, - # `unchanged_s`; here, a rail the command could ever stand at. + # SAFETY-20, and THE SECOND REASON TO USE THIS ROW RATHER THAN + # A NEW MECHANISM. (DECISIONS/MAIN-orchestration-archive.md) _no_rating = (not self.site_max_w or not is_commandable(self.site_max_w)) out.append({"ok": False, "warn": True, "id": "freeze_check", @@ -7114,37 +4848,24 @@ class Controller: "rating" if _no_rating else "range")}) else: # ⚠️ SAFETY-20. THE RAIL PRINTED HERE IS THE RAIL THE PREDICATE - # USES, device range included. This row used to interpolate - # `rail_w(max_w)` while `looks_frozen()` graded a command the - # device had already clipped below it, so on any install whose - # inverter is smaller than its configured ceiling the page named - # a number the command could never reach and called it `armed`. - # Both the row and the predicate now read the same three inputs. + # USES, device range included. + # (DECISIONS/MAIN-orchestration-archive.md) _rail = rail_w(self.site_max_w, dev_min=self.site_dev_bound("dev_min"), dev_max=self.site_dev_bound("dev_max")) # And when the device is what set that number, SAY SO - the # operator configured `max_w` and is owed the reason the guard - # is armed somewhere else. - # ⚠️ Either bound may be None on its own - `hass.limits()` - # reads `min` and `max` independently and returns None for - # whichever attribute is missing or unparseable - so this - # renders whatever is there rather than formatting a None. - # UX-08: "the inverter's own limit" says the device set it; which - # inverter and its range are DOCS and the settings, not entity ids. + # is armed somewhere else. ⚠️ Either bound may be None on its + # own - `hass.limits()` reads `min` and `max` independently and + # returns None for whichever attribute is missing or + # unparseable - so this renders whatever is there rather than + # formatting a None. (DECISIONS/MAIN-orchestration-archive.md) out.append({"ok": True, "warn": False, "id": "freeze_check", "text": messages.row_freeze_on( self.freeze_wd.cfg.hold_s, _rail, own_limit=_rail < rail_w(self.site_max_w))}) - # TARIFF-01. The month's billed peak, and - the load-bearing half - - # a DIFFERENT row when the store could not answer. "no peak yet" is - # a normal state on the 1st; "unreadable" is a broken instrument, and - # a status page that spelled them the same way would let a 0 W read - # look like a quiet month right up until SAFETY-07 shaved nothing. - # UX-08: in plain words. The store's state at start is on the INFO - # line `capacity-tariff peak store: ...` and `_pk.explain()` on the - # log; the coverage figure (TARIFF-13) is a row of its own. + # TARIFF-01. (DECISIONS/MAIN-orchestration-archive.md) _pk = self.peak.peak(when) if _pk.is_unreadable: out.append({"ok": False, "warn": True, "id": "month_peak", @@ -7167,10 +4888,8 @@ class Controller: # SAFETY-07. Three rows, not one, because "idle" and "cannot see" # are opposite states and a page that spelled them the same way - # would show a green line over a guard that is switched off. `None` - # is a fourth state on purpose: before the first cycle the guard has - # concluded nothing, and saying "idle" then would be a claim about a - # decision that has not been taken. + # would show a green line over a guard that is switched off. + # (DECISIONS/MAIN-orchestration-archive.md) _pg = self.peak_guard if _pg is None: out.append({"ok": True, "warn": True, "id": "peak_guard", @@ -7183,31 +4902,9 @@ class Controller: not ((_pg.floor_w or 0.0) > 0.0 or _pg.within_aim) or self._peak_floor_limited): # SAFETY-24. NOT ok, like saturation: in both states the money - # is going to be spent and a human is the only actuator left. A - # separate row from saturation because the two ask for opposite - # things - see check_peak_floor_limited(). - # - # ⚠️ SAFETY-25. THE SCOPE SENTENCE IS PART OF THE ROW, NOT A - # DOCS FOOTNOTE. This is the one line an operator reads at the - # exact moment the guard stops discharging, and read alone it - # says "the battery is being held at peak_soc_floor" - which is - # false, and measurably so: on the committed evening replay the - # loop goes on discharging in EVERY cycle this row is showing. - # A floor that binds one consumer and not the other is worse - # than no floor if the page does not say which. - # - # ⚠️ AND IT CARRIES ITS OWN `banner`. status_payload() renders - # the FIRST not-ok/not-warn row as the page's headline, so - # without this the loudest element on the page would be a - # 550-character paragraph that the row three lines below - # repeats verbatim. The banner is the short form; the row keeps - # the whole sentence, because the row is where an operator - # reads what the floor does and does not bound. Shortening the - # ROW instead would be weakening AC 3. - # UX-08: the same three branches, and the same pack naming, as - # check_peak_floor_limited()'s notification. The figures stay in - # `_pg.reason`, on the log and the event list. - # SAFETY-87: `runs`, control goes on (status_payload()). + # is going to be spent and a human is the only actuator left. + # SAFETY-25. + # (DECISIONS/MAIN-orchestration-archive.md) out.append({"ok": False, "warn": False, "id": "peak_guard", "runs": True, "banner": messages.banner_peak_reserve(), @@ -7245,20 +4942,14 @@ class Controller: out.append({"ok": True, "warn": False, "id": "peak_guard", "text": _pg.explain()}) else: - # ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the point. - # SAFETY-07 is NOT COVERED on the legacy single-entity path - the - # quarter-hour average is measured from P1 telegrams and this path - # has none - and a row that only rendered where the guard runs would - # leave the one install that is unprotected showing nothing at all. - # Same answer, same reason, as the freeze detector's NOT COVERED row - # a few lines up. + # ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the + # point. (DECISIONS/MAIN-orchestration-archive.md) out.append({"ok": False, "warn": True, "id": "peak_guard", "text": messages.row_peak_guard_off()}) - # ⚠️ ONE PAIR OF ROWS PER INVERTER, NAMED. A single "battery SoC" row on - # a site with two packs is a page that shows one pack and calls it the - # site. The label keeps its exact wording at a roster of one, because - # that is what an existing install reads. + # ⚠️ ONE PAIR OF ROWS PER INVERTER, NAMED. A single "battery SoC" row + # on a site with two packs is a page that shows one pack and calls it + # the site. (DECISIONS/MAIN-orchestration-archive.md) rows = [] for inv in self.inverters: unit = None if len(self.inverters) == 1 else inv.name @@ -7278,27 +4969,9 @@ class Controller: "text": messages.row_sensor_unset(label, unit)}) elif value is None: # ⚠️ SAFETY-56. THIS ROW ASSERTED A FALSE FACT ABOUT A NAMED - # ENTITY, WHICH IS THE WORST THING A DIAGNOSTIC CAN DO. It read - # " is missing or not numeric" for ANY None, and one of - # the ways this value becomes None is SAFETY-37's freshness gate - # withholding a reading that is present, numeric and updating - - # `Freshness.reading()` returns None BY DESIGN there. On the - # live site on 2026-09-21 the operator opened Developer Tools on - # the entity this row named, found it working, and had every - # reason to conclude the diagnostics were broken. - # - # ⚠️ AND IT IS FIXED BY SAYING LESS, NOT BY LOOKING HARDER. The - # row could ask `inv.batt_fresh.stale` - it is on the same - # object at the same moment - but this loop also renders the - # grid and SoC rows, which have no gate, and a branch per row - # would be a second place where "why is this None" is decided. - # The gate already has a surface of its own: `stale_note()` - # rides `liveness_notes()` a few rows below with the whole - # STALE_READING sentence in it, so the operator's discriminator - # is "is that row there?" and this row's job is only to stop - # pre-empting the answer. Same reason the wording is not shared - # with `freshness._grade()`: that one is a log line about a - # BEHAVIOUR, this one is a banner about an ENTITY. + # ENTITY, WHICH IS THE WORST THING A DIAGNOSTIC CAN DO. + # AND IT IS FIXED BY SAYING LESS, NOT BY LOOKING HARDER. + # (DECISIONS/MAIN-orchestration-archive.md) out.append({"ok": False, "warn": False, "id": "sensor", "text": messages.row_sensor_unusable(label, unit)}) else: @@ -7319,13 +4992,7 @@ class Controller: "text": messages.row_control(inv.dev_min, inv.dev_max, unit)}) - # DEV-02. Whether this controller can still put a number on each - # inverter, and it is reported in the ONE state that matters: a unit - # `live_units()` has stopped listing has had its share handed to its - # siblings, or - at a roster of one - has taken the site to 0 W. A - # dropped link that shows nothing on the page reads as a healthy site. - # Red only for a unit that is no longer live; the rest are amber. - # The same notes, in the same order, as `liveness_notes()`. + # DEV-02. (DECISIONS/MAIN-orchestration-archive.md) _now = datetime.now(timezone.utc) for inv in self.inverters: for note, red in ((inv.link_note(_now), not inv.is_live(_now)[0]), @@ -7334,15 +5001,7 @@ class Controller: if note: out.append({"ok": False, "warn": not red, "text": note}) - # SAFETY-03. Red while an over-rating clamp is engaged (amber for the - # integrator bound, SAFETY-40), and the engagement COUNT is shown even - # after it clears - a row that vanishes with the condition takes the - # evidence with it. Reported only when there is something to report: - # unlike the freeze detector this guard has no "not covered" state, it - # runs on every write on every transport. - # UX-07 item 1 / UX-08: each count is shown under ITS alarm's name - - # over-rating cuts as "Power request too high", integrator-bound cycles - # as "Battery is not keeping up" - never the two summed under one. + # SAFETY-03. (DECISIONS/MAIN-orchestration-archive.md) _cw = self.clamp_wd if _cw.active and _cw.bug: out.append({"ok": False, "warn": False, "id": "power_too_high", @@ -7377,30 +5036,11 @@ class Controller: None if len(self.inverters) == 1 else inv.name)}) - # SAFETY-02. Reported in both states: a watchdog nobody can see is armed - # is a watchdog nobody trusts when it fires. - # ⚠️ ONE ROW PER INVERTER, because there is one watchdog per inverter - # and a trip on B leaves A commanding. A single row would have to pick - # one of them to describe, and the honest answer at a roster of two is - # both. At a roster of one the wording is unchanged. - # ⚠️ AND SINCE SAFETY-30 THE ROW SAYS WHAT THE UNIT WAS COMPARED - # AGAINST, because at a roster of two that is no longer the meter and a - # row that still said "the command opposed the meter" would send an - # operator to look at P1 for a fault that is visible only on one - # inverter's own power sensor. `sign_wd.against` is the same string the - # trip sentence uses, so the two cannot drift apart. + # SAFETY-02. (DECISIONS/MAIN-orchestration-archive.md) for inv in self.inverters: unit = None if len(self.inverters) == 1 else inv.name - # ⚠️ ADDED ONLY WHEN THERE IS SOMETHING TO DISAMBIGUATE. At the - # shipped roster of one the two sentences below are the ones that - # have always been on the page, word for word - AC 8 is about what - # an operator sees as much as about what the detector computes, and - # "opposed a meter reading" would be a gratuitous change to a line - # somebody has learned to recognise. At N>=2 the noun is load- - # bearing: the row would otherwise send them to look at P1 for a - # fault that is visible only on one inverter's own power sensor. - # UX-08: at N>=2 `unit` is set, and the row says "its own power". - # The figures stay in `sign_wd.reason`, on the log and event list. + # ⚠️ ADDED ONLY WHEN THERE IS SOMETHING TO DISAMBIGUATE. + # (DECISIONS/MAIN-orchestration-archive.md) if inv.sign_wd.tripped: out.append({"ok": False, "warn": False, "id": "direction", "text": messages.row_direction_trip(unit)}) @@ -7410,12 +5050,9 @@ class Controller: "text": messages.row_direction_check( held, inv.sign_wd.cfg.hold_s, unit)}) - # --- SAFETY-30: the SITE row ----------------------------------------- - # ⚠️ ONLY AT N>=2, and the absence at N=1 is the honest report rather - # than a missing row. With one inverter the site comparison and the row - # above are the same sentence about the same actuator; printing it twice - # would advertise two guards where there is one, which is how an - # operator learns to skim the list. + # SAFETY-30: the SITE row ⚠️ ONLY AT N>=2, and the absence at N=1 is + # the honest report rather than a missing row. + # (DECISIONS/MAIN-orchestration-archive.md) if len(self.inverters) > 1: if self.site_sign_wd.tripped: out.append({"ok": False, "warn": False, "id": "site_direction", @@ -7426,10 +5063,8 @@ class Controller: "text": messages.row_site_direction_check( _sheld, self.site_sign_wd.cfg.hold_s)}) - # --- SAFETY-31 ----------------------------------------------------- - # ⚠️ SITE-LEVEL, WHERE THE ROWS ABOVE ARE PER-INVERTER, and the row says - # so. There is one circulating figure for the site; naming an inverter - # here would invite the operator to clear "the other one". + # SAFETY-31 ⚠️ SITE-LEVEL, WHERE THE ROWS ABOVE ARE PER-INVERTER, and + # the row says so. (DECISIONS/MAIN-orchestration-archive.md) _circ = self.site_circulating_w if self.circ_wd.tripped: out.append({"ok": False, "warn": False, "id": "circulating", @@ -7447,15 +5082,9 @@ class Controller: "text": messages.row_circulating_check( _circ, held, self.circ_wd.cfg.hold_s)}) - # --- SAFETY-42 ------------------------------------------------------- - # ⚠️ PER INVERTER, AND WARN RATHER THAN BAD, WHICH IS THE ONE PLACE THE - # "reports, does not act" DECISION IS VISIBLE TO AN OPERATOR. Every - # other latched row in this list means the site is at 0 W and a red - # cross is the truth. This one means the site is STILL RUNNING and is - # quietly short by one unit's share; rendering it as a hard failure - # would say the controller had stopped, which it has not. It is not "ok" - # either - `ok: False` with `warn: True` is this list's caution, and a - # caution is what a fault nobody is acting on actually is. + # SAFETY-42 ⚠️ PER INVERTER, AND WARN RATHER THAN BAD, WHICH IS THE + # ONE PLACE THE "reports, does not act" DECISION IS VISIBLE TO AN + # OPERATOR. (DECISIONS/MAIN-orchestration-archive.md) if len(self.inverters) > 1: for inv in self.inverters: if inv.delivery_wd.tripped: @@ -7468,10 +5097,8 @@ class Controller: inv.name, _dheld, inv.delivery_wd.cfg.hold_s)}) else: - # AC 5, and the same honesty `circulating power: n/a` uses one block - # up. At one inverter the reduction this check is has no proper - # subset to find, for EVERY input, so "armed" would be a claim to be - # watching something that cannot happen. + # AC 5, and the same honesty `circulating power: n/a` uses one + # block up. (DECISIONS/MAIN-orchestration-archive.md) out.append({"ok": True, "warn": False, "id": "delivery", "text": messages.row_one_inverter("Delivery check")}) @@ -7483,7 +5110,8 @@ class Controller: "text": messages.row_estop_fitted()}) if not o.get("peak_forecast_entity"): - # ok=False + warn=True renders as a caution, not a pass. A check that + # ok=False + warn=True renders as a caution, not a pass. A check + # that # is both is a check nobody reads. out.append({"ok": False, "warn": True, "id": "peak_forecast", "text": messages.row_no_peak_forecast()}) @@ -7502,25 +5130,11 @@ class Controller: out.append({"ok": False, "warn": True, "id": "solar_forecast", "text": messages.row_forecast_gap( day, gone, len(ents))}) - # --- TEL-11 ---------------------------------------------------------- - # ⚠️ NO ROW AT ALL WHEN THE ROSTER IS EMPTY, AND THAT IS A DELIBERATE - # DIFFERENCE FROM THE PEAK-FORECAST CAUTION DIRECTLY ABOVE. That one - # nags because an unset forecast entity leaves a MONEY GUARD without an - # input; this one is pure instrumentation, and a permanent caution on - # every install that never asked for a PV channel is exactly the - # "no warning spam, no degraded status row" the card forbids. - # - # ⚠️ AND WHEN IT IS SET, A FAILED READ IS `ok=False, warn=True` - a - # CAUTION, never `bad`. `status_payload()` builds "NOT READY" from rows - # that are neither ok nor warn, and a telemetry channel that could put a - # battery site into NOT READY would be observability with a foot in the - # control path. The reason comes from `pv.watts()` rather than being - # re-derived here, so the page cannot disagree with the reading. - # - # TEL-12 kept 0.11.0's single-entity sentences word for word. UX-08 - # replaced them on purpose (DECISIONS/UX-08-short-notifications.md), so - # a `tools/tel11_pv_trace.py` compare against a pre-UX-08 tree shows - # these rows as changed telemetry; the control documents stay equal. + # TEL-11 ⚠️ NO ROW AT ALL WHEN THE ROSTER IS EMPTY, AND THAT IS A + # DELIBERATE DIFFERENCE FROM THE PEAK-FORECAST CAUTION DIRECTLY ABOVE. + # AND WHEN IT IS SET, A FAILED READ IS `ok=False, warn=True` - a + # CAUTION, never `bad`. + # (DECISIONS/MAIN-orchestration-archive.md) roster = self.pv_roster if roster: # UX-08: sources are numbered in the order of the settings list, @@ -7545,10 +5159,9 @@ class Controller: self.pv_w, n_src, n_src == 1 and roster[0] in flip)}) # ⚠️ AND WHEN BOTH KEYS ARE SET, WHICH ONE WON IS STATED. `pv.py`'s - # `entities()` makes `pv_entities` REPLACE `pv_entity`, which is the - # rule an operator upgrading from 0.11.0 is most likely to trip - # over. A CAUTION and never a `bad` row: telemetry must not stop - # control. + # `entities()` makes `pv_entities` REPLACE `pv_entity`, which is + # the rule an operator upgrading from 0.11.0 is most likely to trip + # over. (DECISIONS/MAIN-orchestration-archive.md) if pv_read.entity_is_ignored(self.o): out.append({"ok": False, "warn": True, "id": "pv", "text": messages.row_pv_single_ignored()}) @@ -7575,19 +5188,11 @@ class Controller: runs = [c for c in bad if c.get("runs")] bad = [c for c in bad if not c.get("runs")] running_alarm = waiting = False - # --- SAFETY-10, AND IT IS FIRST IN THE CASCADE ----------------------- - # "The battery is NOT being controlled" is the one sentence on this - # page that may never be displaced by another condition. A banner that - # can be out-ranked does not STATE a thing plainly; it states it - # whenever nothing else is happening, which is the half of the time - # nobody needs telling. - # - # ⚠️ AND IT CARRIES A STANDING LATCH RATHER THAN HIDING ONE. The three - # `bad` branches below all say some version of "held at 0 W", which is - # still true in shadow mode - but an operator who turns shadow mode off - # with a latch up gets a battery that will not move and a page that - # never mentioned it. `shadow.banner()` names the latch and goes red - # for it, so nothing this cascade would have said is lost. + # SAFETY-10, AND IT IS FIRST IN THE CASCADE "The battery is NOT being + # controlled" is the one sentence on this page that may never be + # displaced by another condition. + # AND IT CARRIES A STANDING LATCH RATHER THAN HIDING ONE. + # (DECISIONS/MAIN-orchestration-archive.md) _shadow = shadow.banner(self.o, control_started=self.auto, latched=self.shadow_latches()) if _shadow is not None: @@ -7595,35 +5200,24 @@ class Controller: elif self.circ_wd.tripped: # ⚠️ ABOVE THE SIGN FAILSAFE, and the order is a decision. Both are # latched P_SAFETY stops, so the site is at 0 W either way and the - # banner can only carry one sentence. This one goes first because it - # is the fault the operator has NO OTHER WAY of learning about: a - # sign disagreement is visible at the meter and on every dashboard - # in the house, and circulating power is visible nowhere at all. + # banner can only carry one sentence. + # (DECISIONS/MAIN-orchestration-archive.md) level, banner = "bad", messages.banner_circulating() elif self.site_sign_wd.tripped and len(self.inverters) > 1: # SAFETY-30's site half, ABOVE the per-unit rows below and below # circulating power above, and the ordering is the same argument - # both times: the banner carries one sentence, so it carries the one - # that says the widest true thing. A site sign trip means EVERY - # inverter is parked and the fault is in what the site was asking - # for, not in one device - naming a unit here would send an operator - # to swap the wrong box. + # both times: the banner carries one sentence, so it carries the + # one that says the widest true thing. + # (DECISIONS/MAIN-orchestration-archive.md) level, banner = "bad", messages.banner_site_direction() elif any(i.sign_wd.tripped for i in self.inverters): # Outranks "Not ready": nothing is missing, something went wrong. - # At a roster of one the unit was graded against the meter; at N>=2 - # against its own power, so the banner says so - see checks() on - # why the noun is load-bearing rather than cosmetic. + # (DECISIONS/MAIN-orchestration-archive.md) level, banner = "bad", messages.banner_direction( len(self.inverters) > 1) elif bad: - # ⚠️ `banner` IF THE ROW OFFERS ONE, `text` OTHERWISE. A row may - # need to say more to an operator reading the checks list than fits - # in the one line at the top of the page - SAFETY-25's floor-limited - # row is the case that forced this - and a headline that is a full - # paragraph, repeated verbatim by the row underneath it, is a - # headline nobody reads. UX-08: without the key, the banner takes - # the row's first sentence, so it stays a headline. + # ⚠️ `banner` IF THE ROW OFFERS ONE, `text` OTHERWISE. + # (DECISIONS/MAIN-orchestration-archive.md) level, banner = "bad", messages.banner_not_ready(bad[0]) # SAFETY-112: nothing to do but wait, so Home says that instead # of "Needs you now". Only when it IS the headline: a safety stop @@ -7638,25 +5232,14 @@ class Controller: elif any(i.delivery_wd.tripped for i in self.inverters): # ⚠️ SAFETY-42, AND IT IS A `warn` BANNER RATHER THAN A `bad` ONE, # WHICH IS THE WHOLE "reports, does not act" DECISION IN ONE LINE. - # Every `bad` banner above means the site is at 0 W; this one means - # the site is STILL RUNNING and quietly short by one unit's share, - # and a red headline would say the controller had stopped. - # - # ⚠️ BUT IT MUST DISPLACE "Running — holding grid at zero", AND THAT - # IS NOT COSMETIC. That sentence is a claim the site cannot make - # while a unit is absent - the loop is saturated and the meter is - # short - and "the page reads healthy while the fault runs" is - # precisely the shape this whole card exists to remove. It sits - # BELOW "Stopped" because a stopped site is the more urgent fact and - # the banner carries one sentence. + # BUT IT MUST DISPLACE "Running — holding grid at zero", AND THAT + # IS NOT COSMETIC. + # (DECISIONS/MAIN-orchestration-archive.md) level, banner = "warn", messages.banner_not_delivering( [i.name for i in self.inverters if i.delivery_wd.tripped]) elif self.maint.phase != IDLE: - # ⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE. At a roster - # of one this is the string it has always been, character for - # character; at two, "Maintenance: drain" without a name is a - # banner an operator cannot act on, because the remedy is at one - # device and the site has two. + # ⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE. + # (DECISIONS/MAIN-orchestration-archive.md) level, banner = "warn", messages.banner_maintenance( self.maint.phase, self.maint_unit.name if len(self.inverters) > 1 else "") @@ -7667,11 +5250,9 @@ class Controller: return "—" if v is None else f"{v:,.0f} W".replace(",", " ") # ⚠️ THE EARLIEST ACROSS THE PACKS, NOT THE FIRST PACK'S. One date on a - # site with two schedules has to be the one that comes first, or the row - # says "2026-10-01" while a pack recalibrates tomorrow. `None` means - # "never run, due now" and has to win over any date, so it is tested for - # rather than sorted with. At a roster of one this is that pack's own - # date, unchanged. + # site with two schedules has to be the one that comes first, or the + # row says "2026-10-01" while a pack recalibrates tomorrow. + # (DECISIONS/MAIN-orchestration-archive.md) _now = datetime.now(timezone.utc) _dues = [m.next_due(_now) for m in self.maints] _waits = [u for u in (m.backoff_until(_now) for m in self.maints) if u] @@ -7679,20 +5260,17 @@ class Controller: rows = [ ("Grid", w(self.grid)), ("Battery", w(self.site_batt_w)), - # SAFETY-31, and it belongs DIRECTLY UNDER the two figures it is not - # derivable from. "Grid 0 W, Battery 0 W, Circulating 6000 W" is the - # whole card in three rows: both of the numbers an operator normally - # reads say the site is idle, and the third says 6 kW is going round - # between the packs. Only shown at N>=2 - at one inverter it is - # identically zero and a permanent "0 W" row would be noise. + # SAFETY-31, and it belongs DIRECTLY UNDER the two figures it is + # not derivable from. "Grid 0 W, Battery 0 W, Circulating 6000 W" + # is the whole card in three rows: both of the numbers an operator + # normally reads say the site is idle, and the third says 6 kW is + # going round between the packs. + # (DECISIONS/MAIN-orchestration-archive.md) *(() if len(self.inverters) <= 1 else ((("Circulating", w(self.site_circulating_w)),))), # TEL-11, and ONLY when a source is configured - see `checks()`. # `w(None)` renders "—", which is how this row says "no reading" - # rather than "0 W". It sits beside Grid and Battery because the - # question it answers is the one those two cannot: at "Grid 0 W, - # Battery 0 W" the site is either becalmed or throwing away every - # watt it makes, and only this row separates them. + # rather than "0 W". (DECISIONS/MAIN-orchestration-archive.md) *(() if not self.pv_roster else ((("Solar production", w(self.pv_w)),))), ("Battery level", "—" if self.pack_floor_soc is None @@ -7734,30 +5312,14 @@ class Controller: if self.price_gap else ()), # FCST-04. WHAT §8.3 HAS TO CARRY FORWARD, which is the difference # between a 48-hour plan and a 12-hour one on a weekday morning. - # It is its own row rather than a clause of "Tariff" because it - # answers a different question: "Tariff" says what the plan IS, and - # this says what the plan could still be filled from if the feed is - # late again tomorrow. + # (DECISIONS/MAIN-orchestration-archive.md) ("Price history", self.price_history.words), # OPT-04. The strategy's own sentence, not a re-derivation of it - # every refusal it can make already carries a reason a human can # act on, and "Arbitrage: no" would throw all of them away. - # - # ⚠️ TARIFF-10 RE-READ THIS STRING AND LEFT IT ALONE UNDER A - # COMMENT THAT WAS FALSE, AND TARIFF-11 IS THAT CORRECTION. The - # claim was that `self.arb is None` **if and only if** - # `arbitrage.enabled(self.o)` was False. Only the forward direction - # holds: `cycle()` returns at the input hold (`bad_for < - # stale_input_s`, default 15 s) BEFORE it reaches `self.arb = None` - # at all, so on an armed site `self.arb` is `__init__`'s None at - # startup and the PREVIOUS cycle's decision during a later hold - - # and the row rendered `off (arbitrage_enabled)` on a site whose - # `arbitrage_enabled` is `true`. `arbitrage_row()` reads the option - # instead of inferring it, and separates "trading is off" from "no - # decision was reached this cycle"; `cycle_at` and `arb_at` are what - # make the second one an observation rather than a guess. - # OPT-11 P3: with battery modes on the modes trade, whatever the - # switch says, so the row reports their decision. + # TARIFF-10 RE-READ THIS STRING AND LEFT IT ALONE UNDER A COMMENT + # THAT WAS FALSE, AND TARIFF-11 IS THAT CORRECTION. + # (DECISIONS/MAIN-orchestration-archive.md) ("Arbitrage", arbitrage_row( on=arbitrage.enabled(self.o) or self.modes_on, decision=self.arb, @@ -7765,20 +5327,15 @@ class Controller: now=datetime.now(timezone.utc), why=self.why_words)), ] held = level == "bad" and not running_alarm - # UX-46. The rows were built in English with their Dutch beside them - # (`messages.Bi`); the page's language is chosen once, here, for the - # Home sentences, the checks list, the rows that carry one and the - # banner. A plain str (no Dutch twin) stays as it is. + # UX-46. (DECISIONS/MAIN-orchestration-archive.md) checks = [dict(c, text=messages.pick(c["text"], lang)) for c in checks] rows = [(k, messages.pick(v, lang)) for k, v in rows] banner = messages.pick(banner, lang) out = { "banner": banner, "level": level, "rows": rows, "checks": checks, "hint": self.hint, - # The action log itself, newest first. The page does not show it - # (UX-08 2d), but an installer can still read it in the `status` - # JSON, because several events (a dropped inverter's reason, the - # peak guard's projection) are written nowhere else. + # The action log itself, newest first. + # (DECISIONS/MAIN-orchestration-archive.md) "events": list(self.events), } # UX-13 P1: the Home screen's structured half. Read-only, and it may @@ -7825,19 +5382,8 @@ class Controller: else "control_stopped")) if self.auto: # ⚠️ THE ONLY WAY OUT OF A SIGN-DISAGREEMENT TRIP, on purpose. - # It clears on a deliberate human start, not on the condition - # going away - the condition ALWAYS goes away, because the trip - # commands 0 W and a 0 W command cannot disagree with anything. - # An operator pressing start has, at minimum, looked at the - # banner that says why. - # - # ⚠️ IT CLEARS EVERY INVERTER'S LATCH, AND SAYS SO PER INVERTER. - # One button is what the UI has and one button is what §15's - # banner tells the operator to press; what must not happen is a - # press that clears A while B stays latched with nothing on the - # page changing. Each latch is cleared under its OWN key, so an - # install that had only B tripped comes back with only B's - # history cleared. + # IT CLEARS EVERY INVERTER'S LATCH, AND SAYS SO PER INVERTER. + # (DECISIONS/MAIN-orchestration-archive.md) for inv in self.inverters: if not inv.sign_wd.tripped: continue @@ -7848,44 +5394,17 @@ class Controller: f"operator on {inv.name}", hint=messages.hint("stop_cleared", unit=inv.name)) - # ⚠️ AND THE PRE-DEV-02 FLAT KEYS GO WITH THEM, UNCONDITIONALLY. - # This is what makes `flat True + namespaced False` MEAN - # something, and it is the reason `_grade_store_migration()` can - # run on every start instead of only on the start that migrates. - # - # Without it the flat key is a fossil that says `true` forever - # on any install that ever tripped, so a guard comparing against - # it would re-arm the failsafe on every restart for the rest of - # the install's life - which is why that guard used to be gated - # on `rep.ran`, and why the gate then swallowed the rollback - # ROUND TRIP: migrate, downgrade, trip during the downgrade - # window (the old code writes only the flat key), upgrade again - # -> `rep.ran` is False, the guard never runs, and the - # controller comes up UNLATCHED while /data/state.json says - # `sign_tripped: true`. That is store.py's own header sentence, - # rebuilt by the version marker. - # - # Cleared here, the two spellings can only disagree one way: the - # flat key is newer than the namespaced one, which means it was - # written by a version that did not know about namespaces - i.e. - # a trip inside a downgrade window. Unconditional rather than - # inside the loop because an operator pressing START is - # declaring the whole site clear, and the fossil belongs to no - # unit in particular. + # ⚠️ AND THE PRE-DEV-02 FLAT KEYS GO WITH THEM, + # UNCONDITIONALLY. (DECISIONS/MAIN-orchestration-archive.md) self.store.set("sign_tripped", False) self.store.set("sign_reason", "") # SAFETY-30's SITE latch, cleared by the SAME press and for the # same reason the per-unit ones are: the condition always goes # away on its own, because the trip commands 0 W everywhere and - # a site commanding zero cannot oppose anything. Only a human - # who has read the banner can say the site is clear. - # - # ⚠️ UNCONDITIONAL, LIKE THE FLAT PAIR ABOVE, AND NOT GUARDED ON - # THE ROSTER. An install that ran with two inverters, tripped, - # and was then reconfigured back to one would otherwise carry - # `site_sign_tripped: true` in its file forever, with no roster - # that can reach the code which clears it - and would re-arm the - # moment a second inverter was configured again. + # a site commanding zero cannot oppose anything. + # UNCONDITIONAL, LIKE THE FLAT PAIR ABOVE, AND NOT GUARDED ON + # THE ROSTER. + # (DECISIONS/MAIN-orchestration-archive.md) if self.site_sign_wd.tripped: self.log_event("site sign-disagreement failsafe cleared " "by operator", @@ -7896,13 +5415,9 @@ class Controller: # SAFETY-31, cleared by the SAME press and for the same reason: # the condition always goes away on its own, because the trip # commands 0 W everywhere and two inverters at zero cannot - # oppose. Only a human who has read the banner can say the site - # is clear. - # - # ⚠️ AND `_circ_announced` GOES WITH IT. It is the "has a human - # been shown this" flag; leaving it True through a clear would - # mean the NEXT trip in this process latched, stopped the site, - # and raised no notification at all. + # oppose. + # AND `_circ_announced` GOES WITH IT. + # (DECISIONS/MAIN-orchestration-archive.md) if self.circ_wd.tripped: self.circ_wd.reset() self.store.set("circ_tripped", False) @@ -7910,19 +5425,9 @@ class Controller: self.log_event("circulating-power trip cleared by operator", hint=messages.hint("site_stop_cleared")) self._circ_announced = False - # SAFETY-42, cleared by the SAME press, and for a reason that is - # NOT the one above. The other three latches are cleared here - # because their trip removed its own evidence - a site commanding - # 0 W cannot oppose anything - so only a human can say the fault - # is gone. This one commands nothing, so its evidence is still - # standing and it could in principle clear itself. It does not: - # a unit that stopped delivering for minutes and then resumed is - # an INTERMITTENT fault, and a detector that erased its own - # record of that would be the least useful thing in this file. - # ⚠️ AND `_delivery_announced` GOES WITH IT, like - # `_circ_announced`: leaving a name in it through a clear would - # mean the NEXT trip on that unit latched and raised no - # notification at all. + # SAFETY-42, cleared by the SAME press, and for a reason that + # is NOT the one above. + # (DECISIONS/MAIN-orchestration-archive.md) for inv in self.inverters: if inv.delivery_wd.tripped: self.log_event(f"not-delivering report on {inv.name} " @@ -7935,10 +5440,9 @@ class Controller: inv.store_set("delivery_reason", "") self._delivery_announced = set() if not self.auto: - # ⚠️ THE WHOLE SITE, TARGETS INCLUDED - see command_site_zero(). - # Zeroing `self.target` alone would leave each unit's last share - # standing for its next heartbeat to re-emit, which an operator - # would read as the stop button not working. + # ⚠️ THE WHOLE SITE, TARGETS INCLUDED - see + # command_site_zero(). + # (DECISIONS/MAIN-orchestration-archive.md) await self.command_site_zero() return {"auto": self.auto} if what in ("maint_start", "maint_abort") and unit is not None: @@ -7947,23 +5451,15 @@ class Controller: return await self._maint_unit_action(what, unit, now) if what == "maint_start": # ⚠️ WHICH PACK, BECAUSE THERE ARE NOW SEVERAL AND THE BUTTON HAS - # ONE LABEL. `_maint_button()` answers it: the pack that holds the - # site's maintenance slot if one does - which is what "start" - # already meant at a roster of one, where pressing it mid-drain - # re-entered DRAIN - and otherwise the pack that has gone longest - # without a cycle, which is the same pack `arbitrate()` would have - # picked on its own. At N=1 both branches are `maints[0]` and this - # is the shipped call, byte for byte. - # - # This is the bare request (no unit). A request naming a battery - # was handled above by `_maint_unit_action()` (MAINT-11). + # ONE LABEL. (DECISIONS/MAIN-orchestration-archive.md) _m = self._maint_button(now) for msg in _m.force_start(now): self.log_event(msg, hint=messages.hint("maintenance", phase=_m.phase)) return {"phase": self.maint.phase} if what == "maint_abort": - # Aborts the pack that is actually cycling - `_maint_button()` again + # Aborts the pack that is actually cycling - `_maint_button()` + # again # - and then zeroes the SITE, which is what it always did. _m = self._maint_button(now) for msg in _m.abort(now, "operator"): @@ -7989,11 +5485,7 @@ class ControlTaskDied(RuntimeError): def mode_announcement(opts, store) -> tuple: """OPT-11 P2: `(modes, retract)` for the MQTT publisher at start-up. - - The config topics announced are kept in the store, so turning modes off - removes exactly those (`mqtt.mode_configs()`). An install that never - turned modes on gets nothing, and its store is not written. - """ + (DECISIONS/MAIN-orchestration-archive.md)""" modes = packmode.MODES if packmode.enabled(opts) else () stored = store.data.get(MODE_CONFIGS_KEY) keep, retract = mode_configs(roster_names(opts), modes, stored) @@ -8023,11 +5515,7 @@ DOCS_LINK_TIMEOUT_S = 3 async def docs_link_facts(session, token: str) -> dict: """`{slug, ha_version}` from the Supervisor, each None when unreadable. - - `GET /addons/self/info` and `GET /info` are on the Supervisor's bypass - list for an add-on's own token, so they need no role (see selfconfig.py). - Asked together with a 3 s budget: a link may never hold up control start. - """ + (DECISIONS/MAIN-orchestration-archive.md)""" async def ask(url, field): """`(value, why)`: the field, or None and a short reason (UX-09).""" try: @@ -8115,9 +5603,8 @@ async def amain() -> None: hass = HomeAssistant(session) # Startup self-check: prove we can actually reach the Core API before - # anything tries to control an inverter with it. A 401 here is a - # permissions/token problem, not a configuration mistake, and saying so - # explicitly saves an installer from re-checking entity ids for an hour. + # anything tries to control an inverter with it. + # (DECISIONS/MAIN-orchestration-archive.md) import os as _os _tok = _os.environ.get("SUPERVISOR_TOKEN", "") _LOG.info("supervisor token: %s (%d chars); env has: %s", @@ -8132,31 +5619,24 @@ async def amain() -> None: _LOG.error("core api probe failed: %s", _e) # UX-09: the Details link in every notification needs this add-on's - # slug and Home Assistant's version (see messages.docs_url()). Both - # are best effort: without them the link falls back to the hostname, - # or to naming the section only. + # slug and Home Assistant's version (see messages.docs_url()). + # (DECISIONS/MAIN-orchestration-archive.md) messages.set_site(**await docs_link_facts(session, _tok)) # ⚠️ THE STARTUP GATE (CFG-01). Nothing below this line may run on a # configuration validate.py rejects - specs §1.5: a config the system # cannot honour is refused at startup, not survived at runtime. - # - # It sits HERE, before the broker, before the web server and before any - # task exists, so a refusal cannot leave half a controller running with - # a UI in front of it. Reading the device range is the one thing the - # validator cannot do for itself; (None, None) when ESPHome has not come - # up yet is not a refusal - run_control() re-checks once the real rating - # is known. See validate.py for where the line is drawn and why. + # (DECISIONS/MAIN-orchestration-archive.md) _sp = str(opts.get("setpoint_entity", "") or "").strip() _dev_min = _dev_max = None if _sp: _dev_min, _dev_max, _ = await hass.limits(_sp) _LOG.info("setpoint device %s reports range %s…%s W", _sp, _dev_min, _dev_max) _problems = validate_startup_config(opts, _dev_min, _dev_max) - # UX-15: a new install names no inverter. When that is the ONLY - # refusal it serves the setup wizard in setup-only mode, which builds - # nothing that commands and returns when stopped. Any other refusal - # still exits 78 (DECISIONS/UX-15-setup-only-mode.md). + # UX-15: a new install names no inverter. When that is the ONLY refusal + # it serves the setup wizard in setup-only mode, which builds nothing + # that commands and returns when stopped. + # (DECISIONS/MAIN-orchestration-archive.md) if _problems and setuponly.applies(opts, _problems): await setuponly.serve( opts, HomeAssistant(session, read_only=True), store, @@ -8167,10 +5647,9 @@ async def amain() -> None: raise ConfigRefused(_problems) # ⚠️ Status publishing must NEVER be able to stop the controller. A - # broken broker, a missing library, an API change in paho - all of it is - # observability, and the battery does not care. Caught broadly and on - # purpose: this crashed the add-on once already (paho 1.x vs 2.x) and - # took the control loop down with it. + # broken broker, a missing library, an API change in paho - all of it + # is observability, and the battery does not care. + # (DECISIONS/MAIN-orchestration-archive.md) broker = None try: # OPT-11 P2: each battery's switch and mode list, only while @@ -8185,15 +5664,10 @@ async def amain() -> None: omit=() if is_enabled(opts) else ("p1_age",), # TEL-11. ⚠️ THE SHIPPED PREDICATE, NOT A SECOND READING OF THE # OPTION - `Controller.publish()` gates the row on this same - # call. An install with no PV roster announces exactly - # what it announced before: no topic, no entity, no row. + # call. (DECISIONS/MAIN-orchestration-archive.md) include=optional_keys(opts), - # TEL-07. ⚠️ THE NAMES, NOT A ROSTER. `build_roster()` here - # would construct a SECOND set of `Inverter`s off the same - # Store, minutes before `Controller.__init__` constructs the - # real ones; `roster_names()` is the naming rule on its own and - # is what `build_roster()` itself reads. At a roster of one this - # announces nothing new - see `announced_units()`. + # TEL-07. ⚠️ THE NAMES, NOT A ROSTER. + # (DECISIONS/MAIN-orchestration-archive.md) units=roster_names(opts), modes=_modes, retract=_retract, @@ -8228,23 +5702,16 @@ async def amain() -> None: control_task_done, controller=controller, stop=stop, died=died)) tasks = [control] - # OPT-04. The tariff refresh runs as its OWN long-lived task, for the - # reason P1 ingestion does and then some: `resolve_tariff()` may open an - # HTTPS connection to a supplier, and the control tick is what feeds the - # ESP32 heartbeat. A fetch on that path is a supplier's TLS timeout able - # to push this add-on towards layer 1's 30 s veto. See TARIFF_REFRESH_S. - # - # ⚠️ STARTED FOR A PROVIDER, FOR ARMED TRADING OR FOR BATTERY MODES: - # see `tariff_refresh_wanted()` above and DECISIONS/TARIFF-10 and - # TARIFF-24 for why each term is there. - # - # ⚠️ IT DOES NOT ARM ANYTHING. Fetching a price and trading on one are - # two decisions; this is the first, and `cycle()`'s - # `if arbitrage.enabled(self.o):` is still the only gate on the second. + # OPT-04. + # STARTED FOR A PROVIDER, FOR ARMED TRADING OR FOR BATTERY MODES: see + # `tariff_refresh_wanted()` above and DECISIONS/TARIFF-10 and TARIFF-24 + # for why each term is there. + # (DECISIONS/MAIN-orchestration-archive.md) if tariff_refresh_wanted(opts): tasks.append(asyncio.create_task(controller.run_tariff_refresh())) - # P1 ingestion runs as its own long-lived task. ⚠️ It must not be driven + # P1 ingestion runs as its own long-lived task. ⚠️ It must not be + # driven # off the control loop: telegrams arrive every ~5 s and the loop would # decimate them, so the 15-minute average - the capacity-tariff billing # unit - would be computed from a fraction of the data. @@ -8279,11 +5746,8 @@ def main() -> None: except KeyboardInterrupt: pass except ConfigRefused: - # ⚠️ No traceback, on purpose. The banner refuse() already printed says - # everything an operator needs, and a stack trace on top of it would say - # "this crashed" - the exact confusion AC3 is about. 78 is sysexits(3) - # EX_CONFIG, so even a script reading the exit status can tell a bad - # config from a bug. + # ⚠️ No traceback, on purpose. + # (DECISIONS/MAIN-orchestration-archive.md) raise SystemExit(78) except ControlTaskDied: # No second traceback: control_task_done() logged it at the death.