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..b9316a5 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -1,5 +1,168 @@ # Changelog +## 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/OPT-11-battery-modes.md b/goodwe_controller/DECISIONS/OPT-11-battery-modes.md index 5c58558..93897d6 100644 --- a/goodwe_controller/DECISIONS/OPT-11-battery-modes.md +++ b/goodwe_controller/DECISIONS/OPT-11-battery-modes.md @@ -75,3 +75,55 @@ 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. + +## 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/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 index 7604c47..8c8b0e0 100644 --- a/goodwe_controller/DECISIONS/SAFETY-78-drain-at-two.md +++ b/goodwe_controller/DECISIONS/SAFETY-78-drain-at-two.md @@ -35,10 +35,9 @@ direction. Opposite splits stay forbidden. (`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) outranks the peak floor (50), so the guard does not add - to it: a 5000 W house imports 3000 W against a 2500 W limit, a 6000 W house - 4000 W. Pre-existing, kept by the N=1 identity. +- 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. + the peak floor's `lo` (0 W), so it is not this card. Fixed by [[SAFETY-84]]. 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-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 31b5bec..9394a0e 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -184,6 +184,9 @@ 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 | 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. | @@ -522,7 +525,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,6 +538,16 @@ 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. +- 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. @@ -564,7 +577,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 | |---|---| @@ -575,8 +588,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, @@ -586,7 +600,11 @@ 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). **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 @@ -1626,7 +1644,7 @@ 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` | 19 | Local hour a due cycle begins (see below) | -| `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_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 | @@ -1665,6 +1683,20 @@ 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). #### Capacity-tariff exposure of the grid-following loop @@ -1734,10 +1766,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. @@ -1800,7 +1839,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** @@ -1945,6 +1986,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 @@ -3114,7 +3164,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 @@ -3149,6 +3202,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. @@ -3215,8 +3272,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 @@ -3244,7 +3302,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*. @@ -3382,7 +3442,7 @@ for a new install*. | 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. | @@ -3422,7 +3482,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** diff --git a/goodwe_controller/app/allocator.py b/goodwe_controller/app/allocator.py index a84ef55..5640fbf 100644 --- a/goodwe_controller/app/allocator.py +++ b/goodwe_controller/app/allocator.py @@ -354,6 +354,11 @@ def pin_w(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. diff --git a/goodwe_controller/app/arbiter.py b/goodwe_controller/app/arbiter.py index 42b7a18..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 @@ -190,6 +196,16 @@ CLAIM_SCOPE = { # 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, @@ -619,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 @@ -721,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/homeview.py b/goodwe_controller/app/homeview.py index 6221d12..6814691 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -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,6 +387,56 @@ 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 in the @@ -663,7 +718,9 @@ def battery_rows(c, shadow, running, held=False): 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), - other_cycle=cycling) + 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), @@ -698,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, other_cycle=None) -> 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: @@ -728,6 +800,8 @@ def battery_why(*, modes_on, mode, off, reading, phase, shadow, running, 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" @@ -829,7 +903,9 @@ 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 @@ -848,24 +924,25 @@ 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) -> 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) finally: _LANG[0] = "en" -def _build(c, *, level, checks, now): +def _build(c, *, level, checks, now, held=None): now = now or datetime.now(timezone.utc) shadow = bool(c.shadow) kind = state_of(c, level, shadow) # 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) @@ -875,6 +952,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)}, @@ -1171,6 +1249,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) @@ -1213,8 +1295,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 @@ -1288,6 +1373,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)), @@ -1313,6 +1399,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 22f3aaf..883bb50 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -25,6 +25,7 @@ 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 @@ -42,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 ( @@ -57,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 @@ -72,8 +76,8 @@ from .hass import CORE_API, HomeAssistant from .inverter import (LINK_STALE_S, UNIT_A, build_roster, roster_entries, roster_names) from .maintenance import ( - DRAIN, IDLE, POWER_MOVING, MaintConfig, Maintenance, MaintResult, - arbitrate as maint_arbitrate, site_zone, + CHARGE, DRAIN, HOLD, IDLE, POWER_MOVING, MaintConfig, Maintenance, + MaintResult, arbitrate as maint_arbitrate, site_zone, ) from .mqtt import (MODE_CONFIGS_KEY, MODE_LABELS, USE_OFF, USE_ON, MqttPublisher, announced_units, mode_configs, @@ -88,7 +92,7 @@ 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 .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) @@ -235,6 +239,11 @@ def load_options() -> dict: # valued at (`priceruns.plan(ref_eur_kwh=)`): the rest of that day. REF_CHEAP_KEEP = timedelta(hours=12) +# 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 @@ -273,6 +282,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.""" @@ -928,9 +950,28 @@ class Controller: # 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_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 @@ -950,6 +991,25 @@ class Controller: self._mode_trade_was = False self._charge_blocked_q = None self._ref_cheap = None + # OPT-11 P4: the solar forecast and the day plan. Only with battery + # modes on AND `pv_forecast_entity` set; otherwise none of this runs + # and every mode acts as in P3 option A. + self.fc_entity = (opts.get("pv_forecast_entity") or None) \ + if self.modes_on else None + self.fc_tomorrow_entity = opts.get("pv_forecast_tomorrow_entity") \ + or None + 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 @@ -959,6 +1019,12 @@ class Controller: # 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() # 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. @@ -1152,6 +1218,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 @@ -1196,7 +1268,8 @@ class Controller: 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") @@ -1214,7 +1287,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( @@ -1226,7 +1300,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, @@ -1299,7 +1374,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 @@ -1310,32 +1387,54 @@ 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): + 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 @@ -2084,10 +2183,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] @@ -2105,6 +2208,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 @@ -2119,6 +2225,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 @@ -2157,9 +2299,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) @@ -2172,7 +2327,7 @@ 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. if was and self.mode_tick.action != modetrade.CHARGE and \ @@ -2180,10 +2335,13 @@ class Controller: self._charge_blocked_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() @@ -2268,6 +2426,39 @@ class Controller: self.target = want self.i_w = want - self.tuning.gain * (want - float(batt)) + 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.""" + if len(self.inverters) < 2: + return + live = set(self.live_units()) + now = set() + for inv, res in zip(self.inverters, maint_results): + u = self.actuator_of("maintenance", inv) + if res.phase == CHARGE and u in live and not pre_resolve( + [c for c in claims if c.actuator == u]).contradiction: + now.add(inv.name) + self._charge_ended |= self._charging_was - now + self._charging_was = frozenset(now) + + 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)) + self.target += now_w - float(inv.target) + self.i_w = self.target + def _mode_fault_log(self, resolution) -> None: """OPT-11: say once, per battery, that its mode was dropped as a fault. @@ -2431,6 +2622,325 @@ class Controller: 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: @@ -2535,6 +3045,22 @@ 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.""" + today = await self.hass.state(self.fc_entity) + tomorrow = (await self.hass.state(self.fc_tomorrow_entity) + if self.fc_tomorrow_entity else None) + now = datetime.now(timezone.utc) + fc, why = solarfc.read(today, tomorrow, now) + if (fc is None) != (self.solar_fc is None) or why != self.solar_fc_why: + self.log_event( + "solar forecast: " + ("read" if fc is not None else + f"none ({why})"), + hint=messages.hint("forecast_read" if fc is not None + else "forecast_none")) + self.solar_fc, self.solar_fc_why = fc, why + async def read_inputs(self, now_mono: float | None = None) -> None: """Every input this cycle needs, and SAFETY-37's freshness verdict. @@ -2586,6 +3112,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"]) @@ -2708,6 +3242,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)) @@ -2750,8 +3292,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) @@ -3468,7 +4011,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 @@ -3743,12 +4287,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 @@ -3762,6 +4305,15 @@ class Controller: # gives for SAFETY-18 on a transport that cannot see `unchanged_s`. self._peak_proj = (None if not self.p1_enabled else self.peak.project(self.p1.averager, now)) + _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( self._peak_proj, # ⚠️ THE SITE SUM. The guard sizes a floor from what the battery is @@ -3771,7 +4323,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 @@ -3816,6 +4368,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 @@ -4064,6 +4621,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 - @@ -4148,6 +4706,22 @@ class Controller: 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. @@ -4155,6 +4729,7 @@ 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) 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 @@ -4181,6 +4756,8 @@ class Controller: # is what seeds it, and dropping it there restarts the ~70 s cycle. if self.loop_w is not None and self.target != self.loop_w: self._refusal_end("another writer") + # SAFETY-93: a charge that ended leaves the loop's start point. + self._reseed_charge_end(maint_results) probe = self._refusal_probe() if len(self.inverters) > 1: probe = any(_probing.values()) @@ -4219,9 +4796,19 @@ class Controller: refused=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 " @@ -4230,6 +4817,7 @@ 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) # --- TEL-10 ------------------------------------------------------ @@ -4309,6 +4897,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 @@ -4333,7 +4922,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 @@ -4527,6 +5117,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)) @@ -4740,6 +5332,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. @@ -4949,6 +5584,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`. @@ -4969,6 +5606,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 @@ -4991,13 +5632,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. @@ -5006,8 +5642,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. @@ -5778,8 +6437,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 @@ -5805,22 +6466,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( @@ -5937,7 +6602,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: @@ -5949,10 +6615,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: @@ -6139,6 +6805,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 = 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 @@ -6191,6 +6862,9 @@ class Controller: level, banner = "bad", messages.banner_not_ready(bad[0]) 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. @@ -6312,6 +6986,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, @@ -6326,7 +7001,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)) except Exception: # noqa: BLE001 # Once per process at ERROR: the page polls every 3 s. if not getattr(self, "_home_failed", False): diff --git a/goodwe_controller/app/messages.py b/goodwe_controller/app/messages.py index c7bf2f7..6ad9814 100644 --- a/goodwe_controller/app/messages.py +++ b/goodwe_controller/app/messages.py @@ -217,7 +217,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 +227,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 +242,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. " @@ -470,8 +477,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 +487,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.") @@ -781,6 +793,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 +853,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 +918,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 +927,8 @@ 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", "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", @@ -1010,6 +1033,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( @@ -1078,6 +1104,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)), @@ -1112,6 +1139,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"]), @@ -1173,6 +1202,9 @@ 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", + "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 bf734b4..5453d91 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): * 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 @@ -47,7 +51,7 @@ Rules that keep this safe (tests: test_modes_p3.py): 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") @@ -90,13 +94,35 @@ 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=()) -> 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).""" - 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 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) -> list: + """Batteries that may charge from the grid in a cheap run: below the + level `charge_to()` allows them (P4: the day plan's).""" + 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 and p.name not in done): + out.append(p) + return out def sellers(packs, floor_pct, done=()) -> list: @@ -108,11 +134,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) @@ -121,7 +151,7 @@ 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=(), - draining=False) -> Tick: + targets=None, draining=False) -> Tick: """This tick's ranges, roles and site ask. `runs` is the kept `priceruns.RunPlan` or None (no fresh prices). @@ -132,8 +162,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. `draining` says a battery drains for its - monthly cycle, so none may charge from the grid (why "cycle"). + 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() @@ -141,7 +172,8 @@ 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) if in_cheap else [] + ch = chargers(packs, ceiling_pct, done_charge, targets) if in_cheap \ + else [] if ch and draining: ch, why = [], "cycle" charging = False diff --git a/goodwe_controller/app/p1.py b/goodwe_controller/app/p1.py index 5632aca..8ce893f 100644 --- a/goodwe_controller/app/p1.py +++ b/goodwe_controller/app/p1.py @@ -312,6 +312,13 @@ class QuarterAverager: """ return self._last_net + @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 9e4bf65..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. @@ -1097,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..89977cd --- /dev/null +++ b/goodwe_controller/app/solarfc.py @@ -0,0 +1,254 @@ +"""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. +""" + +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") + + +@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 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 + 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) + day0, day1 = d0.astimezone(timezone.utc), d1.astimezone(timezone.utc) + today, why = _day(today_state, day0, now, today=True) + if today is None: + return None, why + at = _reported(today_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, STALE + tomorrow = None + if tomorrow_state is not None: + t, _w = _day(tomorrow_state, day1, now, today=False) + t_at = _reported(tomorrow_state) + if t is not None and t_at is not None and t_at >= day0 \ + and (now - t_at).total_seconds() <= FRESH_S: + tomorrow = t + return Forecast(today, tomorrow, at), None diff --git a/goodwe_controller/app/static/app.js b/goodwe_controller/app/static/app.js index 8c3fca9..e5354b8 100644 --- a/goodwe_controller/app/static/app.js +++ b/goodwe_controller/app/static/app.js @@ -403,6 +403,9 @@ 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 @@
+