diff --git a/goodwe_controller/DECISIONS/MAIN-orchestration.md b/goodwe_controller/DECISIONS/MAIN-orchestration.md index d1167d0..a6e8136 100644 --- a/goodwe_controller/DECISIONS/MAIN-orchestration.md +++ b/goodwe_controller/DECISIONS/MAIN-orchestration.md @@ -4,7 +4,8 @@ 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]], +`d4c01da`. Since MAINT-21 the Controller's methods are split by job into +`app/main.py` and `app/ctl_*.py`; a comment keeps its text and moves with its code. 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]]. @@ -12,17 +13,18 @@ comments that were shortened but not rewritten here are kept word for word in ## 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). +- **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 (5 400 s), 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. "No plan" is said aloud on a site that configured a provider. 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"]`. +- **One maintenance config (MAINT-05).** The `maintenance_*` options describe the procedure, the same on every pack; the schedule and history are per pack, in the store. A per-entry config block would be a second shape for CFG-04 and a fifth place for `maintenance_charge_w` to disagree with config.yaml; two procedures would be a roster-schema card. - **CFG-04 disclosure.** A declared `inverters:` roster ignores `soc_entity`, `batt_entity`, `batt_invert` and `max_w`, which the UI still shows. Rule 9(e) refuses the dangerous one (a flat `setpoint_entity`); the rest cannot be refused (the schema has no blank value) so one action-log line at every start names the roster and the ignored keys. - **Site bound.** Summed from the roster, not `opts["max_w"]`: it is the integrator bound and rail of the site loop, then split. Identical at N=1; a per-unit `max_w` binds each share through its `supervised` claim. - **SAFETY-39 slew fallback** is 0 ("follow the rail"); it was 1000, a stale third copy. `control.slew_limit_w()` is the one reader. - **CFG-03 in main.py.** Eight `opts.get(key, )` calls used to live in the maintenance setup and two disagreed with config.yaml (2500 against 2000). `MaintConfig.from_options()` reads them. A ninth key goes in config.yaml `options:`/`schema:` and `maintenance.MAINT_OPTION_DEFAULTS` only. - **Watchdog thresholds (SAFETY-30).** The per-unit pair is re-derived at two inverters, not carried (it grades a command against one unit's own power). The site pair is used only at N>=2: the 300 W threshold carries (meter noise, deadband), the window does not (110 s at one inverter, 215 s at two, against a 180 s hold). -- **Latches (SAFETY-02, -31, -34, -42, -18).** SAFETY-02: one per actuator, restored from namespaced keys; graded on every start (a latch that failed to move namespace, or was taken by an older add-on in a rollback, is a disarmed guard, so a disagreement re-arms by hand and shouts). SAFETY-31: site-level, flat keys (circulating power is defined over the roster), restored because it acts. SAFETY-42's delivery latch does not act, so it is restored only so the ALARM survives (the 2026-09-02 outage went unseen 6 h 25 m with the detector correct). SAFETY-18 is not restored (does not act). SAFETY-30's site check is armed only at N>=2. +- **Latches (SAFETY-02, -31, -34, -42, -18).** SAFETY-02: one per actuator, restored from namespaced keys (a latch held only in memory is cleared by the restart an operator tries first); 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: a cleared latch would restart straight into the loop it stopped. 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; a restart re-derives it within one `meter_freeze_s`). SAFETY-30's site check is armed only at N>=2: arming it at one inverter would duplicate `inverters[0].sign_wd` (two latches, two banners, a false-positive record spent twice), so it is constructed there and never evaluated; `cycle()` enforces it. - **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. @@ -32,7 +34,7 @@ comments that were shortened but not rewritten here are kept word for word in - **`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. +- **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). This guard is the only repair for sites upgraded before the fix. 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. @@ -49,4 +51,4 @@ comments that were shortened but not rewritten here are kept word for word in - **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. +- **Trade sizing (OPT-09).** `trade_sizing` uses arbitrage's own SoC bounds (`arbitrage_soc_floor` / `_ceiling`), not the BMS cut-out (SAFETY-25): sizing is the trade's business, the pack envelope is the pack's. It is what the site can push and still have somewhere to put; it sizes the ask and does not route it. Neither side may be zero on a trading tick: `arbitrage.decide()` refuses `IDLE_NO_SIZE`, since a 0 W claim is a park order. Weights are a preference, so a pack between its arbitrage floor and its cut-out can still take part of a sell; fixing it needs a per-pack envelope depending on which claim won the tick. diff --git a/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md b/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md index d9a5dce..9e4553e 100644 --- a/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md +++ b/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md @@ -709,7 +709,7 @@ rather than smuggled in. **Also still not in this card, and named by the shipped code already:** the charge *ceiling* is the mirror image — `pack_floor_soc` is the wrong reduction for it, because a ceiling is the **fullest** pack and `arbitrage`'s `soc_ceiling` -takes one number. `main.py:932-936` says so, and it belongs to CFG-04/SPIKE-04's +takes one number. The `Controller.pack_floor_soc` docstring in `app/main.py` says so, and it belongs to CFG-04/SPIKE-04's per-pack bound. Nothing here touched it. ## 6. What was changed, and one thing that was found on the way diff --git a/goodwe_controller/app/ctl_inputs.py b/goodwe_controller/app/ctl_inputs.py index 273144d..c5af7b1 100644 --- a/goodwe_controller/app/ctl_inputs.py +++ b/goodwe_controller/app/ctl_inputs.py @@ -29,20 +29,14 @@ METER_QUIET_REPEAT_S = 600.0 _mono = time.monotonic # patched by the checks; asyncio keeps the real one -# OPT-04: the background tariff refresh. `static_tariff.resolve_tariff()` may -# do an HTTPS fetch, and a supplier's TLS timeout on the control tick could -# starve the ESP32 heartbeat into layer 1. So the tick NEVER awaits a fetch: it -# reads `self.tariff_plan`, which this task assigns. -# The cadence is not about how often prices change (day-ahead publishes once, -# 14-16 h CET; 30 min picks it up inside the hour with 48 fetches a day). +# OPT-04: the background tariff refresh. The tick NEVER awaits a fetch: it +# reads `self.tariff_plan`, which the refresh task assigns. # DECISIONS/MAIN-orchestration.md. TARIFF_REFRESH_S = 1800.0 -# The age bound is a different question from the refresh: a failed refresh -# still stores a plan (the 11.4 static fallback, provisional). The bound covers -# the refresh task dying and a stale plan standing as current. Three missed -# refreshes, for `p1.max_age_s`'s reason. +# The age bound covers the refresh task dying: three missed refreshes. +# DECISIONS/MAIN-orchestration.md. TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S @@ -330,6 +324,7 @@ class InputsMixin: check and the delivery check, which a frozen register would fool. The standing command is asked of the unit, not recomputed.""" # SMA-2: the driver reads SoC, then power, as this method always did. + # DECISIONS/SMA-2-driver-seam.md. inv.soc, raw, _extra = await self._driver(inv).read() was_stale = inv.batt_fresh.stale was_releases = inv.batt_fresh.releases diff --git a/goodwe_controller/app/ctl_tasks.py b/goodwe_controller/app/ctl_tasks.py index d1801de..5aa2df6 100644 --- a/goodwe_controller/app/ctl_tasks.py +++ b/goodwe_controller/app/ctl_tasks.py @@ -21,13 +21,8 @@ _LOG = logging.getLogger("goodwe") class TasksMixin: def _grade_store_migration(self, store) -> None: """DEV-02. Did SAFETY-02's latch actually survive the key rename? - Graded, not performed: a latch that silently fails to move is a - DISARMED SAFETY GUARD on an install that believes it is protected. The - pre-DEV-02 flat value is compared with what the roster came up with; a - disagreement re-arms the latch by hand (the fail-safe direction is ON). - It runs on EVERY start, not only the migrating one: gating on `rep.ran` - swallowed store.py's rule-2 round trip (upgrade, downgrade with a trip - in the window, upgrade; performed on this site 2026-09-02). + Graded on EVERY start: a latch that silently fails to move is a + disarmed safety guard, so a disagreement re-arms it by hand. DECISIONS/MAIN-orchestration.md.""" rep = self.store_migration if rep.ran and not rep.ok: @@ -38,16 +33,10 @@ class TasksMixin: legacy_tripped = bool(data.get("sign_tripped", False)) if not legacy_tripped or not self.inverters: return - # The question is "is any namespaced latch set THAT A MACHINE READS" - # (SAFETY-44), asked of the STORE's keys, not the roster's objects: - # re-arming A off a flat key that B's trip mirrored would latch a - # healthy inverter on every restart. Three cases: on the roster, stop; - # off-roster named `UNIT_A` (the broken migration's fingerprint), - # repair onto `inverters[0]`; off-roster, other name: stop unless a - # stranded `UNIT_A` latch is present. This guard is the ONLY repair for - # sites upgraded before the fix. Residual: a first unit really named - # `inverter-a`, latched and removed, is re-armed on `inverters[0]` - # (conservative). DECISIONS/MAIN-orchestration.md. + # Is any namespaced latch set THAT A MACHINE READS (SAFETY-44)? Asked + # of the STORE's keys, not the roster's objects. On the roster: stop; + # off-roster `UNIT_A`: repair onto `inverters[0]`; any other + # off-roster name: stop. DECISIONS/MAIN-orchestration.md. on_roster = {inv.name for inv in self.inverters} stranded, on_roster_latched, other_off_roster = [], False, False for k, v in data.items(): @@ -193,7 +182,8 @@ class TasksMixin: self.log_event("REFUSED: " + problems[0], hint=messages.hint("refused")) # Signal and return WITHOUT entering the loop: amain() runs the - # wind-down (0 W, or a hand-back: SMA-10) and exits with code 78. + # wind-down (0 W, or a hand-back: SMA-10, see + # DECISIONS/SMA-10-two-idle-states.md) and exits with code 78. self.stop_event.set() return diff --git a/goodwe_controller/app/ctl_trade.py b/goodwe_controller/app/ctl_trade.py index 2d8e40a..d967d7f 100644 --- a/goodwe_controller/app/ctl_trade.py +++ b/goodwe_controller/app/ctl_trade.py @@ -82,14 +82,10 @@ MAINT_CHARGING_W = 50.0 class TradeMixin: def trade_power_w(self, floor_pct, ceiling_pct) -> tuple: - """OPT-09. `(charge_w, discharge_w)`: how hard a trade may be SIZED: - what the site can push AND STILL HAVE SOMEWHERE TO PUT. The bounds are - arbitrage's own (`arbitrage_soc_floor` / `_ceiling`), NOT the BMS - cut-out that `packshare.envelope()` uses (SAFETY-25): different - questions. It sizes the ask, it does not route it. A pack whose SoC did - not read counts on neither side. Neither side may be zero on a trading - tick (`arbitrage.decide()` refuses `IDLE_NO_SIZE`: a 0 W claim is a - park order). DECISIONS/MAIN-orchestration.md.""" + """OPT-09. `(charge_w, discharge_w)`: how hard a trade may be SIZED, + on arbitrage's own SoC bounds, not the BMS cut-out. A pack whose SoC + did not read counts on neither side; neither side may be zero on a + trading tick (`IDLE_NO_SIZE`). DECISIONS/MAIN-orchestration.md.""" # The reduction lives in `packshare`; `test_packshare.py` part 9 calls # the SAME function (a transcription in a check is a copy that drifts). return packshare.trade_power_w(self.pack_energy, floor_pct, ceiling_pct) diff --git a/goodwe_controller/app/ctl_writes.py b/goodwe_controller/app/ctl_writes.py index d307ab6..637496c 100644 --- a/goodwe_controller/app/ctl_writes.py +++ b/goodwe_controller/app/ctl_writes.py @@ -15,12 +15,8 @@ from . import shadow _LOG = logging.getLogger("goodwe") -# DEV-02: the per-write budget: every write is dispatched concurrently, each -# bounded by this, so one hung `hass.call()` cannot eat a sibling's layer-1 -# budget. DERIVED: `heartbeat_s` max 25 s + 4 < -# `validate.FIRMWARE_INGEST_VETO_S` (30 s), graded by test_inverter.py; below -# hass.py's 10 s HTTP timeout on purpose (abandon, re-issue next tick; -# `last_write` moves only on an ACCEPTED write). +# DEV-02: the per-write budget, DERIVED: `heartbeat_s` max 25 s + 4 < +# `validate.FIRMWARE_INGEST_VETO_S` (30 s), graded by test_inverter.py. # DECISIONS/MAIN-orchestration.md. WRITE_BUDGET_S = 4.0 @@ -33,6 +29,7 @@ RELEASE_BUDGET_S = WRITE_BUDGET_S # SMA-10: the one fallback write after a failed release (default 0 W). Worst # case RELEASE_BUDGET_S + FALLBACK_BUDGET_S = 6 s, inside the Supervisor's 10 s. +# DECISIONS/SMA-10-two-idle-states.md. FALLBACK_BUDGET_S = 2.0 @@ -345,7 +342,8 @@ class WritesMixin: """Put `value` on ONE inverter, or with `unit=None` on all. `unit=None` is for a number that is not a share (stop button, `shutdown()`, `maint_abort()`: 0 W means the same to every inverter). Writes are - concurrent, each under its own budget (DEV-02). `last`: SAFETY-137.""" + concurrent, each under its own budget (DEV-02). `last`: + DECISIONS/SAFETY-137-last-write-is-zero.md.""" if unit is None: targets = [i for i in self.inverters] if len(targets) > 1 and is_commandable(value) and float(value) != 0.0: @@ -375,14 +373,16 @@ class WritesMixin: async def _await_inflight(self, inv, budget_s: float) -> None: """Wait, at most `budget_s`, for writes already on their way to `inv`; one - still running is left to its own WRITE_BUDGET_S. SAFETY-137.""" + still running is left to its own WRITE_BUDGET_S. + DECISIONS/SAFETY-137-last-write-is-zero.md.""" pending = [t for t in inv.inflight if not t.done()] if pending: await asyncio.wait(pending, timeout=budget_s) async def _release_last(self, inv) -> bool: """`release()` once any write in flight to `inv` has finished (at most - SETTLE_BUDGET_S): the release is the LAST thing the unit hears.""" + SETTLE_BUDGET_S): the release is the LAST thing the unit hears. + DECISIONS/SMA-10-two-idle-states.md.""" await self._await_inflight(inv, SETTLE_BUDGET_S) return await self._driver(inv).release() @@ -397,7 +397,7 @@ class WritesMixin: if not inv.driver.configured(): return False # After a failure the next try waits one heartbeat; Stop and shutdown - # (`force`) always try. + # (`force`) always try. DECISIONS/SMA-10-two-idle-states.md. now_m = time.monotonic() if not force and now_m < inv.release_retry_at: return False @@ -421,6 +421,7 @@ class WritesMixin: self.log_event(f"{inv.name}: handed back", hint=messages.hint("handed_back", unit=inv.name)) return True + # SMA-10: a failed hand-back is tried again, and sent 0 W once below. inv.released = False inv.release_fails += 1 inv.release_retry_at = now_m + float(self.o.get("heartbeat_s", 10)) @@ -433,7 +434,7 @@ class WritesMixin: hint=messages.hint("hand_back_failed", unit=inv.name)) if not inv.release_zero_sent: # ⚠️ ONCE: a 0 W re-sent on every try would reset the inverter's own - # fallback timer for ever. + # fallback timer for ever. DECISIONS/SMA-10-two-idle-states.md. inv.release_zero_sent = True try: if await _bounded(self._driver(inv).release_fallback(), @@ -539,7 +540,8 @@ class WritesMixin: # ACCEPTED write). try: _w = self._driver(inv).write(value) - # SMA-10 / SAFETY-137: kept so a release or last 0 W can wait for it. + # SMA-10 / SAFETY-137: kept so a release or last 0 W can wait for + # it. DECISIONS/SAFETY-137-last-write-is-zero.md. _w = asyncio.ensure_future(_w) inv.inflight.add(_w) _w.add_done_callback(inv.inflight.discard) diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 77d7000..1f2efd0 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -16,7 +16,10 @@ takes the battery to zero. Never "hold the last value to be safe". layer 3 the optional RS485 e-stop: 0 W after 30 s of bus silence; the only cover for this add-on's host dying. -Per-section reasoning: DECISIONS/MAIN-orchestration.md.""" +Per-section reasoning: DECISIONS/MAIN-orchestration.md. The Controller keeps +__init__, its roster views and shutdown here; its other methods are in +ctl_inputs, ctl_writes, ctl_trade, ctl_cycle, ctl_alarms, ctl_status and +ctl_tasks (MAINT-21), the status-row functions in rows.""" import asyncio import contextlib @@ -103,6 +106,8 @@ from .watchdog import ( ) from . import setuponly from . import web +# MAINT-21: the Controller's parts and the names they now define; every one +# stays importable from `app.main`. DECISIONS/MAIN-orchestration.md. from .ctl_tasks import TasksMixin from .ctl_status import StatusMixin from .ctl_alarms import ( @@ -190,42 +195,32 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin, self.store = store self.mqtt = mqtt_pub # REQUIRED and keyword-only: a default `stop=None` made a Controller - # that cannot stop itself (inert but looks healthy on the late-refusal - # path in run_control()). DECISIONS/MAIN-orchestration.md. + # that cannot stop itself. DECISIONS/MAIN-orchestration.md. self.stop_event = stop self.refused: list[str] | None = None - # First, because the store migration below can log to it: 15's action - # log is how an operator learns a latch was re-armed, and an - # AttributeError here would stop the add-on booting. + # First: the store migration below can log to it. + # DECISIONS/MAIN-orchestration.md. self.events: list[str] = [] # UX-08: the last event in plain words, for the page. See log_event(). self.hint = "" - # DEV-02: the roster, built before anything reads a rating; every - # per-device value lives on these objects (app/inverter.py), but a - # `Tuning` and the integrator are deliberately NOT per device (6.2: one - # integrator on P1 above the split). The store migration runs here, - # BEFORE the first `Inverter` reads its SAFETY-02 latch, else a tripped - # install comes up ARMED with a green banner. - # DECISIONS/MAIN-orchestration.md. + # DEV-02: the roster, built before anything reads a rating. The store + # migration runs here, BEFORE the first `Inverter` reads its SAFETY-02 + # latch. DECISIONS/MAIN-orchestration.md. self.sign_cfg = sign_cfg = SignWatchdogConfig( min_w=float(opts.get("sign_disagree_w", 300)), hold_s=float(opts.get("sign_disagree_s", 180)), ) - # SAFETY-30: the per-unit comparison's own pair, re-derived at two - # inverters, not carried across (a command against one unit's own power - # is a different quantity). DECISIONS/SAFETY-30-per-unit-sign.md; - # watchdog.SIGN_OPTION_DEFAULTS. + # SAFETY-30: the per-unit pair, re-derived at two inverters, not + # carried. DECISIONS/SAFETY-30-per-unit-sign.md. self.unit_sign_cfg = SignWatchdogConfig( min_w=float(opts.get("unit_sign_disagree_w", SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"])), hold_s=float(opts.get("unit_sign_disagree_s", SIGN_OPTION_DEFAULTS["unit_sign_disagree_s"])), ) - # SAFETY-30: the site comparison's own pair, used only at N>=2. The 300 - # W threshold is carried (meter noise and deadband); the window is not - # (110 s at one inverter, 215 s at two, against a 180 s hold). See - # watchdog.SIGN_OPTION_DEFAULTS. + # SAFETY-30: the site pair, used only at N>=2. + # DECISIONS/MAIN-orchestration.md. self.site_sign_cfg = SignWatchdogConfig( min_w=float(opts.get("site_sign_disagree_w", SIGN_OPTION_DEFAULTS["site_sign_disagree_w"])), @@ -239,11 +234,8 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin, hold_s=float(opts.get("delivery_hold_s", DELIVERY_OPTION_DEFAULTS["delivery_hold_s"])), ) - # SAFETY-44: hand the migration the names the machines read, - # `roster_names(opts)[0]`, never `UNIT_A` (on a declared roster the - # latch landed under `unit/inverter-a/`, on no roster, and stamped the - # marker). `roster_names()` is `[UNIT_A]` on the flat shape, so today's - # path is byte-identical. DECISIONS/MAIN-orchestration.md. + # SAFETY-44: the migration gets `roster_names(opts)[0]`, never + # `UNIT_A`. DECISIONS/MAIN-orchestration.md. _roster_names = roster_names(opts) self.store_migration = migrate_unit_keys( store, _roster_names[0], _roster_names) @@ -252,10 +244,8 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin, # restart and shadow mode (DECISIONS/SMA-10-two-idle-states.md). handback.set_roster(handback.units_of(self.inverters)) uitext.set_idle(handback.overlay(handback.current())) - # CFG-04: say which shape is in force. A declared `inverters:` roster - # ignores `soc_entity`, `batt_entity`, `batt_invert` and `max_w`, which - # the UI still shows; validate.py rule 9(e) refuses only the dangerous - # one, so the rest are DISCLOSED in one action-log line at every start. + # CFG-04: a declared roster ignores flat keys the UI still shows; say + # so once at every start. DECISIONS/MAIN-orchestration.md. if roster_entries(opts): self.log_event( "roster: %d inverters from `inverters:` - %s. The flat " @@ -267,14 +257,11 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin, self.tuning = Tuning( gain=float(opts.get("gain", 0.6)), - # The SITE bound is summed from the roster, not `opts["max_w"]`: it - # is the integrator bound and rail of the site loop, split across - # inverters. Identical to `max_w` at N=1; a per-unit `max_w` still - # binds each share through its `supervised` claim. + # The SITE bound is summed from the roster, not `opts["max_w"]`. + # DECISIONS/MAIN-orchestration.md. max_w=self.site_max_w, - # SAFETY-39: the fallback is 0, meaning "follow the rail" - # (config.yaml and `Tuning`). It was 1000, a stale third copy. - # `control.slew_limit_w()` is the one reader. + # SAFETY-39: 0 means "follow the rail"; `control.slew_limit_w()` + # is the one reader. DECISIONS/MAIN-orchestration.md. slew_w=float(opts.get("slew_w", 0) or 0), deadband_w=float(opts.get("deadband_w", 15)), target_grid_w=float(opts.get("target_grid_w", 0)), @@ -287,14 +274,9 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin, integrator_max_w=(float(opts["integrator_max_w"]) if opts.get("integrator_max_w") else None), ) - # CFG-03: no `opts.get(key, )` for maintenance here; - # `MaintConfig.from_options()` reads them next to - # `maintenance.MAINT_OPTION_DEFAULTS`; a new key goes in config.yaml - # `options:`/`schema:` and that dict only. MAINT-05: one `Maintenance` - # per roster entry; the history migration runs first (else a pack - # believes it was never recalibrated) and targets - # `self.inverters[0].name`, NOT `UNIT_A`; the roster is passed so - # `migrate_maint_keys()` grades the namespace it wrote. + # CFG-03: `MaintConfig.from_options()` reads the options. MAINT-05: + # one `Maintenance` per roster entry; the history migration runs first, + # onto `self.inverters[0].name`, NOT `UNIT_A`. # DECISIONS/MAIN-orchestration.md. self.maint_migration = migrate_maint_keys( store, self.inverters[0].name, [inv.name for inv in self.inverters]) @@ -306,12 +288,8 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin, self.log_event("maintenance history did not move over cleanly: " + self.maint_migration.explain(), hint=messages.hint("maint_history")) - # MAINT-05: one config, N machines: the `maintenance_*` options - # describe the PROCEDURE (same on every pack); the SCHEDULE and HISTORY - # are per pack, in the store. A per-entry config block would be a - # second shape for CFG-04 and a fifth place for `maintenance_charge_w` - # to disagree with config.yaml; two procedures would be a roster-schema - # card. + # MAINT-05: one config, N machines: the options describe the + # PROCEDURE; the SCHEDULE and HISTORY are per pack, in the store. _maint_cfg = MaintConfig.from_options(opts) _one_pack = len(self.inverters) <= 1 self.maints = [ @@ -329,12 +307,9 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin, tz=site_tz) # MAINT-10: the start hour's zone, from amain() for i, inv in enumerate(self.inverters)] - # SAFETY-02: one watchdog per actuator, each restored from ITS OWN - # namespaced store keys (a trip on B latches B, A keeps commanding). - # Restored, because a latch held only in memory is cleared by the - # restart an operator tries first. Graded on every start: a - # disagreement with the flat value re-arms by hand and shouts (the - # fail-safe direction is ON). DECISIONS/MAIN-orchestration.md. + # SAFETY-02: one watchdog per actuator, restored from ITS OWN + # namespaced keys and graded on every start. + # DECISIONS/MAIN-orchestration.md. self._grade_store_migration(store) for inv in self.inverters: if inv.sign_wd.tripped: @@ -354,12 +329,9 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin, hint=messages.hint("stop_restored", unit=inv.name)) - # SAFETY-42 + SAFETY-34: a latch that does NOT act, unlike SAFETY-02's - # and SAFETY-31's (which command 0 W). It is restored so the ALARM - # survives (2026-09-02: an outage unseen for 6 h 25 m with the detector - # correct); `delivery.py` argues reporting rather than acting. - # `_delivery_announced` is per process (as `_circ_announced`): a - # restart clears the flag and keeps the latch. + # SAFETY-42 + SAFETY-34: a latch that does NOT act, restored so the + # ALARM survives; `_delivery_announced` is per process, so a restart + # clears the flag and keeps the latch. DECISIONS/MAIN-orchestration.md. self._delivery_announced = set() for inv in self.inverters: if inv.delivery_wd.tripped: @@ -376,12 +348,9 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin, hint=messages.hint("not_delivering", unit=inv.name)) - # SAFETY-30: the SITE half of the sign check, one per site, armed only - # at N>=2: the SUM of the standing commands against P1. At a roster of - # one it is constructed and never evaluated (arming it would duplicate - # `inverters[0].sign_wd`: two latches, two banners, a false-positive - # record spent twice); `cycle()` enforces it. Restored from the store - # with FLAT keys (store.py). DECISIONS/SAFETY-30-per-unit-sign.md. + # SAFETY-30: the SITE half of the sign check: armed only at N>=2 (at + # one inverter it is constructed and never evaluated), restored with + # FLAT keys. DECISIONS/SAFETY-30-per-unit-sign.md. self.site_sign_wd = SignWatchdog( self.site_sign_cfg, tripped=bool(store.data.get("site_sign_tripped", False)), @@ -403,15 +372,12 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin, self._arm_sign_watchdogs() # SAFETY-18: the frozen-but-fresh meter detector (freeze.py). NOT - # restored from the store: it does not act, so it does not latch; a - # restart re-derives it within one `meter_freeze_s`. + # restored: it does not act. DECISIONS/MAIN-orchestration.md. self.freeze_wd = FreezeWatchdog( FreezeWatchdogConfig(hold_s=float(opts.get("meter_freeze_s", 300)))) - # SAFETY-31: one per site, RESTORED (unlike SAFETY-18): a trip files a - # P_SAFETY SET that stops every inverter, and a cleared latch would - # restart straight into the loop it stopped. SITE-level keys, not - # namespaced: circulating power is defined over the roster (store.py). + # SAFETY-31: one per site, RESTORED because it acts, with SITE-level + # keys. DECISIONS/MAIN-orchestration.md. self.circ_wd = CirculationWatchdog( CirculationConfig( min_w=float(opts.get("circulation_w", @@ -1198,6 +1164,7 @@ async def amain() -> None: # yet up is not a refusal (run_control() re-checks). # (DECISIONS/MAIN-orchestration-archive.md) # SMA-2: the flat options are the GoodWe driver's (no Controller yet). + # DECISIONS/SMA-2-driver-seam.md. _sp = str(opts.get("setpoint_entity", "") or "").strip() _drv = GoodWeEsphomeDriver(setpoint_entity=_sp).bind(hass) _dev_min = _dev_max = None diff --git a/goodwe_controller/app/rows.py b/goodwe_controller/app/rows.py index a44c305..c5ccb78 100644 --- a/goodwe_controller/app/rows.py +++ b/goodwe_controller/app/rows.py @@ -40,11 +40,8 @@ def why_key(winner: str, claims) -> str: def tariff_plan_row(plan, tick_words: str, *, age_s, horizon_h: float, provider: bool, max_age_s: float) -> str: """The *Tariff* status row: the 30 s tick's sentence AND the provider plan - (TARIFF-11): two sources, two clocks, and the row says so. The plan is - stamped with its own age from `tariff_at` (not moved on a kept plan, - TARIFF-12), up to TARIFF_MAX_AGE_S (5400 s); `age_s` is the caller's - measurement. "No plan" is said aloud on a site that configured a provider. - DECISIONS/MAIN-orchestration.md.""" + (TARIFF-11), each with its own clock. "No plan" is said aloud on a site + that configured a provider. DECISIONS/MAIN-orchestration.md.""" # UX-08: plain words, every figure kept. The tick's sentence is "now"; the # plan carries its own age. if plan is None: @@ -58,12 +55,9 @@ def tariff_plan_row(plan, tick_words: str, *, age_s, horizon_h: float, def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now, why: str) -> str: - """The *Arbitrage* status row: three states (TARIFF-11): off (not armed), - armed with no decision this cycle (`why` is the loop's reason), or a - decision with its age. - `on` is read from the options, never inferred from `decision is None`: - `cycle()` returns at the input hold before `self.arb = None`, so the old - row named an option that was ON. The age is always printed, even 0. + """The *Arbitrage* status row: off, armed with no decision this cycle + (`why` is the loop's reason), or a decision with its age (TARIFF-11). + `on` is read from the options, never inferred from `decision is None`. DECISIONS/MAIN-orchestration.md.""" # UX-08: plain words; the setting is named by its label. if not on: