Compare commits
19
Commits
master
...
SAFETY-119
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96375ecbec | ||
|
|
6fd0fa05f1 | ||
|
|
e27e62ff72 | ||
|
|
86b94bedb5 | ||
|
|
103db2fcda | ||
|
|
85542e443b | ||
|
|
e76c70ecb0 | ||
|
|
b9ff6acea4 | ||
|
|
98026f9a46 | ||
|
|
b0c2e5977d | ||
|
|
c4e15ec4cd | ||
|
|
75f0804f9d | ||
|
|
eb8375157d | ||
|
|
17c6048417 | ||
|
|
243a756e7b | ||
|
|
a4a7ff695e | ||
|
|
b8b357b552 | ||
|
|
4584502adb | ||
|
|
fd08090360 |
+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>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# SAFETY-119 - no "not keeping up" when the loop takes the wire back
|
||||
|
||||
**Status: for review.** Tests: `test_safety119.py`.
|
||||
|
||||
**Cause.** When another owner (the peak guard, a maintenance charge) has set
|
||||
the wire and the loop wins again, `cycle()` re-seeds the integrator from that
|
||||
owner's command, and `compute()` adds `gain * error` from a meter that does
|
||||
not show that command yet. On the TEST-23 rig (SAFETY-118 I-2) the guard
|
||||
stepped from -2000 W to +429 W with 5 s left. A cycle later the loop's ask
|
||||
crossed the +2500 W rail and beat the guard's floor, so SAFETY-85 rule 1
|
||||
counted it as the loop's own bound and raised `goodwe_integrator_bound`. The
|
||||
battery gave what it was told, and the cap held.
|
||||
|
||||
**Decision.** After a hand-back, the loop's integrator bound is not counted
|
||||
until the plant's settle has passed (`SHORT_CYCLES` cycles and
|
||||
`SHORT_SPAN_S`, the window SAFETY-85 grades a battery on). After that it
|
||||
counts as before. An over-rating (`clamped`, a bug) on a cycle the loop
|
||||
decided always counts. No command changes, and no text was added or changed.
|
||||
|
||||
**A rolling budget (round 3).** The settle above is per hand-back, and
|
||||
losing the wire restarts it. Across all hand-backs the bound is hidden on at
|
||||
most a budget of cycles in any 15 minutes (`ROLLING_BUDGET`, `ROLLING_S`); a
|
||||
cycle over the budget counts. Round 1 restarted the settle at every
|
||||
hand-back, so a guard and a charge taking turns hid the bound for good (0
|
||||
warnings in 2 h where 28 were raised before). Round 2 gave each run of
|
||||
hand-backs its own budget; at 10 s and 15 s meters each quarter then started
|
||||
a fresh one and the loop's bound was never counted. A quarter-edge kick is a
|
||||
few bound cycles in one quarter, a real bound that stays is many: a budget
|
||||
per 15 minutes tells them apart.
|
||||
|
||||
**The budget follows the cycle length.** 10 cycles up to a 3.5 s cycle, 8 up
|
||||
to 11 s, 6 up to 16 s, 4 beyond. The cycle length is the time between two
|
||||
decided cycles (a stop, or a gap over two minutes, is not one); until it is
|
||||
known, and without a monotonic clock, the budget is 8. Without a monotonic
|
||||
clock the budget never refills.
|
||||
|
||||
**Why not one number.** Measured over 2 h (`goodwe-addon-checks:1`), the
|
||||
quarter-edge case (house 800 W, cap 2500 W, no forecast, 30 seeds) and the
|
||||
case below. No single budget held both at 1 to 15 s: 3 to 6 let the false
|
||||
alarm back at 2 s; 7 on 10 of 30 seeds at 2 s; 8 on 1 of 30 (seed 25) at
|
||||
2 s, and warned in only 1 to 3 of 7 quarters at 15 s; 9 and 10 miss quarters
|
||||
at 10 s. A slow cycle needs a small budget to warn, a fast one a large budget
|
||||
to stay quiet. Per band, the largest budget that still warns: up to 3.5 s,
|
||||
10 (9 and 10 had no false alarm at 1 and 2 s; above 10 was not swept); up to
|
||||
11 s, 8 (9 misses quarters at 10 s); up to 16 s, 6 (7 leaves two quiet
|
||||
quarters in a row at 15 s, 4 false-alarms at 12 s on 7 of 30 seeds); beyond,
|
||||
4 (3 false-alarms at 20 and 30 s on 14 of 30, 5 leaves five quiet quarters at
|
||||
20 s).
|
||||
|
||||
**What holds.** Quarter-edge case, 30 seeds each: 0 notifications at 1, 2,
|
||||
3, 4, 5, 10, 12, 15, 20 and 30 s. Repeated hand-backs (2 x 2500 W,
|
||||
cheap-window charge, no usable forecast, cap 5000 W, house 5050 and 5100 W,
|
||||
constant and with noise, 8 runs per meter):
|
||||
|
||||
| meter | warnings in 2 h | quarters warned (of 7 after the first) | without the fix |
|
||||
|---|---|---|---|
|
||||
| 1 s | 56 to 63 | 7 | 56 to 63 |
|
||||
| 2 s | 49 to 56 | 7 | 54 to 56 |
|
||||
| 5 s | 19 to 23 | 7 | 28 |
|
||||
| 10 s | 7 to 10 | 7 | 14 |
|
||||
| 15 s | 4 to 10 | 4 to 7, never two quiet in a row | 14 |
|
||||
| 30 s | 3 to 7 | 3 to 5, up to two quiet in a row | 7 to 11 |
|
||||
|
||||
At 30 s no budget does better: 4 leaves two quiet quarters, 3 brings back
|
||||
the false alarm. Single hand-back, the loop at its rail, first warning after
|
||||
the hand-back compared with no fix: 10 s at 1 s, 20 s at 2 s, 40 s at 5 s
|
||||
(default), 80 s at 10 s, 90 s at 15 s, 80 s at 20 s, 120 s at 30 s, 240 s at
|
||||
60 s. At 60 s the rig is a different regime (the house is not over the cap
|
||||
in the case above), so nothing more is claimed there. The cap held at 1 to
|
||||
10 s in the quarter-edge case and at 1 to 5 s in the case above; at slower
|
||||
meters the meter's lag puts quarters over it, the same with and without the
|
||||
fix (commands are identical).
|
||||
|
||||
**Limits.** These runs have one cycle per meter reading, at a steady
|
||||
interval. A 20 or 30 s cycle needs `cycle_floor_s` raised; at the default
|
||||
15 s, a slower meter adds cycles on an old reading. Measured that way (11
|
||||
seeds): a 15 s meter that varies by up to 30 % false-alarmed on 5 seeds, a
|
||||
30 s meter on 10 (0 to 4 in 2 h, 8 to 15 without the fix); a 20 s meter on
|
||||
none. The case above still warned every quarter there.
|
||||
|
||||
**Kept.** The loop in charge at its rail warns on the same cycle as before.
|
||||
The start and a restart after a stop are not hand-backs. The quarter-edge
|
||||
kick of `test_safety119.py` part 1 stays hidden (see above).
|
||||
|
||||
**Re-pinned.** test_safety84 (all six) and test_safety87 (`n1_crossing`,
|
||||
`n1_above`, `n1_charge_low`): only OUTPUT_CLAMP log rows differ; commands and
|
||||
claims are the same (`test_safety119.py` part 6).
|
||||
|
||||
**Not hidden.** A battery that gives less than another owner told it still
|
||||
alarms through SAFETY-85 rule 2. "Always counts" for an over-rating holds on
|
||||
cycles the loop decided; on others SAFETY-85 rule 1 already dropped it.
|
||||
|
||||
**Not changed.** The kick itself is control behaviour: for a few seconds at
|
||||
a quarter's edge the loop asks up to its rail. In `test_safety119.py` part 1
|
||||
that is more discharge than the guard asked at a quarter's end, and a few
|
||||
seconds of up to 500 W more charge at the next quarter's start.
|
||||
@@ -1,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
|
||||
@@ -299,6 +299,14 @@ REFUSED_PROGRESS_W = _ctl.PROBE_ANSWER_W / 2
|
||||
# was told over the same span as above (the plant's measured settle time), so
|
||||
# a healthy battery still settling on a new command is not short.
|
||||
SHORT_CYCLES, SHORT_SPAN_S = _ctl.SETTLE_CYCLES, REFUSED_SPAN_S
|
||||
# SAFETY-119: across all hand-backs, the loop's bound is hidden on at most
|
||||
# ROLLING_BUDGET cycles in any ROLLING_S seconds, fewer on a slower cycle:
|
||||
# (cycle seconds up to, cycles), measured in
|
||||
# DECISIONS/SAFETY-119-bound-after-hand-back.md. Without a monotonic clock
|
||||
# the budget never refills.
|
||||
ROLLING_S = 900.0
|
||||
ROLLING_BUDGET = ((3.5, 10), (11.0, 8), (16.0, 6), (float("inf"), 4))
|
||||
ROLLING_BOUND_CYCLES = 8 # the budget while the cycle length is unknown
|
||||
|
||||
|
||||
def _refused_interval(sign: int) -> tuple:
|
||||
@@ -1272,6 +1280,14 @@ class Controller:
|
||||
self._loop_decision = None
|
||||
self._asked_hist: dict = {}
|
||||
self._short_run = 0
|
||||
# SAFETY-119: did another owner set the wire last cycle; the window
|
||||
# after a hand-back (monotonic s, the loop's cycles in a row); the
|
||||
# monotonic s of the bound cycles hidden in the last ROLLING_S; the
|
||||
# last cycle's monotonic s and the cycle length.
|
||||
self._wire_lost = False
|
||||
self._handback = None
|
||||
self._hidden = ()
|
||||
self._settle_t, self._settle_dt = None, None
|
||||
self.stopping = False
|
||||
# SAFETY-61. time.monotonic() at the end of the last run_control() pass.
|
||||
self.loop_pass_mono = None
|
||||
@@ -5813,6 +5829,8 @@ class Controller:
|
||||
if d is None:
|
||||
self._short_run = 0
|
||||
self._asked_hist = {}
|
||||
self._wire_lost, self._handback = False, None
|
||||
self._settle_t = None # a stop is not a cycle length
|
||||
return
|
||||
unmet = self.allocation.unmet_w if self.allocation is not None else 0
|
||||
cut = (resolution.bound_by or "").split("(")[0]
|
||||
@@ -5838,7 +5856,9 @@ class Controller:
|
||||
and delivery_short(asked[inv.name], inv.batt,
|
||||
self.tuning.saturation_w)]
|
||||
self._short_run = self._short_run + 1 if short else 0
|
||||
if owns and self.loop_clamped[0]:
|
||||
settling = self._settling(owns, now)
|
||||
if owns and self.loop_clamped[0] and not (
|
||||
settling and not self.loop_clamped[2]):
|
||||
return
|
||||
engaged = self._short_run >= self.tuning.saturation_cycles
|
||||
named = len(self.inverters) > 1
|
||||
@@ -5847,6 +5867,49 @@ class Controller:
|
||||
if engaged else "", False)
|
||||
self.loop_clamp_facts = ()
|
||||
|
||||
def _settling(self, owns, now) -> bool:
|
||||
"""SAFETY-119: does the loop's bound on this cycle count? Not within
|
||||
a settle after another owner handed the wire back: its integrator is
|
||||
re-seeded from that owner's command and kicked by a meter that does
|
||||
not show it yet. The settle is SHORT_CYCLES cycles and SHORT_SPAN_S
|
||||
after the hand-back; losing the wire restarts it. Across all
|
||||
hand-backs the bound is hidden on at most ROLLING_BUDGET[cycle
|
||||
length] cycles in any ROLLING_S, so a bound that stays is counted
|
||||
however often the wire changes hands.
|
||||
DECISIONS/SAFETY-119-bound-after-hand-back.md."""
|
||||
def aged(t0, span):
|
||||
return now is None or t0 is None or now - t0 >= span
|
||||
|
||||
# The cycle length (a gap over two minutes is not one).
|
||||
if now is not None and self._settle_t is not None \
|
||||
and 0.0 < now - self._settle_t <= 120.0:
|
||||
self._settle_dt = now - self._settle_t
|
||||
self._settle_t = now
|
||||
if not owns:
|
||||
self._wire_lost, self._handback = True, None
|
||||
return False
|
||||
if self._wire_lost:
|
||||
self._wire_lost, self._handback = False, (now, 0)
|
||||
if self._handback is None:
|
||||
return False
|
||||
tc, n = self._handback
|
||||
n += 1
|
||||
self._handback = (tc, n)
|
||||
if aged(tc, SHORT_SPAN_S) and n >= SHORT_CYCLES:
|
||||
self._handback = None
|
||||
return False
|
||||
if self.loop_clamped[0] and not self.loop_clamped[2]:
|
||||
kept = tuple(t for t in self._hidden if now is None or (
|
||||
t is not None and now - t < ROLLING_S))
|
||||
dt = self._settle_dt
|
||||
budget = ROLLING_BOUND_CYCLES if dt is None else next(
|
||||
m for up, m in ROLLING_BUDGET if dt <= up)
|
||||
if len(kept) >= budget:
|
||||
self._hidden = kept
|
||||
return False
|
||||
self._hidden = kept + (now,)
|
||||
return True
|
||||
|
||||
async def check_output_clamp(self) -> None:
|
||||
"""SAFETY-03. Alarm whenever a clamp actually engaged this cycle.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,8 +2,8 @@ name: GoodWe RS485 Controller
|
||||
version: "0.18.2"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||
meter, holding net grid exchange at zero and running a monthly battery
|
||||
Drives a GoodWe ES/BP battery inverter over its RS485 EMS port by sending it a
|
||||
grid reading, holding net grid exchange at zero and running a monthly battery
|
||||
maintenance cycle.
|
||||
url: https://gittea.kammenstraatha.duckdns.org/admin/goodwe-addon
|
||||
arch:
|
||||
|
||||
@@ -33,7 +33,9 @@ from app import homeview, web
|
||||
from app.main import Controller
|
||||
from app.peakguard import PeakDecision
|
||||
from app.price import PricePoint, PriceProduct, PriceRead
|
||||
from app.static_tariff import TariffPlan
|
||||
from app.static_tariff import TariffPlan, static_plan
|
||||
from app import packmode
|
||||
from app.store import unit_key
|
||||
from app.uimodel import REGION_PRESETS
|
||||
from named_absence import field, nth, present
|
||||
|
||||
@@ -152,6 +154,14 @@ def quiet_guard(projected_w=1240.0):
|
||||
ceiling_from="cap", projected_w=projected_w)
|
||||
|
||||
|
||||
def set_modes(c, *modes):
|
||||
"""File each battery's mode in the store (None leaves the default, which
|
||||
follows the house)."""
|
||||
for inv, m in zip(c.inverters, modes):
|
||||
if m is not None:
|
||||
c.store.data[unit_key(inv.name, packmode.MODE_KEY)] = m
|
||||
|
||||
|
||||
def status(c, now=NOW):
|
||||
s = c.status_payload()
|
||||
# The status route builds at wall-clock time; rebuild the structured half
|
||||
@@ -317,7 +327,7 @@ check("a plan with no published prices yet says so, not 'the price gap is "
|
||||
_t == [] and homeview.line_no_trade("not_firm") in _l
|
||||
and homeview.line_no_trade("not_worth") not in _l)
|
||||
check("the price bars are today's 24 hours, from the real series, with the "
|
||||
"cheap and expensive thirds marked",
|
||||
"cheap and dear hour marked",
|
||||
[b["eur_kwh"] for b in s_arb["plan"]["prices"]] == MOCK
|
||||
and s_arb["plan"]["prices"][13]["kind"] == "cheap"
|
||||
and s_arb["plan"]["prices"][19]["kind"] == "dear"
|
||||
@@ -518,10 +528,269 @@ check("quarter-hour prices: the charge block, its price and the price line's "
|
||||
and f"Today: lowest €{_lo.eur_per_kwh:.2f} at "
|
||||
f"{homeview.hhmm(_lo.start)}," in s_q["plan"]["price_line"]
|
||||
and "the list" not in json.dumps(s_q["plan"]))
|
||||
check("...and the hour bar says it is an hour average",
|
||||
s_q["plan"]["prices"][13]["points"] == 4
|
||||
and '"plan.average_tip"' in io.open(os.path.join(
|
||||
HERE, "app", "static", "app.js"), encoding="utf-8").read())
|
||||
|
||||
# UX-45: the chart draws the steps the add-on decides on.
|
||||
_pq = s_q["plan"]["prices"]
|
||||
check("UX-45: 96 quarter prices -> 96 bars, each one quarter, labelled "
|
||||
"from its own start, with its own price and no average",
|
||||
len(_pq) == 96 and _pq[0]["label"] == "00:00"
|
||||
and _pq[0]["end_label"] == "00:15" and _pq[57]["label"] == "14:15"
|
||||
and _pq[57]["end_label"] == "14:30"
|
||||
and [b["eur_kwh"] for b in _pq] == [round(p.eur_per_kwh, 4)
|
||||
for p in _q[:96]]
|
||||
and all("points" not in b for b in _pq)
|
||||
and s_q["plan"]["hours"] == 24)
|
||||
check("...the quarters of one hour differ (the chart shows the steps)",
|
||||
len({b["eur_kwh"] for b in _pq[52:56]}) > 1)
|
||||
check("...and the page has no 'hour average' tip any more",
|
||||
"average_tip" not in io.open(os.path.join(
|
||||
HERE, "app", "static", "app.js"), encoding="utf-8").read()
|
||||
and "average_tip" not in io.open(os.path.join(
|
||||
HERE, "app", "ui_text", "en.json"), encoding="utf-8").read()
|
||||
and "average_tip" not in io.open(os.path.join(
|
||||
HERE, "app", "ui_text", "nl.json"), encoding="utf-8").read())
|
||||
_cheap_q = [b["start"] for b in _pq if b["kind"] == "cheap"]
|
||||
_dear_q = [b["start"] for b in _pq if b["kind"] == "dear"]
|
||||
_hi = max(_q[:96], key=lambda p: p.eur_per_kwh)
|
||||
check("...without battery modes the marks are the two quarters the "
|
||||
"arbitrage verdict trades in, and no others",
|
||||
_cheap_q == [_lo.start.isoformat()] and _dear_q == [_hi.start.isoformat()]
|
||||
and sum(1 for b in _pq if b["kind"] == "mid") == 94)
|
||||
|
||||
# Battery modes on: the marks ARE the kept priceruns result.
|
||||
from app import priceruns
|
||||
_rp = priceruns.plan(tuple(_q), NOW, required_eur_kwh=0.05, charge_kwh=2.0,
|
||||
charge_kw=4.0, sell_kwh=0.0, sell_kw=0.0)
|
||||
c_m = make(ARB)
|
||||
c_m.tariff_plan, c_m.tariff_at = c_q.tariff_plan, NOW
|
||||
c_m.peak_guard = quiet_guard()
|
||||
c_m.modes_on, c_m.mode_runs = True, _rp
|
||||
set_modes(c_m, packmode.SOLAR_FIRST, packmode.CHEAP_HOURS)
|
||||
_pm = status(c_m)["plan"]["prices"]
|
||||
_today = [(a, z) for a, z, _p in priceruns.firm_slots(_q, NOW)
|
||||
if a < DAY0 + timedelta(hours=24)]
|
||||
_want_c = {a.isoformat() for a, z in _today
|
||||
if _rp.cheap.start <= a < _rp.cheap.end}
|
||||
_want_d = {a.isoformat() for g in _rp.give for a, z in _today
|
||||
if g.start <= a < g.end}
|
||||
check("UX-45: with battery modes on, the cheap and dear quarters are the "
|
||||
"priceruns cheap run and give runs for the same input",
|
||||
_rp.on and _rp.charge_useful() and _want_c and _want_d
|
||||
and {b["start"] for b in _pm if b["kind"] == "cheap"} == _want_c
|
||||
and {b["start"] for b in _pm if b["kind"] == "dear"} == _want_d
|
||||
and len(_pm) == 96)
|
||||
c_m2 = make(ARB)
|
||||
c_m2.tariff_plan, c_m2.tariff_at = c_q.tariff_plan, NOW
|
||||
c_m2.peak_guard = quiet_guard()
|
||||
c_m2.modes_on = True
|
||||
set_modes(c_m2, packmode.CHEAP_HOURS, None)
|
||||
c_m2.mode_runs = priceruns.plan(tuple(_q), NOW, required_eur_kwh=5.0,
|
||||
charge_kwh=2.0, charge_kw=4.0, sell_kwh=0.0,
|
||||
sell_kw=0.0)
|
||||
check("...a run plan that says no trade marks nothing",
|
||||
not c_m2.mode_runs.on
|
||||
and {b["kind"] for b in status(c_m2)["plan"]["prices"]} == {"mid"})
|
||||
|
||||
# A quarter that is over keeps the mark it had while it was ahead.
|
||||
# (`status()` also builds at wall-clock time, which starts the day's memory
|
||||
# over, so the two builds below go straight to `homeview.build`.)
|
||||
_later = NOW + timedelta(hours=4)
|
||||
_s0 = c_q.status_payload()
|
||||
homeview.build(c_q, level=_s0["level"], checks=_s0["checks"], now=NOW)
|
||||
c_q.tariff_at = _later
|
||||
_pl = homeview.build(c_q, level=_s0["level"], checks=_s0["checks"],
|
||||
now=_later)["plan"]["prices"]
|
||||
check("...and a quarter already over keeps the mark it had while ahead",
|
||||
[b["kind"] for b in _pl if b["start"] == _lo.start.isoformat()]
|
||||
== ["cheap"]
|
||||
and [b["kind"] for b in _pl if b["label"] == "19:00"] == ["dear"])
|
||||
c_q.tariff_at = NOW
|
||||
|
||||
# UX-45 round 2 (F2): a hole is covered by the add-on's own rule,
|
||||
# `price._cover_table()`: the point before it runs on, never over an hour.
|
||||
def _hole_bars(missing):
|
||||
pts = [PricePoint(DAY0 + timedelta(minutes=15 * i), .10 + .001 * i, False)
|
||||
for i in range(96)]
|
||||
gone = {DAY0 + timedelta(hours=10, minutes=15 * (1 + j))
|
||||
for j in range(missing)}
|
||||
pts = [p for p in pts if p.start not in gone]
|
||||
return homeview.price_bars(pts, DAY0, DAY0 + timedelta(hours=24))
|
||||
|
||||
|
||||
_gap = {}
|
||||
for _n in (1, 2, 3, 4, 5):
|
||||
_bb = _hole_bars(_n)
|
||||
_gap[_n] = ([b["eur_kwh"] is None for b in _bb[41:41 + _n]],
|
||||
len(_bb))
|
||||
_b1 = _hole_bars(1)
|
||||
check("UX-45: 1, 2 or 3 missing quarters show the price before them (the "
|
||||
"add-on's own cover rule), and nothing shifts",
|
||||
all(len(_hole_bars(n)) == 96 for n in (1, 2, 3))
|
||||
and _gap[1][0] == [False] and _gap[2][0] == [False, False]
|
||||
and _gap[3][0] == [False, False, False]
|
||||
and [b["eur_kwh"] for b in _hole_bars(3)[40:44]]
|
||||
== [_hole_bars(3)[40]["eur_kwh"]] * 4
|
||||
and _b1[42]["label"] == "10:30" and _b1[43]["label"] == "10:45"
|
||||
and _b1[43]["eur_kwh"] == round(.10 + .001 * 43, 4))
|
||||
check("...4 missing quarters leave 1 bar empty, 5 leave 2: a hole over an "
|
||||
"hour is not filled past the hour",
|
||||
_gap[4][0] == [False, False, False, True]
|
||||
and _gap[5][0] == [False, False, False, True, True]
|
||||
and sum(b["eur_kwh"] is None for b in _hole_bars(4)) == 1
|
||||
and sum(b["eur_kwh"] is None for b in _hole_bars(5)) == 2)
|
||||
|
||||
# A cheap run that is not worth charging in is not marked; an estimate is.
|
||||
import types
|
||||
_pts = [PricePoint(DAY0 + timedelta(minutes=15 * i),
|
||||
.30 if i < 4 else .10, False) for i in range(8)]
|
||||
_rp2 = priceruns.plan(tuple(_pts), DAY0, required_eur_kwh=0.05,
|
||||
charge_kwh=0.5, charge_kw=4.0, sell_kwh=0.0, sell_kw=0.0)
|
||||
c_w2 = make(ARB)
|
||||
c_w2.modes_on, c_w2.mode_runs = True, _rp2
|
||||
set_modes(c_w2, packmode.LEFTOVER_SOLAR, None)
|
||||
_w2 = homeview.price_windows(c_w2, None, None)
|
||||
check("UX-45: a cheap run that comes after every dear slot is not worth a "
|
||||
"charge, so it is not marked (the dear run is)",
|
||||
_rp2.on and _rp2.cheap is not None and not _rp2.charge_useful()
|
||||
and [w[0] for w in _w2] == ["dear"])
|
||||
_est = [PricePoint(DAY0 + timedelta(minutes=15 * i), .2, False)
|
||||
for i in range(80)] + [PricePoint(DAY0 + timedelta(hours=20), .25,
|
||||
True)]
|
||||
_be = homeview.price_bars(_est, DAY0, DAY0 + timedelta(hours=24))
|
||||
check("UX-45: a carried (estimate) price after quarter prices is the last "
|
||||
"point, so it covers one quarter (the add-on's tail rule), as an "
|
||||
"estimate bar; the rest stays empty",
|
||||
[b["estimate"] for b in _be[78:83]] == [False, False, True, False, False]
|
||||
and _be[80]["eur_kwh"] == .25 and _be[81]["eur_kwh"] is None)
|
||||
_est2 = _est[:80] + [PricePoint(DAY0 + timedelta(hours=20 + k), .25, True)
|
||||
for k in range(3)]
|
||||
_be2 = homeview.price_bars(_est2, DAY0, DAY0 + timedelta(hours=24))
|
||||
check("...carried hourly points one after another cover an hour each",
|
||||
[b["eur_kwh"] for b in _be2[80:92]] == [.25] * 12
|
||||
and _be2[92]["eur_kwh"] is None)
|
||||
|
||||
# Hourly prices -> 24 bars (above); a day/night tariff -> 24 bars.
|
||||
_ph1 = s_arb["plan"]["prices"]
|
||||
check("UX-45: hourly prices -> 24 hour bars with the hour's own price",
|
||||
len(_ph1) == 24 and _ph1[13]["end_label"] == "14:00"
|
||||
and "points" not in _ph1[13])
|
||||
c_dn = make(dict(ARB, static_profile=(
|
||||
'{"periods": [{"name":"day","from":"07:00","to":"22:00","days":"all",'
|
||||
'"eur_per_kwh":0.34},{"name":"night","from":"22:00","to":"07:00",'
|
||||
'"days":"all","eur_per_kwh":0.26}]}')))
|
||||
c_dn.peak_guard = quiet_guard()
|
||||
_pd = status(c_dn)["plan"]["prices"]
|
||||
check("UX-45: a day/night tariff -> 24 bars, 0.26 at night and 0.34 by day",
|
||||
len(_pd) == 24
|
||||
and [b["eur_kwh"] for b in _pd]
|
||||
== [.26] * 7 + [.34] * 15 + [.26] * 2
|
||||
and all(b["kind"] == "mid" for b in _pd))
|
||||
|
||||
# Clock-change days: 92 and 100 quarters.
|
||||
_d0 = datetime(2026, 3, 28, 23, 0, tzinfo=timezone.utc)
|
||||
_d1 = datetime(2026, 3, 29, 22, 0, tzinfo=timezone.utc)
|
||||
_sp = [PricePoint(_d0 + timedelta(minutes=15 * i), .1, False)
|
||||
for i in range(92)]
|
||||
check("UX-45: a 23-hour day has 92 quarter bars, a 25-hour day 100",
|
||||
len(homeview.price_bars(_sp, _d0, _d1)) == 92
|
||||
and len(homeview.price_bars(
|
||||
_sp, _d0, _d0 + timedelta(hours=25))) == 100)
|
||||
|
||||
_f0 = datetime(2026, 10, 24, 22, 0, tzinfo=timezone.utc) # 25-hour day
|
||||
_fb = [PricePoint(_f0 + timedelta(minutes=15 * i), .1, False)
|
||||
for i in range(100)]
|
||||
|
||||
# F5: the day/night chart with the stored static plan, as in production
|
||||
# (the cycle stores `static_plan()` when the provider is off).
|
||||
_PROF = ('{"periods": [{"name":"day","from":"07:00","to":"22:00",'
|
||||
'"days":"all","eur_per_kwh":0.34},{"name":"night","from":"22:00",'
|
||||
'"to":"07:00","days":"all","eur_per_kwh":0.26}]}')
|
||||
|
||||
|
||||
def _dn(arb):
|
||||
c = make(dict(ARB if arb else TWO, static_profile=_PROF))
|
||||
c.peak_guard = quiet_guard()
|
||||
c.tariff_plan = static_plan(c.o, NOW, NOW + timedelta(hours=36), now=NOW)
|
||||
c.tariff_at = NOW
|
||||
return c, status(c)["plan"]["prices"]
|
||||
|
||||
|
||||
_c_off, _p_off = _dn(False)
|
||||
_c_on, _p_on = _dn(True)
|
||||
_v_on, _why_on = homeview.trade_verdict(_c_on, _c_on.fresh_tariff_plan(NOW))
|
||||
check("UX-45: day/night tariff with the stored plan and price trading off: "
|
||||
"24 bars, no cheap or dear mark",
|
||||
len(_p_off) == 24 and {b["kind"] for b in _p_off} == {"mid"})
|
||||
check("...and with price trading on, the marks are the verdict's cheapest "
|
||||
"and dearest hour (one each)",
|
||||
_v_on is not None and len(_p_on) == 24
|
||||
and [datetime.fromisoformat(b["start"]) for b in _p_on
|
||||
if b["kind"] == "cheap"] == [_v_on.cheapest.start]
|
||||
and [datetime.fromisoformat(b["start"]) for b in _p_on
|
||||
if b["kind"] == "dear"] == [_v_on.dearest.start]
|
||||
and sum(b["kind"] == "mid" for b in _p_on) == 22)
|
||||
|
||||
# F3 / owner decision 2a: with battery modes on, marks only when a battery
|
||||
# uses a price mode.
|
||||
def _modes(*modes, use=(True, True)):
|
||||
c = make(ARB)
|
||||
c.tariff_plan, c.tariff_at = c_q.tariff_plan, NOW
|
||||
c.peak_guard = quiet_guard()
|
||||
c.modes_on, c.mode_runs = True, _rp
|
||||
set_modes(c, *modes)
|
||||
for inv, u in zip(c.inverters, use):
|
||||
c.store.data[unit_key(inv.name, packmode.USE_KEY)] = u
|
||||
return {b["kind"] for b in status(c)["plan"]["prices"]}
|
||||
|
||||
|
||||
_F, _L, _C = (packmode.SOLAR_FIRST, packmode.LEFTOVER_SOLAR,
|
||||
packmode.CHEAP_HOURS)
|
||||
check("UX-45: battery modes on, every battery follows the house: no marks",
|
||||
_modes(_F, _F) == {"mid"} and _modes(None, None) == {"mid"})
|
||||
check("...one battery on Cheap hours or Leftover solar: the marks show",
|
||||
{"cheap", "dear"} <= _modes(_F, _C) and {"cheap", "dear"} <= _modes(_L, _F))
|
||||
check("...a price mode on a battery that is switched off does not count",
|
||||
_modes(_F, _C, use=(True, False)) == {"mid"}
|
||||
and {"cheap", "dear"} <= _modes(_F, _C, use=(False, True)))
|
||||
|
||||
# F4: the day's memory of past marks starts over on a new day.
|
||||
_s4 = c_q.status_payload()
|
||||
homeview.build(c_q, level=_s4["level"], checks=_s4["checks"], now=NOW)
|
||||
_k0 = dict(c_q.home_prices.kinds)
|
||||
_tom = NOW + timedelta(days=1)
|
||||
c_q.tariff_at = _tom
|
||||
homeview.build(c_q, level=_s4["level"], checks=_s4["checks"], now=_tom)
|
||||
c_q.tariff_at = NOW
|
||||
check("UX-45: past marks start over on a new day (nothing of yesterday is "
|
||||
"kept)",
|
||||
len(_k0) > 0 and c_q.home_prices.day != DAY0
|
||||
and c_q.home_prices.kinds
|
||||
and all(k >= DAY0 + timedelta(days=1) for k in c_q.home_prices.kinds)
|
||||
and len(c_q.home_prices.kinds) <= 96)
|
||||
|
||||
# F1: the setup wizard counts hours, not bars.
|
||||
from app.validate import setup_checks
|
||||
_o = dict(ARB, price_provider="entsoe_be")
|
||||
_say = lambda n: [x for x in map(repr, setup_checks(
|
||||
_o, entities={}, running=_o, prices_known=n)) if "Prices for today" in x]
|
||||
check("UX-45: 96 quarter prices count as 24 hours in the setup wizard, "
|
||||
"not 96",
|
||||
homeview.priced_hours(_pq) == 24
|
||||
and homeview.priced_hours(s_arb["plan"]["prices"]) == 24
|
||||
and "for 24 of 24 hours" in "".join(_say(homeview.priced_hours(_pq)))
|
||||
and "96 of 24" not in "".join(_say(homeview.priced_hours(_pq))))
|
||||
check("...a half-priced day counts its hours; a hole of one quarter loses "
|
||||
"no hour",
|
||||
homeview.priced_hours(_pq[:48]) == 12
|
||||
and homeview.priced_hours(_hole_bars(5)) == 24
|
||||
and homeview.priced_hours([dict(b, eur_kwh=None) for b in _pq]) == 0)
|
||||
check("...a 23-hour day gives 23 and a 25-hour day 24 (clock hours)",
|
||||
homeview.priced_hours(homeview.price_bars(_sp, _d0, _d1)) == 23
|
||||
and homeview.priced_hours(homeview.price_bars(
|
||||
_fb, _f0, _f0 + timedelta(hours=25))) == 24)
|
||||
check("...and the wizard takes that count",
|
||||
"known = priced_hours(prices)" in io.open(os.path.join(
|
||||
HERE, "app", "web.py"), encoding="utf-8").read())
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. a fault in the Home view never costs the page its rows")
|
||||
|
||||
@@ -104,7 +104,7 @@ def check(name, cond):
|
||||
# release/1.0 rather than against release/1.0.
|
||||
# ---------------------------------------------------------------------------
|
||||
_DEV = ('{"identifiers": ["goodwe_rs485_controller"], "name": "GoodWe", '
|
||||
'"manufacturer": "GoodWe (via RS485 meter emulation)", '
|
||||
'"manufacturer": "GoodWe (via RS485 EMS port)", '
|
||||
'"model": "ES/BP series"}')
|
||||
|
||||
BASELINE = [
|
||||
@@ -442,7 +442,7 @@ for c_ in unit_cfgs:
|
||||
print(f" regression pin (sha256 of the eight payloads): {_pin}")
|
||||
check("the per-unit payloads are the ones this PR was reviewed with "
|
||||
"(REGRESSION PIN, not a correctness claim - see the comment)",
|
||||
_pin == "eb5022b0455158d7")
|
||||
_pin == "b4274714d8557c37")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n4. zero overlap between A's entities and B's")
|
||||
@@ -976,7 +976,7 @@ def announce_p2(units=(), omit=(), include=(), modes=(), retract=(),
|
||||
# `git show ed739d4:goodwe_controller/app/mqtt.py` and this loop.
|
||||
ROSTERS = [[], ["inverter-a"], ["inverter-a", "inverter-b"], ["a", "b", "c"],
|
||||
["inverter-b", "Inverter B", "x", "y"], ["café"]]
|
||||
ED739D4_OFF = "501a7d93782a260b"
|
||||
ED739D4_OFF = "057be1d8ad7bb582" # DOC-8: ed739d4 digest with only the manufacturer string changed
|
||||
_h, _n, _subs_off = hashlib.sha256(), 0, []
|
||||
for _r in ROSTERS:
|
||||
for _om in [(), ("p1_age",), ("p1_age", "grid")]:
|
||||
|
||||
@@ -0,0 +1,667 @@
|
||||
"""Runnable check for SAFETY-119: no "Battery is not keeping up" when the
|
||||
loop takes the wire back from another owner. `python3 test_safety119.py`
|
||||
|
||||
During a maintenance charge with no usable peak forecast, the measured peak
|
||||
guard steps in during a quarter's last seconds and hands back. On the cycle
|
||||
the loop gets the wire back, its integrator is re-seeded from the other
|
||||
owner's command and kicked by a meter reading taken before the battery moved.
|
||||
It crossed its rail for one or two cycles, the loop's ask beat the one-sided
|
||||
limit, so the cycle counted as the loop's own and raised
|
||||
`goodwe_integrator_bound`. The battery gave what it was told. Now the loop's
|
||||
bound does not count until the plant's settle (SHORT_CYCLES cycles and
|
||||
SHORT_SPAN_S) has passed since the hand-back. Round 3: across all
|
||||
hand-backs the bound is hidden on at most ROLLING_BUDGET[cycle length] cycles
|
||||
(10 up to a 3.5 s cycle, 8 up to 11 s, 6 up to 16 s, 4 beyond) in any
|
||||
ROLLING_S seconds, so a bound that stays warns when the guard and the charge
|
||||
keep taking turns. DECISIONS/SAFETY-119-bound-after-hand-back.md.
|
||||
|
||||
1. The rig's shape (1 s meter that lags the battery, 1 s battery lag, house
|
||||
800 W, rating 2500 W, limit 2500 W, margin 50, charge 2000 W, no
|
||||
forecast), 2 h, three seeds: no notification, the cap held, every command
|
||||
the same as without the fix; without the fix it warns.
|
||||
2. A genuine integrator bound still warns: the loop in charge with the house
|
||||
over the rating (at once, as before), and the loop at its rail after a
|
||||
hand-back (after the settle).
|
||||
3. With a usable forecast: the same commands and the same alarms as without
|
||||
the fix.
|
||||
4. Repeated hand-backs (N=2, the guard and the charge take turns), 2, 5, 10
|
||||
and 15 s meters: it warns (every quarter at 2, 5 and 10 s, every other
|
||||
quarter at least at 15 s); the quarter-edge case stays silent at 2, 5, 10
|
||||
and 15 s, including the 2 s seed where a fixed budget of 8 warns; a start
|
||||
or a blind cycle is not a hand-back; a slow meter delays by cycles.
|
||||
5. The rule at its boundaries (`Controller._settling`): the settle, and the
|
||||
rolling budget.
|
||||
6. test_safety84's and test_safety87's re-pinned digests: the same commands
|
||||
and claims; only OUTPUT_CLAMP log rows differ.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import safety79_identity as s79 # noqa: E402
|
||||
import safety84_identity as s84 # noqa: E402
|
||||
import app.main as app_main # noqa: E402
|
||||
from app import messages # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
from named_absence import first, nth, present # noqa: E402
|
||||
|
||||
fails = []
|
||||
OPT0 = dict(s84.OPT)
|
||||
CAP = 2500.0
|
||||
QUARTER = 900.0
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
async def _call(self, *a):
|
||||
"""The rig's Home Assistant, recording each notification (time, id)."""
|
||||
if len(a) >= 3 and a[1] == "create" and isinstance(a[2], dict):
|
||||
self.__dict__.setdefault("notes", []).append(
|
||||
(self.t, a[2].get("notification_id")))
|
||||
return True
|
||||
|
||||
|
||||
def _power(self, u, lag=1.0):
|
||||
"""The TEST-23 plant's battery: 800 W/s ramp, 1 s first-order lag."""
|
||||
st = self.__dict__.setdefault("_plant", {})
|
||||
a, tgt, tl = st.get(u, (0.0, 0.0, self.t))
|
||||
for _ in range(int(round((self.t - tl) / 0.1))):
|
||||
d = self.cmd[u] - tgt
|
||||
tgt += math.copysign(min(abs(d), 80.0), d)
|
||||
a += (tgt - a) * (1 - math.exp(-0.1 / lag))
|
||||
st[u] = (a, tgt, self.t)
|
||||
if self.cut(u, self.t) and self.cmd[u] >= -4.0:
|
||||
return -4.0
|
||||
return float(round(a))
|
||||
|
||||
|
||||
_real_number, _real_power = rig._Hass.number, rig._Hass.power
|
||||
_real_feed = s79._feed
|
||||
|
||||
|
||||
async def _number(self, e, invert=False):
|
||||
"""sensor.fc: a usable demand forecast of 800 W (part 3)."""
|
||||
if e == "sensor.fc":
|
||||
return 800.0
|
||||
return await _real_number(self, e, invert)
|
||||
|
||||
|
||||
rig._Hass.call, rig._Hass.power, rig._Hass.number = _call, _power, _number
|
||||
rig.TICK = 1.0
|
||||
DELAY = [1]
|
||||
|
||||
|
||||
def _feed(c, t):
|
||||
"""The P1 meter `DELAY` ticks behind the battery, as on the rig (the
|
||||
meter reached HA after the battery reading). Records the true grid."""
|
||||
h = c.hass.__dict__.setdefault("grid_true", [])
|
||||
h.append((t, c.hass.grid()))
|
||||
g = h[max(0, len(h) - 1 - DELAY[0])][1]
|
||||
imp, exp = split_signed(g)
|
||||
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=rig.BASE + timedelta(seconds=t),
|
||||
ingest_mono=time.monotonic()))
|
||||
|
||||
|
||||
s79._feed = _feed
|
||||
|
||||
|
||||
def house(seed, base=800.0, step=None):
|
||||
"""800 W +- 30 W, a new value each second; `step` = (t, W) from then."""
|
||||
rnd = random.Random(seed)
|
||||
noise = [rnd.uniform(-30.0, 30.0) for _ in range(4000)]
|
||||
|
||||
def load(t):
|
||||
if step is not None and t >= step[0]:
|
||||
return step[1]
|
||||
return base + noise[int(t) % 4000]
|
||||
return load
|
||||
|
||||
|
||||
def run(load, *, n=1, dur=7200.0, delay=1, extra=None, cheap=True, cut=None,
|
||||
charging=True, blind=0.0):
|
||||
"""One run of `n` inverters of 2500 W; `charging=False`: no maintenance,
|
||||
the loop in charge; `blind`: no meter reading until that second."""
|
||||
extra = dict(extra or {})
|
||||
s84.OPT = dict(OPT0, peak_cap_w=int(CAP * n), peak_margin_w=50,
|
||||
maintenance_charge_w=2000)
|
||||
if not charging:
|
||||
extra.update(maintenance_enabled=False, peak_cap_w=20000)
|
||||
for k in [k for k in extra if k in s84.OPT]:
|
||||
s84.OPT[k] = extra.pop(k)
|
||||
DELAY[0] = delay
|
||||
try:
|
||||
feed = s84.feed(cheap)
|
||||
|
||||
def before(c, t):
|
||||
if t >= blind: # before that: no meter reading
|
||||
return feed(c, t)
|
||||
return s84.run(n, load=load, cheap=cheap,
|
||||
store=(s79.store_charging(n) if charging
|
||||
else rig._Store()),
|
||||
soc=lambda u, t: 60.0, dur=dur, cut=cut,
|
||||
before=before if blind else None,
|
||||
extra=dict({"max_w": 2500, "slew_w": 0}, **extra))
|
||||
finally:
|
||||
s84.OPT = OPT0
|
||||
|
||||
|
||||
def patched(obj, name, value):
|
||||
def wrap(fn):
|
||||
real = getattr(obj, name)
|
||||
setattr(obj, name, value)
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
setattr(obj, name, real)
|
||||
return wrap
|
||||
|
||||
|
||||
no_fix = patched(Controller, "_settling", lambda self, owns, now: False)
|
||||
|
||||
|
||||
def notes(c):
|
||||
return [t for t, i in getattr(c.hass, "notes", [])
|
||||
if i == messages.ID_LOOP_BOUND]
|
||||
|
||||
|
||||
def quarters(c, tick=1.0):
|
||||
"""Each whole quarter's true average import (W)."""
|
||||
q = {}
|
||||
for t, g in c.hass.grid_true:
|
||||
q.setdefault(int(t // QUARTER), []).append(g)
|
||||
return [sum(v) / len(v) for v in q.values()
|
||||
if len(v) == QUARTER / tick]
|
||||
|
||||
|
||||
def commands(rows):
|
||||
return [(r[0], r[1], r[2]) for r in rows]
|
||||
|
||||
|
||||
def guard_steps(c):
|
||||
return sum(1 for e in c.events if "PEAK_GUARD:" in e)
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. a maintenance charge with no forecast, the guard steps in at each "
|
||||
"quarter's end, 2 h")
|
||||
NEW, OLD = {}, {}
|
||||
for seed, delay in ((1, 1), (2, 1), (3, 2)):
|
||||
NEW[seed] = run(house(seed), delay=delay)
|
||||
OLD[seed] = no_fix(lambda: run(house(seed), delay=delay))
|
||||
print(f" seed {seed} meter {delay} s behind: notifications "
|
||||
f"{notes(NEW[seed][1])} (without the fix {notes(OLD[seed][1])}), "
|
||||
f"highest quarter {max(quarters(NEW[seed][1])):.0f} W, guard "
|
||||
f"step-ins in the last 40 log rows {guard_steps(NEW[seed][1])}")
|
||||
check("⭐ no 'Battery is not keeping up' notification, cycle or episode",
|
||||
all(notes(c) == [] and c.clamp_wd.bound == 0
|
||||
and c.clamp_wd.bound_episodes == 0 for _r, c in NEW.values()))
|
||||
check("⭐ the peak cap is held: every quarter's average is under 2500 W",
|
||||
all(len(quarters(c)) == 8 and max(quarters(c)) < CAP
|
||||
for _r, c in NEW.values()))
|
||||
check("⭐ control is unchanged: every command the same as without the fix",
|
||||
all(commands(NEW[k][0]) == commands(OLD[k][0]) for k in NEW))
|
||||
check("...the charge ran and the guard stepped in near each quarter's end",
|
||||
all(any(r[1] == -2000.0 for r in rows) and any(
|
||||
r[-1][1] and r[-1][1][0] and r[0] % QUARTER >= QUARTER - 40
|
||||
for r in rows) for rows, _c in NEW.values()))
|
||||
check("non-vacuous: without the fix every run warns, at a quarter's edge",
|
||||
all(len(notes(c)) >= 1 for _r, c in OLD.values())
|
||||
and all(min(t % QUARTER, QUARTER - t % QUARTER) <= 40
|
||||
for _r, c in OLD.values() for t in notes(c)))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. a genuine integrator bound still warns")
|
||||
_rail = run(house(1, base=4000.0), dur=600.0,
|
||||
charging=False)
|
||||
_rail_old = no_fix(lambda: run(house(1, base=4000.0), dur=600.0,
|
||||
charging=False))
|
||||
print(f" loop in charge, house 4000 W over the 2500 W rating: "
|
||||
f"{notes(_rail[1])}, without the fix {notes(_rail_old[1])}")
|
||||
check("⭐ the loop in charge at its rail warns (log and notification), at "
|
||||
"the same cycle as before",
|
||||
len(notes(_rail[1])) >= 1 and notes(_rail[1]) == notes(_rail_old[1])
|
||||
and _rail[1].clamp_wd.bound == _rail_old[1].clamp_wd.bound
|
||||
and any("OUTPUT_CLAMP episode" in e for e in _rail[1].events))
|
||||
|
||||
# The charge sets the wire until 600 s, then a solar surplus of 6000 W: the
|
||||
# loop takes the wire back and stands at its -2500 W rail.
|
||||
_hb = run(house(1, step=(600.0, -6000.0)), dur=1200.0,
|
||||
extra={"peak_cap_w": 20000})
|
||||
_hb_old = no_fix(lambda: run(house(1, step=(600.0, -6000.0)), dur=1200.0,
|
||||
extra={"peak_cap_w": 20000}))
|
||||
_first = notes(_hb[1])[:1]
|
||||
print(f" hand-back to a loop at its rail at 600 s: first notification "
|
||||
f"{_first}, without the fix {notes(_hb_old[1])[:1]}; command at the "
|
||||
f"end {_hb[0][-1][1]}")
|
||||
MB = 10 # the budget at a 1 s cycle
|
||||
check(f"⭐ the loop at its rail after a hand-back still warns, once "
|
||||
f"{MB} of its bound cycles are hidden (1 s meter: {MB} s)",
|
||||
_first and 600.0 + MB <= _first[0] <= 600.0 + MB + 4.0
|
||||
and _hb[0][-1][1] == -2500.0
|
||||
and _hb[1].clamp_wd.bound > 100)
|
||||
check("...without the fix it warned at once (non-vacuous)",
|
||||
notes(_hb_old[1])[:1] and notes(_hb_old[1])[0] < 600.0 + MB)
|
||||
# The same charge with no forecast; at 1795 s, under the guard's step-in at
|
||||
# the quarter's end, the house goes to 4000 W, over the 2500 W rating.
|
||||
_up = run(house(1, step=(1795.0, 4000.0)), dur=1900.0)
|
||||
_up_old = no_fix(lambda: run(house(1, step=(1795.0, 4000.0)), dur=1900.0))
|
||||
print(f" guard step-in, then the house over the rating from 1795 s: "
|
||||
f"{notes(_up[1])}, without the fix {notes(_up_old[1])}")
|
||||
check("⭐ a genuine bound in the rig's own scenario still warns, within the "
|
||||
"settle of the guard's hand-back",
|
||||
notes(_up[1]) and 1795.0 <= notes(_up[1])[0] <= 1795.0 + 40.0
|
||||
and any("OUTPUT_CLAMP episode" in e for e in _up[1].events))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. with a usable forecast (800 W): unchanged")
|
||||
FC = {"peak_forecast_entity": "sensor.fc"}
|
||||
_fc = run(house(1), extra=FC)
|
||||
_fc_old = no_fix(lambda: run(house(1), extra=FC))
|
||||
print(f" notifications {notes(_fc[1])} (without the fix "
|
||||
f"{notes(_fc_old[1])}), highest quarter "
|
||||
f"{max(quarters(_fc[1])):.0f} W, charge "
|
||||
f"{min(r[1] for r in _fc[0][:300]):.0f} W")
|
||||
check("⭐ the same commands and the same alarms as without the fix, and "
|
||||
"the cap held",
|
||||
commands(_fc[0]) == commands(_fc_old[0])
|
||||
and notes(_fc[1]) == notes(_fc_old[1])
|
||||
and _fc[1].clamp_wd.engagements == _fc_old[1].clamp_wd.engagements
|
||||
and max(quarters(_fc[1])) < CAP)
|
||||
check("...the forecast was used (the charge is shaped under -2000 W)",
|
||||
-2000.0 < min(r[1] for r in _fc[0][:300]) < 0.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. repeated hand-backs: the guard and the charge take turns")
|
||||
|
||||
|
||||
def round1(self, owns, now):
|
||||
"""Round 1's rule, kept here to show what it hid: every hand-back
|
||||
restarts the window, so a loss within the settle never lets it end."""
|
||||
d = self.__dict__
|
||||
if not owns:
|
||||
d["_r1"], d["_r1_n"] = True, None
|
||||
return False
|
||||
if d.get("_r1"):
|
||||
d["_r1"], d["_r1_n"] = False, (now, 0)
|
||||
if d.get("_r1_n") is None:
|
||||
return False
|
||||
t0, n = d["_r1_n"]
|
||||
d["_r1_n"] = (t0, n + 1)
|
||||
if n + 1 >= app_main.SHORT_CYCLES and (
|
||||
now is None or t0 is None or now - t0 >= app_main.SHORT_SPAN_S):
|
||||
d["_r1_n"] = None
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# N=2 (2 x 2500 W), a cheap-window charge, no usable forecast, the house 50 W
|
||||
# over the 5000 W rating from 300 s: the loop never held the wire for more
|
||||
# than 10 cycles. Round 1: no notification in 2 h; round 2: none at 10 s or
|
||||
# 15 s meters.
|
||||
def never(tick, hw=5050.0, fix=True):
|
||||
rig.TICK = tick
|
||||
try:
|
||||
f = lambda: run(house(1, step=(300.0, hw)), n=2)
|
||||
return f() if fix else no_fix(f)
|
||||
finally:
|
||||
rig.TICK = 1.0
|
||||
|
||||
|
||||
def per_quarter(c):
|
||||
return [sum(1 for t in notes(c) if int(t // QUARTER) == q)
|
||||
for q in range(8)]
|
||||
|
||||
|
||||
_nv = {t: never(t) for t in (2.0, 5.0, 10.0, 15.0)}
|
||||
_nv_old = {t: never(t, fix=False) for t in (2.0, 5.0, 10.0, 15.0)}
|
||||
_nv_r1 = patched(Controller, "_settling", round1)(lambda: never(5.0))
|
||||
for _t in _nv:
|
||||
print(f" N=2, {_t:.0f} s meter, house 5050 W: notifications "
|
||||
f"{len(notes(_nv[_t][1]))} (per quarter "
|
||||
f"{per_quarter(_nv[_t][1])}), without the fix "
|
||||
f"{len(notes(_nv_old[_t][1]))}, highest quarter "
|
||||
f"{max(quarters(_nv[_t][1], _t)):.0f} W")
|
||||
print(f" round 1's rule at 5 s: {len(notes(_nv_r1[1]))}")
|
||||
check("⭐ 2 s and 5 s meters: it warns in every quarter after the first",
|
||||
all(per_quarter(_nv[t][1])[q] >= 1 for q in range(1, 8)
|
||||
for t in (2.0, 5.0)))
|
||||
check("⭐ 10 s meter: it warns in every quarter after the first (round 2 "
|
||||
"gave none)",
|
||||
all(per_quarter(_nv[10.0][1])[q] >= 1 for q in range(1, 8)))
|
||||
check("⭐ 15 s meter: it warns at least every other quarter after the first "
|
||||
"(round 2 gave none)",
|
||||
not any(a == 0 and b == 0 for a, b in zip(
|
||||
per_quarter(_nv[15.0][1])[1:], per_quarter(_nv[15.0][1])[2:])))
|
||||
check("non-vacuous: round 1's rule (a window per hand-back) never warned "
|
||||
"here, and the fix still hides some of what was raised without it",
|
||||
notes(_nv_r1[1]) == [] and all(
|
||||
_nv[t][1].clamp_wd.bound < _nv_old[t][1].clamp_wd.bound
|
||||
for t in _nv))
|
||||
check("...every command is the same as without the fix, at 2, 5, 10 and "
|
||||
"15 s",
|
||||
all(commands(_nv[t][0]) == commands(_nv_old[t][0]) for t in _nv))
|
||||
check("...and at 5 s the cap held",
|
||||
len(quarters(_nv[5.0][1], 5.0)) == 8
|
||||
and max(quarters(_nv[5.0][1], 5.0)) < 5000.0)
|
||||
|
||||
# The quarter-edge case (part 1) at the slower meters: still no warning.
|
||||
# The meter's own lag limits the cap there, the same with and without the fix.
|
||||
_qe = {}
|
||||
# Seeds 1 to 3 are the brief's fixed set; 2 s seed 25 is the one where the
|
||||
# sweep's fixed budget of 8 warned (DECISIONS), kept here so that stays seen.
|
||||
for _t in (2.0, 5.0, 10.0, 15.0): # with part 1: 1, 2, 5, 10, 15 s
|
||||
rig.TICK = _t
|
||||
for _seed, _dl in ((1, 1), (2, 1), (3, 2)) + (
|
||||
((25, 2),) if _t == 2.0 else ()):
|
||||
_qe[_t, _seed] = (run(house(_seed), delay=_dl),
|
||||
no_fix(lambda: run(house(_seed), delay=_dl)))
|
||||
rig.TICK = 1.0
|
||||
print(f" {_t:.0f} s meter, quarter edge: notifications "
|
||||
f"{[len(notes(r[0][1])) for k, r in _qe.items() if k[0] == _t]} "
|
||||
f"(without the fix "
|
||||
f"{[len(notes(r[1][1])) for k, r in _qe.items() if k[0] == _t]})")
|
||||
check("⭐ the quarter-edge case at 2, 5, 10 and 15 s meters, seeds 1 to 3 "
|
||||
"and (2 s) 25: no notification",
|
||||
all(notes(r[0][1]) == [] and r[0][1].clamp_wd.bound == 0
|
||||
for r in _qe.values()))
|
||||
check("...every command the same as without the fix; without it the false "
|
||||
"alarm is there at each meter (non-vacuous)",
|
||||
all(commands(r[0][0]) == commands(r[1][0]) for r in _qe.values())
|
||||
and all(any(len(notes(_qe[_t, k][1][1])) >= 1 for k in (1, 2, 3))
|
||||
for _t in (2.0, 5.0, 10.0, 15.0)))
|
||||
rig.TICK = 2.0
|
||||
_q25_8 = patched(app_main, "ROLLING_BUDGET", ((math.inf, 8),))(
|
||||
lambda: run(house(25), delay=2))
|
||||
rig.TICK = 1.0
|
||||
print(f" 2 s meter, seed 25, a fixed budget of 8: notifications "
|
||||
f"{notes(_q25_8[1])}")
|
||||
check("...2 s seed 25 is the sweep's known case: a fixed budget of 8 warns "
|
||||
"there (why the budget follows the cycle length)",
|
||||
len(notes(_q25_8[1])) >= 1)
|
||||
check("the budget is the one the sweep chose. Changing it means re-running "
|
||||
"the sweep in DECISIONS/SAFETY-119-bound-after-hand-back.md",
|
||||
app_main.ROLLING_BUDGET == ((3.5, 10), (11.0, 8), (16.0, 6),
|
||||
(math.inf, 4))
|
||||
and app_main.ROLLING_BOUND_CYCLES == 8 and app_main.ROLLING_S == 900.0)
|
||||
|
||||
# A start, or a blind cycle, is not a hand-back: the loop at its rail after
|
||||
# 20 s of not running warns at once (R2: a restart counted as a hand-back).
|
||||
_bl = run(house(1, base=4000.0), dur=120.0, charging=False, blind=20.0)
|
||||
_bl_old = no_fix(lambda: run(house(1, base=4000.0), dur=120.0,
|
||||
charging=False, blind=20.0))
|
||||
print(f" the loop not running until 20 s, then at its rail: "
|
||||
f"{notes(_bl[1])}, without the fix {notes(_bl_old[1])}")
|
||||
check("⭐ a start after a blind spell is not a hand-back: it warns at the "
|
||||
"same cycle as without the fix",
|
||||
notes(_bl[1])[:1] and 20.0 <= notes(_bl[1])[0] <= 26.0
|
||||
and notes(_bl[1]) == notes(_bl_old[1]))
|
||||
|
||||
# The budget is cycles, not seconds (R4): at a 5 s meter the loop at its
|
||||
# rail after a hand-back warns 8 cycles (40 s) later, not 8 s.
|
||||
MB = 8 # the budget at a 5 s cycle
|
||||
rig.TICK = 5.0
|
||||
_sl = run(house(1, step=(600.0, -6000.0)), dur=900.0,
|
||||
extra={"peak_cap_w": 20000})
|
||||
rig.TICK = 1.0
|
||||
_sl1 = [t for t in notes(_sl[1]) if t >= 600.0][:1]
|
||||
print(f" 5 s meter, hand-back to a loop at its rail at 600 s: first "
|
||||
f"notification {_sl1}")
|
||||
check(f"⭐ at a 5 s meter the budget is cycles, not seconds: the first "
|
||||
f"warning comes {MB} cycles ({MB * 5} s) after the hand-back",
|
||||
_sl1 and 600.0 + MB * 5.0 <= _sl1[0] <= 600.0 + MB * 5.0 + 10.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. the rule at its boundaries")
|
||||
|
||||
|
||||
class _C:
|
||||
_settling = Controller._settling
|
||||
|
||||
def __init__(self):
|
||||
self._wire_lost, self._handback, self._hidden = False, None, ()
|
||||
self._settle_t, self._settle_dt = None, None
|
||||
self.loop_clamped = (False, "", False)
|
||||
|
||||
|
||||
def seq(c, steps):
|
||||
"""(owned, time[, the loop's bound that cycle]) -> does it count not."""
|
||||
out = []
|
||||
for st in steps:
|
||||
c.loop_clamped = (len(st) > 2 and st[2], "", False)
|
||||
out.append(c._settling(st[0], st[1]))
|
||||
return out
|
||||
|
||||
|
||||
N, SPAN = app_main.SHORT_CYCLES, app_main.SHORT_SPAN_S
|
||||
M, ROLL = 8, 900.0 # the chosen budget, written out on purpose
|
||||
_c = _C()
|
||||
check("from the start, owned every cycle: never settling (no hand-back)",
|
||||
seq(_c, [(True, float(i)) for i in range(30)]) == [False] * 30)
|
||||
_c = _C()
|
||||
_out = seq(_c, [(False, 0.0)] + [(True, 2.0 * i) for i in range(1, N + 3)])
|
||||
check(f"after another owner: settling for {N - 1} owned cycles, then not "
|
||||
f"(12 cycles at 2 s span {SPAN:.0f} s)",
|
||||
_out == [False] + [True] * (N - 1) + [False] * 3)
|
||||
_c = _C()
|
||||
_out = seq(_c, [(False, 0.0)] + [(True, 0.5 * i) for i in range(1, 60)])
|
||||
_end = _out.index(False, 1) if False in _out[1:] else -1
|
||||
check(f"at a 0.5 s meter the span decides: settling until {SPAN:.0f} s",
|
||||
_end == 1 + int(SPAN / 0.5) and all(_out[1:_end]))
|
||||
_c = _C()
|
||||
_out = seq(_c, [(False, 0.0), (True, 2.0), (True, 4.0), (False, 6.0),
|
||||
(True, 8.0), (True, 10.0)])
|
||||
check("losing the wire again restarts the settle",
|
||||
_out == [False, True, True, False, True, True]
|
||||
and _c._handback == (8.0, 2))
|
||||
_c = _C()
|
||||
check("no monotonic clock: the cycle count alone decides",
|
||||
seq(_c, [(False, None)] + [(True, None)] * (N + 1))
|
||||
== [False] + [True] * (N - 1) + [False] * 2)
|
||||
|
||||
# The rolling budget: the loop's bound is hidden on at most M cycles in any
|
||||
# ROLL seconds, across all hand-backs. T = the loop's bound that cycle.
|
||||
assert M < N - 1, "the budget is meant to run out inside one settle"
|
||||
|
||||
|
||||
def turns(c, tick, lost, periods, first=0.0, bound=True):
|
||||
"""A hand-back, then `periods` x (1 owned cycle with the loop's bound,
|
||||
`lost` cycles away). Returns [(time, hidden)] of the owned cycles."""
|
||||
t, out = first, []
|
||||
for _ in range(lost):
|
||||
c._settling(False, t)
|
||||
t += tick
|
||||
for _ in range(periods):
|
||||
c.loop_clamped = (bound, "", False)
|
||||
out.append((t, c._settling(True, t)))
|
||||
t += tick
|
||||
for _ in range(lost):
|
||||
c._settling(False, t)
|
||||
t += tick
|
||||
return out, t
|
||||
|
||||
|
||||
_c = _C()
|
||||
_o = seq(_c, [(False, 0.0)] + [(True, 5.0 * i, True) for i in range(1, N)])
|
||||
check(f"one hand-back at 5 s, the bound on every cycle: {M} are hidden, "
|
||||
f"then it counts for the rest of the settle ({N - 1} cycles)",
|
||||
_o == [False] + [True] * M + [False] * (N - 1 - M))
|
||||
_c = _C()
|
||||
_o, _ = turns(_c, 5.0, 3, 60)
|
||||
_hid = [t for t, h in _o if h]
|
||||
check(f"across hand-backs (one bound cycle per turn, 5 s): the first {M} "
|
||||
"are hidden, then every one counts",
|
||||
[h for _t, h in _o[:M + 5]] == [True] * M + [False] * 5)
|
||||
check(f"...never more than {M} hidden in any {ROLL:.0f} s, and they come "
|
||||
f"back as the oldest ages out (exactly {M} in the first {ROLL:.0f} s "
|
||||
"after the first)",
|
||||
all(sum(1 for x in _hid if t <= x < t + ROLL) <= M for t in _hid)
|
||||
and sum(1 for x in _hid if x < _hid[0] + ROLL) == M
|
||||
and any(x >= _hid[0] + ROLL for x in _hid))
|
||||
_c = _C()
|
||||
_c._hidden = (0.0,) * M
|
||||
_c._wire_lost, _c._handback = False, (ROLL - 30.0, 0)
|
||||
_c.loop_clamped = (True, "", False)
|
||||
_a = _c._settling(True, ROLL - 1.0)
|
||||
_c.loop_clamped = (True, "", False)
|
||||
_b = _c._settling(True, ROLL)
|
||||
check(f"the budget refills {ROLL:.0f} s after the oldest hidden cycle: "
|
||||
f"counted at {ROLL - 1:.0f} s, hidden again at {ROLL:.0f} s",
|
||||
_a is False and _b is True)
|
||||
_c = _C()
|
||||
_o1, _t = turns(_c, 2.0, 3, 20)
|
||||
for _ in range(N): # the wire away a whole settle
|
||||
_c._settling(False, _t)
|
||||
_t += 2.0
|
||||
_o2, _ = turns(_c, 2.0, 3, 5, first=_t)
|
||||
check("the budget is not per hand-back or per run: after a whole settle "
|
||||
"with the wire away, a new hand-back within the same "
|
||||
f"{ROLL:.0f} s hides nothing more",
|
||||
[h for _t2, h in _o1[:M]] == [True] * M
|
||||
and not any(h for _t2, h in _o2))
|
||||
_c = _C()
|
||||
_o1, _t = turns(_c, 2.0, 3, 20)
|
||||
_ = [_c._settling(True, _t + 2.0 * i) for i in range(N + 1)]
|
||||
_c._settling(False, _t + 40.0)
|
||||
_c.loop_clamped = (True, "", False)
|
||||
check("the loop holding the wire a whole settle ends the window: the next "
|
||||
"hand-back hides nothing more while the budget is spent",
|
||||
_c._settling(True, _t + 42.0) is False)
|
||||
_c = _C()
|
||||
_c._wire_lost = True
|
||||
_t = 0.0
|
||||
for _ in range(M + 4): # an over-rating: always counts
|
||||
_c.loop_clamped = (True, "", True)
|
||||
_c._settling(True, _t)
|
||||
_c._wire_lost = True
|
||||
_t += 5.0
|
||||
_c._settling(False, _t)
|
||||
_c.loop_clamped = (True, "", False)
|
||||
check("an over-rating does not use up the budget",
|
||||
_c._settling(True, _t + 5.0) is True and len(_c._hidden) == 1)
|
||||
|
||||
# The budget follows the cycle length: bound cycles hidden before the first
|
||||
# one counts, one hand-back per turn.
|
||||
_bud = {}
|
||||
for _tick in (1.0, 2.0, 3.0, 5.0, 10.0, 15.0, 20.0, 30.0, 60.0):
|
||||
_c = _C()
|
||||
_o, _ = turns(_c, _tick, 1, min(14, int(880.0 // (2 * _tick))))
|
||||
_bud[_tick] = sum(1 for _t2, h in _o if h)
|
||||
print(f" hidden before the first counts, by cycle length: {_bud}")
|
||||
check("the budget by cycle length: 10 at 1-3 s, 8 at 5-10 s, 6 at 15 s, 4 "
|
||||
"at 20-60 s",
|
||||
_bud == {1.0: 10, 2.0: 10, 3.0: 10, 5.0: 8, 10.0: 8, 15.0: 6,
|
||||
20.0: 4, 30.0: 4, 60.0: 4})
|
||||
_c = _C()
|
||||
_o, _t = turns(_c, 2.0, 3, 8)
|
||||
_o2, _ = turns(_c, 121.0, 1, 3, first=_t + 119.0)
|
||||
check("a gap over two minutes is not a cycle length: the 2 s budget (10) "
|
||||
"stays",
|
||||
[h for _t2, h in _o + _o2] == [True] * 10 + [False])
|
||||
|
||||
# No monotonic clock: the budget cannot refill, so a bound that stays is
|
||||
# counted after the budget however often the wire changes hands.
|
||||
_c = _C()
|
||||
_nc = []
|
||||
for _ in range(40):
|
||||
for _ in range(3):
|
||||
_c._settling(False, None)
|
||||
_c.loop_clamped = (True, "", False)
|
||||
_nc.append(_c._settling(True, None))
|
||||
check("no monotonic clock, repeated hand-backs: the budget holds (8 hidden, "
|
||||
"then every bound counts)", _nc == [True] * M + [False] * (40 - M))
|
||||
|
||||
# An over-rating (a command over `max_w`) is a bug, never settled away.
|
||||
_ctl = _hb[1]
|
||||
_ctl.loop_clamped = (True, "x", True)
|
||||
_ctl._wire_lost, _ctl._handback, _ctl._hidden = True, None, ()
|
||||
|
||||
|
||||
class _Res:
|
||||
winner, bound_by, target_w = "loop", "", 0.0
|
||||
unit_bounds = {}
|
||||
|
||||
|
||||
class _D:
|
||||
held, target_w = False, 0.0
|
||||
|
||||
|
||||
_ctl._loop_decision, _ctl.allocation = _D(), None
|
||||
_ctl._loop_alarm_input(_Res())
|
||||
check("an over-rating on a hand-back cycle still counts (bug=True kept)",
|
||||
_ctl.loop_clamped == (True, "x", True) and _ctl._handback is not None)
|
||||
_ctl.loop_clamped = (True, "y", False)
|
||||
_ctl._loop_alarm_input(_Res())
|
||||
check("...while the integrator bound on the next cycle is not counted",
|
||||
_ctl.loop_clamped[0] is False)
|
||||
|
||||
_ctl._wire_lost, _ctl._handback = True, (1.0, 3)
|
||||
_ctl._loop_decision = None # stopped or blind
|
||||
_ctl._loop_alarm_input(_Res())
|
||||
check("a stop clears it: the next start is not a hand-back, and the stop "
|
||||
"is not a cycle length",
|
||||
(_ctl._wire_lost, _ctl._handback, _ctl._settle_t)
|
||||
== (False, None, None))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n6. the pinned digests of test_safety84 and test_safety87: only the "
|
||||
"log changes")
|
||||
# Those harnesses run at the 5 s tick with a battery that follows at once.
|
||||
rig._Hass.power, rig.TICK, s79._feed = _real_power, 5.0, _real_feed
|
||||
rig._Hass.number = _real_number
|
||||
import safety87_identity as s87 # noqa: E402
|
||||
|
||||
RUNS = dict({f"s84 {k}": (lambda v=v: s84.run(**v))
|
||||
for k, v in s84.SCENARIOS.items()},
|
||||
**{f"s87 {k}": (lambda v=v: s87_run(v))
|
||||
for k, v in s87.SCENARIOS.items()})
|
||||
|
||||
|
||||
def s87_run(v):
|
||||
v = dict(v)
|
||||
return s84.run(1, dur=s87.DUR, store=s87.store(v.pop("store", None)), **v)
|
||||
|
||||
|
||||
_moved, _gone = [], 0
|
||||
for k, fn in RUNS.items():
|
||||
new, old = fn()[0], no_fix(fn)[0]
|
||||
same = all(a[:-1] == b[:-1] and a[-1][:4] == b[-1][:4]
|
||||
for a, b in zip(new, old)) and len(new) == len(old)
|
||||
logs = {e for r in old for e in [r[-1][5]] if e} - {
|
||||
e for r in new for e in [r[-1][5]] if e}
|
||||
_gone += len(logs)
|
||||
if not same or any("OUTPUT_CLAMP episode" not in e for e in logs):
|
||||
_moved.append(k)
|
||||
print(f" {k}: commands and claims "
|
||||
f"{'the same' if same else 'DIFFER'}, log rows differ {len(logs)}, "
|
||||
f"digest {s84.digest(new)[:16]}")
|
||||
check("⭐ in all twelve, every command, loop field, claim, guard decision "
|
||||
"and phase is the same as without the fix; the only log rows that "
|
||||
"differ are OUTPUT_CLAMP rows (gone, or a lower count)",
|
||||
not _moved and _gone > 0)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"FAILED: {len(fails)} check(s)")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all SAFETY-119 checks passed")
|
||||
@@ -472,19 +472,22 @@ print("\n8. the N=1 and N=2 digests")
|
||||
# charge pin stands. n2_charge and _banked change only the loop's reason and
|
||||
# claim on 2 cycles; n2_charge_dear changes commands on 98 cycles, where A's
|
||||
# pin lifts (t=1800) and B no longer jumps 1390 W, and after the surplus step.
|
||||
# SAFETY-119 re-pinned all six: only the log. OUTPUT_CLAMP rows from the
|
||||
# loop's bound just after the guard hands back are gone; every command and
|
||||
# claim is the same (test_safety119.py part 6).
|
||||
PINNED = {
|
||||
"n1_charge":
|
||||
"0ed25c856456b97acb07a39299a8cb7b1a762c4d0005b88a0823c03057a80be5",
|
||||
"19781f90b9751b10994dedeaafb38e42d48ad2551640792e601336b7f9b5bb4f",
|
||||
"n1_charge_dear":
|
||||
"c25340b56060d4581f7204e200b07e7c8618711be2baca3b1667b7ebeb600dc3",
|
||||
"eeeb0fa19a6fdc04d44a0838bb2bb911f506a936b0f93d8a6f4d87159053ec74",
|
||||
"n1_charge_banked":
|
||||
"2619acdd06b04e56c000b168d64e1d044346c6ba3f929c94258d6ad8189ec609",
|
||||
"a51f125a8c1c98fdbb009fce72b6781d20d384bdf0f5ce7bbfe367d86e5477db",
|
||||
"n2_charge":
|
||||
"b16d692128f14bcce3d87b142d290e0d6b13724289680e44a05215c9eaa24a7c",
|
||||
"e37e98390baa5dad9128991fabf8b170894395fe026d8a33c8a51de7ed0789cc",
|
||||
"n2_charge_dear":
|
||||
"00a197d89a5dd1e8b3ae6a5d1af66329a4dc4850476569c5931c125fa4d9eaee",
|
||||
"7727372de423f62d77b4426a7226eae31a30f3bb89a3cf70272cc7c1823d7263",
|
||||
"n2_charge_banked":
|
||||
"a6973faea916e4c88ca1770047f94478346eeb1efa4889b154fad0747f91267f",
|
||||
"c77e26006c026b953e9c3dcc9a02f3993589f885cb52c7cbceac6bd59b600551",
|
||||
}
|
||||
_now = s84.digests()
|
||||
for k in PINNED:
|
||||
@@ -492,7 +495,8 @@ for k in PINNED:
|
||||
f"{_now[k][:16]}")
|
||||
check("⭐ at N=1 a charge in and out of a cheap window and under a banked "
|
||||
"month peak is byte-identical to 9d5b7d1 (commands, reasons and every "
|
||||
"claim), and N=2 outside a cheap window matches its SAFETY-85 pin",
|
||||
"claim; the log as re-pinned by SAFETY-119), and N=2 outside a cheap "
|
||||
"window matches its SAFETY-85 pin",
|
||||
all(_now[k] == PINNED[k] for k in ("n1_charge", "n1_charge_dear",
|
||||
"n1_charge_banked",
|
||||
"n2_charge_dear")))
|
||||
|
||||
@@ -518,17 +518,21 @@ check("...a stopped controller still says so: the peak alarm does not take "
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n9. N=1 is byte-identical to release/1.0 b8ddfd7")
|
||||
# SAFETY-119 re-pinned n1_crossing, n1_above and n1_charge_low: only the
|
||||
# log. The loop's bound just after a hand-back is not counted, so OUTPUT_CLAMP
|
||||
# rows are gone or count fewer; every command and claim is the same
|
||||
# (test_safety119.py part 6).
|
||||
PINNED = { # tools/safety87_identity.py on b8ddfd7
|
||||
"n1_under":
|
||||
"d2395ef8d74fb23607ad864d5a4bf62c03044bbd9367e873675d7ad10e8c169f",
|
||||
"n1_crossing":
|
||||
"97f7321017d9d34ee17a40dfd4abf73d915f25b9faef086a483d2b5cd124b198",
|
||||
"20f8e542c1ee3eb3a58c8fd6ba3b3e5b6b1486800cd63b675abc68bc222a172b",
|
||||
"n1_above":
|
||||
"45726a285aa84f40542d56482652397c3c1e212ba4fe2009f6cc52f537ccad52",
|
||||
"3a79f1394b2b49fa88f3c054da1d9da450153ad5f0d2a2c2f960d02a7abe2208",
|
||||
"n1_under_banked":
|
||||
"a2876c7cc873bc4c974d2b4bd98e23bba2d007b84545587d2fe0b713b0a6249b",
|
||||
"n1_charge_low":
|
||||
"91336e3c0314985a3b00f314c79b5ffb5a740d218de3ad44f88a4db6e4235b32",
|
||||
"2e1ee6db0d4f9f18836c4cbbe406fddd496656b54b97f329da513a1591f0ce6c",
|
||||
"n1_hold":
|
||||
"0a1df347754c69166b20d04c15ee878bbcefb02abb824b9b452334f68f03e46d",
|
||||
}
|
||||
@@ -536,8 +540,9 @@ _now = s87.digests()
|
||||
for k in PINNED:
|
||||
print(f" {k} {'same' if _now[k] == PINNED[k] else 'DIFFERS'}")
|
||||
check("⭐ every N=1 digest (claims, commands, the guard's decision and the "
|
||||
"log) equals b8ddfd7's, under, crossing and above the reserve, in a "
|
||||
"charge and a HOLD, and under a banked peak",
|
||||
"log) equals b8ddfd7's (the log as re-pinned by SAFETY-119), under, "
|
||||
"crossing and above the reserve, in a charge and a HOLD, and under a "
|
||||
"banked peak",
|
||||
_now == PINNED)
|
||||
|
||||
# ============================================================================ #
|
||||
|
||||
Reference in New Issue
Block a user