Compare commits

..
7 Commits
Author SHA1 Message Date
glenn schrooyenandClaude eb8375157d UX-45: count priced hours in the wizard, fill chart holes by the add-on's cover rule, mark only for price-mode batteries
checks / unit (pull_request) Successful in 40m20s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 10:01:58 +02:00
glenn schrooyenandClaude 17c6048417 UX-45: Home price chart draws one bar per price step the add-on uses
checks / unit (pull_request) Successful in 40m15s
infra-pin / fresh (pull_request) Successful in 1s
With 15-minute prices the chart now has one bar per quarter (92/96/100),
not hour averages; hourly and static tariffs still draw one bar per hour.
Cheap and dear marks come from the kept priceruns result (battery modes
on) or the arbitrage verdict's two points (off), not a thirds split.
A missing quarter is an empty bar. Removes plan.average_tip (EN/NL).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 00:35:29 +02:00
admin 243a756e7b Merge pull request 'DOC-8: say EMS port, not meter bus' (#201) from DOC-8 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-10-17 04:09:33 +02:00
glenn schrooyenandClaude Opus 5.5 a4a7ff695e DOC-8 review: say the field guide PDF is rebuilt, still v0.1.0
checks / unit (pull_request) Successful in 40m29s
infra-pin / fresh (pull_request) Successful in 1s
README and FIELD-GUIDE.md said the PDF was not rebuilt; it was, on
2026-10-17. Its content is still v0.1.0 and outdated. Rewrap the
DOCS.md intro line. Text only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 03:27:35 +02:00
glenn schrooyenandClaude b8b357b552 DOC-8: update test_mqtt digest pins for the new manufacturer string
infra-pin / fresh (pull_request) Successful in 2s
checks / unit (pull_request) Successful in 40m23s
Both digests hash the discovery payloads, which carry the manufacturer.
Values regenerated; nothing else changes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-17 02:16:34 +02:00
glenn schrooyenandClaude 4584502adb DOC-8: HA manufacturer says EMS port; rebuild field guide PDF
checks / unit (pull_request) Failing after 41m1s
infra-pin / fresh (pull_request) Successful in 1s
Device id (identifiers) is unchanged; only the display text moves.
test_mqtt baseline updated for that one string.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-17 01:53:33 +02:00
glenn schrooyenandClaude fd08090360 DOC-8: say EMS port, not meter bus
checks / unit (pull_request) Successful in 40m22s
infra-pin / fresh (pull_request) Successful in 1s
The add-on is wired to the inverter's RS485 port labelled EMS. Reword
README, DOCS, config description, field guide source and HTML. The HA
manufacturer string is left unchanged: test_mqtt pins it byte for byte.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-17 01:09:42 +02:00
18 changed files with 410 additions and 85 deletions
+3 -3
View File
@@ -4,9 +4,9 @@ The guide technicians carry is the PDF:
**`docs/GoodWe-RS485-Field-Guide.pdf`** — 10 pages, A4, print it double-sided. **`docs/GoodWe-RS485-Field-Guide.pdf`** — 10 pages, A4, print it double-sided.
⚠️ **Outdated: it is v0.1.0.** Its `slew_w` row is corrected in `build_guide.py` ⚠️ **Outdated: the content is still v0.1.0.** The PDF was rebuilt on 2026-10-17
and `field-guide.html`, but the PDF has not been rebuilt (see below). Where the for the EMS-port wording and the `slew_w` row only. Where the guide disagrees with
guide disagrees with `goodwe_controller/DOCS.md`, DOCS.md wins. `goodwe_controller/DOCS.md`, DOCS.md wins.
It covers, in the order the work is done: pre-visit checks, hardware, firmware, 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 add-on install and configuration, how to read the Web UI, the six commissioning
+1 -1
View File
@@ -156,7 +156,7 @@ that looks hung gets deleted.
**A GoodWe "-20" / G2 inverter cannot be driven by this.** **A GoodWe "-20" / G2 inverter cannot be driven by this.**
Platform comes from characters 6–8 of the serial number. Only Platform comes from characters 6–8 of the serial number. Only
`ESU EMU ESA BPS BPU EMJ IJL` — platform 105, the AA55-era meter bus — are `ESU EMU ESA BPS BPU EMJ IJL` — platform 105, the AA55-era generation — are
supported. SBP G2 (`SPB`/`SPN`) and ES G2 (`ESN`/`ESC`) are platform 745, a supported. SBP G2 (`SPB`/`SPN`) and ES G2 (`ESN`/`ESC`) are platform 745, a
different protocol. The reference unit is `BPS`. different protocol. The reference unit is `BPS`.
+6 -6
View File
@@ -1,8 +1,8 @@
# GoodWe RS485 Controller — Home Assistant add-on repository # GoodWe RS485 Controller — Home Assistant add-on repository
Replaces a GoodWe battery inverter's vendor controller with a local one: holds Replaces a GoodWe battery inverter's vendor controller with a local one: holds
net grid exchange at zero by emulating the inverter's smart meter over RS485, net grid exchange at zero by sending the inverter a grid reading over its RS485
and runs the monthly battery maintenance cycle the vendor box was doing. EMS port, and runs the monthly battery maintenance cycle the vendor box was doing.
``` ```
repository.yaml add-on repository metadata repository.yaml add-on repository metadata
@@ -30,7 +30,7 @@ goodwe_controller/ the add-on
returns, run against its own release image returns, run against its own release image
firmware/goodwe-master.yaml ESPHome config for the T-CAN485 firmware/goodwe-master.yaml ESPHome config for the T-CAN485
estop/rs485_log.py optional RS485 e-stop / bus witness estop/rs485_log.py optional RS485 e-stop / bus witness
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4; outdated) docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4; rebuilt 2026-10-17, content still v0.1.0 and outdated)
docs/build_guide.py its single source - rebuilds the HTML and PDF docs/build_guide.py its single source - rebuilds the HTML and PDF
FIELD-GUIDE.md how to edit and rebuild the guide FIELD-GUIDE.md how to edit and rebuild the guide
docs/RELEASE.md how a change reaches the battery - `master` is docs/RELEASE.md how a change reaches the battery - `master` is
@@ -118,17 +118,17 @@ the platform tag:
| tag | platform | verdict | | tag | platform | verdict |
|---|---|---| |---|---|---|
| `ESU EMU ESA BPS BPU EMJ IJL` | 105 — ES/EM/BP, AA55-era meter bus | **supported** | | `ESU EMU ESA BPS BPU EMJ IJL` | 105 — ES/EM/BP, AA55-era | **supported** |
| `SPB SPN` | 745 — SBP **G2** (`GW…-SBP-20`) | no | | `SPB SPN` | 745 — SBP **G2** (`GW…-SBP-20`) | no |
| `ESN ESC` | 745 — ES **G2** (`GW…-ES-20`) | no | | `ESN ESC` | 745 — ES **G2** (`GW…-ES-20`) | no |
| `ETU EHU BTU …` | 205/745/753 — ET/EH/BT hybrids | no | | `ETU EHU BTU …` | 205/745/753 — ET/EH/BT hybrids | no |
| `SDT DST MSU NSU …` | PV-only inverters — no battery to control | no | | `SDT DST MSU NSU …` | PV-only inverters — no battery to control | no |
A "-20" or "G2" suffix means a different platform with a different meter A "-20" or "G2" suffix means a different platform with a different
protocol. The tags come from the `goodwe` library's `PLATFORM_105_MODELS`, which 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. is also what Home Assistant's own integration uses to pick a protocol.
- GoodWe **ES / BP family** inverters (AA55 / RS485 meter-bus generation) - GoodWe **ES / BP family** inverters (AA55 generation)
- The protocol is **reverse-engineered**. There is no vendor contract, and a - 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 firmware change on GoodWe's side could break every installation at once. Say
so when you sell it. so when you sell it.
Binary file not shown.
+6 -6
View File
@@ -154,7 +154,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<table style="margin-bottom:2mm"> <table style="margin-bottom:2mm">
<tr><th style="width:34mm">Tag</th><th>Platform</th><th style="width:30mm">Verdict</th></tr> <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> <tr><td><code>ESU EMU ESA BPS<br>BPU EMJ IJL</code></td>
<td><b>Platform 105</b> — ES / EM / BP, AA55-era meter bus</td> <td><b>Platform 105</b> — ES / EM / BP, AA55-era</td>
<td><b>SUPPORTED</b></td></tr> <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>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> <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> <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> </table>
<p style="margin:0"><small>Reference unit: <code>95000BPS225W0290</code> → <code>BPS</code> → supported. <p style="margin:0"><small>Reference unit: <code>95000BPS225W0290</code> → <code>BPS</code> → supported.
A “-20” or “G2” suffix in the model name means a different platform with a different meter A “-20” or “G2” suffix in the model name means a different platform with a different
protocol. <b>If the tag is not on the supported list, stop — do not quote the job.</b></small></p> protocol. <b>If the tag is not on the supported list, stop — do not quote the job.</b></small></p>
</div> </div>
@@ -208,13 +208,13 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<!-- ============ PAGE 3 ============ --> <!-- ============ PAGE 3 ============ -->
<div class="page" id="p3"> <div class="page" id="p3">
<h2>Steps 1–3 — Hardware</h2> <h2>Steps 1–3 — Hardware</h2>
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The meter bus <div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The RS485 EMS port
is low-voltage but sits inside an energised installation.</div> is low-voltage but sits inside an energised installation.</div>
<div class="step"> <div class="step">
<h3><span class="num">1</span> Survey and record</h3> <h3><span class="num">1</span> Survey and record</h3>
<ol class="do"> <ol class="do">
<li><b>Photograph</b> the meter-port wiring before touching it.</li> <li><b>Photograph</b> the EMS-port wiring before touching it.</li>
<li>Identify the RS485 pair (A/B) running to the vendor controller.</li> <li>Identify the RS485 pair (A/B) running to the vendor controller.</li>
<li>Record inverter model + serial, battery capacity (Ah) and nominal voltage.</li> <li>Record inverter model + serial, battery capacity (Ah) and nominal voltage.</li>
</ol> </ol>
@@ -226,7 +226,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<ol class="do"> <ol class="do">
<li>Bring the system to <b>0 W</b> — battery neither charging nor discharging (check the <li>Bring the system to <b>0 W</b> — battery neither charging nor discharging (check the
inverter display).</li> inverter display).</li>
<li>Disconnect the vendor controller from the meter port.</li> <li>Disconnect the vendor controller from the inverter's EMS port.</li>
<li>Leave it physically mounted but unplugged — that keeps the install reversible and gives <li>Leave it physically mounted but unplugged — that keeps the install reversible and gives
you a rollback story.</li> you a rollback story.</li>
</ol> </ol>
@@ -237,7 +237,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<div class="step"> <div class="step">
<h3><span class="num">3</span> Fit and power the T-CAN485</h3> <h3><span class="num">3</span> Fit and power the T-CAN485</h3>
<ol class="do"> <ol class="do">
<li>Wire <b>A→A, B→B</b> to the meter port, plus its own supply.</li> <li>Wire <b>A→A, B→B</b> to the EMS port, plus its own supply.</li>
<li>If fitting the e-stop, connect its USB-RS485 adapter to the <b>same pair, in parallel</b>.</li> <li>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> <li>Power the T-CAN485 <b>last</b>.</li>
</ol> </ol>
+6 -6
View File
@@ -119,7 +119,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<table style="margin-bottom:2mm"> <table style="margin-bottom:2mm">
<tr><th style="width:34mm">Tag</th><th>Platform</th><th style="width:30mm">Verdict</th></tr> <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> <tr><td><code>ESU EMU ESA BPS<br>BPU EMJ IJL</code></td>
<td><b>Platform 105</b> — ES / EM / BP, AA55-era meter bus</td> <td><b>Platform 105</b> — ES / EM / BP, AA55-era</td>
<td><b>SUPPORTED</b></td></tr> <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>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> <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> <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> </table>
<p style="margin:0"><small>Reference unit: <code>95000BPS225W0290</code> → <code>BPS</code> → supported. <p style="margin:0"><small>Reference unit: <code>95000BPS225W0290</code> → <code>BPS</code> → supported.
A “-20” or “G2” suffix in the model name means a different platform with a different meter A “-20” or “G2” suffix in the model name means a different platform with a different
protocol. <b>If the tag is not on the supported list, stop — do not quote the job.</b></small></p> protocol. <b>If the tag is not on the supported list, stop — do not quote the job.</b></small></p>
</div> </div>
@@ -173,13 +173,13 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<!-- ============ PAGE 3 ============ --> <!-- ============ PAGE 3 ============ -->
<div class="page" id="p3"> <div class="page" id="p3">
<h2>Steps 1–3 — Hardware</h2> <h2>Steps 1–3 — Hardware</h2>
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The meter bus <div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The RS485 EMS port
is low-voltage but sits inside an energised installation.</div> is low-voltage but sits inside an energised installation.</div>
<div class="step"> <div class="step">
<h3><span class="num">1</span> Survey and record</h3> <h3><span class="num">1</span> Survey and record</h3>
<ol class="do"> <ol class="do">
<li><b>Photograph</b> the meter-port wiring before touching it.</li> <li><b>Photograph</b> the EMS-port wiring before touching it.</li>
<li>Identify the RS485 pair (A/B) running to the vendor controller.</li> <li>Identify the RS485 pair (A/B) running to the vendor controller.</li>
<li>Record inverter model + serial, battery capacity (Ah) and nominal voltage.</li> <li>Record inverter model + serial, battery capacity (Ah) and nominal voltage.</li>
</ol> </ol>
@@ -191,7 +191,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<ol class="do"> <ol class="do">
<li>Bring the system to <b>0 W</b> — battery neither charging nor discharging (check the <li>Bring the system to <b>0 W</b> — battery neither charging nor discharging (check the
inverter display).</li> inverter display).</li>
<li>Disconnect the vendor controller from the meter port.</li> <li>Disconnect the vendor controller from the inverter's EMS port.</li>
<li>Leave it physically mounted but unplugged — that keeps the install reversible and gives <li>Leave it physically mounted but unplugged — that keeps the install reversible and gives
you a rollback story.</li> you a rollback story.</li>
</ol> </ol>
@@ -202,7 +202,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<div class="step"> <div class="step">
<h3><span class="num">3</span> Fit and power the T-CAN485</h3> <h3><span class="num">3</span> Fit and power the T-CAN485</h3>
<ol class="do"> <ol class="do">
<li>Wire <b>A→A, B→B</b> to the meter port, plus its own supply.</li> <li>Wire <b>A→A, B→B</b> to the EMS port, plus its own supply.</li>
<li>If fitting the e-stop, connect its USB-RS485 adapter to the <b>same pair, in parallel</b>.</li> <li>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> <li>Power the T-CAN485 <b>last</b>.</li>
</ol> </ol>
+6 -5
View File
@@ -1,8 +1,9 @@
# GoodWe RS485 Controller # GoodWe RS485 Controller
Drives a GoodWe ES/BP battery inverter over its RS485 meter bus: holds net grid Drives a GoodWe ES/BP battery inverter over its RS485 EMS port: sends it a grid
exchange at zero, and runs a monthly battery maintenance cycle so the BMS can reading to hold net grid exchange at zero, and runs a monthly battery
balance cells and recalibrate its coulomb counter. 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 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. v0.1.0 and outdated; where it disagrees with this page, this page wins.
@@ -33,8 +34,8 @@ v0.1.0 and outdated; where it disagrees with this page, this page wins.
You need: You need:
- A GoodWe **ES / BP family** inverter (AA55 / RS485 meter-bus generation) - A GoodWe **ES / BP family** inverter (AA55 generation)
- The vendor's meter-emulating controller **disconnected** from the bus - The vendor's controller **disconnected** from the inverter's EMS port
- A control board: a T-CAN485 (ESP32) flashed with - A control board: a T-CAN485 (ESP32) flashed with
`firmware/goodwe-master.yaml` `firmware/goodwe-master.yaml`
- A grid-power sensor already working in Home Assistant, updating every ~5–10 s - A grid-power sensor already working in Home Assistant, updating every ~5–10 s
+92 -38
View File
@@ -22,16 +22,13 @@ from . import arbitrage, messages, peakguard, uitext
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD from .maintenance import 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, next_slot_start
from .packmode import CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, SOLAR_FIRST from .packmode import CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, SOLAR_FIRST
from .price import Absence, Uncertainty, brussels from .price import Absence, Uncertainty, brussels, _cover_table
from .static_tariff import static_plan from .static_tariff import static_plan
from .tariff import BILLING_TZ from .tariff import BILLING_TZ
from .uimodel import REGION_PRESETS, options_to_ui from .uimodel import REGION_PRESETS, options_to_ui
# A battery at or under this many watts is resting, not charging or giving. # A battery at or under this many watts is resting, not charging or giving.
IDLE_W = 50.0 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. # Severity words the page shows; the same four as STYLE.md section 3.
ALARM, WARNING, INFO = messages.ALARM, messages.WARNING, messages.INFO ALARM, WARNING, INFO = messages.ALARM, messages.WARNING, messages.INFO
@@ -142,55 +139,103 @@ class DayPrices:
def __init__(self): def __init__(self):
self.day = None self.day = None
self.points = {} self.points = {}
self.kinds = {}
def see(self, points, day0, day1): def see(self, points, day0, day1):
if self.day != day0: if self.day != day0:
self.day, self.points = day0, {} self.day, self.points, self.kinds = day0, {}, {}
for p in points: for p in points:
if day0 <= p.start < day1: if day0 <= p.start < day1:
self.points[p.start] = p self.points[p.start] = p
return sorted(self.points.values(), key=lambda p: p.start) return sorted(self.points.values(), key=lambda p: p.start)
def hour_bars(points, day0, day1): def _bar_step(pts):
"""One bar per local hour: the mean of its prices, or None when unknown. """The series' own spacing: its smallest gap, never over an hour."""
`kind` is cheap / dear / mid by thirds of today's range (display only). gaps = [y.start - x.start for x, y in zip(pts, pts[1:])
The page calls these "low price" and "high price", not cheap hours: the if y.start > x.start]
add-on trades in the one cheapest and one dearest hour, not in a third.""" return min(gaps + [timedelta(hours=1)])
n = int(round(_hours(day1, day0)))
def _bar_cover(pts):
"""`[(start, end, point)]`: what each point is the price of on the chart.
The add-on's own rule, not a copy: `price._cover_table()` (TARIFF-14), so
a point covers until the next one starts and never over an hour, and the
last one for the span of the one before it."""
return _cover_table(pts)
def price_bars(points, day0, day1, windows=(), now=None, kept=None):
"""One bar per price step of the day: a quarter for a 15-minute series
(96; 92 and 100 on the clock-change days), an hour for an hourly one or a
static tariff. A bar is the price of the point that covers it, never an
average, and a step with no price is a bar with `eur_kwh` None.
`kind` is "cheap" or "dear" where `windows` (`[(kind, start, end)]`, the
runs the add-on itself uses) cover the bar's start, else "mid". A bar that
is over keeps the kind it had while it was still ahead (`kept`, a dict
that lives a day). Display only."""
pts = sorted(points, key=lambda p: p.start)
step = _bar_step(pts)
cover = _bar_cover(pts)
n = int((day1 - day0) / step)
bars = [] bars = []
for k in range(n): for k in range(n):
a, b = day0 + timedelta(hours=k), day0 + timedelta(hours=k + 1) a = day0 + k * step
inside = [p for p in points if a <= p.start < b] z = a + step
price = (sum(p.eur_per_kwh for p in inside) / len(inside) hit = next((p for s, e, p in cover if s <= a < e), None)
if inside else None) kind = None
if hit is not None:
if kept is not None and now is not None and z <= now:
kind = kept.get(a, "mid")
else:
kind = next((w[0] for w in windows if w[1] <= a < w[2]),
"mid")
if kept is not None:
kept[a] = kind
bars.append({"h": k, "start": a.isoformat(), "label": hhmm(a), bars.append({"h": k, "start": a.isoformat(), "label": hhmm(a),
"end_label": hhmm(b), "points": len(inside), "end_label": hhmm(z),
"eur_kwh": None if price is None else round(price, 4), "eur_kwh": None if hit is None
"estimate": bool(inside) and any(p.provisional else round(hit.eur_per_kwh, 4),
for p in inside), "estimate": hit is not None and bool(hit.provisional),
"kind": None}) "kind": kind})
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 return bars
def _price_mode_in_use(c):
"""True when at least one battery is on and uses a price mode. The runs
belong to those batteries; with all on Solar first or off, nothing acts
on a price."""
for inv in c.inverters:
pm = c.pack_mode(inv)
if pm.use and pm.mode in PRICE_MODES:
return True
return False
def price_windows(c, plan, verdict):
"""`[(kind, start, end)]`: where the add-on itself buys ("cheap") and
gives ("dear"). With battery modes on that is the kept `priceruns.RunPlan`
(`c.mode_runs`): the cheap run when charging in it pays, and every give
run. With them off it is the arbitrage verdict's two points, the ones
`trade_runs()` draws. Nothing is classified here."""
if getattr(c, "modes_on", False):
rp = getattr(c, "mode_runs", None)
if rp is None or not rp.on or not _price_mode_in_use(c):
return []
out = [("cheap", rp.cheap.start, rp.cheap.end)] \
if rp.charge_useful() else []
return out + [("dear", g.start, g.end) for g in rp.give]
if verdict is None:
return []
return [("cheap", verdict.cheapest.start,
_point_end(plan, verdict.cheapest)),
("dear", verdict.dearest.start,
_point_end(plan, verdict.dearest))]
def day_range(points): def day_range(points):
"""(lowest, highest) of today's published points, as {eur_kwh, label}, """(lowest, highest) of today's published points, as {eur_kwh, label},
or (None, None). The feed's own points, like the trade legs, not the or (None, None). The feed's own points, like the trade legs."""
chart's hour averages."""
firm = [p for p in points if not p.provisional] firm = [p for p in points if not p.provisional]
if not firm: if not firm:
return None, None return None, None
@@ -551,13 +596,15 @@ def build_plan(c, now, shadow, running, held=False, settled=True):
plan = static plan = static
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
pass pass
seen = _day_prices(c).see(_points(plan), day0, day1) days = _day_prices(c)
bars = hour_bars(seen, day0, day1) seen = days.see(_points(plan), day0, day1)
limit_w = c.peak.cap_w # the limit the controller parsed at start limit_w = c.peak.cap_w # the limit the controller parsed at start
runs, lines = [], [] runs, lines = [], []
# The control cycle trades on the fresh plan only, so this does too. # The control cycle trades on the fresh plan only, so this does too.
verdict, trade_why = trade_verdict(c, fresh) verdict, trade_why = trade_verdict(c, fresh)
bars = price_bars(seen, day0, day1, price_windows(c, fresh, verdict),
now, days.kinds)
modes_on = bool(getattr(c, "modes_on", False)) modes_on = bool(getattr(c, "modes_on", False))
if running: if running:
off = {i for i, inv in enumerate(c.inverters) if _off(c, inv)} off = {i for i, inv in enumerate(c.inverters) if _off(c, inv)}
@@ -1431,3 +1478,10 @@ def _samples() -> list:
("not_covered", "no_prices", "peak", "not_firm", "not_worth", ("not_covered", "no_prices", "peak", "not_firm", "not_worth",
"no_level")] "no_level")]
return out 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 -1
View File
@@ -3,7 +3,7 @@
WHAT THIS THING DOES, in one paragraph: it reads the house's net grid power from WHAT THIS THING DOES, in one paragraph: it reads the house's net grid power from
Home Assistant, decides how hard the battery should charge or discharge to hold Home Assistant, decides how hard the battery should charge or discharge to hold
that at zero, and writes that figure to an ESP32 which puts it on the inverter's that at zero, and writes that figure to an ESP32 which puts it on the inverter's
RS485 meter bus. Once a month it runs a full low->full battery cycle so the BMS RS485 EMS port. Once a month it runs a full low->full battery cycle so the BMS
can balance cells and recalibrate its coulomb counter. can balance cells and recalibrate its coulomb counter.
⚠️ THE ONE THING TO UNDERSTAND BEFORE CHANGING ANY OF THIS: the inverter holds ⚠️ THE ONE THING TO UNDERSTAND BEFORE CHANGING ANY OF THIS: the inverter holds
+1 -1
View File
@@ -29,7 +29,7 @@ _LOG = logging.getLogger("goodwe.mqtt")
DEVICE = { DEVICE = {
"identifiers": ["goodwe_rs485_controller"], "identifiers": ["goodwe_rs485_controller"],
"name": "GoodWe", "name": "GoodWe",
"manufacturer": "GoodWe (via RS485 meter emulation)", "manufacturer": "GoodWe (via RS485 EMS port)",
"model": "ES/BP series", "model": "ES/BP series",
} }
+2
View File
@@ -78,6 +78,8 @@ h2{font-size:15px;margin:0 0 10px;font-weight:650}
.bars i.cheap{background:var(--cheap)} .bars i.cheap{background:var(--cheap)}
/* not by colour alone: expensive hours are striped, estimates are faded */ /* 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 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.est{opacity:.55}
.bars i.none{background:none;border-bottom:2px dotted var(--faint);height:2px!important} .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} .lane{position:relative;height:18px;margin-top:4px;background:var(--bg);border-radius:4px}
+2 -1
View File
@@ -177,11 +177,12 @@
const known = p.prices.filter((b) => b.eur_kwh != null); const known = p.prices.filter((b) => b.eur_kwh != null);
const mx = Math.max(0.01, ...known.map((b) => Math.abs(b.eur_kwh))); const mx = Math.max(0.01, ...known.map((b) => Math.abs(b.eur_kwh)));
bars.setAttribute("aria-label", t("plan.name")); bars.setAttribute("aria-label", t("plan.name"));
bars.classList.toggle("fine", p.prices.length > 48);
bars.replaceChildren(...p.prices.map((b) => { bars.replaceChildren(...p.prices.map((b) => {
const title = b.label + " " + t("plan.time_to") + " " + b.end_label; 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") + "."]); 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 kind = b.kind === "cheap" ? ", " + t("plan.cheap_tip") : b.kind === "dear" ? ", " + t("plan.dear_tip") : "";
const est = (b.points > 1 ? ", " + t("plan.average_tip") : "") + (b.estimate ? ", " + t("plan.estimate_tip") : ""); const est = (b.estimate ? ", " + t("plan.estimate_tip") : "");
return chartItem("i", (b.kind || "mid") + (b.estimate ? " est" : ""), return chartItem("i", (b.kind || "mid") + (b.estimate ? " est" : ""),
"height:" + Math.max(4, Math.round(Math.abs(b.eur_kwh) / mx * 100)) + "%", "height:" + Math.max(4, Math.round(Math.abs(b.eur_kwh) / mx * 100)) + "%",
title, [eur(b.eur_kwh) + " " + t("plan.per_kwh") + kind + est + "."]); title, [eur(b.eur_kwh) + " " + t("plan.per_kwh") + kind + est + "."]);
+1 -2
View File
@@ -66,8 +66,7 @@
"estimate_tip": "estimate from yesterday", "estimate_tip": "estimate from yesterday",
"none_tip": "No price known", "none_tip": "No price known",
"no_prices": {"description": "No prices for today, so the chart shows only the plan."}, "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"}, "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"}, "attention": {"name": "Needs attention", "nothing": "Nothing to look at", "to_look": "to look at", "details": "Details", "alarm": "Alarm", "warning": "Warning", "info": "Info"},
+1 -2
View File
@@ -63,8 +63,7 @@
"estimate_tip": "schatting op basis van gisteren", "estimate_tip": "schatting op basis van gisteren",
"none_tip": "Geen prijs bekend", "none_tip": "Geen prijs bekend",
"no_prices": {"description": "Geen prijzen voor vandaag, dus de grafiek toont alleen het plan."}, "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"}, "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"}, "attention": {"name": "Vraagt aandacht", "nothing": "Niets om na te kijken", "to_look": "na te kijken", "details": "Details", "alarm": "Alarm", "warning": "Waarschuwing", "info": "Info"},
+2 -2
View File
@@ -50,7 +50,7 @@ from aiohttp import web
from . import messages, price, selfconfig, uipage, uitext from . import messages, price, selfconfig, uipage, uitext
from .haentities import EntityCache from .haentities import EntityCache
from .homeview import region_of from .homeview import priced_hours, region_of
from .validate import config_notes, setup_checks from .validate import config_notes, setup_checks
from .config_page import CONFIG_PAGE from .config_page import CONFIG_PAGE
from .configui import (changed_keys, field_model, hide_secrets, keep_secrets, from .configui import (changed_keys, field_model, hide_secrets, keep_secrets,
@@ -328,7 +328,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
try: try:
prices = (controller.status_payload().get("plan") or {}).get( prices = (controller.status_payload().get("plan") or {}).get(
"prices") or [] "prices") or []
known = sum(1 for p in prices if p.get("eur_kwh") is not None) known = priced_hours(prices)
except Exception: # noqa: BLE001 - a display figure, never a failure except Exception: # noqa: BLE001 - a display figure, never a failure
known = None known = None
return setup_checks(options, entities=rows, running=controller.o, return setup_checks(options, entities=rows, running=controller.o,
+2 -2
View File
@@ -2,8 +2,8 @@ name: GoodWe RS485 Controller
version: "0.18.2" version: "0.18.2"
slug: goodwe_controller slug: goodwe_controller
description: >- description: >-
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart Drives a GoodWe ES/BP battery inverter over its RS485 EMS port by sending it a
meter, holding net grid exchange at zero and running a monthly battery grid reading, holding net grid exchange at zero and running a monthly battery
maintenance cycle. maintenance cycle.
url: https://gittea.kammenstraatha.duckdns.org/admin/goodwe-addon url: https://gittea.kammenstraatha.duckdns.org/admin/goodwe-addon
arch: arch:
+275 -6
View File
@@ -33,7 +33,9 @@ from app import homeview, web
from app.main import Controller from app.main import Controller
from app.peakguard import PeakDecision from app.peakguard import PeakDecision
from app.price import PricePoint, PriceProduct, PriceRead from app.price import PricePoint, PriceProduct, PriceRead
from app.static_tariff import TariffPlan from app.static_tariff import TariffPlan, static_plan
from app import packmode
from app.store import unit_key
from app.uimodel import REGION_PRESETS from app.uimodel import REGION_PRESETS
from named_absence import field, nth, present from named_absence import field, nth, present
@@ -152,6 +154,14 @@ def quiet_guard(projected_w=1240.0):
ceiling_from="cap", projected_w=projected_w) 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): def status(c, now=NOW):
s = c.status_payload() s = c.status_payload()
# The status route builds at wall-clock time; rebuild the structured half # The status route builds at wall-clock time; rebuild the structured half
@@ -317,7 +327,7 @@ check("a plan with no published prices yet says so, not 'the price gap is "
_t == [] and homeview.line_no_trade("not_firm") in _l _t == [] and homeview.line_no_trade("not_firm") in _l
and homeview.line_no_trade("not_worth") not 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 " check("the price bars are today's 24 hours, from the real series, with the "
"cheap and expensive thirds marked", "cheap and dear hour marked",
[b["eur_kwh"] for b in s_arb["plan"]["prices"]] == MOCK [b["eur_kwh"] for b in s_arb["plan"]["prices"]] == MOCK
and s_arb["plan"]["prices"][13]["kind"] == "cheap" and s_arb["plan"]["prices"][13]["kind"] == "cheap"
and s_arb["plan"]["prices"][19]["kind"] == "dear" and s_arb["plan"]["prices"][19]["kind"] == "dear"
@@ -518,10 +528,269 @@ check("quarter-hour prices: the charge block, its price and the price line's "
and f"Today: lowest €{_lo.eur_per_kwh:.2f} at " and f"Today: lowest €{_lo.eur_per_kwh:.2f} at "
f"{homeview.hhmm(_lo.start)}," in s_q["plan"]["price_line"] f"{homeview.hhmm(_lo.start)}," in s_q["plan"]["price_line"]
and "the list" not in json.dumps(s_q["plan"])) and "the list" not in json.dumps(s_q["plan"]))
check("...and the hour bar says it is an hour average",
s_q["plan"]["prices"][13]["points"] == 4 # UX-45: the chart draws the steps the add-on decides on.
and '"plan.average_tip"' in io.open(os.path.join( _pq = s_q["plan"]["prices"]
HERE, "app", "static", "app.js"), encoding="utf-8").read()) check("UX-45: 96 quarter prices -> 96 bars, each one quarter, labelled "
"from its own start, with its own price and no average",
len(_pq) == 96 and _pq[0]["label"] == "00:00"
and _pq[0]["end_label"] == "00:15" and _pq[57]["label"] == "14:15"
and _pq[57]["end_label"] == "14:30"
and [b["eur_kwh"] for b in _pq] == [round(p.eur_per_kwh, 4)
for p in _q[:96]]
and all("points" not in b for b in _pq)
and s_q["plan"]["hours"] == 24)
check("...the quarters of one hour differ (the chart shows the steps)",
len({b["eur_kwh"] for b in _pq[52:56]}) > 1)
check("...and the page has no 'hour average' tip any more",
"average_tip" not in io.open(os.path.join(
HERE, "app", "static", "app.js"), encoding="utf-8").read()
and "average_tip" not in io.open(os.path.join(
HERE, "app", "ui_text", "en.json"), encoding="utf-8").read()
and "average_tip" not in io.open(os.path.join(
HERE, "app", "ui_text", "nl.json"), encoding="utf-8").read())
_cheap_q = [b["start"] for b in _pq if b["kind"] == "cheap"]
_dear_q = [b["start"] for b in _pq if b["kind"] == "dear"]
_hi = max(_q[:96], key=lambda p: p.eur_per_kwh)
check("...without battery modes the marks are the two quarters the "
"arbitrage verdict trades in, and no others",
_cheap_q == [_lo.start.isoformat()] and _dear_q == [_hi.start.isoformat()]
and sum(1 for b in _pq if b["kind"] == "mid") == 94)
# Battery modes on: the marks ARE the kept priceruns result.
from app import priceruns
_rp = priceruns.plan(tuple(_q), NOW, required_eur_kwh=0.05, charge_kwh=2.0,
charge_kw=4.0, sell_kwh=0.0, sell_kw=0.0)
c_m = make(ARB)
c_m.tariff_plan, c_m.tariff_at = c_q.tariff_plan, NOW
c_m.peak_guard = quiet_guard()
c_m.modes_on, c_m.mode_runs = True, _rp
set_modes(c_m, packmode.SOLAR_FIRST, packmode.CHEAP_HOURS)
_pm = status(c_m)["plan"]["prices"]
_today = [(a, z) for a, z, _p in priceruns.firm_slots(_q, NOW)
if a < DAY0 + timedelta(hours=24)]
_want_c = {a.isoformat() for a, z in _today
if _rp.cheap.start <= a < _rp.cheap.end}
_want_d = {a.isoformat() for g in _rp.give for a, z in _today
if g.start <= a < g.end}
check("UX-45: with battery modes on, the cheap and dear quarters are the "
"priceruns cheap run and give runs for the same input",
_rp.on and _rp.charge_useful() and _want_c and _want_d
and {b["start"] for b in _pm if b["kind"] == "cheap"} == _want_c
and {b["start"] for b in _pm if b["kind"] == "dear"} == _want_d
and len(_pm) == 96)
c_m2 = make(ARB)
c_m2.tariff_plan, c_m2.tariff_at = c_q.tariff_plan, NOW
c_m2.peak_guard = quiet_guard()
c_m2.modes_on = True
set_modes(c_m2, packmode.CHEAP_HOURS, None)
c_m2.mode_runs = priceruns.plan(tuple(_q), NOW, required_eur_kwh=5.0,
charge_kwh=2.0, charge_kw=4.0, sell_kwh=0.0,
sell_kw=0.0)
check("...a run plan that says no trade marks nothing",
not c_m2.mode_runs.on
and {b["kind"] for b in status(c_m2)["plan"]["prices"]} == {"mid"})
# A quarter that is over keeps the mark it had while it was ahead.
# (`status()` also builds at wall-clock time, which starts the day's memory
# over, so the two builds below go straight to `homeview.build`.)
_later = NOW + timedelta(hours=4)
_s0 = c_q.status_payload()
homeview.build(c_q, level=_s0["level"], checks=_s0["checks"], now=NOW)
c_q.tariff_at = _later
_pl = homeview.build(c_q, level=_s0["level"], checks=_s0["checks"],
now=_later)["plan"]["prices"]
check("...and a quarter already over keeps the mark it had while ahead",
[b["kind"] for b in _pl if b["start"] == _lo.start.isoformat()]
== ["cheap"]
and [b["kind"] for b in _pl if b["label"] == "19:00"] == ["dear"])
c_q.tariff_at = NOW
# UX-45 round 2 (F2): a hole is covered by the add-on's own rule,
# `price._cover_table()`: the point before it runs on, never over an hour.
def _hole_bars(missing):
pts = [PricePoint(DAY0 + timedelta(minutes=15 * i), .10 + .001 * i, False)
for i in range(96)]
gone = {DAY0 + timedelta(hours=10, minutes=15 * (1 + j))
for j in range(missing)}
pts = [p for p in pts if p.start not in gone]
return homeview.price_bars(pts, DAY0, DAY0 + timedelta(hours=24))
_gap = {}
for _n in (1, 2, 3, 4, 5):
_bb = _hole_bars(_n)
_gap[_n] = ([b["eur_kwh"] is None for b in _bb[41:41 + _n]],
len(_bb))
_b1 = _hole_bars(1)
check("UX-45: 1, 2 or 3 missing quarters show the price before them (the "
"add-on's own cover rule), and nothing shifts",
all(len(_hole_bars(n)) == 96 for n in (1, 2, 3))
and _gap[1][0] == [False] and _gap[2][0] == [False, False]
and _gap[3][0] == [False, False, False]
and [b["eur_kwh"] for b in _hole_bars(3)[40:44]]
== [_hole_bars(3)[40]["eur_kwh"]] * 4
and _b1[42]["label"] == "10:30" and _b1[43]["label"] == "10:45"
and _b1[43]["eur_kwh"] == round(.10 + .001 * 43, 4))
check("...4 missing quarters leave 1 bar empty, 5 leave 2: a hole over an "
"hour is not filled past the hour",
_gap[4][0] == [False, False, False, True]
and _gap[5][0] == [False, False, False, True, True]
and sum(b["eur_kwh"] is None for b in _hole_bars(4)) == 1
and sum(b["eur_kwh"] is None for b in _hole_bars(5)) == 2)
# A cheap run that is not worth charging in is not marked; an estimate is.
import types
_pts = [PricePoint(DAY0 + timedelta(minutes=15 * i),
.30 if i < 4 else .10, False) for i in range(8)]
_rp2 = priceruns.plan(tuple(_pts), DAY0, required_eur_kwh=0.05,
charge_kwh=0.5, charge_kw=4.0, sell_kwh=0.0, sell_kw=0.0)
c_w2 = make(ARB)
c_w2.modes_on, c_w2.mode_runs = True, _rp2
set_modes(c_w2, packmode.LEFTOVER_SOLAR, None)
_w2 = homeview.price_windows(c_w2, None, None)
check("UX-45: a cheap run that comes after every dear slot is not worth a "
"charge, so it is not marked (the dear run is)",
_rp2.on and _rp2.cheap is not None and not _rp2.charge_useful()
and [w[0] for w in _w2] == ["dear"])
_est = [PricePoint(DAY0 + timedelta(minutes=15 * i), .2, False)
for i in range(80)] + [PricePoint(DAY0 + timedelta(hours=20), .25,
True)]
_be = homeview.price_bars(_est, DAY0, DAY0 + timedelta(hours=24))
check("UX-45: a carried (estimate) price after quarter prices is the last "
"point, so it covers one quarter (the add-on's tail rule), as an "
"estimate bar; the rest stays empty",
[b["estimate"] for b in _be[78:83]] == [False, False, True, False, False]
and _be[80]["eur_kwh"] == .25 and _be[81]["eur_kwh"] is None)
_est2 = _est[:80] + [PricePoint(DAY0 + timedelta(hours=20 + k), .25, True)
for k in range(3)]
_be2 = homeview.price_bars(_est2, DAY0, DAY0 + timedelta(hours=24))
check("...carried hourly points one after another cover an hour each",
[b["eur_kwh"] for b in _be2[80:92]] == [.25] * 12
and _be2[92]["eur_kwh"] is None)
# Hourly prices -> 24 bars (above); a day/night tariff -> 24 bars.
_ph1 = s_arb["plan"]["prices"]
check("UX-45: hourly prices -> 24 hour bars with the hour's own price",
len(_ph1) == 24 and _ph1[13]["end_label"] == "14:00"
and "points" not in _ph1[13])
c_dn = make(dict(ARB, static_profile=(
'{"periods": [{"name":"day","from":"07:00","to":"22:00","days":"all",'
'"eur_per_kwh":0.34},{"name":"night","from":"22:00","to":"07:00",'
'"days":"all","eur_per_kwh":0.26}]}')))
c_dn.peak_guard = quiet_guard()
_pd = status(c_dn)["plan"]["prices"]
check("UX-45: a day/night tariff -> 24 bars, 0.26 at night and 0.34 by day",
len(_pd) == 24
and [b["eur_kwh"] for b in _pd]
== [.26] * 7 + [.34] * 15 + [.26] * 2
and all(b["kind"] == "mid" for b in _pd))
# Clock-change days: 92 and 100 quarters.
_d0 = datetime(2026, 3, 28, 23, 0, tzinfo=timezone.utc)
_d1 = datetime(2026, 3, 29, 22, 0, tzinfo=timezone.utc)
_sp = [PricePoint(_d0 + timedelta(minutes=15 * i), .1, False)
for i in range(92)]
check("UX-45: a 23-hour day has 92 quarter bars, a 25-hour day 100",
len(homeview.price_bars(_sp, _d0, _d1)) == 92
and len(homeview.price_bars(
_sp, _d0, _d0 + timedelta(hours=25))) == 100)
_f0 = datetime(2026, 10, 24, 22, 0, tzinfo=timezone.utc) # 25-hour day
_fb = [PricePoint(_f0 + timedelta(minutes=15 * i), .1, False)
for i in range(100)]
# F5: the day/night chart with the stored static plan, as in production
# (the cycle stores `static_plan()` when the provider is off).
_PROF = ('{"periods": [{"name":"day","from":"07:00","to":"22:00",'
'"days":"all","eur_per_kwh":0.34},{"name":"night","from":"22:00",'
'"to":"07:00","days":"all","eur_per_kwh":0.26}]}')
def _dn(arb):
c = make(dict(ARB if arb else TWO, static_profile=_PROF))
c.peak_guard = quiet_guard()
c.tariff_plan = static_plan(c.o, NOW, NOW + timedelta(hours=36), now=NOW)
c.tariff_at = NOW
return c, status(c)["plan"]["prices"]
_c_off, _p_off = _dn(False)
_c_on, _p_on = _dn(True)
_v_on, _why_on = homeview.trade_verdict(_c_on, _c_on.fresh_tariff_plan(NOW))
check("UX-45: day/night tariff with the stored plan and price trading off: "
"24 bars, no cheap or dear mark",
len(_p_off) == 24 and {b["kind"] for b in _p_off} == {"mid"})
check("...and with price trading on, the marks are the verdict's cheapest "
"and dearest hour (one each)",
_v_on is not None and len(_p_on) == 24
and [datetime.fromisoformat(b["start"]) for b in _p_on
if b["kind"] == "cheap"] == [_v_on.cheapest.start]
and [datetime.fromisoformat(b["start"]) for b in _p_on
if b["kind"] == "dear"] == [_v_on.dearest.start]
and sum(b["kind"] == "mid" for b in _p_on) == 22)
# F3 / owner decision 2a: with battery modes on, marks only when a battery
# uses a price mode.
def _modes(*modes, use=(True, True)):
c = make(ARB)
c.tariff_plan, c.tariff_at = c_q.tariff_plan, NOW
c.peak_guard = quiet_guard()
c.modes_on, c.mode_runs = True, _rp
set_modes(c, *modes)
for inv, u in zip(c.inverters, use):
c.store.data[unit_key(inv.name, packmode.USE_KEY)] = u
return {b["kind"] for b in status(c)["plan"]["prices"]}
_F, _L, _C = (packmode.SOLAR_FIRST, packmode.LEFTOVER_SOLAR,
packmode.CHEAP_HOURS)
check("UX-45: battery modes on, every battery follows the house: no marks",
_modes(_F, _F) == {"mid"} and _modes(None, None) == {"mid"})
check("...one battery on Cheap hours or Leftover solar: the marks show",
{"cheap", "dear"} <= _modes(_F, _C) and {"cheap", "dear"} <= _modes(_L, _F))
check("...a price mode on a battery that is switched off does not count",
_modes(_F, _C, use=(True, False)) == {"mid"}
and {"cheap", "dear"} <= _modes(_F, _C, use=(False, True)))
# F4: the day's memory of past marks starts over on a new day.
_s4 = c_q.status_payload()
homeview.build(c_q, level=_s4["level"], checks=_s4["checks"], now=NOW)
_k0 = dict(c_q.home_prices.kinds)
_tom = NOW + timedelta(days=1)
c_q.tariff_at = _tom
homeview.build(c_q, level=_s4["level"], checks=_s4["checks"], now=_tom)
c_q.tariff_at = NOW
check("UX-45: past marks start over on a new day (nothing of yesterday is "
"kept)",
len(_k0) > 0 and c_q.home_prices.day != DAY0
and c_q.home_prices.kinds
and all(k >= DAY0 + timedelta(days=1) for k in c_q.home_prices.kinds)
and len(c_q.home_prices.kinds) <= 96)
# F1: the setup wizard counts hours, not bars.
from app.validate import setup_checks
_o = dict(ARB, price_provider="entsoe_be")
_say = lambda n: [x for x in map(repr, setup_checks(
_o, entities={}, running=_o, prices_known=n)) if "Prices for today" in x]
check("UX-45: 96 quarter prices count as 24 hours in the setup wizard, "
"not 96",
homeview.priced_hours(_pq) == 24
and homeview.priced_hours(s_arb["plan"]["prices"]) == 24
and "for 24 of 24 hours" in "".join(_say(homeview.priced_hours(_pq)))
and "96 of 24" not in "".join(_say(homeview.priced_hours(_pq))))
check("...a half-priced day counts its hours; a hole of one quarter loses "
"no hour",
homeview.priced_hours(_pq[:48]) == 12
and homeview.priced_hours(_hole_bars(5)) == 24
and homeview.priced_hours([dict(b, eur_kwh=None) for b in _pq]) == 0)
check("...a 23-hour day gives 23 and a 25-hour day 24 (clock hours)",
homeview.priced_hours(homeview.price_bars(_sp, _d0, _d1)) == 23
and homeview.priced_hours(homeview.price_bars(
_fb, _f0, _f0 + timedelta(hours=25))) == 24)
check("...and the wizard takes that count",
"known = priced_hours(prices)" in io.open(os.path.join(
HERE, "app", "web.py"), encoding="utf-8").read())
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
print("\n4. a fault in the Home view never costs the page its rows") print("\n4. a fault in the Home view never costs the page its rows")
+3 -3
View File
@@ -104,7 +104,7 @@ def check(name, cond):
# release/1.0 rather than against release/1.0. # release/1.0 rather than against release/1.0.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_DEV = ('{"identifiers": ["goodwe_rs485_controller"], "name": "GoodWe", ' _DEV = ('{"identifiers": ["goodwe_rs485_controller"], "name": "GoodWe", '
'"manufacturer": "GoodWe (via RS485 meter emulation)", ' '"manufacturer": "GoodWe (via RS485 EMS port)", '
'"model": "ES/BP series"}') '"model": "ES/BP series"}')
BASELINE = [ BASELINE = [
@@ -442,7 +442,7 @@ for c_ in unit_cfgs:
print(f" regression pin (sha256 of the eight payloads): {_pin}") print(f" regression pin (sha256 of the eight payloads): {_pin}")
check("the per-unit payloads are the ones this PR was reviewed with " check("the per-unit payloads are the ones this PR was reviewed with "
"(REGRESSION PIN, not a correctness claim - see the comment)", "(REGRESSION PIN, not a correctness claim - see the comment)",
_pin == "eb5022b0455158d7") _pin == "b4274714d8557c37")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
print("\n4. zero overlap between A's entities and B's") 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. # `git show ed739d4:goodwe_controller/app/mqtt.py` and this loop.
ROSTERS = [[], ["inverter-a"], ["inverter-a", "inverter-b"], ["a", "b", "c"], ROSTERS = [[], ["inverter-a"], ["inverter-a", "inverter-b"], ["a", "b", "c"],
["inverter-b", "Inverter B", "x", "y"], ["café"]] ["inverter-b", "Inverter B", "x", "y"], ["café"]]
ED739D4_OFF = "501a7d93782a260b" ED739D4_OFF = "057be1d8ad7bb582" # DOC-8: ed739d4 digest with only the manufacturer string changed
_h, _n, _subs_off = hashlib.sha256(), 0, [] _h, _n, _subs_off = hashlib.sha256(), 0, []
for _r in ROSTERS: for _r in ROSTERS:
for _om in [(), ("p1_age",), ("p1_age", "grid")]: for _om in [(), ("p1_age",), ("p1_age", "grid")]: