diff --git a/FIELD-GUIDE.md b/FIELD-GUIDE.md index 1d71b4b..3e1524f 100644 --- a/FIELD-GUIDE.md +++ b/FIELD-GUIDE.md @@ -4,9 +4,9 @@ The guide technicians carry is the PDF: **`docs/GoodWe-RS485-Field-Guide.pdf`** — 10 pages, A4, print it double-sided. -⚠️ **Outdated: it is v0.1.0.** Its `slew_w` row is corrected in `build_guide.py` -and `field-guide.html`, but the PDF has not been rebuilt (see below). Where the -guide disagrees with `goodwe_controller/DOCS.md`, DOCS.md wins. +⚠️ **Outdated: the content is still v0.1.0.** The PDF was rebuilt on 2026-10-17 +for the EMS-port wording and the `slew_w` row only. Where the guide disagrees with +`goodwe_controller/DOCS.md`, DOCS.md wins. It covers, in the order the work is done: pre-visit checks, hardware, firmware, add-on install and configuration, how to read the Web UI, the six commissioning diff --git a/HANDOVER.md b/HANDOVER.md index c926f1f..565da3e 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -156,7 +156,7 @@ that looks hung gets deleted. **A GoodWe "-20" / G2 inverter cannot be driven by this.** Platform comes from characters 6–8 of the serial number. Only -`ESU EMU ESA BPS BPU EMJ IJL` — platform 105, the AA55-era meter bus — are +`ESU EMU ESA BPS BPU EMJ IJL` — platform 105, the AA55-era generation — are supported. SBP G2 (`SPB`/`SPN`) and ES G2 (`ESN`/`ESC`) are platform 745, a different protocol. The reference unit is `BPS`. diff --git a/README.md b/README.md index 53438fc..d9711d4 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # GoodWe RS485 Controller — Home Assistant add-on repository Replaces a GoodWe battery inverter's vendor controller with a local one: holds -net grid exchange at zero by emulating the inverter's smart meter over RS485, -and runs the monthly battery maintenance cycle the vendor box was doing. +net grid exchange at zero by sending the inverter a grid reading over its RS485 +EMS port, and runs the monthly battery maintenance cycle the vendor box was doing. ``` repository.yaml add-on repository metadata @@ -30,7 +30,7 @@ goodwe_controller/ the add-on returns, run against its own release image firmware/goodwe-master.yaml ESPHome config for the T-CAN485 estop/rs485_log.py optional RS485 e-stop / bus witness -docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4; outdated) +docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4; rebuilt 2026-10-17, content still v0.1.0 and outdated) docs/build_guide.py its single source - rebuilds the HTML and PDF FIELD-GUIDE.md how to edit and rebuild the guide docs/RELEASE.md how a change reaches the battery - `master` is @@ -118,17 +118,17 @@ the platform tag: | tag | platform | verdict | |---|---|---| -| `ESU EMU ESA BPS BPU EMJ IJL` | 105 — ES/EM/BP, AA55-era meter bus | **supported** | +| `ESU EMU ESA BPS BPU EMJ IJL` | 105 — ES/EM/BP, AA55-era | **supported** | | `SPB SPN` | 745 — SBP **G2** (`GW…-SBP-20`) | no | | `ESN ESC` | 745 — ES **G2** (`GW…-ES-20`) | no | | `ETU EHU BTU …` | 205/745/753 — ET/EH/BT hybrids | no | | `SDT DST MSU NSU …` | PV-only inverters — no battery to control | no | -A "-20" or "G2" suffix means a different platform with a different meter +A "-20" or "G2" suffix means a different platform with a different protocol. The tags come from the `goodwe` library's `PLATFORM_105_MODELS`, which is also what Home Assistant's own integration uses to pick a protocol. -- GoodWe **ES / BP family** inverters (AA55 / RS485 meter-bus generation) +- GoodWe **ES / BP family** inverters (AA55 generation) - The protocol is **reverse-engineered**. There is no vendor contract, and a firmware change on GoodWe's side could break every installation at once. Say so when you sell it. diff --git a/docs/GoodWe-RS485-Field-Guide.pdf b/docs/GoodWe-RS485-Field-Guide.pdf index 42f4264..cfada9f 100644 Binary files a/docs/GoodWe-RS485-Field-Guide.pdf and b/docs/GoodWe-RS485-Field-Guide.pdf differ diff --git a/docs/build_guide.py b/docs/build_guide.py index f006f71..0e5f554 100644 --- a/docs/build_guide.py +++ b/docs/build_guide.py @@ -154,7 +154,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; } - + @@ -162,7 +162,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
TagPlatformVerdict
ESU EMU ESA BPS
BPU EMJ IJL
Platform 105 — ES / EM / BP, AA55-era meter busPlatform 105 — ES / EM / BP, AA55-era SUPPORTED
SPB SPNSBP G2 (GW…-SBP-20) — platform 745NO
ESN ESCES G2 (GW…-ES-20) — platform 745NO
SDT DST MSU NSU …PV-only inverters — no battery to controlNO

Reference unit: 95000BPS225W0290 → BPS → supported. - A “-20” or “G2” suffix in the model name means a different platform with a different meter + A “-20” or “G2” suffix in the model name means a different platform with a different protocol. If the tag is not on the supported list, stop — do not quote the job.

@@ -208,13 +208,13 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }

Steps 1–3 — Hardware

-
Qualified persons only. Mains AC and battery DC are present. The meter bus +
Qualified persons only. Mains AC and battery DC are present. The RS485 EMS port is low-voltage but sits inside an energised installation.

1 Survey and record

    -
  1. Photograph the meter-port wiring before touching it.
  2. +
  3. Photograph the EMS-port wiring before touching it.
  4. Identify the RS485 pair (A/B) running to the vendor controller.
  5. Record inverter model + serial, battery capacity (Ah) and nominal voltage.
@@ -226,7 +226,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
  1. Bring the system to 0 W — battery neither charging nor discharging (check the inverter display).
  2. -
  3. Disconnect the vendor controller from the meter port.
  4. +
  5. Disconnect the vendor controller from the inverter's EMS port.
  6. Leave it physically mounted but unplugged — that keeps the install reversible and gives you a rollback story.
@@ -237,7 +237,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }

3 Fit and power the T-CAN485

    -
  1. Wire A→A, B→B to the meter port, plus its own supply.
  2. +
  3. Wire A→A, B→B to the EMS port, plus its own supply.
  4. If fitting the e-stop, connect its USB-RS485 adapter to the same pair, in parallel.
  5. Power the T-CAN485 last.
diff --git a/docs/field-guide.html b/docs/field-guide.html index 77570b2..2488c94 100644 --- a/docs/field-guide.html +++ b/docs/field-guide.html @@ -119,7 +119,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; } - + @@ -127,7 +127,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
TagPlatformVerdict
ESU EMU ESA BPS
BPU EMJ IJL
Platform 105 — ES / EM / BP, AA55-era meter busPlatform 105 — ES / EM / BP, AA55-era SUPPORTED
SPB SPNSBP G2 (GW…-SBP-20) — platform 745NO
ESN ESCES G2 (GW…-ES-20) — platform 745NO
SDT DST MSU NSU …PV-only inverters — no battery to controlNO

Reference unit: 95000BPS225W0290 → BPS → supported. - A “-20” or “G2” suffix in the model name means a different platform with a different meter + A “-20” or “G2” suffix in the model name means a different platform with a different protocol. If the tag is not on the supported list, stop — do not quote the job.

@@ -173,13 +173,13 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }

Steps 1–3 — Hardware

-
Qualified persons only. Mains AC and battery DC are present. The meter bus +
Qualified persons only. Mains AC and battery DC are present. The RS485 EMS port is low-voltage but sits inside an energised installation.

1 Survey and record

    -
  1. Photograph the meter-port wiring before touching it.
  2. +
  3. Photograph the EMS-port wiring before touching it.
  4. Identify the RS485 pair (A/B) running to the vendor controller.
  5. Record inverter model + serial, battery capacity (Ah) and nominal voltage.
@@ -191,7 +191,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
  1. Bring the system to 0 W — battery neither charging nor discharging (check the inverter display).
  2. -
  3. Disconnect the vendor controller from the meter port.
  4. +
  5. Disconnect the vendor controller from the inverter's EMS port.
  6. Leave it physically mounted but unplugged — that keeps the install reversible and gives you a rollback story.
@@ -202,7 +202,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }

3 Fit and power the T-CAN485

    -
  1. Wire A→A, B→B to the meter port, plus its own supply.
  2. +
  3. Wire A→A, B→B to the EMS port, plus its own supply.
  4. If fitting the e-stop, connect its USB-RS485 adapter to the same pair, in parallel.
  5. Power the T-CAN485 last.
diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 209d08b..5131424 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -1,5 +1,76 @@ # Changelog +## 0.19.0 + +**A minor bump: price trading behaves differently, plus a fix for a false warning, the Home price chart in 15-minute bars, more Dutch text and EMS-port wording, and no option changes.** +No option was added, removed or renamed, and no default changed (86 options). It is a minor bump because price trading changes when the battery charges and gives power; every saved setting still starts. +**Existing installs need no option change:** options that start 0.18.2 also +start 0.19.0. The changes below apply with one inverter or two. + +**Price trading skips periods that are over.** When the add-on trades on price, it now picks only from price periods that are not over yet. With battery modes off and 15-minute prices, the battery therefore trades for longer (about 1.5 to 2.5 times the minutes in a simulation of 16 real days), so it cycles more; with battery modes on, it no longer charges from the grid because of a cheap period that is over. Hourly and day/night sites change too: they no longer pause at the start of each hour. + +Contents of this release, in merge order since 0.18.2 (`c67d0cd`): + +| | | +|---|---| +| `243a756` | The docs, the add-on description and the field guide now say the add-on is wired to the inverter's EMS port, not a "meter bus". In Home Assistant the device's manufacturer text becomes "GoodWe (via RS485 EMS port)", and the device and its entities stay the same ([[DOC-8]], #201) | +| `75f0804` | The price chart on the Home page now shows one bar for each 15-minute price, the price the add-on decides on, where it showed hour averages. Its cheap and dear colours now show where the add-on will charge or give power, instead of thirds of the price range ([[UX-45]], #202) | +| `bee7431` | First half of the "not keeping up" fix, shipped with the next row and described there ([[SAFETY-119]], #203) | +| `e077ae7` | The false "Battery is not keeping up" warning, and its Home Assistant notification, no longer appear when the peak guard steps in and then gives control back near the start of a 15-minute period; battery commands do not change. A real overload still warns at the start of each 15-minute period, up to about 25 s later with the default 5 s meter, but with an uneven meter of about 15 to 20 s and a small overload, it can stay quiet for up to an hour ([[SAFETY-121]], #204, with [[SAFETY-119]]) | +| `90bc901` | On Dutch pages, the status rows, banners and settings notes now show Dutch text where they showed English, and so do the setup wizard's checks once [[UX-51]] is fixed. The settings label of the 15-minute demand forecast sensor no longer ends in "(W)", and English pages change only by that label ([[UX-46]], #205) | +| `aa22806` | Price trading, and the check for it with battery modes on, now ignore price periods that have already ended, so the add-on no longer aims at a 15-minute period that is over and then does nothing. Hourly and day/night sites also change: there is no pause at the start of each hour, as the owner accepted ([[PRICE-02]], #206) | + +### Known issues + +- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]). +- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]). +- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]). +- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]). +- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]). +- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]). +- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]). +- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]). +- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]). +- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]). +- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]). +- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]). +- When the 15-minute demand forecast sensor counts the battery's own charge, the maintenance charge runs at about half the power the peak limit leaves, so it takes about twice as long. Seen on a site: about 1.2 kW instead of 2.5 kW, and the peak limit was kept ([[SAFETY-111]]). +- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]). +- After a Home Assistant restart, control still waits until Home Assistant has finished starting, which can take several minutes, though the readings are already there ([[SAFETY-115]]). +- While Home Assistant is not answering, the page can list inverters as refusing commands and sensors as missing, and send you to Developer Tools, though they are fine ([[UX-37]]). +- If Home Assistant hangs or stops, the page keeps saying "Waiting for Home Assistant" and never turns into an alarm ([[SAFETY-113]]). +- Right after Home Assistant has started, the page can say for about 4 s that an inverter cannot be controlled, and the first command can come late ([[SAFETY-114]]). +- After a Home Assistant restart, the controller status sensor can still say "running" while the add-on cannot control. It was seen once, and the cause is not known ([[SAFETY-116]]). +- Battery modes on: after a planned top-up from the grid, the Batteries screen can say "Enough sun is forecast" ([[UX-32]]). +- Battery modes on: the forecast line "we count on" can show more sun than the plan counts, because the plan takes house use off first ([[UX-33]]). +- Battery modes on: before a planned top-up, the Today list shows a smaller "gives" amount than the plan will use; it grows as the charge happens ([[UX-34]]). +- Two inverters, battery modes on: while the other battery is in its maintenance charge, a battery's card can say it charges from the grid while it stays at 0 W ([[OPT-18]]). +- Two inverters, battery modes on: while a battery refuses to charge in a cheap run, the action log on the page gets about 8 lines an hour for its retries, and the page hint says "waiting for a better price" ([[UX-38]]). +- A battery in a safety stop, or one the add-on cannot reach, can still be started for a cycle, and its drain then waits about 12 h and ends in a failed-cycle notice. This comes from reading the code; it has not been seen on a site ([[MAINT-19]]). +- On the Settings page, the rows of the solar forecast lists are labelled "Phase 1", "Phase 2" instead of "Sensor 1", "Sensor 2" ([[UX-27]]). +- In the solar forecast list, a blank or repeated row makes "source 2 of 2" count differently from the rows on the Settings page ([[UX-28]]). +- The solar forecast message says "has no reading" also when the sensor shows a number that is too old or in the wrong unit ([[UX-29]]). +- If you use only the forecast lists, two Settings notes sit on the single-sensor field, and one of them tells you to set that field ([[UX-30]]). +- After a restart during a price outage, the "No prices" message can say "yet" when prices did come in earlier ([[UX-39]]). +- On the new settings page, you cannot see whether a price service token is set. Check the Configuration tab ([[UX-41]]). +- On the classic settings page, the first click on Save after editing a field can do nothing. Click again ([[UX-42]]). +- A steady overload on a 1 s meter can warn, and send the Home Assistant notification, 2 or 3 times at the start of each 15-minute period instead of once. The peak limit is still kept ([[SAFETY-122]]). +- A fixed tariff is priced once per hour, so a price window that starts or ends at :15, :30 or :45 gets the wrong price for part of that hour. Windows that start and end on the hour are right ([[TARIFF-23]]). +- Step 6 of the setup wizard stays empty because of an error in the page, in English and in Dutch ([[UX-51]]). +- On Dutch pages, refusal texts, the last-event line under the Home buttons, the installer details and the trading status sentences are still English, and a few Dutch texts read badly, such as "1 metingen geweigerd" ([[UX-52]]). +- Home can show a grid charge that the add-on will not take: the rest of a cheap run that the trade check refuses (battery modes on), or a charge after the last dear price period (battery modes off). The battery stays idle then ([[UX-53]]). +- Some price texts say "hour" where the add-on acts on a 15-minute period, such as "cheapest hour" ([[UX-47]]). +- After the add-on restarts, the Home price chart shows no price for the earlier part of today, and the setup check can say "12 of 24 hours", though the prices are known ([[UX-48]]). +- With battery modes on and only batteries on "Leftover solar", the Home price chart can mark a cheap period where no battery charges ([[UX-49]]). +- On a phone, the bars of the Home price chart are too thin to tap one 15-minute period. The arrow keys work ([[UX-50]]). + +### 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.18.2 **A patch bump: one fix to how the 15-minute demand forecast sensor is read, and no option changes.** diff --git a/goodwe_controller/DECISIONS/PRICE-02-the-verdict-skips-ended-periods.md b/goodwe_controller/DECISIONS/PRICE-02-the-verdict-skips-ended-periods.md new file mode 100644 index 0000000..e95b443 --- /dev/null +++ b/goodwe_controller/DECISIONS/PRICE-02-the-verdict-skips-ended-periods.md @@ -0,0 +1,89 @@ +# PRICE-02 - the trading verdict skips price periods that have ended + +**Status: for review.** Tests: `test_price02.py`. Found by AUDIT-01 (F1). + +The refresh floors the plan to the top of the hour, so on the 15-minute feed +it holds the periods of this hour that are already over. The verdict took the +cheapest and dearest over every point. With modes off it could aim at a period +that had passed and then do nothing, for up to 45 min plus the refresh lag. +With modes on the `allowed()` gate could be open only because of such a period. + +Decision: `TariffPlan.arbitrage(now=...)` drops the points whose period has +ended (`end <= now`, the span `price._cover_table()` gives, as +`priceruns.firm_slots()` does). Its three callers pass their own `now`: +`arbitrage.decide()`, `Controller._mode_roles()` and `homeview.trade_verdict()`. + +- Taken in the verdict, not when the plan is built: a plan built at 03:00 is + still used at 03:20, and the Home chart keeps the day's earlier bars. +- An hourly point that is still current stays in. But an hourly or static plan + is rebuilt every 30 min counted from add-on start, so it is usually off the + hour and, until the next refresh, holds the hour that has just ended. That + hour is dropped too (round 3, owner-accepted 2026-10-22). On a day/night + tariff all day hours tie and the old verdict took the first, the ended one, + so it paused the trade every hour until the refresh: 91 on/off changes over + 2 days with the refresh at :10, 4 now (4 before as well, with the refresh on + the hour). With the refresh on the hour nothing changes. +- `now` is required: keyword-only, no default, like the other three arguments. A + caller that could leave it out would get the elapsed periods back and the + defect with them. A test that wants the whole series passes an instant before it. +- The sentences of this verdict in `arbitrage.py` (status row and action log, + English only) say "price period" where they said "hour": the idle one now + reads "now is not the cheapest or the dearest price period". The UI texts + that say "cheapest hour" (AUDIT-01 T1-T5) are not touched here. +- Not changed here: F2 (static profile sampled at HH:00) and F3 (carried value). + +Measured: with price off, every command over the modes-off replay is +byte-identical. On a static tariff rebuilt on the hour it is too; rebuilt 20 +min off the hour the commands differ (digest 79f06d6381ff3358 on release/1.0, +18de19d9fe919fe5 here), for the reason above. On the dynamic replay every command is +the same too; only a sale's sentence quotes a smaller "above the cheapest" +figure once the first cheap period is over. The identity pins in +`test_modes_p3.py` and `test_price_run_fixes.py` hide just that figure and the +word change (`opt11p3_identity.trade_digest()`). + +## Ties (round 2) + +The verdict takes `min`/`max` over the points still ahead and the first one wins +a tie, so with equal prices it is the first period still ahead, which is the +current one. Before, it was the first of the whole plan, often one that had +ended. Two effects follow, and both are the card's intent, not a side effect: + +- Equal quarters (hour-constant data, or a feed that prints the same price in + several quarters) now act in every quarter of the cheap (or dear) block, + not in the first only. Test: `test_price02.py` part 6. +- After the cheapest period has passed, the next cheapest still ahead is the + target, if its spread still clears the cost. A hourly plan did this at every + refresh already (it started at the current hour); a 15-minute plan did it + only from the next hour on, because it kept the elapsed quarters of this one. + +Measured with `scratch/PRICE-02/dev/tiesim.py` (refresh every 30 min from the +floored hour, verdict at 7 min into each period, threshold 0.05, old = the +verdict over the whole plan, new = this card): + +| data | days | acting periods old -> new | action changes old -> new | +|---|---|---|---| +| real 15-minute day, energy-charts 2026-09-09 | 1 | 4 -> 5 | 6 -> 6 | +| real 15-minute days 2026-09-09 and -10 | 2 | 14 -> 26 | 24 -> 24 | +| EPEX 2025-26 hourly prices as four equal quarters | 12 | 18 -> 72 | 36 -> 20 | +| the same with random +-0.002 noise per quarter (no ties) | 12 | 18 -> 33 | 36 -> 48 | + +So a real 15-minute day does not switch more often (24 -> 24), and a day of +equal quarters switches less. A synthetic day of noisy, tie-free quarters does +(3.0 -> 4.0 changes a day): each period that passes hands the target to the +next cheapest one ahead. Every change is still at a period edge, never within +one. The replay in `test_arbitrage.py` (synthetic year, hourly) gives the same +ledger on the old and the new code once its ties are broken (280 round trips, +85 held, both): it printed one maximum twice per day, and the old fixture +tested the first of them only because its plan began at midnight. + +## Battery throughput (round 3) + +On real 15-minute days the trade runs longer: about 1.5 to 2.5 times the +minutes spent charging or discharging, with fewer on/off switches (for example +78 -> 68, 92 -> 72), over 16 days (2026-09-02 to 09-17, a decision every +minute, refresh at :00 or :10, tomorrow's prices known always or from 13:00; +reviewer's sim). Through a modelled 10 kWh battery that is 27.5 -> 42.6 kWh +(refresh at :00, tomorrow always known); 19.6-31.9 -> 43.4-48.7 kWh in the +other three variants. Charge to +discharge reversals are unchanged (10 -> 10, 13 -> 13). More cycling is the +price of not pausing. diff --git a/goodwe_controller/DECISIONS/SAFETY-119-bound-after-hand-back.md b/goodwe_controller/DECISIONS/SAFETY-119-bound-after-hand-back.md new file mode 100644 index 0000000..6c7256b --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-119-bound-after-hand-back.md @@ -0,0 +1,99 @@ +# SAFETY-119 - no "not keeping up" when the loop takes the wire back + +**Status: merged (bee7431); the settle and the budget changed in SAFETY-121** +(DECISIONS/SAFETY-121-bound-every-quarter.md: 4 cycles and 22 s, one budget +of 11). The rolling-budget sections below describe bee7431. Tests: +`test_safety119.py`. + +**Cause.** When another owner (the peak guard, a maintenance charge) has set +the wire and the loop wins again, `cycle()` re-seeds the integrator from that +owner's command, and `compute()` adds `gain * error` from a meter that does +not show that command yet. On the TEST-23 rig (SAFETY-118 I-2) the guard +stepped from -2000 W to +429 W with 5 s left. A cycle later the loop's ask +crossed the +2500 W rail and beat the guard's floor, so SAFETY-85 rule 1 +counted it as the loop's own bound and raised `goodwe_integrator_bound`. The +battery gave what it was told, and the cap held. + +**Decision.** After a hand-back, the loop's integrator bound is not counted +until the plant's settle has passed (`SHORT_CYCLES` cycles and +`SHORT_SPAN_S`, the window SAFETY-85 grades a battery on). After that it +counts as before. An over-rating (`clamped`, a bug) on a cycle the loop +decided always counts. No command changes, and no text was added or changed. + +**A rolling budget (round 3).** The settle above is per hand-back, and +losing the wire restarts it. Across all hand-backs the bound is hidden on at +most a budget of cycles in any 15 minutes (`ROLLING_BUDGET`, `ROLLING_S`); a +cycle over the budget counts. Round 1 restarted the settle at every +hand-back, so a guard and a charge taking turns hid the bound for good (0 +warnings in 2 h where 28 were raised before). Round 2 gave each run of +hand-backs its own budget; at 10 s and 15 s meters each quarter then started +a fresh one and the loop's bound was never counted. A quarter-edge kick is a +few bound cycles in one quarter, a real bound that stays is many: a budget +per 15 minutes tells them apart. + +**The budget follows the cycle length.** 10 cycles up to a 3.5 s cycle, 8 up +to 11 s, 6 up to 16 s, 4 beyond. The cycle length is the time between two +decided cycles (a stop, or a gap over two minutes, is not one); until it is +known, and without a monotonic clock, the budget is 8. Without a monotonic +clock the budget never refills. + +**Why not one number.** Measured over 2 h (`goodwe-addon-checks:1`), the +quarter-edge case (house 800 W, cap 2500 W, no forecast, 30 seeds) and the +case below. No single budget held both at 1 to 15 s: 3 to 6 let the false +alarm back at 2 s; 7 on 10 of 30 seeds at 2 s; 8 on 1 of 30 (seed 25) at +2 s, and warned in only 1 to 3 of 7 quarters at 15 s; 9 and 10 miss quarters +at 10 s. A slow cycle needs a small budget to warn, a fast one a large budget +to stay quiet. Per band, the largest budget that still warns: up to 3.5 s, +10 (9 and 10 had no false alarm at 1 and 2 s; above 10 was not swept); up to +11 s, 8 (9 misses quarters at 10 s); up to 16 s, 6 (7 leaves two quiet +quarters in a row at 15 s, 4 false-alarms at 12 s on 7 of 30 seeds); beyond, +4 (3 false-alarms at 20 and 30 s on 14 of 30, 5 leaves five quiet quarters at +20 s). + +**What holds.** Quarter-edge case, 30 seeds each: 0 notifications at 1, 2, +3, 4, 5, 10, 12, 15, 20 and 30 s. Repeated hand-backs (2 x 2500 W, +cheap-window charge, no usable forecast, cap 5000 W, house 5050 and 5100 W, +constant and with noise, 8 runs per meter): + +| meter | warnings in 2 h | quarters warned (of 7 after the first) | without the fix | +|---|---|---|---| +| 1 s | 56 to 63 | 7 | 56 to 63 | +| 2 s | 49 to 56 | 7 | 54 to 56 | +| 5 s | 19 to 23 | 7 | 28 | +| 10 s | 7 to 10 | 7 | 14 | +| 15 s | 4 to 10 | 4 to 7, never two quiet in a row | 14 | +| 30 s | 3 to 7 | 3 to 5, up to two quiet in a row | 7 to 11 | + +At 30 s no budget does better: 4 leaves two quiet quarters, 3 brings back +the false alarm. Single hand-back, the loop at its rail, first warning after +the hand-back compared with no fix: 10 s at 1 s, 20 s at 2 s, 40 s at 5 s +(default), 80 s at 10 s, 90 s at 15 s, 80 s at 20 s, 120 s at 30 s, 240 s at +60 s. At 60 s the rig is a different regime (the house is not over the cap +in the case above), so nothing more is claimed there. The cap held at 1 to +10 s in the quarter-edge case and at 1 to 5 s in the case above; at slower +meters the meter's lag puts quarters over it, the same with and without the +fix (commands are identical). + +**Limits.** These runs have one cycle per meter reading, at a steady +interval. A 20 or 30 s cycle needs `cycle_floor_s` raised; at the default +15 s, a slower meter adds cycles on an old reading. Measured that way (11 +seeds): a 15 s meter that varies by up to 30 % false-alarmed on 5 seeds, a +30 s meter on 10 (0 to 4 in 2 h, 8 to 15 without the fix); a 20 s meter on +none. The case above still warned every quarter there. + +**Kept.** The loop in charge at its rail warns on the same cycle as before. +The start and a restart after a stop are not hand-backs. The quarter-edge +kick of `test_safety119.py` part 1 stays hidden (see above). + +**Re-pinned.** test_safety84 (all six) and test_safety87 (`n1_crossing`, +`n1_above`, `n1_charge_low`): only OUTPUT_CLAMP log rows differ; commands and +claims are the same (`test_safety119.py` part 6). + +**Not hidden.** A battery that gives less than another owner told it still +alarms through SAFETY-85 rule 2. "Always counts" for an over-rating holds on +cycles the loop decided; on others SAFETY-85 rule 1 already dropped it. + +**Not changed.** The kick itself is control behaviour: for a few seconds at +a quarter's edge the loop asks up to its rail. In `test_safety119.py` part 1 +that is more discharge than the guard asked at a quarter's end, and a few +seconds of up to 500 W more charge at the next quarter's start. diff --git a/goodwe_controller/DECISIONS/SAFETY-121-bound-every-quarter.md b/goodwe_controller/DECISIONS/SAFETY-121-bound-every-quarter.md new file mode 100644 index 0000000..b15b374 --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-121-bound-every-quarter.md @@ -0,0 +1,95 @@ +# SAFETY-121 - a recurring overload warns every quarter + +**Status: for review.** Tests: `test_safety119.py` (parts 2, 4, 5, 6). +Follows DECISIONS/SAFETY-119-bound-after-hand-back.md. + +**The defect (TEST-23, SAFETY-119 rig test F-1).** 5 s meter, 2 x 2500 W, +cap 5000 W, maintenance charge, no forecast, house 5100 W. bee7431 warned +once in 70 min (04:31:01), then not at 04:45, 05:00 or 05:15. Base 75f0804 +warned at every quarter start. The loop held the wire once a quarter, at its +start, and was at its bound on 4 or 5 cycles. SAFETY-119's settle (12 +cycles, 60 s at 5 s) covered all of them, and 5 cycles a quarter fit in its +budget of 8 per 900 s. + +**Why the simulation said "every quarter".** `test_safety119.py` fed the +meter one cycle behind the battery reading (`delay` counts cycles: 5 s at a +5 s meter). Just after the loop leaves its rail, the guard's charge +headroom (house = meter + battery) is then off by up to 5000 W, the charge +pulls 2000 W, the guard steps in again, and the two chase each other: 8 to +10 hand-backs and about 20 bound cycles a quarter at 5 s (rig: 1 and 4-5). +With the meter read in the same cycle (`delay` 0, the rig's shape) the sim +gives the rig's numbers: 3 to 5 bound cycles a quarter at 5 s, all inside +SAFETY-119's settle, and bee7431 warns in 0 to 2 of 7 quarters. The test +now runs both shapes. + +**Decision.** After a hand-back the loop's bound is hidden until 4 cycles +(`HANDBACK_CYCLES`) and 22 s (`SHORT_SPAN_S`) have passed; across all +hand-backs on at most 11 cycles (`ROLLING_BOUND_CYCLES`) in any 900 s. The +budget no longer follows the cycle length, and the cycle length is not +tracked. A stop ends the settle and keeps the budget. An over-rating always +counts. Commands are unchanged. + +**Why it holds for any number of bound cycles a quarter.** The settle is +fixed: at most 3 cycles or 22 s after a hand-back, whichever is longer. A +bound that lasts past it counts on its first cycle after it. So one +hand-back a quarter, with the loop at its rail past the settle, warns in +that quarter, whether it is 1 bound cycle or 100. A bound that never lasts +past a settle counts once more than 11 are hidden within 900 s. Hidden +are only shapes with at most 11 bound cycles in 900 s, each within 3 cycles +or 22 s of a hand-back. That is the quarter-edge kick: in every steady-meter +run below it is on the 1st to 3rd cycle after its hand-back (up to 30 s at +a 30 s meter). In the rig's shape at 5 s the bound comes 30 to 55 s after +the hand-back. + +**Sweep** (`goodwe-addon-checks:1`, 2 h runs, scratch/SAFETY-121/dev/). +Quarter edge = test_safety119 part 1 (house 800 W, cap 2500 W, no +forecast). Overload = part 4 (2 x 2500 W, cap 5000 W, house 5050 or +5100 W, seeds 0-3). "Delay 1" is the old sim, "delay 0" the rig's shape. + +| meter | quarter edge, runs with a warning (new / bee7431 / no fix) | overload delay 1, quarters warned of 7 (new / bee7431 / no fix) | overload delay 0, 5100 W (new / bee7431 / no fix) | +|---|---|---|---| +| 1 s | 0/70 / 0/70 / 68/70 | 7 / 7 / 7 | 7 / 7 / 7 | +| 2 s | 0/70 / 0/70 / 39/70 | 7 / 7 / 7 | 7 / 7 / 7 | +| 3 s | 0/70 / 0/70 / 40/70 | 7 / 7 / 7 | 7 / 7 / 7 (5050 W: 7 / 1-7 / 7) | +| 5 s | 0/70 / 0/70 / 40/70 | 7 / 7 / 7 | 7 / 0-2 / 7 | +| 10 s | 0/70 / 0/70 / 40/70 | 7 / 7 / 7 | 7 / 0 / 7 | +| 15 s | 0/70 / 0/70 / 40/70 | 7 / 4-7 / 7 | no fix warns 0-1 (5300 W: 7 / 0 / 7) | +| 20 s | 0/60 / 0/60 / 30/60 | 7 / 5-7 / 7 | no fix warns 0 (5300 W: 7 / 0 / 7) | +| 30 s | 0/60 / 0/60 / 30/60 | 7 / 3-5 / 7 | no fix warns 0 (5300 W: 6-7 / 0 / 6-7) | + +Quarter-edge seeds: 1-30 with the meter in the same cycle, and 1-40 (1-30 +at 20 and 30 s) with the test's 1-2 cycle delay. 5300 W: seeds 1-5. +Commands identical to no fix and to bee7431 in all 1013 runs (these and +the limits below). Choosing the numbers: the runs above were recorded +without the fix and replayed through candidate rules; with 3, 4 or 5 +cycles, budgets 9 to 13 met both cases at 1-30 s. 8 warns at the quarter +edge (2 s, seed 25) and 14 misses quarters in the rig's shape (1 s, +5050 W), both checked in part 4. 11 is the middle, and 4 the middle of 3 +to 5; with a jittery meter (below) 3 gave more false alarms, 5 more quiet +quarters (replay). + +**First warning after one hand-back** (the loop at its rail; later than +no fix by): 11 s at 1 s, 22 s at 2 s, 24 s at 3 s, 25 s at 5 s (default), +30 s at 10 s, 45 s at 15 s, 60 s at 20 s, 90 s at 30 s, 180 s at 60 s. +bee7431: 10, 20, 30, 40, 80, 90, 80, 120, 240 s. In the overload runs at +1 to 30 s, the first warning was at most 90 s later than without the fix. + +**Limits (measured, not fixed).** +- A meter that jitters by 30 % with `cycle_floor_s` 15 (11 seeds): with + the test's 1-2 cycle meter delay the quarter edge warns on 2 of 11 seeds + at 15 s, 2 at 20 s and 5 at 30 s (bee7431: 4, 0, 10; no fix: 11 each); + with the meter in the same cycle on none (no fix: 10, 9, 11). The overload at 5100 W leaves up + to 4 quiet quarters in a row at 15 and 20 s (bee7431: no warning at all + on some seeds); at 5200 W at most 1. Its first warning came up to 61 min + later than without the fix. The numbers depend on the jitter draw: the + review's draw gave 4, 4 and 5 of 11 at the quarter edge (bee7431: 4, 1, + 9) and up to 2 and 4 quiet quarters at 15 and 20 s. +- With cycles over about 40 s, a bound that is never past a settle uses up + 11 in 900 s only if the wire changes hands every few cycles; otherwise + it is never counted (part 5 prints it at 60 s). +- Hidden as designed: a real bound with the kick's shape (at most 11 cycles + in 900 s, each within 3 cycles or 22 s of a hand-back). + +**Re-pinned.** test_safety84 (all six) and test_safety87 (`n1_crossing`, +`n1_above`, `n1_charge_low`): only OUTPUT_CLAMP log rows differ from +bee7431 and from no fix; commands, claims, guard and phases are the same. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index cd9051c..27b7b20 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -1,8 +1,9 @@ # GoodWe RS485 Controller -Drives a GoodWe ES/BP battery inverter over its RS485 meter bus: holds net grid -exchange at zero, and runs a monthly battery maintenance cycle so the BMS can -balance cells and recalibrate its coulomb counter. +Drives a GoodWe ES/BP battery inverter over its RS485 EMS port: sends it a grid +reading to hold net grid exchange at zero, and runs a monthly battery +maintenance cycle so the BMS can balance cells and recalibrate its coulomb +counter. Installers: the printed field guide (`docs/GoodWe-RS485-Field-Guide.pdf`) is v0.1.0 and outdated; where it disagrees with this page, this page wins. @@ -33,8 +34,8 @@ v0.1.0 and outdated; where it disagrees with this page, this page wins. You need: -- A GoodWe **ES / BP family** inverter (AA55 / RS485 meter-bus generation) -- The vendor's meter-emulating controller **disconnected** from the bus +- A GoodWe **ES / BP family** inverter (AA55 generation) +- The vendor's controller **disconnected** from the inverter's EMS port - A control board: a T-CAN485 (ESP32) flashed with `firmware/goodwe-master.yaml` - A grid-power sensor already working in Home Assistant, updating every ~5–10 s @@ -210,7 +211,7 @@ The label and description each setting shows on the Configuration tab, in tab or | Limits | Battery cut-out level (%) | `bms_soc_floor` | The level where the battery's own management system stops giving power. Read it off your battery rather than copying a figure. | | Limits | Peak limit (W) | `peak_cap_w` | Your own number: the 15-minute average power the add-on keeps the grid under. Take it from your grid contract. | | Limits | Margin below the peak limit (W) | `peak_margin_w` | The add-on aims this much below the peak limit, so a 15-minute period that ends a little high still stays under it. | -| Limits | 15-minute demand forecast entity (W) | `peak_forecast_entity` | Optional. A forecast of this 15-minute period's average power, in W or kW. With no power unit it is not used. Leave it empty and there is no peak limit. | +| Limits | 15-minute demand forecast entity | `peak_forecast_entity` | Optional. A forecast of this 15-minute period's average power, in W or kW. With no power unit it is not used. Leave it empty and there is no peak limit. | | Limits | Peak reserve (%) | `peak_soc_floor` | Peak limiting will not draw the battery below this level, but ordinary use can. Derived value, leave it unchanged. | | Tariff | Dynamic price source | `price_provider` | Where prices come from. Choosing a source starts the price fetch, even with trading off. | | Tariff | Supplier markup (EUR/kWh) | `price_markup_eur_kwh` | Your supplier's margin. It is added only when the price source does not already include it. | diff --git a/goodwe_controller/app/arbitrage.py b/goodwe_controller/app/arbitrage.py index da18309..2fbde48 100644 --- a/goodwe_controller/app/arbitrage.py +++ b/goodwe_controller/app/arbitrage.py @@ -172,7 +172,7 @@ IDLE_NOT_COVERED = ( IDLE_NO_SOC = ( "state of charge is not being read, and a battery whose fill nobody knows " "cannot be told to empty or to fill") -IDLE_OFF_PEAK_HOUR = "this hour is neither the buy hour nor the sell hour" +IDLE_OFF_PEAK_HOUR = "now is not the cheapest or the dearest price period" # OPT-09. A TRADE NOTHING CAN TAKE IS NOT A TRADE, AND THE ALTERNATIVE IS WORSE # THAN A MISSED HOUR. `charge_w`/`discharge_w` used to be the site's whole rating # and are now SIZED to the packs whose state of charge permits that direction, so @@ -196,7 +196,7 @@ IDLE_HEADROOM_UNMEASURED = ( "creates the billed quantity (§9.1) and nothing is measuring the quarter " "this one would land in") NEGATIVE_PRICE = ( - "§17: the sell hour is priced BELOW ZERO, so discharging into it would give " + "§17: the sell period is priced BELOW ZERO, so discharging into it would give " "energy away and pay for the privilege. Arbitrage must not export at a " "negative price") @@ -347,7 +347,7 @@ def decide(plan, now: datetime, *, peak_guard_active, charge_headroom_w, soc, verdict = plan.arbitrage(peak_guard_active=peak_guard_active, min_spread_eur_kwh=min_spread_eur_kwh, - uncertainty=uncertainty) + uncertainty=uncertainty, now=now) return verdict.match( refused=lambda v: _idle(v.reason), allowed=lambda v: _act(plan, now, v, soc, charge_w, discharge_w, @@ -362,7 +362,7 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct, if here is None: return _idle( f"the series carries a tradeable spread but no price for this " - f"instant ({now.isoformat()}), which is §11.4 for this hour rather " + f"instant ({now.isoformat()}), which is §11.4 for this period rather " f"than a reason to reach for a neighbouring one", spread=v.spread_eur_kwh, required=v.required_spread_eur_kwh) @@ -381,7 +381,7 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct, # irreversible action may rest on it. if here.provisional: return _idle( - f"§8.3: the price for this hour ({here.start.isoformat()}) is " + f"§8.3: the price for this period ({here.start.isoformat()}) is " f"CARRIED FORWARD, not published, and no irreversible action may be " f"taken on a provisional price", spread=v.spread_eur_kwh, required=v.required_spread_eur_kwh, @@ -399,18 +399,18 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct, # negative price reports the reason that actually forbids the trade # rather than one that happens to fire first on some other site. if price < 0: - return _idle(f"{NEGATIVE_PRICE} - this hour is " + return _idle(f"{NEGATIVE_PRICE} - this period is " f"{price:+.4f} EUR/kWh", **common) if soc <= floor_pct: return _idle( - f"the sell hour is here at {price:.4f} EUR/kWh but the pack is " + f"the sell period is here at {price:.4f} EUR/kWh but the pack is " f"at {soc:g} %, at or under the {floor_pct:g} % reserve floor " f"arbitrage may not spend (§10.1 priority 4)", **common) if not abs(float(discharge_w)) > 0.0: return _idle(IDLE_NO_SIZE, **common) return ArbitrageDecision( action=DISCHARGE, power_w=abs(float(discharge_w)), - reason=(f"{DISCHARGE}: this is the dearest firm hour at " + reason=(f"{DISCHARGE}: this is the dearest firm price period at " f"{price:.4f} EUR/kWh, {v.spread_eur_kwh:.4f} above the " f"cheapest and clear of the {v.required_spread_eur_kwh:.4f} " f"this cycle had to cover; discharging " @@ -421,7 +421,7 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct, if soc >= ceiling_pct: return _idle( - f"the buy hour is here at {price:.4f} EUR/kWh but the pack is at " + f"the buy period is here at {price:.4f} EUR/kWh but the pack is at " f"{soc:g} %, at or over the {ceiling_pct:g} % ceiling - there is " f"nowhere to put the cheap energy", **common) if not abs(float(charge_w)) > 0.0: @@ -456,7 +456,7 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct, capped = size_w < asked_w return ArbitrageDecision( action=CHARGE, power_w=-size_w, - reason=(f"{CHARGE}: this is the cheapest firm hour at " + reason=(f"{CHARGE}: this is the cheapest firm price period at " f"{price:.4f} EUR/kWh, {v.spread_eur_kwh:.4f} below the dearest " f"and clear of the {v.required_spread_eur_kwh:.4f} this cycle " f"had to cover; charging {size_w:.0f} W from {soc:g} %" diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index d63c552..dfc6940 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -22,16 +22,13 @@ from . import arbitrage, messages, peakguard, uitext from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD from .maintenance import IDLE as M_IDLE, next_slot_start from .packmode import CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, SOLAR_FIRST -from .price import Absence, Uncertainty, brussels +from .price import Absence, Uncertainty, brussels, _cover_table from .static_tariff import static_plan from .tariff import BILLING_TZ from .uimodel import REGION_PRESETS, options_to_ui # A battery at or under this many watts is resting, not charging or giving. IDLE_W = 50.0 -# Display only: the lowest third of today's price range is "cheap", the top -# third "expensive". A price range under this is flat, and nothing is marked. -FLAT_EUR_KWH = 0.01 # Severity words the page shows; the same four as STYLE.md section 3. ALARM, WARNING, INFO = messages.ALARM, messages.WARNING, messages.INFO @@ -142,55 +139,103 @@ class DayPrices: def __init__(self): self.day = None self.points = {} + self.kinds = {} def see(self, points, day0, day1): if self.day != day0: - self.day, self.points = day0, {} + self.day, self.points, self.kinds = day0, {}, {} for p in points: if day0 <= p.start < day1: self.points[p.start] = p return sorted(self.points.values(), key=lambda p: p.start) -def hour_bars(points, day0, day1): - """One bar per local hour: the mean of its prices, or None when unknown. - `kind` is cheap / dear / mid by thirds of today's range (display only). - The page calls these "low price" and "high price", not cheap hours: the - add-on trades in the one cheapest and one dearest hour, not in a third.""" - n = int(round(_hours(day1, day0))) +def _bar_step(pts): + """The series' own spacing: its smallest gap, never over an hour.""" + gaps = [y.start - x.start for x, y in zip(pts, pts[1:]) + if y.start > x.start] + return min(gaps + [timedelta(hours=1)]) + + +def _bar_cover(pts): + """`[(start, end, point)]`: what each point is the price of on the chart. + The add-on's own rule, not a copy: `price._cover_table()` (TARIFF-14), so + a point covers until the next one starts and never over an hour, and the + last one for the span of the one before it.""" + return _cover_table(pts) + + +def price_bars(points, day0, day1, windows=(), now=None, kept=None): + """One bar per price step of the day: a quarter for a 15-minute series + (96; 92 and 100 on the clock-change days), an hour for an hourly one or a + static tariff. A bar is the price of the point that covers it, never an + average, and a step with no price is a bar with `eur_kwh` None. + `kind` is "cheap" or "dear" where `windows` (`[(kind, start, end)]`, the + runs the add-on itself uses) cover the bar's start, else "mid". A bar that + is over keeps the kind it had while it was still ahead (`kept`, a dict + that lives a day). Display only.""" + pts = sorted(points, key=lambda p: p.start) + step = _bar_step(pts) + cover = _bar_cover(pts) + n = int((day1 - day0) / step) bars = [] for k in range(n): - a, b = day0 + timedelta(hours=k), day0 + timedelta(hours=k + 1) - inside = [p for p in points if a <= p.start < b] - price = (sum(p.eur_per_kwh for p in inside) / len(inside) - if inside else None) + a = day0 + k * step + z = a + step + hit = next((p for s, e, p in cover if s <= a < e), None) + kind = None + if hit is not None: + if kept is not None and now is not None and z <= now: + kind = kept.get(a, "mid") + else: + kind = next((w[0] for w in windows if w[1] <= a < w[2]), + "mid") + if kept is not None: + kept[a] = kind bars.append({"h": k, "start": a.isoformat(), "label": hhmm(a), - "end_label": hhmm(b), "points": len(inside), - "eur_kwh": None if price is None else round(price, 4), - "estimate": bool(inside) and any(p.provisional - for p in inside), - "kind": None}) - known = [b["eur_kwh"] for b in bars if b["eur_kwh"] is not None] - if known and max(known) - min(known) >= FLAT_EUR_KWH: - lo, hi = min(known), max(known) - third = (hi - lo) / 3.0 - for b in bars: - p = b["eur_kwh"] - if p is None: - continue - b["kind"] = ("cheap" if p <= lo + third else - "dear" if p >= hi - third else "mid") - elif known: - for b in bars: - if b["eur_kwh"] is not None: - b["kind"] = "mid" + "end_label": hhmm(z), + "eur_kwh": None if hit is None + else round(hit.eur_per_kwh, 4), + "estimate": hit is not None and bool(hit.provisional), + "kind": kind}) return bars +def _price_mode_in_use(c): + """True when at least one battery is on and uses a price mode. The runs + belong to those batteries; with all on Solar first or off, nothing acts + on a price.""" + for inv in c.inverters: + pm = c.pack_mode(inv) + if pm.use and pm.mode in PRICE_MODES: + return True + return False + + +def price_windows(c, plan, verdict): + """`[(kind, start, end)]`: where the add-on itself buys ("cheap") and + gives ("dear"). With battery modes on that is the kept `priceruns.RunPlan` + (`c.mode_runs`): the cheap run when charging in it pays, and every give + run. With them off it is the arbitrage verdict's two points, the ones + `trade_runs()` draws. Nothing is classified here.""" + if getattr(c, "modes_on", False): + rp = getattr(c, "mode_runs", None) + if rp is None or not rp.on or not _price_mode_in_use(c): + return [] + out = [("cheap", rp.cheap.start, rp.cheap.end)] \ + if rp.charge_useful() else [] + return out + [("dear", g.start, g.end) for g in rp.give] + if verdict is None: + return [] + return [("cheap", verdict.cheapest.start, + _point_end(plan, verdict.cheapest)), + ("dear", verdict.dearest.start, + _point_end(plan, verdict.dearest))] + + def day_range(points): """(lowest, highest) of today's published points, as {eur_kwh, label}, - or (None, None). The feed's own points, like the trade legs, not the - chart's hour averages.""" + or (None, None). The feed's own points, like the trade legs.""" firm = [p for p in points if not p.provisional] if not firm: return None, None @@ -217,11 +262,12 @@ def _run(i, kind, start, end, day0, day1, what, why, amount, *, kwh=None, "eur_kwh": None if price is None else round(price, 4)} -def trade_verdict(c, plan): +def trade_verdict(c, plan, now): """The arbitrage verdict the control cycle would reach on this plan, or a reason there is none. The SAME call and inputs as `Controller.cycle()`: `plan.arbitrage()` with the peak guard's state, the cycle cost and the - no-weather uncertainty. Returns `(verdict_or_None, why_key)`.""" + no-weather uncertainty, and (PRICE-02) the same `now`. Returns + `(verdict_or_None, why_key)`.""" if not arbitrage.enabled(c.o): return None, "off" guard = arbitrage.guard_state(c.peak_guard) @@ -231,20 +277,24 @@ def trade_verdict(c, plan): return None, "no_prices" verdict = plan.arbitrage(peak_guard_active=guard, min_spread_eur_kwh=arbitrage.threshold_eur_kwh(c.o), - uncertainty=frozenset({Uncertainty.NO_WEATHER})) + uncertainty=frozenset({Uncertainty.NO_WEATHER}), + now=now) return verdict.match( allowed=lambda v: (v, "allowed"), - refused=lambda _v: (None, refusal_key(plan, guard))) + refused=lambda _v: (None, refusal_key(plan, guard, now))) -def refusal_key(plan, guard): +def refusal_key(plan, guard, now): """Which of `TariffPlan.arbitrage()`'s refusals fired, in its own order: the peak guard, no prices, under two published points, then the gap.""" if guard: return "peak" if not plan.has_prices: return "no_prices" - if sum(1 for p in _points(plan) if not p.provisional) < 2: + pts = _points(plan) + ahead = {id(p) for _s, end, p in _cover_table(pts) if end > now} + pts = [p for p in pts if id(p) in ahead] + if sum(1 for p in pts if not p.provisional) < 2: return "not_firm" return "not_worth" @@ -551,13 +601,15 @@ def build_plan(c, now, shadow, running, held=False, settled=True): plan = static except Exception: # noqa: BLE001 pass - seen = _day_prices(c).see(_points(plan), day0, day1) - bars = hour_bars(seen, day0, day1) + days = _day_prices(c) + seen = days.see(_points(plan), day0, day1) limit_w = c.peak.cap_w # the limit the controller parsed at start runs, lines = [], [] # The control cycle trades on the fresh plan only, so this does too. - verdict, trade_why = trade_verdict(c, fresh) + verdict, trade_why = trade_verdict(c, fresh, now) + bars = price_bars(seen, day0, day1, price_windows(c, fresh, verdict), + now, days.kinds) modes_on = bool(getattr(c, "modes_on", False)) if running: off = {i for i, inv in enumerate(c.inverters) if _off(c, inv)} @@ -1028,9 +1080,9 @@ def _kwh(v) -> str: return _dec(f"{float(v):.1f}") + " kWh" -# Row id -> the title on Home. The row's own sentence is the body, and it stays -# in English (app/messages.py). Five titles are the notification's own title, -# which test_ui_text.py checks. +# Row id -> the title on Home. The row's own sentence is the body, in the +# page's language (UX-46, `messages.pick`). Five titles are the notification's +# own title, which test_ui_text.py checks. _EN = uitext.words("en")["home_words"] ATTN_TITLES = {(None if k == "none" else k): v for k, v in _EN["attn"].items()} ATTN_FROM_MESSAGES = { @@ -1431,3 +1483,10 @@ def _samples() -> list: ("not_covered", "no_prices", "peak", "not_firm", "not_worth", "no_level")] return out + + +def priced_hours(bars): + """How many clock hours of the day have a price: distinct hours among the + bars that have one, so 96 quarters count 24, not 96. The setup wizard's + "N of 24 hours". A 23-hour day gives 23, a 25-hour day 24.""" + return len({b["label"][:2] for b in bars if b.get("eur_kwh") is not None}) diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 9e98549..bc71984 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -3,7 +3,7 @@ WHAT THIS THING DOES, in one paragraph: it reads the house's net grid power from Home Assistant, decides how hard the battery should charge or discharge to hold that at zero, and writes that figure to an ESP32 which puts it on the inverter's -RS485 meter bus. Once a month it runs a full low->full battery cycle so the BMS +RS485 EMS port. Once a month it runs a full low->full battery cycle so the BMS can balance cells and recalibrate its coulomb counter. ⚠️ THE ONE THING TO UNDERSTAND BEFORE CHANGING ANY OF THIS: the inverter holds @@ -299,6 +299,14 @@ REFUSED_PROGRESS_W = _ctl.PROBE_ANSWER_W / 2 # 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 +# SAFETY-119/121: after a hand-back the loop's bound is hidden until +# HANDBACK_CYCLES cycles and SHORT_SPAN_S have passed; across all hand-backs +# on at most ROLLING_BOUND_CYCLES cycles in any ROLLING_S seconds. Measured in +# DECISIONS/SAFETY-121-bound-every-quarter.md. Without a monotonic clock the +# budget never refills. +HANDBACK_CYCLES = 4 +ROLLING_S = 900.0 +ROLLING_BOUND_CYCLES = 11 def _refused_interval(sign: int) -> tuple: @@ -1272,6 +1280,13 @@ class Controller: self._loop_decision = None self._asked_hist: dict = {} self._short_run = 0 + # SAFETY-119: did another owner set the wire last cycle; the window + # after a hand-back (monotonic s, the loop's cycles in a row); the + # monotonic s of the bound cycles hidden in the last ROLLING_S (a + # stop does not clear them). + self._wire_lost = False + self._handback = None + self._hidden = () self.stopping = False # SAFETY-61. time.monotonic() at the end of the last run_control() pass. self.loop_pass_mono = None @@ -2354,7 +2369,8 @@ class Controller: return plan.arbitrage( peak_guard_active=active, min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o), - uncertainty=frozenset({Uncertainty.NO_WEATHER})).match( + uncertainty=frozenset({Uncertainty.NO_WEATHER}), + now=now).match( allowed=lambda _v: True, refused=lambda _v: False) if guard is not None and plan is not None and runs is not None: # A charge asks the guard (section 9.1 interlock); a sell only @@ -5813,6 +5829,7 @@ class Controller: if d is None: self._short_run = 0 self._asked_hist = {} + self._wire_lost, self._handback = False, None return unmet = self.allocation.unmet_w if self.allocation is not None else 0 cut = (resolution.bound_by or "").split("(")[0] @@ -5838,7 +5855,9 @@ class Controller: 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]: + settling = self._settling(owns, now) + if owns and self.loop_clamped[0] and not ( + settling and not self.loop_clamped[2]): return engaged = self._short_run >= self.tuning.saturation_cycles named = len(self.inverters) > 1 @@ -5847,6 +5866,41 @@ class Controller: if engaged else "", False) self.loop_clamp_facts = () + def _settling(self, owns, now) -> bool: + """SAFETY-119: does the loop's bound on this cycle count? Not within + a settle after another owner handed the wire back: its integrator is + re-seeded from that owner's command and kicked by a meter that does + not show it yet. SAFETY-121: the settle is HANDBACK_CYCLES cycles and + SHORT_SPAN_S after the hand-back, so a bound that outlasts it counts + at any cycle length; losing the wire restarts it. Across all + hand-backs the bound is hidden on at most ROLLING_BOUND_CYCLES cycles + in any ROLLING_S, so a bound that never outlasts a settle is counted + too. DECISIONS/SAFETY-121-bound-every-quarter.md.""" + def aged(t0, span): + return now is None or t0 is None or now - t0 >= span + + if not owns: + self._wire_lost, self._handback = True, None + return False + if self._wire_lost: + self._wire_lost, self._handback = False, (now, 0) + if self._handback is None: + return False + tc, n = self._handback + n += 1 + self._handback = (tc, n) + if aged(tc, SHORT_SPAN_S) and n >= HANDBACK_CYCLES: + self._handback = None + return False + if self.loop_clamped[0] and not self.loop_clamped[2]: + kept = tuple(t for t in self._hidden if now is None or ( + t is not None and now - t < ROLLING_S)) + if len(kept) >= ROLLING_BOUND_CYCLES: + self._hidden = kept + return False + self._hidden = kept + (now,) + return True + async def check_output_clamp(self) -> None: """SAFETY-03. Alarm whenever a clamp actually engaged this cycle. @@ -7411,7 +7465,8 @@ class Controller: level, banner = "warn", messages.banner_stopped() elif runs: level, running_alarm = "bad", True - banner = runs[0].get("banner") or messages.headline(runs[0]["text"]) + banner = runs[0].get("banner") or messages.headline_bi( + 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. @@ -7434,11 +7489,11 @@ class Controller: # character; at two, "Maintenance: drain" without a name is a # banner an operator cannot act on, because the remedy is at one # device and the site has two. - level, banner = "warn", f"Maintenance: {self.maint.phase}" - if len(self.inverters) > 1: - banner += f" ({self.maint_unit.name})" + level, banner = "warn", messages.banner_maintenance( + self.maint.phase, + self.maint_unit.name if len(self.inverters) > 1 else "") else: - level, banner = "ok", "Running — holding grid at zero" + level, banner = "ok", messages.banner_running() def w(v): return "—" if v is None else f"{v:,.0f} W".replace(",", " ") @@ -7542,6 +7597,13 @@ class Controller: now=datetime.now(timezone.utc), why=self.why_words)), ] held = level == "bad" and not running_alarm + # UX-46. The rows were built in English with their Dutch beside them + # (`messages.Bi`); the page's language is chosen once, here, for the + # Home sentences, the checks list, the rows that carry one and the + # banner. A plain str (no Dutch twin) stays as it is. + checks = [dict(c, text=messages.pick(c["text"], lang)) for c in checks] + rows = [(k, messages.pick(v, lang)) for k, v in rows] + banner = messages.pick(banner, lang) out = { "banner": banner, "level": level, "rows": rows, "checks": checks, "hint": self.hint, diff --git a/goodwe_controller/app/messages.py b/goodwe_controller/app/messages.py index 0c1344f..298c9e0 100644 --- a/goodwe_controller/app/messages.py +++ b/goodwe_controller/app/messages.py @@ -31,6 +31,54 @@ def duration(seconds) -> str: return f"{s / 60:.0f} minutes" +class Bi(str): + """UX-46. A status row in both languages. It is its English text, so every + caller and every log line sees English, and `.nl` is the Dutch. The Home + page picks one with `pick()` where it knows the page's language.""" + + def __new__(cls, en, nl=None): + out = super().__new__(cls, en) + out.nl = nl + return out + + def __getnewargs__(self): + return (str(self), self.nl) + + +def pick(text, lang="en"): + """`text` in `lang`. A `Bi` gives its Dutch for "nl"; any other text, and + any language but "nl", gives the text as it is.""" + if lang == "nl" and isinstance(text, Bi) and text.nl: + return str(text.nl) + return str(text) if isinstance(text, Bi) else text + + +def seconden(value) -> str: + n = f"{float(value):.0f}" + return f"{n} seconde" if n == "1" else f"{n} seconden" + + +def duur(value) -> str: + s = float(value) + return f"{s:.0f} seconden" if s < 120 else f"{s / 60:.0f} minuten" + + +def keer(n) -> str: + return f"{int(n)} keer" + + +def voor(unit) -> str: + """` voor inverter-b` on a site with several inverters, else nothing.""" + return f" voor {unit}" if unit else "" + + +def namen(items) -> str: + items = list(items) + if len(items) <= 1: + return "".join(items) + return ", ".join(items[:-1]) + " en " + items[-1] + + def names(items) -> str: items = list(items) if len(items) <= 1: @@ -129,6 +177,13 @@ S_NOT_DELIVERING = "An inverter is not delivering" T_METER_FROZEN = "Meter reading may be stuck" T_MAINT_FAILED = "Battery cycle did not finish" T_MAINT_FAILED_NL = "Batterijcyclus niet afgerond" +# UX-46: the Dutch of the titles the rows repeat. The Home page's own titles +# (ui_text/nl.json, "attn") are the same words. +T_LOOP_BOUND_NL = "Batterij kan niet volgen" +T_OUTPUT_CLAMP_NL = "Vermogensvraag te hoog" +T_PEAK_SATURATED_NL = "Belasting te hoog voor de batterij" +T_METER_FROZEN_NL = "Meter staat mogelijk vast" +TITLE_NL = {T_LOOP_BOUND: T_LOOP_BOUND_NL, T_OUTPUT_CLAMP: T_OUTPUT_CLAMP_NL} T_NO_PRICES = "No prices" T_NO_PRICES_NL = "Prijzen ontbreken" @@ -387,13 +442,18 @@ def fixed_tariff_card_gone() -> Dismiss: def row_no_prices(since=None, now=None) -> str: """PRICE-01: the status row while no price covers now.""" if since is not None: - return f"No prices since {since_text(since, now)}, maintenance waits." - return "No prices yet, maintenance waits." + return Bi(f"No prices since {since_text(since, now)}, maintenance " + "waits.", + f"Geen prijzen sinds {since_text(since, now, 'nl')}, " + "onderhoud wacht.") + return Bi("No prices yet, maintenance waits.", + "Nog geen prijzen, onderhoud wacht.") def row_maint_waits(until_text: str) -> str: """MAINT-13: the status row while a failed cycle waits.""" - return f"Waits until {until_text} after a cycle that did not finish." + return Bi(f"Waits until {until_text} after a cycle that did not finish.", + f"Wacht tot {until_text} na een cyclus die niet af was.") # --- the status page (UX-08 phase 2b) ---------------------------------------- @@ -405,6 +465,8 @@ def row_maint_waits(until_text: str) -> str: # DOCS.md, "Status page", explains every row. NOT_READY = "Not ready" +BANNER_RUNNING = ("Running — holding grid at zero", + "Actief — houdt het net op nul") DOCS_STATUS = "Status page" @@ -462,42 +524,74 @@ def headline(text: str) -> str: # Banners (the headline at the top of the page). +def headline_bi(text): + """`headline()` of a row, in both languages.""" + return Bi(headline(text), headline(getattr(text, "nl", None) or text)) + + def banner_not_ready(row: dict) -> str: - return f"{NOT_READY} — {row.get('banner') or headline(row['text'])}" + part = row.get("banner") or headline_bi(row["text"]) + return Bi(f"{NOT_READY} — {part}", + f"Niet klaar — {getattr(part, 'nl', None) or part}") def banner_circulating() -> str: - return "Inverters worked against each other. Every inverter is set to 0 W." + return Bi("Inverters worked against each other. Every inverter is set to " + "0 W.", + "Omvormers werkten elkaar tegen. Elke omvormer staat op 0 W.") def banner_site_direction() -> str: - return "Total power went the wrong way. All inverters at 0 W." + return Bi("Total power went the wrong way. All inverters at 0 W.", + "Totaal vermogen ging de verkeerde kant op. Alle omvormers op " + "0 W.") def banner_direction(several: bool) -> str: if several: - return "An inverter's own power went the wrong way. Set to 0 W." - return "The meter saw power go the wrong way. Inverter at 0 W." + return Bi("An inverter's own power went the wrong way. Set to 0 W.", + "Eigen vermogen van een omvormer ging verkeerd. Op 0 W " + "gezet.") + return Bi("The meter saw power go the wrong way. Inverter at 0 W.", + "De meter zag het vermogen verkeerd gaan. Omvormer op 0 W.") def banner_not_delivering(units) -> str: units = list(units) verb = "are" if len(units) > 1 else "is" - return f"{names(units)} {verb} not delivering. Control keeps running." + werkwoord = "leveren" if len(units) > 1 else "levert" + return Bi(f"{names(units)} {verb} not delivering. Control keeps running.", + f"{namen(units)} {werkwoord} niet. De regeling blijft draaien.") + + +def banner_maintenance(phase: str, unit: str = "") -> str: + where = f" ({unit})" if unit else "" + fase = {"drain": "leegmaken", "charge": "opladen", "hold": "vol houden"} + return Bi(f"Maintenance: {phase}{where}", + f"Onderhoud: {fase.get(phase, phase)}{where}") + + +def banner_running() -> str: + return Bi(*BANNER_RUNNING) def banner_stopped() -> str: - return "Stopped — the inverters are set to 0 W" + return Bi("Stopped — the inverters are set to 0 W", + "Gestopt — de omvormers staan op 0 W") def banner_setup_only() -> str: """UX-15: setup-only mode, where the add-on serves the page and commands nothing (app/setuponly.py).""" - return "Setup: nothing is controlled yet" + return Bi("Setup: nothing is controlled yet", + "Installatie: nog niets wordt geregeld") def row_setup_only() -> str: - return "No inverter is named yet, so finish Setup and restart the add-on." + return Bi("No inverter is named yet, so finish Setup and restart the " + "add-on.", + "Er is nog geen omvormer opgegeven, dus rond de installatie af " + "en herstart de add-on.") def banner_shadow(latched: bool) -> str: @@ -508,84 +602,117 @@ def banner_shadow(latched: bool) -> str: clears. """ if latched: - return ("Shadow mode: battery not controlled. A safety stop stays on, " - "see below.") - return "Shadow mode: the battery is not controlled. Inverters stay at 0 W." + return Bi("Shadow mode: battery not controlled. A safety stop stays " + "on, see below.", + "Schaduwmodus: batterij niet geregeld. Een veiligheidsstop " + "blijft aan, zie hieronder.") + return Bi("Shadow mode: the battery is not controlled. Inverters stay at " + "0 W.", + "Schaduwmodus: de batterij wordt niet geregeld. Omvormers " + "blijven op 0 W.") def banner_ha_waiting() -> str: """SAFETY-112: the Supervisor passes nothing on until Home Assistant has finished starting, so the add-on can neither read nor write.""" - return "Waiting for Home Assistant to finish starting" + return Bi("Waiting for Home Assistant to finish starting", + "Wachten tot Home Assistant is opgestart") def row_ha_waiting() -> str: - return ("Home Assistant is not answering, perhaps it is still starting. " - "Control resumes by itself when it answers.") + return Bi("Home Assistant is not answering, perhaps it is still " + "starting. Control resumes by itself when it answers.", + "Home Assistant antwoordt niet, misschien start het nog op. " + "De regeling gaat vanzelf verder zodra het antwoordt.") # Rows: control and the meter. "Control" is what the add-on does, as in # "start control" (STYLE.md section 4). def row_controller_starting() -> str: - return "Control: starting" + return Bi("Control: starting", "Regeling: start op") def row_controller_ran(age_s) -> str: - return f"Control: last ran {seconds(age_s)} ago" + return Bi(f"Control: last ran {seconds(age_s)} ago", + f"Regeling: laatst uitgevoerd {seconden(age_s)} geleden") def row_controller_stalled(age_s, veto_s) -> str: - return (f"Control has not run for {seconds(age_s)}. The control board " - f"sets the inverter to 0 W after {seconds(veto_s)}.") + return Bi(f"Control has not run for {seconds(age_s)}. The control board " + f"sets the inverter to 0 W after {seconds(veto_s)}.", + f"De regeling is al {seconden(age_s)} niet uitgevoerd. De " + f"stuurmodule zet de omvormer na {seconden(veto_s)} op 0 W.") def row_meter_waiting(limit_s) -> str: """UX-10: no reading yet since the start, and still inside the limit.""" - return f"Meter: waiting for the first reading, limit {seconds(limit_s)}" + return Bi(f"Meter: waiting for the first reading, limit " + f"{seconds(limit_s)}", + f"Meter: wacht op de eerste meting, limiet {seconden(limit_s)}") def row_meter_stale(age_s, limit_s) -> str: - return (f"Meter: no reading for {seconds(age_s)}, limit " - f"{seconds(limit_s)}. The add-on log shows why.") + return Bi(f"Meter: no reading for {seconds(age_s)}, limit " + f"{seconds(limit_s)}. The add-on log shows why.", + f"Meter: al {seconden(age_s)} geen meting, limiet " + f"{seconden(limit_s)}. De log van de add-on toont waarom.") def row_meter(net_w, age_s, rejected=0, unchanged_s=None) -> str: - return (f"Meter: {signed_watts(net_w)}, {seconds(age_s)} old" - + (f", {rejected} readings refused" if rejected else "") - + ("" if unchanged_s is None - else f", unchanged for {duration(unchanged_s)}")) + return Bi(f"Meter: {signed_watts(net_w)}, {seconds(age_s)} old" + + (f", {rejected} readings refused" if rejected else "") + + ("" if unchanged_s is None + else f", unchanged for {duration(unchanged_s)}"), + f"Meter: {signed_watts(net_w)}, {seconden(age_s)} oud" + + (f", {rejected} metingen geweigerd" if rejected else "") + + ("" if unchanged_s is None + else f", ongewijzigd gedurende {duur(unchanged_s)}")) def row_meter_frozen() -> str: - return (f"{T_METER_FROZEN}. Control still follows it. Check the meter, " - f"and stop control if it is stuck.") + return Bi(f"{T_METER_FROZEN}. Control still follows it. Check the meter, " + f"and stop control if it is stuck.", + f"{T_METER_FROZEN_NL}. De regeling volgt hem nog. Controleer " + f"de meter en stop de regeling als hij vastzit.") # Settings are named by their labels in translations/en.yaml. METER_SOURCE = "“Where the meter reading comes from”" +METER_SOURCE_NL = "“Waar de meterstand vandaan komt”" FREEZE_OFF = { "source": f"it needs {METER_SOURCE} set to HomeWizard local", "rating": "“Inverter rating” is not set", "range": "the inverter reports no usable power range", } +FREEZE_OFF_NL = { + "source": f"vereist {METER_SOURCE_NL} op HomeWizard local", + "rating": "“Vermogen van de omvormer” is niet ingesteld", + "range": "de omvormer meldt geen bruikbaar vermogensbereik", +} def row_freeze_off(why: str) -> str: """`why` is a key of FREEZE_OFF: why the stuck-meter check cannot run.""" - return f"Stuck-meter check: off, {FREEZE_OFF[why]}" + return Bi(f"Stuck-meter check: off, {FREEZE_OFF[why]}", + f"Controle op vastgelopen meter: uit, {FREEZE_OFF_NL[why]}") def row_freeze_on(hold_s, rail_w, own_limit: bool) -> str: - return (f"Stuck-meter check: alerts after {duration(hold_s)} unchanged " - f"above {watts(rail_w)}" - + (", the inverter's own limit" if own_limit else "")) + return Bi(f"Stuck-meter check: alerts after {duration(hold_s)} unchanged " + f"above {watts(rail_w)}" + + (", the inverter's own limit" if own_limit else ""), + f"Controle op vastgelopen meter: alarm na {duur(hold_s)} " + f"ongewijzigd boven {watts(rail_w)}" + + (", omvormerlimiet" if own_limit else "")) # Rows: the peak guard. def row_peak_saturated(ceiling_w) -> str: - return (f"{T_PEAK_SATURATED}. Switch off large loads to stay under the " - f"{watts(ceiling_w)} peak limit.") + return Bi(f"{T_PEAK_SATURATED}. Switch off large loads to stay under the " + f"{watts(ceiling_w)} peak limit.", + f"{T_PEAK_SATURATED_NL}. Schakel grote verbruikers uit om onder " + f"de piekgrens van {watts(ceiling_w)} te blijven.") def row_peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool, @@ -593,95 +720,143 @@ def row_peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool, """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" + why, why_nl = ("Battery level cannot be read", + "Het laadniveau is niet leesbaar") elif floor_unknown: - why = "A battery cut-out level is not set" + why, why_nl = ("A battery cut-out level is not set", + "Een uitschakelniveau van de batterij is niet ingesteld") else: - why = (f"{names(packs)} battery at its peak reserve" if packs - else "Battery at its peak reserve") + why, why_nl = ((f"{names(packs)} battery at its peak reserve", + f"Batterij van {namen(packs)} op de piekreserve") + if packs else + ("Battery at its peak reserve", + "Batterij op de piekreserve")) 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.") + return Bi(f"{why}. The others cannot hold the {watts(ceiling_w)} peak " + f"limit alone. Home use still draws it lower, to the " + f"cut-out level.", + f"{why_nl}. De andere kunnen de piekgrens van " + f"{watts(ceiling_w)} niet alleen houden. Het verbruik in " + f"huis trekt de batterij verder leeg, tot het " + f"uitschakelniveau.") + return Bi(f"{why}, so the {watts(ceiling_w)} peak limit will be passed. " + f"Home use still draws the battery below it, down to the " + f"cut-out level.", + f"{why_nl}, dus de piekgrens van {watts(ceiling_w)} wordt " + f"overschreden. Het verbruik in huis trekt de batterij verder " + f"leeg, tot het uitschakelniveau.") def row_peak_guard_off() -> str: """`meter_source` is off: the 15-minute average cannot be measured.""" - return f"Peak guard: off, set {METER_SOURCE} to anything but off" + return Bi(f"Peak guard: off, set {METER_SOURCE} to anything but off", + f"Piekbewaking: uit, zet {METER_SOURCE_NL} op iets anders dan " + f"uit") def banner_peak_reserve() -> str: - return T_PEAK_RESERVE + return Bi(T_PEAK_RESERVE, "Batterij beperkt de piek niet meer") # Rows: the sensors and the inverter control. LABEL_SOC = "Battery level" LABEL_BATTERY = "Battery power" LABEL_GRID = "Grid power" +# UX-46: the same three in Dutch, and the settings that name them. +LABEL_NL = {LABEL_SOC: "Laadniveau", LABEL_BATTERY: "Batterijvermogen", + LABEL_GRID: "Netvermogen"} +LABEL_ENTITY_NL = {LABEL_SOC: "Entiteit laadniveau", + LABEL_BATTERY: "Entiteit batterijvermogen", + LABEL_GRID: "Entiteit netvermogen"} def row_sensor(label, value, unit=None) -> str: shown = f"{float(value):g} %" if label.startswith(LABEL_SOC) \ else signed_watts(value) - return f"{label}{for_unit(unit)}: {shown}" + return Bi(f"{label}{for_unit(unit)}: {shown}", + f"{LABEL_NL.get(label, label)}{voor(unit)}: {shown}") def row_sensor_unset(label, unit=None) -> str: """The setting is "