Compare commits

..
Author SHA1 Message Date
goodwe-review 1c607a961a REL-0.16.0 review: CHANGELOG wording fixes
checks / unit (pull_request) Successful in 32m48s
infra-pin / fresh (pull_request) Successful in 1s
SAFETY-103 swing 'by about 800 W for about 5 s'; SAFETY-99 'late, by less
than 30 s'; MAINT-10 row: one-at-a-time is not new; SAFETY-96: drop the
test-only 800 W; SAFETY-90 and MAINT-10 rows trimmed to two sentences.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:09:08 +02:00
glenn schrooyenandClaude Opus 5.5 756b50ddbb Release 0.16.0
checks / unit (pull_request) Successful in 32m41s
infra-pin / fresh (pull_request) Successful in 1s
version 0.15.0 -> 0.16.0 and a closed ## 0.16.0 CHANGELOG section.
No code change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:25:14 +02:00
admin 340c7eef42 Merge pull request 'SAFETY-95: a maintenance drain that ends hands back smoothly' (#177) from safety-95-drain-end-reseed into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-10-07 17:48:53 +02:00
glenn schrooyenandClaude Opus 5.5 4707d37019 SAFETY-95 review: test the stale-link and contradiction legs; refusal +1 only
checks / unit (pull_request) Successful in 32m34s
infra-pin / fresh (pull_request) Successful in 1s
F1: test_safety95 checks that a dropped draining battery is this rule's
(stale link, N=3, 4000 W: grid import 676 W vs base 786 W); kills R2.
F2: _charge_track tested directly with a contradicting drain (SAFETY-91's
pin as a floor against a closed pack); kills R3. Today a drain contradicts
only through a refusal; the clause stays, as a contradiction sends 0 W.
F3: a drain ends on a refusal only when the battery will not give
(refused > 0, as _held_back()); one that will not take still drains.
Tested both signs; kills R8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:41:16 +02:00
glenn schrooyenandClaude Opus 5.5 9c0314a826 SAFETY-95: N=1 identity for every drain end; plainer DOCS line
checks / unit (pull_request) Successful in 32m35s
infra-pin / fresh (pull_request) Successful in 2s
test_safety95.py: the N=1 byte-identity now covers all six drain ends
(Off and stale link were missing); end_at()/measure() read through a
default, so test_named_absence.py passes (it failed on bdec66e); a check
that a dropped battery's start is 0 W even under a pinned charge (it
kills a mutation that survived).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:36:36 +02:00
glenn schrooyenandClaude Opus 5.5 bdec66e97a SAFETY-95: a maintenance drain that ends hands back smoothly (N>=2)
When a battery's drain ended (done, Off, timeout, abort, stale link,
refusal), its drain floor stayed in the site target and landed on the
others: B 0 -> +1600 W in one cycle at a 1200 W house, exporting; under a
solar surplus B gave into the export.

