Compare commits
11
Commits
SAFETY-118
...
UX-45
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb8375157d | ||
|
|
17c6048417 | ||
|
|
243a756e7b | ||
|
|
a4a7ff695e | ||
|
|
b8b357b552 | ||
|
|
4584502adb | ||
|
|
fd08090360 | ||
|
|
c67d0cdb67 | ||
|
|
ea77c63552 | ||
|
|
75f4359aac | ||
|
|
09a54859f2 |
+3
-3
@@ -4,9 +4,9 @@ The guide technicians carry is the PDF:
|
||||
|
||||
**`docs/GoodWe-RS485-Field-Guide.pdf`** — 10 pages, A4, print it double-sided.
|
||||
|
||||
⚠️ **Outdated: it is v0.1.0.** Its `slew_w` row is corrected in `build_guide.py`
|
||||
and `field-guide.html`, but the PDF has not been rebuilt (see below). Where the
|
||||
guide disagrees with `goodwe_controller/DOCS.md`, DOCS.md wins.
|
||||
⚠️ **Outdated: the content is still v0.1.0.** The PDF was rebuilt on 2026-10-17
|
||||
for the EMS-port wording and the `slew_w` row only. Where the guide disagrees with
|
||||
`goodwe_controller/DOCS.md`, DOCS.md wins.
|
||||
|
||||
It covers, in the order the work is done: pre-visit checks, hardware, firmware,
|
||||
add-on install and configuration, how to read the Web UI, the six commissioning
|
||||
|
||||
+1
-1
@@ -156,7 +156,7 @@ that looks hung gets deleted.
|
||||
**A GoodWe "-20" / G2 inverter cannot be driven by this.**
|
||||
|
||||
Platform comes from characters 6–8 of the serial number. Only
|
||||
`ESU EMU ESA BPS BPU EMJ IJL` — platform 105, the AA55-era meter bus — are
|
||||
`ESU EMU ESA BPS BPU EMJ IJL` — platform 105, the AA55-era generation — are
|
||||
supported. SBP G2 (`SPB`/`SPN`) and ES G2 (`ESN`/`ESC`) are platform 745, a
|
||||
different protocol. The reference unit is `BPS`.
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# GoodWe RS485 Controller — Home Assistant add-on repository
|
||||
|
||||
Replaces a GoodWe battery inverter's vendor controller with a local one: holds
|
||||
net grid exchange at zero by emulating the inverter's smart meter over RS485,
|
||||
and runs the monthly battery maintenance cycle the vendor box was doing.
|
||||
net grid exchange at zero by sending the inverter a grid reading over its RS485
|
||||
EMS port, and runs the monthly battery maintenance cycle the vendor box was doing.
|
||||
|
||||
```
|
||||
repository.yaml add-on repository metadata
|
||||
@@ -30,7 +30,7 @@ goodwe_controller/ the add-on
|
||||
returns, run against its own release image
|
||||
firmware/goodwe-master.yaml ESPHome config for the T-CAN485
|
||||
estop/rs485_log.py optional RS485 e-stop / bus witness
|
||||
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4; outdated)
|
||||
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4; rebuilt 2026-10-17, content still v0.1.0 and outdated)
|
||||
docs/build_guide.py its single source - rebuilds the HTML and PDF
|
||||
FIELD-GUIDE.md how to edit and rebuild the guide
|
||||
docs/RELEASE.md how a change reaches the battery - `master` is
|
||||
@@ -118,17 +118,17 @@ the platform tag:
|
||||
|
||||
| tag | platform | verdict |
|
||||
|---|---|---|
|
||||
| `ESU EMU ESA BPS BPU EMJ IJL` | 105 — ES/EM/BP, AA55-era meter bus | **supported** |
|
||||
| `ESU EMU ESA BPS BPU EMJ IJL` | 105 — ES/EM/BP, AA55-era | **supported** |
|
||||
| `SPB SPN` | 745 — SBP **G2** (`GW…-SBP-20`) | no |
|
||||
| `ESN ESC` | 745 — ES **G2** (`GW…-ES-20`) | no |
|
||||
| `ETU EHU BTU …` | 205/745/753 — ET/EH/BT hybrids | no |
|
||||
| `SDT DST MSU NSU …` | PV-only inverters — no battery to control | no |
|
||||
|
||||
A "-20" or "G2" suffix means a different platform with a different meter
|
||||
A "-20" or "G2" suffix means a different platform with a different
|
||||
protocol. The tags come from the `goodwe` library's `PLATFORM_105_MODELS`, which
|
||||
is also what Home Assistant's own integration uses to pick a protocol.
|
||||
|
||||
- GoodWe **ES / BP family** inverters (AA55 / RS485 meter-bus generation)
|
||||
- GoodWe **ES / BP family** inverters (AA55 generation)
|
||||
- The protocol is **reverse-engineered**. There is no vendor contract, and a
|
||||
firmware change on GoodWe's side could break every installation at once. Say
|
||||
so when you sell it.
|
||||
|
||||
Binary file not shown.
+6
-6
@@ -154,7 +154,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<table style="margin-bottom:2mm">
|
||||
<tr><th style="width:34mm">Tag</th><th>Platform</th><th style="width:30mm">Verdict</th></tr>
|
||||
<tr><td><code>ESU EMU ESA BPS<br>BPU EMJ IJL</code></td>
|
||||
<td><b>Platform 105</b> — ES / EM / BP, AA55-era meter bus</td>
|
||||
<td><b>Platform 105</b> — ES / EM / BP, AA55-era</td>
|
||||
<td><b>SUPPORTED</b></td></tr>
|
||||
<tr><td><code>SPB SPN</code></td><td>SBP <b>G2</b> (GW…-SBP-20) — platform 745</td><td><b>NO</b></td></tr>
|
||||
<tr><td><code>ESN ESC</code></td><td>ES <b>G2</b> (GW…-ES-20) — platform 745</td><td><b>NO</b></td></tr>
|
||||
@@ -162,7 +162,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<tr><td><code>SDT DST MSU NSU …</code></td><td>PV-only inverters — <b>no battery to control</b></td><td><b>NO</b></td></tr>
|
||||
</table>
|
||||
<p style="margin:0"><small>Reference unit: <code>95000BPS225W0290</code> → <code>BPS</code> → supported.
|
||||
A “-20” or “G2” suffix in the model name means a different platform with a different meter
|
||||
A “-20” or “G2” suffix in the model name means a different platform with a different
|
||||
protocol. <b>If the tag is not on the supported list, stop — do not quote the job.</b></small></p>
|
||||
</div>
|
||||
|
||||
@@ -208,13 +208,13 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<!-- ============ PAGE 3 ============ -->
|
||||
<div class="page" id="p3">
|
||||
<h2>Steps 1–3 — Hardware</h2>
|
||||
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The meter bus
|
||||
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The RS485 EMS port
|
||||
is low-voltage but sits inside an energised installation.</div>
|
||||
|
||||
<div class="step">
|
||||
<h3><span class="num">1</span> Survey and record</h3>
|
||||
<ol class="do">
|
||||
<li><b>Photograph</b> the meter-port wiring before touching it.</li>
|
||||
<li><b>Photograph</b> the EMS-port wiring before touching it.</li>
|
||||
<li>Identify the RS485 pair (A/B) running to the vendor controller.</li>
|
||||
<li>Record inverter model + serial, battery capacity (Ah) and nominal voltage.</li>
|
||||
</ol>
|
||||
@@ -226,7 +226,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<ol class="do">
|
||||
<li>Bring the system to <b>0 W</b> — battery neither charging nor discharging (check the
|
||||
inverter display).</li>
|
||||
<li>Disconnect the vendor controller from the meter port.</li>
|
||||
<li>Disconnect the vendor controller from the inverter's EMS port.</li>
|
||||
<li>Leave it physically mounted but unplugged — that keeps the install reversible and gives
|
||||
you a rollback story.</li>
|
||||
</ol>
|
||||
@@ -237,7 +237,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<div class="step">
|
||||
<h3><span class="num">3</span> Fit and power the T-CAN485</h3>
|
||||
<ol class="do">
|
||||
<li>Wire <b>A→A, B→B</b> to the meter port, plus its own supply.</li>
|
||||
<li>Wire <b>A→A, B→B</b> to the EMS port, plus its own supply.</li>
|
||||
<li>If fitting the e-stop, connect its USB-RS485 adapter to the <b>same pair, in parallel</b>.</li>
|
||||
<li>Power the T-CAN485 <b>last</b>.</li>
|
||||
</ol>
|
||||
|
||||
@@ -119,7 +119,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<table style="margin-bottom:2mm">
|
||||
<tr><th style="width:34mm">Tag</th><th>Platform</th><th style="width:30mm">Verdict</th></tr>
|
||||
<tr><td><code>ESU EMU ESA BPS<br>BPU EMJ IJL</code></td>
|
||||
<td><b>Platform 105</b> — ES / EM / BP, AA55-era meter bus</td>
|
||||
<td><b>Platform 105</b> — ES / EM / BP, AA55-era</td>
|
||||
<td><b>SUPPORTED</b></td></tr>
|
||||
<tr><td><code>SPB SPN</code></td><td>SBP <b>G2</b> (GW…-SBP-20) — platform 745</td><td><b>NO</b></td></tr>
|
||||
<tr><td><code>ESN ESC</code></td><td>ES <b>G2</b> (GW…-ES-20) — platform 745</td><td><b>NO</b></td></tr>
|
||||
@@ -127,7 +127,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<tr><td><code>SDT DST MSU NSU …</code></td><td>PV-only inverters — <b>no battery to control</b></td><td><b>NO</b></td></tr>
|
||||
</table>
|
||||
<p style="margin:0"><small>Reference unit: <code>95000BPS225W0290</code> → <code>BPS</code> → supported.
|
||||
A “-20” or “G2” suffix in the model name means a different platform with a different meter
|
||||
A “-20” or “G2” suffix in the model name means a different platform with a different
|
||||
protocol. <b>If the tag is not on the supported list, stop — do not quote the job.</b></small></p>
|
||||
</div>
|
||||
|
||||
@@ -173,13 +173,13 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<!-- ============ PAGE 3 ============ -->
|
||||
<div class="page" id="p3">
|
||||
<h2>Steps 1–3 — Hardware</h2>
|
||||
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The meter bus
|
||||
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The RS485 EMS port
|
||||
is low-voltage but sits inside an energised installation.</div>
|
||||
|
||||
<div class="step">
|
||||
<h3><span class="num">1</span> Survey and record</h3>
|
||||
<ol class="do">
|
||||
<li><b>Photograph</b> the meter-port wiring before touching it.</li>
|
||||
<li><b>Photograph</b> the EMS-port wiring before touching it.</li>
|
||||
<li>Identify the RS485 pair (A/B) running to the vendor controller.</li>
|
||||
<li>Record inverter model + serial, battery capacity (Ah) and nominal voltage.</li>
|
||||
</ol>
|
||||
@@ -191,7 +191,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<ol class="do">
|
||||
<li>Bring the system to <b>0 W</b> — battery neither charging nor discharging (check the
|
||||
inverter display).</li>
|
||||
<li>Disconnect the vendor controller from the meter port.</li>
|
||||
<li>Disconnect the vendor controller from the inverter's EMS port.</li>
|
||||
<li>Leave it physically mounted but unplugged — that keeps the install reversible and gives
|
||||
you a rollback story.</li>
|
||||
</ol>
|
||||
@@ -202,7 +202,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<div class="step">
|
||||
<h3><span class="num">3</span> Fit and power the T-CAN485</h3>
|
||||
<ol class="do">
|
||||
<li>Wire <b>A→A, B→B</b> to the meter port, plus its own supply.</li>
|
||||
<li>Wire <b>A→A, B→B</b> to the EMS port, plus its own supply.</li>
|
||||
<li>If fitting the e-stop, connect its USB-RS485 adapter to the <b>same pair, in parallel</b>.</li>
|
||||
<li>Power the T-CAN485 <b>last</b>.</li>
|
||||
</ol>
|
||||
|
||||
@@ -1,5 +1,67 @@
|
||||
# Changelog
|
||||
|
||||
## 0.18.2
|
||||
|
||||
**A patch bump: one fix to how the 15-minute demand forecast sensor is read, and no option changes.**
|
||||
No option was added, removed or renamed, and no default changed (86 options).
|
||||
**Existing installs need no option change:** options that start 0.18.1 also
|
||||
start 0.18.2. Only a forecast sensor with no power unit needs a look (below).
|
||||
The change below applies with one inverter or two.
|
||||
|
||||
**A forecast sensor with no power unit.** If your 15-minute demand forecast
|
||||
sensor has no unit, or a unit that is not power (for example kWh), the add-on
|
||||
now ignores that forecast. The log warns once, and the Home and Settings pages
|
||||
say so while it lasts. Pick a sensor that reads in W or kW.
|
||||
|
||||
Contents of this release, in merge order since 0.18.1 (`780c1f6`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `09a5485` | The 15-minute demand forecast sensor is now read in its own unit, W or kW, and the old rule that took any value below 50 as kW is gone. With no unit, or a unit that is not power, the forecast is not used, and the log and the pages say so; the settings page no longer says a kW sensor is taken as watts ([[SAFETY-118]], #198) |
|
||||
|
||||
### Known issues
|
||||
|
||||
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
|
||||
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
|
||||
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
|
||||
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]).
|
||||
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
|
||||
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
|
||||
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
|
||||
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
|
||||
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
|
||||
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
|
||||
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
|
||||
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
|
||||
- When the 15-minute demand forecast sensor counts the battery's own charge, the maintenance charge runs at about half the power the peak limit leaves, so it takes about twice as long. Seen on a site: about 1.2 kW instead of 2.5 kW, and the peak limit was kept ([[SAFETY-111]]).
|
||||
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
|
||||
- After a Home Assistant restart, control still waits until Home Assistant has finished starting, which can take several minutes, though the readings are already there ([[SAFETY-115]]).
|
||||
- While Home Assistant is not answering, the page can list inverters as refusing commands and sensors as missing, and send you to Developer Tools, though they are fine ([[UX-37]]).
|
||||
- If Home Assistant hangs or stops, the page keeps saying "Waiting for Home Assistant" and never turns into an alarm ([[SAFETY-113]]).
|
||||
- Right after Home Assistant has started, the page can say for about 4 s that an inverter cannot be controlled, and the first command can come late ([[SAFETY-114]]).
|
||||
- After a Home Assistant restart, the controller status sensor can still say "running" while the add-on cannot control. It was seen once, and the cause is not known ([[SAFETY-116]]).
|
||||
- Battery modes on: after a planned top-up from the grid, the Batteries screen can say "Enough sun is forecast" ([[UX-32]]).
|
||||
- Battery modes on: the forecast line "we count on" can show more sun than the plan counts, because the plan takes house use off first ([[UX-33]]).
|
||||
- Battery modes on: before a planned top-up, the Today list shows a smaller "gives" amount than the plan will use; it grows as the charge happens ([[UX-34]]).
|
||||
- Two inverters, battery modes on: while the other battery is in its maintenance charge, a battery's card can say it charges from the grid while it stays at 0 W ([[OPT-18]]).
|
||||
- Two inverters, battery modes on: while a battery refuses to charge in a cheap run, the action log on the page gets about 8 lines an hour for its retries, and the page hint says "waiting for a better price" ([[UX-38]]).
|
||||
- A battery in a safety stop, or one the add-on cannot reach, can still be started for a cycle, and its drain then waits about 12 h and ends in a failed-cycle notice. This comes from reading the code; it has not been seen on a site ([[MAINT-19]]).
|
||||
- On the Settings page, the rows of the solar forecast lists are labelled "Phase 1", "Phase 2" instead of "Sensor 1", "Sensor 2" ([[UX-27]]).
|
||||
- In the solar forecast list, a blank or repeated row makes "source 2 of 2" count differently from the rows on the Settings page ([[UX-28]]).
|
||||
- The solar forecast message says "has no reading" also when the sensor shows a number that is too old or in the wrong unit ([[UX-29]]).
|
||||
- If you use only the forecast lists, two Settings notes sit on the single-sensor field, and one of them tells you to set that field ([[UX-30]]).
|
||||
- After a restart during a price outage, the "No prices" message can say "yet" when prices did come in earlier ([[UX-39]]).
|
||||
- On the new settings page, you cannot see whether a price service token is set. Check the Configuration tab ([[UX-41]]).
|
||||
- On the classic settings page, the first click on Save after editing a field can do nothing. Click again ([[UX-42]]).
|
||||
- The price chart on the Home page shows one bar per hour, the average of four 15-minute prices, while the add-on decides on the 15-minute prices ([[UX-45]]).
|
||||
|
||||
### If you are crossing from a version older than 0.10.0
|
||||
|
||||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||||
bare options (`jq '.data.options'`) first.
|
||||
|
||||
## 0.18.1
|
||||
|
||||
**A patch bump: two small fixes to the settings page, and no option changes.**
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# GoodWe RS485 Controller
|
||||
|
||||
Drives a GoodWe ES/BP battery inverter over its RS485 meter bus: holds net grid
|
||||
exchange at zero, and runs a monthly battery maintenance cycle so the BMS can
|
||||
balance cells and recalibrate its coulomb counter.
|
||||
Drives a GoodWe ES/BP battery inverter over its RS485 EMS port: sends it a grid
|
||||
reading to hold net grid exchange at zero, and runs a monthly battery
|
||||
maintenance cycle so the BMS can balance cells and recalibrate its coulomb
|
||||
counter.
|
||||
|
||||
Installers: the printed field guide (`docs/GoodWe-RS485-Field-Guide.pdf`) is
|
||||
v0.1.0 and outdated; where it disagrees with this page, this page wins.
|
||||
@@ -33,8 +34,8 @@ v0.1.0 and outdated; where it disagrees with this page, this page wins.
|
||||
|
||||
You need:
|
||||
|
||||
- A GoodWe **ES / BP family** inverter (AA55 / RS485 meter-bus generation)
|
||||
- The vendor's meter-emulating controller **disconnected** from the bus
|
||||
- A GoodWe **ES / BP family** inverter (AA55 generation)
|
||||
- The vendor's controller **disconnected** from the inverter's EMS port
|
||||
- A control board: a T-CAN485 (ESP32) flashed with
|
||||
`firmware/goodwe-master.yaml`
|
||||
- A grid-power sensor already working in Home Assistant, updating every ~5–10 s
|
||||
|
||||
@@ -22,16 +22,13 @@ from . import arbitrage, messages, peakguard, uitext
|
||||
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
|
||||
from .maintenance import IDLE as M_IDLE, next_slot_start
|
||||
from .packmode import CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, SOLAR_FIRST
|
||||
from .price import Absence, Uncertainty, brussels
|
||||
from .price import Absence, Uncertainty, brussels, _cover_table
|
||||
from .static_tariff import static_plan
|
||||
from .tariff import BILLING_TZ
|
||||
from .uimodel import REGION_PRESETS, options_to_ui
|
||||
|
||||
# A battery at or under this many watts is resting, not charging or giving.
|
||||
IDLE_W = 50.0
|
||||
# Display only: the lowest third of today's price range is "cheap", the top
|
||||
# third "expensive". A price range under this is flat, and nothing is marked.
|
||||
FLAT_EUR_KWH = 0.01
|
||||
|
||||
# Severity words the page shows; the same four as STYLE.md section 3.
|
||||
ALARM, WARNING, INFO = messages.ALARM, messages.WARNING, messages.INFO
|
||||
@@ -142,55 +139,103 @@ class DayPrices:
|
||||
def __init__(self):
|
||||
self.day = None
|
||||
self.points = {}
|
||||
self.kinds = {}
|
||||
|
||||
def see(self, points, day0, day1):
|
||||
if self.day != day0:
|
||||
self.day, self.points = day0, {}
|
||||
self.day, self.points, self.kinds = day0, {}, {}
|
||||
for p in points:
|
||||
if day0 <= p.start < day1:
|
||||
self.points[p.start] = p
|
||||
return sorted(self.points.values(), key=lambda p: p.start)
|
||||
|
||||
|
||||
def hour_bars(points, day0, day1):
|
||||
"""One bar per local hour: the mean of its prices, or None when unknown.
|
||||
`kind` is cheap / dear / mid by thirds of today's range (display only).
|
||||
The page calls these "low price" and "high price", not cheap hours: the
|
||||
add-on trades in the one cheapest and one dearest hour, not in a third."""
|
||||
n = int(round(_hours(day1, day0)))
|
||||
def _bar_step(pts):
|
||||
"""The series' own spacing: its smallest gap, never over an hour."""
|
||||
gaps = [y.start - x.start for x, y in zip(pts, pts[1:])
|
||||
if y.start > x.start]
|
||||
return min(gaps + [timedelta(hours=1)])
|
||||
|
||||
|
||||
def _bar_cover(pts):
|
||||
"""`[(start, end, point)]`: what each point is the price of on the chart.
|
||||
The add-on's own rule, not a copy: `price._cover_table()` (TARIFF-14), so
|
||||
a point covers until the next one starts and never over an hour, and the
|
||||
last one for the span of the one before it."""
|
||||
return _cover_table(pts)
|
||||
|
||||
|
||||
def price_bars(points, day0, day1, windows=(), now=None, kept=None):
|
||||
"""One bar per price step of the day: a quarter for a 15-minute series
|
||||
(96; 92 and 100 on the clock-change days), an hour for an hourly one or a
|
||||
static tariff. A bar is the price of the point that covers it, never an
|
||||
average, and a step with no price is a bar with `eur_kwh` None.
|
||||
`kind` is "cheap" or "dear" where `windows` (`[(kind, start, end)]`, the
|
||||
runs the add-on itself uses) cover the bar's start, else "mid". A bar that
|
||||
is over keeps the kind it had while it was still ahead (`kept`, a dict
|
||||
that lives a day). Display only."""
|
||||
pts = sorted(points, key=lambda p: p.start)
|
||||
step = _bar_step(pts)
|
||||
cover = _bar_cover(pts)
|
||||
n = int((day1 - day0) / step)
|
||||
bars = []
|
||||
for k in range(n):
|
||||
a, b = day0 + timedelta(hours=k), day0 + timedelta(hours=k + 1)
|
||||
inside = [p for p in points if a <= p.start < b]
|
||||
price = (sum(p.eur_per_kwh for p in inside) / len(inside)
|
||||
if inside else None)
|
||||
a = day0 + k * step
|
||||
z = a + step
|
||||
hit = next((p for s, e, p in cover if s <= a < e), None)
|
||||
kind = None
|
||||
if hit is not None:
|
||||
if kept is not None and now is not None and z <= now:
|
||||
kind = kept.get(a, "mid")
|
||||
else:
|
||||
kind = next((w[0] for w in windows if w[1] <= a < w[2]),
|
||||
"mid")
|
||||
if kept is not None:
|
||||
kept[a] = kind
|
||||
bars.append({"h": k, "start": a.isoformat(), "label": hhmm(a),
|
||||
"end_label": hhmm(b), "points": len(inside),
|
||||
"eur_kwh": None if price is None else round(price, 4),
|
||||
"estimate": bool(inside) and any(p.provisional
|
||||
for p in inside),
|
||||
"kind": None})
|
||||
known = [b["eur_kwh"] for b in bars if b["eur_kwh"] is not None]
|
||||
if known and max(known) - min(known) >= FLAT_EUR_KWH:
|
||||
lo, hi = min(known), max(known)
|
||||
third = (hi - lo) / 3.0
|
||||
for b in bars:
|
||||
p = b["eur_kwh"]
|
||||
if p is None:
|
||||
continue
|
||||
b["kind"] = ("cheap" if p <= lo + third else
|
||||
"dear" if p >= hi - third else "mid")
|
||||
elif known:
|
||||
for b in bars:
|
||||
if b["eur_kwh"] is not None:
|
||||
b["kind"] = "mid"
|
||||
"end_label": hhmm(z),
|
||||
"eur_kwh": None if hit is None
|
||||
else round(hit.eur_per_kwh, 4),
|
||||
"estimate": hit is not None and bool(hit.provisional),
|
||||
"kind": kind})
|
||||
return bars
|
||||
|
||||
|
||||
def _price_mode_in_use(c):
|
||||
"""True when at least one battery is on and uses a price mode. The runs
|
||||
belong to those batteries; with all on Solar first or off, nothing acts
|
||||
on a price."""
|
||||
for inv in c.inverters:
|
||||
pm = c.pack_mode(inv)
|
||||
if pm.use and pm.mode in PRICE_MODES:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def price_windows(c, plan, verdict):
|
||||
"""`[(kind, start, end)]`: where the add-on itself buys ("cheap") and
|
||||
gives ("dear"). With battery modes on that is the kept `priceruns.RunPlan`
|
||||
(`c.mode_runs`): the cheap run when charging in it pays, and every give
|
||||
run. With them off it is the arbitrage verdict's two points, the ones
|
||||
`trade_runs()` draws. Nothing is classified here."""
|
||||
if getattr(c, "modes_on", False):
|
||||
rp = getattr(c, "mode_runs", None)
|
||||
if rp is None or not rp.on or not _price_mode_in_use(c):
|
||||
return []
|
||||
out = [("cheap", rp.cheap.start, rp.cheap.end)] \
|
||||
if rp.charge_useful() else []
|
||||
return out + [("dear", g.start, g.end) for g in rp.give]
|
||||
if verdict is None:
|
||||
return []
|
||||
return [("cheap", verdict.cheapest.start,
|
||||
_point_end(plan, verdict.cheapest)),
|
||||
("dear", verdict.dearest.start,
|
||||
_point_end(plan, verdict.dearest))]
|
||||
|
||||
|
||||
def day_range(points):
|
||||
"""(lowest, highest) of today's published points, as {eur_kwh, label},
|
||||
or (None, None). The feed's own points, like the trade legs, not the
|
||||
chart's hour averages."""
|
||||
or (None, None). The feed's own points, like the trade legs."""
|
||||
firm = [p for p in points if not p.provisional]
|
||||
if not firm:
|
||||
return None, None
|
||||
@@ -551,13 +596,15 @@ def build_plan(c, now, shadow, running, held=False, settled=True):
|
||||
plan = static
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
seen = _day_prices(c).see(_points(plan), day0, day1)
|
||||
bars = hour_bars(seen, day0, day1)
|
||||
days = _day_prices(c)
|
||||
seen = days.see(_points(plan), day0, day1)
|
||||
|
||||
limit_w = c.peak.cap_w # the limit the controller parsed at start
|
||||
runs, lines = [], []
|
||||
# The control cycle trades on the fresh plan only, so this does too.
|
||||
verdict, trade_why = trade_verdict(c, fresh)
|
||||
bars = price_bars(seen, day0, day1, price_windows(c, fresh, verdict),
|
||||
now, days.kinds)
|
||||
modes_on = bool(getattr(c, "modes_on", False))
|
||||
if running:
|
||||
off = {i for i, inv in enumerate(c.inverters) if _off(c, inv)}
|
||||
@@ -1431,3 +1478,10 @@ def _samples() -> list:
|
||||
("not_covered", "no_prices", "peak", "not_firm", "not_worth",
|
||||
"no_level")]
|
||||
return out
|
||||
|
||||
|
||||
def priced_hours(bars):
|
||||
"""How many clock hours of the day have a price: distinct hours among the
|
||||
bars that have one, so 96 quarters count 24, not 96. The setup wizard's
|
||||
"N of 24 hours". A 23-hour day gives 23, a 25-hour day 24."""
|
||||
return len({b["label"][:2] for b in bars if b.get("eur_kwh") is not None})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
WHAT THIS THING DOES, in one paragraph: it reads the house's net grid power from
|
||||
Home Assistant, decides how hard the battery should charge or discharge to hold
|
||||
that at zero, and writes that figure to an ESP32 which puts it on the inverter's
|
||||
RS485 meter bus. Once a month it runs a full low->full battery cycle so the BMS
|
||||
RS485 EMS port. Once a month it runs a full low->full battery cycle so the BMS
|
||||
can balance cells and recalibrate its coulomb counter.
|
||||
|
||||
⚠️ THE ONE THING TO UNDERSTAND BEFORE CHANGING ANY OF THIS: the inverter holds
|
||||
|
||||
@@ -29,7 +29,7 @@ _LOG = logging.getLogger("goodwe.mqtt")
|
||||
DEVICE = {
|
||||
"identifiers": ["goodwe_rs485_controller"],
|
||||
"name": "GoodWe",
|
||||
"manufacturer": "GoodWe (via RS485 meter emulation)",
|
||||
"manufacturer": "GoodWe (via RS485 EMS port)",
|
||||
"model": "ES/BP series",
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,8 @@ h2{font-size:15px;margin:0 0 10px;font-weight:650}
|
||||
.bars i.cheap{background:var(--cheap)}
|
||||
/* not by colour alone: expensive hours are striped, estimates are faded */
|
||||
.bars i.dear{background:repeating-linear-gradient(135deg,var(--dear) 0 4px,color-mix(in srgb,var(--dear) 60%,var(--card)) 4px 7px)}
|
||||
.bars.fine{gap:1px}
|
||||
.bars.fine i{border-radius:1px 1px 0 0}
|
||||
.bars i.est{opacity:.55}
|
||||
.bars i.none{background:none;border-bottom:2px dotted var(--faint);height:2px!important}
|
||||
.lane{position:relative;height:18px;margin-top:4px;background:var(--bg);border-radius:4px}
|
||||
|
||||
@@ -177,11 +177,12 @@
|
||||
const known = p.prices.filter((b) => b.eur_kwh != null);
|
||||
const mx = Math.max(0.01, ...known.map((b) => Math.abs(b.eur_kwh)));
|
||||
bars.setAttribute("aria-label", t("plan.name"));
|
||||
bars.classList.toggle("fine", p.prices.length > 48);
|
||||
bars.replaceChildren(...p.prices.map((b) => {
|
||||
const title = b.label + " " + t("plan.time_to") + " " + b.end_label;
|
||||
if (b.eur_kwh == null) return chartItem("i", "none", "", title, [t("plan.none_tip") + "."]);
|
||||
const kind = b.kind === "cheap" ? ", " + t("plan.cheap_tip") : b.kind === "dear" ? ", " + t("plan.dear_tip") : "";
|
||||
const est = (b.points > 1 ? ", " + t("plan.average_tip") : "") + (b.estimate ? ", " + t("plan.estimate_tip") : "");
|
||||
const est = (b.estimate ? ", " + t("plan.estimate_tip") : "");
|
||||
return chartItem("i", (b.kind || "mid") + (b.estimate ? " est" : ""),
|
||||
"height:" + Math.max(4, Math.round(Math.abs(b.eur_kwh) / mx * 100)) + "%",
|
||||
title, [eur(b.eur_kwh) + " " + t("plan.per_kwh") + kind + est + "."]);
|
||||
|
||||
@@ -66,8 +66,7 @@
|
||||
"estimate_tip": "estimate from yesterday",
|
||||
"none_tip": "No price known",
|
||||
"no_prices": {"description": "No prices for today, so the chart shows only the plan."},
|
||||
"list": "The plan in words",
|
||||
"average_tip": "hour average"
|
||||
"list": "The plan in words"
|
||||
},
|
||||
"peak": {"name": "Peak limit", "this_month": "This month's peak", "limit": "Limit", "none": "None recorded yet", "unreadable": "Cannot be read now"},
|
||||
"attention": {"name": "Needs attention", "nothing": "Nothing to look at", "to_look": "to look at", "details": "Details", "alarm": "Alarm", "warning": "Warning", "info": "Info"},
|
||||
|
||||
@@ -63,8 +63,7 @@
|
||||
"estimate_tip": "schatting op basis van gisteren",
|
||||
"none_tip": "Geen prijs bekend",
|
||||
"no_prices": {"description": "Geen prijzen voor vandaag, dus de grafiek toont alleen het plan."},
|
||||
"list": "Het plan in woorden",
|
||||
"average_tip": "gemiddelde van het uur"
|
||||
"list": "Het plan in woorden"
|
||||
},
|
||||
"peak": {"name": "Piekgrens", "this_month": "Piek van deze maand", "limit": "Grens", "none": "Nog niets gemeten", "unreadable": "Nu niet leesbaar"},
|
||||
"attention": {"name": "Vraagt aandacht", "nothing": "Niets om na te kijken", "to_look": "na te kijken", "details": "Details", "alarm": "Alarm", "warning": "Waarschuwing", "info": "Info"},
|
||||
|
||||
@@ -50,7 +50,7 @@ from aiohttp import web
|
||||
|
||||
from . import messages, price, selfconfig, uipage, uitext
|
||||
from .haentities import EntityCache
|
||||
from .homeview import region_of
|
||||
from .homeview import priced_hours, region_of
|
||||
from .validate import config_notes, setup_checks
|
||||
from .config_page import CONFIG_PAGE
|
||||
from .configui import (changed_keys, field_model, hide_secrets, keep_secrets,
|
||||
@@ -328,7 +328,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
try:
|
||||
prices = (controller.status_payload().get("plan") or {}).get(
|
||||
"prices") or []
|
||||
known = sum(1 for p in prices if p.get("eur_kwh") is not None)
|
||||
known = priced_hours(prices)
|
||||
except Exception: # noqa: BLE001 - a display figure, never a failure
|
||||
known = None
|
||||
return setup_checks(options, entities=rows, running=controller.o,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.18.1"
|
||||
version: "0.18.2"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||
meter, holding net grid exchange at zero and running a monthly battery
|
||||
Drives a GoodWe ES/BP battery inverter over its RS485 EMS port by sending it a
|
||||
grid reading, holding net grid exchange at zero and running a monthly battery
|
||||
maintenance cycle.
|
||||
url: https://gittea.kammenstraatha.duckdns.org/admin/goodwe-addon
|
||||
arch:
|
||||
|
||||
@@ -33,7 +33,9 @@ from app import homeview, web
|
||||
from app.main import Controller
|
||||
from app.peakguard import PeakDecision
|
||||
from app.price import PricePoint, PriceProduct, PriceRead
|
||||
from app.static_tariff import TariffPlan
|
||||
from app.static_tariff import TariffPlan, static_plan
|
||||
from app import packmode
|
||||
from app.store import unit_key
|
||||
from app.uimodel import REGION_PRESETS
|
||||
from named_absence import field, nth, present
|
||||
|
||||
@@ -152,6 +154,14 @@ def quiet_guard(projected_w=1240.0):
|
||||
ceiling_from="cap", projected_w=projected_w)
|
||||
|
||||
|
||||
def set_modes(c, *modes):
|
||||
"""File each battery's mode in the store (None leaves the default, which
|
||||
follows the house)."""
|
||||
for inv, m in zip(c.inverters, modes):
|
||||
if m is not None:
|
||||
c.store.data[unit_key(inv.name, packmode.MODE_KEY)] = m
|
||||
|
||||
|
||||
def status(c, now=NOW):
|
||||
s = c.status_payload()
|
||||
# The status route builds at wall-clock time; rebuild the structured half
|
||||
@@ -317,7 +327,7 @@ check("a plan with no published prices yet says so, not 'the price gap is "
|
||||
_t == [] and homeview.line_no_trade("not_firm") in _l
|
||||
and homeview.line_no_trade("not_worth") not in _l)
|
||||
check("the price bars are today's 24 hours, from the real series, with the "
|
||||
"cheap and expensive thirds marked",
|
||||
"cheap and dear hour marked",
|
||||
[b["eur_kwh"] for b in s_arb["plan"]["prices"]] == MOCK
|
||||
and s_arb["plan"]["prices"][13]["kind"] == "cheap"
|
||||
and s_arb["plan"]["prices"][19]["kind"] == "dear"
|
||||
@@ -518,10 +528,269 @@ check("quarter-hour prices: the charge block, its price and the price line's "
|
||||
and f"Today: lowest €{_lo.eur_per_kwh:.2f} at "
|
||||
f"{homeview.hhmm(_lo.start)}," in s_q["plan"]["price_line"]
|
||||
and "the list" not in json.dumps(s_q["plan"]))
|
||||
check("...and the hour bar says it is an hour average",
|
||||
s_q["plan"]["prices"][13]["points"] == 4
|
||||
and '"plan.average_tip"' in io.open(os.path.join(
|
||||
HERE, "app", "static", "app.js"), encoding="utf-8").read())
|
||||
|
||||
# UX-45: the chart draws the steps the add-on decides on.
|
||||
_pq = s_q["plan"]["prices"]
|
||||
check("UX-45: 96 quarter prices -> 96 bars, each one quarter, labelled "
|
||||
"from its own start, with its own price and no average",
|
||||
len(_pq) == 96 and _pq[0]["label"] == "00:00"
|
||||
and _pq[0]["end_label"] == "00:15" and _pq[57]["label"] == "14:15"
|
||||
and _pq[57]["end_label"] == "14:30"
|
||||
and [b["eur_kwh"] for b in _pq] == [round(p.eur_per_kwh, 4)
|
||||
for p in _q[:96]]
|
||||
and all("points" not in b for b in _pq)
|
||||
and s_q["plan"]["hours"] == 24)
|
||||
check("...the quarters of one hour differ (the chart shows the steps)",
|
||||
len({b["eur_kwh"] for b in _pq[52:56]}) > 1)
|
||||
check("...and the page has no 'hour average' tip any more",
|
||||
"average_tip" not in io.open(os.path.join(
|
||||
HERE, "app", "static", "app.js"), encoding="utf-8").read()
|
||||
and "average_tip" not in io.open(os.path.join(
|
||||
HERE, "app", "ui_text", "en.json"), encoding="utf-8").read()
|
||||
and "average_tip" not in io.open(os.path.join(
|
||||
HERE, "app", "ui_text", "nl.json"), encoding="utf-8").read())
|
||||
_cheap_q = [b["start"] for b in _pq if b["kind"] == "cheap"]
|
||||
_dear_q = [b["start"] for b in _pq if b["kind"] == "dear"]
|
||||
_hi = max(_q[:96], key=lambda p: p.eur_per_kwh)
|
||||
check("...without battery modes the marks are the two quarters the "
|
||||
"arbitrage verdict trades in, and no others",
|
||||
_cheap_q == [_lo.start.isoformat()] and _dear_q == [_hi.start.isoformat()]
|
||||
and sum(1 for b in _pq if b["kind"] == "mid") == 94)
|
||||
|
||||
# Battery modes on: the marks ARE the kept priceruns result.
|
||||
from app import priceruns
|
||||
_rp = priceruns.plan(tuple(_q), NOW, required_eur_kwh=0.05, charge_kwh=2.0,
|
||||
charge_kw=4.0, sell_kwh=0.0, sell_kw=0.0)
|
||||
c_m = make(ARB)
|
||||
c_m.tariff_plan, c_m.tariff_at = c_q.tariff_plan, NOW
|
||||
c_m.peak_guard = quiet_guard()
|
||||
c_m.modes_on, c_m.mode_runs = True, _rp
|
||||
set_modes(c_m, packmode.SOLAR_FIRST, packmode.CHEAP_HOURS)
|
||||
_pm = status(c_m)["plan"]["prices"]
|
||||
_today = [(a, z) for a, z, _p in priceruns.firm_slots(_q, NOW)
|
||||
if a < DAY0 + timedelta(hours=24)]
|
||||
_want_c = {a.isoformat() for a, z in _today
|
||||
if _rp.cheap.start <= a < _rp.cheap.end}
|
||||
_want_d = {a.isoformat() for g in _rp.give for a, z in _today
|
||||
if g.start <= a < g.end}
|
||||
check("UX-45: with battery modes on, the cheap and dear quarters are the "
|
||||
"priceruns cheap run and give runs for the same input",
|
||||
_rp.on and _rp.charge_useful() and _want_c and _want_d
|
||||
and {b["start"] for b in _pm if b["kind"] == "cheap"} == _want_c
|
||||
and {b["start"] for b in _pm if b["kind"] == "dear"} == _want_d
|
||||
and len(_pm) == 96)
|
||||
c_m2 = make(ARB)
|
||||
c_m2.tariff_plan, c_m2.tariff_at = c_q.tariff_plan, NOW
|
||||
c_m2.peak_guard = quiet_guard()
|
||||
c_m2.modes_on = True
|
||||
set_modes(c_m2, packmode.CHEAP_HOURS, None)
|
||||
c_m2.mode_runs = priceruns.plan(tuple(_q), NOW, required_eur_kwh=5.0,
|
||||
charge_kwh=2.0, charge_kw=4.0, sell_kwh=0.0,
|
||||
sell_kw=0.0)
|
||||
check("...a run plan that says no trade marks nothing",
|
||||
not c_m2.mode_runs.on
|
||||
and {b["kind"] for b in status(c_m2)["plan"]["prices"]} == {"mid"})
|
||||
|
||||
# A quarter that is over keeps the mark it had while it was ahead.
|
||||
# (`status()` also builds at wall-clock time, which starts the day's memory
|
||||
# over, so the two builds below go straight to `homeview.build`.)
|
||||
_later = NOW + timedelta(hours=4)
|
||||
_s0 = c_q.status_payload()
|
||||
homeview.build(c_q, level=_s0["level"], checks=_s0["checks"], now=NOW)
|
||||
c_q.tariff_at = _later
|
||||
_pl = homeview.build(c_q, level=_s0["level"], checks=_s0["checks"],
|
||||
now=_later)["plan"]["prices"]
|
||||
check("...and a quarter already over keeps the mark it had while ahead",
|
||||
[b["kind"] for b in _pl if b["start"] == _lo.start.isoformat()]
|
||||
== ["cheap"]
|
||||
and [b["kind"] for b in _pl if b["label"] == "19:00"] == ["dear"])
|
||||
c_q.tariff_at = NOW
|
||||
|
||||
# UX-45 round 2 (F2): a hole is covered by the add-on's own rule,
|
||||
# `price._cover_table()`: the point before it runs on, never over an hour.
|
||||
def _hole_bars(missing):
|
||||
pts = [PricePoint(DAY0 + timedelta(minutes=15 * i), .10 + .001 * i, False)
|
||||
for i in range(96)]
|
||||
gone = {DAY0 + timedelta(hours=10, minutes=15 * (1 + j))
|
||||
for j in range(missing)}
|
||||
pts = [p for p in pts if p.start not in gone]
|
||||
return homeview.price_bars(pts, DAY0, DAY0 + timedelta(hours=24))
|
||||
|
||||
|
||||
_gap = {}
|
||||
for _n in (1, 2, 3, 4, 5):
|
||||
_bb = _hole_bars(_n)
|
||||
_gap[_n] = ([b["eur_kwh"] is None for b in _bb[41:41 + _n]],
|
||||
len(_bb))
|
||||
_b1 = _hole_bars(1)
|
||||
check("UX-45: 1, 2 or 3 missing quarters show the price before them (the "
|
||||
"add-on's own cover rule), and nothing shifts",
|
||||
all(len(_hole_bars(n)) == 96 for n in (1, 2, 3))
|
||||
and _gap[1][0] == [False] and _gap[2][0] == [False, False]
|
||||
and _gap[3][0] == [False, False, False]
|
||||
and [b["eur_kwh"] for b in _hole_bars(3)[40:44]]
|
||||
== [_hole_bars(3)[40]["eur_kwh"]] * 4
|
||||
and _b1[42]["label"] == "10:30" and _b1[43]["label"] == "10:45"
|
||||
and _b1[43]["eur_kwh"] == round(.10 + .001 * 43, 4))
|
||||
check("...4 missing quarters leave 1 bar empty, 5 leave 2: a hole over an "
|
||||
"hour is not filled past the hour",
|
||||
_gap[4][0] == [False, False, False, True]
|
||||
and _gap[5][0] == [False, False, False, True, True]
|
||||
and sum(b["eur_kwh"] is None for b in _hole_bars(4)) == 1
|
||||
and sum(b["eur_kwh"] is None for b in _hole_bars(5)) == 2)
|
||||
|
||||
# A cheap run that is not worth charging in is not marked; an estimate is.
|
||||
import types
|
||||
_pts = [PricePoint(DAY0 + timedelta(minutes=15 * i),
|
||||
.30 if i < 4 else .10, False) for i in range(8)]
|
||||
_rp2 = priceruns.plan(tuple(_pts), DAY0, required_eur_kwh=0.05,
|
||||
charge_kwh=0.5, charge_kw=4.0, sell_kwh=0.0, sell_kw=0.0)
|
||||
c_w2 = make(ARB)
|
||||
c_w2.modes_on, c_w2.mode_runs = True, _rp2
|
||||
set_modes(c_w2, packmode.LEFTOVER_SOLAR, None)
|
||||
_w2 = homeview.price_windows(c_w2, None, None)
|
||||
check("UX-45: a cheap run that comes after every dear slot is not worth a "
|
||||
"charge, so it is not marked (the dear run is)",
|
||||
_rp2.on and _rp2.cheap is not None and not _rp2.charge_useful()
|
||||
and [w[0] for w in _w2] == ["dear"])
|
||||
_est = [PricePoint(DAY0 + timedelta(minutes=15 * i), .2, False)
|
||||
for i in range(80)] + [PricePoint(DAY0 + timedelta(hours=20), .25,
|
||||
True)]
|
||||
_be = homeview.price_bars(_est, DAY0, DAY0 + timedelta(hours=24))
|
||||
check("UX-45: a carried (estimate) price after quarter prices is the last "
|
||||
"point, so it covers one quarter (the add-on's tail rule), as an "
|
||||
"estimate bar; the rest stays empty",
|
||||
[b["estimate"] for b in _be[78:83]] == [False, False, True, False, False]
|
||||
and _be[80]["eur_kwh"] == .25 and _be[81]["eur_kwh"] is None)
|
||||
_est2 = _est[:80] + [PricePoint(DAY0 + timedelta(hours=20 + k), .25, True)
|
||||
for k in range(3)]
|
||||
_be2 = homeview.price_bars(_est2, DAY0, DAY0 + timedelta(hours=24))
|
||||
check("...carried hourly points one after another cover an hour each",
|
||||
[b["eur_kwh"] for b in _be2[80:92]] == [.25] * 12
|
||||
and _be2[92]["eur_kwh"] is None)
|
||||
|
||||
# Hourly prices -> 24 bars (above); a day/night tariff -> 24 bars.
|
||||
_ph1 = s_arb["plan"]["prices"]
|
||||
check("UX-45: hourly prices -> 24 hour bars with the hour's own price",
|
||||
len(_ph1) == 24 and _ph1[13]["end_label"] == "14:00"
|
||||
and "points" not in _ph1[13])
|
||||
c_dn = make(dict(ARB, static_profile=(
|
||||
'{"periods": [{"name":"day","from":"07:00","to":"22:00","days":"all",'
|
||||
'"eur_per_kwh":0.34},{"name":"night","from":"22:00","to":"07:00",'
|
||||
'"days":"all","eur_per_kwh":0.26}]}')))
|
||||
c_dn.peak_guard = quiet_guard()
|
||||
_pd = status(c_dn)["plan"]["prices"]
|
||||
check("UX-45: a day/night tariff -> 24 bars, 0.26 at night and 0.34 by day",
|
||||
len(_pd) == 24
|
||||
and [b["eur_kwh"] for b in _pd]
|
||||
== [.26] * 7 + [.34] * 15 + [.26] * 2
|
||||
and all(b["kind"] == "mid" for b in _pd))
|
||||
|
||||
# Clock-change days: 92 and 100 quarters.
|
||||
_d0 = datetime(2026, 3, 28, 23, 0, tzinfo=timezone.utc)
|
||||
_d1 = datetime(2026, 3, 29, 22, 0, tzinfo=timezone.utc)
|
||||
_sp = [PricePoint(_d0 + timedelta(minutes=15 * i), .1, False)
|
||||
for i in range(92)]
|
||||
check("UX-45: a 23-hour day has 92 quarter bars, a 25-hour day 100",
|
||||
len(homeview.price_bars(_sp, _d0, _d1)) == 92
|
||||
and len(homeview.price_bars(
|
||||
_sp, _d0, _d0 + timedelta(hours=25))) == 100)
|
||||
|
||||
_f0 = datetime(2026, 10, 24, 22, 0, tzinfo=timezone.utc) # 25-hour day
|
||||
_fb = [PricePoint(_f0 + timedelta(minutes=15 * i), .1, False)
|
||||
for i in range(100)]
|
||||
|
||||
# F5: the day/night chart with the stored static plan, as in production
|
||||
# (the cycle stores `static_plan()` when the provider is off).
|
||||
_PROF = ('{"periods": [{"name":"day","from":"07:00","to":"22:00",'
|
||||
'"days":"all","eur_per_kwh":0.34},{"name":"night","from":"22:00",'
|
||||
'"to":"07:00","days":"all","eur_per_kwh":0.26}]}')
|
||||
|
||||
|
||||
def _dn(arb):
|
||||
c = make(dict(ARB if arb else TWO, static_profile=_PROF))
|
||||
c.peak_guard = quiet_guard()
|
||||
c.tariff_plan = static_plan(c.o, NOW, NOW + timedelta(hours=36), now=NOW)
|
||||
c.tariff_at = NOW
|
||||
return c, status(c)["plan"]["prices"]
|
||||
|
||||
|
||||
_c_off, _p_off = _dn(False)
|
||||
_c_on, _p_on = _dn(True)
|
||||
_v_on, _why_on = homeview.trade_verdict(_c_on, _c_on.fresh_tariff_plan(NOW))
|
||||
check("UX-45: day/night tariff with the stored plan and price trading off: "
|
||||
"24 bars, no cheap or dear mark",
|
||||
len(_p_off) == 24 and {b["kind"] for b in _p_off} == {"mid"})
|
||||
check("...and with price trading on, the marks are the verdict's cheapest "
|
||||
"and dearest hour (one each)",
|
||||
_v_on is not None and len(_p_on) == 24
|
||||
and [datetime.fromisoformat(b["start"]) for b in _p_on
|
||||
if b["kind"] == "cheap"] == [_v_on.cheapest.start]
|
||||
and [datetime.fromisoformat(b["start"]) for b in _p_on
|
||||
if b["kind"] == "dear"] == [_v_on.dearest.start]
|
||||
and sum(b["kind"] == "mid" for b in _p_on) == 22)
|
||||
|
||||
# F3 / owner decision 2a: with battery modes on, marks only when a battery
|
||||
# uses a price mode.
|
||||
def _modes(*modes, use=(True, True)):
|
||||
c = make(ARB)
|
||||
c.tariff_plan, c.tariff_at = c_q.tariff_plan, NOW
|
||||
c.peak_guard = quiet_guard()
|
||||
c.modes_on, c.mode_runs = True, _rp
|
||||
set_modes(c, *modes)
|
||||
for inv, u in zip(c.inverters, use):
|
||||
c.store.data[unit_key(inv.name, packmode.USE_KEY)] = u
|
||||
return {b["kind"] for b in status(c)["plan"]["prices"]}
|
||||
|
||||
|
||||
_F, _L, _C = (packmode.SOLAR_FIRST, packmode.LEFTOVER_SOLAR,
|
||||
packmode.CHEAP_HOURS)
|
||||
check("UX-45: battery modes on, every battery follows the house: no marks",
|
||||
_modes(_F, _F) == {"mid"} and _modes(None, None) == {"mid"})
|
||||
check("...one battery on Cheap hours or Leftover solar: the marks show",
|
||||
{"cheap", "dear"} <= _modes(_F, _C) and {"cheap", "dear"} <= _modes(_L, _F))
|
||||
check("...a price mode on a battery that is switched off does not count",
|
||||
_modes(_F, _C, use=(True, False)) == {"mid"}
|
||||
and {"cheap", "dear"} <= _modes(_F, _C, use=(False, True)))
|
||||
|
||||
# F4: the day's memory of past marks starts over on a new day.
|
||||
_s4 = c_q.status_payload()
|
||||
homeview.build(c_q, level=_s4["level"], checks=_s4["checks"], now=NOW)
|
||||
_k0 = dict(c_q.home_prices.kinds)
|
||||
_tom = NOW + timedelta(days=1)
|
||||
c_q.tariff_at = _tom
|
||||
homeview.build(c_q, level=_s4["level"], checks=_s4["checks"], now=_tom)
|
||||
c_q.tariff_at = NOW
|
||||
check("UX-45: past marks start over on a new day (nothing of yesterday is "
|
||||
"kept)",
|
||||
len(_k0) > 0 and c_q.home_prices.day != DAY0
|
||||
and c_q.home_prices.kinds
|
||||
and all(k >= DAY0 + timedelta(days=1) for k in c_q.home_prices.kinds)
|
||||
and len(c_q.home_prices.kinds) <= 96)
|
||||
|
||||
# F1: the setup wizard counts hours, not bars.
|
||||
from app.validate import setup_checks
|
||||
_o = dict(ARB, price_provider="entsoe_be")
|
||||
_say = lambda n: [x for x in map(repr, setup_checks(
|
||||
_o, entities={}, running=_o, prices_known=n)) if "Prices for today" in x]
|
||||
check("UX-45: 96 quarter prices count as 24 hours in the setup wizard, "
|
||||
"not 96",
|
||||
homeview.priced_hours(_pq) == 24
|
||||
and homeview.priced_hours(s_arb["plan"]["prices"]) == 24
|
||||
and "for 24 of 24 hours" in "".join(_say(homeview.priced_hours(_pq)))
|
||||
and "96 of 24" not in "".join(_say(homeview.priced_hours(_pq))))
|
||||
check("...a half-priced day counts its hours; a hole of one quarter loses "
|
||||
"no hour",
|
||||
homeview.priced_hours(_pq[:48]) == 12
|
||||
and homeview.priced_hours(_hole_bars(5)) == 24
|
||||
and homeview.priced_hours([dict(b, eur_kwh=None) for b in _pq]) == 0)
|
||||
check("...a 23-hour day gives 23 and a 25-hour day 24 (clock hours)",
|
||||
homeview.priced_hours(homeview.price_bars(_sp, _d0, _d1)) == 23
|
||||
and homeview.priced_hours(homeview.price_bars(
|
||||
_fb, _f0, _f0 + timedelta(hours=25))) == 24)
|
||||
check("...and the wizard takes that count",
|
||||
"known = priced_hours(prices)" in io.open(os.path.join(
|
||||
HERE, "app", "web.py"), encoding="utf-8").read())
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. a fault in the Home view never costs the page its rows")
|
||||
|
||||
@@ -104,7 +104,7 @@ def check(name, cond):
|
||||
# release/1.0 rather than against release/1.0.
|
||||
# ---------------------------------------------------------------------------
|
||||
_DEV = ('{"identifiers": ["goodwe_rs485_controller"], "name": "GoodWe", '
|
||||
'"manufacturer": "GoodWe (via RS485 meter emulation)", '
|
||||
'"manufacturer": "GoodWe (via RS485 EMS port)", '
|
||||
'"model": "ES/BP series"}')
|
||||
|
||||
BASELINE = [
|
||||
@@ -442,7 +442,7 @@ for c_ in unit_cfgs:
|
||||
print(f" regression pin (sha256 of the eight payloads): {_pin}")
|
||||
check("the per-unit payloads are the ones this PR was reviewed with "
|
||||
"(REGRESSION PIN, not a correctness claim - see the comment)",
|
||||
_pin == "eb5022b0455158d7")
|
||||
_pin == "b4274714d8557c37")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n4. zero overlap between A's entities and B's")
|
||||
@@ -976,7 +976,7 @@ def announce_p2(units=(), omit=(), include=(), modes=(), retract=(),
|
||||
# `git show ed739d4:goodwe_controller/app/mqtt.py` and this loop.
|
||||
ROSTERS = [[], ["inverter-a"], ["inverter-a", "inverter-b"], ["a", "b", "c"],
|
||||
["inverter-b", "Inverter B", "x", "y"], ["café"]]
|
||||
ED739D4_OFF = "501a7d93782a260b"
|
||||
ED739D4_OFF = "057be1d8ad7bb582" # DOC-8: ed739d4 digest with only the manufacturer string changed
|
||||
_h, _n, _subs_off = hashlib.sha256(), 0, []
|
||||
for _r in ROSTERS:
|
||||
for _om in [(), ("p1_age",), ("p1_age", "grid")]:
|
||||
|
||||
Reference in New Issue
Block a user