- check_peak_floor_limited: restore the SAFETY-25 line (the reserve sentence is
in messages.peak_reserve() on every branch, unreadable SoC included).
- Three status-page comments that no longer said anything (SAFETY-20 row,
AC 5 delivery row, banner-vs-text) now say their rule in one line.
- tariff_refresh_wanted docstring: no orphan "TARIFF-24)" line.
- MAIN-orchestration.md: blank line before the first heading.
- MAIN-orchestration-archive.md: refilled intro; says how to find a section
from a main.py pointer.
No code change: maint20_same_code.py 16029d7 -> SAME CODE, -OO same.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
15 KiB
15 KiB
MAIN - why app/main.py is built the way it is
Status: SHIPPED. Written down by MAINT-20 part 2 from the long comments and
docstrings of app/main.py, so the module can keep one line and a pointer. The
comments that were shortened but not rewritten here are kept word for word in
MAIN-orchestration-archive, by their line in app/main.py at release/1.0
d4c01da. Related: SAFETY-07-peak-guard, FCST-04-the-day-that-is-carried,
SAFETY-30-per-unit-sign, SAFETY-73-refusal-per-inverter,
SAFETY-121-bound-every-quarter, TARIFF-10-fetching-is-not-trading,
TARIFF-24-modes-need-the-tariff-task, OPT-34-the-night-charge-keeps-its-level.
Controller construction
- Per-write budget (DEV-02). Each write is dispatched concurrently and bounded, so one hung
hass.call()cannot eat a sibling's layer-1 budget (the ESP32 zeroes after about 30 s, and a fault on A could zero a healthy B). The number is derived:heartbeat_smax 25 s, layer-1 vetovalidate.FIRMWARE_INGEST_VETO_S30 s, so 25 + 4 < 30. test_inverter.py grades the inequality, so raising the heartbeat ceiling goes red instead of taking an inverter to zero. Below hass.py's 10 s HTTP timeout on purpose: abandon the write and re-issue it next tick, sincelast_writemoves only on an accepted write. - Tariff refresh (OPT-04).
resolve_tariff()may do an HTTPS fetch. The tick feeds the ESP32 heartbeat, so a supplier's TLS timeout on the tick would push the add-on into layer 1. The tick never awaits a fetch. The 30 min cadence is not about price change (day-ahead publishes once at 14-16 h CET and 8.3's carried hours go firm then); it picks the switch up within the hour at 48 fetches a day. The age bound answers another question: a failed refresh still stores a plan (the 11.4 static fallback), the bound covers the refresh task dying while a plan from hours ago stands as current. Three missed refreshes, forp1.max_age_s's reason. - Status rows (TARIFF-11). The Tariff row used to be
static_status()'s sentence alone and never saw the provider plan (read 'no static profile' at all 802 samples of a 40 h soak with a live feed).tick_wordsandplanare two clocks and the row says so; the plan is stamped with its own age fromtariff_at(TARIFF-12 does not move it on a kept plan), andage_sis the caller's measurement. The Arbitrage row used to be"off (arbitrage_enabled)" if self.arb is None, under a comment sayingarb is Noneiff trading was off; false, sincecycle()returns at the input hold beforeself.arb = None:arbitrage_enabled: trueplus a sensor answering None rendered 'off (arbitrage_enabled)', naming an option that is on, and past the first cycle the previous decision stood as current. Three states: off, armed with no decision this cycle, a decision with its age (always printed: a row that reports staleness only once stale cannot be told from one that never does). stopis required (review catch).stop=Nonemade a Controller that cannot stop itself the default: on the late-refusal path inrun_control()it logged the banner, returned and left the process alive forever with no loop and no output.- Action log first. The store migration can log to it (15: how an operator finds a latch was re-armed); an AttributeError on the way to saying so would stop the add-on booting.
- Roster and migrations (DEV-02, SAFETY-44, MAINT-05). Every per-device value lives on the roster objects (app/inverter.py); a
Tuningand an integrator are deliberately not per device (6.2: one integrator on P1 above the split; a second regulating the same meter is the two-writers failure). The SAFETY-02 key migration runs before the firstInverterreads its latch, else a tripped install comes up armed with a green banner. SAFETY-44: it is handedroster_names(opts)[0], neverUNIT_A(on a declared roster the flat latch landed underunit/inverter-a/, read back under the name it was handed, stamping the marker);roster_names()returns[UNIT_A]on the flat shape so today's path is byte-identical.misdirectedis unreachable from that call site (unitis a roster member by construction; dropping therosterargument survives 28/28), so the claim that the call site buys the refusal is gone; the argument stays for new callers and is graded in test_maintenance.py. The maintenance migration targetsself.inverters[0].name, notUNIT_A: withUNIT_Athe history would sit under a name on no roster (a nameless entry falls back to a positionalinverter-1). Every two-pack fixture once named its first entryinverter-a, so swapping them left 19 files green;migrate_maint_keys()now grades the namespace it wrote against the names the machines read, and test_maintenance.py sections 9 and 11 use["garage", "shed"]. - CFG-04 disclosure. A declared
inverters:roster ignoressoc_entity,batt_entity,batt_invertandmax_w, which the UI still shows. Rule 9(e) refuses the dangerous one (a flatsetpoint_entity); the rest cannot be refused (the schema has no blank value) so one action-log line at every start names the roster and the ignored keys. - Site bound. Summed from the roster, not
opts["max_w"]: it is the integrator bound and rail of the site loop, then split. Identical at N=1; a per-unitmax_wbinds each share through itssupervisedclaim. - SAFETY-39 slew fallback is 0 ("follow the rail"); it was 1000, a stale third copy.
control.slew_limit_w()is the one reader. - CFG-03 in main.py. Eight
opts.get(key, <literal>)calls used to live in the maintenance setup and two disagreed with config.yaml (2500 against 2000).MaintConfig.from_options()reads them. A ninth key goes in config.yamloptions:/schema:andmaintenance.MAINT_OPTION_DEFAULTSonly. - Watchdog thresholds (SAFETY-30). The per-unit pair is re-derived at two inverters, not carried (it grades a command against one unit's own power). The site pair is used only at N>=2: the 300 W threshold carries (meter noise, deadband), the window does not (110 s at one inverter, 215 s at two, against a 180 s hold).
- Latches (SAFETY-02, -31, -34, -42, -18). SAFETY-02: one per actuator, restored from namespaced keys; graded on every start (a latch that failed to move namespace, or was taken by an older add-on in a rollback, is a disarmed guard, so a disagreement re-arms by hand and shouts). SAFETY-31: site-level, flat keys (circulating power is defined over the roster), restored because it acts. SAFETY-42's delivery latch does not act, so it is restored only so the ALARM survives (the 2026-09-02 outage went unseen 6 h 25 m with the detector correct). SAFETY-18 is not restored (does not act). SAFETY-30's site check is armed only at N>=2.
- PV (TEL-11). Instrumentation only; wiring it in would be an unrequested behaviour change and would destroy the measurement.
- Peak guard setup (SAFETY-07, TARIFF-01, FCST-04). See SAFETY-07-peak-guard.md and FCST-04. The windowing caveat for FCST-04: hours already elapsed leave the spread, so a cheap night that has gone no longer sets the day's cheapest hour.
Cycle state and latch repair
- Allocation record (SAFETY-28). The tick that mints a record increments once per resolution, not per loop iteration. A cycle with no resolution (the "inputs missing, not yet stale" hold) must not bump it: that hold returns before the allocator so one missed poll does not zero the inverter, and the heartbeat re-affirms
self.targetthrough the write path; bumping would make that heartbeat unauthorised and drop the site to 0 W. The record is superseded, never expired. self.arb(TARIFF-11). See "Status rows" above;arb_at < cycle_atis the derived "the latest cycle did not reach the arbitrage step".- Shadow (SAFETY-10, SAFETY-47).
shadow_would_wis what a live controller would have put on the wire;self.targetis what is standing on the wire (held atshadow.WIRE_W, re-seeded each shadow cycle). Folding them shows an operator a number nothing is doing. The "sized from the billed-quarter meter" flag is recorded where the decision was taken (the page runs on its own task); False, not None, before the first shadow cycle. - ARB-03.
unitsis a property derived from the roster so a site cannot claim two actuators while holding one writer. At N=1 (6.2) the grid connection and the only inverter are one actuator; a second entry makes per-inverter claims stop being site claims (ALLOC-01). - DEV-02 latch guard (
_check_sign_latch...). Runs on every start: gating onrep.ranswallowed store.py's rule-2 round trip (upgrade, downgrade with a trip in the window, upgrade; the controller came up untripped while state.json saidsign_tripped: true; a firmware rollback path was performed on this site on 2026-09-02). The gate could go becausehandle_action()clears the flat keys with the namespaced ones and the trip mirrors them. SAFETY-44 second half: the question is whether a namespaced latch THAT A MACHINE READS is set. An earlier version accepted anyunit/*/sign_tripped, which blinded the guard on exactly the installs the broken migration had put the latch underunit/inverter-a/(a key nothing reads). The first blunt fix (every off-roster latch is litter) re-armed a healthy A off B's mirrored flat key (test_inverter.py DEV-02 restart case).UNIT_Ais the discriminator: the only name the broken migration wrote. A single flag written for two intents was defeated by one removed unit's key (roster ['garage','shed'] withunit/old-shed/sign_trippeddrove on a state file readingsign_tripped: true;tools/rehearse_roster.pyexit 1); the fixtures now hold both arrangements. Residual: a first unit really namedinverter-a, latched and removed from the roster is re-armed oninverters[0](conservative). The printed reason is the flat mirror's (whatever tripped last), so on a multi-unit site one inverter's evidence may appear under another's name; test_maintenance.py's check that the reason is not the removed unit's depends on that fixture's flat string, not on this code. - Maintenance views (MAINT-05).
maintsis one FSM per roster entry;maint,maint_unitand the button target are views of the one that holds the slot (maintenance.arbitrate()allows at most one out of IDLE). validate.py rule 6 gradesmaintenance_charge_wagainst the weakest cap on the roster. - N=1 views. Properties, not fields, and they refuse at N>=2 (6.2's identity is false there). Nothing inside main.py may read them (test_inverter.py AST scan).
_arm_sign_watchdogs(SAFETY-30). Called every cycle because fixtures append anInverterafter construction; a detector frozen at construction would be graded against the meter at a roster of two.arm()touches no timer or latch.- Per-inverter refusal (SAFETY-73). The probe ask is PROBE_W, not
saturation_w: a unit's share is often small (a 400 W night load split two ways) and SAFETY-43 would name the empty battery as not delivering. The site target is restarted from the watts on the wire when a unit reopens (phase A: -1059 W).
Write path and cycle
- The write gate (SAFETY-28). specs 1.5 makes two writers on one actuator a non-goal.
len(validate.ACTUATOR_WRITE_KEYS) == 1is a fact about the options schema, not the writes, and goes false when CFG-04 adds per-inverter config. So the enforcement is on the write: a number reaches the wire only if the single allocator placed it, on this tick, for this unit (Allocation.authorises()); a second writer must file a Claim. 0 W is always authorised and the test for zero comes first and reads nothing: the failsafe branches write 0 W while the allocator has refused (Allocation.okFalse, no shares), and a gate asking its permission would deadlock the failsafe behind the component it answers; at S = 0 broadcast and allocation coincide, so there is no over-command (the hazard is N-times loop gain from broadcast, TEST-19'sN=2 gain 1.0limit cycle). The predicate isvalue == 0.0, not afailsafe=Trueflag, which a second writer would pass; -0.0 == 0.0 and a nan never gets here. DEV-02: the unit is an argument, not the N=1 view; the default refuses at N>=2. The refusal sits above the clamp and below the non-finite coercion. - Clamp (SAFETY-03) is on the only path to the wire, so no optimizer, maintenance or manual path routes around it. A non-finite value is loss of input (11.1), coerced to 0 W. Every engagement goes to the action log undeduplicated; the notification is deduplicated.
- Shadow (SAFETY-10) suppresses in the same method, below the gate and the clamp, as a substitution not a return. It is not a second writer.
- Reads (DEV-02, TEL-12). Each inverter's readings are gathered concurrently across units and sequentially within one; PV entities ride in the same gather (a slow sensor must not push a healthy battery toward layer 1's veto). P1 has no inverter-side fallback: the inverter's AC power correlates 0.998 with battery power and 0.09 with the real meter.
- Stale reading (SAFETY-37/56/57). The power reading goes through the freshness gate, the SoC does not (the power feeds the sign and delivery checks, which a frozen register fools). SAFETY-57 rewrote the escape clause: "it clears itself when the register reports a different value" was false for the state announced, because the 0 W it announces is what stops the value changing. The release is reported, never silent (it withdraws a SITE STOP).
- TARIFF-03. The one shiftable load is the maintenance cycle's cheap-window top-up; the static window is synchronous and I/O-free, and validate.py refuses
static_profiletogether with the price entities. - Cycle ordering (SAFETY-02/31/42). The watchdog evaluations run every cycle, before the input hold, so a run of held cycles cannot leave their timers resuming from a start that predates the gap. Reason first, flag second (each
setwrites the file): a crash between persists a reason nothing reads, not a latch with no explanation. The per-unit trip mirrors the legacy flat keys (store.py rule 2 covered only the latch present at migration time). The site check is flat-keyed, armed only at N>=2. SAFETY-31's input is MEASURED power (inv.batt), never the allocation record, which has sign-coherence by construction. - Maintenance FSMs (MAINT-05). Each pack is ticked with its own SoC (a minimum would abort a cycle on a sibling's state, an average would declare a pack full that is not);
arbitrate()runs first. A deferral is carried to the status page because the edge-logged line scrolls out of the 40-row log. - Aggregates. SUM for the site bound, achievable rating and the battery power the site loop regulates against (
compute()'s saturation test compares against the site target; one unit's power trips the freeze permanently, up to 510 W of split-dependence at gain 0.2, test_alloc.py part 8). MINIMUM for the discharge permission (the emptiest pack). MEASURED power for circulating power. Unknown bounds stay unknown when any unit is silent. - Trade sizing (OPT-09).
trade_sizinguses arbitrage's own SoC bounds, not the BMS cut-out (SAFETY-25): sizing is the trade's business, the pack envelope is the pack's. Weights are a preference, so a pack between its arbitrage floor and its cut-out can still take part of a sell; fixing it needs a per-pack envelope depending on which claim won the tick.