Compare commits

...
Author SHA1 Message Date
admin 451729c9b5 Release 0.16.0 (#178)
infra-pin / fresh (push) Successful in 1s
checks / unit (pull_request) Successful in 32m56s
infra-pin / fresh (pull_request) Successful in 2s
2026-10-08 01:02:20 +02:00
goodwe-review 1c607a961a REL-0.16.0 review: CHANGELOG wording fixes
checks / unit (pull_request) Successful in 32m48s
infra-pin / fresh (pull_request) Successful in 1s
SAFETY-103 swing 'by about 800 W for about 5 s'; SAFETY-99 'late, by less
than 30 s'; MAINT-10 row: one-at-a-time is not new; SAFETY-96: drop the
test-only 800 W; SAFETY-90 and MAINT-10 rows trimmed to two sentences.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:09:08 +02:00
glenn schrooyenandClaude Opus 5.5 756b50ddbb Release 0.16.0
checks / unit (pull_request) Successful in 32m41s
infra-pin / fresh (pull_request) Successful in 1s
version 0.15.0 -> 0.16.0 and a closed ## 0.16.0 CHANGELOG section.
No code change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:25:14 +02:00
admin 340c7eef42 Merge pull request 'SAFETY-95: a maintenance drain that ends hands back smoothly' (#177) from safety-95-drain-end-reseed into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-10-07 17:48:53 +02:00
glenn schrooyenandClaude Opus 5.5 4707d37019 SAFETY-95 review: test the stale-link and contradiction legs; refusal +1 only
checks / unit (pull_request) Successful in 32m34s
infra-pin / fresh (pull_request) Successful in 1s
F1: test_safety95 checks that a dropped draining battery is this rule's
(stale link, N=3, 4000 W: grid import 676 W vs base 786 W); kills R2.
F2: _charge_track tested directly with a contradicting drain (SAFETY-91's
pin as a floor against a closed pack); kills R3. Today a drain contradicts
only through a refusal; the clause stays, as a contradiction sends 0 W.
F3: a drain ends on a refusal only when the battery will not give
(refused > 0, as _held_back()); one that will not take still drains.
Tested both signs; kills R8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:41:16 +02:00
glenn schrooyenandClaude Opus 5.5 9c0314a826 SAFETY-95: N=1 identity for every drain end; plainer DOCS line
checks / unit (pull_request) Successful in 32m35s
infra-pin / fresh (pull_request) Successful in 2s
test_safety95.py: the N=1 byte-identity now covers all six drain ends
(Off and stale link were missing); end_at()/measure() read through a
default, so test_named_absence.py passes (it failed on bdec66e); a check
that a dropped battery's start is 0 W even under a pinned charge (it
kills a mutation that survived).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:36:36 +02:00
glenn schrooyenandClaude Opus 5.5 bdec66e97a SAFETY-95: a maintenance drain that ends hands back smoothly (N>=2)
When a battery's drain ended (done, Off, timeout, abort, stale link,
refusal), its drain floor stayed in the site target and landed on the
others: B 0 -> +1600 W in one cycle at a 1200 W house, exporting; under a
solar surplus B gave into the export.

Now, on that cycle, the loop starts from what the others stood at plus the
battery's new command, raised to the house's own point when the house needs
more (never above last cycle's start). While the battery settles, the loop
counts the batteries at its start instead of chasing the drain's tail on
the meter. Then one slew step a cycle. N=1 is unchanged.

test_safety95.py; DECISIONS/SAFETY-95-drain-end.md; one DOCS line.
test_modes.py: opt11 n2_maint re-pinned on purpose (its drain ends by a
refusal at 1655 s; B now stays at the house's 1420 W instead of 2500 W).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:44:25 +02:00
admin b2f55732b6 Merge pull request 'SAFETY-96: a battery the loop cannot move does not freeze it' (#176) from safety-96-stale-unit-site-total into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-10-06 06:19:12 +02:00
glenn schrooyenandClaude Opus 5.5 956711b801 SAFETY-96 review: keep the refusal count over a refused write; test a fast board
checks / unit (pull_request) Successful in 31m53s
infra-pin / fresh (pull_request) Successful in 2s
F1: resetting the count on each refused write hid a battery that really
refuses behind a link refusing 1 write in 3 (never learned; learned at
358 s with the count kept). F2: a board at 0 W before its link is stale
learned no refusal untested (mutation R7 survived); now a 1 s-lag case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 05:46:32 +02:00
glenn schrooyenandClaude Opus 5.5 88b9cd470a SAFETY-96: a battery the loop cannot move does not freeze it
checks / unit (pull_request) Successful in 31m44s
infra-pin / fresh (pull_request) Successful in 2s
At N>=2 a battery out of the split (stale link), with refused writes, or
settling after a step the loop did not make (a charge end, a rejoin) now
counts in the freeze test at its command, not its reading. Its real power
still reaches the loop through the meter. At a drop its share leaves the
loop's start point (the others take over one slew step a cycle); at a
rejoin it enters at its reading. No refusal is learned while a battery's
writes are refused. N=1 and the 46 identity digests are byte-identical.

test_safety78 part 2: the dropped-drain case now rests at B 1510 W (grid at
its -10 W aim) instead of 1500 W; the drop no longer sends B to 2500 W.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 04:02:03 +02:00
admin 1bf6b7f9a7 Merge pull request 'MAINT-10: maintenance start hour in local time, default 19:00' (#159) from maint-10-local-start-hour into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-10-05 23:56:19 +02:00
glenn schrooyenandClaude Opus 5.5 1d28daf8a0 MAINT-10 review: warn when HA did not answer and TZ is UTC; test the 3 s budget
checks / unit (pull_request) Successful in 31m18s
infra-pin / fresh (pull_request) Successful in 1s
HA can take minutes to start (2026-10-05, 502 from the proxy). Then TZ holds,
which the Supervisor sets from HA's zone, so the hour stays right; only a TZ
of UTC there now logs a WARNING asking for a restart. A test pins the 3 s
budget on the zone request (a mutant without it survived).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:24:02 +02:00
glenn schrooyenandClaude Opus 5.5 0b6b34f563 MAINT-10: every pack at N=2, the next DST days, plainer upgrade note
checks / unit (pull_request) Successful in 31m20s
infra-pin / fresh (pull_request) Successful in 1s
test_maint10.py part 10: at two inverters both packs get the site zone, the
real Controller is identical for 11 UTC and 13 Brussels, and two due packs
across 24/25 Oct 2026 and 27/28 Mar 2027 start at the local hour on
successive days; a pack freed in the repeated hour does not start there.
Part 3 adds 28 Mar 2027. DOCS: an existing install keeps its saved hour, now
read as local time; an hour set to make up for UTC goes back to 19.
DECISIONS: the SAFETY-83 gate is met; section on two inverters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:51:15 +02:00
glenn schrooyenandClaude Opus 5.5 08fb1235fc Merge release/1.0 (0.15.0 + SAFETY-90) into maint-10-local-start-hour
Conflicts: main.py's maintenance import (base added CHARGE, HOLD; branch added
site_zone) - both kept. DOCS maintenance table: base's longer drain-rate row
kept, start hour row at 19 / local.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:51:04 +02:00
admin 7ef73474dc Merge pull request 'SAFETY-90: the peak guard goes on after a P1 meter gap' (#175) from safety-90-peak-guard-gap into release/1.0
infra-pin / fresh (push) Successful in 2s
SAFETY-90: the peak guard goes on after a P1 meter gap (#175)
2026-10-05 13:23:32 +02:00
Claude (SAFETY-90 review) 24dbbd18ad SAFETY-90 review: test that a second gap does not price the first one down
checks / unit (pull_request) Successful in 31m12s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:39:28 +02:00
Claude (SAFETY-90 review) 8dfe91e1d2 SAFETY-90 review: count the start-up seconds like a gap
While the add-on is down the batteries are at 0 W (it sets 0 W when it
stops; the ESP32 watchdog after 30 s), so the import before its first
reading was the house, not the aim. Those seconds now count at
gap_w(limit, the import seen first), and after a restart the restored
reading's held 30 s is topped up the same way. Status row: "counted as
high use". Tests take the pre-start import as the house (batteries at
0 W) and add restarts of 10/60/150 s with a restored averager.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:24:33 +02:00
Claude (SAFETY-90 dev) 9d3fbe1be9 SAFETY-90: the peak guard goes on after a P1 meter gap
checks / unit (pull_request) Successful in 31m10s
infra-pin / fresh (pull_request) Successful in 1s
A meter gap past meter_max_age_s left seconds the quarter average never
counted, so the guard was blind for the rest of the quarter (rig case 7b:
3964.8 W against a 3450 W aim). Once readings are back, the missed seconds
now count at the higher of the limit and the import seen just before and
just after the gap with the batteries at 0 W (peakguard.gap_w/fill_gaps);
the gap's first meter_max_age_s is topped up to that too. Seconds before
the add-on's first reading count at the aim, and the page says part of the
period was not measured (EN/NL). The bill record is unchanged; no option.

DECISIONS/SAFETY-90-peak-guard-after-a-gap.md, test_safety90.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:46:58 +02:00
admin 3ed3302d2f Merge pull request 'Release 0.15.0' (#173) from release-0.15.0 into release/1.0
infra-pin / fresh (push) Successful in 1s
checks / unit (pull_request) Successful in 29m8s
infra-pin / fresh (pull_request) Successful in 1s
Release 0.15.0 (#173)
2026-10-05 01:52:40 +02:00
glenn schrooyenandClaude Opus 5.5 8c6e5bca2e Release 0.15.0: correct the SAFETY-94/97 row, add the ±170 W known issue
checks / unit (pull_request) Successful in 29m6s
infra-pin / fresh (pull_request) Successful in 1s
Review round 1 (F1-F3) on PR #173.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:23:03 +02:00
adminandClaude Opus 5.5 f01152b113 Release 0.15.0: bump version and close the CHANGELOG section
checks / unit (pull_request) Successful in 29m1s
infra-pin / fresh (pull_request) Successful in 1s
config.yaml version 0.14.0 -> 0.15.0 (minor: behaviour change from
SAFETY-94/97, no option change). New `## 0.15.0` section with the contents
since 0.14.0 (cf705a9 #171, 3129dae #172) and the known issues.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:48:04 +02:00
admin 3129daed62 Merge pull request 'CI-14: infra-pin to admin/goodwe 3b4ff84 (WITNESS-09)' (#172) from ci-14-infra-pin-3b4ff84 into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-10-05 00:45:15 +02:00
glenn schrooyenandClaude Opus 5.5 64dddd64bf CI-14: infra-pin to admin/goodwe 3b4ff84 (WITNESS-09)
checks / unit (pull_request) Successful in 29m18s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:13:01 +02:00
admin cf705a9058 SAFETY-94 + SAFETY-97: no false stale or refusal on a lagging battery; refusals graded against each battery's idle reading (#171)
infra-pin / fresh (push) Failing after 2s
2026-10-04 17:27:20 +02:00
adminandClaude Opus 5.5 7d06aa674d SAFETY-97: centre the band on the idle reading clamped to 75 W
checks / unit (pull_request) Successful in 29m26s
infra-pin / fresh (pull_request) Failing after 2s
Owner decision 2026-10-04 (review R1). A wrong idle reading more than
about 100 W off hid every refusal on that battery. Clamped under the band,
the band keeps 0 W +-25 W, so a battery reading about 0 W while refusing is
caught whatever was learned or restored. Samples are still taken to 250 W.
Beyond about +-170 W of idle a battery behaves as in 0.14.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:57:16 +02:00
adminandClaude Opus 5.5 88ebf3a5a6 SAFETY-97 review: tests for four surviving mutants
checks / unit (pull_request) Successful in 28m57s
infra-pin / fresh (pull_request) Failing after 2s
The N=1 stale-path refusal and SAFETY-42's parked test are centred on the
idle reading; learning stops while control is stopped or the sign watchdog
has latched. Each had no check (review mutants X7, X8, X13, X14).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:29:27 +02:00
adminandClaude Opus 5.5 3db2b6392f SAFETY-97: test the centred stale-path refusal (mutant S10 survived)
checks / unit (pull_request) Successful in 29m7s
infra-pin / fresh (pull_request) Failing after 1s
At a 5 s meter a register frozen at rest on B trips the stale gate before
the 12-cycle evidence; its refusal must be centred on B's idle reading,
or the site stops and re-trips instead of refusing B.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
2026-10-04 13:07:06 +02:00
adminandClaude Opus 5.5 80bfc5721e SAFETY-97: point test_safety97 at MUTATION.md; record the DC-entity option
checks / unit (pull_request) Successful in 29m9s
infra-pin / fresh (pull_request) Failing after 1s
test_bytecode requires every suite to name MUTATION.md (CI run 1158).
DECISIONS: why the integration's DC battery power entity is not the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
2026-10-04 12:51:52 +02:00
adminandClaude Opus 5.5 6918e154c8 SAFETY-97: "gives nothing" is graded against each battery's idle reading
checks / unit (pull_request) Failing after 29m7s
infra-pin / fresh (pull_request) Failing after 2s
B's power reading shows -95 to -118 W at idle (standby draw), so the
|reading| < 100 W refusal band never held a refusing B: never at N=2,
and since SAFETY-94's shared rule not at N=1 either (review F1).

Each unit now learns its idle reading (app/idle.py): only while control
runs, the unit is not refused/stale/tripped and its command has stayed
within 50 W for 120 s; first value a 120 s mean, then a 30 min follow;
samples beyond 250 W dropped, value clamped to 250 W; saved per unit and
restored. The shared refusal evidence, compute()'s N=1 engage and end,
the unit refusal ends, the stale-path refusal and SAFETY-42's "parked"
test are centred on it. Until learned it is 0 W, the rule as it was.

Refusing B caught at N=1 28/28/65 s and N=2 24/24/60 s (1/2/5 s
meters), before never; refusing A unchanged. Healthy B with its offset,
SAFETY-94's scenarios, 96 runs: 0 stale, 0 refused. Live replay: 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
2026-10-04 12:21:24 +02:00
glenn schrooyenandClaude Opus 5.5 dd5eb6fdc8 SAFETY-94 review: tests for three surviving mutants; record two gaps
checks / unit (pull_request) Successful in 28m7s
infra-pin / fresh (pull_request) Failing after 2s
- test_safety94 part 4: time under 250 W is not counted; a command that
  swaps direction every cycle is never refusal evidence; a reading moving
  away from the ask does not restart the evidence. These kill review
  mutants R1, R3 and R6, which survived the PR's tests.
- DECISIONS: at N=1 a battery with B's standby offset no longer latches
  a refusal (SAFETY-97 now covers N=1); a part-time ask is caught after
  35/d s of wall time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:17:52 +02:00
adminandClaude Opus 5.5 6759d21ea5 SAFETY-94: a healthy battery that lags or reverses is not flagged as frozen or refusing
checks / unit (pull_request) Successful in 28m10s
infra-pin / fresh (pull_request) Failing after 2s
The stale-reading gate counted its 35 s from the register's last change, so
after a rest the first read after a new command tripped it (SAFETY-58; live
2026-10-03, four trips 1 s after the command). It now counts only the time
the command asked the register to move.

At N=1 the refusal engaged after 12 frozen cycles if the reading was under
100 W at that moment, which a battery reversing through its lag passes at a
1 s meter. The refusal now needs the N>1 evidence at every roster size: under
100 W, one way, for 12 cycles spanning 22 s, restarting when the reading
moves 50 W towards the ask.

DECISIONS/SAFETY-94-lagging-battery.md; test_safety94.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:15:42 +02:00
admin 02ed1200dc Merge pull request 'Release 0.14.0' (#169) from release-0.14.0 into release/1.0
infra-pin / fresh (push) Successful in 1s
checks / unit (pull_request) Successful in 27m3s
infra-pin / fresh (pull_request) Successful in 2s
2026-10-03 11:29:13 +02:00
Claude f13dab1407 Release 0.14.0: changelog review fixes
checks / unit (pull_request) Successful in 26m57s
infra-pin / fresh (pull_request) Successful in 1s
Slew step not one house's 400 W; three two-inverter known issues, not
four; commissioning reference covers a new install; SoC example holds
for decimal sensors; SAFETY-95 states the jump, not the export.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:01:44 +02:00
glenn schrooyenandClaude Opus 5.5 b307db1049 Release 0.14.0: version bump and changelog
checks / unit (pull_request) Successful in 27m2s
infra-pin / fresh (pull_request) Successful in 2s
Two inverters are now supported for maintenance and the peak guard
(SAFETY-91, SAFETY-93). No option changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:30:25 +02:00
admin e51e3e02b2 Merge pull request 'CI-13: infra-pin to admin/goodwe 0c04488 (FW-05, WITNESS-08)' (#168) from ci-13-infra-pin-0c04488 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-10-03 10:27:02 +02:00
glenn schrooyenandClaude Opus 5.5 69dc1f0e7b CI: bump infra pin to 0c04488 (admin/goodwe#62 FW-05, #63 WITNESS-08)
checks / unit (pull_request) Successful in 27m26s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:56:36 +02:00
admin 858f77cfb5 Merge pull request 'SAFETY-93: the maintenance charge end hands back smoothly, with no grid charging at a high house' (#167) from safety-93-no-grid-charge-at-charge-end into release/1.0
infra-pin / fresh (push) Failing after 2s
2026-10-03 00:58:37 +02:00
adminandClaude Opus 5.5 214051d1a8 SAFETY-93 review: a charge that ends by a stale link or a refusal hands back smoothly too
checks / unit (pull_request) Successful in 27m5s
infra-pin / fresh (pull_request) Failing after 1s
A battery still in CHARGE but dropped by the allocator (stale link, or a
SAFETY-73 refusal contradicting its charge) left its -2000 W in the loop's
start point, and B went 0 -> -1600 W in one cycle. The end is now noted
after the unit claims are filed: in CHARGE, live and not contradicted.
Tests: part 6 (stale link, refusal) and a spy on the guard suspension.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:10:21 +02:00
adminandClaude Opus 5.5 05eb93f139 SAFETY-93: the maintenance charge end hands back smoothly, with no grid charging at a high house
checks / unit (pull_request) Successful in 26m55s
infra-pin / fresh (pull_request) Failing after 1s
At N>=2, when a battery's maintenance charge ended, its watts stayed in the
loop's start point and landed on the other battery (B 0 -> -1600 W in one
cycle, charging from the grid at a 6000 W house). On the cycle a charge
ends, the loop now starts from what the others stood at plus that
battery's new command. N=1 and all identity digests unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:38:42 +02:00
admin f3e409cf76 Merge pull request 'SAFETY-91: a held battery is drawn only after the others are full' (#165) from safety-91-held-battery-last into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-10-02 15:53:33 +02:00
adminandClaude Opus 5.5 4cd604afcb SAFETY-91 review: a held-back battery is released 1 point above its reserve
checks / unit (pull_request) Successful in 26m26s
infra-pin / fresh (pull_request) Successful in 1s
A SoC reading flipping across the reserve swapped ~1000 W between the
batteries on each flip (grid +-500 W with unequal lags). Also grades
next_quarter()'s staleness boundary (29.5 / 30.5 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:54:20 +02:00
adminandClaude Opus 5.5 6872353e2e SAFETY-91: a held battery is drawn only after the others are full
checks / unit (pull_request) Successful in 26m19s
infra-pin / fresh (pull_request) Successful in 1s
At N>=2 a battery at or under its own peak reserve now weighs 0 whenever
the site gives (not only for the peak), a draining battery's pin becomes
a floor at its drain rate so it rises before the held one gives, and a
quarter's edge is graded on the next quarter when its first telegram is
late. SAFETY-25 option A stays: the held battery is still drawn once the
others are full. N=1 unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:03:54 +02:00
goodwe-agent 2f5ab1f378 MAINT-10: DECISIONS says where the release note lives
checks / unit (pull_request) Successful in 23m8s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:09:06 +02:00
goodwe-agent fbdd552555 MAINT-10: CHANGELOG draft line moves to the PR (version gate refuses an Unreleased block)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:08:46 +02:00
goodwe-agent 02c0a3dd7b MAINT-10: maintenance start hour in local time, default 19:00
The start hour was compared with the UTC hour, so the shipped 10 started at
12:00 in a Belgian summer. It is now read in Home Assistant's time zone
(fallback: the container's TZ, then UTC, one log line says which), and the
default moves to 19. DST: a missing hour starts at the first hour after the
jump; a repeated hour starts only in its first pass. Stored options are not
rewritten: a saved 10 now means 10:00 local (DOCS, CHANGELOG).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:52:05 +02:00
47 changed files with 5916 additions and 151 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").
d637d0b1b13f1b863dc47e445e2ca4cf4a82fe10
3b4ff84757f9c2c5b63a4b4a53e593ccaeafb847
+117
View File
@@ -1,5 +1,122 @@
# Changelog
## 0.16.0
**A minor bump: changed behaviour, no option added or removed, and one default
and its meaning changed.** There are still 83 options. `maintenance_start_hour`
used to be read as UTC and its default was 10. It is now a local hour (in Home
Assistant's time zone) and its default is 19. The other changes below apply
with one inverter or two, unless a line says "Two inverters".
**Before you update (MAINT-10).** A saved `maintenance_start_hour` is kept, and
it now means local time. If you set it in UTC to get an evening start (for
example 17 for 19:00 in summer), set it back to the local hour (19). A saved
10 now starts at 10:00, not 12:00 in summer. The new default 19 only reaches
installs that have no hour saved.
Contents of this release, in merge order since 0.15.0 (`3ed3302`):
| | |
|---|---|
| `7ef7347` | After a gap in the P1 meter, the peak guard counts the missed time as high use and keeps the 15-minute period near the limit; before, it was blind until the next period. In the test, a 5 min meter silence at one inverter ended the period at 3.3 kW, against 4.8 kW on 0.15.0 ([[SAFETY-90]]) |
| `1bf6b7f` | The maintenance start hour is now a local hour, and its default is 19 (was 10, read as UTC). The log says which time zone it used; with two inverters, both use that hour and still cycle one at a time ([[MAINT-10]]) |
| `b2f5573` | Two inverters: when one battery's link drops, the other takes over in small steps, and control no longer stalls. The battery that comes back rejoins within 2 s, without a false "battery refuses" ([[SAFETY-96]]) |
| `340c7ee` | Two inverters: when a drain ends (done, switched Off, timeout, lost link), the other battery covers the house right away, without jumping to 1.6 kW, and then moves in small steps. It no longer gives into a solar surplus ([[SAFETY-95]]) |
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
- A battery whose reading at rest is beyond about ±170 W is still not caught when it refuses, as in 0.14.0 ([[SAFETY-97]]).
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]).
- A charge to 100 % can time out at 98 %. The add-on then counts the cycle as failed and drains the battery again the next day ([[MAINT-13]]).
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
- Two inverters: after a drain ends, the other battery can swing by about 800 W for about 5 s, because some readings are old ([[SAFETY-103]]).
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
## 0.15.0
**A minor bump: changed behaviour, and no option changes.** No option was
added, removed or renamed, and no default changed (83 options). Options that
start 0.14.0 also start 0.15.0. The changes below apply with one inverter and
with two.
The add-on now learns and saves, per battery, what its power reading shows
at rest (`unit/<name>/idle_w`). It learns it after about two quiet minutes and
uses 0 W until then, as before. There is nothing to set.
Contents of this release, in merge order since 0.14.0 (`02ed120`):
| | |
|---|---|
| `cf705a9` | No more false "battery power reading stopped changing" errors at a new command after a rest, or at a change of phase. Each one paused control for a few seconds (several a day on a live site). A healthy battery that lags or turns around is no longer held as refusing for up to 15 min. With two inverters, a refusing battery whose reading at rest is about −100 W is now caught too. The rest value used is limited to ±75 W ([[SAFETY-94]], [[SAFETY-97]], also fixes [[SAFETY-58]]) |
| `3129dae` | CI tests against a newer `admin/goodwe` (CI only) ([[CI-14]]) |
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- After a gap in the P1 meter, the peak guard is blind for the rest of that quarter hour ([[SAFETY-90]]).
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
- A battery whose reading at rest is beyond about ±170 W is still not caught when it refuses, as in 0.14.0 ([[SAFETY-97]]).
- Two inverters: if one battery's drain stops early (e.g. switched Off), the other battery can jump to about 1.6 kW at once, and the surplus goes to the grid ([[SAFETY-95]]).
- Two inverters: after one battery's link drops, control can stall until a safety stop ([[SAFETY-96]]).
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
## 0.14.0
**A minor bump: new behaviour with two inverters, and no option changes.**
No option was added, removed or renamed, and no default changed (83
options). Options that start 0.13.0 also start 0.14.0. With one inverter,
control is the same as in 0.13.0.
⭐ **Two inverters are now supported** for the monthly maintenance (drain,
charge, hold) and the peak guard. To add a second inverter, follow
`docs/commissioning-b.md` in the `admin/goodwe` repo. Its Stage 5, where the
add-on takes control of it, needs 0.14.0. Read the known issues below first:
three of them are about two inverters.
Contents of this release, in merge order since 0.13.0 (`8c438ab`):
| | |
|---|---|
| `f3e409c` | Two inverters: a battery at or under its peak reserve gives last, after the others give their full power. It is freed again above reserve + 1 point (e.g. above 21 % for a 20 % reserve). At a quarter's edge the peak guard no longer drops out for a second ([[SAFETY-91]]) |
| `858f77c` | Two inverters: when a maintenance charge ends, for any reason, the other battery no longer starts charging from the grid; control hands over in small steps ([[SAFETY-93]]) |
| `e51e3e0` | CI tests against a newer `admin/goodwe` (CI only) ([[CI-13]]) |
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- A healthy battery that turns from charging to giving can be held at 0 W for up to 15 min ([[SAFETY-94]]).
- After a gap in the P1 meter, the peak guard is blind for the rest of that quarter hour ([[SAFETY-90]]).
- Two inverters: if one battery's drain stops early (e.g. switched Off), the other battery can jump to about 1.6 kW at once, and the surplus goes to the grid ([[SAFETY-95]]).
- Two inverters: after one battery's link drops, control can stall until a safety stop ([[SAFETY-96]]).
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
## 0.13.0
**A minor bump: new behaviour, a new add-on page (Home, Settings, Batteries,
@@ -0,0 +1,67 @@
# MAINT-10 — the maintenance start hour is local time, default 19:00
Owner decision 2026-09-29 (option A). `maintenance_start_hour` was compared
with the UTC hour, so the shipped 10 started at 12:00 in a Belgian summer and
11:00 in winter. It is now read on the site's local clock, and the default moves
to 19 so the drain runs into the evening and night rather than the solar day.
Evening drains meet higher house loads; SAFETY-83 (merged first) lets the
peak guard outrank the drain.
## 1. Which zone
Home Assistant's `time_zone` (`GET /core/api/config`, 3 s budget) at startup,
else the container's `TZ`, else UTC. One log line says which (WARNING only for
UTC). Resolved once per start: a zone change in HA takes effect on the next
add-on restart. HA can still be starting when the add-on starts (502 from the
proxy, 2026-10-05); then `TZ` holds. HA pushes its zone to the Supervisor
(hassio `push_config`), which stores it and sets it as the add-on's `TZ`
(`docker/app.py`, Supervisor 2026.08.0), so the hour stays right. Only a `TZ` of
UTC there logs a WARNING asking for a restart. No later re-resolve: the fallback
already is HA's zone, so the rare UTC case gets a warning rather than more code.
The add-on image has tzdata (checked in `amd64-base:3.20`); the CI image does
not, so the tests grade DST on `tariff._EUTimezone` too.
## 2. DST
`maintenance.in_start_slot()` works from UTC instants only, so it does not
depend on how a tzinfo resolves a missing or doubled wall time:
- normally the slot is the local hour `start_hour`;
- clocks forward and the hour does not exist: the first hour after the jump;
- clocks back and the hour happens twice: the first pass only. Without this an
aborted start (90 min cooldown) would start again in the 120 min repeated hour.
Every day has exactly one 60 min slot for every hour 0-23: checked against
zoneinfo for Europe/Brussels, America/New_York, America/Santiago (midnight
transitions), Asia/Kathmandu (+05:45) and Australia/Lord_Howe (30 min DST)
over all of 2026.
## 3. Existing installs
The Supervisor merges the shipped defaults under the STORED options, and saving
the settings stores every key (the reference install's blob has all 76). So
almost every existing install keeps its stored 10, which now means 10:00 local
instead of 12:00 (summer) or 11:00 (winter). Only a blob without the key gets 19.
The add-on does not rewrite a stored option: a silent migration would override
an operator's choice with no way to tell it apart from the old default. A site
that set an earlier hour to make up for UTC (17 for 19:00 summer) must set it
back to 19 after the upgrade, or it starts two hours early. DOCS and the release
note (drafted on the PR, for the version bump) say this.
`tools/supervisor_shape_probe.py` §I measures this on Supervisor 2026.08.0.
## 4. Two inverters
`Controller` hands the same zone to every pack's `Maintenance`, so every pack's
`wants_start()` reads the local hour. `arbitrate()` is unchanged: one pack
cycles at a time, and the one that lost the slot starts in the first start
slot after the other is done (a later day: a cycle outlasts its start hour). On the day the clocks go back, a pack freed during the second pass of
a repeated hour does not start there. `test_maint10.py` part 10.
## 5. What does not change
Only the start decision (`Maintenance.wants_start`) and the Home screen's shown
start time read the zone. `test_maint10.py` parts 9 and 10 run the real
Controller at N=1 and N=2 with start hour 11 UTC against 13 Europe/Brussels (the
same instant): every command, claim, reason and log line is identical. A
`Maintenance` built without a zone reads UTC, as before.
@@ -0,0 +1,81 @@
# SAFETY-90 - the peak guard goes on after a P1 meter gap
**Status: DEV 2026-10-05, for review.** Tests: `test_safety90.py`.
**The defect.** A meter gap longer than `meter_max_age_s` leaves seconds the
quarter average does not count (`p1.QuarterAverager`, on purpose: a guess must
not reach the bill record). `peakguard.blindness()` then called the projection
blind for the rest of the quarter, so the floor was 0 W. During a HOLD the
battery gave nothing and the quarter went over: SAFETY-86 rig case 7b, N=2,
3964.8 W against a 3450 W aim, the month peak raised; N=1, 3701 W (review).
The page still read "Keeping the grid near zero". At start-up mid-quarter the
same rule left the start-up quarter blind, and a held battery was drawn as last
resort (SAFETY-91 test, O2).
**The fix** (`peakguard.fill_gaps()`, `peakguard.gap_w()`,
`Controller._fill_peak_gaps()`).
- Once readings are back, every second of a silence longer than
`meter_max_age_s` counts at `gap_w = max(the limit, the import just before
the gap, the import just after it)`, where "import" is the grid plus the
batteries' discharge, i.e. with the batteries at 0 W. The gap's first
`meter_max_age_s`, which the averager credits at the last reading, is topped
up to `gap_w` too. The guard then grades the quarter as usual, so it is not
blind and gives what keeps the quarter at the aim.
- **The bound.** The count is at or above the true import as long as, during
the gap, the house load (less solar) stayed at or under the higher of the loads seen just
before and just after it, or under the limit, and the batteries did not
start charging (during a gap they hold their last command, then go to 0 W).
It never counts the gap below the limit. A load that rises above all three
only inside the gap is the one case it can miss.
- **Cost of the bound.** It is conservative: with the house at 6500 W, a 30 s
to 5 min gap lands the quarter 100-180 W under the aim (table below), paid in
battery energy.
- **While the gap lasts** nothing changes: the reading is too old to steer by,
the guard is blind (floor 0 W) and the "inputs missing" stop sets 0 W, as
before.
- **Before the first reading** (start-up or restart mid-quarter) the
add-on was down, so the batteries were at 0 W (it sets 0 W when it stops,
the ESP32 watchdog after 30 s). Those seconds count like a gap, at
`gap_w(the limit, the import seen first)`; after a restart the restored
reading's held `meter_max_age_s` is topped up the same way. The bound is
the gap's, with only the "after" side known. A load the batteries cannot
make up then raises "Load too high", which is true: that quarter is going
over. The page says so: the status row "Peak guard: part of this
15-minute period was not measured, counted as high use" and the home line
"Part of this 15-minute period was not measured." / "Een deel van dit
kwartier is niet gemeten." until the quarter ends.
*Review:* the first version counted them at the aim, with "nothing is
known" as the reason. The first reading does show the load, and with the
batteries at 0 W the import was that load, not the aim: a 60 s restart
during a 6500 W HOLD landed 3653 W against 3450 W.
- The bill record (`quarter_blocks`) is unchanged: it still banks only what
was measured. The estimate steers the guard and is never banked.
- The action log gets one `PEAK_GUARD_GAP` row per gap, with the seconds and
the watts it was counted at.
- No option. The threshold is `meter_max_age_s`, the limit and the aim are
`peak_cap_w` and `peak_margin_w`.
**Before -> after** (`test_safety90.py`, quarter as the meter really averages
it, gap starting 300 s in; 10 s battery lag; aim 3450 W):
| case | before | after |
|---|---|---|
| N=1 HOLD 6500 W, gap 30 s / 60 s | 3456.6 / 5329.2 | 3346.2 / 3272.2 |
| N=1 HOLD 5500 W, gap 5 min | 4717.9 | 3330.6 |
| N=2 HOLD, B able, 6500 W, gap 30 s / 60 s | 3456.6 / 3865.6 | 3346.3 / 3272.7 |
| N=2 HOLD, B able, 5500 W, gap 5 min | 3745.4 | 3288.9 |
| N=2 HOLD, B held, 5500 W, gap 60 s | 3254.9, B drawn 2500 W | 3335.4, B 0 W |
| gap across the edge, next quarter, N=1 / N=2 | 4100.0 / 3621.2 | 3446.3 / 3382.2 |
| house 3000 -> 6500 W inside a 60 s gap, N=1 | 5255.6 | 3368.3 |
| house 6500 -> 2000 W late in a 5 min gap, N=1 | 3629.2 | 3145.1 |
| start-up 450 s in, N=1, house 5000 W (batteries at 0 W before it) | 5000.0 | 3450.0 |
| restart, down 60 s / 150 s, N=1, house 6500 W | 4039.1 / 5483.3 | 3450.0 / 3450.0 |
| meter never drops, N=1 and N=2 | | rows identical |
**Not changed.** The late-reading edge (SAFETY-91 test, O1): a reading late by
less than `meter_max_age_s` is not a gap, and the seconds since it are not
counted in the projection (on any quarter, not only after a gap). Near a
quarter's end the 900/remaining gain turns that into a large negative excess
and the floor drops for a cycle or more. Counting those seconds at the held
reading would fix it, but it changes every quarter's last seconds on a
healthy meter; it is handed back as its own card.
@@ -0,0 +1,74 @@
# SAFETY-91 - a held battery is drawn only after the others are full
**Status: DECIDED 2026-10-01 (owner: held battery last, SAFETY-25 option A
kept).** Follows SAFETY-87. Tests: `test_safety91.py`.
**The defect** (SAFETY-87 rig test, F1 and F2). At N>=2 a battery at or under
its own peak reserve was held back (weight 0) only on cycles where the peak
guard asked for power.
- F1: B draining at 1000 W, A held at 15 %, house 5500. Once the grid was
near zero the loop set the target, A had its normal weight and B's drain
pin bound, so the loop drew A at 2500 W for the whole quarter (15.0 ->
10.7 %) while B stayed at 1000 W. In the next quarter A reached its
cut-out, and B stayed pinned at the peak floor (~2422 W), not its 2500 W.
- F2: in a HOLD (A holding, B held), at a quarter's edge the guard saw a block
with no seconds left and asked for nothing for one cycle. The HOLD's
ceiling lapsed, A went back to 0 W and the loop's target went to B: 2500 W
for 1 s.
**The fix** (`main.py`, N>=2 only).
- `_held_back()`: the batteries at or under their own reserve while another
can still give (live, not refusing), graded as in `_peak_on_able()`. They
weigh 0 in every split where the site gives, not only for the peak. With
nobody able (all held, or the rest refusing or dropped) nobody is held back
and the split is as before.
Review: once held back, a battery stays so until its SoC reads more than 1
point above its reserve. A reading flipping 20/21 % otherwise swapped
~1000 W between the batteries on each flip (grid +-500 W with A 10 s and
B 3 s lag). The guard's own permission (SAFETY-87) is unchanged.
- `_drain_rise()`: while a battery is held back, a draining battery's pin
becomes a floor at its drain rate (`lo`), so it rises towards its rating
before the held one gives. Not for a held battery's own drain, not under a
site stop, not for a battery that is not live.
- `tariff.next_quarter()`: past a block's end, before the next block's first
telegram, the guard grades the next quarter, projected from the held
reading (the averager credits it there). Not when that reading is too old
to be credited (`max_hold_s`).
**What stays.** SAFETY-25 option A: once every able or draining battery is
at full power, the loop still draws a held battery (`allocator._fill()`'s
last-resort pass). With house 5500 and 2 x 2500 W, A still gives 2500 W
after B is full. Nothing is placed on a battery at its cut-out: OPT-09's
pack limit closes it (`hi = 0`), and the drain rise lets B take the rest.
**Before -> after** (`test_safety91.py`, 2 x 2500 W, lagging battery 10 s):
| case | before | after |
|---|---|---|
| B drain 1000, A held, house 4000 | A 2500, B 1000 | A 1500, B 2500 |
| same, house 5500 | A 2500, B 1000 | A 2500, B 2500 (option A) |
| cycles A asked while B not full | 612 of 900 | 0 |
| A at its cut-out, house 5500: B | 1572 W mean | 2500 W, A 0 W |
| HOLD, late telegram at the edge | A 0, B 2470 for 1 cycle | A 2050, B 0 |
| idle, A held, house 2000 | A 1005, B 1005 | A 0, B 2020 |
| N=1, every scenario | | byte-identical |
**Why N>=2 only.** At N=1 there is no other battery to give first. The edge
roll would also fix the N=1 one-cycle dip at a quarter's edge (SAFETY-86
rig, "A dropped to 0 W for 1 s"), but this ticket keeps N=1 byte-identical;
extending it is a one-line change for a later ticket.
**Re-pinned.** `test_modes.py` `n2` and `n2_off`: from t=1600 battery A is
at its 20 % reserve, so B carries the house and A gives last (100 of 540
cycles differ). With SAFETY-87's held rule both are a5dddad's again, and the
test grades that.
Two fixtures changed with it: `test_safety87.py` part 10's "without the
raise" run also turns off `_drain_rise()` (else B rises anyway), and
`test_peakguard.py` 10g and `test_watchdog.py`'s two-inverter rig give
their second battery a cut-out, as rule 8 requires (an unknown cut-out is held, so A alone would carry the cycle).
**Not changed.** The peak guard's own rules (SAFETY-87). A drain with
nobody held stays a pin (SAFETY-78). An Off battery beside a held one
(SAFETY-87 Open). The first cycle after start, before any quarter is
measured, can still put the loop's first step on a held battery in a HOLD.
SAFETY-88, -89, -90, -92, -93, -94.
@@ -0,0 +1,60 @@
# SAFETY-93 - the maintenance charge end hands back smoothly
**Status: implemented 2026-10-02, for review.** Tests: `test_safety93.py`.
**The defect** (SAFETY-85 rig test, check 4; also on 9358781). At N=2, when
A's maintenance charge ended, B went 0 -> -1600 W in one cycle and charged
from the grid with the house at 6000 W. The HOLD end was already smooth
(SAFETY-85).
**Cause.** During the charge the site target holds A's charge (SAFETY-84's
`charge` ceiling, -2000 W). At the end A gets its HOLD pin (0 W), but the
loop starts its next step from last cycle's site target. The slew limit
moves the site total by 400 W (-2000 -> -1600), and the only battery left
to place it on is B. The same happens when the charge times out or the
battery is switched Off (A free, its -2000 W shared out). With the charge
suspended by the guard and A giving under the loop, B took A's share in
one step (675 W).
**The fix** (`main.py`, `_reseed_charge_end()`, N>=2 only). On the cycle a
battery leaves CHARGE, the loop's start point (`self.target`, and the
integrator with it) is what the other batteries stood at plus that
battery's new command: its pin (HOLD or abort, 0 W), or, if it is free, its
last discharge (a charge stops). The loop then moves from there one slew
step a cycle. The ending battery's own step (-2000 -> 0 W) is maintenance's
pin, as before; it lowers the grid import. The end is kept until the loop
next runs, so a missed poll on that cycle (the input hold) does not lose it.
Review: a charge also ends, for the site, when the battery is still in
CHARGE but can no longer be placed: a stale link or a refusal (SAFETY-73)
drops it, and its -2000 W landed on B the same way (B 0 -> -1600 W at a
1200 W house). So the end is noted after the unit claims are filed: a
battery leaves the charge when it leaves CHARGE, is not live, or its own
claims contradict. The guard suspending the charge (SAFETY-84) is no end.
Why a start-point reseed, and not a per-battery slew in the allocator: the
jump comes from one stale number at one edge, the kind of edge SAFETY-73
(`_unit_refusal_end`) and OPT-11 P3 (`_reseed_run_edge`) already reseed.
The change stays at that edge; nothing else at N>=2 moves.
**Before -> after** (`test_safety93.py`, 2 x 2500 W, 10 s lag, 2 s meter):
| case | before | after |
|---|---|---|
| rig case: house 6000, A charges 2000 to the end | B 0 -> -1600, charges 4 cycles | B 0 -> 400 -> 800 ..., never charges |
| house 1200 | B 0 -> -1600 (2800 W from the grid) | B steps of 400, never charges |
| charge times out / A switched Off | B 0 -> -1600 | B 0 -> 400 |
| guard suspended the charge, A gives 1825 | B step 675 W | B step 395 W |
| missed poll on the end cycle | B 0 -> -1600 a cycle later | B 0 -> 400 |
| A dropped mid-charge (stale link, refusal), house 1200 | B 0 -> -1600 | B 0 -> 400 |
| N=1, both charge ends above | | byte-identical |
No pinned digest moved: the 46 digests of `tools/*identity*.py` (N=1 and
N=2) are byte-identical to f3e409c; none of their scenarios reaches a
charge end.
**Not changed.** The peak guard's floor still moves the site in one step
when it engages (a house step, a quarter edge): the guard outranks the slew
(owner order). A HOLD pin still drops the holding battery to 0 W at once
and SAFETY-86 gives way to the others first. SAFETY-88 (charge flapping
above the aim) is untouched.
@@ -0,0 +1,102 @@
# SAFETY-94 - a battery that lags or reverses is not frozen or refusing
**Status: implemented 2026-10-03, for review.** Tests: `test_safety94.py`
(rig: `tools/safety94_rig.py`). Also closes SAFETY-58. SAFETY-74 stays open.
## The defects
1. **Stale-reading gate (SAFETY-37).** Its 35 s ran from the register's last
change. After a rest that clock was already past 35 s, so the first read
after a new command tripped it, before the register could answer. Live
2026-10-03 (0.14.0, N=2): 17:39:16 B and 17:39:25 A (load ramp after a
rest), 19:00:04 A (drain start), 21:21:22 B (drain -> charge). Each was
1 s after its command; each register answered within 11 s. This is
SAFETY-58's shape.
2. **Refusal at N=1 (SAFETY-52).** `compute()` engaged after `SETTLE_CYCLES`
frozen cycles if the reading was under 100 W at that moment. The frozen
cycles include a reversal through the battery's lag. At a 1 s meter the
12th cycle comes as a battery reversing out of a charge passes 0 W
(SAFETY-85 rig, O1: "gave -20 W for 13 cycles"). The refusal then held
0 W, the battery settled at 0 W, never gave 100 W, and waited for the
15-minute probe. N>1 already required 22 s in the band, so it was safe
at a 10 s lag but not at 20 s.
## What changed
- **The gate counts only time the register was asked to move**
(`freshness.Freshness._oblige`). A read counts the time since the last
read when, at that read, the command is 250 W or more and has moved 250 W
from where it stood at the register's last change. A register change
(or a SAFETY-57 release) restarts the count. A command flapping in and
out of the ask still adds up (frozen register, 0/1500 W every 5 s: trip
at 69 s). The conjunction, `MOVE_W` and `RELEASE_S` are unchanged.
- **The bound is unchanged at 35 s and now means what its derivation
says.** The floor was always "how long a healthy register takes to answer
a command": 6 s plant + 10 s publish + 1 s read = 17 s. The band
`[2 x 17, 0.5 x 75] = [34, 37.5]` holds. Live answers measured: 1.4, 3,
6 and 11 s after the command.
- **Refusal evidence is the same at every roster size**
(`main.Controller._gave_nothing`, SAFETY-73's count moved into a method).
The reading must stay under 100 W, one way, while asked over 200 W, for 12
cycles spanning 22 s. At N=1 `compute()` gets this as `gave_nothing` and
needs it on top of its own freeze test. `None` (compute alone) keeps the
old law.
- **The evidence restarts when the reading moves 50 W towards the ask**
(`REFUSED_PROGRESS_W`). A refusing battery reads flat (-3/-4 W live). Live
readings wander by under 25 W at rest. A first-order lag crossing the band
gains 50 W in at most 0.4 tau, so it cannot fill 22 s unless tau > 54 s.
This also covers B, whose reading is 100 W below its power.
## Bounds and worst-case catch
| fault | before | after | budget |
|---|---|---|---|
| register frozen, then asked to move | 0-35 s after the ask (often 1 s) | 35 s after the ask (+1 read) | SAFETY-37: 35 s, under the 75/85 s consumer holds |
| register frozen at rest reading ~0 W | stale 1 s after the ask | refused or stale, 0 W within 23-35 s | the same |
| refusing battery, 1 s meter, N=1 | 11-16 s | 22-28 s | SAFETY-73's 22 s span (+ one 10 s publish) |
| refusing battery, 2 s meter | 22-28 s | 22-28 s | unchanged |
| refusing battery, 5 s meter | N=1 35-41 s, N=2 41-55 s | N=1 40-45 s, N=2 50-55 s | 12 cycles = 55-60 s |
Rig: `test_safety94.py` part 3. A release after SAFETY-57 still exposes
the held number for one 35 s window of asking, so 35 < 75 < 85 still holds.
## Before -> after
- Live replay (part 1): 4 trips -> 0.
- Refusing B (its -100 W idle reading): see SAFETY-97.
- Rig, 10 s lag, N=1 and N=2, 1/2/5 s meters, two register shapes, 48 runs
(part 2): before 27 stale trips and 1 refusal, after 0 and 0.
- Reversals, lag 10 and 20 s, 1 s meter (part 2): before N=1 6 stale / 3
refused, N=2 6 / 1; after 0 / 0.
- 16-day loop capture (`test_freshness.py` part 8): 93 trips -> 59. Trips
answered within 20 s: 21 -> 1. Two of the removed trips were not quick:
a register held 351 s under a command hovering at the 250 W travel edge
(under 35 s of asking before it moved; survivor 2's shape), and a
refusing battery the gate had caught only on a passing ramp (SAFETY-52's
refusal covers it).
## SAFETY-58 and SAFETY-74
- **SAFETY-58 is fixed** by this change: its card asks for the clock to
start with the obligation and for the bound to be re-derived. Both are
done above. `test_freshness.py` survivor 5 is now graded as closed.
- **SAFETY-74 is not fixed.** A battery capped at 300 W with a perfectly
flat reading, 5 s meter, house stepping: 50 trips/h at N=1 and 80 at N=2
before, 37 and 60 after. With +-1 W dither: 0. A flat reading under a
command that moved is exactly what the gate is for; telling "limited"
from "frozen" needs a second signal (the meter, or SoC).
## Known gaps
- ~~B's standby offset (-95 to -118 W at idle) puts a refusing B outside the
100 W band, so a refusing B never latched (review F1: at N=1 new, at N=2
as before).~~ **Fixed by SAFETY-97 in the same PR:** the band is centred
on each unit's learned idle reading. `SAFETY-97-idle-offset.md`.
- A command that asks only part of the time (a fraction d) is caught after
35 s of asking, which is 35/d s of wall time (0/1500 W every 5 s: 69 s).
SAFETY-30's per-unit check restarts when its command stops opposing the
held reading, which a lapsing ask normally does, so 35 < 85 holds there.
SAFETY-31's circulation check (75 s) ignores the command, so its margin
holds only while d >= 0.47.
- A register frozen under a command that stays near the 250 W travel edge
is caught late or not at all (survivor 2).
@@ -0,0 +1,69 @@
# SAFETY-95 - a maintenance drain that ends hands back smoothly
**Status: implemented 2026-10-06, for review.** Tests: `test_safety95.py`.
**The defect** (SAFETY-93 review, O1). At N=2, when A's drain ended (A
switched Off, say) with the house at 1200 W, B went 0 -> +1600 W in one
cycle and exported. It is the mirror of SAFETY-93 on the drain end.
**Cause.** During a drain the site target carries A's drain floor
(SAFETY-78, `drain`, 2000 W) above what the house needs. At the end A's
command drops (pin, Off, abort, stale link, refusal), but the loop slews
from last cycle's target (2000 -> 1600) and only B is left to place it.
Under a solar surplus B gave 1600 W into the export.
**The rule** (`main.py`, `_reseed_drain_end()`, `_drain_grid_w()`, N>=2
only). On the cycle a drain ends:
1. The start point is what the others stood at plus A's new command (its
pin, else 0 W; a dropped battery is sent 0 W), as SAFETY-93 does.
2. If the house needs more, the start is raised to the house's own point
(live batteries' readings + grid - target, as `_reseed_run_edge()`),
but never above last cycle's start. So the others cover the house at
once and never take over the drain's export.
3. While A settles (SAFETY-96's window: within the deadband, at most
`PLANT_SETTLE_DOWN_S`), the loop's error is the house's point minus its
start, not the meter. The meter still shows A's tail and the others'
climb; chasing it sent B down and then up again.
4. Then the loop moves one slew step a cycle.
A drain ends when its battery leaves DRAIN, is not live, its claims
contradict, or it will not give (SAFETY-73's limit outranks the pin
without a contradiction, so the refusal is read from the battery; +1 only,
as `_held_back()`). Today a contradiction on a drain only arises through
a refusal; the clause stays because a contradiction sends A 0 W, which is
an end (tested directly). The end is kept
until the loop runs (a missed poll). A dropped draining battery is this
rule's, not `_reseed_drop()`'s. Order in one cycle: drop/rejoin
(SAFETY-96), charge end (SAFETY-93), drain end (this) last; each moves the
start by its own battery, and the drain end's raise reads the start the
others left.
**Why not SAFETY-93's rule alone** (step 1, then slew). Measured on the
same plant: at 1200 W the grid imports 849 W and B goes to -465 W
(charging) while the loop chases A's tail; at N=3 and 4000 W it imports
784 W where base imports 42 W, as the others give up A's share of the house.
The house point keeps the grid at its aim; the cap at last cycle's start
keeps it from exporting.
**Before -> after** (`test_safety95.py`, 2500 W batteries, 10 s lag, 2 s
meter; base = the same tree with the change taken out):
| case | before | after |
|---|---|---|
| drain done / Off / timeout, N=2, 1200 W | B 0 -> 1600, +390 W commanded export, grid then +559 W | B 0 -> 1210, grid within 40 W of its aim |
| the same, N=3, 1200 W | B, C 0 -> 800, grid +695 W | 0 -> 605, grid +39 W |
| the same, solar surplus | B gives 1600 W into the surplus | B never gives; site moves 400 W a cycle |
| refusal, N=3, 1200 W | B, C step 1020 W, +2000 W commanded export | step 20 W, +70 W |
| N=3, 4000 W | others take A's share at once | the same (grid +1 W, base +42 W) |
| abort (site zeroed by the operator) | climbs from 0, grid +669 W | climbs from 0, grid +16 W |
| stale link (board watchdog 25 s) | | as base, but N=3 4000 W: grid +676 W (base +786) |
| N=1, every drain end | | byte-identical |
`tools/opt11_identity.py` n2_maint moved on purpose (A's drain refused at
1655 s; B stays at the house's 1420 W instead of 2500 W). Re-pinned in
`test_modes.py`. The other 45 digests are byte-identical.
**Not changed.** The peak guard's floor still binds (arbiter, after the
loop). Held batteries still give last; the idle clamp and the freeze test
are as they were. A charge end (SAFETY-93) and a trade end are unchanged.
@@ -0,0 +1,59 @@
# SAFETY-96 - a battery the loop cannot move does not freeze it
**Status: implemented 2026-10-06, for review.** Tests: `test_safety96.py`.
**The defect** (SAFETY-93 rig test). At N=2 the saturation freeze compares
the site target with the site sum of the readings. After A's link went
stale, A was out of the split but its reading (-2000 W, still charging) was
in the sum: "saturation freeze (800 W vs -1903 W)", B held at 1200 W for
~236 cycles until the circulation trip. After a charge timeout or Off at
6000 W the loop froze ~14 s at 800 W while A came out of its charge. At a
drop, B also took A's whole share in one step.
**The rule** (`main.py`, `_held_out_w()`, `_reseed_drop()`, N>=2 only).
A battery is held out of the freeze test, counted at its command instead of
its reading, while:
- it is out of the split (writes refused for `LINK_STALE_S`);
- its last write was refused (its board may have gone to 0 W on its own
watchdog);
- it settles after a step the loop did not make: a charge end (SAFETY-93:
HOLD, timeout, Off, refusal, stale link) or a rejoin (then every battery,
as the split is made again). Settling ends when its reading is within the
deadband of its command, or after `PLANT_SETTLE_DOWN_S` (23 s, the
measured plant settle).
Its real power is not lost and not counted twice: the meter sees it, and
the loop follows the meter. Only the freeze stops reading it as the loop's.
The freeze still fires on the other batteries' own lag and on unplaced
watts.
At a drop the battery's share leaves the loop's start point, so the others
take over one slew step a cycle. At a rejoin it enters at its reading.
**Not a drop.** A missing reading or a stale register stops the site
(`inputs_ok`), as before. A refused battery (SAFETY-73) stays in the sum:
it is asked ~0 W and reads ~0 W. A watchdog-stopped battery (SAFETY-02/30)
is pinned at 0 W and stays in the sum. New: no refusal is learned on a
battery whose writes are refused; its board going to 0 W is no refusal. Its
count is kept, not reset, so a link that refuses 1 write in 3 does not hide
a battery that really refuses (review).
**Before -> after** (2 x 2500 W, 10 s lag, 2 s meter; freeze cycles not
about the remaining battery's own lag):
| case | before | after |
|---|---|---|
| A drops while giving, A holds 1200 W | 128, B steps 1210 W | 0, B stays |
| A drops while charging, A holds -2000 W | 78 | 0 (circulation trip stays: 4 kW goes round) |
| A drops while charging, board to 0 W | 5 | 0 |
| A drops then comes back, A holds 1200 W | 46, grid +1.1 kW | 0, grid within 250 W |
| charge timeout at 6000 W | 7, B held at 400 W | 0, B climbs |
| A switched Off at 6000 W | 6, B held at 800 W | 0, B climbs |
| N=1, N=2 with no drop | | byte-identical |
**Not changed.** At a rejoin the allocator splits again at once (A 0 ->
1205, B 2410 -> 1205 W); the site total does not step. A battery that keeps
charging on its own after its link drops still trips the circulation check
(it is real circulation). A trade end (`_reseed_run_edge`) still starts
from the full site sum.
@@ -0,0 +1,118 @@
# SAFETY-97 - "gives nothing" is graded against each battery's idle reading
**Status: implemented 2026-10-04, folded into PR #171 (SAFETY-94), for
review.** Owner decision 2026-10-04: the band's centre is clamped to
+-75 W (see Bounds). Tests: `test_safety97.py` (rig: `tools/safety94_rig.py`).
Closes SAFETY-94 review finding F1.
## The defect
B's power reading (board B's `inverter_ac_power`, 0x0518) shows -95 to -118 W
at idle: its standby draw (the GoodWe app shows 0 W). A's shows -2 to -4 W.
Every "the battery gives nothing" test was `|reading| < 100 W`, so a refusing
B sat on the band's edge and never latched:
- N=2: never caught, before or after SAFETY-94.
- N=1: the old one-instant check caught it in 17-105 s; SAFETY-94's shared
rule (12 cycles in the band in a row) never did (review probe, 1200 s).
## Design
- **Offset source: learned per unit** (`app/idle.py`, `Inverter.idle`). No
new option: a required one is a setup step every client gets wrong, and an
optional one is a second source to reconcile. An override can be added
later if a site needs one.
- **When it learns:** control runs; the unit is not refused (unit or site),
stale or sign-tripped; its command has been within 50 W (`IDLE_ASK_W`) for
120 s (`IDLE_QUIET_S`, 6 lags of 20 s). A refusing battery is asked more
than 50 W, and while refused it does not learn, so its refusal cannot be
learned as idle. A capped battery under a big ask is not sampled either.
- **How:** the first value is the mean of 120 s of samples; then it follows
drift with a 30 min time constant. A sample beyond 250 W (`IDLE_SAMPLE_W`)
is dropped (a register held at a giving value). The band is centred on
the value clamped to 75 W (`IDLE_MAX_W`; owner decision below).
- **Restart:** saved per unit (`unit/<name>/idle_w`, on first learn and each
5 W move) and restored, clamped to 250 W. Without it a battery refusing from the
start is never quiet, so never learned, so never caught.
- **Until learned the idle reading is 0 W**: the rule as it was.
## Not chosen: the GoodWe LAN integration's DC battery power
Both inverters also appear through HA's GoodWe integration (B:
`sensor.kelder_dyness_battery_power`, 0 W at idle; found read-only on the
live HA 2026-10-04). Pointing `batt_entity` at it removes B's offset by
configuration, but:
- the band must be safe without it: other installs may not have it;
- it is polled over UDP on the inverter's Wi-Fi, a second channel that can
drop while the board keeps reading (HW-01 lost both once); a missing
reading stops the site;
- SAFETY-37's 35 s bound is derived from the board's 10 s publish; the
integration's poll interval (believed 10 s, not checked here) and latency
are not measured;
- DC power is not 0x0518, which SAFETY-30/31/42 were measured against.
It stays an install choice to test on the rig, not a code change here. With
it, the learned idle reading is simply ~0 W.
## Where it applies
Every test of "gives nothing / gives / takes" now uses `reading - idle`:
| check | change |
|---|---|
| shared refusal evidence `_gave_nothing` (N=1 and N>=2) | centred |
| N=1 engage and end in `compute()` (`idle_w`, the site sum) | centred |
| N=1 end in `_refusal_update` | centred |
| N>1 unit end (probe answered, takes the other way) | centred; uncentred, B's idle -100 W read as "takes 100 W" and ended a charge refusal at once |
| stale-path refusal (`_refusal_from_stale`, N=1 and N>1) | centred |
| SAFETY-42 "parked" (small ask, gives nothing) | centred |
| SAFETY-37 stale gate | unchanged: it grades register changes against command travel, not a level |
| SAFETY-57 release | unchanged: it reads the command only |
| SAFETY-30 per-unit sign | unchanged: opposition needs both over 250 W; B's 100 W idle cannot make or hide one alone |
| SAFETY-42 shortfall | unchanged: B reads ~100 W short at every power, which errs towards reporting (report only) |
## Bounds: owner decision 2026-10-04 (review R1)
Band: `|reading - centre| < 100 W`, centre = idle clamped to +-75 W.
- **Why a clamp under the band.** A wrong idle reading (taught during a
rest while the reading shows something else, e.g. a grid top-up of a unit
held at 0 W, or restored onto a swapped inverter) more than about 100 W off
hid every refusal on that battery, at N=1 and N=2; with the first +-250 W
clamp the rig missed a refusing A whenever the error was 110-250 W. At
+-75 W the band always keeps 0 W +-25 W, so a battery reading about 0 W
while refusing is caught whatever was learned or restored, and a wrong idle
cannot refuse a healthy 0 W battery (its ask is over 200 W).
- **The trade-off.** Options: keep +-250 W (covers idle readings to about
+-350 W, any 100 W mis-learn hides refusals); clamp to +-75 W (chosen);
"old band or centred band" (rejected: a correct B would need to give 200 W
to answer its 200 W probe).
- **Cost.** A battery whose idle reading is beyond about +-170 W is centred
at most 75 W out, so its refusal falls outside the band: it behaves as in
0.14.0 (not caught by this rule). B (-95 to -118 W) reads 20-43 W from its
clamped centre. B is caught across -150..+90 W of error in its idle
reading; learned 110 W or more too high it is missed, as in 0.14.0 at N=2.
- A frozen register is still caught by the stale gate at 35 s of asking.
- Recovery from a wrong value needs quiet time: tau 30 min.
## Catch times (rig, refusing from 200 s after a 400 s rest, +-4 W)
| refusing | meter 1 s | 2 s | 5 s |
|---|---|---|---|
| A, N=1 | 28 s (before 28) | 28 (28) | 65 (65) |
| A, N=2 | 28 (28) | 28 (28) | 110 (110) |
| B, N=1 | 28 (never) | 28 (never) | 65 (never) |
| B, N=2 | 24 (never) | 24 (never) | 60 (never) |
A at N=2 on a 5 s meter is 110 s here because the rig's grid goes flat once
A is cut, so the loop cycles on the 15 s floor; the same run on the PR before
SAFETY-97 gives 110 s. B restored after a restart, refusing from the start:
24 s; with nothing saved: never.
## Before -> after
- Healthy B with its offset, SAFETY-94's four scenarios, N=1 and N=2, 1/2/5 s
meters, both register shapes, idle learned or restored (96 runs): 0 stale,
0 refused. Reversals, lag 10/20 s, idle restored: 0.
- Live replay (SAFETY-94 part 1): 0 trips (the gate is untouched).
+52 -11
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 | `maintenance_start_hour` | The local hour, 0 to 23, at which a due cycle starts. |
| Maintenance | Start hour (local time) | `maintenance_start_hour` | The hour on your local clock, 0 to 23, when a due cycle starts. 19 means 19:00. |
| Maintenance | Draw-down rate (W) | `maintenance_discharge_w` | How fast the cycle empties the battery. Above “Inverter rating” it is cut back to the rating, and the cycle just takes longer. |
| Maintenance | Recharge rate (W) | `maintenance_charge_w` | How fast the cycle recharges the battery. It must be at or below “Inverter rating”, or the add-on will not start. |
| Maintenance | Draw down to (%) | `maintenance_soc_floor` | The level the monthly cycle empties the battery to. It is not the “Peak reserve”, and the two differ on purpose. |
@@ -590,7 +590,7 @@ accumulator is doing:
| `freeze-released` | the freeze had run out of plant-shaped explanations and the command was let move again |
| `mode-bound` | the batteries' limits set the command, not the meter: their battery modes, or, with two or more inverters, a battery in its monthly maintenance |
| `not-running` | control is stopped **and** shadow mode is off, so the loop did not run |
| `battery refuses` | the battery gave less than 100 W for 12 cycles (a minute at a 5-second meter) while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) |
| `battery refuses` | the battery gave less than 100 W for 12 cycles and at least 22 s (a minute at a 5-second meter) while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) |
| `battery probe` | the add-on asks 200 W to test whether a refusing battery gives power again |
**When the battery refuses.** At its cut-out level the battery gives nothing,
@@ -600,7 +600,11 @@ request, and the `Why` row reads *The battery refuses to give power: 0 W*. Every
the battery gives 100 W or more, when the grid needs charging instead, and when
control is stopped or started. 200 W is below the 250 W the stale-reading check
needs, so a test never trips it. A battery that gives some power, even 100 W,
is not refusing and keeps its request.
is not refusing and keeps its request. Nor is a battery whose reading is
still moving towards the request, such as one turning from charging to
giving (SAFETY-94). "Gives" is counted from what the battery's reading shows
at rest, which the add-on learns after two quiet minutes and keeps: some
inverters show about -100 W at rest (SAFETY-97).
**With two or more inverters** the same rule works per inverter. An inverter
whose battery gives less than 100 W for 12 cycles, and at least 22 s, while
@@ -1639,13 +1643,30 @@ help - see "Why the tuning is what it is".
|---|---|---|
| `maintenance_enabled` | false | Enable the monthly cycle |
| `maintenance_interval_days` | 28 | Minimum gap between cycles |
| `maintenance_start_hour` | 10 | Hour of day a due cycle begins |
| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). The peak guard may ask for more (SAFETY-83; at N≥2 while another battery is at its peak reserve, SAFETY-87). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower |
| `maintenance_start_hour` | 19 | Local hour a due cycle begins (see below) |
| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). The peak guard may ask for more (SAFETY-83; at N≥2 while another battery is at its peak reserve, SAFETY-87), and at N≥2 so may the house while another battery is at its peak reserve (SAFETY-91). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower |
| `maintenance_charge_w` | 2000 | Charge ceiling, capped again by peak headroom. Must stay **at or below `max_w`** or the cheap-window shaping (`hi = -maintenance_charge_w`) contradicts the supervised limit (`lo = -max_w`), the arbiter resolves it to **0 W**, and maintenance silently never charges in a cheap window — **the add-on refuses to start** |
| `maintenance_soc_floor` | 11 | Drain target — stay just above the inverter's own floor. ⚠️ **This is not `peak_soc_floor`.** It is the depth the monthly recalibration deliberately goes to, once, on purpose; `peak_soc_floor` is the depth a money-saving discharge may never reach on any day. The right values differ (11 vs 20 as shipped), which is why they are two keys |
| `maintenance_soc_target` | 99 | Charge target |
| `maintenance_hold_min` | 120 | Hold at full so the BMS can balance |
**The start hour is local time.** The add-on reads Home Assistant's time zone
(Settings → System → General) at startup. If Home Assistant does not answer,
it uses the add-on's own time zone, and failing that UTC. The log says which
one it used, and warns if it is UTC: then restart the add-on once Home Assistant
is up. On the day the clocks go forward, a start hour that does not exist
starts at the first hour after the jump. On the day they go back, a repeated
hour starts only once, in its first pass. With two inverters both batteries use
the same local hour; one cycles at a time, and the other starts at that hour on
a later day.
**Upgrading.** The hour used to be read as UTC. An existing install keeps its
saved hour, and that hour now means local time: a saved 10 now starts at 10:00,
not 12:00 in summer. The new default, 19, only reaches installs that have no
hour saved. If you set an earlier hour to get an evening start in UTC (for
example 17 for 19:00 in summer), set it to 19. The add-on never changes a saved
value. *For installers:* DECISIONS/MAINT-10-local-start-hour.md.
### Tariff (all optional)
| option | meaning |
@@ -1667,6 +1688,9 @@ others only add power when the house needs more; none charges while it drains
(SAFETY-78). Below the drain rate the rest is exported, as with one battery.
If the house would still go over the peak limit, the battery being emptied
gives more than its drain rate, with one battery too (SAFETY-83).
A battery at its peak reserve gives last: the others give their full power
first, and the battery being emptied rises above its drain rate before it
(SAFETY-91).
In a cheap window the battery being filled charges and the others only add
charge; none gives while it charges. The charge is cut to what keeps the
15-minute peak at the limit minus `peak_margin_w` (SAFETY-84).
@@ -1674,6 +1698,13 @@ While a full battery rests after its charge (the hold), it gives just enough
to keep the 15-minute peak at that aim if the house needs it; with two
batteries the other one gives first. The hold still ends on its clock
(SAFETY-86).
When a charge ends, the other batteries take over one slew step a cycle,
and none starts charging from the grid (SAFETY-93).
When a battery's link drops, the others take over one slew step a cycle,
and control does not stall on the dropped battery's power (SAFETY-96).
When a drain ends (done, Off, timeout, abort, lost link, refused), the
other batteries cover the house at once but do not export its drain; then
they move one slew step a cycle (SAFETY-95).
#### Capacity-tariff exposure of the grid-following loop
@@ -1743,10 +1774,17 @@ ceiling at the cap, so a broken instrument makes the guard stricter, never loose
**When it cannot see, it forbids importing.** Three states are not evidence and
are checked before the projection is graded: no open quarter (a fresh averager
projects a confident 0 W, which is under every ceiling there is), a meter gap
longer than `meter_max_age_s`, and SAFETY-18's frozen-meter conjunction. In all
three the claim becomes a floor of **0 W**. That is the backstop for the frozen
meter: the alarm arrives at `meter_freeze_s` (300 s) of a 900 s billing quarter
and commands nothing, and this stops the import.
longer than `meter_max_age_s` while it lasts, and SAFETY-18's frozen-meter
conjunction. In all three the claim becomes a floor of **0 W**. That is the
backstop for the frozen meter: the alarm arrives at `meter_freeze_s` (300 s) of
a 900 s billing quarter and commands nothing, and this stops the import.
**After a gap it goes on (SAFETY-90).** The seconds the meter missed count at
the higher of the peak limit and the import seen just before and just after
the gap, with the batteries at 0 W. Seconds before the add-on's first reading
count the same way, at the import seen first, and the page says part of the
period was not measured. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md has the
bound.
**It trips, where the freeze detector only notifies,** and the captures are why.
On the healthy reference day (2026-08-23) it engages on **zero** of 95 quarters.
@@ -1809,7 +1847,9 @@ exactly like the SoC drift the monthly maintenance cycle exists to correct.
It will discharge straight past this number and keep going until the pack's own
BMS cut-out stops it. What you are promised by `peak_soc_floor: 20` is *"the
capacity-peak guard will not spend the pack for money below 20 %"*, and
**nothing more**. See below for what that costs on a real evening.
**nothing more**. See below for what that costs on a real evening. With two
or more batteries, one under it is used last: only once the others give
their full power (SAFETY-91).
- **When it binds you are told**, once per episode, with notification
`goodwe_peak_soc_floor` and a red status-page row reading *Battery at its
peak reserve* (`PEAK_GUARD_FLOOR_LIMITED` in the log): the quarter **will**
@@ -3450,7 +3490,8 @@ reason code, and its full figures are on the DEBUG line right after it and in
…`, and the peak guard's full reason is on the DEBUG line after its ERROR.
- ***name*: battery power reading stopped changing** is SAFETY-37/56/57. The
ERROR line says when it clears and names `STALE_READING`. The full reason,
with both possible causes, is on the DEBUG line after it.
with both possible causes, is on the DEBUG line after it. The 35 s counts
only while the command asks the reading to move (SAFETY-94).
- **Meter: no reading for …** (red): the add-on logs one WARNING when the
meter goes quiet, "No meter reading for …", and an INFO line when readings
return, at most once every 10 minutes. Past `stale_input_s` the **Why**
+3 -3
View File
@@ -737,9 +737,9 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
gate, held = _unit_modes(out, site_claims, bounds, live)
if callable(weights):
weights = weights(out.target_w)
# SAFETY-87: units under their peak reserve weigh 0 while the site gives,
# so the others carry the peak floor. An Off battery gives the peak's
# shortfall, so it goes before them when no other unit is left.
# SAFETY-87/91: units under their peak reserve weigh 0 while the site
# gives, so the others carry the target first. An Off battery gives the
# peak's shortfall, so it goes before them when no other unit is left.
reserve = set(reserve) if out.target_w > 0.0 else set()
if held and reserve and not set(bounds) - held - reserve:
held = set()
+16 -2
View File
@@ -484,6 +484,8 @@ def compute(
refused: int | None = None,
probe: bool = False,
bound: tuple | None = None,
gave_nothing: bool | None = None,
idle_w: float = 0.0,
) -> Decision:
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
@@ -497,6 +499,13 @@ def compute(
`bound` OPT-11: `(lo, hi)`, what the batteries may place this tick with
their modes applied (`allocator.loop_bound()`), or None. See
MODE_BOUND below. None is the law as it was.
`gave_nothing` SAFETY-94: the caller's span check - the reading stayed
under REFUSED_BELOW_W, one way, for SETTLE_CYCLES spanning
REFUSED_SPAN_S. A refusal engages only with it. None leaves it
out (compute() alone, as before).
`idle_w` SAFETY-97: what `actual_w` reads while the batteries give
nothing (app/idle.py). The refusal's band and its end are
centred on it. 0 is the law as it was.
"""
reason = "tracking"
# OPT-11: a bound that leaves out 0 W would FORCE a direction, which no
@@ -817,13 +826,18 @@ def compute(
# `saturation_w` is a refusal, not a slow plant. The drop to 0 skips the slew
# limit because it only removes an ask the battery is not meeting.
if refused is not None:
# SAFETY-94: and only when the reading has given nothing for the
# whole span, not just now. `sat_count` also counts the cycles a
# healthy battery spends reversing through its lag (-2000 W towards
# +800 W): at a 1 s meter its 12 cycles end as the reading passes 0.
if (refused == 0 and frozen and sat_count >= SETTLE_CYCLES
and abs(prev_w) > tuning.saturation_w
and abs(actual_w) < REFUSED_BELOW_W):
and abs(actual_w - idle_w) < REFUSED_BELOW_W
and gave_nothing is not False):
refused = 1 if prev_w > 0 else -1
if refused and refused * i_w < 0:
refused = 0 # the loop wants the other way
elif refused and refused * actual_w >= PROBE_ANSWER_W:
elif refused and refused * (actual_w - idle_w) >= PROBE_ANSWER_W:
refused = 0 # it delivers: a probe answered,
# or a slow reading arriving
elif refused:
+53 -7
View File
@@ -87,6 +87,10 @@ when it has not moved THROUGH A CHANGE IT WAS OBLIGED TO FOLLOW:
AND |standing command now - standing command when the reading
last changed| >= MOVE_W
SAFETY-94: "unchanged for" counts only the reads at which both command terms
held, so a register that rested is not judged before it could answer.
DECISIONS/SAFETY-94-lagging-battery.md.
0x0518 is the inverter reporting ITSELF and it tracks its own command: r = 0.998
with battery power against r = 0.09 with the real meter (goodwe/NOTES.md §3.3),
command and actual agreeing within 4 W once settled (§5.10, 2026-08-22), and the
@@ -216,6 +220,8 @@ change - which was the ONLY exit - has a median of 143 s, 17 episodes at or
over the live one's 710 s, and a worst case of 21440 s. That distribution is a
property of this battery's firmware. It is not a property of this code, and the
add-on must not depend on it.
SAFETY-94: many were the gate judging a rested register on its first read
(SAFETY-58). Counted from the ask, the same days give 59 trips.
THE RULE, and it is SAFETY-13's shape one module over - "which latches get an
exit, not how long the block lasts":
@@ -372,7 +378,9 @@ BOUNDS = {
"SAFETY-57: the verdict is withdrawn after 17.0 s of the "
"standing command below 250 W, because at 0 W the register "
"is obliged to nothing and the verdict cannot be tested "
"again from inside the state it creates"),
"again from inside the state it creates. SAFETY-94: the "
"35 s counts only reads at which the command asks the "
"register to move"),
"soc": Bound(
signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)",
unchanged_s=float("inf"),
@@ -416,11 +424,9 @@ class Freshness:
new grading window at that instant. The 1 Hz oscillation the latch exists to
prevent is still prevented: the new window is `bound.unchanged_s` wide, so
no re-trip can land sooner than 35 s after a release, rather than on the
next read. ⚠️ MEASURED FROM THE RELEASE, NOT FROM THE COMMAND RESUMING - if
the site rests for longer than the window before it asks for anything, the
window is already old and the re-trip lands on the first read at which the
command travels 250 W again. That is survivor 5's shape, it predates this
card, and its cost is one `stale_input_s` hold.
next read. SAFETY-94: the 35 s counts only while the command asks the
register to move, so a site that rests after a release is not re-tripped
on its first ask.
⚠️ TWO CLOCKS, AND THE DISTINCTION IS THE WHOLE OF SAFETY-57'S HONESTY.
`changed_mono` / `held` are facts about the REGISTER: when it last carried a
@@ -451,6 +457,11 @@ class Freshness:
# the change-stamps; `_release()` is where they diverge.
self.graded_from = None
self.standing_at_grade = None
# SAFETY-94. Seconds of this window during which the register was
# obliged to move (the command at least `move_w` and `move_w` away
# from `standing_at_grade`), and the instant of the last read.
self.obliged_acc = 0.0
self.last_read = None
# The instant the standing command last FELL below `move_w`, or None
# while an obligation is standing. The release timer runs off this.
self.rested_since = None
@@ -499,6 +510,7 @@ class Freshness:
# it reads in the order the state machine moves - withdraw, then
# re-grade - not because the code depends on it.
self._release(standing_w, now_mono)
self._oblige(standing_w, now_mono)
self._grade(standing_w, now_mono)
def _open_window(self, standing_w, now_mono) -> None:
@@ -510,6 +522,35 @@ class Freshness:
self.graded_from = float(now_mono)
self.standing_at_grade = (None if standing_w is None
else float(standing_w))
# The read that opens a window is never an ask (no travel yet).
self.obliged_acc = 0.0
def _obliged(self, standing_w) -> bool:
"""Is the register asked to move: the command is at least `move_w` and
has travelled `move_w` since the window opened?"""
if standing_w is None or self.standing_at_grade is None:
return False
standing_w = float(standing_w)
return (abs(standing_w) >= self.bound.move_w
and abs(standing_w - self.standing_at_grade)
>= self.bound.move_w)
def _oblige(self, standing_w, now_mono) -> None:
"""SAFETY-94. Count only the time the register was asked to move.
A register that held still while nothing asked it to move has not
failed, so time before the command moved is not evidence. The time
since the previous read counts when the obligation stands at this
read; a command flapping in and out of the obligation still adds up.
"""
now_mono = float(now_mono)
if self.last_read is not None and self._obliged(standing_w):
self.obliged_acc += max(0.0, now_mono - self.last_read)
self.last_read = now_mono
def obliged_s(self) -> float:
"""Seconds of the current window the register was obliged to move."""
return self.obliged_acc
def _release(self, standing_w, now_mono) -> None:
"""SAFETY-57. Withdraw a verdict whose obligation is no longer standing.
@@ -543,7 +584,12 @@ class Freshness:
if self.stale or self.graded_from is None:
return
held_s = self.graded_s(now_mono)
if held_s < self.bound.unchanged_s:
# SAFETY-94 (closes SAFETY-58): the bound counts only the time the
# command asked the register to move, not the time since it last
# changed. A register that rested for minutes was otherwise judged on
# the first read after a new command, before it could answer (live,
# 2026-10-03: 1 s after the command).
if self.obliged_s() < self.bound.unchanged_s:
return
# ⚠️ BOTH None GUARDS ARE FOR A CALLER THAT DOES NOT KNOW ITS OWN
# OBLIGATION YET, WHICH IS NOT `_read_unit`. `Inverter.standing_w()`
+14 -9
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
from .maintenance import IDLE as M_IDLE, next_slot_start
from .packmode import CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, SOLAR_FIRST
from .price import Absence, Uncertainty, brussels
from .static_tariff import static_plan
@@ -439,8 +439,8 @@ def solar_block(c, now):
def maintenance_runs(c, day0, day1, now, shadow):
"""The monthly battery cycle: under way now, or starting later today.
The cycle starts in the hour `maintenance_start_hour` names, read as the
control cycle reads it (UTC hour of `now`), shown in local time."""
The cycle starts in the hour `maintenance_start_hour` names, read in the
site's zone exactly as the control cycle reads it (MAINT-10)."""
runs, lines = [], []
waiting = []
for i, (inv, m) in enumerate(zip(c.inverters, c.maints)):
@@ -459,10 +459,9 @@ def maintenance_runs(c, day0, day1, now, shadow):
if not m.due(now) and (m.next_due(now) is None
or m.next_due(now) >= day1):
continue
start = now.replace(hour=cfg.start_hour, minute=0, second=0,
microsecond=0)
if start + timedelta(hours=1) <= now:
start += timedelta(days=1)
start = next_slot_start(now, cfg.start_hour, m.tz)
if start is None:
continue
due_at = m.next_due(now)
if due_at is not None and due_at > start:
continue
@@ -904,7 +903,9 @@ def month_peak(c, now):
if pg is not None:
out["period_w"] = pg.projected_w
ceiling = pg.ceiling_w or limit_w
out["line"] = line_period(out["period_w"], ceiling, c.shadow)
out["line"] = line_period(out["period_w"], ceiling, c.shadow,
unmeasured=getattr(c, "peak_unmeasured_s", 0.0)
> 0.0)
return out
@@ -1294,8 +1295,11 @@ def line_peak_not_covered() -> str:
return _t("peak_not_covered")
def line_period(period_w, limit_w, shadow) -> str:
def line_period(period_w, limit_w, shadow, unmeasured=False) -> str:
step = _w(shadow, "step_in", kw=_kw(limit_w))
if unmeasured:
# SAFETY-90: the add-on started during this period.
return _t("period_unmeasured") + " " + step
if period_w is None:
return step
return _t("period", kw=_kw(period_w)) + " " + step
@@ -1369,6 +1373,7 @@ def _samples() -> list:
("plan", line_peak_limit(3500, sh)),
("plan", line_period(1240, 3500, sh)),
("plan", line_period(None, 3500, sh)),
("plan", line_period(1240, 3500, sh, unmeasured=True)),
("plan", control_text(True, sh)),
("plan", control_text(False, sh))]
out += [("plan", run_grid_why(0.1412)), ("plan", run_grid_amount(1.23)),
+87
View File
@@ -0,0 +1,87 @@
"""SAFETY-97: what a battery's power reading shows while it gives nothing.
B's reading (0x0518) shows -95 to -118 W at idle (standby draw, live
2026-10-03); A's shows -2 to -4 W. "The battery gives nothing" is graded
against this idle reading, not against 0 W, or a refusing B never latches.
DECISIONS/SAFETY-97-idle-offset.md.
Learned per unit, never configured:
- only while control runs, the unit is not refused, stale or tripped, and
its command has been within IDLE_ASK_W for IDLE_QUIET_S (a refusing battery
is asked more than that, so it cannot teach its refusal as idle);
- a reading beyond IDLE_SAMPLE_W is not a sample;
- first value: the mean of IDLE_FIRST_S of samples; then a slow follow
(IDLE_TAU_S);
- persisted per unit and restored, clamped to IDLE_SAMPLE_W, at start.
The band is centred on it clamped to IDLE_MAX_W (owner, 2026-10-04). Until
learned the idle reading is 0 W, the rule as it was.
"""
import math
IDLE_MAX_W = 75.0 # |idle| used: under the 100 W band, so it keeps 0 W +-25 W
IDLE_SAMPLE_W = 250.0 # a reading beyond this is not a sample
IDLE_ASK_W = 50.0 # a command this small counts as "asked ~0"
IDLE_QUIET_S = 120.0 # 6 lags of 20 s: a 2500 W step has decayed to 6 W
IDLE_FIRST_S = 120.0 # seconds of samples averaged for the first value
IDLE_TAU_S = 1800.0 # then follow drift this slowly
IDLE_SAVE_W = 5.0 # persist when the value moved this far
IDLE_GAP_S = 10.0 # a longer gap between reads counts as this
def _num(v):
try:
v = float(v)
except (TypeError, ValueError):
return None
return v if math.isfinite(v) else None
class IdleReading:
"""One unit's idle reading. `w` is what "gives nothing" is centred on."""
def __init__(self, saved=None):
v = _num(saved)
self.value = (None if v is None
else max(-IDLE_SAMPLE_W, min(IDLE_SAMPLE_W, v)))
self.saved = self.value
self.quiet_since = None
self.last = None
self.acc_w = 0.0
self.acc_s = 0.0
@property
def w(self) -> float:
"""The centre used: the learned value clamped to IDLE_MAX_W."""
if self.value is None:
return 0.0
return max(-IDLE_MAX_W, min(IDLE_MAX_W, self.value))
def observe(self, reading, standing_w, now_mono, may_learn) -> bool:
"""One read. Returns True when the value should be persisted."""
now = float(now_mono)
dt = (0.0 if self.last is None
else max(0.0, min(IDLE_GAP_S, now - self.last)))
self.last = now
standing = _num(standing_w)
if not may_learn or standing is None or abs(standing) > IDLE_ASK_W:
self.quiet_since = None
return False
if self.quiet_since is None:
self.quiet_since = now
b = _num(reading)
if (now - self.quiet_since < IDLE_QUIET_S or b is None
or abs(b) > IDLE_SAMPLE_W or dt <= 0.0):
return False
if self.value is None:
self.acc_w += b * dt
self.acc_s += dt
if self.acc_s < IDLE_FIRST_S:
return False
self.value = self.acc_w / self.acc_s
else:
self.value += (b - self.value) * min(1.0, dt / IDLE_TAU_S)
if self.saved is None or abs(self.value - self.saved) >= IDLE_SAVE_W:
self.saved = self.value
return True
return False
+10 -1
View File
@@ -127,6 +127,7 @@ from . import messages
# SAFETY-37. The freshness policy, and the ONE place its bounds are written.
# freshness.py imports nothing from this package, so the direction is one-way.
from .freshness import BOUNDS, Freshness
from .idle import IdleReading
# CFG-03's ONE in-code mirror of config.yaml's SAFETY-24 defaults. Imported
# rather than retyped: the flat-keys path below needs `bms_soc_floor`'s
# shipped default and a literal here would be the fourth defaults site that
@@ -321,13 +322,21 @@ class Inverter:
# take, 0 none), when that refusal or its last probe started, the
# current probe's start, and the cycles of evidence so far and when
# they began. Memory only, like SAFETY-52's: stop and start end it.
# Used at N>1 only.
# The evidence count is also used at N=1 (SAFETY-94); the rest at N>1.
self.refused = 0
self.refused_at = None
self.probe_from = None
self.ref_count = 0
self.ref_since = None
self.ref_sign = 0
self.ref_from = None
# SAFETY-97: what this unit's reading shows at idle; "gives nothing"
# is centred on it. Learned, persisted, restored (app/idle.py).
self.idle = IdleReading(data.get(self.key("idle_w")))
@property
def idle_w(self) -> float:
return self.idle.w
# -- store ---------------------------------------------------------------
def key(self, name: str) -> str:
+472 -37
View File
@@ -30,6 +30,7 @@ import functools
import json
import logging
import math
import os
import signal
import time
from datetime import datetime, timedelta, timezone
@@ -68,15 +69,15 @@ from . import messages
from .freeze import (
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
)
from .hass import HomeAssistant
from .hass import CORE_API, HomeAssistant
# ⚠️ `LINK_STALE_S` IS RE-EXPORTED DELIBERATELY. `live_units()` reasons about it
# and two checks read `app.main.LINK_STALE_S` to build a stale link, so the
# window an operator would look up here is the one the roster actually uses.
from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
roster_entries, roster_names)
from .maintenance import (
DRAIN, HOLD, IDLE, POWER_MOVING, MaintConfig, Maintenance, MaintResult,
arbitrate as maint_arbitrate,
CHARGE, DRAIN, HOLD, IDLE, POWER_MOVING, MaintConfig, Maintenance,
MaintResult, arbitrate as maint_arbitrate, site_zone,
)
from .mqtt import (MODE_CONFIGS_KEY, MODE_LABELS, USE_OFF, USE_ON,
MqttPublisher, announced_units, mode_configs,
@@ -91,7 +92,7 @@ from .price import required_spread as price_required_spread
from .price import uncertainty_of as price_uncertainty_of
from .price_history import PriceHistory
from .static_tariff import resolve_tariff, static_status
from .tariff import PeakStore, PeakTracker
from .tariff import PeakStore, PeakTracker, next_quarter
from .store import migrate_maint_keys, migrate_unit_keys
from .validate import (FIRMWARE_INGEST_VETO_S, ConfigRefused, refuse,
validate_startup_config, validate_unit_ranges)
@@ -281,6 +282,14 @@ def pack_energy_of(inv):
# ordinary load steps. At 2 s and slower this changes nothing.
REFUSED_SPAN_S = (_ctl.SETTLE_CYCLES - 1) * _ctl.LOOP_TICK_S
# SAFETY-94: the evidence restarts when the reading has moved this far towards
# the ask since the evidence began. A refusing battery reads flat (-4 W live,
# 2026-09-06); live readings wander by under 25 W at rest (B's standby, -95 to
# -118 W, 2026-10-03). A battery crossing the 100 W band on a first-order lag
# gains 50 W in at most tau x ln(151/101) = 0.4 tau, so the evidence cannot
# span REFUSED_SPAN_S unless the lag is over 54 s.
REFUSED_PROGRESS_W = _ctl.PROBE_ANSWER_W / 2
# SAFETY-85 review: a battery another owner set is graded against the least it
# was told over the same span as above (the plant's measured settle time), so
# a healthy battery still settling on a new command is not short.
@@ -416,7 +425,8 @@ def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now,
class Controller:
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
peak_db_path: str = "/data/tariff.db",
price_history_path: str = "/data/price_history.json"):
price_history_path: str = "/data/price_history.json",
site_tz=None):
# UX-15: a read-only HomeAssistant belongs to setup-only mode, which
# builds no controller. Refused here so no controller can hold one.
if getattr(hass, "read_only", False) is True:
@@ -657,7 +667,8 @@ class Controller:
name=inv.name,
# The rollback view, and only ever the first pack's: the flat
# keys mean `inverters[0]` on every install that has them.
mirror_flat=(not _one_pack and i == 0))
mirror_flat=(not _one_pack and i == 0),
tz=site_tz) # MAINT-10: the start hour's zone, from amain()
for i, inv in enumerate(self.inverters)]
# SAFETY-02. ONE INSTANCE PER ACTUATOR - which is what watchdog.py's own
@@ -939,6 +950,22 @@ class Controller:
# SAFETY-81: what the alarm is graded on, beside the guard's decision.
self._peak_proj = None
self._peak_batt_prev = None
# SAFETY-90: what a meter gap is counted at (`_fill_peak_gaps()`).
self._gap_seen_w = None
self._gap_w = None
self._gap_block = None
self._gap_filled_s = 0.0
self._gap_held_ws = 0.0
self._gap_pre = None
self._unseen = None
# A held sample TARIFF-01 restored from before this restart: the
# averager credits it max_hold_s into the restart, at 0 W batteries.
_held = self.p1.averager.held_since
self._run_pre = (None if _held is None
else (_held, self.p1.averager.held_net_w))
self._unseen_held_ws = 0.0
self.peak_gap_s = 0.0
self.peak_unmeasured_s = 0.0
self._peak_alarm_block = None
self._peak_high_since = None
self._peak_floor_limited = False
@@ -992,6 +1019,24 @@ class Controller:
# SAFETY-78: names of draining packs that cannot give power now, so
# the "waits" line is logged once, not every cycle.
self._drain_waits = set()
# SAFETY-91: last cycle's held-back batteries (`_held_back()`).
self._held_was = frozenset()
# SAFETY-93: the batteries placed in their maintenance charge last
# cycle, and those whose charge ended since the loop last ran.
self._charging_was = frozenset()
self._charge_ended = frozenset()
# SAFETY-95: the same for a maintenance drain.
self._draining_was = frozenset()
self._drain_ended = frozenset()
# SAFETY-95: batteries settling after their drain ended; meanwhile
# the loop's error counts every battery at its command.
self._drain_tail = set()
# SAFETY-96: last cycle's commandable batteries, this cycle's start
# point per battery where a reseed moved it, and batteries settling
# after a step the loop did not make ({name: since, monotonic}).
self._live_was = None
self._loop_start = {}
self._settle = {}
# Set by amain() once the transport is built, so the status page can show
# what only the transport knows (homewizard_local's unchanged_s). Stays
# None when P1 is off, or under a transport that has no such counter.
@@ -1235,7 +1280,8 @@ class Controller:
f"gave {batt_w:+.0f} W for "
f"{decision.sat_count} cycles")
elif self.batt_refused and not now:
gave = self.batt_refused * batt_w >= _ctl.PROBE_ANSWER_W
gave = (self.batt_refused * (batt_w - self._site_idle_w())
>= _ctl.PROBE_ANSWER_W)
self._refusal_end("the probe was answered" if gave and probe
else "the battery gives power" if gave
else "the grid now asks the other direction")
@@ -1253,7 +1299,8 @@ class Controller:
if ((self.auto or self.shadow) and not inv.refused
and inv.ref_count >= t.saturation_cycles
and inv.batt_fresh.held is not None
and abs(inv.batt_fresh.held) < _ctl.REFUSED_BELOW_W
and abs(inv.batt_fresh.held - inv.idle_w)
< _ctl.REFUSED_BELOW_W
and abs(standing) > _ctl.PROBE_W):
self._now_mono = now_mono
self._unit_refusal_start(
@@ -1265,7 +1312,8 @@ class Controller:
and not self.batt_refused
and self.sat_count >= t.saturation_cycles
and inv.batt_fresh.held is not None
and abs(inv.batt_fresh.held) < _ctl.REFUSED_BELOW_W
and abs(inv.batt_fresh.held - inv.idle_w)
< _ctl.REFUSED_BELOW_W
and abs(standing) > t.saturation_w):
self._now_mono = now_mono
self._refusal_start(1 if standing > 0 else -1,
@@ -1338,7 +1386,9 @@ class Controller:
for inv in self.inverters:
b = inv.batt
if inv.refused:
if b is not None and inv.refused * b >= _ctl.PROBE_ANSWER_W:
# SAFETY-97: given and taken are counted from the idle reading.
if (b is not None and inv.refused * (b - inv.idle_w)
>= _ctl.PROBE_ANSWER_W):
self._unit_refusal_end(
inv, "the probe was answered"
if inv.probe_from is not None
@@ -1349,32 +1399,60 @@ class Controller:
# on its own reading, so one cycle of the loop overshooting
# through zero does not hand it its share back.
inv.ref_count = (inv.ref_count + 1 if b is not None and
-inv.refused * b >= _ctl.PROBE_ANSWER_W else 0)
-inv.refused * (b - inv.idle_w)
>= _ctl.PROBE_ANSWER_W else 0)
if inv.ref_count >= _ctl.SETTLE_CYCLES:
self._unit_refusal_end(
inv, "the battery takes power the other way")
continue
if b is None:
continue # a gap keeps the count: the gate's trip seeds
standing = inv.standing_w()
sign = 1 if standing > 0 else -1
# One direction throughout: a command that swaps sign every cycle
# (a flapping load) reads ~0 through the plant's own lag, and that
# is a battery following, not refusing (TEST-19 flapping family).
if abs(standing) > _ctl.PROBE_W and abs(b) < _ctl.REFUSED_BELOW_W:
if sign != inv.ref_sign or not inv.ref_count:
inv.ref_count, inv.ref_since = 0, self._now_mono
inv.ref_count = min(inv.ref_count + 1, _ctl.SETTLE_CYCLES)
else:
inv.ref_count = 0
inv.ref_sign = sign
if inv.ref_count >= _ctl.SETTLE_CYCLES and (
inv.ref_since is None or self._now_mono is None
or self._now_mono - inv.ref_since >= REFUSED_SPAN_S):
if inv.rejected_since is not None:
# SAFETY-96: its writes are refused, so its board may have
# gone to 0 W on its own; that is no refusal by the battery.
# The count is kept, as across a gap, so a link that refuses
# now and then does not hide a battery that does refuse.
continue
sign = self._gave_nothing(inv)
if sign:
self._unit_refusal_start(
inv, sign,
f"asked {standing:+.0f} W, the battery gave {b:+.0f} W "
f"for {_ctl.SETTLE_CYCLES} cycles")
f"asked {inv.standing_w():+.0f} W, the battery gave "
f"{b:+.0f} W for {_ctl.SETTLE_CYCLES} cycles")
def _gave_nothing(self, inv) -> int:
"""The refusal evidence, one cycle of it: the sign `inv` has given
under REFUSED_BELOW_W while asked over PROBE_W, one way, for
SETTLE_CYCLES spanning REFUSED_SPAN_S, else 0. SAFETY-73 at N>1;
SAFETY-94 adds it to the N=1 engage in compute(). SAFETY-97: the band
is centred on the unit's idle reading."""
b = inv.batt
if b is None:
return 0 # a gap keeps the count: the gate's trip seeds
standing = inv.standing_w()
sign = 1 if standing > 0 else -1
# One direction throughout: a command that swaps sign every cycle
# (a flapping load) reads ~0 through the plant's own lag, and that
# is a battery following, not refusing (TEST-19 flapping family).
if (abs(standing) > _ctl.PROBE_W
and abs(b - inv.idle_w) < _ctl.REFUSED_BELOW_W):
# SAFETY-94: a reading still moving towards the ask is a battery
# catching up through its lag, not one refusing: start again.
if (sign != inv.ref_sign or not inv.ref_count
or sign * (b - inv.ref_from) >= REFUSED_PROGRESS_W):
inv.ref_count, inv.ref_since, inv.ref_from = (
0, self._now_mono, b)
inv.ref_count = min(inv.ref_count + 1, _ctl.SETTLE_CYCLES)
else:
inv.ref_count = 0
inv.ref_sign = sign
if inv.ref_count >= _ctl.SETTLE_CYCLES and (
inv.ref_since is None or self._now_mono is None
or self._now_mono - inv.ref_since >= REFUSED_SPAN_S):
return sign
return 0
def _site_idle_w(self) -> float:
"""SAFETY-97: the site reading at idle, the sum of the units'."""
return float(sum(inv.idle_w for inv in self.inverters))
def _site_refused(self):
"""Owner decision 4: every unit refuses the same way, so the site law
@@ -2366,6 +2444,152 @@ class Controller:
self.target = want
self.i_w = want - self.tuning.gain * (want - float(batt))
def _charge_track(self, claims, maint_results) -> None:
"""SAFETY-93, N>=2: a charge ends when its battery leaves CHARGE or can
no longer be placed (stale link, refusal). Kept until the loop runs.
SAFETY-95: a drain likewise; its refusal (SAFETY-73) outranks the pin
without a contradiction, so it is read from the battery (+1, will not
give; a battery that will not take still drains)."""
if len(self.inverters) < 2:
return
live = set(self.live_units())
now = {CHARGE: set(), DRAIN: set()}
for inv, res in zip(self.inverters, maint_results):
u = self.actuator_of("maintenance", inv)
if res.phase in now and u in live and not pre_resolve(
[c for c in claims if c.actuator == u]).contradiction \
and not (res.phase == DRAIN and (inv.refused or 0) > 0):
now[res.phase].add(inv.name)
self._charge_ended |= self._charging_was - now[CHARGE]
self._charging_was = frozenset(now[CHARGE])
self._drain_ended |= self._draining_was - now[DRAIN]
self._draining_was = frozenset(now[DRAIN])
def _reseed_charge_end(self, maint_results) -> None:
"""SAFETY-93, N>=2: when a battery's maintenance charge ends, the loop
starts from what the others stood at plus that battery's new command
(its HOLD pin, or its last discharge if it is free; a charge stops).
Else the charge's watts stay in the site target and land on the
others: B 0 -> -1600 W in one cycle. DECISIONS/SAFETY-93-charge-end.md.
"""
ended, self._charge_ended = self._charge_ended, frozenset()
if len(self.inverters) < 2 or not ended:
return
for inv, res in zip(self.inverters, maint_results):
if inv.name not in ended:
continue
now_w = (float(res.setpoint_w or 0.0) if res.owns_setpoint
else max(float(inv.target), 0.0))
if not inv.is_live(datetime.now(timezone.utc))[0]:
now_w = 0.0 # SAFETY-96: out of the split, it is sent 0 W
self.target += now_w - float(inv.target)
# SAFETY-96: it settles from its charge to that command.
self._loop_start[inv.name] = now_w
self._settle[inv.name] = self._now_mono
self.i_w = self.target
def _reseed_drain_end(self, maint_results, live) -> None:
"""SAFETY-95, N>=2: when a battery's maintenance drain ends, its
drain leaves the loop's start point, as a charge does (SAFETY-93): the
start is what the others stood at plus its new command (its pin, else
0 W). Where the house needs more, the start is the house's own point
(the live batteries' readings + grid - target), never above last
cycle's start. The loop then moves one slew step a cycle. Else the
drain's floor stays in the target and lands on the others: B 0 ->
+1600 W in one cycle, exporting. DECISIONS/SAFETY-95-drain-end.md.
"""
ended, self._drain_ended = self._drain_ended, frozenset()
if len(self.inverters) < 2 or not ended:
return
hi = self.target
for inv, res in zip(self.inverters, maint_results):
if inv.name not in ended:
continue
now_w = (float(res.setpoint_w or 0.0)
if res.owns_setpoint and res.phase != DRAIN
else min(float(inv.target), 0.0))
if inv.name not in live:
now_w = 0.0 # SAFETY-96: out of the split, it is sent 0 W
else:
self._drain_tail.add(inv.name)
self.target += now_w - float(inv.target)
self._loop_start[inv.name] = now_w
self._settle[inv.name] = self._now_mono
lo, hi = sorted((self.target, hi))
# A dropped battery's power is the meter's, like the house's.
here = float(self.grid) - self.tuning.target_grid_w + sum(
float(inv.batt) for inv in self.inverters if inv.name in live)
self.target = self.i_w = min(hi, max(lo, here))
def _drain_grid_w(self, live) -> float:
"""SAFETY-95: the grid the loop regulates. While a battery settles
from its ended drain (`_held_out_w`: until within the deadband, at
most PLANT_SETTLE_DOWN_S), the meter still shows the drain's tail and
the others' climb, so the loop would chase them (B down, then up
again). Meanwhile the batteries count at the site command: the error
is the house's own point minus the loop's start."""
grid = float(self.grid)
self._drain_tail &= set(self._settle) & set(live)
if not self._drain_tail:
return grid
return grid - float(self.target) + sum(
float(inv.batt) for inv in self.inverters if inv.name in live)
def _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 | self._drain_ended:
continue # SAFETY-93/95's edge: `_reseed_*_end()`
if inv.name in was and inv.name not in live:
now_w = 0.0
elif inv.name in live and inv.name not in was:
now_w = float(inv.batt)
# The split is made again, so every battery's command steps.
self._settle.update(
{u: self._now_mono for u in live})
else:
continue
self.target += now_w - float(inv.target)
self._loop_start[inv.name] = now_w
self.i_w = self.target
def _held_out_w(self, live) -> float:
"""SAFETY-96, N>=2: what to add to the site sum so that a battery
the loop cannot move right now counts at its command, not at its
reading. That is a battery out of the split, one whose writes are
refused, or one settling after a step the loop did not make (a charge
end, a rejoin) until its reading is within the deadband of its
command or PLANT_SETTLE_DOWN_S has passed. Its real power still
reaches the loop through the meter, so it is neither lost nor counted
twice; only the saturation freeze stops seeing it as the loop's.
"""
if len(self.inverters) < 2:
return 0.0
out = 0.0
for inv in self.inverters:
cmd = self._loop_start.get(inv.name, float(inv.target))
since = self._settle.get(inv.name)
age = (None if since is None or self._now_mono is None
else self._now_mono - since)
if since is not None and (
age is None or age >= _ctl.PLANT_SETTLE_DOWN_S
or (age > 0 and abs(float(inv.batt) - cmd)
< self.tuning.deadband_w)):
del self._settle[inv.name]
if (inv.name not in live or inv.rejected_since is not None
or inv.name in self._settle):
out += cmd - float(inv.batt)
return out
def _mode_fault_log(self, resolution) -> None:
"""OPT-11: say once, per battery, that its mode was dropped as a fault.
@@ -2567,6 +2791,126 @@ class Controller:
return frozenset()
return frozenset(g.held)
def _held_back(self) -> frozenset:
"""SAFETY-91, N>=2: the batteries at or under their own peak reserve
while another can still give (live, not refusing). They weigh 0 in
every split where the site gives, so they give last (SAFETY-25
option A: still drawn once the others are full). One held back
stays so until its SoC reads more than 1 point above its reserve, so
a reading flipping across it does not swap the split each cycle."""
if len(self.inverters) < 2:
return frozenset()
floor = float(self.o.get(
"peak_soc_floor", peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"]))
ok, held = peakguard.reserve_split(self.packs, floor)
held = set(held) | {
p.name for p in ok if p.name in self._held_was
and float(p.soc) <= peakguard.pack_floor_pct(p, floor) + 1.0}
live = set(self.live_units())
if not held or not any(
p.name not in held and inv.name in live
and not (inv.refused or 0) > 0
for p, inv in zip(self.packs, self.inverters)):
self._held_was = frozenset()
else:
self._held_was = frozenset(held)
return self._held_was
def _drain_rise(self, claims, maint_results) -> None:
"""SAFETY-91, N>=2: while a battery is held back (`_held_back()`), a
draining battery's pin becomes a floor at its drain rate, so it rises
towards its rating before the held one gives. A site stop still
decides alone. DECISIONS/SAFETY-91-held-battery-last.md."""
held = self._held_back()
if not held or any(c.kind == SET and c.actuator == ACTUATOR_SITE
and c.priority > P_PEAK for c in claims):
return
live = set(self.live_units())
for inv, res in zip(self.inverters, maint_results):
u = self.actuator_of("maintenance", inv)
if res.phase != DRAIN or not res.owns_setpoint \
or inv.name in held or u not in live:
continue
b = pre_resolve([c for c in claims if c.actuator == u])
p = (None if b.contradiction or b.pinned_by != "maintenance"
else allocator.pin_w(b))
if p is None or not p > 0.0:
continue
for i, c in enumerate(claims):
if c.source == "maintenance" and c.kind == SET \
and c.actuator == u:
claims[i] = Claim.limit("maintenance", P_MAINTENANCE,
lo=p, reason=c.reason,
actuator=u)
def _fill_peak_gaps(self, proj, now, reading):
"""SAFETY-90: `proj` with a meter gap's uncounted seconds counted at
`peakguard.gap_w()` of the imports seen just before and just after it,
and those before this run's first reading likewise at the import seen
first (shown as not measured).
DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
self.peak_gap_s = self.peak_unmeasured_s = 0.0
avg = self.p1.averager
since = avg.held_since
if proj is None or proj.block_start is None or since is None:
return proj
if self._unseen is None and self.p1.last is not None:
self._unseen = [proj.block_start, max(0.0, (
since - proj.block_start).total_seconds() - avg.elapsed_s),
since, None]
seen = (None if self.grid is None or self.site_batt_w is None
else float(self.grid) + max(0.0, float(self.site_batt_w)))
if proj.block_start != self._gap_block:
self._gap_block, self._gap_w = proj.block_start, None
self._gap_filled_s = self._gap_held_ws = 0.0
cap = float(self.o.get("peak_cap_w", 3500))
ceiling = peakguard.quarter_ceiling_w(cap, reading)[0]
unseen_on = (self._unseen is not None
and self._unseen[0] == proj.block_start)
if unseen_on and self._unseen[3] is None and seen is not None:
# While the add-on was down the batteries were at 0 W, so the
# seconds before its first reading count like a gap, at the
# import seen first; a restored reading's held part is topped up.
self._unseen[3] = peakguard.gap_w(ceiling, seen)
if self._run_pre is not None:
s0, g0 = self._run_pre
h = (min(s0 + timedelta(seconds=avg.max_hold_s),
self._unseen[2])
- max(s0, proj.block_start)).total_seconds()
self._unseen_held_ws = max(0.0, h) * max(
0.0, self._unseen[3] - max(0.0, g0))
kw = dict(tail_s=(now - since).total_seconds(),
max_hold_s=avg.max_hold_s,
unseen_s=self._unseen[1] if unseen_on else 0.0,
unseen_w=(self._unseen[3] if unseen_on
and self._unseen[3] is not None else ceiling))
_p, gap_s, _u = peakguard.fill_gaps(proj, gap_w=0.0, **kw)
if gap_s > self._gap_filled_s + peakguard.GAP_TOL_S:
self._gap_w = peakguard.gap_w(ceiling, self._gap_w,
self._gap_seen_w, seen)
if self._gap_pre is not None:
# The averager credited the gap's first max_hold_s at the
# reading before it; top that part up to gap_w as well.
s0, g0 = self._gap_pre
h = (min(s0 + timedelta(seconds=avg.max_hold_s), since)
- max(s0, proj.block_start)).total_seconds()
self._gap_held_ws += max(0.0, h) * max(
0.0, self._gap_w - max(0.0, g0))
self.log_event(f"PEAK_GUARD_GAP: the meter missed {gap_s:.0f} s "
f"of this 15-minute period, counted at "
f"{self._gap_w:.0f} W",
hint=messages.hint("peak_gap"))
self._gap_filled_s = max(self._gap_filled_s, gap_s)
if seen is not None:
self._gap_seen_w = seen
if self.grid is not None:
self._gap_pre = (since, float(self.grid))
out, self.peak_gap_s, self.peak_unmeasured_s = peakguard.fill_gaps(
proj, gap_w=self._gap_w if self._gap_w is not None else ceiling,
held_ws=self._gap_held_ws + (self._unseen_held_ws if unseen_on
else 0.0), **kw)
return out
def _peak_over_drain(self, claims, maint_results) -> None:
"""SAFETY-83: at N=1 a peak floor above the drain rate outranks it.
@@ -3029,6 +3373,14 @@ class Controller:
# `stale_input_s` and then commands 0 W and keeps commanding, which is
# §11.1 rule 1 and is already what a missing reading does here.
inv.batt = inv.batt_fresh.reading()
# SAFETY-97: learn this unit's idle reading, only while control runs
# and nothing says the unit is refusing, stale or faulty.
if inv.idle.observe(
inv.batt, inv.standing_w(), now_mono,
bool(self.auto) and inv.batt is not None
and not inv.batt_fresh.stale and not inv.refused
and not self.batt_refused and not inv.sign_wd.tripped):
inv.store_set("idle_w", round(inv.idle.value, 1))
if inv.batt_fresh.stale and not was_stale:
self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason,
hint=messages.hint("battery_stuck", unit=inv.name))
@@ -3071,8 +3423,9 @@ class Controller:
inv.batt_fresh.bound.move_w,
messages.reason_code(inv.batt_fresh.reason))
_LOG.debug("Evidence for %s: %s.", inv.name, inv.batt_fresh.reason)
_LOG.debug("%s: the limits are unchanged over %.0f s, a command "
"over %.0f W and a move by over %.0f W.", inv.name,
_LOG.debug("%s: the limits are unchanged for %.0f s while asked "
"to move: a command over %.0f W that moved over %.0f W.",
inv.name,
inv.batt_fresh.bound.unchanged_s,
inv.batt_fresh.bound.move_w,
inv.batt_fresh.bound.move_w)
@@ -3789,7 +4142,8 @@ class Controller:
parked=inv.sign_wd.tripped or bool(inv.refused) or (
n_units > 1 and inv.batt is not None
and abs(float(standing[inv.name])) <= _ctl.PROBE_W
and abs(float(inv.batt)) < _ctl.REFUSED_BELOW_W),
and abs(float(inv.batt) - inv.idle_w)
< _ctl.REFUSED_BELOW_W),
# ⚠️ THIS UNIT'S OWN CUT-OUT. Not the shipped 10 %, and not
# the site's `peak_soc_floor` either - that one bounds a
# money-saving discharge only, and grading a pack against it
@@ -4082,6 +4436,15 @@ class Controller:
# gives for SAFETY-18 on a transport that cannot see `unchanged_s`.
self._peak_proj = (None if not self.p1_enabled
else self.peak.project(self.p1.averager, now))
_reading = self.peak.peak(now) if self.p1_enabled else None
# SAFETY-90: seconds a meter gap left uncounted are counted, so the
# guard goes on after the readings return.
self._peak_proj = self._fill_peak_gaps(self._peak_proj, now, _reading)
# SAFETY-91: at N>=2 the quarter's edge is graded on the next quarter,
# so a HOLD's ceiling does not lapse for a cycle onto a held battery.
if self._peak_proj is not None and len(self.inverters) > 1:
self._peak_proj = next_quarter(self._peak_proj,
self.p1.averager.max_hold_s)
self.peak_guard = None if not self.p1_enabled else peakguard.decide(
self._peak_proj,
# ⚠️ THE SITE SUM. The guard sizes a floor from what the battery is
@@ -4091,7 +4454,7 @@ class Controller:
batt_w=self.site_batt_w,
cap_w=float(self.o.get("peak_cap_w", 3500)),
margin_w=self.peak_margin_w,
reading=self.peak.peak(now),
reading=_reading,
# ⚠️ SUMMED FROM THE PER-UNIT CONJUNCTION, WHICH IS AC 4. Each
# unit contributes `min(its max_w, |its own device bounds|)`, so
# inverter B's rating bounds inverter B. Conjoining the SUMS instead
@@ -4482,6 +4845,8 @@ class Controller:
claims.append(Claim.limit("hold", P_MAINTENANCE, hi=hold_hi,
reason="maintenance hold",
actuator=self.actuator_of("hold")))
# --- SAFETY-91: a drain rises before a held battery gives ------------
self._drain_rise(claims, maint_results)
# --- SAFETY-84: a cheap-window charge is a ceiling over the site ----
charge = self._charge_ceiling(claims)
if charge is not None:
@@ -4495,6 +4860,9 @@ class Controller:
if self.modes_on:
mode_trade = self._mode_trade(claims, now)
self._mode_trade_was = mode_trade
self._charge_track(claims, maint_results)
_live96 = set(self.live_units())
self._loop_start = {}
if (self.auto or self.shadow) and self.inputs_ok:
# ⚠️ prev is the ARBITER's last output, not the loop's own last
# wish. If something outranked the loop, that is what the hardware
@@ -4521,6 +4889,14 @@ 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)
# SAFETY-95: then a drain that ended, last (it reads the start
# the others left).
self._reseed_drain_end(maint_results, _live96)
probe = self._refusal_probe()
if len(self.inverters) > 1:
probe = any(_probing.values())
@@ -4538,6 +4914,9 @@ class Controller:
# level: the two halves of §6.2's identity break in opposite
# directions. See `site_batt_w` and test_alloc.py part 8.
site_batt = self.site_batt_w
# SAFETY-96: a battery the loop cannot move counts at its command,
# so its own power does not freeze the loop (the meter has it).
site_batt += self._held_out_w(_live96)
# TEL-10. ⚠️ ONE NAME FOR THE COMMAND THE LOOP WAS MEASURED
# AGAINST. `self.target` is reassigned further down this method, so
# a `decision.i_w - self.target` written at publish time would
@@ -4547,7 +4926,9 @@ class Controller:
loop_prev = self.target
decision = compute(
prev_w=loop_prev,
grid_w=self.grid,
# SAFETY-95: a drain's tail is not the loop's to chase.
grid_w=(self._drain_grid_w(_live96) if self._drain_tail
else self.grid),
actual_w=site_batt,
tuning=self.tuning,
sat_count=self.sat_count,
@@ -4559,6 +4940,12 @@ class Controller:
refused=self.batt_refused if len(self.inverters) == 1
else self._site_refused(),
probe=probe,
# SAFETY-94: at N=1 the engage also needs the reading to have
# given nothing for the whole span, as at N>1.
gave_nothing=(bool(self._gave_nothing(self.inverters[0]))
if len(self.inverters) == 1 else None),
# SAFETY-97: "gives nothing" is centred on the idle reading.
idle_w=self._site_idle_w(),
# OPT-11: None unless battery modes are on. SAFETY-85: or while
# maintenance holds a battery at N>=2, so the loop does not
# wind past what the others can give and jump when it ends.
@@ -4640,6 +5027,7 @@ class Controller:
# Stopped or blind: no accumulation may survive the outage.
self.i_w = 0.0
self.loop_w = None
self._live_was = frozenset(_live96)
# TEL-10, and it is the same zero the line above writes rather than
# a second opinion about it. ⚠️ `not-running` rather than a stale
# `tracking`: a reason row that keeps repeating the last word the
@@ -4680,7 +5068,7 @@ class Controller:
while True:
resolution = resolve(claims, units=self.units, live=_live,
weights=self.pack_weights, tick=self.alloc_tick,
reserve=self._peak_reserve())
reserve=self._held_back())
# SAFETY-73: a probe pin stands only while the resolved site target
# asks at least PROBE_W its way. Below that a sibling would carry
# the opposite direction (circulation), so the pin goes back to the
@@ -6238,6 +6626,11 @@ class Controller:
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_blind(
peakguard.blind_kind(_pg.blind))})
elif self.peak_unmeasured_s > 0.0:
# SAFETY-90: the start of this quarter came before the first
# reading, and the guard counts it as high use.
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_unmeasured()})
elif _pg.engaged:
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_on(
@@ -7005,6 +7398,47 @@ async def docs_link_facts(session, token: str) -> dict:
return out
async def ha_time_zone(session, token: str) -> tuple:
"""MAINT-10: `(zone, why)` - Home Assistant's configured time zone, or None
and a short reason. Same 3 s budget as the docs link: never holds up start."""
try:
async with session.get(f"{CORE_API}/config", timeout=DOCS_LINK_TIMEOUT_S,
headers={"Authorization": f"Bearer {token}"}) as r:
body = await r.json(content_type=None)
zone = body.get("time_zone") if isinstance(body, dict) else None
return (zone, "") if zone else (None, "no time_zone in its answer")
except Exception as err: # noqa: BLE001 - a fallback exists, never a stop
text = str(err).strip()
return None, f"{type(err).__name__}: {text}" if text else type(err).__name__
_UTC_NAMES = {"UTC", "ETC/UTC", "GMT", "ETC/GMT", "UCT", "ETC/UCT",
"UNIVERSAL", "ETC/UNIVERSAL", "ZULU", "ETC/ZULU"}
async def maint_site_tz(session, token: str):
"""MAINT-10: the zone the maintenance start hour is read in, logged once."""
zone, why = await ha_time_zone(session, token)
tz, name, source, why = site_zone(zone, why, os.environ.get("TZ"))
if source == "ha":
_LOG.info("Maintenance start hour is local time in %s, from Home "
"Assistant.", name)
elif source == "tz" and name.strip().upper() in _UTC_NAMES:
# The Supervisor sets TZ to its copy of HA's zone, or to UTC if it has
# none: HA did not answer here, and UTC may not be the site's clock.
_LOG.warning("Maintenance start hour uses UTC, because Home Assistant "
"did not answer. Restart the add-on when it is up.")
_LOG.debug("maintenance time zone: %s", why)
elif source == "tz":
_LOG.info("Maintenance start hour is local time in %s, from the "
"add-on's time zone. Home Assistant gave none: %s.", name, why)
else:
_LOG.warning("Maintenance start hour uses UTC, because no time zone "
"was found. Set the time zone in Home Assistant.")
_LOG.debug("maintenance time zone: %s", why)
return tz
async def amain() -> None:
opts = load_options()
logging.basicConfig(
@@ -7114,7 +7548,8 @@ async def amain() -> None:
pub = _NoMqtt()
stop = asyncio.Event()
controller = Controller(opts, hass, store, pub, stop=stop)
controller = Controller(opts, hass, store, pub, stop=stop,
site_tz=await maint_site_tz(session, _tok))
if callable(getattr(pub, "bind", None)):
pub.bind(asyncio.get_running_loop(), controller.mqtt_command)
runner = await web.start(controller, port=INGRESS_PORT)
+74 -4
View File
@@ -34,7 +34,7 @@ simply not do.
import logging
import math
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from datetime import datetime, timedelta, timezone, tzinfo
from .store import MAINT_UNIT_KEYS, unit_key
@@ -112,7 +112,9 @@ RESTART_COOLDOWN_MIN = 90
MAINT_OPTION_DEFAULTS = {
"maintenance_enabled": False,
"maintenance_interval_days": 28,
"maintenance_start_hour": 10,
# MAINT-10: a LOCAL hour (site_zone() below), 19:00 so the drain runs
# into the night rather than through the solar day.
"maintenance_start_hour": 19,
# ⚠️ 2000, NOT THE 2500 THIS SHIPPED AS, and config.yaml carries the
# argument: both rates must stay at or below `max_w` (2000) or the
# cheap-window charge shaping asks the arbiter for `hi = -charge_w` against
@@ -177,6 +179,69 @@ class MaintConfig:
)
# --------------------------------------------------------------------------- #
# MAINT-10: the start hour is local time. See DECISIONS/MAINT-10-local-start-hour.md.
# --------------------------------------------------------------------------- #
def _load_zone(name):
"""A tzinfo for an IANA name, or `(None, why)`."""
if not name or not isinstance(name, str):
return None, "none given"
try:
from zoneinfo import ZoneInfo
return ZoneInfo(name.strip()), ""
except Exception as err: # noqa: BLE001 - any failure
return None, f"{name!r} is not a known zone ({type(err).__name__})"
def site_zone(ha_zone, ha_why: str, env_tz) -> tuple:
"""`(tzinfo, name, source, why)`: Home Assistant's zone (source "ha"), else
the container's TZ ("tz"), else UTC ("utc"). `why` says what was missing."""
tz, why = _load_zone(ha_zone)
if tz is not None:
return tz, ha_zone, "ha", ""
ha_why = ha_why or why
tz, env_why = _load_zone(env_tz)
if tz is not None:
return tz, env_tz, "tz", ha_why
return timezone.utc, "UTC", "utc", f"Home Assistant: {ha_why}, TZ: {env_why}"
def in_start_slot(now: datetime, start_hour: int, tz: tzinfo) -> bool:
"""Is `now` inside today's one-hour start slot, read on the local clock?
Normally the slot is the local hour `start_hour`. On the day the clocks go
forward and that hour does not exist, it is the first hour after the jump.
On the day they go back and it happens twice, only the first one counts.
Worked from UTC instants only (`astimezone`), so it does not depend on how
a tzinfo resolves a wall time that is missing or doubled.
"""
if now.tzinfo is None:
now = now.replace(tzinfo=timezone.utc)
here = now.astimezone(tz).replace(tzinfo=None)
before = (now - timedelta(hours=1)).astimezone(tz).replace(tzinfo=None)
if here.hour == start_hour:
# the second pass through a repeated hour: an hour ago read the same
return not (before.date() == here.date() and before.hour == start_hour)
slot = here.replace(hour=start_hour, minute=0, second=0, microsecond=0)
# the clock jumped over the start of the slot within the last hour
return before < slot and here >= slot + timedelta(hours=1)
def next_slot_start(now: datetime, start_hour: int, tz: tzinfo):
"""The start of the slot `now` is in, or else of the next one; None if
there is none within 50 h (no real zone does that)."""
step = timedelta(minutes=15)
t = now.replace(minute=now.minute - now.minute % 15, second=0,
microsecond=0) - timedelta(hours=1)
for _ in range(4 * 50):
if (in_start_slot(t, start_hour, tz)
and not in_start_slot(t - step, start_hour, tz)
and t + timedelta(hours=1) > now):
return t
t += step
return None
@dataclass
class MaintResult:
phase: str
@@ -256,8 +321,12 @@ class Maintenance:
"""
def __init__(self, cfg: MaintConfig, store, unit: str | None = None,
name: str = "", mirror_flat: bool = False):
name: str = "", mirror_flat: bool = False,
tz: tzinfo | None = None):
self.cfg = cfg
# MAINT-10: the zone `cfg.start_hour` is read in - main.py resolves it
# once at startup (`site_zone()`). UTC when nobody says, as before.
self.tz = tz or timezone.utc
# ⚠️ `self.store` IS THE VIEW, NOT THE Store. Everything in this class
# names an UNQUALIFIED key - "phase", "last_completed" - and the view is
# the one place that turns it into the key this pack owns. A raw
@@ -331,7 +400,8 @@ class Maintenance:
about who was asking - so the IDLE branch of `tick()` calls this one.
"""
return (self.phase == IDLE and self.cfg.enabled
and now.hour == self.cfg.start_hour and self.due(now)
and in_start_slot(now, self.cfg.start_hour, self.tz)
and self.due(now)
and self._cooled(now))
# -- operations ----------------------------------------------------------
+8
View File
@@ -793,6 +793,12 @@ def row_peak_guard_blind(why: str) -> str:
f"meanwhile")
def row_peak_guard_unmeasured() -> str:
"""SAFETY-90. Before the first reading; that part counts as high use."""
return ("Peak guard: part of this 15-minute period was not measured, "
"counted as high use")
def row_peak_guard_on(floor_w, limit_w, *, shadow: bool = False) -> str:
if shadow:
return (f"Peak guard: would ask for {watts(floor_w)} to stay under "
@@ -912,6 +918,7 @@ HINTS = {
"peak_on": "Peak guard started",
"peak_off": "Peak guard idle",
"peak_blind": "Peak guard cannot read the meter well",
"peak_gap": "Meter is back, the peak guard counts the missed time as high use",
"peak_saturated": T_PEAK_SATURATED,
"peak_reserve": T_PEAK_RESERVE,
"meter_frozen": T_METER_FROZEN,
@@ -1097,6 +1104,7 @@ def status_samples() -> list:
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, True)),
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, False)),
("row_peak_guard_quiet", GREEN, row_peak_guard_quiet()),
("row_peak_guard_unmeasured", AMBER, row_peak_guard_unmeasured()),
*(("row_peak_guard_blind", AMBER, row_peak_guard_blind(k))
for k in BLIND_WORDS),
("row_peak_guard_on", AMBER, row_peak_guard_on(1200, 2500)),
+7
View File
@@ -312,6 +312,13 @@ class QuarterAverager:
"""
return self._last_net
@property
def held_since(self) -> datetime | None:
"""SAFETY-90: when the held sample arrived (its `ingest_ts`), or None."""
if self._last_t is None:
return None
return datetime.fromtimestamp(self._last_t, timezone.utc)
@property
def offtake_avg_w(self) -> float:
"""Average offtake over the part of the open block seen so far."""
+53
View File
@@ -183,6 +183,8 @@ guard passes its own tests while being switched off:
record). Those seconds make the projection read LOW, which is the
under-guarding direction. The threshold is `max_hold_s` itself - the point
p1.py already stops trusting a held sample - not a new number.
SAFETY-90: once readings are back, main.py counts those seconds
(`fill_gaps()`), so this holds only while the gap lasts.
* FROZEN. SAFETY-18's conjunction, evaluated through `freeze.looks_frozen()`
rather than re-written here. A frozen meter is the worst case of all: the
@@ -599,6 +601,57 @@ def blindness(projection, max_hold_s, frozen: bool) -> str | None:
return None
# SAFETY-90. Seconds of uncounted time below which nothing is filled: float
# noise between the averager's sums and the wall clock, never a real gap.
GAP_TOL_S = 1e-3
def gap_w(ceiling_w, *seen_w) -> float:
"""SAFETY-90: the watts a meter gap is counted at. The highest of the
limit and `seen_w`, the import with the batteries at 0 W (grid + their
discharge) just before and just after the gap; None entries are skipped.
DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
return max([float(ceiling_w)] + [float(w) for w in seen_w if w is not None])
def fill_gaps(projection, *, tail_s, max_hold_s, unseen_s, gap_w, unseen_w,
held_ws=0.0):
"""SAFETY-90: `projection` with the seconds the averager did not count
(a meter gap past `max_hold_s`) counted at `gap_w`, and those from before
this run's first reading (`unseen_s`) at `unseen_w`. `held_ws` is added
too: what the gap's first `max_hold_s`, credited at the reading before
it, lacks to reach `gap_w`. Returns
`(projection, gap seconds filled, unseen seconds filled)`.
`tail_s` is the age of the held reading: up to `max_hold_s` of it is left
as before (the averager credits it once the next reading lands). Past
that the gap is still open, nothing is filled and `blindness()` answers
as before. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
if projection.block_start is None or max_hold_s is None:
return projection, 0.0, 0.0
wall = float(projection.elapsed_s)
tail = min(max(0.0, float(tail_s)), wall)
if tail > float(max_hold_s):
return projection, 0.0, 0.0
covered = wall - uncovered_s(projection)
missing = wall - covered - tail
if missing <= GAP_TOL_S:
missing = 0.0
if held_ws <= 0.0:
return projection, 0.0, 0.0
unseen = min(missing, max(0.0, float(unseen_s)))
gap = missing - unseen
add_ws = gap * float(gap_w) + unseen * float(unseen_w) + float(held_ws)
done = covered + missing
return (replace(
projection,
projected_w=float(projection.projected_w) + add_ws / QUARTER_S,
elapsed_w=((float(projection.elapsed_w) * covered + add_ws) / done
if done > 0.0 else float(projection.elapsed_w)),
covered_frac=(done / wall if wall > 0.0
else float(projection.covered_frac))), gap, unseen)
def discharge_permitted(soc, soc_floor) -> str | None:
"""SAFETY-24. May the guard size a forced discharge, or why may it not?
+16
View File
@@ -822,6 +822,22 @@ class PeakTracker:
held_w=averager.held_net_w)
def next_quarter(p: Projection, max_hold_s: float) -> Projection:
"""SAFETY-91: `p` past its block's end, before the next block's first
telegram: the next quarter, projected from the held reading (the averager
credits it there). `p` itself otherwise, or when that reading is too old
to be credited. DECISIONS/SAFETY-91-held-battery-last.md."""
if p.block_start is None or p.elapsed_s < QUARTER_S \
or p.elapsed_s * (1.0 - p.covered_frac) > float(max_hold_s):
return p
held = max(p.held_w, 0.0)
return Projection(projected_w=held, elapsed_w=held,
elapsed_s=p.elapsed_s - QUARTER_S, covered_frac=1.0,
complete=True,
block_start=p.block_start + timedelta(seconds=QUARTER_S),
held_w=p.held_w)
@dataclass(frozen=True)
class _ClosedBlock:
"""A block reconstructed from a checkpoint. Shaped like `p1.QuarterBlock` so
+1
View File
@@ -410,6 +410,7 @@
"step_in": "The batteries step in above {kw}.",
"step_in_would": "The batteries would step in above {kw}.",
"period": "This 15-minute period is heading for {kw}.",
"period_unmeasured": "Part of this 15-minute period was not measured.",
"prices": {
"now": "Price now {eur} per kWh, all in.",
"today": "Today: lowest {lo} at {lo_at}, highest {hi} at {hi_at}.",
+1
View File
@@ -347,6 +347,7 @@
"step_in": "De batterijen springen bij boven {kw}.",
"step_in_would": "De batterijen zouden bijspringen boven {kw}.",
"period": "Dit kwartier gaat richting {kw}.",
"period_unmeasured": "Een deel van dit kwartier is niet gemeten.",
"prices": {"now": "Prijs nu {eur} per kWh, alles inbegrepen.", "today": "Vandaag: laagste {lo} om {lo_at}, hoogste {hi} om {hi_at}.", "none": "Geen prijzen voor vandaag."},
"tomorrow": {"known": "De prijzen van morgen zijn binnen.", "unknown": "De prijzen van morgen komen meestal na 13:00."},
"region": {"preset": "Heffingen en btw: {region}, cijfers van {year}.", "own": "Heffingen en btw: uw eigen bedrag."},
+3 -2
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller
version: "0.13.0"
version: "0.16.0"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
@@ -630,7 +630,8 @@ options:
# contradiction. Changing one of these numbers now moves every consumer.
maintenance_enabled: false
maintenance_interval_days: 28
maintenance_start_hour: 10
# MAINT-10: a LOCAL hour, in Home Assistant's time zone.
maintenance_start_hour: 19
# ⚠️ BOTH MUST STAY AT OR BELOW max_w, and that is why they are 2000 rather
# than the 2500 they shipped as. The cheap-window charge shaping asks the
+7 -4
View File
@@ -439,7 +439,7 @@ class _Hass:
return True
def wired(grid, cycles=40, batt=0.0, track=True, **over):
def wired(grid, cycles=40, batt=0.0, track=True, tick=None, **over):
async def go():
hass = _Hass(grid, batt=batt, track=track)
c = Controller(dict(
@@ -455,8 +455,11 @@ def wired(grid, cycles=40, batt=0.0, track=True, **over):
# hysteresis clears it a few cycles later - so it is captured live
# rather than looked for after the run, when it is correctly gone.
c.red_snapshot = []
for _ in range(cycles):
await c.read_inputs()
for i in range(cycles):
# `tick`: simulated seconds per cycle. SAFETY-94: a refusal needs
# its evidence to span REFUSED_SPAN_S, which back-to-back cycles
# on the real clock never do.
await c.read_inputs(now_mono=None if tick is None else i * tick)
await c.cycle()
if c.clamp_wd.active and not c.red_snapshot:
c.red_snapshot = c.checks()
@@ -474,7 +477,7 @@ def clamp_notes(hass):
# unclamped integrator climbed ~130 W per 4 s to over 10 kW and, pinned at the
# 5 kW rail, reported 14 768 W." `track=False` is the inverter switched off: it
# reports 0 W however hard it is commanded, so the error never closes.
hass_r, ctl_r = wired(6000.0, cycles=60, track=False)
hass_r, ctl_r = wired(6000.0, cycles=60, track=False, tick=2.0)
emitted = [abs(w) for w in hass_r.writes]
# What the SAME error would have done to an accumulator with no bound - the
+85 -54
View File
@@ -900,34 +900,51 @@ def _loop_series(day, sig):
return out
class _Before94(Freshness):
"""The gate before SAFETY-94: the clock ran from the register's last
change, not from when the command asked it to move. The history below is
about that gate, so it is replayed on it."""
def obliged_s(self):
return self.graded_s(self.last_read)
def _replay16(cls):
"""Every committed day at 1 Hz. Returns (trips, waits for the next move)."""
waits, trips = [], 0
for _day in _loop_days:
_b, _s = _loop_series(_day, "batt"), _loop_series(_day, "setpoint")
if not _b or not _s:
continue
_moves = [r[0] for i, r in enumerate(_b)
if i == 0 or r[1] != _b[i - 1][1]]
_f8b, _prev8b = cls(BOUNDS["batt"]), False
_ib = _is = 0
_t = max(_b[0][0], _s[0][0])
_end = min(_b[-1][0], _s[-1][0])
while _t <= _end:
while _ib + 1 < len(_b) and _b[_ib + 1][0] <= _t:
_ib += 1
while _is + 1 < len(_s) and _s[_is + 1][0] <= _t:
_is += 1
_f8b.submit(_b[_ib][1], _s[_is][1], _t)
if _f8b.stale and not _prev8b:
trips += 1
_nxt = next((m for m in _moves if m > _t), None)
if _nxt is not None:
waits.append(_nxt - _t)
_prev8b = _f8b.stale
_t += 1.0
return trips, sorted(waits)
_t8b = time.monotonic()
_waits, _trips8b = [], 0
for _day in _loop_days:
_b, _s = _loop_series(_day, "batt"), _loop_series(_day, "setpoint")
if not _b or not _s:
continue
_moves = [r[0] for i, r in enumerate(_b) if i == 0 or r[1] != _b[i - 1][1]]
_f8b, _prev8b = Freshness(BOUNDS["batt"]), False
_ib = _is = 0
_t = max(_b[0][0], _s[0][0])
_end = min(_b[-1][0], _s[-1][0])
while _t <= _end:
while _ib + 1 < len(_b) and _b[_ib + 1][0] <= _t:
_ib += 1
while _is + 1 < len(_s) and _s[_is + 1][0] <= _t:
_is += 1
_f8b.submit(_b[_ib][1], _s[_is][1], _t)
if _f8b.stale and not _prev8b:
_trips8b += 1
_nxt = next((m for m in _moves if m > _t), None)
if _nxt is not None:
_waits.append(_nxt - _t)
_prev8b = _f8b.stale
_t += 1.0
_waits.sort()
_trips8b, _waits = _replay16(_Before94)
_trips94, _waits94 = _replay16(Freshness)
_p50 = nth(_waits, len(_waits) // 2)
print(f" {len(_loop_days)} days replayed at 1 Hz in "
f"{time.monotonic() - _t8b:.1f}s: {_trips8b} trips")
f"{time.monotonic() - _t8b:.1f}s (twice): {_trips8b} trips before "
f"SAFETY-94, {_trips94} after")
print(f" wait for the register's next change after a trip: "
f"min {nth(_waits, 0):.1f} s median {_p50:.1f} s max {nth(_waits, -1):.1f} s; "
f"{sum(1 for w in _waits if w >= 710.0)} at or over the live episode's "
@@ -953,6 +970,17 @@ check("⚠️ AND THAT DISTRIBUTION IS A PROPERTY OF THIS BMS, NOT OF THIS CODE
"is `stale_input_s + release_s` = 32 s whatever the register does",
sum(1 for w in _waits if w < 20.0) >= 20
and float(option_default("stale_input_s")) + BOUNDS["batt"].release_s == 32.0)
print(f" SAFETY-94: trips answered within 20 s: {sum(1 for w in _waits if w < 20.0)}"
f" before, {sum(1 for w in _waits94 if w < 20.0)} after; quickest "
f"answer after a trip {nth(_waits94, 0):.1f} s")
check("⚠️ SAFETY-94 (SAFETY-58): MOST OF THOSE SHORT WAITS WERE FALSE TRIPS, "
"NOT KEEP-ALIVE PULSES. The clock now counts only the time the command "
"asked the register to move. The same sixteen days give 59 trips, not "
"93, and only one of the 59 is answered within 20 s (21 before): the "
"gate no longer judges a register on the first read after a rest",
_trips94 == 59 and len(_waits94) == _trips94
and sum(1 for w in _waits94 if w < 20.0) == 1
and sum(1 for w in _waits if w < 20.0) == 21)
# ==============================================================================
@@ -1017,32 +1045,30 @@ check("NOT A SURVIVOR, recorded because it looks like one: a publisher failure "
"each unit's own command travel and each unit's own number",
_s4a.stale and _s4b.stale and _s4a.held != _s4b.held)
# S5. SAFETY-57's own, and it is a FALSE POSITIVE that predates this card.
# The unchanged clock keeps running while the site rests, so the FIRST read
# at which a command travels 250 W after a long quiet already satisfies all
# three terms - the register is given no time at all to answer the new
# command. Present on the shipped tree; the live capture in part 10 contains
# one (10:48:14, cleared one second later).
# S5. SAFETY-57's own FALSE POSITIVE, CLOSED BY SAFETY-94 (it was SAFETY-58).
# The unchanged clock used to keep running while the site rested, so the
# FIRST read at which a command travelled 250 W after a long quiet tripped
# the gate. The clock now counts only time the register was asked to move.
# Live 2026-10-03: four such trips, each 1 s after the command.
_s5 = Freshness(BOUNDS["batt"])
for t in range(0, 300): # five minutes of a resting site
_s5.submit(-3.0, 0.0, float(t))
_s5_quiet = _s5.stale
_s5.submit(-3.0, 1500.0, 300.0) # the loop asks for 1500 W
check("⚠️ SURVIVOR 5, FALSE POSITIVE, AND IT IS OLDER THAN SAFETY-57: after a "
"long rest the unchanged clock is already past 35 s, so the very FIRST "
"read carrying a travelled command trips the gate - before the 17.0 s in "
"which a healthy register could have answered it. The gate accuses on "
"read one and the register answers on read eleven",
not _s5_quiet and _s5.stale and _s5.graded_s(300.0) == 300.0)
check("...and its SIZE is one `stale_input_s` hold, which is why it is recorded "
"here rather than fixed here: `cycle()` returns at `inputs missing "
"{n}s` for the whole of it, so nothing is commanded differently, and the "
"register's next publish clears it. Fixing it means measuring the "
"unchanged clock from when the OBLIGATION appeared rather than from the "
"register's last move, which re-derives the 35.0 s bound and is a "
"different card",
(lambda f: (f.submit(-4.0, 1500.0, 311.0), not f.stale)[-1])(_s5)
and float(option_default("stale_input_s")) >= 5.0)
_s5_first = _s5.stale
for t in range(301, 334):
_s5.submit(-3.0, 1500.0, float(t))
_s5_before = _s5.stale
_s5.submit(-3.0, 1500.0, 334.0)
check("⚠️ SURVIVOR 5 IS CLOSED (SAFETY-94): after five minutes of rest the "
"first read carrying a travelled command is NOT a trip, nor is any read "
"before 35 s of standing command - and a register that still has not "
"moved after 35 s of it IS",
not _s5_quiet and not _s5_first and not _s5_before and _s5.stale
and _s5.graded_s(334.0) == 334.0 and _s5.obliged_s() == 35.0)
check("...and the evidence sentence still gives the register's own unchanged "
"time (334 s), which an operator compares with a history graph",
"unchanged for 334 s" in _s5.reason)
# ==============================================================================
@@ -1353,20 +1379,23 @@ print(f" after : held number reached a consumer on {sum(_exp57)} reads "
check("⚠️ THE EXIT IS NOT FREE AND THE PRICE IS STATED AS A NUMBER. A register "
"that is dead for good is re-graded for ever: over 1201 reads the held "
"number reaches a consumer on 630 of them against 35 before, and 17 "
"number reaches a consumer on 631 of them against 36 before, and 17 "
"releases are taken. This check exists so that cost cannot be quietly "
"forgotten, and so a change that makes it worse has to move a number "
"somebody reads",
sum(_expb) == 35 and sum(_exp57) == 630 and _dead.releases == 17
sum(_expb) == 36 and sum(_exp57) == 631 and _dead.releases == 17
and _deadb.releases == 0)
check("⚠️ AND IT IS PAYABLE ONLY BECAUSE ONE RUN IS SHORTER THAN EITHER "
"CONSUMER'S HOLD. Every unbroken run of exposure is exactly one 35.0 s "
"grading window, and 35 < 75 (circulation_hold_s) < 85 "
"CONSUMER'S HOLD. Every unbroken run of exposure after a release is "
"exactly one 35.0 s window of standing command (the first is 36 s: the "
"fixture's command starts one read late), and 36 < 75 "
"(circulation_hold_s) < 85 "
"(unit_sign_disagree_s) - so NEITHER detector can reach a verdict inside "
"one, and the re-trip that ends each run hands them a None, which CLEARS "
"their windows rather than accumulating into them",
set(_runs57) == {BOUNDS["batt"].unchanged_s}
and BOUNDS["batt"].unchanged_s < _circ_hold < _unit_hold)
set(_runs57[1:]) == {BOUNDS["batt"].unchanged_s}
and nth(_runs57, 0) == BOUNDS["batt"].unchanged_s + 1.0
and max(_runs57) < _circ_hold < _unit_hold)
check("...and nothing SAFETY-37 detects is given back by that, which is the "
"half that makes the trade a trade. A permanent None clears both windows "
"for ever too, so on the shipped-before module the same two detectors are "
@@ -1643,8 +1672,10 @@ check("...and a re-trip's reason names the TWO durations separately, each "
[f.submit(A_FROZEN_W, 0.0, float(t)) for t in range(40, 80)],
[f.submit(A_FROZEN_W, 1300.0, float(t)) for t in range(80, 130)],
f.stale and f.releases == 1
and "unchanged for 92 s" in f.reason
and "checked 2 times over a 35 s window since the last stop was "
# SAFETY-94: the re-trip comes 35 s after the command returns at
# 80 s (114 s), not 35 s after the release at 57 s.
and "unchanged for 114 s" in f.reason
and "checked 2 times over a 57 s window since the last stop was "
"withdrawn" in f.reason)[-1])(
Freshness(BOUNDS["batt"])))
+9
View File
@@ -400,6 +400,15 @@ check("...with its duration from the battery's capacity and the cycle's rates "
"and hold time",
present(nth(_cyc)) and _cyc[0]["amount"].startswith("Takes about ")
and _cyc[0]["end_h"] > _cyc[0]["start_h"] + 2)
# MAINT-10: with the site's zone set, the hour is read on the local clock.
from app.tariff import _EUTimezone # noqa: E402
c_ml = make(dict(MAINT, maintenance_start_hour=14))
for _mm in c_ml.maints:
_mm.tz = _EUTimezone()
_cyc_l = [r for r in status(c_ml)["plan"]["runs"] if r["kind"] == "cycle"]
check("MAINT-10: start hour 14 in the local zone is drawn at 14:00, the "
"instant the cycle starts", len(_cyc_l) == 1
and field(nth(_cyc_l), "from") == "14:00")
c_m.maints[0].store.set("phase", "drain")
s_md = status(c_m)
check("a cycle under way is drawn from now, and the battery's now line says "
+543
View File
@@ -0,0 +1,543 @@
"""MAINT-10: the maintenance start hour is LOCAL time. `python3 test_maint10.py`
Graded here: the slot on ordinary days and on both DST change days, the zone
source and its fallbacks, the 19:00 default, the startup wiring, and that the
controller's commands are unchanged once the start instant is the same.
See DECISIONS/MAINT-10-local-start-hour.md.
Mutating what this file grades? Read MUTATION.md first (stale bytecode).
⚠️ The CI image has no tzdata, so every DST check runs on tariff.py's encoded
EU rule and, where zoneinfo works, on Europe/Brussels from tzdata as well.
"""
import asyncio
import functools
import logging
import os
import re
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools"))
import app.main as M # noqa: E402
from app import maintenance as MT # noqa: E402
from app.maintenance import ( # noqa: E402
DRAIN, IDLE, MAINT_OPTION_DEFAULTS, MaintConfig, Maintenance,
in_start_slot, next_slot_start, site_zone,
)
from app.tariff import _EUTimezone # noqa: E402
HERE = Path(__file__).resolve().parent
UTC = timezone.utc
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
ZONES = [("encoded EU rule", _EUTimezone())]
try:
from zoneinfo import ZoneInfo
ZONES.append(("zoneinfo Europe/Brussels", ZoneInfo("Europe/Brussels")))
except Exception as err: # noqa: BLE001
print(f" zoneinfo unavailable here ({err}); graded on the EU rule only")
SPRING = datetime(2026, 3, 29, tzinfo=UTC) # 02:00 CET -> 03:00 CEST
SPRING27 = datetime(2027, 3, 28, tzinfo=UTC) # the next one, last Sunday of March
AUTUMN = datetime(2026, 10, 25, tzinfo=UTC) # 03:00 CEST -> 02:00 CET
SUMMER = datetime(2026, 7, 15, tzinfo=UTC)
WINTER = datetime(2026, 1, 15, tzinfo=UTC)
def slot_minutes(day_utc, hour, tz):
"""UTC minutes in the slot, over the LOCAL day of that date."""
t, out = day_utc - timedelta(hours=3), []
while t < day_utc + timedelta(hours=27):
if (t.astimezone(tz).date() == day_utc.date()
and in_start_slot(t, hour, tz)):
out.append(t)
t += timedelta(minutes=1)
return out
def one_block(mins):
"""`(start, minutes)` when the minutes form one contiguous run, else None."""
if not mins:
return None
for a, b in zip(mins, mins[1:]):
if b - a != timedelta(minutes=1):
return None
return mins[0], len(mins)
# =========================================================================== #
print("1. UTC stays what it was: the slot IS `now.hour == start_hour`")
t, same = datetime(2026, 1, 1, tzinfo=UTC), True
while t < datetime(2027, 1, 1, tzinfo=UTC):
for h in range(24):
if in_start_slot(t, h, UTC) != (t.hour == h):
same = False
t += timedelta(minutes=37)
check("over a year of instants, every hour 0-23: identical to the old test",
same)
check("a naive `now` is read as UTC, as before",
in_start_slot(datetime(2026, 7, 1, 19, 5), 19, UTC)
and not in_start_slot(datetime(2026, 7, 1, 18, 5), 19, UTC))
# =========================================================================== #
print("2. local time: 19 means 19:00 on the site's clock")
for label, tz in ZONES:
s = one_block(slot_minutes(SUMMER, 19, tz))
w = one_block(slot_minutes(WINTER, 19, tz))
check(f"[{label}] summer: 17:00-18:00 UTC (CEST), one hour",
s == (SUMMER + timedelta(hours=17), 60))
check(f"[{label}] winter: 18:00-19:00 UTC (CET), one hour",
w == (WINTER + timedelta(hours=18), 60))
check(f"[{label}] the old reading is gone: 19:00 UTC is not the slot "
f"in summer", not in_start_slot(SUMMER + timedelta(hours=19, minutes=5),
19, tz))
# =========================================================================== #
print("3. the DST change days, every start hour")
for label, tz in ZONES:
for name, day in (("spring", SPRING), ("spring 2027", SPRING27),
("autumn", AUTUMN)):
bad = []
for h in range(24):
b = one_block(slot_minutes(day, h, tz))
# the local day runs 23:00 UTC the day before to 22:00/23:00 UTC
if b is None or b[1] != 60:
bad.append((h, b))
check(f"[{label}] {name}: every hour 0-23 gives ONE slot of exactly "
f"60 minutes that day {bad or ''}", not bad)
sp2 = one_block(slot_minutes(SPRING, 2, tz))
check(f"[{label}] spring, hour 2 does not exist: it starts at 03:00 CEST "
f"(01:00 UTC), once", sp2 == (SPRING + timedelta(hours=1), 60))
check(f"[{label}] spring 2027 (28 March), hour 2: 01:00 UTC as well",
one_block(slot_minutes(SPRING27, 2, tz))
== (SPRING27 + timedelta(hours=1), 60))
check(f"[{label}] the evening before spring 2027, hour 19: 18:00 UTC "
f"(CET); that day, 17:00 UTC (CEST)",
one_block(slot_minutes(SPRING27 - timedelta(days=1), 19, tz))
== (SPRING27 - timedelta(hours=6), 60)
and one_block(slot_minutes(SPRING27, 19, tz))
== (SPRING27 + timedelta(hours=17), 60))
au2 = one_block(slot_minutes(AUTUMN, 2, tz))
check(f"[{label}] autumn, hour 2 happens twice: only the first, 02:00 CEST "
f"(00:00 UTC)", au2 == (AUTUMN, 60))
check(f"[{label}] autumn: the second 02:xx (CET) is not a slot",
not in_start_slot(AUTUMN + timedelta(hours=1, minutes=30), 2, tz))
check(f"[{label}] autumn, hour 19 still 18:00 UTC (CET)",
one_block(slot_minutes(AUTUMN, 19, tz))
== (AUTUMN + timedelta(hours=18), 60))
check(f"[{label}] spring, hour 19 already 17:00 UTC (CEST)",
one_block(slot_minutes(SPRING, 19, tz))
== (SPRING + timedelta(hours=17), 60))
# =========================================================================== #
class FakeStore:
def __init__(self):
self.data = {"phase": IDLE, "phase_started": None,
"last_completed": None, "last_start_attempt": None}
def set(self, k, v):
self.data[k] = v
def set_time(self, k, when):
self.data[k] = when
def get_time(self, k):
return self.data.get(k)
def get(self, k, default=None):
return self.data.get(k, default)
def save(self):
pass
def starts(day, hour, tz, *, abort_after=None, blocked=None):
"""Tick a due pack each minute around a day; the UTC instants in that
LOCAL day at which it entered DRAIN. `abort_after` aborts each drain that many minutes in (interlock);
`blocked(t)` defers it."""
m = Maintenance(MaintConfig(enabled=True, start_hour=hour), FakeStore(),
tz=tz)
out, since = [], None
t = day - timedelta(hours=2)
while t < day + timedelta(hours=26):
why = ""
if since is not None and abort_after is not None \
and t - since >= timedelta(minutes=abort_after):
why = "a test interlock"
if blocked is not None and blocked(t):
why = "the other battery is cycling"
was = m.phase
m.tick(t, soc=80, blocked_by=why)
if m.phase == DRAIN and was != DRAIN:
out.append(t)
since = t
if m.phase == IDLE:
since = None
t += timedelta(minutes=1)
return [x for x in out if x.astimezone(tz).date() == day.date()]
print("4. the state machine: one start per day, DST days included")
for label, tz in ZONES:
check(f"[{label}] spring, hour 2: one start, at 01:00 UTC (03:00 CEST)",
starts(SPRING, 2, tz, abort_after=5)
== [SPRING + timedelta(hours=1)])
# An aborted start waits RESTART_COOLDOWN_MIN (90). The repeated hour is
# 120 minutes long, so without the first-pass rule it would start again.
check(f"[{label}] autumn, hour 2, aborted after 5 min: NO second start "
f"in the repeated hour",
starts(AUTUMN, 2, tz, abort_after=5) == [AUTUMN])
check(f"[{label}] autumn, hour 2, deferred for the whole first pass: it "
f"does not start in the second pass either",
starts(AUTUMN, 2, tz, blocked=lambda t: t < AUTUMN + timedelta(hours=1))
== [])
check(f"[{label}] summer, hour 19: one start at 17:00 UTC",
starts(SUMMER, 19, tz, abort_after=5)
== [SUMMER + timedelta(hours=17)])
# =========================================================================== #
print("5. where the start is shown (homeview) is where it happens")
for label, tz in ZONES:
ok = True
for day in (SPRING, AUTUMN, SUMMER):
for h in (0, 2, 3, 10, 19, 23):
first = next(iter(slot_minutes(day, h, tz)), None)
if first is None:
continue
for probe in (first - timedelta(hours=5), first,
first + timedelta(minutes=59)):
if next_slot_start(probe, h, tz) != first:
ok = False
check(f"[{label}] next_slot_start() lands on the slot's first minute, "
f"from before it and from inside it", ok)
# =========================================================================== #
print("6. which zone: Home Assistant, else the container's TZ, else UTC")
_FAKE = {"Europe/Brussels": ZONES[-1][1], "America/New_York": object(),
"UTC": UTC}
_real_load = MT._load_zone
MT._load_zone = lambda n: ((_FAKE[n], "") if n in _FAKE
else (None, f"{n!r} is not a known zone"
if n else "none given"))
try:
tz, name, src, _ = site_zone("Europe/Brussels", "", "America/New_York")
check("Home Assistant's zone wins over the container's TZ",
name == "Europe/Brussels" and src == "ha"
and tz is _FAKE["Europe/Brussels"])
tz, name, src, why = site_zone(None, "ClientConnectorError",
"America/New_York")
check("Home Assistant unavailable: the container's TZ, and why",
name == "America/New_York" and src == "tz"
and why == "ClientConnectorError")
tz, name, src, why = site_zone("Mars/Base", "", "Europe/Brussels")
check("an unknown Home Assistant zone falls back too",
name == "Europe/Brussels" and src == "tz" and "Mars/Base" in why)
tz, name, src, why = site_zone(None, "no answer", None)
check("neither: UTC", tz is UTC and src == "utc" and "no answer" in why)
finally:
MT._load_zone = _real_load
if len(ZONES) > 1:
check("the real loader resolves a real name and refuses a made-up one",
MT._load_zone("Europe/Brussels")[0] is not None
and MT._load_zone("Mars/Base")[0] is None)
check("the real loader refuses an empty name without raising",
MT._load_zone("")[0] is None and MT._load_zone(None)[0] is None)
# =========================================================================== #
print("7. the default is 19, an explicit value is honoured")
cfg_text = (HERE / "config.yaml").read_text()
opts_block = cfg_text.split("\nschema:")[0]
check("config.yaml options: ships 19",
re.search(r"^ maintenance_start_hour: 19$", opts_block, re.M) is not None)
check("config.yaml schema: still int(0,23)",
re.search(r"^ maintenance_start_hour: int\(0,23\)$", cfg_text, re.M)
is not None)
check("the in-code mirror says 19",
MAINT_OPTION_DEFAULTS["maintenance_start_hour"] == 19
and MaintConfig().start_hour == 19
and MaintConfig.from_options({}).start_hour == 19)
check("an explicit 10 stays 10 (a stored option is never rewritten)",
MaintConfig.from_options({"maintenance_start_hour": 10}).start_hour == 10)
_eu = ZONES[0][1]
_m10 = Maintenance(MaintConfig.from_options(
{"maintenance_enabled": True, "maintenance_start_hour": 10}),
FakeStore(), tz=_eu)
check("...and now means 10:00 local: 08:00 UTC in summer, not 10:00 UTC",
_m10.wants_start(SUMMER + timedelta(hours=8, minutes=1))
and not _m10.wants_start(SUMMER + timedelta(hours=10, minutes=1)))
check("a Maintenance built with no zone reads UTC, as before",
Maintenance(MaintConfig(), FakeStore()).tz is UTC)
# =========================================================================== #
print("8. startup: Home Assistant's zone is asked for and reaches the machines")
class _Resp:
def __init__(self, body, status=200):
self.body, self.status = body, status
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def json(self, content_type=None):
if isinstance(self.body, Exception):
raise self.body
return self.body
async def text(self):
return "{}"
class _Session:
def __init__(self, zone_body):
self.zone_body, self.asked = zone_body, []
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
def get(self, url, **kw):
self.asked.append(url)
self.timeouts = getattr(self, "timeouts", []) + [kw.get("timeout")]
if url.endswith("/core/api/config"):
return _Resp(self.zone_body)
return _Resp({"data": {}})
s = _Session({"time_zone": "Europe/Brussels"})
check("ha_time_zone() reads /core/api/config's time_zone",
asyncio.run(M.ha_time_zone(s, "tok")) == ("Europe/Brussels", "")
and s.asked == [M.CORE_API + "/config"])
# Review: HA can take minutes to start (2026-10-05); the ask must not wait on it.
check("...with a time budget of at most 3 s, so a slow HA cannot hold up start",
s.timeouts == [M.DOCS_LINK_TIMEOUT_S] and 0 < M.DOCS_LINK_TIMEOUT_S <= 3)
check("...and a failure is None plus the reason, never a raise",
asyncio.run(M.ha_time_zone(_Session(OSError("down")), "tok"))
== (None, "OSError: down")
and asyncio.run(M.ha_time_zone(_Session({}), "tok"))[0] is None)
class _Built(Exception):
pass
def amain_zone(zone_body, env_tz):
"""Run the real amain() up to the Controller; the tz its machines get."""
got, logs = {}, []
class _Capture(logging.Handler):
def emit(self, rec):
logs.append((rec.levelname, rec.getMessage()))
class _Hass:
async def limits(self, e):
return (-5000.0, 5000.0, 1.0)
async def mqtt_service(self):
raise RuntimeError("no broker in this test")
real = {k: getattr(M, k) for k in ("load_options", "HomeAssistant",
"validate_startup_config",
"mode_announcement", "Controller")}
real_session, real_store = M.aiohttp.ClientSession, None
import app.store
real_store = app.store.Store
def _controller(opts, hass, store, pub, *, stop, site_tz=None, **kw):
got["tz"] = site_tz
raise _Built()
M.load_options = lambda: {"setpoint_entity": "number.sp"}
M.HomeAssistant = lambda session, **kw: _Hass()
M.validate_startup_config = lambda *a, **k: []
M.mode_announcement = lambda opts, store: ((), ())
M.Controller = _controller
M.aiohttp.ClientSession = lambda *a, **k: _Session(zone_body)
app.store.Store = lambda *a, **k: object()
old_tz = os.environ.pop("TZ", None)
if env_tz:
os.environ["TZ"] = env_tz
h = _Capture()
logging.getLogger("goodwe").addHandler(h)
try:
asyncio.run(M.amain())
except _Built:
pass
finally:
logging.getLogger("goodwe").removeHandler(h)
for k, v in real.items():
setattr(M, k, v)
M.aiohttp.ClientSession = real_session
app.store.Store = real_store
os.environ.pop("TZ", None)
if old_tz is not None:
os.environ["TZ"] = old_tz
return got.get("tz"), [m for lvl, m in logs if "Maintenance start" in m], logs
MT._load_zone = lambda n: ((_FAKE[n], "") if n in _FAKE
else (None, "none given"))
try:
tz, lines, _ = amain_zone({"time_zone": "Europe/Brussels"}, "America/New_York")
check("amain(): Home Assistant's zone reaches the Controller",
tz is _FAKE["Europe/Brussels"])
check("...logged once, naming the zone and the source",
lines == ["Maintenance start hour is local time in Europe/Brussels, "
"from Home Assistant."])
tz, lines, _ = amain_zone(OSError("down"), "America/New_York")
check("amain(), Home Assistant unavailable: the container's TZ, said "
"plainly", tz is _FAKE["America/New_York"]
and lines == ["Maintenance start hour is local time in "
"America/New_York, from the add-on's time zone. Home "
"Assistant gave none: OSError: down."])
tz, lines, logs = amain_zone(OSError("down"), None)
check("amain(), neither: UTC, with a WARNING line",
tz is UTC and any(lvl == "WARNING" and "uses UTC" in m
for lvl, m in logs))
# Review: HA still starting (502) and the Supervisor's TZ is UTC.
tz, lines, logs = amain_zone(OSError("502"), "UTC")
check("amain(), Home Assistant unavailable and TZ is UTC: a WARNING "
"that says to restart", tz is UTC
and [(lvl, m) for lvl, m in logs if "Maintenance start" in m]
== [("WARNING", "Maintenance start hour uses UTC, because Home "
"Assistant did not answer. Restart the add-on when it is up.")])
tz, lines, logs = amain_zone(OSError("502"), "America/New_York")
check("...but a real TZ is used without a warning",
not any(lvl == "WARNING" for lvl, m in logs
if "Maintenance start" in m))
finally:
MT._load_zone = _real_load
# =========================================================================== #
print("9. nothing else in control moves: the same start instant, the same "
"commands")
import opt11_identity as rig # noqa: E402
import safety78_identity as s78 # noqa: E402
# rig's clock starts 2026-09-20 11:00 UTC = 13:00 CEST. The same drain,
# asked for as UTC hour 11 (the old reading) and as local hour 13 (the new).
def rows_with(start_hour, tz, n=1, both=False):
"""The rig's rows; `both=True` also returns the Controller."""
real_drain, real_ctl = s78.DRAIN, rig.Controller
s78.DRAIN = {"maintenance_enabled": True,
"maintenance_start_hour": start_hour}
rig.Controller = functools.partial(real_ctl, site_tz=tz)
try:
# s78.run() wraps rig.Controller once more for its own tariff db.
rows, c = s78.run(n, dur=600.0)
return (rows, c) if both else rows
finally:
s78.DRAIN, rig.Controller = real_drain, real_ctl
_utc11 = rows_with(11, None)
_loc13 = rows_with(13, _eu)
check("precondition: the drain really starts in the UTC-11 run",
any("drain" in r[-1][4] for r in _utc11))
check(f"start hour 11 UTC and 13 Europe/Brussels: every command, claim, "
f"reason and log line identical over {len(_utc11)} cycles",
_utc11 == _loc13)
_utc12 = rows_with(12, None)
_loc14 = rows_with(14, _eu)
check("precondition: no drain in the not-yet-due-hour run",
not any("drain" in r[-1][4] for r in _utc12))
check("outside the start hour (12 UTC / 14 local): identical as well",
_utc12 == _loc14)
check("...and the zone matters: hour 11 read in Brussels does NOT start",
not any("drain" in r[-1][4] for r in rows_with(11, _eu)))
# =========================================================================== #
print("10. two inverters: every pack reads the start hour in local time")
_r2u, _c2u = rows_with(11, None, n=2, both=True)
_r2l, _c2l = rows_with(13, _eu, n=2, both=True)
check("N=2: both packs get the site's zone, not just the first",
len(_c2l.maints) == 2 and all(mm.tz is _eu for mm in _c2l.maints))
check("N=2 precondition: a drain starts in the UTC-11 run",
any(DRAIN in r[-1][4] for r in _r2u))
check(f"N=2: start hour 11 UTC and 13 Europe/Brussels identical over "
f"{len(_r2u)} cycles", _r2u == _r2l)
check("N=2: hour 11 read in Brussels does NOT start either pack",
not any(DRAIN in r[-1][4] for r in rows_with(11, _eu, n=2)))
def pair_starts(day0, days, hour, tz, cycle_min=70):
"""Two due packs, ticked each minute through `arbitrate()` as main.py
does; a cycle takes about `cycle_min` minutes. `(pack, "DD HH:MM" UTC)`s."""
cfg = MaintConfig(enabled=True, start_hour=hour, hold_min=10)
ms = [Maintenance(cfg, FakeStore(), name=u, tz=tz)
for u in ("A", "B")]
ms[0].store.set_time("last_completed", day0 - timedelta(days=60))
ms[1].store.set_time("last_completed", day0 - timedelta(days=40))
half = (cycle_min - 10) // 2
soc, out = [80.0, 80.0], []
t = day0 - timedelta(hours=3)
while t < day0 + timedelta(days=days):
blocks = MT.arbitrate(ms, t)
for i, mm in enumerate(ms):
was = mm.phase
mm.tick(t, soc=soc[i], blocked_by=blocks[i])
if mm.phase == DRAIN:
soc[i] -= (80.0 - cfg.soc_floor) / half
elif mm.phase == MT.CHARGE:
soc[i] += (cfg.soc_target - cfg.soc_floor) / half
if mm.phase == DRAIN and was != DRAIN:
out.append((mm.name, t))
t += timedelta(minutes=1)
return [(n, x.strftime("%d %H:%M")) for n, x in out]
def _at(*pairs):
return [(n, x.strftime("%d %H:%M")) for n, x in pairs]
for label, tz in ZONES:
# 24 Oct (CEST) and 25 Oct (CET): 19:00 local is 17:00 then 18:00 UTC.
got = pair_starts(AUTUMN - timedelta(days=1), 2, 19, tz)
check(f"[{label}] N=2 across the autumn change, hour 19: A at 19:00 "
f"local on the 24th, B at 19:00 local on the 25th {got}",
got == _at(("A", AUTUMN - timedelta(hours=7)),
("B", AUTUMN + timedelta(hours=18))))
got = pair_starts(SPRING27 - timedelta(days=1), 2, 19, tz)
check(f"[{label}] N=2 across the spring 2027 change, hour 19: 18:00 UTC "
f"then 17:00 UTC {got}",
got == _at(("A", SPRING27 - timedelta(hours=6)),
("B", SPRING27 + timedelta(hours=17))))
# Hour 2 on 25 Oct: A takes the first pass (00:00 UTC) and is done in its
# second pass. B must not start there - it waits for the next day.
got = pair_starts(AUTUMN, 2, 2, tz)
check(f"[{label}] N=2, autumn, hour 2: B does not start in the repeated "
f"hour after A is done; it starts at 02:00 CET on the 26th {got}",
got == _at(("A", AUTUMN),
("B", AUTUMN + timedelta(days=1, hours=1))))
got = pair_starts(SPRING27, 2, 2, tz)
check(f"[{label}] N=2, spring 2027, hour 2: A at 03:00 CEST (01:00 UTC), "
f"B the next night at 02:00 CEST (00:00 UTC) {got}",
got == _at(("A", SPRING27 + timedelta(hours=1)),
("B", SPRING27 + timedelta(days=1))))
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
sys.exit(1)
print("all checks passed")
+34 -6
View File
@@ -129,6 +129,24 @@ finally:
# cd4d9cb5b7e29ff4ab4fe65dab5106683d8a599f09323fbdee4018ad9b28fcd1.
SAFETY78_N2_MAINT = \
"1afcd46b1b987868b110d2b0f4a7555848c9aa4728a836d4a2ce2a2107268497"
# SAFETY-95 changes it again ON PURPOSE: A's drain ends at t=1655 (its
# refusal), and B now stays at the house's 1420 W instead of jumping to
# 2500 W (1100 W export) and slewing back. Rows differ only at 1655-2105 s.
SAFETY95_N2_MAINT = \
"949e7c093dcf065257417de3f2f908e4d57311790abc9fba0a09aa4b5d64c0d2"
# SAFETY-91 changes n2 and n2_off ON PURPOSE: from t=1600 battery A is at its
# 20 % reserve, so B carries the house first and A gives last. With
# `_held_back()` reverted to SAFETY-87's (held only for the peak) both are
# a5dddad's again, which the check below grades.
SAFETY91_N2 = \
"9535c08f556d6d50bec8fb0872445173480c29b10df1a7c437ef73d6ddb2814d"
_real_hb = app_main.Controller._held_back
app_main.Controller._held_back = lambda self: self._peak_reserve()
try:
_n2_s87 = rig.digest(rig.run(**rig.SCENARIOS["n2"])[0])
finally:
app_main.Controller._held_back = _real_hb
_S91 = ("n2", "n2_off", "n2_maint")
for k in A5DDDAD:
print(f" {k:7} {'same' if _now[k] == A5DDDAD[k] else 'DIFFERS'} "
f"{_now[k][:16]}")
@@ -136,14 +154,24 @@ check("⭐ at N=1 and N=2, with the option absent and with it false, every "
"command, reason and allocation record over 540 cycles each is "
"byte-identical to a5dddad (house steps, a solar surplus, a battery "
"at its cut-out level, at N=2 a refusal with probes, both batteries "
"due for maintenance; n2_maint apart, below), and so is the arbiter "
"on 6000 claim lists",
{k: v for k, v in _now.items() if k != "n2_maint"}
== {k: v for k, v in A5DDDAD.items() if k != "n2_maint"})
"due for maintenance; n2, n2_off and n2_maint apart, below), and so "
"is the arbiter on 6000 claim lists",
{k: v for k, v in _now.items() if k not in _S91}
== {k: v for k, v in A5DDDAD.items() if k not in _S91})
# SAFETY-94: with SAFETY-87's held rule, A (cut out at 1600 s) now trips the
# stale gate at 1640 s, not 1635 s - 35 s after its command moved rather than
# after its reading last changed - so that run is no longer a5dddad's. It was
# a5dddad's (a60003b1...) before SAFETY-94. The shipped n2 is unchanged.
SAFETY94_N2_S87 = \
"4ddea7ef9dc1efa437af899675db442cd531b084ae4cec35951d7c7bb3d6d8b5"
check("...n2 and n2_off are the SAFETY-91 digest (A at its reserve gives "
"last), and SAFETY-87's held rule gives SAFETY-94's",
_now["n2"] == _now["n2_off"] == SAFETY91_N2 != A5DDDAD["n2"]
and _n2_s87 == SAFETY94_N2_S87)
check("...and n2_maint is the SAFETY-78 digest: the N=2 drain that a5dddad "
"refused on 392 of 540 cycles now runs (test_safety78.py), bounded by "
"SAFETY-85",
_now["n2_maint"] == SAFETY78_N2_MAINT != A5DDDAD["n2_maint"])
"SAFETY-85, its end handed back by SAFETY-95",
_now["n2_maint"] == SAFETY95_N2_MAINT != A5DDDAD["n2_maint"])
check("...and no cycle in those runs filed a `mode` claim, and only "
"n2_maint's (SAFETY-85: maintenance at N=2) handed `compute()` a bound",
_claims and not any(c.source == "mode" for cl in _claims for c in cl)
+4
View File
@@ -3132,6 +3132,10 @@ check("...and between them the nineteen appends account for exactly the "
# through this file's reading of main.py. It grades three of the eight appends.
# The static pin above is what covers the other five.
_b2, _restore_b2 = second_inverter(c_src)
# SAFETY-91: B states a cut-out, as every roster entry must (rule 8). Without
# one it is held back at its reserve and gives last, so A alone would carry
# this cycle's 500 W and the broadcast check below could not see a split.
_b2.bms_soc_floor = c_src.inverters[0].bms_soc_floor
_n2_writes_before = len(c_src.hass.per_entity)
try:
_, _cl2 = cycle_claims(lambda: run_cycle(c_src))
+5 -4
View File
@@ -716,10 +716,11 @@ check("the drain's commitment window is DERIVED from the phase machine's own "
"timeout, so the gate and maintenance.py cannot drift apart",
near(MAINTENANCE_DRAIN.commits_h, DRAIN_TIMEOUT_MIN / 60.0)
and near(MAINTENANCE_CHARGE.commits_h, CHARGE_TIMEOUT_MIN / 60.0))
# The shipped `maintenance_start_hour` is 10:00 local, so a drain started on
# schedule runs into the evening - hours a morning-only day-ahead print has not
# covered. That is the ticket's scenario and it is where the window is placed.
START = D0 + timedelta(hours=MaintConfig().start_hour)
# A drain started at 10:00 local runs into the evening - hours a morning-only
# day-ahead print has not covered. That is the ticket's scenario, so the hour is
# pinned here: 10 was the shipped default until MAINT-10 moved it to 19, and an
# install that stored 10 still starts then.
START = D0 + timedelta(hours=10)
_hours = derived(lambda: committed_hours(START, MAINTENANCE_DRAIN))
check(f"...and it commits {len(_hours) if not isinstance(_hours, _Raised) else _hours} "
f"whole hours, one per hour of the timeout",
+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"] == 1500.0
and cmd(_l_rows[-1])["b"] == 1510.0
and sum("cannot give power now" in e for e in _l.events) == 1)
# ============================================================================ #
+3
View File
@@ -559,11 +559,14 @@ def _drain_run():
DR = _drain_run()
_real_dor = Controller._drain_over_reserve
_real_rise = Controller._drain_rise # SAFETY-91 would raise B as well
Controller._drain_over_reserve = lambda self, claims, results: None
Controller._drain_rise = lambda self, claims, results: None
try:
DR_OLD = _drain_run()
finally:
Controller._drain_over_reserve = _real_dor
Controller._drain_rise = _real_rise
def _a_for_peak(rows):
+483
View File
@@ -0,0 +1,483 @@
"""Runnable check for SAFETY-90: the peak guard goes on after a P1 meter gap.
`python3 test_safety90.py`
A gap past `meter_max_age_s` left seconds the quarter average never counted,
so the guard was blind for the rest of the quarter: a HOLD gave 0 W and the
quarter raised the month peak (SAFETY-86 rig case 7b: 3964.8 W against a
3450 W aim). Now the missed seconds count at `peakguard.gap_w()`, and so do
the seconds before the add-on's first reading, with a plain line on the
page. Drives the REAL Controller on a lagging battery (10 s), with the P1
telegrams stopped for each gap. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md.
1. N=1 HOLD: gaps of 30 s, 60 s and 5 min mid-quarter land at or under the aim.
2. N=2 HOLD, B able: the same.
3. N=2 HOLD, B held at its reserve: the same, and B gives nothing (O2).
4. A gap across a quarter edge; the house rising during a gap.
5. Start-up mid-quarter: the guard runs, the page says what it assumed.
6. While the gap lasts the guard is blind as before; no gap, no change.
7. fill_gaps() and gap_w() at their boundaries.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import logging
import math
import os
import sys
import types
from datetime import timedelta
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import safety84_identity as s84 # noqa: E402
import app.p1 as p1mod # noqa: E402
import app.tariff as tariff # noqa: E402
from app import homeview, messages, peakguard # noqa: E402
from app.maintenance import HOLD # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
from app.store import unit_key # noqa: E402
from app.tariff import QUARTER_S, Projection # noqa: E402
fails = []
A, B = "inverter-a", "inverter-b"
AIM = 3450.0 # peak_cap_w 3500 - peak_margin_w 50
TOL = 5.0 # W over the aim a quarter may land (SAFETY-79)
CUT = 10.0
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def store(phase_of, n):
s = rig._Store()
for u in (A, B)[:n]:
key = (lambda k: k) if n == 1 else (lambda k, u=u: unit_key(u, k))
if u in phase_of:
s.data[key("phase")] = phase_of[u]
s.data[key("phase_started")] = rig.BASE.isoformat()
else:
s.data[key("last_completed")] = rig.BASE.isoformat()
return s
def run(house, socs, dur, *, n, gaps=(), start=0.0, lag=10.0, on_cycle=None):
"""Rows and controller. A holds full; `socs` are the starting levels
(B's follows its power). `gaps`: (t0, t1), no telegram for t0 <= t < t1.
`start`: the add-on starts this many s into the run (mid-quarter). The
P1 clock is simulated, so a gap really goes stale. `c.truth` is the grid
each second, which the quarter really averages."""
clock = [0.0]
soc, last, truth = dict(socs), [start], []
def before(c, t0):
if on_cycle is not None:
on_cycle(c, t0) # the state the last cycle left
t = t0 + start
clock[0] = t
g = c.hass.grid()
truth.append((t, g))
for u in soc:
soc[u] = max(CUT, soc[u] - c.hass.power(u) * (t - last[0])
/ 3600.0 / 153.6)
last[0] = t
if any(a <= t < b for a, b in gaps):
return
imp, exp = split_signed(g)
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=rig.BASE + timedelta(seconds=t0),
ingest_mono=1e6 + t))
def power(self, u):
st = self.__dict__.setdefault("_plant", {})
a, tgt, tl = st.get(u, (0.0, 0.0, self.t))
for _ in range(int(round((self.t - tl) / 0.1))):
d = self.cmd[u] - tgt
tgt += math.copysign(min(abs(d), 80.0), d)
a += (tgt - a) * (1 - math.exp(-0.1 / lag))
st[u] = (a, tgt, self.t)
return 0.0 if self.soc(u, self.t) <= CUT and a > 0 else float(round(a))
real = (rig._Hass.power, rig.TICK, rig.BASE, p1mod.time)
rig._Hass.power, rig.TICK = power, 1.0
rig.BASE = real[2] + timedelta(seconds=start)
p1mod.time = types.SimpleNamespace(monotonic=lambda: 1e6 + clock[0])
try:
rows, c = s84.run(
n, load=house if callable(house) else (lambda t: house),
cheap=False, store=store({A: HOLD}, n), before=before, dur=dur,
soc=lambda u, t: soc[u],
extra={"maintenance_hold_min": 600})
finally:
rig._Hass.power, rig.TICK, rig.BASE, p1mod.time = real
c.truth = truth
return rows, c
def without_fix(fn):
"""release/1.0 3ed3302: nothing is filled, so a gap blinds the guard."""
real = peakguard.fill_gaps
peakguard.fill_gaps = lambda p, **kw: (p, 0.0, 0.0)
try:
return fn()
finally:
peakguard.fill_gaps = real
def quarter(c, q):
"""What quarter `q` (0 = the run's first) really averages, in W."""
return sum(max(g, 0.0) for t, g in c.truth if int(t // QUARTER_S) == q) \
/ QUARTER_S
def cmd(r):
return dict(r[2])
def mean(rows, u, lo, hi):
rs = [cmd(r)[u] for r in rows if lo <= r[0] < hi]
return round(sum(rs) / len(rs))
TABLE = []
def case(tag, n, house, socs, gaps, q=0, dur=1800.0):
"""One gap case, after and before the fix; prints and records it."""
new = run(house, socs, dur, n=n, gaps=gaps)
old = without_fix(lambda: run(house, socs, dur, n=n, gaps=gaps))
a, b = quarter(new[1], q), quarter(old[1], q)
print(f" {tag}: quarter {a:.1f} W (before the fix {b:.1f} W), aim "
f"{AIM:.0f}")
TABLE.append((tag, n, round(a, 1), round(b, 1)))
return new, old, a, b
# ============================================================================ #
print("1. N=1 HOLD, house 6500 W (5500 W for 5 min, so it can be held)")
for tag, gap, house in (("30 s", (300, 330), 6500.0),
("60 s", (300, 360), 6500.0),
("5 min", (300, 600), 5500.0)):
(rows, c), _old, a, b = case(f"N=1 gap {tag}", 1, house, {"a": 99.0},
[gap])
check(f"⭐ N=1, {tag} gap mid-quarter: the quarter lands at or under the "
f"aim ({a:.1f} W)", a <= AIM + TOL)
if tag != "30 s":
check(f"...non-vacuous: before the fix it went over ({b:.1f} W)",
b > AIM + 100)
check("...and once readings are back the battery gives at least the "
"house over the aim",
mean(rows, "a", gap[1] + 15, 900) >= house - AIM)
check("...the action log says what the gap was counted at",
any("PEAK_GUARD_GAP" in e for e in c.events))
# ============================================================================ #
print("\n2. N=2 HOLD (A holding), B able at 60 %, house 6500 W")
for tag, gap, house in (("30 s", (300, 330), 6500.0),
("60 s", (300, 360), 6500.0),
("5 min", (300, 600), 5500.0)):
(rows, c), _old, a, b = case(f"N=2 gap {tag}", 2, house,
{"a": 99.0, "b": 60.0}, [gap])
check(f"⭐ N=2, {tag} gap: the quarter lands at or under the aim "
f"({a:.1f} W)", a <= AIM + TOL)
if tag != "30 s":
check(f"...non-vacuous: before the fix it went over ({b:.1f} W)",
b > AIM + 100)
check("...B gives first: at its full 2500 W once readings are back",
mean(rows, "b", gap[1] + 30, 900) >= 2450)
# ============================================================================ #
print("\n3. N=2 HOLD, B held at 15 % (under its 20 % reserve), house 5500 W "
"(4500 W for 5 min: A alone gives 2500 W)")
for tag, gap, house in (("30 s", (300, 330), 5500.0),
("60 s", (300, 360), 5500.0),
("5 min", (300, 600), 4500.0)):
(rows, c), (orows, _oc), a, b = case(
f"N=2 B held, gap {tag}", 2, house, {"a": 99.0, "b": 15.0}, [gap])
check(f"⭐ N=2 B held, {tag} gap: the quarter lands at or under the aim "
f"({a:.1f} W)", a <= AIM + TOL)
check("⭐ O2: the held B gives nothing once readings are back; the "
"holding A gives the need",
all(cmd(r)["b"] == 0.0 for r in rows if gap[1] <= r[0] < 900)
and mean(rows, "a", gap[1] + 15, 900) >= house - AIM)
if tag != "30 s":
check("...non-vacuous: before the fix the blind guard left A at 0 W "
"and the held B was drawn at 2500 W as last resort",
mean(orows, "a", gap[1] + 30, 900) == 0
and mean(orows, "b", gap[1] + 30, 900) >= 2450)
rows, c = run(5500.0, {"a": 99.0, "b": 15.0}, 1000.0, n=2,
gaps=[(300, 360), (899, 910)])
edge = [(r[0], cmd(r)["a"], cmd(r)["b"]) for r in rows if 900 <= r[0] < 915]
print(f" a 60 s gap, then the edge's telegram 11 s late: {edge[:4]}; the "
f"second before the edge (O1, not this card): "
f"{[(r[0], cmd(r)['a'], cmd(r)['b']) for r in rows if r[0] == 899]}")
check("⭐ ...a quarter that had a gap still hands over at its edge (SAFETY-91): "
"with the edge's telegram late the held B gives nothing and A keeps "
"giving", all(b == 0.0 and a > 1500.0 for _t, a, b in edge))
# ============================================================================ #
print("\n4. a gap across a quarter edge, and a house that rises in a gap")
for n, socs in ((1, {"a": 99.0}), (2, {"a": 99.0, "b": 60.0})):
(rows, c), _o, a, b = case(f"N={n} gap across the edge", n, 6500.0, socs,
[(870, 930)], q=1)
check(f"⭐ N={n}: the quarter the readings come back in lands at or "
f"under the aim ({a:.1f} W; before the fix {b:.1f} W)",
a <= AIM + TOL and b > AIM + 100)
check("...the quarter that ended in the gap could not be steered after "
"it, and stays under the limit itself",
quarter(c, 0) <= 3500.0)
def rising(t):
return 3000.0 if t < 320 else 6500.0
for n, socs in ((1, {"a": 99.0}), (2, {"a": 99.0, "b": 15.0})):
house = rising if n == 1 else (lambda t: 3000.0 if t < 320 else 5500.0)
(rows, c), _o, a, b = case(f"N={n} house rises in a 60 s gap", n, house,
socs, [(300, 360)])
check(f"⭐ N={n}: the house goes from 3000 W to over the limit during "
f"the gap; the import seen after it is counted and the quarter "
f"lands at or under the aim ({a:.1f} W)", a <= AIM + TOL)
for n, socs in ((1, {"a": 99.0}), (2, {"a": 99.0, "b": 15.0})):
big = 6500.0 if n == 1 else 5500.0
(rows, c), _o, a, b = case(
f"N={n} house falls in a 5 min gap", n,
lambda t, big=big: big if t < 560 else 2000.0, socs, [(300, 600)])
check(f"⭐ N={n}: the house falls to 2000 W just before readings return; "
f"the import before the gap (batteries at 0 W) is counted and the "
f"quarter lands at or under the aim ({a:.1f} W; before {b:.1f} W, "
f"at N=2 by drawing the held B)",
a <= AIM + TOL and (n == 2 or b > AIM + 100))
(rows, c), _o, a, b = case(
"N=1 5 min gap at 3000 W, then 60 s gap at 6500 W next quarter", 1,
lambda t: 3000.0 if t < 900 else 6500.0, {"a": 99.0},
[(300, 600), (1200, 1260)], q=1)
check(f"⭐ ...each quarter's gap is counted on its own: a long gap at a low "
f"load does not hide a shorter one in the next quarter ({a:.1f} W)",
a <= AIM + TOL and sum("PEAK_GUARD_GAP" in e for e in c.events) == 2)
def dip(t):
return 3000.0 if 450 <= t < 700 else 6500.0
(rows, c), _o, a, b = case("N=1 two gaps, the second at a lower load", 1,
dip, {"a": 99.0}, [(300, 360), (500, 560)])
at = [float(e.split("counted at ")[1].split(" W")[0]) for e in c.events
if "PEAK_GUARD_GAP" in e]
check(f"⭐ ...two gaps in one quarter: the second, at a lower load, does not "
f"price the first one down (counted at {at}; {a:.1f} W)",
a <= AIM + TOL and len(at) == 2 and min(at) >= 6400.0)
# ============================================================================ #
print("\n5. start-up mid-quarter (the add-on starts 450 s into the quarter)")
UI = {}
def grab(c, t):
if t == 60.0:
UI["unmeasured"] = c.peak_unmeasured_s
UI["rows"] = [r for r in c.checks() if r.get("id") == "peak_guard"]
UI["en"] = homeview.month_peak(c, c.cycle_at)["line"]
homeview._LANG[0] = "nl"
try:
UI["nl"] = homeview.month_peak(c, c.cycle_at)["line"]
finally:
homeview._LANG[0] = "en"
if t == 453.0:
UI["later"] = (c.peak_unmeasured_s,
[r for r in c.checks() if r.get("id") == "peak_guard"])
def restored(t_dead, start=450.0):
"""TARIFF-01 restores the averager at start-up: 0..t_dead measured at
the aim, the held sample at t_dead (the restart's first second)."""
def fake(self, averager, now=None):
bs = int(rig.BASE.timestamp() - start)
averager.restore({"block": bs, "acc": AIM * t_dead,
"pp_acc": [AIM * t_dead], "elapsed": t_dead,
"last_t": bs + t_dead, "last_net": AIM,
"last_pp": [AIM], "phases": 1})
self.restore_state = "resumed"
return self.restore_state
return fake
def startup(house, socs, n, *, t_dead=None, on_cycle=None):
"""The add-on starts 450 s in. While it was down the batteries were at
0 W (the ESP32 watchdog), so the meter saw the house: the quarter is
that, or the restored part at the aim, plus what this run measured."""
real = tariff.PeakTracker.restore
if t_dead is not None:
tariff.PeakTracker.restore = restored(t_dead)
try:
rows, c = run(house, socs, 455.0, n=n, start=450.0,
on_cycle=on_cycle)
finally:
tariff.PeakTracker.restore = real
pre = 450.0 * house if t_dead is None else (
t_dead * AIM + (450.0 - t_dead) * house)
return rows, c, (pre + sum(max(g, 0.0) for t, g in c.truth if t < 900)) \
/ QUARTER_S
for n, socs, house in ((1, {"a": 99.0}, 5000.0),
(2, {"a": 99.0, "b": 15.0}, 4500.0)):
rows, c, land = startup(house, socs, n, on_cycle=grab if n == 1 else None)
orows, _oc, old = without_fix(lambda: startup(house, socs, n))
TABLE.append((f"N={n} start-up mid-quarter", n, round(land, 1),
round(old, 1)))
check(f"⭐ N={n}: the first 450 s (house {house:.0f} W, batteries at 0 W) "
f"count at the import seen first; the quarter lands at or under "
f"the aim ({land:.1f} W, before the fix {old:.1f} W)",
land <= AIM + TOL and (n == 2 or old > AIM + 100))
if n == 2:
check("...and the held B gives nothing (O2 at start-up); before the "
"fix it was drawn at 2500 W",
all(cmd(r)["b"] == 0.0 for r in rows if 1 <= r[0] < 450)
and mean(orows, "b", 30, 450) >= 2450)
check("...non-vacuous: before the fix A gave 0 W for the quarter",
mean(orows, "a", 30, 450) == 0)
rows, c, land = startup(6500.0, {"a": 99.0}, 1)
check(f"⭐ N=1, house 6500 W: more than the battery can make up; A gives its "
f"full 5000 W and the alarm says so ({land:.1f} W)",
mean(rows, "a", 30, 450) >= 4900
and any("PEAK_GUARD_SATURATED" in e for e in c.events))
for t_dead in (300.0, 390.0, 440.0):
rows, c, land = startup(6500.0, {"a": 99.0}, 1, t_dead=t_dead)
TABLE.append((f"N=1 restart, down {450 - t_dead:.0f} s", 1,
round(land, 1), round(without_fix(lambda: startup(
6500.0, {"a": 99.0}, 1, t_dead=t_dead))[2], 1)))
check(f"⭐ restart mid-quarter, down {450 - t_dead:.0f} s at house "
f"6500 W (the restored reading's held 30 s topped up too): the "
f"quarter lands at or under the aim ({land:.1f} W)",
land <= AIM + TOL)
rows, c = run(6500.0, {"a": 99.0}, 1350.0, n=1, start=450.0,
gaps=[(1200, 1260)])
a = quarter(c, 1)
TABLE.append(("N=1 start-up, then 60 s gap next quarter", 1, round(a, 1),
round(quarter(without_fix(lambda: run(
6500.0, {"a": 99.0}, 1350.0, n=1, start=450.0,
gaps=[(1200, 1260)]))[1], 1), 1)))
check(f"...the start-up assumption stays in its own quarter: a 60 s gap in "
f"the next one is counted as a gap and lands at or under the aim "
f"({a:.1f} W)", a <= AIM + TOL)
print(f" page: {UI.get('rows')}\n EN {UI.get('en')!r}\n"
f" NL {UI.get('nl')!r}")
check("⭐ the page says plainly that part of the period was not measured, "
"in English and Dutch",
UI.get("unmeasured", 0) > 400
and UI["rows"][0]["text"] == messages.row_peak_guard_unmeasured()
and UI["en"].startswith("Part of this 15-minute period was not "
"measured.")
and UI["nl"].startswith("Een deel van dit kwartier is niet gemeten."))
check("...and stops saying it in the next quarter",
UI["later"][0] == 0.0 and UI["later"][1][0]["text"]
!= messages.row_peak_guard_unmeasured())
# ============================================================================ #
print("\n6. while the gap lasts, and with no gap at all")
LIVE = {}
def live(c, t):
if t == 350.0:
LIVE["g"] = c.peak_guard
run(6500.0, {"a": 99.0}, 400.0, n=1, gaps=[(300, 360)], on_cycle=live)
check("⭐ 50 s into a gap the guard is blind as before (floor 0 W, no "
"charging): nothing is filled while the gap is open",
LIVE["g"].blind is not None and LIVE["g"].floor_w == 0.0)
for n, socs, house in ((1, {"a": 99.0}, 6500.0),
(2, {"a": 99.0, "b": 60.0}, 6500.0),
(2, {"a": 99.0, "b": 15.0}, 5500.0)):
rows, c = run(house, socs, 1000.0, n=n)
orows, _ = without_fix(lambda: run(house, socs, 1000.0, n=n))
check(f"⭐ N={n}, meter never drops: every row is identical with and "
f"without the fix, and nothing is filled",
rows == orows and not any("PEAK_GUARD_GAP" in e for e in c.events))
# ============================================================================ #
print("\n7. fill_gaps() and gap_w() at their boundaries")
T0 = rig.BASE
def proj(wall, covered, held=1000.0, acc_w=1000.0):
return Projection(projected_w=(acc_w * covered + held * (QUARTER_S - wall))
/ QUARTER_S, elapsed_w=acc_w, elapsed_s=wall,
covered_frac=covered / wall, complete=False,
block_start=T0, held_w=held)
def fill(p, tail, unseen=0.0, gw=4000.0, uw=3450.0):
return peakguard.fill_gaps(p, tail_s=tail, max_hold_s=30.0,
unseen_s=unseen, gap_w=gw, unseen_w=uw)
p = proj(400.0, 300.0) # 100 s uncounted
f, g, u = fill(p, 10.0)
check("90 s missed (100 s uncounted less a 10 s tail): counted at gap_w, "
"the tail left as before, no longer blind",
g == 90.0 and u == 0.0
and abs(f.projected_w - (p.projected_w + 90.0 * 4000.0 / QUARTER_S))
< 1e-9 and abs(peakguard.uncovered_s(f) - 10.0) < 1e-9
and peakguard.blindness(f, 30.0, False) is None
and peakguard.blindness(p, 30.0, False) is not None)
check("tail exactly max_hold_s: filled; one past it: the gap is open, "
"nothing filled",
fill(p, 30.0)[1] == 70.0 and fill(p, 30.001) == (p, 0.0, 0.0))
f, g, u = fill(p, 10.0, unseen=60.0)
check("unseen seconds come first and count at the aim, the rest at gap_w",
u == 60.0 and g == 30.0
and abs(f.projected_w - (p.projected_w + (60 * 3450.0 + 30 * 4000.0)
/ QUARTER_S)) < 1e-9)
check("nothing missed (or under 1 ms): the projection object is returned "
"unchanged",
fill(proj(400.0, 400.0), 0.0)[0] is not None
and fill(proj(400.0, 400.0), 0.0) == (proj(400.0, 400.0), 0.0, 0.0)
and fill(proj(400.0, 399.9995), 0.0)[1] == 0.0)
f, g, u = fill(proj(400.0, 400.0), 0.0)
f2 = peakguard.fill_gaps(proj(400.0, 400.0), tail_s=0.0, max_hold_s=30.0,
unseen_s=0.0, gap_w=4000.0, unseen_w=3450.0,
held_ws=9000.0)
check("held_ws (the gap's first 30 s topped up) is added even when nothing "
"is missing any more",
g == 0.0 and abs(f2[0].projected_w - f.projected_w - 10.0) < 1e-9)
z = peakguard.fill_gaps(Projection(0, 0, 0, 0, False, T0, 0), tail_s=0.0,
max_hold_s=30.0, unseen_s=0.0, gap_w=4000.0,
unseen_w=3450.0, held_ws=9000.0)
check("the first instant of a block (0 s elapsed) with held_ws: no division "
"by zero", abs(z[0].projected_w - 10.0) < 1e-9)
check("no open block: unchanged (blindness says so)",
fill(Projection(0, 0, 0, 0, False, None, 0), 0.0)[1:] == (0.0, 0.0))
check("gap_w(): the highest of the limit and the imports seen, None skipped",
peakguard.gap_w(3500, None, 3000.0) == 3500.0
and peakguard.gap_w(3500, 6500.0, None) == 6500.0
and peakguard.gap_w(3500, 3000.0, 4500.0) == 4500.0
and peakguard.gap_w(3500) == 3500.0)
# ============================================================================ #
print("\n case N quarter W before W aim")
for tag, n, a, b in TABLE:
print(f" {tag:<34}{n} {a:>9.1f} {b:>9.1f} {AIM:.0f}")
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all SAFETY-90 checks passed")
+452
View File
@@ -0,0 +1,452 @@
"""Runnable check for SAFETY-91: a held battery is drawn only after the others
are full. `python3 test_safety91.py`
At N>=2 a battery at or under its peak reserve was held back only while the
peak guard asked for power. The self-consumption loop drew it at its full
rating while a draining battery stayed at its drain rate (SAFETY-87 rig test
F1), and at a quarter's edge in a HOLD the held battery gave 2500 W for one
cycle (F2). Now a held battery weighs 0 whenever the site gives, a draining
battery rises above its drain rate before it, and the edge is graded on the
next quarter. SAFETY-25 option A stands: once the others are full the loop
may still draw it. Drives the REAL Controller on a lagging battery (10 s, as
on the rig). DECISIONS/SAFETY-91-held-battery-last.md.
1. B draining, A held, house over B's rating: B rises to full before A gives.
2. A held reaching its cut-out: nothing is placed on it, B gives its rating.
3. HOLD across a quarter edge: the held battery gives nothing at the edge.
4. Idle, A held: B carries the house first; both held or B refusing: as before;
a SoC reading flipping across the reserve does not swap the split.
5. The rules at their boundaries (claim lists, N=3, a site stop).
6. next_quarter() at its boundaries.
7. N=1 is untouched.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import logging
import math
import os
import sys
import time
import types
from datetime import timedelta
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import safety84_identity as s84 # noqa: E402
from app import peakguard # noqa: E402
from app.arbiter import (Claim, P_MAINTENANCE, P_SAFETY, # noqa: E402
resolve)
from app.main import Controller # noqa: E402
from app.maintenance import DRAIN, HOLD, IDLE, MaintResult # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
from app.store import unit_key # noqa: E402
from app.tariff import QUARTER_S, Projection, next_quarter # noqa: E402
fails = []
A, B = "inverter-a", "inverter-b"
FULL = 2500.0
CUT = 10.0 # bms_soc_floor of both test batteries
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def lagging(fn, lag=10.0, tick=1.0, lags=None):
"""`fn` on plantsim's battery (800 W/s ramp, `lag` s first-order lag, or
`lags[u]`), a 1 s meter tick, and a BMS that stops discharge at its
cut-out."""
real_p, real_t = rig._Hass.power, rig.TICK
def power(self, u):
st = self.__dict__.setdefault("_plant", {})
a, tgt, tl = st.get(u, (0.0, 0.0, self.t))
for _ in range(int(round((self.t - tl) / 0.1))):
d = self.cmd[u] - tgt
tgt += math.copysign(min(abs(d), 80.0), d)
a += (tgt - a) * (1 - math.exp(-0.1 / (lags or {}).get(u, lag)))
st[u] = (a, tgt, self.t)
return 0.0 if self.soc(u, self.t) <= CUT and a > 0 else float(round(a))
rig._Hass.power, rig.TICK = power, tick
try:
return fn()
finally:
rig._Hass.power, rig.TICK = real_p, real_t
def store(phase_of, n=2):
"""`phase_of`: {name: phase}; the others have just done their cycle."""
s = rig._Store()
for u in (A, B)[:n]:
key = (lambda k: k) if n == 1 else (lambda k: unit_key(u, k))
if u in phase_of:
s.data[key("phase")] = phase_of[u]
s.data[key("phase_started")] = rig.BASE.isoformat()
else:
s.data[key("last_completed")] = rig.BASE.isoformat()
return s
def run(house, phase_of, socs, dur, late=0.0, kwh=15.36, n=2, read=None,
lags=None):
"""Rows and controller. Levels follow each battery's measured power; the
P1 telegram read at each cycle is `late` s old. `read(u, t, level)` is
the SoC the controller reads (default the level)."""
soc, last, levels = dict(socs), [0.0], {}
grid = []
def before(c, t):
grid.append((t, c.hass.grid()))
for u in soc:
soc[u] = max(CUT, soc[u] - c.hass.power(u) * (t - last[0])
/ 3600.0 / (kwh * 10.0))
last[0], levels[t] = t, dict(soc)
imp, exp = split_signed(c.hass.grid())
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=rig.BASE + timedelta(seconds=t - late),
ingest_mono=time.monotonic()))
rows, c = lagging(lambda: s84.run(
n, load=house if callable(house) else (lambda t: house), cheap=False,
store=store(phase_of, n), before=before, dur=dur,
soc=(lambda u, t: soc[u]) if read is None
else (lambda u, t: read(u, t, soc[u])),
extra={"maintenance_discharge_w": 1000,
"maintenance_hold_min": 600}), lags=lags)
c.levels, c.grid = levels, grid
return rows, c
def without_fix(fn):
"""release/1.0 8c438ab: held only for the peak, a drain stays pinned, the
quarter's edge is not rolled over."""
real = (Controller._held_back, Controller._drain_rise)
import app.main as m
real_nq = m.next_quarter
Controller._held_back = lambda self: self._peak_reserve()
Controller._drain_rise = lambda self, claims, results: None
m.next_quarter = lambda p, max_hold_s: p
try:
return fn()
finally:
Controller._held_back, Controller._drain_rise = real
m.next_quarter = real_nq
def cmd(r):
return dict(r[2])
def seg(rows, lo, hi):
"""Mean A and B commands over [lo, hi) s."""
rs = [r for r in rows if lo <= r[0] < hi]
return (round(sum(cmd(r)["a"] for r in rs) / len(rs)),
round(sum(cmd(r)["b"] for r in rs) / len(rs)))
def a_before_b(rows, t0=0.0):
"""Cycles where held A is asked to give while B is not at full power."""
return sum(1 for r in rows if r[0] >= t0
and cmd(r)["a"] > 0.0 and cmd(r)["b"] < FULL)
def unmet(rows, lo, hi):
return sum(1 for r in rows if lo <= r[0] < hi and r[7] and "UNMET" in r[7])
# ============================================================================ #
print("1. B draining at 1000 W, A held at 15 %, house over B's rating")
STEPS = (800.0, 4000.0, 5500.0)
def steps(t):
return STEPS[min(int(t // 300), 2)]
DR = run(steps, {B: DRAIN}, {"a": 15.0, "b": 60.0}, 900.0)
DR_OLD = without_fix(lambda: run(steps, {B: DRAIN}, {"a": 15.0, "b": 60.0},
900.0))
for tag, (rows, c) in (("after", DR), ("before", DR_OLD)):
print(f" {tag}: house 800 A/B {seg(rows, 60, 300)}, 4000 "
f"{seg(rows, 360, 600)}, 5500 {seg(rows, 660, 900)}; A asked while "
f"B not full: {a_before_b(rows)} cycles; UNMET "
f"{unmet(rows, 0, 900)}")
check("⭐ A (held) is never asked to give while B has room: every cycle that "
"puts power on A has B at its full 2500 W",
a_before_b(DR[0]) == 0 and DR[1].maints[1].phase == DRAIN)
check("...house 4000: B rises from its 1000 W drain rate to 2500 W and A "
"gives only the 1500 W rest (before: A 2500, B 1000)",
seg(DR[0], 360, 600)[1] >= 2480 and seg(DR[0], 360, 600)[0] <= 1520
and seg(DR_OLD[0], 360, 600)[0] >= 2480
and seg(DR_OLD[0], 360, 600)[1] <= 1020)
check("...house 800: B still drains at its 1000 W rate (the rest is "
"exported, as before) and A gives nothing",
all(cmd(r)["b"] >= 1000.0 and cmd(r)["a"] == 0.0
for r in DR[0] if 60 <= r[0] < 300))
check("...house 5500 (over both ratings): option A stands, A is drawn as a "
"last resort once B is full, and nothing is left UNMET",
seg(DR[0], 660, 900) == (2500, 2500) and unmet(DR[0], 0, 900) == 0)
check("non-vacuous: before the fix A was asked while B had room",
a_before_b(DR_OLD[0]) >= 100)
# ============================================================================ #
print("\n2. the held battery reaches its cut-out (house 5500)")
CO = run(5500.0, {B: DRAIN}, {"a": 11.0, "b": 60.0}, 1200.0)
CO_OLD = without_fix(lambda: run(5500.0, {B: DRAIN}, {"a": 11.0, "b": 60.0},
1200.0))
def after_cut(rows, c):
"""Rows once A reads its cut-out (its level follows its power)."""
return [r for r in rows if c.levels[r[0]]["a"] <= CUT]
_ac, _ac_old = after_cut(*CO), after_cut(*CO_OLD)
print(f" after A's cut-out: B {seg(_ac, 0, 9e9)[1]} W over {len(_ac)} "
f"cycles (before: {seg(_ac_old, 0, 9e9)[1]} W); A soc "
f"{CO[1].inverters[0].soc:.2f} %")
check("⭐ at its cut-out nothing is placed on A (0 W on every cycle), B "
"gives its full 2500 W and nothing is left UNMET on the empty A",
CO[1].inverters[0].soc <= CUT and len(_ac) >= 100
and all(cmd(r)["a"] == 0.0 and cmd(r)["b"] == FULL for r in _ac)
and unmet(CO[0], 600, 1200) == 0)
check("non-vacuous: before the fix B stayed pinned under its rating on most "
"of those cycles (the rig saw ~2422 W)",
len(_ac_old) >= 100
and sum(1 for r in _ac_old if cmd(r)["b"] < FULL) > len(_ac_old) / 2)
# ============================================================================ #
print("\n3. HOLD across a quarter edge (A full holding, B held at 15 %)")
EDGE = run(5500.0, {A: HOLD}, {"a": 99.0, "b": 15.0}, 1000.0, late=0.5)
EDGE_OLD = without_fix(lambda: run(5500.0, {A: HOLD}, {"a": 99.0, "b": 15.0},
1000.0, late=0.5))
def at_edge(rows):
return [(r[0], cmd(r)["a"], cmd(r)["b"]) for r in rows
if QUARTER_S - 1 <= r[0] <= QUARTER_S + 1]
print(f" edge after {at_edge(EDGE[0])}, before {at_edge(EDGE_OLD[0])}")
check("⭐ the telegram at the edge comes late (0.5 s): B gives nothing on any "
"cycle (the first cycle, before any quarter is measured, aside), A "
"keeps giving and stays in its HOLD",
all(cmd(r)["b"] == 0.0 for r in EDGE[0] if r[0] > 0)
and all(a > 2000.0 for _t, a, _b in at_edge(EDGE[0]))
and EDGE[1].maints[0].phase == HOLD)
check("non-vacuous: before the fix B gave over 2000 W at the edge (F2)",
any(b > 2000.0 for _t, _a, b in at_edge(EDGE_OLD[0])))
# ============================================================================ #
print("\n4. idle, house 2000 W")
ID = run(2000.0, {}, {"a": 15.0, "b": 60.0}, 300.0)
ID_OLD = without_fix(lambda: run(2000.0, {}, {"a": 15.0, "b": 60.0}, 300.0))
print(f" A held: after {seg(ID[0], 120, 300)}, before "
f"{seg(ID_OLD[0], 120, 300)}")
check("⭐ A held, B able: B carries the house alone; A is asked only on the "
"cycles where the lagging B is already at its full command",
a_before_b(ID[0]) == 0 and seg(ID[0], 120, 300)[0] == 0
and seg(ID[0], 120, 300)[1] >= 1950)
check("non-vacuous: before the fix the weights gave A a share",
seg(ID_OLD[0], 120, 300)[0] > 50)
BOTH = run(2000.0, {}, {"a": 15.0, "b": 14.0}, 300.0)
BOTH_OLD = without_fix(lambda: run(2000.0, {}, {"a": 15.0, "b": 14.0}, 300.0))
check("...both held: nothing to give first, the split is as before",
[r[:3] for r in BOTH[0]] == [r[:3] for r in BOTH_OLD[0]])
def held_back(socs, refused=(0, 0), live=(A, B)):
c = ID[1]
for inv, s, r in zip(c.inverters, socs, refused):
inv.soc, inv.refused = s, r
c.live_units = lambda: live
try:
return c._held_back()
finally:
del c.live_units
for inv in c.inverters:
inv.refused = 0
check("...held back only while another can give: A held beside B able; "
"nothing with B refusing to give, B dropped, or both under",
held_back((15.0, 60.0)) == {A} and held_back((15.0, 60.0), (0, 1))
== frozenset() and held_back((15.0, 60.0), live=(A,)) == frozenset()
and held_back((15.0, 14.0)) == frozenset()
and held_back((15.0, 60.0), (0, -1)) == {A})
# A's SoC READING flips 20/21 % across its 20 % reserve every 10 s, and B
# follows its command faster (3 s) than A (10 s), as two different inverters.
def _flip(u, t, level):
return (20.0 + int(t // 10) % 2) if u == "a" else level
def _a_jumps(rows, t0):
rs = [r for r in rows if r[0] >= t0]
return sum(1 for p, q in zip(rs, rs[1:])
if abs(cmd(p)["a"] - cmd(q)["a"]) > 300.0)
def _grid_rms(c, t0):
g = [w + 10.0 for t, w in c.grid if t >= t0]
return math.sqrt(sum(w * w for w in g) / len(g))
_lags = {"a": 10.0, "b": 3.0}
FL = run(2000.0, {}, {"a": 20.0, "b": 60.0}, 600.0, read=_flip, lags=_lags)
_real_was = Controller.__dict__["_held_back"]
def _no_hyst(self):
self._held_was = frozenset()
return _real_was(self)
Controller._held_back = _no_hyst
try:
FL_OLD = run(2000.0, {}, {"a": 20.0, "b": 60.0}, 600.0, read=_flip,
lags=_lags)
finally:
Controller._held_back = _real_was
print(f" A's reading flipping 20/21 %: A jumps {_a_jumps(FL[0], 120)}, "
f"grid rms {_grid_rms(FL[1], 120):.0f} W (no hysteresis: "
f"{_a_jumps(FL_OLD[0], 120)}, {_grid_rms(FL_OLD[1], 120):.0f} W)")
check("⭐ a SoC reading flipping across the reserve does not swap the split: "
"A stays held back (0 W) and the grid stays near its target",
_a_jumps(FL[0], 120) == 0 and seg(FL[0], 120, 600)[0] == 0
and _grid_rms(FL[1], 120) < 30.0)
check("non-vacuous: without the 1-point hysteresis the split swaps on "
"every flip and the grid swings",
_a_jumps(FL_OLD[0], 120) >= 20 and _grid_rms(FL_OLD[1], 120) > 100.0)
REL = run(2000.0, {}, {"a": 20.0, "b": 60.0}, 300.0,
read=lambda u, t, lv: (20.0 if t < 100 else 21.0 if t < 200
else 22.0) if u == "a" else lv)
check("...held back at 20 %, still at 21 %, released at 22 % (more than 1 "
"point above its reserve)",
seg(REL[0], 40, 100)[0] == 0 and seg(REL[0], 140, 200)[0] == 0
and seg(REL[0], 240, 300)[0] > 500)
ID[1]._held_was = frozenset({A, B})
check("...a battery kept held back by the hysteresis is not one that can "
"give: A at 20 % and B kept at 21 % hold nobody back",
held_back((20.0, 21.0)) == frozenset())
# ============================================================================ #
print("\n5. the rules at their boundaries")
def stub(names, held, live=None):
"""A Controller stand-in for `_drain_rise()`, on SIMULATED units."""
s = types.SimpleNamespace(
inverters=[types.SimpleNamespace(name=n) for n in names],
actuator_of=lambda src, inv=None: inv.name,
live_units=lambda: tuple(live or names),
_held_back=lambda: frozenset(held))
return s
def rise(names, held, target, drains, extra=(), live=None):
"""`_drain_rise()` then the split: (claims kinds, shares)."""
cl = [Claim.limit("supervised", P_SAFETY, -FULL, FULL, actuator=n)
for n in names]
cl += [Claim.set("maintenance", P_MAINTENANCE, w, DRAIN, actuator=n)
for n, w in drains.items()]
cl += list(extra)
res = [MaintResult(DRAIN, True, drains[n], False, []) if n in drains
else MaintResult(IDLE, False, None, False, []) for n in names]
Controller._drain_rise(stub(names, held, live), cl, res)
r = resolve(cl + [Claim.set("loop", 10, target)], units=tuple(names),
live=set(live or names), reserve=held)
kinds = {c.actuator: c.kind for c in cl if c.source == "maintenance"}
return kinds, {u: round(v) for u, v in r.shares.items()}
_n3 = rise(("x", "y", "z"), {"z"}, 1500.0, {"x": 1000.0})
print(f" N=3, x draining 1000, y able, z held, 1500 W: {_n3[1]}")
check("⭐ N=3: the draining battery keeps its drain rate as a floor (x >= "
"1000 W) while sharing with the able one; the held one gives nothing",
_n3[0] == {"x": "limit"} and _n3[1]["x"] >= 1000 and _n3[1]["z"] == 0
and sum(_n3[1].values()) == 1500)
_stop = rise((A, B), {A}, 3000.0, {B: 1000.0},
(Claim.set("safety", P_SAFETY, 0.0, "stopped"),))
check("⭐ a site stop decides alone: the drain stays a pin, nothing rises",
_stop[0] == {B: "set"} and _stop[1] == {A: 0, B: 0})
_hd = rise((A, B), {A}, 5000.0, {A: 1000.0})
check("...a held battery's own drain stays a pin (it is not raised for the "
"loop): A 1000 W, B 2500 W",
_hd[0] == {A: "set"} and _hd[1] == {A: 1000, B: 2500})
_nh = rise((A, B), set(), 3000.0, {B: 1000.0})
check("...with nothing held the drain stays a pin, as before (SAFETY-78)",
_nh[0] == {B: "set"} and _nh[1] == {A: 2000, B: 1000})
_dead = rise((A, B), {A}, 3000.0, {B: 1000.0}, live=(A,))
check("...a draining battery that is not live is not touched",
_dead[0] == {B: "set"})
# ============================================================================ #
print("\n6. next_quarter()")
T0 = rig.BASE
def proj(elapsed, covered=1.0, held=3000.0, start=T0):
return Projection(projected_w=2000.0, elapsed_w=1900.0, elapsed_s=elapsed,
covered_frac=covered, complete=covered >= 1.0,
block_start=start, held_w=held)
_in = proj(899.0)
_past = next_quarter(proj(900.4, covered=899.9 / 900.4), 30.0)
check("⭐ inside its block a projection is unchanged (the same object)",
next_quarter(_in, 30.0) is _in)
check("⭐ past its end with a fresh held reading: the next quarter, "
"projected from that reading",
_past.block_start == T0 + timedelta(seconds=QUARTER_S)
and abs(_past.elapsed_s - 0.4) < 1e-9 and _past.projected_w == 3000.0
and _past.covered_frac == 1.0
and peakguard.blindness(_past, 30.0, False) is None)
_old = proj(960.0, covered=880.0 / 960.0)
_edge29 = proj(901.0, covered=871.5 / 901.0) # 29.5 s uncovered
_edge31 = proj(901.0, covered=870.5 / 901.0) # 30.5 s uncovered
check("...the held reading is credited up to max_hold_s and no further "
"(29.5 s uncovered: rolled; 30.5 s: unchanged)",
next_quarter(_edge29, 30.0) is not _edge29
and next_quarter(_edge31, 30.0) is _edge31)
check("...a held reading too old to be credited, no block, or an export: "
"unchanged, or projected at 0 W offtake",
next_quarter(_old, 30.0) is _old
and next_quarter(proj(950.0, start=None), 30.0).block_start is None
and next_quarter(proj(900.5, held=-800.0), 30.0).projected_w == 0.0)
# ============================================================================ #
print("\n7. N=1")
for _name, _args in (("HOLD, late telegram at the edge",
(5500.0, {A: HOLD}, {"a": 99.0}, 1000.0, 0.5)),
("drain under the reserve, house 4000",
(4000.0, {A: DRAIN}, {"a": 15.0}, 600.0, 0.5))):
_new = run(*_args, n=1)
_old = without_fix(lambda: run(*_args, n=1))
print(f" {_name}: A at the edge "
f"{[cmd(r)['a'] for r in _new[0] if 899 <= r[0] <= 901]}")
check(f"⭐ N=1, {_name}: every row (claims, commands, the guard) is "
"identical with and without the fix",
_new[1].maints[0].phase == _args[1][A] and len(_new[0]) > 500
and _new[0] == _old[0])
_one = types.SimpleNamespace(inverters=[object()])
_cl = [Claim.set("maintenance", P_MAINTENANCE, 1000.0, DRAIN)]
check("⭐ at one inverter nothing is held back and no pin moves (the N=1 "
"identity digests in test_safety78/79/83/84/86/87 stay pinned)",
Controller._held_back(_one) == frozenset()
and Controller._drain_rise(types.SimpleNamespace(
inverters=[object()], _held_back=lambda: frozenset()), _cl,
[MaintResult(DRAIN, True, 1000.0, False, [])]) is None
and _cl[0].kind == "set")
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all SAFETY-91 checks passed")
+422
View File
@@ -0,0 +1,422 @@
"""Runnable check for SAFETY-93: the maintenance charge end hands back
smoothly, with no grid charging at a high house. `python3 test_safety93.py`
At N=2, when battery A's maintenance charge ended, its watts stayed in the
loop's site target and landed on B: B went 0 -> -1600 W in one cycle and
charged from the grid with the house at 6000 W (SAFETY-85 rig test, check 4).
Now the loop starts from what B stood at plus A's new command. Drives the
REAL Controller on a lagging battery (10 s, 2 s meter, as on the rig).
DECISIONS/SAFETY-93-charge-end.md.
1. The rig case: A charges 2000 W late in a quarter with the house at 6000 W,
then its charge ends (HOLD).
2. The same at a low house.
3. The charge ends another way: a timeout (abort) and the battery switched
Off.
4. The charge ends while the guard has suspended it (A gives under the loop).
5. N=1 is untouched; a missed poll on the end cycle; the rule at its
boundaries.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import hashlib
import logging
import math
import os
import sys
from datetime import timedelta
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import safety79_identity as s79 # noqa: E402
import safety84_identity as s84 # noqa: E402
from app import packmode # noqa: E402
from app.main import Controller # noqa: E402
from app.maintenance import (CHARGE_TIMEOUT_MIN, HOLD, IDLE, # noqa: E402
MaintResult)
from app.store import unit_key # noqa: E402
from named_absence import first, present # noqa: E402
fails = []
A = "inverter-a"
SLEW = 400.0 # the rig's slew_w
AIM = 3500 - 50 # peak_cap_w - peak_margin_w
OPT0 = dict(s84.OPT)
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def lagging(fn, lag=10.0):
"""plantsim's battery (800 W/s ramp, `lag` s first-order lag) at the
live 2 s meter tick (test_safety85.py part 7)."""
real_p, real_t = rig._Hass.power, rig.TICK
def power(self, u):
st = self.__dict__.setdefault("_plant", {})
a, tgt, tl = st.get(u, (0.0, 0.0, self.t))
for _ in range(int(round((self.t - tl) / 0.1))):
d = self.cmd[u] - tgt
tgt += math.copysign(min(abs(d), 80.0), d)
a += (tgt - a) * (1 - math.exp(-0.1 / lag))
st[u] = (a, tgt, self.t)
return float(round(a))
rig._Hass.power, rig.TICK = power, 2.0
try:
return fn()
finally:
rig._Hass.power, rig.TICK = real_p, real_t
def run(n, house, end, *, step=None, dur=None, store=None, extra=None,
before=None):
"""A charging in a cheap window from the first cycle (quarters start at
0, 900, 1800 s); the house is 1200 W, then `house` from `step`; A reads
99 % (its charge target) from `end`. Returns (rows, controller)."""
step = end if step is None else step
s84.OPT = dict(OPT0, peak_cap_w=3500)
feed = s84.feed(True)
def both(c, t):
if before is not None:
before(c, t)
feed(c, t)
try:
return lagging(lambda: s84.run(
n, load=lambda t: house if t >= step else 1200.0, cheap=True,
store=store or s79.store_charging(n),
soc=lambda u, t: (99.0 if t >= end else 90.0) if u == "a"
else 60.0, dur=dur or end + 300.0, before=both,
extra=dict({"maintenance_hold_min": 600}, **(extra or {}))))
finally:
s84.OPT = OPT0
def cmd(r):
return dict(r[2])
def moves(rows, u, lo, hi):
"""The largest change in battery `u`'s command between cycles in
(lo, hi]."""
return max(abs(cmd(b)[u] - cmd(a)[u]) for a, b in zip(rows, rows[1:])
if lo < b[0] <= hi)
def charging_hi(rows, lo, hi, house_at):
"""Cycles in [lo, hi] where a battery charges while the house is above
the peak aim."""
return sum(1 for r in rows if lo <= r[0] <= hi and house_at(r[0]) > AIM
and min(cmd(r).values()) < 0.0)
# None on a tree without the fix (f3e409c), where this file runs red.
no_fix = getattr(Controller, "_reseed_charge_end", None)
def without(fn):
"""Run `fn` with the SAFETY-93 reseed taken out."""
if no_fix is None:
return fn()
Controller._reseed_charge_end = lambda self, ended: None
try:
return fn()
finally:
Controller._reseed_charge_end = no_fix
def phase_at(rows, t):
return first((r[-1][3][0] for r in rows if r[0] == t), f"row {t}")
def at(rows, t, u):
"""Battery `u`'s command at `t` s."""
r = first((r for r in rows if r[0] == t), f"row {t}")
return dict(r[2])[u] if present(r) else r
# ============================================================================ #
print("1. the rig case: A charges 2000 W late in a quarter, house 6000 W")
# House 1200 W until 1760 s, then 6000 W; the quarter (900-1800 s) has room,
# so the charge stays at 2000 W. A is full at 1780 s. Graded to 1798 s: the
# guard grades a new quarter from 1800 s.
R1 = run(2, 6000.0, 1780.0, step=1760.0)
R1_old = without(lambda: run(2, 6000.0, 1780.0, step=1760.0))
def hi1(t):
return 6000.0 if t >= 1760.0 else 1200.0
for name, (rows, _c) in (("now", R1), ("without the fix", R1_old)):
print(f" {name}: " + ", ".join(
f"{r[0]:.0f}s A {cmd(r)['a']:.0f} B {cmd(r)['b']:.0f}"
for r in rows if 1776 <= r[0] <= 1786))
_r, _ = R1
check("the case is the rig's: A charges 2000 W with the house at 6000 W the "
"cycle before its charge ends, then holds",
at(_r, 1778.0, "a") == -2000.0
and phase_at(_r, 1778.0) == "charge" and phase_at(_r, 1780.0) == HOLD)
check("⭐ no battery charges from the grid after the charge ends (house above "
"the aim)", charging_hi(_r, 1780, 1798, hi1) == 0)
check("⭐ B moves at most one slew step a cycle across the charge end, and A "
"after its HOLD pin", moves(_r, "b", 1776, 1798) <= SLEW
and moves(_r, "a", 1780, 1798) <= SLEW)
check("...and B gives toward the house at once (+400 W at the end cycle)",
at(_r, 1780.0, "b") == SLEW)
check("non-vacuous: without the fix B jumps 0 -> -1600 W and charges from "
"the grid for 4 cycles", moves(R1_old[0], "b", 1776, 1798) >= 1600
and charging_hi(R1_old[0], 1780, 1798, hi1) >= 4)
# ============================================================================ #
print("\n2. the same at a low house")
R2 = run(2, 1200.0, 1300.0)
R2_old = without(lambda: run(2, 1200.0, 1300.0))
print(f" B's largest move: {moves(R2[0], 'b', 1296, 1600):.0f} W, without "
f"the fix {moves(R2_old[0], 'b', 1296, 1600):.0f} W; B's least "
f"command after the end "
f"{min(cmd(r)['b'] for r in R2[0] if r[0] >= 1300):.0f}"
f" W, without {min(cmd(r)['b'] for r in R2_old[0] if r[0] >= 1300):.0f}"
" W")
check("⭐ house 1200 W: B moves at most one slew step a cycle and never "
"charges from the grid after A's charge ends",
moves(R2[0], "b", 1296, 1600) <= SLEW
and min(cmd(r)["b"] for r in R2[0] if r[0] >= 1300) >= 0.0)
check("non-vacuous: without the fix B jumps to -1600 W (2800 W from the grid)",
min(cmd(r)["b"] for r in R2_old[0] if r[0] >= 1300) <= -1600.0)
# ============================================================================ #
print("\n3. the charge ends another way")
def timed_out(end):
"""A store whose charge passes CHARGE_TIMEOUT_MIN at `end` s."""
s = s79.store_charging(2)
s.data[unit_key(A, "phase_started")] = (
rig.BASE + timedelta(seconds=end - CHARGE_TIMEOUT_MIN * 60 - 1)
).isoformat()
return s
def b_done():
"""A charging; B finished its monthly cycle, so it starts none here."""
s = s79.store_charging(2)
s.data[unit_key("inverter-b", "last_completed")] = rig.BASE.isoformat()
return s
def switch_off(at):
"""Battery A switched Off at `at` s (OPT-11 modes on)."""
def before(c, t):
if t >= at:
c.store.data[unit_key(A, packmode.USE_KEY)] = False
return before
ENDS = {
"timeout": lambda: run(2, 6000.0, 9999.0, step=1760.0, dur=2080.0,
store=timed_out(1780.0)),
"Off": lambda: run(2, 6000.0, 9999.0, step=1760.0, dur=2080.0,
store=b_done(), before=switch_off(1780.0),
extra={"battery_modes_enabled": True}),
}
for k, fn in ENDS.items():
rows, c = fn()
old = without(fn)[0]
print(f" {k}: A {[cmd(r)['a'] for r in rows if 1776 <= r[0] <= 1784]}"
f" B {[cmd(r)['b'] for r in rows if 1776 <= r[0] <= 1784]}; "
f"without the fix B "
f"{[cmd(r)['b'] for r in old if 1776 <= r[0] <= 1784]}")
check(f"{k}: the charge ends at 1780 s",
phase_at(rows, 1778.0) == "charge"
and phase_at(rows, 1780.0) == IDLE)
check(f"⭐ {k}: no battery charges from the grid after it, and B moves at "
f"most one slew step a cycle",
charging_hi(rows, 1780, 1798, hi1) == 0
and moves(rows, "b", 1776, 1798) <= SLEW)
check(f"non-vacuous ({k}): without the fix B jumps to -1600 W, from "
f"the grid", at(old, 1780.0, "b") == -1600.0
and charging_hi(old, 1780, 1798, hi1) >= 1)
# ============================================================================ #
print("\n4. the charge ends while the guard has suspended it")
# House 6000 W from 1500 s: the guard suspends the shaping, the loop has both
# batteries give. A is full at 1530 s; its HOLD pin takes it to 0 W.
R4 = run(2, 6000.0, 1530.0, step=1500.0)
R4_old = without(lambda: run(2, 6000.0, 1530.0, step=1500.0))
_a4 = at(R4[0], 1528.0, "a")
print(f" A gave {_a4:.0f} W before its HOLD; B's largest move "
f"{moves(R4[0], 'b', 1526, 1798):.0f} W, without the fix "
f"{moves(R4_old[0], 'b', 1526, 1798):.0f} W")
check("A gives under the loop before its charge ends",
present(_a4) and _a4 > SLEW and R4[1].maints[0].phase == HOLD)
check("⭐ B takes A's share at one slew step a cycle (A's own give stops "
"with its HOLD pin, as before)", moves(R4[0], "b", 1526, 1798) <= SLEW
and charging_hi(R4[0], 1530, 1798, lambda t: 6000.0) == 0)
check("non-vacuous: without the fix B jumps more than a slew step",
moves(R4_old[0], "b", 1526, 1798) > SLEW)
# ============================================================================ #
print("\n5. N=1 is untouched; a missed poll; the rule at its boundaries")
def digest(rows):
return hashlib.sha256(repr(rows).encode()).hexdigest()
_n1 = [digest(run(1, h, e, step=s)[0]) for h, e, s in
((6000.0, 1780.0, 1760.0), (1200.0, 1300.0, None))]
_n1_old = [digest(without(lambda: run(1, h, e, step=s))[0]) for h, e, s in
((6000.0, 1780.0, 1760.0), (1200.0, 1300.0, None))]
check("⭐ N=1, the charge ending at a high and a low house: every row "
"byte-identical with and without the reseed", _n1 == _n1_old)
if no_fix is None:
print(f"\n{len(fails)} FAILED (no _reseed_charge_end): " + ", ".join(fails))
sys.exit(1)
def missed_poll(at_t):
"""Battery B's power reading is missing on the cycle at `at_t` s."""
def before(c, t):
if not getattr(c.hass, "_s93", False):
read = c.hass.number
async def number(e, invert=False):
if e == "sensor.batt_b" and c.hass.t == at_t:
return None
return await read(e, invert)
c.hass.number, c.hass._s93 = number, True
return before
R6 = run(2, 6000.0, 1780.0, step=1760.0, before=missed_poll(1780.0))
R6_old = without(lambda: run(2, 6000.0, 1780.0, step=1760.0,
before=missed_poll(1780.0)))
print(f" a missed poll on the end cycle: B "
f"{[cmd(r)['b'] for r in R6[0] if 1778 <= r[0] <= 1786]}, without "
f"the fix {[cmd(r)['b'] for r in R6_old[0] if 1778 <= r[0] <= 1786]}")
check("a missed poll on the cycle the charge ends: the loop holds that cycle",
phase_at(R6[0], 1780.0) == HOLD
and "inputs missing" in str(first(r[6] for r in R6[0] if r[0] == 1780.0)))
check("⭐ ...and the end still reaches the loop on the next cycle: no grid "
"charge (A's charge stands through the held cycle), one slew step a "
"cycle", charging_hi(R6[0], 1782, 1798, hi1) == 0
and moves(R6[0], "b", 1776, 1798) <= SLEW)
check("non-vacuous: without the fix B charges from the grid",
charging_hi(R6_old[0], 1782, 1798, hi1) >= 1)
_c = R1[1]
def reseeded(target, shares, res, ended=frozenset({A})):
"""`_reseed_charge_end` on the rig's controller, A's charge ended and
its result `res`: (target, i_w)."""
_c.target, _c.i_w, _c._charge_ended = target, 123.0, ended
for inv in _c.inverters:
inv.target = shares[inv.name[-1]]
_c._reseed_charge_end([res, _free])
return _c.target, _c.i_w
_pin = MaintResult(HOLD, True, 0.0, False, [])
_free = MaintResult(IDLE, False, None, False, [])
check("a HOLD pin: the start is B's command plus the pin, integrator with it",
reseeded(-2000.0, {"a": -2000.0, "b": 0.0}, _pin) == (0.0, 0.0)
and reseeded(2770.0, {"a": 1385.0, "b": 1385.0}, _pin)
== (1385.0, 1385.0) and _c._charge_ended == frozenset())
check("a free battery: its charge leaves the start, its discharge stays",
reseeded(-2000.0, {"a": -2000.0, "b": 0.0}, _free) == (0.0, 0.0)
and reseeded(2770.0, {"a": 1385.0, "b": 1385.0}, _free)
== (2770.0, 2770.0))
check("no charge ended: nothing moves (the integrator either)",
reseeded(-2000.0, {"a": -2000.0, "b": 0.0}, _pin, frozenset())
== (-2000.0, 123.0))
_n1c = run(1, 6000.0, 1780.0, step=1760.0)[1]
_n1c.target, _n1c.i_w = -2000.0, 5.0
_n1c.inverters[0].target, _n1c._charge_ended = -2000.0, frozenset({A})
_n1c._reseed_charge_end([_pin])
check("N=1: nothing moves", (_n1c.target, _n1c.i_w) == (-2000.0, 5.0))
_seen = []
def _spy(self, maint_results):
_seen.append(sorted(self._charge_ended))
return no_fix(self, maint_results)
Controller._reseed_charge_end = _spy
try:
run(2, 6000.0, 1780.0, step=1760.0, dur=2400.0,
extra={"maintenance_hold_min": 5})
finally:
Controller._reseed_charge_end = no_fix
check("it fires once, at the charge end: not at the HOLD end or on any "
"other cycle", [x for x in _seen if x] == [[A]]
and len(_seen) > 300)
# ============================================================================ #
print("\n6. A still in CHARGE but no longer placed: stale link, refusal")
def link_drop(at):
"""A's writes rejected from `at` s, so it is dropped as a stale link."""
def before(c, t):
a = c.inverters[0]
if t >= at and a.rejected_since is None:
a.rejected_since = rig.BASE + timedelta(seconds=at)
return before
def refuses(at):
"""A stops charging from `at` s (reads 0 W); SAFETY-73 drops it."""
def before(c, t):
if not getattr(c.hass, "_s93r", False):
real = c.hass.power
def power(u):
p = real(u)
return 0.0 if u == "a" and c.hass.t >= at and p < 0 else p
c.hass.power, c.hass._s93r = power, True
return before
for k, hook in (("stale link", link_drop), ("refusal", refuses)):
def fn(hook=hook):
return run(2, 1200.0, 9999.0, dur=1500.0, before=hook(1300.0))
rows, _c6 = fn()
old = without(fn)[0]
drop = first((r[0] for r in rows if r[0] >= 1300
and "dropped inverter-a" in str(r[7])), "A dropped")
print(f" {k}: A dropped at {drop:.0f} s; B "
f"{[cmd(r)['b'] for r in rows if drop - 2 <= r[0] <= drop + 4]}, "
f"without the fix {[cmd(r)['b'] for r in old if drop - 2 <= r[0] <= drop + 4]}")
check(f"⭐ {k}: A is dropped while still in CHARGE, and B never charges "
f"from the grid and moves at most one slew step a cycle",
phase_at(rows, drop) == "charge"
and min(cmd(r)["b"] for r in rows if r[0] >= 1300) >= 0.0
and moves(rows, "b", 1296, 1500) <= SLEW)
check(f"non-vacuous ({k}): without the fix B jumps to -1600 W",
min(cmd(r)["b"] for r in old if r[0] >= 1300) <= -1600.0)
_seen.clear()
Controller._reseed_charge_end = _spy
try:
run(2, 6000.0, 1530.0, step=1500.0)
finally:
Controller._reseed_charge_end = no_fix
check("the guard suspending the charge is no end: it fires once, at the HOLD",
[x for x in _seen if x] == [[A]])
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all SAFETY-93 checks passed")
+361
View File
@@ -0,0 +1,361 @@
"""Runnable check for SAFETY-94: a healthy battery that lags or reverses is
not flagged as frozen or refusing. `python3 test_safety94.py`
Two false alarms, both pre-existing:
- The stale-reading gate (SAFETY-37) counted its 35 s from the register's last
change. After a rest that clock was already past 35 s, so the first read
after a new command tripped it, before the register could answer (SAFETY-58;
live 2026-10-03, four trips, each 1 s after a command).
- At N=1 the refusal (SAFETY-52) engaged after 12 frozen cycles if the reading
was under 100 W at that moment. At a 1 s meter, a battery reversing out of a
charge through its lag is passing 0 W just then (SAFETY-85 rig, O1). It was
held at 0 W until the next probe, up to 15 min.
Now the gate counts only the time the command asked the register to move, and
a refusal needs the reading to have stayed under 100 W, not moving towards the
ask, for 12 cycles spanning 22 s, at every roster size.
DECISIONS/SAFETY-94-lagging-battery.md.
1. The four live trips, replayed through the gate.
2. The REAL Controller on a lagging battery (tools/safety94_rig.py), N=1 and
N=2: rest then a step, maintenance drain start, drain -> charge, a charge
reversing into a discharge. No stale trip and no refusal.
3. A frozen register and a refusing battery are still caught, and how fast.
4. The rules at their edges.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import logging
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.CRITICAL)
import safety94_rig as rig # noqa: E402
import app.inverter as app_inverter # noqa: E402
import app.main as app_main # noqa: E402
from app import control # noqa: E402
from app.freshness import BOUNDS, Freshness # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
class Before94(Freshness):
"""The gate before SAFETY-94: the clock ran from the last change."""
def obliged_s(self):
return self.graded_s(self.last_read)
def without_fix(fn):
"""Run `fn` with the gate's old clock and the old refusal evidence."""
real_f, real_c = app_inverter.Freshness, app_main.compute
real_p = app_main.REFUSED_PROGRESS_W
app_inverter.Freshness = Before94
app_main.compute = lambda *a, **k: real_c(*a, **dict(k, gave_nothing=None))
app_main.REFUSED_PROGRESS_W = float("inf")
try:
return fn()
finally:
app_inverter.Freshness, app_main.compute = real_f, real_c
app_main.REFUSED_PROGRESS_W = real_p
# ============================================================================ #
print("1. the four live trips of 2026-10-03, replayed")
# HA history (only changes are recorded), local time: the unit's setpoint
# (what stands on its wire) and its battery power register (0x0518).
LIVE = {
"17:39:16 inverter-b": (
[("17:38:00", -24), ("17:38:14.7", 0), ("17:38:25.4", 5),
("17:38:40.5", 16), ("17:38:44.7", 26), ("17:38:55.7", 37),
("17:39:05.1", 42), ("17:39:09.8", 58), ("17:39:15.6", 268),
("17:39:25.0", 479), ("17:39:30.7", 689), ("17:39:34.8", 900)],
[("17:38:00", -118), ("17:38:21.6", -99), ("17:39:21.6", 179),
("17:39:31.6", 399)]),
"17:39:25 inverter-a": (
[("17:38:00", -16), ("17:38:14.9", 0), ("17:38:25.0", 5),
("17:38:40.0", 14), ("17:38:44.7", 24), ("17:38:55.2", 33),
("17:39:04.7", 38), ("17:39:09.3", 52), ("17:39:15.0", 242),
("17:39:24.6", 431), ("17:39:30.3", 621), ("17:39:34.8", 810)],
[("17:38:00", -3), ("17:38:25.9", 0), ("17:39:25.9", 318),
("17:39:36.0", 654)]),
"19:00:04 inverter-a": (
[("18:58:30.0", 290), ("18:58:43.3", 305), ("18:58:48.0", 314),
("18:58:52.5", 300), ("19:00:03.1", 2500)],
[("18:58:30.0", 292), ("19:00:06.0", 951), ("19:00:16.0", 2360),
("19:00:26.0", 2376)]),
"21:21:22 inverter-b": (
[("21:19:30.0", 0), ("21:21:21.9", 2100), ("21:21:26.6", 1700),
("21:21:31.1", 1300), ("21:21:36.9", 900), ("21:21:41.0", 500),
("21:21:47.3", 200)],
[("21:19:30.0", -98), ("21:21:22.8", -97), ("21:21:32.8", 1411),
("21:21:42.8", 798)]),
}
def _sec(hms):
h, m, s = hms.split(":")
return int(h) * 3600 + int(m) * 60 + float(s)
def replay(cls, setp, reg):
"""1 Hz reads, as `run_control()` does. Returns the trip times."""
f, trips = cls(BOUNDS["batt"]), []
t, end = _sec(setp[0][0]), _sec(reg[-1][0])
def at(series, t):
return [float(v) for ts, v in series if _sec(ts) <= t][-1]
while t <= end:
was = f.stale
f.submit(at(reg, t), at(setp, t), t)
if f.stale and not was:
trips.append(t)
t += 1.0
return trips
for name, (setp, reg) in LIVE.items():
old, new = replay(Before94, setp, reg), replay(Freshness, setp, reg)
print(f" {name}: before {[f'{int(t // 3600):02d}:{int(t % 3600 // 60):02d}:{t % 60:04.1f}' for t in old]}"
f", now {len(new)} trips")
check(f"{name}: the shipped trip reproduces 1-2 s after the command, and "
f"now there is none (the register answered within 11 s)",
len(old) == 1 and abs(old[0] - _sec(name.split()[0])) < 1.0
and new == [])
# ============================================================================ #
print("\n2. the REAL Controller on a lagging battery (10 s lag)")
# Two register shapes: published every 10 s and repeating its value (the
# boards, live), and every 1 s with +-1 W of dither (the SAFETY-85 rig).
SHAPES = ((10.0, False), (1.0, True))
NAMES = ("rest_step", "drain_start", "drain_charge", "reversal")
def sweep(fn=lambda f: f()):
out = {}
for n in (1, 2):
for name in NAMES:
for tick in (1.0, 2.0, 5.0):
for pub, dith in SHAPES:
s, r, _ = fn(lambda: rig.counts(n, name, tick=tick,
publish_s=pub,
dither=dith))
out[(n, name, tick, pub)] = (s, r)
return out
NOW = sweep()
OLD = sweep(without_fix)
for n in (1, 2):
for name in NAMES:
now = [NOW[k] for k in NOW if k[:2] == (n, name)]
old = [OLD[k] for k in OLD if k[:2] == (n, name)]
print(f" N={n} {name:12s} stale/refused now "
f"{sum(a for a, _ in now)}/{sum(b for _, b in now)}, before "
f"{sum(a for a, _ in old)}/{sum(b for _, b in old)}")
check("⭐ no stale trip and no refusal, at N=1 and N=2, at 1, 2 and 5 s meters, "
"on both register shapes: a step after a rest, a drain start, drain -> "
"charge and a charge reversing into a discharge",
all(v == (0, 0) for v in NOW.values()) and len(NOW) == 48)
check("non-vacuous: before, the same runs trip the gate (drain start and "
"drain -> charge at both N, as live) and refuse at N=1 on a 1 s meter",
sum(OLD[(n, nm, t, 10.0)][0] for n in (1, 2)
for nm in ("drain_start", "drain_charge") for t in (1.0, 2.0, 5.0))
>= 12 and OLD[(1, "drain_charge", 1.0, 1.0)][1] >= 1)
def reversals(n, tick, taus=(10.0, 20.0)):
"""A charge reversing into a discharge: (stale trips, refusals) summed."""
s = r = 0
for tau in taus:
for charge in (-1500.0, -2500.0):
for house in (300.0, 800.0, 2000.0):
for pub, dith in SHAPES:
x = rig.run(n, rig.reversal(300.0, charge, house), 600.0,
tau=tau, tick=tick, publish_s=pub,
dither=dith)
s, r = s + len(x["stale"]), r + len(x["refused"])
return s, r
REV = {(n, t): reversals(n, t) for n in (1, 2) for t in (1.0, 2.0)}
REV_OLD = {(n, 1.0): without_fix(lambda n=n: reversals(n, 1.0)) for n in (1, 2)}
print(f" reversals (lag 10 and 20 s): now {REV}, before {REV_OLD}")
check("⭐ SAFETY-94's case: a battery reversing out of a charge (1500 or 2500 "
"W) into a 300-2000 W house, lag 10 or 20 s, 1 or 2 s meter, is never "
"refused or flagged, at N=1 or N=2",
all(v == (0, 0) for v in REV.values()))
check("non-vacuous: before, the 1 s meter refuses at N=1 (lag 20 s) and at "
"N=2 (B, lag 20 s, its reading 100 W below its power)",
REV_OLD[(1, 1.0)][1] >= 1 and REV_OLD[(2, 1.0)][1] >= 1)
# ============================================================================ #
print("\n3. a frozen register and a refusing battery are still caught")
def obliged_at(rows, t_f):
"""First cycle from `t_f` where A's command is 250 W or more and has moved
250 W from where it stood at `t_f`."""
c0 = None
for row in rows:
if row[0] >= t_f and c0 is None:
c0 = row[2]["a"]
if c0 is not None and abs(row[2]["a"]) >= 250 \
and abs(row[2]["a"] - c0) >= 250:
return row[0]
return None
CATCH = {}
for n in (1, 2):
for tick in (1.0, 5.0):
# frozen while giving 1000 W, then the house steps
r1 = rig.run(n, lambda t: 3000.0 if t >= 400 else 1000.0, 520.0,
tick=tick, publish_s=1.0, dither=True,
freeze_at=lambda u: 300.0 if u == "a" else None)
ob = obliged_at(r1["rows"], 300.0)
st = [t for t, u in r1["stale"] if u == "inverter-a"]
# frozen at rest (HW-01's shape), the house steps 500 s later
r2 = rig.run(n, lambda t: 2500.0 if t >= 600 else 0.0, 700.0,
tick=tick, publish_s=1.0, dither=True,
freeze_at=lambda u: 100.0 if u == "a" else None)
ob2 = obliged_at(r2["rows"], 100.0)
park = min([t for t, u in r2["stale"] if u == "inverter-a"]
+ [t for t, _u in r2["refused"]] or [None])
# a refusing battery from 300 s
r3 = rig.run(n, lambda t: 3000.0, 420.0, tick=tick, publish_s=10.0,
cut=lambda u, t: u == "a" and t >= 300.0)
ref = [t for t, _u in r3["refused"]]
CATCH[(n, tick)] = (st[0] - ob if st and ob is not None else None,
park - ob2 if park is not None and ob2 is not None
else None, ref[0] - 300.0 if ref else None)
print(" (N, meter): (frozen giving: trip after the command moved, frozen "
"at rest: 0 W after, refusing: refused after the cut), in s")
print(f" {CATCH}")
check("⭐ a register frozen while giving is caught 35 s after the command asks "
"it to move (the SAFETY-37 bound, now counted from the ask)",
all(v[0] == BOUNDS["batt"].unchanged_s for v in CATCH.values()))
check("⭐ a register frozen at rest reads like a refusing battery: the "
"battery is at 0 W, refused or stale, within 36 s of the ask",
all(v[1] is not None and v[1] <= 36.0 for v in CATCH.values()))
check("⭐ a refusing battery is still refused: within 22 s plus one 10 s "
"publish at a 1 s meter, and within 12 cycles plus a publish at 5 s",
all(CATCH[(n, 1.0)][2] is not None and CATCH[(n, 1.0)][2] <= 32.0
for n in (1, 2))
and all(CATCH[(n, 5.0)][2] is not None
and CATCH[(n, 5.0)][2] <= 12 * 5.0 + 10.0 for n in (1, 2)))
# ============================================================================ #
print("\n4. the rules at their edges")
# The gate counts time the register was asked to move, across a flapping
# command too.
f = Freshness(BOUNDS["batt"])
trip = None
for t in range(0, 200):
f.submit(-3.0, 1500.0 if (t // 5) % 2 else 0.0, float(t))
if f.stale and trip is None:
trip = t
print(f" command flapping 0 / 1500 W every 5 s, register frozen: trip at "
f"{trip} s")
check("a frozen register under a command that flaps in and out of the ask is "
"still caught (after 35 s of asking, about 70 s here)",
trip is not None and 65 <= trip <= 75)
f = Freshness(BOUNDS["batt"])
for t in range(0, 30):
f.submit(-3.0, 1500.0, float(t))
f.submit(400.0, 1500.0, 30.0) # it answers
for t in range(31, 60):
f.submit(400.0, 1500.0, float(t))
check("a register that answers starts a fresh count: 30 s asked, then it "
"moves, then 29 s more is not stale",
not f.stale and f.obliged_s() < 35.0)
f = Freshness(BOUNDS["batt"])
for t in range(0, 600):
f.submit(-3.0, 200.0 if t % 60 < 3 else 0.0, float(t))
check("a command that never asks 250 W is never an ask (SAFETY-52's 200 W "
"probe cannot arm the gate)",
not f.stale and f.obliged_s() == 0.0)
# Review: time under 250 W is not counted, even after the command travelled.
f = Freshness(BOUNDS["batt"])
for t in range(0, 61):
f.submit(-300.0, -300.0 if t == 0 else 200.0, float(t))
for t in range(61, 131):
f.submit(-300.0, 260.0, float(t))
if t == 90:
early = f.stale
check("a command that has travelled 500 W but stands at 200 W for a minute "
"adds nothing: 30 s after it reaches 260 W the register is not stale, "
"and by 70 s it is",
not early and f.stale)
# compute(): `gave_nothing` gates the N=1 engage; None keeps the old law.
T = control.Tuning(max_w=4000.0, slew_w=400.0)
args = dict(prev_w=1200.0, grid_w=1200.0, actual_w=-20.0, tuning=T,
sat_count=control.SETTLE_CYCLES, i_w=1200.0, refused=0)
check("compute(): a frozen loop at SETTLE_CYCLES reading -20 W engages with "
"gave_nothing None (compute alone, as before) or True, and not with "
"False",
control.compute(**args).refused == 1
and control.compute(**args, gave_nothing=True).refused == 1
and control.compute(**args, gave_nothing=False).refused == 0)
# The evidence restarts on progress towards the ask, not on jitter.
print(f" REFUSED_PROGRESS_W {app_main.REFUSED_PROGRESS_W:.0f} W, span "
f"{app_main.REFUSED_SPAN_S:.0f} s")
class _Inv:
def __init__(self):
self.batt, self.ref_count, self.ref_since, self.ref_sign = 0.0, 0, None, 0
self.ref_from = None
self.idle_w = 0.0 # SAFETY-97: A, ~0 W at idle
self.ask = 800.0
def standing_w(self):
return self.ask
class _C:
_now_mono = 0.0
def evidence(readings, tick=1.0, ask=lambda i: 800.0):
c, inv = _C(), _Inv()
got = []
for i, b in enumerate(readings):
c._now_mono, inv.batt, inv.ask = i * tick, b, ask(i)
got.append(app_main.Controller._gave_nothing(c, inv))
return got
flat = evidence([-4.0 + (i % 3) for i in range(30)])
climb = evidence([-95.0 + 4.0 * i for i in range(48)])
check("a flat reading (+-1 W) under an 800 W ask is evidence after 22 s; one "
"climbing 4 W/s through the band is never",
1 in flat and flat.index(1) == 22 and not any(climb))
flap = evidence([-4.0] * 60, ask=lambda i: 800.0 if i % 2 else -800.0)
away = evidence([40.0] * 5 + [-40.0] * 25)
check("a command that swaps direction every cycle is never evidence, and a "
"reading moving 80 W away from the ask does not restart it (latched at "
"22 s, counted from the first reading)",
not any(flap) and 1 in away and away.index(1) == 22)
check("REFUSED_PROGRESS_W is under the band's width and over the live "
"readings' rest wander (25 W)",
25.0 < app_main.REFUSED_PROGRESS_W < 2 * control.REFUSED_BELOW_W)
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all checks passed")
+578
View File
@@ -0,0 +1,578 @@
"""Runnable check for SAFETY-95: a maintenance drain that ends hands back
smoothly at N>=2. `python3 test_safety95.py`
At N=2, when battery A's drain ended (A switched Off, say) with the house at
1200 W, the drain's floor stayed in the loop's site target and landed on B:
B went 0 -> +1600 W in one cycle and exported. Now the loop starts from what
the others stood at plus A's new command, raised to the house's own point
when the house needs more (never above last cycle's start), and counts every
battery at its command while A settles. Drives the REAL Controller on a
lagging battery (10 s, 2 s meter, as on the rig), at N=2 and N=3.
DECISIONS/SAFETY-95-drain-end.md.
1. Every drain end (complete -> charge, Off, timeout, operator abort, stale
link, refusal) at houses of 1200 W and 4000 W and under a solar surplus.
2. The peak guard still binds across a drain end.
3. A drain end in the same cycle as another battery's drop; a missed poll.
4. N=1 is untouched; the rule at its boundaries.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import asyncio
import functools
import hashlib
import logging
import math
import os
import sys
import tempfile
from datetime import timedelta
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import safety79_identity as s79 # noqa: E402
from app import main as app_main, packmode # noqa: E402
from app.arbiter import Claim, P_MAINTENANCE, P_SAFETY # noqa: E402
from app.main import Controller # noqa: E402
from app.maintenance import (CHARGE, DRAIN, DRAIN_TIMEOUT_MIN, # noqa: E402
HOLD, IDLE, MaintResult)
from app.store import unit_key # noqa: E402
fails = []
SLEW = 400.0 # the rig's slew_w
TOL = SLEW / 4 # the loop's own overshoot on a lagging battery
AIM = -10.0 # the rig's target_grid_w
T = 300.0 # the drain ends here
M = {"maintenance_enabled": True, "maintenance_start_hour": 11}
A = "inverter-a"
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
# None on a tree without the fix, where this file prints the base numbers
# and runs red.
fixed = getattr(Controller, "_reseed_drain_end", None)
real_grid = getattr(Controller, "_drain_grid_w", None)
real_drop = Controller._reseed_drop
def _base_drop(self, live):
"""SAFETY-96's drop reseed as it was: a draining battery is not
skipped."""
keep, self._drain_ended = self._drain_ended, frozenset()
try:
real_drop(self, live)
finally:
self._drain_ended = keep
def without(fn):
"""Run `fn` with SAFETY-95 taken out (the base behaviour)."""
if fixed is None:
return fn()
Controller._reseed_drain_end = lambda self, r, live: setattr(
self, "_drain_ended", frozenset())
Controller._drain_grid_w = lambda self, live: float(self.grid)
Controller._reseed_drop = _base_drop
try:
return fn()
finally:
Controller._reseed_drain_end = fixed
Controller._drain_grid_w = real_grid
Controller._reseed_drop = real_drop
def options(n, extra):
o = rig._options(n, extra)
if n > 1:
o["inverters"] = [
{"name": f"inverter-{u}", "setpoint_entity": f"number.sp_{u}",
"soc_entity": f"sensor.soc_{u}", "batt_entity": f"sensor.batt_{u}",
"max_w": 2500, "bms_soc_floor": 10} for u in "abc"[:n]]
return o
def run(n, house, *, store, extra=None, soc=None, reject=None, dead=None,
before=None, dur=T + 120.0, lag=10.0, p1=False, back=None):
"""Rows of one run at N=`n` on plantsim's battery (800 W/s ramp, `lag`
s first-order lag) at a 2 s meter tick. Writes to the units in `reject`
(name -> from s) are refused, until `back` s; `dead` (u, from s[, to
s]): u gives nothing from then on (until `to`). Each row: (t, the
add-on's commands, site target, grid, phases, loop reason, peak floor).
opt11_identity's cycle, with a third battery."""
units = tuple("abc"[:n])
soc = soc or (lambda u, t: 50.0)
def refused(u, t):
return bool(reject) and u in reject and t >= reject[u] and (
back is None or t < back)
class Hass(rig._Hass):
def power(self, u):
st = self.__dict__.setdefault("_plant", {})
a, tgt, tl = st.get(u, (0.0, 0.0, self.t))
for k in range(int(round((self.t - tl) / 0.1))):
now = tl + (k + 1) * 0.1
c = self.cmd[u]
if dead is not None and u == dead[0] and dead[1] <= now < (
dead[2] if len(dead) > 2 else math.inf):
c = 0.0
d = c - tgt
tgt += math.copysign(min(abs(d), 80.0), d)
a += (tgt - a) * (1 - math.exp(-0.1 / lag))
st[u] = (a, tgt, self.t)
return float(round(a))
async def set_number(self, e, v):
if refused(e[-1], self.t):
return False
self.cmd[e[-1]] = float(v)
return True
opts = options(n, dict(M, **(s79.P1 if p1 else {}), **(extra or {})))
db = os.path.join(tempfile.mkdtemp(prefix="s95-"), "tariff.db")
async def go():
hass = Hass(units, soc, lambda u, t: False,
lambda t: house(t) if callable(house) else house)
c = Controller(opts, hass, store, rig._Pub(),
stop=asyncio.Event(), peak_db_path=db)
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
real_dt, clock = app_main.datetime, [0.0]
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return rig.BASE + timedelta(seconds=clock[0])
app_main.datetime = _DT
rows = []
try:
t = 0.0
while t < dur:
hass.t = clock[0] = t
for inv in c.inverters:
u = inv.name[-1]
if refused(u, t) and inv.rejected_since is None:
inv.rejected_since = rig.BASE + timedelta(seconds=t)
if before is not None:
r = before(c, t)
if asyncio.iscoroutine(r):
await r
if p1:
s79._feed(c, t)
await c.read_inputs(now_mono=t)
await c.cycle()
g = c.peak_guard
rows.append((t, tuple(i.target for i in c.inverters),
c.target,
hass.grid(), tuple(m.phase for m in c.maints),
c.loop_reason,
None if g is None or not g.engaged
else g.floor_w))
t += 2.0
finally:
app_main.datetime = real_dt
return rows, c
return asyncio.run(go())
def store(n, **a):
"""A due for its monthly cycle (it drains from the first cycle); the
others finished theirs, so none starts here."""
s = rig._Store()
if n == 1:
for k, v in a.items():
s.data[k] = v
return s
for u in "bc"[:n - 1]:
s.data[unit_key(f"inverter-{u}", "last_completed")] = \
rig.BASE.isoformat()
for k, v in a.items():
s.data[unit_key(A, k)] = v
return s
def timed_store(n):
"""A's drain passes DRAIN_TIMEOUT_MIN at T; its last start was at 0 s,
so the abort's cooldown keeps it from starting again."""
return store(n, phase=DRAIN, phase_started=(
rig.BASE + timedelta(seconds=T - DRAIN_TIMEOUT_MIN * 60 - 1)
).isoformat(), last_start_attempt=rig.BASE.isoformat())
def off(c, t):
if t >= T:
c.store.data[unit_key(A, packmode.USE_KEY)] = False
async def abort(c, t):
if t == T:
await c.handle_action("maint_abort")
ENDS = {
"complete": lambda n, h, **k: run(
n, h, store=store(n), soc=lambda u, t:
5.0 if (u == "a" and t >= T) else 50.0, **k),
"Off": lambda n, h, **k: run(n, h, store=store(n), before=off,
extra={"battery_modes_enabled": True}, **k),
"timeout": lambda n, h, **k: run(n, h, store=timed_store(n), **k),
"abort": lambda n, h, **k: run(n, h, store=store(n), before=abort, **k),
# A's link goes stale; its board's watchdog sends it to 0 W after 25 s.
"stale link": lambda n, h, **k: run(n, h, store=store(n),
reject={"a": T}, dead=("a", T + 25.0),
dur=T + 200.0, **k),
# A gives nothing from T; SAFETY-73 learns it ~55 s later.
"refusal": lambda n, h, **k: run(n, h, store=store(n), dead=("a", T),
dur=T + 300.0, **k),
}
HOUSES = (1200.0, 4000.0, -1500.0)
def end_at(rows):
"""The first cycle A is not draining, or its command left the drain;
None if its drain never ended."""
return next((r[0] for r in rows if r[0] >= T and (
r[4][0] != DRAIN or r[1][0] < 2000.0)), None)
def measure(rows, n, house):
"""From the end on: the site target's largest step after the end
cycle (the slew's subject); the commanded export in the 20 s after it (the site command over
what the house needs, or over what the others stood at); the largest
grid import; the export beyond what the site exported before the end;
the largest grid step; the others' largest command step."""
te = end_at(rows)
if te is None: # no end: every measure is NaN, so every check FAILs
return dict(end=math.nan, site_step=math.nan, cmd_export=math.nan,
imp=math.nan, exp=math.nan, gstep=math.nan,
ostep=math.nan, rows=rows)
pre = next((r for r in reversed(rows) if r[0] < te), rows[0])
seg = [r for r in rows if r[0] >= te - 2.0]
aft = [r for r in rows if te <= r[0] <= te + 120.0]
others0 = sum(pre[1][1:])
site = [r[2] for r in seg]
return dict(
end=te,
site_step=max(abs(b - a) for a, b in zip(site[1:], site[2:])),
cmd_export=max(sum(r[1]) - max(house - AIM, others0) for r in aft
if r[0] <= te + 20.0),
imp=max(r[3] for r in aft),
exp=min(min(pre[3], AIM) - r[3] for r in aft),
gstep=max(abs(b[3] - a[3]) for a, b in zip(seg, seg[1:])),
ostep=max(abs(b[1][i] - a[1][i]) for a, b in zip(seg, seg[1:])
for i in range(1, n)),
rows=rows)
TABLE = []
def case(kind, n, house):
now = measure(ENDS[kind](n, house)[0], n, house)
old = measure(without(lambda: ENDS[kind](n, house))[0], n, house)
TABLE.append((kind, n, house, now, old))
return now, old
# ============================================================================ #
print("1. every drain end, N=2 and N=3, houses 1200 W, 4000 W and -1500 W")
for kind in ENDS:
for n in (2, 3):
for house in HOUSES:
now, old = case(kind, n, house)
tag = f"{kind}, N={n}, house {house:.0f} W"
# Abort zeroes the site (operator); a stale link's start is
# SAFETY-96's drop. Those are graded against base.
own = kind not in ("abort", "stale link")
check(f"{'⭐' if own else ' '} {tag}: no battery gives into an "
f"export after the end (commanded export "
f"{now['cmd_export']:.0f} W, base {old['cmd_export']:.0f}) "
f"and the site target moves at most one slew step a cycle "
f"after it ({now['site_step']:.0f} W, base "
f"{old['site_step']:.0f})",
now["cmd_export"] <= (TOL if own else max(
TOL, old["cmd_export"] + TOL))
and now["site_step"] <= SLEW)
check(f" {tag}: the grid imports no more than base (+50 W) "
f"({now['imp']:.0f} W, base {old['imp']:.0f}) and exports "
f"no more than it did before the end, or base (+50 W) "
f"({now['exp']:.0f} W more, base {old['exp']:.0f})",
now["imp"] <= old["imp"] + 50.0
and now["exp"] <= max(0.0, old["exp"]) + 50.0)
_by = {(k, n, h): (a, b) for k, n, h, a, b in TABLE}
for kind in ("complete", "Off", "timeout", "refusal"):
_now, _old = _by[(kind, 2, 1200.0)]
check(f"non-vacuous ({kind}, N=2, 1200 W): without the fix B steps "
f"more than a slew step into an export",
_old["cmd_export"] >= 300.0 and _old["ostep"] > SLEW
and _now["ostep"] < _old["ostep"])
_now, _old = _by[(kind, 2, -1500.0)]
check(f"non-vacuous ({kind}, solar surplus): without the fix B gives "
f"into the surplus", _old["cmd_export"] >= 1000.0
and _now["cmd_export"] <= TOL)
_now, _old = _by[("complete", 2, 1200.0)]
check("⭐ the rig case (A's drain completes at 1200 W): B covers the house "
"at once (0 -> 1210 W, not 1600), and the grid stays within 50 W of "
"its aim while A settles (base: +559 W import, B down to 230 W)",
[r[1][1] for r in _now["rows"] if r[0] == _now["end"]] == [1210.0]
and _now["imp"] <= 50.0 and _old["imp"] > 400.0)
_now, _old = _by[("complete", 3, 4000.0)]
check("N=3 at 4000 W: the others take A's share at once, as before (the "
"site does not drop), so the grid does not import",
_now["imp"] <= 50.0 and abs(_now["imp"] - _old["imp"]) <= 50.0)
_now, _old = _by[("stale link", 3, 4000.0)]
check(f"⭐ stale link, N=3, 4000 W: a dropped draining battery is this "
f"rule's, not the link-drop reseed's, so the grid imports at least "
f"50 W less than base ({_now['imp']:.0f} W, base {_old['imp']:.0f})",
_now["imp"] <= _old["imp"] - 50.0)
# ============================================================================ #
print("\n2. the peak guard still binds across a drain end")
_g = ENDS["Off"](2, 4000.0, p1=True, dur=T + 120.0)[0]
_gs = [r for r in _g if r[0] >= T - 20 and r[6] is not None]
print(f" guard engaged on {len(_gs)} cycles around the end; site "
f"{[sum(r[1]) for r in _g if T - 4 <= r[0] <= T + 8]}")
check("⭐ house 4000 W over a 2500 W limit, A switched Off mid-drain: on "
"every cycle the guard is engaged, the site gives at least its floor "
"(or every battery is at its rating)",
_gs and all(sum(r[1]) >= min(r[6], 2500.0 * 1) - 1.0 for r in _gs))
# ============================================================================ #
print("\n3. a drain end with another edge in the same cycle; a missed poll")
def c_drops(c, t):
"""C's writes are refused from T - 30 s, so it drops at T, the cycle A
is switched Off; its board goes to 0 W at once (T - 30 s)."""
off(c, t)
_e3 = run(3, 4000.0, store=store(3), before=c_drops, reject={"c": T - 30.0},
dead=("c", T - 30.0), extra={"battery_modes_enabled": True})[0]
_e3m = measure(_e3, 3, 4000.0)
print(f" A Off as C drops, house 4000 W: B "
f"{[r[1][1] for r in _e3 if T - 4 <= r[0] <= T + 10]}")
check("⭐ A switched Off on the cycle C drops (N=3, 4000 W): B takes over "
"with no export and the site moves one slew step a cycle after",
_e3m["cmd_export"] <= TOL and _e3m["site_step"] <= SLEW
and _e3m["end"] == T)
def c_back(c, t):
"""C's link (stale since T - 60 s, its board holding 0 W) answers again
at T, the cycle A is switched Off."""
off(c, t)
if t == T:
c.inverters[2].note_write(True, rig.BASE + timedelta(seconds=t))
_r3 = run(3, 1200.0, store=store(3), before=c_back, reject={"c": T - 60.0},
back=T, dead=("c", T - 60.0, T), extra={"battery_modes_enabled": True})[0]
_r3m = measure(_r3, 3, 1200.0)
print(f" A Off as C comes back, house 1200 W: B, C "
f"{[r[1][1:] for r in _r3 if T - 4 <= r[0] <= T + 6]}")
check("⭐ A switched Off on the cycle C comes back (N=3, 1200 W): no "
"export, one slew step a cycle after",
_r3m["cmd_export"] <= TOL and _r3m["site_step"] <= SLEW
and _r3m["end"] == T)
def missed_poll(at_t):
"""Battery B's power reading is missing on the cycle at `at_t` s."""
def before(c, t):
off(c, t)
if not getattr(c.hass, "_s95", False):
read = c.hass.number
async def number(e, invert=False):
if e == "sensor.batt_b" and c.hass.t == at_t:
return None
return await read(e, invert)
c.hass.number, c.hass._s95 = number, True
return before
_mp = run(2, 1200.0, store=store(2), before=missed_poll(T),
extra={"battery_modes_enabled": True})[0]
_mpo = without(lambda: run(2, 1200.0, store=store(2), before=missed_poll(T),
extra={"battery_modes_enabled": True}))[0]
print(f" a missed poll on the end cycle: B "
f"{[r[1][1] for r in _mp if T - 2 <= r[0] <= T + 8]}, without the fix "
f"{[r[1][1] for r in _mpo if T - 2 <= r[0] <= T + 8]}")
check("⭐ a missed poll on the cycle A is switched Off: the end still "
"reaches the loop on the next cycle (B covers the house, no export)",
max(sum(r[1]) for r in _mp if r[0] > T) <= 1200.0 - AIM + TOL
and max(sum(r[1]) for r in _mpo if r[0] > T) >= 1500.0)
# ============================================================================ #
print("\n4. N=1 is untouched; the rule at its boundaries")
def digest(rows):
return hashlib.sha256(repr(rows).encode()).hexdigest()
_n1 = [functools.partial(ENDS[k], 1, h) for k in ENDS
for h in (1200.0, -1500.0)]
check("⭐ N=1, every drain end (complete, Off, timeout, abort, stale link, "
"refusal), at 1200 W and under a surplus: every row byte-identical "
"with and without SAFETY-95",
[digest(f()[0]) for f in _n1] == [digest(without(f)[0]) for f in _n1])
_wave = functools.partial(run, 2, lambda t: 2400.0 + 1500 * math.sin(t / 40),
store=store(2, last_completed=rig.BASE.isoformat()),
dur=600.0)
check("N=2 healthy (a moving house, no maintenance): every row "
"byte-identical with and without SAFETY-95",
digest(_wave()[0]) == digest(without(_wave)[0]))
if fixed is None:
print(f"\n{len(fails)} FAILED (no _reseed_drain_end): " + ", ".join(fails))
sys.exit(1)
_c = run(2, 1200.0, store=store(2), dur=4.0)[1]
_pin = MaintResult(IDLE, True, 0.0, False, [])
_free = MaintResult(CHARGE, False, None, True, [])
_b = MaintResult(IDLE, False, None, False, [])
AB = {"inverter-a", "inverter-b"}
def reseeded(target, shares, batts, grid, res=_pin, live=AB, ended=(A,)):
"""`_reseed_drain_end` on a 2-battery controller: (target, i_w,
A's start)."""
_c.target, _c.i_w, _c._drain_ended = target, 123.0, frozenset(ended)
_c._loop_start, _c._settle, _c._drain_tail = {}, {}, set()
_c._now_mono, _c.grid = 50.0, grid
for inv in _c.inverters:
inv.target, inv.batt = shares[inv.name[-1]], batts[inv.name[-1]]
_c._reseed_drain_end([res, _b], set(live))
return _c.target, round(_c.i_w, 6), _c._loop_start.get(A)
# House 1200 W: A drains 2000, B 0, grid -800.
check("the house needs more than the others stand at: the start is the "
"house's own point (1210 W), the integrator with it",
reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
-800.0) == (1210.0, 1210.0, 0.0)
and _c._settle == {A: 50.0} and _c._drain_tail == {A}
and _c._drain_ended == frozenset())
check("...and while A settles the loop's error is the house's point minus "
"its start (0 here: it holds), not the meter's -790 W",
_c._drain_grid_w(AB) - AIM == 0.0)
check("a solar surplus (house -1500 W): the start is what the others stood "
"at plus A's new command (0 W), and the loop moves down from there",
reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
-3500.0)[0] == 0.0)
check("the house needs more than stood placed (4000 W, B at 2000): never "
"above last cycle's start",
reseeded(4000.0, {"a": 2000.0, "b": 2000.0},
{"a": 2000.0, "b": 2000.0}, 0.0)[0] == 4000.0)
check("a free battery (drain complete -> charge): its drain leaves the "
"start; a pin's value is its new command",
reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
-3500.0, res=_free)[2] == 0.0
and reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
-3500.0, res=MaintResult(HOLD, True, 0.0, False, []))[2]
== 0.0)
check("a dropped battery's power is the meter's: a board stuck at 2000 W "
"keeps B where it stood, and there is no tail to count",
reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
-800.0, live={"inverter-b"})[0] == 0.0
and _c._drain_tail == set())
_chg = MaintResult(CHARGE, True, -1500.0, False, [])
check("a drain that ends into a pinned charge: the pin is A's new command "
"while A is live, but 0 W once it is dropped (it is sent 0 W)",
reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
-3500.0, res=_chg)[2] == -1500.0
and reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
-3500.0, res=_chg, live={"inverter-b"}) == (0.0, 0.0, 0.0))
check("no drain ended: nothing moves (the integrator either)",
reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
-800.0, ended=()) == (2000.0, 123.0, None))
_c._drain_tail, _c._settle, _c.grid = {A}, {}, -800.0
check("the tail ends with A's settling (SAFETY-96's window)",
_c._drain_grid_w(AB) == -800.0 and _c._drain_tail == set())
_UA, _UB = (_c.actuator_of("maintenance", i) for i in _c.inverters)
_drain = MaintResult(DRAIN, True, 2000.0, False, [])
def drain_ends(extra, refused=0):
"""`_charge_track` with A draining last cycle, its drain SET and the
claims `extra` on A: which drains it ends."""
_c._draining_was, _c._drain_ended = frozenset({A}), frozenset()
_c._charging_was, _c._charge_ended = frozenset(), frozenset()
_c.live_units = lambda: (_UA, _UB)
_c.inverters[0].refused = refused
try:
_c._charge_track([Claim.set("maintenance", P_MAINTENANCE, 2000.0,
actuator=_UA)] + extra, [_drain, _b])
finally:
del _c.live_units
_c.inverters[0].refused = 0
return set(_c._drain_ended)
_rating = [Claim.limit("device", P_SAFETY, -2500.0, 2500.0, actuator=_UA)]
check("a drain that still stands does not end (its rating binds, no "
"contradiction, no refusal)", drain_ends(_rating) == set())
check("a drain whose limits contradict ends: A is sent 0 W (SAFETY-91's "
"pin as a floor against a closed pack)",
drain_ends(_rating + [
Claim.limit("maintenance", P_MAINTENANCE, lo=2000.0, actuator=_UA),
Claim.limit("pack", P_SAFETY, hi=0.0, actuator=_UA)]) == {A})
check("a refusal ends a drain only if A will not give (+1); one that will "
"not take (-1) leaves the drain standing",
drain_ends(_rating, refused=1) == {A}
and drain_ends(_rating, refused=-1) == set())
_n1c = run(1, 1200.0, store=store(1), dur=4.0)[1]
_n1c.target, _n1c.i_w, _n1c._drain_ended = 2000.0, 5.0, frozenset({A})
_n1c._reseed_drain_end([_pin], {A})
check("N=1: nothing moves", (_n1c.target, _n1c.i_w) == (2000.0, 5.0))
_seen = []
def _spy(self, maint_results, live):
_seen.append(sorted(self._drain_ended))
return fixed(self, maint_results, live)
Controller._reseed_drain_end = _spy
try:
run(2, 1200.0, store=store(2), soc=lambda u, t:
5.0 if (u == "a" and T <= t < T + 200) else 99.5 if u == "a" and
t >= T + 200 else 50.0, dur=T + 400.0,
extra={"maintenance_hold_min": 2})
finally:
Controller._reseed_drain_end = fixed
check("it fires once, at the drain end: not at the charge end, the HOLD "
"end, or on any other cycle", [x for x in _seen if x] == [[A]]
and len(_seen) > 300)
print("\n drain end | N | house W | site step W head/base | commanded "
"export W head/base | others' step W head/base | grid import W "
"head/base | grid step W head/base | export beyond the end's W head/base")
for k, n, h, a, b in TABLE:
print(f" {k} | {n} | {h:.0f} | {a['site_step']:.0f}/"
f"{b['site_step']:.0f} | {a['cmd_export']:.0f}/{b['cmd_export']:.0f}"
f" | {a['ostep']:.0f}/{b['ostep']:.0f} | {a['imp']:.0f}/"
f"{b['imp']:.0f} | {a['gstep']:.0f}/{b['gstep']:.0f} | "
f"{a['exp']:.0f}/{b['exp']:.0f}")
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all SAFETY-95 checks passed")
+573
View File
@@ -0,0 +1,573 @@
"""Runnable check for SAFETY-96: a battery the loop cannot move does not
freeze the loop. `python3 test_safety96.py`
At N=2, after A's link went stale the site sum still counted A's power, so
`compute()` saw the site "not following" and froze B (SAFETY-93 rig test:
B held at 1200 W for ~236 cycles). After a charge timeout or Off at 6000 W
it froze ~14 s while A came out of its charge. Now a battery out of the
split, with refused writes, or settling after a step the loop did not make
counts at its command; its real power reaches the loop through the meter.
Drives the REAL Controller on a lagging battery (10 s, 2 s meter).
DECISIONS/SAFETY-96-dropped-battery.md.
1. A's link drops while it discharges: A holds its last command (stuck), or
its board goes to 0 W 25 s after the first refused write.
2. A's link drops while it charges (maintenance).
3. B's link drops; both drop.
4. A drops, then comes back.
5. A's charge times out, or A is switched Off, at 6000 W.
6. N=1 is untouched; the rule's parts one by one.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import hashlib
import logging
import math
import os
import sys
from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import safety79_identity as s79 # noqa: E402
import safety84_identity as s84 # noqa: E402
from app import packmode # noqa: E402
from app.main import Controller # noqa: E402
from app.maintenance import CHARGE_TIMEOUT_MIN, IDLE, MaintResult # noqa: E402
from app.store import unit_key # noqa: E402
from named_absence import first, present # noqa: E402
fails = []
SLEW = 400.0
FROZEN = ("saturated-freeze", "freeze-released")
OPT = dict(s84.OPT, peak_cap_w=3500)
PLAIN = dict(s79.P1, peak_cap_w=3500)
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
# None on a tree without the fix, where this file prints the base numbers
# and runs red.
fixed = getattr(Controller, "_held_out_w", None)
real_drop = getattr(Controller, "_reseed_drop", None)
def without(fn):
"""Run `fn` with SAFETY-96 taken out (the base behaviour)."""
if fixed is None:
return fn()
Controller._held_out_w = lambda self, live: 0.0
Controller._reseed_drop = lambda self, live: setattr(
self, "_live_was", frozenset(live))
try:
return fn()
finally:
Controller._held_out_w, Controller._reseed_drop = fixed, real_drop
def run(n=2, *, house=lambda t: 2400.0, dur=600.0, store=None, opt=None,
soc=None, reject=None, back=None, wd=None, before=None, lag=10.0,
blind=None, flaky=None, dead=None):
"""Rows of one run. Writes to the units in `reject` (name -> from s) are
refused from that time until `back` s; the add-on sees the refusal at
once (a heartbeat write). A refused unit holds its last command, or with
`wd` its board goes to 0 W `wd` s after the first refused write. B's
reading is missing in the `blind` (from, to) window. `flaky(u, t)`:
writes to u refused at t, each cycle's verdict seen (a heartbeat).
`dead` (u, from s): u gives nothing from then on, whatever it is told.
Each row: (t, plant commands, add-on commands, loop reason, site target,
grid, circulation tripped)."""
reject = reject or {}
real_p, real_s, real_tick = rig._Hass.power, rig._Hass.set_number, rig.TICK
def refused(u, t):
if flaky is not None:
return flaky(u, t)
return u in reject and t >= reject[u] and (back is None or t < back)
def power(self, u):
st = self.__dict__.setdefault("_plant", {})
a, tgt, tl = st.get(u, (0.0, 0.0, self.t))
for k in range(int(round((self.t - tl) / 0.1))):
now = tl + (k + 1) * 0.1
c = self.cmd[u]
if wd is not None and refused(u, now) and now - reject[u] >= wd:
c = 0.0
if dead is not None and u == dead[0] and now >= dead[1]:
c = 0.0
d = c - tgt
tgt += math.copysign(min(abs(d), 80.0), d)
a += (tgt - a) * (1 - math.exp(-0.1 / lag))
st[u] = (a, tgt, self.t)
return float(round(a))
async def set_number(self, e, v):
if refused(e[-1], self.t):
return False
self.cmd[e[-1]] = float(v)
return True
rows = []
def hook(c, t):
for inv in c.inverters:
if flaky is not None:
inv.note_write(not flaky(inv.name[-1], t),
rig.BASE + timedelta(seconds=t))
elif refused(inv.name[-1], t) and inv.rejected_since is None:
inv.rejected_since = rig.BASE + timedelta(seconds=t)
if before is not None:
before(c, t)
if blind is not None and not getattr(c.hass, "_s96", False):
read = c.hass.number
async def number(e, invert=False):
if e == "sensor.batt_b" and blind[0] <= c.hass.t < blind[1]:
return None
return await read(e, invert)
c.hass.number, c.hass._s96 = number, True
(s84.feed(True) if opt is not None else s79._feed)(c, t)
def on_cycle(c, t):
rows.append((t, tuple(sorted(c.hass.cmd.items())),
tuple(inv.target for inv in c.inverters), c.loop_reason,
c.target, c.hass.grid(), c.circ_wd.tripped,
tuple(inv.batt for inv in c.inverters)))
rig._Hass.power, rig._Hass.set_number, rig.TICK = power, set_number, 2.0
try:
_r, ctl = rig.run(n, extra=dict(opt if opt is not None else PLAIN),
dur=dur, load=house, soc=soc or (lambda u, t: 60.0),
cut=lambda u, t: False, before=hook,
on_cycle=on_cycle, store=store or rig._Store())
finally:
rig._Hass.power, rig._Hass.set_number, rig.TICK = real_p, real_s, \
real_tick
return rows, ctl
def freeze(rows, lo, hi=1e9):
return sum(1 for r in rows if lo <= r[0] < hi and r[3] in FROZEN)
def phantom(rows, k, lo, hi=1e9):
"""Freeze cycles while battery `k` (the one taking over) reads within
saturation_w (500 W) of what the loop last asked of it plus anything
unplaced (last site target less the other's command): the freeze is
then about the other battery, not about `k`'s own lag."""
return sum(1 for a, b in zip(rows, rows[1:]) if lo <= b[0] < hi
and b[3] in FROZEN
and abs(a[4] - a[2][1 - k] - b[7][k]) <= 500.0)
def ramp(rows, k, lo, hi=1e9):
"""The largest change of unit `k`'s command (0 = A) between cycles."""
return max(abs(b[2][k] - a[2][k]) for a, b in zip(rows, rows[1:])
if lo < b[0] <= hi)
def site_step(rows, lo, hi=1e9):
return max(abs(b[4] - a[4]) for a, b in zip(rows, rows[1:])
if lo < b[0] <= hi)
def grid_err(rows, lo, hi=1e9):
"""Mean |grid - aim| over [lo, hi) (aim -10 W)."""
xs = [abs(r[5] + 10.0) for r in rows if lo <= r[0] < hi]
return sum(xs) / len(xs)
def trip_at(rows):
"""When the circulation guard tripped (its own 0 W step is no ramp)."""
return next((r[0] for r in rows if r[6]), 1e9)
def tripped(rows):
return rows[-1][6]
def at(rows, t, k):
return first((r[2][k] for r in rows if r[0] == t), f"row {t}")
TABLE = []
LAST = [None] # the controller of the last run with the fix
def case(name, fn, lo, k, hi=1e9):
"""Run `fn` with and without SAFETY-96, print and record the numbers:
freeze cycles (and those not about `k`'s own lag), `k`'s largest step,
the mean grid error, a circulation trip."""
(now, LAST[0]), old = fn(), without(fn)[0]
row = (name, freeze(now, lo, hi), freeze(old, lo, hi),
phantom(now, k, lo, hi), phantom(old, k, lo, hi),
ramp(now, k, lo, min(hi, trip_at(now) - 1)),
ramp(old, k, lo, min(hi, trip_at(old) - 1)),
grid_err(now, lo, hi), grid_err(old, lo, hi),
tripped(now), tripped(old))
TABLE.append(row)
print(f" {name}: freeze {row[1]} (base {row[2]}), not about its own lag "
f"{row[3]} (base {row[4]}); step {row[5]:.0f} (base {row[6]:.0f}) "
f"W/cycle; grid error {row[7]:.0f} (base {row[8]:.0f}) W; "
f"circulation trip {row[9]} (base {row[10]})")
return now, old
STUCK, BOARD = ("stuck", None), ("board 0 W", 25.0)
# ============================================================================ #
print("1. A's link drops while it discharges (house 2400 W, each 1200 W)")
for v, w in (STUCK, BOARD):
now, old = case(f"A drop discharging, {v}",
lambda w=w: run(reject={"a": 300.0}, wd=w), 300.0, 1)
check(f"⭐ {v}: no freeze about A, B moves at most one slew step a "
f"cycle, no circulation trip", phantom(now, 1, 300) == 0
and ramp(now, 1, 300) <= SLEW and not tripped(now))
check(f"non-vacuous ({v}): without the fix the loop freezes about A, or "
f"B jumps more than a slew step at the drop",
phantom(old, 1, 300) >= 10 or ramp(old, 1, 300) > SLEW)
if w is None:
check("stuck: B stays at its share, since A still gives its 1200 W "
"(no freeze at all, grid within 15 W on average)",
freeze(now, 300) == 0 and grid_err(now, 300) < 15.0
and freeze(old, 300) >= 100)
else:
check("board 0 W: B ends up carrying the whole house (2410 W)",
abs(now[-1][2][1] - 2410.0) <= 20.0)
# ============================================================================ #
print("\n2. A's link drops while it charges (maintenance, house 1200 W)")
def charging(**kw):
return run(house=lambda t: 1200.0, dur=1500.0,
store=s79.store_charging(2), opt=dict(
OPT, maintenance_hold_min=600),
soc=lambda u, t: 90.0 if u == "a" else 60.0, **kw)
for v, w in (STUCK, BOARD):
now, old = case(f"A drop charging, {v}",
lambda w=w: charging(reject={"a": 1300.0}, wd=w),
1300.0, 1)
trip = trip_at(now)
check(f"⭐ {v}: no freeze about A, and B climbs at most one slew step a "
f"cycle and never charges (until any circulation trip)",
phantom(now, 1, 1300, trip) == 0
and ramp(now, 1, 1300, trip - 1) <= SLEW
and min(r[2][1] for r in now if r[0] >= 1300) >= 0.0)
check(f"non-vacuous ({v}): without the fix the loop freezes about A",
phantom(old, 1, 1300) >= 3)
if w is None:
check("stuck (A charges 2000 W on its own): B reaches its 2500 W "
"before the circulation trip, which still trips on the real "
"4000 W going round (as on base)",
max(r[2][1] for r in now if r[0] < trip) == 2500.0
and tripped(now) and tripped(old))
else:
check("board 0 W: B carries the house (grid within 50 W from 100 s "
"after the drop), no circulation trip",
grid_err(now, 1400) < 50.0 and not tripped(now))
# ============================================================================ #
print("\n3. B's link drops; both drop")
for v, w in (STUCK, BOARD):
now, old = case(f"B drop discharging, {v}",
lambda w=w: run(reject={"b": 300.0}, wd=w), 300.0, 0)
check(f"⭐ B drops ({v}): no freeze about B, A moves at most one slew "
f"step a cycle, no circulation trip", phantom(now, 0, 300) == 0
and ramp(now, 0, 300) <= SLEW and not tripped(now))
for v, w in (STUCK, BOARD):
now, old = case(f"both drop, {v}",
lambda w=w: run(reject={"a": 300.0, "b": 300.0}, wd=w),
300.0, 1)
check(f"⭐ both drop ({v}): no freeze; both are commanded 0 W once "
f"nothing can be placed", freeze(now, 300) == 0
and now[-1][2] == (0.0, 0.0) and not tripped(now))
# ============================================================================ #
print("\n4. A drops, then comes back")
def rejoined(rows):
return next((r[0] for r in rows if r[0] > 420 and r[2][0] != 0.0), 1e9)
for v, w in (STUCK, BOARD):
now, old = case(f"A drop and rejoin, {v}",
lambda w=w: run(reject={"a": 300.0}, back=420.0, wd=w,
dur=700.0), 300.0, 1)
back = rejoined(now)
check(f"rejoin ({v}): A is back in the split within 4 s of its link "
f"returning (no refusal was learned while its writes were refused)",
back <= 424.0 and not any("REFUSES" in e for e in LAST[0].events))
print(f" A back in the split at {back:.0f} s; A "
f"{[r[2][0] for r in now if back - 2 <= r[0] <= back + 6]}, B "
f"{[r[2][1] for r in now if back - 2 <= r[0] <= back + 6]}; grid "
f"{[round(r[5]) for r in now if back - 2 <= r[0] <= back + 6]}; "
f"base grid {[round(r[5]) for r in old if back - 2 <= r[0] <= back + 6]}")
a0 = first((r for r in now if r[0] == back - 2), "row before the rejoin")
a1 = first((r for r in now if r[0] == back), "the rejoin row")
check(f"⭐ rejoin ({v}): no freeze after it; the site target takes in only "
f"A's own reading, then moves at most one slew step a cycle; the "
f"grid stays within 250 W of its aim; no circulation trip",
present(a0) and present(a1) and freeze(now, back) == 0
and abs(a1[4] - a0[4] - a0[7][0]) <= SLEW
and site_step(now, back) <= SLEW
and max((abs(r[5] + 10.0) for r in now if r[0] >= back),
default=1e9) < 250.0
and not tripped(now))
if w is None:
check("non-vacuous (stuck): without the fix the grid swings over "
"1000 W after the rejoin", max((abs(r[5] + 10.0) for r in old
if r[0] >= back), default=0.0)
>= 1000.0)
# Review: a battery that does refuse, behind a link that refuses one write
# in three, is still found out (the count is kept over a refused write).
_fl_rows, _fl = run(dur=700.0, dead=("a", 300.0),
flaky=lambda u, t: u == "a" and t >= 300.0
and int(t / 2) % 3 == 0)
_fl_at = next((r[0] for r in _fl_rows if r[2][0] == 0.0 and r[0] > 300.0),
None)
_fl_end = first((r[2] for r in _fl_rows[-1:]), "the last row")
print(f" A gives nothing from 300 s, 1 write in 3 refused: A's share is "
f"taken off at {_fl_at} s; A {_fl.inverters[0].refused}, last "
f"commands {_fl_end}")
check("⭐ a battery that refuses behind a link refusing 1 write in 3: its "
"refusal is still learned and B carries the house",
_fl.inverters[0].refused == 1 and _fl_at is not None
and _fl_at <= 400.0 and present(_fl_end)
and abs(_fl_end[1] - 2410.0) <= 20.0)
# Review: a fast battery whose board goes to 0 W 1 s after the first refused
# write reads ~0 W long before its link is stale (30 s): no refusal either.
_ref5 = []
_e5_rows, _e5 = run(reject={"a": 300.0}, back=420.0, wd=1.0, lag=1.0,
dur=700.0,
before=lambda c, t: _ref5.append(c.inverters[0].refused))
check("fast battery, board 0 W 1 s after the first refused write: no "
"refusal is learned, A is back in the split within 4 s of its link "
"returning",
not any(_ref5) and rejoined(_e5_rows) <= 424.0)
_seen = []
def blind_seen(c, t):
"""Record the last cycle: whether the loop ran, is A live, what settles."""
if t > 340.0:
_seen.append((t - 2.0, c.loop_reason, "inverter-a" in c.live_units(),
dict(c._settle)))
run(reject={"a": 300.0}, back=420.0, blind=(340.0, 470.0), dur=490.0,
before=blind_seen)
_back = first((x for x in _seen if x[1] != "not-running" and x[0] > 400),
"the restart")
print(f" B's reading missing 340-470 s, A back at 420 s: control holds from "
f"{first((x[0] for x in _seen if x[1] == 'not-running'), '?')} s, "
f"restarts at {_back[0]:.0f} s, settling then: {_back[3]}")
check("A drops, then comes back while control holds (a reading missing): no "
"rejoin at the restart, which starts from what is on the wire",
any(x[1] == "not-running" and x[2] for x in _seen) and _back[3] == {})
# ============================================================================ #
print("\n5. A's charge times out, or A is switched Off, at a 6000 W house")
def timed_out(end):
"""A's charge passes CHARGE_TIMEOUT_MIN at `end` s; A then stays free
(its cycle started today, so no restart) and B is not due."""
s = s79.store_charging(2)
s.data[unit_key("inverter-a", "phase_started")] = (
rig.BASE + timedelta(seconds=end - CHARGE_TIMEOUT_MIN * 60 - 1)
).isoformat()
s.data[unit_key("inverter-a", "last_start_attempt")] = rig.BASE.isoformat()
s.data[unit_key("inverter-b", "last_completed")] = rig.BASE.isoformat()
return s
def b_done():
s = s79.store_charging(2)
s.data[unit_key("inverter-b", "last_completed")] = rig.BASE.isoformat()
return s
def switch_off(at_t):
def before(c, t):
if t >= at_t:
c.store.data[unit_key("inverter-a", packmode.USE_KEY)] = False
return before
def high(t):
return 6000.0 if t >= 1760.0 else 1200.0
ENDS = {
"timeout": lambda: run(house=high, dur=1798.0, store=timed_out(1780.0),
opt=dict(OPT, maintenance_hold_min=600),
soc=lambda u, t: 90.0 if u == "a" else 60.0),
"Off": lambda: run(house=high, dur=1798.0, store=b_done(),
before=switch_off(1780.0),
opt=dict(OPT, maintenance_hold_min=600,
battery_modes_enabled=True),
soc=lambda u, t: 90.0 if u == "a" else 60.0),
}
for k, fn in ENDS.items():
now, old = case(f"{k} at 6000 W", fn, 1780.0, 1)
print(f" B {[r[2][1] for r in now if 1778 <= r[0] <= 1796]}, base "
f"{[r[2][1] for r in old if 1778 <= r[0] <= 1796]}")
check(f"⭐ {k}: no freeze about A while it comes out of its charge; B "
f"climbs one slew step a cycle and never charges",
phantom(now, 1, 1780) == 0 and ramp(now, 1, 1780) <= SLEW
and min(r[2][1] for r in now if r[0] >= 1780) >= 0.0)
check(f"non-vacuous ({k}): without the fix the loop freezes about A and "
f"B ends lower", phantom(old, 1, 1780) >= 3
and at(now, 1796.0, 1) > at(old, 1796.0, 1))
# ============================================================================ #
print("\n6. N=1 is untouched; the rule's parts")
def digest(rows):
return hashlib.sha256(repr(rows).encode()).hexdigest()
def wave(t):
return 2400.0 + 1500 * math.sin(t / 40)
_n1 = [lambda: run(1, reject={"a": 300.0}, back=420.0, wd=25.0),
lambda: run(1, house=high, dur=1798.0, store=timed_out(1780.0),
opt=dict(OPT, maintenance_hold_min=600)),
lambda: run(1, house=wave)]
check("⭐ N=1 (a refused link that comes back, a charge timeout, a moving "
"house): every row byte-identical with and without SAFETY-96",
[digest(f()[0]) for f in _n1] == [digest(without(f)[0]) for f in _n1])
check("N=2 healthy (a moving house, no drop): every row byte-identical with "
"and without SAFETY-96",
digest(run(house=wave)[0]) == digest(without(lambda: run(
house=wave))[0]))
if fixed is None:
print(f"\n{len(fails)} FAILED (no _held_out_w): " + ", ".join(fails))
sys.exit(1)
_c = run(dur=4.0)[1]
AB = ("inverter-a", "inverter-b")
def held(batts, targets, live=AB, rejected=(), settle=None, now=100.0,
start=None):
for inv, b, t in zip(_c.inverters, batts, targets):
inv.batt, inv.target = b, t
inv.rejected_since = rig.BASE if inv.name in rejected else None
_c._settle = dict(settle or {})
_c._loop_start = dict(start or {})
_c._now_mono = now
return _c._held_out_w(set(live))
check("a healthy pair: nothing held out, however far a reading lags",
held((-2000.0, 400.0), (0.0, 400.0)) == 0.0)
check("a dropped battery counts at its command (0 W), not its reading",
held((-2000.0, 800.0), (0.0, 800.0), live=("inverter-b",)) == 2000.0)
check("a battery whose writes are refused counts at its command",
held((900.0, 800.0), (1200.0, 800.0), rejected=("inverter-a",))
== 300.0)
check("a settling battery counts at its command until its reading is within "
"the deadband (15 W) of it, then it is the loop's again",
held((-20.0, 800.0), (0.0, 800.0), settle={"inverter-a": 95.0})
== 20.0 and "inverter-a" in _c._settle
and held((-10.0, 800.0), (0.0, 800.0), settle={"inverter-a": 95.0})
== 0.0 and "inverter-a" not in _c._settle)
check("...but not on the cycle it starts",
held((-10.0, 800.0), (0.0, 800.0), settle={"inverter-a": 100.0})
== 10.0 and "inverter-a" in _c._settle)
check("...or until PLANT_SETTLE_DOWN_S (23 s) has passed, whichever is first",
held((-2000.0, 800.0), (0.0, 800.0), settle={"inverter-a": 77.1})
== 2000.0 and held((-2000.0, 800.0), (0.0, 800.0),
settle={"inverter-a": 77.0}) == 0.0)
check("a reseeded start (SAFETY-93's charge end) is the command it counts at",
held((-2000.0, 800.0), (-2000.0, 800.0), settle={"inverter-a": 99.0},
start={"inverter-a": 0.0}) == 2000.0)
def dropped(was, live, target, shares, batts=(0.0, 0.0), start=None):
_c._live_was, _c.target, _c.i_w = (None if not was else frozenset(was),
target, 123.0)
_c._loop_start, _c._settle, _c._now_mono = dict(start or {}), {}, 50.0
for inv, b in zip(_c.inverters, batts):
inv.target, inv.batt = shares[inv.name[-1]], b
_c._reseed_drop(set(live))
return _c.target, _c.i_w
check("a drop: the dropped battery's share leaves the start point, the "
"integrator with it", dropped(AB, ("inverter-b",), 2400.0,
{"a": 1200.0, "b": 1200.0})
== (1200.0, 1200.0) and _c._live_was == frozenset({"inverter-b"}))
_c._charge_ended = frozenset({"inverter-a"})
check("...but a battery whose charge ended too is left to SAFETY-93's reseed",
dropped(AB, ("inverter-b",), -2000.0, {"a": -2000.0, "b": 0.0})
== (-2000.0, -2000.0))
_c._charge_ended = frozenset()
_free = MaintResult(IDLE, False, None, False, [])
def charge_end(rejected_s):
"""SAFETY-93's reseed for A (giving 1385 W under the loop, its charge
suspended), A's writes refused for `rejected_s` s: (target, start)."""
_c.target, _c.i_w, _c._charge_ended = 2770.0, 0.0, frozenset({AB[0]})
_c._loop_start, _c._settle = {}, {}
for inv in _c.inverters:
inv.target, inv.rejected_since = 1385.0, None
_c.inverters[0].rejected_since = datetime.now(timezone.utc) - timedelta(
seconds=rejected_s)
_c._reseed_charge_end([_free, _free])
_c.inverters[0].rejected_since = None
return _c.target, _c._loop_start[AB[0]]
check("a charge that ends as its battery drops: the battery's start is 0 W "
"(it is sent 0 W), not its last give; still live, its give stays",
charge_end(31.0) == (1385.0, 0.0) and charge_end(5.0) == (2770.0, 1385.0))
check("a rejoin: the battery enters the start point at its reading, and "
"both settle while the split is made again",
dropped(("inverter-b",), AB, 1200.0, {"a": 0.0, "b": 1200.0},
batts=(-700.0, 1200.0)) == (500.0, 500.0)
and _c._settle == {"inverter-a": 50.0, "inverter-b": 50.0})
check("no change: nothing moves (the integrator either)",
dropped(AB, AB, 1200.0, {"a": 600.0, "b": 600.0}) == (1200.0, 123.0)
and dropped((), ("inverter-b",), 1200.0, {"a": 0.0, "b": 1200.0})
== (1200.0, 123.0))
_n1c = run(1, dur=4.0)[1]
_n1c._live_was, _n1c.target, _n1c.i_w = frozenset({"inverter-a"}), 900.0, 5.0
_n1c._reseed_drop(set())
check("N=1: nothing moves and nothing is held out",
(_n1c.target, _n1c.i_w) == (900.0, 5.0)
and _n1c._held_out_w(set()) == 0.0)
print("\n case | freeze head/base | not about its own lag head/base | "
"step W head/base | grid error W head/base | circulation trip head/base")
for r in TABLE:
print(f" {r[0]} | {r[1]}/{r[2]} | {r[3]}/{r[4]} | {r[5]:.0f}/{r[6]:.0f} |"
f" {r[7]:.0f}/{r[8]:.0f} | {r[9]}/{r[10]}")
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all SAFETY-96 checks passed")
+441
View File
@@ -0,0 +1,441 @@
"""Runnable check for SAFETY-97: "the battery gives nothing" is graded against
each battery's own idle reading. `python3 test_safety97.py`
B's power reading shows -95 to -118 W at idle (standby draw; live
2026-10-03). The refusal evidence (SAFETY-52/73, shared since SAFETY-94)
needed the reading under 100 W in magnitude, so a refusing B never latched,
at N=2 ever, and at N=1 since SAFETY-94 (review F1).
Now each unit learns its idle reading (app/idle.py) and the band, the
refusal's end and the stale-path refusal are centred on it.
DECISIONS/SAFETY-97-idle-offset.md.
1. A refusing B is caught at N=1 and N=2, 1/2/5 s meters; A unchanged.
2. A healthy B with its offset is never refused or flagged (SAFETY-94's
scenarios), learned or restored.
3. Learning: not while refusing or asked, bounded, restored, follows drift.
4. The rules at their edges.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import logging
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.CRITICAL)
import safety94_rig as rig # noqa: E402
import app.idle as idle # noqa: E402
import app.main as app_main # noqa: E402
from app import control # noqa: E402
from app.store import unit_key # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def without_fix(fn):
"""Run `fn` with every idle reading at 0 W (the rule before SAFETY-97)."""
real = idle.IdleReading.w
idle.IdleReading.w = property(lambda self: 0.0)
try:
return fn()
finally:
idle.IdleReading.w = real
def with_offset(n, fn):
"""At N=1 the rig's only unit is "a": give it B's offset."""
saved = dict(rig.OFFSET)
if n == 1:
rig.OFFSET["a"] = rig.OFFSET["b"]
try:
return fn()
finally:
rig.OFFSET.clear()
rig.OFFSET.update(saved)
REST, CUT = 400.0, 600.0 # rest (it learns), then ask, then cut
REST_LOAD = (lambda t: 0.0 if t < REST else 3000.0)
def caught(n, unit, tick, *, offset=True, dur=None, store=None, load=None,
cut_at=CUT, **kw):
"""Seconds from the cut to the refusal, or None, and the run."""
cu = "a" if n == 1 else unit
def go():
return rig.run(n, load or REST_LOAD, dur or cut_at + 300.0,
tick=tick, noise=4.0, store=store,
cut=lambda u, t: u == cu and t >= cut_at, **kw)
r = with_offset(n, go) if offset and unit == "b" else go()
ref = [t - cut_at for t, _u in r["refused"]]
return (ref[0] if ref else None), r
# ============================================================================ #
print("1. a refusing battery, B with its -100 W idle reading (+-4 W noise)")
TIMES, OLD = {}, {}
for unit in ("a", "b"):
for n in (1, 2):
for tick in (1.0, 2.0, 5.0):
TIMES[(unit, n, tick)] = caught(n, unit, tick)[0]
OLD[(unit, n, tick)] = without_fix(
lambda: caught(n, unit, tick)[0])
for k in TIMES:
print(f" refusing {k[0].upper()} N={k[1]} meter {k[2]:.0f} s: refused "
f"{TIMES[k]} s after the cut (before {OLD[k]})")
check("⭐ a refusing B is caught at N=1 and N=2: within 22 s plus one 10 s "
"publish at 1 and 2 s meters, 12 cycles plus a publish at 5 s",
all(TIMES[("b", n, t)] is not None
and TIMES[("b", n, t)] <= (32.0 if t < 5 else 12 * 5.0 + 10.0)
for n in (1, 2) for t in (1.0, 2.0, 5.0)))
check("non-vacuous: before, a refusing B is never caught (300 s), at N=1 "
"or N=2",
all(OLD[("b", n, t)] is None for n in (1, 2) for t in (1.0, 2.0, 5.0)))
check("⭐ a refusing A is caught exactly as before",
all(TIMES[("a", n, t)] == OLD[("a", n, t)] is not None
for n in (1, 2) for t in (1.0, 2.0, 5.0)))
# B refusing for 800 s: one refusal, held, not ended by its own idle reading.
_t, r = caught(2, "b", 1.0, dur=CUT + 800.0)
starts = [x for x in r["refused"] if x[1] == "inverter-b"]
check("a refusing B stays refused until its probe: its idle reading is not "
"read as taking power the other way (one refusal in 800 s)",
len(starts) == 1 and r["c"].inverters[1].refused == 1)
# A register frozen at rest on B: refused or stale within 36 s of the ask.
r = rig.run(2, lambda t: 2500.0 if t >= 600 else 0.0, 700.0, tick=1.0,
publish_s=1.0, dither=True,
freeze_at=lambda u: 300.0 if u == "b" else None)
park = min([t for t, u in r["stale"] if u == "inverter-b"]
+ [t for t, u in r["refused"] if u == "inverter-b"] or [None])
print(f" B's register frozen at rest from 300 s, asked at 600 s: 0 W at "
f"{park}")
check("a register frozen at rest on B is caught as on A (within 36 s of "
"the ask)", park is not None and park - 600.0 <= 36.0)
# At a 5 s meter the stale trip comes first: its refusal is centred too.
saved = dict(rig.OFFSET)
rig.OFFSET["b"] = -110.0
try:
r = rig.run(2, lambda t: 2500.0 if t >= 600 else 0.0, 760.0, tick=5.0,
publish_s=1.0, dither=True,
freeze_at=lambda u: 300.0 if u == "b" else None)
finally:
rig.OFFSET.clear()
rig.OFFSET.update(saved)
st = [t for t, u in r["stale"] if u == "inverter-b"]
ref = [t for t, u in r["refused"] if u == "inverter-b"]
print(f" the same at a 5 s meter, B idle -110 W: stale {st}, refused {ref}")
check("...and at a 5 s meter the stale trip on B's held -110 W is a refusal "
"of B (one trip), not a repeating site stop",
len(st) == 1 and ref == st)
# Review: the two centrings with no check of their own.
# N=1, 5 s meter: the stale trip on a held -110 W idle reading is a refusal.
saved = dict(rig.OFFSET)
rig.OFFSET["a"] = -110.0
try:
r = rig.run(1, lambda t: 2500.0 if t >= 600 else 0.0, 800.0, tick=5.0,
publish_s=1.0, dither=True, freeze_at=lambda u: 300.0)
finally:
rig.OFFSET.clear()
rig.OFFSET.update(saved)
st = [t for t, _u in r["stale"]]
ref = [t for t, _u in r["refused"]]
print(f" N=1, 5 s meter, register frozen on -110 W: stale {st}, refused "
f"{ref}")
check("...and at N=1 the same stale trip is a refusal (one trip), not a "
"repeating site stop", len(st) == 1 and ref == st)
# SAFETY-42: B asked under 200 W while giving nothing (its idle reading) is
# parked, not named as not delivering; A carries the house.
def small_ask():
return rig.run(2, lambda t: 150.0, 700.0, tick=1.0, noise=4.0,
store={unit_key("inverter-b", "idle_w"): -100.0},
cut=lambda u, t: u == "b")["c"].inverters[1]
b, b_old = small_ask(), without_fix(small_ask)
check("SAFETY-42: B giving nothing at a small ask, idle -100 W, is parked "
"(not latched as not delivering in 700 s; with idle 0 W it is)",
not b.delivery_wd.tripped and b.idle.value == -100.0
and b_old.delivery_wd.tripped)
# ============================================================================ #
print("\n2. a healthy B with its offset is never refused or flagged")
SHAPES = ((10.0, False), (1.0, True))
NAMES = ("rest_step", "drain_start", "drain_charge", "reversal")
SAVED = {"b": {unit_key("inverter-b", "idle_w"): -100.0},
"a": {unit_key("inverter-a", "idle_w"): -100.0}}
bad = []
runs = 0
for n in (1, 2):
for name in NAMES:
for tick in (1.0, 2.0, 5.0):
for pub, dith in SHAPES:
for store in (None, SAVED["a" if n == 1 else "b"]):
s, f, _r = with_offset(n, lambda: rig.counts(
n, name, tick=tick, publish_s=pub, dither=dith,
store=store))
runs += 1
if s or f:
bad.append((n, name, tick, pub, bool(store), s, f))
print(f" {runs} runs, stale or refused: {bad}")
check("⭐ SAFETY-94's scenarios with B's offset (N=1 and N=2, 1/2/5 s "
"meters, both register shapes, idle learned or restored): no stale "
"trip and no refusal", not bad and runs == 96)
rev = []
for n in (1, 2):
for tau in (10.0, 20.0):
for charge in (-1500.0, -2500.0):
for house in (300.0, 800.0, 2000.0):
x = with_offset(n, lambda: rig.run(
n, rig.reversal(300.0, charge, house), 600.0, tau=tau,
tick=1.0, store=SAVED["a" if n == 1 else "b"]))
if x["stale"] or x["refused"]:
rev.append((n, tau, charge, house))
check("⭐ a charge reversing into a discharge (lag 10 and 20 s, 1 s meter) "
"with B's idle reading restored is never refused or flagged",
not rev)
# ============================================================================ #
print("\n3. learning the idle reading")
_t, r = caught(2, "b", 1.0)
c = r["c"]
check("learned during the rest: A about 0 W, B about -100 W (used: -75 W), "
"and saved",
abs(c.inverters[0].idle_w) < 5
and abs(c.inverters[1].idle.value + 100) < 5
and c.inverters[1].idle_w == -idle.IDLE_MAX_W
and abs(c.store.data.get(unit_key("inverter-b", "idle_w"), 0) + 100)
< 5)
# A refusing B from the start, no rest, nothing saved: nothing to learn from.
_t, r = caught(2, "b", 1.0, load=lambda t: 3000.0, cut_at=60.0, dur=1200.0)
check("a battery refusing from the start teaches nothing: asked the whole "
"time, then held at 0 W by its refusal (none learned, none saved)",
r["c"].inverters[1].idle.value is None
and unit_key("inverter-b", "idle_w") not in r["c"].store.data)
# ... which is why the value is saved: restored, the same battery is caught.
t_saved, _r = caught(2, "b", 1.0, load=lambda t: 3000.0, cut_at=60.0,
store=SAVED["b"])
t_none, _r = caught(2, "b", 1.0, load=lambda t: 3000.0, cut_at=60.0)
print(f" refusing from the start: saved idle {t_saved} s, none {t_none}")
check("⭐ after a restart the saved idle reading is used: a B refusing from "
"the start is caught in 32 s; with nothing saved it is not",
t_saved is not None and t_saved <= 32.0 and t_none is None)
# Not learned while refused: a refusing B reading -160 W (in the band) does
# not pull its idle reading during the 0 W its refusal holds.
class _P(rig.Plant):
def advance(self, t):
super().advance(t)
for u in self.units:
if self.cut(u, self.t):
self.reg[u] = -160.0
real_plant = rig.Plant
rig.Plant = _P
try:
_t, r = caught(2, "b", 1.0, dur=CUT + 800.0)
finally:
rig.Plant = real_plant
b = r["c"].inverters[1]
print(f" refusing B reading -160 W: refused at {_t} s, idle "
f"{b.idle.value:.1f}")
check("⭐ not learned while refused: 800 s held at 0 W reading -160 W leaves "
"B's idle reading at -100 W",
_t is not None and b.refused and abs(b.idle.value + 100.0) < 1.0)
# Not learned while control is stopped (another owner may drive the battery)
# or while the unit's sign watchdog has latched.
class _Stopped(app_main.Controller):
auto = property(lambda self: False, lambda self, v: None)
rig.Controller = _Stopped
try:
r = rig.run(2, lambda t: 0.0, 600.0, tick=1.0)
finally:
rig.Controller = app_main.Controller
r2 = rig.run(2, lambda t: 0.0, 600.0, tick=1.0,
store={unit_key("inverter-b", "sign_tripped"): True})
check("not learned while control is stopped, nor on a unit whose sign "
"watchdog has latched (600 s at 0 W); its sibling learns",
all(i.idle.value is None for i in r["c"].inverters)
and r2["c"].inverters[1].idle.value is None
and r2["c"].inverters[0].idle.value is not None)
# A battery capped at 150 W under a big ask is not idle.
x = idle.IdleReading()
for t in range(0, 3600):
x.observe(150.0, 2000.0, float(t), True)
check("a reading under a command over 50 W is never a sample (a capped "
"battery is not idle)", x.value is None and x.w == 0.0)
x = idle.IdleReading()
for t in range(0, 239):
x.observe(-100.0, 10.0, float(t), True)
first = x.value
x.observe(-100.0, 10.0, 239.0, True)
x.observe(-100.0, 10.0, 240.0, True)
check("the first value needs 120 s asked ~0 and then 120 s of samples",
first is None and abs(x.value + 100.0) < 1e-6)
y = idle.IdleReading()
for t in range(0, 400):
y.observe(-100.0, 60.0 if t % 100 == 0 else 0.0, float(t), True)
check("a command over 50 W restarts the quiet time (a 60 W blip every "
"100 s: never learned)", y.value is None)
z = idle.IdleReading()
for t in range(0, 400):
z.observe(-100.0, 0.0, float(t), False)
check("nothing is learned while learning is not allowed (refused, stale, "
"tripped or stopped)", z.value is None)
for t in range(240, 300):
x.observe(-130.0, 0.0, float(t), True)
after_min = x.value
for t in range(300, 240 + 3600):
x.observe(-130.0, 0.0, float(t), True)
print(f" drift -100 -> -130 W: {after_min:.1f} after 60 s, {x.value:.1f} "
f"after an hour")
check("drift is followed slowly: under 2 W in the first minute, most of it "
"in an hour", -102.0 < after_min < -100.0 and -130 < x.value < -124)
o = idle.IdleReading()
for t in range(0, 600):
o.observe(-300.0, 0.0, float(t), True)
p = idle.IdleReading()
for t in range(0, 600):
p.observe(2000.0 if t < 300 else -240.0, 0.0, float(t), True)
check("a reading beyond 250 W is not a sample (-300 W: never learned; a "
"register held at 2000 W then -240 W: -240 W)",
o.value is None and p.value is not None and abs(p.value + 240.0) < 1e-6)
check("a saved value is clamped to 250 W, the band's centre to 75 W, and "
"garbage is ignored",
idle.IdleReading(900).value == idle.IDLE_SAMPLE_W
and idle.IdleReading(900).w == idle.IDLE_MAX_W
and idle.IdleReading(-900.0).w == -idle.IDLE_MAX_W
and idle.IdleReading(-60.0).w == -60.0
and idle.IdleReading("x").value is None
and idle.IdleReading(float("nan")).value is None)
# ============================================================================ #
print("\n3b. a wrong idle reading (owner, 2026-10-04: centre clamped to 75 W)")
def wrong_idle(n, unit, err, restore):
"""Seconds from the cut to the refusal when the idle reading is `err` W
off: taught wrong during the rest, or restored wrong."""
cu = "a" if n == 1 else unit
true = -100.0 if unit == "b" else 0.0
if restore:
return caught(n, unit, 1.0, store={
unit_key(f"inverter-{cu}", "idle_w"): true + err})[0]
class _W(rig.Plant):
def advance(self, t):
rig.OFFSET[cu] = true + (err if t < REST else 0.0)
super().advance(t)
saved, real = dict(rig.OFFSET), rig.Plant
rig.Plant = _W
try:
return caught(n, unit, 1.0)[0]
finally:
rig.Plant = real
rig.OFFSET.clear()
rig.OFFSET.update(saved)
A_WRONG = {(n, e, rs): wrong_idle(n, "a", e, rs) for n in (1, 2)
for e, rs in ((-250.0, False), (-150.0, False), (150.0, False),
(250.0, False), (-250.0, True), (200.0, True),
(250.0, True))}
print(f" A (reads about 0 W) refusing, idle off by E: {A_WRONG}")
check("⭐ a battery reading about 0 W while refusing is caught whatever idle "
"reading was learned or restored (A, E -250..+250 W, N=1 and N=2)",
all(v is not None and v <= 32.0 for v in A_WRONG.values()))
check("⭐ ...including a wrong saved idle_w of +200 W on A",
all(A_WRONG[(n, 200.0, True)] is not None for n in (1, 2)))
B_WRONG = {(n, e): wrong_idle(n, "b", e, False) for n in (1, 2)
for e in (-150.0, -50.0, 90.0)}
print(f" B refusing, idle taught wrong by E: {B_WRONG}")
check("B is caught across -150..+90 W of error in its idle reading",
all(v is not None and v <= 32.0 for v in B_WRONG.values()))
# ============================================================================ #
print("\n4. the rules at their edges")
class _Inv:
def __init__(self, idle_w):
self.batt, self.ref_count, self.ref_since, self.ref_sign = 0.0, 0, None, 0
self.ref_from, self.idle_w = None, idle_w
def standing_w(self):
return 800.0
class _C:
_now_mono = 0.0
def evidence(readings, idle_w):
c, inv = _C(), _Inv(idle_w)
got = []
for i, b in enumerate(readings):
c._now_mono, inv.batt = float(i), b
got.append(app_main.Controller._gave_nothing(c, inv))
return got
flat_b = evidence([-104.0 + (i % 3) for i in range(30)], -100.0)
flat_b0 = evidence([-104.0 + (i % 3) for i in range(30)], 0.0)
gives = evidence([0.0] * 30, -100.0)
takes = evidence([-200.0] * 30, -100.0)
check("the band is centred on the idle reading: B flat at -104 W is evidence "
"at 22 s with idle -100 W and never with idle 0 W; reading 0 W (giving "
"100 W) or -200 W (taking 100 W) is not",
1 in flat_b and flat_b.index(1) == 22 and not any(flat_b0)
and not any(gives) and not any(takes))
T = control.Tuning(max_w=4000.0, slew_w=400.0)
args = dict(prev_w=1200.0, grid_w=1200.0, tuning=T,
sat_count=control.SETTLE_CYCLES, i_w=1200.0, refused=0,
gave_nothing=True)
check("compute(): the N=1 engage is centred on idle_w (-104 W refuses with "
"idle -100, not with 0; -4 W refuses with idle 0, not with -150)",
control.compute(actual_w=-104.0, idle_w=-100.0, **args).refused == 1
and control.compute(actual_w=-104.0, **args).refused == 0
and control.compute(actual_w=-4.0, **args).refused == 1
and control.compute(actual_w=-4.0, idle_w=-150.0, **args).refused == 0)
args.update(refused=-1, i_w=-1200.0, prev_w=0.0, grid_w=-1200.0)
check("compute(): a charge refusal ends when the battery takes 100 W more "
"than its idle reading, not at its idle reading",
control.compute(actual_w=-110.0, idle_w=-100.0, **args).refused == -1
and control.compute(actual_w=-205.0, idle_w=-100.0, **args).refused
== 0
and control.compute(actual_w=-110.0, **args).refused == 0)
check("the band's centre stays within 75 W of 0 W, so the band keeps 0 W "
"+-25 W; samples to 250 W",
idle.IDLE_MAX_W == 75.0 and idle.IDLE_SAMPLE_W == 250.0
and control.REFUSED_BELOW_W - idle.IDLE_MAX_W >= 25.0
and idle.IDLE_ASK_W < control.PROBE_W)
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all checks passed")
+4 -1
View File
@@ -1631,7 +1631,10 @@ def _sign_site(hass, store=None, **over):
_P(), stop=asyncio.Event())
b = Inverter("inverter-b", setpoint_entity="number.sp_b",
soc_entity="sensor.soc_b", batt_entity="sensor.batt_b",
max_w=B_RATING_HYPOTHESIS, store=c.store)
max_w=B_RATING_HYPOTHESIS, store=c.store,
# SAFETY-91: a cut-out, as rule 8 requires; without one B is
# held back at its reserve and gets no share to reverse.
bms_soc_floor=c.inverters[0].bms_soc_floor)
b.dev_min, b.dev_max = -B_RATING_HYPOTHESIS, B_RATING_HYPOTHESIS
c.inverters.append(b)
c.auto = True
+271
View File
@@ -0,0 +1,271 @@
#!/usr/bin/env python3
"""SAFETY-94: the REAL Controller on a lagging battery whose reading is late.
python3 tools/safety94_rig.py # prints every scenario's counts
Each battery ramps at 800 W/s, then follows a first-order lag (`tau`, 10 s
as on the SAFETY-85 rig). Its reading (register 0x0518) is published every
10 s, at a different phase per unit, and repeats its value between publishes,
as the live boards do. B reads 100 W below what it gives (its standby draw:
-100 W at idle, live 2026-10-03). The meter updates every `tick` s; the add-on
reads every 1 s and cycles on a new meter value, as `run_control()` does.
A frozen register stops publishing at `freeze_at`; a refusing battery (`cut`)
gives 0 W and reads -4 W plus its offset (B: -104 W), with `noise` W of
seeded jitter (SAFETY-97). Uses only the Controller API release/1.0 02ed120
has, so the same file runs on the tree before SAFETY-94.
"""
import asyncio
import math
import os
import random
import sys
from datetime import datetime, timedelta, timezone
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import app.main as m # noqa: E402
from app.main import Controller # noqa: E402
BASE = datetime(2026, 10, 3, 10, 50, tzinfo=timezone.utc) # drain from 600 s
PUBLISH_S = 10.0
PHASE = {"a": 6.0, "b": 1.6} # live: A publishes at :x6, B at :x1.6
OFFSET = {"a": 0.0, "b": -100.0} # B's standby draw, live 2026-10-03
CUT_W = -4.0
LIVE = {"gain": 0.6, "slew_w": 400, "target_grid_w": -10, "deadband_w": 15,
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15,
"auto_start": True, "meter_entity": "sensor.grid",
"meter_source": "off"}
MAINT = {"maintenance_enabled": True, "maintenance_start_hour": 11,
"maintenance_discharge_w": 2500, "maintenance_hold_min": 30}
class _Store:
def __init__(self, data=None):
from app.store import DEFAULTS
self.data = dict(DEFAULTS, **(data or {}))
def set(self, k, v):
self.data[k] = v
def get_time(self, k):
v = self.data.get(k)
return datetime.fromisoformat(v) if v else None
def set_time(self, k, when):
self.data[k] = when.isoformat() if when else None
class _Pub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
class Plant:
"""The batteries, their registers and the meter, in simulated seconds."""
def __init__(self, units, load, tau, tick, soc, cut, freeze_at,
publish_s=PUBLISH_S, dither=False, noise=0.0, seed=0):
self.units, self.load, self.tau, self.tick = units, load, tau, tick
self.noise, self.rng = noise, random.Random(seed)
self.publish_s, self.dither, self.n_pub = publish_s, dither, 0
self.soc_fn, self.cut, self.freeze_at = soc, cut, freeze_at
self.cmd = {u: 0.0 for u in units}
self.ramp = {u: 0.0 for u in units}
self.real = {u: 0.0 for u in units}
self.reg = {u: round(OFFSET[u]) for u in units}
self.grid_v = float(load(0.0))
self.t = 0.0
def advance(self, t):
"""Integrate to `t` in 0.1 s steps; publish and sample on schedule."""
while self.t < t - 1e-9:
self.t = round(self.t + 0.1, 6)
for u in self.units:
want = 0.0 if self.cut(u, self.t) else self.cmd[u]
d = want - self.ramp[u]
self.ramp[u] += math.copysign(min(abs(d), 80.0), d)
self.real[u] += ((self.ramp[u] - self.real[u])
* (1 - math.exp(-0.1 / self.tau)))
frozen = (self.freeze_at is not None
and self.freeze_at(u) is not None
and self.t >= self.freeze_at(u))
ph = (self.t - PHASE[u] % self.publish_s) / self.publish_s
if not frozen and abs(ph - round(ph)) < 1e-6:
# `dither`: the +-1 W a settled register shows (live).
self.n_pub += 1
jit = (self.n_pub * 7 % 3 - 1) if self.dither else 0
self.reg[u] = (
float(round(CUT_W + OFFSET[u] + self.rng.uniform(
-self.noise, self.noise)))
if self.cut(u, self.t)
else float(round(self.real[u] + OFFSET[u]) + jit))
if abs(self.t / self.tick - round(self.t / self.tick)) < 1e-6:
self.grid_v = float(round(self.load(self.t)
- sum(self.real.values())))
class _Hass:
def __init__(self, plant):
self.p = plant
async def limits(self, e):
return (-5000.0, 5000.0, 1.0)
async def call(self, *a):
return True
async def number(self, e, invert=False):
if e == "sensor.grid":
return self.p.grid_v
u = e[-1]
if e.startswith("sensor.soc"):
return self.p.soc_fn(u, self.p.t)
if e.startswith("sensor.batt"):
return self.p.reg[u]
return None
async def set_number(self, e, v):
self.p.cmd[e[-1]] = float(v)
return True
def options(n, extra=None):
o = dict(LIVE)
if n == 1:
o.update({"setpoint_entity": "number.sp_a",
"soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a",
"max_w": 4000, "bms_soc_floor": 10})
else:
o["inverters"] = [
{"name": f"inverter-{u}", "setpoint_entity": f"number.sp_{u}",
"soc_entity": f"sensor.soc_{u}", "batt_entity": f"sensor.batt_{u}",
"max_w": 2500, "bms_soc_floor": 10} for u in "ab"[:n]]
o.update(extra or {})
return o
def run(n, load, dur, *, tau=10.0, tick=1.0, soc=None, cut=None,
freeze_at=None, extra=None, floor_s=15.0, publish_s=PUBLISH_S,
dither=False, noise=0.0, seed=0, store=None):
"""Returns a dict: per-unit stale trips [(t, unit)], refusals
[(t, unit or 'site')], rows [(t, grid, {u: cmd}, {u: real}, {u: reg},
phases)] and the controller. `store`: saved state to start from."""
units = tuple("ab"[:n])
soc = soc or (lambda u, t: 50.0)
cut = cut or (lambda u, t: False)
plant = Plant(units, load, tau, tick, soc, cut, freeze_at, publish_s,
dither, noise, seed)
async def go():
c = Controller(options(n, extra), _Hass(plant), _Store(store), _Pub(),
stop=asyncio.Event())
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
real_dt = m.datetime
clock = [0.0]
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return BASE + timedelta(seconds=clock[0])
m.datetime = _DT
out = {"stale": [], "refused": [], "rows": [], "c": c}
was = {i.name: False for i in c.inverters}
was_ref = {i.name: 0 for i in c.inverters}
was_site = 0
last_grid, last_cycle = None, None
try:
t = 0.0
while t < dur:
plant.advance(t)
clock[0] = t
await c.read_inputs(now_mono=t)
for i in c.inverters:
if i.batt_fresh.stale and not was[i.name]:
out["stale"].append((t, i.name))
was[i.name] = i.batt_fresh.stale
cycled = (c.grid != last_grid or last_cycle is None
or t - last_cycle >= floor_s)
if cycled:
last_grid, last_cycle = c.grid, t
await c.cycle()
# A refusal can start on a read (a gate trip) or in a cycle.
for i in c.inverters:
if i.refused and not was_ref[i.name]:
out["refused"].append((t, i.name))
was_ref[i.name] = i.refused
if c.batt_refused and not was_site:
out["refused"].append((t, "site"))
was_site = c.batt_refused
if cycled:
out["rows"].append((
t, plant.grid_v, dict(plant.cmd),
{u: round(v) for u, v in plant.real.items()},
dict(plant.reg), [mm.phase for mm in c.maints]))
t += 1.0
finally:
m.datetime = real_dt
return out
return asyncio.run(go())
# --- the scenarios ---------------------------------------------------------
def rest_then(step_at, w):
"""A house at 0 W (the batteries rest) until `step_at`, then `w`."""
return lambda t: w if t >= step_at else 0.0
def reversal(at_s, charge_w, house_w):
"""Solar surplus (the batteries charge), then a house load from `at_s`."""
return lambda t: house_w if t >= at_s else charge_w
def drain_soc(floor_at):
"""A drains (maintenance) and reaches its floor at `floor_at`; B at 50 %."""
def soc(u, t):
if u != "a":
return 50.0
return 50.0 if t < floor_at else 10.0
return soc
SCENARIOS = {
# first command after a long rest (the live 17:39 shape)
"rest_step": dict(load=rest_then(600.0, 2400.0), dur=900.0),
# maintenance drain starts after a rest (the live 19:00 shape)
"drain_start": dict(load=lambda t: 300.0, dur=900.0,
extra=MAINT, soc=drain_soc(1e9)),
# A's drain ends and its charge starts (the live 21:21 shape)
"drain_charge": dict(load=lambda t: 300.0, dur=1200.0, extra=MAINT,
soc=drain_soc(900.0)),
# a charge reverses into a discharge through the lag (SAFETY-94)
"reversal": dict(load=reversal(300.0, -2500.0, 800.0), dur=600.0),
}
def counts(n, name, **kw):
sc = dict(SCENARIOS[name], **kw)
r = run(n, **sc)
return len(r["stale"]), len(r["refused"]), r
if __name__ == "__main__":
for n in (1, 2):
for name in SCENARIOS:
for tick in (1.0, 2.0, 5.0):
for pub, dith in ((10.0, False), (1.0, True)):
s, f, r = counts(n, name, tick=tick, publish_s=pub,
dither=dith)
print(f"N={n} {name:13s} tick {tick:.0f} s publish "
f"{pub:2.0f} s{' dither' if dith else ' '}: "
f"stale {s} {r['stale'][:2]}, refusals {f} "
f"{r['refused'][:2]}")
@@ -749,6 +749,24 @@ show("(d) GLOBAL `pv_invert: bool` validates too - it loses on SEMANTICS (one "
"sign for a mixed roster), not on the schema", ok_d, "" if ok_d else out_d)
print("\n== I. MAINT-10: `maintenance_start_hour` now defaults to 19 ==")
_mk = "maintenance_start_hour"
ok_i, out_i = validate(defaults)
show("the shipped `options:` block validates and carries 19",
ok_i and out_i.get(_mk) == 19, "" if ok_i else out_i)
# What the Supervisor hands the add-on is deepmerge(options:, stored).
_stored10 = dict(defaults, **{_mk: 10})
ok_i2, out_i2 = validate(MERGER.merge(copy.deepcopy(defaults), _stored10))
show("a stored explicit 10 (what a blob saved before MAINT-10 holds) stays 10",
ok_i2 and out_i2.get(_mk) == 10, "" if ok_i2 else out_i2)
_absent = {k: v for k, v in defaults.items() if k != _mk}
ok_i3, out_i3 = validate(MERGER.merge(copy.deepcopy(defaults), _absent))
show("a stored blob WITHOUT the key gets the new default, 19",
ok_i3 and out_i3.get(_mk) == 19, "" if ok_i3 else out_i3)
ok_i4, out_i4 = validate(dict(defaults, **{_mk: 24}))
show("the range is unchanged: 24 is refused (`at most 23`)",
(not ok_i4) and "at most 23" in str(out_i4), out_i4)
# ⚠️ THE EXIT CODE IS PART OF THE INSTRUMENT. Without it the probe prints FAIL
# and still succeeds, and a transcript is only evidence if a wrong answer is
# distinguishable from a right one by something other than reading it.
+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"
description: "The local hour, 0 to 23, at which a due cycle starts."
name: "Maintenance · Start hour (local time)"
description: "The hour on your local clock, 0 to 23, when a due cycle starts. 19 means 19:00."
maintenance_discharge_w:
name: "Maintenance · Draw-down rate (W)"
description: "How fast the cycle empties the battery. Above “Inverter rating” it is cut back to the rating, and the cycle just takes longer."
+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"
description: "Het lokale uur, 0 tot 23, waarop een geplande cyclus begint."
name: "Onderhoud · Startuur (lokale tijd)"
description: "Het uur op je lokale klok, 0 tot 23, waarop een geplande cyclus begint. 19 is 19:00."
maintenance_discharge_w:
name: "Onderhoud · Ontlaadvermogen (W)"
description: "Hoe snel de cyclus de batterij leegmaakt. Boven het vermogen van de omvormer wordt het daartoe beperkt, en duurt de cyclus gewoon langer."