Word for word in MAIN-orchestration-archive.md, keyed by the line at 16029d7.
No code change.
Co-Authored-By: Claude <noreply@anthropic.com>
2765 lines
137 KiB
Markdown
2765 lines
137 KiB
Markdown
# 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.
|
|
|
|
2. THE ALLOCATOR SUCCEEDED AND THIS CONTROLLER CANNOT DELIVER IT.
|
|
⚠️ RE-AIMED BY DEV-02, NOT DELETED, AND THE DIFFERENCE MATTERS.
|
|
Under ALLOC-01 this fired whenever the split named any inverter but
|
|
the single one this controller could write, because there was one
|
|
`setpoint_entity`. There is a writer per inverter now, so that
|
|
trigger is gone - and the INSTINCT is not, because a roster entry
|
|
can still have no writer: an inverter configured with no
|
|
`setpoint_entity` of its own is an inverter this controller cannot
|
|
put a number on. Writing the site target to the ones it CAN reach
|
|
would over-command them by exactly the missing unit's share -
|
|
not the broadcast fault, but the same shape of over-command.
|
|
Commanding a partial site is a worse instruction than commanding
|
|
nothing, and "nothing" here means 0 W and says so.
|
|
|
|
⚠️ IT IS NOT THE SAME EVENT AS A DEAD LINK, WHICH IS WHY IT IS NOT
|
|
FOLDED INTO `live_units()`. A unit whose writes are being rejected
|
|
is DROPPED and its share is carried by the survivors - a real, and
|
|
announced, change in what the site can deliver. A unit with no
|
|
writer configured at all is a CONFIGURATION that cannot be
|
|
delivered, and the site stops rather than silently running at part
|
|
strength on a roster somebody wrote down wrong.
|
|
|
|
### L5643 ⚠️ `shares={}`, NOT THE SHARES IT REFUSED TO DELIVER, AND SINCE
|
|
|
|
⚠️ `shares={}`, NOT THE SHARES IT REFUSED TO DELIVER, AND SINCE
|
|
DEV-02 THAT IS LOAD-BEARING RATHER THAN TIDY. The write loop below
|
|
commands each inverter `shares.get(name, 0.0)`, so carrying the
|
|
split through a refusal would write the very numbers this branch
|
|
just declined - and, worse, would write NOTHING at all when they
|
|
equal what is already standing, because an unchanged value is not
|
|
re-sent. The record the operator needs survives on
|
|
`self.allocation`, which is assigned above and untouched here.
|
|
|
|
### L5658 3. AC 6: A DROPPED INVERTER IS NEVER SILENT. The survivors carry its
|
|
|
|
3. AC 6: A DROPPED INVERTER IS NEVER SILENT. The survivors carry its
|
|
share, which is a real change in what the site can deliver, and an
|
|
operator who is not told reads a working site. Edge-triggered on
|
|
the SET of dropped units, not level-triggered, because the action
|
|
log is a log of events (§15) and a stale link would otherwise write
|
|
a line every second for as long as it lasts.
|
|
|
|
### L5676 3b. OPT-09 / IF-04: WATTS NOBODY COULD TAKE ARE NEVER SILENT EITHER.
|
|
|
|
3b. OPT-09 / IF-04: WATTS NOBODY COULD TAKE ARE NEVER SILENT EITHER.
|
|
`unmet_w` is the site target the envelopes could not absorb. It is
|
|
NOT a broken allocation - `Allocation`'s own docstring says why
|
|
fusing it with `ok` would let one saturated inverter park the whole
|
|
site - but it IS the site failing to deliver what the loop asked
|
|
for, and before this card nothing anywhere reported it. It is the
|
|
state a closed envelope produces when BOTH packs are at their
|
|
limits: the ask has nowhere left to go, and an operator watching a
|
|
standing grid error needs the reason rather than the symptom.
|
|
Edge-triggered on the VALUE, for `_dropped` reason.
|
|
|
|
### L5698 A claim reached the arbiter on an actuator its source's scope does
|
|
|
|
A claim reached the arbiter on an actuator its source's scope does
|
|
not permit. It is a BUG in the filing site, not a state to survive:
|
|
a per-inverter constraint in the site list is the `lo > hi` this
|
|
card removes, and a site constraint on one unit is a bound the
|
|
other unit never gets. Loud, every cycle, and it does not change
|
|
the resolution - the arbiter already routed by `actuator`.
|
|
|
|
### L5709 SAFETY-02's arming gate
|
|
|
|
SAFETY-02's arming gate
|
|
Was the number about to go on the wire chosen by the grid-following
|
|
loop, or by a strategy that commands against the meter on purpose?
|
|
Only the former is expected to agree in sign with P1 - without this,
|
|
every maintenance grid charge trips the failsafe within three minutes.
|
|
|
|
⚠️ THE EXPRESSION LIVES IN watchdog.py, NOT HERE, and that is not
|
|
tidiness. It was inline, test_watchdog.py held a transcription of it,
|
|
and the P_PEAK case meant to force whoever lands peak shaving to come
|
|
and change an expectation did not: editing this line alone to
|
|
`winner in ("loop", "peak")` left every suite green while the watchdog
|
|
went blind for every peak event. Read loop_is_following() before
|
|
touching the gate - the forward hazard and the reasoning are there.
|
|
|
|
### L5725 UX-08: the same answer in plain words, for the page's "Why" line.
|
|
|
|
UX-08: the same answer in plain words, for the page's "Why" line.
|
|
`reason` stays the technical record the log and the checks read.
|
|
UX-10: `why_key()` maps a safety winner to its claim's reason, and
|
|
every key it can return has words in messages.WHY_WINNER.
|
|
|
|
### L5739 DEV-02: EACH INVERTER IS TOLD ITS OWN SHARE
|
|
|
|
DEV-02: EACH INVERTER IS TOLD ITS OWN SHARE
|
|
⚠️ ITS OWN SHARE, NEVER THE SITE TARGET. `resolution.shares` is the
|
|
allocator's answer and `sum(shares) + unmet_w == round(target_w)` is
|
|
an integer identity, so handing each unit its entry is what makes the
|
|
gain at the meter a CONVEX COMBINATION of the per-unit gains. Handing
|
|
each unit `self.target` is the ARCH-01 fault: each supplies it in
|
|
full, the gain becomes N times the per-unit gain, and on the
|
|
two-master rig that is 4000 W peak-to-peak forever at gain 1.0 with
|
|
not one guard firing.
|
|
|
|
⚠️ AT N=1 THIS IS BYTE-IDENTICAL. The allocator's pass-through returns
|
|
`{inverter-a: target_w}` as an unrounded float, so the number written
|
|
is `self.target` exactly, as it always was.
|
|
|
|
⚠️ AND A UNIT NOT IN `shares` IS COMMANDED 0 W, not left standing.
|
|
That is the dropped-link case: the inverter holds its last command
|
|
FOREVER, so "not in the split" must mean zero on the wire, not
|
|
silence. The write is the same clamped, budgeted path as every other.
|
|
SAFETY-85, before the writes: the wire still holds last cycle's.
|
|
|
|
### L5765 SAFETY-03, and its boundary is the WRITE
|
|
|
|
SAFETY-03, and its boundary is the WRITE
|
|
⚠️ NOT THE SAME BOUNDARY AS SAFETY-18's BELOW, THOUGH THE CALLS ABUT.
|
|
This one reads `self.clamp_last`, which is None until write_setpoint()
|
|
has actually run, so it has to follow the write. The one below grades
|
|
`self.target`, which is assigned further up - see check_output_clamp()
|
|
and check_meter_frozen() for each. Two guards, two orderings, adjacent
|
|
lines: do not "tidy" them into one rule.
|
|
|
|
Moving it BELOW the other guards is harmless - nothing between here and
|
|
the end of cycle() writes `self.clamp_last`. Moving it above the write
|
|
is not, and test_clamp.py part 6 fails on exactly that: it grades the
|
|
evidence, not the line number, so a rename or a move into a helper that
|
|
still runs after the write keeps it green. (TEST-11.)
|
|
|
|
### L5780 SAFETY-18, and its boundary is the ASSIGNMENT
|
|
|
|
SAFETY-18, and its boundary is the ASSIGNMENT
|
|
⚠️ THE BOUNDARY IS `self.target = resolution.target_w` ABOVE, NOT
|
|
write_setpoint(). This comment used to say "the command that has just
|
|
gone on the wire", which is wrong twice over and was corrected by
|
|
TEST-06's reviewer: `check_meter_frozen()` grades `self.target` - the
|
|
arbiter's resolved intent - and `write_setpoint()` clamps only a local
|
|
`value` and can skip the write entirely, so that number may never have
|
|
gone anywhere.
|
|
|
|
What both terms of the conjunction must describe is the SAME moment:
|
|
the command this cycle resolved, against how long the meter that chose
|
|
it has been standing still. Evaluated before
|
|
`self.target = resolution.target_w` it would grade the PREVIOUS
|
|
cycle's command against THIS cycle's meter - the one combination that
|
|
was true of neither moment. Moving it between the assignment and
|
|
write_setpoint() is therefore harmless; moving it above the assignment
|
|
is not, and test_freeze.py part 5 fails on exactly that.
|
|
|
|
### L5805 SAFETY-31's alarm, and its boundary is the LATCH, not a number
|
|
|
|
SAFETY-31's alarm, and its boundary is the LATCH, not a number
|
|
Unconstrained where the two above are not: it grades
|
|
`self.circ_wd.tripped`, which was advanced near the top of this method
|
|
beside SAFETY-02 and which nothing since has touched. It is called on
|
|
EVERY cycle rather than only on the cycle that trips, because its
|
|
second job is re-raising the alarm after a restart into an existing
|
|
trip - see its docstring on why that shares one code path with the
|
|
fresh trip instead of getting its own.
|
|
|
|
### L5815 SAFETY-42's alarm, and its boundary is the LATCH, like SAFETY-31's
|
|
|
|
SAFETY-42's alarm, and its boundary is the LATCH, like SAFETY-31's
|
|
It grades `inv.delivery_wd.tripped`, which was advanced near the top
|
|
of this method beside SAFETY-02 and SAFETY-31 and which nothing since
|
|
has touched. Called on EVERY cycle rather than only on the cycle that
|
|
trips, because its second job is re-raising the alarm after a restart
|
|
into an existing trip - see its docstring on why that shares one code
|
|
path with the fresh trip instead of getting its own.
|
|
|
|
### L5829 SAFETY-24's alarm, and it is the OTHER way the money gets spent
|
|
|
|
SAFETY-24's alarm, and it is the OTHER way the money gets spent
|
|
Same boundary argument as check_peak_saturated(): it grades
|
|
`self.peak_guard`, which cycle() assigned above and nothing since has
|
|
touched. Kept as its own method rather than folded into that one,
|
|
because the two states are mutually exclusive and want OPPOSITE
|
|
operator actions - saturation says "shed load, the inverter is already
|
|
flat out"; this says "the pack is at its floor and this quarter is not
|
|
being shaved at all".
|
|
|
|
### L5839 TARIFF-01, and it is LAST on purpose
|
|
|
|
TARIFF-01, and it is LAST on purpose
|
|
Banking a quarter is bookkeeping about a moment that has already
|
|
happened; a slow SD card must never sit between the meter and the
|
|
setpoint. Its position is unconstrained in the way SAFETY-03's and
|
|
SAFETY-18's are not - it grades neither `target` nor `standing` and
|
|
reads only `self.p1`, which no line in this method writes.
|
|
|
|
### L5847 SAFETY-10, AND IT IS LAST FOR A REASON THAT IS NOT TIDINESS
|
|
|
|
SAFETY-10, AND IT IS LAST FOR A REASON THAT IS NOT TIDINESS
|
|
Every guard above has now read `self.target` - the resolved intent -
|
|
and SAFETY-18 in particular grades exactly that. Re-seeding before
|
|
them would hand each one a 0 W that no decision produced. Re-seeding
|
|
AFTER them hands the NEXT cycle, and the sign watchdogs, what is
|
|
actually standing on the wire. See `_shadow_settle()`.
|
|
|
|
### L5856 SAFETY-10. Record the decision, then re-seed the carried command.
|
|
|
|
SAFETY-10. Record the decision, then re-seed the carried command.
|
|
|
|
TWO JOBS, AND THEY ARE IN THIS ORDER BECAUSE THE SECOND DESTROYS THE
|
|
FIRST'S EVIDENCE.
|
|
|
|
1. THE RECORD. A shadow run is only worth running if it answers "what
|
|
would it have done, and why". `self.shadow_would_w` and
|
|
`Resolution.explain()` are that answer, and the action log gets one
|
|
line per CHANGE OF DECISION rather than one per cycle - the log
|
|
holds 40 rows, and a level-logged shadow run would flush the context
|
|
of its own findings inside four minutes at a 15 s cycle floor. The
|
|
same edge rule the peak guard and the arbitrage strategy already
|
|
use, for the same reason.
|
|
|
|
2. THE RE-SEED. `self.target` and each `inv.target` are set back to
|
|
`shadow.WIRE_W` - what the write gate actually put on the wire.
|
|
|
|
⚠️ WHY THE RE-SEED IS A SAFETY PROPERTY AND NOT BOOKKEEPING. Three
|
|
machines read the carried command as a statement about the HARDWARE:
|
|
|
|
* `compute(prev_w=self.target, ...)` - the loop's slew limiter and
|
|
its integrator. Left holding a shadow decision, the first cycle
|
|
after shadow mode is turned off would slew from a base that never
|
|
existed: up to a full `slew_w` step onto a battery sitting at 0 W.
|
|
Re-seeded, the exit is safe BY CONSTRUCTION and needs no separate
|
|
transition code - the carried command already equals the standing
|
|
one.
|
|
* `Inverter.standing_w()`, which is what SAFETY-02/30's sign
|
|
watchdogs grade. Fed a shadow decision they would compare a number
|
|
against a meter that never saw it, disagree for as long as the
|
|
decision asked for power, and LATCH A TRIP THAT SURVIVES INTO LIVE
|
|
OPERATION AND A RESTART. Shadow mode would manufacture the failsafe
|
|
it exists to help debug. ⚠️ THIS ONE IS ALREADY HELD BY THE WIRE
|
|
GATE - `standing_w()` prefers `last_write`, and that is the 0.0 W
|
|
the gate accepted - so the re-seed is the SECOND rung, covering
|
|
the fallback before any write has been accepted. Two rungs
|
|
answering one question is MUTATION.md's masking shape, and the
|
|
mutation that reproduces the hazard has to defeat BOTH: driven
|
|
singly, dropping this line leaves the detector inert and names
|
|
nothing about the latch. Measured.
|
|
* SAFETY-42's delivery watchdog compares `inv.target` against what
|
|
the pack is producing. A share nothing was told to produce is a
|
|
delinquency report on every cycle of every shadow run.
|
|
|
|
⚠️ AND EVERY CYCLE'S DECISION IS THEREFORE COMPUTED FROM WHERE THE
|
|
PLANT REALLY IS, not from a counterfactual plant that drifts further
|
|
from reality every tick. "What would this controller command NOW,
|
|
given this meter and a battery at 0 W" is a true statement about the
|
|
present; "what would the eleventh cycle of a run that never happened
|
|
have commanded" is not a statement about anything.
|
|
|
|
|
|
### L5909 ⚠️ `first` EXISTS SO A LIVE INSTALL'S FIRST CYCLE IS SILENT. The
|
|
|
|
⚠️ `first` EXISTS SO A LIVE INSTALL'S FIRST CYCLE IS SILENT. The
|
|
edge is against `None` on cycle one, and an install that comes up
|
|
CONTROLLING has nothing to report - a "shadow mode off" line at
|
|
every start is one more row of boilerplate in a 40-row log, and
|
|
it was measured pushing a real clamp episode's context out of
|
|
test_clamp.py part 2's ring. Coming up IN shadow mode is the
|
|
other direction and it is always worth a line: it is the log an
|
|
operator reads to find out why nothing is being commanded.
|
|
|
|
### L5945 SAFETY-47. ⚠️ NO LOGIC HERE, DELIBERATELY. All three rungs - the
|
|
|
|
SAFETY-47. ⚠️ NO LOGIC HERE, DELIBERATELY. All three rungs - the
|
|
arbiter's winner, the leg being a CHARGE, and the headroom having
|
|
been MEASURED - live inside `arbitrage.sized_from_meter()`, which
|
|
says why, including why it is NOT keyed on whether the trade was
|
|
capped. A rung written at a call site is a rung no check can reach
|
|
with the other two clear, which is TEST-16's rule.
|
|
|
|
### L5953 ⚠️ THE WINNER AND THE ROUNDED WATTS, NOT THE WATTS ALONE. A decision
|
|
|
|
⚠️ THE WINNER AND THE ROUNDED WATTS, NOT THE WATTS ALONE. A decision
|
|
that moves from the loop to the peak guard at the same number is a
|
|
different decision and the operator needs the line; a loop target
|
|
that wanders by a watt is not, and 15 s of those would be the whole
|
|
log.
|
|
⚠️ SAFETY-47 ADDS THE CAVEAT FLAG TO THE EDGE. The same winner at the
|
|
same watts with and without the caveat are two different claims about
|
|
the same number, and an edge key blind to that would print the first
|
|
one and silently keep it standing. It cannot flood: the flag only
|
|
moves when arbitrage starts or stops owning the resolution, or when
|
|
the guard starts or stops measuring the quarter, and both of those
|
|
move the winner or the watts as well on every cycle measured.
|
|
|
|
### L5979 SAFETY-85. The loop's clamp counts only on a cycle the loop decided
|
|
|
|
SAFETY-85. The loop's clamp counts only on a cycle the loop decided
|
|
(a rating may cut it). A battery another owner set to discharge counts
|
|
when it gave less than it was told for `saturation_cycles` cycles in a
|
|
row. DECISIONS/SAFETY-85-no-false-not-keeping-up.md.
|
|
|
|
### L6025 SAFETY-119: does the loop's bound on this cycle count? Not within
|
|
|
|
SAFETY-119: does the loop's bound on this cycle count? Not within
|
|
a settle after another owner handed the wire back: its integrator is
|
|
re-seeded from that owner's command and kicked by a meter that does
|
|
not show it yet. SAFETY-121: the settle is HANDBACK_CYCLES cycles and
|
|
SHORT_SPAN_S after the hand-back, so a bound that outlasts it counts
|
|
at any cycle length; losing the wire restarts it. Across all
|
|
hand-backs the bound is hidden on at most ROLLING_BOUND_CYCLES cycles
|
|
in any ROLLING_S, so a bound that never outlasts a settle is counted
|
|
too. DECISIONS/SAFETY-121-bound-every-quarter.md.
|
|
|
|
### L6060 SAFETY-03. Alarm whenever a clamp actually engaged this cycle.
|
|
|
|
SAFETY-03. Alarm whenever a clamp actually engaged this cycle.
|
|
|
|
Runs AFTER write_setpoint() for the same reason SAFETY-18 does: the
|
|
clamp is arithmetic done inside the write, so before the write there is
|
|
nothing yet to report.
|
|
|
|
⚠️ PUBLISHING IS NOT IN THE WRITE PATH. The clamp itself is synchronous
|
|
inside write_setpoint(); this - the awaits, the notification, anything
|
|
that can block on Home Assistant - is out here. A stalled supervisor must
|
|
never be able to delay or drop a frame.
|
|
|
|
⚠️ THIS IS NOT A TRIP. No claim is filed, nothing is latched, 0 W is not
|
|
commanded and `publish()` still reports "running". §11.2's action for
|
|
this row is "clamp and alarm", and the clamped value is written and the
|
|
loop keeps running. Turning this into a trip means coming here and
|
|
changing that deliberately, not discovering it in the field.
|
|
|
|
|
|
### L6077 ⚠️ THE EVIDENCE IS PER UNIT AND THE ALARM IS NOT. `clamp_last` moved
|
|
|
|
⚠️ THE EVIDENCE IS PER UNIT AND THE ALARM IS NOT. `clamp_last` moved
|
|
onto the Inverter because a clamp on B says nothing about A; the
|
|
ClampWatchdog stayed one per controller because clamp.py's own
|
|
docstring says so - it is an anti-flood counter over an EVENT BUS, not
|
|
a property of a device, and one notification per inverter per cycle is
|
|
the flood it exists to remove. So: any unit that clamped is an
|
|
engagement, and the reason NAMES the unit.
|
|
|
|
### L6107 THE ACTION LOG, AND IT IS COALESCED PER EPISODE
|
|
|
|
THE ACTION LOG, AND IT IS COALESCED PER EPISODE
|
|
Every engagement is recorded, but consecutive ones are recorded by
|
|
UPDATING one row's count rather than inserting a row each.
|
|
|
|
⚠️ THAT IS NOT COSMETIC, AND IT WAS FOUND THE HARD WAY. `log_event()`
|
|
keeps 40 rows; a winding loop clamps on every cycle, so a row per
|
|
engagement flushes the whole ring within 40 cycles - and the first
|
|
thing it evicted, in test_freeze.py, was SAFETY-18's "METER FROZEN?"
|
|
row. A guard whose log drowns out the other guards' logs has moved the
|
|
flood rather than removed it. The count here is `clamp_wd.engagements`,
|
|
so nothing is lost: the row says exactly how many, and the number is
|
|
read off the counter rather than off the number of rows.
|
|
|
|
This lives here rather than only in write_setpoint() because on a
|
|
default install the actuator-side clamp is unreachable - compute() has
|
|
already bounded the integrator - so the only engagements that exist are
|
|
loop-side ones. Measured: 60 cycles of the §10.3 runaway produce 0
|
|
write-path engagements and 2 loop-side ones.
|
|
|
|
### L6162 A fixed notification_id, for the reason check_meter_frozen() gives -
|
|
|
|
A fixed notification_id, for the reason check_meter_frozen() gives -
|
|
and a DIFFERENT one from every other alarm this add-on raises, because
|
|
a clamp is not a frozen meter and neither is a saturation freeze.
|
|
UX-08: the words are messages.output_clamp()'s; the numbers are the
|
|
ones `reason` was written from (ClampWatchdog.facts), asked first.
|
|
|
|
### L6172 SAFETY-07. Alarm ONLY when the guard has run out of inverter.
|
|
|
|
SAFETY-07. Alarm ONLY when the guard has run out of inverter.
|
|
|
|
⚠️ AN ENGAGEMENT IS NOT AN ALARM, AND THE DIFFERENCE IS §10.3's DOCTRINE
|
|
READ CAREFULLY. "Treat an out-of-range computed setpoint as a bug signal
|
|
- clamp it, then alarm" is SAFETY-03's rule and it is right there: the
|
|
loop computing 14 768 W is a WINDING INTEGRATOR, a defect, and clipping
|
|
it silently is what let it keep climbing behind a dashboard full of legal
|
|
numbers. A peak floor above the inverter's rating is not a defect. It is
|
|
a house load bigger than the inverter - 2026-08-20 15:45 ran ~5900 W
|
|
against a 5000 W unit - and alarming on the ordinary case is how an
|
|
operator learns to dismiss the notification that matters. So an
|
|
engagement is an ordinary control action: it goes to the §15 action log
|
|
and the status page, and no further.
|
|
|
|
What DOES earn the operator's attention is saturation: the guard asked
|
|
for more discharge than the inverter can give, so the quarter is going
|
|
over the ceiling anyway and the controller has already done everything
|
|
available to it. That is the one state where the money is going to be
|
|
spent and a human is the only remaining actuator.
|
|
|
|
SAFETY-81: `g.saturated` alone is not enough. It is graded against the
|
|
aim, and a 1 s meter spike, noise or the last seconds of a quarter set
|
|
it while the quarter lands on target; `peakguard.load_too_high()` must
|
|
agree too, for `ALARM_HOLD_S`. The battery reading it gets is the lower
|
|
of this cycle's and the last one's - see there.
|
|
|
|
Edge-triggered, and de-asserting arms it again - the same shape as
|
|
FreezeWatchdog's `evaluate()`, and for the same reason: one notification
|
|
per episode, not one per cycle. SAFETY-81: the notification and action
|
|
log line at most once per quarter; the ERROR line once per episode.
|
|
|
|
|
|
### L6254 SAFETY-24. Alarm when the EMS SoC floor stops the guard shaving.
|
|
|
|
SAFETY-24. Alarm when the EMS SoC floor stops the guard shaving.
|
|
|
|
⚠️ FAILING TO SHAVE MUST NOT BE SILENT, AND THAT IS THE WHOLE POINT.
|
|
The guard exists for one euro figure - the capaciteitstarief bills the
|
|
month's single worst quarter - so a cycle in which it CHOSE not to shave
|
|
is a cycle in which money is being spent on purpose. Under-shaving
|
|
quietly would leave the operator with a bill and a status page full of
|
|
green.
|
|
|
|
The distinction from check_peak_saturated() is the operator's action,
|
|
which is why these are two notifications and not one:
|
|
|
|
saturated the guard asked for everything the inverter has and it
|
|
was not enough. SHED LOAD - the controller has already
|
|
done all it can, and it is still discharging.
|
|
floor-limited the guard asked for NOTHING, because the pack is at or
|
|
under the EMS floor, or its SoC could not be read.
|
|
Nothing is being discharged for this quarter at all.
|
|
(SAFETY-87, N>=2: or the batteries above their
|
|
reserve give all they can and it is not enough.)
|
|
|
|
They cannot both be true: peakguard.decide()'s floor-limited branch
|
|
returns before the `achievable_w` clamp that sets `saturated`.
|
|
|
|
⚠️ SAFETY-25 - AND THE MESSAGE SAYS WHAT THE FLOOR IS NOT. Read on its
|
|
own, "the capacity-peak guard has run out of battery" invites exactly
|
|
one wrong inference: that the pack is now being held at
|
|
`peak_soc_floor`. It is not. `peak_soc_floor` bounds THIS GUARD only;
|
|
the grid-following loop has no SoC floor at all and, on the committed
|
|
2026-08-20 replay, is being granted discharge in EVERY cycle that raises
|
|
this notification. An operator who reads the alarm and relaxes because
|
|
"the EMS has a 20 % reserve" has been misled by us, so the alarm carries
|
|
the correction itself rather than leaving it to DOCS.md.
|
|
|
|
Edge-triggered, and de-asserting arms it again - FreezeWatchdog's shape,
|
|
for FreezeWatchdog's reason: one notification per episode, not one per
|
|
cycle for a quarter of an hour.
|
|
|
|
|
|
### L6304 No prefix: `reason` already opens with PEAK_GUARD_FLOOR_LIMITED, and
|
|
|
|
No prefix: `reason` already opens with PEAK_GUARD_FLOOR_LIMITED, and
|
|
a second copy is the kind of stutter that makes a 40-line action log
|
|
read like boilerplate. The saturated path needs its prefix because its
|
|
reason carries the word in the middle of a sentence, not at the front.
|
|
|
|
### L6313 A fixed notification_id, and a DIFFERENT one from every other alarm
|
|
|
|
A fixed notification_id, and a DIFFERENT one from every other alarm
|
|
here - check_meter_frozen() gives the reasoning. A battery at its
|
|
energy floor is not a saturated inverter and is not a frozen meter,
|
|
and an operator who has to dismiss three identical-looking cards
|
|
learns to dismiss the one that mattered.
|
|
⚠️ SAFETY-25's sentence must render on the unreadable-SoC branch too,
|
|
where `soc_floor` is a number but naming it would invite the reader to
|
|
think the pack is AT it. `decide()` guards its own reason string the
|
|
same way; a f-string that formats a None here would turn the one
|
|
notification that matters into a traceback.
|
|
SAFETY-32/87: which battery is at its reserve, each graded on its own
|
|
floor (`g.held`). At a roster of one no battery is named.
|
|
⚠️ SAFETY-25's sentence (the reserve binds the peak guard only) is
|
|
part of messages.peak_reserve() on every branch, the unreadable-SoC
|
|
one included.
|
|
|
|
### L6360 SAFETY-31 + SAFETY-34. The alarm, and it must survive a restart.
|
|
|
|
SAFETY-31 + SAFETY-34. The alarm, and it must survive a restart.
|
|
|
|
⚠️ THE LATCH IS NOT THE ALARM. `circ_tripped` in `/data/state.json`
|
|
survives everything, but nothing looks at a JSON file at 3 a.m. What the
|
|
operator sees is a `persistent_notification`, and THAT IS IN MEMORY: a
|
|
Home Assistant restart deletes it while the fault runs on. This is not a
|
|
hypothetical - it is precisely how the 2026-09-02 outage went unseen for
|
|
6 h 25 m with the detector correct the whole time (SAFETY-34).
|
|
|
|
So the durable half and the visible half are joined HERE, and the join
|
|
is `_circ_announced`: a per-PROCESS flag, deliberately not persisted.
|
|
The latch says "this site is tripped"; the flag says "this process has
|
|
put that in front of a human". A restart clears the flag and keeps the
|
|
latch, so the notification is RE-RAISED on the first cycle after every
|
|
add-on start. A Core-only restart does NOT restart the add-on, so it is
|
|
also re-asserted every REANNOUNCE_S (SAFETY-34).
|
|
|
|
⚠️ ONE CODE PATH FOR BOTH, ON PURPOSE. A fresh trip and a restart into
|
|
an existing trip raise the alarm through these same lines. Two paths
|
|
would be two chances for the restart one to rot unnoticed, and the
|
|
restart one is the case nobody exercises by hand.
|
|
|
|
|
|
### L6386 Set BEFORE the await. `hass.call()` can raise or hang, and a retry
|
|
|
|
Set BEFORE the await. `hass.call()` can raise or hang, and a retry
|
|
every cycle for a broker that is down would be an alarm storm on the
|
|
event bus on top of a site that is already stopped. The latch is
|
|
already durable and the log line above already fired; this surface is
|
|
best-effort by design.
|
|
|
|
### L6410 SAFETY-42 + SAFETY-34. The alarm, and it must survive a restart.
|
|
|
|
SAFETY-42 + SAFETY-34. The alarm, and it must survive a restart.
|
|
|
|
⚠️ THE LATCH IS NOT THE ALARM, and this guard has nothing BUT the alarm.
|
|
`unit/<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_id`s), so `battery` stays the number the meter sees the
|
|
battery doing - the SUM - and `soc` stays the one a floor is graded
|
|
against - the EMPTIEST pack. At the shipped roster of one both are
|
|
inverter A's own readings, unchanged. Per-inverter entities are
|
|
TEL-07's, not a key rename here.
|
|
|
|
### L6882 ⚠️ SAFETY-02's alarm surface, and it is deliberately an existing
|
|
|
|
⚠️ SAFETY-02's alarm surface, and it is deliberately an existing
|
|
one. IF-01/IF-02 own `ems/event/failsafe` and the `failsafe active`
|
|
binary sensor and neither is built yet; inventing a bespoke topic
|
|
here would be a surface to migrate later. This sensor is already
|
|
discovered, already on every dashboard, and "failsafe" is
|
|
additive to its vocabulary rather than a change to it.
|
|
SAFETY-30 adds the site sign latch to the same vocabulary, for
|
|
the reason the note above gives: "failsafe" is additive to a
|
|
sensor that already exists and is already on every dashboard, and
|
|
a P_SAFETY stop that did not reach it would be a site at 0 W
|
|
reporting "running".
|
|
|
|
### L6898 SAFETY-10: "shadow" is its own value and not a
|
|
|
|
SAFETY-10: "shadow" is its own value and not a
|
|
flavour of "stopped". A fleet dashboard reporting a
|
|
shadow install as `running` would be calling a
|
|
battery nobody is controlling healthy; one reporting
|
|
it as `stopped` would lose the fact that the decision
|
|
path is live and being recorded. ⚠️ BELOW `failsafe`:
|
|
a latch that is up is up, and shadow does not clear it.
|
|
|
|
### L6907 SAFETY-31. ⚠️ MEASURED, not the allocation record - see
|
|
|
|
SAFETY-31. ⚠️ MEASURED, not the allocation record - see
|
|
`site_circulating_w`. `publish()` drops None values, so a cycle
|
|
with a missing per-unit reading leaves the last figure standing
|
|
rather than publishing a zero that would read as health.
|
|
|
|
### L6913 TEL-10. The loop's own three, written from the fields `cycle()`
|
|
|
|
TEL-10. The loop's own three, written from the fields `cycle()`
|
|
recorded and not re-derived here - the page and the publisher run
|
|
on their own tasks and `self.target` has moved on by the time
|
|
either of them looks (SAFETY-47's note on `sized_from_meter`, one
|
|
field up, is the same hazard).
|
|
|
|
### L6922 TEL-11
|
|
|
|
TEL-11
|
|
⚠️ THE KEY IS PRESENT WITH THE VALUE `None` WHEN THERE IS NO READING,
|
|
AND ABSENT ENTIRELY WHEN THERE IS NO SOURCE. Those are two different
|
|
states and the publisher treats them as two: `mqtt.ABSENT_AS_UNKNOWN`
|
|
turns the first into HA's own `None` payload, so the entity's state
|
|
goes to `unknown` and the recorder records a GAP - which is how a
|
|
reader of a week of history tells "no PV reported" from "PV reported
|
|
0 W". The second never reaches the broker at all, because an install
|
|
that never set `pv_entity` or `pv_entities` has no such entity
|
|
announced. ⚠️ TEL-12: AND THE ROW IS STILL ONE NUMBER. At a roster of
|
|
three it is the SITE total, `None` unless EVERY entity answered - see
|
|
`pv.total()`, whose note is where the decision not to publish a
|
|
partial sum is argued. This line did not have to change for that, and
|
|
that is the point: TEL-11's `None`-is-an-absence rule already covered
|
|
it, one layer up.
|
|
|
|
⚠️ AND THE GATE IS `optional_keys()`, THE SAME EXPRESSION THE
|
|
ANNOUNCEMENT USES. Written as a second predicate here the two could
|
|
disagree, and both directions are bad - an announced entity nothing
|
|
feeds, or a topic nothing declared. `announced_units()` two blocks
|
|
down is the same discipline for the roster.
|
|
|
|
### L6946 ⚠️ ONLY when P1 ingestion is actually running. The ESP32's stale-input
|
|
|
|
⚠️ ONLY when P1 ingestion is actually running. The ESP32's stale-input
|
|
watchdog subscribes to sensor.goodwe_p1_sample_age - see the entity-id
|
|
note in mqtt.py; the composed name is not the object_id - and forces layer-1
|
|
failsafe once it reaches max_age_s. With meter_source off there is no
|
|
ingester feeding it, so published_age_s would be time-since-startup
|
|
climbing without bound - i.e. every existing install would cross the
|
|
threshold within 30 s and pin its inverter at 0 W forever. Publishing
|
|
nothing leaves the entity non-existent, which is the status quo and
|
|
what has_state() in the firmware is checking for.
|
|
|
|
### L6958 TEL-07. THE PER-UNIT ROWS, AND THE EMPTY LIST IS THE GATE.
|
|
|
|
TEL-07. THE PER-UNIT ROWS, AND THE EMPTY LIST IS THE GATE.
|
|
|
|
⚠️ THERE IS NO `if len(self.inverters) > 1` HERE, DELIBERATELY.
|
|
`announced_units()` answers `[]` at a roster of one, `zip` then yields
|
|
nothing, and the values dict comes out byte-identical to what shipped -
|
|
so the condition that decides what is ANNOUNCED is the same expression
|
|
that decides what is PUBLISHED. Written as a second predicate, the two
|
|
could disagree, and both directions are bad: an announced entity
|
|
nothing feeds (the hazard `omit` exists for) or, at N=1, a published
|
|
topic nothing announced, which AC 4 forbids outright.
|
|
|
|
⚠️ AND THE SITE ROWS ABOVE DO NOT MOVE. `battery` stays the SUM and
|
|
`soc` stays the EMPTIEST pack; these are additions beside them, not a
|
|
re-pointing of them, because those two ids are on the live install's
|
|
dashboards and in its automations.
|
|
|
|
### L6992 Every inverter to 0 W, and every inverter's TARGET to 0 W with it.
|
|
|
|
Every inverter to 0 W, and every inverter's TARGET to 0 W with it.
|
|
|
|
⚠️ THE SECOND HALF IS NOT BOOKKEEPING, AND IT IS A HAZARD DEV-02
|
|
CREATED. Before this card the heartbeat re-emitted `self.target`, so
|
|
zeroing that one scalar was enough: the stop button set it and the next
|
|
refresh carried the zero. The heartbeat now re-emits `inv.target` - it
|
|
has to, or it would broadcast the site number - so a stop that zeroed
|
|
only `self.target` would leave each unit's LAST SHARE standing, and the
|
|
very next heartbeat (up to `heartbeat_s` later, and before any cycle has
|
|
run) would put it back on the wire. The operator would have pressed
|
|
stop, watched the inverter go to zero, and seen it resume seconds later.
|
|
Both must move together, which is why this is one method with a reason
|
|
and not two lines repeated at three call sites.
|
|
|
|
⚠️ AND 0 W IS THE ONE VALUE THAT MAY BE WRITTEN SITE-WIDE. It means the
|
|
same thing to every inverter; a non-zero site-wide write is the ARCH-01
|
|
broadcast fault, and `write_setpoint()` refuses it.
|
|
|
|
|
|
### L7017 The status rows. `when` is the reference time for anything billed by
|
|
|
|
The status rows. `when` is the reference time for anything billed by
|
|
the calendar - today's month when it is None, which is what the live
|
|
page means; `status_payload()` calls this with no argument.
|
|
|
|
⚠️ IT EXISTS SO THE PEAK ROW CAN BE GRADED (TEST-17). Every other peak
|
|
query already takes it - `PeakStore.peak()`, `PeakTracker.peak()`,
|
|
`headroom_w()`, `warning_frac()` - and the row built from them was the
|
|
one thing that could only be read against `datetime.now()`. A suite
|
|
that banks an August quarter then asks this page what the peak is goes
|
|
green in August and red in September, which is what happened at 00:00
|
|
on 2026-09-01: TARIFF-01's own section 10, red on a calendar boundary,
|
|
on a commit nobody had touched for a day.
|
|
|
|
|
|
### L7058 UX-10: no reading yet since the start, still inside the
|
|
|
|
UX-10: no reading yet since the start, still inside the
|
|
limit. Amber, not red: it is not a fault yet, but control
|
|
waits for it. Red at 0 s contradicted the limit the row
|
|
prints. Past the limit it is the red row below, as before.
|
|
|
|
### L7071 ⚠️ unchanged_s is REPORTED, never thresholded and never folded
|
|
|
|
⚠️ unchanged_s is REPORTED, never thresholded and never folded
|
|
into the age - see HomeWizardLocalSource.unchanged_s for why
|
|
(at the converged -10 W this controller aims for, a 1 Wh
|
|
register needs ~6 minutes to move, so any limit false-trips at
|
|
the exact operating point we target). The whole argument for
|
|
leaving it unthresholded is that a human interprets it, which
|
|
requires a human being able to see it - so here it is. getattr:
|
|
only homewizard_local has one, and p1_source is None until
|
|
amain() builds the transport.
|
|
|
|
### L7085 SAFETY-18. Reported in both states, for the reason SAFETY-02 is:
|
|
|
|
SAFETY-18. Reported in both states, for the reason SAFETY-02 is:
|
|
a detector nobody can see is armed is a detector nobody trusts
|
|
when it fires. ok=False/warn=False so the banner goes red - this
|
|
is an alarm, and the banner is the loudest surface we own. It
|
|
changes NOTHING about control; see check_meter_frozen().
|
|
|
|
### L7104 SAFETY-20, and THE SECOND REASON TO USE THIS ROW RATHER THAN A
|
|
|
|
SAFETY-20, and THE SECOND REASON TO USE THIS ROW RATHER THAN A
|
|
NEW MECHANISM. The transport case above and this one are the
|
|
same statement - "the conjunction cannot be evaluated here" -
|
|
and an operator does not need to learn two vocabularies for
|
|
it. What differs is only which term is missing: there,
|
|
`unchanged_s`; here, a rail the command could ever stand at.
|
|
|
|
### L7116 ⚠️ SAFETY-20. THE RAIL PRINTED HERE IS THE RAIL THE PREDICATE
|
|
|
|
⚠️ SAFETY-20. THE RAIL PRINTED HERE IS THE RAIL THE PREDICATE
|
|
USES, device range included. This row used to interpolate
|
|
`rail_w(max_w)` while `looks_frozen()` graded a command the
|
|
device had already clipped below it, so on any install whose
|
|
inverter is smaller than its configured ceiling the page named
|
|
a number the command could never reach and called it `armed`.
|
|
Both the row and the predicate now read the same three inputs.
|
|
|
|
### L7126 And when the device is what set that number, SAY SO - the
|
|
|
|
And when the device is what set that number, SAY SO - the
|
|
operator configured `max_w` and is owed the reason the guard
|
|
is armed somewhere else.
|
|
⚠️ Either bound may be None on its own - `hass.limits()`
|
|
reads `min` and `max` independently and returns None for
|
|
whichever attribute is missing or unparseable - so this
|
|
renders whatever is there rather than formatting a None.
|
|
UX-08: "the inverter's own limit" says the device set it; which
|
|
inverter and its range are DOCS and the settings, not entity ids.
|
|
|
|
### L7140 TARIFF-01. The month's billed peak, and - the load-bearing half -
|
|
|
|
TARIFF-01. The month's billed peak, and - the load-bearing half -
|
|
a DIFFERENT row when the store could not answer. "no peak yet" is
|
|
a normal state on the 1st; "unreadable" is a broken instrument, and
|
|
a status page that spelled them the same way would let a 0 W read
|
|
look like a quiet month right up until SAFETY-07 shaved nothing.
|
|
UX-08: in plain words. The store's state at start is on the INFO
|
|
line `capacity-tariff peak store: ...` and `_pk.explain()` on the
|
|
log; the coverage figure (TARIFF-13) is a row of its own.
|
|
|
|
### L7168 SAFETY-07. Three rows, not one, because "idle" and "cannot see"
|
|
|
|
SAFETY-07. Three rows, not one, because "idle" and "cannot see"
|
|
are opposite states and a page that spelled them the same way
|
|
would show a green line over a guard that is switched off. `None`
|
|
is a fourth state on purpose: before the first cycle the guard has
|
|
concluded nothing, and saying "idle" then would be a claim about a
|
|
decision that has not been taken.
|
|
|
|
### L7185 SAFETY-24. NOT ok, like saturation: in both states the money
|
|
|
|
SAFETY-24. NOT ok, like saturation: in both states the money
|
|
is going to be spent and a human is the only actuator left. A
|
|
separate row from saturation because the two ask for opposite
|
|
things - see check_peak_floor_limited().
|
|
|
|
⚠️ SAFETY-25. THE SCOPE SENTENCE IS PART OF THE ROW, NOT A
|
|
DOCS FOOTNOTE. This is the one line an operator reads at the
|
|
exact moment the guard stops discharging, and read alone it
|
|
says "the battery is being held at peak_soc_floor" - which is
|
|
false, and measurably so: on the committed evening replay the
|
|
loop goes on discharging in EVERY cycle this row is showing.
|
|
A floor that binds one consumer and not the other is worse
|
|
than no floor if the page does not say which.
|
|
|
|
⚠️ AND IT CARRIES ITS OWN `banner`. status_payload() renders
|
|
the FIRST not-ok/not-warn row as the page's headline, so
|
|
without this the loudest element on the page would be a
|
|
550-character paragraph that the row three lines below
|
|
repeats verbatim. The banner is the short form; the row keeps
|
|
the whole sentence, because the row is where an operator
|
|
reads what the floor does and does not bound. Shortening the
|
|
ROW instead would be weakening AC 3.
|
|
UX-08: the same three branches, and the same pack naming, as
|
|
check_peak_floor_limited()'s notification. The figures stay in
|
|
`_pg.reason`, on the log and the event list.
|
|
SAFETY-87: `runs`, control goes on (status_payload()).
|
|
|
|
### L7248 ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the point.
|
|
|
|
⚠️ OUTSIDE the `p1_enabled` block, and that placement is the point.
|
|
SAFETY-07 is NOT COVERED on the legacy single-entity path - the
|
|
quarter-hour average is measured from P1 telegrams and this path
|
|
has none - and a row that only rendered where the guard runs would
|
|
leave the one install that is unprotected showing nothing at all.
|
|
Same answer, same reason, as the freeze detector's NOT COVERED row
|
|
a few lines up.
|
|
|
|
### L7258 ⚠️ ONE PAIR OF ROWS PER INVERTER, NAMED. A single "battery SoC" row on
|
|
|
|
⚠️ ONE PAIR OF ROWS PER INVERTER, NAMED. A single "battery SoC" row on
|
|
a site with two packs is a page that shows one pack and calls it the
|
|
site. The label keeps its exact wording at a roster of one, because
|
|
that is what an existing install reads.
|
|
|
|
### L7280 ⚠️ SAFETY-56. THIS ROW ASSERTED A FALSE FACT ABOUT A NAMED
|
|
|
|
⚠️ SAFETY-56. THIS ROW ASSERTED A FALSE FACT ABOUT A NAMED
|
|
ENTITY, WHICH IS THE WORST THING A DIAGNOSTIC CAN DO. It read
|
|
"<entity> is missing or not numeric" for ANY None, and one of
|
|
the ways this value becomes None is SAFETY-37's freshness gate
|
|
withholding a reading that is present, numeric and updating -
|
|
`Freshness.reading()` returns None BY DESIGN there. On the
|
|
live site on 2026-09-21 the operator opened Developer Tools on
|
|
the entity this row named, found it working, and had every
|
|
reason to conclude the diagnostics were broken.
|
|
|
|
⚠️ AND IT IS FIXED BY SAYING LESS, NOT BY LOOKING HARDER. The
|
|
row could ask `inv.batt_fresh.stale` - it is on the same
|
|
object at the same moment - but this loop also renders the
|
|
grid and SoC rows, which have no gate, and a branch per row
|
|
would be a second place where "why is this None" is decided.
|
|
The gate already has a surface of its own: `stale_note()`
|
|
rides `liveness_notes()` a few rows below with the whole
|
|
STALE_READING sentence in it, so the operator's discriminator
|
|
is "is that row there?" and this row's job is only to stop
|
|
pre-empting the answer. Same reason the wording is not shared
|
|
with `freshness._grade()`: that one is a log line about a
|
|
BEHAVIOUR, this one is a banner about an ENTITY.
|
|
|
|
### L7322 DEV-02. Whether this controller can still put a number on each
|
|
|
|
DEV-02. Whether this controller can still put a number on each
|
|
inverter, and it is reported in the ONE state that matters: a unit
|
|
`live_units()` has stopped listing has had its share handed to its
|
|
siblings, or - at a roster of one - has taken the site to 0 W. A
|
|
dropped link that shows nothing on the page reads as a healthy site.
|
|
Red only for a unit that is no longer live; the rest are amber.
|
|
The same notes, in the same order, as `liveness_notes()`.
|
|
|
|
### L7337 SAFETY-03. Red while an over-rating clamp is engaged (amber for the
|
|
|
|
SAFETY-03. Red while an over-rating clamp is engaged (amber for the
|
|
integrator bound, SAFETY-40), and the engagement COUNT is shown even
|
|
after it clears - a row that vanishes with the condition takes the
|
|
evidence with it. Reported only when there is something to report:
|
|
unlike the freeze detector this guard has no "not covered" state, it
|
|
runs on every write on every transport.
|
|
UX-07 item 1 / UX-08: each count is shown under ITS alarm's name -
|
|
over-rating cuts as "Power request too high", integrator-bound cycles
|
|
as "Battery is not keeping up" - never the two summed under one.
|
|
|
|
### L7380 SAFETY-02. Reported in both states: a watchdog nobody can see is armed
|
|
|
|
SAFETY-02. Reported in both states: a watchdog nobody can see is armed
|
|
is a watchdog nobody trusts when it fires.
|
|
⚠️ ONE ROW PER INVERTER, because there is one watchdog per inverter
|
|
and a trip on B leaves A commanding. A single row would have to pick
|
|
one of them to describe, and the honest answer at a roster of two is
|
|
both. At a roster of one the wording is unchanged.
|
|
⚠️ AND SINCE SAFETY-30 THE ROW SAYS WHAT THE UNIT WAS COMPARED
|
|
AGAINST, because at a roster of two that is no longer the meter and a
|
|
row that still said "the command opposed the meter" would send an
|
|
operator to look at P1 for a fault that is visible only on one
|
|
inverter's own power sensor. `sign_wd.against` is the same string the
|
|
trip sentence uses, so the two cannot drift apart.
|
|
|
|
### L7394 ⚠️ ADDED ONLY WHEN THERE IS SOMETHING TO DISAMBIGUATE. At the
|
|
|
|
⚠️ ADDED ONLY WHEN THERE IS SOMETHING TO DISAMBIGUATE. At the
|
|
shipped roster of one the two sentences below are the ones that
|
|
have always been on the page, word for word - AC 8 is about what
|
|
an operator sees as much as about what the detector computes, and
|
|
"opposed a meter reading" would be a gratuitous change to a line
|
|
somebody has learned to recognise. At N>=2 the noun is load-
|
|
bearing: the row would otherwise send them to look at P1 for a
|
|
fault that is visible only on one inverter's own power sensor.
|
|
UX-08: at N>=2 `unit` is set, and the row says "its own power".
|
|
The figures stay in `sign_wd.reason`, on the log and event list.
|
|
|
|
### L7413 SAFETY-30: the SITE row
|
|
|
|
SAFETY-30: the SITE row
|
|
⚠️ ONLY AT N>=2, and the absence at N=1 is the honest report rather
|
|
than a missing row. With one inverter the site comparison and the row
|
|
above are the same sentence about the same actuator; printing it twice
|
|
would advertise two guards where there is one, which is how an
|
|
operator learns to skim the list.
|
|
|
|
### L7429 SAFETY-31
|
|
|
|
SAFETY-31
|
|
⚠️ SITE-LEVEL, WHERE THE ROWS ABOVE ARE PER-INVERTER, and the row says
|
|
so. There is one circulating figure for the site; naming an inverter
|
|
here would invite the operator to clear "the other one".
|
|
|
|
### L7450 SAFETY-42
|
|
|
|
SAFETY-42
|
|
⚠️ PER INVERTER, AND WARN RATHER THAN BAD, WHICH IS THE ONE PLACE THE
|
|
"reports, does not act" DECISION IS VISIBLE TO AN OPERATOR. Every
|
|
other latched row in this list means the site is at 0 W and a red
|
|
cross is the truth. This one means the site is STILL RUNNING and is
|
|
quietly short by one unit's share; rendering it as a hard failure
|
|
would say the controller had stopped, which it has not. It is not "ok"
|
|
either - `ok: False` with `warn: True` is this list's caution, and a
|
|
caution is what a fault nobody is acting on actually is.
|
|
|
|
### L7471 AC 5, and the same honesty `circulating power: n/a` uses one block
|
|
|
|
AC 5, and the same honesty `circulating power: n/a` uses one block
|
|
up. At one inverter the reduction this check is has no proper
|
|
subset to find, for EVERY input, so "armed" would be a claim to be
|
|
watching something that cannot happen.
|
|
|
|
### L7505 TEL-11
|
|
|
|
TEL-11
|
|
⚠️ NO ROW AT ALL WHEN THE ROSTER IS EMPTY, AND THAT IS A DELIBERATE
|
|
DIFFERENCE FROM THE PEAK-FORECAST CAUTION DIRECTLY ABOVE. That one
|
|
nags because an unset forecast entity leaves a MONEY GUARD without an
|
|
input; this one is pure instrumentation, and a permanent caution on
|
|
every install that never asked for a PV channel is exactly the
|
|
"no warning spam, no degraded status row" the card forbids.
|
|
|
|
⚠️ AND WHEN IT IS SET, A FAILED READ IS `ok=False, warn=True` - a
|
|
CAUTION, never `bad`. `status_payload()` builds "NOT READY" from rows
|
|
that are neither ok nor warn, and a telemetry channel that could put a
|
|
battery site into NOT READY would be observability with a foot in the
|
|
control path. The reason comes from `pv.watts()` rather than being
|
|
re-derived here, so the page cannot disagree with the reading.
|
|
|
|
TEL-12 kept 0.11.0's single-entity sentences word for word. UX-08
|
|
replaced them on purpose (DECISIONS/UX-08-short-notifications.md), so
|
|
a `tools/tel11_pv_trace.py` compare against a pre-UX-08 tree shows
|
|
these rows as changed telemetry; the control documents stay equal.
|
|
|
|
### L7547 ⚠️ AND WHEN BOTH KEYS ARE SET, WHICH ONE WON IS STATED. `pv.py`'s
|
|
|
|
⚠️ AND WHEN BOTH KEYS ARE SET, WHICH ONE WON IS STATED. `pv.py`'s
|
|
`entities()` makes `pv_entities` REPLACE `pv_entity`, which is the
|
|
rule an operator upgrading from 0.11.0 is most likely to trip
|
|
over. A CAUTION and never a `bad` row: telemetry must not stop
|
|
control.
|
|
|
|
### L7578 SAFETY-10, AND IT IS FIRST IN THE CASCADE
|
|
|
|
SAFETY-10, AND IT IS FIRST IN THE CASCADE
|
|
"The battery is NOT being controlled" is the one sentence on this
|
|
page that may never be displaced by another condition. A banner that
|
|
can be out-ranked does not STATE a thing plainly; it states it
|
|
whenever nothing else is happening, which is the half of the time
|
|
nobody needs telling.
|
|
|
|
⚠️ AND IT CARRIES A STANDING LATCH RATHER THAN HIDING ONE. The three
|
|
`bad` branches below all say some version of "held at 0 W", which is
|
|
still true in shadow mode - but an operator who turns shadow mode off
|
|
with a latch up gets a battery that will not move and a page that
|
|
never mentioned it. `shadow.banner()` names the latch and goes red
|
|
for it, so nothing this cascade would have said is lost.
|
|
|
|
### L7596 ⚠️ ABOVE THE SIGN FAILSAFE, and the order is a decision. Both are
|
|
|
|
⚠️ ABOVE THE SIGN FAILSAFE, and the order is a decision. Both are
|
|
latched P_SAFETY stops, so the site is at 0 W either way and the
|
|
banner can only carry one sentence. This one goes first because it
|
|
is the fault the operator has NO OTHER WAY of learning about: a
|
|
sign disagreement is visible at the meter and on every dashboard
|
|
in the house, and circulating power is visible nowhere at all.
|
|
|
|
### L7604 SAFETY-30's site half, ABOVE the per-unit rows below and below
|
|
|
|
SAFETY-30's site half, ABOVE the per-unit rows below and below
|
|
circulating power above, and the ordering is the same argument
|
|
both times: the banner carries one sentence, so it carries the one
|
|
that says the widest true thing. A site sign trip means EVERY
|
|
inverter is parked and the fault is in what the site was asking
|
|
for, not in one device - naming a unit here would send an operator
|
|
to swap the wrong box.
|
|
|
|
### L7613 Outranks "Not ready": nothing is missing, something went wrong.
|
|
|
|
Outranks "Not ready": nothing is missing, something went wrong.
|
|
At a roster of one the unit was graded against the meter; at N>=2
|
|
against its own power, so the banner says so - see checks() on
|
|
why the noun is load-bearing rather than cosmetic.
|
|
|
|
### L7620 ⚠️ `banner` IF THE ROW OFFERS ONE, `text` OTHERWISE. A row may
|
|
|
|
⚠️ `banner` IF THE ROW OFFERS ONE, `text` OTHERWISE. A row may
|
|
need to say more to an operator reading the checks list than fits
|
|
in the one line at the top of the page - SAFETY-25's floor-limited
|
|
row is the case that forced this - and a headline that is a full
|
|
paragraph, repeated verbatim by the row underneath it, is a
|
|
headline nobody reads. UX-08: without the key, the banner takes
|
|
the row's first sentence, so it stays a headline.
|
|
|
|
### L7639 ⚠️ SAFETY-42, AND IT IS A `warn` BANNER RATHER THAN A `bad` ONE,
|
|
|
|
⚠️ SAFETY-42, AND IT IS A `warn` BANNER RATHER THAN A `bad` ONE,
|
|
WHICH IS THE WHOLE "reports, does not act" DECISION IN ONE LINE.
|
|
Every `bad` banner above means the site is at 0 W; this one means
|
|
the site is STILL RUNNING and quietly short by one unit's share,
|
|
and a red headline would say the controller had stopped.
|
|
|
|
⚠️ BUT IT MUST DISPLACE "Running — holding grid at zero", AND THAT
|
|
IS NOT COSMETIC. That sentence is a claim the site cannot make
|
|
while a unit is absent - the loop is saturated and the meter is
|
|
short - and "the page reads healthy while the fault runs" is
|
|
precisely the shape this whole card exists to remove. It sits
|
|
BELOW "Stopped" because a stopped site is the more urgent fact and
|
|
the banner carries one sentence.
|
|
|
|
### L7655 ⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE. At a roster
|
|
|
|
⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE. At a roster
|
|
of one this is the string it has always been, character for
|
|
character; at two, "Maintenance: drain" without a name is a
|
|
banner an operator cannot act on, because the remedy is at one
|
|
device and the site has two.
|
|
|
|
### L7669 ⚠️ THE EARLIEST ACROSS THE PACKS, NOT THE FIRST PACK'S. One date on a
|
|
|
|
⚠️ THE EARLIEST ACROSS THE PACKS, NOT THE FIRST PACK'S. One date on a
|
|
site with two schedules has to be the one that comes first, or the row
|
|
says "2026-10-01" while a pack recalibrates tomorrow. `None` means
|
|
"never run, due now" and has to win over any date, so it is tested for
|
|
rather than sorted with. At a roster of one this is that pack's own
|
|
date, unchanged.
|
|
|
|
### L7682 SAFETY-31, and it belongs DIRECTLY UNDER the two figures it is not
|
|
|
|
SAFETY-31, and it belongs DIRECTLY UNDER the two figures it is not
|
|
derivable from. "Grid 0 W, Battery 0 W, Circulating 6000 W" is the
|
|
whole card in three rows: both of the numbers an operator normally
|
|
reads say the site is idle, and the third says 6 kW is going round
|
|
between the packs. Only shown at N>=2 - at one inverter it is
|
|
identically zero and a permanent "0 W" row would be noise.
|
|
|
|
### L7690 TEL-11, and ONLY when a source is configured - see `checks()`.
|
|
|
|
TEL-11, and ONLY when a source is configured - see `checks()`.
|
|
`w(None)` renders "—", which is how this row says "no reading"
|
|
rather than "0 W". It sits beside Grid and Battery because the
|
|
question it answers is the one those two cannot: at "Grid 0 W,
|
|
Battery 0 W" the site is either becalmed or throwing away every
|
|
watt it makes, and only this row separates them.
|
|
|
|
### L7735 FCST-04. WHAT §8.3 HAS TO CARRY FORWARD, which is the difference
|
|
|
|
FCST-04. WHAT §8.3 HAS TO CARRY FORWARD, which is the difference
|
|
between a 48-hour plan and a 12-hour one on a weekday morning.
|
|
It is its own row rather than a clause of "Tariff" because it
|
|
answers a different question: "Tariff" says what the plan IS, and
|
|
this says what the plan could still be filled from if the feed is
|
|
late again tomorrow.
|
|
|
|
### L7742 OPT-04. The strategy's own sentence, not a re-derivation of it -
|
|
|
|
OPT-04. The strategy's own sentence, not a re-derivation of it -
|
|
every refusal it can make already carries a reason a human can
|
|
act on, and "Arbitrage: no" would throw all of them away.
|
|
|
|
⚠️ TARIFF-10 RE-READ THIS STRING AND LEFT IT ALONE UNDER A
|
|
COMMENT THAT WAS FALSE, AND TARIFF-11 IS THAT CORRECTION. The
|
|
claim was that `self.arb is None` **if and only if**
|
|
`arbitrage.enabled(self.o)` was False. Only the forward direction
|
|
holds: `cycle()` returns at the input hold (`bad_for <
|
|
stale_input_s`, default 15 s) BEFORE it reaches `self.arb = None`
|
|
at all, so on an armed site `self.arb` is `__init__`'s None at
|
|
startup and the PREVIOUS cycle's decision during a later hold -
|
|
and the row rendered `off (arbitrage_enabled)` on a site whose
|
|
`arbitrage_enabled` is `true`. `arbitrage_row()` reads the option
|
|
instead of inferring it, and separates "trading is off" from "no
|
|
decision was reached this cycle"; `cycle_at` and `arb_at` are what
|
|
make the second one an observation rather than a guess.
|
|
OPT-11 P3: with battery modes on the modes trade, whatever the
|
|
switch says, so the row reports their decision.
|
|
|
|
### L7768 UX-46. The rows were built in English with their Dutch beside them
|
|
|
|
UX-46. The rows were built in English with their Dutch beside them
|
|
(`messages.Bi`); the page's language is chosen once, here, for the
|
|
Home sentences, the checks list, the rows that carry one and the
|
|
banner. A plain str (no Dutch twin) stays as it is.
|
|
|
|
### L7778 The action log itself, newest first. The page does not show it
|
|
|
|
The action log itself, newest first. The page does not show it
|
|
(UX-08 2d), but an installer can still read it in the `status`
|
|
JSON, because several events (a dropped inverter's reason, the
|
|
peak guard's projection) are written nowhere else.
|
|
|
|
### L7827 ⚠️ THE ONLY WAY OUT OF A SIGN-DISAGREEMENT TRIP, on purpose.
|
|
|
|
⚠️ THE ONLY WAY OUT OF A SIGN-DISAGREEMENT TRIP, on purpose.
|
|
It clears on a deliberate human start, not on the condition
|
|
going away - the condition ALWAYS goes away, because the trip
|
|
commands 0 W and a 0 W command cannot disagree with anything.
|
|
An operator pressing start has, at minimum, looked at the
|
|
banner that says why.
|
|
|
|
⚠️ IT CLEARS EVERY INVERTER'S LATCH, AND SAYS SO PER INVERTER.
|
|
One button is what the UI has and one button is what §15's
|
|
banner tells the operator to press; what must not happen is a
|
|
press that clears A while B stays latched with nothing on the
|
|
page changing. Each latch is cleared under its OWN key, so an
|
|
install that had only B tripped comes back with only B's
|
|
history cleared.
|
|
|
|
### L7851 ⚠️ AND THE PRE-DEV-02 FLAT KEYS GO WITH THEM, UNCONDITIONALLY.
|
|
|
|
⚠️ AND THE PRE-DEV-02 FLAT KEYS GO WITH THEM, UNCONDITIONALLY.
|
|
This is what makes `flat True + namespaced False` MEAN
|
|
something, and it is the reason `_grade_store_migration()` can
|
|
run on every start instead of only on the start that migrates.
|
|
|
|
Without it the flat key is a fossil that says `true` forever
|
|
on any install that ever tripped, so a guard comparing against
|
|
it would re-arm the failsafe on every restart for the rest of
|
|
the install's life - which is why that guard used to be gated
|
|
on `rep.ran`, and why the gate then swallowed the rollback
|
|
ROUND TRIP: migrate, downgrade, trip during the downgrade
|
|
window (the old code writes only the flat key), upgrade again
|
|
-> `rep.ran` is False, the guard never runs, and the
|
|
controller comes up UNLATCHED while /data/state.json says
|
|
`sign_tripped: true`. That is store.py's own header sentence,
|
|
rebuilt by the version marker.
|
|
|
|
Cleared here, the two spellings can only disagree one way: the
|
|
flat key is newer than the namespaced one, which means it was
|
|
written by a version that did not know about namespaces - i.e.
|
|
a trip inside a downgrade window. Unconditional rather than
|
|
inside the loop because an operator pressing START is
|
|
declaring the whole site clear, and the fossil belongs to no
|
|
unit in particular.
|
|
|
|
### L7877 SAFETY-30's SITE latch, cleared by the SAME press and for the
|
|
|
|
SAFETY-30's SITE latch, cleared by the SAME press and for the
|
|
same reason the per-unit ones are: the condition always goes
|
|
away on its own, because the trip commands 0 W everywhere and
|
|
a site commanding zero cannot oppose anything. Only a human
|
|
who has read the banner can say the site is clear.
|
|
|
|
⚠️ UNCONDITIONAL, LIKE THE FLAT PAIR ABOVE, AND NOT GUARDED ON
|
|
THE ROSTER. An install that ran with two inverters, tripped,
|
|
and was then reconfigured back to one would otherwise carry
|
|
`site_sign_tripped: true` in its file forever, with no roster
|
|
that can reach the code which clears it - and would re-arm the
|
|
moment a second inverter was configured again.
|
|
|
|
### L7896 SAFETY-31, cleared by the SAME press and for the same reason:
|
|
|
|
SAFETY-31, cleared by the SAME press and for the same reason:
|
|
the condition always goes away on its own, because the trip
|
|
commands 0 W everywhere and two inverters at zero cannot
|
|
oppose. Only a human who has read the banner can say the site
|
|
is clear.
|
|
|
|
⚠️ AND `_circ_announced` GOES WITH IT. It is the "has a human
|
|
been shown this" flag; leaving it True through a clear would
|
|
mean the NEXT trip in this process latched, stopped the site,
|
|
and raised no notification at all.
|
|
|
|
### L7913 SAFETY-42, cleared by the SAME press, and for a reason that is
|
|
|
|
SAFETY-42, cleared by the SAME press, and for a reason that is
|
|
NOT the one above. The other three latches are cleared here
|
|
because their trip removed its own evidence - a site commanding
|
|
0 W cannot oppose anything - so only a human can say the fault
|
|
is gone. This one commands nothing, so its evidence is still
|
|
standing and it could in principle clear itself. It does not:
|
|
a unit that stopped delivering for minutes and then resumed is
|
|
an INTERMITTENT fault, and a detector that erased its own
|
|
record of that would be the least useful thing in this file.
|
|
⚠️ AND `_delivery_announced` GOES WITH IT, like
|
|
`_circ_announced`: leaving a name in it through a clear would
|
|
mean the NEXT trip on that unit latched and raised no
|
|
notification at all.
|
|
|
|
### L7938 ⚠️ THE WHOLE SITE, TARGETS INCLUDED - see command_site_zero().
|
|
|
|
⚠️ THE WHOLE SITE, TARGETS INCLUDED - see command_site_zero().
|
|
Zeroing `self.target` alone would leave each unit's last share
|
|
standing for its next heartbeat to re-emit, which an operator
|
|
would read as the stop button not working.
|
|
|
|
### L7949 ⚠️ WHICH PACK, BECAUSE THERE ARE NOW SEVERAL AND THE BUTTON HAS
|
|
|
|
⚠️ WHICH PACK, BECAUSE THERE ARE NOW SEVERAL AND THE BUTTON HAS
|
|
ONE LABEL. `_maint_button()` answers it: the pack that holds the
|
|
site's maintenance slot if one does - which is what "start"
|
|
already meant at a roster of one, where pressing it mid-drain
|
|
re-entered DRAIN - and otherwise the pack that has gone longest
|
|
without a cycle, which is the same pack `arbitrate()` would have
|
|
picked on its own. At N=1 both branches are `maints[0]` and this
|
|
is the shipped call, byte for byte.
|
|
|
|
This is the bare request (no unit). A request naming a battery
|
|
was handled above by `_maint_unit_action()` (MAINT-11).
|
|
|
|
### L7991 OPT-11 P2: `(modes, retract)` for the MQTT publisher at start-up.
|
|
|
|
OPT-11 P2: `(modes, retract)` for the MQTT publisher at start-up.
|
|
|
|
The config topics announced are kept in the store, so turning modes off
|
|
removes exactly those (`mqtt.mode_configs()`). An install that never
|
|
turned modes on gets nothing, and its store is not written.
|
|
|
|
|
|
### L8025 `{slug, ha_version}` from the Supervisor, each None when unreadable.
|
|
|
|
`{slug, ha_version}` from the Supervisor, each None when unreadable.
|
|
|
|
`GET /addons/self/info` and `GET /info` are on the Supervisor's bypass
|
|
list for an add-on's own token, so they need no role (see selfconfig.py).
|
|
Asked together with a 3 s budget: a link may never hold up control start.
|
|
|
|
|
|
### L8117 Startup self-check: prove we can actually reach the Core API before
|
|
|
|
Startup self-check: prove we can actually reach the Core API before
|
|
anything tries to control an inverter with it. A 401 here is a
|
|
permissions/token problem, not a configuration mistake, and saying so
|
|
explicitly saves an installer from re-checking entity ids for an hour.
|
|
|
|
### L8134 UX-09: the Details link in every notification needs this add-on's
|
|
|
|
UX-09: the Details link in every notification needs this add-on's
|
|
slug and Home Assistant's version (see messages.docs_url()). Both
|
|
are best effort: without them the link falls back to the hostname,
|
|
or to naming the section only.
|
|
|
|
### L8140 ⚠️ THE STARTUP GATE (CFG-01). Nothing below this line may run on a
|
|
|
|
⚠️ THE STARTUP GATE (CFG-01). Nothing below this line may run on a
|
|
configuration validate.py rejects - specs §1.5: a config the system
|
|
cannot honour is refused at startup, not survived at runtime.
|
|
|
|
It sits HERE, before the broker, before the web server and before any
|
|
task exists, so a refusal cannot leave half a controller running with
|
|
a UI in front of it. Reading the device range is the one thing the
|
|
validator cannot do for itself; (None, None) when ESPHome has not come
|
|
up yet is not a refusal - run_control() re-checks once the real rating
|
|
is known. See validate.py for where the line is drawn and why.
|
|
|
|
### L8156 UX-15: a new install names no inverter. When that is the ONLY
|
|
|
|
UX-15: a new install names no inverter. When that is the ONLY
|
|
refusal it serves the setup wizard in setup-only mode, which builds
|
|
nothing that commands and returns when stopped. Any other refusal
|
|
still exits 78 (DECISIONS/UX-15-setup-only-mode.md).
|
|
|
|
### L8169 ⚠️ Status publishing must NEVER be able to stop the controller. A
|
|
|
|
⚠️ Status publishing must NEVER be able to stop the controller. A
|
|
broken broker, a missing library, an API change in paho - all of it is
|
|
observability, and the battery does not care. Caught broadly and on
|
|
purpose: this crashed the add-on once already (paho 1.x vs 2.x) and
|
|
took the control loop down with it.
|
|
|
|
### L8186 TEL-11. ⚠️ THE SHIPPED PREDICATE, NOT A SECOND READING OF THE
|
|
|
|
TEL-11. ⚠️ THE SHIPPED PREDICATE, NOT A SECOND READING OF THE
|
|
OPTION - `Controller.publish()` gates the row on this same
|
|
call. An install with no PV roster announces exactly
|
|
what it announced before: no topic, no entity, no row.
|
|
|
|
### L8191 TEL-07. ⚠️ THE NAMES, NOT A ROSTER. `build_roster()` here
|
|
|
|
TEL-07. ⚠️ THE NAMES, NOT A ROSTER. `build_roster()` here
|
|
would construct a SECOND set of `Inverter`s off the same
|
|
Store, minutes before `Controller.__init__` constructs the
|
|
real ones; `roster_names()` is the naming rule on its own and
|
|
is what `build_roster()` itself reads. At a roster of one this
|
|
announces nothing new - see `announced_units()`.
|
|
|
|
### L8231 OPT-04. The tariff refresh runs as its OWN long-lived task, for the
|
|
|
|
OPT-04. The tariff refresh runs as its OWN long-lived task, for the
|
|
reason P1 ingestion does and then some: `resolve_tariff()` may open an
|
|
HTTPS connection to a supplier, and the control tick is what feeds the
|
|
ESP32 heartbeat. A fetch on that path is a supplier's TLS timeout able
|
|
to push this add-on towards layer 1's 30 s veto. See TARIFF_REFRESH_S.
|
|
|
|
⚠️ STARTED FOR A PROVIDER, FOR ARMED TRADING OR FOR BATTERY MODES:
|
|
see `tariff_refresh_wanted()` above and DECISIONS/TARIFF-10 and
|
|
TARIFF-24 for why each term is there.
|
|
|
|
⚠️ IT DOES NOT ARM ANYTHING. Fetching a price and trading on one are
|
|
two decisions; this is the first, and `cycle()`'s
|
|
`if arbitrage.enabled(self.o):` is still the only gate on the second.
|
|
|
|
### L8282 ⚠️ No traceback, on purpose. The banner refuse() already printed says
|
|
|
|
⚠️ No traceback, on purpose. The banner refuse() already printed says
|
|
everything an operator needs, and a stack trace on top of it would say
|
|
"this crashed" - the exact confusion AC3 is about. 78 is sysexits(3)
|
|
EX_CONFIG, so even a script reading the exit status can tell a bad
|
|
config from a bug.
|
|
|
|
## Added at release/1.0 `16029d7` (SMA-2, SMA-10, SAFETY-128, SAFETY-137)
|
|
|
|
Same form, keyed by the first line number in `app/main.py` at `16029d7`.
|
|
|
|
### L143 SMA-10. The same bound for handing a unit back (`driver.release()`). I
|
|
|
|
SMA-10. The same bound for handing a unit back (`driver.release()`). It is the
|
|
time the shutdown path already allows one 0 W write, and the units are
|
|
released CONCURRENTLY, so one slow or hung unit costs the wind-down no more
|
|
than a hung GoodWe write does today: the Supervisor's kill window is not lost
|
|
to it. A release still running at the bound is abandoned, not awaited.
|
|
|
|
### L150 SMA-10 round 2. The one fallback write after a failed release (default
|
|
|
|
SMA-10 round 2. The one fallback write after a failed release (default 0 W,
|
|
`Driver.release_fallback()`). Worst case for a unit whose release hangs:
|
|
RELEASE_BUDGET_S + FALLBACK_BUDGET_S = 6 s, against the Supervisor's 10 s.
|
|
|
|
### L156 SAFETY-137. How long a LAST write (the Stop 0 W, the shutdown 0 W, a r
|
|
|
|
SAFETY-137. How long a LAST write (the Stop 0 W, the shutdown 0 W, a release)
|
|
waits for a write already on its way to the same unit. Half the release
|
|
budget, as `_release_last` always waited. For a GoodWe unit that is
|
|
SETTLE_BUDGET_S + WRITE_BUDGET_S = 6 s at worst, which is HAND_BACK_WORST_S:
|
|
the wind-down is no slower than SMA-10's worst case.
|
|
|
|
### L586 SMA-10. Which units hand back decides what the page says about Stop,
|
|
|
|
SMA-10. Which units hand back decides what the page says about Stop,
|
|
restart and shadow mode. A GoodWe-only roster adds nothing.
|
|
|
|
### L3152 SAFETY-128: update the answer gate; seconds the late verdict has
|
|
|
|
SAFETY-128: update the answer gate; seconds the late verdict has
|
|
stood, or None. Readings found late are logged once per episode.
|
|
DECISIONS/SAFETY-128-late-readings.md.
|
|
|
|
### L3805 `inv`'s driver, wired to this Controller's Home Assistant on first use
|
|
|
|
`inv`'s driver, wired to this Controller's Home Assistant on first use.
|
|
|
|
SMA-2. Bound HERE rather than when the roster is built, so a unit added
|
|
to `self.inverters` afterwards is wired too, and the driver follows
|
|
whatever `self.hass` is at the time (a check may swap it).
|
|
|
|
### L3835 SMA-2. The driver reads, in the order this method always did: SoC,
|
|
|
|
SMA-2. The driver reads, in the order this method always did: SoC,
|
|
then power. `extra` is whatever else the driver learned; nothing
|
|
consumes it yet.
|
|
|
|
### L4074 Wait, at most `budget_s`, for the writes already on their way to
|
|
|
|
Wait, at most `budget_s`, for the writes already on their way to
|
|
`inv` (SAFETY-137: every driver, GoodWe too). A write still running at
|
|
the bound is left alone, not cancelled: its own WRITE_BUDGET_S ends it.
|
|
|
|
### L4082 `release()`, after a write already on its way to `inv` has finished
|
|
|
|
`release()`, after a write already on its way to `inv` has finished
|
|
(at most half the budget is spent waiting for it), so the release is the
|
|
LAST thing the unit hears and a late write cannot undo it.
|
|
|
|
### L4089 SMA-10. Give ONE unit back: exactly one `release()`, then nothing.
|
|
|
|
SMA-10. Give ONE unit back: exactly one `release()`, then nothing.
|
|
|
|
True when the unit is handed back (now, or already). Never raises: a
|
|
driver that raises or hangs is logged and the unit is tried again on the
|
|
next idle write, and it cannot stop a sibling being released or zeroed.
|
|
|
|
⚠️ `released` IS SET BEFORE THE FIRST AWAIT. A second caller (the
|
|
heartbeat while the Stop button is mid-release) then sees it and does
|
|
nothing, so a unit gets one `release()` and not two. If the release
|
|
did not take, the flag goes back down and the unit stays un-handed
|
|
until a later idle write tries again.
|
|
|
|
⚠️ IT IS NOT A WRITE: no `note_write`, no `last_write`, so a hand-back
|
|
cannot be read as a link that answered. `last_write` is cleared so the
|
|
first write after control starts again is always sent.
|
|
|
|
### L4109 After a failure the next try waits one heartbeat, so a hung release
|
|
|
|
After a failure the next try waits one heartbeat, so a hung release
|
|
costs a cycle its 4 s once per heartbeat, not on every cycle. The
|
|
Stop button and shutdown (`force`) always try.
|
|
|
|
### L4146 ⚠️ ONCE. The unit must not keep carrying its last command, but a
|
|
|
|
⚠️ ONCE. The unit must not keep carrying its last command, but a
|
|
0 W re-sent on every try would reset the inverter's own fallback
|
|
timer for ever. The driver says what the one write is.
|
|
|
|
### L4162 --- SMA-10: A UNIT THAT HANDS BACK IS NOT WRITTEN WHILE THE ADD-ON IS
|
|
|
|
--- SMA-10: A UNIT THAT HANDS BACK IS NOT WRITTEN WHILE THE ADD-ON IS
|
|
IDLE. Idle is: shutting down, control stopped, or shadow mode. The
|
|
unit is released once and then nothing is written to it - not the
|
|
heartbeat, not the cycle's "stopped" 0 W - so its own controller keeps
|
|
it. This is the passive half; `command_site_release()` is the active
|
|
one, for the moment the add-on goes away.
|
|
|
|
⚠️ A SAFETY STOP IS NOT IDLE. It happens while control runs (`auto`,
|
|
not shadow), so it falls through to the 0 W write below: the add-on's
|
|
own decision to hold 0 W is never turned into a hand-back.
|
|
|
|
⚠️ AND CONTROL STARTING AGAIN RE-ARMS THE UNIT: the flag drops and
|
|
`last_write` is cleared, so the first write goes out whatever it is.
|
|
`stopping` never re-arms: a shutdown in progress stays handed back.
|
|
|
|
### L4181 Control has the unit again: forget the hand-back, and any
|
|
|
|
Control has the unit again: forget the hand-back, and any
|
|
failed streak with it, so the next Stop starts clean (its own
|
|
one 0 W, its own error line and event).
|
|
|
|
### L4302 --- SAFETY-137: THE 0 W THAT ENDS THINGS IS THE LAST WRITE THE UNIT
|
|
|
|
--- SAFETY-137: THE 0 W THAT ENDS THINGS IS THE LAST WRITE THE UNIT
|
|
HEARS. `last` is the Stop 0 W and the shutdown 0 W (and nothing else).
|
|
It first waits, bounded, for a write already on its way to this unit,
|
|
so that write cannot land after it. And once shutdown has begun every
|
|
other write (cycle, heartbeat, a safety stop) is dropped here: the
|
|
check sits right before the write is created with no await between,
|
|
so a write that starts after it cannot exist, and one that started
|
|
before is in `inflight` for the wait above. The idle gate for a
|
|
hand-back unit is higher up and does the same for those.
|
|
|
|
### L4328 SMA-10 / SAFETY-137: kept for every driver, so a release or a last
|
|
|
|
SMA-10 / SAFETY-137: kept for every driver, so a release or a last
|
|
0 W can wait for it (`_await_inflight`).
|
|
|
|
### L6948 Signal, then return WITHOUT entering the loop. amain() wakes
|
|
|
|
Signal, then return WITHOUT entering the loop. amain() wakes
|
|
on this, runs the normal wind-down (which commands 0 W, or hands
|
|
back a unit whose driver does: SMA-10) and re-raises
|
|
ConfigRefused afterwards, so the exit code is 78 and the
|
|
inverter is left at zero rather than latched.
|
|
|
|
### L7222 SMA-10. The add-on is going away (Stop, shutdown, restart, task
|
|
|
|
SMA-10. The add-on is going away (Stop, shutdown, restart, task
|
|
death, a config refusal): every unit that hands back is given back, and
|
|
every other unit is held at 0 W exactly as `command_site_zero()` does.
|
|
|
|
⚠️ THE SAFETY STOPS AND REFUSALS DO NOT COME HERE. A sign trip, a
|
|
refused write, a missing reading, another writer: those are the add-on's
|
|
own decisions and hold 0 W through `write(0)` for EVERY unit, handed-back
|
|
drivers included. `command_site_zero()` is that, and it is unchanged. A
|
|
unit the add-on has just declared it cannot trust must not be handed to
|
|
something else that will then do what it likes with the battery.
|
|
|
|
⚠️ A ROSTER WITH NO HAND-BACK UNIT IS `command_site_zero()`, line for
|
|
line: GoodWe's release is its 0 W write, so Stop keeps today's write
|
|
and today's heartbeat.
|
|
|
|
⚠️ ONE UNIT'S FAILURE IS ITS OWN. `_hand_back()` never raises and is
|
|
bounded by RELEASE_BUDGET_S; the units run concurrently, so the whole
|
|
call takes no longer than its slowest bound.
|
|
|
|
### L8207 SMA-10: the Stop button is the add-on going quiet, so a unit
|
|
|
|
SMA-10: the Stop button is the add-on going quiet, so a unit
|
|
that hands back is given back; every other unit is zeroed.
|
|
|
|
### L8254 SMA-10. `await awaitable`, but give up after `budget_s` and do NOT wai
|
|
|
|
SMA-10. `await awaitable`, but give up after `budget_s` and do NOT wait
|
|
for it to wind down. `asyncio.wait_for` waits for the cancelled task to
|
|
finish, so a driver that ignores its cancellation would hold the whole
|
|
shutdown; this abandons it. Raises `asyncio.TimeoutError` at the bound.
|
|
|
|
### L8434 SMA-2. The flat options are the one-inverter shape, so it is the
|
|
|
|
SMA-2. The flat options are the one-inverter shape, so it is the
|
|
GoodWe driver's, asked without a Controller (none exists yet).
|