Compare commits
34
Commits
UX-43
..
SAFETY-121
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24f31a9c3a | ||
|
|
8bf25696cb | ||
|
|
bee7431793 | ||
|
|
96375ecbec | ||
|
|
6fd0fa05f1 | ||
|
|
e27e62ff72 | ||
|
|
86b94bedb5 | ||
|
|
103db2fcda | ||
|
|
85542e443b | ||
|
|
e76c70ecb0 | ||
|
|
b9ff6acea4 | ||
|
|
98026f9a46 | ||
|
|
b0c2e5977d | ||
|
|
c4e15ec4cd | ||
|
|
75f0804f9d | ||
|
|
eb8375157d | ||
|
|
17c6048417 | ||
|
|
243a756e7b | ||
|
|
a4a7ff695e | ||
|
|
b8b357b552 | ||
|
|
4584502adb | ||
|
|
fd08090360 | ||
|
|
c67d0cdb67 | ||
|
|
ea77c63552 | ||
|
|
75f4359aac | ||
|
|
09a54859f2 | ||
|
|
7d52dc1c0f | ||
|
|
f0b4e68e49 | ||
|
|
780c1f67bb | ||
|
|
0ee3c3468b | ||
|
|
dec8e7b69f | ||
|
|
8e02a0928b | ||
|
|
1bd2ba1397 | ||
|
|
b703aaa6d5 |
+3
-3
@@ -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
|
||||
|
||||
+1
-1
@@ -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`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Binary file not shown.
+6
-6
@@ -154,7 +154,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<table style="margin-bottom:2mm">
|
||||
<tr><th style="width:34mm">Tag</th><th>Platform</th><th style="width:30mm">Verdict</th></tr>
|
||||
<tr><td><code>ESU EMU ESA BPS<br>BPU EMJ IJL</code></td>
|
||||
<td><b>Platform 105</b> — ES / EM / BP, AA55-era meter bus</td>
|
||||
<td><b>Platform 105</b> — ES / EM / BP, AA55-era</td>
|
||||
<td><b>SUPPORTED</b></td></tr>
|
||||
<tr><td><code>SPB SPN</code></td><td>SBP <b>G2</b> (GW…-SBP-20) — platform 745</td><td><b>NO</b></td></tr>
|
||||
<tr><td><code>ESN ESC</code></td><td>ES <b>G2</b> (GW…-ES-20) — platform 745</td><td><b>NO</b></td></tr>
|
||||
@@ -162,7 +162,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<tr><td><code>SDT DST MSU NSU …</code></td><td>PV-only inverters — <b>no battery to control</b></td><td><b>NO</b></td></tr>
|
||||
</table>
|
||||
<p style="margin:0"><small>Reference unit: <code>95000BPS225W0290</code> → <code>BPS</code> → 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. <b>If the tag is not on the supported list, stop — do not quote the job.</b></small></p>
|
||||
</div>
|
||||
|
||||
@@ -208,13 +208,13 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<!-- ============ PAGE 3 ============ -->
|
||||
<div class="page" id="p3">
|
||||
<h2>Steps 1–3 — Hardware</h2>
|
||||
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The meter bus
|
||||
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The RS485 EMS port
|
||||
is low-voltage but sits inside an energised installation.</div>
|
||||
|
||||
<div class="step">
|
||||
<h3><span class="num">1</span> Survey and record</h3>
|
||||
<ol class="do">
|
||||
<li><b>Photograph</b> the meter-port wiring before touching it.</li>
|
||||
<li><b>Photograph</b> the EMS-port wiring before touching it.</li>
|
||||
<li>Identify the RS485 pair (A/B) running to the vendor controller.</li>
|
||||
<li>Record inverter model + serial, battery capacity (Ah) and nominal voltage.</li>
|
||||
</ol>
|
||||
@@ -226,7 +226,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<ol class="do">
|
||||
<li>Bring the system to <b>0 W</b> — battery neither charging nor discharging (check the
|
||||
inverter display).</li>
|
||||
<li>Disconnect the vendor controller from the meter port.</li>
|
||||
<li>Disconnect the vendor controller from the inverter's EMS port.</li>
|
||||
<li>Leave it physically mounted but unplugged — that keeps the install reversible and gives
|
||||
you a rollback story.</li>
|
||||
</ol>
|
||||
@@ -237,7 +237,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<div class="step">
|
||||
<h3><span class="num">3</span> Fit and power the T-CAN485</h3>
|
||||
<ol class="do">
|
||||
<li>Wire <b>A→A, B→B</b> to the meter port, plus its own supply.</li>
|
||||
<li>Wire <b>A→A, B→B</b> to the EMS port, plus its own supply.</li>
|
||||
<li>If fitting the e-stop, connect its USB-RS485 adapter to the <b>same pair, in parallel</b>.</li>
|
||||
<li>Power the T-CAN485 <b>last</b>.</li>
|
||||
</ol>
|
||||
|
||||
@@ -119,7 +119,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<table style="margin-bottom:2mm">
|
||||
<tr><th style="width:34mm">Tag</th><th>Platform</th><th style="width:30mm">Verdict</th></tr>
|
||||
<tr><td><code>ESU EMU ESA BPS<br>BPU EMJ IJL</code></td>
|
||||
<td><b>Platform 105</b> — ES / EM / BP, AA55-era meter bus</td>
|
||||
<td><b>Platform 105</b> — ES / EM / BP, AA55-era</td>
|
||||
<td><b>SUPPORTED</b></td></tr>
|
||||
<tr><td><code>SPB SPN</code></td><td>SBP <b>G2</b> (GW…-SBP-20) — platform 745</td><td><b>NO</b></td></tr>
|
||||
<tr><td><code>ESN ESC</code></td><td>ES <b>G2</b> (GW…-ES-20) — platform 745</td><td><b>NO</b></td></tr>
|
||||
@@ -127,7 +127,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<tr><td><code>SDT DST MSU NSU …</code></td><td>PV-only inverters — <b>no battery to control</b></td><td><b>NO</b></td></tr>
|
||||
</table>
|
||||
<p style="margin:0"><small>Reference unit: <code>95000BPS225W0290</code> → <code>BPS</code> → 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. <b>If the tag is not on the supported list, stop — do not quote the job.</b></small></p>
|
||||
</div>
|
||||
|
||||
@@ -173,13 +173,13 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<!-- ============ PAGE 3 ============ -->
|
||||
<div class="page" id="p3">
|
||||
<h2>Steps 1–3 — Hardware</h2>
|
||||
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The meter bus
|
||||
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The RS485 EMS port
|
||||
is low-voltage but sits inside an energised installation.</div>
|
||||
|
||||
<div class="step">
|
||||
<h3><span class="num">1</span> Survey and record</h3>
|
||||
<ol class="do">
|
||||
<li><b>Photograph</b> the meter-port wiring before touching it.</li>
|
||||
<li><b>Photograph</b> the EMS-port wiring before touching it.</li>
|
||||
<li>Identify the RS485 pair (A/B) running to the vendor controller.</li>
|
||||
<li>Record inverter model + serial, battery capacity (Ah) and nominal voltage.</li>
|
||||
</ol>
|
||||
@@ -191,7 +191,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<ol class="do">
|
||||
<li>Bring the system to <b>0 W</b> — battery neither charging nor discharging (check the
|
||||
inverter display).</li>
|
||||
<li>Disconnect the vendor controller from the meter port.</li>
|
||||
<li>Disconnect the vendor controller from the inverter's EMS port.</li>
|
||||
<li>Leave it physically mounted but unplugged — that keeps the install reversible and gives
|
||||
you a rollback story.</li>
|
||||
</ol>
|
||||
@@ -202,7 +202,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<div class="step">
|
||||
<h3><span class="num">3</span> Fit and power the T-CAN485</h3>
|
||||
<ol class="do">
|
||||
<li>Wire <b>A→A, B→B</b> to the meter port, plus its own supply.</li>
|
||||
<li>Wire <b>A→A, B→B</b> to the EMS port, plus its own supply.</li>
|
||||
<li>If fitting the e-stop, connect its USB-RS485 adapter to the <b>same pair, in parallel</b>.</li>
|
||||
<li>Power the T-CAN485 <b>last</b>.</li>
|
||||
</ol>
|
||||
|
||||
@@ -1,5 +1,130 @@
|
||||
# Changelog
|
||||
|
||||
## 0.18.2
|
||||
|
||||
**A patch bump: one fix to how the 15-minute demand forecast sensor is read, and no option changes.**
|
||||
No option was added, removed or renamed, and no default changed (86 options).
|
||||
**Existing installs need no option change:** options that start 0.18.1 also
|
||||
start 0.18.2. Only a forecast sensor with no power unit needs a look (below).
|
||||
The change below applies with one inverter or two.
|
||||
|
||||
**A forecast sensor with no power unit.** If your 15-minute demand forecast
|
||||
sensor has no unit, or a unit that is not power (for example kWh), the add-on
|
||||
now ignores that forecast. The log warns once, and the Home and Settings pages
|
||||
say so while it lasts. Pick a sensor that reads in W or kW.
|
||||
|
||||
Contents of this release, in merge order since 0.18.1 (`780c1f6`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `09a5485` | The 15-minute demand forecast sensor is now read in its own unit, W or kW, and the old rule that took any value below 50 as kW is gone. With no unit, or a unit that is not power, the forecast is not used, and the log and the pages say so; the settings page no longer says a kW sensor is taken as watts ([[SAFETY-118]], #198) |
|
||||
|
||||
### 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]]).
|
||||
- The price chart on the Home page shows one bar per hour, the average of four 15-minute prices, while the add-on decides on the 15-minute prices ([[UX-45]]).
|
||||
|
||||
### 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.1
|
||||
|
||||
**A patch bump: two small fixes to the settings page, and no option changes.**
|
||||
No option was added, removed or renamed, and no default changed (86 options).
|
||||
**Existing installs need no option change:** options that start 0.18.0 also
|
||||
start 0.18.1. The changes below apply with one inverter or two.
|
||||
|
||||
**Price rounding on 0.18.0.** On 0.18.0, the new settings page saved the fixed,
|
||||
day and night prices rounded to 2 decimals (0.34512 became 0.35); prices in time
|
||||
windows kept their decimals. If you typed more than 2 decimals in those
|
||||
fields on 0.18.0, enter them again.
|
||||
|
||||
Contents of this release, in merge order since 0.18.0 (`a0e091c`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `b703aaa` | The four tariff price fields on the new settings page (fixed, day, night and time windows) take 5 decimals, for example 0.34512 €/kWh, and are saved as typed. A short hint says to divide the c€/kWh on your bill by 100 ([[UX-43]], #194) |
|
||||
| `8e02a09` | The dynamic contract choice on the new settings page now says "Dynamic, every 15 minutes" ("Dynamisch, per kwartier") instead of "hourly". The choice works as before, and one line in the docs no longer calls dynamic prices hourly ([[UX-44]], #195) |
|
||||
|
||||
### 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]]).
|
||||
- If the 15-minute demand forecast sensor reads in kW, the settings page warns that its number is taken as watts and asks for a sensor in W. The add-on does turn kW into W, so a kW sensor works ([[SAFETY-118]]).
|
||||
- The price chart on the Home page shows one bar per hour, the average of four 15-minute prices, while the add-on decides on the 15-minute prices ([[UX-45]]).
|
||||
|
||||
### 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.0
|
||||
|
||||
**A minor bump: new behaviour, three new options and one new value for an
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# SAFETY-118 - the peak forecast is read in its own unit
|
||||
|
||||
**Status: for review.** Tests: `test_safety118.py`.
|
||||
|
||||
`peak_forecast_entity` was read as a bare number, and anything under 50 was
|
||||
multiplied by 1000 ("accept kW or W"). A W sensor reading 17 to 45 W, which a
|
||||
real site did 430 times in one morning, would be taken as 17 to 45 kW. The
|
||||
validator also called a kW sensor wrong, which was false for this entity.
|
||||
|
||||
Decision: the size rule is gone. The entity is read through `pv.watts()`, the
|
||||
same unit table and reader as the solar entities, so W, kW, MW and the rest
|
||||
convert by the sensor's own `unit_of_measurement`. No unit, or a unit that is
|
||||
not power (kWh), means the forecast is not used, exactly as if the option were
|
||||
empty: the loop cap and every non-forecast peak step still apply. One WARNING
|
||||
per cause, and a status row and a settings note say the same.
|
||||
|
||||
No size rule can be right in both units: 30 W and 30 kW are both ordinary.
|
||||
The read is still one `GET /api/states/<id>` per cycle, as `hass.number()` made.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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. Leave it empty and there is no peak limit. |
|
||||
| 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 | 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. |
|
||||
@@ -1697,7 +1698,7 @@ DECISIONS/MAINT-13-full-pack-is-complete.md.
|
||||
|
||||
| option | meaning |
|
||||
|---|---|
|
||||
| `peak_forecast_entity` | Quarter-hour demand forecast, for capacity-tariff markets. Empty = no cap |
|
||||
| `peak_forecast_entity` | Quarter-hour demand forecast, for capacity-tariff markets. Read in its own unit (W, kW or any power unit). No unit, or a unit that is not power, means it is not used, as if empty (SAFETY-118). Empty = no cap |
|
||||
| `peak_cap_w` | The site's capacity-tariff target |
|
||||
| `peak_margin_w` (50) | Watts the peak guard aims under `peak_cap_w` (SAFETY-79). Reports and the bill use `peak_cap_w` itself |
|
||||
| `peak_soc_floor` (20) | **The percent below which the capacity-peak guard will not discharge the battery.** ⚠️ It bounds **the peak guard only** — it is **not a pack-wide reserve**, and ordinary self-consumption discharges straight past it (SAFETY-25, see below). Not optional and not the same thing as `maintenance_soc_floor` |
|
||||
@@ -3113,7 +3114,7 @@ second set of 23- and 25-hour-day bugs.
|
||||
### Static day/night and fully-fixed tariffs (`static_profile`)
|
||||
|
||||
Most Belgian households are **not** on a dynamic contract, and an EMS that only
|
||||
works under hourly pricing is an EMS for a minority. `static_profile` is how a
|
||||
works under dynamic pricing is an EMS for a minority. `static_profile` is how a
|
||||
two-period day/night tariff or a single fixed price becomes a first-class
|
||||
configuration rather than "turn the price feature off and hope".
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -551,13 +596,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)
|
||||
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)}
|
||||
@@ -1431,3 +1478,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})
|
||||
|
||||
@@ -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:
|
||||
@@ -1202,6 +1210,9 @@ class Controller:
|
||||
# `grid` is P1, one meter, one connection, and belongs here.
|
||||
self.grid = None
|
||||
self.peak_fc = None
|
||||
# SAFETY-118: the unit fault last logged for the peak forecast (None =
|
||||
# no fault), so the warning comes once and again only if it changes.
|
||||
self._peak_fc_fault = None
|
||||
self.cheap = False
|
||||
self._price_gated = False # MAINT-13: is a price gate set up
|
||||
# PRICE-01. Which step of the price chain the prices come from (logged
|
||||
@@ -1269,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
|
||||
@@ -3358,6 +3376,22 @@ class Controller:
|
||||
self.pv_w, self.pv_why, self.pv_parts = pv_read.total(
|
||||
list(zip(roster, states)), pv_read.inverted(self.o))
|
||||
|
||||
def _peak_fc_unit_log(self, fault) -> None:
|
||||
"""SAFETY-118. One WARNING when the peak forecast's unit cannot be read
|
||||
in watts, and again only if the unit changes; the unit is on DEBUG."""
|
||||
if fault == self._peak_fc_fault:
|
||||
return
|
||||
self._peak_fc_fault = fault
|
||||
if fault is None:
|
||||
_LOG.info("The 15-minute demand forecast is used again.")
|
||||
elif fault == "":
|
||||
_LOG.warning("The 15-minute demand forecast has no unit, so it "
|
||||
"is not used.")
|
||||
else:
|
||||
_LOG.warning("The 15-minute demand forecast is not in a power "
|
||||
"unit, so it is not used.")
|
||||
_LOG.debug("The 15-minute demand forecast reads in %r.", fault)
|
||||
|
||||
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."""
|
||||
@@ -3448,11 +3482,17 @@ class Controller:
|
||||
_reads.append(self._read_forecast())
|
||||
await asyncio.gather(*_reads)
|
||||
if o.get("peak_forecast_entity"):
|
||||
raw = await self.hass.number(o["peak_forecast_entity"])
|
||||
# Accept kW or W - nobody's quarter-hour forecast is 50 W.
|
||||
self.peak_fc = None if raw is None else (raw * 1000 if abs(raw) < 50 else raw)
|
||||
# SAFETY-118: read in its own unit, like the PV path. One state
|
||||
# read, as `hass.number()` made. No unit or a unit that is not
|
||||
# power: not used, as if the option were empty.
|
||||
st = await self.hass.state(o["peak_forecast_entity"])
|
||||
self.peak_fc, _why = pv_read.watts(st)
|
||||
# A missing reading leaves the unit-fault state as it was.
|
||||
if pv_read.has_reading(st):
|
||||
self._peak_fc_unit_log(pv_read.unit_fault(st))
|
||||
else:
|
||||
self.peak_fc = None
|
||||
self._peak_fc_fault = None
|
||||
|
||||
now_e, avg_e = o.get("price_now_entity"), o.get("price_avg_entity")
|
||||
if now_e and avg_e:
|
||||
@@ -5788,6 +5828,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]
|
||||
@@ -5813,7 +5854,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
|
||||
@@ -5822,6 +5865,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.
|
||||
|
||||
@@ -7240,6 +7318,10 @@ class Controller:
|
||||
# is both is a check nobody reads.
|
||||
out.append({"ok": False, "warn": True, "id": "peak_forecast",
|
||||
"text": messages.row_no_peak_forecast()})
|
||||
elif getattr(self, "_peak_fc_fault", None) is not None:
|
||||
out.append({"ok": False, "warn": True, "id": "peak_forecast",
|
||||
"text": messages.row_peak_forecast_unit(
|
||||
self._peak_fc_fault)})
|
||||
# OPT-17: a forecast summed from several entities names, by position,
|
||||
# each member with no usable reading. A caution: control goes on.
|
||||
gaps = getattr(self, "solar_fc_gaps", ())
|
||||
|
||||
@@ -788,6 +788,25 @@ def row_no_peak_forecast() -> str:
|
||||
"the peak limit")
|
||||
|
||||
|
||||
def peak_forecast_unit(unit, lang="en") -> str:
|
||||
"""SAFETY-118. The 15-minute demand forecast has a number but no power
|
||||
unit, so it is not used. `unit` is "" for none. One sentence, for the log
|
||||
line, the status row and the settings page. Log lines stay English."""
|
||||
if lang == "nl":
|
||||
return ("De kwartierprognose heeft geen eenheid, dus ze wordt niet "
|
||||
"gebruikt." if not unit else
|
||||
"De kwartierprognose heeft geen vermogenseenheid, dus ze "
|
||||
"wordt niet gebruikt.")
|
||||
return ("The 15-minute demand forecast has no unit, so it is not used."
|
||||
if not unit else
|
||||
"The 15-minute demand forecast is not in a power unit, so it is "
|
||||
"not used.")
|
||||
|
||||
|
||||
def row_peak_forecast_unit(unit) -> str:
|
||||
return peak_forecast_unit(unit)
|
||||
|
||||
|
||||
def row_peak_guard_waiting() -> str:
|
||||
return "Peak guard: no decision yet, control has not run"
|
||||
|
||||
@@ -1229,6 +1248,8 @@ def status_samples() -> list:
|
||||
("row_no_estop", AMBER, row_no_estop()),
|
||||
("row_estop_fitted", GREEN, row_estop_fitted()),
|
||||
("row_no_peak_forecast", AMBER, row_no_peak_forecast()),
|
||||
("row_peak_forecast_unit", AMBER, row_peak_forecast_unit("")),
|
||||
("row_peak_forecast_unit", AMBER, row_peak_forecast_unit("kWh")),
|
||||
("row_peak_guard_waiting", AMBER, row_peak_guard_waiting()),
|
||||
("row_pv", GREEN, row_pv(3200, 2)),
|
||||
("row_pv", GREEN, row_pv(1800, 1, True)),
|
||||
|
||||
@@ -29,7 +29,7 @@ _LOG = logging.getLogger("goodwe.mqtt")
|
||||
DEVICE = {
|
||||
"identifiers": ["goodwe_rs485_controller"],
|
||||
"name": "GoodWe",
|
||||
"manufacturer": "GoodWe (via RS485 meter emulation)",
|
||||
"manufacturer": "GoodWe (via RS485 EMS port)",
|
||||
"model": "ES/BP series",
|
||||
}
|
||||
|
||||
|
||||
@@ -93,12 +93,12 @@ def watts(state: dict | None) -> tuple:
|
||||
unknown" are different claims and the page has to be able to make the first.
|
||||
|
||||
⚠️ THE UNIT IS READ OFF THE ENTITY AND NOT ASSUMED, AND THERE IS NO
|
||||
MAGNITUDE HEURISTIC. `read_inputs()` guesses kW-or-W for
|
||||
`peak_forecast_entity` with `abs(raw) < 50`, which is safe there because
|
||||
nobody's quarter-hour demand forecast is 50 W. It is NOT safe here: 30 W of
|
||||
real production at dusk is an ordinary reading, and that heuristic would
|
||||
publish it as 30 kW - a fabricated number, in the channel whose entire
|
||||
purpose is to be believed a week later. A sensor whose unit this cannot read
|
||||
MAGNITUDE HEURISTIC. 30 W of real production at dusk is an ordinary reading,
|
||||
and a "small number means kW" rule would publish it as 30 kW - a fabricated
|
||||
number, in the channel whose entire purpose is to be believed a week later.
|
||||
SAFETY-118 removed the last such rule, on `peak_forecast_entity`, for the
|
||||
same reason (DECISIONS/SAFETY-118-peak-forecast-unit.md): the peak forecast
|
||||
now reads through this function too. A sensor whose unit this cannot read
|
||||
is REFUSED rather than guessed at.
|
||||
"""
|
||||
if state is None:
|
||||
@@ -126,6 +126,31 @@ def watts(state: dict | None) -> tuple:
|
||||
return value * WATTS_PER[unit], unit
|
||||
|
||||
|
||||
def has_reading(state: dict | None) -> bool:
|
||||
"""SAFETY-118. True when `state` holds a number, so its unit can be judged.
|
||||
A missing, unavailable, unknown or non-numeric state is a missing reading."""
|
||||
if state is None:
|
||||
return False
|
||||
raw = str(state.get("state", "")).strip()
|
||||
if raw.lower() in BAD_STATES:
|
||||
return False
|
||||
try:
|
||||
float(raw)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def unit_fault(state: dict | None):
|
||||
"""SAFETY-118. `None` unless `state` holds a number whose unit cannot be
|
||||
read in watts, then that unit (`""` for none). `None` also means a missing
|
||||
reading: use `has_reading()` to tell it from a good unit."""
|
||||
if not has_reading(state):
|
||||
return None
|
||||
unit = str((state.get("attributes") or {}).get(UNIT_KEY) or "").strip()
|
||||
return None if unit in WATTS_PER else unit
|
||||
|
||||
|
||||
# --- TEL-12: the ROSTER, and the SITE figure over it --------------------------
|
||||
|
||||
def entities(opts: dict) -> tuple:
|
||||
|
||||
@@ -78,6 +78,8 @@ h2{font-size:15px;margin:0 0 10px;font-weight:650}
|
||||
.bars i.cheap{background:var(--cheap)}
|
||||
/* not by colour alone: expensive hours are striped, estimates are faded */
|
||||
.bars i.dear{background:repeating-linear-gradient(135deg,var(--dear) 0 4px,color-mix(in srgb,var(--dear) 60%,var(--card)) 4px 7px)}
|
||||
.bars.fine{gap:1px}
|
||||
.bars.fine i{border-radius:1px 1px 0 0}
|
||||
.bars i.est{opacity:.55}
|
||||
.bars i.none{background:none;border-bottom:2px dotted var(--faint);height:2px!important}
|
||||
.lane{position:relative;height:18px;margin-top:4px;background:var(--bg);border-radius:4px}
|
||||
|
||||
@@ -177,11 +177,12 @@
|
||||
const known = p.prices.filter((b) => b.eur_kwh != null);
|
||||
const mx = Math.max(0.01, ...known.map((b) => Math.abs(b.eur_kwh)));
|
||||
bars.setAttribute("aria-label", t("plan.name"));
|
||||
bars.classList.toggle("fine", p.prices.length > 48);
|
||||
bars.replaceChildren(...p.prices.map((b) => {
|
||||
const title = b.label + " " + t("plan.time_to") + " " + b.end_label;
|
||||
if (b.eur_kwh == null) return chartItem("i", "none", "", title, [t("plan.none_tip") + "."]);
|
||||
const kind = b.kind === "cheap" ? ", " + t("plan.cheap_tip") : b.kind === "dear" ? ", " + t("plan.dear_tip") : "";
|
||||
const est = (b.points > 1 ? ", " + t("plan.average_tip") : "") + (b.estimate ? ", " + t("plan.estimate_tip") : "");
|
||||
const est = (b.estimate ? ", " + t("plan.estimate_tip") : "");
|
||||
return chartItem("i", (b.kind || "mid") + (b.estimate ? " est" : ""),
|
||||
"height:" + Math.max(4, Math.round(Math.abs(b.eur_kwh) / mx * 100)) + "%",
|
||||
title, [eur(b.eur_kwh) + " " + t("plan.per_kwh") + kind + est + "."]);
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"periods": {"name": "Time windows and prices", "description": "One price per kWh for each time window, all in, as on your bill: divide c€/kWh by 100, so 34.512 c€/kWh is 0.34512."}
|
||||
},
|
||||
"choices": {
|
||||
"contract": {"none": "No prices", "fixed": "Fixed price", "daynight": "Day and night", "dynamic": "Dynamic, hourly", "custom": "Other time windows"},
|
||||
"contract": {"none": "No prices", "fixed": "Fixed price", "daynight": "Day and night", "dynamic": "Dynamic, every 15 minutes", "custom": "Other time windows"},
|
||||
"fallback": {"none": "None", "fixed": "One price", "daynight": "Day and night", "custom": "Other time windows"},
|
||||
"region": {"flanders": "Flanders, Fluvius Antwerpen", "own": "I enter my own figure"},
|
||||
"meter_source": {"off": "One grid power entity", "ha_dsmr": "Import and export entities", "mqtt_p1": "Meter over MQTT", "ha_signed": "One net power entity", "homewizard_local": "HomeWizard meter on the network"},
|
||||
@@ -66,8 +66,7 @@
|
||||
"estimate_tip": "estimate from yesterday",
|
||||
"none_tip": "No price known",
|
||||
"no_prices": {"description": "No prices for today, so the chart shows only the plan."},
|
||||
"list": "The plan in words",
|
||||
"average_tip": "hour average"
|
||||
"list": "The plan in words"
|
||||
},
|
||||
"peak": {"name": "Peak limit", "this_month": "This month's peak", "limit": "Limit", "none": "None recorded yet", "unreadable": "Cannot be read now"},
|
||||
"attention": {"name": "Needs attention", "nothing": "Nothing to look at", "to_look": "to look at", "details": "Details", "alarm": "Alarm", "warning": "Warning", "info": "Info"},
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"periods": {"name": "Tijdvakken en prijzen", "description": "Eén prijs per kWh voor elk tijdvak, alles inbegrepen, zoals op uw factuur: deel c€/kWh door 100, dus 34,512 c€/kWh is 0,34512."}
|
||||
},
|
||||
"choices": {
|
||||
"contract": {"none": "Geen prijzen", "fixed": "Vaste prijs", "daynight": "Dag en nacht", "dynamic": "Dynamisch, per uur", "custom": "Andere tijdvakken"},
|
||||
"contract": {"none": "Geen prijzen", "fixed": "Vaste prijs", "daynight": "Dag en nacht", "dynamic": "Dynamisch, per kwartier", "custom": "Andere tijdvakken"},
|
||||
"fallback": {"none": "Geen", "fixed": "Eén prijs", "daynight": "Dag en nacht", "custom": "Andere tijdvakken"},
|
||||
"region": {"flanders": "Vlaanderen, Fluvius Antwerpen", "own": "Ik vul zelf een bedrag in"},
|
||||
"meter_source": {"off": "Eén entiteit voor netvermogen", "ha_dsmr": "Entiteiten voor afname en injectie", "mqtt_p1": "Meter via MQTT", "ha_signed": "Eén entiteit voor nettovermogen", "homewizard_local": "HomeWizard-meter op het netwerk"},
|
||||
@@ -63,8 +63,7 @@
|
||||
"estimate_tip": "schatting op basis van gisteren",
|
||||
"none_tip": "Geen prijs bekend",
|
||||
"no_prices": {"description": "Geen prijzen voor vandaag, dus de grafiek toont alleen het plan."},
|
||||
"list": "Het plan in woorden",
|
||||
"average_tip": "gemiddelde van het uur"
|
||||
"list": "Het plan in woorden"
|
||||
},
|
||||
"peak": {"name": "Piekgrens", "this_month": "Piek van deze maand", "limit": "Grens", "none": "Nog niets gemeten", "unreadable": "Nu niet leesbaar"},
|
||||
"attention": {"name": "Vraagt aandacht", "nothing": "Niets om na te kijken", "to_look": "na te kijken", "details": "Details", "alarm": "Alarm", "warning": "Waarschuwing", "info": "Info"},
|
||||
|
||||
@@ -194,7 +194,7 @@ GROUPS = (
|
||||
Field("peak_cap_w", SLIDER, "kW", scale=1000, step=100,
|
||||
simple=True, wizard=5, note="month_peak"),
|
||||
Field("peak_margin_w", SLIDER, "W", step=10), # SAFETY-79
|
||||
Field("peak_forecast_entity", ENTITY, kind="power_w", wizard=5),
|
||||
Field("peak_forecast_entity", ENTITY, kind="power", wizard=5),
|
||||
Field("peak_soc_floor", LOCKED, "%"),
|
||||
)),
|
||||
Part("start", (
|
||||
|
||||
@@ -1741,11 +1741,12 @@ def _card_note(option: str, spec) -> str:
|
||||
|
||||
|
||||
# UX-13: entity options whose number the add-on takes as watts, with no unit
|
||||
# conversion. pv.py converts each solar entity's own unit, so those are not here.
|
||||
# conversion. pv.py converts each solar entity's own unit, so those are not here,
|
||||
# and SAFETY-118 moved the peak forecast there too.
|
||||
WATT_ENTITY_KEYS = (
|
||||
"meter_entity", "p1_net_entity", "p1_import_entity", "p1_export_entity",
|
||||
"p1_phase_net_entities", "p1_phase_import_entities",
|
||||
"p1_phase_export_entities", "batt_entity", "peak_forecast_entity")
|
||||
"p1_phase_export_entities", "batt_entity")
|
||||
|
||||
|
||||
# The meter entities each `meter_source` reads through Home Assistant.
|
||||
@@ -1829,6 +1830,10 @@ def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
|
||||
_advise(notes, "warn", (key,),
|
||||
f"{entity} reads in {unit}, but {S(key, where)} is "
|
||||
"taken as watts, so pick a sensor in W.")
|
||||
if key == "peak_forecast_entity" and unit not in pv.WATTS_PER:
|
||||
_advise(notes, "warn", (key,),
|
||||
messages.peak_forecast_unit(unit) + " Pick a sensor in "
|
||||
"W or kW.")
|
||||
if key in FORECAST_KEYS and unit not in solarfc.KWH_PER:
|
||||
_advise(notes, "warn", (key,),
|
||||
f"{entity} does not read in kWh, so pick a solar "
|
||||
|
||||
@@ -50,7 +50,7 @@ from aiohttp import web
|
||||
|
||||
from . import messages, price, selfconfig, uipage, uitext
|
||||
from .haentities import EntityCache
|
||||
from .homeview import region_of
|
||||
from .homeview import priced_hours, region_of
|
||||
from .validate import config_notes, setup_checks
|
||||
from .config_page import CONFIG_PAGE
|
||||
from .configui import (changed_keys, field_model, hide_secrets, keep_secrets,
|
||||
@@ -328,7 +328,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
try:
|
||||
prices = (controller.status_payload().get("plan") or {}).get(
|
||||
"prices") or []
|
||||
known = sum(1 for p in prices if p.get("eur_kwh") is not None)
|
||||
known = priced_hours(prices)
|
||||
except Exception: # noqa: BLE001 - a display figure, never a failure
|
||||
known = None
|
||||
return setup_checks(options, entities=rows, running=controller.o,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.18.0"
|
||||
version: "0.18.2"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||
meter, holding net grid exchange at zero and running a monthly battery
|
||||
Drives a GoodWe ES/BP battery inverter over its RS485 EMS port by sending it a
|
||||
grid reading, holding net grid exchange at zero and running a monthly battery
|
||||
maintenance cycle.
|
||||
url: https://gittea.kammenstraatha.duckdns.org/admin/goodwe-addon
|
||||
arch:
|
||||
|
||||
@@ -33,7 +33,9 @@ from app import homeview, web
|
||||
from app.main import Controller
|
||||
from app.peakguard import PeakDecision
|
||||
from app.price import PricePoint, PriceProduct, PriceRead
|
||||
from app.static_tariff import TariffPlan
|
||||
from app.static_tariff import TariffPlan, static_plan
|
||||
from app import packmode
|
||||
from app.store import unit_key
|
||||
from app.uimodel import REGION_PRESETS
|
||||
from named_absence import field, nth, present
|
||||
|
||||
@@ -152,6 +154,14 @@ def quiet_guard(projected_w=1240.0):
|
||||
ceiling_from="cap", projected_w=projected_w)
|
||||
|
||||
|
||||
def set_modes(c, *modes):
|
||||
"""File each battery's mode in the store (None leaves the default, which
|
||||
follows the house)."""
|
||||
for inv, m in zip(c.inverters, modes):
|
||||
if m is not None:
|
||||
c.store.data[unit_key(inv.name, packmode.MODE_KEY)] = m
|
||||
|
||||
|
||||
def status(c, now=NOW):
|
||||
s = c.status_payload()
|
||||
# The status route builds at wall-clock time; rebuild the structured half
|
||||
@@ -317,7 +327,7 @@ check("a plan with no published prices yet says so, not 'the price gap is "
|
||||
_t == [] and homeview.line_no_trade("not_firm") in _l
|
||||
and homeview.line_no_trade("not_worth") not in _l)
|
||||
check("the price bars are today's 24 hours, from the real series, with the "
|
||||
"cheap and expensive thirds marked",
|
||||
"cheap and dear hour marked",
|
||||
[b["eur_kwh"] for b in s_arb["plan"]["prices"]] == MOCK
|
||||
and s_arb["plan"]["prices"][13]["kind"] == "cheap"
|
||||
and s_arb["plan"]["prices"][19]["kind"] == "dear"
|
||||
@@ -518,10 +528,269 @@ check("quarter-hour prices: the charge block, its price and the price line's "
|
||||
and f"Today: lowest €{_lo.eur_per_kwh:.2f} at "
|
||||
f"{homeview.hhmm(_lo.start)}," in s_q["plan"]["price_line"]
|
||||
and "the list" not in json.dumps(s_q["plan"]))
|
||||
check("...and the hour bar says it is an hour average",
|
||||
s_q["plan"]["prices"][13]["points"] == 4
|
||||
and '"plan.average_tip"' in io.open(os.path.join(
|
||||
HERE, "app", "static", "app.js"), encoding="utf-8").read())
|
||||
|
||||
# UX-45: the chart draws the steps the add-on decides on.
|
||||
_pq = s_q["plan"]["prices"]
|
||||
check("UX-45: 96 quarter prices -> 96 bars, each one quarter, labelled "
|
||||
"from its own start, with its own price and no average",
|
||||
len(_pq) == 96 and _pq[0]["label"] == "00:00"
|
||||
and _pq[0]["end_label"] == "00:15" and _pq[57]["label"] == "14:15"
|
||||
and _pq[57]["end_label"] == "14:30"
|
||||
and [b["eur_kwh"] for b in _pq] == [round(p.eur_per_kwh, 4)
|
||||
for p in _q[:96]]
|
||||
and all("points" not in b for b in _pq)
|
||||
and s_q["plan"]["hours"] == 24)
|
||||
check("...the quarters of one hour differ (the chart shows the steps)",
|
||||
len({b["eur_kwh"] for b in _pq[52:56]}) > 1)
|
||||
check("...and the page has no 'hour average' tip any more",
|
||||
"average_tip" not in io.open(os.path.join(
|
||||
HERE, "app", "static", "app.js"), encoding="utf-8").read()
|
||||
and "average_tip" not in io.open(os.path.join(
|
||||
HERE, "app", "ui_text", "en.json"), encoding="utf-8").read()
|
||||
and "average_tip" not in io.open(os.path.join(
|
||||
HERE, "app", "ui_text", "nl.json"), encoding="utf-8").read())
|
||||
_cheap_q = [b["start"] for b in _pq if b["kind"] == "cheap"]
|
||||
_dear_q = [b["start"] for b in _pq if b["kind"] == "dear"]
|
||||
_hi = max(_q[:96], key=lambda p: p.eur_per_kwh)
|
||||
check("...without battery modes the marks are the two quarters the "
|
||||
"arbitrage verdict trades in, and no others",
|
||||
_cheap_q == [_lo.start.isoformat()] and _dear_q == [_hi.start.isoformat()]
|
||||
and sum(1 for b in _pq if b["kind"] == "mid") == 94)
|
||||
|
||||
# Battery modes on: the marks ARE the kept priceruns result.
|
||||
from app import priceruns
|
||||
_rp = priceruns.plan(tuple(_q), NOW, required_eur_kwh=0.05, charge_kwh=2.0,
|
||||
charge_kw=4.0, sell_kwh=0.0, sell_kw=0.0)
|
||||
c_m = make(ARB)
|
||||
c_m.tariff_plan, c_m.tariff_at = c_q.tariff_plan, NOW
|
||||
c_m.peak_guard = quiet_guard()
|
||||
c_m.modes_on, c_m.mode_runs = True, _rp
|
||||
set_modes(c_m, packmode.SOLAR_FIRST, packmode.CHEAP_HOURS)
|
||||
_pm = status(c_m)["plan"]["prices"]
|
||||
_today = [(a, z) for a, z, _p in priceruns.firm_slots(_q, NOW)
|
||||
if a < DAY0 + timedelta(hours=24)]
|
||||
_want_c = {a.isoformat() for a, z in _today
|
||||
if _rp.cheap.start <= a < _rp.cheap.end}
|
||||
_want_d = {a.isoformat() for g in _rp.give for a, z in _today
|
||||
if g.start <= a < g.end}
|
||||
check("UX-45: with battery modes on, the cheap and dear quarters are the "
|
||||
"priceruns cheap run and give runs for the same input",
|
||||
_rp.on and _rp.charge_useful() and _want_c and _want_d
|
||||
and {b["start"] for b in _pm if b["kind"] == "cheap"} == _want_c
|
||||
and {b["start"] for b in _pm if b["kind"] == "dear"} == _want_d
|
||||
and len(_pm) == 96)
|
||||
c_m2 = make(ARB)
|
||||
c_m2.tariff_plan, c_m2.tariff_at = c_q.tariff_plan, NOW
|
||||
c_m2.peak_guard = quiet_guard()
|
||||
c_m2.modes_on = True
|
||||
set_modes(c_m2, packmode.CHEAP_HOURS, None)
|
||||
c_m2.mode_runs = priceruns.plan(tuple(_q), NOW, required_eur_kwh=5.0,
|
||||
charge_kwh=2.0, charge_kw=4.0, sell_kwh=0.0,
|
||||
sell_kw=0.0)
|
||||
check("...a run plan that says no trade marks nothing",
|
||||
not c_m2.mode_runs.on
|
||||
and {b["kind"] for b in status(c_m2)["plan"]["prices"]} == {"mid"})
|
||||
|
||||
# A quarter that is over keeps the mark it had while it was ahead.
|
||||
# (`status()` also builds at wall-clock time, which starts the day's memory
|
||||
# over, so the two builds below go straight to `homeview.build`.)
|
||||
_later = NOW + timedelta(hours=4)
|
||||
_s0 = c_q.status_payload()
|
||||
homeview.build(c_q, level=_s0["level"], checks=_s0["checks"], now=NOW)
|
||||
c_q.tariff_at = _later
|
||||
_pl = homeview.build(c_q, level=_s0["level"], checks=_s0["checks"],
|
||||
now=_later)["plan"]["prices"]
|
||||
check("...and a quarter already over keeps the mark it had while ahead",
|
||||
[b["kind"] for b in _pl if b["start"] == _lo.start.isoformat()]
|
||||
== ["cheap"]
|
||||
and [b["kind"] for b in _pl if b["label"] == "19:00"] == ["dear"])
|
||||
c_q.tariff_at = NOW
|
||||
|
||||
# UX-45 round 2 (F2): a hole is covered by the add-on's own rule,
|
||||
# `price._cover_table()`: the point before it runs on, never over an hour.
|
||||
def _hole_bars(missing):
|
||||
pts = [PricePoint(DAY0 + timedelta(minutes=15 * i), .10 + .001 * i, False)
|
||||
for i in range(96)]
|
||||
gone = {DAY0 + timedelta(hours=10, minutes=15 * (1 + j))
|
||||
for j in range(missing)}
|
||||
pts = [p for p in pts if p.start not in gone]
|
||||
return homeview.price_bars(pts, DAY0, DAY0 + timedelta(hours=24))
|
||||
|
||||
|
||||
_gap = {}
|
||||
for _n in (1, 2, 3, 4, 5):
|
||||
_bb = _hole_bars(_n)
|
||||
_gap[_n] = ([b["eur_kwh"] is None for b in _bb[41:41 + _n]],
|
||||
len(_bb))
|
||||
_b1 = _hole_bars(1)
|
||||
check("UX-45: 1, 2 or 3 missing quarters show the price before them (the "
|
||||
"add-on's own cover rule), and nothing shifts",
|
||||
all(len(_hole_bars(n)) == 96 for n in (1, 2, 3))
|
||||
and _gap[1][0] == [False] and _gap[2][0] == [False, False]
|
||||
and _gap[3][0] == [False, False, False]
|
||||
and [b["eur_kwh"] for b in _hole_bars(3)[40:44]]
|
||||
== [_hole_bars(3)[40]["eur_kwh"]] * 4
|
||||
and _b1[42]["label"] == "10:30" and _b1[43]["label"] == "10:45"
|
||||
and _b1[43]["eur_kwh"] == round(.10 + .001 * 43, 4))
|
||||
check("...4 missing quarters leave 1 bar empty, 5 leave 2: a hole over an "
|
||||
"hour is not filled past the hour",
|
||||
_gap[4][0] == [False, False, False, True]
|
||||
and _gap[5][0] == [False, False, False, True, True]
|
||||
and sum(b["eur_kwh"] is None for b in _hole_bars(4)) == 1
|
||||
and sum(b["eur_kwh"] is None for b in _hole_bars(5)) == 2)
|
||||
|
||||
# A cheap run that is not worth charging in is not marked; an estimate is.
|
||||
import types
|
||||
_pts = [PricePoint(DAY0 + timedelta(minutes=15 * i),
|
||||
.30 if i < 4 else .10, False) for i in range(8)]
|
||||
_rp2 = priceruns.plan(tuple(_pts), DAY0, required_eur_kwh=0.05,
|
||||
charge_kwh=0.5, charge_kw=4.0, sell_kwh=0.0, sell_kw=0.0)
|
||||
c_w2 = make(ARB)
|
||||
c_w2.modes_on, c_w2.mode_runs = True, _rp2
|
||||
set_modes(c_w2, packmode.LEFTOVER_SOLAR, None)
|
||||
_w2 = homeview.price_windows(c_w2, None, None)
|
||||
check("UX-45: a cheap run that comes after every dear slot is not worth a "
|
||||
"charge, so it is not marked (the dear run is)",
|
||||
_rp2.on and _rp2.cheap is not None and not _rp2.charge_useful()
|
||||
and [w[0] for w in _w2] == ["dear"])
|
||||
_est = [PricePoint(DAY0 + timedelta(minutes=15 * i), .2, False)
|
||||
for i in range(80)] + [PricePoint(DAY0 + timedelta(hours=20), .25,
|
||||
True)]
|
||||
_be = homeview.price_bars(_est, DAY0, DAY0 + timedelta(hours=24))
|
||||
check("UX-45: a carried (estimate) price after quarter prices is the last "
|
||||
"point, so it covers one quarter (the add-on's tail rule), as an "
|
||||
"estimate bar; the rest stays empty",
|
||||
[b["estimate"] for b in _be[78:83]] == [False, False, True, False, False]
|
||||
and _be[80]["eur_kwh"] == .25 and _be[81]["eur_kwh"] is None)
|
||||
_est2 = _est[:80] + [PricePoint(DAY0 + timedelta(hours=20 + k), .25, True)
|
||||
for k in range(3)]
|
||||
_be2 = homeview.price_bars(_est2, DAY0, DAY0 + timedelta(hours=24))
|
||||
check("...carried hourly points one after another cover an hour each",
|
||||
[b["eur_kwh"] for b in _be2[80:92]] == [.25] * 12
|
||||
and _be2[92]["eur_kwh"] is None)
|
||||
|
||||
# Hourly prices -> 24 bars (above); a day/night tariff -> 24 bars.
|
||||
_ph1 = s_arb["plan"]["prices"]
|
||||
check("UX-45: hourly prices -> 24 hour bars with the hour's own price",
|
||||
len(_ph1) == 24 and _ph1[13]["end_label"] == "14:00"
|
||||
and "points" not in _ph1[13])
|
||||
c_dn = make(dict(ARB, static_profile=(
|
||||
'{"periods": [{"name":"day","from":"07:00","to":"22:00","days":"all",'
|
||||
'"eur_per_kwh":0.34},{"name":"night","from":"22:00","to":"07:00",'
|
||||
'"days":"all","eur_per_kwh":0.26}]}')))
|
||||
c_dn.peak_guard = quiet_guard()
|
||||
_pd = status(c_dn)["plan"]["prices"]
|
||||
check("UX-45: a day/night tariff -> 24 bars, 0.26 at night and 0.34 by day",
|
||||
len(_pd) == 24
|
||||
and [b["eur_kwh"] for b in _pd]
|
||||
== [.26] * 7 + [.34] * 15 + [.26] * 2
|
||||
and all(b["kind"] == "mid" for b in _pd))
|
||||
|
||||
# Clock-change days: 92 and 100 quarters.
|
||||
_d0 = datetime(2026, 3, 28, 23, 0, tzinfo=timezone.utc)
|
||||
_d1 = datetime(2026, 3, 29, 22, 0, tzinfo=timezone.utc)
|
||||
_sp = [PricePoint(_d0 + timedelta(minutes=15 * i), .1, False)
|
||||
for i in range(92)]
|
||||
check("UX-45: a 23-hour day has 92 quarter bars, a 25-hour day 100",
|
||||
len(homeview.price_bars(_sp, _d0, _d1)) == 92
|
||||
and len(homeview.price_bars(
|
||||
_sp, _d0, _d0 + timedelta(hours=25))) == 100)
|
||||
|
||||
_f0 = datetime(2026, 10, 24, 22, 0, tzinfo=timezone.utc) # 25-hour day
|
||||
_fb = [PricePoint(_f0 + timedelta(minutes=15 * i), .1, False)
|
||||
for i in range(100)]
|
||||
|
||||
# F5: the day/night chart with the stored static plan, as in production
|
||||
# (the cycle stores `static_plan()` when the provider is off).
|
||||
_PROF = ('{"periods": [{"name":"day","from":"07:00","to":"22:00",'
|
||||
'"days":"all","eur_per_kwh":0.34},{"name":"night","from":"22:00",'
|
||||
'"to":"07:00","days":"all","eur_per_kwh":0.26}]}')
|
||||
|
||||
|
||||
def _dn(arb):
|
||||
c = make(dict(ARB if arb else TWO, static_profile=_PROF))
|
||||
c.peak_guard = quiet_guard()
|
||||
c.tariff_plan = static_plan(c.o, NOW, NOW + timedelta(hours=36), now=NOW)
|
||||
c.tariff_at = NOW
|
||||
return c, status(c)["plan"]["prices"]
|
||||
|
||||
|
||||
_c_off, _p_off = _dn(False)
|
||||
_c_on, _p_on = _dn(True)
|
||||
_v_on, _why_on = homeview.trade_verdict(_c_on, _c_on.fresh_tariff_plan(NOW))
|
||||
check("UX-45: day/night tariff with the stored plan and price trading off: "
|
||||
"24 bars, no cheap or dear mark",
|
||||
len(_p_off) == 24 and {b["kind"] for b in _p_off} == {"mid"})
|
||||
check("...and with price trading on, the marks are the verdict's cheapest "
|
||||
"and dearest hour (one each)",
|
||||
_v_on is not None and len(_p_on) == 24
|
||||
and [datetime.fromisoformat(b["start"]) for b in _p_on
|
||||
if b["kind"] == "cheap"] == [_v_on.cheapest.start]
|
||||
and [datetime.fromisoformat(b["start"]) for b in _p_on
|
||||
if b["kind"] == "dear"] == [_v_on.dearest.start]
|
||||
and sum(b["kind"] == "mid" for b in _p_on) == 22)
|
||||
|
||||
# F3 / owner decision 2a: with battery modes on, marks only when a battery
|
||||
# uses a price mode.
|
||||
def _modes(*modes, use=(True, True)):
|
||||
c = make(ARB)
|
||||
c.tariff_plan, c.tariff_at = c_q.tariff_plan, NOW
|
||||
c.peak_guard = quiet_guard()
|
||||
c.modes_on, c.mode_runs = True, _rp
|
||||
set_modes(c, *modes)
|
||||
for inv, u in zip(c.inverters, use):
|
||||
c.store.data[unit_key(inv.name, packmode.USE_KEY)] = u
|
||||
return {b["kind"] for b in status(c)["plan"]["prices"]}
|
||||
|
||||
|
||||
_F, _L, _C = (packmode.SOLAR_FIRST, packmode.LEFTOVER_SOLAR,
|
||||
packmode.CHEAP_HOURS)
|
||||
check("UX-45: battery modes on, every battery follows the house: no marks",
|
||||
_modes(_F, _F) == {"mid"} and _modes(None, None) == {"mid"})
|
||||
check("...one battery on Cheap hours or Leftover solar: the marks show",
|
||||
{"cheap", "dear"} <= _modes(_F, _C) and {"cheap", "dear"} <= _modes(_L, _F))
|
||||
check("...a price mode on a battery that is switched off does not count",
|
||||
_modes(_F, _C, use=(True, False)) == {"mid"}
|
||||
and {"cheap", "dear"} <= _modes(_F, _C, use=(False, True)))
|
||||
|
||||
# F4: the day's memory of past marks starts over on a new day.
|
||||
_s4 = c_q.status_payload()
|
||||
homeview.build(c_q, level=_s4["level"], checks=_s4["checks"], now=NOW)
|
||||
_k0 = dict(c_q.home_prices.kinds)
|
||||
_tom = NOW + timedelta(days=1)
|
||||
c_q.tariff_at = _tom
|
||||
homeview.build(c_q, level=_s4["level"], checks=_s4["checks"], now=_tom)
|
||||
c_q.tariff_at = NOW
|
||||
check("UX-45: past marks start over on a new day (nothing of yesterday is "
|
||||
"kept)",
|
||||
len(_k0) > 0 and c_q.home_prices.day != DAY0
|
||||
and c_q.home_prices.kinds
|
||||
and all(k >= DAY0 + timedelta(days=1) for k in c_q.home_prices.kinds)
|
||||
and len(c_q.home_prices.kinds) <= 96)
|
||||
|
||||
# F1: the setup wizard counts hours, not bars.
|
||||
from app.validate import setup_checks
|
||||
_o = dict(ARB, price_provider="entsoe_be")
|
||||
_say = lambda n: [x for x in map(repr, setup_checks(
|
||||
_o, entities={}, running=_o, prices_known=n)) if "Prices for today" in x]
|
||||
check("UX-45: 96 quarter prices count as 24 hours in the setup wizard, "
|
||||
"not 96",
|
||||
homeview.priced_hours(_pq) == 24
|
||||
and homeview.priced_hours(s_arb["plan"]["prices"]) == 24
|
||||
and "for 24 of 24 hours" in "".join(_say(homeview.priced_hours(_pq)))
|
||||
and "96 of 24" not in "".join(_say(homeview.priced_hours(_pq))))
|
||||
check("...a half-priced day counts its hours; a hole of one quarter loses "
|
||||
"no hour",
|
||||
homeview.priced_hours(_pq[:48]) == 12
|
||||
and homeview.priced_hours(_hole_bars(5)) == 24
|
||||
and homeview.priced_hours([dict(b, eur_kwh=None) for b in _pq]) == 0)
|
||||
check("...a 23-hour day gives 23 and a 25-hour day 24 (clock hours)",
|
||||
homeview.priced_hours(homeview.price_bars(_sp, _d0, _d1)) == 23
|
||||
and homeview.priced_hours(homeview.price_bars(
|
||||
_fb, _f0, _f0 + timedelta(hours=25))) == 24)
|
||||
check("...and the wizard takes that count",
|
||||
"known = priced_hours(prices)" in io.open(os.path.join(
|
||||
HERE, "app", "web.py"), encoding="utf-8").read())
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. a fault in the Home view never costs the page its rows")
|
||||
|
||||
@@ -104,7 +104,7 @@ def check(name, cond):
|
||||
# release/1.0 rather than against release/1.0.
|
||||
# ---------------------------------------------------------------------------
|
||||
_DEV = ('{"identifiers": ["goodwe_rs485_controller"], "name": "GoodWe", '
|
||||
'"manufacturer": "GoodWe (via RS485 meter emulation)", '
|
||||
'"manufacturer": "GoodWe (via RS485 EMS port)", '
|
||||
'"model": "ES/BP series"}')
|
||||
|
||||
BASELINE = [
|
||||
@@ -442,7 +442,7 @@ for c_ in unit_cfgs:
|
||||
print(f" regression pin (sha256 of the eight payloads): {_pin}")
|
||||
check("the per-unit payloads are the ones this PR was reviewed with "
|
||||
"(REGRESSION PIN, not a correctness claim - see the comment)",
|
||||
_pin == "eb5022b0455158d7")
|
||||
_pin == "b4274714d8557c37")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n4. zero overlap between A's entities and B's")
|
||||
@@ -976,7 +976,7 @@ def announce_p2(units=(), omit=(), include=(), modes=(), retract=(),
|
||||
# `git show ed739d4:goodwe_controller/app/mqtt.py` and this loop.
|
||||
ROSTERS = [[], ["inverter-a"], ["inverter-a", "inverter-b"], ["a", "b", "c"],
|
||||
["inverter-b", "Inverter B", "x", "y"], ["café"]]
|
||||
ED739D4_OFF = "501a7d93782a260b"
|
||||
ED739D4_OFF = "057be1d8ad7bb582" # DOC-8: ed739d4 digest with only the manufacturer string changed
|
||||
_h, _n, _subs_off = hashlib.sha256(), 0, []
|
||||
for _r in ROSTERS:
|
||||
for _om in [(), ("p1_age",), ("p1_age", "grid")]:
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
"""SAFETY-118: the peak forecast is read in its own unit. `python3 -B test_safety118.py`
|
||||
|
||||
`peak_forecast_entity` used to be read as a bare number and anything under 50
|
||||
was multiplied by 1000, so a W sensor reading 17 W was taken as 17 kW. It is
|
||||
now read through `pv.watts()` by its own `unit_of_measurement`. No unit, or a
|
||||
unit that is not power: the forecast is not used, as if the option were empty.
|
||||
|
||||
1. The reader through the real `read_inputs()`: W, kW, MW, no unit, kWh, bad state.
|
||||
2. The warning: once, again only when the cause changes, never every cycle.
|
||||
3. One state read per cycle, as before.
|
||||
4. The maintenance charge headroom and the charge-only clamp act on the
|
||||
converted value (real `cycle()`).
|
||||
5. The validator, the status row and the strings.
|
||||
|
||||
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from app import main as _main
|
||||
from app import messages, pv, validate
|
||||
from app.main import Controller
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def st(value, unit=None):
|
||||
attrs = {} if unit is None else {"unit_of_measurement": unit}
|
||||
return {"state": str(value), "attributes": attrs}
|
||||
|
||||
|
||||
class _Store:
|
||||
def __init__(self, data=None):
|
||||
self.data = dict(data or {})
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get_time(self, *a):
|
||||
return None
|
||||
|
||||
def set_time(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
class _Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, *a, **k):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Hass:
|
||||
def __init__(self, fc=None, prices=None):
|
||||
self.fc = fc
|
||||
self.prices = prices or {}
|
||||
self.reads = []
|
||||
self.writes = []
|
||||
|
||||
async def limits(self, entity):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def state(self, entity):
|
||||
self.reads.append(entity)
|
||||
if entity == "sensor.fc":
|
||||
return self.fc
|
||||
v = {"sensor.grid": 0.0, "sensor.soc": 50.0, "sensor.batt": 0.0,
|
||||
**self.prices}.get(entity)
|
||||
return None if v is None else st(v, "W")
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
obj = await self.state(entity)
|
||||
try:
|
||||
return float(obj["state"]) if obj else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
self.writes.append(float(value))
|
||||
return True
|
||||
|
||||
async def call(self, domain, service, data):
|
||||
return True
|
||||
|
||||
|
||||
OPTS = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "heartbeat_s": 10, "stale_input_s": 15,
|
||||
"max_w": 5000.0, "gain": 0.6, "slew_w": 400.0, "deadband_w": 15,
|
||||
"step_w": 10, "target_grid_w": -10.0, "stale_input_s": 15,
|
||||
"peak_cap_w": 5000, "peak_margin_w": 0,
|
||||
"peak_forecast_entity": "sensor.fc"}
|
||||
|
||||
|
||||
class _Rec(logging.Handler):
|
||||
def __init__(self):
|
||||
super().__init__(logging.DEBUG)
|
||||
self.rows = []
|
||||
|
||||
def emit(self, r):
|
||||
self.rows.append((r.levelno, r.getMessage()))
|
||||
|
||||
|
||||
def make(fc, store=None, prices=None, **over):
|
||||
hass = _Hass(fc, prices)
|
||||
c = Controller(dict(OPTS, **over), hass, _Store(store), _Pub(),
|
||||
stop=asyncio.Event())
|
||||
c.auto, c.dev_min, c.dev_max = True, -5000.0, 5000.0
|
||||
return c, hass
|
||||
|
||||
|
||||
def reads(c):
|
||||
asyncio.run(c.read_inputs())
|
||||
return c.peak_fc
|
||||
|
||||
|
||||
def peak_of(state):
|
||||
c, _h = make(state)
|
||||
return reads(c)
|
||||
|
||||
|
||||
print("1. the reader")
|
||||
CASES = [("W 17", st(17, "W"), 17.0), ("W 820", st(820, "W"), 820.0),
|
||||
("kW 0.82", st(0.82, "kW"), 820.0), ("kW 0.017", st(0.017, "kW"), 17.0),
|
||||
("MW 0.0008", st(0.0008, "MW"), 800.0), ("W 49", st(49, "W"), 49.0),
|
||||
("W 50", st(50, "W"), 50.0), ("kW 30", st(30, "kW"), 30000.0),
|
||||
("W 0", st(0, "W"), 0.0)]
|
||||
for label, s, want in CASES:
|
||||
got = peak_of(s)
|
||||
check(f"{label} -> {want} W (got {got})",
|
||||
got is not None and abs(got - want) < 1e-6)
|
||||
for label, s in [("no unit", st(820)), ("empty unit", st(820, "")),
|
||||
("kWh", st(820, "kWh")), ("percent", st(20, "%")),
|
||||
("unavailable", st("unavailable", "W")),
|
||||
("unknown", st("unknown", "W")), ("missing entity", None),
|
||||
("not a number", st("sunny", "W"))]:
|
||||
check(f"{label} -> None (got {peak_of(s)})", peak_of(s) is None)
|
||||
c, _h = make(st(820, "W"), peak_forecast_entity="")
|
||||
check("option empty -> None and the entity is never read",
|
||||
reads(c) is None and "sensor.fc" not in _h.reads)
|
||||
|
||||
print("2. the warning")
|
||||
rec = _Rec()
|
||||
lg = logging.getLogger("goodwe")
|
||||
lg.addHandler(rec)
|
||||
lg.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
def warns():
|
||||
return [m for lv, m in rec.rows if lv >= logging.WARNING]
|
||||
|
||||
|
||||
c, h = make(st(820, "kWh"))
|
||||
for _ in range(5):
|
||||
reads(c)
|
||||
check(f"kWh: five cycles, one WARNING ({warns()})", len(warns()) == 1)
|
||||
check("...short, plain, and says it is not used",
|
||||
warns()[0] == "The 15-minute demand forecast is not in a power unit, so "
|
||||
"it is not used." and len(warns()[0].split()) <= 25)
|
||||
h.fc = st(820, "MWh")
|
||||
reads(c)
|
||||
check("the same cause with another unit warns again (the cause changed)",
|
||||
len(warns()) == 2)
|
||||
h.fc = st(820)
|
||||
reads(c)
|
||||
check("no unit is a different cause and warns", len(warns()) == 3
|
||||
and "has no unit" in warns()[2])
|
||||
reads(c)
|
||||
check("...and only once", len(warns()) == 3)
|
||||
USED = (logging.INFO, "The 15-minute demand forecast is used again.")
|
||||
|
||||
|
||||
def used():
|
||||
return [r for r in rec.rows if r == USED]
|
||||
|
||||
|
||||
h.fc = st("unavailable")
|
||||
reads(c)
|
||||
check("unavailable adds no warning and no 'used again'",
|
||||
len(warns()) == 3 and not used())
|
||||
h.fc = st(820, "W")
|
||||
reads(c)
|
||||
check("a good unit is read again: one INFO, no new warning",
|
||||
c.peak_fc == 820.0 and len(warns()) == 3 and len(used()) == 1
|
||||
and rec.rows[-1] == USED)
|
||||
n = len(warns())
|
||||
for s in (st(820, "W"), st(0.8, "kW")):
|
||||
h.fc = s
|
||||
reads(c)
|
||||
check("W and kW never warn", len(warns()) == n)
|
||||
rows = [r for r in c.checks() if r.get("id") == "peak_forecast"]
|
||||
check("a good forecast has no peak_forecast row", not rows)
|
||||
h.fc = st(820, "kWh")
|
||||
reads(c)
|
||||
rows = [r for r in c.checks() if r.get("id") == "peak_forecast"]
|
||||
check("the status page carries a caution row for the unit fault",
|
||||
len(rows) == 1 and rows[0]["warn"] and not rows[0]["ok"]
|
||||
and rows[0]["text"] == messages.row_peak_forecast_unit("kWh"))
|
||||
lg.removeHandler(rec)
|
||||
|
||||
print("3. one state read per cycle")
|
||||
c, h = make(st(0.82, "kW"))
|
||||
reads(c)
|
||||
check(f"one read of the forecast entity per read_inputs() ({h.reads})",
|
||||
h.reads.count("sensor.fc") == 1)
|
||||
|
||||
print("4. maintenance headroom and the charge-only clamp use watts")
|
||||
seen = []
|
||||
_real = _main.maintenance_charge_floor
|
||||
|
||||
|
||||
def spy(charge_w, fc, cap):
|
||||
out = _real(charge_w, fc, cap)
|
||||
seen.append((charge_w, fc, cap, out))
|
||||
return out
|
||||
|
||||
|
||||
_main.maintenance_charge_floor = spy
|
||||
try:
|
||||
def cycle(fc, **kw):
|
||||
c, h = make(fc, store={"phase": "charge"},
|
||||
prices={"sensor.pnow": 1.0, "sensor.pavg": 2.0},
|
||||
price_now_entity="sensor.pnow",
|
||||
price_avg_entity="sensor.pavg", **kw)
|
||||
seen.clear()
|
||||
asyncio.run(c.read_inputs())
|
||||
asyncio.run(c.cycle())
|
||||
return c, h
|
||||
|
||||
# cap 2500 W, charge rate 2000 W: 820 W of forecast leaves 1680 W, 17 W
|
||||
# leaves the whole 2000 W (the size rule read 17 as 17 kW and left 0).
|
||||
c, h = cycle(st(0.82, "kW"), peak_cap_w=2500)
|
||||
check(f"cheap-window headroom is cap - 820 W = 1680 W ({seen})",
|
||||
len(seen) == 1 and seen[0][1] == 820.0 and seen[0][0] == 2000.0
|
||||
and abs(seen[0][3] - 1680.0) < 1e-6)
|
||||
c, h = cycle(st(17, "W"), peak_cap_w=2500)
|
||||
check(f"a 17 W forecast leaves the full 2000 W charge rate ({seen})",
|
||||
len(seen) == 1 and seen[0][1] == 17.0 and abs(seen[0][3] - 2000.0) < 1e-6)
|
||||
# charge-only clamp: not cheap, shaping suspended only if the forecast is
|
||||
# over the aim. 6 kW is over 5000 W; 17 W is not (the old rule read it
|
||||
# as 17 kW and suspended).
|
||||
def clamp(fc):
|
||||
c, h = make(fc, store={"phase": "charge"})
|
||||
asyncio.run(c.read_inputs())
|
||||
asyncio.run(c.cycle())
|
||||
return c._maint_shape_suspended
|
||||
check("kW 6.0 (6000 W, over the 5000 W aim) suspends the charge-only clamp",
|
||||
clamp(st(6.0, "kW")) is True)
|
||||
check("W 17 leaves the charge-only clamp in place",
|
||||
clamp(st(17, "W")) is False)
|
||||
check("kW 0.017 leaves the charge-only clamp in place",
|
||||
clamp(st(0.017, "kW")) is False)
|
||||
check("no unit: forecast unused, clamp in place, as with the option empty",
|
||||
clamp(st(6000)) is False)
|
||||
finally:
|
||||
_main.maintenance_charge_floor = _real
|
||||
|
||||
print("5. validator and strings")
|
||||
def notes(unit, key="peak_forecast_entity"):
|
||||
ent = {"sensor.fc": {"entity_id": "sensor.fc", "unit": unit,
|
||||
"state": "1"}}
|
||||
return [n for n in validate.config_notes(
|
||||
{key: "sensor.fc", "meter_source": "off"}, entities=ent)
|
||||
if key in n.keys]
|
||||
|
||||
|
||||
check("validator: W no note", not notes("W"))
|
||||
check("validator: kW no note", not notes("kW"))
|
||||
check("validator: MW no note", not notes("MW"))
|
||||
nu, kw = notes(""), notes("kWh")
|
||||
check(f"validator: no unit warns ({nu})", len(nu) == 1 and nu[0].tier == "warn"
|
||||
and "has no unit" in nu[0].text)
|
||||
check(f"validator: kWh warns ({kw})", len(kw) == 1 and kw[0].tier == "warn"
|
||||
and "not in a power unit" in kw[0].text)
|
||||
check("validator: the false 'taken as watts' claim is gone for this entity",
|
||||
not any("taken as watts" in n.text for n in nu + kw + notes("kW")))
|
||||
check("validator: another watt key keeps its claim",
|
||||
len(notes("kW", "batt_entity")) == 1
|
||||
and "taken as watts" in notes("kW", "batt_entity")[0].text)
|
||||
check("WATT_ENTITY_KEYS no longer holds the peak forecast",
|
||||
"peak_forecast_entity" not in validate.WATT_ENTITY_KEYS
|
||||
and "batt_entity" in validate.WATT_ENTITY_KEYS)
|
||||
print(" EN no unit :", messages.peak_forecast_unit(""))
|
||||
print(" EN bad unit:", messages.peak_forecast_unit("kWh"))
|
||||
print(" NL no unit :", messages.peak_forecast_unit("", "nl"))
|
||||
print(" NL bad unit:", messages.peak_forecast_unit("kWh", "nl"))
|
||||
check("the strings differ by cause and by language",
|
||||
len({messages.peak_forecast_unit(u, l) for u in ("", "kWh")
|
||||
for l in ("en", "nl")}) == 4)
|
||||
print("6. a missing reading leaves the unit fault as it was (F1)")
|
||||
|
||||
|
||||
lg.addHandler(rec)
|
||||
|
||||
|
||||
def prow(c):
|
||||
return [r for r in c.checks() if r.get("id") == "peak_forecast"]
|
||||
|
||||
|
||||
def flap(first, gap, last):
|
||||
"""first, gap, first again (or last): the log rows and the status row
|
||||
after each cycle."""
|
||||
rec.rows.clear()
|
||||
c, h = make(first)
|
||||
out = []
|
||||
for s in (first, gap, last):
|
||||
h.fc = s
|
||||
reads(c)
|
||||
out.append((len(warns()), len(used()), len(prow(c)), c.peak_fc))
|
||||
return out
|
||||
|
||||
|
||||
for label, first, gap in [
|
||||
("kWh -> unavailable -> kWh", st(820, "kWh"), st("unavailable", "kWh")),
|
||||
("kWh -> unknown -> kWh", st(820, "kWh"), st("unknown", "kWh")),
|
||||
("kWh -> failed read -> kWh", st(820, "kWh"), None),
|
||||
("kWh -> non-numeric -> kWh", st(820, "kWh"), st("sunny", "kWh")),
|
||||
("no unit -> failed read -> no unit", st(820), None),
|
||||
("no unit -> unavailable -> no unit", st(820), st("unavailable"))]:
|
||||
r = flap(first, gap, first)
|
||||
check(f"{label}: one WARNING, no 'used again', row on all 3 ({r})",
|
||||
[x[0] for x in r] == [1, 1, 1] and [x[1] for x in r] == [0, 0, 0]
|
||||
and [x[2] for x in r] == [1, 1, 1]
|
||||
and [x[3] for x in r] == [None, None, None])
|
||||
r = flap(st(820, "kWh"), st("unavailable", "kWh"), st(820, "W"))
|
||||
check(f"kWh -> unavailable -> W: the fault clears only on the W reading ({r})",
|
||||
[x[1] for x in r] == [0, 0, 1] and [x[2] for x in r] == [1, 1, 0]
|
||||
and [x[0] for x in r] == [1, 1, 1] and r[2][3] == 820.0)
|
||||
rec.rows.clear()
|
||||
c, h = make(st(820, "W"))
|
||||
for s in (st(820, "W"), st("unavailable", "W"), None, st("sunny", "W")):
|
||||
h.fc = s
|
||||
reads(c)
|
||||
check(f"good unit, reading {s and s['state']!r}: no row, no log",
|
||||
not prow(c) and not warns() and not used())
|
||||
rec.rows.clear()
|
||||
c, h = make(st(820, "kWh"))
|
||||
reads(c)
|
||||
for s in (st("unavailable", "kWh"), None):
|
||||
h.fc = s
|
||||
reads(c)
|
||||
check("fault row stays while the reading is missing",
|
||||
len(prow(c)) == 1 and prow(c)[0]["text"]
|
||||
== messages.row_peak_forecast_unit("kWh"))
|
||||
c, h = make(st(820, "W"))
|
||||
h.fc = st("unavailable", "W")
|
||||
reads(c)
|
||||
check("good-unit sensor only unavailable: the fault row stays away",
|
||||
not prow(c))
|
||||
lg.removeHandler(rec)
|
||||
check("has_reading: numbers only",
|
||||
pv.has_reading(st(0, "kWh")) and pv.has_reading(st("1.5"))
|
||||
and not pv.has_reading(None) and not pv.has_reading(st("unavailable"))
|
||||
and not pv.has_reading(st("unknown")) and not pv.has_reading(st("x")))
|
||||
check("pv.unit_fault: None for a good unit and for a missing reading (has_reading tells them apart)",
|
||||
pv.unit_fault(st(1, "kW")) is None and pv.unit_fault(None) is None
|
||||
and pv.unit_fault(st("unavailable")) is None
|
||||
and pv.unit_fault(st(1)) == "" and pv.unit_fault(st(1, "kWh")) == "kWh")
|
||||
src = (Path(__file__).parent / "app" / "main.py").read_text(encoding="utf-8")
|
||||
check("the size rule is gone from main.py", "abs(raw) < 50" not in src)
|
||||
|
||||
print()
|
||||
print("FAILED: " + "; ".join(fails) if fails else "all checks passed")
|
||||
raise SystemExit(1 if fails else 0)
|
||||
@@ -0,0 +1,737 @@
|
||||
"""Runnable check for SAFETY-119: no "Battery is not keeping up" when the
|
||||
loop takes the wire back from another owner. `python3 test_safety119.py`
|
||||
|
||||
During a maintenance charge with no usable peak forecast, the measured peak
|
||||
guard steps in during a quarter's last seconds and hands back. On the cycle
|
||||
the loop gets the wire back, its integrator is re-seeded from the other
|
||||
owner's command and kicked by a meter reading taken before the battery moved.
|
||||
It crossed its rail for one or two cycles, the loop's ask beat the one-sided
|
||||
limit, so the cycle counted as the loop's own and raised
|
||||
`goodwe_integrator_bound`. The battery gave what it was told. Now the loop's
|
||||
bound does not count until the plant's settle (SHORT_CYCLES cycles and
|
||||
SHORT_SPAN_S) has passed since the hand-back. SAFETY-121: the settle is
|
||||
HANDBACK_CYCLES (4) cycles and SHORT_SPAN_S (22 s), and across all
|
||||
hand-backs the bound is hidden on at most ROLLING_BOUND_CYCLES (11) cycles in
|
||||
any ROLLING_S (900 s), at every cycle length.
|
||||
DECISIONS/SAFETY-119-bound-after-hand-back.md,
|
||||
DECISIONS/SAFETY-121-bound-every-quarter.md.
|
||||
|
||||
1. The rig's shape (1 s meter that lags the battery, 1 s battery lag, house
|
||||
800 W, rating 2500 W, limit 2500 W, margin 50, charge 2000 W, no
|
||||
forecast), 2 h, three seeds: no notification, the cap held, every command
|
||||
the same as without the fix; without the fix it warns.
|
||||
2. A genuine integrator bound still warns: the loop in charge with the house
|
||||
over the rating (at once, as before), and the loop at its rail after a
|
||||
hand-back (after the settle).
|
||||
3. With a usable forecast: the same commands and the same alarms as without
|
||||
the fix.
|
||||
4. Repeated hand-backs (N=2, the guard and the charge take turns), 2, 5, 10
|
||||
and 15 s meters: it warns in every quarter, both with the meter a cycle
|
||||
behind the battery reading and in the rig's shape (SAFETY-121, F-1: the
|
||||
meter read in the same cycle, 1 to 5 bound cycles a quarter), where
|
||||
SAFETY-119's settle of 12 cycles hid every quarter but one; the
|
||||
quarter-edge case stays silent at 2, 5, 10 and 15 s; the budget's margins
|
||||
(8 warns at the quarter edge, 14 misses quarters); a start or a blind
|
||||
cycle is not a hand-back; the delay after one hand-back.
|
||||
5. The rule at its boundaries (`Controller._settling`): the settle, the
|
||||
rolling budget, and a stop that does not give the budget back.
|
||||
6. test_safety84's and test_safety87's re-pinned digests: the same commands
|
||||
and claims; only OUTPUT_CLAMP log rows differ.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import safety79_identity as s79 # noqa: E402
|
||||
import safety84_identity as s84 # noqa: E402
|
||||
import app.main as app_main # noqa: E402
|
||||
from app import messages # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
from named_absence import first, nth, present # noqa: E402
|
||||
|
||||
fails = []
|
||||
OPT0 = dict(s84.OPT)
|
||||
CAP = 2500.0
|
||||
QUARTER = 900.0
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
async def _call(self, *a):
|
||||
"""The rig's Home Assistant, recording each notification (time, id)."""
|
||||
if len(a) >= 3 and a[1] == "create" and isinstance(a[2], dict):
|
||||
self.__dict__.setdefault("notes", []).append(
|
||||
(self.t, a[2].get("notification_id")))
|
||||
return True
|
||||
|
||||
|
||||
def _power(self, u, lag=1.0):
|
||||
"""The TEST-23 plant's battery: 800 W/s ramp, 1 s first-order lag."""
|
||||
st = self.__dict__.setdefault("_plant", {})
|
||||
a, tgt, tl = st.get(u, (0.0, 0.0, self.t))
|
||||
for _ in range(int(round((self.t - tl) / 0.1))):
|
||||
d = self.cmd[u] - tgt
|
||||
tgt += math.copysign(min(abs(d), 80.0), d)
|
||||
a += (tgt - a) * (1 - math.exp(-0.1 / lag))
|
||||
st[u] = (a, tgt, self.t)
|
||||
if self.cut(u, self.t) and self.cmd[u] >= -4.0:
|
||||
return -4.0
|
||||
return float(round(a))
|
||||
|
||||
|
||||
_real_number, _real_power = rig._Hass.number, rig._Hass.power
|
||||
_real_feed = s79._feed
|
||||
|
||||
|
||||
async def _number(self, e, invert=False):
|
||||
"""sensor.fc: a usable demand forecast of 800 W (part 3)."""
|
||||
if e == "sensor.fc":
|
||||
return 800.0
|
||||
return await _real_number(self, e, invert)
|
||||
|
||||
|
||||
rig._Hass.call, rig._Hass.power, rig._Hass.number = _call, _power, _number
|
||||
rig.TICK = 1.0
|
||||
DELAY = [1]
|
||||
|
||||
|
||||
def _feed(c, t):
|
||||
"""The P1 meter `DELAY` ticks behind the battery, as on the rig (the
|
||||
meter reached HA after the battery reading). Records the true grid."""
|
||||
h = c.hass.__dict__.setdefault("grid_true", [])
|
||||
h.append((t, c.hass.grid()))
|
||||
g = h[max(0, len(h) - 1 - DELAY[0])][1]
|
||||
imp, exp = split_signed(g)
|
||||
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=rig.BASE + timedelta(seconds=t),
|
||||
ingest_mono=time.monotonic()))
|
||||
|
||||
|
||||
s79._feed = _feed
|
||||
|
||||
|
||||
def house(seed, base=800.0, step=None):
|
||||
"""800 W +- 30 W, a new value each second; `step` = (t, W) from then."""
|
||||
rnd = random.Random(seed)
|
||||
noise = [rnd.uniform(-30.0, 30.0) for _ in range(4000)]
|
||||
|
||||
def load(t):
|
||||
if step is not None and t >= step[0]:
|
||||
return step[1]
|
||||
return base + noise[int(t) % 4000]
|
||||
return load
|
||||
|
||||
|
||||
def run(load, *, n=1, dur=7200.0, delay=1, extra=None, cheap=True, cut=None,
|
||||
charging=True, blind=0.0):
|
||||
"""One run of `n` inverters of 2500 W; `charging=False`: no maintenance,
|
||||
the loop in charge; `blind`: no meter reading until that second."""
|
||||
extra = dict(extra or {})
|
||||
s84.OPT = dict(OPT0, peak_cap_w=int(CAP * n), peak_margin_w=50,
|
||||
maintenance_charge_w=2000)
|
||||
if not charging:
|
||||
extra.update(maintenance_enabled=False, peak_cap_w=20000)
|
||||
for k in [k for k in extra if k in s84.OPT]:
|
||||
s84.OPT[k] = extra.pop(k)
|
||||
DELAY[0] = delay
|
||||
try:
|
||||
feed = s84.feed(cheap)
|
||||
|
||||
def before(c, t):
|
||||
if t >= blind: # before that: no meter reading
|
||||
return feed(c, t)
|
||||
return s84.run(n, load=load, cheap=cheap,
|
||||
store=(s79.store_charging(n) if charging
|
||||
else rig._Store()),
|
||||
soc=lambda u, t: 60.0, dur=dur, cut=cut,
|
||||
before=before if blind else None,
|
||||
extra=dict({"max_w": 2500, "slew_w": 0}, **extra))
|
||||
finally:
|
||||
s84.OPT = OPT0
|
||||
|
||||
|
||||
def patched(obj, name, value):
|
||||
def wrap(fn):
|
||||
real = getattr(obj, name)
|
||||
setattr(obj, name, value)
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
setattr(obj, name, real)
|
||||
return wrap
|
||||
|
||||
|
||||
no_fix = patched(Controller, "_settling", lambda self, owns, now: False)
|
||||
|
||||
|
||||
def notes(c):
|
||||
return [t for t, i in getattr(c.hass, "notes", [])
|
||||
if i == messages.ID_LOOP_BOUND]
|
||||
|
||||
|
||||
def quarters(c, tick=1.0):
|
||||
"""Each whole quarter's true average import (W)."""
|
||||
q = {}
|
||||
for t, g in c.hass.grid_true:
|
||||
q.setdefault(int(t // QUARTER), []).append(g)
|
||||
return [sum(v) / len(v) for v in q.values()
|
||||
if len(v) == QUARTER / tick]
|
||||
|
||||
|
||||
def commands(rows):
|
||||
return [(r[0], r[1], r[2]) for r in rows]
|
||||
|
||||
|
||||
def guard_steps(c):
|
||||
return sum(1 for e in c.events if "PEAK_GUARD:" in e)
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. a maintenance charge with no forecast, the guard steps in at each "
|
||||
"quarter's end, 2 h")
|
||||
NEW, OLD = {}, {}
|
||||
for seed, delay in ((1, 1), (2, 1), (3, 2)):
|
||||
NEW[seed] = run(house(seed), delay=delay)
|
||||
OLD[seed] = no_fix(lambda: run(house(seed), delay=delay))
|
||||
print(f" seed {seed} meter {delay} s behind: notifications "
|
||||
f"{notes(NEW[seed][1])} (without the fix {notes(OLD[seed][1])}), "
|
||||
f"highest quarter {max(quarters(NEW[seed][1])):.0f} W, guard "
|
||||
f"step-ins in the last 40 log rows {guard_steps(NEW[seed][1])}")
|
||||
check("⭐ no 'Battery is not keeping up' notification, cycle or episode",
|
||||
all(notes(c) == [] and c.clamp_wd.bound == 0
|
||||
and c.clamp_wd.bound_episodes == 0 for _r, c in NEW.values()))
|
||||
check("⭐ the peak cap is held: every quarter's average is under 2500 W",
|
||||
all(len(quarters(c)) == 8 and max(quarters(c)) < CAP
|
||||
for _r, c in NEW.values()))
|
||||
check("⭐ control is unchanged: every command the same as without the fix",
|
||||
all(commands(NEW[k][0]) == commands(OLD[k][0]) for k in NEW))
|
||||
check("...the charge ran and the guard stepped in near each quarter's end",
|
||||
all(any(r[1] == -2000.0 for r in rows) and any(
|
||||
r[-1][1] and r[-1][1][0] and r[0] % QUARTER >= QUARTER - 40
|
||||
for r in rows) for rows, _c in NEW.values()))
|
||||
check("non-vacuous: without the fix every run warns, at a quarter's edge",
|
||||
all(len(notes(c)) >= 1 for _r, c in OLD.values())
|
||||
and all(min(t % QUARTER, QUARTER - t % QUARTER) <= 40
|
||||
for _r, c in OLD.values() for t in notes(c)))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. a genuine integrator bound still warns")
|
||||
_rail = run(house(1, base=4000.0), dur=600.0,
|
||||
charging=False)
|
||||
_rail_old = no_fix(lambda: run(house(1, base=4000.0), dur=600.0,
|
||||
charging=False))
|
||||
print(f" loop in charge, house 4000 W over the 2500 W rating: "
|
||||
f"{notes(_rail[1])}, without the fix {notes(_rail_old[1])}")
|
||||
check("⭐ the loop in charge at its rail warns (log and notification), at "
|
||||
"the same cycle as before",
|
||||
len(notes(_rail[1])) >= 1 and notes(_rail[1]) == notes(_rail_old[1])
|
||||
and _rail[1].clamp_wd.bound == _rail_old[1].clamp_wd.bound
|
||||
and any("OUTPUT_CLAMP episode" in e for e in _rail[1].events))
|
||||
|
||||
# The charge sets the wire until 600 s, then a solar surplus of 6000 W: the
|
||||
# loop takes the wire back and stands at its -2500 W rail.
|
||||
_hb = run(house(1, step=(600.0, -6000.0)), dur=1200.0,
|
||||
extra={"peak_cap_w": 20000})
|
||||
_hb_old = no_fix(lambda: run(house(1, step=(600.0, -6000.0)), dur=1200.0,
|
||||
extra={"peak_cap_w": 20000}))
|
||||
_first = notes(_hb[1])[:1]
|
||||
print(f" hand-back to a loop at its rail at 600 s: first notification "
|
||||
f"{_first}, without the fix {notes(_hb_old[1])[:1]}; command at the "
|
||||
f"end {_hb[0][-1][1]}")
|
||||
MB = 11 # the budget (at 1 s it ends the settle)
|
||||
check(f"⭐ the loop at its rail after a hand-back still warns, once "
|
||||
f"{MB} of its bound cycles are hidden (1 s meter: {MB} s)",
|
||||
_first and 600.0 + MB <= _first[0] <= 600.0 + MB + 4.0
|
||||
and _hb[0][-1][1] == -2500.0
|
||||
and _hb[1].clamp_wd.bound > 100)
|
||||
check("...without the fix it warned at once (non-vacuous)",
|
||||
notes(_hb_old[1])[:1] and notes(_hb_old[1])[0] < 600.0 + MB)
|
||||
# The same charge with no forecast; at 1795 s, under the guard's step-in at
|
||||
# the quarter's end, the house goes to 4000 W, over the 2500 W rating.
|
||||
_up = run(house(1, step=(1795.0, 4000.0)), dur=1900.0)
|
||||
_up_old = no_fix(lambda: run(house(1, step=(1795.0, 4000.0)), dur=1900.0))
|
||||
print(f" guard step-in, then the house over the rating from 1795 s: "
|
||||
f"{notes(_up[1])}, without the fix {notes(_up_old[1])}")
|
||||
check("⭐ a genuine bound in the rig's own scenario still warns, within the "
|
||||
"settle of the guard's hand-back",
|
||||
notes(_up[1]) and 1795.0 <= notes(_up[1])[0] <= 1795.0 + 40.0
|
||||
and any("OUTPUT_CLAMP episode" in e for e in _up[1].events))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. with a usable forecast (800 W): unchanged")
|
||||
FC = {"peak_forecast_entity": "sensor.fc"}
|
||||
_fc = run(house(1), extra=FC)
|
||||
_fc_old = no_fix(lambda: run(house(1), extra=FC))
|
||||
print(f" notifications {notes(_fc[1])} (without the fix "
|
||||
f"{notes(_fc_old[1])}), highest quarter "
|
||||
f"{max(quarters(_fc[1])):.0f} W, charge "
|
||||
f"{min(r[1] for r in _fc[0][:300]):.0f} W")
|
||||
check("⭐ the same commands and the same alarms as without the fix, and "
|
||||
"the cap held",
|
||||
commands(_fc[0]) == commands(_fc_old[0])
|
||||
and notes(_fc[1]) == notes(_fc_old[1])
|
||||
and _fc[1].clamp_wd.engagements == _fc_old[1].clamp_wd.engagements
|
||||
and max(quarters(_fc[1])) < CAP)
|
||||
check("...the forecast was used (the charge is shaped under -2000 W)",
|
||||
-2000.0 < min(r[1] for r in _fc[0][:300]) < 0.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. repeated hand-backs: the guard and the charge take turns")
|
||||
|
||||
|
||||
def round1(self, owns, now):
|
||||
"""Round 1's rule, kept here to show what it hid: every hand-back
|
||||
restarts the window, so a loss within the settle never lets it end."""
|
||||
d = self.__dict__
|
||||
if not owns:
|
||||
d["_r1"], d["_r1_n"] = True, None
|
||||
return False
|
||||
if d.get("_r1"):
|
||||
d["_r1"], d["_r1_n"] = False, (now, 0)
|
||||
if d.get("_r1_n") is None:
|
||||
return False
|
||||
t0, n = d["_r1_n"]
|
||||
d["_r1_n"] = (t0, n + 1)
|
||||
if n + 1 >= app_main.SHORT_CYCLES and (
|
||||
now is None or t0 is None or now - t0 >= app_main.SHORT_SPAN_S):
|
||||
d["_r1_n"] = None
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# N=2 (2 x 2500 W), a cheap-window charge, no usable forecast, the house 50 W
|
||||
# over the 5000 W rating from 300 s: the loop never held the wire for more
|
||||
# than 10 cycles. Round 1: no notification in 2 h; round 2: none at 10 s or
|
||||
# 15 s meters.
|
||||
def never(tick, hw=5050.0, fix=True, seed=0, delay=1):
|
||||
"""`seed` 0: a constant house; else 800 W +- 30 W, `hw` +- 30 W from
|
||||
300 s. `delay` 0 is the rig's shape: the meter read in the same cycle
|
||||
as the battery (SAFETY-121)."""
|
||||
rig.TICK = tick
|
||||
if seed:
|
||||
load = (lambda t, h=house(seed):
|
||||
h(t) + (hw - 800.0 if t >= 300.0 else 0.0))
|
||||
else:
|
||||
load = house(1, step=(300.0, hw))
|
||||
try:
|
||||
f = lambda: run(load, n=2, delay=delay)
|
||||
return f() if fix else no_fix(f)
|
||||
finally:
|
||||
rig.TICK = 1.0
|
||||
|
||||
|
||||
def per_quarter(c):
|
||||
return [sum(1 for t in notes(c) if int(t // QUARTER) == q)
|
||||
for q in range(8)]
|
||||
|
||||
|
||||
def every_quarter(c):
|
||||
return all(n >= 1 for n in per_quarter(c)[1:])
|
||||
|
||||
|
||||
_nv = {t: never(t) for t in (2.0, 5.0, 10.0, 15.0)}
|
||||
_nv_old = {t: never(t, fix=False) for t in (2.0, 5.0, 10.0, 15.0)}
|
||||
_nv_r1 = patched(Controller, "_settling", round1)(lambda: never(5.0))
|
||||
for _t in _nv:
|
||||
print(f" N=2, {_t:.0f} s meter, house 5050 W: notifications "
|
||||
f"{len(notes(_nv[_t][1]))} (per quarter "
|
||||
f"{per_quarter(_nv[_t][1])}), without the fix "
|
||||
f"{len(notes(_nv_old[_t][1]))}, highest quarter "
|
||||
f"{max(quarters(_nv[_t][1], _t)):.0f} W")
|
||||
print(f" round 1's rule at 5 s: {len(notes(_nv_r1[1]))}")
|
||||
check("⭐ 2, 5, 10 and 15 s meters: it warns in every quarter after the "
|
||||
"first (round 2 gave none at 10 and 15 s)",
|
||||
all(every_quarter(_nv[t][1]) for t in _nv))
|
||||
check("non-vacuous: round 1's rule (a window per hand-back) never warned "
|
||||
"here, and the fix still hides some of what was raised without it",
|
||||
notes(_nv_r1[1]) == [] and all(
|
||||
_nv[t][1].clamp_wd.bound < _nv_old[t][1].clamp_wd.bound
|
||||
for t in _nv))
|
||||
check("...every command is the same as without the fix, at 2, 5, 10 and "
|
||||
"15 s",
|
||||
all(commands(_nv[t][0]) == commands(_nv_old[t][0]) for t in _nv))
|
||||
check("...and at 5 s the cap held",
|
||||
len(quarters(_nv[5.0][1], 5.0)) == 8
|
||||
and max(quarters(_nv[5.0][1], 5.0)) < 5000.0)
|
||||
|
||||
# SAFETY-121, the rig's shape (TEST-23, F-1): the meter is read in the same
|
||||
# cycle as the battery, so the guard's charge headroom is right after it
|
||||
# hands back and the guard and the charge do not chase each other. The loop
|
||||
# then holds the wire once a quarter, at its start, and is at its bound on
|
||||
# 1 to 5 cycles (5 s), not 20. House 5100 W +- 30 W (5300 W at 15 s, where
|
||||
# 5100 W is over the rating in one quarter only, without the fix too).
|
||||
RIG = {1.0: 5100.0, 2.0: 5100.0, 5.0: 5100.0, 10.0: 5100.0, 15.0: 5300.0}
|
||||
_rg = {t: never(t, hw, seed=1, delay=0) for t, hw in RIG.items()}
|
||||
_rg_old = {t: never(t, hw, fix=False, seed=1, delay=0)
|
||||
for t, hw in RIG.items()}
|
||||
# SAFETY-119 as merged (bee7431) at 5 s: a settle of 12 cycles, budget 8.
|
||||
_rg_119 = patched(app_main, "HANDBACK_CYCLES", app_main.SHORT_CYCLES)(
|
||||
lambda: patched(app_main, "ROLLING_BOUND_CYCLES", 8)(
|
||||
lambda: never(5.0, 5100.0, seed=1, delay=0)))
|
||||
for _t in _rg:
|
||||
print(f" rig shape, {_t:.0f} s meter, house {RIG[_t]:.0f} W: per "
|
||||
f"quarter {per_quarter(_rg[_t][1])}, without the fix "
|
||||
f"{per_quarter(_rg_old[_t][1])}, highest quarter "
|
||||
f"{max(quarters(_rg[_t][1], _t)):.0f} W")
|
||||
print(f" rig shape, 5 s, SAFETY-119's settle and budget: per quarter "
|
||||
f"{per_quarter(_rg_119[1])}")
|
||||
check("⭐ the rig's shape at 1, 2, 5, 10 and 15 s meters: it warns in every "
|
||||
"quarter after the first, as without the fix",
|
||||
all(every_quarter(_rg[t][1]) and every_quarter(_rg_old[t][1])
|
||||
for t in _rg))
|
||||
check("non-vacuous (F-1): with SAFETY-119's settle of 12 cycles and budget "
|
||||
"of 8, the rig's shape at 5 s warns in at most one quarter",
|
||||
sum(1 for n in per_quarter(_rg_119[1])[1:] if n) <= 1)
|
||||
check("...every command the same as without the fix, and the cap held at "
|
||||
"1 to 10 s",
|
||||
all(commands(_rg[t][0]) == commands(_rg_old[t][0]) for t in _rg)
|
||||
and all(max(quarters(_rg[t][1], t)) < 5000.0
|
||||
for t in (1.0, 2.0, 5.0, 10.0)))
|
||||
|
||||
# The quarter-edge case (part 1) at the slower meters: still no warning.
|
||||
# The meter's own lag limits the cap there, the same with and without the fix.
|
||||
_qe = {}
|
||||
for _t in (2.0, 5.0, 10.0, 15.0): # with part 1: 1, 2, 5, 10, 15 s
|
||||
rig.TICK = _t
|
||||
for _seed, _dl in ((1, 1), (2, 1), (3, 2)):
|
||||
_qe[_t, _seed] = (run(house(_seed), delay=_dl),
|
||||
no_fix(lambda: run(house(_seed), delay=_dl)))
|
||||
rig.TICK = 1.0
|
||||
print(f" {_t:.0f} s meter, quarter edge: notifications "
|
||||
f"{[len(notes(r[0][1])) for k, r in _qe.items() if k[0] == _t]} "
|
||||
f"(without the fix "
|
||||
f"{[len(notes(r[1][1])) for k, r in _qe.items() if k[0] == _t]})")
|
||||
check("⭐ the quarter-edge case at 2, 5, 10 and 15 s meters, seeds 1 to 3: "
|
||||
"no notification",
|
||||
all(notes(r[0][1]) == [] and r[0][1].clamp_wd.bound == 0
|
||||
for r in _qe.values()))
|
||||
check("...every command the same as without the fix; without it the false "
|
||||
"alarm is there at each meter (non-vacuous)",
|
||||
all(commands(r[0][0]) == commands(r[1][0]) for r in _qe.values())
|
||||
and all(any(len(notes(_qe[_t, k][1][1])) >= 1 for k in (1, 2, 3))
|
||||
for _t in (2.0, 5.0, 10.0, 15.0)))
|
||||
|
||||
# The budget's margins (DECISIONS/SAFETY-121): 9 to 13 met both cases in the
|
||||
# sweep. 8 warns at the quarter edge (2 s, seed 25); 14 misses quarters in
|
||||
# the rig's shape (1 s, house 5050 W, seed 1).
|
||||
rig.TICK = 2.0
|
||||
_q25 = (run(house(25), delay=2),
|
||||
patched(app_main, "ROLLING_BOUND_CYCLES", 8)(
|
||||
lambda: run(house(25), delay=2)))
|
||||
rig.TICK = 1.0
|
||||
_m14 = patched(app_main, "ROLLING_BOUND_CYCLES", 14)(
|
||||
lambda: never(1.0, 5050.0, seed=1, delay=0))
|
||||
_m11 = never(1.0, 5050.0, seed=1, delay=0)
|
||||
print(f" 2 s, quarter edge, seed 25: {notes(_q25[0][1])}, budget 8 "
|
||||
f"{notes(_q25[1][1])}; rig shape 1 s 5050 W per quarter "
|
||||
f"{per_quarter(_m11[1])}, budget 14 {per_quarter(_m14[1])}")
|
||||
check("...the budget's margins: 11 is silent at 2 s seed 25 and warns every "
|
||||
"quarter in the rig's shape at 1 s and 5050 W; 8 warns there, 14 "
|
||||
"misses a quarter there",
|
||||
notes(_q25[0][1]) == [] and len(notes(_q25[1][1])) >= 1
|
||||
and every_quarter(_m11[1]) and not every_quarter(_m14[1]))
|
||||
check("the rule is the one the sweep chose. Changing it means re-running "
|
||||
"the sweep in DECISIONS/SAFETY-121-bound-every-quarter.md",
|
||||
app_main.HANDBACK_CYCLES == 4 and app_main.ROLLING_BOUND_CYCLES == 11
|
||||
and app_main.ROLLING_S == 900.0 and app_main.SHORT_SPAN_S == 22.0)
|
||||
|
||||
# A start, or a blind cycle, is not a hand-back: the loop at its rail after
|
||||
# 20 s of not running warns at once (R2: a restart counted as a hand-back).
|
||||
_bl = run(house(1, base=4000.0), dur=120.0, charging=False, blind=20.0)
|
||||
_bl_old = no_fix(lambda: run(house(1, base=4000.0), dur=120.0,
|
||||
charging=False, blind=20.0))
|
||||
print(f" the loop not running until 20 s, then at its rail: "
|
||||
f"{notes(_bl[1])}, without the fix {notes(_bl_old[1])}")
|
||||
check("⭐ a start after a blind spell is not a hand-back: it warns at the "
|
||||
"same cycle as without the fix",
|
||||
notes(_bl[1])[:1] and 20.0 <= notes(_bl[1])[0] <= 26.0
|
||||
and notes(_bl[1]) == notes(_bl_old[1]))
|
||||
|
||||
# One hand-back to a loop at its rail at 600 s: the first warning comes when
|
||||
# the settle ends, 22 s and 4 cycles after it (SAFETY-119: 12 cycles, 40 s at
|
||||
# 5 s and 80 s at 10 s).
|
||||
_sl = {}
|
||||
for _t in (5.0, 10.0):
|
||||
rig.TICK = _t
|
||||
_sl[_t] = run(house(1, step=(600.0, -6000.0)), dur=900.0,
|
||||
extra={"peak_cap_w": 20000})
|
||||
rig.TICK = 1.0
|
||||
_sl1 = {t: [x for x in notes(r[1]) if x >= 600.0][:1] for t, r in _sl.items()}
|
||||
print(f" hand-back to a loop at its rail at 600 s, first notification: "
|
||||
f"5 s {_sl1[5.0]}, 10 s {_sl1[10.0]}")
|
||||
check("⭐ one hand-back (the loop has the wire from the cycle after the "
|
||||
"step): the first warning comes 25 s after it at a 5 s meter (the "
|
||||
"22 s span), 30 s after it at 10 s (4 cycles)",
|
||||
_sl1[5.0] == [630.0] and _sl1[10.0] == [640.0])
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. the rule at its boundaries")
|
||||
|
||||
|
||||
class _C:
|
||||
_settling = Controller._settling
|
||||
|
||||
def __init__(self):
|
||||
self._wire_lost, self._handback, self._hidden = False, None, ()
|
||||
self.loop_clamped = (False, "", False)
|
||||
|
||||
|
||||
def seq(c, steps):
|
||||
"""(owned, time[, the loop's bound that cycle]) -> does it count not."""
|
||||
out = []
|
||||
for st in steps:
|
||||
c.loop_clamped = (len(st) > 2 and st[2], "", False)
|
||||
out.append(c._settling(st[0], st[1]))
|
||||
return out
|
||||
|
||||
|
||||
SPAN = app_main.SHORT_SPAN_S
|
||||
K, M, ROLL = 4, 11, 900.0 # the chosen rule, written out on purpose
|
||||
_c = _C()
|
||||
check("from the start, owned every cycle: never settling (no hand-back)",
|
||||
seq(_c, [(True, float(i)) for i in range(30)]) == [False] * 30)
|
||||
_c = _C()
|
||||
_out = seq(_c, [(False, 0.0)] + [(True, 2.0 * i) for i in range(1, 15)])
|
||||
check(f"after another owner at 2 s: settling for 11 owned cycles, then not "
|
||||
f"(the span, {SPAN:.0f} s, decides)",
|
||||
_out == [False] + [True] * 11 + [False] * 3)
|
||||
_c = _C()
|
||||
_out = seq(_c, [(False, 0.0)] + [(True, 0.5 * i) for i in range(1, 60)])
|
||||
_end = _out.index(False, 1) if False in _out[1:] else -1
|
||||
check(f"at a 0.5 s meter the span decides: settling until {SPAN:.0f} s",
|
||||
_end == 1 + int(SPAN / 0.5) and all(_out[1:_end]))
|
||||
_set = {}
|
||||
for _tick in (10.0, 30.0, 60.0):
|
||||
_c = _C()
|
||||
_set[_tick] = seq(_c, [(False, 0.0)]
|
||||
+ [(True, _tick * i) for i in range(1, K + 2)])
|
||||
check(f"at 10, 30 and 60 s the cycles decide: settling for {K - 1} owned "
|
||||
f"cycles, then not (SAFETY-119: 11)",
|
||||
all(o == [False] + [True] * (K - 1) + [False] * 2
|
||||
for o in _set.values()))
|
||||
_c = _C()
|
||||
_out = seq(_c, [(False, 0.0), (True, 2.0), (True, 4.0), (False, 6.0),
|
||||
(True, 8.0), (True, 10.0)])
|
||||
check("losing the wire again restarts the settle",
|
||||
_out == [False, True, True, False, True, True]
|
||||
and _c._handback == (8.0, 2))
|
||||
_c = _C()
|
||||
check("no monotonic clock: the cycle count alone decides",
|
||||
seq(_c, [(False, None)] + [(True, None)] * (K + 1))
|
||||
== [False] + [True] * (K - 1) + [False] * 2)
|
||||
|
||||
# One hand-back, the loop's bound on every cycle: the settle (5 s) or the
|
||||
# budget (1 s) ends the hiding.
|
||||
_c = _C()
|
||||
_o5 = seq(_c, [(False, 0.0)] + [(True, 5.0 * i, True) for i in range(1, 9)])
|
||||
_c = _C()
|
||||
_o1 = seq(_c, [(False, 0.0)] + [(True, 1.0 * i, True) for i in range(1, 21)])
|
||||
check("one hand-back, the bound on every cycle: 5 hidden at 5 s (22 s), "
|
||||
f"then counted; {M} hidden at 1 s (the budget), then counted",
|
||||
_o5 == [False] + [True] * 5 + [False] * 3
|
||||
and _o1 == [False] + [True] * M + [False] * (20 - M))
|
||||
|
||||
|
||||
def turns(c, tick, lost, periods, first=0.0, bound=True):
|
||||
"""A hand-back, then `periods` x (1 owned cycle with the loop's bound,
|
||||
`lost` cycles away). Returns [(time, hidden)] of the owned cycles."""
|
||||
t, out = first, []
|
||||
for _ in range(lost):
|
||||
c._settling(False, t)
|
||||
t += tick
|
||||
for _ in range(periods):
|
||||
c.loop_clamped = (bound, "", False)
|
||||
out.append((t, c._settling(True, t)))
|
||||
t += tick
|
||||
for _ in range(lost):
|
||||
c._settling(False, t)
|
||||
t += tick
|
||||
return out, t
|
||||
|
||||
|
||||
# The rolling budget: the loop's bound is hidden on at most M cycles in any
|
||||
# ROLL seconds, across all hand-backs.
|
||||
_c = _C()
|
||||
_o, _ = turns(_c, 5.0, 3, 60)
|
||||
_hid = [t for t, h in _o if h]
|
||||
check(f"across hand-backs (one bound cycle per turn, 5 s): the first {M} "
|
||||
"are hidden, then every one counts",
|
||||
[h for _t, h in _o[:M + 5]] == [True] * M + [False] * 5)
|
||||
check(f"...never more than {M} hidden in any {ROLL:.0f} s, and they come "
|
||||
f"back as the oldest ages out (exactly {M} in the first {ROLL:.0f} s "
|
||||
"after the first)",
|
||||
all(sum(1 for x in _hid if t <= x < t + ROLL) <= M for t in _hid)
|
||||
and sum(1 for x in _hid if x < _hid[0] + ROLL) == M
|
||||
and any(x >= _hid[0] + ROLL for x in _hid))
|
||||
_c = _C()
|
||||
_c._hidden = (0.0,) * M
|
||||
_c._wire_lost, _c._handback = False, (ROLL - 30.0, 0)
|
||||
_c.loop_clamped = (True, "", False)
|
||||
_a = _c._settling(True, ROLL - 1.0)
|
||||
_c.loop_clamped = (True, "", False)
|
||||
_b = _c._settling(True, ROLL)
|
||||
check(f"the budget refills {ROLL:.0f} s after the oldest hidden cycle: "
|
||||
f"counted at {ROLL - 1:.0f} s, hidden again at {ROLL:.0f} s",
|
||||
_a is False and _b is True)
|
||||
_c = _C()
|
||||
_o1, _t = turns(_c, 2.0, 3, 20)
|
||||
for _ in range(12): # the wire away a whole settle
|
||||
_c._settling(False, _t)
|
||||
_t += 2.0
|
||||
_o2, _ = turns(_c, 2.0, 3, 5, first=_t)
|
||||
check("the budget is not per hand-back or per run: after a whole settle "
|
||||
"with the wire away, a new hand-back within the same "
|
||||
f"{ROLL:.0f} s hides nothing more",
|
||||
[h for _t2, h in _o1[:M]] == [True] * M
|
||||
and not any(h for _t2, h in _o2))
|
||||
_c = _C()
|
||||
_o1, _t = turns(_c, 2.0, 3, 20)
|
||||
_ = [_c._settling(True, _t + 2.0 * i) for i in range(13)]
|
||||
_c._settling(False, _t + 40.0)
|
||||
_c.loop_clamped = (True, "", False)
|
||||
check("the loop holding the wire a whole settle ends the window: the next "
|
||||
"hand-back hides nothing more while the budget is spent",
|
||||
_c._settling(True, _t + 42.0) is False)
|
||||
_c = _C()
|
||||
_c._wire_lost = True
|
||||
_t = 0.0
|
||||
for _ in range(M + 4): # an over-rating: always counts
|
||||
_c.loop_clamped = (True, "", True)
|
||||
_c._settling(True, _t)
|
||||
_c._wire_lost = True
|
||||
_t += 5.0
|
||||
_c._settling(False, _t)
|
||||
_c.loop_clamped = (True, "", False)
|
||||
check("an over-rating does not use up the budget",
|
||||
_c._settling(True, _t + 5.0) is True and len(_c._hidden) == 1)
|
||||
|
||||
# The budget is one number at every cycle length (SAFETY-119 scaled it).
|
||||
_bud = {}
|
||||
for _tick in (1.0, 2.0, 3.0, 5.0, 10.0, 15.0, 20.0, 30.0):
|
||||
_c = _C()
|
||||
_o, _ = turns(_c, _tick, 1, 14)
|
||||
_bud[_tick] = sum(1 for _t2, h in _o if h)
|
||||
_c = _C()
|
||||
_o60, _ = turns(_c, 60.0, 1, 14)
|
||||
print(f" hidden before the first counts, one bound cycle per turn: {_bud}; "
|
||||
f"at 60 s, turns of 2 cycles: {sum(1 for _t2, h in _o60 if h)} of 14 "
|
||||
"hidden (the budget never runs out: DECISIONS limit)")
|
||||
check(f"the budget does not follow the cycle length: {M} hidden at 1 to "
|
||||
"30 s, then counted",
|
||||
set(_bud.values()) == {M})
|
||||
|
||||
# No monotonic clock: the budget cannot refill, so a bound that stays is
|
||||
# counted after the budget however often the wire changes hands.
|
||||
_c = _C()
|
||||
_nc = []
|
||||
for _ in range(40):
|
||||
for _ in range(3):
|
||||
_c._settling(False, None)
|
||||
_c.loop_clamped = (True, "", False)
|
||||
_nc.append(_c._settling(True, None))
|
||||
check(f"no monotonic clock, repeated hand-backs: the budget holds ({M} "
|
||||
"hidden, then every bound counts)", _nc == [True] * M + [False] * (40 - M))
|
||||
|
||||
# An over-rating (a command over `max_w`) is a bug, never settled away.
|
||||
_ctl = _hb[1]
|
||||
_ctl.loop_clamped = (True, "x", True)
|
||||
_ctl._wire_lost, _ctl._handback, _ctl._hidden = True, None, ()
|
||||
|
||||
|
||||
class _Res:
|
||||
winner, bound_by, target_w = "loop", "", 0.0
|
||||
unit_bounds = {}
|
||||
|
||||
|
||||
class _D:
|
||||
held, target_w = False, 0.0
|
||||
|
||||
|
||||
_ctl._loop_decision, _ctl.allocation = _D(), None
|
||||
_ctl._loop_alarm_input(_Res())
|
||||
check("an over-rating on a hand-back cycle still counts (bug=True kept)",
|
||||
_ctl.loop_clamped == (True, "x", True) and _ctl._handback is not None)
|
||||
_ctl.loop_clamped = (True, "y", False)
|
||||
_ctl._loop_alarm_input(_Res())
|
||||
check("...while the integrator bound on the next cycle is not counted",
|
||||
_ctl.loop_clamped[0] is False)
|
||||
|
||||
# A stop ends the window but does not give the budget back (review F2, X3).
|
||||
_now = _ctl._now_mono
|
||||
_ctl._hidden = (_now,) * M
|
||||
_ctl._wire_lost, _ctl._handback = True, (1.0, 3)
|
||||
_ctl._loop_decision = None # stopped or blind
|
||||
_ctl._loop_alarm_input(_Res())
|
||||
_st, _kept = (_ctl._wire_lost, _ctl._handback), _ctl._hidden
|
||||
_ctl._settling(False, _now + 1.0)
|
||||
_ctl.loop_clamped = (True, "", False)
|
||||
_after = _ctl._settling(True, _now + 2.0)
|
||||
check("a stop clears the window (the next start is not a hand-back) and "
|
||||
f"keeps the budget: with {M} hidden, the bound after the next "
|
||||
"hand-back counts",
|
||||
_st == (False, None) and _kept == (_now,) * M and _after is False)
|
||||
print(f" after the stop: hidden kept {len(_kept)}, the next hand-back's "
|
||||
f"bound hidden {_after}")
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n6. the pinned digests of test_safety84 and test_safety87: only the "
|
||||
"log changes")
|
||||
# Those harnesses run at the 5 s tick with a battery that follows at once.
|
||||
rig._Hass.power, rig.TICK, s79._feed = _real_power, 5.0, _real_feed
|
||||
rig._Hass.number = _real_number
|
||||
import safety87_identity as s87 # noqa: E402
|
||||
|
||||
RUNS = dict({f"s84 {k}": (lambda v=v: s84.run(**v))
|
||||
for k, v in s84.SCENARIOS.items()},
|
||||
**{f"s87 {k}": (lambda v=v: s87_run(v))
|
||||
for k, v in s87.SCENARIOS.items()})
|
||||
|
||||
|
||||
def s87_run(v):
|
||||
v = dict(v)
|
||||
return s84.run(1, dur=s87.DUR, store=s87.store(v.pop("store", None)), **v)
|
||||
|
||||
|
||||
_moved, _gone = [], 0
|
||||
for k, fn in RUNS.items():
|
||||
new, old = fn()[0], no_fix(fn)[0]
|
||||
same = all(a[:-1] == b[:-1] and a[-1][:4] == b[-1][:4]
|
||||
for a, b in zip(new, old)) and len(new) == len(old)
|
||||
logs = {e for r in old for e in [r[-1][5]] if e} - {
|
||||
e for r in new for e in [r[-1][5]] if e}
|
||||
_gone += len(logs)
|
||||
if not same or any("OUTPUT_CLAMP episode" not in e for e in logs):
|
||||
_moved.append(k)
|
||||
print(f" {k}: commands and claims "
|
||||
f"{'the same' if same else 'DIFFER'}, log rows differ {len(logs)}, "
|
||||
f"digest {s84.digest(new)[:16]}")
|
||||
check("⭐ in all twelve, every command, loop field, claim, guard decision "
|
||||
"and phase is the same as without the fix; the only log rows that "
|
||||
"differ are OUTPUT_CLAMP rows (gone, or a lower count)",
|
||||
not _moved and _gone > 0)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"FAILED: {len(fails)} check(s)")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all SAFETY-119 checks passed")
|
||||
@@ -472,6 +472,10 @@ print("\n8. the N=1 and N=2 digests")
|
||||
# charge pin stands. n2_charge and _banked change only the loop's reason and
|
||||
# claim on 2 cycles; n2_charge_dear changes commands on 98 cycles, where A's
|
||||
# pin lifts (t=1800) and B no longer jumps 1390 W, and after the surplus step.
|
||||
# SAFETY-119 re-pinned all six: only the log. OUTPUT_CLAMP rows from the
|
||||
# loop's bound just after the guard hands back are gone; every command and
|
||||
# claim is the same (test_safety119.py part 6). SAFETY-121 re-pinned all six
|
||||
# again: only OUTPUT_CLAMP rows (test_safety119.py part 6).
|
||||
PINNED = {
|
||||
"n1_charge":
|
||||
"0ed25c856456b97acb07a39299a8cb7b1a762c4d0005b88a0823c03057a80be5",
|
||||
@@ -482,7 +486,7 @@ PINNED = {
|
||||
"n2_charge":
|
||||
"b16d692128f14bcce3d87b142d290e0d6b13724289680e44a05215c9eaa24a7c",
|
||||
"n2_charge_dear":
|
||||
"00a197d89a5dd1e8b3ae6a5d1af66329a4dc4850476569c5931c125fa4d9eaee",
|
||||
"aa2bbc677a8e2274b25ba1170de1b1635e08a3bd93105e2a38a9bb1575b5b6db",
|
||||
"n2_charge_banked":
|
||||
"a6973faea916e4c88ca1770047f94478346eeb1efa4889b154fad0747f91267f",
|
||||
}
|
||||
@@ -492,7 +496,8 @@ for k in PINNED:
|
||||
f"{_now[k][:16]}")
|
||||
check("⭐ at N=1 a charge in and out of a cheap window and under a banked "
|
||||
"month peak is byte-identical to 9d5b7d1 (commands, reasons and every "
|
||||
"claim), and N=2 outside a cheap window matches its SAFETY-85 pin",
|
||||
"claim; the log as re-pinned by SAFETY-121), and N=2 outside a cheap "
|
||||
"window matches its SAFETY-85 pin",
|
||||
all(_now[k] == PINNED[k] for k in ("n1_charge", "n1_charge_dear",
|
||||
"n1_charge_banked",
|
||||
"n2_charge_dear")))
|
||||
|
||||
@@ -518,13 +518,18 @@ check("...a stopped controller still says so: the peak alarm does not take "
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n9. N=1 is byte-identical to release/1.0 b8ddfd7")
|
||||
# SAFETY-119 re-pinned n1_crossing, n1_above and n1_charge_low: only the
|
||||
# log. The loop's bound just after a hand-back is not counted, so OUTPUT_CLAMP
|
||||
# rows are gone or count fewer; every command and claim is the same
|
||||
# (test_safety119.py part 6). SAFETY-121 re-pinned the same three again:
|
||||
# only OUTPUT_CLAMP rows (test_safety119.py part 6).
|
||||
PINNED = { # tools/safety87_identity.py on b8ddfd7
|
||||
"n1_under":
|
||||
"d2395ef8d74fb23607ad864d5a4bf62c03044bbd9367e873675d7ad10e8c169f",
|
||||
"n1_crossing":
|
||||
"97f7321017d9d34ee17a40dfd4abf73d915f25b9faef086a483d2b5cd124b198",
|
||||
"n1_above":
|
||||
"45726a285aa84f40542d56482652397c3c1e212ba4fe2009f6cc52f537ccad52",
|
||||
"9229b0f0e8addd1bd114461cc36c7f02e9734012b3b7e154d7747bd4cc69b773",
|
||||
"n1_under_banked":
|
||||
"a2876c7cc873bc4c974d2b4bd98e23bba2d007b84545587d2fe0b713b0a6249b",
|
||||
"n1_charge_low":
|
||||
@@ -536,8 +541,9 @@ _now = s87.digests()
|
||||
for k in PINNED:
|
||||
print(f" {k} {'same' if _now[k] == PINNED[k] else 'DIFFERS'}")
|
||||
check("⭐ every N=1 digest (claims, commands, the guard's decision and the "
|
||||
"log) equals b8ddfd7's, under, crossing and above the reserve, in a "
|
||||
"charge and a HOLD, and under a banked peak",
|
||||
"log) equals b8ddfd7's (the log as re-pinned by SAFETY-121), under, "
|
||||
"crossing and above the reserve, in a charge and a HOLD, and under a "
|
||||
"banked peak",
|
||||
_now == PINNED)
|
||||
|
||||
# ============================================================================ #
|
||||
|
||||
@@ -286,6 +286,22 @@ check("app/uitext.py falls back to English per key and reads nl-BE as nl",
|
||||
and uitext._merge({"a": {"b": 1, "c": 2}}, {"a": {"b": 3}})
|
||||
== {"a": {"b": 3, "c": 2}})
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n7. the dynamic contract choice says 15 minutes, never hourly (UX-44)")
|
||||
# Belgian day-ahead prices are per 15 minutes since 2025-10-01.
|
||||
HOURLY = re.compile(r"hourly|per hour|\bhour\b|per uur|\buur\b|uurprijs",
|
||||
re.I)
|
||||
_dyn = {"en": EN["choices"]["contract"]["dynamic"],
|
||||
"nl": NL["choices"]["contract"]["dynamic"]}
|
||||
check(f"the label reads 'Dynamic, every 15 minutes' / 'Dynamisch, per "
|
||||
f"kwartier' ({_dyn})",
|
||||
_dyn == {"en": "Dynamic, every 15 minutes",
|
||||
"nl": "Dynamisch, per kwartier"})
|
||||
check("neither language calls the dynamic contract hourly",
|
||||
not any(HOURLY.search(t) for t in _dyn.values()))
|
||||
check("mutation: the old labels are caught",
|
||||
all(HOURLY.search(t) for t in ("Dynamic, hourly", "Dynamisch, per uur")))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
|
||||
@@ -105,6 +105,13 @@ class _Hass:
|
||||
return self.power(u)
|
||||
return None
|
||||
|
||||
async def state(self, e):
|
||||
"""SAFETY-118: the peak forecast is read as a state object; every
|
||||
number this rig serves is in watts."""
|
||||
v = await self.number(e)
|
||||
return None if v is None else {
|
||||
"state": str(v), "attributes": {"unit_of_measurement": "W"}}
|
||||
|
||||
async def set_number(self, e, v):
|
||||
self.cmd[e[-1]] = float(v)
|
||||
return True
|
||||
|
||||
@@ -165,7 +165,7 @@ configuration:
|
||||
description: "The add-on aims this much below the peak limit, so a 15-minute period that ends a little high still stays under it."
|
||||
peak_forecast_entity:
|
||||
name: "Limits · 15-minute demand forecast entity (W)"
|
||||
description: "Optional. A forecast of this 15-minute period's average power. Leave it empty and there is no peak limit."
|
||||
description: "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."
|
||||
peak_soc_floor:
|
||||
name: "Limits · Peak reserve (%)"
|
||||
description: "Peak limiting will not draw the battery below this level, but ordinary use can. Derived value, leave it unchanged."
|
||||
|
||||
@@ -146,7 +146,7 @@ configuration:
|
||||
description: "De add-on mikt zoveel onder de piekgrens, zodat een kwartier dat iets hoger uitvalt er toch onder blijft."
|
||||
peak_forecast_entity:
|
||||
name: "Grenzen · Entiteit kwartierprognose (W)"
|
||||
description: "Optioneel. Een prognose van het gemiddelde vermogen in het lopende kwartier. Laat u dit leeg, dan is er geen piekgrens."
|
||||
description: "Optioneel. Een prognose van het gemiddelde vermogen in het lopende kwartier, in W of kW. Zonder vermogenseenheid wordt ze niet gebruikt. Laat u dit leeg, dan is er geen piekgrens."
|
||||
peak_soc_floor:
|
||||
name: "Grenzen · Piekreserve (%)"
|
||||
description: "De piekbeperking laat de batterij niet onder dit niveau zakken, gewoon verbruik wel. Afgeleide waarde, laat ze ongewijzigd."
|
||||
|
||||
Reference in New Issue
Block a user