Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b9680fc35 | ||
|
|
3ce18708b6 | ||
|
|
78acb558ff | ||
|
|
0f14c2a5de |
+1
-1
@@ -1,2 +1,2 @@
|
||||
# The admin/goodwe commit CI tests against. Bump: set to the full SHA of admin/goodwe release/1.0 HEAD (README.md, "CI and the infra pin").
|
||||
3b4ff84757f9c2c5b63a4b4a53e593ccaeafb847
|
||||
d637d0b1b13f1b863dc47e445e2ca4cf4a82fe10
|
||||
|
||||
+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: 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.
|
||||
⚠️ **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.
|
||||
|
||||
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 generation — are
|
||||
`ESU EMU ESA BPS BPU EMJ IJL` — platform 105, the AA55-era meter bus — 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 sending the inverter a grid reading over its RS485
|
||||
EMS port, and runs the monthly battery maintenance cycle the vendor box was doing.
|
||||
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.
|
||||
|
||||
```
|
||||
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; rebuilt 2026-10-17, content still v0.1.0 and outdated)
|
||||
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4; 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 | **supported** |
|
||||
| `ESU EMU ESA BPS BPU EMJ IJL` | 105 — ES/EM/BP, AA55-era meter bus | **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
|
||||
A "-20" or "G2" suffix means a different platform with a different meter
|
||||
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 generation)
|
||||
- GoodWe **ES / BP family** inverters (AA55 / RS485 meter-bus 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</td>
|
||||
<td><b>Platform 105</b> — ES / EM / BP, AA55-era meter bus</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
|
||||
A “-20” or “G2” suffix in the model name means a different platform with a different meter
|
||||
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 RS485 EMS port
|
||||
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The meter bus
|
||||
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 EMS-port wiring before touching it.</li>
|
||||
<li><b>Photograph</b> the meter-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 inverter's EMS port.</li>
|
||||
<li>Disconnect the vendor controller from the meter 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 EMS port, plus its own supply.</li>
|
||||
<li>Wire <b>A→A, B→B</b> to the meter 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</td>
|
||||
<td><b>Platform 105</b> — ES / EM / BP, AA55-era meter bus</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
|
||||
A “-20” or “G2” suffix in the model name means a different platform with a different meter
|
||||
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 RS485 EMS port
|
||||
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The meter bus
|
||||
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 EMS-port wiring before touching it.</li>
|
||||
<li><b>Photograph</b> the meter-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 inverter's EMS port.</li>
|
||||
<li>Disconnect the vendor controller from the meter 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 EMS port, plus its own supply.</li>
|
||||
<li>Wire <b>A→A, B→B</b> to the meter 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,441 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.19.0
|
||||
|
||||
**A minor bump: price trading behaves differently, plus a fix for a false warning, the Home price chart in 15-minute bars, more Dutch text and EMS-port wording, and no option changes.**
|
||||
No option was added, removed or renamed, and no default changed (86 options). It is a minor bump because price trading changes when the battery charges and gives power; every saved setting still starts.
|
||||
**Existing installs need no option change:** options that start 0.18.2 also
|
||||
start 0.19.0. The changes below apply with one inverter or two.
|
||||
|
||||
**Price trading skips periods that are over.** When the add-on trades on price, it now picks only from price periods that are not over yet. With battery modes off and 15-minute prices, the battery therefore trades for longer (about 1.5 to 2.5 times the minutes in a simulation of 16 real days), so it cycles more; with battery modes on, it no longer charges from the grid because of a cheap period that is over. Hourly and day/night sites change too: they no longer pause at the start of each hour.
|
||||
|
||||
Contents of this release, in merge order since 0.18.2 (`c67d0cd`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `243a756` | The docs, the add-on description and the field guide now say the add-on is wired to the inverter's EMS port, not a "meter bus". In Home Assistant the device's manufacturer text becomes "GoodWe (via RS485 EMS port)", and the device and its entities stay the same ([[DOC-8]], #201) |
|
||||
| `75f0804` | The price chart on the Home page now shows one bar for each 15-minute price, the price the add-on decides on, where it showed hour averages. Its cheap and dear colours now show where the add-on will charge or give power, instead of thirds of the price range ([[UX-45]], #202) |
|
||||
| `bee7431` | First half of the "not keeping up" fix, shipped with the next row and described there ([[SAFETY-119]], #203) |
|
||||
| `e077ae7` | The false "Battery is not keeping up" warning, and its Home Assistant notification, no longer appear when the peak guard steps in and then gives control back near the start of a 15-minute period; battery commands do not change. A real overload still warns at the start of each 15-minute period, up to about 25 s later with the default 5 s meter, but with an uneven meter of about 15 to 20 s and a small overload, it can stay quiet for up to an hour ([[SAFETY-121]], #204, with [[SAFETY-119]]) |
|
||||
| `90bc901` | On Dutch pages, the status rows, banners and settings notes now show Dutch text where they showed English, and so do the setup wizard's checks once [[UX-51]] is fixed. The settings label of the 15-minute demand forecast sensor no longer ends in "(W)", and English pages change only by that label ([[UX-46]], #205) |
|
||||
| `aa22806` | Price trading, and the check for it with battery modes on, now ignore price periods that have already ended, so the add-on no longer aims at a 15-minute period that is over and then does nothing. Hourly and day/night sites also change: there is no pause at the start of each hour, as the owner accepted ([[PRICE-02]], #206) |
|
||||
|
||||
### Known issues
|
||||
|
||||
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
|
||||
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
|
||||
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
|
||||
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]).
|
||||
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
|
||||
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
|
||||
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
|
||||
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
|
||||
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
|
||||
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
|
||||
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
|
||||
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
|
||||
- When the 15-minute demand forecast sensor counts the battery's own charge, the maintenance charge runs at about half the power the peak limit leaves, so it takes about twice as long. Seen on a site: about 1.2 kW instead of 2.5 kW, and the peak limit was kept ([[SAFETY-111]]).
|
||||
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
|
||||
- After a Home Assistant restart, control still waits until Home Assistant has finished starting, which can take several minutes, though the readings are already there ([[SAFETY-115]]).
|
||||
- While Home Assistant is not answering, the page can list inverters as refusing commands and sensors as missing, and send you to Developer Tools, though they are fine ([[UX-37]]).
|
||||
- If Home Assistant hangs or stops, the page keeps saying "Waiting for Home Assistant" and never turns into an alarm ([[SAFETY-113]]).
|
||||
- Right after Home Assistant has started, the page can say for about 4 s that an inverter cannot be controlled, and the first command can come late ([[SAFETY-114]]).
|
||||
- After a Home Assistant restart, the controller status sensor can still say "running" while the add-on cannot control. It was seen once, and the cause is not known ([[SAFETY-116]]).
|
||||
- Battery modes on: after a planned top-up from the grid, the Batteries screen can say "Enough sun is forecast" ([[UX-32]]).
|
||||
- Battery modes on: the forecast line "we count on" can show more sun than the plan counts, because the plan takes house use off first ([[UX-33]]).
|
||||
- Battery modes on: before a planned top-up, the Today list shows a smaller "gives" amount than the plan will use; it grows as the charge happens ([[UX-34]]).
|
||||
- Two inverters, battery modes on: while the other battery is in its maintenance charge, a battery's card can say it charges from the grid while it stays at 0 W ([[OPT-18]]).
|
||||
- Two inverters, battery modes on: while a battery refuses to charge in a cheap run, the action log on the page gets about 8 lines an hour for its retries, and the page hint says "waiting for a better price" ([[UX-38]]).
|
||||
- A battery in a safety stop, or one the add-on cannot reach, can still be started for a cycle, and its drain then waits about 12 h and ends in a failed-cycle notice. This comes from reading the code; it has not been seen on a site ([[MAINT-19]]).
|
||||
- On the Settings page, the rows of the solar forecast lists are labelled "Phase 1", "Phase 2" instead of "Sensor 1", "Sensor 2" ([[UX-27]]).
|
||||
- In the solar forecast list, a blank or repeated row makes "source 2 of 2" count differently from the rows on the Settings page ([[UX-28]]).
|
||||
- The solar forecast message says "has no reading" also when the sensor shows a number that is too old or in the wrong unit ([[UX-29]]).
|
||||
- If you use only the forecast lists, two Settings notes sit on the single-sensor field, and one of them tells you to set that field ([[UX-30]]).
|
||||
- After a restart during a price outage, the "No prices" message can say "yet" when prices did come in earlier ([[UX-39]]).
|
||||
- On the new settings page, you cannot see whether a price service token is set. Check the Configuration tab ([[UX-41]]).
|
||||
- On the classic settings page, the first click on Save after editing a field can do nothing. Click again ([[UX-42]]).
|
||||
- A steady overload on a 1 s meter can warn, and send the Home Assistant notification, 2 or 3 times at the start of each 15-minute period instead of once. The peak limit is still kept ([[SAFETY-122]]).
|
||||
- A fixed tariff is priced once per hour, so a price window that starts or ends at :15, :30 or :45 gets the wrong price for part of that hour. Windows that start and end on the hour are right ([[TARIFF-23]]).
|
||||
- Step 6 of the setup wizard stays empty because of an error in the page, in English and in Dutch ([[UX-51]]).
|
||||
- On Dutch pages, refusal texts, the last-event line under the Home buttons, the installer details and the trading status sentences are still English, and a few Dutch texts read badly, such as "1 metingen geweigerd" ([[UX-52]]).
|
||||
- Home can show a grid charge that the add-on will not take: the rest of a cheap run that the trade check refuses (battery modes on), or a charge after the last dear price period (battery modes off). The battery stays idle then ([[UX-53]]).
|
||||
- Some price texts say "hour" where the add-on acts on a 15-minute period, such as "cheapest hour" ([[UX-47]]).
|
||||
- After the add-on restarts, the Home price chart shows no price for the earlier part of today, and the setup check can say "12 of 24 hours", though the prices are known ([[UX-48]]).
|
||||
- With battery modes on and only batteries on "Leftover solar", the Home price chart can mark a cheap period where no battery charges ([[UX-49]]).
|
||||
- On a phone, the bars of the Home price chart are too thin to tap one 15-minute period. The arrow keys work ([[UX-50]]).
|
||||
|
||||
### If you are crossing from a version older than 0.10.0
|
||||
|
||||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||||
bare options (`jq '.data.options'`) first.
|
||||
|
||||
## 0.18.2
|
||||
|
||||
**A patch bump: one fix to how the 15-minute demand forecast sensor is read, and no option changes.**
|
||||
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
|
||||
existing option.** No option was removed or renamed, and no default of an
|
||||
existing option changed (83 options -> 86). **Existing installs need no option
|
||||
change:** options that start 0.17.0 also start 0.18.0, and the new options are
|
||||
off or empty until you use them. The changes below apply with one inverter or
|
||||
two, unless a line says "Two inverters".
|
||||
|
||||
New options, and what they do if you leave them alone:
|
||||
|
||||
| option | default | left alone |
|
||||
|---|---|---|
|
||||
| `pv_forecast_entities`, `pv_forecast_tomorrow_entities` | `[]` | the single forecast sensors work as before ([[OPT-17]]) |
|
||||
| `price_service_token` | empty | not used; it is read only with `price_provider: price_service` ([[PRICE-01]]) |
|
||||
|
||||
`price_provider` also accepts a new value, `price_service`. Its default is
|
||||
unchanged, so the price source stays as it is.
|
||||
|
||||
**Price service.** The new price source `price_service` reads prices from the
|
||||
add-on's price server, with the token you were given. If it does not answer,
|
||||
prices come from energy-charts.info, then from the prices the add-on saved.
|
||||
Sites on the Home Assistant price sensors or a fixed tariff need no change. To
|
||||
move a site over, see `DOCS.md`, "Moving a site from the Home Assistant price
|
||||
sensors to `price_service`".
|
||||
|
||||
Contents of this release, in merge order since 0.17.0 (`e36565f`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `3050fa5` | The solar forecast can come from several sensors, added up, for example one per inverter. If one of them has no reading, that day's forecast is not used, and the status says which one. The single sensor still works ([[OPT-17]]) |
|
||||
| `e3e4ba6` | While Home Assistant is still starting, the page now says "Waiting for Home Assistant" and the log says the add-on waits, instead of looking like a fault. Control itself is unchanged: it starts when Home Assistant answers ([[SAFETY-112]]) |
|
||||
| `79dd077` | Battery modes on only: a charge in a cheap run no longer stops at the start of a new 15-minute period, and with two inverters a battery that refused to charge is tried again every 15 min. In one test, the export at the end of a charge was 48 W where the code before this fix gave 746 W ([[OPT-15]]) |
|
||||
| `0ce7d35` | Two inverters: each battery card has Start cycle and Stop cycle buttons, so you can start or stop the cycle on the battery you choose. Only one battery can cycle at a time, and a second start is refused with a short message ([[MAINT-11]]) |
|
||||
| `bf9019e` | New price source `price_service`. If it fails, prices come from energy-charts.info, then from saved prices. With no price at all and the monthly cycle on, a notice shows and the cycle waits ([[PRICE-01]]) |
|
||||
| `004f859` | Two inverters, battery modes on: the 15-minute retry of a battery that refused to charge now asks that battery for 200 W, and no longer puts the rest of the charge on another battery. In the test, without this fix, a battery in its maintenance charge took about 2,300 W for about 34 s at each retry ([[OPT-20]]) |
|
||||
|
||||
### 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 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.17.0
|
||||
|
||||
**A minor bump: changed behaviour, and no option changes.** No option was
|
||||
added, removed or renamed, and no default changed (83 options). Options you
|
||||
saved work as before. The changes below apply with one inverter or two, unless
|
||||
a line says "Two inverters".
|
||||
|
||||
**Maintenance with no battery level.** When the battery level is missing,
|
||||
battery maintenance now ends on its own time limits instead of waiting for the
|
||||
level to come back (MAINT-14, MAINT-15). You do not need to change any option.
|
||||
|
||||
Contents of this release, in merge order since 0.16.0 (`451729c`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `b527e99` | Two inverters: after a drain ends, the other battery no longer swings on old readings. In the test, it held steady for 23 s where 0.16.0 moved it by 720 W ([[SAFETY-103]]) |
|
||||
| `8a17711` | A full battery that stops at 98 or 99 % no longer counts as a failed charge. After 60 min with no new high, the charge counts as done; a charge that truly fails now waits 7 days and shows one notice ([[MAINT-13]]) |
|
||||
| `bf3ac79` | With no battery level, a maintenance charge still ends at its time limit and a drain waits at 0 W. The log no longer repeats a line every second ([[MAINT-14]]) |
|
||||
| `db9e231` | One inverter: when maintenance charges a battery that is already full, the log no longer repeats "The battery refuses to give power" every second. In the test, 1 line in 16 min where the code before this fix wrote about 1,700 in 15 min; the power does not change ([[SAFETY-107]]) |
|
||||
| `580c200` | The balancing hold after a charge now ends on time when the battery level is missing. Before, it waited until the level came back ([[MAINT-15]]) |
|
||||
|
||||
### Known issues
|
||||
|
||||
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
|
||||
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
|
||||
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
|
||||
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]).
|
||||
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
|
||||
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
|
||||
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
|
||||
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
|
||||
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
|
||||
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
|
||||
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
|
||||
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
|
||||
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
|
||||
|
||||
### If you are crossing from a version older than 0.10.0
|
||||
|
||||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||||
bare options (`jq '.data.options'`) first.
|
||||
|
||||
## 0.16.0
|
||||
|
||||
**A minor bump: changed behaviour, no option added or removed, and one default
|
||||
and its meaning changed.** There are still 83 options. `maintenance_start_hour`
|
||||
used to be read as UTC and its default was 10. It is now a local hour (in Home
|
||||
Assistant's time zone) and its default is 19. The other changes below apply
|
||||
with one inverter or two, unless a line says "Two inverters".
|
||||
|
||||
**Before you update (MAINT-10).** A saved `maintenance_start_hour` is kept, and
|
||||
it now means local time. If you set it in UTC to get an evening start (for
|
||||
example 17 for 19:00 in summer), set it back to the local hour (19). A saved
|
||||
10 now starts at 10:00, not 12:00 in summer. The new default 19 only reaches
|
||||
installs that have no hour saved.
|
||||
|
||||
Contents of this release, in merge order since 0.15.0 (`3ed3302`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `7ef7347` | After a gap in the P1 meter, the peak guard counts the missed time as high use and keeps the 15-minute period near the limit; before, it was blind until the next period. In the test, a 5 min meter silence at one inverter ended the period at 3.3 kW, against 4.8 kW on 0.15.0 ([[SAFETY-90]]) |
|
||||
| `1bf6b7f` | The maintenance start hour is now a local hour, and its default is 19 (was 10, read as UTC). The log says which time zone it used; with two inverters, both use that hour and still cycle one at a time ([[MAINT-10]]) |
|
||||
| `b2f5573` | Two inverters: when one battery's link drops, the other takes over in small steps, and control no longer stalls. The battery that comes back rejoins within 2 s, without a false "battery refuses" ([[SAFETY-96]]) |
|
||||
| `340c7ee` | Two inverters: when a drain ends (done, switched Off, timeout, lost link), the other battery covers the house right away, without jumping to 1.6 kW, and then moves in small steps. It no longer gives into a solar surplus ([[SAFETY-95]]) |
|
||||
|
||||
### Known issues
|
||||
|
||||
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
|
||||
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
|
||||
- A battery whose reading at rest is beyond about ±170 W is still not caught when it refuses, as in 0.14.0 ([[SAFETY-97]]).
|
||||
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]).
|
||||
- A charge to 100 % can time out at 98 %. The add-on then counts the cycle as failed and drains the battery again the next day ([[MAINT-13]]).
|
||||
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
|
||||
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
|
||||
- Two inverters: after a drain ends, the other battery can swing by about 800 W for about 5 s, because some readings are old ([[SAFETY-103]]).
|
||||
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
|
||||
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
|
||||
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
|
||||
|
||||
### If you are crossing from a version older than 0.10.0
|
||||
|
||||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||||
bare options (`jq '.data.options'`) first.
|
||||
|
||||
## 0.15.0
|
||||
|
||||
**A minor bump: changed behaviour, and no option changes.** No option was
|
||||
added, removed or renamed, and no default changed (83 options). Options that
|
||||
start 0.14.0 also start 0.15.0. The changes below apply with one inverter and
|
||||
with two.
|
||||
|
||||
The add-on now learns and saves, per battery, what its power reading shows
|
||||
at rest (`unit/<name>/idle_w`). It learns it after about two quiet minutes and
|
||||
uses 0 W until then, as before. There is nothing to set.
|
||||
|
||||
Contents of this release, in merge order since 0.14.0 (`02ed120`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `cf705a9` | No more false "battery power reading stopped changing" errors at a new command after a rest, or at a change of phase. Each one paused control for a few seconds (several a day on a live site). A healthy battery that lags or turns around is no longer held as refusing for up to 15 min. With two inverters, a refusing battery whose reading at rest is about −100 W is now caught too. The rest value used is limited to ±75 W ([[SAFETY-94]], [[SAFETY-97]], also fixes [[SAFETY-58]]) |
|
||||
| `3129dae` | CI tests against a newer `admin/goodwe` (CI only) ([[CI-14]]) |
|
||||
|
||||
### Known issues
|
||||
|
||||
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
|
||||
- After a gap in the P1 meter, the peak guard is blind for the rest of that quarter hour ([[SAFETY-90]]).
|
||||
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
|
||||
- A battery whose reading at rest is beyond about ±170 W is still not caught when it refuses, as in 0.14.0 ([[SAFETY-97]]).
|
||||
- Two inverters: if one battery's drain stops early (e.g. switched Off), the other battery can jump to about 1.6 kW at once, and the surplus goes to the grid ([[SAFETY-95]]).
|
||||
- Two inverters: after one battery's link drops, control can stall until a safety stop ([[SAFETY-96]]).
|
||||
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
|
||||
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
|
||||
|
||||
### If you are crossing from a version older than 0.10.0
|
||||
|
||||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||||
bare options (`jq '.data.options'`) first.
|
||||
|
||||
## 0.14.0
|
||||
|
||||
**A minor bump: new behaviour with two inverters, and no option changes.**
|
||||
No option was added, removed or renamed, and no default changed (83
|
||||
options). Options that start 0.13.0 also start 0.14.0. With one inverter,
|
||||
control is the same as in 0.13.0.
|
||||
|
||||
⭐ **Two inverters are now supported** for the monthly maintenance (drain,
|
||||
charge, hold) and the peak guard. To add a second inverter, follow
|
||||
`docs/commissioning-b.md` in the `admin/goodwe` repo. Its Stage 5, where the
|
||||
add-on takes control of it, needs 0.14.0. Read the known issues below first:
|
||||
three of them are about two inverters.
|
||||
|
||||
Contents of this release, in merge order since 0.13.0 (`8c438ab`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `f3e409c` | Two inverters: a battery at or under its peak reserve gives last, after the others give their full power. It is freed again above reserve + 1 point (e.g. above 21 % for a 20 % reserve). At a quarter's edge the peak guard no longer drops out for a second ([[SAFETY-91]]) |
|
||||
| `858f77c` | Two inverters: when a maintenance charge ends, for any reason, the other battery no longer starts charging from the grid; control hands over in small steps ([[SAFETY-93]]) |
|
||||
| `e51e3e0` | CI tests against a newer `admin/goodwe` (CI only) ([[CI-13]]) |
|
||||
|
||||
### Known issues
|
||||
|
||||
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
|
||||
- A healthy battery that turns from charging to giving can be held at 0 W for up to 15 min ([[SAFETY-94]]).
|
||||
- After a gap in the P1 meter, the peak guard is blind for the rest of that quarter hour ([[SAFETY-90]]).
|
||||
- Two inverters: if one battery's drain stops early (e.g. switched Off), the other battery can jump to about 1.6 kW at once, and the surplus goes to the grid ([[SAFETY-95]]).
|
||||
- Two inverters: after one battery's link drops, control can stall until a safety stop ([[SAFETY-96]]).
|
||||
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
|
||||
|
||||
### If you are crossing from a version older than 0.10.0
|
||||
|
||||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||||
bare options (`jq '.data.options'`) first.
|
||||
|
||||
## 0.13.0
|
||||
|
||||
**A minor bump: new behaviour, a new add-on page (Home, Settings, Batteries,
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# MAINT-10 — the maintenance start hour is local time, default 19:00
|
||||
|
||||
Owner decision 2026-09-29 (option A). `maintenance_start_hour` was compared
|
||||
with the UTC hour, so the shipped 10 started at 12:00 in a Belgian summer and
|
||||
11:00 in winter. It is now read on the site's local clock, and the default moves
|
||||
to 19 so the drain runs into the evening and night rather than the solar day.
|
||||
|
||||
Evening drains meet higher house loads; SAFETY-83 (merged first) lets the
|
||||
peak guard outrank the drain.
|
||||
|
||||
## 1. Which zone
|
||||
|
||||
Home Assistant's `time_zone` (`GET /core/api/config`, 3 s budget) at startup,
|
||||
else the container's `TZ`, else UTC. One log line says which (WARNING only for
|
||||
UTC). Resolved once per start: a zone change in HA takes effect on the next
|
||||
add-on restart. HA can still be starting when the add-on starts (502 from the
|
||||
proxy, 2026-10-05); then `TZ` holds. HA pushes its zone to the Supervisor
|
||||
(hassio `push_config`), which stores it and sets it as the add-on's `TZ`
|
||||
(`docker/app.py`, Supervisor 2026.08.0), so the hour stays right. Only a `TZ` of
|
||||
UTC there logs a WARNING asking for a restart. No later re-resolve: the fallback
|
||||
already is HA's zone, so the rare UTC case gets a warning rather than more code.
|
||||
The add-on image has tzdata (checked in `amd64-base:3.20`); the CI image does
|
||||
not, so the tests grade DST on `tariff._EUTimezone` too.
|
||||
|
||||
## 2. DST
|
||||
|
||||
`maintenance.in_start_slot()` works from UTC instants only, so it does not
|
||||
depend on how a tzinfo resolves a missing or doubled wall time:
|
||||
|
||||
- normally the slot is the local hour `start_hour`;
|
||||
- clocks forward and the hour does not exist: the first hour after the jump;
|
||||
- clocks back and the hour happens twice: the first pass only. Without this an
|
||||
aborted start (90 min cooldown) would start again in the 120 min repeated hour.
|
||||
|
||||
Every day has exactly one 60 min slot for every hour 0-23: checked against
|
||||
zoneinfo for Europe/Brussels, America/New_York, America/Santiago (midnight
|
||||
transitions), Asia/Kathmandu (+05:45) and Australia/Lord_Howe (30 min DST)
|
||||
over all of 2026.
|
||||
|
||||
## 3. Existing installs
|
||||
|
||||
The Supervisor merges the shipped defaults under the STORED options, and saving
|
||||
the settings stores every key (the reference install's blob has all 76). So
|
||||
almost every existing install keeps its stored 10, which now means 10:00 local
|
||||
instead of 12:00 (summer) or 11:00 (winter). Only a blob without the key gets 19.
|
||||
The add-on does not rewrite a stored option: a silent migration would override
|
||||
an operator's choice with no way to tell it apart from the old default. A site
|
||||
that set an earlier hour to make up for UTC (17 for 19:00 summer) must set it
|
||||
back to 19 after the upgrade, or it starts two hours early. DOCS and the release
|
||||
note (drafted on the PR, for the version bump) say this.
|
||||
`tools/supervisor_shape_probe.py` §I measures this on Supervisor 2026.08.0.
|
||||
|
||||
## 4. Two inverters
|
||||
|
||||
`Controller` hands the same zone to every pack's `Maintenance`, so every pack's
|
||||
`wants_start()` reads the local hour. `arbitrate()` is unchanged: one pack
|
||||
cycles at a time, and the one that lost the slot starts in the first start
|
||||
slot after the other is done (a later day: a cycle outlasts its start hour). On the day the clocks go back, a pack freed during the second pass of
|
||||
a repeated hour does not start there. `test_maint10.py` part 10.
|
||||
|
||||
## 5. What does not change
|
||||
|
||||
Only the start decision (`Maintenance.wants_start`) and the Home screen's shown
|
||||
start time read the zone. `test_maint10.py` parts 9 and 10 run the real
|
||||
Controller at N=1 and N=2 with start hour 11 UTC against 13 Europe/Brussels (the
|
||||
same instant): every command, claim, reason and log line is identical. A
|
||||
`Maintenance` built without a zone reads UTC, as before.
|
||||
@@ -1,37 +0,0 @@
|
||||
# MAINT-11 - start or stop the maintenance cycle per battery
|
||||
|
||||
**Status: for review.** Tests: `test_maint11.py`. No option added.
|
||||
|
||||
**The fault.** At two or more batteries the page had one "start a battery cycle"
|
||||
button, and `_maint_button()` chose the battery. An operator could not cycle a
|
||||
chosen one (inverter B, new after storage, needed its first cycle).
|
||||
|
||||
## Rule
|
||||
|
||||
1. `maint_start` and `maint_abort` take an optional `unit`. With no unit they
|
||||
run the old code unchanged. The Batteries screen sends the unit from a
|
||||
per-battery Start cycle and Stop cycle button, at two or more batteries only.
|
||||
2. **A start while any battery holds the maintenance slot is refused, not
|
||||
queued.** The message names the battery that is cycling. One slot per site
|
||||
stays the rule, and the scheduler is untouched.
|
||||
3. Also refused, with a plain message: an unknown unit, a start on a battery
|
||||
that is switched off (battery modes on), and a stop on a battery that is not
|
||||
cycling. Nothing changes on a refusal.
|
||||
4. A stop on the cycling battery is today's abort: the cycle ends, then every
|
||||
inverter goes to 0 W.
|
||||
|
||||
## Why refuse and not queue
|
||||
|
||||
A queued start is a cycle that begins hours later, unseen, after the operator
|
||||
has moved on. A refusal is immediate and says why. The scheduler already
|
||||
queues by waiting time, so nothing is lost.
|
||||
|
||||
## What this does not cover
|
||||
|
||||
- Today's bare button never refused a battery that is refusing to give power
|
||||
(SAFETY-73); its drain waits. The per-battery start does the same.
|
||||
- A start on the battery that is already cycling is refused as "busy", where
|
||||
the bare button restarts its drain. The bare button is unchanged.
|
||||
- At one battery, a request that names the battery uses the new path, so a
|
||||
second start is refused instead of restarting the drain. Nothing sends a name
|
||||
at one battery, and the bare request is unchanged.
|
||||
@@ -1,109 +0,0 @@
|
||||
# MAINT-13 - a full pack that stops at 98-99 % is not a failed charge
|
||||
|
||||
**Status: for review.** Tests: `test_maint13.py`. No option added; every number
|
||||
is a constant in `app/maintenance.py`.
|
||||
|
||||
**What happened (live, B).** The drain ended 2026-10-04 18:24 UTC and the charge
|
||||
began. It ran in pieces behind the price gate, reached 99 % at 2026-10-06 04:00,
|
||||
fell back to 98 % at 04:15 while the loop still asked about -1370 W, and never
|
||||
reached `maintenance_soc_target` 100. At 06:24 (2160 min = 36 h of wall clock
|
||||
after the charge began) it aborted "charge stuck at 98 %". `last_completed` was
|
||||
not set, so B was still due and would have drained again from 98 % to 11 % at the
|
||||
next start hour, with no notice. Three faults: a pack may never report 100 %
|
||||
while the BMS balances; a failed cycle came back the next day; and the 36 h
|
||||
counted hours the price gate kept the pack waiting.
|
||||
|
||||
## 1. The charge is complete when
|
||||
|
||||
1. SoC >= target (as before), or
|
||||
2. SoC >= target - 3 (`CHARGE_NEAR_FULL_PCT`) and the SoC has not beaten its
|
||||
best for 60 min (`CHARGE_STALL_MIN`) of allowed charging, or
|
||||
3. the charging time (`CHARGE_TIMEOUT_MIN`, 2160 min) runs out at SoC >= target - 3.
|
||||
|
||||
Then the usual HOLD follows and `last_completed` is set after it. Each of 2 and
|
||||
3 logs one plain line ("the battery takes no more charge at 98 %, so the charge
|
||||
counts as done", "charge time is up at 98 %, close enough to full ...").
|
||||
|
||||
**Why "SoC has not beaten its best", and not asked/delivered watts.** SoC is the
|
||||
one signal the live record gives us (99 at 04:00, 98 at 04:15); the asked power
|
||||
was -1370 W but no delivered figure was kept, so a watt threshold (X, Y) would be
|
||||
invented. SoC also covers the case where power flows but nothing is stored.
|
||||
**Why 3 points.** The card's band; at target 100 it is 97, and the live pack sat
|
||||
at 98-99. **Why 60 min.** A judgement, not a measurement: we have no per-minute record of
|
||||
the pack taking charge near full. The one live fact is 2 h 24 min (04:00 to
|
||||
06:24) without a new high, so 60 min is well inside it and long enough that a
|
||||
pack still creeping up (one new high within the hour) keeps resetting the wait.
|
||||
On the live run the rule fires after 60 gate-open minutes past the 99 %: 05:00
|
||||
UTC at the earliest, before 06:24 if the gate was open. If the pack turns out to
|
||||
step up more slowly than that, raise the constant. Falling back by one point
|
||||
does not reset the wait; only a new best does.
|
||||
|
||||
## 2. A failed cycle backs off 7 days
|
||||
|
||||
A drain stuck 12 h or a charge that times out below target - 3 is a failure
|
||||
(`FAIL_BACKOFF_DAYS` = 7). The machine goes IDLE as before, `last_failed` is
|
||||
stored, and `wants_start()` is false until 7 days later. The operator's forced
|
||||
start ignores it; a cycle that completes after the failure ends it. An operator
|
||||
abort, an interlock stop and a switched-off pack are not failures.
|
||||
|
||||
**Why 7 days, not the 28 day interval.** A judgement: a failure may have a
|
||||
short cause (several dull days with no cheap hours, a pack switched off), and
|
||||
waiting the full interval leaves the coulomb counter uncalibrated for a month
|
||||
more. A week is long enough that a bad pack costs one drain a week, with one
|
||||
notice a week, not one a day. **Cost.** A truly faulty pack still drains once a week until someone looks;
|
||||
the notice says so each time.
|
||||
|
||||
**Notice, once.** The tick that fails carries `failed`; the Controller sends one
|
||||
persistent notification "Battery cycle did not finish" (EN, with the Dutch
|
||||
below it; per pack at N=2). It is not repeated, and Home Assistant restarts
|
||||
wipe it, so the status page shows a "Maintenance paused" row with the date for
|
||||
as long as the wait lasts. The operator may also see the log lines.
|
||||
|
||||
## 3. The timeout counts only allowed time
|
||||
|
||||
`Controller._charge_gate_open()` says whether the pack may charge now. It is
|
||||
true when no price gate is set up (no price entities, no fixed tariff: nothing
|
||||
holds the charge back, and `cheap` is always false there), in a cheap window, or
|
||||
while the pack is really taking charge (battery power at or below -50 W, which is
|
||||
sun outside cheap hours). It is false only when a gate exists, is shut, and the
|
||||
pack is not charging; a pack asked for charge that delivers nothing then does not
|
||||
count, which is the case the clock cannot judge. A site with a price provider
|
||||
(TARIFF-11) and no fixed tariff counts as having no gate: the provider's plan
|
||||
reaches only the Tariff row, never `cheap`, so no cheap window ever opens a grid
|
||||
top-up there and only the sun charges the pack, as on a site with no tariff. Its
|
||||
time counts in full. When a provider plan starts to drive `cheap`,
|
||||
`_price_gated` must become true for it. Whether a fixed tariff is set up is
|
||||
worked out once an hour, not on every tick. Only open ticks add to `open_min`; one tick
|
||||
adds at most 5 min, so a gap (add-on down) adds nothing. The stall wait pauses
|
||||
while the gate is closed. The accounting is written to the state file at most
|
||||
every 5 min (`charge_track`); a restart loses at most that. A state file from
|
||||
before this change, or with no record for the current charge, starts from the
|
||||
wall-clock minutes so far, which is the old behaviour.
|
||||
|
||||
**Backstop.** A gated site whose gate never opens (no cheap hours, no sun) would never time
|
||||
out, so a charge also ends after 7 days of wall clock (`CHARGE_WALL_TIMEOUT_MIN`):
|
||||
complete at >= target - 3, a failure below.
|
||||
|
||||
**MAINT-12.** This is the smallest change that makes the timeout honest: one
|
||||
boolean in, one counter. MAINT-12's block plan can replace the boolean (the
|
||||
minutes it plans are the allowed ones) without touching the rest. Two things it
|
||||
must keep: the back-off is in `wants_start()`, so any new entry point (such as
|
||||
`price_forecast.begin_phase`, which is not wired today) must go through it or
|
||||
check `backoff_until()`; and `gate_open` must stay "the pack is allowed to
|
||||
charge now". The peak guard pausing the charge shaping is not seen as a closed
|
||||
gate: that time still counts.
|
||||
|
||||
## 4. What does not change, and what this does not cover
|
||||
|
||||
- The drain, the HOLD, the held-battery rules and `arbitrate()` are unchanged. A
|
||||
backed-off pack does not want a start, so at N=2 the other pack may take the
|
||||
slot. One pack at a time at N=1 and N=2.
|
||||
- `last_failed` is a maintenance key and moves with the others when a second
|
||||
inverter is added (`MAINT_UNIT_KEYS`). The charge record is not moved; it
|
||||
restarts from the wall-clock minutes.
|
||||
- It does not find out why a pack stopped. A pack that stops at 96 % and never
|
||||
gets near is still a failure.
|
||||
- The Dutch text is only in the notification. The status row and the log lines
|
||||
are English, like the rest of the status page.
|
||||
- It does not change the target: a site that wants a different stop point sets
|
||||
`maintenance_soc_target`.
|
||||
@@ -1,68 +0,0 @@
|
||||
# MAINT-14 - no SoC reading in a maintenance cycle
|
||||
|
||||
**Status: for review.** Tests: `test_maint14.py`. No option added; the one new
|
||||
number is a constant in `app/maintenance.py`.
|
||||
|
||||
**The fault.** `Maintenance.tick()` returned early when the SoC was `None`,
|
||||
before any timeout check. (1) A charge on a pack whose SoC reading was lost
|
||||
never ended: no timeout, no back-off, no notice, and the 7-day backstop of
|
||||
MAINT-13 sat behind the same return. (2) A drain kept commanding `discharge_w`
|
||||
with an unknown level, which breaks the TEL-08 rule that an unknown level never
|
||||
allows discharge.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Charge, no SoC.** The charge timeout (`CHARGE_TIMEOUT_MIN`, gate-open
|
||||
minutes) and the 7-day wall backstop still run. When one fires: if the last
|
||||
SoC seen was >= target - 3 the charge counts as done (the MAINT-13 "close
|
||||
enough to full" rule, HOLD follows); otherwise it fails: MAINT-13 back-off
|
||||
and its one notification. If no SoC was ever seen, it fails.
|
||||
2. **Drain, no SoC.** DRAIN holds at 0 W and owns the setpoint, exactly as HOLD
|
||||
does. The 12 h drain timeout still runs and fails as before.
|
||||
3. **SoC comes back.** Nothing is recorded; the drain continues at the drain
|
||||
rate, the charge carries on, and the stall wait of MAINT-13 restarts from
|
||||
what it was: it paused while the reading was missing, so a gap never counts
|
||||
as "no more charge".
|
||||
4. **HOLD** is unchanged (0 W).
|
||||
5. **Log.** One line when a drain starts holding for this reason ("the battery
|
||||
level is unknown, so the drain holds at 0 W until it is back"), and one line
|
||||
when the level has been missing for `NOSOC_NOTE_MIN` = 15 min. Both are said
|
||||
once per episode; the old line that repeated every tick is gone.
|
||||
|
||||
## Why
|
||||
|
||||
- **"Last seen", not "best seen".** A pack that was at 99 and then dropped to 50
|
||||
before the reading went is not close to full. Only the last reading counts.
|
||||
It is saved with the charge record (`charge_track.last`), and saved at once
|
||||
when the reading first goes missing, so a restart mid-gap still knows it
|
||||
(within the usual 5 min save interval otherwise).
|
||||
- **Failure when nothing was seen.** With no level we cannot call a pack full.
|
||||
The failure costs a 7-day wait and one notice; calling it done costs a
|
||||
missed balance and a month-long false "complete". The first is the honest one.
|
||||
- **Timeouts count the same minutes as with a reading.** Gate-open charging
|
||||
minutes only; the wall backstop for a gate that never opens.
|
||||
- **15 min.** A judgement. A poll glitch of a few minutes should not write a
|
||||
line; a reading lost for a quarter of an hour is worth one. Raise it if the
|
||||
action log shows noise.
|
||||
- **0 W, not "stop the phase".** Aborting a drain on a short gap would throw
|
||||
away the part already drained. Holding at 0 W loses nothing and the 12 h
|
||||
timeout is the limit.
|
||||
|
||||
## Cost
|
||||
|
||||
- A drain that loses its reading stops draining, and the 12 h clock keeps
|
||||
running. A gap of many hours can therefore fail a drain that was working. The
|
||||
back-off then applies (7 days).
|
||||
- A charge whose reading is lost early (last SoC below target - 3) fails at the
|
||||
timeout, even if the pack was in fact filling. The same 7-day wait applies.
|
||||
|
||||
## What this does not cover
|
||||
|
||||
- **HOLD with no SoC**: closed by MAINT-15 (`MAINT-15-hold-ends-without-soc.md`).
|
||||
- It does not tell the operator why the reading is missing (sensor, register,
|
||||
link). It does not reuse the stale-input gate; it is the cycle's own rule.
|
||||
- The charge itself is still driven by the control loop, which has its own
|
||||
handling of a missing SoC (TEL-08 for the loop is a separate card).
|
||||
- A restart loses at most 5 min of accounting, as in MAINT-13. The "unknown for
|
||||
long" line may be said again after a restart: the episode start is in memory.
|
||||
- Text added is two log lines, English only, like the other log lines.
|
||||
@@ -1,47 +0,0 @@
|
||||
# MAINT-15 - the balancing hold ends on time with no SoC reading
|
||||
|
||||
**Status: for review.** Tests: `test_maint15.py`. No option added.
|
||||
|
||||
**The fault.** `step()` sent every tick with no SoC to `_no_soc()`, and for
|
||||
HOLD that returned 0 W without the time check. A hold that lost its reading
|
||||
never ended: `last_completed` was never written and the next due date never
|
||||
moved. Harmless for power (0 W), but the cycle was stuck. This was left open
|
||||
in MAINT-14.
|
||||
|
||||
## Rule
|
||||
|
||||
1. **HOLD ends at `hold_min` whether or not there is a reading.** One helper,
|
||||
`Maintenance._hold()`, holds the time check and is used by both paths. When
|
||||
the time is up it writes `last_completed`, goes IDLE and logs the same
|
||||
"cycle COMPLETE" line. Before the time is up it commands 0 W as before.
|
||||
2. **Text.** The 15-min "no battery level" line in HOLD now reads "The hold
|
||||
still ends at its normal time." Still once per episode.
|
||||
|
||||
## Why
|
||||
|
||||
HOLD is reached only after the charge counted as done. Every entry into HOLD
|
||||
is in `step()` or `_no_soc()` and follows a charge that reached the target,
|
||||
stalled near full (MAINT-13) or timed out near full (MAINT-13/14). Nothing else
|
||||
sets the phase to HOLD (`_enter(HOLD` has four call sites, all after a done
|
||||
charge). The hold is a wait for the BMS to balance; it never needed the level.
|
||||
So a hold that ends with no reading is a completed cycle. The "cycle COMPLETE"
|
||||
line is true without a reading: it names minutes of hold, not a level.
|
||||
|
||||
## Cost
|
||||
|
||||
- If the level was lost during the hold, the cycle is recorded as complete
|
||||
without anyone seeing the final level. The charge had counted as done, so this
|
||||
is the same trust MAINT-14 already gives a charge that ends with no reading.
|
||||
|
||||
## What this does not cover
|
||||
|
||||
- It does not check that the pack is still full at the end of the hold.
|
||||
- It does not say why the reading was lost.
|
||||
- With a reading, nothing changes. `test_maint15.py` section 7 compares the new
|
||||
HOLD tick by tick with a frozen copy of the release/1.0 HOLD branch (phase,
|
||||
setpoint, owner, events, `last_completed`) at four `hold_min` values. A wider
|
||||
trace of the whole cycle (800 seeded runs against db9e231) was run outside the
|
||||
repo; its script and log are kept under `scratch/MAINT-15/` of the dev
|
||||
workspace, not in this repository.
|
||||
- A state file written by an older version with `phase: hold` loads and ends on
|
||||
time; the file is not changed in format.
|
||||
@@ -50,7 +50,7 @@ P1 is its section 6 P1, and the pull request lists what was measured.
|
||||
needs its own run to clear the spread (without: a night charge was spent
|
||||
on flat morning prices, then charging went on at them);
|
||||
- a run edge reseeds the loop at the house's level (a 3 kW import spike
|
||||
removed) and, since OPT-15, holds it there until the meter shows the step.
|
||||
removed; about 0.1 x rating still overshoots as export).
|
||||
- Replay (tools/opt11p3_replay.py, 16 recorded days, EPEX hourly prices a
|
||||
year earlier, modelled batteries), net EUR, lower is better: P2 19.33, P3
|
||||
Solar first + Leftover solar 19.57, with a sale 19.60, the old one-period
|
||||
@@ -75,80 +75,6 @@ P1 is its section 6 P1, and the pull request lists what was measured.
|
||||
P2's 19.33; Solar first + Cheap hours only 19.57.
|
||||
- Not done in P3: the forecast (P4), and a headroom too short for the run
|
||||
moving the rest to the next cheap periods.
|
||||
- OPT-15 (TEST-23 findings on 5a4cdc2, merged up to e3e4ba6,
|
||||
`test_price_run_fixes.py`):
|
||||
- F1, a charge stopped at a quarter edge and latched for the quarter. The
|
||||
P1 averager opens a quarter only when a sample arrives, and the HA source
|
||||
builds one 0.35 s after the reading, so the cycle on the boundary still
|
||||
saw the old block: "no seconds left", headroom None, "no trade
|
||||
(headroom)", latched for the new quarter. With modes on the guard now
|
||||
projects the quarter `now` is in (`PeakTracker.project(roll=True)`,
|
||||
crediting the held sample as the averager will), and only a measured
|
||||
stop latches. Modes off still reads the old block (identity).
|
||||
Since SAFETY-91, `next_quarter()` does the same for the guard at two
|
||||
inverters (no stop there without the roll, same banked quarters); the
|
||||
roll is what covers one inverter, where the old projection stops the
|
||||
charge at every edge. With modes on at one inverter the roll also removes
|
||||
the 1 s 0 W command at each quarter edge that SAFETY-81's sim showed, so
|
||||
its modes-on digests are re-pinned (modes off equal to e3e4ba6). A
|
||||
rolled projection is not graded by SAFETY-81's alarm (the old edge tick
|
||||
read "no seconds left"), so a standing overload is still notified once
|
||||
per quarter.
|
||||
- F2, -1120 W export at a charge end against -257 W modelled. The replay
|
||||
acted every 5 s on readings of that instant, and by then the lagged
|
||||
battery had nearly arrived, so it never acted on a reading that still
|
||||
showed the trade. The add-on cycles on each meter update (1 s on the rig)
|
||||
on readings 1-2 s old: the two cycles after the reseed saw the trade's
|
||||
+3300 W and slewed E up 555 W each. The loop now holds E until the meter
|
||||
reaches the target (or `EDGE_HOLD_S`, 18 s; to 36 s while the battery
|
||||
still moves). The replay gained `Lag` (dead time, reading age, the P1
|
||||
debounce, 1 s cycles). Charge end, 800 W house: -10 to -51 W over eight
|
||||
lag variants; without the hold -1370 W (meter 2 s). On e3e4ba6 the same
|
||||
numbers hold. The hold gives the loop the target as its grid, so it
|
||||
yields to SAFETY-95's drain tail, and any other claim that moves the
|
||||
command ends it.
|
||||
- F3, a charge refusal in a cheap run was never probed: the SAFETY-73 pin
|
||||
stands only while the site asks that way, and nothing asked to charge.
|
||||
Probing was chosen over changing the card: the probe is 200 W for 34 s
|
||||
each 15 min under the same peak gates, and it wins back the run. In its
|
||||
probe window the refusing battery is a charger, pinned at 200 W by its
|
||||
own claim, and the others rest. A monthly drain (SAFETY-78) still comes
|
||||
first, and with a day plan a probing Leftover solar battery goes only to
|
||||
its planned level. At one inverter SAFETY-52 already ends a charge
|
||||
refusal as soon as the house asks for power.
|
||||
- Not fixed, same on e3e4ba6: with lagged readings a headroom-sized quarter
|
||||
banks up to 1.4 W over the aim (48.6 W under the cap), and a battery
|
||||
reading older than the meter can make a charge start look like a house
|
||||
over the aim, a real guard stop.
|
||||
- Review: an unmeasured stop that never latched let a headroom missing
|
||||
every other cycle flip the charge 1799 times in 30 min, so the second one
|
||||
in a quarter latches (at most 4 changes a quarter). A house that rises
|
||||
1, 2 or 3 kW as the charge ends, from 30 s into the quarter to 15 s
|
||||
before its end (two inverters, lagged readings): the hold adds at most
|
||||
+33 W to that quarter against the loop without it, and the most any
|
||||
quarter banks is 3431 W with the charge sized to the aim (3450 W; the cap
|
||||
is 3500 W), 3456 W with a slow 8 s battery (the lag's own, the same on
|
||||
e3e4ba6). The import during the hold is the rise, below the charge's
|
||||
own, and a rise past that is the peak floor's, which ends the hold.
|
||||
A maintenance drain, hold or charge that starts during the hold ends it
|
||||
on that tick (replayed at edge + 3 s).
|
||||
- OPT-20 (TEST-23 OPT-15 g7, `test_opt20.py`): a probe asks only PROBE_W.
|
||||
The trade was sized at the probing battery's rating (2500 W), B's pin held
|
||||
200 W of it, and the allocator put the other 2300 W on A, in its
|
||||
maintenance charge phase (no mode range there, so nothing kept it out):
|
||||
grid 3100 W for 34 s. Now the ask is PROBE_W for the probing battery plus
|
||||
what the other chargers would have had without the probe (none: 200 W
|
||||
alone), and it carries the pin only on the ticks the pin is filed (the
|
||||
window's first and last cycle moved another battery's share by 200 W).
|
||||
- Chosen: the probe sits on top of a headroom-sized charge, so beside a
|
||||
normal charger its command is the same and the grid rises 200 W (about
|
||||
7 W on the quarter) rather than that charger giving 200 W up. A probe
|
||||
that the headroom or the bound cannot hold at 200 W is not filed.
|
||||
- Unchanged, same on bf9019e: with a charge ask the other batteries cannot
|
||||
give (one sign), so an idle-looking A that gives the house stops for the
|
||||
window and the grid rises by what it gave. The trade line now says 200 W
|
||||
for a probe alone; its words are UX-38's. The base P4 F4 path (a normal
|
||||
trade on a battery in maintenance charge) is not touched.
|
||||
|
||||
## P4: the solar forecast and the day plan (2026-09-29)
|
||||
- Input: a Home Assistant forecast sensor (`pv_forecast_entity`, kWh left
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
# OPT-17 - the solar forecast from several entities, summed
|
||||
|
||||
**Status: for review.** Tests: `test_opt17.py`. Two options added.
|
||||
|
||||
A site whose forecast comes as several Home Assistant sensors (one per
|
||||
inverter or roof side) had to build a template sensor to sum them. Now each
|
||||
day can list them.
|
||||
|
||||
## Options
|
||||
|
||||
- `pv_forecast_entities` and `pv_forecast_tomorrow_entities`: lists of
|
||||
sensors, default `[]`, the same shape as `pv_entities`.
|
||||
- `pv_forecast_entity` and `pv_forecast_tomorrow_entity` are unchanged. An
|
||||
install that uses them needs no edit.
|
||||
- **Why new keys, not "string or list" in the old ones.** Measured on
|
||||
Supervisor 2026.08.0 by `tools/supervisor_shape_probe.py` §J, as TEL-12's
|
||||
§G did for `pv_entity`. Declared as a list, each forecast key refuses the
|
||||
string an install already stores (`Invalid list for option
|
||||
'pv_forecast_entity'`), and a `[]` default does not rescue it. The one
|
||||
spelling that takes both, `match(.*)?`, also takes the integer 417 and
|
||||
shows a plain text box, so a second sensor cannot be added in the UI.
|
||||
- §J also shows: a 0.17.0 blob validates unchanged with the lists at `[]`;
|
||||
the lists render as repeatable fields; a downgrade drops them and keeps
|
||||
the single keys.
|
||||
- `[]` in `options:` is load-bearing, as for `pv_entities`.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **One key per day.** Setting the single key and the list for the same day
|
||||
is refused at start, with one message: keep the list, clear the single one.
|
||||
The list can hold that entity too. (TEL-12 lets `pv_entities` replace
|
||||
`pv_entity` with a caution row. Here the brief asked for a refusal: a
|
||||
doubled or dropped forecast changes what a battery buys, and no install
|
||||
has both keys yet, so the refusal breaks nothing.)
|
||||
2. **Each member in its own unit.** Every member is read as one forecast
|
||||
sensor is today (`solarfc._day()`): Wh, kWh or MWh, never guessed. Then the
|
||||
day's kWh are added up. A repeated id counts once.
|
||||
3. **One member not usable means the day is not usable.** Missing,
|
||||
unavailable, unknown, stale, not an energy unit, or a bad number: that
|
||||
day's sum is not used, exactly as when the single sensor reads that way.
|
||||
No today means no forecast (P3 option A). No tomorrow means today only.
|
||||
4. **Hourly split.** If every member has a split, the splits are joined
|
||||
(overlapping periods add up). If any member has none, the sum is spread
|
||||
over the bell, as for a state-only sensor.
|
||||
5. **Time.** The forecast is as old as its oldest member.
|
||||
6. **One read.** All members of both days are read in one gather, inside the
|
||||
gather that reads the inverters, so a slow sensor does not delay control.
|
||||
7. **Shown and logged once.** With two or more members, each one not usable
|
||||
is named on the status page by its place in the list (STYLE.md: no entity
|
||||
ids on the page), as a caution row titled "Solar forecast" on Home. The
|
||||
action log names the entity id, once when the set of failing members
|
||||
changes.
|
||||
8. **One member is the single key.** A one-member list reads, plans, shows
|
||||
and logs exactly as the single key with that entity.
|
||||
|
||||
## Why
|
||||
|
||||
- A partial sum is a smaller number that looks real. Two of three inverters'
|
||||
forecasts read as a duller day, and Leftover solar would then buy grid
|
||||
energy the sun was going to give. TEL-12 took the same rule for the same
|
||||
reason. No forecast is a known state with a tested fallback.
|
||||
- The bell for a mixed list: joining one member's hours with another's total
|
||||
has no honest shape, and the bell is the existing state-only rule.
|
||||
|
||||
## Cost
|
||||
|
||||
- One flaky member costs the whole day's forecast. A site with three
|
||||
sensors loses the plan more often than one with a template sum that hides
|
||||
gaps.
|
||||
- Two more options (85).
|
||||
|
||||
## What this does not cover
|
||||
|
||||
- No weighting or calibration per member; FCST-01 is still the own forecast.
|
||||
- Members on different integrations with different refresh times: each must
|
||||
be fresh (3 h) on its own; there is no wait for a lagging one.
|
||||
- The day plan itself, the trust and the house profile are unchanged.
|
||||
- The Batteries screen still says only "no solar forecast now". The member
|
||||
is named on the status page.
|
||||
@@ -1,117 +0,0 @@
|
||||
# PRICE-01 - the owner's price server as the price source, with fallbacks
|
||||
|
||||
**Status: for review.** Tests: `test_price_service.py`. One option added,
|
||||
`price_service_token` (password). Follows [[TARIFF-10]], whose section 4 this
|
||||
card answers for one provider. Design: the owner's price service note of
|
||||
2026-10-05. Owner go 2026-10-05 and 2026-10-14.
|
||||
|
||||
## What it is
|
||||
|
||||
`price_provider: price_service` reads `GET https://prices.kammenstraatha.duckdns.org/v1/dayahead/BE`
|
||||
with `Authorization: Bearer <token>`. The answer is the energy-charts layout
|
||||
plus `covers`, so the existing columnar parser reads it. The price is wholesale
|
||||
energy only; the framework adds markup and taxes, as for `generic_dayahead`.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **The host is a constant in the plugin**, declared as its only egress host,
|
||||
HTTPS only. The operator's `price_feed_url` is not read by this provider.
|
||||
*Cost:* every client talks to the one host, and moving the server is a new
|
||||
add-on version. *What replaces the per-site setting:* the egress allow-list,
|
||||
TLS, and a token per client that the server can revoke.
|
||||
2. **The chain is server, then energy-charts, then the saved prices**
|
||||
(`app/price_chain.py`). A step counts only if it has a price for *now*: a
|
||||
server that answers with old prices is skipped like one that is down. A step
|
||||
down and a step up are each one log line, written when the step changes. The
|
||||
operator's `price_feed_url` is cleared for the energy-charts step, which is
|
||||
asked for its own feed.
|
||||
3. **The saved prices are `/data/price_history.json`**, the file `PriceHistory`
|
||||
already keeps (the last three Brussels days of what was published). They are
|
||||
the all-in prices as stored; a change of markup shows from the next fetch
|
||||
that works. A read from the store is not filed again.
|
||||
4. **No price for now** means no published price covers the present moment. Not
|
||||
"none for the coming hours": before about 13:00 tomorrow's prices are not
|
||||
published on any healthy day, and carried hours are already marked estimated.
|
||||
It sets one status row (*Prices*), one Home plan line (English and Dutch),
|
||||
and one notification (*No prices*, English and Dutch), taken down when a
|
||||
price returns. A start-up before the first refresh finished is not a gap.
|
||||
The notification is not re-asserted after a Home Assistant core restart
|
||||
(SAFETY-34 does that for latched safety alarms); the status row stays.
|
||||
*Open for the owner:* if "coming hours" should mean a look-ahead of N hours,
|
||||
that is a change to `gate_for()` and `PriceWatch`, not to the chain.
|
||||
5. **The maintenance charge gate** (owner's decision, 2026-10-14): the charge may
|
||||
run when the *current 15-minute price is at or below today's average*, today
|
||||
being the Brussels day, computed from the provider's prices. This is the rule
|
||||
the Home Assistant pair gave (`price_service.yaml`), so a site that moves from
|
||||
the pair to the provider does not change when the maintenance charge runs.
|
||||
It is not `TariffPlan.cheap_now()` (strictly below the mean of the firm
|
||||
hours of the whole plan). This replaces, for this provider only,
|
||||
TARIFF-10 section 4: its three reasons are answered as follows. (1) Two
|
||||
writers: the pair beside a provider stays refused; `static_profile` beside
|
||||
`price_service` is accepted and no longer writes `cheap`. (2) An expired or
|
||||
absent plan shuts the gate (the charge waits), as an unavailable HA sensor
|
||||
did. (3) The basis is the one above, not `cheap_now()`.
|
||||
6. **The token is a secret.** `password?` in the schema; hidden on the settings
|
||||
page (`uilayout.HIDDEN`), which can keep `price_service` but not choose it;
|
||||
never put in a message by the plugin; taken out of any reason text by
|
||||
`price.scrub()`; the real transport reports only a status or an exception's
|
||||
name. validate.py refuses `price_service` with no token, a token with a
|
||||
space or line break, and one under 16 characters (so `scrub()`, which skips
|
||||
values under 4 characters, always has something to take out). Round 2: the
|
||||
token is in no page and no JSON answer of the web app. Every `password`
|
||||
option of the schema is shown as `********` (set) or empty, in
|
||||
`/config/model` (`options`, `running`, `ui`) and in a stale refusal; the
|
||||
classic page draws it as a password field; a save or a validate that sends
|
||||
`********` or nothing keeps the stored value, any other value replaces it.
|
||||
A token can therefore be changed but not cleared from the page.
|
||||
7. **A fixed tariff beside `price_service` is allowed and quietly noted.** It is
|
||||
still the plan's last fallback (`resolve_tariff` -> `_degrade`); only the
|
||||
cheap window and the gate ignore it, because they read the provider's
|
||||
prices. Keeping it set is the recommended setup, and clearing it triggers the
|
||||
"static_profile is empty" warning, so no notification (round 2's *Fixed
|
||||
tariff not used* was false and fired on every sound start; round 3 removed
|
||||
it). Instead one INFO line at start (English, as every log line) and an info
|
||||
note beside the setting (EN and NL, "With the price service, the fixed
|
||||
tariff is only used if no prices are available."), both
|
||||
only with a non-blank `static_profile` (whitespace counts as blank). At
|
||||
start the add-on dismisses the old notification id, in case a pre-release
|
||||
build raised it.
|
||||
8. **The no-price words show only with the maintenance charge on** (owner,
|
||||
2026-10-14): the status row, the Home line and the notification say
|
||||
'maintenance waits', so with `maintenance_enabled` off none is shown.
|
||||
|
||||
## Where the gate equals the Home Assistant pair, and where it does not
|
||||
|
||||
Equal: `test_price_service.py` section 8 feeds the same day both ways, to the
|
||||
rules in `price_service.yaml` (price at the quarter, average of today's
|
||||
quarters rounded to 0.00001 EUR/kWh, `<=`), over 6 days including both
|
||||
clock-change days, at three instants in every period, and through a real
|
||||
`Controller` for a whole day. Every answer is the same.
|
||||
|
||||
Not equal, by design or by arithmetic:
|
||||
|
||||
- The comparison is made on all-in prices. With a flat taxes figure and a
|
||||
markup and taxes of at most five decimals the offset cancels and the answer
|
||||
is the same. With `price_taxes_profile` (an hour-varying levy) the all-in
|
||||
prices differ by hour, so the answer can differ from the wholesale comparison
|
||||
the HA sensors made. A site on that profile should check before moving.
|
||||
- A quarter within 0.00001 EUR/kWh of the average can differ if markup or taxes
|
||||
have more than five decimals.
|
||||
- The plan is retired when no refresh has succeeded for 90 minutes
|
||||
(`TARIFF_MAX_AGE_S`) and the gate shuts then. How long the HA pair kept an
|
||||
answer after its REST sensor stopped updating was not measured here.
|
||||
- The average is over the firm prices the provider holds for today. After a
|
||||
restart in the middle of a day with only the saved part of today, it is the
|
||||
average of that part.
|
||||
|
||||
## Not covered
|
||||
|
||||
- **No real answer of the server was seen.** No token was available. The
|
||||
layout is the design's; `test_price_service.py` uses a mock and, for the
|
||||
parser, the real energy-charts capture reshaped with the design's extra keys.
|
||||
The first capture from the live server (rig test) should replace the mock.
|
||||
- The 429 limit (about 60 calls an hour) is not approached: one fetch per
|
||||
30 minutes, and at most two requests when the server fails.
|
||||
- Battery modes read the plan as for `generic_dayahead` and were not changed.
|
||||
- The `price_service` REST and template sensors in Home Assistant are not
|
||||
touched; they stop being read when the pair is cleared.
|
||||
@@ -1,89 +0,0 @@
|
||||
# PRICE-02 - the trading verdict skips price periods that have ended
|
||||
|
||||
**Status: for review.** Tests: `test_price02.py`. Found by AUDIT-01 (F1).
|
||||
|
||||
The refresh floors the plan to the top of the hour, so on the 15-minute feed
|
||||
it holds the periods of this hour that are already over. The verdict took the
|
||||
cheapest and dearest over every point. With modes off it could aim at a period
|
||||
that had passed and then do nothing, for up to 45 min plus the refresh lag.
|
||||
With modes on the `allowed()` gate could be open only because of such a period.
|
||||
|
||||
Decision: `TariffPlan.arbitrage(now=...)` drops the points whose period has
|
||||
ended (`end <= now`, the span `price._cover_table()` gives, as
|
||||
`priceruns.firm_slots()` does). Its three callers pass their own `now`:
|
||||
`arbitrage.decide()`, `Controller._mode_roles()` and `homeview.trade_verdict()`.
|
||||
|
||||
- Taken in the verdict, not when the plan is built: a plan built at 03:00 is
|
||||
still used at 03:20, and the Home chart keeps the day's earlier bars.
|
||||
- An hourly point that is still current stays in. But an hourly or static plan
|
||||
is rebuilt every 30 min counted from add-on start, so it is usually off the
|
||||
hour and, until the next refresh, holds the hour that has just ended. That
|
||||
hour is dropped too (round 3, owner-accepted 2026-10-22). On a day/night
|
||||
tariff all day hours tie and the old verdict took the first, the ended one,
|
||||
so it paused the trade every hour until the refresh: 91 on/off changes over
|
||||
2 days with the refresh at :10, 4 now (4 before as well, with the refresh on
|
||||
the hour). With the refresh on the hour nothing changes.
|
||||
- `now` is required: keyword-only, no default, like the other three arguments. A
|
||||
caller that could leave it out would get the elapsed periods back and the
|
||||
defect with them. A test that wants the whole series passes an instant before it.
|
||||
- The sentences of this verdict in `arbitrage.py` (status row and action log,
|
||||
English only) say "price period" where they said "hour": the idle one now
|
||||
reads "now is not the cheapest or the dearest price period". The UI texts
|
||||
that say "cheapest hour" (AUDIT-01 T1-T5) are not touched here.
|
||||
- Not changed here: F2 (static profile sampled at HH:00) and F3 (carried value).
|
||||
|
||||
Measured: with price off, every command over the modes-off replay is
|
||||
byte-identical. On a static tariff rebuilt on the hour it is too; rebuilt 20
|
||||
min off the hour the commands differ (digest 79f06d6381ff3358 on release/1.0,
|
||||
18de19d9fe919fe5 here), for the reason above. On the dynamic replay every command is
|
||||
the same too; only a sale's sentence quotes a smaller "above the cheapest"
|
||||
figure once the first cheap period is over. The identity pins in
|
||||
`test_modes_p3.py` and `test_price_run_fixes.py` hide just that figure and the
|
||||
word change (`opt11p3_identity.trade_digest()`).
|
||||
|
||||
## Ties (round 2)
|
||||
|
||||
The verdict takes `min`/`max` over the points still ahead and the first one wins
|
||||
a tie, so with equal prices it is the first period still ahead, which is the
|
||||
current one. Before, it was the first of the whole plan, often one that had
|
||||
ended. Two effects follow, and both are the card's intent, not a side effect:
|
||||
|
||||
- Equal quarters (hour-constant data, or a feed that prints the same price in
|
||||
several quarters) now act in every quarter of the cheap (or dear) block,
|
||||
not in the first only. Test: `test_price02.py` part 6.
|
||||
- After the cheapest period has passed, the next cheapest still ahead is the
|
||||
target, if its spread still clears the cost. A hourly plan did this at every
|
||||
refresh already (it started at the current hour); a 15-minute plan did it
|
||||
only from the next hour on, because it kept the elapsed quarters of this one.
|
||||
|
||||
Measured with `scratch/PRICE-02/dev/tiesim.py` (refresh every 30 min from the
|
||||
floored hour, verdict at 7 min into each period, threshold 0.05, old = the
|
||||
verdict over the whole plan, new = this card):
|
||||
|
||||
| data | days | acting periods old -> new | action changes old -> new |
|
||||
|---|---|---|---|
|
||||
| real 15-minute day, energy-charts 2026-09-09 | 1 | 4 -> 5 | 6 -> 6 |
|
||||
| real 15-minute days 2026-09-09 and -10 | 2 | 14 -> 26 | 24 -> 24 |
|
||||
| EPEX 2025-26 hourly prices as four equal quarters | 12 | 18 -> 72 | 36 -> 20 |
|
||||
| the same with random +-0.002 noise per quarter (no ties) | 12 | 18 -> 33 | 36 -> 48 |
|
||||
|
||||
So a real 15-minute day does not switch more often (24 -> 24), and a day of
|
||||
equal quarters switches less. A synthetic day of noisy, tie-free quarters does
|
||||
(3.0 -> 4.0 changes a day): each period that passes hands the target to the
|
||||
next cheapest one ahead. Every change is still at a period edge, never within
|
||||
one. The replay in `test_arbitrage.py` (synthetic year, hourly) gives the same
|
||||
ledger on the old and the new code once its ties are broken (280 round trips,
|
||||
85 held, both): it printed one maximum twice per day, and the old fixture
|
||||
tested the first of them only because its plan began at midnight.
|
||||
|
||||
## Battery throughput (round 3)
|
||||
|
||||
On real 15-minute days the trade runs longer: about 1.5 to 2.5 times the
|
||||
minutes spent charging or discharging, with fewer on/off switches (for example
|
||||
78 -> 68, 92 -> 72), over 16 days (2026-09-02 to 09-17, a decision every
|
||||
minute, refresh at :00 or :10, tomorrow's prices known always or from 13:00;
|
||||
reviewer's sim). Through a modelled 10 kWh battery that is 27.5 -> 42.6 kWh
|
||||
(refresh at :00, tomorrow always known); 19.6-31.9 -> 43.4-48.7 kWh in the
|
||||
other three variants. Charge to
|
||||
discharge reversals are unchanged (10 -> 10, 13 -> 13). More cycling is the
|
||||
price of not pausing.
|
||||
@@ -1,80 +0,0 @@
|
||||
# SAFETY-103 - no swing on old battery readings after a drain ends
|
||||
|
||||
**Status: implemented 2026-10-08, for review.** Tests: `test_safety103.py`.
|
||||
|
||||
**The defect** (SAFETY-95 rig test, check 5, F1). While a battery settles
|
||||
from its ended drain, SAFETY-95 steers on the house's point: grid + every
|
||||
battery's reading - aim. The meter is 1 s old; a battery register can be
|
||||
10 s old (the SAFETY-90 register model). At E+13 A's reading said 1238 W
|
||||
(6.8 s old) while A was at 616 W, so the house looked ~620 W bigger: B went
|
||||
860 -> 1650 -> 870 W. B's own old reading does the same the other way.
|
||||
|
||||
**The rule** (`main.py`, `_track_reads()`, `_drain_grid_w()`, N>=2, only
|
||||
while a drain's tail settles):
|
||||
|
||||
1. Each cycle, per battery: the range its power can be in. That is its last
|
||||
changed reading, widened by every command on its wire since (a lagging
|
||||
battery moves from one toward the others, never beyond). A new reading
|
||||
starts the range again.
|
||||
2. A reading that changed this cycle is taken as it reads. So is the side a
|
||||
battery refuses (SAFETY-73): it is not heading that way.
|
||||
3. The house point is held (first cycle: the loop's start from SAFETY-95).
|
||||
It moves only as far as the meter proves: to the nearest value of grid +
|
||||
the batteries' ranges - aim.
|
||||
|
||||
So the loop no longer moves on a reading that has not caught up. A real
|
||||
house step still moves it, as soon as the meter shows more than the
|
||||
batteries can explain.
|
||||
|
||||
**Why not the tester's rule** ("a battery whose reading is older than its
|
||||
last command counts at its command"). B's command changes almost every
|
||||
cycle, so B is almost always at its command. That is the plain meter, which
|
||||
chases A's tail: B swings 1420 W. A lag model needs the plant's time
|
||||
constant, which is not known for a client's inverter. Measured on the plant
|
||||
below (N=2, 1200 W, A Off; swing = a move of the others later undone):
|
||||
|
||||
| rule | 10 s lag | 5 s lag | 20 s lag | surplus, 5 s lag |
|
||||
|---|---|---|---|---|
|
||||
| release/1.0 (readings) | 760 | 840 | 150 | 800 |
|
||||
| tester's: at the command | 1420 | 1020 | 1600 | 800 |
|
||||
| lag model, tau 10 s | 70 | 140 | 80 | 580 |
|
||||
| lag model, tau 5 s, plant 20 s | | | 600 | |
|
||||
| hold all window | 0 | 0 | 0 | 0 |
|
||||
| **this rule** | **0** | **0** | **0** | **130** |
|
||||
|
||||
Holding all window ignores a house step: a 2 kW kettle at E+5 s imported
|
||||
14.4 Wh over the next 30 s (release 11.5, this rule 11.3); 2 kW more sun
|
||||
exported 16.9 Wh (release 8.5, this rule 8.9).
|
||||
|
||||
**Before -> after** (`test_safety103.py`, `tools/safety103_plant.py`: 800
|
||||
W/s ramp, 10 s lag, meter 1 s, registers 10 s at the rig's phases, B idles
|
||||
at -100 W; summed swing of the others in the 23 s after the end):
|
||||
|
||||
| case | release/1.0 | after |
|
||||
|---|---|---|
|
||||
| rig check 5: N=2, 1200 W, A Off | B 1140 -> 850 -> 1630 -> 870, swing 760 W | B holds 1140 W |
|
||||
| N=3, 1200 W / 4000 W, A Off | 680 / 630 W | 0 / 0 W |
|
||||
| solar surplus, N=2 / N=3 | 890 / 740 W | 130 / 130 W |
|
||||
| lag 5 or 20 s, registers 5 or 10 s, N=2 and N=3 | | at most 10 W |
|
||||
| registers as fresh as the meter (1 s) | | rows byte-identical |
|
||||
| refusal end | | rows byte-identical |
|
||||
| N=1, every end | | rows byte-identical (also against the release/1.0 tree) |
|
||||
|
||||
**Cost.** In the window, a house change smaller than what the batteries
|
||||
could still be doing is not followed until a reading catches up (at most a
|
||||
register period) or the window ends (23 s). Under a surplus the others
|
||||
still swing up to 130 W, a third of a slew step.
|
||||
|
||||
**Not covered.**
|
||||
- Outside the drain tail the loop steers on the meter; readings only reach
|
||||
the saturation freeze (SAFETY-96), unchanged.
|
||||
- A lost link or a refusal ends the tail at once (A is out of the split, or
|
||||
reads ~0 W). The swing after them is the loop's own on a lagging plant,
|
||||
the same with 1 s registers and on release/1.0: under a surplus up to
|
||||
1.5 kW at N=2 and 3.9 kW summed at N=3; after a lost link about 1 kW
|
||||
even without one. Not this card.
|
||||
- A battery that overshoots its command, or a reading offset beyond the
|
||||
range, can put the true power outside the range; the meter then moves the
|
||||
point, as before.
|
||||
- The end cycle's start (SAFETY-95 step 2) still uses the readings; the
|
||||
batteries were steady during the drain, so they are current there.
|
||||
@@ -1,96 +0,0 @@
|
||||
# SAFETY-107 - a refusal held under another writer is learned once
|
||||
|
||||
**Status: for review.** Tests: `test_safety107.py`. One inverter only; nothing
|
||||
changes at N>1 (SAFETY-73's unit refusal has no "another writer" end).
|
||||
|
||||
**The defect** (MAINT-13 F5, MAINT-14 "seen again"). A full pack under a
|
||||
maintenance charge in a cheap window takes no charge. At N=1 the action log then
|
||||
showed "BATTERY REFUSES asked -2500 W ..." and "BATTERY REFUSES ENDED: another
|
||||
writer" every cycle: 548 + 548 lines in 15 min on the MAINT-13 rig head,
|
||||
652 + 651 in 12 min on its base, 33 + 32 in 60 s in MAINT-14's c2b. The 40-line
|
||||
log flooded and the hint under the buttons changed every second, so the
|
||||
MAINT-13 notice was gone at once.
|
||||
|
||||
**Cause** (`main.py`, the SAFETY-52 block before `compute()`). The end tested
|
||||
`self.target != self.loop_w`: the wire differs from the loop's ask. A held
|
||||
refusal makes the loop ask 0 W, so any claim that binds (the maintenance
|
||||
charge's `hi`, the peak guard's `lo`, a trade's SET) differs from it on every
|
||||
cycle. The refusal was ended against the same writer it had been learned
|
||||
against. `sat_count` is not reset by that end, so `compute()` learned the same
|
||||
refusal again in the same cycle. Nothing about the battery or the wire had
|
||||
changed; only the log and the hint did.
|
||||
|
||||
**Other writers.** The same flap comes from any writer that holds the wire
|
||||
while the loop is refused, as long as the evidence lasts: a trade (a SET at
|
||||
P_ARBITRAGE) into a full pack, and the peak guard over an empty pack: 335
|
||||
refusal lines in 15 min each at a 5 s meter (`test_safety107.py` part 2, run on
|
||||
release/1.0).
|
||||
A maintenance drain or hold does not flap: its SET resets `sat_count` each cycle.
|
||||
|
||||
**The rule** (`_refusal_update()`, `rejudged`). When another writer held the
|
||||
wire, the held refusal is judged afresh: `compute()` gets `refused=0` and no
|
||||
probe, exactly as before the change. Then:
|
||||
|
||||
1. it finds the same refusal against that writer's ask: the same episode
|
||||
continues, with no line and no hint. The probe clock restarts, as it did on
|
||||
the old end and start: a probe is the loop's ask, and it is not on the wire
|
||||
while another writer is;
|
||||
2. it does not: the refusal ends, logged "another writer", or "the battery
|
||||
gives power" when the reading shows it (it was "another writer" before);
|
||||
3. it finds the other direction: end, then a new start, as before. In
|
||||
practice 2 comes first: SAFETY-94's evidence restarts when the ask changes
|
||||
sign, so a writer that turns round ends the refusal at once and the other
|
||||
way is learned ~12 cycles later (`test_safety107.py` part 3e).
|
||||
|
||||
**Why this rule.** The end now means what SAFETY-52 meant by it: the refusal no
|
||||
longer describes what is on the wire, judged by the same test that learns it.
|
||||
`compute()` sees the same inputs as before and the state after each cycle is
|
||||
the same (`batt_refused`, `batt_refused_at`), so every command is unchanged.
|
||||
Keeping the held refusal into `compute()` instead (mutation M5) would also
|
||||
stop the lines, but it holds the loop at 0 W where the old law let it ask past
|
||||
a writer's limit, which changes commands; it also removes SAFETY-52's
|
||||
owner-decided "another writer" exit.
|
||||
|
||||
**Evidence.**
|
||||
- Differential trace (`goodwe/scratch/SAFETY-107/probe/trace.py`, not in this
|
||||
repo; the real Controller in the CI image, release/1.0 bf3ac79 against this
|
||||
tree): 300 seeds, N=1 (153) and
|
||||
N=2 (147), 1/2/5 s meter, full/empty/dead/healthy battery windows, a stepping
|
||||
house, maintenance charge with the cheap window opening and closing, P1 and
|
||||
the peak guard, trade windows, stop/start; 427 680 cycles, 294 seeds with a
|
||||
refusal held. Every command, site target, integrator, `sat_count`, refusal
|
||||
state and reason is identical. Refusal lines: 100 748 before, 2 188 after.
|
||||
- The card's case: 1 line in 15 min at N=1 and N=2, 1/2/5 s meter, house
|
||||
importing or exporting (release/1.0: 335-1 753 at N=1; N=2 was already 1).
|
||||
|
||||
**Mutations** (`tools/mutation_run.sh` on purged copies; reds in
|
||||
test_safety107 / test_refusal / test_refusal_units; trace = seeds of 300 whose
|
||||
commands differ from release/1.0):
|
||||
|
||||
| | mutation | reds | trace |
|
||||
|---|---|---|---|
|
||||
| M1 | `now == rejudged` -> `False` (every such cycle ends: the old flap) | 26 / 0 / 0 | 0 |
|
||||
| M2 | `rejudged` always 0 (another writer never ends it) | 5 / 1 / 2 | 29 |
|
||||
| M3 | the probe clock is not restarted on a continued episode | 1 / 0 / 0 | 2 |
|
||||
| M4 | a probe may open while rejudged | 0 / 0 / 0 | 0 |
|
||||
| M5 | the held refusal goes into `compute()` while rejudged | 2 / 1 / 2 | 27 |
|
||||
| M6 | an end while rejudged always says "another writer" | 1 / 0 / 0 | 0 |
|
||||
|
||||
M4 is equivalent: a probe matters only if `compute()` engages in that call,
|
||||
which needs `SETTLE_CYCLES` saturated against the writer, and each of those
|
||||
cycles restarted the clock. The line stays because it states the rule.
|
||||
|
||||
**Cost.** None in power. A takeover by a different writer while the battery
|
||||
still refuses is logged as an end and, ~12 cycles later, a new start against
|
||||
that writer (3 lines for the episode, `test_safety107.py` part 3a).
|
||||
|
||||
**Not covered.**
|
||||
- A perfectly flat reading under a writer. The stale-reading gate trips, stops
|
||||
the site and seeds the refusal (SAFETY-52 decision 3). After the stop the
|
||||
writer's ask returns fresh, `compute()` has no saturation history against it,
|
||||
so the refusal ends at the first cycle and the next trip seeds it again: 2
|
||||
lines per gate cycle (29 in 15 min, house exporting), the same as release/1.0.
|
||||
The gate cycle itself (0 W, then the writer's ask) is the cause; stopping it
|
||||
changes commands. None of the rig runs showed it: real readings move.
|
||||
- At N=1 a refusal shapes only the loop. A writer above it still asks the
|
||||
battery; at N>1 SAFETY-73's P_SAFETY claim binds every writer.
|
||||
@@ -1,98 +0,0 @@
|
||||
# SAFETY-112 - control waits for Home Assistant to finish starting
|
||||
|
||||
**Status: for review.** Tests: `test_safety112.py`, `test_messages.py`.
|
||||
Control is unchanged. The add-on now says plainly that it is waiting.
|
||||
|
||||
## Cause
|
||||
|
||||
The Supervisor's proxy refuses every add-on request with 502 until Core's
|
||||
state is `RUNNING`. The add-on reads every battery reading and writes every
|
||||
power request through that proxy, so it cannot control until then. Readings
|
||||
that are already live inside Home Assistant do not help.
|
||||
|
||||
Source, Supervisor 2026.08.0 (the image the live site ran at CFG-04):
|
||||
|
||||
- `supervisor/api/proxy.py`, `APIProxy.api()` (REST, used for reads and
|
||||
`number.set_value`) and `APIProxy.websocket()` both start with
|
||||
`if not await self.sys_homeassistant.api.check_api_state(): raise HTTPBadGateway`.
|
||||
- `supervisor/homeassistant/api.py`, `check_api_state()` returns
|
||||
`state.core_state == "RUNNING" or state.offline_db_migration`. The state
|
||||
comes from Core's `/api/core/state`.
|
||||
- Upstream `main` has the same gate in both handlers (read 2026-10-13).
|
||||
- A Core restart restarts only the Core container
|
||||
(`homeassistant/core.py`, `restart()`). The add-on keeps running.
|
||||
|
||||
The add-on itself has no wait. Nothing in `app/` reads Core's state or waits
|
||||
for an event. The loop reads its inputs every second, a 502 gives `None`,
|
||||
and missing inputs give 0 W. P1 backoff is not involved: the live site reads
|
||||
the meter straight from the HomeWizard (`homewizard_local`), not through Home
|
||||
Assistant.
|
||||
|
||||
## Measured
|
||||
|
||||
Repro stack `safety112-*`, all removed afterwards:
|
||||
|
||||
- Home Assistant 2026.9.4, as live.
|
||||
- A test integration whose `async_setup` sleeps, so Core stays `NOT_RUNNING`.
|
||||
- A fake meter and two fake inverters that update every 2 s from load.
|
||||
- The Supervisor's own `APIProxy` and `HomeAssistantAPI` from the 2026.08.0
|
||||
image. Only the add-on token lookup, Core's access token and the container
|
||||
check are stubbed.
|
||||
- The add-on on `meter_source: homewizard_local` against a fake HomeWizard.
|
||||
|
||||
Times are seconds after `docker restart` of Home Assistant:
|
||||
|
||||
| run | Core `NOT_RUNNING` | entities live in HA | Core `RUNNING` | proxy 502 | first write | wait from live readings |
|
||||
|---|---|---|---|---|---|---|
|
||||
| base 3050fa5, N=2 | 13.1 | 17.1 | 253.1 | 0.9-252.7 (1 132 requests) | 254.7 | 237.6 |
|
||||
| base 3050fa5, N=1 | 13.1 | 18.1 | 256.1 | 1.5-255.1 (573 requests) | 257.2 | 239.1 |
|
||||
| this change, N=2 | 12.2 | 17.2 | 313.2 | 1.4-312.5 (1 406 requests) | 317.6 | 300.4 |
|
||||
| this change, N=1 | 12.2 | 17.2 | 255.2 | 1.2-254.1 (571 requests) | 257.1 | 240.0 |
|
||||
|
||||
The first write comes 1.0-4.4 s after `RUNNING`, so the whole wait is the
|
||||
proxy's. With a 400 s sleep Core moved on at 300.88 s on its own (bootstrap's
|
||||
stage timeout), so one slow integration holds a start stage for about 5
|
||||
minutes. The whole start can take longer: the live one took 414 s.
|
||||
|
||||
Before this change the page said "Not ready — Battery level: no usable
|
||||
reading", which sends the reader to Developer Tools for an entity that is
|
||||
fine.
|
||||
|
||||
## Rule
|
||||
|
||||
1. No way around the Supervisor. The add-on still talks to Home Assistant
|
||||
only through the proxy.
|
||||
2. While the proxy does not answer on the API (`hass.OUTAGE.waiting()`), the
|
||||
first status row is red: "Home Assistant is not answering, perhaps it is
|
||||
still starting. Control resumes by itself when it answers." The banner
|
||||
reads "Not ready — Waiting for Home Assistant to finish starting". The Home
|
||||
pill reads "Waiting for Home Assistant", in English and Dutch.
|
||||
3. A safety stop, a stalled control loop or shadow mode keeps its own banner
|
||||
and state over the wait, because none of them clears by itself.
|
||||
4. The log has one WARNING when Home Assistant stops answering, an INFO line
|
||||
every 5 minutes while it is down, and an INFO line with the length of the
|
||||
wait when it answers again.
|
||||
5. Only the API counts. The meter websocket alone does not, because the meter
|
||||
has its own row.
|
||||
|
||||
## Why it is safe
|
||||
|
||||
The change is text only. `cycle()`, the writes, the heartbeat, the stand-down
|
||||
on shutdown and every watchdog are untouched. `test_safety112.py` part 3 shows
|
||||
the same targets and writes with the flag on and off, for N=1 and N=2.
|
||||
|
||||
## Open, for the owner
|
||||
|
||||
The add-on cannot control while Home Assistant starts. These ways around it
|
||||
were not taken:
|
||||
|
||||
- **Talk to Core directly with a long-lived token** (`http://homeassistant:8123`).
|
||||
Core's own API answers while `NOT_RUNNING`. In the repro `/api/core/state`
|
||||
answered from 12-13 s, and a direct `number.set_value` 10 s into
|
||||
`NOT_RUNNING` returned 200 and set the value. The cost is a secret stored
|
||||
at every client site, which `hass.py` exists to avoid.
|
||||
- **Talk to the control board directly** (ESPHome native API), so control
|
||||
does not need Home Assistant at all. This is a large change.
|
||||
- **Remove the slow start at the site.** On 2026-10-12 the Solis
|
||||
integration's discovery held the start. Fixing or disabling it shortens the
|
||||
wait there, but not on a client site with another slow integration.
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,99 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,95 +0,0 @@
|
||||
# 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,81 +0,0 @@
|
||||
# SAFETY-90 - the peak guard goes on after a P1 meter gap
|
||||
|
||||
**Status: DEV 2026-10-05, for review.** Tests: `test_safety90.py`.
|
||||
|
||||
**The defect.** A meter gap longer than `meter_max_age_s` leaves seconds the
|
||||
quarter average does not count (`p1.QuarterAverager`, on purpose: a guess must
|
||||
not reach the bill record). `peakguard.blindness()` then called the projection
|
||||
blind for the rest of the quarter, so the floor was 0 W. During a HOLD the
|
||||
battery gave nothing and the quarter went over: SAFETY-86 rig case 7b, N=2,
|
||||
3964.8 W against a 3450 W aim, the month peak raised; N=1, 3701 W (review).
|
||||
The page still read "Keeping the grid near zero". At start-up mid-quarter the
|
||||
same rule left the start-up quarter blind, and a held battery was drawn as last
|
||||
resort (SAFETY-91 test, O2).
|
||||
|
||||
**The fix** (`peakguard.fill_gaps()`, `peakguard.gap_w()`,
|
||||
`Controller._fill_peak_gaps()`).
|
||||
- Once readings are back, every second of a silence longer than
|
||||
`meter_max_age_s` counts at `gap_w = max(the limit, the import just before
|
||||
the gap, the import just after it)`, where "import" is the grid plus the
|
||||
batteries' discharge, i.e. with the batteries at 0 W. The gap's first
|
||||
`meter_max_age_s`, which the averager credits at the last reading, is topped
|
||||
up to `gap_w` too. The guard then grades the quarter as usual, so it is not
|
||||
blind and gives what keeps the quarter at the aim.
|
||||
- **The bound.** The count is at or above the true import as long as, during
|
||||
the gap, the house load (less solar) stayed at or under the higher of the loads seen just
|
||||
before and just after it, or under the limit, and the batteries did not
|
||||
start charging (during a gap they hold their last command, then go to 0 W).
|
||||
It never counts the gap below the limit. A load that rises above all three
|
||||
only inside the gap is the one case it can miss.
|
||||
- **Cost of the bound.** It is conservative: with the house at 6500 W, a 30 s
|
||||
to 5 min gap lands the quarter 100-180 W under the aim (table below), paid in
|
||||
battery energy.
|
||||
- **While the gap lasts** nothing changes: the reading is too old to steer by,
|
||||
the guard is blind (floor 0 W) and the "inputs missing" stop sets 0 W, as
|
||||
before.
|
||||
- **Before the first reading** (start-up or restart mid-quarter) the
|
||||
add-on was down, so the batteries were at 0 W (it sets 0 W when it stops,
|
||||
the ESP32 watchdog after 30 s). Those seconds count like a gap, at
|
||||
`gap_w(the limit, the import seen first)`; after a restart the restored
|
||||
reading's held `meter_max_age_s` is topped up the same way. The bound is
|
||||
the gap's, with only the "after" side known. A load the batteries cannot
|
||||
make up then raises "Load too high", which is true: that quarter is going
|
||||
over. The page says so: the status row "Peak guard: part of this
|
||||
15-minute period was not measured, counted as high use" and the home line
|
||||
"Part of this 15-minute period was not measured." / "Een deel van dit
|
||||
kwartier is niet gemeten." until the quarter ends.
|
||||
*Review:* the first version counted them at the aim, with "nothing is
|
||||
known" as the reason. The first reading does show the load, and with the
|
||||
batteries at 0 W the import was that load, not the aim: a 60 s restart
|
||||
during a 6500 W HOLD landed 3653 W against 3450 W.
|
||||
- The bill record (`quarter_blocks`) is unchanged: it still banks only what
|
||||
was measured. The estimate steers the guard and is never banked.
|
||||
- The action log gets one `PEAK_GUARD_GAP` row per gap, with the seconds and
|
||||
the watts it was counted at.
|
||||
- No option. The threshold is `meter_max_age_s`, the limit and the aim are
|
||||
`peak_cap_w` and `peak_margin_w`.
|
||||
|
||||
**Before -> after** (`test_safety90.py`, quarter as the meter really averages
|
||||
it, gap starting 300 s in; 10 s battery lag; aim 3450 W):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| N=1 HOLD 6500 W, gap 30 s / 60 s | 3456.6 / 5329.2 | 3346.2 / 3272.2 |
|
||||
| N=1 HOLD 5500 W, gap 5 min | 4717.9 | 3330.6 |
|
||||
| N=2 HOLD, B able, 6500 W, gap 30 s / 60 s | 3456.6 / 3865.6 | 3346.3 / 3272.7 |
|
||||
| N=2 HOLD, B able, 5500 W, gap 5 min | 3745.4 | 3288.9 |
|
||||
| N=2 HOLD, B held, 5500 W, gap 60 s | 3254.9, B drawn 2500 W | 3335.4, B 0 W |
|
||||
| gap across the edge, next quarter, N=1 / N=2 | 4100.0 / 3621.2 | 3446.3 / 3382.2 |
|
||||
| house 3000 -> 6500 W inside a 60 s gap, N=1 | 5255.6 | 3368.3 |
|
||||
| house 6500 -> 2000 W late in a 5 min gap, N=1 | 3629.2 | 3145.1 |
|
||||
| start-up 450 s in, N=1, house 5000 W (batteries at 0 W before it) | 5000.0 | 3450.0 |
|
||||
| restart, down 60 s / 150 s, N=1, house 6500 W | 4039.1 / 5483.3 | 3450.0 / 3450.0 |
|
||||
| meter never drops, N=1 and N=2 | | rows identical |
|
||||
|
||||
**Not changed.** The late-reading edge (SAFETY-91 test, O1): a reading late by
|
||||
less than `meter_max_age_s` is not a gap, and the seconds since it are not
|
||||
counted in the projection (on any quarter, not only after a gap). Near a
|
||||
quarter's end the 900/remaining gain turns that into a large negative excess
|
||||
and the floor drops for a cycle or more. Counting those seconds at the held
|
||||
reading would fix it, but it changes every quarter's last seconds on a
|
||||
healthy meter; it is handed back as its own card.
|
||||
@@ -1,74 +0,0 @@
|
||||
# SAFETY-91 - a held battery is drawn only after the others are full
|
||||
|
||||
**Status: DECIDED 2026-10-01 (owner: held battery last, SAFETY-25 option A
|
||||
kept).** Follows SAFETY-87. Tests: `test_safety91.py`.
|
||||
|
||||
**The defect** (SAFETY-87 rig test, F1 and F2). At N>=2 a battery at or under
|
||||
its own peak reserve was held back (weight 0) only on cycles where the peak
|
||||
guard asked for power.
|
||||
- F1: B draining at 1000 W, A held at 15 %, house 5500. Once the grid was
|
||||
near zero the loop set the target, A had its normal weight and B's drain
|
||||
pin bound, so the loop drew A at 2500 W for the whole quarter (15.0 ->
|
||||
10.7 %) while B stayed at 1000 W. In the next quarter A reached its
|
||||
cut-out, and B stayed pinned at the peak floor (~2422 W), not its 2500 W.
|
||||
- F2: in a HOLD (A holding, B held), at a quarter's edge the guard saw a block
|
||||
with no seconds left and asked for nothing for one cycle. The HOLD's
|
||||
ceiling lapsed, A went back to 0 W and the loop's target went to B: 2500 W
|
||||
for 1 s.
|
||||
|
||||
**The fix** (`main.py`, N>=2 only).
|
||||
- `_held_back()`: the batteries at or under their own reserve while another
|
||||
can still give (live, not refusing), graded as in `_peak_on_able()`. They
|
||||
weigh 0 in every split where the site gives, not only for the peak. With
|
||||
nobody able (all held, or the rest refusing or dropped) nobody is held back
|
||||
and the split is as before.
|
||||
Review: once held back, a battery stays so until its SoC reads more than 1
|
||||
point above its reserve. A reading flipping 20/21 % otherwise swapped
|
||||
~1000 W between the batteries on each flip (grid +-500 W with A 10 s and
|
||||
B 3 s lag). The guard's own permission (SAFETY-87) is unchanged.
|
||||
- `_drain_rise()`: while a battery is held back, a draining battery's pin
|
||||
becomes a floor at its drain rate (`lo`), so it rises towards its rating
|
||||
before the held one gives. Not for a held battery's own drain, not under a
|
||||
site stop, not for a battery that is not live.
|
||||
- `tariff.next_quarter()`: past a block's end, before the next block's first
|
||||
telegram, the guard grades the next quarter, projected from the held
|
||||
reading (the averager credits it there). Not when that reading is too old
|
||||
to be credited (`max_hold_s`).
|
||||
|
||||
**What stays.** SAFETY-25 option A: once every able or draining battery is
|
||||
at full power, the loop still draws a held battery (`allocator._fill()`'s
|
||||
last-resort pass). With house 5500 and 2 x 2500 W, A still gives 2500 W
|
||||
after B is full. Nothing is placed on a battery at its cut-out: OPT-09's
|
||||
pack limit closes it (`hi = 0`), and the drain rise lets B take the rest.
|
||||
|
||||
**Before -> after** (`test_safety91.py`, 2 x 2500 W, lagging battery 10 s):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| B drain 1000, A held, house 4000 | A 2500, B 1000 | A 1500, B 2500 |
|
||||
| same, house 5500 | A 2500, B 1000 | A 2500, B 2500 (option A) |
|
||||
| cycles A asked while B not full | 612 of 900 | 0 |
|
||||
| A at its cut-out, house 5500: B | 1572 W mean | 2500 W, A 0 W |
|
||||
| HOLD, late telegram at the edge | A 0, B 2470 for 1 cycle | A 2050, B 0 |
|
||||
| idle, A held, house 2000 | A 1005, B 1005 | A 0, B 2020 |
|
||||
| N=1, every scenario | | byte-identical |
|
||||
|
||||
**Why N>=2 only.** At N=1 there is no other battery to give first. The edge
|
||||
roll would also fix the N=1 one-cycle dip at a quarter's edge (SAFETY-86
|
||||
rig, "A dropped to 0 W for 1 s"), but this ticket keeps N=1 byte-identical;
|
||||
extending it is a one-line change for a later ticket.
|
||||
|
||||
**Re-pinned.** `test_modes.py` `n2` and `n2_off`: from t=1600 battery A is
|
||||
at its 20 % reserve, so B carries the house and A gives last (100 of 540
|
||||
cycles differ). With SAFETY-87's held rule both are a5dddad's again, and the
|
||||
test grades that.
|
||||
Two fixtures changed with it: `test_safety87.py` part 10's "without the
|
||||
raise" run also turns off `_drain_rise()` (else B rises anyway), and
|
||||
`test_peakguard.py` 10g and `test_watchdog.py`'s two-inverter rig give
|
||||
their second battery a cut-out, as rule 8 requires (an unknown cut-out is held, so A alone would carry the cycle).
|
||||
|
||||
**Not changed.** The peak guard's own rules (SAFETY-87). A drain with
|
||||
nobody held stays a pin (SAFETY-78). An Off battery beside a held one
|
||||
(SAFETY-87 Open). The first cycle after start, before any quarter is
|
||||
measured, can still put the loop's first step on a held battery in a HOLD.
|
||||
SAFETY-88, -89, -90, -92, -93, -94.
|
||||
@@ -1,60 +0,0 @@
|
||||
# SAFETY-93 - the maintenance charge end hands back smoothly
|
||||
|
||||
**Status: implemented 2026-10-02, for review.** Tests: `test_safety93.py`.
|
||||
|
||||
**The defect** (SAFETY-85 rig test, check 4; also on 9358781). At N=2, when
|
||||
A's maintenance charge ended, B went 0 -> -1600 W in one cycle and charged
|
||||
from the grid with the house at 6000 W. The HOLD end was already smooth
|
||||
(SAFETY-85).
|
||||
|
||||
**Cause.** During the charge the site target holds A's charge (SAFETY-84's
|
||||
`charge` ceiling, -2000 W). At the end A gets its HOLD pin (0 W), but the
|
||||
loop starts its next step from last cycle's site target. The slew limit
|
||||
moves the site total by 400 W (-2000 -> -1600), and the only battery left
|
||||
to place it on is B. The same happens when the charge times out or the
|
||||
battery is switched Off (A free, its -2000 W shared out). With the charge
|
||||
suspended by the guard and A giving under the loop, B took A's share in
|
||||
one step (675 W).
|
||||
|
||||
**The fix** (`main.py`, `_reseed_charge_end()`, N>=2 only). On the cycle a
|
||||
battery leaves CHARGE, the loop's start point (`self.target`, and the
|
||||
integrator with it) is what the other batteries stood at plus that
|
||||
battery's new command: its pin (HOLD or abort, 0 W), or, if it is free, its
|
||||
last discharge (a charge stops). The loop then moves from there one slew
|
||||
step a cycle. The ending battery's own step (-2000 -> 0 W) is maintenance's
|
||||
pin, as before; it lowers the grid import. The end is kept until the loop
|
||||
next runs, so a missed poll on that cycle (the input hold) does not lose it.
|
||||
|
||||
Review: a charge also ends, for the site, when the battery is still in
|
||||
CHARGE but can no longer be placed: a stale link or a refusal (SAFETY-73)
|
||||
drops it, and its -2000 W landed on B the same way (B 0 -> -1600 W at a
|
||||
1200 W house). So the end is noted after the unit claims are filed: a
|
||||
battery leaves the charge when it leaves CHARGE, is not live, or its own
|
||||
claims contradict. The guard suspending the charge (SAFETY-84) is no end.
|
||||
|
||||
Why a start-point reseed, and not a per-battery slew in the allocator: the
|
||||
jump comes from one stale number at one edge, the kind of edge SAFETY-73
|
||||
(`_unit_refusal_end`) and OPT-11 P3 (`_reseed_run_edge`) already reseed.
|
||||
The change stays at that edge; nothing else at N>=2 moves.
|
||||
|
||||
**Before -> after** (`test_safety93.py`, 2 x 2500 W, 10 s lag, 2 s meter):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| rig case: house 6000, A charges 2000 to the end | B 0 -> -1600, charges 4 cycles | B 0 -> 400 -> 800 ..., never charges |
|
||||
| house 1200 | B 0 -> -1600 (2800 W from the grid) | B steps of 400, never charges |
|
||||
| charge times out / A switched Off | B 0 -> -1600 | B 0 -> 400 |
|
||||
| guard suspended the charge, A gives 1825 | B step 675 W | B step 395 W |
|
||||
| missed poll on the end cycle | B 0 -> -1600 a cycle later | B 0 -> 400 |
|
||||
| A dropped mid-charge (stale link, refusal), house 1200 | B 0 -> -1600 | B 0 -> 400 |
|
||||
| N=1, both charge ends above | | byte-identical |
|
||||
|
||||
No pinned digest moved: the 46 digests of `tools/*identity*.py` (N=1 and
|
||||
N=2) are byte-identical to f3e409c; none of their scenarios reaches a
|
||||
charge end.
|
||||
|
||||
**Not changed.** The peak guard's floor still moves the site in one step
|
||||
when it engages (a house step, a quarter edge): the guard outranks the slew
|
||||
(owner order). A HOLD pin still drops the holding battery to 0 W at once
|
||||
and SAFETY-86 gives way to the others first. SAFETY-88 (charge flapping
|
||||
above the aim) is untouched.
|
||||
@@ -1,102 +0,0 @@
|
||||
# SAFETY-94 - a battery that lags or reverses is not frozen or refusing
|
||||
|
||||
**Status: implemented 2026-10-03, for review.** Tests: `test_safety94.py`
|
||||
(rig: `tools/safety94_rig.py`). Also closes SAFETY-58. SAFETY-74 stays open.
|
||||
|
||||
## The defects
|
||||
|
||||
1. **Stale-reading gate (SAFETY-37).** Its 35 s ran from the register's last
|
||||
change. After a rest that clock was already past 35 s, so the first read
|
||||
after a new command tripped it, before the register could answer. Live
|
||||
2026-10-03 (0.14.0, N=2): 17:39:16 B and 17:39:25 A (load ramp after a
|
||||
rest), 19:00:04 A (drain start), 21:21:22 B (drain -> charge). Each was
|
||||
1 s after its command; each register answered within 11 s. This is
|
||||
SAFETY-58's shape.
|
||||
2. **Refusal at N=1 (SAFETY-52).** `compute()` engaged after `SETTLE_CYCLES`
|
||||
frozen cycles if the reading was under 100 W at that moment. The frozen
|
||||
cycles include a reversal through the battery's lag. At a 1 s meter the
|
||||
12th cycle comes as a battery reversing out of a charge passes 0 W
|
||||
(SAFETY-85 rig, O1: "gave -20 W for 13 cycles"). The refusal then held
|
||||
0 W, the battery settled at 0 W, never gave 100 W, and waited for the
|
||||
15-minute probe. N>1 already required 22 s in the band, so it was safe
|
||||
at a 10 s lag but not at 20 s.
|
||||
|
||||
## What changed
|
||||
|
||||
- **The gate counts only time the register was asked to move**
|
||||
(`freshness.Freshness._oblige`). A read counts the time since the last
|
||||
read when, at that read, the command is 250 W or more and has moved 250 W
|
||||
from where it stood at the register's last change. A register change
|
||||
(or a SAFETY-57 release) restarts the count. A command flapping in and
|
||||
out of the ask still adds up (frozen register, 0/1500 W every 5 s: trip
|
||||
at 69 s). The conjunction, `MOVE_W` and `RELEASE_S` are unchanged.
|
||||
- **The bound is unchanged at 35 s and now means what its derivation
|
||||
says.** The floor was always "how long a healthy register takes to answer
|
||||
a command": 6 s plant + 10 s publish + 1 s read = 17 s. The band
|
||||
`[2 x 17, 0.5 x 75] = [34, 37.5]` holds. Live answers measured: 1.4, 3,
|
||||
6 and 11 s after the command.
|
||||
- **Refusal evidence is the same at every roster size**
|
||||
(`main.Controller._gave_nothing`, SAFETY-73's count moved into a method).
|
||||
The reading must stay under 100 W, one way, while asked over 200 W, for 12
|
||||
cycles spanning 22 s. At N=1 `compute()` gets this as `gave_nothing` and
|
||||
needs it on top of its own freeze test. `None` (compute alone) keeps the
|
||||
old law.
|
||||
- **The evidence restarts when the reading moves 50 W towards the ask**
|
||||
(`REFUSED_PROGRESS_W`). A refusing battery reads flat (-3/-4 W live). Live
|
||||
readings wander by under 25 W at rest. A first-order lag crossing the band
|
||||
gains 50 W in at most 0.4 tau, so it cannot fill 22 s unless tau > 54 s.
|
||||
This also covers B, whose reading is 100 W below its power.
|
||||
|
||||
## Bounds and worst-case catch
|
||||
|
||||
| fault | before | after | budget |
|
||||
|---|---|---|---|
|
||||
| register frozen, then asked to move | 0-35 s after the ask (often 1 s) | 35 s after the ask (+1 read) | SAFETY-37: 35 s, under the 75/85 s consumer holds |
|
||||
| register frozen at rest reading ~0 W | stale 1 s after the ask | refused or stale, 0 W within 23-35 s | the same |
|
||||
| refusing battery, 1 s meter, N=1 | 11-16 s | 22-28 s | SAFETY-73's 22 s span (+ one 10 s publish) |
|
||||
| refusing battery, 2 s meter | 22-28 s | 22-28 s | unchanged |
|
||||
| refusing battery, 5 s meter | N=1 35-41 s, N=2 41-55 s | N=1 40-45 s, N=2 50-55 s | 12 cycles = 55-60 s |
|
||||
|
||||
Rig: `test_safety94.py` part 3. A release after SAFETY-57 still exposes
|
||||
the held number for one 35 s window of asking, so 35 < 75 < 85 still holds.
|
||||
|
||||
## Before -> after
|
||||
|
||||
- Live replay (part 1): 4 trips -> 0.
|
||||
- Refusing B (its -100 W idle reading): see SAFETY-97.
|
||||
- Rig, 10 s lag, N=1 and N=2, 1/2/5 s meters, two register shapes, 48 runs
|
||||
(part 2): before 27 stale trips and 1 refusal, after 0 and 0.
|
||||
- Reversals, lag 10 and 20 s, 1 s meter (part 2): before N=1 6 stale / 3
|
||||
refused, N=2 6 / 1; after 0 / 0.
|
||||
- 16-day loop capture (`test_freshness.py` part 8): 93 trips -> 59. Trips
|
||||
answered within 20 s: 21 -> 1. Two of the removed trips were not quick:
|
||||
a register held 351 s under a command hovering at the 250 W travel edge
|
||||
(under 35 s of asking before it moved; survivor 2's shape), and a
|
||||
refusing battery the gate had caught only on a passing ramp (SAFETY-52's
|
||||
refusal covers it).
|
||||
|
||||
## SAFETY-58 and SAFETY-74
|
||||
|
||||
- **SAFETY-58 is fixed** by this change: its card asks for the clock to
|
||||
start with the obligation and for the bound to be re-derived. Both are
|
||||
done above. `test_freshness.py` survivor 5 is now graded as closed.
|
||||
- **SAFETY-74 is not fixed.** A battery capped at 300 W with a perfectly
|
||||
flat reading, 5 s meter, house stepping: 50 trips/h at N=1 and 80 at N=2
|
||||
before, 37 and 60 after. With +-1 W dither: 0. A flat reading under a
|
||||
command that moved is exactly what the gate is for; telling "limited"
|
||||
from "frozen" needs a second signal (the meter, or SoC).
|
||||
|
||||
## Known gaps
|
||||
|
||||
- ~~B's standby offset (-95 to -118 W at idle) puts a refusing B outside the
|
||||
100 W band, so a refusing B never latched (review F1: at N=1 new, at N=2
|
||||
as before).~~ **Fixed by SAFETY-97 in the same PR:** the band is centred
|
||||
on each unit's learned idle reading. `SAFETY-97-idle-offset.md`.
|
||||
- A command that asks only part of the time (a fraction d) is caught after
|
||||
35 s of asking, which is 35/d s of wall time (0/1500 W every 5 s: 69 s).
|
||||
SAFETY-30's per-unit check restarts when its command stops opposing the
|
||||
held reading, which a lapsing ask normally does, so 35 < 85 holds there.
|
||||
SAFETY-31's circulation check (75 s) ignores the command, so its margin
|
||||
holds only while d >= 0.47.
|
||||
- A register frozen under a command that stays near the 250 W travel edge
|
||||
is caught late or not at all (survivor 2).
|
||||
@@ -1,69 +0,0 @@
|
||||
# SAFETY-95 - a maintenance drain that ends hands back smoothly
|
||||
|
||||
**Status: implemented 2026-10-06, for review.** Tests: `test_safety95.py`.
|
||||
|
||||
**The defect** (SAFETY-93 review, O1). At N=2, when A's drain ended (A
|
||||
switched Off, say) with the house at 1200 W, B went 0 -> +1600 W in one
|
||||
cycle and exported. It is the mirror of SAFETY-93 on the drain end.
|
||||
|
||||
**Cause.** During a drain the site target carries A's drain floor
|
||||
(SAFETY-78, `drain`, 2000 W) above what the house needs. At the end A's
|
||||
command drops (pin, Off, abort, stale link, refusal), but the loop slews
|
||||
from last cycle's target (2000 -> 1600) and only B is left to place it.
|
||||
Under a solar surplus B gave 1600 W into the export.
|
||||
|
||||
**The rule** (`main.py`, `_reseed_drain_end()`, `_drain_grid_w()`, N>=2
|
||||
only). On the cycle a drain ends:
|
||||
|
||||
1. The start point is what the others stood at plus A's new command (its
|
||||
pin, else 0 W; a dropped battery is sent 0 W), as SAFETY-93 does.
|
||||
2. If the house needs more, the start is raised to the house's own point
|
||||
(live batteries' readings + grid - target, as `_reseed_run_edge()`),
|
||||
but never above last cycle's start. So the others cover the house at
|
||||
once and never take over the drain's export.
|
||||
3. While A settles (SAFETY-96's window: within the deadband, at most
|
||||
`PLANT_SETTLE_DOWN_S`), the loop's error is the house's point minus its
|
||||
start, not the meter. The meter still shows A's tail and the others'
|
||||
climb; chasing it sent B down and then up again.
|
||||
4. Then the loop moves one slew step a cycle.
|
||||
|
||||
A drain ends when its battery leaves DRAIN, is not live, its claims
|
||||
contradict, or it will not give (SAFETY-73's limit outranks the pin
|
||||
without a contradiction, so the refusal is read from the battery; +1 only,
|
||||
as `_held_back()`). Today a contradiction on a drain only arises through
|
||||
a refusal; the clause stays because a contradiction sends A 0 W, which is
|
||||
an end (tested directly). The end is kept
|
||||
until the loop runs (a missed poll). A dropped draining battery is this
|
||||
rule's, not `_reseed_drop()`'s. Order in one cycle: drop/rejoin
|
||||
(SAFETY-96), charge end (SAFETY-93), drain end (this) last; each moves the
|
||||
start by its own battery, and the drain end's raise reads the start the
|
||||
others left.
|
||||
|
||||
**Why not SAFETY-93's rule alone** (step 1, then slew). Measured on the
|
||||
same plant: at 1200 W the grid imports 849 W and B goes to -465 W
|
||||
(charging) while the loop chases A's tail; at N=3 and 4000 W it imports
|
||||
784 W where base imports 42 W, as the others give up A's share of the house.
|
||||
The house point keeps the grid at its aim; the cap at last cycle's start
|
||||
keeps it from exporting.
|
||||
|
||||
**Before -> after** (`test_safety95.py`, 2500 W batteries, 10 s lag, 2 s
|
||||
meter; base = the same tree with the change taken out):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| drain done / Off / timeout, N=2, 1200 W | B 0 -> 1600, +390 W commanded export, grid then +559 W | B 0 -> 1210, grid within 40 W of its aim |
|
||||
| the same, N=3, 1200 W | B, C 0 -> 800, grid +695 W | 0 -> 605, grid +39 W |
|
||||
| the same, solar surplus | B gives 1600 W into the surplus | B never gives; site moves 400 W a cycle |
|
||||
| refusal, N=3, 1200 W | B, C step 1020 W, +2000 W commanded export | step 20 W, +70 W |
|
||||
| N=3, 4000 W | others take A's share at once | the same (grid +1 W, base +42 W) |
|
||||
| abort (site zeroed by the operator) | climbs from 0, grid +669 W | climbs from 0, grid +16 W |
|
||||
| stale link (board watchdog 25 s) | | as base, but N=3 4000 W: grid +676 W (base +786) |
|
||||
| N=1, every drain end | | byte-identical |
|
||||
|
||||
`tools/opt11_identity.py` n2_maint moved on purpose (A's drain refused at
|
||||
1655 s; B stays at the house's 1420 W instead of 2500 W). Re-pinned in
|
||||
`test_modes.py`. The other 45 digests are byte-identical.
|
||||
|
||||
**Not changed.** The peak guard's floor still binds (arbiter, after the
|
||||
loop). Held batteries still give last; the idle clamp and the freeze test
|
||||
are as they were. A charge end (SAFETY-93) and a trade end are unchanged.
|
||||
@@ -1,59 +0,0 @@
|
||||
# SAFETY-96 - a battery the loop cannot move does not freeze it
|
||||
|
||||
**Status: implemented 2026-10-06, for review.** Tests: `test_safety96.py`.
|
||||
|
||||
**The defect** (SAFETY-93 rig test). At N=2 the saturation freeze compares
|
||||
the site target with the site sum of the readings. After A's link went
|
||||
stale, A was out of the split but its reading (-2000 W, still charging) was
|
||||
in the sum: "saturation freeze (800 W vs -1903 W)", B held at 1200 W for
|
||||
~236 cycles until the circulation trip. After a charge timeout or Off at
|
||||
6000 W the loop froze ~14 s at 800 W while A came out of its charge. At a
|
||||
drop, B also took A's whole share in one step.
|
||||
|
||||
**The rule** (`main.py`, `_held_out_w()`, `_reseed_drop()`, N>=2 only).
|
||||
A battery is held out of the freeze test, counted at its command instead of
|
||||
its reading, while:
|
||||
|
||||
- it is out of the split (writes refused for `LINK_STALE_S`);
|
||||
- its last write was refused (its board may have gone to 0 W on its own
|
||||
watchdog);
|
||||
- it settles after a step the loop did not make: a charge end (SAFETY-93:
|
||||
HOLD, timeout, Off, refusal, stale link) or a rejoin (then every battery,
|
||||
as the split is made again). Settling ends when its reading is within the
|
||||
deadband of its command, or after `PLANT_SETTLE_DOWN_S` (23 s, the
|
||||
measured plant settle).
|
||||
|
||||
Its real power is not lost and not counted twice: the meter sees it, and
|
||||
the loop follows the meter. Only the freeze stops reading it as the loop's.
|
||||
The freeze still fires on the other batteries' own lag and on unplaced
|
||||
watts.
|
||||
|
||||
At a drop the battery's share leaves the loop's start point, so the others
|
||||
take over one slew step a cycle. At a rejoin it enters at its reading.
|
||||
|
||||
**Not a drop.** A missing reading or a stale register stops the site
|
||||
(`inputs_ok`), as before. A refused battery (SAFETY-73) stays in the sum:
|
||||
it is asked ~0 W and reads ~0 W. A watchdog-stopped battery (SAFETY-02/30)
|
||||
is pinned at 0 W and stays in the sum. New: no refusal is learned on a
|
||||
battery whose writes are refused; its board going to 0 W is no refusal. Its
|
||||
count is kept, not reset, so a link that refuses 1 write in 3 does not hide
|
||||
a battery that really refuses (review).
|
||||
|
||||
**Before -> after** (2 x 2500 W, 10 s lag, 2 s meter; freeze cycles not
|
||||
about the remaining battery's own lag):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| A drops while giving, A holds 1200 W | 128, B steps 1210 W | 0, B stays |
|
||||
| A drops while charging, A holds -2000 W | 78 | 0 (circulation trip stays: 4 kW goes round) |
|
||||
| A drops while charging, board to 0 W | 5 | 0 |
|
||||
| A drops then comes back, A holds 1200 W | 46, grid +1.1 kW | 0, grid within 250 W |
|
||||
| charge timeout at 6000 W | 7, B held at 400 W | 0, B climbs |
|
||||
| A switched Off at 6000 W | 6, B held at 800 W | 0, B climbs |
|
||||
| N=1, N=2 with no drop | | byte-identical |
|
||||
|
||||
**Not changed.** At a rejoin the allocator splits again at once (A 0 ->
|
||||
1205, B 2410 -> 1205 W); the site total does not step. A battery that keeps
|
||||
charging on its own after its link drops still trips the circulation check
|
||||
(it is real circulation). A trade end (`_reseed_run_edge`) still starts
|
||||
from the full site sum.
|
||||
@@ -1,118 +0,0 @@
|
||||
# SAFETY-97 - "gives nothing" is graded against each battery's idle reading
|
||||
|
||||
**Status: implemented 2026-10-04, folded into PR #171 (SAFETY-94), for
|
||||
review.** Owner decision 2026-10-04: the band's centre is clamped to
|
||||
+-75 W (see Bounds). Tests: `test_safety97.py` (rig: `tools/safety94_rig.py`).
|
||||
Closes SAFETY-94 review finding F1.
|
||||
|
||||
## The defect
|
||||
|
||||
B's power reading (board B's `inverter_ac_power`, 0x0518) shows -95 to -118 W
|
||||
at idle: its standby draw (the GoodWe app shows 0 W). A's shows -2 to -4 W.
|
||||
Every "the battery gives nothing" test was `|reading| < 100 W`, so a refusing
|
||||
B sat on the band's edge and never latched:
|
||||
|
||||
- N=2: never caught, before or after SAFETY-94.
|
||||
- N=1: the old one-instant check caught it in 17-105 s; SAFETY-94's shared
|
||||
rule (12 cycles in the band in a row) never did (review probe, 1200 s).
|
||||
|
||||
## Design
|
||||
|
||||
- **Offset source: learned per unit** (`app/idle.py`, `Inverter.idle`). No
|
||||
new option: a required one is a setup step every client gets wrong, and an
|
||||
optional one is a second source to reconcile. An override can be added
|
||||
later if a site needs one.
|
||||
- **When it learns:** control runs; the unit is not refused (unit or site),
|
||||
stale or sign-tripped; its command has been within 50 W (`IDLE_ASK_W`) for
|
||||
120 s (`IDLE_QUIET_S`, 6 lags of 20 s). A refusing battery is asked more
|
||||
than 50 W, and while refused it does not learn, so its refusal cannot be
|
||||
learned as idle. A capped battery under a big ask is not sampled either.
|
||||
- **How:** the first value is the mean of 120 s of samples; then it follows
|
||||
drift with a 30 min time constant. A sample beyond 250 W (`IDLE_SAMPLE_W`)
|
||||
is dropped (a register held at a giving value). The band is centred on
|
||||
the value clamped to 75 W (`IDLE_MAX_W`; owner decision below).
|
||||
- **Restart:** saved per unit (`unit/<name>/idle_w`, on first learn and each
|
||||
5 W move) and restored, clamped to 250 W. Without it a battery refusing from the
|
||||
start is never quiet, so never learned, so never caught.
|
||||
- **Until learned the idle reading is 0 W**: the rule as it was.
|
||||
|
||||
## Not chosen: the GoodWe LAN integration's DC battery power
|
||||
|
||||
Both inverters also appear through HA's GoodWe integration (B:
|
||||
`sensor.kelder_dyness_battery_power`, 0 W at idle; found read-only on the
|
||||
live HA 2026-10-04). Pointing `batt_entity` at it removes B's offset by
|
||||
configuration, but:
|
||||
|
||||
- the band must be safe without it: other installs may not have it;
|
||||
- it is polled over UDP on the inverter's Wi-Fi, a second channel that can
|
||||
drop while the board keeps reading (HW-01 lost both once); a missing
|
||||
reading stops the site;
|
||||
- SAFETY-37's 35 s bound is derived from the board's 10 s publish; the
|
||||
integration's poll interval (believed 10 s, not checked here) and latency
|
||||
are not measured;
|
||||
- DC power is not 0x0518, which SAFETY-30/31/42 were measured against.
|
||||
|
||||
It stays an install choice to test on the rig, not a code change here. With
|
||||
it, the learned idle reading is simply ~0 W.
|
||||
|
||||
## Where it applies
|
||||
|
||||
Every test of "gives nothing / gives / takes" now uses `reading - idle`:
|
||||
|
||||
| check | change |
|
||||
|---|---|
|
||||
| shared refusal evidence `_gave_nothing` (N=1 and N>=2) | centred |
|
||||
| N=1 engage and end in `compute()` (`idle_w`, the site sum) | centred |
|
||||
| N=1 end in `_refusal_update` | centred |
|
||||
| N>1 unit end (probe answered, takes the other way) | centred; uncentred, B's idle -100 W read as "takes 100 W" and ended a charge refusal at once |
|
||||
| stale-path refusal (`_refusal_from_stale`, N=1 and N>1) | centred |
|
||||
| SAFETY-42 "parked" (small ask, gives nothing) | centred |
|
||||
| SAFETY-37 stale gate | unchanged: it grades register changes against command travel, not a level |
|
||||
| SAFETY-57 release | unchanged: it reads the command only |
|
||||
| SAFETY-30 per-unit sign | unchanged: opposition needs both over 250 W; B's 100 W idle cannot make or hide one alone |
|
||||
| SAFETY-42 shortfall | unchanged: B reads ~100 W short at every power, which errs towards reporting (report only) |
|
||||
|
||||
## Bounds: owner decision 2026-10-04 (review R1)
|
||||
|
||||
Band: `|reading - centre| < 100 W`, centre = idle clamped to +-75 W.
|
||||
|
||||
- **Why a clamp under the band.** A wrong idle reading (taught during a
|
||||
rest while the reading shows something else, e.g. a grid top-up of a unit
|
||||
held at 0 W, or restored onto a swapped inverter) more than about 100 W off
|
||||
hid every refusal on that battery, at N=1 and N=2; with the first +-250 W
|
||||
clamp the rig missed a refusing A whenever the error was 110-250 W. At
|
||||
+-75 W the band always keeps 0 W +-25 W, so a battery reading about 0 W
|
||||
while refusing is caught whatever was learned or restored, and a wrong idle
|
||||
cannot refuse a healthy 0 W battery (its ask is over 200 W).
|
||||
- **The trade-off.** Options: keep +-250 W (covers idle readings to about
|
||||
+-350 W, any 100 W mis-learn hides refusals); clamp to +-75 W (chosen);
|
||||
"old band or centred band" (rejected: a correct B would need to give 200 W
|
||||
to answer its 200 W probe).
|
||||
- **Cost.** A battery whose idle reading is beyond about +-170 W is centred
|
||||
at most 75 W out, so its refusal falls outside the band: it behaves as in
|
||||
0.14.0 (not caught by this rule). B (-95 to -118 W) reads 20-43 W from its
|
||||
clamped centre. B is caught across -150..+90 W of error in its idle
|
||||
reading; learned 110 W or more too high it is missed, as in 0.14.0 at N=2.
|
||||
- A frozen register is still caught by the stale gate at 35 s of asking.
|
||||
- Recovery from a wrong value needs quiet time: tau 30 min.
|
||||
|
||||
## Catch times (rig, refusing from 200 s after a 400 s rest, +-4 W)
|
||||
|
||||
| refusing | meter 1 s | 2 s | 5 s |
|
||||
|---|---|---|---|
|
||||
| A, N=1 | 28 s (before 28) | 28 (28) | 65 (65) |
|
||||
| A, N=2 | 28 (28) | 28 (28) | 110 (110) |
|
||||
| B, N=1 | 28 (never) | 28 (never) | 65 (never) |
|
||||
| B, N=2 | 24 (never) | 24 (never) | 60 (never) |
|
||||
|
||||
A at N=2 on a 5 s meter is 110 s here because the rig's grid goes flat once
|
||||
A is cut, so the loop cycles on the 15 s floor; the same run on the PR before
|
||||
SAFETY-97 gives 110 s. B restored after a restart, refusing from the start:
|
||||
24 s; with nothing saved: never.
|
||||
|
||||
## Before -> after
|
||||
|
||||
- Healthy B with its offset, SAFETY-94's four scenarios, N=1 and N=2, 1/2/5 s
|
||||
meters, both register shapes, idle learned or restored (96 runs): 0 stale,
|
||||
0 refused. Reversals, lag 10/20 s, idle restored: 0.
|
||||
- Live replay (SAFETY-94 part 1): 0 trips (the gate is untouched).
|
||||
@@ -1,40 +0,0 @@
|
||||
# UX-53 - Home draws a grid charge only where the add-on will take it
|
||||
|
||||
**Status: DONE 2026-10-26 (owner go 2026-10-25, from the PRICE-02 rig test F-1 and F-2).**
|
||||
|
||||
## Decision
|
||||
|
||||
Home asks the add-on's own gate, and does not decide for itself.
|
||||
|
||||
- **Modes on.** `Controller._mode_roles()` files the gate it just asked
|
||||
(`TariffPlan.arbitrage()` with the measured peak guard) as `mode_gate`: None
|
||||
when it allowed a charge, else its reason (`peak`, `not_worth`,
|
||||
`not_covered`). While it is set, `homeview.mode_runs()` draws no grid charge
|
||||
and `price_windows()` marks no cheap quarter. Control never reads it. A
|
||||
refusal that is not the gate (no measured headroom, a battery cycling) is
|
||||
not filed, so those pages are as before.
|
||||
- **Modes off.** A charge the verdict puts after its last dear period is
|
||||
refused when its time comes: the quarters left are all equal. `leg_taken()`
|
||||
asks `trade_verdict()` (the control cycle's own call) at the leg's start and
|
||||
keeps the leg only if the gate allows it there. A leg in progress is the
|
||||
verdict's own answer now. The sale, "Tomorrow:" lines and chart marks use
|
||||
the same answer.
|
||||
- **Words.** "Lowest/Highest published price" becomes "Lowest/Highest price
|
||||
left" (the verdict reads only the periods not yet over). The flat-tariff
|
||||
refusal calls a site "a fully fixed product" only when its product is fixed.
|
||||
|
||||
## What does not change
|
||||
|
||||
No control decision, command or option. Day/night and hourly sites with a
|
||||
second day of prices keep both legs at every half hour of a day
|
||||
(`test_ux53.py` part 4), except where every price left is equal: with an
|
||||
all-night weekend, Friday's 22:00 charge is no longer drawn, as the add-on
|
||||
does not take it. Price-off sites draw nothing, as before.
|
||||
|
||||
## Limits
|
||||
|
||||
The leg's gate is asked on today's plan, as the control cycle will find it at
|
||||
that time. A plan that gets new prices in between is drawn again at the next
|
||||
refresh. While the peak guard holds, a modes-on page shows no grid charge at
|
||||
all, also not the rest of a run the add-on charges in once the guard clears,
|
||||
as the modes-off page already did.
|
||||
+30
-226
@@ -1,9 +1,8 @@
|
||||
# GoodWe RS485 Controller
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -34,8 +33,8 @@ v0.1.0 and outdated; where it disagrees with this page, this page wins.
|
||||
|
||||
You need:
|
||||
|
||||
- A GoodWe **ES / BP family** inverter (AA55 generation)
|
||||
- The vendor's controller **disconnected** from the inverter's EMS port
|
||||
- A GoodWe **ES / BP family** inverter (AA55 / RS485 meter-bus generation)
|
||||
- The vendor's meter-emulating controller **disconnected** from the bus
|
||||
- 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
|
||||
@@ -187,8 +186,6 @@ The label and description each setting shows on the Configuration tab, in tab or
|
||||
| Hardware | Battery modes | `battery_modes_enabled` | Give each battery an on and off switch and a mode, set on the Batteries screen. |
|
||||
| Hardware | Solar forecast, today (kWh) | `pv_forecast_entity` | Optional. A forecast sensor with today's remaining solar energy, such as Forecast.Solar or Solcast. A battery then buys only what the sun will not give. |
|
||||
| Hardware | Solar forecast, tomorrow (kWh) | `pv_forecast_tomorrow_entity` | Optional. The same forecast for tomorrow. It helps plan a cheap hour before midnight. |
|
||||
| Hardware | Several solar forecasts, today (kWh) | `pv_forecast_entities` | Optional. One row per forecast sensor, such as one per inverter, used instead of the single one above. They are added up. |
|
||||
| Hardware | Several solar forecasts, tomorrow (kWh) | `pv_forecast_tomorrow_entities` | Optional. The same list for tomorrow. If one sensor has no reading, that day's forecast is not used. |
|
||||
| Hardware | Trust in the forecast (%) | `solar_trust_pct` | Forecasts are often too sunny. At 70 % the add-on plans with 7 of every 10 kWh forecast. |
|
||||
| Hardware | Solar production entity (W) | `pv_entity` | Optional. Your solar inverter's power sensor, positive while producing. The add-on records it and does not use it for control. |
|
||||
| Hardware | Solar production entities, per inverter (W) | `pv_entities` | Optional. Use one row per solar inverter instead of “Solar production entity” when you have several. If one stops reporting, no total is recorded. |
|
||||
@@ -211,7 +208,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 | `peak_forecast_entity` | Optional. A forecast of this 15-minute period's average power, in W or kW. With no power unit it is not used. Leave it empty and there is no peak limit. |
|
||||
| Limits | 15-minute demand forecast entity (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 | 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. |
|
||||
@@ -223,7 +220,6 @@ The label and description each setting shows on the Configuration tab, in tab or
|
||||
| Tariff | Your own injection formula | `price_export_formula` | Optional. The injection formula from your tariff card, in the same shape as your own tariff formula. Leave it empty to use the flat injection price. |
|
||||
| Tariff | Price CSV file | `price_csv_path` | For manual_csv only. Where in /share the add-on reads your price file. |
|
||||
| Tariff | Price feed URL override | `price_feed_url` | Leave it empty to use the source's own feed. An override is sent exactly as typed and often returns only one day. |
|
||||
| Tariff | Price service token | `price_service_token` | For the price service only. The token you were given for it. Leave it empty with any other price source. |
|
||||
| Tariff | Price look-ahead (hours) | `price_horizon_h` | How many hours of prices to ask the source for. |
|
||||
| Tariff | Price request timeout (s) | `price_timeout_s` | How long one request to the price source may take before it is given up. |
|
||||
| Tariff | Current price entity (older) | `price_now_entity` | The older way to get a price, used only to charge in a cheap window during maintenance. Do not combine it with a dynamic price source. |
|
||||
@@ -234,7 +230,7 @@ The label and description each setting shows on the Configuration tab, in tab or
|
||||
| Tariff | Trading charges at most (%) | `arbitrage_soc_ceiling` | Price trading will not charge the battery above this level. |
|
||||
| Maintenance | Run the monthly battery cycle | `maintenance_enabled` | Once a month, empty the battery deeply, charge it full and hold it there, so its level reading stays accurate. Off by default. |
|
||||
| Maintenance | Minimum days between cycles | `maintenance_interval_days` | The shortest gap between two maintenance cycles. |
|
||||
| Maintenance | Start hour (local time) | `maintenance_start_hour` | The hour on your local clock, 0 to 23, when a due cycle starts. 19 means 19:00. |
|
||||
| Maintenance | Start hour | `maintenance_start_hour` | The local hour, 0 to 23, at which a due cycle starts. |
|
||||
| Maintenance | Draw-down rate (W) | `maintenance_discharge_w` | How fast the cycle empties the battery. Above “Inverter rating” it is cut back to the rating, and the cycle just takes longer. |
|
||||
| Maintenance | Recharge rate (W) | `maintenance_charge_w` | How fast the cycle recharges the battery. It must be at or below “Inverter rating”, or the add-on will not start. |
|
||||
| Maintenance | Draw down to (%) | `maintenance_soc_floor` | The level the monthly cycle empties the battery to. It is not the “Peak reserve”, and the two differ on purpose. |
|
||||
@@ -292,7 +288,6 @@ When the add-on refuses to start, each reason names the setting by its label and
|
||||
- **Fixed or day/night tariff** (`static_profile`): the periods must cover every day type exactly once with no gap and no overlap, and a gap is refused rather than filled. A window 22:00-07:00 on mon-fri does not cover Saturday morning, so a weekend row is required. See [Static day/night and fully-fixed tariffs](#static-daynight-and-fully-fixed-tariffs-static_profile).
|
||||
- **Your own tariff and injection formula** (`price_formula`, `price_export_formula`): type the card's numbers in the card's units. The `card` block is required on the tariff formula: the add-on recomputes the card's example price and refuses to start if it does not come out. Injection is VAT-exempt in Belgium, so `vat_pct` there is normally 0. See [Your own tariff formula](#your-own-tariff-formula-custom_formula) and [`price_export_formula`, field by field](#price_export_formula-field-by-field).
|
||||
- **Price feed URL override** (`price_feed_url`): only a host the chosen source declares is accepted. Nothing is added to the URL, not even the date range, so an override often returns one day and the rest of the plan is carried over and excluded from trading. The log warns on every refresh.
|
||||
- **Price service token** (`price_service_token`): a password field. Every page of the add-on, and every answer its web app gives, shows only `********` when a token is set and nothing when not; the token itself is never sent to a page. A save that leaves the field empty or as `********` keeps the stored token, and a new value replaces it. It must be at least 16 characters. It is needed with `price_provider: price_service`, and the add-on refuses to start without it. It never appears in the log, the status page, a refusal or a notification. See [`price_service`](#price_service--your-own-price-server-and-what-it-falls-back-to).
|
||||
- **Current and average price entities** (`price_now_entity`, `price_avg_entity`): kept because removing them would take the maintenance cycle's only price signal away from a live site.
|
||||
- **Trade on the price spread** (`arbitrage_enabled`): off as shipped, on purpose, because it moves energy against the meter by design. It needs a price source and a P1 meter, and the add-on refuses combinations that cannot work.
|
||||
- **Battery cycle cost** (`cycle_cost_threshold_eur`): wear plus conversion losses. It is not derived from a measurement: 0.10 is a product decision still open with the owner (OPT-08). At this value a fixed day/night site does not trade, since a realistic Belgian day/night spread is about 0.08 EUR/kWh.
|
||||
@@ -544,9 +539,6 @@ With battery modes on, these choices replace the *Charging and selling on price*
|
||||
- With no prices, or too small a gap, every battery follows the house.
|
||||
|
||||
**Solar forecast** (`pv_forecast_entity`, optional). A Home Assistant sensor with the solar energy still to come today, in kWh. These work: Forecast.Solar (`sensor.energy_production_today_remaining`), Solcast PV Forecast (`sensor.solcast_pv_forecast_forecast_remaining_today`) and Open-Meteo Solar Forecast (`sensor.energy_production_today_remaining`). Set `pv_forecast_tomorrow_entity` to the matching *tomorrow* sensor to plan a cheap run before midnight.
|
||||
- **Several forecasts.** If your forecast comes as several sensors (one per inverter or roof side), list them in `pv_forecast_entities` and, for tomorrow, `pv_forecast_tomorrow_entities`, instead of the single key. They are added up, each in its own unit (Wh, kWh or MWh). Set the list or the single key for a day, not both.
|
||||
- If one of them has no reading, is stale or is not in an energy unit, that day's forecast is not used at all. A part of the sun is not counted as all of it. The status page names the source by its place in the list.
|
||||
- The hourly shape is kept only if every sensor has one. Otherwise the total is spread over the day as for Forecast.Solar.
|
||||
- Before each cheap run the add-on asks: will the sun fill the batteries before the expensive hours? It counts on `solar_trust_pct` of the forecast (70 %), less what the house uses. Solar first batteries get that sun first.
|
||||
- *Leftover solar* then charges from the grid only the shortfall: what the house will draw from it in the expensive hours, less its share of the sun. Not up to the top level.
|
||||
- *Cheap hours only* ignores the forecast and fills to the top level.
|
||||
@@ -558,10 +550,10 @@ With battery modes on, these choices replace the *Charging and selling on price*
|
||||
|
||||
**What always comes first.**
|
||||
- The peak limit, the safety stops and the monthly cycle come before a battery's mode.
|
||||
- A grid charge is sized so the 15-minute period stays under the peak limit less its margin (`peak_margin_w`). It starts only at 500 W or more. Once the peak limit stops it, it stays off for the rest of that 15-minute period. The start of a new period does not stop it.
|
||||
- A grid charge is sized so the 15-minute period stays under the peak limit less its margin (`peak_margin_w`). It starts only at 500 W or more. Once the peak limit stops it, it stays off for the rest of that 15-minute period.
|
||||
- While one battery charges from the grid, the others rest, and the house uses the grid.
|
||||
- While one battery empties for its monthly cycle, no battery charges from the grid.
|
||||
- A battery that refuses to charge or give power takes no part in a run in that direction. In a cheap run it is still tested every 15 minutes: it charges at 200 W for about half a minute while the others rest.
|
||||
- A battery that refuses to charge or give power takes no part in a run in that direction.
|
||||
|
||||
**In Home Assistant.** With MQTT set up, each battery also gets a *Mode* list and a *Use* switch, for example `select.goodwe_inverter_a_mode` and `switch.goodwe_inverter_a_use`. They do the same as the Batteries screen. Turn battery modes off and restart, and they are removed.
|
||||
|
||||
@@ -569,7 +561,7 @@ With battery modes on, these choices replace the *Charging and selling on price*
|
||||
|
||||
*For installers:* a mode is a limit at priority 35, kept apart from the hard limits. It gives way only as far as a limit above it (the peak guard) needs. A mode range always holds 0 W, so a mode can forbid a direction and never force one. With one inverter the mode also bounds the loop. The record is `DECISIONS/OPT-11-battery-modes.md`.
|
||||
|
||||
*For installers:* when a charge or sale ends, the loop restarts at the house's own level rather than at the trade's watts, and holds there until the meter shows the change (at most 18 s, up to 36 s while the battery is still moving). This removes the ~3 kW import spike after a 2.5 kW charge and the export overshoot that readings a second or two old caused (-1120 W on the test rig); with the hold it stays within 0.15 × the battery's power. The *Mode* list gains *Cheap hours only* in Home Assistant, at the end.
|
||||
*For installers:* when a charge or sale ends, the loop restarts at the house's own level rather than at the trade's watts. On the replay model that removes a ~3 kW import spike after a 2.5 kW charge and halves the settling time; about 0.1 × the battery's power still overshoots as export. The *Mode* list gains *Cheap hours only* in Home Assistant, at the end.
|
||||
|
||||
### Reading the grid-following loop: `Loop integrator`, `Loop integrator lead`, `Loop reason`
|
||||
|
||||
@@ -598,7 +590,7 @@ accumulator is doing:
|
||||
| `freeze-released` | the freeze had run out of plant-shaped explanations and the command was let move again |
|
||||
| `mode-bound` | the batteries' limits set the command, not the meter: their battery modes, or, with two or more inverters, a battery in its monthly maintenance |
|
||||
| `not-running` | control is stopped **and** shadow mode is off, so the loop did not run |
|
||||
| `battery refuses` | the battery gave less than 100 W for 12 cycles and at least 22 s (a minute at a 5-second meter) while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) |
|
||||
| `battery refuses` | the battery gave less than 100 W for 12 cycles (a minute at a 5-second meter) while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) |
|
||||
| `battery probe` | the add-on asks 200 W to test whether a refusing battery gives power again |
|
||||
|
||||
**When the battery refuses.** At its cut-out level the battery gives nothing,
|
||||
@@ -608,15 +600,7 @@ request, and the `Why` row reads *The battery refuses to give power: 0 W*. Every
|
||||
the battery gives 100 W or more, when the grid needs charging instead, and when
|
||||
control is stopped or started. 200 W is below the 250 W the stale-reading check
|
||||
needs, so a test never trips it. A battery that gives some power, even 100 W,
|
||||
is not refusing and keeps its request. Nor is a battery whose reading is
|
||||
still moving towards the request, such as one turning from charging to
|
||||
giving (SAFETY-94). "Gives" is counted from what the battery's reading shows
|
||||
at rest, which the add-on learns after two quiet minutes and keeps: some
|
||||
inverters show about -100 W at rest (SAFETY-97).
|
||||
While another task sets the power, such as a maintenance charge, the peak
|
||||
guard or a trade, the refusal stays as long as the battery still refuses that
|
||||
task's request. It is logged once, and the 200 W test waits until control sets
|
||||
the power again (SAFETY-107).
|
||||
is not refusing and keeps its request.
|
||||
|
||||
**With two or more inverters** the same rule works per inverter. An inverter
|
||||
whose battery gives less than 100 W for 12 cycles, and at least 22 s, while
|
||||
@@ -630,7 +614,7 @@ If the battery refuses to give power while its level reads above its “Battery
|
||||
cut-out level”, the log adds one warning ending in `REFUSES_ABOVE_CUT_OUT`: check that setting.
|
||||
A battery that refuses is not reported as *not delivering*.
|
||||
|
||||
*For installers:* SAFETY-52, SAFETY-73 and SAFETY-107. The event log reads
|
||||
*For installers:* SAFETY-52 and SAFETY-73. The event log reads
|
||||
`BATTERY REFUSES …` and `BATTERY REFUSES ENDED …` (with the inverter's name at
|
||||
N>1), and the INFO line ends in `BATTERY_REFUSES`.
|
||||
`DECISIONS/SAFETY-52-a-battery-that-refuses.md` has the replay figures and
|
||||
@@ -1655,50 +1639,18 @@ help - see "Why the tuning is what it is".
|
||||
|---|---|---|
|
||||
| `maintenance_enabled` | false | Enable the monthly cycle |
|
||||
| `maintenance_interval_days` | 28 | Minimum gap between cycles |
|
||||
| `maintenance_start_hour` | 19 | Local hour a due cycle begins (see below) |
|
||||
| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). The peak guard may ask for more (SAFETY-83; at N≥2 while another battery is at its peak reserve, SAFETY-87), and at N≥2 so may the house while another battery is at its peak reserve (SAFETY-91). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower |
|
||||
| `maintenance_start_hour` | 10 | Hour of day a due cycle begins |
|
||||
| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). The peak guard may ask for more (SAFETY-83; at N≥2 while another battery is at its peak reserve, SAFETY-87). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower |
|
||||
| `maintenance_charge_w` | 2000 | Charge ceiling, capped again by peak headroom. Must stay **at or below `max_w`** or the cheap-window shaping (`hi = -maintenance_charge_w`) contradicts the supervised limit (`lo = -max_w`), the arbiter resolves it to **0 W**, and maintenance silently never charges in a cheap window — **the add-on refuses to start** |
|
||||
| `maintenance_soc_floor` | 11 | Drain target — stay just above the inverter's own floor. ⚠️ **This is not `peak_soc_floor`.** It is the depth the monthly recalibration deliberately goes to, once, on purpose; `peak_soc_floor` is the depth a money-saving discharge may never reach on any day. The right values differ (11 vs 20 as shipped), which is why they are two keys |
|
||||
| `maintenance_soc_target` | 99 | Charge target |
|
||||
| `maintenance_hold_min` | 120 | Hold at full so the BMS can balance |
|
||||
|
||||
**The start hour is local time.** The add-on reads Home Assistant's time zone
|
||||
(Settings → System → General) at startup. If Home Assistant does not answer,
|
||||
it uses the add-on's own time zone, and failing that UTC. The log says which
|
||||
one it used, and warns if it is UTC: then restart the add-on once Home Assistant
|
||||
is up. On the day the clocks go forward, a start hour that does not exist
|
||||
starts at the first hour after the jump. On the day they go back, a repeated
|
||||
hour starts only once, in its first pass. With two inverters both batteries use
|
||||
the same local hour; one cycles at a time, and the other starts at that hour on
|
||||
a later day.
|
||||
|
||||
**Upgrading.** The hour used to be read as UTC. An existing install keeps its
|
||||
saved hour, and that hour now means local time: a saved 10 now starts at 10:00,
|
||||
not 12:00 in summer. The new default, 19, only reaches installs that have no
|
||||
hour saved. If you set an earlier hour to get an evening start in UTC (for
|
||||
example 17 for 19:00 in summer), set it to 19. The add-on never changes a saved
|
||||
value. *For installers:* DECISIONS/MAINT-10-local-start-hour.md.
|
||||
|
||||
**When the charge counts as done.** A battery may stop at 98 or 99 % while it
|
||||
balances, so it need not reach `maintenance_soc_target`. The charge counts as
|
||||
done when the battery is within 3 points of the target and has taken no more
|
||||
charge for 60 minutes of charging time, or when the charging time (36 hours) runs
|
||||
out within 3 points of the target. Only the minutes the cheap-hours gate let the
|
||||
battery charge count, and the charge ends after 7 days at the
|
||||
latest. Then the usual hold follows.
|
||||
|
||||
**When a cycle fails.** If the battery does not empty in 12 hours, or does not
|
||||
get within 3 points of the target in 36 hours of charging time, the cycle
|
||||
stops, you get the notification *Battery cycle did not finish*, and it is not
|
||||
tried again for 7 days. The status page shows *Maintenance paused* with the
|
||||
date. Starting a cycle by hand ignores the wait. *For installers:*
|
||||
DECISIONS/MAINT-13-full-pack-is-complete.md.
|
||||
|
||||
### Tariff (all optional)
|
||||
|
||||
| option | meaning |
|
||||
|---|---|
|
||||
| `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_forecast_entity` | Quarter-hour demand forecast, for capacity-tariff markets. 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` |
|
||||
@@ -1715,9 +1667,6 @@ others only add power when the house needs more; none charges while it drains
|
||||
(SAFETY-78). Below the drain rate the rest is exported, as with one battery.
|
||||
If the house would still go over the peak limit, the battery being emptied
|
||||
gives more than its drain rate, with one battery too (SAFETY-83).
|
||||
A battery at its peak reserve gives last: the others give their full power
|
||||
first, and the battery being emptied rises above its drain rate before it
|
||||
(SAFETY-91).
|
||||
In a cheap window the battery being filled charges and the others only add
|
||||
charge; none gives while it charges. The charge is cut to what keeps the
|
||||
15-minute peak at the limit minus `peak_margin_w` (SAFETY-84).
|
||||
@@ -1725,14 +1674,6 @@ While a full battery rests after its charge (the hold), it gives just enough
|
||||
to keep the 15-minute peak at that aim if the house needs it; with two
|
||||
batteries the other one gives first. The hold still ends on its clock
|
||||
(SAFETY-86).
|
||||
When a charge ends, the other batteries take over one slew step a cycle,
|
||||
and none starts charging from the grid (SAFETY-93).
|
||||
When a battery's link drops, the others take over one slew step a cycle,
|
||||
and control does not stall on the dropped battery's power (SAFETY-96).
|
||||
When a drain ends (done, Off, timeout, abort, lost link, refused), the
|
||||
other batteries cover the house at once but do not export its drain; then
|
||||
they move one slew step a cycle (SAFETY-95). While that battery settles,
|
||||
an old battery reading does not move them (SAFETY-103).
|
||||
|
||||
#### Capacity-tariff exposure of the grid-following loop
|
||||
|
||||
@@ -1802,17 +1743,10 @@ ceiling at the cap, so a broken instrument makes the guard stricter, never loose
|
||||
**When it cannot see, it forbids importing.** Three states are not evidence and
|
||||
are checked before the projection is graded: no open quarter (a fresh averager
|
||||
projects a confident 0 W, which is under every ceiling there is), a meter gap
|
||||
longer than `meter_max_age_s` while it lasts, and SAFETY-18's frozen-meter
|
||||
conjunction. In all three the claim becomes a floor of **0 W**. That is the
|
||||
backstop for the frozen meter: the alarm arrives at `meter_freeze_s` (300 s) of
|
||||
a 900 s billing quarter and commands nothing, and this stops the import.
|
||||
|
||||
**After a gap it goes on (SAFETY-90).** The seconds the meter missed count at
|
||||
the higher of the peak limit and the import seen just before and just after
|
||||
the gap, with the batteries at 0 W. Seconds before the add-on's first reading
|
||||
count the same way, at the import seen first, and the page says part of the
|
||||
period was not measured. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md has the
|
||||
bound.
|
||||
longer than `meter_max_age_s`, and SAFETY-18's frozen-meter conjunction. In all
|
||||
three the claim becomes a floor of **0 W**. That is the backstop for the frozen
|
||||
meter: the alarm arrives at `meter_freeze_s` (300 s) of a 900 s billing quarter
|
||||
and commands nothing, and this stops the import.
|
||||
|
||||
**It trips, where the freeze detector only notifies,** and the captures are why.
|
||||
On the healthy reference day (2026-08-23) it engages on **zero** of 95 quarters.
|
||||
@@ -1875,9 +1809,7 @@ exactly like the SoC drift the monthly maintenance cycle exists to correct.
|
||||
It will discharge straight past this number and keep going until the pack's own
|
||||
BMS cut-out stops it. What you are promised by `peak_soc_floor: 20` is *"the
|
||||
capacity-peak guard will not spend the pack for money below 20 %"*, and
|
||||
**nothing more**. See below for what that costs on a real evening. With two
|
||||
or more batteries, one under it is used last: only once the others give
|
||||
their full power (SAFETY-91).
|
||||
**nothing more**. See below for what that costs on a real evening.
|
||||
- **When it binds you are told**, once per episode, with notification
|
||||
`goodwe_peak_soc_floor` and a red status-page row reading *Battery at its
|
||||
peak reserve* (`PEAK_GUARD_FLOOR_LIMITED` in the log): the quarter **will**
|
||||
@@ -2201,24 +2133,20 @@ does, and only after you have read why.
|
||||
> `static_profile` below needs no network at all, so its cheap window is
|
||||
> computed on every cycle and reaches the maintenance cycle's grid top-up
|
||||
> whether or not arbitrage is armed. ⚠️ **And it is still the only source of
|
||||
> that window, with one exception.** A provider-backed plan exists on every
|
||||
> configured site, but *Cheap window* and the maintenance cycle's grid top-up
|
||||
> read `static_profile` and nothing else — a deliberate limit, recorded with
|
||||
> what it would take to change in
|
||||
> `DECISIONS/TARIFF-10-fetching-is-not-trading.md`. The exception is
|
||||
> `price_service` (PRICE-01): with it, the window comes from the provider's own
|
||||
> prices, as described under [`price_service`](#price_service--your-own-price-server-and-what-it-falls-back-to).
|
||||
> that window.** A provider-backed plan now exists on every configured site,
|
||||
> but *Cheap window* and the maintenance cycle's grid top-up read
|
||||
> `static_profile` and nothing else — a deliberate limit, recorded with what it
|
||||
> would take to change in `DECISIONS/TARIFF-10-fetching-is-not-trading.md`.
|
||||
|
||||
| option | meaning |
|
||||
|---|---|
|
||||
| `price_provider` | `off` (default) or one of the plugins below |
|
||||
| `price_provider` | `off` (default) or one of the three plugins below |
|
||||
| `price_markup_eur_kwh` | Your supplier's margin, EUR/kWh. **Added only when the chosen plugin's feed does not already contain it** |
|
||||
| `price_taxes_eur_kwh` | Distribution, levies and VAT, EUR/kWh. Same rule. Defaults to **0.11736** — a real Flemish figure, and **yours is probably different**: see *What `price_taxes_eur_kwh` is made of* below |
|
||||
| `export_price_eur_kwh` | The **flat injection** price, EUR/kWh. May be negative — see below |
|
||||
| `price_export_formula` | The **injection formula**, if your contract has one. Same shape as `price_formula`, its own coefficients, its own `vat_pct` — see below |
|
||||
| `price_csv_path` | Where `manual_csv` reads its file |
|
||||
| `price_feed_url` | Override the plugin's default feed. Used **exactly as written** — the add-on appends nothing to it, including the date range, so an override gets whatever horizon that URL returns. Only a host the plugin itself declares is accepted |
|
||||
| `price_service_token` | The token for `price_service`. A password field. Empty with any other source |
|
||||
| `price_horizon_h` | How far ahead to ask for, hours |
|
||||
| `price_timeout_s` | Per-request timeout |
|
||||
| `static_profile` | A static day/night or fully-fixed tariff. Empty by default — see below |
|
||||
@@ -2231,7 +2159,6 @@ arbitrage decision wrong by that amount, in a direction nothing reports.
|
||||
| plugin | product | the published figure contains | the add-on adds |
|
||||
|---|---|---|---|
|
||||
| `generic_dayahead` | day-ahead | energy only (wholesale EPEX BE) | markup **and** taxes |
|
||||
| `price_service` | day-ahead | energy only (wholesale, from your own price server) | markup **and** taxes |
|
||||
| `custom_formula` | day-ahead | energy **and** your contract's markup — your card's `Tariefformule` line | taxes |
|
||||
| `manual_csv` | day-ahead | everything, all-in | nothing |
|
||||
|
||||
@@ -2261,86 +2188,6 @@ from. Any alias would have to invent the numbers your battery is priced against.
|
||||
Eneco's dynamic product is *Zon & Wind Dynamisch*, and it is indexed to
|
||||
Belpex/EPEX **day-ahead**, not to imbalance.)
|
||||
|
||||
#### `price_service` — your own price server, and what it falls back to
|
||||
|
||||
*For installers: PRICE-01. The decision is in
|
||||
`DECISIONS/PRICE-01-own-price-server.md`.*
|
||||
|
||||
`price_provider: price_service` reads the day-ahead prices for Belgium from the
|
||||
owner's price server, `https://prices.kammenstraatha.duckdns.org/v1/dayahead/BE`.
|
||||
The host is fixed in the add-on and only HTTPS is used. The token goes in
|
||||
`price_service_token` and is sent as `Authorization: Bearer <token>`. The server
|
||||
answers the energy-charts layout, so the same parser reads it. The price is the
|
||||
wholesale energy price and nothing else, so the add-on adds your markup and
|
||||
taxes exactly as for `generic_dayahead`.
|
||||
|
||||
**Where the prices come from.** Each step is used only if it has a price for
|
||||
now. A server that answers with last night's prices is treated as down.
|
||||
|
||||
| Step | Source | When it is used | What the log says (once per change) |
|
||||
|---|---|---|---|
|
||||
| 1 | The price server | It answers, with a price for now | `The price service answers again.` on the way back. |
|
||||
| 2 | energy-charts.info | Step 1 fails: down, timeout, refused token (401), error (5xx), a damaged answer, or no price for now | `The price service does not answer, so prices come from energy-charts.` Or `The price service refused the token, so prices come from energy-charts.` |
|
||||
| 3 | The prices saved in `/data/price_history.json` | Steps 1 and 2 fail | `No price source answers, so the add-on uses the prices it saved.` |
|
||||
| none | Nothing | No saved price covers now either | `No price source answers, and no saved price covers now.` |
|
||||
|
||||
The saved prices are the last answers that worked, kept on disk, so a site
|
||||
whose server and energy-charts are both down for a day goes on planning from
|
||||
what it has. It keeps the days the history holds (today, tomorrow once it was
|
||||
fetched, and a day back). They are the all-in prices as stored: a change of
|
||||
markup or taxes shows from the next fetch that works. Going back up the chain
|
||||
is logged once as well: `Prices come from energy-charts again.` and `The price
|
||||
service answers again.` The refresh runs every 30 minutes.
|
||||
|
||||
**When there is no price for now (step none).** One status line, `Prices: No
|
||||
prices since 00:00, maintenance waits.` (on the add-on page; the Home screen says
|
||||
the same in English and Dutch), and one notification, *No prices*. Both go when a
|
||||
source has a price for now again. They show only while **Run the monthly battery cycle**
|
||||
(`maintenance_enabled`) is on; with it off, neither is shown. "Since" is the moment the last price ran out.
|
||||
Before the first refresh has finished there is no line, so a start-up is not a
|
||||
gap. Control is not touched: the battery follows the meter as ever, battery
|
||||
modes and trading read what prices there are, and only the maintenance charge
|
||||
waits.
|
||||
|
||||
**The maintenance charge and the cheap window.** With this provider the
|
||||
maintenance charge gate reads the provider's prices and no Home Assistant
|
||||
entity. It keeps the rule the Home Assistant pair gave: the charge may run
|
||||
when the **current 15-minute price is at or below today's average**, today
|
||||
being the Brussels day. Moving a site from the pair to `price_service` does not
|
||||
change when the charge runs. Where the two can differ is listed in the decision
|
||||
record: the Home Assistant average was rounded to 0.00001 EUR/kWh, and so is
|
||||
this one before it is compared. With no price for now the gate is shut, as it
|
||||
was when an entity was unavailable. *Cheap window* on the status page shows this
|
||||
answer. This is not the *cheap hours* rule of battery modes and not
|
||||
`cheap_now()`.
|
||||
|
||||
**Moving a site from the Home Assistant price sensors to `price_service`.**
|
||||
Do this on the Configuration tab, in one save, and restart once:
|
||||
|
||||
1. Set **Dynamic price source** (`price_provider`) to `price_service`.
|
||||
2. Paste the token into **Price service token** (`price_service_token`).
|
||||
3. **Clear** **Current price entity** (`price_now_entity`) and **Average price
|
||||
entity** (`price_avg_entity`). The add-on refuses to start with a price
|
||||
source and these two together.
|
||||
4. Check **Supplier markup** and **Distribution, levies and VAT**. With the
|
||||
Home Assistant pair they decided nothing about the maintenance charge. With
|
||||
the provider they are added to the wholesale price, so they set the prices
|
||||
shown on the page and, if battery modes or trading are on, the prices those
|
||||
decide on. The maintenance charge gate does not depend on them.
|
||||
5. Keep **Fixed or day/night tariff** (`static_profile`) if you have one; it
|
||||
is fine to leave it set. It is the plan's last fallback: it is used,
|
||||
marked as estimated and without trading, only when no source has a price.
|
||||
*Cheap window* and the maintenance charge never read it: the provider's
|
||||
prices decide them. The add-on logs one INFO line at start ("Fixed tariff
|
||||
is only used if no prices are available.") and shows the same note beside
|
||||
the setting. Nothing needs clearing, and clearing it only removes the
|
||||
fallback (the settings page then warns that the site has no tariff while the
|
||||
price feed is down).
|
||||
6. Remove the `price_service` REST and template sensors from Home Assistant
|
||||
when you are satisfied. They are no longer read.
|
||||
|
||||
To go back: put the two entities back, set the source to `off`, and restart.
|
||||
|
||||
#### So what should a Belgian dynamic-tariff customer do today?
|
||||
|
||||
**Honestly: `generic_dayahead` gets you close, and you should know exactly how
|
||||
@@ -2897,9 +2744,6 @@ uses the result as a cheap-window hint for the maintenance charge. They are
|
||||
— configure two of them and the add-on refuses to start, naming the conflict,
|
||||
because the add-on reads exactly one and the loser is silent. Use one.
|
||||
|
||||
To move a site off them onto the owner's price server, see [`price_service`](#price_service--your-own-price-server-and-what-it-falls-back-to):
|
||||
it lists which options to set and which to clear.
|
||||
|
||||
### The three buckets — which price components can move a decision
|
||||
|
||||
*For installers: card TARIFF-17.*
|
||||
@@ -3114,16 +2958,15 @@ 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 dynamic pricing is an EMS for a minority. `static_profile` is how a
|
||||
works under hourly 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".
|
||||
|
||||
On the add-on page, choose **Other time windows** under Settings, Tariff and
|
||||
prices, or in Setup step 4. Add one row per time window, with its start, end,
|
||||
days and price. The page names any overlap, gap or empty price before you can
|
||||
save. Prices take five decimals: divide the c€/kWh on your bill by 100, so
|
||||
34.512 c€/kWh is 0.34512 €/kWh. You only need the JSON below for days the page
|
||||
cannot choose, such as Monday and Wednesday only.
|
||||
save. You only need the JSON below for days the page cannot choose, such as
|
||||
Monday and Wednesday only.
|
||||
|
||||
It is one option, holding a JSON object, on one line:
|
||||
|
||||
@@ -3366,30 +3209,8 @@ reading is stuck. Control keeps running and keeps following the reading, so a
|
||||
stuck reading can raise this month's peak. Check the meter. If it is stuck,
|
||||
stop control on the add-on page.
|
||||
|
||||
### Battery cycle did not finish
|
||||
|
||||
The monthly battery cycle stopped because the battery did not empty, or did not
|
||||
fill up, in time. The battery is back under normal control. The add-on waits 7
|
||||
days before it tries again, so it does not empty the battery every night. To
|
||||
try sooner, start a cycle on the add-on page. If it keeps happening, check that
|
||||
the battery follows its commands and that the cheap hours leave time to charge.
|
||||
|
||||
### No prices
|
||||
|
||||
No price source has a price for the present moment: not the price server,
|
||||
not energy-charts, and nothing saved. The monthly battery cycle waits, because
|
||||
it will not charge from the grid without a price to judge by. Control of the
|
||||
battery goes on as before. Check that the add-on can reach the internet and that
|
||||
the price service token is right. The notification goes by itself when a price
|
||||
is back. It is shown only while the monthly battery cycle is switched on.
|
||||
|
||||
### For installers: notifications
|
||||
|
||||
A fixed tariff beside the price service raises no notification: it is the
|
||||
plan's last fallback, not a fault. An earlier pre-release build raised a card
|
||||
with the id `goodwe_fixed_tariff_unused`; the add-on takes that id down at every
|
||||
start, which does nothing when it is absent.
|
||||
|
||||
This part is for installers. The texts live in `app/messages.py` and follow
|
||||
`STYLE.md`. Every notification keeps a fixed `notification_id`, so a repeat
|
||||
replaces the card. The ERROR or WARNING line of the add-on log names the
|
||||
@@ -3407,8 +3228,6 @@ default `info` hides it.
|
||||
| Inverters working against each other | alarm | `goodwe_circulating_power` | SAFETY-31 |
|
||||
| *name* is not delivering | alarm | `goodwe_not_delivering_<name>` | SAFETY-42 |
|
||||
| Meter reading may be stuck | alarm | `goodwe_meter_frozen` | SAFETY-18 |
|
||||
| Battery cycle did not finish | warning | `goodwe_maint_failed_<name>` | MAINT-13 |
|
||||
| No prices | warning | `goodwe_no_prices` | PRICE-01 |
|
||||
|
||||
**The Details link.** Home Assistant 2026.2 and newer show add-on pages at
|
||||
`/config/app/<slug>/documentation`; 2026.5 removed the old
|
||||
@@ -3520,11 +3339,6 @@ day's price runs.
|
||||
An Off battery that the peak limit still needs says so, with the power it
|
||||
gives: "Off, but gives 0.5 kW for the peak limit."
|
||||
|
||||
With two or more batteries, each card also has **Start cycle** and **Stop
|
||||
cycle**, to run the monthly cycle on that battery now. Only one battery cycles
|
||||
at a time: a start while another battery is cycling, or on a battery that is
|
||||
switched off, is refused and the message names the battery.
|
||||
|
||||
**Settings** opens on the **Simple** view, or on the view you last picked:
|
||||
the peak limit, the batteries' switches and modes, and shadow mode, each
|
||||
marked "Applies right away" or "Applies after a restart". **Installer** shows
|
||||
@@ -3590,7 +3404,6 @@ for a new install*.
|
||||
|
||||
| Row starts with | What it tells you |
|
||||
|---|---|
|
||||
| Home Assistant is not answering | Home Assistant is not passing readings or power requests on, usually because it is still starting. The add-on cannot control until then. It resumes by itself. Nothing to do unless it lasts. |
|
||||
| Control | When control last ran. Red when it has stopped running: the control board then sets the inverter to 0 W. |
|
||||
| Meter | The grid reading, its age, how many readings were refused, and how long its value has not changed. Just after a start it is amber and *waiting for the first reading*. Red means no reading for longer than the limit. |
|
||||
| Stuck-meter check | Whether the add-on can warn you about a stuck meter. |
|
||||
@@ -3606,7 +3419,6 @@ for a new install*.
|
||||
| Direction check, Site direction check | Whether each inverter's power goes the way the add-on asks. *Opposite for 12 of 60 seconds* counts toward a stop. When it stops, the inverter, or every inverter, is set to 0 W until you start control. |
|
||||
| Check for inverters working against each other, Inverters worked against each other | Only with several inverters. See *Inverters working against each other*. |
|
||||
| Delivery check, *name* is not delivering | Only with several inverters. See *An inverter is not delivering*. |
|
||||
| Prices | Only while no price source has a price for now: *No prices since 00:00, maintenance waits.* See *No prices* under Notifications. |
|
||||
| Solar production | Your solar production. Sources are numbered in the order of “Solar production entities, per inverter”. A strongly negative reading by day means that entity reads the wrong way round: add it to “Reversed-sign solar entities”. |
|
||||
|
||||
### For installers: status page
|
||||
@@ -3638,8 +3450,7 @@ reason code, and its full figures are on the DEBUG line right after it and in
|
||||
…`, and the peak guard's full reason is on the DEBUG line after its ERROR.
|
||||
- ***name*: battery power reading stopped changing** is SAFETY-37/56/57. The
|
||||
ERROR line says when it clears and names `STALE_READING`. The full reason,
|
||||
with both possible causes, is on the DEBUG line after it. The 35 s counts
|
||||
only while the command asks the reading to move (SAFETY-94).
|
||||
with both possible causes, is on the DEBUG line after it.
|
||||
- **Meter: no reading for …** (red): the add-on logs one WARNING when the
|
||||
meter goes quiet, "No meter reading for …", and an INFO line when readings
|
||||
return, at most once every 10 minutes. Past `stale_input_s` the **Why**
|
||||
@@ -3650,13 +3461,6 @@ reason code, and its full figures are on the DEBUG line right after it and in
|
||||
(TARIFF-11), in words.
|
||||
- **Solar production** reads `pv_entity` or `pv_entities` (TEL-11/12). The
|
||||
*Reversed-sign solar entities* setting is `pv_invert_entities` (TEL-14).
|
||||
- **Home Assistant is not answering** (SAFETY-112): the Supervisor answers
|
||||
502 to every add-on read, write and websocket until Home Assistant is
|
||||
RUNNING, even when its entities are already live. A slow integration at
|
||||
start-up therefore holds control for as long as it holds Home Assistant's
|
||||
start. The log has one WARNING, a "Still waiting" INFO line every 5
|
||||
minutes and "answers again after …" at the end. See
|
||||
`DECISIONS/SAFETY-112-waiting-for-home-assistant.md`.
|
||||
- **Shadow mode** (SAFETY-10): the banner turns red when a latch is up
|
||||
(circulating power, or a site or per-inverter sign trip), and that latch keeps
|
||||
its own red row.
|
||||
|
||||
@@ -737,9 +737,9 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
|
||||
gate, held = _unit_modes(out, site_claims, bounds, live)
|
||||
if callable(weights):
|
||||
weights = weights(out.target_w)
|
||||
# SAFETY-87/91: units under their peak reserve weigh 0 while the site
|
||||
# gives, so the others carry the target first. An Off battery gives the
|
||||
# peak's shortfall, so it goes before them when no other unit is left.
|
||||
# SAFETY-87: units under their peak reserve weigh 0 while the site gives,
|
||||
# so the others carry the peak floor. An Off battery gives the peak's
|
||||
# shortfall, so it goes before them when no other unit is left.
|
||||
reserve = set(reserve) if out.target_w > 0.0 else set()
|
||||
if held and reserve and not set(bounds) - held - reserve:
|
||||
held = set()
|
||||
|
||||
@@ -172,7 +172,7 @@ IDLE_NOT_COVERED = (
|
||||
IDLE_NO_SOC = (
|
||||
"state of charge is not being read, and a battery whose fill nobody knows "
|
||||
"cannot be told to empty or to fill")
|
||||
IDLE_OFF_PEAK_HOUR = "now is not the cheapest or the dearest price period"
|
||||
IDLE_OFF_PEAK_HOUR = "this hour is neither the buy hour nor the sell hour"
|
||||
# OPT-09. A TRADE NOTHING CAN TAKE IS NOT A TRADE, AND THE ALTERNATIVE IS WORSE
|
||||
# THAN A MISSED HOUR. `charge_w`/`discharge_w` used to be the site's whole rating
|
||||
# and are now SIZED to the packs whose state of charge permits that direction, so
|
||||
@@ -196,7 +196,7 @@ IDLE_HEADROOM_UNMEASURED = (
|
||||
"creates the billed quantity (§9.1) and nothing is measuring the quarter "
|
||||
"this one would land in")
|
||||
NEGATIVE_PRICE = (
|
||||
"§17: the sell period is priced BELOW ZERO, so discharging into it would give "
|
||||
"§17: the sell hour is priced BELOW ZERO, so discharging into it would give "
|
||||
"energy away and pay for the privilege. Arbitrage must not export at a "
|
||||
"negative price")
|
||||
|
||||
@@ -347,7 +347,7 @@ def decide(plan, now: datetime, *, peak_guard_active, charge_headroom_w, soc,
|
||||
|
||||
verdict = plan.arbitrage(peak_guard_active=peak_guard_active,
|
||||
min_spread_eur_kwh=min_spread_eur_kwh,
|
||||
uncertainty=uncertainty, now=now)
|
||||
uncertainty=uncertainty)
|
||||
return verdict.match(
|
||||
refused=lambda v: _idle(v.reason),
|
||||
allowed=lambda v: _act(plan, now, v, soc, charge_w, discharge_w,
|
||||
@@ -362,7 +362,7 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct,
|
||||
if here is None:
|
||||
return _idle(
|
||||
f"the series carries a tradeable spread but no price for this "
|
||||
f"instant ({now.isoformat()}), which is §11.4 for this period rather "
|
||||
f"instant ({now.isoformat()}), which is §11.4 for this hour rather "
|
||||
f"than a reason to reach for a neighbouring one",
|
||||
spread=v.spread_eur_kwh, required=v.required_spread_eur_kwh)
|
||||
|
||||
@@ -381,7 +381,7 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct,
|
||||
# irreversible action may rest on it.
|
||||
if here.provisional:
|
||||
return _idle(
|
||||
f"§8.3: the price for this period ({here.start.isoformat()}) is "
|
||||
f"§8.3: the price for this hour ({here.start.isoformat()}) is "
|
||||
f"CARRIED FORWARD, not published, and no irreversible action may be "
|
||||
f"taken on a provisional price",
|
||||
spread=v.spread_eur_kwh, required=v.required_spread_eur_kwh,
|
||||
@@ -399,18 +399,18 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct,
|
||||
# negative price reports the reason that actually forbids the trade
|
||||
# rather than one that happens to fire first on some other site.
|
||||
if price < 0:
|
||||
return _idle(f"{NEGATIVE_PRICE} - this period is "
|
||||
return _idle(f"{NEGATIVE_PRICE} - this hour is "
|
||||
f"{price:+.4f} EUR/kWh", **common)
|
||||
if soc <= floor_pct:
|
||||
return _idle(
|
||||
f"the sell period is here at {price:.4f} EUR/kWh but the pack is "
|
||||
f"the sell hour is here at {price:.4f} EUR/kWh but the pack is "
|
||||
f"at {soc:g} %, at or under the {floor_pct:g} % reserve floor "
|
||||
f"arbitrage may not spend (§10.1 priority 4)", **common)
|
||||
if not abs(float(discharge_w)) > 0.0:
|
||||
return _idle(IDLE_NO_SIZE, **common)
|
||||
return ArbitrageDecision(
|
||||
action=DISCHARGE, power_w=abs(float(discharge_w)),
|
||||
reason=(f"{DISCHARGE}: this is the dearest firm price period at "
|
||||
reason=(f"{DISCHARGE}: this is the dearest firm hour at "
|
||||
f"{price:.4f} EUR/kWh, {v.spread_eur_kwh:.4f} above the "
|
||||
f"cheapest and clear of the {v.required_spread_eur_kwh:.4f} "
|
||||
f"this cycle had to cover; discharging "
|
||||
@@ -421,7 +421,7 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct,
|
||||
|
||||
if soc >= ceiling_pct:
|
||||
return _idle(
|
||||
f"the buy period is here at {price:.4f} EUR/kWh but the pack is at "
|
||||
f"the buy hour is here at {price:.4f} EUR/kWh but the pack is at "
|
||||
f"{soc:g} %, at or over the {ceiling_pct:g} % ceiling - there is "
|
||||
f"nowhere to put the cheap energy", **common)
|
||||
if not abs(float(charge_w)) > 0.0:
|
||||
@@ -456,7 +456,7 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct,
|
||||
capped = size_w < asked_w
|
||||
return ArbitrageDecision(
|
||||
action=CHARGE, power_w=-size_w,
|
||||
reason=(f"{CHARGE}: this is the cheapest firm price period at "
|
||||
reason=(f"{CHARGE}: this is the cheapest firm hour at "
|
||||
f"{price:.4f} EUR/kWh, {v.spread_eur_kwh:.4f} below the dearest "
|
||||
f"and clear of the {v.required_spread_eur_kwh:.4f} this cycle "
|
||||
f"had to cover; charging {size_w:.0f} W from {soc:g} %"
|
||||
|
||||
@@ -221,11 +221,6 @@ function makeInput(field, value, onchange){
|
||||
input.step = field.type === 'integer' ? '1' : 'any';
|
||||
if(field.min !== undefined) input.min = field.min;
|
||||
if(field.max !== undefined) input.max = field.max;
|
||||
} else if(field.type === 'password'){
|
||||
input.type = 'password';
|
||||
input.autocomplete = 'new-password';
|
||||
input.autocapitalize = 'none';
|
||||
input.spellcheck = false;
|
||||
} else {
|
||||
input.type = 'text';
|
||||
input.autocapitalize = 'none';
|
||||
|
||||
@@ -206,8 +206,6 @@ def _one_field(node, translations, lang, path=()):
|
||||
"multiple": bool(node.get("multiple")),
|
||||
"unit": unit_of(key),
|
||||
}
|
||||
if node.get("format") == "password":
|
||||
field["type"] = "password"
|
||||
if node.get("lengthMin") is not None:
|
||||
field["min"] = node["lengthMin"]
|
||||
if node.get("lengthMax") is not None:
|
||||
@@ -308,52 +306,6 @@ def problems_by_field(problems, keys):
|
||||
return by_key, unanchored
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# SECRETS (PRICE-01 F1). A `password` option never leaves this process in a page
|
||||
# or a JSON answer. The pages get SECRET_SHOWN when one is set and "" when not.
|
||||
# A save that sends SECRET_SHOWN or nothing keeps the stored value; any other
|
||||
# value replaces it. The names come from the Supervisor's own schema, so a
|
||||
# password option added later is covered without an edit here.
|
||||
# --------------------------------------------------------------------------- #
|
||||
SECRET_SHOWN = "********"
|
||||
|
||||
|
||||
def secret_keys(schema, extra=()):
|
||||
"""Names of the options whose schema type is `password`, plus `extra`."""
|
||||
# The Supervisor may spell it `type: password` or `type: string` with
|
||||
# `format: password`; both count.
|
||||
return frozenset(str(n.get("name")) for n in schema or ()
|
||||
if isinstance(n, dict) and (
|
||||
n.get("type") == "password"
|
||||
or n.get("format") == "password")
|
||||
) | frozenset(extra)
|
||||
|
||||
|
||||
def hide_secrets(value, keys):
|
||||
"""A copy of `value` with every secret key's value replaced by
|
||||
SECRET_SHOWN (set) or "" (empty), at any depth."""
|
||||
if isinstance(value, dict):
|
||||
return {k: ((SECRET_SHOWN if v else "") if k in keys
|
||||
else hide_secrets(v, keys))
|
||||
for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [hide_secrets(v, keys) for v in value]
|
||||
return value
|
||||
|
||||
|
||||
def keep_secrets(proposed, stored, keys):
|
||||
"""`proposed` with each secret the page did not change put back from
|
||||
`stored`: absent, empty or SECRET_SHOWN means 'leave it'."""
|
||||
out = dict(proposed)
|
||||
for key in keys:
|
||||
if out.get(key) in (None, "", SECRET_SHOWN):
|
||||
if isinstance(stored, dict) and key in stored:
|
||||
out[key] = stored[key]
|
||||
elif key in out and out[key] == SECRET_SHOWN:
|
||||
out[key] = ""
|
||||
return out
|
||||
|
||||
|
||||
def changed_keys(left, right):
|
||||
"""Sorted keys whose values differ between two option blobs.
|
||||
|
||||
|
||||
@@ -484,8 +484,6 @@ def compute(
|
||||
refused: int | None = None,
|
||||
probe: bool = False,
|
||||
bound: tuple | None = None,
|
||||
gave_nothing: bool | None = None,
|
||||
idle_w: float = 0.0,
|
||||
) -> Decision:
|
||||
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
|
||||
|
||||
@@ -499,13 +497,6 @@ def compute(
|
||||
`bound` OPT-11: `(lo, hi)`, what the batteries may place this tick with
|
||||
their modes applied (`allocator.loop_bound()`), or None. See
|
||||
MODE_BOUND below. None is the law as it was.
|
||||
`gave_nothing` SAFETY-94: the caller's span check - the reading stayed
|
||||
under REFUSED_BELOW_W, one way, for SETTLE_CYCLES spanning
|
||||
REFUSED_SPAN_S. A refusal engages only with it. None leaves it
|
||||
out (compute() alone, as before).
|
||||
`idle_w` SAFETY-97: what `actual_w` reads while the batteries give
|
||||
nothing (app/idle.py). The refusal's band and its end are
|
||||
centred on it. 0 is the law as it was.
|
||||
"""
|
||||
reason = "tracking"
|
||||
# OPT-11: a bound that leaves out 0 W would FORCE a direction, which no
|
||||
@@ -826,18 +817,13 @@ def compute(
|
||||
# `saturation_w` is a refusal, not a slow plant. The drop to 0 skips the slew
|
||||
# limit because it only removes an ask the battery is not meeting.
|
||||
if refused is not None:
|
||||
# SAFETY-94: and only when the reading has given nothing for the
|
||||
# whole span, not just now. `sat_count` also counts the cycles a
|
||||
# healthy battery spends reversing through its lag (-2000 W towards
|
||||
# +800 W): at a 1 s meter its 12 cycles end as the reading passes 0.
|
||||
if (refused == 0 and frozen and sat_count >= SETTLE_CYCLES
|
||||
and abs(prev_w) > tuning.saturation_w
|
||||
and abs(actual_w - idle_w) < REFUSED_BELOW_W
|
||||
and gave_nothing is not False):
|
||||
and abs(actual_w) < REFUSED_BELOW_W):
|
||||
refused = 1 if prev_w > 0 else -1
|
||||
if refused and refused * i_w < 0:
|
||||
refused = 0 # the loop wants the other way
|
||||
elif refused and refused * (actual_w - idle_w) >= PROBE_ANSWER_W:
|
||||
elif refused and refused * actual_w >= PROBE_ANSWER_W:
|
||||
refused = 0 # it delivers: a probe answered,
|
||||
# or a slow reading arriving
|
||||
elif refused:
|
||||
|
||||
@@ -87,10 +87,6 @@ when it has not moved THROUGH A CHANGE IT WAS OBLIGED TO FOLLOW:
|
||||
AND |standing command now - standing command when the reading
|
||||
last changed| >= MOVE_W
|
||||
|
||||
SAFETY-94: "unchanged for" counts only the reads at which both command terms
|
||||
held, so a register that rested is not judged before it could answer.
|
||||
DECISIONS/SAFETY-94-lagging-battery.md.
|
||||
|
||||
0x0518 is the inverter reporting ITSELF and it tracks its own command: r = 0.998
|
||||
with battery power against r = 0.09 with the real meter (goodwe/NOTES.md §3.3),
|
||||
command and actual agreeing within 4 W once settled (§5.10, 2026-08-22), and the
|
||||
@@ -220,8 +216,6 @@ change - which was the ONLY exit - has a median of 143 s, 17 episodes at or
|
||||
over the live one's 710 s, and a worst case of 21440 s. That distribution is a
|
||||
property of this battery's firmware. It is not a property of this code, and the
|
||||
add-on must not depend on it.
|
||||
SAFETY-94: many were the gate judging a rested register on its first read
|
||||
(SAFETY-58). Counted from the ask, the same days give 59 trips.
|
||||
|
||||
THE RULE, and it is SAFETY-13's shape one module over - "which latches get an
|
||||
exit, not how long the block lasts":
|
||||
@@ -378,9 +372,7 @@ BOUNDS = {
|
||||
"SAFETY-57: the verdict is withdrawn after 17.0 s of the "
|
||||
"standing command below 250 W, because at 0 W the register "
|
||||
"is obliged to nothing and the verdict cannot be tested "
|
||||
"again from inside the state it creates. SAFETY-94: the "
|
||||
"35 s counts only reads at which the command asks the "
|
||||
"register to move"),
|
||||
"again from inside the state it creates"),
|
||||
"soc": Bound(
|
||||
signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)",
|
||||
unchanged_s=float("inf"),
|
||||
@@ -424,9 +416,11 @@ class Freshness:
|
||||
new grading window at that instant. The 1 Hz oscillation the latch exists to
|
||||
prevent is still prevented: the new window is `bound.unchanged_s` wide, so
|
||||
no re-trip can land sooner than 35 s after a release, rather than on the
|
||||
next read. SAFETY-94: the 35 s counts only while the command asks the
|
||||
register to move, so a site that rests after a release is not re-tripped
|
||||
on its first ask.
|
||||
next read. ⚠️ MEASURED FROM THE RELEASE, NOT FROM THE COMMAND RESUMING - if
|
||||
the site rests for longer than the window before it asks for anything, the
|
||||
window is already old and the re-trip lands on the first read at which the
|
||||
command travels 250 W again. That is survivor 5's shape, it predates this
|
||||
card, and its cost is one `stale_input_s` hold.
|
||||
|
||||
⚠️ TWO CLOCKS, AND THE DISTINCTION IS THE WHOLE OF SAFETY-57'S HONESTY.
|
||||
`changed_mono` / `held` are facts about the REGISTER: when it last carried a
|
||||
@@ -457,11 +451,6 @@ class Freshness:
|
||||
# the change-stamps; `_release()` is where they diverge.
|
||||
self.graded_from = None
|
||||
self.standing_at_grade = None
|
||||
# SAFETY-94. Seconds of this window during which the register was
|
||||
# obliged to move (the command at least `move_w` and `move_w` away
|
||||
# from `standing_at_grade`), and the instant of the last read.
|
||||
self.obliged_acc = 0.0
|
||||
self.last_read = None
|
||||
# The instant the standing command last FELL below `move_w`, or None
|
||||
# while an obligation is standing. The release timer runs off this.
|
||||
self.rested_since = None
|
||||
@@ -510,7 +499,6 @@ class Freshness:
|
||||
# it reads in the order the state machine moves - withdraw, then
|
||||
# re-grade - not because the code depends on it.
|
||||
self._release(standing_w, now_mono)
|
||||
self._oblige(standing_w, now_mono)
|
||||
self._grade(standing_w, now_mono)
|
||||
|
||||
def _open_window(self, standing_w, now_mono) -> None:
|
||||
@@ -522,35 +510,6 @@ class Freshness:
|
||||
self.graded_from = float(now_mono)
|
||||
self.standing_at_grade = (None if standing_w is None
|
||||
else float(standing_w))
|
||||
# The read that opens a window is never an ask (no travel yet).
|
||||
self.obliged_acc = 0.0
|
||||
|
||||
def _obliged(self, standing_w) -> bool:
|
||||
"""Is the register asked to move: the command is at least `move_w` and
|
||||
has travelled `move_w` since the window opened?"""
|
||||
if standing_w is None or self.standing_at_grade is None:
|
||||
return False
|
||||
standing_w = float(standing_w)
|
||||
return (abs(standing_w) >= self.bound.move_w
|
||||
and abs(standing_w - self.standing_at_grade)
|
||||
>= self.bound.move_w)
|
||||
|
||||
def _oblige(self, standing_w, now_mono) -> None:
|
||||
"""SAFETY-94. Count only the time the register was asked to move.
|
||||
|
||||
A register that held still while nothing asked it to move has not
|
||||
failed, so time before the command moved is not evidence. The time
|
||||
since the previous read counts when the obligation stands at this
|
||||
read; a command flapping in and out of the obligation still adds up.
|
||||
"""
|
||||
now_mono = float(now_mono)
|
||||
if self.last_read is not None and self._obliged(standing_w):
|
||||
self.obliged_acc += max(0.0, now_mono - self.last_read)
|
||||
self.last_read = now_mono
|
||||
|
||||
def obliged_s(self) -> float:
|
||||
"""Seconds of the current window the register was obliged to move."""
|
||||
return self.obliged_acc
|
||||
|
||||
def _release(self, standing_w, now_mono) -> None:
|
||||
"""SAFETY-57. Withdraw a verdict whose obligation is no longer standing.
|
||||
@@ -584,12 +543,7 @@ class Freshness:
|
||||
if self.stale or self.graded_from is None:
|
||||
return
|
||||
held_s = self.graded_s(now_mono)
|
||||
# SAFETY-94 (closes SAFETY-58): the bound counts only the time the
|
||||
# command asked the register to move, not the time since it last
|
||||
# changed. A register that rested for minutes was otherwise judged on
|
||||
# the first read after a new command, before it could answer (live,
|
||||
# 2026-10-03: 1 s after the command).
|
||||
if self.obliged_s() < self.bound.unchanged_s:
|
||||
if held_s < self.bound.unchanged_s:
|
||||
return
|
||||
# ⚠️ BOTH None GUARDS ARE FOR A CALLER THAT DOES NOT KNOW ITS OWN
|
||||
# OBLIGATION YET, WHICH IS NOT `_read_unit`. `Inverter.standing_w()`
|
||||
|
||||
@@ -11,8 +11,6 @@ import time
|
||||
|
||||
import aiohttp
|
||||
|
||||
from . import messages
|
||||
|
||||
_LOG = logging.getLogger("goodwe.hass")
|
||||
|
||||
CORE_API = "http://supervisor/core/api"
|
||||
@@ -21,7 +19,6 @@ BAD_STATES = ("unknown", "unavailable", "none", "")
|
||||
# What the Supervisor's proxy answers while Home Assistant restarts.
|
||||
UNAVAILABLE = (502, 503, 504)
|
||||
STATES_TIMEOUT_S = 10 # UX-13's entity list, like every other read here
|
||||
STILL_WAITING_S = 300 # SAFETY-112: one reminder line per 5 minutes down
|
||||
|
||||
|
||||
class Outage:
|
||||
@@ -32,30 +29,15 @@ class Outage:
|
||||
("502, message='Bad Gateway', url=URL(...)", "Received message 8:1000 is
|
||||
not str"). Now the first channel to fail says it once at WARNING, the raw
|
||||
text goes to DEBUG, and the last channel to recover says so at INFO.
|
||||
|
||||
SAFETY-112: the Supervisor answers 502 for every read, write and
|
||||
websocket until Home Assistant has finished starting, however live its
|
||||
entities already are (DECISIONS/SAFETY-112-waiting-for-home-assistant.md).
|
||||
So an outage also says, every STILL_WAITING_S, that it is still on, and
|
||||
`waiting()` puts it on the status page.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.down_on = set()
|
||||
self.since = None # monotonic start of this outage
|
||||
self._told = None # when the last "still waiting" line went
|
||||
|
||||
def down(self, channel: str, what: str, detail) -> None:
|
||||
now = time.monotonic()
|
||||
if not self.down_on:
|
||||
_LOG.warning("Home Assistant is not answering (%s), perhaps it is "
|
||||
"still starting. Control waits and resumes by itself.",
|
||||
what)
|
||||
self.since = self._told = now
|
||||
elif now - self._told >= STILL_WAITING_S:
|
||||
self._told = now
|
||||
_LOG.info("Still waiting for Home Assistant to finish starting, "
|
||||
"%s so far.", messages.duration(now - self.since))
|
||||
"restarting. The add-on keeps trying.", what)
|
||||
self.down_on.add(channel)
|
||||
_LOG.debug("Home Assistant not answering on %s: %s", channel, detail)
|
||||
|
||||
@@ -63,13 +45,7 @@ class Outage:
|
||||
if channel in self.down_on:
|
||||
self.down_on.discard(channel)
|
||||
if not self.down_on:
|
||||
_LOG.info("Home Assistant answers again after %s.",
|
||||
messages.duration(time.monotonic() - self.since))
|
||||
|
||||
def waiting(self) -> bool:
|
||||
"""True while Home Assistant's API does not answer. The API, not the
|
||||
meter websocket: every battery reading and every write rides on it."""
|
||||
return "api" in self.down_on
|
||||
_LOG.info("Home Assistant answers again.")
|
||||
|
||||
|
||||
OUTAGE = Outage()
|
||||
|
||||
@@ -20,15 +20,18 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
from . import arbitrage, messages, peakguard, uitext
|
||||
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
|
||||
from .maintenance import IDLE as M_IDLE, next_slot_start
|
||||
from .maintenance import IDLE as M_IDLE
|
||||
from .packmode import CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, SOLAR_FIRST
|
||||
from .price import Absence, Uncertainty, brussels, _cover_table
|
||||
from .price import Absence, Uncertainty, brussels
|
||||
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
|
||||
@@ -139,104 +142,55 @@ 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, self.kinds = day0, {}, {}
|
||||
self.day, self.points = 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 _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)
|
||||
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)))
|
||||
bars = []
|
||||
for k in range(n):
|
||||
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
|
||||
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)
|
||||
bars.append({"h": k, "start": a.isoformat(), "label": hhmm(a),
|
||||
"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})
|
||||
"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"
|
||||
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, now=None):
|
||||
"""`[(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 the trade gate
|
||||
allows it (UX-53), and every give run. With them off it is the arbitrage
|
||||
verdict's legs the add-on will take, the ones `trade_runs()` draws
|
||||
(`trade_legs()`, given `now`). 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() and getattr(c, "mode_gate", None) is None \
|
||||
else []
|
||||
return out + [("dear", g.start, g.end) for g in rp.give]
|
||||
if verdict is None:
|
||||
return []
|
||||
return [("cheap" if kind == "grid" else "dear", pt.start,
|
||||
_point_end(plan, pt))
|
||||
for kind, pt in trade_legs(c, plan, verdict, now)]
|
||||
|
||||
|
||||
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."""
|
||||
or (None, None). The feed's own points, like the trade legs, not the
|
||||
chart's hour averages."""
|
||||
firm = [p for p in points if not p.provisional]
|
||||
if not firm:
|
||||
return None, None
|
||||
@@ -263,12 +217,11 @@ def _run(i, kind, start, end, day0, day1, what, why, amount, *, kwh=None,
|
||||
"eur_kwh": None if price is None else round(price, 4)}
|
||||
|
||||
|
||||
def trade_verdict(c, plan, now):
|
||||
def trade_verdict(c, plan):
|
||||
"""The arbitrage verdict the control cycle would reach on this plan, or a
|
||||
reason there is none. The SAME call and inputs as `Controller.cycle()`:
|
||||
`plan.arbitrage()` with the peak guard's state, the cycle cost and the
|
||||
no-weather uncertainty, and (PRICE-02) the same `now`. Returns
|
||||
`(verdict_or_None, why_key)`."""
|
||||
no-weather uncertainty. Returns `(verdict_or_None, why_key)`."""
|
||||
if not arbitrage.enabled(c.o):
|
||||
return None, "off"
|
||||
guard = arbitrage.guard_state(c.peak_guard)
|
||||
@@ -278,24 +231,20 @@ def trade_verdict(c, plan, now):
|
||||
return None, "no_prices"
|
||||
verdict = plan.arbitrage(peak_guard_active=guard,
|
||||
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(c.o),
|
||||
uncertainty=frozenset({Uncertainty.NO_WEATHER}),
|
||||
now=now)
|
||||
uncertainty=frozenset({Uncertainty.NO_WEATHER}))
|
||||
return verdict.match(
|
||||
allowed=lambda v: (v, "allowed"),
|
||||
refused=lambda _v: (None, refusal_key(plan, guard, now)))
|
||||
refused=lambda _v: (None, refusal_key(plan, guard)))
|
||||
|
||||
|
||||
def refusal_key(plan, guard, now):
|
||||
def refusal_key(plan, guard):
|
||||
"""Which of `TariffPlan.arbitrage()`'s refusals fired, in its own order:
|
||||
the peak guard, no prices, under two published points, then the gap."""
|
||||
if guard:
|
||||
return "peak"
|
||||
if not plan.has_prices:
|
||||
return "no_prices"
|
||||
pts = _points(plan)
|
||||
ahead = {id(p) for _s, end, p in _cover_table(pts) if end > now}
|
||||
pts = [p for p in pts if id(p) in ahead]
|
||||
if sum(1 for p in pts if not p.provisional) < 2:
|
||||
if sum(1 for p in _points(plan) if not p.provisional) < 2:
|
||||
return "not_firm"
|
||||
return "not_worth"
|
||||
|
||||
@@ -307,27 +256,6 @@ def _point_end(plan, point):
|
||||
return point.start + timedelta(hours=1)
|
||||
|
||||
|
||||
def leg_taken(c, plan, pt, now):
|
||||
"""UX-53: will the add-on act on this point of the verdict? One in
|
||||
progress is the verdict's own answer now. One still ahead is asked of the
|
||||
same gate (`trade_verdict()`, the call the control cycle makes) at the
|
||||
point's own start. A charge that comes after the last dear period meets a
|
||||
flat remainder there and is refused, so it is not announced. When the gate
|
||||
allows it, the point is still the cheapest (dearest) one: the points
|
||||
before it are gone and it was the first extreme of the points from now."""
|
||||
if now is None or pt.start <= now:
|
||||
return True
|
||||
return trade_verdict(c, plan, pt.start)[0] is not None
|
||||
|
||||
|
||||
def trade_legs(c, plan, verdict, now):
|
||||
"""`[(kind, point)]` by start: the verdict's charge ("grid") and give
|
||||
("give") legs that the add-on will take (`leg_taken()`)."""
|
||||
legs = sorted((("grid", verdict.cheapest), ("give", verdict.dearest)),
|
||||
key=lambda kp: kp[1].start)
|
||||
return [(k, pt) for k, pt in legs if leg_taken(c, plan, pt, now)]
|
||||
|
||||
|
||||
# Under this many kWh a block is not drawn: the cycle would not move it.
|
||||
MIN_KWH = 0.05
|
||||
|
||||
@@ -344,8 +272,10 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow):
|
||||
if any(inv.soc is None for inv in c.inverters):
|
||||
return [], [], "no_level"
|
||||
level = [float(inv.soc) for inv in c.inverters]
|
||||
legs = sorted((("grid", verdict.cheapest), ("give", verdict.dearest)),
|
||||
key=lambda kp: kp[1].start)
|
||||
runs, later = [], []
|
||||
for kind, pt in trade_legs(c, plan, verdict, now):
|
||||
for kind, pt in legs:
|
||||
end = _point_end(plan, pt)
|
||||
if end <= now:
|
||||
continue
|
||||
@@ -391,9 +321,8 @@ def mode_runs(c, day0, day1, now, shadow):
|
||||
them (`c.mode_runs`): the cheap run on the batteries that may charge
|
||||
from the grid, the give runs on Leftover solar and Cheap hours, and the
|
||||
sell run on those with selling on. Amounts are upper bounds ("up to"),
|
||||
from the battery's own room, rating and the run's length. UX-53: no grid
|
||||
charge is drawn while the trade gate refuses one (`c.mode_gate`, what the
|
||||
control cycle found). Returns `(runs, no_trade_key)`."""
|
||||
from the battery's own room, rating and the run's length. Returns
|
||||
`(runs, no_trade_key)`."""
|
||||
rp = getattr(c, "mode_runs", None)
|
||||
if getattr(c, "mode_tick", None) is None:
|
||||
return [], None # no cycle yet: nothing is known
|
||||
@@ -402,7 +331,6 @@ def mode_runs(c, day0, day1, now, shadow):
|
||||
if not rp.on:
|
||||
return [], "not_worth"
|
||||
floor, ceiling = arbitrage.soc_bounds(c.o)
|
||||
gate = getattr(c, "mode_gate", None)
|
||||
out = []
|
||||
for i, inv in enumerate(c.inverters):
|
||||
pm = c.pack_mode(inv)
|
||||
@@ -419,9 +347,8 @@ def mode_runs(c, day0, day1, now, shadow):
|
||||
blocks = []
|
||||
# P4: with a day plan, the grid block is the planned shortfall.
|
||||
need = plan_need(c, inv.name)
|
||||
if gate is None and rp.charge_useful() and (
|
||||
need > 0.0 if need is not None
|
||||
else pm.mode in GRID_CHARGE_MODES):
|
||||
if rp.charge_useful() and (need > 0.0 if need is not None
|
||||
else pm.mode in GRID_CHARGE_MODES):
|
||||
blocks.append(("grid", rp.cheap))
|
||||
sell = rp.sell if pm.sell and pm.can_sell else None
|
||||
for g in rp.give:
|
||||
@@ -512,8 +439,8 @@ def solar_block(c, now):
|
||||
|
||||
def maintenance_runs(c, day0, day1, now, shadow):
|
||||
"""The monthly battery cycle: under way now, or starting later today.
|
||||
The cycle starts in the hour `maintenance_start_hour` names, read in the
|
||||
site's zone exactly as the control cycle reads it (MAINT-10)."""
|
||||
The cycle starts in the hour `maintenance_start_hour` names, read as the
|
||||
control cycle reads it (UTC hour of `now`), shown in local time."""
|
||||
runs, lines = [], []
|
||||
waiting = []
|
||||
for i, (inv, m) in enumerate(zip(c.inverters, c.maints)):
|
||||
@@ -532,9 +459,10 @@ def maintenance_runs(c, day0, day1, now, shadow):
|
||||
if not m.due(now) and (m.next_due(now) is None
|
||||
or m.next_due(now) >= day1):
|
||||
continue
|
||||
start = next_slot_start(now, cfg.start_hour, m.tz)
|
||||
if start is None:
|
||||
continue
|
||||
start = now.replace(hour=cfg.start_hour, minute=0, second=0,
|
||||
microsecond=0)
|
||||
if start + timedelta(hours=1) <= now:
|
||||
start += timedelta(days=1)
|
||||
due_at = m.next_due(now)
|
||||
if due_at is not None and due_at > start:
|
||||
continue
|
||||
@@ -624,15 +552,13 @@ def build_plan(c, now, shadow, running, held=False, settled=True):
|
||||
plan = static
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
days = _day_prices(c)
|
||||
seen = days.see(_points(plan), day0, day1)
|
||||
seen = _day_prices(c).see(_points(plan), day0, day1)
|
||||
bars = hour_bars(seen, 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, now)
|
||||
bars = price_bars(seen, day0, day1, price_windows(c, fresh, verdict, now),
|
||||
now, days.kinds)
|
||||
verdict, trade_why = trade_verdict(c, fresh)
|
||||
modes_on = bool(getattr(c, "modes_on", False))
|
||||
if running:
|
||||
off = {i for i, inv in enumerate(c.inverters) if _off(c, inv)}
|
||||
@@ -657,8 +583,6 @@ def build_plan(c, now, shadow, running, held=False, settled=True):
|
||||
cyc, waits = maintenance_runs(c, day0, day1, now, shadow)
|
||||
runs += cyc
|
||||
lines += waits
|
||||
if getattr(c, "price_gap", False):
|
||||
lines.append(line_no_prices(c.price_gap_since, now))
|
||||
runs += peak_run(c, day0, day1, now, shadow, limit_w)
|
||||
if c.p1_enabled:
|
||||
# The guard's ceiling: the cap, or this month's peak once above it.
|
||||
@@ -980,20 +904,17 @@ def month_peak(c, now):
|
||||
if pg is not None:
|
||||
out["period_w"] = pg.projected_w
|
||||
ceiling = pg.ceiling_w or limit_w
|
||||
out["line"] = line_period(out["period_w"], ceiling, c.shadow,
|
||||
unmeasured=getattr(c, "peak_unmeasured_s", 0.0)
|
||||
> 0.0)
|
||||
out["line"] = line_period(out["period_w"], ceiling, c.shadow)
|
||||
return out
|
||||
|
||||
|
||||
def state_of(c, level, shadow, waiting=False):
|
||||
def state_of(c, level, shadow):
|
||||
"""One word for the pill and the banner's colour: running, shadow,
|
||||
stopped, alarm, waiting or warning. `waiting`: the red headline is
|
||||
Home Assistant still starting (SAFETY-112), which needs nobody."""
|
||||
stopped, alarm or warning."""
|
||||
if shadow:
|
||||
return "shadow"
|
||||
if level == "bad":
|
||||
return "waiting" if waiting else "alarm"
|
||||
return "alarm"
|
||||
if not c.auto:
|
||||
return "stopped"
|
||||
if level == "warn":
|
||||
@@ -1002,22 +923,20 @@ def state_of(c, level, shadow, waiting=False):
|
||||
|
||||
|
||||
def build(c, *, level: str, checks: list, now: datetime | None = None,
|
||||
lang: str = "en", held: bool | None = None,
|
||||
waiting: bool = False) -> dict:
|
||||
lang: str = "en", held: bool | None = None) -> dict:
|
||||
"""The structured half of `/status`, in `lang` (UX-13 P5). Pure reads of
|
||||
the controller."""
|
||||
_LANG[0] = uitext.lang_of(lang)
|
||||
try:
|
||||
return _build(c, level=level, checks=checks, now=now, held=held,
|
||||
waiting=waiting)
|
||||
return _build(c, level=level, checks=checks, now=now, held=held)
|
||||
finally:
|
||||
_LANG[0] = "en"
|
||||
|
||||
|
||||
def _build(c, *, level, checks, now, held=None, waiting=False):
|
||||
def _build(c, *, level, checks, now, held=None):
|
||||
now = now or datetime.now(timezone.utc)
|
||||
shadow = bool(c.shadow)
|
||||
kind = state_of(c, level, shadow, waiting)
|
||||
kind = state_of(c, level, shadow)
|
||||
# A red banner means every inverter is at 0 W (the status cascade), so
|
||||
# nothing is planned until it clears. Shadow mode still plans: it is
|
||||
# what the add-on would do. `held` False: an alarm while control runs
|
||||
@@ -1103,9 +1022,9 @@ def _kwh(v) -> str:
|
||||
return _dec(f"{float(v):.1f}") + " kWh"
|
||||
|
||||
|
||||
# Row id -> the title on Home. The row's own sentence is the body, in the
|
||||
# page's language (UX-46, `messages.pick`). Five titles are the notification's
|
||||
# own title, which test_ui_text.py checks.
|
||||
# Row id -> the title on Home. The row's own sentence is the body, and it stays
|
||||
# in English (app/messages.py). Five titles are the notification's own title,
|
||||
# which test_ui_text.py checks.
|
||||
_EN = uitext.words("en")["home_words"]
|
||||
ATTN_TITLES = {(None if k == "none" else k): v for k, v in _EN["attn"].items()}
|
||||
ATTN_FROM_MESSAGES = {
|
||||
@@ -1345,17 +1264,6 @@ def line_cycle_waits(name) -> str:
|
||||
return _t("cycle_waits", name=name)
|
||||
|
||||
|
||||
def line_no_prices(since, now) -> str:
|
||||
"""PRICE-01: no price covers now. `since` is an instant or None."""
|
||||
if since is None:
|
||||
return _t("no_prices_yet")
|
||||
local, today = brussels(since), brussels(now)
|
||||
if local.date() == today.date():
|
||||
return _t("no_prices_since", at=f"{local:%H:%M}")
|
||||
return _t("no_prices_since_day", day=local.day, month=month_name(local),
|
||||
at=f"{local:%H:%M}")
|
||||
|
||||
|
||||
def line_peak_limit(limit_w, shadow) -> str:
|
||||
return _w(shadow, "step_in", kw=_kw(limit_w))
|
||||
|
||||
@@ -1386,11 +1294,8 @@ def line_peak_not_covered() -> str:
|
||||
return _t("peak_not_covered")
|
||||
|
||||
|
||||
def line_period(period_w, limit_w, shadow, unmeasured=False) -> str:
|
||||
def line_period(period_w, limit_w, shadow) -> str:
|
||||
step = _w(shadow, "step_in", kw=_kw(limit_w))
|
||||
if unmeasured:
|
||||
# SAFETY-90: the add-on started during this period.
|
||||
return _t("period_unmeasured") + " " + step
|
||||
if period_w is None:
|
||||
return step
|
||||
return _t("period", kw=_kw(period_w)) + " " + step
|
||||
@@ -1423,7 +1328,7 @@ def _samples() -> list:
|
||||
out += [("title", mode_word(k)) for k in words["modes"]]
|
||||
out += [("title", mode_house()), ("title", attn_shadow()),
|
||||
("line", attn_shadow_body())]
|
||||
for k in ("running", "shadow", "stopped", "alarm", "waiting", "warning"):
|
||||
for k in ("running", "shadow", "stopped", "alarm", "warning"):
|
||||
out += [("title", pill(k)), ("line", banner_sub(k))]
|
||||
for sh in (False, True):
|
||||
out += [("title", tile_solar(v, s)) for v, s in
|
||||
@@ -1464,7 +1369,6 @@ def _samples() -> list:
|
||||
("plan", line_peak_limit(3500, sh)),
|
||||
("plan", line_period(1240, 3500, sh)),
|
||||
("plan", line_period(None, 3500, sh)),
|
||||
("plan", line_period(1240, 3500, sh, unmeasured=True)),
|
||||
("plan", control_text(True, sh)),
|
||||
("plan", control_text(False, sh))]
|
||||
out += [("plan", run_grid_why(0.1412)), ("plan", run_grid_amount(1.23)),
|
||||
@@ -1475,16 +1379,6 @@ def _samples() -> list:
|
||||
("plan", run_house_why()), ("plan", run_house_why(True)),
|
||||
("plan", run_house_amount()),
|
||||
("plan", line_cycle_waits("Battery B")),
|
||||
("plan", line_no_prices(None, datetime(2026, 10, 14, 9, 0,
|
||||
tzinfo=timezone.utc))),
|
||||
("plan", line_no_prices(datetime(2026, 10, 14, 0, 0,
|
||||
tzinfo=timezone.utc),
|
||||
datetime(2026, 10, 14, 9, 0,
|
||||
tzinfo=timezone.utc))),
|
||||
("plan", line_no_prices(datetime(2026, 10, 13, 22, 0,
|
||||
tzinfo=timezone.utc),
|
||||
datetime(2026, 10, 14, 9, 0,
|
||||
tzinfo=timezone.utc))),
|
||||
("plan", line_prices(0.16, {"eur_kwh": 0.14, "label": "13:00"},
|
||||
{"eur_kwh": 0.36, "label": "19:00"})),
|
||||
("plan", line_prices(None, None, None)),
|
||||
@@ -1506,10 +1400,3 @@ 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})
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
"""SAFETY-97: what a battery's power reading shows while it gives nothing.
|
||||
|
||||
B's reading (0x0518) shows -95 to -118 W at idle (standby draw, live
|
||||
2026-10-03); A's shows -2 to -4 W. "The battery gives nothing" is graded
|
||||
against this idle reading, not against 0 W, or a refusing B never latches.
|
||||
DECISIONS/SAFETY-97-idle-offset.md.
|
||||
|
||||
Learned per unit, never configured:
|
||||
- only while control runs, the unit is not refused, stale or tripped, and
|
||||
its command has been within IDLE_ASK_W for IDLE_QUIET_S (a refusing battery
|
||||
is asked more than that, so it cannot teach its refusal as idle);
|
||||
- a reading beyond IDLE_SAMPLE_W is not a sample;
|
||||
- first value: the mean of IDLE_FIRST_S of samples; then a slow follow
|
||||
(IDLE_TAU_S);
|
||||
- persisted per unit and restored, clamped to IDLE_SAMPLE_W, at start.
|
||||
The band is centred on it clamped to IDLE_MAX_W (owner, 2026-10-04). Until
|
||||
learned the idle reading is 0 W, the rule as it was.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
IDLE_MAX_W = 75.0 # |idle| used: under the 100 W band, so it keeps 0 W +-25 W
|
||||
IDLE_SAMPLE_W = 250.0 # a reading beyond this is not a sample
|
||||
IDLE_ASK_W = 50.0 # a command this small counts as "asked ~0"
|
||||
IDLE_QUIET_S = 120.0 # 6 lags of 20 s: a 2500 W step has decayed to 6 W
|
||||
IDLE_FIRST_S = 120.0 # seconds of samples averaged for the first value
|
||||
IDLE_TAU_S = 1800.0 # then follow drift this slowly
|
||||
IDLE_SAVE_W = 5.0 # persist when the value moved this far
|
||||
IDLE_GAP_S = 10.0 # a longer gap between reads counts as this
|
||||
|
||||
|
||||
def _num(v):
|
||||
try:
|
||||
v = float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return v if math.isfinite(v) else None
|
||||
|
||||
|
||||
class IdleReading:
|
||||
"""One unit's idle reading. `w` is what "gives nothing" is centred on."""
|
||||
|
||||
def __init__(self, saved=None):
|
||||
v = _num(saved)
|
||||
self.value = (None if v is None
|
||||
else max(-IDLE_SAMPLE_W, min(IDLE_SAMPLE_W, v)))
|
||||
self.saved = self.value
|
||||
self.quiet_since = None
|
||||
self.last = None
|
||||
self.acc_w = 0.0
|
||||
self.acc_s = 0.0
|
||||
|
||||
@property
|
||||
def w(self) -> float:
|
||||
"""The centre used: the learned value clamped to IDLE_MAX_W."""
|
||||
if self.value is None:
|
||||
return 0.0
|
||||
return max(-IDLE_MAX_W, min(IDLE_MAX_W, self.value))
|
||||
|
||||
def observe(self, reading, standing_w, now_mono, may_learn) -> bool:
|
||||
"""One read. Returns True when the value should be persisted."""
|
||||
now = float(now_mono)
|
||||
dt = (0.0 if self.last is None
|
||||
else max(0.0, min(IDLE_GAP_S, now - self.last)))
|
||||
self.last = now
|
||||
standing = _num(standing_w)
|
||||
if not may_learn or standing is None or abs(standing) > IDLE_ASK_W:
|
||||
self.quiet_since = None
|
||||
return False
|
||||
if self.quiet_since is None:
|
||||
self.quiet_since = now
|
||||
b = _num(reading)
|
||||
if (now - self.quiet_since < IDLE_QUIET_S or b is None
|
||||
or abs(b) > IDLE_SAMPLE_W or dt <= 0.0):
|
||||
return False
|
||||
if self.value is None:
|
||||
self.acc_w += b * dt
|
||||
self.acc_s += dt
|
||||
if self.acc_s < IDLE_FIRST_S:
|
||||
return False
|
||||
self.value = self.acc_w / self.acc_s
|
||||
else:
|
||||
self.value += (b - self.value) * min(1.0, dt / IDLE_TAU_S)
|
||||
if self.saved is None or abs(self.value - self.saved) >= IDLE_SAVE_W:
|
||||
self.saved = self.value
|
||||
return True
|
||||
return False
|
||||
@@ -127,7 +127,6 @@ from . import messages
|
||||
# SAFETY-37. The freshness policy, and the ONE place its bounds are written.
|
||||
# freshness.py imports nothing from this package, so the direction is one-way.
|
||||
from .freshness import BOUNDS, Freshness
|
||||
from .idle import IdleReading
|
||||
# CFG-03's ONE in-code mirror of config.yaml's SAFETY-24 defaults. Imported
|
||||
# rather than retyped: the flat-keys path below needs `bms_soc_floor`'s
|
||||
# shipped default and a literal here would be the fourth defaults site that
|
||||
@@ -322,21 +321,13 @@ class Inverter:
|
||||
# take, 0 none), when that refusal or its last probe started, the
|
||||
# current probe's start, and the cycles of evidence so far and when
|
||||
# they began. Memory only, like SAFETY-52's: stop and start end it.
|
||||
# The evidence count is also used at N=1 (SAFETY-94); the rest at N>1.
|
||||
# Used at N>1 only.
|
||||
self.refused = 0
|
||||
self.refused_at = None
|
||||
self.probe_from = None
|
||||
self.ref_count = 0
|
||||
self.ref_since = None
|
||||
self.ref_sign = 0
|
||||
self.ref_from = None
|
||||
# SAFETY-97: what this unit's reading shows at idle; "gives nothing"
|
||||
# is centred on it. Learned, persisted, restored (app/idle.py).
|
||||
self.idle = IdleReading(data.get(self.key("idle_w")))
|
||||
|
||||
@property
|
||||
def idle_w(self) -> float:
|
||||
return self.idle.w
|
||||
|
||||
# -- store ---------------------------------------------------------------
|
||||
def key(self, name: str) -> str:
|
||||
|
||||
+101
-1019
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,7 @@ simply not do.
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone, tzinfo
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from .store import MAINT_UNIT_KEYS, unit_key
|
||||
|
||||
@@ -65,30 +65,10 @@ POWER_MOVING = (DRAIN, CHARGE, HOLD)
|
||||
# 12 h means the battery is not following commands - a fault, not a slow day. The charge phase deliberately waits for sun and for cheap hours, so
|
||||
# it is expected to span a night; 36 h still bounds it.
|
||||
DRAIN_TIMEOUT_MIN = 720
|
||||
# MAINT-13: minutes the pack was ALLOWED to charge (the price gate open), not
|
||||
# wall-clock minutes since the charge began. See CHARGE_WALL_TIMEOUT_MIN.
|
||||
CHARGE_TIMEOUT_MIN = 2160
|
||||
# After an abort, do not immediately re-trigger in the same start hour.
|
||||
RESTART_COOLDOWN_MIN = 90
|
||||
|
||||
# MAINT-13 (DECISIONS/MAINT-13-full-pack-is-complete.md). Constants, not options.
|
||||
# A pack that reaches target - 3 and then takes no more charge for this many
|
||||
# minutes of allowed charging counts as full.
|
||||
CHARGE_NEAR_FULL_PCT = 3
|
||||
CHARGE_STALL_MIN = 60
|
||||
# Wall-clock backstop for a charge whose gate never opens (no cheap hours, no sun).
|
||||
CHARGE_WALL_TIMEOUT_MIN = 10080
|
||||
# A failed cycle (stuck drain or charge) is not retried before this many days.
|
||||
FAIL_BACKOFF_DAYS = 7
|
||||
# One tick counts at most this many minutes: a gap means the add-on was down.
|
||||
CHARGE_TICK_MAX_MIN = 5
|
||||
# The charge accounting is written to the state file at most this often.
|
||||
CHARGE_SAVE_MIN = 5
|
||||
# MAINT-14 (DECISIONS/MAINT-14-no-soc-in-maintenance.md): with no SoC reading
|
||||
# the timeouts still run, a drain holds at 0 W, and the log says once, after this
|
||||
# many minutes, that the level has been unknown for long.
|
||||
NOSOC_NOTE_MIN = 15
|
||||
|
||||
# ⚠️ CFG-03. THE ONE IN-CODE MIRROR OF config.yaml FOR EVERY `maintenance_*`
|
||||
# OPTION, and the only place in the add-on where one of these numbers is typed.
|
||||
#
|
||||
@@ -132,9 +112,7 @@ NOSOC_NOTE_MIN = 15
|
||||
MAINT_OPTION_DEFAULTS = {
|
||||
"maintenance_enabled": False,
|
||||
"maintenance_interval_days": 28,
|
||||
# MAINT-10: a LOCAL hour (site_zone() below), 19:00 so the drain runs
|
||||
# into the night rather than through the solar day.
|
||||
"maintenance_start_hour": 19,
|
||||
"maintenance_start_hour": 10,
|
||||
# ⚠️ 2000, NOT THE 2500 THIS SHIPPED AS, and config.yaml carries the
|
||||
# argument: both rates must stay at or below `max_w` (2000) or the
|
||||
# cheap-window charge shaping asks the arbiter for `hi = -charge_w` against
|
||||
@@ -199,69 +177,6 @@ class MaintConfig:
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# MAINT-10: the start hour is local time. See DECISIONS/MAINT-10-local-start-hour.md.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _load_zone(name):
|
||||
"""A tzinfo for an IANA name, or `(None, why)`."""
|
||||
if not name or not isinstance(name, str):
|
||||
return None, "none given"
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
return ZoneInfo(name.strip()), ""
|
||||
except Exception as err: # noqa: BLE001 - any failure
|
||||
return None, f"{name!r} is not a known zone ({type(err).__name__})"
|
||||
|
||||
|
||||
def site_zone(ha_zone, ha_why: str, env_tz) -> tuple:
|
||||
"""`(tzinfo, name, source, why)`: Home Assistant's zone (source "ha"), else
|
||||
the container's TZ ("tz"), else UTC ("utc"). `why` says what was missing."""
|
||||
tz, why = _load_zone(ha_zone)
|
||||
if tz is not None:
|
||||
return tz, ha_zone, "ha", ""
|
||||
ha_why = ha_why or why
|
||||
tz, env_why = _load_zone(env_tz)
|
||||
if tz is not None:
|
||||
return tz, env_tz, "tz", ha_why
|
||||
return timezone.utc, "UTC", "utc", f"Home Assistant: {ha_why}, TZ: {env_why}"
|
||||
|
||||
|
||||
def in_start_slot(now: datetime, start_hour: int, tz: tzinfo) -> bool:
|
||||
"""Is `now` inside today's one-hour start slot, read on the local clock?
|
||||
|
||||
Normally the slot is the local hour `start_hour`. On the day the clocks go
|
||||
forward and that hour does not exist, it is the first hour after the jump.
|
||||
On the day they go back and it happens twice, only the first one counts.
|
||||
Worked from UTC instants only (`astimezone`), so it does not depend on how
|
||||
a tzinfo resolves a wall time that is missing or doubled.
|
||||
"""
|
||||
if now.tzinfo is None:
|
||||
now = now.replace(tzinfo=timezone.utc)
|
||||
here = now.astimezone(tz).replace(tzinfo=None)
|
||||
before = (now - timedelta(hours=1)).astimezone(tz).replace(tzinfo=None)
|
||||
if here.hour == start_hour:
|
||||
# the second pass through a repeated hour: an hour ago read the same
|
||||
return not (before.date() == here.date() and before.hour == start_hour)
|
||||
slot = here.replace(hour=start_hour, minute=0, second=0, microsecond=0)
|
||||
# the clock jumped over the start of the slot within the last hour
|
||||
return before < slot and here >= slot + timedelta(hours=1)
|
||||
|
||||
|
||||
def next_slot_start(now: datetime, start_hour: int, tz: tzinfo):
|
||||
"""The start of the slot `now` is in, or else of the next one; None if
|
||||
there is none within 50 h (no real zone does that)."""
|
||||
step = timedelta(minutes=15)
|
||||
t = now.replace(minute=now.minute - now.minute % 15, second=0,
|
||||
microsecond=0) - timedelta(hours=1)
|
||||
for _ in range(4 * 50):
|
||||
if (in_start_slot(t, start_hour, tz)
|
||||
and not in_start_slot(t - step, start_hour, tz)
|
||||
and t + timedelta(hours=1) > now):
|
||||
return t
|
||||
t += step
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MaintResult:
|
||||
phase: str
|
||||
@@ -277,9 +192,6 @@ class MaintResult:
|
||||
# showing an idle machine and a due date in the past. Empty on every path
|
||||
# that is not deferring, so a roster of one never sees it.
|
||||
deferred: str = ""
|
||||
# MAINT-13: "drain" or "charge" on the one tick where a cycle FAILED, so
|
||||
# the Controller can tell the operator once. Empty on every other tick.
|
||||
failed: str = ""
|
||||
|
||||
|
||||
class UnitHistory:
|
||||
@@ -344,12 +256,8 @@ class Maintenance:
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: MaintConfig, store, unit: str | None = None,
|
||||
name: str = "", mirror_flat: bool = False,
|
||||
tz: tzinfo | None = None):
|
||||
name: str = "", mirror_flat: bool = False):
|
||||
self.cfg = cfg
|
||||
# MAINT-10: the zone `cfg.start_hour` is read in - main.py resolves it
|
||||
# once at startup (`site_zone()`). UTC when nobody says, as before.
|
||||
self.tz = tz or timezone.utc
|
||||
# ⚠️ `self.store` IS THE VIEW, NOT THE Store. Everything in this class
|
||||
# names an UNQUALIFIED key - "phase", "last_completed" - and the view is
|
||||
# the one place that turns it into the key this pack owns. A raw
|
||||
@@ -367,14 +275,6 @@ class Maintenance:
|
||||
# per cycle would flush the very context that explains it. Same pattern
|
||||
# as `Controller._arb_action` and `_maint_shape_suspended`.
|
||||
self._deferred = ""
|
||||
# MAINT-13: the charge accounting (see _charge_account), in memory and
|
||||
# written to the state file every CHARGE_SAVE_MIN minutes.
|
||||
self._trk = None
|
||||
self._trk_saved = None
|
||||
# MAINT-14: when the SoC reading went missing (None while it is there),
|
||||
# and whether the "unknown for long" line was said in this episode.
|
||||
self._nosoc_since = None
|
||||
self._nosoc_said = False
|
||||
|
||||
# -- state ---------------------------------------------------------------
|
||||
@property
|
||||
@@ -382,11 +282,6 @@ class Maintenance:
|
||||
return self.store.get("phase", IDLE)
|
||||
|
||||
def _enter(self, phase: str, now: datetime, events: list, msg: str) -> None:
|
||||
self._nosoc_since = None
|
||||
self._trk = None
|
||||
self._trk_saved = None
|
||||
if self.store.get("charge_track") is not None:
|
||||
self.store.set("charge_track", None)
|
||||
self.store.set("phase", phase)
|
||||
self.store.set_time("phase_started", now)
|
||||
_LOG.info("%s", msg)
|
||||
@@ -427,21 +322,6 @@ class Maintenance:
|
||||
return (last_try is None
|
||||
or (now - last_try) >= timedelta(minutes=RESTART_COOLDOWN_MIN))
|
||||
|
||||
def backoff_until(self, now: datetime):
|
||||
"""MAINT-13: when a failed cycle may be tried again, or None.
|
||||
|
||||
None when nothing failed, when the back-off is over, or when a cycle
|
||||
completed after the failure (a forced start that worked ends it).
|
||||
"""
|
||||
failed = self.store.get_time("last_failed")
|
||||
if failed is None:
|
||||
return None
|
||||
done = self.store.get_time("last_completed")
|
||||
if done is not None and done >= failed:
|
||||
return None
|
||||
until = failed + timedelta(days=FAIL_BACKOFF_DAYS)
|
||||
return until if now < until else None
|
||||
|
||||
def wants_start(self, now: datetime) -> bool:
|
||||
"""Would this pack begin a cycle on this tick if the site let it?
|
||||
|
||||
@@ -451,10 +331,8 @@ class Maintenance:
|
||||
about who was asking - so the IDLE branch of `tick()` calls this one.
|
||||
"""
|
||||
return (self.phase == IDLE and self.cfg.enabled
|
||||
and in_start_slot(now, self.cfg.start_hour, self.tz)
|
||||
and self.due(now)
|
||||
and self._cooled(now)
|
||||
and self.backoff_until(now) is None)
|
||||
and now.hour == self.cfg.start_hour and self.due(now)
|
||||
and self._cooled(now))
|
||||
|
||||
# -- operations ----------------------------------------------------------
|
||||
def force_start(self, now: datetime, blocked_by: str = "") -> list:
|
||||
@@ -482,140 +360,6 @@ class Maintenance:
|
||||
self._enter(DRAIN, now, events, "maintenance: forced start -> drain")
|
||||
return events
|
||||
|
||||
def _charge_account(self, now: datetime, soc: float | None,
|
||||
gate_open: bool) -> dict:
|
||||
"""MAINT-13: what this charge has used so far, as minutes of ALLOWED time.
|
||||
|
||||
`open_min` counts only ticks with the price gate open; `flat_min` counts
|
||||
gate-open minutes since the SoC last beat its best; a closed gate pauses
|
||||
both. One tick adds at most CHARGE_TICK_MAX_MIN, so a gap (add-on down)
|
||||
counts for nothing. A state file from before MAINT-13, or from another
|
||||
charge, starts from the wall-clock minutes so far: the old behaviour.
|
||||
|
||||
MAINT-14: `soc` may be None (no reading). Time still counts, the stall
|
||||
wait pauses, and `last` keeps the last SoC seen (saved with the rest).
|
||||
"""
|
||||
since = self.store.get("phase_started")
|
||||
trk = self._trk
|
||||
if trk is None:
|
||||
raw = self.store.get("charge_track")
|
||||
if isinstance(raw, dict) and raw.get("since") == since:
|
||||
trk = dict(raw)
|
||||
else:
|
||||
trk = {"since": since, "open_min": self._elapsed_min(now),
|
||||
"flat_min": 0.0,
|
||||
"best": None if soc is None else float(soc),
|
||||
"last": None if soc is None else float(soc), "at": None}
|
||||
self._trk = trk
|
||||
at = trk.get("at")
|
||||
try:
|
||||
last = datetime.fromisoformat(at) if at else None
|
||||
except (TypeError, ValueError):
|
||||
last = None
|
||||
dt = 0.0 if last is None else min(
|
||||
CHARGE_TICK_MAX_MIN, max(0.0, (now - last).total_seconds() / 60.0))
|
||||
trk["at"] = now.isoformat()
|
||||
if soc is None:
|
||||
pass
|
||||
elif trk.get("best") is None or soc > trk["best"]:
|
||||
trk["best"], trk["flat_min"] = float(soc), 0.0
|
||||
elif gate_open:
|
||||
trk["flat_min"] += dt
|
||||
if soc is not None:
|
||||
trk["last"] = float(soc)
|
||||
if gate_open:
|
||||
trk["open_min"] += dt
|
||||
saved = self._trk_saved
|
||||
if saved is None or (now - saved).total_seconds() >= CHARGE_SAVE_MIN * 60:
|
||||
self.store.set("charge_track", dict(trk))
|
||||
self._trk_saved = now
|
||||
return trk
|
||||
|
||||
def _fail(self, now: datetime, kind: str, why: str) -> MaintResult:
|
||||
"""MAINT-13: stand down, remember the failure, and tell the Controller."""
|
||||
events = self.abort(now, why)
|
||||
self.store.set_time("last_failed", now)
|
||||
msg = (f"maintenance: the cycle did not finish. It waits "
|
||||
f"{FAIL_BACKOFF_DAYS} days before it tries again.")
|
||||
_LOG.warning("%s", msg)
|
||||
events.append(msg)
|
||||
return MaintResult(IDLE, True, 0.0, False, events, failed=kind)
|
||||
|
||||
def _hold(self, now: datetime, events: list) -> MaintResult:
|
||||
"""HOLD for one tick, with or without a SoC reading (MAINT-15).
|
||||
|
||||
HOLD needs no level to end: it is reached only after the charge counted
|
||||
as done (MAINT-13/14), so its end is a time check and nothing else.
|
||||
"""
|
||||
elapsed = self._elapsed_min(now)
|
||||
if elapsed >= self.cfg.hold_min:
|
||||
self.store.set_time("last_completed", now)
|
||||
self._enter(IDLE, now, events,
|
||||
f"maintenance: cycle COMPLETE ({elapsed:.0f} min hold) "
|
||||
"- handing back to the loop")
|
||||
return MaintResult(IDLE, False, None, False, events)
|
||||
# 0 W, not "do nothing": commanding zero is what stops the loop
|
||||
# pulling the pack straight back down the moment it is full, which
|
||||
# is exactly when the BMS needs it parked at the top.
|
||||
return MaintResult(HOLD, True, 0.0, False, events)
|
||||
|
||||
def _no_soc(self, now: datetime, phase: str, gate_open: bool,
|
||||
events: list) -> MaintResult:
|
||||
"""MAINT-14: one tick of a pack in a moving phase with no SoC reading.
|
||||
|
||||
HOLD stays at 0 W and ends at its normal time (MAINT-15). DRAIN holds at 0 W, like HOLD, and its 12 h
|
||||
timeout still runs. CHARGE keeps its timeout and wall backstop: out of
|
||||
time is a failure, except when the last SoC seen was within
|
||||
CHARGE_NEAR_FULL_PCT of the target, which counts as done (MAINT-13).
|
||||
Nothing here may start a phase change that needs the level.
|
||||
"""
|
||||
if self._nosoc_since is None:
|
||||
self._nosoc_since = now
|
||||
self._nosoc_said = False
|
||||
self._trk_saved = None # save the last SoC seen, now
|
||||
if phase == DRAIN:
|
||||
msg = ("maintenance: the battery level is unknown, so the "
|
||||
"drain holds at 0 W until it is back.")
|
||||
_LOG.warning("%s", msg)
|
||||
events.append(msg)
|
||||
if (not self._nosoc_said and (now - self._nosoc_since).total_seconds()
|
||||
>= NOSOC_NOTE_MIN * 60):
|
||||
self._nosoc_said = True
|
||||
tail = ("The hold still ends at its normal time."
|
||||
if phase == HOLD else
|
||||
f"The time limit of the {phase} still applies.")
|
||||
msg = (f"maintenance: no battery level for {NOSOC_NOTE_MIN} min. "
|
||||
f"{tail}")
|
||||
_LOG.warning("%s", msg)
|
||||
events.append(msg)
|
||||
if phase == HOLD:
|
||||
return self._hold(now, events)
|
||||
elapsed = self._elapsed_min(now)
|
||||
if phase == DRAIN:
|
||||
if elapsed > DRAIN_TIMEOUT_MIN:
|
||||
return self._fail(now, "drain",
|
||||
f"drain stuck, battery level unknown, after "
|
||||
f"{elapsed / 60:.0f} h")
|
||||
return MaintResult(DRAIN, True, 0.0, False, events)
|
||||
trk = self._charge_account(now, None, gate_open)
|
||||
if (trk["open_min"] > CHARGE_TIMEOUT_MIN
|
||||
or elapsed > CHARGE_WALL_TIMEOUT_MIN):
|
||||
last = trk.get("last")
|
||||
if last is not None and last >= self.cfg.soc_target - CHARGE_NEAR_FULL_PCT:
|
||||
self._enter(HOLD, now, events,
|
||||
f"maintenance: charge time is up and the last level "
|
||||
f"seen was {last:.0f} %, close enough to full, so the "
|
||||
f"charge counts as done -> holding "
|
||||
f"{self.cfg.hold_min} min to balance")
|
||||
return MaintResult(HOLD, True, 0.0, False, events)
|
||||
if elapsed > CHARGE_WALL_TIMEOUT_MIN:
|
||||
lim = f"the {CHARGE_WALL_TIMEOUT_MIN // 1440}-day limit"
|
||||
else:
|
||||
lim = f"{trk['open_min'] / 60:.0f} h of charging time"
|
||||
return self._fail(now, "charge",
|
||||
f"charge stuck, battery level unknown, after {lim}")
|
||||
return MaintResult(CHARGE, False, None, True, events)
|
||||
|
||||
def abort(self, now: datetime, why: str) -> list:
|
||||
events = []
|
||||
self._enter(IDLE, now, events, f"maintenance: ABORT ({why})")
|
||||
@@ -643,16 +387,13 @@ class Maintenance:
|
||||
|
||||
# -- the machine ---------------------------------------------------------
|
||||
def tick(self, now: datetime, soc: float | None,
|
||||
blocked_by: str = "", gate_open: bool = True) -> MaintResult:
|
||||
blocked_by: str = "") -> MaintResult:
|
||||
"""One cycle of this pack's state machine.
|
||||
|
||||
`blocked_by` is `arbitrate()`'s answer for THIS pack: empty when the
|
||||
site's one maintenance slot is available to it, otherwise the reason it
|
||||
is not. It gates two things and nothing else - a start, and a phase that
|
||||
is already moving power on a pack that does not hold the slot.
|
||||
|
||||
`gate_open` (MAINT-13) is whether the pack may charge now (the price
|
||||
gate); only those minutes count towards the charge timeout.
|
||||
"""
|
||||
events: list = []
|
||||
phase = self.phase
|
||||
@@ -703,13 +444,13 @@ class Maintenance:
|
||||
# transitions below mean anything, and a stale "100 %" would end the
|
||||
# charge phase having charged nothing. Hold the current phase's command
|
||||
# and wait; the phase timeout is the backstop.
|
||||
#
|
||||
# MAINT-14: "hold and wait" is not "wait for ever". The timeouts run
|
||||
# here too, and a drain holds at 0 W (an unknown level never allows
|
||||
# discharge, TEL-08). See `_no_soc`.
|
||||
if soc is None:
|
||||
return self._no_soc(now, phase, gate_open, events)
|
||||
self._nosoc_since = None
|
||||
_LOG.warning("maintenance: no SoC reading, holding phase %s", phase)
|
||||
if phase == DRAIN:
|
||||
return MaintResult(DRAIN, True, self.cfg.discharge_w, False, events)
|
||||
if phase == HOLD:
|
||||
return MaintResult(HOLD, True, 0.0, False, events)
|
||||
return MaintResult(CHARGE, False, None, True, events)
|
||||
|
||||
elapsed = self._elapsed_min(now)
|
||||
|
||||
@@ -720,8 +461,8 @@ class Maintenance:
|
||||
f"after {elapsed:.0f} min -> charge")
|
||||
return MaintResult(CHARGE, False, None, True, events)
|
||||
if elapsed > DRAIN_TIMEOUT_MIN:
|
||||
return self._fail(now, "drain", f"drain stuck at {soc:.0f} % "
|
||||
f"after {elapsed:.0f} min")
|
||||
events += self.abort(now, f"drain stuck at {soc:.0f} % after {elapsed:.0f} min")
|
||||
return MaintResult(IDLE, True, 0.0, False, events)
|
||||
return MaintResult(DRAIN, True, self.cfg.discharge_w, False, events)
|
||||
|
||||
if phase == CHARGE:
|
||||
@@ -730,31 +471,22 @@ class Maintenance:
|
||||
f"maintenance: charge complete at {soc:.0f} % after "
|
||||
f"{elapsed:.0f} min -> holding {self.cfg.hold_min} min to balance")
|
||||
return MaintResult(HOLD, True, 0.0, False, events)
|
||||
trk = self._charge_account(now, soc, gate_open)
|
||||
near = soc >= self.cfg.soc_target - CHARGE_NEAR_FULL_PCT
|
||||
# MAINT-13 rule 1: close to full and no longer taking charge.
|
||||
if near and trk["flat_min"] >= CHARGE_STALL_MIN:
|
||||
self._enter(HOLD, now, events,
|
||||
f"maintenance: the battery takes no more charge at "
|
||||
f"{soc:.0f} %, so the charge counts as done -> holding "
|
||||
f"{self.cfg.hold_min} min to balance")
|
||||
return MaintResult(HOLD, True, 0.0, False, events)
|
||||
if (trk["open_min"] > CHARGE_TIMEOUT_MIN
|
||||
or elapsed > CHARGE_WALL_TIMEOUT_MIN):
|
||||
# MAINT-13 rule 2: out of time close to full is complete.
|
||||
if near:
|
||||
self._enter(HOLD, now, events,
|
||||
f"maintenance: charge time is up at {soc:.0f} %, "
|
||||
f"close enough to full, so the charge counts as "
|
||||
f"done -> holding {self.cfg.hold_min} min to balance")
|
||||
return MaintResult(HOLD, True, 0.0, False, events)
|
||||
return self._fail(now, "charge",
|
||||
f"charge stuck at {soc:.0f} % after "
|
||||
f"{trk['open_min']:.0f} min of charging time")
|
||||
if elapsed > CHARGE_TIMEOUT_MIN:
|
||||
events += self.abort(now, f"charge stuck at {soc:.0f} % after {elapsed:.0f} min")
|
||||
return MaintResult(IDLE, True, 0.0, False, events)
|
||||
return MaintResult(CHARGE, False, None, True, events)
|
||||
|
||||
if phase == HOLD:
|
||||
return self._hold(now, events)
|
||||
if elapsed >= self.cfg.hold_min:
|
||||
self.store.set_time("last_completed", now)
|
||||
self._enter(IDLE, now, events,
|
||||
f"maintenance: cycle COMPLETE ({elapsed:.0f} min hold) "
|
||||
"- handing back to the loop")
|
||||
return MaintResult(IDLE, False, None, False, events)
|
||||
# 0 W, not "do nothing": commanding zero is what stops the loop
|
||||
# pulling the pack straight back down the moment it is full, which
|
||||
# is exactly when the BMS needs it parked at the top.
|
||||
return MaintResult(HOLD, True, 0.0, False, events)
|
||||
|
||||
_LOG.error("maintenance: unknown phase %r, forcing idle", phase)
|
||||
events += self.abort(now, f"unknown phase {phase!r}")
|
||||
|
||||
+137
-685
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,7 @@ Roles (the page's Why line names them):
|
||||
grid the others cannot give power either; the allocator never
|
||||
splits a target both ways).
|
||||
|
||||
Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py):
|
||||
Rules that keep this safe (tests: test_modes_p3.py):
|
||||
* every range holds 0 W (a mode may forbid a direction, never force one);
|
||||
* a Solar first battery never charges from the grid;
|
||||
* P4: with a day plan (`targets`), a Leftover solar battery charges from
|
||||
@@ -31,11 +31,11 @@ Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py):
|
||||
* grid charging needs a measured peak guard that is clear and a measured
|
||||
headroom (SAFETY-45); the trade is sized to that headroom, whose aim is
|
||||
`peak_cap_w - peak_margin_w` (SAFETY-79). It starts only at START_W or
|
||||
more, and once the guard or a measured headroom stops it, it stays
|
||||
stopped for the rest of that 15-minute period (`blocked`): a charge
|
||||
makes the other batteries stop giving power, so a few watts of charge
|
||||
flipping on and off would swing the house between them and the grid
|
||||
(measured on the 2026-09-09 replay: 13 flips in 4 minutes at 3.5 kW);
|
||||
more, and once the guard or the headroom stops it, it stays stopped for
|
||||
the rest of that 15-minute period (`blocked`): a charge makes the other
|
||||
batteries stop giving power, so a few watts of charge flipping on and
|
||||
off would swing the house between them and the grid (measured on the
|
||||
2026-09-09 replay: 13 flips in 4 minutes at a 3.5 kW house);
|
||||
* selling needs selling on for that battery, the sell run, a price >= 0
|
||||
now, and the battery above `arbitrage_soc_floor`; it stops there. The
|
||||
peak guard never stops a sell (section 9.1: discharge is never capped);
|
||||
@@ -43,21 +43,13 @@ Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py):
|
||||
* a battery out of a run (full, or at the floor) stays out until the run
|
||||
ends, so the ceiling and the floor do not re-arm within one run;
|
||||
* a battery that refuses a direction (SAFETY-52/73) takes no part in a
|
||||
run in that direction: the refusal wins over the run. The one exception
|
||||
is its SAFETY-73 probe inside a cheap run (OPT-15): it is a charger then,
|
||||
pinned at PROBE_W by its own claim, so the others rest (one sign) and the
|
||||
card's "tries again every 15 minutes" holds in the run too;
|
||||
* OPT-20: a probe asks PROBE_W for the probing battery and nothing more.
|
||||
The rest of the ask is what the other chargers would have had without
|
||||
the probe (none: PROBE_W alone), so a probe never places charge on a
|
||||
battery in a maintenance phase or on one that rests;
|
||||
run in that direction: the refusal wins over the run;
|
||||
* no grid charge while another battery drains for its monthly cycle
|
||||
(`draining`, SAFETY-78): it would charge against the drain.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .control import PROBE_W
|
||||
from .packmode import (CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR,
|
||||
PLAN_CHARGE_MODES, SOLAR_FIRST)
|
||||
|
||||
@@ -83,7 +75,6 @@ class Pack:
|
||||
live: bool = True
|
||||
idle: bool = True # not in a maintenance phase
|
||||
refused: int = 0 # SAFETY-52/73: +1 refuses to give, -1 to charge
|
||||
probing: bool = False # OPT-15: its SAFETY-73 probe window is open
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -93,7 +84,6 @@ class Tick:
|
||||
action: str | None # "charge" | "sell" | None
|
||||
ask_w: float # signed site ask before sizing (0 if none)
|
||||
why: str | None # why no trade when a run is on, else None
|
||||
probe_w: float = 0.0 # OPT-20: the part of `ask_w` that is a probe
|
||||
|
||||
|
||||
def _room_kwh(p: Pack, pct: float) -> float:
|
||||
@@ -122,18 +112,15 @@ def charge_to(p: Pack, ceiling_pct, targets=None):
|
||||
return None if t is None else min(float(ceiling_pct), float(t))
|
||||
|
||||
|
||||
def chargers(packs, ceiling_pct, done=(), targets=None, probe=False) -> list:
|
||||
def chargers(packs, ceiling_pct, done=(), targets=None) -> list:
|
||||
"""Batteries that may charge from the grid in a cheap run: below the
|
||||
level `charge_to()` allows them (P4: the day plan's). With `probe`, also
|
||||
one refusing to charge while its SAFETY-73 probe is open: its own P_SAFETY
|
||||
pin keeps it at PROBE_W (OPT-15)."""
|
||||
level `charge_to()` allows them (P4: the day plan's)."""
|
||||
out = []
|
||||
for p in packs:
|
||||
to = charge_to(p, ceiling_pct, targets)
|
||||
if (to is not None and p.use and p.idle and p.live
|
||||
and p.soc is not None and p.soc < to
|
||||
and (p.refused >= 0 or (probe and p.probing))
|
||||
and p.name not in done):
|
||||
and p.refused >= 0 and p.name not in done):
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
@@ -185,8 +172,8 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
||||
in_sell = plan_on and runs.sell is not None and runs.sell.holds(now)
|
||||
|
||||
why = None
|
||||
ch = chargers(packs, ceiling_pct, done_charge, targets, probe=True) \
|
||||
if in_cheap else []
|
||||
ch = chargers(packs, ceiling_pct, done_charge, targets) if in_cheap \
|
||||
else []
|
||||
if ch and draining:
|
||||
ch, why = [], "cycle"
|
||||
charging = False
|
||||
@@ -238,12 +225,8 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
||||
ranges[p.name] = (lo, hi)
|
||||
|
||||
if charging:
|
||||
# OPT-20: a probing refuser is asked PROBE_W (its own pin holds it
|
||||
# there); only the batteries that charge without a probe are asked
|
||||
# for their rating.
|
||||
probe_w = PROBE_W * sum(1 for p in ch if p.refused < 0)
|
||||
ask = -sum(abs(p.rating_w) for p in ch if p.refused >= 0) - probe_w
|
||||
return Tick(ranges, roles, CHARGE, ask, None, probe_w)
|
||||
ask = -sum(abs(p.rating_w) for p in ch)
|
||||
return Tick(ranges, roles, CHARGE, ask, None)
|
||||
if selling:
|
||||
ask = sum(abs(p.rating_w) for p in se) + max(0.0, float(house_w))
|
||||
return Tick(ranges, roles, SELL, ask, None)
|
||||
@@ -260,20 +243,8 @@ def size(tick: Tick, bound, headroom_w) -> float:
|
||||
return 0.0
|
||||
lo, hi = bound
|
||||
if tick.action == CHARGE:
|
||||
room = float(headroom_w) if headroom_w is not None else 0.0
|
||||
pw = float(tick.probe_w)
|
||||
if pw > 0.0:
|
||||
# OPT-20: the others' share is sized as without the probe; the
|
||||
# probe's PROBE_W is added on top (the grid rises by PROBE_W at
|
||||
# most), and alone it is sized by the bound and the headroom.
|
||||
rest = max(0.0, min(-float(tick.ask_w) - pw, -float(lo) - pw,
|
||||
room))
|
||||
if rest > 0.0:
|
||||
return -(rest + pw)
|
||||
# PROBE_W exactly or no trade: less would void the pin and hand
|
||||
# the remainder to whichever battery the allocator can reach.
|
||||
return -pw if min(-float(lo), room) >= pw else 0.0
|
||||
cap = min(-float(tick.ask_w), -float(lo), room)
|
||||
cap = min(-float(tick.ask_w), -float(lo),
|
||||
float(headroom_w) if headroom_w is not None else 0.0)
|
||||
return -max(0.0, cap)
|
||||
return max(0.0, min(float(tick.ask_w), float(hi)))
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ _LOG = logging.getLogger("goodwe.mqtt")
|
||||
DEVICE = {
|
||||
"identifiers": ["goodwe_rs485_controller"],
|
||||
"name": "GoodWe",
|
||||
"manufacturer": "GoodWe (via RS485 EMS port)",
|
||||
"manufacturer": "GoodWe (via RS485 meter emulation)",
|
||||
"model": "ES/BP series",
|
||||
}
|
||||
|
||||
|
||||
@@ -312,19 +312,6 @@ class QuarterAverager:
|
||||
"""
|
||||
return self._last_net
|
||||
|
||||
@property
|
||||
def held_t(self) -> float | None:
|
||||
"""Epoch seconds of the held sample. OPT-15: `PeakTracker.project()`
|
||||
with `roll=True` credits it into the next block as `add()` will."""
|
||||
return self._last_t
|
||||
|
||||
@property
|
||||
def held_since(self) -> datetime | None:
|
||||
"""SAFETY-90: when the held sample arrived (its `ingest_ts`), or None."""
|
||||
if self._last_t is None:
|
||||
return None
|
||||
return datetime.fromtimestamp(self._last_t, timezone.utc)
|
||||
|
||||
@property
|
||||
def offtake_avg_w(self) -> float:
|
||||
"""Average offtake over the part of the open block seen so far."""
|
||||
|
||||
@@ -183,8 +183,6 @@ guard passes its own tests while being switched off:
|
||||
record). Those seconds make the projection read LOW, which is the
|
||||
under-guarding direction. The threshold is `max_hold_s` itself - the point
|
||||
p1.py already stops trusting a held sample - not a new number.
|
||||
SAFETY-90: once readings are back, main.py counts those seconds
|
||||
(`fill_gaps()`), so this holds only while the gap lasts.
|
||||
|
||||
* FROZEN. SAFETY-18's conjunction, evaluated through `freeze.looks_frozen()`
|
||||
rather than re-written here. A frozen meter is the worst case of all: the
|
||||
@@ -601,57 +599,6 @@ def blindness(projection, max_hold_s, frozen: bool) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
# SAFETY-90. Seconds of uncounted time below which nothing is filled: float
|
||||
# noise between the averager's sums and the wall clock, never a real gap.
|
||||
GAP_TOL_S = 1e-3
|
||||
|
||||
|
||||
def gap_w(ceiling_w, *seen_w) -> float:
|
||||
"""SAFETY-90: the watts a meter gap is counted at. The highest of the
|
||||
limit and `seen_w`, the import with the batteries at 0 W (grid + their
|
||||
discharge) just before and just after the gap; None entries are skipped.
|
||||
DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
|
||||
return max([float(ceiling_w)] + [float(w) for w in seen_w if w is not None])
|
||||
|
||||
|
||||
def fill_gaps(projection, *, tail_s, max_hold_s, unseen_s, gap_w, unseen_w,
|
||||
held_ws=0.0):
|
||||
"""SAFETY-90: `projection` with the seconds the averager did not count
|
||||
(a meter gap past `max_hold_s`) counted at `gap_w`, and those from before
|
||||
this run's first reading (`unseen_s`) at `unseen_w`. `held_ws` is added
|
||||
too: what the gap's first `max_hold_s`, credited at the reading before
|
||||
it, lacks to reach `gap_w`. Returns
|
||||
`(projection, gap seconds filled, unseen seconds filled)`.
|
||||
|
||||
`tail_s` is the age of the held reading: up to `max_hold_s` of it is left
|
||||
as before (the averager credits it once the next reading lands). Past
|
||||
that the gap is still open, nothing is filled and `blindness()` answers
|
||||
as before. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
|
||||
if projection.block_start is None or max_hold_s is None:
|
||||
return projection, 0.0, 0.0
|
||||
wall = float(projection.elapsed_s)
|
||||
tail = min(max(0.0, float(tail_s)), wall)
|
||||
if tail > float(max_hold_s):
|
||||
return projection, 0.0, 0.0
|
||||
covered = wall - uncovered_s(projection)
|
||||
missing = wall - covered - tail
|
||||
if missing <= GAP_TOL_S:
|
||||
missing = 0.0
|
||||
if held_ws <= 0.0:
|
||||
return projection, 0.0, 0.0
|
||||
unseen = min(missing, max(0.0, float(unseen_s)))
|
||||
gap = missing - unseen
|
||||
add_ws = gap * float(gap_w) + unseen * float(unseen_w) + float(held_ws)
|
||||
done = covered + missing
|
||||
return (replace(
|
||||
projection,
|
||||
projected_w=float(projection.projected_w) + add_ws / QUARTER_S,
|
||||
elapsed_w=((float(projection.elapsed_w) * covered + add_ws) / done
|
||||
if done > 0.0 else float(projection.elapsed_w)),
|
||||
covered_frac=(done / wall if wall > 0.0
|
||||
else float(projection.covered_frac))), gap, unseen)
|
||||
|
||||
|
||||
def discharge_permitted(soc, soc_floor) -> str | None:
|
||||
"""SAFETY-24. May the guard size a forced discharge, or why may it not?
|
||||
|
||||
|
||||
@@ -750,18 +750,6 @@ class _Series(PriceRead):
|
||||
except Absence:
|
||||
return False
|
||||
|
||||
def last_end_before(self, instant: datetime):
|
||||
"""When the last price that has run out by `instant` stopped covering,
|
||||
or None if no price has (PRICE-01: "no prices since 00:00").
|
||||
|
||||
Each point's end is the one `_cover_table()` worked out, so a hole in
|
||||
the series is reported from where the hole starts and not from the end
|
||||
of the day.
|
||||
"""
|
||||
u = instant.astimezone(timezone.utc)
|
||||
ends = [end for _start, end, _p in self._cover if end <= u]
|
||||
return max(ends) if ends else None
|
||||
|
||||
def window(self, since: datetime, until: datetime) -> tuple:
|
||||
"""Points whose START falls in [since, until). May legitimately be empty.
|
||||
|
||||
@@ -918,10 +906,6 @@ PRICE_OPTION_DEFAULTS = {
|
||||
"export_price_eur_kwh": 0.04, # §14 tariffs.export_price_eur_kwh (AC4)
|
||||
"price_csv_path": "/share/goodwe_prices.csv",
|
||||
"price_feed_url": "",
|
||||
# PRICE-01. The bearer token of the owner's price server. A secret: it is
|
||||
# in SECRET_OPTIONS below, so no text built from an error reaches a log,
|
||||
# the status page or a notification with it in.
|
||||
"price_service_token": "",
|
||||
"price_horizon_h": 48,
|
||||
"price_timeout_s": 10,
|
||||
# TARIFF-03. §14 `tariffs.static_profile: null`. A JSON object describing a
|
||||
@@ -1008,26 +992,6 @@ FRAMEWORK_KEYS = frozenset({
|
||||
})
|
||||
|
||||
|
||||
# PRICE-01. Options whose VALUE must never be printed. `scrub()` removes them
|
||||
# from any text the price code builds out of an error.
|
||||
SECRET_OPTIONS = frozenset({"price_service_token"})
|
||||
|
||||
|
||||
def scrub(text, opts) -> str:
|
||||
"""`text` with every secret option's value replaced by `***`.
|
||||
|
||||
The price code builds reasons out of library errors, and a library may put
|
||||
a request header into its error text. Taking the value out here is the
|
||||
last line of defence; the plugin also never puts it in a message itself.
|
||||
"""
|
||||
out = str(text)
|
||||
for key in SECRET_OPTIONS:
|
||||
secret = str((opts or {}).get(key) or "").strip()
|
||||
if len(secret) >= 4:
|
||||
out = out.replace(secret, "***")
|
||||
return out
|
||||
|
||||
|
||||
class PluginOptions:
|
||||
"""The options dict, minus the keys only the framework may read.
|
||||
|
||||
@@ -1252,13 +1216,6 @@ class PriceProvider(ABC):
|
||||
|
||||
id = "?"
|
||||
|
||||
# PRICE-01. The ids to try, in order, when this source cannot answer, and
|
||||
# whether the last answer is kept for a day without any source. Empty and
|
||||
# False for every plugin that existed before, which is what keeps them
|
||||
# exactly as they were: `price_chain.py` is not used for them at all.
|
||||
FALLBACKS: tuple = ()
|
||||
KEEPS_LAST_GOOD: bool = False
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def capabilities(cls) -> ProviderCapabilities:
|
||||
@@ -1576,58 +1533,19 @@ def assemble(caps: ProviderCapabilities, raw, markup: float,
|
||||
# Transport - one place, HTTPS only, host checked against the declaration
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
async def https_get(url: str, timeout_s: float, headers=None) -> bytes:
|
||||
async def https_get(url: str, timeout_s: float) -> bytes:
|
||||
"""The default transport. §16: outbound is allow-listed and TLS'd.
|
||||
|
||||
Imported lazily so `manual_csv` - the offline fallback - never needs aiohttp
|
||||
to be importable at all. AC3 says it works fully offline; that includes a
|
||||
machine where the HTTP stack is missing.
|
||||
|
||||
PRICE-01. `headers` is None for every plugin but `price_service`, and with
|
||||
None this is the code it has always been, byte for byte. With headers it
|
||||
answers in the SAFE words of `status_words()` and never lets a library
|
||||
error through: an error from a request that carried a token is the one
|
||||
place a token could be printed by somebody else's code.
|
||||
"""
|
||||
import aiohttp
|
||||
t = aiohttp.ClientTimeout(total=float(timeout_s))
|
||||
if headers is None:
|
||||
async with aiohttp.ClientSession(timeout=t) as s:
|
||||
async with s.get(url) as r:
|
||||
r.raise_for_status()
|
||||
return await r.read()
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=t) as s:
|
||||
# No redirects: the host is fixed in code, and a redirect would
|
||||
# take the request (and the token) somewhere the allow-list never
|
||||
# saw. A 3xx is reported like any other status.
|
||||
async with s.get(url, headers=headers,
|
||||
allow_redirects=False) as r:
|
||||
if r.status != 200:
|
||||
raise SourceUnavailable(status_words(r.status))
|
||||
return await r.read()
|
||||
except SourceUnavailable:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise SourceUnavailable(
|
||||
f"the price service did not answer ({type(e).__name__})")
|
||||
|
||||
|
||||
# PRICE-01. The one phrase a refused token is reported with. The fallback chain
|
||||
# reads it to tell "the owner must act" from "the server is down", so it is a
|
||||
# constant both sides import.
|
||||
AUTH_REFUSED = "the price service refused the token"
|
||||
|
||||
|
||||
def status_words(status: int) -> str:
|
||||
"""What an HTTP status from the price service is called. No header text."""
|
||||
if status in (401, 403):
|
||||
return f"{AUTH_REFUSED} ({status})"
|
||||
if status == 429:
|
||||
return "the price service says too many requests (429)"
|
||||
if status >= 500:
|
||||
return f"the price service has an error ({status})"
|
||||
return f"the price service answered {status}"
|
||||
async with aiohttp.ClientSession(timeout=t) as s:
|
||||
async with s.get(url) as r:
|
||||
r.raise_for_status()
|
||||
return await r.read()
|
||||
|
||||
|
||||
def check_egress(url: str, caps: ProviderCapabilities) -> str:
|
||||
@@ -1952,91 +1870,6 @@ class GenericDayAhead(PriceProvider):
|
||||
provisional_default=False)
|
||||
|
||||
|
||||
@register
|
||||
class PriceService(PriceProvider):
|
||||
"""The owner's own price server (PRICE-01), COLUMNAR, in EUR/MWh.
|
||||
|
||||
SOURCES: energy
|
||||
The framework ADDS: markup, taxes
|
||||
|
||||
The server answers the energy-charts layout (`unix_seconds`, `price`,
|
||||
`unit`) plus `covers`, the complete Brussels days it holds. The price is
|
||||
the wholesale energy price and nothing else, so markup and taxes are added
|
||||
by the framework exactly as for `generic_dayahead`.
|
||||
|
||||
The host is fixed here, HTTPS only, and a bearer token (the option
|
||||
`price_service_token`) goes in the `Authorization` header. The token is not
|
||||
put into any message this class builds.
|
||||
|
||||
`FALLBACKS` names the next source down. `app/price_chain.py` walks it and
|
||||
then the stored prices; this class only fetches.
|
||||
"""
|
||||
|
||||
id = "price_service"
|
||||
|
||||
HOST = "prices.kammenstraatha.duckdns.org"
|
||||
URL = "https://prices.kammenstraatha.duckdns.org/v1/dayahead/BE"
|
||||
FALLBACKS = ("generic_dayahead",)
|
||||
KEEPS_LAST_GOOD = True
|
||||
|
||||
@classmethod
|
||||
def capabilities(cls):
|
||||
return ProviderCapabilities(
|
||||
product=PriceProduct.DAY_AHEAD,
|
||||
sources=frozenset({Component.ENERGY}),
|
||||
egress_hosts=(cls.HOST,))
|
||||
|
||||
async def fetch(self, opts, since, until, transport):
|
||||
token = str(opts.get("price_service_token") or "").strip()
|
||||
if not token:
|
||||
raise SourceUnavailable("no token is set for the price service")
|
||||
if any(c.isspace() or not c.isprintable() for c in token):
|
||||
raise SourceUnavailable(
|
||||
"the price service token has a space or a line break in it")
|
||||
check_egress(self.URL, self.capabilities())
|
||||
try:
|
||||
body = await transport(
|
||||
self.URL, float(opts.get("price_timeout_s")),
|
||||
headers={"Authorization": "Bearer " + token})
|
||||
except SourceUnavailable:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise SourceUnavailable(
|
||||
f"the price service did not answer ({type(e).__name__})")
|
||||
points = _points_from_columnar_json(body, expect_unit="EUR / MWh",
|
||||
scale=0.001,
|
||||
provisional_default=False)
|
||||
covers = _covered_days(body)
|
||||
if covers is None:
|
||||
return points
|
||||
whole = [p for p in points
|
||||
if brussels(p.start).date().isoformat() in covers]
|
||||
if not whole:
|
||||
raise SourceUnavailable(
|
||||
"the price service sent no complete day")
|
||||
return whole
|
||||
|
||||
|
||||
def _covered_days(body):
|
||||
"""The `covers` list of a price service answer as a set, or None if absent.
|
||||
|
||||
A day the server does not list as complete is not used: an hour missing
|
||||
inside a day is a hole, and a hole is not a free hour.
|
||||
"""
|
||||
try:
|
||||
text = body.decode("utf-8") if isinstance(body, (bytes, bytearray)) \
|
||||
else str(body)
|
||||
covers = json.loads(text).get("covers")
|
||||
except (ValueError, UnicodeDecodeError, AttributeError):
|
||||
return None
|
||||
if covers is None:
|
||||
return None
|
||||
if not isinstance(covers, list) or \
|
||||
not all(isinstance(d, str) for d in covers):
|
||||
raise SourceUnavailable("the price service sent a bad list of days")
|
||||
return set(covers)
|
||||
|
||||
|
||||
@register
|
||||
class ManualCsv(PriceProvider):
|
||||
"""A file on disk. AC3: the documented fallback, fully offline.
|
||||
@@ -2336,10 +2169,9 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
|
||||
except FrameworkOption:
|
||||
raise # a plugin bug, not a site condition
|
||||
except SourceUnavailable as e:
|
||||
return PriceRead.no_data(scrub(f"{pid}: {e}", opts))
|
||||
return PriceRead.no_data(f"{pid}: {e}")
|
||||
except Exception as e: # noqa: BLE001 - see the docstring
|
||||
return PriceRead.no_data(
|
||||
scrub(f"{pid}: {type(e).__name__}: {e}", opts))
|
||||
return PriceRead.no_data(f"{pid}: {type(e).__name__}: {e}")
|
||||
|
||||
_emit_startup_preview(plugin, pid, points)
|
||||
return PriceRead.series(points, caps.product, pid,
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
"""PRICE-01 - where the prices come from when the first source does not answer.
|
||||
|
||||
A provider that names `FALLBACKS` (app/price.py) is read through this module
|
||||
instead of straight from `load_prices()`. The order is fixed:
|
||||
|
||||
1. the provider itself (the owner's price server)
|
||||
2. each id in its `FALLBACKS` (energy-charts.info)
|
||||
3. the prices the history store kept from the last fetch that worked
|
||||
|
||||
A step counts only if its answer holds a price for NOW. A server that answers
|
||||
200 with yesterday's prices is as useless to a battery as one that is down, so
|
||||
it is skipped, with the same log line.
|
||||
|
||||
Every other provider never comes here (`uses_chain()` is False for it), so
|
||||
`generic_dayahead`, `manual_csv`, `custom_formula` and `off` take the road they
|
||||
always took.
|
||||
|
||||
THE GATE. `gate_for()` is the maintenance charge rule for a site on such a
|
||||
provider: the current 15-minute price is at or below today's average. It is the
|
||||
rule the Home Assistant pair (`price_now_entity`, `price_avg_entity`) gave, read
|
||||
from the provider's own prices, so moving a site from the pair to the provider
|
||||
does not change WHEN the charge runs. DECISIONS/PRICE-01-own-price-server.md
|
||||
says how equal, and where it is not.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from .price import (AUTH_REFUSED, PROVIDERS, PriceRead, brussels,
|
||||
price_option, provider_id, scrub)
|
||||
|
||||
_LOG = logging.getLogger("goodwe.price.chain")
|
||||
|
||||
# The four places a plan can come from, best first. A higher number is further
|
||||
# down the chain.
|
||||
STEP_OWN, STEP_PUBLIC, STEP_STORED, STEP_NONE = 0, 1, 2, 3
|
||||
|
||||
# What a step is called in a status line.
|
||||
SOURCE_WORDS = {
|
||||
"price_service": "the price service",
|
||||
"generic_dayahead": "energy-charts",
|
||||
"price_history": "saved prices",
|
||||
}
|
||||
|
||||
|
||||
def uses_chain(opts: dict) -> bool:
|
||||
"""Does the selected provider ask for the fallback chain?"""
|
||||
cls = PROVIDERS.get(provider_id(opts))
|
||||
return bool(cls is not None and (cls.FALLBACKS or cls.KEEPS_LAST_GOOD))
|
||||
|
||||
|
||||
def fixed_tariff_beside_service(opts: dict) -> bool:
|
||||
"""PRICE-01: a fixed tariff is set beside `price_service`. Not a fault: it
|
||||
stays the plan's last fallback (`resolve_tariff`), and only the cheap window
|
||||
and the gate ignore it. A blank or whitespace-only value is not set."""
|
||||
return (provider_id(opts) == "price_service"
|
||||
and bool(str(price_option(opts, "static_profile")).strip()))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Resolved:
|
||||
"""What the chain found. `published` is the series BEFORE the yesterday
|
||||
fill and the window, which is what the day's average has to be read from:
|
||||
the plan itself starts at the current hour."""
|
||||
|
||||
read: PriceRead
|
||||
step: int
|
||||
reasons: tuple = ()
|
||||
|
||||
@property
|
||||
def published(self):
|
||||
return self.read.match(series=lambda r: r, no_data=lambda _r: None)
|
||||
|
||||
|
||||
class PriceWatch:
|
||||
"""Which step the prices come from, and one log line per change of it.
|
||||
|
||||
The line is written when the step CHANGES and not before. Nothing repeats
|
||||
while a source stays down, and nothing is written for a refresh that
|
||||
changes nothing. The reasons (a status code, a timeout) go on a DEBUG line
|
||||
beside it, with the token taken out.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.step = None
|
||||
self.auth_refused = False
|
||||
|
||||
def note(self, step: int, reasons=(), opts=None) -> None:
|
||||
old, self.step = self.step, step
|
||||
self.auth_refused = any(AUTH_REFUSED in r for r in reasons)
|
||||
if old == step:
|
||||
return
|
||||
if reasons:
|
||||
_LOG.debug("Price sources said: %s.",
|
||||
scrub("; ".join(reasons), opts))
|
||||
if step == STEP_OWN:
|
||||
if old is None:
|
||||
_LOG.info("Prices come from the price service.")
|
||||
else:
|
||||
_LOG.info("The price service answers again.")
|
||||
elif step == STEP_PUBLIC:
|
||||
if old is not None and old > step:
|
||||
_LOG.info("Prices come from energy-charts again.")
|
||||
elif self.auth_refused:
|
||||
_LOG.warning("The price service refused the token, so prices "
|
||||
"come from energy-charts.")
|
||||
else:
|
||||
_LOG.warning("The price service does not answer, so prices "
|
||||
"come from energy-charts.")
|
||||
elif step == STEP_STORED:
|
||||
if old is not None and old > step:
|
||||
_LOG.info("The add-on uses the prices it saved again.")
|
||||
else:
|
||||
_LOG.warning("No price source answers, so the add-on uses "
|
||||
"the prices it saved.")
|
||||
else:
|
||||
_LOG.warning("No price source answers, and no saved price covers "
|
||||
"now.")
|
||||
|
||||
|
||||
async def resolve(opts: dict, since: datetime, until: datetime, transport,
|
||||
history, stamp: datetime, watch: PriceWatch,
|
||||
load) -> Resolved:
|
||||
"""Walk the chain and return the first step that has a price for `stamp`.
|
||||
|
||||
`load` is the caller's `load_prices`, passed in so that `static_tariff.py`
|
||||
stays the one module that fetches prices. Never raises
|
||||
for a source condition: a read that gets nowhere is a `no_data` whose
|
||||
reason names every step, so the §11.4 row applies exactly as for a single
|
||||
provider that failed.
|
||||
"""
|
||||
pid = provider_id(opts)
|
||||
cls = PROVIDERS[pid]
|
||||
reasons = []
|
||||
for step, sid in enumerate((pid,) + tuple(cls.FALLBACKS)):
|
||||
# A fallback is asked for ITS OWN feed. The operator's feed override is
|
||||
# for the first provider and would send energy-charts' request to a
|
||||
# URL it was never meant for.
|
||||
o = opts if step == 0 else dict(opts, price_provider=sid,
|
||||
price_feed_url="")
|
||||
read = await load(o, since, until, transport)
|
||||
if read.match(series=lambda r: r.covers(stamp), no_data=lambda _r: False):
|
||||
# One fallback is shipped. Any further one would also be "public".
|
||||
where = STEP_OWN if step == 0 else STEP_PUBLIC
|
||||
watch.note(where, reasons, opts)
|
||||
return Resolved(read, where, tuple(reasons))
|
||||
reasons.append(read.match(
|
||||
series=lambda r: f"{sid}: it has no price for now",
|
||||
no_data=lambda r: r.reason))
|
||||
if cls.KEEPS_LAST_GOOD:
|
||||
kept = history.stored(stamp)
|
||||
if kept.match(series=lambda r: r.covers(stamp),
|
||||
no_data=lambda _r: False):
|
||||
watch.note(STEP_STORED, reasons, opts)
|
||||
return Resolved(kept, STEP_STORED, tuple(reasons))
|
||||
reasons.append(kept.match(
|
||||
series=lambda r: "price_history: it has no price for now",
|
||||
no_data=lambda r: r.reason))
|
||||
watch.note(STEP_NONE, reasons, opts)
|
||||
# The reason is short on purpose: it is printed on the status page and in
|
||||
# a WARNING every refresh. What each step said is on the DEBUG line.
|
||||
return Resolved(PriceRead.no_data(
|
||||
"no price source has a price for now"), STEP_NONE,
|
||||
tuple(scrub(r, opts) for r in reasons))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The maintenance charge gate, read from the provider's prices
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Gate:
|
||||
"""What the 30 s tick takes from the standing plan.
|
||||
|
||||
`covered` is whether a published price covers now. When it is False,
|
||||
`cheap` is False and the charge waits: no price is not a cheap price.
|
||||
`since` is when the cover ended, for the status line, or None when nothing
|
||||
was ever published.
|
||||
"""
|
||||
|
||||
cheap: bool
|
||||
covered: bool
|
||||
since: datetime | None
|
||||
words: str
|
||||
|
||||
|
||||
NO_PLAN = Gate(False, False, None, "No prices yet")
|
||||
|
||||
|
||||
def _round5(value: float) -> float:
|
||||
# The Home Assistant template rounded the average to 5 decimals, and a
|
||||
# price to 5 as well. Both sides are compared as it did, so a quarter that
|
||||
# sits within 0.00001 EUR/kWh of the average gets the same answer.
|
||||
return round(float(value), 5)
|
||||
|
||||
|
||||
def gate_for(published, now: datetime) -> Gate:
|
||||
"""The charge gate for `now`, from the series the chain published.
|
||||
|
||||
THE RULE (owner's decision, PRICE-01): the current 15-minute price is at or
|
||||
below the average of today's prices, today being the Brussels day. This is
|
||||
what `now_p <= avg_p` was on the Home Assistant pair. It is NOT
|
||||
`TariffPlan.cheap_now()`, which is strictly below the mean of the firm
|
||||
hours of the whole plan, and it is not meant to be.
|
||||
|
||||
`published` is a series or None. With no price for now there is no gate
|
||||
answer: the charge waits.
|
||||
"""
|
||||
if published is None:
|
||||
return NO_PLAN
|
||||
if not published.covers(now):
|
||||
return Gate(False, False, published.last_end_before(now),
|
||||
"No price for now")
|
||||
today = brussels(now).date()
|
||||
todays = [p.eur_per_kwh for p in published.points
|
||||
if not p.provisional and brussels(p.start).date() == today]
|
||||
here = published.at(now)
|
||||
if here.provisional or not todays:
|
||||
return Gate(False, False, published.last_end_before(now),
|
||||
"No price for now")
|
||||
avg = sum(todays) / len(todays)
|
||||
cheap = _round5(here.eur_per_kwh) <= _round5(avg)
|
||||
source = SOURCE_WORDS.get(published.provider, published.provider)
|
||||
return Gate(cheap, True, None,
|
||||
f"Prices from {source}, {here.eur_per_kwh:.4f} EUR/kWh now, "
|
||||
f"{'cheap' if cheap else 'not cheap'} against today's average "
|
||||
f"of {avg:.4f}")
|
||||
@@ -581,7 +581,7 @@ class PriceHistory:
|
||||
|
||||
# -- the one public entry point -----------------------------------------
|
||||
def carry(self, read: PriceRead, *, since: datetime, until: datetime,
|
||||
stamp: datetime, remember: bool = True) -> PriceRead:
|
||||
stamp: datetime) -> PriceRead:
|
||||
"""Remember what this read published, then §8.3-fill the missing hours.
|
||||
|
||||
THE ORDER IS DELIBERATE AND IS THE POINT OF THE METHOD. The read is
|
||||
@@ -599,11 +599,7 @@ class PriceHistory:
|
||||
source can price is left out (§11.4 for that hour), and `no_data` comes
|
||||
back when that is every hour.
|
||||
"""
|
||||
if remember:
|
||||
# PRICE-01: False only for a read that CAME FROM this store
|
||||
# (`stored()`), which is already filed; filing it again would
|
||||
# rewrite the same file for nothing, and nothing else may pass it.
|
||||
self._remember(read, stamp)
|
||||
self._remember(read, stamp)
|
||||
return fill_from_yesterday(read, yesterday=self.yesterday(stamp),
|
||||
since=since, until=until, stamp=stamp)
|
||||
|
||||
@@ -802,37 +798,6 @@ class PriceHistory:
|
||||
str(rec.get("provider") or "price_history"),
|
||||
stamp)
|
||||
|
||||
def stored(self, stamp: datetime) -> PriceRead:
|
||||
"""PRICE-01: every price this store holds, as one series, or no_data.
|
||||
|
||||
This is the LAST step of the price server's fallback (price_chain.py):
|
||||
the answer of the last fetch that worked, kept on disk, so a site can
|
||||
plan for as long as the days it holds last. It is a read of what was
|
||||
PUBLISHED and filed, in the order they happened, and it is never the
|
||||
carried fill (rule 1 holds: `carry(..., remember=False)` is how this
|
||||
read is filled, and it is not filed again).
|
||||
|
||||
The prices are the all-in ones stored when they were fetched. They are
|
||||
not worked out again, so a change of markup or taxes shows from the
|
||||
next fetch that works, not in these.
|
||||
"""
|
||||
if stamp.tzinfo is None or stamp.utcoffset() is None:
|
||||
raise ValueError(f"stored({stamp!r}): a naive datetime is not an "
|
||||
f"instant")
|
||||
points = []
|
||||
for day in sorted(self._days):
|
||||
rec = self._days[day]
|
||||
got, _bad = _parse_points(rec.get("points") if isinstance(
|
||||
rec, dict) else None)
|
||||
points.extend(got)
|
||||
if not points:
|
||||
why = self.last_error or "it holds no prices"
|
||||
return PriceRead.no_data(
|
||||
f"the price history at {self.path} gives nothing: {why}")
|
||||
points.sort(key=lambda p: p.start.astimezone(timezone.utc))
|
||||
return PriceRead.series(points, PriceProduct.DAY_AHEAD,
|
||||
"price_history", stamp)
|
||||
|
||||
# -- observability -------------------------------------------------------
|
||||
@property
|
||||
def words(self) -> str:
|
||||
@@ -908,14 +873,9 @@ class NoHistory:
|
||||
last_error = "no history is kept by this caller"
|
||||
words = "not kept"
|
||||
|
||||
def carry(self, read: PriceRead, *, since, until, stamp,
|
||||
remember: bool = True) -> PriceRead:
|
||||
def carry(self, read: PriceRead, *, since, until, stamp) -> PriceRead:
|
||||
return read
|
||||
|
||||
def stored(self, stamp: datetime) -> PriceRead:
|
||||
return PriceRead.no_data("this caller keeps no price history, so "
|
||||
"there are no stored prices")
|
||||
|
||||
def yesterday(self, stamp: datetime) -> PriceRead:
|
||||
return PriceRead.no_data(
|
||||
"this caller keeps no price history, so there are no prices from "
|
||||
|
||||
@@ -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. 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
|
||||
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
|
||||
is REFUSED rather than guessed at.
|
||||
"""
|
||||
if state is None:
|
||||
@@ -126,31 +126,6 @@ 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:
|
||||
|
||||
@@ -54,12 +54,10 @@ class SetupOnly:
|
||||
|
||||
def status_payload(self, lang: str = "en") -> dict:
|
||||
return {"setup_only": True, "mode": "setup_only",
|
||||
"banner": messages.pick(messages.banner_setup_only(),
|
||||
lang), "level": "warn",
|
||||
"banner": messages.banner_setup_only(), "level": "warn",
|
||||
"rows": [["Control", "not started, in setup"]],
|
||||
"checks": [{"ok": False, "warn": True,
|
||||
"text": messages.pick(messages.row_setup_only(),
|
||||
lang)}],
|
||||
"text": messages.row_setup_only()}],
|
||||
"hint": self.hint, "events": list(self.events)}
|
||||
|
||||
async def handle_action(self, what):
|
||||
|
||||
@@ -27,11 +27,6 @@ Without an hourly split, the day's energy is spread over a fixed bell from
|
||||
A reading older than `FRESH_S`, or from before local midnight, is stale,
|
||||
and a stale or missing forecast is no forecast: the day plan is off and
|
||||
every mode acts as in P3 option A.
|
||||
|
||||
OPT-17: each day may instead name a list (`pv_forecast_entities`,
|
||||
`pv_forecast_tomorrow_entities`), summed by `read_sum()`. One member that is
|
||||
not usable makes that day's sum unusable, never a partial sum.
|
||||
DECISIONS/OPT-17-several-forecast-entities.md.
|
||||
"""
|
||||
|
||||
import math
|
||||
@@ -54,12 +49,6 @@ BELL_FROM_H, BELL_TO_H = 6.0, 20.0
|
||||
NOT_SET, MISSING, UNIT, STALE, BAD = (
|
||||
"not_set", "missing", "unit", "stale", "bad")
|
||||
|
||||
# OPT-17: (single key, list key) per day. A set list replaces the single key;
|
||||
# the validator refuses both set for the same day.
|
||||
TODAY_KEYS = ("pv_forecast_entity", "pv_forecast_entities")
|
||||
TOMORROW_KEYS = ("pv_forecast_tomorrow_entity",
|
||||
"pv_forecast_tomorrow_entities")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Day:
|
||||
@@ -234,52 +223,6 @@ def _reported(state):
|
||||
return max(ts) if ts else None
|
||||
|
||||
|
||||
def entities(opts: dict, *, tomorrow=False) -> tuple:
|
||||
"""The forecast entities one day names, in configured order: the list
|
||||
if it has any entry, else the single key, else (). A repeated id is
|
||||
dropped, as `pv.entities()` does: it would count one forecast twice."""
|
||||
one, many = TOMORROW_KEYS if tomorrow else TODAY_KEYS
|
||||
listed = [e for e in (str(x or "").strip()
|
||||
for x in (opts.get(many) or [])) if e]
|
||||
if listed:
|
||||
return tuple(dict.fromkeys(listed))
|
||||
e = str(opts.get(one) or "").strip()
|
||||
return (e,) if e else ()
|
||||
|
||||
|
||||
def _bounds(now):
|
||||
here = now.astimezone(BILLING_TZ)
|
||||
d0 = datetime(here.year, here.month, here.day, tzinfo=BILLING_TZ)
|
||||
nd = d0.date() + timedelta(days=1)
|
||||
d1 = datetime(nd.year, nd.month, nd.day, tzinfo=BILLING_TZ)
|
||||
return d0.astimezone(timezone.utc), d1.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _today(state, day0, now):
|
||||
"""`(Day, at, None)` or `(None, None, why)` for one today sensor."""
|
||||
today, why = _day(state, day0, now, today=True)
|
||||
if today is None:
|
||||
return None, None, why
|
||||
at = _reported(state)
|
||||
# A reading from before local midnight is yesterday's "left today" (0
|
||||
# after sunset) until the integration refreshes: stale, not a dark day.
|
||||
if at is None or at < day0 or (now - at).total_seconds() > FRESH_S \
|
||||
or (at - now).total_seconds() > 300:
|
||||
return None, None, STALE
|
||||
return today, at, None
|
||||
|
||||
|
||||
def _tomorrow(state, day0, day1, now):
|
||||
"""`(Day, None)` or `(None, why)` for one tomorrow sensor."""
|
||||
t, why = _day(state, day1, now, today=False)
|
||||
if t is None:
|
||||
return None, why
|
||||
t_at = _reported(state)
|
||||
if t_at is None or t_at < day0 or (now - t_at).total_seconds() > FRESH_S:
|
||||
return None, STALE
|
||||
return t, None
|
||||
|
||||
|
||||
def read(today_state, tomorrow_state, now: datetime, *,
|
||||
configured=True):
|
||||
"""`(Forecast, None)` or `(None, why)`. `tomorrow_state` may be None
|
||||
@@ -287,64 +230,25 @@ def read(today_state, tomorrow_state, now: datetime, *,
|
||||
covers today only."""
|
||||
if not configured:
|
||||
return None, NOT_SET
|
||||
day0, day1 = _bounds(now)
|
||||
today, at, why = _today(today_state, day0, now)
|
||||
here = now.astimezone(BILLING_TZ)
|
||||
d0 = datetime(here.year, here.month, here.day, tzinfo=BILLING_TZ)
|
||||
nd = d0.date() + timedelta(days=1)
|
||||
d1 = datetime(nd.year, nd.month, nd.day, tzinfo=BILLING_TZ)
|
||||
day0, day1 = d0.astimezone(timezone.utc), d1.astimezone(timezone.utc)
|
||||
today, why = _day(today_state, day0, now, today=True)
|
||||
if today is None:
|
||||
return None, why
|
||||
at = _reported(today_state)
|
||||
# A reading from before local midnight is yesterday's "left today" (0
|
||||
# after sunset) until the integration refreshes: stale, not a dark day.
|
||||
if at is None or at < day0 or (now - at).total_seconds() > FRESH_S \
|
||||
or (at - now).total_seconds() > 300:
|
||||
return None, STALE
|
||||
tomorrow = None
|
||||
if tomorrow_state is not None:
|
||||
tomorrow, _w = _tomorrow(tomorrow_state, day0, day1, now)
|
||||
t, _w = _day(tomorrow_state, day1, now, today=False)
|
||||
t_at = _reported(tomorrow_state)
|
||||
if t is not None and t_at is not None and t_at >= day0 \
|
||||
and (now - t_at).total_seconds() <= FRESH_S:
|
||||
tomorrow = t
|
||||
return Forecast(today, tomorrow, at), None
|
||||
|
||||
|
||||
def _sum(days):
|
||||
"""One Day from several members' Days. The hourly split is kept only if
|
||||
every member has one: the splits are then joined (`_day_between()`
|
||||
adds overlapping periods). Else the total goes on the bell."""
|
||||
if len(days) == 1:
|
||||
return days[0]
|
||||
periods = ()
|
||||
if all(d.periods for d in days):
|
||||
periods = tuple(sorted(p for d in days for p in d.periods))
|
||||
return Day(days[0].day0, sum(d.kwh for d in days), periods)
|
||||
|
||||
|
||||
def read_sum(today, tomorrow, now: datetime):
|
||||
"""OPT-17: `(Forecast or None, why, gaps)` over lists of
|
||||
`(entity, state)`, one per configured member, in configured order.
|
||||
|
||||
Each member is read exactly as `read()` reads one sensor, in its own
|
||||
unit. A member that is missing, unavailable, stale, not in an energy
|
||||
unit or bad makes that DAY unusable, as one such sensor does today: no
|
||||
today means no forecast, no tomorrow means today only. A partial sum
|
||||
would under-count the sun and mislead the day plan.
|
||||
|
||||
`gaps` is `((day, position, entity, why), ...)` for every unusable
|
||||
member (`day` "today" or "tomorrow", `position` 1-based), so the page
|
||||
and the log can name it. With one member per day the forecast is what
|
||||
`read()` gives (test_opt17.py proves it)."""
|
||||
if not today:
|
||||
return None, NOT_SET, ()
|
||||
day0, day1 = _bounds(now)
|
||||
gaps, days, ats, why0 = [], [], [], None
|
||||
for i, (ent, st) in enumerate(today, 1):
|
||||
d, at, why = _today(st, day0, now)
|
||||
if d is None:
|
||||
gaps.append(("today", i, ent, why))
|
||||
why0 = why0 or why
|
||||
else:
|
||||
days.append(d)
|
||||
ats.append(at)
|
||||
tdays = []
|
||||
for i, (ent, st) in enumerate(tomorrow, 1):
|
||||
t, why = _tomorrow(st, day0, day1, now)
|
||||
if t is None:
|
||||
gaps.append(("tomorrow", i, ent, why))
|
||||
else:
|
||||
tdays.append(t)
|
||||
gaps = tuple(gaps)
|
||||
if why0 is not None:
|
||||
return None, why0, gaps
|
||||
tday = _sum(tdays) if tomorrow and len(tdays) == len(tomorrow) else None
|
||||
# The oldest member's report is when the sum was last fully known.
|
||||
return Forecast(_sum(days), tday, min(ats)), None, gaps
|
||||
|
||||
@@ -78,8 +78,6 @@ 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}
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
}
|
||||
function renderPill() {
|
||||
const st = S.state || null;
|
||||
const dot = st ? ({ running: "", shadow: " warn", stopped: " warn", warning: " warn", alarm: " bad", waiting: " warn" }[st.kind] ?? " warn")
|
||||
const dot = st ? ({ running: "", shadow: " warn", stopped: " warn", warning: " warn", alarm: " bad" }[st.kind] ?? " warn")
|
||||
: (S.level === "ok" ? "" : S.level === "bad" ? " bad" : " warn");
|
||||
const words = st ? [st.pill, st.ran].filter(Boolean).join(". ") : S.setup_only === true ? t("setup_only.name") : S.banner;
|
||||
$("#pill").replaceChildren(h("span", { class: "dot" + dot, "aria-hidden": "true" }), h("span", {}, words));
|
||||
@@ -177,12 +177,11 @@
|
||||
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.estimate ? ", " + t("plan.estimate_tip") : "");
|
||||
const est = (b.points > 1 ? ", " + t("plan.average_tip") : "") + (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 + "."]);
|
||||
@@ -337,39 +336,6 @@
|
||||
finally { packBusy = false; }
|
||||
await refresh(true);
|
||||
}
|
||||
// MAINT-11: start or stop ONE battery's cycle. Two or more batteries only;
|
||||
// the add-on refuses a second cycle and says which battery is cycling.
|
||||
async function cycleAct(what, b) {
|
||||
if (packBusy) return;
|
||||
packBusy = true;
|
||||
try {
|
||||
const r = await fetch("action", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: what, unit: b.name }) });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
const own = j.code && t("batteries.cycle." + j.code);
|
||||
if (!r.ok) toast(own ? say(own.description, { name: j.unit || b.name }) : (j.error || t("toast.failed")));
|
||||
else toast(say(t("batteries.cycle.toast." + (what === "maint_start" ? "started" : "stopped")), { name: b.name }));
|
||||
} catch (e) { toast(t("toast.failed")); }
|
||||
finally { packBusy = false; }
|
||||
await refresh(true);
|
||||
}
|
||||
// A button that cannot work is disabled (the add-on still refuses it): Stop
|
||||
// needs this battery cycling; Start needs no battery cycling and this one on.
|
||||
function cycleState(b) {
|
||||
const cycling = (x) => x.maintenance !== "idle";
|
||||
return { start: !S.batteries.some(cycling) && b.use !== false, abort: cycling(b) };
|
||||
}
|
||||
function cycleButtons(b, i) {
|
||||
const can = cycleState(b);
|
||||
const ask1 = (key, what) => () => {
|
||||
const w = t("batteries.cycle." + key);
|
||||
ask({ name: say(w.name, { name: b.name }), description: say(w.description, { name: b.name }), yes: w.yes },
|
||||
() => cycleAct(what, b));
|
||||
};
|
||||
return h("div", { class: "cyc", style: "display:flex;gap:8px;flex-wrap:wrap;margin-top:10px" },
|
||||
h("button", { type: "button", class: "btn", id: "bcs-" + i, disabled: !can.start, onclick: ask1("start_dialog", "maint_start") }, t("batteries.cycle.start")),
|
||||
h("button", { type: "button", class: "btn danger", id: "bca-" + i, disabled: !can.abort, onclick: ask1("abort_dialog", "maint_abort") }, t("batteries.cycle.abort")));
|
||||
}
|
||||
function onSwitch(b, i, where, lid) {
|
||||
const sw = h("button", { type: "button", class: "switch", role: "switch", id: "bsw-" + where + i,
|
||||
"aria-checked": String(b.use), "aria-labelledby": lid,
|
||||
@@ -404,8 +370,7 @@
|
||||
h("dt", {}, t("batteries.level")), h("dd", {}, lvl + stored),
|
||||
h("dt", {}, t("batteries.now")), h("dd", {}, b.now),
|
||||
h("dt", {}, t("batteries.today")), h("dd", {}, todayLines(b)),
|
||||
h("dt", {}, t("batteries.why")), h("dd", { class: "muted" }, b.why || "–")),
|
||||
S.batteries.length > 1 ? cycleButtons(b, i) : null);
|
||||
h("dt", {}, t("batteries.why")), h("dd", { class: "muted" }, b.why || "–")));
|
||||
}
|
||||
function simpleRow(b, i) {
|
||||
const lid = "bsname-" + i;
|
||||
@@ -434,7 +399,7 @@
|
||||
$("#batt-how").hidden = !modes;
|
||||
const docs = $("#batt-docs");
|
||||
if (S.control && S.control.docs_url) { docs.href = S.control.docs_url; docs.hidden = false; } else docs.hidden = true;
|
||||
if (S.state && ["shadow", "stopped", "alarm", "waiting"].includes(S.state.kind)) banner($("#batt-banner"), S.level, S.state.pill, S.state.sub, S.level === "bad" ? "alert" : null);
|
||||
if (S.state && ["shadow", "stopped", "alarm"].includes(S.state.kind)) banner($("#batt-banner"), S.level, S.state.pill, S.state.sub, S.level === "bad" ? "alert" : null);
|
||||
else $("#batt-banner").replaceChildren();
|
||||
keepFocus(() => {
|
||||
$("#bcards").replaceChildren(...S.batteries.map(batteryCard));
|
||||
|
||||
@@ -311,7 +311,7 @@
|
||||
const days = h("select", { class: "sel", "aria-label": say(P.days) });
|
||||
for (const [v, label] of f.days || []) days.append(h("option", { value: v, selected: v === row.days }, label));
|
||||
days.addEventListener("change", () => set("days", days.value));
|
||||
const price = h("input", { class: "num", type: "number", inputmode: "decimal", step: String(f.step || 0.01),
|
||||
const price = h("input", { class: "num", type: "number", inputmode: "decimal", step: "0.01",
|
||||
value: row.price == null ? "" : row.price, "aria-label": say(P.price) });
|
||||
price.addEventListener("input", () => set("price", price.value === "" || isNaN(+price.value) ? null : +price.value));
|
||||
const cell = (label, ...kids) => h("label", { class: "pf" }, h("span", { class: "tiny muted" }, label), ...kids);
|
||||
|
||||
@@ -204,13 +204,12 @@ import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from . import messages, price_chain
|
||||
from . import messages
|
||||
from .price import (
|
||||
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
|
||||
Uncertainty, _Outcome, _cover_table, brussels, load_prices, price_option,
|
||||
Uncertainty, _Outcome, brussels, load_prices, price_option,
|
||||
provider_configured, required_spread, uncertainty_of, widen_factor,
|
||||
)
|
||||
from .price_chain import uses_chain
|
||||
|
||||
_LOG = logging.getLogger("goodwe.tariff.static")
|
||||
|
||||
@@ -794,13 +793,6 @@ class TariffPlan:
|
||||
self.read = read
|
||||
self.profile = profile
|
||||
self.origin = origin
|
||||
# PRICE-01. The series the price chain found, BEFORE the yesterday fill
|
||||
# cut it to the hours ahead: the maintenance gate needs the whole of
|
||||
# today to know its average. None for every plan that did not come
|
||||
# through the chain, which is every plan of every other provider.
|
||||
self.published = None
|
||||
self.step = None
|
||||
self.reasons = ()
|
||||
|
||||
# -- derived facts -------------------------------------------------------
|
||||
@property
|
||||
@@ -986,25 +978,12 @@ class TariffPlan:
|
||||
return here.eur_per_kwh < (sum(p.eur_per_kwh for p in firm) / len(firm))
|
||||
|
||||
def arbitrage(self, *, peak_guard_active: bool, min_spread_eur_kwh,
|
||||
uncertainty, now: datetime) -> Arbitrage:
|
||||
uncertainty) -> Arbitrage:
|
||||
"""May OPT-04 trade, and on what numbers. See the module docstring.
|
||||
|
||||
PRICE-02. `now` (tz-aware) drops the points whose price period has
|
||||
ended (`end <= now`, the span `price._cover_table()` gives it), so the
|
||||
cheapest and dearest point are always ones that can still be traded.
|
||||
It is taken here, per call, and not when the plan is built: a plan
|
||||
built at 03:00 is still used at 03:20. An hourly point that is still
|
||||
current stays in.
|
||||
|
||||
⚠️ ALL FOUR ARGUMENTS ARE REQUIRED AND KEYWORD-ONLY, for one reason
|
||||
⚠️ ALL THREE ARGUMENTS ARE REQUIRED AND KEYWORD-ONLY, for one reason
|
||||
each, and none of them has a default:
|
||||
|
||||
`now` PRICE-02. A caller that could leave it out
|
||||
would get the whole series back, elapsed
|
||||
periods included, and the verdict could aim
|
||||
at a period that is over. A test that wants
|
||||
the whole series passes an instant before it.
|
||||
|
||||
`peak_guard_active` §9.1/§10.1 put peak shaving above arbitrage,
|
||||
and this is how that ordering survives a
|
||||
strategy that has not been written yet: the
|
||||
@@ -1042,12 +1021,6 @@ class TariffPlan:
|
||||
f"{sorted(map(repr, caller_sources))} - a string widens by "
|
||||
f"nothing while reporting that it widened.")
|
||||
|
||||
if not isinstance(now, datetime) or now.tzinfo is None \
|
||||
or now.utcoffset() is None:
|
||||
raise TypeError(f"now must be a tz-aware datetime, not {now!r}; "
|
||||
f"a naive instant is ambiguous for one hour "
|
||||
f"every October")
|
||||
|
||||
if peak_guard_active:
|
||||
return Arbitrage.refuse(
|
||||
"the capacity-tariff peak guard is holding, and §10.1 puts peak "
|
||||
@@ -1075,8 +1048,6 @@ class TariffPlan:
|
||||
# day with no fetch at all) has no firm pair left and is refused, which
|
||||
# is the previous behaviour arrived at by a truer route.
|
||||
pts = self.read.points
|
||||
ahead = {id(p) for _s, end, p in _cover_table(pts) if end > now}
|
||||
pts = tuple(p for p in pts if id(p) in ahead)
|
||||
firm = tuple(p for p in pts if not p.provisional)
|
||||
if len(firm) < 2:
|
||||
# ⚠️ POINTS, AND THE HOUR FIGURE BESIDE IT RATHER THAN INSTEAD OF
|
||||
@@ -1103,12 +1074,8 @@ class TariffPlan:
|
||||
f"the tariff is flat - every one of the {len(firm)} firm "
|
||||
f"points, spanning {_whole_hours(firm)} hours, costs "
|
||||
f"{firm[0].eur_per_kwh:.4f} EUR/kWh, so buying low and "
|
||||
f"selling high is arithmetically impossible"
|
||||
# UX-53: only a real fixed product is "fully fixed". A
|
||||
# dynamic or day/night site whose remaining periods happen to
|
||||
# be equal trades again when they differ.
|
||||
+ (". §9.2: a fully fixed product is self-consumption only"
|
||||
if self.product is PriceProduct.FIXED else ""))
|
||||
f"selling high is arithmetically impossible. §9.2: a fully "
|
||||
f"fixed product is self-consumption only")
|
||||
|
||||
# REFUSAL 5, NEW IN FCST-03 (AC3). One widening path, driven by the set
|
||||
# of active uncertainty sources: the product's own (§9.2 row 2, derived
|
||||
@@ -1265,16 +1232,6 @@ def static_plan(opts: dict, since: datetime, until: datetime, *,
|
||||
profile, "fallback" if provisional else "static")
|
||||
|
||||
|
||||
def static_gated(opts: dict, now: datetime) -> bool:
|
||||
"""MAINT-13: is a fixed tariff set up, so that a cheap window can hold a
|
||||
charge back? False for no profile or one that cannot be used."""
|
||||
try:
|
||||
return bool(static_plan(opts, now, now + timedelta(days=MEAN_WINDOW_DAYS),
|
||||
now=now).has_prices)
|
||||
except ProfileInvalid:
|
||||
return False
|
||||
|
||||
|
||||
def static_status(opts: dict, now: datetime):
|
||||
"""(cheap, words) for the status page and for main.py's cheap-window flag.
|
||||
|
||||
@@ -1307,8 +1264,7 @@ def static_status(opts: dict, now: datetime):
|
||||
|
||||
|
||||
async def resolve_tariff(opts: dict, since: datetime, until: datetime,
|
||||
transport=None, now=None, *, history,
|
||||
watch=None) -> TariffPlan:
|
||||
transport=None, now=None, *, history) -> TariffPlan:
|
||||
"""The full §9.2 + §11.4 resolution. MAY PERFORM I/O - see `static_plan`.
|
||||
|
||||
⚠️ NOT ON THE 30 s TICK, and that is a deliberate boundary rather than an
|
||||
@@ -1359,40 +1315,18 @@ async def resolve_tariff(opts: dict, since: datetime, until: datetime,
|
||||
that, so the spread is over hours that are still ahead. Both directions of
|
||||
the difference are real: the window gains the unpublished hours it was
|
||||
missing and loses the elapsed ones it should never have had.
|
||||
|
||||
⚠️ PRICE-02: THAT HOLDS FOR AN HOURLY FEED ONLY. The window is floored to
|
||||
the hour, so a 15-minute feed keeps the elapsed periods of the current
|
||||
hour, and the plan ages between refreshes. `TariffPlan.arbitrage(now=...)`
|
||||
drops the periods that have ended, at every call.
|
||||
"""
|
||||
stamp = now or datetime.now(timezone.utc)
|
||||
if not provider_configured(opts):
|
||||
return static_plan(opts, since, until, provisional=False, now=stamp)
|
||||
|
||||
if uses_chain(opts):
|
||||
# PRICE-01. The provider names fallbacks, so its prices come from the
|
||||
# first step of the chain that has one for now. Nothing else does.
|
||||
found = await price_chain.resolve(
|
||||
opts, since, until, transport, history, stamp,
|
||||
watch if watch is not None else price_chain.PriceWatch(),
|
||||
load=load_prices)
|
||||
plan = found.read.match(
|
||||
series=lambda r: _provider_plan(
|
||||
opts, r, since, until, stamp, history,
|
||||
remember=found.step != price_chain.STEP_STORED),
|
||||
no_data=lambda r: _degrade(opts, r, since, until, stamp))
|
||||
plan.published, plan.step = found.published, found.step
|
||||
plan.reasons = found.reasons
|
||||
return plan
|
||||
|
||||
read = await load_prices(opts, since, until, transport)
|
||||
return read.match(
|
||||
series=lambda r: _provider_plan(opts, r, since, until, stamp, history),
|
||||
no_data=lambda r: _degrade(opts, r, since, until, stamp))
|
||||
|
||||
|
||||
def _provider_plan(opts, read, since, until, stamp, history, *,
|
||||
remember=True) -> TariffPlan:
|
||||
def _provider_plan(opts, read, since, until, stamp, history) -> TariffPlan:
|
||||
"""§8.3: record what the feed published, carry yesterday into the gaps.
|
||||
|
||||
The fill can itself return `no_data` - a feed whose points all fall outside
|
||||
@@ -1400,12 +1334,7 @@ def _provider_plan(opts, read, since, until, stamp, history, *,
|
||||
§11.4 row arrived at one step later, so it degrades exactly as a failed
|
||||
fetch does rather than being special-cased here.
|
||||
"""
|
||||
if remember:
|
||||
filled = history.carry(read, since=since, until=until, stamp=stamp)
|
||||
else:
|
||||
# PRICE-01: a read that came out of the store is not filed again.
|
||||
filled = history.carry(read, since=since, until=until, stamp=stamp,
|
||||
remember=False)
|
||||
filled = history.carry(read, since=since, until=until, stamp=stamp)
|
||||
return filled.match(
|
||||
series=lambda r: TariffPlan(r, _quiet_profile(opts), "provider"),
|
||||
no_data=lambda r: _degrade(opts, r, since, until, stamp))
|
||||
|
||||
@@ -94,7 +94,6 @@ DEFAULTS = {
|
||||
"phase_started": None,
|
||||
"last_completed": None,
|
||||
"last_start_attempt": None,
|
||||
"last_failed": None,
|
||||
"sign_tripped": False,
|
||||
"sign_reason": "",
|
||||
"circ_tripped": False,
|
||||
@@ -172,7 +171,7 @@ MAINT_SCHEMA_KEY = "maint_schema_version"
|
||||
# silently stop being migrated - the same property `UNIT_STORE_KEYS` gives the
|
||||
# SAFETY-02 pair.
|
||||
MAINT_UNIT_KEYS = ("phase", "phase_started", "last_completed",
|
||||
"last_start_attempt", "last_failed")
|
||||
"last_start_attempt")
|
||||
|
||||
|
||||
def unit_key(unit: str, key: str) -> str:
|
||||
|
||||
@@ -776,14 +776,9 @@ class PeakTracker:
|
||||
return r.peak_w / self.cap_w
|
||||
|
||||
@staticmethod
|
||||
def project(averager, now: datetime, roll: bool = False) -> Projection:
|
||||
def project(averager, now: datetime) -> Projection:
|
||||
"""End-of-quarter average if the last sample held for the rest of it.
|
||||
|
||||
`roll` (OPT-15, battery modes only): when `now` is past the open block
|
||||
because no sample has come since the boundary, project the quarter
|
||||
`now` is in, crediting the held sample as `add()` will (`max_hold_s`).
|
||||
Without it that tick reads "no seconds left" and measures nothing.
|
||||
|
||||
⚠️ The two divisors are DIFFERENT and only coincide at the end of the
|
||||
block. `elapsed_w` divides by the seconds actually covered (what the
|
||||
meter has shown so far); `projected_w` divides by the full 900 s and
|
||||
@@ -803,14 +798,6 @@ class PeakTracker:
|
||||
elapsed_wall = max(0.0, (now - start).total_seconds())
|
||||
elapsed = averager.elapsed_s
|
||||
acc = averager.partial_ws
|
||||
if roll and elapsed_wall >= QUARTER_S:
|
||||
t = now.timestamp()
|
||||
block = int(t // QUARTER_S) * QUARTER_S
|
||||
start = datetime.fromtimestamp(block, timezone.utc)
|
||||
held_end = float(averager.held_t) + float(averager.max_hold_s)
|
||||
elapsed = max(0.0, min(t, held_end) - block)
|
||||
elapsed_wall = t - block
|
||||
acc = max(averager.held_net_w, 0.0) * elapsed
|
||||
remaining = max(0.0, QUARTER_S - elapsed_wall)
|
||||
# ⚠️ NO `getattr` DEFAULT, AND THAT IS THE FIX, NOT A TIDY-UP. This read
|
||||
# was `getattr(averager, "_last_net", 0.0)`: a private name with a
|
||||
@@ -835,22 +822,6 @@ class PeakTracker:
|
||||
held_w=averager.held_net_w)
|
||||
|
||||
|
||||
def next_quarter(p: Projection, max_hold_s: float) -> Projection:
|
||||
"""SAFETY-91: `p` past its block's end, before the next block's first
|
||||
telegram: the next quarter, projected from the held reading (the averager
|
||||
credits it there). `p` itself otherwise, or when that reading is too old
|
||||
to be credited. DECISIONS/SAFETY-91-held-battery-last.md."""
|
||||
if p.block_start is None or p.elapsed_s < QUARTER_S \
|
||||
or p.elapsed_s * (1.0 - p.covered_frac) > float(max_hold_s):
|
||||
return p
|
||||
held = max(p.held_w, 0.0)
|
||||
return Projection(projected_w=held, elapsed_w=held,
|
||||
elapsed_s=p.elapsed_s - QUARTER_S, covered_frac=1.0,
|
||||
complete=True,
|
||||
block_start=p.block_start + timedelta(seconds=QUARTER_S),
|
||||
held_w=p.held_w)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ClosedBlock:
|
||||
"""A block reconstructed from a checkpoint. Shaped like `p1.QuarterBlock` so
|
||||
|
||||
@@ -23,19 +23,19 @@
|
||||
"contract": {"name": "Your contract", "description": "How your supplier prices each kWh you take from the grid."},
|
||||
"region": {"name": "Region", "description": "Fills in grid costs, levies and VAT for the year shown, but not yet for Wallonia or Brussels, so check your bill."},
|
||||
"fallback": {"name": "Price when the feed is down", "description": "Used when the price source does not answer."},
|
||||
"fixed_price": {"name": "Price per kWh, all in", "description": "Energy, supplier costs, grid costs, levies and VAT together: divide c€/kWh from the bill by 100, so 34.512 c€/kWh is 0.34512."},
|
||||
"day_price": {"name": "Day price, weekdays 07:00 to 22:00", "description": "All in, as on your bill: divide c€/kWh by 100, so 34.512 c€/kWh is 0.34512."},
|
||||
"night_price": {"name": "Night and weekend price", "description": "All in, as on your bill: divide c€/kWh by 100, so 34.512 c€/kWh is 0.34512."},
|
||||
"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."}
|
||||
"fixed_price": {"name": "Price per kWh, all in", "description": "Energy, supplier costs, grid costs, levies and VAT together."},
|
||||
"day_price": {"name": "Day price, weekdays 07:00 to 22:00", "description": "All in, as on your bill."},
|
||||
"night_price": {"name": "Night and weekend price", "description": "All in, as on your bill."},
|
||||
"periods": {"name": "Time windows and prices", "description": "One price per kWh for each time window, all in, as on your bill."}
|
||||
},
|
||||
"choices": {
|
||||
"contract": {"none": "No prices", "fixed": "Fixed price", "daynight": "Day and night", "dynamic": "Dynamic, every 15 minutes", "custom": "Other time windows"},
|
||||
"contract": {"none": "No prices", "fixed": "Fixed price", "daynight": "Day and night", "dynamic": "Dynamic, hourly", "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"},
|
||||
"meter_phases": {"1": "1 phase", "3": "3 phases"},
|
||||
"shadow_mode": {"auto": "Automatic", "on": "Always on", "off": "Off"},
|
||||
"price_provider": {"generic_dayahead": "Day-ahead market price", "custom_formula": "Your own price formula", "manual_csv": "A price file", "price_service": "Your own price service"},
|
||||
"price_provider": {"generic_dayahead": "Day-ahead market price", "custom_formula": "Your own price formula", "manual_csv": "A price file"},
|
||||
"log_level": {"trace": "Everything, for support", "debug": "Detailed, to find a problem", "info": "Normal", "warning": "Warnings and errors", "error": "Errors only"},
|
||||
"period_days": {"mon-sun": "Every day", "mon-fri": "Monday to Friday", "sat,sun": "Saturday and Sunday", "mon": "Monday", "tue": "Tuesday", "wed": "Wednesday", "thu": "Thursday", "fri": "Friday", "sat": "Saturday", "sun": "Sunday"}
|
||||
},
|
||||
@@ -66,7 +66,8 @@
|
||||
"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"
|
||||
"list": "The plan in words",
|
||||
"average_tip": "hour average"
|
||||
},
|
||||
"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"},
|
||||
@@ -91,7 +92,6 @@
|
||||
"off": {"name": "Off", "description": "Rests, and helps only when the peak limit needs it."}
|
||||
},
|
||||
"today": "Today", "why": "Why",
|
||||
"cycle": {"start": "Start cycle", "abort": "Stop cycle", "start_dialog": {"name": "Start a cycle on {name}?", "description": "{name} empties, refills and holds full, which takes a few hours.", "yes": "Start the cycle"}, "abort_dialog": {"name": "Stop the cycle on {name}?", "description": "Every inverter is set to 0 W for a moment, then control carries on.", "yes": "Stop the cycle"}, "toast": {"started": "Cycle started on {name}.", "stopped": "Cycle stopped on {name}."}, "unit": {"description": "There is no battery with that name."}, "busy": {"description": "Only one battery can cycle at a time, and {name} is cycling now."}, "off": {"description": "{name} is switched off, so it cannot start a cycle."}, "idle": {"description": "{name} is not running a cycle."}},
|
||||
"modes_off": {"name": "Battery modes are off"},
|
||||
"how": {"name": "How the modes work", "description": "Which battery gets solar first, and when each gives power.",
|
||||
"solar_first": {"description": "Solar first gets spare solar before the other batteries, and covers the house at any hour."},
|
||||
@@ -267,7 +267,6 @@
|
||||
"shadow": "Shadow mode",
|
||||
"stopped": "Stopped",
|
||||
"alarm": "Needs you now",
|
||||
"waiting": "Waiting for Home Assistant",
|
||||
"warning": "Running, with a warning"
|
||||
},
|
||||
"banner_sub": {
|
||||
@@ -275,7 +274,6 @@
|
||||
"shadow": "The plans below say what the add-on would do.",
|
||||
"stopped": "Start control below when you are ready.",
|
||||
"alarm": "See Needs attention below for what to do.",
|
||||
"waiting": "Control resumes by itself when Home Assistant answers.",
|
||||
"warning": "See Needs attention below."
|
||||
},
|
||||
"tile": {
|
||||
@@ -367,11 +365,11 @@
|
||||
"run": {
|
||||
"grid_what": "Charges up to {kwh} from the grid.",
|
||||
"grid_what_would": "Would charge up to {kwh} from the grid.",
|
||||
"grid_why": "Lowest price left, {eur} per kWh.",
|
||||
"grid_why": "Lowest published price, {eur} per kWh.",
|
||||
"grid_amount": "Costs up to {eur}.",
|
||||
"give_what": "Gives up to {kwh}, to the house first.",
|
||||
"give_what_would": "Would give up to {kwh}, to the house first.",
|
||||
"give_why": "Highest price left, {eur} per kWh.",
|
||||
"give_why": "Highest published price, {eur} per kWh.",
|
||||
"sell_what": "Sells up to {kwh}, to the house first.",
|
||||
"sell_what_would": "Would sell up to {kwh}, to the house first.",
|
||||
"cheap_why": "Cheapest hours, about {eur} per kWh.",
|
||||
@@ -409,13 +407,9 @@
|
||||
"give_would": "Tomorrow: would give power at {at}, {eur} per kWh."
|
||||
},
|
||||
"cycle_waits": "{name} waits its turn for the monthly cycle.",
|
||||
"no_prices_yet": "No prices yet, maintenance waits.",
|
||||
"no_prices_since": "No prices since {at}, maintenance waits.",
|
||||
"no_prices_since_day": "No prices since {day} {month} at {at}, maintenance waits.",
|
||||
"step_in": "The batteries step in above {kw}.",
|
||||
"step_in_would": "The batteries would step in above {kw}.",
|
||||
"period": "This 15-minute period is heading for {kw}.",
|
||||
"period_unmeasured": "Part of this 15-minute period was not measured.",
|
||||
"prices": {
|
||||
"now": "Price now {eur} per kWh, all in.",
|
||||
"today": "Today: lowest {lo} at {lo_at}, highest {hi} at {hi_at}.",
|
||||
@@ -436,7 +430,6 @@
|
||||
"stopped": "Control is stopped. Every inverter is at 0 W."
|
||||
},
|
||||
"attn": {
|
||||
"home_assistant": "Home Assistant is not answering",
|
||||
"controller": "Control is not running",
|
||||
"meter": "Meter reading",
|
||||
"meter_frozen": "Meter reading may be stuck",
|
||||
@@ -455,7 +448,6 @@
|
||||
"delivery": "An inverter is not delivering",
|
||||
"estop": "Emergency stop",
|
||||
"pv": "Solar reading",
|
||||
"solar_forecast": "Solar forecast",
|
||||
"none": "Inverter connection"
|
||||
},
|
||||
"months": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
|
||||
|
||||
@@ -23,19 +23,19 @@
|
||||
"contract": {"name": "Uw contract", "description": "Hoe uw leverancier elke kWh prijst die u van het net afneemt."},
|
||||
"region": {"name": "Regio", "description": "Vult nettarieven, heffingen en btw in voor het getoonde jaar, nog niet voor Wallonië of Brussel, dus kijk uw factuur na."},
|
||||
"fallback": {"name": "Prijs als de feed wegvalt", "description": "Gebruikt als de prijsbron niet antwoordt."},
|
||||
"fixed_price": {"name": "Prijs per kWh, alles inbegrepen", "description": "Energie, kosten van de leverancier, nettarieven, heffingen en btw samen: deel c€/kWh van de factuur door 100, dus 34,512 c€/kWh is 0,34512."},
|
||||
"day_price": {"name": "Dagprijs, weekdagen 07:00 tot 22:00", "description": "Alles inbegrepen, zoals op uw factuur: deel c€/kWh door 100, dus 34,512 c€/kWh is 0,34512."},
|
||||
"night_price": {"name": "Nacht- en weekendprijs", "description": "Alles inbegrepen, zoals op uw factuur: deel c€/kWh door 100, dus 34,512 c€/kWh is 0,34512."},
|
||||
"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."}
|
||||
"fixed_price": {"name": "Prijs per kWh, alles inbegrepen", "description": "Energie, kosten van de leverancier, nettarieven, heffingen en btw samen."},
|
||||
"day_price": {"name": "Dagprijs, weekdagen 07:00 tot 22:00", "description": "Alles inbegrepen, zoals op uw factuur."},
|
||||
"night_price": {"name": "Nacht- en weekendprijs", "description": "Alles inbegrepen, zoals op uw factuur."},
|
||||
"periods": {"name": "Tijdvakken en prijzen", "description": "Eén prijs per kWh voor elk tijdvak, alles inbegrepen, zoals op uw factuur."}
|
||||
},
|
||||
"choices": {
|
||||
"contract": {"none": "Geen prijzen", "fixed": "Vaste prijs", "daynight": "Dag en nacht", "dynamic": "Dynamisch, per kwartier", "custom": "Andere tijdvakken"},
|
||||
"contract": {"none": "Geen prijzen", "fixed": "Vaste prijs", "daynight": "Dag en nacht", "dynamic": "Dynamisch, per uur", "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"},
|
||||
"meter_phases": {"1": "1 fase", "3": "3 fasen"},
|
||||
"shadow_mode": {"auto": "Automatisch", "on": "Altijd aan", "off": "Uit"},
|
||||
"price_provider": {"generic_dayahead": "Day-aheadprijs van de markt", "custom_formula": "Uw eigen prijsformule", "manual_csv": "Een prijsbestand", "price_service": "Uw eigen prijsdienst"},
|
||||
"price_provider": {"generic_dayahead": "Day-aheadprijs van de markt", "custom_formula": "Uw eigen prijsformule", "manual_csv": "Een prijsbestand"},
|
||||
"log_level": {"trace": "Alles, voor support", "debug": "Uitgebreid, om een probleem te vinden", "info": "Normaal", "warning": "Waarschuwingen en fouten", "error": "Alleen fouten"},
|
||||
"period_days": {"mon-sun": "Elke dag", "mon-fri": "Maandag tot vrijdag", "sat,sun": "Zaterdag en zondag", "mon": "maandag", "tue": "dinsdag", "wed": "woensdag", "thu": "donderdag", "fri": "vrijdag", "sat": "zaterdag", "sun": "zondag"}
|
||||
},
|
||||
@@ -63,7 +63,8 @@
|
||||
"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"
|
||||
"list": "Het plan in woorden",
|
||||
"average_tip": "gemiddelde van het uur"
|
||||
},
|
||||
"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"},
|
||||
@@ -88,7 +89,6 @@
|
||||
"off": {"name": "Uit", "description": "Rust, en helpt alleen als de piekgrens haar nodig heeft."}
|
||||
},
|
||||
"today": "Vandaag", "why": "Waarom",
|
||||
"cycle": {"start": "Cyclus starten", "abort": "Cyclus stoppen", "start_dialog": {"name": "Cyclus starten op {name}?", "description": "{name} loopt leeg, laadt weer op en blijft vol, wat enkele uren duurt.", "yes": "Cyclus starten"}, "abort_dialog": {"name": "Cyclus stoppen op {name}?", "description": "Elke omvormer gaat even naar 0 W, daarna gaat de regeling verder.", "yes": "Cyclus stoppen"}, "toast": {"started": "Cyclus gestart op {name}.", "stopped": "Cyclus gestopt op {name}."}, "unit": {"description": "Er is geen batterij met die naam."}, "busy": {"description": "Er kan maar één batterij tegelijk een cyclus doen, en {name} is nu bezig."}, "off": {"description": "{name} staat uit en kan dus geen cyclus starten."}, "idle": {"description": "{name} doet nu geen cyclus."}},
|
||||
"modes_off": {"name": "Batterijmodi staan uit"},
|
||||
"how": {"name": "Zo werken de modi", "description": "Welke batterij eerst zonne-energie krijgt, en wanneer elke batterij levert.",
|
||||
"solar_first": {"description": "Eerst zonne-energie krijgt het overschot vóór de andere batterijen, en dekt het huis op elk uur."},
|
||||
@@ -230,8 +230,8 @@
|
||||
"zero_held": "Op 0 W gehouden",
|
||||
"zero_going": "Gaat naar 0 W",
|
||||
"verdict": {"held": "Op 0 W gehouden", "stopped": "Gestopt", "cycle": "Vandaag maandelijkse cyclus", "trade": "Handelt op prijs", "house": "Volgt het huis"},
|
||||
"pill": {"running": "Actief", "shadow": "Schaduwmodus", "stopped": "Gestopt", "alarm": "Heeft u nu nodig", "waiting": "Wacht op Home Assistant", "warning": "Actief, met een waarschuwing"},
|
||||
"banner_sub": {"running": "De batterijen volgen het plan hieronder.", "shadow": "De plannen hieronder tonen wat de add-on zou doen.", "stopped": "Start de regeling hieronder als u klaar bent.", "alarm": "Zie Vraagt aandacht hieronder voor wat u moet doen.", "waiting": "De regeling gaat vanzelf verder zodra Home Assistant antwoordt.", "warning": "Zie Vraagt aandacht hieronder."},
|
||||
"pill": {"running": "Actief", "shadow": "Schaduwmodus", "stopped": "Gestopt", "alarm": "Heeft u nu nodig", "warning": "Actief, met een waarschuwing"},
|
||||
"banner_sub": {"running": "De batterijen volgen het plan hieronder.", "shadow": "De plannen hieronder tonen wat de add-on zou doen.", "stopped": "Start de regeling hieronder als u klaar bent.", "alarm": "Zie Vraagt aandacht hieronder voor wat u moet doen.", "warning": "Zie Vraagt aandacht hieronder."},
|
||||
"tile": {"not_set_up": "Niet ingesteld", "no_reading": "Geen meting", "producing": "Produceert", "not_producing": "Produceert niet", "needs_solar": "Zonnemeting nodig", "not_adding_up": "Metingen kloppen niet", "using": "Verbruikt", "taking": "Neemt af van het net", "feeding": "Injecteert", "balanced": "In evenwicht", "giving": "Levert stroom", "charging": "Laadt", "resting": "Rust"},
|
||||
"now": {
|
||||
"no_reading": "Geen meting van deze batterij.",
|
||||
@@ -307,11 +307,11 @@
|
||||
"run": {
|
||||
"grid_what": "Laadt tot {kwh} van het net.",
|
||||
"grid_what_would": "Zou tot {kwh} van het net laden.",
|
||||
"grid_why": "Laagste prijs die nog over is, {eur} per kWh.",
|
||||
"grid_why": "Laagste gepubliceerde prijs, {eur} per kWh.",
|
||||
"grid_amount": "Kost tot {eur}.",
|
||||
"give_what": "Levert tot {kwh}, eerst aan het huis.",
|
||||
"give_what_would": "Zou tot {kwh} leveren, eerst aan het huis.",
|
||||
"give_why": "Hoogste prijs die nog over is, {eur} per kWh.",
|
||||
"give_why": "Hoogste gepubliceerde prijs, {eur} per kWh.",
|
||||
"sell_what": "Verkoopt tot {kwh}, eerst aan het huis.",
|
||||
"sell_what_would": "Zou tot {kwh} verkopen, eerst aan het huis.",
|
||||
"cheap_why": "Goedkoopste uren, ongeveer {eur} per kWh.",
|
||||
@@ -344,20 +344,15 @@
|
||||
},
|
||||
"tomorrow_trade": {"grid": "Morgen: laadt om {at}, {eur} per kWh.", "grid_would": "Morgen: zou laden om {at}, {eur} per kWh.", "give": "Morgen: levert stroom om {at}, {eur} per kWh.", "give_would": "Morgen: zou stroom leveren om {at}, {eur} per kWh."},
|
||||
"cycle_waits": "{name} wacht zijn beurt af voor de maandelijkse cyclus.",
|
||||
"no_prices_yet": "Nog geen prijzen, onderhoud wacht.",
|
||||
"no_prices_since": "Geen prijzen sinds {at}, onderhoud wacht.",
|
||||
"no_prices_since_day": "Geen prijzen sinds {day} {month} om {at}, onderhoud wacht.",
|
||||
"step_in": "De batterijen springen bij boven {kw}.",
|
||||
"step_in_would": "De batterijen zouden bijspringen boven {kw}.",
|
||||
"period": "Dit kwartier gaat richting {kw}.",
|
||||
"period_unmeasured": "Een deel van dit kwartier is niet gemeten.",
|
||||
"prices": {"now": "Prijs nu {eur} per kWh, alles inbegrepen.", "today": "Vandaag: laagste {lo} om {lo_at}, hoogste {hi} om {hi_at}.", "none": "Geen prijzen voor vandaag."},
|
||||
"tomorrow": {"known": "De prijzen van morgen zijn binnen.", "unknown": "De prijzen van morgen komen meestal na 13:00."},
|
||||
"region": {"preset": "Heffingen en btw: {region}, cijfers van {year}.", "own": "Heffingen en btw: uw eigen bedrag."},
|
||||
"peak_not_covered": "Geen piekgrens: deze meter meet geen kwartieren.",
|
||||
"control": {"shadow": "Schaduwmodus: elke omvormer blijft op 0 W.", "running": "De regeling is actief.", "stopped": "De regeling is gestopt. Elke omvormer staat op 0 W."},
|
||||
"attn": {
|
||||
"home_assistant": "Home Assistant antwoordt niet",
|
||||
"controller": "Regeling is niet actief",
|
||||
"meter": "Meting van de meter",
|
||||
"meter_frozen": "Meter staat mogelijk vast",
|
||||
@@ -376,7 +371,6 @@
|
||||
"delivery": "Een omvormer levert niet",
|
||||
"estop": "Noodstop",
|
||||
"pv": "Meting van de zonnepanelen",
|
||||
"solar_forecast": "Zonneprognose",
|
||||
"none": "Verbinding met de omvormer"
|
||||
},
|
||||
"months": ["januari", "februari", "maart", "april", "mei", "juni", "juli", "augustus", "september", "oktober", "november", "december"]
|
||||
|
||||
@@ -135,10 +135,6 @@ GROUPS = (
|
||||
Field("pv_forecast_entity", ENTITY, kind="energy", simple=True,
|
||||
note="forecast"),
|
||||
Field("pv_forecast_tomorrow_entity", ENTITY, kind="energy"),
|
||||
# OPT-17: several forecast sensors per day, summed.
|
||||
Field("pv_forecast_entities", ENTITY_LIST, kind="energy"),
|
||||
Field("pv_forecast_tomorrow_entities", ENTITY_LIST,
|
||||
kind="energy"),
|
||||
Field("solar_trust_pct", SLIDER, "%", step=5),
|
||||
)),
|
||||
)),
|
||||
@@ -164,13 +160,13 @@ GROUPS = (
|
||||
wizard=4),
|
||||
Field("price_export_formula", TEXT),
|
||||
Field("ui.fallback", SEGMENTED, show=_DYNAMIC, wizard=4),
|
||||
Field("ui.fixed_price", NUMBER, "€/kWh", step=0.00001,
|
||||
Field("ui.fixed_price", NUMBER, "€/kWh", step=0.01,
|
||||
show=(("ui.fallback", "==", "fixed"),), wizard=4),
|
||||
Field("ui.day_price", NUMBER, "€/kWh", step=0.00001,
|
||||
Field("ui.day_price", NUMBER, "€/kWh", step=0.01,
|
||||
show=(("ui.fallback", "==", "daynight"),), wizard=4),
|
||||
Field("ui.night_price", NUMBER, "€/kWh", step=0.00001,
|
||||
Field("ui.night_price", NUMBER, "€/kWh", step=0.01,
|
||||
show=(("ui.fallback", "==", "daynight"),), wizard=4),
|
||||
Field("ui.periods", PERIOD_LIST, "€/kWh", step=0.00001,
|
||||
Field("ui.periods", PERIOD_LIST, "€/kWh", step=0.01,
|
||||
show=(("ui.fallback", "==", "custom"),), wizard=4),
|
||||
)),
|
||||
Part("selling", (
|
||||
@@ -194,7 +190,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", wizard=5),
|
||||
Field("peak_forecast_entity", ENTITY, kind="power_w", wizard=5),
|
||||
Field("peak_soc_floor", LOCKED, "%"),
|
||||
)),
|
||||
Part("start", (
|
||||
@@ -297,9 +293,7 @@ DERIVED = {
|
||||
"price_taxes_profile": "ui.region", # a preset writes it, "own" keeps it
|
||||
}
|
||||
# Option keys the page never shows and never writes: passed through as stored.
|
||||
# PRICE-01: the price service token is a secret. It is set in the Configuration
|
||||
# tab (a password field there) and this page never draws it.
|
||||
HIDDEN: dict = {"price_service_token": "a secret, set on the Configuration tab"}
|
||||
HIDDEN: dict = {}
|
||||
|
||||
|
||||
def fields():
|
||||
|
||||
@@ -12,17 +12,14 @@ import copy
|
||||
import os
|
||||
import re
|
||||
|
||||
from . import messages
|
||||
from . import uilayout as L
|
||||
from . import uitext
|
||||
from . import uimodel as U
|
||||
from .configui import help_of, label_of, problems_by_field, split_group
|
||||
from .validate import note_text, power_request_entities
|
||||
from .validate import power_request_entities
|
||||
|
||||
# A choice the page cannot make, only keep. `price_service` needs a token, and
|
||||
# the token is a secret this page never draws (uilayout.HIDDEN), so it is set on
|
||||
# the Configuration tab and the page only keeps it (PRICE-01).
|
||||
ONLY_IF_HELD = ("price_service",)
|
||||
# A choice the page cannot make, only keep. None since P5's period editor.
|
||||
ONLY_IF_HELD = ()
|
||||
_TIERS = ("bad", "warn", "info")
|
||||
|
||||
|
||||
@@ -248,7 +245,7 @@ def derive_messages(errors, problems, notes, model, keys, lang="en"):
|
||||
kept = [p for p in problems if p in kept]
|
||||
notes = [n for n in notes if not set(n.keys or ()) & failed]
|
||||
return ([derive_message(err, lang) for err in errors]
|
||||
+ anchors(kept, notes, model, keys, lang))
|
||||
+ anchors(kept, notes, model, keys))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -293,10 +290,9 @@ def targets(key: str, text: str, model: dict) -> list:
|
||||
return [(None, None)]
|
||||
|
||||
|
||||
def anchors(problems, notes, model, keys, lang="en") -> list:
|
||||
def anchors(problems, notes, model, keys) -> list:
|
||||
"""[{field, card, tier, text}] for the refusals and notes. `field` is
|
||||
None for a sentence that names no setting on the page. A note with a Dutch
|
||||
twin in `messages.NOTE_NL` is shown in Dutch when `lang` is "nl"."""
|
||||
None for a sentence that names no setting on the page."""
|
||||
out, seen = [], set()
|
||||
|
||||
def add(field, card, tier, text):
|
||||
@@ -314,11 +310,10 @@ def anchors(problems, notes, model, keys, lang="en") -> list:
|
||||
add(None, None, "bad", text)
|
||||
for note in notes:
|
||||
tier = note.tier if note.tier in _TIERS else "info"
|
||||
text = note_text(note, lang)
|
||||
for key in note.keys or (None,):
|
||||
for field, card in (targets(key, note.text, model) if key
|
||||
else [(None, None)]):
|
||||
add(field, card, tier, text)
|
||||
add(field, card, tier, note.text)
|
||||
# A sentence placed on one inverter's field is not also put on the list.
|
||||
roster = _roster()
|
||||
placed = {m["text"] for m in out
|
||||
|
||||
@@ -386,23 +386,12 @@ class Note(NamedTuple):
|
||||
# also "ok" and "bad" (setup_checks)
|
||||
keys: tuple
|
||||
text: str
|
||||
nl: str = "" # UX-46: the same sentence in Dutch
|
||||
|
||||
|
||||
def _advise(notes, tier: str, keys, text: str, nl: str = "") -> None:
|
||||
"""Add a Note when the caller collects them (the settings page). `nl` is
|
||||
the Dutch twin of `text`; the page picks one (`note_text()`)."""
|
||||
def _advise(notes, tier: str, keys, text: str) -> None:
|
||||
"""Add a Note when the caller collects them (the settings page)."""
|
||||
if notes is not None:
|
||||
notes.append(Note(tier, tuple(keys), text, nl))
|
||||
|
||||
|
||||
def note_text(note, lang: str = "en") -> str:
|
||||
"""The note's sentence in the page's language, English when it has no
|
||||
Dutch twin."""
|
||||
if lang == "nl":
|
||||
return getattr(note, "nl", "") or messages.NOTE_NL.get(
|
||||
note.text, note.text)
|
||||
return note.text
|
||||
notes.append(Note(tier, tuple(keys), text))
|
||||
|
||||
|
||||
# ⚠️ A MIRROR OF A FIRMWARE CONSTANT, AND IT CANNOT BE IMPORTED. The firmware
|
||||
@@ -639,11 +628,6 @@ def S(key: str, where: str = "") -> str:
|
||||
return messages.setting(key, where.strip().strip("()"))
|
||||
|
||||
|
||||
def SN(key: str, where: str = "") -> str:
|
||||
"""`S()` with the Dutch label, for the Dutch twin of a note."""
|
||||
return messages.setting(key, where.strip().strip("()"), "nl")
|
||||
|
||||
|
||||
def _labels(keys, joiner: str = "and") -> str:
|
||||
"""Several settings, each with its key: the config panel files a refusal
|
||||
under every key it names (`configui.problems_by_field()`)."""
|
||||
@@ -1232,9 +1216,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
|
||||
integ_w, max_w)
|
||||
_advise(notes, "warn", ("integrator_max_w",),
|
||||
f"{S('integrator_max_w')} is above {S('max_w')}, which only "
|
||||
"adds overshoot, so set it to 0.",
|
||||
nl=f"{SN('integrator_max_w')} is hoger dan {SN('max_w')}, wat "
|
||||
"alleen doorschieten geeft, dus zet het op 0.")
|
||||
"adds overshoot, so set it to 0.")
|
||||
|
||||
# ⚠️ THE SIBLING OF RULE 6, AND NOT THE SAME CASE - see the docstring. The
|
||||
# drain phase OWNS the actuator (`Claim.set` at P_MAINTENANCE), and a SET is
|
||||
@@ -1262,10 +1244,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
|
||||
_advise(notes, "warn", ("maintenance_discharge_w",),
|
||||
f"{S('maintenance_discharge_w')} is above "
|
||||
f"{S('max_w', maint_where)}, so the battery empties at "
|
||||
f"{messages.watts(maint_cap)} and the cycle takes longer.",
|
||||
nl=f"{SN('maintenance_discharge_w')} is hoger dan "
|
||||
f"{SN('max_w', maint_where)}, dus de batterij loopt leeg met "
|
||||
f"{messages.watts(maint_cap)} en de cyclus duurt langer.")
|
||||
f"{messages.watts(maint_cap)} and the cycle takes longer.")
|
||||
_LOG.debug(
|
||||
"Not refused: the drain phase sets the setpoint, the arbiter "
|
||||
"clamps it to %.0f W and names the clamp, and the drain phase has "
|
||||
@@ -1494,9 +1473,7 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
|
||||
TAXES_PROFILE_OPTION, negative)
|
||||
_advise(notes, "warn", (TAXES_PROFILE_OPTION,),
|
||||
f"{S(TAXES_PROFILE_OPTION)} has negative figures, which "
|
||||
"lower the price, and that is usually a sign error.",
|
||||
nl=f"{SN(TAXES_PROFILE_OPTION)} heeft negatieve bedragen, "
|
||||
"die de prijs verlagen, en dat is meestal een tekenfout.")
|
||||
"lower the price, and that is usually a sign error.")
|
||||
|
||||
# ⚠️ TARIFF-15: THE FORMULA, GRADED AGAINST THE OPERATOR'S OWN CARD, AT
|
||||
# STARTUP RATHER THAN AT 03:00.
|
||||
@@ -1555,10 +1532,7 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
|
||||
_advise(notes, "warn", ("price_formula", "price_provider"),
|
||||
f"{S('price_formula')} is not used, because "
|
||||
f"{S('price_provider')} is not 'custom_formula', so clear "
|
||||
"it or choose that source.",
|
||||
nl=f"{SN('price_formula')} wordt niet gebruikt, omdat "
|
||||
f"{SN('price_provider')} niet 'custom_formula' is, dus wis "
|
||||
"ze of kies die bron.")
|
||||
"it or choose that source.")
|
||||
if spec is not None and pid == "custom_formula":
|
||||
taxes = _number(opts, "price_taxes_eur_kwh",
|
||||
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
|
||||
@@ -1573,8 +1547,7 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
|
||||
# cannot start.
|
||||
_LOG.warning("%s", aged)
|
||||
_advise(notes, "warn", ("price_formula",),
|
||||
_card_note("price_formula", spec),
|
||||
nl=_card_note("price_formula", spec, "nl"))
|
||||
_card_note("price_formula", spec))
|
||||
|
||||
# ⚠️ TARIFF-16: THE INJECTION LEG, REFUSED AT STARTUP ON THE SAME RULE.
|
||||
#
|
||||
@@ -1657,10 +1630,7 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
|
||||
xspec.offset_eur_kwh)
|
||||
_advise(notes, "info", (EXPORT_FORMULA_OPTION,),
|
||||
f"{S(EXPORT_FORMULA_OPTION)} has no card block, so "
|
||||
"only the shape of its numbers is checked.",
|
||||
nl=f"{SN(EXPORT_FORMULA_OPTION)} heeft geen kaartblok, "
|
||||
"dus alleen de vorm van de getallen wordt "
|
||||
"gecontroleerd.")
|
||||
"only the shape of its numbers is checked.")
|
||||
_LOG.debug(
|
||||
"Accepted because some suppliers (Eneco) print the "
|
||||
"injection formula with no price beside it. If your card "
|
||||
@@ -1673,8 +1643,7 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
|
||||
if xaged:
|
||||
_LOG.warning("%s", xaged)
|
||||
_advise(notes, "warn", (EXPORT_FORMULA_OPTION,),
|
||||
_card_note(EXPORT_FORMULA_OPTION, xspec),
|
||||
nl=_card_note(EXPORT_FORMULA_OPTION, xspec, "nl"))
|
||||
_card_note(EXPORT_FORMULA_OPTION, xspec))
|
||||
|
||||
# ⚠️ A WARNING, NOT A REFUSAL, AND THE ASYMMETRY IS DELIBERATE. §11.4 says a
|
||||
# site with no price data falls back to the static profile; a dynamic site
|
||||
@@ -1688,16 +1657,7 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
|
||||
"fallback.", pid)
|
||||
_advise(notes, "warn", ("static_profile",),
|
||||
f"{S('static_profile')} is empty, so the site has no tariff "
|
||||
"at all while the price feed is down.",
|
||||
nl=f"{SN('static_profile')} is leeg, dus de installatie heeft "
|
||||
"helemaal geen tarief zolang de prijsfeed uitvalt.")
|
||||
|
||||
# PRICE-01: not a fault, so an info note and no log line here (the add-on
|
||||
# logs it once at start). With `price_service` the fixed tariff is only the
|
||||
# plan's last fallback; the cheap window and the gate do not read it.
|
||||
if pid == "price_service" and str(opts.get("static_profile", "") or "").strip():
|
||||
_advise(notes, "info", ("static_profile",), messages.FIXED_TARIFF_NOTE,
|
||||
nl=messages.FIXED_TARIFF_NOTE_NL)
|
||||
"at all while the price feed is down.")
|
||||
|
||||
# ⚠️ OPT-04. THE THREE WAYS `arbitrage_enabled: true` CAN BE A LIE, refused
|
||||
# rather than logged, because a strategy that is armed in the UI and cannot
|
||||
@@ -1746,45 +1706,20 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
|
||||
f"{S('price_csv_path')} is empty. Set the path to the CSV file, or "
|
||||
"choose 'off'.")
|
||||
|
||||
# PRICE-01. The price service answers only to its token. Without one every
|
||||
# fetch is refused and the add-on lives on its fallbacks, which the
|
||||
# operator would not see: a source they chose, overridden by a layer they
|
||||
# cannot see. A token the server cannot read as a header is the same case.
|
||||
if pid == "price_service":
|
||||
token = str(opts.get("price_service_token", "") or "").strip()
|
||||
if not token:
|
||||
problems.append(
|
||||
f"{S('price_provider')} is 'price_service', but "
|
||||
f"{S('price_service_token')} is empty. Set the token, or "
|
||||
"choose another source.")
|
||||
elif any(c.isspace() or not c.isprintable() for c in token):
|
||||
problems.append(
|
||||
f"{S('price_service_token')} has a space or a line break in "
|
||||
"it. Paste the token again, with nothing around it.")
|
||||
elif len(token) < PRICE_TOKEN_MIN:
|
||||
problems.append(
|
||||
f"{S('price_service_token')} is too short. Paste the whole "
|
||||
"token.")
|
||||
|
||||
return problems
|
||||
|
||||
|
||||
def _card_note(option: str, spec, lang: str = "en") -> str:
|
||||
if lang == "nl":
|
||||
return (f"{SN(option)} gebruikt een tariefkaart van "
|
||||
f"{spec.card_month}, dus neem de nieuwe cijfers over als uw "
|
||||
"contract veranderde.")
|
||||
def _card_note(option: str, spec) -> str:
|
||||
return (f"{S(option)} uses a tariff card dated {spec.card_month}, so copy "
|
||||
"the new figures if your contract changed.")
|
||||
|
||||
|
||||
# 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,
|
||||
# and SAFETY-118 moved the peak forecast there too.
|
||||
# conversion. pv.py converts each solar entity's own unit, so those are not here.
|
||||
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")
|
||||
"p1_phase_export_entities", "batt_entity", "peak_forecast_entity")
|
||||
|
||||
|
||||
# The meter entities each `meter_source` reads through Home Assistant.
|
||||
@@ -1827,11 +1762,6 @@ def _entities_read(opts: dict) -> list[tuple[str, str, str]]:
|
||||
return out
|
||||
|
||||
|
||||
# PRICE-01 F5. A shorter value is not a token; it also could not be scrubbed
|
||||
# from a log (price.scrub skips values under 4 characters).
|
||||
PRICE_TOKEN_MIN = 16
|
||||
|
||||
|
||||
def config_notes(opts: dict, *, entities: dict | None = None,
|
||||
month_peak_w=None) -> list[Note]:
|
||||
"""UX-13: warnings that need live data, for the settings page. Never a
|
||||
@@ -1848,10 +1778,7 @@ def config_notes(opts: dict, *, entities: dict | None = None,
|
||||
if month_peak_w is not None and cap is not None and cap < month_peak_w:
|
||||
_advise(notes, "warn", ("peak_cap_w",),
|
||||
f"This month's peak is already {messages.watts(month_peak_w)}, "
|
||||
f"so a lower {S('peak_cap_w')} saves nothing until next month.",
|
||||
nl=f"De piek van deze maand is al "
|
||||
f"{messages.watts(month_peak_w)}, dus een lagere "
|
||||
f"{SN('peak_cap_w')} bespaart niets tot volgende maand.")
|
||||
f"so a lower {S('peak_cap_w')} saves nothing until next month.")
|
||||
return notes
|
||||
|
||||
|
||||
@@ -1864,43 +1791,26 @@ def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
|
||||
if row is None:
|
||||
_advise(notes, "bad" if key in bad else "warn", (key,),
|
||||
f"Home Assistant has no entity {entity}, which "
|
||||
f"{S(key, where)} names.",
|
||||
nl=f"Home Assistant kent {entity} niet, maar "
|
||||
f"{SN(key, where)} verwijst ernaar.")
|
||||
f"{S(key, where)} names.")
|
||||
continue
|
||||
unit = str(row.get("unit") or "")
|
||||
if key in WATT_ENTITY_KEYS and unit in pv.WATTS_PER and unit != "W":
|
||||
_advise(notes, "warn", (key,),
|
||||
f"{entity} reads in {unit}, but {S(key, where)} is "
|
||||
"taken as watts, so pick a sensor in W.",
|
||||
nl=f"{entity} meldt in {unit}, maar {SN(key, where)} wordt "
|
||||
"als watt gelezen, dus kies een 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.",
|
||||
nl=messages.peak_forecast_unit(unit, "nl")
|
||||
+ " Kies een sensor in W of kW.")
|
||||
"taken as watts, so pick a sensor in W.")
|
||||
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 "
|
||||
"forecast sensor in kWh.",
|
||||
nl=f"{entity} meet niet in kWh, dus kies een sensor voor "
|
||||
"de zonneprognose in kWh.")
|
||||
today = solarfc.entities(opts)
|
||||
tomorrow = solarfc.entities(opts, tomorrow=True)
|
||||
if (today or tomorrow) and \
|
||||
"forecast sensor in kWh.")
|
||||
if any(opts.get(k) for k in FORECAST_KEYS) and \
|
||||
opts.get("battery_modes_enabled") is not True:
|
||||
_advise(notes, "info", ("pv_forecast_entity",),
|
||||
"The solar forecast is used only with battery modes on.",
|
||||
nl="De zonneprognose wordt alleen gebruikt met batterijmodi "
|
||||
"aan.")
|
||||
if tomorrow and not today:
|
||||
"The solar forecast is used only with battery modes on.")
|
||||
if opts.get("pv_forecast_tomorrow_entity") and \
|
||||
not opts.get("pv_forecast_entity"):
|
||||
_advise(notes, "warn", ("pv_forecast_tomorrow_entity",),
|
||||
"Tomorrow's forecast is used only with today's, so set "
|
||||
f"{S('pv_forecast_entity')} too.",
|
||||
nl="De prognose voor morgen wordt alleen met die van vandaag "
|
||||
f"gebruikt, dus stel ook {SN('pv_forecast_entity')} in.")
|
||||
f"{S('pv_forecast_entity')} too.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -1979,9 +1889,7 @@ def setup_checks(opts: dict, *, entities: dict | None, running=None,
|
||||
if entities is None:
|
||||
# Red: only the list can tell a missing power request entity (SAFETY-77).
|
||||
_advise(notes, "bad", ("setpoint_entity",), "Home Assistant is not "
|
||||
"answering, so press Check again once it is back.",
|
||||
nl="Home Assistant antwoordt niet, dus druk op Opnieuw "
|
||||
"nakijken zodra het terug is.")
|
||||
"answering, so press Check again once it is back.")
|
||||
else:
|
||||
# A missing meter entity is red too (UX-15): with the kettle test
|
||||
# skipped, nothing else on the way to the save would catch it.
|
||||
@@ -2003,16 +1911,13 @@ def setup_checks(opts: dict, *, entities: dict | None, running=None,
|
||||
solar.append(None)
|
||||
_advise(notes, "warn", ("pv_invert_entities",),
|
||||
f"{entity} reads below zero now, so turn on its "
|
||||
"“Reads negative” switch in the Solar step.",
|
||||
nl=f"{entity} meet nu onder nul, dus zet de schakelaar "
|
||||
"“Meet negatief” aan bij de stap Zonnepanelen.")
|
||||
"“Reads negative” switch in the Solar step.")
|
||||
else:
|
||||
w = -w if entity in flipped else w
|
||||
solar.append(w)
|
||||
_advise(notes, "ok", ("pv_entity",),
|
||||
f"{entity} reads {_signed(w)} of solar production "
|
||||
"now.",
|
||||
nl=f"{entity} meet nu {_signed(w)} zonneproductie.")
|
||||
"now.")
|
||||
# A battery sensor that reads the wrong way while giving power makes the
|
||||
# house use less than nothing. Only with every term known: a missing
|
||||
# solar reading could be the difference.
|
||||
@@ -2022,10 +1927,7 @@ def setup_checks(opts: dict, *, entities: dict | None, running=None,
|
||||
_advise(notes, "warn", ("batt_invert",),
|
||||
f"The readings add up to {_signed(sum(known))} of house "
|
||||
"use, so check each "
|
||||
"battery's “Battery power sign is reversed” switch in step 2.",
|
||||
nl=f"De metingen tellen op tot {_signed(sum(known))} "
|
||||
"huisverbruik, dus controleer bij elke batterij de schakelaar "
|
||||
"“Teken batterijvermogen omkeren” in stap 2.")
|
||||
"battery's “Battery power sign is reversed” switch in step 2.")
|
||||
_price_check(notes, opts, running, prices_known)
|
||||
return notes
|
||||
|
||||
@@ -2049,9 +1951,7 @@ def _meter_check(notes, opts, entities, running, meter_now_w):
|
||||
return float(meter_now_w)
|
||||
_advise(notes, "info", ("meter_source",), "The add-on reads "
|
||||
"this meter itself, so Home shows its reading after "
|
||||
"the restart.",
|
||||
nl="De add-on leest deze meter zelf, dus Start toont de "
|
||||
"meting na de herstart.")
|
||||
"the restart.")
|
||||
return None
|
||||
if entities is None:
|
||||
return None
|
||||
@@ -2064,9 +1964,7 @@ def _meter_check(notes, opts, entities, running, meter_now_w):
|
||||
if w is None:
|
||||
_advise(notes, "warn", ("meter_source",),
|
||||
f"{entity} reads {text} now, so check it in Home "
|
||||
"Assistant before you start.",
|
||||
nl=f"{entity} meldt nu {text}, dus controleer ze in Home "
|
||||
"Assistant voor u start.")
|
||||
"Assistant before you start.")
|
||||
return None
|
||||
total += sign * w
|
||||
if source == "off" and opts.get("meter_invert"):
|
||||
@@ -2077,14 +1975,12 @@ def _meter_check(notes, opts, entities, running, meter_now_w):
|
||||
|
||||
def _meter_line(notes, w) -> None:
|
||||
if abs(w) < 1.0:
|
||||
text, nl = "The meter reads 0 W now.", "De meter meldt nu 0 W."
|
||||
text = "The meter reads 0 W now."
|
||||
elif w > 0:
|
||||
text = f"The meter reads {messages.watts(w)} taken from the grid now."
|
||||
nl = f"De meter meldt nu {messages.watts(w)} afname van het net."
|
||||
else:
|
||||
text = f"The meter reads {messages.watts(w)} fed into the grid now."
|
||||
nl = f"De meter meldt nu {messages.watts(w)} injectie op het net."
|
||||
_advise(notes, "ok", ("meter_source",), text, nl=nl)
|
||||
_advise(notes, "ok", ("meter_source",), text)
|
||||
|
||||
|
||||
def _card_checks(notes, who, entry, entities):
|
||||
@@ -2092,55 +1988,40 @@ def _card_checks(notes, who, entry, entities):
|
||||
positive while giving power, or None when it is not known."""
|
||||
if entities is None:
|
||||
return None
|
||||
wie = who.replace(" for ", " voor ", 1)
|
||||
setpoint = str(entry.get("setpoint_entity") or "").strip()
|
||||
if setpoint and setpoint in entities:
|
||||
_advise(notes, "ok", ("setpoint_entity",),
|
||||
f"Home Assistant has {setpoint}, the power request "
|
||||
f"entity{who}.",
|
||||
nl=f"Home Assistant heeft {setpoint}, de entiteit voor de "
|
||||
f"vermogensvraag{wie}.")
|
||||
f"entity{who}.")
|
||||
soc = str(entry.get("soc_entity") or "").strip()
|
||||
if not soc:
|
||||
_advise(notes, "warn", ("soc_entity",), f"There is no battery "
|
||||
f"level entity{who}, so pick one in step 2 or the add-on "
|
||||
"holds 0 W.",
|
||||
nl=f"Er is geen entiteit voor het laadniveau{wie}, dus kies er "
|
||||
"een in stap 2 of de add-on houdt 0 W aan.")
|
||||
"holds 0 W.")
|
||||
elif soc in entities:
|
||||
level, _unit = _reading(entities, soc)
|
||||
if level is None or not 0.0 <= level <= 100.0:
|
||||
_advise(notes, "warn", ("soc_entity",), f"The battery "
|
||||
f"level{who} reads nothing now, so check {soc}.",
|
||||
nl=f"Het laadniveau{wie} meldt niets, dus controleer "
|
||||
f"{soc}.")
|
||||
f"level{who} reads nothing now, so check {soc}.")
|
||||
else:
|
||||
_advise(notes, "ok", ("soc_entity",),
|
||||
f"The battery level{who} reads {level:.0f} %.",
|
||||
nl=f"Het laadniveau{wie} meldt {level:.0f} %.")
|
||||
f"The battery level{who} reads {level:.0f} %.")
|
||||
batt = str(entry.get("batt_entity") or "").strip()
|
||||
if batt and batt in entities:
|
||||
w, _unit = _reading(entities, batt)
|
||||
if w is None:
|
||||
_advise(notes, "warn", ("batt_entity",), f"The battery "
|
||||
f"power{who} reads nothing now, so check {batt}.",
|
||||
nl=f"Het batterijvermogen{wie} meldt niets, dus controleer "
|
||||
f"{batt}.")
|
||||
f"power{who} reads nothing now, so check {batt}.")
|
||||
return None
|
||||
w = -w if entry.get("batt_invert") else w
|
||||
if abs(w) < IDLE_W:
|
||||
_advise(notes, "info", ("batt_entity",), f"The battery{who} is "
|
||||
"idle now, so check its direction on Home later.",
|
||||
nl=f"De batterij{wie} staat nu stil, dus controleer later "
|
||||
"op Start de richting.")
|
||||
"idle now, so check its direction on Home later.")
|
||||
else:
|
||||
doing = "giving power" if w > 0 else "charging"
|
||||
doet = "levert" if w > 0 else "laadt"
|
||||
_advise(notes, "info", ("batt_invert",),
|
||||
f"The battery{who} reads {messages.watts(w)} {doing} "
|
||||
"now, so check the inverter shows the same.",
|
||||
nl=f"De batterij{wie} {doet} nu {messages.watts(w)}, dus "
|
||||
"controleer of de omvormer hetzelfde toont.")
|
||||
"now, so check the inverter shows the same.")
|
||||
return w
|
||||
return None
|
||||
|
||||
@@ -2152,28 +2033,20 @@ def _price_check(notes, opts, running, prices_known) -> None:
|
||||
if profile:
|
||||
_advise(notes, "ok", ("static_profile",),
|
||||
"Your prices come from your contract, so they are "
|
||||
"always known.",
|
||||
nl="Uw prijzen komen uit uw contract, dus ze zijn altijd "
|
||||
"bekend.")
|
||||
"always known.")
|
||||
else:
|
||||
_advise(notes, "info", ("price_provider",),
|
||||
"No prices are set, so the batteries do not charge "
|
||||
"on price.",
|
||||
nl="Er zijn geen prijzen ingesteld, dus de batterijen "
|
||||
"laden niet op prijs.")
|
||||
"on price.")
|
||||
return
|
||||
same = (isinstance(running, dict)
|
||||
and str(running.get("price_provider") or "") == provider)
|
||||
if same and prices_known:
|
||||
_advise(notes, "ok", ("price_provider",),
|
||||
f"Prices for today are in, for {prices_known} of 24 hours.",
|
||||
nl=f"De prijzen van vandaag zijn binnen, voor {prices_known} "
|
||||
"van 24 uur.")
|
||||
f"Prices for today are in, for {prices_known} of 24 hours.")
|
||||
else:
|
||||
_advise(notes, "info", ("price_provider",),
|
||||
"Prices are fetched after the restart, and Home shows them.",
|
||||
nl="De prijzen worden na de herstart opgehaald, en Start toont "
|
||||
"ze.")
|
||||
"Prices are fetched after the restart, and Home shows them.")
|
||||
|
||||
|
||||
def _margin_problems(opts: dict) -> list[str]:
|
||||
@@ -2196,13 +2069,12 @@ def _margin_problems(opts: dict) -> list[str]:
|
||||
TRUST_RANGE_PCT = (30, 100)
|
||||
# A Home Assistant sensor id; it goes into the /api/states/<id> path.
|
||||
_SENSOR_ID = re.compile(r"sensor\.[a-z0-9_]+")
|
||||
FORECAST_KEYS = solarfc.TODAY_KEYS + solarfc.TOMORROW_KEYS
|
||||
FORECAST_KEYS = ("pv_forecast_entity", "pv_forecast_tomorrow_entity")
|
||||
|
||||
|
||||
def _forecast_problems(opts: dict) -> list[str]:
|
||||
"""Rule 13 (OPT-11 P4): the trust is a whole percentage in range, and
|
||||
each forecast entity set is a sensor. OPT-17: a day names its forecast
|
||||
in the single key or in the list, not both."""
|
||||
each forecast entity set is a sensor."""
|
||||
out = []
|
||||
raw = opts.get("solar_trust_pct")
|
||||
trust = _number(opts, "solar_trust_pct", dayplan.DEFAULT_TRUST_PCT)
|
||||
@@ -2210,30 +2082,14 @@ def _forecast_problems(opts: dict) -> list[str]:
|
||||
if trust is None or trust != int(trust) or not lo <= trust <= hi:
|
||||
out.append(f"{S('solar_trust_pct')} must be a whole number from {lo} "
|
||||
f"to {hi} %. It is {messages.shown(raw)}.")
|
||||
for one, many in (solarfc.TODAY_KEYS, solarfc.TOMORROW_KEYS):
|
||||
e = opts.get(one)
|
||||
if e is not None and e != "" and (
|
||||
not isinstance(e, str) or not _SENSOR_ID.fullmatch(e)):
|
||||
out.append(f"{S(one)} must be a sensor, like "
|
||||
for key in FORECAST_KEYS:
|
||||
e = opts.get(key)
|
||||
if e is None or e == "":
|
||||
continue
|
||||
if not isinstance(e, str) or not _SENSOR_ID.fullmatch(e):
|
||||
out.append(f"{S(key)} must be a sensor, like "
|
||||
f"sensor.energy_production_today_remaining. It is "
|
||||
f"{messages.shown(e)}.")
|
||||
raw = opts.get(many)
|
||||
if raw is None:
|
||||
raw = []
|
||||
if not isinstance(raw, list):
|
||||
out.append(f"{S(many)} must be a list of sensors. It is "
|
||||
f"{messages.shown(raw)}.")
|
||||
continue
|
||||
listed = [x for x in raw if x is not None
|
||||
and not (isinstance(x, str) and not x.strip())]
|
||||
for x in listed:
|
||||
if not isinstance(x, str) or not _SENSOR_ID.fullmatch(x):
|
||||
out.append(f"{S(many)} must list only sensors, like "
|
||||
f"sensor.energy_production_today_remaining. "
|
||||
f"It has {messages.shown(x)}.")
|
||||
if e and listed:
|
||||
out.append(f"Set {S(one)} or {S(many)}, not both. Keep the "
|
||||
f"list and clear the single one.")
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -48,21 +48,18 @@ from datetime import datetime, timezone
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import messages, price, selfconfig, uipage, uitext
|
||||
from . import messages, selfconfig, uipage, uitext
|
||||
from .haentities import EntityCache
|
||||
from .homeview import priced_hours, region_of
|
||||
from .validate import config_notes, note_text, setup_checks
|
||||
from .homeview import 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,
|
||||
problems_by_field, secret_keys)
|
||||
from .configui import changed_keys, field_model, problems_by_field
|
||||
from .uimodel import DeriveError, options_to_ui
|
||||
|
||||
_LOG = logging.getLogger("goodwe.web")
|
||||
|
||||
# OPT-11: the per-battery actions, which name a battery and a value.
|
||||
PACK_ACTIONS = ("pack_use", "pack_mode", "pack_sell")
|
||||
# MAINT-11: start or stop one battery's maintenance cycle (the unit is optional).
|
||||
MAINT_ACTIONS = ("maint_start", "maint_abort")
|
||||
|
||||
# UX-13 P1: the page lives in app/static/ as three files - index.html,
|
||||
# app.css, app.js - plus the page words from app/ui_text/<lang>.json (English
|
||||
@@ -105,9 +102,6 @@ def page() -> str:
|
||||
DEVICE_NOTE = (
|
||||
"%s did not report its power range, so two checks were skipped, here and "
|
||||
"at start. If they fail later, the add-on will not start then.")
|
||||
DEVICE_NOTE_NL = (
|
||||
"%s meldde geen vermogensbereik, dus twee controles zijn overgeslagen, "
|
||||
"hier en bij het starten. Falen ze later, dan start de add-on niet.")
|
||||
|
||||
|
||||
def build_app(controller, selfcfg=None) -> web.Application:
|
||||
@@ -169,14 +163,6 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
return web.json_response({"ok": False,
|
||||
"error": result["error"]},
|
||||
status=400)
|
||||
elif what in MAINT_ACTIONS and body.get("unit") is not None:
|
||||
# MAINT-11: one battery's cycle. No unit = the old bare call below.
|
||||
result = await controller.handle_action(what, unit=body["unit"])
|
||||
if isinstance(result, dict) and "error" in result:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": result["error"],
|
||||
"code": result["code"], "unit": result["unit"]},
|
||||
status=409 if result["code"] != "unit" else 400)
|
||||
else:
|
||||
result = await controller.handle_action(what)
|
||||
if result is None:
|
||||
@@ -213,11 +199,6 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
raise web.HTTPBadRequest(text="expected an options object")
|
||||
return body, options, None
|
||||
|
||||
def _secrets(info):
|
||||
# PRICE-01 F1: every `password` option of the schema, and the price
|
||||
# token by name in case a schema is missing.
|
||||
return secret_keys((info or {}).get("schema"), price.SECRET_OPTIONS)
|
||||
|
||||
async def config_model(request):
|
||||
"""Everything the page needs to draw itself, from ONE Supervisor call.
|
||||
|
||||
@@ -236,14 +217,13 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
_LOG.error("cannot read the add-on's own options: %s", err)
|
||||
return web.json_response({"error": str(err)}, status=502)
|
||||
options = info.get("options") or {}
|
||||
secrets = _secrets(info)
|
||||
lang = str(request.query.get("lang", "en") or "en")[:8]
|
||||
model = field_model(info.get("schema") or [],
|
||||
info.get("translations") or {}, lang)
|
||||
model.update({
|
||||
"options": hide_secrets(options, secrets),
|
||||
"options": options,
|
||||
"fingerprint": hand_out(options),
|
||||
"running": hide_secrets(controller.o, secrets),
|
||||
"running": controller.o,
|
||||
"running_differs": changed_keys(controller.o, options),
|
||||
"lang": lang,
|
||||
# UX-13 P1: the levies preset and its year, for the P2 Settings
|
||||
@@ -252,7 +232,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
# UX-13 P2: the Installer view draws from these two.
|
||||
"layout": uipage.layout(info.get("schema") or [],
|
||||
info.get("translations") or {}, lang),
|
||||
"ui": hide_secrets(options_to_ui(options), secrets),
|
||||
"ui": options_to_ui(options),
|
||||
# UX-13 P4: the setup wizard, and why it opens by itself.
|
||||
"setup": uipage.setup_spec(),
|
||||
"setup_needed": uipage.setup_needed(
|
||||
@@ -291,12 +271,6 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
model, options = None, _body.get("options")
|
||||
if not isinstance(options, dict):
|
||||
raise web.HTTPBadRequest(text="expected an options object")
|
||||
try:
|
||||
info_now = await selfcfg.info()
|
||||
options = keep_secrets(options, info_now.get("options"),
|
||||
_secrets(info_now))
|
||||
except Exception: # noqa: BLE001 - a check never fails for this
|
||||
pass
|
||||
notes = []
|
||||
problems, unread = await selfconfig.problems_and_ranges(
|
||||
options, controller.hass, notes=notes)
|
||||
@@ -304,27 +278,21 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
# UX-13: warnings and info beside the refusals. Never block a save.
|
||||
# The entity checks use the picker's cached list and never wait on HA.
|
||||
notes += config_notes(options, entities=entities.peek())
|
||||
# UX-46: one language choice for everything below that has words.
|
||||
lang = uitext.lang_of(_body.get("lang"))
|
||||
payload = {"problems": problems, "by_key": by_key,
|
||||
"unanchored": unanchored,
|
||||
"notes": [n._asdict() for n in notes]}
|
||||
if unread:
|
||||
payload["device_note"] = (DEVICE_NOTE_NL if lang == "nl"
|
||||
else DEVICE_NOTE) % ", ".join(
|
||||
sorted(set(unread)))
|
||||
payload["device_note"] = DEVICE_NOTE % ", ".join(sorted(set(unread)))
|
||||
if model is not None:
|
||||
payload["messages"] = (
|
||||
uipage.derive_messages(errors, problems, notes, model,
|
||||
_keys_of(options), lang)
|
||||
_keys_of(options),
|
||||
uitext.lang_of(_body.get("lang")))
|
||||
if errors
|
||||
else uipage.anchors(problems, notes, model, _keys_of(options),
|
||||
lang))
|
||||
else uipage.anchors(problems, notes, model, _keys_of(options)))
|
||||
payload["steps"] = uipage.step_problems(payload["messages"], model)
|
||||
if _body.get("setup"):
|
||||
payload["setup"] = [
|
||||
dict(n._asdict(), text=note_text(n, lang))
|
||||
for n in await _setup_checks(options)]
|
||||
payload["setup"] = [n._asdict() for n in await _setup_checks(options)]
|
||||
return web.json_response(payload)
|
||||
|
||||
async def _setup_checks(options):
|
||||
@@ -336,7 +304,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
try:
|
||||
prices = (controller.status_payload().get("plan") or {}).get(
|
||||
"prices") or []
|
||||
known = priced_hours(prices)
|
||||
known = sum(1 for p in prices if p.get("eur_kwh") is not None)
|
||||
except Exception: # noqa: BLE001 - a display figure, never a failure
|
||||
known = None
|
||||
return setup_checks(options, entities=rows, running=controller.o,
|
||||
@@ -361,12 +329,9 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
except ValueError:
|
||||
raise web.HTTPBadRequest(text="expected JSON")
|
||||
stored_now = None
|
||||
secrets = frozenset(price.SECRET_OPTIONS)
|
||||
if isinstance(posted, dict):
|
||||
if isinstance(posted, dict) and isinstance(posted.get("model"), dict):
|
||||
try:
|
||||
info_now = await selfcfg.info()
|
||||
stored_now = info_now.get("options") or {}
|
||||
secrets = _secrets(info_now)
|
||||
stored_now = (await selfcfg.info()).get("options") or {}
|
||||
except selfconfig.SupervisorUnavailable as err:
|
||||
_LOG.error("saving options failed: %s", err)
|
||||
return web.json_response({"error": str(err)}, status=502)
|
||||
@@ -382,8 +347,6 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
"unanchored": [], "messages": messages_,
|
||||
"steps": uipage.step_problems(messages_, model)},
|
||||
status=422)
|
||||
# PRICE-01 F1: a secret the page did not change is the stored one.
|
||||
options = keep_secrets(options, stored_now, secrets)
|
||||
expect = str(body.get("fingerprint") or "")
|
||||
try:
|
||||
result = await selfconfig.save(selfcfg, options, expect,
|
||||
@@ -401,7 +364,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
"moved": refused.moved,
|
||||
"changed_fields": (uipage.fields_of(moved, model)
|
||||
if model is not None else []),
|
||||
"stored": hide_secrets(stored, secrets),
|
||||
"stored": stored,
|
||||
# The page needs the CURRENT token to be able to save
|
||||
# again after the operator has read the difference. That
|
||||
# is not a force door: it takes a second, deliberate press
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.19.0"
|
||||
version: "0.13.0"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
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
|
||||
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
|
||||
maintenance cycle.
|
||||
url: https://gittea.kammenstraatha.duckdns.org/admin/goodwe-addon
|
||||
arch:
|
||||
@@ -157,11 +157,6 @@ options:
|
||||
# integrations whose sensors fit.
|
||||
pv_forecast_entity: ""
|
||||
pv_forecast_tomorrow_entity: ""
|
||||
# OPT-17: several forecast sensors per day (one per inverter or string),
|
||||
# summed. Use the list OR the single key above, not both. `[]` is
|
||||
# load-bearing, as for `pv_entities`.
|
||||
pv_forecast_entities: []
|
||||
pv_forecast_tomorrow_entities: []
|
||||
solar_trust_pct: 70
|
||||
|
||||
# TEL-11 - THE SITE'S PV PRODUCTION, IN WATTS, AND IT IS READ BY NOTHING THAT
|
||||
@@ -556,10 +551,6 @@ options:
|
||||
# naming that consequence on every fetch that uses an override; clearing this
|
||||
# option is what restores the full horizon.
|
||||
price_feed_url: ""
|
||||
# PRICE-01. The bearer token for the owner's price server
|
||||
# (`price_provider: price_service`). A password field. Empty with any other
|
||||
# source. Never printed: not in the log, the status page or a notification.
|
||||
price_service_token: ""
|
||||
price_horizon_h: 48
|
||||
price_timeout_s: 10
|
||||
|
||||
@@ -639,8 +630,7 @@ options:
|
||||
# contradiction. Changing one of these numbers now moves every consumer.
|
||||
maintenance_enabled: false
|
||||
maintenance_interval_days: 28
|
||||
# MAINT-10: a LOCAL hour, in Home Assistant's time zone.
|
||||
maintenance_start_hour: 19
|
||||
maintenance_start_hour: 10
|
||||
|
||||
# ⚠️ BOTH MUST STAY AT OR BELOW max_w, and that is why they are 2000 rather
|
||||
# than the 2500 they shipped as. The cheap-window charge shaping asks the
|
||||
@@ -970,13 +960,6 @@ schema:
|
||||
battery_modes_enabled: bool
|
||||
pv_forecast_entity: str?
|
||||
pv_forecast_tomorrow_entity: str?
|
||||
# OPT-17: new list keys beside the single ones. A list schema on the old
|
||||
# keys refuses the string an install stores (tools/supervisor_shape_probe.py
|
||||
# section J); DECISIONS/OPT-17-several-forecast-entities.md.
|
||||
pv_forecast_entities:
|
||||
- str
|
||||
pv_forecast_tomorrow_entities:
|
||||
- str
|
||||
solar_trust_pct: int(30,100)
|
||||
# TEL-11. Optional, and OPTIONAL WITHOUT A COMPANION RULE - unlike
|
||||
# `price_now_entity`/`price_avg_entity`, which are legal only while
|
||||
@@ -1089,7 +1072,7 @@ schema:
|
||||
# this line offers exactly PROVIDERS + RETIRED_PROVIDERS + 'off' - derived, not
|
||||
# typed - and register() refuses to re-register a retired id, so the two
|
||||
# cannot both claim one.
|
||||
price_provider: list(off|generic_dayahead|price_service|custom_formula|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
|
||||
price_provider: list(off|generic_dayahead|custom_formula|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
|
||||
price_markup_eur_kwh: float(0,1)
|
||||
price_taxes_eur_kwh: float(0,1)
|
||||
|
||||
@@ -1123,7 +1106,6 @@ schema:
|
||||
price_formula: str?
|
||||
price_csv_path: str?
|
||||
price_feed_url: str?
|
||||
price_service_token: password?
|
||||
price_horizon_h: int(1,72)
|
||||
price_timeout_s: int(2,60)
|
||||
price_now_entity: str?
|
||||
|
||||
@@ -81,7 +81,6 @@ from datetime import datetime, timedelta, timezone
|
||||
from app import arbitrage as _arb
|
||||
from named_absence import first
|
||||
from app import main as _main
|
||||
from app.price_chain import PriceWatch
|
||||
from app import peakguard as _pg
|
||||
from app.arbiter import (
|
||||
P_ARBITRAGE, P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim,
|
||||
@@ -109,11 +108,6 @@ from app.validate import validate_startup_config
|
||||
|
||||
UTC = timezone.utc
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
|
||||
# point these fixtures build, so no period has ended and the whole series counts.
|
||||
from datetime import datetime as _DT, timezone as _TZ # noqa: E402
|
||||
LONG_AGO = _DT(2000, 1, 1, tzinfo=_TZ.utc)
|
||||
fails = []
|
||||
|
||||
|
||||
@@ -199,22 +193,13 @@ NO_WEATHER = frozenset({Uncertainty.NO_WEATHER})
|
||||
|
||||
|
||||
def plan_of(prices, *, product=PriceProduct.DAY_AHEAD, start=D0,
|
||||
provisional=False, provider="generic_dayahead", days=2):
|
||||
provisional=False, provider="generic_dayahead"):
|
||||
"""A TariffPlan over consecutive hourly prices starting at `start`.
|
||||
|
||||
`provisional` may be a single bool or a per-hour sequence.
|
||||
|
||||
PRICE-02: the verdict skips price periods that have ended, so a plan
|
||||
probed at 18:30 no longer sees the cheap hour at 03:00 of the same day. A
|
||||
running site holds 24-48 h, so `days=2` repeats the day (tomorrow's
|
||||
prices are today's): probes in day one keep their extremes, and the
|
||||
first cheapest/dearest hour still ahead is today's. Pass `days=1` for a
|
||||
plan that must be exactly the given hours.
|
||||
"""
|
||||
prices = list(prices) * days
|
||||
flags = ([bool(provisional)] * len(prices)
|
||||
if isinstance(provisional, bool)
|
||||
else list(provisional) * days)
|
||||
if isinstance(provisional, bool) else list(provisional))
|
||||
pts = [PricePoint(start=brussels(start + timedelta(hours=i)),
|
||||
eur_per_kwh=round(float(p), 6), provisional=f)
|
||||
for i, (p, f) in enumerate(zip(prices, flags))]
|
||||
@@ -358,7 +343,7 @@ check(f"a realistic Belgian day/night profile has a spread of "
|
||||
f"{_st_spread!r} EUR/kWh - the number the product decision is about",
|
||||
near(_st_spread, 0.08))
|
||||
_shipped = PRICE_OPTION_DEFAULTS["cycle_cost_threshold_eur"]
|
||||
_st_verdict = derived(lambda: _st_plan.arbitrage(now=LONG_AGO,
|
||||
_st_verdict = derived(lambda: _st_plan.arbitrage(
|
||||
peak_guard_active=False, min_spread_eur_kwh=_shipped,
|
||||
uncertainty=NO_WEATHER))
|
||||
check(f"...and at the SHIPPED threshold of {_shipped} EUR/kWh that site does "
|
||||
@@ -375,7 +360,7 @@ check(f"...and it is the SPREAD rung that refuses it, not a flat-tariff or a "
|
||||
check("...while the SAME site trades once the threshold is set below its "
|
||||
"spread, so the refusal above is about the number and not about static "
|
||||
"tariffs being unsupported",
|
||||
derived(lambda: _st_plan.arbitrage(now=LONG_AGO,
|
||||
derived(lambda: _st_plan.arbitrage(
|
||||
peak_guard_active=False,
|
||||
min_spread_eur_kwh=_st_spread / widen_factor(NO_WEATHER) - 1e-4,
|
||||
uncertainty=NO_WEATHER).kind) == "allowed")
|
||||
@@ -1658,7 +1643,7 @@ check("a FULLY carried series trades nothing at all, whatever its spread - "
|
||||
# standing if a future caller ever hands `_act` a plan the verdict did not come
|
||||
# from.
|
||||
_probe = _arb._point_at(PLAN, hour(CHEAP_H))
|
||||
_verdict = PLAN.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0,
|
||||
_verdict = PLAN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0,
|
||||
uncertainty=frozenset())
|
||||
check("the point matched for the buy hour IS the verdict's own cheapest point, "
|
||||
"by identity - which is why `_act`'s provisional guard is unreachable "
|
||||
@@ -1679,14 +1664,6 @@ print("4b. §10.2 hysteresis - a price on the boundary cannot flap at 30 s")
|
||||
# threshold repeatedly, hour to hour, which is the shape that makes a
|
||||
# per-tick price-vs-threshold comparison flap.
|
||||
FLAP = [0.20 + (0.101 if i % 2 else 0.0) for i in range(24)]
|
||||
# PRICE-02: the verdict skips periods that have ended, and this day had ties
|
||||
# (0.20 at every even hour, 0.301 at every odd one). With ties the first hour
|
||||
# still ahead is the extreme, so the verdict moves on every hour - once per
|
||||
# hour, never per tick, which test_price02.py part 6 grades. This check is
|
||||
# about a verdict that stays put while the prices keep crossing the threshold,
|
||||
# so the day gets one cheapest and one dearest hour at its end (22: 0.60,
|
||||
# 23: 0.05), unique and ahead of every probe until they come.
|
||||
FLAP[22], FLAP[23] = 0.60, 0.05
|
||||
_flap_plan = plan_of(FLAP)
|
||||
check("the flapping fixture really does straddle the threshold hour by hour - "
|
||||
"every neighbouring pair differs by more than it",
|
||||
@@ -1868,15 +1845,7 @@ def replay(threshold, unc, *, soc0=50.0, floor=20.0, ceiling=95.0):
|
||||
neg_sells = 0
|
||||
for n in range(365):
|
||||
day0 = REPLAY_START + timedelta(days=n)
|
||||
# PRICE-02: no two hours share a price. The synthetic day is
|
||||
# symmetric, so it prints the same maximum twice; the verdict now takes
|
||||
# the first one still AHEAD, where it used to take the first of the
|
||||
# day (a tie, graded on its own in test_price02.py part 6). So the
|
||||
# ties are broken by a jitter of at most 5e-5 EUR/kWh that has no
|
||||
# direction: not later-is-dearer, which would send every day to a
|
||||
# buy-then-sell and hide the selectivity this replay checks.
|
||||
prices = [round(p + 1e-6 * ((37 * h + 101 * n) % 50), 6)
|
||||
for h, p in enumerate(synth_day(n))]
|
||||
prices = synth_day(n)
|
||||
plan = plan_of(prices, start=day0)
|
||||
open_buy = None
|
||||
for h in range(24):
|
||||
@@ -2086,26 +2055,18 @@ check(f"BUT NOT ONE OF THE 365 DAYS HAS A NEGATIVE DEAREST HOUR "
|
||||
def replay_real(days, threshold, unc, *, soc0=50.0, floor=20.0, ceiling=95.0):
|
||||
"""The shipped `decide()` over the real year. Returns the ledger, nothing else.
|
||||
|
||||
The plan is built the way production builds it: one per hour, from that
|
||||
hour (the refresh floors to the hour) to the end of the Brussels day,
|
||||
plus the next day from 13:00 Brussels, when the day-ahead prices are
|
||||
published. So a site sees 11 to 35 hours ahead, never a whole day behind
|
||||
it and never a day on its own. (An earlier version built one plan per
|
||||
calendar day with no tomorrow: after the cheap hour no cheap hour was left
|
||||
ahead, and the ledger shrank to a few trades that no real site would see.)
|
||||
An open buy is kept over midnight: with tomorrow in view most buys are
|
||||
sold the next day or later (46 of 51 at the shipped threshold).
|
||||
One `TariffPlan` per Brussels day, so "the cheapest hour" means the cheapest
|
||||
hour of that day - the same shape as section 7's replay, and the same shape
|
||||
a site with a day-ahead feed actually sees.
|
||||
"""
|
||||
soc = soc0
|
||||
trades, holdings, bad, neg_sells = [], [], [], 0
|
||||
open_buy = None
|
||||
for di, day in enumerate(days):
|
||||
nxt = days[di + 1] if di + 1 < len(days) else []
|
||||
for i, p in enumerate(day):
|
||||
ahead = day[i:] + (nxt if brussels(p.start).hour >= 13 else [])
|
||||
plan = TariffPlan(
|
||||
PriceRead.series(ahead, PriceProduct.DAY_AHEAD,
|
||||
"generic_dayahead", p.start), None, "provider")
|
||||
for day in days:
|
||||
plan = TariffPlan(
|
||||
PriceRead.series(day, PriceProduct.DAY_AHEAD, "generic_dayahead",
|
||||
day[0].start), None, "provider")
|
||||
open_buy = None
|
||||
for p in day:
|
||||
d = decide(plan, p.start + timedelta(minutes=30),
|
||||
peak_guard_active=False, soc=soc,
|
||||
min_spread_eur_kwh=threshold, uncertainty=unc,
|
||||
@@ -2350,7 +2311,6 @@ class _T12Controller:
|
||||
self.tariff_plan = None
|
||||
self.tariff_at = None
|
||||
self.price_history = NoHistory()
|
||||
self.price_watch = PriceWatch() # PRICE-01: the refresh passes it on
|
||||
self.stop_event = asyncio.Event()
|
||||
self.stop_event.set() # so the inter-refresh wait returns
|
||||
self._ticks = 0
|
||||
@@ -2482,7 +2442,7 @@ check("...and every one of its points is provisional, so firm_points() is "
|
||||
"a fresh observation",
|
||||
isinstance(_t12_fb, TariffPlan) and _t12_fb.provisional is True
|
||||
and _t12_fb.firm_points() == ()
|
||||
and _t12_fb.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0,
|
||||
and _t12_fb.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0,
|
||||
uncertainty=NO_WEATHER).kind == "refused")
|
||||
_t12_f.tariff_plan, _t12_f.tariff_at = _t12_good, datetime.now(UTC) - \
|
||||
timedelta(seconds=1800)
|
||||
@@ -2827,7 +2787,7 @@ check(f"the shipped rule and this one give DIFFERENT points at both probes - "
|
||||
|
||||
print()
|
||||
print("10b. the buy/sell identity test matches the actual extremum interval")
|
||||
_qv = QPLAN.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=_shipped,
|
||||
_qv = QPLAN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=_shipped,
|
||||
uncertainty=NO_WEATHER)
|
||||
check("the verdict's cheapest and dearest are the two QUARTERS, not the hours "
|
||||
"containing them - `static_tariff` reduces over every firm point and "
|
||||
@@ -2959,7 +2919,7 @@ check(f"...and its firm half's extremes are quarters {_mix_cheap_i} and "
|
||||
f"{_mix_dear_i}, neither on a whole hour, so the identity test below is "
|
||||
f"about the quarter and not about the hour",
|
||||
(_mix_cheap_i - MIX_N_H) % 4 != 0 and (_mix_dear_i - MIX_N_H) % 4 != 0)
|
||||
_mv = MIX_PLAN.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=_shipped,
|
||||
_mv = MIX_PLAN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=_shipped,
|
||||
uncertainty=NO_WEATHER)
|
||||
_mix_buy_t = qprobe(_mix_cheap_i, gaps=MIX_GAPS)
|
||||
_mix_sell_t = qprobe(_mix_dear_i, gaps=MIX_GAPS)
|
||||
@@ -2971,19 +2931,14 @@ check("the verdict over a MIXED series still names firm quarters, and "
|
||||
is MIX_PTS[_mix_cheap_i]
|
||||
and derived(lambda: _arb._point_at(MIX_PLAN, _mix_sell_t))
|
||||
is MIX_PTS[_mix_dear_i])
|
||||
# PRICE-02: the same day twice, as a running site holds 24-48 h (see plan_of).
|
||||
# The probes stay in day one and keep their extremes: once the cheap quarter is
|
||||
# over, the verdict at the dear quarter needs a cheap one still ahead.
|
||||
MIX2_PLAN = qplan_of(MIX_PRICES * 2, provisional=MIX_FLAGS * 2,
|
||||
gaps=MIX_GAPS + [900] + MIX_GAPS)
|
||||
check("...so the mixed day CHARGES and DISCHARGES in its firm quarters, at "
|
||||
"their own prices",
|
||||
decide(MIX2_PLAN, _mix_buy_t, peak_guard_active=False, soc=50.0,
|
||||
decide(MIX_PLAN, _mix_buy_t, peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0).action == CHARGE
|
||||
and decide(MIX2_PLAN, _mix_sell_t, peak_guard_active=False, soc=50.0,
|
||||
and decide(MIX_PLAN, _mix_sell_t, peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
|
||||
@@ -439,7 +439,7 @@ class _Hass:
|
||||
return True
|
||||
|
||||
|
||||
def wired(grid, cycles=40, batt=0.0, track=True, tick=None, **over):
|
||||
def wired(grid, cycles=40, batt=0.0, track=True, **over):
|
||||
async def go():
|
||||
hass = _Hass(grid, batt=batt, track=track)
|
||||
c = Controller(dict(
|
||||
@@ -455,11 +455,8 @@ def wired(grid, cycles=40, batt=0.0, track=True, tick=None, **over):
|
||||
# hysteresis clears it a few cycles later - so it is captured live
|
||||
# rather than looked for after the run, when it is correctly gone.
|
||||
c.red_snapshot = []
|
||||
for i in range(cycles):
|
||||
# `tick`: simulated seconds per cycle. SAFETY-94: a refusal needs
|
||||
# its evidence to span REFUSED_SPAN_S, which back-to-back cycles
|
||||
# on the real clock never do.
|
||||
await c.read_inputs(now_mono=None if tick is None else i * tick)
|
||||
for _ in range(cycles):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
if c.clamp_wd.active and not c.red_snapshot:
|
||||
c.red_snapshot = c.checks()
|
||||
@@ -477,7 +474,7 @@ def clamp_notes(hass):
|
||||
# unclamped integrator climbed ~130 W per 4 s to over 10 kW and, pinned at the
|
||||
# 5 kW rail, reported 14 768 W." `track=False` is the inverter switched off: it
|
||||
# reports 0 W however hard it is commanded, so the error never closes.
|
||||
hass_r, ctl_r = wired(6000.0, cycles=60, track=False, tick=2.0)
|
||||
hass_r, ctl_r = wired(6000.0, cycles=60, track=False)
|
||||
emitted = [abs(w) for w in hass_r.writes]
|
||||
|
||||
# What the SAME error would have done to an accumulator with no bound - the
|
||||
|
||||
@@ -900,51 +900,34 @@ def _loop_series(day, sig):
|
||||
return out
|
||||
|
||||
|
||||
class _Before94(Freshness):
|
||||
"""The gate before SAFETY-94: the clock ran from the register's last
|
||||
change, not from when the command asked it to move. The history below is
|
||||
about that gate, so it is replayed on it."""
|
||||
|
||||
def obliged_s(self):
|
||||
return self.graded_s(self.last_read)
|
||||
|
||||
|
||||
def _replay16(cls):
|
||||
"""Every committed day at 1 Hz. Returns (trips, waits for the next move)."""
|
||||
waits, trips = [], 0
|
||||
for _day in _loop_days:
|
||||
_b, _s = _loop_series(_day, "batt"), _loop_series(_day, "setpoint")
|
||||
if not _b or not _s:
|
||||
continue
|
||||
_moves = [r[0] for i, r in enumerate(_b)
|
||||
if i == 0 or r[1] != _b[i - 1][1]]
|
||||
_f8b, _prev8b = cls(BOUNDS["batt"]), False
|
||||
_ib = _is = 0
|
||||
_t = max(_b[0][0], _s[0][0])
|
||||
_end = min(_b[-1][0], _s[-1][0])
|
||||
while _t <= _end:
|
||||
while _ib + 1 < len(_b) and _b[_ib + 1][0] <= _t:
|
||||
_ib += 1
|
||||
while _is + 1 < len(_s) and _s[_is + 1][0] <= _t:
|
||||
_is += 1
|
||||
_f8b.submit(_b[_ib][1], _s[_is][1], _t)
|
||||
if _f8b.stale and not _prev8b:
|
||||
trips += 1
|
||||
_nxt = next((m for m in _moves if m > _t), None)
|
||||
if _nxt is not None:
|
||||
waits.append(_nxt - _t)
|
||||
_prev8b = _f8b.stale
|
||||
_t += 1.0
|
||||
return trips, sorted(waits)
|
||||
|
||||
|
||||
_t8b = time.monotonic()
|
||||
_trips8b, _waits = _replay16(_Before94)
|
||||
_trips94, _waits94 = _replay16(Freshness)
|
||||
_waits, _trips8b = [], 0
|
||||
for _day in _loop_days:
|
||||
_b, _s = _loop_series(_day, "batt"), _loop_series(_day, "setpoint")
|
||||
if not _b or not _s:
|
||||
continue
|
||||
_moves = [r[0] for i, r in enumerate(_b) if i == 0 or r[1] != _b[i - 1][1]]
|
||||
_f8b, _prev8b = Freshness(BOUNDS["batt"]), False
|
||||
_ib = _is = 0
|
||||
_t = max(_b[0][0], _s[0][0])
|
||||
_end = min(_b[-1][0], _s[-1][0])
|
||||
while _t <= _end:
|
||||
while _ib + 1 < len(_b) and _b[_ib + 1][0] <= _t:
|
||||
_ib += 1
|
||||
while _is + 1 < len(_s) and _s[_is + 1][0] <= _t:
|
||||
_is += 1
|
||||
_f8b.submit(_b[_ib][1], _s[_is][1], _t)
|
||||
if _f8b.stale and not _prev8b:
|
||||
_trips8b += 1
|
||||
_nxt = next((m for m in _moves if m > _t), None)
|
||||
if _nxt is not None:
|
||||
_waits.append(_nxt - _t)
|
||||
_prev8b = _f8b.stale
|
||||
_t += 1.0
|
||||
_waits.sort()
|
||||
_p50 = nth(_waits, len(_waits) // 2)
|
||||
print(f" {len(_loop_days)} days replayed at 1 Hz in "
|
||||
f"{time.monotonic() - _t8b:.1f}s (twice): {_trips8b} trips before "
|
||||
f"SAFETY-94, {_trips94} after")
|
||||
f"{time.monotonic() - _t8b:.1f}s: {_trips8b} trips")
|
||||
print(f" wait for the register's next change after a trip: "
|
||||
f"min {nth(_waits, 0):.1f} s median {_p50:.1f} s max {nth(_waits, -1):.1f} s; "
|
||||
f"{sum(1 for w in _waits if w >= 710.0)} at or over the live episode's "
|
||||
@@ -970,17 +953,6 @@ check("⚠️ AND THAT DISTRIBUTION IS A PROPERTY OF THIS BMS, NOT OF THIS CODE
|
||||
"is `stale_input_s + release_s` = 32 s whatever the register does",
|
||||
sum(1 for w in _waits if w < 20.0) >= 20
|
||||
and float(option_default("stale_input_s")) + BOUNDS["batt"].release_s == 32.0)
|
||||
print(f" SAFETY-94: trips answered within 20 s: {sum(1 for w in _waits if w < 20.0)}"
|
||||
f" before, {sum(1 for w in _waits94 if w < 20.0)} after; quickest "
|
||||
f"answer after a trip {nth(_waits94, 0):.1f} s")
|
||||
check("⚠️ SAFETY-94 (SAFETY-58): MOST OF THOSE SHORT WAITS WERE FALSE TRIPS, "
|
||||
"NOT KEEP-ALIVE PULSES. The clock now counts only the time the command "
|
||||
"asked the register to move. The same sixteen days give 59 trips, not "
|
||||
"93, and only one of the 59 is answered within 20 s (21 before): the "
|
||||
"gate no longer judges a register on the first read after a rest",
|
||||
_trips94 == 59 and len(_waits94) == _trips94
|
||||
and sum(1 for w in _waits94 if w < 20.0) == 1
|
||||
and sum(1 for w in _waits if w < 20.0) == 21)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -1045,30 +1017,32 @@ check("NOT A SURVIVOR, recorded because it looks like one: a publisher failure "
|
||||
"each unit's own command travel and each unit's own number",
|
||||
_s4a.stale and _s4b.stale and _s4a.held != _s4b.held)
|
||||
|
||||
# S5. SAFETY-57's own FALSE POSITIVE, CLOSED BY SAFETY-94 (it was SAFETY-58).
|
||||
# The unchanged clock used to keep running while the site rested, so the
|
||||
# FIRST read at which a command travelled 250 W after a long quiet tripped
|
||||
# the gate. The clock now counts only time the register was asked to move.
|
||||
# Live 2026-10-03: four such trips, each 1 s after the command.
|
||||
# S5. SAFETY-57's own, and it is a FALSE POSITIVE that predates this card.
|
||||
# The unchanged clock keeps running while the site rests, so the FIRST read
|
||||
# at which a command travels 250 W after a long quiet already satisfies all
|
||||
# three terms - the register is given no time at all to answer the new
|
||||
# command. Present on the shipped tree; the live capture in part 10 contains
|
||||
# one (10:48:14, cleared one second later).
|
||||
_s5 = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 300): # five minutes of a resting site
|
||||
_s5.submit(-3.0, 0.0, float(t))
|
||||
_s5_quiet = _s5.stale
|
||||
_s5.submit(-3.0, 1500.0, 300.0) # the loop asks for 1500 W
|
||||
_s5_first = _s5.stale
|
||||
for t in range(301, 334):
|
||||
_s5.submit(-3.0, 1500.0, float(t))
|
||||
_s5_before = _s5.stale
|
||||
_s5.submit(-3.0, 1500.0, 334.0)
|
||||
check("⚠️ SURVIVOR 5 IS CLOSED (SAFETY-94): after five minutes of rest the "
|
||||
"first read carrying a travelled command is NOT a trip, nor is any read "
|
||||
"before 35 s of standing command - and a register that still has not "
|
||||
"moved after 35 s of it IS",
|
||||
not _s5_quiet and not _s5_first and not _s5_before and _s5.stale
|
||||
and _s5.graded_s(334.0) == 334.0 and _s5.obliged_s() == 35.0)
|
||||
check("...and the evidence sentence still gives the register's own unchanged "
|
||||
"time (334 s), which an operator compares with a history graph",
|
||||
"unchanged for 334 s" in _s5.reason)
|
||||
check("⚠️ SURVIVOR 5, FALSE POSITIVE, AND IT IS OLDER THAN SAFETY-57: after a "
|
||||
"long rest the unchanged clock is already past 35 s, so the very FIRST "
|
||||
"read carrying a travelled command trips the gate - before the 17.0 s in "
|
||||
"which a healthy register could have answered it. The gate accuses on "
|
||||
"read one and the register answers on read eleven",
|
||||
not _s5_quiet and _s5.stale and _s5.graded_s(300.0) == 300.0)
|
||||
check("...and its SIZE is one `stale_input_s` hold, which is why it is recorded "
|
||||
"here rather than fixed here: `cycle()` returns at `inputs missing "
|
||||
"{n}s` for the whole of it, so nothing is commanded differently, and the "
|
||||
"register's next publish clears it. Fixing it means measuring the "
|
||||
"unchanged clock from when the OBLIGATION appeared rather than from the "
|
||||
"register's last move, which re-derives the 35.0 s bound and is a "
|
||||
"different card",
|
||||
(lambda f: (f.submit(-4.0, 1500.0, 311.0), not f.stale)[-1])(_s5)
|
||||
and float(option_default("stale_input_s")) >= 5.0)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -1379,23 +1353,20 @@ print(f" after : held number reached a consumer on {sum(_exp57)} reads "
|
||||
|
||||
check("⚠️ THE EXIT IS NOT FREE AND THE PRICE IS STATED AS A NUMBER. A register "
|
||||
"that is dead for good is re-graded for ever: over 1201 reads the held "
|
||||
"number reaches a consumer on 631 of them against 36 before, and 17 "
|
||||
"number reaches a consumer on 630 of them against 35 before, and 17 "
|
||||
"releases are taken. This check exists so that cost cannot be quietly "
|
||||
"forgotten, and so a change that makes it worse has to move a number "
|
||||
"somebody reads",
|
||||
sum(_expb) == 36 and sum(_exp57) == 631 and _dead.releases == 17
|
||||
sum(_expb) == 35 and sum(_exp57) == 630 and _dead.releases == 17
|
||||
and _deadb.releases == 0)
|
||||
check("⚠️ AND IT IS PAYABLE ONLY BECAUSE ONE RUN IS SHORTER THAN EITHER "
|
||||
"CONSUMER'S HOLD. Every unbroken run of exposure after a release is "
|
||||
"exactly one 35.0 s window of standing command (the first is 36 s: the "
|
||||
"fixture's command starts one read late), and 36 < 75 "
|
||||
"(circulation_hold_s) < 85 "
|
||||
"CONSUMER'S HOLD. Every unbroken run of exposure is exactly one 35.0 s "
|
||||
"grading window, and 35 < 75 (circulation_hold_s) < 85 "
|
||||
"(unit_sign_disagree_s) - so NEITHER detector can reach a verdict inside "
|
||||
"one, and the re-trip that ends each run hands them a None, which CLEARS "
|
||||
"their windows rather than accumulating into them",
|
||||
set(_runs57[1:]) == {BOUNDS["batt"].unchanged_s}
|
||||
and nth(_runs57, 0) == BOUNDS["batt"].unchanged_s + 1.0
|
||||
and max(_runs57) < _circ_hold < _unit_hold)
|
||||
set(_runs57) == {BOUNDS["batt"].unchanged_s}
|
||||
and BOUNDS["batt"].unchanged_s < _circ_hold < _unit_hold)
|
||||
check("...and nothing SAFETY-37 detects is given back by that, which is the "
|
||||
"half that makes the trade a trade. A permanent None clears both windows "
|
||||
"for ever too, so on the shipped-before module the same two detectors are "
|
||||
@@ -1672,10 +1643,8 @@ check("...and a re-trip's reason names the TWO durations separately, each "
|
||||
[f.submit(A_FROZEN_W, 0.0, float(t)) for t in range(40, 80)],
|
||||
[f.submit(A_FROZEN_W, 1300.0, float(t)) for t in range(80, 130)],
|
||||
f.stale and f.releases == 1
|
||||
# SAFETY-94: the re-trip comes 35 s after the command returns at
|
||||
# 80 s (114 s), not 35 s after the release at 57 s.
|
||||
and "unchanged for 114 s" in f.reason
|
||||
and "checked 2 times over a 57 s window since the last stop was "
|
||||
and "unchanged for 92 s" in f.reason
|
||||
and "checked 2 times over a 35 s window since the last stop was "
|
||||
"withdrawn" in f.reason)[-1])(
|
||||
Freshness(BOUNDS["batt"])))
|
||||
|
||||
|
||||
@@ -33,9 +33,7 @@ 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, static_plan
|
||||
from app import packmode
|
||||
from app.store import unit_key
|
||||
from app.static_tariff import TariffPlan
|
||||
from app.uimodel import REGION_PRESETS
|
||||
from named_absence import field, nth, present
|
||||
|
||||
@@ -154,14 +152,6 @@ 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
|
||||
@@ -264,7 +254,7 @@ check("trading on price: a charge block at the cheapest hour (13:00) and a "
|
||||
check("...placed where the control cycle's own verdict puts them: the same "
|
||||
"plan.arbitrage() call on the same plan",
|
||||
c_arb.fresh_tariff_plan(NOW).arbitrage(
|
||||
now=NOW, peak_guard_active=False, min_spread_eur_kwh=0.05,
|
||||
peak_guard_active=False, min_spread_eur_kwh=0.05,
|
||||
uncertainty=frozenset({__import__("app.price", fromlist=["x"])
|
||||
.Uncertainty.NO_WEATHER}))
|
||||
.match(allowed=lambda v: (v.cheapest.start, v.dearest.start),
|
||||
@@ -327,7 +317,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 dear hour marked",
|
||||
"cheap and expensive thirds 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"
|
||||
@@ -410,15 +400,6 @@ check("...with its duration from the battery's capacity and the cycle's rates "
|
||||
"and hold time",
|
||||
present(nth(_cyc)) and _cyc[0]["amount"].startswith("Takes about ")
|
||||
and _cyc[0]["end_h"] > _cyc[0]["start_h"] + 2)
|
||||
# MAINT-10: with the site's zone set, the hour is read on the local clock.
|
||||
from app.tariff import _EUTimezone # noqa: E402
|
||||
c_ml = make(dict(MAINT, maintenance_start_hour=14))
|
||||
for _mm in c_ml.maints:
|
||||
_mm.tz = _EUTimezone()
|
||||
_cyc_l = [r for r in status(c_ml)["plan"]["runs"] if r["kind"] == "cycle"]
|
||||
check("MAINT-10: start hour 14 in the local zone is drawn at 14:00, the "
|
||||
"instant the cycle starts", len(_cyc_l) == 1
|
||||
and field(nth(_cyc_l), "from") == "14:00")
|
||||
c_m.maints[0].store.set("phase", "drain")
|
||||
s_md = status(c_m)
|
||||
check("a cycle under way is drawn from now, and the battery's now line says "
|
||||
@@ -523,274 +504,15 @@ _lo = min(_q[:96], key=lambda p: p.eur_per_kwh)
|
||||
check("quarter-hour prices: the charge block, its price and the price line's "
|
||||
"lowest are the same 15-minute period",
|
||||
_grid and _grid[0]["from"] == homeview.hhmm(_lo.start)
|
||||
and _grid[0]["why"] == f"Lowest price left, €{_lo.eur_per_kwh:.2f} "
|
||||
and _grid[0]["why"] == f"Lowest published price, €{_lo.eur_per_kwh:.2f} "
|
||||
"per kWh."
|
||||
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"]))
|
||||
|
||||
# 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), 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())
|
||||
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())
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. a fault in the Home view never costs the page its rows")
|
||||
|
||||
@@ -1,543 +0,0 @@
|
||||
"""MAINT-10: the maintenance start hour is LOCAL time. `python3 test_maint10.py`
|
||||
|
||||
Graded here: the slot on ordinary days and on both DST change days, the zone
|
||||
source and its fallbacks, the 19:00 default, the startup wiring, and that the
|
||||
controller's commands are unchanged once the start instant is the same.
|
||||
See DECISIONS/MAINT-10-local-start-hour.md.
|
||||
|
||||
Mutating what this file grades? Read MUTATION.md first (stale bytecode).
|
||||
|
||||
⚠️ The CI image has no tzdata, so every DST check runs on tariff.py's encoded
|
||||
EU rule and, where zoneinfo works, on Europe/Brussels from tzdata as well.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools"))
|
||||
|
||||
import app.main as M # noqa: E402
|
||||
from app import maintenance as MT # noqa: E402
|
||||
from app.maintenance import ( # noqa: E402
|
||||
DRAIN, IDLE, MAINT_OPTION_DEFAULTS, MaintConfig, Maintenance,
|
||||
in_start_slot, next_slot_start, site_zone,
|
||||
)
|
||||
from app.tariff import _EUTimezone # noqa: E402
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
UTC = timezone.utc
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
ZONES = [("encoded EU rule", _EUTimezone())]
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
ZONES.append(("zoneinfo Europe/Brussels", ZoneInfo("Europe/Brussels")))
|
||||
except Exception as err: # noqa: BLE001
|
||||
print(f" zoneinfo unavailable here ({err}); graded on the EU rule only")
|
||||
|
||||
SPRING = datetime(2026, 3, 29, tzinfo=UTC) # 02:00 CET -> 03:00 CEST
|
||||
SPRING27 = datetime(2027, 3, 28, tzinfo=UTC) # the next one, last Sunday of March
|
||||
AUTUMN = datetime(2026, 10, 25, tzinfo=UTC) # 03:00 CEST -> 02:00 CET
|
||||
SUMMER = datetime(2026, 7, 15, tzinfo=UTC)
|
||||
WINTER = datetime(2026, 1, 15, tzinfo=UTC)
|
||||
|
||||
|
||||
def slot_minutes(day_utc, hour, tz):
|
||||
"""UTC minutes in the slot, over the LOCAL day of that date."""
|
||||
t, out = day_utc - timedelta(hours=3), []
|
||||
while t < day_utc + timedelta(hours=27):
|
||||
if (t.astimezone(tz).date() == day_utc.date()
|
||||
and in_start_slot(t, hour, tz)):
|
||||
out.append(t)
|
||||
t += timedelta(minutes=1)
|
||||
return out
|
||||
|
||||
|
||||
def one_block(mins):
|
||||
"""`(start, minutes)` when the minutes form one contiguous run, else None."""
|
||||
if not mins:
|
||||
return None
|
||||
for a, b in zip(mins, mins[1:]):
|
||||
if b - a != timedelta(minutes=1):
|
||||
return None
|
||||
return mins[0], len(mins)
|
||||
|
||||
|
||||
# =========================================================================== #
|
||||
print("1. UTC stays what it was: the slot IS `now.hour == start_hour`")
|
||||
t, same = datetime(2026, 1, 1, tzinfo=UTC), True
|
||||
while t < datetime(2027, 1, 1, tzinfo=UTC):
|
||||
for h in range(24):
|
||||
if in_start_slot(t, h, UTC) != (t.hour == h):
|
||||
same = False
|
||||
t += timedelta(minutes=37)
|
||||
check("over a year of instants, every hour 0-23: identical to the old test",
|
||||
same)
|
||||
check("a naive `now` is read as UTC, as before",
|
||||
in_start_slot(datetime(2026, 7, 1, 19, 5), 19, UTC)
|
||||
and not in_start_slot(datetime(2026, 7, 1, 18, 5), 19, UTC))
|
||||
|
||||
# =========================================================================== #
|
||||
print("2. local time: 19 means 19:00 on the site's clock")
|
||||
for label, tz in ZONES:
|
||||
s = one_block(slot_minutes(SUMMER, 19, tz))
|
||||
w = one_block(slot_minutes(WINTER, 19, tz))
|
||||
check(f"[{label}] summer: 17:00-18:00 UTC (CEST), one hour",
|
||||
s == (SUMMER + timedelta(hours=17), 60))
|
||||
check(f"[{label}] winter: 18:00-19:00 UTC (CET), one hour",
|
||||
w == (WINTER + timedelta(hours=18), 60))
|
||||
check(f"[{label}] the old reading is gone: 19:00 UTC is not the slot "
|
||||
f"in summer", not in_start_slot(SUMMER + timedelta(hours=19, minutes=5),
|
||||
19, tz))
|
||||
|
||||
# =========================================================================== #
|
||||
print("3. the DST change days, every start hour")
|
||||
for label, tz in ZONES:
|
||||
for name, day in (("spring", SPRING), ("spring 2027", SPRING27),
|
||||
("autumn", AUTUMN)):
|
||||
bad = []
|
||||
for h in range(24):
|
||||
b = one_block(slot_minutes(day, h, tz))
|
||||
# the local day runs 23:00 UTC the day before to 22:00/23:00 UTC
|
||||
if b is None or b[1] != 60:
|
||||
bad.append((h, b))
|
||||
check(f"[{label}] {name}: every hour 0-23 gives ONE slot of exactly "
|
||||
f"60 minutes that day {bad or ''}", not bad)
|
||||
sp2 = one_block(slot_minutes(SPRING, 2, tz))
|
||||
check(f"[{label}] spring, hour 2 does not exist: it starts at 03:00 CEST "
|
||||
f"(01:00 UTC), once", sp2 == (SPRING + timedelta(hours=1), 60))
|
||||
check(f"[{label}] spring 2027 (28 March), hour 2: 01:00 UTC as well",
|
||||
one_block(slot_minutes(SPRING27, 2, tz))
|
||||
== (SPRING27 + timedelta(hours=1), 60))
|
||||
check(f"[{label}] the evening before spring 2027, hour 19: 18:00 UTC "
|
||||
f"(CET); that day, 17:00 UTC (CEST)",
|
||||
one_block(slot_minutes(SPRING27 - timedelta(days=1), 19, tz))
|
||||
== (SPRING27 - timedelta(hours=6), 60)
|
||||
and one_block(slot_minutes(SPRING27, 19, tz))
|
||||
== (SPRING27 + timedelta(hours=17), 60))
|
||||
au2 = one_block(slot_minutes(AUTUMN, 2, tz))
|
||||
check(f"[{label}] autumn, hour 2 happens twice: only the first, 02:00 CEST "
|
||||
f"(00:00 UTC)", au2 == (AUTUMN, 60))
|
||||
check(f"[{label}] autumn: the second 02:xx (CET) is not a slot",
|
||||
not in_start_slot(AUTUMN + timedelta(hours=1, minutes=30), 2, tz))
|
||||
check(f"[{label}] autumn, hour 19 still 18:00 UTC (CET)",
|
||||
one_block(slot_minutes(AUTUMN, 19, tz))
|
||||
== (AUTUMN + timedelta(hours=18), 60))
|
||||
check(f"[{label}] spring, hour 19 already 17:00 UTC (CEST)",
|
||||
one_block(slot_minutes(SPRING, 19, tz))
|
||||
== (SPRING + timedelta(hours=17), 60))
|
||||
|
||||
|
||||
# =========================================================================== #
|
||||
class FakeStore:
|
||||
def __init__(self):
|
||||
self.data = {"phase": IDLE, "phase_started": None,
|
||||
"last_completed": None, "last_start_attempt": None}
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def set_time(self, k, when):
|
||||
self.data[k] = when
|
||||
|
||||
def get_time(self, k):
|
||||
return self.data.get(k)
|
||||
|
||||
def get(self, k, default=None):
|
||||
return self.data.get(k, default)
|
||||
|
||||
def save(self):
|
||||
pass
|
||||
|
||||
|
||||
def starts(day, hour, tz, *, abort_after=None, blocked=None):
|
||||
"""Tick a due pack each minute around a day; the UTC instants in that
|
||||
LOCAL day at which it entered DRAIN. `abort_after` aborts each drain that many minutes in (interlock);
|
||||
`blocked(t)` defers it."""
|
||||
m = Maintenance(MaintConfig(enabled=True, start_hour=hour), FakeStore(),
|
||||
tz=tz)
|
||||
out, since = [], None
|
||||
t = day - timedelta(hours=2)
|
||||
while t < day + timedelta(hours=26):
|
||||
why = ""
|
||||
if since is not None and abort_after is not None \
|
||||
and t - since >= timedelta(minutes=abort_after):
|
||||
why = "a test interlock"
|
||||
if blocked is not None and blocked(t):
|
||||
why = "the other battery is cycling"
|
||||
was = m.phase
|
||||
m.tick(t, soc=80, blocked_by=why)
|
||||
if m.phase == DRAIN and was != DRAIN:
|
||||
out.append(t)
|
||||
since = t
|
||||
if m.phase == IDLE:
|
||||
since = None
|
||||
t += timedelta(minutes=1)
|
||||
return [x for x in out if x.astimezone(tz).date() == day.date()]
|
||||
|
||||
|
||||
print("4. the state machine: one start per day, DST days included")
|
||||
for label, tz in ZONES:
|
||||
check(f"[{label}] spring, hour 2: one start, at 01:00 UTC (03:00 CEST)",
|
||||
starts(SPRING, 2, tz, abort_after=5)
|
||||
== [SPRING + timedelta(hours=1)])
|
||||
# An aborted start waits RESTART_COOLDOWN_MIN (90). The repeated hour is
|
||||
# 120 minutes long, so without the first-pass rule it would start again.
|
||||
check(f"[{label}] autumn, hour 2, aborted after 5 min: NO second start "
|
||||
f"in the repeated hour",
|
||||
starts(AUTUMN, 2, tz, abort_after=5) == [AUTUMN])
|
||||
check(f"[{label}] autumn, hour 2, deferred for the whole first pass: it "
|
||||
f"does not start in the second pass either",
|
||||
starts(AUTUMN, 2, tz, blocked=lambda t: t < AUTUMN + timedelta(hours=1))
|
||||
== [])
|
||||
check(f"[{label}] summer, hour 19: one start at 17:00 UTC",
|
||||
starts(SUMMER, 19, tz, abort_after=5)
|
||||
== [SUMMER + timedelta(hours=17)])
|
||||
|
||||
# =========================================================================== #
|
||||
print("5. where the start is shown (homeview) is where it happens")
|
||||
for label, tz in ZONES:
|
||||
ok = True
|
||||
for day in (SPRING, AUTUMN, SUMMER):
|
||||
for h in (0, 2, 3, 10, 19, 23):
|
||||
first = next(iter(slot_minutes(day, h, tz)), None)
|
||||
if first is None:
|
||||
continue
|
||||
for probe in (first - timedelta(hours=5), first,
|
||||
first + timedelta(minutes=59)):
|
||||
if next_slot_start(probe, h, tz) != first:
|
||||
ok = False
|
||||
check(f"[{label}] next_slot_start() lands on the slot's first minute, "
|
||||
f"from before it and from inside it", ok)
|
||||
|
||||
# =========================================================================== #
|
||||
print("6. which zone: Home Assistant, else the container's TZ, else UTC")
|
||||
_FAKE = {"Europe/Brussels": ZONES[-1][1], "America/New_York": object(),
|
||||
"UTC": UTC}
|
||||
_real_load = MT._load_zone
|
||||
MT._load_zone = lambda n: ((_FAKE[n], "") if n in _FAKE
|
||||
else (None, f"{n!r} is not a known zone"
|
||||
if n else "none given"))
|
||||
try:
|
||||
tz, name, src, _ = site_zone("Europe/Brussels", "", "America/New_York")
|
||||
check("Home Assistant's zone wins over the container's TZ",
|
||||
name == "Europe/Brussels" and src == "ha"
|
||||
and tz is _FAKE["Europe/Brussels"])
|
||||
tz, name, src, why = site_zone(None, "ClientConnectorError",
|
||||
"America/New_York")
|
||||
check("Home Assistant unavailable: the container's TZ, and why",
|
||||
name == "America/New_York" and src == "tz"
|
||||
and why == "ClientConnectorError")
|
||||
tz, name, src, why = site_zone("Mars/Base", "", "Europe/Brussels")
|
||||
check("an unknown Home Assistant zone falls back too",
|
||||
name == "Europe/Brussels" and src == "tz" and "Mars/Base" in why)
|
||||
tz, name, src, why = site_zone(None, "no answer", None)
|
||||
check("neither: UTC", tz is UTC and src == "utc" and "no answer" in why)
|
||||
finally:
|
||||
MT._load_zone = _real_load
|
||||
if len(ZONES) > 1:
|
||||
check("the real loader resolves a real name and refuses a made-up one",
|
||||
MT._load_zone("Europe/Brussels")[0] is not None
|
||||
and MT._load_zone("Mars/Base")[0] is None)
|
||||
check("the real loader refuses an empty name without raising",
|
||||
MT._load_zone("")[0] is None and MT._load_zone(None)[0] is None)
|
||||
|
||||
# =========================================================================== #
|
||||
print("7. the default is 19, an explicit value is honoured")
|
||||
cfg_text = (HERE / "config.yaml").read_text()
|
||||
opts_block = cfg_text.split("\nschema:")[0]
|
||||
check("config.yaml options: ships 19",
|
||||
re.search(r"^ maintenance_start_hour: 19$", opts_block, re.M) is not None)
|
||||
check("config.yaml schema: still int(0,23)",
|
||||
re.search(r"^ maintenance_start_hour: int\(0,23\)$", cfg_text, re.M)
|
||||
is not None)
|
||||
check("the in-code mirror says 19",
|
||||
MAINT_OPTION_DEFAULTS["maintenance_start_hour"] == 19
|
||||
and MaintConfig().start_hour == 19
|
||||
and MaintConfig.from_options({}).start_hour == 19)
|
||||
check("an explicit 10 stays 10 (a stored option is never rewritten)",
|
||||
MaintConfig.from_options({"maintenance_start_hour": 10}).start_hour == 10)
|
||||
_eu = ZONES[0][1]
|
||||
_m10 = Maintenance(MaintConfig.from_options(
|
||||
{"maintenance_enabled": True, "maintenance_start_hour": 10}),
|
||||
FakeStore(), tz=_eu)
|
||||
check("...and now means 10:00 local: 08:00 UTC in summer, not 10:00 UTC",
|
||||
_m10.wants_start(SUMMER + timedelta(hours=8, minutes=1))
|
||||
and not _m10.wants_start(SUMMER + timedelta(hours=10, minutes=1)))
|
||||
check("a Maintenance built with no zone reads UTC, as before",
|
||||
Maintenance(MaintConfig(), FakeStore()).tz is UTC)
|
||||
|
||||
|
||||
# =========================================================================== #
|
||||
print("8. startup: Home Assistant's zone is asked for and reaches the machines")
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, body, status=200):
|
||||
self.body, self.status = body, status
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def json(self, content_type=None):
|
||||
if isinstance(self.body, Exception):
|
||||
raise self.body
|
||||
return self.body
|
||||
|
||||
async def text(self):
|
||||
return "{}"
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self, zone_body):
|
||||
self.zone_body, self.asked = zone_body, []
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
def get(self, url, **kw):
|
||||
self.asked.append(url)
|
||||
self.timeouts = getattr(self, "timeouts", []) + [kw.get("timeout")]
|
||||
if url.endswith("/core/api/config"):
|
||||
return _Resp(self.zone_body)
|
||||
return _Resp({"data": {}})
|
||||
|
||||
|
||||
s = _Session({"time_zone": "Europe/Brussels"})
|
||||
check("ha_time_zone() reads /core/api/config's time_zone",
|
||||
asyncio.run(M.ha_time_zone(s, "tok")) == ("Europe/Brussels", "")
|
||||
and s.asked == [M.CORE_API + "/config"])
|
||||
# Review: HA can take minutes to start (2026-10-05); the ask must not wait on it.
|
||||
check("...with a time budget of at most 3 s, so a slow HA cannot hold up start",
|
||||
s.timeouts == [M.DOCS_LINK_TIMEOUT_S] and 0 < M.DOCS_LINK_TIMEOUT_S <= 3)
|
||||
check("...and a failure is None plus the reason, never a raise",
|
||||
asyncio.run(M.ha_time_zone(_Session(OSError("down")), "tok"))
|
||||
== (None, "OSError: down")
|
||||
and asyncio.run(M.ha_time_zone(_Session({}), "tok"))[0] is None)
|
||||
|
||||
|
||||
class _Built(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def amain_zone(zone_body, env_tz):
|
||||
"""Run the real amain() up to the Controller; the tz its machines get."""
|
||||
got, logs = {}, []
|
||||
|
||||
class _Capture(logging.Handler):
|
||||
def emit(self, rec):
|
||||
logs.append((rec.levelname, rec.getMessage()))
|
||||
|
||||
class _Hass:
|
||||
async def limits(self, e):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def mqtt_service(self):
|
||||
raise RuntimeError("no broker in this test")
|
||||
|
||||
real = {k: getattr(M, k) for k in ("load_options", "HomeAssistant",
|
||||
"validate_startup_config",
|
||||
"mode_announcement", "Controller")}
|
||||
real_session, real_store = M.aiohttp.ClientSession, None
|
||||
import app.store
|
||||
real_store = app.store.Store
|
||||
|
||||
def _controller(opts, hass, store, pub, *, stop, site_tz=None, **kw):
|
||||
got["tz"] = site_tz
|
||||
raise _Built()
|
||||
|
||||
M.load_options = lambda: {"setpoint_entity": "number.sp"}
|
||||
M.HomeAssistant = lambda session, **kw: _Hass()
|
||||
M.validate_startup_config = lambda *a, **k: []
|
||||
M.mode_announcement = lambda opts, store: ((), ())
|
||||
M.Controller = _controller
|
||||
M.aiohttp.ClientSession = lambda *a, **k: _Session(zone_body)
|
||||
app.store.Store = lambda *a, **k: object()
|
||||
old_tz = os.environ.pop("TZ", None)
|
||||
if env_tz:
|
||||
os.environ["TZ"] = env_tz
|
||||
h = _Capture()
|
||||
logging.getLogger("goodwe").addHandler(h)
|
||||
try:
|
||||
asyncio.run(M.amain())
|
||||
except _Built:
|
||||
pass
|
||||
finally:
|
||||
logging.getLogger("goodwe").removeHandler(h)
|
||||
for k, v in real.items():
|
||||
setattr(M, k, v)
|
||||
M.aiohttp.ClientSession = real_session
|
||||
app.store.Store = real_store
|
||||
os.environ.pop("TZ", None)
|
||||
if old_tz is not None:
|
||||
os.environ["TZ"] = old_tz
|
||||
return got.get("tz"), [m for lvl, m in logs if "Maintenance start" in m], logs
|
||||
|
||||
|
||||
MT._load_zone = lambda n: ((_FAKE[n], "") if n in _FAKE
|
||||
else (None, "none given"))
|
||||
try:
|
||||
tz, lines, _ = amain_zone({"time_zone": "Europe/Brussels"}, "America/New_York")
|
||||
check("amain(): Home Assistant's zone reaches the Controller",
|
||||
tz is _FAKE["Europe/Brussels"])
|
||||
check("...logged once, naming the zone and the source",
|
||||
lines == ["Maintenance start hour is local time in Europe/Brussels, "
|
||||
"from Home Assistant."])
|
||||
tz, lines, _ = amain_zone(OSError("down"), "America/New_York")
|
||||
check("amain(), Home Assistant unavailable: the container's TZ, said "
|
||||
"plainly", tz is _FAKE["America/New_York"]
|
||||
and lines == ["Maintenance start hour is local time in "
|
||||
"America/New_York, from the add-on's time zone. Home "
|
||||
"Assistant gave none: OSError: down."])
|
||||
tz, lines, logs = amain_zone(OSError("down"), None)
|
||||
check("amain(), neither: UTC, with a WARNING line",
|
||||
tz is UTC and any(lvl == "WARNING" and "uses UTC" in m
|
||||
for lvl, m in logs))
|
||||
# Review: HA still starting (502) and the Supervisor's TZ is UTC.
|
||||
tz, lines, logs = amain_zone(OSError("502"), "UTC")
|
||||
check("amain(), Home Assistant unavailable and TZ is UTC: a WARNING "
|
||||
"that says to restart", tz is UTC
|
||||
and [(lvl, m) for lvl, m in logs if "Maintenance start" in m]
|
||||
== [("WARNING", "Maintenance start hour uses UTC, because Home "
|
||||
"Assistant did not answer. Restart the add-on when it is up.")])
|
||||
tz, lines, logs = amain_zone(OSError("502"), "America/New_York")
|
||||
check("...but a real TZ is used without a warning",
|
||||
not any(lvl == "WARNING" for lvl, m in logs
|
||||
if "Maintenance start" in m))
|
||||
finally:
|
||||
MT._load_zone = _real_load
|
||||
|
||||
|
||||
# =========================================================================== #
|
||||
print("9. nothing else in control moves: the same start instant, the same "
|
||||
"commands")
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import safety78_identity as s78 # noqa: E402
|
||||
|
||||
# rig's clock starts 2026-09-20 11:00 UTC = 13:00 CEST. The same drain,
|
||||
# asked for as UTC hour 11 (the old reading) and as local hour 13 (the new).
|
||||
|
||||
|
||||
def rows_with(start_hour, tz, n=1, both=False):
|
||||
"""The rig's rows; `both=True` also returns the Controller."""
|
||||
real_drain, real_ctl = s78.DRAIN, rig.Controller
|
||||
s78.DRAIN = {"maintenance_enabled": True,
|
||||
"maintenance_start_hour": start_hour}
|
||||
rig.Controller = functools.partial(real_ctl, site_tz=tz)
|
||||
try:
|
||||
# s78.run() wraps rig.Controller once more for its own tariff db.
|
||||
rows, c = s78.run(n, dur=600.0)
|
||||
return (rows, c) if both else rows
|
||||
finally:
|
||||
s78.DRAIN, rig.Controller = real_drain, real_ctl
|
||||
|
||||
|
||||
_utc11 = rows_with(11, None)
|
||||
_loc13 = rows_with(13, _eu)
|
||||
check("precondition: the drain really starts in the UTC-11 run",
|
||||
any("drain" in r[-1][4] for r in _utc11))
|
||||
check(f"start hour 11 UTC and 13 Europe/Brussels: every command, claim, "
|
||||
f"reason and log line identical over {len(_utc11)} cycles",
|
||||
_utc11 == _loc13)
|
||||
_utc12 = rows_with(12, None)
|
||||
_loc14 = rows_with(14, _eu)
|
||||
check("precondition: no drain in the not-yet-due-hour run",
|
||||
not any("drain" in r[-1][4] for r in _utc12))
|
||||
check("outside the start hour (12 UTC / 14 local): identical as well",
|
||||
_utc12 == _loc14)
|
||||
check("...and the zone matters: hour 11 read in Brussels does NOT start",
|
||||
not any("drain" in r[-1][4] for r in rows_with(11, _eu)))
|
||||
|
||||
# =========================================================================== #
|
||||
print("10. two inverters: every pack reads the start hour in local time")
|
||||
_r2u, _c2u = rows_with(11, None, n=2, both=True)
|
||||
_r2l, _c2l = rows_with(13, _eu, n=2, both=True)
|
||||
check("N=2: both packs get the site's zone, not just the first",
|
||||
len(_c2l.maints) == 2 and all(mm.tz is _eu for mm in _c2l.maints))
|
||||
check("N=2 precondition: a drain starts in the UTC-11 run",
|
||||
any(DRAIN in r[-1][4] for r in _r2u))
|
||||
check(f"N=2: start hour 11 UTC and 13 Europe/Brussels identical over "
|
||||
f"{len(_r2u)} cycles", _r2u == _r2l)
|
||||
check("N=2: hour 11 read in Brussels does NOT start either pack",
|
||||
not any(DRAIN in r[-1][4] for r in rows_with(11, _eu, n=2)))
|
||||
|
||||
|
||||
def pair_starts(day0, days, hour, tz, cycle_min=70):
|
||||
"""Two due packs, ticked each minute through `arbitrate()` as main.py
|
||||
does; a cycle takes about `cycle_min` minutes. `(pack, "DD HH:MM" UTC)`s."""
|
||||
cfg = MaintConfig(enabled=True, start_hour=hour, hold_min=10)
|
||||
ms = [Maintenance(cfg, FakeStore(), name=u, tz=tz)
|
||||
for u in ("A", "B")]
|
||||
ms[0].store.set_time("last_completed", day0 - timedelta(days=60))
|
||||
ms[1].store.set_time("last_completed", day0 - timedelta(days=40))
|
||||
half = (cycle_min - 10) // 2
|
||||
soc, out = [80.0, 80.0], []
|
||||
t = day0 - timedelta(hours=3)
|
||||
while t < day0 + timedelta(days=days):
|
||||
blocks = MT.arbitrate(ms, t)
|
||||
for i, mm in enumerate(ms):
|
||||
was = mm.phase
|
||||
mm.tick(t, soc=soc[i], blocked_by=blocks[i])
|
||||
if mm.phase == DRAIN:
|
||||
soc[i] -= (80.0 - cfg.soc_floor) / half
|
||||
elif mm.phase == MT.CHARGE:
|
||||
soc[i] += (cfg.soc_target - cfg.soc_floor) / half
|
||||
if mm.phase == DRAIN and was != DRAIN:
|
||||
out.append((mm.name, t))
|
||||
t += timedelta(minutes=1)
|
||||
return [(n, x.strftime("%d %H:%M")) for n, x in out]
|
||||
|
||||
|
||||
def _at(*pairs):
|
||||
return [(n, x.strftime("%d %H:%M")) for n, x in pairs]
|
||||
|
||||
|
||||
for label, tz in ZONES:
|
||||
# 24 Oct (CEST) and 25 Oct (CET): 19:00 local is 17:00 then 18:00 UTC.
|
||||
got = pair_starts(AUTUMN - timedelta(days=1), 2, 19, tz)
|
||||
check(f"[{label}] N=2 across the autumn change, hour 19: A at 19:00 "
|
||||
f"local on the 24th, B at 19:00 local on the 25th {got}",
|
||||
got == _at(("A", AUTUMN - timedelta(hours=7)),
|
||||
("B", AUTUMN + timedelta(hours=18))))
|
||||
got = pair_starts(SPRING27 - timedelta(days=1), 2, 19, tz)
|
||||
check(f"[{label}] N=2 across the spring 2027 change, hour 19: 18:00 UTC "
|
||||
f"then 17:00 UTC {got}",
|
||||
got == _at(("A", SPRING27 - timedelta(hours=6)),
|
||||
("B", SPRING27 + timedelta(hours=17))))
|
||||
# Hour 2 on 25 Oct: A takes the first pass (00:00 UTC) and is done in its
|
||||
# second pass. B must not start there - it waits for the next day.
|
||||
got = pair_starts(AUTUMN, 2, 2, tz)
|
||||
check(f"[{label}] N=2, autumn, hour 2: B does not start in the repeated "
|
||||
f"hour after A is done; it starts at 02:00 CET on the 26th {got}",
|
||||
got == _at(("A", AUTUMN),
|
||||
("B", AUTUMN + timedelta(days=1, hours=1))))
|
||||
got = pair_starts(SPRING27, 2, 2, tz)
|
||||
check(f"[{label}] N=2, spring 2027, hour 2: A at 03:00 CEST (01:00 UTC), "
|
||||
f"B the next night at 02:00 CEST (00:00 UTC) {got}",
|
||||
got == _at(("A", SPRING27 + timedelta(hours=1)),
|
||||
("B", SPRING27 + timedelta(days=1))))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -1,405 +0,0 @@
|
||||
"""MAINT-11: start or stop the maintenance cycle of ONE battery.
|
||||
`python3 test_maint11.py`
|
||||
|
||||
Graded here, at two and at three batteries: a start on a chosen battery with the
|
||||
slot free (and it is that battery, not the one the bare button would pick); a
|
||||
start with the slot held, in each moving phase, refused by name; an unknown
|
||||
unit; a battery switched off; a battery that refuses to give power; a stop on a
|
||||
battery that is not cycling; a stop on the cycling one; a request with no unit
|
||||
doing what it always did; the web layer's codes; the words in both languages;
|
||||
and a roster of one, whose requests, log and writes are pinned.
|
||||
See DECISIONS/MAINT-11-cycle-per-battery.md.
|
||||
|
||||
Mutating what this file grades? Read MUTATION.md first (stale bytecode).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web as aioweb
|
||||
|
||||
from app import messages, packmode, web
|
||||
from app.main import Controller
|
||||
from app.maintenance import CHARGE, DRAIN, HOLD, IDLE, POWER_MOVING
|
||||
from app.store import DEFAULTS
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
class MemStore:
|
||||
def __init__(self):
|
||||
self.data = dict(DEFAULTS)
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get_time(self, k):
|
||||
raw = self.data.get(k)
|
||||
if not raw:
|
||||
return None
|
||||
if isinstance(raw, datetime):
|
||||
return raw
|
||||
parsed = datetime.fromisoformat(raw)
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
||||
|
||||
def set_time(self, k, when):
|
||||
self.data[k] = when.astimezone(timezone.utc).isoformat() if when else None
|
||||
|
||||
def save(self):
|
||||
pass
|
||||
|
||||
|
||||
class Hass:
|
||||
session = None
|
||||
token = ""
|
||||
|
||||
def __init__(self, n):
|
||||
self.numbers = {"sensor.grid": 5000.0}
|
||||
self.limits_by = {}
|
||||
for k in range(n):
|
||||
self.numbers[f"sensor.soc_{k}"] = 80.0
|
||||
self.numbers[f"sensor.batt_{k}"] = 0.0
|
||||
self.limits_by[f"number.sp_{k}"] = (-5000.0, 5000.0, 1.0)
|
||||
self.writes = []
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
v = self.numbers.get(entity)
|
||||
return None if v is None else (-v if invert else v)
|
||||
|
||||
async def limits(self, entity):
|
||||
return self.limits_by.get(entity, (None, None, None))
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
self.writes.append((entity, value))
|
||||
return True
|
||||
|
||||
async def call(self, domain, service, data):
|
||||
return True
|
||||
|
||||
|
||||
class Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, *a, **k):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
NAMES = ["inverter-a", "inverter-b", "inverter-c"]
|
||||
OPTS = dict(meter_source="off", meter_entity="sensor.grid", gain=0.6,
|
||||
slew_w=6000, deadband_w=15, target_grid_w=0, step_w=10,
|
||||
heartbeat_s=10, stale_input_s=15, maintenance_discharge_w=2000,
|
||||
maintenance_charge_w=2000)
|
||||
|
||||
|
||||
def controller(n, modes=False):
|
||||
opts = dict(OPTS)
|
||||
opts["inverters"] = [
|
||||
dict(name=NAMES[k], setpoint_entity=f"number.sp_{k}",
|
||||
soc_entity=f"sensor.soc_{k}", batt_entity=f"sensor.batt_{k}",
|
||||
max_w=5000.0) for k in range(n)]
|
||||
if n == 1:
|
||||
del opts["inverters"]
|
||||
opts.update(setpoint_entity="number.sp_0", soc_entity="sensor.soc_0",
|
||||
batt_entity="sensor.batt_0", max_w=5000.0)
|
||||
if modes:
|
||||
opts["battery_modes_enabled"] = True
|
||||
hass = Hass(n)
|
||||
c = Controller(opts, hass, MemStore(), Pub(), stop=asyncio.Event())
|
||||
for inv in c.inverters:
|
||||
inv.dev_min, inv.dev_max = -inv.max_w, inv.max_w
|
||||
c.auto = True
|
||||
return c, hass
|
||||
|
||||
|
||||
def act(c, what, unit=None):
|
||||
if unit is None:
|
||||
return asyncio.run(c.handle_action(what))
|
||||
return asyncio.run(c.handle_action(what, unit=unit))
|
||||
|
||||
|
||||
def phases(c):
|
||||
return [m.phase for m in c.maints]
|
||||
|
||||
|
||||
def snapshot(c, hass):
|
||||
return (phases(c), list(c.events), list(hass.writes),
|
||||
json.dumps(c.store.data, sort_keys=True, default=str))
|
||||
|
||||
|
||||
def settle(c):
|
||||
"""Events carry a clock stamp; compare them without it."""
|
||||
return [e.split(" ", 1)[1] for e in c.events]
|
||||
|
||||
|
||||
for N in (2, 3):
|
||||
print(f"\n{N} batteries")
|
||||
names = NAMES[:N]
|
||||
last = N - 1
|
||||
|
||||
# --- start with the slot free -------------------------------------------
|
||||
c, hass = controller(N)
|
||||
bare_pick = c._maint_button(datetime.now(timezone.utc))
|
||||
check("with every battery never cycled the bare button picks the first "
|
||||
"(this is the card's case)", bare_pick is c.maints[0])
|
||||
r = act(c, "maint_start", names[last])
|
||||
check(f"start on {names[last]}: its cycle starts, nobody else's",
|
||||
r == {"unit": names[last], "phase": DRAIN}
|
||||
and phases(c) == [IDLE] * last + [DRAIN])
|
||||
check("...and the log says a forced start",
|
||||
any("forced start" in e for e in c.events))
|
||||
check("...and the machine holding the slot is the named one",
|
||||
c.maint_index == last)
|
||||
|
||||
c, hass = controller(N)
|
||||
r = act(c, "maint_start", names[0])
|
||||
check("start on the first battery works the same way",
|
||||
r["unit"] == names[0] and phases(c)[0] == DRAIN
|
||||
and phases(c)[1:] == [IDLE] * last)
|
||||
|
||||
# --- start with the slot held --------------------------------------------
|
||||
for phase in POWER_MOVING:
|
||||
for holder in range(N):
|
||||
c, hass = controller(N)
|
||||
c.maints[holder].store.set("phase", phase)
|
||||
before = snapshot(c, hass)
|
||||
bad = []
|
||||
for k in range(N):
|
||||
r = act(c, "maint_start", names[k])
|
||||
bad.append(not (r.get("code") == "busy"
|
||||
and r["unit"] == names[holder]
|
||||
and names[holder] in r["error"]
|
||||
and "error" in r))
|
||||
check(f"slot held by {names[holder]} in {phase}: a start on any "
|
||||
f"battery (itself too) is refused, naming {names[holder]}, "
|
||||
f"and nothing changes", not any(bad)
|
||||
and snapshot(c, hass) == before)
|
||||
|
||||
# --- unknown unit --------------------------------------------------------
|
||||
c, hass = controller(N)
|
||||
before = snapshot(c, hass)
|
||||
rs = [act(c, w, u) for w in ("maint_start", "maint_abort")
|
||||
for u in ("inverter-z", "", "Inverter-A", 7, ["inverter-a"])]
|
||||
check("an unknown unit (wrong name, empty, wrong case, not a string) is "
|
||||
"refused for start and for stop, and nothing changes",
|
||||
all(r.get("code") == "unit" and r["error"]
|
||||
== messages.maint_refused("unit") for r in rs)
|
||||
and snapshot(c, hass) == before)
|
||||
|
||||
# --- a battery switched off ---------------------------------------------
|
||||
c, hass = controller(N, modes=True)
|
||||
c.inverters[last].store_set(packmode.USE_KEY, False)
|
||||
before = snapshot(c, hass)
|
||||
r = act(c, "maint_start", names[last])
|
||||
check("a battery switched off (battery modes on) is refused by name and "
|
||||
"nothing changes", r.get("code") == "off" and r["unit"] == names[last]
|
||||
and names[last] in r["error"] and snapshot(c, hass) == before)
|
||||
r = act(c, "maint_start", names[0])
|
||||
check("...while a battery that is on still starts", r.get("phase") == DRAIN
|
||||
and phases(c)[0] == DRAIN)
|
||||
c, hass = controller(N)
|
||||
c.inverters[last].store_set(packmode.USE_KEY, False)
|
||||
check("with battery modes off the stored switch is not consulted (it is "
|
||||
"not shown, so it cannot refuse)",
|
||||
act(c, "maint_start", names[last]).get("phase") == DRAIN)
|
||||
|
||||
# --- a battery that refuses to give power ---------------------------------
|
||||
c, hass = controller(N)
|
||||
c.inverters[last].refused = 1
|
||||
r = act(c, "maint_start", names[last])
|
||||
check("a battery that refuses to give power starts like the bare button "
|
||||
"would start it (its drain then waits, as before)",
|
||||
r == {"unit": names[last], "phase": DRAIN})
|
||||
|
||||
# --- stop ---------------------------------------------------------------
|
||||
c, hass = controller(N)
|
||||
before = snapshot(c, hass)
|
||||
r = act(c, "maint_abort", names[0])
|
||||
check("a stop on a battery that is not cycling is refused and nothing "
|
||||
"changes (no 0 W written either)", r.get("code") == "idle"
|
||||
and names[0] in r["error"] and snapshot(c, hass) == before)
|
||||
c.maints[last].store.set("phase", CHARGE)
|
||||
before = snapshot(c, hass)
|
||||
r = act(c, "maint_abort", names[0])
|
||||
check(f"a stop on {names[0]} while {names[last]} cycles is refused, and "
|
||||
f"{names[last]}'s cycle is not touched", r.get("code") == "idle"
|
||||
and phases(c)[last] == CHARGE and snapshot(c, hass) == before)
|
||||
|
||||
for phase in POWER_MOVING:
|
||||
c, hass = controller(N)
|
||||
c.maints[last].store.set("phase", phase)
|
||||
r = act(c, "maint_abort", names[last])
|
||||
check(f"a stop on the cycling battery ({phase}) ends its cycle, "
|
||||
f"logs the abort and sets every inverter to 0 W",
|
||||
r == {"unit": names[last], "phase": IDLE}
|
||||
and phases(c) == [IDLE] * N
|
||||
and any("ABORT (operator)" in e for e in c.events)
|
||||
and {w for w in hass.writes}
|
||||
== {(f"number.sp_{k}", 0.0) for k in range(N)}
|
||||
and all(i.target == 0.0 for i in c.inverters))
|
||||
|
||||
# --- the old request ---------------------------------------------------
|
||||
c, hass = controller(N)
|
||||
r = act(c, "maint_start")
|
||||
check("a start with no unit is the old call: the bare button's pick, and "
|
||||
"the old answer", r == {"phase": DRAIN} and phases(c)[0] == DRAIN
|
||||
and any("forced start" in e for e in c.events))
|
||||
r = act(c, "maint_start")
|
||||
check("...a second bare start re-enters the drain of the cycling battery, "
|
||||
"as it always did", r == {"phase": DRAIN} and phases(c)[0] == DRAIN)
|
||||
c.maints[last].store.set("phase", IDLE)
|
||||
r = act(c, "maint_abort")
|
||||
check("...and a bare stop stops the cycling battery and answers as before",
|
||||
r == {"phase": IDLE} and phases(c) == [IDLE] * N
|
||||
and any("ABORT (operator)" in e for e in c.events))
|
||||
c, hass = controller(N)
|
||||
r = act(c, "maint_abort")
|
||||
check("...a bare stop with nothing cycling is still accepted, as before",
|
||||
r == {"phase": IDLE} and hass.writes
|
||||
and {w[1] for w in hass.writes} == {0.0})
|
||||
|
||||
# --- the web layer ----------------------------------------------------------
|
||||
print("\nthe web layer")
|
||||
|
||||
|
||||
async def serve(c, calls):
|
||||
runner = aioweb.AppRunner(web.build_app(c, object()))
|
||||
await runner.setup()
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
await aioweb.TCPSite(runner, "127.0.0.1", port).start()
|
||||
out = []
|
||||
try:
|
||||
async with aiohttp.ClientSession() as s:
|
||||
for body in calls:
|
||||
async with s.post(f"http://127.0.0.1:{port}/action",
|
||||
json=body) as r:
|
||||
out.append((r.status, await r.json(content_type=None)))
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
return out
|
||||
|
||||
|
||||
c, hass = controller(2)
|
||||
rs = asyncio.run(serve(c, [
|
||||
{"action": "maint_start", "unit": "inverter-b"},
|
||||
{"action": "maint_start", "unit": "inverter-a"},
|
||||
{"action": "maint_abort", "unit": "inverter-a"},
|
||||
{"action": "maint_start", "unit": "nope"},
|
||||
{"action": "maint_abort", "unit": "inverter-b"},
|
||||
{"action": "maint_start", "unit": None},
|
||||
{"action": "maint_abort"},
|
||||
{"action": "maint_start", "unit": ""},
|
||||
]))
|
||||
check("start with a unit: 200 and the unit's answer",
|
||||
rs[0] == (200, {"ok": True, "result": {"unit": "inverter-b",
|
||||
"phase": DRAIN}}))
|
||||
check("start while the slot is held: 409, a code, the cycling battery, the "
|
||||
"plain sentence", rs[1][0] == 409 and rs[1][1] == {
|
||||
"ok": False, "code": "busy", "unit": "inverter-b",
|
||||
"error": messages.maint_refused("busy", "inverter-b")})
|
||||
check("stop on a battery that is not cycling: 409 with the sentence",
|
||||
rs[2][0] == 409 and rs[2][1]["code"] == "idle"
|
||||
and rs[2][1]["error"] == messages.maint_refused("idle", "inverter-a"))
|
||||
check("unknown unit: 400 with the sentence", rs[3][0] == 400
|
||||
and rs[3][1]["code"] == "unit"
|
||||
and rs[3][1]["error"] == messages.maint_refused("unit"))
|
||||
check("stop on the cycling battery: 200", rs[4] == (
|
||||
200, {"ok": True, "result": {"unit": "inverter-b", "phase": IDLE}}))
|
||||
check("a null unit is the bare call: 200 and the old answer",
|
||||
rs[5] == (200, {"ok": True, "result": {"phase": DRAIN}}))
|
||||
check("a request with no unit: 200 and the old answer", rs[6] == (
|
||||
200, {"ok": True, "result": {"phase": IDLE}}))
|
||||
check("an empty unit is refused (400), not taken as the bare call",
|
||||
rs[7][0] == 400 and rs[7][1]["code"] == "unit"
|
||||
and phases(c) == [IDLE, IDLE])
|
||||
|
||||
# --- the words --------------------------------------------------------------
|
||||
print("\nthe words")
|
||||
words = {}
|
||||
for lang in ("en", "nl"):
|
||||
with open(os.path.join(HERE, "app", "ui_text", f"{lang}.json"),
|
||||
encoding="utf-8") as f:
|
||||
words[lang] = json.load(f)["home"]["batteries"]["cycle"]
|
||||
for code, sentence in messages.MAINT_REFUSED.items():
|
||||
en = words["en"][code]["description"]
|
||||
check(f"'{code}': the page's English is the API's sentence",
|
||||
en == sentence.replace("{unit}", "{name}"))
|
||||
nl = words["nl"][code]["description"]
|
||||
check(f"'{code}': the Dutch has the same placeholders",
|
||||
sorted(re.findall(r"\{\w+\}", nl))
|
||||
== sorted(re.findall(r"\{\w+\}", en)) and nl != en)
|
||||
check("both languages carry the same keys",
|
||||
json.dumps(words["en"], sort_keys=True).count('":')
|
||||
== json.dumps(words["nl"], sort_keys=True).count('":')
|
||||
and set(words["en"]) == set(words["nl"]))
|
||||
check("every code the server can answer has a page sentence",
|
||||
set(messages.MAINT_REFUSED) <= set(words["en"]))
|
||||
|
||||
# --- the page ----------------------------------------------------------------
|
||||
print("\nthe page")
|
||||
JS = open(os.path.join(HERE, "app", "static", "app.js"),
|
||||
encoding="utf-8").read()
|
||||
check("the buttons are drawn only with two or more batteries",
|
||||
"S.batteries.length > 1 ? cycleButtons(b, i) : null" in JS)
|
||||
check("both buttons post the battery's name and ask first",
|
||||
'JSON.stringify({ action: what, unit: b.name }) });' in JS and 'ask1("start_dialog", "maint_start")' in JS
|
||||
and 'ask1("abort_dialog", "maint_abort")' in JS)
|
||||
check("a refusal is shown in the page's language, by its code",
|
||||
'const own = j.code && t("batteries.cycle." + j.code);' in JS
|
||||
and 'toast(own ? say(own.description' in JS)
|
||||
check("Stop is disabled on a battery that is not cycling",
|
||||
'abort: cycling(b) };' in JS and "disabled: !can.abort" in JS)
|
||||
check("Start is disabled while any battery cycles",
|
||||
"start: !S.batteries.some(cycling) && " in JS
|
||||
and "disabled: !can.start" in JS)
|
||||
check("Start is disabled on a switched-off battery (use false, modes on)",
|
||||
"&& b.use !== false, abort" in JS)
|
||||
check("each button carries its own flag, and only that flag",
|
||||
'id: "bcs-" + i, disabled: !can.start, onclick: ask1("start_dialog"' in JS
|
||||
and 'id: "bca-" + i, disabled: !can.abort, onclick: ask1("abort_dialog"' in JS)
|
||||
check("a battery counts as cycling when its phase is not idle",
|
||||
'cycling = (x) => x.maintenance !== "idle"' in JS)
|
||||
check("the old header buttons still post a bare request",
|
||||
'ask("maint_start", () => simple("maint_start"))' in JS
|
||||
and "body: JSON.stringify({ action: what }) });" in JS)
|
||||
|
||||
# --- a roster of one -----------------------------------------------------------
|
||||
print("\none battery: the requests, the log and the wire stay as they were")
|
||||
c, hass = controller(1)
|
||||
r1 = act(c, "maint_start")
|
||||
r2 = act(c, "maint_start")
|
||||
r3 = act(c, "maint_abort")
|
||||
r4 = act(c, "maint_abort")
|
||||
check("answers", [r1, r2, r3, r4] == [{"phase": DRAIN}, {"phase": DRAIN},
|
||||
{"phase": IDLE}, {"phase": IDLE}])
|
||||
check("log lines (stamp removed), newest first", settle(c) == [
|
||||
"maintenance: ABORT (operator)", "maintenance: ABORT (operator)",
|
||||
"maintenance: forced start -> drain",
|
||||
"maintenance: forced start -> drain"][:len(settle(c))]
|
||||
and len(settle(c)) == 4)
|
||||
check("wire: two stops, each 0 W", hass.writes == [("number.sp_0", 0.0)] * 2)
|
||||
c, hass = controller(1)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
sys.exit(1)
|
||||
print("all passed")
|
||||
@@ -1,574 +0,0 @@
|
||||
"""MAINT-13: a full pack that stops at 98-99 % is not a failed charge.
|
||||
`python3 test_maint13.py`
|
||||
|
||||
Graded here: the stall rule, the near-full timeout rule, the 7 day back-off
|
||||
after a failed cycle (and what ends it), the timeout counting only the minutes
|
||||
the price gate was open, restart and old-state-file behaviour, the notification
|
||||
(once), and the Controller wiring at one and two packs.
|
||||
See DECISIONS/MAINT-13-full-pack-is-complete.md.
|
||||
|
||||
Mutating what this file grades? Read MUTATION.md first (stale bytecode).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app import messages
|
||||
from app.maintenance import (
|
||||
CHARGE, CHARGE_NEAR_FULL_PCT, CHARGE_STALL_MIN, CHARGE_TIMEOUT_MIN,
|
||||
CHARGE_WALL_TIMEOUT_MIN, DRAIN, FAIL_BACKOFF_DAYS, HOLD, IDLE,
|
||||
MaintConfig, Maintenance, arbitrate,
|
||||
)
|
||||
from app.main import Controller
|
||||
from app.store import (DEFAULTS, MAINT_UNIT_KEYS, Store, migrate_maint_keys,
|
||||
unit_key)
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
UTC = timezone.utc
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
# The live site: target 100, shipped hold. Start hour 19 UTC for the schedule.
|
||||
CFG = MaintConfig(enabled=True, interval_days=28, start_hour=19,
|
||||
discharge_w=2000, charge_w=2000, soc_floor=11,
|
||||
soc_target=100, hold_min=120)
|
||||
T0 = datetime(2026, 10, 4, 18, 24, tzinfo=UTC) # B's drain ended (live)
|
||||
MIN = timedelta(minutes=1)
|
||||
|
||||
|
||||
class Mem:
|
||||
"""Store's surface, no disk; times kept as the real Store keeps them."""
|
||||
|
||||
def __init__(self, data=None):
|
||||
self.data = dict(DEFAULTS, **(data or {}))
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def set_time(self, k, when):
|
||||
self.data[k] = when.astimezone(UTC).isoformat() if when else None
|
||||
|
||||
def get_time(self, k):
|
||||
raw = self.data.get(k)
|
||||
return datetime.fromisoformat(raw) if raw else None
|
||||
|
||||
def save(self):
|
||||
pass
|
||||
|
||||
|
||||
def in_charge(store=None, cfg=CFG, at=T0):
|
||||
"""A pack that has just finished its drain and is charging at `at`."""
|
||||
m = Maintenance(cfg, store or Mem())
|
||||
m.force_start(at)
|
||||
m.tick(at, 11.0) # drain complete -> charge
|
||||
assert m.phase == CHARGE
|
||||
return m
|
||||
|
||||
|
||||
def run(m, start, minutes, soc, gate=True, step=1):
|
||||
"""Tick every `step` minutes for `minutes`; `soc` is a number or f(minute).
|
||||
Returns (the last result, the time after the run, the first non-CHARGE result)."""
|
||||
t, res, out = start, None, None
|
||||
for i in range(0, minutes, step):
|
||||
t = start + i * MIN
|
||||
s = soc(i) if callable(soc) else soc
|
||||
res = m.tick(t, s, gate_open=gate)
|
||||
if out is None and res.phase != CHARGE:
|
||||
out = (res, t)
|
||||
break
|
||||
return res, start + minutes * MIN, out
|
||||
|
||||
|
||||
print("1. the stall rule: near full and no longer taking charge")
|
||||
# Live record: 99 % at 04:00, back to 98 % at 04:15, never 100.
|
||||
m = in_charge()
|
||||
res, end, out = run(m, T0 + 60 * MIN, 400, lambda i: 99.0 if i < 15 else 98.0)
|
||||
check("a pack that reaches 99 and falls back to 98 completes (HOLD) after "
|
||||
f"{CHARGE_STALL_MIN} min without a new high",
|
||||
out is not None and out[0].phase == HOLD)
|
||||
check("...at 60 min after the last new high (the 99), not before",
|
||||
out is not None and out[1] == T0 + 120 * MIN)
|
||||
check("...with one plain log line",
|
||||
out is not None and len(out[0].events) == 1
|
||||
and "takes no more charge at 98 %" in out[0].events[0])
|
||||
m = in_charge()
|
||||
res, _, out = run(m, T0, 59, 98.0)
|
||||
check("59 minutes flat is not enough", out is None and res.phase == CHARGE)
|
||||
m = in_charge()
|
||||
res, _, out = run(m, T0, 61, lambda i: 97.0)
|
||||
check("target - 3 (97 of 100) counts as near full", out and out[0].phase == HOLD)
|
||||
m = in_charge()
|
||||
res, _, out = run(m, T0, 300, lambda i: 96.0)
|
||||
check("target - 4 (96 of 100) does not complete however long it is flat",
|
||||
out is None)
|
||||
cfg99 = MaintConfig(enabled=True, start_hour=19, soc_target=99, hold_min=120)
|
||||
m = in_charge(cfg=cfg99)
|
||||
res, _, out = run(m, T0, 61, lambda i: 96.0)
|
||||
check("the shipped target 99 works the same way (96 is near full)",
|
||||
out and out[0].phase == HOLD)
|
||||
m = in_charge()
|
||||
res, _, out = run(m, T0, 400, lambda i: min(99.0, 94.0 + i // 30))
|
||||
check("a pack still rising 1 point per 30 min is not stalled: it completes 60 "
|
||||
"min after the 99 at minute 150", out and out[1] == T0 + 210 * MIN)
|
||||
m = in_charge()
|
||||
res, _, out = run(m, T0, 130, lambda i: 97.0 if i < 50 else (98.0 if i < 100 else 98.0))
|
||||
check("a new high resets the wait: 97 flat for 50 min, 98 at 50 min -> "
|
||||
"completes at 110, not 60", out and out[1] == T0 + 110 * MIN)
|
||||
m = in_charge()
|
||||
res, _, out = run(m, T0, 300, lambda i: 97.0, gate=False)
|
||||
check("minutes with the gate closed do not count as 'no more charge'",
|
||||
out is None)
|
||||
m = in_charge()
|
||||
_, t, _ = run(m, T0, 30, 98.0)
|
||||
_, t, _ = run(m, t, 120, 98.0, gate=False)
|
||||
res, _, out = run(m, t, 31, 98.0)
|
||||
check("30 open + 120 closed + 31 open minutes complete (the closed ones "
|
||||
"pause the count)", out and out[0].phase == HOLD)
|
||||
|
||||
print("2. SoC at the target completes at once")
|
||||
m = in_charge()
|
||||
r = m.tick(T0 + MIN, 100.0)
|
||||
check("100 of 100 -> HOLD on that tick", r.phase == HOLD)
|
||||
check("...with the old log line", "charge complete at 100 %" in r.events[0])
|
||||
m = in_charge(cfg=cfg99)
|
||||
check("99 of 99 -> HOLD", m.tick(T0 + MIN, 99.0).phase == HOLD)
|
||||
|
||||
print("3. a charge that runs out of time")
|
||||
m = in_charge()
|
||||
res, _, out = run(m, T0, CHARGE_TIMEOUT_MIN + 10,
|
||||
lambda i: 60.0 + i / 55.0, step=1)
|
||||
check("still rising 1 point per 55 min (never 60 flat) and near full when the "
|
||||
"time is up -> counts as complete", out and out[0].phase == HOLD)
|
||||
check("...with one plain line saying so",
|
||||
out and len(out[0].events) == 1 and "charge time is up" in out[0].events[0]
|
||||
and "counts as done" in out[0].events[0])
|
||||
check("...and no failure is recorded", m.store.get_time("last_failed") is None)
|
||||
m = in_charge()
|
||||
res, _, out = run(m, T0, CHARGE_TIMEOUT_MIN + 10, 70.0)
|
||||
check("a pack stuck at 70 % fails when the time is up",
|
||||
out and out[0].phase == IDLE and out[0].failed == "charge")
|
||||
check("...once: the failure is on one tick, and the pack is idle after",
|
||||
m.phase == IDLE and m.tick(out[1] + MIN, 70.0).failed == "")
|
||||
check("...with the old technical line and one plain line",
|
||||
len(out[0].events) == 2 and "ABORT" in out[0].events[0]
|
||||
and "did not finish" in out[0].events[1]
|
||||
and f"{FAIL_BACKOFF_DAYS} days" in out[0].events[1])
|
||||
check("...and last_failed is set, last_completed is not",
|
||||
m.store.get_time("last_failed") == out[1]
|
||||
and m.store.get_time("last_completed") is None)
|
||||
|
||||
print("4. the back-off after a failed cycle")
|
||||
fail_at = out[1]
|
||||
day = lambda n: fail_at.replace(hour=19, minute=0) + timedelta(days=n)
|
||||
check("the next day at the start hour: no cycle (it would begin with a drain)",
|
||||
not m.wants_start(day(1)) and m.tick(day(1), 70.0).phase == IDLE)
|
||||
check("every day for 6 days: still no cycle",
|
||||
all(not m.wants_start(day(n)) for n in range(1, 7)))
|
||||
r = [m.tick(day(n), 70.0) for n in range(1, 7)]
|
||||
check("...and tick() keeps the pack idle and silent",
|
||||
all(x.phase == IDLE and not x.events and x.failed == "" for x in r))
|
||||
until = m.backoff_until(day(1))
|
||||
check("backoff_until() names the date, 7 days after the failure",
|
||||
until == fail_at + timedelta(days=FAIL_BACKOFF_DAYS))
|
||||
check("...it holds one minute before and is over at that minute",
|
||||
m.backoff_until(until - MIN) == until and m.backoff_until(until) is None)
|
||||
check("at 7 days, in the start hour, the pack wants to start again",
|
||||
m.backoff_until(until) is None
|
||||
and m.wants_start(until.replace(hour=19, minute=5)))
|
||||
check("a forced start ignores the back-off",
|
||||
m.force_start(day(1)) and m.phase == DRAIN)
|
||||
m2 = in_charge()
|
||||
run(m2, T0, CHARGE_TIMEOUT_MIN + 10, 70.0)
|
||||
m2.force_start(day(2))
|
||||
m2.tick(day(2) + MIN, 11.0)
|
||||
res, _, out2 = run(m2, day(2) + 2 * MIN, 400, 100.0)
|
||||
check("a forced cycle that completes ends the back-off",
|
||||
out2 and out2[0].phase == HOLD)
|
||||
m2.tick(out2[1] + 130 * MIN, 100.0)
|
||||
check("...(the hold completes, last_completed is set)",
|
||||
m2.store.get_time("last_completed") is not None
|
||||
and m2.backoff_until(day(3)) is None)
|
||||
m3 = in_charge()
|
||||
m3.store.set_time("last_failed", T0)
|
||||
m3.abort(T0 + MIN, "operator")
|
||||
check("an operator abort is not a failure and starts no back-off",
|
||||
m3.store.get_time("last_failed") == T0
|
||||
and in_charge().store.get_time("last_failed") is None)
|
||||
m4 = Maintenance(CFG, Mem())
|
||||
m4.force_start(T0)
|
||||
r = m4.tick(T0 + timedelta(minutes=721), 40.0)
|
||||
check("a drain that times out is a failure too, with the same back-off",
|
||||
r.failed == "drain" and m4.store.get_time("last_failed") is not None
|
||||
and not m4.wants_start(day(1)))
|
||||
|
||||
print("5. only the minutes the pack was allowed to charge count")
|
||||
m = in_charge()
|
||||
# 5 min steps: one tick counts at most 5 min, so this is the same count as
|
||||
# 1 min steps in a fifth of the ticks.
|
||||
res, t, out = run(m, T0, CHARGE_TIMEOUT_MIN * 2, 70.0, gate=False, step=5)
|
||||
check("72 h with the gate closed: not timed out", out is None and m.phase == CHARGE)
|
||||
res, t, out = run(m, t, CHARGE_TIMEOUT_MIN - 5, 70.0, step=5)
|
||||
check("...and 35 h 55 min open on top: still charging", out is None)
|
||||
res, t, out = run(m, t, 10, 70.0)
|
||||
check("...36 h of open time in all: the charge fails", out and out[0].failed == "charge")
|
||||
m = in_charge()
|
||||
WALL = T0 + CHARGE_WALL_TIMEOUT_MIN * MIN
|
||||
res, t, out = run(m, T0, CHARGE_WALL_TIMEOUT_MIN + 10, 70.0, gate=False, step=5)
|
||||
check("a gate that never opens still ends in a failure after 7 days (not before)",
|
||||
out and out[0].failed == "charge" and WALL < out[1] <= WALL + 5 * MIN)
|
||||
m = in_charge()
|
||||
res, t, out = run(m, T0, CHARGE_WALL_TIMEOUT_MIN + 10, 98.0, gate=False, step=5)
|
||||
check("...or in a complete charge when the pack is near full",
|
||||
out and out[0].phase == HOLD and WALL < out[1] <= WALL + 5 * MIN)
|
||||
m = in_charge()
|
||||
res, t, out = run(m, T0, 10, 70.0)
|
||||
before = m._trk["open_min"]
|
||||
res = m.tick(t + 2 * timedelta(hours=1), 70.0)
|
||||
check("a gap in the ticks (add-on down) counts for exactly 5 minutes (the cap)",
|
||||
abs(m._trk["open_min"] - before - 5.0) < 1e-9)
|
||||
m.tick(t + 2 * timedelta(hours=1) + 2 * MIN, 70.0)
|
||||
check("...and a 2 minute step counts 2", abs(m._trk["open_min"] - before - 7.0) < 1e-9)
|
||||
_t = t + 2 * timedelta(hours=1) + 2 * MIN
|
||||
m.tick(_t - 10 * MIN, 70.0) # the clock steps back 10 min
|
||||
check("...and a step back in time counts nothing (not minus)",
|
||||
abs(m._trk["open_min"] - before - 7.0) < 1e-9)
|
||||
|
||||
print("6. restarts and old state files")
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "state.json")
|
||||
st = Store(path)
|
||||
m = Maintenance(CFG, st)
|
||||
m.force_start(T0)
|
||||
m.tick(T0, 11.0)
|
||||
_, t, out = run(m, T0, 60, 70.0, gate=False) # 60 min waiting
|
||||
_, t, out = run(m, t, 40, 70.0) # 40 min charging
|
||||
open_before = m._trk["open_min"]
|
||||
# a fresh process on the same file
|
||||
m_b = Maintenance(CFG, Store(path))
|
||||
r = m_b.tick(t + MIN, 70.0)
|
||||
check("a restart mid-charge keeps the open minutes (40 of 100 min; within "
|
||||
"the 5 min save interval), not the 100 wall-clock minutes",
|
||||
m_b.phase == CHARGE
|
||||
and open_before - 6 <= m_b._trk["open_min"] <= open_before + 2
|
||||
and m_b._trk["open_min"] < 60)
|
||||
_, t2, out = run(m_b, t + 2 * MIN, CHARGE_TIMEOUT_MIN - 30, 70.0, step=5)
|
||||
check("...so the timeout comes after 36 h in all, not 36 h from the restart",
|
||||
out is not None and out[0].failed == "charge")
|
||||
m = Maintenance(CFG, Store(path))
|
||||
m.store.set_time("last_failed", T0)
|
||||
m_c = Maintenance(CFG, Store(path))
|
||||
check("a restart mid back-off keeps it (last_failed is in the file)",
|
||||
m_c.backoff_until(T0 + timedelta(days=3)) == T0 + timedelta(days=7)
|
||||
and not m_c.wants_start((T0 + timedelta(days=3)).replace(hour=19)))
|
||||
check("...and the state file is plain JSON",
|
||||
isinstance(json.load(open(path)).get("last_failed"), str))
|
||||
old = {"phase": "charge",
|
||||
"phase_started": (T0 - timedelta(hours=35)).isoformat(),
|
||||
"last_completed": None, "last_start_attempt": None,
|
||||
"auto": True}
|
||||
path2 = os.path.join(d, "old.json")
|
||||
json.dump(old, open(path2, "w"))
|
||||
mo = Maintenance(CFG, Store(path2))
|
||||
check("a state file from before this change loads with no failure and no "
|
||||
"charge record", mo.backoff_until(T0) is None
|
||||
and mo.store.get("charge_track") is None and mo.phase == CHARGE)
|
||||
r = mo.tick(T0, 70.0)
|
||||
check("...a charge in progress counts the hours it already ran (35 h)",
|
||||
r.phase == CHARGE and 34 * 60 < mo._trk["open_min"] < 36 * 60)
|
||||
res, _, out = run(mo, T0 + MIN, 120, 70.0)
|
||||
check("...so it fails when the 36 h are up, as before",
|
||||
out and out[0].failed == "charge")
|
||||
# A record left by another charge (an add-on rolled back and forward
|
||||
# again does not clear it) must not be read as this charge's.
|
||||
stale = {"phase": "charge",
|
||||
"phase_started": (T0 - timedelta(hours=2)).isoformat(),
|
||||
"charge_track": {"since": (T0 - timedelta(days=30)).isoformat(),
|
||||
"open_min": 2000.0, "flat_min": 59.0,
|
||||
"best": 99.0, "at": None}}
|
||||
ms = Maintenance(CFG, Mem(stale))
|
||||
r = ms.tick(T0, 98.0)
|
||||
check("a charge record from another charge is ignored: this charge counts "
|
||||
"its own 2 h and its own best",
|
||||
r.phase == CHARGE and 119 < ms._trk["open_min"] < 121
|
||||
and ms._trk["flat_min"] == 0.0 and ms._trk["best"] == 98.0)
|
||||
mi = Maintenance(CFG, Store(os.path.join(d, "none.json")))
|
||||
check("no state file: due, no back-off", mi.due(T0) and mi.backoff_until(T0) is None)
|
||||
|
||||
print("7. two packs (N=2)")
|
||||
a, b = Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a"), \
|
||||
Maintenance(CFG, Mem(), unit="inverter-b", name="inverter-b")
|
||||
b.force_start(T0)
|
||||
b.tick(T0, 11.0)
|
||||
_, t, out = run(b, T0, CHARGE_TIMEOUT_MIN + 10, 70.0)
|
||||
slot = datetime(2026, 10, 7, 19, 5, tzinfo=UTC)
|
||||
check("B failed: B is backed off, A (due) is not",
|
||||
out and out[0].failed == "charge" and not b.wants_start(slot)
|
||||
and a.wants_start(slot))
|
||||
check("...so A takes the slot, and B is not named as waiting for anyone",
|
||||
arbitrate([a, b], slot) == ["", ""])
|
||||
a.force_start(slot)
|
||||
check("...and while A cycles, B is held as before",
|
||||
"inverter-a" in arbitrate([a, b], slot + 3 * MIN)[1])
|
||||
check("B's failure is under B's own keys, not A's",
|
||||
b.store.get_time("last_failed") is not None
|
||||
and a.store.get_time("last_failed") is None)
|
||||
mst = Mem({"last_failed": T0.isoformat(), "phase": "idle"})
|
||||
migrate_maint_keys(mst, "inverter-a", ["inverter-a", "inverter-b"])
|
||||
check("going from one pack to two moves the back-off to the first pack",
|
||||
mst.data.get(unit_key("inverter-a", "last_failed")) == T0.isoformat()
|
||||
and "last_failed" in MAINT_UNIT_KEYS)
|
||||
|
||||
|
||||
print("8. the Controller: gate, notification, status row")
|
||||
|
||||
|
||||
class Hass:
|
||||
def __init__(self, numbers, limits):
|
||||
self.numbers, self.limits_by, self.calls = dict(numbers), dict(limits), []
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
v = self.numbers.get(entity)
|
||||
return None if v is None else (-v if invert else v)
|
||||
|
||||
async def limits(self, entity):
|
||||
return self.limits_by.get(entity, (None, None, None))
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
return True
|
||||
|
||||
async def call(self, domain, service, data):
|
||||
self.calls.append((domain, service, data))
|
||||
return True
|
||||
|
||||
|
||||
class Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, *a, **k):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
OPTS1 = dict(setpoint_entity="number.sp_a", soc_entity="sensor.soc_a",
|
||||
batt_entity="sensor.batt_a", meter_source="off",
|
||||
meter_entity="sensor.grid", max_w=5000.0, gain=0.6, slew_w=6000,
|
||||
deadband_w=15, target_grid_w=0, step_w=10, heartbeat_s=10,
|
||||
stale_input_s=15, maintenance_discharge_w=2000,
|
||||
maintenance_charge_w=2000, maintenance_enabled=True,
|
||||
maintenance_soc_target=100,
|
||||
price_now_entity="sensor.p_now", price_avg_entity="sensor.p_avg")
|
||||
OPTS2 = {k: v for k, v in OPTS1.items()
|
||||
if k not in ("setpoint_entity", "soc_entity", "batt_entity")}
|
||||
OPTS2["inverters"] = [
|
||||
dict(name="inverter-a", setpoint_entity="number.sp_a",
|
||||
soc_entity="sensor.soc_a", batt_entity="sensor.batt_a", max_w=5000.0),
|
||||
dict(name="inverter-b", setpoint_entity="number.sp_b",
|
||||
soc_entity="sensor.soc_b", batt_entity="sensor.batt_b", max_w=3000.0)]
|
||||
|
||||
|
||||
def controller(opts, cheap, soc_a=70.0, soc_b=70.0):
|
||||
p_now, p_avg = (0.05, 0.10) if cheap else (0.20, 0.10)
|
||||
hass = Hass({"sensor.grid": 800.0, "sensor.soc_a": soc_a,
|
||||
"sensor.batt_a": 0.0, "sensor.soc_b": soc_b,
|
||||
"sensor.batt_b": 0.0, "sensor.p_now": p_now,
|
||||
"sensor.p_avg": p_avg},
|
||||
{"number.sp_a": (-5000.0, 5000.0, 1.0),
|
||||
"number.sp_b": (-3000.0, 3000.0, 1.0)})
|
||||
c = Controller(dict(opts), hass, Mem(), Pub(), stop=asyncio.Event())
|
||||
for inv in c.inverters:
|
||||
inv.dev_min, inv.dev_max = -inv.max_w, inv.max_w
|
||||
c.auto = True
|
||||
return c, hass
|
||||
|
||||
|
||||
def one_cycle(c):
|
||||
async def go():
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def put_in_charge(c, which, hours_ago, open_min):
|
||||
now = datetime.now(UTC)
|
||||
m = c.maints[which]
|
||||
m.store.set("phase", CHARGE)
|
||||
m.store.set_time("phase_started", now - timedelta(hours=hours_ago))
|
||||
m.store.set("charge_track", {
|
||||
"since": m.store.get("phase_started"), "open_min": open_min,
|
||||
"flat_min": 0.0, "best": 70.0,
|
||||
"at": (now - timedelta(minutes=3)).isoformat()})
|
||||
return m
|
||||
|
||||
|
||||
def notes(h):
|
||||
# Not the dismiss of PRICE-01's old fixed-tariff card, which every start sends.
|
||||
# Only that dismiss: a `create` with the old id would still be counted.
|
||||
return [x for x in h.calls if x[0] == "persistent_notification"
|
||||
and not (x[1] == "dismiss"
|
||||
and x[2].get("notification_id") == "goodwe_fixed_tariff_unused")]
|
||||
|
||||
|
||||
for cheap in (True, False):
|
||||
c, h = controller(OPTS1, cheap)
|
||||
m = put_in_charge(c, 0, 5, 100.0)
|
||||
one_cycle(c)
|
||||
got = m._trk["open_min"] - 100.0
|
||||
check(f"N=1, price gate {'open' if cheap else 'closed'}: the Controller "
|
||||
f"{'adds' if cheap else 'does not add'} the 3 minutes since the last "
|
||||
f"tick to the charging time (+{got:.1f})",
|
||||
(2.5 < got < 3.5) if cheap else abs(got) < 0.01)
|
||||
|
||||
c, h = controller(OPTS1, True)
|
||||
m = put_in_charge(c, 0, 40, CHARGE_TIMEOUT_MIN + 1)
|
||||
one_cycle(c)
|
||||
n = notes(h)
|
||||
check("N=1: a charge past its time at 70 % ends in IDLE with ONE notification",
|
||||
m.phase == IDLE and len(n) == 1
|
||||
and n[0][2]["notification_id"] == "goodwe_maint_failed_battery"
|
||||
and n[0][2]["title"] == "Battery cycle did not finish")
|
||||
one_cycle(c)
|
||||
one_cycle(c)
|
||||
check("...and later cycles send no second one, and start no drain",
|
||||
len(notes(h)) == 1 and m.phase == IDLE)
|
||||
check("...the status page says when it waits until",
|
||||
any(k == "Maintenance paused" and "Waits until" in v
|
||||
for k, v in c.status_payload()["rows"]))
|
||||
c0, _h0 = controller(OPTS1, True)
|
||||
check("...and has no such row when nothing failed",
|
||||
not any(k == "Maintenance paused" for k, v in c0.status_payload()["rows"]))
|
||||
|
||||
c, h = controller(OPTS2, True)
|
||||
m = put_in_charge(c, 1, 40, CHARGE_TIMEOUT_MIN + 1)
|
||||
one_cycle(c)
|
||||
n = notes(h)
|
||||
check("N=2: pack B fails, B alone is told, by name, under its own id",
|
||||
c.maints[1].phase == IDLE and c.maints[0].phase == IDLE and len(n) == 1
|
||||
and n[0][2]["notification_id"] == "goodwe_maint_failed_inverter_b"
|
||||
and "inverter-b" in n[0][2]["message"])
|
||||
check("...and its failure is on B's keys",
|
||||
c.maints[1].store.get_time("last_failed") is not None
|
||||
and c.maints[0].store.get_time("last_failed") is None)
|
||||
|
||||
# a site with no price entities and no fixed tariff: nothing holds the charge back
|
||||
NOTARIFF = {k: v for k, v in OPTS1.items()
|
||||
if k not in ("price_now_entity", "price_avg_entity")}
|
||||
c, h = controller(NOTARIFF, False)
|
||||
m = put_in_charge(c, 0, 5, 100.0)
|
||||
one_cycle(c)
|
||||
got = m._trk["open_min"] - 100.0
|
||||
check("N=1, no tariff and no price entities: the gate counts as open, so the "
|
||||
f"3 minutes count (+{got:.1f}) although `cheap` is false",
|
||||
not c.cheap and 2.5 < got < 3.5)
|
||||
c, h = controller(NOTARIFF, False)
|
||||
m = put_in_charge(c, 0, 40, CHARGE_TIMEOUT_MIN + 1)
|
||||
one_cycle(c)
|
||||
check("...and a pack past its charging time there fails at once (not 7 days)",
|
||||
m.phase == IDLE and len(notes(h)) == 1)
|
||||
# a site with a price provider (TARIFF-11) and no fixed tariff: the provider's
|
||||
# plan reaches only the Tariff row, never `cheap`, so no cheap window ever
|
||||
# opens a grid top-up there. Like the site above, nothing gate-like holds the
|
||||
# charge back, and the time counts.
|
||||
PROVIDER = dict(NOTARIFF, price_provider="generic_dayahead", static_profile="")
|
||||
c, h = controller(PROVIDER, False)
|
||||
m = put_in_charge(c, 0, 5, 100.0)
|
||||
one_cycle(c)
|
||||
got = m._trk["open_min"] - 100.0
|
||||
check("N=1, price provider and no fixed tariff: not a gate (`cheap` is never "
|
||||
f"set from the provider), so the 3 minutes count (+{got:.1f})",
|
||||
c._price_gated is False and not c.cheap
|
||||
and c._charge_gate_open(c.inverters[0]) is True and 2.5 < got < 3.5)
|
||||
def read(c):
|
||||
asyncio.run(c.read_inputs())
|
||||
|
||||
|
||||
c, h = controller(OPTS1, False)
|
||||
read(c)
|
||||
c.inverters[0].batt = -800.0
|
||||
check("price gate closed but the pack is taking 800 W (sun): gate open",
|
||||
c._charge_gate_open(c.inverters[0]) is True)
|
||||
c.inverters[0].batt = -10.0
|
||||
check("...closed and about idle: gate closed",
|
||||
c._charge_gate_open(c.inverters[0]) is False)
|
||||
c.inverters[0].batt = None
|
||||
check("...no battery reading: gate closed", c._charge_gate_open(c.inverters[0]) is False)
|
||||
c, h = controller(OPTS1, True)
|
||||
read(c)
|
||||
check("price gate open: gate open", c._charge_gate_open(c.inverters[0]) is True)
|
||||
PROFILE = json.dumps({"periods": [
|
||||
{"name": "day", "from": "07:00", "to": "22:00", "days": "all",
|
||||
"eur_per_kwh": 0.29},
|
||||
{"name": "night", "from": "22:00", "to": "07:00", "days": "all",
|
||||
"eur_per_kwh": 0.19}]})
|
||||
c, h = controller(dict(NOTARIFF, price_provider="off", static_profile=PROFILE),
|
||||
False)
|
||||
read(c)
|
||||
check("a fixed tariff set up counts as a gate: outside its cheap hours a pack "
|
||||
"that is not charging is closed, inside them it is open",
|
||||
c._price_gated is True
|
||||
and c._charge_gate_open(c.inverters[0]) is c.cheap)
|
||||
import app.main as main_mod # noqa: E402
|
||||
_real_gated, _calls = main_mod.static_gated, []
|
||||
main_mod.static_gated = lambda o, now: _calls.append(now) or _real_gated(o, now)
|
||||
try:
|
||||
c._gated_key = None
|
||||
read(c)
|
||||
read(c)
|
||||
read(c)
|
||||
c._static_gated(c.o, datetime.now(UTC) + timedelta(hours=1))
|
||||
finally:
|
||||
main_mod.static_gated = _real_gated
|
||||
check("...worked out once an hour, not on every tick (3 reads in one hour = 1 "
|
||||
f"build; a new hour = 1 more): {len(_calls)} builds",
|
||||
len(_calls) == 2 and c._price_gated is True)
|
||||
|
||||
# the save timer is reset on a phase change
|
||||
mm = in_charge()
|
||||
mm.tick(T0 + MIN, 70.0)
|
||||
saved_before = mm._trk_saved
|
||||
mm.abort(T0 + 2 * MIN, "operator")
|
||||
check("leaving CHARGE resets the save timer along with the record",
|
||||
saved_before is not None and mm._trk is None and mm._trk_saved is None)
|
||||
|
||||
print("9. the words")
|
||||
en, nl = messages.maint_failed("charge", 7), messages.maint_failed("drain", 7)
|
||||
for n_ in (en, nl):
|
||||
print(" " + n_.title + " | " + n_.body + " | " + n_.body_nl)
|
||||
check("EN charge text", en.body == "The battery did not fill up, so its cycle "
|
||||
"stopped and tries again in 7 days.")
|
||||
check("EN drain text", nl.body == "The battery did not empty, so its cycle "
|
||||
"stopped and tries again in 7 days.")
|
||||
check("NL texts", en.body_nl == "Batterijcyclus niet afgerond. De batterij "
|
||||
"raakte niet vol, dus de cyclus is gestopt en probeert het over 7 dagen "
|
||||
"opnieuw." and nl.body_nl.endswith("raakte niet leeg, dus de cyclus is "
|
||||
"gestopt en probeert het over 7 dagen opnieuw."))
|
||||
check("the notification carries EN, NL and the Details line, in that order",
|
||||
re.fullmatch(r"The battery .*\n\nBatterijcyclus .*\n\nDetails: .*",
|
||||
en.data()["message"], re.S) is not None)
|
||||
check("the DOCS section and table row exist",
|
||||
"### Battery cycle did not finish" in (HERE / "DOCS.md").read_text()
|
||||
and "goodwe_maint_failed_<name>" in (HERE / "DOCS.md").read_text())
|
||||
check("no new option: the numbers are constants",
|
||||
(CHARGE_NEAR_FULL_PCT, CHARGE_STALL_MIN, FAIL_BACKOFF_DAYS)
|
||||
== (3, 60, 7))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all passed")
|
||||
@@ -1,403 +0,0 @@
|
||||
"""MAINT-14: maintenance with no SoC reading.
|
||||
`python3 test_maint14.py`
|
||||
|
||||
Graded here: charge and drain with SoC None at one and two packs - the charge
|
||||
timeout and 7 day backstop still run (failure, or done when the last SoC was
|
||||
>= target - 3), the drain holds at 0 W and times out, SoC coming back resumes
|
||||
normally with no back-off, the log lines (once per episode), a restart while the
|
||||
SoC is missing, and one pack's missing SoC leaving the other pack alone.
|
||||
See DECISIONS/MAINT-14-no-soc-in-maintenance.md.
|
||||
|
||||
Mutating what this file grades? Read MUTATION.md first (stale bytecode).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.maintenance import (
|
||||
CHARGE, CHARGE_NEAR_FULL_PCT, CHARGE_TIMEOUT_MIN, CHARGE_WALL_TIMEOUT_MIN,
|
||||
DRAIN, DRAIN_TIMEOUT_MIN, FAIL_BACKOFF_DAYS, HOLD, IDLE, NOSOC_NOTE_MIN,
|
||||
MaintConfig, Maintenance, arbitrate,
|
||||
)
|
||||
from app.store import DEFAULTS, Store
|
||||
|
||||
UTC = timezone.utc
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
CFG = MaintConfig(enabled=True, interval_days=28, start_hour=19,
|
||||
discharge_w=2000, charge_w=2000, soc_floor=11,
|
||||
soc_target=100, hold_min=120)
|
||||
T0 = datetime(2026, 10, 4, 18, 24, tzinfo=UTC)
|
||||
MIN = timedelta(minutes=1)
|
||||
|
||||
|
||||
class Mem:
|
||||
def __init__(self, data=None):
|
||||
self.data = dict(DEFAULTS, **(data or {}))
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def set_time(self, k, when):
|
||||
self.data[k] = when.astimezone(UTC).isoformat() if when else None
|
||||
|
||||
def get_time(self, k):
|
||||
raw = self.data.get(k)
|
||||
return datetime.fromisoformat(raw) if raw else None
|
||||
|
||||
def save(self):
|
||||
pass
|
||||
|
||||
|
||||
def in_drain(store=None, unit=None, at=T0):
|
||||
m = Maintenance(CFG, store or Mem(), unit=unit, name=unit or "")
|
||||
m.force_start(at)
|
||||
assert m.phase == DRAIN
|
||||
return m
|
||||
|
||||
|
||||
def in_charge(store=None, unit=None, at=T0):
|
||||
m = in_drain(store, unit, at)
|
||||
m.tick(at, 11.0)
|
||||
assert m.phase == CHARGE
|
||||
return m
|
||||
|
||||
|
||||
def run(m, start, minutes, soc, gate=True, step=1):
|
||||
"""Tick every `step` min; returns (last result, end time, first result that
|
||||
is not the starting phase with its time, all events)."""
|
||||
ph, t, res, out, ev = m.phase, start, None, None, []
|
||||
for i in range(0, minutes, step):
|
||||
t = start + i * MIN
|
||||
s = soc(i) if callable(soc) else soc
|
||||
res = m.tick(t, s, gate_open=gate)
|
||||
ev += res.events
|
||||
if out is None and res.phase != ph:
|
||||
out = (res, t)
|
||||
break
|
||||
return res, start + minutes * MIN, out, ev
|
||||
|
||||
|
||||
print("1. charge with no SoC")
|
||||
m = in_charge()
|
||||
res, _, out, _ = run(m, T0, 600, None)
|
||||
check("it keeps charging: the loop owns the setpoint, no start of a new phase",
|
||||
out is None and res.phase == CHARGE and res.charge_only
|
||||
and not res.owns_setpoint)
|
||||
|
||||
m = in_charge()
|
||||
run(m, T0, 60, 60.0)
|
||||
res, _, out, ev = run(m, T0 + 60 * MIN, CHARGE_TIMEOUT_MIN + 10, None)
|
||||
check("the charge timeout still runs: it fails once the time is up (last SoC 60)",
|
||||
out and out[0].failed == "charge" and out[0].phase == IDLE)
|
||||
check("...with the MAINT-13 back-off and its one plain line",
|
||||
m.store.get_time("last_failed") is not None
|
||||
and sum("did not finish" in e for e in ev) == 1
|
||||
and f"{FAIL_BACKOFF_DAYS} days" in ev[-1])
|
||||
check("...it took 36 h of allowed time from the charge start, not more",
|
||||
out and out[1] <= T0 + timedelta(minutes=CHARGE_TIMEOUT_MIN + 10))
|
||||
check("...and the pack does not want a start in the back-off",
|
||||
not m.wants_start((T0 + timedelta(days=3)).replace(hour=19)))
|
||||
|
||||
m = in_charge()
|
||||
res, _, out, _ = run(m, T0, CHARGE_TIMEOUT_MIN + 10, None)
|
||||
check("never saw a SoC: the timeout is a failure, not 'done'",
|
||||
out and out[0].failed == "charge")
|
||||
|
||||
for last in (97.0, 99.0):
|
||||
m = in_charge()
|
||||
run(m, T0, 10, last)
|
||||
res, _, out, ev = run(m, T0 + 10 * MIN, CHARGE_TIMEOUT_MIN + 20, None)
|
||||
check(f"last SoC {last:.0f} (>= target - {CHARGE_NEAR_FULL_PCT}): time up counts as done",
|
||||
out and out[0].phase == HOLD and out[0].failed == ""
|
||||
and m.store.get_time("last_failed") is None)
|
||||
check("...one plain line says so",
|
||||
sum("counts as done" in e for e in ev) == 1
|
||||
and f"{last:.0f} %" in " ".join(ev))
|
||||
m = in_charge()
|
||||
run(m, T0, 10, 96.0)
|
||||
res, _, out, _ = run(m, T0 + 10 * MIN, CHARGE_TIMEOUT_MIN + 20, None)
|
||||
check("last SoC 96 (below target - 3): time up is a failure",
|
||||
out and out[0].failed == "charge")
|
||||
m = in_charge()
|
||||
run(m, T0, 10, 99.0)
|
||||
run(m, T0 + 10 * MIN, 10, 50.0)
|
||||
res, _, out, _ = run(m, T0 + 20 * MIN, CHARGE_TIMEOUT_MIN + 20, None)
|
||||
check("'last seen' is the last reading, not the best (99 then 50: failure)",
|
||||
out and out[0].failed == "charge")
|
||||
|
||||
print("2. the 7 day backstop, with the gate shut")
|
||||
m = in_charge()
|
||||
run(m, T0, 10, 70.0)
|
||||
res, _, out, _ = run(m, T0 + 10 * MIN, CHARGE_WALL_TIMEOUT_MIN + 30, None,
|
||||
gate=False, step=5)
|
||||
check("gate never opens, last SoC 70: failure at 7 days",
|
||||
out and out[0].failed == "charge"
|
||||
and out[1] >= T0 + timedelta(minutes=CHARGE_WALL_TIMEOUT_MIN)
|
||||
and out[1] <= T0 + timedelta(minutes=CHARGE_WALL_TIMEOUT_MIN + 10))
|
||||
m = in_charge()
|
||||
run(m, T0, 10, 98.0)
|
||||
res, _, out, _ = run(m, T0 + 10 * MIN, CHARGE_WALL_TIMEOUT_MIN + 30, None,
|
||||
gate=False, step=5)
|
||||
check("gate never opens, last SoC 98: done at 7 days",
|
||||
out and out[0].phase == HOLD and out[0].failed == ""
|
||||
and out[1] >= T0 + timedelta(minutes=CHARGE_WALL_TIMEOUT_MIN))
|
||||
m = in_charge()
|
||||
run(m, T0, 10, 70.0)
|
||||
res, _, out, _ = run(m, T0 + 10 * MIN, 3000, None, gate=False, step=5)
|
||||
check("gate shut, no SoC: the open-time clock does not run (no early end)",
|
||||
out is None)
|
||||
|
||||
print("3. SoC comes back mid-charge")
|
||||
m = in_charge()
|
||||
run(m, T0, 30, 80.0)
|
||||
run(m, T0 + 30 * MIN, 600, None)
|
||||
res, _, out, _ = run(m, T0 + 630 * MIN, 5, 80.0)
|
||||
check("normal behaviour resumes: still charging, nothing recorded",
|
||||
out is None and res.phase == CHARGE
|
||||
and m.store.get_time("last_failed") is None)
|
||||
res, _, out, _ = run(m, T0 + 640 * MIN, 10, 100.0)
|
||||
check("...and it completes at the target as usual",
|
||||
out and out[0].phase == HOLD and m.store.get_time("last_failed") is None)
|
||||
m = in_charge()
|
||||
run(m, T0, 30, 98.0)
|
||||
run(m, T0 + 30 * MIN, 600, None)
|
||||
res, _, out, _ = run(m, T0 + 630 * MIN, 30, 98.0)
|
||||
check("the stall wait paused while the SoC was missing (30 min flat after the "
|
||||
"gap is not 60)", out is None)
|
||||
res, _, out, _ = run(m, T0 + 660 * MIN, 40, 98.0)
|
||||
check("...and completes once 60 min flat are in", out and out[0].phase == HOLD)
|
||||
m = in_charge()
|
||||
run(m, T0, 10, 70.0)
|
||||
run(m, T0 + 10 * MIN, CHARGE_TIMEOUT_MIN - 100, None)
|
||||
res, _, out, _ = run(m, T0 + (CHARGE_TIMEOUT_MIN - 90) * MIN, 5, 70.0)
|
||||
check("a gap just before the limit then a reading: no failure, no back-off",
|
||||
out is None and m.store.get_time("last_failed") is None)
|
||||
|
||||
print("4. drain with no SoC")
|
||||
m = in_drain()
|
||||
run(m, T0, 30, 60.0)
|
||||
res, _, out, ev = run(m, T0 + 30 * MIN, 60, None)
|
||||
check("DRAIN holds at 0 W, owned by maintenance, the same as HOLD",
|
||||
out is None and res.phase == DRAIN and res.owns_setpoint
|
||||
and res.setpoint_w == 0.0 and not res.charge_only)
|
||||
hold = in_charge()
|
||||
hold.tick(T0 + MIN, 100.0)
|
||||
hr = hold.tick(T0 + 2 * MIN, None)
|
||||
check("...the same answer as HOLD gives",
|
||||
(hr.owns_setpoint, hr.setpoint_w) == (res.owns_setpoint, res.setpoint_w))
|
||||
check("never above 0 W on any missing tick",
|
||||
all(m.tick(T0 + (100 + i) * MIN, None).setpoint_w == 0.0 for i in range(30)))
|
||||
check("one plain line that the drain holds, once, not per tick",
|
||||
sum("drain holds at 0 W" in e for e in ev) == 1)
|
||||
check("one plain line after the threshold, once",
|
||||
sum("no battery level for" in e for e in ev) == 1
|
||||
and f"{NOSOC_NOTE_MIN} min" in " ".join(ev))
|
||||
m = in_drain()
|
||||
res, _, out, ev = run(m, T0, NOSOC_NOTE_MIN - 1, None)
|
||||
check("under the threshold the 'no level for long' line is not said",
|
||||
not any("no battery level for" in e for e in ev))
|
||||
m = in_drain()
|
||||
res, _, out, ev = run(m, T0, DRAIN_TIMEOUT_MIN + 10, None)
|
||||
check("the 12 h drain timeout still runs: failure with back-off",
|
||||
out and out[0].failed == "drain" and out[0].phase == IDLE
|
||||
and out[1] > T0 + timedelta(minutes=DRAIN_TIMEOUT_MIN)
|
||||
and m.store.get_time("last_failed") is not None)
|
||||
m = in_drain()
|
||||
run(m, T0, 60, None)
|
||||
res, _, out, _ = run(m, T0 + 60 * MIN, 5, 60.0)
|
||||
check("SoC back: the drain continues at the drain rate",
|
||||
out is None and res.phase == DRAIN and res.owns_setpoint
|
||||
and res.setpoint_w == CFG.discharge_w)
|
||||
res, _, out, _ = run(m, T0 + 70 * MIN, 5, 10.0)
|
||||
check("...and ends at the floor as usual",
|
||||
out and out[0].phase == CHARGE and m.store.get_time("last_failed") is None)
|
||||
m = in_drain()
|
||||
run(m, T0, 60, None)
|
||||
run(m, T0 + 60 * MIN, 5, 60.0)
|
||||
res, _, out, ev = run(m, T0 + 70 * MIN, 5, None)
|
||||
check("a second episode says its lines again",
|
||||
any("drain holds at 0 W" in e for e in ev))
|
||||
|
||||
print("5. the log is once per episode")
|
||||
m = in_charge()
|
||||
res, _, out, ev = run(m, T0, 300, None)
|
||||
check("a 5 h charge episode: exactly one 'no level for long' line, none per tick",
|
||||
len(ev) == 1 and "no battery level for" in ev[0])
|
||||
run(m, T0 + 300 * MIN, 3, 70.0)
|
||||
res, _, out, ev = run(m, T0 + 303 * MIN, 60, None)
|
||||
check("...and one more for a new episode", len(ev) == 1)
|
||||
m = in_charge()
|
||||
res, _, out, ev = run(m, T0, 5, None)
|
||||
check("a 5 min gap says nothing", ev == [])
|
||||
|
||||
print("5b. the words per phase")
|
||||
m = in_charge()
|
||||
m.tick(T0 + MIN, 100.0)
|
||||
_, _, _, ev = run(m, T0 + 2 * MIN, 30, None)
|
||||
note = [e for e in ev if "no battery level for" in e]
|
||||
check("HOLD: the line exists and says the hold ends at its normal time (MAINT-15)",
|
||||
len(note) == 1 and "normal time" in note[0] and "waits" not in note[0])
|
||||
m = in_charge()
|
||||
_, _, _, ev = run(m, T0, 30, None)
|
||||
check("charge keeps its wording", any("time limit of the charge" in e for e in ev))
|
||||
m = in_charge()
|
||||
run(m, T0, 10, 70.0)
|
||||
_, _, out, ev = run(m, T0 + 10 * MIN, CHARGE_WALL_TIMEOUT_MIN + 30, None,
|
||||
gate=False, step=5)
|
||||
fail = [e for e in ev if "ABORT" in e]
|
||||
check("7-day backstop: the reason names the 7-day limit, not '0 h'",
|
||||
len(fail) == 1 and "7-day limit" in fail[0] and "0 h" not in fail[0])
|
||||
m = in_charge()
|
||||
_, _, out, ev = run(m, T0, CHARGE_TIMEOUT_MIN + 10, None)
|
||||
fail = [e for e in ev if "ABORT" in e]
|
||||
check("charge-time limit: the reason gives the hours of charging time",
|
||||
len(fail) == 1 and "36 h of charging time" in fail[0])
|
||||
m = in_charge()
|
||||
run(m, T0, 10, 98.0)
|
||||
_, _, out, _ = run(m, T0 + 10 * MIN, CHARGE_TIMEOUT_MIN + 20, None)
|
||||
_, _, _, ev = run(m, out[1] + MIN, 30, None)
|
||||
check("charge done with no SoC, then HOLD with no SoC: a new episode, so the "
|
||||
"HOLD line is said", out[0].phase == HOLD
|
||||
and sum("normal time" in e for e in ev) == 1)
|
||||
|
||||
print("6. restart while the SoC is missing")
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "state.json")
|
||||
m = Maintenance(CFG, Store(path))
|
||||
m.force_start(T0)
|
||||
m.tick(T0, 11.0)
|
||||
run(m, T0, 97, lambda i: 90.0 if i < 96 else 98.0) # 98 seen 1 min ago
|
||||
run(m, T0 + 97 * MIN, 3, None) # SoC lost; state saved at once
|
||||
t = T0 + 100 * MIN # restart 3 min after the loss, before the next 5 min save
|
||||
m2 = Maintenance(CFG, Store(path))
|
||||
r = m2.tick(t, None)
|
||||
check("a fresh process reads charge, and still charges", m2.phase == CHARGE
|
||||
and r.phase == CHARGE and r.charge_only)
|
||||
check("...with the open minutes and the last SoC seen from the file",
|
||||
m2._trk is not None and m2._trk["last"] == 98.0
|
||||
and 92 <= m2._trk["open_min"] <= 105)
|
||||
res, _, out, _ = run(m2, t + MIN, CHARGE_TIMEOUT_MIN, None, step=5)
|
||||
check("...so the timeout counts from the first charge minute and, with the "
|
||||
"last SoC 98, completes (no failure)",
|
||||
out and out[0].phase == HOLD and out[0].failed == ""
|
||||
and out[1] < T0 + timedelta(minutes=CHARGE_TIMEOUT_MIN + 60))
|
||||
# drain: restart keeps it at 0 W and keeps the clock
|
||||
path2 = os.path.join(d, "s2.json")
|
||||
md = Maintenance(CFG, Store(path2))
|
||||
md.force_start(T0)
|
||||
md.tick(T0 + 100 * MIN, None)
|
||||
md2 = Maintenance(CFG, Store(path2))
|
||||
r = md2.tick(T0 + 101 * MIN, None)
|
||||
check("a drain restarted with no SoC holds at 0 W", md2.phase == DRAIN
|
||||
and r.setpoint_w == 0.0 and r.owns_setpoint)
|
||||
res, _, out, _ = run(md2, T0 + 102 * MIN, DRAIN_TIMEOUT_MIN, None, step=5)
|
||||
check("...and the 12 h clock is from the drain start, not the restart",
|
||||
out and out[0].failed == "drain"
|
||||
and out[1] <= T0 + timedelta(minutes=DRAIN_TIMEOUT_MIN + 10))
|
||||
# a charge restarted with no record of any SoC (old state file)
|
||||
old = {"phase": "charge", "phase_started": (T0 - timedelta(hours=35)).isoformat(),
|
||||
"last_completed": None, "last_start_attempt": None, "auto": True}
|
||||
import json
|
||||
path3 = os.path.join(d, "s3.json")
|
||||
json.dump(old, open(path3, "w"))
|
||||
mo = Maintenance(CFG, Store(path3))
|
||||
res, _, out, _ = run(mo, T0, 120, None)
|
||||
check("an old state file with no SoC seen: the charge fails at 36 h",
|
||||
out and out[0].failed == "charge")
|
||||
# a MAINT-13 state file: charge_track with a best but no `last`
|
||||
since = (T0 - timedelta(hours=35)).isoformat()
|
||||
m13 = dict(old, phase_started=since, charge_track={
|
||||
"since": since, "open_min": 2100.0, "flat_min": 0.0, "best": 99.0,
|
||||
"at": (T0 - MIN).isoformat()})
|
||||
path4 = os.path.join(d, "s4.json")
|
||||
json.dump(m13, open(path4, "w"))
|
||||
mo = Maintenance(CFG, Store(path4))
|
||||
res, _, out, _ = run(mo, T0, 120, None)
|
||||
check("a MAINT-13 state file (no last SoC): no crash, the charge fails at "
|
||||
"36 h (best is not last)", out and out[0].failed == "charge")
|
||||
|
||||
print("7. two packs")
|
||||
a, b = (Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a"),
|
||||
Maintenance(CFG, Mem(), unit="inverter-b", name="inverter-b"))
|
||||
a.force_start(T0)
|
||||
# the twin site: the same two packs, A healthy (SoC 60, still above the floor)
|
||||
a_ok, b_alone = (Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a"),
|
||||
Maintenance(CFG, Mem(), unit="inverter-b", name="inverter-b"))
|
||||
a_ok.force_start(T0)
|
||||
same = True
|
||||
for i in range(0, 120, 10):
|
||||
t = T0 + i * MIN
|
||||
plan = arbitrate([a, b], t)
|
||||
plan0 = arbitrate([a_ok, b_alone], t)
|
||||
same = same and plan[1] == plan0[1]
|
||||
ra = a.tick(t, None, blocked_by=plan[0])
|
||||
rb = b.tick(t, 55.0, blocked_by=plan[1])
|
||||
a_ok.tick(t, 60.0, blocked_by=plan0[0])
|
||||
rb0 = b_alone.tick(t, 55.0, blocked_by=plan0[1])
|
||||
same = same and (rb.phase, rb.setpoint_w, rb.deferred) == \
|
||||
(rb0.phase, rb0.setpoint_w, rb0.deferred)
|
||||
check("A drains with no SoC at 0 W while B (SoC 55, idle) is as it is when A "
|
||||
"is healthy", ra.setpoint_w == 0.0 and ra.owns_setpoint and rb.phase == IDLE
|
||||
and rb.setpoint_w is None and not rb.owns_setpoint)
|
||||
check("...B's answers are unchanged tick by tick", same)
|
||||
res = None
|
||||
for i in range(0, DRAIN_TIMEOUT_MIN + 30, 10):
|
||||
t = T0 + 120 * MIN + i * MIN
|
||||
plan = arbitrate([a, b], t)
|
||||
res = a.tick(t, None, blocked_by=plan[0])
|
||||
b.tick(t, 55.0, blocked_by=plan[1])
|
||||
if res.failed:
|
||||
break
|
||||
check("A's drain times out and A backs off (A's keys only)",
|
||||
res.failed == "drain" and a.store.get_time("last_failed") is not None
|
||||
and b.store.get_time("last_failed") is None)
|
||||
slot = (T0 + timedelta(days=1)).replace(hour=19, minute=5)
|
||||
check("...then B, with its SoC, takes the slot as usual",
|
||||
arbitrate([a, b], slot) == ["", ""] and b.wants_start(slot)
|
||||
and not a.wants_start(slot))
|
||||
b.force_start(slot)
|
||||
rb = b.tick(slot + 5 * MIN, 50.0)
|
||||
check("...and B's drain runs at the drain rate",
|
||||
rb.phase == DRAIN and rb.setpoint_w == CFG.discharge_w)
|
||||
# the other way: B charges with no SoC, A has a SoC
|
||||
a = Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a")
|
||||
b = Maintenance(CFG, Mem(), unit="inverter-b", name="inverter-b")
|
||||
b.force_start(T0)
|
||||
b.tick(T0, 11.0)
|
||||
run(b, T0, 5, 97.0)
|
||||
a_alone = Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a")
|
||||
same, t = True, T0 + 10 * MIN
|
||||
while t < T0 + timedelta(minutes=CHARGE_TIMEOUT_MIN + 30):
|
||||
plan = arbitrate([a, b], t)
|
||||
rb = b.tick(t, None, blocked_by=plan[1])
|
||||
ra = a.tick(t, 60.0, blocked_by=plan[0])
|
||||
ra0 = a_alone.tick(t, 60.0, blocked_by=plan[0])
|
||||
same = same and (ra.phase, ra.setpoint_w, ra.deferred) == \
|
||||
(ra0.phase, ra0.setpoint_w, ra0.deferred)
|
||||
if rb.phase != CHARGE:
|
||||
break
|
||||
t += 5 * MIN
|
||||
check("B charges with no SoC (last 97): A (SoC 60) is unchanged tick by tick",
|
||||
same)
|
||||
check("...B ends as done (HOLD), A untouched, no failure on either",
|
||||
rb.phase == HOLD and rb.failed == ""
|
||||
and b.store.get_time("last_failed") is None
|
||||
and a.store.get_time("last_failed") is None and a.phase == IDLE)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all passed")
|
||||
@@ -1,299 +0,0 @@
|
||||
"""MAINT-15: the HOLD ends on time with no SoC reading.
|
||||
`python3 test_maint15.py`
|
||||
|
||||
Graded here: a HOLD that loses its SoC (at the start, mid-hold, just before
|
||||
hold_min) ends on time, writes last_completed and says the COMPLETE line; SoC
|
||||
back mid-hold changes nothing; HOLD stays 0 W; one pack's loss leaves the other
|
||||
pack alone; a restart in HOLD with no SoC and an old state file.
|
||||
See DECISIONS/MAINT-15-hold-ends-without-soc.md.
|
||||
|
||||
Mutating what this file grades? Read MUTATION.md first (stale bytecode).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.maintenance import (
|
||||
CHARGE, DRAIN, HOLD, IDLE, NOSOC_NOTE_MIN, MaintConfig, Maintenance,
|
||||
)
|
||||
from app.store import DEFAULTS, Store
|
||||
|
||||
UTC = timezone.utc
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
CFG = MaintConfig(enabled=True, interval_days=28, start_hour=19,
|
||||
discharge_w=2000, charge_w=2000, soc_floor=11,
|
||||
soc_target=100, hold_min=120)
|
||||
T0 = datetime(2026, 10, 4, 18, 24, tzinfo=UTC)
|
||||
MIN = timedelta(minutes=1)
|
||||
|
||||
|
||||
class Mem:
|
||||
def __init__(self, data=None):
|
||||
self.data = dict(DEFAULTS, **(data or {}))
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def set_time(self, k, when):
|
||||
self.data[k] = when.astimezone(UTC).isoformat() if when else None
|
||||
|
||||
def get_time(self, k):
|
||||
raw = self.data.get(k)
|
||||
return datetime.fromisoformat(raw) if raw else None
|
||||
|
||||
def save(self):
|
||||
pass
|
||||
|
||||
|
||||
def in_drain(store=None, unit=None, at=T0):
|
||||
m = Maintenance(CFG, store or Mem(), unit=unit, name=unit or "")
|
||||
m.force_start(at)
|
||||
assert m.phase == DRAIN
|
||||
return m
|
||||
|
||||
|
||||
def in_charge(store=None, unit=None, at=T0):
|
||||
m = in_drain(store, unit, at)
|
||||
m.tick(at, 11.0)
|
||||
assert m.phase == CHARGE
|
||||
return m
|
||||
|
||||
|
||||
def run(m, start, minutes, soc, gate=True, step=1):
|
||||
"""Tick every `step` min; returns (last result, end time, first result that
|
||||
is not the starting phase with its time, all events)."""
|
||||
ph, t, res, out, ev = m.phase, start, None, None, []
|
||||
for i in range(0, minutes, step):
|
||||
t = start + i * MIN
|
||||
s = soc(i) if callable(soc) else soc
|
||||
res = m.tick(t, s, gate_open=gate)
|
||||
ev += res.events
|
||||
if out is None and res.phase != ph:
|
||||
out = (res, t)
|
||||
break
|
||||
return res, start + minutes * MIN, out, ev
|
||||
|
||||
|
||||
|
||||
HOLD_MIN = CFG.hold_min
|
||||
|
||||
|
||||
def in_hold(store=None, unit=None, at=T0):
|
||||
"""A pack in HOLD that started at `at` + 1 min (charge counted as done)."""
|
||||
m = in_charge(store, unit, at)
|
||||
m.tick(at + MIN, 100.0)
|
||||
assert m.phase == HOLD
|
||||
return m
|
||||
|
||||
|
||||
H0 = T0 + MIN # the hold's start
|
||||
print("1. SoC lost in HOLD: the hold ends on time")
|
||||
for label, lost_at in (("at the start", 0), ("mid-hold", 60),
|
||||
("just before hold_min", HOLD_MIN - 1)):
|
||||
m = in_hold()
|
||||
run(m, H0, lost_at, 100.0) if lost_at else None
|
||||
res, _, out, ev = run(m, H0 + lost_at * MIN, HOLD_MIN + 5 - lost_at, None)
|
||||
check(f"lost {label}: ends at hold_min, to IDLE, not failed",
|
||||
out is not None and out[0].phase == IDLE and out[0].failed == ""
|
||||
and out[1] == H0 + HOLD_MIN * MIN)
|
||||
check("...last_completed is written at that time",
|
||||
m.store.get_time("last_completed") == H0 + HOLD_MIN * MIN)
|
||||
check("...the same COMPLETE line, once, and no last_failed",
|
||||
sum("cycle COMPLETE (120 min hold)" in e for e in ev) == 1
|
||||
and m.store.get_time("last_failed") is None)
|
||||
check("...the pack is idle and hands the setpoint back",
|
||||
m.phase == IDLE and not out[0].owns_setpoint)
|
||||
|
||||
m = in_hold()
|
||||
res, _, out, _ = run(m, H0, HOLD_MIN - 1, None)
|
||||
check("before hold_min: still HOLD at 0 W, owned, nothing written",
|
||||
out is None and res.phase == HOLD and res.owns_setpoint
|
||||
and res.setpoint_w == 0.0 and m.store.get_time("last_completed") is None)
|
||||
check("...the pack is not due again (next due date moved after the end)",
|
||||
(run(m, H0 + (HOLD_MIN - 1) * MIN, 3, None), m.due(H0 + 130 * MIN))[1] is False)
|
||||
|
||||
print("2. the text")
|
||||
m = in_hold()
|
||||
_, _, out, ev = run(m, H0, HOLD_MIN + 5, None)
|
||||
note = [e for e in ev if "no battery level for" in e]
|
||||
check("one 15 min line, saying the hold ends at its normal time",
|
||||
len(note) == 1 and "normal time" in note[0] and "waits" not in note[0]
|
||||
and f"{NOSOC_NOTE_MIN} min" in note[0])
|
||||
check("...and the other events are the one COMPLETE line only", len(ev) == 2)
|
||||
m = in_hold()
|
||||
_, _, _, ev = run(m, H0, NOSOC_NOTE_MIN - 2, None)
|
||||
check("under the threshold the line is not said", ev == [])
|
||||
|
||||
print("3. SoC comes back mid-hold")
|
||||
ends = {}
|
||||
for label, soc in (("no SoC", lambda i: None if i < 40 else 100.0),
|
||||
("SoC the whole time", 100.0),
|
||||
("a low SoC after the gap", lambda i: None if i < 40 else 55.0)):
|
||||
m = in_hold()
|
||||
res, _, out, ev = run(m, H0, HOLD_MIN + 5, soc)
|
||||
ends[label] = (out[1], m.store.get_time("last_completed")) if out else None
|
||||
check(f"{label}: ends at hold_min", out is not None
|
||||
and out[1] == H0 + HOLD_MIN * MIN)
|
||||
check("...the end time is the same with or without the gap",
|
||||
len(set(ends.values())) == 1)
|
||||
m = in_hold()
|
||||
run(m, H0, 40, None)
|
||||
res, _, out, _ = run(m, H0 + 40 * MIN, 30, 100.0)
|
||||
check("back mid-hold: still HOLD at 0 W until the time is up",
|
||||
out is None and res.phase == HOLD and res.setpoint_w == 0.0)
|
||||
|
||||
print("4. restart in HOLD with no SoC")
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "state.json")
|
||||
m = Maintenance(CFG, Store(path))
|
||||
m.force_start(T0)
|
||||
m.tick(T0, 11.0)
|
||||
m.tick(H0, 100.0)
|
||||
run(m, H0, 50, None)
|
||||
m2 = Maintenance(CFG, Store(path))
|
||||
r = m2.tick(H0 + 51 * MIN, None)
|
||||
check("a fresh process reads HOLD and holds 0 W", m2.phase == HOLD
|
||||
and r.phase == HOLD and r.owns_setpoint and r.setpoint_w == 0.0)
|
||||
res, _, out, _ = run(m2, H0 + 52 * MIN, HOLD_MIN, None)
|
||||
check("...and the hold ends at hold_min from the hold start, not the restart",
|
||||
out and out[0].phase == IDLE and out[1] == H0 + HOLD_MIN * MIN
|
||||
and m2.store.get_time("last_completed") == H0 + HOLD_MIN * MIN)
|
||||
import json
|
||||
old = {"phase": "hold", "phase_started": H0.isoformat(),
|
||||
"last_completed": None, "last_start_attempt": None, "auto": True}
|
||||
p2 = os.path.join(d, "old.json")
|
||||
json.dump(old, open(p2, "w"))
|
||||
mo = Maintenance(CFG, Store(p2))
|
||||
r = mo.tick(H0 + 10 * MIN, None)
|
||||
check("an old state file in HOLD loads and holds 0 W", mo.phase == HOLD
|
||||
and r.setpoint_w == 0.0)
|
||||
res, _, out, _ = run(mo, H0 + 11 * MIN, HOLD_MIN, None)
|
||||
check("...and ends on time, writing last_completed",
|
||||
out and out[1] == H0 + HOLD_MIN * MIN
|
||||
and mo.store.get_time("last_completed") == H0 + HOLD_MIN * MIN)
|
||||
mr = Maintenance(CFG, Store(p2))
|
||||
check("...the written file reloads as a finished cycle",
|
||||
mr.phase == IDLE and mr.store.get_time("last_completed") is not None)
|
||||
|
||||
print("5. two packs")
|
||||
a, b = (Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a"),
|
||||
Maintenance(CFG, Mem(), unit="inverter-b", name="inverter-b"))
|
||||
a_ok, b_ok = (Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a"),
|
||||
Maintenance(CFG, Mem(), unit="inverter-b", name="inverter-b"))
|
||||
for x in (a, a_ok):
|
||||
x.force_start(T0)
|
||||
x.tick(T0, 11.0)
|
||||
x.tick(H0, 100.0)
|
||||
# B is idle; A's hold loses its SoC (twin site: A keeps it)
|
||||
same, a_end = True, None
|
||||
for i in range(0, HOLD_MIN + 10):
|
||||
t = H0 + i * MIN
|
||||
ra = a.tick(t, None)
|
||||
rb = b.tick(t, 60.0)
|
||||
ra2 = a_ok.tick(t, 100.0)
|
||||
rb2 = b_ok.tick(t, 60.0)
|
||||
same = same and (rb.phase, rb.owns_setpoint, rb.setpoint_w) == \
|
||||
(rb2.phase, rb2.owns_setpoint, rb2.setpoint_w) and b.phase == b_ok.phase
|
||||
if a_end is None and a.phase == IDLE:
|
||||
a_end = t
|
||||
check("A loses SoC in HOLD: A ends on time (as the twin with a reading)",
|
||||
a_end == H0 + HOLD_MIN * MIN and a_ok.phase == IDLE
|
||||
and a.store.get_time("last_completed") == a_ok.store.get_time("last_completed"))
|
||||
check("...B is the same as in the twin site where A kept its SoC", same)
|
||||
# B in its own HOLD with SoC, A in HOLD without
|
||||
a, b = (Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a"),
|
||||
Maintenance(CFG, Mem(), unit="inverter-b", name="inverter-b"))
|
||||
a.force_start(T0); a.tick(T0, 11.0); a.tick(H0, 100.0)
|
||||
b_start = H0 + 30 * MIN
|
||||
b.force_start(b_start - MIN); b.tick(b_start - MIN, 11.0); b.tick(b_start, 100.0)
|
||||
b_end = None
|
||||
for i in range(0, HOLD_MIN + 40):
|
||||
t = H0 + i * MIN
|
||||
a.tick(t, None)
|
||||
if t >= b_start:
|
||||
b.tick(t, 100.0)
|
||||
if b_end is None and b.phase == IDLE:
|
||||
b_end = t
|
||||
check("B (SoC present) ends at its own hold_min, A's loss changes nothing",
|
||||
b_end == b_start + HOLD_MIN * MIN and a.phase == IDLE
|
||||
and b.store.get_time("last_completed") == b_start + HOLD_MIN * MIN)
|
||||
check("...A's end is not delayed by B", a.store.get_time("last_completed")
|
||||
== H0 + HOLD_MIN * MIN)
|
||||
|
||||
print("6. other phases unchanged")
|
||||
m = in_drain()
|
||||
res, _, out, _ = run(m, T0, 60, None)
|
||||
check("DRAIN with no SoC still holds 0 W and does not end", out is None
|
||||
and res.phase == DRAIN and res.setpoint_w == 0.0)
|
||||
m = in_charge()
|
||||
res, _, out, _ = run(m, T0, 600, None)
|
||||
check("CHARGE with no SoC still keeps charging", out is None
|
||||
and res.phase == CHARGE and res.charge_only)
|
||||
|
||||
|
||||
def old_hold_branch(phase_started, last_completed, now, hold_min):
|
||||
"""FROZEN copy of release/1.0 (db9e231) HOLD branch of step(), with a
|
||||
reading: returns (phase, owns_setpoint, setpoint_w, events,
|
||||
last_completed). Kept as a reference for the differential check below."""
|
||||
elapsed = (now - phase_started).total_seconds() / 60.0
|
||||
if elapsed >= hold_min:
|
||||
return (IDLE, False, None,
|
||||
[f"maintenance: cycle COMPLETE ({elapsed:.0f} min hold) "
|
||||
"- handing back to the loop"], now)
|
||||
return (HOLD, True, 0.0, [], last_completed)
|
||||
|
||||
|
||||
print("7. with a reading: the new HOLD against the frozen release/1.0 branch")
|
||||
for hm in (0, 5, 60, 120):
|
||||
cfg = MaintConfig(enabled=True, interval_days=28, start_hour=19,
|
||||
discharge_w=2000, charge_w=2000, soc_floor=11,
|
||||
soc_target=100, hold_min=hm)
|
||||
m = Maintenance(cfg, Mem())
|
||||
m.force_start(T0)
|
||||
m.tick(T0, 11.0)
|
||||
m.tick(H0, 100.0)
|
||||
started, last, same, end = H0, None, True, None
|
||||
for i in range(0, hm + 15):
|
||||
t = H0 + i * MIN
|
||||
if m.phase != HOLD:
|
||||
break
|
||||
want = old_hold_branch(started, last, t, hm)
|
||||
r = m.tick(t, 100.0 - (i % 3))
|
||||
got = (r.phase, r.owns_setpoint, r.setpoint_w, r.events,
|
||||
m.store.get_time("last_completed"))
|
||||
same = same and got == want
|
||||
last = want[4]
|
||||
if r.phase == IDLE:
|
||||
end = t
|
||||
check(f"hold_min {hm}: every tick identical (phase, owns, W, events, "
|
||||
"last_completed)", same)
|
||||
check("...ends at hold_min, last_completed = that tick, one COMPLETE line",
|
||||
end == H0 + hm * MIN and m.store.get_time("last_completed") == end
|
||||
and m.phase == IDLE)
|
||||
|
||||
m = in_hold()
|
||||
res, _, out, ev = run(m, H0, HOLD_MIN + 5, 100.0)
|
||||
check("HOLD with a reading: 0 W until the end, then IDLE at hold_min",
|
||||
out is not None and out[1] == H0 + HOLD_MIN * MIN and out[0].phase == IDLE)
|
||||
check("...last_completed is that tick and the COMPLETE line is said once",
|
||||
m.store.get_time("last_completed") == H0 + HOLD_MIN * MIN
|
||||
and ev == ["maintenance: cycle COMPLETE (120 min hold) "
|
||||
"- handing back to the loop"])
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all ok")
|
||||
@@ -122,8 +122,7 @@ m = Maintenance(cfg, FakeStore())
|
||||
m.force_start(t0)
|
||||
r = m.tick(t0 + timedelta(minutes=5), soc=None)
|
||||
check("no SoC: holds the phase rather than guessing", r.phase == DRAIN)
|
||||
check("no SoC: MAINT-14 the drain holds at 0 W, never discharges",
|
||||
r.owns_setpoint and r.setpoint_w == 0.0)
|
||||
check("no SoC: still commands the drain rate", r.setpoint_w == 2500)
|
||||
|
||||
m = Maintenance(cfg, FakeStore())
|
||||
m.store.set("phase", "banana")
|
||||
@@ -623,8 +622,6 @@ def flat_store(**over):
|
||||
"phase_started": "2026-09-03T22:00:00+00:00",
|
||||
"last_completed": "2026-08-07T12:00:00+00:00",
|
||||
"last_start_attempt": "2026-09-03T10:00:00+00:00",
|
||||
# MAINT-13: a failed cycle on record, so that key's move is graded too.
|
||||
"last_failed": "2026-09-01T10:00:00+00:00",
|
||||
}, **over))
|
||||
|
||||
|
||||
@@ -932,11 +929,10 @@ print(f" ok={_rep8.ok} lost={len(_rep8.lost)} kept={len(_rep8.kept)} "
|
||||
f"misdirected={len(_rep8.misdirected)}; marker "
|
||||
f"{_m8.data.get(MAINT_SCHEMA_KEY, '<<absent>>')!r}")
|
||||
check("PRECONDITION: this fixture reaches the marker through the `lost` guard "
|
||||
"and ONLY that one - two keys in `kept`, three in `lost` (the three the "
|
||||
"fixture records; MAINT-13 added `last_failed`), `misdirected` "
|
||||
"and ONLY that one - two keys in `kept`, two in `lost`, `misdirected` "
|
||||
"empty. If it ever stops having a non-empty `kept` the mutation below "
|
||||
"goes invisible again, so the shape is asserted rather than assumed",
|
||||
_rep8.ran and len(_rep8.kept) == 2 and len(_rep8.lost) == 3
|
||||
_rep8.ran and len(_rep8.kept) == 2 and len(_rep8.lost) == 2
|
||||
and not _rep8.misdirected and not _rep8.ok)
|
||||
check("⚠️ THE MARKER IS NOT STAMPED ON A MIGRATION THAT LOST A MONTH OF "
|
||||
"HISTORY, and the two keys that DID land do not buy it one. `rep.ok` is "
|
||||
@@ -1014,14 +1010,10 @@ check("...and nothing about a deferral, a slot or another pack appears in any "
|
||||
# pressure.
|
||||
_solo2 = Maintenance(cfg, MemStore())
|
||||
_solo2.force_start(t0)
|
||||
# MAINT-13: a drain that times out is a failed cycle, so one plain line follows
|
||||
# the abort line (the abort line itself is unchanged).
|
||||
check("...and so is the abort, which is the string an operator reads under "
|
||||
"pressure",
|
||||
_solo2.tick(t0 + timedelta(hours=13), 60.0).events
|
||||
== ["maintenance: ABORT (drain stuck at 60 % after 780 min)",
|
||||
"maintenance: the cycle did not finish. It waits 7 days before it "
|
||||
"tries again."])
|
||||
== ["maintenance: ABORT (drain stuck at 60 % after 780 min)"])
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1469,11 +1469,8 @@ _info = [r.getMessage() for r in _out if r.levelno == logging.INFO]
|
||||
check(f"a Home Assistant restart is one plain WARNING line, and one INFO line "
|
||||
f"when both connections are back ({_warn}, {_info})",
|
||||
_warn[:1] == ["Home Assistant is not answering (error 502), perhaps it "
|
||||
"is still starting. Control waits and resumes by itself."]
|
||||
and len(_info) == 1
|
||||
and re.fullmatch(r"Home Assistant answers again after \d+ seconds\.",
|
||||
_info[0]) is not None and len(_warn) == 2)
|
||||
# SAFETY-112's reminder and its timing are graded in test_safety112.py.
|
||||
"is restarting. The add-on keeps trying."]
|
||||
and _info == ["Home Assistant answers again."] and len(_warn) == 2)
|
||||
check("...and the library's own text is kept, at DEBUG",
|
||||
any("Received message 8:1000" in r.getMessage() for r in _out
|
||||
if r.levelno == logging.DEBUG)
|
||||
|
||||
@@ -129,24 +129,6 @@ finally:
|
||||
# cd4d9cb5b7e29ff4ab4fe65dab5106683d8a599f09323fbdee4018ad9b28fcd1.
|
||||
SAFETY78_N2_MAINT = \
|
||||
"1afcd46b1b987868b110d2b0f4a7555848c9aa4728a836d4a2ce2a2107268497"
|
||||
# SAFETY-95 changes it again ON PURPOSE: A's drain ends at t=1655 (its
|
||||
# refusal), and B now stays at the house's 1420 W instead of jumping to
|
||||
# 2500 W (1100 W export) and slewing back. Rows differ only at 1655-2105 s.
|
||||
SAFETY95_N2_MAINT = \
|
||||
"949e7c093dcf065257417de3f2f908e4d57311790abc9fba0a09aa4b5d64c0d2"
|
||||
# SAFETY-91 changes n2 and n2_off ON PURPOSE: from t=1600 battery A is at its
|
||||
# 20 % reserve, so B carries the house first and A gives last. With
|
||||
# `_held_back()` reverted to SAFETY-87's (held only for the peak) both are
|
||||
# a5dddad's again, which the check below grades.
|
||||
SAFETY91_N2 = \
|
||||
"9535c08f556d6d50bec8fb0872445173480c29b10df1a7c437ef73d6ddb2814d"
|
||||
_real_hb = app_main.Controller._held_back
|
||||
app_main.Controller._held_back = lambda self: self._peak_reserve()
|
||||
try:
|
||||
_n2_s87 = rig.digest(rig.run(**rig.SCENARIOS["n2"])[0])
|
||||
finally:
|
||||
app_main.Controller._held_back = _real_hb
|
||||
_S91 = ("n2", "n2_off", "n2_maint")
|
||||
for k in A5DDDAD:
|
||||
print(f" {k:7} {'same' if _now[k] == A5DDDAD[k] else 'DIFFERS'} "
|
||||
f"{_now[k][:16]}")
|
||||
@@ -154,24 +136,14 @@ check("⭐ at N=1 and N=2, with the option absent and with it false, every "
|
||||
"command, reason and allocation record over 540 cycles each is "
|
||||
"byte-identical to a5dddad (house steps, a solar surplus, a battery "
|
||||
"at its cut-out level, at N=2 a refusal with probes, both batteries "
|
||||
"due for maintenance; n2, n2_off and n2_maint apart, below), and so "
|
||||
"is the arbiter on 6000 claim lists",
|
||||
{k: v for k, v in _now.items() if k not in _S91}
|
||||
== {k: v for k, v in A5DDDAD.items() if k not in _S91})
|
||||
# SAFETY-94: with SAFETY-87's held rule, A (cut out at 1600 s) now trips the
|
||||
# stale gate at 1640 s, not 1635 s - 35 s after its command moved rather than
|
||||
# after its reading last changed - so that run is no longer a5dddad's. It was
|
||||
# a5dddad's (a60003b1...) before SAFETY-94. The shipped n2 is unchanged.
|
||||
SAFETY94_N2_S87 = \
|
||||
"4ddea7ef9dc1efa437af899675db442cd531b084ae4cec35951d7c7bb3d6d8b5"
|
||||
check("...n2 and n2_off are the SAFETY-91 digest (A at its reserve gives "
|
||||
"last), and SAFETY-87's held rule gives SAFETY-94's",
|
||||
_now["n2"] == _now["n2_off"] == SAFETY91_N2 != A5DDDAD["n2"]
|
||||
and _n2_s87 == SAFETY94_N2_S87)
|
||||
"due for maintenance; n2_maint apart, below), and so is the arbiter "
|
||||
"on 6000 claim lists",
|
||||
{k: v for k, v in _now.items() if k != "n2_maint"}
|
||||
== {k: v for k, v in A5DDDAD.items() if k != "n2_maint"})
|
||||
check("...and n2_maint is the SAFETY-78 digest: the N=2 drain that a5dddad "
|
||||
"refused on 392 of 540 cycles now runs (test_safety78.py), bounded by "
|
||||
"SAFETY-85, its end handed back by SAFETY-95",
|
||||
_now["n2_maint"] == SAFETY95_N2_MAINT != A5DDDAD["n2_maint"])
|
||||
"SAFETY-85",
|
||||
_now["n2_maint"] == SAFETY78_N2_MAINT != A5DDDAD["n2_maint"])
|
||||
check("...and no cycle in those runs filed a `mode` claim, and only "
|
||||
"n2_maint's (SAFETY-85: maintenance at N=2) handed `compute()` a bound",
|
||||
_claims and not any(c.source == "mode" for cl in _claims for c in cl)
|
||||
|
||||
@@ -66,54 +66,22 @@ A, B = "inverter-a", "inverter-b"
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. modes off: byte-identical to 7a00f12 on the trade path")
|
||||
# Printed on release/1.0 7a00f12 (2026-09-29), goodwe-addon-checks:1, 1080
|
||||
# cycles each; the trade CHARGEs and DISCHARGEs.
|
||||
#
|
||||
# PRICE-02 (2026-10-22) changed two things in the trade sentence and nothing
|
||||
# else: the plan now drops price periods that have ended, so a sale's "N above
|
||||
# the cheapest" is another number once the first cheap period is over, and the
|
||||
# sentences say "price period" for "hour". So there are three pins:
|
||||
# AT_7A00F12_OLD the digests printed on 7a00f12, sentence included. They no
|
||||
# longer match, and the check below says so: that is what
|
||||
# makes the mask necessary.
|
||||
# AT_7A00F12 the same rows through `ident.trade_digest()`, which hides
|
||||
# exactly those two things (and only for this comparison with
|
||||
# an older tree). It equals what 7a00f12 gives through it.
|
||||
# AT_NOW this tree's own digests, sentence included, with nothing
|
||||
# hidden: any later change to a command, a sentence or a
|
||||
# figure moves it. The mask cannot hide a future drift.
|
||||
AT_7A00F12_OLD = {
|
||||
# Printed by tools/opt11p3_identity.py on release/1.0 7a00f12 (2026-09-29),
|
||||
# goodwe-addon-checks:1. 1080 cycles each; the trade CHARGEs and DISCHARGEs.
|
||||
AT_7A00F12 = {
|
||||
"n1": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c",
|
||||
"n2": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
|
||||
"n1_off": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c",
|
||||
"n2_off": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
|
||||
}
|
||||
AT_7A00F12 = {
|
||||
"n1": "33886ef03f364852883f0370230e6e04823fbb46a4704f88ca75bf9082d98c01",
|
||||
"n2": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
|
||||
"n1_off": "33886ef03f364852883f0370230e6e04823fbb46a4704f88ca75bf9082d98c01",
|
||||
"n2_off": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
|
||||
}
|
||||
AT_NOW = {
|
||||
"n1": "939b3d47bbd64fa50e3d70538f092fb98e5c4bd5ead85a7fc696a1dbb514039e",
|
||||
"n2": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d",
|
||||
"n1_off": "939b3d47bbd64fa50e3d70538f092fb98e5c4bd5ead85a7fc696a1dbb514039e",
|
||||
"n2_off": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d",
|
||||
}
|
||||
_cmd_digest = ident.trade_digest
|
||||
_rows = {k: ident.run(**ident.SCENARIOS[k])[0] for k in AT_7A00F12}
|
||||
_got = {k: _cmd_digest(v) for k, v in _rows.items()}
|
||||
_rows = {k: ident.run(**v)[0] for k, v in ident.SCENARIOS.items()}
|
||||
_got = {k: ident.digest(v) for k, v in _rows.items()}
|
||||
for k in AT_7A00F12:
|
||||
print(f" {k} {'same' if _got[k] == AT_7A00F12[k] else 'DIFFERS'} "
|
||||
f"{_got[k][:16]}")
|
||||
_full = {k: ident.digest(v) for k, v in _rows.items()}
|
||||
check("with battery modes off (option absent or false), every command, "
|
||||
"integrator, allocation and trade action equals 7a00f12's, at one "
|
||||
"inverter and at two (the sentence masked as above)",
|
||||
_got == AT_7A00F12)
|
||||
check("...the mask is needed and nothing more: unmasked, n1 and n2 differ "
|
||||
"from 7a00f12's printed digests, and unmasked they equal this tree's "
|
||||
"own pins, so a later change to any sentence or figure fails here",
|
||||
all(_full[k] != AT_7A00F12_OLD[k] for k in AT_7A00F12_OLD)
|
||||
and _full == AT_NOW)
|
||||
"integrator, allocation and trade sentence equals 7a00f12's, at one "
|
||||
"inverter and at two", _got == AT_7A00F12)
|
||||
_acts = {r[-1][0] for r in _rows["n2"] if r[-1]}
|
||||
check("...and the runs reach the trade P3 now skips with modes on: a charge "
|
||||
"and a sale", {arbitrage.CHARGE, arbitrage.DISCHARGE} <= _acts)
|
||||
@@ -128,7 +96,7 @@ def _moved(scn, patch):
|
||||
real(c, t)
|
||||
ident._feed = feed
|
||||
try:
|
||||
return _cmd_digest(ident.run(**ident.SCENARIOS[scn])[0]) \
|
||||
return ident.digest(ident.run(**ident.SCENARIOS[scn])[0]) \
|
||||
!= AT_7A00F12[scn]
|
||||
finally:
|
||||
ident._feed = real
|
||||
@@ -144,7 +112,7 @@ def _moved_headroom(scn):
|
||||
real = peakguard.charge_headroom_w
|
||||
peakguard.charge_headroom_w = lambda g: None
|
||||
try:
|
||||
return _cmd_digest(ident.run(**ident.SCENARIOS[scn])[0]) \
|
||||
return ident.digest(ident.run(**ident.SCENARIOS[scn])[0]) \
|
||||
!= AT_7A00F12[scn]
|
||||
finally:
|
||||
peakguard.charge_headroom_w = real
|
||||
@@ -514,16 +482,10 @@ _ref = [r for r in ROWS_R if present(_first)
|
||||
print(f" B refuses to charge; after 3 min: trades "
|
||||
f"{sorted({str(r['action']) for r in _ref})}, B roles "
|
||||
f"{sorted({r['roles'].get(B) for r in _ref})}")
|
||||
# OPT-15: B's SAFETY-73 probe (every 15 min, 34 s) is the one grid trade
|
||||
# left: PROBE_W on B alone, A resting.
|
||||
_prb = [r for r in _ref if r["action"] is not None]
|
||||
check("B refusing to charge: no grid trade is filed after the refusal is "
|
||||
"learned but B's probe (PROBE_W on B, A resting), B charges only then, "
|
||||
"and A covers the house",
|
||||
_ref and all(r["shares"].get(B) == -200 and r["shares"].get(A, 0) == 0
|
||||
for r in _prb) and len(_prb) <= 0.1 * len(_ref)
|
||||
and all(r["roles"].get(B) != modetrade.CHARGE
|
||||
for r in _ref if r["action"] is None)
|
||||
"learned, B's role is no longer 'charge', and A covers the house",
|
||||
_ref and all(r["action"] is None for r in _ref)
|
||||
and all(r["roles"].get(B) != modetrade.CHARGE for r in _ref)
|
||||
and sum(r["shares"].get(A, 0) > 0 for r in _ref) > len(_ref) / 2)
|
||||
|
||||
# ============================================================================ #
|
||||
@@ -554,7 +516,7 @@ print("\n7. a run edge reseeds the loop")
|
||||
|
||||
|
||||
def _no_reseed(c):
|
||||
c._reseed_run_edge = lambda claims, now: None
|
||||
c._reseed_run_edge = lambda claims: None
|
||||
|
||||
|
||||
def edge(rows, kind):
|
||||
|
||||
@@ -60,42 +60,37 @@ UTC0 = D0.astimezone(timezone.utc)
|
||||
print("1. no usable forecast: byte-identical to 5a4cdc2 (P3 option A)")
|
||||
# Printed by tools/opt11p4_identity.py on release/1.0 5a4cdc2 (2026-09-29),
|
||||
# goodwe-addon-checks:1. 1080 cycles each, battery modes on.
|
||||
# OPT-15: the six scenarios with a grid charge (`_ch`) were re-pinned on the
|
||||
# OPT-15 head: a charge end now holds the loop at the house's level (F2). With
|
||||
# that hold and its reseed switched off they equal 5a4cdc2's again; the others
|
||||
# (no charge) never moved.
|
||||
BASE_DIGESTS = {
|
||||
"n1_ls":
|
||||
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
|
||||
"n1_ch":
|
||||
"97893a54f8a8e3f36ad24f100973b3ff35766527e7287a6ae30cc1c8941bac02",
|
||||
"ff3fb2cdf849bc74185864e0fd87c04c70a947b2f2b08d3f5c9b35ee8b75f490",
|
||||
"n2_sf_ls":
|
||||
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
|
||||
"n2_ch_ls":
|
||||
"9c54abddf877ddacbb3511d9cbe5231ddbda74986a89f396b83c5b2d22dc16f6",
|
||||
"63e09c4323148ec4e1e6766a3c50ab270b6575ec13c35aaa1115d2c5cabc342b",
|
||||
"n2_ls_ls":
|
||||
"98e180d43fa2aeecbe693838d2097dd4cb698c60aba8213c198d80d4e7166265",
|
||||
"n2_sf_ch":
|
||||
"d16d3cac0d9f720b68212c5899624d2a02fb6f7ecae258ddfc8dbba560d47bcf",
|
||||
"95908916505bc5fa460db7b9829d5b56ec216038caa47be4bbf7be775241c7a2",
|
||||
"n1_ls_fc_down":
|
||||
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
|
||||
"n1_ch_fc_down":
|
||||
"97893a54f8a8e3f36ad24f100973b3ff35766527e7287a6ae30cc1c8941bac02",
|
||||
"ff3fb2cdf849bc74185864e0fd87c04c70a947b2f2b08d3f5c9b35ee8b75f490",
|
||||
"n2_sf_ls_fc_down":
|
||||
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
|
||||
"n2_ch_ls_fc_down":
|
||||
"9c54abddf877ddacbb3511d9cbe5231ddbda74986a89f396b83c5b2d22dc16f6",
|
||||
"63e09c4323148ec4e1e6766a3c50ab270b6575ec13c35aaa1115d2c5cabc342b",
|
||||
"n2_ls_ls_fc_down":
|
||||
"98e180d43fa2aeecbe693838d2097dd4cb698c60aba8213c198d80d4e7166265",
|
||||
"n2_sf_ch_fc_down":
|
||||
"d16d3cac0d9f720b68212c5899624d2a02fb6f7ecae258ddfc8dbba560d47bcf",
|
||||
"95908916505bc5fa460db7b9829d5b56ec216038caa47be4bbf7be775241c7a2",
|
||||
}
|
||||
import opt11p4_identity as ident # noqa: E402
|
||||
|
||||
got = ident.digests()
|
||||
for k, v in BASE_DIGESTS.items():
|
||||
check(f"{k}: pinned digest (5a4cdc2; OPT-15 for the _ch ones)",
|
||||
got.get(k) == v)
|
||||
check(f"{k}: same digest as 5a4cdc2", got.get(k) == v)
|
||||
check("every scenario is pinned", set(got) == set(BASE_DIGESTS))
|
||||
for k in [k for k in got if k.endswith("_fc_down")]:
|
||||
check(f"{k}: a set but unavailable forecast equals no forecast",
|
||||
@@ -118,21 +113,19 @@ check("the harness bites: Leftover solar grid-charging with no plan moves "
|
||||
lambda: setattr(modetrade, "GRID_CHARGE_MODES",
|
||||
(packmode.LEFTOVER_SOLAR, packmode.CHEAP_HOURS)),
|
||||
lambda: setattr(modetrade, "GRID_CHARGE_MODES", _gcm)))
|
||||
# OPT-17: the controller reads through `read_sum()`, so that is the seam.
|
||||
_read = solarfc.read_sum
|
||||
_read = solarfc.read
|
||||
|
||||
|
||||
def _as_dark(today, tomorrow, now, **kw):
|
||||
return _read([("sensor.f", {"state": "0", "attributes": {
|
||||
"unit_of_measurement": "kWh"}, "last_updated": now.isoformat()})],
|
||||
[], now)
|
||||
return _read({"state": "0", "attributes": {"unit_of_measurement":
|
||||
"kWh"},
|
||||
"last_updated": now.isoformat()}, None, now)
|
||||
|
||||
|
||||
check("...and an unavailable forecast read as a dark one moves "
|
||||
"n2_sf_ls_fc_down (Leftover solar would buy its shortfall)",
|
||||
_moved("n2_sf_ls_fc_down",
|
||||
lambda: setattr(solarfc, "read_sum", _as_dark),
|
||||
lambda: setattr(solarfc, "read_sum", _read)))
|
||||
_moved("n2_sf_ls_fc_down", lambda: setattr(solarfc, "read", _as_dark),
|
||||
lambda: setattr(solarfc, "read", _read)))
|
||||
|
||||
# ============================================================================ #
|
||||
print("2. the forecast sensors")
|
||||
|
||||
@@ -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 EMS port)", '
|
||||
'"manufacturer": "GoodWe (via RS485 meter emulation)", '
|
||||
'"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 == "b4274714d8557c37")
|
||||
_pin == "eb5022b0455158d7")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
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 = "057be1d8ad7bb582" # DOC-8: ed739d4 digest with only the manufacturer string changed
|
||||
ED739D4_OFF = "501a7d93782a260b"
|
||||
_h, _n, _subs_off = hashlib.sha256(), 0, []
|
||||
for _r in ROSTERS:
|
||||
for _om in [(), ("p1_age",), ("p1_age", "grid")]:
|
||||
|
||||
@@ -1,498 +0,0 @@
|
||||
"""Runnable check for OPT-17, the solar forecast summed from several entities.
|
||||
`python3 test_opt17.py`
|
||||
|
||||
1. `solarfc.read_sum()`: sums in mixed units, refusals, a member down or
|
||||
stale makes that day unusable (never a partial sum), hourly splits, today
|
||||
and tomorrow, an empty list, and one member equal to `read()`.
|
||||
2. `solarfc.entities()`: which key a day uses.
|
||||
3. Options: the validator, the settings notes and config.yaml.
|
||||
4. The REAL controller (tools/opt11p3_replay.py): a one-member list runs
|
||||
exactly like the single key, two halves like one whole, a member down
|
||||
like a forecast down; the status row and the action log name the member
|
||||
once; every member is read in one gather; modes off reads nothing.
|
||||
|
||||
DECISIONS/OPT-17-several-forecast-entities.md has the rules. Mutations
|
||||
(MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
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 opt11p3_replay as rp # noqa: E402
|
||||
from app import (dayplan, homeview, messages, solarfc, uitext, # noqa: E402
|
||||
validate)
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
L = timezone(timedelta(hours=2))
|
||||
UTC0 = datetime(2026, 9, 21, tzinfo=L).astimezone(timezone.utc)
|
||||
TOM = UTC0 + timedelta(days=1)
|
||||
NOW = datetime(2026, 9, 21, 9, 0, tzinfo=L).astimezone(timezone.utc)
|
||||
END = UTC0 + timedelta(days=1)
|
||||
OLD = NOW - timedelta(seconds=solarfc.FRESH_S + 1)
|
||||
|
||||
|
||||
def st(state, unit="kWh", at=NOW, **attrs):
|
||||
a = dict(attrs)
|
||||
if unit is not None:
|
||||
a["unit_of_measurement"] = unit
|
||||
return {"state": state, "attributes": a,
|
||||
"last_updated": at.isoformat(), "last_reported": at.isoformat()}
|
||||
|
||||
|
||||
def members(*states):
|
||||
return [(f"sensor.fc_{i}", s) for i, s in enumerate(states, 1)]
|
||||
|
||||
|
||||
def near(a, b, eps=1e-6):
|
||||
return a is not None and b is not None and abs(a - b) < eps
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. read_sum")
|
||||
fc, why, gaps = solarfc.read_sum(members(st("5"), st("7500", "Wh")), [], NOW)
|
||||
check("two members, kWh + Wh: 5 + 7.5 = 12.5 kWh",
|
||||
fc is not None and near(fc.today.kwh, 12.5) and why is None
|
||||
and gaps == ())
|
||||
check("...and the rest of the day adds up to the sum",
|
||||
near(fc.between(NOW, END), 12.5))
|
||||
fc, why, gaps = solarfc.read_sum(
|
||||
members(st("5"), st("2500", "Wh"), st("0.005", "MWh")), [], NOW)
|
||||
check("three members, kWh + Wh + MWh: 5 + 2.5 + 5 = 12.5 kWh",
|
||||
fc is not None and near(fc.today.kwh, 12.5))
|
||||
for unit in ("W", None, "kW", "%"):
|
||||
fc, why, gaps = solarfc.read_sum(members(st("5"), st("5", unit)), [],
|
||||
NOW)
|
||||
check(f"a member in {unit or 'no unit'} makes today unusable (unit), "
|
||||
f"and is named",
|
||||
fc is None and why == solarfc.UNIT
|
||||
and gaps == (("today", 2, "sensor.fc_2", solarfc.UNIT),))
|
||||
for bad, w in (("unavailable", solarfc.MISSING), ("unknown", solarfc.MISSING),
|
||||
(None, solarfc.MISSING), ("-1", solarfc.BAD),
|
||||
("lots", solarfc.BAD)):
|
||||
s2 = None if bad is None else st(bad)
|
||||
fc, why, gaps = solarfc.read_sum(members(st("5"), s2, st("4")), [], NOW)
|
||||
check(f"a member that reads {bad!r}: no forecast at all ({w}), not "
|
||||
f"the 9 kWh of the others",
|
||||
fc is None and why == w
|
||||
and gaps == (("today", 2, "sensor.fc_2", w),))
|
||||
fc, why, gaps = solarfc.read_sum(members(st("5"), st("4", at=OLD)), [], NOW)
|
||||
check("a stale member (older than 3 h): no forecast (stale), named",
|
||||
fc is None and why == solarfc.STALE
|
||||
and gaps == (("today", 2, "sensor.fc_2", solarfc.STALE),))
|
||||
EVE = datetime(2026, 9, 20, 23, 30, tzinfo=L).astimezone(timezone.utc)
|
||||
MID = datetime(2026, 9, 21, 0, 10, tzinfo=L).astimezone(timezone.utc)
|
||||
fc, why, gaps = solarfc.read_sum(members(st("5", at=MID), st("0", at=EVE)),
|
||||
[], MID)
|
||||
check("a member from before local midnight is stale, not a dark member",
|
||||
fc is None and why == solarfc.STALE)
|
||||
fc, why, gaps = solarfc.read_sum(members(st("unavailable"), st("4", at=OLD)),
|
||||
[], NOW)
|
||||
check("two members down: both named, the first reason is the day's",
|
||||
fc is None and why == solarfc.MISSING and [g[1] for g in gaps] == [1, 2])
|
||||
check("an empty list is not set", solarfc.read_sum([], [], NOW)
|
||||
== (None, solarfc.NOT_SET, ())
|
||||
and solarfc.read_sum([], members(st("5")), NOW)[1] == solarfc.NOT_SET)
|
||||
fc, _w, _g = solarfc.read_sum(members(st("5", at=NOW - timedelta(hours=2)),
|
||||
st("4")), [], NOW)
|
||||
check("the forecast's time is the oldest member's report",
|
||||
fc.at == NOW - timedelta(hours=2))
|
||||
|
||||
# Tomorrow.
|
||||
fc, why, gaps = solarfc.read_sum(members(st("5"), st("4")),
|
||||
members(st("12"), st("8000", "Wh")), NOW)
|
||||
check("tomorrow: 12 + 8 = 20 kWh, and today is 9",
|
||||
fc is not None and near(fc.tomorrow.kwh, 20.0) and near(fc.today.kwh,
|
||||
9.0)
|
||||
and near(fc.between(TOM, TOM + timedelta(days=1)), 20.0))
|
||||
for name, t2, w in (("unavailable", st("unavailable"), solarfc.MISSING),
|
||||
("stale", st("8", at=OLD), solarfc.STALE),
|
||||
("in W", st("8", "W"), solarfc.UNIT)):
|
||||
fc, why, gaps = solarfc.read_sum(members(st("5"), st("4")),
|
||||
members(st("12"), t2), NOW)
|
||||
check(f"a tomorrow member {name}: tomorrow is not used (not 12 kWh), "
|
||||
f"today is kept, the member is named",
|
||||
fc is not None and fc.tomorrow is None and near(fc.today.kwh, 9.0)
|
||||
and why is None
|
||||
and gaps == (("tomorrow", 2, "sensor.fc_2", w),)
|
||||
and fc.between(NOW, TOM + timedelta(hours=9)) is None)
|
||||
fc, _w, gaps = solarfc.read_sum(members(st("5"), st("4")), [], NOW)
|
||||
check("no tomorrow list: today only, nothing named",
|
||||
fc.tomorrow is None and gaps == ())
|
||||
|
||||
# Hourly splits.
|
||||
sol = [{"period_start": (UTC0 + timedelta(minutes=30 * i)).isoformat(),
|
||||
"pv_estimate": 2.0 if 20 <= i < 36 else 0.0} for i in range(48)]
|
||||
wh = {(UTC0 + timedelta(hours=h)).isoformat(): (1000.0 if 8 <= h < 16
|
||||
else 0.0)
|
||||
for h in range(24)}
|
||||
fc, _w, _g = solarfc.read_sum(members(st("8.0", detailedForecast=sol),
|
||||
st("7.0", wh_period=wh)), [], NOW)
|
||||
one_s = solarfc.read(st("8.0", detailedForecast=sol), None, NOW)[0]
|
||||
one_w = solarfc.read(st("7.0", wh_period=wh), None, NOW)[0]
|
||||
spans = [(NOW, END)] + [
|
||||
(datetime(2026, 9, 21, h, tzinfo=L), datetime(2026, 9, 21, h + 1,
|
||||
tzinfo=L))
|
||||
for h in range(9, 20)]
|
||||
check("every member has a split: the splits are merged (hour by hour, the "
|
||||
"sum of each member's own split)",
|
||||
fc.today.periods and near(fc.today.kwh, 15.0)
|
||||
and all(near(fc.between(a, b), one_s.between(a, b)
|
||||
+ one_w.between(a, b)) for a, b in spans))
|
||||
check("...so 10:00-12:00 is 2 (Solcast) + 2 (Open-Meteo) = 4 kWh",
|
||||
near(fc.between(datetime(2026, 9, 21, 10, tzinfo=L),
|
||||
datetime(2026, 9, 21, 12, tzinfo=L)), 4.0))
|
||||
fc, _w, _g = solarfc.read_sum(members(st("8.0", detailedForecast=sol),
|
||||
st("7.0")), [], NOW)
|
||||
bell = solarfc.read(st("15.0"), None, NOW)[0]
|
||||
check("one member without a split: the sum goes on the bell, as one "
|
||||
"state-only sensor of 15 kWh would",
|
||||
fc.today.periods == () and near(fc.today.kwh, 15.0)
|
||||
and all(near(fc.between(a, b), bell.between(a, b)) for a, b in spans))
|
||||
wht = {(TOM + timedelta(hours=h)).isoformat(): (500.0 if 8 <= h < 16 else 0)
|
||||
for h in range(24)}
|
||||
fc, _w, _g = solarfc.read_sum(members(st("5")),
|
||||
members(st("4", wh_period=wht),
|
||||
st("4", wh_period=wht)), NOW)
|
||||
check("tomorrow's splits are merged too (8 x 0.5 kWh each, 8 in all)",
|
||||
fc.tomorrow.periods and near(fc.between(TOM, TOM + timedelta(days=1)),
|
||||
8.0)
|
||||
and near(fc.between(TOM + timedelta(hours=8),
|
||||
TOM + timedelta(hours=10)), 2.0))
|
||||
fc, _w, _g = solarfc.read_sum(members(st("5")),
|
||||
members(st("4", wh_period=wht), st("4")), NOW)
|
||||
check("...and fall back to the bell when one has none",
|
||||
fc.tomorrow.periods == () and near(fc.tomorrow.kwh, 8.0))
|
||||
|
||||
# One member is read().
|
||||
TODAYS = [st("12.5"), st("12500", "Wh"), st("0.0125", "MWh"), st("12.5", "W"),
|
||||
st("12.5", None), st("unavailable"), st("unknown"), None, st("-1"),
|
||||
st("lots"), st("12.5", at=OLD), st("12.5", at=NOW + timedelta(
|
||||
hours=1)), st("8.0", detailedForecast=sol),
|
||||
st("7.0", wh_period=wh), st("0")]
|
||||
TOMS = [None, st("20"), st("20", at=OLD), st("unavailable"), st("20", "W"),
|
||||
st("4", wh_period=wht)]
|
||||
WHENS = [NOW, MID, datetime(2026, 9, 21, 19, 30, tzinfo=L)]
|
||||
same, n = True, 0
|
||||
for now in WHENS:
|
||||
for a in TODAYS:
|
||||
for b in TOMS:
|
||||
n += 1
|
||||
one = solarfc.read(a, b, now)
|
||||
got = solarfc.read_sum([("sensor.f", a)],
|
||||
[("sensor.t", b)] if b is not None
|
||||
else [], now)
|
||||
same = same and got[:2] == one
|
||||
check(f"one member per day gives exactly what read() gives ({n} cases: "
|
||||
f"units, refusals, stale, splits, tomorrow)", same and n == 270)
|
||||
|
||||
# ============================================================================ #
|
||||
print("2. entities")
|
||||
E = solarfc.entities
|
||||
check("the single key alone", E({"pv_forecast_entity": "sensor.a"})
|
||||
== ("sensor.a",))
|
||||
check("the list alone, in order", E({"pv_forecast_entities": [
|
||||
"sensor.b", "sensor.a"]}) == ("sensor.b", "sensor.a"))
|
||||
check("a list with an entry replaces the single key",
|
||||
E({"pv_forecast_entity": "sensor.a",
|
||||
"pv_forecast_entities": ["sensor.b"]}) == ("sensor.b",))
|
||||
check("an empty list or blank rows use the single key",
|
||||
E({"pv_forecast_entity": "sensor.a", "pv_forecast_entities": []})
|
||||
== ("sensor.a",)
|
||||
and E({"pv_forecast_entity": "sensor.a",
|
||||
"pv_forecast_entities": ["", " "]}) == ("sensor.a",))
|
||||
check("a repeated id counts once (it would double that forecast)",
|
||||
E({"pv_forecast_entities": ["sensor.a", "sensor.b", "sensor.a"]})
|
||||
== ("sensor.a", "sensor.b"))
|
||||
check("tomorrow reads its own keys",
|
||||
E({"pv_forecast_entities": ["sensor.a"],
|
||||
"pv_forecast_tomorrow_entities": ["sensor.t1", "sensor.t2"]},
|
||||
tomorrow=True) == ("sensor.t1", "sensor.t2")
|
||||
and E({"pv_forecast_tomorrow_entity": "sensor.t"}, tomorrow=True)
|
||||
== ("sensor.t",))
|
||||
check("nothing set: ()", E({}) == () and E({}, tomorrow=True) == ())
|
||||
|
||||
# ============================================================================ #
|
||||
print("3. options")
|
||||
F = validate._forecast_problems
|
||||
for one, many in (solarfc.TODAY_KEYS, solarfc.TOMORROW_KEYS):
|
||||
p = F({one: "sensor.a", many: ["sensor.b", "sensor.c"]})
|
||||
check(f"{one} and {many} both set: refused, one plain message that "
|
||||
f"says which to keep",
|
||||
len(p) == 1 and "not both" in p[0] and "Keep the list" in p[0]
|
||||
and one in p[0] and many in p[0] and len(p[0].split()) <= 30)
|
||||
check(f"...{many} alone is fine, and so is {one} alone",
|
||||
not F({many: ["sensor.b", "sensor.c"]}) and not F({one: "sensor.a"})
|
||||
and not F({one: "sensor.a", many: []})
|
||||
and not F({one: "", many: ["sensor.b"]}))
|
||||
check(f"...blank rows in {many} are ignored, alone or beside {one}",
|
||||
not F({many: ["", None, "sensor.b"]})
|
||||
and not F({one: "sensor.a", many: ["", None]}))
|
||||
p = F({many: ["sensor.b", "number.x"]})
|
||||
check(f"...an entry in {many} that is not a sensor is refused",
|
||||
len(p) == 1 and "only sensors" in p[0] and "number.x" in p[0])
|
||||
check(f"...and {many} that is not a list is refused",
|
||||
F({many: "sensor.b"}) and "list of sensors" in F({many: "x"})[0])
|
||||
with open(os.path.join(HERE, "translations", "en.yaml"),
|
||||
encoding="utf-8") as fh:
|
||||
EN = fh.read()
|
||||
for k in solarfc.TODAY_KEYS + solarfc.TOMORROW_KEYS:
|
||||
name = re.search(rf"^ {k}:\n name: \"Hardware · ([^\"(]+?)"
|
||||
rf"(?: \(kWh\))?\"$", EN, re.M)
|
||||
check(f"the refusal names {k} by its label on the tab",
|
||||
name is not None
|
||||
and f"“{name.group(1)}” ({k})" in validate.S(k))
|
||||
print(" " + F({"pv_forecast_entity": "sensor.a",
|
||||
"pv_forecast_entities": ["sensor.b"]})[0])
|
||||
VBASE = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "maintenance_charge_w": 2000,
|
||||
"maintenance_discharge_w": 2000, "battery_modes_enabled": True}
|
||||
check("the start-up check refuses both keys set for a day",
|
||||
any("not both" in p for p in validate.validate_startup_config(
|
||||
dict(VBASE, pv_forecast_tomorrow_entity="sensor.a",
|
||||
pv_forecast_tomorrow_entities=["sensor.b"])))
|
||||
and validate.validate_startup_config(
|
||||
dict(VBASE, pv_forecast_entities=["sensor.a", "sensor.b"],
|
||||
pv_forecast_tomorrow_entities=["sensor.c", "sensor.d"]))
|
||||
== [])
|
||||
notes = []
|
||||
validate._entity_notes(notes, dict(VBASE, pv_forecast_entities=[
|
||||
"sensor.a", "sensor.b", "sensor.gone"]),
|
||||
{"sensor.a": {"unit": "kWh"}, "sensor.b": {"unit": "W"}})
|
||||
check("settings notes: a listed sensor not in kWh, and one Home Assistant "
|
||||
"does not have, are each named",
|
||||
any("sensor.b" in n.text and "kWh" in n.text for n in notes)
|
||||
and any("sensor.gone" in n.text for n in notes)
|
||||
and not any("sensor.a " in n.text for n in notes))
|
||||
notes = []
|
||||
validate._entity_notes(notes, dict(VBASE, pv_forecast_tomorrow_entities=[
|
||||
"sensor.t"]), {"sensor.t": {"unit": "kWh"}})
|
||||
check("a tomorrow list without today's forecast gets the same warning as "
|
||||
"the single key", any("used only with today's" in n.text
|
||||
for n in notes))
|
||||
notes = []
|
||||
validate._entity_notes(notes, {"pv_forecast_entities": ["sensor.a"]},
|
||||
{"sensor.a": {"unit": "kWh"}})
|
||||
check("a list with battery modes off gets the 'unused' note",
|
||||
any("battery modes on" in n.text for n in notes))
|
||||
with open(os.path.join(HERE, "config.yaml"), encoding="utf-8") as fh:
|
||||
CFG = fh.read()
|
||||
for k in ("pv_forecast_entities", "pv_forecast_tomorrow_entities"):
|
||||
check(f"config.yaml: {k} defaults to [] (load-bearing) and is a list "
|
||||
f"of str",
|
||||
re.search(rf"^ {k}: \[\]$", CFG, re.M) is not None
|
||||
and re.search(rf"^ {k}:\n - str$", CFG, re.M) is not None)
|
||||
for k in solarfc.TODAY_KEYS[:1] + solarfc.TOMORROW_KEYS[:1]:
|
||||
check(f"config.yaml: {k} is unchanged (str?, default \"\")",
|
||||
re.search(rf"^ {k}: \"\"$", CFG, re.M) is not None
|
||||
and re.search(rf"^ {k}: str\?$", CFG, re.M) is not None)
|
||||
|
||||
# ============================================================================ #
|
||||
print("4. the real controller")
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
# Prices as in test_modes_p4: cheap 01:00-03:00, dear 19:00-20:00 local.
|
||||
DAYW = [0.20] * 96 * 2
|
||||
for i in list(range(4, 12)) + list(range(100, 108)):
|
||||
DAYW[i] = 0.02
|
||||
for i in list(range(76, 80)) + list(range(172, 176)):
|
||||
DAYW[i] = 0.40
|
||||
WPTS = [(UTC0 + timedelta(minutes=15 * i), 900, p)
|
||||
for i, p in enumerate(DAYW)]
|
||||
SF_LS = {"a": (True, "solar_first", False),
|
||||
"b": (True, "leftover_solar", False)}
|
||||
# Each fake sensor: (share of the day's sun, has a split, status).
|
||||
SENSORS = {}
|
||||
FC_KWH = [1.0]
|
||||
START = [UTC0]
|
||||
CALLS = []
|
||||
|
||||
|
||||
async def _sensor(self, e):
|
||||
CALLS.append(("start", e))
|
||||
await asyncio.sleep(0)
|
||||
CALLS.append(("end", e))
|
||||
now = START[0] + timedelta(seconds=self.t)
|
||||
share, split, status = SENSORS[e]
|
||||
if status == "down":
|
||||
return {"state": "unavailable", "attributes": {},
|
||||
"last_updated": now.isoformat()}
|
||||
at = now - timedelta(hours=4) if status == "stale" else now
|
||||
loc = now.astimezone(L)
|
||||
d0 = loc.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
if e.startswith("sensor.tom"):
|
||||
d0 += timedelta(days=1)
|
||||
wh = {(d0 + timedelta(hours=h)).astimezone(timezone.utc).isoformat():
|
||||
(FC_KWH[0] * 100.0 * share if 8 <= h < 18 else 0.0)
|
||||
for h in range(24)}
|
||||
left = sum(v for k, v in wh.items()
|
||||
if datetime.fromisoformat(k) >= now) / 1000.0
|
||||
attrs = {"unit_of_measurement": "kWh"}
|
||||
if split:
|
||||
attrs["wh_period"] = wh
|
||||
return {"state": f"{left:.6f}", "attributes": attrs,
|
||||
"last_updated": at.isoformat(), "last_reported": at.isoformat()}
|
||||
|
||||
|
||||
rp.Hass.state = _sensor
|
||||
|
||||
|
||||
def seed(c):
|
||||
if c.house_profile is not None:
|
||||
hp = dayplan.HouseProfile()
|
||||
t = UTC0 - timedelta(days=1)
|
||||
while t < UTC0:
|
||||
h = t.astimezone(L).hour
|
||||
hp.add(t, 3000.0 if 17 <= h < 22 else 600.0)
|
||||
t += timedelta(seconds=5)
|
||||
hp.add(t, None)
|
||||
c.house_profile = hp
|
||||
|
||||
|
||||
def run(opts, hours=4.0, modes=True):
|
||||
start = UTC0 + timedelta(hours=0.75)
|
||||
START[0] = start
|
||||
rows = []
|
||||
|
||||
def cb(c, h, now, pr):
|
||||
sh = dict(c.allocation.shares) if c.allocation is not None and \
|
||||
c.allocation.shares else {}
|
||||
rows.append((now, round(h.grid(), 6), tuple(sorted(sh.items())),
|
||||
getattr(c.arb, "action", None),
|
||||
tuple(sorted(c.mode_tick.roles.items()))
|
||||
if c.mode_tick else (),
|
||||
tuple(sorted((u, round(p.soc, 6))
|
||||
for u, p in h.plants.items())),
|
||||
tuple(sorted((c.plan_targets() or {}).items())),
|
||||
None if c.day_plan is None else c.day_plan.verdict))
|
||||
_st, c = rp.run("p3_ls" if modes else "modes_off", [(UTC0.timestamp()
|
||||
- 10, 800.0)],
|
||||
WPTS, start, 3600 * hours, check=cb,
|
||||
soc0={"a": 60.0, "b": 30.0}, choice=SF_LS,
|
||||
extra=dict({"cycle_cost_threshold_eur": 0.20}, **opts),
|
||||
patch=seed)
|
||||
return rows, c
|
||||
|
||||
|
||||
def fc_rows(c):
|
||||
return [r for r in c.checks() if r["id"] == "solar_forecast"]
|
||||
|
||||
|
||||
def fc_events(c):
|
||||
return [e for e in c.events if "solar forecast" in e]
|
||||
|
||||
|
||||
SENSORS.update({
|
||||
"sensor.all": (1.0, True, "ok"), "sensor.h1": (0.5, True, "ok"),
|
||||
"sensor.h2": (0.5, True, "ok"), "sensor.h2_flat": (0.5, False, "ok"),
|
||||
"sensor.down": (0.5, True, "down"), "sensor.stale": (0.5, True, "stale"),
|
||||
"sensor.tom_all": (1.0, True, "ok"), "sensor.tom_h1": (0.5, True, "ok"),
|
||||
"sensor.tom_down": (0.5, True, "down"), "sensor.none": (1.0, True, "down"),
|
||||
})
|
||||
single, c_single = run({"pv_forecast_entity": "sensor.all"})
|
||||
listed, c_listed = run({"pv_forecast_entities": ["sensor.all"]})
|
||||
plans = [r for r in single if r[6]]
|
||||
check(f"the run makes a day plan with a level ({len(plans)} of "
|
||||
f"{len(single)} cycles), so the forecast steers something",
|
||||
plans and any(r[7] is not None for r in single))
|
||||
check("a one-member list runs exactly like the single key: every grid "
|
||||
"figure, share, action, role, level and plan, cycle by cycle",
|
||||
single == listed and len(single) > 2000)
|
||||
check("...and says the same on the status page and in the action log",
|
||||
fc_rows(c_single) == fc_rows(c_listed) == []
|
||||
and fc_events(c_single) == fc_events(c_listed))
|
||||
halves, c_halves = run({"pv_forecast_entities": ["sensor.h1", "sensor.h2"]})
|
||||
check("two members of half the sun each run like one sensor of all of it",
|
||||
[r[:6] + (r[7],) for r in halves] == [r[:6] + (r[7],) for r in single]
|
||||
and all(len(a[6]) == len(b[6]) and all(
|
||||
k1 == k2 and abs(v1 - v2) < 1e-6 for (k1, v1), (k2, v2)
|
||||
in zip(a[6], b[6])) for a, b in zip(halves, single)))
|
||||
none_, c_none = run({"pv_forecast_entity": "sensor.none"})
|
||||
check("(the comparison bites: no forecast runs differently)",
|
||||
[r[:6] for r in none_] != [r[:6] for r in single])
|
||||
for name, mem in (("unavailable", "sensor.down"), ("stale", "sensor.stale")):
|
||||
rows, c = run({"pv_forecast_entities": ["sensor.h1", mem]})
|
||||
check(f"a member {name}: the controller runs exactly as with the single "
|
||||
f"forecast unavailable (no partial sum)", rows == none_)
|
||||
check(f"...the status page names it by place, in one short line",
|
||||
[r["text"] for r in fc_rows(c)] == [
|
||||
"Solar forecast for today: not used, source 2 of 2 has no "
|
||||
"reading"] and fc_rows(c)[0]["warn"])
|
||||
ev = fc_events(c)
|
||||
check(f"...and the action log names it once in {len(single)} cycles",
|
||||
len(ev) == 1 and mem in ev[0] and "today" in ev[0])
|
||||
one_down, c_one_down = run({"pv_forecast_entities": ["sensor.none"]})
|
||||
check("a one-member list that is down runs, shows and logs exactly like the "
|
||||
"single key down", one_down == none_
|
||||
and fc_rows(c_one_down) == fc_rows(c_none)
|
||||
and fc_events(c_one_down) == fc_events(c_none))
|
||||
# A forecast missing from the start logs nothing (the start state is
|
||||
# already "missing"); that is release/1.0's behaviour, kept.
|
||||
check("the single key down: no new row and no new log line",
|
||||
fc_rows(c_none) == [] and fc_events(c_none) == [])
|
||||
CALLS.clear()
|
||||
rows, c = run({"pv_forecast_entities": ["sensor.h1", "sensor.h2"],
|
||||
"pv_forecast_tomorrow_entities": ["sensor.tom_h1",
|
||||
"sensor.tom_down"]},
|
||||
hours=0.2)
|
||||
check("a tomorrow member down: today is used, tomorrow is not, and the row "
|
||||
"says so",
|
||||
c.solar_fc is not None and c.solar_fc.tomorrow is None
|
||||
and [r["text"] for r in fc_rows(c)] == [
|
||||
"Solar forecast for tomorrow: not used, source 2 of 2 has no "
|
||||
"reading"]
|
||||
and len(fc_events(c)) == 1 and "sensor.tom_down" in fc_events(c)[0])
|
||||
check("...and the page hint says tomorrow's forecast is not used",
|
||||
(c.hint or "").endswith(messages.hint("forecast_no_tomorrow")))
|
||||
check("...and Home titles the row as the solar forecast, in EN and NL",
|
||||
[a["title"] for a in homeview.attention(fc_rows(c))]
|
||||
== ["Solar forecast"]
|
||||
and uitext.words("nl")["home_words"]["attn"]["solar_forecast"]
|
||||
== "Zonneprognose")
|
||||
first = CALLS[:8]
|
||||
check("every member of both days is read in one gather (all four start "
|
||||
"before any ends)",
|
||||
[k for k, _e in first] == ["start"] * 4 + ["end"] * 4
|
||||
and sorted(e for _k, e in first[:4]) == sorted(
|
||||
["sensor.h1", "sensor.h2", "sensor.tom_h1", "sensor.tom_down"]))
|
||||
rows, c = run({"pv_forecast_entities": ["sensor.h1"],
|
||||
"pv_forecast_tomorrow_entities": ["sensor.tom_all"]},
|
||||
hours=0.2)
|
||||
check("tomorrow: a one-member list is read and counted",
|
||||
c.solar_fc is not None and c.solar_fc.tomorrow is not None)
|
||||
rows, c = run({"pv_forecast_entities": ["sensor.h1", "sensor.h2_flat"]},
|
||||
hours=0.2)
|
||||
check("a member without a split: the controller's forecast is on the bell",
|
||||
c.solar_fc is not None and c.solar_fc.today.periods == ())
|
||||
CALLS.clear()
|
||||
run({"pv_forecast_entities": ["sensor.h1", "sensor.h2"]}, hours=0.2,
|
||||
modes=False)
|
||||
check("battery modes off: no forecast member is read", CALLS == [])
|
||||
check("the status row is a STYLE status row (<= 15 words, no entity id)",
|
||||
all(len(t.split()) <= 15 and "sensor." not in t for t in (
|
||||
messages.row_forecast_gap("today", [2, 3], 3),
|
||||
messages.row_forecast_gap("tomorrow", [1], 2))))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"FAILED: {len(fails)}")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -1,267 +0,0 @@
|
||||
"""Runnable check for OPT-20. `python3 test_opt20.py`
|
||||
|
||||
A refusal probe (OPT-15 F3, SAFETY-73) asks the probing battery PROBE_W and
|
||||
nothing else. On TEST-23 (OPT-15 g7) the probe made the trade ask 2500 W: B
|
||||
was pinned at 200 W and the allocator put the other 2300 W on A, which sat in
|
||||
its maintenance charge phase at 0 W, with the grid at 3100 W for 34 s.
|
||||
|
||||
1. modetrade: the ask carries PROBE_W for the probing battery, the other
|
||||
chargers' ratings as without the probe, and `size()` adds the probe on top
|
||||
of their share (or asks PROBE_W exactly, or nothing).
|
||||
2. main: the ask carries PROBE_W for exactly the pins filed this tick, so the
|
||||
first and the last cycle of a window do not move another battery's share.
|
||||
3. The real Controller in the lagged replay, with B refusing and probed while
|
||||
A is (a) in its maintenance charge phase (also with the charge shaped to
|
||||
0 W), (b) idle, (c) a normal charger in the same run, and the OPT-15 rig's
|
||||
g7 sequence: A's command is the same through the window as just before it,
|
||||
B gets PROBE_W, and the commanded charge rises by PROBE_W at most.
|
||||
4. Battery modes on with no probe: byte-identical to release/1.0 bf9019e.
|
||||
|
||||
Out of scope, and unchanged: a normal (non-probing) trade placed on a battery
|
||||
in maintenance charge (OPT-11 P4 F4) and the action-log wording (UX-38).
|
||||
Mutations: MUTATION.md says how to run them (purged copies); the 12 of
|
||||
OPT-20 are listed in the pull request.
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
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 opt20_scen as sc # noqa: E402
|
||||
from app import arbitrage, modetrade, packmode # noqa: E402
|
||||
from app import control as ctl # noqa: E402
|
||||
from app import main # noqa: E402
|
||||
from app.arbiter import Claim, P_SAFETY # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
PW = ctl.PROBE_W
|
||||
A, B = sc.A, sc.B
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. modetrade: the ask and the size")
|
||||
_runs = SimpleNamespace(on=True, cheap=SimpleNamespace(holds=lambda n: True),
|
||||
charge_useful=lambda: True, give_at=lambda n: None,
|
||||
sell=None)
|
||||
|
||||
|
||||
def _dec(packs, **kw):
|
||||
args = dict(charge_ok=True, sell_ok=True, trade_why=None, price_now=0.1,
|
||||
headroom_w=3000.0, floor_pct=20.0, ceiling_pct=95.0,
|
||||
house_w=800.0)
|
||||
args.update(kw)
|
||||
return modetrade.decide(_runs, sc.UTC0, packs, **args)
|
||||
|
||||
|
||||
_pb = modetrade.Pack(B, True, packmode.CHEAP_HOURS, False, 2500.0, 30.0, 10.0,
|
||||
refused=-1, probing=True)
|
||||
_pa = modetrade.Pack(A, True, packmode.CHEAP_HOURS, False, 2500.0, 50.0, 10.0)
|
||||
_pm = dataclasses.replace(_pa, mode=packmode.SOLAR_FIRST, idle=False)
|
||||
_pi = dataclasses.replace(_pa, mode=packmode.SOLAR_FIRST)
|
||||
|
||||
_t_alone = _dec([_pb, _pm])
|
||||
check("a probe beside a battery in maintenance asks PROBE_W and no more",
|
||||
_t_alone.action == modetrade.CHARGE and _t_alone.ask_w == -PW
|
||||
and _t_alone.probe_w == PW)
|
||||
check("...beside an idle battery that cannot charge from the grid too",
|
||||
_dec([_pb, _pi]).ask_w == -PW and _dec([_pb, _pi]).probe_w == PW)
|
||||
_t_two = _dec([_pb, _pa])
|
||||
check("...beside a normal charger the ask is that charger's rating plus "
|
||||
"PROBE_W", _t_two.ask_w == -2500.0 - PW and _t_two.probe_w == PW)
|
||||
_t_np = _dec([dataclasses.replace(_pb, probing=False), _pa])
|
||||
check("no probe: the ask is the chargers' ratings, probe_w is 0 (as before)",
|
||||
_t_np.ask_w == -2500.0 and _t_np.probe_w == 0.0
|
||||
and _dec([_pa, _pm]).ask_w == -2500.0
|
||||
and modetrade.Tick({}, {}, None, 0.0, None).probe_w == 0.0)
|
||||
|
||||
|
||||
def sz(ask, probe_w, lo, room, action=modetrade.CHARGE):
|
||||
t = modetrade.Tick({}, {}, action, ask, None, probe_w)
|
||||
return modetrade.size(t, (lo, 2500.0), room)
|
||||
|
||||
|
||||
check("size, a probe alone: PROBE_W, whatever the headroom above it",
|
||||
sz(-PW, PW, -(PW), 3000.0) == -PW and sz(-PW, PW, -2700.0, 9000.0) == -PW)
|
||||
check("...exactly PROBE_W or nothing: a headroom or a bound under it files "
|
||||
"no trade (a smaller ask voids the pin and the allocator would place "
|
||||
"it elsewhere)",
|
||||
sz(-PW, PW, -2700.0, PW - 1) == 0.0 and sz(-PW, PW, -(PW - 1), 3000.0)
|
||||
== 0.0 and sz(-PW, PW, -2700.0, 0.0) == 0.0
|
||||
and sz(-PW, PW, -2700.0, PW) == -PW)
|
||||
check("size with another charger: its share is sized as without the probe, "
|
||||
"the probe's PROBE_W is added on top",
|
||||
sz(-2700.0, PW, -2700.0, 3000.0) == -2700.0
|
||||
and sz(-2700.0, PW, -2700.0, 1000.0) == -1200.0
|
||||
and sz(-2700.0, PW, -1700.0, 3000.0) == -1700.0
|
||||
and sz(-2700.0, PW, -2700.0, 0.0) == 0.0)
|
||||
check("size with no probe is what it was (ask, bound, headroom)",
|
||||
sz(-2500.0, 0.0, -2700.0, 3000.0) == -2500.0
|
||||
and sz(-2500.0, 0.0, -2700.0, 1000.0) == -1000.0
|
||||
and sz(-2500.0, 0.0, -1800.0, 3000.0) == -1800.0
|
||||
and sz(-2500.0, 0.0, -2700.0, None) == 0.0
|
||||
and modetrade.size(modetrade.Tick({}, {}, None, 0.0, None),
|
||||
(-5.0, 5.0), 3000.0) == 0.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. main: the ask carries the pins filed this tick")
|
||||
|
||||
|
||||
def trade(tick, pins, bound=(-2700.0, 2500.0), room=3000.0):
|
||||
"""The trade `_mode_trade()` files: (claims, size or None)."""
|
||||
claims = [Claim.set("refused", P_SAFETY, -PW * 1.0, ctl.PROBING,
|
||||
actuator="x") for _ in range(pins)]
|
||||
me = SimpleNamespace(
|
||||
mode_tick=tick, loop_bound=lambda cl: bound, arb_headroom_w=room,
|
||||
mode_runs=SimpleNamespace(
|
||||
cheap=SimpleNamespace(start=sc.UTC0, end=sc.UTC0,
|
||||
mean_eur_kwh=0.1),
|
||||
sell=None, level_eur_kwh=0.1),
|
||||
arb=None, _arb_action=None, log_event=lambda *a, **k: None)
|
||||
filed = main.Controller._mode_trade(me, claims, sc.UTC0)
|
||||
t = [c for c in claims if c.source == arbitrage.SOURCE]
|
||||
return filed, (t[0].value if t else None), me.mode_tick
|
||||
|
||||
|
||||
_t0 = modetrade.Tick({}, {A: modetrade.CHARGE}, modetrade.CHARGE, -2500.0,
|
||||
None, 0.0)
|
||||
check("the pin is filed but decide() ran before it (the window's first "
|
||||
"cycle): the charger keeps its whole ask and the pin is added",
|
||||
trade(_t0, 1)[1] == -2700.0 and trade(_t0, 1)[2].probe_w == PW)
|
||||
check("the window closed after decide() (its last cycle): the probe's PROBE_W "
|
||||
"leaves the ask, so no battery is handed it",
|
||||
trade(_t_two, 0)[1] == -2500.0
|
||||
and trade(_t_alone, 0) == (False, None, trade(_t_alone, 0)[2])
|
||||
and trade(_t_alone, 0)[2].action is None)
|
||||
check("pin and ask agree: nothing changes",
|
||||
trade(_t_two, 1)[1] == -2700.0 and trade(_t_alone, 1)[1] == -PW
|
||||
and trade(_t0, 0)[1] == -2500.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. the real Controller: B refuses and is probed")
|
||||
|
||||
|
||||
def charge(r):
|
||||
return -sum(min(0.0, v) for v in r["shares"].values())
|
||||
|
||||
|
||||
def window_checks(rows, label):
|
||||
"""Per probe window: A as just before, B at PROBE_W (or 0 when nothing is
|
||||
traded), the charge up by at most PROBE_W."""
|
||||
win = sc.windows(rows)
|
||||
ok_a = ok_b = ok_sum = ok_grid = True
|
||||
for i, j in win:
|
||||
pre = rows[i - 1]
|
||||
lo, hi = max(0, i - 3), min(len(rows) - 1, j + 3)
|
||||
ext = rows[lo:hi + 1]
|
||||
base_sum = sum(min(0.0, v) for v in pre["shares"].values())
|
||||
ok_a &= all(abs(r["shares"].get(A, 0.0) - pre["shares"].get(A, 0.0))
|
||||
< 1e-6 for r in ext)
|
||||
bs = [r["shares"].get(B, 0.0) for r in ext]
|
||||
if any(r["action"] for r in rows[i:j + 1]):
|
||||
ok_b &= set(bs) <= {0.0, -PW} and bs.count(-PW) >= 20
|
||||
else: # a drain comes first: the window trades nothing
|
||||
ok_b &= set(bs) == {0.0}
|
||||
ok_sum &= all(charge(r) <= charge(pre) + PW + 1e-6 for r in ext)
|
||||
ok_grid &= max(r["grid"] for r in ext) <= pre["grid"] + PW + 50.0
|
||||
n_ch = sum(1 for r in rows if r["probing"] and r["action"])
|
||||
print(f" {label}: {len(win)} window(s), {n_ch} probing cycles with a "
|
||||
f"trade, highest grid "
|
||||
f"{max(r['grid'] for r in rows):.0f} W")
|
||||
return win, ok_a, ok_b, ok_sum, ok_grid
|
||||
|
||||
|
||||
SCEN = {
|
||||
"(a) A in its maintenance charge phase": (
|
||||
sc.day(sc.SF_CH, a_maint=sc.MAINT_CHARGE), "same"),
|
||||
"(a') ...with the charge shaped to 0 W": (
|
||||
sc.day(sc.SF_CH, a_maint=sc.MAINT_CHARGE, shaped=True), "same"),
|
||||
"(b) A idle at 0 W (Solar first, the sun covers the house)": (
|
||||
sc.day(sc.SF_CH, house_w=-10.0), "same"),
|
||||
"(c) A a normal charger in the same run": (sc.day(sc.CH_CH), "same"),
|
||||
"(g7) the OPT-15 rig: drain, then maintenance charge at 0 W": (
|
||||
sc.g7(), "same"),
|
||||
# Not the fix's: A gives the house, and a charge ask means no other
|
||||
# battery gives (OPT-11 owner answer 3), so A rests, as on bf9019e.
|
||||
"(b2) A gives 800 W to the house": (sc.day(sc.SF_CH), "rests"),
|
||||
}
|
||||
for _name, (_rows, _kind) in SCEN.items():
|
||||
win, ok_a, ok_b, ok_sum, ok_grid = window_checks(_rows, _name)
|
||||
check(f"{_name}: a probe happens (non-vacuous)", len(win) >= 1)
|
||||
if _kind == "same":
|
||||
check(f"{_name}: A's command is the same through each window, first "
|
||||
"and last cycle included", ok_a)
|
||||
check(f"{_name}: the grid rises by PROBE_W at most (plus 50 W of "
|
||||
"noise)", ok_grid)
|
||||
else:
|
||||
check(f"{_name}: A stops giving for the window (the one-sign rule, "
|
||||
"as on bf9019e), charges nothing and gives back after it",
|
||||
all(r["shares"].get(A, 0.0) == 0.0 for i, j in win
|
||||
for r in _rows[i + 1:j]) and all(
|
||||
_rows[j + 4]["shares"].get(A, 0.0) > 0.0 for i, j in win))
|
||||
check(f"{_name}: B gets PROBE_W and nothing else", ok_b)
|
||||
check(f"{_name}: the commanded charge rises by PROBE_W at most", ok_sum)
|
||||
|
||||
# The g7 sequence in detail: the probe after the drain.
|
||||
_g7 = SCEN["(g7) the OPT-15 rig: drain, then maintenance charge at 0 W"][0]
|
||||
_w7 = sc.windows(_g7)
|
||||
_after = [w for w in _w7 if _g7[w[0]]["now"] > sc.at(18.3, sc.GUTC0)]
|
||||
_peak7 = max(r["grid"] for r in _g7 if r["now"] > sc.at(18.3, sc.GUTC0))
|
||||
print(f" g7: probes after the drain at "
|
||||
f"{[_g7[w[0]]['now'].astimezone(sc.L).strftime('%H:%M:%S') for w in _after]}"
|
||||
f", grid after the drain at most {_peak7:.0f} W (TEST-23: 3100 W)")
|
||||
check("g7: the probe after A's drain asks B for 200 W with A at 0 W "
|
||||
"(TEST-23: -2300 W on A, the grid at 3100 W)",
|
||||
_after and all(
|
||||
abs(r["shares"].get(A, 0.0)) < 1e-6 for i, j in _after
|
||||
for r in _g7[i:j + 4]) and _peak7 <= 800.0 + PW + 50.0)
|
||||
check("g7: during the drain no probe trades (a drain comes first)",
|
||||
all(r["action"] is None for r in _g7
|
||||
if sc.at(18.0, sc.GUTC0) <= r["now"] < sc.at(18.28, sc.GUTC0)))
|
||||
_acts = [r["reason"] for i, j in _after for r in _g7[i:j + 1]
|
||||
if r["action"]]
|
||||
check("g7: the action log's trade line names the probe's watts, 200 W",
|
||||
_acts and all(f"{PW:.0f} W on {B}" in a for a in _acts))
|
||||
check("no opposite-direction split on any of these cycles",
|
||||
not [r for _r, _ in SCEN.values() for r in _r
|
||||
if any(v > 0 for k, v in r["shares"].items() if r["action"])
|
||||
and any(v < 0 for v in r["shares"].values())])
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. battery modes on, no probe: byte-identical to release/1.0 bf9019e")
|
||||
# Printed by tools/opt20_scen.py on release/1.0 bf9019e (2026-10-15),
|
||||
# goodwe-addon-checks:1.
|
||||
AT_BASE = {
|
||||
"sf_ch": "422d6578a7decf1ae3749edd1898ff3cd0541d904c95f636cdd9b1de8c1a07c9",
|
||||
"ch_ch": "15fc25dd34186e56d34b752d37c9e8613295ba737938c92454acca08c83080fb",
|
||||
"ch_ch_maint":
|
||||
"e46e3c666dd63ab33b56e83c0a560c07cec7774a9a70560f55c62a6ae6f55920",
|
||||
}
|
||||
_got = sc.no_probe_digests()
|
||||
for _k in AT_BASE:
|
||||
print(f" {_k} {'same' if _got[_k] == AT_BASE[_k] else 'DIFFERS'} "
|
||||
f"{_got[_k][:16]}")
|
||||
check("with no refusal every share, grid, trade sentence, role and plant "
|
||||
"power of a scripted cheap run equals bf9019e's (A Solar first, A "
|
||||
"charging, A in maintenance charge)", _got == AT_BASE)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -3132,10 +3132,6 @@ check("...and between them the nineteen appends account for exactly the "
|
||||
# through this file's reading of main.py. It grades three of the eight appends.
|
||||
# The static pin above is what covers the other five.
|
||||
_b2, _restore_b2 = second_inverter(c_src)
|
||||
# SAFETY-91: B states a cut-out, as every roster entry must (rule 8). Without
|
||||
# one it is held back at its reserve and gives last, so A alone would carry
|
||||
# this cycle's 500 W and the broadcast check below could not see a split.
|
||||
_b2.bms_soc_floor = c_src.inverters[0].bms_soc_floor
|
||||
_n2_writes_before = len(c_src.hass.per_entity)
|
||||
try:
|
||||
_, _cl2 = cycle_claims(lambda: run_cycle(c_src))
|
||||
|
||||
@@ -99,11 +99,6 @@ from app.static_tariff import (
|
||||
TariffPlan, parse_taxes_profile, static_status)
|
||||
from app.validate import validate_startup_config
|
||||
|
||||
|
||||
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
|
||||
# point these fixtures build, so no period has ended and the whole series counts.
|
||||
from datetime import datetime as _DT, timezone as _TZ # noqa: E402
|
||||
LONG_AGO = _DT(2000, 1, 1, tzinfo=_TZ.utc)
|
||||
fails = []
|
||||
|
||||
|
||||
@@ -539,15 +534,11 @@ FIXTURES = {
|
||||
[v / 1000 for v in EPEX_EUR_MWH]),
|
||||
"custom_formula": (columnar_feed(EPEX_EUR_MWH), FORMULA_EUR_KWH),
|
||||
"manual_csv": (None, [0.1834, -0.0210, 0.3115]),
|
||||
# PRICE-01: the owner's price server answers the same columnar layout.
|
||||
"price_service": (columnar_feed(EPEX_EUR_MWH),
|
||||
[v / 1000 for v in EPEX_EUR_MWH]),
|
||||
}
|
||||
|
||||
check(f"the plugins that ship after TARIFF-07 and TARIFF-15 are registered "
|
||||
f"{sorted(PROVIDERS)}",
|
||||
set(PROVIDERS) == {"generic_dayahead", "custom_formula", "manual_csv",
|
||||
"price_service"})
|
||||
set(PROVIDERS) == {"generic_dayahead", "custom_formula", "manual_csv"})
|
||||
check(f"...and the three withdrawn ids resolve to NOTHING "
|
||||
f"{sorted(RETIRED_PROVIDERS)}",
|
||||
set(RETIRED_PROVIDERS) == {"engie_dynamic", "totalenergies_dynamic",
|
||||
@@ -580,7 +571,7 @@ def canned(body):
|
||||
"""A transport that serves recorded bytes and records that it was used."""
|
||||
calls = []
|
||||
|
||||
async def t(url, timeout_s, headers=None):
|
||||
async def t(url, timeout_s):
|
||||
calls.append((url, timeout_s))
|
||||
return body
|
||||
t.calls = calls
|
||||
@@ -612,9 +603,6 @@ def load_any(pid):
|
||||
# is exactly what section 3 grades.
|
||||
return load(pid, {"price_formula": json.dumps(ENECO_CARD)},
|
||||
transport=canned(body))
|
||||
if pid == "price_service":
|
||||
return load(pid, {"price_service_token": "test-token-1234"},
|
||||
transport=canned(body))
|
||||
return load(pid, transport=canned(body))
|
||||
|
||||
|
||||
@@ -910,16 +898,7 @@ _networked = sorted(pid for pid, cls in PROVIDERS.items()
|
||||
# SAME CAPTURED BYTES (section 13c), because it reads the same endpoint with
|
||||
# the same parser. A networked plugin with no capture is what this line exists
|
||||
# to forbid.
|
||||
#
|
||||
# ⚠️ PRICE-01 PUT A THIRD ON IT, AND ITS CAPTURE IS A RESHAPE, SAID PLAINLY. The
|
||||
# price server answers only to a token this session does not hold, so no real
|
||||
# answer of its own exists to replay. Its answer is the energy-charts layout
|
||||
# (design, API section), so test_price_service.py replays the REAL energy-charts
|
||||
# two-day capture with the three extra keys the design lists (`covers`,
|
||||
# `source`, `fetched_at`). The prices and instants are real; the wrapper is the
|
||||
# design's. The first answer captured from the live server on the rig test
|
||||
# replaces it - that is the open item, and it is in the PRICE-01 report.
|
||||
_replayed = ["custom_formula", "generic_dayahead", "price_service"]
|
||||
_replayed = ["custom_formula", "generic_dayahead"]
|
||||
check(f"the networked plugins are {_networked}, and EVERY one of them is "
|
||||
f"replayed from a captured real body ({_replayed}) - no shipped plugin "
|
||||
f"reaches a network this file has never seen answer (TARIFF-07)",
|
||||
@@ -1273,7 +1252,7 @@ check(f"a negative day-ahead price passes through, all-in {_neg}",
|
||||
# (TARIFF-15): it reads the SAME columnar document, which has no per-point
|
||||
# flag, and applies arithmetic to it - arithmetic cannot invent a provenance
|
||||
# the document never carried.
|
||||
_SETTLED_FEED = {"generic_dayahead", "custom_formula", "price_service"}
|
||||
_SETTLED_FEED = {"generic_dayahead", "custom_formula"}
|
||||
for pid in sorted(set(READS) - _SETTLED_FEED):
|
||||
read = READS[pid]
|
||||
flags = derived(lambda r=read: [p.provisional for p in r.points])
|
||||
@@ -4523,8 +4502,8 @@ _T11_FLAT = TariffPlan(
|
||||
[PricePoint(_T11_NOW + timedelta(minutes=15 * i), 0.10, False)
|
||||
for i in range(96)],
|
||||
PriceProduct.DAY_AHEAD, "generic_dayahead", _T11_NOW), None, "provider")
|
||||
_r83 = derived(lambda: _T11_ONEFIRM.arbitrage(now=LONG_AGO, **_T11_A9).reason)
|
||||
_rflat = derived(lambda: _T11_FLAT.arbitrage(now=LONG_AGO, **_T11_A9).reason)
|
||||
_r83 = derived(lambda: _T11_ONEFIRM.arbitrage(**_T11_A9).reason)
|
||||
_rflat = derived(lambda: _T11_FLAT.arbitrage(**_T11_A9).reason)
|
||||
print(f" §8.3 refusal : {_r83[:118]}")
|
||||
print(f" flat refusal : {_rflat[:118]}")
|
||||
# 96 quarter-hourly points span 24 hours; ONE firm point spans one of them.
|
||||
@@ -4833,7 +4812,7 @@ def _t17_verdict(points):
|
||||
read = PriceRead.series(points, PriceProduct.DAY_AHEAD, "t17",
|
||||
datetime.now(UTC))
|
||||
plan = TariffPlan(read, None, "provider")
|
||||
return plan.arbitrage(now=LONG_AGO, peak_guard_active=False,
|
||||
return plan.arbitrage(peak_guard_active=False,
|
||||
min_spread_eur_kwh=PRICE_OPTION_DEFAULTS[
|
||||
"cycle_cost_threshold_eur"],
|
||||
uncertainty=frozenset())
|
||||
|
||||
@@ -1,538 +0,0 @@
|
||||
"""Runnable check for PRICE-02. `python3 test_price02.py`
|
||||
|
||||
The trading verdict is taken over the price periods that have not ended.
|
||||
`TariffPlan.arbitrage(now=...)` drops the points with `end <= now` (the span
|
||||
`price._cover_table()` gives each one) and every caller passes its `now`:
|
||||
`arbitrage.decide()` (modes off), the `allowed()` gate in
|
||||
`Controller._mode_roles()` (modes on) and the Home page's `trade_verdict()`.
|
||||
|
||||
1. A 15-minute day, probed at :05, :20, :35 and :50 of the cheap hour: the
|
||||
buy leg and the sell leg move to the current period and never name one that
|
||||
has ended, and `decide()` trades in it. Cheapest in the past, the current
|
||||
and a future period are all covered.
|
||||
2. A plan built at 03:00 and used at 03:20 (the 30-minute refresh lag), and the
|
||||
shipped refresh (`fill_from_yesterday`, which keeps the whole current hour).
|
||||
3. An hourly feed and the static day/night tariff: an hourly point that is
|
||||
still current stays in. A plan built off the hour (the refresh runs every
|
||||
30 min from add-on start, so :20 and :50 are normal) no longer aims at the
|
||||
hour that has just ended, so a trade runs through (owner, 2026-10-22), and
|
||||
a period that ends exactly at `now` is over.
|
||||
4. Modes on: a spread that exists only because of an ended period no longer
|
||||
opens the gate (`Controller._mode_roles`), nor the Home page verdict.
|
||||
5. The reason text says "price period"; `now` is required (no default).
|
||||
6. Ties. The verdict takes the first extreme still ahead, where it used to
|
||||
take the first of the whole plan. On hour-constant 15-minute data (four
|
||||
equal quarters) that is four acting periods in the cheap hour, not one;
|
||||
the number of times the action changes does not grow. The same on a real
|
||||
15-minute day, and hourly alternating prices change at most once an hour.
|
||||
|
||||
Mutations: MUTATION.md says how to run them (purged copies); the ones of
|
||||
PRICE-02 are listed in the pull request.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
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)
|
||||
|
||||
from app import arbitrage, homeview # noqa: E402
|
||||
from app.arbitrage import CHARGE, DISCHARGE, decide # noqa: E402
|
||||
from app.price import (PricePoint, PriceProduct, PriceRead, # noqa: E402
|
||||
Uncertainty, brussels)
|
||||
from app.price_forecast import fill_from_yesterday # noqa: E402
|
||||
from app.static_tariff import TariffPlan, static_plan # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
UTC = timezone.utc
|
||||
LONG_AGO = datetime(2000, 1, 1, tzinfo=UTC) # before every point: nothing has ended, the old reach
|
||||
NO_WEATHER = frozenset({Uncertainty.NO_WEATHER})
|
||||
D0 = datetime(2026, 10, 20, 0, 0, tzinfo=UTC) # 02:00 Brussels
|
||||
H3 = D0 + timedelta(hours=1) # 03:00 Brussels
|
||||
ARGS = dict(peak_guard_active=False, min_spread_eur_kwh=0.10,
|
||||
uncertainty=NO_WEATHER)
|
||||
|
||||
|
||||
def qplan(prices, start=D0, minutes=15, product=PriceProduct.DAY_AHEAD):
|
||||
pts = [PricePoint(brussels(start + timedelta(minutes=minutes * i)), p,
|
||||
False) for i, p in enumerate(prices)]
|
||||
return TariffPlan(PriceRead.series(pts, product, "generic_dayahead",
|
||||
start), None, "provider")
|
||||
|
||||
|
||||
ARGS_NO_NOW = dict(ARGS)
|
||||
ARGS5 = dict(ARGS, min_spread_eur_kwh=0.05, uncertainty=frozenset())
|
||||
|
||||
|
||||
def dec(plan, now, **kw):
|
||||
base = dict(peak_guard_active=False, charge_headroom_w=5000.0, soc=50.0,
|
||||
min_spread_eur_kwh=0.10, uncertainty=frozenset(),
|
||||
charge_w=2500.0, discharge_w=2500.0, soc_floor_pct=10.0,
|
||||
soc_ceiling_pct=90.0)
|
||||
base.update(kw)
|
||||
return decide(plan, now, **base)
|
||||
|
||||
|
||||
def hhmm(t):
|
||||
return f"{brussels(t):%H:%M}"
|
||||
|
||||
|
||||
def ahead_extreme(prices, now, pick, start=D0, minutes=15):
|
||||
"""The expected leg, by plain arithmetic: the extreme price over the
|
||||
periods whose end is after `now`, as the start instant (first on a tie)."""
|
||||
best = None
|
||||
for i, p in enumerate(prices):
|
||||
s = start + timedelta(minutes=minutes * i)
|
||||
if s + timedelta(minutes=minutes) <= now:
|
||||
continue
|
||||
if best is None or pick(p, best[1]):
|
||||
best = (s, p)
|
||||
return best[0]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. a 15-minute day: the verdict is taken from the current period on")
|
||||
BUY = [0.30] * 192 # 48 h of quarters from 02:00
|
||||
for q, p in ((4, 0.05), (5, 0.06), (6, 0.07), (7, 0.08), (68, 0.50)):
|
||||
BUY[q] = p # 03:00 .05 03:15 .06 03:30 .07 03:45 .08 19:00 .50
|
||||
BUY_PLAN = qplan(BUY)
|
||||
check("the fixture: the day's cheapest period is 03:00, then three dearer "
|
||||
"ones, and the dearest is 19:00 - so every probe has a past, a current "
|
||||
"and a future period to choose between",
|
||||
BUY.index(min(BUY)) == 4 and BUY.index(max(BUY)) == 68
|
||||
and BUY[4] < BUY[5] < BUY[6] < BUY[7] < min(BUY[8:68]))
|
||||
want_q = {5: 4, 20: 5, 35: 6, 50: 7}
|
||||
for mins, q in want_q.items():
|
||||
now = H3 + timedelta(minutes=mins)
|
||||
v = BUY_PLAN.arbitrage(now=now, **ARGS)
|
||||
d = dec(BUY_PLAN, now)
|
||||
got = v.cheapest.start
|
||||
exp = ahead_extreme(BUY, now, lambda a, b: a < b)
|
||||
check(f"03:{mins:02d}: the buy leg is {hhmm(exp)} (the first period not "
|
||||
f"over), the verdict says {hhmm(got)}, and decide() is {d.action}",
|
||||
got == exp == D0 + timedelta(minutes=15 * q)
|
||||
and d.action == CHARGE
|
||||
and abs(d.price_now_eur_kwh - BUY[q]) < 1e-9)
|
||||
check("before the change the same plan at 03:20 aimed at 03:00 (the "
|
||||
"verdict without `now`) and decide() did nothing - the defect, shown",
|
||||
BUY_PLAN.arbitrage(now=LONG_AGO, **ARGS).cheapest.start == H3
|
||||
and arbitrage._act(BUY_PLAN, H3 + timedelta(minutes=20),
|
||||
BUY_PLAN.arbitrage(now=LONG_AGO, **ARGS), 50.0, 2500.0, 2500.0,
|
||||
10.0, 90.0, 5000.0).action is None)
|
||||
|
||||
# A cheapest period still AHEAD stays the target from every probe.
|
||||
FUT = [0.30] * 192
|
||||
FUT[8], FUT[68] = 0.04, 0.50 # cheapest 04:00, dearest 19:00
|
||||
FUT_PLAN = qplan(FUT)
|
||||
check("the cheapest in the FUTURE (04:00) is the buy leg at :05, :20, :35 and "
|
||||
":50 of the hour before it - nothing ahead is dropped",
|
||||
all(FUT_PLAN.arbitrage(now=H3 + timedelta(minutes=m), **ARGS)
|
||||
.cheapest.start == H3 + timedelta(hours=1) for m in (5, 20, 35, 50)))
|
||||
check("...and at 04:20 (that period is now the current one) it is still the "
|
||||
"current period at :00-:15 only: 04:20 aims elsewhere",
|
||||
FUT_PLAN.arbitrage(now=H3 + timedelta(hours=1, minutes=20), **ARGS)
|
||||
.cheapest.start != H3 + timedelta(hours=1))
|
||||
|
||||
print("\n1b. the sell leg")
|
||||
SELL = [0.30] * 192
|
||||
for q, p in ((4, 0.70), (5, 0.65), (6, 0.60), (7, 0.55), (68, 0.10)):
|
||||
SELL[q] = p # dearest 03:00 .70 .. 03:45 .55 ; cheapest 19:00
|
||||
SELL_PLAN = qplan(SELL)
|
||||
for mins, q in want_q.items():
|
||||
now = H3 + timedelta(minutes=mins)
|
||||
v = SELL_PLAN.arbitrage(now=now, **ARGS)
|
||||
d = dec(SELL_PLAN, now)
|
||||
exp = ahead_extreme(SELL, now, lambda a, b: a > b)
|
||||
check(f"03:{mins:02d}: the sell leg is {hhmm(exp)}, the verdict says "
|
||||
f"{hhmm(v.dearest.start)}, and decide() is {d.action}",
|
||||
v.dearest.start == exp == D0 + timedelta(minutes=15 * q)
|
||||
and d.action == DISCHARGE
|
||||
and abs(d.price_now_eur_kwh - SELL[q]) < 1e-9)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. the plan is older than the verdict")
|
||||
# Built at 03:00 and used at 03:20, 03:35 (the refresh runs every 30 min).
|
||||
built = H3
|
||||
feed = qplan(BUY).read
|
||||
plan_0300 = TariffPlan(fill_from_yesterday(
|
||||
feed, yesterday=PriceRead.no_data("none"), since=built,
|
||||
until=built + timedelta(hours=48), stamp=built), None, "provider")
|
||||
check("the plan built at 03:00 starts at 03:00 (the refresh floors to the hour)",
|
||||
min(p.start for p in plan_0300.read.points) == H3)
|
||||
for mins, q in ((20, 5), (35, 6)):
|
||||
now = H3 + timedelta(minutes=mins)
|
||||
d = dec(plan_0300, now)
|
||||
check(f"used at 03:{mins:02d} the verdict is "
|
||||
f"{hhmm(D0 + timedelta(minutes=15 * q))} and decide() charges there",
|
||||
plan_0300.arbitrage(now=now, **ARGS).cheapest.start
|
||||
== D0 + timedelta(minutes=15 * q) and d.action == CHARGE)
|
||||
# The shipped refresh at 03:20 keeps the whole hour: the verdict still skips it.
|
||||
now = H3 + timedelta(minutes=20)
|
||||
plan_refresh = TariffPlan(fill_from_yesterday(
|
||||
feed, yesterday=PriceRead.no_data("none"), since=now,
|
||||
until=now + timedelta(hours=48), stamp=now), None, "provider")
|
||||
check("the shipped refresh at 03:20 still holds the 03:00 period, as before, "
|
||||
"and the verdict does not aim at it",
|
||||
min(p.start for p in plan_refresh.read.points) == H3
|
||||
and plan_refresh.arbitrage(now=now, **ARGS).cheapest.start
|
||||
== H3 + timedelta(minutes=15))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. hourly feeds and the static tariff: a current hour stays in")
|
||||
HR = [0.30] * 48 # 48 hourly points from 02:00
|
||||
HR[1], HR[17] = 0.05, 0.50 # cheapest 03:00, dearest 19:00
|
||||
HR_PLAN = qplan(HR, minutes=60)
|
||||
same = True
|
||||
for m in (0, 5, 20, 35, 59):
|
||||
now = H3 + timedelta(minutes=m)
|
||||
a = HR_PLAN.arbitrage(now=now, **ARGS)
|
||||
b = HR_PLAN.arbitrage(now=LONG_AGO, **ARGS)
|
||||
same = same and a.cheapest is b.cheapest and a.dearest is b.dearest \
|
||||
and a.spread_eur_kwh == b.spread_eur_kwh
|
||||
check("an hourly point that is still current stays in: at 03:00, :05, :20, "
|
||||
":35 and :59 the verdict is the one without `now`", same)
|
||||
check("...and decide() still charges in the current hour at 03:40",
|
||||
dec(HR_PLAN, H3 + timedelta(minutes=40)).action == CHARGE)
|
||||
check("...and once the hour has ended its point is gone: at 04:05 the buy leg "
|
||||
"is no longer 03:00",
|
||||
HR_PLAN.arbitrage(now=H3 + timedelta(hours=1, minutes=5), **ARGS)
|
||||
.cheapest.start != H3)
|
||||
|
||||
TWO = {"periods": [
|
||||
{"name": "day", "from": "07:00", "to": "22:00", "days": "mon-sun",
|
||||
"eur_per_kwh": 0.40},
|
||||
{"name": "night", "from": "22:00", "to": "07:00", "days": "mon-sun",
|
||||
"eur_per_kwh": 0.18}]}
|
||||
OPTS = {"price_provider": "off", "static_profile": json.dumps(TWO)}
|
||||
ok, n_checked = True, 0
|
||||
for h in range(0, 24):
|
||||
for m in (0, 20, 50):
|
||||
now = D0 + timedelta(hours=h - 2, minutes=m) # from 22:00 UTC the day before
|
||||
plan = static_plan(OPTS, now, now + timedelta(hours=48), now=now)
|
||||
a = plan.arbitrage(now=now, **ARGS)
|
||||
b = plan.arbitrage(now=LONG_AGO, **ARGS)
|
||||
n_checked += 1
|
||||
ok = ok and a.kind == b.kind == "allowed" and a.cheapest is b.cheapest \
|
||||
and a.dearest is b.dearest and a.spread_eur_kwh == b.spread_eur_kwh
|
||||
check(f"the static day/night plan, built at the instant it is used (so it "
|
||||
f"holds no ended hour), gives the same verdict with and without `now` "
|
||||
f"at {n_checked} instants", ok and n_checked == 72)
|
||||
|
||||
# 3b. The refresh is NOT on the hour. main.py rebuilds the plan every 30 min
|
||||
# counted from add-on start (TARIFF_REFRESH_S = 1800), so a plan built at :20
|
||||
# or :50 starts at the floored hour and, until the next refresh, still holds
|
||||
# the hour that has just ended. Owner decision 2026-10-22: the verdict skips
|
||||
# that hour too, so a trade runs through instead of pausing until the refresh.
|
||||
print("\n3b. hourly and static plans built off the hour: the ended hour is not the target")
|
||||
HR2 = [0.30] * 48 # hourly from 02:00 Brussels
|
||||
HR2[1], HR2[2], HR2[17] = 0.05, 0.05, 0.50 # cheap 03:00 and 04:00 (a tie), dear 19:00
|
||||
built = H3 + timedelta(minutes=50) # the refresh at :50 floors to 03:00
|
||||
plan_h = TariffPlan(fill_from_yesterday(
|
||||
qplan(HR2, minutes=60).read, yesterday=PriceRead.no_data("none"),
|
||||
since=built, until=built + timedelta(hours=48), stamp=built),
|
||||
None, "provider")
|
||||
now = H3 + timedelta(hours=1, minutes=5) # 04:05, before the next refresh
|
||||
check("the hourly plan built at 03:50 starts at 03:00 and is used at 04:05, so "
|
||||
"it still holds the 03:00 hour, which is over",
|
||||
min(p.start for p in plan_h.read.points) == H3)
|
||||
old_v = plan_h.arbitrage(now=LONG_AGO, **ARGS)
|
||||
new_v = plan_h.arbitrage(now=now, **ARGS)
|
||||
check("without `now` (the old verdict) the buy leg is the ended 03:00 hour "
|
||||
"(first of the tie) and decide() does nothing at 04:05 - the pause",
|
||||
old_v.cheapest.start == H3
|
||||
and arbitrage._act(plan_h, now, old_v, 50.0, 2500.0, 2500.0, 10.0,
|
||||
90.0, 5000.0).action is None)
|
||||
check("with `now` the buy leg is 04:00, the hour in progress, and decide() "
|
||||
"charges at 04:05: the trade runs through",
|
||||
new_v.cheapest.start == H3 + timedelta(hours=1)
|
||||
and dec(plan_h, now).action == CHARGE)
|
||||
|
||||
# The day/night tariff, refresh at :20 of the hour: all 15 day hours tie.
|
||||
b_local = D0 + timedelta(hours=10, minutes=20) # 12:20 Brussels
|
||||
plan_s = static_plan(OPTS, b_local, b_local + timedelta(hours=48), now=b_local)
|
||||
now = D0 + timedelta(hours=11, minutes=5) # 13:05 Brussels
|
||||
old_v = plan_s.arbitrage(now=LONG_AGO, **ARGS)
|
||||
new_v = plan_s.arbitrage(now=now, **ARGS)
|
||||
check("the day/night plan built at 12:20 starts at 12:00; used at 13:05 its "
|
||||
"sell leg without `now` is the ended 12:00 hour (first of the tied day "
|
||||
"hours) and decide() does nothing - the old hourly pause",
|
||||
min(p.start for p in plan_s.read.points) == D0 + timedelta(hours=10)
|
||||
and old_v.dearest.start == D0 + timedelta(hours=10)
|
||||
and arbitrage._act(plan_s, now, old_v, 50.0, 2500.0, 2500.0, 10.0,
|
||||
90.0, 5000.0).action is None)
|
||||
check("with `now` the sell leg is 13:00, the hour in progress, and decide() "
|
||||
"discharges at 13:05: the trade runs through",
|
||||
new_v.dearest.start == D0 + timedelta(hours=11)
|
||||
and dec(plan_s, now).action == DISCHARGE)
|
||||
ok = True
|
||||
for m in (0, 5, 20, 35, 50, 59): # every minute class of the hour
|
||||
t = D0 + timedelta(hours=12, minutes=m) # 14:mm Brussels
|
||||
v = plan_s.arbitrage(now=t, **ARGS)
|
||||
ok = ok and v.dearest.start == D0 + timedelta(hours=12) \
|
||||
and dec(plan_s, t).action == DISCHARGE
|
||||
check("...and through the whole of 14:00-14:59 the target is the 14:00 hour "
|
||||
"and the trade stays on (no pause at the top of the hour)", ok)
|
||||
|
||||
# The exact boundary: a period ends AT its `end`, so at that instant it is over.
|
||||
# (`end >= now` would keep it for one more instant.)
|
||||
print("\n3c. the exact end of a period")
|
||||
at = H3 + timedelta(minutes=15) # 03:15:00 exactly
|
||||
v = BUY_PLAN.arbitrage(now=at, **ARGS)
|
||||
check("at 03:15:00 exactly the 03:00 quarter is over: the buy leg is 03:15 "
|
||||
"and decide() charges", v.cheapest.start == at and dec(BUY_PLAN, at)
|
||||
.action == CHARGE)
|
||||
check("one microsecond earlier the 03:00 quarter is still in view",
|
||||
BUY_PLAN.arbitrage(now=at - timedelta(microseconds=1), **ARGS)
|
||||
.cheapest.start == H3)
|
||||
at_h = H3 + timedelta(hours=1) # 04:00:00 exactly
|
||||
check("at 04:00:00 exactly the 03:00 hour is over (hourly feed), one "
|
||||
"microsecond earlier it is not",
|
||||
plan_h.arbitrage(now=at_h, **ARGS).cheapest.start == at_h
|
||||
and plan_h.arbitrage(now=at_h - timedelta(microseconds=1), **ARGS)
|
||||
.cheapest.start == H3)
|
||||
two = qplan([0.10, 0.20]) # two firm quarters from 02:00
|
||||
check("the Home page's refusal key at the exact end of the first of two "
|
||||
"quarters: one is left, so 'not_firm' (with `end >= now` it would say "
|
||||
"'not_worth'); a microsecond earlier both count",
|
||||
homeview.refusal_key(two, False, D0 + timedelta(minutes=15)) == "not_firm"
|
||||
and homeview.refusal_key(two, False, D0 + timedelta(minutes=15)
|
||||
- timedelta(microseconds=1)) == "not_worth")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. modes on: an ended period no longer opens the gate")
|
||||
GATE = [0.30] * 192
|
||||
GATE[4] = 0.10 # the ONLY spread is the 03:00 period
|
||||
GATE_PLAN = qplan(GATE)
|
||||
now = H3 + timedelta(minutes=20)
|
||||
check("the fixture: with the ended 03:00 period in view the verdict is "
|
||||
"allowed; from 03:20 on the rest of the series is flat",
|
||||
GATE_PLAN.arbitrage(now=LONG_AGO, **ARGS).kind == "allowed"
|
||||
and GATE_PLAN.arbitrage(now=now, **ARGS).kind == "refused"
|
||||
and "flat" in GATE_PLAN.arbitrage(now=now, **ARGS).reason)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
|
||||
WPTS = [(D0 + timedelta(minutes=15 * i), 900, 0.10) for i in range(192)]
|
||||
|
||||
|
||||
def controller_at(at, plan):
|
||||
"""The real Controller after a few cycles of the modes-on replay at `at`
|
||||
(two batteries, one on Cheap hours), holding `plan` as its fresh plan."""
|
||||
_st, c = rp.run("p3_ch_sell", [(D0.timestamp() - 10, 800.0)], WPTS, at,
|
||||
20.0, soc0={"a": 60.0, "b": 30.0},
|
||||
extra={"cycle_cost_threshold_eur": 0.05})
|
||||
c.tariff_plan, c.tariff_at = plan, at
|
||||
return c
|
||||
|
||||
|
||||
from app import modetrade # noqa: E402
|
||||
|
||||
GATE_SEEN = {}
|
||||
_real_decide = modetrade.decide
|
||||
|
||||
|
||||
def _spy(runs, now_, packs, **kw):
|
||||
GATE_SEEN.update(charge_ok=kw["charge_ok"], sell_ok=kw["sell_ok"],
|
||||
trade_why=kw["trade_why"])
|
||||
return _real_decide(runs, now_, packs, **kw)
|
||||
|
||||
|
||||
modetrade.decide = _spy
|
||||
c = controller_at(now, GATE_PLAN)
|
||||
GATE_SEEN.clear()
|
||||
c._mode_roles(now, None)
|
||||
late = dict(GATE_SEEN)
|
||||
c2 = controller_at(H3 + timedelta(minutes=5), GATE_PLAN)
|
||||
GATE_SEEN.clear()
|
||||
c2._mode_roles(H3 + timedelta(minutes=5), None)
|
||||
early = dict(GATE_SEEN)
|
||||
modetrade.decide = _real_decide
|
||||
check(f"Controller._mode_roles() (modes on, the `allowed()` gate) at 03:05, "
|
||||
f"03:00 still current: charge and sell are allowed ({early})",
|
||||
early.get("charge_ok") is True and early.get("sell_ok") is True)
|
||||
check(f"...and at 03:20, with only flat periods ahead: neither is allowed, "
|
||||
f"and the reason is the spread ({late})",
|
||||
late.get("charge_ok") is False and late.get("sell_ok") is False
|
||||
and late.get("trade_why") == "not_worth")
|
||||
|
||||
c.o["arbitrage_enabled"] = True
|
||||
v, why = homeview.trade_verdict(c, GATE_PLAN, now)
|
||||
check(f"the Home page verdict takes the same `now`: no verdict at 03:20 "
|
||||
f"({why!r}), and a verdict at 03:05",
|
||||
v is None and why == "not_worth"
|
||||
and homeview.trade_verdict(c, GATE_PLAN, H3 + timedelta(minutes=5))[0]
|
||||
is not None)
|
||||
check("...and the refusal key counts only periods not over: two firm periods "
|
||||
"left, so 'not_worth', and with one left 'not_firm'",
|
||||
homeview.refusal_key(GATE_PLAN, False, now) == "not_worth"
|
||||
and homeview.refusal_key(qplan([0.1, 0.2]), False,
|
||||
D0 + timedelta(minutes=20)) == "not_firm")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n5. the text")
|
||||
check("the reason for an off-peak period says 'price period', not 'hour'",
|
||||
arbitrage.IDLE_OFF_PEAK_HOUR
|
||||
== "now is not the cheapest or the dearest price period")
|
||||
_b = dec(BUY_PLAN, H3 + timedelta(minutes=20))
|
||||
_s = dec(SELL_PLAN, H3 + timedelta(minutes=20))
|
||||
check("...and the charge and the discharge sentences say 'price period' too "
|
||||
"(the unit is 15 minutes), with no 'firm hour' left",
|
||||
"cheapest firm price period" in _b.reason
|
||||
and "dearest firm price period" in _s.reason
|
||||
and "firm hour" not in _b.reason + _s.reason)
|
||||
_hi = dec(BUY_PLAN, H3 + timedelta(minutes=20), soc=95.0)
|
||||
_lo = dec(SELL_PLAN, H3 + timedelta(minutes=20), soc=10.0)
|
||||
check("...and so do the full-pack and empty-pack sentences ('buy period', "
|
||||
"'sell period')",
|
||||
"the buy period is here" in _hi.reason
|
||||
and "the sell period is here" in _lo.reason)
|
||||
import inspect # noqa: E402
|
||||
|
||||
_par = inspect.signature(TariffPlan.arbitrage).parameters
|
||||
check("`now` is required: keyword-only with no default, like the three "
|
||||
"arguments beside it, so a caller that forgets it fails loudly instead "
|
||||
"of getting the elapsed periods back",
|
||||
_par["now"].default is inspect.Parameter.empty
|
||||
and _par["now"].kind is inspect.Parameter.KEYWORD_ONLY
|
||||
and all(p.default is inspect.Parameter.empty
|
||||
for n, p in _par.items() if n != "self"))
|
||||
try:
|
||||
BUY_PLAN.arbitrage(**ARGS_NO_NOW)
|
||||
_forgot = False
|
||||
except TypeError:
|
||||
_forgot = True
|
||||
check("...and calling without it raises TypeError", _forgot)
|
||||
try:
|
||||
BUY_PLAN.arbitrage(now=datetime(2026, 10, 20, 3, 20), **ARGS)
|
||||
_naive = False
|
||||
except TypeError as e:
|
||||
_naive = "tz-aware" in str(e)
|
||||
check("a naive `now` is refused: it is ambiguous for one hour every October",
|
||||
_naive)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n6. ties: the first extreme still ahead")
|
||||
def acts(plan, instants, ago=False, args=ARGS):
|
||||
out = []
|
||||
for t in instants:
|
||||
v = plan.arbitrage(now=LONG_AGO if ago else t, **args)
|
||||
d = arbitrage._act(plan, t, v, 50.0, 2500.0, 2500.0, 10.0, 90.0,
|
||||
5000.0) if v.kind == "allowed" else None
|
||||
out.append(None if d is None else d.action)
|
||||
return out
|
||||
|
||||
|
||||
# Hour-constant 15-minute data: the cheap hour is four equal quarters.
|
||||
HC = [round(0.40 - 0.0001 * i, 4) for i in range(192)] # falling, no ties
|
||||
for q in range(4, 8):
|
||||
HC[q] = 0.05 # 03:00-04:00 four equal quarters
|
||||
HC[68:72] = [0.50] * 4 # 19:00-20:00 four equal quarters
|
||||
HC_PLAN = qplan(HC)
|
||||
probe = [H3 + timedelta(minutes=m) for m in (5, 20, 35, 50, 65)]
|
||||
old = acts(HC_PLAN, probe, ago=True)
|
||||
new = acts(HC_PLAN, probe)
|
||||
check(f"before: four equal cheap quarters, the verdict is the first of them, "
|
||||
f"so the trade acts in one quarter ({old})",
|
||||
old == [CHARGE, None, None, None, None])
|
||||
check(f"after: the first one still ahead, so it acts in all four and stops "
|
||||
f"with the hour ({new}) - four acting periods for the same hour, one "
|
||||
f"block either way",
|
||||
new == [CHARGE, CHARGE, CHARGE, CHARGE, None])
|
||||
changes = lambda a: sum(1 for x, y in zip(a, a[1:]) if x != y) # noqa: E731
|
||||
check("...the action changes the same number of times (one start, one stop)",
|
||||
changes(old) == changes(new) == 1)
|
||||
|
||||
# A real 15-minute day, as production runs it: refresh every 30 min from the
|
||||
# floored hour, the verdict at 7 min into every period.
|
||||
REAL = os.path.join(HERE, "scenarios",
|
||||
"energy-charts-be-price-2026-09-09_2026-09-10-twoday.json")
|
||||
_d = json.load(open(REAL))
|
||||
_pts = [PricePoint(brussels(datetime.fromtimestamp(t, UTC)), p / 1000.0, False)
|
||||
for t, p in zip(_d["unix_seconds"], _d["price"])]
|
||||
_feed = PriceRead.series(_pts, PriceProduct.DAY_AHEAD, "generic_dayahead",
|
||||
_pts[0].start)
|
||||
_day0 = _pts[0].start.astimezone(UTC)
|
||||
|
||||
def real_day(ago):
|
||||
out = []
|
||||
for q in range(96):
|
||||
t = _day0 + timedelta(minutes=15 * q + 7)
|
||||
ref = t.replace(minute=0 if t.minute < 30 else 30, second=0,
|
||||
microsecond=0)
|
||||
plan = TariffPlan(fill_from_yesterday(
|
||||
_feed, yesterday=PriceRead.no_data("none"), since=ref,
|
||||
until=ref + timedelta(hours=36), stamp=ref), None, "provider")
|
||||
v = plan.arbitrage(now=LONG_AGO if ago else t, **ARGS5)
|
||||
out.append(arbitrage._act(plan, t, v, 50.0, 2500.0, 2500.0, 10.0,
|
||||
90.0, 5000.0).action
|
||||
if v.kind == "allowed" else None)
|
||||
return out
|
||||
|
||||
|
||||
r_old, r_new = real_day(True), real_day(False)
|
||||
n_old = sum(1 for a in r_old if a)
|
||||
n_new = sum(1 for a in r_new if a)
|
||||
print(f" real 15-minute day 2026-09-09: acting periods {n_old} -> {n_new}, "
|
||||
f"action changes {changes(r_old)} -> {changes(r_new)}")
|
||||
check("a real 15-minute day (energy-charts, 2026-09-09) does not switch more "
|
||||
"often: the action changes no more often than before",
|
||||
changes(r_new) <= changes(r_old))
|
||||
check("...and every period the old verdict acted in is still acted in, with "
|
||||
"the quarters it had lost to the elapsed-period tie added",
|
||||
all(n is not None for o, n in zip(r_old, r_new) if o) and n_new >= n_old)
|
||||
|
||||
# Alternating hourly prices (the flapping day): ties everywhere, so the verdict
|
||||
# moves on every hour. At most once an hour, never once a tick.
|
||||
FL = [0.20 + (0.101 if i % 2 else 0.0) for i in range(24)] * 2
|
||||
FL_PLAN = qplan(FL, minutes=60)
|
||||
ticks = [acts(FL_PLAN, [D0 + timedelta(seconds=30 * k)], args=ARGS5)[0]
|
||||
for k in range(24 * 120)]
|
||||
check(f"alternating hourly prices: {changes(ticks)} action changes over "
|
||||
f"2880 ticks - at most one an hour, not one a tick",
|
||||
changes(ticks) <= 24 and CHARGE in ticks and DISCHARGE in ticks)
|
||||
|
||||
# The Home page path: `homeview.build_plan()` hands its own `now` to
|
||||
# `trade_verdict`. With a `now` floored to the hour it would still aim at the
|
||||
# 03:00 period at 03:20 and draw a trade that the control cycle does not take.
|
||||
print("\n4b. the Home page plan (build_plan) uses the instant it is rendered at")
|
||||
c.o["arbitrage_enabled"] = True
|
||||
c.modes_on = False
|
||||
|
||||
|
||||
def home_at(t):
|
||||
c.tariff_plan, c.tariff_at = GATE_PLAN, t
|
||||
return homeview.build_plan(c, t, False, True)
|
||||
|
||||
|
||||
h_early = home_at(H3 + timedelta(minutes=5))
|
||||
h_late = home_at(H3 + timedelta(minutes=20))
|
||||
trade_kinds = ("grid", "give")
|
||||
check("build_plan at 03:05 (03:00 still current): the spread is real, so the "
|
||||
"page draws a trade (verdict_key 'trade')",
|
||||
h_early["verdict_key"] == "trade"
|
||||
and any(r["kind"] in trade_kinds for r in h_early["runs"]))
|
||||
check("build_plan at 03:20 (only flat periods ahead): no trade on the page, "
|
||||
"the same refusal as trade_verdict gives (verdict_key 'house')",
|
||||
h_late["verdict_key"] == "house"
|
||||
and not any(r["kind"] in trade_kinds for r in h_late["runs"])
|
||||
and h_late["lines"] != h_early["lines"])
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -87,11 +87,6 @@ from app.static_tariff import Arbitrage, TariffPlan
|
||||
|
||||
UTC = timezone.utc
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
|
||||
# point these fixtures build, so no period has ended and the whole series counts.
|
||||
from datetime import datetime as _DT, timezone as _TZ # noqa: E402
|
||||
LONG_AGO = _DT(2000, 1, 1, tzinfo=_TZ.utc)
|
||||
fails = []
|
||||
|
||||
|
||||
@@ -721,11 +716,10 @@ check("the drain's commitment window is DERIVED from the phase machine's own "
|
||||
"timeout, so the gate and maintenance.py cannot drift apart",
|
||||
near(MAINTENANCE_DRAIN.commits_h, DRAIN_TIMEOUT_MIN / 60.0)
|
||||
and near(MAINTENANCE_CHARGE.commits_h, CHARGE_TIMEOUT_MIN / 60.0))
|
||||
# A drain started at 10:00 local runs into the evening - hours a morning-only
|
||||
# day-ahead print has not covered. That is the ticket's scenario, so the hour is
|
||||
# pinned here: 10 was the shipped default until MAINT-10 moved it to 19, and an
|
||||
# install that stored 10 still starts then.
|
||||
START = D0 + timedelta(hours=10)
|
||||
# The shipped `maintenance_start_hour` is 10:00 local, so a drain started on
|
||||
# schedule runs into the evening - hours a morning-only day-ahead print has not
|
||||
# covered. That is the ticket's scenario and it is where the window is placed.
|
||||
START = D0 + timedelta(hours=MaintConfig().start_hour)
|
||||
_hours = derived(lambda: committed_hours(START, MAINTENANCE_DRAIN))
|
||||
check(f"...and it commits {len(_hours) if not isinstance(_hours, _Raised) else _hours} "
|
||||
f"whole hours, one per hour of the timeout",
|
||||
@@ -910,7 +904,7 @@ def min_accepted_spread(product, sources, base, provider="generic_dayahead"):
|
||||
lo, hi = 0.0, 4.0
|
||||
for _ in range(60):
|
||||
mid = (lo + hi) / 2
|
||||
v = plan_with_spread(mid, product, provider).arbitrage(now=LONG_AGO,
|
||||
v = plan_with_spread(mid, product, provider).arbitrage(
|
||||
peak_guard_active=False, min_spread_eur_kwh=base,
|
||||
uncertainty=sources)
|
||||
if v.kind == "allowed":
|
||||
@@ -1018,7 +1012,7 @@ check(f"the fixture is non-degenerate: the whole-series spread "
|
||||
f"{_all_spread:.6f} and the firm-only spread {_firm_spread:.6f} are "
|
||||
f"different numbers, so a verdict can be traced to one of them",
|
||||
not near(_all_spread, _firm_spread, 1e-6))
|
||||
_v = derived(lambda: _mixed.arbitrage(now=LONG_AGO, peak_guard_active=False,
|
||||
_v = derived(lambda: _mixed.arbitrage(peak_guard_active=False,
|
||||
min_spread_eur_kwh=0.0,
|
||||
uncertainty=frozenset()))
|
||||
check("a PART-published series still allows arbitrage - §8.3 forbids acting on "
|
||||
@@ -1037,7 +1031,7 @@ check("...and it reports the threshold it cleared and why, rather than leaving "
|
||||
_all_prov = TariffPlan(PriceRead.series(_cheap_stale, PriceProduct.DAY_AHEAD,
|
||||
"generic_dayahead", D0), None,
|
||||
"fallback")
|
||||
_vp = derived(lambda: _all_prov.arbitrage(now=LONG_AGO, peak_guard_active=False,
|
||||
_vp = derived(lambda: _all_prov.arbitrage(peak_guard_active=False,
|
||||
min_spread_eur_kwh=0.0,
|
||||
uncertainty=frozenset()))
|
||||
check("a WHOLLY provisional series - the §11.4 fallback, or a day before any "
|
||||
@@ -1051,23 +1045,23 @@ _one_firm = TariffPlan(
|
||||
"generic_dayahead", D0), None, "provider")
|
||||
check("one firm hour is not enough either: a buy hour and a sell hour must "
|
||||
"BOTH be published before a cycle can be committed",
|
||||
_one_firm.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0,
|
||||
_one_firm.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0,
|
||||
uncertainty=frozenset()).kind == "refused")
|
||||
check("§10.1 still holds above all of it: the peak guard refuses the same "
|
||||
"series that is otherwise allowed",
|
||||
_mixed.arbitrage(now=LONG_AGO, peak_guard_active=True, min_spread_eur_kwh=0.0,
|
||||
_mixed.arbitrage(peak_guard_active=True, min_spread_eur_kwh=0.0,
|
||||
uncertainty=frozenset()).kind == "refused")
|
||||
check("the three questions cannot be skipped: all of peak_guard_active, "
|
||||
"min_spread_eur_kwh and uncertainty are required and keyword-only",
|
||||
raises(lambda: _mixed.arbitrage(now=LONG_AGO, peak_guard_active=False), TypeError)
|
||||
and raises(lambda: _mixed.arbitrage(now=LONG_AGO, peak_guard_active=False,
|
||||
raises(lambda: _mixed.arbitrage(peak_guard_active=False), TypeError)
|
||||
and raises(lambda: _mixed.arbitrage(peak_guard_active=False,
|
||||
min_spread_eur_kwh=0.0), TypeError)
|
||||
and raises(lambda: _mixed.arbitrage(False, 0.0, frozenset()), TypeError))
|
||||
check("...nor answered with a truthy 'unknown' or a negative threshold",
|
||||
raises(lambda: _mixed.arbitrage(now=LONG_AGO, peak_guard_active=None,
|
||||
raises(lambda: _mixed.arbitrage(peak_guard_active=None,
|
||||
min_spread_eur_kwh=0.0,
|
||||
uncertainty=frozenset()), TypeError)
|
||||
and raises(lambda: _mixed.arbitrage(now=LONG_AGO, peak_guard_active=False,
|
||||
and raises(lambda: _mixed.arbitrage(peak_guard_active=False,
|
||||
min_spread_eur_kwh=-0.1,
|
||||
uncertainty=frozenset()), TypeError))
|
||||
|
||||
@@ -1138,11 +1132,6 @@ _T17_KEYS = ["price_taxes_profile"]
|
||||
# `export_price_eur_kwh` is untouched, every site on the scalar is unaffected,
|
||||
# and it lives in this dict and in config.yaml and nowhere else.
|
||||
_T16_KEYS = ["price_export_formula"]
|
||||
# PRICE-01 added the eighth: `price_service_token`, the bearer token of the
|
||||
# owner's price server. An ADDITION, like the three above: nothing was renamed,
|
||||
# every other provider ignores it, and it lives in this dict and in config.yaml
|
||||
# (as a password field) and nowhere else.
|
||||
_P01_KEYS = ["price_service_token"]
|
||||
check(f"the in-code mirror holds exactly TARIFF-02's, TARIFF-03's, OPT-04's, "
|
||||
f"TARIFF-15's, TARIFF-17's and TARIFF-16's keys - FCST-03 still added "
|
||||
f"none of its own {_defaults}",
|
||||
@@ -1152,7 +1141,7 @@ check(f"the in-code mirror holds exactly TARIFF-02's, TARIFF-03's, OPT-04's, "
|
||||
"price_horizon_h", "price_timeout_s",
|
||||
"static_profile"]
|
||||
+ _OPT04_KEYS + _T15_KEYS + _T17_KEYS
|
||||
+ _T16_KEYS + _P01_KEYS))
|
||||
+ _T16_KEYS))
|
||||
# ...and the later keys are genuinely NEW, not a rename of something that was
|
||||
# already there, or the line above would pass while the mirror silently lost a
|
||||
# key.
|
||||
@@ -1161,7 +1150,7 @@ check("...and OPT-04's four, TARIFF-15's one, TARIFF-17's one and TARIFF-16's "
|
||||
"are all still present",
|
||||
isinstance(_defaults, list)
|
||||
and len(set(_defaults) - set(_OPT04_KEYS) - set(_T15_KEYS)
|
||||
- set(_T17_KEYS) - set(_T16_KEYS) - set(_P01_KEYS)) == 9)
|
||||
- set(_T17_KEYS) - set(_T16_KEYS)) == 9)
|
||||
# ⚠️ AND THE ONE TARIFF-17 IS THE HOUR-VARYING TWIN OF IS STILL THERE, BY NAME.
|
||||
# The line above counts nine, and a rename of `price_taxes_eur_kwh` to
|
||||
# `price_taxes_profile` would also count nine. The scalar path being unchanged
|
||||
|
||||
@@ -118,11 +118,6 @@ from app.static_tariff import resolve_tariff
|
||||
|
||||
UTC = timezone.utc
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
|
||||
# point these fixtures build, so no period has ended and the whole series counts.
|
||||
from datetime import datetime as _DT, timezone as _TZ # noqa: E402
|
||||
LONG_AGO = _DT(2000, 1, 1, tzinfo=_TZ.utc)
|
||||
fails = []
|
||||
_tmpdirs = []
|
||||
|
||||
@@ -704,8 +699,8 @@ check(f"the short answer stays a SERIES of what exists - {_pre.read.kind}, "
|
||||
# --- the trade set, which is where the whole thing lands ------------------- #
|
||||
_A9 = dict(peak_guard_active=False, min_spread_eur_kwh=0.0,
|
||||
uncertainty=frozenset())
|
||||
_v2 = _pub.arbitrage(now=LONG_AGO, **_A9)
|
||||
_v1 = _pre.arbitrage(now=LONG_AGO, **_A9)
|
||||
_v2 = _pub.arbitrage(**_A9)
|
||||
_v1 = _pre.arbitrage(**_A9)
|
||||
check("both plans still ALLOW a trade, so the two verdicts below are compared "
|
||||
"against each other and not against a refusal",
|
||||
_v2.kind == "allowed" and _v1.kind == "allowed")
|
||||
@@ -749,8 +744,8 @@ print("2. AC2 - the spread stopped being taken over hours that have gone")
|
||||
|
||||
_A = dict(peak_guard_active=False, min_spread_eur_kwh=0.0,
|
||||
uncertainty=frozenset())
|
||||
_v_before = _before.arbitrage(now=LONG_AGO, **_A)
|
||||
_v_after = _after.arbitrage(now=LONG_AGO, **_A)
|
||||
_v_before = _before.arbitrage(**_A)
|
||||
_v_after = _after.arbitrage(**_A)
|
||||
check("both plans still ALLOW a trade, so this section compares two verdicts "
|
||||
"rather than a verdict and a refusal",
|
||||
_v_before.kind == "allowed" and _v_after.kind == "allowed")
|
||||
@@ -848,7 +843,7 @@ check("...and no two of them share a whole hour by accident: the fixture puts "
|
||||
|
||||
_q_spread_want = round(max(p.eur_per_kwh for p in _q_win)
|
||||
- min(p.eur_per_kwh for p in _q_win), 6)
|
||||
_qv = _q_after.arbitrage(now=LONG_AGO, **_A)
|
||||
_qv = _q_after.arbitrage(**_A)
|
||||
_q_spread_got = _qv.match(allowed=lambda v: v.spread_eur_kwh,
|
||||
refused=lambda _v: None)
|
||||
check(f"...and the spread is the fixture's own max - min over that window "
|
||||
|
||||
@@ -1,664 +0,0 @@
|
||||
"""Runnable check for OPT-15, the P3 price-run fixes. `python3 test_price_run_fixes.py`
|
||||
|
||||
The OPT-11 P3 post-merge test on TEST-23 (5a4cdc2) found three defects. This
|
||||
file drives the pure modules and the REAL Controller (tools/opt11p3_replay.py)
|
||||
with the lagged model (`Lag`: command dead time, meter and battery reading
|
||||
age, the P1 debounce, 1 s cycles), which reproduces all three on 5a4cdc2.
|
||||
|
||||
1. F1: a quarter edge while charging does not stop the charge (the guard
|
||||
projects the quarter `now` is in), and a real guard stop still latches.
|
||||
At two inverters SAFETY-91's `next_quarter()` already does that for the
|
||||
guard; at one inverter only the rolled projection does.
|
||||
2. F2: the export overshoot at a charge end stays within 0.15 x rating in
|
||||
the model and in the lagged variants (TEST-23 measured -1120 W).
|
||||
3. F3: a charge refusal inside a cheap run is probed every 15 minutes.
|
||||
4. The P3 and SAFETY-79 invariants on the lagged runs.
|
||||
5. Modes off is byte-identical to release/1.0 e3e4ba6, also with a late P1
|
||||
sample on every quarter boundary, and the harness sees a change there.
|
||||
|
||||
DECISIONS/OPT-11-battery-modes.md has the record. Mutations (MUTATION.md,
|
||||
purged copies, graded over this file and test_modes_p3.py) are listed in the
|
||||
pull request.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
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 opt11p3_identity as ident # noqa: E402
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from app import arbitrage, modetrade, packmode, peakguard # noqa: E402
|
||||
from app import tariff # noqa: E402
|
||||
from app import control as ctl # noqa: E402
|
||||
from app import main # noqa: E402
|
||||
from app.p1 import QuarterAverager, make_sample # noqa: E402
|
||||
from app.tariff import PeakTracker # noqa: E402
|
||||
from named_absence import nth, present # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
L = timezone(timedelta(hours=2))
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
D0 = datetime(2026, 9, 9, 0, 0, tzinfo=L)
|
||||
UTC0 = D0.astimezone(timezone.utc)
|
||||
# Cheap 02:00-04:00 local, dear 19:00-20:00, flat otherwise (two days).
|
||||
PRICES = [0.30] * 192
|
||||
for _i in range(8, 16):
|
||||
PRICES[_i] = 0.10
|
||||
for _i in range(76, 80):
|
||||
PRICES[_i] = 0.40
|
||||
WPTS = [(UTC0 + timedelta(minutes=15 * i), 900, p)
|
||||
for i, p in enumerate(PRICES)]
|
||||
RATING = rp.RATING
|
||||
BOUND = 0.15 * RATING
|
||||
AIM = rp.PEAK_CAP - 50
|
||||
CH_SF = {"a": (True, packmode.CHEAP_HOURS, False),
|
||||
"b": (True, packmode.SOLAR_FIRST, False)}
|
||||
|
||||
|
||||
def at(h):
|
||||
return UTC0 + timedelta(hours=h)
|
||||
|
||||
|
||||
def house(steps, base=800.0):
|
||||
"""[(t, W)] for the replay: `base`, then each (local hour, W) from then."""
|
||||
out = [(UTC0.timestamp() - 10, base)]
|
||||
out += [(at(h).timestamp(), float(w)) for h, w in steps]
|
||||
return out
|
||||
|
||||
|
||||
def run(start_h, hours, *, lag=None, soc_a=20.0, steps=(), choice=None,
|
||||
refuse=(), patch=None, check_cb=None, n=2):
|
||||
rows = []
|
||||
|
||||
def cb(c, h, now, pr):
|
||||
sh = dict(c.allocation.shares) if c.allocation is not None and \
|
||||
c.allocation.shares else {}
|
||||
rows.append(dict(
|
||||
now=now, grid=h.grid(), shares=sh,
|
||||
action=getattr(c.arb, "action", None),
|
||||
reason=getattr(c.arb, "reason", None) or "",
|
||||
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
|
||||
refused=c.inverters[0].refused,
|
||||
probing=c.inverters[0].probe_from is not None,
|
||||
headroom=c.arb_headroom_w,
|
||||
p={u: p.p for u, p in h.plants.items()}))
|
||||
if check_cb:
|
||||
check_cb(c, h, now, pr)
|
||||
st, c = rp.run("p3_ch_sell", house(steps), WPTS, at(start_h),
|
||||
3600 * hours, check=cb, lag=lag, n=n,
|
||||
soc0={"a": soc_a, "b": 60.0},
|
||||
choice=CH_SF if choice is None else choice,
|
||||
refuse=refuse, patch=patch,
|
||||
extra={"cycle_cost_threshold_eur": 0.10})
|
||||
return rows, c
|
||||
|
||||
|
||||
def stops(rows):
|
||||
"""[(row index, row)] where a grid charge stopped."""
|
||||
return [(i, rows[i]) for i in range(1, len(rows))
|
||||
if rows[i - 1]["action"] == arbitrage.CHARGE
|
||||
and rows[i]["action"] != arbitrage.CHARGE]
|
||||
|
||||
|
||||
def q(when):
|
||||
return int(when.timestamp() // 900)
|
||||
|
||||
|
||||
def banked(c, rows):
|
||||
"""The add-on's own P1 blocks for the quarters that had a grid charge."""
|
||||
chq = {q(r["now"]) for r in rows if r["action"] == arbitrage.CHARGE}
|
||||
return [b.offtake_avg_w for b in c.p1.blocks if q(b.start) in chq]
|
||||
|
||||
|
||||
def mixed(rows):
|
||||
return [r for r in rows if any(v > 0 for v in r["shares"].values())
|
||||
and any(v < 0 for v in r["shares"].values())]
|
||||
|
||||
|
||||
# Close to TEST-23's plant: 1 s cycles, readings 1 s old, a battery that
|
||||
# follows the previous second's command.
|
||||
LAG = rp.Lag(tau_s=0.05, ramp_w_s=1e9)
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. F1: a quarter edge while charging")
|
||||
# 1a. The projection on the edge tick, before the new block's first sample.
|
||||
_avg = QuarterAverager(phases=1, max_hold_s=30.0)
|
||||
_edge = datetime(2026, 9, 9, 12, 0, tzinfo=timezone.utc)
|
||||
for _s in range(-900, 1, 1):
|
||||
_t = _edge + timedelta(seconds=_s - 0.35)
|
||||
_avg.add(make_sample("ha_signed", 3300.0, 0.0, phases=1, ingest_ts=_t,
|
||||
ingest_mono=0.0))
|
||||
_now = _edge + timedelta(seconds=0.6)
|
||||
_old = PeakTracker.project(_avg, _now)
|
||||
_new = PeakTracker.project(_avg, _now, roll=True)
|
||||
|
||||
|
||||
def _decide(pr):
|
||||
return peakguard.decide(pr, batt_w=-2500.0, cap_w=3500.0, margin_w=50.0,
|
||||
reading=None, achievable_w=5000.0, soc=50.0,
|
||||
soc_floor=20.0, max_hold_s=30.0)
|
||||
|
||||
|
||||
print(f" edge tick: old block {_old.explain()}; rolled {_new.explain()}")
|
||||
check("on the edge tick the open block is still the old one: the guard reads "
|
||||
"'no seconds left' and the headroom is unmeasured (the F1 cause)",
|
||||
_old.elapsed_s >= 900 and peakguard.charge_headroom_w(
|
||||
_decide(_old)) is None)
|
||||
_later = QuarterAverager(phases=1, max_hold_s=30.0)
|
||||
for _s in range(-900, 1, 1):
|
||||
_later.add(make_sample("ha_signed", 3300.0, 0.0, phases=1,
|
||||
ingest_ts=_edge + timedelta(seconds=_s - 0.35),
|
||||
ingest_mono=0.0))
|
||||
_later.add(make_sample("ha_signed", 3300.0, 0.0, phases=1,
|
||||
ingest_ts=_edge + timedelta(seconds=0.6),
|
||||
ingest_mono=0.0))
|
||||
_real = PeakTracker.project(_later, _now)
|
||||
_hr = peakguard.charge_headroom_w(_decide(_new))
|
||||
check("rolled, it is the new quarter, measured, and equal to what the "
|
||||
"averager projects once the new block's first sample is in; the "
|
||||
"headroom is the house and the aim (3450 - 800 W)",
|
||||
_new.block_start == _edge == _real.block_start and _new.complete
|
||||
and abs(_new.projected_w - _real.projected_w) < 0.01
|
||||
and abs(_new.elapsed_s - 0.6) < 1e-6
|
||||
and _hr is not None and abs(_hr - 2650.0) < 1.0)
|
||||
_gap = QuarterAverager(phases=1, max_hold_s=30.0)
|
||||
_gap.add(make_sample("ha_signed", 3300.0, 0.0, phases=1,
|
||||
ingest_ts=_edge - timedelta(seconds=100), ingest_mono=0))
|
||||
_gp = PeakTracker.project(_gap, _edge + timedelta(seconds=40), roll=True)
|
||||
check("...and a sample older than the hold is not credited: past the hold "
|
||||
"the rolled quarter is blind, as the averager would make it",
|
||||
_gp.elapsed_s == 40.0 and _gp.covered_frac == 0.0
|
||||
and peakguard.blindness(_gp, 30.0, False) is not None)
|
||||
check("without `roll` nothing changes (the modes-off path)",
|
||||
PeakTracker.project(_avg, _now, roll=False) == _old)
|
||||
# Review of #157 (X1): a held export is credited as 0 W, as `add()` banks it,
|
||||
# never as a negative that would pull the rolled quarter under the meter.
|
||||
_exp = QuarterAverager(phases=1, max_hold_s=30.0)
|
||||
for _s in range(-900, 1, 1):
|
||||
_exp.add(make_sample("ha_signed", 0.0, 1500.0, phases=1,
|
||||
ingest_ts=_edge + timedelta(seconds=_s - 0.35),
|
||||
ingest_mono=0.0))
|
||||
_ep = PeakTracker.project(_exp, _now, roll=True)
|
||||
_exp.add(make_sample("ha_signed", 0.0, 1500.0, phases=1,
|
||||
ingest_ts=_edge + timedelta(seconds=0.6),
|
||||
ingest_mono=0.0))
|
||||
_er = PeakTracker.project(_exp, _now)
|
||||
check("...and a held export rolls in as 0 W, equal to what the averager "
|
||||
"banks for those seconds",
|
||||
_ep.block_start == _er.block_start and _ep.projected_w == 0.0
|
||||
and _ep.elapsed_w == 0.0 and _er.projected_w == 0.0)
|
||||
|
||||
# 1b. The lagged replay: 02:00-03:30 local, a Cheap hours battery charging
|
||||
# under an 800 W house through five quarter edges.
|
||||
ROWS1, C1 = run(1.9, 1.6, lag=LAG, choice={
|
||||
"a": (True, packmode.CHEAP_HOURS, False),
|
||||
"b": (True, packmode.LEFTOVER_SOLAR, False)})
|
||||
_ch1 = [r for r in ROWS1 if r["action"] == arbitrage.CHARGE]
|
||||
_edges1 = sorted({q(r["now"]) for r in _ch1})
|
||||
_st1 = stops(ROWS1)
|
||||
print(f" {len(_ch1)} charge cycles over {len(_edges1)} quarters; stops "
|
||||
f"{[(r['now'].astimezone(L).strftime('%H:%M:%S'), r['reason'][:40]) for _, r in _st1]}")
|
||||
check("no stop at any quarter edge crossed while charging (two inverters; "
|
||||
"see below for the old projection)",
|
||||
len(_edges1) >= 5 and not _st1)
|
||||
|
||||
|
||||
def _no_roll(c):
|
||||
real = c.peak.project
|
||||
c.peak.project = lambda a, now, roll=False: real(a, now)
|
||||
|
||||
|
||||
_CH1 = {"a": (True, packmode.CHEAP_HOURS, False),
|
||||
"b": (True, packmode.LEFTOVER_SOLAR, False)}
|
||||
# One inverter: SAFETY-91's `next_quarter()` is not applied there, so this is
|
||||
# where the old projection stops the charge at every edge.
|
||||
ROWS1N, C1N = run(1.9, 1.6, lag=LAG, n=1, choice=_CH1)
|
||||
ROWS1M, _ = run(1.9, 0.6, lag=LAG, n=1, patch=_no_roll, choice=_CH1)
|
||||
_m1 = stops(ROWS1M)
|
||||
check("one inverter: no stop at any edge crossed while charging",
|
||||
len({q(r["now"]) for r in ROWS1N if r["action"] == arbitrage.CHARGE})
|
||||
>= 5 and not stops(ROWS1N))
|
||||
check("non-vacuous: with the old projection one inverter stops at every edge "
|
||||
"for want of a measured headroom (5a4cdc2 then latched the quarter; "
|
||||
"the latch fix alone keeps it to that tick)",
|
||||
len(_m1) >= 2 and all("headroom" in r["reason"] and
|
||||
r["now"].astimezone(L).minute % 15 == 0 and
|
||||
r["now"].astimezone(L).second == 0
|
||||
for _, r in _m1))
|
||||
# Two inverters: since SAFETY-91 the guard grades the next quarter past a
|
||||
# block's end (`tariff.next_quarter()`), so the old projection no longer
|
||||
# stops there; the rolled one gives the same quarters.
|
||||
ROWS1O, C1O = run(1.9, 1.6, lag=LAG, patch=_no_roll, choice=_CH1)
|
||||
print(f" two inverters, old projection: {len(stops(ROWS1O))} stops; banked "
|
||||
f"{[round(x) for x in banked(C1O, ROWS1O)]} against "
|
||||
f"{[round(x) for x in banked(C1, ROWS1)]} rolled")
|
||||
check("two inverters: SAFETY-91 already holds the edge (no stop with the old "
|
||||
"projection), and rolling it changes no banked quarter",
|
||||
not stops(ROWS1O) and banked(C1O, ROWS1O) == banked(C1, ROWS1))
|
||||
|
||||
# 1c. A real guard stop still latches, and the 500 W minimum stays.
|
||||
# House: 800 W; 3700 W at 02:20-02:21; 3000 W from 02:26 (headroom 450 W
|
||||
# in the next quarter); 2850 W from 02:40 (600 W).
|
||||
ROWS2, C2 = run(1.9, 0.9, lag=LAG, steps=[
|
||||
(2 + 20 / 60, 3700), (2 + 21 / 60, 800), (2 + 26 / 60, 3000),
|
||||
(2 + 40 / 60, 2850)])
|
||||
_st2 = stops(ROWS2)
|
||||
_first = _st2[0][1] if _st2 else None
|
||||
_q2 = q(_first["now"]) if _first else None
|
||||
_after2 = [r for r in ROWS2 if _first and r["now"] > _first["now"]
|
||||
and q(r["now"]) == _q2]
|
||||
print(f" guard stop at "
|
||||
f"{_first['now'].astimezone(L).strftime('%H:%M:%S') if _first else None}"
|
||||
f" ({_first['reason'][:40] if _first else ''})")
|
||||
check("a mid-quarter 3.7 kW load stops the charge for the peak and it stays "
|
||||
"off for the rest of that quarter, the house back at 800 W",
|
||||
_first is not None and _first["now"].astimezone(L).minute == 20
|
||||
and ("peak" in _first["reason"] or "headroom" in _first["reason"])
|
||||
and _after2 and all(r["action"] is None for r in _after2))
|
||||
_starts = [b for a, b in zip(ROWS1 + ROWS2, (ROWS1 + ROWS2)[1:])
|
||||
if a["action"] != arbitrage.CHARGE
|
||||
and b["action"] == arbitrage.CHARGE]
|
||||
_low = [r for r in ROWS2 if r["headroom"] is not None
|
||||
and 0 < r["headroom"] <= modetrade.START_W and r["action"] is None]
|
||||
print(f" {len(_starts)} charge starts, lowest headroom at a start "
|
||||
f"{min(r['headroom'] for r in _starts):.0f} W; {len(_low)} cycles "
|
||||
f"with 0-500 W of headroom and no charge")
|
||||
check("the 500 W minimum stays: every charge starts with more than 500 W of "
|
||||
"headroom, and a house at 3 kW (450 W) holds it off",
|
||||
_starts and _low and all(r["headroom"] > modetrade.START_W
|
||||
for r in _starts))
|
||||
|
||||
# 1d. An unmeasured headroom stops one tick and does not latch.
|
||||
_c1d = run(1.9, 0.2, soc_a=20.0)[1]
|
||||
_n1d = UTC0 + timedelta(hours=2, minutes=5)
|
||||
_ok1d = False
|
||||
if _c1d.mode_tick is not None and _c1d.mode_tick.action == modetrade.CHARGE:
|
||||
_c1d.arb_headroom_w = None
|
||||
_t1 = _c1d._mode_roles(_n1d, None)
|
||||
_c1d.arb_headroom_w = 3000.0
|
||||
_t2 = _c1d._mode_roles(_n1d + timedelta(seconds=1), None)
|
||||
_c1d.arb_headroom_w = 0.0
|
||||
_t3 = _c1d._mode_roles(_n1d + timedelta(seconds=2), None)
|
||||
_c1d.arb_headroom_w = 3000.0
|
||||
_t4 = _c1d._mode_roles(_n1d + timedelta(seconds=3), None)
|
||||
_ok1d = (_t1.action is None and _t2.action == modetrade.CHARGE
|
||||
and _t3.action is None and _t4.action is None
|
||||
and _c1d._charge_blocked_q == q(_n1d))
|
||||
check("an unmeasured headroom (None) stops the charge that tick only; a "
|
||||
"measured 0 W stops it for the rest of the quarter", _ok1d)
|
||||
|
||||
|
||||
# 1e. Review of #157: gaps cannot flip the charge. A headroom forced to None
|
||||
# every other cycle flipped it 1799 times in 30 min before this; the second
|
||||
# unmeasured stop in a quarter now latches it.
|
||||
def _gaps(c):
|
||||
real, k = c._mode_roles, [0]
|
||||
|
||||
def mr(now, maint):
|
||||
k[0] += 1
|
||||
if k[0] % 2 == 0:
|
||||
c.arb_headroom_w = None
|
||||
return real(now, maint)
|
||||
c._mode_roles = mr
|
||||
|
||||
|
||||
_rows1e = run(2.0, 0.5, lag=LAG, patch=_gaps)[0]
|
||||
_flips = {}
|
||||
_on1e = next((i for i, r in enumerate(_rows1e)
|
||||
if r["action"] == arbitrage.CHARGE), len(_rows1e))
|
||||
for _a, _b in zip(_rows1e[_on1e:], _rows1e[_on1e + 1:]):
|
||||
if (_a["action"] == arbitrage.CHARGE) != (_b["action"] == arbitrage.CHARGE):
|
||||
_flips[q(_b["now"])] = _flips.get(q(_b["now"]), 0) + 1
|
||||
print(f" None every other cycle: charge on/off changes per quarter "
|
||||
f"{sorted(_flips.values())}")
|
||||
check("a headroom unmeasured every other cycle changes the charge at most "
|
||||
"4 times a quarter (restart, stop, restart, stop and latch)",
|
||||
_flips and max(_flips.values()) <= 4)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. F2: the export overshoot at a charge end")
|
||||
# A fills to its 95 % ceiling at about 04:03 under an 800 W house, and
|
||||
# Solar first B takes the house over.
|
||||
VARIANTS = {
|
||||
"model (5 s, instant readings)": None,
|
||||
"model, lagged (1 s cycles)": rp.Lag(),
|
||||
"TEST-23 plant, meter 1 s": rp.Lag(tau_s=0.05, ramp_w_s=1e9),
|
||||
"TEST-23 plant, meter 2 s": rp.Lag(tau_s=0.05, ramp_w_s=1e9,
|
||||
meter_s=2.0),
|
||||
"meter 2 s, battery reading fresh": rp.Lag(tau_s=0.05, ramp_w_s=1e9,
|
||||
meter_s=2.0, batt_s=0.0),
|
||||
"2 s cycles, meter 2 s": rp.Lag(tick=2.0, meter_s=2.0),
|
||||
"5 s meter": rp.Lag(tick=5.0, meter_s=5.0, batt_s=2.0),
|
||||
"slow battery (8 s, 2 s dead), 5 s meter": rp.Lag(
|
||||
tau_s=8.0, dead_s=2.0, tick=5.0, meter_s=5.0, batt_s=2.0),
|
||||
}
|
||||
|
||||
|
||||
def charge_end(rows, lag):
|
||||
"""(min grid, max grid, pre-edge grid, settle s) after the first charge
|
||||
end, over 120 s; settle: from then on within 40 W of the target."""
|
||||
st = stops(rows)
|
||||
if not st:
|
||||
return None
|
||||
i = st[0][0]
|
||||
tick = rp.TICK if lag is None else lag.tick
|
||||
w = [r["grid"] for r in rows[i:i + int(120 / tick)]]
|
||||
settle = next((j * tick for j in range(len(w))
|
||||
if all(abs(x + 10) < 40 for x in w[j:])), None)
|
||||
return min(w), max(w), rows[i - 1]["grid"], settle
|
||||
|
||||
|
||||
F2 = {}
|
||||
for _k, _lag in VARIANTS.items():
|
||||
F2[_k] = charge_end(run(3.9, 0.5, lag=_lag, soc_a=86.0)[0], _lag)
|
||||
print(f" {_k}: {F2[_k] and tuple(round(x) if x is not None else x for x in F2[_k])}")
|
||||
check(f"every variant: the charge ends, the export overshoot stays within "
|
||||
f"0.15 x rating ({BOUND:.0f} W), no import above the charge's own, and "
|
||||
f"the grid settles within 45 s (90 s for the slow battery)",
|
||||
all(v is not None and v[0] >= -BOUND and v[1] <= v[2] + 50
|
||||
and v[3] is not None and v[3] <= (90 if k.startswith("slow")
|
||||
else 45)
|
||||
for k, v in F2.items()))
|
||||
|
||||
|
||||
def _no_hold(c):
|
||||
c._edge_grid = lambda end, now: c.grid
|
||||
|
||||
|
||||
_pre = charge_end(run(3.9, 0.5, lag=VARIANTS["TEST-23 plant, meter 2 s"],
|
||||
soc_a=86.0, patch=_no_hold)[0],
|
||||
VARIANTS["TEST-23 plant, meter 2 s"])
|
||||
print(f" without the hold (5a4cdc2's reseed), meter 2 s: "
|
||||
f"{_pre and tuple(round(x) if x is not None else x for x in _pre)}")
|
||||
check("non-vacuous: without the hold the lagged model shows TEST-23's "
|
||||
"overshoot (over 0.4 x rating), so it cannot hide again",
|
||||
_pre is not None and _pre[0] < -0.4 * RATING)
|
||||
|
||||
# The hold must not hide a real load change at the edge.
|
||||
_down = run(3.9, 0.5, lag=LAG, soc_a=86.0, steps=[])[0]
|
||||
_i_end = stops(_down)[0][0] if stops(_down) else None
|
||||
_edge_t = _down[_i_end]["now"] if _i_end else None
|
||||
|
||||
|
||||
def _edge_step(w):
|
||||
"""The same charge end with the house stepping to `w` at the edge:
|
||||
(seconds until the loop moves B off E, lowest grid, highest grid after
|
||||
the first 3 s)."""
|
||||
if _edge_t is None:
|
||||
return None
|
||||
h = (_edge_t - UTC0).total_seconds() / 3600
|
||||
rows = run(3.9, 0.5, lag=LAG, soc_a=86.0, steps=[(h, w)])[0]
|
||||
after = [r for r in rows if r["now"] >= _edge_t]
|
||||
first = nth(after, 0, "the edge cycle")
|
||||
if not present(first):
|
||||
return None
|
||||
e = first["shares"].get(B)
|
||||
moved = next(((r["now"] - _edge_t).total_seconds() for r in after
|
||||
if r["shares"].get(B) != e), None)
|
||||
return moved, min(r["grid"] for r in after[:60]), max(
|
||||
r["grid"] for r in after[3:60]), [r["grid"] for r in after[:60]]
|
||||
|
||||
|
||||
_fall, _rise = _edge_step(200.0), _edge_step(1800.0)
|
||||
print(f" house 800 -> 200 W at the edge: loop moves after {_fall[0]} s, "
|
||||
f"lowest {_fall[1]:.0f} W; 800 -> 1800 W: after {_rise[0]} s, highest "
|
||||
f"{_rise[2]:.0f} W")
|
||||
check("the hold does not hide a load change: a house that falls at the edge "
|
||||
"ends it within 5 s, with no export beyond the fall and the bound; one "
|
||||
"that rises is followed by EDGE_HOLD_S, with no import beyond the rise",
|
||||
_fall and _rise and _fall[0] is not None and _fall[0] <= 5
|
||||
and _fall[1] >= -600 - BOUND
|
||||
and _rise[0] is not None and _rise[0] <= main.EDGE_HOLD_S + 2
|
||||
and _rise[2] <= 1000 + 50)
|
||||
_peak = _edge_step(4500.0)
|
||||
_g10 = nth(_peak[3], 10, "10 s after the edge") if _peak else None
|
||||
print(f" 800 -> 4500 W at the edge: grid 10 s later {_g10} W")
|
||||
check("...and when the peak floor takes the command during the hold, the "
|
||||
"hold ends: 10 s after the edge B gives its rating (grid 4500 - 2500 W), "
|
||||
"not the aim the floor holds", present(_g10) and _g10 <= 2100)
|
||||
|
||||
# Review of #157: the hold's own rules, on `_reseed_run_edge()` and
|
||||
# `_edge_grid()` directly (target -10 W, deadband 15 W, EDGE_HOLD_S 18 s).
|
||||
_T0 = UTC0
|
||||
|
||||
|
||||
def _held(grid, batt, s, last_b=-2500.0, end=False):
|
||||
"""(what the loop reads, still held) for a charge end E=800 W at +s."""
|
||||
f = SimpleNamespace(
|
||||
_edge=(800.0, 1.0, _T0 + timedelta(seconds=main.EDGE_HOLD_S), last_b),
|
||||
tuning=ctl.Tuning(target_grid_w=-10.0), grid=grid,
|
||||
site_batt_w=batt, i_w=0.0)
|
||||
g = main.Controller._edge_grid(f, end, _T0 + timedelta(seconds=s))
|
||||
return g, f._edge is not None
|
||||
|
||||
|
||||
_hs = main.EDGE_HOLD_S
|
||||
check("the hold: the loop reads the target while the meter still shows the "
|
||||
"step; it ends on a new trade or claim, when the meter comes within the "
|
||||
"deadband of the target, and at EDGE_HOLD_S (later only while the "
|
||||
"battery still moves to E, and never past twice that)",
|
||||
_held(3300.0, -2500.0, 1) == (-10.0, True)
|
||||
and _held(3300.0, -2500.0, 1, end=True) == (3300.0, False)
|
||||
and _held(5.0, -2500.0, 1) == (-10.0, True)
|
||||
and _held(4.0, -2500.0, 1) == (4.0, False)
|
||||
and _held(3300.0, -2500.0, _hs + 1) == (3300.0, False)
|
||||
and _held(3300.0, -2000.0, _hs + 1) == (-10.0, True)
|
||||
and _held(3300.0, -2000.0, 2 * _hs + 1) == (3300.0, False))
|
||||
# Review of #157 (X4): a reading that has passed E is not "still moving".
|
||||
check("...and a battery reading already past E does not extend the hold",
|
||||
_held(3300.0, 900.0, _hs + 1, last_b=700.0) == (3300.0, False))
|
||||
_f = SimpleNamespace(site_batt_w=500.0, grid=90.0, _edge=None,
|
||||
tuning=ctl.Tuning(target_grid_w=-10.0), site_max_w=5000.0,
|
||||
loop_bound=lambda claims: None, target=None, i_w=None)
|
||||
main.Controller._reseed_run_edge(_f, [], _T0)
|
||||
check("...and a small step is held too (E 600 W from 500 W)",
|
||||
_f.target == 600.0 and _f._edge is not None and _f._edge[:2] == (
|
||||
600.0, 1.0))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. F3: a charge refusal inside a cheap run is probed")
|
||||
# A refuses to charge from its first charge; the plant accepts again at
|
||||
# 02:40. The cheap run lasts to 04:45.
|
||||
|
||||
|
||||
def _accept(c, h, now, pr):
|
||||
if now >= at(2 + 40 / 60):
|
||||
h.plants["a"].refuse_charge = False
|
||||
|
||||
|
||||
ROWS3, C3 = run(1.9, 1.2, soc_a=30.0, refuse=("a",), check_cb=_accept)
|
||||
_ref3 = [r for r in ROWS3 if r["refused"]]
|
||||
_pr3 = [r for r in ROWS3 if r["probing"] and r["action"] == arbitrage.CHARGE]
|
||||
_probe_q = sorted({r["now"].astimezone(L).strftime("%H:%M") for r in _pr3})
|
||||
_end3 = next((r for r in ROWS3 if _ref3 and r["now"] > _ref3[-1]["now"]),
|
||||
None)
|
||||
print(f" refused {len(_ref3)} cycles; charge probes at {_probe_q}; "
|
||||
f"after: {_end3 and _end3['now'].astimezone(L).strftime('%H:%M:%S')}"
|
||||
f" {_end3 and _end3['action']}")
|
||||
check("while A refuses, it is probed every 15 minutes: at PROBE_W exactly, "
|
||||
"with B resting (no share on B, one sign)",
|
||||
len(_probe_q) >= 2 and all(
|
||||
r["shares"].get(A) == -ctl.PROBE_W and r["shares"].get(B, 0) == 0
|
||||
for r in _pr3))
|
||||
check("...outside a probe the refusal wins: no grid trade and A never "
|
||||
"charges",
|
||||
all(r["action"] is None and r["shares"].get(A, 0) >= 0
|
||||
for r in _ref3 if not r["probing"]))
|
||||
check("...and the probe after the plant accepts again ends the refusal and "
|
||||
"the charge resumes in the same run",
|
||||
_end3 is not None and _end3["now"] < at(3)
|
||||
and any(r["action"] == arbitrage.CHARGE and r["shares"].get(A, 0)
|
||||
< -1000 for r in ROWS3 if r["now"] > _end3["now"]))
|
||||
# Review of #157 (M15): outside its probe a refuser is no charger, so the
|
||||
# others are not made to rest. With solar to spare, Solar first B takes it.
|
||||
_rows3s = run(1.9, 0.5, soc_a=30.0, refuse=("a",), steps=[(2, -1500)])[0]
|
||||
_pt = [r["now"] for r in _rows3s if r["probing"]]
|
||||
_sun = [r for r in _rows3s if r["refused"] and not r["probing"]
|
||||
and r["now"] >= at(2 + 5 / 60) and not any(
|
||||
timedelta(0) < r["now"] - t <= timedelta(seconds=30) for t in _pt)]
|
||||
_bsun = sorted(r["shares"].get(B, 0) for r in _sun)
|
||||
print(f" solar 1500 W over the house, A refusing: B's share outside probes "
|
||||
f"{_bsun[0] if _bsun else None} .. {_bsun[-1] if _bsun else None} W")
|
||||
check("...outside a probe the others are not held at rest: Solar first B "
|
||||
"takes the spare solar (from 30 s after a probe)",
|
||||
_sun and all(r["shares"].get(B, 0) < -1000 for r in _sun))
|
||||
_pk = modetrade.Pack(A, True, packmode.CHEAP_HOURS, False, 2500.0, 30.0,
|
||||
10.0, refused=-1, probing=True)
|
||||
_runs = SimpleNamespace(on=True, cheap=SimpleNamespace(holds=lambda n: True),
|
||||
charge_useful=lambda: True, give_at=lambda n: None,
|
||||
sell=None)
|
||||
|
||||
|
||||
def _dec(packs, **kw):
|
||||
args = dict(charge_ok=True, sell_ok=True, trade_why=None,
|
||||
price_now=0.1, headroom_w=3000.0, floor_pct=20.0,
|
||||
ceiling_pct=95.0, house_w=800.0)
|
||||
args.update(kw)
|
||||
return modetrade.decide(_runs, UTC0, packs, **args)
|
||||
|
||||
|
||||
_pb = modetrade.Pack(B, True, packmode.SOLAR_FIRST, False, 2500.0, 60.0,
|
||||
10.0)
|
||||
check("modetrade: a probing charge refuser is a charger, the others rest; "
|
||||
"not while in maintenance, not on Leftover solar or Solar first, not "
|
||||
"latched, not under the 500 W minimum, not with the guard engaged, "
|
||||
"and never in energy() (the plan's sizing)",
|
||||
_dec([_pk, _pb]).action == modetrade.CHARGE
|
||||
and _dec([_pk, _pb]).roles == {A: modetrade.CHARGE, B: modetrade.REST}
|
||||
and _dec([replace(_pk, idle=False), _pb]).action is None
|
||||
and all(_dec([replace(_pk, mode=m), _pb]).action is None
|
||||
for m in (packmode.LEFTOVER_SOLAR, packmode.SOLAR_FIRST))
|
||||
and _dec([_pk, _pb], blocked=True).action is None
|
||||
and _dec([_pk, _pb], headroom_w=450.0).action is None
|
||||
and _dec([_pk, _pb], charge_ok=False, trade_why="peak").action is None
|
||||
and modetrade.energy([_pk], 20.0, 95.0)[1] == 0.0
|
||||
and _dec([replace(_pk, probing=False), _pb]).action is None)
|
||||
|
||||
# On the new base (release/1.0 e3e4ba6): SAFETY-78's drain comes before the
|
||||
# probe, and P4's day plan sets how far a probing Leftover solar battery goes.
|
||||
_ls = replace(_pk, mode=packmode.LEFTOVER_SOLAR)
|
||||
_dr = _dec([_pk, _pb], draining=True)
|
||||
check("a monthly drain comes before the probe: no grid charge while another "
|
||||
"battery drains, the probing refuser included (why 'cycle')",
|
||||
_dr.action is None and _dr.why == "cycle"
|
||||
and _dr.roles.get(A) != modetrade.CHARGE
|
||||
and _dec([_pk, _pb], draining=False).action == modetrade.CHARGE)
|
||||
check("with a day plan a probing Leftover solar battery is a charger up to "
|
||||
"its planned level and no further; without a level for it, it is not",
|
||||
_dec([_ls, _pb], targets={A: 60.0}).action == modetrade.CHARGE
|
||||
and _dec([_ls, _pb], targets={A: 25.0}).action is None
|
||||
and _dec([_ls, _pb], targets={B: 60.0}).action is None
|
||||
and _dec([_ls, _pb]).action is None
|
||||
and modetrade.energy([_ls], 20.0, 95.0, targets={A: 60.0})[1] == 0.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. invariants on these runs (and part 1c's house in the model)")
|
||||
_rows4, _c4 = run(1.9, 0.9, steps=[
|
||||
(2 + 20 / 60, 3700), (2 + 21 / 60, 800), (2 + 26 / 60, 3000),
|
||||
(2 + 40 / 60, 2850)])
|
||||
_all = ROWS1 + ROWS2 + ROWS3 + _rows4
|
||||
check("no opposite-direction split on any cycle", not mixed(_all))
|
||||
_ceil = peakguard.quarter_ceiling_w(rp.PEAK_CAP, None)[0]
|
||||
_bk0 = banked(_c4, _rows4)
|
||||
_bk = banked(C1, ROWS1) + banked(C2, ROWS2)
|
||||
print(f" banked quarters with a grid charge: model {len(_bk0)}, highest "
|
||||
f"{max(_bk0):.2f} W; lagged {len(_bk)}, highest {max(_bk):.2f} W "
|
||||
f"(e3e4ba6: the same 3451.37 W); ceiling - margin {_ceil - 50:.0f} W")
|
||||
check("every banked 15-minute period with a grid charge is at most "
|
||||
"quarter_ceiling_w() - margin (SAFETY-79) in the model; lagged, the "
|
||||
"battery's lag may use 2 W of the 50 W margin, as on e3e4ba6",
|
||||
_bk0 and max(_bk0) <= _ceil - 50 + 0.5
|
||||
and _bk and max(_bk) <= _ceil - 50 + 2.0)
|
||||
check("Solar first and Leftover solar never charge from the grid: no charge "
|
||||
"role and no charge share on B while the site charges",
|
||||
all(r["roles"].get(B) != modetrade.CHARGE for r in _all)
|
||||
and all(r["shares"].get(B, 0) >= 0 for r in _all
|
||||
if r["action"] == arbitrage.CHARGE))
|
||||
check("selling is off: no sale on any cycle",
|
||||
packmode.SELLING_ENABLED is False
|
||||
and all(r["action"] != arbitrage.DISCHARGE for r in _all))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. modes off: byte-identical to release/1.0 e3e4ba6")
|
||||
# Printed on release/1.0 e3e4ba6 (2026-10-13), goodwe-addon-checks:1, with this
|
||||
# branch's harness (same API). n1 and n2 are also 5a4cdc2's; the lag ones moved
|
||||
# with SAFETY-90/91 (n2_lag now equals n2: the next-quarter grading removed the
|
||||
# edge tick's blindness at N=2).
|
||||
#
|
||||
# PRICE-02 (2026-10-22) changed two things in the trade sentence and nothing
|
||||
# else (see test_modes_p3.py part 1): the "N above the cheapest" figure of a
|
||||
# sale and "hour" -> "price period". Three pins, as there:
|
||||
# AT_BASE_OLD the digests printed on e3e4ba6, sentence included (no longer
|
||||
# matched: the mask is needed);
|
||||
# AT_BASE the same rows through `ident.trade_digest()`, equal to what
|
||||
# e3e4ba6 gives through it;
|
||||
# AT_NOW this tree's own digests with nothing hidden.
|
||||
AT_BASE_OLD = {
|
||||
"n1": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c",
|
||||
"n2": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
|
||||
"n1_lag": "86ef0f5ad8da1498ff7dcf59262a177bef847a0b201ce804e947c8eab5f07190",
|
||||
"n2_lag": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
|
||||
}
|
||||
AT_BASE = {
|
||||
"n1": "33886ef03f364852883f0370230e6e04823fbb46a4704f88ca75bf9082d98c01",
|
||||
"n2": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
|
||||
"n1_lag": "f6c67b351bd732fb69e62cafbfdb2bcff6032f5d8845836659d096b8c677d3c0",
|
||||
"n2_lag": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
|
||||
}
|
||||
AT_NOW = {
|
||||
"n1": "939b3d47bbd64fa50e3d70538f092fb98e5c4bd5ead85a7fc696a1dbb514039e",
|
||||
"n2": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d",
|
||||
"n1_lag": "78aac892975be81a7ceac0c000ed441a731bd4f42ff9f729996cbee53d6b7f02",
|
||||
"n2_lag": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d",
|
||||
}
|
||||
_cmd_digest = ident.trade_digest
|
||||
_rows5 = {k: ident.run(**ident.SCENARIOS[k])[0] for k in AT_BASE}
|
||||
_got = {k: _cmd_digest(v) for k, v in _rows5.items()}
|
||||
_full5 = {k: ident.digest(v) for k, v in _rows5.items()}
|
||||
for _k in AT_BASE:
|
||||
print(f" {_k} {'same' if _got[_k] == AT_BASE[_k] else 'DIFFERS'} "
|
||||
f"{_got[_k][:16]}")
|
||||
check("with battery modes off every command, integrator, allocation and "
|
||||
"trade action equals e3e4ba6's (the sentence masked as above), also when "
|
||||
"every quarter boundary's cycle still sees the old P1 block",
|
||||
_got == AT_BASE)
|
||||
check("...the mask is needed and nothing more: unmasked, the digests differ "
|
||||
"from e3e4ba6's printed ones, and equal this tree's own pins, so a "
|
||||
"later change to any sentence or figure fails here",
|
||||
all(_full5[k] != AT_BASE_OLD[k] for k in AT_BASE_OLD)
|
||||
and _full5 == AT_NOW)
|
||||
|
||||
_real_project = tariff.PeakTracker.project
|
||||
tariff.PeakTracker.project = staticmethod(
|
||||
lambda a, now, roll=False: _real_project(a, now, roll=True))
|
||||
try:
|
||||
_moved = _cmd_digest(ident.run(**ident.SCENARIOS["n1_lag"])[0]) \
|
||||
!= AT_BASE["n1_lag"]
|
||||
finally:
|
||||
tariff.PeakTracker.project = _real_project
|
||||
check("non-vacuous: the rolled projection leaking into modes off moves the "
|
||||
"late-sample scenario", _moved)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -1,412 +0,0 @@
|
||||
"""PRICE-01 round 2 (F1): a `password` option never reaches a page.
|
||||
`python3 test_price_secret.py`
|
||||
|
||||
The real web app (`web.build_app`) runs over a fake Supervisor that holds a
|
||||
site on `price_service` with a token. The token string must be absent from
|
||||
`/config`, `/config/model` (`options`, `running`, `ui`), `/config/validate`,
|
||||
a good save's answer and every refused save's answer. A save that sends the
|
||||
placeholder, an empty field or no key keeps the stored token; a new value
|
||||
replaces it. The same holds for any option the schema calls a password, not
|
||||
only this one name.
|
||||
|
||||
⚠️ MUTATING A MODULE THIS FILE GRADES? Purge `__pycache__` first, or use
|
||||
`tools/mutation_run.sh`. See MUTATION.md.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
from aiohttp import ClientSession, web as aioweb # noqa: E402
|
||||
|
||||
import translations_lint as TL # noqa: E402
|
||||
import ux13_demo as D # noqa: E402
|
||||
from app import configui, price, web # noqa: E402
|
||||
from app.configui import SECRET_SHOWN # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
TOKEN = "tk-SECRET-0123456789-abcdef"
|
||||
NEW_TOKEN = "tk-REPLACED-9876543210-zyxw"
|
||||
SCHEMA = D.schema_ui(D.CONFIG)
|
||||
TRANSLATIONS = {"en": TL.parse_translation_file(
|
||||
os.path.join(HERE, "translations", "en.yaml"))}
|
||||
SITE = dict(D.defaults(D.CONFIG), setpoint_entity="number.goodwe_setpoint",
|
||||
soc_entity="sensor.soc", batt_entity="sensor.batt",
|
||||
price_provider="price_service", price_service_token=TOKEN)
|
||||
|
||||
|
||||
class Sup:
|
||||
def __init__(self, options, schema=SCHEMA):
|
||||
self.options, self.schema, self.writes = copy.deepcopy(options), schema, []
|
||||
|
||||
async def info(self):
|
||||
return {"options": copy.deepcopy(self.options), "schema": self.schema,
|
||||
"translations": TRANSLATIONS}
|
||||
|
||||
async def write_options(self, o):
|
||||
self.writes.append(copy.deepcopy(o))
|
||||
self.options = copy.deepcopy(o)
|
||||
|
||||
async def restart(self, c):
|
||||
raise AssertionError
|
||||
|
||||
|
||||
class Hass:
|
||||
session = None
|
||||
token = ""
|
||||
|
||||
async def limits(self, _e):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def states(self):
|
||||
return []
|
||||
|
||||
|
||||
class Ctl:
|
||||
def __init__(self, o):
|
||||
self.o, self.hass, self.inverters = dict(o), Hass(), []
|
||||
|
||||
def log_event(self, *a, **k):
|
||||
pass
|
||||
|
||||
def status_payload(self):
|
||||
return {"banner": "", "level": "ok", "rows": [], "checks": []}
|
||||
|
||||
|
||||
async def serve(sup, ctl_options=None):
|
||||
runner = aioweb.AppRunner(web.build_app(Ctl(ctl_options or sup.options), sup))
|
||||
await runner.setup()
|
||||
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
|
||||
await site.start()
|
||||
port = site._server.sockets[0].getsockname()[1]
|
||||
return runner, f"http://127.0.0.1:{port}/"
|
||||
|
||||
|
||||
async def scenario():
|
||||
out = {}
|
||||
sup = Sup(SITE)
|
||||
runner, root = await serve(sup)
|
||||
seen = [] # every answer body, as text
|
||||
try:
|
||||
async with ClientSession() as s:
|
||||
async def get(path):
|
||||
async with s.get(root + path) as r:
|
||||
t = await r.text()
|
||||
seen.append(t)
|
||||
return r.status, t
|
||||
|
||||
async def post(path, body):
|
||||
async with s.post(root + path, json=body) as r:
|
||||
t = await r.text()
|
||||
seen.append(t)
|
||||
return r.status, t
|
||||
|
||||
_, page = await get("config")
|
||||
out["page"] = page
|
||||
_, raw = await get("config/model?lang=en")
|
||||
m = json.loads(raw)
|
||||
out["model"] = m
|
||||
out["raw_model"] = raw
|
||||
out["field"] = [f for g in m["groups"] for f in g["fields"]
|
||||
if f["key"] == "price_service_token"]
|
||||
|
||||
# a model of a site with NO token: shown empty, not as 'set'
|
||||
sup0 = Sup(dict(SITE, price_service_token=""))
|
||||
r0, root0 = await serve(sup0)
|
||||
try:
|
||||
async with s.get(root0 + "config/model?lang=en") as r:
|
||||
out["model_unset"] = await r.json()
|
||||
finally:
|
||||
await r0.cleanup()
|
||||
|
||||
# --- classic page save: the page posts `options`, placeholder in
|
||||
opts = copy.deepcopy(m["options"])
|
||||
opts["peak_cap_w"] = 4100
|
||||
st, body = await post("config/save",
|
||||
{"options": opts, "fingerprint": m["fingerprint"]})
|
||||
out["classic_placeholder"] = (st, sup.options["price_service_token"],
|
||||
sup.options["peak_cap_w"], body)
|
||||
# --- classic save with the field emptied
|
||||
_, raw2 = await get("config/model?lang=en")
|
||||
m2 = json.loads(raw2)
|
||||
opts = copy.deepcopy(m2["options"])
|
||||
opts["price_service_token"] = ""
|
||||
opts["peak_cap_w"] = 4200
|
||||
st, body = await post("config/save",
|
||||
{"options": opts, "fingerprint": m2["fingerprint"]})
|
||||
out["classic_empty"] = (st, sup.options["price_service_token"],
|
||||
sup.options["peak_cap_w"])
|
||||
# --- classic save with the key left out
|
||||
_, raw2 = await get("config/model?lang=en")
|
||||
m2 = json.loads(raw2)
|
||||
opts = copy.deepcopy(m2["options"])
|
||||
del opts["price_service_token"]
|
||||
opts["peak_cap_w"] = 4250
|
||||
st, body = await post("config/save",
|
||||
{"options": opts, "fingerprint": m2["fingerprint"]})
|
||||
out["classic_absent"] = (st, sup.options["price_service_token"])
|
||||
# --- the new settings page: model in, with and without base
|
||||
_, raw3 = await get("config/model?lang=en")
|
||||
m3 = json.loads(raw3)
|
||||
ui = copy.deepcopy(m3["ui"])
|
||||
ui["peak_cap_w"] = 4300
|
||||
st, body = await post("config/save", {
|
||||
"model": ui, "base": copy.deepcopy(m3["ui"]),
|
||||
"fingerprint": m3["fingerprint"]})
|
||||
out["ui_base"] = (st, sup.options["price_service_token"])
|
||||
_, raw3 = await get("config/model?lang=en")
|
||||
m3 = json.loads(raw3)
|
||||
ui = copy.deepcopy(m3["ui"])
|
||||
ui["peak_cap_w"] = 4400
|
||||
st, body = await post("config/save",
|
||||
{"model": ui, "fingerprint": m3["fingerprint"]})
|
||||
out["ui_nobase"] = (st, sup.options["price_service_token"])
|
||||
# --- validate with the placeholder in: no 'too short', no echo
|
||||
_, raw4 = await get("config/model?lang=en")
|
||||
m4 = json.loads(raw4)
|
||||
st, body = await post("config/validate", {"options": m4["options"]})
|
||||
out["validate_placeholder"] = (st, json.loads(body)["problems"])
|
||||
st, body = await post("config/validate", {"model": m4["ui"]})
|
||||
out["validate_ui_placeholder"] = (st, json.loads(body)["problems"])
|
||||
# --- a refused save (token too short) and a stale one
|
||||
opts = copy.deepcopy(m4["options"])
|
||||
opts["price_service_token"] = "short"
|
||||
st, body = await post("config/save",
|
||||
{"options": opts, "fingerprint": m4["fingerprint"]})
|
||||
out["refused"] = (st, json.loads(body), sup.options["price_service_token"])
|
||||
opts = copy.deepcopy(m4["options"])
|
||||
opts["peak_cap_w"] = 4500
|
||||
st, body = await post("config/save",
|
||||
{"options": opts, "fingerprint": "not-current"})
|
||||
out["stale"] = (st, json.loads(body))
|
||||
# --- a refusal that is not about the token
|
||||
opts = copy.deepcopy(m4["options"])
|
||||
opts["max_w"] = -5
|
||||
st, body = await post("config/save",
|
||||
{"options": opts, "fingerprint": m4["fingerprint"]})
|
||||
out["refused_other"] = (st, sup.options["price_service_token"],
|
||||
sup.options["max_w"])
|
||||
# --- a new value replaces
|
||||
_, raw5 = await get("config/model?lang=en")
|
||||
m5 = json.loads(raw5)
|
||||
opts = copy.deepcopy(m5["options"])
|
||||
opts["price_service_token"] = NEW_TOKEN
|
||||
st, body = await post("config/save",
|
||||
{"options": opts, "fingerprint": m5["fingerprint"]})
|
||||
out["replaced"] = (st, sup.options["price_service_token"], body)
|
||||
out["writes"] = len(sup.writes)
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
out["seen"] = seen
|
||||
return out
|
||||
|
||||
|
||||
S = asyncio.run(scenario())
|
||||
|
||||
print("1. the token is in no page and no JSON answer")
|
||||
check("GET /config (the classic page) has no token",
|
||||
TOKEN not in S["page"] and NEW_TOKEN not in S["page"])
|
||||
check("GET /config/model has no token anywhere in its JSON",
|
||||
TOKEN not in S["raw_model"])
|
||||
for key in ("options", "running", "ui"):
|
||||
check(f"...and {key} shows the placeholder, not the token",
|
||||
json.dumps(S["model"][key]).count(SECRET_SHOWN) >= 1
|
||||
and TOKEN not in json.dumps(S["model"][key]))
|
||||
check("...the placeholder stands in the token's own place",
|
||||
S["model"]["options"]["price_service_token"] == SECRET_SHOWN
|
||||
and S["model"]["running"]["price_service_token"] == SECRET_SHOWN
|
||||
and S["model"]["ui"]["price_service_token"] == SECRET_SHOWN)
|
||||
check("a site with no token shows it empty, so the page can say 'not set'",
|
||||
S["model_unset"]["options"]["price_service_token"] == ""
|
||||
and S["model_unset"]["ui"]["price_service_token"] == "")
|
||||
check("no answer the app gave in the whole run (page, model, validate, saves, "
|
||||
"refusals, the stale one) holds the stored token",
|
||||
not [t for t in S["seen"] if TOKEN in t])
|
||||
check("...nor the new token, after it was saved",
|
||||
not [t for t in S["seen"] if NEW_TOKEN in t])
|
||||
|
||||
print("\n2. the classic page draws it as a password field")
|
||||
check("the field model types it as a password",
|
||||
[f["type"] for f in S["field"]] == ["password"])
|
||||
check("the classic page's script makes a password input for that type",
|
||||
"'password'" in S["page"] and "input.type = 'password'" in S["page"])
|
||||
|
||||
print("\n3. saves keep the stored token unless given a new one")
|
||||
st, tok, cap, body = S["classic_placeholder"]
|
||||
check("a classic save with the placeholder keeps the token and saves the "
|
||||
"other edit", st == 200 and tok == TOKEN and cap == 4100)
|
||||
st, tok, cap = S["classic_empty"]
|
||||
check("a classic save with the field emptied keeps the token",
|
||||
st == 200 and tok == TOKEN and cap == 4200)
|
||||
check("a classic save without the key keeps the token",
|
||||
S["classic_absent"] == (200, TOKEN))
|
||||
check("a save from the new settings page keeps it (with a base)",
|
||||
S["ui_base"] == (200, TOKEN))
|
||||
check("...and without a base", S["ui_nobase"] == (200, TOKEN))
|
||||
st, tok, body = S["replaced"]
|
||||
check("a new value replaces the token", st == 200 and tok == NEW_TOKEN)
|
||||
check("...and the answer to that save does not echo it",
|
||||
NEW_TOKEN not in body)
|
||||
st, probs = S["validate_placeholder"]
|
||||
check("validate with the placeholder in sees the stored token: nothing about "
|
||||
"the token is refused", st == 200 and not [p for p in probs if "token" in p])
|
||||
st, probs = S["validate_ui_placeholder"]
|
||||
check("...also from the new page's model",
|
||||
st == 200 and not [p for p in probs if "token" in p])
|
||||
st, body, tok = S["refused"]
|
||||
check("a refused save (token too short) is 422, says why in short plain "
|
||||
"words, and writes nothing",
|
||||
st == 422 and tok == TOKEN
|
||||
and any("too short" in p for p in body["problems"]))
|
||||
st, body = S["stale"]
|
||||
check("a stale refusal is 409 and its `stored` shows the placeholder",
|
||||
st == 409 and body["stored"]["price_service_token"] == SECRET_SHOWN)
|
||||
check("a refusal about something else (422) writes nothing, so the token and "
|
||||
"the rating stay",
|
||||
S["refused_other"][0] == 422 and S["refused_other"][1] == TOKEN
|
||||
and S["refused_other"][2] != -5)
|
||||
|
||||
print("\n4. every password option, not only this name")
|
||||
other = [{"name": "mqtt_secret", "type": "string", "format": "password"},
|
||||
{"name": "other_pw", "type": "password"},
|
||||
{"name": "plain", "type": "string"}]
|
||||
keys = configui.secret_keys(other)
|
||||
check("a schema password is found in both spellings, a plain string is not",
|
||||
keys == {"mqtt_secret", "other_pw"})
|
||||
check("the price token is counted by name too, in case a schema is missing",
|
||||
configui.secret_keys([], price.SECRET_OPTIONS)
|
||||
== frozenset(price.SECRET_OPTIONS) and "price_service_token" in
|
||||
price.SECRET_OPTIONS)
|
||||
blob = {"mqtt_secret": "hunter2hunter2", "other_pw": "", "plain": "x",
|
||||
"nested": [{"mqtt_secret": "deep-secret-value"}]}
|
||||
shown = configui.hide_secrets(blob, keys)
|
||||
check("hide_secrets: set -> placeholder, empty -> empty, other keys alone, "
|
||||
"at any depth",
|
||||
shown == {"mqtt_secret": SECRET_SHOWN, "other_pw": "", "plain": "x",
|
||||
"nested": [{"mqtt_secret": SECRET_SHOWN}]}
|
||||
and blob["mqtt_secret"] == "hunter2hunter2")
|
||||
back = configui.keep_secrets({"mqtt_secret": SECRET_SHOWN, "other_pw": "new",
|
||||
"plain": "y"}, blob, keys)
|
||||
check("keep_secrets: the placeholder gives the stored value, a new value "
|
||||
"stays",
|
||||
back["mqtt_secret"] == "hunter2hunter2" and back["other_pw"] == "new"
|
||||
and back["plain"] == "y")
|
||||
check("keep_secrets: a placeholder with nothing stored is never saved as "
|
||||
"the placeholder",
|
||||
configui.keep_secrets({"mqtt_secret": SECRET_SHOWN}, {}, keys)
|
||||
["mqtt_secret"] == "")
|
||||
|
||||
|
||||
async def other_option_scenario():
|
||||
"""A second password option, found from the schema, is hidden and kept."""
|
||||
schema = SCHEMA + [{"name": "extra_secret", "type": "string",
|
||||
"format": "password", "required": False,
|
||||
"optional": True}]
|
||||
sup = Sup(dict(SITE, extra_secret="extra-secret-value-12345"), schema)
|
||||
runner, root = await serve(sup)
|
||||
try:
|
||||
async with ClientSession() as s:
|
||||
async with s.get(root + "config/model?lang=en") as r:
|
||||
raw = await r.text()
|
||||
m = json.loads(raw)
|
||||
opts = copy.deepcopy(m["options"])
|
||||
opts["peak_cap_w"] = 4100
|
||||
async with s.post(root + "config/save", json={
|
||||
"options": opts, "fingerprint": m["fingerprint"]}) as r:
|
||||
st = r.status
|
||||
return raw, st, sup.options.get("extra_secret")
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
_raw, _st, _kept = asyncio.run(other_option_scenario())
|
||||
check("a password option the add-on has no name for is hidden from the model",
|
||||
"extra-secret-value-12345" not in _raw)
|
||||
check("...and kept by a save that sends the placeholder",
|
||||
_st == 200 and _kept == "extra-secret-value-12345")
|
||||
|
||||
async def named_scenario():
|
||||
"""A schema that does not call the token a password (a Supervisor that
|
||||
spells it another way): the add-on still hides it, by name."""
|
||||
schema = [dict(n, format=None) if n["name"] == "price_service_token" else n
|
||||
for n in SCHEMA]
|
||||
sup = Sup(SITE, schema)
|
||||
runner, root = await serve(sup)
|
||||
try:
|
||||
async with ClientSession() as s:
|
||||
async with s.get(root + "config/model?lang=en") as r:
|
||||
return await r.text()
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
check("the price token is hidden by name even when the schema does not mark "
|
||||
"it as a password", TOKEN not in asyncio.run(named_scenario()))
|
||||
|
||||
print("\n5. PRICE-01 round 3: the fixed-tariff note on the real validate route")
|
||||
_PROFILE = json.dumps({"periods": [
|
||||
{"name": "all", "days": "all", "from": "00:00", "to": "24:00",
|
||||
"eur_per_kwh": 0.25}]})
|
||||
_NOTE = {"en": ("With the price service, the fixed tariff is only used if no "
|
||||
"prices are available."),
|
||||
"nl": ("Met de prijsdienst wordt het vaste tarief alleen gebruikt als "
|
||||
"er geen prijzen zijn.")}
|
||||
|
||||
|
||||
async def note_scenario(site):
|
||||
runner, root = await serve(Sup(site))
|
||||
out = {}
|
||||
try:
|
||||
async with ClientSession() as s:
|
||||
for lang in ("en", "nl"):
|
||||
async with s.get(root + f"config/model?lang={lang}") as r:
|
||||
m = await r.json()
|
||||
async with s.post(root + "config/validate",
|
||||
json={"model": m["ui"], "lang": lang}) as r:
|
||||
b = await r.json()
|
||||
out[lang] = [x for x in b.get("messages", [])
|
||||
if x["text"] in _NOTE.values()]
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
return out
|
||||
|
||||
|
||||
_with = asyncio.run(note_scenario(dict(SITE, static_profile=_PROFILE)))
|
||||
check("an English page gets the note in English, as info, beside a field",
|
||||
[(x["text"], x["tier"]) for x in _with["en"]] == [(_NOTE["en"], "info")]
|
||||
and _with["en"][0]["field"])
|
||||
check("...and a Dutch page gets it in Dutch, beside the same field",
|
||||
[(x["text"], x["tier"]) for x in _with["nl"]] == [(_NOTE["nl"], "info")]
|
||||
and _with["nl"][0]["field"] == _with["en"][0]["field"])
|
||||
_blank = asyncio.run(note_scenario(dict(SITE, static_profile=" ")))
|
||||
check("a spaces-only fixed tariff: no note on either page",
|
||||
_blank == {"en": [], "nl": []})
|
||||
|
||||
print("\n6. the shipped config.yaml")
|
||||
cfg = open(os.path.join(HERE, "config.yaml"), encoding="utf-8").read()
|
||||
schema_text = cfg[cfg.index("\nschema:"):]
|
||||
pw = {line.split(":")[0].strip() for line in schema_text.splitlines()
|
||||
if line.split(":", 1)[-1].strip().rstrip("?") == "password"}
|
||||
check(f"every password option of config.yaml is counted as a secret ({sorted(pw)})",
|
||||
pw and pw <= configui.secret_keys(SCHEMA, price.SECRET_OPTIONS))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,401 +0,0 @@
|
||||
"""Runnable check for SAFETY-103: no swing on old battery readings after a
|
||||
drain ends. `python3 test_safety103.py`
|
||||
|
||||
While a battery settles from its ended drain (SAFETY-95), the loop works
|
||||
from the grid plus each battery's reading. A reading can be one register
|
||||
period (10 s) older than the meter: on the rig (SAFETY-95 test, check 5) A's
|
||||
reading still said 1238 W while A was at 616 W, and B went 860 -> 1650 ->
|
||||
870 W. Now the house point is held, and moved only as far as the meter
|
||||
proves (each battery between its last changed reading and its commands
|
||||
since). Drives the REAL Controller on tools/safety103_plant.py: a lagging
|
||||
battery, the meter every 1 s, registers every 10 s at the rig's phases, B
|
||||
idling at -100 W. DECISIONS/SAFETY-103-old-readings-after-a-drain.md.
|
||||
|
||||
1. The rig case and every drain end at N=2 and N=3, 10 s registers: no
|
||||
swing beyond one slew step; release/1.0 swings (non-vacuous).
|
||||
2. SAFETY-95/96's gains stay: no jump at the end, no give into a surplus,
|
||||
one slew step a cycle, refusal and stale link still learned.
|
||||
3. A house step inside the window is still followed.
|
||||
4. With readings as fresh as the meter, every row is release/1.0's; N=1 is
|
||||
untouched.
|
||||
5. The rule's parts, one by one.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
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 safety103_plant as p # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.maintenance import DRAIN # noqa: E402
|
||||
|
||||
fails = []
|
||||
SLEW = 400.0 # the rig's slew_w
|
||||
AIM = -10.0 # the rig's target_grid_w
|
||||
T = p.T
|
||||
A = p.A
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
fixed = Controller._drain_grid_w
|
||||
track = getattr(Controller, "_track_reads", None)
|
||||
|
||||
|
||||
def _release(self, live):
|
||||
"""release/1.0's `_drain_grid_w` (SAFETY-95 as merged): every battery at
|
||||
its reading."""
|
||||
grid = float(self.grid)
|
||||
self._drain_tail &= set(self._settle) & set(live)
|
||||
if not self._drain_tail:
|
||||
return grid
|
||||
return grid - float(self.target) + sum(
|
||||
float(inv.batt) for inv in self.inverters if inv.name in live)
|
||||
|
||||
|
||||
def without(fn):
|
||||
"""Run `fn` with SAFETY-103 taken out (release/1.0's rule)."""
|
||||
Controller._drain_grid_w = _release
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
Controller._drain_grid_w = fixed
|
||||
|
||||
|
||||
def soc_done(u, t):
|
||||
return 5.0 if (u == "a" and t >= T) else 50.0
|
||||
|
||||
|
||||
# A's drain ends at T: switched Off (the rig case), completed on SoC, its
|
||||
# battery refusing (gives nothing from T; SAFETY-73 learns it later), or its
|
||||
# link going stale (writes refused from T, its board to 0 W 25 s later).
|
||||
ENDS = {
|
||||
"Off": dict(),
|
||||
"complete": dict(before=None, soc=soc_done),
|
||||
"refusal": dict(before=None, dead=("a", T), dur=T + 150.0),
|
||||
"stale link": dict(before=None, reject={"a": T}, dead=("a", T + 25.0),
|
||||
dur=T + 150.0),
|
||||
}
|
||||
|
||||
|
||||
def end_of(rows):
|
||||
"""The first cycle A's drain is over (its command left 2000 W, or its
|
||||
phase left DRAIN); None if never."""
|
||||
return next((r["t"] for r in rows if r["t"] >= T and (
|
||||
r["cmd"][0] < 1990.0 or r["phase"][0] != DRAIN)), None)
|
||||
|
||||
|
||||
def measure(rows, n, house):
|
||||
"""From the end on: the largest swing of the others' summed command in
|
||||
the 23 s window (a move later undone; the slew step is the site's); their
|
||||
largest command on the end cycle; the site target's largest step after
|
||||
the end cycle; the others' largest command after the end (for a
|
||||
surplus); the grid's range in the window."""
|
||||
te = end_of(rows)
|
||||
if te is None:
|
||||
return dict(end=math.nan, swing=math.nan, first=math.nan,
|
||||
step=math.nan, give=math.nan, gmin=math.nan,
|
||||
gmax=math.nan, rows=rows)
|
||||
win = [r for r in rows if te <= r["t"] <= te + 23.0]
|
||||
sw = 0.0
|
||||
w = [sum(r["cmd"][1:]) for r in win][1:]
|
||||
for j in range(len(w)):
|
||||
sw = max(sw, min(w[j] - min(w[:j + 1]), w[j] - min(w[j:])),
|
||||
min(max(w[:j + 1]) - w[j], max(w[j:]) - w[j]))
|
||||
seg = [r["target"] for r in rows if r["t"] >= te]
|
||||
return dict(
|
||||
end=te, swing=sw,
|
||||
first=max(win[0]["cmd"][1:]) if win else math.nan,
|
||||
step=max(abs(b - a) for a, b in zip(seg, seg[1:])),
|
||||
give=max(max(r["cmd"][1:]) for r in rows if r["t"] >= te),
|
||||
gmin=min(r["grid"] for r in win), gmax=max(r["grid"] for r in win),
|
||||
rows=rows)
|
||||
|
||||
|
||||
def run(kind, n, house, **k):
|
||||
return p.run(n, house, **dict(ENDS[kind], **k))[0]
|
||||
|
||||
|
||||
TABLE = []
|
||||
# A settles from its drain: the tail this rule is about. A refusing battery
|
||||
# already reads ~0 W, and a dropped one is out of the split (the meter has
|
||||
# it), so their tails last at most a cycle.
|
||||
SETTLE = ("Off", "complete")
|
||||
|
||||
|
||||
def case(kind, n, house, **k):
|
||||
now = measure(run(kind, n, house, **k), n, house)
|
||||
old = measure(without(lambda: run(kind, n, house, **k)), n, house)
|
||||
fresh = measure(run(kind, n, house, reg_s=1.0, **k), n, house)
|
||||
TABLE.append((kind, n, house, now, old, fresh))
|
||||
return now, old, fresh
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. no swing on an old reading: every drain end, N=2 and N=3, 10 s "
|
||||
"registers")
|
||||
for kind in ENDS:
|
||||
for n in (2, 3):
|
||||
for house in (1200.0, 4000.0, -1500.0):
|
||||
now, old, fresh = case(kind, n, house)
|
||||
own = kind in SETTLE
|
||||
check(f"{'⭐' if kind == 'Off' else ' '} {kind}, N={n}, house "
|
||||
f"{house:.0f} W: old readings add at most one slew step "
|
||||
f"to the others' swing in the 23 s after the end"
|
||||
f"{', and it is at most one slew step' if own else ''} "
|
||||
f"({now['swing']:.0f} W; 1 s registers "
|
||||
f"{fresh['swing']:.0f}; release/1.0 {old['swing']:.0f})",
|
||||
now["swing"] - fresh["swing"] <= SLEW
|
||||
and (not own or now["swing"] <= SLEW))
|
||||
_by = {(k, n, h): (a, b) for k, n, h, a, b, f in TABLE}
|
||||
_now, _old = _by[("Off", 2, 1200.0)]
|
||||
_b = [r["cmd"][1] for r in _now["rows"] if 0 <= r["t"] - _now["end"] <= 23]
|
||||
_bo = [r["cmd"][1] for r in _old["rows"] if 0 <= r["t"] - _old["end"] <= 23]
|
||||
print(f" rig check 5 (N=2, 1200 W, A Off): B {_b}\n"
|
||||
f" release/1.0: B {_bo}")
|
||||
check("⭐ the rig case (SAFETY-95 check 5): release/1.0 swings B by more "
|
||||
"than a slew step on A's old reading; now B holds within 50 W",
|
||||
_old["swing"] > SLEW and max(_b) - min(_b) <= 50.0)
|
||||
for n in (2, 3):
|
||||
check(f"non-vacuous (N={n}): on release/1.0 old readings add more than "
|
||||
f"a slew step in at least one of these cases",
|
||||
any(b["swing"] - f["swing"] > SLEW
|
||||
for k, nn, h, a, b, f in TABLE if nn == n))
|
||||
_s5 = []
|
||||
for lag in (5.0, 20.0):
|
||||
for reg in (5.0, 10.0):
|
||||
for n in (2, 3):
|
||||
_s5.append(measure(run("Off", n, 1200.0, lag=lag, reg_s=reg), n,
|
||||
1200.0)["swing"])
|
||||
check(f"no plant constant is assumed: battery lag 5 s and 20 s, registers "
|
||||
f"every 5 s and 10 s, N=2 and N=3: largest swing {max(_s5):.0f} W",
|
||||
max(_s5) <= SLEW)
|
||||
_ph = [measure(run("Off", 2, 1200.0, phase={"a": a, "b": b}), 2, 1200.0)
|
||||
["swing"] for a, b in ((0.0, 0.5), (0.9, 0.1), (0.3, 0.3))]
|
||||
check(f"other register phases: largest swing {max(_ph):.0f} W",
|
||||
max(_ph) <= SLEW)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. SAFETY-95/96's gains stay")
|
||||
for kind in ENDS:
|
||||
for n in (2, 3):
|
||||
now, old = _by[(kind, n, 1200.0)]
|
||||
check(f"{kind}, N={n}, 1200 W: no jump on the end cycle (others "
|
||||
f"<= the house, {now['first']:.0f} W) and the site moves at "
|
||||
f"most one slew step a cycle after ({now['step']:.0f} W)",
|
||||
now["first"] <= 1200.0 - AIM + 50.0 and now["step"] <= SLEW)
|
||||
now, old = _by[(kind, n, -1500.0)]
|
||||
check(f"{kind}, N={n}, solar surplus: no battery gives into it "
|
||||
f"(largest command after the end {now['give']:.0f} W"
|
||||
+ (f"; a lost link as release/1.0, {old['give']:.0f} W)"
|
||||
if kind == "stale link" else ")"),
|
||||
now["give"] <= (max(50.0, old["give"]) if kind == "stale link"
|
||||
else 50.0))
|
||||
_now, _old = _by[("Off", 2, 1200.0)]
|
||||
check(f"the grid in the window stays inside release/1.0's range "
|
||||
f"([{_now['gmin']:.0f}, {_now['gmax']:.0f}] W, release/1.0 "
|
||||
f"[{_old['gmin']:.0f}, {_old['gmax']:.0f}])",
|
||||
_now["gmin"] >= _old["gmin"] - 50.0 and _now["gmax"] <= _old["gmax"])
|
||||
|
||||
|
||||
_ref = {}
|
||||
for name, f in (("now", lambda: p.run(2, 1200.0, **ENDS["refusal"])),
|
||||
("old", lambda: without(lambda: p.run(
|
||||
2, 1200.0, **ENDS["refusal"])))):
|
||||
rows, c = f()
|
||||
_ref[name] = (c.inverters[0].refused, c.inverters[0].refused_at)
|
||||
check(f"a refusal is still learned, at the same instant as release/1.0 "
|
||||
f"({_ref['now']})", _ref["now"] == _ref["old"]
|
||||
and _ref["now"][0] == 1)
|
||||
_st = {}
|
||||
for name, f in (("now", lambda: p.run(2, 1200.0, **ENDS["stale link"])),
|
||||
("old", lambda: without(lambda: p.run(
|
||||
2, 1200.0, **ENDS["stale link"])))):
|
||||
rows, c = f()
|
||||
_st[name] = sorted(c.live_units())
|
||||
check(f"a stale link is still dropped, as in release/1.0 "
|
||||
f"(live: {_st['now']})", _st["now"] == _st["old"]
|
||||
and len(_st["now"]) == 1)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. a house step inside the window is still followed")
|
||||
|
||||
|
||||
def step(sign):
|
||||
return lambda t: 1200.0 + (sign * 2000.0 if t >= T + 5.0 else 0.0)
|
||||
|
||||
|
||||
for sign, what in ((1, "kettle +2000 W"), (-1, "solar -2000 W")):
|
||||
for n in (2, 3):
|
||||
rows = p.run(n, step(sign))[0]
|
||||
rows_o = without(lambda: p.run(n, step(sign)))[0]
|
||||
te = end_of(rows)
|
||||
|
||||
def wh(rs):
|
||||
return sum(abs(r["grid"] - AIM) for r in rs
|
||||
if te + 5.0 <= r["t"] <= te + 35.0) / 3600.0
|
||||
sw = measure(rows, n, 1200.0)["swing"]
|
||||
check(f"{what} at E+5 s, N={n}: the grid's distance from its aim "
|
||||
f"over the next 30 s is within 1.5 Wh of release/1.0's "
|
||||
f"({wh(rows):.1f} Wh, release/1.0 {wh(rows_o):.1f}); swing "
|
||||
f"{sw:.0f} W", wh(rows) <= wh(rows_o) + 1.5 and sw <= SLEW)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. fresh readings and N=1: release/1.0's rows")
|
||||
|
||||
|
||||
def digest(rows):
|
||||
return hashlib.sha256(repr(rows).encode()).hexdigest()
|
||||
|
||||
|
||||
_fresh = [(n, h) for n in (2, 3) for h in (1200.0, 4000.0, -1500.0)]
|
||||
check("⭐ registers as fresh as the meter (1 s), N=2 and N=3, three houses: "
|
||||
"every row byte-identical with release/1.0 (a reading that changed "
|
||||
"this cycle is taken as it is)",
|
||||
[digest(p.run(n, h, reg_s=1.0)[0]) for n, h in _fresh]
|
||||
== [digest(without(lambda: p.run(n, h, reg_s=1.0))[0])
|
||||
for n, h in _fresh])
|
||||
_rf = [(n, h) for n in (2, 3) for h in (1200.0, -1500.0)]
|
||||
check("⭐ a refusal end, 10 s registers, N=2 and N=3, 1200 W and a surplus: "
|
||||
"every row byte-identical with release/1.0 (A reads ~0 W and will not "
|
||||
"give, so its side is its reading)",
|
||||
[digest(run("refusal", n, h)) for n, h in _rf]
|
||||
== [digest(without(lambda: run("refusal", n, h))) for n, h in _rf])
|
||||
_n1 = [dict(), dict(before=None, soc=soc_done)]
|
||||
check("⭐ N=1, A switched Off and A's drain completing, 10 s registers: "
|
||||
"every row byte-identical with release/1.0",
|
||||
[digest(p.run(1, 1200.0, **k)[0]) for k in _n1]
|
||||
== [digest(without(lambda: p.run(1, 1200.0, **k))[0]) for k in _n1])
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. the rule's parts")
|
||||
_c = p.run(2, 1200.0, dur=4.0)[1]
|
||||
AB = {"inverter-a", "inverter-b"}
|
||||
ia, ib = _c.inverters
|
||||
|
||||
|
||||
def at(now, batts, cmds, changed):
|
||||
"""Set the clock, each battery's reading, its standing command and the
|
||||
instant its reading last changed; then track."""
|
||||
_c._now_mono = now
|
||||
for inv, b, cmd, ch in zip(_c.inverters, batts, cmds, changed):
|
||||
inv.batt, inv.last_write = b, cmd
|
||||
inv.batt_fresh.changed_mono = ch
|
||||
_c._track_reads()
|
||||
|
||||
|
||||
_c._read_span = {}
|
||||
at(10.0, (2000.0, -100.0), (2000.0, 0.0), (10.0, 10.0))
|
||||
check("a reading that changed is the start of its range: (R, R) joined "
|
||||
"with the command on its wire",
|
||||
_c._read_span[A] == (2000.0, 2000.0, 10.0)
|
||||
and _c._read_span["inverter-b"] == (-100.0, 0.0, 10.0))
|
||||
at(11.0, (2000.0, -100.0), (0.0, 1140.0), (10.0, 10.0))
|
||||
at(12.0, (2000.0, -100.0), (0.0, 800.0), (10.0, 10.0))
|
||||
check("every command since the reading widens the range (a lagging "
|
||||
"battery moves toward each in turn): A [0, 2000], B [-100, 1140]",
|
||||
_c._read_span[A][:2] == (0.0, 2000.0)
|
||||
and _c._read_span["inverter-b"][:2] == (-100.0, 1140.0))
|
||||
at(13.0, (1241.0, -100.0), (0.0, 800.0), (13.0, 10.0))
|
||||
check("a new reading starts the range again: A [0, 1241]",
|
||||
_c._read_span[A][:2] == (0.0, 1241.0))
|
||||
_c._read_span = {}
|
||||
_c.inverters = _c.inverters[:1]
|
||||
at(14.0, (2000.0,), (2000.0,), (14.0,))
|
||||
_c.inverters = [ia, ib]
|
||||
check("N=1: nothing is tracked", _c._read_span == {})
|
||||
|
||||
|
||||
def tail(now, grid, target, held=None, live=AB):
|
||||
"""`_drain_grid_w` with A settling: the error it hands the loop (grid
|
||||
minus aim) and the house point it holds."""
|
||||
_c._now_mono, _c.grid, _c.target = now, grid, target
|
||||
_c._drain_tail, _c._settle = {A}, {A: 0.0}
|
||||
_c._drain_house = held
|
||||
g = _c._drain_grid_w(live)
|
||||
return round(g - AIM, 6), _c._drain_house
|
||||
|
||||
|
||||
# A: reading 1241 W from 13 s, range [0, 1241]; B: reading -100 W from 10 s,
|
||||
# range [-100, 1140]. Grid -330 W: the house is in
|
||||
# [-330 + 10 + 0 - 100, -330 + 10 + 1241 + 1140] = [-420, 2061].
|
||||
_c._read_span = {A: (0.0, 1241.0, 13.0), "inverter-b": (-100.0, 1140.0, 10.0)}
|
||||
ia.batt, ib.batt = 1241.0, -100.0
|
||||
ia.batt_fresh.changed_mono, ib.batt_fresh.changed_mono = 13.0, 10.0
|
||||
check("⭐ held: the first window cycle holds the loop's start (1140 W) while "
|
||||
"the meter allows it (error 0; release/1.0's would be -330 + 1241 - "
|
||||
"100 + 10 - 1140 = -319 W)", tail(14.0, -330.0, 1140.0) == (0.0, 1140.0)
|
||||
and round(_release(_c, AB) - AIM) == -319)
|
||||
check("⭐ held, then kept: a later cycle keeps the held point, not the "
|
||||
"loop's target", tail(15.0, -330.0, 1000.0, held=1140.0)
|
||||
== (140.0, 1140.0))
|
||||
check("⭐ moved as far as the meter proves: a grid of +2000 W puts the "
|
||||
"house at least at 2000 + 10 + 0 - 100 = 1910 W",
|
||||
tail(15.0, 2000.0, 1140.0, held=1140.0) == (770.0, 1910.0))
|
||||
check("...and down: a grid of -2500 W puts it at most at -2500 + 10 + 1241 "
|
||||
"+ 1140 = -109 W", tail(15.0, -2500.0, 1140.0, held=1140.0)
|
||||
== (-1249.0, -109.0))
|
||||
ia.batt_fresh.changed_mono = 15.0
|
||||
check("⭐ a reading that changed this cycle is taken as it is: A at 1241 W, "
|
||||
"not [0, 1241], so a grid of +2000 W puts the house at least at "
|
||||
"2000 + 10 + 1241 - 100 = 3151 W", tail(15.0, 2000.0, 1140.0,
|
||||
held=1140.0) == (2011.0, 3151.0))
|
||||
ia.batt_fresh.changed_mono = 13.0
|
||||
# A refuses to give (SAFETY-73): it reads 8 W, though it was asked 2000 W
|
||||
# since that reading. Grid -1500 W: the house is at most -1500 + 10 + 8 +
|
||||
# 1140 = -342 W, not -1500 + 10 + 2000 + 1140 = 1650 W.
|
||||
_c._read_span[A] = (8.0, 2000.0, 13.0)
|
||||
ia.batt = 8.0
|
||||
ia.refused = 1
|
||||
_ref1 = tail(15.0, -1500.0, 1140.0, held=1140.0)
|
||||
ia.refused = -1
|
||||
_c._read_span[A] = (-2000.0, 8.0, 13.0)
|
||||
_ref2 = tail(15.0, 1000.0, -500.0, held=-500.0)
|
||||
ia.refused = 0
|
||||
check("⭐ a battery that refuses one way is taken at its reading on that "
|
||||
"side: will not give, asked 2000 W, reads 8 W -> the house at most "
|
||||
"-342 W; will not take, asked -2000 W -> at least 1000 + 10 + 8 - 100 "
|
||||
"= 918 W", _ref1 == (-1482.0, -342.0) and _ref2 == (1418.0, 918.0))
|
||||
_c._read_span[A], ia.batt = (0.0, 1241.0, 10.0), 1241.0
|
||||
check("a range from an older reading than the one in hand (a cycle the "
|
||||
"loop did not run) is not used: A at its reading, 1241 W",
|
||||
tail(15.0, 2000.0, 1140.0, held=1140.0) == (2011.0, 3151.0))
|
||||
_c._read_span[A] = (0.0, 1241.0, 13.0)
|
||||
check("⭐ a battery out of the split is not counted (the meter has its "
|
||||
"power): B dropped, a grid of +2000 W puts the house at least at "
|
||||
"2000 + 10 + 0 = 2010 W, not 1910 W",
|
||||
tail(15.0, 2000.0, 1140.0, held=1140.0, live={A}) == (870.0, 2010.0))
|
||||
_c._drain_tail, _c._settle, _c._drain_house = {A}, {}, 1140.0
|
||||
_c.grid = -330.0
|
||||
check("⭐ the tail's end lets the hold go: the meter again, and the next "
|
||||
"tail starts from the loop's start",
|
||||
_c._drain_grid_w(AB) == -330.0 and _c._drain_house is None)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n drain end | N | house W | swing W now/release/1 s registers | "
|
||||
"end-cycle others W now/release | site step W now/release | grid in "
|
||||
"window W now | release")
|
||||
for k, n, h, a, b, f in TABLE:
|
||||
print(f" {k} | {n} | {h:.0f} | {a['swing']:.0f}/{b['swing']:.0f}/"
|
||||
f"{f['swing']:.0f} | "
|
||||
f"{a['first']:.0f}/{b['first']:.0f} | {a['step']:.0f}/"
|
||||
f"{b['step']:.0f} | [{a['gmin']:.0f}, {a['gmax']:.0f}] | "
|
||||
f"[{b['gmin']:.0f}, {b['gmax']:.0f}]")
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all SAFETY-103 checks passed")
|
||||
@@ -1,307 +0,0 @@
|
||||
"""Runnable check for SAFETY-107: a refusal held while another writer has the
|
||||
wire is learned once. `python3 test_safety107.py`
|
||||
|
||||
A full pack under a maintenance charge in a cheap window took no charge. At
|
||||
one inverter SAFETY-52's "another writer" end then fired every cycle, and
|
||||
compute() learned the refusal again in the same cycle: "BATTERY REFUSES" and
|
||||
"BATTERY REFUSES ENDED: another writer" each second on the rig (MAINT-13 F5).
|
||||
Now another writer ends a refusal only if compute(), judging it afresh
|
||||
against that writer's ask, no longer finds it. DECISIONS/SAFETY-107.
|
||||
|
||||
Drives the REAL Controller (read_inputs + cycle) on opt11_identity's rig.
|
||||
1. The card's case: 15 min of a full pack under a maintenance charge, N=1
|
||||
and N=2, at a 1, 2 and 5 s meter: at most 2 refusal lines, held throughout.
|
||||
2. Other writers: a trade and the peak guard over a battery that gives
|
||||
nothing. One episode each, no flap.
|
||||
3. A refusal still ends: a different writer really takes over, the battery
|
||||
delivers again, a writer asks the other way, stop. The probe clock.
|
||||
|
||||
4. Not covered, pinned: a perfectly flat reading under a writer.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in DECISIONS/SAFETY-107.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
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 app.main as app_main # noqa: E402
|
||||
from app import control # noqa: E402
|
||||
from app.arbiter import Claim, P_ARBITRAGE # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from named_absence import nth, present # noqa: E402
|
||||
|
||||
fails = []
|
||||
QUARTER = 900.0
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def run(n=1, *, plant, house, dur=QUARTER, tick=5.0, maint=True,
|
||||
cheap=lambda c, t: True, trade=lambda t: None, p1=False, cap=2500,
|
||||
auto=lambda t: True):
|
||||
"""Rows (t, commands, site target, loop reason, refused, refused_at,
|
||||
unit refusals) and every action-log line as (t, line, hint).
|
||||
|
||||
`plant(u, t, cmd)` is battery u's reading. `cheap(c, t)` opens the cheap
|
||||
window. `trade(t)` is a SET claim at P_ARBITRAGE added to the claim list
|
||||
(a writer above the loop), or None."""
|
||||
log, rows, now = [], [], [0.0]
|
||||
real_power, real_tick = rig._Hass.power, rig.TICK
|
||||
real_log, real_resolve, real_c = (Controller.log_event, app_main.resolve,
|
||||
rig.Controller)
|
||||
|
||||
def power(self, u):
|
||||
return float(plant(u, self.t, self.cmd[u]))
|
||||
|
||||
def log_event(self, msg, hint=None):
|
||||
log.append((now[0], msg, hint))
|
||||
return real_log(self, msg, hint)
|
||||
|
||||
def resolve(claims, **kw):
|
||||
v = trade(now[0])
|
||||
if v is not None:
|
||||
claims.append(Claim.set("arbitrage", P_ARBITRAGE, v, "trade"))
|
||||
return real_resolve(claims, **kw)
|
||||
|
||||
def before(c, t):
|
||||
now[0] = t
|
||||
if not getattr(c.hass, "_s107", False):
|
||||
read = c.hass.number
|
||||
|
||||
async def number(e, invert=False):
|
||||
if e == "sensor.pnow":
|
||||
return 0.05 if cheap(c, c.hass.t) else 0.30
|
||||
if e == "sensor.pavg":
|
||||
return 0.20
|
||||
return await read(e, invert)
|
||||
c.hass.number, c.hass._s107 = number, True
|
||||
if p1:
|
||||
s79._feed(c, t)
|
||||
c.auto = auto(t)
|
||||
|
||||
def on_cycle(c, t):
|
||||
rows.append((t, tuple(c.hass.cmd[u] for u in "ab"[:n]), c.target,
|
||||
c.loop_reason, c.batt_refused, c.batt_refused_at,
|
||||
tuple(i.refused for i in c.inverters)))
|
||||
|
||||
extra = {"price_now_entity": "sensor.pnow",
|
||||
"price_avg_entity": "sensor.pavg"}
|
||||
if maint:
|
||||
extra.update({"maintenance_enabled": True,
|
||||
"maintenance_start_hour": 11,
|
||||
"maintenance_charge_w": 2000})
|
||||
if p1:
|
||||
extra.update(s79.P1, peak_cap_w=cap)
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="s107-"), "tariff.db")
|
||||
rig._Hass.power, rig.TICK = power, tick
|
||||
Controller.log_event, app_main.resolve = log_event, resolve
|
||||
rig.Controller = functools.partial(real_c, peak_db_path=db)
|
||||
try:
|
||||
_, c = rig.run(n, extra=extra, dur=dur, load=house,
|
||||
soc=lambda u, t: 50.0, cut=lambda u, t: False,
|
||||
before=before, on_cycle=on_cycle,
|
||||
store=s79.store_charging(n) if maint else rig._Store())
|
||||
finally:
|
||||
rig._Hass.power, rig.TICK = real_power, real_tick
|
||||
Controller.log_event, app_main.resolve = real_log, real_resolve
|
||||
rig.Controller = real_c
|
||||
return rows, log, c
|
||||
|
||||
|
||||
def full_a(u, t, cmd):
|
||||
"""A takes no charge (a full pack), its reading jittering by a few watts
|
||||
as a real one does; B follows."""
|
||||
return -1.0 - int(t) % 3 if u == "a" and cmd < 0 else round(cmd)
|
||||
|
||||
|
||||
def flat_a(u, t, cmd):
|
||||
"""`full_a` with a perfectly flat reading (the stale-reading gate trips)."""
|
||||
return -1.0 if u == "a" and cmd < 0 else round(cmd)
|
||||
|
||||
|
||||
def empty_a(u, t, cmd):
|
||||
"""A gives nothing (an empty pack), its reading jittering by a few watts
|
||||
(SAFETY-73's jitter shape, so the stale-reading gate stays out of it);
|
||||
B follows."""
|
||||
return -4.0 - int(t) % 3 if u == "a" and cmd >= -4.0 else round(cmd)
|
||||
|
||||
|
||||
def lines(log, word="REFUSES"):
|
||||
return [x for x in log if word in x[1]]
|
||||
|
||||
|
||||
def ends(log):
|
||||
return [x for x in log if "REFUSES ENDED" in x[1]]
|
||||
|
||||
|
||||
def refused(r):
|
||||
"""Row r holds a refusal (site at N=1, any unit at N>1)."""
|
||||
return bool(r[4]) or any(r[6])
|
||||
|
||||
|
||||
def held_from(rows):
|
||||
"""True when, once learned, the refusal is held on every later row."""
|
||||
first = next((k for k, r in enumerate(rows) if refused(r)), None)
|
||||
return first is not None and all(refused(r) for r in rows[first:])
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print("1. the card: a full pack under a maintenance charge, 15 min")
|
||||
# ==============================================================================
|
||||
for n, tick, house in ((n, k, h) for n in (1, 2) for k in (1.0, 2.0, 5.0)
|
||||
for h in (800.0, -1500.0)):
|
||||
rows, log, c = run(n, plant=full_a, house=lambda t: house, tick=tick)
|
||||
ref = lines(log)
|
||||
tag = f"N={n}, {tick:.0f} s meter, house {house:+.0f} W"
|
||||
print(f" {tag}: {len(ref)} refusal line(s): "
|
||||
+ " / ".join(x[1][:60] for x in ref))
|
||||
check(f"{tag}: at most 2 refusal lines in 15 min (rig: 548 + 548)",
|
||||
1 <= len(ref) <= 2)
|
||||
check(f"{tag}: no end line", not ends(log))
|
||||
check(f"{tag}: learned once and held to the end", held_from(rows))
|
||||
# N=1: the charge's ceiling stays on the wire. N=2: SAFETY-73 closes A
|
||||
# in the refused direction, as before.
|
||||
check(f"{tag}: still in its charge, the wire as before",
|
||||
c.maints[0].phase == "charge"
|
||||
and all(r[1][0] == (-2000.0 if n == 1 else 0.0)
|
||||
for r in rows[-10:]))
|
||||
# The hint under the buttons is set at most once for the refusal.
|
||||
check(f"{tag}: the refusal hint is set once",
|
||||
sum(1 for x in ref if x[2] is not None) == 1)
|
||||
|
||||
# The probe clock: while the charge holds the wire no probe is tried (the
|
||||
# loop's ask is not on the wire), so the action log stays quiet.
|
||||
rows, log, _ = run(1, plant=full_a, house=lambda t: 800.0, dur=3600.0)
|
||||
check("N=1, an hour: still one line, no probe tried under the charge",
|
||||
len(lines(log)) == 1
|
||||
and not any(r[3] == control.PROBING for r in rows))
|
||||
|
||||
# ==============================================================================
|
||||
print("\n2. other writers: a trade and the peak guard, no flap")
|
||||
# ==============================================================================
|
||||
# A trade (a SET above the loop) asks a full pack to charge for 15 min.
|
||||
rows, log, _ = run(1, plant=full_a, house=lambda t: 800.0, maint=False,
|
||||
trade=lambda t: -1500.0)
|
||||
print(f" trade into a full pack: {len(lines(log))} line(s)")
|
||||
check("a trade into a full pack: learned once, held, no end",
|
||||
len(lines(log)) == 1 and not ends(log) and held_from(rows))
|
||||
# The peak guard asks an empty pack to give while the house is over the limit.
|
||||
rows, log, c = run(1, plant=empty_a, house=lambda t: 4500.0, maint=False,
|
||||
p1=True)
|
||||
print(f" peak guard on an empty pack: {len(lines(log))} line(s), "
|
||||
f"guard floor {c.peak_guard.floor_w if c.peak_guard else None}")
|
||||
check("the peak guard on an empty pack: the guard holds the wire",
|
||||
c.peak_guard is not None and c.peak_guard.engaged)
|
||||
check("the peak guard on an empty pack: at most 2 lines, held to the end",
|
||||
1 <= len(lines(log)) <= 2 and held_from(rows))
|
||||
|
||||
# ==============================================================================
|
||||
print("\n3. a refusal still ends")
|
||||
# ==============================================================================
|
||||
# a. Learned by the loop on an empty pack; at 300 s a trade really takes the
|
||||
# wire and asks it to give 2000 W. The refusal was learned against the
|
||||
# loop's ask: it ends, then is learned against the trade, once.
|
||||
rows, log, _ = run(1, plant=empty_a, house=lambda t: 2000.0, maint=False,
|
||||
trade=lambda t: 2000.0 if 300.0 <= t < 900.0 else None,
|
||||
dur=1200.0)
|
||||
e = ends(log)
|
||||
print(" " + " / ".join(f"{x[0]:.0f} s {x[1][:45]}" for x in lines(log)))
|
||||
check("a different writer takes over: the refusal ends, 'another writer'",
|
||||
len(e) == 1 and "another writer" in e[0][1] and 300.0 < e[0][0] < 320.0)
|
||||
check("...and is learned again against the trade, once, and held",
|
||||
len(lines(log)) == 3 and held_from([r for r in rows if r[0] > 400.0]))
|
||||
check("...and the loop keeps it when the trade leaves (no line)",
|
||||
all(x[0] < 900.0 for x in lines(log)) and refused(nth(rows, -1)))
|
||||
# The writer asks the other way, which the empty pack takes.
|
||||
rows, log, _ = run(1, plant=empty_a, house=lambda t: 2000.0, maint=False,
|
||||
trade=lambda t: -1500.0 if t >= 300.0 else None)
|
||||
e = ends(log)
|
||||
check("a writer that asks the other way ends it ('another writer')",
|
||||
len(e) == 1 and "another writer" in e[0][1] and present(nth(rows, -1))
|
||||
and not refused(nth(rows, -1)))
|
||||
# b. The full pack starts taking charge again under the same writer.
|
||||
rows, log, _ = run(1, plant=lambda u, t, cmd: (
|
||||
round(cmd) if t >= 400.0 else full_a(u, t, cmd)), house=lambda t: 800.0)
|
||||
e = ends(log)
|
||||
print(" " + " / ".join(f"{x[0]:.0f} s {x[1][:45]}" for x in lines(log)))
|
||||
check("the battery takes charge again: the refusal ends once, on its reading",
|
||||
len(e) == 1 and "the battery gives power" in e[0][1]
|
||||
and 400.0 <= e[0][0] < 430.0 and present(nth(rows, -1))
|
||||
and not refused(nth(rows, -1)))
|
||||
# c. Stop and start still end it.
|
||||
rows, log, _ = run(1, plant=full_a, house=lambda t: 800.0,
|
||||
auto=lambda t: not 500.0 <= t < 520.0)
|
||||
e = ends(log)
|
||||
check("stopping control still ends it", any("control stopped" in x[1]
|
||||
for x in e))
|
||||
# d. The probe clock. Learned under the charge; the cheap window closes when
|
||||
# the refusal is 910 s old, and the loop holds the wire (a surplus, so it
|
||||
# wants to charge). The first probe is a full PROBE_EVERY_S after the
|
||||
# window closed, as before: never at once.
|
||||
close = [None]
|
||||
|
||||
|
||||
def window(c, t):
|
||||
if close[0] is None and c.batt_refused:
|
||||
close[0] = t + 910.0
|
||||
return close[0] is None or t < close[0]
|
||||
|
||||
|
||||
rows, log, _ = run(1, plant=full_a, house=lambda t: -1500.0, cheap=window,
|
||||
dur=3000.0)
|
||||
probes = [r[0] for r in rows if r[3] == control.PROBING]
|
||||
print(" " + " / ".join(f"{x[0]:.0f} s {x[1][:45]}" for x in lines(log)))
|
||||
print(f" window closed at {close[0]} s, first probe at "
|
||||
f"{probes[0] if probes else None} s")
|
||||
check("the refusal carries over to the loop with no line",
|
||||
len(lines(log)) == 1 and held_from(rows))
|
||||
check("the first probe after the writer left comes PROBE_EVERY_S later",
|
||||
close[0] is not None and bool(probes)
|
||||
and probes[0] >= close[0] + control.PROBE_EVERY_S - 10.0)
|
||||
# e. A writer turns round on a dead pack with no gap. The evidence restarts
|
||||
# on the new sign (SAFETY-94), so the old refusal ends at once and the
|
||||
# other way is learned once, ~12 cycles later.
|
||||
rows, log, _ = run(1, plant=lambda u, t, cmd: (
|
||||
-2.0 - int(t) % 3 if u == "a" else round(cmd)), house=lambda t: 2000.0,
|
||||
maint=False, trade=lambda t: 2000.0 if t < 300.0 else -2000.0)
|
||||
e = ends(log)
|
||||
print(" " + " / ".join(f"{x[0]:.0f} s {x[1][:45]}" for x in lines(log)))
|
||||
check("a writer turns round on a dead pack: one end, then the other way",
|
||||
len(lines(log)) == 3 and len(e) == 1 and 300.0 < e[0][0] < 400.0
|
||||
and present(nth(rows, -1)) and nth(rows, -1)[4] == -1)
|
||||
|
||||
# ==============================================================================
|
||||
print("\n4. not covered: a perfectly flat reading under a writer")
|
||||
# ==============================================================================
|
||||
# The stale-reading gate trips on a flat reading while the command moves,
|
||||
# stops the site and seeds the refusal (SAFETY-52 decision 3). After the stop
|
||||
# the writer's ask comes back fresh, compute() has no saturation history
|
||||
# against it yet, so the refusal ends at the first cycle and the next trip
|
||||
# seeds it again: one pair per gate cycle, as on release/1.0 (the commands
|
||||
# are the same; DECISIONS/SAFETY-107 "Not covered"). Pinned so a change shows.
|
||||
rows, log, _ = run(1, plant=flat_a, house=lambda t: -1500.0)
|
||||
trips = len(lines(log, "STALE inverter"))
|
||||
print(f" flat reading, house -1500 W: {trips} gate trips, "
|
||||
f"{len(lines(log))} refusal lines in 15 min")
|
||||
check("flat reading: no per-cycle flap, at most one pair per gate trip",
|
||||
trips >= 1 and len(lines(log)) <= 2 * trips)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all SAFETY-107 checks passed")
|
||||
@@ -1,440 +0,0 @@
|
||||
"""SAFETY-112: waiting for Home Assistant to finish starting. `python3 -B test_safety112.py`
|
||||
|
||||
The Supervisor answers 502 to every add-on read, write and websocket until
|
||||
Home Assistant is RUNNING (DECISIONS/SAFETY-112-waiting-for-home-assistant.md),
|
||||
so the add-on cannot control then. This file grades what it says meanwhile:
|
||||
|
||||
1. The log: one WARNING when Home Assistant stops answering, one INFO line
|
||||
every 5 minutes while it stays down, one INFO line with the wait when it
|
||||
answers again. Only the API channel counts as "waiting": a 502 or a
|
||||
refused connection turns it on, the next good read off, a timeout never.
|
||||
2. The status page, N=1 and N=2: the waiting row is first and red, and is the
|
||||
banner. Home says "Waiting for Home Assistant" in English and Dutch, not
|
||||
"Needs you now", unless a safety stop outranks it. The page script has it.
|
||||
3. Control is unchanged: the same readings give the same writes with the
|
||||
waiting flag on or off, with 502 readings and with live ones.
|
||||
|
||||
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app import hass as _hass
|
||||
from app import messages
|
||||
from app.main import Controller
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
class _Rec(logging.Handler):
|
||||
def __init__(self):
|
||||
super().__init__(logging.DEBUG)
|
||||
self.records = []
|
||||
|
||||
def emit(self, record):
|
||||
self.records.append(record)
|
||||
|
||||
|
||||
def captured(fn):
|
||||
rec, lg = _Rec(), logging.getLogger("goodwe.hass")
|
||||
old = lg.level
|
||||
lg.addHandler(rec)
|
||||
lg.setLevel(logging.DEBUG)
|
||||
try:
|
||||
fn()
|
||||
finally:
|
||||
lg.removeHandler(rec)
|
||||
lg.setLevel(old)
|
||||
return [(r.levelno, r.getMessage()) for r in rec.records
|
||||
if r.levelno >= logging.INFO]
|
||||
|
||||
|
||||
class Clock:
|
||||
"""`hass.time` stand-in: only `monotonic` is read by Outage."""
|
||||
|
||||
def __init__(self):
|
||||
self.t = 1000.0
|
||||
|
||||
def monotonic(self):
|
||||
return self.t
|
||||
|
||||
|
||||
def with_clock(fn):
|
||||
clock, real = Clock(), _hass.time
|
||||
_hass.time = SimpleNamespace(monotonic=clock.monotonic)
|
||||
try:
|
||||
return fn(clock)
|
||||
finally:
|
||||
_hass.time = real
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. the log while Home Assistant does not answer")
|
||||
W, I = logging.WARNING, logging.INFO
|
||||
|
||||
|
||||
def outage(clock):
|
||||
o = _hass.Outage()
|
||||
o.down("api", "error 502", "read sensor.x: 502")
|
||||
for t in (1, 60, 299):
|
||||
clock.t = 1000 + t
|
||||
o.down("api", "error 502", "read sensor.x: 502")
|
||||
clock.t = 1300
|
||||
o.down("api", "error 502", "") # 5 minutes: the first reminder
|
||||
clock.t = 1301
|
||||
o.down("websocket", "the meter connection closed", "")
|
||||
clock.t = 1599
|
||||
o.down("api", "error 502", "") # 299 s after the reminder: none
|
||||
clock.t = 1600
|
||||
o.down("api", "error 502", "") # 10 minutes: the second
|
||||
clock.t = 1640
|
||||
o.up("api")
|
||||
o.up("websocket") # 640 s in all
|
||||
clock.t = 2000
|
||||
o.down("api", "no connection", "") # a new outage starts afresh
|
||||
clock.t = 2030
|
||||
o.up("api")
|
||||
|
||||
|
||||
lines = with_clock(lambda c: captured(lambda: outage(c)))
|
||||
check(f"one WARNING per outage, in plain words ({lines})",
|
||||
[m for lv, m in lines if lv == W] == [
|
||||
"Home Assistant is not answering (error 502), perhaps it is still "
|
||||
"starting. Control waits and resumes by itself.",
|
||||
"Home Assistant is not answering (no connection), perhaps it is "
|
||||
"still starting. Control waits and resumes by itself."])
|
||||
check("one 'still waiting' line per 5 minutes down, with the time so far",
|
||||
[m for lv, m in lines if lv == I and m.startswith("Still")] == [
|
||||
"Still waiting for Home Assistant to finish starting, 5 minutes "
|
||||
"so far.",
|
||||
"Still waiting for Home Assistant to finish starting, 10 minutes "
|
||||
"so far."])
|
||||
check("one INFO line when the last channel is back, with the wait, and a new "
|
||||
"outage counts from its own start",
|
||||
[m for lv, m in lines if lv == I and m.startswith("Home")] == [
|
||||
"Home Assistant answers again after 11 minutes.",
|
||||
"Home Assistant answers again after 30 seconds."])
|
||||
|
||||
|
||||
def channels(_clock):
|
||||
o = _hass.Outage()
|
||||
seen = [o.waiting()]
|
||||
o.down("websocket", "the meter connection closed", "")
|
||||
seen.append(o.waiting())
|
||||
o.down("api", "error 502", "")
|
||||
seen.append(o.waiting())
|
||||
o.up("websocket")
|
||||
seen.append(o.waiting())
|
||||
o.up("api")
|
||||
seen.append(o.waiting())
|
||||
return seen
|
||||
|
||||
|
||||
check("waiting() follows the API channel only, not the meter websocket",
|
||||
with_clock(channels) == [False, False, True, True, False])
|
||||
|
||||
|
||||
# The flag as `HomeAssistant.state()` sets it: a 502 or a refused connection
|
||||
# turns it on, the next good read turns it off, and a timeout (a slow Home
|
||||
# Assistant, not a starting one) leaves it alone.
|
||||
class _Answer:
|
||||
def __init__(self, what):
|
||||
self.what = what
|
||||
|
||||
async def __aenter__(self):
|
||||
if isinstance(self.what, BaseException):
|
||||
raise self.what
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_a):
|
||||
return False
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
return self.what
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
async def json(self):
|
||||
return {"state": "1"}
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self, answers):
|
||||
self.answers = list(answers)
|
||||
|
||||
def get(self, *_a, **_k):
|
||||
return _Answer(self.answers.pop(0))
|
||||
|
||||
|
||||
def reads(answers):
|
||||
async def go():
|
||||
real, _hass.OUTAGE = _hass.OUTAGE, _hass.Outage()
|
||||
lg = logging.getLogger("goodwe.hass")
|
||||
lg.disabled = True
|
||||
try:
|
||||
h = _hass.HomeAssistant(_Session(answers), token="t")
|
||||
seen = []
|
||||
for _ in answers:
|
||||
await h.state("sensor.x")
|
||||
seen.append(_hass.OUTAGE.waiting())
|
||||
return seen
|
||||
finally:
|
||||
_hass.OUTAGE = real
|
||||
lg.disabled = False
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
check("a 502 read turns waiting on and the next good read turns it off",
|
||||
reads([502, 200, 503, 404]) == [True, False, True, False])
|
||||
check("a refused connection is waiting too",
|
||||
reads([_hass.aiohttp.ClientConnectionError(), 200]) == [True, False])
|
||||
check("a read timeout is not waiting",
|
||||
reads([TimeoutError(), TimeoutError()]) == [False, False])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("2. the status page, N=1 and N=2")
|
||||
|
||||
|
||||
class _Hass:
|
||||
session = None
|
||||
token = ""
|
||||
|
||||
def __init__(self):
|
||||
self.writes = []
|
||||
|
||||
async def limits(self, _e):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def number(self, _e, invert=False):
|
||||
return None # what a 502 read gives
|
||||
|
||||
async def state(self, _e):
|
||||
return None
|
||||
|
||||
async def set_number(self, e, v):
|
||||
self.writes.append((e, float(v)))
|
||||
return False # what a 502 write gives
|
||||
|
||||
async def call(self, *_a):
|
||||
return False
|
||||
|
||||
async def states(self):
|
||||
return None
|
||||
|
||||
|
||||
class _Store:
|
||||
def __init__(self):
|
||||
self.data = {}
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get(self, k, d=None):
|
||||
return self.data.get(k, d)
|
||||
|
||||
def set_time(self, k, v):
|
||||
self.data[k] = v.isoformat()
|
||||
|
||||
def get_time(self, k):
|
||||
return None
|
||||
|
||||
|
||||
class _Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, *_a, **_k):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
ONE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": 5000, "shadow_mode": "off",
|
||||
"maintenance_enabled": False, "stale_input_s": 15}
|
||||
TWO = dict(ONE, setpoint_entity="", soc_entity="", batt_entity="", inverters=[
|
||||
{"name": "inverter-a", "setpoint_entity": "number.a", "soc_entity":
|
||||
"sensor.soc_a", "batt_entity": "sensor.batt_a", "max_w": 2500,
|
||||
"bms_soc_floor": 10},
|
||||
{"name": "inverter-b", "setpoint_entity": "number.b", "soc_entity":
|
||||
"sensor.soc_b", "batt_entity": "sensor.batt_b", "max_w": 2500,
|
||||
"bms_soc_floor": 10}])
|
||||
|
||||
|
||||
def make(opts, hass=None):
|
||||
c = Controller(dict(opts), hass or _Hass(), _Store(), _Pub(),
|
||||
stop=asyncio.Event())
|
||||
c.auto = True
|
||||
for inv in c.inverters:
|
||||
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
||||
return c
|
||||
|
||||
|
||||
def waiting_on():
|
||||
_hass.OUTAGE.down_on = {"api"}
|
||||
_hass.OUTAGE.since = _hass.OUTAGE._told = _hass.time.monotonic()
|
||||
|
||||
|
||||
def waiting_off():
|
||||
_hass.OUTAGE.down_on = set()
|
||||
|
||||
|
||||
BANNER = "Not ready — Waiting for Home Assistant to finish starting"
|
||||
for label, opts in (("N=1", ONE), ("N=2", TWO)):
|
||||
c = make(opts)
|
||||
waiting_off()
|
||||
plain = c.checks()
|
||||
pay_off = c.status_payload()
|
||||
waiting_on()
|
||||
rows = c.checks()
|
||||
pay = c.status_payload()
|
||||
pay_nl = c.status_payload(lang="nl")
|
||||
waiting_off()
|
||||
check(f"{label}: no waiting row while Home Assistant answers",
|
||||
not [r for r in plain if r.get("id") == "home_assistant"]
|
||||
and pay_off["state"]["kind"] != "waiting")
|
||||
check(f"{label}: while it does not, the waiting row is first and red",
|
||||
rows[0] == {"ok": False, "warn": False, "id": "home_assistant",
|
||||
"banner": messages.banner_ha_waiting(),
|
||||
"text": messages.row_ha_waiting()}
|
||||
and rows[1:] == plain)
|
||||
check(f"{label}: ...and it is the banner ({pay['banner']!r})",
|
||||
pay["banner"] == BANNER and pay["level"] == "bad")
|
||||
check(f"{label}: Home says waiting, not 'Needs you now' "
|
||||
f"({pay['state']['pill']!r}, {pay['state']['sub']!r})",
|
||||
pay["state"]["kind"] == "waiting"
|
||||
and pay["state"]["pill"] == "Waiting for Home Assistant"
|
||||
and pay["state"]["sub"] == "Control resumes by itself when Home "
|
||||
"Assistant answers.")
|
||||
check(f"{label}: ...and in Dutch ({pay_nl['state']['pill']!r})",
|
||||
pay_nl["state"]["pill"] == "Wacht op Home Assistant"
|
||||
and pay_nl["state"]["sub"] == "De regeling gaat vanzelf verder "
|
||||
"zodra Home Assistant antwoordt.")
|
||||
check(f"{label}: Needs attention lists it first, with its own title",
|
||||
pay["attention"][0]["id"] == "home_assistant"
|
||||
and pay["attention"][0]["title"] == "Home Assistant is not answering"
|
||||
and pay_nl["attention"][0]["title"] == "Home Assistant antwoordt "
|
||||
"niet")
|
||||
|
||||
# A safety stop outranks the wait: it still needs a person.
|
||||
c = make(TWO)
|
||||
c.inverters[1].sign_wd.tripped = True
|
||||
waiting_on()
|
||||
pay = c.status_payload()
|
||||
waiting_off()
|
||||
check(f"a safety stop keeps its banner and 'Needs you now' over the wait "
|
||||
f"({pay['banner']!r}, {pay['state']['kind']!r})",
|
||||
pay["banner"] == messages.banner_direction(True)
|
||||
and pay["state"]["kind"] == "alarm")
|
||||
# A stalled control loop does not resume by itself, so it stays the headline.
|
||||
c = make(ONE)
|
||||
c.loop_pass_mono = 0.0
|
||||
waiting_on()
|
||||
rows, pay = c.checks(), c.status_payload()
|
||||
waiting_off()
|
||||
check(f"a stalled control loop stays first and keeps 'Needs you now' "
|
||||
f"({pay['banner']!r})",
|
||||
[r["id"] for r in rows[:2]] == ["controller", "home_assistant"]
|
||||
and pay["state"]["kind"] == "alarm")
|
||||
c = make(dict(ONE, shadow_mode="on"))
|
||||
waiting_on()
|
||||
pay = c.status_payload()
|
||||
waiting_off()
|
||||
check("shadow mode keeps its own state over the wait",
|
||||
pay["state"]["kind"] == "shadow")
|
||||
|
||||
|
||||
# The page script, read as text (the image has no JS engine): an amber dot,
|
||||
# and the batteries tab carries the banner like the other held states.
|
||||
JS = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "app",
|
||||
"static", "app.js"), encoding="utf-8").read()
|
||||
check("the page script gives 'waiting' an amber dot",
|
||||
re.search(r'alarm: " bad", waiting: " warn" \}\[st\.kind\]', JS)
|
||||
is not None)
|
||||
check("...and shows it as the batteries-tab banner",
|
||||
'["shadow", "stopped", "alarm", "waiting"].includes(S.state.kind)'
|
||||
in JS)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("3. control is unchanged by the waiting flag")
|
||||
|
||||
|
||||
def run(opts, flag):
|
||||
async def go():
|
||||
h = _Hass()
|
||||
c = make(opts, h)
|
||||
(waiting_on if flag else waiting_off)()
|
||||
targets = []
|
||||
for _ in range(4):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
await c.send_heartbeats(asyncio.get_running_loop().time(), 10.0)
|
||||
targets.append(c.target)
|
||||
waiting_off()
|
||||
return targets, h.writes
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
for label, opts in (("N=1", ONE), ("N=2", TWO)):
|
||||
off, on = run(opts, False), run(opts, True)
|
||||
check(f"{label}: same targets and writes with the flag on or off "
|
||||
f"({on[0]}, {len(on[1])} writes)", off == on)
|
||||
check(f"{label}: ...and every write while Home Assistant does not answer "
|
||||
f"asks 0 W", on[1] and all(v == 0.0 for _e, v in on[1]))
|
||||
|
||||
|
||||
# With 502 readings every write is 0 W whatever the flag says, so the check
|
||||
# above cannot see a flag that forces 0 W. Live readings can: the flag alone
|
||||
# must not change a non-zero write.
|
||||
class _LiveHass(_Hass):
|
||||
async def number(self, e, invert=False):
|
||||
v = {"sensor.grid": 900.0}.get(e, 50.0 if "soc" in e else 0.0)
|
||||
return -v if invert else v
|
||||
|
||||
async def set_number(self, e, v):
|
||||
self.writes.append((e, float(v)))
|
||||
return True
|
||||
|
||||
|
||||
def run_live(opts, flag):
|
||||
async def go():
|
||||
h = _LiveHass()
|
||||
c = make(opts, h)
|
||||
(waiting_on if flag else waiting_off)()
|
||||
for _ in range(4):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
await c.send_heartbeats(asyncio.get_running_loop().time(), 10.0)
|
||||
waiting_off()
|
||||
return h.writes
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
for label, opts in (("N=1", ONE), ("N=2", TWO)):
|
||||
off, on = run_live(opts, False), run_live(opts, True)
|
||||
check(f"{label}: with live readings the flag changes no write "
|
||||
f"({on[-2:]})", off == on and any(v != 0.0 for _e, v in on))
|
||||
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
raise SystemExit(1)
|
||||
print("all passed")
|
||||
@@ -1,375 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,737 +0,0 @@
|
||||
"""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")
|
||||
@@ -187,7 +187,7 @@ check("⭐ a draining battery that is dropped (stale link) adds no floor: B "
|
||||
"covers the 1500 W house, not the 2000 W drain rate, and the wait is "
|
||||
"logged once",
|
||||
"dropped inverter-a" in _l_rows[-1][7]
|
||||
and cmd(_l_rows[-1])["b"] == 1510.0
|
||||
and cmd(_l_rows[-1])["b"] == 1500.0
|
||||
and sum("cannot give power now" in e for e in _l.events) == 1)
|
||||
|
||||
# ============================================================================ #
|
||||
|
||||
@@ -57,28 +57,6 @@ BASE = {
|
||||
"twice7000": ([1001.0, 1502.0, 2699.0], "1cddda8cf35859efb2ad503f1683f744cb812a6325a390c4fa3df19d717af043"),
|
||||
"step6500_gap": ([1201.0, 1801.0], "95e5c90e4a6814526cb4f3bea76286063b449d86a420f888c014531fb6e913a3"),
|
||||
}
|
||||
# OPT-15: with battery modes ON the guard no longer reads "no seconds left" on
|
||||
# a quarter's edge tick (it projects the quarter it is in), so the 1 s 0 W
|
||||
# command at each edge is gone and every run differs from 5a4cdc2's. Printed by
|
||||
# `python3 tools/safety81_sim.py` on the OPT-15 head (BASE above stays as the
|
||||
# reference). Modes OFF stays byte-identical (section 1b).
|
||||
OPT15 = {
|
||||
"const4500": "ed2f4ef9a74172ada2f00707a09ad839743449642f61dc6583ace44bac33caf4",
|
||||
"const4500_m0": "63d4d62643b9d39697b16078603576d180d90090117a497122e77c33f3db2227",
|
||||
"sine4500": "a034af0a896d55552915f1e4816055e26c006d4df0a86d1c9d9c3a625faf747f",
|
||||
"edge4600": "74e03c7182469715c1517765c2f53f1d68d7342aed4a8ad388878eac24964020",
|
||||
"edge4600_m0": "93e4402b6b879a44da5ecc28fb3b51b16c0b0f701396e00a9703803273dbd18e",
|
||||
"burst_end": "8dc4001e06ad8fa562caea02d755227cdfa16396708fa986aef7a5704e22a691",
|
||||
"noise5000_m50": "71e29a970837211835e56636280c29f70d7c2d3f3062cfac2b940ea30fa66424",
|
||||
"noise5000_m0": "a47988b7bbee629a6f8722588f325a4c87d7dcb143bba549b6df452664b30251",
|
||||
"step6500": "a7356fe0f8c00ac4d16dd4f60b054af7336ceb2eedcd76911d30138df4b457b8",
|
||||
"miss5560": "2b0afb600fe1cfd0bfe4b28fbfeeff73fc7e875e3d5f377ae7c2a489e20abf53",
|
||||
"ramp6500": "ae3838aa8f2af9458f73b540eeb76cb5fa25ace0f1ea06df2b4f6d9201907522",
|
||||
"step7500_reserve": "21eb9c07a4582f47f347084d1c75a28a5d767bc068bc3f74f32433d3239e9ccb",
|
||||
"sine4500_fast": "47ed9d02a4f7afbc50905f44519b14364f262ba793aae80f3cd077eb6f600b8b",
|
||||
"twice7000": "b89396022f589bbb90bc592ece121cf2a0bf3a4f04316da36891ac1a2b6ae18e",
|
||||
"step6500_gap": "a7356fe0f8c00ac4d16dd4f60b054af7336ceb2eedcd76911d30138df4b457b8",
|
||||
}
|
||||
OUT = {k: sim.run(**v) for k, v in sim.SCENARIOS.items()}
|
||||
RUN = {k: v[:2] for k, v in OUT.items()}
|
||||
ERR = {k: v[4] for k, v in OUT.items()}
|
||||
@@ -87,23 +65,7 @@ ERR = {k: v[4] for k, v in OUT.items()}
|
||||
print("1. every command byte-identical to release/1.0 5a4cdc2")
|
||||
check("the same scenarios as 5a4cdc2", set(RUN) == set(BASE))
|
||||
for k, (alarms, digest) in RUN.items():
|
||||
check(f"{k}: digest {digest[:12]} (OPT-15 pin; modes off in 1b)",
|
||||
digest == OPT15[k])
|
||||
|
||||
# ============================================================================ #
|
||||
print("1b. battery modes off: every command byte-identical to release/1.0 e3e4ba6")
|
||||
# Printed on e3e4ba6 (this sim copied in, `modes=False`), in the CI image.
|
||||
OFF = {
|
||||
"const4500": "719d8bfa450710d9c0c22282d17ea4ab9945b1cb53ce344084e5ff906ca027b3",
|
||||
"edge4600": "4f8f943f5a4e9b218c2e8bc6a95d20934ec19f1bebfbdc5748ca52e753e5afa0",
|
||||
"step6500": "719d8bfa450710d9c0c22282d17ea4ab9945b1cb53ce344084e5ff906ca027b3",
|
||||
"burst_end": "4ca4ba895afe7dacfe3952743e13d21607e690dfec39029fd5c5745fe03af5d7",
|
||||
"twice7000": "719d8bfa450710d9c0c22282d17ea4ab9945b1cb53ce344084e5ff906ca027b3",
|
||||
"sine4500_fast": "3e41ad417880a4ebb63ae3d6826f413fb779c18dfb677207775b1c980133423f",
|
||||
}
|
||||
for k, want in OFF.items():
|
||||
digest = sim.run(modes=False, **sim.SCENARIOS[k])[1]
|
||||
check(f"{k} (modes off): digest {digest[:12]}", digest == want)
|
||||
check(f"{k}: digest {digest[:12]}", digest == BASE[k][1])
|
||||
|
||||
# ============================================================================ #
|
||||
print("2. no false alarm (5a4cdc2's are listed)")
|
||||
@@ -114,17 +76,16 @@ for k in ("edge4600", "edge4600_m0", "const4500", "const4500_m0", "sine4500",
|
||||
# ============================================================================ #
|
||||
print("3. real overloads still alarm, ALARM_HOLD_S after 5a4cdc2")
|
||||
# ALARM_HOLD_S (10 s) plus up to 2 s of late meter samples; at 1800 s the
|
||||
# quarter-edge tick restarts the hold (OPT-15: with modes on, by not grading
|
||||
# a projection that rolled into the new quarter before its first sample).
|
||||
for k, want in (("step6500", [1211.0, 1811.0]),
|
||||
("miss5560", [1213.0, 1811.0])):
|
||||
# quarter-edge tick restarts the hold.
|
||||
for k, want in (("step6500", [1211.0, 1813.0]),
|
||||
("miss5560", [1213.0, 1813.0])):
|
||||
print(f" {k}: load steps up at 1200 s; alarms at {RUN[k][0]}, "
|
||||
f"5a4cdc2 {BASE[k][0]}")
|
||||
check(f"{k}: after the hold, and again only at the next quarter",
|
||||
RUN[k][0] == want)
|
||||
check("step6500_gap: cycles stopped by a lost battery reading do not "
|
||||
"restart the hold",
|
||||
RUN["step6500_gap"][0] == [1215.0, 1811.0])
|
||||
RUN["step6500_gap"][0] == [1215.0, 1813.0])
|
||||
check("twice7000: notified once in the quarter (5a4cdc2: "
|
||||
f"{BASE['twice7000'][0]})", RUN["twice7000"][0] == [1011.0])
|
||||
check("twice7000: the ERROR line for both overloads",
|
||||
@@ -135,7 +96,7 @@ print(f" ramp6500: alarms at {RUN['ramp6500'][0]}, "
|
||||
f"5a4cdc2 {BASE['ramp6500'][0]}")
|
||||
check("ramp6500: alarms once the quarter will end over the real cap by "
|
||||
"more than ALARM_MISS_W",
|
||||
RUN["ramp6500"][0] == [1317.0, 1811.0])
|
||||
RUN["ramp6500"][0] == [1316.0, 1813.0])
|
||||
check("step7500 under the peak reserve: not this alarm (the guard is not "
|
||||
"allowed to discharge)", RUN["step7500_reserve"][0] == [])
|
||||
|
||||
@@ -189,7 +150,6 @@ stub = SimpleNamespace(
|
||||
reason="PEAK_GUARD: fixture"),
|
||||
site_rating_w=2000.0, shadow=False, events=[],
|
||||
_peak_batt_prev=None, _peak_alarm_block=None, _peak_high_since=None,
|
||||
_peak_rolled=False,
|
||||
_peak_saturated=False, hass=SimpleNamespace(call=_call),
|
||||
log_event=lambda msg, hint=None: None)
|
||||
first = None
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user