Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d28daf8a0 | ||
|
|
0b6b34f563 | ||
|
|
08fb1235fc | ||
|
|
7ef73474dc | ||
|
|
24dbbd18ad | ||
|
|
8dfe91e1d2 | ||
|
|
9d3fbe1be9 | ||
|
|
3ed3302d2f | ||
|
|
8c6e5bca2e | ||
|
|
f01152b113 | ||
|
|
3129daed62 | ||
|
|
64dddd64bf | ||
|
|
cf705a9058 | ||
|
|
7d06aa674d | ||
|
|
88ebf3a5a6 | ||
|
|
3db2b6392f | ||
|
|
80bfc5721e | ||
|
|
6918e154c8 | ||
|
|
dd5eb6fdc8 | ||
|
|
6759d21ea5 | ||
|
|
02ed1200dc | ||
|
|
f13dab1407 | ||
|
|
b307db1049 | ||
|
|
e51e3e02b2 | ||
|
|
69dc1f0e7b | ||
|
|
858f77cfb5 | ||
|
|
214051d1a8 | ||
|
|
05eb93f139 | ||
|
|
f3e409cf76 | ||
|
|
4cd604afcb | ||
|
|
6872353e2e | ||
|
|
8c438ab2e6 | ||
|
|
b37b7f95dc | ||
|
|
28cab03947 | ||
|
|
61072ca460 | ||
|
|
7b723a6cd5 | ||
|
|
4b81387908 | ||
|
|
935878193b | ||
|
|
b4d25dd485 | ||
|
|
a4a04d203c | ||
|
|
aa501ffec7 | ||
|
|
d89c093318 | ||
|
|
d0db5b8c39 | ||
|
|
b8ddfd787f | ||
|
|
1c41a1e412 | ||
|
|
ba31e7c8b1 | ||
|
|
513183aba3 | ||
|
|
4ad8298ead | ||
|
|
5c9f64d87a | ||
|
|
43a52e7f60 | ||
|
|
9d5b7d129a | ||
|
|
2f5ab1f378 | ||
|
|
fbdd552555 | ||
|
|
02c0a3dd7b |
+1
-1
@@ -1,2 +1,2 @@
|
||||
# The admin/goodwe commit CI tests against. Bump: set to the full SHA of admin/goodwe release/1.0 HEAD (README.md, "CI and the infra pin").
|
||||
d637d0b1b13f1b863dc47e445e2ca4cf4a82fe10
|
||||
3b4ff84757f9c2c5b63a4b4a53e593ccaeafb847
|
||||
|
||||
@@ -1,5 +1,168 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
@@ -40,4 +40,4 @@ direction. Opposite splits stay forbidden.
|
||||
- At N=2 a maintenance CHARGE in a cheap window has the same stall: the
|
||||
charging pack's `hi = -floor` against the loop asking the other to give is
|
||||
refused on 141 of 261 cycles. The mirror fix is a site `hi`, which can meet
|
||||
the peak floor's `lo` (0 W), so it is not this card.
|
||||
the peak floor's `lo` (0 W), so it is not this card. Fixed by [[SAFETY-84]].
|
||||
|
||||
@@ -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]].
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# SAFETY-84 - a maintenance charge at two inverters
|
||||
|
||||
**Status: DECIDED 2026-09-30.** Follows SAFETY-78's "Not changed" list.
|
||||
Tests: `test_safety84.py`; identity: `tools/safety84_identity.py`.
|
||||
|
||||
**The defect.** In a cheap window the charging pack files its own
|
||||
`hi = -charge` (P_MAINT_SHAPE). At N>=2 the loop's site target sat above it
|
||||
(a house to cover, or its 400 W slew step from 0 W), so the other pack had to
|
||||
give while this one charged. The allocator refuses opposite directions
|
||||
(SAFETY-31), so the site sat at 0 W whatever the house
|
||||
(`tools/safety84_identity.py` on 9d5b7d1: 436 of 480 cycles refused). N=1
|
||||
was fine.
|
||||
|
||||
**The fix.**
|
||||
- While a pack's own limits make it charge at N>=2, `main.py` files a site
|
||||
limit `charge`, `hi` = what that pack's own `hi` places
|
||||
(`allocator.hi_w()`, whole W as the allocator rounds). The site target is
|
||||
never above it, so the other pack only adds power in the same direction.
|
||||
Opposite splits stay forbidden. Mirror of SAFETY-78's `drain` floor.
|
||||
- Peak guard first (owner order, SAFETY-83). The charge is sized to the
|
||||
quarter's headroom under the aim (`charge_headroom_w()`, cap or banked peak
|
||||
minus `peak_margin_w`), so it charges less instead of stopping: the meter
|
||||
sits on the aim and the guard does not need to step in. Where the house
|
||||
alone is over the aim the guard engages, the shaping is suspended as before
|
||||
(`shaping_suspended()`) and no `charge` is filed, so the guard's `lo` and
|
||||
this `hi` never meet in one cycle (no `lo > hi`, no park at 0 W).
|
||||
Without the headroom sizing the aim still held (the suspension did it), but
|
||||
a battery gave power back on 285 of 540 cycles at a 3000 W house
|
||||
(test_safety84.py part 2).
|
||||
- A site stop (a safety SET above P_MAINT_SHAPE) sizes the charge to 0 W:
|
||||
the unit `hi` is a hard limit, so a charge under a 0 W stop needs the other
|
||||
pack to give. It was refused, with an ARBITER line, on most cycles of the
|
||||
stop (test_safety84.py part 4). The stop still parked the site at 0 W.
|
||||
- A pack that cannot take the charge (a refusal, a full pack, a stale link)
|
||||
files no ceiling; the other pack follows the loop.
|
||||
- Not filed at N=1: byte-identical (`tools/safety84_identity.py`, which hashes
|
||||
every claim, in and out of a cheap window and under a banked peak).
|
||||
|
||||
**Not changed.**
|
||||
- At N=1 the charge is not sized to the headroom: the guard still suspends it
|
||||
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 (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,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).
|
||||
+80
-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,6 +1688,18 @@ 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).
|
||||
|
||||
#### Capacity-tariff exposure of the grid-following loop
|
||||
|
||||
@@ -1735,10 +1769,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.
|
||||
@@ -1801,7 +1842,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**
|
||||
@@ -1946,6 +1989,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
|
||||
@@ -3115,7 +3167,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
|
||||
|
||||
@@ -3150,6 +3205,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.
|
||||
@@ -3216,8 +3275,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
|
||||
@@ -3245,7 +3305,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*.
|
||||
|
||||
@@ -3383,7 +3445,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. |
|
||||
@@ -3423,7 +3485,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**
|
||||
|
||||
@@ -354,6 +354,11 @@ def pin_w(b):
|
||||
return float(pv) if lo > hi else float(_clamp_int(round(float(pv)), lo, hi))
|
||||
|
||||
|
||||
def hi_w(b):
|
||||
"""A unit's upper hard limit as `plan()` narrows it to whole W (SAFETY-84)."""
|
||||
return float(_int_envelope(b)[1])
|
||||
|
||||
|
||||
def loop_bound(bounds, live=None):
|
||||
"""OPT-11. `(lo, hi)`: what the units can place when the LOOP decides.
|
||||
|
||||
|
||||
@@ -79,7 +79,9 @@ 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)
|
||||
# OPT-04. §10.1 priority 3 - price arbitrage. ⚠️ ITS POSITION IS THE SPEC, NOT A
|
||||
# preference: P_PEAK (50) is ABOVE it so clause 2 lets the capacity-tariff floor
|
||||
@@ -194,6 +196,16 @@ CLAIM_SCOPE = {
|
||||
# charge against the drain (circulation, refused, 0 W). A site `lo`,
|
||||
# never filed at N=1. DECISIONS/SAFETY-78-drain-at-two.md.
|
||||
"drain": SCOPE_SITE,
|
||||
# main.py: `Claim.limit("charge", P_MAINT_SHAPE, hi=...)`, SAFETY-84. The
|
||||
# mirror of `drain` for a cheap-window maintenance charge at N>=2: the site
|
||||
# target may not go above what the charging pack's own `hi` asks, or a
|
||||
# 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,
|
||||
@@ -623,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
|
||||
@@ -725,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:
|
||||
|
||||
+654
-62
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 "
|
||||
@@ -835,6 +853,8 @@ WHY_LIMIT = {
|
||||
"pack": "the battery level",
|
||||
"reserve": "the battery reserve",
|
||||
"maintenance": "battery maintenance",
|
||||
"charge": "battery maintenance",
|
||||
"hold": "battery maintenance",
|
||||
"refused": "a battery that refuses power",
|
||||
}
|
||||
|
||||
@@ -898,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,
|
||||
@@ -1012,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(
|
||||
@@ -1080,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)),
|
||||
@@ -1114,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.15.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: fourteen 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
|
||||
@@ -175,6 +175,13 @@ SITE_SOURCES = {
|
||||
"drain": "the site target may not go below a pinned maintenance "
|
||||
"drain, or a sibling would charge against it. Filed at N>1 "
|
||||
"only",
|
||||
# SAFETY-84.
|
||||
"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",
|
||||
@@ -206,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 FOURTEEN claim sources are classified, and only those fourteen",
|
||||
check("all SIXTEEN claim sources are classified, and only those sixteen",
|
||||
set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES))
|
||||
check("the seven 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))
|
||||
@@ -230,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 "
|
||||
"seven 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}})
|
||||
@@ -511,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,40 @@ 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-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 +149,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)",
|
||||
"refused on 392 of 540 cycles now runs (test_safety78.py), bounded by "
|
||||
"SAFETY-85",
|
||||
_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",
|
||||
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))
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
|
||||
@@ -64,9 +64,21 @@ _default = rig.digests()
|
||||
for k in ED739D4:
|
||||
print(f" {k:7} margin 0 {'same' if _zero[k] == ED739D4[k] else 'DIFFERS'}"
|
||||
f" {_zero[k][:16]}, default {_default[k][:16]}")
|
||||
# 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 = \
|
||||
"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", _zero == ED739D4)
|
||||
"byte-identical to ed739d4 (n2_maint apart, below)",
|
||||
{k: v for k, v in _zero.items() if k != "n2_maint"}
|
||||
== {k: v for k, v in ED739D4.items() if k != "n2_maint"})
|
||||
check("...and n2_maint at margin 0 is the SAFETY-84 digest: the N=2 charge "
|
||||
"that 9d5b7d1 refused on 214 of 540 cycles now runs (test_safety84.py)",
|
||||
_zero["n2_maint"] == SAFETY84_N2_MAINT != ED739D4["n2_maint"])
|
||||
check("...and the default (key absent) changes every scenario, so the "
|
||||
"scenarios do reach the margin", all(_default[k] != ED739D4[k]
|
||||
for k in ED739D4))
|
||||
@@ -163,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 "
|
||||
@@ -2687,9 +2694,16 @@ check("...and so does the persistent notification, which outlives the page and "
|
||||
# state of charge, it never closes discharge (a `lo`, not a `hi`), and it is
|
||||
# never filed at N=1, so the sweeps below still see {device, supervised,
|
||||
# loop}. DECISIONS/SAFETY-78-drain-at-two.md.
|
||||
# ⚠️ SAFETY-84 ADDED `charge`, ITS MIRROR, AND IT IS NOT THE RESERVE EITHER. A
|
||||
# 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"}
|
||||
"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.
|
||||
@@ -2731,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 FOURTEEN. 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 "
|
||||
@@ -2871,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 and SAFETY-78's additions: the seven 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; "
|
||||
@@ -2881,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"}
|
||||
"drain", "charge", "hold"}
|
||||
and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT}
|
||||
== {"device", "supervised", "failsafe", "maintenance", "pack",
|
||||
"refused", "mode"})
|
||||
@@ -2986,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})
|
||||
"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)
|
||||
@@ -3065,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 FIFTEEN 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) == 15
|
||||
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 "
|
||||
@@ -3088,7 +3103,7 @@ check("⚠️ AND EVERY PER-INVERTER APPEND NAMES THE INVERTER IT IS ABOUT. "
|
||||
"site-scoped sources and is the whole defect for a per-inverter one: a "
|
||||
"`device` limit built inside the roster loop and routed without `inv` "
|
||||
"files inverter B's rating on inverter A, where it binds the wrong "
|
||||
"share. Nine of the fifteen appends are SCOPE_UNIT - `device`, "
|
||||
"share. Nine of the sixteen appends are SCOPE_UNIT - `device`, "
|
||||
"`supervised`, `failsafe`, maintenance's two, OPT-09's `pack`, "
|
||||
"SAFETY-73's two `refused` and OPT-11's `mode` - and all nine pass one. "
|
||||
"SAFETY-30's `sign_site` is deliberately NOT one of them: it is filed on "
|
||||
@@ -3105,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 seventeen appends account for exactly the "
|
||||
"fourteen "
|
||||
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())
|
||||
@@ -3117,6 +3132,10 @@ check("...and between them the seventeen 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))
|
||||
@@ -3167,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"})
|
||||
"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
|
||||
@@ -4131,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 "
|
||||
@@ -4213,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 "
|
||||
@@ -4378,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")
|
||||
@@ -4573,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 "
|
||||
@@ -5719,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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
"""Runnable check for SAFETY-84: a maintenance charge at N=2 no longer stalls.
|
||||
`python3 test_safety84.py`
|
||||
|
||||
In a cheap window the charging battery's own `hi` makes it charge. At two
|
||||
inverters the loop's site target sat above it, the other battery had to give,
|
||||
the allocator refused the opposite directions and the site sat at 0 W. Now a
|
||||
site `hi` (`charge`, P_MAINT_SHAPE) keeps the site target at or below the
|
||||
charge, and the charge is sized to the quarter's headroom so it yields to the
|
||||
peak guard. Drives the REAL Controller (tools/safety84_identity.py's harness).
|
||||
DECISIONS/SAFETY-84-charge-at-two.md has the record.
|
||||
|
||||
1. N=2 in a cheap window, house below, at and above the charge rate and a
|
||||
solar surplus: the charge runs, no opposite split, no refusal, and it ends.
|
||||
2. The peak guard: the charge yields just enough to hold the aim (cap -
|
||||
margin, and a banked month peak - margin); over the aim the guard wins,
|
||||
the charging battery gives too, and nothing parks; no false 'Load too
|
||||
high'. Without P1 the charge is not sized, as at N=1.
|
||||
3. Outside a cheap window nothing changes.
|
||||
4. A safety stop parks both batteries without a refusal, and the charge
|
||||
resumes after it.
|
||||
5. One battery refusing, or dropped, and B Off with battery modes on.
|
||||
6. A trade outranked by the charge (P_ARBITRAGE < P_MAINT_SHAPE).
|
||||
7. A seeded sweep of the ceiling against the loop, trades, refusals, packs
|
||||
and modes.
|
||||
8. The N=1 digests equal release/1.0 9d5b7d1, and the N=2 ones are pinned.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import opt11p3_identity as p3 # noqa: E402
|
||||
import safety84_identity as s84 # noqa: E402
|
||||
from app import arbitrage, packmode # noqa: E402
|
||||
from app.arbiter import (Claim, P_ARBITRAGE, P_LOOP, P_MAINT_SHAPE, # noqa
|
||||
P_MODE, P_SAFETY, resolve)
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.maintenance import CHARGE, HOLD # 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)
|
||||
|
||||
|
||||
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 cmd(r):
|
||||
return dict(r[2])
|
||||
|
||||
|
||||
def refused(rows):
|
||||
return sum(1 for r in rows if r[7] and "REFUSED" in r[7])
|
||||
|
||||
|
||||
def opposite(rows):
|
||||
return sum(1 for r in rows if cmd(r)["a"] * cmd(r)["b"] < 0)
|
||||
|
||||
|
||||
def charge_claims(r):
|
||||
"""The site `charge` claims main.py handed resolve() on row `r`."""
|
||||
return [c for c in r[-1][0] if c[0] == "charge"]
|
||||
|
||||
|
||||
def banked(c):
|
||||
return [b.offtake_avg_w for b in c.p1.blocks]
|
||||
|
||||
|
||||
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 run(house, n=2, **kw):
|
||||
return s84.run(n, load=house if callable(house) else lambda t: house,
|
||||
**kw)
|
||||
|
||||
|
||||
def rise(u, t):
|
||||
"""A fills from 60 % (1 % a cycle); B stays at 50 %."""
|
||||
return min(100.0, 60.0 + t / 5.0) if u == "a" else 50.0
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. N=2 in a cheap window: the charge runs whatever the house uses")
|
||||
RUNS = {h: run(h, dur=900.0) for h in (800.0, 2000.0, 3000.0, -1500.0)}
|
||||
for h, (rows, c) in RUNS.items():
|
||||
print(f" house {h:+.0f} W: last A {cmd(rows[-1])['a']:+.0f} W, B "
|
||||
f"{cmd(rows[-1])['b']:+.0f} W; {rows[-1][6]}; refusals "
|
||||
f"{refused(rows)}")
|
||||
check("⭐ below, at and above the charge rate and under a solar surplus, "
|
||||
"battery A charges on EVERY cycle (it used to sit at 0 W from the "
|
||||
"first cycle)",
|
||||
all(cmd(r)["a"] < 0.0 for rows, _c in RUNS.values() for r in rows)
|
||||
and all(c.maints[0].phase == CHARGE for _r, c in RUNS.values()))
|
||||
check("⭐ no cycle splits the site both ways and the allocator refuses "
|
||||
"nothing: battery B never gives while A charges",
|
||||
all(opposite(rows) == 0 and refused(rows) == 0
|
||||
and all(cmd(r)["b"] <= 0.0 for r in rows)
|
||||
for rows, _c in RUNS.values()))
|
||||
check("...and nothing is logged per cycle: no ARBITER line in the action log",
|
||||
not any("ARBITER" in e for _r, c in RUNS.values() for e in c.events))
|
||||
check("with the quarter's headroom above the charge rate, A charges at the "
|
||||
"full 2000 W and the Why names the charge",
|
||||
all(cmd(r)["a"] == -2000.0 for h in (800.0, -1500.0)
|
||||
for r in RUNS[h][0])
|
||||
and "limited by charge" in RUNS[800.0][0][-1][6])
|
||||
print(f" Why: {RUNS[800.0][1].why_words}")
|
||||
check("...and the page's Why reads as at one battery: 'held back by battery "
|
||||
"maintenance'",
|
||||
RUNS[800.0][1].why_words.endswith("held back by battery maintenance"))
|
||||
|
||||
_end_rows, _end = run(800.0, dur=600.0, soc=rise)
|
||||
check("⭐ and the charge ENDS: A reaches its 99 % target and the cycle moves "
|
||||
"on to its hold, with no refusal on the way",
|
||||
_end.maints[0].phase == HOLD and refused(_end_rows) == 0
|
||||
and any("charge complete" in e for e in _end.events))
|
||||
|
||||
_real = Controller._charge_ceiling
|
||||
Controller._charge_ceiling = lambda self, claims: None
|
||||
try:
|
||||
_bite, _bc = run(800.0, dur=120.0)
|
||||
finally:
|
||||
Controller._charge_ceiling = _real
|
||||
print(f" without the ceiling: refusals {refused(_bite)}/{len(_bite)}, "
|
||||
f"last A {cmd(_bite[-1])['a']:+.0f} W")
|
||||
check("non-vacuous: with the ceiling taken out, the same fixture refuses "
|
||||
"every cycle and parks A at 0 W (the reported defect)",
|
||||
refused(_bite) == len(_bite) and cmd(_bite[-1])["a"] == 0.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. the peak guard outranks the charge")
|
||||
_q_rows, _q = run(3000.0, dur=2700.0)
|
||||
print(f" house 3000: banked {[round(v) for v in banked(_q)]} W, last A "
|
||||
f"{cmd(_q_rows[-1])['a']:+.0f} W")
|
||||
check("⭐ house 3000 W, aim 3450 W: the charge yields just enough - every "
|
||||
"banked quarter lands on the aim, not on house + 2000 W",
|
||||
len(banked(_q)) >= 2
|
||||
and all(AIM - TOL <= v <= AIM + TOL for v in banked(_q)))
|
||||
check("...and it yields by charging less, not by stopping: A charges and B "
|
||||
"is 0 W on every cycle, nothing is refused, no 'Load too high'",
|
||||
all(cmd(r)["a"] < 0.0 and cmd(r)["b"] == 0.0 for r in _q_rows)
|
||||
and refused(_q_rows) == 0 and not alarmed(_q))
|
||||
check("...and the peak guard never has to step in (the charge stays under "
|
||||
"what the quarter can afford)",
|
||||
not any(r[-1][1] and r[-1][1][0] for r in _q_rows))
|
||||
|
||||
_o_rows, _o = run(5000.0, dur=2700.0)
|
||||
_both = [r for r in _o_rows
|
||||
if charge_claims(r) and any(c[0] == "peak" for c in r[-1][0])]
|
||||
print(f" house 5000: banked {[round(v) for v in banked(_o)]} W, last "
|
||||
f"{cmd(_o_rows[-1])}, {_o_rows[-1][6]}")
|
||||
check("⭐ house 5000 W, over the aim alone: the peak guard wins - every "
|
||||
"banked quarter is at or under the aim, both batteries give in the "
|
||||
"same direction, nothing is refused and nothing parks at 0 W",
|
||||
all(v <= AIM + TOL for v in banked(_o)) and opposite(_o_rows) == 0
|
||||
and refused(_o_rows) == 0
|
||||
and sum(cmd(r)["a"] + cmd(r)["b"] > 0.0 for r in _o_rows) > 100)
|
||||
check("...and the guard's `lo` never meets the charge's `hi` in one cycle "
|
||||
"(the charge is suspended while the guard holds)", not _both)
|
||||
check("...with no false 'Load too high' alarm", not alarmed(_o))
|
||||
|
||||
_k_rows, _k = run(3000.0, dur=2700.0, month_peak=4000.0)
|
||||
print(f" month peak 4000 banked, house 3000: banked "
|
||||
f"{[round(v) for v in banked(_k)]} W")
|
||||
check("⭐ under a month peak banked at 4000 W the aim is 3950 W: the charge "
|
||||
"takes the extra headroom and every banked quarter lands on 3950 W",
|
||||
len(banked(_k)) >= 2
|
||||
and all(3950.0 - TOL <= v <= 3950.0 + TOL for v in banked(_k))
|
||||
and refused(_k_rows) == 0 and not alarmed(_k))
|
||||
|
||||
_real = Controller._charge_headroom
|
||||
Controller._charge_headroom = lambda self, floor, claims: floor
|
||||
try:
|
||||
_nh_rows, _nh = run(3000.0, dur=2700.0)
|
||||
finally:
|
||||
Controller._charge_headroom = _real
|
||||
_gave = sum(1 for r in _nh_rows if cmd(r)["a"] > 0.0 or cmd(r)["b"] > 0.0)
|
||||
print(f" without the headroom: cycles a battery gives {_gave}, banked "
|
||||
f"{[round(v) for v in banked(_nh)]} W")
|
||||
check("non-vacuous: charging at the full rate instead, the guard has to "
|
||||
"step in and the batteries give back on many cycles",
|
||||
_gave > 50)
|
||||
|
||||
|
||||
def peak_run(house):
|
||||
rows, c = run(house, dur=1800.0)
|
||||
return rows, c, [r for r in rows if r[-1][1] and r[-1][1][0]]
|
||||
|
||||
|
||||
# A 6500 W house needs 3050 W off the meter: more than B's 2500 W alone.
|
||||
_p_rows, _p, _p_eng = peak_run(6500.0)
|
||||
print(f" house 6500: banked {[round(v) for v in banked(_p)]} W, A gives "
|
||||
f"on {sum(cmd(r)['a'] > 0.0 for r in _p_eng)} of {len(_p_eng)} "
|
||||
f"engaged cycles")
|
||||
check("⭐ house 6500 W, more than B alone can cover: the charge is suspended "
|
||||
"and A, the battery being filled, gives too; the quarter lands on the "
|
||||
"aim, nothing is refused, no 'Load too high'",
|
||||
len(_p_eng) > 100 and all(cmd(r)["a"] > 0.0 for r in _p_eng)
|
||||
and all(v <= AIM + TOL for v in banked(_p))
|
||||
and refused(_p_rows) == 0 and not alarmed(_p))
|
||||
_real = s84.m.peakguard.shaping_suspended
|
||||
s84.m.peakguard.shaping_suspended = lambda *a: False
|
||||
try:
|
||||
_ns_rows, _ns, _ns_eng = peak_run(6500.0)
|
||||
finally:
|
||||
s84.m.peakguard.shaping_suspended = _real
|
||||
check("non-vacuous: with the suspension taken out, A is held at 0 W, the "
|
||||
"quarter goes over the cap and 'Load too high' fires",
|
||||
not any(cmd(r)["a"] > 0.0 for r in _ns_eng)
|
||||
and max(banked(_ns)) > 3500.0 and alarmed(_ns))
|
||||
|
||||
|
||||
def prices_only(c, t):
|
||||
"""A cheap price and no P1 telegram (`meter_source: off`)."""
|
||||
if not getattr(c.hass, "_px", False):
|
||||
read, px = c.hass.number, {"sensor.pnow": 0.05, "sensor.pavg": 0.20}
|
||||
|
||||
async def number(e, invert=False):
|
||||
return px[e] if e in px else await read(e, invert)
|
||||
c.hass.number, c.hass._px = number, True
|
||||
|
||||
|
||||
_p1 = s84.s79.P1
|
||||
s84.s79.P1 = {"meter_source": "off", "peak_cap_w": 3500}
|
||||
try:
|
||||
_off_rows, _off = run(800.0, dur=600.0, before=prices_only)
|
||||
finally:
|
||||
s84.s79.P1 = _p1
|
||||
check("⭐ with no P1 meter nothing measures the quarter and the charge is not "
|
||||
"sized, as at N=1: A charges at 2000 W on every cycle, nothing refused",
|
||||
_off.peak_guard is None and all(cmd(r)["a"] == -2000.0 for r in _off_rows)
|
||||
and refused(_off_rows) == 0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. outside a cheap window nothing changes")
|
||||
_d = {h: run(h, cheap=False, dur=600.0) for h in (800.0, -1500.0)}
|
||||
check("⭐ no `charge` claim is filed outside a cheap window: B covers the "
|
||||
"house, the charge takes solar only, nothing is refused",
|
||||
not any(charge_claims(r) for rows, _c in _d.values() for r in rows)
|
||||
and cmd(_d[800.0][0][-1]) == {"a": 0.0, "b": 800.0}
|
||||
and cmd(_d[-1500.0][0][-1])["a"] < 0.0
|
||||
and all(refused(rows) == 0 for rows, _c in _d.values()))
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. a safety stop outranks the charge")
|
||||
|
||||
|
||||
def blind_at(t0, t1):
|
||||
"""A's power reading is missing from `t0` to `t1`: a safety SET."""
|
||||
def before(c, t):
|
||||
s84.feed(True)(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
|
||||
|
||||
|
||||
_s_rows, _s = run(800.0, dur=600.0, before=blind_at(150.0, 400.0))
|
||||
_stop = [r for r in _s_rows if 175.0 <= r[0] < 400.0]
|
||||
_last = _s_rows[79] # t = 395 s, still without the reading
|
||||
print(f" A's reading lost 150-400 s: {_last[2]}, {_last[6]}; after: "
|
||||
f"{_s_rows[-1][2]}")
|
||||
check("⭐ with a reading gone the safety SET parks both batteries at 0 W "
|
||||
"with no refusal and no ARBITER line (it used to refuse every cycle)",
|
||||
all(cmd(r) == {"a": 0.0, "b": 0.0} for r in _stop)
|
||||
and _last[0] == 395.0 and _last[6].startswith("safety -> 0 W")
|
||||
and refused(_s_rows) == 0 and not any("ARBITER" in e for e in _s.events))
|
||||
check("...and the charge ran before and runs again after",
|
||||
cmd(_s_rows[20])["a"] == -2000.0 and cmd(_s_rows[-1])["a"] == -2000.0)
|
||||
|
||||
|
||||
_full = _s._charge_headroom(2000.0, [])
|
||||
check("the stop test reads only SITE SETs: a unit's own safety SET (on B) "
|
||||
"or a site limit does not cut A's charge, a site safety SET does",
|
||||
_s._charge_headroom(2000.0, [Claim.set("failsafe", P_SAFETY, 0.0,
|
||||
actuator=B)]) == _full > 0.0
|
||||
and _s._charge_headroom(2000.0, [Claim.limit("peak", P_SAFETY,
|
||||
lo=0.0)]) == _full
|
||||
and _s._charge_headroom(2000.0, [Claim.set("safety", P_SAFETY,
|
||||
0.0)]) == 0.0)
|
||||
|
||||
|
||||
def stop_at(t0):
|
||||
def before(c, t):
|
||||
s84.feed(True)(c, t)
|
||||
if t >= t0:
|
||||
c.auto = False
|
||||
return before
|
||||
|
||||
|
||||
_st_rows, _st = run(800.0, dur=400.0, before=stop_at(200.0))
|
||||
check("control switched off mid-charge: both batteries at 0 W, no refusal",
|
||||
cmd(_st_rows[-1]) == {"a": 0.0, "b": 0.0} and refused(_st_rows) == 0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. one battery refusing, dropped, or Off")
|
||||
|
||||
|
||||
def a_takes_nothing(c, t):
|
||||
"""A reads 0 W whenever it is asked to charge (a battery that refuses)."""
|
||||
s84.feed(True)(c, t)
|
||||
if not getattr(c.hass, "_rt", False):
|
||||
real = c.hass.power
|
||||
c.hass.power = lambda u: (0.0 if u == "a" and c.hass.cmd["a"] < 0.0
|
||||
else real(u))
|
||||
c.hass._rt = True
|
||||
|
||||
|
||||
_r_rows, _r = run(800.0, dur=900.0, before=a_takes_nothing)
|
||||
print(f" A refuses to charge: {_r_rows[-1][2]}, {_r_rows[-1][6]}")
|
||||
check("⭐ A refuses to charge (SAFETY-73): no ceiling is filed for it, B "
|
||||
"covers the 800 W house, and no cycle is split both ways or refused",
|
||||
_r.inverters[0].refused == -1 and cmd(_r_rows[-1]) == {"a": 0.0,
|
||||
"b": 800.0}
|
||||
and not charge_claims(_r_rows[-1])
|
||||
and opposite(_r_rows) == 0 and refused(_r_rows) == 0)
|
||||
|
||||
_g_rows, _g = run(3000.0, dur=600.0, cut=lambda u, t: u == "b")
|
||||
check("B refuses to give: A still charges and nothing is refused",
|
||||
all(cmd(r)["a"] < 0.0 for r in _g_rows) and refused(_g_rows) == 0)
|
||||
|
||||
|
||||
def reject_a(c, t):
|
||||
"""Every write to A is rejected, so A's link goes stale (not live)."""
|
||||
s84.feed(True)(c, t)
|
||||
if not getattr(c.hass, "_rej", False):
|
||||
real = c.hass.set_number
|
||||
|
||||
async def set_number(e, v):
|
||||
return False if e == "number.sp_a" else await real(e, v)
|
||||
c.hass.set_number, c.hass._rej = set_number, True
|
||||
|
||||
|
||||
_l_rows, _l = run(1500.0, dur=400.0, before=reject_a)
|
||||
print(f" A's writes rejected: {_l_rows[-1][2]}, {_l_rows[-1][7][-40:]}")
|
||||
check("⭐ a charging battery that is dropped (stale link) adds no ceiling: "
|
||||
"B covers the 1500 W house instead of charging for A",
|
||||
"dropped inverter-a" in _l_rows[-1][7]
|
||||
and 1490.0 <= cmd(_l_rows[-1])["b"] <= 1530.0
|
||||
and not charge_claims(_l_rows[-1]))
|
||||
|
||||
|
||||
def modes_store(b_mode=None, b_off=False):
|
||||
s = rig._Store()
|
||||
for k in ("phase", "phase_started"):
|
||||
s.data[unit_key(A, k)] = ("charge" if k == "phase"
|
||||
else rig.BASE.isoformat())
|
||||
if b_mode:
|
||||
s.data[unit_key(B, packmode.MODE_KEY)] = b_mode
|
||||
if b_off:
|
||||
s.data[unit_key(B, packmode.USE_KEY)] = False
|
||||
return s
|
||||
|
||||
|
||||
_m = {h: run(h, dur=600.0, extra={"battery_modes_enabled": True},
|
||||
store=modes_store(b_off=True)) for h in (800.0, -3000.0)}
|
||||
print(f" B Off: {[(h, cmd(rows[-1])) for h, (rows, _c) in _m.items()]}")
|
||||
check("⭐ battery modes on, B Off: A charges at 2000 W over an 800 W house "
|
||||
"and takes 2500 W of a 3000 W surplus, B stays at 0 W, nothing is "
|
||||
"refused",
|
||||
cmd(_m[800.0][0][-1]) == {"a": -2000.0, "b": 0.0}
|
||||
and cmd(_m[-3000.0][0][-1]) == {"a": -2500.0, "b": 0.0}
|
||||
and all(refused(rows) == 0 and opposite(rows) == 0
|
||||
for rows, _c in _m.values()))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n6. a trade does not outrank the charge")
|
||||
|
||||
|
||||
def sell_feed(c, t):
|
||||
p3._feed(c, t)
|
||||
s84.feed(True)(c, t)
|
||||
|
||||
|
||||
_trades = []
|
||||
_t_rows, _t = run(800.0, dur=5400.0, before=sell_feed,
|
||||
extra=dict(p3.ARB),
|
||||
soc=lambda u, t: 60.0)
|
||||
print(f" modes off, a sell at 75-90 min: last {_t_rows[-1][2]}")
|
||||
_sells = [r for r in _t_rows if any(c[0] == "arbitrage" and c[3] > 0.0
|
||||
for c in r[-1][0])]
|
||||
check("⭐ battery modes off: the site trade asks to SELL while A charges; "
|
||||
"the charge (P_MAINT_SHAPE) outranks it, so A keeps charging and "
|
||||
"nothing is refused",
|
||||
len(_sells) > 50
|
||||
and all(cmd(r)["a"] < 0.0 and cmd(r)["b"] <= 0.0 for r in _sells)
|
||||
and refused(_t_rows) == 0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n7. a seeded sweep of the ceiling")
|
||||
_c = run(800.0, dur=30.0)[1]
|
||||
_rnd = random.Random(84)
|
||||
_bad = []
|
||||
for _i in range(20000):
|
||||
ra, rb = _rnd.choice((2000.0, 2500.0)), _rnd.choice((1000.0, 2500.0))
|
||||
f = _rnd.uniform(1.0, ra)
|
||||
cl = [Claim.limit("supervised", P_SAFETY, -ra, ra, actuator=A),
|
||||
Claim.limit("supervised", P_SAFETY, -rb, rb, actuator=B),
|
||||
Claim.limit("maintenance", P_MAINT_SHAPE, hi=-f, actuator=A)]
|
||||
x = _rnd.random()
|
||||
if x < 0.1:
|
||||
cl.append(Claim.limit("refused", P_SAFETY, 0.0, actuator=A))
|
||||
elif x < 0.2:
|
||||
cl.append(Claim.limit("pack", P_SAFETY, 0.0, actuator=A))
|
||||
y = _rnd.random()
|
||||
if y < 0.15:
|
||||
cl.append(Claim.limit("refused", P_SAFETY, *_rnd.choice(
|
||||
((0.0, float("inf")), (float("-inf"), 0.0))), actuator=B))
|
||||
elif y < 0.3:
|
||||
cl.append(Claim.limit("mode", P_MODE, *_rnd.choice(
|
||||
((0.0, 0.0), (0.0, rb), (-rb, 0.0))), actuator=B))
|
||||
cl.append(Claim.set("loop", P_LOOP, _rnd.uniform(-6000, 6000)))
|
||||
if _rnd.random() < 0.4:
|
||||
cl.append(Claim.set("arbitrage", P_ARBITRAGE,
|
||||
_rnd.uniform(-5000, 5000)))
|
||||
ceil = _c._charge_ceiling(cl)
|
||||
if ceil is not None:
|
||||
cl.append(Claim.limit("charge", P_MAINT_SHAPE, hi=ceil))
|
||||
res = resolve(cl, units=(A, B), live={A, B})
|
||||
sh = res.shares or {}
|
||||
why = []
|
||||
if not res.allocated or res.contradiction:
|
||||
why.append("refused or contradicted")
|
||||
if sh.get(A, 0.0) * sh.get(B, 0.0) < 0.0:
|
||||
why.append("opposite")
|
||||
a_free = x >= 0.2
|
||||
if a_free and (ceil is None or sh.get(A, 0.0) > -f + 1.0):
|
||||
why.append("A does not charge")
|
||||
if not a_free and ceil is not None:
|
||||
why.append("ceiling filed for a pack that cannot charge")
|
||||
if why:
|
||||
_bad.append((_i, why))
|
||||
print(f" 20000 claim lists, bad {len(_bad)}: {_bad[:3]}")
|
||||
check("⭐ 20000 random N=2 claim lists (a charge on A, refusals, a full "
|
||||
"pack, B's mode, the loop and trades in both directions): never "
|
||||
"refused, never both ways, A always charges at least its rate, and "
|
||||
"no ceiling for a pack that cannot charge", not _bad)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n8. the N=1 and N=2 digests")
|
||||
# Printed by `python3 tools/safety84_identity.py` in goodwe-addon-checks:1,
|
||||
# 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",
|
||||
"n1_charge_dear":
|
||||
"c25340b56060d4581f7204e200b07e7c8618711be2baca3b1667b7ebeb600dc3",
|
||||
"n1_charge_banked":
|
||||
"2619acdd06b04e56c000b168d64e1d044346c6ba3f929c94258d6ad8189ec609",
|
||||
"n2_charge":
|
||||
"b16d692128f14bcce3d87b142d290e0d6b13724289680e44a05215c9eaa24a7c",
|
||||
"n2_charge_dear":
|
||||
"00a197d89a5dd1e8b3ae6a5d1af66329a4dc4850476569c5931c125fa4d9eaee",
|
||||
"n2_charge_banked":
|
||||
"a6973faea916e4c88ca1770047f94478346eeb1efa4889b154fad0747f91267f",
|
||||
}
|
||||
_now = s84.digests()
|
||||
for k in PINNED:
|
||||
print(f" {k} {'same' if _now[k] == PINNED[k] else 'DIFFERS'} "
|
||||
f"{_now[k][:16]}")
|
||||
check("⭐ at N=1 a charge in and out of a cheap window and under a banked "
|
||||
"month peak is byte-identical to 9d5b7d1 (commands, reasons and every "
|
||||
"claim), and N=2 outside a cheap window matches its SAFETY-85 pin",
|
||||
all(_now[k] == PINNED[k] for k in ("n1_charge", "n1_charge_dear",
|
||||
"n1_charge_banked",
|
||||
"n2_charge_dear")))
|
||||
check("...and the N=2 cheap-window digests match the pins",
|
||||
_now["n2_charge"] == PINNED["n2_charge"]
|
||||
and _now["n2_charge_banked"] == PINNED["n2_charge_banked"])
|
||||
_n1 = s84.run(1, dur=600.0)[0]
|
||||
check("...and no N=1 cycle files a `charge` claim",
|
||||
not any(charge_claims(r) for r in _n1))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all SAFETY-84 checks passed")
|
||||
@@ -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,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,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SAFETY-84: at one inverter, is a maintenance charge byte-identical to before?
|
||||
|
||||
python3 tools/safety84_identity.py # prints one digest per scenario
|
||||
|
||||
safety78_identity's harness (the REAL Controller on a P1 meter, every claim
|
||||
hashed) with the battery in its maintenance CHARGE phase from the first cycle,
|
||||
on the owner's settings (limit 3500, margin 50, charge 2000 W). The house steps
|
||||
below the charge rate, at it, under the aim, over it and into a solar surplus,
|
||||
in a cheap window, outside one, and under a month peak banked at 4000 W. Uses
|
||||
only the API release/1.0 9d5b7d1 has, so the same file runs on both trees;
|
||||
test_safety84.py holds this tree to the digests printed there (N=1) and to the
|
||||
new N=2 ones.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import safety79_identity as s79 # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import tariff # noqa: E402
|
||||
|
||||
DUR = 2400.0
|
||||
OPT = {"peak_cap_w": 3500, "peak_margin_w": 50, "maintenance_enabled": True,
|
||||
"maintenance_start_hour": 11, "maintenance_charge_w": 2000,
|
||||
"price_now_entity": "sensor.pnow", "price_avg_entity": "sensor.pavg"}
|
||||
|
||||
|
||||
def load(t):
|
||||
"""Below the 2000 W charge, at it, under the aim, over it, a surplus."""
|
||||
return (800.0, 2000.0, 3000.0, 5000.0, -1500.0)[min(int(t // 480), 4)]
|
||||
|
||||
|
||||
def feed(cheap):
|
||||
"""s79's P1 feed plus a price now under (cheap) or over the average."""
|
||||
now = 0.05 if cheap else 0.30
|
||||
|
||||
def before(c, t):
|
||||
if not getattr(c.hass, "_s84", False):
|
||||
read = c.hass.number
|
||||
|
||||
async def number(e, invert=False):
|
||||
if e == "sensor.pnow":
|
||||
return now
|
||||
if e == "sensor.pavg":
|
||||
return 0.20
|
||||
return await read(e, invert)
|
||||
c.hass.number, c.hass._s84 = number, True
|
||||
s79._feed(c, t)
|
||||
return before
|
||||
|
||||
|
||||
SCENARIOS = {
|
||||
"n1_charge": dict(n=1),
|
||||
"n1_charge_dear": dict(n=1, cheap=False),
|
||||
"n1_charge_banked": dict(n=1, month_peak=4000.0),
|
||||
"n2_charge": dict(n=2),
|
||||
"n2_charge_dear": dict(n=2, cheap=False),
|
||||
"n2_charge_banked": dict(n=2, month_peak=4000.0),
|
||||
}
|
||||
|
||||
|
||||
def run(n, *, cheap=True, month_peak=None, dur=DUR, load=load, soc=None,
|
||||
before=None, cut=None, extra=None, store=None):
|
||||
"""Rows (opt11_identity's fields plus every claim, the guard and the
|
||||
phases) and the controller. `month_peak` banks that peak this month."""
|
||||
seen, extra_rows = [], []
|
||||
real_resolve = m.resolve
|
||||
|
||||
def spy(claims, **kw):
|
||||
seen.append(tuple((c.source, c.priority, c.kind, c.value, c.lo, c.hi,
|
||||
c.reason, c.actuator) for c in claims))
|
||||
return real_resolve(claims, **kw)
|
||||
|
||||
def on_cycle(c, t):
|
||||
g = c.peak_guard
|
||||
extra_rows.append((
|
||||
seen[-1] if seen else None,
|
||||
None if g is None else (g.engaged, g.floor_w, g.reason),
|
||||
c._maint_shape_suspended, [mm.phase for mm in c.maints],
|
||||
len(c.events), c.events[0] if c.events else None))
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="s84-"), "tariff.db")
|
||||
if month_peak is not None:
|
||||
tariff.PeakStore(db)._db.execute(
|
||||
"INSERT INTO monthly_peak VALUES (?, ?, ?, NULL)",
|
||||
(tariff.month_key(rig.BASE), month_peak,
|
||||
"2026-09-02T10:00:00+00:00")).connection.commit()
|
||||
real = rig.Controller
|
||||
rig.Controller = functools.partial(real, peak_db_path=db)
|
||||
m.resolve = spy
|
||||
try:
|
||||
rows, c = rig.run(n, extra=dict(s79.P1, **OPT, **(extra or {})),
|
||||
dur=dur, load=load, soc=soc or s79.soc,
|
||||
cut=cut or (lambda u, t: False),
|
||||
before=before or feed(cheap), on_cycle=on_cycle,
|
||||
store=store or s79.store_charging(n))
|
||||
finally:
|
||||
rig.Controller = real
|
||||
m.resolve = real_resolve
|
||||
return [r + (x,) for r, x in zip(rows, extra_rows)], c
|
||||
|
||||
|
||||
def digest(rows) -> str:
|
||||
return hashlib.sha256(repr(rows).encode()).hexdigest()
|
||||
|
||||
|
||||
def digests() -> dict:
|
||||
return {k: digest(run(**v)[0]) for k, v in SCENARIOS.items()}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for k, v in digests().items():
|
||||
print(f"{k} {v}")
|
||||
@@ -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