Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35b2a699de | ||
|
|
cd1bf2a694 | ||
|
|
db9e2315d1 | ||
|
|
848140c29f | ||
|
|
d12228d986 | ||
|
|
bf3ac7998c | ||
|
|
61a61941bb | ||
|
|
ef35f4b925 | ||
|
|
f39e52efd3 | ||
|
|
8a17711f60 | ||
|
|
ea31289ec0 | ||
|
|
6836f61bd4 | ||
|
|
f7b83cc75c | ||
|
|
6f05b2e4be | ||
|
|
28eff568b5 | ||
|
|
b527e99f13 | ||
|
|
c6f23a69d4 | ||
|
|
98dd110a4f | ||
|
|
138e45b53f | ||
|
|
451729c9b5 | ||
|
|
1c607a961a | ||
|
|
756b50ddbb | ||
|
|
340c7eef42 | ||
|
|
4707d37019 | ||
|
|
9c0314a826 | ||
|
|
bdec66e97a | ||
|
|
b2f55732b6 | ||
|
|
956711b801 | ||
|
|
88b9cd470a | ||
|
|
1bf6b7f9a7 | ||
|
|
1d28daf8a0 | ||
|
|
0b6b34f563 | ||
|
|
08fb1235fc | ||
|
|
7ef73474dc | ||
|
|
24dbbd18ad | ||
|
|
8dfe91e1d2 | ||
|
|
9d3fbe1be9 | ||
|
|
3ed3302d2f | ||
|
|
8c6e5bca2e | ||
|
|
f01152b113 | ||
|
|
3129daed62 | ||
|
|
64dddd64bf | ||
|
|
cf705a9058 | ||
|
|
7d06aa674d | ||
|
|
88ebf3a5a6 | ||
|
|
3db2b6392f | ||
|
|
80bfc5721e | ||
|
|
6918e154c8 | ||
|
|
dd5eb6fdc8 | ||
|
|
6759d21ea5 | ||
|
|
02ed1200dc | ||
|
|
f13dab1407 | ||
|
|
b307db1049 | ||
|
|
e51e3e02b2 | ||
|
|
69dc1f0e7b | ||
|
|
858f77cfb5 | ||
|
|
214051d1a8 | ||
|
|
05eb93f139 | ||
|
|
f3e409cf76 | ||
|
|
4cd604afcb | ||
|
|
6872353e2e | ||
|
|
8c438ab2e6 | ||
|
|
b37b7f95dc | ||
|
|
28cab03947 | ||
|
|
61072ca460 | ||
|
|
7b723a6cd5 | ||
|
|
4b81387908 | ||
|
|
935878193b | ||
|
|
b4d25dd485 | ||
|
|
a4a04d203c | ||
|
|
aa501ffec7 | ||
|
|
d89c093318 | ||
|
|
d0db5b8c39 | ||
|
|
b8ddfd787f | ||
|
|
2f5ab1f378 | ||
|
|
fbdd552555 | ||
|
|
02c0a3dd7b |
+1
-1
@@ -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").
|
||||
d637d0b1b13f1b863dc47e445e2ca4cf4a82fe10
|
||||
3b4ff84757f9c2c5b63a4b4a53e593ccaeafb847
|
||||
|
||||
@@ -1,5 +1,212 @@
|
||||
# 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.**
|
||||
No option was added, removed or renamed, and no default changed (83
|
||||
options). Options that start 0.13.0 also start 0.14.0. With one inverter,
|
||||
control is the same as in 0.13.0.
|
||||
|
||||
⭐ **Two inverters are now supported** for the monthly maintenance (drain,
|
||||
charge, hold) and the peak guard. To add a second inverter, follow
|
||||
`docs/commissioning-b.md` in the `admin/goodwe` repo. Its Stage 5, where the
|
||||
add-on takes control of it, needs 0.14.0. Read the known issues below first:
|
||||
three of them are about two inverters.
|
||||
|
||||
Contents of this release, in merge order since 0.13.0 (`8c438ab`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `f3e409c` | Two inverters: a battery at or under its peak reserve gives last, after the others give their full power. It is freed again above reserve + 1 point (e.g. above 21 % for a 20 % reserve). At a quarter's edge the peak guard no longer drops out for a second ([[SAFETY-91]]) |
|
||||
| `858f77c` | Two inverters: when a maintenance charge ends, for any reason, the other battery no longer starts charging from the grid; control hands over in small steps ([[SAFETY-93]]) |
|
||||
| `e51e3e0` | CI tests against a newer `admin/goodwe` (CI only) ([[CI-13]]) |
|
||||
|
||||
### Known issues
|
||||
|
||||
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
|
||||
- A healthy battery that turns from charging to giving can be held at 0 W for up to 15 min ([[SAFETY-94]]).
|
||||
- After a gap in the P1 meter, the peak guard is blind for the rest of that quarter hour ([[SAFETY-90]]).
|
||||
- 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]]).
|
||||
|
||||
### 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.13.0
|
||||
|
||||
**A minor bump: new behaviour, a new add-on page (Home, Settings, Batteries,
|
||||
Setup), and six new options.** No option was removed or renamed, and no
|
||||
default of an existing option changed (77 options -> 83). The only new
|
||||
start-up checks are on the new options, so options that start 0.12.0 also
|
||||
start 0.13.0.
|
||||
|
||||
New options, and what they do if you leave them alone:
|
||||
|
||||
| option | default | left alone |
|
||||
|---|---|---|
|
||||
| `peak_margin_w` | `50` | the peak guard aims 50 W below your peak limit, or below a higher peak already reached this month; `0` is the old aim ([[SAFETY-79]]) |
|
||||
| `battery_modes_enabled` | `false` | no battery modes, control as before ([[OPT-11]]) |
|
||||
| `pv_forecast_entity`, `pv_forecast_tomorrow_entity` | empty | no solar forecast is used ([[OPT-11]]) |
|
||||
| `solar_trust_pct` | `70` | only used with a solar forecast ([[OPT-11]]) |
|
||||
| `pv_invert_entities` | `[]` | PV readings as before ([[TEL-14]]) |
|
||||
|
||||
⚠️ **Turn on Watchdog** on the add-on's Info tab. It is off by default. If
|
||||
the control loop crashes, the add-on now sets the batteries to 0 W and stops.
|
||||
Home Assistant only starts it again when Watchdog is on ([[SAFETY-61]],
|
||||
[[DOC-5]]).
|
||||
|
||||
⚠️ **The peak guard now starts a little earlier.** It aims 50 W below your
|
||||
peak limit, so the batteries give a bit more at peak times. Set
|
||||
`peak_margin_w: 0` for the old aim ([[SAFETY-79]]).
|
||||
|
||||
Contents of this release, in merge order since 0.12.0 (`a45ed06`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `fa3d237` | Docs name the right P1 age entity and a battery list that starts ([[DOC-1]]) |
|
||||
| `6e65272` | If the control loop crashes, the batteries go to 0 W and the add-on stops ([[SAFETY-61]]) |
|
||||
| `3e38baf` | The check tools refuse a file they cannot read, instead of saying all clear (tools only) ([[SAFETY-60]], [[SAFETY-62]]) |
|
||||
| `43b2740` | A missing value in a check is a named failure (tests only) ([[TEST-26]]) |
|
||||
| `20260e8` | Docs: turn on Watchdog ([[DOC-5]]) |
|
||||
| `16c796f` | New option `pv_invert_entities`: flip the sign of a PV sensor that reads backwards ([[TEL-14]]) |
|
||||
| `6a8a5ff` | CI tests against the real inverter simulator (CI only) ([[CI-12]]) |
|
||||
| `82f032e` | CI pin checks report failures properly (CI only) ([[TOOL-9]]) |
|
||||
| `fe13251` | CI pin update (CI only) |
|
||||
| `397dd15` | Alarm notifications that are still active come back after a Home Assistant restart ([[SAFETY-34]]) |
|
||||
| `59415ea` | CI pin update (CI only) |
|
||||
| `2503de1` | "Battery is not keeping up" is a warning with its own notification, not a "bug" alarm; its figures are correct ([[SAFETY-40]]) |
|
||||
| `48ef06a` | Shorter, plainer notifications ([[UX-08]]) |
|
||||
| `5c151a3` | CI pin update (CI only) |
|
||||
| `d57767f` | Shorter, plainer status rows and banners ([[UX-08]]) |
|
||||
| `e1d0970` | Details link works on current Home Assistant; plainer words ([[UX-09]]) |
|
||||
| `a02cd4b` | Shorter refusals, errors and option texts ([[UX-08]]) |
|
||||
| `32b9e56` | Plainer log lines and docs ([[UX-08]]) |
|
||||
| `125b070` | CI pin update (CI only) |
|
||||
| `2b14b88` | Last plain-language fixes in logs and the status page ([[UX-10]]) |
|
||||
| `c4032eb` | CI pin update (CI only) |
|
||||
| `d1f7e33` | Safety log lines: one plain sentence and a code ([[UX-11]]) |
|
||||
| `652097a` | A battery that refuses power (e.g. empty) is asked for 0 W, and tried at 200 W for 34 s every 15 min ([[SAFETY-52]]) |
|
||||
| `afd6af0` | Groundwork for the new settings pages ([[UX-13]]) |
|
||||
| `a36dca6` | New page with a Home screen ([[UX-13]]) |
|
||||
| `28a49cc` | With two or more inverters, a battery that refuses power is held on its own; the others take over ([[SAFETY-73]]) |
|
||||
| `515c11b` | New Installer settings view ([[UX-13]]) |
|
||||
| `4de9675` | New setup wizard for installers ([[UX-13]]) |
|
||||
| `8c34647` | A new install with no inverter opens the setup wizard instead of stopping ([[UX-15]]) |
|
||||
| `a5dddad` | Dutch text and a time-period editor ([[UX-13]]) |
|
||||
| `ed739d4` | Battery modes (off by default): per-battery limits, on/off, solar order ([[OPT-11]]) |
|
||||
| `7995e4d` | Simple settings view and a Batteries screen ([[UX-13]]) |
|
||||
| `7337b79` | Battery modes in Home Assistant, when modes are on ([[OPT-11]]) |
|
||||
| `7a00f12` | New option `peak_margin_w`: the peak guard keeps a margin below the limit ([[SAFETY-79]]) |
|
||||
| `5a4cdc2` | Battery modes: charge in cheap hours, hold for expensive hours; selling is off for now ([[OPT-11]]) |
|
||||
| `4f25b24` | No false "Load too high" alarms at quarter edges or with a noisy load (10 s hold) ([[SAFETY-81]]) |
|
||||
| `35cdffe` | Maintenance drain with two batteries no longer stalls ([[SAFETY-78]]) |
|
||||
| `1145a93` | Battery modes: a solar forecast decides how much to buy from the grid ([[OPT-11]]) |
|
||||
| `9d5b7d1` | With one battery, the peak guard wins over the maintenance drain ([[SAFETY-83]]) |
|
||||
| `4ad8298` | Maintenance charge with two batteries no longer stalls; it gives way to the peak guard ([[SAFETY-84]]) |
|
||||
| `b8ddfd7` | The peak guard wins over the maintenance hold; the hold ends on time, with no top-up ([[SAFETY-86]]) |
|
||||
| `9358781` | With two or more batteries, the peak guard uses every battery that can still give; peak alarms no longer say control stopped ([[SAFETY-87]]) |
|
||||
| `61072ca` | No false "Battery is not keeping up" during maintenance; real shortfalls still warn; the status row counts each episode once ([[SAFETY-85]]) |
|
||||
|
||||
### Known issues
|
||||
|
||||
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
|
||||
- Two inverters: at the end of a maintenance charge one battery can charge from the grid while the house uses a lot. Do not add a second inverter before the next release ([[SAFETY-93]]).
|
||||
- A healthy battery that turns from charging to giving can be held at 0 W for up to 15 min ([[SAFETY-94]]).
|
||||
- After a gap in the P1 meter, the peak guard is blind for the rest of that quarter hour ([[SAFETY-90]]).
|
||||
- Two batteries: one at its peak reserve can be used before a draining battery has used its room ([[SAFETY-91]]).
|
||||
|
||||
### 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.12.0
|
||||
|
||||
**A MINOR BUMP FOR ONE NEW OPTION: `pv_entities` ([[TEL-12]]), for sites with
|
||||
|
||||
@@ -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,109 @@
|
||||
# MAINT-13 - a full pack that stops at 98-99 % is not a failed charge
|
||||
|
||||
**Status: for review.** Tests: `test_maint13.py`. No option added; every number
|
||||
is a constant in `app/maintenance.py`.
|
||||
|
||||
**What happened (live, B).** The drain ended 2026-10-04 18:24 UTC and the charge
|
||||
began. It ran in pieces behind the price gate, reached 99 % at 2026-10-06 04:00,
|
||||
fell back to 98 % at 04:15 while the loop still asked about -1370 W, and never
|
||||
reached `maintenance_soc_target` 100. At 06:24 (2160 min = 36 h of wall clock
|
||||
after the charge began) it aborted "charge stuck at 98 %". `last_completed` was
|
||||
not set, so B was still due and would have drained again from 98 % to 11 % at the
|
||||
next start hour, with no notice. Three faults: a pack may never report 100 %
|
||||
while the BMS balances; a failed cycle came back the next day; and the 36 h
|
||||
counted hours the price gate kept the pack waiting.
|
||||
|
||||
## 1. The charge is complete when
|
||||
|
||||
1. SoC >= target (as before), or
|
||||
2. SoC >= target - 3 (`CHARGE_NEAR_FULL_PCT`) and the SoC has not beaten its
|
||||
best for 60 min (`CHARGE_STALL_MIN`) of allowed charging, or
|
||||
3. the charging time (`CHARGE_TIMEOUT_MIN`, 2160 min) runs out at SoC >= target - 3.
|
||||
|
||||
Then the usual HOLD follows and `last_completed` is set after it. Each of 2 and
|
||||
3 logs one plain line ("the battery takes no more charge at 98 %, so the charge
|
||||
counts as done", "charge time is up at 98 %, close enough to full ...").
|
||||
|
||||
**Why "SoC has not beaten its best", and not asked/delivered watts.** SoC is the
|
||||
one signal the live record gives us (99 at 04:00, 98 at 04:15); the asked power
|
||||
was -1370 W but no delivered figure was kept, so a watt threshold (X, Y) would be
|
||||
invented. SoC also covers the case where power flows but nothing is stored.
|
||||
**Why 3 points.** The card's band; at target 100 it is 97, and the live pack sat
|
||||
at 98-99. **Why 60 min.** A judgement, not a measurement: we have no per-minute record of
|
||||
the pack taking charge near full. The one live fact is 2 h 24 min (04:00 to
|
||||
06:24) without a new high, so 60 min is well inside it and long enough that a
|
||||
pack still creeping up (one new high within the hour) keeps resetting the wait.
|
||||
On the live run the rule fires after 60 gate-open minutes past the 99 %: 05:00
|
||||
UTC at the earliest, before 06:24 if the gate was open. If the pack turns out to
|
||||
step up more slowly than that, raise the constant. Falling back by one point
|
||||
does not reset the wait; only a new best does.
|
||||
|
||||
## 2. A failed cycle backs off 7 days
|
||||
|
||||
A drain stuck 12 h or a charge that times out below target - 3 is a failure
|
||||
(`FAIL_BACKOFF_DAYS` = 7). The machine goes IDLE as before, `last_failed` is
|
||||
stored, and `wants_start()` is false until 7 days later. The operator's forced
|
||||
start ignores it; a cycle that completes after the failure ends it. An operator
|
||||
abort, an interlock stop and a switched-off pack are not failures.
|
||||
|
||||
**Why 7 days, not the 28 day interval.** A judgement: a failure may have a
|
||||
short cause (several dull days with no cheap hours, a pack switched off), and
|
||||
waiting the full interval leaves the coulomb counter uncalibrated for a month
|
||||
more. A week is long enough that a bad pack costs one drain a week, with one
|
||||
notice a week, not one a day. **Cost.** A truly faulty pack still drains once a week until someone looks;
|
||||
the notice says so each time.
|
||||
|
||||
**Notice, once.** The tick that fails carries `failed`; the Controller sends one
|
||||
persistent notification "Battery cycle did not finish" (EN, with the Dutch
|
||||
below it; per pack at N=2). It is not repeated, and Home Assistant restarts
|
||||
wipe it, so the status page shows a "Maintenance paused" row with the date for
|
||||
as long as the wait lasts. The operator may also see the log lines.
|
||||
|
||||
## 3. The timeout counts only allowed time
|
||||
|
||||
`Controller._charge_gate_open()` says whether the pack may charge now. It is
|
||||
true when no price gate is set up (no price entities, no fixed tariff: nothing
|
||||
holds the charge back, and `cheap` is always false there), in a cheap window, or
|
||||
while the pack is really taking charge (battery power at or below -50 W, which is
|
||||
sun outside cheap hours). It is false only when a gate exists, is shut, and the
|
||||
pack is not charging; a pack asked for charge that delivers nothing then does not
|
||||
count, which is the case the clock cannot judge. A site with a price provider
|
||||
(TARIFF-11) and no fixed tariff counts as having no gate: the provider's plan
|
||||
reaches only the Tariff row, never `cheap`, so no cheap window ever opens a grid
|
||||
top-up there and only the sun charges the pack, as on a site with no tariff. Its
|
||||
time counts in full. When a provider plan starts to drive `cheap`,
|
||||
`_price_gated` must become true for it. Whether a fixed tariff is set up is
|
||||
worked out once an hour, not on every tick. Only open ticks add to `open_min`; one tick
|
||||
adds at most 5 min, so a gap (add-on down) adds nothing. The stall wait pauses
|
||||
while the gate is closed. The accounting is written to the state file at most
|
||||
every 5 min (`charge_track`); a restart loses at most that. A state file from
|
||||
before this change, or with no record for the current charge, starts from the
|
||||
wall-clock minutes so far, which is the old behaviour.
|
||||
|
||||
**Backstop.** A gated site whose gate never opens (no cheap hours, no sun) would never time
|
||||
out, so a charge also ends after 7 days of wall clock (`CHARGE_WALL_TIMEOUT_MIN`):
|
||||
complete at >= target - 3, a failure below.
|
||||
|
||||
**MAINT-12.** This is the smallest change that makes the timeout honest: one
|
||||
boolean in, one counter. MAINT-12's block plan can replace the boolean (the
|
||||
minutes it plans are the allowed ones) without touching the rest. Two things it
|
||||
must keep: the back-off is in `wants_start()`, so any new entry point (such as
|
||||
`price_forecast.begin_phase`, which is not wired today) must go through it or
|
||||
check `backoff_until()`; and `gate_open` must stay "the pack is allowed to
|
||||
charge now". The peak guard pausing the charge shaping is not seen as a closed
|
||||
gate: that time still counts.
|
||||
|
||||
## 4. What does not change, and what this does not cover
|
||||
|
||||
- The drain, the HOLD, the held-battery rules and `arbitrate()` are unchanged. A
|
||||
backed-off pack does not want a start, so at N=2 the other pack may take the
|
||||
slot. One pack at a time at N=1 and N=2.
|
||||
- `last_failed` is a maintenance key and moves with the others when a second
|
||||
inverter is added (`MAINT_UNIT_KEYS`). The charge record is not moved; it
|
||||
restarts from the wall-clock minutes.
|
||||
- It does not find out why a pack stopped. A pack that stops at 96 % and never
|
||||
gets near is still a failure.
|
||||
- The Dutch text is only in the notification. The status row and the log lines
|
||||
are English, like the rest of the status page.
|
||||
- It does not change the target: a site that wants a different stop point sets
|
||||
`maintenance_soc_target`.
|
||||
@@ -0,0 +1,68 @@
|
||||
# MAINT-14 - no SoC reading in a maintenance cycle
|
||||
|
||||
**Status: for review.** Tests: `test_maint14.py`. No option added; the one new
|
||||
number is a constant in `app/maintenance.py`.
|
||||
|
||||
**The fault.** `Maintenance.tick()` returned early when the SoC was `None`,
|
||||
before any timeout check. (1) A charge on a pack whose SoC reading was lost
|
||||
never ended: no timeout, no back-off, no notice, and the 7-day backstop of
|
||||
MAINT-13 sat behind the same return. (2) A drain kept commanding `discharge_w`
|
||||
with an unknown level, which breaks the TEL-08 rule that an unknown level never
|
||||
allows discharge.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Charge, no SoC.** The charge timeout (`CHARGE_TIMEOUT_MIN`, gate-open
|
||||
minutes) and the 7-day wall backstop still run. When one fires: if the last
|
||||
SoC seen was >= target - 3 the charge counts as done (the MAINT-13 "close
|
||||
enough to full" rule, HOLD follows); otherwise it fails: MAINT-13 back-off
|
||||
and its one notification. If no SoC was ever seen, it fails.
|
||||
2. **Drain, no SoC.** DRAIN holds at 0 W and owns the setpoint, exactly as HOLD
|
||||
does. The 12 h drain timeout still runs and fails as before.
|
||||
3. **SoC comes back.** Nothing is recorded; the drain continues at the drain
|
||||
rate, the charge carries on, and the stall wait of MAINT-13 restarts from
|
||||
what it was: it paused while the reading was missing, so a gap never counts
|
||||
as "no more charge".
|
||||
4. **HOLD** is unchanged (0 W).
|
||||
5. **Log.** One line when a drain starts holding for this reason ("the battery
|
||||
level is unknown, so the drain holds at 0 W until it is back"), and one line
|
||||
when the level has been missing for `NOSOC_NOTE_MIN` = 15 min. Both are said
|
||||
once per episode; the old line that repeated every tick is gone.
|
||||
|
||||
## Why
|
||||
|
||||
- **"Last seen", not "best seen".** A pack that was at 99 and then dropped to 50
|
||||
before the reading went is not close to full. Only the last reading counts.
|
||||
It is saved with the charge record (`charge_track.last`), and saved at once
|
||||
when the reading first goes missing, so a restart mid-gap still knows it
|
||||
(within the usual 5 min save interval otherwise).
|
||||
- **Failure when nothing was seen.** With no level we cannot call a pack full.
|
||||
The failure costs a 7-day wait and one notice; calling it done costs a
|
||||
missed balance and a month-long false "complete". The first is the honest one.
|
||||
- **Timeouts count the same minutes as with a reading.** Gate-open charging
|
||||
minutes only; the wall backstop for a gate that never opens.
|
||||
- **15 min.** A judgement. A poll glitch of a few minutes should not write a
|
||||
line; a reading lost for a quarter of an hour is worth one. Raise it if the
|
||||
action log shows noise.
|
||||
- **0 W, not "stop the phase".** Aborting a drain on a short gap would throw
|
||||
away the part already drained. Holding at 0 W loses nothing and the 12 h
|
||||
timeout is the limit.
|
||||
|
||||
## Cost
|
||||
|
||||
- A drain that loses its reading stops draining, and the 12 h clock keeps
|
||||
running. A gap of many hours can therefore fail a drain that was working. The
|
||||
back-off then applies (7 days).
|
||||
- A charge whose reading is lost early (last SoC below target - 3) fails at the
|
||||
timeout, even if the pack was in fact filling. The same 7-day wait applies.
|
||||
|
||||
## What this does not cover
|
||||
|
||||
- **HOLD with no SoC**: closed by MAINT-15 (`MAINT-15-hold-ends-without-soc.md`).
|
||||
- It does not tell the operator why the reading is missing (sensor, register,
|
||||
link). It does not reuse the stale-input gate; it is the cycle's own rule.
|
||||
- The charge itself is still driven by the control loop, which has its own
|
||||
handling of a missing SoC (TEL-08 for the loop is a separate card).
|
||||
- A restart loses at most 5 min of accounting, as in MAINT-13. The "unknown for
|
||||
long" line may be said again after a restart: the episode start is in memory.
|
||||
- Text added is two log lines, English only, like the other log lines.
|
||||
@@ -0,0 +1,47 @@
|
||||
# MAINT-15 - the balancing hold ends on time with no SoC reading
|
||||
|
||||
**Status: for review.** Tests: `test_maint15.py`. No option added.
|
||||
|
||||
**The fault.** `step()` sent every tick with no SoC to `_no_soc()`, and for
|
||||
HOLD that returned 0 W without the time check. A hold that lost its reading
|
||||
never ended: `last_completed` was never written and the next due date never
|
||||
moved. Harmless for power (0 W), but the cycle was stuck. This was left open
|
||||
in MAINT-14.
|
||||
|
||||
## Rule
|
||||
|
||||
1. **HOLD ends at `hold_min` whether or not there is a reading.** One helper,
|
||||
`Maintenance._hold()`, holds the time check and is used by both paths. When
|
||||
the time is up it writes `last_completed`, goes IDLE and logs the same
|
||||
"cycle COMPLETE" line. Before the time is up it commands 0 W as before.
|
||||
2. **Text.** The 15-min "no battery level" line in HOLD now reads "The hold
|
||||
still ends at its normal time." Still once per episode.
|
||||
|
||||
## Why
|
||||
|
||||
HOLD is reached only after the charge counted as done. Every entry into HOLD
|
||||
is in `step()` or `_no_soc()` and follows a charge that reached the target,
|
||||
stalled near full (MAINT-13) or timed out near full (MAINT-13/14). Nothing else
|
||||
sets the phase to HOLD (`_enter(HOLD` has four call sites, all after a done
|
||||
charge). The hold is a wait for the BMS to balance; it never needed the level.
|
||||
So a hold that ends with no reading is a completed cycle. The "cycle COMPLETE"
|
||||
line is true without a reading: it names minutes of hold, not a level.
|
||||
|
||||
## Cost
|
||||
|
||||
- If the level was lost during the hold, the cycle is recorded as complete
|
||||
without anyone seeing the final level. The charge had counted as done, so this
|
||||
is the same trust MAINT-14 already gives a charge that ends with no reading.
|
||||
|
||||
## What this does not cover
|
||||
|
||||
- It does not check that the pack is still full at the end of the hold.
|
||||
- It does not say why the reading was lost.
|
||||
- With a reading, nothing changes. `test_maint15.py` section 7 compares the new
|
||||
HOLD tick by tick with a frozen copy of the release/1.0 HOLD branch (phase,
|
||||
setpoint, owner, events, `last_completed`) at four `hold_min` values. A wider
|
||||
trace of the whole cycle (800 seeded runs against db9e231) was run outside the
|
||||
repo; its script and log are kept under `scratch/MAINT-15/` of the dev
|
||||
workspace, not in this repository.
|
||||
- A state file written by an older version with `phase: hold` loads and ends on
|
||||
time; the file is not changed in format.
|
||||
@@ -0,0 +1,80 @@
|
||||
# SAFETY-103 - no swing on old battery readings after a drain ends
|
||||
|
||||
**Status: implemented 2026-10-08, for review.** Tests: `test_safety103.py`.
|
||||
|
||||
**The defect** (SAFETY-95 rig test, check 5, F1). While a battery settles
|
||||
from its ended drain, SAFETY-95 steers on the house's point: grid + every
|
||||
battery's reading - aim. The meter is 1 s old; a battery register can be
|
||||
10 s old (the SAFETY-90 register model). At E+13 A's reading said 1238 W
|
||||
(6.8 s old) while A was at 616 W, so the house looked ~620 W bigger: B went
|
||||
860 -> 1650 -> 870 W. B's own old reading does the same the other way.
|
||||
|
||||
**The rule** (`main.py`, `_track_reads()`, `_drain_grid_w()`, N>=2, only
|
||||
while a drain's tail settles):
|
||||
|
||||
1. Each cycle, per battery: the range its power can be in. That is its last
|
||||
changed reading, widened by every command on its wire since (a lagging
|
||||
battery moves from one toward the others, never beyond). A new reading
|
||||
starts the range again.
|
||||
2. A reading that changed this cycle is taken as it reads. So is the side a
|
||||
battery refuses (SAFETY-73): it is not heading that way.
|
||||
3. The house point is held (first cycle: the loop's start from SAFETY-95).
|
||||
It moves only as far as the meter proves: to the nearest value of grid +
|
||||
the batteries' ranges - aim.
|
||||
|
||||
So the loop no longer moves on a reading that has not caught up. A real
|
||||
house step still moves it, as soon as the meter shows more than the
|
||||
batteries can explain.
|
||||
|
||||
**Why not the tester's rule** ("a battery whose reading is older than its
|
||||
last command counts at its command"). B's command changes almost every
|
||||
cycle, so B is almost always at its command. That is the plain meter, which
|
||||
chases A's tail: B swings 1420 W. A lag model needs the plant's time
|
||||
constant, which is not known for a client's inverter. Measured on the plant
|
||||
below (N=2, 1200 W, A Off; swing = a move of the others later undone):
|
||||
|
||||
| rule | 10 s lag | 5 s lag | 20 s lag | surplus, 5 s lag |
|
||||
|---|---|---|---|---|
|
||||
| release/1.0 (readings) | 760 | 840 | 150 | 800 |
|
||||
| tester's: at the command | 1420 | 1020 | 1600 | 800 |
|
||||
| lag model, tau 10 s | 70 | 140 | 80 | 580 |
|
||||
| lag model, tau 5 s, plant 20 s | | | 600 | |
|
||||
| hold all window | 0 | 0 | 0 | 0 |
|
||||
| **this rule** | **0** | **0** | **0** | **130** |
|
||||
|
||||
Holding all window ignores a house step: a 2 kW kettle at E+5 s imported
|
||||
14.4 Wh over the next 30 s (release 11.5, this rule 11.3); 2 kW more sun
|
||||
exported 16.9 Wh (release 8.5, this rule 8.9).
|
||||
|
||||
**Before -> after** (`test_safety103.py`, `tools/safety103_plant.py`: 800
|
||||
W/s ramp, 10 s lag, meter 1 s, registers 10 s at the rig's phases, B idles
|
||||
at -100 W; summed swing of the others in the 23 s after the end):
|
||||
|
||||
| case | release/1.0 | after |
|
||||
|---|---|---|
|
||||
| rig check 5: N=2, 1200 W, A Off | B 1140 -> 850 -> 1630 -> 870, swing 760 W | B holds 1140 W |
|
||||
| N=3, 1200 W / 4000 W, A Off | 680 / 630 W | 0 / 0 W |
|
||||
| solar surplus, N=2 / N=3 | 890 / 740 W | 130 / 130 W |
|
||||
| lag 5 or 20 s, registers 5 or 10 s, N=2 and N=3 | | at most 10 W |
|
||||
| registers as fresh as the meter (1 s) | | rows byte-identical |
|
||||
| refusal end | | rows byte-identical |
|
||||
| N=1, every end | | rows byte-identical (also against the release/1.0 tree) |
|
||||
|
||||
**Cost.** In the window, a house change smaller than what the batteries
|
||||
could still be doing is not followed until a reading catches up (at most a
|
||||
register period) or the window ends (23 s). Under a surplus the others
|
||||
still swing up to 130 W, a third of a slew step.
|
||||
|
||||
**Not covered.**
|
||||
- Outside the drain tail the loop steers on the meter; readings only reach
|
||||
the saturation freeze (SAFETY-96), unchanged.
|
||||
- A lost link or a refusal ends the tail at once (A is out of the split, or
|
||||
reads ~0 W). The swing after them is the loop's own on a lagging plant,
|
||||
the same with 1 s registers and on release/1.0: under a surplus up to
|
||||
1.5 kW at N=2 and 3.9 kW summed at N=3; after a lost link about 1 kW
|
||||
even without one. Not this card.
|
||||
- A battery that overshoots its command, or a reading offset beyond the
|
||||
range, can put the true power outside the range; the meter then moves the
|
||||
point, as before.
|
||||
- The end cycle's start (SAFETY-95 step 2) still uses the readings; the
|
||||
batteries were steady during the drain, so they are current there.
|
||||
@@ -0,0 +1,96 @@
|
||||
# SAFETY-107 - a refusal held under another writer is learned once
|
||||
|
||||
**Status: for review.** Tests: `test_safety107.py`. One inverter only; nothing
|
||||
changes at N>1 (SAFETY-73's unit refusal has no "another writer" end).
|
||||
|
||||
**The defect** (MAINT-13 F5, MAINT-14 "seen again"). A full pack under a
|
||||
maintenance charge in a cheap window takes no charge. At N=1 the action log then
|
||||
showed "BATTERY REFUSES asked -2500 W ..." and "BATTERY REFUSES ENDED: another
|
||||
writer" every cycle: 548 + 548 lines in 15 min on the MAINT-13 rig head,
|
||||
652 + 651 in 12 min on its base, 33 + 32 in 60 s in MAINT-14's c2b. The 40-line
|
||||
log flooded and the hint under the buttons changed every second, so the
|
||||
MAINT-13 notice was gone at once.
|
||||
|
||||
**Cause** (`main.py`, the SAFETY-52 block before `compute()`). The end tested
|
||||
`self.target != self.loop_w`: the wire differs from the loop's ask. A held
|
||||
refusal makes the loop ask 0 W, so any claim that binds (the maintenance
|
||||
charge's `hi`, the peak guard's `lo`, a trade's SET) differs from it on every
|
||||
cycle. The refusal was ended against the same writer it had been learned
|
||||
against. `sat_count` is not reset by that end, so `compute()` learned the same
|
||||
refusal again in the same cycle. Nothing about the battery or the wire had
|
||||
changed; only the log and the hint did.
|
||||
|
||||
**Other writers.** The same flap comes from any writer that holds the wire
|
||||
while the loop is refused, as long as the evidence lasts: a trade (a SET at
|
||||
P_ARBITRAGE) into a full pack, and the peak guard over an empty pack: 335
|
||||
refusal lines in 15 min each at a 5 s meter (`test_safety107.py` part 2, run on
|
||||
release/1.0).
|
||||
A maintenance drain or hold does not flap: its SET resets `sat_count` each cycle.
|
||||
|
||||
**The rule** (`_refusal_update()`, `rejudged`). When another writer held the
|
||||
wire, the held refusal is judged afresh: `compute()` gets `refused=0` and no
|
||||
probe, exactly as before the change. Then:
|
||||
|
||||
1. it finds the same refusal against that writer's ask: the same episode
|
||||
continues, with no line and no hint. The probe clock restarts, as it did on
|
||||
the old end and start: a probe is the loop's ask, and it is not on the wire
|
||||
while another writer is;
|
||||
2. it does not: the refusal ends, logged "another writer", or "the battery
|
||||
gives power" when the reading shows it (it was "another writer" before);
|
||||
3. it finds the other direction: end, then a new start, as before. In
|
||||
practice 2 comes first: SAFETY-94's evidence restarts when the ask changes
|
||||
sign, so a writer that turns round ends the refusal at once and the other
|
||||
way is learned ~12 cycles later (`test_safety107.py` part 3e).
|
||||
|
||||
**Why this rule.** The end now means what SAFETY-52 meant by it: the refusal no
|
||||
longer describes what is on the wire, judged by the same test that learns it.
|
||||
`compute()` sees the same inputs as before and the state after each cycle is
|
||||
the same (`batt_refused`, `batt_refused_at`), so every command is unchanged.
|
||||
Keeping the held refusal into `compute()` instead (mutation M5) would also
|
||||
stop the lines, but it holds the loop at 0 W where the old law let it ask past
|
||||
a writer's limit, which changes commands; it also removes SAFETY-52's
|
||||
owner-decided "another writer" exit.
|
||||
|
||||
**Evidence.**
|
||||
- Differential trace (`goodwe/scratch/SAFETY-107/probe/trace.py`, not in this
|
||||
repo; the real Controller in the CI image, release/1.0 bf3ac79 against this
|
||||
tree): 300 seeds, N=1 (153) and
|
||||
N=2 (147), 1/2/5 s meter, full/empty/dead/healthy battery windows, a stepping
|
||||
house, maintenance charge with the cheap window opening and closing, P1 and
|
||||
the peak guard, trade windows, stop/start; 427 680 cycles, 294 seeds with a
|
||||
refusal held. Every command, site target, integrator, `sat_count`, refusal
|
||||
state and reason is identical. Refusal lines: 100 748 before, 2 188 after.
|
||||
- The card's case: 1 line in 15 min at N=1 and N=2, 1/2/5 s meter, house
|
||||
importing or exporting (release/1.0: 335-1 753 at N=1; N=2 was already 1).
|
||||
|
||||
**Mutations** (`tools/mutation_run.sh` on purged copies; reds in
|
||||
test_safety107 / test_refusal / test_refusal_units; trace = seeds of 300 whose
|
||||
commands differ from release/1.0):
|
||||
|
||||
| | mutation | reds | trace |
|
||||
|---|---|---|---|
|
||||
| M1 | `now == rejudged` -> `False` (every such cycle ends: the old flap) | 26 / 0 / 0 | 0 |
|
||||
| M2 | `rejudged` always 0 (another writer never ends it) | 5 / 1 / 2 | 29 |
|
||||
| M3 | the probe clock is not restarted on a continued episode | 1 / 0 / 0 | 2 |
|
||||
| M4 | a probe may open while rejudged | 0 / 0 / 0 | 0 |
|
||||
| M5 | the held refusal goes into `compute()` while rejudged | 2 / 1 / 2 | 27 |
|
||||
| M6 | an end while rejudged always says "another writer" | 1 / 0 / 0 | 0 |
|
||||
|
||||
M4 is equivalent: a probe matters only if `compute()` engages in that call,
|
||||
which needs `SETTLE_CYCLES` saturated against the writer, and each of those
|
||||
cycles restarted the clock. The line stays because it states the rule.
|
||||
|
||||
**Cost.** None in power. A takeover by a different writer while the battery
|
||||
still refuses is logged as an end and, ~12 cycles later, a new start against
|
||||
that writer (3 lines for the episode, `test_safety107.py` part 3a).
|
||||
|
||||
**Not covered.**
|
||||
- A perfectly flat reading under a writer. The stale-reading gate trips, stops
|
||||
the site and seeds the refusal (SAFETY-52 decision 3). After the stop the
|
||||
writer's ask returns fresh, `compute()` has no saturation history against it,
|
||||
so the refusal ends at the first cycle and the next trip seeds it again: 2
|
||||
lines per gate cycle (29 in 15 min, house exporting), the same as release/1.0.
|
||||
The gate cycle itself (0 W, then the writer's ask) is the cause; stopping it
|
||||
changes commands. None of the rig runs showed it: real readings move.
|
||||
- At N=1 a refusal shapes only the loop. A writer above it still asks the
|
||||
battery; at N>1 SAFETY-73's P_SAFETY claim binds every writer.
|
||||
@@ -69,6 +69,8 @@ which does not run the startup gate) the absence costs a shave and never a pack.
|
||||
|
||||
## 3. DECISION 2 — the site verdict is the MAXIMUM of the packs' own floors
|
||||
|
||||
*At N>=2 one pack at its floor no longer stops the others: [[SAFETY-87]].*
|
||||
|
||||
`Controller.pack_floor_soc` — the **minimum** measured SoC, the emptiest pack —
|
||||
**is unchanged**, exactly as the card instructed. What changed is the floor it is
|
||||
graded against. `peakguard.binding_soc_floor()` reduces the roster's per-pack
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# SAFETY-85 - no false "not keeping up" while another owner sets the battery
|
||||
|
||||
**Status: implemented 2026-10-01, for review.** Tests: `test_safety85.py`.
|
||||
|
||||
**The defect.** "Battery is not keeping up" (SAFETY-40, `goodwe_integrator_bound`)
|
||||
fired about every 2.5 min during maintenance (drain, charge, HOLD) and under the
|
||||
peak guard, while the battery gave exactly what it was told. Each warning
|
||||
re-created the HA notification. Rig counts: 24 in an hour (SAFETY-83 F1), 43 in
|
||||
7 episodes (SAFETY-86), 35 in 16 (SAFETY-87), 12 in 6 (SAFETY-84). The page
|
||||
counter counted cycles (861), not episodes.
|
||||
|
||||
**Cause, measured on the real Controller.**
|
||||
- N=1: when a higher claim decides, `cycle()` re-seeds the integrator from the
|
||||
target each cycle, then `compute()` adds `gain * error` for a grid error the
|
||||
loop does not own. Target + kick crosses the rail and sets `i_clamped`. The
|
||||
carried integrator is not wound (SAFETY-48 caps the lead at one slew step),
|
||||
so control comes back one slew step at a time.
|
||||
- N>=2: a maintenance pin is a unit claim. The loop stays the site winner and
|
||||
winds to its rail for watts the pinned battery may not give. When the pin
|
||||
lifts, the site jumps: 2500 W in one cycle when a HOLD ends under a 4500 W
|
||||
house, 1390 W when a charge pin lifts (`s84 n2_charge_dear`, t=1800 s).
|
||||
|
||||
**Decision.** Three rules; the alarm, its text and its id are unchanged.
|
||||
1. **The loop's clamp counts only on a cycle the loop decided.** The winner is
|
||||
the loop. Its number is on the wire, or only a rating (`device`,
|
||||
`supervised`) cut it. No bound held it, and nothing was unmet.
|
||||
(`Controller._loop_alarm_input`.)
|
||||
2. **A battery another owner set to discharge alarms when it gives less than
|
||||
it was told.** This covers every battery on a cycle the loop did not
|
||||
decide, and a battery with its own pin otherwise. The test is more than
|
||||
`saturation_w` short of the least it stood at over `SHORT_CYCLES` cycles
|
||||
and `SHORT_SPAN_S` (the plant's measured settle, SAFETY-13/73), for
|
||||
`saturation_cycles` cycles in a row (`clamp.delivery_short`). It covers a
|
||||
real shortfall during maintenance or under the peak guard. Before, that
|
||||
shortfall was silent unless the kick happened to reach the rail (house
|
||||
1000 W, drain 2000 W, battery gives nothing: 0 warnings before, 4 in 10 min
|
||||
now).
|
||||
3. **At N>=2, while maintenance holds a battery, the loop is bounded** by
|
||||
OPT-11's `loop_bound()` (what the batteries may place). It does not wind,
|
||||
and control comes back one slew step a cycle. Why maintenance only: that is
|
||||
where the jump was measured and where the owner order puts maintenance
|
||||
above the loop. Other N>=2 limits (pack cut-out, SAFETY-73 refusal) keep
|
||||
the old law. Rule 1 still silences them (the target is unmet), so they no
|
||||
longer warn either.
|
||||
|
||||
Fixing the windup was chosen over only ignoring the wound part, because at
|
||||
N>=2 the wound integrator moves real watts at hand-back. At N=1 there was
|
||||
nothing to fix: re-seeding already prevents windup.
|
||||
|
||||
**What SAFETY-03/40 protect is kept.** With the loop in charge, the integrator
|
||||
bound still warns (a runaway, a house above the rating), and a saturated
|
||||
inverter under the loop's own command still does not alarm (SAFETY-03 AC 2):
|
||||
rule 2 grades only batteries another owner set.
|
||||
|
||||
**The counter.** `ClampWatchdog.bound_episodes` counts episodes that had a
|
||||
bound engagement. The status row shows it ("N times since start").
|
||||
`bound` (cycles) stays for the log and the tests.
|
||||
|
||||
**Re-pinned digests.** Every change is intended; nothing at N=1 moves a watt.
|
||||
|
||||
| digest | what changed |
|
||||
|---|---|
|
||||
| s78 `n1_drain_owner` | the false clamp rows are gone from the log; commands identical |
|
||||
| s78 `n2_drain`, s84 `n2_charge`, `n2_charge_banked`, s79 `n2_maint` (test_peak_margin) | loop reason (`mode-bound`), integrator and loop claim while bounded; commands identical |
|
||||
| opt11 `n2_maint` (test_modes) | A drops out at t=1655 s: B follows the house at once, 8 cycles differ (max 920 W) |
|
||||
| s84 `n2_charge_dear` | A's pin lifts at t=1800 s: 400 W a cycle, not a 1390 W jump; the surplus step after it; 98 cycles |
|
||||
|
||||
test_safety86's "without the ceiling the log floods" now needs the ceiling
|
||||
and the bound both removed: either one alone keeps the loop inside what the
|
||||
batteries can give. The `alarmed()` helpers in test_safety84/86 also read
|
||||
SAFETY-81's quarter stamp, because ALLOCATOR rows can push its row out of
|
||||
the 40-row log.
|
||||
|
||||
**Review (PR #163).** The first draft graded each battery against last
|
||||
cycle's command and graded charges too. On plantsim's battery (10 s lag) at
|
||||
the live 2 s meter, every drain or charge start warned once, and an N=2 drain
|
||||
or HOLD under the peak guard with a stepping house warned 15 times an hour. A
|
||||
maintenance charge tapering near full warned 11 times in 50 min, which
|
||||
SAFETY-42 section 7 had ruled out. With the settle window and discharge only:
|
||||
0 in all of those cases. A battery that gives nothing still warns, lagging
|
||||
or not. At a hypothetical 20 s lag, it warns at most twice an hour. The cost:
|
||||
a battery that does not take a charge another owner set is not named by this
|
||||
alarm (the same gap SAFETY-42 states; the charge has its 36 h timeout).
|
||||
`test_safety85.py` part 7.
|
||||
|
||||
**Not changed, open for the owner.**
|
||||
- With the loop in charge and the house above the batteries' total rating,
|
||||
it still warns (SAFETY-40). On the rig's 2 x 2000 W N=2, most of SAFETY-84's
|
||||
count came from houses of 4500 and 6500 W.
|
||||
- TEL-10's `Loop reason` reads `mode-bound` while the maintenance bound holds
|
||||
the loop, with modes off. DOCS lists the value with both meanings.
|
||||
@@ -0,0 +1,83 @@
|
||||
# SAFETY-87 - the peak guard uses every battery that can still give
|
||||
|
||||
**Status: DECIDED 2026-10-01 (owner card).** Follows SAFETY-86's "Not
|
||||
changed" list. Tests: `test_safety87.py`; identity:
|
||||
`tools/safety87_identity.py`.
|
||||
|
||||
**The defect.** At N>=2 the guard graded the emptiest battery against the
|
||||
highest floor ([[SAFETY-32]] §3). One battery under `peak_soc_floor` made the
|
||||
whole site floor-limited: the claim asked for 0 W and the reserve alarm fired,
|
||||
even with the other battery full. Measured: a maintenance charge (A at 11 %)
|
||||
raised the alarm 10 times while both batteries gave 2000 W and the quarter
|
||||
landed on the aim (SAFETY-84 rig test, check 3); a HOLD (A at 99 %, B at
|
||||
15 %) left A at 0 W and banked 4051 W, raising the month peak (SAFETY-86
|
||||
review; rig case 7a: 4563.8 W).
|
||||
|
||||
**The fix.**
|
||||
- `decide()` and its call are unchanged. At N>=2 `Controller._peak_on_able()`
|
||||
then grades each battery on its own floor (`peakguard.reserve_split()`).
|
||||
One at or under it is *held*. The others that are live and not refusing
|
||||
(SAFETY-73) are *able*.
|
||||
- A floor-limited decision is re-taken on the able batteries
|
||||
(`peakguard.place_on_able()`): their own permission (min SoC against max
|
||||
floor, among them), and a floor of `min(need, able_w)`. `able_w` is their
|
||||
rating plus the drain a held battery gives anyway (its maintenance pin).
|
||||
- The held batteries weigh 0 in the split while the site gives
|
||||
(`arbiter.resolve(reserve=)`), so the floor lands on the able ones. During a
|
||||
HOLD, a held battery is not counted among the "others" (`_peak_over_hold()`),
|
||||
so the holding battery covers the need.
|
||||
- Floor-limited now means: some battery is held and the able ones cannot hold
|
||||
the aim. The claim still asks them for `able_w`. The alarm fires only if
|
||||
they miss the real limit at full power for `ALARM_HOLD_S`, the same test as
|
||||
'Load too high' (`_reserve_short()`), so a one-cycle miss at a quarter's end
|
||||
is not an alarm. With nothing able (all held, or the rest refusing or
|
||||
dropped) it is the old state: 0 W, immediate alarm.
|
||||
- The alarm and the row name `held`, each battery graded on its own floor. The
|
||||
old code named batteries against the site's highest floor.
|
||||
- Page: the two peak rows carry `runs`. A red `runs` row gives the banner its
|
||||
title without "Not ready", and the Home page is not shown as held at 0 W.
|
||||
Rows that do stop control still outrank it.
|
||||
|
||||
**How SAFETY-32's concern is still covered.** SAFETY-32 took min(SoC) against
|
||||
max(floor) so the guard never sizes a forced discharge a battery's own BMS
|
||||
stops. Now no battery at or under its own floor is asked for the peak: it is
|
||||
graded on its own floor, and an unread SoC or unknown cut-out counts as held.
|
||||
The min/max pairing still decides permission, over the able batteries. The
|
||||
roster verdict `decide()` echoes (`soc`, `soc_floor`) is kept, so SAFETY-32's
|
||||
call-site and reduction checks still bite. What changed is only that one held
|
||||
battery no longer stops the others. One exception, with battery modes on: see
|
||||
Open.
|
||||
|
||||
**Maintenance pins beside a held battery** (review). A draining battery that
|
||||
may still give is raised from its drain rate towards its rating for the peak
|
||||
floor, after the others (`_drain_over_reserve()`; peak > drain), else the
|
||||
floor's rest landed on the held battery. In a HOLD, a held battery counts its
|
||||
own pin (a drain) among the others, and a held battery pinned to charge stops
|
||||
the raise (never split both ways).
|
||||
|
||||
**Before -> after** (test_safety87.py, rig of 2 x 2500 W, aim 3450 W):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| charge, A 11 %, house 4500 | 3 reserve alarms, quarter 3450 | 0 alarms, 3450 |
|
||||
| HOLD, A 99 % / B 15 %, house 5500 | A 0 W, B 2488 W, 3 alarms | A 2054 W, B 0 W, 0 alarms, 3450 |
|
||||
| HOLD, house 6500 | A 0 W, B 2488 W, 4051 W | A 2500 W, B 0 W, 4014 W, 1 alarm (A alone is short) |
|
||||
| HOLD, month peak 4000, house 6000 | A 0 W, B 2488 W, 3551 W, alarms | A 2054 W, B 0 W, 3950 (peak - margin), 0 alarms |
|
||||
| both under the reserve | alarm | alarm (unchanged) |
|
||||
| N=1, every scenario | | byte-identical |
|
||||
|
||||
**Open, for the owner.**
|
||||
- The loop still draws a held battery (SAFETY-25 option A: the reserve binds
|
||||
the peak guard only). It gives last, after the able ones. So with house 6500
|
||||
in the charge case, B alone cannot hold the aim, the alarm fires as
|
||||
specified, and the loop then lands the quarter on the aim from A. The text
|
||||
says "cannot keep … alone", not "will go over", so it stays true.
|
||||
- Battery modes on, A Off and B held: when B alone can place the floor, OPT-11
|
||||
keeps A resting and B gives it. When B cannot, A gives first. Making Off give
|
||||
before a held battery in the first case needs a third allocator tier. The
|
||||
same at N=3: A Off, B held, C able, floor 4000 W gives C 2500, B 1500, A 0.
|
||||
Until then, with modes on, a held battery can give for the peak.
|
||||
- The notification and row text live in `messages.py`, which has no NL layer
|
||||
yet (UX-19). The Home page text, which has NL, is unchanged.
|
||||
|
||||
**Not changed.** N=1. 'Load too high' (SAFETY-81). SAFETY-85, -88, -89, -90.
|
||||
@@ -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,74 @@
|
||||
# SAFETY-91 - a held battery is drawn only after the others are full
|
||||
|
||||
**Status: DECIDED 2026-10-01 (owner: held battery last, SAFETY-25 option A
|
||||
kept).** Follows SAFETY-87. Tests: `test_safety91.py`.
|
||||
|
||||
**The defect** (SAFETY-87 rig test, F1 and F2). At N>=2 a battery at or under
|
||||
its own peak reserve was held back (weight 0) only on cycles where the peak
|
||||
guard asked for power.
|
||||
- F1: B draining at 1000 W, A held at 15 %, house 5500. Once the grid was
|
||||
near zero the loop set the target, A had its normal weight and B's drain
|
||||
pin bound, so the loop drew A at 2500 W for the whole quarter (15.0 ->
|
||||
10.7 %) while B stayed at 1000 W. In the next quarter A reached its
|
||||
cut-out, and B stayed pinned at the peak floor (~2422 W), not its 2500 W.
|
||||
- F2: in a HOLD (A holding, B held), at a quarter's edge the guard saw a block
|
||||
with no seconds left and asked for nothing for one cycle. The HOLD's
|
||||
ceiling lapsed, A went back to 0 W and the loop's target went to B: 2500 W
|
||||
for 1 s.
|
||||
|
||||
**The fix** (`main.py`, N>=2 only).
|
||||
- `_held_back()`: the batteries at or under their own reserve while another
|
||||
can still give (live, not refusing), graded as in `_peak_on_able()`. They
|
||||
weigh 0 in every split where the site gives, not only for the peak. With
|
||||
nobody able (all held, or the rest refusing or dropped) nobody is held back
|
||||
and the split is as before.
|
||||
Review: once held back, a battery stays so until its SoC reads more than 1
|
||||
point above its reserve. A reading flipping 20/21 % otherwise swapped
|
||||
~1000 W between the batteries on each flip (grid +-500 W with A 10 s and
|
||||
B 3 s lag). The guard's own permission (SAFETY-87) is unchanged.
|
||||
- `_drain_rise()`: while a battery is held back, a draining battery's pin
|
||||
becomes a floor at its drain rate (`lo`), so it rises towards its rating
|
||||
before the held one gives. Not for a held battery's own drain, not under a
|
||||
site stop, not for a battery that is not live.
|
||||
- `tariff.next_quarter()`: past a block's end, before the next block's first
|
||||
telegram, the guard grades the next quarter, projected from the held
|
||||
reading (the averager credits it there). Not when that reading is too old
|
||||
to be credited (`max_hold_s`).
|
||||
|
||||
**What stays.** SAFETY-25 option A: once every able or draining battery is
|
||||
at full power, the loop still draws a held battery (`allocator._fill()`'s
|
||||
last-resort pass). With house 5500 and 2 x 2500 W, A still gives 2500 W
|
||||
after B is full. Nothing is placed on a battery at its cut-out: OPT-09's
|
||||
pack limit closes it (`hi = 0`), and the drain rise lets B take the rest.
|
||||
|
||||
**Before -> after** (`test_safety91.py`, 2 x 2500 W, lagging battery 10 s):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| B drain 1000, A held, house 4000 | A 2500, B 1000 | A 1500, B 2500 |
|
||||
| same, house 5500 | A 2500, B 1000 | A 2500, B 2500 (option A) |
|
||||
| cycles A asked while B not full | 612 of 900 | 0 |
|
||||
| A at its cut-out, house 5500: B | 1572 W mean | 2500 W, A 0 W |
|
||||
| HOLD, late telegram at the edge | A 0, B 2470 for 1 cycle | A 2050, B 0 |
|
||||
| idle, A held, house 2000 | A 1005, B 1005 | A 0, B 2020 |
|
||||
| N=1, every scenario | | byte-identical |
|
||||
|
||||
**Why N>=2 only.** At N=1 there is no other battery to give first. The edge
|
||||
roll would also fix the N=1 one-cycle dip at a quarter's edge (SAFETY-86
|
||||
rig, "A dropped to 0 W for 1 s"), but this ticket keeps N=1 byte-identical;
|
||||
extending it is a one-line change for a later ticket.
|
||||
|
||||
**Re-pinned.** `test_modes.py` `n2` and `n2_off`: from t=1600 battery A is
|
||||
at its 20 % reserve, so B carries the house and A gives last (100 of 540
|
||||
cycles differ). With SAFETY-87's held rule both are a5dddad's again, and the
|
||||
test grades that.
|
||||
Two fixtures changed with it: `test_safety87.py` part 10's "without the
|
||||
raise" run also turns off `_drain_rise()` (else B rises anyway), and
|
||||
`test_peakguard.py` 10g and `test_watchdog.py`'s two-inverter rig give
|
||||
their second battery a cut-out, as rule 8 requires (an unknown cut-out is held, so A alone would carry the cycle).
|
||||
|
||||
**Not changed.** The peak guard's own rules (SAFETY-87). A drain with
|
||||
nobody held stays a pin (SAFETY-78). An Off battery beside a held one
|
||||
(SAFETY-87 Open). The first cycle after start, before any quarter is
|
||||
measured, can still put the loop's first step on a held battery in a HOLD.
|
||||
SAFETY-88, -89, -90, -92, -93, -94.
|
||||
@@ -0,0 +1,60 @@
|
||||
# SAFETY-93 - the maintenance charge end hands back smoothly
|
||||
|
||||
**Status: implemented 2026-10-02, for review.** Tests: `test_safety93.py`.
|
||||
|
||||
**The defect** (SAFETY-85 rig test, check 4; also on 9358781). At N=2, when
|
||||
A's maintenance charge ended, B went 0 -> -1600 W in one cycle and charged
|
||||
from the grid with the house at 6000 W. The HOLD end was already smooth
|
||||
(SAFETY-85).
|
||||
|
||||
**Cause.** During the charge the site target holds A's charge (SAFETY-84's
|
||||
`charge` ceiling, -2000 W). At the end A gets its HOLD pin (0 W), but the
|
||||
loop starts its next step from last cycle's site target. The slew limit
|
||||
moves the site total by 400 W (-2000 -> -1600), and the only battery left
|
||||
to place it on is B. The same happens when the charge times out or the
|
||||
battery is switched Off (A free, its -2000 W shared out). With the charge
|
||||
suspended by the guard and A giving under the loop, B took A's share in
|
||||
one step (675 W).
|
||||
|
||||
**The fix** (`main.py`, `_reseed_charge_end()`, N>=2 only). On the cycle a
|
||||
battery leaves CHARGE, the loop's start point (`self.target`, and the
|
||||
integrator with it) is what the other batteries stood at plus that
|
||||
battery's new command: its pin (HOLD or abort, 0 W), or, if it is free, its
|
||||
last discharge (a charge stops). The loop then moves from there one slew
|
||||
step a cycle. The ending battery's own step (-2000 -> 0 W) is maintenance's
|
||||
pin, as before; it lowers the grid import. The end is kept until the loop
|
||||
next runs, so a missed poll on that cycle (the input hold) does not lose it.
|
||||
|
||||
Review: a charge also ends, for the site, when the battery is still in
|
||||
CHARGE but can no longer be placed: a stale link or a refusal (SAFETY-73)
|
||||
drops it, and its -2000 W landed on B the same way (B 0 -> -1600 W at a
|
||||
1200 W house). So the end is noted after the unit claims are filed: a
|
||||
battery leaves the charge when it leaves CHARGE, is not live, or its own
|
||||
claims contradict. The guard suspending the charge (SAFETY-84) is no end.
|
||||
|
||||
Why a start-point reseed, and not a per-battery slew in the allocator: the
|
||||
jump comes from one stale number at one edge, the kind of edge SAFETY-73
|
||||
(`_unit_refusal_end`) and OPT-11 P3 (`_reseed_run_edge`) already reseed.
|
||||
The change stays at that edge; nothing else at N>=2 moves.
|
||||
|
||||
**Before -> after** (`test_safety93.py`, 2 x 2500 W, 10 s lag, 2 s meter):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| rig case: house 6000, A charges 2000 to the end | B 0 -> -1600, charges 4 cycles | B 0 -> 400 -> 800 ..., never charges |
|
||||
| house 1200 | B 0 -> -1600 (2800 W from the grid) | B steps of 400, never charges |
|
||||
| charge times out / A switched Off | B 0 -> -1600 | B 0 -> 400 |
|
||||
| guard suspended the charge, A gives 1825 | B step 675 W | B step 395 W |
|
||||
| missed poll on the end cycle | B 0 -> -1600 a cycle later | B 0 -> 400 |
|
||||
| A dropped mid-charge (stale link, refusal), house 1200 | B 0 -> -1600 | B 0 -> 400 |
|
||||
| N=1, both charge ends above | | byte-identical |
|
||||
|
||||
No pinned digest moved: the 46 digests of `tools/*identity*.py` (N=1 and
|
||||
N=2) are byte-identical to f3e409c; none of their scenarios reaches a
|
||||
charge end.
|
||||
|
||||
**Not changed.** The peak guard's floor still moves the site in one step
|
||||
when it engages (a house step, a quarter edge): the guard outranks the slew
|
||||
(owner order). A HOLD pin still drops the holding battery to 0 W at once
|
||||
and SAFETY-86 gives way to the others first. SAFETY-88 (charge flapping
|
||||
above the aim) is untouched.
|
||||
@@ -0,0 +1,102 @@
|
||||
# SAFETY-94 - a battery that lags or reverses is not frozen or refusing
|
||||
|
||||
**Status: implemented 2026-10-03, for review.** Tests: `test_safety94.py`
|
||||
(rig: `tools/safety94_rig.py`). Also closes SAFETY-58. SAFETY-74 stays open.
|
||||
|
||||
## The defects
|
||||
|
||||
1. **Stale-reading gate (SAFETY-37).** Its 35 s ran from the register's last
|
||||
change. After a rest that clock was already past 35 s, so the first read
|
||||
after a new command tripped it, before the register could answer. Live
|
||||
2026-10-03 (0.14.0, N=2): 17:39:16 B and 17:39:25 A (load ramp after a
|
||||
rest), 19:00:04 A (drain start), 21:21:22 B (drain -> charge). Each was
|
||||
1 s after its command; each register answered within 11 s. This is
|
||||
SAFETY-58's shape.
|
||||
2. **Refusal at N=1 (SAFETY-52).** `compute()` engaged after `SETTLE_CYCLES`
|
||||
frozen cycles if the reading was under 100 W at that moment. The frozen
|
||||
cycles include a reversal through the battery's lag. At a 1 s meter the
|
||||
12th cycle comes as a battery reversing out of a charge passes 0 W
|
||||
(SAFETY-85 rig, O1: "gave -20 W for 13 cycles"). The refusal then held
|
||||
0 W, the battery settled at 0 W, never gave 100 W, and waited for the
|
||||
15-minute probe. N>1 already required 22 s in the band, so it was safe
|
||||
at a 10 s lag but not at 20 s.
|
||||
|
||||
## What changed
|
||||
|
||||
- **The gate counts only time the register was asked to move**
|
||||
(`freshness.Freshness._oblige`). A read counts the time since the last
|
||||
read when, at that read, the command is 250 W or more and has moved 250 W
|
||||
from where it stood at the register's last change. A register change
|
||||
(or a SAFETY-57 release) restarts the count. A command flapping in and
|
||||
out of the ask still adds up (frozen register, 0/1500 W every 5 s: trip
|
||||
at 69 s). The conjunction, `MOVE_W` and `RELEASE_S` are unchanged.
|
||||
- **The bound is unchanged at 35 s and now means what its derivation
|
||||
says.** The floor was always "how long a healthy register takes to answer
|
||||
a command": 6 s plant + 10 s publish + 1 s read = 17 s. The band
|
||||
`[2 x 17, 0.5 x 75] = [34, 37.5]` holds. Live answers measured: 1.4, 3,
|
||||
6 and 11 s after the command.
|
||||
- **Refusal evidence is the same at every roster size**
|
||||
(`main.Controller._gave_nothing`, SAFETY-73's count moved into a method).
|
||||
The reading must stay under 100 W, one way, while asked over 200 W, for 12
|
||||
cycles spanning 22 s. At N=1 `compute()` gets this as `gave_nothing` and
|
||||
needs it on top of its own freeze test. `None` (compute alone) keeps the
|
||||
old law.
|
||||
- **The evidence restarts when the reading moves 50 W towards the ask**
|
||||
(`REFUSED_PROGRESS_W`). A refusing battery reads flat (-3/-4 W live). Live
|
||||
readings wander by under 25 W at rest. A first-order lag crossing the band
|
||||
gains 50 W in at most 0.4 tau, so it cannot fill 22 s unless tau > 54 s.
|
||||
This also covers B, whose reading is 100 W below its power.
|
||||
|
||||
## Bounds and worst-case catch
|
||||
|
||||
| fault | before | after | budget |
|
||||
|---|---|---|---|
|
||||
| register frozen, then asked to move | 0-35 s after the ask (often 1 s) | 35 s after the ask (+1 read) | SAFETY-37: 35 s, under the 75/85 s consumer holds |
|
||||
| register frozen at rest reading ~0 W | stale 1 s after the ask | refused or stale, 0 W within 23-35 s | the same |
|
||||
| refusing battery, 1 s meter, N=1 | 11-16 s | 22-28 s | SAFETY-73's 22 s span (+ one 10 s publish) |
|
||||
| refusing battery, 2 s meter | 22-28 s | 22-28 s | unchanged |
|
||||
| refusing battery, 5 s meter | N=1 35-41 s, N=2 41-55 s | N=1 40-45 s, N=2 50-55 s | 12 cycles = 55-60 s |
|
||||
|
||||
Rig: `test_safety94.py` part 3. A release after SAFETY-57 still exposes
|
||||
the held number for one 35 s window of asking, so 35 < 75 < 85 still holds.
|
||||
|
||||
## Before -> after
|
||||
|
||||
- Live replay (part 1): 4 trips -> 0.
|
||||
- Refusing B (its -100 W idle reading): see SAFETY-97.
|
||||
- Rig, 10 s lag, N=1 and N=2, 1/2/5 s meters, two register shapes, 48 runs
|
||||
(part 2): before 27 stale trips and 1 refusal, after 0 and 0.
|
||||
- Reversals, lag 10 and 20 s, 1 s meter (part 2): before N=1 6 stale / 3
|
||||
refused, N=2 6 / 1; after 0 / 0.
|
||||
- 16-day loop capture (`test_freshness.py` part 8): 93 trips -> 59. Trips
|
||||
answered within 20 s: 21 -> 1. Two of the removed trips were not quick:
|
||||
a register held 351 s under a command hovering at the 250 W travel edge
|
||||
(under 35 s of asking before it moved; survivor 2's shape), and a
|
||||
refusing battery the gate had caught only on a passing ramp (SAFETY-52's
|
||||
refusal covers it).
|
||||
|
||||
## SAFETY-58 and SAFETY-74
|
||||
|
||||
- **SAFETY-58 is fixed** by this change: its card asks for the clock to
|
||||
start with the obligation and for the bound to be re-derived. Both are
|
||||
done above. `test_freshness.py` survivor 5 is now graded as closed.
|
||||
- **SAFETY-74 is not fixed.** A battery capped at 300 W with a perfectly
|
||||
flat reading, 5 s meter, house stepping: 50 trips/h at N=1 and 80 at N=2
|
||||
before, 37 and 60 after. With +-1 W dither: 0. A flat reading under a
|
||||
command that moved is exactly what the gate is for; telling "limited"
|
||||
from "frozen" needs a second signal (the meter, or SoC).
|
||||
|
||||
## Known gaps
|
||||
|
||||
- ~~B's standby offset (-95 to -118 W at idle) puts a refusing B outside the
|
||||
100 W band, so a refusing B never latched (review F1: at N=1 new, at N=2
|
||||
as before).~~ **Fixed by SAFETY-97 in the same PR:** the band is centred
|
||||
on each unit's learned idle reading. `SAFETY-97-idle-offset.md`.
|
||||
- A command that asks only part of the time (a fraction d) is caught after
|
||||
35 s of asking, which is 35/d s of wall time (0/1500 W every 5 s: 69 s).
|
||||
SAFETY-30's per-unit check restarts when its command stops opposing the
|
||||
held reading, which a lapsing ask normally does, so 35 < 85 holds there.
|
||||
SAFETY-31's circulation check (75 s) ignores the command, so its margin
|
||||
holds only while d >= 0.47.
|
||||
- A register frozen under a command that stays near the 250 W travel edge
|
||||
is caught late or not at all (survivor 2).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,118 @@
|
||||
# SAFETY-97 - "gives nothing" is graded against each battery's idle reading
|
||||
|
||||
**Status: implemented 2026-10-04, folded into PR #171 (SAFETY-94), for
|
||||
review.** Owner decision 2026-10-04: the band's centre is clamped to
|
||||
+-75 W (see Bounds). Tests: `test_safety97.py` (rig: `tools/safety94_rig.py`).
|
||||
Closes SAFETY-94 review finding F1.
|
||||
|
||||
## The defect
|
||||
|
||||
B's power reading (board B's `inverter_ac_power`, 0x0518) shows -95 to -118 W
|
||||
at idle: its standby draw (the GoodWe app shows 0 W). A's shows -2 to -4 W.
|
||||
Every "the battery gives nothing" test was `|reading| < 100 W`, so a refusing
|
||||
B sat on the band's edge and never latched:
|
||||
|
||||
- N=2: never caught, before or after SAFETY-94.
|
||||
- N=1: the old one-instant check caught it in 17-105 s; SAFETY-94's shared
|
||||
rule (12 cycles in the band in a row) never did (review probe, 1200 s).
|
||||
|
||||
## Design
|
||||
|
||||
- **Offset source: learned per unit** (`app/idle.py`, `Inverter.idle`). No
|
||||
new option: a required one is a setup step every client gets wrong, and an
|
||||
optional one is a second source to reconcile. An override can be added
|
||||
later if a site needs one.
|
||||
- **When it learns:** control runs; the unit is not refused (unit or site),
|
||||
stale or sign-tripped; its command has been within 50 W (`IDLE_ASK_W`) for
|
||||
120 s (`IDLE_QUIET_S`, 6 lags of 20 s). A refusing battery is asked more
|
||||
than 50 W, and while refused it does not learn, so its refusal cannot be
|
||||
learned as idle. A capped battery under a big ask is not sampled either.
|
||||
- **How:** the first value is the mean of 120 s of samples; then it follows
|
||||
drift with a 30 min time constant. A sample beyond 250 W (`IDLE_SAMPLE_W`)
|
||||
is dropped (a register held at a giving value). The band is centred on
|
||||
the value clamped to 75 W (`IDLE_MAX_W`; owner decision below).
|
||||
- **Restart:** saved per unit (`unit/<name>/idle_w`, on first learn and each
|
||||
5 W move) and restored, clamped to 250 W. Without it a battery refusing from the
|
||||
start is never quiet, so never learned, so never caught.
|
||||
- **Until learned the idle reading is 0 W**: the rule as it was.
|
||||
|
||||
## Not chosen: the GoodWe LAN integration's DC battery power
|
||||
|
||||
Both inverters also appear through HA's GoodWe integration (B:
|
||||
`sensor.kelder_dyness_battery_power`, 0 W at idle; found read-only on the
|
||||
live HA 2026-10-04). Pointing `batt_entity` at it removes B's offset by
|
||||
configuration, but:
|
||||
|
||||
- the band must be safe without it: other installs may not have it;
|
||||
- it is polled over UDP on the inverter's Wi-Fi, a second channel that can
|
||||
drop while the board keeps reading (HW-01 lost both once); a missing
|
||||
reading stops the site;
|
||||
- SAFETY-37's 35 s bound is derived from the board's 10 s publish; the
|
||||
integration's poll interval (believed 10 s, not checked here) and latency
|
||||
are not measured;
|
||||
- DC power is not 0x0518, which SAFETY-30/31/42 were measured against.
|
||||
|
||||
It stays an install choice to test on the rig, not a code change here. With
|
||||
it, the learned idle reading is simply ~0 W.
|
||||
|
||||
## Where it applies
|
||||
|
||||
Every test of "gives nothing / gives / takes" now uses `reading - idle`:
|
||||
|
||||
| check | change |
|
||||
|---|---|
|
||||
| shared refusal evidence `_gave_nothing` (N=1 and N>=2) | centred |
|
||||
| N=1 engage and end in `compute()` (`idle_w`, the site sum) | centred |
|
||||
| N=1 end in `_refusal_update` | centred |
|
||||
| N>1 unit end (probe answered, takes the other way) | centred; uncentred, B's idle -100 W read as "takes 100 W" and ended a charge refusal at once |
|
||||
| stale-path refusal (`_refusal_from_stale`, N=1 and N>1) | centred |
|
||||
| SAFETY-42 "parked" (small ask, gives nothing) | centred |
|
||||
| SAFETY-37 stale gate | unchanged: it grades register changes against command travel, not a level |
|
||||
| SAFETY-57 release | unchanged: it reads the command only |
|
||||
| SAFETY-30 per-unit sign | unchanged: opposition needs both over 250 W; B's 100 W idle cannot make or hide one alone |
|
||||
| SAFETY-42 shortfall | unchanged: B reads ~100 W short at every power, which errs towards reporting (report only) |
|
||||
|
||||
## Bounds: owner decision 2026-10-04 (review R1)
|
||||
|
||||
Band: `|reading - centre| < 100 W`, centre = idle clamped to +-75 W.
|
||||
|
||||
- **Why a clamp under the band.** A wrong idle reading (taught during a
|
||||
rest while the reading shows something else, e.g. a grid top-up of a unit
|
||||
held at 0 W, or restored onto a swapped inverter) more than about 100 W off
|
||||
hid every refusal on that battery, at N=1 and N=2; with the first +-250 W
|
||||
clamp the rig missed a refusing A whenever the error was 110-250 W. At
|
||||
+-75 W the band always keeps 0 W +-25 W, so a battery reading about 0 W
|
||||
while refusing is caught whatever was learned or restored, and a wrong idle
|
||||
cannot refuse a healthy 0 W battery (its ask is over 200 W).
|
||||
- **The trade-off.** Options: keep +-250 W (covers idle readings to about
|
||||
+-350 W, any 100 W mis-learn hides refusals); clamp to +-75 W (chosen);
|
||||
"old band or centred band" (rejected: a correct B would need to give 200 W
|
||||
to answer its 200 W probe).
|
||||
- **Cost.** A battery whose idle reading is beyond about +-170 W is centred
|
||||
at most 75 W out, so its refusal falls outside the band: it behaves as in
|
||||
0.14.0 (not caught by this rule). B (-95 to -118 W) reads 20-43 W from its
|
||||
clamped centre. B is caught across -150..+90 W of error in its idle
|
||||
reading; learned 110 W or more too high it is missed, as in 0.14.0 at N=2.
|
||||
- A frozen register is still caught by the stale gate at 35 s of asking.
|
||||
- Recovery from a wrong value needs quiet time: tau 30 min.
|
||||
|
||||
## Catch times (rig, refusing from 200 s after a 400 s rest, +-4 W)
|
||||
|
||||
| refusing | meter 1 s | 2 s | 5 s |
|
||||
|---|---|---|---|
|
||||
| A, N=1 | 28 s (before 28) | 28 (28) | 65 (65) |
|
||||
| A, N=2 | 28 (28) | 28 (28) | 110 (110) |
|
||||
| B, N=1 | 28 (never) | 28 (never) | 65 (never) |
|
||||
| B, N=2 | 24 (never) | 24 (never) | 60 (never) |
|
||||
|
||||
A at N=2 on a 5 s meter is 110 s here because the rig's grid goes flat once
|
||||
A is cut, so the loop cycles on the 15 s floor; the same run on the PR before
|
||||
SAFETY-97 gives 110 s. B restored after a restart, refusing from the start:
|
||||
24 s; with nothing saved: never.
|
||||
|
||||
## Before -> after
|
||||
|
||||
- Healthy B with its offset, SAFETY-94's four scenarios, N=1 and N=2, 1/2/5 s
|
||||
meters, both register shapes, idle learned or restored (96 runs): 0 stale,
|
||||
0 refused. Reversals, lag 10/20 s, idle restored: 0.
|
||||
- Live replay (SAFETY-94 part 1): 0 trips (the gate is untouched).
|
||||
+108
-18
@@ -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. |
|
||||
@@ -577,7 +577,7 @@ accumulator is doing:
|
||||
| `Loop integrator lead` | W | that value minus **the command it was computed against**, i.e. the command the previous cycle left standing |
|
||||
| `Loop reason` | — | the law's own word for this cycle |
|
||||
|
||||
`Loop reason` takes one of ten values:
|
||||
`Loop reason` takes one of eleven values:
|
||||
|
||||
| value | what happened |
|
||||
|---|---|
|
||||
@@ -588,8 +588,9 @@ accumulator is doing:
|
||||
| `slew-limited` | the command could not move as far as the integrator asked for; `slew_w` is what stopped it |
|
||||
| `saturated-freeze` | the inverter is not following, so the command's magnitude may fall but not rise |
|
||||
| `freeze-released` | the freeze had run out of plant-shaped explanations and the command was let move again |
|
||||
| `mode-bound` | the batteries' limits set the command, not the meter: their battery modes, or, with two or more inverters, a battery in its monthly maintenance |
|
||||
| `not-running` | control is stopped **and** shadow mode is off, so the loop did not run |
|
||||
| `battery refuses` | the battery gave less than 100 W for 12 cycles (a minute at a 5-second meter) while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) |
|
||||
| `battery refuses` | the battery gave less than 100 W for 12 cycles and at least 22 s (a minute at a 5-second meter) while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) |
|
||||
| `battery probe` | the add-on asks 200 W to test whether a refusing battery gives power again |
|
||||
|
||||
**When the battery refuses.** At its cut-out level the battery gives nothing,
|
||||
@@ -599,7 +600,15 @@ request, and the `Why` row reads *The battery refuses to give power: 0 W*. Every
|
||||
the battery gives 100 W or more, when the grid needs charging instead, and when
|
||||
control is stopped or started. 200 W is below the 250 W the stale-reading check
|
||||
needs, so a test never trips it. A battery that gives some power, even 100 W,
|
||||
is not refusing and keeps its request.
|
||||
is not refusing and keeps its request. Nor is a battery whose reading is
|
||||
still moving towards the request, such as one turning from charging to
|
||||
giving (SAFETY-94). "Gives" is counted from what the battery's reading shows
|
||||
at rest, which the add-on learns after two quiet minutes and keeps: some
|
||||
inverters show about -100 W at rest (SAFETY-97).
|
||||
While another task sets the power, such as a maintenance charge, the peak
|
||||
guard or a trade, the refusal stays as long as the battery still refuses that
|
||||
task's request. It is logged once, and the 200 W test waits until control sets
|
||||
the power again (SAFETY-107).
|
||||
|
||||
**With two or more inverters** the same rule works per inverter. An inverter
|
||||
whose battery gives less than 100 W for 12 cycles, and at least 22 s, while
|
||||
@@ -613,7 +622,7 @@ If the battery refuses to give power while its level reads above its “Battery
|
||||
cut-out level”, the log adds one warning ending in `REFUSES_ABOVE_CUT_OUT`: check that setting.
|
||||
A battery that refuses is not reported as *not delivering*.
|
||||
|
||||
*For installers:* SAFETY-52 and SAFETY-73. The event log reads
|
||||
*For installers:* SAFETY-52, SAFETY-73 and SAFETY-107. The event log reads
|
||||
`BATTERY REFUSES …` and `BATTERY REFUSES ENDED …` (with the inverter's name at
|
||||
N>1), and the INFO line ends in `BATTERY_REFUSES`.
|
||||
`DECISIONS/SAFETY-52-a-battery-that-refuses.md` has the replay figures and
|
||||
@@ -1638,13 +1647,45 @@ 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_discharge_w` | 2000 | Drain rate (exports the surplus). The peak guard may ask for more (SAFETY-83). 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_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.
|
||||
|
||||
**When the charge counts as done.** A battery may stop at 98 or 99 % while it
|
||||
balances, so it need not reach `maintenance_soc_target`. The charge counts as
|
||||
done when the battery is within 3 points of the target and has taken no more
|
||||
charge for 60 minutes of charging time, or when the charging time (36 hours) runs
|
||||
out within 3 points of the target. Only the minutes the cheap-hours gate let the
|
||||
battery charge count, and the charge ends after 7 days at the
|
||||
latest. Then the usual hold follows.
|
||||
|
||||
**When a cycle fails.** If the battery does not empty in 12 hours, or does not
|
||||
get within 3 points of the target in 36 hours of charging time, the cycle
|
||||
stops, you get the notification *Battery cycle did not finish*, and it is not
|
||||
tried again for 7 days. The status page shows *Maintenance paused* with the
|
||||
date. Starting a cycle by hand ignores the wait. *For installers:*
|
||||
DECISIONS/MAINT-13-full-pack-is-complete.md.
|
||||
|
||||
### Tariff (all optional)
|
||||
|
||||
| option | meaning |
|
||||
@@ -1666,6 +1707,9 @@ others only add power when the house needs more; none charges while it drains
|
||||
(SAFETY-78). Below the drain rate the rest is exported, as with one battery.
|
||||
If the house would still go over the peak limit, the battery being emptied
|
||||
gives more than its drain rate, with one battery too (SAFETY-83).
|
||||
A battery at its peak reserve gives last: the others give their full power
|
||||
first, and the battery being emptied rises above its drain rate before it
|
||||
(SAFETY-91).
|
||||
In a cheap window the battery being filled charges and the others only add
|
||||
charge; none gives while it charges. The charge is cut to what keeps the
|
||||
15-minute peak at the limit minus `peak_margin_w` (SAFETY-84).
|
||||
@@ -1673,6 +1717,14 @@ While a full battery rests after its charge (the hold), it gives just enough
|
||||
to keep the 15-minute peak at that aim if the house needs it; with two
|
||||
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). While that battery settles,
|
||||
an old battery reading does not move them (SAFETY-103).
|
||||
|
||||
#### Capacity-tariff exposure of the grid-following loop
|
||||
|
||||
@@ -1742,10 +1794,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.
|
||||
@@ -1808,7 +1867,9 @@ exactly like the SoC drift the monthly maintenance cycle exists to correct.
|
||||
It will discharge straight past this number and keep going until the pack's own
|
||||
BMS cut-out stops it. What you are promised by `peak_soc_floor: 20` is *"the
|
||||
capacity-peak guard will not spend the pack for money below 20 %"*, and
|
||||
**nothing more**. See below for what that costs on a real evening.
|
||||
**nothing more**. See below for what that costs on a real evening. With two
|
||||
or more batteries, one under it is used last: only once the others give
|
||||
their full power (SAFETY-91).
|
||||
- **When it binds you are told**, once per episode, with notification
|
||||
`goodwe_peak_soc_floor` and a red status-page row reading *Battery at its
|
||||
peak reserve* (`PEAK_GUARD_FLOOR_LIMITED` in the log): the quarter **will**
|
||||
@@ -1953,6 +2014,15 @@ over the rating is logged as a software fault, at `ERROR`, and it has its own
|
||||
notification budget.
|
||||
Why: [`DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md`](DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md).
|
||||
|
||||
The integrator bound counts only on a cycle the loop decided. While
|
||||
maintenance, the peak guard or a safety limit sets a battery to discharge, the
|
||||
same alarm fires only when that battery gives more than `saturation_w` less
|
||||
than the least it was told over the plant's settle time (at least 23 s), for
|
||||
`saturation_cycles` cycles. A charge is not graded: a full battery takes less
|
||||
as it tops up. At two or more inverters the loop does not wind while
|
||||
maintenance holds a battery.
|
||||
Why: [`DECISIONS/SAFETY-85-no-false-not-keeping-up.md`](DECISIONS/SAFETY-85-no-false-not-keeping-up.md).
|
||||
|
||||
### The frozen-meter alarm
|
||||
|
||||
A meter that has died is easy — it stops answering, the sample age climbs past
|
||||
@@ -3122,7 +3192,10 @@ The battery gives less power than the add-on asks for. This is normal for a
|
||||
short time, for example just after a large load switches on or the sun comes
|
||||
out. It is not a fault, and control keeps running. If it keeps coming back,
|
||||
check that the battery is switched on, and that it is not empty (when it should
|
||||
give power) or full (when it should charge).
|
||||
give power) or full (when it should charge). During the monthly maintenance or
|
||||
while the peak limit is guarded, it only shows when the battery gives
|
||||
clearly less than it was told. The status page counts how many times it
|
||||
happened.
|
||||
|
||||
### Power request too high
|
||||
|
||||
@@ -3157,6 +3230,10 @@ and what to do:
|
||||
- **A battery cut-out level is not known.** Fill in “Battery cut-out level”
|
||||
for each inverter in the settings.
|
||||
|
||||
With several inverters, a battery at its peak reserve does not give for the
|
||||
peak and the others do. This alarm then comes only when the others cannot
|
||||
keep the period under the limit alone. Control keeps running meanwhile.
|
||||
|
||||
The peak reserve only applies to keeping the peak down. Normal home use can
|
||||
still draw the battery below it, down to the battery cut-out level, where the
|
||||
battery switches itself off.
|
||||
@@ -3192,6 +3269,14 @@ reading is stuck. Control keeps running and keeps following the reading, so a
|
||||
stuck reading can raise this month's peak. Check the meter. If it is stuck,
|
||||
stop control on the add-on page.
|
||||
|
||||
### Battery cycle did not finish
|
||||
|
||||
The monthly battery cycle stopped because the battery did not empty, or did not
|
||||
fill up, in time. The battery is back under normal control. The add-on waits 7
|
||||
days before it tries again, so it does not empty the battery every night. To
|
||||
try sooner, start a cycle on the add-on page. If it keeps happening, check that
|
||||
the battery follows its commands and that the cheap hours leave time to charge.
|
||||
|
||||
### For installers: notifications
|
||||
|
||||
This part is for installers. The texts live in `app/messages.py` and follow
|
||||
@@ -3211,6 +3296,7 @@ default `info` hides it.
|
||||
| Inverters working against each other | alarm | `goodwe_circulating_power` | SAFETY-31 |
|
||||
| *name* is not delivering | alarm | `goodwe_not_delivering_<name>` | SAFETY-42 |
|
||||
| Meter reading may be stuck | alarm | `goodwe_meter_frozen` | SAFETY-18 |
|
||||
| Battery cycle did not finish | warning | `goodwe_maint_failed_<name>` | MAINT-13 |
|
||||
|
||||
**The Details link.** Home Assistant 2026.2 and newer show add-on pages at
|
||||
`/config/app/<slug>/documentation`; 2026.5 removed the old
|
||||
@@ -3223,8 +3309,9 @@ section without a link. The documentation tab adds no heading anchors, so the
|
||||
link cannot jump to the section.
|
||||
|
||||
**Battery is not keeping up.** The grid-following loop's integrator reached its
|
||||
anti-windup bound (`integrator_max_w`, SAFETY-40). No command went over the
|
||||
rating. The log line is a WARNING with reason code `OUTPUT_CLAMP`. See *The
|
||||
anti-windup bound (`integrator_max_w`, SAFETY-40), or a battery that another
|
||||
owner set to discharge gave less than it was told (SAFETY-85). No command went
|
||||
over the rating. The log line is a WARNING with reason code `OUTPUT_CLAMP`. See *The
|
||||
winding-loop alarm*.
|
||||
|
||||
**Power request too high.** A computed command above a limit was cut before it
|
||||
@@ -3252,7 +3339,9 @@ state of charge is unread, or when an inverter's `bms_soc_floor` (label
|
||||
“Battery cut-out level”) is missing, because the reserve is graded against it.
|
||||
Charging from the grid stays blocked meanwhile. With several inverters the
|
||||
notification names the battery at its limit (SAFETY-32), each graded against
|
||||
its own cut-out level. ⚠️ **The reserve binds the peak guard only
|
||||
its own cut-out level. That battery is held (weight 0 while the site gives)
|
||||
and the guard's need goes to the others, up to their rating; the alarm fires
|
||||
only if they miss the limit at full power for `ALARM_HOLD_S` (SAFETY-87). ⚠️ **The reserve binds the peak guard only
|
||||
(SAFETY-25).** Self-consumption has no such floor. See *What `peak_soc_floor`
|
||||
does not protect*.
|
||||
|
||||
@@ -3390,7 +3479,7 @@ for a new install*.
|
||||
| Meter reading may be stuck | See *Meter reading may be stuck* under Notifications. |
|
||||
| Month peak | This month's highest 15-minute average, when it happened, and how close it is to your peak limit. Amber from 80 %. |
|
||||
| 15-minute periods measured this month | How many 15-minute periods the add-on has measured and saved this month. |
|
||||
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. *Idle, heading for …* is where this 15-minute period is heading. *Waiting for meter readings* after a restart means the battery does not charge until the add-on can measure the period again. |
|
||||
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. *Idle, heading for …* is where this 15-minute period is heading. *Waiting for meter readings* after a restart means the battery does not charge until the add-on can measure the period again. A red peak row is an alarm while control keeps running, so the banner shows its title without *Not ready*. |
|
||||
| Battery level, Battery power, Grid power | The reading of that entity. *No usable reading* means the entity is missing, or its reading is too old. Check it in Developer Tools before replacing it. |
|
||||
| Inverter control | Whether the inverter can be controlled, and how much it can charge and give. |
|
||||
| *name*: battery power reading stopped changing | The reading did not change while the add-on asked for a change. It may be stuck, or the battery may be refusing. The add-on sets the inverters to 0 W until it changes. |
|
||||
@@ -3430,7 +3519,8 @@ reason code, and its full figures are on the DEBUG line right after it and in
|
||||
…`, and the peak guard's full reason is on the DEBUG line after its ERROR.
|
||||
- ***name*: battery power reading stopped changing** is SAFETY-37/56/57. The
|
||||
ERROR line says when it clears and names `STALE_READING`. The full reason,
|
||||
with both possible causes, is on the DEBUG line after it.
|
||||
with both possible causes, is on the DEBUG line after it. The 35 s counts
|
||||
only while the command asks the reading to move (SAFETY-94).
|
||||
- **Meter: no reading for …** (red): the add-on logs one WARNING when the
|
||||
meter goes quiet, "No meter reading for …", and an INFO line when readings
|
||||
return, at most once every 10 minutes. Past `stale_input_s` the **Why**
|
||||
|
||||
@@ -635,7 +635,7 @@ def site_resolve(claims: list) -> Resolution:
|
||||
|
||||
|
||||
def resolve(claims: list, *, units=None, weights=None, live=None,
|
||||
tick=None) -> Resolution:
|
||||
tick=None, reserve=()) -> Resolution:
|
||||
"""The three stages, in order, over one cycle's claims.
|
||||
|
||||
`units` is the site's roster of inverters. Left None it is derived from the
|
||||
@@ -737,6 +737,13 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
|
||||
gate, held = _unit_modes(out, site_claims, bounds, live)
|
||||
if callable(weights):
|
||||
weights = weights(out.target_w)
|
||||
# SAFETY-87/91: units under their peak reserve weigh 0 while the site
|
||||
# gives, so the others carry the target first. An Off battery gives the
|
||||
# peak's shortfall, so it goes before them when no other unit is left.
|
||||
reserve = set(reserve) if out.target_w > 0.0 else set()
|
||||
if held and reserve and not set(bounds) - held - reserve:
|
||||
held = set()
|
||||
held = held | reserve
|
||||
if held:
|
||||
weights = {u: (0.0 if u in held else
|
||||
(1.0 if weights is None else weights.get(u, 1.0)))
|
||||
|
||||
@@ -260,6 +260,22 @@ def loop_clamp_note(d) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def shortfall_note(named) -> str:
|
||||
"""SAFETY-85: the alarm text while another owner sets the batteries.
|
||||
`named` is `[(inverter name or None, asked W, given W)]`, one per battery
|
||||
that gave less than asked."""
|
||||
return f"{REASON_CODE}: " + ", ".join(
|
||||
f"{name or 'the battery'} gives {given:+.0f} W of the {asked:+.0f} W "
|
||||
f"asked" for name, asked, given in named)
|
||||
|
||||
|
||||
def delivery_short(asked_w, given_w, margin_w) -> bool:
|
||||
"""SAFETY-85: did a battery asked to discharge give more than `margin_w`
|
||||
less? A charge is never short: a full pack tapers (SAFETY-42 section 7)."""
|
||||
asked, given = float(asked_w), float(given_w)
|
||||
return asked > 0.0 and asked - given > abs(float(margin_w))
|
||||
|
||||
|
||||
def write_clamp_note(named) -> str:
|
||||
"""The write-side alarm text: `named` is `[(inverter name or None,
|
||||
ClampResult)]` for every inverter whose write was cut back this cycle.
|
||||
@@ -382,6 +398,10 @@ class ClampWatchdog:
|
||||
self.over_rating = 0 # bug=True: "Power request too high"
|
||||
self.bound = 0 # bug=False: "Battery is not keeping up"
|
||||
self.episodes = 0 # rising edges
|
||||
# SAFETY-85: episodes with a bug=False engagement. The status row
|
||||
# counts these; counting cycles read 861 on one rig run.
|
||||
self.bound_episodes = 0
|
||||
self._bound_counted = False
|
||||
self.since: datetime | None = None
|
||||
self.last_alarm: datetime | None = None
|
||||
self.reason = ""
|
||||
@@ -436,6 +456,10 @@ class ClampWatchdog:
|
||||
self.spoken = False
|
||||
self.bug = False
|
||||
self.bug_spoken = False
|
||||
self._bound_counted = False
|
||||
if not bug and not self._bound_counted:
|
||||
self._bound_counted = True
|
||||
self.bound_episodes += 1
|
||||
if bug or not self.bug:
|
||||
self.reason = reason
|
||||
self.facts = tuple(facts)
|
||||
|
||||
@@ -382,6 +382,9 @@ class Decision:
|
||||
# SAFETY-52. The refused direction to carry back in (+1 discharge, -1
|
||||
# charge, 0 none), or None when the caller does not carry it.
|
||||
refused: int | None = None
|
||||
# SAFETY-85. The `bound` held the integrator or the command this cycle,
|
||||
# so the batteries' limits, not the loop, decided it.
|
||||
held: bool = False
|
||||
|
||||
|
||||
def slew_limit_w(tuning: Tuning) -> float:
|
||||
@@ -481,6 +484,8 @@ def compute(
|
||||
refused: int | None = None,
|
||||
probe: bool = False,
|
||||
bound: tuple | None = None,
|
||||
gave_nothing: bool | None = None,
|
||||
idle_w: float = 0.0,
|
||||
) -> Decision:
|
||||
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
|
||||
|
||||
@@ -494,6 +499,13 @@ def compute(
|
||||
`bound` OPT-11: `(lo, hi)`, what the batteries may place this tick with
|
||||
their modes applied (`allocator.loop_bound()`), or None. See
|
||||
MODE_BOUND below. None is the law as it was.
|
||||
`gave_nothing` SAFETY-94: the caller's span check - the reading stayed
|
||||
under REFUSED_BELOW_W, one way, for SETTLE_CYCLES spanning
|
||||
REFUSED_SPAN_S. A refusal engages only with it. None leaves it
|
||||
out (compute() alone, as before).
|
||||
`idle_w` SAFETY-97: what `actual_w` reads while the batteries give
|
||||
nothing (app/idle.py). The refusal's band and its end are
|
||||
centred on it. 0 is the law as it was.
|
||||
"""
|
||||
reason = "tracking"
|
||||
# OPT-11: a bound that leaves out 0 W would FORCE a direction, which no
|
||||
@@ -614,11 +626,13 @@ def compute(
|
||||
# OPT-11: the integrator does not wind past what the modes let the
|
||||
# batteries place. Only a side tighter than the rail is left in `bound`
|
||||
# (above), so this never takes SAFETY-03's `i_clamped` away from the rail.
|
||||
held_by_bound = False
|
||||
if bound is not None:
|
||||
held = max(float(bound[0]), min(float(bound[1]), i_w))
|
||||
if held != i_w:
|
||||
i_w = held
|
||||
reason = MODE_BOUND
|
||||
held_by_bound = True
|
||||
|
||||
# ⚠️ Applied EVERY cycle, frozen or not: the freeze is conditional, this
|
||||
# bound is not. It is what makes the worst-case unwind time finite and
|
||||
@@ -812,13 +826,18 @@ def compute(
|
||||
# `saturation_w` is a refusal, not a slow plant. The drop to 0 skips the slew
|
||||
# limit because it only removes an ask the battery is not meeting.
|
||||
if refused is not None:
|
||||
# SAFETY-94: and only when the reading has given nothing for the
|
||||
# whole span, not just now. `sat_count` also counts the cycles a
|
||||
# healthy battery spends reversing through its lag (-2000 W towards
|
||||
# +800 W): at a 1 s meter its 12 cycles end as the reading passes 0.
|
||||
if (refused == 0 and frozen and sat_count >= SETTLE_CYCLES
|
||||
and abs(prev_w) > tuning.saturation_w
|
||||
and abs(actual_w) < REFUSED_BELOW_W):
|
||||
and abs(actual_w - idle_w) < REFUSED_BELOW_W
|
||||
and gave_nothing is not False):
|
||||
refused = 1 if prev_w > 0 else -1
|
||||
if refused and refused * i_w < 0:
|
||||
refused = 0 # the loop wants the other way
|
||||
elif refused and refused * actual_w >= PROBE_ANSWER_W:
|
||||
elif refused and refused * (actual_w - idle_w) >= PROBE_ANSWER_W:
|
||||
refused = 0 # it delivers: a probe answered,
|
||||
# or a slow reading arriving
|
||||
elif refused:
|
||||
@@ -839,6 +858,7 @@ def compute(
|
||||
if inside != target:
|
||||
target = inside
|
||||
reason = MODE_BOUND
|
||||
held_by_bound = True
|
||||
# SAFETY-48's lead cap above may pull the integrator back toward the
|
||||
# slewed command; what is CARRIED stays inside the bound too.
|
||||
i_w = max(float(bound[0]), min(float(bound[1]), i_w))
|
||||
@@ -860,7 +880,7 @@ def compute(
|
||||
|
||||
return Decision(float(target), sat_count, frozen, reason, float(i_w),
|
||||
i_clamped, clamped, float(i_bound_w), float(want),
|
||||
float(clamped_w), refused)
|
||||
float(clamped_w), refused, held_by_bound)
|
||||
|
||||
|
||||
def maintenance_charge_floor(
|
||||
|
||||
@@ -87,6 +87,10 @@ when it has not moved THROUGH A CHANGE IT WAS OBLIGED TO FOLLOW:
|
||||
AND |standing command now - standing command when the reading
|
||||
last changed| >= MOVE_W
|
||||
|
||||
SAFETY-94: "unchanged for" counts only the reads at which both command terms
|
||||
held, so a register that rested is not judged before it could answer.
|
||||
DECISIONS/SAFETY-94-lagging-battery.md.
|
||||
|
||||
0x0518 is the inverter reporting ITSELF and it tracks its own command: r = 0.998
|
||||
with battery power against r = 0.09 with the real meter (goodwe/NOTES.md §3.3),
|
||||
command and actual agreeing within 4 W once settled (§5.10, 2026-08-22), and the
|
||||
@@ -216,6 +220,8 @@ change - which was the ONLY exit - has a median of 143 s, 17 episodes at or
|
||||
over the live one's 710 s, and a worst case of 21440 s. That distribution is a
|
||||
property of this battery's firmware. It is not a property of this code, and the
|
||||
add-on must not depend on it.
|
||||
SAFETY-94: many were the gate judging a rested register on its first read
|
||||
(SAFETY-58). Counted from the ask, the same days give 59 trips.
|
||||
|
||||
THE RULE, and it is SAFETY-13's shape one module over - "which latches get an
|
||||
exit, not how long the block lasts":
|
||||
@@ -372,7 +378,9 @@ BOUNDS = {
|
||||
"SAFETY-57: the verdict is withdrawn after 17.0 s of the "
|
||||
"standing command below 250 W, because at 0 W the register "
|
||||
"is obliged to nothing and the verdict cannot be tested "
|
||||
"again from inside the state it creates"),
|
||||
"again from inside the state it creates. SAFETY-94: the "
|
||||
"35 s counts only reads at which the command asks the "
|
||||
"register to move"),
|
||||
"soc": Bound(
|
||||
signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)",
|
||||
unchanged_s=float("inf"),
|
||||
@@ -416,11 +424,9 @@ class Freshness:
|
||||
new grading window at that instant. The 1 Hz oscillation the latch exists to
|
||||
prevent is still prevented: the new window is `bound.unchanged_s` wide, so
|
||||
no re-trip can land sooner than 35 s after a release, rather than on the
|
||||
next read. ⚠️ MEASURED FROM THE RELEASE, NOT FROM THE COMMAND RESUMING - if
|
||||
the site rests for longer than the window before it asks for anything, the
|
||||
window is already old and the re-trip lands on the first read at which the
|
||||
command travels 250 W again. That is survivor 5's shape, it predates this
|
||||
card, and its cost is one `stale_input_s` hold.
|
||||
next read. SAFETY-94: the 35 s counts only while the command asks the
|
||||
register to move, so a site that rests after a release is not re-tripped
|
||||
on its first ask.
|
||||
|
||||
⚠️ TWO CLOCKS, AND THE DISTINCTION IS THE WHOLE OF SAFETY-57'S HONESTY.
|
||||
`changed_mono` / `held` are facts about the REGISTER: when it last carried a
|
||||
@@ -451,6 +457,11 @@ class Freshness:
|
||||
# the change-stamps; `_release()` is where they diverge.
|
||||
self.graded_from = None
|
||||
self.standing_at_grade = None
|
||||
# SAFETY-94. Seconds of this window during which the register was
|
||||
# obliged to move (the command at least `move_w` and `move_w` away
|
||||
# from `standing_at_grade`), and the instant of the last read.
|
||||
self.obliged_acc = 0.0
|
||||
self.last_read = None
|
||||
# The instant the standing command last FELL below `move_w`, or None
|
||||
# while an obligation is standing. The release timer runs off this.
|
||||
self.rested_since = None
|
||||
@@ -499,6 +510,7 @@ class Freshness:
|
||||
# it reads in the order the state machine moves - withdraw, then
|
||||
# re-grade - not because the code depends on it.
|
||||
self._release(standing_w, now_mono)
|
||||
self._oblige(standing_w, now_mono)
|
||||
self._grade(standing_w, now_mono)
|
||||
|
||||
def _open_window(self, standing_w, now_mono) -> None:
|
||||
@@ -510,6 +522,35 @@ class Freshness:
|
||||
self.graded_from = float(now_mono)
|
||||
self.standing_at_grade = (None if standing_w is None
|
||||
else float(standing_w))
|
||||
# The read that opens a window is never an ask (no travel yet).
|
||||
self.obliged_acc = 0.0
|
||||
|
||||
def _obliged(self, standing_w) -> bool:
|
||||
"""Is the register asked to move: the command is at least `move_w` and
|
||||
has travelled `move_w` since the window opened?"""
|
||||
if standing_w is None or self.standing_at_grade is None:
|
||||
return False
|
||||
standing_w = float(standing_w)
|
||||
return (abs(standing_w) >= self.bound.move_w
|
||||
and abs(standing_w - self.standing_at_grade)
|
||||
>= self.bound.move_w)
|
||||
|
||||
def _oblige(self, standing_w, now_mono) -> None:
|
||||
"""SAFETY-94. Count only the time the register was asked to move.
|
||||
|
||||
A register that held still while nothing asked it to move has not
|
||||
failed, so time before the command moved is not evidence. The time
|
||||
since the previous read counts when the obligation stands at this
|
||||
read; a command flapping in and out of the obligation still adds up.
|
||||
"""
|
||||
now_mono = float(now_mono)
|
||||
if self.last_read is not None and self._obliged(standing_w):
|
||||
self.obliged_acc += max(0.0, now_mono - self.last_read)
|
||||
self.last_read = now_mono
|
||||
|
||||
def obliged_s(self) -> float:
|
||||
"""Seconds of the current window the register was obliged to move."""
|
||||
return self.obliged_acc
|
||||
|
||||
def _release(self, standing_w, now_mono) -> None:
|
||||
"""SAFETY-57. Withdraw a verdict whose obligation is no longer standing.
|
||||
@@ -543,7 +584,12 @@ class Freshness:
|
||||
if self.stale or self.graded_from is None:
|
||||
return
|
||||
held_s = self.graded_s(now_mono)
|
||||
if held_s < self.bound.unchanged_s:
|
||||
# SAFETY-94 (closes SAFETY-58): the bound counts only the time the
|
||||
# command asked the register to move, not the time since it last
|
||||
# changed. A register that rested for minutes was otherwise judged on
|
||||
# the first read after a new command, before it could answer (live,
|
||||
# 2026-10-03: 1 s after the command).
|
||||
if self.obliged_s() < self.bound.unchanged_s:
|
||||
return
|
||||
# ⚠️ BOTH None GUARDS ARE FOR A CALLER THAT DOES NOT KNOW ITS OWN
|
||||
# OBLIGATION YET, WHICH IS NOT `_read_unit`. `Inverter.standing_w()`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -923,24 +924,25 @@ def state_of(c, level, shadow):
|
||||
|
||||
|
||||
def build(c, *, level: str, checks: list, now: datetime | None = None,
|
||||
lang: str = "en") -> dict:
|
||||
lang: str = "en", held: bool | None = None) -> dict:
|
||||
"""The structured half of `/status`, in `lang` (UX-13 P5). Pure reads of
|
||||
the controller."""
|
||||
_LANG[0] = uitext.lang_of(lang)
|
||||
try:
|
||||
return _build(c, level=level, checks=checks, now=now)
|
||||
return _build(c, level=level, checks=checks, now=now, held=held)
|
||||
finally:
|
||||
_LANG[0] = "en"
|
||||
|
||||
|
||||
def _build(c, *, level, checks, now):
|
||||
def _build(c, *, level, checks, now, held=None):
|
||||
now = now or datetime.now(timezone.utc)
|
||||
shadow = bool(c.shadow)
|
||||
kind = state_of(c, level, shadow)
|
||||
# A red banner means every inverter is at 0 W (the status cascade), so
|
||||
# nothing is planned until it clears. Shadow mode still plans: it is
|
||||
# what the add-on would do.
|
||||
held = level == "bad" and not shadow
|
||||
# what the add-on would do. `held` False: an alarm while control runs
|
||||
# (SAFETY-87, the peak alarms).
|
||||
held = level == "bad" and not shadow and held is not False
|
||||
running = (bool(c.auto) or shadow) and not held
|
||||
settled = all(at_zero(inv) for inv in c.inverters)
|
||||
ctl = next((r for r in checks if r.get("id") == "controller"), None)
|
||||
@@ -1293,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
|
||||
@@ -1368,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)),
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""SAFETY-97: what a battery's power reading shows while it gives nothing.
|
||||
|
||||
B's reading (0x0518) shows -95 to -118 W at idle (standby draw, live
|
||||
2026-10-03); A's shows -2 to -4 W. "The battery gives nothing" is graded
|
||||
against this idle reading, not against 0 W, or a refusing B never latches.
|
||||
DECISIONS/SAFETY-97-idle-offset.md.
|
||||
|
||||
Learned per unit, never configured:
|
||||
- only while control runs, the unit is not refused, stale or tripped, and
|
||||
its command has been within IDLE_ASK_W for IDLE_QUIET_S (a refusing battery
|
||||
is asked more than that, so it cannot teach its refusal as idle);
|
||||
- a reading beyond IDLE_SAMPLE_W is not a sample;
|
||||
- first value: the mean of IDLE_FIRST_S of samples; then a slow follow
|
||||
(IDLE_TAU_S);
|
||||
- persisted per unit and restored, clamped to IDLE_SAMPLE_W, at start.
|
||||
The band is centred on it clamped to IDLE_MAX_W (owner, 2026-10-04). Until
|
||||
learned the idle reading is 0 W, the rule as it was.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
IDLE_MAX_W = 75.0 # |idle| used: under the 100 W band, so it keeps 0 W +-25 W
|
||||
IDLE_SAMPLE_W = 250.0 # a reading beyond this is not a sample
|
||||
IDLE_ASK_W = 50.0 # a command this small counts as "asked ~0"
|
||||
IDLE_QUIET_S = 120.0 # 6 lags of 20 s: a 2500 W step has decayed to 6 W
|
||||
IDLE_FIRST_S = 120.0 # seconds of samples averaged for the first value
|
||||
IDLE_TAU_S = 1800.0 # then follow drift this slowly
|
||||
IDLE_SAVE_W = 5.0 # persist when the value moved this far
|
||||
IDLE_GAP_S = 10.0 # a longer gap between reads counts as this
|
||||
|
||||
|
||||
def _num(v):
|
||||
try:
|
||||
v = float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return v if math.isfinite(v) else None
|
||||
|
||||
|
||||
class IdleReading:
|
||||
"""One unit's idle reading. `w` is what "gives nothing" is centred on."""
|
||||
|
||||
def __init__(self, saved=None):
|
||||
v = _num(saved)
|
||||
self.value = (None if v is None
|
||||
else max(-IDLE_SAMPLE_W, min(IDLE_SAMPLE_W, v)))
|
||||
self.saved = self.value
|
||||
self.quiet_since = None
|
||||
self.last = None
|
||||
self.acc_w = 0.0
|
||||
self.acc_s = 0.0
|
||||
|
||||
@property
|
||||
def w(self) -> float:
|
||||
"""The centre used: the learned value clamped to IDLE_MAX_W."""
|
||||
if self.value is None:
|
||||
return 0.0
|
||||
return max(-IDLE_MAX_W, min(IDLE_MAX_W, self.value))
|
||||
|
||||
def observe(self, reading, standing_w, now_mono, may_learn) -> bool:
|
||||
"""One read. Returns True when the value should be persisted."""
|
||||
now = float(now_mono)
|
||||
dt = (0.0 if self.last is None
|
||||
else max(0.0, min(IDLE_GAP_S, now - self.last)))
|
||||
self.last = now
|
||||
standing = _num(standing_w)
|
||||
if not may_learn or standing is None or abs(standing) > IDLE_ASK_W:
|
||||
self.quiet_since = None
|
||||
return False
|
||||
if self.quiet_since is None:
|
||||
self.quiet_since = now
|
||||
b = _num(reading)
|
||||
if (now - self.quiet_since < IDLE_QUIET_S or b is None
|
||||
or abs(b) > IDLE_SAMPLE_W or dt <= 0.0):
|
||||
return False
|
||||
if self.value is None:
|
||||
self.acc_w += b * dt
|
||||
self.acc_s += dt
|
||||
if self.acc_s < IDLE_FIRST_S:
|
||||
return False
|
||||
self.value = self.acc_w / self.acc_s
|
||||
else:
|
||||
self.value += (b - self.value) * min(1.0, dt / IDLE_TAU_S)
|
||||
if self.saved is None or abs(self.value - self.saved) >= IDLE_SAVE_W:
|
||||
self.saved = self.value
|
||||
return True
|
||||
return False
|
||||
@@ -127,6 +127,7 @@ from . import messages
|
||||
# SAFETY-37. The freshness policy, and the ONE place its bounds are written.
|
||||
# freshness.py imports nothing from this package, so the direction is one-way.
|
||||
from .freshness import BOUNDS, Freshness
|
||||
from .idle import IdleReading
|
||||
# CFG-03's ONE in-code mirror of config.yaml's SAFETY-24 defaults. Imported
|
||||
# rather than retyped: the flat-keys path below needs `bms_soc_floor`'s
|
||||
# shipped default and a literal here would be the fourth defaults site that
|
||||
@@ -321,13 +322,21 @@ class Inverter:
|
||||
# take, 0 none), when that refusal or its last probe started, the
|
||||
# current probe's start, and the cycles of evidence so far and when
|
||||
# they began. Memory only, like SAFETY-52's: stop and start end it.
|
||||
# Used at N>1 only.
|
||||
# The evidence count is also used at N=1 (SAFETY-94); the rest at N>1.
|
||||
self.refused = 0
|
||||
self.refused_at = None
|
||||
self.probe_from = None
|
||||
self.ref_count = 0
|
||||
self.ref_since = None
|
||||
self.ref_sign = 0
|
||||
self.ref_from = None
|
||||
# SAFETY-97: what this unit's reading shows at idle; "gives nothing"
|
||||
# is centred on it. Learned, persisted, restored (app/idle.py).
|
||||
self.idle = IdleReading(data.get(self.key("idle_w")))
|
||||
|
||||
@property
|
||||
def idle_w(self) -> float:
|
||||
return self.idle.w
|
||||
|
||||
# -- store ---------------------------------------------------------------
|
||||
def key(self, name: str) -> str:
|
||||
|
||||
+842
-75
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -65,10 +65,30 @@ POWER_MOVING = (DRAIN, CHARGE, HOLD)
|
||||
# 12 h means the battery is not following commands - a fault, not a slow day. The charge phase deliberately waits for sun and for cheap hours, so
|
||||
# it is expected to span a night; 36 h still bounds it.
|
||||
DRAIN_TIMEOUT_MIN = 720
|
||||
# MAINT-13: minutes the pack was ALLOWED to charge (the price gate open), not
|
||||
# wall-clock minutes since the charge began. See CHARGE_WALL_TIMEOUT_MIN.
|
||||
CHARGE_TIMEOUT_MIN = 2160
|
||||
# After an abort, do not immediately re-trigger in the same start hour.
|
||||
RESTART_COOLDOWN_MIN = 90
|
||||
|
||||
# MAINT-13 (DECISIONS/MAINT-13-full-pack-is-complete.md). Constants, not options.
|
||||
# A pack that reaches target - 3 and then takes no more charge for this many
|
||||
# minutes of allowed charging counts as full.
|
||||
CHARGE_NEAR_FULL_PCT = 3
|
||||
CHARGE_STALL_MIN = 60
|
||||
# Wall-clock backstop for a charge whose gate never opens (no cheap hours, no sun).
|
||||
CHARGE_WALL_TIMEOUT_MIN = 10080
|
||||
# A failed cycle (stuck drain or charge) is not retried before this many days.
|
||||
FAIL_BACKOFF_DAYS = 7
|
||||
# One tick counts at most this many minutes: a gap means the add-on was down.
|
||||
CHARGE_TICK_MAX_MIN = 5
|
||||
# The charge accounting is written to the state file at most this often.
|
||||
CHARGE_SAVE_MIN = 5
|
||||
# MAINT-14 (DECISIONS/MAINT-14-no-soc-in-maintenance.md): with no SoC reading
|
||||
# the timeouts still run, a drain holds at 0 W, and the log says once, after this
|
||||
# many minutes, that the level has been unknown for long.
|
||||
NOSOC_NOTE_MIN = 15
|
||||
|
||||
# ⚠️ CFG-03. THE ONE IN-CODE MIRROR OF config.yaml FOR EVERY `maintenance_*`
|
||||
# OPTION, and the only place in the add-on where one of these numbers is typed.
|
||||
#
|
||||
@@ -112,7 +132,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 +199,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
|
||||
@@ -192,6 +277,9 @@ class MaintResult:
|
||||
# showing an idle machine and a due date in the past. Empty on every path
|
||||
# that is not deferring, so a roster of one never sees it.
|
||||
deferred: str = ""
|
||||
# MAINT-13: "drain" or "charge" on the one tick where a cycle FAILED, so
|
||||
# the Controller can tell the operator once. Empty on every other tick.
|
||||
failed: str = ""
|
||||
|
||||
|
||||
class UnitHistory:
|
||||
@@ -256,8 +344,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
|
||||
@@ -275,6 +367,14 @@ class Maintenance:
|
||||
# per cycle would flush the very context that explains it. Same pattern
|
||||
# as `Controller._arb_action` and `_maint_shape_suspended`.
|
||||
self._deferred = ""
|
||||
# MAINT-13: the charge accounting (see _charge_account), in memory and
|
||||
# written to the state file every CHARGE_SAVE_MIN minutes.
|
||||
self._trk = None
|
||||
self._trk_saved = None
|
||||
# MAINT-14: when the SoC reading went missing (None while it is there),
|
||||
# and whether the "unknown for long" line was said in this episode.
|
||||
self._nosoc_since = None
|
||||
self._nosoc_said = False
|
||||
|
||||
# -- state ---------------------------------------------------------------
|
||||
@property
|
||||
@@ -282,6 +382,11 @@ class Maintenance:
|
||||
return self.store.get("phase", IDLE)
|
||||
|
||||
def _enter(self, phase: str, now: datetime, events: list, msg: str) -> None:
|
||||
self._nosoc_since = None
|
||||
self._trk = None
|
||||
self._trk_saved = None
|
||||
if self.store.get("charge_track") is not None:
|
||||
self.store.set("charge_track", None)
|
||||
self.store.set("phase", phase)
|
||||
self.store.set_time("phase_started", now)
|
||||
_LOG.info("%s", msg)
|
||||
@@ -322,6 +427,21 @@ class Maintenance:
|
||||
return (last_try is None
|
||||
or (now - last_try) >= timedelta(minutes=RESTART_COOLDOWN_MIN))
|
||||
|
||||
def backoff_until(self, now: datetime):
|
||||
"""MAINT-13: when a failed cycle may be tried again, or None.
|
||||
|
||||
None when nothing failed, when the back-off is over, or when a cycle
|
||||
completed after the failure (a forced start that worked ends it).
|
||||
"""
|
||||
failed = self.store.get_time("last_failed")
|
||||
if failed is None:
|
||||
return None
|
||||
done = self.store.get_time("last_completed")
|
||||
if done is not None and done >= failed:
|
||||
return None
|
||||
until = failed + timedelta(days=FAIL_BACKOFF_DAYS)
|
||||
return until if now < until else None
|
||||
|
||||
def wants_start(self, now: datetime) -> bool:
|
||||
"""Would this pack begin a cycle on this tick if the site let it?
|
||||
|
||||
@@ -331,8 +451,10 @@ 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 self._cooled(now))
|
||||
and in_start_slot(now, self.cfg.start_hour, self.tz)
|
||||
and self.due(now)
|
||||
and self._cooled(now)
|
||||
and self.backoff_until(now) is None)
|
||||
|
||||
# -- operations ----------------------------------------------------------
|
||||
def force_start(self, now: datetime, blocked_by: str = "") -> list:
|
||||
@@ -360,6 +482,140 @@ class Maintenance:
|
||||
self._enter(DRAIN, now, events, "maintenance: forced start -> drain")
|
||||
return events
|
||||
|
||||
def _charge_account(self, now: datetime, soc: float | None,
|
||||
gate_open: bool) -> dict:
|
||||
"""MAINT-13: what this charge has used so far, as minutes of ALLOWED time.
|
||||
|
||||
`open_min` counts only ticks with the price gate open; `flat_min` counts
|
||||
gate-open minutes since the SoC last beat its best; a closed gate pauses
|
||||
both. One tick adds at most CHARGE_TICK_MAX_MIN, so a gap (add-on down)
|
||||
counts for nothing. A state file from before MAINT-13, or from another
|
||||
charge, starts from the wall-clock minutes so far: the old behaviour.
|
||||
|
||||
MAINT-14: `soc` may be None (no reading). Time still counts, the stall
|
||||
wait pauses, and `last` keeps the last SoC seen (saved with the rest).
|
||||
"""
|
||||
since = self.store.get("phase_started")
|
||||
trk = self._trk
|
||||
if trk is None:
|
||||
raw = self.store.get("charge_track")
|
||||
if isinstance(raw, dict) and raw.get("since") == since:
|
||||
trk = dict(raw)
|
||||
else:
|
||||
trk = {"since": since, "open_min": self._elapsed_min(now),
|
||||
"flat_min": 0.0,
|
||||
"best": None if soc is None else float(soc),
|
||||
"last": None if soc is None else float(soc), "at": None}
|
||||
self._trk = trk
|
||||
at = trk.get("at")
|
||||
try:
|
||||
last = datetime.fromisoformat(at) if at else None
|
||||
except (TypeError, ValueError):
|
||||
last = None
|
||||
dt = 0.0 if last is None else min(
|
||||
CHARGE_TICK_MAX_MIN, max(0.0, (now - last).total_seconds() / 60.0))
|
||||
trk["at"] = now.isoformat()
|
||||
if soc is None:
|
||||
pass
|
||||
elif trk.get("best") is None or soc > trk["best"]:
|
||||
trk["best"], trk["flat_min"] = float(soc), 0.0
|
||||
elif gate_open:
|
||||
trk["flat_min"] += dt
|
||||
if soc is not None:
|
||||
trk["last"] = float(soc)
|
||||
if gate_open:
|
||||
trk["open_min"] += dt
|
||||
saved = self._trk_saved
|
||||
if saved is None or (now - saved).total_seconds() >= CHARGE_SAVE_MIN * 60:
|
||||
self.store.set("charge_track", dict(trk))
|
||||
self._trk_saved = now
|
||||
return trk
|
||||
|
||||
def _fail(self, now: datetime, kind: str, why: str) -> MaintResult:
|
||||
"""MAINT-13: stand down, remember the failure, and tell the Controller."""
|
||||
events = self.abort(now, why)
|
||||
self.store.set_time("last_failed", now)
|
||||
msg = (f"maintenance: the cycle did not finish. It waits "
|
||||
f"{FAIL_BACKOFF_DAYS} days before it tries again.")
|
||||
_LOG.warning("%s", msg)
|
||||
events.append(msg)
|
||||
return MaintResult(IDLE, True, 0.0, False, events, failed=kind)
|
||||
|
||||
def _hold(self, now: datetime, events: list) -> MaintResult:
|
||||
"""HOLD for one tick, with or without a SoC reading (MAINT-15).
|
||||
|
||||
HOLD needs no level to end: it is reached only after the charge counted
|
||||
as done (MAINT-13/14), so its end is a time check and nothing else.
|
||||
"""
|
||||
elapsed = self._elapsed_min(now)
|
||||
if elapsed >= self.cfg.hold_min:
|
||||
self.store.set_time("last_completed", now)
|
||||
self._enter(IDLE, now, events,
|
||||
f"maintenance: cycle COMPLETE ({elapsed:.0f} min hold) "
|
||||
"- handing back to the loop")
|
||||
return MaintResult(IDLE, False, None, False, events)
|
||||
# 0 W, not "do nothing": commanding zero is what stops the loop
|
||||
# pulling the pack straight back down the moment it is full, which
|
||||
# is exactly when the BMS needs it parked at the top.
|
||||
return MaintResult(HOLD, True, 0.0, False, events)
|
||||
|
||||
def _no_soc(self, now: datetime, phase: str, gate_open: bool,
|
||||
events: list) -> MaintResult:
|
||||
"""MAINT-14: one tick of a pack in a moving phase with no SoC reading.
|
||||
|
||||
HOLD stays at 0 W and ends at its normal time (MAINT-15). DRAIN holds at 0 W, like HOLD, and its 12 h
|
||||
timeout still runs. CHARGE keeps its timeout and wall backstop: out of
|
||||
time is a failure, except when the last SoC seen was within
|
||||
CHARGE_NEAR_FULL_PCT of the target, which counts as done (MAINT-13).
|
||||
Nothing here may start a phase change that needs the level.
|
||||
"""
|
||||
if self._nosoc_since is None:
|
||||
self._nosoc_since = now
|
||||
self._nosoc_said = False
|
||||
self._trk_saved = None # save the last SoC seen, now
|
||||
if phase == DRAIN:
|
||||
msg = ("maintenance: the battery level is unknown, so the "
|
||||
"drain holds at 0 W until it is back.")
|
||||
_LOG.warning("%s", msg)
|
||||
events.append(msg)
|
||||
if (not self._nosoc_said and (now - self._nosoc_since).total_seconds()
|
||||
>= NOSOC_NOTE_MIN * 60):
|
||||
self._nosoc_said = True
|
||||
tail = ("The hold still ends at its normal time."
|
||||
if phase == HOLD else
|
||||
f"The time limit of the {phase} still applies.")
|
||||
msg = (f"maintenance: no battery level for {NOSOC_NOTE_MIN} min. "
|
||||
f"{tail}")
|
||||
_LOG.warning("%s", msg)
|
||||
events.append(msg)
|
||||
if phase == HOLD:
|
||||
return self._hold(now, events)
|
||||
elapsed = self._elapsed_min(now)
|
||||
if phase == DRAIN:
|
||||
if elapsed > DRAIN_TIMEOUT_MIN:
|
||||
return self._fail(now, "drain",
|
||||
f"drain stuck, battery level unknown, after "
|
||||
f"{elapsed / 60:.0f} h")
|
||||
return MaintResult(DRAIN, True, 0.0, False, events)
|
||||
trk = self._charge_account(now, None, gate_open)
|
||||
if (trk["open_min"] > CHARGE_TIMEOUT_MIN
|
||||
or elapsed > CHARGE_WALL_TIMEOUT_MIN):
|
||||
last = trk.get("last")
|
||||
if last is not None and last >= self.cfg.soc_target - CHARGE_NEAR_FULL_PCT:
|
||||
self._enter(HOLD, now, events,
|
||||
f"maintenance: charge time is up and the last level "
|
||||
f"seen was {last:.0f} %, close enough to full, so the "
|
||||
f"charge counts as done -> holding "
|
||||
f"{self.cfg.hold_min} min to balance")
|
||||
return MaintResult(HOLD, True, 0.0, False, events)
|
||||
if elapsed > CHARGE_WALL_TIMEOUT_MIN:
|
||||
lim = f"the {CHARGE_WALL_TIMEOUT_MIN // 1440}-day limit"
|
||||
else:
|
||||
lim = f"{trk['open_min'] / 60:.0f} h of charging time"
|
||||
return self._fail(now, "charge",
|
||||
f"charge stuck, battery level unknown, after {lim}")
|
||||
return MaintResult(CHARGE, False, None, True, events)
|
||||
|
||||
def abort(self, now: datetime, why: str) -> list:
|
||||
events = []
|
||||
self._enter(IDLE, now, events, f"maintenance: ABORT ({why})")
|
||||
@@ -387,13 +643,16 @@ class Maintenance:
|
||||
|
||||
# -- the machine ---------------------------------------------------------
|
||||
def tick(self, now: datetime, soc: float | None,
|
||||
blocked_by: str = "") -> MaintResult:
|
||||
blocked_by: str = "", gate_open: bool = True) -> MaintResult:
|
||||
"""One cycle of this pack's state machine.
|
||||
|
||||
`blocked_by` is `arbitrate()`'s answer for THIS pack: empty when the
|
||||
site's one maintenance slot is available to it, otherwise the reason it
|
||||
is not. It gates two things and nothing else - a start, and a phase that
|
||||
is already moving power on a pack that does not hold the slot.
|
||||
|
||||
`gate_open` (MAINT-13) is whether the pack may charge now (the price
|
||||
gate); only those minutes count towards the charge timeout.
|
||||
"""
|
||||
events: list = []
|
||||
phase = self.phase
|
||||
@@ -444,13 +703,13 @@ class Maintenance:
|
||||
# transitions below mean anything, and a stale "100 %" would end the
|
||||
# charge phase having charged nothing. Hold the current phase's command
|
||||
# and wait; the phase timeout is the backstop.
|
||||
#
|
||||
# MAINT-14: "hold and wait" is not "wait for ever". The timeouts run
|
||||
# here too, and a drain holds at 0 W (an unknown level never allows
|
||||
# discharge, TEL-08). See `_no_soc`.
|
||||
if soc is None:
|
||||
_LOG.warning("maintenance: no SoC reading, holding phase %s", phase)
|
||||
if phase == DRAIN:
|
||||
return MaintResult(DRAIN, True, self.cfg.discharge_w, False, events)
|
||||
if phase == HOLD:
|
||||
return MaintResult(HOLD, True, 0.0, False, events)
|
||||
return MaintResult(CHARGE, False, None, True, events)
|
||||
return self._no_soc(now, phase, gate_open, events)
|
||||
self._nosoc_since = None
|
||||
|
||||
elapsed = self._elapsed_min(now)
|
||||
|
||||
@@ -461,8 +720,8 @@ class Maintenance:
|
||||
f"after {elapsed:.0f} min -> charge")
|
||||
return MaintResult(CHARGE, False, None, True, events)
|
||||
if elapsed > DRAIN_TIMEOUT_MIN:
|
||||
events += self.abort(now, f"drain stuck at {soc:.0f} % after {elapsed:.0f} min")
|
||||
return MaintResult(IDLE, True, 0.0, False, events)
|
||||
return self._fail(now, "drain", f"drain stuck at {soc:.0f} % "
|
||||
f"after {elapsed:.0f} min")
|
||||
return MaintResult(DRAIN, True, self.cfg.discharge_w, False, events)
|
||||
|
||||
if phase == CHARGE:
|
||||
@@ -471,22 +730,31 @@ class Maintenance:
|
||||
f"maintenance: charge complete at {soc:.0f} % after "
|
||||
f"{elapsed:.0f} min -> holding {self.cfg.hold_min} min to balance")
|
||||
return MaintResult(HOLD, True, 0.0, False, events)
|
||||
if elapsed > CHARGE_TIMEOUT_MIN:
|
||||
events += self.abort(now, f"charge stuck at {soc:.0f} % after {elapsed:.0f} min")
|
||||
return MaintResult(IDLE, True, 0.0, False, events)
|
||||
trk = self._charge_account(now, soc, gate_open)
|
||||
near = soc >= self.cfg.soc_target - CHARGE_NEAR_FULL_PCT
|
||||
# MAINT-13 rule 1: close to full and no longer taking charge.
|
||||
if near and trk["flat_min"] >= CHARGE_STALL_MIN:
|
||||
self._enter(HOLD, now, events,
|
||||
f"maintenance: the battery takes no more charge at "
|
||||
f"{soc:.0f} %, so the charge counts as done -> holding "
|
||||
f"{self.cfg.hold_min} min to balance")
|
||||
return MaintResult(HOLD, True, 0.0, False, events)
|
||||
if (trk["open_min"] > CHARGE_TIMEOUT_MIN
|
||||
or elapsed > CHARGE_WALL_TIMEOUT_MIN):
|
||||
# MAINT-13 rule 2: out of time close to full is complete.
|
||||
if near:
|
||||
self._enter(HOLD, now, events,
|
||||
f"maintenance: charge time is up at {soc:.0f} %, "
|
||||
f"close enough to full, so the charge counts as "
|
||||
f"done -> holding {self.cfg.hold_min} min to balance")
|
||||
return MaintResult(HOLD, True, 0.0, False, events)
|
||||
return self._fail(now, "charge",
|
||||
f"charge stuck at {soc:.0f} % after "
|
||||
f"{trk['open_min']:.0f} min of charging time")
|
||||
return MaintResult(CHARGE, False, None, True, events)
|
||||
|
||||
if phase == HOLD:
|
||||
if elapsed >= self.cfg.hold_min:
|
||||
self.store.set_time("last_completed", now)
|
||||
self._enter(IDLE, now, events,
|
||||
f"maintenance: cycle COMPLETE ({elapsed:.0f} min hold) "
|
||||
"- handing back to the loop")
|
||||
return MaintResult(IDLE, False, None, False, events)
|
||||
# 0 W, not "do nothing": commanding zero is what stops the loop
|
||||
# pulling the pack straight back down the moment it is full, which
|
||||
# is exactly when the BMS needs it parked at the top.
|
||||
return MaintResult(HOLD, True, 0.0, False, events)
|
||||
return self._hold(now, events)
|
||||
|
||||
_LOG.error("maintenance: unknown phase %r, forcing idle", phase)
|
||||
events += self.abort(now, f"unknown phase {phase!r}")
|
||||
|
||||
@@ -102,6 +102,7 @@ class Notice:
|
||||
title: str
|
||||
body: str
|
||||
section: str = "" # the DOCS.md heading, when it is not the title itself
|
||||
body_nl: str = "" # MAINT-13: the same words in Dutch, shown under the English
|
||||
|
||||
@property
|
||||
def docs(self) -> str:
|
||||
@@ -110,7 +111,9 @@ class Notice:
|
||||
def data(self) -> dict:
|
||||
"""The `persistent_notification.create` payload."""
|
||||
return {"notification_id": self.id, "title": self.title,
|
||||
"message": f"{self.body}\n\n{details(self.docs)}"}
|
||||
"message": f"{self.body}\n\n"
|
||||
+ (f"{self.body_nl}\n\n" if self.body_nl else "")
|
||||
+ details(self.docs)}
|
||||
|
||||
|
||||
# --- the catalogue ----------------------------------------------------------
|
||||
@@ -124,6 +127,8 @@ T_CIRCULATING = "Inverters working against each other"
|
||||
T_NOT_DELIVERING = "{unit} is not delivering"
|
||||
S_NOT_DELIVERING = "An inverter is not delivering"
|
||||
T_METER_FROZEN = "Meter reading may be stuck"
|
||||
T_MAINT_FAILED = "Battery cycle did not finish"
|
||||
T_MAINT_FAILED_NL = "Batterijcyclus niet afgerond"
|
||||
|
||||
ID_LOOP_BOUND = "goodwe_integrator_bound"
|
||||
ID_OUTPUT_CLAMP = "goodwe_output_clamp"
|
||||
@@ -132,6 +137,7 @@ ID_PEAK_RESERVE = "goodwe_peak_soc_floor"
|
||||
ID_CIRCULATING = "goodwe_circulating_power"
|
||||
ID_NOT_DELIVERING = "goodwe_not_delivering_{slug}"
|
||||
ID_METER_FROZEN = "goodwe_meter_frozen"
|
||||
ID_MAINT_FAILED = "goodwe_maint_failed_{slug}"
|
||||
|
||||
# Which limit cut a command (clamp.ClampResult.bound, plus the loop's own).
|
||||
# The setting is named by its label, "Inverter rating (W)".
|
||||
@@ -217,7 +223,7 @@ def peak_saturated(ceiling_w, *, shadow: bool = False) -> Notice:
|
||||
|
||||
|
||||
def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
|
||||
packs=()) -> Notice:
|
||||
packs=(), others: bool = False) -> Notice:
|
||||
"""SAFETY-24/25/32: the peak guard may not draw the battery any lower.
|
||||
|
||||
The last sentence is SAFETY-25 and must stay: the reserve binds the peak
|
||||
@@ -227,7 +233,8 @@ def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
|
||||
roster means some inverter's cut-out level is missing (the peak reserve
|
||||
itself always has a value) - so the action names the cut-out level.
|
||||
Both are named by their setting labels, "Peak reserve (%)" and "Battery
|
||||
cut-out level (%)".
|
||||
cut-out level (%)". `others`: the other batteries give all they can and
|
||||
it is not enough (SAFETY-87), so the limit MAY be passed.
|
||||
"""
|
||||
if soc_unread:
|
||||
why = "The battery level cannot be read"
|
||||
@@ -241,6 +248,12 @@ def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
|
||||
why = (f"The battery of {names(packs)} is at its peak reserve" if packs
|
||||
else "The battery is at its peak reserve")
|
||||
act = "Switch off large loads, or lower the peak reserve."
|
||||
if others:
|
||||
return Notice(ID_PEAK_RESERVE, ALARM, T_PEAK_RESERVE,
|
||||
f"{why}, and the other batteries cannot keep this "
|
||||
f"15-minute period under your {watts(ceiling_w)} peak "
|
||||
f"limit alone. {act} Home use can still draw a battery "
|
||||
f"below its reserve, down to its cut-out level.")
|
||||
return Notice(ID_PEAK_RESERVE, ALARM, T_PEAK_RESERVE,
|
||||
f"{why}, so this 15-minute period will go over your "
|
||||
f"{watts(ceiling_w)} peak limit and raise this month's peak. "
|
||||
@@ -288,6 +301,24 @@ def meter_frozen(unchanged_s) -> Notice:
|
||||
f"stop control on the add-on page.")
|
||||
|
||||
|
||||
def maint_failed(kind: str, days: int, unit: str = "", slug: str = "") -> Notice:
|
||||
"""MAINT-13: a monthly cycle stopped before it was done. Said once."""
|
||||
full = kind == "charge"
|
||||
who = unit or "The battery"
|
||||
who_nl = unit or "De batterij"
|
||||
en = f"{who} did not {'fill up' if full else 'empty'}, so its cycle " \
|
||||
f"stopped and tries again in {days} days."
|
||||
nl = f"{who_nl} raakte niet {'vol' if full else 'leeg'}, dus de cyclus is " \
|
||||
f"gestopt en probeert het over {days} dagen opnieuw."
|
||||
return Notice(ID_MAINT_FAILED.format(slug=slug or "battery"), WARNING,
|
||||
T_MAINT_FAILED, en, body_nl=f"{T_MAINT_FAILED_NL}. {nl}")
|
||||
|
||||
|
||||
def row_maint_waits(until_text: str) -> str:
|
||||
"""MAINT-13: the status row while a failed cycle waits."""
|
||||
return f"Waits until {until_text} after a cycle that did not finish."
|
||||
|
||||
|
||||
# --- the status page (UX-08 phase 2b) ----------------------------------------
|
||||
# The rows of the Commissioning list and the banner at the top of the add-on
|
||||
# page. `main.py` decides WHEN a row shows and its colour (ok / warn / bad);
|
||||
@@ -470,8 +501,9 @@ def row_peak_saturated(ceiling_w) -> str:
|
||||
|
||||
|
||||
def row_peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
|
||||
packs=()) -> str:
|
||||
"""SAFETY-24/25/32. The second sentence is SAFETY-25 and must stay."""
|
||||
packs=(), others: bool = False) -> str:
|
||||
"""SAFETY-24/25/32. The last sentence is SAFETY-25 and must stay.
|
||||
`others`: as in `peak_reserve()` (SAFETY-87)."""
|
||||
if soc_unread:
|
||||
why = "Battery level cannot be read"
|
||||
elif floor_unknown:
|
||||
@@ -479,6 +511,10 @@ def row_peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
|
||||
else:
|
||||
why = (f"{names(packs)} battery at its peak reserve" if packs
|
||||
else "Battery at its peak reserve")
|
||||
if others:
|
||||
return (f"{why}. The others cannot hold the {watts(ceiling_w)} peak "
|
||||
f"limit alone. Home use still draws it lower, to the cut-out "
|
||||
f"level.")
|
||||
return (f"{why}, so the {watts(ceiling_w)} peak limit will be passed. Home "
|
||||
f"use still draws the battery below it, down to the cut-out level.")
|
||||
|
||||
@@ -781,6 +817,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 "
|
||||
@@ -900,6 +942,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,
|
||||
@@ -928,6 +971,7 @@ HINTS = {
|
||||
"not_delivering_cleared": "{unit}: not-delivering alarm cleared",
|
||||
"maintenance": "Battery maintenance: {phase}",
|
||||
"maint_paused": "Maintenance charging paused to stay under the peak limit",
|
||||
"maint_failed": "The battery cycle did not finish, so it waits before trying again",
|
||||
"pack_on": "{unit}: battery switched on",
|
||||
"pack_off": "{unit}: battery switched off, it is asked for 0 W",
|
||||
"pack_mode": "{unit}: battery mode changed",
|
||||
@@ -1014,6 +1058,9 @@ def status_samples() -> list:
|
||||
("row_peak_guard_off", AMBER, row_peak_guard_off()),
|
||||
("row_peak_reserve", RED, row_peak_reserve(
|
||||
5000, soc_unread=False, floor_unknown=False, packs=["inverter-b"])),
|
||||
("row_peak_reserve", RED, row_peak_reserve(
|
||||
5000, soc_unread=False, floor_unknown=False, packs=["inverter-b"],
|
||||
others=True)),
|
||||
("row_peak_reserve", RED, row_peak_reserve(
|
||||
5000, soc_unread=True, floor_unknown=False)),
|
||||
("row_peak_reserve", RED, row_peak_reserve(
|
||||
@@ -1082,6 +1129,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)),
|
||||
@@ -1095,6 +1143,7 @@ def status_samples() -> list:
|
||||
("row_battery_refuses", AMBER, row_battery_refuses("inverter-b", 1,
|
||||
True)),
|
||||
("row_circulating_check", GREEN, row_circulating_check(0, 0, 60)),
|
||||
("row_maint_waits", AMBER, row_maint_waits("2026-10-13")),
|
||||
]
|
||||
|
||||
|
||||
@@ -1116,6 +1165,8 @@ def samples() -> list:
|
||||
peak_reserve(5000, soc_unread=False, floor_unknown=False),
|
||||
peak_reserve(5000, soc_unread=False, floor_unknown=False,
|
||||
packs=["inverter-b"]),
|
||||
peak_reserve(5000, soc_unread=False, floor_unknown=False,
|
||||
packs=["inverter-b"], others=True),
|
||||
peak_reserve(5000, soc_unread=True, floor_unknown=False),
|
||||
peak_reserve(5000, soc_unread=False, floor_unknown=True),
|
||||
circulating(["inverter-a"], ["inverter-b"]),
|
||||
@@ -1123,6 +1174,8 @@ def samples() -> list:
|
||||
not_delivering("inverter-b", "inverter_b"),
|
||||
meter_frozen(600),
|
||||
meter_frozen(90),
|
||||
maint_failed("charge", 7),
|
||||
maint_failed("drain", 7, "inverter-b", "inverter_b"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -397,7 +399,7 @@ function, so whichever way the owner answers, this file does not change and does
|
||||
not need re-testing. Nothing here indexes a phase.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
from . import messages
|
||||
from .arbiter import ACTUATOR_SITE, P_PEAK, Claim
|
||||
@@ -490,11 +492,18 @@ class PeakDecision:
|
||||
# floor refused to size it. Mutually exclusive with `saturated` by
|
||||
# construction: this branch asks for nothing, so it cannot have run out of
|
||||
# inverter. `soc_floor_reason` says WHICH of the two ways it bound.
|
||||
# SAFETY-87: at N>=2 it may ask the batteries above their reserve for
|
||||
# `able_w`, all they have, when that is not enough (`place_on_able()`).
|
||||
floor_limited: bool = False
|
||||
soc: float | None = None
|
||||
soc_floor: float | None = None
|
||||
soc_floor_reason: str | None = None
|
||||
reason: str = ""
|
||||
# SAFETY-87, N>=2 only: the batteries at or under their own reserve,
|
||||
# which give nothing for the peak, and what the others can give (None
|
||||
# when they are not asked). See `place_on_able()`.
|
||||
held: tuple = ()
|
||||
able_w: float | None = None
|
||||
|
||||
def explain(self) -> str:
|
||||
"""Idle: the status row's plain words (UX-08). Engaged: `reason`, the
|
||||
@@ -592,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?
|
||||
|
||||
@@ -733,6 +793,43 @@ def binding_soc_floor(packs, soc_floor):
|
||||
return max(floors)
|
||||
|
||||
|
||||
def reserve_split(packs, soc_floor) -> tuple[list, tuple]:
|
||||
"""SAFETY-87: `(packs the guard may spend, names of those it may not)`,
|
||||
each pack graded on its own floor by `pack_permissions()`."""
|
||||
perms = pack_permissions(packs, soc_floor)
|
||||
return ([p for p, (_n, why) in zip(packs, perms) if why is None],
|
||||
tuple(n for n, why in perms if why is not None))
|
||||
|
||||
|
||||
def place_on_able(decision, held, *, able_w, soc, soc_floor) -> PeakDecision:
|
||||
"""SAFETY-87: `decision` with the batteries in `held` (at or under their
|
||||
own reserve) giving nothing for the peak. `able_w`, `soc` and `soc_floor`
|
||||
describe the others, as `decide()`'s arguments do for the roster. A
|
||||
floor-limited decision is re-taken on them: they are asked for the need
|
||||
up to `able_w`, and it stays floor-limited only if that is not enough.
|
||||
`soc` and `soc_floor` keep the roster's verdict, as `decide()` echoed it.
|
||||
DECISIONS/SAFETY-87-guard-uses-able-batteries.md."""
|
||||
held = tuple(held)
|
||||
if not decision.floor_limited or decision.wanted_w is None \
|
||||
or discharge_permitted(soc, soc_floor) is not None:
|
||||
return replace(decision, held=held)
|
||||
wanted = float(decision.wanted_w)
|
||||
floor = min(wanted, float(able_w))
|
||||
head = (f"this 15-minute period heads for {decision.projected_w:.0f} W "
|
||||
f"against a {decision.ceiling_w:.0f} W limit "
|
||||
f"({decision.ceiling_from}), aiming at {decision.target_w:.0f} W. "
|
||||
f"At the peak reserve, so giving nothing for the peak: "
|
||||
f"{messages.names(held)}. The others are asked for {floor:+.0f} W")
|
||||
if floor >= wanted:
|
||||
return replace(decision, floor_limited=False, floor_w=floor,
|
||||
soc_floor_reason=None, held=held, able_w=float(able_w),
|
||||
reason=f"PEAK_GUARD: {head}")
|
||||
return replace(decision, floor_w=floor, held=held, able_w=float(able_w),
|
||||
reason=(f"PEAK_GUARD_FLOOR_LIMITED: {head}, all they can "
|
||||
f"give, but {wanted:+.0f} W was needed, so this "
|
||||
f"period may go over the limit"))
|
||||
|
||||
|
||||
def decide(projection, *, batt_w, cap_w, margin_w, reading, achievable_w, soc,
|
||||
soc_floor, max_hold_s=None, frozen: bool = False) -> PeakDecision:
|
||||
"""The whole guard. Pure: no clock, no I/O, no state carried between calls.
|
||||
@@ -1097,5 +1194,6 @@ def claim(decision: PeakDecision) -> Claim | None:
|
||||
return Claim.limit(SOURCE, P_PEAK, lo=float(decision.floor_w),
|
||||
reason=("blind" if decision.blind
|
||||
else "SoC floor" if decision.floor_limited
|
||||
and not decision.floor_w
|
||||
else "quarter peak"),
|
||||
actuator=ACTUATOR_SITE)
|
||||
|
||||
@@ -1232,6 +1232,16 @@ def static_plan(opts: dict, since: datetime, until: datetime, *,
|
||||
profile, "fallback" if provisional else "static")
|
||||
|
||||
|
||||
def static_gated(opts: dict, now: datetime) -> bool:
|
||||
"""MAINT-13: is a fixed tariff set up, so that a cheap window can hold a
|
||||
charge back? False for no profile or one that cannot be used."""
|
||||
try:
|
||||
return bool(static_plan(opts, now, now + timedelta(days=MEAN_WINDOW_DAYS),
|
||||
now=now).has_prices)
|
||||
except ProfileInvalid:
|
||||
return False
|
||||
|
||||
|
||||
def static_status(opts: dict, now: datetime):
|
||||
"""(cheap, words) for the status page and for main.py's cheap-window flag.
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ DEFAULTS = {
|
||||
"phase_started": None,
|
||||
"last_completed": None,
|
||||
"last_start_attempt": None,
|
||||
"last_failed": None,
|
||||
"sign_tripped": False,
|
||||
"sign_reason": "",
|
||||
"circ_tripped": False,
|
||||
@@ -171,7 +172,7 @@ MAINT_SCHEMA_KEY = "maint_schema_version"
|
||||
# silently stop being migrated - the same property `UNIT_STORE_KEYS` gives the
|
||||
# SAFETY-02 pair.
|
||||
MAINT_UNIT_KEYS = ("phase", "phase_started", "last_completed",
|
||||
"last_start_attempt")
|
||||
"last_start_attempt", "last_failed")
|
||||
|
||||
|
||||
def unit_key(unit: str, key: str) -> str:
|
||||
|
||||
@@ -822,6 +822,22 @@ class PeakTracker:
|
||||
held_w=averager.held_net_w)
|
||||
|
||||
|
||||
def next_quarter(p: Projection, max_hold_s: float) -> Projection:
|
||||
"""SAFETY-91: `p` past its block's end, before the next block's first
|
||||
telegram: the next quarter, projected from the held reading (the averager
|
||||
credits it there). `p` itself otherwise, or when that reading is too old
|
||||
to be credited. DECISIONS/SAFETY-91-held-battery-last.md."""
|
||||
if p.block_start is None or p.elapsed_s < QUARTER_S \
|
||||
or p.elapsed_s * (1.0 - p.covered_frac) > float(max_hold_s):
|
||||
return p
|
||||
held = max(p.held_w, 0.0)
|
||||
return Projection(projected_w=held, elapsed_w=held,
|
||||
elapsed_s=p.elapsed_s - QUARTER_S, covered_frac=1.0,
|
||||
complete=True,
|
||||
block_start=p.block_start + timedelta(seconds=QUARTER_S),
|
||||
held_w=p.held_w)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ClosedBlock:
|
||||
"""A block reconstructed from a checkpoint. Shaped like `p1.QuarterBlock` so
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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."},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.12.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
|
||||
|
||||
@@ -439,7 +439,7 @@ class _Hass:
|
||||
return True
|
||||
|
||||
|
||||
def wired(grid, cycles=40, batt=0.0, track=True, **over):
|
||||
def wired(grid, cycles=40, batt=0.0, track=True, tick=None, **over):
|
||||
async def go():
|
||||
hass = _Hass(grid, batt=batt, track=track)
|
||||
c = Controller(dict(
|
||||
@@ -455,8 +455,11 @@ def wired(grid, cycles=40, batt=0.0, track=True, **over):
|
||||
# hysteresis clears it a few cycles later - so it is captured live
|
||||
# rather than looked for after the run, when it is correctly gone.
|
||||
c.red_snapshot = []
|
||||
for _ in range(cycles):
|
||||
await c.read_inputs()
|
||||
for i in range(cycles):
|
||||
# `tick`: simulated seconds per cycle. SAFETY-94: a refusal needs
|
||||
# its evidence to span REFUSED_SPAN_S, which back-to-back cycles
|
||||
# on the real clock never do.
|
||||
await c.read_inputs(now_mono=None if tick is None else i * tick)
|
||||
await c.cycle()
|
||||
if c.clamp_wd.active and not c.red_snapshot:
|
||||
c.red_snapshot = c.checks()
|
||||
@@ -474,7 +477,7 @@ def clamp_notes(hass):
|
||||
# unclamped integrator climbed ~130 W per 4 s to over 10 kW and, pinned at the
|
||||
# 5 kW rail, reported 14 768 W." `track=False` is the inverter switched off: it
|
||||
# reports 0 W however hard it is commanded, so the error never closes.
|
||||
hass_r, ctl_r = wired(6000.0, cycles=60, track=False)
|
||||
hass_r, ctl_r = wired(6000.0, cycles=60, track=False, tick=2.0)
|
||||
emitted = [abs(w) for w in hass_r.writes]
|
||||
|
||||
# What the SAME error would have done to an accumulator with no bound - the
|
||||
@@ -553,12 +556,14 @@ check("the status page flags it WHILE it is engaged - amber, since it is the "
|
||||
any(not c["ok"] and c["warn"] and c.get("id") == "not_keeping_up"
|
||||
and c["text"].startswith(messages.T_LOOP_BOUND)
|
||||
for c in ctl_r.red_snapshot))
|
||||
check("...and once clear, a warn row still says how many times it engaged, "
|
||||
"under the integrator bound's own title",
|
||||
# SAFETY-85: the row counts episodes, not cycles.
|
||||
check("...and once clear, a warn row still says how many times it happened "
|
||||
"(episodes, not cycles), under the integrator bound's own title",
|
||||
any(c["warn"] and c.get("id") == "not_keeping_up"
|
||||
and c["text"].startswith(messages.T_LOOP_BOUND)
|
||||
and messages.times(ctl_r.clamp_wd.bound) in c["text"]
|
||||
and "fine now" in c["text"] for c in ctl_r.checks()))
|
||||
and c["text"] == messages.row_before(
|
||||
messages.T_LOOP_BOUND, ctl_r.clamp_wd.bound_episodes)
|
||||
for c in ctl_r.checks())
|
||||
and 0 < ctl_r.clamp_wd.bound_episodes < ctl_r.clamp_wd.bound)
|
||||
# UX-07 item 1: the after-episode row used to say "output clamp ... engaged N
|
||||
# time(s)" for integrator-bound cycles. Each count now sits under its own name.
|
||||
check("UX-07 item 1: bound-only engagements are counted as the bound, and no "
|
||||
|
||||
@@ -900,34 +900,51 @@ def _loop_series(day, sig):
|
||||
return out
|
||||
|
||||
|
||||
class _Before94(Freshness):
|
||||
"""The gate before SAFETY-94: the clock ran from the register's last
|
||||
change, not from when the command asked it to move. The history below is
|
||||
about that gate, so it is replayed on it."""
|
||||
|
||||
def obliged_s(self):
|
||||
return self.graded_s(self.last_read)
|
||||
|
||||
|
||||
def _replay16(cls):
|
||||
"""Every committed day at 1 Hz. Returns (trips, waits for the next move)."""
|
||||
waits, trips = [], 0
|
||||
for _day in _loop_days:
|
||||
_b, _s = _loop_series(_day, "batt"), _loop_series(_day, "setpoint")
|
||||
if not _b or not _s:
|
||||
continue
|
||||
_moves = [r[0] for i, r in enumerate(_b)
|
||||
if i == 0 or r[1] != _b[i - 1][1]]
|
||||
_f8b, _prev8b = cls(BOUNDS["batt"]), False
|
||||
_ib = _is = 0
|
||||
_t = max(_b[0][0], _s[0][0])
|
||||
_end = min(_b[-1][0], _s[-1][0])
|
||||
while _t <= _end:
|
||||
while _ib + 1 < len(_b) and _b[_ib + 1][0] <= _t:
|
||||
_ib += 1
|
||||
while _is + 1 < len(_s) and _s[_is + 1][0] <= _t:
|
||||
_is += 1
|
||||
_f8b.submit(_b[_ib][1], _s[_is][1], _t)
|
||||
if _f8b.stale and not _prev8b:
|
||||
trips += 1
|
||||
_nxt = next((m for m in _moves if m > _t), None)
|
||||
if _nxt is not None:
|
||||
waits.append(_nxt - _t)
|
||||
_prev8b = _f8b.stale
|
||||
_t += 1.0
|
||||
return trips, sorted(waits)
|
||||
|
||||
|
||||
_t8b = time.monotonic()
|
||||
_waits, _trips8b = [], 0
|
||||
for _day in _loop_days:
|
||||
_b, _s = _loop_series(_day, "batt"), _loop_series(_day, "setpoint")
|
||||
if not _b or not _s:
|
||||
continue
|
||||
_moves = [r[0] for i, r in enumerate(_b) if i == 0 or r[1] != _b[i - 1][1]]
|
||||
_f8b, _prev8b = Freshness(BOUNDS["batt"]), False
|
||||
_ib = _is = 0
|
||||
_t = max(_b[0][0], _s[0][0])
|
||||
_end = min(_b[-1][0], _s[-1][0])
|
||||
while _t <= _end:
|
||||
while _ib + 1 < len(_b) and _b[_ib + 1][0] <= _t:
|
||||
_ib += 1
|
||||
while _is + 1 < len(_s) and _s[_is + 1][0] <= _t:
|
||||
_is += 1
|
||||
_f8b.submit(_b[_ib][1], _s[_is][1], _t)
|
||||
if _f8b.stale and not _prev8b:
|
||||
_trips8b += 1
|
||||
_nxt = next((m for m in _moves if m > _t), None)
|
||||
if _nxt is not None:
|
||||
_waits.append(_nxt - _t)
|
||||
_prev8b = _f8b.stale
|
||||
_t += 1.0
|
||||
_waits.sort()
|
||||
_trips8b, _waits = _replay16(_Before94)
|
||||
_trips94, _waits94 = _replay16(Freshness)
|
||||
_p50 = nth(_waits, len(_waits) // 2)
|
||||
print(f" {len(_loop_days)} days replayed at 1 Hz in "
|
||||
f"{time.monotonic() - _t8b:.1f}s: {_trips8b} trips")
|
||||
f"{time.monotonic() - _t8b:.1f}s (twice): {_trips8b} trips before "
|
||||
f"SAFETY-94, {_trips94} after")
|
||||
print(f" wait for the register's next change after a trip: "
|
||||
f"min {nth(_waits, 0):.1f} s median {_p50:.1f} s max {nth(_waits, -1):.1f} s; "
|
||||
f"{sum(1 for w in _waits if w >= 710.0)} at or over the live episode's "
|
||||
@@ -953,6 +970,17 @@ check("⚠️ AND THAT DISTRIBUTION IS A PROPERTY OF THIS BMS, NOT OF THIS CODE
|
||||
"is `stale_input_s + release_s` = 32 s whatever the register does",
|
||||
sum(1 for w in _waits if w < 20.0) >= 20
|
||||
and float(option_default("stale_input_s")) + BOUNDS["batt"].release_s == 32.0)
|
||||
print(f" SAFETY-94: trips answered within 20 s: {sum(1 for w in _waits if w < 20.0)}"
|
||||
f" before, {sum(1 for w in _waits94 if w < 20.0)} after; quickest "
|
||||
f"answer after a trip {nth(_waits94, 0):.1f} s")
|
||||
check("⚠️ SAFETY-94 (SAFETY-58): MOST OF THOSE SHORT WAITS WERE FALSE TRIPS, "
|
||||
"NOT KEEP-ALIVE PULSES. The clock now counts only the time the command "
|
||||
"asked the register to move. The same sixteen days give 59 trips, not "
|
||||
"93, and only one of the 59 is answered within 20 s (21 before): the "
|
||||
"gate no longer judges a register on the first read after a rest",
|
||||
_trips94 == 59 and len(_waits94) == _trips94
|
||||
and sum(1 for w in _waits94 if w < 20.0) == 1
|
||||
and sum(1 for w in _waits if w < 20.0) == 21)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -1017,32 +1045,30 @@ check("NOT A SURVIVOR, recorded because it looks like one: a publisher failure "
|
||||
"each unit's own command travel and each unit's own number",
|
||||
_s4a.stale and _s4b.stale and _s4a.held != _s4b.held)
|
||||
|
||||
# S5. SAFETY-57's own, and it is a FALSE POSITIVE that predates this card.
|
||||
# The unchanged clock keeps running while the site rests, so the FIRST read
|
||||
# at which a command travels 250 W after a long quiet already satisfies all
|
||||
# three terms - the register is given no time at all to answer the new
|
||||
# command. Present on the shipped tree; the live capture in part 10 contains
|
||||
# one (10:48:14, cleared one second later).
|
||||
# S5. SAFETY-57's own FALSE POSITIVE, CLOSED BY SAFETY-94 (it was SAFETY-58).
|
||||
# The unchanged clock used to keep running while the site rested, so the
|
||||
# FIRST read at which a command travelled 250 W after a long quiet tripped
|
||||
# the gate. The clock now counts only time the register was asked to move.
|
||||
# Live 2026-10-03: four such trips, each 1 s after the command.
|
||||
_s5 = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 300): # five minutes of a resting site
|
||||
_s5.submit(-3.0, 0.0, float(t))
|
||||
_s5_quiet = _s5.stale
|
||||
_s5.submit(-3.0, 1500.0, 300.0) # the loop asks for 1500 W
|
||||
check("⚠️ SURVIVOR 5, FALSE POSITIVE, AND IT IS OLDER THAN SAFETY-57: after a "
|
||||
"long rest the unchanged clock is already past 35 s, so the very FIRST "
|
||||
"read carrying a travelled command trips the gate - before the 17.0 s in "
|
||||
"which a healthy register could have answered it. The gate accuses on "
|
||||
"read one and the register answers on read eleven",
|
||||
not _s5_quiet and _s5.stale and _s5.graded_s(300.0) == 300.0)
|
||||
check("...and its SIZE is one `stale_input_s` hold, which is why it is recorded "
|
||||
"here rather than fixed here: `cycle()` returns at `inputs missing "
|
||||
"{n}s` for the whole of it, so nothing is commanded differently, and the "
|
||||
"register's next publish clears it. Fixing it means measuring the "
|
||||
"unchanged clock from when the OBLIGATION appeared rather than from the "
|
||||
"register's last move, which re-derives the 35.0 s bound and is a "
|
||||
"different card",
|
||||
(lambda f: (f.submit(-4.0, 1500.0, 311.0), not f.stale)[-1])(_s5)
|
||||
and float(option_default("stale_input_s")) >= 5.0)
|
||||
_s5_first = _s5.stale
|
||||
for t in range(301, 334):
|
||||
_s5.submit(-3.0, 1500.0, float(t))
|
||||
_s5_before = _s5.stale
|
||||
_s5.submit(-3.0, 1500.0, 334.0)
|
||||
check("⚠️ SURVIVOR 5 IS CLOSED (SAFETY-94): after five minutes of rest the "
|
||||
"first read carrying a travelled command is NOT a trip, nor is any read "
|
||||
"before 35 s of standing command - and a register that still has not "
|
||||
"moved after 35 s of it IS",
|
||||
not _s5_quiet and not _s5_first and not _s5_before and _s5.stale
|
||||
and _s5.graded_s(334.0) == 334.0 and _s5.obliged_s() == 35.0)
|
||||
check("...and the evidence sentence still gives the register's own unchanged "
|
||||
"time (334 s), which an operator compares with a history graph",
|
||||
"unchanged for 334 s" in _s5.reason)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -1353,20 +1379,23 @@ print(f" after : held number reached a consumer on {sum(_exp57)} reads "
|
||||
|
||||
check("⚠️ THE EXIT IS NOT FREE AND THE PRICE IS STATED AS A NUMBER. A register "
|
||||
"that is dead for good is re-graded for ever: over 1201 reads the held "
|
||||
"number reaches a consumer on 630 of them against 35 before, and 17 "
|
||||
"number reaches a consumer on 631 of them against 36 before, and 17 "
|
||||
"releases are taken. This check exists so that cost cannot be quietly "
|
||||
"forgotten, and so a change that makes it worse has to move a number "
|
||||
"somebody reads",
|
||||
sum(_expb) == 35 and sum(_exp57) == 630 and _dead.releases == 17
|
||||
sum(_expb) == 36 and sum(_exp57) == 631 and _dead.releases == 17
|
||||
and _deadb.releases == 0)
|
||||
check("⚠️ AND IT IS PAYABLE ONLY BECAUSE ONE RUN IS SHORTER THAN EITHER "
|
||||
"CONSUMER'S HOLD. Every unbroken run of exposure is exactly one 35.0 s "
|
||||
"grading window, and 35 < 75 (circulation_hold_s) < 85 "
|
||||
"CONSUMER'S HOLD. Every unbroken run of exposure after a release is "
|
||||
"exactly one 35.0 s window of standing command (the first is 36 s: the "
|
||||
"fixture's command starts one read late), and 36 < 75 "
|
||||
"(circulation_hold_s) < 85 "
|
||||
"(unit_sign_disagree_s) - so NEITHER detector can reach a verdict inside "
|
||||
"one, and the re-trip that ends each run hands them a None, which CLEARS "
|
||||
"their windows rather than accumulating into them",
|
||||
set(_runs57) == {BOUNDS["batt"].unchanged_s}
|
||||
and BOUNDS["batt"].unchanged_s < _circ_hold < _unit_hold)
|
||||
set(_runs57[1:]) == {BOUNDS["batt"].unchanged_s}
|
||||
and nth(_runs57, 0) == BOUNDS["batt"].unchanged_s + 1.0
|
||||
and max(_runs57) < _circ_hold < _unit_hold)
|
||||
check("...and nothing SAFETY-37 detects is given back by that, which is the "
|
||||
"half that makes the trade a trade. A permanent None clears both windows "
|
||||
"for ever too, so on the shipped-before module the same two detectors are "
|
||||
@@ -1643,8 +1672,10 @@ check("...and a re-trip's reason names the TWO durations separately, each "
|
||||
[f.submit(A_FROZEN_W, 0.0, float(t)) for t in range(40, 80)],
|
||||
[f.submit(A_FROZEN_W, 1300.0, float(t)) for t in range(80, 130)],
|
||||
f.stale and f.releases == 1
|
||||
and "unchanged for 92 s" in f.reason
|
||||
and "checked 2 times over a 35 s window since the last stop was "
|
||||
# SAFETY-94: the re-trip comes 35 s after the command returns at
|
||||
# 80 s (114 s), not 35 s after the release at 57 s.
|
||||
and "unchanged for 114 s" in f.reason
|
||||
and "checked 2 times over a 57 s window since the last stop was "
|
||||
"withdrawn" in f.reason)[-1])(
|
||||
Freshness(BOUNDS["batt"])))
|
||||
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1,570 @@
|
||||
"""MAINT-13: a full pack that stops at 98-99 % is not a failed charge.
|
||||
`python3 test_maint13.py`
|
||||
|
||||
Graded here: the stall rule, the near-full timeout rule, the 7 day back-off
|
||||
after a failed cycle (and what ends it), the timeout counting only the minutes
|
||||
the price gate was open, restart and old-state-file behaviour, the notification
|
||||
(once), and the Controller wiring at one and two packs.
|
||||
See DECISIONS/MAINT-13-full-pack-is-complete.md.
|
||||
|
||||
Mutating what this file grades? Read MUTATION.md first (stale bytecode).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app import messages
|
||||
from app.maintenance import (
|
||||
CHARGE, CHARGE_NEAR_FULL_PCT, CHARGE_STALL_MIN, CHARGE_TIMEOUT_MIN,
|
||||
CHARGE_WALL_TIMEOUT_MIN, DRAIN, FAIL_BACKOFF_DAYS, HOLD, IDLE,
|
||||
MaintConfig, Maintenance, arbitrate,
|
||||
)
|
||||
from app.main import Controller
|
||||
from app.store import (DEFAULTS, MAINT_UNIT_KEYS, Store, migrate_maint_keys,
|
||||
unit_key)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# The live site: target 100, shipped hold. Start hour 19 UTC for the schedule.
|
||||
CFG = MaintConfig(enabled=True, interval_days=28, start_hour=19,
|
||||
discharge_w=2000, charge_w=2000, soc_floor=11,
|
||||
soc_target=100, hold_min=120)
|
||||
T0 = datetime(2026, 10, 4, 18, 24, tzinfo=UTC) # B's drain ended (live)
|
||||
MIN = timedelta(minutes=1)
|
||||
|
||||
|
||||
class Mem:
|
||||
"""Store's surface, no disk; times kept as the real Store keeps them."""
|
||||
|
||||
def __init__(self, data=None):
|
||||
self.data = dict(DEFAULTS, **(data or {}))
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def set_time(self, k, when):
|
||||
self.data[k] = when.astimezone(UTC).isoformat() if when else None
|
||||
|
||||
def get_time(self, k):
|
||||
raw = self.data.get(k)
|
||||
return datetime.fromisoformat(raw) if raw else None
|
||||
|
||||
def save(self):
|
||||
pass
|
||||
|
||||
|
||||
def in_charge(store=None, cfg=CFG, at=T0):
|
||||
"""A pack that has just finished its drain and is charging at `at`."""
|
||||
m = Maintenance(cfg, store or Mem())
|
||||
m.force_start(at)
|
||||
m.tick(at, 11.0) # drain complete -> charge
|
||||
assert m.phase == CHARGE
|
||||
return m
|
||||
|
||||
|
||||
def run(m, start, minutes, soc, gate=True, step=1):
|
||||
"""Tick every `step` minutes for `minutes`; `soc` is a number or f(minute).
|
||||
Returns (the last result, the time after the run, the first non-CHARGE result)."""
|
||||
t, res, out = start, None, None
|
||||
for i in range(0, minutes, step):
|
||||
t = start + i * MIN
|
||||
s = soc(i) if callable(soc) else soc
|
||||
res = m.tick(t, s, gate_open=gate)
|
||||
if out is None and res.phase != CHARGE:
|
||||
out = (res, t)
|
||||
break
|
||||
return res, start + minutes * MIN, out
|
||||
|
||||
|
||||
print("1. the stall rule: near full and no longer taking charge")
|
||||
# Live record: 99 % at 04:00, back to 98 % at 04:15, never 100.
|
||||
m = in_charge()
|
||||
res, end, out = run(m, T0 + 60 * MIN, 400, lambda i: 99.0 if i < 15 else 98.0)
|
||||
check("a pack that reaches 99 and falls back to 98 completes (HOLD) after "
|
||||
f"{CHARGE_STALL_MIN} min without a new high",
|
||||
out is not None and out[0].phase == HOLD)
|
||||
check("...at 60 min after the last new high (the 99), not before",
|
||||
out is not None and out[1] == T0 + 120 * MIN)
|
||||
check("...with one plain log line",
|
||||
out is not None and len(out[0].events) == 1
|
||||
and "takes no more charge at 98 %" in out[0].events[0])
|
||||
m = in_charge()
|
||||
res, _, out = run(m, T0, 59, 98.0)
|
||||
check("59 minutes flat is not enough", out is None and res.phase == CHARGE)
|
||||
m = in_charge()
|
||||
res, _, out = run(m, T0, 61, lambda i: 97.0)
|
||||
check("target - 3 (97 of 100) counts as near full", out and out[0].phase == HOLD)
|
||||
m = in_charge()
|
||||
res, _, out = run(m, T0, 300, lambda i: 96.0)
|
||||
check("target - 4 (96 of 100) does not complete however long it is flat",
|
||||
out is None)
|
||||
cfg99 = MaintConfig(enabled=True, start_hour=19, soc_target=99, hold_min=120)
|
||||
m = in_charge(cfg=cfg99)
|
||||
res, _, out = run(m, T0, 61, lambda i: 96.0)
|
||||
check("the shipped target 99 works the same way (96 is near full)",
|
||||
out and out[0].phase == HOLD)
|
||||
m = in_charge()
|
||||
res, _, out = run(m, T0, 400, lambda i: min(99.0, 94.0 + i // 30))
|
||||
check("a pack still rising 1 point per 30 min is not stalled: it completes 60 "
|
||||
"min after the 99 at minute 150", out and out[1] == T0 + 210 * MIN)
|
||||
m = in_charge()
|
||||
res, _, out = run(m, T0, 130, lambda i: 97.0 if i < 50 else (98.0 if i < 100 else 98.0))
|
||||
check("a new high resets the wait: 97 flat for 50 min, 98 at 50 min -> "
|
||||
"completes at 110, not 60", out and out[1] == T0 + 110 * MIN)
|
||||
m = in_charge()
|
||||
res, _, out = run(m, T0, 300, lambda i: 97.0, gate=False)
|
||||
check("minutes with the gate closed do not count as 'no more charge'",
|
||||
out is None)
|
||||
m = in_charge()
|
||||
_, t, _ = run(m, T0, 30, 98.0)
|
||||
_, t, _ = run(m, t, 120, 98.0, gate=False)
|
||||
res, _, out = run(m, t, 31, 98.0)
|
||||
check("30 open + 120 closed + 31 open minutes complete (the closed ones "
|
||||
"pause the count)", out and out[0].phase == HOLD)
|
||||
|
||||
print("2. SoC at the target completes at once")
|
||||
m = in_charge()
|
||||
r = m.tick(T0 + MIN, 100.0)
|
||||
check("100 of 100 -> HOLD on that tick", r.phase == HOLD)
|
||||
check("...with the old log line", "charge complete at 100 %" in r.events[0])
|
||||
m = in_charge(cfg=cfg99)
|
||||
check("99 of 99 -> HOLD", m.tick(T0 + MIN, 99.0).phase == HOLD)
|
||||
|
||||
print("3. a charge that runs out of time")
|
||||
m = in_charge()
|
||||
res, _, out = run(m, T0, CHARGE_TIMEOUT_MIN + 10,
|
||||
lambda i: 60.0 + i / 55.0, step=1)
|
||||
check("still rising 1 point per 55 min (never 60 flat) and near full when the "
|
||||
"time is up -> counts as complete", out and out[0].phase == HOLD)
|
||||
check("...with one plain line saying so",
|
||||
out and len(out[0].events) == 1 and "charge time is up" in out[0].events[0]
|
||||
and "counts as done" in out[0].events[0])
|
||||
check("...and no failure is recorded", m.store.get_time("last_failed") is None)
|
||||
m = in_charge()
|
||||
res, _, out = run(m, T0, CHARGE_TIMEOUT_MIN + 10, 70.0)
|
||||
check("a pack stuck at 70 % fails when the time is up",
|
||||
out and out[0].phase == IDLE and out[0].failed == "charge")
|
||||
check("...once: the failure is on one tick, and the pack is idle after",
|
||||
m.phase == IDLE and m.tick(out[1] + MIN, 70.0).failed == "")
|
||||
check("...with the old technical line and one plain line",
|
||||
len(out[0].events) == 2 and "ABORT" in out[0].events[0]
|
||||
and "did not finish" in out[0].events[1]
|
||||
and f"{FAIL_BACKOFF_DAYS} days" in out[0].events[1])
|
||||
check("...and last_failed is set, last_completed is not",
|
||||
m.store.get_time("last_failed") == out[1]
|
||||
and m.store.get_time("last_completed") is None)
|
||||
|
||||
print("4. the back-off after a failed cycle")
|
||||
fail_at = out[1]
|
||||
day = lambda n: fail_at.replace(hour=19, minute=0) + timedelta(days=n)
|
||||
check("the next day at the start hour: no cycle (it would begin with a drain)",
|
||||
not m.wants_start(day(1)) and m.tick(day(1), 70.0).phase == IDLE)
|
||||
check("every day for 6 days: still no cycle",
|
||||
all(not m.wants_start(day(n)) for n in range(1, 7)))
|
||||
r = [m.tick(day(n), 70.0) for n in range(1, 7)]
|
||||
check("...and tick() keeps the pack idle and silent",
|
||||
all(x.phase == IDLE and not x.events and x.failed == "" for x in r))
|
||||
until = m.backoff_until(day(1))
|
||||
check("backoff_until() names the date, 7 days after the failure",
|
||||
until == fail_at + timedelta(days=FAIL_BACKOFF_DAYS))
|
||||
check("...it holds one minute before and is over at that minute",
|
||||
m.backoff_until(until - MIN) == until and m.backoff_until(until) is None)
|
||||
check("at 7 days, in the start hour, the pack wants to start again",
|
||||
m.backoff_until(until) is None
|
||||
and m.wants_start(until.replace(hour=19, minute=5)))
|
||||
check("a forced start ignores the back-off",
|
||||
m.force_start(day(1)) and m.phase == DRAIN)
|
||||
m2 = in_charge()
|
||||
run(m2, T0, CHARGE_TIMEOUT_MIN + 10, 70.0)
|
||||
m2.force_start(day(2))
|
||||
m2.tick(day(2) + MIN, 11.0)
|
||||
res, _, out2 = run(m2, day(2) + 2 * MIN, 400, 100.0)
|
||||
check("a forced cycle that completes ends the back-off",
|
||||
out2 and out2[0].phase == HOLD)
|
||||
m2.tick(out2[1] + 130 * MIN, 100.0)
|
||||
check("...(the hold completes, last_completed is set)",
|
||||
m2.store.get_time("last_completed") is not None
|
||||
and m2.backoff_until(day(3)) is None)
|
||||
m3 = in_charge()
|
||||
m3.store.set_time("last_failed", T0)
|
||||
m3.abort(T0 + MIN, "operator")
|
||||
check("an operator abort is not a failure and starts no back-off",
|
||||
m3.store.get_time("last_failed") == T0
|
||||
and in_charge().store.get_time("last_failed") is None)
|
||||
m4 = Maintenance(CFG, Mem())
|
||||
m4.force_start(T0)
|
||||
r = m4.tick(T0 + timedelta(minutes=721), 40.0)
|
||||
check("a drain that times out is a failure too, with the same back-off",
|
||||
r.failed == "drain" and m4.store.get_time("last_failed") is not None
|
||||
and not m4.wants_start(day(1)))
|
||||
|
||||
print("5. only the minutes the pack was allowed to charge count")
|
||||
m = in_charge()
|
||||
# 5 min steps: one tick counts at most 5 min, so this is the same count as
|
||||
# 1 min steps in a fifth of the ticks.
|
||||
res, t, out = run(m, T0, CHARGE_TIMEOUT_MIN * 2, 70.0, gate=False, step=5)
|
||||
check("72 h with the gate closed: not timed out", out is None and m.phase == CHARGE)
|
||||
res, t, out = run(m, t, CHARGE_TIMEOUT_MIN - 5, 70.0, step=5)
|
||||
check("...and 35 h 55 min open on top: still charging", out is None)
|
||||
res, t, out = run(m, t, 10, 70.0)
|
||||
check("...36 h of open time in all: the charge fails", out and out[0].failed == "charge")
|
||||
m = in_charge()
|
||||
WALL = T0 + CHARGE_WALL_TIMEOUT_MIN * MIN
|
||||
res, t, out = run(m, T0, CHARGE_WALL_TIMEOUT_MIN + 10, 70.0, gate=False, step=5)
|
||||
check("a gate that never opens still ends in a failure after 7 days (not before)",
|
||||
out and out[0].failed == "charge" and WALL < out[1] <= WALL + 5 * MIN)
|
||||
m = in_charge()
|
||||
res, t, out = run(m, T0, CHARGE_WALL_TIMEOUT_MIN + 10, 98.0, gate=False, step=5)
|
||||
check("...or in a complete charge when the pack is near full",
|
||||
out and out[0].phase == HOLD and WALL < out[1] <= WALL + 5 * MIN)
|
||||
m = in_charge()
|
||||
res, t, out = run(m, T0, 10, 70.0)
|
||||
before = m._trk["open_min"]
|
||||
res = m.tick(t + 2 * timedelta(hours=1), 70.0)
|
||||
check("a gap in the ticks (add-on down) counts for exactly 5 minutes (the cap)",
|
||||
abs(m._trk["open_min"] - before - 5.0) < 1e-9)
|
||||
m.tick(t + 2 * timedelta(hours=1) + 2 * MIN, 70.0)
|
||||
check("...and a 2 minute step counts 2", abs(m._trk["open_min"] - before - 7.0) < 1e-9)
|
||||
_t = t + 2 * timedelta(hours=1) + 2 * MIN
|
||||
m.tick(_t - 10 * MIN, 70.0) # the clock steps back 10 min
|
||||
check("...and a step back in time counts nothing (not minus)",
|
||||
abs(m._trk["open_min"] - before - 7.0) < 1e-9)
|
||||
|
||||
print("6. restarts and old state files")
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "state.json")
|
||||
st = Store(path)
|
||||
m = Maintenance(CFG, st)
|
||||
m.force_start(T0)
|
||||
m.tick(T0, 11.0)
|
||||
_, t, out = run(m, T0, 60, 70.0, gate=False) # 60 min waiting
|
||||
_, t, out = run(m, t, 40, 70.0) # 40 min charging
|
||||
open_before = m._trk["open_min"]
|
||||
# a fresh process on the same file
|
||||
m_b = Maintenance(CFG, Store(path))
|
||||
r = m_b.tick(t + MIN, 70.0)
|
||||
check("a restart mid-charge keeps the open minutes (40 of 100 min; within "
|
||||
"the 5 min save interval), not the 100 wall-clock minutes",
|
||||
m_b.phase == CHARGE
|
||||
and open_before - 6 <= m_b._trk["open_min"] <= open_before + 2
|
||||
and m_b._trk["open_min"] < 60)
|
||||
_, t2, out = run(m_b, t + 2 * MIN, CHARGE_TIMEOUT_MIN - 30, 70.0, step=5)
|
||||
check("...so the timeout comes after 36 h in all, not 36 h from the restart",
|
||||
out is not None and out[0].failed == "charge")
|
||||
m = Maintenance(CFG, Store(path))
|
||||
m.store.set_time("last_failed", T0)
|
||||
m_c = Maintenance(CFG, Store(path))
|
||||
check("a restart mid back-off keeps it (last_failed is in the file)",
|
||||
m_c.backoff_until(T0 + timedelta(days=3)) == T0 + timedelta(days=7)
|
||||
and not m_c.wants_start((T0 + timedelta(days=3)).replace(hour=19)))
|
||||
check("...and the state file is plain JSON",
|
||||
isinstance(json.load(open(path)).get("last_failed"), str))
|
||||
old = {"phase": "charge",
|
||||
"phase_started": (T0 - timedelta(hours=35)).isoformat(),
|
||||
"last_completed": None, "last_start_attempt": None,
|
||||
"auto": True}
|
||||
path2 = os.path.join(d, "old.json")
|
||||
json.dump(old, open(path2, "w"))
|
||||
mo = Maintenance(CFG, Store(path2))
|
||||
check("a state file from before this change loads with no failure and no "
|
||||
"charge record", mo.backoff_until(T0) is None
|
||||
and mo.store.get("charge_track") is None and mo.phase == CHARGE)
|
||||
r = mo.tick(T0, 70.0)
|
||||
check("...a charge in progress counts the hours it already ran (35 h)",
|
||||
r.phase == CHARGE and 34 * 60 < mo._trk["open_min"] < 36 * 60)
|
||||
res, _, out = run(mo, T0 + MIN, 120, 70.0)
|
||||
check("...so it fails when the 36 h are up, as before",
|
||||
out and out[0].failed == "charge")
|
||||
# A record left by another charge (an add-on rolled back and forward
|
||||
# again does not clear it) must not be read as this charge's.
|
||||
stale = {"phase": "charge",
|
||||
"phase_started": (T0 - timedelta(hours=2)).isoformat(),
|
||||
"charge_track": {"since": (T0 - timedelta(days=30)).isoformat(),
|
||||
"open_min": 2000.0, "flat_min": 59.0,
|
||||
"best": 99.0, "at": None}}
|
||||
ms = Maintenance(CFG, Mem(stale))
|
||||
r = ms.tick(T0, 98.0)
|
||||
check("a charge record from another charge is ignored: this charge counts "
|
||||
"its own 2 h and its own best",
|
||||
r.phase == CHARGE and 119 < ms._trk["open_min"] < 121
|
||||
and ms._trk["flat_min"] == 0.0 and ms._trk["best"] == 98.0)
|
||||
mi = Maintenance(CFG, Store(os.path.join(d, "none.json")))
|
||||
check("no state file: due, no back-off", mi.due(T0) and mi.backoff_until(T0) is None)
|
||||
|
||||
print("7. two packs (N=2)")
|
||||
a, b = Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a"), \
|
||||
Maintenance(CFG, Mem(), unit="inverter-b", name="inverter-b")
|
||||
b.force_start(T0)
|
||||
b.tick(T0, 11.0)
|
||||
_, t, out = run(b, T0, CHARGE_TIMEOUT_MIN + 10, 70.0)
|
||||
slot = datetime(2026, 10, 7, 19, 5, tzinfo=UTC)
|
||||
check("B failed: B is backed off, A (due) is not",
|
||||
out and out[0].failed == "charge" and not b.wants_start(slot)
|
||||
and a.wants_start(slot))
|
||||
check("...so A takes the slot, and B is not named as waiting for anyone",
|
||||
arbitrate([a, b], slot) == ["", ""])
|
||||
a.force_start(slot)
|
||||
check("...and while A cycles, B is held as before",
|
||||
"inverter-a" in arbitrate([a, b], slot + 3 * MIN)[1])
|
||||
check("B's failure is under B's own keys, not A's",
|
||||
b.store.get_time("last_failed") is not None
|
||||
and a.store.get_time("last_failed") is None)
|
||||
mst = Mem({"last_failed": T0.isoformat(), "phase": "idle"})
|
||||
migrate_maint_keys(mst, "inverter-a", ["inverter-a", "inverter-b"])
|
||||
check("going from one pack to two moves the back-off to the first pack",
|
||||
mst.data.get(unit_key("inverter-a", "last_failed")) == T0.isoformat()
|
||||
and "last_failed" in MAINT_UNIT_KEYS)
|
||||
|
||||
|
||||
print("8. the Controller: gate, notification, status row")
|
||||
|
||||
|
||||
class Hass:
|
||||
def __init__(self, numbers, limits):
|
||||
self.numbers, self.limits_by, self.calls = dict(numbers), dict(limits), []
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
v = self.numbers.get(entity)
|
||||
return None if v is None else (-v if invert else v)
|
||||
|
||||
async def limits(self, entity):
|
||||
return self.limits_by.get(entity, (None, None, None))
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
return True
|
||||
|
||||
async def call(self, domain, service, data):
|
||||
self.calls.append((domain, service, data))
|
||||
return True
|
||||
|
||||
|
||||
class Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, *a, **k):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
OPTS1 = dict(setpoint_entity="number.sp_a", soc_entity="sensor.soc_a",
|
||||
batt_entity="sensor.batt_a", meter_source="off",
|
||||
meter_entity="sensor.grid", max_w=5000.0, gain=0.6, slew_w=6000,
|
||||
deadband_w=15, target_grid_w=0, step_w=10, heartbeat_s=10,
|
||||
stale_input_s=15, maintenance_discharge_w=2000,
|
||||
maintenance_charge_w=2000, maintenance_enabled=True,
|
||||
maintenance_soc_target=100,
|
||||
price_now_entity="sensor.p_now", price_avg_entity="sensor.p_avg")
|
||||
OPTS2 = {k: v for k, v in OPTS1.items()
|
||||
if k not in ("setpoint_entity", "soc_entity", "batt_entity")}
|
||||
OPTS2["inverters"] = [
|
||||
dict(name="inverter-a", setpoint_entity="number.sp_a",
|
||||
soc_entity="sensor.soc_a", batt_entity="sensor.batt_a", max_w=5000.0),
|
||||
dict(name="inverter-b", setpoint_entity="number.sp_b",
|
||||
soc_entity="sensor.soc_b", batt_entity="sensor.batt_b", max_w=3000.0)]
|
||||
|
||||
|
||||
def controller(opts, cheap, soc_a=70.0, soc_b=70.0):
|
||||
p_now, p_avg = (0.05, 0.10) if cheap else (0.20, 0.10)
|
||||
hass = Hass({"sensor.grid": 800.0, "sensor.soc_a": soc_a,
|
||||
"sensor.batt_a": 0.0, "sensor.soc_b": soc_b,
|
||||
"sensor.batt_b": 0.0, "sensor.p_now": p_now,
|
||||
"sensor.p_avg": p_avg},
|
||||
{"number.sp_a": (-5000.0, 5000.0, 1.0),
|
||||
"number.sp_b": (-3000.0, 3000.0, 1.0)})
|
||||
c = Controller(dict(opts), hass, Mem(), Pub(), stop=asyncio.Event())
|
||||
for inv in c.inverters:
|
||||
inv.dev_min, inv.dev_max = -inv.max_w, inv.max_w
|
||||
c.auto = True
|
||||
return c, hass
|
||||
|
||||
|
||||
def one_cycle(c):
|
||||
async def go():
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def put_in_charge(c, which, hours_ago, open_min):
|
||||
now = datetime.now(UTC)
|
||||
m = c.maints[which]
|
||||
m.store.set("phase", CHARGE)
|
||||
m.store.set_time("phase_started", now - timedelta(hours=hours_ago))
|
||||
m.store.set("charge_track", {
|
||||
"since": m.store.get("phase_started"), "open_min": open_min,
|
||||
"flat_min": 0.0, "best": 70.0,
|
||||
"at": (now - timedelta(minutes=3)).isoformat()})
|
||||
return m
|
||||
|
||||
|
||||
def notes(h):
|
||||
return [x for x in h.calls if x[0] == "persistent_notification"]
|
||||
|
||||
|
||||
for cheap in (True, False):
|
||||
c, h = controller(OPTS1, cheap)
|
||||
m = put_in_charge(c, 0, 5, 100.0)
|
||||
one_cycle(c)
|
||||
got = m._trk["open_min"] - 100.0
|
||||
check(f"N=1, price gate {'open' if cheap else 'closed'}: the Controller "
|
||||
f"{'adds' if cheap else 'does not add'} the 3 minutes since the last "
|
||||
f"tick to the charging time (+{got:.1f})",
|
||||
(2.5 < got < 3.5) if cheap else abs(got) < 0.01)
|
||||
|
||||
c, h = controller(OPTS1, True)
|
||||
m = put_in_charge(c, 0, 40, CHARGE_TIMEOUT_MIN + 1)
|
||||
one_cycle(c)
|
||||
n = notes(h)
|
||||
check("N=1: a charge past its time at 70 % ends in IDLE with ONE notification",
|
||||
m.phase == IDLE and len(n) == 1
|
||||
and n[0][2]["notification_id"] == "goodwe_maint_failed_battery"
|
||||
and n[0][2]["title"] == "Battery cycle did not finish")
|
||||
one_cycle(c)
|
||||
one_cycle(c)
|
||||
check("...and later cycles send no second one, and start no drain",
|
||||
len(notes(h)) == 1 and m.phase == IDLE)
|
||||
check("...the status page says when it waits until",
|
||||
any(k == "Maintenance paused" and "Waits until" in v
|
||||
for k, v in c.status_payload()["rows"]))
|
||||
c0, _h0 = controller(OPTS1, True)
|
||||
check("...and has no such row when nothing failed",
|
||||
not any(k == "Maintenance paused" for k, v in c0.status_payload()["rows"]))
|
||||
|
||||
c, h = controller(OPTS2, True)
|
||||
m = put_in_charge(c, 1, 40, CHARGE_TIMEOUT_MIN + 1)
|
||||
one_cycle(c)
|
||||
n = notes(h)
|
||||
check("N=2: pack B fails, B alone is told, by name, under its own id",
|
||||
c.maints[1].phase == IDLE and c.maints[0].phase == IDLE and len(n) == 1
|
||||
and n[0][2]["notification_id"] == "goodwe_maint_failed_inverter_b"
|
||||
and "inverter-b" in n[0][2]["message"])
|
||||
check("...and its failure is on B's keys",
|
||||
c.maints[1].store.get_time("last_failed") is not None
|
||||
and c.maints[0].store.get_time("last_failed") is None)
|
||||
|
||||
# a site with no price entities and no fixed tariff: nothing holds the charge back
|
||||
NOTARIFF = {k: v for k, v in OPTS1.items()
|
||||
if k not in ("price_now_entity", "price_avg_entity")}
|
||||
c, h = controller(NOTARIFF, False)
|
||||
m = put_in_charge(c, 0, 5, 100.0)
|
||||
one_cycle(c)
|
||||
got = m._trk["open_min"] - 100.0
|
||||
check("N=1, no tariff and no price entities: the gate counts as open, so the "
|
||||
f"3 minutes count (+{got:.1f}) although `cheap` is false",
|
||||
not c.cheap and 2.5 < got < 3.5)
|
||||
c, h = controller(NOTARIFF, False)
|
||||
m = put_in_charge(c, 0, 40, CHARGE_TIMEOUT_MIN + 1)
|
||||
one_cycle(c)
|
||||
check("...and a pack past its charging time there fails at once (not 7 days)",
|
||||
m.phase == IDLE and len(notes(h)) == 1)
|
||||
# a site with a price provider (TARIFF-11) and no fixed tariff: the provider's
|
||||
# plan reaches only the Tariff row, never `cheap`, so no cheap window ever
|
||||
# opens a grid top-up there. Like the site above, nothing gate-like holds the
|
||||
# charge back, and the time counts.
|
||||
PROVIDER = dict(NOTARIFF, price_provider="generic_dayahead", static_profile="")
|
||||
c, h = controller(PROVIDER, False)
|
||||
m = put_in_charge(c, 0, 5, 100.0)
|
||||
one_cycle(c)
|
||||
got = m._trk["open_min"] - 100.0
|
||||
check("N=1, price provider and no fixed tariff: not a gate (`cheap` is never "
|
||||
f"set from the provider), so the 3 minutes count (+{got:.1f})",
|
||||
c._price_gated is False and not c.cheap
|
||||
and c._charge_gate_open(c.inverters[0]) is True and 2.5 < got < 3.5)
|
||||
def read(c):
|
||||
asyncio.run(c.read_inputs())
|
||||
|
||||
|
||||
c, h = controller(OPTS1, False)
|
||||
read(c)
|
||||
c.inverters[0].batt = -800.0
|
||||
check("price gate closed but the pack is taking 800 W (sun): gate open",
|
||||
c._charge_gate_open(c.inverters[0]) is True)
|
||||
c.inverters[0].batt = -10.0
|
||||
check("...closed and about idle: gate closed",
|
||||
c._charge_gate_open(c.inverters[0]) is False)
|
||||
c.inverters[0].batt = None
|
||||
check("...no battery reading: gate closed", c._charge_gate_open(c.inverters[0]) is False)
|
||||
c, h = controller(OPTS1, True)
|
||||
read(c)
|
||||
check("price gate open: gate open", c._charge_gate_open(c.inverters[0]) is True)
|
||||
PROFILE = json.dumps({"periods": [
|
||||
{"name": "day", "from": "07:00", "to": "22:00", "days": "all",
|
||||
"eur_per_kwh": 0.29},
|
||||
{"name": "night", "from": "22:00", "to": "07:00", "days": "all",
|
||||
"eur_per_kwh": 0.19}]})
|
||||
c, h = controller(dict(NOTARIFF, price_provider="off", static_profile=PROFILE),
|
||||
False)
|
||||
read(c)
|
||||
check("a fixed tariff set up counts as a gate: outside its cheap hours a pack "
|
||||
"that is not charging is closed, inside them it is open",
|
||||
c._price_gated is True
|
||||
and c._charge_gate_open(c.inverters[0]) is c.cheap)
|
||||
import app.main as main_mod # noqa: E402
|
||||
_real_gated, _calls = main_mod.static_gated, []
|
||||
main_mod.static_gated = lambda o, now: _calls.append(now) or _real_gated(o, now)
|
||||
try:
|
||||
c._gated_key = None
|
||||
read(c)
|
||||
read(c)
|
||||
read(c)
|
||||
c._static_gated(c.o, datetime.now(UTC) + timedelta(hours=1))
|
||||
finally:
|
||||
main_mod.static_gated = _real_gated
|
||||
check("...worked out once an hour, not on every tick (3 reads in one hour = 1 "
|
||||
f"build; a new hour = 1 more): {len(_calls)} builds",
|
||||
len(_calls) == 2 and c._price_gated is True)
|
||||
|
||||
# the save timer is reset on a phase change
|
||||
mm = in_charge()
|
||||
mm.tick(T0 + MIN, 70.0)
|
||||
saved_before = mm._trk_saved
|
||||
mm.abort(T0 + 2 * MIN, "operator")
|
||||
check("leaving CHARGE resets the save timer along with the record",
|
||||
saved_before is not None and mm._trk is None and mm._trk_saved is None)
|
||||
|
||||
print("9. the words")
|
||||
en, nl = messages.maint_failed("charge", 7), messages.maint_failed("drain", 7)
|
||||
for n_ in (en, nl):
|
||||
print(" " + n_.title + " | " + n_.body + " | " + n_.body_nl)
|
||||
check("EN charge text", en.body == "The battery did not fill up, so its cycle "
|
||||
"stopped and tries again in 7 days.")
|
||||
check("EN drain text", nl.body == "The battery did not empty, so its cycle "
|
||||
"stopped and tries again in 7 days.")
|
||||
check("NL texts", en.body_nl == "Batterijcyclus niet afgerond. De batterij "
|
||||
"raakte niet vol, dus de cyclus is gestopt en probeert het over 7 dagen "
|
||||
"opnieuw." and nl.body_nl.endswith("raakte niet leeg, dus de cyclus is "
|
||||
"gestopt en probeert het over 7 dagen opnieuw."))
|
||||
check("the notification carries EN, NL and the Details line, in that order",
|
||||
re.fullmatch(r"The battery .*\n\nBatterijcyclus .*\n\nDetails: .*",
|
||||
en.data()["message"], re.S) is not None)
|
||||
check("the DOCS section and table row exist",
|
||||
"### Battery cycle did not finish" in (HERE / "DOCS.md").read_text()
|
||||
and "goodwe_maint_failed_<name>" in (HERE / "DOCS.md").read_text())
|
||||
check("no new option: the numbers are constants",
|
||||
(CHARGE_NEAR_FULL_PCT, CHARGE_STALL_MIN, FAIL_BACKOFF_DAYS)
|
||||
== (3, 60, 7))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all passed")
|
||||
@@ -0,0 +1,403 @@
|
||||
"""MAINT-14: maintenance with no SoC reading.
|
||||
`python3 test_maint14.py`
|
||||
|
||||
Graded here: charge and drain with SoC None at one and two packs - the charge
|
||||
timeout and 7 day backstop still run (failure, or done when the last SoC was
|
||||
>= target - 3), the drain holds at 0 W and times out, SoC coming back resumes
|
||||
normally with no back-off, the log lines (once per episode), a restart while the
|
||||
SoC is missing, and one pack's missing SoC leaving the other pack alone.
|
||||
See DECISIONS/MAINT-14-no-soc-in-maintenance.md.
|
||||
|
||||
Mutating what this file grades? Read MUTATION.md first (stale bytecode).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.maintenance import (
|
||||
CHARGE, CHARGE_NEAR_FULL_PCT, CHARGE_TIMEOUT_MIN, CHARGE_WALL_TIMEOUT_MIN,
|
||||
DRAIN, DRAIN_TIMEOUT_MIN, FAIL_BACKOFF_DAYS, HOLD, IDLE, NOSOC_NOTE_MIN,
|
||||
MaintConfig, Maintenance, arbitrate,
|
||||
)
|
||||
from app.store import DEFAULTS, Store
|
||||
|
||||
UTC = timezone.utc
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
CFG = MaintConfig(enabled=True, interval_days=28, start_hour=19,
|
||||
discharge_w=2000, charge_w=2000, soc_floor=11,
|
||||
soc_target=100, hold_min=120)
|
||||
T0 = datetime(2026, 10, 4, 18, 24, tzinfo=UTC)
|
||||
MIN = timedelta(minutes=1)
|
||||
|
||||
|
||||
class Mem:
|
||||
def __init__(self, data=None):
|
||||
self.data = dict(DEFAULTS, **(data or {}))
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def set_time(self, k, when):
|
||||
self.data[k] = when.astimezone(UTC).isoformat() if when else None
|
||||
|
||||
def get_time(self, k):
|
||||
raw = self.data.get(k)
|
||||
return datetime.fromisoformat(raw) if raw else None
|
||||
|
||||
def save(self):
|
||||
pass
|
||||
|
||||
|
||||
def in_drain(store=None, unit=None, at=T0):
|
||||
m = Maintenance(CFG, store or Mem(), unit=unit, name=unit or "")
|
||||
m.force_start(at)
|
||||
assert m.phase == DRAIN
|
||||
return m
|
||||
|
||||
|
||||
def in_charge(store=None, unit=None, at=T0):
|
||||
m = in_drain(store, unit, at)
|
||||
m.tick(at, 11.0)
|
||||
assert m.phase == CHARGE
|
||||
return m
|
||||
|
||||
|
||||
def run(m, start, minutes, soc, gate=True, step=1):
|
||||
"""Tick every `step` min; returns (last result, end time, first result that
|
||||
is not the starting phase with its time, all events)."""
|
||||
ph, t, res, out, ev = m.phase, start, None, None, []
|
||||
for i in range(0, minutes, step):
|
||||
t = start + i * MIN
|
||||
s = soc(i) if callable(soc) else soc
|
||||
res = m.tick(t, s, gate_open=gate)
|
||||
ev += res.events
|
||||
if out is None and res.phase != ph:
|
||||
out = (res, t)
|
||||
break
|
||||
return res, start + minutes * MIN, out, ev
|
||||
|
||||
|
||||
print("1. charge with no SoC")
|
||||
m = in_charge()
|
||||
res, _, out, _ = run(m, T0, 600, None)
|
||||
check("it keeps charging: the loop owns the setpoint, no start of a new phase",
|
||||
out is None and res.phase == CHARGE and res.charge_only
|
||||
and not res.owns_setpoint)
|
||||
|
||||
m = in_charge()
|
||||
run(m, T0, 60, 60.0)
|
||||
res, _, out, ev = run(m, T0 + 60 * MIN, CHARGE_TIMEOUT_MIN + 10, None)
|
||||
check("the charge timeout still runs: it fails once the time is up (last SoC 60)",
|
||||
out and out[0].failed == "charge" and out[0].phase == IDLE)
|
||||
check("...with the MAINT-13 back-off and its one plain line",
|
||||
m.store.get_time("last_failed") is not None
|
||||
and sum("did not finish" in e for e in ev) == 1
|
||||
and f"{FAIL_BACKOFF_DAYS} days" in ev[-1])
|
||||
check("...it took 36 h of allowed time from the charge start, not more",
|
||||
out and out[1] <= T0 + timedelta(minutes=CHARGE_TIMEOUT_MIN + 10))
|
||||
check("...and the pack does not want a start in the back-off",
|
||||
not m.wants_start((T0 + timedelta(days=3)).replace(hour=19)))
|
||||
|
||||
m = in_charge()
|
||||
res, _, out, _ = run(m, T0, CHARGE_TIMEOUT_MIN + 10, None)
|
||||
check("never saw a SoC: the timeout is a failure, not 'done'",
|
||||
out and out[0].failed == "charge")
|
||||
|
||||
for last in (97.0, 99.0):
|
||||
m = in_charge()
|
||||
run(m, T0, 10, last)
|
||||
res, _, out, ev = run(m, T0 + 10 * MIN, CHARGE_TIMEOUT_MIN + 20, None)
|
||||
check(f"last SoC {last:.0f} (>= target - {CHARGE_NEAR_FULL_PCT}): time up counts as done",
|
||||
out and out[0].phase == HOLD and out[0].failed == ""
|
||||
and m.store.get_time("last_failed") is None)
|
||||
check("...one plain line says so",
|
||||
sum("counts as done" in e for e in ev) == 1
|
||||
and f"{last:.0f} %" in " ".join(ev))
|
||||
m = in_charge()
|
||||
run(m, T0, 10, 96.0)
|
||||
res, _, out, _ = run(m, T0 + 10 * MIN, CHARGE_TIMEOUT_MIN + 20, None)
|
||||
check("last SoC 96 (below target - 3): time up is a failure",
|
||||
out and out[0].failed == "charge")
|
||||
m = in_charge()
|
||||
run(m, T0, 10, 99.0)
|
||||
run(m, T0 + 10 * MIN, 10, 50.0)
|
||||
res, _, out, _ = run(m, T0 + 20 * MIN, CHARGE_TIMEOUT_MIN + 20, None)
|
||||
check("'last seen' is the last reading, not the best (99 then 50: failure)",
|
||||
out and out[0].failed == "charge")
|
||||
|
||||
print("2. the 7 day backstop, with the gate shut")
|
||||
m = in_charge()
|
||||
run(m, T0, 10, 70.0)
|
||||
res, _, out, _ = run(m, T0 + 10 * MIN, CHARGE_WALL_TIMEOUT_MIN + 30, None,
|
||||
gate=False, step=5)
|
||||
check("gate never opens, last SoC 70: failure at 7 days",
|
||||
out and out[0].failed == "charge"
|
||||
and out[1] >= T0 + timedelta(minutes=CHARGE_WALL_TIMEOUT_MIN)
|
||||
and out[1] <= T0 + timedelta(minutes=CHARGE_WALL_TIMEOUT_MIN + 10))
|
||||
m = in_charge()
|
||||
run(m, T0, 10, 98.0)
|
||||
res, _, out, _ = run(m, T0 + 10 * MIN, CHARGE_WALL_TIMEOUT_MIN + 30, None,
|
||||
gate=False, step=5)
|
||||
check("gate never opens, last SoC 98: done at 7 days",
|
||||
out and out[0].phase == HOLD and out[0].failed == ""
|
||||
and out[1] >= T0 + timedelta(minutes=CHARGE_WALL_TIMEOUT_MIN))
|
||||
m = in_charge()
|
||||
run(m, T0, 10, 70.0)
|
||||
res, _, out, _ = run(m, T0 + 10 * MIN, 3000, None, gate=False, step=5)
|
||||
check("gate shut, no SoC: the open-time clock does not run (no early end)",
|
||||
out is None)
|
||||
|
||||
print("3. SoC comes back mid-charge")
|
||||
m = in_charge()
|
||||
run(m, T0, 30, 80.0)
|
||||
run(m, T0 + 30 * MIN, 600, None)
|
||||
res, _, out, _ = run(m, T0 + 630 * MIN, 5, 80.0)
|
||||
check("normal behaviour resumes: still charging, nothing recorded",
|
||||
out is None and res.phase == CHARGE
|
||||
and m.store.get_time("last_failed") is None)
|
||||
res, _, out, _ = run(m, T0 + 640 * MIN, 10, 100.0)
|
||||
check("...and it completes at the target as usual",
|
||||
out and out[0].phase == HOLD and m.store.get_time("last_failed") is None)
|
||||
m = in_charge()
|
||||
run(m, T0, 30, 98.0)
|
||||
run(m, T0 + 30 * MIN, 600, None)
|
||||
res, _, out, _ = run(m, T0 + 630 * MIN, 30, 98.0)
|
||||
check("the stall wait paused while the SoC was missing (30 min flat after the "
|
||||
"gap is not 60)", out is None)
|
||||
res, _, out, _ = run(m, T0 + 660 * MIN, 40, 98.0)
|
||||
check("...and completes once 60 min flat are in", out and out[0].phase == HOLD)
|
||||
m = in_charge()
|
||||
run(m, T0, 10, 70.0)
|
||||
run(m, T0 + 10 * MIN, CHARGE_TIMEOUT_MIN - 100, None)
|
||||
res, _, out, _ = run(m, T0 + (CHARGE_TIMEOUT_MIN - 90) * MIN, 5, 70.0)
|
||||
check("a gap just before the limit then a reading: no failure, no back-off",
|
||||
out is None and m.store.get_time("last_failed") is None)
|
||||
|
||||
print("4. drain with no SoC")
|
||||
m = in_drain()
|
||||
run(m, T0, 30, 60.0)
|
||||
res, _, out, ev = run(m, T0 + 30 * MIN, 60, None)
|
||||
check("DRAIN holds at 0 W, owned by maintenance, the same as HOLD",
|
||||
out is None and res.phase == DRAIN and res.owns_setpoint
|
||||
and res.setpoint_w == 0.0 and not res.charge_only)
|
||||
hold = in_charge()
|
||||
hold.tick(T0 + MIN, 100.0)
|
||||
hr = hold.tick(T0 + 2 * MIN, None)
|
||||
check("...the same answer as HOLD gives",
|
||||
(hr.owns_setpoint, hr.setpoint_w) == (res.owns_setpoint, res.setpoint_w))
|
||||
check("never above 0 W on any missing tick",
|
||||
all(m.tick(T0 + (100 + i) * MIN, None).setpoint_w == 0.0 for i in range(30)))
|
||||
check("one plain line that the drain holds, once, not per tick",
|
||||
sum("drain holds at 0 W" in e for e in ev) == 1)
|
||||
check("one plain line after the threshold, once",
|
||||
sum("no battery level for" in e for e in ev) == 1
|
||||
and f"{NOSOC_NOTE_MIN} min" in " ".join(ev))
|
||||
m = in_drain()
|
||||
res, _, out, ev = run(m, T0, NOSOC_NOTE_MIN - 1, None)
|
||||
check("under the threshold the 'no level for long' line is not said",
|
||||
not any("no battery level for" in e for e in ev))
|
||||
m = in_drain()
|
||||
res, _, out, ev = run(m, T0, DRAIN_TIMEOUT_MIN + 10, None)
|
||||
check("the 12 h drain timeout still runs: failure with back-off",
|
||||
out and out[0].failed == "drain" and out[0].phase == IDLE
|
||||
and out[1] > T0 + timedelta(minutes=DRAIN_TIMEOUT_MIN)
|
||||
and m.store.get_time("last_failed") is not None)
|
||||
m = in_drain()
|
||||
run(m, T0, 60, None)
|
||||
res, _, out, _ = run(m, T0 + 60 * MIN, 5, 60.0)
|
||||
check("SoC back: the drain continues at the drain rate",
|
||||
out is None and res.phase == DRAIN and res.owns_setpoint
|
||||
and res.setpoint_w == CFG.discharge_w)
|
||||
res, _, out, _ = run(m, T0 + 70 * MIN, 5, 10.0)
|
||||
check("...and ends at the floor as usual",
|
||||
out and out[0].phase == CHARGE and m.store.get_time("last_failed") is None)
|
||||
m = in_drain()
|
||||
run(m, T0, 60, None)
|
||||
run(m, T0 + 60 * MIN, 5, 60.0)
|
||||
res, _, out, ev = run(m, T0 + 70 * MIN, 5, None)
|
||||
check("a second episode says its lines again",
|
||||
any("drain holds at 0 W" in e for e in ev))
|
||||
|
||||
print("5. the log is once per episode")
|
||||
m = in_charge()
|
||||
res, _, out, ev = run(m, T0, 300, None)
|
||||
check("a 5 h charge episode: exactly one 'no level for long' line, none per tick",
|
||||
len(ev) == 1 and "no battery level for" in ev[0])
|
||||
run(m, T0 + 300 * MIN, 3, 70.0)
|
||||
res, _, out, ev = run(m, T0 + 303 * MIN, 60, None)
|
||||
check("...and one more for a new episode", len(ev) == 1)
|
||||
m = in_charge()
|
||||
res, _, out, ev = run(m, T0, 5, None)
|
||||
check("a 5 min gap says nothing", ev == [])
|
||||
|
||||
print("5b. the words per phase")
|
||||
m = in_charge()
|
||||
m.tick(T0 + MIN, 100.0)
|
||||
_, _, _, ev = run(m, T0 + 2 * MIN, 30, None)
|
||||
note = [e for e in ev if "no battery level for" in e]
|
||||
check("HOLD: the line exists and says the hold ends at its normal time (MAINT-15)",
|
||||
len(note) == 1 and "normal time" in note[0] and "waits" not in note[0])
|
||||
m = in_charge()
|
||||
_, _, _, ev = run(m, T0, 30, None)
|
||||
check("charge keeps its wording", any("time limit of the charge" in e for e in ev))
|
||||
m = in_charge()
|
||||
run(m, T0, 10, 70.0)
|
||||
_, _, out, ev = run(m, T0 + 10 * MIN, CHARGE_WALL_TIMEOUT_MIN + 30, None,
|
||||
gate=False, step=5)
|
||||
fail = [e for e in ev if "ABORT" in e]
|
||||
check("7-day backstop: the reason names the 7-day limit, not '0 h'",
|
||||
len(fail) == 1 and "7-day limit" in fail[0] and "0 h" not in fail[0])
|
||||
m = in_charge()
|
||||
_, _, out, ev = run(m, T0, CHARGE_TIMEOUT_MIN + 10, None)
|
||||
fail = [e for e in ev if "ABORT" in e]
|
||||
check("charge-time limit: the reason gives the hours of charging time",
|
||||
len(fail) == 1 and "36 h of charging time" in fail[0])
|
||||
m = in_charge()
|
||||
run(m, T0, 10, 98.0)
|
||||
_, _, out, _ = run(m, T0 + 10 * MIN, CHARGE_TIMEOUT_MIN + 20, None)
|
||||
_, _, _, ev = run(m, out[1] + MIN, 30, None)
|
||||
check("charge done with no SoC, then HOLD with no SoC: a new episode, so the "
|
||||
"HOLD line is said", out[0].phase == HOLD
|
||||
and sum("normal time" in e for e in ev) == 1)
|
||||
|
||||
print("6. restart while the SoC is missing")
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "state.json")
|
||||
m = Maintenance(CFG, Store(path))
|
||||
m.force_start(T0)
|
||||
m.tick(T0, 11.0)
|
||||
run(m, T0, 97, lambda i: 90.0 if i < 96 else 98.0) # 98 seen 1 min ago
|
||||
run(m, T0 + 97 * MIN, 3, None) # SoC lost; state saved at once
|
||||
t = T0 + 100 * MIN # restart 3 min after the loss, before the next 5 min save
|
||||
m2 = Maintenance(CFG, Store(path))
|
||||
r = m2.tick(t, None)
|
||||
check("a fresh process reads charge, and still charges", m2.phase == CHARGE
|
||||
and r.phase == CHARGE and r.charge_only)
|
||||
check("...with the open minutes and the last SoC seen from the file",
|
||||
m2._trk is not None and m2._trk["last"] == 98.0
|
||||
and 92 <= m2._trk["open_min"] <= 105)
|
||||
res, _, out, _ = run(m2, t + MIN, CHARGE_TIMEOUT_MIN, None, step=5)
|
||||
check("...so the timeout counts from the first charge minute and, with the "
|
||||
"last SoC 98, completes (no failure)",
|
||||
out and out[0].phase == HOLD and out[0].failed == ""
|
||||
and out[1] < T0 + timedelta(minutes=CHARGE_TIMEOUT_MIN + 60))
|
||||
# drain: restart keeps it at 0 W and keeps the clock
|
||||
path2 = os.path.join(d, "s2.json")
|
||||
md = Maintenance(CFG, Store(path2))
|
||||
md.force_start(T0)
|
||||
md.tick(T0 + 100 * MIN, None)
|
||||
md2 = Maintenance(CFG, Store(path2))
|
||||
r = md2.tick(T0 + 101 * MIN, None)
|
||||
check("a drain restarted with no SoC holds at 0 W", md2.phase == DRAIN
|
||||
and r.setpoint_w == 0.0 and r.owns_setpoint)
|
||||
res, _, out, _ = run(md2, T0 + 102 * MIN, DRAIN_TIMEOUT_MIN, None, step=5)
|
||||
check("...and the 12 h clock is from the drain start, not the restart",
|
||||
out and out[0].failed == "drain"
|
||||
and out[1] <= T0 + timedelta(minutes=DRAIN_TIMEOUT_MIN + 10))
|
||||
# a charge restarted with no record of any SoC (old state file)
|
||||
old = {"phase": "charge", "phase_started": (T0 - timedelta(hours=35)).isoformat(),
|
||||
"last_completed": None, "last_start_attempt": None, "auto": True}
|
||||
import json
|
||||
path3 = os.path.join(d, "s3.json")
|
||||
json.dump(old, open(path3, "w"))
|
||||
mo = Maintenance(CFG, Store(path3))
|
||||
res, _, out, _ = run(mo, T0, 120, None)
|
||||
check("an old state file with no SoC seen: the charge fails at 36 h",
|
||||
out and out[0].failed == "charge")
|
||||
# a MAINT-13 state file: charge_track with a best but no `last`
|
||||
since = (T0 - timedelta(hours=35)).isoformat()
|
||||
m13 = dict(old, phase_started=since, charge_track={
|
||||
"since": since, "open_min": 2100.0, "flat_min": 0.0, "best": 99.0,
|
||||
"at": (T0 - MIN).isoformat()})
|
||||
path4 = os.path.join(d, "s4.json")
|
||||
json.dump(m13, open(path4, "w"))
|
||||
mo = Maintenance(CFG, Store(path4))
|
||||
res, _, out, _ = run(mo, T0, 120, None)
|
||||
check("a MAINT-13 state file (no last SoC): no crash, the charge fails at "
|
||||
"36 h (best is not last)", out and out[0].failed == "charge")
|
||||
|
||||
print("7. two packs")
|
||||
a, b = (Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a"),
|
||||
Maintenance(CFG, Mem(), unit="inverter-b", name="inverter-b"))
|
||||
a.force_start(T0)
|
||||
# the twin site: the same two packs, A healthy (SoC 60, still above the floor)
|
||||
a_ok, b_alone = (Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a"),
|
||||
Maintenance(CFG, Mem(), unit="inverter-b", name="inverter-b"))
|
||||
a_ok.force_start(T0)
|
||||
same = True
|
||||
for i in range(0, 120, 10):
|
||||
t = T0 + i * MIN
|
||||
plan = arbitrate([a, b], t)
|
||||
plan0 = arbitrate([a_ok, b_alone], t)
|
||||
same = same and plan[1] == plan0[1]
|
||||
ra = a.tick(t, None, blocked_by=plan[0])
|
||||
rb = b.tick(t, 55.0, blocked_by=plan[1])
|
||||
a_ok.tick(t, 60.0, blocked_by=plan0[0])
|
||||
rb0 = b_alone.tick(t, 55.0, blocked_by=plan0[1])
|
||||
same = same and (rb.phase, rb.setpoint_w, rb.deferred) == \
|
||||
(rb0.phase, rb0.setpoint_w, rb0.deferred)
|
||||
check("A drains with no SoC at 0 W while B (SoC 55, idle) is as it is when A "
|
||||
"is healthy", ra.setpoint_w == 0.0 and ra.owns_setpoint and rb.phase == IDLE
|
||||
and rb.setpoint_w is None and not rb.owns_setpoint)
|
||||
check("...B's answers are unchanged tick by tick", same)
|
||||
res = None
|
||||
for i in range(0, DRAIN_TIMEOUT_MIN + 30, 10):
|
||||
t = T0 + 120 * MIN + i * MIN
|
||||
plan = arbitrate([a, b], t)
|
||||
res = a.tick(t, None, blocked_by=plan[0])
|
||||
b.tick(t, 55.0, blocked_by=plan[1])
|
||||
if res.failed:
|
||||
break
|
||||
check("A's drain times out and A backs off (A's keys only)",
|
||||
res.failed == "drain" and a.store.get_time("last_failed") is not None
|
||||
and b.store.get_time("last_failed") is None)
|
||||
slot = (T0 + timedelta(days=1)).replace(hour=19, minute=5)
|
||||
check("...then B, with its SoC, takes the slot as usual",
|
||||
arbitrate([a, b], slot) == ["", ""] and b.wants_start(slot)
|
||||
and not a.wants_start(slot))
|
||||
b.force_start(slot)
|
||||
rb = b.tick(slot + 5 * MIN, 50.0)
|
||||
check("...and B's drain runs at the drain rate",
|
||||
rb.phase == DRAIN and rb.setpoint_w == CFG.discharge_w)
|
||||
# the other way: B charges with no SoC, A has a SoC
|
||||
a = Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a")
|
||||
b = Maintenance(CFG, Mem(), unit="inverter-b", name="inverter-b")
|
||||
b.force_start(T0)
|
||||
b.tick(T0, 11.0)
|
||||
run(b, T0, 5, 97.0)
|
||||
a_alone = Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a")
|
||||
same, t = True, T0 + 10 * MIN
|
||||
while t < T0 + timedelta(minutes=CHARGE_TIMEOUT_MIN + 30):
|
||||
plan = arbitrate([a, b], t)
|
||||
rb = b.tick(t, None, blocked_by=plan[1])
|
||||
ra = a.tick(t, 60.0, blocked_by=plan[0])
|
||||
ra0 = a_alone.tick(t, 60.0, blocked_by=plan[0])
|
||||
same = same and (ra.phase, ra.setpoint_w, ra.deferred) == \
|
||||
(ra0.phase, ra0.setpoint_w, ra0.deferred)
|
||||
if rb.phase != CHARGE:
|
||||
break
|
||||
t += 5 * MIN
|
||||
check("B charges with no SoC (last 97): A (SoC 60) is unchanged tick by tick",
|
||||
same)
|
||||
check("...B ends as done (HOLD), A untouched, no failure on either",
|
||||
rb.phase == HOLD and rb.failed == ""
|
||||
and b.store.get_time("last_failed") is None
|
||||
and a.store.get_time("last_failed") is None and a.phase == IDLE)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all passed")
|
||||
@@ -0,0 +1,299 @@
|
||||
"""MAINT-15: the HOLD ends on time with no SoC reading.
|
||||
`python3 test_maint15.py`
|
||||
|
||||
Graded here: a HOLD that loses its SoC (at the start, mid-hold, just before
|
||||
hold_min) ends on time, writes last_completed and says the COMPLETE line; SoC
|
||||
back mid-hold changes nothing; HOLD stays 0 W; one pack's loss leaves the other
|
||||
pack alone; a restart in HOLD with no SoC and an old state file.
|
||||
See DECISIONS/MAINT-15-hold-ends-without-soc.md.
|
||||
|
||||
Mutating what this file grades? Read MUTATION.md first (stale bytecode).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.maintenance import (
|
||||
CHARGE, DRAIN, HOLD, IDLE, NOSOC_NOTE_MIN, MaintConfig, Maintenance,
|
||||
)
|
||||
from app.store import DEFAULTS, Store
|
||||
|
||||
UTC = timezone.utc
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
CFG = MaintConfig(enabled=True, interval_days=28, start_hour=19,
|
||||
discharge_w=2000, charge_w=2000, soc_floor=11,
|
||||
soc_target=100, hold_min=120)
|
||||
T0 = datetime(2026, 10, 4, 18, 24, tzinfo=UTC)
|
||||
MIN = timedelta(minutes=1)
|
||||
|
||||
|
||||
class Mem:
|
||||
def __init__(self, data=None):
|
||||
self.data = dict(DEFAULTS, **(data or {}))
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def set_time(self, k, when):
|
||||
self.data[k] = when.astimezone(UTC).isoformat() if when else None
|
||||
|
||||
def get_time(self, k):
|
||||
raw = self.data.get(k)
|
||||
return datetime.fromisoformat(raw) if raw else None
|
||||
|
||||
def save(self):
|
||||
pass
|
||||
|
||||
|
||||
def in_drain(store=None, unit=None, at=T0):
|
||||
m = Maintenance(CFG, store or Mem(), unit=unit, name=unit or "")
|
||||
m.force_start(at)
|
||||
assert m.phase == DRAIN
|
||||
return m
|
||||
|
||||
|
||||
def in_charge(store=None, unit=None, at=T0):
|
||||
m = in_drain(store, unit, at)
|
||||
m.tick(at, 11.0)
|
||||
assert m.phase == CHARGE
|
||||
return m
|
||||
|
||||
|
||||
def run(m, start, minutes, soc, gate=True, step=1):
|
||||
"""Tick every `step` min; returns (last result, end time, first result that
|
||||
is not the starting phase with its time, all events)."""
|
||||
ph, t, res, out, ev = m.phase, start, None, None, []
|
||||
for i in range(0, minutes, step):
|
||||
t = start + i * MIN
|
||||
s = soc(i) if callable(soc) else soc
|
||||
res = m.tick(t, s, gate_open=gate)
|
||||
ev += res.events
|
||||
if out is None and res.phase != ph:
|
||||
out = (res, t)
|
||||
break
|
||||
return res, start + minutes * MIN, out, ev
|
||||
|
||||
|
||||
|
||||
HOLD_MIN = CFG.hold_min
|
||||
|
||||
|
||||
def in_hold(store=None, unit=None, at=T0):
|
||||
"""A pack in HOLD that started at `at` + 1 min (charge counted as done)."""
|
||||
m = in_charge(store, unit, at)
|
||||
m.tick(at + MIN, 100.0)
|
||||
assert m.phase == HOLD
|
||||
return m
|
||||
|
||||
|
||||
H0 = T0 + MIN # the hold's start
|
||||
print("1. SoC lost in HOLD: the hold ends on time")
|
||||
for label, lost_at in (("at the start", 0), ("mid-hold", 60),
|
||||
("just before hold_min", HOLD_MIN - 1)):
|
||||
m = in_hold()
|
||||
run(m, H0, lost_at, 100.0) if lost_at else None
|
||||
res, _, out, ev = run(m, H0 + lost_at * MIN, HOLD_MIN + 5 - lost_at, None)
|
||||
check(f"lost {label}: ends at hold_min, to IDLE, not failed",
|
||||
out is not None and out[0].phase == IDLE and out[0].failed == ""
|
||||
and out[1] == H0 + HOLD_MIN * MIN)
|
||||
check("...last_completed is written at that time",
|
||||
m.store.get_time("last_completed") == H0 + HOLD_MIN * MIN)
|
||||
check("...the same COMPLETE line, once, and no last_failed",
|
||||
sum("cycle COMPLETE (120 min hold)" in e for e in ev) == 1
|
||||
and m.store.get_time("last_failed") is None)
|
||||
check("...the pack is idle and hands the setpoint back",
|
||||
m.phase == IDLE and not out[0].owns_setpoint)
|
||||
|
||||
m = in_hold()
|
||||
res, _, out, _ = run(m, H0, HOLD_MIN - 1, None)
|
||||
check("before hold_min: still HOLD at 0 W, owned, nothing written",
|
||||
out is None and res.phase == HOLD and res.owns_setpoint
|
||||
and res.setpoint_w == 0.0 and m.store.get_time("last_completed") is None)
|
||||
check("...the pack is not due again (next due date moved after the end)",
|
||||
(run(m, H0 + (HOLD_MIN - 1) * MIN, 3, None), m.due(H0 + 130 * MIN))[1] is False)
|
||||
|
||||
print("2. the text")
|
||||
m = in_hold()
|
||||
_, _, out, ev = run(m, H0, HOLD_MIN + 5, None)
|
||||
note = [e for e in ev if "no battery level for" in e]
|
||||
check("one 15 min line, saying the hold ends at its normal time",
|
||||
len(note) == 1 and "normal time" in note[0] and "waits" not in note[0]
|
||||
and f"{NOSOC_NOTE_MIN} min" in note[0])
|
||||
check("...and the other events are the one COMPLETE line only", len(ev) == 2)
|
||||
m = in_hold()
|
||||
_, _, _, ev = run(m, H0, NOSOC_NOTE_MIN - 2, None)
|
||||
check("under the threshold the line is not said", ev == [])
|
||||
|
||||
print("3. SoC comes back mid-hold")
|
||||
ends = {}
|
||||
for label, soc in (("no SoC", lambda i: None if i < 40 else 100.0),
|
||||
("SoC the whole time", 100.0),
|
||||
("a low SoC after the gap", lambda i: None if i < 40 else 55.0)):
|
||||
m = in_hold()
|
||||
res, _, out, ev = run(m, H0, HOLD_MIN + 5, soc)
|
||||
ends[label] = (out[1], m.store.get_time("last_completed")) if out else None
|
||||
check(f"{label}: ends at hold_min", out is not None
|
||||
and out[1] == H0 + HOLD_MIN * MIN)
|
||||
check("...the end time is the same with or without the gap",
|
||||
len(set(ends.values())) == 1)
|
||||
m = in_hold()
|
||||
run(m, H0, 40, None)
|
||||
res, _, out, _ = run(m, H0 + 40 * MIN, 30, 100.0)
|
||||
check("back mid-hold: still HOLD at 0 W until the time is up",
|
||||
out is None and res.phase == HOLD and res.setpoint_w == 0.0)
|
||||
|
||||
print("4. restart in HOLD with no SoC")
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "state.json")
|
||||
m = Maintenance(CFG, Store(path))
|
||||
m.force_start(T0)
|
||||
m.tick(T0, 11.0)
|
||||
m.tick(H0, 100.0)
|
||||
run(m, H0, 50, None)
|
||||
m2 = Maintenance(CFG, Store(path))
|
||||
r = m2.tick(H0 + 51 * MIN, None)
|
||||
check("a fresh process reads HOLD and holds 0 W", m2.phase == HOLD
|
||||
and r.phase == HOLD and r.owns_setpoint and r.setpoint_w == 0.0)
|
||||
res, _, out, _ = run(m2, H0 + 52 * MIN, HOLD_MIN, None)
|
||||
check("...and the hold ends at hold_min from the hold start, not the restart",
|
||||
out and out[0].phase == IDLE and out[1] == H0 + HOLD_MIN * MIN
|
||||
and m2.store.get_time("last_completed") == H0 + HOLD_MIN * MIN)
|
||||
import json
|
||||
old = {"phase": "hold", "phase_started": H0.isoformat(),
|
||||
"last_completed": None, "last_start_attempt": None, "auto": True}
|
||||
p2 = os.path.join(d, "old.json")
|
||||
json.dump(old, open(p2, "w"))
|
||||
mo = Maintenance(CFG, Store(p2))
|
||||
r = mo.tick(H0 + 10 * MIN, None)
|
||||
check("an old state file in HOLD loads and holds 0 W", mo.phase == HOLD
|
||||
and r.setpoint_w == 0.0)
|
||||
res, _, out, _ = run(mo, H0 + 11 * MIN, HOLD_MIN, None)
|
||||
check("...and ends on time, writing last_completed",
|
||||
out and out[1] == H0 + HOLD_MIN * MIN
|
||||
and mo.store.get_time("last_completed") == H0 + HOLD_MIN * MIN)
|
||||
mr = Maintenance(CFG, Store(p2))
|
||||
check("...the written file reloads as a finished cycle",
|
||||
mr.phase == IDLE and mr.store.get_time("last_completed") is not None)
|
||||
|
||||
print("5. two packs")
|
||||
a, b = (Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a"),
|
||||
Maintenance(CFG, Mem(), unit="inverter-b", name="inverter-b"))
|
||||
a_ok, b_ok = (Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a"),
|
||||
Maintenance(CFG, Mem(), unit="inverter-b", name="inverter-b"))
|
||||
for x in (a, a_ok):
|
||||
x.force_start(T0)
|
||||
x.tick(T0, 11.0)
|
||||
x.tick(H0, 100.0)
|
||||
# B is idle; A's hold loses its SoC (twin site: A keeps it)
|
||||
same, a_end = True, None
|
||||
for i in range(0, HOLD_MIN + 10):
|
||||
t = H0 + i * MIN
|
||||
ra = a.tick(t, None)
|
||||
rb = b.tick(t, 60.0)
|
||||
ra2 = a_ok.tick(t, 100.0)
|
||||
rb2 = b_ok.tick(t, 60.0)
|
||||
same = same and (rb.phase, rb.owns_setpoint, rb.setpoint_w) == \
|
||||
(rb2.phase, rb2.owns_setpoint, rb2.setpoint_w) and b.phase == b_ok.phase
|
||||
if a_end is None and a.phase == IDLE:
|
||||
a_end = t
|
||||
check("A loses SoC in HOLD: A ends on time (as the twin with a reading)",
|
||||
a_end == H0 + HOLD_MIN * MIN and a_ok.phase == IDLE
|
||||
and a.store.get_time("last_completed") == a_ok.store.get_time("last_completed"))
|
||||
check("...B is the same as in the twin site where A kept its SoC", same)
|
||||
# B in its own HOLD with SoC, A in HOLD without
|
||||
a, b = (Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a"),
|
||||
Maintenance(CFG, Mem(), unit="inverter-b", name="inverter-b"))
|
||||
a.force_start(T0); a.tick(T0, 11.0); a.tick(H0, 100.0)
|
||||
b_start = H0 + 30 * MIN
|
||||
b.force_start(b_start - MIN); b.tick(b_start - MIN, 11.0); b.tick(b_start, 100.0)
|
||||
b_end = None
|
||||
for i in range(0, HOLD_MIN + 40):
|
||||
t = H0 + i * MIN
|
||||
a.tick(t, None)
|
||||
if t >= b_start:
|
||||
b.tick(t, 100.0)
|
||||
if b_end is None and b.phase == IDLE:
|
||||
b_end = t
|
||||
check("B (SoC present) ends at its own hold_min, A's loss changes nothing",
|
||||
b_end == b_start + HOLD_MIN * MIN and a.phase == IDLE
|
||||
and b.store.get_time("last_completed") == b_start + HOLD_MIN * MIN)
|
||||
check("...A's end is not delayed by B", a.store.get_time("last_completed")
|
||||
== H0 + HOLD_MIN * MIN)
|
||||
|
||||
print("6. other phases unchanged")
|
||||
m = in_drain()
|
||||
res, _, out, _ = run(m, T0, 60, None)
|
||||
check("DRAIN with no SoC still holds 0 W and does not end", out is None
|
||||
and res.phase == DRAIN and res.setpoint_w == 0.0)
|
||||
m = in_charge()
|
||||
res, _, out, _ = run(m, T0, 600, None)
|
||||
check("CHARGE with no SoC still keeps charging", out is None
|
||||
and res.phase == CHARGE and res.charge_only)
|
||||
|
||||
|
||||
def old_hold_branch(phase_started, last_completed, now, hold_min):
|
||||
"""FROZEN copy of release/1.0 (db9e231) HOLD branch of step(), with a
|
||||
reading: returns (phase, owns_setpoint, setpoint_w, events,
|
||||
last_completed). Kept as a reference for the differential check below."""
|
||||
elapsed = (now - phase_started).total_seconds() / 60.0
|
||||
if elapsed >= hold_min:
|
||||
return (IDLE, False, None,
|
||||
[f"maintenance: cycle COMPLETE ({elapsed:.0f} min hold) "
|
||||
"- handing back to the loop"], now)
|
||||
return (HOLD, True, 0.0, [], last_completed)
|
||||
|
||||
|
||||
print("7. with a reading: the new HOLD against the frozen release/1.0 branch")
|
||||
for hm in (0, 5, 60, 120):
|
||||
cfg = MaintConfig(enabled=True, interval_days=28, start_hour=19,
|
||||
discharge_w=2000, charge_w=2000, soc_floor=11,
|
||||
soc_target=100, hold_min=hm)
|
||||
m = Maintenance(cfg, Mem())
|
||||
m.force_start(T0)
|
||||
m.tick(T0, 11.0)
|
||||
m.tick(H0, 100.0)
|
||||
started, last, same, end = H0, None, True, None
|
||||
for i in range(0, hm + 15):
|
||||
t = H0 + i * MIN
|
||||
if m.phase != HOLD:
|
||||
break
|
||||
want = old_hold_branch(started, last, t, hm)
|
||||
r = m.tick(t, 100.0 - (i % 3))
|
||||
got = (r.phase, r.owns_setpoint, r.setpoint_w, r.events,
|
||||
m.store.get_time("last_completed"))
|
||||
same = same and got == want
|
||||
last = want[4]
|
||||
if r.phase == IDLE:
|
||||
end = t
|
||||
check(f"hold_min {hm}: every tick identical (phase, owns, W, events, "
|
||||
"last_completed)", same)
|
||||
check("...ends at hold_min, last_completed = that tick, one COMPLETE line",
|
||||
end == H0 + hm * MIN and m.store.get_time("last_completed") == end
|
||||
and m.phase == IDLE)
|
||||
|
||||
m = in_hold()
|
||||
res, _, out, ev = run(m, H0, HOLD_MIN + 5, 100.0)
|
||||
check("HOLD with a reading: 0 W until the end, then IDLE at hold_min",
|
||||
out is not None and out[1] == H0 + HOLD_MIN * MIN and out[0].phase == IDLE)
|
||||
check("...last_completed is that tick and the COMPLETE line is said once",
|
||||
m.store.get_time("last_completed") == H0 + HOLD_MIN * MIN
|
||||
and ev == ["maintenance: cycle COMPLETE (120 min hold) "
|
||||
"- handing back to the loop"])
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all ok")
|
||||
@@ -122,7 +122,8 @@ m = Maintenance(cfg, FakeStore())
|
||||
m.force_start(t0)
|
||||
r = m.tick(t0 + timedelta(minutes=5), soc=None)
|
||||
check("no SoC: holds the phase rather than guessing", r.phase == DRAIN)
|
||||
check("no SoC: still commands the drain rate", r.setpoint_w == 2500)
|
||||
check("no SoC: MAINT-14 the drain holds at 0 W, never discharges",
|
||||
r.owns_setpoint and r.setpoint_w == 0.0)
|
||||
|
||||
m = Maintenance(cfg, FakeStore())
|
||||
m.store.set("phase", "banana")
|
||||
@@ -622,6 +623,8 @@ def flat_store(**over):
|
||||
"phase_started": "2026-09-03T22:00:00+00:00",
|
||||
"last_completed": "2026-08-07T12:00:00+00:00",
|
||||
"last_start_attempt": "2026-09-03T10:00:00+00:00",
|
||||
# MAINT-13: a failed cycle on record, so that key's move is graded too.
|
||||
"last_failed": "2026-09-01T10:00:00+00:00",
|
||||
}, **over))
|
||||
|
||||
|
||||
@@ -929,10 +932,11 @@ print(f" ok={_rep8.ok} lost={len(_rep8.lost)} kept={len(_rep8.kept)} "
|
||||
f"misdirected={len(_rep8.misdirected)}; marker "
|
||||
f"{_m8.data.get(MAINT_SCHEMA_KEY, '<<absent>>')!r}")
|
||||
check("PRECONDITION: this fixture reaches the marker through the `lost` guard "
|
||||
"and ONLY that one - two keys in `kept`, two in `lost`, `misdirected` "
|
||||
"and ONLY that one - two keys in `kept`, three in `lost` (the three the "
|
||||
"fixture records; MAINT-13 added `last_failed`), `misdirected` "
|
||||
"empty. If it ever stops having a non-empty `kept` the mutation below "
|
||||
"goes invisible again, so the shape is asserted rather than assumed",
|
||||
_rep8.ran and len(_rep8.kept) == 2 and len(_rep8.lost) == 2
|
||||
_rep8.ran and len(_rep8.kept) == 2 and len(_rep8.lost) == 3
|
||||
and not _rep8.misdirected and not _rep8.ok)
|
||||
check("⚠️ THE MARKER IS NOT STAMPED ON A MIGRATION THAT LOST A MONTH OF "
|
||||
"HISTORY, and the two keys that DID land do not buy it one. `rep.ok` is "
|
||||
@@ -1010,10 +1014,14 @@ check("...and nothing about a deferral, a slot or another pack appears in any "
|
||||
# pressure.
|
||||
_solo2 = Maintenance(cfg, MemStore())
|
||||
_solo2.force_start(t0)
|
||||
# MAINT-13: a drain that times out is a failed cycle, so one plain line follows
|
||||
# the abort line (the abort line itself is unchanged).
|
||||
check("...and so is the abort, which is the string an operator reads under "
|
||||
"pressure",
|
||||
_solo2.tick(t0 + timedelta(hours=13), 60.0).events
|
||||
== ["maintenance: ABORT (drain stuck at 60 % after 780 min)"])
|
||||
== ["maintenance: ABORT (drain stuck at 60 % after 780 min)",
|
||||
"maintenance: the cycle did not finish. It waits 7 days before it "
|
||||
"tries again."])
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -108,15 +108,45 @@ A5DDDAD = {
|
||||
_claims, _undo_c = spy_claims()
|
||||
_bounds, _undo_b = spy_bounds()
|
||||
try:
|
||||
_now = rig.digests()
|
||||
_now = {k: rig.digest(rig.run(**v)[0])
|
||||
for k, v in rig.SCENARIOS.items() if k != "n2_maint"}
|
||||
_now["resolve"] = rig.digest(rig.resolve_rows())
|
||||
finally:
|
||||
_undo_b()
|
||||
# SAFETY-85: n2_maint (maintenance at N=2) is the one run with a bound.
|
||||
_bounds_m, _undo_b = spy_bounds()
|
||||
try:
|
||||
_now["n2_maint"] = rig.digest(rig.run(**rig.SCENARIOS["n2_maint"])[0])
|
||||
finally:
|
||||
_undo_c()
|
||||
_undo_b()
|
||||
# SAFETY-78 changes n2_maint ON PURPOSE: on a5dddad and 5a4cdc2 its N=2 drain
|
||||
# was refused on 392 of 540 cycles (site at 0 W); now none is. Printed on the
|
||||
# SAFETY-78 tree the same way. Every other digest is unchanged.
|
||||
# SAFETY-85 changes it again ON PURPOSE: while A's drain pin stands the loop
|
||||
# is bounded by what the batteries can place, so when A drops out at t=1655 B
|
||||
# follows the house down at once instead of unwinding 920 W too high. Was
|
||||
# cd4d9cb5b7e29ff4ab4fe65dab5106683d8a599f09323fbdee4018ad9b28fcd1.
|
||||
SAFETY78_N2_MAINT = \
|
||||
"cd4d9cb5b7e29ff4ab4fe65dab5106683d8a599f09323fbdee4018ad9b28fcd1"
|
||||
"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
|
||||
# a5dddad's again, which the check below grades.
|
||||
SAFETY91_N2 = \
|
||||
"9535c08f556d6d50bec8fb0872445173480c29b10df1a7c437ef73d6ddb2814d"
|
||||
_real_hb = app_main.Controller._held_back
|
||||
app_main.Controller._held_back = lambda self: self._peak_reserve()
|
||||
try:
|
||||
_n2_s87 = rig.digest(rig.run(**rig.SCENARIOS["n2"])[0])
|
||||
finally:
|
||||
app_main.Controller._held_back = _real_hb
|
||||
_S91 = ("n2", "n2_off", "n2_maint")
|
||||
for k in A5DDDAD:
|
||||
print(f" {k:7} {'same' if _now[k] == A5DDDAD[k] else 'DIFFERS'} "
|
||||
f"{_now[k][:16]}")
|
||||
@@ -124,17 +154,29 @@ check("⭐ at N=1 and N=2, with the option absent and with it false, every "
|
||||
"command, reason and allocation record over 540 cycles each is "
|
||||
"byte-identical to a5dddad (house steps, a solar surplus, a battery "
|
||||
"at its cut-out level, at N=2 a refusal with probes, both batteries "
|
||||
"due for maintenance; n2_maint apart, below), and so is the arbiter "
|
||||
"on 6000 claim lists",
|
||||
{k: v for k, v in _now.items() if k != "n2_maint"}
|
||||
== {k: v for k, v in A5DDDAD.items() if k != "n2_maint"})
|
||||
"due for maintenance; n2, n2_off and n2_maint apart, below), and so "
|
||||
"is the arbiter on 6000 claim lists",
|
||||
{k: v for k, v in _now.items() if k not in _S91}
|
||||
== {k: v for k, v in A5DDDAD.items() if k not in _S91})
|
||||
# SAFETY-94: with SAFETY-87's held rule, A (cut out at 1600 s) now trips the
|
||||
# stale gate at 1640 s, not 1635 s - 35 s after its command moved rather than
|
||||
# after its reading last changed - so that run is no longer a5dddad's. It was
|
||||
# a5dddad's (a60003b1...) before SAFETY-94. The shipped n2 is unchanged.
|
||||
SAFETY94_N2_S87 = \
|
||||
"4ddea7ef9dc1efa437af899675db442cd531b084ae4cec35951d7c7bb3d6d8b5"
|
||||
check("...n2 and n2_off are the SAFETY-91 digest (A at its reserve gives "
|
||||
"last), and SAFETY-87's held rule gives SAFETY-94's",
|
||||
_now["n2"] == _now["n2_off"] == SAFETY91_N2 != A5DDDAD["n2"]
|
||||
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)",
|
||||
_now["n2_maint"] == SAFETY78_N2_MAINT != A5DDDAD["n2_maint"])
|
||||
check("...and no cycle in those runs filed a `mode` claim or handed "
|
||||
"`compute()` a bound",
|
||||
"refused on 392 of 540 cycles now runs (test_safety78.py), bounded by "
|
||||
"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)
|
||||
and _bounds and set(_bounds) == {None})
|
||||
and _bounds and set(_bounds) == {None}
|
||||
and any(b is not None for b in _bounds_m))
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
|
||||
@@ -67,8 +67,10 @@ for k in ED739D4:
|
||||
# SAFETY-84 changes n2_maint ON PURPOSE: on 9d5b7d1 its N=2 cheap-window
|
||||
# charge was refused on 214 of 540 cycles (site at 0 W); now none is.
|
||||
# Printed on the SAFETY-84 tree the same way (margin 0).
|
||||
# SAFETY-85: the loop is bounded while A charges (reasons change, commands
|
||||
# do not). Was 95722b262f1bcf504493d7b5babc3895799ac56cc15f5d2ccdbe7c9f04adfcbb.
|
||||
SAFETY84_N2_MAINT = \
|
||||
"95722b262f1bcf504493d7b5babc3895799ac56cc15f5d2ccdbe7c9f04adfcbb"
|
||||
"ef97ae2e13668b3e0b2d22f3f5f37835ea46cd306c2cc475c97e284f9dcc6545"
|
||||
check("⭐ at N=1 and N=2, with and without battery modes and with a "
|
||||
"maintenance charge, margin 0 is "
|
||||
"byte-identical to ed739d4 (n2_maint apart, below)",
|
||||
|
||||
@@ -109,6 +109,7 @@ import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from math import inf, isinf
|
||||
@@ -2537,12 +2538,18 @@ print(f" banner ({len(_pay25['banner'])} chars): {_pay25['banner']}")
|
||||
# that is going over the ceiling.
|
||||
# UX-08: the banner is the notification's own title, and the row below keeps
|
||||
# the SAFETY-25 sentence.
|
||||
check("the Not ready banner is a short headline - the notification's title - "
|
||||
"and NOT the row repeated at the top of the page. A headline that is a "
|
||||
"paragraph is a headline nobody reads",
|
||||
# SAFETY-87: control goes on while the guard is floor-limited, so the banner
|
||||
# does not say "Not ready". This fixture also has a row that does stop control
|
||||
# (no SoC entity), which outranks it; the peak row alone is graded below.
|
||||
c25.checks = lambda: rows25
|
||||
_pay25 = c25.status_payload()
|
||||
del c25.checks
|
||||
print(f" banner, peak row alone: {_pay25['banner']}")
|
||||
check("the banner is a short headline - the notification's title, without "
|
||||
"'Not ready' (control goes on) - and NOT the row repeated at the top of "
|
||||
"the page. A headline that is a paragraph is a headline nobody reads",
|
||||
_pay25["level"] == "bad"
|
||||
and _pay25["banner"] == (f"{messages.NOT_READY} — "
|
||||
f"{messages.T_PEAK_RESERVE}")
|
||||
and _pay25["banner"] == messages.T_PEAK_RESERVE
|
||||
and not _row_scope(_pay25["banner"]))
|
||||
check("...and shortening the banner did NOT shorten the row: the SAFETY-25 "
|
||||
"sentence is still what the checks list carries, which is the surface the "
|
||||
@@ -3125,6 +3132,10 @@ check("...and between them the nineteen appends account for exactly the "
|
||||
# through this file's reading of main.py. It grades three of the eight appends.
|
||||
# The static pin above is what covers the other five.
|
||||
_b2, _restore_b2 = second_inverter(c_src)
|
||||
# SAFETY-91: B states a cut-out, as every roster entry must (rule 8). Without
|
||||
# one it is held back at its reserve and gives last, so A alone would carry
|
||||
# this cycle's 500 W and the broadcast check below could not see a split.
|
||||
_b2.bms_soc_floor = c_src.inverters[0].bms_soc_floor
|
||||
_n2_writes_before = len(c_src.hass.per_entity)
|
||||
try:
|
||||
_, _cl2 = cycle_claims(lambda: run_cycle(c_src))
|
||||
@@ -4139,18 +4150,22 @@ check("⚠️ ...AND THE SAME PAIR SWAPPED GIVES THE SAME SITE FLOOR, so no "
|
||||
and _g_deepa.soc_floor != _g_shallow.soc_floor
|
||||
and [pk.bms_floor for pk in _g_ctl_deepa.packs]
|
||||
== [B_PACK_FLOOR_DEEP_SIM, B_PACK_FLOOR_SIM])
|
||||
check("...and it is not just a number in a report - THE SITE'S VERDICT FLIPS. "
|
||||
"With both packs at 25 % the shallow site is permitted and the guard "
|
||||
"sizes a positive forced discharge; BOTH deep sites are FLOOR-LIMITED, "
|
||||
"because 25 % is at or below the 35 % cut-out wherever on the roster it "
|
||||
"sits. Grading the roster against one position would spend a pack its "
|
||||
"BMS stops - through `sim/inject.py`, which does not run rule 8 - and "
|
||||
"this line is what goes red when it does",
|
||||
# SAFETY-87 changed what the verdict is at N>=2: the pack at or under its own
|
||||
# floor is HELD (named, gives nothing for the peak) and the others still shave.
|
||||
check("...and it is not just a number in a report - THE HELD PACK FOLLOWS IT. "
|
||||
"With both packs at 25 % the shallow site holds nobody back; on BOTH deep "
|
||||
"sites the pack whose 35 % cut-out is at or above 25 % is held, wherever "
|
||||
"it sits, gives nothing for the peak (weight 0), and the other pack "
|
||||
"carries a positive floor (SAFETY-87). Holding a position instead of "
|
||||
"the pack would spend a pack its BMS stops",
|
||||
_g_shallow.floor_limited is False and _g_shallow.floor_w > 0.0
|
||||
and _g_deep.floor_limited is True and _g_deep.floor_w == 0.0
|
||||
and _g_deep.soc_floor_reason == FLOOR_AT_SOC_FLOOR
|
||||
and _g_deepa.floor_limited is True and _g_deepa.floor_w == 0.0
|
||||
and _g_deepa.soc_floor_reason == FLOOR_AT_SOC_FLOOR)
|
||||
and _g_shallow.held == ()
|
||||
and _g_deep.floor_limited is False and _g_deep.floor_w > 0.0
|
||||
and _g_deep.held == ("inverter-b",)
|
||||
and _g_ctl_deep._peak_reserve() == {"inverter-b"}
|
||||
and _g_deepa.floor_limited is False and _g_deepa.floor_w > 0.0
|
||||
and _g_deepa.held == ("inverter-a",)
|
||||
and _g_ctl_deepa._peak_reserve() == {"inverter-a"})
|
||||
check("...and the precondition that makes the three runs comparable (habit 1): "
|
||||
"the two deep runs carry THE SAME PAIR of cut-outs in opposite orders, "
|
||||
"those two numbers really are different, and all six packs are at the "
|
||||
@@ -4221,14 +4236,14 @@ check("⚠️ THE POSITIONAL PIN, AND IT NEEDS THREE PACKS TO EXIST. On a roster
|
||||
for r in _g3_runs)
|
||||
and [[pk.bms_floor for pk in r[2].packs] for r in _g3_runs]
|
||||
== [r[1] for r in _g3_runs])
|
||||
check("...and the VERDICT flips with it, on all three: 25 % is at or below the "
|
||||
"35 % cut-out wherever it sits, so every one of these sites is "
|
||||
"FLOOR-LIMITED and sizes no forced discharge at all - where the "
|
||||
"all-shallow two-pack baseline above was permitted and sized a positive "
|
||||
"one. A slice that dropped the deep position would spend, through "
|
||||
"`sim/inject.py`, a pack its BMS stops",
|
||||
all(r[3].floor_limited is True and r[3].floor_w == 0.0
|
||||
and r[3].soc_floor_reason == FLOOR_AT_SOC_FLOOR for r in _g3_runs)
|
||||
check("...and the HELD pack follows it, on all three (SAFETY-87): 25 % is at "
|
||||
"or below the 35 % cut-out wherever it sits, so that pack and only that "
|
||||
"pack is held and gives nothing for the peak, while the two others carry "
|
||||
"a positive floor. A slice that dropped the deep position would spend, "
|
||||
"through `sim/inject.py`, a pack its BMS stops",
|
||||
all(r[3].floor_limited is False and r[3].floor_w > 0.0
|
||||
and r[3].held == (r[2].inverters[r[1].index(_G3_DEEP_SIM)].name,)
|
||||
and r[2]._peak_reserve() == set(r[3].held) for r in _g3_runs)
|
||||
and _g_shallow.floor_limited is False and _g_shallow.floor_w > 0.0)
|
||||
check("...and the precondition that makes the three comparable (habit 1): every "
|
||||
"pack in all three rosters is at the SAME state of charge, so neither the "
|
||||
@@ -4386,12 +4401,18 @@ async def _notify32(packs_socs, floors):
|
||||
inv_b, _ = second_inverter(ctl)
|
||||
for inv, so, fl in zip(ctl.inverters, packs_socs, floors):
|
||||
inv.soc, inv.bms_soc_floor = so, fl
|
||||
ctl.peak_guard = PeakDecision(
|
||||
# SAFETY-87: the full pack refuses to give (SAFETY-73), so no battery
|
||||
# can carry the peak and the alarm still fires; it names the held one.
|
||||
inv.refused = 1 if so > SOC_FLOOR else 0
|
||||
_idle = [types.SimpleNamespace(phase="idle", owns_setpoint=False,
|
||||
setpoint_w=None)] * 2
|
||||
ctl.peak_guard = ctl._peak_on_able(PeakDecision(
|
||||
engaged=True, floor_w=0.0, ceiling_w=CAP_W, ceiling_from=CEILING_CAP,
|
||||
projected_w=CAP_W + 500.0, excess_w=500.0, wanted_w=500.0,
|
||||
floor_limited=True, soc=min(packs_socs),
|
||||
soc_floor=binding_soc_floor(ctl.packs, SOC_FLOOR),
|
||||
soc_floor_reason=FLOOR_AT_SOC_FLOOR, reason="PEAK GUARD FLOOR-LIMITED: x")
|
||||
soc_floor_reason=FLOOR_AT_SOC_FLOOR, reason="PEAK GUARD FLOOR-LIMITED: x"),
|
||||
_idle)
|
||||
ctl._peak_floor_limited = False
|
||||
await ctl.check_peak_floor_limited()
|
||||
return hs.last_message("goodwe_peak_soc_floor")
|
||||
@@ -4581,18 +4602,19 @@ check("...and the TWO-pack mirror, which is what production reaches the moment "
|
||||
[r[3].soc for r in _s2_runs] == [_S_EMPTY_SIM] * 2
|
||||
and [[inv.soc for inv in r[2].inverters] for r in _s2_runs]
|
||||
== [[_S_EMPTY_SIM, _S_FULL_SIM], [_S_FULL_SIM, _S_EMPTY_SIM]])
|
||||
check("⚠️ ...AND THE SITE'S VERDICT FLIPS WITH IT, which is what makes this a "
|
||||
"defect rather than a reporting difference. With the emptiest pack at "
|
||||
"11 % - below the 20 % EMS floor - every one of those five sites is "
|
||||
"FLOOR-LIMITED and sizes NO forced discharge at all, where the baseline "
|
||||
"with its emptiest pack at 25 % is permitted and sizes a positive one. A "
|
||||
"reduction that dropped the empty pack's position would grade this site "
|
||||
"for PERMISSION against a pack 39 points fuller and spend, through "
|
||||
"`sim/inject.py`, a pack its own BMS stops",
|
||||
all(r[3].floor_limited is True and r[3].floor_w == 0.0
|
||||
# SAFETY-87: at N>=2 the emptiest pack is HELD and the others still shave.
|
||||
check("⚠️ ...AND THE HELD PACK FOLLOWS IT, which is what makes this a defect "
|
||||
"rather than a reporting difference. With the emptiest pack at 11 % - "
|
||||
"below the 20 % EMS floor - that pack and only that pack is held on "
|
||||
"every one of those five sites and gives nothing for the peak, while "
|
||||
"the fuller ones carry a positive floor (SAFETY-87); the baseline holds "
|
||||
"nobody back. A reduction that dropped the empty pack's position would "
|
||||
"spend, through `sim/inject.py`, a pack its own BMS stops",
|
||||
all(r[3].floor_limited is False and r[3].floor_w > 0.0
|
||||
and r[3].held == (r[2].inverters[r[1].index(_S_EMPTY_SIM)].name,)
|
||||
for r in _s3_runs + _s2_runs)
|
||||
and _s_ok.floor_limited is False and _s_ok.floor_w > 0.0
|
||||
and _s_ok.soc == SOC_BETWEEN)
|
||||
and _s_ok.held == () and _s_ok.soc == SOC_BETWEEN)
|
||||
check("...and the precondition that makes the six runs comparable (habit 1): "
|
||||
"every pack in every roster carries the SAME cut-out, so "
|
||||
"`binding_soc_floor()` answers the SAME site floor on all of them and the "
|
||||
@@ -5727,8 +5749,8 @@ MUT.append(("main.py grading the site against ONE roster position - "
|
||||
_g_deep.soc_floor == float(B_PACK_FLOOR_DEEP_SIM)
|
||||
and _g_deepa.soc_floor == float(B_PACK_FLOOR_DEEP_SIM)
|
||||
and _g_shallow.soc_floor == float(SOC_FLOOR)
|
||||
and _g_deep.floor_limited and _g_deepa.floor_limited
|
||||
and not _g_shallow.floor_limited,
|
||||
and _g_deep.held == ("inverter-b",)
|
||||
and _g_deepa.held == ("inverter-a",) and _g_shallow.held == (),
|
||||
"part 12g, REWRITTEN as behaviour: real two-pack cycles, the SAME "
|
||||
"pair of cut-outs carried in both orders, and both the floor "
|
||||
"reaching decide() and the site's verdict follow the ROSTER rather "
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
"""Runnable check for SAFETY-103: no swing on old battery readings after a
|
||||
drain ends. `python3 test_safety103.py`
|
||||
|
||||
While a battery settles from its ended drain (SAFETY-95), the loop works
|
||||
from the grid plus each battery's reading. A reading can be one register
|
||||
period (10 s) older than the meter: on the rig (SAFETY-95 test, check 5) A's
|
||||
reading still said 1238 W while A was at 616 W, and B went 860 -> 1650 ->
|
||||
870 W. Now the house point is held, and moved only as far as the meter
|
||||
proves (each battery between its last changed reading and its commands
|
||||
since). Drives the REAL Controller on tools/safety103_plant.py: a lagging
|
||||
battery, the meter every 1 s, registers every 10 s at the rig's phases, B
|
||||
idling at -100 W. DECISIONS/SAFETY-103-old-readings-after-a-drain.md.
|
||||
|
||||
1. The rig case and every drain end at N=2 and N=3, 10 s registers: no
|
||||
swing beyond one slew step; release/1.0 swings (non-vacuous).
|
||||
2. SAFETY-95/96's gains stay: no jump at the end, no give into a surplus,
|
||||
one slew step a cycle, refusal and stale link still learned.
|
||||
3. A house step inside the window is still followed.
|
||||
4. With readings as fresh as the meter, every row is release/1.0's; N=1 is
|
||||
untouched.
|
||||
5. 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
|
||||
|
||||
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 safety103_plant as p # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.maintenance import DRAIN # noqa: E402
|
||||
|
||||
fails = []
|
||||
SLEW = 400.0 # the rig's slew_w
|
||||
AIM = -10.0 # the rig's target_grid_w
|
||||
T = p.T
|
||||
A = p.A
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
fixed = Controller._drain_grid_w
|
||||
track = getattr(Controller, "_track_reads", None)
|
||||
|
||||
|
||||
def _release(self, live):
|
||||
"""release/1.0's `_drain_grid_w` (SAFETY-95 as merged): every battery at
|
||||
its reading."""
|
||||
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 without(fn):
|
||||
"""Run `fn` with SAFETY-103 taken out (release/1.0's rule)."""
|
||||
Controller._drain_grid_w = _release
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
Controller._drain_grid_w = fixed
|
||||
|
||||
|
||||
def soc_done(u, t):
|
||||
return 5.0 if (u == "a" and t >= T) else 50.0
|
||||
|
||||
|
||||
# A's drain ends at T: switched Off (the rig case), completed on SoC, its
|
||||
# battery refusing (gives nothing from T; SAFETY-73 learns it later), or its
|
||||
# link going stale (writes refused from T, its board to 0 W 25 s later).
|
||||
ENDS = {
|
||||
"Off": dict(),
|
||||
"complete": dict(before=None, soc=soc_done),
|
||||
"refusal": dict(before=None, dead=("a", T), dur=T + 150.0),
|
||||
"stale link": dict(before=None, reject={"a": T}, dead=("a", T + 25.0),
|
||||
dur=T + 150.0),
|
||||
}
|
||||
|
||||
|
||||
def end_of(rows):
|
||||
"""The first cycle A's drain is over (its command left 2000 W, or its
|
||||
phase left DRAIN); None if never."""
|
||||
return next((r["t"] for r in rows if r["t"] >= T and (
|
||||
r["cmd"][0] < 1990.0 or r["phase"][0] != DRAIN)), None)
|
||||
|
||||
|
||||
def measure(rows, n, house):
|
||||
"""From the end on: the largest swing of the others' summed command in
|
||||
the 23 s window (a move later undone; the slew step is the site's); their
|
||||
largest command on the end cycle; the site target's largest step after
|
||||
the end cycle; the others' largest command after the end (for a
|
||||
surplus); the grid's range in the window."""
|
||||
te = end_of(rows)
|
||||
if te is None:
|
||||
return dict(end=math.nan, swing=math.nan, first=math.nan,
|
||||
step=math.nan, give=math.nan, gmin=math.nan,
|
||||
gmax=math.nan, rows=rows)
|
||||
win = [r for r in rows if te <= r["t"] <= te + 23.0]
|
||||
sw = 0.0
|
||||
w = [sum(r["cmd"][1:]) for r in win][1:]
|
||||
for j in range(len(w)):
|
||||
sw = max(sw, min(w[j] - min(w[:j + 1]), w[j] - min(w[j:])),
|
||||
min(max(w[:j + 1]) - w[j], max(w[j:]) - w[j]))
|
||||
seg = [r["target"] for r in rows if r["t"] >= te]
|
||||
return dict(
|
||||
end=te, swing=sw,
|
||||
first=max(win[0]["cmd"][1:]) if win else math.nan,
|
||||
step=max(abs(b - a) for a, b in zip(seg, seg[1:])),
|
||||
give=max(max(r["cmd"][1:]) for r in rows if r["t"] >= te),
|
||||
gmin=min(r["grid"] for r in win), gmax=max(r["grid"] for r in win),
|
||||
rows=rows)
|
||||
|
||||
|
||||
def run(kind, n, house, **k):
|
||||
return p.run(n, house, **dict(ENDS[kind], **k))[0]
|
||||
|
||||
|
||||
TABLE = []
|
||||
# A settles from its drain: the tail this rule is about. A refusing battery
|
||||
# already reads ~0 W, and a dropped one is out of the split (the meter has
|
||||
# it), so their tails last at most a cycle.
|
||||
SETTLE = ("Off", "complete")
|
||||
|
||||
|
||||
def case(kind, n, house, **k):
|
||||
now = measure(run(kind, n, house, **k), n, house)
|
||||
old = measure(without(lambda: run(kind, n, house, **k)), n, house)
|
||||
fresh = measure(run(kind, n, house, reg_s=1.0, **k), n, house)
|
||||
TABLE.append((kind, n, house, now, old, fresh))
|
||||
return now, old, fresh
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. no swing on an old reading: every drain end, N=2 and N=3, 10 s "
|
||||
"registers")
|
||||
for kind in ENDS:
|
||||
for n in (2, 3):
|
||||
for house in (1200.0, 4000.0, -1500.0):
|
||||
now, old, fresh = case(kind, n, house)
|
||||
own = kind in SETTLE
|
||||
check(f"{'⭐' if kind == 'Off' else ' '} {kind}, N={n}, house "
|
||||
f"{house:.0f} W: old readings add at most one slew step "
|
||||
f"to the others' swing in the 23 s after the end"
|
||||
f"{', and it is at most one slew step' if own else ''} "
|
||||
f"({now['swing']:.0f} W; 1 s registers "
|
||||
f"{fresh['swing']:.0f}; release/1.0 {old['swing']:.0f})",
|
||||
now["swing"] - fresh["swing"] <= SLEW
|
||||
and (not own or now["swing"] <= SLEW))
|
||||
_by = {(k, n, h): (a, b) for k, n, h, a, b, f in TABLE}
|
||||
_now, _old = _by[("Off", 2, 1200.0)]
|
||||
_b = [r["cmd"][1] for r in _now["rows"] if 0 <= r["t"] - _now["end"] <= 23]
|
||||
_bo = [r["cmd"][1] for r in _old["rows"] if 0 <= r["t"] - _old["end"] <= 23]
|
||||
print(f" rig check 5 (N=2, 1200 W, A Off): B {_b}\n"
|
||||
f" release/1.0: B {_bo}")
|
||||
check("⭐ the rig case (SAFETY-95 check 5): release/1.0 swings B by more "
|
||||
"than a slew step on A's old reading; now B holds within 50 W",
|
||||
_old["swing"] > SLEW and max(_b) - min(_b) <= 50.0)
|
||||
for n in (2, 3):
|
||||
check(f"non-vacuous (N={n}): on release/1.0 old readings add more than "
|
||||
f"a slew step in at least one of these cases",
|
||||
any(b["swing"] - f["swing"] > SLEW
|
||||
for k, nn, h, a, b, f in TABLE if nn == n))
|
||||
_s5 = []
|
||||
for lag in (5.0, 20.0):
|
||||
for reg in (5.0, 10.0):
|
||||
for n in (2, 3):
|
||||
_s5.append(measure(run("Off", n, 1200.0, lag=lag, reg_s=reg), n,
|
||||
1200.0)["swing"])
|
||||
check(f"no plant constant is assumed: battery lag 5 s and 20 s, registers "
|
||||
f"every 5 s and 10 s, N=2 and N=3: largest swing {max(_s5):.0f} W",
|
||||
max(_s5) <= SLEW)
|
||||
_ph = [measure(run("Off", 2, 1200.0, phase={"a": a, "b": b}), 2, 1200.0)
|
||||
["swing"] for a, b in ((0.0, 0.5), (0.9, 0.1), (0.3, 0.3))]
|
||||
check(f"other register phases: largest swing {max(_ph):.0f} W",
|
||||
max(_ph) <= SLEW)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. SAFETY-95/96's gains stay")
|
||||
for kind in ENDS:
|
||||
for n in (2, 3):
|
||||
now, old = _by[(kind, n, 1200.0)]
|
||||
check(f"{kind}, N={n}, 1200 W: no jump on the end cycle (others "
|
||||
f"<= the house, {now['first']:.0f} W) and the site moves at "
|
||||
f"most one slew step a cycle after ({now['step']:.0f} W)",
|
||||
now["first"] <= 1200.0 - AIM + 50.0 and now["step"] <= SLEW)
|
||||
now, old = _by[(kind, n, -1500.0)]
|
||||
check(f"{kind}, N={n}, solar surplus: no battery gives into it "
|
||||
f"(largest command after the end {now['give']:.0f} W"
|
||||
+ (f"; a lost link as release/1.0, {old['give']:.0f} W)"
|
||||
if kind == "stale link" else ")"),
|
||||
now["give"] <= (max(50.0, old["give"]) if kind == "stale link"
|
||||
else 50.0))
|
||||
_now, _old = _by[("Off", 2, 1200.0)]
|
||||
check(f"the grid in the window stays inside release/1.0's range "
|
||||
f"([{_now['gmin']:.0f}, {_now['gmax']:.0f}] W, release/1.0 "
|
||||
f"[{_old['gmin']:.0f}, {_old['gmax']:.0f}])",
|
||||
_now["gmin"] >= _old["gmin"] - 50.0 and _now["gmax"] <= _old["gmax"])
|
||||
|
||||
|
||||
_ref = {}
|
||||
for name, f in (("now", lambda: p.run(2, 1200.0, **ENDS["refusal"])),
|
||||
("old", lambda: without(lambda: p.run(
|
||||
2, 1200.0, **ENDS["refusal"])))):
|
||||
rows, c = f()
|
||||
_ref[name] = (c.inverters[0].refused, c.inverters[0].refused_at)
|
||||
check(f"a refusal is still learned, at the same instant as release/1.0 "
|
||||
f"({_ref['now']})", _ref["now"] == _ref["old"]
|
||||
and _ref["now"][0] == 1)
|
||||
_st = {}
|
||||
for name, f in (("now", lambda: p.run(2, 1200.0, **ENDS["stale link"])),
|
||||
("old", lambda: without(lambda: p.run(
|
||||
2, 1200.0, **ENDS["stale link"])))):
|
||||
rows, c = f()
|
||||
_st[name] = sorted(c.live_units())
|
||||
check(f"a stale link is still dropped, as in release/1.0 "
|
||||
f"(live: {_st['now']})", _st["now"] == _st["old"]
|
||||
and len(_st["now"]) == 1)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. a house step inside the window is still followed")
|
||||
|
||||
|
||||
def step(sign):
|
||||
return lambda t: 1200.0 + (sign * 2000.0 if t >= T + 5.0 else 0.0)
|
||||
|
||||
|
||||
for sign, what in ((1, "kettle +2000 W"), (-1, "solar -2000 W")):
|
||||
for n in (2, 3):
|
||||
rows = p.run(n, step(sign))[0]
|
||||
rows_o = without(lambda: p.run(n, step(sign)))[0]
|
||||
te = end_of(rows)
|
||||
|
||||
def wh(rs):
|
||||
return sum(abs(r["grid"] - AIM) for r in rs
|
||||
if te + 5.0 <= r["t"] <= te + 35.0) / 3600.0
|
||||
sw = measure(rows, n, 1200.0)["swing"]
|
||||
check(f"{what} at E+5 s, N={n}: the grid's distance from its aim "
|
||||
f"over the next 30 s is within 1.5 Wh of release/1.0's "
|
||||
f"({wh(rows):.1f} Wh, release/1.0 {wh(rows_o):.1f}); swing "
|
||||
f"{sw:.0f} W", wh(rows) <= wh(rows_o) + 1.5 and sw <= SLEW)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. fresh readings and N=1: release/1.0's rows")
|
||||
|
||||
|
||||
def digest(rows):
|
||||
return hashlib.sha256(repr(rows).encode()).hexdigest()
|
||||
|
||||
|
||||
_fresh = [(n, h) for n in (2, 3) for h in (1200.0, 4000.0, -1500.0)]
|
||||
check("⭐ registers as fresh as the meter (1 s), N=2 and N=3, three houses: "
|
||||
"every row byte-identical with release/1.0 (a reading that changed "
|
||||
"this cycle is taken as it is)",
|
||||
[digest(p.run(n, h, reg_s=1.0)[0]) for n, h in _fresh]
|
||||
== [digest(without(lambda: p.run(n, h, reg_s=1.0))[0])
|
||||
for n, h in _fresh])
|
||||
_rf = [(n, h) for n in (2, 3) for h in (1200.0, -1500.0)]
|
||||
check("⭐ a refusal end, 10 s registers, N=2 and N=3, 1200 W and a surplus: "
|
||||
"every row byte-identical with release/1.0 (A reads ~0 W and will not "
|
||||
"give, so its side is its reading)",
|
||||
[digest(run("refusal", n, h)) for n, h in _rf]
|
||||
== [digest(without(lambda: run("refusal", n, h))) for n, h in _rf])
|
||||
_n1 = [dict(), dict(before=None, soc=soc_done)]
|
||||
check("⭐ N=1, A switched Off and A's drain completing, 10 s registers: "
|
||||
"every row byte-identical with release/1.0",
|
||||
[digest(p.run(1, 1200.0, **k)[0]) for k in _n1]
|
||||
== [digest(without(lambda: p.run(1, 1200.0, **k))[0]) for k in _n1])
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. the rule's parts")
|
||||
_c = p.run(2, 1200.0, dur=4.0)[1]
|
||||
AB = {"inverter-a", "inverter-b"}
|
||||
ia, ib = _c.inverters
|
||||
|
||||
|
||||
def at(now, batts, cmds, changed):
|
||||
"""Set the clock, each battery's reading, its standing command and the
|
||||
instant its reading last changed; then track."""
|
||||
_c._now_mono = now
|
||||
for inv, b, cmd, ch in zip(_c.inverters, batts, cmds, changed):
|
||||
inv.batt, inv.last_write = b, cmd
|
||||
inv.batt_fresh.changed_mono = ch
|
||||
_c._track_reads()
|
||||
|
||||
|
||||
_c._read_span = {}
|
||||
at(10.0, (2000.0, -100.0), (2000.0, 0.0), (10.0, 10.0))
|
||||
check("a reading that changed is the start of its range: (R, R) joined "
|
||||
"with the command on its wire",
|
||||
_c._read_span[A] == (2000.0, 2000.0, 10.0)
|
||||
and _c._read_span["inverter-b"] == (-100.0, 0.0, 10.0))
|
||||
at(11.0, (2000.0, -100.0), (0.0, 1140.0), (10.0, 10.0))
|
||||
at(12.0, (2000.0, -100.0), (0.0, 800.0), (10.0, 10.0))
|
||||
check("every command since the reading widens the range (a lagging "
|
||||
"battery moves toward each in turn): A [0, 2000], B [-100, 1140]",
|
||||
_c._read_span[A][:2] == (0.0, 2000.0)
|
||||
and _c._read_span["inverter-b"][:2] == (-100.0, 1140.0))
|
||||
at(13.0, (1241.0, -100.0), (0.0, 800.0), (13.0, 10.0))
|
||||
check("a new reading starts the range again: A [0, 1241]",
|
||||
_c._read_span[A][:2] == (0.0, 1241.0))
|
||||
_c._read_span = {}
|
||||
_c.inverters = _c.inverters[:1]
|
||||
at(14.0, (2000.0,), (2000.0,), (14.0,))
|
||||
_c.inverters = [ia, ib]
|
||||
check("N=1: nothing is tracked", _c._read_span == {})
|
||||
|
||||
|
||||
def tail(now, grid, target, held=None, live=AB):
|
||||
"""`_drain_grid_w` with A settling: the error it hands the loop (grid
|
||||
minus aim) and the house point it holds."""
|
||||
_c._now_mono, _c.grid, _c.target = now, grid, target
|
||||
_c._drain_tail, _c._settle = {A}, {A: 0.0}
|
||||
_c._drain_house = held
|
||||
g = _c._drain_grid_w(live)
|
||||
return round(g - AIM, 6), _c._drain_house
|
||||
|
||||
|
||||
# A: reading 1241 W from 13 s, range [0, 1241]; B: reading -100 W from 10 s,
|
||||
# range [-100, 1140]. Grid -330 W: the house is in
|
||||
# [-330 + 10 + 0 - 100, -330 + 10 + 1241 + 1140] = [-420, 2061].
|
||||
_c._read_span = {A: (0.0, 1241.0, 13.0), "inverter-b": (-100.0, 1140.0, 10.0)}
|
||||
ia.batt, ib.batt = 1241.0, -100.0
|
||||
ia.batt_fresh.changed_mono, ib.batt_fresh.changed_mono = 13.0, 10.0
|
||||
check("⭐ held: the first window cycle holds the loop's start (1140 W) while "
|
||||
"the meter allows it (error 0; release/1.0's would be -330 + 1241 - "
|
||||
"100 + 10 - 1140 = -319 W)", tail(14.0, -330.0, 1140.0) == (0.0, 1140.0)
|
||||
and round(_release(_c, AB) - AIM) == -319)
|
||||
check("⭐ held, then kept: a later cycle keeps the held point, not the "
|
||||
"loop's target", tail(15.0, -330.0, 1000.0, held=1140.0)
|
||||
== (140.0, 1140.0))
|
||||
check("⭐ moved as far as the meter proves: a grid of +2000 W puts the "
|
||||
"house at least at 2000 + 10 + 0 - 100 = 1910 W",
|
||||
tail(15.0, 2000.0, 1140.0, held=1140.0) == (770.0, 1910.0))
|
||||
check("...and down: a grid of -2500 W puts it at most at -2500 + 10 + 1241 "
|
||||
"+ 1140 = -109 W", tail(15.0, -2500.0, 1140.0, held=1140.0)
|
||||
== (-1249.0, -109.0))
|
||||
ia.batt_fresh.changed_mono = 15.0
|
||||
check("⭐ a reading that changed this cycle is taken as it is: A at 1241 W, "
|
||||
"not [0, 1241], so a grid of +2000 W puts the house at least at "
|
||||
"2000 + 10 + 1241 - 100 = 3151 W", tail(15.0, 2000.0, 1140.0,
|
||||
held=1140.0) == (2011.0, 3151.0))
|
||||
ia.batt_fresh.changed_mono = 13.0
|
||||
# A refuses to give (SAFETY-73): it reads 8 W, though it was asked 2000 W
|
||||
# since that reading. Grid -1500 W: the house is at most -1500 + 10 + 8 +
|
||||
# 1140 = -342 W, not -1500 + 10 + 2000 + 1140 = 1650 W.
|
||||
_c._read_span[A] = (8.0, 2000.0, 13.0)
|
||||
ia.batt = 8.0
|
||||
ia.refused = 1
|
||||
_ref1 = tail(15.0, -1500.0, 1140.0, held=1140.0)
|
||||
ia.refused = -1
|
||||
_c._read_span[A] = (-2000.0, 8.0, 13.0)
|
||||
_ref2 = tail(15.0, 1000.0, -500.0, held=-500.0)
|
||||
ia.refused = 0
|
||||
check("⭐ a battery that refuses one way is taken at its reading on that "
|
||||
"side: will not give, asked 2000 W, reads 8 W -> the house at most "
|
||||
"-342 W; will not take, asked -2000 W -> at least 1000 + 10 + 8 - 100 "
|
||||
"= 918 W", _ref1 == (-1482.0, -342.0) and _ref2 == (1418.0, 918.0))
|
||||
_c._read_span[A], ia.batt = (0.0, 1241.0, 10.0), 1241.0
|
||||
check("a range from an older reading than the one in hand (a cycle the "
|
||||
"loop did not run) is not used: A at its reading, 1241 W",
|
||||
tail(15.0, 2000.0, 1140.0, held=1140.0) == (2011.0, 3151.0))
|
||||
_c._read_span[A] = (0.0, 1241.0, 13.0)
|
||||
check("⭐ a battery out of the split is not counted (the meter has its "
|
||||
"power): B dropped, a grid of +2000 W puts the house at least at "
|
||||
"2000 + 10 + 0 = 2010 W, not 1910 W",
|
||||
tail(15.0, 2000.0, 1140.0, held=1140.0, live={A}) == (870.0, 2010.0))
|
||||
_c._drain_tail, _c._settle, _c._drain_house = {A}, {}, 1140.0
|
||||
_c.grid = -330.0
|
||||
check("⭐ the tail's end lets the hold go: the meter again, and the next "
|
||||
"tail starts from the loop's start",
|
||||
_c._drain_grid_w(AB) == -330.0 and _c._drain_house is None)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n drain end | N | house W | swing W now/release/1 s registers | "
|
||||
"end-cycle others W now/release | site step W now/release | grid in "
|
||||
"window W now | release")
|
||||
for k, n, h, a, b, f in TABLE:
|
||||
print(f" {k} | {n} | {h:.0f} | {a['swing']:.0f}/{b['swing']:.0f}/"
|
||||
f"{f['swing']:.0f} | "
|
||||
f"{a['first']:.0f}/{b['first']:.0f} | {a['step']:.0f}/"
|
||||
f"{b['step']:.0f} | [{a['gmin']:.0f}, {a['gmax']:.0f}] | "
|
||||
f"[{b['gmin']:.0f}, {b['gmax']:.0f}]")
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all SAFETY-103 checks passed")
|
||||
@@ -0,0 +1,307 @@
|
||||
"""Runnable check for SAFETY-107: a refusal held while another writer has the
|
||||
wire is learned once. `python3 test_safety107.py`
|
||||
|
||||
A full pack under a maintenance charge in a cheap window took no charge. At
|
||||
one inverter SAFETY-52's "another writer" end then fired every cycle, and
|
||||
compute() learned the refusal again in the same cycle: "BATTERY REFUSES" and
|
||||
"BATTERY REFUSES ENDED: another writer" each second on the rig (MAINT-13 F5).
|
||||
Now another writer ends a refusal only if compute(), judging it afresh
|
||||
against that writer's ask, no longer finds it. DECISIONS/SAFETY-107.
|
||||
|
||||
Drives the REAL Controller (read_inputs + cycle) on opt11_identity's rig.
|
||||
1. The card's case: 15 min of a full pack under a maintenance charge, N=1
|
||||
and N=2, at a 1, 2 and 5 s meter: at most 2 refusal lines, held throughout.
|
||||
2. Other writers: a trade and the peak guard over a battery that gives
|
||||
nothing. One episode each, no flap.
|
||||
3. A refusal still ends: a different writer really takes over, the battery
|
||||
delivers again, a writer asks the other way, stop. The probe clock.
|
||||
|
||||
4. Not covered, pinned: a perfectly flat reading under a writer.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in DECISIONS/SAFETY-107.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
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 app.main as app_main # noqa: E402
|
||||
from app import control # noqa: E402
|
||||
from app.arbiter import Claim, P_ARBITRAGE # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from named_absence import nth, present # noqa: E402
|
||||
|
||||
fails = []
|
||||
QUARTER = 900.0
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def run(n=1, *, plant, house, dur=QUARTER, tick=5.0, maint=True,
|
||||
cheap=lambda c, t: True, trade=lambda t: None, p1=False, cap=2500,
|
||||
auto=lambda t: True):
|
||||
"""Rows (t, commands, site target, loop reason, refused, refused_at,
|
||||
unit refusals) and every action-log line as (t, line, hint).
|
||||
|
||||
`plant(u, t, cmd)` is battery u's reading. `cheap(c, t)` opens the cheap
|
||||
window. `trade(t)` is a SET claim at P_ARBITRAGE added to the claim list
|
||||
(a writer above the loop), or None."""
|
||||
log, rows, now = [], [], [0.0]
|
||||
real_power, real_tick = rig._Hass.power, rig.TICK
|
||||
real_log, real_resolve, real_c = (Controller.log_event, app_main.resolve,
|
||||
rig.Controller)
|
||||
|
||||
def power(self, u):
|
||||
return float(plant(u, self.t, self.cmd[u]))
|
||||
|
||||
def log_event(self, msg, hint=None):
|
||||
log.append((now[0], msg, hint))
|
||||
return real_log(self, msg, hint)
|
||||
|
||||
def resolve(claims, **kw):
|
||||
v = trade(now[0])
|
||||
if v is not None:
|
||||
claims.append(Claim.set("arbitrage", P_ARBITRAGE, v, "trade"))
|
||||
return real_resolve(claims, **kw)
|
||||
|
||||
def before(c, t):
|
||||
now[0] = t
|
||||
if not getattr(c.hass, "_s107", False):
|
||||
read = c.hass.number
|
||||
|
||||
async def number(e, invert=False):
|
||||
if e == "sensor.pnow":
|
||||
return 0.05 if cheap(c, c.hass.t) else 0.30
|
||||
if e == "sensor.pavg":
|
||||
return 0.20
|
||||
return await read(e, invert)
|
||||
c.hass.number, c.hass._s107 = number, True
|
||||
if p1:
|
||||
s79._feed(c, t)
|
||||
c.auto = auto(t)
|
||||
|
||||
def on_cycle(c, t):
|
||||
rows.append((t, tuple(c.hass.cmd[u] for u in "ab"[:n]), c.target,
|
||||
c.loop_reason, c.batt_refused, c.batt_refused_at,
|
||||
tuple(i.refused for i in c.inverters)))
|
||||
|
||||
extra = {"price_now_entity": "sensor.pnow",
|
||||
"price_avg_entity": "sensor.pavg"}
|
||||
if maint:
|
||||
extra.update({"maintenance_enabled": True,
|
||||
"maintenance_start_hour": 11,
|
||||
"maintenance_charge_w": 2000})
|
||||
if p1:
|
||||
extra.update(s79.P1, peak_cap_w=cap)
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="s107-"), "tariff.db")
|
||||
rig._Hass.power, rig.TICK = power, tick
|
||||
Controller.log_event, app_main.resolve = log_event, resolve
|
||||
rig.Controller = functools.partial(real_c, peak_db_path=db)
|
||||
try:
|
||||
_, c = rig.run(n, extra=extra, dur=dur, load=house,
|
||||
soc=lambda u, t: 50.0, cut=lambda u, t: False,
|
||||
before=before, on_cycle=on_cycle,
|
||||
store=s79.store_charging(n) if maint else rig._Store())
|
||||
finally:
|
||||
rig._Hass.power, rig.TICK = real_power, real_tick
|
||||
Controller.log_event, app_main.resolve = real_log, real_resolve
|
||||
rig.Controller = real_c
|
||||
return rows, log, c
|
||||
|
||||
|
||||
def full_a(u, t, cmd):
|
||||
"""A takes no charge (a full pack), its reading jittering by a few watts
|
||||
as a real one does; B follows."""
|
||||
return -1.0 - int(t) % 3 if u == "a" and cmd < 0 else round(cmd)
|
||||
|
||||
|
||||
def flat_a(u, t, cmd):
|
||||
"""`full_a` with a perfectly flat reading (the stale-reading gate trips)."""
|
||||
return -1.0 if u == "a" and cmd < 0 else round(cmd)
|
||||
|
||||
|
||||
def empty_a(u, t, cmd):
|
||||
"""A gives nothing (an empty pack), its reading jittering by a few watts
|
||||
(SAFETY-73's jitter shape, so the stale-reading gate stays out of it);
|
||||
B follows."""
|
||||
return -4.0 - int(t) % 3 if u == "a" and cmd >= -4.0 else round(cmd)
|
||||
|
||||
|
||||
def lines(log, word="REFUSES"):
|
||||
return [x for x in log if word in x[1]]
|
||||
|
||||
|
||||
def ends(log):
|
||||
return [x for x in log if "REFUSES ENDED" in x[1]]
|
||||
|
||||
|
||||
def refused(r):
|
||||
"""Row r holds a refusal (site at N=1, any unit at N>1)."""
|
||||
return bool(r[4]) or any(r[6])
|
||||
|
||||
|
||||
def held_from(rows):
|
||||
"""True when, once learned, the refusal is held on every later row."""
|
||||
first = next((k for k, r in enumerate(rows) if refused(r)), None)
|
||||
return first is not None and all(refused(r) for r in rows[first:])
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print("1. the card: a full pack under a maintenance charge, 15 min")
|
||||
# ==============================================================================
|
||||
for n, tick, house in ((n, k, h) for n in (1, 2) for k in (1.0, 2.0, 5.0)
|
||||
for h in (800.0, -1500.0)):
|
||||
rows, log, c = run(n, plant=full_a, house=lambda t: house, tick=tick)
|
||||
ref = lines(log)
|
||||
tag = f"N={n}, {tick:.0f} s meter, house {house:+.0f} W"
|
||||
print(f" {tag}: {len(ref)} refusal line(s): "
|
||||
+ " / ".join(x[1][:60] for x in ref))
|
||||
check(f"{tag}: at most 2 refusal lines in 15 min (rig: 548 + 548)",
|
||||
1 <= len(ref) <= 2)
|
||||
check(f"{tag}: no end line", not ends(log))
|
||||
check(f"{tag}: learned once and held to the end", held_from(rows))
|
||||
# N=1: the charge's ceiling stays on the wire. N=2: SAFETY-73 closes A
|
||||
# in the refused direction, as before.
|
||||
check(f"{tag}: still in its charge, the wire as before",
|
||||
c.maints[0].phase == "charge"
|
||||
and all(r[1][0] == (-2000.0 if n == 1 else 0.0)
|
||||
for r in rows[-10:]))
|
||||
# The hint under the buttons is set at most once for the refusal.
|
||||
check(f"{tag}: the refusal hint is set once",
|
||||
sum(1 for x in ref if x[2] is not None) == 1)
|
||||
|
||||
# The probe clock: while the charge holds the wire no probe is tried (the
|
||||
# loop's ask is not on the wire), so the action log stays quiet.
|
||||
rows, log, _ = run(1, plant=full_a, house=lambda t: 800.0, dur=3600.0)
|
||||
check("N=1, an hour: still one line, no probe tried under the charge",
|
||||
len(lines(log)) == 1
|
||||
and not any(r[3] == control.PROBING for r in rows))
|
||||
|
||||
# ==============================================================================
|
||||
print("\n2. other writers: a trade and the peak guard, no flap")
|
||||
# ==============================================================================
|
||||
# A trade (a SET above the loop) asks a full pack to charge for 15 min.
|
||||
rows, log, _ = run(1, plant=full_a, house=lambda t: 800.0, maint=False,
|
||||
trade=lambda t: -1500.0)
|
||||
print(f" trade into a full pack: {len(lines(log))} line(s)")
|
||||
check("a trade into a full pack: learned once, held, no end",
|
||||
len(lines(log)) == 1 and not ends(log) and held_from(rows))
|
||||
# The peak guard asks an empty pack to give while the house is over the limit.
|
||||
rows, log, c = run(1, plant=empty_a, house=lambda t: 4500.0, maint=False,
|
||||
p1=True)
|
||||
print(f" peak guard on an empty pack: {len(lines(log))} line(s), "
|
||||
f"guard floor {c.peak_guard.floor_w if c.peak_guard else None}")
|
||||
check("the peak guard on an empty pack: the guard holds the wire",
|
||||
c.peak_guard is not None and c.peak_guard.engaged)
|
||||
check("the peak guard on an empty pack: at most 2 lines, held to the end",
|
||||
1 <= len(lines(log)) <= 2 and held_from(rows))
|
||||
|
||||
# ==============================================================================
|
||||
print("\n3. a refusal still ends")
|
||||
# ==============================================================================
|
||||
# a. Learned by the loop on an empty pack; at 300 s a trade really takes the
|
||||
# wire and asks it to give 2000 W. The refusal was learned against the
|
||||
# loop's ask: it ends, then is learned against the trade, once.
|
||||
rows, log, _ = run(1, plant=empty_a, house=lambda t: 2000.0, maint=False,
|
||||
trade=lambda t: 2000.0 if 300.0 <= t < 900.0 else None,
|
||||
dur=1200.0)
|
||||
e = ends(log)
|
||||
print(" " + " / ".join(f"{x[0]:.0f} s {x[1][:45]}" for x in lines(log)))
|
||||
check("a different writer takes over: the refusal ends, 'another writer'",
|
||||
len(e) == 1 and "another writer" in e[0][1] and 300.0 < e[0][0] < 320.0)
|
||||
check("...and is learned again against the trade, once, and held",
|
||||
len(lines(log)) == 3 and held_from([r for r in rows if r[0] > 400.0]))
|
||||
check("...and the loop keeps it when the trade leaves (no line)",
|
||||
all(x[0] < 900.0 for x in lines(log)) and refused(nth(rows, -1)))
|
||||
# The writer asks the other way, which the empty pack takes.
|
||||
rows, log, _ = run(1, plant=empty_a, house=lambda t: 2000.0, maint=False,
|
||||
trade=lambda t: -1500.0 if t >= 300.0 else None)
|
||||
e = ends(log)
|
||||
check("a writer that asks the other way ends it ('another writer')",
|
||||
len(e) == 1 and "another writer" in e[0][1] and present(nth(rows, -1))
|
||||
and not refused(nth(rows, -1)))
|
||||
# b. The full pack starts taking charge again under the same writer.
|
||||
rows, log, _ = run(1, plant=lambda u, t, cmd: (
|
||||
round(cmd) if t >= 400.0 else full_a(u, t, cmd)), house=lambda t: 800.0)
|
||||
e = ends(log)
|
||||
print(" " + " / ".join(f"{x[0]:.0f} s {x[1][:45]}" for x in lines(log)))
|
||||
check("the battery takes charge again: the refusal ends once, on its reading",
|
||||
len(e) == 1 and "the battery gives power" in e[0][1]
|
||||
and 400.0 <= e[0][0] < 430.0 and present(nth(rows, -1))
|
||||
and not refused(nth(rows, -1)))
|
||||
# c. Stop and start still end it.
|
||||
rows, log, _ = run(1, plant=full_a, house=lambda t: 800.0,
|
||||
auto=lambda t: not 500.0 <= t < 520.0)
|
||||
e = ends(log)
|
||||
check("stopping control still ends it", any("control stopped" in x[1]
|
||||
for x in e))
|
||||
# d. The probe clock. Learned under the charge; the cheap window closes when
|
||||
# the refusal is 910 s old, and the loop holds the wire (a surplus, so it
|
||||
# wants to charge). The first probe is a full PROBE_EVERY_S after the
|
||||
# window closed, as before: never at once.
|
||||
close = [None]
|
||||
|
||||
|
||||
def window(c, t):
|
||||
if close[0] is None and c.batt_refused:
|
||||
close[0] = t + 910.0
|
||||
return close[0] is None or t < close[0]
|
||||
|
||||
|
||||
rows, log, _ = run(1, plant=full_a, house=lambda t: -1500.0, cheap=window,
|
||||
dur=3000.0)
|
||||
probes = [r[0] for r in rows if r[3] == control.PROBING]
|
||||
print(" " + " / ".join(f"{x[0]:.0f} s {x[1][:45]}" for x in lines(log)))
|
||||
print(f" window closed at {close[0]} s, first probe at "
|
||||
f"{probes[0] if probes else None} s")
|
||||
check("the refusal carries over to the loop with no line",
|
||||
len(lines(log)) == 1 and held_from(rows))
|
||||
check("the first probe after the writer left comes PROBE_EVERY_S later",
|
||||
close[0] is not None and bool(probes)
|
||||
and probes[0] >= close[0] + control.PROBE_EVERY_S - 10.0)
|
||||
# e. A writer turns round on a dead pack with no gap. The evidence restarts
|
||||
# on the new sign (SAFETY-94), so the old refusal ends at once and the
|
||||
# other way is learned once, ~12 cycles later.
|
||||
rows, log, _ = run(1, plant=lambda u, t, cmd: (
|
||||
-2.0 - int(t) % 3 if u == "a" else round(cmd)), house=lambda t: 2000.0,
|
||||
maint=False, trade=lambda t: 2000.0 if t < 300.0 else -2000.0)
|
||||
e = ends(log)
|
||||
print(" " + " / ".join(f"{x[0]:.0f} s {x[1][:45]}" for x in lines(log)))
|
||||
check("a writer turns round on a dead pack: one end, then the other way",
|
||||
len(lines(log)) == 3 and len(e) == 1 and 300.0 < e[0][0] < 400.0
|
||||
and present(nth(rows, -1)) and nth(rows, -1)[4] == -1)
|
||||
|
||||
# ==============================================================================
|
||||
print("\n4. not covered: a perfectly flat reading under a writer")
|
||||
# ==============================================================================
|
||||
# The stale-reading gate trips on a flat reading while the command moves,
|
||||
# stops the site and seeds the refusal (SAFETY-52 decision 3). After the stop
|
||||
# the writer's ask comes back fresh, compute() has no saturation history
|
||||
# against it yet, so the refusal ends at the first cycle and the next trip
|
||||
# seeds it again: one pair per gate cycle, as on release/1.0 (the commands
|
||||
# are the same; DECISIONS/SAFETY-107 "Not covered"). Pinned so a change shows.
|
||||
rows, log, _ = run(1, plant=flat_a, house=lambda t: -1500.0)
|
||||
trips = len(lines(log, "STALE inverter"))
|
||||
print(f" flat reading, house -1500 W: {trips} gate trips, "
|
||||
f"{len(lines(log))} refusal lines in 15 min")
|
||||
check("flat reading: no per-cycle flap, at most one pair per gate trip",
|
||||
trips >= 1 and len(lines(log)) <= 2 * trips)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all SAFETY-107 checks passed")
|
||||
@@ -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)
|
||||
|
||||
# ============================================================================ #
|
||||
@@ -332,15 +332,18 @@ print("\n5. the N=1 and N=2 digests")
|
||||
# newest action-log line hashed. N=2: the same on release/1.0 35cdffe. N=1:
|
||||
# re-pinned by SAFETY-83, whose `--diff` against 35cdffe changes only cycles
|
||||
# where the peak guard asked for more than the drain (test_safety83.py).
|
||||
# SAFETY-85 re-pinned two: n1_drain_owner loses its false 'not keeping up'
|
||||
# rows (commands unchanged); n2_drain's loop is bounded while A drains
|
||||
# (reasons and the loop claim change, commands unchanged).
|
||||
PINNED = {
|
||||
"n1_drain":
|
||||
"b0f9ef44149163bc0acece537fe047871c00f0d9ea213e7a7fc8efc4ad3aa500",
|
||||
"n1_drain_modes":
|
||||
"95ab82426e23bce308db3edebe18b1ab37d956952e9f521adce37150ce87ee8a",
|
||||
"n1_drain_owner":
|
||||
"10f7f42a6a952e899d739eb10c674874215c440f41ead7a9f2408bc9b4cb9ea4",
|
||||
"607c2583c7ae4bdafb90d780b3414c62d76c430ca67798cd117d1a46f3bb40ae",
|
||||
"n2_drain":
|
||||
"055d83de0552d3a014dc73d87fdd697dde8b5933b8032f9b3a464ff0d08795fb",
|
||||
"5806a6f457b7baf8dfb44daddced502f1939c114e15b84029dbb2659be8c8b0f",
|
||||
"n2_drain_owner":
|
||||
"05750347f1829c249f99154613f068f442a59ef59f409093907f6d0add78dd4c",
|
||||
}
|
||||
|
||||
@@ -83,8 +83,11 @@ def banked(c):
|
||||
|
||||
|
||||
def alarmed(c):
|
||||
"""Did SAFETY-81's 'Load too high' alarm fire on this run?"""
|
||||
return any("PEAK_GUARD_SATURATED" in e for e in c.events)
|
||||
"""Did SAFETY-81's 'Load too high' alarm fire on this run? Its quarter
|
||||
stamp too, since ALLOCATOR rows can push its row out of the 40-row log
|
||||
(SAFETY-85)."""
|
||||
return c._peak_alarm_block is not None or any(
|
||||
"PEAK_GUARD_SATURATED" in e for e in c.events)
|
||||
|
||||
|
||||
def run(house, n=2, **kw):
|
||||
@@ -465,6 +468,10 @@ print("\n8. the N=1 and N=2 digests")
|
||||
# 2026-09-30, 480 cycles each, every claim hashed. N=1 and n2_charge_dear:
|
||||
# the same on release/1.0 9d5b7d1 (the tool copied in). N=2 in a cheap
|
||||
# window: new, the charge that 9d5b7d1 refused on every cycle.
|
||||
# SAFETY-85 re-pinned the three N=2 ones: the loop is bounded while A's
|
||||
# charge pin stands. n2_charge and _banked change only the loop's reason and
|
||||
# claim on 2 cycles; n2_charge_dear changes commands on 98 cycles, where A's
|
||||
# pin lifts (t=1800) and B no longer jumps 1390 W, and after the surplus step.
|
||||
PINNED = {
|
||||
"n1_charge":
|
||||
"0ed25c856456b97acb07a39299a8cb7b1a762c4d0005b88a0823c03057a80be5",
|
||||
@@ -473,11 +480,11 @@ PINNED = {
|
||||
"n1_charge_banked":
|
||||
"2619acdd06b04e56c000b168d64e1d044346c6ba3f929c94258d6ad8189ec609",
|
||||
"n2_charge":
|
||||
"748abe785a79084bc738c82d0d6f90b951e1cb78e9629195c0c01d3bb67b55e2",
|
||||
"b16d692128f14bcce3d87b142d290e0d6b13724289680e44a05215c9eaa24a7c",
|
||||
"n2_charge_dear":
|
||||
"c34f08b3b5b6256e68cf05030fb0c29522916dc8f57c351008f48384657966c0",
|
||||
"00a197d89a5dd1e8b3ae6a5d1af66329a4dc4850476569c5931c125fa4d9eaee",
|
||||
"n2_charge_banked":
|
||||
"fb600ee1dd3202910715c97c5809584d50c6b8b57b55aaa4dc83bcaaa97627ef",
|
||||
"a6973faea916e4c88ca1770047f94478346eeb1efa4889b154fad0747f91267f",
|
||||
}
|
||||
_now = s84.digests()
|
||||
for k in PINNED:
|
||||
@@ -485,7 +492,7 @@ for k in PINNED:
|
||||
f"{_now[k][:16]}")
|
||||
check("⭐ at N=1 a charge in and out of a cheap window and under a banked "
|
||||
"month peak is byte-identical to 9d5b7d1 (commands, reasons and every "
|
||||
"claim), and so is N=2 outside a cheap window",
|
||||
"claim), and N=2 outside a cheap window matches its SAFETY-85 pin",
|
||||
all(_now[k] == PINNED[k] for k in ("n1_charge", "n1_charge_dear",
|
||||
"n1_charge_banked",
|
||||
"n2_charge_dear")))
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
"""Runnable check for SAFETY-85: no false 'not keeping up' while another owner
|
||||
sets the battery. `python3 test_safety85.py`
|
||||
|
||||
The loop's integrator bound fired every ~2.5 min during maintenance and under
|
||||
the peak guard, though the battery gave what it was told: the loop's own ask,
|
||||
re-seeded and pushed by a grid error it did not own, crossed its rail. Now the
|
||||
loop's clamp counts only on a cycle the loop decided, a battery another owner
|
||||
set alarms only when it gives less than told, and at N>=2 the loop is bounded
|
||||
while maintenance holds a battery. Drives the REAL Controller
|
||||
(tools/safety84_identity.py's harness). DECISIONS/SAFETY-85-no-false-not-keeping-up.md.
|
||||
|
||||
1. N=1 and N=2: a drain, a charge and a HOLD with the house above what the
|
||||
battery is set to, an hour each: no warning; without the fix they warn.
|
||||
2. A battery that gives less than told during a drain does alarm (N=1, N=2);
|
||||
a two-cycle dip does not.
|
||||
3. Control coming back from maintenance does not jump (N=2 hold end, N=2
|
||||
charge pin lifting, N=1 hold end).
|
||||
4. The loop's own alarm stays (house over the rating); a safety limit at N=2
|
||||
(a battery at its cut-out) does not warn.
|
||||
5. The status row counts episodes, not cycles.
|
||||
6. The rules at their boundaries.
|
||||
7. Review: a battery the peak guard sets that gives nothing warns; a battery
|
||||
that lags (10 s) and a charge that tapers do not.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import logging
|
||||
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
|
||||
import app.main as app_main # noqa: E402
|
||||
from app import clamp, messages # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
fails = []
|
||||
A = "inverter-a"
|
||||
HOUR = 3600.0
|
||||
SLEW = 400.0 # the rig's slew_w
|
||||
OPT0 = dict(s84.OPT)
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
async def _call(self, *a):
|
||||
"""The rig's Home Assistant, recording each notification id it is sent."""
|
||||
if len(a) >= 3 and a[1] == "create" and isinstance(a[2], dict):
|
||||
self.__dict__.setdefault("notes", []).append(
|
||||
a[2].get("notification_id"))
|
||||
return True
|
||||
|
||||
|
||||
rig._Hass.call = _call
|
||||
|
||||
|
||||
def store(n, phase):
|
||||
"""Battery A in `phase` from the first cycle ('drain': due at 11:00;
|
||||
'idle': only with `maint=False`)."""
|
||||
if phase == "drain":
|
||||
return rig._Store()
|
||||
if phase == "charge":
|
||||
return s79.store_charging(n)
|
||||
s = rig._Store()
|
||||
if phase == "hold":
|
||||
for k, v in (("phase", "hold"), ("phase_started",
|
||||
rig.BASE.isoformat())):
|
||||
s.data[k if n == 1 else unit_key(A, k)] = v
|
||||
return s
|
||||
|
||||
|
||||
def run(n, phase, house, cap, *, dur=HOUR, soc=None, cut=None, extra=None,
|
||||
maint=True):
|
||||
"""Rows and controller; `cap` is the peak limit (20000: never binds);
|
||||
`house` is watts or a function of time."""
|
||||
s84.OPT = dict(OPT0, peak_cap_w=cap, maintenance_enabled=maint)
|
||||
if soc is None:
|
||||
def soc(u, t):
|
||||
return 99.0 if phase == "hold" and u == "a" else 60.0
|
||||
try:
|
||||
load = house if callable(house) else (lambda t: house)
|
||||
return s84.run(n, load=load, cheap=(phase == "charge"),
|
||||
store=store(n, phase), soc=soc, dur=dur, cut=cut,
|
||||
extra=dict({"maintenance_hold_min": 600,
|
||||
"maintenance_discharge_w": 2000},
|
||||
**(extra or {})))
|
||||
finally:
|
||||
s84.OPT = OPT0
|
||||
|
||||
|
||||
def warned(c):
|
||||
"""'Battery is not keeping up': notifications, cycles, episodes."""
|
||||
return (getattr(c.hass, "notes", []).count(messages.ID_LOOP_BOUND),
|
||||
c.clamp_wd.bound, c.clamp_wd.bound_episodes)
|
||||
|
||||
|
||||
def patched(obj, name, value):
|
||||
"""Run `fn` with `obj.name` replaced (the 'without the fix' runs)."""
|
||||
def wrap(fn):
|
||||
real = getattr(obj, name)
|
||||
setattr(obj, name, value)
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
setattr(obj, name, real)
|
||||
return wrap
|
||||
|
||||
|
||||
no_gate = patched(Controller, "_loop_alarm_input", lambda self, r: None)
|
||||
no_bound = patched(Controller, "loop_bound", lambda self, claims: None)
|
||||
|
||||
|
||||
def cmd(r):
|
||||
return dict(r[2])
|
||||
|
||||
|
||||
def steps(rows, lo, hi):
|
||||
"""The largest change in the total command between cycles in [lo, hi]."""
|
||||
tot = [(r[0], sum(cmd(r).values())) for r in rows]
|
||||
return max(abs(b - a) for (t0, a), (t1, b) in zip(tot, tot[1:])
|
||||
if lo <= t1 <= hi)
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. the house above what the battery is set to, an hour each")
|
||||
CASES = [
|
||||
# (n, phase, house, cap): the peak guard raises the drain / the HOLD at a
|
||||
# 3500 W limit; the rest are the phase alone under a house that pushes the
|
||||
# loop's re-seeded ask past its rail.
|
||||
(1, "drain", 6500.0, 3500), (1, "drain", 7500.0, 20000),
|
||||
(1, "charge", 10000.0, 20000),
|
||||
(1, "hold", 6500.0, 3500), (1, "hold", 9000.0, 20000),
|
||||
(2, "drain", 6500.0, 3500), (2, "charge", 10000.0, 20000),
|
||||
(2, "hold", 4500.0, 20000), (2, "hold", 6500.0, 3500),
|
||||
]
|
||||
NEW, OLD = {}, {}
|
||||
for k in CASES:
|
||||
NEW[k] = run(*k)
|
||||
OLD[k] = no_gate(lambda: no_bound(lambda: run(*k)))
|
||||
print(f" N={k[0]} {k[1]:6} house {k[2]:5.0f} limit {k[3]:5}: phases "
|
||||
f"{sorted({p for r in NEW[k][0] for p in r[-1][3]})}, warnings "
|
||||
f"(notes, cycles, episodes) {warned(NEW[k][1])}, without the fix "
|
||||
f"{warned(OLD[k][1])}")
|
||||
check("⭐ no 'Battery is not keeping up' notification, cycle or episode in any "
|
||||
"of the nine hours, at N=1 and N=2",
|
||||
all(warned(c) == (0, 0, 0) for _r, c in NEW.values()))
|
||||
check("...the phase held all hour (the battery was set by maintenance, or by "
|
||||
"the guard over it, throughout)",
|
||||
all(all(r[-1][3][0] == k[1] for r in NEW[k][0]) for k in CASES))
|
||||
check("non-vacuous: without the fix every one of the nine warns, the "
|
||||
"notification re-created on the re-assert cadence",
|
||||
all(warned(c)[0] >= 1 and warned(c)[1] > 0 for _r, c in OLD.values())
|
||||
and sum(warned(c)[0] for _r, c in OLD.values()) >= 9 * 5)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. a battery that really gives less than told still alarms")
|
||||
|
||||
|
||||
def gone(t0, t1):
|
||||
"""Battery A gives nothing from t0 to t1."""
|
||||
return lambda u, t: u == "a" and t0 <= t < t1
|
||||
|
||||
|
||||
SHORT = {n: run(n, "drain", 1000.0, 20000, dur=1200.0, cut=gone(600, 1200))
|
||||
for n in (1, 2)}
|
||||
for n, (rows, c) in SHORT.items():
|
||||
print(f" N={n}: {warned(c)}, log "
|
||||
f"{[e for e in c.events if 'OUTPUT_CLAMP' in e][:1]}")
|
||||
_r1, _c1 = SHORT[1]
|
||||
check("⭐ N=1, a drain of 2000 W that gives nothing: it warns, and the log "
|
||||
"says what was asked and what came",
|
||||
warned(_c1)[0] >= 1 and any(
|
||||
"the battery gives -4 W of the +2000 W asked" in e
|
||||
for e in _c1.events))
|
||||
_r2, _c2 = SHORT[2]
|
||||
check("⭐ N=2, the same on battery A while B follows the house: it warns and "
|
||||
"names inverter-a",
|
||||
warned(_c2)[0] >= 1 and any(
|
||||
"inverter-a gives -4 W of the +2000 W asked" in e
|
||||
for e in _c2.events))
|
||||
_hb = run(2, "hold", 4500.0, 20000, dur=1200.0,
|
||||
cut=lambda u, t: u == "b" and t >= 600)
|
||||
print(f" N=2 HOLD on A, B gives nothing: {warned(_hb[1])}")
|
||||
check("⭐ N=2, A in its HOLD and B, which the loop drives, gives nothing: the "
|
||||
"loop stands at what B may give, and it warns naming inverter-b",
|
||||
warned(_hb[1])[0] >= 1 and any(
|
||||
"inverter-b gives -4 W of the +2500 W asked" in e
|
||||
for e in _hb[1].events))
|
||||
check("...it starts no earlier than the saturation cycles allow (3 x 5 s)",
|
||||
all(e.split(" ", 1)[0] >= "11:10:10" for c in (_c1, _c2)
|
||||
for e in c.events if "OUTPUT_CLAMP" in e))
|
||||
_quiet = patched(app_main, "delivery_short", lambda *a: False)(
|
||||
lambda: run(1, "drain", 1000.0, 20000, dur=1200.0, cut=gone(600, 1200)))
|
||||
check("non-vacuous: without the delivery rule the N=1 drain that gives "
|
||||
"nothing is silent (the loop's clamp never reaches its rail here)",
|
||||
warned(_quiet[1]) == (0, 0, 0))
|
||||
_dip = run(1, "drain", 1000.0, 20000, dur=900.0, cut=gone(600, 610))
|
||||
check("a dip of two cycles is not an alarm", warned(_dip[1]) == (0, 0, 0))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. control coming back from maintenance does not jump")
|
||||
END = {"maintenance_hold_min": 10}
|
||||
_bounds = []
|
||||
_real_compute = app_main.compute
|
||||
|
||||
|
||||
def _spy_compute(*a, **kw):
|
||||
_bounds.append(kw.get("bound"))
|
||||
return _real_compute(*a, **kw)
|
||||
|
||||
|
||||
app_main.compute = _spy_compute
|
||||
try:
|
||||
_h2 = run(2, "hold", 4500.0, 20000, dur=900.0, extra=END)
|
||||
finally:
|
||||
app_main.compute = _real_compute
|
||||
_h2_old = no_bound(lambda: run(2, "hold", 4500.0, 20000, dur=900.0,
|
||||
extra=END))
|
||||
_h1 = run(1, "hold", 4500.0, 20000, dur=900.0, extra=END)
|
||||
s84.OPT = dict(OPT0)
|
||||
_cd = s84.run(2, cheap=False)
|
||||
_cd_old = no_bound(lambda: s84.run(2, cheap=False))
|
||||
print(f" N=2 hold end: largest step {steps(_h2[0], 560, 700):.0f} W "
|
||||
f"(without the bound {steps(_h2_old[0], 560, 700):.0f} W); N=1 "
|
||||
f"{steps(_h1[0], 560, 700):.0f} W; N=2 charge pin lifts at 1800 s: "
|
||||
f"{steps(_cd[0], 1780, 1840):.0f} W (without "
|
||||
f"{steps(_cd_old[0], 1780, 1840):.0f} W)")
|
||||
check("⭐ N=2, the HOLD ends under a 4500 W house: the batteries move at most "
|
||||
"one slew step a cycle",
|
||||
_h2[1].maints[0].phase != "hold" and steps(_h2[0], 560, 700) <= SLEW)
|
||||
_held = [any(p != "idle" for p in r[-1][3]) for r in _h2[0]]
|
||||
print(f" bound per cycle: {len(_bounds)} calls, {len(_held)} rows, "
|
||||
f"{sum(_held)} in maintenance, "
|
||||
f"{sum(b is not None for b in _bounds)} bounded")
|
||||
check("...and the bound is on while A holds and off from the cycle the HOLD "
|
||||
"ends (review)",
|
||||
len(_bounds) == len(_held) and any(not h for h in _held)
|
||||
and all((b is not None) == h for b, h in zip(_bounds, _held)))
|
||||
check("⭐ N=2, A's charge pin lifts at a quarter's start: at most one slew "
|
||||
"step a cycle", steps(_cd[0], 1780, 1840) <= SLEW)
|
||||
check("...and N=1 already moved one slew step at a time (re-seeded each "
|
||||
"cycle), unchanged", steps(_h1[0], 560, 700) <= SLEW)
|
||||
check("non-vacuous: without the bound the N=2 loop stands wound at its rail "
|
||||
"and both jump (2500 W at the hold's end)",
|
||||
steps(_h2_old[0], 560, 700) >= 2000.0
|
||||
and steps(_cd_old[0], 1780, 1840) >= 1000.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. what still warns, and what a safety limit does")
|
||||
_rail = run(1, "drain", 6500.0, 20000, dur=600.0, maint=False)
|
||||
print(f" N=1 no maintenance, house 6500 over a 5000 W rating: "
|
||||
f"{warned(_rail[1])}")
|
||||
check("⭐ the loop in charge at its rail (house over the rating) still warns, "
|
||||
"as SAFETY-40 decided", warned(_rail[1])[0] >= 1)
|
||||
|
||||
|
||||
def at_cutout(u, t):
|
||||
return 9.0 if u == "a" else 60.0
|
||||
|
||||
|
||||
_cut = run(2, "idle", 6000.0, 20000, dur=900.0, soc=at_cutout, maint=False)
|
||||
_cut_old = no_gate(lambda: run(2, "idle", 6000.0, 20000, dur=900.0,
|
||||
soc=at_cutout, maint=False))
|
||||
print(f" N=2, A at its cut-out, house 6000: {warned(_cut[1])}, without "
|
||||
f"the fix {warned(_cut_old[1])}")
|
||||
check("⭐ N=2, A at its cut-out level (a safety limit sets it to 0 W) and B "
|
||||
"at its rating: no warning, each battery gives what it is told",
|
||||
warned(_cut[1]) == (0, 0, 0) and cmd(_cut[0][-1]) == {"a": 0.0,
|
||||
"b": 2500.0})
|
||||
check("non-vacuous: without the fix it warns",
|
||||
warned(_cut_old[1])[0] >= 1)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. the status row counts episodes")
|
||||
_wd = clamp.ClampWatchdog()
|
||||
_t = datetime(2026, 9, 20, 11, 0, tzinfo=timezone.utc)
|
||||
for ep in range(3):
|
||||
for i in range(10):
|
||||
_wd.note(True, "x", _t, bug=False)
|
||||
_t += timedelta(seconds=5)
|
||||
for i in range(clamp.CLEAR_CYCLES):
|
||||
_wd.note(False, "", _t)
|
||||
_t += timedelta(seconds=5)
|
||||
_wd.note(True, "y", _t, bug=True)
|
||||
check("three bound episodes of ten cycles and one over-rating: 3 episodes, "
|
||||
"30 cycles", (_wd.bound_episodes, _wd.bound, _wd.episodes) == (3, 30, 4))
|
||||
_rows = [x for x in _c1.checks() if x.get("id") == "not_keeping_up"]
|
||||
check("⭐ the page row reads the episodes, not the cycles "
|
||||
f"({_c1.clamp_wd.bound_episodes} vs {_c1.clamp_wd.bound})",
|
||||
_rows and _c1.clamp_wd.bound > _c1.clamp_wd.bound_episodes >= 1
|
||||
and _rows[0]["text"].startswith(messages.T_LOOP_BOUND + ": "
|
||||
+ messages.times(
|
||||
_c1.clamp_wd.bound_episodes)))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n6. the rules at their boundaries")
|
||||
DS = [((2000, 1499, 500), True), ((2000, 1500, 500), False),
|
||||
((2000, 2600, 500), False), ((2000, -4, 500), True),
|
||||
((0, -900, 500), False), ((0, 900, 500), False),
|
||||
((-2000, -1499, 500), False), ((-2000, 600, 500), False)]
|
||||
check("delivery_short: a discharge more than the margin short; more than "
|
||||
"asked, 0 W asked, or a charge (a full pack tapers) is never short",
|
||||
all(clamp.delivery_short(*a) is want for a, want in DS))
|
||||
check("the note names each battery, asked before given",
|
||||
clamp.shortfall_note([("inverter-a", 2000.0, -4.0)])
|
||||
== "OUTPUT_CLAMP: inverter-a gives -4 W of the +2000 W asked"
|
||||
and clamp.shortfall_note([(None, -2000.0, 0.0)])
|
||||
== "OUTPUT_CLAMP: the battery gives +0 W of the -2000 W asked")
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n7. review: the peak guard, a battery that lags, a charge that tapers")
|
||||
import math # noqa: E402
|
||||
|
||||
|
||||
def under_guard(n, cut):
|
||||
"""A HOLD the peak guard raises (house 6500, limit 3500), 20 min."""
|
||||
return run(n, "hold", 6500.0, 3500, dur=1200.0, cut=cut)
|
||||
|
||||
|
||||
_g1 = under_guard(1, lambda u, t: t >= 600)
|
||||
_g2 = under_guard(2, lambda u, t: u == "a" and t >= 600)
|
||||
print(f" under the guard, the battery gives nothing: N=1 {warned(_g1[1])}, "
|
||||
f"N=2 {warned(_g2[1])}")
|
||||
check("⭐ the peak guard sets the battery and it gives nothing: it warns at "
|
||||
"N=1, and at N=2 (A, raised for the peak, gives nothing; B gives all "
|
||||
"it is told)",
|
||||
warned(_g1[1])[0] >= 1 and warned(_g2[1])[0] >= 1
|
||||
and all("peak" in r[6] for rows, _c in (_g1, _g2) for r in rows
|
||||
if 600 <= r[0] <= 620))
|
||||
check("...and it gives what it is told until then (no warning before 600 s)",
|
||||
all(e.split(" ", 1)[0] >= "11:10:00" for c in (_g1[1], _g2[1])
|
||||
for e in c.events if "OUTPUT_CLAMP" in e))
|
||||
|
||||
|
||||
def lagging(fn, lag=10.0):
|
||||
"""Run `fn` on plantsim's battery (800 W/s ramp, `lag` s first-order lag)
|
||||
at the live 2 s meter tick."""
|
||||
real_p, real_t = rig._Hass.power, rig.TICK
|
||||
|
||||
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)
|
||||
if self.cut(u, self.t) and self.cmd[u] >= -4.0:
|
||||
return -4.0
|
||||
return float(round(a))
|
||||
rig._Hass.power, rig.TICK = power, 2.0
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
rig._Hass.power, rig.TICK = real_p, real_t
|
||||
|
||||
|
||||
def kettle(t):
|
||||
"""800 W, plus 2 kW 3 min in 10, plus 3 kW 1 min in 4."""
|
||||
return (800.0 + (2000.0 if t % 600 < 180 else 0.0)
|
||||
+ (3000.0 if t % 240 < 60 else 0.0))
|
||||
|
||||
|
||||
def lag_runs():
|
||||
return [run(n, ph, kettle, 3500, dur=1800.0)
|
||||
for n, ph in ((1, "drain"), (2, "drain"), (2, "hold"))]
|
||||
|
||||
|
||||
_lag = lagging(lag_runs)
|
||||
_lag_old = patched(app_main, "SHORT_CYCLES", 1)(
|
||||
lambda: patched(app_main, "SHORT_SPAN_S", 0.0)(
|
||||
lambda: lagging(lag_runs)))
|
||||
_lag_dead = lagging(lambda: run(1, "drain", 1000.0, 20000, dur=900.0,
|
||||
cut=gone(600, 900)))
|
||||
print(f" lagging battery, house steps under the guard: "
|
||||
f"{[warned(c) for _r, c in _lag]}, graded on last cycle's command "
|
||||
f"{[warned(c) for _r, c in _lag_old]}; gives nothing "
|
||||
f"{warned(_lag_dead[1])}")
|
||||
check("⭐ a battery that lags (10 s) behind maintenance and peak-guard steps "
|
||||
"at a 2 s meter does not warn (N=1 drain, N=2 drain and HOLD, 30 min)",
|
||||
all(warned(c) == (0, 0, 0) for _r, c in _lag))
|
||||
check("non-vacuous: graded on last cycle's command alone, it warns",
|
||||
sum(warned(c)[0] for _r, c in _lag_old) >= 3)
|
||||
check("...and a lagging battery that gives nothing still warns",
|
||||
warned(_lag_dead[1])[0] >= 1)
|
||||
|
||||
|
||||
def taper(fn):
|
||||
"""Battery A takes the full charge until 600 s, then its acceptance falls
|
||||
to 150 W over 30 min (constant-voltage taper)."""
|
||||
real_p = rig._Hass.power
|
||||
|
||||
def power(self, u):
|
||||
c = self.cmd[u]
|
||||
if c < 0 and u == "a":
|
||||
c = max(c, -max(150.0, 2500.0 - max(0.0, self.t - 600) / 1800
|
||||
* 2350.0))
|
||||
return float(round(c))
|
||||
rig._Hass.power = power
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
rig._Hass.power = real_p
|
||||
|
||||
|
||||
def charge_runs():
|
||||
s84.OPT = dict(OPT0, peak_cap_w=20000)
|
||||
try:
|
||||
return [s84.run(n, load=lambda t: 800.0, cheap=True,
|
||||
store=s79.store_charging(n), soc=lambda u, t: 60.0,
|
||||
dur=2400.0) for n in (1, 2)]
|
||||
finally:
|
||||
s84.OPT = OPT0
|
||||
|
||||
|
||||
def both_ways(asked, given, margin):
|
||||
asked, given = float(asked), float(given)
|
||||
return asked != 0.0 and (asked - given) * math.copysign(1.0, asked) > margin
|
||||
|
||||
|
||||
_tp = taper(charge_runs)
|
||||
_tp_old = patched(app_main, "delivery_short", both_ways)(
|
||||
lambda: taper(charge_runs))
|
||||
print(f" a charge that tapers: {[warned(c) for _r, c in _tp]}, grading "
|
||||
f"charges too {[warned(c) for _r, c in _tp_old]}")
|
||||
check("⭐ a maintenance charge that tapers near full does not warn (N=1, N=2)",
|
||||
all(warned(c) == (0, 0, 0) for _r, c in _tp)
|
||||
and all("charge" in {p for r in rows for p in r[-1][3]}
|
||||
for rows, _c in _tp))
|
||||
check("non-vacuous: grading charges too, the taper warns",
|
||||
all(warned(c)[0] >= 3 for _r, c in _tp_old))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all SAFETY-85 checks passed")
|
||||
@@ -102,8 +102,11 @@ def on_aim(c, aim=AIM):
|
||||
|
||||
|
||||
def alarmed(c):
|
||||
"""Did SAFETY-81's 'Load too high' alarm fire on this run?"""
|
||||
return any("PEAK_GUARD_SATURATED" in e for e in c.events)
|
||||
"""Did SAFETY-81's 'Load too high' alarm fire on this run? Its quarter
|
||||
stamp too, since ALLOCATOR rows can push its row out of the 40-row log
|
||||
(SAFETY-85)."""
|
||||
return c._peak_alarm_block is not None or any(
|
||||
"PEAK_GUARD_SATURATED" in e for e in c.events)
|
||||
|
||||
|
||||
def refused(rows):
|
||||
@@ -231,8 +234,21 @@ def rows_logged(c, word):
|
||||
return sum(1 for e in c.events if word in e)
|
||||
|
||||
|
||||
def no_bound(fn):
|
||||
"""Run without SAFETY-85's loop bound (modes are off here)."""
|
||||
real = Controller.loop_bound
|
||||
Controller.loop_bound = lambda self, claims: None
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
Controller.loop_bound = real
|
||||
|
||||
|
||||
_nz_rows, _nz = run(noisy, 2)
|
||||
_nz_old = no_ceiling(lambda: run(noisy, 2))[1]
|
||||
# SAFETY-85's loop bound also keeps the loop inside what the batteries can
|
||||
# give, so the flood needs both the ceiling and the bound taken out.
|
||||
_nz_old = no_bound(lambda: no_ceiling(lambda: run(noisy, 2)))[1]
|
||||
_nz_nc = no_ceiling(lambda: run(noisy, 2))[1]
|
||||
print(f" noisy house: ALLOCATOR rows {rows_logged(_nz, 'ALLOCATOR')}, "
|
||||
f"clamp rows {rows_logged(_nz, 'OUTPUT_CLAMP')} (without the site "
|
||||
f"ceiling {rows_logged(_nz_old, 'ALLOCATOR')} and "
|
||||
@@ -244,9 +260,13 @@ check("⭐ while A gives, the site target is the guard's floor (the `hold` "
|
||||
if cmd(r)["a"] > 0.0)
|
||||
and rows_logged(_nz, "ALLOCATOR") == 0
|
||||
and rows_logged(_nz, "OUTPUT_CLAMP") <= 1)
|
||||
check("non-vacuous: without the ceiling the loop asks 5000 W and the "
|
||||
"ALLOCATOR and clamp rows fill the 40-row log",
|
||||
check("non-vacuous: without the ceiling and SAFETY-85's bound the loop asks "
|
||||
"5000 W and the ALLOCATOR and clamp rows fill the 40-row log",
|
||||
rows_logged(_nz_old, "ALLOCATOR") >= 10 and len(_nz_old.events) == 40)
|
||||
check("...and either one alone keeps it clean (without the ceiling: "
|
||||
f"{rows_logged(_nz_nc, 'ALLOCATOR')} ALLOCATOR rows)",
|
||||
on_aim(_nz_nc) and rows_logged(_nz_nc, "ALLOCATOR") == 0
|
||||
and rows_logged(_nz_nc, "OUTPUT_CLAMP") <= 1)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. a month peak banked above the limit")
|
||||
@@ -337,7 +357,8 @@ HOLDING = [MaintResult(HOLD, True, 0.0, False, []),
|
||||
def pin_after(floor, *extra, live=(A, B), results=HOLDING, c=_c):
|
||||
fake = types.SimpleNamespace(inverters=c.inverters,
|
||||
actuator_of=c.actuator_of,
|
||||
live_units=lambda: tuple(live))
|
||||
live_units=lambda: tuple(live),
|
||||
_peak_reserve=frozenset)
|
||||
cl = [Claim.limit("supervised", P_SAFETY, -2500.0, 2500.0, actuator=A),
|
||||
Claim.limit("supervised", P_SAFETY, -2500.0, 2500.0, actuator=B),
|
||||
Claim.set("maintenance", P_MAINTENANCE, 0.0, HOLD, actuator=A),
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
"""Runnable check for SAFETY-87: the peak guard uses every battery that can
|
||||
still give. `python3 test_safety87.py`
|
||||
|
||||
At N>=2 the guard graded the EMPTIEST battery (SAFETY-32), so one battery under
|
||||
the peak reserve stopped the guard for all of them: it asked for nothing and
|
||||
raised the reserve alarm while the other battery was full. Now a battery at or
|
||||
under its own reserve is HELD (it gives nothing for the peak, weight 0) and
|
||||
the guard's need is placed on the others; the reserve alarm fires only when
|
||||
they cannot hold the limit. Drives the REAL Controller (tools/
|
||||
safety84_identity.py's harness). DECISIONS/SAFETY-87-guard-uses-able-batteries.md.
|
||||
|
||||
1. Maintenance charge at N=2 (A under the reserve, B able), house over the
|
||||
aim: no reserve alarm, the quarter on the aim, A gives nothing for the
|
||||
peak while B can carry it. When B cannot, the alarm fires and names A.
|
||||
2. HOLD at N=2 (A full holding, B under the reserve): A gives, B gives
|
||||
nothing, the quarter lands on the aim; at a banked month peak too.
|
||||
3. Both under the reserve, or the other one refusing: the alarm still fires
|
||||
and names the right battery.
|
||||
4. The alarm at the quarter's edge: a one-cycle miss does not fire it.
|
||||
5. place_on_able() at its boundaries, and the claim it files.
|
||||
6. The allocator: a held battery gives last, after an Off one.
|
||||
7. A held battery draining for maintenance counts for its drain.
|
||||
8. The page: a peak alarm does not say "Not ready" or "0 W until the alarm
|
||||
clears" while control goes on.
|
||||
9. N=1 is byte-identical to release/1.0 b8ddfd7 (tools/safety87_identity.py).
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
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 safety87_identity as s87 # noqa: E402
|
||||
from app import messages, packmode, peakguard # noqa: E402
|
||||
from app.arbiter import (Claim, P_MAINTENANCE, P_MODE, P_PEAK, # noqa: E402
|
||||
P_SAFETY, UnitBounds, resolve)
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.maintenance import DRAIN, HOLD, IDLE, MaintResult # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
fails = []
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
AIM = 3450.0 # peak_cap_w 3500 - peak_margin_w 50
|
||||
TOL = 10.0 # W over the aim a banked quarter may show (SAFETY-81)
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def store(phase, *, b_off=False):
|
||||
"""Battery A in `phase` from the first cycle (B idle, cycle done)."""
|
||||
s = rig._Store()
|
||||
s.data[unit_key(A, "phase")] = phase
|
||||
s.data[unit_key(A, "phase_started")] = rig.BASE.isoformat()
|
||||
s.data[unit_key(B, "last_completed")] = rig.BASE.isoformat()
|
||||
if phase == IDLE:
|
||||
s.data[unit_key(A, "last_completed")] = rig.BASE.isoformat()
|
||||
if b_off:
|
||||
s.data[unit_key(B, packmode.USE_KEY)] = False
|
||||
return s
|
||||
|
||||
|
||||
def run(house, phase, socs, *, cheap=False, n=2, **kw):
|
||||
"""Rows, controller, the notifications raised and one record per cycle:
|
||||
(floor_limited, held, floor_w, reserve set, alarm latched)."""
|
||||
notes, seen = [], []
|
||||
real_check = Controller.check_peak_floor_limited
|
||||
|
||||
async def spy(self):
|
||||
await real_check(self)
|
||||
g = self.peak_guard
|
||||
seen.append(None if g is None else (
|
||||
g.floor_limited, g.held, g.floor_w, self._peak_reserve(),
|
||||
self._peak_floor_limited))
|
||||
|
||||
def before(c, t):
|
||||
if not getattr(c.hass, "_s87", False):
|
||||
real = c.hass.call
|
||||
|
||||
async def call(*a):
|
||||
if a[:2] == ("persistent_notification", "create"):
|
||||
notes.append(a[2])
|
||||
return await real(*a)
|
||||
c.hass.call, c.hass._s87 = call, True
|
||||
return s84.feed(cheap)(c, t)
|
||||
|
||||
kw.setdefault("dur", 2700.0)
|
||||
extra = dict({"maintenance_hold_min": 600}, **kw.pop("extra", {}))
|
||||
Controller.check_peak_floor_limited = spy
|
||||
try:
|
||||
rows, c = s84.run(
|
||||
n, load=house if callable(house) else lambda t: house,
|
||||
cheap=cheap, store=kw.pop("store", None) or store(phase),
|
||||
soc=lambda u, t: socs[u], before=before, extra=extra, **kw)
|
||||
finally:
|
||||
Controller.check_peak_floor_limited = real_check
|
||||
return rows, c, notes, seen
|
||||
|
||||
|
||||
def without_fix(fn):
|
||||
"""The shipped b8ddfd7 behaviour: no battery is ever held back."""
|
||||
real = Controller._peak_on_able
|
||||
Controller._peak_on_able = lambda self, decision, results: decision
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
Controller._peak_on_able = real
|
||||
|
||||
|
||||
def cmd(r):
|
||||
return dict(r[2])
|
||||
|
||||
|
||||
def banked(c):
|
||||
return [b.offtake_avg_w for b in c.p1.blocks]
|
||||
|
||||
|
||||
def on_aim(c, aim=AIM):
|
||||
b = banked(c)
|
||||
return len(b) >= 2 and all(aim - TOL <= v <= aim + TOL for v in b)
|
||||
|
||||
|
||||
def reserve_notes(notes):
|
||||
return [x for x in notes
|
||||
if x["notification_id"] == messages.ID_PEAK_RESERVE]
|
||||
|
||||
|
||||
def limited_events(c):
|
||||
return sum(1 for e in c.events if "PEAK_GUARD_FLOOR_LIMITED" in e)
|
||||
|
||||
|
||||
def show(tag, rows, c, notes, seen):
|
||||
a = [cmd(r)["a"] for r in rows]
|
||||
b = [cmd(r)["b"] for r in rows]
|
||||
print(f" {tag}: banked {[round(v, 1) for v in banked(c)]} W, mean A "
|
||||
f"{sum(a) / len(a):.0f} W, B {sum(b) / len(b):.0f} W, reserve "
|
||||
f"alarms {len(reserve_notes(notes))}, held "
|
||||
f"{sorted({h for s in seen if s for h in s[1]})}")
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. maintenance charge at N=2: A under the reserve, B able")
|
||||
LOW_A = {"a": 11.0, "b": 60.0}
|
||||
CH = run(4500.0, "charge", LOW_A, cheap=True)
|
||||
CH_OLD = without_fix(lambda: run(4500.0, "charge", LOW_A, cheap=True))
|
||||
show("house 4500", *CH)
|
||||
show("house 4500, before the fix", *CH_OLD)
|
||||
check("⭐ house 4500 W, B can carry the guard alone: no reserve alarm (no "
|
||||
"notification, no PEAK_GUARD_FLOOR_LIMITED line) and every quarter on "
|
||||
"the aim",
|
||||
not reserve_notes(CH[2]) and limited_events(CH[1]) == 0
|
||||
and on_aim(CH[1]))
|
||||
check("...A is held (it gives nothing for the peak) on every cycle the guard "
|
||||
"asks for power, and the guard asks for a positive floor",
|
||||
any(s and s[2] and s[2] > 0.0 for s in CH[3])
|
||||
and all(s[1] == (A,) and s[3] == {A} for s in CH[3]
|
||||
if s and s[2] and s[2] > 0.0))
|
||||
check("non-vacuous: before the fix the same run raised the reserve alarm "
|
||||
"(the SAFETY-84 rig test's check 2/3 false alarm)",
|
||||
len(reserve_notes(CH_OLD[2])) >= 1 and limited_events(CH_OLD[1]) >= 1)
|
||||
_b_carry = [r for r, s in zip(CH[0], CH[3])
|
||||
if s and s[3] == {A} and r[1] <= 2500.0 and r[1] > 0.0]
|
||||
check("...and while B can carry the site target (at most its 2500 W) A is "
|
||||
"not asked to give at all (weight 0 behind B)",
|
||||
len(_b_carry) >= 3 and all(cmd(r)["a"] <= 0.0 for r in _b_carry))
|
||||
|
||||
CH65 = run(6500.0, "charge", LOW_A, cheap=True)
|
||||
show("house 6500", *CH65)
|
||||
_n65 = reserve_notes(CH65[2])
|
||||
check("⭐ house 6500 W, B (2500 W) cannot hold the 3450 W aim alone: the "
|
||||
"reserve alarm fires and names A, the battery under its reserve, and "
|
||||
"says the others cannot hold the limit alone",
|
||||
len(_n65) >= 1
|
||||
and all("inverter-a is at its peak reserve" in x["message"]
|
||||
and "inverter-b" not in x["message"]
|
||||
and "cannot keep this 15-minute period under your 3 500 W peak "
|
||||
"limit alone" in x["message"] for x in _n65))
|
||||
check("...and while it is limited the guard still asks B for everything B "
|
||||
"has (floor 2500 W), so B is never left idle",
|
||||
any(s and s[0] and s[2] == 2500.0 for s in CH65[3])
|
||||
and all(s[2] == 2500.0 for s in CH65[3] if s and s[0]))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. HOLD at N=2: A full and holding, B under the reserve")
|
||||
LOW_B = {"a": 99.0, "b": 15.0}
|
||||
HD = run(5500.0, HOLD, LOW_B)
|
||||
HD_OLD = without_fix(lambda: run(5500.0, HOLD, LOW_B))
|
||||
show("house 5500", *HD)
|
||||
show("house 5500, before the fix", *HD_OLD)
|
||||
check("⭐ house 5500 W: A gives, B gives nothing, every quarter lands on the "
|
||||
"aim, no reserve alarm, and A stays in its HOLD",
|
||||
on_aim(HD[1]) and not reserve_notes(HD[2])
|
||||
and limited_events(HD[1]) == 0
|
||||
and all(cmd(r)["b"] == 0.0 for r in HD[0])
|
||||
and max(cmd(r)["a"] for r in HD[0]) > 2000.0
|
||||
and HD[1].maints[0].phase == HOLD)
|
||||
check("non-vacuous: before the fix A sat at 0 W, B (at 15 %) gave through "
|
||||
"the loop and the reserve alarm fired (SAFETY-86 rig case 7a)",
|
||||
all(cmd(r)["a"] == 0.0 for r in HD_OLD[0])
|
||||
and max(cmd(r)["b"] for r in HD_OLD[0]) > 2000.0
|
||||
and len(reserve_notes(HD_OLD[2])) >= 1)
|
||||
|
||||
HD65 = run(6500.0, HOLD, LOW_B)
|
||||
HD65_OLD = without_fix(lambda: run(6500.0, HOLD, LOW_B))
|
||||
show("house 6500", *HD65)
|
||||
show("house 6500, before the fix", *HD65_OLD)
|
||||
_n = reserve_notes(HD65[2])
|
||||
check("⭐ house 6500 W, A alone (2500 W) cannot hold the aim: A gives all it "
|
||||
"has, B still gives nothing, and the reserve alarm fires ONCE naming B",
|
||||
all(cmd(r)["b"] == 0.0 for r in HD65[0])
|
||||
and max(cmd(r)["a"] for r in HD65[0]) == 2500.0
|
||||
and len(_n) == 1 and "inverter-b is at its peak reserve" in
|
||||
_n[0]["message"] and "inverter-a" not in _n[0]["message"])
|
||||
check("...and the quarter is no worse than before the fix (4051 W in the "
|
||||
"SAFETY-86 review), with B kept at its reserve",
|
||||
max(banked(HD65[1])) <= max(banked(HD65_OLD[1])) + 1.0)
|
||||
|
||||
BK = run(6000.0, HOLD, LOW_B, month_peak=4000.0)
|
||||
show("house 6000, month peak 4000", *BK)
|
||||
check("⭐ under a banked month peak of 4000 W the aim is 3950 W (peak - "
|
||||
"margin): A gives, B nothing, every quarter on 3950 W, no alarm",
|
||||
on_aim(BK[1], 3950.0) and not reserve_notes(BK[2])
|
||||
and all(cmd(r)["b"] == 0.0 for r in BK[0]))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. a real loss of peak limiting still alarms")
|
||||
BOTH = run(5500.0, IDLE, {"a": 15.0, "b": 12.0})
|
||||
show("both under", *BOTH)
|
||||
_nb = reserve_notes(BOTH[2])
|
||||
check("⭐ both batteries under the reserve: the reserve alarm fires and "
|
||||
"names both, the guard asks for nothing (claim lo 0 W, 'SoC floor')",
|
||||
len(_nb) >= 1
|
||||
and all("inverter-a and inverter-b" in x["message"] for x in _nb)
|
||||
and all(not s[2] for s in BOTH[3] if s and s[0])
|
||||
and any(c[0] == "peak" and c[4] == 0.0 and c[6] == "SoC floor"
|
||||
for r in BOTH[0] for c in r[-1][0]))
|
||||
|
||||
|
||||
_ctl2 = BOTH[1]
|
||||
for _inv, _s, _r in zip(_ctl2.inverters, (15.0, 60.0), (0, 1)):
|
||||
_inv.soc, _inv.refused = _s, _r # SAFETY-73: B refuses to give
|
||||
_ref = _ctl2._peak_on_able(_lim_all := peakguard.PeakDecision(
|
||||
engaged=True, floor_w=0.0, ceiling_w=3500.0,
|
||||
ceiling_from=peakguard.CEILING_CAP, projected_w=4000.0, excess_w=550.0,
|
||||
wanted_w=2000.0, target_w=3450.0, floor_limited=True, soc=15.0,
|
||||
soc_floor=20.0, soc_floor_reason=peakguard.FLOOR_AT_SOC_FLOOR,
|
||||
reason="PEAK_GUARD_FLOOR_LIMITED: x"),
|
||||
[MaintResult(IDLE, False, None, False, [])] * 2)
|
||||
check("⭐ A under the reserve and B refusing to give (SAFETY-73): nothing can "
|
||||
"give for the peak, so it stays limited at 0 W (the alarm fires) and "
|
||||
"only A is named (test_peakguard.py part 12h drives the notification)",
|
||||
_ref.floor_limited and _ref.floor_w == 0.0 and _ref.held == (A,)
|
||||
and _ref.able_w is None and _ctl2._peak_reserve() == frozenset())
|
||||
_ctl2.inverters[1].refused = 0
|
||||
_ctl2.live_units = lambda: (A,) # B dropped from the split
|
||||
_drop = _ctl2._peak_on_able(_lim_all, [MaintResult(IDLE, False, None, False,
|
||||
[])] * 2)
|
||||
del _ctl2.live_units
|
||||
check("...and the same with B dropped (not live): nothing can give, the "
|
||||
"alarm state stands at 0 W, only A named",
|
||||
_drop.floor_limited and _drop.floor_w == 0.0 and _drop.held == (A,)
|
||||
and _drop.able_w is None)
|
||||
|
||||
|
||||
async def _notify(ctl, socs, cutouts, refused):
|
||||
"""The shipped check_peak_floor_limited() on `ctl`, its notification."""
|
||||
sent = []
|
||||
|
||||
async def call(*a):
|
||||
sent.append(a)
|
||||
return True
|
||||
for inv, so, cut, ref in zip(ctl.inverters, socs, cutouts, refused):
|
||||
inv.soc, inv.bms_soc_floor, inv.refused = so, cut, ref
|
||||
real, ctl.hass.call = ctl.hass.call, call
|
||||
ctl.peak_guard = ctl._peak_on_able(dataclasses.replace(
|
||||
_lim_all, soc=min(socs), soc_floor=peakguard.binding_soc_floor(
|
||||
ctl.packs, 20.0)), [MaintResult(IDLE, False, None, False, [])] * 2)
|
||||
ctl._peak_floor_limited = False
|
||||
try:
|
||||
await ctl.check_peak_floor_limited()
|
||||
finally:
|
||||
ctl.hass.call = real
|
||||
for inv in ctl.inverters:
|
||||
inv.bms_soc_floor, inv.refused = 10, 0
|
||||
return [a[2]["message"] for a in sent
|
||||
if a[2].get("notification_id") == messages.ID_PEAK_RESERVE]
|
||||
|
||||
|
||||
# Cut-outs A 10 % / B 35 % (simulated; rule 8 refuses it, sim/inject.py does
|
||||
# not run rule 8), A at 25 % refusing, B at 30 %. Only B is at its own floor;
|
||||
# A is above its own but under the site's highest (35 %).
|
||||
_msg = asyncio.run(_notify(_ctl2, (25.0, 30.0), (10, 35), (1, 0)))
|
||||
print(f" differing cut-outs: {_msg}")
|
||||
check("⭐ the alarm names the battery at ITS OWN floor (B, 30 % <= 35 %), not "
|
||||
"every battery under the site's highest floor (A at 25 % is above its "
|
||||
"own 20 %)",
|
||||
len(_msg) == 1 and "inverter-b is at its peak reserve" in _msg[0]
|
||||
and "inverter-a" not in _msg[0])
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. the reserve alarm at the quarter's edge")
|
||||
def spiky(t):
|
||||
"""A 5000 W house (B alone can hold the aim) with a 5 s spike to
|
||||
11000 W every 150 s, which B alone cannot."""
|
||||
return 11000.0 if t % 150 < 5 else 5000.0
|
||||
|
||||
|
||||
LOW_A_IDLE = {"a": 15.0, "b": 60.0}
|
||||
SP = run(spiky, IDLE, LOW_A_IDLE)
|
||||
_flips = sum(1 for s in SP[3] if s and s[0])
|
||||
show("spiky house", *SP)
|
||||
print(f" {_flips} cycles floor-limited (B alone short during a spike)")
|
||||
check("⭐ a 5 s spike that B alone cannot cover makes the guard limited for a "
|
||||
"cycle, and that does not fire the alarm: the miss must hold for "
|
||||
"ALARM_HOLD_S, as for 'Load too high'. No quarter goes over the aim",
|
||||
_flips >= 5 and not reserve_notes(SP[2])
|
||||
and not any(s[4] for s in SP[3] if s)
|
||||
and len(banked(SP[1])) >= 2 and max(banked(SP[1])) <= AIM)
|
||||
|
||||
|
||||
def ungated(fn):
|
||||
real = Controller._reserve_short
|
||||
Controller._reserve_short = lambda self, g: True
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
Controller._reserve_short = real
|
||||
|
||||
|
||||
_ug = ungated(lambda: run(spiky, IDLE, LOW_A_IDLE))
|
||||
check("non-vacuous: without the hold the same run raises the alarm",
|
||||
len(reserve_notes(_ug[2])) >= 1)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. place_on_able() at its boundaries")
|
||||
_lim = peakguard.PeakDecision(
|
||||
engaged=True, floor_w=0.0, ceiling_w=3500.0,
|
||||
ceiling_from=peakguard.CEILING_CAP, projected_w=4000.0, excess_w=550.0,
|
||||
wanted_w=2000.0, target_w=3450.0, floor_limited=True, soc=15.0,
|
||||
soc_floor=20.0, soc_floor_reason=peakguard.FLOOR_AT_SOC_FLOOR,
|
||||
reason="PEAK_GUARD_FLOOR_LIMITED: x")
|
||||
|
||||
|
||||
def place(d=_lim, held=("b",), able_w=2000.0, soc=60.0, floor=20.0):
|
||||
return peakguard.place_on_able(d, held, able_w=able_w, soc=soc,
|
||||
soc_floor=floor)
|
||||
|
||||
|
||||
_eq, _short = place(), place(able_w=1999.0)
|
||||
check("⭐ the others can give exactly the need (2000 W): not limited, floor "
|
||||
"2000 W, the claim reads 'quarter peak' (the page says 'the peak "
|
||||
"limit')",
|
||||
not _eq.floor_limited and _eq.engaged and _eq.floor_w == 2000.0
|
||||
and _eq.held == ("b",) and _eq.able_w == 2000.0
|
||||
and peakguard.claim(_eq).lo == 2000.0
|
||||
and peakguard.claim(_eq).reason == "quarter peak"
|
||||
and _eq.reason.startswith("PEAK_GUARD: "))
|
||||
check("...1 W short: limited, but the claim still asks them for all they "
|
||||
"have (1999 W), not 0 W",
|
||||
_short.floor_limited and _short.floor_w == 1999.0
|
||||
and peakguard.claim(_short).lo == 1999.0
|
||||
and peakguard.claim(_short).reason == "quarter peak"
|
||||
and _short.reason.startswith("PEAK_GUARD_FLOOR_LIMITED: "))
|
||||
check("...the others' own permission is graded: at or under their floor, or "
|
||||
"unread, nothing is asked (0 W, 'SoC floor')",
|
||||
all(place(soc=s).floor_w == 0.0 and place(soc=s).floor_limited
|
||||
and peakguard.claim(place(soc=s)).reason == "SoC floor"
|
||||
for s in (20.0, 19.0, None)))
|
||||
check("...the roster's verdict is kept as decide() echoed it (soc 15 %, "
|
||||
"floor 20 %), and a decision that was not limited is only labelled",
|
||||
_eq.soc == 15.0 and _eq.soc_floor == 20.0
|
||||
and all(place(d=d) == dataclasses.replace(d, held=("b",))
|
||||
for d in (dataclasses.replace(
|
||||
_lim, floor_limited=False, engaged=False,
|
||||
floor_w=None),
|
||||
dataclasses.replace(_lim, wanted_w=None))))
|
||||
check("...and at N=1 the floor-limited claim is unchanged: 0 W, 'SoC floor'",
|
||||
peakguard.claim(_lim).lo == 0.0
|
||||
and peakguard.claim(_lim).reason == "SoC floor")
|
||||
|
||||
|
||||
|
||||
def three(socs, cuts):
|
||||
"""`_peak_on_able()` on a stub roster of three (SIMULATED packs)."""
|
||||
names = ("x", "y", "z")
|
||||
invs = [types.SimpleNamespace(name=n, refused=0, rating_w=lambda: 2500.0)
|
||||
for n in names]
|
||||
stub = types.SimpleNamespace(
|
||||
inverters=invs, o={"peak_soc_floor": 20},
|
||||
packs=[peakguard.Pack(n, so, c) for n, so, c in zip(names, socs, cuts)],
|
||||
live_units=lambda: names)
|
||||
return Controller._peak_on_able(stub, _lim, [MaintResult(
|
||||
IDLE, False, None, False, [])] * 3)
|
||||
|
||||
|
||||
# x 25 % (cut-out 10 -> floor 20), y 40 % (cut-out 30), z 15 % (held). x and
|
||||
# y each clear their own floor, but 25 % is not above y's 30 %: the pair is
|
||||
# graded min SoC against max floor (SAFETY-32), so nothing is asked.
|
||||
_t3 = three((25.0, 40.0, 15.0), (10, 30, 10))
|
||||
_t3ok = three((35.0, 40.0, 15.0), (10, 30, 10))
|
||||
check("⭐ three batteries, z held: the two others are graded as a pair, the "
|
||||
"EMPTIEST of them (25 %) against the HIGHEST of their floors (30 %), "
|
||||
"so nothing is asked; at 35 % they are asked for the need",
|
||||
_t3.held == ("z",) and _t3.floor_limited and _t3.floor_w == 0.0
|
||||
and _t3ok.held == ("z",) and not _t3ok.floor_limited
|
||||
and _t3ok.floor_w == 2000.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n6. the allocator: a held battery gives last")
|
||||
|
||||
|
||||
def split(target, reserve, modes=(), floor=1500.0):
|
||||
cl = [Claim.set("loop", 10, target),
|
||||
Claim.limit("supervised", P_SAFETY, -2500.0, 2500.0, actuator=A),
|
||||
Claim.limit("supervised", P_SAFETY, -2500.0, 2500.0, actuator=B),
|
||||
Claim.limit("peak", P_PEAK, lo=min(target, floor))]
|
||||
cl += [Claim.limit("mode", P_MODE, 0.0, 0.0, "off", actuator=u)
|
||||
for u in modes]
|
||||
r = resolve(cl, units=(A, B), live=(A, B), weights={A: 1.0, B: 3.0},
|
||||
reserve=reserve)
|
||||
return {u: round(v) for u, v in r.shares.items()}
|
||||
|
||||
|
||||
check("⭐ B held: A carries the site target first (B's weight 3.0 ignored) "
|
||||
"and B only takes what A cannot: 1500 -> A 1500 / B 0, 4000 -> A 2500 "
|
||||
"/ B 1500",
|
||||
split(1500.0, {B}) == {A: 1500, B: 0}
|
||||
and split(4000.0, {B}) == {A: 2500, B: 1500})
|
||||
check("...no one held: the weights decide, as before",
|
||||
split(1500.0, set()) == {A: 375, B: 1125})
|
||||
check("...charging is not touched: a held battery may still charge first",
|
||||
split(-2000.0, {B}) == split(-2000.0, set()))
|
||||
check("⭐ A Off and B under the reserve, a 3000 W floor B cannot place: the "
|
||||
"Off battery gives first (2500 W) and B only the last 500 W - not an "
|
||||
"equal 1500/1500 split",
|
||||
split(3000.0, {B}, modes=(A,), floor=3000.0) == {A: 2500, B: 500})
|
||||
check("...OPEN, recorded: when B alone CAN place the floor, OPT-11 keeps the "
|
||||
"Off battery resting and B gives it (1500 W). See the DECISIONS entry",
|
||||
split(1500.0, {B}, modes=(A,)) == {A: 0, B: 1500})
|
||||
check("...and with B Off and nobody held, Off still gives way to the other "
|
||||
"battery, as before (OPT-11)",
|
||||
split(1500.0, set(), modes=(B,)) == {A: 1500, B: 0})
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n7. a held battery draining for maintenance")
|
||||
_ctl = HD[1]
|
||||
|
||||
|
||||
def able_w(results, socs=(15.0, 60.0)):
|
||||
for inv, s in zip(_ctl.inverters, socs):
|
||||
inv.soc, inv.refused = s, 0
|
||||
d = _ctl._peak_on_able(dataclasses.replace(
|
||||
_lim, wanted_w=6000.0), results)
|
||||
return d.able_w, d.held, d.floor_w
|
||||
|
||||
|
||||
_idle = [MaintResult(IDLE, False, None, False, [])] * 2
|
||||
_drain = [MaintResult(DRAIN, True, 2000.0, False, []),
|
||||
MaintResult(IDLE, False, None, False, [])]
|
||||
print(f" A at 15 % idle: {able_w(_idle)}; draining 2000 W: "
|
||||
f"{able_w(_drain)}")
|
||||
check("⭐ A under the reserve but draining 2000 W for its monthly cycle: "
|
||||
"that drain still counts (able 4500 W = B 2500 + 2000), so B is not "
|
||||
"asked to carry it; idle, only B's 2500 W",
|
||||
able_w(_idle) == (2500.0, (A,), 2500.0)
|
||||
and able_w(_drain) == (4500.0, (A,), 4500.0))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n8. the page while a peak alarm stands and control goes on")
|
||||
_rows65, _c65 = CH65[0], CH65[1]
|
||||
_c65._peak_floor_limited = True
|
||||
_c65.peak_guard = dataclasses.replace(
|
||||
_lim, floor_w=2500.0, held=(A,), able_w=2500.0)
|
||||
_pay = _c65.status_payload()
|
||||
_txt = json.dumps(_pay, ensure_ascii=False)
|
||||
_row = [r for r in _pay["checks"] if r.get("id") == "peak_guard"]
|
||||
print(f" banner: {_pay['banner']}; row: {_row[0]['text'] if _row else '-'}")
|
||||
check("⭐ the banner is the alarm's title without 'Not ready', and nothing "
|
||||
"on the page says the inverters go to 0 W or that an alarm stopped "
|
||||
"control",
|
||||
_pay["banner"] == messages.T_PEAK_RESERVE and _pay["level"] == "bad"
|
||||
and "Not ready" not in _txt and "until the alarm clears" not in _txt
|
||||
and "An alarm stopped control" not in _txt)
|
||||
check("...the row names A and says the others cannot hold the limit alone, "
|
||||
"in three short sentences",
|
||||
len(_row) == 1 and _row[0]["text"].startswith(
|
||||
"inverter-a battery at its peak reserve. The others cannot hold "
|
||||
"the 3 500 W peak limit alone."))
|
||||
_c65._peak_floor_limited = False
|
||||
_blip = [r for r in _c65.checks() if r.get("id") == "peak_guard"]
|
||||
_c65._peak_floor_limited = True
|
||||
check("...and while the alarm is not latched (a one-cycle miss) the row is "
|
||||
"the ordinary amber guard row, not the red reserve row",
|
||||
len(_blip) == 1 and _blip[0]["warn"] and not _blip[0].get("runs")
|
||||
and "peak reserve" not in _blip[0]["text"])
|
||||
_c65.auto = False
|
||||
_stopped = _c65.status_payload()
|
||||
_c65.auto = True
|
||||
check("...a stopped controller still says so: the peak alarm does not take "
|
||||
"the banner from it", _stopped["banner"] != messages.T_PEAK_RESERVE
|
||||
and _stopped["level"] != "bad")
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n9. N=1 is byte-identical to release/1.0 b8ddfd7")
|
||||
PINNED = { # tools/safety87_identity.py on b8ddfd7
|
||||
"n1_under":
|
||||
"d2395ef8d74fb23607ad864d5a4bf62c03044bbd9367e873675d7ad10e8c169f",
|
||||
"n1_crossing":
|
||||
"97f7321017d9d34ee17a40dfd4abf73d915f25b9faef086a483d2b5cd124b198",
|
||||
"n1_above":
|
||||
"45726a285aa84f40542d56482652397c3c1e212ba4fe2009f6cc52f537ccad52",
|
||||
"n1_under_banked":
|
||||
"a2876c7cc873bc4c974d2b4bd98e23bba2d007b84545587d2fe0b713b0a6249b",
|
||||
"n1_charge_low":
|
||||
"91336e3c0314985a3b00f314c79b5ffb5a740d218de3ad44f88a4db6e4235b32",
|
||||
"n1_hold":
|
||||
"0a1df347754c69166b20d04c15ee878bbcefb02abb824b9b452334f68f03e46d",
|
||||
}
|
||||
_now = s87.digests()
|
||||
for k in PINNED:
|
||||
print(f" {k} {'same' if _now[k] == PINNED[k] else 'DIFFERS'}")
|
||||
check("⭐ every N=1 digest (claims, commands, the guard's decision and the "
|
||||
"log) equals b8ddfd7's, under, crossing and above the reserve, in a "
|
||||
"charge and a HOLD, and under a banked peak",
|
||||
_now == PINNED)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n10. review: maintenance pins beside a held battery, N=3, the page")
|
||||
|
||||
|
||||
def _drain_store():
|
||||
s = rig._Store()
|
||||
s.data[unit_key(B, "phase")] = DRAIN
|
||||
s.data[unit_key(B, "phase_started")] = rig.BASE.isoformat()
|
||||
s.data[unit_key(A, "last_completed")] = rig.BASE.isoformat()
|
||||
return s
|
||||
|
||||
|
||||
def _drain_run():
|
||||
return run(8000.0, IDLE, {"a": 15.0, "b": 60.0}, store=_drain_store(),
|
||||
dur=900.0)
|
||||
|
||||
|
||||
DR = _drain_run()
|
||||
_real_dor = Controller._drain_over_reserve
|
||||
_real_rise = Controller._drain_rise # SAFETY-91 would raise B as well
|
||||
Controller._drain_over_reserve = lambda self, claims, results: None
|
||||
Controller._drain_rise = lambda self, claims, results: None
|
||||
try:
|
||||
DR_OLD = _drain_run()
|
||||
finally:
|
||||
Controller._drain_over_reserve = _real_dor
|
||||
Controller._drain_rise = _real_rise
|
||||
|
||||
|
||||
def _a_for_peak(rows):
|
||||
"""A's command on the cycles where the site target is the peak floor
|
||||
and B alone (2500 W) could place it."""
|
||||
return [cmd(r)["a"] for r in rows
|
||||
if r[-1][1] and r[-1][1][1] and r[1] <= 2500.0
|
||||
and abs(r[1] - r[-1][1][1]) < 1.0]
|
||||
|
||||
|
||||
print(f" B draining, A held: A for the peak {_a_for_peak(DR[0])[:4]}, "
|
||||
f"without the raise {_a_for_peak(DR_OLD[0])[:4]}")
|
||||
check("⭐ B draining (2000 W pin) while A is held: B is raised to its rating "
|
||||
"for the peak floor, so A gives nothing for it (peak > drain)",
|
||||
DR[1].maints[1].phase == DRAIN and _a_for_peak(DR[0])
|
||||
and all(a <= 0.0 for a in _a_for_peak(DR[0])))
|
||||
check("non-vacuous: without the raise A gave the floor's rest above B's pin",
|
||||
any(a > 0.0 for a in _a_for_peak(DR_OLD[0])))
|
||||
|
||||
|
||||
def _stub(names, reserve, live=None):
|
||||
"""A Controller stand-in for the pin rules, on SIMULATED units."""
|
||||
s = types.SimpleNamespace(
|
||||
inverters=[types.SimpleNamespace(name=n) for n in names],
|
||||
actuator_of=lambda src, inv=None: inv.name,
|
||||
live_units=lambda: tuple(live or names),
|
||||
_peak_reserve=lambda: frozenset(reserve))
|
||||
s._held_pin = lambda cl, u: Controller._held_pin(s, cl, u)
|
||||
return s
|
||||
|
||||
|
||||
def _hold(floor, names, reserve, extra=(), results=None):
|
||||
"""`_peak_over_hold()`: x HOLDs; returns (x's pin, resolved shares)."""
|
||||
s = _stub(names, reserve)
|
||||
cl = [Claim.limit("supervised", P_SAFETY, -2500.0, 2500.0, actuator=n)
|
||||
for n in names]
|
||||
cl += [Claim.set("maintenance", P_MAINTENANCE, 0.0, HOLD, actuator="x"),
|
||||
Claim.limit("peak", P_PEAK, lo=floor, reason="quarter peak"),
|
||||
*extra]
|
||||
res = results or ([MaintResult(HOLD, True, 0.0, False, [])]
|
||||
+ [MaintResult(IDLE, False, None, False, [])]
|
||||
* (len(names) - 1))
|
||||
hi = Controller._peak_over_hold(s, cl, res)
|
||||
if hi is not None:
|
||||
cl.append(Claim.limit("hold", P_MAINTENANCE, hi=hi))
|
||||
r = resolve(cl + [Claim.set("loop", 10, floor)], units=tuple(names),
|
||||
live=set(names), reserve=reserve)
|
||||
pin = next((c.value for c in cl
|
||||
if c.source == "maintenance" and c.actuator == "x"), None)
|
||||
return pin, {u: round(v) for u, v in r.shares.items()}
|
||||
|
||||
|
||||
_h3 = _hold(3000.0, ("x", "y", "z"), {"z"})
|
||||
_h3b = _hold(2000.0, ("x", "y", "z"), {"z"})
|
||||
print(f" N=3 HOLD x, y able, z held: floor 3000 {_h3}, 2000 {_h3b}")
|
||||
check("⭐ HOLD at N=3 with one held: the able battery gives first, the "
|
||||
"holding one the rest (3000 -> y 2500, x 500), the held one nothing",
|
||||
_h3 == (500.0, {"x": 500, "y": 2500, "z": 0})
|
||||
and _h3b == (0.0, {"x": 0, "y": 2000, "z": 0}))
|
||||
_hd = _hold(2000.0, ("x", "z"), {"z"},
|
||||
(Claim.set("maintenance", P_MAINTENANCE, 1500.0, DRAIN,
|
||||
actuator="z"),),
|
||||
[MaintResult(HOLD, True, 0.0, False, []),
|
||||
MaintResult(DRAIN, True, 1500.0, False, [])])
|
||||
_hp = _hold(2000.0, ("x", "z"), {"z"},
|
||||
(Claim.set("refused", P_SAFETY, -50.0, "probe", actuator="z"),))
|
||||
print(f" held z draining 1500: {_hd}; held z charge probe: {_hp}")
|
||||
check("⭐ a held battery's own pin counts: draining 1500 W, the hold gives "
|
||||
"only the 500 W rest (no over-placement); a charge probe leaves the "
|
||||
"hold at 0 W (never split both ways, no park)",
|
||||
_hd == (500.0, {"x": 500, "z": 1500}) and _hp[0] == 0.0)
|
||||
|
||||
_unk = three((60.0, 60.0, 60.0), (10, None, 10))
|
||||
_unr = three((60.0, None, 60.0), (10, 10, 10))
|
||||
check("⭐ N=3: an unknown cut-out or an unread level holds that battery "
|
||||
"only; the two others are asked for the need",
|
||||
_unk.held == ("y",) and not _unk.floor_limited
|
||||
and _unk.floor_w == 2000.0 and _unr.held == ("y",)
|
||||
and not _unr.floor_limited and _unr.floor_w == 2000.0)
|
||||
|
||||
_stop_row = {"ok": False, "warn": False, "id": "meter", "text": "Meter down."}
|
||||
_peak_rows = [r for r in _c65.checks() if r.get("id") == "peak_guard"]
|
||||
_c65.checks = lambda: _peak_rows[:1] + [_stop_row]
|
||||
_both = _c65.status_payload()
|
||||
del _c65.checks
|
||||
check("⭐ a row that stops control outranks the running peak alarm: the "
|
||||
"banner says 'Not ready' for it and the Home page shows the hold",
|
||||
len(_peak_rows) == 1 and _peak_rows[0].get("runs")
|
||||
and _both["banner"].startswith(messages.NOT_READY)
|
||||
and "Meter down" in _both["banner"]
|
||||
and "until the alarm clears" in json.dumps(_both, ensure_ascii=False))
|
||||
|
||||
for _inv, _s, _r in zip(_ctl2.inverters, (15.0, 60.0), (0, -1)):
|
||||
_inv.soc, _inv.refused = _s, _r # B refuses only to CHARGE
|
||||
_chg = _ctl2._peak_on_able(_lim_all, [MaintResult(IDLE, False, None, False,
|
||||
[])] * 2)
|
||||
_ctl2.inverters[1].refused = 0
|
||||
check("⭐ a battery that refuses only to charge can still give: it stays "
|
||||
"able and carries the floor (2000 W of 2500 W)",
|
||||
not _chg.floor_limited and _chg.floor_w == 2000.0
|
||||
and _chg.able_w == 2500.0 and _chg.held == (A,))
|
||||
|
||||
|
||||
def _short_for(held_w, secs=30.0):
|
||||
"""`_reserve_short()` over `secs` of a steady quarter: the meter at
|
||||
`held_w` with the able batteries already at their full 2500 W."""
|
||||
s = types.SimpleNamespace(_reserve_batt_prev=None,
|
||||
_reserve_high_since=None,
|
||||
_peak_floor_limited=False, site_batt_w=2500.0)
|
||||
g = dataclasses.replace(_lim, floor_w=2500.0, able_w=2500.0)
|
||||
out = []
|
||||
for k in range(int(secs / 5.0) + 1):
|
||||
s._peak_proj = types.SimpleNamespace(
|
||||
block_start=rig.BASE, elapsed_s=450.0 + 5.0 * k,
|
||||
projected_w=held_w, held_w=held_w)
|
||||
out.append(Controller._reserve_short(s, g))
|
||||
return out
|
||||
|
||||
|
||||
_aim_miss, _limit_miss = _short_for(3480.0), _short_for(3600.0)
|
||||
check("⭐ the reserve alarm is graded on the real limit, not the aim: a "
|
||||
"quarter ending at 3480 W (over the 3450 W aim, under 3500 W) never "
|
||||
"alarms; one ending at 3600 W alarms once it holds ALARM_HOLD_S",
|
||||
not any(_aim_miss) and not any(_limit_miss[:2])
|
||||
and all(_limit_miss[2:]))
|
||||
|
||||
_runs_src = {}
|
||||
for _f in ("main.py", "homeview.py"):
|
||||
with open(os.path.join(HERE, "app", _f), encoding="utf-8") as _fh:
|
||||
_src = _fh.read()
|
||||
for _part in _src.split('out.append({')[1:]:
|
||||
if '"runs": True' in _part.split("})", 1)[0]:
|
||||
_runs_src.setdefault(_f, []).append(
|
||||
_part.split('"id": "', 1)[-1].split('"', 1)[0])
|
||||
check("⭐ 'control still runs' is set on the two peak rows only (saturated "
|
||||
"and reserve); no row that stops control carries it",
|
||||
_runs_src == {"main.py": ["peak_guard", "peak_guard"]})
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all SAFETY-87 checks passed")
|
||||
@@ -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")
|
||||
@@ -0,0 +1,452 @@
|
||||
"""Runnable check for SAFETY-91: a held battery is drawn only after the others
|
||||
are full. `python3 test_safety91.py`
|
||||
|
||||
At N>=2 a battery at or under its peak reserve was held back only while the
|
||||
peak guard asked for power. The self-consumption loop drew it at its full
|
||||
rating while a draining battery stayed at its drain rate (SAFETY-87 rig test
|
||||
F1), and at a quarter's edge in a HOLD the held battery gave 2500 W for one
|
||||
cycle (F2). Now a held battery weighs 0 whenever the site gives, a draining
|
||||
battery rises above its drain rate before it, and the edge is graded on the
|
||||
next quarter. SAFETY-25 option A stands: once the others are full the loop
|
||||
may still draw it. Drives the REAL Controller on a lagging battery (10 s, as
|
||||
on the rig). DECISIONS/SAFETY-91-held-battery-last.md.
|
||||
|
||||
1. B draining, A held, house over B's rating: B rises to full before A gives.
|
||||
2. A held reaching its cut-out: nothing is placed on it, B gives its rating.
|
||||
3. HOLD across a quarter edge: the held battery gives nothing at the edge.
|
||||
4. Idle, A held: B carries the house first; both held or B refusing: as before;
|
||||
a SoC reading flipping across the reserve does not swap the split.
|
||||
5. The rules at their boundaries (claim lists, N=3, a site stop).
|
||||
6. next_quarter() at its boundaries.
|
||||
7. N=1 is untouched.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
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
|
||||
from app import peakguard # noqa: E402
|
||||
from app.arbiter import (Claim, P_MAINTENANCE, P_SAFETY, # noqa: E402
|
||||
resolve)
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.maintenance import DRAIN, HOLD, IDLE, MaintResult # 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, next_quarter # noqa: E402
|
||||
|
||||
fails = []
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
FULL = 2500.0
|
||||
CUT = 10.0 # bms_soc_floor of both test batteries
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def lagging(fn, lag=10.0, tick=1.0, lags=None):
|
||||
"""`fn` on plantsim's battery (800 W/s ramp, `lag` s first-order lag, or
|
||||
`lags[u]`), a 1 s meter tick, and a BMS that stops discharge at its
|
||||
cut-out."""
|
||||
real_p, real_t = rig._Hass.power, rig.TICK
|
||||
|
||||
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 / (lags or {}).get(u, lag)))
|
||||
st[u] = (a, tgt, self.t)
|
||||
return 0.0 if self.soc(u, self.t) <= CUT and a > 0 else float(round(a))
|
||||
rig._Hass.power, rig.TICK = power, tick
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
rig._Hass.power, rig.TICK = real_p, real_t
|
||||
|
||||
|
||||
def store(phase_of, n=2):
|
||||
"""`phase_of`: {name: phase}; the others have just done their cycle."""
|
||||
s = rig._Store()
|
||||
for u in (A, B)[:n]:
|
||||
key = (lambda k: k) if n == 1 else (lambda k: 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, phase_of, socs, dur, late=0.0, kwh=15.36, n=2, read=None,
|
||||
lags=None):
|
||||
"""Rows and controller. Levels follow each battery's measured power; the
|
||||
P1 telegram read at each cycle is `late` s old. `read(u, t, level)` is
|
||||
the SoC the controller reads (default the level)."""
|
||||
soc, last, levels = dict(socs), [0.0], {}
|
||||
grid = []
|
||||
|
||||
def before(c, t):
|
||||
grid.append((t, c.hass.grid()))
|
||||
for u in soc:
|
||||
soc[u] = max(CUT, soc[u] - c.hass.power(u) * (t - last[0])
|
||||
/ 3600.0 / (kwh * 10.0))
|
||||
last[0], levels[t] = t, dict(soc)
|
||||
imp, exp = split_signed(c.hass.grid())
|
||||
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=rig.BASE + timedelta(seconds=t - late),
|
||||
ingest_mono=time.monotonic()))
|
||||
rows, c = lagging(lambda: s84.run(
|
||||
n, load=house if callable(house) else (lambda t: house), cheap=False,
|
||||
store=store(phase_of, n), before=before, dur=dur,
|
||||
soc=(lambda u, t: soc[u]) if read is None
|
||||
else (lambda u, t: read(u, t, soc[u])),
|
||||
extra={"maintenance_discharge_w": 1000,
|
||||
"maintenance_hold_min": 600}), lags=lags)
|
||||
c.levels, c.grid = levels, grid
|
||||
return rows, c
|
||||
|
||||
|
||||
def without_fix(fn):
|
||||
"""release/1.0 8c438ab: held only for the peak, a drain stays pinned, the
|
||||
quarter's edge is not rolled over."""
|
||||
real = (Controller._held_back, Controller._drain_rise)
|
||||
import app.main as m
|
||||
real_nq = m.next_quarter
|
||||
Controller._held_back = lambda self: self._peak_reserve()
|
||||
Controller._drain_rise = lambda self, claims, results: None
|
||||
m.next_quarter = lambda p, max_hold_s: p
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
Controller._held_back, Controller._drain_rise = real
|
||||
m.next_quarter = real_nq
|
||||
|
||||
|
||||
def cmd(r):
|
||||
return dict(r[2])
|
||||
|
||||
|
||||
def seg(rows, lo, hi):
|
||||
"""Mean A and B commands over [lo, hi) s."""
|
||||
rs = [r for r in rows if lo <= r[0] < hi]
|
||||
return (round(sum(cmd(r)["a"] for r in rs) / len(rs)),
|
||||
round(sum(cmd(r)["b"] for r in rs) / len(rs)))
|
||||
|
||||
|
||||
def a_before_b(rows, t0=0.0):
|
||||
"""Cycles where held A is asked to give while B is not at full power."""
|
||||
return sum(1 for r in rows if r[0] >= t0
|
||||
and cmd(r)["a"] > 0.0 and cmd(r)["b"] < FULL)
|
||||
|
||||
|
||||
def unmet(rows, lo, hi):
|
||||
return sum(1 for r in rows if lo <= r[0] < hi and r[7] and "UNMET" in r[7])
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. B draining at 1000 W, A held at 15 %, house over B's rating")
|
||||
STEPS = (800.0, 4000.0, 5500.0)
|
||||
|
||||
|
||||
def steps(t):
|
||||
return STEPS[min(int(t // 300), 2)]
|
||||
|
||||
|
||||
DR = run(steps, {B: DRAIN}, {"a": 15.0, "b": 60.0}, 900.0)
|
||||
DR_OLD = without_fix(lambda: run(steps, {B: DRAIN}, {"a": 15.0, "b": 60.0},
|
||||
900.0))
|
||||
for tag, (rows, c) in (("after", DR), ("before", DR_OLD)):
|
||||
print(f" {tag}: house 800 A/B {seg(rows, 60, 300)}, 4000 "
|
||||
f"{seg(rows, 360, 600)}, 5500 {seg(rows, 660, 900)}; A asked while "
|
||||
f"B not full: {a_before_b(rows)} cycles; UNMET "
|
||||
f"{unmet(rows, 0, 900)}")
|
||||
check("⭐ A (held) is never asked to give while B has room: every cycle that "
|
||||
"puts power on A has B at its full 2500 W",
|
||||
a_before_b(DR[0]) == 0 and DR[1].maints[1].phase == DRAIN)
|
||||
check("...house 4000: B rises from its 1000 W drain rate to 2500 W and A "
|
||||
"gives only the 1500 W rest (before: A 2500, B 1000)",
|
||||
seg(DR[0], 360, 600)[1] >= 2480 and seg(DR[0], 360, 600)[0] <= 1520
|
||||
and seg(DR_OLD[0], 360, 600)[0] >= 2480
|
||||
and seg(DR_OLD[0], 360, 600)[1] <= 1020)
|
||||
check("...house 800: B still drains at its 1000 W rate (the rest is "
|
||||
"exported, as before) and A gives nothing",
|
||||
all(cmd(r)["b"] >= 1000.0 and cmd(r)["a"] == 0.0
|
||||
for r in DR[0] if 60 <= r[0] < 300))
|
||||
check("...house 5500 (over both ratings): option A stands, A is drawn as a "
|
||||
"last resort once B is full, and nothing is left UNMET",
|
||||
seg(DR[0], 660, 900) == (2500, 2500) and unmet(DR[0], 0, 900) == 0)
|
||||
check("non-vacuous: before the fix A was asked while B had room",
|
||||
a_before_b(DR_OLD[0]) >= 100)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. the held battery reaches its cut-out (house 5500)")
|
||||
CO = run(5500.0, {B: DRAIN}, {"a": 11.0, "b": 60.0}, 1200.0)
|
||||
CO_OLD = without_fix(lambda: run(5500.0, {B: DRAIN}, {"a": 11.0, "b": 60.0},
|
||||
1200.0))
|
||||
|
||||
|
||||
def after_cut(rows, c):
|
||||
"""Rows once A reads its cut-out (its level follows its power)."""
|
||||
return [r for r in rows if c.levels[r[0]]["a"] <= CUT]
|
||||
|
||||
|
||||
_ac, _ac_old = after_cut(*CO), after_cut(*CO_OLD)
|
||||
print(f" after A's cut-out: B {seg(_ac, 0, 9e9)[1]} W over {len(_ac)} "
|
||||
f"cycles (before: {seg(_ac_old, 0, 9e9)[1]} W); A soc "
|
||||
f"{CO[1].inverters[0].soc:.2f} %")
|
||||
check("⭐ at its cut-out nothing is placed on A (0 W on every cycle), B "
|
||||
"gives its full 2500 W and nothing is left UNMET on the empty A",
|
||||
CO[1].inverters[0].soc <= CUT and len(_ac) >= 100
|
||||
and all(cmd(r)["a"] == 0.0 and cmd(r)["b"] == FULL for r in _ac)
|
||||
and unmet(CO[0], 600, 1200) == 0)
|
||||
check("non-vacuous: before the fix B stayed pinned under its rating on most "
|
||||
"of those cycles (the rig saw ~2422 W)",
|
||||
len(_ac_old) >= 100
|
||||
and sum(1 for r in _ac_old if cmd(r)["b"] < FULL) > len(_ac_old) / 2)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. HOLD across a quarter edge (A full holding, B held at 15 %)")
|
||||
EDGE = run(5500.0, {A: HOLD}, {"a": 99.0, "b": 15.0}, 1000.0, late=0.5)
|
||||
EDGE_OLD = without_fix(lambda: run(5500.0, {A: HOLD}, {"a": 99.0, "b": 15.0},
|
||||
1000.0, late=0.5))
|
||||
|
||||
|
||||
def at_edge(rows):
|
||||
return [(r[0], cmd(r)["a"], cmd(r)["b"]) for r in rows
|
||||
if QUARTER_S - 1 <= r[0] <= QUARTER_S + 1]
|
||||
|
||||
|
||||
print(f" edge after {at_edge(EDGE[0])}, before {at_edge(EDGE_OLD[0])}")
|
||||
check("⭐ the telegram at the edge comes late (0.5 s): B gives nothing on any "
|
||||
"cycle (the first cycle, before any quarter is measured, aside), A "
|
||||
"keeps giving and stays in its HOLD",
|
||||
all(cmd(r)["b"] == 0.0 for r in EDGE[0] if r[0] > 0)
|
||||
and all(a > 2000.0 for _t, a, _b in at_edge(EDGE[0]))
|
||||
and EDGE[1].maints[0].phase == HOLD)
|
||||
check("non-vacuous: before the fix B gave over 2000 W at the edge (F2)",
|
||||
any(b > 2000.0 for _t, _a, b in at_edge(EDGE_OLD[0])))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. idle, house 2000 W")
|
||||
ID = run(2000.0, {}, {"a": 15.0, "b": 60.0}, 300.0)
|
||||
ID_OLD = without_fix(lambda: run(2000.0, {}, {"a": 15.0, "b": 60.0}, 300.0))
|
||||
print(f" A held: after {seg(ID[0], 120, 300)}, before "
|
||||
f"{seg(ID_OLD[0], 120, 300)}")
|
||||
check("⭐ A held, B able: B carries the house alone; A is asked only on the "
|
||||
"cycles where the lagging B is already at its full command",
|
||||
a_before_b(ID[0]) == 0 and seg(ID[0], 120, 300)[0] == 0
|
||||
and seg(ID[0], 120, 300)[1] >= 1950)
|
||||
check("non-vacuous: before the fix the weights gave A a share",
|
||||
seg(ID_OLD[0], 120, 300)[0] > 50)
|
||||
BOTH = run(2000.0, {}, {"a": 15.0, "b": 14.0}, 300.0)
|
||||
BOTH_OLD = without_fix(lambda: run(2000.0, {}, {"a": 15.0, "b": 14.0}, 300.0))
|
||||
check("...both held: nothing to give first, the split is as before",
|
||||
[r[:3] for r in BOTH[0]] == [r[:3] for r in BOTH_OLD[0]])
|
||||
|
||||
|
||||
def held_back(socs, refused=(0, 0), live=(A, B)):
|
||||
c = ID[1]
|
||||
for inv, s, r in zip(c.inverters, socs, refused):
|
||||
inv.soc, inv.refused = s, r
|
||||
c.live_units = lambda: live
|
||||
try:
|
||||
return c._held_back()
|
||||
finally:
|
||||
del c.live_units
|
||||
for inv in c.inverters:
|
||||
inv.refused = 0
|
||||
|
||||
|
||||
check("...held back only while another can give: A held beside B able; "
|
||||
"nothing with B refusing to give, B dropped, or both under",
|
||||
held_back((15.0, 60.0)) == {A} and held_back((15.0, 60.0), (0, 1))
|
||||
== frozenset() and held_back((15.0, 60.0), live=(A,)) == frozenset()
|
||||
and held_back((15.0, 14.0)) == frozenset()
|
||||
and held_back((15.0, 60.0), (0, -1)) == {A})
|
||||
|
||||
# A's SoC READING flips 20/21 % across its 20 % reserve every 10 s, and B
|
||||
# follows its command faster (3 s) than A (10 s), as two different inverters.
|
||||
def _flip(u, t, level):
|
||||
return (20.0 + int(t // 10) % 2) if u == "a" else level
|
||||
|
||||
|
||||
def _a_jumps(rows, t0):
|
||||
rs = [r for r in rows if r[0] >= t0]
|
||||
return sum(1 for p, q in zip(rs, rs[1:])
|
||||
if abs(cmd(p)["a"] - cmd(q)["a"]) > 300.0)
|
||||
|
||||
|
||||
def _grid_rms(c, t0):
|
||||
g = [w + 10.0 for t, w in c.grid if t >= t0]
|
||||
return math.sqrt(sum(w * w for w in g) / len(g))
|
||||
|
||||
|
||||
_lags = {"a": 10.0, "b": 3.0}
|
||||
FL = run(2000.0, {}, {"a": 20.0, "b": 60.0}, 600.0, read=_flip, lags=_lags)
|
||||
_real_was = Controller.__dict__["_held_back"]
|
||||
|
||||
|
||||
def _no_hyst(self):
|
||||
self._held_was = frozenset()
|
||||
return _real_was(self)
|
||||
|
||||
|
||||
Controller._held_back = _no_hyst
|
||||
try:
|
||||
FL_OLD = run(2000.0, {}, {"a": 20.0, "b": 60.0}, 600.0, read=_flip,
|
||||
lags=_lags)
|
||||
finally:
|
||||
Controller._held_back = _real_was
|
||||
print(f" A's reading flipping 20/21 %: A jumps {_a_jumps(FL[0], 120)}, "
|
||||
f"grid rms {_grid_rms(FL[1], 120):.0f} W (no hysteresis: "
|
||||
f"{_a_jumps(FL_OLD[0], 120)}, {_grid_rms(FL_OLD[1], 120):.0f} W)")
|
||||
check("⭐ a SoC reading flipping across the reserve does not swap the split: "
|
||||
"A stays held back (0 W) and the grid stays near its target",
|
||||
_a_jumps(FL[0], 120) == 0 and seg(FL[0], 120, 600)[0] == 0
|
||||
and _grid_rms(FL[1], 120) < 30.0)
|
||||
check("non-vacuous: without the 1-point hysteresis the split swaps on "
|
||||
"every flip and the grid swings",
|
||||
_a_jumps(FL_OLD[0], 120) >= 20 and _grid_rms(FL_OLD[1], 120) > 100.0)
|
||||
REL = run(2000.0, {}, {"a": 20.0, "b": 60.0}, 300.0,
|
||||
read=lambda u, t, lv: (20.0 if t < 100 else 21.0 if t < 200
|
||||
else 22.0) if u == "a" else lv)
|
||||
check("...held back at 20 %, still at 21 %, released at 22 % (more than 1 "
|
||||
"point above its reserve)",
|
||||
seg(REL[0], 40, 100)[0] == 0 and seg(REL[0], 140, 200)[0] == 0
|
||||
and seg(REL[0], 240, 300)[0] > 500)
|
||||
ID[1]._held_was = frozenset({A, B})
|
||||
check("...a battery kept held back by the hysteresis is not one that can "
|
||||
"give: A at 20 % and B kept at 21 % hold nobody back",
|
||||
held_back((20.0, 21.0)) == frozenset())
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. the rules at their boundaries")
|
||||
|
||||
|
||||
def stub(names, held, live=None):
|
||||
"""A Controller stand-in for `_drain_rise()`, on SIMULATED units."""
|
||||
s = types.SimpleNamespace(
|
||||
inverters=[types.SimpleNamespace(name=n) for n in names],
|
||||
actuator_of=lambda src, inv=None: inv.name,
|
||||
live_units=lambda: tuple(live or names),
|
||||
_held_back=lambda: frozenset(held))
|
||||
return s
|
||||
|
||||
|
||||
def rise(names, held, target, drains, extra=(), live=None):
|
||||
"""`_drain_rise()` then the split: (claims kinds, shares)."""
|
||||
cl = [Claim.limit("supervised", P_SAFETY, -FULL, FULL, actuator=n)
|
||||
for n in names]
|
||||
cl += [Claim.set("maintenance", P_MAINTENANCE, w, DRAIN, actuator=n)
|
||||
for n, w in drains.items()]
|
||||
cl += list(extra)
|
||||
res = [MaintResult(DRAIN, True, drains[n], False, []) if n in drains
|
||||
else MaintResult(IDLE, False, None, False, []) for n in names]
|
||||
Controller._drain_rise(stub(names, held, live), cl, res)
|
||||
r = resolve(cl + [Claim.set("loop", 10, target)], units=tuple(names),
|
||||
live=set(live or names), reserve=held)
|
||||
kinds = {c.actuator: c.kind for c in cl if c.source == "maintenance"}
|
||||
return kinds, {u: round(v) for u, v in r.shares.items()}
|
||||
|
||||
|
||||
_n3 = rise(("x", "y", "z"), {"z"}, 1500.0, {"x": 1000.0})
|
||||
print(f" N=3, x draining 1000, y able, z held, 1500 W: {_n3[1]}")
|
||||
check("⭐ N=3: the draining battery keeps its drain rate as a floor (x >= "
|
||||
"1000 W) while sharing with the able one; the held one gives nothing",
|
||||
_n3[0] == {"x": "limit"} and _n3[1]["x"] >= 1000 and _n3[1]["z"] == 0
|
||||
and sum(_n3[1].values()) == 1500)
|
||||
_stop = rise((A, B), {A}, 3000.0, {B: 1000.0},
|
||||
(Claim.set("safety", P_SAFETY, 0.0, "stopped"),))
|
||||
check("⭐ a site stop decides alone: the drain stays a pin, nothing rises",
|
||||
_stop[0] == {B: "set"} and _stop[1] == {A: 0, B: 0})
|
||||
_hd = rise((A, B), {A}, 5000.0, {A: 1000.0})
|
||||
check("...a held battery's own drain stays a pin (it is not raised for the "
|
||||
"loop): A 1000 W, B 2500 W",
|
||||
_hd[0] == {A: "set"} and _hd[1] == {A: 1000, B: 2500})
|
||||
_nh = rise((A, B), set(), 3000.0, {B: 1000.0})
|
||||
check("...with nothing held the drain stays a pin, as before (SAFETY-78)",
|
||||
_nh[0] == {B: "set"} and _nh[1] == {A: 2000, B: 1000})
|
||||
_dead = rise((A, B), {A}, 3000.0, {B: 1000.0}, live=(A,))
|
||||
check("...a draining battery that is not live is not touched",
|
||||
_dead[0] == {B: "set"})
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n6. next_quarter()")
|
||||
T0 = rig.BASE
|
||||
|
||||
|
||||
def proj(elapsed, covered=1.0, held=3000.0, start=T0):
|
||||
return Projection(projected_w=2000.0, elapsed_w=1900.0, elapsed_s=elapsed,
|
||||
covered_frac=covered, complete=covered >= 1.0,
|
||||
block_start=start, held_w=held)
|
||||
|
||||
|
||||
_in = proj(899.0)
|
||||
_past = next_quarter(proj(900.4, covered=899.9 / 900.4), 30.0)
|
||||
check("⭐ inside its block a projection is unchanged (the same object)",
|
||||
next_quarter(_in, 30.0) is _in)
|
||||
check("⭐ past its end with a fresh held reading: the next quarter, "
|
||||
"projected from that reading",
|
||||
_past.block_start == T0 + timedelta(seconds=QUARTER_S)
|
||||
and abs(_past.elapsed_s - 0.4) < 1e-9 and _past.projected_w == 3000.0
|
||||
and _past.covered_frac == 1.0
|
||||
and peakguard.blindness(_past, 30.0, False) is None)
|
||||
_old = proj(960.0, covered=880.0 / 960.0)
|
||||
_edge29 = proj(901.0, covered=871.5 / 901.0) # 29.5 s uncovered
|
||||
_edge31 = proj(901.0, covered=870.5 / 901.0) # 30.5 s uncovered
|
||||
check("...the held reading is credited up to max_hold_s and no further "
|
||||
"(29.5 s uncovered: rolled; 30.5 s: unchanged)",
|
||||
next_quarter(_edge29, 30.0) is not _edge29
|
||||
and next_quarter(_edge31, 30.0) is _edge31)
|
||||
check("...a held reading too old to be credited, no block, or an export: "
|
||||
"unchanged, or projected at 0 W offtake",
|
||||
next_quarter(_old, 30.0) is _old
|
||||
and next_quarter(proj(950.0, start=None), 30.0).block_start is None
|
||||
and next_quarter(proj(900.5, held=-800.0), 30.0).projected_w == 0.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n7. N=1")
|
||||
for _name, _args in (("HOLD, late telegram at the edge",
|
||||
(5500.0, {A: HOLD}, {"a": 99.0}, 1000.0, 0.5)),
|
||||
("drain under the reserve, house 4000",
|
||||
(4000.0, {A: DRAIN}, {"a": 15.0}, 600.0, 0.5))):
|
||||
_new = run(*_args, n=1)
|
||||
_old = without_fix(lambda: run(*_args, n=1))
|
||||
print(f" {_name}: A at the edge "
|
||||
f"{[cmd(r)['a'] for r in _new[0] if 899 <= r[0] <= 901]}")
|
||||
check(f"⭐ N=1, {_name}: every row (claims, commands, the guard) is "
|
||||
"identical with and without the fix",
|
||||
_new[1].maints[0].phase == _args[1][A] and len(_new[0]) > 500
|
||||
and _new[0] == _old[0])
|
||||
_one = types.SimpleNamespace(inverters=[object()])
|
||||
_cl = [Claim.set("maintenance", P_MAINTENANCE, 1000.0, DRAIN)]
|
||||
check("⭐ at one inverter nothing is held back and no pin moves (the N=1 "
|
||||
"identity digests in test_safety78/79/83/84/86/87 stay pinned)",
|
||||
Controller._held_back(_one) == frozenset()
|
||||
and Controller._drain_rise(types.SimpleNamespace(
|
||||
inverters=[object()], _held_back=lambda: frozenset()), _cl,
|
||||
[MaintResult(DRAIN, True, 1000.0, False, [])]) is None
|
||||
and _cl[0].kind == "set")
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all SAFETY-91 checks passed")
|
||||
@@ -0,0 +1,454 @@
|
||||
"""Runnable check for SAFETY-93: the maintenance charge end hands back
|
||||
smoothly, with no grid charging at a high house. `python3 test_safety93.py`
|
||||
|
||||
At N=2, when battery A's maintenance charge ended, its watts stayed in the
|
||||
loop's site target and landed on B: B went 0 -> -1600 W in one cycle and
|
||||
charged from the grid with the house at 6000 W (SAFETY-85 rig test, check 4).
|
||||
Now the loop starts from what B stood at plus A's new command. Drives the
|
||||
REAL Controller on a lagging battery (10 s, 2 s meter, as on the rig).
|
||||
DECISIONS/SAFETY-93-charge-end.md.
|
||||
|
||||
1. The rig case: A charges 2000 W late in a quarter with the house at 6000 W,
|
||||
then its charge ends (HOLD).
|
||||
2. The same at a low house.
|
||||
3. The charge ends another way: a timeout (abort) and the battery switched
|
||||
Off.
|
||||
4. The charge ends while the guard has suspended it (A gives under the loop).
|
||||
5. N=1 is untouched; a missed poll on the end cycle; the rule at its
|
||||
boundaries.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from datetime import timedelta
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import safety79_identity as s79 # noqa: E402
|
||||
import safety84_identity as s84 # noqa: E402
|
||||
from app import packmode # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.maintenance import (CHARGE_TIMEOUT_MIN, DRAIN, HOLD, IDLE, # noqa: E402
|
||||
MaintResult)
|
||||
from app.store import unit_key # noqa: E402
|
||||
from named_absence import first, present # noqa: E402
|
||||
|
||||
fails = []
|
||||
A = "inverter-a"
|
||||
SLEW = 400.0 # the rig's slew_w
|
||||
AIM = 3500 - 50 # peak_cap_w - peak_margin_w
|
||||
OPT0 = dict(s84.OPT)
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def lagging(fn, lag=10.0):
|
||||
"""plantsim's battery (800 W/s ramp, `lag` s first-order lag) at the
|
||||
live 2 s meter tick (test_safety85.py part 7)."""
|
||||
real_p, real_t = rig._Hass.power, rig.TICK
|
||||
|
||||
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 float(round(a))
|
||||
rig._Hass.power, rig.TICK = power, 2.0
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
rig._Hass.power, rig.TICK = real_p, real_t
|
||||
|
||||
|
||||
def run(n, house, end, *, step=None, dur=None, store=None, extra=None,
|
||||
before=None):
|
||||
"""A charging in a cheap window from the first cycle (quarters start at
|
||||
0, 900, 1800 s); the house is 1200 W, then `house` from `step`; A reads
|
||||
99 % (its charge target) from `end`. Returns (rows, controller)."""
|
||||
step = end if step is None else step
|
||||
s84.OPT = dict(OPT0, peak_cap_w=3500)
|
||||
feed = s84.feed(True)
|
||||
|
||||
def both(c, t):
|
||||
if before is not None:
|
||||
before(c, t)
|
||||
feed(c, t)
|
||||
try:
|
||||
return lagging(lambda: s84.run(
|
||||
n, load=lambda t: house if t >= step else 1200.0, cheap=True,
|
||||
store=store or s79.store_charging(n),
|
||||
soc=lambda u, t: (99.0 if t >= end else 90.0) if u == "a"
|
||||
else 60.0, dur=dur or end + 300.0, before=both,
|
||||
extra=dict({"maintenance_hold_min": 600}, **(extra or {}))))
|
||||
finally:
|
||||
s84.OPT = OPT0
|
||||
|
||||
|
||||
def cmd(r):
|
||||
return dict(r[2])
|
||||
|
||||
|
||||
def moves(rows, u, lo, hi):
|
||||
"""The largest change in battery `u`'s command between cycles in
|
||||
(lo, hi]."""
|
||||
return max(abs(cmd(b)[u] - cmd(a)[u]) for a, b in zip(rows, rows[1:])
|
||||
if lo < b[0] <= hi)
|
||||
|
||||
|
||||
def charging_hi(rows, lo, hi, house_at):
|
||||
"""Cycles in [lo, hi] where a battery charges while the house is above
|
||||
the peak aim."""
|
||||
return sum(1 for r in rows if lo <= r[0] <= hi and house_at(r[0]) > AIM
|
||||
and min(cmd(r).values()) < 0.0)
|
||||
|
||||
|
||||
# None on a tree without the fix (f3e409c), where this file runs red.
|
||||
no_fix = getattr(Controller, "_reseed_charge_end", None)
|
||||
|
||||
|
||||
def without(fn):
|
||||
"""Run `fn` with the SAFETY-93 reseed taken out."""
|
||||
if no_fix is None:
|
||||
return fn()
|
||||
Controller._reseed_charge_end = lambda self, ended: None
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
Controller._reseed_charge_end = no_fix
|
||||
|
||||
|
||||
def phase_at(rows, t):
|
||||
return first((r[-1][3][0] for r in rows if r[0] == t), f"row {t}")
|
||||
|
||||
|
||||
def at(rows, t, u):
|
||||
"""Battery `u`'s command at `t` s."""
|
||||
r = first((r for r in rows if r[0] == t), f"row {t}")
|
||||
return dict(r[2])[u] if present(r) else r
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. the rig case: A charges 2000 W late in a quarter, house 6000 W")
|
||||
# House 1200 W until 1760 s, then 6000 W; the quarter (900-1800 s) has room,
|
||||
# so the charge stays at 2000 W. A is full at 1780 s. Graded to 1798 s: the
|
||||
# guard grades a new quarter from 1800 s.
|
||||
R1 = run(2, 6000.0, 1780.0, step=1760.0)
|
||||
R1_old = without(lambda: run(2, 6000.0, 1780.0, step=1760.0))
|
||||
|
||||
|
||||
def hi1(t):
|
||||
return 6000.0 if t >= 1760.0 else 1200.0
|
||||
|
||||
|
||||
for name, (rows, _c) in (("now", R1), ("without the fix", R1_old)):
|
||||
print(f" {name}: " + ", ".join(
|
||||
f"{r[0]:.0f}s A {cmd(r)['a']:.0f} B {cmd(r)['b']:.0f}"
|
||||
for r in rows if 1776 <= r[0] <= 1786))
|
||||
_r, _ = R1
|
||||
check("the case is the rig's: A charges 2000 W with the house at 6000 W the "
|
||||
"cycle before its charge ends, then holds",
|
||||
at(_r, 1778.0, "a") == -2000.0
|
||||
and phase_at(_r, 1778.0) == "charge" and phase_at(_r, 1780.0) == HOLD)
|
||||
check("⭐ no battery charges from the grid after the charge ends (house above "
|
||||
"the aim)", charging_hi(_r, 1780, 1798, hi1) == 0)
|
||||
check("⭐ B moves at most one slew step a cycle across the charge end, and A "
|
||||
"after its HOLD pin", moves(_r, "b", 1776, 1798) <= SLEW
|
||||
and moves(_r, "a", 1780, 1798) <= SLEW)
|
||||
check("...and B gives toward the house at once (+400 W at the end cycle)",
|
||||
at(_r, 1780.0, "b") == SLEW)
|
||||
check("non-vacuous: without the fix B jumps 0 -> -1600 W and charges from "
|
||||
"the grid for 4 cycles", moves(R1_old[0], "b", 1776, 1798) >= 1600
|
||||
and charging_hi(R1_old[0], 1780, 1798, hi1) >= 4)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. the same at a low house")
|
||||
R2 = run(2, 1200.0, 1300.0)
|
||||
R2_old = without(lambda: run(2, 1200.0, 1300.0))
|
||||
print(f" B's largest move: {moves(R2[0], 'b', 1296, 1600):.0f} W, without "
|
||||
f"the fix {moves(R2_old[0], 'b', 1296, 1600):.0f} W; B's least "
|
||||
f"command after the end "
|
||||
f"{min(cmd(r)['b'] for r in R2[0] if r[0] >= 1300):.0f}"
|
||||
f" W, without {min(cmd(r)['b'] for r in R2_old[0] if r[0] >= 1300):.0f}"
|
||||
" W")
|
||||
check("⭐ house 1200 W: B moves at most one slew step a cycle and never "
|
||||
"charges from the grid after A's charge ends",
|
||||
moves(R2[0], "b", 1296, 1600) <= SLEW
|
||||
and min(cmd(r)["b"] for r in R2[0] if r[0] >= 1300) >= 0.0)
|
||||
check("non-vacuous: without the fix B jumps to -1600 W (2800 W from the grid)",
|
||||
min(cmd(r)["b"] for r in R2_old[0] if r[0] >= 1300) <= -1600.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. the charge ends another way")
|
||||
|
||||
|
||||
def timed_out(end):
|
||||
"""A store whose charge passes CHARGE_TIMEOUT_MIN at `end` s."""
|
||||
s = s79.store_charging(2)
|
||||
s.data[unit_key(A, "phase_started")] = (
|
||||
rig.BASE + timedelta(seconds=end - CHARGE_TIMEOUT_MIN * 60 - 1)
|
||||
).isoformat()
|
||||
return s
|
||||
|
||||
|
||||
def b_done():
|
||||
"""A charging; B finished its monthly cycle, so it starts none here."""
|
||||
s = s79.store_charging(2)
|
||||
s.data[unit_key("inverter-b", "last_completed")] = rig.BASE.isoformat()
|
||||
return s
|
||||
|
||||
|
||||
def switch_off(at):
|
||||
"""Battery A switched Off at `at` s (OPT-11 modes on)."""
|
||||
def before(c, t):
|
||||
if t >= at:
|
||||
c.store.data[unit_key(A, packmode.USE_KEY)] = False
|
||||
return before
|
||||
|
||||
|
||||
def timed_out_b_done(end):
|
||||
"""timed_out(), with B's monthly cycle finished so it starts none."""
|
||||
s = timed_out(end)
|
||||
s.data[unit_key("inverter-b", "last_completed")] = rig.BASE.isoformat()
|
||||
return s
|
||||
|
||||
|
||||
ENDS = {
|
||||
"timeout": lambda: run(2, 6000.0, 9999.0, step=1760.0, dur=2080.0,
|
||||
store=timed_out_b_done(1780.0)),
|
||||
"Off": lambda: run(2, 6000.0, 9999.0, step=1760.0, dur=2080.0,
|
||||
store=b_done(), before=switch_off(1780.0),
|
||||
extra={"battery_modes_enabled": True}),
|
||||
}
|
||||
for k, fn in ENDS.items():
|
||||
rows, c = fn()
|
||||
old = without(fn)[0]
|
||||
print(f" {k}: A {[cmd(r)['a'] for r in rows if 1776 <= r[0] <= 1784]}"
|
||||
f" B {[cmd(r)['b'] for r in rows if 1776 <= r[0] <= 1784]}; "
|
||||
f"without the fix B "
|
||||
f"{[cmd(r)['b'] for r in old if 1776 <= r[0] <= 1784]}")
|
||||
check(f"{k}: the charge ends at 1780 s",
|
||||
phase_at(rows, 1778.0) == "charge"
|
||||
and phase_at(rows, 1780.0) == IDLE)
|
||||
check(f"⭐ {k}: no battery charges from the grid after it, and B moves at "
|
||||
f"most one slew step a cycle",
|
||||
charging_hi(rows, 1780, 1798, hi1) == 0
|
||||
and moves(rows, "b", 1776, 1798) <= SLEW)
|
||||
check(f"non-vacuous ({k}): without the fix B jumps to -1600 W, from "
|
||||
f"the grid", at(old, 1780.0, "b") == -1600.0
|
||||
and charging_hi(old, 1780, 1798, hi1) >= 1)
|
||||
|
||||
# MAINT-13: A's timed-out charge is a failed cycle and backs off, so A no longer
|
||||
# restarts at once; a due B takes the slot instead and starts ITS drain. That is
|
||||
# a drain start (a step to the drain rate by design), not a charge end.
|
||||
rows, c = run(2, 6000.0, 9999.0, step=1760.0, dur=2080.0,
|
||||
store=timed_out(1780.0))
|
||||
print(f" timeout, B due: A {[cmd(r)['a'] for r in rows if 1776 <= r[0] <= 1790]}"
|
||||
f" B {[cmd(r)['b'] for r in rows if 1776 <= r[0] <= 1790]}")
|
||||
check("timeout with B due: the charge ends at 1780 s and A is not restarted",
|
||||
phase_at(rows, 1778.0) == "charge" and phase_at(rows, 1780.0) == IDLE
|
||||
and c.maints[0].phase == IDLE
|
||||
and c.maints[0].backoff_until(rig.BASE + timedelta(seconds=1800))
|
||||
is not None)
|
||||
# A command below 0 W is a charge, so B's commands must stay at or above 0;
|
||||
# the drain rate is the one this rig's controller was configured with.
|
||||
drain_w = c.maints[1].cfg.discharge_w
|
||||
print(f" B drain rate configured on the rig: {drain_w:.0f} W")
|
||||
check("⭐ timeout with B due: no battery charges from the grid after it",
|
||||
charging_hi(rows, 1780, 1798, hi1) == 0
|
||||
and min(cmd(r)["b"] for r in rows if r[0] >= 1780) >= 0.0)
|
||||
check("...B takes the slot and begins its own drain, which is the only large "
|
||||
"step, to the configured drain rate and no higher",
|
||||
c.maints[1].phase == DRAIN
|
||||
and max(cmd(r)["b"] for r in rows if r[0] >= 1780) <= drain_w
|
||||
and moves(rows, "a", 1780, 1798) <= SLEW)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. the charge ends while the guard has suspended it")
|
||||
# House 6000 W from 1500 s: the guard suspends the shaping, the loop has both
|
||||
# batteries give. A is full at 1530 s; its HOLD pin takes it to 0 W.
|
||||
R4 = run(2, 6000.0, 1530.0, step=1500.0)
|
||||
R4_old = without(lambda: run(2, 6000.0, 1530.0, step=1500.0))
|
||||
_a4 = at(R4[0], 1528.0, "a")
|
||||
print(f" A gave {_a4:.0f} W before its HOLD; B's largest move "
|
||||
f"{moves(R4[0], 'b', 1526, 1798):.0f} W, without the fix "
|
||||
f"{moves(R4_old[0], 'b', 1526, 1798):.0f} W")
|
||||
check("A gives under the loop before its charge ends",
|
||||
present(_a4) and _a4 > SLEW and R4[1].maints[0].phase == HOLD)
|
||||
check("⭐ B takes A's share at one slew step a cycle (A's own give stops "
|
||||
"with its HOLD pin, as before)", moves(R4[0], "b", 1526, 1798) <= SLEW
|
||||
and charging_hi(R4[0], 1530, 1798, lambda t: 6000.0) == 0)
|
||||
check("non-vacuous: without the fix B jumps more than a slew step",
|
||||
moves(R4_old[0], "b", 1526, 1798) > SLEW)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. N=1 is untouched; a missed poll; the rule at its boundaries")
|
||||
|
||||
|
||||
def digest(rows):
|
||||
return hashlib.sha256(repr(rows).encode()).hexdigest()
|
||||
|
||||
|
||||
_n1 = [digest(run(1, h, e, step=s)[0]) for h, e, s in
|
||||
((6000.0, 1780.0, 1760.0), (1200.0, 1300.0, None))]
|
||||
_n1_old = [digest(without(lambda: run(1, h, e, step=s))[0]) for h, e, s in
|
||||
((6000.0, 1780.0, 1760.0), (1200.0, 1300.0, None))]
|
||||
check("⭐ N=1, the charge ending at a high and a low house: every row "
|
||||
"byte-identical with and without the reseed", _n1 == _n1_old)
|
||||
|
||||
if no_fix is None:
|
||||
print(f"\n{len(fails)} FAILED (no _reseed_charge_end): " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def missed_poll(at_t):
|
||||
"""Battery B's power reading is missing on the cycle at `at_t` s."""
|
||||
def before(c, t):
|
||||
if not getattr(c.hass, "_s93", 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._s93 = number, True
|
||||
return before
|
||||
|
||||
|
||||
R6 = run(2, 6000.0, 1780.0, step=1760.0, before=missed_poll(1780.0))
|
||||
R6_old = without(lambda: run(2, 6000.0, 1780.0, step=1760.0,
|
||||
before=missed_poll(1780.0)))
|
||||
print(f" a missed poll on the end cycle: B "
|
||||
f"{[cmd(r)['b'] for r in R6[0] if 1778 <= r[0] <= 1786]}, without "
|
||||
f"the fix {[cmd(r)['b'] for r in R6_old[0] if 1778 <= r[0] <= 1786]}")
|
||||
check("a missed poll on the cycle the charge ends: the loop holds that cycle",
|
||||
phase_at(R6[0], 1780.0) == HOLD
|
||||
and "inputs missing" in str(first(r[6] for r in R6[0] if r[0] == 1780.0)))
|
||||
check("⭐ ...and the end still reaches the loop on the next cycle: no grid "
|
||||
"charge (A's charge stands through the held cycle), one slew step a "
|
||||
"cycle", charging_hi(R6[0], 1782, 1798, hi1) == 0
|
||||
and moves(R6[0], "b", 1776, 1798) <= SLEW)
|
||||
check("non-vacuous: without the fix B charges from the grid",
|
||||
charging_hi(R6_old[0], 1782, 1798, hi1) >= 1)
|
||||
_c = R1[1]
|
||||
|
||||
|
||||
def reseeded(target, shares, res, ended=frozenset({A})):
|
||||
"""`_reseed_charge_end` on the rig's controller, A's charge ended and
|
||||
its result `res`: (target, i_w)."""
|
||||
_c.target, _c.i_w, _c._charge_ended = target, 123.0, ended
|
||||
for inv in _c.inverters:
|
||||
inv.target = shares[inv.name[-1]]
|
||||
_c._reseed_charge_end([res, _free])
|
||||
return _c.target, _c.i_w
|
||||
|
||||
|
||||
_pin = MaintResult(HOLD, True, 0.0, False, [])
|
||||
_free = MaintResult(IDLE, False, None, False, [])
|
||||
check("a HOLD pin: the start is B's command plus the pin, integrator with it",
|
||||
reseeded(-2000.0, {"a": -2000.0, "b": 0.0}, _pin) == (0.0, 0.0)
|
||||
and reseeded(2770.0, {"a": 1385.0, "b": 1385.0}, _pin)
|
||||
== (1385.0, 1385.0) and _c._charge_ended == frozenset())
|
||||
check("a free battery: its charge leaves the start, its discharge stays",
|
||||
reseeded(-2000.0, {"a": -2000.0, "b": 0.0}, _free) == (0.0, 0.0)
|
||||
and reseeded(2770.0, {"a": 1385.0, "b": 1385.0}, _free)
|
||||
== (2770.0, 2770.0))
|
||||
check("no charge ended: nothing moves (the integrator either)",
|
||||
reseeded(-2000.0, {"a": -2000.0, "b": 0.0}, _pin, frozenset())
|
||||
== (-2000.0, 123.0))
|
||||
_n1c = run(1, 6000.0, 1780.0, step=1760.0)[1]
|
||||
_n1c.target, _n1c.i_w = -2000.0, 5.0
|
||||
_n1c.inverters[0].target, _n1c._charge_ended = -2000.0, frozenset({A})
|
||||
_n1c._reseed_charge_end([_pin])
|
||||
check("N=1: nothing moves", (_n1c.target, _n1c.i_w) == (-2000.0, 5.0))
|
||||
_seen = []
|
||||
|
||||
|
||||
def _spy(self, maint_results):
|
||||
_seen.append(sorted(self._charge_ended))
|
||||
return no_fix(self, maint_results)
|
||||
|
||||
|
||||
Controller._reseed_charge_end = _spy
|
||||
try:
|
||||
run(2, 6000.0, 1780.0, step=1760.0, dur=2400.0,
|
||||
extra={"maintenance_hold_min": 5})
|
||||
finally:
|
||||
Controller._reseed_charge_end = no_fix
|
||||
check("it fires once, at the charge end: not at the HOLD end or on any "
|
||||
"other cycle", [x for x in _seen if x] == [[A]]
|
||||
and len(_seen) > 300)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n6. A still in CHARGE but no longer placed: stale link, refusal")
|
||||
|
||||
|
||||
def link_drop(at):
|
||||
"""A's writes rejected from `at` s, so it is dropped as a stale link."""
|
||||
def before(c, t):
|
||||
a = c.inverters[0]
|
||||
if t >= at and a.rejected_since is None:
|
||||
a.rejected_since = rig.BASE + timedelta(seconds=at)
|
||||
return before
|
||||
|
||||
|
||||
def refuses(at):
|
||||
"""A stops charging from `at` s (reads 0 W); SAFETY-73 drops it."""
|
||||
def before(c, t):
|
||||
if not getattr(c.hass, "_s93r", False):
|
||||
real = c.hass.power
|
||||
|
||||
def power(u):
|
||||
p = real(u)
|
||||
return 0.0 if u == "a" and c.hass.t >= at and p < 0 else p
|
||||
c.hass.power, c.hass._s93r = power, True
|
||||
return before
|
||||
|
||||
|
||||
for k, hook in (("stale link", link_drop), ("refusal", refuses)):
|
||||
def fn(hook=hook):
|
||||
return run(2, 1200.0, 9999.0, dur=1500.0, before=hook(1300.0))
|
||||
rows, _c6 = fn()
|
||||
old = without(fn)[0]
|
||||
drop = first((r[0] for r in rows if r[0] >= 1300
|
||||
and "dropped inverter-a" in str(r[7])), "A dropped")
|
||||
print(f" {k}: A dropped at {drop:.0f} s; B "
|
||||
f"{[cmd(r)['b'] for r in rows if drop - 2 <= r[0] <= drop + 4]}, "
|
||||
f"without the fix {[cmd(r)['b'] for r in old if drop - 2 <= r[0] <= drop + 4]}")
|
||||
check(f"⭐ {k}: A is dropped while still in CHARGE, and B never charges "
|
||||
f"from the grid and moves at most one slew step a cycle",
|
||||
phase_at(rows, drop) == "charge"
|
||||
and min(cmd(r)["b"] for r in rows if r[0] >= 1300) >= 0.0
|
||||
and moves(rows, "b", 1296, 1500) <= SLEW)
|
||||
check(f"non-vacuous ({k}): without the fix B jumps to -1600 W",
|
||||
min(cmd(r)["b"] for r in old if r[0] >= 1300) <= -1600.0)
|
||||
|
||||
_seen.clear()
|
||||
Controller._reseed_charge_end = _spy
|
||||
try:
|
||||
run(2, 6000.0, 1530.0, step=1500.0)
|
||||
finally:
|
||||
Controller._reseed_charge_end = no_fix
|
||||
check("the guard suspending the charge is no end: it fires once, at the HOLD",
|
||||
[x for x in _seen if x] == [[A]])
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all SAFETY-93 checks passed")
|
||||
@@ -0,0 +1,361 @@
|
||||
"""Runnable check for SAFETY-94: a healthy battery that lags or reverses is
|
||||
not flagged as frozen or refusing. `python3 test_safety94.py`
|
||||
|
||||
Two false alarms, both pre-existing:
|
||||
- The stale-reading gate (SAFETY-37) counted its 35 s from the register's last
|
||||
change. After a rest that clock was already past 35 s, so the first read
|
||||
after a new command tripped it, before the register could answer (SAFETY-58;
|
||||
live 2026-10-03, four trips, each 1 s after a command).
|
||||
- At N=1 the refusal (SAFETY-52) engaged after 12 frozen cycles if the reading
|
||||
was under 100 W at that moment. At a 1 s meter, a battery reversing out of a
|
||||
charge through its lag is passing 0 W just then (SAFETY-85 rig, O1). It was
|
||||
held at 0 W until the next probe, up to 15 min.
|
||||
|
||||
Now the gate counts only the time the command asked the register to move, and
|
||||
a refusal needs the reading to have stayed under 100 W, not moving towards the
|
||||
ask, for 12 cycles spanning 22 s, at every roster size.
|
||||
DECISIONS/SAFETY-94-lagging-battery.md.
|
||||
|
||||
1. The four live trips, replayed through the gate.
|
||||
2. The REAL Controller on a lagging battery (tools/safety94_rig.py), N=1 and
|
||||
N=2: rest then a step, maintenance drain start, drain -> charge, a charge
|
||||
reversing into a discharge. No stale trip and no refusal.
|
||||
3. A frozen register and a refusing battery are still caught, and how fast.
|
||||
4. The rules at their edges.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
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.CRITICAL)
|
||||
|
||||
import safety94_rig as rig # noqa: E402
|
||||
import app.inverter as app_inverter # noqa: E402
|
||||
import app.main as app_main # noqa: E402
|
||||
from app import control # noqa: E402
|
||||
from app.freshness import BOUNDS, Freshness # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
class Before94(Freshness):
|
||||
"""The gate before SAFETY-94: the clock ran from the last change."""
|
||||
|
||||
def obliged_s(self):
|
||||
return self.graded_s(self.last_read)
|
||||
|
||||
|
||||
def without_fix(fn):
|
||||
"""Run `fn` with the gate's old clock and the old refusal evidence."""
|
||||
real_f, real_c = app_inverter.Freshness, app_main.compute
|
||||
real_p = app_main.REFUSED_PROGRESS_W
|
||||
app_inverter.Freshness = Before94
|
||||
app_main.compute = lambda *a, **k: real_c(*a, **dict(k, gave_nothing=None))
|
||||
app_main.REFUSED_PROGRESS_W = float("inf")
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
app_inverter.Freshness, app_main.compute = real_f, real_c
|
||||
app_main.REFUSED_PROGRESS_W = real_p
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. the four live trips of 2026-10-03, replayed")
|
||||
# HA history (only changes are recorded), local time: the unit's setpoint
|
||||
# (what stands on its wire) and its battery power register (0x0518).
|
||||
LIVE = {
|
||||
"17:39:16 inverter-b": (
|
||||
[("17:38:00", -24), ("17:38:14.7", 0), ("17:38:25.4", 5),
|
||||
("17:38:40.5", 16), ("17:38:44.7", 26), ("17:38:55.7", 37),
|
||||
("17:39:05.1", 42), ("17:39:09.8", 58), ("17:39:15.6", 268),
|
||||
("17:39:25.0", 479), ("17:39:30.7", 689), ("17:39:34.8", 900)],
|
||||
[("17:38:00", -118), ("17:38:21.6", -99), ("17:39:21.6", 179),
|
||||
("17:39:31.6", 399)]),
|
||||
"17:39:25 inverter-a": (
|
||||
[("17:38:00", -16), ("17:38:14.9", 0), ("17:38:25.0", 5),
|
||||
("17:38:40.0", 14), ("17:38:44.7", 24), ("17:38:55.2", 33),
|
||||
("17:39:04.7", 38), ("17:39:09.3", 52), ("17:39:15.0", 242),
|
||||
("17:39:24.6", 431), ("17:39:30.3", 621), ("17:39:34.8", 810)],
|
||||
[("17:38:00", -3), ("17:38:25.9", 0), ("17:39:25.9", 318),
|
||||
("17:39:36.0", 654)]),
|
||||
"19:00:04 inverter-a": (
|
||||
[("18:58:30.0", 290), ("18:58:43.3", 305), ("18:58:48.0", 314),
|
||||
("18:58:52.5", 300), ("19:00:03.1", 2500)],
|
||||
[("18:58:30.0", 292), ("19:00:06.0", 951), ("19:00:16.0", 2360),
|
||||
("19:00:26.0", 2376)]),
|
||||
"21:21:22 inverter-b": (
|
||||
[("21:19:30.0", 0), ("21:21:21.9", 2100), ("21:21:26.6", 1700),
|
||||
("21:21:31.1", 1300), ("21:21:36.9", 900), ("21:21:41.0", 500),
|
||||
("21:21:47.3", 200)],
|
||||
[("21:19:30.0", -98), ("21:21:22.8", -97), ("21:21:32.8", 1411),
|
||||
("21:21:42.8", 798)]),
|
||||
}
|
||||
|
||||
|
||||
def _sec(hms):
|
||||
h, m, s = hms.split(":")
|
||||
return int(h) * 3600 + int(m) * 60 + float(s)
|
||||
|
||||
|
||||
def replay(cls, setp, reg):
|
||||
"""1 Hz reads, as `run_control()` does. Returns the trip times."""
|
||||
f, trips = cls(BOUNDS["batt"]), []
|
||||
t, end = _sec(setp[0][0]), _sec(reg[-1][0])
|
||||
|
||||
def at(series, t):
|
||||
return [float(v) for ts, v in series if _sec(ts) <= t][-1]
|
||||
while t <= end:
|
||||
was = f.stale
|
||||
f.submit(at(reg, t), at(setp, t), t)
|
||||
if f.stale and not was:
|
||||
trips.append(t)
|
||||
t += 1.0
|
||||
return trips
|
||||
|
||||
|
||||
for name, (setp, reg) in LIVE.items():
|
||||
old, new = replay(Before94, setp, reg), replay(Freshness, setp, reg)
|
||||
print(f" {name}: before {[f'{int(t // 3600):02d}:{int(t % 3600 // 60):02d}:{t % 60:04.1f}' for t in old]}"
|
||||
f", now {len(new)} trips")
|
||||
check(f"{name}: the shipped trip reproduces 1-2 s after the command, and "
|
||||
f"now there is none (the register answered within 11 s)",
|
||||
len(old) == 1 and abs(old[0] - _sec(name.split()[0])) < 1.0
|
||||
and new == [])
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. the REAL Controller on a lagging battery (10 s lag)")
|
||||
# Two register shapes: published every 10 s and repeating its value (the
|
||||
# boards, live), and every 1 s with +-1 W of dither (the SAFETY-85 rig).
|
||||
SHAPES = ((10.0, False), (1.0, True))
|
||||
NAMES = ("rest_step", "drain_start", "drain_charge", "reversal")
|
||||
|
||||
|
||||
def sweep(fn=lambda f: f()):
|
||||
out = {}
|
||||
for n in (1, 2):
|
||||
for name in NAMES:
|
||||
for tick in (1.0, 2.0, 5.0):
|
||||
for pub, dith in SHAPES:
|
||||
s, r, _ = fn(lambda: rig.counts(n, name, tick=tick,
|
||||
publish_s=pub,
|
||||
dither=dith))
|
||||
out[(n, name, tick, pub)] = (s, r)
|
||||
return out
|
||||
|
||||
|
||||
NOW = sweep()
|
||||
OLD = sweep(without_fix)
|
||||
for n in (1, 2):
|
||||
for name in NAMES:
|
||||
now = [NOW[k] for k in NOW if k[:2] == (n, name)]
|
||||
old = [OLD[k] for k in OLD if k[:2] == (n, name)]
|
||||
print(f" N={n} {name:12s} stale/refused now "
|
||||
f"{sum(a for a, _ in now)}/{sum(b for _, b in now)}, before "
|
||||
f"{sum(a for a, _ in old)}/{sum(b for _, b in old)}")
|
||||
check("⭐ no stale trip and no refusal, at N=1 and N=2, at 1, 2 and 5 s meters, "
|
||||
"on both register shapes: a step after a rest, a drain start, drain -> "
|
||||
"charge and a charge reversing into a discharge",
|
||||
all(v == (0, 0) for v in NOW.values()) and len(NOW) == 48)
|
||||
check("non-vacuous: before, the same runs trip the gate (drain start and "
|
||||
"drain -> charge at both N, as live) and refuse at N=1 on a 1 s meter",
|
||||
sum(OLD[(n, nm, t, 10.0)][0] for n in (1, 2)
|
||||
for nm in ("drain_start", "drain_charge") for t in (1.0, 2.0, 5.0))
|
||||
>= 12 and OLD[(1, "drain_charge", 1.0, 1.0)][1] >= 1)
|
||||
|
||||
|
||||
def reversals(n, tick, taus=(10.0, 20.0)):
|
||||
"""A charge reversing into a discharge: (stale trips, refusals) summed."""
|
||||
s = r = 0
|
||||
for tau in taus:
|
||||
for charge in (-1500.0, -2500.0):
|
||||
for house in (300.0, 800.0, 2000.0):
|
||||
for pub, dith in SHAPES:
|
||||
x = rig.run(n, rig.reversal(300.0, charge, house), 600.0,
|
||||
tau=tau, tick=tick, publish_s=pub,
|
||||
dither=dith)
|
||||
s, r = s + len(x["stale"]), r + len(x["refused"])
|
||||
return s, r
|
||||
|
||||
|
||||
REV = {(n, t): reversals(n, t) for n in (1, 2) for t in (1.0, 2.0)}
|
||||
REV_OLD = {(n, 1.0): without_fix(lambda n=n: reversals(n, 1.0)) for n in (1, 2)}
|
||||
print(f" reversals (lag 10 and 20 s): now {REV}, before {REV_OLD}")
|
||||
check("⭐ SAFETY-94's case: a battery reversing out of a charge (1500 or 2500 "
|
||||
"W) into a 300-2000 W house, lag 10 or 20 s, 1 or 2 s meter, is never "
|
||||
"refused or flagged, at N=1 or N=2",
|
||||
all(v == (0, 0) for v in REV.values()))
|
||||
check("non-vacuous: before, the 1 s meter refuses at N=1 (lag 20 s) and at "
|
||||
"N=2 (B, lag 20 s, its reading 100 W below its power)",
|
||||
REV_OLD[(1, 1.0)][1] >= 1 and REV_OLD[(2, 1.0)][1] >= 1)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. a frozen register and a refusing battery are still caught")
|
||||
|
||||
|
||||
def obliged_at(rows, t_f):
|
||||
"""First cycle from `t_f` where A's command is 250 W or more and has moved
|
||||
250 W from where it stood at `t_f`."""
|
||||
c0 = None
|
||||
for row in rows:
|
||||
if row[0] >= t_f and c0 is None:
|
||||
c0 = row[2]["a"]
|
||||
if c0 is not None and abs(row[2]["a"]) >= 250 \
|
||||
and abs(row[2]["a"] - c0) >= 250:
|
||||
return row[0]
|
||||
return None
|
||||
|
||||
|
||||
CATCH = {}
|
||||
for n in (1, 2):
|
||||
for tick in (1.0, 5.0):
|
||||
# frozen while giving 1000 W, then the house steps
|
||||
r1 = rig.run(n, lambda t: 3000.0 if t >= 400 else 1000.0, 520.0,
|
||||
tick=tick, publish_s=1.0, dither=True,
|
||||
freeze_at=lambda u: 300.0 if u == "a" else None)
|
||||
ob = obliged_at(r1["rows"], 300.0)
|
||||
st = [t for t, u in r1["stale"] if u == "inverter-a"]
|
||||
# frozen at rest (HW-01's shape), the house steps 500 s later
|
||||
r2 = rig.run(n, lambda t: 2500.0 if t >= 600 else 0.0, 700.0,
|
||||
tick=tick, publish_s=1.0, dither=True,
|
||||
freeze_at=lambda u: 100.0 if u == "a" else None)
|
||||
ob2 = obliged_at(r2["rows"], 100.0)
|
||||
park = min([t for t, u in r2["stale"] if u == "inverter-a"]
|
||||
+ [t for t, _u in r2["refused"]] or [None])
|
||||
# a refusing battery from 300 s
|
||||
r3 = rig.run(n, lambda t: 3000.0, 420.0, tick=tick, publish_s=10.0,
|
||||
cut=lambda u, t: u == "a" and t >= 300.0)
|
||||
ref = [t for t, _u in r3["refused"]]
|
||||
CATCH[(n, tick)] = (st[0] - ob if st and ob is not None else None,
|
||||
park - ob2 if park is not None and ob2 is not None
|
||||
else None, ref[0] - 300.0 if ref else None)
|
||||
print(" (N, meter): (frozen giving: trip after the command moved, frozen "
|
||||
"at rest: 0 W after, refusing: refused after the cut), in s")
|
||||
print(f" {CATCH}")
|
||||
check("⭐ a register frozen while giving is caught 35 s after the command asks "
|
||||
"it to move (the SAFETY-37 bound, now counted from the ask)",
|
||||
all(v[0] == BOUNDS["batt"].unchanged_s for v in CATCH.values()))
|
||||
check("⭐ a register frozen at rest reads like a refusing battery: the "
|
||||
"battery is at 0 W, refused or stale, within 36 s of the ask",
|
||||
all(v[1] is not None and v[1] <= 36.0 for v in CATCH.values()))
|
||||
check("⭐ a refusing battery is still refused: within 22 s plus one 10 s "
|
||||
"publish at a 1 s meter, and within 12 cycles plus a publish at 5 s",
|
||||
all(CATCH[(n, 1.0)][2] is not None and CATCH[(n, 1.0)][2] <= 32.0
|
||||
for n in (1, 2))
|
||||
and all(CATCH[(n, 5.0)][2] is not None
|
||||
and CATCH[(n, 5.0)][2] <= 12 * 5.0 + 10.0 for n in (1, 2)))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. the rules at their edges")
|
||||
# The gate counts time the register was asked to move, across a flapping
|
||||
# command too.
|
||||
f = Freshness(BOUNDS["batt"])
|
||||
trip = None
|
||||
for t in range(0, 200):
|
||||
f.submit(-3.0, 1500.0 if (t // 5) % 2 else 0.0, float(t))
|
||||
if f.stale and trip is None:
|
||||
trip = t
|
||||
print(f" command flapping 0 / 1500 W every 5 s, register frozen: trip at "
|
||||
f"{trip} s")
|
||||
check("a frozen register under a command that flaps in and out of the ask is "
|
||||
"still caught (after 35 s of asking, about 70 s here)",
|
||||
trip is not None and 65 <= trip <= 75)
|
||||
f = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 30):
|
||||
f.submit(-3.0, 1500.0, float(t))
|
||||
f.submit(400.0, 1500.0, 30.0) # it answers
|
||||
for t in range(31, 60):
|
||||
f.submit(400.0, 1500.0, float(t))
|
||||
check("a register that answers starts a fresh count: 30 s asked, then it "
|
||||
"moves, then 29 s more is not stale",
|
||||
not f.stale and f.obliged_s() < 35.0)
|
||||
f = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 600):
|
||||
f.submit(-3.0, 200.0 if t % 60 < 3 else 0.0, float(t))
|
||||
check("a command that never asks 250 W is never an ask (SAFETY-52's 200 W "
|
||||
"probe cannot arm the gate)",
|
||||
not f.stale and f.obliged_s() == 0.0)
|
||||
# Review: time under 250 W is not counted, even after the command travelled.
|
||||
f = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 61):
|
||||
f.submit(-300.0, -300.0 if t == 0 else 200.0, float(t))
|
||||
for t in range(61, 131):
|
||||
f.submit(-300.0, 260.0, float(t))
|
||||
if t == 90:
|
||||
early = f.stale
|
||||
check("a command that has travelled 500 W but stands at 200 W for a minute "
|
||||
"adds nothing: 30 s after it reaches 260 W the register is not stale, "
|
||||
"and by 70 s it is",
|
||||
not early and f.stale)
|
||||
|
||||
# compute(): `gave_nothing` gates the N=1 engage; None keeps the old law.
|
||||
T = control.Tuning(max_w=4000.0, slew_w=400.0)
|
||||
args = dict(prev_w=1200.0, grid_w=1200.0, actual_w=-20.0, tuning=T,
|
||||
sat_count=control.SETTLE_CYCLES, i_w=1200.0, refused=0)
|
||||
check("compute(): a frozen loop at SETTLE_CYCLES reading -20 W engages with "
|
||||
"gave_nothing None (compute alone, as before) or True, and not with "
|
||||
"False",
|
||||
control.compute(**args).refused == 1
|
||||
and control.compute(**args, gave_nothing=True).refused == 1
|
||||
and control.compute(**args, gave_nothing=False).refused == 0)
|
||||
|
||||
# The evidence restarts on progress towards the ask, not on jitter.
|
||||
print(f" REFUSED_PROGRESS_W {app_main.REFUSED_PROGRESS_W:.0f} W, span "
|
||||
f"{app_main.REFUSED_SPAN_S:.0f} s")
|
||||
|
||||
|
||||
class _Inv:
|
||||
def __init__(self):
|
||||
self.batt, self.ref_count, self.ref_since, self.ref_sign = 0.0, 0, None, 0
|
||||
self.ref_from = None
|
||||
self.idle_w = 0.0 # SAFETY-97: A, ~0 W at idle
|
||||
self.ask = 800.0
|
||||
|
||||
def standing_w(self):
|
||||
return self.ask
|
||||
|
||||
|
||||
class _C:
|
||||
_now_mono = 0.0
|
||||
|
||||
|
||||
def evidence(readings, tick=1.0, ask=lambda i: 800.0):
|
||||
c, inv = _C(), _Inv()
|
||||
got = []
|
||||
for i, b in enumerate(readings):
|
||||
c._now_mono, inv.batt, inv.ask = i * tick, b, ask(i)
|
||||
got.append(app_main.Controller._gave_nothing(c, inv))
|
||||
return got
|
||||
|
||||
|
||||
flat = evidence([-4.0 + (i % 3) for i in range(30)])
|
||||
climb = evidence([-95.0 + 4.0 * i for i in range(48)])
|
||||
check("a flat reading (+-1 W) under an 800 W ask is evidence after 22 s; one "
|
||||
"climbing 4 W/s through the band is never",
|
||||
1 in flat and flat.index(1) == 22 and not any(climb))
|
||||
flap = evidence([-4.0] * 60, ask=lambda i: 800.0 if i % 2 else -800.0)
|
||||
away = evidence([40.0] * 5 + [-40.0] * 25)
|
||||
check("a command that swaps direction every cycle is never evidence, and a "
|
||||
"reading moving 80 W away from the ask does not restart it (latched at "
|
||||
"22 s, counted from the first reading)",
|
||||
not any(flap) and 1 in away and away.index(1) == 22)
|
||||
check("REFUSED_PROGRESS_W is under the band's width and over the live "
|
||||
"readings' rest wander (25 W)",
|
||||
25.0 < app_main.REFUSED_PROGRESS_W < 2 * control.REFUSED_BELOW_W)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -0,0 +1,441 @@
|
||||
"""Runnable check for SAFETY-97: "the battery gives nothing" is graded against
|
||||
each battery's own idle reading. `python3 test_safety97.py`
|
||||
|
||||
B's power reading shows -95 to -118 W at idle (standby draw; live
|
||||
2026-10-03). The refusal evidence (SAFETY-52/73, shared since SAFETY-94)
|
||||
needed the reading under 100 W in magnitude, so a refusing B never latched,
|
||||
at N=2 ever, and at N=1 since SAFETY-94 (review F1).
|
||||
|
||||
Now each unit learns its idle reading (app/idle.py) and the band, the
|
||||
refusal's end and the stale-path refusal are centred on it.
|
||||
DECISIONS/SAFETY-97-idle-offset.md.
|
||||
|
||||
1. A refusing B is caught at N=1 and N=2, 1/2/5 s meters; A unchanged.
|
||||
2. A healthy B with its offset is never refused or flagged (SAFETY-94's
|
||||
scenarios), learned or restored.
|
||||
3. Learning: not while refusing or asked, bounded, restored, follows drift.
|
||||
4. The rules at their edges.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
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.CRITICAL)
|
||||
|
||||
import safety94_rig as rig # noqa: E402
|
||||
import app.idle as idle # noqa: E402
|
||||
import app.main as app_main # noqa: E402
|
||||
from app import control # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def without_fix(fn):
|
||||
"""Run `fn` with every idle reading at 0 W (the rule before SAFETY-97)."""
|
||||
real = idle.IdleReading.w
|
||||
idle.IdleReading.w = property(lambda self: 0.0)
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
idle.IdleReading.w = real
|
||||
|
||||
|
||||
def with_offset(n, fn):
|
||||
"""At N=1 the rig's only unit is "a": give it B's offset."""
|
||||
saved = dict(rig.OFFSET)
|
||||
if n == 1:
|
||||
rig.OFFSET["a"] = rig.OFFSET["b"]
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
rig.OFFSET.clear()
|
||||
rig.OFFSET.update(saved)
|
||||
|
||||
|
||||
REST, CUT = 400.0, 600.0 # rest (it learns), then ask, then cut
|
||||
REST_LOAD = (lambda t: 0.0 if t < REST else 3000.0)
|
||||
|
||||
|
||||
def caught(n, unit, tick, *, offset=True, dur=None, store=None, load=None,
|
||||
cut_at=CUT, **kw):
|
||||
"""Seconds from the cut to the refusal, or None, and the run."""
|
||||
cu = "a" if n == 1 else unit
|
||||
|
||||
def go():
|
||||
return rig.run(n, load or REST_LOAD, dur or cut_at + 300.0,
|
||||
tick=tick, noise=4.0, store=store,
|
||||
cut=lambda u, t: u == cu and t >= cut_at, **kw)
|
||||
r = with_offset(n, go) if offset and unit == "b" else go()
|
||||
ref = [t - cut_at for t, _u in r["refused"]]
|
||||
return (ref[0] if ref else None), r
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. a refusing battery, B with its -100 W idle reading (+-4 W noise)")
|
||||
TIMES, OLD = {}, {}
|
||||
for unit in ("a", "b"):
|
||||
for n in (1, 2):
|
||||
for tick in (1.0, 2.0, 5.0):
|
||||
TIMES[(unit, n, tick)] = caught(n, unit, tick)[0]
|
||||
OLD[(unit, n, tick)] = without_fix(
|
||||
lambda: caught(n, unit, tick)[0])
|
||||
for k in TIMES:
|
||||
print(f" refusing {k[0].upper()} N={k[1]} meter {k[2]:.0f} s: refused "
|
||||
f"{TIMES[k]} s after the cut (before {OLD[k]})")
|
||||
check("⭐ a refusing B is caught at N=1 and N=2: within 22 s plus one 10 s "
|
||||
"publish at 1 and 2 s meters, 12 cycles plus a publish at 5 s",
|
||||
all(TIMES[("b", n, t)] is not None
|
||||
and TIMES[("b", n, t)] <= (32.0 if t < 5 else 12 * 5.0 + 10.0)
|
||||
for n in (1, 2) for t in (1.0, 2.0, 5.0)))
|
||||
check("non-vacuous: before, a refusing B is never caught (300 s), at N=1 "
|
||||
"or N=2",
|
||||
all(OLD[("b", n, t)] is None for n in (1, 2) for t in (1.0, 2.0, 5.0)))
|
||||
check("⭐ a refusing A is caught exactly as before",
|
||||
all(TIMES[("a", n, t)] == OLD[("a", n, t)] is not None
|
||||
for n in (1, 2) for t in (1.0, 2.0, 5.0)))
|
||||
|
||||
# B refusing for 800 s: one refusal, held, not ended by its own idle reading.
|
||||
_t, r = caught(2, "b", 1.0, dur=CUT + 800.0)
|
||||
starts = [x for x in r["refused"] if x[1] == "inverter-b"]
|
||||
check("a refusing B stays refused until its probe: its idle reading is not "
|
||||
"read as taking power the other way (one refusal in 800 s)",
|
||||
len(starts) == 1 and r["c"].inverters[1].refused == 1)
|
||||
|
||||
# A register frozen at rest on B: refused or stale within 36 s of the ask.
|
||||
r = rig.run(2, lambda t: 2500.0 if t >= 600 else 0.0, 700.0, tick=1.0,
|
||||
publish_s=1.0, dither=True,
|
||||
freeze_at=lambda u: 300.0 if u == "b" else None)
|
||||
park = min([t for t, u in r["stale"] if u == "inverter-b"]
|
||||
+ [t for t, u in r["refused"] if u == "inverter-b"] or [None])
|
||||
print(f" B's register frozen at rest from 300 s, asked at 600 s: 0 W at "
|
||||
f"{park}")
|
||||
check("a register frozen at rest on B is caught as on A (within 36 s of "
|
||||
"the ask)", park is not None and park - 600.0 <= 36.0)
|
||||
# At a 5 s meter the stale trip comes first: its refusal is centred too.
|
||||
saved = dict(rig.OFFSET)
|
||||
rig.OFFSET["b"] = -110.0
|
||||
try:
|
||||
r = rig.run(2, lambda t: 2500.0 if t >= 600 else 0.0, 760.0, tick=5.0,
|
||||
publish_s=1.0, dither=True,
|
||||
freeze_at=lambda u: 300.0 if u == "b" else None)
|
||||
finally:
|
||||
rig.OFFSET.clear()
|
||||
rig.OFFSET.update(saved)
|
||||
st = [t for t, u in r["stale"] if u == "inverter-b"]
|
||||
ref = [t for t, u in r["refused"] if u == "inverter-b"]
|
||||
print(f" the same at a 5 s meter, B idle -110 W: stale {st}, refused {ref}")
|
||||
check("...and at a 5 s meter the stale trip on B's held -110 W is a refusal "
|
||||
"of B (one trip), not a repeating site stop",
|
||||
len(st) == 1 and ref == st)
|
||||
|
||||
# Review: the two centrings with no check of their own.
|
||||
# N=1, 5 s meter: the stale trip on a held -110 W idle reading is a refusal.
|
||||
saved = dict(rig.OFFSET)
|
||||
rig.OFFSET["a"] = -110.0
|
||||
try:
|
||||
r = rig.run(1, lambda t: 2500.0 if t >= 600 else 0.0, 800.0, tick=5.0,
|
||||
publish_s=1.0, dither=True, freeze_at=lambda u: 300.0)
|
||||
finally:
|
||||
rig.OFFSET.clear()
|
||||
rig.OFFSET.update(saved)
|
||||
st = [t for t, _u in r["stale"]]
|
||||
ref = [t for t, _u in r["refused"]]
|
||||
print(f" N=1, 5 s meter, register frozen on -110 W: stale {st}, refused "
|
||||
f"{ref}")
|
||||
check("...and at N=1 the same stale trip is a refusal (one trip), not a "
|
||||
"repeating site stop", len(st) == 1 and ref == st)
|
||||
|
||||
# SAFETY-42: B asked under 200 W while giving nothing (its idle reading) is
|
||||
# parked, not named as not delivering; A carries the house.
|
||||
def small_ask():
|
||||
return rig.run(2, lambda t: 150.0, 700.0, tick=1.0, noise=4.0,
|
||||
store={unit_key("inverter-b", "idle_w"): -100.0},
|
||||
cut=lambda u, t: u == "b")["c"].inverters[1]
|
||||
|
||||
|
||||
b, b_old = small_ask(), without_fix(small_ask)
|
||||
check("SAFETY-42: B giving nothing at a small ask, idle -100 W, is parked "
|
||||
"(not latched as not delivering in 700 s; with idle 0 W it is)",
|
||||
not b.delivery_wd.tripped and b.idle.value == -100.0
|
||||
and b_old.delivery_wd.tripped)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. a healthy B with its offset is never refused or flagged")
|
||||
SHAPES = ((10.0, False), (1.0, True))
|
||||
NAMES = ("rest_step", "drain_start", "drain_charge", "reversal")
|
||||
SAVED = {"b": {unit_key("inverter-b", "idle_w"): -100.0},
|
||||
"a": {unit_key("inverter-a", "idle_w"): -100.0}}
|
||||
bad = []
|
||||
runs = 0
|
||||
for n in (1, 2):
|
||||
for name in NAMES:
|
||||
for tick in (1.0, 2.0, 5.0):
|
||||
for pub, dith in SHAPES:
|
||||
for store in (None, SAVED["a" if n == 1 else "b"]):
|
||||
s, f, _r = with_offset(n, lambda: rig.counts(
|
||||
n, name, tick=tick, publish_s=pub, dither=dith,
|
||||
store=store))
|
||||
runs += 1
|
||||
if s or f:
|
||||
bad.append((n, name, tick, pub, bool(store), s, f))
|
||||
print(f" {runs} runs, stale or refused: {bad}")
|
||||
check("⭐ SAFETY-94's scenarios with B's offset (N=1 and N=2, 1/2/5 s "
|
||||
"meters, both register shapes, idle learned or restored): no stale "
|
||||
"trip and no refusal", not bad and runs == 96)
|
||||
rev = []
|
||||
for n in (1, 2):
|
||||
for tau in (10.0, 20.0):
|
||||
for charge in (-1500.0, -2500.0):
|
||||
for house in (300.0, 800.0, 2000.0):
|
||||
x = with_offset(n, lambda: rig.run(
|
||||
n, rig.reversal(300.0, charge, house), 600.0, tau=tau,
|
||||
tick=1.0, store=SAVED["a" if n == 1 else "b"]))
|
||||
if x["stale"] or x["refused"]:
|
||||
rev.append((n, tau, charge, house))
|
||||
check("⭐ a charge reversing into a discharge (lag 10 and 20 s, 1 s meter) "
|
||||
"with B's idle reading restored is never refused or flagged",
|
||||
not rev)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. learning the idle reading")
|
||||
_t, r = caught(2, "b", 1.0)
|
||||
c = r["c"]
|
||||
check("learned during the rest: A about 0 W, B about -100 W (used: -75 W), "
|
||||
"and saved",
|
||||
abs(c.inverters[0].idle_w) < 5
|
||||
and abs(c.inverters[1].idle.value + 100) < 5
|
||||
and c.inverters[1].idle_w == -idle.IDLE_MAX_W
|
||||
and abs(c.store.data.get(unit_key("inverter-b", "idle_w"), 0) + 100)
|
||||
< 5)
|
||||
|
||||
# A refusing B from the start, no rest, nothing saved: nothing to learn from.
|
||||
_t, r = caught(2, "b", 1.0, load=lambda t: 3000.0, cut_at=60.0, dur=1200.0)
|
||||
check("a battery refusing from the start teaches nothing: asked the whole "
|
||||
"time, then held at 0 W by its refusal (none learned, none saved)",
|
||||
r["c"].inverters[1].idle.value is None
|
||||
and unit_key("inverter-b", "idle_w") not in r["c"].store.data)
|
||||
# ... which is why the value is saved: restored, the same battery is caught.
|
||||
t_saved, _r = caught(2, "b", 1.0, load=lambda t: 3000.0, cut_at=60.0,
|
||||
store=SAVED["b"])
|
||||
t_none, _r = caught(2, "b", 1.0, load=lambda t: 3000.0, cut_at=60.0)
|
||||
print(f" refusing from the start: saved idle {t_saved} s, none {t_none}")
|
||||
check("⭐ after a restart the saved idle reading is used: a B refusing from "
|
||||
"the start is caught in 32 s; with nothing saved it is not",
|
||||
t_saved is not None and t_saved <= 32.0 and t_none is None)
|
||||
|
||||
# Not learned while refused: a refusing B reading -160 W (in the band) does
|
||||
# not pull its idle reading during the 0 W its refusal holds.
|
||||
class _P(rig.Plant):
|
||||
def advance(self, t):
|
||||
super().advance(t)
|
||||
for u in self.units:
|
||||
if self.cut(u, self.t):
|
||||
self.reg[u] = -160.0
|
||||
|
||||
|
||||
real_plant = rig.Plant
|
||||
rig.Plant = _P
|
||||
try:
|
||||
_t, r = caught(2, "b", 1.0, dur=CUT + 800.0)
|
||||
finally:
|
||||
rig.Plant = real_plant
|
||||
b = r["c"].inverters[1]
|
||||
print(f" refusing B reading -160 W: refused at {_t} s, idle "
|
||||
f"{b.idle.value:.1f}")
|
||||
check("⭐ not learned while refused: 800 s held at 0 W reading -160 W leaves "
|
||||
"B's idle reading at -100 W",
|
||||
_t is not None and b.refused and abs(b.idle.value + 100.0) < 1.0)
|
||||
|
||||
# Not learned while control is stopped (another owner may drive the battery)
|
||||
# or while the unit's sign watchdog has latched.
|
||||
class _Stopped(app_main.Controller):
|
||||
auto = property(lambda self: False, lambda self, v: None)
|
||||
|
||||
|
||||
rig.Controller = _Stopped
|
||||
try:
|
||||
r = rig.run(2, lambda t: 0.0, 600.0, tick=1.0)
|
||||
finally:
|
||||
rig.Controller = app_main.Controller
|
||||
r2 = rig.run(2, lambda t: 0.0, 600.0, tick=1.0,
|
||||
store={unit_key("inverter-b", "sign_tripped"): True})
|
||||
check("not learned while control is stopped, nor on a unit whose sign "
|
||||
"watchdog has latched (600 s at 0 W); its sibling learns",
|
||||
all(i.idle.value is None for i in r["c"].inverters)
|
||||
and r2["c"].inverters[1].idle.value is None
|
||||
and r2["c"].inverters[0].idle.value is not None)
|
||||
|
||||
# A battery capped at 150 W under a big ask is not idle.
|
||||
x = idle.IdleReading()
|
||||
for t in range(0, 3600):
|
||||
x.observe(150.0, 2000.0, float(t), True)
|
||||
check("a reading under a command over 50 W is never a sample (a capped "
|
||||
"battery is not idle)", x.value is None and x.w == 0.0)
|
||||
|
||||
x = idle.IdleReading()
|
||||
for t in range(0, 239):
|
||||
x.observe(-100.0, 10.0, float(t), True)
|
||||
first = x.value
|
||||
x.observe(-100.0, 10.0, 239.0, True)
|
||||
x.observe(-100.0, 10.0, 240.0, True)
|
||||
check("the first value needs 120 s asked ~0 and then 120 s of samples",
|
||||
first is None and abs(x.value + 100.0) < 1e-6)
|
||||
y = idle.IdleReading()
|
||||
for t in range(0, 400):
|
||||
y.observe(-100.0, 60.0 if t % 100 == 0 else 0.0, float(t), True)
|
||||
check("a command over 50 W restarts the quiet time (a 60 W blip every "
|
||||
"100 s: never learned)", y.value is None)
|
||||
z = idle.IdleReading()
|
||||
for t in range(0, 400):
|
||||
z.observe(-100.0, 0.0, float(t), False)
|
||||
check("nothing is learned while learning is not allowed (refused, stale, "
|
||||
"tripped or stopped)", z.value is None)
|
||||
|
||||
for t in range(240, 300):
|
||||
x.observe(-130.0, 0.0, float(t), True)
|
||||
after_min = x.value
|
||||
for t in range(300, 240 + 3600):
|
||||
x.observe(-130.0, 0.0, float(t), True)
|
||||
print(f" drift -100 -> -130 W: {after_min:.1f} after 60 s, {x.value:.1f} "
|
||||
f"after an hour")
|
||||
check("drift is followed slowly: under 2 W in the first minute, most of it "
|
||||
"in an hour", -102.0 < after_min < -100.0 and -130 < x.value < -124)
|
||||
|
||||
o = idle.IdleReading()
|
||||
for t in range(0, 600):
|
||||
o.observe(-300.0, 0.0, float(t), True)
|
||||
p = idle.IdleReading()
|
||||
for t in range(0, 600):
|
||||
p.observe(2000.0 if t < 300 else -240.0, 0.0, float(t), True)
|
||||
check("a reading beyond 250 W is not a sample (-300 W: never learned; a "
|
||||
"register held at 2000 W then -240 W: -240 W)",
|
||||
o.value is None and p.value is not None and abs(p.value + 240.0) < 1e-6)
|
||||
check("a saved value is clamped to 250 W, the band's centre to 75 W, and "
|
||||
"garbage is ignored",
|
||||
idle.IdleReading(900).value == idle.IDLE_SAMPLE_W
|
||||
and idle.IdleReading(900).w == idle.IDLE_MAX_W
|
||||
and idle.IdleReading(-900.0).w == -idle.IDLE_MAX_W
|
||||
and idle.IdleReading(-60.0).w == -60.0
|
||||
and idle.IdleReading("x").value is None
|
||||
and idle.IdleReading(float("nan")).value is None)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3b. a wrong idle reading (owner, 2026-10-04: centre clamped to 75 W)")
|
||||
|
||||
|
||||
def wrong_idle(n, unit, err, restore):
|
||||
"""Seconds from the cut to the refusal when the idle reading is `err` W
|
||||
off: taught wrong during the rest, or restored wrong."""
|
||||
cu = "a" if n == 1 else unit
|
||||
true = -100.0 if unit == "b" else 0.0
|
||||
if restore:
|
||||
return caught(n, unit, 1.0, store={
|
||||
unit_key(f"inverter-{cu}", "idle_w"): true + err})[0]
|
||||
|
||||
class _W(rig.Plant):
|
||||
def advance(self, t):
|
||||
rig.OFFSET[cu] = true + (err if t < REST else 0.0)
|
||||
super().advance(t)
|
||||
saved, real = dict(rig.OFFSET), rig.Plant
|
||||
rig.Plant = _W
|
||||
try:
|
||||
return caught(n, unit, 1.0)[0]
|
||||
finally:
|
||||
rig.Plant = real
|
||||
rig.OFFSET.clear()
|
||||
rig.OFFSET.update(saved)
|
||||
|
||||
|
||||
A_WRONG = {(n, e, rs): wrong_idle(n, "a", e, rs) for n in (1, 2)
|
||||
for e, rs in ((-250.0, False), (-150.0, False), (150.0, False),
|
||||
(250.0, False), (-250.0, True), (200.0, True),
|
||||
(250.0, True))}
|
||||
print(f" A (reads about 0 W) refusing, idle off by E: {A_WRONG}")
|
||||
check("⭐ a battery reading about 0 W while refusing is caught whatever idle "
|
||||
"reading was learned or restored (A, E -250..+250 W, N=1 and N=2)",
|
||||
all(v is not None and v <= 32.0 for v in A_WRONG.values()))
|
||||
check("⭐ ...including a wrong saved idle_w of +200 W on A",
|
||||
all(A_WRONG[(n, 200.0, True)] is not None for n in (1, 2)))
|
||||
B_WRONG = {(n, e): wrong_idle(n, "b", e, False) for n in (1, 2)
|
||||
for e in (-150.0, -50.0, 90.0)}
|
||||
print(f" B refusing, idle taught wrong by E: {B_WRONG}")
|
||||
check("B is caught across -150..+90 W of error in its idle reading",
|
||||
all(v is not None and v <= 32.0 for v in B_WRONG.values()))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. the rules at their edges")
|
||||
|
||||
|
||||
class _Inv:
|
||||
def __init__(self, idle_w):
|
||||
self.batt, self.ref_count, self.ref_since, self.ref_sign = 0.0, 0, None, 0
|
||||
self.ref_from, self.idle_w = None, idle_w
|
||||
|
||||
def standing_w(self):
|
||||
return 800.0
|
||||
|
||||
|
||||
class _C:
|
||||
_now_mono = 0.0
|
||||
|
||||
|
||||
def evidence(readings, idle_w):
|
||||
c, inv = _C(), _Inv(idle_w)
|
||||
got = []
|
||||
for i, b in enumerate(readings):
|
||||
c._now_mono, inv.batt = float(i), b
|
||||
got.append(app_main.Controller._gave_nothing(c, inv))
|
||||
return got
|
||||
|
||||
|
||||
flat_b = evidence([-104.0 + (i % 3) for i in range(30)], -100.0)
|
||||
flat_b0 = evidence([-104.0 + (i % 3) for i in range(30)], 0.0)
|
||||
gives = evidence([0.0] * 30, -100.0)
|
||||
takes = evidence([-200.0] * 30, -100.0)
|
||||
check("the band is centred on the idle reading: B flat at -104 W is evidence "
|
||||
"at 22 s with idle -100 W and never with idle 0 W; reading 0 W (giving "
|
||||
"100 W) or -200 W (taking 100 W) is not",
|
||||
1 in flat_b and flat_b.index(1) == 22 and not any(flat_b0)
|
||||
and not any(gives) and not any(takes))
|
||||
|
||||
T = control.Tuning(max_w=4000.0, slew_w=400.0)
|
||||
args = dict(prev_w=1200.0, grid_w=1200.0, tuning=T,
|
||||
sat_count=control.SETTLE_CYCLES, i_w=1200.0, refused=0,
|
||||
gave_nothing=True)
|
||||
check("compute(): the N=1 engage is centred on idle_w (-104 W refuses with "
|
||||
"idle -100, not with 0; -4 W refuses with idle 0, not with -150)",
|
||||
control.compute(actual_w=-104.0, idle_w=-100.0, **args).refused == 1
|
||||
and control.compute(actual_w=-104.0, **args).refused == 0
|
||||
and control.compute(actual_w=-4.0, **args).refused == 1
|
||||
and control.compute(actual_w=-4.0, idle_w=-150.0, **args).refused == 0)
|
||||
args.update(refused=-1, i_w=-1200.0, prev_w=0.0, grid_w=-1200.0)
|
||||
check("compute(): a charge refusal ends when the battery takes 100 W more "
|
||||
"than its idle reading, not at its idle reading",
|
||||
control.compute(actual_w=-110.0, idle_w=-100.0, **args).refused == -1
|
||||
and control.compute(actual_w=-205.0, idle_w=-100.0, **args).refused
|
||||
== 0
|
||||
and control.compute(actual_w=-110.0, **args).refused == 0)
|
||||
check("the band's centre stays within 75 W of 0 W, so the band keeps 0 W "
|
||||
"+-25 W; samples to 250 W",
|
||||
idle.IDLE_MAX_W == 75.0 and idle.IDLE_SAMPLE_W == 250.0
|
||||
and control.REFUSED_BELOW_W - idle.IDLE_MAX_W >= 25.0
|
||||
and idle.IDLE_ASK_W < control.PROBE_W)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -1631,7 +1631,10 @@ def _sign_site(hass, store=None, **over):
|
||||
_P(), stop=asyncio.Event())
|
||||
b = Inverter("inverter-b", setpoint_entity="number.sp_b",
|
||||
soc_entity="sensor.soc_b", batt_entity="sensor.batt_b",
|
||||
max_w=B_RATING_HYPOTHESIS, store=c.store)
|
||||
max_w=B_RATING_HYPOTHESIS, store=c.store,
|
||||
# SAFETY-91: a cut-out, as rule 8 requires; without one B is
|
||||
# held back at its reserve and gets no share to reverse.
|
||||
bms_soc_floor=c.inverters[0].bms_soc_floor)
|
||||
b.dev_min, b.dev_max = -B_RATING_HYPOTHESIS, B_RATING_HYPOTHESIS
|
||||
c.inverters.append(b)
|
||||
c.auto = True
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""SAFETY-103's plant: test_safety95's lagging battery with the SAFETY-90
|
||||
register model, driven through the REAL Controller in simulated time.
|
||||
|
||||
Each battery ramps at 800 W/s, then follows a first-order lag (`lag` s). Its
|
||||
power register publishes every `reg_s` s at its own phase (the SAFETY-90/96
|
||||
rig: A at 0.6, B at 0.16 of the period), and reads `off` W from the truth
|
||||
(B idles at -100 W). The meter reads every cycle (`tick` s). A drain on A
|
||||
runs from the first cycle (the others finished theirs); `before(c, t)` ends
|
||||
it. `run()` has no side effects on import, so tests and probes share it.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import timedelta
|
||||
|
||||
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
from app import main as app_main, packmode # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
A = "inverter-a"
|
||||
T = 300.0 # A's drain ends here
|
||||
PHASE = {"a": 0.6, "b": 0.16, "c": 0.38}
|
||||
OFF = {"b": -100.0} # the rig's B: idle reading -100 W
|
||||
M = {"maintenance_enabled": True, "maintenance_start_hour": 11,
|
||||
"battery_modes_enabled": True}
|
||||
|
||||
|
||||
def options(n, extra=None):
|
||||
o = rig._options(n, dict(M, **(extra or {})))
|
||||
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 store(n):
|
||||
"""A due for its monthly cycle (it drains from the first cycle)."""
|
||||
s = rig._Store()
|
||||
for u in "bc"[:n - 1]:
|
||||
s.data[unit_key(f"inverter-{u}", "last_completed")] = \
|
||||
rig.BASE.isoformat()
|
||||
return s
|
||||
|
||||
|
||||
def off(c, t):
|
||||
"""A switched Off at T (the SAFETY-95 check 5 end)."""
|
||||
if t >= T:
|
||||
c.store.data[unit_key(A, packmode.USE_KEY)] = False
|
||||
|
||||
|
||||
def run(n, house, *, before=off, dur=T + 90.0, tick=1.0, reg_s=10.0,
|
||||
lag=10.0, noise=50.0, seed=103, phase=None, offs=None, extra=None,
|
||||
st=None, soc=None, dead=None, reject=None):
|
||||
"""Rows of one run. Each row is a dict: t, cmd (the add-on's commands),
|
||||
target, grid (true), true (each battery's power), read (each register),
|
||||
age (s since each register published), phase (maintenance phases).
|
||||
`dead` (u, from s): u gives nothing from then on (board at 0 W).
|
||||
`reject` {u: from s}: writes to u are refused from then on."""
|
||||
units = tuple("abc"[:n])
|
||||
phase = PHASE if phase is None else phase
|
||||
offs = OFF if offs is None else offs
|
||||
rnd = random.Random(seed)
|
||||
soc = soc or (lambda u, t: 50.0)
|
||||
|
||||
class Hass(rig._Hass):
|
||||
def __init__(self, *a):
|
||||
super().__init__(*a)
|
||||
self.tgt = {u: 0.0 for u in units}
|
||||
self.act = {u: 0.0 for u in units}
|
||||
self.reg = {u: 0.0 for u in units}
|
||||
self.pub_t = {u: 0.0 for u in units}
|
||||
self.slot = {u: None for u in units}
|
||||
self.tl = 0.0
|
||||
self.hn = 0.0
|
||||
|
||||
def advance(self, t):
|
||||
for k in range(int(round((t - self.tl) / 0.1))):
|
||||
now = self.tl + (k + 1) * 0.1
|
||||
for u in units:
|
||||
c = self.cmd[u]
|
||||
if dead is not None and u == dead[0] and now >= dead[1]:
|
||||
c = 0.0
|
||||
d = c - self.tgt[u]
|
||||
self.tgt[u] += math.copysign(min(abs(d), 80.0), d)
|
||||
self.act[u] += (self.tgt[u] - self.act[u]) * (
|
||||
1 - math.exp(-0.1 / lag))
|
||||
s = math.floor((now - phase.get(u, 0.0) * reg_s) / reg_s)
|
||||
if s != self.slot[u]:
|
||||
self.slot[u] = s
|
||||
self.reg[u] = float(round(self.act[u] + offs.get(u, 0.0)))
|
||||
self.pub_t[u] = now
|
||||
self.tl = t
|
||||
self.hn = rnd.uniform(-noise, noise) if noise else 0.0
|
||||
|
||||
def power(self, u):
|
||||
return self.reg[u]
|
||||
|
||||
def grid(self):
|
||||
h = house(self.t) if callable(house) else house
|
||||
return float(round(h + self.hn - sum(self.act.values())))
|
||||
|
||||
async def set_number(self, e, v):
|
||||
u = e[-1]
|
||||
if reject and u in reject and self.t >= reject[u]:
|
||||
return False
|
||||
self.cmd[u] = float(v)
|
||||
return True
|
||||
|
||||
opts = options(n, extra)
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="s103-"), "tariff.db")
|
||||
|
||||
async def go():
|
||||
hass = Hass(units, soc, lambda u, t: False, lambda t: 0.0)
|
||||
c = Controller(opts, hass, st or store(n), 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.advance(t)
|
||||
hass.t = clock[0] = t
|
||||
for inv in c.inverters:
|
||||
u = inv.name[-1]
|
||||
if reject and u in reject and t >= reject[u] \
|
||||
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
|
||||
await c.read_inputs(now_mono=t)
|
||||
await c.cycle()
|
||||
rows.append(dict(
|
||||
t=t, cmd=tuple(i.target for i in c.inverters),
|
||||
target=c.target, grid=hass.grid(),
|
||||
true=tuple(round(hass.act[u]) for u in units),
|
||||
read=tuple(hass.reg[u] for u in units),
|
||||
age=tuple(round(t - hass.pub_t[u], 1) for u in units),
|
||||
phase=tuple(m.phase for m in c.maints),
|
||||
reason=c.loop_reason))
|
||||
t += tick
|
||||
finally:
|
||||
app_main.datetime = real_dt
|
||||
return rows, c
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
def end_at(rows):
|
||||
"""The first cycle A's command left its drain (None if it never did)."""
|
||||
return next((r["t"] for r in rows if r["t"] >= T
|
||||
and r["cmd"][0] < 1990.0), None)
|
||||
|
||||
|
||||
def window(rows, span=23.0):
|
||||
"""The rows from the end cycle to `span` s after it (the settle window,
|
||||
PLANT_SETTLE_DOWN_S)."""
|
||||
te = end_at(rows)
|
||||
return [r for r in rows if te is not None and te <= r["t"] <= te + span]
|
||||
|
||||
|
||||
def swing(rows, i=1, span=23.0):
|
||||
"""Battery i's command in the settle window after the end cycle, and
|
||||
its largest swing there: a move that is later undone (up then down, or
|
||||
down then up), the smaller of its two legs. A steady climb scores 0."""
|
||||
w = [r["cmd"][i] for r in window(rows, span)][1:]
|
||||
best = 0.0
|
||||
for j in range(len(w)):
|
||||
lo_b = min(w[:j + 1])
|
||||
hi_b = max(w[:j + 1])
|
||||
lo_a = min(w[j:])
|
||||
hi_a = max(w[j:])
|
||||
best = max(best, min(w[j] - lo_b, w[j] - lo_a),
|
||||
min(hi_b - w[j], hi_a - w[j]))
|
||||
return best, w
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SAFETY-87: at one inverter, is the peak guard byte-identical to before?
|
||||
|
||||
python3 tools/safety87_identity.py # prints one digest per scenario
|
||||
|
||||
safety84_identity's harness (the REAL Controller on a P1 meter, every claim
|
||||
and the guard's decision hashed) with the battery under, crossing and above
|
||||
the peak reserve, the house over the aim, in a maintenance charge and HOLD,
|
||||
and under a month peak banked at 4000 W. Uses only the API release/1.0
|
||||
b8ddfd7 has, so the same file runs on both trees; test_safety87.py holds this
|
||||
tree to the digests printed there.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import safety84_identity as s84 # noqa: E402
|
||||
|
||||
DUR = 1800.0
|
||||
|
||||
|
||||
def crossing(u, t):
|
||||
"""30 % falling 1 % a minute to 12 %: crosses the 20 % reserve."""
|
||||
return max(12.0, 30.0 - t / 60.0)
|
||||
|
||||
|
||||
def store(phase=None):
|
||||
"""In `phase` from the first cycle, or idle with a cycle done yesterday."""
|
||||
s = rig._Store()
|
||||
if phase is None:
|
||||
s.data["last_completed"] = (rig.BASE - timedelta(days=1)).isoformat()
|
||||
else:
|
||||
s.data["phase"], s.data["phase_started"] = phase, rig.BASE.isoformat()
|
||||
return s
|
||||
|
||||
|
||||
SCENARIOS = {
|
||||
"n1_under": dict(soc=lambda u, t: 15.0, load=lambda t: 5500.0),
|
||||
"n1_crossing": dict(soc=crossing, load=lambda t: 5000.0),
|
||||
"n1_above": dict(soc=lambda u, t: 60.0, load=lambda t: 5500.0),
|
||||
"n1_under_banked": dict(soc=lambda u, t: 15.0, load=lambda t: 6000.0,
|
||||
month_peak=4000.0),
|
||||
"n1_charge_low": dict(soc=lambda u, t: 11.0, load=lambda t: 5000.0,
|
||||
store="charge"),
|
||||
"n1_hold": dict(soc=lambda u, t: 99.0, load=lambda t: 5500.0,
|
||||
store="hold", cheap=False,
|
||||
extra={"maintenance_hold_min": 600}),
|
||||
}
|
||||
|
||||
|
||||
def digests() -> dict:
|
||||
out = {}
|
||||
for k, v in SCENARIOS.items():
|
||||
v = dict(v)
|
||||
st = store(v.pop("store", None))
|
||||
out[k] = s84.digest(s84.run(1, dur=DUR, store=st, **v)[0])
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for k, v in digests().items():
|
||||
print(f"{k} {v}")
|
||||
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SAFETY-94: the REAL Controller on a lagging battery whose reading is late.
|
||||
|
||||
python3 tools/safety94_rig.py # prints every scenario's counts
|
||||
|
||||
Each battery ramps at 800 W/s, then follows a first-order lag (`tau`, 10 s
|
||||
as on the SAFETY-85 rig). Its reading (register 0x0518) is published every
|
||||
10 s, at a different phase per unit, and repeats its value between publishes,
|
||||
as the live boards do. B reads 100 W below what it gives (its standby draw:
|
||||
-100 W at idle, live 2026-10-03). The meter updates every `tick` s; the add-on
|
||||
reads every 1 s and cycles on a new meter value, as `run_control()` does.
|
||||
|
||||
A frozen register stops publishing at `freeze_at`; a refusing battery (`cut`)
|
||||
gives 0 W and reads -4 W plus its offset (B: -104 W), with `noise` W of
|
||||
seeded jitter (SAFETY-97). Uses only the Controller API release/1.0 02ed120
|
||||
has, so the same file runs on the tree before SAFETY-94.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import app.main as m # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
|
||||
BASE = datetime(2026, 10, 3, 10, 50, tzinfo=timezone.utc) # drain from 600 s
|
||||
PUBLISH_S = 10.0
|
||||
PHASE = {"a": 6.0, "b": 1.6} # live: A publishes at :x6, B at :x1.6
|
||||
OFFSET = {"a": 0.0, "b": -100.0} # B's standby draw, live 2026-10-03
|
||||
CUT_W = -4.0
|
||||
LIVE = {"gain": 0.6, "slew_w": 400, "target_grid_w": -10, "deadband_w": 15,
|
||||
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15,
|
||||
"auto_start": True, "meter_entity": "sensor.grid",
|
||||
"meter_source": "off"}
|
||||
MAINT = {"maintenance_enabled": True, "maintenance_start_hour": 11,
|
||||
"maintenance_discharge_w": 2500, "maintenance_hold_min": 30}
|
||||
|
||||
|
||||
class _Store:
|
||||
def __init__(self, data=None):
|
||||
from app.store import DEFAULTS
|
||||
self.data = dict(DEFAULTS, **(data or {}))
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get_time(self, k):
|
||||
v = self.data.get(k)
|
||||
return datetime.fromisoformat(v) if v else None
|
||||
|
||||
def set_time(self, k, when):
|
||||
self.data[k] = when.isoformat() if when else None
|
||||
|
||||
|
||||
class _Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, *a, **k):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class Plant:
|
||||
"""The batteries, their registers and the meter, in simulated seconds."""
|
||||
|
||||
def __init__(self, units, load, tau, tick, soc, cut, freeze_at,
|
||||
publish_s=PUBLISH_S, dither=False, noise=0.0, seed=0):
|
||||
self.units, self.load, self.tau, self.tick = units, load, tau, tick
|
||||
self.noise, self.rng = noise, random.Random(seed)
|
||||
self.publish_s, self.dither, self.n_pub = publish_s, dither, 0
|
||||
self.soc_fn, self.cut, self.freeze_at = soc, cut, freeze_at
|
||||
self.cmd = {u: 0.0 for u in units}
|
||||
self.ramp = {u: 0.0 for u in units}
|
||||
self.real = {u: 0.0 for u in units}
|
||||
self.reg = {u: round(OFFSET[u]) for u in units}
|
||||
self.grid_v = float(load(0.0))
|
||||
self.t = 0.0
|
||||
|
||||
def advance(self, t):
|
||||
"""Integrate to `t` in 0.1 s steps; publish and sample on schedule."""
|
||||
while self.t < t - 1e-9:
|
||||
self.t = round(self.t + 0.1, 6)
|
||||
for u in self.units:
|
||||
want = 0.0 if self.cut(u, self.t) else self.cmd[u]
|
||||
d = want - self.ramp[u]
|
||||
self.ramp[u] += math.copysign(min(abs(d), 80.0), d)
|
||||
self.real[u] += ((self.ramp[u] - self.real[u])
|
||||
* (1 - math.exp(-0.1 / self.tau)))
|
||||
frozen = (self.freeze_at is not None
|
||||
and self.freeze_at(u) is not None
|
||||
and self.t >= self.freeze_at(u))
|
||||
ph = (self.t - PHASE[u] % self.publish_s) / self.publish_s
|
||||
if not frozen and abs(ph - round(ph)) < 1e-6:
|
||||
# `dither`: the +-1 W a settled register shows (live).
|
||||
self.n_pub += 1
|
||||
jit = (self.n_pub * 7 % 3 - 1) if self.dither else 0
|
||||
self.reg[u] = (
|
||||
float(round(CUT_W + OFFSET[u] + self.rng.uniform(
|
||||
-self.noise, self.noise)))
|
||||
if self.cut(u, self.t)
|
||||
else float(round(self.real[u] + OFFSET[u]) + jit))
|
||||
if abs(self.t / self.tick - round(self.t / self.tick)) < 1e-6:
|
||||
self.grid_v = float(round(self.load(self.t)
|
||||
- sum(self.real.values())))
|
||||
|
||||
|
||||
class _Hass:
|
||||
def __init__(self, plant):
|
||||
self.p = plant
|
||||
|
||||
async def limits(self, e):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def call(self, *a):
|
||||
return True
|
||||
|
||||
async def number(self, e, invert=False):
|
||||
if e == "sensor.grid":
|
||||
return self.p.grid_v
|
||||
u = e[-1]
|
||||
if e.startswith("sensor.soc"):
|
||||
return self.p.soc_fn(u, self.p.t)
|
||||
if e.startswith("sensor.batt"):
|
||||
return self.p.reg[u]
|
||||
return None
|
||||
|
||||
async def set_number(self, e, v):
|
||||
self.p.cmd[e[-1]] = float(v)
|
||||
return True
|
||||
|
||||
|
||||
def options(n, extra=None):
|
||||
o = dict(LIVE)
|
||||
if n == 1:
|
||||
o.update({"setpoint_entity": "number.sp_a",
|
||||
"soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a",
|
||||
"max_w": 4000, "bms_soc_floor": 10})
|
||||
else:
|
||||
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 "ab"[:n]]
|
||||
o.update(extra or {})
|
||||
return o
|
||||
|
||||
|
||||
def run(n, load, dur, *, tau=10.0, tick=1.0, soc=None, cut=None,
|
||||
freeze_at=None, extra=None, floor_s=15.0, publish_s=PUBLISH_S,
|
||||
dither=False, noise=0.0, seed=0, store=None):
|
||||
"""Returns a dict: per-unit stale trips [(t, unit)], refusals
|
||||
[(t, unit or 'site')], rows [(t, grid, {u: cmd}, {u: real}, {u: reg},
|
||||
phases)] and the controller. `store`: saved state to start from."""
|
||||
units = tuple("ab"[:n])
|
||||
soc = soc or (lambda u, t: 50.0)
|
||||
cut = cut or (lambda u, t: False)
|
||||
plant = Plant(units, load, tau, tick, soc, cut, freeze_at, publish_s,
|
||||
dither, noise, seed)
|
||||
|
||||
async def go():
|
||||
c = Controller(options(n, extra), _Hass(plant), _Store(store), _Pub(),
|
||||
stop=asyncio.Event())
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
c.auto = True
|
||||
real_dt = m.datetime
|
||||
clock = [0.0]
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return BASE + timedelta(seconds=clock[0])
|
||||
|
||||
m.datetime = _DT
|
||||
out = {"stale": [], "refused": [], "rows": [], "c": c}
|
||||
was = {i.name: False for i in c.inverters}
|
||||
was_ref = {i.name: 0 for i in c.inverters}
|
||||
was_site = 0
|
||||
last_grid, last_cycle = None, None
|
||||
try:
|
||||
t = 0.0
|
||||
while t < dur:
|
||||
plant.advance(t)
|
||||
clock[0] = t
|
||||
await c.read_inputs(now_mono=t)
|
||||
for i in c.inverters:
|
||||
if i.batt_fresh.stale and not was[i.name]:
|
||||
out["stale"].append((t, i.name))
|
||||
was[i.name] = i.batt_fresh.stale
|
||||
cycled = (c.grid != last_grid or last_cycle is None
|
||||
or t - last_cycle >= floor_s)
|
||||
if cycled:
|
||||
last_grid, last_cycle = c.grid, t
|
||||
await c.cycle()
|
||||
# A refusal can start on a read (a gate trip) or in a cycle.
|
||||
for i in c.inverters:
|
||||
if i.refused and not was_ref[i.name]:
|
||||
out["refused"].append((t, i.name))
|
||||
was_ref[i.name] = i.refused
|
||||
if c.batt_refused and not was_site:
|
||||
out["refused"].append((t, "site"))
|
||||
was_site = c.batt_refused
|
||||
if cycled:
|
||||
out["rows"].append((
|
||||
t, plant.grid_v, dict(plant.cmd),
|
||||
{u: round(v) for u, v in plant.real.items()},
|
||||
dict(plant.reg), [mm.phase for mm in c.maints]))
|
||||
t += 1.0
|
||||
finally:
|
||||
m.datetime = real_dt
|
||||
return out
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
# --- the scenarios ---------------------------------------------------------
|
||||
def rest_then(step_at, w):
|
||||
"""A house at 0 W (the batteries rest) until `step_at`, then `w`."""
|
||||
return lambda t: w if t >= step_at else 0.0
|
||||
|
||||
|
||||
def reversal(at_s, charge_w, house_w):
|
||||
"""Solar surplus (the batteries charge), then a house load from `at_s`."""
|
||||
return lambda t: house_w if t >= at_s else charge_w
|
||||
|
||||
|
||||
def drain_soc(floor_at):
|
||||
"""A drains (maintenance) and reaches its floor at `floor_at`; B at 50 %."""
|
||||
def soc(u, t):
|
||||
if u != "a":
|
||||
return 50.0
|
||||
return 50.0 if t < floor_at else 10.0
|
||||
return soc
|
||||
|
||||
|
||||
SCENARIOS = {
|
||||
# first command after a long rest (the live 17:39 shape)
|
||||
"rest_step": dict(load=rest_then(600.0, 2400.0), dur=900.0),
|
||||
# maintenance drain starts after a rest (the live 19:00 shape)
|
||||
"drain_start": dict(load=lambda t: 300.0, dur=900.0,
|
||||
extra=MAINT, soc=drain_soc(1e9)),
|
||||
# A's drain ends and its charge starts (the live 21:21 shape)
|
||||
"drain_charge": dict(load=lambda t: 300.0, dur=1200.0, extra=MAINT,
|
||||
soc=drain_soc(900.0)),
|
||||
# a charge reverses into a discharge through the lag (SAFETY-94)
|
||||
"reversal": dict(load=reversal(300.0, -2500.0, 800.0), dur=600.0),
|
||||
}
|
||||
|
||||
|
||||
def counts(n, name, **kw):
|
||||
sc = dict(SCENARIOS[name], **kw)
|
||||
r = run(n, **sc)
|
||||
return len(r["stale"]), len(r["refused"]), r
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for n in (1, 2):
|
||||
for name in SCENARIOS:
|
||||
for tick in (1.0, 2.0, 5.0):
|
||||
for pub, dith in ((10.0, False), (1.0, True)):
|
||||
s, f, r = counts(n, name, tick=tick, publish_s=pub,
|
||||
dither=dith)
|
||||
print(f"N={n} {name:13s} tick {tick:.0f} s publish "
|
||||
f"{pub:2.0f} s{' dither' if dith else ' '}: "
|
||||
f"stale {s} {r['stale'][:2]}, refusals {f} "
|
||||
f"{r['refused'][:2]}")
|
||||
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user