Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df154a26d1 | ||
|
|
451729c9b5 | ||
|
|
1c607a961a | ||
|
|
756b50ddbb | ||
|
|
340c7eef42 | ||
|
|
4707d37019 | ||
|
|
9c0314a826 | ||
|
|
bdec66e97a | ||
|
|
b2f55732b6 | ||
|
|
956711b801 | ||
|
|
88b9cd470a | ||
|
|
1bf6b7f9a7 | ||
|
|
1d28daf8a0 | ||
|
|
0b6b34f563 | ||
|
|
08fb1235fc | ||
|
|
7ef73474dc | ||
|
|
24dbbd18ad | ||
|
|
8dfe91e1d2 | ||
|
|
9d3fbe1be9 | ||
|
|
21599938ea | ||
|
|
3ed3302d2f | ||
|
|
8c6e5bca2e | ||
|
|
f01152b113 | ||
|
|
3129daed62 | ||
|
|
64dddd64bf | ||
|
|
cf705a9058 | ||
|
|
7d06aa674d | ||
|
|
88ebf3a5a6 | ||
|
|
3db2b6392f | ||
|
|
80bfc5721e | ||
|
|
6918e154c8 | ||
|
|
dd5eb6fdc8 | ||
|
|
6759d21ea5 | ||
|
|
3a54b253cb | ||
|
|
02ed1200dc | ||
|
|
f13dab1407 | ||
|
|
b307db1049 | ||
|
|
e51e3e02b2 | ||
|
|
69dc1f0e7b | ||
|
|
858f77cfb5 | ||
|
|
214051d1a8 | ||
|
|
05eb93f139 | ||
|
|
f3e409cf76 | ||
|
|
8b9680fc35 | ||
|
|
4cd604afcb | ||
|
|
6872353e2e | ||
|
|
8c438ab2e6 | ||
|
|
b37b7f95dc | ||
|
|
28cab03947 | ||
|
|
61072ca460 | ||
|
|
7b723a6cd5 | ||
|
|
4b81387908 | ||
|
|
935878193b | ||
|
|
b4d25dd485 | ||
|
|
a4a04d203c | ||
|
|
aa501ffec7 | ||
|
|
d89c093318 | ||
|
|
d0db5b8c39 | ||
|
|
b8ddfd787f | ||
|
|
1c41a1e412 | ||
|
|
ba31e7c8b1 | ||
|
|
513183aba3 | ||
|
|
4ad8298ead | ||
|
|
2f5ab1f378 | ||
|
|
fbdd552555 | ||
|
|
02c0a3dd7b | ||
|
|
3ce18708b6 | ||
|
|
78acb558ff | ||
|
|
0f14c2a5de |
+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.
|
||||
@@ -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
|
||||
|
||||
@@ -30,6 +30,5 @@ everything. Where the floor is at or below the drain rate nothing changes.
|
||||
**Not changed.**
|
||||
- Between the drain rate and the peak aim, the house still imports what the
|
||||
drain leaves (N=1 behaviour kept by the identity). At N=2 the loop covers it.
|
||||
- A maintenance HOLD (0 W, 120 min, battery full) still outranks the peak
|
||||
guard at N=1. Discharging there spends the charge the BMS balances on; it is
|
||||
an owner question, not this fix.
|
||||
- A maintenance HOLD (0 W, 120 min, battery full) still outranked the peak
|
||||
guard at N=1. Fixed by [[SAFETY-86]].
|
||||
|
||||
@@ -41,6 +41,6 @@ was fine.
|
||||
and the battery gives until the quarter is safe. The same sizing would fit
|
||||
there; that is an owner question, not this fix.
|
||||
- Outside a cheap window (`hi = 0`, solar only) nothing is filed, at any N.
|
||||
- A HOLD (SAFETY-86) and the "Battery is not keeping up" alarm (SAFETY-85)
|
||||
are untouched. In the identity runs that alarm's log line comes as often at
|
||||
N=2 as at N=1 (2 in 480 cycles).
|
||||
- A HOLD (later fixed by [[SAFETY-86]]) and the "Battery is not keeping up"
|
||||
alarm (SAFETY-85) are untouched. In the identity runs that alarm's log line
|
||||
comes as often at N=2 as at N=1 (2 in 480 cycles).
|
||||
|
||||
@@ -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,47 @@
|
||||
# SAFETY-86 - the peak guard outranks the maintenance HOLD
|
||||
|
||||
**Status: DECIDED 2026-09-30 (owner).** Follows SAFETY-83's "Not changed"
|
||||
list. Tests: `test_safety86.py`.
|
||||
|
||||
**The defect.** The HOLD (battery resting full after the maintenance charge)
|
||||
is a SET of 0 W at `P_MAINTENANCE` (60), above the peak floor (`P_PEAK`, 50),
|
||||
so the floor did not bind. At N=1 a 6500 W house banked 6500 W quarters and
|
||||
'Load too high' fired (SAFETY-83 rig test, check 4). At N=2 only the other
|
||||
battery gave: 4500 W against a 3950 W aim (SAFETY-84 rig test, check 6).
|
||||
|
||||
**The fix** (`main.py`, `_peak_over_hold()`).
|
||||
- N=1: during a HOLD a peak floor above 0 W is refiled at `P_MAINTENANCE`, as
|
||||
for a drain (SAFETY-83). The battery gives the floor; the Why reads "raised
|
||||
for the peak limit".
|
||||
- N>=2: the holding battery's pin is raised to the floor minus what the other
|
||||
live batteries may place (their hard `hi`, or their pin). The others give
|
||||
first, the holding one only the shortfall. Why this split and not the
|
||||
allocator's shares: the hold is maintenance and outranks the others' use
|
||||
(owner order), and the owner rule for an Off battery is the same, "give way
|
||||
only for the shortfall". A battery Off by its mode (P_MODE, below
|
||||
maintenance) therefore still gives before the holding one.
|
||||
- N>=2, while the holding battery gives: a site `hold` ceiling at the floor
|
||||
(P_MAINTENANCE), so the loop does not ask for watts the hold keeps back.
|
||||
Without it the moving pin re-logged the ALLOCATOR "could not be placed"
|
||||
row each cycle and filled the action log (review, PR #161).
|
||||
- Below the aim nothing changes: the floor is 0 W, the hold is 0 W, the rows
|
||||
are identical to before (test_safety86.py part 2).
|
||||
- A site stop (a SET above `P_PEAK`) decides alone: nothing is raised at N>=2,
|
||||
and the refiled floor at N=1 stays under `P_SAFETY`. If another battery is
|
||||
pinned to charge (a SAFETY-73 probe) the pin is not raised, so the site is
|
||||
never split both ways.
|
||||
- The hold resumes by itself: the rule is evaluated each cycle, so when the
|
||||
guard lets go the command is 0 W again.
|
||||
|
||||
**The level dips below the target: the hold finishes anyway.** Owner
|
||||
decision 2026-10-01: the HOLD ends on its clock (`maintenance_hold_min`) even
|
||||
when the peak guard pulls the level down; no top-up. A top-up would re-enter
|
||||
CHARGE (up to 36 h, `CHARGE_TIMEOUT_MIN`) and keep the site's one maintenance
|
||||
slot meanwhile.
|
||||
|
||||
**Not changed.**
|
||||
- The peak guard still grades the emptiest battery (SAFETY-32): at N=2 with
|
||||
B under `peak_soc_floor` the floor is limited and A holds even when it is
|
||||
full (SAFETY-87).
|
||||
- "Battery is not keeping up" during maintenance (SAFETY-85), the N=2 charge
|
||||
flapping (SAFETY-88) and the N=1 charge headroom sizing are untouched.
|
||||
@@ -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).
|
||||
+82
-17
@@ -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,11 @@ 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).
|
||||
|
||||
**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
|
||||
@@ -1638,13 +1643,30 @@ help - see "Why the tuning is what it is".
|
||||
|---|---|---|
|
||||
| `maintenance_enabled` | false | Enable the monthly cycle |
|
||||
| `maintenance_interval_days` | 28 | Minimum gap between cycles |
|
||||
| `maintenance_start_hour` | 10 | Hour of day a due cycle begins |
|
||||
| `maintenance_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.
|
||||
|
||||
### Tariff (all optional)
|
||||
|
||||
| option | meaning |
|
||||
@@ -1666,9 +1688,23 @@ 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).
|
||||
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).
|
||||
|
||||
#### Capacity-tariff exposure of the grid-following loop
|
||||
|
||||
@@ -1738,10 +1774,17 @@ ceiling at the cap, so a broken instrument makes the guard stricter, never loose
|
||||
**When it cannot see, it forbids importing.** Three states are not evidence and
|
||||
are checked before the projection is graded: no open quarter (a fresh averager
|
||||
projects a confident 0 W, which is under every ceiling there is), a meter gap
|
||||
longer than `meter_max_age_s`, and SAFETY-18's frozen-meter conjunction. In all
|
||||
three the claim becomes a floor of **0 W**. That is the backstop for the frozen
|
||||
meter: the alarm arrives at `meter_freeze_s` (300 s) of a 900 s billing quarter
|
||||
and commands nothing, and this stops the import.
|
||||
longer than `meter_max_age_s` while it lasts, and SAFETY-18's frozen-meter
|
||||
conjunction. In all three the claim becomes a floor of **0 W**. That is the
|
||||
backstop for the frozen meter: the alarm arrives at `meter_freeze_s` (300 s) of
|
||||
a 900 s billing quarter and commands nothing, and this stops the import.
|
||||
|
||||
**After a gap it goes on (SAFETY-90).** The seconds the meter missed count at
|
||||
the higher of the peak limit and the import seen just before and just after
|
||||
the gap, with the batteries at 0 W. Seconds before the add-on's first reading
|
||||
count the same way, at the import seen first, and the page says part of the
|
||||
period was not measured. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md has the
|
||||
bound.
|
||||
|
||||
**It trips, where the freeze detector only notifies,** and the captures are why.
|
||||
On the healthy reference day (2026-08-23) it engages on **zero** of 95 quarters.
|
||||
@@ -1804,7 +1847,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**
|
||||
@@ -1949,6 +1994,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
|
||||
@@ -3118,7 +3172,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
|
||||
|
||||
@@ -3153,6 +3210,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.
|
||||
@@ -3219,8 +3280,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
|
||||
@@ -3248,7 +3310,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*.
|
||||
|
||||
@@ -3386,7 +3450,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. |
|
||||
@@ -3426,7 +3490,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**
|
||||
|
||||
@@ -79,7 +79,7 @@ P_PEAK = 50 # capacity-tariff peak shaving - costs real money
|
||||
# The order in effect: safety limits and the SAFETY-73 refusal > the peak
|
||||
# guard > maintenance > modes > trade > loop. A peak floor above a drain still
|
||||
# binds: the `drain` site floor at N>=2 (SAFETY-78), refiled at P_MAINTENANCE
|
||||
# at N=1 (SAFETY-83). Not a maintenance HOLD at N=1 (DECISIONS/SAFETY-83).
|
||||
# at N=1 (SAFETY-83); over a HOLD, `_peak_over_hold()` (SAFETY-86).
|
||||
# A cheap-window charge is suspended while the peak guard holds and, at N>=2,
|
||||
# sized to the quarter's headroom (SAFETY-84).
|
||||
P_MAINT_SHAPE = 40 # maintenance shaping the loop (charge-only)
|
||||
@@ -202,6 +202,10 @@ CLAIM_SCOPE = {
|
||||
# sibling would have to give against it. Never filed at N=1.
|
||||
# DECISIONS/SAFETY-84-charge-at-two.md.
|
||||
"charge": SCOPE_SITE,
|
||||
# main.py: `Claim.limit("hold", P_MAINTENANCE, hi=...)`, SAFETY-86. While a
|
||||
# holding pack gives the peak shortfall at N>=2, the site target stays at
|
||||
# the peak floor, what the pin and the others place. Never filed at N=1.
|
||||
"hold": SCOPE_SITE,
|
||||
|
||||
# --- UNIT: about one pack, one inverter, one device -------------------
|
||||
# main.py: `Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max,
|
||||
@@ -631,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
|
||||
@@ -733,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:
|
||||
|
||||
+760
-63
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
|
||||
|
||||
@@ -112,7 +112,9 @@ RESTART_COOLDOWN_MIN = 90
|
||||
MAINT_OPTION_DEFAULTS = {
|
||||
"maintenance_enabled": False,
|
||||
"maintenance_interval_days": 28,
|
||||
"maintenance_start_hour": 10,
|
||||
# MAINT-10: a LOCAL hour (site_zone() below), 19:00 so the drain runs
|
||||
# into the night rather than through the solar day.
|
||||
"maintenance_start_hour": 19,
|
||||
# ⚠️ 2000, NOT THE 2500 THIS SHIPPED AS, and config.yaml carries the
|
||||
# argument: both rates must stay at or below `max_w` (2000) or the
|
||||
# cheap-window charge shaping asks the arbiter for `hi = -charge_w` against
|
||||
@@ -177,6 +179,69 @@ class MaintConfig:
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# MAINT-10: the start hour is local time. See DECISIONS/MAINT-10-local-start-hour.md.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _load_zone(name):
|
||||
"""A tzinfo for an IANA name, or `(None, why)`."""
|
||||
if not name or not isinstance(name, str):
|
||||
return None, "none given"
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
return ZoneInfo(name.strip()), ""
|
||||
except Exception as err: # noqa: BLE001 - any failure
|
||||
return None, f"{name!r} is not a known zone ({type(err).__name__})"
|
||||
|
||||
|
||||
def site_zone(ha_zone, ha_why: str, env_tz) -> tuple:
|
||||
"""`(tzinfo, name, source, why)`: Home Assistant's zone (source "ha"), else
|
||||
the container's TZ ("tz"), else UTC ("utc"). `why` says what was missing."""
|
||||
tz, why = _load_zone(ha_zone)
|
||||
if tz is not None:
|
||||
return tz, ha_zone, "ha", ""
|
||||
ha_why = ha_why or why
|
||||
tz, env_why = _load_zone(env_tz)
|
||||
if tz is not None:
|
||||
return tz, env_tz, "tz", ha_why
|
||||
return timezone.utc, "UTC", "utc", f"Home Assistant: {ha_why}, TZ: {env_why}"
|
||||
|
||||
|
||||
def in_start_slot(now: datetime, start_hour: int, tz: tzinfo) -> bool:
|
||||
"""Is `now` inside today's one-hour start slot, read on the local clock?
|
||||
|
||||
Normally the slot is the local hour `start_hour`. On the day the clocks go
|
||||
forward and that hour does not exist, it is the first hour after the jump.
|
||||
On the day they go back and it happens twice, only the first one counts.
|
||||
Worked from UTC instants only (`astimezone`), so it does not depend on how
|
||||
a tzinfo resolves a wall time that is missing or doubled.
|
||||
"""
|
||||
if now.tzinfo is None:
|
||||
now = now.replace(tzinfo=timezone.utc)
|
||||
here = now.astimezone(tz).replace(tzinfo=None)
|
||||
before = (now - timedelta(hours=1)).astimezone(tz).replace(tzinfo=None)
|
||||
if here.hour == start_hour:
|
||||
# the second pass through a repeated hour: an hour ago read the same
|
||||
return not (before.date() == here.date() and before.hour == start_hour)
|
||||
slot = here.replace(hour=start_hour, minute=0, second=0, microsecond=0)
|
||||
# the clock jumped over the start of the slot within the last hour
|
||||
return before < slot and here >= slot + timedelta(hours=1)
|
||||
|
||||
|
||||
def next_slot_start(now: datetime, start_hour: int, tz: tzinfo):
|
||||
"""The start of the slot `now` is in, or else of the next one; None if
|
||||
there is none within 50 h (no real zone does that)."""
|
||||
step = timedelta(minutes=15)
|
||||
t = now.replace(minute=now.minute - now.minute % 15, second=0,
|
||||
microsecond=0) - timedelta(hours=1)
|
||||
for _ in range(4 * 50):
|
||||
if (in_start_slot(t, start_hour, tz)
|
||||
and not in_start_slot(t - step, start_hour, tz)
|
||||
and t + timedelta(hours=1) > now):
|
||||
return t
|
||||
t += step
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MaintResult:
|
||||
phase: str
|
||||
@@ -256,8 +321,12 @@ class Maintenance:
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: MaintConfig, store, unit: str | None = None,
|
||||
name: str = "", mirror_flat: bool = False):
|
||||
name: str = "", mirror_flat: bool = False,
|
||||
tz: tzinfo | None = None):
|
||||
self.cfg = cfg
|
||||
# MAINT-10: the zone `cfg.start_hour` is read in - main.py resolves it
|
||||
# once at startup (`site_zone()`). UTC when nobody says, as before.
|
||||
self.tz = tz or timezone.utc
|
||||
# ⚠️ `self.store` IS THE VIEW, NOT THE Store. Everything in this class
|
||||
# names an UNQUALIFIED key - "phase", "last_completed" - and the view is
|
||||
# the one place that turns it into the key this pack owns. A raw
|
||||
@@ -331,7 +400,8 @@ class Maintenance:
|
||||
about who was asking - so the IDLE branch of `tick()` calls this one.
|
||||
"""
|
||||
return (self.phase == IDLE and self.cfg.enabled
|
||||
and now.hour == self.cfg.start_hour and self.due(now)
|
||||
and in_start_slot(now, self.cfg.start_hour, self.tz)
|
||||
and self.due(now)
|
||||
and self._cooled(now))
|
||||
|
||||
# -- operations ----------------------------------------------------------
|
||||
|
||||
@@ -217,7 +217,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 +227,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 +242,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. "
|
||||
@@ -470,8 +477,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 +487,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 +793,12 @@ def row_peak_guard_blind(why: str) -> str:
|
||||
f"meanwhile")
|
||||
|
||||
|
||||
def row_peak_guard_unmeasured() -> str:
|
||||
"""SAFETY-90. Before the first reading; that part counts as high use."""
|
||||
return ("Peak guard: part of this 15-minute period was not measured, "
|
||||
"counted as high use")
|
||||
|
||||
|
||||
def row_peak_guard_on(floor_w, limit_w, *, shadow: bool = False) -> str:
|
||||
if shadow:
|
||||
return (f"Peak guard: would ask for {watts(floor_w)} to stay under "
|
||||
@@ -836,6 +854,7 @@ WHY_LIMIT = {
|
||||
"reserve": "the battery reserve",
|
||||
"maintenance": "battery maintenance",
|
||||
"charge": "battery maintenance",
|
||||
"hold": "battery maintenance",
|
||||
"refused": "a battery that refuses power",
|
||||
}
|
||||
|
||||
@@ -899,6 +918,7 @@ HINTS = {
|
||||
"peak_on": "Peak guard started",
|
||||
"peak_off": "Peak guard idle",
|
||||
"peak_blind": "Peak guard cannot read the meter well",
|
||||
"peak_gap": "Meter is back, the peak guard counts the missed time as high use",
|
||||
"peak_saturated": T_PEAK_SATURATED,
|
||||
"peak_reserve": T_PEAK_RESERVE,
|
||||
"meter_frozen": T_METER_FROZEN,
|
||||
@@ -1013,6 +1033,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(
|
||||
@@ -1081,6 +1104,7 @@ def status_samples() -> list:
|
||||
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, True)),
|
||||
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, False)),
|
||||
("row_peak_guard_quiet", GREEN, row_peak_guard_quiet()),
|
||||
("row_peak_guard_unmeasured", AMBER, row_peak_guard_unmeasured()),
|
||||
*(("row_peak_guard_blind", AMBER, row_peak_guard_blind(k))
|
||||
for k in BLIND_WORDS),
|
||||
("row_peak_guard_on", AMBER, row_peak_guard_on(1200, 2500)),
|
||||
@@ -1115,6 +1139,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"]),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -149,7 +149,7 @@ check("...and `source` is still the first positional argument, which is what "
|
||||
Claim.limit("device", P_SAFETY, -1, 1).source == "device")
|
||||
|
||||
print()
|
||||
print("ARB-03 2. the split: fifteen sources, each classified where it is BUILT")
|
||||
print("ARB-03 2. the split: sixteen sources, each classified where it is BUILT")
|
||||
# The classification is graded against what each claim is ABOUT, restated here
|
||||
# so that the table in arbiter.py and the expectation in a test are not the same
|
||||
# sentence read twice. test_peakguard.py part 10f pins this set against main.py's
|
||||
@@ -179,6 +179,9 @@ SITE_SOURCES = {
|
||||
"charge": "the site target may not go above a cheap-window maintenance "
|
||||
"charge, or a sibling would give against it. Filed at N>1 "
|
||||
"only",
|
||||
# SAFETY-86.
|
||||
"hold": "the site target stays at the peak floor while a holding pack "
|
||||
"gives the shortfall. Filed at N>1 only",
|
||||
}
|
||||
UNIT_SOURCES = {
|
||||
"device": "one inverter's own reported min/max rating",
|
||||
@@ -210,9 +213,9 @@ for _src, _why in sorted(SITE_SOURCES.items()):
|
||||
print(f" site {_src:<11} {_why}")
|
||||
for _src, _why in sorted(UNIT_SOURCES.items()):
|
||||
print(f" unit {_src:<11} {_why}")
|
||||
check("all FIFTEEN claim sources are classified, and only those fifteen",
|
||||
check("all SIXTEEN claim sources are classified, and only those sixteen",
|
||||
set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES))
|
||||
check("the eight that are about the GRID CONNECTION are site claims",
|
||||
check("the nine that are about the GRID CONNECTION are site claims",
|
||||
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE} == set(SITE_SOURCES))
|
||||
check("the seven that are about a PACK, a DEVICE or one write are per-inverter",
|
||||
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} == set(UNIT_SOURCES))
|
||||
@@ -234,7 +237,7 @@ check("...and with none configured at all, likewise",
|
||||
all(actuator_for(s, "inverter-a", ()) == ACTUATOR_SITE for s in CLAIM_SCOPE))
|
||||
check("with TWO the identity breaks and the split becomes real: the seven "
|
||||
"per-inverter sources route to the unit this controller drives, the "
|
||||
"eight site sources stay on the grid connection",
|
||||
"nine site sources stay on the grid connection",
|
||||
{s: actuator_for(s, "inverter-a", TWO) for s in CLAIM_SCOPE}
|
||||
== {**{s: "inverter-a" for s in UNIT_SOURCES},
|
||||
**{s: ACTUATOR_SITE for s in SITE_SOURCES}})
|
||||
@@ -515,7 +518,7 @@ check("...and at N=1, where every source legitimately routes to the site, the "
|
||||
Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0)]) == [])
|
||||
check("an unclassified ninth source is a violation wherever it is filed",
|
||||
scope_violations([Claim.limit("reserve", P_LOOP, hi=0.0)]) != [])
|
||||
check("and the eight, routed by actuator_for() at a two-inverter site, are "
|
||||
check("and the sixteen, routed by actuator_for() at a two-inverter site, are "
|
||||
"clean - which is what says the router and the checker agree",
|
||||
scope_violations(
|
||||
[Claim.limit(s, P_SAFETY, -1, 1,
|
||||
|
||||
@@ -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")
|
||||
@@ -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)",
|
||||
@@ -173,14 +175,15 @@ check("...and decide() sizes the floor to land the quarter 50 W under it "
|
||||
# ============================================================================ #
|
||||
print("\n3. the bill and the reports keep the real cap")
|
||||
_c = _runs["n1_off", None][1]
|
||||
_pk = _c.peak.peak().peak_w
|
||||
_t = rig.rig.BASE # the runs' clock, not the wall clock (a new month)
|
||||
_pk = _c.peak.peak(_t).peak_w
|
||||
print(f" month peak {_pk:.0f} W, tracker cap {_c.peak.cap_w:.0f} W, "
|
||||
f"warning {_c.peak.warning_frac():.3f}, headroom "
|
||||
f"{_c.peak.headroom_w():.0f} W")
|
||||
f"warning {_c.peak.warning_frac(_t):.3f}, headroom "
|
||||
f"{_c.peak.headroom_w(_t):.0f} W")
|
||||
check("test_billing_uses_cap: the month-peak tracker (bill, headroom, 80 % "
|
||||
"warning) is built on the real cap, not cap - margin",
|
||||
_c.peak.cap_w == CAP and _c.peak.warning_frac() == _pk / CAP
|
||||
and _c.peak.headroom_w() == CAP - _pk)
|
||||
_c.peak.cap_w == CAP and _c.peak.warning_frac(_t) == _pk / CAP
|
||||
and _c.peak.headroom_w(_t) == CAP - _pk)
|
||||
_eng = [g for r in _runs["n1_off", None][0] for g in (r[-1][1],)
|
||||
if g and g[0] and g[1]]
|
||||
print(f" {len(_eng)} engaged cycles; one: {_eng[0][4] if _eng else None}")
|
||||
|
||||
@@ -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 "
|
||||
@@ -2691,9 +2698,12 @@ check("...and so does the persistent notification, which outlives the page and "
|
||||
# site `hi` at P_MAINT_SHAPE, filed only at N>=2 while a pack's cheap-window
|
||||
# maintenance charge makes it charge, equal to that pack's own `hi`. Never at
|
||||
# N=1. DECISIONS/SAFETY-84-charge-at-two.md.
|
||||
# SAFETY-86 added `hold`: a site `hi` at the peak floor while a holding pack
|
||||
# gives at N>=2. Not a reserve either. DECISIONS/SAFETY-86-peak-over-hold.md.
|
||||
CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak",
|
||||
"arbitrage", "maintenance", "loop", "circulation",
|
||||
"sign_site", "pack", "refused", "mode", "drain", "charge"}
|
||||
"sign_site", "pack", "refused", "mode", "drain", "charge",
|
||||
"hold"}
|
||||
# The two appends that file a claim BUILT ELSEWHERE in the method. Named here so
|
||||
# that a THIRD such indirection reads as an unrecognised append rather than as a
|
||||
# silent gap in the scan.
|
||||
@@ -2735,7 +2745,7 @@ check("every `claims.append(...)` in main.py's cycle is one this scan can READ "
|
||||
"- a claim filed through a shape it cannot parse has to fail here rather "
|
||||
"than be counted as nothing, which is how a scan like this goes quietly "
|
||||
"vacuous", len(_appends) >= 10 and None not in _filed)
|
||||
check("...and the sources that cycle can file are EXACTLY these FIFTEEN. THIS "
|
||||
check("...and the sources that cycle can file are EXACTLY these SIXTEEN. THIS "
|
||||
"is what makes part 10 a tripwire: replay_loop() hand-builds the claim "
|
||||
"list, so it cannot see a claim main.py adds - and a loop-side SoC "
|
||||
"reserve is a new source. Filing one takes this line red on the line it "
|
||||
@@ -2875,7 +2885,7 @@ print(" site: " + ", ".join(sorted(s for s, v in CLAIM_SCOPE.items()
|
||||
+ " | per-inverter: "
|
||||
+ ", ".join(sorted(s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT)))
|
||||
check("...and the split is the one ARB-03 argued, with SAFETY-31's and "
|
||||
"SAFETY-30's, OPT-09's, SAFETY-78's and SAFETY-84's additions: the eight about the GRID CONNECTION "
|
||||
"SAFETY-30's, OPT-09's, SAFETY-78's, SAFETY-84's and SAFETY-86's additions: the nine about the GRID CONNECTION "
|
||||
"are site claims and the five about a pack, a device or one write are "
|
||||
"per-inverter. `circulation` is a site claim because its quantity is "
|
||||
"defined over the roster and is identically zero for any one unit; "
|
||||
@@ -2885,7 +2895,7 @@ check("...and the split is the one ARB-03 argued, with SAFETY-31's and "
|
||||
"against that unit's OWN measured power",
|
||||
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE}
|
||||
== {"loop", "peak", "arbitrage", "safety", "circulation", "sign_site",
|
||||
"drain", "charge"}
|
||||
"drain", "charge", "hold"}
|
||||
and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT}
|
||||
== {"device", "supervised", "failsafe", "maintenance", "pack",
|
||||
"refused", "mode"})
|
||||
@@ -2990,7 +3000,8 @@ check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second "
|
||||
"loop": ACTUATOR_SITE, "peak": ACTUATOR_SITE,
|
||||
"arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE,
|
||||
"circulation": ACTUATOR_SITE, "sign_site": ACTUATOR_SITE,
|
||||
"drain": ACTUATOR_SITE, "charge": ACTUATOR_SITE})
|
||||
"drain": ACTUATOR_SITE, "charge": ACTUATOR_SITE,
|
||||
"hold": ACTUATOR_SITE})
|
||||
check("...and the roster it was restored to is the shipped one, so nothing "
|
||||
"below inherits a two-inverter Controller",
|
||||
c_src.units == (app_main.UNIT_A,) and len(c_src.inverters) == 1)
|
||||
@@ -3069,13 +3080,13 @@ _unit_sources_unrouted = [
|
||||
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT and "," not in r[1]]
|
||||
print(f" {len(_direct)} direct appends, each read at its own line: "
|
||||
+ ", ".join(f"main.py:{ln} {s} -> {a}" for s, a, ln in _routing))
|
||||
check("all SIXTEEN of `Controller.cycle()`'s DIRECT appends route through "
|
||||
check("all SEVENTEEN of `Controller.cycle()`'s DIRECT appends route through "
|
||||
"`self.actuator_of(<string literal>)`, so this scan can read the routing "
|
||||
"of EVERY one of them. An append that computed its actuator, or omitted "
|
||||
"the argument, or hand-wrote a string, fails HERE rather than being "
|
||||
"silently skipped - which is how a scan like this goes quietly vacuous, "
|
||||
"and 10f is in this file because that already happened once",
|
||||
len(_direct) == 16
|
||||
len(_direct) == 17
|
||||
and all(a is not None and a.startswith("self.actuator_of('")
|
||||
for _s, a, _ln in _routing))
|
||||
check("⚠️ AND AT EVERY ONE OF THE ELEVEN THE LITERAL IT ROUTES THROUGH IS THE "
|
||||
@@ -3109,8 +3120,8 @@ check("...and the only appends this pin does NOT reach are the two that file a "
|
||||
"arbitrage.claim() by test_arbitrage.py",
|
||||
sorted(_indirect) == sorted(CLAIM_BUILDERS)
|
||||
and len(_direct) + len(_indirect) == len(_appends))
|
||||
check("...and between them the eighteen appends account for exactly the "
|
||||
"fifteen "
|
||||
check("...and between them the nineteen appends account for exactly the "
|
||||
"sixteen "
|
||||
"sources 10f pins, so the routing pin and the source set are one scan "
|
||||
"and neither can drift from the other",
|
||||
{s for s, _a, _ln in _routing} | set(CLAIM_BUILDERS.values())
|
||||
@@ -3121,6 +3132,10 @@ check("...and between them the eighteen 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))
|
||||
@@ -3171,7 +3186,7 @@ check("...and naming those three is not an accident of this fixture but the "
|
||||
{c.source for c in _cl2}
|
||||
== CLAIM_SOURCES - {"failsafe", "safety", "maintenance", "peak",
|
||||
"arbitrage", "circulation", "sign_site",
|
||||
"refused", "mode", "drain", "charge"})
|
||||
"refused", "mode", "drain", "charge", "hold"})
|
||||
# ⚠️ AND NOW THE HALF THAT WAS EVIDENCE-SHAPED AND OBSERVED NOTHING. The three
|
||||
# checks above grade WHICH ACTUATOR each claim lands on. They say nothing about
|
||||
# WHOSE NUMBERS the bound is built from - and those are two different defects
|
||||
@@ -4135,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 "
|
||||
@@ -4217,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 "
|
||||
@@ -4382,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")
|
||||
@@ -4577,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 "
|
||||
@@ -5723,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",
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -295,11 +295,10 @@ def store_hold():
|
||||
|
||||
|
||||
_h_rows, _h, _hb = run(6000.0, 2500, 50, 2000, dur=600.0, store=store_hold())
|
||||
check("a maintenance HOLD at N=1 is not changed (DECISIONS/SAFETY-83, not "
|
||||
"changed): the battery stays at 0 W with the house over the limit",
|
||||
check("a maintenance HOLD at N=1 now gives way to the guard too (SAFETY-86, "
|
||||
"test_safety86.py): the battery gives with the house over the limit",
|
||||
_h.maints[0].phase == "hold" and _h.peak_guard.engaged
|
||||
and _h.peak_guard.floor_w > 0.0
|
||||
and all(cmd(r) == 0.0 for r in _h_rows))
|
||||
and cmd(_h_rows[-1]) > 0.0 and "limited by peak" in _h_rows[-1][6])
|
||||
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1,442 @@
|
||||
"""Runnable check for SAFETY-86: the peak guard outranks the maintenance HOLD.
|
||||
`python3 test_safety86.py`
|
||||
|
||||
During the HOLD the battery rests full at 0 W (P_MAINTENANCE, above the peak
|
||||
floor), so the house imported everything and quarters went over the peak
|
||||
limit. Now the holding battery gives what keeps the quarter at the aim: at N=1
|
||||
the peak floor is refiled at P_MAINTENANCE, at N>=2 the hold's pin is raised
|
||||
to what the other batteries cannot give. Drives the REAL Controller
|
||||
(tools/safety84_identity.py's harness). DECISIONS/SAFETY-86-peak-over-hold.md.
|
||||
|
||||
1. N=1, house over the aim: every quarter lands on the aim, the hold goes on,
|
||||
no 'Load too high'; without the fix the quarter goes over and it fires.
|
||||
2. Under the aim nothing changes, at N=1 and N=2 (rows identical).
|
||||
3. N=2, house over the aim: the other battery gives first, the holding one
|
||||
only the shortfall; with battery modes on and B Off too.
|
||||
4. A month peak banked above the limit: the aim is that peak - margin.
|
||||
5. A safety stop still parks everything, and the hold gives again after.
|
||||
6. The level dips below the target: the hold still ends on its clock.
|
||||
7. The rule at its boundaries, on claim lists.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
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
|
||||
from app import control as _ctl # noqa: E402
|
||||
from app import packmode # noqa: E402
|
||||
from app.arbiter import (Claim, P_MAINTENANCE, P_PEAK, P_SAFETY, # noqa
|
||||
resolve)
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.maintenance import (CHARGE, DRAIN, HOLD, IDLE, # noqa: E402
|
||||
MaintResult)
|
||||
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_hold(n, b_off=False):
|
||||
"""A store with battery A in its HOLD from the first cycle."""
|
||||
s = rig._Store()
|
||||
for k in ("phase", "phase_started"):
|
||||
s.data[k if n == 1 else unit_key(A, k)] = (
|
||||
"hold" if k == "phase" else rig.BASE.isoformat())
|
||||
if b_off:
|
||||
s.data[unit_key(B, packmode.USE_KEY)] = False
|
||||
return s
|
||||
|
||||
|
||||
def full(u, t):
|
||||
"""A rests full; B is half full."""
|
||||
return 99.0 if u == "a" else 60.0
|
||||
|
||||
|
||||
def run(house, n, *, hold_min=600, b_off=False, extra=None, **kw):
|
||||
kw.setdefault("soc", full)
|
||||
kw.setdefault("dur", 2700.0)
|
||||
return s84.run(n, load=house if callable(house) else lambda t: house,
|
||||
cheap=False, store=store_hold(n, b_off),
|
||||
extra=dict({"maintenance_hold_min": hold_min},
|
||||
**(extra or {})), **kw)
|
||||
|
||||
|
||||
def without_fix(fn):
|
||||
real = Controller._peak_over_hold
|
||||
Controller._peak_over_hold = lambda self, claims, results: None
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
Controller._peak_over_hold = 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 alarmed(c):
|
||||
"""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):
|
||||
return sum(1 for r in rows if r[7] and "REFUSED" in r[7])
|
||||
|
||||
|
||||
def peak_prio(r):
|
||||
return [c[1] for c in r[-1][0] if c[0] == "peak"]
|
||||
|
||||
|
||||
def hold_pin(r):
|
||||
return [c[3] for c in r[-1][0] if c[0] == "maintenance" and c[2] == "set"]
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. N=1: a house over the aim during the HOLD")
|
||||
N1 = {h: run(h, 1) for h in (5000.0, 6500.0, 7000.0)}
|
||||
N1_OLD = {h: without_fix(lambda: run(h, 1)) for h in N1}
|
||||
for h in N1:
|
||||
print(f" house {h:.0f}: banked {[round(v) for v in banked(N1[h][1])]} W"
|
||||
f" (before the fix {[round(v) for v in banked(N1_OLD[h][1])]} W), "
|
||||
f"last {N1[h][0][-1][6]}")
|
||||
check("⭐ every quarter lands on the aim (3450 W) for a 5000, 6500 and "
|
||||
"7000 W house, and 'Load too high' never fires",
|
||||
all(on_aim(c) and not alarmed(c) for _r, c in N1.values()))
|
||||
check("...the battery gives, raised for the peak limit, and stays in its "
|
||||
"HOLD: the phase is hold and the claim still says so",
|
||||
all(c.maints[0].phase == HOLD and cmd(rows[-1])["a"] > 1000.0
|
||||
and "limited by peak" in rows[-1][6]
|
||||
and c.why_words.endswith("raised for the peak limit")
|
||||
for rows, c in N1.values()))
|
||||
check("non-vacuous: without the fix the battery sits at 0 W, the quarter "
|
||||
"banks the whole house and 'Load too high' fires",
|
||||
all(all(cmd(r)["a"] == 0.0 for r in rows) and min(banked(c)) >= h - 1
|
||||
and alarmed(c) for h, (rows, c) in N1_OLD.items()))
|
||||
|
||||
|
||||
def hob(t):
|
||||
"""A 6000 W house with a 5 s spike to 11000 W every 150 s."""
|
||||
return 11000.0 if t % 150 < 5 else 6000.0
|
||||
|
||||
|
||||
_sp_rows, _sp = run(hob, 1)
|
||||
check("...and 5 s spikes that saturate the guard for a cycle do not fire it "
|
||||
"(SAFETY-81's 10 s hold)",
|
||||
any(r[-1][1] and r[-1][1][1] >= 5000.0 for r in _sp_rows)
|
||||
and not alarmed(_sp))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. under the aim the HOLD is as before")
|
||||
BELOW = {(n, h): (run(h, n, dur=1800.0),
|
||||
without_fix(lambda: run(h, n, dur=1800.0)))
|
||||
for n, h in ((1, 1200.0), (1, 3000.0), (1, -1500.0),
|
||||
(2, 1500.0), (2, 3000.0), (2, 5000.0))}
|
||||
check("⭐ N=1 at a 1200 W and 3000 W house and a 1500 W surplus, N=2 at "
|
||||
"1500, 3000 and 5000 W (B covers it): battery A is at 0 W on every "
|
||||
"cycle and every row equals the run without the fix",
|
||||
all(all(cmd(r)["a"] == 0.0 for r in new[0]) and new[0] == old[0]
|
||||
for new, old in BELOW.values()))
|
||||
check("...and the peak claim stays at P_PEAK on every cycle",
|
||||
all(p == P_PEAK for new, _o in BELOW.values() for r in new[0]
|
||||
for p in peak_prio(r)))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. N=2: the other battery gives first, the holding one the rest")
|
||||
N2 = {h: run(h, 2) for h in (6500.0, 7000.0)}
|
||||
N2_OLD = {h: without_fix(lambda: run(h, 2)) for h in N2}
|
||||
for h, (rows, c) in N2.items():
|
||||
print(f" house {h:.0f}: banked {[round(v) for v in banked(c)]} W "
|
||||
f"(before the fix {[round(v) for v in banked(N2_OLD[h][1])]} W), "
|
||||
f"last {rows[-1][2]}")
|
||||
check("⭐ house 6500 and 7000 W, B at most 2500 W: every quarter lands on "
|
||||
"the aim, nothing is refused or split both ways, no 'Load too high'",
|
||||
all(on_aim(c) and refused(rows) == 0 and not alarmed(c)
|
||||
and all(cmd(r)["a"] * cmd(r)["b"] >= 0.0 for r in rows)
|
||||
for rows, c in N2.values()))
|
||||
_gives = [r for rows, _c in N2.values() for r in rows if cmd(r)["a"] > 0.0]
|
||||
check("⭐ A gives only the shortfall: on every cycle A gives, B is at its "
|
||||
"2500 W and A's pin is the guard's floor minus 2500 W",
|
||||
len(_gives) > 500
|
||||
and all(cmd(r)["b"] == 2500.0 and hold_pin(r)
|
||||
and abs(hold_pin(r)[0] - (r[-1][1][1] - 2500.0)) < 1e-6
|
||||
for r in _gives))
|
||||
check("...A stays in its HOLD, and its last command is the shortfall "
|
||||
"(6500 - 3450 - 2500 = 550 W, give or take the meter's 1 W)",
|
||||
all(c.maints[0].phase == HOLD for _r, c in N2.values())
|
||||
and 540.0 <= cmd(N2[6500.0][0][-1])["a"] <= 560.0)
|
||||
check("non-vacuous: without the fix A sits at 0 W, the quarter goes over "
|
||||
"the limit and 'Load too high' fires",
|
||||
all(all(cmd(r)["a"] == 0.0 for r in rows) and max(banked(c)) > 3900.0
|
||||
and alarmed(c) for rows, c in N2_OLD.values()))
|
||||
|
||||
_off_rows, _off = run(6500.0, 2, b_off=True,
|
||||
extra={"battery_modes_enabled": True})
|
||||
print(f" B Off, house 6500: banked {[round(v) for v in banked(_off)]} W, "
|
||||
f"last {_off_rows[-1][2]}")
|
||||
check("⭐ battery modes on, B Off: the Off battery still gives before the "
|
||||
"holding one (maintenance outranks a mode), the quarter lands on the "
|
||||
"aim, nothing refused",
|
||||
on_aim(_off) and cmd(_off_rows[-1])["b"] == 2500.0
|
||||
and 540.0 <= cmd(_off_rows[-1])["a"] <= 560.0
|
||||
and refused(_off_rows) == 0 and not alarmed(_off))
|
||||
|
||||
|
||||
|
||||
def noisy(t):
|
||||
"""A 6500 W house that moves up to 150 W from one second to the next."""
|
||||
return 6500.0 + ((int(t) * 7919) % 301) - 150.0
|
||||
|
||||
|
||||
def no_ceiling(fn):
|
||||
"""Run with the pin raised but no `hold` site ceiling (review check)."""
|
||||
real = Controller._peak_over_hold
|
||||
|
||||
def pin_only(self, claims, results):
|
||||
real(self, claims, results)
|
||||
Controller._peak_over_hold = pin_only
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
Controller._peak_over_hold = real
|
||||
|
||||
|
||||
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)
|
||||
# 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 "
|
||||
f"{rows_logged(_nz_old, 'OUTPUT_CLAMP')})")
|
||||
check("⭐ while A gives, the site target is the guard's floor (the `hold` "
|
||||
"ceiling), so a noisy house does not fill the action log: no ALLOCATOR "
|
||||
"row and at most one clamp row",
|
||||
on_aim(_nz) and all(abs(r[1] - r[-1][1][1]) < 1e-6 for r in _nz_rows
|
||||
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 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")
|
||||
BANK = {n: (run(6500.0, n, month_peak=4000.0),
|
||||
without_fix(lambda: run(6500.0, n, month_peak=4000.0)))
|
||||
for n in (1, 2)}
|
||||
for n, (new, old) in BANK.items():
|
||||
print(f" N={n} house 6500, month peak 4000: banked "
|
||||
f"{[round(v) for v in banked(new[1])]} W (before the fix "
|
||||
f"{[round(v) for v in banked(old[1])]} W)")
|
||||
check("⭐ under a month peak of 4000 W the aim is 3950 W (peak - margin), "
|
||||
"at N=1 and N=2, and no 'Load too high'",
|
||||
all(on_aim(new[1], 3950.0) and not alarmed(new[1])
|
||||
for new, _old in BANK.values()))
|
||||
check("non-vacuous: before the fix N=1 banks the whole house and N=2 banks "
|
||||
"4000 W, over that aim",
|
||||
min(banked(BANK[1][1][1])) >= 6499.0
|
||||
and min(banked(BANK[2][1][1])) >= 3999.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. a safety stop still wins")
|
||||
|
||||
|
||||
def blind_at(t0, t1):
|
||||
"""A's power reading is missing from `t0` to `t1`: a site safety SET."""
|
||||
def before(c, t):
|
||||
s84.feed(False)(c, t)
|
||||
if not getattr(c.hass, "_blind", False):
|
||||
real = c.hass.number
|
||||
|
||||
async def number(e, invert=False):
|
||||
if e == "sensor.batt_a" and t0 <= c.hass.t < t1:
|
||||
return None
|
||||
return await real(e, invert)
|
||||
c.hass.number, c.hass._blind = number, True
|
||||
return before
|
||||
|
||||
|
||||
STOP = {n: run(6500.0, n, dur=900.0, before=blind_at(300.0, 600.0))
|
||||
for n in (1, 2)}
|
||||
for n, (rows, _c) in STOP.items():
|
||||
print(f" N={n} reading lost 300-600 s: at {rows[100][0]:.0f} s "
|
||||
f"{rows[100][2]}, {rows[100][6]}; after {rows[-1][2]}")
|
||||
check("⭐ with a reading gone the safety SET parks every battery at 0 W, at "
|
||||
"N=1 and N=2, with nothing refused",
|
||||
all(all(v == 0.0 for r in rows if 330.0 <= r[0] < 600.0
|
||||
for v in cmd(r).values())
|
||||
and any(r[6].startswith("safety -> 0 W") for r in rows)
|
||||
and refused(rows) == 0 for rows, _c in STOP.values()))
|
||||
check("...the hold gave before the stop and gives again after it",
|
||||
all(cmd(rows[50])["a"] > 0.0 and cmd(rows[-1])["a"] > 0.0
|
||||
for rows, _c in STOP.values()))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n6. the level dips below the hold's target")
|
||||
|
||||
|
||||
def draining(u, t):
|
||||
"""A's level falls 1 % a minute from 99 % (set, not integrated); B 60 %."""
|
||||
return max(20.0, 99.0 - t / 60.0) if u == "a" else 60.0
|
||||
|
||||
|
||||
DIP = {n: run(6500.0, n, hold_min=30, dur=2400.0, soc=draining)
|
||||
for n in (1, 2)}
|
||||
for n, (rows, c) in DIP.items():
|
||||
print(f" N={n}: phases {sorted({tuple(x[-1][3]) for x in rows})}, "
|
||||
f"last event {[e for e in c.events if 'maintenance' in e][:1]}")
|
||||
check("⭐ the level falls from 99 % to 70 % while the hold gives, and "
|
||||
"the hold still ends on its 30 min clock: HOLD -> IDLE, cycle "
|
||||
"COMPLETE, never back to CHARGE, at N=1 and N=2",
|
||||
all(c.maints[0].phase == IDLE
|
||||
and any("cycle COMPLETE" in e for e in c.events)
|
||||
and {x[-1][3][0] for x in rows} == {HOLD, IDLE}
|
||||
and all(x[-1][3][0] == HOLD for x in rows if x[0] < 1795.0)
|
||||
for rows, c in DIP.values()))
|
||||
check("...the hold gave on the way (the level really dipped)",
|
||||
all(any(cmd(r)["a"] > 0.0 for r in rows if r[0] < 1795.0)
|
||||
for rows, _c in DIP.values()))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n7. the rule at its boundaries")
|
||||
_c = run(1500.0, 2, dur=10.0)[1]
|
||||
_c1 = run(1500.0, 1, dur=10.0)[1]
|
||||
HOLDING = [MaintResult(HOLD, True, 0.0, False, []),
|
||||
MaintResult(IDLE, False, None, 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),
|
||||
_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),
|
||||
Claim.limit("peak", P_PEAK, lo=floor, reason="quarter peak"),
|
||||
*extra]
|
||||
ceiling = Controller._peak_over_hold(fake, cl, results)
|
||||
pin_after.ceiling = ceiling
|
||||
return next((x.value for x in cl
|
||||
if x.source == "maintenance" and x.kind == "set"), None), cl
|
||||
|
||||
|
||||
check("⭐ at N=2 a floor under or at B's 2500 W leaves A at 0 W (never "
|
||||
"charging); 0.5 W above it raises A's pin to 0.5 W",
|
||||
pin_after(1000.0)[0] == 0.0 and pin_after(2500.0)[0] == 0.0
|
||||
and pin_after(2500.5)[0] == 0.5)
|
||||
check("⭐ B not live, contradicting itself, or refusing to give: A gives the "
|
||||
"whole floor",
|
||||
pin_after(2000.0, live=(A,))[0] == 2000.0
|
||||
and pin_after(2000.0, Claim.limit("pack", P_SAFETY, 10.0, -10.0,
|
||||
actuator=B))[0] == 2000.0
|
||||
and pin_after(2000.0, Claim.limit("refused", P_SAFETY, hi=0.0,
|
||||
actuator=B))[0] == 2000.0)
|
||||
_probe = _ctl.PROBE_W
|
||||
check("⭐ B probing: giving counts its probe (A gives the rest), charging "
|
||||
"leaves A at 0 W so the site is never split both ways",
|
||||
pin_after(2000.0, Claim.set("refused", P_SAFETY, _probe, "probe",
|
||||
actuator=B))[0] == 2000.0 - _probe
|
||||
and pin_after(2000.0, Claim.set("refused", P_SAFETY, -_probe, "probe",
|
||||
actuator=B))[0] == 0.0)
|
||||
_p, _cl = pin_after(4000.0, Claim.set("safety", P_SAFETY, 0.0,
|
||||
"inputs missing"))
|
||||
check("⭐ a site safety SET: A stays at 0 W and the site resolves to 0 W; "
|
||||
"a unit safety SET on B does not stop A giving",
|
||||
_p == 0.0 and resolve(_cl, units=(A, B), live={A, B}).target_w == 0.0
|
||||
and pin_after(3000.0, Claim.set("failsafe", P_SAFETY, 0.0,
|
||||
actuator=B))[0] == 3000.0)
|
||||
_p, _cl = pin_after(2600.0)
|
||||
_hi = pin_after.ceiling
|
||||
_cl += [Claim.limit("hold", P_MAINTENANCE, hi=_hi, actuator="site"),
|
||||
Claim.set("loop", 10, 5000.0, "the loop asks for the whole house")]
|
||||
pin_after(2500.0)
|
||||
check("⭐ at N=2 the `hold` site ceiling is the floor itself, only when A "
|
||||
"gives, and a 5000 W loop ask resolves to that floor",
|
||||
_hi == 2600.0 and pin_after.ceiling is None
|
||||
and resolve(_cl, units=(A, B), live={A, B}).target_w == 2600.0)
|
||||
check("only a HOLD: a DRAIN pin or an idle pack is not raised",
|
||||
pin_after(4000.0, results=[MaintResult(DRAIN, True, 0.0, False, []),
|
||||
HOLDING[1]])[0] == 0.0
|
||||
and pin_after(4000.0, results=[HOLDING[1], HOLDING[1]])[0] == 0.0)
|
||||
|
||||
|
||||
def refile(lo, results=(HOLDING[0],)):
|
||||
fake = types.SimpleNamespace(inverters=_c1.inverters,
|
||||
actuator_of=_c1.actuator_of)
|
||||
cl = [Claim.set("maintenance", P_MAINTENANCE, 0.0, HOLD),
|
||||
Claim.limit("peak", P_PEAK, lo=lo, reason="quarter peak")]
|
||||
Controller._peak_over_hold(fake, cl, list(results))
|
||||
return cl
|
||||
|
||||
|
||||
check("⭐ at N=1 a floor of 0 W stays at P_PEAK, 0.5 W is refiled at "
|
||||
"P_MAINTENANCE, and a blind claim (a ceiling only) is not refiled",
|
||||
refile(0.0)[1].priority == P_PEAK
|
||||
and refile(0.5)[1].priority == P_MAINTENANCE
|
||||
and refile(float("-inf"))[1].priority == P_PEAK)
|
||||
_stop = refile(3000.0) + [Claim.set("safety", P_SAFETY, 0.0,
|
||||
"inputs missing")]
|
||||
check("⭐ at N=1 a refiled floor stays under a safety SET: 0 W",
|
||||
resolve(_stop).target_w == 0.0)
|
||||
check("at N=1 outside a HOLD (idle, or a CHARGE) the claim is not touched "
|
||||
"by this rule",
|
||||
refile(3000.0, (MaintResult(IDLE, False, None, False, []),))[1]
|
||||
.priority == P_PEAK
|
||||
and refile(3000.0, (MaintResult(CHARGE, False, None, True, []),))[1]
|
||||
.priority == P_PEAK)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all SAFETY-86 checks passed")
|
||||
@@ -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,422 @@
|
||||
"""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, 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
|
||||
|
||||
|
||||
ENDS = {
|
||||
"timeout": lambda: run(2, 6000.0, 9999.0, step=1760.0, dur=2080.0,
|
||||
store=timed_out(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)
|
||||
|
||||
# ============================================================================ #
|
||||
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,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