Compare commits

..
Author SHA1 Message Date
admin 3a54b253cb Merge pull request 'Deploy 0.14.0 to master' (#170) from deploy-0.14.0 into master
Deploy 0.14.0 to master (#170)
2026-10-03 12:32:16 +02:00
admin 8b9680fc35 Merge pull request 'Deploy 0.13.0 to master' (#166) from deploy-0.13.0 into master
Reviewed-on: #166
2026-10-02 15:26:46 +02:00
admin 3ce18708b6 PKG-18: ship 0.12.0 to master, so TEL-12's pv_entities can reach an installed 0.11.0 (#118) 2026-09-24 23:22:34 +02:00
adminandClaude Opus 5 78acb558ff PKG-17: ship 0.11.0 to master, so SAFETY-56's wording fix and TEL-11's pv_entity can reach an installed 0.10.1 (#115)
master is what the Home Assistant Supervisor clones, and the Supervisor
compares config.yaml version strings - so 0.11.0 could not be offered to an
installed 0.10.1 without this bump reaching master.

Carries SAFETY-56 (#112), TEL-11 (#113), the CI-9 follow-up (#102) and SEC-2's
docs/RELEASE.md (#108), by way of the 0.11.0 cut (#114).

NOT A FAST-FORWARD. master was not an ancestor of release/1.0: 0f14c2a, the
pre-SEC-2 direct push, exists only on master. Its tree is the same object as
the merge base b0b24e5 (c1fef26), so it carries no content delta, and
`git merge-tree --write-tree` over the two branches returns a tree bit-identical
to cccb2fa's own (6670502). This merge commit therefore contributes exactly
release/1.0's tree - master synthesises nothing.

Reviewed as the release decision, not a re-review of content. Verified from the
remote: slug goodwe_controller and every identity key unchanged (cross-checked
against the live box's Supervisor export), version 0.10.1 -> 0.11.0, and the
green is this PR's own - run 828 / job 1953, path checks.yml@refs/pull/115/head,
PRNUM 115, GRADED refs/pull/115/head merged into master, run_attempt 1, 31 test
files all passing in 8m01s of real work.

predeploy_check.py re-run against the owner's current options (exported
2026-09-24 20:02, 75 keys, pv_entity unset): CLEARED, exit 0. That retires the
stale-2026-09-07-export warning in the pull request body. Option surface
75 -> 76 with only pv_entity added, str? defaulting to "", no key removed, no
default moved, no rule added to validate.py - no SystemExit(78) on this hop.

Merging is not installing. No tag is cut here and `ha store reload` /
`ha addons update` have not been run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 20:28:35 +02:00
glenn schrooyenandClaude Opus 5 0f14c2a5de PKG-16: ship 0.10.1 to master, so the Supervisor can offer SAFETY-57's fix
master is what Home Assistant clones. SAFETY-57 fixed a deadlock that parked
the reference install's battery for ~13 minutes while the site exported
2.4 kW, and the Supervisor compares config.yaml version strings - so the fix
could not reach an installed 0.10.0 without this bump reaching master.

Brings SAFETY-57 (#104) and the 0.10.1 cut (#105). master was a strict
ancestor; no conflict was possible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-22 15:54:29 +02:00
36 changed files with 137 additions and 3943 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
# The admin/goodwe commit CI tests against. Bump: set to the full SHA of admin/goodwe release/1.0 HEAD (README.md, "CI and the infra pin").
3b4ff84757f9c2c5b63a4b4a53e593ccaeafb847
0c04488a9194039c2bf851b680a8ffc53beff671
-36
View File
@@ -1,41 +1,5 @@
# 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.**
@@ -1,67 +0,0 @@
# 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.
@@ -1,81 +0,0 @@
# 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.
@@ -1,102 +0,0 @@
# 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).
@@ -1,59 +0,0 @@
# 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.
@@ -1,118 +0,0 @@
# 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).
+9 -40
View File
@@ -230,7 +230,7 @@ The label and description each setting shows on the Configuration tab, in tab or
| Tariff | Trading charges at most (%) | `arbitrage_soc_ceiling` | Price trading will not charge the battery above this level. |
| Maintenance | Run the monthly battery cycle | `maintenance_enabled` | Once a month, empty the battery deeply, charge it full and hold it there, so its level reading stays accurate. Off by default. |
| Maintenance | Minimum days between cycles | `maintenance_interval_days` | The shortest gap between two maintenance cycles. |
| Maintenance | Start hour (local time) | `maintenance_start_hour` | The hour on your local clock, 0 to 23, when a due cycle starts. 19 means 19:00. |
| Maintenance | Start hour | `maintenance_start_hour` | The local hour, 0 to 23, at which a due cycle starts. |
| 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 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 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 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,11 +600,7 @@ 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. 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).
is not refusing and keeps its request.
**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
@@ -1643,30 +1639,13 @@ 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` | 19 | Local hour a due cycle begins (see below) |
| `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), 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 |
@@ -1700,8 +1679,6 @@ 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
@@ -1771,17 +1748,10 @@ 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` 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.
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.
**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.
@@ -3487,8 +3457,7 @@ 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. The 35 s counts
only while the command asks the reading to move (SAFETY-94).
with both possible causes, is on the DEBUG line after it.
- **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**
+2 -16
View File
@@ -484,8 +484,6 @@ 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).
@@ -499,13 +497,6 @@ 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
@@ -826,18 +817,13 @@ 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 - idle_w) < REFUSED_BELOW_W
and gave_nothing is not False):
and abs(actual_w) < REFUSED_BELOW_W):
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 - idle_w) >= PROBE_ANSWER_W:
elif refused and refused * actual_w >= PROBE_ANSWER_W:
refused = 0 # it delivers: a probe answered,
# or a slow reading arriving
elif refused:
+7 -53
View File
@@ -87,10 +87,6 @@ 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
@@ -220,8 +216,6 @@ 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":
@@ -378,9 +372,7 @@ 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. SAFETY-94: the "
"35 s counts only reads at which the command asks the "
"register to move"),
"again from inside the state it creates"),
"soc": Bound(
signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)",
unchanged_s=float("inf"),
@@ -424,9 +416,11 @@ 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. 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.
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.
⚠️ 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
@@ -457,11 +451,6 @@ 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
@@ -510,7 +499,6 @@ 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:
@@ -522,35 +510,6 @@ 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.
@@ -584,12 +543,7 @@ class Freshness:
if self.stale or self.graded_from is None:
return
held_s = self.graded_s(now_mono)
# 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:
if held_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()`
+9 -14
View File
@@ -20,7 +20,7 @@ from datetime import datetime, timedelta, timezone
from . import arbitrage, messages, peakguard, uitext
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
from .maintenance import IDLE as M_IDLE, next_slot_start
from .maintenance import IDLE as M_IDLE
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 in the
site's zone exactly as the control cycle reads it (MAINT-10)."""
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."""
runs, lines = [], []
waiting = []
for i, (inv, m) in enumerate(zip(c.inverters, c.maints)):
@@ -459,9 +459,10 @@ 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 = next_slot_start(now, cfg.start_hour, m.tz)
if start is None:
continue
start = now.replace(hour=cfg.start_hour, minute=0, second=0,
microsecond=0)
if start + timedelta(hours=1) <= now:
start += timedelta(days=1)
due_at = m.next_due(now)
if due_at is not None and due_at > start:
continue
@@ -903,9 +904,7 @@ 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,
unmeasured=getattr(c, "peak_unmeasured_s", 0.0)
> 0.0)
out["line"] = line_period(out["period_w"], ceiling, c.shadow)
return out
@@ -1295,11 +1294,8 @@ def line_peak_not_covered() -> str:
return _t("peak_not_covered")
def line_period(period_w, limit_w, shadow, unmeasured=False) -> str:
def line_period(period_w, limit_w, shadow) -> 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
@@ -1373,7 +1369,6 @@ 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)),
-87
View File
@@ -1,87 +0,0 @@
"""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
+1 -10
View File
@@ -127,7 +127,6 @@ 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
@@ -322,21 +321,13 @@ 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.
# The evidence count is also used at N=1 (SAFETY-94); the rest at N>1.
# Used at N>1 only.
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:
+33 -303
View File
@@ -30,7 +30,6 @@ import functools
import json
import logging
import math
import os
import signal
import time
from datetime import datetime, timedelta, timezone
@@ -69,7 +68,7 @@ from . import messages
from .freeze import (
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
)
from .hass import CORE_API, HomeAssistant
from .hass import 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.
@@ -77,7 +76,7 @@ from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
roster_entries, roster_names)
from .maintenance import (
CHARGE, DRAIN, HOLD, IDLE, POWER_MOVING, MaintConfig, Maintenance,
MaintResult, arbitrate as maint_arbitrate, site_zone,
MaintResult, arbitrate as maint_arbitrate,
)
from .mqtt import (MODE_CONFIGS_KEY, MODE_LABELS, USE_OFF, USE_ON,
MqttPublisher, announced_units, mode_configs,
@@ -282,14 +281,6 @@ 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.
@@ -425,8 +416,7 @@ 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",
site_tz=None):
price_history_path: str = "/data/price_history.json"):
# 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:
@@ -667,8 +657,7 @@ 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),
tz=site_tz) # MAINT-10: the start hour's zone, from amain()
mirror_flat=(not _one_pack and i == 0))
for i, inv in enumerate(self.inverters)]
# SAFETY-02. ONE INSTANCE PER ACTUATOR - which is what watchdog.py's own
@@ -950,22 +939,6 @@ 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
@@ -1025,12 +998,6 @@ class Controller:
# 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.
@@ -1274,8 +1241,7 @@ 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 - self._site_idle_w())
>= _ctl.PROBE_ANSWER_W)
gave = self.batt_refused * batt_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")
@@ -1293,8 +1259,7 @@ 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 - inv.idle_w)
< _ctl.REFUSED_BELOW_W
and abs(inv.batt_fresh.held) < _ctl.REFUSED_BELOW_W
and abs(standing) > _ctl.PROBE_W):
self._now_mono = now_mono
self._unit_refusal_start(
@@ -1306,8 +1271,7 @@ 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 - inv.idle_w)
< _ctl.REFUSED_BELOW_W
and abs(inv.batt_fresh.held) < _ctl.REFUSED_BELOW_W
and abs(standing) > t.saturation_w):
self._now_mono = now_mono
self._refusal_start(1 if standing > 0 else -1,
@@ -1380,9 +1344,7 @@ class Controller:
for inv in self.inverters:
b = inv.batt
if inv.refused:
# 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):
if b is not None and inv.refused * b >= _ctl.PROBE_ANSWER_W:
self._unit_refusal_end(
inv, "the probe was answered"
if inv.probe_from is not None
@@ -1393,60 +1355,32 @@ 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 - inv.idle_w)
>= _ctl.PROBE_ANSWER_W else 0)
-inv.refused * b >= _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 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:
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):
self._unit_refusal_start(
inv, sign,
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))
f"asked {standing:+.0f} W, the battery gave {b:+.0f} W "
f"for {_ctl.SETTLE_CYCLES} cycles")
def _site_refused(self):
"""Owner decision 4: every unit refuses the same way, so the site law
@@ -2468,69 +2402,9 @@ class Controller:
continue
now_w = (float(res.setpoint_w or 0.0) if res.owns_setpoint
else max(float(inv.target), 0.0))
if not inv.is_live(datetime.now(timezone.utc))[0]:
now_w = 0.0 # SAFETY-96: out of the split, it is sent 0 W
self.target += now_w - float(inv.target)
# SAFETY-96: it settles from its charge to that command.
self._loop_start[inv.name] = now_w
self._settle[inv.name] = self._now_mono
self.i_w = self.target
def _reseed_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.
@@ -2784,74 +2658,6 @@ class Controller:
lo=p, reason=c.reason,
actuator=u)
def _fill_peak_gaps(self, proj, now, reading):
"""SAFETY-90: `proj` with a meter gap's uncounted seconds counted at
`peakguard.gap_w()` of the imports seen just before and just after it,
and those before this run's first reading likewise at the import seen
first (shown as not measured).
DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
self.peak_gap_s = self.peak_unmeasured_s = 0.0
avg = self.p1.averager
since = avg.held_since
if proj is None or proj.block_start is None or since is None:
return proj
if self._unseen is None and self.p1.last is not None:
self._unseen = [proj.block_start, max(0.0, (
since - proj.block_start).total_seconds() - avg.elapsed_s),
since, None]
seen = (None if self.grid is None or self.site_batt_w is None
else float(self.grid) + max(0.0, float(self.site_batt_w)))
if proj.block_start != self._gap_block:
self._gap_block, self._gap_w = proj.block_start, None
self._gap_filled_s = self._gap_held_ws = 0.0
cap = float(self.o.get("peak_cap_w", 3500))
ceiling = peakguard.quarter_ceiling_w(cap, reading)[0]
unseen_on = (self._unseen is not None
and self._unseen[0] == proj.block_start)
if unseen_on and self._unseen[3] is None and seen is not None:
# While the add-on was down the batteries were at 0 W, so the
# seconds before its first reading count like a gap, at the
# import seen first; a restored reading's held part is topped up.
self._unseen[3] = peakguard.gap_w(ceiling, seen)
if self._run_pre is not None:
s0, g0 = self._run_pre
h = (min(s0 + timedelta(seconds=avg.max_hold_s),
self._unseen[2])
- max(s0, proj.block_start)).total_seconds()
self._unseen_held_ws = max(0.0, h) * max(
0.0, self._unseen[3] - max(0.0, g0))
kw = dict(tail_s=(now - since).total_seconds(),
max_hold_s=avg.max_hold_s,
unseen_s=self._unseen[1] if unseen_on else 0.0,
unseen_w=(self._unseen[3] if unseen_on
and self._unseen[3] is not None else ceiling))
_p, gap_s, _u = peakguard.fill_gaps(proj, gap_w=0.0, **kw)
if gap_s > self._gap_filled_s + peakguard.GAP_TOL_S:
self._gap_w = peakguard.gap_w(ceiling, self._gap_w,
self._gap_seen_w, seen)
if self._gap_pre is not None:
# The averager credited the gap's first max_hold_s at the
# reading before it; top that part up to gap_w as well.
s0, g0 = self._gap_pre
h = (min(s0 + timedelta(seconds=avg.max_hold_s), since)
- max(s0, proj.block_start)).total_seconds()
self._gap_held_ws += max(0.0, h) * max(
0.0, self._gap_w - max(0.0, g0))
self.log_event(f"PEAK_GUARD_GAP: the meter missed {gap_s:.0f} s "
f"of this 15-minute period, counted at "
f"{self._gap_w:.0f} W",
hint=messages.hint("peak_gap"))
self._gap_filled_s = max(self._gap_filled_s, gap_s)
if seen is not None:
self._gap_seen_w = seen
if self.grid is not None:
self._gap_pre = (since, float(self.grid))
out, self.peak_gap_s, self.peak_unmeasured_s = peakguard.fill_gaps(
proj, gap_w=self._gap_w if self._gap_w is not None else ceiling,
held_ws=self._gap_held_ws + (self._unseen_held_ws if unseen_on
else 0.0), **kw)
return out
def _peak_over_drain(self, claims, maint_results) -> None:
"""SAFETY-83: at N=1 a peak floor above the drain rate outranks it.
@@ -3314,14 +3120,6 @@ 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))
@@ -3364,9 +3162,8 @@ 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 for %.0f s while asked "
"to move: a command over %.0f W that moved over %.0f W.",
inv.name,
_LOG.debug("%s: the limits are unchanged over %.0f s, a command "
"over %.0f W and a move by over %.0f W.", inv.name,
inv.batt_fresh.bound.unchanged_s,
inv.batt_fresh.bound.move_w,
inv.batt_fresh.bound.move_w)
@@ -4083,8 +3880,7 @@ 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) - inv.idle_w)
< _ctl.REFUSED_BELOW_W),
and abs(float(inv.batt)) < _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
@@ -4377,10 +4173,6 @@ 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:
@@ -4395,7 +4187,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=_reading,
reading=self.peak.peak(now),
# ⚠️ 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
@@ -4802,8 +4594,6 @@ class Controller:
mode_trade = self._mode_trade(claims, now)
self._mode_trade_was = mode_trade
self._charge_track(claims, maint_results)
_live96 = set(self.live_units())
self._loop_start = {}
if (self.auto or self.shadow) and self.inputs_ok:
# ⚠️ prev is the ARBITER's last output, not the loop's own last
# wish. If something outranked the loop, that is what the hardware
@@ -4830,9 +4620,6 @@ 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()
@@ -4852,9 +4639,6 @@ 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
@@ -4876,12 +4660,6 @@ 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.
@@ -4963,7 +4741,6 @@ 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
@@ -6562,11 +6339,6 @@ 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(
@@ -7334,47 +7106,6 @@ 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(
@@ -7484,8 +7215,7 @@ async def amain() -> None:
pub = _NoMqtt()
stop = asyncio.Event()
controller = Controller(opts, hass, store, pub, stop=stop,
site_tz=await maint_site_tz(session, _tok))
controller = Controller(opts, hass, store, pub, stop=stop)
if callable(getattr(pub, "bind", None)):
pub.bind(asyncio.get_running_loop(), controller.mqtt_command)
runner = await web.start(controller, port=INGRESS_PORT)
+4 -74
View File
@@ -34,7 +34,7 @@ simply not do.
import logging
import math
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone, tzinfo
from datetime import datetime, timedelta, timezone
from .store import MAINT_UNIT_KEYS, unit_key
@@ -112,9 +112,7 @@ RESTART_COOLDOWN_MIN = 90
MAINT_OPTION_DEFAULTS = {
"maintenance_enabled": False,
"maintenance_interval_days": 28,
# 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,
"maintenance_start_hour": 10,
# ⚠️ 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
@@ -179,69 +177,6 @@ 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
@@ -321,12 +256,8 @@ class Maintenance:
"""
def __init__(self, cfg: MaintConfig, store, unit: str | None = None,
name: str = "", mirror_flat: bool = False,
tz: tzinfo | None = None):
name: str = "", mirror_flat: bool = False):
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
@@ -400,8 +331,7 @@ class Maintenance:
about who was asking - so the IDLE branch of `tick()` calls this one.
"""
return (self.phase == IDLE and self.cfg.enabled
and in_start_slot(now, self.cfg.start_hour, self.tz)
and self.due(now)
and now.hour == self.cfg.start_hour and self.due(now)
and self._cooled(now))
# -- operations ----------------------------------------------------------
-8
View File
@@ -793,12 +793,6 @@ 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 "
@@ -918,7 +912,6 @@ 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,
@@ -1104,7 +1097,6 @@ def status_samples() -> list:
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, True)),
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, False)),
("row_peak_guard_quiet", GREEN, row_peak_guard_quiet()),
("row_peak_guard_unmeasured", AMBER, row_peak_guard_unmeasured()),
*(("row_peak_guard_blind", AMBER, row_peak_guard_blind(k))
for k in BLIND_WORDS),
("row_peak_guard_on", AMBER, row_peak_guard_on(1200, 2500)),
-7
View File
@@ -312,13 +312,6 @@ class QuarterAverager:
"""
return self._last_net
@property
def held_since(self) -> datetime | None:
"""SAFETY-90: when the held sample arrived (its `ingest_ts`), or None."""
if self._last_t is None:
return None
return datetime.fromtimestamp(self._last_t, timezone.utc)
@property
def offtake_avg_w(self) -> float:
"""Average offtake over the part of the open block seen so far."""
-53
View File
@@ -183,8 +183,6 @@ 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
@@ -601,57 +599,6 @@ def blindness(projection, max_hold_s, frozen: bool) -> str | None:
return None
# SAFETY-90. Seconds of uncounted time below which nothing is filled: float
# noise between the averager's sums and the wall clock, never a real gap.
GAP_TOL_S = 1e-3
def gap_w(ceiling_w, *seen_w) -> float:
"""SAFETY-90: the watts a meter gap is counted at. The highest of the
limit and `seen_w`, the import with the batteries at 0 W (grid + their
discharge) just before and just after the gap; None entries are skipped.
DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
return max([float(ceiling_w)] + [float(w) for w in seen_w if w is not None])
def fill_gaps(projection, *, tail_s, max_hold_s, unseen_s, gap_w, unseen_w,
held_ws=0.0):
"""SAFETY-90: `projection` with the seconds the averager did not count
(a meter gap past `max_hold_s`) counted at `gap_w`, and those from before
this run's first reading (`unseen_s`) at `unseen_w`. `held_ws` is added
too: what the gap's first `max_hold_s`, credited at the reading before
it, lacks to reach `gap_w`. Returns
`(projection, gap seconds filled, unseen seconds filled)`.
`tail_s` is the age of the held reading: up to `max_hold_s` of it is left
as before (the averager credits it once the next reading lands). Past
that the gap is still open, nothing is filled and `blindness()` answers
as before. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
if projection.block_start is None or max_hold_s is None:
return projection, 0.0, 0.0
wall = float(projection.elapsed_s)
tail = min(max(0.0, float(tail_s)), wall)
if tail > float(max_hold_s):
return projection, 0.0, 0.0
covered = wall - uncovered_s(projection)
missing = wall - covered - tail
if missing <= GAP_TOL_S:
missing = 0.0
if held_ws <= 0.0:
return projection, 0.0, 0.0
unseen = min(missing, max(0.0, float(unseen_s)))
gap = missing - unseen
add_ws = gap * float(gap_w) + unseen * float(unseen_w) + float(held_ws)
done = covered + missing
return (replace(
projection,
projected_w=float(projection.projected_w) + add_ws / QUARTER_S,
elapsed_w=((float(projection.elapsed_w) * covered + add_ws) / done
if done > 0.0 else float(projection.elapsed_w)),
covered_frac=(done / wall if wall > 0.0
else float(projection.covered_frac))), gap, unseen)
def discharge_permitted(soc, soc_floor) -> str | None:
"""SAFETY-24. May the guard size a forced discharge, or why may it not?
-1
View File
@@ -410,7 +410,6 @@
"step_in": "The batteries step in above {kw}.",
"step_in_would": "The batteries would step in above {kw}.",
"period": "This 15-minute period is heading for {kw}.",
"period_unmeasured": "Part of this 15-minute period was not measured.",
"prices": {
"now": "Price now {eur} per kWh, all in.",
"today": "Today: lowest {lo} at {lo_at}, highest {hi} at {hi_at}.",
-1
View File
@@ -347,7 +347,6 @@
"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."},
+2 -3
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller
version: "0.15.0"
version: "0.14.0"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
@@ -630,8 +630,7 @@ options:
# contradiction. Changing one of these numbers now moves every consumer.
maintenance_enabled: false
maintenance_interval_days: 28
# MAINT-10: a LOCAL hour, in Home Assistant's time zone.
maintenance_start_hour: 19
maintenance_start_hour: 10
# ⚠️ 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
+4 -7
View File
@@ -439,7 +439,7 @@ class _Hass:
return True
def wired(grid, cycles=40, batt=0.0, track=True, tick=None, **over):
def wired(grid, cycles=40, batt=0.0, track=True, **over):
async def go():
hass = _Hass(grid, batt=batt, track=track)
c = Controller(dict(
@@ -455,11 +455,8 @@ def wired(grid, cycles=40, batt=0.0, track=True, tick=None, **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 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)
for _ in range(cycles):
await c.read_inputs()
await c.cycle()
if c.clamp_wd.active and not c.red_snapshot:
c.red_snapshot = c.checks()
@@ -477,7 +474,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, tick=2.0)
hass_r, ctl_r = wired(6000.0, cycles=60, track=False)
emitted = [abs(w) for w in hass_r.writes]
# What the SAME error would have done to an accumulator with no bound - the
+54 -85
View File
@@ -900,51 +900,34 @@ 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()
_trips8b, _waits = _replay16(_Before94)
_trips94, _waits94 = _replay16(Freshness)
_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()
_p50 = nth(_waits, len(_waits) // 2)
print(f" {len(_loop_days)} days replayed at 1 Hz in "
f"{time.monotonic() - _t8b:.1f}s (twice): {_trips8b} trips before "
f"SAFETY-94, {_trips94} after")
f"{time.monotonic() - _t8b:.1f}s: {_trips8b} trips")
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 "
@@ -970,17 +953,6 @@ 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)
# ==============================================================================
@@ -1045,30 +1017,32 @@ 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 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. 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 = 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
_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)
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)
# ==============================================================================
@@ -1379,23 +1353,20 @@ 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 631 of them against 36 before, and 17 "
"number reaches a consumer on 630 of them against 35 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) == 36 and sum(_exp57) == 631 and _dead.releases == 17
sum(_expb) == 35 and sum(_exp57) == 630 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 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 "
"CONSUMER'S HOLD. Every unbroken run of exposure is exactly one 35.0 s "
"grading window, and 35 < 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[1:]) == {BOUNDS["batt"].unchanged_s}
and nth(_runs57, 0) == BOUNDS["batt"].unchanged_s + 1.0
and max(_runs57) < _circ_hold < _unit_hold)
set(_runs57) == {BOUNDS["batt"].unchanged_s}
and BOUNDS["batt"].unchanged_s < _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 "
@@ -1672,10 +1643,8 @@ 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
# 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 "
and "unchanged for 92 s" in f.reason
and "checked 2 times over a 35 s window since the last stop was "
"withdrawn" in f.reason)[-1])(
Freshness(BOUNDS["batt"])))
-9
View File
@@ -400,15 +400,6 @@ check("...with its duration from the battery's capacity and the cycle's rates "
"and hold time",
present(nth(_cyc)) and _cyc[0]["amount"].startswith("Takes about ")
and _cyc[0]["end_h"] > _cyc[0]["start_h"] + 2)
# MAINT-10: with the site's zone set, the hour is read on the local clock.
from app.tariff import _EUTimezone # noqa: E402
c_ml = make(dict(MAINT, maintenance_start_hour=14))
for _mm in c_ml.maints:
_mm.tz = _EUTimezone()
_cyc_l = [r for r in status(c_ml)["plan"]["runs"] if r["kind"] == "cycle"]
check("MAINT-10: start hour 14 in the local zone is drawn at 14:00, the "
"instant the cycle starts", len(_cyc_l) == 1
and field(nth(_cyc_l), "from") == "14:00")
c_m.maints[0].store.set("phase", "drain")
s_md = status(c_m)
check("a cycle under way is drawn from now, and the battery's now line says "
-543
View File
@@ -1,543 +0,0 @@
"""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")
+2 -8
View File
@@ -153,16 +153,10 @@ check("⭐ at N=1 and N=2, with the option absent and with it false, every "
"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",
"last), and SAFETY-87's held rule gives a5dddad's back",
_now["n2"] == _now["n2_off"] == SAFETY91_N2 != A5DDDAD["n2"]
and _n2_s87 == SAFETY94_N2_S87)
and _n2_s87 == A5DDDAD["n2"])
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",
+4 -5
View File
@@ -716,11 +716,10 @@ 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))
# 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)
# 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)
_hours = derived(lambda: committed_hours(START, MAINTENANCE_DRAIN))
check(f"...and it commits {len(_hours) if not isinstance(_hours, _Raised) else _hours} "
f"whole hours, one per hour of the timeout",
+1 -1
View File
@@ -187,7 +187,7 @@ check("⭐ a draining battery that is dropped (stale link) adds no floor: B "
"covers the 1500 W house, not the 2000 W drain rate, and the wait is "
"logged once",
"dropped inverter-a" in _l_rows[-1][7]
and cmd(_l_rows[-1])["b"] == 1510.0
and cmd(_l_rows[-1])["b"] == 1500.0
and sum("cannot give power now" in e for e in _l.events) == 1)
# ============================================================================ #
-483
View File
@@ -1,483 +0,0 @@
"""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")
-361
View File
@@ -1,361 +0,0 @@
"""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")
-573
View File
@@ -1,573 +0,0 @@
"""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")
-441
View File
@@ -1,441 +0,0 @@
"""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")
-271
View File
@@ -1,271 +0,0 @@
#!/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,24 +749,6 @@ show("(d) GLOBAL `pv_invert: bool` validates too - it loses on SEMANTICS (one "
"sign for a mixed roster), not on the schema", ok_d, "" if ok_d else out_d)
print("\n== I. MAINT-10: `maintenance_start_hour` now defaults to 19 ==")
_mk = "maintenance_start_hour"
ok_i, out_i = validate(defaults)
show("the shipped `options:` block validates and carries 19",
ok_i and out_i.get(_mk) == 19, "" if ok_i else out_i)
# What the Supervisor hands the add-on is deepmerge(options:, stored).
_stored10 = dict(defaults, **{_mk: 10})
ok_i2, out_i2 = validate(MERGER.merge(copy.deepcopy(defaults), _stored10))
show("a stored explicit 10 (what a blob saved before MAINT-10 holds) stays 10",
ok_i2 and out_i2.get(_mk) == 10, "" if ok_i2 else out_i2)
_absent = {k: v for k, v in defaults.items() if k != _mk}
ok_i3, out_i3 = validate(MERGER.merge(copy.deepcopy(defaults), _absent))
show("a stored blob WITHOUT the key gets the new default, 19",
ok_i3 and out_i3.get(_mk) == 19, "" if ok_i3 else out_i3)
ok_i4, out_i4 = validate(dict(defaults, **{_mk: 24}))
show("the range is unchanged: 24 is refused (`at most 23`)",
(not ok_i4) and "at most 23" in str(out_i4), out_i4)
# ⚠️ THE EXIT CODE IS PART OF THE INSTRUMENT. Without it the probe prints FAIL
# and still succeeds, and a transcript is only evidence if a wrong answer is
# distinguishable from a right one by something other than reading it.
+2 -2
View File
@@ -224,8 +224,8 @@ configuration:
name: "Maintenance · Minimum days between cycles"
description: "The shortest gap between two maintenance cycles."
maintenance_start_hour:
name: "Maintenance · Start hour (local time)"
description: "The hour on your local clock, 0 to 23, when a due cycle starts. 19 means 19:00."
name: "Maintenance · Start hour"
description: "The local hour, 0 to 23, at which a due cycle starts."
maintenance_discharge_w:
name: "Maintenance · Draw-down rate (W)"
description: "How fast the cycle empties the battery. Above “Inverter rating” it is cut back to the rating, and the cycle just takes longer."
+2 -2
View File
@@ -205,8 +205,8 @@ configuration:
name: "Onderhoud · Minimum dagen tussen cycli"
description: "De kortste tijd tussen twee onderhoudscycli."
maintenance_start_hour:
name: "Onderhoud · Startuur (lokale tijd)"
description: "Het uur op je lokale klok, 0 tot 23, waarop een geplande cyclus begint. 19 is 19:00."
name: "Onderhoud · Startuur"
description: "Het lokale uur, 0 tot 23, waarop een geplande cyclus begint."
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."