Files
goodwe-addon/goodwe_controller/DECISIONS/MAIN-orchestration-archive.md
T
glenn schrooyenandClaude 3d31b679eb
checks / unit (pull_request) Failing after 24m55s
infra-pin / fresh (pull_request) Successful in 1s
MAINT-20 part 2: main.py: keep whole sentences, no truncated warnings; refresh archive
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-01 03:33:54 +01:00

129 KiB

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.

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.

  1. 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

  1. 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/<name>/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_ids), 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.

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 Inverters 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.