diff --git a/.gitea/infra-pin b/.gitea/infra-pin index 2434e5a..f01d2b5 100644 --- a/.gitea/infra-pin +++ b/.gitea/infra-pin @@ -1,2 +1,2 @@ # The admin/goodwe commit CI tests against. Bump: set to the full SHA of admin/goodwe release/1.0 HEAD (README.md, "CI and the infra pin"). -d637d0b1b13f1b863dc47e445e2ca4cf4a82fe10 +3b4ff84757f9c2c5b63a4b4a53e593ccaeafb847 diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 6e298b6..f407c42 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -1,5 +1,256 @@ # Changelog +## 0.17.0 + +**A minor bump: changed behaviour, and no option changes.** No option was +added, removed or renamed, and no default changed (83 options). Options you +saved work as before. The changes below apply with one inverter or two, unless +a line says "Two inverters". + +**Maintenance with no battery level.** When the battery level is missing, +battery maintenance now ends on its own time limits instead of waiting for the +level to come back (MAINT-14, MAINT-15). You do not need to change any option. + +Contents of this release, in merge order since 0.16.0 (`451729c`): + +| | | +|---|---| +| `b527e99` | Two inverters: after a drain ends, the other battery no longer swings on old readings. In the test, it held steady for 23 s where 0.16.0 moved it by 720 W ([[SAFETY-103]]) | +| `8a17711` | A full battery that stops at 98 or 99 % no longer counts as a failed charge. After 60 min with no new high, the charge counts as done; a charge that truly fails now waits 7 days and shows one notice ([[MAINT-13]]) | +| `bf3ac79` | With no battery level, a maintenance charge still ends at its time limit and a drain waits at 0 W. The log no longer repeats a line every second ([[MAINT-14]]) | +| `db9e231` | One inverter: when maintenance charges a battery that is already full, the log no longer repeats "The battery refuses to give power" every second. In the test, 1 line in 16 min where the code before this fix wrote about 1,700 in 15 min; the power does not change ([[SAFETY-107]]) | +| `580c200` | The balancing hold after a charge now ends on time when the battery level is missing. Before, it waited until the level came back ([[MAINT-15]]) | + +### Known issues + +- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]). +- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]). +- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]). +- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]). +- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]). +- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]). +- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]). +- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]). +- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]). +- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]). +- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]). +- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]). +- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]). + +### If you are crossing from a version older than 0.10.0 + +`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the +withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and +`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the +bare options (`jq '.data.options'`) first. + +## 0.16.0 + +**A minor bump: changed behaviour, no option added or removed, and one default +and its meaning changed.** There are still 83 options. `maintenance_start_hour` +used to be read as UTC and its default was 10. It is now a local hour (in Home +Assistant's time zone) and its default is 19. The other changes below apply +with one inverter or two, unless a line says "Two inverters". + +**Before you update (MAINT-10).** A saved `maintenance_start_hour` is kept, and +it now means local time. If you set it in UTC to get an evening start (for +example 17 for 19:00 in summer), set it back to the local hour (19). A saved +10 now starts at 10:00, not 12:00 in summer. The new default 19 only reaches +installs that have no hour saved. + +Contents of this release, in merge order since 0.15.0 (`3ed3302`): + +| | | +|---|---| +| `7ef7347` | After a gap in the P1 meter, the peak guard counts the missed time as high use and keeps the 15-minute period near the limit; before, it was blind until the next period. In the test, a 5 min meter silence at one inverter ended the period at 3.3 kW, against 4.8 kW on 0.15.0 ([[SAFETY-90]]) | +| `1bf6b7f` | The maintenance start hour is now a local hour, and its default is 19 (was 10, read as UTC). The log says which time zone it used; with two inverters, both use that hour and still cycle one at a time ([[MAINT-10]]) | +| `b2f5573` | Two inverters: when one battery's link drops, the other takes over in small steps, and control no longer stalls. The battery that comes back rejoins within 2 s, without a false "battery refuses" ([[SAFETY-96]]) | +| `340c7ee` | Two inverters: when a drain ends (done, switched Off, timeout, lost link), the other battery covers the house right away, without jumping to 1.6 kW, and then moves in small steps. It no longer gives into a solar surplus ([[SAFETY-95]]) | + +### Known issues + +- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]). +- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]). +- A battery whose reading at rest is beyond about ±170 W is still not caught when it refuses, as in 0.14.0 ([[SAFETY-97]]). +- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]). +- A charge to 100 % can time out at 98 %. The add-on then counts the cycle as failed and drains the battery again the next day ([[MAINT-13]]). +- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]). +- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]). +- Two inverters: after a drain ends, the other battery can swing by about 800 W for about 5 s, because some readings are old ([[SAFETY-103]]). +- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]). +- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]). +- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]). + +### If you are crossing from a version older than 0.10.0 + +`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the +withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and +`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the +bare options (`jq '.data.options'`) first. + +## 0.15.0 + +**A minor bump: changed behaviour, and no option changes.** No option was +added, removed or renamed, and no default changed (83 options). Options that +start 0.14.0 also start 0.15.0. The changes below apply with one inverter and +with two. + +The add-on now learns and saves, per battery, what its power reading shows +at rest (`unit//idle_w`). It learns it after about two quiet minutes and +uses 0 W until then, as before. There is nothing to set. + +Contents of this release, in merge order since 0.14.0 (`02ed120`): + +| | | +|---|---| +| `cf705a9` | No more false "battery power reading stopped changing" errors at a new command after a rest, or at a change of phase. Each one paused control for a few seconds (several a day on a live site). A healthy battery that lags or turns around is no longer held as refusing for up to 15 min. With two inverters, a refusing battery whose reading at rest is about −100 W is now caught too. The rest value used is limited to ±75 W ([[SAFETY-94]], [[SAFETY-97]], also fixes [[SAFETY-58]]) | +| `3129dae` | CI tests against a newer `admin/goodwe` (CI only) ([[CI-14]]) | + +### Known issues + +- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]). +- After a gap in the P1 meter, the peak guard is blind for the rest of that quarter hour ([[SAFETY-90]]). +- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]). +- A battery whose reading at rest is beyond about ±170 W is still not caught when it refuses, as in 0.14.0 ([[SAFETY-97]]). +- Two inverters: if one battery's drain stops early (e.g. switched Off), the other battery can jump to about 1.6 kW at once, and the surplus goes to the grid ([[SAFETY-95]]). +- Two inverters: after one battery's link drops, control can stall until a safety stop ([[SAFETY-96]]). +- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]). +- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]). + +### If you are crossing from a version older than 0.10.0 + +`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the +withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and +`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the +bare options (`jq '.data.options'`) first. + +## 0.14.0 + +**A minor bump: new behaviour with two inverters, and no option changes.** +No option was added, removed or renamed, and no default changed (83 +options). Options that start 0.13.0 also start 0.14.0. With one inverter, +control is the same as in 0.13.0. + +⭐ **Two inverters are now supported** for the monthly maintenance (drain, +charge, hold) and the peak guard. To add a second inverter, follow +`docs/commissioning-b.md` in the `admin/goodwe` repo. Its Stage 5, where the +add-on takes control of it, needs 0.14.0. Read the known issues below first: +three of them are about two inverters. + +Contents of this release, in merge order since 0.13.0 (`8c438ab`): + +| | | +|---|---| +| `f3e409c` | Two inverters: a battery at or under its peak reserve gives last, after the others give their full power. It is freed again above reserve + 1 point (e.g. above 21 % for a 20 % reserve). At a quarter's edge the peak guard no longer drops out for a second ([[SAFETY-91]]) | +| `858f77c` | Two inverters: when a maintenance charge ends, for any reason, the other battery no longer starts charging from the grid; control hands over in small steps ([[SAFETY-93]]) | +| `e51e3e0` | CI tests against a newer `admin/goodwe` (CI only) ([[CI-13]]) | + +### Known issues + +- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]). +- A healthy battery that turns from charging to giving can be held at 0 W for up to 15 min ([[SAFETY-94]]). +- After a gap in the P1 meter, the peak guard is blind for the rest of that quarter hour ([[SAFETY-90]]). +- Two inverters: if one battery's drain stops early (e.g. switched Off), the other battery can jump to about 1.6 kW at once, and the surplus goes to the grid ([[SAFETY-95]]). +- Two inverters: after one battery's link drops, control can stall until a safety stop ([[SAFETY-96]]). +- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]). + +### If you are crossing from a version older than 0.10.0 + +`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the +withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and +`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the +bare options (`jq '.data.options'`) first. + +## 0.13.0 + +**A minor bump: new behaviour, a new add-on page (Home, Settings, Batteries, +Setup), and six new options.** No option was removed or renamed, and no +default of an existing option changed (77 options -> 83). The only new +start-up checks are on the new options, so options that start 0.12.0 also +start 0.13.0. + +New options, and what they do if you leave them alone: + +| option | default | left alone | +|---|---|---| +| `peak_margin_w` | `50` | the peak guard aims 50 W below your peak limit, or below a higher peak already reached this month; `0` is the old aim ([[SAFETY-79]]) | +| `battery_modes_enabled` | `false` | no battery modes, control as before ([[OPT-11]]) | +| `pv_forecast_entity`, `pv_forecast_tomorrow_entity` | empty | no solar forecast is used ([[OPT-11]]) | +| `solar_trust_pct` | `70` | only used with a solar forecast ([[OPT-11]]) | +| `pv_invert_entities` | `[]` | PV readings as before ([[TEL-14]]) | + +⚠️ **Turn on Watchdog** on the add-on's Info tab. It is off by default. If +the control loop crashes, the add-on now sets the batteries to 0 W and stops. +Home Assistant only starts it again when Watchdog is on ([[SAFETY-61]], +[[DOC-5]]). + +⚠️ **The peak guard now starts a little earlier.** It aims 50 W below your +peak limit, so the batteries give a bit more at peak times. Set +`peak_margin_w: 0` for the old aim ([[SAFETY-79]]). + +Contents of this release, in merge order since 0.12.0 (`a45ed06`): + +| | | +|---|---| +| `fa3d237` | Docs name the right P1 age entity and a battery list that starts ([[DOC-1]]) | +| `6e65272` | If the control loop crashes, the batteries go to 0 W and the add-on stops ([[SAFETY-61]]) | +| `3e38baf` | The check tools refuse a file they cannot read, instead of saying all clear (tools only) ([[SAFETY-60]], [[SAFETY-62]]) | +| `43b2740` | A missing value in a check is a named failure (tests only) ([[TEST-26]]) | +| `20260e8` | Docs: turn on Watchdog ([[DOC-5]]) | +| `16c796f` | New option `pv_invert_entities`: flip the sign of a PV sensor that reads backwards ([[TEL-14]]) | +| `6a8a5ff` | CI tests against the real inverter simulator (CI only) ([[CI-12]]) | +| `82f032e` | CI pin checks report failures properly (CI only) ([[TOOL-9]]) | +| `fe13251` | CI pin update (CI only) | +| `397dd15` | Alarm notifications that are still active come back after a Home Assistant restart ([[SAFETY-34]]) | +| `59415ea` | CI pin update (CI only) | +| `2503de1` | "Battery is not keeping up" is a warning with its own notification, not a "bug" alarm; its figures are correct ([[SAFETY-40]]) | +| `48ef06a` | Shorter, plainer notifications ([[UX-08]]) | +| `5c151a3` | CI pin update (CI only) | +| `d57767f` | Shorter, plainer status rows and banners ([[UX-08]]) | +| `e1d0970` | Details link works on current Home Assistant; plainer words ([[UX-09]]) | +| `a02cd4b` | Shorter refusals, errors and option texts ([[UX-08]]) | +| `32b9e56` | Plainer log lines and docs ([[UX-08]]) | +| `125b070` | CI pin update (CI only) | +| `2b14b88` | Last plain-language fixes in logs and the status page ([[UX-10]]) | +| `c4032eb` | CI pin update (CI only) | +| `d1f7e33` | Safety log lines: one plain sentence and a code ([[UX-11]]) | +| `652097a` | A battery that refuses power (e.g. empty) is asked for 0 W, and tried at 200 W for 34 s every 15 min ([[SAFETY-52]]) | +| `afd6af0` | Groundwork for the new settings pages ([[UX-13]]) | +| `a36dca6` | New page with a Home screen ([[UX-13]]) | +| `28a49cc` | With two or more inverters, a battery that refuses power is held on its own; the others take over ([[SAFETY-73]]) | +| `515c11b` | New Installer settings view ([[UX-13]]) | +| `4de9675` | New setup wizard for installers ([[UX-13]]) | +| `8c34647` | A new install with no inverter opens the setup wizard instead of stopping ([[UX-15]]) | +| `a5dddad` | Dutch text and a time-period editor ([[UX-13]]) | +| `ed739d4` | Battery modes (off by default): per-battery limits, on/off, solar order ([[OPT-11]]) | +| `7995e4d` | Simple settings view and a Batteries screen ([[UX-13]]) | +| `7337b79` | Battery modes in Home Assistant, when modes are on ([[OPT-11]]) | +| `7a00f12` | New option `peak_margin_w`: the peak guard keeps a margin below the limit ([[SAFETY-79]]) | +| `5a4cdc2` | Battery modes: charge in cheap hours, hold for expensive hours; selling is off for now ([[OPT-11]]) | +| `4f25b24` | No false "Load too high" alarms at quarter edges or with a noisy load (10 s hold) ([[SAFETY-81]]) | +| `35cdffe` | Maintenance drain with two batteries no longer stalls ([[SAFETY-78]]) | +| `1145a93` | Battery modes: a solar forecast decides how much to buy from the grid ([[OPT-11]]) | +| `9d5b7d1` | With one battery, the peak guard wins over the maintenance drain ([[SAFETY-83]]) | +| `4ad8298` | Maintenance charge with two batteries no longer stalls; it gives way to the peak guard ([[SAFETY-84]]) | +| `b8ddfd7` | The peak guard wins over the maintenance hold; the hold ends on time, with no top-up ([[SAFETY-86]]) | +| `9358781` | With two or more batteries, the peak guard uses every battery that can still give; peak alarms no longer say control stopped ([[SAFETY-87]]) | +| `61072ca` | No false "Battery is not keeping up" during maintenance; real shortfalls still warn; the status row counts each episode once ([[SAFETY-85]]) | + +### Known issues + +- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]). +- Two inverters: at the end of a maintenance charge one battery can charge from the grid while the house uses a lot. Do not add a second inverter before the next release ([[SAFETY-93]]). +- A healthy battery that turns from charging to giving can be held at 0 W for up to 15 min ([[SAFETY-94]]). +- After a gap in the P1 meter, the peak guard is blind for the rest of that quarter hour ([[SAFETY-90]]). +- Two batteries: one at its peak reserve can be used before a draining battery has used its room ([[SAFETY-91]]). + +### If you are crossing from a version older than 0.10.0 + +`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the +withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and +`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the +bare options (`jq '.data.options'`) first. + ## 0.12.0 **A MINOR BUMP FOR ONE NEW OPTION: `pv_entities` ([[TEL-12]]), for sites with diff --git a/goodwe_controller/DECISIONS/MAINT-05-per-pack-maintenance.md b/goodwe_controller/DECISIONS/MAINT-05-per-pack-maintenance.md index 546af20..305db50 100644 --- a/goodwe_controller/DECISIONS/MAINT-05-per-pack-maintenance.md +++ b/goodwe_controller/DECISIONS/MAINT-05-per-pack-maintenance.md @@ -285,32 +285,11 @@ cross-inverter `lo > hi` contradiction at `arbiter.py:114`. The only line this card changed there is **which** roster entry the claim is filed on: the one whose state machine produced the result, rather than `inverters[0]`. -## 6. FOUND AND NOT FIXED: the site loop still contradicts a drain at N>=2 +## 6. FIXED BY SAFETY-78: the site loop contradicted a drain at N>=2 -Measured while wiring this card, and **pre-existing on `release/1.0` at -`2d025ee`** — the same drain filed on `inverters[0]` produces the identical -refusal with the two unit names swapped. - -The maintenance SET **pins** its pack. The loop's target is a **site** claim. -When the site target is *below* the pin — a 2000 W drain while the house imports -less than that, which is most of a night — the only split that sums to the site -target asks the sibling to **charge** while the maintained pack **discharges**, -and `allocator.plan()` refuses it by name as circulating power. The refusal is -correct and fails safe: the whole site goes to 0 W with an `ARBITER: REFUSED` -line in the action log. - -**The consequence an operator would see is a misdiagnosis.** The pack is held at -0 W while its state machine believes it is draining, so after 12 h the DRAIN -timeout fires and reports *"drain stuck"* — §12's words for a battery that is not -following commands. The battery is fine; the site refused to allocate. - -**Why it is not fixed here.** The fix is to change what the site loop's target -*means* while a unit is pinned — regulate the meter using only the unpinned -packs — which is loop and allocator territory and changes the integrator's -subject. MAINT-05 moves which pack is pinned and nothing else about it. It is -pinned as a running check (`test_maintenance.py` part 12) rather than left as a -comment, so it cannot be forgotten or quietly tuned away, and it wants a card of -its own. +Found here and pre-existing: below the pin the other pack had to charge, the +allocator refused it and the site sat at 0 W. Fixed by a site `drain` floor; +see [[SAFETY-78]] (`DECISIONS/SAFETY-78-drain-at-two.md`). ## 7. FOUND AND NOT FIXED: `migrate_unit_keys()` has the same hole, and it is a LATCH diff --git a/goodwe_controller/DECISIONS/MAINT-10-local-start-hour.md b/goodwe_controller/DECISIONS/MAINT-10-local-start-hour.md new file mode 100644 index 0000000..f0b0821 --- /dev/null +++ b/goodwe_controller/DECISIONS/MAINT-10-local-start-hour.md @@ -0,0 +1,67 @@ +# MAINT-10 — the maintenance start hour is local time, default 19:00 + +Owner decision 2026-09-29 (option A). `maintenance_start_hour` was compared +with the UTC hour, so the shipped 10 started at 12:00 in a Belgian summer and +11:00 in winter. It is now read on the site's local clock, and the default moves +to 19 so the drain runs into the evening and night rather than the solar day. + +Evening drains meet higher house loads; SAFETY-83 (merged first) lets the +peak guard outrank the drain. + +## 1. Which zone + +Home Assistant's `time_zone` (`GET /core/api/config`, 3 s budget) at startup, +else the container's `TZ`, else UTC. One log line says which (WARNING only for +UTC). Resolved once per start: a zone change in HA takes effect on the next +add-on restart. HA can still be starting when the add-on starts (502 from the +proxy, 2026-10-05); then `TZ` holds. HA pushes its zone to the Supervisor +(hassio `push_config`), which stores it and sets it as the add-on's `TZ` +(`docker/app.py`, Supervisor 2026.08.0), so the hour stays right. Only a `TZ` of +UTC there logs a WARNING asking for a restart. No later re-resolve: the fallback +already is HA's zone, so the rare UTC case gets a warning rather than more code. +The add-on image has tzdata (checked in `amd64-base:3.20`); the CI image does +not, so the tests grade DST on `tariff._EUTimezone` too. + +## 2. DST + +`maintenance.in_start_slot()` works from UTC instants only, so it does not +depend on how a tzinfo resolves a missing or doubled wall time: + +- normally the slot is the local hour `start_hour`; +- clocks forward and the hour does not exist: the first hour after the jump; +- clocks back and the hour happens twice: the first pass only. Without this an + aborted start (90 min cooldown) would start again in the 120 min repeated hour. + +Every day has exactly one 60 min slot for every hour 0-23: checked against +zoneinfo for Europe/Brussels, America/New_York, America/Santiago (midnight +transitions), Asia/Kathmandu (+05:45) and Australia/Lord_Howe (30 min DST) +over all of 2026. + +## 3. Existing installs + +The Supervisor merges the shipped defaults under the STORED options, and saving +the settings stores every key (the reference install's blob has all 76). So +almost every existing install keeps its stored 10, which now means 10:00 local +instead of 12:00 (summer) or 11:00 (winter). Only a blob without the key gets 19. +The add-on does not rewrite a stored option: a silent migration would override +an operator's choice with no way to tell it apart from the old default. A site +that set an earlier hour to make up for UTC (17 for 19:00 summer) must set it +back to 19 after the upgrade, or it starts two hours early. DOCS and the release +note (drafted on the PR, for the version bump) say this. +`tools/supervisor_shape_probe.py` §I measures this on Supervisor 2026.08.0. + +## 4. Two inverters + +`Controller` hands the same zone to every pack's `Maintenance`, so every pack's +`wants_start()` reads the local hour. `arbitrate()` is unchanged: one pack +cycles at a time, and the one that lost the slot starts in the first start +slot after the other is done (a later day: a cycle outlasts its start hour). On the day the clocks go back, a pack freed during the second pass of +a repeated hour does not start there. `test_maint10.py` part 10. + +## 5. What does not change + +Only the start decision (`Maintenance.wants_start`) and the Home screen's shown +start time read the zone. `test_maint10.py` parts 9 and 10 run the real +Controller at N=1 and N=2 with start hour 11 UTC against 13 Europe/Brussels (the +same instant): every command, claim, reason and log line is identical. A +`Maintenance` built without a zone reads UTC, as before. diff --git a/goodwe_controller/DECISIONS/MAINT-13-full-pack-is-complete.md b/goodwe_controller/DECISIONS/MAINT-13-full-pack-is-complete.md new file mode 100644 index 0000000..af3aa27 --- /dev/null +++ b/goodwe_controller/DECISIONS/MAINT-13-full-pack-is-complete.md @@ -0,0 +1,109 @@ +# MAINT-13 - a full pack that stops at 98-99 % is not a failed charge + +**Status: for review.** Tests: `test_maint13.py`. No option added; every number +is a constant in `app/maintenance.py`. + +**What happened (live, B).** The drain ended 2026-10-04 18:24 UTC and the charge +began. It ran in pieces behind the price gate, reached 99 % at 2026-10-06 04:00, +fell back to 98 % at 04:15 while the loop still asked about -1370 W, and never +reached `maintenance_soc_target` 100. At 06:24 (2160 min = 36 h of wall clock +after the charge began) it aborted "charge stuck at 98 %". `last_completed` was +not set, so B was still due and would have drained again from 98 % to 11 % at the +next start hour, with no notice. Three faults: a pack may never report 100 % +while the BMS balances; a failed cycle came back the next day; and the 36 h +counted hours the price gate kept the pack waiting. + +## 1. The charge is complete when + +1. SoC >= target (as before), or +2. SoC >= target - 3 (`CHARGE_NEAR_FULL_PCT`) and the SoC has not beaten its + best for 60 min (`CHARGE_STALL_MIN`) of allowed charging, or +3. the charging time (`CHARGE_TIMEOUT_MIN`, 2160 min) runs out at SoC >= target - 3. + +Then the usual HOLD follows and `last_completed` is set after it. Each of 2 and +3 logs one plain line ("the battery takes no more charge at 98 %, so the charge +counts as done", "charge time is up at 98 %, close enough to full ..."). + +**Why "SoC has not beaten its best", and not asked/delivered watts.** SoC is the +one signal the live record gives us (99 at 04:00, 98 at 04:15); the asked power +was -1370 W but no delivered figure was kept, so a watt threshold (X, Y) would be +invented. SoC also covers the case where power flows but nothing is stored. +**Why 3 points.** The card's band; at target 100 it is 97, and the live pack sat +at 98-99. **Why 60 min.** A judgement, not a measurement: we have no per-minute record of +the pack taking charge near full. The one live fact is 2 h 24 min (04:00 to +06:24) without a new high, so 60 min is well inside it and long enough that a +pack still creeping up (one new high within the hour) keeps resetting the wait. +On the live run the rule fires after 60 gate-open minutes past the 99 %: 05:00 +UTC at the earliest, before 06:24 if the gate was open. If the pack turns out to +step up more slowly than that, raise the constant. Falling back by one point +does not reset the wait; only a new best does. + +## 2. A failed cycle backs off 7 days + +A drain stuck 12 h or a charge that times out below target - 3 is a failure +(`FAIL_BACKOFF_DAYS` = 7). The machine goes IDLE as before, `last_failed` is +stored, and `wants_start()` is false until 7 days later. The operator's forced +start ignores it; a cycle that completes after the failure ends it. An operator +abort, an interlock stop and a switched-off pack are not failures. + +**Why 7 days, not the 28 day interval.** A judgement: a failure may have a +short cause (several dull days with no cheap hours, a pack switched off), and +waiting the full interval leaves the coulomb counter uncalibrated for a month +more. A week is long enough that a bad pack costs one drain a week, with one +notice a week, not one a day. **Cost.** A truly faulty pack still drains once a week until someone looks; +the notice says so each time. + +**Notice, once.** The tick that fails carries `failed`; the Controller sends one +persistent notification "Battery cycle did not finish" (EN, with the Dutch +below it; per pack at N=2). It is not repeated, and Home Assistant restarts +wipe it, so the status page shows a "Maintenance paused" row with the date for +as long as the wait lasts. The operator may also see the log lines. + +## 3. The timeout counts only allowed time + +`Controller._charge_gate_open()` says whether the pack may charge now. It is +true when no price gate is set up (no price entities, no fixed tariff: nothing +holds the charge back, and `cheap` is always false there), in a cheap window, or +while the pack is really taking charge (battery power at or below -50 W, which is +sun outside cheap hours). It is false only when a gate exists, is shut, and the +pack is not charging; a pack asked for charge that delivers nothing then does not +count, which is the case the clock cannot judge. A site with a price provider +(TARIFF-11) and no fixed tariff counts as having no gate: the provider's plan +reaches only the Tariff row, never `cheap`, so no cheap window ever opens a grid +top-up there and only the sun charges the pack, as on a site with no tariff. Its +time counts in full. When a provider plan starts to drive `cheap`, +`_price_gated` must become true for it. Whether a fixed tariff is set up is +worked out once an hour, not on every tick. Only open ticks add to `open_min`; one tick +adds at most 5 min, so a gap (add-on down) adds nothing. The stall wait pauses +while the gate is closed. The accounting is written to the state file at most +every 5 min (`charge_track`); a restart loses at most that. A state file from +before this change, or with no record for the current charge, starts from the +wall-clock minutes so far, which is the old behaviour. + +**Backstop.** A gated site whose gate never opens (no cheap hours, no sun) would never time +out, so a charge also ends after 7 days of wall clock (`CHARGE_WALL_TIMEOUT_MIN`): +complete at >= target - 3, a failure below. + +**MAINT-12.** This is the smallest change that makes the timeout honest: one +boolean in, one counter. MAINT-12's block plan can replace the boolean (the +minutes it plans are the allowed ones) without touching the rest. Two things it +must keep: the back-off is in `wants_start()`, so any new entry point (such as +`price_forecast.begin_phase`, which is not wired today) must go through it or +check `backoff_until()`; and `gate_open` must stay "the pack is allowed to +charge now". The peak guard pausing the charge shaping is not seen as a closed +gate: that time still counts. + +## 4. What does not change, and what this does not cover + +- The drain, the HOLD, the held-battery rules and `arbitrate()` are unchanged. A + backed-off pack does not want a start, so at N=2 the other pack may take the + slot. One pack at a time at N=1 and N=2. +- `last_failed` is a maintenance key and moves with the others when a second + inverter is added (`MAINT_UNIT_KEYS`). The charge record is not moved; it + restarts from the wall-clock minutes. +- It does not find out why a pack stopped. A pack that stops at 96 % and never + gets near is still a failure. +- The Dutch text is only in the notification. The status row and the log lines + are English, like the rest of the status page. +- It does not change the target: a site that wants a different stop point sets + `maintenance_soc_target`. diff --git a/goodwe_controller/DECISIONS/MAINT-14-no-soc-in-maintenance.md b/goodwe_controller/DECISIONS/MAINT-14-no-soc-in-maintenance.md new file mode 100644 index 0000000..71cd057 --- /dev/null +++ b/goodwe_controller/DECISIONS/MAINT-14-no-soc-in-maintenance.md @@ -0,0 +1,68 @@ +# MAINT-14 - no SoC reading in a maintenance cycle + +**Status: for review.** Tests: `test_maint14.py`. No option added; the one new +number is a constant in `app/maintenance.py`. + +**The fault.** `Maintenance.tick()` returned early when the SoC was `None`, +before any timeout check. (1) A charge on a pack whose SoC reading was lost +never ended: no timeout, no back-off, no notice, and the 7-day backstop of +MAINT-13 sat behind the same return. (2) A drain kept commanding `discharge_w` +with an unknown level, which breaks the TEL-08 rule that an unknown level never +allows discharge. + +## Rules + +1. **Charge, no SoC.** The charge timeout (`CHARGE_TIMEOUT_MIN`, gate-open + minutes) and the 7-day wall backstop still run. When one fires: if the last + SoC seen was >= target - 3 the charge counts as done (the MAINT-13 "close + enough to full" rule, HOLD follows); otherwise it fails: MAINT-13 back-off + and its one notification. If no SoC was ever seen, it fails. +2. **Drain, no SoC.** DRAIN holds at 0 W and owns the setpoint, exactly as HOLD + does. The 12 h drain timeout still runs and fails as before. +3. **SoC comes back.** Nothing is recorded; the drain continues at the drain + rate, the charge carries on, and the stall wait of MAINT-13 restarts from + what it was: it paused while the reading was missing, so a gap never counts + as "no more charge". +4. **HOLD** is unchanged (0 W). +5. **Log.** One line when a drain starts holding for this reason ("the battery + level is unknown, so the drain holds at 0 W until it is back"), and one line + when the level has been missing for `NOSOC_NOTE_MIN` = 15 min. Both are said + once per episode; the old line that repeated every tick is gone. + +## Why + +- **"Last seen", not "best seen".** A pack that was at 99 and then dropped to 50 + before the reading went is not close to full. Only the last reading counts. + It is saved with the charge record (`charge_track.last`), and saved at once + when the reading first goes missing, so a restart mid-gap still knows it + (within the usual 5 min save interval otherwise). +- **Failure when nothing was seen.** With no level we cannot call a pack full. + The failure costs a 7-day wait and one notice; calling it done costs a + missed balance and a month-long false "complete". The first is the honest one. +- **Timeouts count the same minutes as with a reading.** Gate-open charging + minutes only; the wall backstop for a gate that never opens. +- **15 min.** A judgement. A poll glitch of a few minutes should not write a + line; a reading lost for a quarter of an hour is worth one. Raise it if the + action log shows noise. +- **0 W, not "stop the phase".** Aborting a drain on a short gap would throw + away the part already drained. Holding at 0 W loses nothing and the 12 h + timeout is the limit. + +## Cost + +- A drain that loses its reading stops draining, and the 12 h clock keeps + running. A gap of many hours can therefore fail a drain that was working. The + back-off then applies (7 days). +- A charge whose reading is lost early (last SoC below target - 3) fails at the + timeout, even if the pack was in fact filling. The same 7-day wait applies. + +## What this does not cover + +- **HOLD with no SoC**: closed by MAINT-15 (`MAINT-15-hold-ends-without-soc.md`). +- It does not tell the operator why the reading is missing (sensor, register, + link). It does not reuse the stale-input gate; it is the cycle's own rule. +- The charge itself is still driven by the control loop, which has its own + handling of a missing SoC (TEL-08 for the loop is a separate card). +- A restart loses at most 5 min of accounting, as in MAINT-13. The "unknown for + long" line may be said again after a restart: the episode start is in memory. +- Text added is two log lines, English only, like the other log lines. diff --git a/goodwe_controller/DECISIONS/MAINT-15-hold-ends-without-soc.md b/goodwe_controller/DECISIONS/MAINT-15-hold-ends-without-soc.md new file mode 100644 index 0000000..3da9bc0 --- /dev/null +++ b/goodwe_controller/DECISIONS/MAINT-15-hold-ends-without-soc.md @@ -0,0 +1,47 @@ +# MAINT-15 - the balancing hold ends on time with no SoC reading + +**Status: for review.** Tests: `test_maint15.py`. No option added. + +**The fault.** `step()` sent every tick with no SoC to `_no_soc()`, and for +HOLD that returned 0 W without the time check. A hold that lost its reading +never ended: `last_completed` was never written and the next due date never +moved. Harmless for power (0 W), but the cycle was stuck. This was left open +in MAINT-14. + +## Rule + +1. **HOLD ends at `hold_min` whether or not there is a reading.** One helper, + `Maintenance._hold()`, holds the time check and is used by both paths. When + the time is up it writes `last_completed`, goes IDLE and logs the same + "cycle COMPLETE" line. Before the time is up it commands 0 W as before. +2. **Text.** The 15-min "no battery level" line in HOLD now reads "The hold + still ends at its normal time." Still once per episode. + +## Why + +HOLD is reached only after the charge counted as done. Every entry into HOLD +is in `step()` or `_no_soc()` and follows a charge that reached the target, +stalled near full (MAINT-13) or timed out near full (MAINT-13/14). Nothing else +sets the phase to HOLD (`_enter(HOLD` has four call sites, all after a done +charge). The hold is a wait for the BMS to balance; it never needed the level. +So a hold that ends with no reading is a completed cycle. The "cycle COMPLETE" +line is true without a reading: it names minutes of hold, not a level. + +## Cost + +- If the level was lost during the hold, the cycle is recorded as complete + without anyone seeing the final level. The charge had counted as done, so this + is the same trust MAINT-14 already gives a charge that ends with no reading. + +## What this does not cover + +- It does not check that the pack is still full at the end of the hold. +- It does not say why the reading was lost. +- With a reading, nothing changes. `test_maint15.py` section 7 compares the new + HOLD tick by tick with a frozen copy of the release/1.0 HOLD branch (phase, + setpoint, owner, events, `last_completed`) at four `hold_min` values. A wider + trace of the whole cycle (800 seeded runs against db9e231) was run outside the + repo; its script and log are kept under `scratch/MAINT-15/` of the dev + workspace, not in this repository. +- A state file written by an older version with `phase: hold` loads and ends on + time; the file is not changed in format. diff --git a/goodwe_controller/DECISIONS/OPT-11-battery-modes.md b/goodwe_controller/DECISIONS/OPT-11-battery-modes.md index 6318b35..b453090 100644 --- a/goodwe_controller/DECISIONS/OPT-11-battery-modes.md +++ b/goodwe_controller/DECISIONS/OPT-11-battery-modes.md @@ -75,7 +75,8 @@ P1 is its section 6 P1, and the pull request lists what was measured. P2's 19.33; Solar first + Cheap hours only 19.57. - Not done in P3: the forecast (P4), and a headroom too short for the run moving the rest to the next cheap periods. -- OPT-15 (TEST-23 findings on 5a4cdc2, `test_price_run_fixes.py`): +- OPT-15 (TEST-23 findings on 5a4cdc2, merged up to e3e4ba6, + `test_price_run_fixes.py`): - F1, a charge stopped at a quarter edge and latched for the quarter. The P1 averager opens a quarter only when a sample arrives, and the HA source builds one 0.35 s after the reading, so the cycle on the boundary still @@ -84,6 +85,15 @@ P1 is its section 6 P1, and the pull request lists what was measured. projects the quarter `now` is in (`PeakTracker.project(roll=True)`, crediting the held sample as the averager will), and only a measured stop latches. Modes off still reads the old block (identity). + Since SAFETY-91, `next_quarter()` does the same for the guard at two + inverters (no stop there without the roll, same banked quarters); the + roll is what covers one inverter, where the old projection stops the + charge at every edge. With modes on at one inverter the roll also removes + the 1 s 0 W command at each quarter edge that SAFETY-81's sim showed, so + its modes-on digests are re-pinned (modes off equal to e3e4ba6). A + rolled projection is not graded by SAFETY-81's alarm (the old edge tick + read "no seconds left"), so a standing overload is still notified once + per quarter. - F2, -1120 W export at a charge end against -257 W modelled. The replay acted every 5 s on readings of that instant, and by then the lagged battery had nearly arrived, so it never acted on a reading that still @@ -93,21 +103,81 @@ P1 is its section 6 P1, and the pull request lists what was measured. reaches the target (or `EDGE_HOLD_S`, 18 s; to 36 s while the battery still moves). The replay gained `Lag` (dead time, reading age, the P1 debounce, 1 s cycles). Charge end, 800 W house: -10 to -51 W over eight - lag variants; without the hold -1370 W (meter 2 s). + lag variants; without the hold -1370 W (meter 2 s). On e3e4ba6 the same + numbers hold. The hold gives the loop the target as its grid, so it + yields to SAFETY-95's drain tail, and any other claim that moves the + command ends it. - F3, a charge refusal in a cheap run was never probed: the SAFETY-73 pin stands only while the site asks that way, and nothing asked to charge. Probing was chosen over changing the card: the probe is 200 W for 34 s each 15 min under the same peak gates, and it wins back the run. In its probe window the refusing battery is a charger, pinned at 200 W by its - own claim, and the others rest. At one inverter SAFETY-52 already ends a - charge refusal as soon as the house asks for power. - - Not fixed, same on 5a4cdc2: with lagged readings a headroom-sized quarter + own claim, and the others rest. A monthly drain (SAFETY-78) still comes + first, and with a day plan a probing Leftover solar battery goes only to + its planned level. At one inverter SAFETY-52 already ends a charge + refusal as soon as the house asks for power. + - Not fixed, same on e3e4ba6: with lagged readings a headroom-sized quarter banks up to 1.4 W over the aim (48.6 W under the cap), and a battery reading older than the meter can make a charge start look like a house over the aim, a real guard stop. - Review: an unmeasured stop that never latched let a headroom missing every other cycle flip the charge 1799 times in 30 min, so the second one - in a quarter latches (at most 4 changes a quarter). House steps of +3 and - +5 kW during the hold, N=1 and 2: the peak guard's floor ends the hold at - once, and no banked quarter differs from the loop without the hold by - more than +33 W (1342 W, far under the aim). + in a quarter latches (at most 4 changes a quarter). A house that rises + 1, 2 or 3 kW as the charge ends, from 30 s into the quarter to 15 s + before its end (two inverters, lagged readings): the hold adds at most + +33 W to that quarter against the loop without it, and the most any + quarter banks is 3431 W with the charge sized to the aim (3450 W; the cap + is 3500 W). The import during the hold is the rise, below the charge's + own, and a rise past that is the peak floor's, which ends the hold. + +## P4: the solar forecast and the day plan (2026-09-29) +- Input: a Home Assistant forecast sensor (`pv_forecast_entity`, kWh left + today; `pv_forecast_tomorrow_entity` optional), read by `app/solarfc.py`. + Supported shapes: Forecast.Solar (state only), Solcast (`detailedForecast`) + and Open-Meteo Solar Forecast (`wh_period`). The unit is read, not guessed; + older than 3 h is stale (one missed hourly poll must not flag it). +- Trust: `solar_trust_pct`, default 70 (owner answer 5). +- House use: nothing the add-on recorded fitted. `tariff.db` holds grid + import per quarter, which the batteries shape. So `dayplan.HouseProfile` + keeps grid + battery per local hour for 7 days in the store + (`house_profile`), median per hour. It is net of the solar the house used, + so the solar sum never takes it below the night median. Solar readings are + not used (TEL-11). The first plan needs one day of it. +- Verdict (`dayplan.plan()`): trust x sum max(0, forecast - house) x 0.95 + until the expensive run the cheap run is for. Solar first takes it first. + - Leftover solar may then charge from the grid up to min(ceiling, floor + + the house's use in the expensive runs / 0.95), less its sun share: only + the shortfall. + - Cheap hours only ignores the plan and fills to the ceiling (design + section 1; review of #156, see the replay below). + - Solar first never charges from the grid (owner, 2026-09-29). +- The level is kept unless it moves by min(1 kWh, 10 %). With no plan + (no, stale or failed forecast, no house profile, no capacity) every mode + is P3 option A exactly: `test_modes_p4.py` part 1 pins 12 digests from + 5a4cdc2 (modes on, with and without an unavailable forecast). +- The plan is made with the runs (every 15 min, or at once when the forecast + comes or goes). A lost forecast drops the levels at the next cycle. +- Not done: the design's scaling by measured solar from 10:00 (it needs the + solar readings TEL-11 keeps out), the HA `goodwe__plan` sensor, and + FCST-01. +- Replay (`tools/opt11p4_replay.py`, 16 recorded days, Solar first + Leftover + solar, net EUR, lower is better; site solar rebuilt from the recording, + forecast errors from Elia's day-ahead Belgian solar forecast): + - year-earlier hourly prices: P2 19.33, P3 option A 19.44, old one-period + trade 18.14, P4 19.15 (perfect forecast 19.14, 1.5 x too high 19.16, + missing 19.44); + - the days' real 2026 quarter-hourly prices (held out): P2 32.26, option A + 32.01, old trade 31.40, P4 29.19 (perfect 29.21, too high 29.28, missing + 32.01). + - Solar first + Cheap hours only, skipping its sun share (the first + build): 19.57 (same as P3) and 29.72 against P3's 29.41. The shorter, + moved runs left B lower, so later midday top-ups rested A while the sun + shone. Cheap hours now ignores the plan, which is P3 exactly. + - A forecast of no sun at all gives 19.14 and 29.33. So nearly all of the + gain is Leftover solar buying the evening's shortfall in the cheap run; + the forecast itself was worth 0.14 EUR in 16 September days. + - Bias: the site's solar is rebuilt from the same net use the replay + plays, and "recorded" keeps its hourly shape and adds only the national + daily error. Both favour P4; "national" (no house leakage) gives 29.25. + No threshold was tuned to these days. The 2026-09-24 PV recording could not + be replayed: it is only in the live Home Assistant. diff --git a/goodwe_controller/DECISIONS/OPT-17-several-forecast-entities.md b/goodwe_controller/DECISIONS/OPT-17-several-forecast-entities.md new file mode 100644 index 0000000..2512f79 --- /dev/null +++ b/goodwe_controller/DECISIONS/OPT-17-several-forecast-entities.md @@ -0,0 +1,79 @@ +# OPT-17 - the solar forecast from several entities, summed + +**Status: for review.** Tests: `test_opt17.py`. Two options added. + +A site whose forecast comes as several Home Assistant sensors (one per +inverter or roof side) had to build a template sensor to sum them. Now each +day can list them. + +## Options + +- `pv_forecast_entities` and `pv_forecast_tomorrow_entities`: lists of + sensors, default `[]`, the same shape as `pv_entities`. +- `pv_forecast_entity` and `pv_forecast_tomorrow_entity` are unchanged. An + install that uses them needs no edit. +- **Why new keys, not "string or list" in the old ones.** Measured on + Supervisor 2026.08.0 by `tools/supervisor_shape_probe.py` §J, as TEL-12's + §G did for `pv_entity`. Declared as a list, each forecast key refuses the + string an install already stores (`Invalid list for option + 'pv_forecast_entity'`), and a `[]` default does not rescue it. The one + spelling that takes both, `match(.*)?`, also takes the integer 417 and + shows a plain text box, so a second sensor cannot be added in the UI. +- §J also shows: a 0.17.0 blob validates unchanged with the lists at `[]`; + the lists render as repeatable fields; a downgrade drops them and keeps + the single keys. +- `[]` in `options:` is load-bearing, as for `pv_entities`. + +## Rules + +1. **One key per day.** Setting the single key and the list for the same day + is refused at start, with one message: keep the list, clear the single one. + The list can hold that entity too. (TEL-12 lets `pv_entities` replace + `pv_entity` with a caution row. Here the brief asked for a refusal: a + doubled or dropped forecast changes what a battery buys, and no install + has both keys yet, so the refusal breaks nothing.) +2. **Each member in its own unit.** Every member is read as one forecast + sensor is today (`solarfc._day()`): Wh, kWh or MWh, never guessed. Then the + day's kWh are added up. A repeated id counts once. +3. **One member not usable means the day is not usable.** Missing, + unavailable, unknown, stale, not an energy unit, or a bad number: that + day's sum is not used, exactly as when the single sensor reads that way. + No today means no forecast (P3 option A). No tomorrow means today only. +4. **Hourly split.** If every member has a split, the splits are joined + (overlapping periods add up). If any member has none, the sum is spread + over the bell, as for a state-only sensor. +5. **Time.** The forecast is as old as its oldest member. +6. **One read.** All members of both days are read in one gather, inside the + gather that reads the inverters, so a slow sensor does not delay control. +7. **Shown and logged once.** With two or more members, each one not usable + is named on the status page by its place in the list (STYLE.md: no entity + ids on the page), as a caution row titled "Solar forecast" on Home. The + action log names the entity id, once when the set of failing members + changes. +8. **One member is the single key.** A one-member list reads, plans, shows + and logs exactly as the single key with that entity. + +## Why + +- A partial sum is a smaller number that looks real. Two of three inverters' + forecasts read as a duller day, and Leftover solar would then buy grid + energy the sun was going to give. TEL-12 took the same rule for the same + reason. No forecast is a known state with a tested fallback. +- The bell for a mixed list: joining one member's hours with another's total + has no honest shape, and the bell is the existing state-only rule. + +## Cost + +- One flaky member costs the whole day's forecast. A site with three + sensors loses the plan more often than one with a template sum that hides + gaps. +- Two more options (85). + +## What this does not cover + +- No weighting or calibration per member; FCST-01 is still the own forecast. +- Members on different integrations with different refresh times: each must + be fresh (3 h) on its own; there is no wait for a lagging one. +- The day plan itself, the trust and the house profile are unchanged. +- The Batteries screen still says only "no solar forecast now". The member + is named on the status page. diff --git a/goodwe_controller/DECISIONS/SAFETY-103-old-readings-after-a-drain.md b/goodwe_controller/DECISIONS/SAFETY-103-old-readings-after-a-drain.md new file mode 100644 index 0000000..eb271fc --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-103-old-readings-after-a-drain.md @@ -0,0 +1,80 @@ +# SAFETY-103 - no swing on old battery readings after a drain ends + +**Status: implemented 2026-10-08, for review.** Tests: `test_safety103.py`. + +**The defect** (SAFETY-95 rig test, check 5, F1). While a battery settles +from its ended drain, SAFETY-95 steers on the house's point: grid + every +battery's reading - aim. The meter is 1 s old; a battery register can be +10 s old (the SAFETY-90 register model). At E+13 A's reading said 1238 W +(6.8 s old) while A was at 616 W, so the house looked ~620 W bigger: B went +860 -> 1650 -> 870 W. B's own old reading does the same the other way. + +**The rule** (`main.py`, `_track_reads()`, `_drain_grid_w()`, N>=2, only +while a drain's tail settles): + +1. Each cycle, per battery: the range its power can be in. That is its last + changed reading, widened by every command on its wire since (a lagging + battery moves from one toward the others, never beyond). A new reading + starts the range again. +2. A reading that changed this cycle is taken as it reads. So is the side a + battery refuses (SAFETY-73): it is not heading that way. +3. The house point is held (first cycle: the loop's start from SAFETY-95). + It moves only as far as the meter proves: to the nearest value of grid + + the batteries' ranges - aim. + +So the loop no longer moves on a reading that has not caught up. A real +house step still moves it, as soon as the meter shows more than the +batteries can explain. + +**Why not the tester's rule** ("a battery whose reading is older than its +last command counts at its command"). B's command changes almost every +cycle, so B is almost always at its command. That is the plain meter, which +chases A's tail: B swings 1420 W. A lag model needs the plant's time +constant, which is not known for a client's inverter. Measured on the plant +below (N=2, 1200 W, A Off; swing = a move of the others later undone): + +| rule | 10 s lag | 5 s lag | 20 s lag | surplus, 5 s lag | +|---|---|---|---|---| +| release/1.0 (readings) | 760 | 840 | 150 | 800 | +| tester's: at the command | 1420 | 1020 | 1600 | 800 | +| lag model, tau 10 s | 70 | 140 | 80 | 580 | +| lag model, tau 5 s, plant 20 s | | | 600 | | +| hold all window | 0 | 0 | 0 | 0 | +| **this rule** | **0** | **0** | **0** | **130** | + +Holding all window ignores a house step: a 2 kW kettle at E+5 s imported +14.4 Wh over the next 30 s (release 11.5, this rule 11.3); 2 kW more sun +exported 16.9 Wh (release 8.5, this rule 8.9). + +**Before -> after** (`test_safety103.py`, `tools/safety103_plant.py`: 800 +W/s ramp, 10 s lag, meter 1 s, registers 10 s at the rig's phases, B idles +at -100 W; summed swing of the others in the 23 s after the end): + +| case | release/1.0 | after | +|---|---|---| +| rig check 5: N=2, 1200 W, A Off | B 1140 -> 850 -> 1630 -> 870, swing 760 W | B holds 1140 W | +| N=3, 1200 W / 4000 W, A Off | 680 / 630 W | 0 / 0 W | +| solar surplus, N=2 / N=3 | 890 / 740 W | 130 / 130 W | +| lag 5 or 20 s, registers 5 or 10 s, N=2 and N=3 | | at most 10 W | +| registers as fresh as the meter (1 s) | | rows byte-identical | +| refusal end | | rows byte-identical | +| N=1, every end | | rows byte-identical (also against the release/1.0 tree) | + +**Cost.** In the window, a house change smaller than what the batteries +could still be doing is not followed until a reading catches up (at most a +register period) or the window ends (23 s). Under a surplus the others +still swing up to 130 W, a third of a slew step. + +**Not covered.** +- Outside the drain tail the loop steers on the meter; readings only reach + the saturation freeze (SAFETY-96), unchanged. +- A lost link or a refusal ends the tail at once (A is out of the split, or + reads ~0 W). The swing after them is the loop's own on a lagging plant, + the same with 1 s registers and on release/1.0: under a surplus up to + 1.5 kW at N=2 and 3.9 kW summed at N=3; after a lost link about 1 kW + even without one. Not this card. +- A battery that overshoots its command, or a reading offset beyond the + range, can put the true power outside the range; the meter then moves the + point, as before. +- The end cycle's start (SAFETY-95 step 2) still uses the readings; the + batteries were steady during the drain, so they are current there. diff --git a/goodwe_controller/DECISIONS/SAFETY-107-refusal-under-another-writer.md b/goodwe_controller/DECISIONS/SAFETY-107-refusal-under-another-writer.md new file mode 100644 index 0000000..a3c1062 --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-107-refusal-under-another-writer.md @@ -0,0 +1,96 @@ +# SAFETY-107 - a refusal held under another writer is learned once + +**Status: for review.** Tests: `test_safety107.py`. One inverter only; nothing +changes at N>1 (SAFETY-73's unit refusal has no "another writer" end). + +**The defect** (MAINT-13 F5, MAINT-14 "seen again"). A full pack under a +maintenance charge in a cheap window takes no charge. At N=1 the action log then +showed "BATTERY REFUSES asked -2500 W ..." and "BATTERY REFUSES ENDED: another +writer" every cycle: 548 + 548 lines in 15 min on the MAINT-13 rig head, +652 + 651 in 12 min on its base, 33 + 32 in 60 s in MAINT-14's c2b. The 40-line +log flooded and the hint under the buttons changed every second, so the +MAINT-13 notice was gone at once. + +**Cause** (`main.py`, the SAFETY-52 block before `compute()`). The end tested +`self.target != self.loop_w`: the wire differs from the loop's ask. A held +refusal makes the loop ask 0 W, so any claim that binds (the maintenance +charge's `hi`, the peak guard's `lo`, a trade's SET) differs from it on every +cycle. The refusal was ended against the same writer it had been learned +against. `sat_count` is not reset by that end, so `compute()` learned the same +refusal again in the same cycle. Nothing about the battery or the wire had +changed; only the log and the hint did. + +**Other writers.** The same flap comes from any writer that holds the wire +while the loop is refused, as long as the evidence lasts: a trade (a SET at +P_ARBITRAGE) into a full pack, and the peak guard over an empty pack: 335 +refusal lines in 15 min each at a 5 s meter (`test_safety107.py` part 2, run on +release/1.0). +A maintenance drain or hold does not flap: its SET resets `sat_count` each cycle. + +**The rule** (`_refusal_update()`, `rejudged`). When another writer held the +wire, the held refusal is judged afresh: `compute()` gets `refused=0` and no +probe, exactly as before the change. Then: + +1. it finds the same refusal against that writer's ask: the same episode + continues, with no line and no hint. The probe clock restarts, as it did on + the old end and start: a probe is the loop's ask, and it is not on the wire + while another writer is; +2. it does not: the refusal ends, logged "another writer", or "the battery + gives power" when the reading shows it (it was "another writer" before); +3. it finds the other direction: end, then a new start, as before. In + practice 2 comes first: SAFETY-94's evidence restarts when the ask changes + sign, so a writer that turns round ends the refusal at once and the other + way is learned ~12 cycles later (`test_safety107.py` part 3e). + +**Why this rule.** The end now means what SAFETY-52 meant by it: the refusal no +longer describes what is on the wire, judged by the same test that learns it. +`compute()` sees the same inputs as before and the state after each cycle is +the same (`batt_refused`, `batt_refused_at`), so every command is unchanged. +Keeping the held refusal into `compute()` instead (mutation M5) would also +stop the lines, but it holds the loop at 0 W where the old law let it ask past +a writer's limit, which changes commands; it also removes SAFETY-52's +owner-decided "another writer" exit. + +**Evidence.** +- Differential trace (`goodwe/scratch/SAFETY-107/probe/trace.py`, not in this + repo; the real Controller in the CI image, release/1.0 bf3ac79 against this + tree): 300 seeds, N=1 (153) and + N=2 (147), 1/2/5 s meter, full/empty/dead/healthy battery windows, a stepping + house, maintenance charge with the cheap window opening and closing, P1 and + the peak guard, trade windows, stop/start; 427 680 cycles, 294 seeds with a + refusal held. Every command, site target, integrator, `sat_count`, refusal + state and reason is identical. Refusal lines: 100 748 before, 2 188 after. +- The card's case: 1 line in 15 min at N=1 and N=2, 1/2/5 s meter, house + importing or exporting (release/1.0: 335-1 753 at N=1; N=2 was already 1). + +**Mutations** (`tools/mutation_run.sh` on purged copies; reds in +test_safety107 / test_refusal / test_refusal_units; trace = seeds of 300 whose +commands differ from release/1.0): + +| | mutation | reds | trace | +|---|---|---|---| +| M1 | `now == rejudged` -> `False` (every such cycle ends: the old flap) | 26 / 0 / 0 | 0 | +| M2 | `rejudged` always 0 (another writer never ends it) | 5 / 1 / 2 | 29 | +| M3 | the probe clock is not restarted on a continued episode | 1 / 0 / 0 | 2 | +| M4 | a probe may open while rejudged | 0 / 0 / 0 | 0 | +| M5 | the held refusal goes into `compute()` while rejudged | 2 / 1 / 2 | 27 | +| M6 | an end while rejudged always says "another writer" | 1 / 0 / 0 | 0 | + +M4 is equivalent: a probe matters only if `compute()` engages in that call, +which needs `SETTLE_CYCLES` saturated against the writer, and each of those +cycles restarted the clock. The line stays because it states the rule. + +**Cost.** None in power. A takeover by a different writer while the battery +still refuses is logged as an end and, ~12 cycles later, a new start against +that writer (3 lines for the episode, `test_safety107.py` part 3a). + +**Not covered.** +- A perfectly flat reading under a writer. The stale-reading gate trips, stops + the site and seeds the refusal (SAFETY-52 decision 3). After the stop the + writer's ask returns fresh, `compute()` has no saturation history against it, + so the refusal ends at the first cycle and the next trip seeds it again: 2 + lines per gate cycle (29 in 15 min, house exporting), the same as release/1.0. + The gate cycle itself (0 W, then the writer's ask) is the cause; stopping it + changes commands. None of the rig runs showed it: real readings move. +- At N=1 a refusal shapes only the loop. A writer above it still asks the + battery; at N>1 SAFETY-73's P_SAFETY claim binds every writer. diff --git a/goodwe_controller/DECISIONS/SAFETY-112-waiting-for-home-assistant.md b/goodwe_controller/DECISIONS/SAFETY-112-waiting-for-home-assistant.md new file mode 100644 index 0000000..0d54065 --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-112-waiting-for-home-assistant.md @@ -0,0 +1,98 @@ +# SAFETY-112 - control waits for Home Assistant to finish starting + +**Status: for review.** Tests: `test_safety112.py`, `test_messages.py`. +Control is unchanged. The add-on now says plainly that it is waiting. + +## Cause + +The Supervisor's proxy refuses every add-on request with 502 until Core's +state is `RUNNING`. The add-on reads every battery reading and writes every +power request through that proxy, so it cannot control until then. Readings +that are already live inside Home Assistant do not help. + +Source, Supervisor 2026.08.0 (the image the live site ran at CFG-04): + +- `supervisor/api/proxy.py`, `APIProxy.api()` (REST, used for reads and + `number.set_value`) and `APIProxy.websocket()` both start with + `if not await self.sys_homeassistant.api.check_api_state(): raise HTTPBadGateway`. +- `supervisor/homeassistant/api.py`, `check_api_state()` returns + `state.core_state == "RUNNING" or state.offline_db_migration`. The state + comes from Core's `/api/core/state`. +- Upstream `main` has the same gate in both handlers (read 2026-10-13). +- A Core restart restarts only the Core container + (`homeassistant/core.py`, `restart()`). The add-on keeps running. + +The add-on itself has no wait. Nothing in `app/` reads Core's state or waits +for an event. The loop reads its inputs every second, a 502 gives `None`, +and missing inputs give 0 W. P1 backoff is not involved: the live site reads +the meter straight from the HomeWizard (`homewizard_local`), not through Home +Assistant. + +## Measured + +Repro stack `safety112-*`, all removed afterwards: + +- Home Assistant 2026.9.4, as live. +- A test integration whose `async_setup` sleeps, so Core stays `NOT_RUNNING`. +- A fake meter and two fake inverters that update every 2 s from load. +- The Supervisor's own `APIProxy` and `HomeAssistantAPI` from the 2026.08.0 + image. Only the add-on token lookup, Core's access token and the container + check are stubbed. +- The add-on on `meter_source: homewizard_local` against a fake HomeWizard. + +Times are seconds after `docker restart` of Home Assistant: + +| run | Core `NOT_RUNNING` | entities live in HA | Core `RUNNING` | proxy 502 | first write | wait from live readings | +|---|---|---|---|---|---|---| +| base 3050fa5, N=2 | 13.1 | 17.1 | 253.1 | 0.9-252.7 (1 132 requests) | 254.7 | 237.6 | +| base 3050fa5, N=1 | 13.1 | 18.1 | 256.1 | 1.5-255.1 (573 requests) | 257.2 | 239.1 | +| this change, N=2 | 12.2 | 17.2 | 313.2 | 1.4-312.5 (1 406 requests) | 317.6 | 300.4 | +| this change, N=1 | 12.2 | 17.2 | 255.2 | 1.2-254.1 (571 requests) | 257.1 | 240.0 | + +The first write comes 1.0-4.4 s after `RUNNING`, so the whole wait is the +proxy's. With a 400 s sleep Core moved on at 300.88 s on its own (bootstrap's +stage timeout), so one slow integration holds a start stage for about 5 +minutes. The whole start can take longer: the live one took 414 s. + +Before this change the page said "Not ready — Battery level: no usable +reading", which sends the reader to Developer Tools for an entity that is +fine. + +## Rule + +1. No way around the Supervisor. The add-on still talks to Home Assistant + only through the proxy. +2. While the proxy does not answer on the API (`hass.OUTAGE.waiting()`), the + first status row is red: "Home Assistant is not answering, perhaps it is + still starting. Control resumes by itself when it answers." The banner + reads "Not ready — Waiting for Home Assistant to finish starting". The Home + pill reads "Waiting for Home Assistant", in English and Dutch. +3. A safety stop, a stalled control loop or shadow mode keeps its own banner + and state over the wait, because none of them clears by itself. +4. The log has one WARNING when Home Assistant stops answering, an INFO line + every 5 minutes while it is down, and an INFO line with the length of the + wait when it answers again. +5. Only the API counts. The meter websocket alone does not, because the meter + has its own row. + +## Why it is safe + +The change is text only. `cycle()`, the writes, the heartbeat, the stand-down +on shutdown and every watchdog are untouched. `test_safety112.py` part 3 shows +the same targets and writes with the flag on and off, for N=1 and N=2. + +## Open, for the owner + +The add-on cannot control while Home Assistant starts. These ways around it +were not taken: + +- **Talk to Core directly with a long-lived token** (`http://homeassistant:8123`). + Core's own API answers while `NOT_RUNNING`. In the repro `/api/core/state` + answered from 12-13 s, and a direct `number.set_value` 10 s into + `NOT_RUNNING` returned 200 and set the value. The cost is a secret stored + at every client site, which `hass.py` exists to avoid. +- **Talk to the control board directly** (ESPHome native API), so control + does not need Home Assistant at all. This is a large change. +- **Remove the slow start at the site.** On 2026-10-12 the Solis + integration's discovery held the start. Fixing or disabling it shortens the + wait there, but not on a client site with another slow integration. 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 2b0cdc9..d9a5dce 100644 --- a/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md +++ b/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md @@ -69,6 +69,8 @@ which does not run the startup gate) the absence costs a shave and never a pack. ## 3. DECISION 2 — the site verdict is the MAXIMUM of the packs' own floors +*At N>=2 one pack at its floor no longer stops the others: [[SAFETY-87]].* + `Controller.pack_floor_soc` — the **minimum** measured SoC, the emptiest pack — **is unchanged**, exactly as the card instructed. What changed is the floor it is graded against. `peakguard.binding_soc_floor()` reduces the roster's per-pack diff --git a/goodwe_controller/DECISIONS/SAFETY-78-drain-at-two.md b/goodwe_controller/DECISIONS/SAFETY-78-drain-at-two.md new file mode 100644 index 0000000..8c8b0e0 --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-78-drain-at-two.md @@ -0,0 +1,43 @@ +# SAFETY-78 - a maintenance drain at two inverters + +**Status: DECIDED 2026-09-29.** Fixes MAINT-05 §6 (OPT-11 P1 F1, UX-13 P3 +check 5, OPT-11 P3 F4). Tests: `test_safety78.py`. + +**The defect.** At N>=2 the drain pins its pack (a unit SET at +`P_MAINTENANCE`) and the site target is split around the pin. Whenever the +site target was below the pin, the other pack had to charge. The allocator +refuses opposite directions (SAFETY-25 §0a, SAFETY-31), so the site went to +0 W and logged `ARBITER: REFUSED` every cycle. That covers a house below the +drain rate, a grid-charge trade, and a house above it too: after a refusal the +loop restarts from 0 W and its 400 W slew step is below the pin again. + +**The fix.** While a pack drains at N>=2, main.py files a site limit +`drain`, `lo` = what the pin really places (`allocator.pin_w()`: the drain +rate inside that pack's own hard limits), at `P_MAINTENANCE`. So the site +target is never below the pin and the other pack only adds power in the same +direction. Opposite splits stay forbidden. + +- The order is the arbiter's own: a safety SET (100) outranks the limit and + the pin, so everything parks; the peak floor (50) and the drain floor are + both applied, so the higher wins; a trade or the loop below the drain is + lifted to it. +- Below the drain rate the drained pack exports the rest, as the drain does at + N=1 (`maintenance.py`: "forced discharge, exports the surplus"). Holding it + to the house load would make N=2 drain differently from N=1; that is an + owner question, not this fix. +- A draining pack that can place nothing (a refusal, a stale link) adds no + floor, and the wait is logged once per episode. +- With battery modes on, no pack is a grid charger while another drains + (`modetrade.decide(draining=)`, why `cycle`), and the other battery's card + says why (`other_cycle`). Not while the drain waits: no floor is filed + then, so the other battery may charge from solar. +- Not filed at N=1, where the drain is the site SET itself: byte-identical + (`tools/safety78_identity.py`, which hashes the claims too). + +**Not changed (measured in the PR #154 review, follow-ups).** +- At N=1 a drain (60) outranked the peak floor (50): a 5000 W house imported + 3000 W against a 2500 W limit. Fixed by [[SAFETY-83]]. +- At N=2 a maintenance CHARGE in a cheap window has the same stall: the + charging pack's `hi = -floor` against the loop asking the other to give is + refused on 141 of 261 cycles. The mirror fix is a site `hi`, which can meet + the peak floor's `lo` (0 W), so it is not this card. Fixed by [[SAFETY-84]]. diff --git a/goodwe_controller/DECISIONS/SAFETY-81-load-too-high.md b/goodwe_controller/DECISIONS/SAFETY-81-load-too-high.md new file mode 100644 index 0000000..c94559d --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-81-load-too-high.md @@ -0,0 +1,63 @@ +# SAFETY-81 — no false "Load too high for battery" alarms + +**Status: 2026-09-29, owner go for the card. Review kept the 10 W tolerance +as a constant and added the 10 s hold.** + +## Defect + +The SAFETY-79 test (SAFETY-79-test-report.md, F1/F2) saw 6 false alarms in 7. +The alarm fired whenever `decide()` clamped its floor to the rating +(`saturated`). Three things set that while the quarter landed on target: + +- **Double count.** `decide()` sizes from the held meter reading plus the + battery reading. When the meter sample predates the battery's last increase, + the increase is counted twice. It happens under noise, and at each quarter + start, where the battery is back while the held sample is still the spike. +- **The aim, not the cap.** `saturated` is graded against cap − margin, so at + margin 50 noise needs 50 W less to trip it (F2). +- **Last seconds.** The gain 900 / remaining turns a few W over the aim into + thousands of W wanted, with the battery far from its rating. + +## Decision + +The alarm needs `saturated` and `peakguard.load_too_high()`: with the battery +at full power from now on, the quarter still ends more than `ALARM_MISS_W` +(10 W) over the real limit (`ceiling_w`). That is the miss itself, without the +gain. The battery reading it uses is the lower of this cycle's and the last +one's. It must hold for `ALARM_HOLD_S` (10 s). At most one notification per +quarter, because near the threshold the test flickers with the meter; the +ERROR log line is written for every episode. + +**Why 10 W:** at margin 0 the guard itself lands up to +5.7 W over (SAFETY-79 +test, check 5), and those misses are real but tiny; the alarm would fire in +the last second of most guarded quarters. The margin (default 50) is the fix +for them. Cost: a real miss of 10 W or less is not alarmed; at a capacity +tariff of the order of EUR 50 per kW a year, 10 W in one month is cents. +Review measured +13 W on a 10 kW two-unit site at margin 0, not saturated, so +no alarm: the tolerance only matters when the battery is at its rating. A +constant, not an option: `peak_margin_w` is the owner's knob. + +**Why the hold (review):** the test assumes the load now lasts the rest of +the quarter. A load over cap + battery for ~5 s every 30 s (`sine4500_fast`, +a cycling hob or heater) alarmed at each quarter start while the quarter +landed under the aim. The hold stops that. + +**Cost:** a real alarm comes 10-12 s later than on 5a4cdc2 (`step6500` at +1211 s, not 1201 s); a slow ramp 20 s later (`ramp6500`), as it waits for the +real cap. While the battery refuses or its reading is lost, the alarm waits +until the quarter is lost even at full battery (27-60 s later in review runs); +those faults have their own alarms. A load over cap + battery for more than +10 s still alarms even if the quarter later lands under. `ALARM_HOLD_S = 0` +removes the hold. + +## Not changed: the 1 s spike itself + +The cycle after a quarter boundary and before the new quarter's first meter +sample still sees 0 s left, files no claim, and an Off battery drops to 0 W for +one cycle. That is the same edge tick as OPT-15 F1, so it is left to OPT-15. +Control is unchanged here: every command in `tools/safety81_sim.py` is +byte-identical to 5a4cdc2 (test_safety81.py part 1). + +The double count also moves control, not only the alarm: on a late sample the +guard asks for the full rating for one cycle (edge4600 at 902 s: 2000 W where +1150 W holds the aim). Left to a follow-up card. diff --git a/goodwe_controller/DECISIONS/SAFETY-83-peak-over-drain.md b/goodwe_controller/DECISIONS/SAFETY-83-peak-over-drain.md new file mode 100644 index 0000000..8d12cc6 --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-83-peak-over-drain.md @@ -0,0 +1,34 @@ +# SAFETY-83 - the peak guard outranks the maintenance drain at N=1 + +**Status: DECIDED 2026-09-29 (owner go, P1).** Follows SAFETY-78's "Not +changed" list. Tests: `test_safety83.py`. + +**The defect.** At N=1 the drain is the site SET at `P_MAINTENANCE` (60). The +peak floor is a limit at `P_PEAK` (50), and clause 2 applies only limits at or +above the winner, so the floor was ignored and the house imported what the +drain left. A 5000 W house under a 2500 W limit and a 2000 W drain banked +3000 W quarters; on the owner's settings (limit 3500, drain 2500, margin 50) a +breach starts at about 5950 W. + +**The fix.** While a drain owns the setpoint at N=1 and the peak floor asks +for more than the drain rate, `main.py` (`_peak_over_drain()`) refiles that +peak claim at `P_MAINTENANCE`. The arbiter then applies it: the target is the +floor, still inside the safety limits, and a safety SET (100) still parks +everything. Where the floor is at or below the drain rate nothing changes. + +- The order in effect is now the same at N=1 and N=2: safety limits and the + SAFETY-73 refusal > peak guard (with the SAFETY-79 margin) > maintenance > + modes > trade > loop (`arbiter.py`, beside the priorities). +- The drain keeps counting: the battery discharges at least the drain rate on + every cycle, so the cycle is not reset or stalled; it only ends sooner. +- Below the drain rate the rest is still exported (owner, 2026-09-29). +- Identity: `tools/safety78_identity.py --diff` against 35cdffe changes only + cycles where the guard asked for more than the drain; after such an episode + the commands are identical again and only the log and the idle loop's + integrator differ. N=2 is unchanged. + +**Not changed.** +- Between the drain rate and the peak aim, the house still imports what the + drain leaves (N=1 behaviour kept by the identity). At N=2 the loop covers it. +- A maintenance HOLD (0 W, 120 min, battery full) still outranked the peak + guard at N=1. Fixed by [[SAFETY-86]]. diff --git a/goodwe_controller/DECISIONS/SAFETY-84-charge-at-two.md b/goodwe_controller/DECISIONS/SAFETY-84-charge-at-two.md new file mode 100644 index 0000000..21a9af0 --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-84-charge-at-two.md @@ -0,0 +1,46 @@ +# SAFETY-84 - a maintenance charge at two inverters + +**Status: DECIDED 2026-09-30.** Follows SAFETY-78's "Not changed" list. +Tests: `test_safety84.py`; identity: `tools/safety84_identity.py`. + +**The defect.** In a cheap window the charging pack files its own +`hi = -charge` (P_MAINT_SHAPE). At N>=2 the loop's site target sat above it +(a house to cover, or its 400 W slew step from 0 W), so the other pack had to +give while this one charged. The allocator refuses opposite directions +(SAFETY-31), so the site sat at 0 W whatever the house +(`tools/safety84_identity.py` on 9d5b7d1: 436 of 480 cycles refused). N=1 +was fine. + +**The fix.** +- While a pack's own limits make it charge at N>=2, `main.py` files a site + limit `charge`, `hi` = what that pack's own `hi` places + (`allocator.hi_w()`, whole W as the allocator rounds). The site target is + never above it, so the other pack only adds power in the same direction. + Opposite splits stay forbidden. Mirror of SAFETY-78's `drain` floor. +- Peak guard first (owner order, SAFETY-83). The charge is sized to the + quarter's headroom under the aim (`charge_headroom_w()`, cap or banked peak + minus `peak_margin_w`), so it charges less instead of stopping: the meter + sits on the aim and the guard does not need to step in. Where the house + alone is over the aim the guard engages, the shaping is suspended as before + (`shaping_suspended()`) and no `charge` is filed, so the guard's `lo` and + this `hi` never meet in one cycle (no `lo > hi`, no park at 0 W). + Without the headroom sizing the aim still held (the suspension did it), but + a battery gave power back on 285 of 540 cycles at a 3000 W house + (test_safety84.py part 2). +- A site stop (a safety SET above P_MAINT_SHAPE) sizes the charge to 0 W: + the unit `hi` is a hard limit, so a charge under a 0 W stop needs the other + pack to give. It was refused, with an ARBITER line, on most cycles of the + stop (test_safety84.py part 4). The stop still parked the site at 0 W. +- A pack that cannot take the charge (a refusal, a full pack, a stale link) + files no ceiling; the other pack follows the loop. +- Not filed at N=1: byte-identical (`tools/safety84_identity.py`, which hashes + every claim, in and out of a cheap window and under a banked peak). + +**Not changed.** +- At N=1 the charge is not sized to the headroom: the guard still suspends it + and the battery gives until the quarter is safe. The same sizing would fit + there; that is an owner question, not this fix. +- Outside a cheap window (`hi = 0`, solar only) nothing is filed, at any N. +- A HOLD (later fixed by [[SAFETY-86]]) and the "Battery is not keeping up" + alarm (SAFETY-85) are untouched. In the identity runs that alarm's log line + comes as often at N=2 as at N=1 (2 in 480 cycles). diff --git a/goodwe_controller/DECISIONS/SAFETY-85-no-false-not-keeping-up.md b/goodwe_controller/DECISIONS/SAFETY-85-no-false-not-keeping-up.md new file mode 100644 index 0000000..778e326 --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-85-no-false-not-keeping-up.md @@ -0,0 +1,91 @@ +# SAFETY-85 - no false "not keeping up" while another owner sets the battery + +**Status: implemented 2026-10-01, for review.** Tests: `test_safety85.py`. + +**The defect.** "Battery is not keeping up" (SAFETY-40, `goodwe_integrator_bound`) +fired about every 2.5 min during maintenance (drain, charge, HOLD) and under the +peak guard, while the battery gave exactly what it was told. Each warning +re-created the HA notification. Rig counts: 24 in an hour (SAFETY-83 F1), 43 in +7 episodes (SAFETY-86), 35 in 16 (SAFETY-87), 12 in 6 (SAFETY-84). The page +counter counted cycles (861), not episodes. + +**Cause, measured on the real Controller.** +- N=1: when a higher claim decides, `cycle()` re-seeds the integrator from the + target each cycle, then `compute()` adds `gain * error` for a grid error the + loop does not own. Target + kick crosses the rail and sets `i_clamped`. The + carried integrator is not wound (SAFETY-48 caps the lead at one slew step), + so control comes back one slew step at a time. +- N>=2: a maintenance pin is a unit claim. The loop stays the site winner and + winds to its rail for watts the pinned battery may not give. When the pin + lifts, the site jumps: 2500 W in one cycle when a HOLD ends under a 4500 W + house, 1390 W when a charge pin lifts (`s84 n2_charge_dear`, t=1800 s). + +**Decision.** Three rules; the alarm, its text and its id are unchanged. +1. **The loop's clamp counts only on a cycle the loop decided.** The winner is + the loop. Its number is on the wire, or only a rating (`device`, + `supervised`) cut it. No bound held it, and nothing was unmet. + (`Controller._loop_alarm_input`.) +2. **A battery another owner set to discharge alarms when it gives less than + it was told.** This covers every battery on a cycle the loop did not + decide, and a battery with its own pin otherwise. The test is more than + `saturation_w` short of the least it stood at over `SHORT_CYCLES` cycles + and `SHORT_SPAN_S` (the plant's measured settle, SAFETY-13/73), for + `saturation_cycles` cycles in a row (`clamp.delivery_short`). It covers a + real shortfall during maintenance or under the peak guard. Before, that + shortfall was silent unless the kick happened to reach the rail (house + 1000 W, drain 2000 W, battery gives nothing: 0 warnings before, 4 in 10 min + now). +3. **At N>=2, while maintenance holds a battery, the loop is bounded** by + OPT-11's `loop_bound()` (what the batteries may place). It does not wind, + and control comes back one slew step a cycle. Why maintenance only: that is + where the jump was measured and where the owner order puts maintenance + above the loop. Other N>=2 limits (pack cut-out, SAFETY-73 refusal) keep + the old law. Rule 1 still silences them (the target is unmet), so they no + longer warn either. + +Fixing the windup was chosen over only ignoring the wound part, because at +N>=2 the wound integrator moves real watts at hand-back. At N=1 there was +nothing to fix: re-seeding already prevents windup. + +**What SAFETY-03/40 protect is kept.** With the loop in charge, the integrator +bound still warns (a runaway, a house above the rating), and a saturated +inverter under the loop's own command still does not alarm (SAFETY-03 AC 2): +rule 2 grades only batteries another owner set. + +**The counter.** `ClampWatchdog.bound_episodes` counts episodes that had a +bound engagement. The status row shows it ("N times since start"). +`bound` (cycles) stays for the log and the tests. + +**Re-pinned digests.** Every change is intended; nothing at N=1 moves a watt. + +| digest | what changed | +|---|---| +| s78 `n1_drain_owner` | the false clamp rows are gone from the log; commands identical | +| s78 `n2_drain`, s84 `n2_charge`, `n2_charge_banked`, s79 `n2_maint` (test_peak_margin) | loop reason (`mode-bound`), integrator and loop claim while bounded; commands identical | +| opt11 `n2_maint` (test_modes) | A drops out at t=1655 s: B follows the house at once, 8 cycles differ (max 920 W) | +| s84 `n2_charge_dear` | A's pin lifts at t=1800 s: 400 W a cycle, not a 1390 W jump; the surplus step after it; 98 cycles | + +test_safety86's "without the ceiling the log floods" now needs the ceiling +and the bound both removed: either one alone keeps the loop inside what the +batteries can give. The `alarmed()` helpers in test_safety84/86 also read +SAFETY-81's quarter stamp, because ALLOCATOR rows can push its row out of +the 40-row log. + +**Review (PR #163).** The first draft graded each battery against last +cycle's command and graded charges too. On plantsim's battery (10 s lag) at +the live 2 s meter, every drain or charge start warned once, and an N=2 drain +or HOLD under the peak guard with a stepping house warned 15 times an hour. A +maintenance charge tapering near full warned 11 times in 50 min, which +SAFETY-42 section 7 had ruled out. With the settle window and discharge only: +0 in all of those cases. A battery that gives nothing still warns, lagging +or not. At a hypothetical 20 s lag, it warns at most twice an hour. The cost: +a battery that does not take a charge another owner set is not named by this +alarm (the same gap SAFETY-42 states; the charge has its 36 h timeout). +`test_safety85.py` part 7. + +**Not changed, open for the owner.** +- With the loop in charge and the house above the batteries' total rating, + it still warns (SAFETY-40). On the rig's 2 x 2000 W N=2, most of SAFETY-84's + count came from houses of 4500 and 6500 W. +- TEL-10's `Loop reason` reads `mode-bound` while the maintenance bound holds + the loop, with modes off. DOCS lists the value with both meanings. diff --git a/goodwe_controller/DECISIONS/SAFETY-86-peak-over-hold.md b/goodwe_controller/DECISIONS/SAFETY-86-peak-over-hold.md new file mode 100644 index 0000000..612efb0 --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-86-peak-over-hold.md @@ -0,0 +1,47 @@ +# SAFETY-86 - the peak guard outranks the maintenance HOLD + +**Status: DECIDED 2026-09-30 (owner).** Follows SAFETY-83's "Not changed" +list. Tests: `test_safety86.py`. + +**The defect.** The HOLD (battery resting full after the maintenance charge) +is a SET of 0 W at `P_MAINTENANCE` (60), above the peak floor (`P_PEAK`, 50), +so the floor did not bind. At N=1 a 6500 W house banked 6500 W quarters and +'Load too high' fired (SAFETY-83 rig test, check 4). At N=2 only the other +battery gave: 4500 W against a 3950 W aim (SAFETY-84 rig test, check 6). + +**The fix** (`main.py`, `_peak_over_hold()`). +- N=1: during a HOLD a peak floor above 0 W is refiled at `P_MAINTENANCE`, as + for a drain (SAFETY-83). The battery gives the floor; the Why reads "raised + for the peak limit". +- N>=2: the holding battery's pin is raised to the floor minus what the other + live batteries may place (their hard `hi`, or their pin). The others give + first, the holding one only the shortfall. Why this split and not the + allocator's shares: the hold is maintenance and outranks the others' use + (owner order), and the owner rule for an Off battery is the same, "give way + only for the shortfall". A battery Off by its mode (P_MODE, below + maintenance) therefore still gives before the holding one. +- N>=2, while the holding battery gives: a site `hold` ceiling at the floor + (P_MAINTENANCE), so the loop does not ask for watts the hold keeps back. + Without it the moving pin re-logged the ALLOCATOR "could not be placed" + row each cycle and filled the action log (review, PR #161). +- Below the aim nothing changes: the floor is 0 W, the hold is 0 W, the rows + are identical to before (test_safety86.py part 2). +- A site stop (a SET above `P_PEAK`) decides alone: nothing is raised at N>=2, + and the refiled floor at N=1 stays under `P_SAFETY`. If another battery is + pinned to charge (a SAFETY-73 probe) the pin is not raised, so the site is + never split both ways. +- The hold resumes by itself: the rule is evaluated each cycle, so when the + guard lets go the command is 0 W again. + +**The level dips below the target: the hold finishes anyway.** Owner +decision 2026-10-01: the HOLD ends on its clock (`maintenance_hold_min`) even +when the peak guard pulls the level down; no top-up. A top-up would re-enter +CHARGE (up to 36 h, `CHARGE_TIMEOUT_MIN`) and keep the site's one maintenance +slot meanwhile. + +**Not changed.** +- The peak guard still grades the emptiest battery (SAFETY-32): at N=2 with + B under `peak_soc_floor` the floor is limited and A holds even when it is + full (SAFETY-87). +- "Battery is not keeping up" during maintenance (SAFETY-85), the N=2 charge + flapping (SAFETY-88) and the N=1 charge headroom sizing are untouched. diff --git a/goodwe_controller/DECISIONS/SAFETY-87-guard-uses-able-batteries.md b/goodwe_controller/DECISIONS/SAFETY-87-guard-uses-able-batteries.md new file mode 100644 index 0000000..5f366c5 --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-87-guard-uses-able-batteries.md @@ -0,0 +1,83 @@ +# SAFETY-87 - the peak guard uses every battery that can still give + +**Status: DECIDED 2026-10-01 (owner card).** Follows SAFETY-86's "Not +changed" list. Tests: `test_safety87.py`; identity: +`tools/safety87_identity.py`. + +**The defect.** At N>=2 the guard graded the emptiest battery against the +highest floor ([[SAFETY-32]] §3). One battery under `peak_soc_floor` made the +whole site floor-limited: the claim asked for 0 W and the reserve alarm fired, +even with the other battery full. Measured: a maintenance charge (A at 11 %) +raised the alarm 10 times while both batteries gave 2000 W and the quarter +landed on the aim (SAFETY-84 rig test, check 3); a HOLD (A at 99 %, B at +15 %) left A at 0 W and banked 4051 W, raising the month peak (SAFETY-86 +review; rig case 7a: 4563.8 W). + +**The fix.** +- `decide()` and its call are unchanged. At N>=2 `Controller._peak_on_able()` + then grades each battery on its own floor (`peakguard.reserve_split()`). + One at or under it is *held*. The others that are live and not refusing + (SAFETY-73) are *able*. +- A floor-limited decision is re-taken on the able batteries + (`peakguard.place_on_able()`): their own permission (min SoC against max + floor, among them), and a floor of `min(need, able_w)`. `able_w` is their + rating plus the drain a held battery gives anyway (its maintenance pin). +- The held batteries weigh 0 in the split while the site gives + (`arbiter.resolve(reserve=)`), so the floor lands on the able ones. During a + HOLD, a held battery is not counted among the "others" (`_peak_over_hold()`), + so the holding battery covers the need. +- Floor-limited now means: some battery is held and the able ones cannot hold + the aim. The claim still asks them for `able_w`. The alarm fires only if + they miss the real limit at full power for `ALARM_HOLD_S`, the same test as + 'Load too high' (`_reserve_short()`), so a one-cycle miss at a quarter's end + is not an alarm. With nothing able (all held, or the rest refusing or + dropped) it is the old state: 0 W, immediate alarm. +- The alarm and the row name `held`, each battery graded on its own floor. The + old code named batteries against the site's highest floor. +- Page: the two peak rows carry `runs`. A red `runs` row gives the banner its + title without "Not ready", and the Home page is not shown as held at 0 W. + Rows that do stop control still outrank it. + +**How SAFETY-32's concern is still covered.** SAFETY-32 took min(SoC) against +max(floor) so the guard never sizes a forced discharge a battery's own BMS +stops. Now no battery at or under its own floor is asked for the peak: it is +graded on its own floor, and an unread SoC or unknown cut-out counts as held. +The min/max pairing still decides permission, over the able batteries. The +roster verdict `decide()` echoes (`soc`, `soc_floor`) is kept, so SAFETY-32's +call-site and reduction checks still bite. What changed is only that one held +battery no longer stops the others. One exception, with battery modes on: see +Open. + +**Maintenance pins beside a held battery** (review). A draining battery that +may still give is raised from its drain rate towards its rating for the peak +floor, after the others (`_drain_over_reserve()`; peak > drain), else the +floor's rest landed on the held battery. In a HOLD, a held battery counts its +own pin (a drain) among the others, and a held battery pinned to charge stops +the raise (never split both ways). + +**Before -> after** (test_safety87.py, rig of 2 x 2500 W, aim 3450 W): + +| case | before | after | +|---|---|---| +| charge, A 11 %, house 4500 | 3 reserve alarms, quarter 3450 | 0 alarms, 3450 | +| HOLD, A 99 % / B 15 %, house 5500 | A 0 W, B 2488 W, 3 alarms | A 2054 W, B 0 W, 0 alarms, 3450 | +| HOLD, house 6500 | A 0 W, B 2488 W, 4051 W | A 2500 W, B 0 W, 4014 W, 1 alarm (A alone is short) | +| HOLD, month peak 4000, house 6000 | A 0 W, B 2488 W, 3551 W, alarms | A 2054 W, B 0 W, 3950 (peak - margin), 0 alarms | +| both under the reserve | alarm | alarm (unchanged) | +| N=1, every scenario | | byte-identical | + +**Open, for the owner.** +- The loop still draws a held battery (SAFETY-25 option A: the reserve binds + the peak guard only). It gives last, after the able ones. So with house 6500 + in the charge case, B alone cannot hold the aim, the alarm fires as + specified, and the loop then lands the quarter on the aim from A. The text + says "cannot keep … alone", not "will go over", so it stays true. +- Battery modes on, A Off and B held: when B alone can place the floor, OPT-11 + keeps A resting and B gives it. When B cannot, A gives first. Making Off give + before a held battery in the first case needs a third allocator tier. The + same at N=3: A Off, B held, C able, floor 4000 W gives C 2500, B 1500, A 0. + Until then, with modes on, a held battery can give for the peak. +- The notification and row text live in `messages.py`, which has no NL layer + yet (UX-19). The Home page text, which has NL, is unchanged. + +**Not changed.** N=1. 'Load too high' (SAFETY-81). SAFETY-85, -88, -89, -90. diff --git a/goodwe_controller/DECISIONS/SAFETY-90-peak-guard-after-a-gap.md b/goodwe_controller/DECISIONS/SAFETY-90-peak-guard-after-a-gap.md new file mode 100644 index 0000000..dfaf73c --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-90-peak-guard-after-a-gap.md @@ -0,0 +1,81 @@ +# SAFETY-90 - the peak guard goes on after a P1 meter gap + +**Status: DEV 2026-10-05, for review.** Tests: `test_safety90.py`. + +**The defect.** A meter gap longer than `meter_max_age_s` leaves seconds the +quarter average does not count (`p1.QuarterAverager`, on purpose: a guess must +not reach the bill record). `peakguard.blindness()` then called the projection +blind for the rest of the quarter, so the floor was 0 W. During a HOLD the +battery gave nothing and the quarter went over: SAFETY-86 rig case 7b, N=2, +3964.8 W against a 3450 W aim, the month peak raised; N=1, 3701 W (review). +The page still read "Keeping the grid near zero". At start-up mid-quarter the +same rule left the start-up quarter blind, and a held battery was drawn as last +resort (SAFETY-91 test, O2). + +**The fix** (`peakguard.fill_gaps()`, `peakguard.gap_w()`, +`Controller._fill_peak_gaps()`). +- Once readings are back, every second of a silence longer than + `meter_max_age_s` counts at `gap_w = max(the limit, the import just before + the gap, the import just after it)`, where "import" is the grid plus the + batteries' discharge, i.e. with the batteries at 0 W. The gap's first + `meter_max_age_s`, which the averager credits at the last reading, is topped + up to `gap_w` too. The guard then grades the quarter as usual, so it is not + blind and gives what keeps the quarter at the aim. +- **The bound.** The count is at or above the true import as long as, during + the gap, the house load (less solar) stayed at or under the higher of the loads seen just + before and just after it, or under the limit, and the batteries did not + start charging (during a gap they hold their last command, then go to 0 W). + It never counts the gap below the limit. A load that rises above all three + only inside the gap is the one case it can miss. +- **Cost of the bound.** It is conservative: with the house at 6500 W, a 30 s + to 5 min gap lands the quarter 100-180 W under the aim (table below), paid in + battery energy. +- **While the gap lasts** nothing changes: the reading is too old to steer by, + the guard is blind (floor 0 W) and the "inputs missing" stop sets 0 W, as + before. +- **Before the first reading** (start-up or restart mid-quarter) the + add-on was down, so the batteries were at 0 W (it sets 0 W when it stops, + the ESP32 watchdog after 30 s). Those seconds count like a gap, at + `gap_w(the limit, the import seen first)`; after a restart the restored + reading's held `meter_max_age_s` is topped up the same way. The bound is + the gap's, with only the "after" side known. A load the batteries cannot + make up then raises "Load too high", which is true: that quarter is going + over. The page says so: the status row "Peak guard: part of this + 15-minute period was not measured, counted as high use" and the home line + "Part of this 15-minute period was not measured." / "Een deel van dit + kwartier is niet gemeten." until the quarter ends. + *Review:* the first version counted them at the aim, with "nothing is + known" as the reason. The first reading does show the load, and with the + batteries at 0 W the import was that load, not the aim: a 60 s restart + during a 6500 W HOLD landed 3653 W against 3450 W. +- The bill record (`quarter_blocks`) is unchanged: it still banks only what + was measured. The estimate steers the guard and is never banked. +- The action log gets one `PEAK_GUARD_GAP` row per gap, with the seconds and + the watts it was counted at. +- No option. The threshold is `meter_max_age_s`, the limit and the aim are + `peak_cap_w` and `peak_margin_w`. + +**Before -> after** (`test_safety90.py`, quarter as the meter really averages +it, gap starting 300 s in; 10 s battery lag; aim 3450 W): + +| case | before | after | +|---|---|---| +| N=1 HOLD 6500 W, gap 30 s / 60 s | 3456.6 / 5329.2 | 3346.2 / 3272.2 | +| N=1 HOLD 5500 W, gap 5 min | 4717.9 | 3330.6 | +| N=2 HOLD, B able, 6500 W, gap 30 s / 60 s | 3456.6 / 3865.6 | 3346.3 / 3272.7 | +| N=2 HOLD, B able, 5500 W, gap 5 min | 3745.4 | 3288.9 | +| N=2 HOLD, B held, 5500 W, gap 60 s | 3254.9, B drawn 2500 W | 3335.4, B 0 W | +| gap across the edge, next quarter, N=1 / N=2 | 4100.0 / 3621.2 | 3446.3 / 3382.2 | +| house 3000 -> 6500 W inside a 60 s gap, N=1 | 5255.6 | 3368.3 | +| house 6500 -> 2000 W late in a 5 min gap, N=1 | 3629.2 | 3145.1 | +| start-up 450 s in, N=1, house 5000 W (batteries at 0 W before it) | 5000.0 | 3450.0 | +| restart, down 60 s / 150 s, N=1, house 6500 W | 4039.1 / 5483.3 | 3450.0 / 3450.0 | +| meter never drops, N=1 and N=2 | | rows identical | + +**Not changed.** The late-reading edge (SAFETY-91 test, O1): a reading late by +less than `meter_max_age_s` is not a gap, and the seconds since it are not +counted in the projection (on any quarter, not only after a gap). Near a +quarter's end the 900/remaining gain turns that into a large negative excess +and the floor drops for a cycle or more. Counting those seconds at the held +reading would fix it, but it changes every quarter's last seconds on a +healthy meter; it is handed back as its own card. diff --git a/goodwe_controller/DECISIONS/SAFETY-91-held-battery-last.md b/goodwe_controller/DECISIONS/SAFETY-91-held-battery-last.md new file mode 100644 index 0000000..0ee5826 --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-91-held-battery-last.md @@ -0,0 +1,74 @@ +# SAFETY-91 - a held battery is drawn only after the others are full + +**Status: DECIDED 2026-10-01 (owner: held battery last, SAFETY-25 option A +kept).** Follows SAFETY-87. Tests: `test_safety91.py`. + +**The defect** (SAFETY-87 rig test, F1 and F2). At N>=2 a battery at or under +its own peak reserve was held back (weight 0) only on cycles where the peak +guard asked for power. +- F1: B draining at 1000 W, A held at 15 %, house 5500. Once the grid was + near zero the loop set the target, A had its normal weight and B's drain + pin bound, so the loop drew A at 2500 W for the whole quarter (15.0 -> + 10.7 %) while B stayed at 1000 W. In the next quarter A reached its + cut-out, and B stayed pinned at the peak floor (~2422 W), not its 2500 W. +- F2: in a HOLD (A holding, B held), at a quarter's edge the guard saw a block + with no seconds left and asked for nothing for one cycle. The HOLD's + ceiling lapsed, A went back to 0 W and the loop's target went to B: 2500 W + for 1 s. + +**The fix** (`main.py`, N>=2 only). +- `_held_back()`: the batteries at or under their own reserve while another + can still give (live, not refusing), graded as in `_peak_on_able()`. They + weigh 0 in every split where the site gives, not only for the peak. With + nobody able (all held, or the rest refusing or dropped) nobody is held back + and the split is as before. + Review: once held back, a battery stays so until its SoC reads more than 1 + point above its reserve. A reading flipping 20/21 % otherwise swapped + ~1000 W between the batteries on each flip (grid +-500 W with A 10 s and + B 3 s lag). The guard's own permission (SAFETY-87) is unchanged. +- `_drain_rise()`: while a battery is held back, a draining battery's pin + becomes a floor at its drain rate (`lo`), so it rises towards its rating + before the held one gives. Not for a held battery's own drain, not under a + site stop, not for a battery that is not live. +- `tariff.next_quarter()`: past a block's end, before the next block's first + telegram, the guard grades the next quarter, projected from the held + reading (the averager credits it there). Not when that reading is too old + to be credited (`max_hold_s`). + +**What stays.** SAFETY-25 option A: once every able or draining battery is +at full power, the loop still draws a held battery (`allocator._fill()`'s +last-resort pass). With house 5500 and 2 x 2500 W, A still gives 2500 W +after B is full. Nothing is placed on a battery at its cut-out: OPT-09's +pack limit closes it (`hi = 0`), and the drain rise lets B take the rest. + +**Before -> after** (`test_safety91.py`, 2 x 2500 W, lagging battery 10 s): + +| case | before | after | +|---|---|---| +| B drain 1000, A held, house 4000 | A 2500, B 1000 | A 1500, B 2500 | +| same, house 5500 | A 2500, B 1000 | A 2500, B 2500 (option A) | +| cycles A asked while B not full | 612 of 900 | 0 | +| A at its cut-out, house 5500: B | 1572 W mean | 2500 W, A 0 W | +| HOLD, late telegram at the edge | A 0, B 2470 for 1 cycle | A 2050, B 0 | +| idle, A held, house 2000 | A 1005, B 1005 | A 0, B 2020 | +| N=1, every scenario | | byte-identical | + +**Why N>=2 only.** At N=1 there is no other battery to give first. The edge +roll would also fix the N=1 one-cycle dip at a quarter's edge (SAFETY-86 +rig, "A dropped to 0 W for 1 s"), but this ticket keeps N=1 byte-identical; +extending it is a one-line change for a later ticket. + +**Re-pinned.** `test_modes.py` `n2` and `n2_off`: from t=1600 battery A is +at its 20 % reserve, so B carries the house and A gives last (100 of 540 +cycles differ). With SAFETY-87's held rule both are a5dddad's again, and the +test grades that. +Two fixtures changed with it: `test_safety87.py` part 10's "without the +raise" run also turns off `_drain_rise()` (else B rises anyway), and +`test_peakguard.py` 10g and `test_watchdog.py`'s two-inverter rig give +their second battery a cut-out, as rule 8 requires (an unknown cut-out is held, so A alone would carry the cycle). + +**Not changed.** The peak guard's own rules (SAFETY-87). A drain with +nobody held stays a pin (SAFETY-78). An Off battery beside a held one +(SAFETY-87 Open). The first cycle after start, before any quarter is +measured, can still put the loop's first step on a held battery in a HOLD. +SAFETY-88, -89, -90, -92, -93, -94. diff --git a/goodwe_controller/DECISIONS/SAFETY-93-charge-end.md b/goodwe_controller/DECISIONS/SAFETY-93-charge-end.md new file mode 100644 index 0000000..83e65f4 --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-93-charge-end.md @@ -0,0 +1,60 @@ +# SAFETY-93 - the maintenance charge end hands back smoothly + +**Status: implemented 2026-10-02, for review.** Tests: `test_safety93.py`. + +**The defect** (SAFETY-85 rig test, check 4; also on 9358781). At N=2, when +A's maintenance charge ended, B went 0 -> -1600 W in one cycle and charged +from the grid with the house at 6000 W. The HOLD end was already smooth +(SAFETY-85). + +**Cause.** During the charge the site target holds A's charge (SAFETY-84's +`charge` ceiling, -2000 W). At the end A gets its HOLD pin (0 W), but the +loop starts its next step from last cycle's site target. The slew limit +moves the site total by 400 W (-2000 -> -1600), and the only battery left +to place it on is B. The same happens when the charge times out or the +battery is switched Off (A free, its -2000 W shared out). With the charge +suspended by the guard and A giving under the loop, B took A's share in +one step (675 W). + +**The fix** (`main.py`, `_reseed_charge_end()`, N>=2 only). On the cycle a +battery leaves CHARGE, the loop's start point (`self.target`, and the +integrator with it) is what the other batteries stood at plus that +battery's new command: its pin (HOLD or abort, 0 W), or, if it is free, its +last discharge (a charge stops). The loop then moves from there one slew +step a cycle. The ending battery's own step (-2000 -> 0 W) is maintenance's +pin, as before; it lowers the grid import. The end is kept until the loop +next runs, so a missed poll on that cycle (the input hold) does not lose it. + +Review: a charge also ends, for the site, when the battery is still in +CHARGE but can no longer be placed: a stale link or a refusal (SAFETY-73) +drops it, and its -2000 W landed on B the same way (B 0 -> -1600 W at a +1200 W house). So the end is noted after the unit claims are filed: a +battery leaves the charge when it leaves CHARGE, is not live, or its own +claims contradict. The guard suspending the charge (SAFETY-84) is no end. + +Why a start-point reseed, and not a per-battery slew in the allocator: the +jump comes from one stale number at one edge, the kind of edge SAFETY-73 +(`_unit_refusal_end`) and OPT-11 P3 (`_reseed_run_edge`) already reseed. +The change stays at that edge; nothing else at N>=2 moves. + +**Before -> after** (`test_safety93.py`, 2 x 2500 W, 10 s lag, 2 s meter): + +| case | before | after | +|---|---|---| +| rig case: house 6000, A charges 2000 to the end | B 0 -> -1600, charges 4 cycles | B 0 -> 400 -> 800 ..., never charges | +| house 1200 | B 0 -> -1600 (2800 W from the grid) | B steps of 400, never charges | +| charge times out / A switched Off | B 0 -> -1600 | B 0 -> 400 | +| guard suspended the charge, A gives 1825 | B step 675 W | B step 395 W | +| missed poll on the end cycle | B 0 -> -1600 a cycle later | B 0 -> 400 | +| A dropped mid-charge (stale link, refusal), house 1200 | B 0 -> -1600 | B 0 -> 400 | +| N=1, both charge ends above | | byte-identical | + +No pinned digest moved: the 46 digests of `tools/*identity*.py` (N=1 and +N=2) are byte-identical to f3e409c; none of their scenarios reaches a +charge end. + +**Not changed.** The peak guard's floor still moves the site in one step +when it engages (a house step, a quarter edge): the guard outranks the slew +(owner order). A HOLD pin still drops the holding battery to 0 W at once +and SAFETY-86 gives way to the others first. SAFETY-88 (charge flapping +above the aim) is untouched. diff --git a/goodwe_controller/DECISIONS/SAFETY-94-lagging-battery.md b/goodwe_controller/DECISIONS/SAFETY-94-lagging-battery.md new file mode 100644 index 0000000..49a043d --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-94-lagging-battery.md @@ -0,0 +1,102 @@ +# SAFETY-94 - a battery that lags or reverses is not frozen or refusing + +**Status: implemented 2026-10-03, for review.** Tests: `test_safety94.py` +(rig: `tools/safety94_rig.py`). Also closes SAFETY-58. SAFETY-74 stays open. + +## The defects + +1. **Stale-reading gate (SAFETY-37).** Its 35 s ran from the register's last + change. After a rest that clock was already past 35 s, so the first read + after a new command tripped it, before the register could answer. Live + 2026-10-03 (0.14.0, N=2): 17:39:16 B and 17:39:25 A (load ramp after a + rest), 19:00:04 A (drain start), 21:21:22 B (drain -> charge). Each was + 1 s after its command; each register answered within 11 s. This is + SAFETY-58's shape. +2. **Refusal at N=1 (SAFETY-52).** `compute()` engaged after `SETTLE_CYCLES` + frozen cycles if the reading was under 100 W at that moment. The frozen + cycles include a reversal through the battery's lag. At a 1 s meter the + 12th cycle comes as a battery reversing out of a charge passes 0 W + (SAFETY-85 rig, O1: "gave -20 W for 13 cycles"). The refusal then held + 0 W, the battery settled at 0 W, never gave 100 W, and waited for the + 15-minute probe. N>1 already required 22 s in the band, so it was safe + at a 10 s lag but not at 20 s. + +## What changed + +- **The gate counts only time the register was asked to move** + (`freshness.Freshness._oblige`). A read counts the time since the last + read when, at that read, the command is 250 W or more and has moved 250 W + from where it stood at the register's last change. A register change + (or a SAFETY-57 release) restarts the count. A command flapping in and + out of the ask still adds up (frozen register, 0/1500 W every 5 s: trip + at 69 s). The conjunction, `MOVE_W` and `RELEASE_S` are unchanged. +- **The bound is unchanged at 35 s and now means what its derivation + says.** The floor was always "how long a healthy register takes to answer + a command": 6 s plant + 10 s publish + 1 s read = 17 s. The band + `[2 x 17, 0.5 x 75] = [34, 37.5]` holds. Live answers measured: 1.4, 3, + 6 and 11 s after the command. +- **Refusal evidence is the same at every roster size** + (`main.Controller._gave_nothing`, SAFETY-73's count moved into a method). + The reading must stay under 100 W, one way, while asked over 200 W, for 12 + cycles spanning 22 s. At N=1 `compute()` gets this as `gave_nothing` and + needs it on top of its own freeze test. `None` (compute alone) keeps the + old law. +- **The evidence restarts when the reading moves 50 W towards the ask** + (`REFUSED_PROGRESS_W`). A refusing battery reads flat (-3/-4 W live). Live + readings wander by under 25 W at rest. A first-order lag crossing the band + gains 50 W in at most 0.4 tau, so it cannot fill 22 s unless tau > 54 s. + This also covers B, whose reading is 100 W below its power. + +## Bounds and worst-case catch + +| fault | before | after | budget | +|---|---|---|---| +| register frozen, then asked to move | 0-35 s after the ask (often 1 s) | 35 s after the ask (+1 read) | SAFETY-37: 35 s, under the 75/85 s consumer holds | +| register frozen at rest reading ~0 W | stale 1 s after the ask | refused or stale, 0 W within 23-35 s | the same | +| refusing battery, 1 s meter, N=1 | 11-16 s | 22-28 s | SAFETY-73's 22 s span (+ one 10 s publish) | +| refusing battery, 2 s meter | 22-28 s | 22-28 s | unchanged | +| refusing battery, 5 s meter | N=1 35-41 s, N=2 41-55 s | N=1 40-45 s, N=2 50-55 s | 12 cycles = 55-60 s | + +Rig: `test_safety94.py` part 3. A release after SAFETY-57 still exposes +the held number for one 35 s window of asking, so 35 < 75 < 85 still holds. + +## Before -> after + +- Live replay (part 1): 4 trips -> 0. +- Refusing B (its -100 W idle reading): see SAFETY-97. +- Rig, 10 s lag, N=1 and N=2, 1/2/5 s meters, two register shapes, 48 runs + (part 2): before 27 stale trips and 1 refusal, after 0 and 0. +- Reversals, lag 10 and 20 s, 1 s meter (part 2): before N=1 6 stale / 3 + refused, N=2 6 / 1; after 0 / 0. +- 16-day loop capture (`test_freshness.py` part 8): 93 trips -> 59. Trips + answered within 20 s: 21 -> 1. Two of the removed trips were not quick: + a register held 351 s under a command hovering at the 250 W travel edge + (under 35 s of asking before it moved; survivor 2's shape), and a + refusing battery the gate had caught only on a passing ramp (SAFETY-52's + refusal covers it). + +## SAFETY-58 and SAFETY-74 + +- **SAFETY-58 is fixed** by this change: its card asks for the clock to + start with the obligation and for the bound to be re-derived. Both are + done above. `test_freshness.py` survivor 5 is now graded as closed. +- **SAFETY-74 is not fixed.** A battery capped at 300 W with a perfectly + flat reading, 5 s meter, house stepping: 50 trips/h at N=1 and 80 at N=2 + before, 37 and 60 after. With +-1 W dither: 0. A flat reading under a + command that moved is exactly what the gate is for; telling "limited" + from "frozen" needs a second signal (the meter, or SoC). + +## Known gaps + +- ~~B's standby offset (-95 to -118 W at idle) puts a refusing B outside the + 100 W band, so a refusing B never latched (review F1: at N=1 new, at N=2 + as before).~~ **Fixed by SAFETY-97 in the same PR:** the band is centred + on each unit's learned idle reading. `SAFETY-97-idle-offset.md`. +- A command that asks only part of the time (a fraction d) is caught after + 35 s of asking, which is 35/d s of wall time (0/1500 W every 5 s: 69 s). + SAFETY-30's per-unit check restarts when its command stops opposing the + held reading, which a lapsing ask normally does, so 35 < 85 holds there. + SAFETY-31's circulation check (75 s) ignores the command, so its margin + holds only while d >= 0.47. +- A register frozen under a command that stays near the 250 W travel edge + is caught late or not at all (survivor 2). diff --git a/goodwe_controller/DECISIONS/SAFETY-95-drain-end.md b/goodwe_controller/DECISIONS/SAFETY-95-drain-end.md new file mode 100644 index 0000000..99b6bd4 --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-95-drain-end.md @@ -0,0 +1,69 @@ +# SAFETY-95 - a maintenance drain that ends hands back smoothly + +**Status: implemented 2026-10-06, for review.** Tests: `test_safety95.py`. + +**The defect** (SAFETY-93 review, O1). At N=2, when A's drain ended (A +switched Off, say) with the house at 1200 W, B went 0 -> +1600 W in one +cycle and exported. It is the mirror of SAFETY-93 on the drain end. + +**Cause.** During a drain the site target carries A's drain floor +(SAFETY-78, `drain`, 2000 W) above what the house needs. At the end A's +command drops (pin, Off, abort, stale link, refusal), but the loop slews +from last cycle's target (2000 -> 1600) and only B is left to place it. +Under a solar surplus B gave 1600 W into the export. + +**The rule** (`main.py`, `_reseed_drain_end()`, `_drain_grid_w()`, N>=2 +only). On the cycle a drain ends: + +1. The start point is what the others stood at plus A's new command (its + pin, else 0 W; a dropped battery is sent 0 W), as SAFETY-93 does. +2. If the house needs more, the start is raised to the house's own point + (live batteries' readings + grid - target, as `_reseed_run_edge()`), + but never above last cycle's start. So the others cover the house at + once and never take over the drain's export. +3. While A settles (SAFETY-96's window: within the deadband, at most + `PLANT_SETTLE_DOWN_S`), the loop's error is the house's point minus its + start, not the meter. The meter still shows A's tail and the others' + climb; chasing it sent B down and then up again. +4. Then the loop moves one slew step a cycle. + +A drain ends when its battery leaves DRAIN, is not live, its claims +contradict, or it will not give (SAFETY-73's limit outranks the pin +without a contradiction, so the refusal is read from the battery; +1 only, +as `_held_back()`). Today a contradiction on a drain only arises through +a refusal; the clause stays because a contradiction sends A 0 W, which is +an end (tested directly). The end is kept +until the loop runs (a missed poll). A dropped draining battery is this +rule's, not `_reseed_drop()`'s. Order in one cycle: drop/rejoin +(SAFETY-96), charge end (SAFETY-93), drain end (this) last; each moves the +start by its own battery, and the drain end's raise reads the start the +others left. + +**Why not SAFETY-93's rule alone** (step 1, then slew). Measured on the +same plant: at 1200 W the grid imports 849 W and B goes to -465 W +(charging) while the loop chases A's tail; at N=3 and 4000 W it imports +784 W where base imports 42 W, as the others give up A's share of the house. +The house point keeps the grid at its aim; the cap at last cycle's start +keeps it from exporting. + +**Before -> after** (`test_safety95.py`, 2500 W batteries, 10 s lag, 2 s +meter; base = the same tree with the change taken out): + +| case | before | after | +|---|---|---| +| drain done / Off / timeout, N=2, 1200 W | B 0 -> 1600, +390 W commanded export, grid then +559 W | B 0 -> 1210, grid within 40 W of its aim | +| the same, N=3, 1200 W | B, C 0 -> 800, grid +695 W | 0 -> 605, grid +39 W | +| the same, solar surplus | B gives 1600 W into the surplus | B never gives; site moves 400 W a cycle | +| refusal, N=3, 1200 W | B, C step 1020 W, +2000 W commanded export | step 20 W, +70 W | +| N=3, 4000 W | others take A's share at once | the same (grid +1 W, base +42 W) | +| abort (site zeroed by the operator) | climbs from 0, grid +669 W | climbs from 0, grid +16 W | +| stale link (board watchdog 25 s) | | as base, but N=3 4000 W: grid +676 W (base +786) | +| N=1, every drain end | | byte-identical | + +`tools/opt11_identity.py` n2_maint moved on purpose (A's drain refused at +1655 s; B stays at the house's 1420 W instead of 2500 W). Re-pinned in +`test_modes.py`. The other 45 digests are byte-identical. + +**Not changed.** The peak guard's floor still binds (arbiter, after the +loop). Held batteries still give last; the idle clamp and the freeze test +are as they were. A charge end (SAFETY-93) and a trade end are unchanged. diff --git a/goodwe_controller/DECISIONS/SAFETY-96-dropped-battery.md b/goodwe_controller/DECISIONS/SAFETY-96-dropped-battery.md new file mode 100644 index 0000000..94e9c03 --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-96-dropped-battery.md @@ -0,0 +1,59 @@ +# SAFETY-96 - a battery the loop cannot move does not freeze it + +**Status: implemented 2026-10-06, for review.** Tests: `test_safety96.py`. + +**The defect** (SAFETY-93 rig test). At N=2 the saturation freeze compares +the site target with the site sum of the readings. After A's link went +stale, A was out of the split but its reading (-2000 W, still charging) was +in the sum: "saturation freeze (800 W vs -1903 W)", B held at 1200 W for +~236 cycles until the circulation trip. After a charge timeout or Off at +6000 W the loop froze ~14 s at 800 W while A came out of its charge. At a +drop, B also took A's whole share in one step. + +**The rule** (`main.py`, `_held_out_w()`, `_reseed_drop()`, N>=2 only). +A battery is held out of the freeze test, counted at its command instead of +its reading, while: + +- it is out of the split (writes refused for `LINK_STALE_S`); +- its last write was refused (its board may have gone to 0 W on its own + watchdog); +- it settles after a step the loop did not make: a charge end (SAFETY-93: + HOLD, timeout, Off, refusal, stale link) or a rejoin (then every battery, + as the split is made again). Settling ends when its reading is within the + deadband of its command, or after `PLANT_SETTLE_DOWN_S` (23 s, the + measured plant settle). + +Its real power is not lost and not counted twice: the meter sees it, and +the loop follows the meter. Only the freeze stops reading it as the loop's. +The freeze still fires on the other batteries' own lag and on unplaced +watts. + +At a drop the battery's share leaves the loop's start point, so the others +take over one slew step a cycle. At a rejoin it enters at its reading. + +**Not a drop.** A missing reading or a stale register stops the site +(`inputs_ok`), as before. A refused battery (SAFETY-73) stays in the sum: +it is asked ~0 W and reads ~0 W. A watchdog-stopped battery (SAFETY-02/30) +is pinned at 0 W and stays in the sum. New: no refusal is learned on a +battery whose writes are refused; its board going to 0 W is no refusal. Its +count is kept, not reset, so a link that refuses 1 write in 3 does not hide +a battery that really refuses (review). + +**Before -> after** (2 x 2500 W, 10 s lag, 2 s meter; freeze cycles not +about the remaining battery's own lag): + +| case | before | after | +|---|---|---| +| A drops while giving, A holds 1200 W | 128, B steps 1210 W | 0, B stays | +| A drops while charging, A holds -2000 W | 78 | 0 (circulation trip stays: 4 kW goes round) | +| A drops while charging, board to 0 W | 5 | 0 | +| A drops then comes back, A holds 1200 W | 46, grid +1.1 kW | 0, grid within 250 W | +| charge timeout at 6000 W | 7, B held at 400 W | 0, B climbs | +| A switched Off at 6000 W | 6, B held at 800 W | 0, B climbs | +| N=1, N=2 with no drop | | byte-identical | + +**Not changed.** At a rejoin the allocator splits again at once (A 0 -> +1205, B 2410 -> 1205 W); the site total does not step. A battery that keeps +charging on its own after its link drops still trips the circulation check +(it is real circulation). A trade end (`_reseed_run_edge`) still starts +from the full site sum. diff --git a/goodwe_controller/DECISIONS/SAFETY-97-idle-offset.md b/goodwe_controller/DECISIONS/SAFETY-97-idle-offset.md new file mode 100644 index 0000000..e572314 --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-97-idle-offset.md @@ -0,0 +1,118 @@ +# SAFETY-97 - "gives nothing" is graded against each battery's idle reading + +**Status: implemented 2026-10-04, folded into PR #171 (SAFETY-94), for +review.** Owner decision 2026-10-04: the band's centre is clamped to ++-75 W (see Bounds). Tests: `test_safety97.py` (rig: `tools/safety94_rig.py`). +Closes SAFETY-94 review finding F1. + +## The defect + +B's power reading (board B's `inverter_ac_power`, 0x0518) shows -95 to -118 W +at idle: its standby draw (the GoodWe app shows 0 W). A's shows -2 to -4 W. +Every "the battery gives nothing" test was `|reading| < 100 W`, so a refusing +B sat on the band's edge and never latched: + +- N=2: never caught, before or after SAFETY-94. +- N=1: the old one-instant check caught it in 17-105 s; SAFETY-94's shared + rule (12 cycles in the band in a row) never did (review probe, 1200 s). + +## Design + +- **Offset source: learned per unit** (`app/idle.py`, `Inverter.idle`). No + new option: a required one is a setup step every client gets wrong, and an + optional one is a second source to reconcile. An override can be added + later if a site needs one. +- **When it learns:** control runs; the unit is not refused (unit or site), + stale or sign-tripped; its command has been within 50 W (`IDLE_ASK_W`) for + 120 s (`IDLE_QUIET_S`, 6 lags of 20 s). A refusing battery is asked more + than 50 W, and while refused it does not learn, so its refusal cannot be + learned as idle. A capped battery under a big ask is not sampled either. +- **How:** the first value is the mean of 120 s of samples; then it follows + drift with a 30 min time constant. A sample beyond 250 W (`IDLE_SAMPLE_W`) + is dropped (a register held at a giving value). The band is centred on + the value clamped to 75 W (`IDLE_MAX_W`; owner decision below). +- **Restart:** saved per unit (`unit//idle_w`, on first learn and each + 5 W move) and restored, clamped to 250 W. Without it a battery refusing from the + start is never quiet, so never learned, so never caught. +- **Until learned the idle reading is 0 W**: the rule as it was. + +## Not chosen: the GoodWe LAN integration's DC battery power + +Both inverters also appear through HA's GoodWe integration (B: +`sensor.kelder_dyness_battery_power`, 0 W at idle; found read-only on the +live HA 2026-10-04). Pointing `batt_entity` at it removes B's offset by +configuration, but: + +- the band must be safe without it: other installs may not have it; +- it is polled over UDP on the inverter's Wi-Fi, a second channel that can + drop while the board keeps reading (HW-01 lost both once); a missing + reading stops the site; +- SAFETY-37's 35 s bound is derived from the board's 10 s publish; the + integration's poll interval (believed 10 s, not checked here) and latency + are not measured; +- DC power is not 0x0518, which SAFETY-30/31/42 were measured against. + +It stays an install choice to test on the rig, not a code change here. With +it, the learned idle reading is simply ~0 W. + +## Where it applies + +Every test of "gives nothing / gives / takes" now uses `reading - idle`: + +| check | change | +|---|---| +| shared refusal evidence `_gave_nothing` (N=1 and N>=2) | centred | +| N=1 engage and end in `compute()` (`idle_w`, the site sum) | centred | +| N=1 end in `_refusal_update` | centred | +| N>1 unit end (probe answered, takes the other way) | centred; uncentred, B's idle -100 W read as "takes 100 W" and ended a charge refusal at once | +| stale-path refusal (`_refusal_from_stale`, N=1 and N>1) | centred | +| SAFETY-42 "parked" (small ask, gives nothing) | centred | +| SAFETY-37 stale gate | unchanged: it grades register changes against command travel, not a level | +| SAFETY-57 release | unchanged: it reads the command only | +| SAFETY-30 per-unit sign | unchanged: opposition needs both over 250 W; B's 100 W idle cannot make or hide one alone | +| SAFETY-42 shortfall | unchanged: B reads ~100 W short at every power, which errs towards reporting (report only) | + +## Bounds: owner decision 2026-10-04 (review R1) + +Band: `|reading - centre| < 100 W`, centre = idle clamped to +-75 W. + +- **Why a clamp under the band.** A wrong idle reading (taught during a + rest while the reading shows something else, e.g. a grid top-up of a unit + held at 0 W, or restored onto a swapped inverter) more than about 100 W off + hid every refusal on that battery, at N=1 and N=2; with the first +-250 W + clamp the rig missed a refusing A whenever the error was 110-250 W. At + +-75 W the band always keeps 0 W +-25 W, so a battery reading about 0 W + while refusing is caught whatever was learned or restored, and a wrong idle + cannot refuse a healthy 0 W battery (its ask is over 200 W). +- **The trade-off.** Options: keep +-250 W (covers idle readings to about + +-350 W, any 100 W mis-learn hides refusals); clamp to +-75 W (chosen); + "old band or centred band" (rejected: a correct B would need to give 200 W + to answer its 200 W probe). +- **Cost.** A battery whose idle reading is beyond about +-170 W is centred + at most 75 W out, so its refusal falls outside the band: it behaves as in + 0.14.0 (not caught by this rule). B (-95 to -118 W) reads 20-43 W from its + clamped centre. B is caught across -150..+90 W of error in its idle + reading; learned 110 W or more too high it is missed, as in 0.14.0 at N=2. +- A frozen register is still caught by the stale gate at 35 s of asking. +- Recovery from a wrong value needs quiet time: tau 30 min. + +## Catch times (rig, refusing from 200 s after a 400 s rest, +-4 W) + +| refusing | meter 1 s | 2 s | 5 s | +|---|---|---|---| +| A, N=1 | 28 s (before 28) | 28 (28) | 65 (65) | +| A, N=2 | 28 (28) | 28 (28) | 110 (110) | +| B, N=1 | 28 (never) | 28 (never) | 65 (never) | +| B, N=2 | 24 (never) | 24 (never) | 60 (never) | + +A at N=2 on a 5 s meter is 110 s here because the rig's grid goes flat once +A is cut, so the loop cycles on the 15 s floor; the same run on the PR before +SAFETY-97 gives 110 s. B restored after a restart, refusing from the start: +24 s; with nothing saved: never. + +## Before -> after + +- Healthy B with its offset, SAFETY-94's four scenarios, N=1 and N=2, 1/2/5 s + meters, both register shapes, idle learned or restored (96 runs): 0 stale, + 0 refused. Reversals, lag 10/20 s, idle restored: 0. +- Live replay (SAFETY-94 part 1): 0 trips (the gate is untouched). diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index b054631..5cca6f4 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -184,6 +184,11 @@ The label and description each setting shows on the Configuration tab, in tab or | Hardware · Inverter list | Usable battery capacity (kWh) (optional) | `inverters.capacity_kwh` | This battery's usable capacity from its datasheet. Fill it in on every inverter or on none. The add-on then shares the work by the energy each battery has left. | | Hardware | RS485 emergency stop is fitted | `estop_fitted` | Switch this on if the emergency stop is installed. It only changes a warning on the status page. | | Hardware | Battery modes | `battery_modes_enabled` | Give each battery an on and off switch and a mode, set on the Batteries screen. | +| Hardware | Solar forecast, today (kWh) | `pv_forecast_entity` | Optional. A forecast sensor with today's remaining solar energy, such as Forecast.Solar or Solcast. A battery then buys only what the sun will not give. | +| Hardware | Solar forecast, tomorrow (kWh) | `pv_forecast_tomorrow_entity` | Optional. The same forecast for tomorrow. It helps plan a cheap hour before midnight. | +| Hardware | Several solar forecasts, today (kWh) | `pv_forecast_entities` | Optional. One row per forecast sensor, such as one per inverter, used instead of the single one above. They are added up. | +| Hardware | Several solar forecasts, tomorrow (kWh) | `pv_forecast_tomorrow_entities` | Optional. The same list for tomorrow. If one sensor has no reading, that day's forecast is not used. | +| Hardware | Trust in the forecast (%) | `solar_trust_pct` | Forecasts are often too sunny. At 70 % the add-on plans with 7 of every 10 kWh forecast. | | Hardware | Solar production entity (W) | `pv_entity` | Optional. Your solar inverter's power sensor, positive while producing. The add-on records it and does not use it for control. | | Hardware | Solar production entities, per inverter (W) | `pv_entities` | Optional. Use one row per solar inverter instead of “Solar production entity” when you have several. If one stops reporting, no total is recorded. | | Hardware | Reversed-sign solar entities | `pv_invert_entities` | Optional. Add each solar entity above that reads negative while producing, spelled the same way. Its sign is flipped before adding up. | @@ -227,7 +232,7 @@ The label and description each setting shows on the Configuration tab, in tab or | Tariff | Trading charges at most (%) | `arbitrage_soc_ceiling` | Price trading will not charge the battery above this level. | | Maintenance | Run the monthly battery cycle | `maintenance_enabled` | Once a month, empty the battery deeply, charge it full and hold it there, so its level reading stays accurate. Off by default. | | Maintenance | Minimum days between cycles | `maintenance_interval_days` | The shortest gap between two maintenance cycles. | -| Maintenance | Start hour | `maintenance_start_hour` | The local hour, 0 to 23, at which a due cycle starts. | +| Maintenance | Start hour (local time) | `maintenance_start_hour` | The hour on your local clock, 0 to 23, when a due cycle starts. 19 means 19:00. | | Maintenance | Draw-down rate (W) | `maintenance_discharge_w` | How fast the cycle empties the battery. Above “Inverter rating” it is cut back to the rating, and the cycle just takes longer. | | Maintenance | Recharge rate (W) | `maintenance_charge_w` | How fast the cycle recharges the battery. It must be at or below “Inverter rating”, or the add-on will not start. | | Maintenance | Draw down to (%) | `maintenance_soc_floor` | The level the monthly cycle empties the battery to. It is not the “Peak reserve”, and the two differ on purpose. | @@ -522,7 +527,7 @@ Off by default. Turn it on, restart the add-on, and the Batteries screen (and Se - **On or off.** Off asks the battery for 0 W and starts no monthly cycle on it. A cycle that is running stops, and the add-on log says so. The peak limit may still use it, but only for what the other batteries cannot cover. - **Mode.** - *Solar first* charges from spare solar before the other batteries and covers the house at any hour. It never charges from the grid and never sells. - - *Leftover solar* charges from the solar the others cannot take, never from the grid (until the solar forecast arrives). It saves its power for the expensive hours and covers the house then. + - *Leftover solar* charges from the solar the others cannot take. With a solar forecast it may also charge from the grid, but only what the sun will not give (see below). It saves its power for the expensive hours and covers the house then. - *Cheap hours only* charges from the grid in the cheapest hours, saves its power for the expensive hours and covers the house then. - **Selling** is off until it can be judged on the price the grid pays (owner decision 2026-09-29, option A). @@ -535,10 +540,24 @@ With battery modes on, these choices replace the *Charging and selling on price* - A carried (not yet published) price is never used. A run that has started runs to its end. - With no prices, or too small a gap, every battery follows the house. +**Solar forecast** (`pv_forecast_entity`, optional). A Home Assistant sensor with the solar energy still to come today, in kWh. These work: Forecast.Solar (`sensor.energy_production_today_remaining`), Solcast PV Forecast (`sensor.solcast_pv_forecast_forecast_remaining_today`) and Open-Meteo Solar Forecast (`sensor.energy_production_today_remaining`). Set `pv_forecast_tomorrow_entity` to the matching *tomorrow* sensor to plan a cheap run before midnight. +- **Several forecasts.** If your forecast comes as several sensors (one per inverter or roof side), list them in `pv_forecast_entities` and, for tomorrow, `pv_forecast_tomorrow_entities`, instead of the single key. They are added up, each in its own unit (Wh, kWh or MWh). Set the list or the single key for a day, not both. +- If one of them has no reading, is stale or is not in an energy unit, that day's forecast is not used at all. A part of the sun is not counted as all of it. The status page names the source by its place in the list. +- The hourly shape is kept only if every sensor has one. Otherwise the total is spread over the day as for Forecast.Solar. +- Before each cheap run the add-on asks: will the sun fill the batteries before the expensive hours? It counts on `solar_trust_pct` of the forecast (70 %), less what the house uses. Solar first batteries get that sun first. +- *Leftover solar* then charges from the grid only the shortfall: what the house will draw from it in the expensive hours, less its share of the sun. Not up to the top level. +- *Cheap hours only* ignores the forecast and fills to the top level. +- *Solar first* never charges from the grid. +- The house use is the add-on's own measurement (grid + battery power) per hour, over the last 7 days. It needs one day of it before the first plan. +- The plan is checked every 15 minutes. A planned level moves only by 1 kWh or 10 %, so a wobbling forecast does not flip it. +- No forecast, one older than 3 hours or from before midnight, or no house use yet: *Leftover solar* takes solar only and *Cheap hours only* fills to the top level. The Batteries screen says so. +- A battery needs its capacity (`inverters.capacity_kwh`) for a plan. + **What always comes first.** - The peak limit, the safety stops and the monthly cycle come before a battery's mode. - A grid charge is sized so the 15-minute period stays under the peak limit less its margin (`peak_margin_w`). It starts only at 500 W or more. Once the peak limit stops it, it stays off for the rest of that 15-minute period. The start of a new period does not stop it. - While one battery charges from the grid, the others rest, and the house uses the grid. +- While one battery empties for its monthly cycle, no battery charges from the grid. - A battery that refuses to charge or give power takes no part in a run in that direction. In a cheap run it is still tested every 15 minutes: it charges at 200 W for about half a minute while the others rest. **In Home Assistant.** With MQTT set up, each battery also gets a *Mode* list and a *Use* switch, for example `select.goodwe_inverter_a_mode` and `switch.goodwe_inverter_a_use`. They do the same as the Batteries screen. Turn battery modes off and restart, and they are removed. @@ -563,7 +582,7 @@ accumulator is doing: | `Loop integrator lead` | W | that value minus **the command it was computed against**, i.e. the command the previous cycle left standing | | `Loop reason` | — | the law's own word for this cycle | -`Loop reason` takes one of ten values: +`Loop reason` takes one of eleven values: | value | what happened | |---|---| @@ -574,8 +593,9 @@ accumulator is doing: | `slew-limited` | the command could not move as far as the integrator asked for; `slew_w` is what stopped it | | `saturated-freeze` | the inverter is not following, so the command's magnitude may fall but not rise | | `freeze-released` | the freeze had run out of plant-shaped explanations and the command was let move again | +| `mode-bound` | the batteries' limits set the command, not the meter: their battery modes, or, with two or more inverters, a battery in its monthly maintenance | | `not-running` | control is stopped **and** shadow mode is off, so the loop did not run | -| `battery refuses` | the battery gave less than 100 W for 12 cycles (a minute at a 5-second meter) while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) | +| `battery refuses` | the battery gave less than 100 W for 12 cycles and at least 22 s (a minute at a 5-second meter) while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) | | `battery probe` | the add-on asks 200 W to test whether a refusing battery gives power again | **When the battery refuses.** At its cut-out level the battery gives nothing, @@ -585,7 +605,15 @@ request, and the `Why` row reads *The battery refuses to give power: 0 W*. Every the battery gives 100 W or more, when the grid needs charging instead, and when control is stopped or started. 200 W is below the 250 W the stale-reading check needs, so a test never trips it. A battery that gives some power, even 100 W, -is not refusing and keeps its request. +is not refusing and keeps its request. Nor is a battery whose reading is +still moving towards the request, such as one turning from charging to +giving (SAFETY-94). "Gives" is counted from what the battery's reading shows +at rest, which the add-on learns after two quiet minutes and keeps: some +inverters show about -100 W at rest (SAFETY-97). +While another task sets the power, such as a maintenance charge, the peak +guard or a trade, the refusal stays as long as the battery still refuses that +task's request. It is logged once, and the 200 W test waits until control sets +the power again (SAFETY-107). **With two or more inverters** the same rule works per inverter. An inverter whose battery gives less than 100 W for 12 cycles, and at least 22 s, while @@ -599,7 +627,7 @@ If the battery refuses to give power while its level reads above its “Battery cut-out level”, the log adds one warning ending in `REFUSES_ABOVE_CUT_OUT`: check that setting. A battery that refuses is not reported as *not delivering*. -*For installers:* SAFETY-52 and SAFETY-73. The event log reads +*For installers:* SAFETY-52, SAFETY-73 and SAFETY-107. The event log reads `BATTERY REFUSES …` and `BATTERY REFUSES ENDED …` (with the inverter's name at N>1), and the INFO line ends in `BATTERY_REFUSES`. `DECISIONS/SAFETY-52-a-battery-that-refuses.md` has the replay figures and @@ -1624,13 +1652,45 @@ help - see "Why the tuning is what it is". |---|---|---| | `maintenance_enabled` | false | Enable the monthly cycle | | `maintenance_interval_days` | 28 | Minimum gap between cycles | -| `maintenance_start_hour` | 10 | Hour of day a due cycle begins | -| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower | +| `maintenance_start_hour` | 19 | Local hour a due cycle begins (see below) | +| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). The peak guard may ask for more (SAFETY-83; at N≥2 while another battery is at its peak reserve, SAFETY-87), and at N≥2 so may the house while another battery is at its peak reserve (SAFETY-91). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower | | `maintenance_charge_w` | 2000 | Charge ceiling, capped again by peak headroom. Must stay **at or below `max_w`** or the cheap-window shaping (`hi = -maintenance_charge_w`) contradicts the supervised limit (`lo = -max_w`), the arbiter resolves it to **0 W**, and maintenance silently never charges in a cheap window — **the add-on refuses to start** | | `maintenance_soc_floor` | 11 | Drain target — stay just above the inverter's own floor. ⚠️ **This is not `peak_soc_floor`.** It is the depth the monthly recalibration deliberately goes to, once, on purpose; `peak_soc_floor` is the depth a money-saving discharge may never reach on any day. The right values differ (11 vs 20 as shipped), which is why they are two keys | | `maintenance_soc_target` | 99 | Charge target | | `maintenance_hold_min` | 120 | Hold at full so the BMS can balance | +**The start hour is local time.** The add-on reads Home Assistant's time zone +(Settings → System → General) at startup. If Home Assistant does not answer, +it uses the add-on's own time zone, and failing that UTC. The log says which +one it used, and warns if it is UTC: then restart the add-on once Home Assistant +is up. On the day the clocks go forward, a start hour that does not exist +starts at the first hour after the jump. On the day they go back, a repeated +hour starts only once, in its first pass. With two inverters both batteries use +the same local hour; one cycles at a time, and the other starts at that hour on +a later day. + +**Upgrading.** The hour used to be read as UTC. An existing install keeps its +saved hour, and that hour now means local time: a saved 10 now starts at 10:00, +not 12:00 in summer. The new default, 19, only reaches installs that have no +hour saved. If you set an earlier hour to get an evening start in UTC (for +example 17 for 19:00 in summer), set it to 19. The add-on never changes a saved +value. *For installers:* DECISIONS/MAINT-10-local-start-hour.md. + +**When the charge counts as done.** A battery may stop at 98 or 99 % while it +balances, so it need not reach `maintenance_soc_target`. The charge counts as +done when the battery is within 3 points of the target and has taken no more +charge for 60 minutes of charging time, or when the charging time (36 hours) runs +out within 3 points of the target. Only the minutes the cheap-hours gate let the +battery charge count, and the charge ends after 7 days at the +latest. Then the usual hold follows. + +**When a cycle fails.** If the battery does not empty in 12 hours, or does not +get within 3 points of the target in 36 hours of charging time, the cycle +stops, you get the notification *Battery cycle did not finish*, and it is not +tried again for 7 days. The status page shows *Maintenance paused* with the +date. Starting a cycle by hand ignores the wait. *For installers:* +DECISIONS/MAINT-13-full-pack-is-complete.md. + ### Tariff (all optional) | option | meaning | @@ -1647,6 +1707,30 @@ under `peak_cap_w`, and if the forecast goes over the cap the charge-only clamp is dropped so the battery can shave the peak instead. Money outranks the maintenance schedule. +With two or more batteries, the one being emptied gives its drain rate and the +others only add power when the house needs more; none charges while it drains +(SAFETY-78). Below the drain rate the rest is exported, as with one battery. +If the house would still go over the peak limit, the battery being emptied +gives more than its drain rate, with one battery too (SAFETY-83). +A battery at its peak reserve gives last: the others give their full power +first, and the battery being emptied rises above its drain rate before it +(SAFETY-91). +In a cheap window the battery being filled charges and the others only add +charge; none gives while it charges. The charge is cut to what keeps the +15-minute peak at the limit minus `peak_margin_w` (SAFETY-84). +While a full battery rests after its charge (the hold), it gives just enough +to keep the 15-minute peak at that aim if the house needs it; with two +batteries the other one gives first. The hold still ends on its clock +(SAFETY-86). +When a charge ends, the other batteries take over one slew step a cycle, +and none starts charging from the grid (SAFETY-93). +When a battery's link drops, the others take over one slew step a cycle, +and control does not stall on the dropped battery's power (SAFETY-96). +When a drain ends (done, Off, timeout, abort, lost link, refused), the +other batteries cover the house at once but do not export its drain; then +they move one slew step a cycle (SAFETY-95). While that battery settles, +an old battery reading does not move them (SAFETY-103). + #### Capacity-tariff exposure of the grid-following loop ⚠️ **This section described the exposure BEFORE SAFETY-07. The loop is now @@ -1715,10 +1799,17 @@ ceiling at the cap, so a broken instrument makes the guard stricter, never loose **When it cannot see, it forbids importing.** Three states are not evidence and are checked before the projection is graded: no open quarter (a fresh averager projects a confident 0 W, which is under every ceiling there is), a meter gap -longer than `meter_max_age_s`, and SAFETY-18's frozen-meter conjunction. In all -three the claim becomes a floor of **0 W**. That is the backstop for the frozen -meter: the alarm arrives at `meter_freeze_s` (300 s) of a 900 s billing quarter -and commands nothing, and this stops the import. +longer than `meter_max_age_s` while it lasts, and SAFETY-18's frozen-meter +conjunction. In all three the claim becomes a floor of **0 W**. That is the +backstop for the frozen meter: the alarm arrives at `meter_freeze_s` (300 s) of +a 900 s billing quarter and commands nothing, and this stops the import. + +**After a gap it goes on (SAFETY-90).** The seconds the meter missed count at +the higher of the peak limit and the import seen just before and just after +the gap, with the batteries at 0 W. Seconds before the add-on's first reading +count the same way, at the import seen first, and the page says part of the +period was not measured. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md has the +bound. **It trips, where the freeze detector only notifies,** and the captures are why. On the healthy reference day (2026-08-23) it engages on **zero** of 95 quarters. @@ -1739,7 +1830,8 @@ control action and goes to the action log and the status page. A *saturated* guard — asking for everything the inverter has while the quarter is still projected over — raises one notification (`goodwe_peak_saturated`), because that is the one state where the money will be spent and shedding load is the only -thing left that changes the bill. +thing left that changes the bill. SAFETY-81 keeps a 1 s meter spike, noise and +a quarter's last seconds from raising it: see `peakguard.load_too_high()`. **And it sizes a price-driven charge, not only a forced discharge (SAFETY-45).** The same arithmetic read the other way answers *"how hard may this site charge @@ -1780,7 +1872,9 @@ exactly like the SoC drift the monthly maintenance cycle exists to correct. It will discharge straight past this number and keep going until the pack's own BMS cut-out stops it. What you are promised by `peak_soc_floor: 20` is *"the capacity-peak guard will not spend the pack for money below 20 %"*, and - **nothing more**. See below for what that costs on a real evening. + **nothing more**. See below for what that costs on a real evening. With two + or more batteries, one under it is used last: only once the others give + their full power (SAFETY-91). - **When it binds you are told**, once per episode, with notification `goodwe_peak_soc_floor` and a red status-page row reading *Battery at its peak reserve* (`PEAK_GUARD_FLOOR_LIMITED` in the log): the quarter **will** @@ -1925,6 +2019,15 @@ over the rating is logged as a software fault, at `ERROR`, and it has its own notification budget. Why: [`DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md`](DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md). +The integrator bound counts only on a cycle the loop decided. While +maintenance, the peak guard or a safety limit sets a battery to discharge, the +same alarm fires only when that battery gives more than `saturation_w` less +than the least it was told over the plant's settle time (at least 23 s), for +`saturation_cycles` cycles. A charge is not graded: a full battery takes less +as it tops up. At two or more inverters the loop does not wind while +maintenance holds a battery. +Why: [`DECISIONS/SAFETY-85-no-false-not-keeping-up.md`](DECISIONS/SAFETY-85-no-false-not-keeping-up.md). + ### The frozen-meter alarm A meter that has died is easy — it stops answering, the sample age climbs past @@ -3094,7 +3197,10 @@ The battery gives less power than the add-on asks for. This is normal for a short time, for example just after a large load switches on or the sun comes out. It is not a fault, and control keeps running. If it keeps coming back, check that the battery is switched on, and that it is not empty (when it should -give power) or full (when it should charge). +give power) or full (when it should charge). During the monthly maintenance or +while the peak limit is guarded, it only shows when the battery gives +clearly less than it was told. The status page counts how many times it +happened. ### Power request too high @@ -3129,6 +3235,10 @@ and what to do: - **A battery cut-out level is not known.** Fill in “Battery cut-out level” for each inverter in the settings. +With several inverters, a battery at its peak reserve does not give for the +peak and the others do. This alarm then comes only when the others cannot +keep the period under the limit alone. Control keeps running meanwhile. + The peak reserve only applies to keeping the peak down. Normal home use can still draw the battery below it, down to the battery cut-out level, where the battery switches itself off. @@ -3164,6 +3274,14 @@ reading is stuck. Control keeps running and keeps following the reading, so a stuck reading can raise this month's peak. Check the meter. If it is stuck, stop control on the add-on page. +### Battery cycle did not finish + +The monthly battery cycle stopped because the battery did not empty, or did not +fill up, in time. The battery is back under normal control. The add-on waits 7 +days before it tries again, so it does not empty the battery every night. To +try sooner, start a cycle on the add-on page. If it keeps happening, check that +the battery follows its commands and that the cheap hours leave time to charge. + ### For installers: notifications This part is for installers. The texts live in `app/messages.py` and follow @@ -3183,6 +3301,7 @@ default `info` hides it. | Inverters working against each other | alarm | `goodwe_circulating_power` | SAFETY-31 | | *name* is not delivering | alarm | `goodwe_not_delivering_` | SAFETY-42 | | Meter reading may be stuck | alarm | `goodwe_meter_frozen` | SAFETY-18 | +| Battery cycle did not finish | warning | `goodwe_maint_failed_` | MAINT-13 | **The Details link.** Home Assistant 2026.2 and newer show add-on pages at `/config/app//documentation`; 2026.5 removed the old @@ -3195,8 +3314,9 @@ section without a link. The documentation tab adds no heading anchors, so the link cannot jump to the section. **Battery is not keeping up.** The grid-following loop's integrator reached its -anti-windup bound (`integrator_max_w`, SAFETY-40). No command went over the -rating. The log line is a WARNING with reason code `OUTPUT_CLAMP`. See *The +anti-windup bound (`integrator_max_w`, SAFETY-40), or a battery that another +owner set to discharge gave less than it was told (SAFETY-85). No command went +over the rating. The log line is a WARNING with reason code `OUTPUT_CLAMP`. See *The winding-loop alarm*. **Power request too high.** A computed command above a limit was cut before it @@ -3211,9 +3331,12 @@ reason code `OUTPUT_CLAMP`. **Load too high for battery.** The capacity-peak guard (SAFETY-07) asks for everything the inverter can discharge and the running 15-minute average is still projected over the peak limit, so the month's billed peak -(capaciteitstarief) goes up. In shadow mode the guard's projection is made on a -plant that did not respond (SAFETY-47), and the text says so. See *The -capacity-peak guard*. +(capaciteitstarief) goes up. It fires only when, for 10 s, the period would +still end more than 10 W over the real limit (not the margin's aim) with the +battery at full power. It notifies at most once per period; later overloads +in the same period go to the log (SAFETY-81). In shadow mode the +guard's projection is made on a plant that did not respond (SAFETY-47), and +the text says so. See *The capacity-peak guard*. **Battery stopped limiting the peak.** The peak guard may not discharge below `peak_soc_floor` (label “Peak reserve”, SAFETY-24). It also stops when the @@ -3221,7 +3344,9 @@ state of charge is unread, or when an inverter's `bms_soc_floor` (label “Battery cut-out level”) is missing, because the reserve is graded against it. Charging from the grid stays blocked meanwhile. With several inverters the notification names the battery at its limit (SAFETY-32), each graded against -its own cut-out level. ⚠️ **The reserve binds the peak guard only +its own cut-out level. That battery is held (weight 0 while the site gives) +and the guard's need goes to the others, up to their rating; the alarm fires +only if they miss the limit at full power for `ALARM_HOLD_S` (SAFETY-87). ⚠️ **The reserve binds the peak guard only (SAFETY-25).** Self-consumption has no such floor. See *What `peak_soc_floor` does not protect*. @@ -3353,13 +3478,14 @@ for a new install*. | Row starts with | What it tells you | |---|---| +| Home Assistant is not answering | Home Assistant is not passing readings or power requests on, usually because it is still starting. The add-on cannot control until then. It resumes by itself. Nothing to do unless it lasts. | | Control | When control last ran. Red when it has stopped running: the control board then sets the inverter to 0 W. | | Meter | The grid reading, its age, how many readings were refused, and how long its value has not changed. Just after a start it is amber and *waiting for the first reading*. Red means no reading for longer than the limit. | | Stuck-meter check | Whether the add-on can warn you about a stuck meter. | | Meter reading may be stuck | See *Meter reading may be stuck* under Notifications. | | Month peak | This month's highest 15-minute average, when it happened, and how close it is to your peak limit. Amber from 80 %. | | 15-minute periods measured this month | How many 15-minute periods the add-on has measured and saved this month. | -| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. *Idle, heading for …* is where this 15-minute period is heading. *Waiting for meter readings* after a restart means the battery does not charge until the add-on can measure the period again. | +| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. *Idle, heading for …* is where this 15-minute period is heading. *Waiting for meter readings* after a restart means the battery does not charge until the add-on can measure the period again. A red peak row is an alarm while control keeps running, so the banner shows its title without *Not ready*. | | Battery level, Battery power, Grid power | The reading of that entity. *No usable reading* means the entity is missing, or its reading is too old. Check it in Developer Tools before replacing it. | | Inverter control | Whether the inverter can be controlled, and how much it can charge and give. | | *name*: battery power reading stopped changing | The reading did not change while the add-on asked for a change. It may be stuck, or the battery may be refusing. The add-on sets the inverters to 0 W until it changes. | @@ -3399,7 +3525,8 @@ reason code, and its full figures are on the DEBUG line right after it and in …`, and the peak guard's full reason is on the DEBUG line after its ERROR. - ***name*: battery power reading stopped changing** is SAFETY-37/56/57. The ERROR line says when it clears and names `STALE_READING`. The full reason, - with both possible causes, is on the DEBUG line after it. + with both possible causes, is on the DEBUG line after it. The 35 s counts + only while the command asks the reading to move (SAFETY-94). - **Meter: no reading for …** (red): the add-on logs one WARNING when the meter goes quiet, "No meter reading for …", and an INFO line when readings return, at most once every 10 minutes. Past `stale_input_s` the **Why** @@ -3410,6 +3537,13 @@ reason code, and its full figures are on the DEBUG line right after it and in (TARIFF-11), in words. - **Solar production** reads `pv_entity` or `pv_entities` (TEL-11/12). The *Reversed-sign solar entities* setting is `pv_invert_entities` (TEL-14). +- **Home Assistant is not answering** (SAFETY-112): the Supervisor answers + 502 to every add-on read, write and websocket until Home Assistant is + RUNNING, even when its entities are already live. A slow integration at + start-up therefore holds control for as long as it holds Home Assistant's + start. The log has one WARNING, a "Still waiting" INFO line every 5 + minutes and "answers again after …" at the end. See + `DECISIONS/SAFETY-112-waiting-for-home-assistant.md`. - **Shadow mode** (SAFETY-10): the banner turns red when a latch is up (circulating power, or a site or per-inverter sign trip), and that latch keeps its own red row. diff --git a/goodwe_controller/app/allocator.py b/goodwe_controller/app/allocator.py index 9ddef8b..5640fbf 100644 --- a/goodwe_controller/app/allocator.py +++ b/goodwe_controller/app/allocator.py @@ -344,6 +344,21 @@ class _Narrowed: self.lo, self.hi = lo, hi +def pin_w(b): + """The watts a unit's own SET places once held inside its hard limits, + as `plan()` places a standing pin, or None when the unit has no SET.""" + pv = getattr(b, "pinned_w", None) + if getattr(b, "gate", None) is None or pv is None: + return None + lo, hi = _int_envelope(b) + return float(pv) if lo > hi else float(_clamp_int(round(float(pv)), lo, hi)) + + +def hi_w(b): + """A unit's upper hard limit as `plan()` narrows it to whole W (SAFETY-84).""" + return float(_int_envelope(b)[1]) + + def loop_bound(bounds, live=None): """OPT-11. `(lo, hi)`: what the units can place when the LOOP decides. @@ -364,11 +379,8 @@ def loop_bound(bounds, live=None): continue if getattr(b, "contradiction", False): continue - pv = getattr(b, "pinned_w", None) - if getattr(b, "gate", None) is not None and pv is not None: - lo, hi = _int_envelope(b) - p = float(pv) if lo > hi else float(_clamp_int(round(float(pv)), - lo, hi)) + p = pin_w(b) + if p is not None: lo_sum += min(0.0, p) hi_sum += max(0.0, p) continue diff --git a/goodwe_controller/app/arbiter.py b/goodwe_controller/app/arbiter.py index 5d69d1d..8511be6 100644 --- a/goodwe_controller/app/arbiter.py +++ b/goodwe_controller/app/arbiter.py @@ -76,6 +76,12 @@ from . import allocator P_SAFETY = 100 # stopped, inputs missing, shutting down, device limits P_MAINTENANCE = 60 # maintenance owns the actuator outright (drain, hold) P_PEAK = 50 # capacity-tariff peak shaving - costs real money +# The order in effect: safety limits and the SAFETY-73 refusal > the peak +# guard > maintenance > modes > trade > loop. A peak floor above a drain still +# binds: the `drain` site floor at N>=2 (SAFETY-78), refiled at P_MAINTENANCE +# at N=1 (SAFETY-83); over a HOLD, `_peak_over_hold()` (SAFETY-86). +# A cheap-window charge is suspended while the peak guard holds and, at N>=2, +# sized to the quarter's headroom (SAFETY-84). P_MAINT_SHAPE = 40 # maintenance shaping the loop (charge-only) # OPT-04. §10.1 priority 3 - price arbitrage. ⚠️ ITS POSITION IS THE SPEC, NOT A # preference: P_PEAK (50) is ABOVE it so clause 2 lets the capacity-tariff floor @@ -184,6 +190,22 @@ CLAIM_SCOPE = { # the banner and the claim an install has had since SAFETY-02 shipped. # `Controller.cycle()` does not even arm the site detector there. "sign_site": SCOPE_SITE, + # main.py: `Claim.limit("drain", P_MAINTENANCE, lo=...)`, SAFETY-78. At + # N>=2, while a pack's maintenance drain is pinned, the site target may + # not go below what that pin places: below it a sibling would have to + # charge against the drain (circulation, refused, 0 W). A site `lo`, + # never filed at N=1. DECISIONS/SAFETY-78-drain-at-two.md. + "drain": SCOPE_SITE, + # main.py: `Claim.limit("charge", P_MAINT_SHAPE, hi=...)`, SAFETY-84. The + # mirror of `drain` for a cheap-window maintenance charge at N>=2: the site + # target may not go above what the charging pack's own `hi` asks, or a + # sibling would have to give against it. Never filed at N=1. + # DECISIONS/SAFETY-84-charge-at-two.md. + "charge": SCOPE_SITE, + # main.py: `Claim.limit("hold", P_MAINTENANCE, hi=...)`, SAFETY-86. While a + # holding pack gives the peak shortfall at N>=2, the site target stays at + # the peak floor, what the pin and the others place. Never filed at N=1. + "hold": SCOPE_SITE, # --- UNIT: about one pack, one inverter, one device ------------------- # main.py: `Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max, @@ -613,7 +635,7 @@ def site_resolve(claims: list) -> Resolution: def resolve(claims: list, *, units=None, weights=None, live=None, - tick=None) -> Resolution: + tick=None, reserve=()) -> Resolution: """The three stages, in order, over one cycle's claims. `units` is the site's roster of inverters. Left None it is derived from the @@ -715,6 +737,13 @@ def resolve(claims: list, *, units=None, weights=None, live=None, gate, held = _unit_modes(out, site_claims, bounds, live) if callable(weights): weights = weights(out.target_w) + # SAFETY-87/91: units under their peak reserve weigh 0 while the site + # gives, so the others carry the target first. An Off battery gives the + # peak's shortfall, so it goes before them when no other unit is left. + reserve = set(reserve) if out.target_w > 0.0 else set() + if held and reserve and not set(bounds) - held - reserve: + held = set() + held = held | reserve if held: weights = {u: (0.0 if u in held else (1.0 if weights is None else weights.get(u, 1.0))) diff --git a/goodwe_controller/app/clamp.py b/goodwe_controller/app/clamp.py index 31aeb80..16816a3 100644 --- a/goodwe_controller/app/clamp.py +++ b/goodwe_controller/app/clamp.py @@ -260,6 +260,22 @@ def loop_clamp_note(d) -> str: return "" +def shortfall_note(named) -> str: + """SAFETY-85: the alarm text while another owner sets the batteries. + `named` is `[(inverter name or None, asked W, given W)]`, one per battery + that gave less than asked.""" + return f"{REASON_CODE}: " + ", ".join( + f"{name or 'the battery'} gives {given:+.0f} W of the {asked:+.0f} W " + f"asked" for name, asked, given in named) + + +def delivery_short(asked_w, given_w, margin_w) -> bool: + """SAFETY-85: did a battery asked to discharge give more than `margin_w` + less? A charge is never short: a full pack tapers (SAFETY-42 section 7).""" + asked, given = float(asked_w), float(given_w) + return asked > 0.0 and asked - given > abs(float(margin_w)) + + def write_clamp_note(named) -> str: """The write-side alarm text: `named` is `[(inverter name or None, ClampResult)]` for every inverter whose write was cut back this cycle. @@ -382,6 +398,10 @@ class ClampWatchdog: self.over_rating = 0 # bug=True: "Power request too high" self.bound = 0 # bug=False: "Battery is not keeping up" self.episodes = 0 # rising edges + # SAFETY-85: episodes with a bug=False engagement. The status row + # counts these; counting cycles read 861 on one rig run. + self.bound_episodes = 0 + self._bound_counted = False self.since: datetime | None = None self.last_alarm: datetime | None = None self.reason = "" @@ -436,6 +456,10 @@ class ClampWatchdog: self.spoken = False self.bug = False self.bug_spoken = False + self._bound_counted = False + if not bug and not self._bound_counted: + self._bound_counted = True + self.bound_episodes += 1 if bug or not self.bug: self.reason = reason self.facts = tuple(facts) diff --git a/goodwe_controller/app/control.py b/goodwe_controller/app/control.py index 8e01509..3484292 100644 --- a/goodwe_controller/app/control.py +++ b/goodwe_controller/app/control.py @@ -382,6 +382,9 @@ class Decision: # SAFETY-52. The refused direction to carry back in (+1 discharge, -1 # charge, 0 none), or None when the caller does not carry it. refused: int | None = None + # SAFETY-85. The `bound` held the integrator or the command this cycle, + # so the batteries' limits, not the loop, decided it. + held: bool = False def slew_limit_w(tuning: Tuning) -> float: @@ -481,6 +484,8 @@ def compute( refused: int | None = None, probe: bool = False, bound: tuple | None = None, + gave_nothing: bool | None = None, + idle_w: float = 0.0, ) -> Decision: """One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1). @@ -494,6 +499,13 @@ def compute( `bound` OPT-11: `(lo, hi)`, what the batteries may place this tick with their modes applied (`allocator.loop_bound()`), or None. See MODE_BOUND below. None is the law as it was. + `gave_nothing` SAFETY-94: the caller's span check - the reading stayed + under REFUSED_BELOW_W, one way, for SETTLE_CYCLES spanning + REFUSED_SPAN_S. A refusal engages only with it. None leaves it + out (compute() alone, as before). + `idle_w` SAFETY-97: what `actual_w` reads while the batteries give + nothing (app/idle.py). The refusal's band and its end are + centred on it. 0 is the law as it was. """ reason = "tracking" # OPT-11: a bound that leaves out 0 W would FORCE a direction, which no @@ -614,11 +626,13 @@ def compute( # OPT-11: the integrator does not wind past what the modes let the # batteries place. Only a side tighter than the rail is left in `bound` # (above), so this never takes SAFETY-03's `i_clamped` away from the rail. + held_by_bound = False if bound is not None: held = max(float(bound[0]), min(float(bound[1]), i_w)) if held != i_w: i_w = held reason = MODE_BOUND + held_by_bound = True # ⚠️ Applied EVERY cycle, frozen or not: the freeze is conditional, this # bound is not. It is what makes the worst-case unwind time finite and @@ -812,13 +826,18 @@ def compute( # `saturation_w` is a refusal, not a slow plant. The drop to 0 skips the slew # limit because it only removes an ask the battery is not meeting. if refused is not None: + # SAFETY-94: and only when the reading has given nothing for the + # whole span, not just now. `sat_count` also counts the cycles a + # healthy battery spends reversing through its lag (-2000 W towards + # +800 W): at a 1 s meter its 12 cycles end as the reading passes 0. if (refused == 0 and frozen and sat_count >= SETTLE_CYCLES and abs(prev_w) > tuning.saturation_w - and abs(actual_w) < REFUSED_BELOW_W): + and abs(actual_w - idle_w) < REFUSED_BELOW_W + and gave_nothing is not False): refused = 1 if prev_w > 0 else -1 if refused and refused * i_w < 0: refused = 0 # the loop wants the other way - elif refused and refused * actual_w >= PROBE_ANSWER_W: + elif refused and refused * (actual_w - idle_w) >= PROBE_ANSWER_W: refused = 0 # it delivers: a probe answered, # or a slow reading arriving elif refused: @@ -839,6 +858,7 @@ def compute( if inside != target: target = inside reason = MODE_BOUND + held_by_bound = True # SAFETY-48's lead cap above may pull the integrator back toward the # slewed command; what is CARRIED stays inside the bound too. i_w = max(float(bound[0]), min(float(bound[1]), i_w)) @@ -860,7 +880,7 @@ def compute( return Decision(float(target), sat_count, frozen, reason, float(i_w), i_clamped, clamped, float(i_bound_w), float(want), - float(clamped_w), refused) + float(clamped_w), refused, held_by_bound) def maintenance_charge_floor( diff --git a/goodwe_controller/app/dayplan.py b/goodwe_controller/app/dayplan.py new file mode 100644 index 0000000..4bd8ecc --- /dev/null +++ b/goodwe_controller/app/dayplan.py @@ -0,0 +1,262 @@ +"""OPT-11 P4: the day plan. Will the sun fill each battery before the +expensive hours? If not, how much should it take from the grid? + +Pure: the forecast (`solarfc`), the house profile, the price runs and the +batteries in; a level per battery (`Plan.targets`) out. `modetrade.charge_to()` +turns the level into permission to charge from the grid; main.py rebuilds the +plan with the price runs (at most every 15 minutes) and keeps a level unless +it moves by `HYST_KWH` or `HYST_FRAC` (`hold()`). + +The sums (OPT-11-design section 1, "Excess solar", with the owner's answers): +* `sun`: trust x the solar the house will not use, between now and the + expensive run the cheap run is for, x the charge efficiency: + `trust x sum_h max(0, forecast_h - house_h) x EFF`. +* Solar first batteries take it first, up to the ceiling. What is left is + shared by the Leftover solar batteries by their room. +* Cheap hours only ignores the plan and fills to the ceiling, as in P3 and + the design (section 1). Skipping the sun's share cost money in the replay + (PR #156 review: 29.72 against 29.41 EUR on real 2026 prices). +* A Leftover solar battery charges only up to what the house will draw from + it in the expensive runs before the next cheap run, less its share: + `min(ceiling, floor + house use / EFF) - share`. So it buys only the + shortfall, not up to the ceiling. +* Solar first never charges from the grid (owner, 2026-09-29). + +The house profile is the add-on's own measurement, grid + battery power, per +local hour, over the last `PROFILE_DAYS` days; the estimate for an hour is the +median of that hour. It is net of the solar the house used itself, so for +the solar sum it is never taken below the night-time median (`base()`). +The site's own solar readings are not used: TEL-11 keeps them out of every +control decision. +""" + +import statistics +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +from .packmode import LEFTOVER_SOLAR, SOLAR_FIRST +from .tariff import BILLING_TZ + +EFF = 0.95 # charge (and discharge) efficiency +HYST_KWH = 1.0 # a kept level moves only by this much... +HYST_FRAC = 0.10 # ...or this share of the battery's shortfall +PROFILE_DAYS = 7 +MIN_HOURS = 20 # hours of a day recorded before it counts +NIGHT_HOURS = (0, 1, 2, 3, 4, 5, 22, 23) +DEFAULT_TRUST_PCT = 70 + +# Site verdicts (the page's forecast line). +SUNNY, PARTLY, DARK = "sunny", "partly", "dark" + + +# --------------------------------------------------------------------------- # +# The house profile +# --------------------------------------------------------------------------- # +def _local(t: datetime): + return t.astimezone(BILLING_TZ) + + +class HouseProfile: + """Hourly means of the house's net use (grid + battery power), kept per + local day and hour: `{"YYYY-MM-DD": {hour: [mean_w, samples]}}`. The + store holds `dump()`; `load()` takes it back and drops what is bad.""" + + def __init__(self, data=None): + self.days = {} + self._cur = None # (day, hour, sum, n) + if isinstance(data, dict): + self.load(data) + + def load(self, data): + for day, hours in data.items(): + try: + datetime.strptime(str(day), "%Y-%m-%d") + except ValueError: + continue + if not isinstance(hours, dict): + continue + row = {} + for h, v in hours.items(): + try: + hi, w = int(h), float(v) + except (TypeError, ValueError): + continue + if 0 <= hi < 24 and abs(w) < 1e6: + row[hi] = w + if row: + self.days[str(day)] = row + + def dump(self) -> dict: + return {d: {str(h): round(w, 1) for h, w in sorted(r.items())} + for d, r in sorted(self.days.items())} + + def add(self, now: datetime, house_w) -> bool: + """One reading. Returns True when an hour closed (then store it).""" + lt = _local(now) + key = (lt.strftime("%Y-%m-%d"), lt.hour) + closed = False + if self._cur is not None and self._cur[:2] != key: + day, hour, s, n = self._cur + if n >= 60: # about 5 minutes of readings + self.days.setdefault(day, {})[hour] = s / n + closed = True + self._cur = None + self._prune(lt) + if house_w is None: + return closed + if self._cur is None: + self._cur = (key[0], key[1], 0.0, 0) + d, h, s, n = self._cur + self._cur = (d, h, s + float(house_w), n + 1) + return closed + + def _prune(self, lt): + """Keep the last `PROFILE_DAYS` days; a day after today (the clock + was wrong once) would never age out, so it goes too.""" + keep = (lt.date() - timedelta(days=PROFILE_DAYS)).isoformat() + today = lt.date().isoformat() + for d in [d for d in self.days if d < keep or d > today]: + del self.days[d] + + def ready(self) -> bool: + """At least one day with `MIN_HOURS` hours recorded.""" + return any(len(r) >= MIN_HOURS for r in self.days.values()) + + def hour_w(self, hour: int): + vals = [r[hour] for r in self.days.values() if hour in r] + return statistics.median(vals) if vals else None + + def base(self): + vals = [w for h in NIGHT_HOURS if (w := self.hour_w(h)) is not None] + return statistics.median(vals) if vals else None + + def estimator(self): + """`f(hour) -> W`, or None when not ready. Fixed at call time.""" + if not self.ready(): + return None + base = self.base() or 0.0 + table = {} + for h in range(24): + w = self.hour_w(h) + table[h] = base if w is None else w + return lambda h: table[h], max(0.0, base) + + +# --------------------------------------------------------------------------- # +# The plan +# --------------------------------------------------------------------------- # +@dataclass(frozen=True) +class Plan: + verdict: str # SUNNY / PARTLY / DARK + sun_kwh: float # solar the batteries can count on + fc_kwh: float # the forecast over the same span + until: datetime # the expensive run the plan is for + targets: dict # name -> level % (Leftover solar only) + need_kwh: dict # name -> kWh from the grid (0: none) + share_kwh: dict # name -> kWh of solar counted for it + + +def _hour_slices(t0, t1): + """Local-hour slices of [t0, t1): (start, end, local hour).""" + out, t = [], t0 + while t < t1: + lt = _local(t) + nxt = (lt.replace(minute=0, second=0, microsecond=0) + + timedelta(hours=1)).astimezone(timezone.utc) + e = min(nxt, t1) + out.append((t, e, lt.hour)) + t = e + return out + + +def _room(p, to_pct): + if p.capacity_kwh is None or p.soc is None: + return 0.0 + return max(0.0, float(to_pct) - float(p.soc)) / 100.0 * float( + p.capacity_kwh) + + +def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct, + ceiling_pct): + """The day plan, or None when there is none to make: no forecast, no + house profile yet, no price plan, or no expensive run after the cheap + run. `house` is `HouseProfile.estimator()`'s answer. `packs` are + `modetrade.Pack`s.""" + if forecast is None or house is None or runs is None or not runs.on \ + or runs.cheap is None: + return None + house_w, base_w = house + until = next((g for g in runs.give if g.start >= runs.cheap.end), None) + if until is None: + return None + trust = max(0.0, min(1.0, float(trust_pct) / 100.0)) + sun = fc_sum = 0.0 + for s, e, h in _hour_slices(now, until.start): + pv = forecast.between(s, e) + if pv is None: + return None # the span reaches a day with no forecast + hrs = (e - s).total_seconds() / 3600.0 + use = max(house_w(h), base_w) * hrs / 1000.0 + sun += max(0.0, pv - use) + fc_sum += pv + sun *= trust * EFF + + usable = [p for p in packs if p.use and p.live and p.idle + and p.soc is not None and p.capacity_kwh] + sf = [p for p in usable if p.mode == SOLAR_FIRST] + price = [p for p in usable if p.mode == LEFTOVER_SOLAR] + sf_room = sum(_room(p, ceiling_pct) for p in sf) + excess = max(0.0, sun - sf_room) + rooms = {p.name: _room(p, ceiling_pct) for p in price} + total_room = sum(rooms.values()) + + # What the house draws in the expensive runs from the one this plan is + # for until the next cheap run (or a day), from the profile. + horizon = until.start + timedelta(hours=24) + use_kwh = 0.0 + for g in runs.give: + if g.start < until.start or g.start >= horizon: + continue + for s, e, h in _hour_slices(g.start, g.end): + use_kwh += max(0.0, house_w(h)) * ( + e - s).total_seconds() / 3600.0 / 1000.0 + caps = {p.name: float(p.capacity_kwh) for p in price} + cap_sum = sum(caps.values()) or 1.0 + + targets, need, share = {}, {}, {} + for p in price: + sh = excess * rooms[p.name] / total_room if total_room > 0 else 0.0 + sh_pct = sh / caps[p.name] * 100.0 + want = use_kwh * caps[p.name] / cap_sum / EFF + top = min(float(ceiling_pct), + float(floor_pct) + want / caps[p.name] * 100.0) + level = max(0.0, top - sh_pct) + targets[p.name] = round(level, 2) + need[p.name] = round(_room(p, level), 3) + share[p.name] = round(sh, 3) + if sun < sf_room: + verdict = DARK + elif any(v > 0.05 for v in need.values()): + verdict = PARTLY + else: + verdict = SUNNY + return Plan(verdict, round(sun, 3), round(fc_sum, 3), until.start, + targets, need, share) + + +def hold(old: dict | None, new: dict, capacity: dict) -> dict: + """Hysteresis: a battery keeps its old level unless the new one differs + by at least `HYST_KWH`, or by `HYST_FRAC` of the larger level's energy + (whichever is less). A battery new to the plan takes its level at once; + one that left the plan is dropped at once.""" + out = {} + for name, level in new.items(): + was = (old or {}).get(name) + cap = float(capacity.get(name) or 0.0) + if was is None or cap <= 0: + out[name] = level + continue + moved = abs(level - was) / 100.0 * cap + step = min(HYST_KWH, HYST_FRAC * max(was, level) / 100.0 * cap) + out[name] = level if moved >= step else was + return out diff --git a/goodwe_controller/app/freshness.py b/goodwe_controller/app/freshness.py index 7281d7a..ba577c8 100644 --- a/goodwe_controller/app/freshness.py +++ b/goodwe_controller/app/freshness.py @@ -87,6 +87,10 @@ when it has not moved THROUGH A CHANGE IT WAS OBLIGED TO FOLLOW: AND |standing command now - standing command when the reading last changed| >= MOVE_W +SAFETY-94: "unchanged for" counts only the reads at which both command terms +held, so a register that rested is not judged before it could answer. +DECISIONS/SAFETY-94-lagging-battery.md. + 0x0518 is the inverter reporting ITSELF and it tracks its own command: r = 0.998 with battery power against r = 0.09 with the real meter (goodwe/NOTES.md §3.3), command and actual agreeing within 4 W once settled (§5.10, 2026-08-22), and the @@ -216,6 +220,8 @@ change - which was the ONLY exit - has a median of 143 s, 17 episodes at or over the live one's 710 s, and a worst case of 21440 s. That distribution is a property of this battery's firmware. It is not a property of this code, and the add-on must not depend on it. +SAFETY-94: many were the gate judging a rested register on its first read +(SAFETY-58). Counted from the ask, the same days give 59 trips. THE RULE, and it is SAFETY-13's shape one module over - "which latches get an exit, not how long the block lasts": @@ -372,7 +378,9 @@ BOUNDS = { "SAFETY-57: the verdict is withdrawn after 17.0 s of the " "standing command below 250 W, because at 0 W the register " "is obliged to nothing and the verdict cannot be tested " - "again from inside the state it creates"), + "again from inside the state it creates. SAFETY-94: the " + "35 s counts only reads at which the command asks the " + "register to move"), "soc": Bound( signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)", unchanged_s=float("inf"), @@ -416,11 +424,9 @@ class Freshness: new grading window at that instant. The 1 Hz oscillation the latch exists to prevent is still prevented: the new window is `bound.unchanged_s` wide, so no re-trip can land sooner than 35 s after a release, rather than on the - next read. ⚠️ MEASURED FROM THE RELEASE, NOT FROM THE COMMAND RESUMING - if - the site rests for longer than the window before it asks for anything, the - window is already old and the re-trip lands on the first read at which the - command travels 250 W again. That is survivor 5's shape, it predates this - card, and its cost is one `stale_input_s` hold. + next read. SAFETY-94: the 35 s counts only while the command asks the + register to move, so a site that rests after a release is not re-tripped + on its first ask. ⚠️ TWO CLOCKS, AND THE DISTINCTION IS THE WHOLE OF SAFETY-57'S HONESTY. `changed_mono` / `held` are facts about the REGISTER: when it last carried a @@ -451,6 +457,11 @@ class Freshness: # the change-stamps; `_release()` is where they diverge. self.graded_from = None self.standing_at_grade = None + # SAFETY-94. Seconds of this window during which the register was + # obliged to move (the command at least `move_w` and `move_w` away + # from `standing_at_grade`), and the instant of the last read. + self.obliged_acc = 0.0 + self.last_read = None # The instant the standing command last FELL below `move_w`, or None # while an obligation is standing. The release timer runs off this. self.rested_since = None @@ -499,6 +510,7 @@ class Freshness: # it reads in the order the state machine moves - withdraw, then # re-grade - not because the code depends on it. self._release(standing_w, now_mono) + self._oblige(standing_w, now_mono) self._grade(standing_w, now_mono) def _open_window(self, standing_w, now_mono) -> None: @@ -510,6 +522,35 @@ class Freshness: self.graded_from = float(now_mono) self.standing_at_grade = (None if standing_w is None else float(standing_w)) + # The read that opens a window is never an ask (no travel yet). + self.obliged_acc = 0.0 + + def _obliged(self, standing_w) -> bool: + """Is the register asked to move: the command is at least `move_w` and + has travelled `move_w` since the window opened?""" + if standing_w is None or self.standing_at_grade is None: + return False + standing_w = float(standing_w) + return (abs(standing_w) >= self.bound.move_w + and abs(standing_w - self.standing_at_grade) + >= self.bound.move_w) + + def _oblige(self, standing_w, now_mono) -> None: + """SAFETY-94. Count only the time the register was asked to move. + + A register that held still while nothing asked it to move has not + failed, so time before the command moved is not evidence. The time + since the previous read counts when the obligation stands at this + read; a command flapping in and out of the obligation still adds up. + """ + now_mono = float(now_mono) + if self.last_read is not None and self._obliged(standing_w): + self.obliged_acc += max(0.0, now_mono - self.last_read) + self.last_read = now_mono + + def obliged_s(self) -> float: + """Seconds of the current window the register was obliged to move.""" + return self.obliged_acc def _release(self, standing_w, now_mono) -> None: """SAFETY-57. Withdraw a verdict whose obligation is no longer standing. @@ -543,7 +584,12 @@ class Freshness: if self.stale or self.graded_from is None: return held_s = self.graded_s(now_mono) - if held_s < self.bound.unchanged_s: + # SAFETY-94 (closes SAFETY-58): the bound counts only the time the + # command asked the register to move, not the time since it last + # changed. A register that rested for minutes was otherwise judged on + # the first read after a new command, before it could answer (live, + # 2026-10-03: 1 s after the command). + if self.obliged_s() < self.bound.unchanged_s: return # ⚠️ BOTH None GUARDS ARE FOR A CALLER THAT DOES NOT KNOW ITS OWN # OBLIGATION YET, WHICH IS NOT `_read_unit`. `Inverter.standing_w()` diff --git a/goodwe_controller/app/hass.py b/goodwe_controller/app/hass.py index 2f75fa7..d32ec0b 100644 --- a/goodwe_controller/app/hass.py +++ b/goodwe_controller/app/hass.py @@ -11,6 +11,8 @@ import time import aiohttp +from . import messages + _LOG = logging.getLogger("goodwe.hass") CORE_API = "http://supervisor/core/api" @@ -19,6 +21,7 @@ BAD_STATES = ("unknown", "unavailable", "none", "") # What the Supervisor's proxy answers while Home Assistant restarts. UNAVAILABLE = (502, 503, 504) STATES_TIMEOUT_S = 10 # UX-13's entity list, like every other read here +STILL_WAITING_S = 300 # SAFETY-112: one reminder line per 5 minutes down class Outage: @@ -29,15 +32,30 @@ class Outage: ("502, message='Bad Gateway', url=URL(...)", "Received message 8:1000 is not str"). Now the first channel to fail says it once at WARNING, the raw text goes to DEBUG, and the last channel to recover says so at INFO. + + SAFETY-112: the Supervisor answers 502 for every read, write and + websocket until Home Assistant has finished starting, however live its + entities already are (DECISIONS/SAFETY-112-waiting-for-home-assistant.md). + So an outage also says, every STILL_WAITING_S, that it is still on, and + `waiting()` puts it on the status page. """ def __init__(self): self.down_on = set() + self.since = None # monotonic start of this outage + self._told = None # when the last "still waiting" line went def down(self, channel: str, what: str, detail) -> None: + now = time.monotonic() if not self.down_on: _LOG.warning("Home Assistant is not answering (%s), perhaps it is " - "restarting. The add-on keeps trying.", what) + "still starting. Control waits and resumes by itself.", + what) + self.since = self._told = now + elif now - self._told >= STILL_WAITING_S: + self._told = now + _LOG.info("Still waiting for Home Assistant to finish starting, " + "%s so far.", messages.duration(now - self.since)) self.down_on.add(channel) _LOG.debug("Home Assistant not answering on %s: %s", channel, detail) @@ -45,7 +63,13 @@ class Outage: if channel in self.down_on: self.down_on.discard(channel) if not self.down_on: - _LOG.info("Home Assistant answers again.") + _LOG.info("Home Assistant answers again after %s.", + messages.duration(time.monotonic() - self.since)) + + def waiting(self) -> bool: + """True while Home Assistant's API does not answer. The API, not the + meter websocket: every battery reading and every write rides on it.""" + return "api" in self.down_on OUTAGE = Outage() diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index 72520d4..0e2ebfa 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -20,7 +20,7 @@ from datetime import datetime, timedelta, timezone from . import arbitrage, messages, peakguard, uitext from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD -from .maintenance import IDLE as M_IDLE +from .maintenance import IDLE as M_IDLE, next_slot_start from .packmode import CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, SOLAR_FIRST from .price import Absence, Uncertainty, brussels from .static_tariff import static_plan @@ -345,7 +345,10 @@ def mode_runs(c, day0, day1, now, shadow): else (soc - floor)) / 100.0 * cap) return kwh blocks = [] - if rp.charge_useful() and pm.mode in GRID_CHARGE_MODES: + # P4: with a day plan, the grid block is the planned shortfall. + need = plan_need(c, inv.name) + if rp.charge_useful() and (need > 0.0 if need is not None + else pm.mode in GRID_CHARGE_MODES): blocks.append(("grid", rp.cheap)) sell = rp.sell if pm.sell and pm.can_sell else None for g in rp.give: @@ -358,6 +361,8 @@ def mode_runs(c, day0, day1, now, shadow): continue hrs = (run.end - run.start).total_seconds() / 3600.0 kwh = room(kind, hrs) + if kind == "grid" and need is not None: + kwh = min(kwh, need) if kwh < MIN_KWH: continue price = run.mean_eur_kwh @@ -382,10 +387,60 @@ def mode_runs(c, day0, day1, now, shadow): return out, None +def plan_need(c, name): + """P4: kWh this battery buys from the grid by the day plan, or None when + there is no plan for it (then P3 option A applies).""" + targets = c.plan_targets() if hasattr(c, "plan_targets") else None + if not targets or name not in targets: + return None + inv = next((i for i in c.inverters if i.name == name), None) + if inv is None or inv.soc is None or not inv.capacity_kwh: + return None + return max(0.0, targets[name] - float(inv.soc)) / 100.0 * float( + inv.capacity_kwh) + + +def plan_state(c): + """P4: `"plan"` (a day plan is made), `"learning"` (no house profile + yet), `"no_forecast"` (set but missing or stale), or None (not set, or + battery modes off).""" + if not getattr(c, "modes_on", False) or \ + getattr(c, "fc_entity", None) is None: + return None + if getattr(c, "solar_fc", None) is None: + return "no_forecast" + hp = getattr(c, "house_profile", None) + if hp is not None and not hp.ready(): + return "learning" + return "plan" if getattr(c, "day_plan", None) is not None else None + + +def solar_block(c, now): + """P4: the solar forecast line for the Batteries screen and the Simple + settings view: `{state, fc_kwh, count_kwh, updated, line}`.""" + st = plan_state(c) + out = {"state": st, "fc_kwh": None, "count_kwh": None, "updated": None, + "line": ""} + if st is None and getattr(c, "solar_fc", None) is None: + return out + fc = getattr(c, "solar_fc", None) + if fc is not None: + trust = float(getattr(c, "solar_trust_pct", 70)) / 100.0 + out.update(fc_kwh=round(fc.today.kwh, 1), + count_kwh=round(fc.today.kwh * trust, 1), + updated=hhmm(fc.at)) + if st in ("no_forecast", "learning"): + out["line"] = _t("solar." + st) + elif fc is not None: + out["line"] = line_solar(fc.today.kwh, out["count_kwh"], + out["updated"]) + return out + + def maintenance_runs(c, day0, day1, now, shadow): """The monthly battery cycle: under way now, or starting later today. - The cycle starts in the hour `maintenance_start_hour` names, read as the - control cycle reads it (UTC hour of `now`), shown in local time.""" + The cycle starts in the hour `maintenance_start_hour` names, read in the + site's zone exactly as the control cycle reads it (MAINT-10).""" runs, lines = [], [] waiting = [] for i, (inv, m) in enumerate(zip(c.inverters, c.maints)): @@ -404,10 +459,9 @@ def maintenance_runs(c, day0, day1, now, shadow): if not m.due(now) and (m.next_due(now) is None or m.next_due(now) >= day1): continue - start = now.replace(hour=cfg.start_hour, minute=0, second=0, - microsecond=0) - if start + timedelta(hours=1) <= now: - start += timedelta(days=1) + start = next_slot_start(now, cfg.start_hour, m.tz) + if start is None: + continue due_at = m.next_due(now) if due_at is not None and due_at > start: continue @@ -640,6 +694,14 @@ def battery_rows(c, shadow, running, held=False): # carries its share (SAFETY-73)? other = next((o.name for o in c.inverters if o is not inv and refusing(c, o) > 0 and not _off(c, o)), None) + # SAFETY-78: another battery drains for its monthly cycle, so this + # one never charges now. Not while that drain waits: it files no + # floor then, so this battery may charge from solar. + waits = getattr(c, "_drain_waits", ()) + cycling = next((o.name for j, o in enumerate(c.inverters) + if o is not inv and j < len(c.maints) + and c.maints[j].phase == M_DRAIN + and o.name not in waits), None) role = None if tick is None else tick.roles.get(inv.name) # Only once a cycle has run with modes on (`tick`): before that # nothing is known, so nothing is claimed about the prices. @@ -655,7 +717,10 @@ def battery_rows(c, shadow, running, held=False): phase=phase, shadow=shadow, running=running, held=held, refused=refusing(c, inv), peak=peak, giving=flow(inv.batt) == "giving", other_refuses=other, - sf_ahead=sf_ahead, role=role, plan=plan, has_at=bool(at)) + sf_ahead=sf_ahead, role=role, plan=plan, has_at=bool(at), + other_cycle=cycling, + day=None if pm is None or pm.mode not in PRICE_MODES + else day_key(c, inv.name)) rows.append({ "name": inv.name, "level_pct": None if inv.soc is None else float(inv.soc), @@ -673,7 +738,8 @@ def battery_rows(c, shadow, running, held=False): # UX-13 P3: why it does what "now" says, from what the add-on # knows this cycle. Price and forecast reasons are OPT-11 P3/P4. "why_key": why, - "why": why_words(why, name=other, at=at), + "why": why_words(why, name=cycling if why == "other_cycle" + else other, at=at), }) if modes_on: # OPT-11 P1: the battery's switch and mode, for the Batteries @@ -689,10 +755,25 @@ def battery_rows(c, shadow, running, held=False): return rows +def day_key(c, name): + """P4: the day plan's word for one Leftover solar or Cheap hours + battery: `sun_fills`, `tops_up`, `no_forecast`, or None.""" + st = plan_state(c) + if st == "no_forecast": + mode = c.pack_mode(next(i for i in c.inverters if i.name == name)) + return "no_forecast" if mode.mode == LEFTOVER_SOLAR else None + if st != "plan": + return None + need = plan_need(c, name) + if need is None: + return None + return "tops_up" if need >= MIN_KWH else "sun_fills" + + def battery_why(*, modes_on, mode, off, reading, phase, shadow, running, held, refused, peak, sf_ahead, giving=False, other_refuses=None, role=None, plan=None, - has_at=False) -> str: + has_at=False, other_cycle=None, day=None) -> str: """The key of one battery's "why" line (`home_words.why`), most urgent first. Pure, so every branch is tested without a controller.""" if not reading: @@ -712,11 +793,15 @@ def battery_why(*, modes_on, mode, off, reading, phase, shadow, running, return "refuses_give" if refused > 0 else "refuses_charge" if peak: return "peak" + if other_cycle: + return "other_cycle" if giving and other_refuses: return "other_refuses" if not modes_on: return "modes_off" # OPT-11 P3: the battery's role in the price runs this cycle. + if role == "hold" and day is not None: + return day # P4: what the day plan decided if role in ROLE_WHY: if role == "hold" and not has_at: return "saves_later" @@ -818,17 +903,20 @@ def month_peak(c, now): if pg is not None: out["period_w"] = pg.projected_w ceiling = pg.ceiling_w or limit_w - out["line"] = line_period(out["period_w"], ceiling, c.shadow) + out["line"] = line_period(out["period_w"], ceiling, c.shadow, + unmeasured=getattr(c, "peak_unmeasured_s", 0.0) + > 0.0) return out -def state_of(c, level, shadow): +def state_of(c, level, shadow, waiting=False): """One word for the pill and the banner's colour: running, shadow, - stopped, alarm or warning.""" + stopped, alarm, waiting or warning. `waiting`: the red headline is + Home Assistant still starting (SAFETY-112), which needs nobody.""" if shadow: return "shadow" if level == "bad": - return "alarm" + return "waiting" if waiting else "alarm" if not c.auto: return "stopped" if level == "warn": @@ -837,24 +925,27 @@ def state_of(c, level, shadow): def build(c, *, level: str, checks: list, now: datetime | None = None, - lang: str = "en") -> dict: + lang: str = "en", held: bool | None = None, + waiting: bool = False) -> dict: """The structured half of `/status`, in `lang` (UX-13 P5). Pure reads of the controller.""" _LANG[0] = uitext.lang_of(lang) try: - return _build(c, level=level, checks=checks, now=now) + return _build(c, level=level, checks=checks, now=now, held=held, + waiting=waiting) finally: _LANG[0] = "en" -def _build(c, *, level, checks, now): +def _build(c, *, level, checks, now, held=None, waiting=False): now = now or datetime.now(timezone.utc) shadow = bool(c.shadow) - kind = state_of(c, level, shadow) + kind = state_of(c, level, shadow, waiting) # A red banner means every inverter is at 0 W (the status cascade), so # nothing is planned until it clears. Shadow mode still plans: it is - # what the add-on would do. - held = level == "bad" and not shadow + # what the add-on would do. `held` False: an alarm while control runs + # (SAFETY-87, the peak alarms). + held = level == "bad" and not shadow and held is not False running = (bool(c.auto) or shadow) and not held settled = all(at_zero(inv) for inv in c.inverters) ctl = next((r for r in checks if r.get("id") == "controller"), None) @@ -864,6 +955,7 @@ def _build(c, *, level, checks, now): modes_on=bool(getattr(c, "modes_on", False)), running=running, held=held, shadow=shadow) return { + "solar": solar_block(c, now), "state": {"kind": kind, "pill": pill(kind), "ran": ctl.get("text", "") if ctl else "", "sub": banner_sub(kind)}, @@ -1160,6 +1252,10 @@ def run_house_amount() -> str: return _t("run.house_amount") +def line_solar(fc_kwh, count_kwh, at) -> str: + return _t("solar.line", kwh=_kwh(fc_kwh), count=_kwh(count_kwh), at=at) + + def line_no_trade(key) -> str: return "" if key == "off" else _t("no_trade." + key) @@ -1202,8 +1298,11 @@ def line_peak_not_covered() -> str: return _t("peak_not_covered") -def line_period(period_w, limit_w, shadow) -> str: +def line_period(period_w, limit_w, shadow, unmeasured=False) -> str: step = _w(shadow, "step_in", kw=_kw(limit_w)) + if unmeasured: + # SAFETY-90: the add-on started during this period. + return _t("period_unmeasured") + " " + step if period_w is None: return step return _t("period", kw=_kw(period_w)) + " " + step @@ -1236,7 +1335,7 @@ def _samples() -> list: out += [("title", mode_word(k)) for k in words["modes"]] out += [("title", mode_house()), ("title", attn_shadow()), ("line", attn_shadow_body())] - for k in ("running", "shadow", "stopped", "alarm", "warning"): + for k in ("running", "shadow", "stopped", "alarm", "waiting", "warning"): out += [("title", pill(k)), ("line", banner_sub(k))] for sh in (False, True): out += [("title", tile_solar(v, s)) for v, s in @@ -1277,6 +1376,7 @@ def _samples() -> list: ("plan", line_peak_limit(3500, sh)), ("plan", line_period(1240, 3500, sh)), ("plan", line_period(None, 3500, sh)), + ("plan", line_period(1240, 3500, sh, unmeasured=True)), ("plan", control_text(True, sh)), ("plan", control_text(False, sh))] out += [("plan", run_grid_why(0.1412)), ("plan", run_grid_amount(1.23)), @@ -1302,6 +1402,8 @@ def _samples() -> list: off=off, peak=peak))) out += [("plan", why_words(k, name="Battery A")) for k in words["why"]] out += [("plan", today_words(k)) for k in words["today"]] + out += [("plan", line_solar(9.2, 6.4, "10:15")), + ("plan", _t("solar.no_forecast")), ("plan", _t("solar.learning"))] out += [("plan", line_no_trade(k)) for k in ("not_covered", "no_prices", "peak", "not_firm", "not_worth", "no_level")] diff --git a/goodwe_controller/app/idle.py b/goodwe_controller/app/idle.py new file mode 100644 index 0000000..513d210 --- /dev/null +++ b/goodwe_controller/app/idle.py @@ -0,0 +1,87 @@ +"""SAFETY-97: what a battery's power reading shows while it gives nothing. + +B's reading (0x0518) shows -95 to -118 W at idle (standby draw, live +2026-10-03); A's shows -2 to -4 W. "The battery gives nothing" is graded +against this idle reading, not against 0 W, or a refusing B never latches. +DECISIONS/SAFETY-97-idle-offset.md. + +Learned per unit, never configured: +- only while control runs, the unit is not refused, stale or tripped, and + its command has been within IDLE_ASK_W for IDLE_QUIET_S (a refusing battery + is asked more than that, so it cannot teach its refusal as idle); +- a reading beyond IDLE_SAMPLE_W is not a sample; +- first value: the mean of IDLE_FIRST_S of samples; then a slow follow + (IDLE_TAU_S); +- persisted per unit and restored, clamped to IDLE_SAMPLE_W, at start. +The band is centred on it clamped to IDLE_MAX_W (owner, 2026-10-04). Until +learned the idle reading is 0 W, the rule as it was. +""" + +import math + +IDLE_MAX_W = 75.0 # |idle| used: under the 100 W band, so it keeps 0 W +-25 W +IDLE_SAMPLE_W = 250.0 # a reading beyond this is not a sample +IDLE_ASK_W = 50.0 # a command this small counts as "asked ~0" +IDLE_QUIET_S = 120.0 # 6 lags of 20 s: a 2500 W step has decayed to 6 W +IDLE_FIRST_S = 120.0 # seconds of samples averaged for the first value +IDLE_TAU_S = 1800.0 # then follow drift this slowly +IDLE_SAVE_W = 5.0 # persist when the value moved this far +IDLE_GAP_S = 10.0 # a longer gap between reads counts as this + + +def _num(v): + try: + v = float(v) + except (TypeError, ValueError): + return None + return v if math.isfinite(v) else None + + +class IdleReading: + """One unit's idle reading. `w` is what "gives nothing" is centred on.""" + + def __init__(self, saved=None): + v = _num(saved) + self.value = (None if v is None + else max(-IDLE_SAMPLE_W, min(IDLE_SAMPLE_W, v))) + self.saved = self.value + self.quiet_since = None + self.last = None + self.acc_w = 0.0 + self.acc_s = 0.0 + + @property + def w(self) -> float: + """The centre used: the learned value clamped to IDLE_MAX_W.""" + if self.value is None: + return 0.0 + return max(-IDLE_MAX_W, min(IDLE_MAX_W, self.value)) + + def observe(self, reading, standing_w, now_mono, may_learn) -> bool: + """One read. Returns True when the value should be persisted.""" + now = float(now_mono) + dt = (0.0 if self.last is None + else max(0.0, min(IDLE_GAP_S, now - self.last))) + self.last = now + standing = _num(standing_w) + if not may_learn or standing is None or abs(standing) > IDLE_ASK_W: + self.quiet_since = None + return False + if self.quiet_since is None: + self.quiet_since = now + b = _num(reading) + if (now - self.quiet_since < IDLE_QUIET_S or b is None + or abs(b) > IDLE_SAMPLE_W or dt <= 0.0): + return False + if self.value is None: + self.acc_w += b * dt + self.acc_s += dt + if self.acc_s < IDLE_FIRST_S: + return False + self.value = self.acc_w / self.acc_s + else: + self.value += (b - self.value) * min(1.0, dt / IDLE_TAU_S) + if self.saved is None or abs(self.value - self.saved) >= IDLE_SAVE_W: + self.saved = self.value + return True + return False diff --git a/goodwe_controller/app/inverter.py b/goodwe_controller/app/inverter.py index 328c9fa..1816811 100644 --- a/goodwe_controller/app/inverter.py +++ b/goodwe_controller/app/inverter.py @@ -127,6 +127,7 @@ from . import messages # SAFETY-37. The freshness policy, and the ONE place its bounds are written. # freshness.py imports nothing from this package, so the direction is one-way. from .freshness import BOUNDS, Freshness +from .idle import IdleReading # CFG-03's ONE in-code mirror of config.yaml's SAFETY-24 defaults. Imported # rather than retyped: the flat-keys path below needs `bms_soc_floor`'s # shipped default and a literal here would be the fourth defaults site that @@ -321,13 +322,21 @@ class Inverter: # take, 0 none), when that refusal or its last probe started, the # current probe's start, and the cycles of evidence so far and when # they began. Memory only, like SAFETY-52's: stop and start end it. - # Used at N>1 only. + # The evidence count is also used at N=1 (SAFETY-94); the rest at N>1. self.refused = 0 self.refused_at = None self.probe_from = None self.ref_count = 0 self.ref_since = None self.ref_sign = 0 + self.ref_from = None + # SAFETY-97: what this unit's reading shows at idle; "gives nothing" + # is centred on it. Learned, persisted, restored (app/idle.py). + self.idle = IdleReading(data.get(self.key("idle_w"))) + + @property + def idle_w(self) -> float: + return self.idle.w # -- store --------------------------------------------------------------- def key(self, name: str) -> str: diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 9349285..73ff21d 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -25,10 +25,12 @@ battery to zero on its own. Never "hold the last value to be safe". import asyncio import contextlib +import dataclasses import functools import json import logging import math +import os import signal import time from datetime import datetime, timedelta, timezone @@ -41,9 +43,11 @@ from .arbiter import ( P_SAFETY, SET, Claim, Resolution, actuator_for, pre_resolve, resolve, ) from . import allocator +from . import dayplan from . import modetrade from . import packmode from . import priceruns +from . import solarfc from . import control as _ctl from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk from .delivery import ( @@ -56,7 +60,8 @@ from .circulation import ( ) from .clamp import ( REASON_CODE as CLAMP_REASON, ClampWatchdog, - clamp_to_rating, is_commandable, loop_clamp_note, write_clamp_note, + clamp_to_rating, delivery_short, is_commandable, loop_clamp_note, + shortfall_note, write_clamp_note, ) from . import freshness from . import homeview @@ -64,15 +69,15 @@ from . import messages from .freeze import ( FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w, ) -from .hass import HomeAssistant +from .hass import CORE_API, OUTAGE, HomeAssistant # ⚠️ `LINK_STALE_S` IS RE-EXPORTED DELIBERATELY. `live_units()` reasons about it # and two checks read `app.main.LINK_STALE_S` to build a stale link, so the # window an operator would look up here is the one the roster actually uses. from .inverter import (LINK_STALE_S, UNIT_A, build_roster, roster_entries, roster_names) from .maintenance import ( - IDLE, POWER_MOVING, MaintConfig, Maintenance, MaintResult, - arbitrate as maint_arbitrate, + CHARGE, DRAIN, HOLD, IDLE, POWER_MOVING, MaintConfig, Maintenance, + FAIL_BACKOFF_DAYS, MaintResult, arbitrate as maint_arbitrate, site_zone, ) from .mqtt import (MODE_CONFIGS_KEY, MODE_LABELS, USE_OFF, USE_ON, MqttPublisher, announced_units, mode_configs, @@ -86,8 +91,8 @@ from .price import Absence, Uncertainty, brussels, price_option, provider_config from .price import required_spread as price_required_spread from .price import uncertainty_of as price_uncertainty_of from .price_history import PriceHistory -from .static_tariff import resolve_tariff, static_status -from .tariff import PeakStore, PeakTracker +from .static_tariff import resolve_tariff, static_gated, static_status +from .tariff import PeakStore, PeakTracker, next_quarter from .store import migrate_maint_keys, migrate_unit_keys from .validate import (FIRMWARE_INGEST_VETO_S, ConfigRefused, refuse, validate_startup_config, validate_unit_ranges) @@ -237,6 +242,11 @@ REF_CHEAP_KEEP = timedelta(hours=12) # show the step: the plant's measured step time (control.PLANT_SETTLE_S). EDGE_HOLD_S = _ctl.PLANT_SETTLE_S +# OPT-11 P4: the store key of the house profile (dayplan.HouseProfile), and +# how often the forecast sensors are read (they change hourly at most). +HOUSE_PROFILE_KEY = "house_profile" +FORECAST_READ_S = 60.0 + def why_raised(resolution, claims) -> bool: """F2 (OPT-11 P1 test): did the limit that bound the site target push it @@ -275,6 +285,19 @@ def pack_energy_of(inv): # ordinary load steps. At 2 s and slower this changes nothing. REFUSED_SPAN_S = (_ctl.SETTLE_CYCLES - 1) * _ctl.LOOP_TICK_S +# SAFETY-94: the evidence restarts when the reading has moved this far towards +# the ask since the evidence began. A refusing battery reads flat (-4 W live, +# 2026-09-06); live readings wander by under 25 W at rest (B's standby, -95 to +# -118 W, 2026-10-03). A battery crossing the 100 W band on a first-order lag +# gains 50 W in at most tau x ln(151/101) = 0.4 tau, so the evidence cannot +# span REFUSED_SPAN_S unless the lag is over 54 s. +REFUSED_PROGRESS_W = _ctl.PROBE_ANSWER_W / 2 + +# SAFETY-85 review: a battery another owner set is graded against the least it +# was told over the same span as above (the plant's measured settle time), so +# a healthy battery still settling on a new command is not short. +SHORT_CYCLES, SHORT_SPAN_S = _ctl.SETTLE_CYCLES, REFUSED_SPAN_S + def _refused_interval(sign: int) -> tuple: """SAFETY-73: the (lo, hi) a refusing unit may be asked: 0 W that way.""" @@ -405,7 +428,8 @@ def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now, class Controller: def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop, peak_db_path: str = "/data/tariff.db", - price_history_path: str = "/data/price_history.json"): + price_history_path: str = "/data/price_history.json", + site_tz=None): # UX-15: a read-only HomeAssistant belongs to setup-only mode, which # builds no controller. Refused here so no controller can hold one. if getattr(hass, "read_only", False) is True: @@ -646,7 +670,8 @@ class Controller: name=inv.name, # The rollback view, and only ever the first pack's: the flat # keys mean `inverters[0]` on every install that has them. - mirror_flat=(not _one_pack and i == 0)) + mirror_flat=(not _one_pack and i == 0), + tz=site_tz) # MAINT-10: the start hour's zone, from amain() for i, inv in enumerate(self.inverters)] # SAFETY-02. ONE INSTANCE PER ACTUATOR - which is what watchdog.py's own @@ -925,7 +950,32 @@ class Controller: "peak_margin_w", peakguard.PEAK_OPTION_DEFAULTS["peak_margin_w"])) self._peak_engaged = False self._peak_saturated = False + # SAFETY-81: what the alarm is graded on, beside the guard's decision. + self._peak_proj = None + self._peak_batt_prev = None + # SAFETY-90: what a meter gap is counted at (`_fill_peak_gaps()`). + self._gap_seen_w = None + self._gap_w = None + self._gap_block = None + self._gap_filled_s = 0.0 + self._gap_held_ws = 0.0 + self._gap_pre = None + self._unseen = None + # A held sample TARIFF-01 restored from before this restart: the + # averager credits it max_hold_s into the restart, at 0 W batteries. + _held = self.p1.averager.held_since + self._run_pre = (None if _held is None + else (_held, self.p1.averager.held_net_w)) + self._unseen_held_ws = 0.0 + self.peak_gap_s = 0.0 + self.peak_unmeasured_s = 0.0 + self._peak_alarm_block = None + self._peak_high_since = None + self._peak_rolled = False # OPT-15 self._peak_floor_limited = False + # SAFETY-87: the reserve alarm's hold, as SAFETY-81's above. + self._reserve_batt_prev = None + self._reserve_high_since = None self._maint_shape_suspended = False # OPT-11. Battery modes, off by default so an install that has not # turned them on behaves exactly as before (SAFETY-10 section 1). Each @@ -949,12 +999,62 @@ class Controller: # OPT-15: a run edge the loop holds at E: (E, step sign, until, the # last battery reading). self._edge = None + # OPT-11 P4: the solar forecast and the day plan. Only with battery + # modes on AND a forecast for today set; otherwise none of this runs + # and every mode acts as in P3 option A. OPT-17: each day may name + # several entities, summed (`solarfc.entities()`); `fc_entity` is + # the first, and None means no forecast. + self.fc_entities = (solarfc.entities(opts) if self.modes_on + else ()) + self.fc_tomorrow_entities = solarfc.entities(opts, tomorrow=True) + self.fc_entity = self.fc_entities[0] if self.fc_entities else None + # OPT-17: the members that are not usable, as `solarfc.read_sum()` + # gives them, for the status page and the action log. + self.solar_fc_gaps = () + self.solar_trust_pct = opts.get("solar_trust_pct", + dayplan.DEFAULT_TRUST_PCT) + self.solar_fc = None + self.solar_fc_why = (solarfc.NOT_SET if self.fc_entity is None + else solarfc.MISSING) + self._fc_read_mono = None + self.day_plan = None + self._plan_targets = None + self.house_profile = None + if self.fc_entity is not None: + self.house_profile = dayplan.HouseProfile( + (getattr(store, "data", None) or {}).get(HOUSE_PROFILE_KEY)) # OPT-11 P2: (topic, reason) of MQTT commands refused and logged once. self._mqtt_refused = set() self._mqtt_refused_more = 0 # [(pack name, why)] for packs that are due and waiting their turn. # Empty at a roster of one, where nothing can defer anything. self._maint_deferred = [] + # SAFETY-78: names of draining packs that cannot give power now, so + # the "waits" line is logged once, not every cycle. + self._drain_waits = set() + # SAFETY-91: last cycle's held-back batteries (`_held_back()`). + self._held_was = frozenset() + # SAFETY-93: the batteries placed in their maintenance charge last + # cycle, and those whose charge ended since the loop last ran. + self._charging_was = frozenset() + self._charge_ended = frozenset() + # SAFETY-95: the same for a maintenance drain. + self._draining_was = frozenset() + self._drain_ended = frozenset() + # SAFETY-95: batteries settling after their drain ended; meanwhile + # the loop's error counts every battery at its command. + self._drain_tail = set() + # SAFETY-103: per battery, the range its power can be in since its + # reading last changed (that reading and every command since), and + # the house point held while a drain's tail settles. + self._read_span = {} + self._drain_house = None + # SAFETY-96: last cycle's commandable batteries, this cycle's start + # point per battery where a reseed moved it, and batteries settling + # after a step the loop did not make ({name: since, monotonic}). + self._live_was = None + self._loop_start = {} + self._settle = {} # 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. @@ -1101,6 +1201,8 @@ class Controller: self.grid = None self.peak_fc = None self.cheap = False + self._price_gated = False # MAINT-13: is a price gate set up + self._gated_key = None # MAINT-13: (options, hour) of the cache # TARIFF-03. One line of prose for the status page saying WHICH tariff # window we are in and why it is or is not cheap - so "Cheap window: # no" is an evidenced answer rather than a bare no. @@ -1148,6 +1250,12 @@ class Controller: self.loop_clamped: tuple[bool, str, bool] = (False, "", False) # UX-08: the numbers of a loop-side over-rating, for the notification. self.loop_clamp_facts: tuple = () + # SAFETY-85: this cycle's loop Decision, each battery's recent + # standing commands, and how many cycles in a row a battery set by + # another owner gave less than asked. + self._loop_decision = None + self._asked_hist: dict = {} + self._short_run = 0 self.stopping = False # SAFETY-61. time.monotonic() at the end of the last run_control() pass. self.loop_pass_mono = None @@ -1184,15 +1292,33 @@ class Controller: _LOG.info("The battery refusal ends, control asks for power again: %s.", why) - def _refusal_update(self, decision, asked_w, batt_w, probe) -> None: - """Follow `compute()`'s carried refused direction, and say so.""" + def _refusal_update(self, decision, asked_w, batt_w, probe, + rejudged: int = 0) -> None: + """Follow `compute()`'s carried refused direction, and say so. + + `rejudged`: the refusal held while another writer had the wire, so + `compute()` judged it afresh against that writer's ask (SAFETY-107). + """ now = decision.refused or 0 + if rejudged: + if now == rejudged: + # Still refused against the ask on the wire: the same episode. + # The probe clock restarts, as it did when this was an end and + # a new start: a probe is the loop's ask, and it is not on the + # wire while another writer is. + self.batt_refused_at = self._now_mono + return + gave = (rejudged * (batt_w - self._site_idle_w()) + >= _ctl.PROBE_ANSWER_W) + self._refusal_end("the battery gives power" if gave + else "another writer") if now and not self.batt_refused: self._refusal_start(now, f"asked {asked_w:+.0f} W, the battery " f"gave {batt_w:+.0f} W for " f"{decision.sat_count} cycles") elif self.batt_refused and not now: - gave = self.batt_refused * batt_w >= _ctl.PROBE_ANSWER_W + gave = (self.batt_refused * (batt_w - self._site_idle_w()) + >= _ctl.PROBE_ANSWER_W) self._refusal_end("the probe was answered" if gave and probe else "the battery gives power" if gave else "the grid now asks the other direction") @@ -1210,7 +1336,8 @@ class Controller: if ((self.auto or self.shadow) and not inv.refused and inv.ref_count >= t.saturation_cycles and inv.batt_fresh.held is not None - and abs(inv.batt_fresh.held) < _ctl.REFUSED_BELOW_W + and abs(inv.batt_fresh.held - inv.idle_w) + < _ctl.REFUSED_BELOW_W and abs(standing) > _ctl.PROBE_W): self._now_mono = now_mono self._unit_refusal_start( @@ -1222,7 +1349,8 @@ class Controller: and not self.batt_refused and self.sat_count >= t.saturation_cycles and inv.batt_fresh.held is not None - and abs(inv.batt_fresh.held) < _ctl.REFUSED_BELOW_W + and abs(inv.batt_fresh.held - inv.idle_w) + < _ctl.REFUSED_BELOW_W and abs(standing) > t.saturation_w): self._now_mono = now_mono self._refusal_start(1 if standing > 0 else -1, @@ -1295,7 +1423,9 @@ class Controller: for inv in self.inverters: b = inv.batt if inv.refused: - if b is not None and inv.refused * b >= _ctl.PROBE_ANSWER_W: + # SAFETY-97: given and taken are counted from the idle reading. + if (b is not None and inv.refused * (b - inv.idle_w) + >= _ctl.PROBE_ANSWER_W): self._unit_refusal_end( inv, "the probe was answered" if inv.probe_from is not None @@ -1306,32 +1436,60 @@ class Controller: # on its own reading, so one cycle of the loop overshooting # through zero does not hand it its share back. inv.ref_count = (inv.ref_count + 1 if b is not None and - -inv.refused * b >= _ctl.PROBE_ANSWER_W else 0) + -inv.refused * (b - inv.idle_w) + >= _ctl.PROBE_ANSWER_W else 0) if inv.ref_count >= _ctl.SETTLE_CYCLES: self._unit_refusal_end( inv, "the battery takes power the other way") continue - if b is None: - continue # a gap keeps the count: the gate's trip seeds - standing = inv.standing_w() - sign = 1 if standing > 0 else -1 - # One direction throughout: a command that swaps sign every cycle - # (a flapping load) reads ~0 through the plant's own lag, and that - # is a battery following, not refusing (TEST-19 flapping family). - if abs(standing) > _ctl.PROBE_W and abs(b) < _ctl.REFUSED_BELOW_W: - if sign != inv.ref_sign or not inv.ref_count: - inv.ref_count, inv.ref_since = 0, self._now_mono - inv.ref_count = min(inv.ref_count + 1, _ctl.SETTLE_CYCLES) - else: - inv.ref_count = 0 - inv.ref_sign = sign - if inv.ref_count >= _ctl.SETTLE_CYCLES and ( - inv.ref_since is None or self._now_mono is None - or self._now_mono - inv.ref_since >= REFUSED_SPAN_S): + if inv.rejected_since is not None: + # SAFETY-96: its writes are refused, so its board may have + # gone to 0 W on its own; that is no refusal by the battery. + # The count is kept, as across a gap, so a link that refuses + # now and then does not hide a battery that does refuse. + continue + sign = self._gave_nothing(inv) + if sign: self._unit_refusal_start( inv, sign, - f"asked {standing:+.0f} W, the battery gave {b:+.0f} W " - f"for {_ctl.SETTLE_CYCLES} cycles") + f"asked {inv.standing_w():+.0f} W, the battery gave " + f"{b:+.0f} W for {_ctl.SETTLE_CYCLES} cycles") + + def _gave_nothing(self, inv) -> int: + """The refusal evidence, one cycle of it: the sign `inv` has given + under REFUSED_BELOW_W while asked over PROBE_W, one way, for + SETTLE_CYCLES spanning REFUSED_SPAN_S, else 0. SAFETY-73 at N>1; + SAFETY-94 adds it to the N=1 engage in compute(). SAFETY-97: the band + is centred on the unit's idle reading.""" + b = inv.batt + if b is None: + return 0 # a gap keeps the count: the gate's trip seeds + standing = inv.standing_w() + sign = 1 if standing > 0 else -1 + # One direction throughout: a command that swaps sign every cycle + # (a flapping load) reads ~0 through the plant's own lag, and that + # is a battery following, not refusing (TEST-19 flapping family). + if (abs(standing) > _ctl.PROBE_W + and abs(b - inv.idle_w) < _ctl.REFUSED_BELOW_W): + # SAFETY-94: a reading still moving towards the ask is a battery + # catching up through its lag, not one refusing: start again. + if (sign != inv.ref_sign or not inv.ref_count + or sign * (b - inv.ref_from) >= REFUSED_PROGRESS_W): + inv.ref_count, inv.ref_since, inv.ref_from = ( + 0, self._now_mono, b) + inv.ref_count = min(inv.ref_count + 1, _ctl.SETTLE_CYCLES) + else: + inv.ref_count = 0 + inv.ref_sign = sign + if inv.ref_count >= _ctl.SETTLE_CYCLES and ( + inv.ref_since is None or self._now_mono is None + or self._now_mono - inv.ref_since >= REFUSED_SPAN_S): + return sign + return 0 + + def _site_idle_w(self) -> float: + """SAFETY-97: the site reading at idle, the sum of the units'.""" + return float(sum(inv.idle_w for inv in self.inverters)) def _site_refused(self): """Owner decision 4: every unit refuses the same way, so the site law @@ -2085,10 +2243,14 @@ class Controller: plan = self.fresh_tariff_plan(now) if plan is None or not plan.has_prices: self.mode_runs, self._runs_key = None, None + self.day_plan = None return None slot = int(now.timestamp() // 900) key = (id(plan), slot, tuple((p.name, p.use, p.mode, p.sell) for p in packs)) + if self.fc_entity is not None: + # P4: a forecast arriving or going rebuilds the plan at once. + key += (self.solar_fc is None,) if key != self._runs_key: done_c = self._run_done.get("charge", (None, set()))[1] done_s = self._run_done.get("sell", (None, set()))[1] @@ -2106,6 +2268,9 @@ class Controller: charge_kwh=e[0], charge_kw=e[1], sell_kwh=e[2], sell_kw=e[3], ref_eur_kwh=None if ref is None else ref.mean_eur_kwh) + if self.fc_entity is not None: + new = self._day_plan(now, packs, floor_pct, ceiling_pct, + new, plan, required, ref, done_c, e) self.mode_runs = priceruns.keep_started(self.mode_runs, new, now) self._runs_key = key runs = self.mode_runs @@ -2120,6 +2285,42 @@ class Controller: self._ref_cheap = runs.cheap return runs + def _day_plan(self, now, packs, floor_pct, ceiling_pct, runs, plan, + required, ref, done_c, e): + """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.""" + dp = dayplan.plan( + self.solar_fc, + None if self.house_profile is None + else self.house_profile.estimator(), + priceruns.keep_started(self.mode_runs, runs, now), now, packs, + trust_pct=self.solar_trust_pct, floor_pct=floor_pct, + ceiling_pct=ceiling_pct) + self.day_plan = dp + if dp is None: + self._plan_targets = None + return runs + self._plan_targets = dayplan.hold( + self._plan_targets, dp.targets, + {p.name: p.capacity_kwh for p in packs}) + e2 = modetrade.energy(packs, floor_pct, ceiling_pct, done_c, (), + targets=self._plan_targets) + return priceruns.plan( + plan.read.points, now, required_eur_kwh=required, + charge_kwh=e2[0], charge_kw=e2[1], sell_kwh=e[2], sell_kw=e[3], + ref_eur_kwh=None if ref is None else ref.mean_eur_kwh) + + def plan_targets(self): + """P4: each battery's planned level, or None (no day plan). Dropped + at once when the forecast goes (stale or missing).""" + if self.solar_fc is None: + return None + return self._plan_targets + def _mode_roles(self, now, maint_results): """OPT-11 P3: each battery's role and range this tick. Grid charging and selling need a fresh plan that `TariffPlan.arbitrage()` allows @@ -2158,9 +2359,22 @@ class Controller: price_now = None house = (None if self.grid is None or self.site_batt_w is None else float(self.grid) + float(self.site_batt_w)) - done_c = self._run_mark("charge", runs and runs.cheap, packs, - lambda p: p.soc is not None - and p.soc >= ceiling_pct) + if self.house_profile is not None and \ + self.house_profile.add(now, house): + self.store.set(HOUSE_PROFILE_KEY, self.house_profile.dump()) + targets = self.plan_targets() + if targets is None: + done_c = self._run_mark("charge", runs and runs.cheap, packs, + lambda p: p.soc is not None + and p.soc >= ceiling_pct) + else: + # P4: out of the run at its planned level. A level the + # hysteresis moved opens the run to it again (a new mark key). + def full(p): + to = modetrade.charge_to(p, ceiling_pct, targets) + return p.soc is not None and (to is None or p.soc >= to) + done_c = self._run_mark("charge", runs and runs.cheap, packs, + full, tuple(sorted(targets.items()))) done_s = self._run_mark("sell", runs and runs.sell, packs, lambda p: p.soc is not None and p.soc <= floor_pct) @@ -2173,7 +2387,8 @@ class Controller: headroom_w=self.arb_headroom_w, floor_pct=floor_pct, ceiling_pct=ceiling_pct, house_w=house, charging_now=was, blocked=self._charge_blocked_q == quarter, - done_charge=done_c, done_sell=done_s) + done_charge=done_c, done_sell=done_s, targets=targets, + draining=any(r.phase == DRAIN for r in maint_results or ())) # 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 @@ -2188,10 +2403,13 @@ class Controller: self._charge_gap_q = quarter return self.mode_tick - def _run_mark(self, kind, run, packs, out) -> set: + def _run_mark(self, kind, run, packs, out, extra=None) -> set: """The batteries that left run `run` (full, or at the floor); they - stay out until it ends, so a level edge does not re-arm them.""" + stay out until it ends, so a level edge does not re-arm them. + `extra` (P4: the planned levels) is part of the run's key.""" key = None if not run else (run.start, run.end) + if key is not None and extra is not None: + key += (extra,) was_key, done = self._run_done.get(kind, (None, set())) if key != was_key: done = set() @@ -2302,6 +2520,195 @@ class Controller: self.i_w = held_w return tg + def _charge_track(self, claims, maint_results) -> None: + """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).""" + if len(self.inverters) < 2: + return + live = set(self.live_units()) + now = {CHARGE: set(), DRAIN: set()} + for inv, res in zip(self.inverters, maint_results): + u = self.actuator_of("maintenance", inv) + if res.phase in now and u in live and not pre_resolve( + [c for c in claims if c.actuator == u]).contradiction \ + and not (res.phase == DRAIN and (inv.refused or 0) > 0): + now[res.phase].add(inv.name) + self._charge_ended |= self._charging_was - now[CHARGE] + self._charging_was = frozenset(now[CHARGE]) + self._drain_ended |= self._draining_was - now[DRAIN] + self._draining_was = frozenset(now[DRAIN]) + + def _reseed_charge_end(self, maint_results) -> None: + """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. + """ + ended, self._charge_ended = self._charge_ended, frozenset() + if len(self.inverters) < 2 or not ended: + return + for inv, res in zip(self.inverters, maint_results): + if inv.name not in ended: + continue + now_w = (float(res.setpoint_w or 0.0) if res.owns_setpoint + else max(float(inv.target), 0.0)) + if not inv.is_live(datetime.now(timezone.utc))[0]: + now_w = 0.0 # SAFETY-96: out of the split, it is sent 0 W + self.target += now_w - float(inv.target) + # SAFETY-96: it settles from its charge to that command. + self._loop_start[inv.name] = now_w + self._settle[inv.name] = self._now_mono + self.i_w = self.target + + def _reseed_drain_end(self, maint_results, live) -> None: + """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. + """ + ended, self._drain_ended = self._drain_ended, frozenset() + if len(self.inverters) < 2 or not ended: + return + hi = self.target + for inv, res in zip(self.inverters, maint_results): + if inv.name not in ended: + continue + now_w = (float(res.setpoint_w or 0.0) + if res.owns_setpoint and res.phase != DRAIN + else min(float(inv.target), 0.0)) + if inv.name not in live: + now_w = 0.0 # SAFETY-96: out of the split, it is sent 0 W + else: + self._drain_tail.add(inv.name) + self.target += now_w - float(inv.target) + self._loop_start[inv.name] = now_w + self._settle[inv.name] = self._now_mono + lo, hi = sorted((self.target, hi)) + # A dropped battery's power is the meter's, like the house's. + here = float(self.grid) - self.tuning.target_grid_w + sum( + float(inv.batt) for inv in self.inverters if inv.name in live) + self.target = self.i_w = min(hi, max(lo, here)) + + def _drain_grid_w(self, live) -> float: + """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. + """ + grid = float(self.grid) + self._drain_tail &= set(self._settle) & set(live) + if not self._drain_tail: + self._drain_house = None + return grid + aim = self.tuning.target_grid_w + lo = hi = grid - aim + for inv in self.inverters: + if inv.name not in live: + continue + r = float(inv.batt) + span = self._read_span.get(inv.name) + if (span is None or span[2] != inv.batt_fresh.changed_mono + or span[2] == self._now_mono): + span = (r, r) + # A battery that refuses one way (SAFETY-73) is not heading + # that way, whatever it was asked: that side is its reading. + if (inv.refused or 0) > 0: + span = (min(span[0], r), r) + elif (inv.refused or 0) < 0: + span = (r, max(span[1], r)) + lo, hi = lo + span[0], hi + span[1] + held = (float(self.target) if self._drain_house is None + else self._drain_house) + self._drain_house = min(hi, max(lo, held)) + return self._drain_house - float(self.target) + aim + + def _track_reads(self) -> None: + """SAFETY-103, N>=2: per battery, the range its power can be in now: + its last changed reading and every command on its wire since (a + lagging battery moves from one toward the others, never beyond).""" + if len(self.inverters) < 2: + return + for inv in self.inverters: + if inv.batt is None: + continue + cmd, since = inv.standing_w(), inv.batt_fresh.changed_mono + lo, hi, was = self._read_span.get(inv.name, (0.0, 0.0, None)) + if was is None or since != was: + lo = hi = float(inv.batt) + self._read_span[inv.name] = (min(lo, cmd), max(hi, cmd), since) + + def _reseed_drop(self, live) -> None: + """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. + """ + was, self._live_was = self._live_was, frozenset(live) + if len(self.inverters) < 2 or was is None or was == live: + return + for inv in self.inverters: + if inv.name in self._charge_ended | self._drain_ended: + continue # SAFETY-93/95's edge: `_reseed_*_end()` + if inv.name in was and inv.name not in live: + now_w = 0.0 + elif inv.name in live and inv.name not in was: + now_w = float(inv.batt) + # The split is made again, so every battery's command steps. + self._settle.update( + {u: self._now_mono for u in live}) + else: + continue + self.target += now_w - float(inv.target) + self._loop_start[inv.name] = now_w + self.i_w = self.target + + def _held_out_w(self, live) -> float: + """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. + """ + if len(self.inverters) < 2: + return 0.0 + out = 0.0 + for inv in self.inverters: + cmd = self._loop_start.get(inv.name, float(inv.target)) + since = self._settle.get(inv.name) + age = (None if since is None or self._now_mono is None + else self._now_mono - since) + if since is not None and ( + age is None or age >= _ctl.PLANT_SETTLE_DOWN_S + or (age > 0 and abs(float(inv.batt) - cmd) + < self.tuning.deadband_w)): + del self._settle[inv.name] + if (inv.name not in live or inv.rejected_since is not None + or inv.name in self._settle): + out += cmd - float(inv.batt) + return out + def _mode_fault_log(self, resolution) -> None: """OPT-11: say once, per battery, that its mode was dropped as a fault. @@ -2429,6 +2836,361 @@ class Controller: except Exception: # noqa: BLE001 _LOG.exception("Home Assistant command could not be handled") + def _drain_floor(self, claims, maint_results): + """SAFETY-78: the site `lo` for a pinned maintenance drain, or None. + + At N>=2 the drain pins its pack and the site target is split around + the pin. Below the pin a sibling would have to charge (opposite + directions, refused, 0 W, and the drain never moves). So the site + target may not go below what the pin places: the drained pack covers + the house up to its drain rate and exports the rest, as at N=1, and + a sibling only ever adds power in the same direction. At N=1 the + drain is the site SET itself, so nothing is filed. + A draining pack whose own limits leave it nothing to give (a refusal, + a stale link) adds no floor, and that is logged once. + """ + if len(self.inverters) < 2: + return None + live = set(self.live_units()) + floor, waits = 0.0, set() + for inv, res in zip(self.inverters, maint_results): + if res.phase != DRAIN or not res.owns_setpoint \ + or not (res.setpoint_w or 0.0) > 0.0: + continue + u = self.actuator_of("maintenance", inv) + b = pre_resolve([c for c in claims if c.actuator == u]) + p = (None if u not in live or b.contradiction + or b.pinned_by != "maintenance" else allocator.pin_w(b)) + if p is not None and p > 0.0: + floor += p + else: + waits.add(inv.name) + for name in sorted(waits - self._drain_waits): + self.log_event(f"maintenance: {name} cannot give power now, so " + f"its monthly cycle waits", + hint=messages.hint("maint_waits", unit=name)) + self._drain_waits = waits + return floor if floor > 0.0 else None + + def _peak_on_able(self, decision, maint_results): + """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.""" + if len(self.inverters) < 2: + return decision + floor = float(self.o.get( + "peak_soc_floor", peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"])) + ok, held = peakguard.reserve_split(self.packs, floor) + if not held: + return decision + live = set(self.live_units()) + able = [p for p, inv in zip(self.packs, self.inverters) + if p in ok and inv.name in live and not (inv.refused or 0) > 0] + if not able: + return dataclasses.replace(decision, held=held) + drain = sum(min(float(r.setpoint_w), i.rating_w()) + for i, r in zip(self.inverters, maint_results) + if i.name in held and r.phase == DRAIN + and r.owns_setpoint and (r.setpoint_w or 0.0) > 0.0) + names = {p.name for p in able} + return peakguard.place_on_able( + decision, held, + able_w=drain + sum(i.rating_w() for i in self.inverters + if i.name in names), + soc=min(float(p.soc) for p in able), + soc_floor=peakguard.binding_soc_floor(able, floor)) + + def _peak_reserve(self) -> frozenset: + """SAFETY-87: the batteries that give nothing for the peak this + cycle: under their reserve while the others carry a peak floor.""" + g = self.peak_guard + if g is None or g.able_w is None or not (g.floor_w or 0.0) > 0.0: + return frozenset() + return frozenset(g.held) + + def _held_back(self) -> frozenset: + """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.""" + if len(self.inverters) < 2: + return frozenset() + floor = float(self.o.get( + "peak_soc_floor", peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"])) + ok, held = peakguard.reserve_split(self.packs, floor) + held = set(held) | { + p.name for p in ok if p.name in self._held_was + and float(p.soc) <= peakguard.pack_floor_pct(p, floor) + 1.0} + live = set(self.live_units()) + if not held or not any( + p.name not in held and inv.name in live + and not (inv.refused or 0) > 0 + for p, inv in zip(self.packs, self.inverters)): + self._held_was = frozenset() + else: + self._held_was = frozenset(held) + return self._held_was + + def _drain_rise(self, claims, maint_results) -> None: + """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.""" + held = self._held_back() + if not held or any(c.kind == SET and c.actuator == ACTUATOR_SITE + and c.priority > P_PEAK for c in claims): + return + live = set(self.live_units()) + for inv, res in zip(self.inverters, maint_results): + u = self.actuator_of("maintenance", inv) + if res.phase != DRAIN or not res.owns_setpoint \ + or inv.name in held or u not in live: + continue + b = pre_resolve([c for c in claims if c.actuator == u]) + p = (None if b.contradiction or b.pinned_by != "maintenance" + else allocator.pin_w(b)) + if p is None or not p > 0.0: + continue + for i, c in enumerate(claims): + if c.source == "maintenance" and c.kind == SET \ + and c.actuator == u: + claims[i] = Claim.limit("maintenance", P_MAINTENANCE, + lo=p, reason=c.reason, + actuator=u) + + def _fill_peak_gaps(self, proj, now, reading): + """SAFETY-90: `proj` with a meter gap's uncounted seconds counted at + `peakguard.gap_w()` of the imports seen just before and just after it, + and those before this run's first reading likewise at the import seen + first (shown as not measured). + DECISIONS/SAFETY-90-peak-guard-after-a-gap.md.""" + self.peak_gap_s = self.peak_unmeasured_s = 0.0 + avg = self.p1.averager + since = avg.held_since + if proj is None or proj.block_start is None or since is None: + return proj + if self._unseen is None and self.p1.last is not None: + self._unseen = [proj.block_start, max(0.0, ( + since - proj.block_start).total_seconds() - avg.elapsed_s), + since, None] + seen = (None if self.grid is None or self.site_batt_w is None + else float(self.grid) + max(0.0, float(self.site_batt_w))) + if proj.block_start != self._gap_block: + self._gap_block, self._gap_w = proj.block_start, None + self._gap_filled_s = self._gap_held_ws = 0.0 + cap = float(self.o.get("peak_cap_w", 3500)) + ceiling = peakguard.quarter_ceiling_w(cap, reading)[0] + unseen_on = (self._unseen is not None + and self._unseen[0] == proj.block_start) + if unseen_on and self._unseen[3] is None and seen is not None: + # While the add-on was down the batteries were at 0 W, so the + # seconds before its first reading count like a gap, at the + # import seen first; a restored reading's held part is topped up. + self._unseen[3] = peakguard.gap_w(ceiling, seen) + if self._run_pre is not None: + s0, g0 = self._run_pre + h = (min(s0 + timedelta(seconds=avg.max_hold_s), + self._unseen[2]) + - max(s0, proj.block_start)).total_seconds() + self._unseen_held_ws = max(0.0, h) * max( + 0.0, self._unseen[3] - max(0.0, g0)) + kw = dict(tail_s=(now - since).total_seconds(), + max_hold_s=avg.max_hold_s, + unseen_s=self._unseen[1] if unseen_on else 0.0, + unseen_w=(self._unseen[3] if unseen_on + and self._unseen[3] is not None else ceiling)) + _p, gap_s, _u = peakguard.fill_gaps(proj, gap_w=0.0, **kw) + if gap_s > self._gap_filled_s + peakguard.GAP_TOL_S: + self._gap_w = peakguard.gap_w(ceiling, self._gap_w, + self._gap_seen_w, seen) + if self._gap_pre is not None: + # The averager credited the gap's first max_hold_s at the + # reading before it; top that part up to gap_w as well. + s0, g0 = self._gap_pre + h = (min(s0 + timedelta(seconds=avg.max_hold_s), since) + - max(s0, proj.block_start)).total_seconds() + self._gap_held_ws += max(0.0, h) * max( + 0.0, self._gap_w - max(0.0, g0)) + self.log_event(f"PEAK_GUARD_GAP: the meter missed {gap_s:.0f} s " + f"of this 15-minute period, counted at " + f"{self._gap_w:.0f} W", + hint=messages.hint("peak_gap")) + self._gap_filled_s = max(self._gap_filled_s, gap_s) + if seen is not None: + self._gap_seen_w = seen + if self.grid is not None: + self._gap_pre = (since, float(self.grid)) + out, self.peak_gap_s, self.peak_unmeasured_s = peakguard.fill_gaps( + proj, gap_w=self._gap_w if self._gap_w is not None else ceiling, + held_ws=self._gap_held_ws + (self._unseen_held_ws if unseen_on + else 0.0), **kw) + return out + + def _peak_over_drain(self, claims, maint_results) -> None: + """SAFETY-83: at N=1 a peak floor above the drain rate outranks it. + + The drain is the site SET at P_MAINTENANCE, above the peak floor + (P_PEAK), so the floor did not bind and the house imported what the + drain left. Where the floor asks for more than the drain rate, it is + refiled at P_MAINTENANCE and wins; nothing else changes. Safety claims + (P_SAFETY) still outrank it. At N>=2 the `drain` floor does this, and + `_drain_over_reserve()` when a battery is held at its reserve. + """ + if len(self.inverters) != 1: + self._drain_over_reserve(claims, maint_results) + return + res = maint_results[0] + if res.phase != DRAIN or not res.owns_setpoint: + return + rate = float(res.setpoint_w or 0.0) + for i, c in enumerate(claims): + if c.source == peakguard.SOURCE and c.lo > rate: + claims[i] = dataclasses.replace(c, priority=P_MAINTENANCE) + + def _held_pin(self, claims, u): + """SAFETY-87: what held unit `u` places anyway (its own pin), or None + when that pin charges, so the site is never split both ways.""" + b = pre_resolve([c for c in claims if c.actuator == u]) + p = None if b.contradiction else allocator.pin_w(b) + return None if p is not None and p < 0.0 else max(p or 0.0, 0.0) + + def _drain_over_reserve(self, claims, maint_results) -> None: + """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.""" + reserve = self._peak_reserve() + floor = max((c.lo for c in claims if c.source == peakguard.SOURCE), + default=0.0) + if not reserve or not floor > 0.0 or any( + c.kind == SET and c.actuator == ACTUATOR_SITE + and c.priority > P_PEAK for c in claims): + return + live = set(self.live_units()) + drains, others = [], 0.0 + for inv, res in zip(self.inverters, maint_results): + u = self.actuator_of("maintenance", inv) + if u not in live: + continue + if inv.name in reserve: + p = self._held_pin(claims, u) + if p is None: + return + others += p + continue + b = pre_resolve([c for c in claims if c.actuator == u]) + if b.contradiction: + continue + p = allocator.pin_w(b) + if p is not None and p < 0.0: + return # a pack pinned to charge: never split both ways + if res.phase == DRAIN and b.pinned_by == "maintenance" \ + and p is not None and p > 0.0: + drains.append((u, p, allocator.hi_w(b))) + others += allocator.hi_w(b) if p is None else p + give = floor - others + for u, p, hi in drains: + if give <= 0.0: + break + up = max(p, min(hi, p + give)) + give -= up - p + for i, c in enumerate(claims): + if c.source == "maintenance" and c.kind == SET \ + and c.actuator == u: + claims[i] = dataclasses.replace(c, value=up) + + def _peak_over_hold(self, claims, maint_results): + """SAFETY-86: a peak floor outranks a maintenance HOLD (0 W). + + At N=1 the floor is refiled at P_MAINTENANCE, as for a drain. At N>=2 + the holding pack gives only what the others cannot: its pin is raised + to the floor minus what they may place, and the floor is returned as + the site `hold` ceiling (else None). A site stop still decides alone. + DECISIONS/SAFETY-86-peak-over-hold.md. + """ + hold = {self.actuator_of("maintenance", inv) + for inv, res in zip(self.inverters, maint_results) + if res.phase == HOLD and res.owns_setpoint} + peak = [i for i, c in enumerate(claims) + if c.source == peakguard.SOURCE and c.lo > 0.0] + if not hold or not peak: + return + if len(self.inverters) == 1: + for i in peak: + claims[i] = dataclasses.replace(claims[i], + priority=P_MAINTENANCE) + return + if any(c.kind == SET and c.actuator == ACTUATOR_SITE + and c.priority > P_PEAK for c in claims): + return + live = set(self.live_units()) + reserve = self._peak_reserve() # SAFETY-87: they give nothing + others = 0.0 + for inv in self.inverters: + u = self.actuator_of("maintenance", inv) + if u in hold or u not in live: + continue + if inv.name in reserve: # only its own pin, never both ways + p = self._held_pin(claims, u) + if p is None: + return + others += p + continue + b = pre_resolve([c for c in claims if c.actuator == u]) + if b.contradiction: + continue + p = allocator.pin_w(b) + if p is not None and p < 0.0: + return # a pack pinned to charge: never split both ways + others += allocator.hi_w(b) if p is None else p + floor = max(claims[i].lo for i in peak) + give = floor - others + if give <= 0.0: + return + for i, c in enumerate(claims): + if c.source == "maintenance" and c.kind == SET \ + and c.actuator in hold: + claims[i] = dataclasses.replace(c, value=give) + return floor + + def _charge_headroom(self, floor: float, claims) -> float: + """SAFETY-84: at N>=2 a cheap-window charge pulls no more than the + quarter's headroom under the peak aim (`charge_headroom_w()`), so it + yields just enough to the peak guard, and nothing under a site stop + (its 0 W would need the other pack to give). N=1 is unchanged.""" + if len(self.inverters) < 2: + return floor + if any(c.kind == SET and c.actuator == ACTUATOR_SITE + and c.priority > P_MAINT_SHAPE for c in claims): + return 0.0 + room = peakguard.charge_headroom_w(self.peak_guard) + return floor if room is None else min(floor, room) + + def _charge_ceiling(self, claims): + """SAFETY-84: the site `hi` for a cheap-window charge at N>=2, or None. + + The charging pack's own `hi` (P_MAINT_SHAPE) makes it charge; above + that the other pack would have to give (opposite directions, refused, + 0 W). So the site target stays at or below it. Not filed at N=1, nor + when the pack's own limits cancel the charge (a refusal, a stale link). + DECISIONS/SAFETY-84-charge-at-two.md. + """ + if len(self.inverters) < 2: + return None + live = set(self.live_units()) + ceiling = 0.0 + for inv in self.inverters: + u = self.actuator_of("maintenance", inv) + b = pre_resolve([c for c in claims if c.actuator == u]) + if u in live and not b.contradiction and b.hi < 0.0: + ceiling += allocator.hi_w(b) + return ceiling if ceiling < 0.0 else None + def _maint_stop_off(self, machine, now) -> None: """OPT-11: stop this battery's maintenance cycle because it is off.""" if machine.phase == IDLE: @@ -2533,6 +3295,35 @@ class Controller: self.pv_w, self.pv_why, self.pv_parts = pv_read.total( list(zip(roster, states)), pv_read.inverted(self.o)) + async def _read_forecast(self) -> None: + """OPT-11 P4: the solar forecast sensors (`solarfc.read()`). A + failed or stale read is no forecast, never a zero.""" + # OPT-17: every member of both days in one gather. + ents = self.fc_entities + self.fc_tomorrow_entities + states = await asyncio.gather(*(self.hass.state(e) for e in ents)) + n = len(self.fc_entities) + now = datetime.now(timezone.utc) + fc, why, gaps = solarfc.read_sum( + list(zip(self.fc_entities, states[:n])), + list(zip(self.fc_tomorrow_entities, states[n:])), now) + # A member is named only in a sum of several; one entity reads as + # before. Logged once per episode: on a change, never every read. + gaps = tuple(g for g in gaps if len( + self.fc_entities if g[0] == "today" + else self.fc_tomorrow_entities) > 1) + if (fc is None) != (self.solar_fc is None) \ + or why != self.solar_fc_why or gaps != self.solar_fc_gaps: + self.log_event( + "solar forecast: " + ("read" if fc is not None else + f"none ({why})") + + "".join(f", {d} {e} not usable ({w})" + for d, _i, e, w in gaps), + hint=messages.hint( + "forecast_none" if fc is None else + "forecast_no_tomorrow" if gaps else "forecast_read")) + self.solar_fc, self.solar_fc_why = fc, why + self.solar_fc_gaps = gaps + async def read_inputs(self, now_mono: float | None = None) -> None: """Every input this cycle needs, and SAFETY-37's freshness verdict. @@ -2584,6 +3375,14 @@ class Controller: _reads = [self._read_unit(inv, now_mono) for inv in self.inverters] if self.pv_roster: _reads.append(self._read_pv()) + # OPT-11 P4: the solar forecast, in the same gather (a slow sensor + # must not delay control), at most once per FORECAST_READ_S. + if self.fc_entity is not None and ( + self._fc_read_mono is None + or now_mono - self._fc_read_mono >= FORECAST_READ_S + or now_mono < self._fc_read_mono): + self._fc_read_mono = now_mono + _reads.append(self._read_forecast()) await asyncio.gather(*_reads) if o.get("peak_forecast_entity"): raw = await self.hass.number(o["peak_forecast_entity"]) @@ -2597,6 +3396,7 @@ class Controller: now_p = await self.hass.number(now_e) avg_p = await self.hass.number(avg_e) self.cheap = (now_p is not None and avg_p is not None and now_p <= avg_p) + self._price_gated = True self.tariff_words = "Prices from Home Assistant entities" else: # TARIFF-03. §9.2 makes a static day/night tariff a first-class @@ -2620,8 +3420,9 @@ class Controller: # ⚠️ AND IT CANNOT BE BOTH. validate.py refuses static_profile # alongside the two Home Assistant entities, because this `if` picks # one and the loser is silent. - self.cheap, self.tariff_words = static_status( - o, datetime.now(timezone.utc)) + _now = datetime.now(timezone.utc) + self.cheap, self.tariff_words = static_status(o, _now) + self._price_gated = self._static_gated(o, _now) # ⚠️ TARIFF-11. THE PROVIDER PLAN REACHES THE *Tariff* ROW HERE, AND # THIS IS THE ONLY PLACE THE TWO SOURCES MEET. Everything above is the @@ -2706,6 +3507,14 @@ class Controller: # `stale_input_s` and then commands 0 W and keeps commanding, which is # §11.1 rule 1 and is already what a missing reading does here. inv.batt = inv.batt_fresh.reading() + # SAFETY-97: learn this unit's idle reading, only while control runs + # and nothing says the unit is refusing, stale or faulty. + if inv.idle.observe( + inv.batt, inv.standing_w(), now_mono, + bool(self.auto) and inv.batt is not None + and not inv.batt_fresh.stale and not inv.refused + and not self.batt_refused and not inv.sign_wd.tripped): + inv.store_set("idle_w", round(inv.idle.value, 1)) if inv.batt_fresh.stale and not was_stale: self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason, hint=messages.hint("battery_stuck", unit=inv.name)) @@ -2748,8 +3557,9 @@ class Controller: inv.batt_fresh.bound.move_w, messages.reason_code(inv.batt_fresh.reason)) _LOG.debug("Evidence for %s: %s.", inv.name, inv.batt_fresh.reason) - _LOG.debug("%s: the limits are unchanged over %.0f s, a command " - "over %.0f W and a move by over %.0f W.", inv.name, + _LOG.debug("%s: the limits are unchanged for %.0f s while asked " + "to move: a command over %.0f W that moved over %.0f W.", + inv.name, inv.batt_fresh.bound.unchanged_s, inv.batt_fresh.bound.move_w, inv.batt_fresh.bound.move_w) @@ -3151,6 +3961,29 @@ class Controller: inv.refusal_note())] if n] + def _static_gated(self, o: dict, now: datetime) -> bool: + """`static_gated()`, worked out once an hour: it builds the same + month-long plan as `static_status()` (about 20 ms with a profile), and + whether a fixed tariff is set up does not change from tick to tick + (options change only with a restart).""" + key = (id(o), now.strftime("%Y-%m-%d %H")) + if key != self._gated_key: + self._gated_key, self._gated = key, static_gated(o, now) + return self._gated + + def _charge_gate_open(self, inv) -> bool: + """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. + """ + if not self._price_gated or self.cheap: + return True + batt = inv.batt + return batt is not None and float(batt) <= -MAINT_CHARGING_W + async def cycle(self) -> None: """Gather claims, resolve precedence, write the winner. @@ -3199,10 +4032,24 @@ class Controller: if blocked is None: maint_results.append(MaintResult(IDLE, False, None, False, [])) continue - res = machine.tick(now, inv.soc, blocked_by=blocked) + # MAINT-13: the charge timeout counts only the minutes the pack was + # allowed to charge (see `_charge_gate_open`). + res = machine.tick(now, inv.soc, blocked_by=blocked, + gate_open=self._charge_gate_open(inv)) for msg in res.events: - self.log_event(msg, hint=messages.hint("maintenance", - phase=machine.phase)) + self.log_event(msg, hint=messages.hint( + "maint_failed" if res.failed else "maintenance", + phase=machine.phase)) + if res.failed: + # Said once: only the tick that failed carries `res.failed`. + multi = len(self.inverters) > 1 + slug = (unit_slugs(self.units)[self.inverters.index(inv)] + if multi else "battery") + await self.hass.call( + "persistent_notification", "create", + messages.maint_failed( + res.failed, FAIL_BACKOFF_DAYS, + unit=inv.name if multi else "", slug=slug).data()) maint_results.append(res) # ⚠️ CARRIED TO THE STATUS PAGE, NOT LEFT IN THE 40-LINE ACTION LOG. The # deferral is edge-logged (it lasts as long as the other pack's cycle), @@ -3466,7 +4313,8 @@ class Controller: parked=inv.sign_wd.tripped or bool(inv.refused) or ( n_units > 1 and inv.batt is not None and abs(float(standing[inv.name])) <= _ctl.PROBE_W - and abs(float(inv.batt)) < _ctl.REFUSED_BELOW_W), + and abs(float(inv.batt) - inv.idle_w) + < _ctl.REFUSED_BELOW_W), # ⚠️ 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 @@ -3741,12 +4589,11 @@ class Controller: # 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 - and the answer is already right: a - # maintenance DRAIN or HOLD owns the setpoint at P_MAINTENANCE and - # commands discharge or zero, neither of which can create offtake, while - # a maintenance CHARGE is only ever a P_MAINT_SHAPE limit (40) and is - # therefore bound by this floor. That is §9.1's "money outranks the - # maintenance schedule", now as a consequence of the priorities. + # 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 @@ -3758,10 +4605,27 @@ class Controller: # 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). + self._peak_proj = (None if not self.p1_enabled + else self.peak.project(self.p1.averager, now, + roll=self.modes_on)) + # OPT-15 x SAFETY-81: see `check_peak_saturated()`. + self._peak_rolled = (self._peak_proj is not None + and self._peak_proj.block_start is not None + and self._peak_proj.block_start + != self.p1.averager.block_start) + _reading = self.peak.peak(now) if self.p1_enabled else None + # SAFETY-90: seconds a meter gap left uncounted are counted, so the + # guard goes on after the readings return. + self._peak_proj = self._fill_peak_gaps(self._peak_proj, now, _reading) + # SAFETY-91: at N>=2 the quarter's edge is graded on the next quarter, + # so a HOLD's ceiling does not lapse for a cycle onto a held battery. + if self._peak_proj is not None and len(self.inverters) > 1: + self._peak_proj = next_quarter(self._peak_proj, + self.p1.averager.max_hold_s) self.peak_guard = None if not self.p1_enabled else peakguard.decide( - # OPT-15: with battery modes on, the quarter `now` is in, even - # before its first sample (DECISIONS/OPT-11-battery-modes.md). - self.peak.project(self.p1.averager, now, roll=self.modes_on), + self._peak_proj, # ⚠️ 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 @@ -3769,7 +4633,7 @@ class Controller: batt_w=self.site_batt_w, cap_w=float(self.o.get("peak_cap_w", 3500)), margin_w=self.peak_margin_w, - reading=self.peak.peak(now), + reading=_reading, # ⚠️ 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 @@ -3814,6 +4678,11 @@ class Controller: dev_min=self.site_dev_bound("dev_min"), dev_max=self.site_dev_bound("dev_max")), ) + # SAFETY-87: at N>=2 a battery at or under its own reserve gives + # nothing for the peak, and the others carry the guard's need. + if self.peak_guard is not None: + self.peak_guard = self._peak_on_able(self.peak_guard, + maint_results) # 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 @@ -4062,6 +4931,7 @@ class Controller: floor = maintenance_charge_floor( self.maint.cfg.charge_w, self.peak_fc, self.peak_aim_w()) + floor = self._charge_headroom(floor, claims) if floor > 0: hi, reason = -floor, "cheap-window charge" # PER-INVERTER, like the SET above and for the same reason - @@ -4139,6 +5009,29 @@ class Controller: _ctl.REFUSED, actuator=self.actuator_of("refused", inv))) + # --- SAFETY-78: a pinned drain is a floor under the site target ----- + # After every unit claim, so the floor is what the pin really places. + drain = self._drain_floor(claims, maint_results) + if drain is not None: + claims.append(Claim.limit("drain", P_MAINTENANCE, lo=drain, + reason="maintenance drain", + actuator=self.actuator_of("drain"))) + self._peak_over_drain(claims, maint_results) + # --- SAFETY-86: while a HOLD gives, the site stays at the peak floor -- + # So the loop never asks for watts the hold keeps back. + hold_hi = self._peak_over_hold(claims, maint_results) + if hold_hi is not None: + claims.append(Claim.limit("hold", P_MAINTENANCE, hi=hold_hi, + reason="maintenance hold", + actuator=self.actuator_of("hold"))) + # --- SAFETY-91: a drain rises before a held battery gives ------------ + self._drain_rise(claims, maint_results) + # --- SAFETY-84: a cheap-window charge is a ceiling over the site ---- + charge = self._charge_ceiling(claims) + if charge is not None: + claims.append(Claim.limit("charge", P_MAINT_SHAPE, hi=charge, + reason="maintenance charge", + actuator=self.actuator_of("charge"))) # --- OPT-11 P3: the site trade, after every unit claim -------------- # Sized from what the batteries may place with their modes and hard # limits (the loop bound), so none is asked for watts it may not give. @@ -4146,6 +5039,9 @@ class Controller: if self.modes_on: mode_trade = self._mode_trade(claims, now) self._mode_trade_was = mode_trade + self._charge_track(claims, maint_results) + _live96 = set(self.live_units()) + self._loop_start = {} if (self.auto or self.shadow) and self.inputs_ok: # ⚠️ prev is the ARBITER's last output, not the loop's own last # wish. If something outranked the loop, that is what the hardware @@ -4171,12 +5067,25 @@ class Controller: moved = False loop_grid = self._edge_grid(mode_trade or moved, now) # SAFETY-52: another writer overrode the loop, so a refusal learned - # against the loop's own ask no longer describes the wire. A gap + # 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. + rejudged = 0 if self.loop_w is not None and self.target != self.loop_w: - self._refusal_end("another writer") - probe = self._refusal_probe() + rejudged = self.batt_refused + # SAFETY-96: a battery that dropped out of the split leaves the + # loop's start point (SAFETY-93's own if its charge ended too). + self._reseed_drop(_live96) + # SAFETY-93: a charge that ended leaves the loop's start point. + self._reseed_charge_end(maint_results) + # SAFETY-95: then a drain that ended, last (it reads the start + # the others left). + self._reseed_drain_end(maint_results, _live96) + # No probe while it is judged afresh (the loop does not hold the wire). + probe = False if rejudged else self._refusal_probe() if len(self.inverters) > 1: probe = any(_probing.values()) # ⚠️ `actual_w` IS THE SITE SUM AND THAT IS DEV-02's OTHER HALF. @@ -4193,6 +5102,12 @@ class Controller: # level: the two halves of §6.2's identity break in opposite # directions. See `site_batt_w` and test_alloc.py part 8. site_batt = self.site_batt_w + # SAFETY-103: what each battery can be doing (its reading may be + # older than the meter). + self._track_reads() + # SAFETY-96: a battery the loop cannot move counts at its command, + # so its own power does not freeze the loop (the meter has it). + site_batt += self._held_out_w(_live96) # 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 @@ -4202,7 +5117,11 @@ class Controller: loop_prev = self.target decision = compute( prev_w=loop_prev, - grid_w=loop_grid, + # SAFETY-95: a drain's tail is not the loop's to chase. + # OPT-15: `loop_grid` is the grid, or the target while a run + # edge is held. + grid_w=(self._drain_grid_w(_live96) if self._drain_tail + else loop_grid), actual_w=site_batt, tuning=self.tuning, sat_count=self.sat_count, @@ -4211,12 +5130,23 @@ class Controller: # limit already closes an empty battery's discharge end. # SAFETY-73: at N>1 the site law holds 0 W only while every # unit refuses the same way. Otherwise None, the old law. - refused=self.batt_refused if len(self.inverters) == 1 + refused=(0 if rejudged else self.batt_refused) + if len(self.inverters) == 1 else self._site_refused(), probe=probe, - # OPT-11: None unless battery modes are on, which is the law - # exactly as it was. - bound=self.loop_bound(claims) if self.modes_on else None, + # SAFETY-94: at N=1 the engage also needs the reading to have + # given nothing for the whole span, as at N>1. + gave_nothing=(bool(self._gave_nothing(self.inverters[0])) + if len(self.inverters) == 1 else None), + # SAFETY-97: "gives nothing" is centred on the idle reading. + idle_w=self._site_idle_w(), + # OPT-11: None unless battery modes are on. SAFETY-85: or while + # maintenance holds a battery at N>=2, so the loop does not + # wind past what the others can give and jump when it ends. + bound=(self.loop_bound(claims) if self.modes_on or ( + len(self.inverters) > 1 + and any(r.phase != IDLE for r in maint_results)) + else None), ) if decision.frozen and self.sat_count < self.tuning.saturation_cycles: self.log_event(f"saturation freeze ({self.target:.0f} W vs " @@ -4225,8 +5155,10 @@ class Controller: self.sat_count = decision.sat_count self.i_w = decision.i_w self.loop_w = decision.target_w + self._loop_decision = decision if len(self.inverters) == 1: - self._refusal_update(decision, loop_prev, site_batt, probe) + self._refusal_update(decision, loop_prev, site_batt, probe, + rejudged) # --- 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 @@ -4290,6 +5222,7 @@ class Controller: # Stopped or blind: no accumulation may survive the outage. self.i_w = 0.0 self.loop_w = None + self._live_was = frozenset(_live96) # TEL-10, and it is the same zero the line above writes rather than # a second opinion about it. ⚠️ `not-running` rather than a stale # `tracking`: a reason row that keeps repeating the last word the @@ -4304,6 +5237,7 @@ class Controller: # de-assert when control is stopped. self.loop_clamped = (False, "", False) self.loop_clamp_facts = () + self._loop_decision = None # SAFETY-28. A new resolution is a new tick, and the previous record is # stale from this line onwards. Incremented BEFORE resolve() so that the @@ -4328,7 +5262,8 @@ class Controller: _live = self.live_units() while True: resolution = resolve(claims, units=self.units, live=_live, - weights=self.pack_weights, tick=self.alloc_tick) + weights=self.pack_weights, tick=self.alloc_tick, + reserve=self._held_back()) # 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 @@ -4522,6 +5457,8 @@ class Controller: # 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. + self._loop_alarm_input(resolution) writes = [] for inv in self.inverters: inv.target = float(resolution.shares.get(inv.name, 0.0)) @@ -4735,6 +5672,49 @@ class Controller: for inv in self.inverters: inv.target = shadow.WIRE_W + def _loop_alarm_input(self, resolution) -> None: + """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.""" + d = self._loop_decision + if d is None: + self._short_run = 0 + self._asked_hist = {} + return + unmet = self.allocation.unmet_w if self.allocation is not None else 0 + cut = (resolution.bound_by or "").split("(")[0] + owns = (resolution.winner == "loop" and not d.held and not unmet + and (resolution.target_w == d.target_w + or cut in ("device", "supervised"))) + # Graded against the least that stood on the wire (0 W in shadow + # mode) over SHORT_CYCLES cycles and SHORT_SPAN_S seconds. + now, asked = self._now_mono, {} + for inv in self.inverters: + h = self._asked_hist.setdefault(inv.name, []) + h.append((now, inv.standing_w())) + while len(h) > SHORT_CYCLES and ( + now is None or h[0][0] is None + or now - h[0][0] > SHORT_SPAN_S): + h.pop(0) + asked[inv.name] = min(w for _t, w in h) + others = [inv for inv in self.inverters if not owns + or allocator.pin_w(resolution.unit_bounds.get(inv.name)) + is not None] + short = [(inv, asked[inv.name], inv.batt) for inv in others + if inv.batt is not None + and delivery_short(asked[inv.name], inv.batt, + self.tuning.saturation_w)] + self._short_run = self._short_run + 1 if short else 0 + if owns and self.loop_clamped[0]: + return + engaged = self._short_run >= self.tuning.saturation_cycles + named = len(self.inverters) > 1 + self.loop_clamped = (engaged, shortfall_note( + [(inv.name if named else None, a, g) for inv, a, g in short]) + if engaged else "", False) + self.loop_clamp_facts = () + async def check_output_clamp(self) -> None: """SAFETY-03. Alarm whenever a clamp actually engaged this cycle. @@ -4868,22 +5848,60 @@ class Controller: 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. + 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. """ g = self.peak_guard - saturated = bool(g is not None and g.saturated) + batt, prev = self.site_batt_w, self._peak_batt_prev + if batt is None: + # Unknown, not "fine": the hold below neither grows nor restarts. + return + self._peak_batt_prev = batt + if prev is not None: + batt = min(float(batt), float(prev)) + p = self._peak_proj + # OPT-15 x SAFETY-81: until the new quarter's first sample the old + # projection read "no seconds left" (not high), which restarted the + # hold; a rolled one (battery modes on) is not graded then, so a + # persistent overload is still notified once per quarter. + high = bool( + g is not None and g.saturated and p is not None + and p.block_start is not None and not self._peak_rolled + and peakguard.load_too_high( + p, batt_w=batt, ceiling_w=g.ceiling_w, + achievable_w=self.site_rating_w)) + # SAFETY-81: and for ALARM_HOLD_S, on the projection's clock. + saturated = False + if high: + t = p.block_start + timedelta(seconds=float(p.elapsed_s)) + if self._peak_high_since is None: + self._peak_high_since = t + saturated = ((t - self._peak_high_since).total_seconds() + >= peakguard.ALARM_HOLD_S) + else: + self._peak_high_since = None was, self._peak_saturated = self._peak_saturated, saturated if not saturated or was: return - self.log_event("PEAK_GUARD_SATURATED - " + g.reason, - hint=messages.hint("peak_saturated")) _LOG.error( "Switch off large loads, the battery alone cannot keep this " "15-minute period under the peak limit: %s.", messages.reason_code(g.reason)) _LOG.debug("Evidence: %s.", g.reason) + # SAFETY-81: notified once per quarter; a later episode is logged only. + if p.block_start == self._peak_alarm_block: + return + self._peak_alarm_block = p.block_start + self.log_event("PEAK_GUARD_SATURATED - " + g.reason, + hint=messages.hint("peak_saturated")) # A fixed notification_id, and a DIFFERENT one from every other alarm # here - check_meter_frozen() gives the reasoning. A saturated peak guard # is not a frozen meter and is not an out-of-range setpoint. @@ -4910,6 +5928,8 @@ class Controller: 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`. @@ -4930,6 +5950,10 @@ class Controller: """ g = self.peak_guard limited = bool(g is not None and g.floor_limited) + if limited and (g.floor_w or 0.0) > 0.0: + limited = self._reserve_short(g) + else: + self._reserve_batt_prev = self._reserve_high_since = None was, self._peak_floor_limited = self._peak_floor_limited, limited if not limited or was: return @@ -4952,13 +5976,8 @@ class Controller: # 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. WHICH PACK STOPPED THE SITE. `g.soc_floor` is the floor - # the decision was actually taken with, so this re-grades nothing and - # cannot disagree with the alarm it is printed inside. At a roster of one - # no pack is named - there is exactly one. - _blocked = [name for name, why - in peakguard.pack_permissions(self.packs, g.soc_floor) - if why is not None] + # 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. @@ -4967,8 +5986,31 @@ class Controller: g.ceiling_w, soc_unread=g.soc is None, floor_unknown=(g.soc is not None and g.soc_floor is None), - packs=(_blocked if len(self.inverters) > 1 - else ())).data()) + packs=(g.held if len(self.inverters) > 1 + else ()), + others=(g.floor_w or 0.0) > 0.0).data()) + + def _reserve_short(self, g) -> bool: + """SAFETY-87: the batteries that can still give, at full power, miss + the limit (`load_too_high()` on `g.able_w`), for ALARM_HOLD_S, as + SAFETY-81 grades 'Load too high'.""" + p, batt = self._peak_proj, self.site_batt_w + if batt is None: + return self._peak_floor_limited + prev, self._reserve_batt_prev = self._reserve_batt_prev, batt + if prev is not None: + batt = min(float(batt), float(prev)) + if not (p is not None and p.block_start is not None + and peakguard.load_too_high(p, batt_w=batt, + ceiling_w=g.ceiling_w, + achievable_w=g.able_w)): + self._reserve_high_since = None + return False + t = p.block_start + timedelta(seconds=float(p.elapsed_s)) + if self._reserve_high_since is None: + self._reserve_high_since = t + return ((t - self._reserve_high_since).total_seconds() + >= peakguard.ALARM_HOLD_S) async def check_circulating(self) -> None: """SAFETY-31 + SAFETY-34. The alarm, and it must survive a restart. @@ -5613,6 +6655,14 @@ class Controller: else: out.append({"ok": True, "warn": False, "id": "controller", "text": messages.row_controller_ran(_pass_age)}) + # SAFETY-112. The headline unless control has stalled: while the + # Supervisor gives 502 (Home Assistant not yet RUNNING) no reading or + # write gets through, and the rows below that need it follow from it. + if OUTAGE.waiting(): + out.insert(1 if out[0]["ok"] is False else 0, + {"ok": False, "warn": False, "id": "home_assistant", + "banner": messages.banner_ha_waiting(), + "text": messages.row_ha_waiting()}) if self.p1_enabled: age = self.p1.published_age_s if (self.p1.stale and self.p1.age_s() is None @@ -5739,8 +6789,10 @@ class Controller: "text": messages.row_peak_guard_waiting()}) elif _pg.saturated: out.append({"ok": False, "warn": False, "id": "peak_guard", + "runs": True, "text": messages.row_peak_saturated(_pg.ceiling_w)}) - elif _pg.floor_limited: + elif _pg.floor_limited and (not (_pg.floor_w or 0.0) > 0.0 + or self._peak_floor_limited): # 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 @@ -5766,22 +6818,26 @@ class Controller: # 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. - _blocked = [name for name, why - in peakguard.pack_permissions(self.packs, - _pg.soc_floor) - if why is not None] + # SAFETY-87: `runs`, control goes on (status_payload()). out.append({"ok": False, "warn": False, "id": "peak_guard", + "runs": True, "banner": messages.banner_peak_reserve(), "text": messages.row_peak_reserve( _pg.ceiling_w, soc_unread=_pg.soc is None, floor_unknown=(_pg.soc is not None and _pg.soc_floor is None), - packs=(_blocked if len(self.inverters) > 1 - else ()))}) + packs=(_pg.held if len(self.inverters) > 1 + else ()), + others=(_pg.floor_w or 0.0) > 0.0)}) elif _pg.blind: out.append({"ok": False, "warn": True, "id": "peak_guard", "text": messages.row_peak_guard_blind( peakguard.blind_kind(_pg.blind))}) + elif self.peak_unmeasured_s > 0.0: + # SAFETY-90: the start of this quarter came before the first + # reading, and the guard counts it as high use. + out.append({"ok": False, "warn": True, "id": "peak_guard", + "text": messages.row_peak_guard_unmeasured()}) elif _pg.engaged: out.append({"ok": False, "warn": True, "id": "peak_guard", "text": messages.row_peak_guard_on( @@ -5898,7 +6954,8 @@ class Controller: elif _cw.active: # SAFETY-40: the integrator bound alone is amber, not red. out.append({"ok": False, "warn": True, "id": "not_keeping_up", - "text": messages.row_not_keeping_up(_cw.bound)}) + "text": messages.row_not_keeping_up( + _cw.bound_episodes)}) # No over-rating in THIS episode (`bug` would be set), so any count # is from an earlier one: keep that software-fault evidence shown. if _cw.over_rating: @@ -5910,10 +6967,10 @@ class Controller: out.append({"ok": False, "warn": True, "id": "power_too_high", "text": messages.row_before( messages.T_OUTPUT_CLAMP, _cw.over_rating)}) - if _cw.bound: + if _cw.bound_episodes: out.append({"ok": False, "warn": True, "id": "not_keeping_up", "text": messages.row_before( - messages.T_LOOP_BOUND, _cw.bound)}) + messages.T_LOOP_BOUND, _cw.bound_episodes)}) for inv in self.inverters: if inv.last_write_ok is False: @@ -6032,6 +7089,17 @@ class Controller: # is both is a check nobody reads. out.append({"ok": False, "warn": True, "id": "peak_forecast", "text": messages.row_no_peak_forecast()}) + # OPT-17: a forecast summed from several entities names, by position, + # each member with no usable reading. A caution: control goes on. + gaps = getattr(self, "solar_fc_gaps", ()) + for day, ents in (("today", getattr(self, "fc_entities", ())), + ("tomorrow", + getattr(self, "fc_tomorrow_entities", ()))): + gone = [i for d, i, _e, _w in gaps if d == day] + if gone: + out.append({"ok": False, "warn": True, "id": "solar_forecast", + "text": messages.row_forecast_gap( + day, gone, len(ents))}) # --- 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 @@ -6100,6 +7168,11 @@ class Controller: def status_payload(self, lang: str = "en") -> dict: checks = self.checks() bad = [c for c in checks if not c["ok"] and not c["warn"]] + # SAFETY-87: a red row with `runs` (the peak alarms) is an alarm while + # control goes on, so it neither reads "Not ready" nor holds the page. + runs = [c for c in bad if c.get("runs")] + bad = [c for c in bad if not c.get("runs")] + running_alarm = waiting = False # --- 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 @@ -6150,8 +7223,15 @@ class Controller: # headline nobody reads. UX-08: without the key, the banner takes # the row's first sentence, so it stays a headline. level, banner = "bad", messages.banner_not_ready(bad[0]) + # SAFETY-112: nothing to do but wait, so Home says that instead + # of "Needs you now". Only when it IS the headline: a safety stop + # above outranks it and still needs a person. + waiting = bad[0].get("id") == "home_assistant" elif not self.auto: level, banner = "warn", messages.banner_stopped() + elif runs: + level, running_alarm = "bad", True + banner = runs[0].get("banner") or messages.headline(runs[0]["text"]) elif any(i.delivery_wd.tripped for i in self.inverters): # ⚠️ 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. @@ -6191,6 +7271,7 @@ class Controller: # date, unchanged. _now = datetime.now(timezone.utc) _dues = [m.next_due(_now) for m in self.maints] + _waits = [u for u in (m.backoff_until(_now) for m in self.maints) if u] next_due = None if any(d is None for d in _dues) else min(_dues) rows = [ ("Grid", w(self.grid)), @@ -6238,6 +7319,10 @@ class Controller: *((("Maintenance waiting", "; ".join(f"{n}: {why}" for n, why in self._maint_deferred)),) if self._maint_deferred else ()), + # MAINT-13: only while a failed cycle waits, so a site with nothing + # failed carries exactly the rows it carried before. + *((("Maintenance paused", messages.row_maint_waits( + min(_waits).strftime("%Y-%m-%d"))),) if _waits else ()), ("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")), ("Cheap window", "yes" if self.cheap else "no"), ("Tariff", self.tariff_words), @@ -6273,6 +7358,7 @@ class Controller: decided_at=self.arb_at, cycle_at=self.cycle_at, now=datetime.now(timezone.utc), why=self.why_words)), ] + held = level == "bad" and not running_alarm out = { "banner": banner, "level": level, "rows": rows, "checks": checks, "hint": self.hint, @@ -6287,7 +7373,7 @@ class Controller: # fields are served alone (the page then shows the installer details). try: out.update(homeview.build(self, level=level, checks=checks, - lang=lang)) + lang=lang, held=held, waiting=waiting)) except Exception: # noqa: BLE001 # Once per process at ERROR: the page polls every 3 s. if not getattr(self, "_home_failed", False): @@ -6458,6 +7544,10 @@ class Controller: return None +# MAINT-13: a battery power reading at or below minus this many W is a pack +# that is taking charge. +MAINT_CHARGING_W = 50.0 + # SAFETY-61. Exit status when the control task dies; 70 is sysexits(3) # EX_SOFTWARE. See DECISIONS/SAFETY-61-control-task-death.md. EXIT_CONTROL_DIED = 70 @@ -6539,6 +7629,47 @@ async def docs_link_facts(session, token: str) -> dict: return out +async def ha_time_zone(session, token: str) -> tuple: + """MAINT-10: `(zone, why)` - Home Assistant's configured time zone, or None + and a short reason. Same 3 s budget as the docs link: never holds up start.""" + try: + async with session.get(f"{CORE_API}/config", timeout=DOCS_LINK_TIMEOUT_S, + headers={"Authorization": f"Bearer {token}"}) as r: + body = await r.json(content_type=None) + zone = body.get("time_zone") if isinstance(body, dict) else None + return (zone, "") if zone else (None, "no time_zone in its answer") + except Exception as err: # noqa: BLE001 - a fallback exists, never a stop + text = str(err).strip() + return None, f"{type(err).__name__}: {text}" if text else type(err).__name__ + + +_UTC_NAMES = {"UTC", "ETC/UTC", "GMT", "ETC/GMT", "UCT", "ETC/UCT", + "UNIVERSAL", "ETC/UNIVERSAL", "ZULU", "ETC/ZULU"} + + +async def maint_site_tz(session, token: str): + """MAINT-10: the zone the maintenance start hour is read in, logged once.""" + zone, why = await ha_time_zone(session, token) + tz, name, source, why = site_zone(zone, why, os.environ.get("TZ")) + if source == "ha": + _LOG.info("Maintenance start hour is local time in %s, from Home " + "Assistant.", name) + elif source == "tz" and name.strip().upper() in _UTC_NAMES: + # The Supervisor sets TZ to its copy of HA's zone, or to UTC if it has + # none: HA did not answer here, and UTC may not be the site's clock. + _LOG.warning("Maintenance start hour uses UTC, because Home Assistant " + "did not answer. Restart the add-on when it is up.") + _LOG.debug("maintenance time zone: %s", why) + elif source == "tz": + _LOG.info("Maintenance start hour is local time in %s, from the " + "add-on's time zone. Home Assistant gave none: %s.", name, why) + else: + _LOG.warning("Maintenance start hour uses UTC, because no time zone " + "was found. Set the time zone in Home Assistant.") + _LOG.debug("maintenance time zone: %s", why) + return tz + + async def amain() -> None: opts = load_options() logging.basicConfig( @@ -6648,7 +7779,8 @@ async def amain() -> None: pub = _NoMqtt() stop = asyncio.Event() - controller = Controller(opts, hass, store, pub, stop=stop) + controller = Controller(opts, hass, store, pub, stop=stop, + site_tz=await maint_site_tz(session, _tok)) if callable(getattr(pub, "bind", None)): pub.bind(asyncio.get_running_loop(), controller.mqtt_command) runner = await web.start(controller, port=INGRESS_PORT) diff --git a/goodwe_controller/app/maintenance.py b/goodwe_controller/app/maintenance.py index 32fe91f..d38b205 100644 --- a/goodwe_controller/app/maintenance.py +++ b/goodwe_controller/app/maintenance.py @@ -34,7 +34,7 @@ simply not do. import logging import math from dataclasses import dataclass, field -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta, timezone, tzinfo from .store import MAINT_UNIT_KEYS, unit_key @@ -65,10 +65,30 @@ POWER_MOVING = (DRAIN, CHARGE, HOLD) # 12 h means the battery is not following commands - a fault, not a slow day. The charge phase deliberately waits for sun and for cheap hours, so # it is expected to span a night; 36 h still bounds it. DRAIN_TIMEOUT_MIN = 720 +# MAINT-13: minutes the pack was ALLOWED to charge (the price gate open), not +# wall-clock minutes since the charge began. See CHARGE_WALL_TIMEOUT_MIN. CHARGE_TIMEOUT_MIN = 2160 # After an abort, do not immediately re-trigger in the same start hour. RESTART_COOLDOWN_MIN = 90 +# MAINT-13 (DECISIONS/MAINT-13-full-pack-is-complete.md). Constants, not options. +# A pack that reaches target - 3 and then takes no more charge for this many +# minutes of allowed charging counts as full. +CHARGE_NEAR_FULL_PCT = 3 +CHARGE_STALL_MIN = 60 +# Wall-clock backstop for a charge whose gate never opens (no cheap hours, no sun). +CHARGE_WALL_TIMEOUT_MIN = 10080 +# A failed cycle (stuck drain or charge) is not retried before this many days. +FAIL_BACKOFF_DAYS = 7 +# One tick counts at most this many minutes: a gap means the add-on was down. +CHARGE_TICK_MAX_MIN = 5 +# The charge accounting is written to the state file at most this often. +CHARGE_SAVE_MIN = 5 +# MAINT-14 (DECISIONS/MAINT-14-no-soc-in-maintenance.md): with no SoC reading +# the timeouts still run, a drain holds at 0 W, and the log says once, after this +# many minutes, that the level has been unknown for long. +NOSOC_NOTE_MIN = 15 + # ⚠️ CFG-03. THE ONE IN-CODE MIRROR OF config.yaml FOR EVERY `maintenance_*` # OPTION, and the only place in the add-on where one of these numbers is typed. # @@ -112,7 +132,9 @@ RESTART_COOLDOWN_MIN = 90 MAINT_OPTION_DEFAULTS = { "maintenance_enabled": False, "maintenance_interval_days": 28, - "maintenance_start_hour": 10, + # MAINT-10: a LOCAL hour (site_zone() below), 19:00 so the drain runs + # into the night rather than through the solar day. + "maintenance_start_hour": 19, # ⚠️ 2000, NOT THE 2500 THIS SHIPPED AS, and config.yaml carries the # argument: both rates must stay at or below `max_w` (2000) or the # cheap-window charge shaping asks the arbiter for `hi = -charge_w` against @@ -177,6 +199,69 @@ class MaintConfig: ) +# --------------------------------------------------------------------------- # +# MAINT-10: the start hour is local time. See DECISIONS/MAINT-10-local-start-hour.md. +# --------------------------------------------------------------------------- # +def _load_zone(name): + """A tzinfo for an IANA name, or `(None, why)`.""" + if not name or not isinstance(name, str): + return None, "none given" + try: + from zoneinfo import ZoneInfo + return ZoneInfo(name.strip()), "" + except Exception as err: # noqa: BLE001 - any failure + return None, f"{name!r} is not a known zone ({type(err).__name__})" + + +def site_zone(ha_zone, ha_why: str, env_tz) -> tuple: + """`(tzinfo, name, source, why)`: Home Assistant's zone (source "ha"), else + the container's TZ ("tz"), else UTC ("utc"). `why` says what was missing.""" + tz, why = _load_zone(ha_zone) + if tz is not None: + return tz, ha_zone, "ha", "" + ha_why = ha_why or why + tz, env_why = _load_zone(env_tz) + if tz is not None: + return tz, env_tz, "tz", ha_why + return timezone.utc, "UTC", "utc", f"Home Assistant: {ha_why}, TZ: {env_why}" + + +def in_start_slot(now: datetime, start_hour: int, tz: tzinfo) -> bool: + """Is `now` inside today's one-hour start slot, read on the local clock? + + Normally the slot is the local hour `start_hour`. On the day the clocks go + forward and that hour does not exist, it is the first hour after the jump. + On the day they go back and it happens twice, only the first one counts. + Worked from UTC instants only (`astimezone`), so it does not depend on how + a tzinfo resolves a wall time that is missing or doubled. + """ + if now.tzinfo is None: + now = now.replace(tzinfo=timezone.utc) + here = now.astimezone(tz).replace(tzinfo=None) + before = (now - timedelta(hours=1)).astimezone(tz).replace(tzinfo=None) + if here.hour == start_hour: + # the second pass through a repeated hour: an hour ago read the same + return not (before.date() == here.date() and before.hour == start_hour) + slot = here.replace(hour=start_hour, minute=0, second=0, microsecond=0) + # the clock jumped over the start of the slot within the last hour + return before < slot and here >= slot + timedelta(hours=1) + + +def next_slot_start(now: datetime, start_hour: int, tz: tzinfo): + """The start of the slot `now` is in, or else of the next one; None if + there is none within 50 h (no real zone does that).""" + step = timedelta(minutes=15) + t = now.replace(minute=now.minute - now.minute % 15, second=0, + microsecond=0) - timedelta(hours=1) + for _ in range(4 * 50): + if (in_start_slot(t, start_hour, tz) + and not in_start_slot(t - step, start_hour, tz) + and t + timedelta(hours=1) > now): + return t + t += step + return None + + @dataclass class MaintResult: phase: str @@ -192,6 +277,9 @@ class MaintResult: # showing an idle machine and a due date in the past. Empty on every path # that is not deferring, so a roster of one never sees it. deferred: str = "" + # MAINT-13: "drain" or "charge" on the one tick where a cycle FAILED, so + # the Controller can tell the operator once. Empty on every other tick. + failed: str = "" class UnitHistory: @@ -256,8 +344,12 @@ class Maintenance: """ def __init__(self, cfg: MaintConfig, store, unit: str | None = None, - name: str = "", mirror_flat: bool = False): + name: str = "", mirror_flat: bool = False, + tz: tzinfo | None = None): self.cfg = cfg + # MAINT-10: the zone `cfg.start_hour` is read in - main.py resolves it + # once at startup (`site_zone()`). UTC when nobody says, as before. + self.tz = tz or timezone.utc # ⚠️ `self.store` IS THE VIEW, NOT THE Store. Everything in this class # names an UNQUALIFIED key - "phase", "last_completed" - and the view is # the one place that turns it into the key this pack owns. A raw @@ -275,6 +367,14 @@ class Maintenance: # per cycle would flush the very context that explains it. Same pattern # as `Controller._arb_action` and `_maint_shape_suspended`. self._deferred = "" + # MAINT-13: the charge accounting (see _charge_account), in memory and + # written to the state file every CHARGE_SAVE_MIN minutes. + self._trk = None + self._trk_saved = None + # MAINT-14: when the SoC reading went missing (None while it is there), + # and whether the "unknown for long" line was said in this episode. + self._nosoc_since = None + self._nosoc_said = False # -- state --------------------------------------------------------------- @property @@ -282,6 +382,11 @@ class Maintenance: return self.store.get("phase", IDLE) def _enter(self, phase: str, now: datetime, events: list, msg: str) -> None: + self._nosoc_since = None + self._trk = None + self._trk_saved = None + if self.store.get("charge_track") is not None: + self.store.set("charge_track", None) self.store.set("phase", phase) self.store.set_time("phase_started", now) _LOG.info("%s", msg) @@ -322,6 +427,21 @@ class Maintenance: return (last_try is None or (now - last_try) >= timedelta(minutes=RESTART_COOLDOWN_MIN)) + def backoff_until(self, now: datetime): + """MAINT-13: when a failed cycle may be tried again, or None. + + None when nothing failed, when the back-off is over, or when a cycle + completed after the failure (a forced start that worked ends it). + """ + failed = self.store.get_time("last_failed") + if failed is None: + return None + done = self.store.get_time("last_completed") + if done is not None and done >= failed: + return None + until = failed + timedelta(days=FAIL_BACKOFF_DAYS) + return until if now < until else None + def wants_start(self, now: datetime) -> bool: """Would this pack begin a cycle on this tick if the site let it? @@ -331,8 +451,10 @@ class Maintenance: about who was asking - so the IDLE branch of `tick()` calls this one. """ return (self.phase == IDLE and self.cfg.enabled - and now.hour == self.cfg.start_hour and self.due(now) - and self._cooled(now)) + and in_start_slot(now, self.cfg.start_hour, self.tz) + and self.due(now) + and self._cooled(now) + and self.backoff_until(now) is None) # -- operations ---------------------------------------------------------- def force_start(self, now: datetime, blocked_by: str = "") -> list: @@ -360,6 +482,140 @@ class Maintenance: self._enter(DRAIN, now, events, "maintenance: forced start -> drain") return events + def _charge_account(self, now: datetime, soc: float | None, + gate_open: bool) -> dict: + """MAINT-13: what this charge has used so far, as minutes of ALLOWED time. + + `open_min` counts only ticks with the price gate open; `flat_min` counts + gate-open minutes since the SoC last beat its best; a closed gate pauses + both. One tick adds at most CHARGE_TICK_MAX_MIN, so a gap (add-on down) + counts for nothing. A state file from before MAINT-13, or from another + charge, starts from the wall-clock minutes so far: the old behaviour. + + MAINT-14: `soc` may be None (no reading). Time still counts, the stall + wait pauses, and `last` keeps the last SoC seen (saved with the rest). + """ + since = self.store.get("phase_started") + trk = self._trk + if trk is None: + raw = self.store.get("charge_track") + if isinstance(raw, dict) and raw.get("since") == since: + trk = dict(raw) + else: + trk = {"since": since, "open_min": self._elapsed_min(now), + "flat_min": 0.0, + "best": None if soc is None else float(soc), + "last": None if soc is None else float(soc), "at": None} + self._trk = trk + at = trk.get("at") + try: + last = datetime.fromisoformat(at) if at else None + except (TypeError, ValueError): + last = None + dt = 0.0 if last is None else min( + CHARGE_TICK_MAX_MIN, max(0.0, (now - last).total_seconds() / 60.0)) + trk["at"] = now.isoformat() + if soc is None: + pass + elif trk.get("best") is None or soc > trk["best"]: + trk["best"], trk["flat_min"] = float(soc), 0.0 + elif gate_open: + trk["flat_min"] += dt + if soc is not None: + trk["last"] = float(soc) + if gate_open: + trk["open_min"] += dt + saved = self._trk_saved + if saved is None or (now - saved).total_seconds() >= CHARGE_SAVE_MIN * 60: + self.store.set("charge_track", dict(trk)) + self._trk_saved = now + return trk + + def _fail(self, now: datetime, kind: str, why: str) -> MaintResult: + """MAINT-13: stand down, remember the failure, and tell the Controller.""" + events = self.abort(now, why) + self.store.set_time("last_failed", now) + msg = (f"maintenance: the cycle did not finish. It waits " + f"{FAIL_BACKOFF_DAYS} days before it tries again.") + _LOG.warning("%s", msg) + events.append(msg) + return MaintResult(IDLE, True, 0.0, False, events, failed=kind) + + def _hold(self, now: datetime, events: list) -> MaintResult: + """HOLD for one tick, with or without a SoC reading (MAINT-15). + + HOLD needs no level to end: it is reached only after the charge counted + as done (MAINT-13/14), so its end is a time check and nothing else. + """ + elapsed = self._elapsed_min(now) + if elapsed >= self.cfg.hold_min: + self.store.set_time("last_completed", now) + self._enter(IDLE, now, events, + f"maintenance: cycle COMPLETE ({elapsed:.0f} min hold) " + "- handing back to the loop") + return MaintResult(IDLE, False, None, False, events) + # 0 W, not "do nothing": commanding zero is what stops the loop + # pulling the pack straight back down the moment it is full, which + # is exactly when the BMS needs it parked at the top. + return MaintResult(HOLD, True, 0.0, False, events) + + def _no_soc(self, now: datetime, phase: str, gate_open: bool, + events: list) -> MaintResult: + """MAINT-14: one tick of a pack in a moving phase with no SoC reading. + + HOLD stays at 0 W and ends at its normal time (MAINT-15). DRAIN holds at 0 W, like HOLD, and its 12 h + timeout still runs. CHARGE keeps its timeout and wall backstop: out of + time is a failure, except when the last SoC seen was within + CHARGE_NEAR_FULL_PCT of the target, which counts as done (MAINT-13). + Nothing here may start a phase change that needs the level. + """ + if self._nosoc_since is None: + self._nosoc_since = now + self._nosoc_said = False + self._trk_saved = None # save the last SoC seen, now + if phase == DRAIN: + msg = ("maintenance: the battery level is unknown, so the " + "drain holds at 0 W until it is back.") + _LOG.warning("%s", msg) + events.append(msg) + if (not self._nosoc_said and (now - self._nosoc_since).total_seconds() + >= NOSOC_NOTE_MIN * 60): + self._nosoc_said = True + tail = ("The hold still ends at its normal time." + if phase == HOLD else + f"The time limit of the {phase} still applies.") + msg = (f"maintenance: no battery level for {NOSOC_NOTE_MIN} min. " + f"{tail}") + _LOG.warning("%s", msg) + events.append(msg) + if phase == HOLD: + return self._hold(now, events) + elapsed = self._elapsed_min(now) + if phase == DRAIN: + if elapsed > DRAIN_TIMEOUT_MIN: + return self._fail(now, "drain", + f"drain stuck, battery level unknown, after " + f"{elapsed / 60:.0f} h") + return MaintResult(DRAIN, True, 0.0, False, events) + trk = self._charge_account(now, None, gate_open) + if (trk["open_min"] > CHARGE_TIMEOUT_MIN + or elapsed > CHARGE_WALL_TIMEOUT_MIN): + last = trk.get("last") + if last is not None and last >= self.cfg.soc_target - CHARGE_NEAR_FULL_PCT: + self._enter(HOLD, now, events, + f"maintenance: charge time is up and the last level " + f"seen was {last:.0f} %, close enough to full, so the " + f"charge counts as done -> holding " + f"{self.cfg.hold_min} min to balance") + return MaintResult(HOLD, True, 0.0, False, events) + if elapsed > CHARGE_WALL_TIMEOUT_MIN: + lim = f"the {CHARGE_WALL_TIMEOUT_MIN // 1440}-day limit" + else: + lim = f"{trk['open_min'] / 60:.0f} h of charging time" + return self._fail(now, "charge", + f"charge stuck, battery level unknown, after {lim}") + return MaintResult(CHARGE, False, None, True, events) + def abort(self, now: datetime, why: str) -> list: events = [] self._enter(IDLE, now, events, f"maintenance: ABORT ({why})") @@ -387,13 +643,16 @@ class Maintenance: # -- the machine --------------------------------------------------------- def tick(self, now: datetime, soc: float | None, - blocked_by: str = "") -> MaintResult: + blocked_by: str = "", gate_open: bool = True) -> MaintResult: """One cycle of this pack's state machine. `blocked_by` is `arbitrate()`'s answer for THIS pack: empty when the site's one maintenance slot is available to it, otherwise the reason it is not. It gates two things and nothing else - a start, and a phase that is already moving power on a pack that does not hold the slot. + + `gate_open` (MAINT-13) is whether the pack may charge now (the price + gate); only those minutes count towards the charge timeout. """ events: list = [] phase = self.phase @@ -444,13 +703,13 @@ class Maintenance: # transitions below mean anything, and a stale "100 %" would end the # charge phase having charged nothing. Hold the current phase's command # and wait; the phase timeout is the backstop. + # + # MAINT-14: "hold and wait" is not "wait for ever". The timeouts run + # here too, and a drain holds at 0 W (an unknown level never allows + # discharge, TEL-08). See `_no_soc`. if soc is None: - _LOG.warning("maintenance: no SoC reading, holding phase %s", phase) - if phase == DRAIN: - return MaintResult(DRAIN, True, self.cfg.discharge_w, False, events) - if phase == HOLD: - return MaintResult(HOLD, True, 0.0, False, events) - return MaintResult(CHARGE, False, None, True, events) + return self._no_soc(now, phase, gate_open, events) + self._nosoc_since = None elapsed = self._elapsed_min(now) @@ -461,8 +720,8 @@ class Maintenance: f"after {elapsed:.0f} min -> charge") return MaintResult(CHARGE, False, None, True, events) if elapsed > DRAIN_TIMEOUT_MIN: - events += self.abort(now, f"drain stuck at {soc:.0f} % after {elapsed:.0f} min") - return MaintResult(IDLE, True, 0.0, False, events) + return self._fail(now, "drain", f"drain stuck at {soc:.0f} % " + f"after {elapsed:.0f} min") return MaintResult(DRAIN, True, self.cfg.discharge_w, False, events) if phase == CHARGE: @@ -471,22 +730,31 @@ class Maintenance: f"maintenance: charge complete at {soc:.0f} % after " f"{elapsed:.0f} min -> holding {self.cfg.hold_min} min to balance") return MaintResult(HOLD, True, 0.0, False, events) - if elapsed > CHARGE_TIMEOUT_MIN: - events += self.abort(now, f"charge stuck at {soc:.0f} % after {elapsed:.0f} min") - return MaintResult(IDLE, True, 0.0, False, events) + trk = self._charge_account(now, soc, gate_open) + near = soc >= self.cfg.soc_target - CHARGE_NEAR_FULL_PCT + # MAINT-13 rule 1: close to full and no longer taking charge. + if near and trk["flat_min"] >= CHARGE_STALL_MIN: + self._enter(HOLD, now, events, + f"maintenance: the battery takes no more charge at " + f"{soc:.0f} %, so the charge counts as done -> holding " + f"{self.cfg.hold_min} min to balance") + return MaintResult(HOLD, True, 0.0, False, events) + if (trk["open_min"] > CHARGE_TIMEOUT_MIN + or elapsed > CHARGE_WALL_TIMEOUT_MIN): + # MAINT-13 rule 2: out of time close to full is complete. + if near: + self._enter(HOLD, now, events, + f"maintenance: charge time is up at {soc:.0f} %, " + f"close enough to full, so the charge counts as " + f"done -> holding {self.cfg.hold_min} min to balance") + return MaintResult(HOLD, True, 0.0, False, events) + return self._fail(now, "charge", + f"charge stuck at {soc:.0f} % after " + f"{trk['open_min']:.0f} min of charging time") return MaintResult(CHARGE, False, None, True, events) if phase == HOLD: - if elapsed >= self.cfg.hold_min: - self.store.set_time("last_completed", now) - self._enter(IDLE, now, events, - f"maintenance: cycle COMPLETE ({elapsed:.0f} min hold) " - "- handing back to the loop") - return MaintResult(IDLE, False, None, False, events) - # 0 W, not "do nothing": commanding zero is what stops the loop - # pulling the pack straight back down the moment it is full, which - # is exactly when the BMS needs it parked at the top. - return MaintResult(HOLD, True, 0.0, False, events) + return self._hold(now, events) _LOG.error("maintenance: unknown phase %r, forcing idle", phase) events += self.abort(now, f"unknown phase {phase!r}") diff --git a/goodwe_controller/app/messages.py b/goodwe_controller/app/messages.py index 4445a60..5dd84ea 100644 --- a/goodwe_controller/app/messages.py +++ b/goodwe_controller/app/messages.py @@ -102,6 +102,7 @@ class Notice: title: str body: str section: str = "" # the DOCS.md heading, when it is not the title itself + body_nl: str = "" # MAINT-13: the same words in Dutch, shown under the English @property def docs(self) -> str: @@ -110,7 +111,9 @@ class Notice: def data(self) -> dict: """The `persistent_notification.create` payload.""" return {"notification_id": self.id, "title": self.title, - "message": f"{self.body}\n\n{details(self.docs)}"} + "message": f"{self.body}\n\n" + + (f"{self.body_nl}\n\n" if self.body_nl else "") + + details(self.docs)} # --- the catalogue ---------------------------------------------------------- @@ -124,6 +127,8 @@ T_CIRCULATING = "Inverters working against each other" T_NOT_DELIVERING = "{unit} is not delivering" S_NOT_DELIVERING = "An inverter is not delivering" T_METER_FROZEN = "Meter reading may be stuck" +T_MAINT_FAILED = "Battery cycle did not finish" +T_MAINT_FAILED_NL = "Batterijcyclus niet afgerond" ID_LOOP_BOUND = "goodwe_integrator_bound" ID_OUTPUT_CLAMP = "goodwe_output_clamp" @@ -132,6 +137,7 @@ ID_PEAK_RESERVE = "goodwe_peak_soc_floor" ID_CIRCULATING = "goodwe_circulating_power" ID_NOT_DELIVERING = "goodwe_not_delivering_{slug}" ID_METER_FROZEN = "goodwe_meter_frozen" +ID_MAINT_FAILED = "goodwe_maint_failed_{slug}" # Which limit cut a command (clamp.ClampResult.bound, plus the loop's own). # The setting is named by its label, "Inverter rating (W)". @@ -217,7 +223,7 @@ def peak_saturated(ceiling_w, *, shadow: bool = False) -> Notice: def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool, - packs=()) -> Notice: + packs=(), others: bool = False) -> Notice: """SAFETY-24/25/32: the peak guard may not draw the battery any lower. The last sentence is SAFETY-25 and must stay: the reserve binds the peak @@ -227,7 +233,8 @@ def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool, roster means some inverter's cut-out level is missing (the peak reserve itself always has a value) - so the action names the cut-out level. Both are named by their setting labels, "Peak reserve (%)" and "Battery - cut-out level (%)". + cut-out level (%)". `others`: the other batteries give all they can and + it is not enough (SAFETY-87), so the limit MAY be passed. """ if soc_unread: why = "The battery level cannot be read" @@ -241,6 +248,12 @@ def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool, why = (f"The battery of {names(packs)} is at its peak reserve" if packs else "The battery is at its peak reserve") act = "Switch off large loads, or lower the peak reserve." + if others: + return Notice(ID_PEAK_RESERVE, ALARM, T_PEAK_RESERVE, + f"{why}, and the other batteries cannot keep this " + f"15-minute period under your {watts(ceiling_w)} peak " + f"limit alone. {act} Home use can still draw a battery " + f"below its reserve, down to its cut-out level.") return Notice(ID_PEAK_RESERVE, ALARM, T_PEAK_RESERVE, f"{why}, so this 15-minute period will go over your " f"{watts(ceiling_w)} peak limit and raise this month's peak. " @@ -288,6 +301,24 @@ def meter_frozen(unchanged_s) -> Notice: f"stop control on the add-on page.") +def maint_failed(kind: str, days: int, unit: str = "", slug: str = "") -> Notice: + """MAINT-13: a monthly cycle stopped before it was done. Said once.""" + full = kind == "charge" + who = unit or "The battery" + who_nl = unit or "De batterij" + en = f"{who} did not {'fill up' if full else 'empty'}, so its cycle " \ + f"stopped and tries again in {days} days." + nl = f"{who_nl} raakte niet {'vol' if full else 'leeg'}, dus de cyclus is " \ + f"gestopt en probeert het over {days} dagen opnieuw." + return Notice(ID_MAINT_FAILED.format(slug=slug or "battery"), WARNING, + T_MAINT_FAILED, en, body_nl=f"{T_MAINT_FAILED_NL}. {nl}") + + +def row_maint_waits(until_text: str) -> str: + """MAINT-13: the status row while a failed cycle waits.""" + return f"Waits until {until_text} after a cycle that did not finish." + + # --- the status page (UX-08 phase 2b) ---------------------------------------- # The rows of the Commissioning list and the banner at the top of the add-on # page. `main.py` decides WHEN a row shows and its colour (ok / warn / bad); @@ -405,6 +436,17 @@ def banner_shadow(latched: bool) -> str: return "Shadow mode: the battery is not controlled. Inverters stay at 0 W." +def banner_ha_waiting() -> str: + """SAFETY-112: the Supervisor passes nothing on until Home Assistant has + finished starting, so the add-on can neither read nor write.""" + return "Waiting for Home Assistant to finish starting" + + +def row_ha_waiting() -> str: + return ("Home Assistant is not answering, perhaps it is still starting. " + "Control resumes by itself when it answers.") + + # Rows: control and the meter. "Control" is what the add-on does, as in # "start control" (STYLE.md section 4). def row_controller_starting() -> str: @@ -470,8 +512,9 @@ def row_peak_saturated(ceiling_w) -> str: def row_peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool, - packs=()) -> str: - """SAFETY-24/25/32. The second sentence is SAFETY-25 and must stay.""" + packs=(), others: bool = False) -> str: + """SAFETY-24/25/32. The last sentence is SAFETY-25 and must stay. + `others`: as in `peak_reserve()` (SAFETY-87).""" if soc_unread: why = "Battery level cannot be read" elif floor_unknown: @@ -479,6 +522,10 @@ def row_peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool, else: why = (f"{names(packs)} battery at its peak reserve" if packs else "Battery at its peak reserve") + if others: + return (f"{why}. The others cannot hold the {watts(ceiling_w)} peak " + f"limit alone. Home use still draws it lower, to the cut-out " + f"level.") return (f"{why}, so the {watts(ceiling_w)} peak limit will be passed. Home " f"use still draws the battery below it, down to the cut-out level.") @@ -718,6 +765,15 @@ def row_pv_partial(missing, count) -> str: f"reading") +def row_forecast_gap(day, missing, count) -> str: + """OPT-17. `day` is "today" or "tomorrow"; `missing` are the 1-based + positions of the forecast entities with no usable reading.""" + which = names([str(n) for n in missing]) + many = len(missing) > 1 + return (f"Solar forecast for {day}: not used, source{'s' if many else ''} " + f"{which} of {count} {'have' if many else 'has'} no reading") + + def row_pv_single_ignored() -> str: return f"Solar production: clear {PV_ONE}, the per-inverter list replaces it" @@ -781,6 +837,12 @@ def row_peak_guard_blind(why: str) -> str: f"meanwhile") +def row_peak_guard_unmeasured() -> str: + """SAFETY-90. Before the first reading; that part counts as high use.""" + return ("Peak guard: part of this 15-minute period was not measured, " + "counted as high use") + + def row_peak_guard_on(floor_w, limit_w, *, shadow: bool = False) -> str: if shadow: return (f"Peak guard: would ask for {watts(floor_w)} to stay under " @@ -835,6 +897,8 @@ WHY_LIMIT = { "pack": "the battery level", "reserve": "the battery reserve", "maintenance": "battery maintenance", + "charge": "battery maintenance", + "hold": "battery maintenance", "refused": "a battery that refuses power", } @@ -898,6 +962,7 @@ HINTS = { "peak_on": "Peak guard started", "peak_off": "Peak guard idle", "peak_blind": "Peak guard cannot read the meter well", + "peak_gap": "Meter is back, the peak guard counts the missed time as high use", "peak_saturated": T_PEAK_SATURATED, "peak_reserve": T_PEAK_RESERVE, "meter_frozen": T_METER_FROZEN, @@ -906,6 +971,9 @@ HINTS = { "trade_charge": "Trading: charging while the price is low", "trade_give": "Trading: giving power while the price is high", "trade_off": "Trading: waiting for a better price", + "forecast_read": "Solar forecast read, the day plan uses it", + "forecast_none": "No solar forecast, so Leftover solar takes solar only", + "forecast_no_tomorrow": "Tomorrow's solar forecast is not used, a source has no reading", "split": "The power split between inverters changed", "clash": "Two limits clash, inverters set to 0 W", "no_setpoint": "An inverter has no “Setpoint entity”, inverters stay at 0 W", @@ -924,11 +992,13 @@ HINTS = { "not_delivering_cleared": "{unit}: not-delivering alarm cleared", "maintenance": "Battery maintenance: {phase}", "maint_paused": "Maintenance charging paused to stay under the peak limit", + "maint_failed": "The battery cycle did not finish, so it waits before trying again", "pack_on": "{unit}: battery switched on", "pack_off": "{unit}: battery switched off, it is asked for 0 W", "pack_mode": "{unit}: battery mode changed", "pack_sell": "{unit}: selling switched", "maint_off": "{unit}: maintenance stopped, the battery is switched off", + "maint_waits": "{unit} cannot give power now, so its monthly cycle waits", "mode_fault": "{unit}: battery mode ignored because of a software fault", } @@ -996,6 +1066,10 @@ def status_samples() -> list: ("banner_setup_only", None, banner_setup_only()), ("row_setup_only", AMBER, row_setup_only()), ("banner_shadow", None, banner_shadow(True)), + ("banner_not_ready", None, banner_not_ready( + {"text": row_ha_waiting(), "banner": banner_ha_waiting()})), + ("banner_ha_waiting", None, banner_ha_waiting()), + ("row_ha_waiting", RED, row_ha_waiting()), ("row_controller_starting", GREEN, row_controller_starting()), ("row_controller_ran", GREEN, row_controller_ran(2)), ("row_controller_stalled", RED, row_controller_stalled(45, 30)), @@ -1009,6 +1083,9 @@ def status_samples() -> list: ("row_peak_guard_off", AMBER, row_peak_guard_off()), ("row_peak_reserve", RED, row_peak_reserve( 5000, soc_unread=False, floor_unknown=False, packs=["inverter-b"])), + ("row_peak_reserve", RED, row_peak_reserve( + 5000, soc_unread=False, floor_unknown=False, packs=["inverter-b"], + others=True)), ("row_peak_reserve", RED, row_peak_reserve( 5000, soc_unread=True, floor_unknown=False)), ("row_peak_reserve", RED, row_peak_reserve( @@ -1062,6 +1139,8 @@ def status_samples() -> list: "entity - it cannot be read in watts")), ("row_pv_partial", AMBER, row_pv_partial([2, 3], 3)), ("row_pv_partial", AMBER, row_pv_partial([2], 2)), + ("row_forecast_gap", AMBER, row_forecast_gap("today", [2], 3)), + ("row_forecast_gap", AMBER, row_forecast_gap("tomorrow", [1, 2], 2)), ("row_pv_single_ignored", AMBER, row_pv_single_ignored()), ("row_pv_negative", AMBER, row_pv_negative(2, 3, -1500, False)), ("row_pv_negative", AMBER, row_pv_negative(1, 1, -1500, True)), @@ -1077,6 +1156,7 @@ def status_samples() -> list: ("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, True)), ("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, False)), ("row_peak_guard_quiet", GREEN, row_peak_guard_quiet()), + ("row_peak_guard_unmeasured", AMBER, row_peak_guard_unmeasured()), *(("row_peak_guard_blind", AMBER, row_peak_guard_blind(k)) for k in BLIND_WORDS), ("row_peak_guard_on", AMBER, row_peak_guard_on(1200, 2500)), @@ -1090,6 +1170,7 @@ def status_samples() -> list: ("row_battery_refuses", AMBER, row_battery_refuses("inverter-b", 1, True)), ("row_circulating_check", GREEN, row_circulating_check(0, 0, 60)), + ("row_maint_waits", AMBER, row_maint_waits("2026-10-13")), ] @@ -1111,6 +1192,8 @@ def samples() -> list: peak_reserve(5000, soc_unread=False, floor_unknown=False), peak_reserve(5000, soc_unread=False, floor_unknown=False, packs=["inverter-b"]), + peak_reserve(5000, soc_unread=False, floor_unknown=False, + packs=["inverter-b"], others=True), peak_reserve(5000, soc_unread=True, floor_unknown=False), peak_reserve(5000, soc_unread=False, floor_unknown=True), circulating(["inverter-a"], ["inverter-b"]), @@ -1118,6 +1201,8 @@ def samples() -> list: not_delivering("inverter-b", "inverter_b"), meter_frozen(600), meter_frozen(90), + maint_failed("charge", 7), + maint_failed("drain", 7, "inverter-b", "inverter_b"), ] @@ -1172,6 +1257,11 @@ LABELS = { "batt_entity": "Battery power entity", "pv_entity": "Solar production entity", "pv_entities": "Solar production entities, per inverter", + "pv_forecast_entity": "Solar forecast, today", + "pv_forecast_tomorrow_entity": "Solar forecast, tomorrow", + "pv_forecast_entities": "Several solar forecasts, today", + "pv_forecast_tomorrow_entities": "Several solar forecasts, tomorrow", + "solar_trust_pct": "Trust in the forecast", "p1_phase_import_entities": "Import entities per phase", "p1_phase_export_entities": "Export entities per phase", } diff --git a/goodwe_controller/app/modetrade.py b/goodwe_controller/app/modetrade.py index 7e22e2b..e278b9a 100644 --- a/goodwe_controller/app/modetrade.py +++ b/goodwe_controller/app/modetrade.py @@ -23,7 +23,11 @@ Roles (the page's Why line names them): Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py): * every range holds 0 W (a mode may forbid a direction, never force one); - * a Solar first battery never charges from the grid (no forecast yet, P4); + * a Solar first battery never charges from the grid; + * P4: with a day plan (`targets`), a Leftover solar battery charges from + the grid only up to its planned level, never above the ceiling; with + none it takes solar only (P3 option A). Cheap hours only fills to the + ceiling either way (`charge_to()`); * grid charging needs a measured peak guard that is clear and a measured headroom (SAFETY-45); the trade is sized to that headroom, whose aim is `peak_cap_w - peak_margin_w` (SAFETY-79). It starts only at START_W or @@ -42,13 +46,15 @@ Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py): run in that direction: the refusal wins over the run. The one exception is its SAFETY-73 probe inside a cheap run (OPT-15): it is a charger then, pinned at PROBE_W by its own claim, so the others rest (one sign) and the - card's "tries again every 15 minutes" holds in the run too. + card's "tries again every 15 minutes" holds in the run too; + * no grid charge while another battery drains for its monthly cycle + (`draining`, SAFETY-78): it would charge against the drain. """ from dataclasses import dataclass from .packmode import (CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, - SOLAR_FIRST) + PLAN_CHARGE_MODES, SOLAR_FIRST) OFF, HOUSE, HOLD, GIVE, SELL, CHARGE, REST = ( "off", "house", "hold", "give", "sell", "charge", "rest") @@ -92,16 +98,38 @@ def _room_kwh(p: Pack, pct: float) -> float: return max(0.0, abs(pct - p.soc)) / 100.0 * float(p.capacity_kwh) -def chargers(packs, ceiling_pct, done=(), probe=False) -> list: - """Batteries that may charge from the grid in a cheap run: the modes in - `packmode.GRID_CHARGE_MODES` (Cheap hours only until P4's forecast). - With `probe`, also one refusing to charge while its SAFETY-73 probe is - open: its own P_SAFETY pin keeps it at PROBE_W (OPT-15).""" - return [p for p in packs - if p.use and p.idle and p.live and p.mode in GRID_CHARGE_MODES - and p.soc is not None and p.soc < ceiling_pct - and (p.refused >= 0 or (probe and p.probing)) - and p.name not in done] +def charge_to(p: Pack, ceiling_pct, targets=None): + """The level this battery may charge to from the grid, or None. + + `targets` is the day plan (`dayplan.plan()`: name -> level), or None + when there is none (no forecast, stale, or not configured). With no plan + this is P3 option A exactly: Cheap hours to the ceiling, the others never. + With a plan, a Leftover solar battery charges only to its planned level + (the sun fills the rest); a battery the plan leaves out, Cheap hours only + included, gets the no-plan answer. Solar first never charges.""" + if p.mode in GRID_CHARGE_MODES: + return float(ceiling_pct) + if targets is None or p.name not in targets \ + or p.mode not in PLAN_CHARGE_MODES: + return None + t = targets[p.name] + return None if t is None else min(float(ceiling_pct), float(t)) + + +def chargers(packs, ceiling_pct, done=(), targets=None, probe=False) -> list: + """Batteries that may charge from the grid in a cheap run: below the + level `charge_to()` allows them (P4: the day plan's). With `probe`, also + one refusing to charge while its SAFETY-73 probe is open: its own P_SAFETY + pin keeps it at PROBE_W (OPT-15).""" + out = [] + for p in packs: + to = charge_to(p, ceiling_pct, targets) + if (to is not None and p.use and p.idle and p.live + and p.soc is not None and p.soc < to + and (p.refused >= 0 or (probe and p.probing)) + and p.name not in done): + out.append(p) + return out def sellers(packs, floor_pct, done=()) -> list: @@ -113,11 +141,15 @@ def sellers(packs, floor_pct, done=()) -> list: and p.refused <= 0 and p.name not in done] -def energy(packs, floor_pct, ceiling_pct, done_charge=(), done_sell=()): - """`(charge_kwh, charge_kw, sell_kwh, sell_kw)` for `priceruns.plan()`.""" - ch = chargers(packs, ceiling_pct, done_charge) +def energy(packs, floor_pct, ceiling_pct, done_charge=(), done_sell=(), + targets=None): + """`(charge_kwh, charge_kw, sell_kwh, sell_kw)` for `priceruns.plan()`. + With a day plan the charge is each battery's shortfall, so the cheap run + is only as long as the shortfall needs.""" + ch = chargers(packs, ceiling_pct, done_charge, targets) se = sellers(packs, floor_pct, done_sell) - return (sum(_room_kwh(p, ceiling_pct) for p in ch), + return (sum(_room_kwh(p, charge_to(p, ceiling_pct, targets)) + for p in ch), sum(abs(p.rating_w) for p in ch) / 1000.0, sum(_room_kwh(p, floor_pct) for p in se), sum(abs(p.rating_w) for p in se) / 1000.0) @@ -125,7 +157,8 @@ def energy(packs, floor_pct, ceiling_pct, done_charge=(), done_sell=()): def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now, headroom_w, floor_pct, ceiling_pct, house_w, charging_now=False, - blocked=False, done_charge=(), done_sell=()) -> Tick: + blocked=False, done_charge=(), done_sell=(), + targets=None, draining=False) -> Tick: """This tick's ranges, roles and site ask. `runs` is the kept `priceruns.RunPlan` or None (no fresh prices). @@ -136,7 +169,9 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now, `house_w` is the measured house (grid + batteries), or None. `charging_now` says last tick charged (then any headroom above 0 keeps it going); `blocked` that the guard or the headroom stopped a charge in - this 15-minute period. + this 15-minute period. `targets` is the day plan (`charge_to()`). + `draining` says a battery drains for its monthly cycle, so none may + charge from the grid (why "cycle"). """ plan_on = runs is not None and runs.on in_cheap = plan_on and runs.cheap.holds(now) and runs.charge_useful() @@ -144,8 +179,10 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now, in_sell = plan_on and runs.sell is not None and runs.sell.holds(now) why = None - ch = chargers(packs, ceiling_pct, done_charge, probe=True) \ + ch = chargers(packs, ceiling_pct, done_charge, targets, probe=True) \ if in_cheap else [] + if ch and draining: + ch, why = [], "cycle" charging = False if ch: need = 0.0 if charging_now else START_W diff --git a/goodwe_controller/app/p1.py b/goodwe_controller/app/p1.py index 9b3ab96..f54f48a 100644 --- a/goodwe_controller/app/p1.py +++ b/goodwe_controller/app/p1.py @@ -318,6 +318,13 @@ class QuarterAverager: with `roll=True` credits it into the next block as `add()` will.""" return self._last_t + @property + def held_since(self) -> datetime | None: + """SAFETY-90: when the held sample arrived (its `ingest_ts`), or None.""" + if self._last_t is None: + return None + return datetime.fromtimestamp(self._last_t, timezone.utc) + @property def offtake_avg_w(self) -> float: """Average offtake over the part of the open block seen so far.""" diff --git a/goodwe_controller/app/packmode.py b/goodwe_controller/app/packmode.py index 5fd71fe..82cac1e 100644 --- a/goodwe_controller/app/packmode.py +++ b/goodwe_controller/app/packmode.py @@ -46,10 +46,13 @@ SELL_KEY = "pack_sell" # OPT-11 P3: sell in the sell run # refused; the sell code in modetrade/priceruns stays, unreachable. SELLING_ENABLED = False -# The modes that may charge from the grid in a cheap run. Leftover solar joins -# when the solar forecast exists (P4); until then it takes solar only (owner -# decision 2026-09-29, option A). +# The modes that may charge from the grid in a cheap run with no day plan. +# Leftover solar takes solar only then (owner decision 2026-09-29, option A). GRID_CHARGE_MODES = (CHEAP_HOURS,) +# OPT-11 P4: with a fresh solar forecast, the day plan (dayplan.py) lets +# Leftover solar charge from the grid, by the shortfall only. Cheap hours only +# ignores the plan (fills to the ceiling); Solar first never grid-charges. +PLAN_CHARGE_MODES = (LEFTOVER_SOLAR,) def enabled(opts) -> bool: diff --git a/goodwe_controller/app/peakguard.py b/goodwe_controller/app/peakguard.py index e21a7ac..f4cb6ec 100644 --- a/goodwe_controller/app/peakguard.py +++ b/goodwe_controller/app/peakguard.py @@ -183,6 +183,8 @@ guard passes its own tests while being switched off: record). Those seconds make the projection read LOW, which is the under-guarding direction. The threshold is `max_hold_s` itself - the point p1.py already stops trusting a held sample - not a new number. + SAFETY-90: once readings are back, main.py counts those seconds + (`fill_gaps()`), so this holds only while the gap lasts. * FROZEN. SAFETY-18's conjunction, evaluated through `freeze.looks_frozen()` rather than re-written here. A frozen meter is the worst case of all: the @@ -397,7 +399,7 @@ function, so whichever way the owner answers, this file does not change and does not need re-testing. Nothing here indexes a phase. """ -from dataclasses import dataclass +from dataclasses import dataclass, replace from . import messages from .arbiter import ACTUATOR_SITE, P_PEAK, Claim @@ -490,11 +492,18 @@ class PeakDecision: # floor refused to size it. Mutually exclusive with `saturated` by # construction: this branch asks for nothing, so it cannot have run out of # inverter. `soc_floor_reason` says WHICH of the two ways it bound. + # SAFETY-87: at N>=2 it may ask the batteries above their reserve for + # `able_w`, all they have, when that is not enough (`place_on_able()`). floor_limited: bool = False soc: float | None = None soc_floor: float | None = None soc_floor_reason: str | None = None reason: str = "" + # SAFETY-87, N>=2 only: the batteries at or under their own reserve, + # which give nothing for the peak, and what the others can give (None + # when they are not asked). See `place_on_able()`. + held: tuple = () + able_w: float | None = None def explain(self) -> str: """Idle: the status row's plain words (UX-08). Engaged: `reason`, the @@ -592,6 +601,57 @@ def blindness(projection, max_hold_s, frozen: bool) -> str | None: return None +# SAFETY-90. Seconds of uncounted time below which nothing is filled: float +# noise between the averager's sums and the wall clock, never a real gap. +GAP_TOL_S = 1e-3 + + +def gap_w(ceiling_w, *seen_w) -> float: + """SAFETY-90: the watts a meter gap is counted at. The highest of the + limit and `seen_w`, the import with the batteries at 0 W (grid + their + discharge) just before and just after the gap; None entries are skipped. + DECISIONS/SAFETY-90-peak-guard-after-a-gap.md.""" + return max([float(ceiling_w)] + [float(w) for w in seen_w if w is not None]) + + +def fill_gaps(projection, *, tail_s, max_hold_s, unseen_s, gap_w, unseen_w, + held_ws=0.0): + """SAFETY-90: `projection` with the seconds the averager did not count + (a meter gap past `max_hold_s`) counted at `gap_w`, and those from before + this run's first reading (`unseen_s`) at `unseen_w`. `held_ws` is added + too: what the gap's first `max_hold_s`, credited at the reading before + it, lacks to reach `gap_w`. Returns + `(projection, gap seconds filled, unseen seconds filled)`. + + `tail_s` is the age of the held reading: up to `max_hold_s` of it is left + as before (the averager credits it once the next reading lands). Past + that the gap is still open, nothing is filled and `blindness()` answers + as before. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md.""" + if projection.block_start is None or max_hold_s is None: + return projection, 0.0, 0.0 + wall = float(projection.elapsed_s) + tail = min(max(0.0, float(tail_s)), wall) + if tail > float(max_hold_s): + return projection, 0.0, 0.0 + covered = wall - uncovered_s(projection) + missing = wall - covered - tail + if missing <= GAP_TOL_S: + missing = 0.0 + if held_ws <= 0.0: + return projection, 0.0, 0.0 + unseen = min(missing, max(0.0, float(unseen_s))) + gap = missing - unseen + add_ws = gap * float(gap_w) + unseen * float(unseen_w) + float(held_ws) + done = covered + missing + return (replace( + projection, + projected_w=float(projection.projected_w) + add_ws / QUARTER_S, + elapsed_w=((float(projection.elapsed_w) * covered + add_ws) / done + if done > 0.0 else float(projection.elapsed_w)), + covered_frac=(done / wall if wall > 0.0 + else float(projection.covered_frac))), gap, unseen) + + def discharge_permitted(soc, soc_floor) -> str | None: """SAFETY-24. May the guard size a forced discharge, or why may it not? @@ -733,6 +793,43 @@ def binding_soc_floor(packs, soc_floor): return max(floors) +def reserve_split(packs, soc_floor) -> tuple[list, tuple]: + """SAFETY-87: `(packs the guard may spend, names of those it may not)`, + each pack graded on its own floor by `pack_permissions()`.""" + perms = pack_permissions(packs, soc_floor) + return ([p for p, (_n, why) in zip(packs, perms) if why is None], + tuple(n for n, why in perms if why is not None)) + + +def place_on_able(decision, held, *, able_w, soc, soc_floor) -> PeakDecision: + """SAFETY-87: `decision` with the batteries in `held` (at or under their + own reserve) giving nothing for the peak. `able_w`, `soc` and `soc_floor` + describe the others, as `decide()`'s arguments do for the roster. A + floor-limited decision is re-taken on them: they are asked for the need + up to `able_w`, and it stays floor-limited only if that is not enough. + `soc` and `soc_floor` keep the roster's verdict, as `decide()` echoed it. + DECISIONS/SAFETY-87-guard-uses-able-batteries.md.""" + held = tuple(held) + if not decision.floor_limited or decision.wanted_w is None \ + or discharge_permitted(soc, soc_floor) is not None: + return replace(decision, held=held) + wanted = float(decision.wanted_w) + floor = min(wanted, float(able_w)) + head = (f"this 15-minute period heads for {decision.projected_w:.0f} W " + f"against a {decision.ceiling_w:.0f} W limit " + f"({decision.ceiling_from}), aiming at {decision.target_w:.0f} W. " + f"At the peak reserve, so giving nothing for the peak: " + f"{messages.names(held)}. The others are asked for {floor:+.0f} W") + if floor >= wanted: + return replace(decision, floor_limited=False, floor_w=floor, + soc_floor_reason=None, held=held, able_w=float(able_w), + reason=f"PEAK_GUARD: {head}") + return replace(decision, floor_w=floor, held=held, able_w=float(able_w), + reason=(f"PEAK_GUARD_FLOOR_LIMITED: {head}, all they can " + f"give, but {wanted:+.0f} W was needed, so this " + f"period may go over the limit")) + + def decide(projection, *, batt_w, cap_w, margin_w, reading, achievable_w, soc, soc_floor, max_hold_s=None, frozen: bool = False) -> PeakDecision: """The whole guard. Pure: no clock, no I/O, no state carried between calls. @@ -866,6 +963,40 @@ def decide(projection, *, batt_w, cap_w, margin_w, reading, achievable_w, soc, if saturated else ""))) +# SAFETY-81. A quarter must end this many W over the real limit before +# "Load too high for battery" fires. Above the guard's own landing error at +# margin 0 (up to +5.7 W, SAFETY-79 test check 5), which margin 0 accepts. +ALARM_MISS_W = 10.0 +# ...and for this long without a break, so a load that peaks for a few seconds +# (a hob or heater cycling) is not read as the rest of the quarter. +ALARM_HOLD_S = 10.0 + + +def load_too_high(projection, *, batt_w, ceiling_w, achievable_w) -> bool: + """SAFETY-81. May "Load too high for battery" fire? Only if, with the + battery at full power from now on, the quarter still ends more than + `ALARM_MISS_W` over the real limit (`ceiling_w`, not the aim). + + This is the miss itself, in W of the quarter average, so it does not have + `decide()`'s gain (900 / remaining): near a quarter's end that gain turns + a watt over the aim into thousands of W wanted. + + The load is the held meter reading plus `batt_w`. The caller passes the + lower of this cycle's and the last cycle's battery reading, so a meter + sample taken before the battery's last increase is not counted twice + (DECISIONS/SAFETY-81-load-too-high.md). A real overload shows on the meter + before the battery moves, so that filter costs it no cycle. + """ + remaining = QUARTER_S - float(projection.elapsed_s) + if batt_w is None or remaining <= 0.0: + return False + rest = float(projection.held_w) + float(batt_w) - float(achievable_w) + done_ws = (QUARTER_S * float(projection.projected_w) + - max(float(projection.held_w), 0.0) * remaining) + ends_w = (done_ws + max(rest, 0.0) * remaining) / QUARTER_S + return ends_w - float(ceiling_w) > ALARM_MISS_W + + def charge_headroom_w(decision) -> float | None: """SAFETY-45. The hardest this SITE may charge without putting the billed quarter over the ceiling, in W, positive - or None when nothing measured it. @@ -1063,5 +1194,6 @@ def claim(decision: PeakDecision) -> Claim | None: return Claim.limit(SOURCE, P_PEAK, lo=float(decision.floor_w), reason=("blind" if decision.blind else "SoC floor" if decision.floor_limited + and not decision.floor_w else "quarter peak"), actuator=ACTUATOR_SITE) diff --git a/goodwe_controller/app/solarfc.py b/goodwe_controller/app/solarfc.py new file mode 100644 index 0000000..4ebbf6f --- /dev/null +++ b/goodwe_controller/app/solarfc.py @@ -0,0 +1,350 @@ +"""OPT-11 P4: the solar forecast, read from a Home Assistant sensor. + +Pure: HA state dicts and the time in, a `Forecast` (or None and why) out. +main.py reads the entities; dayplan.py uses the answer. FCST-01 replaces +this later with the add-on's own forecast (owner answer, 2026-09-27). + +Two options name the sensors: `pv_forecast_entity`, the solar energy still +to come TODAY, and `pv_forecast_tomorrow_entity`, TOMORROW's (optional; it +is only needed for a cheap run before midnight). Supported, by the entities +they create (DOCS.md lists them): + +* Forecast.Solar (HA core): `sensor.energy_production_today_remaining` and + `sensor.energy_production_tomorrow`. The state only; no hourly split. +* Solcast PV Forecast (HACS `solcast_solar`): + `sensor.solcast_pv_forecast_forecast_remaining_today` and + `..._forecast_tomorrow`, with `detailedForecast` (or `detailedHourly`): + `[{period_start, pv_estimate}]`, kW per period. +* Open-Meteo Solar Forecast (HACS `open_meteo_solar_forecast`): + `sensor.energy_production_today_remaining` / `..._tomorrow`, with + `wh_period`: `{period start: Wh}`. + +Any other sensor whose state is energy in Wh, kWh or MWh works too, as a +state only. The unit is read, never guessed (the rule `pv.watts()` has). +Without an hourly split, the day's energy is spread over a fixed bell from +06:00 to 20:00 local time (`BELL_FROM_H`, `BELL_TO_H`). + +A reading older than `FRESH_S`, or from before local midnight, is stale, +and a stale or missing forecast is no forecast: the day plan is off and +every mode acts as in P3 option A. + +OPT-17: each day may instead name a list (`pv_forecast_entities`, +`pv_forecast_tomorrow_entities`), summed by `read_sum()`. One member that is +not usable makes that day's sum unusable, never a partial sum. +DECISIONS/OPT-17-several-forecast-entities.md. +""" + +import math +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +from .hass import BAD_STATES +from .tariff import BILLING_TZ + +KWH_PER = {"Wh": 0.001, "kWh": 1.0, "MWh": 1000.0} + +# Stale after 3 h. The integrations above refresh at least hourly, so a +# 2 h limit would flag a site whose hourly poll was missed once. +FRESH_S = 3 * 3600.0 + +# The bell a state-only forecast is spread over, in local hours. +BELL_FROM_H, BELL_TO_H = 6.0, 20.0 + +# Why there is no forecast (the page words are in ui_text `home_words`). +NOT_SET, MISSING, UNIT, STALE, BAD = ( + "not_set", "missing", "unit", "stale", "bad") + +# OPT-17: (single key, list key) per day. A set list replaces the single key; +# the validator refuses both set for the same day. +TODAY_KEYS = ("pv_forecast_entity", "pv_forecast_entities") +TOMORROW_KEYS = ("pv_forecast_tomorrow_entity", + "pv_forecast_tomorrow_entities") + + +@dataclass(frozen=True) +class Day: + """One day's forecast: `kwh` still to come (today) or in all (tomorrow), + and the hourly split if the sensor has one: `((start, end, kwh), ...)`.""" + day0: datetime # local midnight, as UTC + kwh: float + periods: tuple = () + + +@dataclass(frozen=True) +class Forecast: + today: Day + tomorrow: Day | None + at: datetime # when the sensor last reported + + def between(self, t0: datetime, t1: datetime) -> float | None: + """kWh the sun should give between `t0` (now or later) and `t1`. + None when the span reaches the sunny hours of a day with no + forecast (tomorrow, when its sensor is not set).""" + if t1 <= t0: + return 0.0 + last = self.tomorrow or self.today + if t1 > last.day0 + timedelta(days=1, hours=BELL_FROM_H): + return None + total = 0.0 + for day in (self.today, self.tomorrow): + if day is None: + continue + a = max(t0, day.day0) + b = min(t1, day.day0 + timedelta(days=1)) + if b > a: + total += _day_between(day, a, b, t0) + return total + + +def _local_hour(t: datetime) -> float: + lt = t.astimezone(BILLING_TZ) + return lt.hour + lt.minute / 60.0 + lt.second / 3600.0 + + +def _bell(h: float) -> float: + """Cumulative share of a day's sun by local hour `h` (0..1).""" + if h <= BELL_FROM_H: + return 0.0 + if h >= BELL_TO_H: + return 1.0 + x = (h - BELL_FROM_H) / (BELL_TO_H - BELL_FROM_H) + return (1.0 - math.cos(math.pi * x)) / 2.0 + + +def _hours_since(day0, t): + return (t - day0).total_seconds() / 3600.0 + + +def _day_between(day: Day, a: datetime, b: datetime, now: datetime) -> float: + if day.periods: + out = 0.0 + for s, e, kwh in day.periods: + lo, hi = max(a, s), min(b, e) + if hi > lo: + out += kwh * (hi - lo).total_seconds() / (e - s).total_seconds() + return out + # State only: `kwh` is what is left from `max(now, day0)` to the day's + # end, spread over the bell. + h_from = _hours_since(day.day0, max(now, day.day0)) + left = 1.0 - _bell(h_from) + if left <= 1e-9: + return 0.0 + share = (_bell(_hours_since(day.day0, b)) + - _bell(_hours_since(day.day0, a))) / left + return day.kwh * max(0.0, share) + + +def _parse_time(v): + try: + t = datetime.fromisoformat(str(v).replace("Z", "+00:00")) + except (TypeError, ValueError): + return None + return t if t.tzinfo else t.replace(tzinfo=timezone.utc) + + +def _periods(attrs: dict, day0: datetime) -> tuple: + """The hourly split, if the sensor has one, for the day at `day0`.""" + d1 = day0 + timedelta(days=1) + out = [] + for key in ("detailedForecast", "detailedHourly"): + rows = attrs.get(key) + if isinstance(rows, list) and rows: + starts = [] + for r in rows: + if not isinstance(r, dict): + continue + t = _parse_time(r.get("period_start")) + try: + kw = float(r.get("pv_estimate")) + except (TypeError, ValueError): + continue + if t is not None and math.isfinite(kw) and kw >= 0: + starts.append((t, kw)) + starts.sort() + for i, (t, kw) in enumerate(starts): + step = (starts[i + 1][0] - t if i + 1 < len(starts) + else timedelta(minutes=30 if key == + "detailedForecast" else 60)) + if step <= timedelta(0) or step > timedelta(hours=1): + step = timedelta(minutes=30 if key == "detailedForecast" + else 60) + if day0 <= t < d1: + out.append((t, t + step, + kw * step.total_seconds() / 3600.0)) + if out: + return tuple(out) + wh = attrs.get("wh_period") + if isinstance(wh, dict) and wh: + rows = [] + for k, v in wh.items(): + t = _parse_time(k) + try: + e = float(v) + except (TypeError, ValueError): + continue + if t is not None and math.isfinite(e) and e >= 0: + rows.append((t, e / 1000.0)) + rows.sort() + for i, (t, kwh) in enumerate(rows): + nxt = rows[i + 1][0] if i + 1 < len(rows) else t + timedelta( + hours=1) + if nxt - t > timedelta(hours=1) or nxt <= t: + nxt = t + timedelta(hours=1) + if day0 <= t < d1: + out.append((t, nxt, kwh)) + return tuple(out) + + +def _day(state, day0, now, *, today): + """`(Day, None)` or `(None, why)` from one sensor's state dict.""" + if state is None: + return None, MISSING + raw = str(state.get("state", "")).strip() + if raw.lower() in BAD_STATES: + return None, MISSING + try: + v = float(raw) + except ValueError: + return None, BAD + attrs = state.get("attributes") or {} + unit = str(attrs.get("unit_of_measurement") or "").strip() + if unit not in KWH_PER: + return None, UNIT + kwh = v * KWH_PER[unit] + if not math.isfinite(kwh) or kwh < 0: + return None, BAD + periods = _periods(attrs, day0) + if periods and today: + # The split is the whole day; keep what is still to come, scaled so + # it adds up to the state (the state is what the integration says + # is left). + left = sum(k * max(0.0, min(1.0, (e - max(s, now)).total_seconds() + / (e - s).total_seconds())) + for s, e, k in periods) + if left > 1e-6: + f = kwh / left + periods = tuple((s, e, k * f) for s, e, k in periods) + return Day(day0, kwh, periods), None + + +def _reported(state): + ts = [_parse_time(state.get(k)) for k in + ("last_reported", "last_updated", "last_changed")] + ts = [t for t in ts if t is not None] + return max(ts) if ts else None + + +def entities(opts: dict, *, tomorrow=False) -> tuple: + """The forecast entities one day names, in configured order: the list + if it has any entry, else the single key, else (). A repeated id is + dropped, as `pv.entities()` does: it would count one forecast twice.""" + one, many = TOMORROW_KEYS if tomorrow else TODAY_KEYS + listed = [e for e in (str(x or "").strip() + for x in (opts.get(many) or [])) if e] + if listed: + return tuple(dict.fromkeys(listed)) + e = str(opts.get(one) or "").strip() + return (e,) if e else () + + +def _bounds(now): + here = now.astimezone(BILLING_TZ) + d0 = datetime(here.year, here.month, here.day, tzinfo=BILLING_TZ) + nd = d0.date() + timedelta(days=1) + d1 = datetime(nd.year, nd.month, nd.day, tzinfo=BILLING_TZ) + return d0.astimezone(timezone.utc), d1.astimezone(timezone.utc) + + +def _today(state, day0, now): + """`(Day, at, None)` or `(None, None, why)` for one today sensor.""" + today, why = _day(state, day0, now, today=True) + if today is None: + return None, None, why + at = _reported(state) + # A reading from before local midnight is yesterday's "left today" (0 + # after sunset) until the integration refreshes: stale, not a dark day. + if at is None or at < day0 or (now - at).total_seconds() > FRESH_S \ + or (at - now).total_seconds() > 300: + return None, None, STALE + return today, at, None + + +def _tomorrow(state, day0, day1, now): + """`(Day, None)` or `(None, why)` for one tomorrow sensor.""" + t, why = _day(state, day1, now, today=False) + if t is None: + return None, why + t_at = _reported(state) + if t_at is None or t_at < day0 or (now - t_at).total_seconds() > FRESH_S: + return None, STALE + return t, None + + +def read(today_state, tomorrow_state, now: datetime, *, + configured=True): + """`(Forecast, None)` or `(None, why)`. `tomorrow_state` may be None + (the option is not set, or the sensor is missing): the forecast then + covers today only.""" + if not configured: + return None, NOT_SET + day0, day1 = _bounds(now) + today, at, why = _today(today_state, day0, now) + if today is None: + return None, why + tomorrow = None + if tomorrow_state is not None: + tomorrow, _w = _tomorrow(tomorrow_state, day0, day1, now) + return Forecast(today, tomorrow, at), None + + +def _sum(days): + """One Day from several members' Days. The hourly split is kept only if + every member has one: the splits are then joined (`_day_between()` + adds overlapping periods). Else the total goes on the bell.""" + if len(days) == 1: + return days[0] + periods = () + if all(d.periods for d in days): + periods = tuple(sorted(p for d in days for p in d.periods)) + return Day(days[0].day0, sum(d.kwh for d in days), periods) + + +def read_sum(today, tomorrow, now: datetime): + """OPT-17: `(Forecast or None, why, gaps)` over lists of + `(entity, state)`, one per configured member, in configured order. + + Each member is read exactly as `read()` reads one sensor, in its own + unit. A member that is missing, unavailable, stale, not in an energy + unit or bad makes that DAY unusable, as one such sensor does today: no + today means no forecast, no tomorrow means today only. A partial sum + would under-count the sun and mislead the day plan. + + `gaps` is `((day, position, entity, why), ...)` for every unusable + member (`day` "today" or "tomorrow", `position` 1-based), so the page + and the log can name it. With one member per day the forecast is what + `read()` gives (test_opt17.py proves it).""" + if not today: + return None, NOT_SET, () + day0, day1 = _bounds(now) + gaps, days, ats, why0 = [], [], [], None + for i, (ent, st) in enumerate(today, 1): + d, at, why = _today(st, day0, now) + if d is None: + gaps.append(("today", i, ent, why)) + why0 = why0 or why + else: + days.append(d) + ats.append(at) + tdays = [] + for i, (ent, st) in enumerate(tomorrow, 1): + t, why = _tomorrow(st, day0, day1, now) + if t is None: + gaps.append(("tomorrow", i, ent, why)) + else: + tdays.append(t) + gaps = tuple(gaps) + if why0 is not None: + return None, why0, gaps + tday = _sum(tdays) if tomorrow and len(tdays) == len(tomorrow) else None + # The oldest member's report is when the sum was last fully known. + return Forecast(_sum(days), tday, min(ats)), None, gaps diff --git a/goodwe_controller/app/static/app.js b/goodwe_controller/app/static/app.js index 8c3fca9..06f201a 100644 --- a/goodwe_controller/app/static/app.js +++ b/goodwe_controller/app/static/app.js @@ -100,7 +100,7 @@ } function renderPill() { const st = S.state || null; - const dot = st ? ({ running: "", shadow: " warn", stopped: " warn", warning: " warn", alarm: " bad" }[st.kind] ?? " warn") + const dot = st ? ({ running: "", shadow: " warn", stopped: " warn", warning: " warn", alarm: " bad", waiting: " warn" }[st.kind] ?? " warn") : (S.level === "ok" ? "" : S.level === "bad" ? " bad" : " warn"); const words = st ? [st.pill, st.ran].filter(Boolean).join(". ") : S.setup_only === true ? t("setup_only.name") : S.banner; $("#pill").replaceChildren(h("span", { class: "dot" + dot, "aria-hidden": "true" }), h("span", {}, words)); @@ -399,10 +399,13 @@ $("#batt-how").hidden = !modes; const docs = $("#batt-docs"); if (S.control && S.control.docs_url) { docs.href = S.control.docs_url; docs.hidden = false; } else docs.hidden = true; - if (S.state && ["shadow", "stopped", "alarm"].includes(S.state.kind)) banner($("#batt-banner"), S.level, S.state.pill, S.state.sub, S.level === "bad" ? "alert" : null); + if (S.state && ["shadow", "stopped", "alarm", "waiting"].includes(S.state.kind)) banner($("#batt-banner"), S.level, S.state.pill, S.state.sub, S.level === "bad" ? "alert" : null); else $("#batt-banner").replaceChildren(); keepFocus(() => { $("#bcards").replaceChildren(...S.batteries.map(batteryCard)); + // OPT-11 P4: the solar forecast line under the cards. + const sl = $("#batt-solar"), line = modes && S.solar ? S.solar.line : ""; + if (sl) { sl.textContent = line || ""; sl.hidden = !line; } if (simpleHost && simpleHost.isConnected) { simpleHost.replaceChildren(...(modes ? S.batteries.map(simpleRow) : [h("p", { class: "small muted", style: "margin:4px 0" }, t("batteries.modes_off.name") + ". " + t("batteries.description"))])); diff --git a/goodwe_controller/app/static/index.html b/goodwe_controller/app/static/index.html index a915a09..24a34eb 100644 --- a/goodwe_controller/app/static/index.html +++ b/goodwe_controller/app/static/index.html @@ -101,6 +101,7 @@
+