Compare commits

...
20 Commits
Author SHA1 Message Date
admin bee7431793 Merge pull request 'SAFETY-119: no "not keeping up" when the loop takes the wire back' (#203) from SAFETY-119 into release/1.0
infra-pin / fresh (push) Successful in 1s
Merge pull request 'SAFETY-119: no "not keeping up" when the loop takes the wire back' (#203) from SAFETY-119 into release/1.0
2026-10-21 03:32:38 +02:00
glenn schrooyenandClaude Opus 5.5 96375ecbec SAFETY-119 review: DECISIONS states the steady-meter limit
checks / unit (pull_request) Successful in 43m33s
infra-pin / fresh (pull_request) Successful in 2s
The quarter-edge numbers were measured with one cycle per reading at a
steady interval. With the default 15 s cycle floor, a meter that is
sometimes slower adds cycles on an old reading and the false alarm can
come back (reviewer's runs, 11 seeds). Text only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 02:47:27 +02:00
glenn schrooyenandClaude 6fd0fa05f1 SAFETY-119 round 3c: the hidden-bound budget follows the cycle length
checks / unit (pull_request) Successful in 43m29s
infra-pin / fresh (pull_request) Successful in 1s
No single rolling budget met both cases in the sweep (8 false-alarmed on
2 s seed 25 and warned in 1 to 3 of 7 quarters at 15 s). The budget is
now 10 cycles up to a 3.5 s cycle, 8 up to 11 s, 6 up to 16 s, 4 beyond,
per rolling 15 minutes; 8 while the cycle length is unknown. A stop or a
gap over two minutes is not a cycle length. The unsourced "owner
accepted N=8" paragraph is removed from DECISIONS. test_safety119 part 4
now runs 2 s seed 25 and shows a fixed budget of 8 warns there; the
never case runs at 2 s and checks every other quarter at 15 s.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-21 00:57:43 +02:00
glenn schrooyenandClaude e27e62ff72 SAFETY-119 round 3b: no-clock budget, fixed-seed tests, accepted limits
checks / unit (pull_request) Successful in 43m1s
infra-pin / fresh (pull_request) Successful in 1s
The rolling budget now holds without a monotonic clock (it never refills).
Quarter-edge tests use seeds 1-3 at 2, 5, 10 and 15 s (no hand-picked seeds).
Re-pin comments point at part 6. DECISIONS lists the limits the owner
accepted with N=8 on 2026-10-20.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-20 20:46:14 +02:00
glenn schrooyenandClaude 86b94bedb5 SAFETY-119 round 3: DECISIONS table ranges over all runs
checks / unit (pull_request) Successful in 42m30s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-20 17:28:22 +02:00
glenn schrooyenandClaude 103db2fcda SAFETY-119 round 3: rolling 15-minute budget for the hidden integrator bound
checks / unit (pull_request) Successful in 42m33s
infra-pin / fresh (pull_request) Successful in 2s
At most 8 hidden bound cycles in any 900 s across all hand-backs, replacing
the per-run budget. Chosen from a sweep (N 3 to 10, 1 to 15 s meters, both
cases); DECISIONS has the table. Tests: never case at 5, 10, 15 s, quarter
edge at 2, 10, 15 s, the rolling budget at its boundaries.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-20 17:27:43 +02:00
glenn schrooyenandClaude 85542e443b SAFETY-119 round 2: test_safety119 reads through named_absence
checks / unit (pull_request) Successful in 42m22s
infra-pin / fresh (pull_request) Successful in 1s
test_named_absence flagged two unguarded reads (next() without a default,
a list indexed in a check name). Use first(), nth() and present().

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-20 01:38:24 +02:00
glenn schrooyenandClaude e76c70ecb0 SAFETY-119 round 2: one settle window per run of hand-backs
checks / unit (pull_request) Failing after 42m32s
infra-pin / fresh (pull_request) Successful in 2s
A hand-back used to restart the window, so a guard and a charge taking
turns hid the loop's integrator bound for good (0 warnings in 2 h; 28
before the fix). A run of hand-backs (the wire away less than a settle
between them) now hides the bound for at most 11 bound cycles and 22 s in
all, then it counts. The quarter-edge kick stays hidden. No command or text
changes. Tests for the two reviewer mutants that only unit checks caught
(a restart counted as a hand-back; span only), DECISIONS gives the delay at
2 s, 5 s and 60 s. test_safety87.py:543 shortened.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-20 00:52:24 +02:00
glenn schrooyenandClaude Opus 5.5 b9ff6acea4 SAFETY-119 review: DECISIONS, at least once a quarter
checks / unit (pull_request) Successful in 42m1s
infra-pin / fresh (pull_request) Successful in 1s
A bigger house warns more often (every 150 s at 7.4 kW), so 'about' was
wrong. Text only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-19 20:29:00 +02:00
glenn schrooyenandClaude Opus 5.5 98026f9a46 SAFETY-119 review: DECISIONS states the worst delay and the no-warn case
checks / unit (pull_request) Successful in 42m3s
infra-pin / fresh (pull_request) Successful in 1s
The delay is 11 cycles, so up to 11 min at a 60 s cycle. A loop that loses
the wire again within 11 cycles is never counted; review measured one such
case (N=2, cheap-window charge, no forecast, house just over the rating).
The over-rating rule applies on cycles the loop decided. Text only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-19 20:17:08 +02:00
glenn schrooyenandClaude b0c2e5977d SAFETY-119: re-pin test_safety84/87 digests (log only); prove it
checks / unit (pull_request) Successful in 41m47s
infra-pin / fresh (pull_request) Successful in 1s
The re-pinned digests differ only in OUTPUT_CLAMP log rows; every command
and claim is the same as without the fix (test_safety119.py part 5).
DECISIONS: the delay at a 5 s meter and the open swap case.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 15:30:18 +02:00
glenn schrooyenandClaude c4e15ec4cd SAFETY-119: no "not keeping up" when the loop takes the wire back
When the peak guard or a maintenance charge hands the wire back, the loop's
integrator is re-seeded from that owner's command and kicked by a meter that
does not show it yet. It crossed its rail for a cycle or two and raised
goodwe_integrator_bound while the battery gave what it was told.

The loop's integrator bound is not counted until the plant's settle
(SHORT_CYCLES, SHORT_SPAN_S) has passed since the hand-back. An over-rating
always counts; a bound that lasts warns after the settle. No command
changes and no text changes. test_safety119.py; DECISIONS entry.

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-17 01:09:42 +02:00
22 changed files with 1258 additions and 98 deletions
+3 -3
View File
@@ -4,9 +4,9 @@ The guide technicians carry is the PDF:
**`docs/GoodWe-RS485-Field-Guide.pdf`** — 10 pages, A4, print it double-sided.
⚠️ **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
View File
@@ -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`.
+6 -6
View File
@@ -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
View File
@@ -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>
+6 -6
View File
@@ -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.
+6 -5
View File
@@ -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
+92 -38
View File
@@ -22,16 +22,13 @@ from . import arbitrage, messages, peakguard, uitext
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
from .maintenance import 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})
+65 -2
View File
@@ -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.
+1 -1
View File
@@ -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",
}
+2
View File
@@ -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}
+2 -1
View File
@@ -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 + "."]);
+1 -2
View File
@@ -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"},
+1 -2
View File
@@ -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"},
+2 -2
View File
@@ -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 -2
View File
@@ -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:
+275 -6
View File
@@ -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")
+3 -3
View File
@@ -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")]:
+667
View File
@@ -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")
+11 -7
View File
@@ -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")))
+10 -5
View File
@@ -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)
# ============================================================================ #