Files
goodwe-addon/goodwe_controller/DECISIONS/MAIN-orchestration.md
T
glenn schrooyenandClaude Opus 5.5 bbbc89b235
checks / unit (pull_request) Successful in 30m13s
infra-pin / fresh (pull_request) Successful in 1s
MAINT-20 part 2 review: comment-only fixes in main.py and the MAIN notes
- 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>
2026-11-08 19:25:37 +01:00

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_s max 25 s, layer-1 veto validate.FIRMWARE_INGEST_VETO_S 30 s, so 25 + 4 < 30. test_inverter.py grades the inequality, so raising the heartbeat ceiling goes red instead of taking an inverter to zero. Below hass.py's 10 s HTTP timeout on purpose: abandon the write and re-issue it next tick, since last_write moves only on an accepted write.
  • Tariff refresh (OPT-04). resolve_tariff() may do an HTTPS fetch. The tick feeds the ESP32 heartbeat, so a supplier's TLS timeout on the tick would push the add-on into layer 1. The tick never awaits a fetch. The 30 min cadence is not about price change (day-ahead publishes once at 14-16 h CET and 8.3's carried hours go firm then); it picks the switch up within the hour at 48 fetches a day. The age bound answers another question: a failed refresh still stores a plan (the 11.4 static fallback), the bound covers the refresh task dying while a plan from hours ago stands as current. Three missed refreshes, for p1.max_age_s's reason.
  • Status rows (TARIFF-11). The Tariff row used to be static_status()'s sentence alone and never saw the provider plan (read 'no static profile' at all 802 samples of a 40 h soak with a live feed). tick_words and plan are two clocks and the row says so; the plan is stamped with its own age from tariff_at (TARIFF-12 does not move it on a kept plan), and age_s is the caller's measurement. The Arbitrage row used to be "off (arbitrage_enabled)" if self.arb is None, under a comment saying arb is None iff trading was off; false, since cycle() returns at the input hold before self.arb = None: arbitrage_enabled: true plus a sensor answering None rendered 'off (arbitrage_enabled)', naming an option that is on, and past the first cycle the previous decision stood as current. Three states: off, armed with no decision this cycle, a decision with its age (always printed: a row that reports staleness only once stale cannot be told from one that never does).
  • stop is required (review catch). stop=None made a Controller that cannot stop itself the default: on the late-refusal path in run_control() it logged the banner, returned and left the process alive forever with no loop and no output.
  • Action log first. The store migration can log to it (15: how an operator finds a latch was re-armed); an AttributeError on the way to saying so would stop the add-on booting.
  • Roster and migrations (DEV-02, SAFETY-44, MAINT-05). Every per-device value lives on the roster objects (app/inverter.py); a Tuning and an integrator are deliberately not per device (6.2: one integrator on P1 above the split; a second regulating the same meter is the two-writers failure). The SAFETY-02 key migration runs before the first Inverter reads its latch, else a tripped install comes up armed with a green banner. SAFETY-44: it is handed roster_names(opts)[0], never UNIT_A (on a declared roster the flat latch landed under unit/inverter-a/, read back under the name it was handed, stamping the marker); roster_names() returns [UNIT_A] on the flat shape so today's path is byte-identical. misdirected is unreachable from that call site (unit is a roster member by construction; dropping the roster argument survives 28/28), so the claim that the call site buys the refusal is gone; the argument stays for new callers and is graded in test_maintenance.py. The maintenance migration targets self.inverters[0].name, not UNIT_A: with UNIT_A the history would sit under a name on no roster (a nameless entry falls back to a positional inverter-1). Every two-pack fixture once named its first entry inverter-a, so swapping them left 19 files green; migrate_maint_keys() now grades the namespace it wrote against the names the machines read, and test_maintenance.py sections 9 and 11 use ["garage", "shed"].
  • CFG-04 disclosure. A declared inverters: roster ignores soc_entity, batt_entity, batt_invert and max_w, which the UI still shows. Rule 9(e) refuses the dangerous one (a flat setpoint_entity); the rest cannot be refused (the schema has no blank value) so one action-log line at every start names the roster and the ignored keys.
  • Site bound. Summed from the roster, not opts["max_w"]: it is the integrator bound and rail of the site loop, then split. Identical at N=1; a per-unit max_w binds each share through its supervised claim.
  • SAFETY-39 slew fallback is 0 ("follow the rail"); it was 1000, a stale third copy. control.slew_limit_w() is the one reader.
  • CFG-03 in main.py. Eight opts.get(key, <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.yaml options:/schema: and maintenance.MAINT_OPTION_DEFAULTS only.
  • Watchdog thresholds (SAFETY-30). The per-unit pair is re-derived at two inverters, not carried (it grades a command against one unit's own power). The site pair is used only at N>=2: the 300 W threshold carries (meter noise, deadband), the window does not (110 s at one inverter, 215 s at two, against a 180 s hold).
  • Latches (SAFETY-02, -31, -34, -42, -18). SAFETY-02: one per actuator, restored from namespaced keys; graded on every start (a latch that failed to move namespace, or was taken by an older add-on in a rollback, is a disarmed guard, so a disagreement re-arms by hand and shouts). SAFETY-31: site-level, flat keys (circulating power is defined over the roster), restored because it acts. SAFETY-42's delivery latch does not act, so it is restored only so the ALARM survives (the 2026-09-02 outage went unseen 6 h 25 m with the detector correct). SAFETY-18 is not restored (does not act). SAFETY-30's site check is armed only at N>=2.
  • PV (TEL-11). Instrumentation only; wiring it in would be an unrequested behaviour change and would destroy the measurement.
  • Peak guard setup (SAFETY-07, TARIFF-01, FCST-04). See SAFETY-07-peak-guard.md and FCST-04. The windowing caveat for FCST-04: hours already elapsed leave the spread, so a cheap night that has gone no longer sets the day's cheapest hour.

Cycle state and latch repair

  • Allocation record (SAFETY-28). The tick that mints a record increments once per resolution, not per loop iteration. A cycle with no resolution (the "inputs missing, not yet stale" hold) must not bump it: that hold returns before the allocator so one missed poll does not zero the inverter, and the heartbeat re-affirms self.target through the write path; bumping would make that heartbeat unauthorised and drop the site to 0 W. The record is superseded, never expired.
  • self.arb (TARIFF-11). See "Status rows" above; arb_at < cycle_at is the derived "the latest cycle did not reach the arbitrage step".
  • Shadow (SAFETY-10, SAFETY-47). shadow_would_w is what a live controller would have put on the wire; self.target is what is standing on the wire (held at shadow.WIRE_W, re-seeded each shadow cycle). Folding them shows an operator a number nothing is doing. The "sized from the billed-quarter meter" flag is recorded where the decision was taken (the page runs on its own task); False, not None, before the first shadow cycle.
  • ARB-03. units is a property derived from the roster so a site cannot claim two actuators while holding one writer. At N=1 (6.2) the grid connection and the only inverter are one actuator; a second entry makes per-inverter claims stop being site claims (ALLOC-01).
  • DEV-02 latch guard (_check_sign_latch...). Runs on every start: gating on rep.ran swallowed store.py's rule-2 round trip (upgrade, downgrade with a trip in the window, upgrade; the controller came up untripped while state.json said sign_tripped: true; a firmware rollback path was performed on this site on 2026-09-02). The gate could go because handle_action() clears the flat keys with the namespaced ones and the trip mirrors them. SAFETY-44 second half: the question is whether a namespaced latch THAT A MACHINE READS is set. An earlier version accepted any unit/*/sign_tripped, which blinded the guard on exactly the installs the broken migration had put the latch under unit/inverter-a/ (a key nothing reads). The first blunt fix (every off-roster latch is litter) re-armed a healthy A off B's mirrored flat key (test_inverter.py DEV-02 restart case). UNIT_A is the discriminator: the only name the broken migration wrote. A single flag written for two intents was defeated by one removed unit's key (roster ['garage','shed'] with unit/old-shed/sign_tripped drove on a state file reading sign_tripped: true; tools/rehearse_roster.py exit 1); the fixtures now hold both arrangements. Residual: a first unit really named inverter-a, latched and removed from the roster is re-armed on inverters[0] (conservative). The printed reason is the flat mirror's (whatever tripped last), so on a multi-unit site one inverter's evidence may appear under another's name; test_maintenance.py's check that the reason is not the removed unit's depends on that fixture's flat string, not on this code.
  • Maintenance views (MAINT-05). maints is one FSM per roster entry; maint, maint_unit and the button target are views of the one that holds the slot (maintenance.arbitrate() allows at most one out of IDLE). validate.py rule 6 grades maintenance_charge_w against the weakest cap on the roster.
  • N=1 views. Properties, not fields, and they refuse at N>=2 (6.2's identity is false there). Nothing inside main.py may read them (test_inverter.py AST scan).
  • _arm_sign_watchdogs (SAFETY-30). Called every cycle because fixtures append an Inverter after construction; a detector frozen at construction would be graded against the meter at a roster of two. arm() touches no timer or latch.
  • Per-inverter refusal (SAFETY-73). The probe ask is PROBE_W, not saturation_w: a unit's share is often small (a 400 W night load split two ways) and SAFETY-43 would name the empty battery as not delivering. The site target is restarted from the watts on the wire when a unit reopens (phase A: -1059 W).

Write path and cycle

  • The write gate (SAFETY-28). specs 1.5 makes two writers on one actuator a non-goal. len(validate.ACTUATOR_WRITE_KEYS) == 1 is a fact about the options schema, not the writes, and goes false when CFG-04 adds per-inverter config. So the enforcement is on the write: a number reaches the wire only if the single allocator placed it, on this tick, for this unit (Allocation.authorises()); a second writer must file a Claim. 0 W is always authorised and the test for zero comes first and reads nothing: the failsafe branches write 0 W while the allocator has refused (Allocation.ok False, no shares), and a gate asking its permission would deadlock the failsafe behind the component it answers; at S = 0 broadcast and allocation coincide, so there is no over-command (the hazard is N-times loop gain from broadcast, TEST-19's N=2 gain 1.0 limit cycle). The predicate is value == 0.0, not a failsafe=True flag, which a second writer would pass; -0.0 == 0.0 and a nan never gets here. DEV-02: the unit is an argument, not the N=1 view; the default refuses at N>=2. The refusal sits above the clamp and below the non-finite coercion.
  • Clamp (SAFETY-03) is on the only path to the wire, so no optimizer, maintenance or manual path routes around it. A non-finite value is loss of input (11.1), coerced to 0 W. Every engagement goes to the action log undeduplicated; the notification is deduplicated.
  • Shadow (SAFETY-10) suppresses in the same method, below the gate and the clamp, as a substitution not a return. It is not a second writer.
  • Reads (DEV-02, TEL-12). Each inverter's readings are gathered concurrently across units and sequentially within one; PV entities ride in the same gather (a slow sensor must not push a healthy battery toward layer 1's veto). P1 has no inverter-side fallback: the inverter's AC power correlates 0.998 with battery power and 0.09 with the real meter.
  • Stale reading (SAFETY-37/56/57). The power reading goes through the freshness gate, the SoC does not (the power feeds the sign and delivery checks, which a frozen register fools). SAFETY-57 rewrote the escape clause: "it clears itself when the register reports a different value" was false for the state announced, because the 0 W it announces is what stops the value changing. The release is reported, never silent (it withdraws a SITE STOP).
  • TARIFF-03. The one shiftable load is the maintenance cycle's cheap-window top-up; the static window is synchronous and I/O-free, and validate.py refuses static_profile together with the price entities.
  • Cycle ordering (SAFETY-02/31/42). The watchdog evaluations run every cycle, before the input hold, so a run of held cycles cannot leave their timers resuming from a start that predates the gap. Reason first, flag second (each set writes the file): a crash between persists a reason nothing reads, not a latch with no explanation. The per-unit trip mirrors the legacy flat keys (store.py rule 2 covered only the latch present at migration time). The site check is flat-keyed, armed only at N>=2. SAFETY-31's input is MEASURED power (inv.batt), never the allocation record, which has sign-coherence by construction.
  • Maintenance FSMs (MAINT-05). Each pack is ticked with its own SoC (a minimum would abort a cycle on a sibling's state, an average would declare a pack full that is not); arbitrate() runs first. A deferral is carried to the status page because the edge-logged line scrolls out of the 40-row log.
  • Aggregates. SUM for the site bound, achievable rating and the battery power the site loop regulates against (compute()'s saturation test compares against the site target; one unit's power trips the freeze permanently, up to 510 W of split-dependence at gain 0.2, test_alloc.py part 8). MINIMUM for the discharge permission (the emptiest pack). MEASURED power for circulating power. Unknown bounds stay unknown when any unit is silent.
  • Trade sizing (OPT-09). trade_sizing uses arbitrage's own SoC bounds, not the BMS cut-out (SAFETY-25): sizing is the trade's business, the pack envelope is the pack's. Weights are a preference, so a pack between its arbitrage floor and its cut-out can still take part of a sell; fixing it needs a per-pack envelope depending on which claim won the tick.