Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
956711b801 | ||
|
|
88b9cd470a | ||
|
|
1bf6b7f9a7 | ||
|
|
1d28daf8a0 | ||
|
|
0b6b34f563 | ||
|
|
08fb1235fc | ||
|
|
7ef73474dc | ||
|
|
24dbbd18ad | ||
|
|
8dfe91e1d2 | ||
|
|
9d3fbe1be9 | ||
|
|
3ed3302d2f | ||
|
|
8c6e5bca2e | ||
|
|
f01152b113 | ||
|
|
3129daed62 | ||
|
|
64dddd64bf | ||
|
|
cf705a9058 | ||
|
|
7d06aa674d | ||
|
|
88ebf3a5a6 | ||
|
|
3db2b6392f | ||
|
|
80bfc5721e | ||
|
|
6918e154c8 | ||
|
|
dd5eb6fdc8 | ||
|
|
6759d21ea5 | ||
|
|
02ed1200dc | ||
|
|
f13dab1407 | ||
|
|
b307db1049 | ||
|
|
e51e3e02b2 | ||
|
|
69dc1f0e7b | ||
|
|
858f77cfb5 | ||
|
|
214051d1a8 | ||
|
|
05eb93f139 | ||
|
|
f3e409cf76 | ||
|
|
4cd604afcb | ||
|
|
6872353e2e | ||
|
|
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,78 @@
|
||||
# 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,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# MAINT-10 — the maintenance start hour is local time, default 19:00
|
||||
|
||||
Owner decision 2026-09-29 (option A). `maintenance_start_hour` was compared
|
||||
with the UTC hour, so the shipped 10 started at 12:00 in a Belgian summer and
|
||||
11:00 in winter. It is now read on the site's local clock, and the default moves
|
||||
to 19 so the drain runs into the evening and night rather than the solar day.
|
||||
|
||||
Evening drains meet higher house loads; SAFETY-83 (merged first) lets the
|
||||
peak guard outrank the drain.
|
||||
|
||||
## 1. Which zone
|
||||
|
||||
Home Assistant's `time_zone` (`GET /core/api/config`, 3 s budget) at startup,
|
||||
else the container's `TZ`, else UTC. One log line says which (WARNING only for
|
||||
UTC). Resolved once per start: a zone change in HA takes effect on the next
|
||||
add-on restart. HA can still be starting when the add-on starts (502 from the
|
||||
proxy, 2026-10-05); then `TZ` holds. HA pushes its zone to the Supervisor
|
||||
(hassio `push_config`), which stores it and sets it as the add-on's `TZ`
|
||||
(`docker/app.py`, Supervisor 2026.08.0), so the hour stays right. Only a `TZ` of
|
||||
UTC there logs a WARNING asking for a restart. No later re-resolve: the fallback
|
||||
already is HA's zone, so the rare UTC case gets a warning rather than more code.
|
||||
The add-on image has tzdata (checked in `amd64-base:3.20`); the CI image does
|
||||
not, so the tests grade DST on `tariff._EUTimezone` too.
|
||||
|
||||
## 2. DST
|
||||
|
||||
`maintenance.in_start_slot()` works from UTC instants only, so it does not
|
||||
depend on how a tzinfo resolves a missing or doubled wall time:
|
||||
|
||||
- normally the slot is the local hour `start_hour`;
|
||||
- clocks forward and the hour does not exist: the first hour after the jump;
|
||||
- clocks back and the hour happens twice: the first pass only. Without this an
|
||||
aborted start (90 min cooldown) would start again in the 120 min repeated hour.
|
||||
|
||||
Every day has exactly one 60 min slot for every hour 0-23: checked against
|
||||
zoneinfo for Europe/Brussels, America/New_York, America/Santiago (midnight
|
||||
transitions), Asia/Kathmandu (+05:45) and Australia/Lord_Howe (30 min DST)
|
||||
over all of 2026.
|
||||
|
||||
## 3. Existing installs
|
||||
|
||||
The Supervisor merges the shipped defaults under the STORED options, and saving
|
||||
the settings stores every key (the reference install's blob has all 76). So
|
||||
almost every existing install keeps its stored 10, which now means 10:00 local
|
||||
instead of 12:00 (summer) or 11:00 (winter). Only a blob without the key gets 19.
|
||||
The add-on does not rewrite a stored option: a silent migration would override
|
||||
an operator's choice with no way to tell it apart from the old default. A site
|
||||
that set an earlier hour to make up for UTC (17 for 19:00 summer) must set it
|
||||
back to 19 after the upgrade, or it starts two hours early. DOCS and the release
|
||||
note (drafted on the PR, for the version bump) say this.
|
||||
`tools/supervisor_shape_probe.py` §I measures this on Supervisor 2026.08.0.
|
||||
|
||||
## 4. Two inverters
|
||||
|
||||
`Controller` hands the same zone to every pack's `Maintenance`, so every pack's
|
||||
`wants_start()` reads the local hour. `arbitrate()` is unchanged: one pack
|
||||
cycles at a time, and the one that lost the slot starts in the first start
|
||||
slot after the other is done (a later day: a cycle outlasts its start hour). On the day the clocks go back, a pack freed during the second pass of
|
||||
a repeated hour does not start there. `test_maint10.py` part 10.
|
||||
|
||||
## 5. What does not change
|
||||
|
||||
Only the start decision (`Maintenance.wants_start`) and the Home screen's shown
|
||||
start time read the zone. `test_maint10.py` parts 9 and 10 run the real
|
||||
Controller at N=1 and N=2 with start hour 11 UTC against 13 Europe/Brussels (the
|
||||
same instant): every command, claim, reason and log line is identical. A
|
||||
`Maintenance` built without a zone reads UTC, as before.
|
||||
@@ -0,0 +1,81 @@
|
||||
# SAFETY-90 - the peak guard goes on after a P1 meter gap
|
||||
|
||||
**Status: DEV 2026-10-05, for review.** Tests: `test_safety90.py`.
|
||||
|
||||
**The defect.** A meter gap longer than `meter_max_age_s` leaves seconds the
|
||||
quarter average does not count (`p1.QuarterAverager`, on purpose: a guess must
|
||||
not reach the bill record). `peakguard.blindness()` then called the projection
|
||||
blind for the rest of the quarter, so the floor was 0 W. During a HOLD the
|
||||
battery gave nothing and the quarter went over: SAFETY-86 rig case 7b, N=2,
|
||||
3964.8 W against a 3450 W aim, the month peak raised; N=1, 3701 W (review).
|
||||
The page still read "Keeping the grid near zero". At start-up mid-quarter the
|
||||
same rule left the start-up quarter blind, and a held battery was drawn as last
|
||||
resort (SAFETY-91 test, O2).
|
||||
|
||||
**The fix** (`peakguard.fill_gaps()`, `peakguard.gap_w()`,
|
||||
`Controller._fill_peak_gaps()`).
|
||||
- Once readings are back, every second of a silence longer than
|
||||
`meter_max_age_s` counts at `gap_w = max(the limit, the import just before
|
||||
the gap, the import just after it)`, where "import" is the grid plus the
|
||||
batteries' discharge, i.e. with the batteries at 0 W. The gap's first
|
||||
`meter_max_age_s`, which the averager credits at the last reading, is topped
|
||||
up to `gap_w` too. The guard then grades the quarter as usual, so it is not
|
||||
blind and gives what keeps the quarter at the aim.
|
||||
- **The bound.** The count is at or above the true import as long as, during
|
||||
the gap, the house load (less solar) stayed at or under the higher of the loads seen just
|
||||
before and just after it, or under the limit, and the batteries did not
|
||||
start charging (during a gap they hold their last command, then go to 0 W).
|
||||
It never counts the gap below the limit. A load that rises above all three
|
||||
only inside the gap is the one case it can miss.
|
||||
- **Cost of the bound.** It is conservative: with the house at 6500 W, a 30 s
|
||||
to 5 min gap lands the quarter 100-180 W under the aim (table below), paid in
|
||||
battery energy.
|
||||
- **While the gap lasts** nothing changes: the reading is too old to steer by,
|
||||
the guard is blind (floor 0 W) and the "inputs missing" stop sets 0 W, as
|
||||
before.
|
||||
- **Before the first reading** (start-up or restart mid-quarter) the
|
||||
add-on was down, so the batteries were at 0 W (it sets 0 W when it stops,
|
||||
the ESP32 watchdog after 30 s). Those seconds count like a gap, at
|
||||
`gap_w(the limit, the import seen first)`; after a restart the restored
|
||||
reading's held `meter_max_age_s` is topped up the same way. The bound is
|
||||
the gap's, with only the "after" side known. A load the batteries cannot
|
||||
make up then raises "Load too high", which is true: that quarter is going
|
||||
over. The page says so: the status row "Peak guard: part of this
|
||||
15-minute period was not measured, counted as high use" and the home line
|
||||
"Part of this 15-minute period was not measured." / "Een deel van dit
|
||||
kwartier is niet gemeten." until the quarter ends.
|
||||
*Review:* the first version counted them at the aim, with "nothing is
|
||||
known" as the reason. The first reading does show the load, and with the
|
||||
batteries at 0 W the import was that load, not the aim: a 60 s restart
|
||||
during a 6500 W HOLD landed 3653 W against 3450 W.
|
||||
- The bill record (`quarter_blocks`) is unchanged: it still banks only what
|
||||
was measured. The estimate steers the guard and is never banked.
|
||||
- The action log gets one `PEAK_GUARD_GAP` row per gap, with the seconds and
|
||||
the watts it was counted at.
|
||||
- No option. The threshold is `meter_max_age_s`, the limit and the aim are
|
||||
`peak_cap_w` and `peak_margin_w`.
|
||||
|
||||
**Before -> after** (`test_safety90.py`, quarter as the meter really averages
|
||||
it, gap starting 300 s in; 10 s battery lag; aim 3450 W):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| N=1 HOLD 6500 W, gap 30 s / 60 s | 3456.6 / 5329.2 | 3346.2 / 3272.2 |
|
||||
| N=1 HOLD 5500 W, gap 5 min | 4717.9 | 3330.6 |
|
||||
| N=2 HOLD, B able, 6500 W, gap 30 s / 60 s | 3456.6 / 3865.6 | 3346.3 / 3272.7 |
|
||||
| N=2 HOLD, B able, 5500 W, gap 5 min | 3745.4 | 3288.9 |
|
||||
| N=2 HOLD, B held, 5500 W, gap 60 s | 3254.9, B drawn 2500 W | 3335.4, B 0 W |
|
||||
| gap across the edge, next quarter, N=1 / N=2 | 4100.0 / 3621.2 | 3446.3 / 3382.2 |
|
||||
| house 3000 -> 6500 W inside a 60 s gap, N=1 | 5255.6 | 3368.3 |
|
||||
| house 6500 -> 2000 W late in a 5 min gap, N=1 | 3629.2 | 3145.1 |
|
||||
| start-up 450 s in, N=1, house 5000 W (batteries at 0 W before it) | 5000.0 | 3450.0 |
|
||||
| restart, down 60 s / 150 s, N=1, house 6500 W | 4039.1 / 5483.3 | 3450.0 / 3450.0 |
|
||||
| meter never drops, N=1 and N=2 | | rows identical |
|
||||
|
||||
**Not changed.** The late-reading edge (SAFETY-91 test, O1): a reading late by
|
||||
less than `meter_max_age_s` is not a gap, and the seconds since it are not
|
||||
counted in the projection (on any quarter, not only after a gap). Near a
|
||||
quarter's end the 900/remaining gain turns that into a large negative excess
|
||||
and the floor drops for a cycle or more. Counting those seconds at the held
|
||||
reading would fix it, but it changes every quarter's last seconds on a
|
||||
healthy meter; it is handed back as its own card.
|
||||
@@ -0,0 +1,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,59 @@
|
||||
# SAFETY-96 - a battery the loop cannot move does not freeze it
|
||||
|
||||
**Status: implemented 2026-10-06, for review.** Tests: `test_safety96.py`.
|
||||
|
||||
**The defect** (SAFETY-93 rig test). At N=2 the saturation freeze compares
|
||||
the site target with the site sum of the readings. After A's link went
|
||||
stale, A was out of the split but its reading (-2000 W, still charging) was
|
||||
in the sum: "saturation freeze (800 W vs -1903 W)", B held at 1200 W for
|
||||
~236 cycles until the circulation trip. After a charge timeout or Off at
|
||||
6000 W the loop froze ~14 s at 800 W while A came out of its charge. At a
|
||||
drop, B also took A's whole share in one step.
|
||||
|
||||
**The rule** (`main.py`, `_held_out_w()`, `_reseed_drop()`, N>=2 only).
|
||||
A battery is held out of the freeze test, counted at its command instead of
|
||||
its reading, while:
|
||||
|
||||
- it is out of the split (writes refused for `LINK_STALE_S`);
|
||||
- its last write was refused (its board may have gone to 0 W on its own
|
||||
watchdog);
|
||||
- it settles after a step the loop did not make: a charge end (SAFETY-93:
|
||||
HOLD, timeout, Off, refusal, stale link) or a rejoin (then every battery,
|
||||
as the split is made again). Settling ends when its reading is within the
|
||||
deadband of its command, or after `PLANT_SETTLE_DOWN_S` (23 s, the
|
||||
measured plant settle).
|
||||
|
||||
Its real power is not lost and not counted twice: the meter sees it, and
|
||||
the loop follows the meter. Only the freeze stops reading it as the loop's.
|
||||
The freeze still fires on the other batteries' own lag and on unplaced
|
||||
watts.
|
||||
|
||||
At a drop the battery's share leaves the loop's start point, so the others
|
||||
take over one slew step a cycle. At a rejoin it enters at its reading.
|
||||
|
||||
**Not a drop.** A missing reading or a stale register stops the site
|
||||
(`inputs_ok`), as before. A refused battery (SAFETY-73) stays in the sum:
|
||||
it is asked ~0 W and reads ~0 W. A watchdog-stopped battery (SAFETY-02/30)
|
||||
is pinned at 0 W and stays in the sum. New: no refusal is learned on a
|
||||
battery whose writes are refused; its board going to 0 W is no refusal. Its
|
||||
count is kept, not reset, so a link that refuses 1 write in 3 does not hide
|
||||
a battery that really refuses (review).
|
||||
|
||||
**Before -> after** (2 x 2500 W, 10 s lag, 2 s meter; freeze cycles not
|
||||
about the remaining battery's own lag):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| A drops while giving, A holds 1200 W | 128, B steps 1210 W | 0, B stays |
|
||||
| A drops while charging, A holds -2000 W | 78 | 0 (circulation trip stays: 4 kW goes round) |
|
||||
| A drops while charging, board to 0 W | 5 | 0 |
|
||||
| A drops then comes back, A holds 1200 W | 46, grid +1.1 kW | 0, grid within 250 W |
|
||||
| charge timeout at 6000 W | 7, B held at 400 W | 0, B climbs |
|
||||
| A switched Off at 6000 W | 6, B held at 800 W | 0, B climbs |
|
||||
| N=1, N=2 with no drop | | byte-identical |
|
||||
|
||||
**Not changed.** At a rejoin the allocator splits again at once (A 0 ->
|
||||
1205, B 2410 -> 1205 W); the site total does not step. A battery that keeps
|
||||
charging on its own after its link drops still trips the circulation check
|
||||
(it is real circulation). A trade end (`_reseed_run_edge`) still starts
|
||||
from the full site sum.
|
||||
@@ -0,0 +1,118 @@
|
||||
# SAFETY-97 - "gives nothing" is graded against each battery's idle reading
|
||||
|
||||
**Status: implemented 2026-10-04, folded into PR #171 (SAFETY-94), for
|
||||
review.** Owner decision 2026-10-04: the band's centre is clamped to
|
||||
+-75 W (see Bounds). Tests: `test_safety97.py` (rig: `tools/safety94_rig.py`).
|
||||
Closes SAFETY-94 review finding F1.
|
||||
|
||||
## The defect
|
||||
|
||||
B's power reading (board B's `inverter_ac_power`, 0x0518) shows -95 to -118 W
|
||||
at idle: its standby draw (the GoodWe app shows 0 W). A's shows -2 to -4 W.
|
||||
Every "the battery gives nothing" test was `|reading| < 100 W`, so a refusing
|
||||
B sat on the band's edge and never latched:
|
||||
|
||||
- N=2: never caught, before or after SAFETY-94.
|
||||
- N=1: the old one-instant check caught it in 17-105 s; SAFETY-94's shared
|
||||
rule (12 cycles in the band in a row) never did (review probe, 1200 s).
|
||||
|
||||
## Design
|
||||
|
||||
- **Offset source: learned per unit** (`app/idle.py`, `Inverter.idle`). No
|
||||
new option: a required one is a setup step every client gets wrong, and an
|
||||
optional one is a second source to reconcile. An override can be added
|
||||
later if a site needs one.
|
||||
- **When it learns:** control runs; the unit is not refused (unit or site),
|
||||
stale or sign-tripped; its command has been within 50 W (`IDLE_ASK_W`) for
|
||||
120 s (`IDLE_QUIET_S`, 6 lags of 20 s). A refusing battery is asked more
|
||||
than 50 W, and while refused it does not learn, so its refusal cannot be
|
||||
learned as idle. A capped battery under a big ask is not sampled either.
|
||||
- **How:** the first value is the mean of 120 s of samples; then it follows
|
||||
drift with a 30 min time constant. A sample beyond 250 W (`IDLE_SAMPLE_W`)
|
||||
is dropped (a register held at a giving value). The band is centred on
|
||||
the value clamped to 75 W (`IDLE_MAX_W`; owner decision below).
|
||||
- **Restart:** saved per unit (`unit/<name>/idle_w`, on first learn and each
|
||||
5 W move) and restored, clamped to 250 W. Without it a battery refusing from the
|
||||
start is never quiet, so never learned, so never caught.
|
||||
- **Until learned the idle reading is 0 W**: the rule as it was.
|
||||
|
||||
## Not chosen: the GoodWe LAN integration's DC battery power
|
||||
|
||||
Both inverters also appear through HA's GoodWe integration (B:
|
||||
`sensor.kelder_dyness_battery_power`, 0 W at idle; found read-only on the
|
||||
live HA 2026-10-04). Pointing `batt_entity` at it removes B's offset by
|
||||
configuration, but:
|
||||
|
||||
- the band must be safe without it: other installs may not have it;
|
||||
- it is polled over UDP on the inverter's Wi-Fi, a second channel that can
|
||||
drop while the board keeps reading (HW-01 lost both once); a missing
|
||||
reading stops the site;
|
||||
- SAFETY-37's 35 s bound is derived from the board's 10 s publish; the
|
||||
integration's poll interval (believed 10 s, not checked here) and latency
|
||||
are not measured;
|
||||
- DC power is not 0x0518, which SAFETY-30/31/42 were measured against.
|
||||
|
||||
It stays an install choice to test on the rig, not a code change here. With
|
||||
it, the learned idle reading is simply ~0 W.
|
||||
|
||||
## Where it applies
|
||||
|
||||
Every test of "gives nothing / gives / takes" now uses `reading - idle`:
|
||||
|
||||
| check | change |
|
||||
|---|---|
|
||||
| shared refusal evidence `_gave_nothing` (N=1 and N>=2) | centred |
|
||||
| N=1 engage and end in `compute()` (`idle_w`, the site sum) | centred |
|
||||
| N=1 end in `_refusal_update` | centred |
|
||||
| N>1 unit end (probe answered, takes the other way) | centred; uncentred, B's idle -100 W read as "takes 100 W" and ended a charge refusal at once |
|
||||
| stale-path refusal (`_refusal_from_stale`, N=1 and N>1) | centred |
|
||||
| SAFETY-42 "parked" (small ask, gives nothing) | centred |
|
||||
| SAFETY-37 stale gate | unchanged: it grades register changes against command travel, not a level |
|
||||
| SAFETY-57 release | unchanged: it reads the command only |
|
||||
| SAFETY-30 per-unit sign | unchanged: opposition needs both over 250 W; B's 100 W idle cannot make or hide one alone |
|
||||
| SAFETY-42 shortfall | unchanged: B reads ~100 W short at every power, which errs towards reporting (report only) |
|
||||
|
||||
## Bounds: owner decision 2026-10-04 (review R1)
|
||||
|
||||
Band: `|reading - centre| < 100 W`, centre = idle clamped to +-75 W.
|
||||
|
||||
- **Why a clamp under the band.** A wrong idle reading (taught during a
|
||||
rest while the reading shows something else, e.g. a grid top-up of a unit
|
||||
held at 0 W, or restored onto a swapped inverter) more than about 100 W off
|
||||
hid every refusal on that battery, at N=1 and N=2; with the first +-250 W
|
||||
clamp the rig missed a refusing A whenever the error was 110-250 W. At
|
||||
+-75 W the band always keeps 0 W +-25 W, so a battery reading about 0 W
|
||||
while refusing is caught whatever was learned or restored, and a wrong idle
|
||||
cannot refuse a healthy 0 W battery (its ask is over 200 W).
|
||||
- **The trade-off.** Options: keep +-250 W (covers idle readings to about
|
||||
+-350 W, any 100 W mis-learn hides refusals); clamp to +-75 W (chosen);
|
||||
"old band or centred band" (rejected: a correct B would need to give 200 W
|
||||
to answer its 200 W probe).
|
||||
- **Cost.** A battery whose idle reading is beyond about +-170 W is centred
|
||||
at most 75 W out, so its refusal falls outside the band: it behaves as in
|
||||
0.14.0 (not caught by this rule). B (-95 to -118 W) reads 20-43 W from its
|
||||
clamped centre. B is caught across -150..+90 W of error in its idle
|
||||
reading; learned 110 W or more too high it is missed, as in 0.14.0 at N=2.
|
||||
- A frozen register is still caught by the stale gate at 35 s of asking.
|
||||
- Recovery from a wrong value needs quiet time: tau 30 min.
|
||||
|
||||
## Catch times (rig, refusing from 200 s after a 400 s rest, +-4 W)
|
||||
|
||||
| refusing | meter 1 s | 2 s | 5 s |
|
||||
|---|---|---|---|
|
||||
| A, N=1 | 28 s (before 28) | 28 (28) | 65 (65) |
|
||||
| A, N=2 | 28 (28) | 28 (28) | 110 (110) |
|
||||
| B, N=1 | 28 (never) | 28 (never) | 65 (never) |
|
||||
| B, N=2 | 24 (never) | 24 (never) | 60 (never) |
|
||||
|
||||
A at N=2 on a 5 s meter is 110 s here because the rig's grid goes flat once
|
||||
A is cut, so the loop cycles on the 15 s floor; the same run on the PR before
|
||||
SAFETY-97 gives 110 s. B restored after a restart, refusing from the start:
|
||||
24 s; with nothing saved: never.
|
||||
|
||||
## Before -> after
|
||||
|
||||
- Healthy B with its offset, SAFETY-94's four scenarios, N=1 and N=2, 1/2/5 s
|
||||
meters, both register shapes, idle learned or restored (96 runs): 0 stale,
|
||||
0 refused. Reversals, lag 10/20 s, idle restored: 0.
|
||||
- Live replay (SAFETY-94 part 1): 0 trips (the gate is untouched).
|
||||
+49
-11
@@ -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. |
|
||||
@@ -590,7 +590,7 @@ accumulator is doing:
|
||||
| `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,
|
||||
@@ -600,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
|
||||
@@ -1639,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; at N≥2 while another battery is at its peak reserve, SAFETY-87). 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 |
|
||||
@@ -1667,6 +1688,9 @@ others only add power when the house needs more; none charges while it drains
|
||||
(SAFETY-78). Below the drain rate the rest is exported, as with one battery.
|
||||
If the house would still go over the peak limit, the battery being emptied
|
||||
gives more than its drain rate, with one battery too (SAFETY-83).
|
||||
A battery at its peak reserve gives last: the others give their full power
|
||||
first, and the battery being emptied rises above its drain rate before it
|
||||
(SAFETY-91).
|
||||
In a cheap window the battery being filled charges and the others only add
|
||||
charge; none gives while it charges. The charge is cut to what keeps the
|
||||
15-minute peak at the limit minus `peak_margin_w` (SAFETY-84).
|
||||
@@ -1674,6 +1698,10 @@ While a full battery rests after its charge (the hold), it gives just enough
|
||||
to keep the 15-minute peak at that aim if the house needs it; with two
|
||||
batteries the other one gives first. The hold still ends on its clock
|
||||
(SAFETY-86).
|
||||
When a charge ends, the other batteries take over one slew step a cycle,
|
||||
and none starts charging from the grid (SAFETY-93).
|
||||
When a battery's link drops, the others take over one slew step a cycle,
|
||||
and control does not stall on the dropped battery's power (SAFETY-96).
|
||||
|
||||
#### Capacity-tariff exposure of the grid-following loop
|
||||
|
||||
@@ -1743,10 +1771,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.
|
||||
@@ -1809,7 +1844,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**
|
||||
@@ -3450,7 +3487,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**
|
||||
|
||||
@@ -737,9 +737,9 @@ 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: units under their peak reserve weigh 0 while the site gives,
|
||||
# so the others carry the peak floor. An Off battery gives the peak's
|
||||
# shortfall, so it goes before them when no other unit is left.
|
||||
# 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()
|
||||
|
||||
@@ -484,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).
|
||||
|
||||
@@ -497,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
|
||||
@@ -817,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1294,8 +1295,11 @@ def line_peak_not_covered() -> str:
|
||||
return _t("peak_not_covered")
|
||||
|
||||
|
||||
def line_period(period_w, limit_w, shadow) -> str:
|
||||
def line_period(period_w, limit_w, shadow, unmeasured=False) -> str:
|
||||
step = _w(shadow, "step_in", kw=_kw(limit_w))
|
||||
if unmeasured:
|
||||
# SAFETY-90: the add-on started during this period.
|
||||
return _t("period_unmeasured") + " " + step
|
||||
if period_w is None:
|
||||
return step
|
||||
return _t("period", kw=_kw(period_w)) + " " + step
|
||||
@@ -1369,6 +1373,7 @@ def _samples() -> list:
|
||||
("plan", line_peak_limit(3500, sh)),
|
||||
("plan", line_period(1240, 3500, sh)),
|
||||
("plan", line_period(None, 3500, sh)),
|
||||
("plan", line_period(1240, 3500, sh, unmeasured=True)),
|
||||
("plan", control_text(True, sh)),
|
||||
("plan", control_text(False, sh))]
|
||||
out += [("plan", run_grid_why(0.1412)), ("plan", run_grid_amount(1.23)),
|
||||
|
||||
@@ -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:
|
||||
|
||||
+407
-36
@@ -30,6 +30,7 @@ import functools
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -68,15 +69,15 @@ from . import messages
|
||||
from .freeze import (
|
||||
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
|
||||
)
|
||||
from .hass import HomeAssistant
|
||||
from .hass import CORE_API, HomeAssistant
|
||||
# ⚠️ `LINK_STALE_S` IS RE-EXPORTED DELIBERATELY. `live_units()` reasons about it
|
||||
# and two checks read `app.main.LINK_STALE_S` to build a stale link, so the
|
||||
# window an operator would look up here is the one the roster actually uses.
|
||||
from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
|
||||
roster_entries, roster_names)
|
||||
from .maintenance import (
|
||||
DRAIN, HOLD, IDLE, POWER_MOVING, MaintConfig, Maintenance, MaintResult,
|
||||
arbitrate as maint_arbitrate,
|
||||
CHARGE, DRAIN, HOLD, IDLE, POWER_MOVING, MaintConfig, Maintenance,
|
||||
MaintResult, arbitrate as maint_arbitrate, site_zone,
|
||||
)
|
||||
from .mqtt import (MODE_CONFIGS_KEY, MODE_LABELS, USE_OFF, USE_ON,
|
||||
MqttPublisher, announced_units, mode_configs,
|
||||
@@ -91,7 +92,7 @@ from .price import required_spread as price_required_spread
|
||||
from .price import uncertainty_of as price_uncertainty_of
|
||||
from .price_history import PriceHistory
|
||||
from .static_tariff import resolve_tariff, static_status
|
||||
from .tariff import PeakStore, PeakTracker
|
||||
from .tariff import PeakStore, PeakTracker, next_quarter
|
||||
from .store import migrate_maint_keys, migrate_unit_keys
|
||||
from .validate import (FIRMWARE_INGEST_VETO_S, ConfigRefused, refuse,
|
||||
validate_startup_config, validate_unit_ranges)
|
||||
@@ -281,6 +282,14 @@ def pack_energy_of(inv):
|
||||
# ordinary load steps. At 2 s and slower this changes nothing.
|
||||
REFUSED_SPAN_S = (_ctl.SETTLE_CYCLES - 1) * _ctl.LOOP_TICK_S
|
||||
|
||||
# SAFETY-94: the evidence restarts when the reading has moved this far towards
|
||||
# the ask since the evidence began. A refusing battery reads flat (-4 W live,
|
||||
# 2026-09-06); live readings wander by under 25 W at rest (B's standby, -95 to
|
||||
# -118 W, 2026-10-03). A battery crossing the 100 W band on a first-order lag
|
||||
# gains 50 W in at most tau x ln(151/101) = 0.4 tau, so the evidence cannot
|
||||
# span REFUSED_SPAN_S unless the lag is over 54 s.
|
||||
REFUSED_PROGRESS_W = _ctl.PROBE_ANSWER_W / 2
|
||||
|
||||
# SAFETY-85 review: a battery another owner set is graded against the least it
|
||||
# was told over the same span as above (the plant's measured settle time), so
|
||||
# a healthy battery still settling on a new command is not short.
|
||||
@@ -416,7 +425,8 @@ def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now,
|
||||
class Controller:
|
||||
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
|
||||
peak_db_path: str = "/data/tariff.db",
|
||||
price_history_path: str = "/data/price_history.json"):
|
||||
price_history_path: str = "/data/price_history.json",
|
||||
site_tz=None):
|
||||
# UX-15: a read-only HomeAssistant belongs to setup-only mode, which
|
||||
# builds no controller. Refused here so no controller can hold one.
|
||||
if getattr(hass, "read_only", False) is True:
|
||||
@@ -657,7 +667,8 @@ class Controller:
|
||||
name=inv.name,
|
||||
# The rollback view, and only ever the first pack's: the flat
|
||||
# keys mean `inverters[0]` on every install that has them.
|
||||
mirror_flat=(not _one_pack and i == 0))
|
||||
mirror_flat=(not _one_pack and i == 0),
|
||||
tz=site_tz) # MAINT-10: the start hour's zone, from amain()
|
||||
for i, inv in enumerate(self.inverters)]
|
||||
|
||||
# SAFETY-02. ONE INSTANCE PER ACTUATOR - which is what watchdog.py's own
|
||||
@@ -939,6 +950,22 @@ class Controller:
|
||||
# SAFETY-81: what the alarm is graded on, beside the guard's decision.
|
||||
self._peak_proj = None
|
||||
self._peak_batt_prev = None
|
||||
# SAFETY-90: what a meter gap is counted at (`_fill_peak_gaps()`).
|
||||
self._gap_seen_w = None
|
||||
self._gap_w = None
|
||||
self._gap_block = None
|
||||
self._gap_filled_s = 0.0
|
||||
self._gap_held_ws = 0.0
|
||||
self._gap_pre = None
|
||||
self._unseen = None
|
||||
# A held sample TARIFF-01 restored from before this restart: the
|
||||
# averager credits it max_hold_s into the restart, at 0 W batteries.
|
||||
_held = self.p1.averager.held_since
|
||||
self._run_pre = (None if _held is None
|
||||
else (_held, self.p1.averager.held_net_w))
|
||||
self._unseen_held_ws = 0.0
|
||||
self.peak_gap_s = 0.0
|
||||
self.peak_unmeasured_s = 0.0
|
||||
self._peak_alarm_block = None
|
||||
self._peak_high_since = None
|
||||
self._peak_floor_limited = False
|
||||
@@ -992,6 +1019,18 @@ class Controller:
|
||||
# SAFETY-78: names of draining packs that cannot give power now, so
|
||||
# the "waits" line is logged once, not every cycle.
|
||||
self._drain_waits = set()
|
||||
# SAFETY-91: last cycle's held-back batteries (`_held_back()`).
|
||||
self._held_was = frozenset()
|
||||
# SAFETY-93: the batteries placed in their maintenance charge last
|
||||
# cycle, and those whose charge ended since the loop last ran.
|
||||
self._charging_was = frozenset()
|
||||
self._charge_ended = frozenset()
|
||||
# SAFETY-96: last cycle's commandable batteries, this cycle's start
|
||||
# point per battery where a reseed moved it, and batteries settling
|
||||
# after a step the loop did not make ({name: since, monotonic}).
|
||||
self._live_was = None
|
||||
self._loop_start = {}
|
||||
self._settle = {}
|
||||
# Set by amain() once the transport is built, so the status page can show
|
||||
# what only the transport knows (homewizard_local's unchanged_s). Stays
|
||||
# None when P1 is off, or under a transport that has no such counter.
|
||||
@@ -1235,7 +1274,8 @@ class Controller:
|
||||
f"gave {batt_w:+.0f} W for "
|
||||
f"{decision.sat_count} cycles")
|
||||
elif self.batt_refused and not now:
|
||||
gave = self.batt_refused * batt_w >= _ctl.PROBE_ANSWER_W
|
||||
gave = (self.batt_refused * (batt_w - self._site_idle_w())
|
||||
>= _ctl.PROBE_ANSWER_W)
|
||||
self._refusal_end("the probe was answered" if gave and probe
|
||||
else "the battery gives power" if gave
|
||||
else "the grid now asks the other direction")
|
||||
@@ -1253,7 +1293,8 @@ class Controller:
|
||||
if ((self.auto or self.shadow) and not inv.refused
|
||||
and inv.ref_count >= t.saturation_cycles
|
||||
and inv.batt_fresh.held is not None
|
||||
and abs(inv.batt_fresh.held) < _ctl.REFUSED_BELOW_W
|
||||
and abs(inv.batt_fresh.held - inv.idle_w)
|
||||
< _ctl.REFUSED_BELOW_W
|
||||
and abs(standing) > _ctl.PROBE_W):
|
||||
self._now_mono = now_mono
|
||||
self._unit_refusal_start(
|
||||
@@ -1265,7 +1306,8 @@ class Controller:
|
||||
and not self.batt_refused
|
||||
and self.sat_count >= t.saturation_cycles
|
||||
and inv.batt_fresh.held is not None
|
||||
and abs(inv.batt_fresh.held) < _ctl.REFUSED_BELOW_W
|
||||
and abs(inv.batt_fresh.held - inv.idle_w)
|
||||
< _ctl.REFUSED_BELOW_W
|
||||
and abs(standing) > t.saturation_w):
|
||||
self._now_mono = now_mono
|
||||
self._refusal_start(1 if standing > 0 else -1,
|
||||
@@ -1338,7 +1380,9 @@ class Controller:
|
||||
for inv in self.inverters:
|
||||
b = inv.batt
|
||||
if inv.refused:
|
||||
if b is not None and inv.refused * b >= _ctl.PROBE_ANSWER_W:
|
||||
# SAFETY-97: given and taken are counted from the idle reading.
|
||||
if (b is not None and inv.refused * (b - inv.idle_w)
|
||||
>= _ctl.PROBE_ANSWER_W):
|
||||
self._unit_refusal_end(
|
||||
inv, "the probe was answered"
|
||||
if inv.probe_from is not None
|
||||
@@ -1349,32 +1393,60 @@ class Controller:
|
||||
# on its own reading, so one cycle of the loop overshooting
|
||||
# through zero does not hand it its share back.
|
||||
inv.ref_count = (inv.ref_count + 1 if b is not None and
|
||||
-inv.refused * b >= _ctl.PROBE_ANSWER_W else 0)
|
||||
-inv.refused * (b - inv.idle_w)
|
||||
>= _ctl.PROBE_ANSWER_W else 0)
|
||||
if inv.ref_count >= _ctl.SETTLE_CYCLES:
|
||||
self._unit_refusal_end(
|
||||
inv, "the battery takes power the other way")
|
||||
continue
|
||||
if b is None:
|
||||
continue # a gap keeps the count: the gate's trip seeds
|
||||
standing = inv.standing_w()
|
||||
sign = 1 if standing > 0 else -1
|
||||
# One direction throughout: a command that swaps sign every cycle
|
||||
# (a flapping load) reads ~0 through the plant's own lag, and that
|
||||
# is a battery following, not refusing (TEST-19 flapping family).
|
||||
if abs(standing) > _ctl.PROBE_W and abs(b) < _ctl.REFUSED_BELOW_W:
|
||||
if sign != inv.ref_sign or not inv.ref_count:
|
||||
inv.ref_count, inv.ref_since = 0, self._now_mono
|
||||
inv.ref_count = min(inv.ref_count + 1, _ctl.SETTLE_CYCLES)
|
||||
else:
|
||||
inv.ref_count = 0
|
||||
inv.ref_sign = sign
|
||||
if inv.ref_count >= _ctl.SETTLE_CYCLES and (
|
||||
inv.ref_since is None or self._now_mono is None
|
||||
or self._now_mono - inv.ref_since >= REFUSED_SPAN_S):
|
||||
if inv.rejected_since is not None:
|
||||
# SAFETY-96: its writes are refused, so its board may have
|
||||
# gone to 0 W on its own; that is no refusal by the battery.
|
||||
# The count is kept, as across a gap, so a link that refuses
|
||||
# now and then does not hide a battery that does refuse.
|
||||
continue
|
||||
sign = self._gave_nothing(inv)
|
||||
if sign:
|
||||
self._unit_refusal_start(
|
||||
inv, sign,
|
||||
f"asked {standing:+.0f} W, the battery gave {b:+.0f} W "
|
||||
f"for {_ctl.SETTLE_CYCLES} cycles")
|
||||
f"asked {inv.standing_w():+.0f} W, the battery gave "
|
||||
f"{b:+.0f} W for {_ctl.SETTLE_CYCLES} cycles")
|
||||
|
||||
def _gave_nothing(self, inv) -> int:
|
||||
"""The refusal evidence, one cycle of it: the sign `inv` has given
|
||||
under REFUSED_BELOW_W while asked over PROBE_W, one way, for
|
||||
SETTLE_CYCLES spanning REFUSED_SPAN_S, else 0. SAFETY-73 at N>1;
|
||||
SAFETY-94 adds it to the N=1 engage in compute(). SAFETY-97: the band
|
||||
is centred on the unit's idle reading."""
|
||||
b = inv.batt
|
||||
if b is None:
|
||||
return 0 # a gap keeps the count: the gate's trip seeds
|
||||
standing = inv.standing_w()
|
||||
sign = 1 if standing > 0 else -1
|
||||
# One direction throughout: a command that swaps sign every cycle
|
||||
# (a flapping load) reads ~0 through the plant's own lag, and that
|
||||
# is a battery following, not refusing (TEST-19 flapping family).
|
||||
if (abs(standing) > _ctl.PROBE_W
|
||||
and abs(b - inv.idle_w) < _ctl.REFUSED_BELOW_W):
|
||||
# SAFETY-94: a reading still moving towards the ask is a battery
|
||||
# catching up through its lag, not one refusing: start again.
|
||||
if (sign != inv.ref_sign or not inv.ref_count
|
||||
or sign * (b - inv.ref_from) >= REFUSED_PROGRESS_W):
|
||||
inv.ref_count, inv.ref_since, inv.ref_from = (
|
||||
0, self._now_mono, b)
|
||||
inv.ref_count = min(inv.ref_count + 1, _ctl.SETTLE_CYCLES)
|
||||
else:
|
||||
inv.ref_count = 0
|
||||
inv.ref_sign = sign
|
||||
if inv.ref_count >= _ctl.SETTLE_CYCLES and (
|
||||
inv.ref_since is None or self._now_mono is None
|
||||
or self._now_mono - inv.ref_since >= REFUSED_SPAN_S):
|
||||
return sign
|
||||
return 0
|
||||
|
||||
def _site_idle_w(self) -> float:
|
||||
"""SAFETY-97: the site reading at idle, the sum of the units'."""
|
||||
return float(sum(inv.idle_w for inv in self.inverters))
|
||||
|
||||
def _site_refused(self):
|
||||
"""Owner decision 4: every unit refuses the same way, so the site law
|
||||
@@ -2366,6 +2438,99 @@ class Controller:
|
||||
self.target = want
|
||||
self.i_w = want - self.tuning.gain * (want - float(batt))
|
||||
|
||||
def _charge_track(self, claims, maint_results) -> None:
|
||||
"""SAFETY-93, N>=2: a charge ends when its battery leaves CHARGE or can
|
||||
no longer be placed (stale link, refusal). Kept until the loop runs."""
|
||||
if len(self.inverters) < 2:
|
||||
return
|
||||
live = set(self.live_units())
|
||||
now = set()
|
||||
for inv, res in zip(self.inverters, maint_results):
|
||||
u = self.actuator_of("maintenance", inv)
|
||||
if res.phase == CHARGE and u in live and not pre_resolve(
|
||||
[c for c in claims if c.actuator == u]).contradiction:
|
||||
now.add(inv.name)
|
||||
self._charge_ended |= self._charging_was - now
|
||||
self._charging_was = frozenset(now)
|
||||
|
||||
def _reseed_charge_end(self, maint_results) -> None:
|
||||
"""SAFETY-93, N>=2: when a battery's maintenance charge ends, the loop
|
||||
starts from what the others stood at plus that battery's new command
|
||||
(its HOLD pin, or its last discharge if it is free; a charge stops).
|
||||
Else the charge's watts stay in the site target and land on the
|
||||
others: B 0 -> -1600 W in one cycle. DECISIONS/SAFETY-93-charge-end.md.
|
||||
"""
|
||||
ended, self._charge_ended = self._charge_ended, frozenset()
|
||||
if len(self.inverters) < 2 or not ended:
|
||||
return
|
||||
for inv, res in zip(self.inverters, maint_results):
|
||||
if inv.name not in ended:
|
||||
continue
|
||||
now_w = (float(res.setpoint_w or 0.0) if res.owns_setpoint
|
||||
else max(float(inv.target), 0.0))
|
||||
if not inv.is_live(datetime.now(timezone.utc))[0]:
|
||||
now_w = 0.0 # SAFETY-96: out of the split, it is sent 0 W
|
||||
self.target += now_w - float(inv.target)
|
||||
# SAFETY-96: it settles from its charge to that command.
|
||||
self._loop_start[inv.name] = now_w
|
||||
self._settle[inv.name] = self._now_mono
|
||||
self.i_w = self.target
|
||||
|
||||
def _reseed_drop(self, live) -> None:
|
||||
"""SAFETY-96, N>=2: a battery that drops out of the split (stale
|
||||
link) leaves the loop's start point, so the others take over at one
|
||||
slew step a cycle instead of taking its share at once. One that comes
|
||||
back enters it at its reading, and every battery settles while the
|
||||
split is made again (`_held_out_w`).
|
||||
DECISIONS/SAFETY-96-dropped-battery.md.
|
||||
"""
|
||||
was, self._live_was = self._live_was, frozenset(live)
|
||||
if len(self.inverters) < 2 or was is None or was == live:
|
||||
return
|
||||
for inv in self.inverters:
|
||||
if inv.name in self._charge_ended:
|
||||
continue # SAFETY-93's edge: `_reseed_charge_end()`
|
||||
if inv.name in was and inv.name not in live:
|
||||
now_w = 0.0
|
||||
elif inv.name in live and inv.name not in was:
|
||||
now_w = float(inv.batt)
|
||||
# The split is made again, so every battery's command steps.
|
||||
self._settle.update(
|
||||
{u: self._now_mono for u in live})
|
||||
else:
|
||||
continue
|
||||
self.target += now_w - float(inv.target)
|
||||
self._loop_start[inv.name] = now_w
|
||||
self.i_w = self.target
|
||||
|
||||
def _held_out_w(self, live) -> float:
|
||||
"""SAFETY-96, N>=2: what to add to the site sum so that a battery
|
||||
the loop cannot move right now counts at its command, not at its
|
||||
reading. That is a battery out of the split, one whose writes are
|
||||
refused, or one settling after a step the loop did not make (a charge
|
||||
end, a rejoin) until its reading is within the deadband of its
|
||||
command or PLANT_SETTLE_DOWN_S has passed. Its real power still
|
||||
reaches the loop through the meter, so it is neither lost nor counted
|
||||
twice; only the saturation freeze stops seeing it as the loop's.
|
||||
"""
|
||||
if len(self.inverters) < 2:
|
||||
return 0.0
|
||||
out = 0.0
|
||||
for inv in self.inverters:
|
||||
cmd = self._loop_start.get(inv.name, float(inv.target))
|
||||
since = self._settle.get(inv.name)
|
||||
age = (None if since is None or self._now_mono is None
|
||||
else self._now_mono - since)
|
||||
if since is not None and (
|
||||
age is None or age >= _ctl.PLANT_SETTLE_DOWN_S
|
||||
or (age > 0 and abs(float(inv.batt) - cmd)
|
||||
< self.tuning.deadband_w)):
|
||||
del self._settle[inv.name]
|
||||
if (inv.name not in live or inv.rejected_since is not None
|
||||
or inv.name in self._settle):
|
||||
out += cmd - float(inv.batt)
|
||||
return out
|
||||
|
||||
def _mode_fault_log(self, resolution) -> None:
|
||||
"""OPT-11: say once, per battery, that its mode was dropped as a fault.
|
||||
|
||||
@@ -2567,6 +2732,126 @@ class Controller:
|
||||
return frozenset()
|
||||
return frozenset(g.held)
|
||||
|
||||
def _held_back(self) -> frozenset:
|
||||
"""SAFETY-91, N>=2: the batteries at or under their own peak reserve
|
||||
while another can still give (live, not refusing). They weigh 0 in
|
||||
every split where the site gives, so they give last (SAFETY-25
|
||||
option A: still drawn once the others are full). One held back
|
||||
stays so until its SoC reads more than 1 point above its reserve, so
|
||||
a reading flipping across it does not swap the split each cycle."""
|
||||
if len(self.inverters) < 2:
|
||||
return frozenset()
|
||||
floor = float(self.o.get(
|
||||
"peak_soc_floor", peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"]))
|
||||
ok, held = peakguard.reserve_split(self.packs, floor)
|
||||
held = set(held) | {
|
||||
p.name for p in ok if p.name in self._held_was
|
||||
and float(p.soc) <= peakguard.pack_floor_pct(p, floor) + 1.0}
|
||||
live = set(self.live_units())
|
||||
if not held or not any(
|
||||
p.name not in held and inv.name in live
|
||||
and not (inv.refused or 0) > 0
|
||||
for p, inv in zip(self.packs, self.inverters)):
|
||||
self._held_was = frozenset()
|
||||
else:
|
||||
self._held_was = frozenset(held)
|
||||
return self._held_was
|
||||
|
||||
def _drain_rise(self, claims, maint_results) -> None:
|
||||
"""SAFETY-91, N>=2: while a battery is held back (`_held_back()`), a
|
||||
draining battery's pin becomes a floor at its drain rate, so it rises
|
||||
towards its rating before the held one gives. A site stop still
|
||||
decides alone. DECISIONS/SAFETY-91-held-battery-last.md."""
|
||||
held = self._held_back()
|
||||
if not held or any(c.kind == SET and c.actuator == ACTUATOR_SITE
|
||||
and c.priority > P_PEAK for c in claims):
|
||||
return
|
||||
live = set(self.live_units())
|
||||
for inv, res in zip(self.inverters, maint_results):
|
||||
u = self.actuator_of("maintenance", inv)
|
||||
if res.phase != DRAIN or not res.owns_setpoint \
|
||||
or inv.name in held or u not in live:
|
||||
continue
|
||||
b = pre_resolve([c for c in claims if c.actuator == u])
|
||||
p = (None if b.contradiction or b.pinned_by != "maintenance"
|
||||
else allocator.pin_w(b))
|
||||
if p is None or not p > 0.0:
|
||||
continue
|
||||
for i, c in enumerate(claims):
|
||||
if c.source == "maintenance" and c.kind == SET \
|
||||
and c.actuator == u:
|
||||
claims[i] = Claim.limit("maintenance", P_MAINTENANCE,
|
||||
lo=p, reason=c.reason,
|
||||
actuator=u)
|
||||
|
||||
def _fill_peak_gaps(self, proj, now, reading):
|
||||
"""SAFETY-90: `proj` with a meter gap's uncounted seconds counted at
|
||||
`peakguard.gap_w()` of the imports seen just before and just after it,
|
||||
and those before this run's first reading likewise at the import seen
|
||||
first (shown as not measured).
|
||||
DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
|
||||
self.peak_gap_s = self.peak_unmeasured_s = 0.0
|
||||
avg = self.p1.averager
|
||||
since = avg.held_since
|
||||
if proj is None or proj.block_start is None or since is None:
|
||||
return proj
|
||||
if self._unseen is None and self.p1.last is not None:
|
||||
self._unseen = [proj.block_start, max(0.0, (
|
||||
since - proj.block_start).total_seconds() - avg.elapsed_s),
|
||||
since, None]
|
||||
seen = (None if self.grid is None or self.site_batt_w is None
|
||||
else float(self.grid) + max(0.0, float(self.site_batt_w)))
|
||||
if proj.block_start != self._gap_block:
|
||||
self._gap_block, self._gap_w = proj.block_start, None
|
||||
self._gap_filled_s = self._gap_held_ws = 0.0
|
||||
cap = float(self.o.get("peak_cap_w", 3500))
|
||||
ceiling = peakguard.quarter_ceiling_w(cap, reading)[0]
|
||||
unseen_on = (self._unseen is not None
|
||||
and self._unseen[0] == proj.block_start)
|
||||
if unseen_on and self._unseen[3] is None and seen is not None:
|
||||
# While the add-on was down the batteries were at 0 W, so the
|
||||
# seconds before its first reading count like a gap, at the
|
||||
# import seen first; a restored reading's held part is topped up.
|
||||
self._unseen[3] = peakguard.gap_w(ceiling, seen)
|
||||
if self._run_pre is not None:
|
||||
s0, g0 = self._run_pre
|
||||
h = (min(s0 + timedelta(seconds=avg.max_hold_s),
|
||||
self._unseen[2])
|
||||
- max(s0, proj.block_start)).total_seconds()
|
||||
self._unseen_held_ws = max(0.0, h) * max(
|
||||
0.0, self._unseen[3] - max(0.0, g0))
|
||||
kw = dict(tail_s=(now - since).total_seconds(),
|
||||
max_hold_s=avg.max_hold_s,
|
||||
unseen_s=self._unseen[1] if unseen_on else 0.0,
|
||||
unseen_w=(self._unseen[3] if unseen_on
|
||||
and self._unseen[3] is not None else ceiling))
|
||||
_p, gap_s, _u = peakguard.fill_gaps(proj, gap_w=0.0, **kw)
|
||||
if gap_s > self._gap_filled_s + peakguard.GAP_TOL_S:
|
||||
self._gap_w = peakguard.gap_w(ceiling, self._gap_w,
|
||||
self._gap_seen_w, seen)
|
||||
if self._gap_pre is not None:
|
||||
# The averager credited the gap's first max_hold_s at the
|
||||
# reading before it; top that part up to gap_w as well.
|
||||
s0, g0 = self._gap_pre
|
||||
h = (min(s0 + timedelta(seconds=avg.max_hold_s), since)
|
||||
- max(s0, proj.block_start)).total_seconds()
|
||||
self._gap_held_ws += max(0.0, h) * max(
|
||||
0.0, self._gap_w - max(0.0, g0))
|
||||
self.log_event(f"PEAK_GUARD_GAP: the meter missed {gap_s:.0f} s "
|
||||
f"of this 15-minute period, counted at "
|
||||
f"{self._gap_w:.0f} W",
|
||||
hint=messages.hint("peak_gap"))
|
||||
self._gap_filled_s = max(self._gap_filled_s, gap_s)
|
||||
if seen is not None:
|
||||
self._gap_seen_w = seen
|
||||
if self.grid is not None:
|
||||
self._gap_pre = (since, float(self.grid))
|
||||
out, self.peak_gap_s, self.peak_unmeasured_s = peakguard.fill_gaps(
|
||||
proj, gap_w=self._gap_w if self._gap_w is not None else ceiling,
|
||||
held_ws=self._gap_held_ws + (self._unseen_held_ws if unseen_on
|
||||
else 0.0), **kw)
|
||||
return out
|
||||
|
||||
def _peak_over_drain(self, claims, maint_results) -> None:
|
||||
"""SAFETY-83: at N=1 a peak floor above the drain rate outranks it.
|
||||
|
||||
@@ -3029,6 +3314,14 @@ class Controller:
|
||||
# `stale_input_s` and then commands 0 W and keeps commanding, which is
|
||||
# §11.1 rule 1 and is already what a missing reading does here.
|
||||
inv.batt = inv.batt_fresh.reading()
|
||||
# SAFETY-97: learn this unit's idle reading, only while control runs
|
||||
# and nothing says the unit is refusing, stale or faulty.
|
||||
if inv.idle.observe(
|
||||
inv.batt, inv.standing_w(), now_mono,
|
||||
bool(self.auto) and inv.batt is not None
|
||||
and not inv.batt_fresh.stale and not inv.refused
|
||||
and not self.batt_refused and not inv.sign_wd.tripped):
|
||||
inv.store_set("idle_w", round(inv.idle.value, 1))
|
||||
if inv.batt_fresh.stale and not was_stale:
|
||||
self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason,
|
||||
hint=messages.hint("battery_stuck", unit=inv.name))
|
||||
@@ -3071,8 +3364,9 @@ class Controller:
|
||||
inv.batt_fresh.bound.move_w,
|
||||
messages.reason_code(inv.batt_fresh.reason))
|
||||
_LOG.debug("Evidence for %s: %s.", inv.name, inv.batt_fresh.reason)
|
||||
_LOG.debug("%s: the limits are unchanged over %.0f s, a command "
|
||||
"over %.0f W and a move by over %.0f W.", inv.name,
|
||||
_LOG.debug("%s: the limits are unchanged for %.0f s while asked "
|
||||
"to move: a command over %.0f W that moved over %.0f W.",
|
||||
inv.name,
|
||||
inv.batt_fresh.bound.unchanged_s,
|
||||
inv.batt_fresh.bound.move_w,
|
||||
inv.batt_fresh.bound.move_w)
|
||||
@@ -3789,7 +4083,8 @@ class Controller:
|
||||
parked=inv.sign_wd.tripped or bool(inv.refused) or (
|
||||
n_units > 1 and inv.batt is not None
|
||||
and abs(float(standing[inv.name])) <= _ctl.PROBE_W
|
||||
and abs(float(inv.batt)) < _ctl.REFUSED_BELOW_W),
|
||||
and abs(float(inv.batt) - inv.idle_w)
|
||||
< _ctl.REFUSED_BELOW_W),
|
||||
# ⚠️ THIS UNIT'S OWN CUT-OUT. Not the shipped 10 %, and not
|
||||
# the site's `peak_soc_floor` either - that one bounds a
|
||||
# money-saving discharge only, and grading a pack against it
|
||||
@@ -4082,6 +4377,15 @@ class Controller:
|
||||
# gives for SAFETY-18 on a transport that cannot see `unchanged_s`.
|
||||
self._peak_proj = (None if not self.p1_enabled
|
||||
else self.peak.project(self.p1.averager, now))
|
||||
_reading = self.peak.peak(now) if self.p1_enabled else None
|
||||
# SAFETY-90: seconds a meter gap left uncounted are counted, so the
|
||||
# guard goes on after the readings return.
|
||||
self._peak_proj = self._fill_peak_gaps(self._peak_proj, now, _reading)
|
||||
# SAFETY-91: at N>=2 the quarter's edge is graded on the next quarter,
|
||||
# so a HOLD's ceiling does not lapse for a cycle onto a held battery.
|
||||
if self._peak_proj is not None and len(self.inverters) > 1:
|
||||
self._peak_proj = next_quarter(self._peak_proj,
|
||||
self.p1.averager.max_hold_s)
|
||||
self.peak_guard = None if not self.p1_enabled else peakguard.decide(
|
||||
self._peak_proj,
|
||||
# ⚠️ THE SITE SUM. The guard sizes a floor from what the battery is
|
||||
@@ -4091,7 +4395,7 @@ class Controller:
|
||||
batt_w=self.site_batt_w,
|
||||
cap_w=float(self.o.get("peak_cap_w", 3500)),
|
||||
margin_w=self.peak_margin_w,
|
||||
reading=self.peak.peak(now),
|
||||
reading=_reading,
|
||||
# ⚠️ SUMMED FROM THE PER-UNIT CONJUNCTION, WHICH IS AC 4. Each
|
||||
# unit contributes `min(its max_w, |its own device bounds|)`, so
|
||||
# inverter B's rating bounds inverter B. Conjoining the SUMS instead
|
||||
@@ -4482,6 +4786,8 @@ class Controller:
|
||||
claims.append(Claim.limit("hold", P_MAINTENANCE, hi=hold_hi,
|
||||
reason="maintenance hold",
|
||||
actuator=self.actuator_of("hold")))
|
||||
# --- SAFETY-91: a drain rises before a held battery gives ------------
|
||||
self._drain_rise(claims, maint_results)
|
||||
# --- SAFETY-84: a cheap-window charge is a ceiling over the site ----
|
||||
charge = self._charge_ceiling(claims)
|
||||
if charge is not None:
|
||||
@@ -4495,6 +4801,9 @@ class Controller:
|
||||
if self.modes_on:
|
||||
mode_trade = self._mode_trade(claims, now)
|
||||
self._mode_trade_was = mode_trade
|
||||
self._charge_track(claims, maint_results)
|
||||
_live96 = set(self.live_units())
|
||||
self._loop_start = {}
|
||||
if (self.auto or self.shadow) and self.inputs_ok:
|
||||
# ⚠️ prev is the ARBITER's last output, not the loop's own last
|
||||
# wish. If something outranked the loop, that is what the hardware
|
||||
@@ -4521,6 +4830,11 @@ class Controller:
|
||||
# is what seeds it, and dropping it there restarts the ~70 s cycle.
|
||||
if self.loop_w is not None and self.target != self.loop_w:
|
||||
self._refusal_end("another writer")
|
||||
# SAFETY-96: a battery that dropped out of the split leaves the
|
||||
# loop's start point (SAFETY-93's own if its charge ended too).
|
||||
self._reseed_drop(_live96)
|
||||
# SAFETY-93: a charge that ended leaves the loop's start point.
|
||||
self._reseed_charge_end(maint_results)
|
||||
probe = self._refusal_probe()
|
||||
if len(self.inverters) > 1:
|
||||
probe = any(_probing.values())
|
||||
@@ -4538,6 +4852,9 @@ class Controller:
|
||||
# level: the two halves of §6.2's identity break in opposite
|
||||
# directions. See `site_batt_w` and test_alloc.py part 8.
|
||||
site_batt = self.site_batt_w
|
||||
# SAFETY-96: a battery the loop cannot move counts at its command,
|
||||
# so its own power does not freeze the loop (the meter has it).
|
||||
site_batt += self._held_out_w(_live96)
|
||||
# TEL-10. ⚠️ ONE NAME FOR THE COMMAND THE LOOP WAS MEASURED
|
||||
# AGAINST. `self.target` is reassigned further down this method, so
|
||||
# a `decision.i_w - self.target` written at publish time would
|
||||
@@ -4559,6 +4876,12 @@ class Controller:
|
||||
refused=self.batt_refused if len(self.inverters) == 1
|
||||
else self._site_refused(),
|
||||
probe=probe,
|
||||
# SAFETY-94: at N=1 the engage also needs the reading to have
|
||||
# given nothing for the whole span, as at N>1.
|
||||
gave_nothing=(bool(self._gave_nothing(self.inverters[0]))
|
||||
if len(self.inverters) == 1 else None),
|
||||
# SAFETY-97: "gives nothing" is centred on the idle reading.
|
||||
idle_w=self._site_idle_w(),
|
||||
# OPT-11: None unless battery modes are on. SAFETY-85: or while
|
||||
# maintenance holds a battery at N>=2, so the loop does not
|
||||
# wind past what the others can give and jump when it ends.
|
||||
@@ -4640,6 +4963,7 @@ class Controller:
|
||||
# Stopped or blind: no accumulation may survive the outage.
|
||||
self.i_w = 0.0
|
||||
self.loop_w = None
|
||||
self._live_was = frozenset(_live96)
|
||||
# TEL-10, and it is the same zero the line above writes rather than
|
||||
# a second opinion about it. ⚠️ `not-running` rather than a stale
|
||||
# `tracking`: a reason row that keeps repeating the last word the
|
||||
@@ -4680,7 +5004,7 @@ class Controller:
|
||||
while True:
|
||||
resolution = resolve(claims, units=self.units, live=_live,
|
||||
weights=self.pack_weights, tick=self.alloc_tick,
|
||||
reserve=self._peak_reserve())
|
||||
reserve=self._held_back())
|
||||
# SAFETY-73: a probe pin stands only while the resolved site target
|
||||
# asks at least PROBE_W its way. Below that a sibling would carry
|
||||
# the opposite direction (circulation), so the pin goes back to the
|
||||
@@ -6238,6 +6562,11 @@ class Controller:
|
||||
out.append({"ok": False, "warn": True, "id": "peak_guard",
|
||||
"text": messages.row_peak_guard_blind(
|
||||
peakguard.blind_kind(_pg.blind))})
|
||||
elif self.peak_unmeasured_s > 0.0:
|
||||
# SAFETY-90: the start of this quarter came before the first
|
||||
# reading, and the guard counts it as high use.
|
||||
out.append({"ok": False, "warn": True, "id": "peak_guard",
|
||||
"text": messages.row_peak_guard_unmeasured()})
|
||||
elif _pg.engaged:
|
||||
out.append({"ok": False, "warn": True, "id": "peak_guard",
|
||||
"text": messages.row_peak_guard_on(
|
||||
@@ -7005,6 +7334,47 @@ async def docs_link_facts(session, token: str) -> dict:
|
||||
return out
|
||||
|
||||
|
||||
async def ha_time_zone(session, token: str) -> tuple:
|
||||
"""MAINT-10: `(zone, why)` - Home Assistant's configured time zone, or None
|
||||
and a short reason. Same 3 s budget as the docs link: never holds up start."""
|
||||
try:
|
||||
async with session.get(f"{CORE_API}/config", timeout=DOCS_LINK_TIMEOUT_S,
|
||||
headers={"Authorization": f"Bearer {token}"}) as r:
|
||||
body = await r.json(content_type=None)
|
||||
zone = body.get("time_zone") if isinstance(body, dict) else None
|
||||
return (zone, "") if zone else (None, "no time_zone in its answer")
|
||||
except Exception as err: # noqa: BLE001 - a fallback exists, never a stop
|
||||
text = str(err).strip()
|
||||
return None, f"{type(err).__name__}: {text}" if text else type(err).__name__
|
||||
|
||||
|
||||
_UTC_NAMES = {"UTC", "ETC/UTC", "GMT", "ETC/GMT", "UCT", "ETC/UCT",
|
||||
"UNIVERSAL", "ETC/UNIVERSAL", "ZULU", "ETC/ZULU"}
|
||||
|
||||
|
||||
async def maint_site_tz(session, token: str):
|
||||
"""MAINT-10: the zone the maintenance start hour is read in, logged once."""
|
||||
zone, why = await ha_time_zone(session, token)
|
||||
tz, name, source, why = site_zone(zone, why, os.environ.get("TZ"))
|
||||
if source == "ha":
|
||||
_LOG.info("Maintenance start hour is local time in %s, from Home "
|
||||
"Assistant.", name)
|
||||
elif source == "tz" and name.strip().upper() in _UTC_NAMES:
|
||||
# The Supervisor sets TZ to its copy of HA's zone, or to UTC if it has
|
||||
# none: HA did not answer here, and UTC may not be the site's clock.
|
||||
_LOG.warning("Maintenance start hour uses UTC, because Home Assistant "
|
||||
"did not answer. Restart the add-on when it is up.")
|
||||
_LOG.debug("maintenance time zone: %s", why)
|
||||
elif source == "tz":
|
||||
_LOG.info("Maintenance start hour is local time in %s, from the "
|
||||
"add-on's time zone. Home Assistant gave none: %s.", name, why)
|
||||
else:
|
||||
_LOG.warning("Maintenance start hour uses UTC, because no time zone "
|
||||
"was found. Set the time zone in Home Assistant.")
|
||||
_LOG.debug("maintenance time zone: %s", why)
|
||||
return tz
|
||||
|
||||
|
||||
async def amain() -> None:
|
||||
opts = load_options()
|
||||
logging.basicConfig(
|
||||
@@ -7114,7 +7484,8 @@ async def amain() -> None:
|
||||
pub = _NoMqtt()
|
||||
|
||||
stop = asyncio.Event()
|
||||
controller = Controller(opts, hass, store, pub, stop=stop)
|
||||
controller = Controller(opts, hass, store, pub, stop=stop,
|
||||
site_tz=await maint_site_tz(session, _tok))
|
||||
if callable(getattr(pub, "bind", None)):
|
||||
pub.bind(asyncio.get_running_loop(), controller.mqtt_command)
|
||||
runner = await web.start(controller, port=INGRESS_PORT)
|
||||
|
||||
@@ -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 ----------------------------------------------------------
|
||||
|
||||
@@ -793,6 +793,12 @@ def row_peak_guard_blind(why: str) -> str:
|
||||
f"meanwhile")
|
||||
|
||||
|
||||
def row_peak_guard_unmeasured() -> str:
|
||||
"""SAFETY-90. Before the first reading; that part counts as high use."""
|
||||
return ("Peak guard: part of this 15-minute period was not measured, "
|
||||
"counted as high use")
|
||||
|
||||
|
||||
def row_peak_guard_on(floor_w, limit_w, *, shadow: bool = False) -> str:
|
||||
if shadow:
|
||||
return (f"Peak guard: would ask for {watts(floor_w)} to stay under "
|
||||
@@ -912,6 +918,7 @@ HINTS = {
|
||||
"peak_on": "Peak guard started",
|
||||
"peak_off": "Peak guard idle",
|
||||
"peak_blind": "Peak guard cannot read the meter well",
|
||||
"peak_gap": "Meter is back, the peak guard counts the missed time as high use",
|
||||
"peak_saturated": T_PEAK_SATURATED,
|
||||
"peak_reserve": T_PEAK_RESERVE,
|
||||
"meter_frozen": T_METER_FROZEN,
|
||||
@@ -1097,6 +1104,7 @@ def status_samples() -> list:
|
||||
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, True)),
|
||||
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, False)),
|
||||
("row_peak_guard_quiet", GREEN, row_peak_guard_quiet()),
|
||||
("row_peak_guard_unmeasured", AMBER, row_peak_guard_unmeasured()),
|
||||
*(("row_peak_guard_blind", AMBER, row_peak_guard_blind(k))
|
||||
for k in BLIND_WORDS),
|
||||
("row_peak_guard_on", AMBER, row_peak_guard_on(1200, 2500)),
|
||||
|
||||
@@ -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
|
||||
@@ -599,6 +601,57 @@ def blindness(projection, max_hold_s, frozen: bool) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
# SAFETY-90. Seconds of uncounted time below which nothing is filled: float
|
||||
# noise between the averager's sums and the wall clock, never a real gap.
|
||||
GAP_TOL_S = 1e-3
|
||||
|
||||
|
||||
def gap_w(ceiling_w, *seen_w) -> float:
|
||||
"""SAFETY-90: the watts a meter gap is counted at. The highest of the
|
||||
limit and `seen_w`, the import with the batteries at 0 W (grid + their
|
||||
discharge) just before and just after the gap; None entries are skipped.
|
||||
DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
|
||||
return max([float(ceiling_w)] + [float(w) for w in seen_w if w is not None])
|
||||
|
||||
|
||||
def fill_gaps(projection, *, tail_s, max_hold_s, unseen_s, gap_w, unseen_w,
|
||||
held_ws=0.0):
|
||||
"""SAFETY-90: `projection` with the seconds the averager did not count
|
||||
(a meter gap past `max_hold_s`) counted at `gap_w`, and those from before
|
||||
this run's first reading (`unseen_s`) at `unseen_w`. `held_ws` is added
|
||||
too: what the gap's first `max_hold_s`, credited at the reading before
|
||||
it, lacks to reach `gap_w`. Returns
|
||||
`(projection, gap seconds filled, unseen seconds filled)`.
|
||||
|
||||
`tail_s` is the age of the held reading: up to `max_hold_s` of it is left
|
||||
as before (the averager credits it once the next reading lands). Past
|
||||
that the gap is still open, nothing is filled and `blindness()` answers
|
||||
as before. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
|
||||
if projection.block_start is None or max_hold_s is None:
|
||||
return projection, 0.0, 0.0
|
||||
wall = float(projection.elapsed_s)
|
||||
tail = min(max(0.0, float(tail_s)), wall)
|
||||
if tail > float(max_hold_s):
|
||||
return projection, 0.0, 0.0
|
||||
covered = wall - uncovered_s(projection)
|
||||
missing = wall - covered - tail
|
||||
if missing <= GAP_TOL_S:
|
||||
missing = 0.0
|
||||
if held_ws <= 0.0:
|
||||
return projection, 0.0, 0.0
|
||||
unseen = min(missing, max(0.0, float(unseen_s)))
|
||||
gap = missing - unseen
|
||||
add_ws = gap * float(gap_w) + unseen * float(unseen_w) + float(held_ws)
|
||||
done = covered + missing
|
||||
return (replace(
|
||||
projection,
|
||||
projected_w=float(projection.projected_w) + add_ws / QUARTER_S,
|
||||
elapsed_w=((float(projection.elapsed_w) * covered + add_ws) / done
|
||||
if done > 0.0 else float(projection.elapsed_w)),
|
||||
covered_frac=(done / wall if wall > 0.0
|
||||
else float(projection.covered_frac))), gap, unseen)
|
||||
|
||||
|
||||
def discharge_permitted(soc, soc_floor) -> str | None:
|
||||
"""SAFETY-24. May the guard size a forced discharge, or why may it not?
|
||||
|
||||
|
||||
@@ -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.13.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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -129,6 +129,19 @@ finally:
|
||||
# cd4d9cb5b7e29ff4ab4fe65dab5106683d8a599f09323fbdee4018ad9b28fcd1.
|
||||
SAFETY78_N2_MAINT = \
|
||||
"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]}")
|
||||
@@ -136,10 +149,20 @@ 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), bounded by "
|
||||
"SAFETY-85",
|
||||
|
||||
@@ -3132,6 +3132,10 @@ check("...and between them the nineteen appends account for exactly the "
|
||||
# through this file's reading of main.py. It grades three of the eight appends.
|
||||
# The static pin above is what covers the other five.
|
||||
_b2, _restore_b2 = second_inverter(c_src)
|
||||
# SAFETY-91: B states a cut-out, as every roster entry must (rule 8). Without
|
||||
# one it is held back at its reserve and gives last, so A alone would carry
|
||||
# this cycle's 500 W and the broadcast check below could not see a split.
|
||||
_b2.bms_soc_floor = c_src.inverters[0].bms_soc_floor
|
||||
_n2_writes_before = len(c_src.hass.per_entity)
|
||||
try:
|
||||
_, _cl2 = cycle_claims(lambda: run_cycle(c_src))
|
||||
|
||||
@@ -716,10 +716,11 @@ check("the drain's commitment window is DERIVED from the phase machine's own "
|
||||
"timeout, so the gate and maintenance.py cannot drift apart",
|
||||
near(MAINTENANCE_DRAIN.commits_h, DRAIN_TIMEOUT_MIN / 60.0)
|
||||
and near(MAINTENANCE_CHARGE.commits_h, CHARGE_TIMEOUT_MIN / 60.0))
|
||||
# The shipped `maintenance_start_hour` is 10:00 local, so a drain started on
|
||||
# schedule runs into the evening - hours a morning-only day-ahead print has not
|
||||
# covered. That is the ticket's scenario and it is where the window is placed.
|
||||
START = D0 + timedelta(hours=MaintConfig().start_hour)
|
||||
# A drain started at 10:00 local runs into the evening - hours a morning-only
|
||||
# day-ahead print has not covered. That is the ticket's scenario, so the hour is
|
||||
# pinned here: 10 was the shipped default until MAINT-10 moved it to 19, and an
|
||||
# install that stored 10 still starts then.
|
||||
START = D0 + timedelta(hours=10)
|
||||
_hours = derived(lambda: committed_hours(START, MAINTENANCE_DRAIN))
|
||||
check(f"...and it commits {len(_hours) if not isinstance(_hours, _Raised) else _hours} "
|
||||
f"whole hours, one per hour of the timeout",
|
||||
|
||||
@@ -187,7 +187,7 @@ check("⭐ a draining battery that is dropped (stale link) adds no floor: B "
|
||||
"covers the 1500 W house, not the 2000 W drain rate, and the wait is "
|
||||
"logged once",
|
||||
"dropped inverter-a" in _l_rows[-1][7]
|
||||
and cmd(_l_rows[-1])["b"] == 1500.0
|
||||
and cmd(_l_rows[-1])["b"] == 1510.0
|
||||
and sum("cannot give power now" in e for e in _l.events) == 1)
|
||||
|
||||
# ============================================================================ #
|
||||
|
||||
@@ -559,11 +559,14 @@ def _drain_run():
|
||||
|
||||
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):
|
||||
|
||||
@@ -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,573 @@
|
||||
"""Runnable check for SAFETY-96: a battery the loop cannot move does not
|
||||
freeze the loop. `python3 test_safety96.py`
|
||||
|
||||
At N=2, after A's link went stale the site sum still counted A's power, so
|
||||
`compute()` saw the site "not following" and froze B (SAFETY-93 rig test:
|
||||
B held at 1200 W for ~236 cycles). After a charge timeout or Off at 6000 W
|
||||
it froze ~14 s while A came out of its charge. Now a battery out of the
|
||||
split, with refused writes, or settling after a step the loop did not make
|
||||
counts at its command; its real power reaches the loop through the meter.
|
||||
Drives the REAL Controller on a lagging battery (10 s, 2 s meter).
|
||||
DECISIONS/SAFETY-96-dropped-battery.md.
|
||||
|
||||
1. A's link drops while it discharges: A holds its last command (stuck), or
|
||||
its board goes to 0 W 25 s after the first refused write.
|
||||
2. A's link drops while it charges (maintenance).
|
||||
3. B's link drops; both drop.
|
||||
4. A drops, then comes back.
|
||||
5. A's charge times out, or A is switched Off, at 6000 W.
|
||||
6. N=1 is untouched; the rule's parts one by one.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import safety79_identity as s79 # noqa: E402
|
||||
import safety84_identity as s84 # noqa: E402
|
||||
from app import packmode # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.maintenance import CHARGE_TIMEOUT_MIN, IDLE, MaintResult # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
from named_absence import first, present # noqa: E402
|
||||
|
||||
fails = []
|
||||
SLEW = 400.0
|
||||
FROZEN = ("saturated-freeze", "freeze-released")
|
||||
OPT = dict(s84.OPT, peak_cap_w=3500)
|
||||
PLAIN = dict(s79.P1, peak_cap_w=3500)
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
# None on a tree without the fix, where this file prints the base numbers
|
||||
# and runs red.
|
||||
fixed = getattr(Controller, "_held_out_w", None)
|
||||
real_drop = getattr(Controller, "_reseed_drop", None)
|
||||
|
||||
|
||||
def without(fn):
|
||||
"""Run `fn` with SAFETY-96 taken out (the base behaviour)."""
|
||||
if fixed is None:
|
||||
return fn()
|
||||
Controller._held_out_w = lambda self, live: 0.0
|
||||
Controller._reseed_drop = lambda self, live: setattr(
|
||||
self, "_live_was", frozenset(live))
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
Controller._held_out_w, Controller._reseed_drop = fixed, real_drop
|
||||
|
||||
|
||||
def run(n=2, *, house=lambda t: 2400.0, dur=600.0, store=None, opt=None,
|
||||
soc=None, reject=None, back=None, wd=None, before=None, lag=10.0,
|
||||
blind=None, flaky=None, dead=None):
|
||||
"""Rows of one run. Writes to the units in `reject` (name -> from s) are
|
||||
refused from that time until `back` s; the add-on sees the refusal at
|
||||
once (a heartbeat write). A refused unit holds its last command, or with
|
||||
`wd` its board goes to 0 W `wd` s after the first refused write. B's
|
||||
reading is missing in the `blind` (from, to) window. `flaky(u, t)`:
|
||||
writes to u refused at t, each cycle's verdict seen (a heartbeat).
|
||||
`dead` (u, from s): u gives nothing from then on, whatever it is told.
|
||||
Each row: (t, plant commands, add-on commands, loop reason, site target,
|
||||
grid, circulation tripped)."""
|
||||
reject = reject or {}
|
||||
real_p, real_s, real_tick = rig._Hass.power, rig._Hass.set_number, rig.TICK
|
||||
|
||||
def refused(u, t):
|
||||
if flaky is not None:
|
||||
return flaky(u, t)
|
||||
return u in reject and t >= reject[u] and (back is None or t < back)
|
||||
|
||||
def power(self, u):
|
||||
st = self.__dict__.setdefault("_plant", {})
|
||||
a, tgt, tl = st.get(u, (0.0, 0.0, self.t))
|
||||
for k in range(int(round((self.t - tl) / 0.1))):
|
||||
now = tl + (k + 1) * 0.1
|
||||
c = self.cmd[u]
|
||||
if wd is not None and refused(u, now) and now - reject[u] >= wd:
|
||||
c = 0.0
|
||||
if dead is not None and u == dead[0] and now >= dead[1]:
|
||||
c = 0.0
|
||||
d = c - tgt
|
||||
tgt += math.copysign(min(abs(d), 80.0), d)
|
||||
a += (tgt - a) * (1 - math.exp(-0.1 / lag))
|
||||
st[u] = (a, tgt, self.t)
|
||||
return float(round(a))
|
||||
|
||||
async def set_number(self, e, v):
|
||||
if refused(e[-1], self.t):
|
||||
return False
|
||||
self.cmd[e[-1]] = float(v)
|
||||
return True
|
||||
|
||||
rows = []
|
||||
|
||||
def hook(c, t):
|
||||
for inv in c.inverters:
|
||||
if flaky is not None:
|
||||
inv.note_write(not flaky(inv.name[-1], t),
|
||||
rig.BASE + timedelta(seconds=t))
|
||||
elif refused(inv.name[-1], t) and inv.rejected_since is None:
|
||||
inv.rejected_since = rig.BASE + timedelta(seconds=t)
|
||||
if before is not None:
|
||||
before(c, t)
|
||||
if blind is not None and not getattr(c.hass, "_s96", False):
|
||||
read = c.hass.number
|
||||
|
||||
async def number(e, invert=False):
|
||||
if e == "sensor.batt_b" and blind[0] <= c.hass.t < blind[1]:
|
||||
return None
|
||||
return await read(e, invert)
|
||||
c.hass.number, c.hass._s96 = number, True
|
||||
(s84.feed(True) if opt is not None else s79._feed)(c, t)
|
||||
|
||||
def on_cycle(c, t):
|
||||
rows.append((t, tuple(sorted(c.hass.cmd.items())),
|
||||
tuple(inv.target for inv in c.inverters), c.loop_reason,
|
||||
c.target, c.hass.grid(), c.circ_wd.tripped,
|
||||
tuple(inv.batt for inv in c.inverters)))
|
||||
rig._Hass.power, rig._Hass.set_number, rig.TICK = power, set_number, 2.0
|
||||
try:
|
||||
_r, ctl = rig.run(n, extra=dict(opt if opt is not None else PLAIN),
|
||||
dur=dur, load=house, soc=soc or (lambda u, t: 60.0),
|
||||
cut=lambda u, t: False, before=hook,
|
||||
on_cycle=on_cycle, store=store or rig._Store())
|
||||
finally:
|
||||
rig._Hass.power, rig._Hass.set_number, rig.TICK = real_p, real_s, \
|
||||
real_tick
|
||||
return rows, ctl
|
||||
|
||||
|
||||
def freeze(rows, lo, hi=1e9):
|
||||
return sum(1 for r in rows if lo <= r[0] < hi and r[3] in FROZEN)
|
||||
|
||||
|
||||
def phantom(rows, k, lo, hi=1e9):
|
||||
"""Freeze cycles while battery `k` (the one taking over) reads within
|
||||
saturation_w (500 W) of what the loop last asked of it plus anything
|
||||
unplaced (last site target less the other's command): the freeze is
|
||||
then about the other battery, not about `k`'s own lag."""
|
||||
return sum(1 for a, b in zip(rows, rows[1:]) if lo <= b[0] < hi
|
||||
and b[3] in FROZEN
|
||||
and abs(a[4] - a[2][1 - k] - b[7][k]) <= 500.0)
|
||||
|
||||
|
||||
def ramp(rows, k, lo, hi=1e9):
|
||||
"""The largest change of unit `k`'s command (0 = A) between cycles."""
|
||||
return max(abs(b[2][k] - a[2][k]) for a, b in zip(rows, rows[1:])
|
||||
if lo < b[0] <= hi)
|
||||
|
||||
|
||||
def site_step(rows, lo, hi=1e9):
|
||||
return max(abs(b[4] - a[4]) for a, b in zip(rows, rows[1:])
|
||||
if lo < b[0] <= hi)
|
||||
|
||||
|
||||
def grid_err(rows, lo, hi=1e9):
|
||||
"""Mean |grid - aim| over [lo, hi) (aim -10 W)."""
|
||||
xs = [abs(r[5] + 10.0) for r in rows if lo <= r[0] < hi]
|
||||
return sum(xs) / len(xs)
|
||||
|
||||
|
||||
def trip_at(rows):
|
||||
"""When the circulation guard tripped (its own 0 W step is no ramp)."""
|
||||
return next((r[0] for r in rows if r[6]), 1e9)
|
||||
|
||||
|
||||
def tripped(rows):
|
||||
return rows[-1][6]
|
||||
|
||||
|
||||
def at(rows, t, k):
|
||||
return first((r[2][k] for r in rows if r[0] == t), f"row {t}")
|
||||
|
||||
|
||||
TABLE = []
|
||||
LAST = [None] # the controller of the last run with the fix
|
||||
|
||||
|
||||
def case(name, fn, lo, k, hi=1e9):
|
||||
"""Run `fn` with and without SAFETY-96, print and record the numbers:
|
||||
freeze cycles (and those not about `k`'s own lag), `k`'s largest step,
|
||||
the mean grid error, a circulation trip."""
|
||||
(now, LAST[0]), old = fn(), without(fn)[0]
|
||||
row = (name, freeze(now, lo, hi), freeze(old, lo, hi),
|
||||
phantom(now, k, lo, hi), phantom(old, k, lo, hi),
|
||||
ramp(now, k, lo, min(hi, trip_at(now) - 1)),
|
||||
ramp(old, k, lo, min(hi, trip_at(old) - 1)),
|
||||
grid_err(now, lo, hi), grid_err(old, lo, hi),
|
||||
tripped(now), tripped(old))
|
||||
TABLE.append(row)
|
||||
print(f" {name}: freeze {row[1]} (base {row[2]}), not about its own lag "
|
||||
f"{row[3]} (base {row[4]}); step {row[5]:.0f} (base {row[6]:.0f}) "
|
||||
f"W/cycle; grid error {row[7]:.0f} (base {row[8]:.0f}) W; "
|
||||
f"circulation trip {row[9]} (base {row[10]})")
|
||||
return now, old
|
||||
|
||||
|
||||
STUCK, BOARD = ("stuck", None), ("board 0 W", 25.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. A's link drops while it discharges (house 2400 W, each 1200 W)")
|
||||
for v, w in (STUCK, BOARD):
|
||||
now, old = case(f"A drop discharging, {v}",
|
||||
lambda w=w: run(reject={"a": 300.0}, wd=w), 300.0, 1)
|
||||
check(f"⭐ {v}: no freeze about A, B moves at most one slew step a "
|
||||
f"cycle, no circulation trip", phantom(now, 1, 300) == 0
|
||||
and ramp(now, 1, 300) <= SLEW and not tripped(now))
|
||||
check(f"non-vacuous ({v}): without the fix the loop freezes about A, or "
|
||||
f"B jumps more than a slew step at the drop",
|
||||
phantom(old, 1, 300) >= 10 or ramp(old, 1, 300) > SLEW)
|
||||
if w is None:
|
||||
check("stuck: B stays at its share, since A still gives its 1200 W "
|
||||
"(no freeze at all, grid within 15 W on average)",
|
||||
freeze(now, 300) == 0 and grid_err(now, 300) < 15.0
|
||||
and freeze(old, 300) >= 100)
|
||||
else:
|
||||
check("board 0 W: B ends up carrying the whole house (2410 W)",
|
||||
abs(now[-1][2][1] - 2410.0) <= 20.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. A's link drops while it charges (maintenance, house 1200 W)")
|
||||
|
||||
|
||||
def charging(**kw):
|
||||
return run(house=lambda t: 1200.0, dur=1500.0,
|
||||
store=s79.store_charging(2), opt=dict(
|
||||
OPT, maintenance_hold_min=600),
|
||||
soc=lambda u, t: 90.0 if u == "a" else 60.0, **kw)
|
||||
|
||||
|
||||
for v, w in (STUCK, BOARD):
|
||||
now, old = case(f"A drop charging, {v}",
|
||||
lambda w=w: charging(reject={"a": 1300.0}, wd=w),
|
||||
1300.0, 1)
|
||||
trip = trip_at(now)
|
||||
check(f"⭐ {v}: no freeze about A, and B climbs at most one slew step a "
|
||||
f"cycle and never charges (until any circulation trip)",
|
||||
phantom(now, 1, 1300, trip) == 0
|
||||
and ramp(now, 1, 1300, trip - 1) <= SLEW
|
||||
and min(r[2][1] for r in now if r[0] >= 1300) >= 0.0)
|
||||
check(f"non-vacuous ({v}): without the fix the loop freezes about A",
|
||||
phantom(old, 1, 1300) >= 3)
|
||||
if w is None:
|
||||
check("stuck (A charges 2000 W on its own): B reaches its 2500 W "
|
||||
"before the circulation trip, which still trips on the real "
|
||||
"4000 W going round (as on base)",
|
||||
max(r[2][1] for r in now if r[0] < trip) == 2500.0
|
||||
and tripped(now) and tripped(old))
|
||||
else:
|
||||
check("board 0 W: B carries the house (grid within 50 W from 100 s "
|
||||
"after the drop), no circulation trip",
|
||||
grid_err(now, 1400) < 50.0 and not tripped(now))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. B's link drops; both drop")
|
||||
for v, w in (STUCK, BOARD):
|
||||
now, old = case(f"B drop discharging, {v}",
|
||||
lambda w=w: run(reject={"b": 300.0}, wd=w), 300.0, 0)
|
||||
check(f"⭐ B drops ({v}): no freeze about B, A moves at most one slew "
|
||||
f"step a cycle, no circulation trip", phantom(now, 0, 300) == 0
|
||||
and ramp(now, 0, 300) <= SLEW and not tripped(now))
|
||||
for v, w in (STUCK, BOARD):
|
||||
now, old = case(f"both drop, {v}",
|
||||
lambda w=w: run(reject={"a": 300.0, "b": 300.0}, wd=w),
|
||||
300.0, 1)
|
||||
check(f"⭐ both drop ({v}): no freeze; both are commanded 0 W once "
|
||||
f"nothing can be placed", freeze(now, 300) == 0
|
||||
and now[-1][2] == (0.0, 0.0) and not tripped(now))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. A drops, then comes back")
|
||||
|
||||
|
||||
def rejoined(rows):
|
||||
return next((r[0] for r in rows if r[0] > 420 and r[2][0] != 0.0), 1e9)
|
||||
|
||||
|
||||
for v, w in (STUCK, BOARD):
|
||||
now, old = case(f"A drop and rejoin, {v}",
|
||||
lambda w=w: run(reject={"a": 300.0}, back=420.0, wd=w,
|
||||
dur=700.0), 300.0, 1)
|
||||
back = rejoined(now)
|
||||
check(f"rejoin ({v}): A is back in the split within 4 s of its link "
|
||||
f"returning (no refusal was learned while its writes were refused)",
|
||||
back <= 424.0 and not any("REFUSES" in e for e in LAST[0].events))
|
||||
print(f" A back in the split at {back:.0f} s; A "
|
||||
f"{[r[2][0] for r in now if back - 2 <= r[0] <= back + 6]}, B "
|
||||
f"{[r[2][1] for r in now if back - 2 <= r[0] <= back + 6]}; grid "
|
||||
f"{[round(r[5]) for r in now if back - 2 <= r[0] <= back + 6]}; "
|
||||
f"base grid {[round(r[5]) for r in old if back - 2 <= r[0] <= back + 6]}")
|
||||
a0 = first((r for r in now if r[0] == back - 2), "row before the rejoin")
|
||||
a1 = first((r for r in now if r[0] == back), "the rejoin row")
|
||||
check(f"⭐ rejoin ({v}): no freeze after it; the site target takes in only "
|
||||
f"A's own reading, then moves at most one slew step a cycle; the "
|
||||
f"grid stays within 250 W of its aim; no circulation trip",
|
||||
present(a0) and present(a1) and freeze(now, back) == 0
|
||||
and abs(a1[4] - a0[4] - a0[7][0]) <= SLEW
|
||||
and site_step(now, back) <= SLEW
|
||||
and max((abs(r[5] + 10.0) for r in now if r[0] >= back),
|
||||
default=1e9) < 250.0
|
||||
and not tripped(now))
|
||||
if w is None:
|
||||
check("non-vacuous (stuck): without the fix the grid swings over "
|
||||
"1000 W after the rejoin", max((abs(r[5] + 10.0) for r in old
|
||||
if r[0] >= back), default=0.0)
|
||||
>= 1000.0)
|
||||
|
||||
|
||||
|
||||
# Review: a battery that does refuse, behind a link that refuses one write
|
||||
# in three, is still found out (the count is kept over a refused write).
|
||||
_fl_rows, _fl = run(dur=700.0, dead=("a", 300.0),
|
||||
flaky=lambda u, t: u == "a" and t >= 300.0
|
||||
and int(t / 2) % 3 == 0)
|
||||
_fl_at = next((r[0] for r in _fl_rows if r[2][0] == 0.0 and r[0] > 300.0),
|
||||
None)
|
||||
_fl_end = first((r[2] for r in _fl_rows[-1:]), "the last row")
|
||||
print(f" A gives nothing from 300 s, 1 write in 3 refused: A's share is "
|
||||
f"taken off at {_fl_at} s; A {_fl.inverters[0].refused}, last "
|
||||
f"commands {_fl_end}")
|
||||
check("⭐ a battery that refuses behind a link refusing 1 write in 3: its "
|
||||
"refusal is still learned and B carries the house",
|
||||
_fl.inverters[0].refused == 1 and _fl_at is not None
|
||||
and _fl_at <= 400.0 and present(_fl_end)
|
||||
and abs(_fl_end[1] - 2410.0) <= 20.0)
|
||||
|
||||
# Review: a fast battery whose board goes to 0 W 1 s after the first refused
|
||||
# write reads ~0 W long before its link is stale (30 s): no refusal either.
|
||||
_ref5 = []
|
||||
_e5_rows, _e5 = run(reject={"a": 300.0}, back=420.0, wd=1.0, lag=1.0,
|
||||
dur=700.0,
|
||||
before=lambda c, t: _ref5.append(c.inverters[0].refused))
|
||||
check("fast battery, board 0 W 1 s after the first refused write: no "
|
||||
"refusal is learned, A is back in the split within 4 s of its link "
|
||||
"returning",
|
||||
not any(_ref5) and rejoined(_e5_rows) <= 424.0)
|
||||
|
||||
_seen = []
|
||||
|
||||
|
||||
def blind_seen(c, t):
|
||||
"""Record the last cycle: whether the loop ran, is A live, what settles."""
|
||||
if t > 340.0:
|
||||
_seen.append((t - 2.0, c.loop_reason, "inverter-a" in c.live_units(),
|
||||
dict(c._settle)))
|
||||
|
||||
|
||||
run(reject={"a": 300.0}, back=420.0, blind=(340.0, 470.0), dur=490.0,
|
||||
before=blind_seen)
|
||||
_back = first((x for x in _seen if x[1] != "not-running" and x[0] > 400),
|
||||
"the restart")
|
||||
print(f" B's reading missing 340-470 s, A back at 420 s: control holds from "
|
||||
f"{first((x[0] for x in _seen if x[1] == 'not-running'), '?')} s, "
|
||||
f"restarts at {_back[0]:.0f} s, settling then: {_back[3]}")
|
||||
check("A drops, then comes back while control holds (a reading missing): no "
|
||||
"rejoin at the restart, which starts from what is on the wire",
|
||||
any(x[1] == "not-running" and x[2] for x in _seen) and _back[3] == {})
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. A's charge times out, or A is switched Off, at a 6000 W house")
|
||||
|
||||
|
||||
def timed_out(end):
|
||||
"""A's charge passes CHARGE_TIMEOUT_MIN at `end` s; A then stays free
|
||||
(its cycle started today, so no restart) and B is not due."""
|
||||
s = s79.store_charging(2)
|
||||
s.data[unit_key("inverter-a", "phase_started")] = (
|
||||
rig.BASE + timedelta(seconds=end - CHARGE_TIMEOUT_MIN * 60 - 1)
|
||||
).isoformat()
|
||||
s.data[unit_key("inverter-a", "last_start_attempt")] = rig.BASE.isoformat()
|
||||
s.data[unit_key("inverter-b", "last_completed")] = rig.BASE.isoformat()
|
||||
return s
|
||||
|
||||
|
||||
def b_done():
|
||||
s = s79.store_charging(2)
|
||||
s.data[unit_key("inverter-b", "last_completed")] = rig.BASE.isoformat()
|
||||
return s
|
||||
|
||||
|
||||
def switch_off(at_t):
|
||||
def before(c, t):
|
||||
if t >= at_t:
|
||||
c.store.data[unit_key("inverter-a", packmode.USE_KEY)] = False
|
||||
return before
|
||||
|
||||
|
||||
def high(t):
|
||||
return 6000.0 if t >= 1760.0 else 1200.0
|
||||
|
||||
|
||||
ENDS = {
|
||||
"timeout": lambda: run(house=high, dur=1798.0, store=timed_out(1780.0),
|
||||
opt=dict(OPT, maintenance_hold_min=600),
|
||||
soc=lambda u, t: 90.0 if u == "a" else 60.0),
|
||||
"Off": lambda: run(house=high, dur=1798.0, store=b_done(),
|
||||
before=switch_off(1780.0),
|
||||
opt=dict(OPT, maintenance_hold_min=600,
|
||||
battery_modes_enabled=True),
|
||||
soc=lambda u, t: 90.0 if u == "a" else 60.0),
|
||||
}
|
||||
for k, fn in ENDS.items():
|
||||
now, old = case(f"{k} at 6000 W", fn, 1780.0, 1)
|
||||
print(f" B {[r[2][1] for r in now if 1778 <= r[0] <= 1796]}, base "
|
||||
f"{[r[2][1] for r in old if 1778 <= r[0] <= 1796]}")
|
||||
check(f"⭐ {k}: no freeze about A while it comes out of its charge; B "
|
||||
f"climbs one slew step a cycle and never charges",
|
||||
phantom(now, 1, 1780) == 0 and ramp(now, 1, 1780) <= SLEW
|
||||
and min(r[2][1] for r in now if r[0] >= 1780) >= 0.0)
|
||||
check(f"non-vacuous ({k}): without the fix the loop freezes about A and "
|
||||
f"B ends lower", phantom(old, 1, 1780) >= 3
|
||||
and at(now, 1796.0, 1) > at(old, 1796.0, 1))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n6. N=1 is untouched; the rule's parts")
|
||||
|
||||
|
||||
def digest(rows):
|
||||
return hashlib.sha256(repr(rows).encode()).hexdigest()
|
||||
|
||||
|
||||
def wave(t):
|
||||
return 2400.0 + 1500 * math.sin(t / 40)
|
||||
|
||||
|
||||
_n1 = [lambda: run(1, reject={"a": 300.0}, back=420.0, wd=25.0),
|
||||
lambda: run(1, house=high, dur=1798.0, store=timed_out(1780.0),
|
||||
opt=dict(OPT, maintenance_hold_min=600)),
|
||||
lambda: run(1, house=wave)]
|
||||
check("⭐ N=1 (a refused link that comes back, a charge timeout, a moving "
|
||||
"house): every row byte-identical with and without SAFETY-96",
|
||||
[digest(f()[0]) for f in _n1] == [digest(without(f)[0]) for f in _n1])
|
||||
check("N=2 healthy (a moving house, no drop): every row byte-identical with "
|
||||
"and without SAFETY-96",
|
||||
digest(run(house=wave)[0]) == digest(without(lambda: run(
|
||||
house=wave))[0]))
|
||||
|
||||
if fixed is None:
|
||||
print(f"\n{len(fails)} FAILED (no _held_out_w): " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
|
||||
_c = run(dur=4.0)[1]
|
||||
AB = ("inverter-a", "inverter-b")
|
||||
|
||||
|
||||
def held(batts, targets, live=AB, rejected=(), settle=None, now=100.0,
|
||||
start=None):
|
||||
for inv, b, t in zip(_c.inverters, batts, targets):
|
||||
inv.batt, inv.target = b, t
|
||||
inv.rejected_since = rig.BASE if inv.name in rejected else None
|
||||
_c._settle = dict(settle or {})
|
||||
_c._loop_start = dict(start or {})
|
||||
_c._now_mono = now
|
||||
return _c._held_out_w(set(live))
|
||||
|
||||
|
||||
check("a healthy pair: nothing held out, however far a reading lags",
|
||||
held((-2000.0, 400.0), (0.0, 400.0)) == 0.0)
|
||||
check("a dropped battery counts at its command (0 W), not its reading",
|
||||
held((-2000.0, 800.0), (0.0, 800.0), live=("inverter-b",)) == 2000.0)
|
||||
check("a battery whose writes are refused counts at its command",
|
||||
held((900.0, 800.0), (1200.0, 800.0), rejected=("inverter-a",))
|
||||
== 300.0)
|
||||
check("a settling battery counts at its command until its reading is within "
|
||||
"the deadband (15 W) of it, then it is the loop's again",
|
||||
held((-20.0, 800.0), (0.0, 800.0), settle={"inverter-a": 95.0})
|
||||
== 20.0 and "inverter-a" in _c._settle
|
||||
and held((-10.0, 800.0), (0.0, 800.0), settle={"inverter-a": 95.0})
|
||||
== 0.0 and "inverter-a" not in _c._settle)
|
||||
check("...but not on the cycle it starts",
|
||||
held((-10.0, 800.0), (0.0, 800.0), settle={"inverter-a": 100.0})
|
||||
== 10.0 and "inverter-a" in _c._settle)
|
||||
check("...or until PLANT_SETTLE_DOWN_S (23 s) has passed, whichever is first",
|
||||
held((-2000.0, 800.0), (0.0, 800.0), settle={"inverter-a": 77.1})
|
||||
== 2000.0 and held((-2000.0, 800.0), (0.0, 800.0),
|
||||
settle={"inverter-a": 77.0}) == 0.0)
|
||||
check("a reseeded start (SAFETY-93's charge end) is the command it counts at",
|
||||
held((-2000.0, 800.0), (-2000.0, 800.0), settle={"inverter-a": 99.0},
|
||||
start={"inverter-a": 0.0}) == 2000.0)
|
||||
|
||||
|
||||
def dropped(was, live, target, shares, batts=(0.0, 0.0), start=None):
|
||||
_c._live_was, _c.target, _c.i_w = (None if not was else frozenset(was),
|
||||
target, 123.0)
|
||||
_c._loop_start, _c._settle, _c._now_mono = dict(start or {}), {}, 50.0
|
||||
for inv, b in zip(_c.inverters, batts):
|
||||
inv.target, inv.batt = shares[inv.name[-1]], b
|
||||
_c._reseed_drop(set(live))
|
||||
return _c.target, _c.i_w
|
||||
|
||||
|
||||
check("a drop: the dropped battery's share leaves the start point, the "
|
||||
"integrator with it", dropped(AB, ("inverter-b",), 2400.0,
|
||||
{"a": 1200.0, "b": 1200.0})
|
||||
== (1200.0, 1200.0) and _c._live_was == frozenset({"inverter-b"}))
|
||||
_c._charge_ended = frozenset({"inverter-a"})
|
||||
check("...but a battery whose charge ended too is left to SAFETY-93's reseed",
|
||||
dropped(AB, ("inverter-b",), -2000.0, {"a": -2000.0, "b": 0.0})
|
||||
== (-2000.0, -2000.0))
|
||||
_c._charge_ended = frozenset()
|
||||
_free = MaintResult(IDLE, False, None, False, [])
|
||||
|
||||
|
||||
def charge_end(rejected_s):
|
||||
"""SAFETY-93's reseed for A (giving 1385 W under the loop, its charge
|
||||
suspended), A's writes refused for `rejected_s` s: (target, start)."""
|
||||
_c.target, _c.i_w, _c._charge_ended = 2770.0, 0.0, frozenset({AB[0]})
|
||||
_c._loop_start, _c._settle = {}, {}
|
||||
for inv in _c.inverters:
|
||||
inv.target, inv.rejected_since = 1385.0, None
|
||||
_c.inverters[0].rejected_since = datetime.now(timezone.utc) - timedelta(
|
||||
seconds=rejected_s)
|
||||
_c._reseed_charge_end([_free, _free])
|
||||
_c.inverters[0].rejected_since = None
|
||||
return _c.target, _c._loop_start[AB[0]]
|
||||
|
||||
|
||||
check("a charge that ends as its battery drops: the battery's start is 0 W "
|
||||
"(it is sent 0 W), not its last give; still live, its give stays",
|
||||
charge_end(31.0) == (1385.0, 0.0) and charge_end(5.0) == (2770.0, 1385.0))
|
||||
check("a rejoin: the battery enters the start point at its reading, and "
|
||||
"both settle while the split is made again",
|
||||
dropped(("inverter-b",), AB, 1200.0, {"a": 0.0, "b": 1200.0},
|
||||
batts=(-700.0, 1200.0)) == (500.0, 500.0)
|
||||
and _c._settle == {"inverter-a": 50.0, "inverter-b": 50.0})
|
||||
check("no change: nothing moves (the integrator either)",
|
||||
dropped(AB, AB, 1200.0, {"a": 600.0, "b": 600.0}) == (1200.0, 123.0)
|
||||
and dropped((), ("inverter-b",), 1200.0, {"a": 0.0, "b": 1200.0})
|
||||
== (1200.0, 123.0))
|
||||
_n1c = run(1, dur=4.0)[1]
|
||||
_n1c._live_was, _n1c.target, _n1c.i_w = frozenset({"inverter-a"}), 900.0, 5.0
|
||||
_n1c._reseed_drop(set())
|
||||
check("N=1: nothing moves and nothing is held out",
|
||||
(_n1c.target, _n1c.i_w) == (900.0, 5.0)
|
||||
and _n1c._held_out_w(set()) == 0.0)
|
||||
|
||||
print("\n case | freeze head/base | not about its own lag head/base | "
|
||||
"step W head/base | grid error W head/base | circulation trip head/base")
|
||||
for r in TABLE:
|
||||
print(f" {r[0]} | {r[1]}/{r[2]} | {r[3]}/{r[4]} | {r[5]:.0f}/{r[6]:.0f} |"
|
||||
f" {r[7]:.0f}/{r[8]:.0f} | {r[9]}/{r[10]}")
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all SAFETY-96 checks passed")
|
||||
@@ -0,0 +1,441 @@
|
||||
"""Runnable check for SAFETY-97: "the battery gives nothing" is graded against
|
||||
each battery's own idle reading. `python3 test_safety97.py`
|
||||
|
||||
B's power reading shows -95 to -118 W at idle (standby draw; live
|
||||
2026-10-03). The refusal evidence (SAFETY-52/73, shared since SAFETY-94)
|
||||
needed the reading under 100 W in magnitude, so a refusing B never latched,
|
||||
at N=2 ever, and at N=1 since SAFETY-94 (review F1).
|
||||
|
||||
Now each unit learns its idle reading (app/idle.py) and the band, the
|
||||
refusal's end and the stale-path refusal are centred on it.
|
||||
DECISIONS/SAFETY-97-idle-offset.md.
|
||||
|
||||
1. A refusing B is caught at N=1 and N=2, 1/2/5 s meters; A unchanged.
|
||||
2. A healthy B with its offset is never refused or flagged (SAFETY-94's
|
||||
scenarios), learned or restored.
|
||||
3. Learning: not while refusing or asked, bounded, restored, follows drift.
|
||||
4. The rules at their edges.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.CRITICAL)
|
||||
|
||||
import safety94_rig as rig # noqa: E402
|
||||
import app.idle as idle # noqa: E402
|
||||
import app.main as app_main # noqa: E402
|
||||
from app import control # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def without_fix(fn):
|
||||
"""Run `fn` with every idle reading at 0 W (the rule before SAFETY-97)."""
|
||||
real = idle.IdleReading.w
|
||||
idle.IdleReading.w = property(lambda self: 0.0)
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
idle.IdleReading.w = real
|
||||
|
||||
|
||||
def with_offset(n, fn):
|
||||
"""At N=1 the rig's only unit is "a": give it B's offset."""
|
||||
saved = dict(rig.OFFSET)
|
||||
if n == 1:
|
||||
rig.OFFSET["a"] = rig.OFFSET["b"]
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
rig.OFFSET.clear()
|
||||
rig.OFFSET.update(saved)
|
||||
|
||||
|
||||
REST, CUT = 400.0, 600.0 # rest (it learns), then ask, then cut
|
||||
REST_LOAD = (lambda t: 0.0 if t < REST else 3000.0)
|
||||
|
||||
|
||||
def caught(n, unit, tick, *, offset=True, dur=None, store=None, load=None,
|
||||
cut_at=CUT, **kw):
|
||||
"""Seconds from the cut to the refusal, or None, and the run."""
|
||||
cu = "a" if n == 1 else unit
|
||||
|
||||
def go():
|
||||
return rig.run(n, load or REST_LOAD, dur or cut_at + 300.0,
|
||||
tick=tick, noise=4.0, store=store,
|
||||
cut=lambda u, t: u == cu and t >= cut_at, **kw)
|
||||
r = with_offset(n, go) if offset and unit == "b" else go()
|
||||
ref = [t - cut_at for t, _u in r["refused"]]
|
||||
return (ref[0] if ref else None), r
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. a refusing battery, B with its -100 W idle reading (+-4 W noise)")
|
||||
TIMES, OLD = {}, {}
|
||||
for unit in ("a", "b"):
|
||||
for n in (1, 2):
|
||||
for tick in (1.0, 2.0, 5.0):
|
||||
TIMES[(unit, n, tick)] = caught(n, unit, tick)[0]
|
||||
OLD[(unit, n, tick)] = without_fix(
|
||||
lambda: caught(n, unit, tick)[0])
|
||||
for k in TIMES:
|
||||
print(f" refusing {k[0].upper()} N={k[1]} meter {k[2]:.0f} s: refused "
|
||||
f"{TIMES[k]} s after the cut (before {OLD[k]})")
|
||||
check("⭐ a refusing B is caught at N=1 and N=2: within 22 s plus one 10 s "
|
||||
"publish at 1 and 2 s meters, 12 cycles plus a publish at 5 s",
|
||||
all(TIMES[("b", n, t)] is not None
|
||||
and TIMES[("b", n, t)] <= (32.0 if t < 5 else 12 * 5.0 + 10.0)
|
||||
for n in (1, 2) for t in (1.0, 2.0, 5.0)))
|
||||
check("non-vacuous: before, a refusing B is never caught (300 s), at N=1 "
|
||||
"or N=2",
|
||||
all(OLD[("b", n, t)] is None for n in (1, 2) for t in (1.0, 2.0, 5.0)))
|
||||
check("⭐ a refusing A is caught exactly as before",
|
||||
all(TIMES[("a", n, t)] == OLD[("a", n, t)] is not None
|
||||
for n in (1, 2) for t in (1.0, 2.0, 5.0)))
|
||||
|
||||
# B refusing for 800 s: one refusal, held, not ended by its own idle reading.
|
||||
_t, r = caught(2, "b", 1.0, dur=CUT + 800.0)
|
||||
starts = [x for x in r["refused"] if x[1] == "inverter-b"]
|
||||
check("a refusing B stays refused until its probe: its idle reading is not "
|
||||
"read as taking power the other way (one refusal in 800 s)",
|
||||
len(starts) == 1 and r["c"].inverters[1].refused == 1)
|
||||
|
||||
# A register frozen at rest on B: refused or stale within 36 s of the ask.
|
||||
r = rig.run(2, lambda t: 2500.0 if t >= 600 else 0.0, 700.0, tick=1.0,
|
||||
publish_s=1.0, dither=True,
|
||||
freeze_at=lambda u: 300.0 if u == "b" else None)
|
||||
park = min([t for t, u in r["stale"] if u == "inverter-b"]
|
||||
+ [t for t, u in r["refused"] if u == "inverter-b"] or [None])
|
||||
print(f" B's register frozen at rest from 300 s, asked at 600 s: 0 W at "
|
||||
f"{park}")
|
||||
check("a register frozen at rest on B is caught as on A (within 36 s of "
|
||||
"the ask)", park is not None and park - 600.0 <= 36.0)
|
||||
# At a 5 s meter the stale trip comes first: its refusal is centred too.
|
||||
saved = dict(rig.OFFSET)
|
||||
rig.OFFSET["b"] = -110.0
|
||||
try:
|
||||
r = rig.run(2, lambda t: 2500.0 if t >= 600 else 0.0, 760.0, tick=5.0,
|
||||
publish_s=1.0, dither=True,
|
||||
freeze_at=lambda u: 300.0 if u == "b" else None)
|
||||
finally:
|
||||
rig.OFFSET.clear()
|
||||
rig.OFFSET.update(saved)
|
||||
st = [t for t, u in r["stale"] if u == "inverter-b"]
|
||||
ref = [t for t, u in r["refused"] if u == "inverter-b"]
|
||||
print(f" the same at a 5 s meter, B idle -110 W: stale {st}, refused {ref}")
|
||||
check("...and at a 5 s meter the stale trip on B's held -110 W is a refusal "
|
||||
"of B (one trip), not a repeating site stop",
|
||||
len(st) == 1 and ref == st)
|
||||
|
||||
# Review: the two centrings with no check of their own.
|
||||
# N=1, 5 s meter: the stale trip on a held -110 W idle reading is a refusal.
|
||||
saved = dict(rig.OFFSET)
|
||||
rig.OFFSET["a"] = -110.0
|
||||
try:
|
||||
r = rig.run(1, lambda t: 2500.0 if t >= 600 else 0.0, 800.0, tick=5.0,
|
||||
publish_s=1.0, dither=True, freeze_at=lambda u: 300.0)
|
||||
finally:
|
||||
rig.OFFSET.clear()
|
||||
rig.OFFSET.update(saved)
|
||||
st = [t for t, _u in r["stale"]]
|
||||
ref = [t for t, _u in r["refused"]]
|
||||
print(f" N=1, 5 s meter, register frozen on -110 W: stale {st}, refused "
|
||||
f"{ref}")
|
||||
check("...and at N=1 the same stale trip is a refusal (one trip), not a "
|
||||
"repeating site stop", len(st) == 1 and ref == st)
|
||||
|
||||
# SAFETY-42: B asked under 200 W while giving nothing (its idle reading) is
|
||||
# parked, not named as not delivering; A carries the house.
|
||||
def small_ask():
|
||||
return rig.run(2, lambda t: 150.0, 700.0, tick=1.0, noise=4.0,
|
||||
store={unit_key("inverter-b", "idle_w"): -100.0},
|
||||
cut=lambda u, t: u == "b")["c"].inverters[1]
|
||||
|
||||
|
||||
b, b_old = small_ask(), without_fix(small_ask)
|
||||
check("SAFETY-42: B giving nothing at a small ask, idle -100 W, is parked "
|
||||
"(not latched as not delivering in 700 s; with idle 0 W it is)",
|
||||
not b.delivery_wd.tripped and b.idle.value == -100.0
|
||||
and b_old.delivery_wd.tripped)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. a healthy B with its offset is never refused or flagged")
|
||||
SHAPES = ((10.0, False), (1.0, True))
|
||||
NAMES = ("rest_step", "drain_start", "drain_charge", "reversal")
|
||||
SAVED = {"b": {unit_key("inverter-b", "idle_w"): -100.0},
|
||||
"a": {unit_key("inverter-a", "idle_w"): -100.0}}
|
||||
bad = []
|
||||
runs = 0
|
||||
for n in (1, 2):
|
||||
for name in NAMES:
|
||||
for tick in (1.0, 2.0, 5.0):
|
||||
for pub, dith in SHAPES:
|
||||
for store in (None, SAVED["a" if n == 1 else "b"]):
|
||||
s, f, _r = with_offset(n, lambda: rig.counts(
|
||||
n, name, tick=tick, publish_s=pub, dither=dith,
|
||||
store=store))
|
||||
runs += 1
|
||||
if s or f:
|
||||
bad.append((n, name, tick, pub, bool(store), s, f))
|
||||
print(f" {runs} runs, stale or refused: {bad}")
|
||||
check("⭐ SAFETY-94's scenarios with B's offset (N=1 and N=2, 1/2/5 s "
|
||||
"meters, both register shapes, idle learned or restored): no stale "
|
||||
"trip and no refusal", not bad and runs == 96)
|
||||
rev = []
|
||||
for n in (1, 2):
|
||||
for tau in (10.0, 20.0):
|
||||
for charge in (-1500.0, -2500.0):
|
||||
for house in (300.0, 800.0, 2000.0):
|
||||
x = with_offset(n, lambda: rig.run(
|
||||
n, rig.reversal(300.0, charge, house), 600.0, tau=tau,
|
||||
tick=1.0, store=SAVED["a" if n == 1 else "b"]))
|
||||
if x["stale"] or x["refused"]:
|
||||
rev.append((n, tau, charge, house))
|
||||
check("⭐ a charge reversing into a discharge (lag 10 and 20 s, 1 s meter) "
|
||||
"with B's idle reading restored is never refused or flagged",
|
||||
not rev)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. learning the idle reading")
|
||||
_t, r = caught(2, "b", 1.0)
|
||||
c = r["c"]
|
||||
check("learned during the rest: A about 0 W, B about -100 W (used: -75 W), "
|
||||
"and saved",
|
||||
abs(c.inverters[0].idle_w) < 5
|
||||
and abs(c.inverters[1].idle.value + 100) < 5
|
||||
and c.inverters[1].idle_w == -idle.IDLE_MAX_W
|
||||
and abs(c.store.data.get(unit_key("inverter-b", "idle_w"), 0) + 100)
|
||||
< 5)
|
||||
|
||||
# A refusing B from the start, no rest, nothing saved: nothing to learn from.
|
||||
_t, r = caught(2, "b", 1.0, load=lambda t: 3000.0, cut_at=60.0, dur=1200.0)
|
||||
check("a battery refusing from the start teaches nothing: asked the whole "
|
||||
"time, then held at 0 W by its refusal (none learned, none saved)",
|
||||
r["c"].inverters[1].idle.value is None
|
||||
and unit_key("inverter-b", "idle_w") not in r["c"].store.data)
|
||||
# ... which is why the value is saved: restored, the same battery is caught.
|
||||
t_saved, _r = caught(2, "b", 1.0, load=lambda t: 3000.0, cut_at=60.0,
|
||||
store=SAVED["b"])
|
||||
t_none, _r = caught(2, "b", 1.0, load=lambda t: 3000.0, cut_at=60.0)
|
||||
print(f" refusing from the start: saved idle {t_saved} s, none {t_none}")
|
||||
check("⭐ after a restart the saved idle reading is used: a B refusing from "
|
||||
"the start is caught in 32 s; with nothing saved it is not",
|
||||
t_saved is not None and t_saved <= 32.0 and t_none is None)
|
||||
|
||||
# Not learned while refused: a refusing B reading -160 W (in the band) does
|
||||
# not pull its idle reading during the 0 W its refusal holds.
|
||||
class _P(rig.Plant):
|
||||
def advance(self, t):
|
||||
super().advance(t)
|
||||
for u in self.units:
|
||||
if self.cut(u, self.t):
|
||||
self.reg[u] = -160.0
|
||||
|
||||
|
||||
real_plant = rig.Plant
|
||||
rig.Plant = _P
|
||||
try:
|
||||
_t, r = caught(2, "b", 1.0, dur=CUT + 800.0)
|
||||
finally:
|
||||
rig.Plant = real_plant
|
||||
b = r["c"].inverters[1]
|
||||
print(f" refusing B reading -160 W: refused at {_t} s, idle "
|
||||
f"{b.idle.value:.1f}")
|
||||
check("⭐ not learned while refused: 800 s held at 0 W reading -160 W leaves "
|
||||
"B's idle reading at -100 W",
|
||||
_t is not None and b.refused and abs(b.idle.value + 100.0) < 1.0)
|
||||
|
||||
# Not learned while control is stopped (another owner may drive the battery)
|
||||
# or while the unit's sign watchdog has latched.
|
||||
class _Stopped(app_main.Controller):
|
||||
auto = property(lambda self: False, lambda self, v: None)
|
||||
|
||||
|
||||
rig.Controller = _Stopped
|
||||
try:
|
||||
r = rig.run(2, lambda t: 0.0, 600.0, tick=1.0)
|
||||
finally:
|
||||
rig.Controller = app_main.Controller
|
||||
r2 = rig.run(2, lambda t: 0.0, 600.0, tick=1.0,
|
||||
store={unit_key("inverter-b", "sign_tripped"): True})
|
||||
check("not learned while control is stopped, nor on a unit whose sign "
|
||||
"watchdog has latched (600 s at 0 W); its sibling learns",
|
||||
all(i.idle.value is None for i in r["c"].inverters)
|
||||
and r2["c"].inverters[1].idle.value is None
|
||||
and r2["c"].inverters[0].idle.value is not None)
|
||||
|
||||
# A battery capped at 150 W under a big ask is not idle.
|
||||
x = idle.IdleReading()
|
||||
for t in range(0, 3600):
|
||||
x.observe(150.0, 2000.0, float(t), True)
|
||||
check("a reading under a command over 50 W is never a sample (a capped "
|
||||
"battery is not idle)", x.value is None and x.w == 0.0)
|
||||
|
||||
x = idle.IdleReading()
|
||||
for t in range(0, 239):
|
||||
x.observe(-100.0, 10.0, float(t), True)
|
||||
first = x.value
|
||||
x.observe(-100.0, 10.0, 239.0, True)
|
||||
x.observe(-100.0, 10.0, 240.0, True)
|
||||
check("the first value needs 120 s asked ~0 and then 120 s of samples",
|
||||
first is None and abs(x.value + 100.0) < 1e-6)
|
||||
y = idle.IdleReading()
|
||||
for t in range(0, 400):
|
||||
y.observe(-100.0, 60.0 if t % 100 == 0 else 0.0, float(t), True)
|
||||
check("a command over 50 W restarts the quiet time (a 60 W blip every "
|
||||
"100 s: never learned)", y.value is None)
|
||||
z = idle.IdleReading()
|
||||
for t in range(0, 400):
|
||||
z.observe(-100.0, 0.0, float(t), False)
|
||||
check("nothing is learned while learning is not allowed (refused, stale, "
|
||||
"tripped or stopped)", z.value is None)
|
||||
|
||||
for t in range(240, 300):
|
||||
x.observe(-130.0, 0.0, float(t), True)
|
||||
after_min = x.value
|
||||
for t in range(300, 240 + 3600):
|
||||
x.observe(-130.0, 0.0, float(t), True)
|
||||
print(f" drift -100 -> -130 W: {after_min:.1f} after 60 s, {x.value:.1f} "
|
||||
f"after an hour")
|
||||
check("drift is followed slowly: under 2 W in the first minute, most of it "
|
||||
"in an hour", -102.0 < after_min < -100.0 and -130 < x.value < -124)
|
||||
|
||||
o = idle.IdleReading()
|
||||
for t in range(0, 600):
|
||||
o.observe(-300.0, 0.0, float(t), True)
|
||||
p = idle.IdleReading()
|
||||
for t in range(0, 600):
|
||||
p.observe(2000.0 if t < 300 else -240.0, 0.0, float(t), True)
|
||||
check("a reading beyond 250 W is not a sample (-300 W: never learned; a "
|
||||
"register held at 2000 W then -240 W: -240 W)",
|
||||
o.value is None and p.value is not None and abs(p.value + 240.0) < 1e-6)
|
||||
check("a saved value is clamped to 250 W, the band's centre to 75 W, and "
|
||||
"garbage is ignored",
|
||||
idle.IdleReading(900).value == idle.IDLE_SAMPLE_W
|
||||
and idle.IdleReading(900).w == idle.IDLE_MAX_W
|
||||
and idle.IdleReading(-900.0).w == -idle.IDLE_MAX_W
|
||||
and idle.IdleReading(-60.0).w == -60.0
|
||||
and idle.IdleReading("x").value is None
|
||||
and idle.IdleReading(float("nan")).value is None)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3b. a wrong idle reading (owner, 2026-10-04: centre clamped to 75 W)")
|
||||
|
||||
|
||||
def wrong_idle(n, unit, err, restore):
|
||||
"""Seconds from the cut to the refusal when the idle reading is `err` W
|
||||
off: taught wrong during the rest, or restored wrong."""
|
||||
cu = "a" if n == 1 else unit
|
||||
true = -100.0 if unit == "b" else 0.0
|
||||
if restore:
|
||||
return caught(n, unit, 1.0, store={
|
||||
unit_key(f"inverter-{cu}", "idle_w"): true + err})[0]
|
||||
|
||||
class _W(rig.Plant):
|
||||
def advance(self, t):
|
||||
rig.OFFSET[cu] = true + (err if t < REST else 0.0)
|
||||
super().advance(t)
|
||||
saved, real = dict(rig.OFFSET), rig.Plant
|
||||
rig.Plant = _W
|
||||
try:
|
||||
return caught(n, unit, 1.0)[0]
|
||||
finally:
|
||||
rig.Plant = real
|
||||
rig.OFFSET.clear()
|
||||
rig.OFFSET.update(saved)
|
||||
|
||||
|
||||
A_WRONG = {(n, e, rs): wrong_idle(n, "a", e, rs) for n in (1, 2)
|
||||
for e, rs in ((-250.0, False), (-150.0, False), (150.0, False),
|
||||
(250.0, False), (-250.0, True), (200.0, True),
|
||||
(250.0, True))}
|
||||
print(f" A (reads about 0 W) refusing, idle off by E: {A_WRONG}")
|
||||
check("⭐ a battery reading about 0 W while refusing is caught whatever idle "
|
||||
"reading was learned or restored (A, E -250..+250 W, N=1 and N=2)",
|
||||
all(v is not None and v <= 32.0 for v in A_WRONG.values()))
|
||||
check("⭐ ...including a wrong saved idle_w of +200 W on A",
|
||||
all(A_WRONG[(n, 200.0, True)] is not None for n in (1, 2)))
|
||||
B_WRONG = {(n, e): wrong_idle(n, "b", e, False) for n in (1, 2)
|
||||
for e in (-150.0, -50.0, 90.0)}
|
||||
print(f" B refusing, idle taught wrong by E: {B_WRONG}")
|
||||
check("B is caught across -150..+90 W of error in its idle reading",
|
||||
all(v is not None and v <= 32.0 for v in B_WRONG.values()))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. the rules at their edges")
|
||||
|
||||
|
||||
class _Inv:
|
||||
def __init__(self, idle_w):
|
||||
self.batt, self.ref_count, self.ref_since, self.ref_sign = 0.0, 0, None, 0
|
||||
self.ref_from, self.idle_w = None, idle_w
|
||||
|
||||
def standing_w(self):
|
||||
return 800.0
|
||||
|
||||
|
||||
class _C:
|
||||
_now_mono = 0.0
|
||||
|
||||
|
||||
def evidence(readings, idle_w):
|
||||
c, inv = _C(), _Inv(idle_w)
|
||||
got = []
|
||||
for i, b in enumerate(readings):
|
||||
c._now_mono, inv.batt = float(i), b
|
||||
got.append(app_main.Controller._gave_nothing(c, inv))
|
||||
return got
|
||||
|
||||
|
||||
flat_b = evidence([-104.0 + (i % 3) for i in range(30)], -100.0)
|
||||
flat_b0 = evidence([-104.0 + (i % 3) for i in range(30)], 0.0)
|
||||
gives = evidence([0.0] * 30, -100.0)
|
||||
takes = evidence([-200.0] * 30, -100.0)
|
||||
check("the band is centred on the idle reading: B flat at -104 W is evidence "
|
||||
"at 22 s with idle -100 W and never with idle 0 W; reading 0 W (giving "
|
||||
"100 W) or -200 W (taking 100 W) is not",
|
||||
1 in flat_b and flat_b.index(1) == 22 and not any(flat_b0)
|
||||
and not any(gives) and not any(takes))
|
||||
|
||||
T = control.Tuning(max_w=4000.0, slew_w=400.0)
|
||||
args = dict(prev_w=1200.0, grid_w=1200.0, tuning=T,
|
||||
sat_count=control.SETTLE_CYCLES, i_w=1200.0, refused=0,
|
||||
gave_nothing=True)
|
||||
check("compute(): the N=1 engage is centred on idle_w (-104 W refuses with "
|
||||
"idle -100, not with 0; -4 W refuses with idle 0, not with -150)",
|
||||
control.compute(actual_w=-104.0, idle_w=-100.0, **args).refused == 1
|
||||
and control.compute(actual_w=-104.0, **args).refused == 0
|
||||
and control.compute(actual_w=-4.0, **args).refused == 1
|
||||
and control.compute(actual_w=-4.0, idle_w=-150.0, **args).refused == 0)
|
||||
args.update(refused=-1, i_w=-1200.0, prev_w=0.0, grid_w=-1200.0)
|
||||
check("compute(): a charge refusal ends when the battery takes 100 W more "
|
||||
"than its idle reading, not at its idle reading",
|
||||
control.compute(actual_w=-110.0, idle_w=-100.0, **args).refused == -1
|
||||
and control.compute(actual_w=-205.0, idle_w=-100.0, **args).refused
|
||||
== 0
|
||||
and control.compute(actual_w=-110.0, **args).refused == 0)
|
||||
check("the band's centre stays within 75 W of 0 W, so the band keeps 0 W "
|
||||
"+-25 W; samples to 250 W",
|
||||
idle.IDLE_MAX_W == 75.0 and idle.IDLE_SAMPLE_W == 250.0
|
||||
and control.REFUSED_BELOW_W - idle.IDLE_MAX_W >= 25.0
|
||||
and idle.IDLE_ASK_W < control.PROBE_W)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -1631,7 +1631,10 @@ def _sign_site(hass, store=None, **over):
|
||||
_P(), stop=asyncio.Event())
|
||||
b = Inverter("inverter-b", setpoint_entity="number.sp_b",
|
||||
soc_entity="sensor.soc_b", batt_entity="sensor.batt_b",
|
||||
max_w=B_RATING_HYPOTHESIS, store=c.store)
|
||||
max_w=B_RATING_HYPOTHESIS, store=c.store,
|
||||
# SAFETY-91: a cut-out, as rule 8 requires; without one B is
|
||||
# held back at its reserve and gets no share to reverse.
|
||||
bms_soc_floor=c.inverters[0].bms_soc_floor)
|
||||
b.dev_min, b.dev_max = -B_RATING_HYPOTHESIS, B_RATING_HYPOTHESIS
|
||||
c.inverters.append(b)
|
||||
c.auto = True
|
||||
|
||||
@@ -0,0 +1,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