Now, on that cycle, the loop starts from what the others stood at plus the
battery's new command, raised to the house's own point when the house needs
more (never above last cycle's start). While the battery settles, the loop
counts the batteries at its start instead of chasing the drain's tail on
the meter. Then one slew step a cycle. N=1 is unchanged.

test_safety95.py; DECISIONS/SAFETY-95-drain-end.md; one DOCS line.
test_modes.py: opt11 n2_maint re-pinned on purpose (its drain ends by a
refusal at 1655 s; B now stays at the house's 1420 W instead of 2500 W).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:44:25 +02:00
admin b2f55732b6 Merge pull request 'SAFETY-96: a battery the loop cannot move does not freeze it' (#176) from safety-96-stale-unit-site-total into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-10-06 06:19:12 +02:00
glenn schrooyenandClaude Opus 5.5 956711b801 SAFETY-96 review: keep the refusal count over a refused write; test a fast board
checks / unit (pull_request) Successful in 31m53s
infra-pin / fresh (pull_request) Successful in 2s
F1: resetting the count on each refused write hid a battery that really
refuses behind a link refusing 1 write in 3 (never learned; learned at
358 s with the count kept). F2: a board at 0 W before its link is stale
learned no refusal untested (mutation R7 survived); now a 1 s-lag case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 05:46:32 +02:00
glenn schrooyenandClaude Opus 5.5 88b9cd470a SAFETY-96: a battery the loop cannot move does not freeze it
checks / unit (pull_request) Successful in 31m44s
infra-pin / fresh (pull_request) Successful in 2s
At N>=2 a battery out of the split (stale link), with refused writes, or
settling after a step the loop did not make (a charge end, a rejoin) now
counts in the freeze test at its command, not its reading. Its real power
still reaches the loop through the meter. At a drop its share leaves the
loop's start point (the others take over one slew step a cycle); at a
rejoin it enters at its reading. No refusal is learned while a battery's
writes are refused. N=1 and the 46 identity digests are byte-identical.

test_safety78 part 2: the dropped-drain case now rests at B 1510 W (grid at
its -10 W aim) instead of 1500 W; the drop no longer sends B to 2500 W.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 04:02:03 +02:00
admin 1bf6b7f9a7 Merge pull request 'MAINT-10: maintenance start hour in local time, default 19:00' (#159) from maint-10-local-start-hour into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-10-05 23:56:19 +02:00
glenn schrooyenandClaude Opus 5.5 1d28daf8a0 MAINT-10 review: warn when HA did not answer and TZ is UTC; test the 3 s budget
checks / unit (pull_request) Successful in 31m18s
infra-pin / fresh (pull_request) Successful in 1s
HA can take minutes to start (2026-10-05, 502 from the proxy). Then TZ holds,
which the Supervisor sets from HA's zone, so the hour stays right; only a TZ
of UTC there now logs a WARNING asking for a restart. A test pins the 3 s
budget on the zone request (a mutant without it survived).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:24:02 +02:00
glenn schrooyenandClaude Opus 5.5 0b6b34f563 MAINT-10: every pack at N=2, the next DST days, plainer upgrade note
checks / unit (pull_request) Successful in 31m20s
infra-pin / fresh (pull_request) Successful in 1s
test_maint10.py part 10: at two inverters both packs get the site zone, the
real Controller is identical for 11 UTC and 13 Brussels, and two due packs
across 24/25 Oct 2026 and 27/28 Mar 2027 start at the local hour on
successive days; a pack freed in the repeated hour does not start there.
Part 3 adds 28 Mar 2027. DOCS: an existing install keeps its saved hour, now
read as local time; an hour set to make up for UTC goes back to 19.
DECISIONS: the SAFETY-83 gate is met; section on two inverters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:51:15 +02:00
glenn schrooyenandClaude Opus 5.5 08fb1235fc Merge release/1.0 (0.15.0 + SAFETY-90) into maint-10-local-start-hour
Conflicts: main.py's maintenance import (base added CHARGE, HOLD; branch added
site_zone) - both kept. DOCS maintenance table: base's longer drain-rate row
kept, start hour row at 19 / local.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:51:04 +02:00
admin 7ef73474dc Merge pull request 'SAFETY-90: the peak guard goes on after a P1 meter gap' (#175) from safety-90-peak-guard-gap into release/1.0
infra-pin / fresh (push) Successful in 2s
SAFETY-90: the peak guard goes on after a P1 meter gap (#175)
2026-10-05 13:23:32 +02:00
Claude (SAFETY-90 review) 24dbbd18ad SAFETY-90 review: test that a second gap does not price the first one down
checks / unit (pull_request) Successful in 31m12s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:39:28 +02:00
Claude (SAFETY-90 review) 8dfe91e1d2 SAFETY-90 review: count the start-up seconds like a gap
While the add-on is down the batteries are at 0 W (it sets 0 W when it
stops; the ESP32 watchdog after 30 s), so the import before its first
reading was the house, not the aim. Those seconds now count at
gap_w(limit, the import seen first), and after a restart the restored
reading's held 30 s is topped up the same way. Status row: "counted as
high use". Tests take the pre-start import as the house (batteries at
0 W) and add restarts of 10/60/150 s with a restored averager.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:24:33 +02:00
Claude (SAFETY-90 dev) 9d3fbe1be9 SAFETY-90: the peak guard goes on after a P1 meter gap
checks / unit (pull_request) Successful in 31m10s
infra-pin / fresh (pull_request) Successful in 1s
A meter gap past meter_max_age_s left seconds the quarter average never
counted, so the guard was blind for the rest of the quarter (rig case 7b:
3964.8 W against a 3450 W aim). Once readings are back, the missed seconds
now count at the higher of the limit and the import seen just before and
just after the gap with the batteries at 0 W (peakguard.gap_w/fill_gaps);
the gap's first meter_max_age_s is topped up to that too. Seconds before
the add-on's first reading count at the aim, and the page says part of the
period was not measured (EN/NL). The bill record is unchanged; no option.

DECISIONS/SAFETY-90-peak-guard-after-a-gap.md, test_safety90.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:46:58 +02:00
admin 3ed3302d2f Merge pull request 'Release 0.15.0' (#173) from release-0.15.0 into release/1.0
infra-pin / fresh (push) Successful in 1s
checks / unit (pull_request) Successful in 29m8s
infra-pin / fresh (pull_request) Successful in 1s
Release 0.15.0 (#173)
2026-10-05 01:52:40 +02:00
glenn schrooyenandClaude Opus 5.5 8c6e5bca2e Release 0.15.0: correct the SAFETY-94/97 row, add the ±170 W known issue
checks / unit (pull_request) Successful in 29m6s
infra-pin / fresh (pull_request) Successful in 1s
Review round 1 (F1-F3) on PR #173.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:23:03 +02:00
adminandClaude Opus 5.5 f01152b113 Release 0.15.0: bump version and close the CHANGELOG section
checks / unit (pull_request) Successful in 29m1s
infra-pin / fresh (pull_request) Successful in 1s
config.yaml version 0.14.0 -> 0.15.0 (minor: behaviour change from
SAFETY-94/97, no option change). New `## 0.15.0` section with the contents
since 0.14.0 (cf705a9 #171, 3129dae #172) and the known issues.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:48:04 +02:00
admin 3129daed62 Merge pull request 'CI-14: infra-pin to admin/goodwe 3b4ff84 (WITNESS-09)' (#172) from ci-14-infra-pin-3b4ff84 into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-10-05 00:45:15 +02:00
glenn schrooyenandClaude Opus 5.5 64dddd64bf CI-14: infra-pin to admin/goodwe 3b4ff84 (WITNESS-09)
checks / unit (pull_request) Successful in 29m18s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:13:01 +02:00
admin cf705a9058 SAFETY-94 + SAFETY-97: no false stale or refusal on a lagging battery; refusals graded against each battery's idle reading (#171)
infra-pin / fresh (push) Failing after 2s
2026-10-04 17:27:20 +02:00
goodwe-agent 2f5ab1f378 MAINT-10: DECISIONS says where the release note lives
checks / unit (pull_request) Successful in 23m8s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:09:06 +02:00
goodwe-agent fbdd552555 MAINT-10: CHANGELOG draft line moves to the PR (version gate refuses an Unreleased block)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:08:46 +02:00
goodwe-agent 02c0a3dd7b MAINT-10: maintenance start hour in local time, default 19:00
The start hour was compared with the UTC hour, so the shipped 10 started at
12:00 in a Belgian summer. It is now read in Home Assistant's time zone
(fallback: the container's TZ, then UTC, one log line says which), and the
default moves to 19. DST: a missing hour starts at the first hour after the
jump; a repeated hour starts only in its first pass. Stored options are not
rewritten: a saved 10 now means 10:00 local (DOCS, CHANGELOG).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:52:05 +02:00
27 changed files with 3071 additions and 47 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
# The admin/goodwe commit CI tests against. Bump: set to the full SHA of admin/goodwe release/1.0 HEAD (README.md, "CI and the infra pin").
0c04488a9194039c2bf851b680a8ffc53beff671
3b4ff84757f9c2c5b63a4b4a53e593ccaeafb847
+80
View File
@@ -1,5 +1,85 @@
# Changelog
## 0.16.0
**A minor bump: changed behaviour, no option added or removed, and one default
and its meaning changed.** There are still 83 options. `maintenance_start_hour`
used to be read as UTC and its default was 10. It is now a local hour (in Home
Assistant's time zone) and its default is 19. The other changes below apply
with one inverter or two, unless a line says "Two inverters".
**Before you update (MAINT-10).** A saved `maintenance_start_hour` is kept, and
it now means local time. If you set it in UTC to get an evening start (for
example 17 for 19:00 in summer), set it back to the local hour (19). A saved
10 now starts at 10:00, not 12:00 in summer. The new default 19 only reaches
installs that have no hour saved.
Contents of this release, in merge order since 0.15.0 (`3ed3302`):
| | |
|---|---|
| `7ef7347` | After a gap in the P1 meter, the peak guard counts the missed time as high use and keeps the 15-minute period near the limit; before, it was blind until the next period. In the test, a 5 min meter silence at one inverter ended the period at 3.3 kW, against 4.8 kW on 0.15.0 ([[SAFETY-90]]) |
| `1bf6b7f` | The maintenance start hour is now a local hour, and its default is 19 (was 10, read as UTC). The log says which time zone it used; with two inverters, both use that hour and still cycle one at a time ([[MAINT-10]]) |
| `b2f5573` | Two inverters: when one battery's link drops, the other takes over in small steps, and control no longer stalls. The battery that comes back rejoins within 2 s, without a false "battery refuses" ([[SAFETY-96]]) |
| `340c7ee` | Two inverters: when a drain ends (done, switched Off, timeout, lost link), the other battery covers the house right away, without jumping to 1.6 kW, and then moves in small steps. It no longer gives into a solar surplus ([[SAFETY-95]]) |
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
- A battery whose reading at rest is beyond about ±170 W is still not caught when it refuses, as in 0.14.0 ([[SAFETY-97]]).
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]).
- A charge to 100 % can time out at 98 %. The add-on then counts the cycle as failed and drains the battery again the next day ([[MAINT-13]]).
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
- Two inverters: after a drain ends, the other battery can swing by about 800 W for about 5 s, because some readings are old ([[SAFETY-103]]).
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
## 0.15.0
**A minor bump: changed behaviour, and no option changes.** No option was
added, removed or renamed, and no default changed (83 options). Options that
start 0.14.0 also start 0.15.0. The changes below apply with one inverter and
with two.
The add-on now learns and saves, per battery, what its power reading shows
at rest (`unit/<name>/idle_w`). It learns it after about two quiet minutes and
uses 0 W until then, as before. There is nothing to set.
Contents of this release, in merge order since 0.14.0 (`02ed120`):
| | |
|---|---|
| `cf705a9` | No more false "battery power reading stopped changing" errors at a new command after a rest, or at a change of phase. Each one paused control for a few seconds (several a day on a live site). A healthy battery that lags or turns around is no longer held as refusing for up to 15 min. With two inverters, a refusing battery whose reading at rest is about −100 W is now caught too. The rest value used is limited to ±75 W ([[SAFETY-94]], [[SAFETY-97]], also fixes [[SAFETY-58]]) |
| `3129dae` | CI tests against a newer `admin/goodwe` (CI only) ([[CI-14]]) |
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- After a gap in the P1 meter, the peak guard is blind for the rest of that quarter hour ([[SAFETY-90]]).
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
- A battery whose reading at rest is beyond about ±170 W is still not caught when it refuses, as in 0.14.0 ([[SAFETY-97]]).
- Two inverters: if one battery's drain stops early (e.g. switched Off), the other battery can jump to about 1.6 kW at once, and the surplus goes to the grid ([[SAFETY-95]]).
- Two inverters: after one battery's link drops, control can stall until a safety stop ([[SAFETY-96]]).
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
## 0.14.0
**A minor bump: new behaviour with two inverters, and no option changes.**
@@ -0,0 +1,67 @@
# MAINT-10 — the maintenance start hour is local time, default 19:00
Owner decision 2026-09-29 (option A). `maintenance_start_hour` was compared
with the UTC hour, so the shipped 10 started at 12:00 in a Belgian summer and
11:00 in winter. It is now read on the site's local clock, and the default moves
to 19 so the drain runs into the evening and night rather than the solar day.
Evening drains meet higher house loads; SAFETY-83 (merged first) lets the
peak guard outrank the drain.
## 1. Which zone
Home Assistant's `time_zone` (`GET /core/api/config`, 3 s budget) at startup,
else the container's `TZ`, else UTC. One log line says which (WARNING only for
UTC). Resolved once per start: a zone change in HA takes effect on the next
add-on restart. HA can still be starting when the add-on starts (502 from the
proxy, 2026-10-05); then `TZ` holds. HA pushes its zone to the Supervisor
(hassio `push_config`), which stores it and sets it as the add-on's `TZ`
(`docker/app.py`, Supervisor 2026.08.0), so the hour stays right. Only a `TZ` of
UTC there logs a WARNING asking for a restart. No later re-resolve: the fallback
already is HA's zone, so the rare UTC case gets a warning rather than more code.
The add-on image has tzdata (checked in `amd64-base:3.20`); the CI image does
not, so the tests grade DST on `tariff._EUTimezone` too.
## 2. DST
`maintenance.in_start_slot()` works from UTC instants only, so it does not
depend on how a tzinfo resolves a missing or doubled wall time:
- normally the slot is the local hour `start_hour`;
- clocks forward and the hour does not exist: the first hour after the jump;
- clocks back and the hour happens twice: the first pass only. Without this an
aborted start (90 min cooldown) would start again in the 120 min repeated hour.
Every day has exactly one 60 min slot for every hour 0-23: checked against
zoneinfo for Europe/Brussels, America/New_York, America/Santiago (midnight
transitions), Asia/Kathmandu (+05:45) and Australia/Lord_Howe (30 min DST)
over all of 2026.
## 3. Existing installs
The Supervisor merges the shipped defaults under the STORED options, and saving
the settings stores every key (the reference install's blob has all 76). So
almost every existing install keeps its stored 10, which now means 10:00 local
instead of 12:00 (summer) or 11:00 (winter). Only a blob without the key gets 19.
The add-on does not rewrite a stored option: a silent migration would override
an operator's choice with no way to tell it apart from the old default. A site
that set an earlier hour to make up for UTC (17 for 19:00 summer) must set it
back to 19 after the upgrade, or it starts two hours early. DOCS and the release
note (drafted on the PR, for the version bump) say this.
`tools/supervisor_shape_probe.py` §I measures this on Supervisor 2026.08.0.
## 4. Two inverters
`Controller` hands the same zone to every pack's `Maintenance`, so every pack's
`wants_start()` reads the local hour. `arbitrate()` is unchanged: one pack
cycles at a time, and the one that lost the slot starts in the first start
slot after the other is done (a later day: a cycle outlasts its start hour). On the day the clocks go back, a pack freed during the second pass of
a repeated hour does not start there. `test_maint10.py` part 10.
## 5. What does not change
Only the start decision (`Maintenance.wants_start`) and the Home screen's shown
start time read the zone. `test_maint10.py` parts 9 and 10 run the real
Controller at N=1 and N=2 with start hour 11 UTC against 13 Europe/Brussels (the
same instant): every command, claim, reason and log line is identical. A
`Maintenance` built without a zone reads UTC, as before.
@@ -0,0 +1,81 @@
# SAFETY-90 - the peak guard goes on after a P1 meter gap
**Status: DEV 2026-10-05, for review.** Tests: `test_safety90.py`.
**The defect.** A meter gap longer than `meter_max_age_s` leaves seconds the
quarter average does not count (`p1.QuarterAverager`, on purpose: a guess must
not reach the bill record). `peakguard.blindness()` then called the projection
blind for the rest of the quarter, so the floor was 0 W. During a HOLD the
battery gave nothing and the quarter went over: SAFETY-86 rig case 7b, N=2,
3964.8 W against a 3450 W aim, the month peak raised; N=1, 3701 W (review).
The page still read "Keeping the grid near zero". At start-up mid-quarter the
same rule left the start-up quarter blind, and a held battery was drawn as last
resort (SAFETY-91 test, O2).
**The fix** (`peakguard.fill_gaps()`, `peakguard.gap_w()`,
`Controller._fill_peak_gaps()`).
- Once readings are back, every second of a silence longer than
`meter_max_age_s` counts at `gap_w = max(the limit, the import just before
the gap, the import just after it)`, where "import" is the grid plus the
batteries' discharge, i.e. with the batteries at 0 W. The gap's first
`meter_max_age_s`, which the averager credits at the last reading, is topped
up to `gap_w` too. The guard then grades the quarter as usual, so it is not
blind and gives what keeps the quarter at the aim.
- **The bound.** The count is at or above the true import as long as, during
the gap, the house load (less solar) stayed at or under the higher of the loads seen just
before and just after it, or under the limit, and the batteries did not
start charging (during a gap they hold their last command, then go to 0 W).
It never counts the gap below the limit. A load that rises above all three
only inside the gap is the one case it can miss.
- **Cost of the bound.** It is conservative: with the house at 6500 W, a 30 s
to 5 min gap lands the quarter 100-180 W under the aim (table below), paid in
battery energy.
- **While the gap lasts** nothing changes: the reading is too old to steer by,
the guard is blind (floor 0 W) and the "inputs missing" stop sets 0 W, as
before.
- **Before the first reading** (start-up or restart mid-quarter) the
add-on was down, so the batteries were at 0 W (it sets 0 W when it stops,
the ESP32 watchdog after 30 s). Those seconds count like a gap, at
`gap_w(the limit, the import seen first)`; after a restart the restored
reading's held `meter_max_age_s` is topped up the same way. The bound is
the gap's, with only the "after" side known. A load the batteries cannot
make up then raises "Load too high", which is true: that quarter is going
over. The page says so: the status row "Peak guard: part of this
15-minute period was not measured, counted as high use" and the home line
"Part of this 15-minute period was not measured." / "Een deel van dit
kwartier is niet gemeten." until the quarter ends.
*Review:* the first version counted them at the aim, with "nothing is
known" as the reason. The first reading does show the load, and with the
batteries at 0 W the import was that load, not the aim: a 60 s restart
during a 6500 W HOLD landed 3653 W against 3450 W.
- The bill record (`quarter_blocks`) is unchanged: it still banks only what
was measured. The estimate steers the guard and is never banked.
- The action log gets one `PEAK_GUARD_GAP` row per gap, with the seconds and
the watts it was counted at.
- No option. The threshold is `meter_max_age_s`, the limit and the aim are
`peak_cap_w` and `peak_margin_w`.
**Before -> after** (`test_safety90.py`, quarter as the meter really averages
it, gap starting 300 s in; 10 s battery lag; aim 3450 W):
| case | before | after |
|---|---|---|
| N=1 HOLD 6500 W, gap 30 s / 60 s | 3456.6 / 5329.2 | 3346.2 / 3272.2 |
| N=1 HOLD 5500 W, gap 5 min | 4717.9 | 3330.6 |
| N=2 HOLD, B able, 6500 W, gap 30 s / 60 s | 3456.6 / 3865.6 | 3346.3 / 3272.7 |
| N=2 HOLD, B able, 5500 W, gap 5 min | 3745.4 | 3288.9 |
| N=2 HOLD, B held, 5500 W, gap 60 s | 3254.9, B drawn 2500 W | 3335.4, B 0 W |
| gap across the edge, next quarter, N=1 / N=2 | 4100.0 / 3621.2 | 3446.3 / 3382.2 |
| house 3000 -> 6500 W inside a 60 s gap, N=1 | 5255.6 | 3368.3 |
| house 6500 -> 2000 W late in a 5 min gap, N=1 | 3629.2 | 3145.1 |
| start-up 450 s in, N=1, house 5000 W (batteries at 0 W before it) | 5000.0 | 3450.0 |
| restart, down 60 s / 150 s, N=1, house 6500 W | 4039.1 / 5483.3 | 3450.0 / 3450.0 |
| meter never drops, N=1 and N=2 | | rows identical |
**Not changed.** The late-reading edge (SAFETY-91 test, O1): a reading late by
less than `meter_max_age_s` is not a gap, and the seconds since it are not
counted in the projection (on any quarter, not only after a gap). Near a
quarter's end the 900/remaining gain turns that into a large negative excess
and the floor drops for a cycle or more. Counting those seconds at the held
reading would fix it, but it changes every quarter's last seconds on a
healthy meter; it is handed back as its own card.
@@ -0,0 +1,69 @@
# SAFETY-95 - a maintenance drain that ends hands back smoothly
**Status: implemented 2026-10-06, for review.** Tests: `test_safety95.py`.
**The defect** (SAFETY-93 review, O1). At N=2, when A's drain ended (A
switched Off, say) with the house at 1200 W, B went 0 -> +1600 W in one
cycle and exported. It is the mirror of SAFETY-93 on the drain end.
**Cause.** During a drain the site target carries A's drain floor
(SAFETY-78, `drain`, 2000 W) above what the house needs. At the end A's
command drops (pin, Off, abort, stale link, refusal), but the loop slews
from last cycle's target (2000 -> 1600) and only B is left to place it.
Under a solar surplus B gave 1600 W into the export.
**The rule** (`main.py`, `_reseed_drain_end()`, `_drain_grid_w()`, N>=2
only). On the cycle a drain ends:
1. The start point is what the others stood at plus A's new command (its
pin, else 0 W; a dropped battery is sent 0 W), as SAFETY-93 does.
2. If the house needs more, the start is raised to the house's own point
(live batteries' readings + grid - target, as `_reseed_run_edge()`),
but never above last cycle's start. So the others cover the house at
once and never take over the drain's export.
3. While A settles (SAFETY-96's window: within the deadband, at most
`PLANT_SETTLE_DOWN_S`), the loop's error is the house's point minus its
start, not the meter. The meter still shows A's tail and the others'
climb; chasing it sent B down and then up again.
4. Then the loop moves one slew step a cycle.
A drain ends when its battery leaves DRAIN, is not live, its claims
contradict, or it will not give (SAFETY-73's limit outranks the pin
without a contradiction, so the refusal is read from the battery; +1 only,
as `_held_back()`). Today a contradiction on a drain only arises through
a refusal; the clause stays because a contradiction sends A 0 W, which is
an end (tested directly). The end is kept
until the loop runs (a missed poll). A dropped draining battery is this
rule's, not `_reseed_drop()`'s. Order in one cycle: drop/rejoin
(SAFETY-96), charge end (SAFETY-93), drain end (this) last; each moves the
start by its own battery, and the drain end's raise reads the start the
others left.
**Why not SAFETY-93's rule alone** (step 1, then slew). Measured on the
same plant: at 1200 W the grid imports 849 W and B goes to -465 W
(charging) while the loop chases A's tail; at N=3 and 4000 W it imports
784 W where base imports 42 W, as the others give up A's share of the house.
The house point keeps the grid at its aim; the cap at last cycle's start
keeps it from exporting.
**Before -> after** (`test_safety95.py`, 2500 W batteries, 10 s lag, 2 s
meter; base = the same tree with the change taken out):
| case | before | after |
|---|---|---|
| drain done / Off / timeout, N=2, 1200 W | B 0 -> 1600, +390 W commanded export, grid then +559 W | B 0 -> 1210, grid within 40 W of its aim |
| the same, N=3, 1200 W | B, C 0 -> 800, grid +695 W | 0 -> 605, grid +39 W |
| the same, solar surplus | B gives 1600 W into the surplus | B never gives; site moves 400 W a cycle |
| refusal, N=3, 1200 W | B, C step 1020 W, +2000 W commanded export | step 20 W, +70 W |
| N=3, 4000 W | others take A's share at once | the same (grid +1 W, base +42 W) |
| abort (site zeroed by the operator) | climbs from 0, grid +669 W | climbs from 0, grid +16 W |
| stale link (board watchdog 25 s) | | as base, but N=3 4000 W: grid +676 W (base +786) |
| N=1, every drain end | | byte-identical |
`tools/opt11_identity.py` n2_maint moved on purpose (A's drain refused at
1655 s; B stays at the house's 1420 W instead of 2500 W). Re-pinned in
`test_modes.py`. The other 45 digests are byte-identical.
**Not changed.** The peak guard's floor still binds (arbiter, after the
loop). Held batteries still give last; the idle clamp and the freeze test
are as they were. A charge end (SAFETY-93) and a trade end are unchanged.
@@ -0,0 +1,59 @@
# SAFETY-96 - a battery the loop cannot move does not freeze it
**Status: implemented 2026-10-06, for review.** Tests: `test_safety96.py`.
**The defect** (SAFETY-93 rig test). At N=2 the saturation freeze compares
the site target with the site sum of the readings. After A's link went
stale, A was out of the split but its reading (-2000 W, still charging) was
in the sum: "saturation freeze (800 W vs -1903 W)", B held at 1200 W for
~236 cycles until the circulation trip. After a charge timeout or Off at
6000 W the loop froze ~14 s at 800 W while A came out of its charge. At a
drop, B also took A's whole share in one step.
**The rule** (`main.py`, `_held_out_w()`, `_reseed_drop()`, N>=2 only).
A battery is held out of the freeze test, counted at its command instead of
its reading, while:
- it is out of the split (writes refused for `LINK_STALE_S`);
- its last write was refused (its board may have gone to 0 W on its own
watchdog);
- it settles after a step the loop did not make: a charge end (SAFETY-93:
HOLD, timeout, Off, refusal, stale link) or a rejoin (then every battery,
as the split is made again). Settling ends when its reading is within the
deadband of its command, or after `PLANT_SETTLE_DOWN_S` (23 s, the
measured plant settle).
Its real power is not lost and not counted twice: the meter sees it, and
the loop follows the meter. Only the freeze stops reading it as the loop's.
The freeze still fires on the other batteries' own lag and on unplaced
watts.
At a drop the battery's share leaves the loop's start point, so the others
take over one slew step a cycle. At a rejoin it enters at its reading.
**Not a drop.** A missing reading or a stale register stops the site
(`inputs_ok`), as before. A refused battery (SAFETY-73) stays in the sum:
it is asked ~0 W and reads ~0 W. A watchdog-stopped battery (SAFETY-02/30)
is pinned at 0 W and stays in the sum. New: no refusal is learned on a
battery whose writes are refused; its board going to 0 W is no refusal. Its
count is kept, not reset, so a link that refuses 1 write in 3 does not hide
a battery that really refuses (review).
**Before -> after** (2 x 2500 W, 10 s lag, 2 s meter; freeze cycles not
about the remaining battery's own lag):
| case | before | after |
|---|---|---|
| A drops while giving, A holds 1200 W | 128, B steps 1210 W | 0, B stays |
| A drops while charging, A holds -2000 W | 78 | 0 (circulation trip stays: 4 kW goes round) |
| A drops while charging, board to 0 W | 5 | 0 |
| A drops then comes back, A holds 1200 W | 46, grid +1.1 kW | 0, grid within 250 W |
| charge timeout at 6000 W | 7, B held at 400 W | 0, B climbs |
| A switched Off at 6000 W | 6, B held at 800 W | 0, B climbs |
| N=1, N=2 with no drop | | byte-identical |
**Not changed.** At a rejoin the allocator splits again at once (A 0 ->
1205, B 2410 -> 1205 W); the site total does not step. A battery that keeps
charging on its own after its link drops still trips the circulation check
(it is real circulation). A trade end (`_reseed_run_edge`) still starts
from the full site sum.
+35 -6
View File
@@ -230,7 +230,7 @@ The label and description each setting shows on the Configuration tab, in tab or
| Tariff | Trading charges at most (%) | `arbitrage_soc_ceiling` | Price trading will not charge the battery above this level. |
| Maintenance | Run the monthly battery cycle | `maintenance_enabled` | Once a month, empty the battery deeply, charge it full and hold it there, so its level reading stays accurate. Off by default. |
| Maintenance | Minimum days between cycles | `maintenance_interval_days` | The shortest gap between two maintenance cycles. |
| Maintenance | Start hour | `maintenance_start_hour` | The local hour, 0 to 23, at which a due cycle starts. |
| Maintenance | Start hour (local time) | `maintenance_start_hour` | The hour on your local clock, 0 to 23, when a due cycle starts. 19 means 19:00. |
| Maintenance | Draw-down rate (W) | `maintenance_discharge_w` | How fast the cycle empties the battery. Above “Inverter rating” it is cut back to the rating, and the cycle just takes longer. |
| Maintenance | Recharge rate (W) | `maintenance_charge_w` | How fast the cycle recharges the battery. It must be at or below “Inverter rating”, or the add-on will not start. |
| Maintenance | Draw down to (%) | `maintenance_soc_floor` | The level the monthly cycle empties the battery to. It is not the “Peak reserve”, and the two differ on purpose. |
@@ -1643,13 +1643,30 @@ help - see "Why the tuning is what it is".
|---|---|---|
| `maintenance_enabled` | false | Enable the monthly cycle |
| `maintenance_interval_days` | 28 | Minimum gap between cycles |
| `maintenance_start_hour` | 10 | Hour of day a due cycle begins |
| `maintenance_start_hour` | 19 | Local hour a due cycle begins (see below) |
| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). The peak guard may ask for more (SAFETY-83; at N≥2 while another battery is at its peak reserve, SAFETY-87), and at N≥2 so may the house while another battery is at its peak reserve (SAFETY-91). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower |
| `maintenance_charge_w` | 2000 | Charge ceiling, capped again by peak headroom. Must stay **at or below `max_w`** or the cheap-window shaping (`hi = -maintenance_charge_w`) contradicts the supervised limit (`lo = -max_w`), the arbiter resolves it to **0 W**, and maintenance silently never charges in a cheap window — **the add-on refuses to start** |
| `maintenance_soc_floor` | 11 | Drain target — stay just above the inverter's own floor. ⚠️ **This is not `peak_soc_floor`.** It is the depth the monthly recalibration deliberately goes to, once, on purpose; `peak_soc_floor` is the depth a money-saving discharge may never reach on any day. The right values differ (11 vs 20 as shipped), which is why they are two keys |
| `maintenance_soc_target` | 99 | Charge target |
| `maintenance_hold_min` | 120 | Hold at full so the BMS can balance |
**The start hour is local time.** The add-on reads Home Assistant's time zone
(Settings → System → General) at startup. If Home Assistant does not answer,
it uses the add-on's own time zone, and failing that UTC. The log says which
one it used, and warns if it is UTC: then restart the add-on once Home Assistant
is up. On the day the clocks go forward, a start hour that does not exist
starts at the first hour after the jump. On the day they go back, a repeated
hour starts only once, in its first pass. With two inverters both batteries use
the same local hour; one cycles at a time, and the other starts at that hour on
a later day.
**Upgrading.** The hour used to be read as UTC. An existing install keeps its
saved hour, and that hour now means local time: a saved 10 now starts at 10:00,
not 12:00 in summer. The new default, 19, only reaches installs that have no
hour saved. If you set an earlier hour to get an evening start in UTC (for
example 17 for 19:00 in summer), set it to 19. The add-on never changes a saved
value. *For installers:* DECISIONS/MAINT-10-local-start-hour.md.
### Tariff (all optional)
| option | meaning |
@@ -1683,6 +1700,11 @@ batteries the other one gives first. The hold still ends on its clock
(SAFETY-86).
When a charge ends, the other batteries take over one slew step a cycle,
and none starts charging from the grid (SAFETY-93).
When a battery's link drops, the others take over one slew step a cycle,
and control does not stall on the dropped battery's power (SAFETY-96).
When a drain ends (done, Off, timeout, abort, lost link, refused), the
other batteries cover the house at once but do not export its drain; then
they move one slew step a cycle (SAFETY-95).
#### Capacity-tariff exposure of the grid-following loop
@@ -1752,10 +1774,17 @@ ceiling at the cap, so a broken instrument makes the guard stricter, never loose
**When it cannot see, it forbids importing.** Three states are not evidence and
are checked before the projection is graded: no open quarter (a fresh averager
projects a confident 0 W, which is under every ceiling there is), a meter gap
longer than `meter_max_age_s`, and SAFETY-18's frozen-meter conjunction. In all
three the claim becomes a floor of **0 W**. That is the backstop for the frozen
meter: the alarm arrives at `meter_freeze_s` (300 s) of a 900 s billing quarter
and commands nothing, and this stops the import.
longer than `meter_max_age_s` while it lasts, and SAFETY-18's frozen-meter
conjunction. In all three the claim becomes a floor of **0 W**. That is the
backstop for the frozen meter: the alarm arrives at `meter_freeze_s` (300 s) of
a 900 s billing quarter and commands nothing, and this stops the import.
**After a gap it goes on (SAFETY-90).** The seconds the meter missed count at
the higher of the peak limit and the import seen just before and just after
the gap, with the batteries at 0 W. Seconds before the add-on's first reading
count the same way, at the import seen first, and the page says part of the
period was not measured. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md has the
bound.
**It trips, where the freeze detector only notifies,** and the captures are why.
On the healthy reference day (2026-08-23) it engages on **zero** of 95 quarters.
+14 -9
View File
@@ -20,7 +20,7 @@ from datetime import datetime, timedelta, timezone
from . import arbitrage, messages, peakguard, uitext
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
from .maintenance import IDLE as M_IDLE
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 .static_tariff import static_plan
@@ -439,8 +439,8 @@ def solar_block(c, now):
def maintenance_runs(c, day0, day1, now, shadow):
"""The monthly battery cycle: under way now, or starting later today.
The cycle starts in the hour `maintenance_start_hour` names, read as the
control cycle reads it (UTC hour of `now`), shown in local time."""
The cycle starts in the hour `maintenance_start_hour` names, read in the
site's zone exactly as the control cycle reads it (MAINT-10)."""
runs, lines = [], []
waiting = []
for i, (inv, m) in enumerate(zip(c.inverters, c.maints)):
@@ -459,10 +459,9 @@ def maintenance_runs(c, day0, day1, now, shadow):
if not m.due(now) and (m.next_due(now) is None
or m.next_due(now) >= day1):
continue
start = now.replace(hour=cfg.start_hour, minute=0, second=0,
microsecond=0)
if start + timedelta(hours=1) <= now:
start += timedelta(days=1)
start = next_slot_start(now, cfg.start_hour, m.tz)
if start is None:
continue
due_at = m.next_due(now)
if due_at is not None and due_at > start:
continue
@@ -904,7 +903,9 @@ def month_peak(c, now):
if pg is not None:
out["period_w"] = pg.projected_w
ceiling = pg.ceiling_w or limit_w
out["line"] = line_period(out["period_w"], ceiling, c.shadow)
out["line"] = line_period(out["period_w"], ceiling, c.shadow,
unmeasured=getattr(c, "peak_unmeasured_s", 0.0)
> 0.0)
return out
@@ -1294,8 +1295,11 @@ def line_peak_not_covered() -> str:
return _t("peak_not_covered")
def line_period(period_w, limit_w, shadow) -> str:
def line_period(period_w, limit_w, shadow, unmeasured=False) -> str:
step = _w(shadow, "step_in", kw=_kw(limit_w))
if unmeasured:
# SAFETY-90: the add-on started during this period.
return _t("period_unmeasured") + " " + step
if period_w is None:
return step
return _t("period", kw=_kw(period_w)) + " " + step
@@ -1369,6 +1373,7 @@ def _samples() -> list:
("plan", line_peak_limit(3500, sh)),
("plan", line_period(1240, 3500, sh)),
("plan", line_period(None, 3500, sh)),
("plan", line_period(1240, 3500, sh, unmeasured=True)),
("plan", control_text(True, sh)),
("plan", control_text(False, sh))]
out += [("plan", run_grid_why(0.1412)), ("plan", run_grid_amount(1.23)),
+297 -14
View File
@@ -30,6 +30,7 @@ import functools
import json
import logging
import math
import os
import signal
import time
from datetime import datetime, timedelta, timezone
@@ -68,7 +69,7 @@ from . import messages
from .freeze import (
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
)
from .hass import HomeAssistant
from .hass import CORE_API, HomeAssistant
# ⚠️ `LINK_STALE_S` IS RE-EXPORTED DELIBERATELY. `live_units()` reasons about it
# and two checks read `app.main.LINK_STALE_S` to build a stale link, so the
# window an operator would look up here is the one the roster actually uses.
@@ -76,7 +77,7 @@ from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
roster_entries, roster_names)
from .maintenance import (
CHARGE, DRAIN, HOLD, IDLE, POWER_MOVING, MaintConfig, Maintenance,
MaintResult, arbitrate as maint_arbitrate,
MaintResult, arbitrate as maint_arbitrate, site_zone,
)
from .mqtt import (MODE_CONFIGS_KEY, MODE_LABELS, USE_OFF, USE_ON,
MqttPublisher, announced_units, mode_configs,
@@ -424,7 +425,8 @@ def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now,
class Controller:
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
peak_db_path: str = "/data/tariff.db",
price_history_path: str = "/data/price_history.json"):
price_history_path: str = "/data/price_history.json",
site_tz=None):
# UX-15: a read-only HomeAssistant belongs to setup-only mode, which
# builds no controller. Refused here so no controller can hold one.
if getattr(hass, "read_only", False) is True:
@@ -665,7 +667,8 @@ class Controller:
name=inv.name,
# The rollback view, and only ever the first pack's: the flat
# keys mean `inverters[0]` on every install that has them.
mirror_flat=(not _one_pack and i == 0))
mirror_flat=(not _one_pack and i == 0),
tz=site_tz) # MAINT-10: the start hour's zone, from amain()
for i, inv in enumerate(self.inverters)]
# SAFETY-02. ONE INSTANCE PER ACTUATOR - which is what watchdog.py's own
@@ -947,6 +950,22 @@ class Controller:
# SAFETY-81: what the alarm is graded on, beside the guard's decision.
self._peak_proj = None
self._peak_batt_prev = None
# SAFETY-90: what a meter gap is counted at (`_fill_peak_gaps()`).
self._gap_seen_w = None
self._gap_w = None
self._gap_block = None
self._gap_filled_s = 0.0
self._gap_held_ws = 0.0
self._gap_pre = None
self._unseen = None
# A held sample TARIFF-01 restored from before this restart: the
# averager credits it max_hold_s into the restart, at 0 W batteries.
_held = self.p1.averager.held_since
self._run_pre = (None if _held is None
else (_held, self.p1.averager.held_net_w))
self._unseen_held_ws = 0.0
self.peak_gap_s = 0.0
self.peak_unmeasured_s = 0.0
self._peak_alarm_block = None
self._peak_high_since = None
self._peak_floor_limited = False
@@ -1006,6 +1025,18 @@ class Controller:
# cycle, and those whose charge ended since the loop last ran.
self._charging_was = frozenset()
self._charge_ended = frozenset()
# SAFETY-95: the same for a maintenance drain.
self._draining_was = frozenset()
self._drain_ended = frozenset()
# SAFETY-95: batteries settling after their drain ended; meanwhile
# the loop's error counts every battery at its command.
self._drain_tail = set()
# SAFETY-96: last cycle's commandable batteries, this cycle's start
# point per battery where a reseed moved it, and batteries settling
# after a step the loop did not make ({name: since, monotonic}).
self._live_was = None
self._loop_start = {}
self._settle = {}
# Set by amain() once the transport is built, so the status page can show
# what only the transport knows (homewizard_local's unchanged_s). Stays
# None when P1 is off, or under a transport that has no such counter.
@@ -1374,6 +1405,12 @@ class Controller:
self._unit_refusal_end(
inv, "the battery takes power the other way")
continue
if inv.rejected_since is not None:
# SAFETY-96: its writes are refused, so its board may have
# gone to 0 W on its own; that is no refusal by the battery.
# The count is kept, as across a gap, so a link that refuses
# now and then does not hide a battery that does refuse.
continue
sign = self._gave_nothing(inv)
if sign:
self._unit_refusal_start(
@@ -2409,18 +2446,24 @@ class Controller:
def _charge_track(self, claims, maint_results) -> None:
"""SAFETY-93, N>=2: a charge ends when its battery leaves CHARGE or can
no longer be placed (stale link, refusal). Kept until the loop runs."""
no longer be placed (stale link, refusal). Kept until the loop runs.
SAFETY-95: a drain likewise; its refusal (SAFETY-73) outranks the pin
without a contradiction, so it is read from the battery (+1, will not
give; a battery that will not take still drains)."""
if len(self.inverters) < 2:
return
live = set(self.live_units())
now = set()
now = {CHARGE: set(), DRAIN: set()}
for inv, res in zip(self.inverters, maint_results):
u = self.actuator_of("maintenance", inv)
if res.phase == CHARGE and u in live and not pre_resolve(
[c for c in claims if c.actuator == u]).contradiction:
now.add(inv.name)
self._charge_ended |= self._charging_was - now
self._charging_was = frozenset(now)
if res.phase in now and u in live and not pre_resolve(
[c for c in claims if c.actuator == u]).contradiction \
and not (res.phase == DRAIN and (inv.refused or 0) > 0):
now[res.phase].add(inv.name)
self._charge_ended |= self._charging_was - now[CHARGE]
self._charging_was = frozenset(now[CHARGE])
self._drain_ended |= self._draining_was - now[DRAIN]
self._draining_was = frozenset(now[DRAIN])
def _reseed_charge_end(self, maint_results) -> None:
"""SAFETY-93, N>=2: when a battery's maintenance charge ends, the loop
@@ -2437,9 +2480,116 @@ class Controller:
continue
now_w = (float(res.setpoint_w or 0.0) if res.owns_setpoint
else max(float(inv.target), 0.0))
if not inv.is_live(datetime.now(timezone.utc))[0]:
now_w = 0.0 # SAFETY-96: out of the split, it is sent 0 W
self.target += now_w - float(inv.target)
# SAFETY-96: it settles from its charge to that command.
self._loop_start[inv.name] = now_w
self._settle[inv.name] = self._now_mono
self.i_w = self.target
def _reseed_drain_end(self, maint_results, live) -> None:
"""SAFETY-95, N>=2: when a battery's maintenance drain ends, its
drain leaves the loop's start point, as a charge does (SAFETY-93): the
start is what the others stood at plus its new command (its pin, else
0 W). Where the house needs more, the start is the house's own point
(the live batteries' readings + grid - target), never above last
cycle's start. The loop then moves one slew step a cycle. Else the
drain's floor stays in the target and lands on the others: B 0 ->
+1600 W in one cycle, exporting. DECISIONS/SAFETY-95-drain-end.md.
"""
ended, self._drain_ended = self._drain_ended, frozenset()
if len(self.inverters) < 2 or not ended:
return
hi = self.target
for inv, res in zip(self.inverters, maint_results):
if inv.name not in ended:
continue
now_w = (float(res.setpoint_w or 0.0)
if res.owns_setpoint and res.phase != DRAIN
else min(float(inv.target), 0.0))
if inv.name not in live:
now_w = 0.0 # SAFETY-96: out of the split, it is sent 0 W
else:
self._drain_tail.add(inv.name)
self.target += now_w - float(inv.target)
self._loop_start[inv.name] = now_w
self._settle[inv.name] = self._now_mono
lo, hi = sorted((self.target, hi))
# A dropped battery's power is the meter's, like the house's.
here = float(self.grid) - self.tuning.target_grid_w + sum(
float(inv.batt) for inv in self.inverters if inv.name in live)
self.target = self.i_w = min(hi, max(lo, here))
def _drain_grid_w(self, live) -> float:
"""SAFETY-95: the grid the loop regulates. While a battery settles
from its ended drain (`_held_out_w`: until within the deadband, at
most PLANT_SETTLE_DOWN_S), the meter still shows the drain's tail and
the others' climb, so the loop would chase them (B down, then up
again). Meanwhile the batteries count at the site command: the error
is the house's own point minus the loop's start."""
grid = float(self.grid)
self._drain_tail &= set(self._settle) & set(live)
if not self._drain_tail:
return grid
return grid - float(self.target) + sum(
float(inv.batt) for inv in self.inverters if inv.name in live)
def _reseed_drop(self, live) -> None:
"""SAFETY-96, N>=2: a battery that drops out of the split (stale
link) leaves the loop's start point, so the others take over at one
slew step a cycle instead of taking its share at once. One that comes
back enters it at its reading, and every battery settles while the
split is made again (`_held_out_w`).
DECISIONS/SAFETY-96-dropped-battery.md.
"""
was, self._live_was = self._live_was, frozenset(live)
if len(self.inverters) < 2 or was is None or was == live:
return
for inv in self.inverters:
if inv.name in self._charge_ended | self._drain_ended:
continue # SAFETY-93/95's edge: `_reseed_*_end()`
if inv.name in was and inv.name not in live:
now_w = 0.0
elif inv.name in live and inv.name not in was:
now_w = float(inv.batt)
# The split is made again, so every battery's command steps.
self._settle.update(
{u: self._now_mono for u in live})
else:
continue
self.target += now_w - float(inv.target)
self._loop_start[inv.name] = now_w
self.i_w = self.target
def _held_out_w(self, live) -> float:
"""SAFETY-96, N>=2: what to add to the site sum so that a battery
the loop cannot move right now counts at its command, not at its
reading. That is a battery out of the split, one whose writes are
refused, or one settling after a step the loop did not make (a charge
end, a rejoin) until its reading is within the deadband of its
command or PLANT_SETTLE_DOWN_S has passed. Its real power still
reaches the loop through the meter, so it is neither lost nor counted
twice; only the saturation freeze stops seeing it as the loop's.
"""
if len(self.inverters) < 2:
return 0.0
out = 0.0
for inv in self.inverters:
cmd = self._loop_start.get(inv.name, float(inv.target))
since = self._settle.get(inv.name)
age = (None if since is None or self._now_mono is None
else self._now_mono - since)
if since is not None and (
age is None or age >= _ctl.PLANT_SETTLE_DOWN_S
or (age > 0 and abs(float(inv.batt) - cmd)
< self.tuning.deadband_w)):
del self._settle[inv.name]
if (inv.name not in live or inv.rejected_since is not None
or inv.name in self._settle):
out += cmd - float(inv.batt)
return out
def _mode_fault_log(self, resolution) -> None:
"""OPT-11: say once, per battery, that its mode was dropped as a fault.
@@ -2693,6 +2843,74 @@ class Controller:
lo=p, reason=c.reason,
actuator=u)
def _fill_peak_gaps(self, proj, now, reading):
"""SAFETY-90: `proj` with a meter gap's uncounted seconds counted at
`peakguard.gap_w()` of the imports seen just before and just after it,
and those before this run's first reading likewise at the import seen
first (shown as not measured).
DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
self.peak_gap_s = self.peak_unmeasured_s = 0.0
avg = self.p1.averager
since = avg.held_since
if proj is None or proj.block_start is None or since is None:
return proj
if self._unseen is None and self.p1.last is not None:
self._unseen = [proj.block_start, max(0.0, (
since - proj.block_start).total_seconds() - avg.elapsed_s),
since, None]
seen = (None if self.grid is None or self.site_batt_w is None
else float(self.grid) + max(0.0, float(self.site_batt_w)))
if proj.block_start != self._gap_block:
self._gap_block, self._gap_w = proj.block_start, None
self._gap_filled_s = self._gap_held_ws = 0.0
cap = float(self.o.get("peak_cap_w", 3500))
ceiling = peakguard.quarter_ceiling_w(cap, reading)[0]
unseen_on = (self._unseen is not None
and self._unseen[0] == proj.block_start)
if unseen_on and self._unseen[3] is None and seen is not None:
# While the add-on was down the batteries were at 0 W, so the
# seconds before its first reading count like a gap, at the
# import seen first; a restored reading's held part is topped up.
self._unseen[3] = peakguard.gap_w(ceiling, seen)
if self._run_pre is not None:
s0, g0 = self._run_pre
h = (min(s0 + timedelta(seconds=avg.max_hold_s),
self._unseen[2])
- max(s0, proj.block_start)).total_seconds()
self._unseen_held_ws = max(0.0, h) * max(
0.0, self._unseen[3] - max(0.0, g0))
kw = dict(tail_s=(now - since).total_seconds(),
max_hold_s=avg.max_hold_s,
unseen_s=self._unseen[1] if unseen_on else 0.0,
unseen_w=(self._unseen[3] if unseen_on
and self._unseen[3] is not None else ceiling))
_p, gap_s, _u = peakguard.fill_gaps(proj, gap_w=0.0, **kw)
if gap_s > self._gap_filled_s + peakguard.GAP_TOL_S:
self._gap_w = peakguard.gap_w(ceiling, self._gap_w,
self._gap_seen_w, seen)
if self._gap_pre is not None:
# The averager credited the gap's first max_hold_s at the
# reading before it; top that part up to gap_w as well.
s0, g0 = self._gap_pre
h = (min(s0 + timedelta(seconds=avg.max_hold_s), since)
- max(s0, proj.block_start)).total_seconds()
self._gap_held_ws += max(0.0, h) * max(
0.0, self._gap_w - max(0.0, g0))
self.log_event(f"PEAK_GUARD_GAP: the meter missed {gap_s:.0f} s "
f"of this 15-minute period, counted at "
f"{self._gap_w:.0f} W",
hint=messages.hint("peak_gap"))
self._gap_filled_s = max(self._gap_filled_s, gap_s)
if seen is not None:
self._gap_seen_w = seen
if self.grid is not None:
self._gap_pre = (since, float(self.grid))
out, self.peak_gap_s, self.peak_unmeasured_s = peakguard.fill_gaps(
proj, gap_w=self._gap_w if self._gap_w is not None else ceiling,
held_ws=self._gap_held_ws + (self._unseen_held_ws if unseen_on
else 0.0), **kw)
return out
def _peak_over_drain(self, claims, maint_results) -> None:
"""SAFETY-83: at N=1 a peak floor above the drain rate outranks it.
@@ -4218,6 +4436,10 @@ class Controller:
# gives for SAFETY-18 on a transport that cannot see `unchanged_s`.
self._peak_proj = (None if not self.p1_enabled
else self.peak.project(self.p1.averager, now))
_reading = self.peak.peak(now) if self.p1_enabled else None
# SAFETY-90: seconds a meter gap left uncounted are counted, so the
# guard goes on after the readings return.
self._peak_proj = self._fill_peak_gaps(self._peak_proj, now, _reading)
# SAFETY-91: at N>=2 the quarter's edge is graded on the next quarter,
# so a HOLD's ceiling does not lapse for a cycle onto a held battery.
if self._peak_proj is not None and len(self.inverters) > 1:
@@ -4232,7 +4454,7 @@ class Controller:
batt_w=self.site_batt_w,
cap_w=float(self.o.get("peak_cap_w", 3500)),
margin_w=self.peak_margin_w,
reading=self.peak.peak(now),
reading=_reading,
# ⚠️ SUMMED FROM THE PER-UNIT CONJUNCTION, WHICH IS AC 4. Each
# unit contributes `min(its max_w, |its own device bounds|)`, so
# inverter B's rating bounds inverter B. Conjoining the SUMS instead
@@ -4639,6 +4861,8 @@ class Controller:
mode_trade = self._mode_trade(claims, now)
self._mode_trade_was = mode_trade
self._charge_track(claims, maint_results)
_live96 = set(self.live_units())
self._loop_start = {}
if (self.auto or self.shadow) and self.inputs_ok:
# ⚠️ prev is the ARBITER's last output, not the loop's own last
# wish. If something outranked the loop, that is what the hardware
@@ -4665,8 +4889,14 @@ class Controller:
# is what seeds it, and dropping it there restarts the ~70 s cycle.
if self.loop_w is not None and self.target != self.loop_w:
self._refusal_end("another writer")
# SAFETY-96: a battery that dropped out of the split leaves the
# loop's start point (SAFETY-93's own if its charge ended too).
self._reseed_drop(_live96)
# SAFETY-93: a charge that ended leaves the loop's start point.
self._reseed_charge_end(maint_results)
# SAFETY-95: then a drain that ended, last (it reads the start
# the others left).
self._reseed_drain_end(maint_results, _live96)
probe = self._refusal_probe()
if len(self.inverters) > 1:
probe = any(_probing.values())
@@ -4684,6 +4914,9 @@ class Controller:
# level: the two halves of §6.2's identity break in opposite
# directions. See `site_batt_w` and test_alloc.py part 8.
site_batt = self.site_batt_w
# SAFETY-96: a battery the loop cannot move counts at its command,
# so its own power does not freeze the loop (the meter has it).
site_batt += self._held_out_w(_live96)
# TEL-10. ⚠️ ONE NAME FOR THE COMMAND THE LOOP WAS MEASURED
# AGAINST. `self.target` is reassigned further down this method, so
# a `decision.i_w - self.target` written at publish time would
@@ -4693,7 +4926,9 @@ class Controller:
loop_prev = self.target
decision = compute(
prev_w=loop_prev,
grid_w=self.grid,
# SAFETY-95: a drain's tail is not the loop's to chase.
grid_w=(self._drain_grid_w(_live96) if self._drain_tail
else self.grid),
actual_w=site_batt,
tuning=self.tuning,
sat_count=self.sat_count,
@@ -4792,6 +5027,7 @@ class Controller:
# Stopped or blind: no accumulation may survive the outage.
self.i_w = 0.0
self.loop_w = None
self._live_was = frozenset(_live96)
# TEL-10, and it is the same zero the line above writes rather than
# a second opinion about it. ⚠️ `not-running` rather than a stale
# `tracking`: a reason row that keeps repeating the last word the
@@ -6390,6 +6626,11 @@ class Controller:
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_blind(
peakguard.blind_kind(_pg.blind))})
elif self.peak_unmeasured_s > 0.0:
# SAFETY-90: the start of this quarter came before the first
# reading, and the guard counts it as high use.
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_unmeasured()})
elif _pg.engaged:
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_on(
@@ -7157,6 +7398,47 @@ async def docs_link_facts(session, token: str) -> dict:
return out
async def ha_time_zone(session, token: str) -> tuple:
"""MAINT-10: `(zone, why)` - Home Assistant's configured time zone, or None
and a short reason. Same 3 s budget as the docs link: never holds up start."""
try:
async with session.get(f"{CORE_API}/config", timeout=DOCS_LINK_TIMEOUT_S,
headers={"Authorization": f"Bearer {token}"}) as r:
body = await r.json(content_type=None)
zone = body.get("time_zone") if isinstance(body, dict) else None
return (zone, "") if zone else (None, "no time_zone in its answer")
except Exception as err: # noqa: BLE001 - a fallback exists, never a stop
text = str(err).strip()
return None, f"{type(err).__name__}: {text}" if text else type(err).__name__
_UTC_NAMES = {"UTC", "ETC/UTC", "GMT", "ETC/GMT", "UCT", "ETC/UCT",
"UNIVERSAL", "ETC/UNIVERSAL", "ZULU", "ETC/ZULU"}
async def maint_site_tz(session, token: str):
"""MAINT-10: the zone the maintenance start hour is read in, logged once."""
zone, why = await ha_time_zone(session, token)
tz, name, source, why = site_zone(zone, why, os.environ.get("TZ"))
if source == "ha":
_LOG.info("Maintenance start hour is local time in %s, from Home "
"Assistant.", name)
elif source == "tz" and name.strip().upper() in _UTC_NAMES:
# The Supervisor sets TZ to its copy of HA's zone, or to UTC if it has
# none: HA did not answer here, and UTC may not be the site's clock.
_LOG.warning("Maintenance start hour uses UTC, because Home Assistant "
"did not answer. Restart the add-on when it is up.")
_LOG.debug("maintenance time zone: %s", why)
elif source == "tz":
_LOG.info("Maintenance start hour is local time in %s, from the "
"add-on's time zone. Home Assistant gave none: %s.", name, why)
else:
_LOG.warning("Maintenance start hour uses UTC, because no time zone "
"was found. Set the time zone in Home Assistant.")
_LOG.debug("maintenance time zone: %s", why)
return tz
async def amain() -> None:
opts = load_options()
logging.basicConfig(
@@ -7266,7 +7548,8 @@ async def amain() -> None:
pub = _NoMqtt()
stop = asyncio.Event()
controller = Controller(opts, hass, store, pub, stop=stop)
controller = Controller(opts, hass, store, pub, stop=stop,
site_tz=await maint_site_tz(session, _tok))
if callable(getattr(pub, "bind", None)):
pub.bind(asyncio.get_running_loop(), controller.mqtt_command)
runner = await web.start(controller, port=INGRESS_PORT)
+74 -4
View File
@@ -34,7 +34,7 @@ simply not do.
import logging
import math
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from datetime import datetime, timedelta, timezone, tzinfo
from .store import MAINT_UNIT_KEYS, unit_key
@@ -112,7 +112,9 @@ RESTART_COOLDOWN_MIN = 90
MAINT_OPTION_DEFAULTS = {
"maintenance_enabled": False,
"maintenance_interval_days": 28,
"maintenance_start_hour": 10,
# MAINT-10: a LOCAL hour (site_zone() below), 19:00 so the drain runs
# into the night rather than through the solar day.
"maintenance_start_hour": 19,
# ⚠️ 2000, NOT THE 2500 THIS SHIPPED AS, and config.yaml carries the
# argument: both rates must stay at or below `max_w` (2000) or the
# cheap-window charge shaping asks the arbiter for `hi = -charge_w` against
@@ -177,6 +179,69 @@ class MaintConfig:
)
# --------------------------------------------------------------------------- #
# MAINT-10: the start hour is local time. See DECISIONS/MAINT-10-local-start-hour.md.
# --------------------------------------------------------------------------- #
def _load_zone(name):
"""A tzinfo for an IANA name, or `(None, why)`."""
if not name or not isinstance(name, str):
return None, "none given"
try:
from zoneinfo import ZoneInfo
return ZoneInfo(name.strip()), ""
except Exception as err: # noqa: BLE001 - any failure
return None, f"{name!r} is not a known zone ({type(err).__name__})"
def site_zone(ha_zone, ha_why: str, env_tz) -> tuple:
"""`(tzinfo, name, source, why)`: Home Assistant's zone (source "ha"), else
the container's TZ ("tz"), else UTC ("utc"). `why` says what was missing."""
tz, why = _load_zone(ha_zone)
if tz is not None:
return tz, ha_zone, "ha", ""
ha_why = ha_why or why
tz, env_why = _load_zone(env_tz)
if tz is not None:
return tz, env_tz, "tz", ha_why
return timezone.utc, "UTC", "utc", f"Home Assistant: {ha_why}, TZ: {env_why}"
def in_start_slot(now: datetime, start_hour: int, tz: tzinfo) -> bool:
"""Is `now` inside today's one-hour start slot, read on the local clock?
Normally the slot is the local hour `start_hour`. On the day the clocks go
forward and that hour does not exist, it is the first hour after the jump.
On the day they go back and it happens twice, only the first one counts.
Worked from UTC instants only (`astimezone`), so it does not depend on how
a tzinfo resolves a wall time that is missing or doubled.
"""
if now.tzinfo is None:
now = now.replace(tzinfo=timezone.utc)
here = now.astimezone(tz).replace(tzinfo=None)
before = (now - timedelta(hours=1)).astimezone(tz).replace(tzinfo=None)
if here.hour == start_hour:
# the second pass through a repeated hour: an hour ago read the same
return not (before.date() == here.date() and before.hour == start_hour)
slot = here.replace(hour=start_hour, minute=0, second=0, microsecond=0)
# the clock jumped over the start of the slot within the last hour
return before < slot and here >= slot + timedelta(hours=1)
def next_slot_start(now: datetime, start_hour: int, tz: tzinfo):
"""The start of the slot `now` is in, or else of the next one; None if
there is none within 50 h (no real zone does that)."""
step = timedelta(minutes=15)
t = now.replace(minute=now.minute - now.minute % 15, second=0,
microsecond=0) - timedelta(hours=1)
for _ in range(4 * 50):
if (in_start_slot(t, start_hour, tz)
and not in_start_slot(t - step, start_hour, tz)
and t + timedelta(hours=1) > now):
return t
t += step
return None
@dataclass
class MaintResult:
phase: str
@@ -256,8 +321,12 @@ class Maintenance:
"""
def __init__(self, cfg: MaintConfig, store, unit: str | None = None,
name: str = "", mirror_flat: bool = False):
name: str = "", mirror_flat: bool = False,
tz: tzinfo | None = None):
self.cfg = cfg
# MAINT-10: the zone `cfg.start_hour` is read in - main.py resolves it
# once at startup (`site_zone()`). UTC when nobody says, as before.
self.tz = tz or timezone.utc
# ⚠️ `self.store` IS THE VIEW, NOT THE Store. Everything in this class
# names an UNQUALIFIED key - "phase", "last_completed" - and the view is
# the one place that turns it into the key this pack owns. A raw
@@ -331,7 +400,8 @@ class Maintenance:
about who was asking - so the IDLE branch of `tick()` calls this one.
"""
return (self.phase == IDLE and self.cfg.enabled
and now.hour == self.cfg.start_hour and self.due(now)
and in_start_slot(now, self.cfg.start_hour, self.tz)
and self.due(now)
and self._cooled(now))
# -- operations ----------------------------------------------------------
+8
View File
@@ -793,6 +793,12 @@ def row_peak_guard_blind(why: str) -> str:
f"meanwhile")
def row_peak_guard_unmeasured() -> str:
"""SAFETY-90. Before the first reading; that part counts as high use."""
return ("Peak guard: part of this 15-minute period was not measured, "
"counted as high use")
def row_peak_guard_on(floor_w, limit_w, *, shadow: bool = False) -> str:
if shadow:
return (f"Peak guard: would ask for {watts(floor_w)} to stay under "
@@ -912,6 +918,7 @@ HINTS = {
"peak_on": "Peak guard started",
"peak_off": "Peak guard idle",
"peak_blind": "Peak guard cannot read the meter well",
"peak_gap": "Meter is back, the peak guard counts the missed time as high use",
"peak_saturated": T_PEAK_SATURATED,
"peak_reserve": T_PEAK_RESERVE,
"meter_frozen": T_METER_FROZEN,
@@ -1097,6 +1104,7 @@ def status_samples() -> list:
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, True)),
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, False)),
("row_peak_guard_quiet", GREEN, row_peak_guard_quiet()),
("row_peak_guard_unmeasured", AMBER, row_peak_guard_unmeasured()),
*(("row_peak_guard_blind", AMBER, row_peak_guard_blind(k))
for k in BLIND_WORDS),
("row_peak_guard_on", AMBER, row_peak_guard_on(1200, 2500)),
+7
View File
@@ -312,6 +312,13 @@ class QuarterAverager:
"""
return self._last_net
@property
def held_since(self) -> datetime | None:
"""SAFETY-90: when the held sample arrived (its `ingest_ts`), or None."""
if self._last_t is None:
return None
return datetime.fromtimestamp(self._last_t, timezone.utc)
@property
def offtake_avg_w(self) -> float:
"""Average offtake over the part of the open block seen so far."""
+53
View File
@@ -183,6 +183,8 @@ guard passes its own tests while being switched off:
record). Those seconds make the projection read LOW, which is the
under-guarding direction. The threshold is `max_hold_s` itself - the point
p1.py already stops trusting a held sample - not a new number.
SAFETY-90: once readings are back, main.py counts those seconds
(`fill_gaps()`), so this holds only while the gap lasts.
* FROZEN. SAFETY-18's conjunction, evaluated through `freeze.looks_frozen()`
rather than re-written here. A frozen meter is the worst case of all: the
@@ -599,6 +601,57 @@ def blindness(projection, max_hold_s, frozen: bool) -> str | None:
return None
# SAFETY-90. Seconds of uncounted time below which nothing is filled: float
# noise between the averager's sums and the wall clock, never a real gap.
GAP_TOL_S = 1e-3
def gap_w(ceiling_w, *seen_w) -> float:
"""SAFETY-90: the watts a meter gap is counted at. The highest of the
limit and `seen_w`, the import with the batteries at 0 W (grid + their
discharge) just before and just after the gap; None entries are skipped.
DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
return max([float(ceiling_w)] + [float(w) for w in seen_w if w is not None])
def fill_gaps(projection, *, tail_s, max_hold_s, unseen_s, gap_w, unseen_w,
held_ws=0.0):
"""SAFETY-90: `projection` with the seconds the averager did not count
(a meter gap past `max_hold_s`) counted at `gap_w`, and those from before
this run's first reading (`unseen_s`) at `unseen_w`. `held_ws` is added
too: what the gap's first `max_hold_s`, credited at the reading before
it, lacks to reach `gap_w`. Returns
`(projection, gap seconds filled, unseen seconds filled)`.
`tail_s` is the age of the held reading: up to `max_hold_s` of it is left
as before (the averager credits it once the next reading lands). Past
that the gap is still open, nothing is filled and `blindness()` answers
as before. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
if projection.block_start is None or max_hold_s is None:
return projection, 0.0, 0.0
wall = float(projection.elapsed_s)
tail = min(max(0.0, float(tail_s)), wall)
if tail > float(max_hold_s):
return projection, 0.0, 0.0
covered = wall - uncovered_s(projection)
missing = wall - covered - tail
if missing <= GAP_TOL_S:
missing = 0.0
if held_ws <= 0.0:
return projection, 0.0, 0.0
unseen = min(missing, max(0.0, float(unseen_s)))
gap = missing - unseen
add_ws = gap * float(gap_w) + unseen * float(unseen_w) + float(held_ws)
done = covered + missing
return (replace(
projection,
projected_w=float(projection.projected_w) + add_ws / QUARTER_S,
elapsed_w=((float(projection.elapsed_w) * covered + add_ws) / done
if done > 0.0 else float(projection.elapsed_w)),
covered_frac=(done / wall if wall > 0.0
else float(projection.covered_frac))), gap, unseen)
def discharge_permitted(soc, soc_floor) -> str | None:
"""SAFETY-24. May the guard size a forced discharge, or why may it not?
+1
View File
@@ -410,6 +410,7 @@
"step_in": "The batteries step in above {kw}.",
"step_in_would": "The batteries would step in above {kw}.",
"period": "This 15-minute period is heading for {kw}.",
"period_unmeasured": "Part of this 15-minute period was not measured.",
"prices": {
"now": "Price now {eur} per kWh, all in.",
"today": "Today: lowest {lo} at {lo_at}, highest {hi} at {hi_at}.",
+1
View File
@@ -347,6 +347,7 @@
"step_in": "De batterijen springen bij boven {kw}.",
"step_in_would": "De batterijen zouden bijspringen boven {kw}.",
"period": "Dit kwartier gaat richting {kw}.",
"period_unmeasured": "Een deel van dit kwartier is niet gemeten.",
"prices": {"now": "Prijs nu {eur} per kWh, alles inbegrepen.", "today": "Vandaag: laagste {lo} om {lo_at}, hoogste {hi} om {hi_at}.", "none": "Geen prijzen voor vandaag."},
"tomorrow": {"known": "De prijzen van morgen zijn binnen.", "unknown": "De prijzen van morgen komen meestal na 13:00."},
"region": {"preset": "Heffingen en btw: {region}, cijfers van {year}.", "own": "Heffingen en btw: uw eigen bedrag."},
+3 -2
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller
version: "0.14.0"
version: "0.16.0"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
@@ -630,7 +630,8 @@ options:
# contradiction. Changing one of these numbers now moves every consumer.
maintenance_enabled: false
maintenance_interval_days: 28
maintenance_start_hour: 10
# MAINT-10: a LOCAL hour, in Home Assistant's time zone.
maintenance_start_hour: 19
# ⚠️ BOTH MUST STAY AT OR BELOW max_w, and that is why they are 2000 rather
# than the 2500 they shipped as. The cheap-window charge shaping asks the
+9
View File
@@ -400,6 +400,15 @@ check("...with its duration from the battery's capacity and the cycle's rates "
"and hold time",
present(nth(_cyc)) and _cyc[0]["amount"].startswith("Takes about ")
and _cyc[0]["end_h"] > _cyc[0]["start_h"] + 2)
# MAINT-10: with the site's zone set, the hour is read on the local clock.
from app.tariff import _EUTimezone # noqa: E402
c_ml = make(dict(MAINT, maintenance_start_hour=14))
for _mm in c_ml.maints:
_mm.tz = _EUTimezone()
_cyc_l = [r for r in status(c_ml)["plan"]["runs"] if r["kind"] == "cycle"]
check("MAINT-10: start hour 14 in the local zone is drawn at 14:00, the "
"instant the cycle starts", len(_cyc_l) == 1
and field(nth(_cyc_l), "from") == "14:00")
c_m.maints[0].store.set("phase", "drain")
s_md = status(c_m)
check("a cycle under way is drawn from now, and the battery's now line says "
+543
View File
@@ -0,0 +1,543 @@
"""MAINT-10: the maintenance start hour is LOCAL time. `python3 test_maint10.py`
Graded here: the slot on ordinary days and on both DST change days, the zone
source and its fallbacks, the 19:00 default, the startup wiring, and that the
controller's commands are unchanged once the start instant is the same.
See DECISIONS/MAINT-10-local-start-hour.md.
Mutating what this file grades? Read MUTATION.md first (stale bytecode).
⚠️ The CI image has no tzdata, so every DST check runs on tariff.py's encoded
EU rule and, where zoneinfo works, on Europe/Brussels from tzdata as well.
"""
import asyncio
import functools
import logging
import os
import re
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools"))
import app.main as M # noqa: E402
from app import maintenance as MT # noqa: E402
from app.maintenance import ( # noqa: E402
DRAIN, IDLE, MAINT_OPTION_DEFAULTS, MaintConfig, Maintenance,
in_start_slot, next_slot_start, site_zone,
)
from app.tariff import _EUTimezone # noqa: E402
HERE = Path(__file__).resolve().parent
UTC = timezone.utc
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
ZONES = [("encoded EU rule", _EUTimezone())]
try:
from zoneinfo import ZoneInfo
ZONES.append(("zoneinfo Europe/Brussels", ZoneInfo("Europe/Brussels")))
except Exception as err: # noqa: BLE001
print(f" zoneinfo unavailable here ({err}); graded on the EU rule only")
SPRING = datetime(2026, 3, 29, tzinfo=UTC) # 02:00 CET -> 03:00 CEST
SPRING27 = datetime(2027, 3, 28, tzinfo=UTC) # the next one, last Sunday of March
AUTUMN = datetime(2026, 10, 25, tzinfo=UTC) # 03:00 CEST -> 02:00 CET
SUMMER = datetime(2026, 7, 15, tzinfo=UTC)
WINTER = datetime(2026, 1, 15, tzinfo=UTC)
def slot_minutes(day_utc, hour, tz):
"""UTC minutes in the slot, over the LOCAL day of that date."""
t, out = day_utc - timedelta(hours=3), []
while t < day_utc + timedelta(hours=27):
if (t.astimezone(tz).date() == day_utc.date()
and in_start_slot(t, hour, tz)):
out.append(t)
t += timedelta(minutes=1)
return out
def one_block(mins):
"""`(start, minutes)` when the minutes form one contiguous run, else None."""
if not mins:
return None
for a, b in zip(mins, mins[1:]):
if b - a != timedelta(minutes=1):
return None
return mins[0], len(mins)
# =========================================================================== #
print("1. UTC stays what it was: the slot IS `now.hour == start_hour`")
t, same = datetime(2026, 1, 1, tzinfo=UTC), True
while t < datetime(2027, 1, 1, tzinfo=UTC):
for h in range(24):
if in_start_slot(t, h, UTC) != (t.hour == h):
same = False
t += timedelta(minutes=37)
check("over a year of instants, every hour 0-23: identical to the old test",
same)
check("a naive `now` is read as UTC, as before",
in_start_slot(datetime(2026, 7, 1, 19, 5), 19, UTC)
and not in_start_slot(datetime(2026, 7, 1, 18, 5), 19, UTC))
# =========================================================================== #
print("2. local time: 19 means 19:00 on the site's clock")
for label, tz in ZONES:
s = one_block(slot_minutes(SUMMER, 19, tz))
w = one_block(slot_minutes(WINTER, 19, tz))
check(f"[{label}] summer: 17:00-18:00 UTC (CEST), one hour",
s == (SUMMER + timedelta(hours=17), 60))
check(f"[{label}] winter: 18:00-19:00 UTC (CET), one hour",
w == (WINTER + timedelta(hours=18), 60))
check(f"[{label}] the old reading is gone: 19:00 UTC is not the slot "
f"in summer", not in_start_slot(SUMMER + timedelta(hours=19, minutes=5),
19, tz))
# =========================================================================== #
print("3. the DST change days, every start hour")
for label, tz in ZONES:
for name, day in (("spring", SPRING), ("spring 2027", SPRING27),
("autumn", AUTUMN)):
bad = []
for h in range(24):
b = one_block(slot_minutes(day, h, tz))
# the local day runs 23:00 UTC the day before to 22:00/23:00 UTC
if b is None or b[1] != 60:
bad.append((h, b))
check(f"[{label}] {name}: every hour 0-23 gives ONE slot of exactly "
f"60 minutes that day {bad or ''}", not bad)
sp2 = one_block(slot_minutes(SPRING, 2, tz))
check(f"[{label}] spring, hour 2 does not exist: it starts at 03:00 CEST "
f"(01:00 UTC), once", sp2 == (SPRING + timedelta(hours=1), 60))
check(f"[{label}] spring 2027 (28 March), hour 2: 01:00 UTC as well",
one_block(slot_minutes(SPRING27, 2, tz))
== (SPRING27 + timedelta(hours=1), 60))
check(f"[{label}] the evening before spring 2027, hour 19: 18:00 UTC "
f"(CET); that day, 17:00 UTC (CEST)",
one_block(slot_minutes(SPRING27 - timedelta(days=1), 19, tz))
== (SPRING27 - timedelta(hours=6), 60)
and one_block(slot_minutes(SPRING27, 19, tz))
== (SPRING27 + timedelta(hours=17), 60))
au2 = one_block(slot_minutes(AUTUMN, 2, tz))
check(f"[{label}] autumn, hour 2 happens twice: only the first, 02:00 CEST "
f"(00:00 UTC)", au2 == (AUTUMN, 60))
check(f"[{label}] autumn: the second 02:xx (CET) is not a slot",
not in_start_slot(AUTUMN + timedelta(hours=1, minutes=30), 2, tz))
check(f"[{label}] autumn, hour 19 still 18:00 UTC (CET)",
one_block(slot_minutes(AUTUMN, 19, tz))
== (AUTUMN + timedelta(hours=18), 60))
check(f"[{label}] spring, hour 19 already 17:00 UTC (CEST)",
one_block(slot_minutes(SPRING, 19, tz))
== (SPRING + timedelta(hours=17), 60))
# =========================================================================== #
class FakeStore:
def __init__(self):
self.data = {"phase": IDLE, "phase_started": None,
"last_completed": None, "last_start_attempt": None}
def set(self, k, v):
self.data[k] = v
def set_time(self, k, when):
self.data[k] = when
def get_time(self, k):
return self.data.get(k)
def get(self, k, default=None):
return self.data.get(k, default)
def save(self):
pass
def starts(day, hour, tz, *, abort_after=None, blocked=None):
"""Tick a due pack each minute around a day; the UTC instants in that
LOCAL day at which it entered DRAIN. `abort_after` aborts each drain that many minutes in (interlock);
`blocked(t)` defers it."""
m = Maintenance(MaintConfig(enabled=True, start_hour=hour), FakeStore(),
tz=tz)
out, since = [], None
t = day - timedelta(hours=2)
while t < day + timedelta(hours=26):
why = ""
if since is not None and abort_after is not None \
and t - since >= timedelta(minutes=abort_after):
why = "a test interlock"
if blocked is not None and blocked(t):
why = "the other battery is cycling"
was = m.phase
m.tick(t, soc=80, blocked_by=why)
if m.phase == DRAIN and was != DRAIN:
out.append(t)
since = t
if m.phase == IDLE:
since = None
t += timedelta(minutes=1)
return [x for x in out if x.astimezone(tz).date() == day.date()]
print("4. the state machine: one start per day, DST days included")
for label, tz in ZONES:
check(f"[{label}] spring, hour 2: one start, at 01:00 UTC (03:00 CEST)",
starts(SPRING, 2, tz, abort_after=5)
== [SPRING + timedelta(hours=1)])
# An aborted start waits RESTART_COOLDOWN_MIN (90). The repeated hour is
# 120 minutes long, so without the first-pass rule it would start again.
check(f"[{label}] autumn, hour 2, aborted after 5 min: NO second start "
f"in the repeated hour",
starts(AUTUMN, 2, tz, abort_after=5) == [AUTUMN])
check(f"[{label}] autumn, hour 2, deferred for the whole first pass: it "
f"does not start in the second pass either",
starts(AUTUMN, 2, tz, blocked=lambda t: t < AUTUMN + timedelta(hours=1))
== [])
check(f"[{label}] summer, hour 19: one start at 17:00 UTC",
starts(SUMMER, 19, tz, abort_after=5)
== [SUMMER + timedelta(hours=17)])
# =========================================================================== #
print("5. where the start is shown (homeview) is where it happens")
for label, tz in ZONES:
ok = True
for day in (SPRING, AUTUMN, SUMMER):
for h in (0, 2, 3, 10, 19, 23):
first = next(iter(slot_minutes(day, h, tz)), None)
if first is None:
continue
for probe in (first - timedelta(hours=5), first,
first + timedelta(minutes=59)):
if next_slot_start(probe, h, tz) != first:
ok = False
check(f"[{label}] next_slot_start() lands on the slot's first minute, "
f"from before it and from inside it", ok)
# =========================================================================== #
print("6. which zone: Home Assistant, else the container's TZ, else UTC")
_FAKE = {"Europe/Brussels": ZONES[-1][1], "America/New_York": object(),
"UTC": UTC}
_real_load = MT._load_zone
MT._load_zone = lambda n: ((_FAKE[n], "") if n in _FAKE
else (None, f"{n!r} is not a known zone"
if n else "none given"))
try:
tz, name, src, _ = site_zone("Europe/Brussels", "", "America/New_York")
check("Home Assistant's zone wins over the container's TZ",
name == "Europe/Brussels" and src == "ha"
and tz is _FAKE["Europe/Brussels"])
tz, name, src, why = site_zone(None, "ClientConnectorError",
"America/New_York")
check("Home Assistant unavailable: the container's TZ, and why",
name == "America/New_York" and src == "tz"
and why == "ClientConnectorError")
tz, name, src, why = site_zone("Mars/Base", "", "Europe/Brussels")
check("an unknown Home Assistant zone falls back too",
name == "Europe/Brussels" and src == "tz" and "Mars/Base" in why)
tz, name, src, why = site_zone(None, "no answer", None)
check("neither: UTC", tz is UTC and src == "utc" and "no answer" in why)
finally:
MT._load_zone = _real_load
if len(ZONES) > 1:
check("the real loader resolves a real name and refuses a made-up one",
MT._load_zone("Europe/Brussels")[0] is not None
and MT._load_zone("Mars/Base")[0] is None)
check("the real loader refuses an empty name without raising",
MT._load_zone("")[0] is None and MT._load_zone(None)[0] is None)
# =========================================================================== #
print("7. the default is 19, an explicit value is honoured")
cfg_text = (HERE / "config.yaml").read_text()
opts_block = cfg_text.split("\nschema:")[0]
check("config.yaml options: ships 19",
re.search(r"^ maintenance_start_hour: 19$", opts_block, re.M) is not None)
check("config.yaml schema: still int(0,23)",
re.search(r"^ maintenance_start_hour: int\(0,23\)$", cfg_text, re.M)
is not None)
check("the in-code mirror says 19",
MAINT_OPTION_DEFAULTS["maintenance_start_hour"] == 19
and MaintConfig().start_hour == 19
and MaintConfig.from_options({}).start_hour == 19)
check("an explicit 10 stays 10 (a stored option is never rewritten)",
MaintConfig.from_options({"maintenance_start_hour": 10}).start_hour == 10)
_eu = ZONES[0][1]
_m10 = Maintenance(MaintConfig.from_options(
{"maintenance_enabled": True, "maintenance_start_hour": 10}),
FakeStore(), tz=_eu)
check("...and now means 10:00 local: 08:00 UTC in summer, not 10:00 UTC",
_m10.wants_start(SUMMER + timedelta(hours=8, minutes=1))
and not _m10.wants_start(SUMMER + timedelta(hours=10, minutes=1)))
check("a Maintenance built with no zone reads UTC, as before",
Maintenance(MaintConfig(), FakeStore()).tz is UTC)
# =========================================================================== #
print("8. startup: Home Assistant's zone is asked for and reaches the machines")
class _Resp:
def __init__(self, body, status=200):
self.body, self.status = body, status
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def json(self, content_type=None):
if isinstance(self.body, Exception):
raise self.body
return self.body
async def text(self):
return "{}"
class _Session:
def __init__(self, zone_body):
self.zone_body, self.asked = zone_body, []
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
def get(self, url, **kw):
self.asked.append(url)
self.timeouts = getattr(self, "timeouts", []) + [kw.get("timeout")]
if url.endswith("/core/api/config"):
return _Resp(self.zone_body)
return _Resp({"data": {}})
s = _Session({"time_zone": "Europe/Brussels"})
check("ha_time_zone() reads /core/api/config's time_zone",
asyncio.run(M.ha_time_zone(s, "tok")) == ("Europe/Brussels", "")
and s.asked == [M.CORE_API + "/config"])
# Review: HA can take minutes to start (2026-10-05); the ask must not wait on it.
check("...with a time budget of at most 3 s, so a slow HA cannot hold up start",
s.timeouts == [M.DOCS_LINK_TIMEOUT_S] and 0 < M.DOCS_LINK_TIMEOUT_S <= 3)
check("...and a failure is None plus the reason, never a raise",
asyncio.run(M.ha_time_zone(_Session(OSError("down")), "tok"))
== (None, "OSError: down")
and asyncio.run(M.ha_time_zone(_Session({}), "tok"))[0] is None)
class _Built(Exception):
pass
def amain_zone(zone_body, env_tz):
"""Run the real amain() up to the Controller; the tz its machines get."""
got, logs = {}, []
class _Capture(logging.Handler):
def emit(self, rec):
logs.append((rec.levelname, rec.getMessage()))
class _Hass:
async def limits(self, e):
return (-5000.0, 5000.0, 1.0)
async def mqtt_service(self):
raise RuntimeError("no broker in this test")
real = {k: getattr(M, k) for k in ("load_options", "HomeAssistant",
"validate_startup_config",
"mode_announcement", "Controller")}
real_session, real_store = M.aiohttp.ClientSession, None
import app.store
real_store = app.store.Store
def _controller(opts, hass, store, pub, *, stop, site_tz=None, **kw):
got["tz"] = site_tz
raise _Built()
M.load_options = lambda: {"setpoint_entity": "number.sp"}
M.HomeAssistant = lambda session, **kw: _Hass()
M.validate_startup_config = lambda *a, **k: []
M.mode_announcement = lambda opts, store: ((), ())
M.Controller = _controller
M.aiohttp.ClientSession = lambda *a, **k: _Session(zone_body)
app.store.Store = lambda *a, **k: object()
old_tz = os.environ.pop("TZ", None)
if env_tz:
os.environ["TZ"] = env_tz
h = _Capture()
logging.getLogger("goodwe").addHandler(h)
try:
asyncio.run(M.amain())
except _Built:
pass
finally:
logging.getLogger("goodwe").removeHandler(h)
for k, v in real.items():
setattr(M, k, v)
M.aiohttp.ClientSession = real_session
app.store.Store = real_store
os.environ.pop("TZ", None)
if old_tz is not None:
os.environ["TZ"] = old_tz
return got.get("tz"), [m for lvl, m in logs if "Maintenance start" in m], logs
MT._load_zone = lambda n: ((_FAKE[n], "") if n in _FAKE
else (None, "none given"))
try:
tz, lines, _ = amain_zone({"time_zone": "Europe/Brussels"}, "America/New_York")
check("amain(): Home Assistant's zone reaches the Controller",
tz is _FAKE["Europe/Brussels"])
check("...logged once, naming the zone and the source",
lines == ["Maintenance start hour is local time in Europe/Brussels, "
"from Home Assistant."])
tz, lines, _ = amain_zone(OSError("down"), "America/New_York")
check("amain(), Home Assistant unavailable: the container's TZ, said "
"plainly", tz is _FAKE["America/New_York"]
and lines == ["Maintenance start hour is local time in "
"America/New_York, from the add-on's time zone. Home "
"Assistant gave none: OSError: down."])
tz, lines, logs = amain_zone(OSError("down"), None)
check("amain(), neither: UTC, with a WARNING line",
tz is UTC and any(lvl == "WARNING" and "uses UTC" in m
for lvl, m in logs))
# Review: HA still starting (502) and the Supervisor's TZ is UTC.
tz, lines, logs = amain_zone(OSError("502"), "UTC")
check("amain(), Home Assistant unavailable and TZ is UTC: a WARNING "
"that says to restart", tz is UTC
and [(lvl, m) for lvl, m in logs if "Maintenance start" in m]
== [("WARNING", "Maintenance start hour uses UTC, because Home "
"Assistant did not answer. Restart the add-on when it is up.")])
tz, lines, logs = amain_zone(OSError("502"), "America/New_York")
check("...but a real TZ is used without a warning",
not any(lvl == "WARNING" for lvl, m in logs
if "Maintenance start" in m))
finally:
MT._load_zone = _real_load
# =========================================================================== #
print("9. nothing else in control moves: the same start instant, the same "
"commands")
import opt11_identity as rig # noqa: E402
import safety78_identity as s78 # noqa: E402
# rig's clock starts 2026-09-20 11:00 UTC = 13:00 CEST. The same drain,
# asked for as UTC hour 11 (the old reading) and as local hour 13 (the new).
def rows_with(start_hour, tz, n=1, both=False):
"""The rig's rows; `both=True` also returns the Controller."""
real_drain, real_ctl = s78.DRAIN, rig.Controller
s78.DRAIN = {"maintenance_enabled": True,
"maintenance_start_hour": start_hour}
rig.Controller = functools.partial(real_ctl, site_tz=tz)
try:
# s78.run() wraps rig.Controller once more for its own tariff db.
rows, c = s78.run(n, dur=600.0)
return (rows, c) if both else rows
finally:
s78.DRAIN, rig.Controller = real_drain, real_ctl
_utc11 = rows_with(11, None)
_loc13 = rows_with(13, _eu)
check("precondition: the drain really starts in the UTC-11 run",
any("drain" in r[-1][4] for r in _utc11))
check(f"start hour 11 UTC and 13 Europe/Brussels: every command, claim, "
f"reason and log line identical over {len(_utc11)} cycles",
_utc11 == _loc13)
_utc12 = rows_with(12, None)
_loc14 = rows_with(14, _eu)
check("precondition: no drain in the not-yet-due-hour run",
not any("drain" in r[-1][4] for r in _utc12))
check("outside the start hour (12 UTC / 14 local): identical as well",
_utc12 == _loc14)
check("...and the zone matters: hour 11 read in Brussels does NOT start",
not any("drain" in r[-1][4] for r in rows_with(11, _eu)))
# =========================================================================== #
print("10. two inverters: every pack reads the start hour in local time")
_r2u, _c2u = rows_with(11, None, n=2, both=True)
_r2l, _c2l = rows_with(13, _eu, n=2, both=True)
check("N=2: both packs get the site's zone, not just the first",
len(_c2l.maints) == 2 and all(mm.tz is _eu for mm in _c2l.maints))
check("N=2 precondition: a drain starts in the UTC-11 run",
any(DRAIN in r[-1][4] for r in _r2u))
check(f"N=2: start hour 11 UTC and 13 Europe/Brussels identical over "
f"{len(_r2u)} cycles", _r2u == _r2l)
check("N=2: hour 11 read in Brussels does NOT start either pack",
not any(DRAIN in r[-1][4] for r in rows_with(11, _eu, n=2)))
def pair_starts(day0, days, hour, tz, cycle_min=70):
"""Two due packs, ticked each minute through `arbitrate()` as main.py
does; a cycle takes about `cycle_min` minutes. `(pack, "DD HH:MM" UTC)`s."""
cfg = MaintConfig(enabled=True, start_hour=hour, hold_min=10)
ms = [Maintenance(cfg, FakeStore(), name=u, tz=tz)
for u in ("A", "B")]
ms[0].store.set_time("last_completed", day0 - timedelta(days=60))
ms[1].store.set_time("last_completed", day0 - timedelta(days=40))
half = (cycle_min - 10) // 2
soc, out = [80.0, 80.0], []
t = day0 - timedelta(hours=3)
while t < day0 + timedelta(days=days):
blocks = MT.arbitrate(ms, t)
for i, mm in enumerate(ms):
was = mm.phase
mm.tick(t, soc=soc[i], blocked_by=blocks[i])
if mm.phase == DRAIN:
soc[i] -= (80.0 - cfg.soc_floor) / half
elif mm.phase == MT.CHARGE:
soc[i] += (cfg.soc_target - cfg.soc_floor) / half
if mm.phase == DRAIN and was != DRAIN:
out.append((mm.name, t))
t += timedelta(minutes=1)
return [(n, x.strftime("%d %H:%M")) for n, x in out]
def _at(*pairs):
return [(n, x.strftime("%d %H:%M")) for n, x in pairs]
for label, tz in ZONES:
# 24 Oct (CEST) and 25 Oct (CET): 19:00 local is 17:00 then 18:00 UTC.
got = pair_starts(AUTUMN - timedelta(days=1), 2, 19, tz)
check(f"[{label}] N=2 across the autumn change, hour 19: A at 19:00 "
f"local on the 24th, B at 19:00 local on the 25th {got}",
got == _at(("A", AUTUMN - timedelta(hours=7)),
("B", AUTUMN + timedelta(hours=18))))
got = pair_starts(SPRING27 - timedelta(days=1), 2, 19, tz)
check(f"[{label}] N=2 across the spring 2027 change, hour 19: 18:00 UTC "
f"then 17:00 UTC {got}",
got == _at(("A", SPRING27 - timedelta(hours=6)),
("B", SPRING27 + timedelta(hours=17))))
# Hour 2 on 25 Oct: A takes the first pass (00:00 UTC) and is done in its
# second pass. B must not start there - it waits for the next day.
got = pair_starts(AUTUMN, 2, 2, tz)
check(f"[{label}] N=2, autumn, hour 2: B does not start in the repeated "
f"hour after A is done; it starts at 02:00 CET on the 26th {got}",
got == _at(("A", AUTUMN),
("B", AUTUMN + timedelta(days=1, hours=1))))
got = pair_starts(SPRING27, 2, 2, tz)
check(f"[{label}] N=2, spring 2027, hour 2: A at 03:00 CEST (01:00 UTC), "
f"B the next night at 02:00 CEST (00:00 UTC) {got}",
got == _at(("A", SPRING27 + timedelta(hours=1)),
("B", SPRING27 + timedelta(days=1))))
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
sys.exit(1)
print("all checks passed")
+7 -2
View File
@@ -129,6 +129,11 @@ finally:
# cd4d9cb5b7e29ff4ab4fe65dab5106683d8a599f09323fbdee4018ad9b28fcd1.
SAFETY78_N2_MAINT = \
"1afcd46b1b987868b110d2b0f4a7555848c9aa4728a836d4a2ce2a2107268497"
# SAFETY-95 changes it again ON PURPOSE: A's drain ends at t=1655 (its
# refusal), and B now stays at the house's 1420 W instead of jumping to
# 2500 W (1100 W export) and slewing back. Rows differ only at 1655-2105 s.
SAFETY95_N2_MAINT = \
"949e7c093dcf065257417de3f2f908e4d57311790abc9fba0a09aa4b5d64c0d2"
# SAFETY-91 changes n2 and n2_off ON PURPOSE: from t=1600 battery A is at its
# 20 % reserve, so B carries the house first and A gives last. With
# `_held_back()` reverted to SAFETY-87's (held only for the peak) both are
@@ -165,8 +170,8 @@ check("...n2 and n2_off are the SAFETY-91 digest (A at its reserve gives "
and _n2_s87 == SAFETY94_N2_S87)
check("...and n2_maint is the SAFETY-78 digest: the N=2 drain that a5dddad "
"refused on 392 of 540 cycles now runs (test_safety78.py), bounded by "
"SAFETY-85",
_now["n2_maint"] == SAFETY78_N2_MAINT != A5DDDAD["n2_maint"])
"SAFETY-85, its end handed back by SAFETY-95",
_now["n2_maint"] == SAFETY95_N2_MAINT != A5DDDAD["n2_maint"])
check("...and no cycle in those runs filed a `mode` claim, and only "
"n2_maint's (SAFETY-85: maintenance at N=2) handed `compute()` a bound",
_claims and not any(c.source == "mode" for cl in _claims for c in cl)
+5 -4
View File
@@ -716,10 +716,11 @@ check("the drain's commitment window is DERIVED from the phase machine's own "
"timeout, so the gate and maintenance.py cannot drift apart",
near(MAINTENANCE_DRAIN.commits_h, DRAIN_TIMEOUT_MIN / 60.0)
and near(MAINTENANCE_CHARGE.commits_h, CHARGE_TIMEOUT_MIN / 60.0))
# The shipped `maintenance_start_hour` is 10:00 local, so a drain started on
# schedule runs into the evening - hours a morning-only day-ahead print has not
# covered. That is the ticket's scenario and it is where the window is placed.
START = D0 + timedelta(hours=MaintConfig().start_hour)
# A drain started at 10:00 local runs into the evening - hours a morning-only
# day-ahead print has not covered. That is the ticket's scenario, so the hour is
# pinned here: 10 was the shipped default until MAINT-10 moved it to 19, and an
# install that stored 10 still starts then.
START = D0 + timedelta(hours=10)
_hours = derived(lambda: committed_hours(START, MAINTENANCE_DRAIN))
check(f"...and it commits {len(_hours) if not isinstance(_hours, _Raised) else _hours} "
f"whole hours, one per hour of the timeout",
+1 -1
View File
@@ -187,7 +187,7 @@ check("⭐ a draining battery that is dropped (stale link) adds no floor: B "
"covers the 1500 W house, not the 2000 W drain rate, and the wait is "
"logged once",
"dropped inverter-a" in _l_rows[-1][7]
and cmd(_l_rows[-1])["b"] == 1500.0
and cmd(_l_rows[-1])["b"] == 1510.0
and sum("cannot give power now" in e for e in _l.events) == 1)
# ============================================================================ #
+483
View File
@@ -0,0 +1,483 @@
"""Runnable check for SAFETY-90: the peak guard goes on after a P1 meter gap.
`python3 test_safety90.py`
A gap past `meter_max_age_s` left seconds the quarter average never counted,
so the guard was blind for the rest of the quarter: a HOLD gave 0 W and the
quarter raised the month peak (SAFETY-86 rig case 7b: 3964.8 W against a
3450 W aim). Now the missed seconds count at `peakguard.gap_w()`, and so do
the seconds before the add-on's first reading, with a plain line on the
page. Drives the REAL Controller on a lagging battery (10 s), with the P1
telegrams stopped for each gap. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md.
1. N=1 HOLD: gaps of 30 s, 60 s and 5 min mid-quarter land at or under the aim.
2. N=2 HOLD, B able: the same.
3. N=2 HOLD, B held at its reserve: the same, and B gives nothing (O2).
4. A gap across a quarter edge; the house rising during a gap.
5. Start-up mid-quarter: the guard runs, the page says what it assumed.
6. While the gap lasts the guard is blind as before; no gap, no change.
7. fill_gaps() and gap_w() at their boundaries.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import logging
import math
import os
import sys
import types
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 safety84_identity as s84 # noqa: E402
import app.p1 as p1mod # noqa: E402
import app.tariff as tariff # noqa: E402
from app import homeview, messages, peakguard # noqa: E402
from app.maintenance import HOLD # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
from app.store import unit_key # noqa: E402
from app.tariff import QUARTER_S, Projection # noqa: E402
fails = []
A, B = "inverter-a", "inverter-b"
AIM = 3450.0 # peak_cap_w 3500 - peak_margin_w 50
TOL = 5.0 # W over the aim a quarter may land (SAFETY-79)
CUT = 10.0
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def store(phase_of, n):
s = rig._Store()
for u in (A, B)[:n]:
key = (lambda k: k) if n == 1 else (lambda k, u=u: unit_key(u, k))
if u in phase_of:
s.data[key("phase")] = phase_of[u]
s.data[key("phase_started")] = rig.BASE.isoformat()
else:
s.data[key("last_completed")] = rig.BASE.isoformat()
return s
def run(house, socs, dur, *, n, gaps=(), start=0.0, lag=10.0, on_cycle=None):
"""Rows and controller. A holds full; `socs` are the starting levels
(B's follows its power). `gaps`: (t0, t1), no telegram for t0 <= t < t1.
`start`: the add-on starts this many s into the run (mid-quarter). The
P1 clock is simulated, so a gap really goes stale. `c.truth` is the grid
each second, which the quarter really averages."""
clock = [0.0]
soc, last, truth = dict(socs), [start], []
def before(c, t0):
if on_cycle is not None:
on_cycle(c, t0) # the state the last cycle left
t = t0 + start
clock[0] = t
g = c.hass.grid()
truth.append((t, g))
for u in soc:
soc[u] = max(CUT, soc[u] - c.hass.power(u) * (t - last[0])
/ 3600.0 / 153.6)
last[0] = t
if any(a <= t < b for a, b in gaps):
return
imp, exp = split_signed(g)
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=rig.BASE + timedelta(seconds=t0),
ingest_mono=1e6 + t))
def power(self, u):
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)
return 0.0 if self.soc(u, self.t) <= CUT and a > 0 else float(round(a))
real = (rig._Hass.power, rig.TICK, rig.BASE, p1mod.time)
rig._Hass.power, rig.TICK = power, 1.0
rig.BASE = real[2] + timedelta(seconds=start)
p1mod.time = types.SimpleNamespace(monotonic=lambda: 1e6 + clock[0])
try:
rows, c = s84.run(
n, load=house if callable(house) else (lambda t: house),
cheap=False, store=store({A: HOLD}, n), before=before, dur=dur,
soc=lambda u, t: soc[u],
extra={"maintenance_hold_min": 600})
finally:
rig._Hass.power, rig.TICK, rig.BASE, p1mod.time = real
c.truth = truth
return rows, c
def without_fix(fn):
"""release/1.0 3ed3302: nothing is filled, so a gap blinds the guard."""
real = peakguard.fill_gaps
peakguard.fill_gaps = lambda p, **kw: (p, 0.0, 0.0)
try:
return fn()
finally:
peakguard.fill_gaps = real
def quarter(c, q):
"""What quarter `q` (0 = the run's first) really averages, in W."""
return sum(max(g, 0.0) for t, g in c.truth if int(t // QUARTER_S) == q) \
/ QUARTER_S
def cmd(r):
return dict(r[2])
def mean(rows, u, lo, hi):
rs = [cmd(r)[u] for r in rows if lo <= r[0] < hi]
return round(sum(rs) / len(rs))
TABLE = []
def case(tag, n, house, socs, gaps, q=0, dur=1800.0):
"""One gap case, after and before the fix; prints and records it."""
new = run(house, socs, dur, n=n, gaps=gaps)
old = without_fix(lambda: run(house, socs, dur, n=n, gaps=gaps))
a, b = quarter(new[1], q), quarter(old[1], q)
print(f" {tag}: quarter {a:.1f} W (before the fix {b:.1f} W), aim "
f"{AIM:.0f}")
TABLE.append((tag, n, round(a, 1), round(b, 1)))
return new, old, a, b
# ============================================================================ #
print("1. N=1 HOLD, house 6500 W (5500 W for 5 min, so it can be held)")
for tag, gap, house in (("30 s", (300, 330), 6500.0),
("60 s", (300, 360), 6500.0),
("5 min", (300, 600), 5500.0)):
(rows, c), _old, a, b = case(f"N=1 gap {tag}", 1, house, {"a": 99.0},
[gap])
check(f"⭐ N=1, {tag} gap mid-quarter: the quarter lands at or under the "
f"aim ({a:.1f} W)", a <= AIM + TOL)
if tag != "30 s":
check(f"...non-vacuous: before the fix it went over ({b:.1f} W)",
b > AIM + 100)
check("...and once readings are back the battery gives at least the "
"house over the aim",
mean(rows, "a", gap[1] + 15, 900) >= house - AIM)
check("...the action log says what the gap was counted at",
any("PEAK_GUARD_GAP" in e for e in c.events))
# ============================================================================ #
print("\n2. N=2 HOLD (A holding), B able at 60 %, house 6500 W")
for tag, gap, house in (("30 s", (300, 330), 6500.0),
("60 s", (300, 360), 6500.0),
("5 min", (300, 600), 5500.0)):
(rows, c), _old, a, b = case(f"N=2 gap {tag}", 2, house,
{"a": 99.0, "b": 60.0}, [gap])
check(f"⭐ N=2, {tag} gap: the quarter lands at or under the aim "
f"({a:.1f} W)", a <= AIM + TOL)
if tag != "30 s":
check(f"...non-vacuous: before the fix it went over ({b:.1f} W)",
b > AIM + 100)
check("...B gives first: at its full 2500 W once readings are back",
mean(rows, "b", gap[1] + 30, 900) >= 2450)
# ============================================================================ #
print("\n3. N=2 HOLD, B held at 15 % (under its 20 % reserve), house 5500 W "
"(4500 W for 5 min: A alone gives 2500 W)")
for tag, gap, house in (("30 s", (300, 330), 5500.0),
("60 s", (300, 360), 5500.0),
("5 min", (300, 600), 4500.0)):
(rows, c), (orows, _oc), a, b = case(
f"N=2 B held, gap {tag}", 2, house, {"a": 99.0, "b": 15.0}, [gap])
check(f"⭐ N=2 B held, {tag} gap: the quarter lands at or under the aim "
f"({a:.1f} W)", a <= AIM + TOL)
check("⭐ O2: the held B gives nothing once readings are back; the "
"holding A gives the need",
all(cmd(r)["b"] == 0.0 for r in rows if gap[1] <= r[0] < 900)
and mean(rows, "a", gap[1] + 15, 900) >= house - AIM)
if tag != "30 s":
check("...non-vacuous: before the fix the blind guard left A at 0 W "
"and the held B was drawn at 2500 W as last resort",
mean(orows, "a", gap[1] + 30, 900) == 0
and mean(orows, "b", gap[1] + 30, 900) >= 2450)
rows, c = run(5500.0, {"a": 99.0, "b": 15.0}, 1000.0, n=2,
gaps=[(300, 360), (899, 910)])
edge = [(r[0], cmd(r)["a"], cmd(r)["b"]) for r in rows if 900 <= r[0] < 915]
print(f" a 60 s gap, then the edge's telegram 11 s late: {edge[:4]}; the "
f"second before the edge (O1, not this card): "
f"{[(r[0], cmd(r)['a'], cmd(r)['b']) for r in rows if r[0] == 899]}")
check("⭐ ...a quarter that had a gap still hands over at its edge (SAFETY-91): "
"with the edge's telegram late the held B gives nothing and A keeps "
"giving", all(b == 0.0 and a > 1500.0 for _t, a, b in edge))
# ============================================================================ #
print("\n4. a gap across a quarter edge, and a house that rises in a gap")
for n, socs in ((1, {"a": 99.0}), (2, {"a": 99.0, "b": 60.0})):
(rows, c), _o, a, b = case(f"N={n} gap across the edge", n, 6500.0, socs,
[(870, 930)], q=1)
check(f"⭐ N={n}: the quarter the readings come back in lands at or "
f"under the aim ({a:.1f} W; before the fix {b:.1f} W)",
a <= AIM + TOL and b > AIM + 100)
check("...the quarter that ended in the gap could not be steered after "
"it, and stays under the limit itself",
quarter(c, 0) <= 3500.0)
def rising(t):
return 3000.0 if t < 320 else 6500.0
for n, socs in ((1, {"a": 99.0}), (2, {"a": 99.0, "b": 15.0})):
house = rising if n == 1 else (lambda t: 3000.0 if t < 320 else 5500.0)
(rows, c), _o, a, b = case(f"N={n} house rises in a 60 s gap", n, house,
socs, [(300, 360)])
check(f"⭐ N={n}: the house goes from 3000 W to over the limit during "
f"the gap; the import seen after it is counted and the quarter "
f"lands at or under the aim ({a:.1f} W)", a <= AIM + TOL)
for n, socs in ((1, {"a": 99.0}), (2, {"a": 99.0, "b": 15.0})):
big = 6500.0 if n == 1 else 5500.0
(rows, c), _o, a, b = case(
f"N={n} house falls in a 5 min gap", n,
lambda t, big=big: big if t < 560 else 2000.0, socs, [(300, 600)])
check(f"⭐ N={n}: the house falls to 2000 W just before readings return; "
f"the import before the gap (batteries at 0 W) is counted and the "
f"quarter lands at or under the aim ({a:.1f} W; before {b:.1f} W, "
f"at N=2 by drawing the held B)",
a <= AIM + TOL and (n == 2 or b > AIM + 100))
(rows, c), _o, a, b = case(
"N=1 5 min gap at 3000 W, then 60 s gap at 6500 W next quarter", 1,
lambda t: 3000.0 if t < 900 else 6500.0, {"a": 99.0},
[(300, 600), (1200, 1260)], q=1)
check(f"⭐ ...each quarter's gap is counted on its own: a long gap at a low "
f"load does not hide a shorter one in the next quarter ({a:.1f} W)",
a <= AIM + TOL and sum("PEAK_GUARD_GAP" in e for e in c.events) == 2)
def dip(t):
return 3000.0 if 450 <= t < 700 else 6500.0
(rows, c), _o, a, b = case("N=1 two gaps, the second at a lower load", 1,
dip, {"a": 99.0}, [(300, 360), (500, 560)])
at = [float(e.split("counted at ")[1].split(" W")[0]) for e in c.events
if "PEAK_GUARD_GAP" in e]
check(f"⭐ ...two gaps in one quarter: the second, at a lower load, does not "
f"price the first one down (counted at {at}; {a:.1f} W)",
a <= AIM + TOL and len(at) == 2 and min(at) >= 6400.0)
# ============================================================================ #
print("\n5. start-up mid-quarter (the add-on starts 450 s into the quarter)")
UI = {}
def grab(c, t):
if t == 60.0:
UI["unmeasured"] = c.peak_unmeasured_s
UI["rows"] = [r for r in c.checks() if r.get("id") == "peak_guard"]
UI["en"] = homeview.month_peak(c, c.cycle_at)["line"]
homeview._LANG[0] = "nl"
try:
UI["nl"] = homeview.month_peak(c, c.cycle_at)["line"]
finally:
homeview._LANG[0] = "en"
if t == 453.0:
UI["later"] = (c.peak_unmeasured_s,
[r for r in c.checks() if r.get("id") == "peak_guard"])
def restored(t_dead, start=450.0):
"""TARIFF-01 restores the averager at start-up: 0..t_dead measured at
the aim, the held sample at t_dead (the restart's first second)."""
def fake(self, averager, now=None):
bs = int(rig.BASE.timestamp() - start)
averager.restore({"block": bs, "acc": AIM * t_dead,
"pp_acc": [AIM * t_dead], "elapsed": t_dead,
"last_t": bs + t_dead, "last_net": AIM,
"last_pp": [AIM], "phases": 1})
self.restore_state = "resumed"
return self.restore_state
return fake
def startup(house, socs, n, *, t_dead=None, on_cycle=None):
"""The add-on starts 450 s in. While it was down the batteries were at
0 W (the ESP32 watchdog), so the meter saw the house: the quarter is
that, or the restored part at the aim, plus what this run measured."""
real = tariff.PeakTracker.restore
if t_dead is not None:
tariff.PeakTracker.restore = restored(t_dead)
try:
rows, c = run(house, socs, 455.0, n=n, start=450.0,
on_cycle=on_cycle)
finally:
tariff.PeakTracker.restore = real
pre = 450.0 * house if t_dead is None else (
t_dead * AIM + (450.0 - t_dead) * house)
return rows, c, (pre + sum(max(g, 0.0) for t, g in c.truth if t < 900)) \
/ QUARTER_S
for n, socs, house in ((1, {"a": 99.0}, 5000.0),
(2, {"a": 99.0, "b": 15.0}, 4500.0)):
rows, c, land = startup(house, socs, n, on_cycle=grab if n == 1 else None)
orows, _oc, old = without_fix(lambda: startup(house, socs, n))
TABLE.append((f"N={n} start-up mid-quarter", n, round(land, 1),
round(old, 1)))
check(f"⭐ N={n}: the first 450 s (house {house:.0f} W, batteries at 0 W) "
f"count at the import seen first; the quarter lands at or under "
f"the aim ({land:.1f} W, before the fix {old:.1f} W)",
land <= AIM + TOL and (n == 2 or old > AIM + 100))
if n == 2:
check("...and the held B gives nothing (O2 at start-up); before the "
"fix it was drawn at 2500 W",
all(cmd(r)["b"] == 0.0 for r in rows if 1 <= r[0] < 450)
and mean(orows, "b", 30, 450) >= 2450)
check("...non-vacuous: before the fix A gave 0 W for the quarter",
mean(orows, "a", 30, 450) == 0)
rows, c, land = startup(6500.0, {"a": 99.0}, 1)
check(f"⭐ N=1, house 6500 W: more than the battery can make up; A gives its "
f"full 5000 W and the alarm says so ({land:.1f} W)",
mean(rows, "a", 30, 450) >= 4900
and any("PEAK_GUARD_SATURATED" in e for e in c.events))
for t_dead in (300.0, 390.0, 440.0):
rows, c, land = startup(6500.0, {"a": 99.0}, 1, t_dead=t_dead)
TABLE.append((f"N=1 restart, down {450 - t_dead:.0f} s", 1,
round(land, 1), round(without_fix(lambda: startup(
6500.0, {"a": 99.0}, 1, t_dead=t_dead))[2], 1)))
check(f"⭐ restart mid-quarter, down {450 - t_dead:.0f} s at house "
f"6500 W (the restored reading's held 30 s topped up too): the "
f"quarter lands at or under the aim ({land:.1f} W)",
land <= AIM + TOL)
rows, c = run(6500.0, {"a": 99.0}, 1350.0, n=1, start=450.0,
gaps=[(1200, 1260)])
a = quarter(c, 1)
TABLE.append(("N=1 start-up, then 60 s gap next quarter", 1, round(a, 1),
round(quarter(without_fix(lambda: run(
6500.0, {"a": 99.0}, 1350.0, n=1, start=450.0,
gaps=[(1200, 1260)]))[1], 1), 1)))
check(f"...the start-up assumption stays in its own quarter: a 60 s gap in "
f"the next one is counted as a gap and lands at or under the aim "
f"({a:.1f} W)", a <= AIM + TOL)
print(f" page: {UI.get('rows')}\n EN {UI.get('en')!r}\n"
f" NL {UI.get('nl')!r}")
check("⭐ the page says plainly that part of the period was not measured, "
"in English and Dutch",
UI.get("unmeasured", 0) > 400
and UI["rows"][0]["text"] == messages.row_peak_guard_unmeasured()
and UI["en"].startswith("Part of this 15-minute period was not "
"measured.")
and UI["nl"].startswith("Een deel van dit kwartier is niet gemeten."))
check("...and stops saying it in the next quarter",
UI["later"][0] == 0.0 and UI["later"][1][0]["text"]
!= messages.row_peak_guard_unmeasured())
# ============================================================================ #
print("\n6. while the gap lasts, and with no gap at all")
LIVE = {}
def live(c, t):
if t == 350.0:
LIVE["g"] = c.peak_guard
run(6500.0, {"a": 99.0}, 400.0, n=1, gaps=[(300, 360)], on_cycle=live)
check("⭐ 50 s into a gap the guard is blind as before (floor 0 W, no "
"charging): nothing is filled while the gap is open",
LIVE["g"].blind is not None and LIVE["g"].floor_w == 0.0)
for n, socs, house in ((1, {"a": 99.0}, 6500.0),
(2, {"a": 99.0, "b": 60.0}, 6500.0),
(2, {"a": 99.0, "b": 15.0}, 5500.0)):
rows, c = run(house, socs, 1000.0, n=n)
orows, _ = without_fix(lambda: run(house, socs, 1000.0, n=n))
check(f"⭐ N={n}, meter never drops: every row is identical with and "
f"without the fix, and nothing is filled",
rows == orows and not any("PEAK_GUARD_GAP" in e for e in c.events))
# ============================================================================ #
print("\n7. fill_gaps() and gap_w() at their boundaries")
T0 = rig.BASE
def proj(wall, covered, held=1000.0, acc_w=1000.0):
return Projection(projected_w=(acc_w * covered + held * (QUARTER_S - wall))
/ QUARTER_S, elapsed_w=acc_w, elapsed_s=wall,
covered_frac=covered / wall, complete=False,
block_start=T0, held_w=held)
def fill(p, tail, unseen=0.0, gw=4000.0, uw=3450.0):
return peakguard.fill_gaps(p, tail_s=tail, max_hold_s=30.0,
unseen_s=unseen, gap_w=gw, unseen_w=uw)
p = proj(400.0, 300.0) # 100 s uncounted
f, g, u = fill(p, 10.0)
check("90 s missed (100 s uncounted less a 10 s tail): counted at gap_w, "
"the tail left as before, no longer blind",
g == 90.0 and u == 0.0
and abs(f.projected_w - (p.projected_w + 90.0 * 4000.0 / QUARTER_S))
< 1e-9 and abs(peakguard.uncovered_s(f) - 10.0) < 1e-9
and peakguard.blindness(f, 30.0, False) is None
and peakguard.blindness(p, 30.0, False) is not None)
check("tail exactly max_hold_s: filled; one past it: the gap is open, "
"nothing filled",
fill(p, 30.0)[1] == 70.0 and fill(p, 30.001) == (p, 0.0, 0.0))
f, g, u = fill(p, 10.0, unseen=60.0)
check("unseen seconds come first and count at the aim, the rest at gap_w",
u == 60.0 and g == 30.0
and abs(f.projected_w - (p.projected_w + (60 * 3450.0 + 30 * 4000.0)
/ QUARTER_S)) < 1e-9)
check("nothing missed (or under 1 ms): the projection object is returned "
"unchanged",
fill(proj(400.0, 400.0), 0.0)[0] is not None
and fill(proj(400.0, 400.0), 0.0) == (proj(400.0, 400.0), 0.0, 0.0)
and fill(proj(400.0, 399.9995), 0.0)[1] == 0.0)
f, g, u = fill(proj(400.0, 400.0), 0.0)
f2 = peakguard.fill_gaps(proj(400.0, 400.0), tail_s=0.0, max_hold_s=30.0,
unseen_s=0.0, gap_w=4000.0, unseen_w=3450.0,
held_ws=9000.0)
check("held_ws (the gap's first 30 s topped up) is added even when nothing "
"is missing any more",
g == 0.0 and abs(f2[0].projected_w - f.projected_w - 10.0) < 1e-9)
z = peakguard.fill_gaps(Projection(0, 0, 0, 0, False, T0, 0), tail_s=0.0,
max_hold_s=30.0, unseen_s=0.0, gap_w=4000.0,
unseen_w=3450.0, held_ws=9000.0)
check("the first instant of a block (0 s elapsed) with held_ws: no division "
"by zero", abs(z[0].projected_w - 10.0) < 1e-9)
check("no open block: unchanged (blindness says so)",
fill(Projection(0, 0, 0, 0, False, None, 0), 0.0)[1:] == (0.0, 0.0))
check("gap_w(): the highest of the limit and the imports seen, None skipped",
peakguard.gap_w(3500, None, 3000.0) == 3500.0
and peakguard.gap_w(3500, 6500.0, None) == 6500.0
and peakguard.gap_w(3500, 3000.0, 4500.0) == 4500.0
and peakguard.gap_w(3500) == 3500.0)
# ============================================================================ #
print("\n case N quarter W before W aim")
for tag, n, a, b in TABLE:
print(f" {tag:<34}{n} {a:>9.1f} {b:>9.1f} {AIM:.0f}")
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all SAFETY-90 checks passed")
+578
View File
@@ -0,0 +1,578 @@
"""Runnable check for SAFETY-95: a maintenance drain that ends hands back
smoothly at N>=2. `python3 test_safety95.py`
At N=2, when battery A's drain ended (A switched Off, say) with the house at
1200 W, the drain's floor stayed in the loop's site target and landed on B:
B went 0 -> +1600 W in one cycle and exported. Now the loop starts from what
the others stood at plus A's new command, raised to the house's own point
when the house needs more (never above last cycle's start), and counts every
battery at its command while A settles. Drives the REAL Controller on a
lagging battery (10 s, 2 s meter, as on the rig), at N=2 and N=3.
DECISIONS/SAFETY-95-drain-end.md.
1. Every drain end (complete -> charge, Off, timeout, operator abort, stale
link, refusal) at houses of 1200 W and 4000 W and under a solar surplus.
2. The peak guard still binds across a drain end.
3. A drain end in the same cycle as another battery's drop; a missed poll.
4. N=1 is untouched; the rule at its boundaries.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import asyncio
import functools
import hashlib
import logging
import math
import os
import sys
import tempfile
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
from app import main as app_main, packmode # noqa: E402
from app.arbiter import Claim, P_MAINTENANCE, P_SAFETY # noqa: E402
from app.main import Controller # noqa: E402
from app.maintenance import (CHARGE, DRAIN, DRAIN_TIMEOUT_MIN, # noqa: E402
HOLD, IDLE, MaintResult)
from app.store import unit_key # noqa: E402
fails = []
SLEW = 400.0 # the rig's slew_w
TOL = SLEW / 4 # the loop's own overshoot on a lagging battery
AIM = -10.0 # the rig's target_grid_w
T = 300.0 # the drain ends here
M = {"maintenance_enabled": True, "maintenance_start_hour": 11}
A = "inverter-a"
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
# None on a tree without the fix, where this file prints the base numbers
# and runs red.
fixed = getattr(Controller, "_reseed_drain_end", None)
real_grid = getattr(Controller, "_drain_grid_w", None)
real_drop = Controller._reseed_drop
def _base_drop(self, live):
"""SAFETY-96's drop reseed as it was: a draining battery is not
skipped."""
keep, self._drain_ended = self._drain_ended, frozenset()
try:
real_drop(self, live)
finally:
self._drain_ended = keep
def without(fn):
"""Run `fn` with SAFETY-95 taken out (the base behaviour)."""
if fixed is None:
return fn()
Controller._reseed_drain_end = lambda self, r, live: setattr(
self, "_drain_ended", frozenset())
Controller._drain_grid_w = lambda self, live: float(self.grid)
Controller._reseed_drop = _base_drop
try:
return fn()
finally:
Controller._reseed_drain_end = fixed
Controller._drain_grid_w = real_grid
Controller._reseed_drop = real_drop
def options(n, extra):
o = rig._options(n, extra)
if n > 1:
o["inverters"] = [
{"name": f"inverter-{u}", "setpoint_entity": f"number.sp_{u}",
"soc_entity": f"sensor.soc_{u}", "batt_entity": f"sensor.batt_{u}",
"max_w": 2500, "bms_soc_floor": 10} for u in "abc"[:n]]
return o
def run(n, house, *, store, extra=None, soc=None, reject=None, dead=None,
before=None, dur=T + 120.0, lag=10.0, p1=False, back=None):
"""Rows of one run at N=`n` on plantsim's battery (800 W/s ramp, `lag`
s first-order lag) at a 2 s meter tick. Writes to the units in `reject`
(name -> from s) are refused, until `back` s; `dead` (u, from s[, to
s]): u gives nothing from then on (until `to`). Each row: (t, the
add-on's commands, site target, grid, phases, loop reason, peak floor).
opt11_identity's cycle, with a third battery."""
units = tuple("abc"[:n])
soc = soc or (lambda u, t: 50.0)
def refused(u, t):
return bool(reject) and u in reject and t >= reject[u] and (
back is None or t < back)
class Hass(rig._Hass):
def power(self, u):
st = self.__dict__.setdefault("_plant", {})
a, tgt, tl = st.get(u, (0.0, 0.0, self.t))
for k in range(int(round((self.t - tl) / 0.1))):
now = tl + (k + 1) * 0.1
c = self.cmd[u]
if dead is not None and u == dead[0] and dead[1] <= now < (
dead[2] if len(dead) > 2 else math.inf):
c = 0.0
d = c - 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)
return float(round(a))
async def set_number(self, e, v):
if refused(e[-1], self.t):
return False
self.cmd[e[-1]] = float(v)
return True
opts = options(n, dict(M, **(s79.P1 if p1 else {}), **(extra or {})))
db = os.path.join(tempfile.mkdtemp(prefix="s95-"), "tariff.db")
async def go():
hass = Hass(units, soc, lambda u, t: False,
lambda t: house(t) if callable(house) else house)
c = Controller(opts, hass, store, rig._Pub(),
stop=asyncio.Event(), peak_db_path=db)
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
real_dt, clock = app_main.datetime, [0.0]
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return rig.BASE + timedelta(seconds=clock[0])
app_main.datetime = _DT
rows = []
try:
t = 0.0
while t < dur:
hass.t = clock[0] = t
for inv in c.inverters:
u = inv.name[-1]
if refused(u, t) and inv.rejected_since is None:
inv.rejected_since = rig.BASE + timedelta(seconds=t)
if before is not None:
r = before(c, t)
if asyncio.iscoroutine(r):
await r
if p1:
s79._feed(c, t)
await c.read_inputs(now_mono=t)
await c.cycle()
g = c.peak_guard
rows.append((t, tuple(i.target for i in c.inverters),
c.target,
hass.grid(), tuple(m.phase for m in c.maints),
c.loop_reason,
None if g is None or not g.engaged
else g.floor_w))
t += 2.0
finally:
app_main.datetime = real_dt
return rows, c
return asyncio.run(go())
def store(n, **a):
"""A due for its monthly cycle (it drains from the first cycle); the
others finished theirs, so none starts here."""
s = rig._Store()
if n == 1:
for k, v in a.items():
s.data[k] = v
return s
for u in "bc"[:n - 1]:
s.data[unit_key(f"inverter-{u}", "last_completed")] = \
rig.BASE.isoformat()
for k, v in a.items():
s.data[unit_key(A, k)] = v
return s
def timed_store(n):
"""A's drain passes DRAIN_TIMEOUT_MIN at T; its last start was at 0 s,
so the abort's cooldown keeps it from starting again."""
return store(n, phase=DRAIN, phase_started=(
rig.BASE + timedelta(seconds=T - DRAIN_TIMEOUT_MIN * 60 - 1)
).isoformat(), last_start_attempt=rig.BASE.isoformat())
def off(c, t):
if t >= T:
c.store.data[unit_key(A, packmode.USE_KEY)] = False
async def abort(c, t):
if t == T:
await c.handle_action("maint_abort")
ENDS = {
"complete": lambda n, h, **k: run(
n, h, store=store(n), soc=lambda u, t:
5.0 if (u == "a" and t >= T) else 50.0, **k),
"Off": lambda n, h, **k: run(n, h, store=store(n), before=off,
extra={"battery_modes_enabled": True}, **k),
"timeout": lambda n, h, **k: run(n, h, store=timed_store(n), **k),
"abort": lambda n, h, **k: run(n, h, store=store(n), before=abort, **k),
# A's link goes stale; its board's watchdog sends it to 0 W after 25 s.
"stale link": lambda n, h, **k: run(n, h, store=store(n),
reject={"a": T}, dead=("a", T + 25.0),
dur=T + 200.0, **k),
# A gives nothing from T; SAFETY-73 learns it ~55 s later.
"refusal": lambda n, h, **k: run(n, h, store=store(n), dead=("a", T),
dur=T + 300.0, **k),
}
HOUSES = (1200.0, 4000.0, -1500.0)
def end_at(rows):
"""The first cycle A is not draining, or its command left the drain;
None if its drain never ended."""
return next((r[0] for r in rows if r[0] >= T and (
r[4][0] != DRAIN or r[1][0] < 2000.0)), None)
def measure(rows, n, house):
"""From the end on: the site target's largest step after the end
cycle (the slew's subject); the commanded export in the 20 s after it (the site command over
what the house needs, or over what the others stood at); the largest
grid import; the export beyond what the site exported before the end;
the largest grid step; the others' largest command step."""
te = end_at(rows)
if te is None: # no end: every measure is NaN, so every check FAILs
return dict(end=math.nan, site_step=math.nan, cmd_export=math.nan,
imp=math.nan, exp=math.nan, gstep=math.nan,
ostep=math.nan, rows=rows)
pre = next((r for r in reversed(rows) if r[0] < te), rows[0])
seg = [r for r in rows if r[0] >= te - 2.0]
aft = [r for r in rows if te <= r[0] <= te + 120.0]
others0 = sum(pre[1][1:])
site = [r[2] for r in seg]
return dict(
end=te,
site_step=max(abs(b - a) for a, b in zip(site[1:], site[2:])),
cmd_export=max(sum(r[1]) - max(house - AIM, others0) for r in aft
if r[0] <= te + 20.0),
imp=max(r[3] for r in aft),
exp=min(min(pre[3], AIM) - r[3] for r in aft),
gstep=max(abs(b[3] - a[3]) for a, b in zip(seg, seg[1:])),
ostep=max(abs(b[1][i] - a[1][i]) for a, b in zip(seg, seg[1:])
for i in range(1, n)),
rows=rows)
TABLE = []
def case(kind, n, house):
now = measure(ENDS[kind](n, house)[0], n, house)
old = measure(without(lambda: ENDS[kind](n, house))[0], n, house)
TABLE.append((kind, n, house, now, old))
return now, old
# ============================================================================ #
print("1. every drain end, N=2 and N=3, houses 1200 W, 4000 W and -1500 W")
for kind in ENDS:
for n in (2, 3):
for house in HOUSES:
now, old = case(kind, n, house)
tag = f"{kind}, N={n}, house {house:.0f} W"
# Abort zeroes the site (operator); a stale link's start is
# SAFETY-96's drop. Those are graded against base.
own = kind not in ("abort", "stale link")
check(f"{'⭐' if own else ' '} {tag}: no battery gives into an "
f"export after the end (commanded export "
f"{now['cmd_export']:.0f} W, base {old['cmd_export']:.0f}) "
f"and the site target moves at most one slew step a cycle "
f"after it ({now['site_step']:.0f} W, base "
f"{old['site_step']:.0f})",
now["cmd_export"] <= (TOL if own else max(
TOL, old["cmd_export"] + TOL))
and now["site_step"] <= SLEW)
check(f" {tag}: the grid imports no more than base (+50 W) "
f"({now['imp']:.0f} W, base {old['imp']:.0f}) and exports "
f"no more than it did before the end, or base (+50 W) "
f"({now['exp']:.0f} W more, base {old['exp']:.0f})",
now["imp"] <= old["imp"] + 50.0
and now["exp"] <= max(0.0, old["exp"]) + 50.0)
_by = {(k, n, h): (a, b) for k, n, h, a, b in TABLE}
for kind in ("complete", "Off", "timeout", "refusal"):
_now, _old = _by[(kind, 2, 1200.0)]
check(f"non-vacuous ({kind}, N=2, 1200 W): without the fix B steps "
f"more than a slew step into an export",
_old["cmd_export"] >= 300.0 and _old["ostep"] > SLEW
and _now["ostep"] < _old["ostep"])
_now, _old = _by[(kind, 2, -1500.0)]
check(f"non-vacuous ({kind}, solar surplus): without the fix B gives "
f"into the surplus", _old["cmd_export"] >= 1000.0
and _now["cmd_export"] <= TOL)
_now, _old = _by[("complete", 2, 1200.0)]
check("⭐ the rig case (A's drain completes at 1200 W): B covers the house "
"at once (0 -> 1210 W, not 1600), and the grid stays within 50 W of "
"its aim while A settles (base: +559 W import, B down to 230 W)",
[r[1][1] for r in _now["rows"] if r[0] == _now["end"]] == [1210.0]
and _now["imp"] <= 50.0 and _old["imp"] > 400.0)
_now, _old = _by[("complete", 3, 4000.0)]
check("N=3 at 4000 W: the others take A's share at once, as before (the "
"site does not drop), so the grid does not import",
_now["imp"] <= 50.0 and abs(_now["imp"] - _old["imp"]) <= 50.0)
_now, _old = _by[("stale link", 3, 4000.0)]
check(f"⭐ stale link, N=3, 4000 W: a dropped draining battery is this "
f"rule's, not the link-drop reseed's, so the grid imports at least "
f"50 W less than base ({_now['imp']:.0f} W, base {_old['imp']:.0f})",
_now["imp"] <= _old["imp"] - 50.0)
# ============================================================================ #
print("\n2. the peak guard still binds across a drain end")
_g = ENDS["Off"](2, 4000.0, p1=True, dur=T + 120.0)[0]
_gs = [r for r in _g if r[0] >= T - 20 and r[6] is not None]
print(f" guard engaged on {len(_gs)} cycles around the end; site "
f"{[sum(r[1]) for r in _g if T - 4 <= r[0] <= T + 8]}")
check("⭐ house 4000 W over a 2500 W limit, A switched Off mid-drain: on "
"every cycle the guard is engaged, the site gives at least its floor "
"(or every battery is at its rating)",
_gs and all(sum(r[1]) >= min(r[6], 2500.0 * 1) - 1.0 for r in _gs))
# ============================================================================ #
print("\n3. a drain end with another edge in the same cycle; a missed poll")
def c_drops(c, t):
"""C's writes are refused from T - 30 s, so it drops at T, the cycle A
is switched Off; its board goes to 0 W at once (T - 30 s)."""
off(c, t)
_e3 = run(3, 4000.0, store=store(3), before=c_drops, reject={"c": T - 30.0},
dead=("c", T - 30.0), extra={"battery_modes_enabled": True})[0]
_e3m = measure(_e3, 3, 4000.0)
print(f" A Off as C drops, house 4000 W: B "
f"{[r[1][1] for r in _e3 if T - 4 <= r[0] <= T + 10]}")
check("⭐ A switched Off on the cycle C drops (N=3, 4000 W): B takes over "
"with no export and the site moves one slew step a cycle after",
_e3m["cmd_export"] <= TOL and _e3m["site_step"] <= SLEW
and _e3m["end"] == T)
def c_back(c, t):
"""C's link (stale since T - 60 s, its board holding 0 W) answers again
at T, the cycle A is switched Off."""
off(c, t)
if t == T:
c.inverters[2].note_write(True, rig.BASE + timedelta(seconds=t))
_r3 = run(3, 1200.0, store=store(3), before=c_back, reject={"c": T - 60.0},
back=T, dead=("c", T - 60.0, T), extra={"battery_modes_enabled": True})[0]
_r3m = measure(_r3, 3, 1200.0)
print(f" A Off as C comes back, house 1200 W: B, C "
f"{[r[1][1:] for r in _r3 if T - 4 <= r[0] <= T + 6]}")
check("⭐ A switched Off on the cycle C comes back (N=3, 1200 W): no "
"export, one slew step a cycle after",
_r3m["cmd_export"] <= TOL and _r3m["site_step"] <= SLEW
and _r3m["end"] == T)
def missed_poll(at_t):
"""Battery B's power reading is missing on the cycle at `at_t` s."""
def before(c, t):
off(c, t)
if not getattr(c.hass, "_s95", False):
read = c.hass.number
async def number(e, invert=False):
if e == "sensor.batt_b" and c.hass.t == at_t:
return None
return await read(e, invert)
c.hass.number, c.hass._s95 = number, True
return before
_mp = run(2, 1200.0, store=store(2), before=missed_poll(T),
extra={"battery_modes_enabled": True})[0]
_mpo = without(lambda: run(2, 1200.0, store=store(2), before=missed_poll(T),
extra={"battery_modes_enabled": True}))[0]
print(f" a missed poll on the end cycle: B "
f"{[r[1][1] for r in _mp if T - 2 <= r[0] <= T + 8]}, without the fix "
f"{[r[1][1] for r in _mpo if T - 2 <= r[0] <= T + 8]}")
check("⭐ a missed poll on the cycle A is switched Off: the end still "
"reaches the loop on the next cycle (B covers the house, no export)",
max(sum(r[1]) for r in _mp if r[0] > T) <= 1200.0 - AIM + TOL
and max(sum(r[1]) for r in _mpo if r[0] > T) >= 1500.0)
# ============================================================================ #
print("\n4. N=1 is untouched; the rule at its boundaries")
def digest(rows):
return hashlib.sha256(repr(rows).encode()).hexdigest()
_n1 = [functools.partial(ENDS[k], 1, h) for k in ENDS
for h in (1200.0, -1500.0)]
check("⭐ N=1, every drain end (complete, Off, timeout, abort, stale link, "
"refusal), at 1200 W and under a surplus: every row byte-identical "
"with and without SAFETY-95",
[digest(f()[0]) for f in _n1] == [digest(without(f)[0]) for f in _n1])
_wave = functools.partial(run, 2, lambda t: 2400.0 + 1500 * math.sin(t / 40),
store=store(2, last_completed=rig.BASE.isoformat()),
dur=600.0)
check("N=2 healthy (a moving house, no maintenance): every row "
"byte-identical with and without SAFETY-95",
digest(_wave()[0]) == digest(without(_wave)[0]))
if fixed is None:
print(f"\n{len(fails)} FAILED (no _reseed_drain_end): " + ", ".join(fails))
sys.exit(1)
_c = run(2, 1200.0, store=store(2), dur=4.0)[1]
_pin = MaintResult(IDLE, True, 0.0, False, [])
_free = MaintResult(CHARGE, False, None, True, [])
_b = MaintResult(IDLE, False, None, False, [])
AB = {"inverter-a", "inverter-b"}
def reseeded(target, shares, batts, grid, res=_pin, live=AB, ended=(A,)):
"""`_reseed_drain_end` on a 2-battery controller: (target, i_w,
A's start)."""
_c.target, _c.i_w, _c._drain_ended = target, 123.0, frozenset(ended)
_c._loop_start, _c._settle, _c._drain_tail = {}, {}, set()
_c._now_mono, _c.grid = 50.0, grid
for inv in _c.inverters:
inv.target, inv.batt = shares[inv.name[-1]], batts[inv.name[-1]]
_c._reseed_drain_end([res, _b], set(live))
return _c.target, round(_c.i_w, 6), _c._loop_start.get(A)
# House 1200 W: A drains 2000, B 0, grid -800.
check("the house needs more than the others stand at: the start is the "
"house's own point (1210 W), the integrator with it",
reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
-800.0) == (1210.0, 1210.0, 0.0)
and _c._settle == {A: 50.0} and _c._drain_tail == {A}
and _c._drain_ended == frozenset())
check("...and while A settles the loop's error is the house's point minus "
"its start (0 here: it holds), not the meter's -790 W",
_c._drain_grid_w(AB) - AIM == 0.0)
check("a solar surplus (house -1500 W): the start is what the others stood "
"at plus A's new command (0 W), and the loop moves down from there",
reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
-3500.0)[0] == 0.0)
check("the house needs more than stood placed (4000 W, B at 2000): never "
"above last cycle's start",
reseeded(4000.0, {"a": 2000.0, "b": 2000.0},
{"a": 2000.0, "b": 2000.0}, 0.0)[0] == 4000.0)
check("a free battery (drain complete -> charge): its drain leaves the "
"start; a pin's value is its new command",
reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
-3500.0, res=_free)[2] == 0.0
and reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
-3500.0, res=MaintResult(HOLD, True, 0.0, False, []))[2]
== 0.0)
check("a dropped battery's power is the meter's: a board stuck at 2000 W "
"keeps B where it stood, and there is no tail to count",
reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
-800.0, live={"inverter-b"})[0] == 0.0
and _c._drain_tail == set())
_chg = MaintResult(CHARGE, True, -1500.0, False, [])
check("a drain that ends into a pinned charge: the pin is A's new command "
"while A is live, but 0 W once it is dropped (it is sent 0 W)",
reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
-3500.0, res=_chg)[2] == -1500.0
and reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
-3500.0, res=_chg, live={"inverter-b"}) == (0.0, 0.0, 0.0))
check("no drain ended: nothing moves (the integrator either)",
reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
-800.0, ended=()) == (2000.0, 123.0, None))
_c._drain_tail, _c._settle, _c.grid = {A}, {}, -800.0
check("the tail ends with A's settling (SAFETY-96's window)",
_c._drain_grid_w(AB) == -800.0 and _c._drain_tail == set())
_UA, _UB = (_c.actuator_of("maintenance", i) for i in _c.inverters)
_drain = MaintResult(DRAIN, True, 2000.0, False, [])
def drain_ends(extra, refused=0):
"""`_charge_track` with A draining last cycle, its drain SET and the
claims `extra` on A: which drains it ends."""
_c._draining_was, _c._drain_ended = frozenset({A}), frozenset()
_c._charging_was, _c._charge_ended = frozenset(), frozenset()
_c.live_units = lambda: (_UA, _UB)
_c.inverters[0].refused = refused
try:
_c._charge_track([Claim.set("maintenance", P_MAINTENANCE, 2000.0,
actuator=_UA)] + extra, [_drain, _b])
finally:
del _c.live_units
_c.inverters[0].refused = 0
return set(_c._drain_ended)
_rating = [Claim.limit("device", P_SAFETY, -2500.0, 2500.0, actuator=_UA)]
check("a drain that still stands does not end (its rating binds, no "
"contradiction, no refusal)", drain_ends(_rating) == set())
check("a drain whose limits contradict ends: A is sent 0 W (SAFETY-91's "
"pin as a floor against a closed pack)",
drain_ends(_rating + [
Claim.limit("maintenance", P_MAINTENANCE, lo=2000.0, actuator=_UA),
Claim.limit("pack", P_SAFETY, hi=0.0, actuator=_UA)]) == {A})
check("a refusal ends a drain only if A will not give (+1); one that will "
"not take (-1) leaves the drain standing",
drain_ends(_rating, refused=1) == {A}
and drain_ends(_rating, refused=-1) == set())
_n1c = run(1, 1200.0, store=store(1), dur=4.0)[1]
_n1c.target, _n1c.i_w, _n1c._drain_ended = 2000.0, 5.0, frozenset({A})
_n1c._reseed_drain_end([_pin], {A})
check("N=1: nothing moves", (_n1c.target, _n1c.i_w) == (2000.0, 5.0))
_seen = []
def _spy(self, maint_results, live):
_seen.append(sorted(self._drain_ended))
return fixed(self, maint_results, live)
Controller._reseed_drain_end = _spy
try:
run(2, 1200.0, store=store(2), soc=lambda u, t:
5.0 if (u == "a" and T <= t < T + 200) else 99.5 if u == "a" and
t >= T + 200 else 50.0, dur=T + 400.0,
extra={"maintenance_hold_min": 2})
finally:
Controller._reseed_drain_end = fixed
check("it fires once, at the drain end: not at the charge end, the HOLD "
"end, or on any other cycle", [x for x in _seen if x] == [[A]]
and len(_seen) > 300)
print("\n drain end | N | house W | site step W head/base | commanded "
"export W head/base | others' step W head/base | grid import W "
"head/base | grid step W head/base | export beyond the end's W head/base")
for k, n, h, a, b in TABLE:
print(f" {k} | {n} | {h:.0f} | {a['site_step']:.0f}/"
f"{b['site_step']:.0f} | {a['cmd_export']:.0f}/{b['cmd_export']:.0f}"
f" | {a['ostep']:.0f}/{b['ostep']:.0f} | {a['imp']:.0f}/"
f"{b['imp']:.0f} | {a['gstep']:.0f}/{b['gstep']:.0f} | "
f"{a['exp']:.0f}/{b['exp']:.0f}")
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all SAFETY-95 checks passed")
+573
View File
@@ -0,0 +1,573 @@
"""Runnable check for SAFETY-96: a battery the loop cannot move does not
freeze the loop. `python3 test_safety96.py`
At N=2, after A's link went stale the site sum still counted A's power, so
`compute()` saw the site "not following" and froze B (SAFETY-93 rig test:
B held at 1200 W for ~236 cycles). After a charge timeout or Off at 6000 W
it froze ~14 s while A came out of its charge. Now a battery out of the
split, with refused writes, or settling after a step the loop did not make
counts at its command; its real power reaches the loop through the meter.
Drives the REAL Controller on a lagging battery (10 s, 2 s meter).
DECISIONS/SAFETY-96-dropped-battery.md.
1. A's link drops while it discharges: A holds its last command (stuck), or
its board goes to 0 W 25 s after the first refused write.
2. A's link drops while it charges (maintenance).
3. B's link drops; both drop.
4. A drops, then comes back.
5. A's charge times out, or A is switched Off, at 6000 W.
6. N=1 is untouched; the rule's parts one by one.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import hashlib
import logging
import math
import os
import sys
from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import safety79_identity as s79 # noqa: E402
import safety84_identity as s84 # noqa: E402
from app import packmode # noqa: E402
from app.main import Controller # noqa: E402
from app.maintenance import CHARGE_TIMEOUT_MIN, IDLE, MaintResult # noqa: E402
from app.store import unit_key # noqa: E402
from named_absence import first, present # noqa: E402
fails = []
SLEW = 400.0
FROZEN = ("saturated-freeze", "freeze-released")
OPT = dict(s84.OPT, peak_cap_w=3500)
PLAIN = dict(s79.P1, peak_cap_w=3500)
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
# None on a tree without the fix, where this file prints the base numbers
# and runs red.
fixed = getattr(Controller, "_held_out_w", None)
real_drop = getattr(Controller, "_reseed_drop", None)
def without(fn):
"""Run `fn` with SAFETY-96 taken out (the base behaviour)."""
if fixed is None:
return fn()
Controller._held_out_w = lambda self, live: 0.0
Controller._reseed_drop = lambda self, live: setattr(
self, "_live_was", frozenset(live))
try:
return fn()
finally:
Controller._held_out_w, Controller._reseed_drop = fixed, real_drop
def run(n=2, *, house=lambda t: 2400.0, dur=600.0, store=None, opt=None,
soc=None, reject=None, back=None, wd=None, before=None, lag=10.0,
blind=None, flaky=None, dead=None):
"""Rows of one run. Writes to the units in `reject` (name -> from s) are
refused from that time until `back` s; the add-on sees the refusal at
once (a heartbeat write). A refused unit holds its last command, or with
`wd` its board goes to 0 W `wd` s after the first refused write. B's
reading is missing in the `blind` (from, to) window. `flaky(u, t)`:
writes to u refused at t, each cycle's verdict seen (a heartbeat).
`dead` (u, from s): u gives nothing from then on, whatever it is told.
Each row: (t, plant commands, add-on commands, loop reason, site target,
grid, circulation tripped)."""
reject = reject or {}
real_p, real_s, real_tick = rig._Hass.power, rig._Hass.set_number, rig.TICK
def refused(u, t):
if flaky is not None:
return flaky(u, t)
return u in reject and t >= reject[u] and (back is None or t < back)
def power(self, u):
st = self.__dict__.setdefault("_plant", {})
a, tgt, tl = st.get(u, (0.0, 0.0, self.t))
for k in range(int(round((self.t - tl) / 0.1))):
now = tl + (k + 1) * 0.1
c = self.cmd[u]
if wd is not None and refused(u, now) and now - reject[u] >= wd:
c = 0.0
if dead is not None and u == dead[0] and now >= dead[1]:
c = 0.0
d = c - 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)
return float(round(a))
async def set_number(self, e, v):
if refused(e[-1], self.t):
return False
self.cmd[e[-1]] = float(v)
return True
rows = []
def hook(c, t):
for inv in c.inverters:
if flaky is not None:
inv.note_write(not flaky(inv.name[-1], t),
rig.BASE + timedelta(seconds=t))
elif refused(inv.name[-1], t) and inv.rejected_since is None:
inv.rejected_since = rig.BASE + timedelta(seconds=t)
if before is not None:
before(c, t)
if blind is not None and not getattr(c.hass, "_s96", False):
read = c.hass.number
async def number(e, invert=False):
if e == "sensor.batt_b" and blind[0] <= c.hass.t < blind[1]:
return None
return await read(e, invert)
c.hass.number, c.hass._s96 = number, True
(s84.feed(True) if opt is not None else s79._feed)(c, t)
def on_cycle(c, t):
rows.append((t, tuple(sorted(c.hass.cmd.items())),
tuple(inv.target for inv in c.inverters), c.loop_reason,
c.target, c.hass.grid(), c.circ_wd.tripped,
tuple(inv.batt for inv in c.inverters)))
rig._Hass.power, rig._Hass.set_number, rig.TICK = power, set_number, 2.0
try:
_r, ctl = rig.run(n, extra=dict(opt if opt is not None else PLAIN),
dur=dur, load=house, soc=soc or (lambda u, t: 60.0),
cut=lambda u, t: False, before=hook,
on_cycle=on_cycle, store=store or rig._Store())
finally:
rig._Hass.power, rig._Hass.set_number, rig.TICK = real_p, real_s, \
real_tick
return rows, ctl
def freeze(rows, lo, hi=1e9):
return sum(1 for r in rows if lo <= r[0] < hi and r[3] in FROZEN)
def phantom(rows, k, lo, hi=1e9):
"""Freeze cycles while battery `k` (the one taking over) reads within
saturation_w (500 W) of what the loop last asked of it plus anything
unplaced (last site target less the other's command): the freeze is
then about the other battery, not about `k`'s own lag."""
return sum(1 for a, b in zip(rows, rows[1:]) if lo <= b[0] < hi
and b[3] in FROZEN
and abs(a[4] - a[2][1 - k] - b[7][k]) <= 500.0)
def ramp(rows, k, lo, hi=1e9):
"""The largest change of unit `k`'s command (0 = A) between cycles."""
return max(abs(b[2][k] - a[2][k]) for a, b in zip(rows, rows[1:])
if lo < b[0] <= hi)
def site_step(rows, lo, hi=1e9):
return max(abs(b[4] - a[4]) for a, b in zip(rows, rows[1:])
if lo < b[0] <= hi)
def grid_err(rows, lo, hi=1e9):
"""Mean |grid - aim| over [lo, hi) (aim -10 W)."""
xs = [abs(r[5] + 10.0) for r in rows if lo <= r[0] < hi]
return sum(xs) / len(xs)
def trip_at(rows):
"""When the circulation guard tripped (its own 0 W step is no ramp)."""
return next((r[0] for r in rows if r[6]), 1e9)
def tripped(rows):
return rows[-1][6]
def at(rows, t, k):
return first((r[2][k] for r in rows if r[0] == t), f"row {t}")
TABLE = []
LAST = [None] # the controller of the last run with the fix
def case(name, fn, lo, k, hi=1e9):
"""Run `fn` with and without SAFETY-96, print and record the numbers:
freeze cycles (and those not about `k`'s own lag), `k`'s largest step,
the mean grid error, a circulation trip."""
(now, LAST[0]), old = fn(), without(fn)[0]
row = (name, freeze(now, lo, hi), freeze(old, lo, hi),
phantom(now, k, lo, hi), phantom(old, k, lo, hi),
ramp(now, k, lo, min(hi, trip_at(now) - 1)),
ramp(old, k, lo, min(hi, trip_at(old) - 1)),
grid_err(now, lo, hi), grid_err(old, lo, hi),
tripped(now), tripped(old))
TABLE.append(row)
print(f" {name}: freeze {row[1]} (base {row[2]}), not about its own lag "
f"{row[3]} (base {row[4]}); step {row[5]:.0f} (base {row[6]:.0f}) "
f"W/cycle; grid error {row[7]:.0f} (base {row[8]:.0f}) W; "
f"circulation trip {row[9]} (base {row[10]})")
return now, old
STUCK, BOARD = ("stuck", None), ("board 0 W", 25.0)
# ============================================================================ #
print("1. A's link drops while it discharges (house 2400 W, each 1200 W)")
for v, w in (STUCK, BOARD):
now, old = case(f"A drop discharging, {v}",
lambda w=w: run(reject={"a": 300.0}, wd=w), 300.0, 1)
check(f"⭐ {v}: no freeze about A, B moves at most one slew step a "
f"cycle, no circulation trip", phantom(now, 1, 300) == 0
and ramp(now, 1, 300) <= SLEW and not tripped(now))
check(f"non-vacuous ({v}): without the fix the loop freezes about A, or "
f"B jumps more than a slew step at the drop",
phantom(old, 1, 300) >= 10 or ramp(old, 1, 300) > SLEW)
if w is None:
check("stuck: B stays at its share, since A still gives its 1200 W "
"(no freeze at all, grid within 15 W on average)",
freeze(now, 300) == 0 and grid_err(now, 300) < 15.0
and freeze(old, 300) >= 100)
else:
check("board 0 W: B ends up carrying the whole house (2410 W)",
abs(now[-1][2][1] - 2410.0) <= 20.0)
# ============================================================================ #
print("\n2. A's link drops while it charges (maintenance, house 1200 W)")
def charging(**kw):
return run(house=lambda t: 1200.0, dur=1500.0,
store=s79.store_charging(2), opt=dict(
OPT, maintenance_hold_min=600),
soc=lambda u, t: 90.0 if u == "a" else 60.0, **kw)
for v, w in (STUCK, BOARD):
now, old = case(f"A drop charging, {v}",
lambda w=w: charging(reject={"a": 1300.0}, wd=w),
1300.0, 1)
trip = trip_at(now)
check(f"⭐ {v}: no freeze about A, and B climbs at most one slew step a "
f"cycle and never charges (until any circulation trip)",
phantom(now, 1, 1300, trip) == 0
and ramp(now, 1, 1300, trip - 1) <= SLEW
and min(r[2][1] for r in now if r[0] >= 1300) >= 0.0)
check(f"non-vacuous ({v}): without the fix the loop freezes about A",
phantom(old, 1, 1300) >= 3)
if w is None:
check("stuck (A charges 2000 W on its own): B reaches its 2500 W "
"before the circulation trip, which still trips on the real "
"4000 W going round (as on base)",
max(r[2][1] for r in now if r[0] < trip) == 2500.0
and tripped(now) and tripped(old))
else:
check("board 0 W: B carries the house (grid within 50 W from 100 s "
"after the drop), no circulation trip",
grid_err(now, 1400) < 50.0 and not tripped(now))
# ============================================================================ #
print("\n3. B's link drops; both drop")
for v, w in (STUCK, BOARD):
now, old = case(f"B drop discharging, {v}",
lambda w=w: run(reject={"b": 300.0}, wd=w), 300.0, 0)
check(f"⭐ B drops ({v}): no freeze about B, A moves at most one slew "
f"step a cycle, no circulation trip", phantom(now, 0, 300) == 0
and ramp(now, 0, 300) <= SLEW and not tripped(now))
for v, w in (STUCK, BOARD):
now, old = case(f"both drop, {v}",
lambda w=w: run(reject={"a": 300.0, "b": 300.0}, wd=w),
300.0, 1)
check(f"⭐ both drop ({v}): no freeze; both are commanded 0 W once "
f"nothing can be placed", freeze(now, 300) == 0
and now[-1][2] == (0.0, 0.0) and not tripped(now))
# ============================================================================ #
print("\n4. A drops, then comes back")
def rejoined(rows):
return next((r[0] for r in rows if r[0] > 420 and r[2][0] != 0.0), 1e9)
for v, w in (STUCK, BOARD):
now, old = case(f"A drop and rejoin, {v}",
lambda w=w: run(reject={"a": 300.0}, back=420.0, wd=w,
dur=700.0), 300.0, 1)
back = rejoined(now)
check(f"rejoin ({v}): A is back in the split within 4 s of its link "
f"returning (no refusal was learned while its writes were refused)",
back <= 424.0 and not any("REFUSES" in e for e in LAST[0].events))
print(f" A back in the split at {back:.0f} s; A "
f"{[r[2][0] for r in now if back - 2 <= r[0] <= back + 6]}, B "
f"{[r[2][1] for r in now if back - 2 <= r[0] <= back + 6]}; grid "
f"{[round(r[5]) for r in now if back - 2 <= r[0] <= back + 6]}; "
f"base grid {[round(r[5]) for r in old if back - 2 <= r[0] <= back + 6]}")
a0 = first((r for r in now if r[0] == back - 2), "row before the rejoin")
a1 = first((r for r in now if r[0] == back), "the rejoin row")
check(f"⭐ rejoin ({v}): no freeze after it; the site target takes in only "
f"A's own reading, then moves at most one slew step a cycle; the "
f"grid stays within 250 W of its aim; no circulation trip",
present(a0) and present(a1) and freeze(now, back) == 0
and abs(a1[4] - a0[4] - a0[7][0]) <= SLEW
and site_step(now, back) <= SLEW
and max((abs(r[5] + 10.0) for r in now if r[0] >= back),
default=1e9) < 250.0
and not tripped(now))
if w is None:
check("non-vacuous (stuck): without the fix the grid swings over "
"1000 W after the rejoin", max((abs(r[5] + 10.0) for r in old
if r[0] >= back), default=0.0)
>= 1000.0)
# Review: a battery that does refuse, behind a link that refuses one write
# in three, is still found out (the count is kept over a refused write).
_fl_rows, _fl = run(dur=700.0, dead=("a", 300.0),
flaky=lambda u, t: u == "a" and t >= 300.0
and int(t / 2) % 3 == 0)
_fl_at = next((r[0] for r in _fl_rows if r[2][0] == 0.0 and r[0] > 300.0),
None)
_fl_end = first((r[2] for r in _fl_rows[-1:]), "the last row")
print(f" A gives nothing from 300 s, 1 write in 3 refused: A's share is "
f"taken off at {_fl_at} s; A {_fl.inverters[0].refused}, last "
f"commands {_fl_end}")
check("⭐ a battery that refuses behind a link refusing 1 write in 3: its "
"refusal is still learned and B carries the house",
_fl.inverters[0].refused == 1 and _fl_at is not None
and _fl_at <= 400.0 and present(_fl_end)
and abs(_fl_end[1] - 2410.0) <= 20.0)
# Review: a fast battery whose board goes to 0 W 1 s after the first refused
# write reads ~0 W long before its link is stale (30 s): no refusal either.
_ref5 = []
_e5_rows, _e5 = run(reject={"a": 300.0}, back=420.0, wd=1.0, lag=1.0,
dur=700.0,
before=lambda c, t: _ref5.append(c.inverters[0].refused))
check("fast battery, board 0 W 1 s after the first refused write: no "
"refusal is learned, A is back in the split within 4 s of its link "
"returning",
not any(_ref5) and rejoined(_e5_rows) <= 424.0)
_seen = []
def blind_seen(c, t):
"""Record the last cycle: whether the loop ran, is A live, what settles."""
if t > 340.0:
_seen.append((t - 2.0, c.loop_reason, "inverter-a" in c.live_units(),
dict(c._settle)))
run(reject={"a": 300.0}, back=420.0, blind=(340.0, 470.0), dur=490.0,
before=blind_seen)
_back = first((x for x in _seen if x[1] != "not-running" and x[0] > 400),
"the restart")
print(f" B's reading missing 340-470 s, A back at 420 s: control holds from "
f"{first((x[0] for x in _seen if x[1] == 'not-running'), '?')} s, "
f"restarts at {_back[0]:.0f} s, settling then: {_back[3]}")
check("A drops, then comes back while control holds (a reading missing): no "
"rejoin at the restart, which starts from what is on the wire",
any(x[1] == "not-running" and x[2] for x in _seen) and _back[3] == {})
# ============================================================================ #
print("\n5. A's charge times out, or A is switched Off, at a 6000 W house")
def timed_out(end):
"""A's charge passes CHARGE_TIMEOUT_MIN at `end` s; A then stays free
(its cycle started today, so no restart) and B is not due."""
s = s79.store_charging(2)
s.data[unit_key("inverter-a", "phase_started")] = (
rig.BASE + timedelta(seconds=end - CHARGE_TIMEOUT_MIN * 60 - 1)
).isoformat()
s.data[unit_key("inverter-a", "last_start_attempt")] = rig.BASE.isoformat()
s.data[unit_key("inverter-b", "last_completed")] = rig.BASE.isoformat()
return s
def b_done():
s = s79.store_charging(2)
s.data[unit_key("inverter-b", "last_completed")] = rig.BASE.isoformat()
return s
def switch_off(at_t):
def before(c, t):
if t >= at_t:
c.store.data[unit_key("inverter-a", packmode.USE_KEY)] = False
return before
def high(t):
return 6000.0 if t >= 1760.0 else 1200.0
ENDS = {
"timeout": lambda: run(house=high, dur=1798.0, store=timed_out(1780.0),
opt=dict(OPT, maintenance_hold_min=600),
soc=lambda u, t: 90.0 if u == "a" else 60.0),
"Off": lambda: run(house=high, dur=1798.0, store=b_done(),
before=switch_off(1780.0),
opt=dict(OPT, maintenance_hold_min=600,
battery_modes_enabled=True),
soc=lambda u, t: 90.0 if u == "a" else 60.0),
}
for k, fn in ENDS.items():
now, old = case(f"{k} at 6000 W", fn, 1780.0, 1)
print(f" B {[r[2][1] for r in now if 1778 <= r[0] <= 1796]}, base "
f"{[r[2][1] for r in old if 1778 <= r[0] <= 1796]}")
check(f"⭐ {k}: no freeze about A while it comes out of its charge; B "
f"climbs one slew step a cycle and never charges",
phantom(now, 1, 1780) == 0 and ramp(now, 1, 1780) <= SLEW
and min(r[2][1] for r in now if r[0] >= 1780) >= 0.0)
check(f"non-vacuous ({k}): without the fix the loop freezes about A and "
f"B ends lower", phantom(old, 1, 1780) >= 3
and at(now, 1796.0, 1) > at(old, 1796.0, 1))
# ============================================================================ #
print("\n6. N=1 is untouched; the rule's parts")
def digest(rows):
return hashlib.sha256(repr(rows).encode()).hexdigest()
def wave(t):
return 2400.0 + 1500 * math.sin(t / 40)
_n1 = [lambda: run(1, reject={"a": 300.0}, back=420.0, wd=25.0),
lambda: run(1, house=high, dur=1798.0, store=timed_out(1780.0),
opt=dict(OPT, maintenance_hold_min=600)),
lambda: run(1, house=wave)]
check("⭐ N=1 (a refused link that comes back, a charge timeout, a moving "
"house): every row byte-identical with and without SAFETY-96",
[digest(f()[0]) for f in _n1] == [digest(without(f)[0]) for f in _n1])
check("N=2 healthy (a moving house, no drop): every row byte-identical with "
"and without SAFETY-96",
digest(run(house=wave)[0]) == digest(without(lambda: run(
house=wave))[0]))
if fixed is None:
print(f"\n{len(fails)} FAILED (no _held_out_w): " + ", ".join(fails))
sys.exit(1)
_c = run(dur=4.0)[1]
AB = ("inverter-a", "inverter-b")
def held(batts, targets, live=AB, rejected=(), settle=None, now=100.0,
start=None):
for inv, b, t in zip(_c.inverters, batts, targets):
inv.batt, inv.target = b, t
inv.rejected_since = rig.BASE if inv.name in rejected else None
_c._settle = dict(settle or {})
_c._loop_start = dict(start or {})
_c._now_mono = now
return _c._held_out_w(set(live))
check("a healthy pair: nothing held out, however far a reading lags",
held((-2000.0, 400.0), (0.0, 400.0)) == 0.0)
check("a dropped battery counts at its command (0 W), not its reading",
held((-2000.0, 800.0), (0.0, 800.0), live=("inverter-b",)) == 2000.0)
check("a battery whose writes are refused counts at its command",
held((900.0, 800.0), (1200.0, 800.0), rejected=("inverter-a",))
== 300.0)
check("a settling battery counts at its command until its reading is within "
"the deadband (15 W) of it, then it is the loop's again",
held((-20.0, 800.0), (0.0, 800.0), settle={"inverter-a": 95.0})
== 20.0 and "inverter-a" in _c._settle
and held((-10.0, 800.0), (0.0, 800.0), settle={"inverter-a": 95.0})
== 0.0 and "inverter-a" not in _c._settle)
check("...but not on the cycle it starts",
held((-10.0, 800.0), (0.0, 800.0), settle={"inverter-a": 100.0})
== 10.0 and "inverter-a" in _c._settle)
check("...or until PLANT_SETTLE_DOWN_S (23 s) has passed, whichever is first",
held((-2000.0, 800.0), (0.0, 800.0), settle={"inverter-a": 77.1})
== 2000.0 and held((-2000.0, 800.0), (0.0, 800.0),
settle={"inverter-a": 77.0}) == 0.0)
check("a reseeded start (SAFETY-93's charge end) is the command it counts at",
held((-2000.0, 800.0), (-2000.0, 800.0), settle={"inverter-a": 99.0},
start={"inverter-a": 0.0}) == 2000.0)
def dropped(was, live, target, shares, batts=(0.0, 0.0), start=None):
_c._live_was, _c.target, _c.i_w = (None if not was else frozenset(was),
target, 123.0)
_c._loop_start, _c._settle, _c._now_mono = dict(start or {}), {}, 50.0
for inv, b in zip(_c.inverters, batts):
inv.target, inv.batt = shares[inv.name[-1]], b
_c._reseed_drop(set(live))
return _c.target, _c.i_w
check("a drop: the dropped battery's share leaves the start point, the "
"integrator with it", dropped(AB, ("inverter-b",), 2400.0,
{"a": 1200.0, "b": 1200.0})
== (1200.0, 1200.0) and _c._live_was == frozenset({"inverter-b"}))
_c._charge_ended = frozenset({"inverter-a"})
check("...but a battery whose charge ended too is left to SAFETY-93's reseed",
dropped(AB, ("inverter-b",), -2000.0, {"a": -2000.0, "b": 0.0})
== (-2000.0, -2000.0))
_c._charge_ended = frozenset()
_free = MaintResult(IDLE, False, None, False, [])
def charge_end(rejected_s):
"""SAFETY-93's reseed for A (giving 1385 W under the loop, its charge
suspended), A's writes refused for `rejected_s` s: (target, start)."""
_c.target, _c.i_w, _c._charge_ended = 2770.0, 0.0, frozenset({AB[0]})
_c._loop_start, _c._settle = {}, {}
for inv in _c.inverters:
inv.target, inv.rejected_since = 1385.0, None
_c.inverters[0].rejected_since = datetime.now(timezone.utc) - timedelta(
seconds=rejected_s)
_c._reseed_charge_end([_free, _free])
_c.inverters[0].rejected_since = None
return _c.target, _c._loop_start[AB[0]]
check("a charge that ends as its battery drops: the battery's start is 0 W "
"(it is sent 0 W), not its last give; still live, its give stays",
charge_end(31.0) == (1385.0, 0.0) and charge_end(5.0) == (2770.0, 1385.0))
check("a rejoin: the battery enters the start point at its reading, and "
"both settle while the split is made again",
dropped(("inverter-b",), AB, 1200.0, {"a": 0.0, "b": 1200.0},
batts=(-700.0, 1200.0)) == (500.0, 500.0)
and _c._settle == {"inverter-a": 50.0, "inverter-b": 50.0})
check("no change: nothing moves (the integrator either)",
dropped(AB, AB, 1200.0, {"a": 600.0, "b": 600.0}) == (1200.0, 123.0)
and dropped((), ("inverter-b",), 1200.0, {"a": 0.0, "b": 1200.0})
== (1200.0, 123.0))
_n1c = run(1, dur=4.0)[1]
_n1c._live_was, _n1c.target, _n1c.i_w = frozenset({"inverter-a"}), 900.0, 5.0
_n1c._reseed_drop(set())
check("N=1: nothing moves and nothing is held out",
(_n1c.target, _n1c.i_w) == (900.0, 5.0)
and _n1c._held_out_w(set()) == 0.0)
print("\n case | freeze head/base | not about its own lag head/base | "
"step W head/base | grid error W head/base | circulation trip head/base")
for r in TABLE:
print(f" {r[0]} | {r[1]}/{r[2]} | {r[3]}/{r[4]} | {r[5]:.0f}/{r[6]:.0f} |"
f" {r[7]:.0f}/{r[8]:.0f} | {r[9]}/{r[10]}")
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all SAFETY-96 checks passed")
@@ -749,6 +749,24 @@ show("(d) GLOBAL `pv_invert: bool` validates too - it loses on SEMANTICS (one "
"sign for a mixed roster), not on the schema", ok_d, "" if ok_d else out_d)
print("\n== I. MAINT-10: `maintenance_start_hour` now defaults to 19 ==")
_mk = "maintenance_start_hour"
ok_i, out_i = validate(defaults)
show("the shipped `options:` block validates and carries 19",
ok_i and out_i.get(_mk) == 19, "" if ok_i else out_i)
# What the Supervisor hands the add-on is deepmerge(options:, stored).
_stored10 = dict(defaults, **{_mk: 10})
ok_i2, out_i2 = validate(MERGER.merge(copy.deepcopy(defaults), _stored10))
show("a stored explicit 10 (what a blob saved before MAINT-10 holds) stays 10",
ok_i2 and out_i2.get(_mk) == 10, "" if ok_i2 else out_i2)
_absent = {k: v for k, v in defaults.items() if k != _mk}
ok_i3, out_i3 = validate(MERGER.merge(copy.deepcopy(defaults), _absent))
show("a stored blob WITHOUT the key gets the new default, 19",
ok_i3 and out_i3.get(_mk) == 19, "" if ok_i3 else out_i3)
ok_i4, out_i4 = validate(dict(defaults, **{_mk: 24}))
show("the range is unchanged: 24 is refused (`at most 23`)",
(not ok_i4) and "at most 23" in str(out_i4), out_i4)
# ⚠️ THE EXIT CODE IS PART OF THE INSTRUMENT. Without it the probe prints FAIL
# and still succeeds, and a transcript is only evidence if a wrong answer is
# distinguishable from a right one by something other than reading it.
+2 -2
View File
@@ -224,8 +224,8 @@ configuration:
name: "Maintenance · Minimum days between cycles"
description: "The shortest gap between two maintenance cycles."
maintenance_start_hour:
name: "Maintenance · Start hour"
description: "The local hour, 0 to 23, at which a due cycle starts."
name: "Maintenance · Start hour (local time)"
description: "The hour on your local clock, 0 to 23, when a due cycle starts. 19 means 19:00."
maintenance_discharge_w:
name: "Maintenance · Draw-down rate (W)"
description: "How fast the cycle empties the battery. Above “Inverter rating” it is cut back to the rating, and the cycle just takes longer."
+2 -2
View File
@@ -205,8 +205,8 @@ configuration:
name: "Onderhoud · Minimum dagen tussen cycli"
description: "De kortste tijd tussen twee onderhoudscycli."
maintenance_start_hour:
name: "Onderhoud · Startuur"
description: "Het lokale uur, 0 tot 23, waarop een geplande cyclus begint."
name: "Onderhoud · Startuur (lokale tijd)"
description: "Het uur op je lokale klok, 0 tot 23, waarop een geplande cyclus begint. 19 is 19:00."
maintenance_discharge_w:
name: "Onderhoud · Ontlaadvermogen (W)"
description: "Hoe snel de cyclus de batterij leegmaakt. Boven het vermogen van de omvormer wordt het daartoe beperkt, en duurt de cyclus gewoon langer."