Compare commits

...
Author SHA1 Message Date
admin df154a26d1 Deploy 0.16.0 to master (#179) 2026-10-08 01:43:37 +02:00
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 21599938ea Merge pull request 'Deploy 0.15.0 to master' (#174) from deploy-0.15.0 into master
Deploy 0.15.0 to master (#174)
2026-10-05 10:17:38 +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 3a54b253cb Merge pull request 'Deploy 0.14.0 to master' (#170) from deploy-0.14.0 into master
Deploy 0.14.0 to master (#170)
2026-10-03 12:32:16 +02:00
admin 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
admin 8b9680fc35 Merge pull request 'Deploy 0.13.0 to master' (#166) from deploy-0.13.0 into master
Reviewed-on: #166
2026-10-02 15:26:46 +02:00
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
admin 8c438ab2e6 Release 0.13.0 (#164)
infra-pin / fresh (push) Successful in 2s
checks / unit (pull_request) Successful in 25m53s
infra-pin / fresh (pull_request) Successful in 1s
2026-10-02 10:04:13 +02:00
adminandClaude Opus 5.5 b37b7f95dc REL-0.13.0 review: plainer, truer 0.13.0 changelog
checks / unit (pull_request) Successful in 25m52s
infra-pin / fresh (pull_request) Successful in 1s
Crash, not stop, for SAFETY-61; Watchdog off by default; its own
warning for the 50 W peak margin (also under a higher month peak);
"refuses power" for SAFETY-52/73; no one-house options claim.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:37:34 +02:00
adminandClaude Opus 5.5 28cab03947 REL-0.13.0: cut 0.13.0
checks / unit (pull_request) Successful in 25m55s
infra-pin / fresh (pull_request) Successful in 2s
Bump config.yaml version 0.12.0 -> 0.13.0 and close a ## 0.13.0
CHANGELOG section covering every merge since 0.12.0 (a45ed06), with the
known issues SAFETY-90/91/92/93/94. Option surface 77 -> 83, none removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:04:47 +02:00
admin 61072ca460 Merge pull request 'SAFETY-85: no false 'not keeping up' while maintenance or the peak guard sets the battery' (#163) from safety-85-no-false-not-keeping-up into release/1.0
infra-pin / fresh (push) Successful in 1s
SAFETY-85: no false 'not keeping up' while maintenance or the peak guard sets the battery (#163)
2026-10-01 20:52:17 +02:00
adminandClaude Opus 5.5 7b723a6cd5 SAFETY-85 review: grade discharge only, over the plant's settle time
checks / unit (pull_request) Successful in 25m47s
infra-pin / fresh (pull_request) Successful in 2s
Rule 2 graded each battery against last cycle's command and graded charges
too. On plantsim's battery (10 s lag) at the 2 s meter it warned at every
drain or charge start and 15 times an hour under the peak guard at N=2; a
charge tapering near full warned 11 times in 50 min (SAFETY-42 section 7).
Now a discharge is graded against the least it stood at over SHORT_CYCLES /
SHORT_SPAN_S. DOCS lists `mode-bound` under Loop reason. test_safety85 part 7:
peak guard with a dead battery, lagging plant, taper; part 3: the bound ends
with maintenance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:25:41 +02:00
glenn schrooyenandClaude Opus 5.5 4b81387908 SAFETY-85: no false 'not keeping up' while maintenance or the peak guard sets the battery
checks / unit (pull_request) Successful in 25m35s
infra-pin / fresh (pull_request) Successful in 2s
The loop's integrator bound counts only on a cycle the loop decided. A battery
another owner set (maintenance, the peak guard, a safety limit) alarms only
when it gives more than saturation_w less than told for saturation_cycles
cycles. At N>=2 the loop is bounded while maintenance holds a battery, so it
does not wind and control comes back one slew step a cycle. The status row
counts episodes, not cycles. Digests re-pinned where behaviour changed on
purpose; DECISIONS/SAFETY-85-no-false-not-keeping-up.md has the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
2026-10-01 19:05:27 +02:00
admin 935878193b Merge pull request 'SAFETY-87: peak guard uses every battery that can still give' (#162) from safety-87-guard-uses-able-batteries into release/1.0
infra-pin / fresh (push) Successful in 2s
SAFETY-87: peak guard uses every battery that can still give (#162)
2026-10-01 12:06:41 +02:00
Claude b4d25dd485 SAFETY-87 review: DOCS, the drain rate may rise for the peak at N>=2
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Successful in 24m53s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
2026-10-01 11:08:42 +02:00
Claude a4a04d203c SAFETY-87 review: grade the three surviving review mutants
checks / unit (pull_request) Successful in 25m5s
infra-pin / fresh (pull_request) Successful in 1s
A battery refusing only to charge stays able; the reserve alarm is graded
on the real limit, not the aim; 'control still runs' is set on the two
peak rows only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
2026-10-01 11:07:43 +02:00
Claude aa501ffec7 SAFETY-87 review: maintenance pins beside a held battery
checks / unit (pull_request) Successful in 26m5s
infra-pin / fresh (pull_request) Successful in 1s
A draining battery that may still give is raised from its drain rate
towards its rating for the peak floor while another battery is held
(peak > drain); before, the floor's rest landed on the held battery. In a
HOLD, a held battery's own pin counts among the others: a held drain no
longer over-places the site, and a held charge probe no longer parks it.

Tests: test_safety87.py part 10 (drain beside a held battery, N=3 HOLD,
unknown cut-out and unread level at N=3, a stopping row outranks the
running peak alarm). DECISIONS entry updated, incl. the modes-on exception.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
2026-10-01 10:50:01 +02:00
Claude d89c093318 SAFETY-87: grade the surviving mutants (dropped battery, own-floor naming, pair permission, latched row)
checks / unit (pull_request) Successful in 24m56s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
2026-10-01 09:55:29 +02:00
Claude d0db5b8c39 SAFETY-87: peak guard uses every battery that can still give
checks / unit (pull_request) Successful in 25m45s
infra-pin / fresh (pull_request) Successful in 1s
At N>=2 a battery at or under its own peak reserve is held: it gives
nothing for the peak (weight 0 while the site gives) and the guard's need
is placed on the live, non-refusing batteries above theirs. The guard is
floor-limited, and alarms, only when those cannot hold the limit at full
power for ALARM_HOLD_S. The peak alarms no longer read "Not ready" or hold
the Home page at 0 W, because control keeps running. N=1 is byte-identical.

DECISIONS/SAFETY-87-guard-uses-able-batteries.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
2026-10-01 09:29:30 +02:00
admin b8ddfd787f Merge pull request 'SAFETY-86: peak guard outranks the maintenance hold' (#161) from safety-86-peak-over-hold into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-10-01 02:27:36 +02:00
glenn schrooyenandClaude Opus 5.5 1c41a1e412 SAFETY-86 review: hold the site at the peak floor while a HOLD gives
checks / unit (pull_request) Successful in 24m23s
infra-pin / fresh (pull_request) Successful in 1s
At N>=2 the holding pack's pin follows the guard's floor while the loop
still asks for +5000 W, so the ALLOCATOR "could not be placed" row was
re-logged on every 1 W change and filled the 40-row action log. While the
pin is raised, cycle() now files a site `hold` ceiling at the floor
(P_MAINTENANCE), as SAFETY-84 does for a charge. Filed only when the
holding pack gives; hi == the floor, so it never clashes with it.

Tests: a noisy N=2 house logs no ALLOCATOR row and at most one clamp
row (20 and 20 without the ceiling); the ceiling is the floor itself;
a CHARGE is not refiled at N=1. The DECISIONS entry records the owner's
2026-10-01 decision: the HOLD ends on its clock, no top-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 02:00:20 +02:00
glenn schrooyenandClaude Opus 5.5 ba31e7c8b1 SAFETY-86: peak guard outranks the maintenance hold
checks / unit (pull_request) Successful in 24m17s
infra-pin / fresh (pull_request) Successful in 1s
During a HOLD the holding battery gives what keeps the quarter at the peak
aim. N=1: the peak floor is refiled at P_MAINTENANCE, as for a drain
(SAFETY-83). N>=2: the hold's pin is raised to the floor minus what the
other live batteries may place, so they give first. A site stop still
decides alone. The hold ends on its clock even if the level dipped.
Below the aim nothing changes (rows identical).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 00:56:34 +02:00
glenn schrooyenandClaude Opus 5.5 513183aba3 test_peak_margin: read the month peak on the runs' clock, not today's
Part 3 read the simulated September's peak with the wall clock, so from
2026-10-01 it read no peak and crashed on every tree (4ad8298 too).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 00:56:34 +02:00
admin 4ad8298ead Merge pull request 'SAFETY-84: maintenance charge at N=2 no longer stalls' (#160) from safety-84-n2-maint-charge into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-30 21:00:56 +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
admin 3ce18708b6 PKG-18: ship 0.12.0 to master, so TEL-12's pv_entities can reach an installed 0.11.0 (#118) 2026-09-24 23:22:34 +02:00
adminandClaude Opus 5 78acb558ff PKG-17: ship 0.11.0 to master, so SAFETY-56's wording fix and TEL-11's pv_entity can reach an installed 0.10.1 (#115)
master is what the Home Assistant Supervisor clones, and the Supervisor
compares config.yaml version strings - so 0.11.0 could not be offered to an
installed 0.10.1 without this bump reaching master.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-22 15:54:29 +02:00
61 changed files with 8468 additions and 280 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
+207
View File
@@ -1,5 +1,212 @@
# Changelog
## 0.16.0
**A minor bump: changed behaviour, no option added or removed, and one default
and its meaning changed.** There are still 83 options. `maintenance_start_hour`
used to be read as UTC and its default was 10. It is now a local hour (in Home
Assistant's time zone) and its default is 19. The other changes below apply
with one inverter or two, unless a line says "Two inverters".
**Before you update (MAINT-10).** A saved `maintenance_start_hour` is kept, and
it now means local time. If you set it in UTC to get an evening start (for
example 17 for 19:00 in summer), set it back to the local hour (19). A saved
10 now starts at 10:00, not 12:00 in summer. The new default 19 only reaches
installs that have no hour saved.
Contents of this release, in merge order since 0.15.0 (`3ed3302`):
| | |
|---|---|
| `7ef7347` | After a gap in the P1 meter, the peak guard counts the missed time as high use and keeps the 15-minute period near the limit; before, it was blind until the next period. In the test, a 5 min meter silence at one inverter ended the period at 3.3 kW, against 4.8 kW on 0.15.0 ([[SAFETY-90]]) |
| `1bf6b7f` | The maintenance start hour is now a local hour, and its default is 19 (was 10, read as UTC). The log says which time zone it used; with two inverters, both use that hour and still cycle one at a time ([[MAINT-10]]) |
| `b2f5573` | Two inverters: when one battery's link drops, the other takes over in small steps, and control no longer stalls. The battery that comes back rejoins within 2 s, without a false "battery refuses" ([[SAFETY-96]]) |
| `340c7ee` | Two inverters: when a drain ends (done, switched Off, timeout, lost link), the other battery covers the house right away, without jumping to 1.6 kW, and then moves in small steps. It no longer gives into a solar surplus ([[SAFETY-95]]) |
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
- A battery whose reading at rest is beyond about ±170 W is still not caught when it refuses, as in 0.14.0 ([[SAFETY-97]]).
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]).
- A charge to 100 % can time out at 98 %. The add-on then counts the cycle as failed and drains the battery again the next day ([[MAINT-13]]).
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
- Two inverters: after a drain ends, the other battery can swing by about 800 W for about 5 s, because some readings are old ([[SAFETY-103]]).
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
## 0.15.0
**A minor bump: changed behaviour, and no option changes.** No option was
added, removed or renamed, and no default changed (83 options). Options that
start 0.14.0 also start 0.15.0. The changes below apply with one inverter and
with two.
The add-on now learns and saves, per battery, what its power reading shows
at rest (`unit/<name>/idle_w`). It learns it after about two quiet minutes and
uses 0 W until then, as before. There is nothing to set.
Contents of this release, in merge order since 0.14.0 (`02ed120`):
| | |
|---|---|
| `cf705a9` | No more false "battery power reading stopped changing" errors at a new command after a rest, or at a change of phase. Each one paused control for a few seconds (several a day on a live site). A healthy battery that lags or turns around is no longer held as refusing for up to 15 min. With two inverters, a refusing battery whose reading at rest is about −100 W is now caught too. The rest value used is limited to ±75 W ([[SAFETY-94]], [[SAFETY-97]], also fixes [[SAFETY-58]]) |
| `3129dae` | CI tests against a newer `admin/goodwe` (CI only) ([[CI-14]]) |
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- After a gap in the P1 meter, the peak guard is blind for the rest of that quarter hour ([[SAFETY-90]]).
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
- A battery whose reading at rest is beyond about ±170 W is still not caught when it refuses, as in 0.14.0 ([[SAFETY-97]]).
- Two inverters: if one battery's drain stops early (e.g. switched Off), the other battery can jump to about 1.6 kW at once, and the surplus goes to the grid ([[SAFETY-95]]).
- Two inverters: after one battery's link drops, control can stall until a safety stop ([[SAFETY-96]]).
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
## 0.14.0
**A minor bump: new behaviour with two inverters, and no option changes.**
No option was added, removed or renamed, and no default changed (83
options). Options that start 0.13.0 also start 0.14.0. With one inverter,
control is the same as in 0.13.0.
⭐ **Two inverters are now supported** for the monthly maintenance (drain,
charge, hold) and the peak guard. To add a second inverter, follow
`docs/commissioning-b.md` in the `admin/goodwe` repo. Its Stage 5, where the
add-on takes control of it, needs 0.14.0. Read the known issues below first:
three of them are about two inverters.
Contents of this release, in merge order since 0.13.0 (`8c438ab`):
| | |
|---|---|
| `f3e409c` | Two inverters: a battery at or under its peak reserve gives last, after the others give their full power. It is freed again above reserve + 1 point (e.g. above 21 % for a 20 % reserve). At a quarter's edge the peak guard no longer drops out for a second ([[SAFETY-91]]) |
| `858f77c` | Two inverters: when a maintenance charge ends, for any reason, the other battery no longer starts charging from the grid; control hands over in small steps ([[SAFETY-93]]) |
| `e51e3e0` | CI tests against a newer `admin/goodwe` (CI only) ([[CI-13]]) |
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- A healthy battery that turns from charging to giving can be held at 0 W for up to 15 min ([[SAFETY-94]]).
- After a gap in the P1 meter, the peak guard is blind for the rest of that quarter hour ([[SAFETY-90]]).
- Two inverters: if one battery's drain stops early (e.g. switched Off), the other battery can jump to about 1.6 kW at once, and the surplus goes to the grid ([[SAFETY-95]]).
- Two inverters: after one battery's link drops, control can stall until a safety stop ([[SAFETY-96]]).
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
## 0.13.0
**A minor bump: new behaviour, a new add-on page (Home, Settings, Batteries,
Setup), and six new options.** No option was removed or renamed, and no
default of an existing option changed (77 options -> 83). The only new
start-up checks are on the new options, so options that start 0.12.0 also
start 0.13.0.
New options, and what they do if you leave them alone:
| option | default | left alone |
|---|---|---|
| `peak_margin_w` | `50` | the peak guard aims 50 W below your peak limit, or below a higher peak already reached this month; `0` is the old aim ([[SAFETY-79]]) |
| `battery_modes_enabled` | `false` | no battery modes, control as before ([[OPT-11]]) |
| `pv_forecast_entity`, `pv_forecast_tomorrow_entity` | empty | no solar forecast is used ([[OPT-11]]) |
| `solar_trust_pct` | `70` | only used with a solar forecast ([[OPT-11]]) |
| `pv_invert_entities` | `[]` | PV readings as before ([[TEL-14]]) |
⚠️ **Turn on Watchdog** on the add-on's Info tab. It is off by default. If
the control loop crashes, the add-on now sets the batteries to 0 W and stops.
Home Assistant only starts it again when Watchdog is on ([[SAFETY-61]],
[[DOC-5]]).
⚠️ **The peak guard now starts a little earlier.** It aims 50 W below your
peak limit, so the batteries give a bit more at peak times. Set
`peak_margin_w: 0` for the old aim ([[SAFETY-79]]).
Contents of this release, in merge order since 0.12.0 (`a45ed06`):
| | |
|---|---|
| `fa3d237` | Docs name the right P1 age entity and a battery list that starts ([[DOC-1]]) |
| `6e65272` | If the control loop crashes, the batteries go to 0 W and the add-on stops ([[SAFETY-61]]) |
| `3e38baf` | The check tools refuse a file they cannot read, instead of saying all clear (tools only) ([[SAFETY-60]], [[SAFETY-62]]) |
| `43b2740` | A missing value in a check is a named failure (tests only) ([[TEST-26]]) |
| `20260e8` | Docs: turn on Watchdog ([[DOC-5]]) |
| `16c796f` | New option `pv_invert_entities`: flip the sign of a PV sensor that reads backwards ([[TEL-14]]) |
| `6a8a5ff` | CI tests against the real inverter simulator (CI only) ([[CI-12]]) |
| `82f032e` | CI pin checks report failures properly (CI only) ([[TOOL-9]]) |
| `fe13251` | CI pin update (CI only) |
| `397dd15` | Alarm notifications that are still active come back after a Home Assistant restart ([[SAFETY-34]]) |
| `59415ea` | CI pin update (CI only) |
| `2503de1` | "Battery is not keeping up" is a warning with its own notification, not a "bug" alarm; its figures are correct ([[SAFETY-40]]) |
| `48ef06a` | Shorter, plainer notifications ([[UX-08]]) |
| `5c151a3` | CI pin update (CI only) |
| `d57767f` | Shorter, plainer status rows and banners ([[UX-08]]) |
| `e1d0970` | Details link works on current Home Assistant; plainer words ([[UX-09]]) |
| `a02cd4b` | Shorter refusals, errors and option texts ([[UX-08]]) |
| `32b9e56` | Plainer log lines and docs ([[UX-08]]) |
| `125b070` | CI pin update (CI only) |
| `2b14b88` | Last plain-language fixes in logs and the status page ([[UX-10]]) |
| `c4032eb` | CI pin update (CI only) |
| `d1f7e33` | Safety log lines: one plain sentence and a code ([[UX-11]]) |
| `652097a` | A battery that refuses power (e.g. empty) is asked for 0 W, and tried at 200 W for 34 s every 15 min ([[SAFETY-52]]) |
| `afd6af0` | Groundwork for the new settings pages ([[UX-13]]) |
| `a36dca6` | New page with a Home screen ([[UX-13]]) |
| `28a49cc` | With two or more inverters, a battery that refuses power is held on its own; the others take over ([[SAFETY-73]]) |
| `515c11b` | New Installer settings view ([[UX-13]]) |
| `4de9675` | New setup wizard for installers ([[UX-13]]) |
| `8c34647` | A new install with no inverter opens the setup wizard instead of stopping ([[UX-15]]) |
| `a5dddad` | Dutch text and a time-period editor ([[UX-13]]) |
| `ed739d4` | Battery modes (off by default): per-battery limits, on/off, solar order ([[OPT-11]]) |
| `7995e4d` | Simple settings view and a Batteries screen ([[UX-13]]) |
| `7337b79` | Battery modes in Home Assistant, when modes are on ([[OPT-11]]) |
| `7a00f12` | New option `peak_margin_w`: the peak guard keeps a margin below the limit ([[SAFETY-79]]) |
| `5a4cdc2` | Battery modes: charge in cheap hours, hold for expensive hours; selling is off for now ([[OPT-11]]) |
| `4f25b24` | No false "Load too high" alarms at quarter edges or with a noisy load (10 s hold) ([[SAFETY-81]]) |
| `35cdffe` | Maintenance drain with two batteries no longer stalls ([[SAFETY-78]]) |
| `1145a93` | Battery modes: a solar forecast decides how much to buy from the grid ([[OPT-11]]) |
| `9d5b7d1` | With one battery, the peak guard wins over the maintenance drain ([[SAFETY-83]]) |
| `4ad8298` | Maintenance charge with two batteries no longer stalls; it gives way to the peak guard ([[SAFETY-84]]) |
| `b8ddfd7` | The peak guard wins over the maintenance hold; the hold ends on time, with no top-up ([[SAFETY-86]]) |
| `9358781` | With two or more batteries, the peak guard uses every battery that can still give; peak alarms no longer say control stopped ([[SAFETY-87]]) |
| `61072ca` | No false "Battery is not keeping up" during maintenance; real shortfalls still warn; the status row counts each episode once ([[SAFETY-85]]) |
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- Two inverters: at the end of a maintenance charge one battery can charge from the grid while the house uses a lot. Do not add a second inverter before the next release ([[SAFETY-93]]).
- A healthy battery that turns from charging to giving can be held at 0 W for up to 15 min ([[SAFETY-94]]).
- After a gap in the P1 meter, the peak guard is blind for the rest of that quarter hour ([[SAFETY-90]]).
- Two batteries: one at its peak reserve can be used before a draining battery has used its room ([[SAFETY-91]]).
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
## 0.12.0
**A MINOR BUMP FOR ONE NEW OPTION: `pv_entities` ([[TEL-12]]), for sites with
@@ -0,0 +1,67 @@
# MAINT-10 — the maintenance start hour is local time, default 19:00
Owner decision 2026-09-29 (option A). `maintenance_start_hour` was compared
with the UTC hour, so the shipped 10 started at 12:00 in a Belgian summer and
11:00 in winter. It is now read on the site's local clock, and the default moves
to 19 so the drain runs into the evening and night rather than the solar day.
Evening drains meet higher house loads; SAFETY-83 (merged first) lets the
peak guard outrank the drain.
## 1. Which zone
Home Assistant's `time_zone` (`GET /core/api/config`, 3 s budget) at startup,
else the container's `TZ`, else UTC. One log line says which (WARNING only for
UTC). Resolved once per start: a zone change in HA takes effect on the next
add-on restart. HA can still be starting when the add-on starts (502 from the
proxy, 2026-10-05); then `TZ` holds. HA pushes its zone to the Supervisor
(hassio `push_config`), which stores it and sets it as the add-on's `TZ`
(`docker/app.py`, Supervisor 2026.08.0), so the hour stays right. Only a `TZ` of
UTC there logs a WARNING asking for a restart. No later re-resolve: the fallback
already is HA's zone, so the rare UTC case gets a warning rather than more code.
The add-on image has tzdata (checked in `amd64-base:3.20`); the CI image does
not, so the tests grade DST on `tariff._EUTimezone` too.
## 2. DST
`maintenance.in_start_slot()` works from UTC instants only, so it does not
depend on how a tzinfo resolves a missing or doubled wall time:
- normally the slot is the local hour `start_hour`;
- clocks forward and the hour does not exist: the first hour after the jump;
- clocks back and the hour happens twice: the first pass only. Without this an
aborted start (90 min cooldown) would start again in the 120 min repeated hour.
Every day has exactly one 60 min slot for every hour 0-23: checked against
zoneinfo for Europe/Brussels, America/New_York, America/Santiago (midnight
transitions), Asia/Kathmandu (+05:45) and Australia/Lord_Howe (30 min DST)
over all of 2026.
## 3. Existing installs
The Supervisor merges the shipped defaults under the STORED options, and saving
the settings stores every key (the reference install's blob has all 76). So
almost every existing install keeps its stored 10, which now means 10:00 local
instead of 12:00 (summer) or 11:00 (winter). Only a blob without the key gets 19.
The add-on does not rewrite a stored option: a silent migration would override
an operator's choice with no way to tell it apart from the old default. A site
that set an earlier hour to make up for UTC (17 for 19:00 summer) must set it
back to 19 after the upgrade, or it starts two hours early. DOCS and the release
note (drafted on the PR, for the version bump) say this.
`tools/supervisor_shape_probe.py` §I measures this on Supervisor 2026.08.0.
## 4. Two inverters
`Controller` hands the same zone to every pack's `Maintenance`, so every pack's
`wants_start()` reads the local hour. `arbitrate()` is unchanged: one pack
cycles at a time, and the one that lost the slot starts in the first start
slot after the other is done (a later day: a cycle outlasts its start hour). On the day the clocks go back, a pack freed during the second pass of
a repeated hour does not start there. `test_maint10.py` part 10.
## 5. What does not change
Only the start decision (`Maintenance.wants_start`) and the Home screen's shown
start time read the zone. `test_maint10.py` parts 9 and 10 run the real
Controller at N=1 and N=2 with start hour 11 UTC against 13 Europe/Brussels (the
same instant): every command, claim, reason and log line is identical. A
`Maintenance` built without a zone reads UTC, as before.
@@ -69,6 +69,8 @@ which does not run the startup gate) the absence costs a shave and never a pack.
## 3. DECISION 2 — the site verdict is the MAXIMUM of the packs' own floors
*At N>=2 one pack at its floor no longer stops the others: [[SAFETY-87]].*
`Controller.pack_floor_soc` — the **minimum** measured SoC, the emptiest pack —
**is unchanged**, exactly as the card instructed. What changed is the floor it is
graded against. `peakguard.binding_soc_floor()` reduces the roster's per-pack
@@ -30,6 +30,5 @@ everything. Where the floor is at or below the drain rate nothing changes.
**Not changed.**
- Between the drain rate and the peak aim, the house still imports what the
drain leaves (N=1 behaviour kept by the identity). At N=2 the loop covers it.
- A maintenance HOLD (0 W, 120 min, battery full) still outranks the peak
guard at N=1. Discharging there spends the charge the BMS balances on; it is
an owner question, not this fix.
- A maintenance HOLD (0 W, 120 min, battery full) still outranked the peak
guard at N=1. Fixed by [[SAFETY-86]].
@@ -41,6 +41,6 @@ was fine.
and the battery gives until the quarter is safe. The same sizing would fit
there; that is an owner question, not this fix.
- Outside a cheap window (`hi = 0`, solar only) nothing is filed, at any N.
- A HOLD (SAFETY-86) and the "Battery is not keeping up" alarm (SAFETY-85)
are untouched. In the identity runs that alarm's log line comes as often at
N=2 as at N=1 (2 in 480 cycles).
- A HOLD (later fixed by [[SAFETY-86]]) and the "Battery is not keeping up"
alarm (SAFETY-85) are untouched. In the identity runs that alarm's log line
comes as often at N=2 as at N=1 (2 in 480 cycles).
@@ -0,0 +1,91 @@
# SAFETY-85 - no false "not keeping up" while another owner sets the battery
**Status: implemented 2026-10-01, for review.** Tests: `test_safety85.py`.
**The defect.** "Battery is not keeping up" (SAFETY-40, `goodwe_integrator_bound`)
fired about every 2.5 min during maintenance (drain, charge, HOLD) and under the
peak guard, while the battery gave exactly what it was told. Each warning
re-created the HA notification. Rig counts: 24 in an hour (SAFETY-83 F1), 43 in
7 episodes (SAFETY-86), 35 in 16 (SAFETY-87), 12 in 6 (SAFETY-84). The page
counter counted cycles (861), not episodes.
**Cause, measured on the real Controller.**
- N=1: when a higher claim decides, `cycle()` re-seeds the integrator from the
target each cycle, then `compute()` adds `gain * error` for a grid error the
loop does not own. Target + kick crosses the rail and sets `i_clamped`. The
carried integrator is not wound (SAFETY-48 caps the lead at one slew step),
so control comes back one slew step at a time.
- N>=2: a maintenance pin is a unit claim. The loop stays the site winner and
winds to its rail for watts the pinned battery may not give. When the pin
lifts, the site jumps: 2500 W in one cycle when a HOLD ends under a 4500 W
house, 1390 W when a charge pin lifts (`s84 n2_charge_dear`, t=1800 s).
**Decision.** Three rules; the alarm, its text and its id are unchanged.
1. **The loop's clamp counts only on a cycle the loop decided.** The winner is
the loop. Its number is on the wire, or only a rating (`device`,
`supervised`) cut it. No bound held it, and nothing was unmet.
(`Controller._loop_alarm_input`.)
2. **A battery another owner set to discharge alarms when it gives less than
it was told.** This covers every battery on a cycle the loop did not
decide, and a battery with its own pin otherwise. The test is more than
`saturation_w` short of the least it stood at over `SHORT_CYCLES` cycles
and `SHORT_SPAN_S` (the plant's measured settle, SAFETY-13/73), for
`saturation_cycles` cycles in a row (`clamp.delivery_short`). It covers a
real shortfall during maintenance or under the peak guard. Before, that
shortfall was silent unless the kick happened to reach the rail (house
1000 W, drain 2000 W, battery gives nothing: 0 warnings before, 4 in 10 min
now).
3. **At N>=2, while maintenance holds a battery, the loop is bounded** by
OPT-11's `loop_bound()` (what the batteries may place). It does not wind,
and control comes back one slew step a cycle. Why maintenance only: that is
where the jump was measured and where the owner order puts maintenance
above the loop. Other N>=2 limits (pack cut-out, SAFETY-73 refusal) keep
the old law. Rule 1 still silences them (the target is unmet), so they no
longer warn either.
Fixing the windup was chosen over only ignoring the wound part, because at
N>=2 the wound integrator moves real watts at hand-back. At N=1 there was
nothing to fix: re-seeding already prevents windup.
**What SAFETY-03/40 protect is kept.** With the loop in charge, the integrator
bound still warns (a runaway, a house above the rating), and a saturated
inverter under the loop's own command still does not alarm (SAFETY-03 AC 2):
rule 2 grades only batteries another owner set.
**The counter.** `ClampWatchdog.bound_episodes` counts episodes that had a
bound engagement. The status row shows it ("N times since start").
`bound` (cycles) stays for the log and the tests.
**Re-pinned digests.** Every change is intended; nothing at N=1 moves a watt.
| digest | what changed |
|---|---|
| s78 `n1_drain_owner` | the false clamp rows are gone from the log; commands identical |
| s78 `n2_drain`, s84 `n2_charge`, `n2_charge_banked`, s79 `n2_maint` (test_peak_margin) | loop reason (`mode-bound`), integrator and loop claim while bounded; commands identical |
| opt11 `n2_maint` (test_modes) | A drops out at t=1655 s: B follows the house at once, 8 cycles differ (max 920 W) |
| s84 `n2_charge_dear` | A's pin lifts at t=1800 s: 400 W a cycle, not a 1390 W jump; the surplus step after it; 98 cycles |
test_safety86's "without the ceiling the log floods" now needs the ceiling
and the bound both removed: either one alone keeps the loop inside what the
batteries can give. The `alarmed()` helpers in test_safety84/86 also read
SAFETY-81's quarter stamp, because ALLOCATOR rows can push its row out of
the 40-row log.
**Review (PR #163).** The first draft graded each battery against last
cycle's command and graded charges too. On plantsim's battery (10 s lag) at
the live 2 s meter, every drain or charge start warned once, and an N=2 drain
or HOLD under the peak guard with a stepping house warned 15 times an hour. A
maintenance charge tapering near full warned 11 times in 50 min, which
SAFETY-42 section 7 had ruled out. With the settle window and discharge only:
0 in all of those cases. A battery that gives nothing still warns, lagging
or not. At a hypothetical 20 s lag, it warns at most twice an hour. The cost:
a battery that does not take a charge another owner set is not named by this
alarm (the same gap SAFETY-42 states; the charge has its 36 h timeout).
`test_safety85.py` part 7.
**Not changed, open for the owner.**
- With the loop in charge and the house above the batteries' total rating,
it still warns (SAFETY-40). On the rig's 2 x 2000 W N=2, most of SAFETY-84's
count came from houses of 4500 and 6500 W.
- TEL-10's `Loop reason` reads `mode-bound` while the maintenance bound holds
the loop, with modes off. DOCS lists the value with both meanings.
@@ -0,0 +1,47 @@
# SAFETY-86 - the peak guard outranks the maintenance HOLD
**Status: DECIDED 2026-09-30 (owner).** Follows SAFETY-83's "Not changed"
list. Tests: `test_safety86.py`.
**The defect.** The HOLD (battery resting full after the maintenance charge)
is a SET of 0 W at `P_MAINTENANCE` (60), above the peak floor (`P_PEAK`, 50),
so the floor did not bind. At N=1 a 6500 W house banked 6500 W quarters and
'Load too high' fired (SAFETY-83 rig test, check 4). At N=2 only the other
battery gave: 4500 W against a 3950 W aim (SAFETY-84 rig test, check 6).
**The fix** (`main.py`, `_peak_over_hold()`).
- N=1: during a HOLD a peak floor above 0 W is refiled at `P_MAINTENANCE`, as
for a drain (SAFETY-83). The battery gives the floor; the Why reads "raised
for the peak limit".
- N>=2: the holding battery's pin is raised to the floor minus what the other
live batteries may place (their hard `hi`, or their pin). The others give
first, the holding one only the shortfall. Why this split and not the
allocator's shares: the hold is maintenance and outranks the others' use
(owner order), and the owner rule for an Off battery is the same, "give way
only for the shortfall". A battery Off by its mode (P_MODE, below
maintenance) therefore still gives before the holding one.
- N>=2, while the holding battery gives: a site `hold` ceiling at the floor
(P_MAINTENANCE), so the loop does not ask for watts the hold keeps back.
Without it the moving pin re-logged the ALLOCATOR "could not be placed"
row each cycle and filled the action log (review, PR #161).
- Below the aim nothing changes: the floor is 0 W, the hold is 0 W, the rows
are identical to before (test_safety86.py part 2).
- A site stop (a SET above `P_PEAK`) decides alone: nothing is raised at N>=2,
and the refiled floor at N=1 stays under `P_SAFETY`. If another battery is
pinned to charge (a SAFETY-73 probe) the pin is not raised, so the site is
never split both ways.
- The hold resumes by itself: the rule is evaluated each cycle, so when the
guard lets go the command is 0 W again.
**The level dips below the target: the hold finishes anyway.** Owner
decision 2026-10-01: the HOLD ends on its clock (`maintenance_hold_min`) even
when the peak guard pulls the level down; no top-up. A top-up would re-enter
CHARGE (up to 36 h, `CHARGE_TIMEOUT_MIN`) and keep the site's one maintenance
slot meanwhile.
**Not changed.**
- The peak guard still grades the emptiest battery (SAFETY-32): at N=2 with
B under `peak_soc_floor` the floor is limited and A holds even when it is
full (SAFETY-87).
- "Battery is not keeping up" during maintenance (SAFETY-85), the N=2 charge
flapping (SAFETY-88) and the N=1 charge headroom sizing are untouched.
@@ -0,0 +1,83 @@
# SAFETY-87 - the peak guard uses every battery that can still give
**Status: DECIDED 2026-10-01 (owner card).** Follows SAFETY-86's "Not
changed" list. Tests: `test_safety87.py`; identity:
`tools/safety87_identity.py`.
**The defect.** At N>=2 the guard graded the emptiest battery against the
highest floor ([[SAFETY-32]] §3). One battery under `peak_soc_floor` made the
whole site floor-limited: the claim asked for 0 W and the reserve alarm fired,
even with the other battery full. Measured: a maintenance charge (A at 11 %)
raised the alarm 10 times while both batteries gave 2000 W and the quarter
landed on the aim (SAFETY-84 rig test, check 3); a HOLD (A at 99 %, B at
15 %) left A at 0 W and banked 4051 W, raising the month peak (SAFETY-86
review; rig case 7a: 4563.8 W).
**The fix.**
- `decide()` and its call are unchanged. At N>=2 `Controller._peak_on_able()`
then grades each battery on its own floor (`peakguard.reserve_split()`).
One at or under it is *held*. The others that are live and not refusing
(SAFETY-73) are *able*.
- A floor-limited decision is re-taken on the able batteries
(`peakguard.place_on_able()`): their own permission (min SoC against max
floor, among them), and a floor of `min(need, able_w)`. `able_w` is their
rating plus the drain a held battery gives anyway (its maintenance pin).
- The held batteries weigh 0 in the split while the site gives
(`arbiter.resolve(reserve=)`), so the floor lands on the able ones. During a
HOLD, a held battery is not counted among the "others" (`_peak_over_hold()`),
so the holding battery covers the need.
- Floor-limited now means: some battery is held and the able ones cannot hold
the aim. The claim still asks them for `able_w`. The alarm fires only if
they miss the real limit at full power for `ALARM_HOLD_S`, the same test as
'Load too high' (`_reserve_short()`), so a one-cycle miss at a quarter's end
is not an alarm. With nothing able (all held, or the rest refusing or
dropped) it is the old state: 0 W, immediate alarm.
- The alarm and the row name `held`, each battery graded on its own floor. The
old code named batteries against the site's highest floor.
- Page: the two peak rows carry `runs`. A red `runs` row gives the banner its
title without "Not ready", and the Home page is not shown as held at 0 W.
Rows that do stop control still outrank it.
**How SAFETY-32's concern is still covered.** SAFETY-32 took min(SoC) against
max(floor) so the guard never sizes a forced discharge a battery's own BMS
stops. Now no battery at or under its own floor is asked for the peak: it is
graded on its own floor, and an unread SoC or unknown cut-out counts as held.
The min/max pairing still decides permission, over the able batteries. The
roster verdict `decide()` echoes (`soc`, `soc_floor`) is kept, so SAFETY-32's
call-site and reduction checks still bite. What changed is only that one held
battery no longer stops the others. One exception, with battery modes on: see
Open.
**Maintenance pins beside a held battery** (review). A draining battery that
may still give is raised from its drain rate towards its rating for the peak
floor, after the others (`_drain_over_reserve()`; peak > drain), else the
floor's rest landed on the held battery. In a HOLD, a held battery counts its
own pin (a drain) among the others, and a held battery pinned to charge stops
the raise (never split both ways).
**Before -> after** (test_safety87.py, rig of 2 x 2500 W, aim 3450 W):
| case | before | after |
|---|---|---|
| charge, A 11 %, house 4500 | 3 reserve alarms, quarter 3450 | 0 alarms, 3450 |
| HOLD, A 99 % / B 15 %, house 5500 | A 0 W, B 2488 W, 3 alarms | A 2054 W, B 0 W, 0 alarms, 3450 |
| HOLD, house 6500 | A 0 W, B 2488 W, 4051 W | A 2500 W, B 0 W, 4014 W, 1 alarm (A alone is short) |
| HOLD, month peak 4000, house 6000 | A 0 W, B 2488 W, 3551 W, alarms | A 2054 W, B 0 W, 3950 (peak - margin), 0 alarms |
| both under the reserve | alarm | alarm (unchanged) |
| N=1, every scenario | | byte-identical |
**Open, for the owner.**
- The loop still draws a held battery (SAFETY-25 option A: the reserve binds
the peak guard only). It gives last, after the able ones. So with house 6500
in the charge case, B alone cannot hold the aim, the alarm fires as
specified, and the loop then lands the quarter on the aim from A. The text
says "cannot keep … alone", not "will go over", so it stays true.
- Battery modes on, A Off and B held: when B alone can place the floor, OPT-11
keeps A resting and B gives it. When B cannot, A gives first. Making Off give
before a held battery in the first case needs a third allocator tier. The
same at N=3: A Off, B held, C able, floor 4000 W gives C 2500, B 1500, A 0.
Until then, with modes on, a held battery can give for the peak.
- The notification and row text live in `messages.py`, which has no NL layer
yet (UX-19). The Home page text, which has NL, is unchanged.
**Not changed.** N=1. 'Load too high' (SAFETY-81). SAFETY-85, -88, -89, -90.
@@ -0,0 +1,81 @@
# SAFETY-90 - the peak guard goes on after a P1 meter gap
**Status: DEV 2026-10-05, for review.** Tests: `test_safety90.py`.
**The defect.** A meter gap longer than `meter_max_age_s` leaves seconds the
quarter average does not count (`p1.QuarterAverager`, on purpose: a guess must
not reach the bill record). `peakguard.blindness()` then called the projection
blind for the rest of the quarter, so the floor was 0 W. During a HOLD the
battery gave nothing and the quarter went over: SAFETY-86 rig case 7b, N=2,
3964.8 W against a 3450 W aim, the month peak raised; N=1, 3701 W (review).
The page still read "Keeping the grid near zero". At start-up mid-quarter the
same rule left the start-up quarter blind, and a held battery was drawn as last
resort (SAFETY-91 test, O2).
**The fix** (`peakguard.fill_gaps()`, `peakguard.gap_w()`,
`Controller._fill_peak_gaps()`).
- Once readings are back, every second of a silence longer than
`meter_max_age_s` counts at `gap_w = max(the limit, the import just before
the gap, the import just after it)`, where "import" is the grid plus the
batteries' discharge, i.e. with the batteries at 0 W. The gap's first
`meter_max_age_s`, which the averager credits at the last reading, is topped
up to `gap_w` too. The guard then grades the quarter as usual, so it is not
blind and gives what keeps the quarter at the aim.
- **The bound.** The count is at or above the true import as long as, during
the gap, the house load (less solar) stayed at or under the higher of the loads seen just
before and just after it, or under the limit, and the batteries did not
start charging (during a gap they hold their last command, then go to 0 W).
It never counts the gap below the limit. A load that rises above all three
only inside the gap is the one case it can miss.
- **Cost of the bound.** It is conservative: with the house at 6500 W, a 30 s
to 5 min gap lands the quarter 100-180 W under the aim (table below), paid in
battery energy.
- **While the gap lasts** nothing changes: the reading is too old to steer by,
the guard is blind (floor 0 W) and the "inputs missing" stop sets 0 W, as
before.
- **Before the first reading** (start-up or restart mid-quarter) the
add-on was down, so the batteries were at 0 W (it sets 0 W when it stops,
the ESP32 watchdog after 30 s). Those seconds count like a gap, at
`gap_w(the limit, the import seen first)`; after a restart the restored
reading's held `meter_max_age_s` is topped up the same way. The bound is
the gap's, with only the "after" side known. A load the batteries cannot
make up then raises "Load too high", which is true: that quarter is going
over. The page says so: the status row "Peak guard: part of this
15-minute period was not measured, counted as high use" and the home line
"Part of this 15-minute period was not measured." / "Een deel van dit
kwartier is niet gemeten." until the quarter ends.
*Review:* the first version counted them at the aim, with "nothing is
known" as the reason. The first reading does show the load, and with the
batteries at 0 W the import was that load, not the aim: a 60 s restart
during a 6500 W HOLD landed 3653 W against 3450 W.
- The bill record (`quarter_blocks`) is unchanged: it still banks only what
was measured. The estimate steers the guard and is never banked.
- The action log gets one `PEAK_GUARD_GAP` row per gap, with the seconds and
the watts it was counted at.
- No option. The threshold is `meter_max_age_s`, the limit and the aim are
`peak_cap_w` and `peak_margin_w`.
**Before -> after** (`test_safety90.py`, quarter as the meter really averages
it, gap starting 300 s in; 10 s battery lag; aim 3450 W):
| case | before | after |
|---|---|---|
| N=1 HOLD 6500 W, gap 30 s / 60 s | 3456.6 / 5329.2 | 3346.2 / 3272.2 |
| N=1 HOLD 5500 W, gap 5 min | 4717.9 | 3330.6 |
| N=2 HOLD, B able, 6500 W, gap 30 s / 60 s | 3456.6 / 3865.6 | 3346.3 / 3272.7 |
| N=2 HOLD, B able, 5500 W, gap 5 min | 3745.4 | 3288.9 |
| N=2 HOLD, B held, 5500 W, gap 60 s | 3254.9, B drawn 2500 W | 3335.4, B 0 W |
| gap across the edge, next quarter, N=1 / N=2 | 4100.0 / 3621.2 | 3446.3 / 3382.2 |
| house 3000 -> 6500 W inside a 60 s gap, N=1 | 5255.6 | 3368.3 |
| house 6500 -> 2000 W late in a 5 min gap, N=1 | 3629.2 | 3145.1 |
| start-up 450 s in, N=1, house 5000 W (batteries at 0 W before it) | 5000.0 | 3450.0 |
| restart, down 60 s / 150 s, N=1, house 6500 W | 4039.1 / 5483.3 | 3450.0 / 3450.0 |
| meter never drops, N=1 and N=2 | | rows identical |
**Not changed.** The late-reading edge (SAFETY-91 test, O1): a reading late by
less than `meter_max_age_s` is not a gap, and the seconds since it are not
counted in the projection (on any quarter, not only after a gap). Near a
quarter's end the 900/remaining gain turns that into a large negative excess
and the floor drops for a cycle or more. Counting those seconds at the held
reading would fix it, but it changes every quarter's last seconds on a
healthy meter; it is handed back as its own card.
@@ -0,0 +1,74 @@
# SAFETY-91 - a held battery is drawn only after the others are full
**Status: DECIDED 2026-10-01 (owner: held battery last, SAFETY-25 option A
kept).** Follows SAFETY-87. Tests: `test_safety91.py`.
**The defect** (SAFETY-87 rig test, F1 and F2). At N>=2 a battery at or under
its own peak reserve was held back (weight 0) only on cycles where the peak
guard asked for power.
- F1: B draining at 1000 W, A held at 15 %, house 5500. Once the grid was
near zero the loop set the target, A had its normal weight and B's drain
pin bound, so the loop drew A at 2500 W for the whole quarter (15.0 ->
10.7 %) while B stayed at 1000 W. In the next quarter A reached its
cut-out, and B stayed pinned at the peak floor (~2422 W), not its 2500 W.
- F2: in a HOLD (A holding, B held), at a quarter's edge the guard saw a block
with no seconds left and asked for nothing for one cycle. The HOLD's
ceiling lapsed, A went back to 0 W and the loop's target went to B: 2500 W
for 1 s.
**The fix** (`main.py`, N>=2 only).
- `_held_back()`: the batteries at or under their own reserve while another
can still give (live, not refusing), graded as in `_peak_on_able()`. They
weigh 0 in every split where the site gives, not only for the peak. With
nobody able (all held, or the rest refusing or dropped) nobody is held back
and the split is as before.
Review: once held back, a battery stays so until its SoC reads more than 1
point above its reserve. A reading flipping 20/21 % otherwise swapped
~1000 W between the batteries on each flip (grid +-500 W with A 10 s and
B 3 s lag). The guard's own permission (SAFETY-87) is unchanged.
- `_drain_rise()`: while a battery is held back, a draining battery's pin
becomes a floor at its drain rate (`lo`), so it rises towards its rating
before the held one gives. Not for a held battery's own drain, not under a
site stop, not for a battery that is not live.
- `tariff.next_quarter()`: past a block's end, before the next block's first
telegram, the guard grades the next quarter, projected from the held
reading (the averager credits it there). Not when that reading is too old
to be credited (`max_hold_s`).
**What stays.** SAFETY-25 option A: once every able or draining battery is
at full power, the loop still draws a held battery (`allocator._fill()`'s
last-resort pass). With house 5500 and 2 x 2500 W, A still gives 2500 W
after B is full. Nothing is placed on a battery at its cut-out: OPT-09's
pack limit closes it (`hi = 0`), and the drain rise lets B take the rest.
**Before -> after** (`test_safety91.py`, 2 x 2500 W, lagging battery 10 s):
| case | before | after |
|---|---|---|
| B drain 1000, A held, house 4000 | A 2500, B 1000 | A 1500, B 2500 |
| same, house 5500 | A 2500, B 1000 | A 2500, B 2500 (option A) |
| cycles A asked while B not full | 612 of 900 | 0 |
| A at its cut-out, house 5500: B | 1572 W mean | 2500 W, A 0 W |
| HOLD, late telegram at the edge | A 0, B 2470 for 1 cycle | A 2050, B 0 |
| idle, A held, house 2000 | A 1005, B 1005 | A 0, B 2020 |
| N=1, every scenario | | byte-identical |
**Why N>=2 only.** At N=1 there is no other battery to give first. The edge
roll would also fix the N=1 one-cycle dip at a quarter's edge (SAFETY-86
rig, "A dropped to 0 W for 1 s"), but this ticket keeps N=1 byte-identical;
extending it is a one-line change for a later ticket.
**Re-pinned.** `test_modes.py` `n2` and `n2_off`: from t=1600 battery A is
at its 20 % reserve, so B carries the house and A gives last (100 of 540
cycles differ). With SAFETY-87's held rule both are a5dddad's again, and the
test grades that.
Two fixtures changed with it: `test_safety87.py` part 10's "without the
raise" run also turns off `_drain_rise()` (else B rises anyway), and
`test_peakguard.py` 10g and `test_watchdog.py`'s two-inverter rig give
their second battery a cut-out, as rule 8 requires (an unknown cut-out is held, so A alone would carry the cycle).
**Not changed.** The peak guard's own rules (SAFETY-87). A drain with
nobody held stays a pin (SAFETY-78). An Off battery beside a held one
(SAFETY-87 Open). The first cycle after start, before any quarter is
measured, can still put the loop's first step on a held battery in a HOLD.
SAFETY-88, -89, -90, -92, -93, -94.
@@ -0,0 +1,60 @@
# SAFETY-93 - the maintenance charge end hands back smoothly
**Status: implemented 2026-10-02, for review.** Tests: `test_safety93.py`.
**The defect** (SAFETY-85 rig test, check 4; also on 9358781). At N=2, when
A's maintenance charge ended, B went 0 -> -1600 W in one cycle and charged
from the grid with the house at 6000 W. The HOLD end was already smooth
(SAFETY-85).
**Cause.** During the charge the site target holds A's charge (SAFETY-84's
`charge` ceiling, -2000 W). At the end A gets its HOLD pin (0 W), but the
loop starts its next step from last cycle's site target. The slew limit
moves the site total by 400 W (-2000 -> -1600), and the only battery left
to place it on is B. The same happens when the charge times out or the
battery is switched Off (A free, its -2000 W shared out). With the charge
suspended by the guard and A giving under the loop, B took A's share in
one step (675 W).
**The fix** (`main.py`, `_reseed_charge_end()`, N>=2 only). On the cycle a
battery leaves CHARGE, the loop's start point (`self.target`, and the
integrator with it) is what the other batteries stood at plus that
battery's new command: its pin (HOLD or abort, 0 W), or, if it is free, its
last discharge (a charge stops). The loop then moves from there one slew
step a cycle. The ending battery's own step (-2000 -> 0 W) is maintenance's
pin, as before; it lowers the grid import. The end is kept until the loop
next runs, so a missed poll on that cycle (the input hold) does not lose it.
Review: a charge also ends, for the site, when the battery is still in
CHARGE but can no longer be placed: a stale link or a refusal (SAFETY-73)
drops it, and its -2000 W landed on B the same way (B 0 -> -1600 W at a
1200 W house). So the end is noted after the unit claims are filed: a
battery leaves the charge when it leaves CHARGE, is not live, or its own
claims contradict. The guard suspending the charge (SAFETY-84) is no end.
Why a start-point reseed, and not a per-battery slew in the allocator: the
jump comes from one stale number at one edge, the kind of edge SAFETY-73
(`_unit_refusal_end`) and OPT-11 P3 (`_reseed_run_edge`) already reseed.
The change stays at that edge; nothing else at N>=2 moves.
**Before -> after** (`test_safety93.py`, 2 x 2500 W, 10 s lag, 2 s meter):
| case | before | after |
|---|---|---|
| rig case: house 6000, A charges 2000 to the end | B 0 -> -1600, charges 4 cycles | B 0 -> 400 -> 800 ..., never charges |
| house 1200 | B 0 -> -1600 (2800 W from the grid) | B steps of 400, never charges |
| charge times out / A switched Off | B 0 -> -1600 | B 0 -> 400 |
| guard suspended the charge, A gives 1825 | B step 675 W | B step 395 W |
| missed poll on the end cycle | B 0 -> -1600 a cycle later | B 0 -> 400 |
| A dropped mid-charge (stale link, refusal), house 1200 | B 0 -> -1600 | B 0 -> 400 |
| N=1, both charge ends above | | byte-identical |
No pinned digest moved: the 46 digests of `tools/*identity*.py` (N=1 and
N=2) are byte-identical to f3e409c; none of their scenarios reaches a
charge end.
**Not changed.** The peak guard's floor still moves the site in one step
when it engages (a house step, a quarter edge): the guard outranks the slew
(owner order). A HOLD pin still drops the holding battery to 0 W at once
and SAFETY-86 gives way to the others first. SAFETY-88 (charge flapping
above the aim) is untouched.
@@ -0,0 +1,102 @@
# SAFETY-94 - a battery that lags or reverses is not frozen or refusing
**Status: implemented 2026-10-03, for review.** Tests: `test_safety94.py`
(rig: `tools/safety94_rig.py`). Also closes SAFETY-58. SAFETY-74 stays open.
## The defects
1. **Stale-reading gate (SAFETY-37).** Its 35 s ran from the register's last
change. After a rest that clock was already past 35 s, so the first read
after a new command tripped it, before the register could answer. Live
2026-10-03 (0.14.0, N=2): 17:39:16 B and 17:39:25 A (load ramp after a
rest), 19:00:04 A (drain start), 21:21:22 B (drain -> charge). Each was
1 s after its command; each register answered within 11 s. This is
SAFETY-58's shape.
2. **Refusal at N=1 (SAFETY-52).** `compute()` engaged after `SETTLE_CYCLES`
frozen cycles if the reading was under 100 W at that moment. The frozen
cycles include a reversal through the battery's lag. At a 1 s meter the
12th cycle comes as a battery reversing out of a charge passes 0 W
(SAFETY-85 rig, O1: "gave -20 W for 13 cycles"). The refusal then held
0 W, the battery settled at 0 W, never gave 100 W, and waited for the
15-minute probe. N>1 already required 22 s in the band, so it was safe
at a 10 s lag but not at 20 s.
## What changed
- **The gate counts only time the register was asked to move**
(`freshness.Freshness._oblige`). A read counts the time since the last
read when, at that read, the command is 250 W or more and has moved 250 W
from where it stood at the register's last change. A register change
(or a SAFETY-57 release) restarts the count. A command flapping in and
out of the ask still adds up (frozen register, 0/1500 W every 5 s: trip
at 69 s). The conjunction, `MOVE_W` and `RELEASE_S` are unchanged.
- **The bound is unchanged at 35 s and now means what its derivation
says.** The floor was always "how long a healthy register takes to answer
a command": 6 s plant + 10 s publish + 1 s read = 17 s. The band
`[2 x 17, 0.5 x 75] = [34, 37.5]` holds. Live answers measured: 1.4, 3,
6 and 11 s after the command.
- **Refusal evidence is the same at every roster size**
(`main.Controller._gave_nothing`, SAFETY-73's count moved into a method).
The reading must stay under 100 W, one way, while asked over 200 W, for 12
cycles spanning 22 s. At N=1 `compute()` gets this as `gave_nothing` and
needs it on top of its own freeze test. `None` (compute alone) keeps the
old law.
- **The evidence restarts when the reading moves 50 W towards the ask**
(`REFUSED_PROGRESS_W`). A refusing battery reads flat (-3/-4 W live). Live
readings wander by under 25 W at rest. A first-order lag crossing the band
gains 50 W in at most 0.4 tau, so it cannot fill 22 s unless tau > 54 s.
This also covers B, whose reading is 100 W below its power.
## Bounds and worst-case catch
| fault | before | after | budget |
|---|---|---|---|
| register frozen, then asked to move | 0-35 s after the ask (often 1 s) | 35 s after the ask (+1 read) | SAFETY-37: 35 s, under the 75/85 s consumer holds |
| register frozen at rest reading ~0 W | stale 1 s after the ask | refused or stale, 0 W within 23-35 s | the same |
| refusing battery, 1 s meter, N=1 | 11-16 s | 22-28 s | SAFETY-73's 22 s span (+ one 10 s publish) |
| refusing battery, 2 s meter | 22-28 s | 22-28 s | unchanged |
| refusing battery, 5 s meter | N=1 35-41 s, N=2 41-55 s | N=1 40-45 s, N=2 50-55 s | 12 cycles = 55-60 s |
Rig: `test_safety94.py` part 3. A release after SAFETY-57 still exposes
the held number for one 35 s window of asking, so 35 < 75 < 85 still holds.
## Before -> after
- Live replay (part 1): 4 trips -> 0.
- Refusing B (its -100 W idle reading): see SAFETY-97.
- Rig, 10 s lag, N=1 and N=2, 1/2/5 s meters, two register shapes, 48 runs
(part 2): before 27 stale trips and 1 refusal, after 0 and 0.
- Reversals, lag 10 and 20 s, 1 s meter (part 2): before N=1 6 stale / 3
refused, N=2 6 / 1; after 0 / 0.
- 16-day loop capture (`test_freshness.py` part 8): 93 trips -> 59. Trips
answered within 20 s: 21 -> 1. Two of the removed trips were not quick:
a register held 351 s under a command hovering at the 250 W travel edge
(under 35 s of asking before it moved; survivor 2's shape), and a
refusing battery the gate had caught only on a passing ramp (SAFETY-52's
refusal covers it).
## SAFETY-58 and SAFETY-74
- **SAFETY-58 is fixed** by this change: its card asks for the clock to
start with the obligation and for the bound to be re-derived. Both are
done above. `test_freshness.py` survivor 5 is now graded as closed.
- **SAFETY-74 is not fixed.** A battery capped at 300 W with a perfectly
flat reading, 5 s meter, house stepping: 50 trips/h at N=1 and 80 at N=2
before, 37 and 60 after. With +-1 W dither: 0. A flat reading under a
command that moved is exactly what the gate is for; telling "limited"
from "frozen" needs a second signal (the meter, or SoC).
## Known gaps
- ~~B's standby offset (-95 to -118 W at idle) puts a refusing B outside the
100 W band, so a refusing B never latched (review F1: at N=1 new, at N=2
as before).~~ **Fixed by SAFETY-97 in the same PR:** the band is centred
on each unit's learned idle reading. `SAFETY-97-idle-offset.md`.
- A command that asks only part of the time (a fraction d) is caught after
35 s of asking, which is 35/d s of wall time (0/1500 W every 5 s: 69 s).
SAFETY-30's per-unit check restarts when its command stops opposing the
held reading, which a lapsing ask normally does, so 35 < 85 holds there.
SAFETY-31's circulation check (75 s) ignores the command, so its margin
holds only while d >= 0.47.
- A register frozen under a command that stays near the 250 W travel edge
is caught late or not at all (survivor 2).
@@ -0,0 +1,69 @@
# SAFETY-95 - a maintenance drain that ends hands back smoothly
**Status: implemented 2026-10-06, for review.** Tests: `test_safety95.py`.
**The defect** (SAFETY-93 review, O1). At N=2, when A's drain ended (A
switched Off, say) with the house at 1200 W, B went 0 -> +1600 W in one
cycle and exported. It is the mirror of SAFETY-93 on the drain end.
**Cause.** During a drain the site target carries A's drain floor
(SAFETY-78, `drain`, 2000 W) above what the house needs. At the end A's
command drops (pin, Off, abort, stale link, refusal), but the loop slews
from last cycle's target (2000 -> 1600) and only B is left to place it.
Under a solar surplus B gave 1600 W into the export.
**The rule** (`main.py`, `_reseed_drain_end()`, `_drain_grid_w()`, N>=2
only). On the cycle a drain ends:
1. The start point is what the others stood at plus A's new command (its
pin, else 0 W; a dropped battery is sent 0 W), as SAFETY-93 does.
2. If the house needs more, the start is raised to the house's own point
(live batteries' readings + grid - target, as `_reseed_run_edge()`),
but never above last cycle's start. So the others cover the house at
once and never take over the drain's export.
3. While A settles (SAFETY-96's window: within the deadband, at most
`PLANT_SETTLE_DOWN_S`), the loop's error is the house's point minus its
start, not the meter. The meter still shows A's tail and the others'
climb; chasing it sent B down and then up again.
4. Then the loop moves one slew step a cycle.
A drain ends when its battery leaves DRAIN, is not live, its claims
contradict, or it will not give (SAFETY-73's limit outranks the pin
without a contradiction, so the refusal is read from the battery; +1 only,
as `_held_back()`). Today a contradiction on a drain only arises through
a refusal; the clause stays because a contradiction sends A 0 W, which is
an end (tested directly). The end is kept
until the loop runs (a missed poll). A dropped draining battery is this
rule's, not `_reseed_drop()`'s. Order in one cycle: drop/rejoin
(SAFETY-96), charge end (SAFETY-93), drain end (this) last; each moves the
start by its own battery, and the drain end's raise reads the start the
others left.
**Why not SAFETY-93's rule alone** (step 1, then slew). Measured on the
same plant: at 1200 W the grid imports 849 W and B goes to -465 W
(charging) while the loop chases A's tail; at N=3 and 4000 W it imports
784 W where base imports 42 W, as the others give up A's share of the house.
The house point keeps the grid at its aim; the cap at last cycle's start
keeps it from exporting.
**Before -> after** (`test_safety95.py`, 2500 W batteries, 10 s lag, 2 s
meter; base = the same tree with the change taken out):
| case | before | after |
|---|---|---|
| drain done / Off / timeout, N=2, 1200 W | B 0 -> 1600, +390 W commanded export, grid then +559 W | B 0 -> 1210, grid within 40 W of its aim |
| the same, N=3, 1200 W | B, C 0 -> 800, grid +695 W | 0 -> 605, grid +39 W |
| the same, solar surplus | B gives 1600 W into the surplus | B never gives; site moves 400 W a cycle |
| refusal, N=3, 1200 W | B, C step 1020 W, +2000 W commanded export | step 20 W, +70 W |
| N=3, 4000 W | others take A's share at once | the same (grid +1 W, base +42 W) |
| abort (site zeroed by the operator) | climbs from 0, grid +669 W | climbs from 0, grid +16 W |
| stale link (board watchdog 25 s) | | as base, but N=3 4000 W: grid +676 W (base +786) |
| N=1, every drain end | | byte-identical |
`tools/opt11_identity.py` n2_maint moved on purpose (A's drain refused at
1655 s; B stays at the house's 1420 W instead of 2500 W). Re-pinned in
`test_modes.py`. The other 45 digests are byte-identical.
**Not changed.** The peak guard's floor still binds (arbiter, after the
loop). Held batteries still give last; the idle clamp and the freeze test
are as they were. A charge end (SAFETY-93) and a trade end are unchanged.
@@ -0,0 +1,59 @@
# SAFETY-96 - a battery the loop cannot move does not freeze it
**Status: implemented 2026-10-06, for review.** Tests: `test_safety96.py`.
**The defect** (SAFETY-93 rig test). At N=2 the saturation freeze compares
the site target with the site sum of the readings. After A's link went
stale, A was out of the split but its reading (-2000 W, still charging) was
in the sum: "saturation freeze (800 W vs -1903 W)", B held at 1200 W for
~236 cycles until the circulation trip. After a charge timeout or Off at
6000 W the loop froze ~14 s at 800 W while A came out of its charge. At a
drop, B also took A's whole share in one step.
**The rule** (`main.py`, `_held_out_w()`, `_reseed_drop()`, N>=2 only).
A battery is held out of the freeze test, counted at its command instead of
its reading, while:
- it is out of the split (writes refused for `LINK_STALE_S`);
- its last write was refused (its board may have gone to 0 W on its own
watchdog);
- it settles after a step the loop did not make: a charge end (SAFETY-93:
HOLD, timeout, Off, refusal, stale link) or a rejoin (then every battery,
as the split is made again). Settling ends when its reading is within the
deadband of its command, or after `PLANT_SETTLE_DOWN_S` (23 s, the
measured plant settle).
Its real power is not lost and not counted twice: the meter sees it, and
the loop follows the meter. Only the freeze stops reading it as the loop's.
The freeze still fires on the other batteries' own lag and on unplaced
watts.
At a drop the battery's share leaves the loop's start point, so the others
take over one slew step a cycle. At a rejoin it enters at its reading.
**Not a drop.** A missing reading or a stale register stops the site
(`inputs_ok`), as before. A refused battery (SAFETY-73) stays in the sum:
it is asked ~0 W and reads ~0 W. A watchdog-stopped battery (SAFETY-02/30)
is pinned at 0 W and stays in the sum. New: no refusal is learned on a
battery whose writes are refused; its board going to 0 W is no refusal. Its
count is kept, not reset, so a link that refuses 1 write in 3 does not hide
a battery that really refuses (review).
**Before -> after** (2 x 2500 W, 10 s lag, 2 s meter; freeze cycles not
about the remaining battery's own lag):
| case | before | after |
|---|---|---|
| A drops while giving, A holds 1200 W | 128, B steps 1210 W | 0, B stays |
| A drops while charging, A holds -2000 W | 78 | 0 (circulation trip stays: 4 kW goes round) |
| A drops while charging, board to 0 W | 5 | 0 |
| A drops then comes back, A holds 1200 W | 46, grid +1.1 kW | 0, grid within 250 W |
| charge timeout at 6000 W | 7, B held at 400 W | 0, B climbs |
| A switched Off at 6000 W | 6, B held at 800 W | 0, B climbs |
| N=1, N=2 with no drop | | byte-identical |
**Not changed.** At a rejoin the allocator splits again at once (A 0 ->
1205, B 2410 -> 1205 W); the site total does not step. A battery that keeps
charging on its own after its link drops still trips the circulation check
(it is real circulation). A trade end (`_reseed_run_edge`) still starts
from the full site sum.
@@ -0,0 +1,118 @@
# SAFETY-97 - "gives nothing" is graded against each battery's idle reading
**Status: implemented 2026-10-04, folded into PR #171 (SAFETY-94), for
review.** Owner decision 2026-10-04: the band's centre is clamped to
+-75 W (see Bounds). Tests: `test_safety97.py` (rig: `tools/safety94_rig.py`).
Closes SAFETY-94 review finding F1.
## The defect
B's power reading (board B's `inverter_ac_power`, 0x0518) shows -95 to -118 W
at idle: its standby draw (the GoodWe app shows 0 W). A's shows -2 to -4 W.
Every "the battery gives nothing" test was `|reading| < 100 W`, so a refusing
B sat on the band's edge and never latched:
- N=2: never caught, before or after SAFETY-94.
- N=1: the old one-instant check caught it in 17-105 s; SAFETY-94's shared
rule (12 cycles in the band in a row) never did (review probe, 1200 s).
## Design
- **Offset source: learned per unit** (`app/idle.py`, `Inverter.idle`). No
new option: a required one is a setup step every client gets wrong, and an
optional one is a second source to reconcile. An override can be added
later if a site needs one.
- **When it learns:** control runs; the unit is not refused (unit or site),
stale or sign-tripped; its command has been within 50 W (`IDLE_ASK_W`) for
120 s (`IDLE_QUIET_S`, 6 lags of 20 s). A refusing battery is asked more
than 50 W, and while refused it does not learn, so its refusal cannot be
learned as idle. A capped battery under a big ask is not sampled either.
- **How:** the first value is the mean of 120 s of samples; then it follows
drift with a 30 min time constant. A sample beyond 250 W (`IDLE_SAMPLE_W`)
is dropped (a register held at a giving value). The band is centred on
the value clamped to 75 W (`IDLE_MAX_W`; owner decision below).
- **Restart:** saved per unit (`unit/<name>/idle_w`, on first learn and each
5 W move) and restored, clamped to 250 W. Without it a battery refusing from the
start is never quiet, so never learned, so never caught.
- **Until learned the idle reading is 0 W**: the rule as it was.
## Not chosen: the GoodWe LAN integration's DC battery power
Both inverters also appear through HA's GoodWe integration (B:
`sensor.kelder_dyness_battery_power`, 0 W at idle; found read-only on the
live HA 2026-10-04). Pointing `batt_entity` at it removes B's offset by
configuration, but:
- the band must be safe without it: other installs may not have it;
- it is polled over UDP on the inverter's Wi-Fi, a second channel that can
drop while the board keeps reading (HW-01 lost both once); a missing
reading stops the site;
- SAFETY-37's 35 s bound is derived from the board's 10 s publish; the
integration's poll interval (believed 10 s, not checked here) and latency
are not measured;
- DC power is not 0x0518, which SAFETY-30/31/42 were measured against.
It stays an install choice to test on the rig, not a code change here. With
it, the learned idle reading is simply ~0 W.
## Where it applies
Every test of "gives nothing / gives / takes" now uses `reading - idle`:
| check | change |
|---|---|
| shared refusal evidence `_gave_nothing` (N=1 and N>=2) | centred |
| N=1 engage and end in `compute()` (`idle_w`, the site sum) | centred |
| N=1 end in `_refusal_update` | centred |
| N>1 unit end (probe answered, takes the other way) | centred; uncentred, B's idle -100 W read as "takes 100 W" and ended a charge refusal at once |
| stale-path refusal (`_refusal_from_stale`, N=1 and N>1) | centred |
| SAFETY-42 "parked" (small ask, gives nothing) | centred |
| SAFETY-37 stale gate | unchanged: it grades register changes against command travel, not a level |
| SAFETY-57 release | unchanged: it reads the command only |
| SAFETY-30 per-unit sign | unchanged: opposition needs both over 250 W; B's 100 W idle cannot make or hide one alone |
| SAFETY-42 shortfall | unchanged: B reads ~100 W short at every power, which errs towards reporting (report only) |
## Bounds: owner decision 2026-10-04 (review R1)
Band: `|reading - centre| < 100 W`, centre = idle clamped to +-75 W.
- **Why a clamp under the band.** A wrong idle reading (taught during a
rest while the reading shows something else, e.g. a grid top-up of a unit
held at 0 W, or restored onto a swapped inverter) more than about 100 W off
hid every refusal on that battery, at N=1 and N=2; with the first +-250 W
clamp the rig missed a refusing A whenever the error was 110-250 W. At
+-75 W the band always keeps 0 W +-25 W, so a battery reading about 0 W
while refusing is caught whatever was learned or restored, and a wrong idle
cannot refuse a healthy 0 W battery (its ask is over 200 W).
- **The trade-off.** Options: keep +-250 W (covers idle readings to about
+-350 W, any 100 W mis-learn hides refusals); clamp to +-75 W (chosen);
"old band or centred band" (rejected: a correct B would need to give 200 W
to answer its 200 W probe).
- **Cost.** A battery whose idle reading is beyond about +-170 W is centred
at most 75 W out, so its refusal falls outside the band: it behaves as in
0.14.0 (not caught by this rule). B (-95 to -118 W) reads 20-43 W from its
clamped centre. B is caught across -150..+90 W of error in its idle
reading; learned 110 W or more too high it is missed, as in 0.14.0 at N=2.
- A frozen register is still caught by the stale gate at 35 s of asking.
- Recovery from a wrong value needs quiet time: tau 30 min.
## Catch times (rig, refusing from 200 s after a 400 s rest, +-4 W)
| refusing | meter 1 s | 2 s | 5 s |
|---|---|---|---|
| A, N=1 | 28 s (before 28) | 28 (28) | 65 (65) |
| A, N=2 | 28 (28) | 28 (28) | 110 (110) |
| B, N=1 | 28 (never) | 28 (never) | 65 (never) |
| B, N=2 | 24 (never) | 24 (never) | 60 (never) |
A at N=2 on a 5 s meter is 110 s here because the rig's grid goes flat once
A is cut, so the loop cycles on the 15 s floor; the same run on the PR before
SAFETY-97 gives 110 s. B restored after a restart, refusing from the start:
24 s; with nothing saved: never.
## Before -> after
- Healthy B with its offset, SAFETY-94's four scenarios, N=1 and N=2, 1/2/5 s
meters, both register shapes, idle learned or restored (96 runs): 0 stale,
0 refused. Reversals, lag 10/20 s, idle restored: 0.
- Live replay (SAFETY-94 part 1): 0 trips (the gate is untouched).
+82 -17
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. |
@@ -577,7 +577,7 @@ accumulator is doing:
| `Loop integrator lead` | W | that value minus **the command it was computed against**, i.e. the command the previous cycle left standing |
| `Loop reason` | — | the law's own word for this cycle |
`Loop reason` takes one of ten values:
`Loop reason` takes one of eleven values:
| value | what happened |
|---|---|
@@ -588,8 +588,9 @@ accumulator is doing:
| `slew-limited` | the command could not move as far as the integrator asked for; `slew_w` is what stopped it |
| `saturated-freeze` | the inverter is not following, so the command's magnitude may fall but not rise |
| `freeze-released` | the freeze had run out of plant-shaped explanations and the command was let move again |
| `mode-bound` | the batteries' limits set the command, not the meter: their battery modes, or, with two or more inverters, a battery in its monthly maintenance |
| `not-running` | control is stopped **and** shadow mode is off, so the loop did not run |
| `battery refuses` | the battery gave less than 100 W for 12 cycles (a minute at a 5-second meter) while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) |
| `battery refuses` | the battery gave less than 100 W for 12 cycles and at least 22 s (a minute at a 5-second meter) while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) |
| `battery probe` | the add-on asks 200 W to test whether a refusing battery gives power again |
**When the battery refuses.** At its cut-out level the battery gives nothing,
@@ -599,7 +600,11 @@ request, and the `Why` row reads *The battery refuses to give power: 0 W*. Every
the battery gives 100 W or more, when the grid needs charging instead, and when
control is stopped or started. 200 W is below the 250 W the stale-reading check
needs, so a test never trips it. A battery that gives some power, even 100 W,
is not refusing and keeps its request.
is not refusing and keeps its request. Nor is a battery whose reading is
still moving towards the request, such as one turning from charging to
giving (SAFETY-94). "Gives" is counted from what the battery's reading shows
at rest, which the add-on learns after two quiet minutes and keeps: some
inverters show about -100 W at rest (SAFETY-97).
**With two or more inverters** the same rule works per inverter. An inverter
whose battery gives less than 100 W for 12 cycles, and at least 22 s, while
@@ -1638,13 +1643,30 @@ help - see "Why the tuning is what it is".
|---|---|---|
| `maintenance_enabled` | false | Enable the monthly cycle |
| `maintenance_interval_days` | 28 | Minimum gap between cycles |
| `maintenance_start_hour` | 10 | Hour of day a due cycle begins |
| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). The peak guard may ask for more (SAFETY-83). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower |
| `maintenance_start_hour` | 19 | Local hour a due cycle begins (see below) |
| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). The peak guard may ask for more (SAFETY-83; at N≥2 while another battery is at its peak reserve, SAFETY-87), and at N≥2 so may the house while another battery is at its peak reserve (SAFETY-91). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower |
| `maintenance_charge_w` | 2000 | Charge ceiling, capped again by peak headroom. Must stay **at or below `max_w`** or the cheap-window shaping (`hi = -maintenance_charge_w`) contradicts the supervised limit (`lo = -max_w`), the arbiter resolves it to **0 W**, and maintenance silently never charges in a cheap window — **the add-on refuses to start** |
| `maintenance_soc_floor` | 11 | Drain target — stay just above the inverter's own floor. ⚠️ **This is not `peak_soc_floor`.** It is the depth the monthly recalibration deliberately goes to, once, on purpose; `peak_soc_floor` is the depth a money-saving discharge may never reach on any day. The right values differ (11 vs 20 as shipped), which is why they are two keys |
| `maintenance_soc_target` | 99 | Charge target |
| `maintenance_hold_min` | 120 | Hold at full so the BMS can balance |
**The start hour is local time.** The add-on reads Home Assistant's time zone
(Settings → System → General) at startup. If Home Assistant does not answer,
it uses the add-on's own time zone, and failing that UTC. The log says which
one it used, and warns if it is UTC: then restart the add-on once Home Assistant
is up. On the day the clocks go forward, a start hour that does not exist
starts at the first hour after the jump. On the day they go back, a repeated
hour starts only once, in its first pass. With two inverters both batteries use
the same local hour; one cycles at a time, and the other starts at that hour on
a later day.
**Upgrading.** The hour used to be read as UTC. An existing install keeps its
saved hour, and that hour now means local time: a saved 10 now starts at 10:00,
not 12:00 in summer. The new default, 19, only reaches installs that have no
hour saved. If you set an earlier hour to get an evening start in UTC (for
example 17 for 19:00 in summer), set it to 19. The add-on never changes a saved
value. *For installers:* DECISIONS/MAINT-10-local-start-hour.md.
### Tariff (all optional)
| option | meaning |
@@ -1666,9 +1688,23 @@ others only add power when the house needs more; none charges while it drains
(SAFETY-78). Below the drain rate the rest is exported, as with one battery.
If the house would still go over the peak limit, the battery being emptied
gives more than its drain rate, with one battery too (SAFETY-83).
A battery at its peak reserve gives last: the others give their full power
first, and the battery being emptied rises above its drain rate before it
(SAFETY-91).
In a cheap window the battery being filled charges and the others only add
charge; none gives while it charges. The charge is cut to what keeps the
15-minute peak at the limit minus `peak_margin_w` (SAFETY-84).
While a full battery rests after its charge (the hold), it gives just enough
to keep the 15-minute peak at that aim if the house needs it; with two
batteries the other one gives first. The hold still ends on its clock
(SAFETY-86).
When a charge ends, the other batteries take over one slew step a cycle,
and none starts charging from the grid (SAFETY-93).
When a battery's link drops, the others take over one slew step a cycle,
and control does not stall on the dropped battery's power (SAFETY-96).
When a drain ends (done, Off, timeout, abort, lost link, refused), the
other batteries cover the house at once but do not export its drain; then
they move one slew step a cycle (SAFETY-95).
#### Capacity-tariff exposure of the grid-following loop
@@ -1738,10 +1774,17 @@ ceiling at the cap, so a broken instrument makes the guard stricter, never loose
**When it cannot see, it forbids importing.** Three states are not evidence and
are checked before the projection is graded: no open quarter (a fresh averager
projects a confident 0 W, which is under every ceiling there is), a meter gap
longer than `meter_max_age_s`, and SAFETY-18's frozen-meter conjunction. In all
three the claim becomes a floor of **0 W**. That is the backstop for the frozen
meter: the alarm arrives at `meter_freeze_s` (300 s) of a 900 s billing quarter
and commands nothing, and this stops the import.
longer than `meter_max_age_s` while it lasts, and SAFETY-18's frozen-meter
conjunction. In all three the claim becomes a floor of **0 W**. That is the
backstop for the frozen meter: the alarm arrives at `meter_freeze_s` (300 s) of
a 900 s billing quarter and commands nothing, and this stops the import.
**After a gap it goes on (SAFETY-90).** The seconds the meter missed count at
the higher of the peak limit and the import seen just before and just after
the gap, with the batteries at 0 W. Seconds before the add-on's first reading
count the same way, at the import seen first, and the page says part of the
period was not measured. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md has the
bound.
**It trips, where the freeze detector only notifies,** and the captures are why.
On the healthy reference day (2026-08-23) it engages on **zero** of 95 quarters.
@@ -1804,7 +1847,9 @@ exactly like the SoC drift the monthly maintenance cycle exists to correct.
It will discharge straight past this number and keep going until the pack's own
BMS cut-out stops it. What you are promised by `peak_soc_floor: 20` is *"the
capacity-peak guard will not spend the pack for money below 20 %"*, and
**nothing more**. See below for what that costs on a real evening.
**nothing more**. See below for what that costs on a real evening. With two
or more batteries, one under it is used last: only once the others give
their full power (SAFETY-91).
- **When it binds you are told**, once per episode, with notification
`goodwe_peak_soc_floor` and a red status-page row reading *Battery at its
peak reserve* (`PEAK_GUARD_FLOOR_LIMITED` in the log): the quarter **will**
@@ -1949,6 +1994,15 @@ over the rating is logged as a software fault, at `ERROR`, and it has its own
notification budget.
Why: [`DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md`](DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md).
The integrator bound counts only on a cycle the loop decided. While
maintenance, the peak guard or a safety limit sets a battery to discharge, the
same alarm fires only when that battery gives more than `saturation_w` less
than the least it was told over the plant's settle time (at least 23 s), for
`saturation_cycles` cycles. A charge is not graded: a full battery takes less
as it tops up. At two or more inverters the loop does not wind while
maintenance holds a battery.
Why: [`DECISIONS/SAFETY-85-no-false-not-keeping-up.md`](DECISIONS/SAFETY-85-no-false-not-keeping-up.md).
### The frozen-meter alarm
A meter that has died is easy — it stops answering, the sample age climbs past
@@ -3118,7 +3172,10 @@ The battery gives less power than the add-on asks for. This is normal for a
short time, for example just after a large load switches on or the sun comes
out. It is not a fault, and control keeps running. If it keeps coming back,
check that the battery is switched on, and that it is not empty (when it should
give power) or full (when it should charge).
give power) or full (when it should charge). During the monthly maintenance or
while the peak limit is guarded, it only shows when the battery gives
clearly less than it was told. The status page counts how many times it
happened.
### Power request too high
@@ -3153,6 +3210,10 @@ and what to do:
- **A battery cut-out level is not known.** Fill in “Battery cut-out level”
for each inverter in the settings.
With several inverters, a battery at its peak reserve does not give for the
peak and the others do. This alarm then comes only when the others cannot
keep the period under the limit alone. Control keeps running meanwhile.
The peak reserve only applies to keeping the peak down. Normal home use can
still draw the battery below it, down to the battery cut-out level, where the
battery switches itself off.
@@ -3219,8 +3280,9 @@ section without a link. The documentation tab adds no heading anchors, so the
link cannot jump to the section.
**Battery is not keeping up.** The grid-following loop's integrator reached its
anti-windup bound (`integrator_max_w`, SAFETY-40). No command went over the
rating. The log line is a WARNING with reason code `OUTPUT_CLAMP`. See *The
anti-windup bound (`integrator_max_w`, SAFETY-40), or a battery that another
owner set to discharge gave less than it was told (SAFETY-85). No command went
over the rating. The log line is a WARNING with reason code `OUTPUT_CLAMP`. See *The
winding-loop alarm*.
**Power request too high.** A computed command above a limit was cut before it
@@ -3248,7 +3310,9 @@ state of charge is unread, or when an inverter's `bms_soc_floor` (label
“Battery cut-out level”) is missing, because the reserve is graded against it.
Charging from the grid stays blocked meanwhile. With several inverters the
notification names the battery at its limit (SAFETY-32), each graded against
its own cut-out level. ⚠️ **The reserve binds the peak guard only
its own cut-out level. That battery is held (weight 0 while the site gives)
and the guard's need goes to the others, up to their rating; the alarm fires
only if they miss the limit at full power for `ALARM_HOLD_S` (SAFETY-87). ⚠️ **The reserve binds the peak guard only
(SAFETY-25).** Self-consumption has no such floor. See *What `peak_soc_floor`
does not protect*.
@@ -3386,7 +3450,7 @@ for a new install*.
| Meter reading may be stuck | See *Meter reading may be stuck* under Notifications. |
| Month peak | This month's highest 15-minute average, when it happened, and how close it is to your peak limit. Amber from 80 %. |
| 15-minute periods measured this month | How many 15-minute periods the add-on has measured and saved this month. |
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. *Idle, heading for …* is where this 15-minute period is heading. *Waiting for meter readings* after a restart means the battery does not charge until the add-on can measure the period again. |
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. *Idle, heading for …* is where this 15-minute period is heading. *Waiting for meter readings* after a restart means the battery does not charge until the add-on can measure the period again. A red peak row is an alarm while control keeps running, so the banner shows its title without *Not ready*. |
| Battery level, Battery power, Grid power | The reading of that entity. *No usable reading* means the entity is missing, or its reading is too old. Check it in Developer Tools before replacing it. |
| Inverter control | Whether the inverter can be controlled, and how much it can charge and give. |
| *name*: battery power reading stopped changing | The reading did not change while the add-on asked for a change. It may be stuck, or the battery may be refusing. The add-on sets the inverters to 0 W until it changes. |
@@ -3426,7 +3490,8 @@ reason code, and its full figures are on the DEBUG line right after it and in
…`, and the peak guard's full reason is on the DEBUG line after its ERROR.
- ***name*: battery power reading stopped changing** is SAFETY-37/56/57. The
ERROR line says when it clears and names `STALE_READING`. The full reason,
with both possible causes, is on the DEBUG line after it.
with both possible causes, is on the DEBUG line after it. The 35 s counts
only while the command asks the reading to move (SAFETY-94).
- **Meter: no reading for …** (red): the add-on logs one WARNING when the
meter goes quiet, "No meter reading for …", and an INFO line when readings
return, at most once every 10 minutes. Past `stale_input_s` the **Why**
+13 -2
View File
@@ -79,7 +79,7 @@ P_PEAK = 50 # capacity-tariff peak shaving - costs real money
# The order in effect: safety limits and the SAFETY-73 refusal > the peak
# guard > maintenance > modes > trade > loop. A peak floor above a drain still
# binds: the `drain` site floor at N>=2 (SAFETY-78), refiled at P_MAINTENANCE
# at N=1 (SAFETY-83). Not a maintenance HOLD at N=1 (DECISIONS/SAFETY-83).
# at N=1 (SAFETY-83); over a HOLD, `_peak_over_hold()` (SAFETY-86).
# A cheap-window charge is suspended while the peak guard holds and, at N>=2,
# sized to the quarter's headroom (SAFETY-84).
P_MAINT_SHAPE = 40 # maintenance shaping the loop (charge-only)
@@ -202,6 +202,10 @@ CLAIM_SCOPE = {
# sibling would have to give against it. Never filed at N=1.
# DECISIONS/SAFETY-84-charge-at-two.md.
"charge": SCOPE_SITE,
# main.py: `Claim.limit("hold", P_MAINTENANCE, hi=...)`, SAFETY-86. While a
# holding pack gives the peak shortfall at N>=2, the site target stays at
# the peak floor, what the pin and the others place. Never filed at N=1.
"hold": SCOPE_SITE,
# --- UNIT: about one pack, one inverter, one device -------------------
# main.py: `Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max,
@@ -631,7 +635,7 @@ def site_resolve(claims: list) -> Resolution:
def resolve(claims: list, *, units=None, weights=None, live=None,
tick=None) -> Resolution:
tick=None, reserve=()) -> Resolution:
"""The three stages, in order, over one cycle's claims.
`units` is the site's roster of inverters. Left None it is derived from the
@@ -733,6 +737,13 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
gate, held = _unit_modes(out, site_claims, bounds, live)
if callable(weights):
weights = weights(out.target_w)
# SAFETY-87/91: units under their peak reserve weigh 0 while the site
# gives, so the others carry the target first. An Off battery gives the
# peak's shortfall, so it goes before them when no other unit is left.
reserve = set(reserve) if out.target_w > 0.0 else set()
if held and reserve and not set(bounds) - held - reserve:
held = set()
held = held | reserve
if held:
weights = {u: (0.0 if u in held else
(1.0 if weights is None else weights.get(u, 1.0)))
+24
View File
@@ -260,6 +260,22 @@ def loop_clamp_note(d) -> str:
return ""
def shortfall_note(named) -> str:
"""SAFETY-85: the alarm text while another owner sets the batteries.
`named` is `[(inverter name or None, asked W, given W)]`, one per battery
that gave less than asked."""
return f"{REASON_CODE}: " + ", ".join(
f"{name or 'the battery'} gives {given:+.0f} W of the {asked:+.0f} W "
f"asked" for name, asked, given in named)
def delivery_short(asked_w, given_w, margin_w) -> bool:
"""SAFETY-85: did a battery asked to discharge give more than `margin_w`
less? A charge is never short: a full pack tapers (SAFETY-42 section 7)."""
asked, given = float(asked_w), float(given_w)
return asked > 0.0 and asked - given > abs(float(margin_w))
def write_clamp_note(named) -> str:
"""The write-side alarm text: `named` is `[(inverter name or None,
ClampResult)]` for every inverter whose write was cut back this cycle.
@@ -382,6 +398,10 @@ class ClampWatchdog:
self.over_rating = 0 # bug=True: "Power request too high"
self.bound = 0 # bug=False: "Battery is not keeping up"
self.episodes = 0 # rising edges
# SAFETY-85: episodes with a bug=False engagement. The status row
# counts these; counting cycles read 861 on one rig run.
self.bound_episodes = 0
self._bound_counted = False
self.since: datetime | None = None
self.last_alarm: datetime | None = None
self.reason = ""
@@ -436,6 +456,10 @@ class ClampWatchdog:
self.spoken = False
self.bug = False
self.bug_spoken = False
self._bound_counted = False
if not bug and not self._bound_counted:
self._bound_counted = True
self.bound_episodes += 1
if bug or not self.bug:
self.reason = reason
self.facts = tuple(facts)
+23 -3
View File
@@ -382,6 +382,9 @@ class Decision:
# SAFETY-52. The refused direction to carry back in (+1 discharge, -1
# charge, 0 none), or None when the caller does not carry it.
refused: int | None = None
# SAFETY-85. The `bound` held the integrator or the command this cycle,
# so the batteries' limits, not the loop, decided it.
held: bool = False
def slew_limit_w(tuning: Tuning) -> float:
@@ -481,6 +484,8 @@ def compute(
refused: int | None = None,
probe: bool = False,
bound: tuple | None = None,
gave_nothing: bool | None = None,
idle_w: float = 0.0,
) -> Decision:
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
@@ -494,6 +499,13 @@ def compute(
`bound` OPT-11: `(lo, hi)`, what the batteries may place this tick with
their modes applied (`allocator.loop_bound()`), or None. See
MODE_BOUND below. None is the law as it was.
`gave_nothing` SAFETY-94: the caller's span check - the reading stayed
under REFUSED_BELOW_W, one way, for SETTLE_CYCLES spanning
REFUSED_SPAN_S. A refusal engages only with it. None leaves it
out (compute() alone, as before).
`idle_w` SAFETY-97: what `actual_w` reads while the batteries give
nothing (app/idle.py). The refusal's band and its end are
centred on it. 0 is the law as it was.
"""
reason = "tracking"
# OPT-11: a bound that leaves out 0 W would FORCE a direction, which no
@@ -614,11 +626,13 @@ def compute(
# OPT-11: the integrator does not wind past what the modes let the
# batteries place. Only a side tighter than the rail is left in `bound`
# (above), so this never takes SAFETY-03's `i_clamped` away from the rail.
held_by_bound = False
if bound is not None:
held = max(float(bound[0]), min(float(bound[1]), i_w))
if held != i_w:
i_w = held
reason = MODE_BOUND
held_by_bound = True
# ⚠️ Applied EVERY cycle, frozen or not: the freeze is conditional, this
# bound is not. It is what makes the worst-case unwind time finite and
@@ -812,13 +826,18 @@ def compute(
# `saturation_w` is a refusal, not a slow plant. The drop to 0 skips the slew
# limit because it only removes an ask the battery is not meeting.
if refused is not None:
# SAFETY-94: and only when the reading has given nothing for the
# whole span, not just now. `sat_count` also counts the cycles a
# healthy battery spends reversing through its lag (-2000 W towards
# +800 W): at a 1 s meter its 12 cycles end as the reading passes 0.
if (refused == 0 and frozen and sat_count >= SETTLE_CYCLES
and abs(prev_w) > tuning.saturation_w
and abs(actual_w) < REFUSED_BELOW_W):
and abs(actual_w - idle_w) < REFUSED_BELOW_W
and gave_nothing is not False):
refused = 1 if prev_w > 0 else -1
if refused and refused * i_w < 0:
refused = 0 # the loop wants the other way
elif refused and refused * actual_w >= PROBE_ANSWER_W:
elif refused and refused * (actual_w - idle_w) >= PROBE_ANSWER_W:
refused = 0 # it delivers: a probe answered,
# or a slow reading arriving
elif refused:
@@ -839,6 +858,7 @@ def compute(
if inside != target:
target = inside
reason = MODE_BOUND
held_by_bound = True
# SAFETY-48's lead cap above may pull the integrator back toward the
# slewed command; what is CARRIED stays inside the bound too.
i_w = max(float(bound[0]), min(float(bound[1]), i_w))
@@ -860,7 +880,7 @@ def compute(
return Decision(float(target), sat_count, frozen, reason, float(i_w),
i_clamped, clamped, float(i_bound_w), float(want),
float(clamped_w), refused)
float(clamped_w), refused, held_by_bound)
def maintenance_charge_floor(
+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()`
+20 -14
View File
@@ -20,7 +20,7 @@ from datetime import datetime, timedelta, timezone
from . import arbitrage, messages, peakguard, uitext
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
from .maintenance import IDLE as M_IDLE
from .maintenance import IDLE as M_IDLE, next_slot_start
from .packmode import CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, SOLAR_FIRST
from .price import Absence, Uncertainty, brussels
from .static_tariff import static_plan
@@ -439,8 +439,8 @@ def solar_block(c, now):
def maintenance_runs(c, day0, day1, now, shadow):
"""The monthly battery cycle: under way now, or starting later today.
The cycle starts in the hour `maintenance_start_hour` names, read as the
control cycle reads it (UTC hour of `now`), shown in local time."""
The cycle starts in the hour `maintenance_start_hour` names, read in the
site's zone exactly as the control cycle reads it (MAINT-10)."""
runs, lines = [], []
waiting = []
for i, (inv, m) in enumerate(zip(c.inverters, c.maints)):
@@ -459,10 +459,9 @@ def maintenance_runs(c, day0, day1, now, shadow):
if not m.due(now) and (m.next_due(now) is None
or m.next_due(now) >= day1):
continue
start = now.replace(hour=cfg.start_hour, minute=0, second=0,
microsecond=0)
if start + timedelta(hours=1) <= now:
start += timedelta(days=1)
start = next_slot_start(now, cfg.start_hour, m.tz)
if start is None:
continue
due_at = m.next_due(now)
if due_at is not None and due_at > start:
continue
@@ -904,7 +903,9 @@ def month_peak(c, now):
if pg is not None:
out["period_w"] = pg.projected_w
ceiling = pg.ceiling_w or limit_w
out["line"] = line_period(out["period_w"], ceiling, c.shadow)
out["line"] = line_period(out["period_w"], ceiling, c.shadow,
unmeasured=getattr(c, "peak_unmeasured_s", 0.0)
> 0.0)
return out
@@ -923,24 +924,25 @@ def state_of(c, level, shadow):
def build(c, *, level: str, checks: list, now: datetime | None = None,
lang: str = "en") -> dict:
lang: str = "en", held: bool | None = None) -> dict:
"""The structured half of `/status`, in `lang` (UX-13 P5). Pure reads of
the controller."""
_LANG[0] = uitext.lang_of(lang)
try:
return _build(c, level=level, checks=checks, now=now)
return _build(c, level=level, checks=checks, now=now, held=held)
finally:
_LANG[0] = "en"
def _build(c, *, level, checks, now):
def _build(c, *, level, checks, now, held=None):
now = now or datetime.now(timezone.utc)
shadow = bool(c.shadow)
kind = state_of(c, level, shadow)
# A red banner means every inverter is at 0 W (the status cascade), so
# nothing is planned until it clears. Shadow mode still plans: it is
# what the add-on would do.
held = level == "bad" and not shadow
# what the add-on would do. `held` False: an alarm while control runs
# (SAFETY-87, the peak alarms).
held = level == "bad" and not shadow and held is not False
running = (bool(c.auto) or shadow) and not held
settled = all(at_zero(inv) for inv in c.inverters)
ctl = next((r for r in checks if r.get("id") == "controller"), None)
@@ -1293,8 +1295,11 @@ def line_peak_not_covered() -> str:
return _t("peak_not_covered")
def line_period(period_w, limit_w, shadow) -> str:
def line_period(period_w, limit_w, shadow, unmeasured=False) -> str:
step = _w(shadow, "step_in", kw=_kw(limit_w))
if unmeasured:
# SAFETY-90: the add-on started during this period.
return _t("period_unmeasured") + " " + step
if period_w is None:
return step
return _t("period", kw=_kw(period_w)) + " " + step
@@ -1368,6 +1373,7 @@ def _samples() -> list:
("plan", line_peak_limit(3500, sh)),
("plan", line_period(1240, 3500, sh)),
("plan", line_period(None, 3500, sh)),
("plan", line_period(1240, 3500, sh, unmeasured=True)),
("plan", control_text(True, sh)),
("plan", control_text(False, sh))]
out += [("plan", run_grid_why(0.1412)), ("plan", run_grid_amount(1.23)),
+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:
File diff suppressed because it is too large Load Diff
+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 ----------------------------------------------------------
+30 -4
View File
@@ -217,7 +217,7 @@ def peak_saturated(ceiling_w, *, shadow: bool = False) -> Notice:
def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
packs=()) -> Notice:
packs=(), others: bool = False) -> Notice:
"""SAFETY-24/25/32: the peak guard may not draw the battery any lower.
The last sentence is SAFETY-25 and must stay: the reserve binds the peak
@@ -227,7 +227,8 @@ def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
roster means some inverter's cut-out level is missing (the peak reserve
itself always has a value) - so the action names the cut-out level.
Both are named by their setting labels, "Peak reserve (%)" and "Battery
cut-out level (%)".
cut-out level (%)". `others`: the other batteries give all they can and
it is not enough (SAFETY-87), so the limit MAY be passed.
"""
if soc_unread:
why = "The battery level cannot be read"
@@ -241,6 +242,12 @@ def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
why = (f"The battery of {names(packs)} is at its peak reserve" if packs
else "The battery is at its peak reserve")
act = "Switch off large loads, or lower the peak reserve."
if others:
return Notice(ID_PEAK_RESERVE, ALARM, T_PEAK_RESERVE,
f"{why}, and the other batteries cannot keep this "
f"15-minute period under your {watts(ceiling_w)} peak "
f"limit alone. {act} Home use can still draw a battery "
f"below its reserve, down to its cut-out level.")
return Notice(ID_PEAK_RESERVE, ALARM, T_PEAK_RESERVE,
f"{why}, so this 15-minute period will go over your "
f"{watts(ceiling_w)} peak limit and raise this month's peak. "
@@ -470,8 +477,9 @@ def row_peak_saturated(ceiling_w) -> str:
def row_peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
packs=()) -> str:
"""SAFETY-24/25/32. The second sentence is SAFETY-25 and must stay."""
packs=(), others: bool = False) -> str:
"""SAFETY-24/25/32. The last sentence is SAFETY-25 and must stay.
`others`: as in `peak_reserve()` (SAFETY-87)."""
if soc_unread:
why = "Battery level cannot be read"
elif floor_unknown:
@@ -479,6 +487,10 @@ def row_peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
else:
why = (f"{names(packs)} battery at its peak reserve" if packs
else "Battery at its peak reserve")
if others:
return (f"{why}. The others cannot hold the {watts(ceiling_w)} peak "
f"limit alone. Home use still draws it lower, to the cut-out "
f"level.")
return (f"{why}, so the {watts(ceiling_w)} peak limit will be passed. Home "
f"use still draws the battery below it, down to the cut-out level.")
@@ -781,6 +793,12 @@ def row_peak_guard_blind(why: str) -> str:
f"meanwhile")
def row_peak_guard_unmeasured() -> str:
"""SAFETY-90. Before the first reading; that part counts as high use."""
return ("Peak guard: part of this 15-minute period was not measured, "
"counted as high use")
def row_peak_guard_on(floor_w, limit_w, *, shadow: bool = False) -> str:
if shadow:
return (f"Peak guard: would ask for {watts(floor_w)} to stay under "
@@ -836,6 +854,7 @@ WHY_LIMIT = {
"reserve": "the battery reserve",
"maintenance": "battery maintenance",
"charge": "battery maintenance",
"hold": "battery maintenance",
"refused": "a battery that refuses power",
}
@@ -899,6 +918,7 @@ HINTS = {
"peak_on": "Peak guard started",
"peak_off": "Peak guard idle",
"peak_blind": "Peak guard cannot read the meter well",
"peak_gap": "Meter is back, the peak guard counts the missed time as high use",
"peak_saturated": T_PEAK_SATURATED,
"peak_reserve": T_PEAK_RESERVE,
"meter_frozen": T_METER_FROZEN,
@@ -1013,6 +1033,9 @@ def status_samples() -> list:
("row_peak_guard_off", AMBER, row_peak_guard_off()),
("row_peak_reserve", RED, row_peak_reserve(
5000, soc_unread=False, floor_unknown=False, packs=["inverter-b"])),
("row_peak_reserve", RED, row_peak_reserve(
5000, soc_unread=False, floor_unknown=False, packs=["inverter-b"],
others=True)),
("row_peak_reserve", RED, row_peak_reserve(
5000, soc_unread=True, floor_unknown=False)),
("row_peak_reserve", RED, row_peak_reserve(
@@ -1081,6 +1104,7 @@ def status_samples() -> list:
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, True)),
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, False)),
("row_peak_guard_quiet", GREEN, row_peak_guard_quiet()),
("row_peak_guard_unmeasured", AMBER, row_peak_guard_unmeasured()),
*(("row_peak_guard_blind", AMBER, row_peak_guard_blind(k))
for k in BLIND_WORDS),
("row_peak_guard_on", AMBER, row_peak_guard_on(1200, 2500)),
@@ -1115,6 +1139,8 @@ def samples() -> list:
peak_reserve(5000, soc_unread=False, floor_unknown=False),
peak_reserve(5000, soc_unread=False, floor_unknown=False,
packs=["inverter-b"]),
peak_reserve(5000, soc_unread=False, floor_unknown=False,
packs=["inverter-b"], others=True),
peak_reserve(5000, soc_unread=True, floor_unknown=False),
peak_reserve(5000, soc_unread=False, floor_unknown=True),
circulating(["inverter-a"], ["inverter-b"]),
+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."""
+99 -1
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
@@ -397,7 +399,7 @@ function, so whichever way the owner answers, this file does not change and does
not need re-testing. Nothing here indexes a phase.
"""
from dataclasses import dataclass
from dataclasses import dataclass, replace
from . import messages
from .arbiter import ACTUATOR_SITE, P_PEAK, Claim
@@ -490,11 +492,18 @@ class PeakDecision:
# floor refused to size it. Mutually exclusive with `saturated` by
# construction: this branch asks for nothing, so it cannot have run out of
# inverter. `soc_floor_reason` says WHICH of the two ways it bound.
# SAFETY-87: at N>=2 it may ask the batteries above their reserve for
# `able_w`, all they have, when that is not enough (`place_on_able()`).
floor_limited: bool = False
soc: float | None = None
soc_floor: float | None = None
soc_floor_reason: str | None = None
reason: str = ""
# SAFETY-87, N>=2 only: the batteries at or under their own reserve,
# which give nothing for the peak, and what the others can give (None
# when they are not asked). See `place_on_able()`.
held: tuple = ()
able_w: float | None = None
def explain(self) -> str:
"""Idle: the status row's plain words (UX-08). Engaged: `reason`, the
@@ -592,6 +601,57 @@ def blindness(projection, max_hold_s, frozen: bool) -> str | None:
return None
# SAFETY-90. Seconds of uncounted time below which nothing is filled: float
# noise between the averager's sums and the wall clock, never a real gap.
GAP_TOL_S = 1e-3
def gap_w(ceiling_w, *seen_w) -> float:
"""SAFETY-90: the watts a meter gap is counted at. The highest of the
limit and `seen_w`, the import with the batteries at 0 W (grid + their
discharge) just before and just after the gap; None entries are skipped.
DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
return max([float(ceiling_w)] + [float(w) for w in seen_w if w is not None])
def fill_gaps(projection, *, tail_s, max_hold_s, unseen_s, gap_w, unseen_w,
held_ws=0.0):
"""SAFETY-90: `projection` with the seconds the averager did not count
(a meter gap past `max_hold_s`) counted at `gap_w`, and those from before
this run's first reading (`unseen_s`) at `unseen_w`. `held_ws` is added
too: what the gap's first `max_hold_s`, credited at the reading before
it, lacks to reach `gap_w`. Returns
`(projection, gap seconds filled, unseen seconds filled)`.
`tail_s` is the age of the held reading: up to `max_hold_s` of it is left
as before (the averager credits it once the next reading lands). Past
that the gap is still open, nothing is filled and `blindness()` answers
as before. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
if projection.block_start is None or max_hold_s is None:
return projection, 0.0, 0.0
wall = float(projection.elapsed_s)
tail = min(max(0.0, float(tail_s)), wall)
if tail > float(max_hold_s):
return projection, 0.0, 0.0
covered = wall - uncovered_s(projection)
missing = wall - covered - tail
if missing <= GAP_TOL_S:
missing = 0.0
if held_ws <= 0.0:
return projection, 0.0, 0.0
unseen = min(missing, max(0.0, float(unseen_s)))
gap = missing - unseen
add_ws = gap * float(gap_w) + unseen * float(unseen_w) + float(held_ws)
done = covered + missing
return (replace(
projection,
projected_w=float(projection.projected_w) + add_ws / QUARTER_S,
elapsed_w=((float(projection.elapsed_w) * covered + add_ws) / done
if done > 0.0 else float(projection.elapsed_w)),
covered_frac=(done / wall if wall > 0.0
else float(projection.covered_frac))), gap, unseen)
def discharge_permitted(soc, soc_floor) -> str | None:
"""SAFETY-24. May the guard size a forced discharge, or why may it not?
@@ -733,6 +793,43 @@ def binding_soc_floor(packs, soc_floor):
return max(floors)
def reserve_split(packs, soc_floor) -> tuple[list, tuple]:
"""SAFETY-87: `(packs the guard may spend, names of those it may not)`,
each pack graded on its own floor by `pack_permissions()`."""
perms = pack_permissions(packs, soc_floor)
return ([p for p, (_n, why) in zip(packs, perms) if why is None],
tuple(n for n, why in perms if why is not None))
def place_on_able(decision, held, *, able_w, soc, soc_floor) -> PeakDecision:
"""SAFETY-87: `decision` with the batteries in `held` (at or under their
own reserve) giving nothing for the peak. `able_w`, `soc` and `soc_floor`
describe the others, as `decide()`'s arguments do for the roster. A
floor-limited decision is re-taken on them: they are asked for the need
up to `able_w`, and it stays floor-limited only if that is not enough.
`soc` and `soc_floor` keep the roster's verdict, as `decide()` echoed it.
DECISIONS/SAFETY-87-guard-uses-able-batteries.md."""
held = tuple(held)
if not decision.floor_limited or decision.wanted_w is None \
or discharge_permitted(soc, soc_floor) is not None:
return replace(decision, held=held)
wanted = float(decision.wanted_w)
floor = min(wanted, float(able_w))
head = (f"this 15-minute period heads for {decision.projected_w:.0f} W "
f"against a {decision.ceiling_w:.0f} W limit "
f"({decision.ceiling_from}), aiming at {decision.target_w:.0f} W. "
f"At the peak reserve, so giving nothing for the peak: "
f"{messages.names(held)}. The others are asked for {floor:+.0f} W")
if floor >= wanted:
return replace(decision, floor_limited=False, floor_w=floor,
soc_floor_reason=None, held=held, able_w=float(able_w),
reason=f"PEAK_GUARD: {head}")
return replace(decision, floor_w=floor, held=held, able_w=float(able_w),
reason=(f"PEAK_GUARD_FLOOR_LIMITED: {head}, all they can "
f"give, but {wanted:+.0f} W was needed, so this "
f"period may go over the limit"))
def decide(projection, *, batt_w, cap_w, margin_w, reading, achievable_w, soc,
soc_floor, max_hold_s=None, frozen: bool = False) -> PeakDecision:
"""The whole guard. Pure: no clock, no I/O, no state carried between calls.
@@ -1097,5 +1194,6 @@ def claim(decision: PeakDecision) -> Claim | None:
return Claim.limit(SOURCE, P_PEAK, lo=float(decision.floor_w),
reason=("blind" if decision.blind
else "SoC floor" if decision.floor_limited
and not decision.floor_w
else "quarter peak"),
actuator=ACTUATOR_SITE)
+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.12.0"
version: "0.16.0"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
@@ -630,7 +630,8 @@ options:
# contradiction. Changing one of these numbers now moves every consumer.
maintenance_enabled: false
maintenance_interval_days: 28
maintenance_start_hour: 10
# MAINT-10: a LOCAL hour, in Home Assistant's time zone.
maintenance_start_hour: 19
# ⚠️ BOTH MUST STAY AT OR BELOW max_w, and that is why they are 2000 rather
# than the 2500 they shipped as. The cheap-window charge shaping asks the
+8 -5
View File
@@ -149,7 +149,7 @@ check("...and `source` is still the first positional argument, which is what "
Claim.limit("device", P_SAFETY, -1, 1).source == "device")
print()
print("ARB-03 2. the split: fifteen sources, each classified where it is BUILT")
print("ARB-03 2. the split: sixteen sources, each classified where it is BUILT")
# The classification is graded against what each claim is ABOUT, restated here
# so that the table in arbiter.py and the expectation in a test are not the same
# sentence read twice. test_peakguard.py part 10f pins this set against main.py's
@@ -179,6 +179,9 @@ SITE_SOURCES = {
"charge": "the site target may not go above a cheap-window maintenance "
"charge, or a sibling would give against it. Filed at N>1 "
"only",
# SAFETY-86.
"hold": "the site target stays at the peak floor while a holding pack "
"gives the shortfall. Filed at N>1 only",
}
UNIT_SOURCES = {
"device": "one inverter's own reported min/max rating",
@@ -210,9 +213,9 @@ for _src, _why in sorted(SITE_SOURCES.items()):
print(f" site {_src:<11} {_why}")
for _src, _why in sorted(UNIT_SOURCES.items()):
print(f" unit {_src:<11} {_why}")
check("all FIFTEEN claim sources are classified, and only those fifteen",
check("all SIXTEEN claim sources are classified, and only those sixteen",
set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES))
check("the eight that are about the GRID CONNECTION are site claims",
check("the nine that are about the GRID CONNECTION are site claims",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE} == set(SITE_SOURCES))
check("the seven that are about a PACK, a DEVICE or one write are per-inverter",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} == set(UNIT_SOURCES))
@@ -234,7 +237,7 @@ check("...and with none configured at all, likewise",
all(actuator_for(s, "inverter-a", ()) == ACTUATOR_SITE for s in CLAIM_SCOPE))
check("with TWO the identity breaks and the split becomes real: the seven "
"per-inverter sources route to the unit this controller drives, the "
"eight site sources stay on the grid connection",
"nine site sources stay on the grid connection",
{s: actuator_for(s, "inverter-a", TWO) for s in CLAIM_SCOPE}
== {**{s: "inverter-a" for s in UNIT_SOURCES},
**{s: ACTUATOR_SITE for s in SITE_SOURCES}})
@@ -515,7 +518,7 @@ check("...and at N=1, where every source legitimately routes to the site, the "
Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0)]) == [])
check("an unclassified ninth source is a violation wherever it is filed",
scope_violations([Claim.limit("reserve", P_LOOP, hi=0.0)]) != [])
check("and the eight, routed by actuator_for() at a two-inverter site, are "
check("and the sixteen, routed by actuator_for() at a two-inverter site, are "
"clean - which is what says the router and the checker agree",
scope_violations(
[Claim.limit(s, P_SAFETY, -1, 1,
+14 -9
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
@@ -553,12 +556,14 @@ check("the status page flags it WHILE it is engaged - amber, since it is the "
any(not c["ok"] and c["warn"] and c.get("id") == "not_keeping_up"
and c["text"].startswith(messages.T_LOOP_BOUND)
for c in ctl_r.red_snapshot))
check("...and once clear, a warn row still says how many times it engaged, "
"under the integrator bound's own title",
# SAFETY-85: the row counts episodes, not cycles.
check("...and once clear, a warn row still says how many times it happened "
"(episodes, not cycles), under the integrator bound's own title",
any(c["warn"] and c.get("id") == "not_keeping_up"
and c["text"].startswith(messages.T_LOOP_BOUND)
and messages.times(ctl_r.clamp_wd.bound) in c["text"]
and "fine now" in c["text"] for c in ctl_r.checks()))
and c["text"] == messages.row_before(
messages.T_LOOP_BOUND, ctl_r.clamp_wd.bound_episodes)
for c in ctl_r.checks())
and 0 < ctl_r.clamp_wd.bound_episodes < ctl_r.clamp_wd.bound)
# UX-07 item 1: the after-episode row used to say "output clamp ... engaged N
# time(s)" for integrator-bound cycles. Each count now sits under its own name.
check("UX-07 item 1: bound-only engagements are counted as the bound, and no "
+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")
+53 -11
View File
@@ -108,15 +108,45 @@ A5DDDAD = {
_claims, _undo_c = spy_claims()
_bounds, _undo_b = spy_bounds()
try:
_now = rig.digests()
_now = {k: rig.digest(rig.run(**v)[0])
for k, v in rig.SCENARIOS.items() if k != "n2_maint"}
_now["resolve"] = rig.digest(rig.resolve_rows())
finally:
_undo_b()
# SAFETY-85: n2_maint (maintenance at N=2) is the one run with a bound.
_bounds_m, _undo_b = spy_bounds()
try:
_now["n2_maint"] = rig.digest(rig.run(**rig.SCENARIOS["n2_maint"])[0])
finally:
_undo_c()
_undo_b()
# SAFETY-78 changes n2_maint ON PURPOSE: on a5dddad and 5a4cdc2 its N=2 drain
# was refused on 392 of 540 cycles (site at 0 W); now none is. Printed on the
# SAFETY-78 tree the same way. Every other digest is unchanged.
# SAFETY-85 changes it again ON PURPOSE: while A's drain pin stands the loop
# is bounded by what the batteries can place, so when A drops out at t=1655 B
# follows the house down at once instead of unwinding 920 W too high. Was
# cd4d9cb5b7e29ff4ab4fe65dab5106683d8a599f09323fbdee4018ad9b28fcd1.
SAFETY78_N2_MAINT = \
"cd4d9cb5b7e29ff4ab4fe65dab5106683d8a599f09323fbdee4018ad9b28fcd1"
"1afcd46b1b987868b110d2b0f4a7555848c9aa4728a836d4a2ce2a2107268497"
# SAFETY-95 changes it again ON PURPOSE: A's drain ends at t=1655 (its
# refusal), and B now stays at the house's 1420 W instead of jumping to
# 2500 W (1100 W export) and slewing back. Rows differ only at 1655-2105 s.
SAFETY95_N2_MAINT = \
"949e7c093dcf065257417de3f2f908e4d57311790abc9fba0a09aa4b5d64c0d2"
# SAFETY-91 changes n2 and n2_off ON PURPOSE: from t=1600 battery A is at its
# 20 % reserve, so B carries the house first and A gives last. With
# `_held_back()` reverted to SAFETY-87's (held only for the peak) both are
# a5dddad's again, which the check below grades.
SAFETY91_N2 = \
"9535c08f556d6d50bec8fb0872445173480c29b10df1a7c437ef73d6ddb2814d"
_real_hb = app_main.Controller._held_back
app_main.Controller._held_back = lambda self: self._peak_reserve()
try:
_n2_s87 = rig.digest(rig.run(**rig.SCENARIOS["n2"])[0])
finally:
app_main.Controller._held_back = _real_hb
_S91 = ("n2", "n2_off", "n2_maint")
for k in A5DDDAD:
print(f" {k:7} {'same' if _now[k] == A5DDDAD[k] else 'DIFFERS'} "
f"{_now[k][:16]}")
@@ -124,17 +154,29 @@ check("⭐ at N=1 and N=2, with the option absent and with it false, every "
"command, reason and allocation record over 540 cycles each is "
"byte-identical to a5dddad (house steps, a solar surplus, a battery "
"at its cut-out level, at N=2 a refusal with probes, both batteries "
"due for maintenance; n2_maint apart, below), and so is the arbiter "
"on 6000 claim lists",
{k: v for k, v in _now.items() if k != "n2_maint"}
== {k: v for k, v in A5DDDAD.items() if k != "n2_maint"})
"due for maintenance; n2, n2_off and n2_maint apart, below), and so "
"is the arbiter on 6000 claim lists",
{k: v for k, v in _now.items() if k not in _S91}
== {k: v for k, v in A5DDDAD.items() if k not in _S91})
# SAFETY-94: with SAFETY-87's held rule, A (cut out at 1600 s) now trips the
# stale gate at 1640 s, not 1635 s - 35 s after its command moved rather than
# after its reading last changed - so that run is no longer a5dddad's. It was
# a5dddad's (a60003b1...) before SAFETY-94. The shipped n2 is unchanged.
SAFETY94_N2_S87 = \
"4ddea7ef9dc1efa437af899675db442cd531b084ae4cec35951d7c7bb3d6d8b5"
check("...n2 and n2_off are the SAFETY-91 digest (A at its reserve gives "
"last), and SAFETY-87's held rule gives SAFETY-94's",
_now["n2"] == _now["n2_off"] == SAFETY91_N2 != A5DDDAD["n2"]
and _n2_s87 == SAFETY94_N2_S87)
check("...and n2_maint is the SAFETY-78 digest: the N=2 drain that a5dddad "
"refused on 392 of 540 cycles now runs (test_safety78.py)",
_now["n2_maint"] == SAFETY78_N2_MAINT != A5DDDAD["n2_maint"])
check("...and no cycle in those runs filed a `mode` claim or handed "
"`compute()` a bound",
"refused on 392 of 540 cycles now runs (test_safety78.py), bounded by "
"SAFETY-85, its end handed back by SAFETY-95",
_now["n2_maint"] == SAFETY95_N2_MAINT != A5DDDAD["n2_maint"])
check("...and no cycle in those runs filed a `mode` claim, and only "
"n2_maint's (SAFETY-85: maintenance at N=2) handed `compute()` a bound",
_claims and not any(c.source == "mode" for cl in _claims for c in cl)
and _bounds and set(_bounds) == {None})
and _bounds and set(_bounds) == {None}
and any(b is not None for b in _bounds_m))
# ============================================================================ #
+9 -6
View File
@@ -67,8 +67,10 @@ for k in ED739D4:
# SAFETY-84 changes n2_maint ON PURPOSE: on 9d5b7d1 its N=2 cheap-window
# charge was refused on 214 of 540 cycles (site at 0 W); now none is.
# Printed on the SAFETY-84 tree the same way (margin 0).
# SAFETY-85: the loop is bounded while A charges (reasons change, commands
# do not). Was 95722b262f1bcf504493d7b5babc3895799ac56cc15f5d2ccdbe7c9f04adfcbb.
SAFETY84_N2_MAINT = \
"95722b262f1bcf504493d7b5babc3895799ac56cc15f5d2ccdbe7c9f04adfcbb"
"ef97ae2e13668b3e0b2d22f3f5f37835ea46cd306c2cc475c97e284f9dcc6545"
check("⭐ at N=1 and N=2, with and without battery modes and with a "
"maintenance charge, margin 0 is "
"byte-identical to ed739d4 (n2_maint apart, below)",
@@ -173,14 +175,15 @@ check("...and decide() sizes the floor to land the quarter 50 W under it "
# ============================================================================ #
print("\n3. the bill and the reports keep the real cap")
_c = _runs["n1_off", None][1]
_pk = _c.peak.peak().peak_w
_t = rig.rig.BASE # the runs' clock, not the wall clock (a new month)
_pk = _c.peak.peak(_t).peak_w
print(f" month peak {_pk:.0f} W, tracker cap {_c.peak.cap_w:.0f} W, "
f"warning {_c.peak.warning_frac():.3f}, headroom "
f"{_c.peak.headroom_w():.0f} W")
f"warning {_c.peak.warning_frac(_t):.3f}, headroom "
f"{_c.peak.headroom_w(_t):.0f} W")
check("test_billing_uses_cap: the month-peak tracker (bill, headroom, 80 % "
"warning) is built on the real cap, not cap - margin",
_c.peak.cap_w == CAP and _c.peak.warning_frac() == _pk / CAP
and _c.peak.headroom_w() == CAP - _pk)
_c.peak.cap_w == CAP and _c.peak.warning_frac(_t) == _pk / CAP
and _c.peak.headroom_w(_t) == CAP - _pk)
_eng = [g for r in _runs["n1_off", None][0] for g in (r[-1][1],)
if g and g[0] and g[1]]
print(f" {len(_eng)} engaged cycles; one: {_eng[0][4] if _eng else None}")
+74 -48
View File
@@ -109,6 +109,7 @@ import re
import shutil
import sys
import tempfile
import types
import time
from datetime import datetime, timedelta, timezone
from math import inf, isinf
@@ -2537,12 +2538,18 @@ print(f" banner ({len(_pay25['banner'])} chars): {_pay25['banner']}")
# that is going over the ceiling.
# UX-08: the banner is the notification's own title, and the row below keeps
# the SAFETY-25 sentence.
check("the Not ready banner is a short headline - the notification's title - "
"and NOT the row repeated at the top of the page. A headline that is a "
"paragraph is a headline nobody reads",
# SAFETY-87: control goes on while the guard is floor-limited, so the banner
# does not say "Not ready". This fixture also has a row that does stop control
# (no SoC entity), which outranks it; the peak row alone is graded below.
c25.checks = lambda: rows25
_pay25 = c25.status_payload()
del c25.checks
print(f" banner, peak row alone: {_pay25['banner']}")
check("the banner is a short headline - the notification's title, without "
"'Not ready' (control goes on) - and NOT the row repeated at the top of "
"the page. A headline that is a paragraph is a headline nobody reads",
_pay25["level"] == "bad"
and _pay25["banner"] == (f"{messages.NOT_READY} — "
f"{messages.T_PEAK_RESERVE}")
and _pay25["banner"] == messages.T_PEAK_RESERVE
and not _row_scope(_pay25["banner"]))
check("...and shortening the banner did NOT shorten the row: the SAFETY-25 "
"sentence is still what the checks list carries, which is the surface the "
@@ -2691,9 +2698,12 @@ check("...and so does the persistent notification, which outlives the page and "
# site `hi` at P_MAINT_SHAPE, filed only at N>=2 while a pack's cheap-window
# maintenance charge makes it charge, equal to that pack's own `hi`. Never at
# N=1. DECISIONS/SAFETY-84-charge-at-two.md.
# SAFETY-86 added `hold`: a site `hi` at the peak floor while a holding pack
# gives at N>=2. Not a reserve either. DECISIONS/SAFETY-86-peak-over-hold.md.
CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak",
"arbitrage", "maintenance", "loop", "circulation",
"sign_site", "pack", "refused", "mode", "drain", "charge"}
"sign_site", "pack", "refused", "mode", "drain", "charge",
"hold"}
# The two appends that file a claim BUILT ELSEWHERE in the method. Named here so
# that a THIRD such indirection reads as an unrecognised append rather than as a
# silent gap in the scan.
@@ -2735,7 +2745,7 @@ check("every `claims.append(...)` in main.py's cycle is one this scan can READ "
"- a claim filed through a shape it cannot parse has to fail here rather "
"than be counted as nothing, which is how a scan like this goes quietly "
"vacuous", len(_appends) >= 10 and None not in _filed)
check("...and the sources that cycle can file are EXACTLY these FIFTEEN. THIS "
check("...and the sources that cycle can file are EXACTLY these SIXTEEN. THIS "
"is what makes part 10 a tripwire: replay_loop() hand-builds the claim "
"list, so it cannot see a claim main.py adds - and a loop-side SoC "
"reserve is a new source. Filing one takes this line red on the line it "
@@ -2875,7 +2885,7 @@ print(" site: " + ", ".join(sorted(s for s, v in CLAIM_SCOPE.items()
+ " | per-inverter: "
+ ", ".join(sorted(s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT)))
check("...and the split is the one ARB-03 argued, with SAFETY-31's and "
"SAFETY-30's, OPT-09's, SAFETY-78's and SAFETY-84's additions: the eight about the GRID CONNECTION "
"SAFETY-30's, OPT-09's, SAFETY-78's, SAFETY-84's and SAFETY-86's additions: the nine about the GRID CONNECTION "
"are site claims and the five about a pack, a device or one write are "
"per-inverter. `circulation` is a site claim because its quantity is "
"defined over the roster and is identically zero for any one unit; "
@@ -2885,7 +2895,7 @@ check("...and the split is the one ARB-03 argued, with SAFETY-31's and "
"against that unit's OWN measured power",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE}
== {"loop", "peak", "arbitrage", "safety", "circulation", "sign_site",
"drain", "charge"}
"drain", "charge", "hold"}
and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT}
== {"device", "supervised", "failsafe", "maintenance", "pack",
"refused", "mode"})
@@ -2990,7 +3000,8 @@ check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second "
"loop": ACTUATOR_SITE, "peak": ACTUATOR_SITE,
"arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE,
"circulation": ACTUATOR_SITE, "sign_site": ACTUATOR_SITE,
"drain": ACTUATOR_SITE, "charge": ACTUATOR_SITE})
"drain": ACTUATOR_SITE, "charge": ACTUATOR_SITE,
"hold": ACTUATOR_SITE})
check("...and the roster it was restored to is the shipped one, so nothing "
"below inherits a two-inverter Controller",
c_src.units == (app_main.UNIT_A,) and len(c_src.inverters) == 1)
@@ -3069,13 +3080,13 @@ _unit_sources_unrouted = [
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT and "," not in r[1]]
print(f" {len(_direct)} direct appends, each read at its own line: "
+ ", ".join(f"main.py:{ln} {s} -> {a}" for s, a, ln in _routing))
check("all SIXTEEN of `Controller.cycle()`'s DIRECT appends route through "
check("all SEVENTEEN of `Controller.cycle()`'s DIRECT appends route through "
"`self.actuator_of(<string literal>)`, so this scan can read the routing "
"of EVERY one of them. An append that computed its actuator, or omitted "
"the argument, or hand-wrote a string, fails HERE rather than being "
"silently skipped - which is how a scan like this goes quietly vacuous, "
"and 10f is in this file because that already happened once",
len(_direct) == 16
len(_direct) == 17
and all(a is not None and a.startswith("self.actuator_of('")
for _s, a, _ln in _routing))
check("⚠️ AND AT EVERY ONE OF THE ELEVEN THE LITERAL IT ROUTES THROUGH IS THE "
@@ -3109,8 +3120,8 @@ check("...and the only appends this pin does NOT reach are the two that file a "
"arbitrage.claim() by test_arbitrage.py",
sorted(_indirect) == sorted(CLAIM_BUILDERS)
and len(_direct) + len(_indirect) == len(_appends))
check("...and between them the eighteen appends account for exactly the "
"fifteen "
check("...and between them the nineteen appends account for exactly the "
"sixteen "
"sources 10f pins, so the routing pin and the source set are one scan "
"and neither can drift from the other",
{s for s, _a, _ln in _routing} | set(CLAIM_BUILDERS.values())
@@ -3121,6 +3132,10 @@ check("...and between them the eighteen appends account for exactly the "
# through this file's reading of main.py. It grades three of the eight appends.
# The static pin above is what covers the other five.
_b2, _restore_b2 = second_inverter(c_src)
# SAFETY-91: B states a cut-out, as every roster entry must (rule 8). Without
# one it is held back at its reserve and gives last, so A alone would carry
# this cycle's 500 W and the broadcast check below could not see a split.
_b2.bms_soc_floor = c_src.inverters[0].bms_soc_floor
_n2_writes_before = len(c_src.hass.per_entity)
try:
_, _cl2 = cycle_claims(lambda: run_cycle(c_src))
@@ -3171,7 +3186,7 @@ check("...and naming those three is not an accident of this fixture but the "
{c.source for c in _cl2}
== CLAIM_SOURCES - {"failsafe", "safety", "maintenance", "peak",
"arbitrage", "circulation", "sign_site",
"refused", "mode", "drain", "charge"})
"refused", "mode", "drain", "charge", "hold"})
# ⚠️ AND NOW THE HALF THAT WAS EVIDENCE-SHAPED AND OBSERVED NOTHING. The three
# checks above grade WHICH ACTUATOR each claim lands on. They say nothing about
# WHOSE NUMBERS the bound is built from - and those are two different defects
@@ -4135,18 +4150,22 @@ check("⚠️ ...AND THE SAME PAIR SWAPPED GIVES THE SAME SITE FLOOR, so no "
and _g_deepa.soc_floor != _g_shallow.soc_floor
and [pk.bms_floor for pk in _g_ctl_deepa.packs]
== [B_PACK_FLOOR_DEEP_SIM, B_PACK_FLOOR_SIM])
check("...and it is not just a number in a report - THE SITE'S VERDICT FLIPS. "
"With both packs at 25 % the shallow site is permitted and the guard "
"sizes a positive forced discharge; BOTH deep sites are FLOOR-LIMITED, "
"because 25 % is at or below the 35 % cut-out wherever on the roster it "
"sits. Grading the roster against one position would spend a pack its "
"BMS stops - through `sim/inject.py`, which does not run rule 8 - and "
"this line is what goes red when it does",
# SAFETY-87 changed what the verdict is at N>=2: the pack at or under its own
# floor is HELD (named, gives nothing for the peak) and the others still shave.
check("...and it is not just a number in a report - THE HELD PACK FOLLOWS IT. "
"With both packs at 25 % the shallow site holds nobody back; on BOTH deep "
"sites the pack whose 35 % cut-out is at or above 25 % is held, wherever "
"it sits, gives nothing for the peak (weight 0), and the other pack "
"carries a positive floor (SAFETY-87). Holding a position instead of "
"the pack would spend a pack its BMS stops",
_g_shallow.floor_limited is False and _g_shallow.floor_w > 0.0
and _g_deep.floor_limited is True and _g_deep.floor_w == 0.0
and _g_deep.soc_floor_reason == FLOOR_AT_SOC_FLOOR
and _g_deepa.floor_limited is True and _g_deepa.floor_w == 0.0
and _g_deepa.soc_floor_reason == FLOOR_AT_SOC_FLOOR)
and _g_shallow.held == ()
and _g_deep.floor_limited is False and _g_deep.floor_w > 0.0
and _g_deep.held == ("inverter-b",)
and _g_ctl_deep._peak_reserve() == {"inverter-b"}
and _g_deepa.floor_limited is False and _g_deepa.floor_w > 0.0
and _g_deepa.held == ("inverter-a",)
and _g_ctl_deepa._peak_reserve() == {"inverter-a"})
check("...and the precondition that makes the three runs comparable (habit 1): "
"the two deep runs carry THE SAME PAIR of cut-outs in opposite orders, "
"those two numbers really are different, and all six packs are at the "
@@ -4217,14 +4236,14 @@ check("⚠️ THE POSITIONAL PIN, AND IT NEEDS THREE PACKS TO EXIST. On a roster
for r in _g3_runs)
and [[pk.bms_floor for pk in r[2].packs] for r in _g3_runs]
== [r[1] for r in _g3_runs])
check("...and the VERDICT flips with it, on all three: 25 % is at or below the "
"35 % cut-out wherever it sits, so every one of these sites is "
"FLOOR-LIMITED and sizes no forced discharge at all - where the "
"all-shallow two-pack baseline above was permitted and sized a positive "
"one. A slice that dropped the deep position would spend, through "
"`sim/inject.py`, a pack its BMS stops",
all(r[3].floor_limited is True and r[3].floor_w == 0.0
and r[3].soc_floor_reason == FLOOR_AT_SOC_FLOOR for r in _g3_runs)
check("...and the HELD pack follows it, on all three (SAFETY-87): 25 % is at "
"or below the 35 % cut-out wherever it sits, so that pack and only that "
"pack is held and gives nothing for the peak, while the two others carry "
"a positive floor. A slice that dropped the deep position would spend, "
"through `sim/inject.py`, a pack its BMS stops",
all(r[3].floor_limited is False and r[3].floor_w > 0.0
and r[3].held == (r[2].inverters[r[1].index(_G3_DEEP_SIM)].name,)
and r[2]._peak_reserve() == set(r[3].held) for r in _g3_runs)
and _g_shallow.floor_limited is False and _g_shallow.floor_w > 0.0)
check("...and the precondition that makes the three comparable (habit 1): every "
"pack in all three rosters is at the SAME state of charge, so neither the "
@@ -4382,12 +4401,18 @@ async def _notify32(packs_socs, floors):
inv_b, _ = second_inverter(ctl)
for inv, so, fl in zip(ctl.inverters, packs_socs, floors):
inv.soc, inv.bms_soc_floor = so, fl
ctl.peak_guard = PeakDecision(
# SAFETY-87: the full pack refuses to give (SAFETY-73), so no battery
# can carry the peak and the alarm still fires; it names the held one.
inv.refused = 1 if so > SOC_FLOOR else 0
_idle = [types.SimpleNamespace(phase="idle", owns_setpoint=False,
setpoint_w=None)] * 2
ctl.peak_guard = ctl._peak_on_able(PeakDecision(
engaged=True, floor_w=0.0, ceiling_w=CAP_W, ceiling_from=CEILING_CAP,
projected_w=CAP_W + 500.0, excess_w=500.0, wanted_w=500.0,
floor_limited=True, soc=min(packs_socs),
soc_floor=binding_soc_floor(ctl.packs, SOC_FLOOR),
soc_floor_reason=FLOOR_AT_SOC_FLOOR, reason="PEAK GUARD FLOOR-LIMITED: x")
soc_floor_reason=FLOOR_AT_SOC_FLOOR, reason="PEAK GUARD FLOOR-LIMITED: x"),
_idle)
ctl._peak_floor_limited = False
await ctl.check_peak_floor_limited()
return hs.last_message("goodwe_peak_soc_floor")
@@ -4577,18 +4602,19 @@ check("...and the TWO-pack mirror, which is what production reaches the moment "
[r[3].soc for r in _s2_runs] == [_S_EMPTY_SIM] * 2
and [[inv.soc for inv in r[2].inverters] for r in _s2_runs]
== [[_S_EMPTY_SIM, _S_FULL_SIM], [_S_FULL_SIM, _S_EMPTY_SIM]])
check("⚠️ ...AND THE SITE'S VERDICT FLIPS WITH IT, which is what makes this a "
"defect rather than a reporting difference. With the emptiest pack at "
"11 % - below the 20 % EMS floor - every one of those five sites is "
"FLOOR-LIMITED and sizes NO forced discharge at all, where the baseline "
"with its emptiest pack at 25 % is permitted and sizes a positive one. A "
"reduction that dropped the empty pack's position would grade this site "
"for PERMISSION against a pack 39 points fuller and spend, through "
"`sim/inject.py`, a pack its own BMS stops",
all(r[3].floor_limited is True and r[3].floor_w == 0.0
# SAFETY-87: at N>=2 the emptiest pack is HELD and the others still shave.
check("⚠️ ...AND THE HELD PACK FOLLOWS IT, which is what makes this a defect "
"rather than a reporting difference. With the emptiest pack at 11 % - "
"below the 20 % EMS floor - that pack and only that pack is held on "
"every one of those five sites and gives nothing for the peak, while "
"the fuller ones carry a positive floor (SAFETY-87); the baseline holds "
"nobody back. A reduction that dropped the empty pack's position would "
"spend, through `sim/inject.py`, a pack its own BMS stops",
all(r[3].floor_limited is False and r[3].floor_w > 0.0
and r[3].held == (r[2].inverters[r[1].index(_S_EMPTY_SIM)].name,)
for r in _s3_runs + _s2_runs)
and _s_ok.floor_limited is False and _s_ok.floor_w > 0.0
and _s_ok.soc == SOC_BETWEEN)
and _s_ok.held == () and _s_ok.soc == SOC_BETWEEN)
check("...and the precondition that makes the six runs comparable (habit 1): "
"every pack in every roster carries the SAME cut-out, so "
"`binding_soc_floor()` answers the SAME site floor on all of them and the "
@@ -5723,8 +5749,8 @@ MUT.append(("main.py grading the site against ONE roster position - "
_g_deep.soc_floor == float(B_PACK_FLOOR_DEEP_SIM)
and _g_deepa.soc_floor == float(B_PACK_FLOOR_DEEP_SIM)
and _g_shallow.soc_floor == float(SOC_FLOOR)
and _g_deep.floor_limited and _g_deepa.floor_limited
and not _g_shallow.floor_limited,
and _g_deep.held == ("inverter-b",)
and _g_deepa.held == ("inverter-a",) and _g_shallow.held == (),
"part 12g, REWRITTEN as behaviour: real two-pack cycles, the SAME "
"pair of cut-outs carried in both orders, and both the floor "
"reaching decide() and the site's verdict follow the ROSTER rather "
+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",
+6 -3
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)
# ============================================================================ #
@@ -332,15 +332,18 @@ print("\n5. the N=1 and N=2 digests")
# newest action-log line hashed. N=2: the same on release/1.0 35cdffe. N=1:
# re-pinned by SAFETY-83, whose `--diff` against 35cdffe changes only cycles
# where the peak guard asked for more than the drain (test_safety83.py).
# SAFETY-85 re-pinned two: n1_drain_owner loses its false 'not keeping up'
# rows (commands unchanged); n2_drain's loop is bounded while A drains
# (reasons and the loop claim change, commands unchanged).
PINNED = {
"n1_drain":
"b0f9ef44149163bc0acece537fe047871c00f0d9ea213e7a7fc8efc4ad3aa500",
"n1_drain_modes":
"95ab82426e23bce308db3edebe18b1ab37d956952e9f521adce37150ce87ee8a",
"n1_drain_owner":
"10f7f42a6a952e899d739eb10c674874215c440f41ead7a9f2408bc9b4cb9ea4",
"607c2583c7ae4bdafb90d780b3414c62d76c430ca67798cd117d1a46f3bb40ae",
"n2_drain":
"055d83de0552d3a014dc73d87fdd697dde8b5933b8032f9b3a464ff0d08795fb",
"5806a6f457b7baf8dfb44daddced502f1939c114e15b84029dbb2659be8c8b0f",
"n2_drain_owner":
"05750347f1829c249f99154613f068f442a59ef59f409093907f6d0add78dd4c",
}
+3 -4
View File
@@ -295,11 +295,10 @@ def store_hold():
_h_rows, _h, _hb = run(6000.0, 2500, 50, 2000, dur=600.0, store=store_hold())
check("a maintenance HOLD at N=1 is not changed (DECISIONS/SAFETY-83, not "
"changed): the battery stays at 0 W with the house over the limit",
check("a maintenance HOLD at N=1 now gives way to the guard too (SAFETY-86, "
"test_safety86.py): the battery gives with the house over the limit",
_h.maints[0].phase == "hold" and _h.peak_guard.engaged
and _h.peak_guard.floor_w > 0.0
and all(cmd(r) == 0.0 for r in _h_rows))
and cmd(_h_rows[-1]) > 0.0 and "limited by peak" in _h_rows[-1][6])
print()
if fails:
+13 -6
View File
@@ -83,8 +83,11 @@ def banked(c):
def alarmed(c):
"""Did SAFETY-81's 'Load too high' alarm fire on this run?"""
return any("PEAK_GUARD_SATURATED" in e for e in c.events)
"""Did SAFETY-81's 'Load too high' alarm fire on this run? Its quarter
stamp too, since ALLOCATOR rows can push its row out of the 40-row log
(SAFETY-85)."""
return c._peak_alarm_block is not None or any(
"PEAK_GUARD_SATURATED" in e for e in c.events)
def run(house, n=2, **kw):
@@ -465,6 +468,10 @@ print("\n8. the N=1 and N=2 digests")
# 2026-09-30, 480 cycles each, every claim hashed. N=1 and n2_charge_dear:
# the same on release/1.0 9d5b7d1 (the tool copied in). N=2 in a cheap
# window: new, the charge that 9d5b7d1 refused on every cycle.
# SAFETY-85 re-pinned the three N=2 ones: the loop is bounded while A's
# charge pin stands. n2_charge and _banked change only the loop's reason and
# claim on 2 cycles; n2_charge_dear changes commands on 98 cycles, where A's
# pin lifts (t=1800) and B no longer jumps 1390 W, and after the surplus step.
PINNED = {
"n1_charge":
"0ed25c856456b97acb07a39299a8cb7b1a762c4d0005b88a0823c03057a80be5",
@@ -473,11 +480,11 @@ PINNED = {
"n1_charge_banked":
"2619acdd06b04e56c000b168d64e1d044346c6ba3f929c94258d6ad8189ec609",
"n2_charge":
"748abe785a79084bc738c82d0d6f90b951e1cb78e9629195c0c01d3bb67b55e2",
"b16d692128f14bcce3d87b142d290e0d6b13724289680e44a05215c9eaa24a7c",
"n2_charge_dear":
"c34f08b3b5b6256e68cf05030fb0c29522916dc8f57c351008f48384657966c0",
"00a197d89a5dd1e8b3ae6a5d1af66329a4dc4850476569c5931c125fa4d9eaee",
"n2_charge_banked":
"fb600ee1dd3202910715c97c5809584d50c6b8b57b55aaa4dc83bcaaa97627ef",
"a6973faea916e4c88ca1770047f94478346eeb1efa4889b154fad0747f91267f",
}
_now = s84.digests()
for k in PINNED:
@@ -485,7 +492,7 @@ for k in PINNED:
f"{_now[k][:16]}")
check("⭐ at N=1 a charge in and out of a cheap window and under a banked "
"month peak is byte-identical to 9d5b7d1 (commands, reasons and every "
"claim), and so is N=2 outside a cheap window",
"claim), and N=2 outside a cheap window matches its SAFETY-85 pin",
all(_now[k] == PINNED[k] for k in ("n1_charge", "n1_charge_dear",
"n1_charge_banked",
"n2_charge_dear")))
+450
View File
@@ -0,0 +1,450 @@
"""Runnable check for SAFETY-85: no false 'not keeping up' while another owner
sets the battery. `python3 test_safety85.py`
The loop's integrator bound fired every ~2.5 min during maintenance and under
the peak guard, though the battery gave what it was told: the loop's own ask,
re-seeded and pushed by a grid error it did not own, crossed its rail. Now the
loop's clamp counts only on a cycle the loop decided, a battery another owner
set alarms only when it gives less than told, and at N>=2 the loop is bounded
while maintenance holds a battery. Drives the REAL Controller
(tools/safety84_identity.py's harness). DECISIONS/SAFETY-85-no-false-not-keeping-up.md.
1. N=1 and N=2: a drain, a charge and a HOLD with the house above what the
battery is set to, an hour each: no warning; without the fix they warn.
2. A battery that gives less than told during a drain does alarm (N=1, N=2);
a two-cycle dip does not.
3. Control coming back from maintenance does not jump (N=2 hold end, N=2
charge pin lifting, N=1 hold end).
4. The loop's own alarm stays (house over the rating); a safety limit at N=2
(a battery at its cut-out) does not warn.
5. The status row counts episodes, not cycles.
6. The rules at their boundaries.
7. Review: a battery the peak guard sets that gives nothing warns; a battery
that lags (10 s) and a charge that tapers do not.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import logging
import os
import sys
from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import safety79_identity as s79 # noqa: E402
import safety84_identity as s84 # noqa: E402
import app.main as app_main # noqa: E402
from app import clamp, messages # noqa: E402
from app.main import Controller # noqa: E402
from app.store import unit_key # noqa: E402
fails = []
A = "inverter-a"
HOUR = 3600.0
SLEW = 400.0 # the rig's slew_w
OPT0 = dict(s84.OPT)
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
async def _call(self, *a):
"""The rig's Home Assistant, recording each notification id it is sent."""
if len(a) >= 3 and a[1] == "create" and isinstance(a[2], dict):
self.__dict__.setdefault("notes", []).append(
a[2].get("notification_id"))
return True
rig._Hass.call = _call
def store(n, phase):
"""Battery A in `phase` from the first cycle ('drain': due at 11:00;
'idle': only with `maint=False`)."""
if phase == "drain":
return rig._Store()
if phase == "charge":
return s79.store_charging(n)
s = rig._Store()
if phase == "hold":
for k, v in (("phase", "hold"), ("phase_started",
rig.BASE.isoformat())):
s.data[k if n == 1 else unit_key(A, k)] = v
return s
def run(n, phase, house, cap, *, dur=HOUR, soc=None, cut=None, extra=None,
maint=True):
"""Rows and controller; `cap` is the peak limit (20000: never binds);
`house` is watts or a function of time."""
s84.OPT = dict(OPT0, peak_cap_w=cap, maintenance_enabled=maint)
if soc is None:
def soc(u, t):
return 99.0 if phase == "hold" and u == "a" else 60.0
try:
load = house if callable(house) else (lambda t: house)
return s84.run(n, load=load, cheap=(phase == "charge"),
store=store(n, phase), soc=soc, dur=dur, cut=cut,
extra=dict({"maintenance_hold_min": 600,
"maintenance_discharge_w": 2000},
**(extra or {})))
finally:
s84.OPT = OPT0
def warned(c):
"""'Battery is not keeping up': notifications, cycles, episodes."""
return (getattr(c.hass, "notes", []).count(messages.ID_LOOP_BOUND),
c.clamp_wd.bound, c.clamp_wd.bound_episodes)
def patched(obj, name, value):
"""Run `fn` with `obj.name` replaced (the 'without the fix' runs)."""
def wrap(fn):
real = getattr(obj, name)
setattr(obj, name, value)
try:
return fn()
finally:
setattr(obj, name, real)
return wrap
no_gate = patched(Controller, "_loop_alarm_input", lambda self, r: None)
no_bound = patched(Controller, "loop_bound", lambda self, claims: None)
def cmd(r):
return dict(r[2])
def steps(rows, lo, hi):
"""The largest change in the total command between cycles in [lo, hi]."""
tot = [(r[0], sum(cmd(r).values())) for r in rows]
return max(abs(b - a) for (t0, a), (t1, b) in zip(tot, tot[1:])
if lo <= t1 <= hi)
# ============================================================================ #
print("1. the house above what the battery is set to, an hour each")
CASES = [
# (n, phase, house, cap): the peak guard raises the drain / the HOLD at a
# 3500 W limit; the rest are the phase alone under a house that pushes the
# loop's re-seeded ask past its rail.
(1, "drain", 6500.0, 3500), (1, "drain", 7500.0, 20000),
(1, "charge", 10000.0, 20000),
(1, "hold", 6500.0, 3500), (1, "hold", 9000.0, 20000),
(2, "drain", 6500.0, 3500), (2, "charge", 10000.0, 20000),
(2, "hold", 4500.0, 20000), (2, "hold", 6500.0, 3500),
]
NEW, OLD = {}, {}
for k in CASES:
NEW[k] = run(*k)
OLD[k] = no_gate(lambda: no_bound(lambda: run(*k)))
print(f" N={k[0]} {k[1]:6} house {k[2]:5.0f} limit {k[3]:5}: phases "
f"{sorted({p for r in NEW[k][0] for p in r[-1][3]})}, warnings "
f"(notes, cycles, episodes) {warned(NEW[k][1])}, without the fix "
f"{warned(OLD[k][1])}")
check("⭐ no 'Battery is not keeping up' notification, cycle or episode in any "
"of the nine hours, at N=1 and N=2",
all(warned(c) == (0, 0, 0) for _r, c in NEW.values()))
check("...the phase held all hour (the battery was set by maintenance, or by "
"the guard over it, throughout)",
all(all(r[-1][3][0] == k[1] for r in NEW[k][0]) for k in CASES))
check("non-vacuous: without the fix every one of the nine warns, the "
"notification re-created on the re-assert cadence",
all(warned(c)[0] >= 1 and warned(c)[1] > 0 for _r, c in OLD.values())
and sum(warned(c)[0] for _r, c in OLD.values()) >= 9 * 5)
# ============================================================================ #
print("\n2. a battery that really gives less than told still alarms")
def gone(t0, t1):
"""Battery A gives nothing from t0 to t1."""
return lambda u, t: u == "a" and t0 <= t < t1
SHORT = {n: run(n, "drain", 1000.0, 20000, dur=1200.0, cut=gone(600, 1200))
for n in (1, 2)}
for n, (rows, c) in SHORT.items():
print(f" N={n}: {warned(c)}, log "
f"{[e for e in c.events if 'OUTPUT_CLAMP' in e][:1]}")
_r1, _c1 = SHORT[1]
check("⭐ N=1, a drain of 2000 W that gives nothing: it warns, and the log "
"says what was asked and what came",
warned(_c1)[0] >= 1 and any(
"the battery gives -4 W of the +2000 W asked" in e
for e in _c1.events))
_r2, _c2 = SHORT[2]
check("⭐ N=2, the same on battery A while B follows the house: it warns and "
"names inverter-a",
warned(_c2)[0] >= 1 and any(
"inverter-a gives -4 W of the +2000 W asked" in e
for e in _c2.events))
_hb = run(2, "hold", 4500.0, 20000, dur=1200.0,
cut=lambda u, t: u == "b" and t >= 600)
print(f" N=2 HOLD on A, B gives nothing: {warned(_hb[1])}")
check("⭐ N=2, A in its HOLD and B, which the loop drives, gives nothing: the "
"loop stands at what B may give, and it warns naming inverter-b",
warned(_hb[1])[0] >= 1 and any(
"inverter-b gives -4 W of the +2500 W asked" in e
for e in _hb[1].events))
check("...it starts no earlier than the saturation cycles allow (3 x 5 s)",
all(e.split(" ", 1)[0] >= "11:10:10" for c in (_c1, _c2)
for e in c.events if "OUTPUT_CLAMP" in e))
_quiet = patched(app_main, "delivery_short", lambda *a: False)(
lambda: run(1, "drain", 1000.0, 20000, dur=1200.0, cut=gone(600, 1200)))
check("non-vacuous: without the delivery rule the N=1 drain that gives "
"nothing is silent (the loop's clamp never reaches its rail here)",
warned(_quiet[1]) == (0, 0, 0))
_dip = run(1, "drain", 1000.0, 20000, dur=900.0, cut=gone(600, 610))
check("a dip of two cycles is not an alarm", warned(_dip[1]) == (0, 0, 0))
# ============================================================================ #
print("\n3. control coming back from maintenance does not jump")
END = {"maintenance_hold_min": 10}
_bounds = []
_real_compute = app_main.compute
def _spy_compute(*a, **kw):
_bounds.append(kw.get("bound"))
return _real_compute(*a, **kw)
app_main.compute = _spy_compute
try:
_h2 = run(2, "hold", 4500.0, 20000, dur=900.0, extra=END)
finally:
app_main.compute = _real_compute
_h2_old = no_bound(lambda: run(2, "hold", 4500.0, 20000, dur=900.0,
extra=END))
_h1 = run(1, "hold", 4500.0, 20000, dur=900.0, extra=END)
s84.OPT = dict(OPT0)
_cd = s84.run(2, cheap=False)
_cd_old = no_bound(lambda: s84.run(2, cheap=False))
print(f" N=2 hold end: largest step {steps(_h2[0], 560, 700):.0f} W "
f"(without the bound {steps(_h2_old[0], 560, 700):.0f} W); N=1 "
f"{steps(_h1[0], 560, 700):.0f} W; N=2 charge pin lifts at 1800 s: "
f"{steps(_cd[0], 1780, 1840):.0f} W (without "
f"{steps(_cd_old[0], 1780, 1840):.0f} W)")
check("⭐ N=2, the HOLD ends under a 4500 W house: the batteries move at most "
"one slew step a cycle",
_h2[1].maints[0].phase != "hold" and steps(_h2[0], 560, 700) <= SLEW)
_held = [any(p != "idle" for p in r[-1][3]) for r in _h2[0]]
print(f" bound per cycle: {len(_bounds)} calls, {len(_held)} rows, "
f"{sum(_held)} in maintenance, "
f"{sum(b is not None for b in _bounds)} bounded")
check("...and the bound is on while A holds and off from the cycle the HOLD "
"ends (review)",
len(_bounds) == len(_held) and any(not h for h in _held)
and all((b is not None) == h for b, h in zip(_bounds, _held)))
check("⭐ N=2, A's charge pin lifts at a quarter's start: at most one slew "
"step a cycle", steps(_cd[0], 1780, 1840) <= SLEW)
check("...and N=1 already moved one slew step at a time (re-seeded each "
"cycle), unchanged", steps(_h1[0], 560, 700) <= SLEW)
check("non-vacuous: without the bound the N=2 loop stands wound at its rail "
"and both jump (2500 W at the hold's end)",
steps(_h2_old[0], 560, 700) >= 2000.0
and steps(_cd_old[0], 1780, 1840) >= 1000.0)
# ============================================================================ #
print("\n4. what still warns, and what a safety limit does")
_rail = run(1, "drain", 6500.0, 20000, dur=600.0, maint=False)
print(f" N=1 no maintenance, house 6500 over a 5000 W rating: "
f"{warned(_rail[1])}")
check("⭐ the loop in charge at its rail (house over the rating) still warns, "
"as SAFETY-40 decided", warned(_rail[1])[0] >= 1)
def at_cutout(u, t):
return 9.0 if u == "a" else 60.0
_cut = run(2, "idle", 6000.0, 20000, dur=900.0, soc=at_cutout, maint=False)
_cut_old = no_gate(lambda: run(2, "idle", 6000.0, 20000, dur=900.0,
soc=at_cutout, maint=False))
print(f" N=2, A at its cut-out, house 6000: {warned(_cut[1])}, without "
f"the fix {warned(_cut_old[1])}")
check("⭐ N=2, A at its cut-out level (a safety limit sets it to 0 W) and B "
"at its rating: no warning, each battery gives what it is told",
warned(_cut[1]) == (0, 0, 0) and cmd(_cut[0][-1]) == {"a": 0.0,
"b": 2500.0})
check("non-vacuous: without the fix it warns",
warned(_cut_old[1])[0] >= 1)
# ============================================================================ #
print("\n5. the status row counts episodes")
_wd = clamp.ClampWatchdog()
_t = datetime(2026, 9, 20, 11, 0, tzinfo=timezone.utc)
for ep in range(3):
for i in range(10):
_wd.note(True, "x", _t, bug=False)
_t += timedelta(seconds=5)
for i in range(clamp.CLEAR_CYCLES):
_wd.note(False, "", _t)
_t += timedelta(seconds=5)
_wd.note(True, "y", _t, bug=True)
check("three bound episodes of ten cycles and one over-rating: 3 episodes, "
"30 cycles", (_wd.bound_episodes, _wd.bound, _wd.episodes) == (3, 30, 4))
_rows = [x for x in _c1.checks() if x.get("id") == "not_keeping_up"]
check("⭐ the page row reads the episodes, not the cycles "
f"({_c1.clamp_wd.bound_episodes} vs {_c1.clamp_wd.bound})",
_rows and _c1.clamp_wd.bound > _c1.clamp_wd.bound_episodes >= 1
and _rows[0]["text"].startswith(messages.T_LOOP_BOUND + ": "
+ messages.times(
_c1.clamp_wd.bound_episodes)))
# ============================================================================ #
print("\n6. the rules at their boundaries")
DS = [((2000, 1499, 500), True), ((2000, 1500, 500), False),
((2000, 2600, 500), False), ((2000, -4, 500), True),
((0, -900, 500), False), ((0, 900, 500), False),
((-2000, -1499, 500), False), ((-2000, 600, 500), False)]
check("delivery_short: a discharge more than the margin short; more than "
"asked, 0 W asked, or a charge (a full pack tapers) is never short",
all(clamp.delivery_short(*a) is want for a, want in DS))
check("the note names each battery, asked before given",
clamp.shortfall_note([("inverter-a", 2000.0, -4.0)])
== "OUTPUT_CLAMP: inverter-a gives -4 W of the +2000 W asked"
and clamp.shortfall_note([(None, -2000.0, 0.0)])
== "OUTPUT_CLAMP: the battery gives +0 W of the -2000 W asked")
# ============================================================================ #
print("\n7. review: the peak guard, a battery that lags, a charge that tapers")
import math # noqa: E402
def under_guard(n, cut):
"""A HOLD the peak guard raises (house 6500, limit 3500), 20 min."""
return run(n, "hold", 6500.0, 3500, dur=1200.0, cut=cut)
_g1 = under_guard(1, lambda u, t: t >= 600)
_g2 = under_guard(2, lambda u, t: u == "a" and t >= 600)
print(f" under the guard, the battery gives nothing: N=1 {warned(_g1[1])}, "
f"N=2 {warned(_g2[1])}")
check("⭐ the peak guard sets the battery and it gives nothing: it warns at "
"N=1, and at N=2 (A, raised for the peak, gives nothing; B gives all "
"it is told)",
warned(_g1[1])[0] >= 1 and warned(_g2[1])[0] >= 1
and all("peak" in r[6] for rows, _c in (_g1, _g2) for r in rows
if 600 <= r[0] <= 620))
check("...and it gives what it is told until then (no warning before 600 s)",
all(e.split(" ", 1)[0] >= "11:10:00" for c in (_g1[1], _g2[1])
for e in c.events if "OUTPUT_CLAMP" in e))
def lagging(fn, lag=10.0):
"""Run `fn` on plantsim's battery (800 W/s ramp, `lag` s first-order lag)
at the live 2 s meter tick."""
real_p, real_t = rig._Hass.power, rig.TICK
def power(self, u):
st = self.__dict__.setdefault("_plant", {})
a, tgt, tl = st.get(u, (0.0, 0.0, self.t))
for _ in range(int(round((self.t - tl) / 0.1))):
d = self.cmd[u] - tgt
tgt += math.copysign(min(abs(d), 80.0), d)
a += (tgt - a) * (1 - math.exp(-0.1 / lag))
st[u] = (a, tgt, self.t)
if self.cut(u, self.t) and self.cmd[u] >= -4.0:
return -4.0
return float(round(a))
rig._Hass.power, rig.TICK = power, 2.0
try:
return fn()
finally:
rig._Hass.power, rig.TICK = real_p, real_t
def kettle(t):
"""800 W, plus 2 kW 3 min in 10, plus 3 kW 1 min in 4."""
return (800.0 + (2000.0 if t % 600 < 180 else 0.0)
+ (3000.0 if t % 240 < 60 else 0.0))
def lag_runs():
return [run(n, ph, kettle, 3500, dur=1800.0)
for n, ph in ((1, "drain"), (2, "drain"), (2, "hold"))]
_lag = lagging(lag_runs)
_lag_old = patched(app_main, "SHORT_CYCLES", 1)(
lambda: patched(app_main, "SHORT_SPAN_S", 0.0)(
lambda: lagging(lag_runs)))
_lag_dead = lagging(lambda: run(1, "drain", 1000.0, 20000, dur=900.0,
cut=gone(600, 900)))
print(f" lagging battery, house steps under the guard: "
f"{[warned(c) for _r, c in _lag]}, graded on last cycle's command "
f"{[warned(c) for _r, c in _lag_old]}; gives nothing "
f"{warned(_lag_dead[1])}")
check("⭐ a battery that lags (10 s) behind maintenance and peak-guard steps "
"at a 2 s meter does not warn (N=1 drain, N=2 drain and HOLD, 30 min)",
all(warned(c) == (0, 0, 0) for _r, c in _lag))
check("non-vacuous: graded on last cycle's command alone, it warns",
sum(warned(c)[0] for _r, c in _lag_old) >= 3)
check("...and a lagging battery that gives nothing still warns",
warned(_lag_dead[1])[0] >= 1)
def taper(fn):
"""Battery A takes the full charge until 600 s, then its acceptance falls
to 150 W over 30 min (constant-voltage taper)."""
real_p = rig._Hass.power
def power(self, u):
c = self.cmd[u]
if c < 0 and u == "a":
c = max(c, -max(150.0, 2500.0 - max(0.0, self.t - 600) / 1800
* 2350.0))
return float(round(c))
rig._Hass.power = power
try:
return fn()
finally:
rig._Hass.power = real_p
def charge_runs():
s84.OPT = dict(OPT0, peak_cap_w=20000)
try:
return [s84.run(n, load=lambda t: 800.0, cheap=True,
store=s79.store_charging(n), soc=lambda u, t: 60.0,
dur=2400.0) for n in (1, 2)]
finally:
s84.OPT = OPT0
def both_ways(asked, given, margin):
asked, given = float(asked), float(given)
return asked != 0.0 and (asked - given) * math.copysign(1.0, asked) > margin
_tp = taper(charge_runs)
_tp_old = patched(app_main, "delivery_short", both_ways)(
lambda: taper(charge_runs))
print(f" a charge that tapers: {[warned(c) for _r, c in _tp]}, grading "
f"charges too {[warned(c) for _r, c in _tp_old]}")
check("⭐ a maintenance charge that tapers near full does not warn (N=1, N=2)",
all(warned(c) == (0, 0, 0) for _r, c in _tp)
and all("charge" in {p for r in rows for p in r[-1][3]}
for rows, _c in _tp))
check("non-vacuous: grading charges too, the taper warns",
all(warned(c)[0] >= 3 for _r, c in _tp_old))
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all SAFETY-85 checks passed")
+442
View File
@@ -0,0 +1,442 @@
"""Runnable check for SAFETY-86: the peak guard outranks the maintenance HOLD.
`python3 test_safety86.py`
During the HOLD the battery rests full at 0 W (P_MAINTENANCE, above the peak
floor), so the house imported everything and quarters went over the peak
limit. Now the holding battery gives what keeps the quarter at the aim: at N=1
the peak floor is refiled at P_MAINTENANCE, at N>=2 the hold's pin is raised
to what the other batteries cannot give. Drives the REAL Controller
(tools/safety84_identity.py's harness). DECISIONS/SAFETY-86-peak-over-hold.md.
1. N=1, house over the aim: every quarter lands on the aim, the hold goes on,
no 'Load too high'; without the fix the quarter goes over and it fires.
2. Under the aim nothing changes, at N=1 and N=2 (rows identical).
3. N=2, house over the aim: the other battery gives first, the holding one
only the shortfall; with battery modes on and B Off too.
4. A month peak banked above the limit: the aim is that peak - margin.
5. A safety stop still parks everything, and the hold gives again after.
6. The level dips below the target: the hold still ends on its clock.
7. The rule at its boundaries, on claim lists.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import logging
import os
import sys
import types
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import safety84_identity as s84 # noqa: E402
from app import control as _ctl # noqa: E402
from app import packmode # noqa: E402
from app.arbiter import (Claim, P_MAINTENANCE, P_PEAK, P_SAFETY, # noqa
resolve)
from app.main import Controller # noqa: E402
from app.maintenance import (CHARGE, DRAIN, HOLD, IDLE, # noqa: E402
MaintResult)
from app.store import unit_key # noqa: E402
fails = []
A, B = "inverter-a", "inverter-b"
AIM = 3450.0 # peak_cap_w 3500 - peak_margin_w 50
TOL = 10.0 # W over the aim a banked quarter may show (SAFETY-81)
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def store_hold(n, b_off=False):
"""A store with battery A in its HOLD from the first cycle."""
s = rig._Store()
for k in ("phase", "phase_started"):
s.data[k if n == 1 else unit_key(A, k)] = (
"hold" if k == "phase" else rig.BASE.isoformat())
if b_off:
s.data[unit_key(B, packmode.USE_KEY)] = False
return s
def full(u, t):
"""A rests full; B is half full."""
return 99.0 if u == "a" else 60.0
def run(house, n, *, hold_min=600, b_off=False, extra=None, **kw):
kw.setdefault("soc", full)
kw.setdefault("dur", 2700.0)
return s84.run(n, load=house if callable(house) else lambda t: house,
cheap=False, store=store_hold(n, b_off),
extra=dict({"maintenance_hold_min": hold_min},
**(extra or {})), **kw)
def without_fix(fn):
real = Controller._peak_over_hold
Controller._peak_over_hold = lambda self, claims, results: None
try:
return fn()
finally:
Controller._peak_over_hold = real
def cmd(r):
return dict(r[2])
def banked(c):
return [b.offtake_avg_w for b in c.p1.blocks]
def on_aim(c, aim=AIM):
b = banked(c)
return len(b) >= 2 and all(aim - TOL <= v <= aim + TOL for v in b)
def alarmed(c):
"""Did SAFETY-81's 'Load too high' alarm fire on this run? Its quarter
stamp too, since ALLOCATOR rows can push its row out of the 40-row log
(SAFETY-85)."""
return c._peak_alarm_block is not None or any(
"PEAK_GUARD_SATURATED" in e for e in c.events)
def refused(rows):
return sum(1 for r in rows if r[7] and "REFUSED" in r[7])
def peak_prio(r):
return [c[1] for c in r[-1][0] if c[0] == "peak"]
def hold_pin(r):
return [c[3] for c in r[-1][0] if c[0] == "maintenance" and c[2] == "set"]
# ============================================================================ #
print("1. N=1: a house over the aim during the HOLD")
N1 = {h: run(h, 1) for h in (5000.0, 6500.0, 7000.0)}
N1_OLD = {h: without_fix(lambda: run(h, 1)) for h in N1}
for h in N1:
print(f" house {h:.0f}: banked {[round(v) for v in banked(N1[h][1])]} W"
f" (before the fix {[round(v) for v in banked(N1_OLD[h][1])]} W), "
f"last {N1[h][0][-1][6]}")
check("⭐ every quarter lands on the aim (3450 W) for a 5000, 6500 and "
"7000 W house, and 'Load too high' never fires",
all(on_aim(c) and not alarmed(c) for _r, c in N1.values()))
check("...the battery gives, raised for the peak limit, and stays in its "
"HOLD: the phase is hold and the claim still says so",
all(c.maints[0].phase == HOLD and cmd(rows[-1])["a"] > 1000.0
and "limited by peak" in rows[-1][6]
and c.why_words.endswith("raised for the peak limit")
for rows, c in N1.values()))
check("non-vacuous: without the fix the battery sits at 0 W, the quarter "
"banks the whole house and 'Load too high' fires",
all(all(cmd(r)["a"] == 0.0 for r in rows) and min(banked(c)) >= h - 1
and alarmed(c) for h, (rows, c) in N1_OLD.items()))
def hob(t):
"""A 6000 W house with a 5 s spike to 11000 W every 150 s."""
return 11000.0 if t % 150 < 5 else 6000.0
_sp_rows, _sp = run(hob, 1)
check("...and 5 s spikes that saturate the guard for a cycle do not fire it "
"(SAFETY-81's 10 s hold)",
any(r[-1][1] and r[-1][1][1] >= 5000.0 for r in _sp_rows)
and not alarmed(_sp))
# ============================================================================ #
print("\n2. under the aim the HOLD is as before")
BELOW = {(n, h): (run(h, n, dur=1800.0),
without_fix(lambda: run(h, n, dur=1800.0)))
for n, h in ((1, 1200.0), (1, 3000.0), (1, -1500.0),
(2, 1500.0), (2, 3000.0), (2, 5000.0))}
check("⭐ N=1 at a 1200 W and 3000 W house and a 1500 W surplus, N=2 at "
"1500, 3000 and 5000 W (B covers it): battery A is at 0 W on every "
"cycle and every row equals the run without the fix",
all(all(cmd(r)["a"] == 0.0 for r in new[0]) and new[0] == old[0]
for new, old in BELOW.values()))
check("...and the peak claim stays at P_PEAK on every cycle",
all(p == P_PEAK for new, _o in BELOW.values() for r in new[0]
for p in peak_prio(r)))
# ============================================================================ #
print("\n3. N=2: the other battery gives first, the holding one the rest")
N2 = {h: run(h, 2) for h in (6500.0, 7000.0)}
N2_OLD = {h: without_fix(lambda: run(h, 2)) for h in N2}
for h, (rows, c) in N2.items():
print(f" house {h:.0f}: banked {[round(v) for v in banked(c)]} W "
f"(before the fix {[round(v) for v in banked(N2_OLD[h][1])]} W), "
f"last {rows[-1][2]}")
check("⭐ house 6500 and 7000 W, B at most 2500 W: every quarter lands on "
"the aim, nothing is refused or split both ways, no 'Load too high'",
all(on_aim(c) and refused(rows) == 0 and not alarmed(c)
and all(cmd(r)["a"] * cmd(r)["b"] >= 0.0 for r in rows)
for rows, c in N2.values()))
_gives = [r for rows, _c in N2.values() for r in rows if cmd(r)["a"] > 0.0]
check("⭐ A gives only the shortfall: on every cycle A gives, B is at its "
"2500 W and A's pin is the guard's floor minus 2500 W",
len(_gives) > 500
and all(cmd(r)["b"] == 2500.0 and hold_pin(r)
and abs(hold_pin(r)[0] - (r[-1][1][1] - 2500.0)) < 1e-6
for r in _gives))
check("...A stays in its HOLD, and its last command is the shortfall "
"(6500 - 3450 - 2500 = 550 W, give or take the meter's 1 W)",
all(c.maints[0].phase == HOLD for _r, c in N2.values())
and 540.0 <= cmd(N2[6500.0][0][-1])["a"] <= 560.0)
check("non-vacuous: without the fix A sits at 0 W, the quarter goes over "
"the limit and 'Load too high' fires",
all(all(cmd(r)["a"] == 0.0 for r in rows) and max(banked(c)) > 3900.0
and alarmed(c) for rows, c in N2_OLD.values()))
_off_rows, _off = run(6500.0, 2, b_off=True,
extra={"battery_modes_enabled": True})
print(f" B Off, house 6500: banked {[round(v) for v in banked(_off)]} W, "
f"last {_off_rows[-1][2]}")
check("⭐ battery modes on, B Off: the Off battery still gives before the "
"holding one (maintenance outranks a mode), the quarter lands on the "
"aim, nothing refused",
on_aim(_off) and cmd(_off_rows[-1])["b"] == 2500.0
and 540.0 <= cmd(_off_rows[-1])["a"] <= 560.0
and refused(_off_rows) == 0 and not alarmed(_off))
def noisy(t):
"""A 6500 W house that moves up to 150 W from one second to the next."""
return 6500.0 + ((int(t) * 7919) % 301) - 150.0
def no_ceiling(fn):
"""Run with the pin raised but no `hold` site ceiling (review check)."""
real = Controller._peak_over_hold
def pin_only(self, claims, results):
real(self, claims, results)
Controller._peak_over_hold = pin_only
try:
return fn()
finally:
Controller._peak_over_hold = real
def rows_logged(c, word):
return sum(1 for e in c.events if word in e)
def no_bound(fn):
"""Run without SAFETY-85's loop bound (modes are off here)."""
real = Controller.loop_bound
Controller.loop_bound = lambda self, claims: None
try:
return fn()
finally:
Controller.loop_bound = real
_nz_rows, _nz = run(noisy, 2)
# SAFETY-85's loop bound also keeps the loop inside what the batteries can
# give, so the flood needs both the ceiling and the bound taken out.
_nz_old = no_bound(lambda: no_ceiling(lambda: run(noisy, 2)))[1]
_nz_nc = no_ceiling(lambda: run(noisy, 2))[1]
print(f" noisy house: ALLOCATOR rows {rows_logged(_nz, 'ALLOCATOR')}, "
f"clamp rows {rows_logged(_nz, 'OUTPUT_CLAMP')} (without the site "
f"ceiling {rows_logged(_nz_old, 'ALLOCATOR')} and "
f"{rows_logged(_nz_old, 'OUTPUT_CLAMP')})")
check("⭐ while A gives, the site target is the guard's floor (the `hold` "
"ceiling), so a noisy house does not fill the action log: no ALLOCATOR "
"row and at most one clamp row",
on_aim(_nz) and all(abs(r[1] - r[-1][1][1]) < 1e-6 for r in _nz_rows
if cmd(r)["a"] > 0.0)
and rows_logged(_nz, "ALLOCATOR") == 0
and rows_logged(_nz, "OUTPUT_CLAMP") <= 1)
check("non-vacuous: without the ceiling and SAFETY-85's bound the loop asks "
"5000 W and the ALLOCATOR and clamp rows fill the 40-row log",
rows_logged(_nz_old, "ALLOCATOR") >= 10 and len(_nz_old.events) == 40)
check("...and either one alone keeps it clean (without the ceiling: "
f"{rows_logged(_nz_nc, 'ALLOCATOR')} ALLOCATOR rows)",
on_aim(_nz_nc) and rows_logged(_nz_nc, "ALLOCATOR") == 0
and rows_logged(_nz_nc, "OUTPUT_CLAMP") <= 1)
# ============================================================================ #
print("\n4. a month peak banked above the limit")
BANK = {n: (run(6500.0, n, month_peak=4000.0),
without_fix(lambda: run(6500.0, n, month_peak=4000.0)))
for n in (1, 2)}
for n, (new, old) in BANK.items():
print(f" N={n} house 6500, month peak 4000: banked "
f"{[round(v) for v in banked(new[1])]} W (before the fix "
f"{[round(v) for v in banked(old[1])]} W)")
check("⭐ under a month peak of 4000 W the aim is 3950 W (peak - margin), "
"at N=1 and N=2, and no 'Load too high'",
all(on_aim(new[1], 3950.0) and not alarmed(new[1])
for new, _old in BANK.values()))
check("non-vacuous: before the fix N=1 banks the whole house and N=2 banks "
"4000 W, over that aim",
min(banked(BANK[1][1][1])) >= 6499.0
and min(banked(BANK[2][1][1])) >= 3999.0)
# ============================================================================ #
print("\n5. a safety stop still wins")
def blind_at(t0, t1):
"""A's power reading is missing from `t0` to `t1`: a site safety SET."""
def before(c, t):
s84.feed(False)(c, t)
if not getattr(c.hass, "_blind", False):
real = c.hass.number
async def number(e, invert=False):
if e == "sensor.batt_a" and t0 <= c.hass.t < t1:
return None
return await real(e, invert)
c.hass.number, c.hass._blind = number, True
return before
STOP = {n: run(6500.0, n, dur=900.0, before=blind_at(300.0, 600.0))
for n in (1, 2)}
for n, (rows, _c) in STOP.items():
print(f" N={n} reading lost 300-600 s: at {rows[100][0]:.0f} s "
f"{rows[100][2]}, {rows[100][6]}; after {rows[-1][2]}")
check("⭐ with a reading gone the safety SET parks every battery at 0 W, at "
"N=1 and N=2, with nothing refused",
all(all(v == 0.0 for r in rows if 330.0 <= r[0] < 600.0
for v in cmd(r).values())
and any(r[6].startswith("safety -> 0 W") for r in rows)
and refused(rows) == 0 for rows, _c in STOP.values()))
check("...the hold gave before the stop and gives again after it",
all(cmd(rows[50])["a"] > 0.0 and cmd(rows[-1])["a"] > 0.0
for rows, _c in STOP.values()))
# ============================================================================ #
print("\n6. the level dips below the hold's target")
def draining(u, t):
"""A's level falls 1 % a minute from 99 % (set, not integrated); B 60 %."""
return max(20.0, 99.0 - t / 60.0) if u == "a" else 60.0
DIP = {n: run(6500.0, n, hold_min=30, dur=2400.0, soc=draining)
for n in (1, 2)}
for n, (rows, c) in DIP.items():
print(f" N={n}: phases {sorted({tuple(x[-1][3]) for x in rows})}, "
f"last event {[e for e in c.events if 'maintenance' in e][:1]}")
check("⭐ the level falls from 99 % to 70 % while the hold gives, and "
"the hold still ends on its 30 min clock: HOLD -> IDLE, cycle "
"COMPLETE, never back to CHARGE, at N=1 and N=2",
all(c.maints[0].phase == IDLE
and any("cycle COMPLETE" in e for e in c.events)
and {x[-1][3][0] for x in rows} == {HOLD, IDLE}
and all(x[-1][3][0] == HOLD for x in rows if x[0] < 1795.0)
for rows, c in DIP.values()))
check("...the hold gave on the way (the level really dipped)",
all(any(cmd(r)["a"] > 0.0 for r in rows if r[0] < 1795.0)
for rows, _c in DIP.values()))
# ============================================================================ #
print("\n7. the rule at its boundaries")
_c = run(1500.0, 2, dur=10.0)[1]
_c1 = run(1500.0, 1, dur=10.0)[1]
HOLDING = [MaintResult(HOLD, True, 0.0, False, []),
MaintResult(IDLE, False, None, False, [])]
def pin_after(floor, *extra, live=(A, B), results=HOLDING, c=_c):
fake = types.SimpleNamespace(inverters=c.inverters,
actuator_of=c.actuator_of,
live_units=lambda: tuple(live),
_peak_reserve=frozenset)
cl = [Claim.limit("supervised", P_SAFETY, -2500.0, 2500.0, actuator=A),
Claim.limit("supervised", P_SAFETY, -2500.0, 2500.0, actuator=B),
Claim.set("maintenance", P_MAINTENANCE, 0.0, HOLD, actuator=A),
Claim.limit("peak", P_PEAK, lo=floor, reason="quarter peak"),
*extra]
ceiling = Controller._peak_over_hold(fake, cl, results)
pin_after.ceiling = ceiling
return next((x.value for x in cl
if x.source == "maintenance" and x.kind == "set"), None), cl
check("⭐ at N=2 a floor under or at B's 2500 W leaves A at 0 W (never "
"charging); 0.5 W above it raises A's pin to 0.5 W",
pin_after(1000.0)[0] == 0.0 and pin_after(2500.0)[0] == 0.0
and pin_after(2500.5)[0] == 0.5)
check("⭐ B not live, contradicting itself, or refusing to give: A gives the "
"whole floor",
pin_after(2000.0, live=(A,))[0] == 2000.0
and pin_after(2000.0, Claim.limit("pack", P_SAFETY, 10.0, -10.0,
actuator=B))[0] == 2000.0
and pin_after(2000.0, Claim.limit("refused", P_SAFETY, hi=0.0,
actuator=B))[0] == 2000.0)
_probe = _ctl.PROBE_W
check("⭐ B probing: giving counts its probe (A gives the rest), charging "
"leaves A at 0 W so the site is never split both ways",
pin_after(2000.0, Claim.set("refused", P_SAFETY, _probe, "probe",
actuator=B))[0] == 2000.0 - _probe
and pin_after(2000.0, Claim.set("refused", P_SAFETY, -_probe, "probe",
actuator=B))[0] == 0.0)
_p, _cl = pin_after(4000.0, Claim.set("safety", P_SAFETY, 0.0,
"inputs missing"))
check("⭐ a site safety SET: A stays at 0 W and the site resolves to 0 W; "
"a unit safety SET on B does not stop A giving",
_p == 0.0 and resolve(_cl, units=(A, B), live={A, B}).target_w == 0.0
and pin_after(3000.0, Claim.set("failsafe", P_SAFETY, 0.0,
actuator=B))[0] == 3000.0)
_p, _cl = pin_after(2600.0)
_hi = pin_after.ceiling
_cl += [Claim.limit("hold", P_MAINTENANCE, hi=_hi, actuator="site"),
Claim.set("loop", 10, 5000.0, "the loop asks for the whole house")]
pin_after(2500.0)
check("⭐ at N=2 the `hold` site ceiling is the floor itself, only when A "
"gives, and a 5000 W loop ask resolves to that floor",
_hi == 2600.0 and pin_after.ceiling is None
and resolve(_cl, units=(A, B), live={A, B}).target_w == 2600.0)
check("only a HOLD: a DRAIN pin or an idle pack is not raised",
pin_after(4000.0, results=[MaintResult(DRAIN, True, 0.0, False, []),
HOLDING[1]])[0] == 0.0
and pin_after(4000.0, results=[HOLDING[1], HOLDING[1]])[0] == 0.0)
def refile(lo, results=(HOLDING[0],)):
fake = types.SimpleNamespace(inverters=_c1.inverters,
actuator_of=_c1.actuator_of)
cl = [Claim.set("maintenance", P_MAINTENANCE, 0.0, HOLD),
Claim.limit("peak", P_PEAK, lo=lo, reason="quarter peak")]
Controller._peak_over_hold(fake, cl, list(results))
return cl
check("⭐ at N=1 a floor of 0 W stays at P_PEAK, 0.5 W is refiled at "
"P_MAINTENANCE, and a blind claim (a ceiling only) is not refiled",
refile(0.0)[1].priority == P_PEAK
and refile(0.5)[1].priority == P_MAINTENANCE
and refile(float("-inf"))[1].priority == P_PEAK)
_stop = refile(3000.0) + [Claim.set("safety", P_SAFETY, 0.0,
"inputs missing")]
check("⭐ at N=1 a refiled floor stays under a safety SET: 0 W",
resolve(_stop).target_w == 0.0)
check("at N=1 outside a HOLD (idle, or a CHARGE) the claim is not touched "
"by this rule",
refile(3000.0, (MaintResult(IDLE, False, None, False, []),))[1]
.priority == P_PEAK
and refile(3000.0, (MaintResult(CHARGE, False, None, True, []),))[1]
.priority == P_PEAK)
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all SAFETY-86 checks passed")
+712
View File
@@ -0,0 +1,712 @@
"""Runnable check for SAFETY-87: the peak guard uses every battery that can
still give. `python3 test_safety87.py`
At N>=2 the guard graded the EMPTIEST battery (SAFETY-32), so one battery under
the peak reserve stopped the guard for all of them: it asked for nothing and
raised the reserve alarm while the other battery was full. Now a battery at or
under its own reserve is HELD (it gives nothing for the peak, weight 0) and
the guard's need is placed on the others; the reserve alarm fires only when
they cannot hold the limit. Drives the REAL Controller (tools/
safety84_identity.py's harness). DECISIONS/SAFETY-87-guard-uses-able-batteries.md.
1. Maintenance charge at N=2 (A under the reserve, B able), house over the
aim: no reserve alarm, the quarter on the aim, A gives nothing for the
peak while B can carry it. When B cannot, the alarm fires and names A.
2. HOLD at N=2 (A full holding, B under the reserve): A gives, B gives
nothing, the quarter lands on the aim; at a banked month peak too.
3. Both under the reserve, or the other one refusing: the alarm still fires
and names the right battery.
4. The alarm at the quarter's edge: a one-cycle miss does not fire it.
5. place_on_able() at its boundaries, and the claim it files.
6. The allocator: a held battery gives last, after an Off one.
7. A held battery draining for maintenance counts for its drain.
8. The page: a peak alarm does not say "Not ready" or "0 W until the alarm
clears" while control goes on.
9. N=1 is byte-identical to release/1.0 b8ddfd7 (tools/safety87_identity.py).
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import asyncio
import dataclasses
import json
import logging
import os
import sys
import types
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import safety84_identity as s84 # noqa: E402
import safety87_identity as s87 # noqa: E402
from app import messages, packmode, peakguard # noqa: E402
from app.arbiter import (Claim, P_MAINTENANCE, P_MODE, P_PEAK, # noqa: E402
P_SAFETY, UnitBounds, resolve)
from app.main import Controller # noqa: E402
from app.maintenance import DRAIN, HOLD, IDLE, MaintResult # noqa: E402
from app.store import unit_key # noqa: E402
fails = []
A, B = "inverter-a", "inverter-b"
AIM = 3450.0 # peak_cap_w 3500 - peak_margin_w 50
TOL = 10.0 # W over the aim a banked quarter may show (SAFETY-81)
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def store(phase, *, b_off=False):
"""Battery A in `phase` from the first cycle (B idle, cycle done)."""
s = rig._Store()
s.data[unit_key(A, "phase")] = phase
s.data[unit_key(A, "phase_started")] = rig.BASE.isoformat()
s.data[unit_key(B, "last_completed")] = rig.BASE.isoformat()
if phase == IDLE:
s.data[unit_key(A, "last_completed")] = rig.BASE.isoformat()
if b_off:
s.data[unit_key(B, packmode.USE_KEY)] = False
return s
def run(house, phase, socs, *, cheap=False, n=2, **kw):
"""Rows, controller, the notifications raised and one record per cycle:
(floor_limited, held, floor_w, reserve set, alarm latched)."""
notes, seen = [], []
real_check = Controller.check_peak_floor_limited
async def spy(self):
await real_check(self)
g = self.peak_guard
seen.append(None if g is None else (
g.floor_limited, g.held, g.floor_w, self._peak_reserve(),
self._peak_floor_limited))
def before(c, t):
if not getattr(c.hass, "_s87", False):
real = c.hass.call
async def call(*a):
if a[:2] == ("persistent_notification", "create"):
notes.append(a[2])
return await real(*a)
c.hass.call, c.hass._s87 = call, True
return s84.feed(cheap)(c, t)
kw.setdefault("dur", 2700.0)
extra = dict({"maintenance_hold_min": 600}, **kw.pop("extra", {}))
Controller.check_peak_floor_limited = spy
try:
rows, c = s84.run(
n, load=house if callable(house) else lambda t: house,
cheap=cheap, store=kw.pop("store", None) or store(phase),
soc=lambda u, t: socs[u], before=before, extra=extra, **kw)
finally:
Controller.check_peak_floor_limited = real_check
return rows, c, notes, seen
def without_fix(fn):
"""The shipped b8ddfd7 behaviour: no battery is ever held back."""
real = Controller._peak_on_able
Controller._peak_on_able = lambda self, decision, results: decision
try:
return fn()
finally:
Controller._peak_on_able = real
def cmd(r):
return dict(r[2])
def banked(c):
return [b.offtake_avg_w for b in c.p1.blocks]
def on_aim(c, aim=AIM):
b = banked(c)
return len(b) >= 2 and all(aim - TOL <= v <= aim + TOL for v in b)
def reserve_notes(notes):
return [x for x in notes
if x["notification_id"] == messages.ID_PEAK_RESERVE]
def limited_events(c):
return sum(1 for e in c.events if "PEAK_GUARD_FLOOR_LIMITED" in e)
def show(tag, rows, c, notes, seen):
a = [cmd(r)["a"] for r in rows]
b = [cmd(r)["b"] for r in rows]
print(f" {tag}: banked {[round(v, 1) for v in banked(c)]} W, mean A "
f"{sum(a) / len(a):.0f} W, B {sum(b) / len(b):.0f} W, reserve "
f"alarms {len(reserve_notes(notes))}, held "
f"{sorted({h for s in seen if s for h in s[1]})}")
# ============================================================================ #
print("1. maintenance charge at N=2: A under the reserve, B able")
LOW_A = {"a": 11.0, "b": 60.0}
CH = run(4500.0, "charge", LOW_A, cheap=True)
CH_OLD = without_fix(lambda: run(4500.0, "charge", LOW_A, cheap=True))
show("house 4500", *CH)
show("house 4500, before the fix", *CH_OLD)
check("⭐ house 4500 W, B can carry the guard alone: no reserve alarm (no "
"notification, no PEAK_GUARD_FLOOR_LIMITED line) and every quarter on "
"the aim",
not reserve_notes(CH[2]) and limited_events(CH[1]) == 0
and on_aim(CH[1]))
check("...A is held (it gives nothing for the peak) on every cycle the guard "
"asks for power, and the guard asks for a positive floor",
any(s and s[2] and s[2] > 0.0 for s in CH[3])
and all(s[1] == (A,) and s[3] == {A} for s in CH[3]
if s and s[2] and s[2] > 0.0))
check("non-vacuous: before the fix the same run raised the reserve alarm "
"(the SAFETY-84 rig test's check 2/3 false alarm)",
len(reserve_notes(CH_OLD[2])) >= 1 and limited_events(CH_OLD[1]) >= 1)
_b_carry = [r for r, s in zip(CH[0], CH[3])
if s and s[3] == {A} and r[1] <= 2500.0 and r[1] > 0.0]
check("...and while B can carry the site target (at most its 2500 W) A is "
"not asked to give at all (weight 0 behind B)",
len(_b_carry) >= 3 and all(cmd(r)["a"] <= 0.0 for r in _b_carry))
CH65 = run(6500.0, "charge", LOW_A, cheap=True)
show("house 6500", *CH65)
_n65 = reserve_notes(CH65[2])
check("⭐ house 6500 W, B (2500 W) cannot hold the 3450 W aim alone: the "
"reserve alarm fires and names A, the battery under its reserve, and "
"says the others cannot hold the limit alone",
len(_n65) >= 1
and all("inverter-a is at its peak reserve" in x["message"]
and "inverter-b" not in x["message"]
and "cannot keep this 15-minute period under your 3 500 W peak "
"limit alone" in x["message"] for x in _n65))
check("...and while it is limited the guard still asks B for everything B "
"has (floor 2500 W), so B is never left idle",
any(s and s[0] and s[2] == 2500.0 for s in CH65[3])
and all(s[2] == 2500.0 for s in CH65[3] if s and s[0]))
# ============================================================================ #
print("\n2. HOLD at N=2: A full and holding, B under the reserve")
LOW_B = {"a": 99.0, "b": 15.0}
HD = run(5500.0, HOLD, LOW_B)
HD_OLD = without_fix(lambda: run(5500.0, HOLD, LOW_B))
show("house 5500", *HD)
show("house 5500, before the fix", *HD_OLD)
check("⭐ house 5500 W: A gives, B gives nothing, every quarter lands on the "
"aim, no reserve alarm, and A stays in its HOLD",
on_aim(HD[1]) and not reserve_notes(HD[2])
and limited_events(HD[1]) == 0
and all(cmd(r)["b"] == 0.0 for r in HD[0])
and max(cmd(r)["a"] for r in HD[0]) > 2000.0
and HD[1].maints[0].phase == HOLD)
check("non-vacuous: before the fix A sat at 0 W, B (at 15 %) gave through "
"the loop and the reserve alarm fired (SAFETY-86 rig case 7a)",
all(cmd(r)["a"] == 0.0 for r in HD_OLD[0])
and max(cmd(r)["b"] for r in HD_OLD[0]) > 2000.0
and len(reserve_notes(HD_OLD[2])) >= 1)
HD65 = run(6500.0, HOLD, LOW_B)
HD65_OLD = without_fix(lambda: run(6500.0, HOLD, LOW_B))
show("house 6500", *HD65)
show("house 6500, before the fix", *HD65_OLD)
_n = reserve_notes(HD65[2])
check("⭐ house 6500 W, A alone (2500 W) cannot hold the aim: A gives all it "
"has, B still gives nothing, and the reserve alarm fires ONCE naming B",
all(cmd(r)["b"] == 0.0 for r in HD65[0])
and max(cmd(r)["a"] for r in HD65[0]) == 2500.0
and len(_n) == 1 and "inverter-b is at its peak reserve" in
_n[0]["message"] and "inverter-a" not in _n[0]["message"])
check("...and the quarter is no worse than before the fix (4051 W in the "
"SAFETY-86 review), with B kept at its reserve",
max(banked(HD65[1])) <= max(banked(HD65_OLD[1])) + 1.0)
BK = run(6000.0, HOLD, LOW_B, month_peak=4000.0)
show("house 6000, month peak 4000", *BK)
check("⭐ under a banked month peak of 4000 W the aim is 3950 W (peak - "
"margin): A gives, B nothing, every quarter on 3950 W, no alarm",
on_aim(BK[1], 3950.0) and not reserve_notes(BK[2])
and all(cmd(r)["b"] == 0.0 for r in BK[0]))
# ============================================================================ #
print("\n3. a real loss of peak limiting still alarms")
BOTH = run(5500.0, IDLE, {"a": 15.0, "b": 12.0})
show("both under", *BOTH)
_nb = reserve_notes(BOTH[2])
check("⭐ both batteries under the reserve: the reserve alarm fires and "
"names both, the guard asks for nothing (claim lo 0 W, 'SoC floor')",
len(_nb) >= 1
and all("inverter-a and inverter-b" in x["message"] for x in _nb)
and all(not s[2] for s in BOTH[3] if s and s[0])
and any(c[0] == "peak" and c[4] == 0.0 and c[6] == "SoC floor"
for r in BOTH[0] for c in r[-1][0]))
_ctl2 = BOTH[1]
for _inv, _s, _r in zip(_ctl2.inverters, (15.0, 60.0), (0, 1)):
_inv.soc, _inv.refused = _s, _r # SAFETY-73: B refuses to give
_ref = _ctl2._peak_on_able(_lim_all := peakguard.PeakDecision(
engaged=True, floor_w=0.0, ceiling_w=3500.0,
ceiling_from=peakguard.CEILING_CAP, projected_w=4000.0, excess_w=550.0,
wanted_w=2000.0, target_w=3450.0, floor_limited=True, soc=15.0,
soc_floor=20.0, soc_floor_reason=peakguard.FLOOR_AT_SOC_FLOOR,
reason="PEAK_GUARD_FLOOR_LIMITED: x"),
[MaintResult(IDLE, False, None, False, [])] * 2)
check("⭐ A under the reserve and B refusing to give (SAFETY-73): nothing can "
"give for the peak, so it stays limited at 0 W (the alarm fires) and "
"only A is named (test_peakguard.py part 12h drives the notification)",
_ref.floor_limited and _ref.floor_w == 0.0 and _ref.held == (A,)
and _ref.able_w is None and _ctl2._peak_reserve() == frozenset())
_ctl2.inverters[1].refused = 0
_ctl2.live_units = lambda: (A,) # B dropped from the split
_drop = _ctl2._peak_on_able(_lim_all, [MaintResult(IDLE, False, None, False,
[])] * 2)
del _ctl2.live_units
check("...and the same with B dropped (not live): nothing can give, the "
"alarm state stands at 0 W, only A named",
_drop.floor_limited and _drop.floor_w == 0.0 and _drop.held == (A,)
and _drop.able_w is None)
async def _notify(ctl, socs, cutouts, refused):
"""The shipped check_peak_floor_limited() on `ctl`, its notification."""
sent = []
async def call(*a):
sent.append(a)
return True
for inv, so, cut, ref in zip(ctl.inverters, socs, cutouts, refused):
inv.soc, inv.bms_soc_floor, inv.refused = so, cut, ref
real, ctl.hass.call = ctl.hass.call, call
ctl.peak_guard = ctl._peak_on_able(dataclasses.replace(
_lim_all, soc=min(socs), soc_floor=peakguard.binding_soc_floor(
ctl.packs, 20.0)), [MaintResult(IDLE, False, None, False, [])] * 2)
ctl._peak_floor_limited = False
try:
await ctl.check_peak_floor_limited()
finally:
ctl.hass.call = real
for inv in ctl.inverters:
inv.bms_soc_floor, inv.refused = 10, 0
return [a[2]["message"] for a in sent
if a[2].get("notification_id") == messages.ID_PEAK_RESERVE]
# Cut-outs A 10 % / B 35 % (simulated; rule 8 refuses it, sim/inject.py does
# not run rule 8), A at 25 % refusing, B at 30 %. Only B is at its own floor;
# A is above its own but under the site's highest (35 %).
_msg = asyncio.run(_notify(_ctl2, (25.0, 30.0), (10, 35), (1, 0)))
print(f" differing cut-outs: {_msg}")
check("⭐ the alarm names the battery at ITS OWN floor (B, 30 % <= 35 %), not "
"every battery under the site's highest floor (A at 25 % is above its "
"own 20 %)",
len(_msg) == 1 and "inverter-b is at its peak reserve" in _msg[0]
and "inverter-a" not in _msg[0])
# ============================================================================ #
print("\n4. the reserve alarm at the quarter's edge")
def spiky(t):
"""A 5000 W house (B alone can hold the aim) with a 5 s spike to
11000 W every 150 s, which B alone cannot."""
return 11000.0 if t % 150 < 5 else 5000.0
LOW_A_IDLE = {"a": 15.0, "b": 60.0}
SP = run(spiky, IDLE, LOW_A_IDLE)
_flips = sum(1 for s in SP[3] if s and s[0])
show("spiky house", *SP)
print(f" {_flips} cycles floor-limited (B alone short during a spike)")
check("⭐ a 5 s spike that B alone cannot cover makes the guard limited for a "
"cycle, and that does not fire the alarm: the miss must hold for "
"ALARM_HOLD_S, as for 'Load too high'. No quarter goes over the aim",
_flips >= 5 and not reserve_notes(SP[2])
and not any(s[4] for s in SP[3] if s)
and len(banked(SP[1])) >= 2 and max(banked(SP[1])) <= AIM)
def ungated(fn):
real = Controller._reserve_short
Controller._reserve_short = lambda self, g: True
try:
return fn()
finally:
Controller._reserve_short = real
_ug = ungated(lambda: run(spiky, IDLE, LOW_A_IDLE))
check("non-vacuous: without the hold the same run raises the alarm",
len(reserve_notes(_ug[2])) >= 1)
# ============================================================================ #
print("\n5. place_on_able() at its boundaries")
_lim = peakguard.PeakDecision(
engaged=True, floor_w=0.0, ceiling_w=3500.0,
ceiling_from=peakguard.CEILING_CAP, projected_w=4000.0, excess_w=550.0,
wanted_w=2000.0, target_w=3450.0, floor_limited=True, soc=15.0,
soc_floor=20.0, soc_floor_reason=peakguard.FLOOR_AT_SOC_FLOOR,
reason="PEAK_GUARD_FLOOR_LIMITED: x")
def place(d=_lim, held=("b",), able_w=2000.0, soc=60.0, floor=20.0):
return peakguard.place_on_able(d, held, able_w=able_w, soc=soc,
soc_floor=floor)
_eq, _short = place(), place(able_w=1999.0)
check("⭐ the others can give exactly the need (2000 W): not limited, floor "
"2000 W, the claim reads 'quarter peak' (the page says 'the peak "
"limit')",
not _eq.floor_limited and _eq.engaged and _eq.floor_w == 2000.0
and _eq.held == ("b",) and _eq.able_w == 2000.0
and peakguard.claim(_eq).lo == 2000.0
and peakguard.claim(_eq).reason == "quarter peak"
and _eq.reason.startswith("PEAK_GUARD: "))
check("...1 W short: limited, but the claim still asks them for all they "
"have (1999 W), not 0 W",
_short.floor_limited and _short.floor_w == 1999.0
and peakguard.claim(_short).lo == 1999.0
and peakguard.claim(_short).reason == "quarter peak"
and _short.reason.startswith("PEAK_GUARD_FLOOR_LIMITED: "))
check("...the others' own permission is graded: at or under their floor, or "
"unread, nothing is asked (0 W, 'SoC floor')",
all(place(soc=s).floor_w == 0.0 and place(soc=s).floor_limited
and peakguard.claim(place(soc=s)).reason == "SoC floor"
for s in (20.0, 19.0, None)))
check("...the roster's verdict is kept as decide() echoed it (soc 15 %, "
"floor 20 %), and a decision that was not limited is only labelled",
_eq.soc == 15.0 and _eq.soc_floor == 20.0
and all(place(d=d) == dataclasses.replace(d, held=("b",))
for d in (dataclasses.replace(
_lim, floor_limited=False, engaged=False,
floor_w=None),
dataclasses.replace(_lim, wanted_w=None))))
check("...and at N=1 the floor-limited claim is unchanged: 0 W, 'SoC floor'",
peakguard.claim(_lim).lo == 0.0
and peakguard.claim(_lim).reason == "SoC floor")
def three(socs, cuts):
"""`_peak_on_able()` on a stub roster of three (SIMULATED packs)."""
names = ("x", "y", "z")
invs = [types.SimpleNamespace(name=n, refused=0, rating_w=lambda: 2500.0)
for n in names]
stub = types.SimpleNamespace(
inverters=invs, o={"peak_soc_floor": 20},
packs=[peakguard.Pack(n, so, c) for n, so, c in zip(names, socs, cuts)],
live_units=lambda: names)
return Controller._peak_on_able(stub, _lim, [MaintResult(
IDLE, False, None, False, [])] * 3)
# x 25 % (cut-out 10 -> floor 20), y 40 % (cut-out 30), z 15 % (held). x and
# y each clear their own floor, but 25 % is not above y's 30 %: the pair is
# graded min SoC against max floor (SAFETY-32), so nothing is asked.
_t3 = three((25.0, 40.0, 15.0), (10, 30, 10))
_t3ok = three((35.0, 40.0, 15.0), (10, 30, 10))
check("⭐ three batteries, z held: the two others are graded as a pair, the "
"EMPTIEST of them (25 %) against the HIGHEST of their floors (30 %), "
"so nothing is asked; at 35 % they are asked for the need",
_t3.held == ("z",) and _t3.floor_limited and _t3.floor_w == 0.0
and _t3ok.held == ("z",) and not _t3ok.floor_limited
and _t3ok.floor_w == 2000.0)
# ============================================================================ #
print("\n6. the allocator: a held battery gives last")
def split(target, reserve, modes=(), floor=1500.0):
cl = [Claim.set("loop", 10, target),
Claim.limit("supervised", P_SAFETY, -2500.0, 2500.0, actuator=A),
Claim.limit("supervised", P_SAFETY, -2500.0, 2500.0, actuator=B),
Claim.limit("peak", P_PEAK, lo=min(target, floor))]
cl += [Claim.limit("mode", P_MODE, 0.0, 0.0, "off", actuator=u)
for u in modes]
r = resolve(cl, units=(A, B), live=(A, B), weights={A: 1.0, B: 3.0},
reserve=reserve)
return {u: round(v) for u, v in r.shares.items()}
check("⭐ B held: A carries the site target first (B's weight 3.0 ignored) "
"and B only takes what A cannot: 1500 -> A 1500 / B 0, 4000 -> A 2500 "
"/ B 1500",
split(1500.0, {B}) == {A: 1500, B: 0}
and split(4000.0, {B}) == {A: 2500, B: 1500})
check("...no one held: the weights decide, as before",
split(1500.0, set()) == {A: 375, B: 1125})
check("...charging is not touched: a held battery may still charge first",
split(-2000.0, {B}) == split(-2000.0, set()))
check("⭐ A Off and B under the reserve, a 3000 W floor B cannot place: the "
"Off battery gives first (2500 W) and B only the last 500 W - not an "
"equal 1500/1500 split",
split(3000.0, {B}, modes=(A,), floor=3000.0) == {A: 2500, B: 500})
check("...OPEN, recorded: when B alone CAN place the floor, OPT-11 keeps the "
"Off battery resting and B gives it (1500 W). See the DECISIONS entry",
split(1500.0, {B}, modes=(A,)) == {A: 0, B: 1500})
check("...and with B Off and nobody held, Off still gives way to the other "
"battery, as before (OPT-11)",
split(1500.0, set(), modes=(B,)) == {A: 1500, B: 0})
# ============================================================================ #
print("\n7. a held battery draining for maintenance")
_ctl = HD[1]
def able_w(results, socs=(15.0, 60.0)):
for inv, s in zip(_ctl.inverters, socs):
inv.soc, inv.refused = s, 0
d = _ctl._peak_on_able(dataclasses.replace(
_lim, wanted_w=6000.0), results)
return d.able_w, d.held, d.floor_w
_idle = [MaintResult(IDLE, False, None, False, [])] * 2
_drain = [MaintResult(DRAIN, True, 2000.0, False, []),
MaintResult(IDLE, False, None, False, [])]
print(f" A at 15 % idle: {able_w(_idle)}; draining 2000 W: "
f"{able_w(_drain)}")
check("⭐ A under the reserve but draining 2000 W for its monthly cycle: "
"that drain still counts (able 4500 W = B 2500 + 2000), so B is not "
"asked to carry it; idle, only B's 2500 W",
able_w(_idle) == (2500.0, (A,), 2500.0)
and able_w(_drain) == (4500.0, (A,), 4500.0))
# ============================================================================ #
print("\n8. the page while a peak alarm stands and control goes on")
_rows65, _c65 = CH65[0], CH65[1]
_c65._peak_floor_limited = True
_c65.peak_guard = dataclasses.replace(
_lim, floor_w=2500.0, held=(A,), able_w=2500.0)
_pay = _c65.status_payload()
_txt = json.dumps(_pay, ensure_ascii=False)
_row = [r for r in _pay["checks"] if r.get("id") == "peak_guard"]
print(f" banner: {_pay['banner']}; row: {_row[0]['text'] if _row else '-'}")
check("⭐ the banner is the alarm's title without 'Not ready', and nothing "
"on the page says the inverters go to 0 W or that an alarm stopped "
"control",
_pay["banner"] == messages.T_PEAK_RESERVE and _pay["level"] == "bad"
and "Not ready" not in _txt and "until the alarm clears" not in _txt
and "An alarm stopped control" not in _txt)
check("...the row names A and says the others cannot hold the limit alone, "
"in three short sentences",
len(_row) == 1 and _row[0]["text"].startswith(
"inverter-a battery at its peak reserve. The others cannot hold "
"the 3 500 W peak limit alone."))
_c65._peak_floor_limited = False
_blip = [r for r in _c65.checks() if r.get("id") == "peak_guard"]
_c65._peak_floor_limited = True
check("...and while the alarm is not latched (a one-cycle miss) the row is "
"the ordinary amber guard row, not the red reserve row",
len(_blip) == 1 and _blip[0]["warn"] and not _blip[0].get("runs")
and "peak reserve" not in _blip[0]["text"])
_c65.auto = False
_stopped = _c65.status_payload()
_c65.auto = True
check("...a stopped controller still says so: the peak alarm does not take "
"the banner from it", _stopped["banner"] != messages.T_PEAK_RESERVE
and _stopped["level"] != "bad")
# ============================================================================ #
print("\n9. N=1 is byte-identical to release/1.0 b8ddfd7")
PINNED = { # tools/safety87_identity.py on b8ddfd7
"n1_under":
"d2395ef8d74fb23607ad864d5a4bf62c03044bbd9367e873675d7ad10e8c169f",
"n1_crossing":
"97f7321017d9d34ee17a40dfd4abf73d915f25b9faef086a483d2b5cd124b198",
"n1_above":
"45726a285aa84f40542d56482652397c3c1e212ba4fe2009f6cc52f537ccad52",
"n1_under_banked":
"a2876c7cc873bc4c974d2b4bd98e23bba2d007b84545587d2fe0b713b0a6249b",
"n1_charge_low":
"91336e3c0314985a3b00f314c79b5ffb5a740d218de3ad44f88a4db6e4235b32",
"n1_hold":
"0a1df347754c69166b20d04c15ee878bbcefb02abb824b9b452334f68f03e46d",
}
_now = s87.digests()
for k in PINNED:
print(f" {k} {'same' if _now[k] == PINNED[k] else 'DIFFERS'}")
check("⭐ every N=1 digest (claims, commands, the guard's decision and the "
"log) equals b8ddfd7's, under, crossing and above the reserve, in a "
"charge and a HOLD, and under a banked peak",
_now == PINNED)
# ============================================================================ #
print("\n10. review: maintenance pins beside a held battery, N=3, the page")
def _drain_store():
s = rig._Store()
s.data[unit_key(B, "phase")] = DRAIN
s.data[unit_key(B, "phase_started")] = rig.BASE.isoformat()
s.data[unit_key(A, "last_completed")] = rig.BASE.isoformat()
return s
def _drain_run():
return run(8000.0, IDLE, {"a": 15.0, "b": 60.0}, store=_drain_store(),
dur=900.0)
DR = _drain_run()
_real_dor = Controller._drain_over_reserve
_real_rise = Controller._drain_rise # SAFETY-91 would raise B as well
Controller._drain_over_reserve = lambda self, claims, results: None
Controller._drain_rise = lambda self, claims, results: None
try:
DR_OLD = _drain_run()
finally:
Controller._drain_over_reserve = _real_dor
Controller._drain_rise = _real_rise
def _a_for_peak(rows):
"""A's command on the cycles where the site target is the peak floor
and B alone (2500 W) could place it."""
return [cmd(r)["a"] for r in rows
if r[-1][1] and r[-1][1][1] and r[1] <= 2500.0
and abs(r[1] - r[-1][1][1]) < 1.0]
print(f" B draining, A held: A for the peak {_a_for_peak(DR[0])[:4]}, "
f"without the raise {_a_for_peak(DR_OLD[0])[:4]}")
check("⭐ B draining (2000 W pin) while A is held: B is raised to its rating "
"for the peak floor, so A gives nothing for it (peak > drain)",
DR[1].maints[1].phase == DRAIN and _a_for_peak(DR[0])
and all(a <= 0.0 for a in _a_for_peak(DR[0])))
check("non-vacuous: without the raise A gave the floor's rest above B's pin",
any(a > 0.0 for a in _a_for_peak(DR_OLD[0])))
def _stub(names, reserve, live=None):
"""A Controller stand-in for the pin rules, on SIMULATED units."""
s = types.SimpleNamespace(
inverters=[types.SimpleNamespace(name=n) for n in names],
actuator_of=lambda src, inv=None: inv.name,
live_units=lambda: tuple(live or names),
_peak_reserve=lambda: frozenset(reserve))
s._held_pin = lambda cl, u: Controller._held_pin(s, cl, u)
return s
def _hold(floor, names, reserve, extra=(), results=None):
"""`_peak_over_hold()`: x HOLDs; returns (x's pin, resolved shares)."""
s = _stub(names, reserve)
cl = [Claim.limit("supervised", P_SAFETY, -2500.0, 2500.0, actuator=n)
for n in names]
cl += [Claim.set("maintenance", P_MAINTENANCE, 0.0, HOLD, actuator="x"),
Claim.limit("peak", P_PEAK, lo=floor, reason="quarter peak"),
*extra]
res = results or ([MaintResult(HOLD, True, 0.0, False, [])]
+ [MaintResult(IDLE, False, None, False, [])]
* (len(names) - 1))
hi = Controller._peak_over_hold(s, cl, res)
if hi is not None:
cl.append(Claim.limit("hold", P_MAINTENANCE, hi=hi))
r = resolve(cl + [Claim.set("loop", 10, floor)], units=tuple(names),
live=set(names), reserve=reserve)
pin = next((c.value for c in cl
if c.source == "maintenance" and c.actuator == "x"), None)
return pin, {u: round(v) for u, v in r.shares.items()}
_h3 = _hold(3000.0, ("x", "y", "z"), {"z"})
_h3b = _hold(2000.0, ("x", "y", "z"), {"z"})
print(f" N=3 HOLD x, y able, z held: floor 3000 {_h3}, 2000 {_h3b}")
check("⭐ HOLD at N=3 with one held: the able battery gives first, the "
"holding one the rest (3000 -> y 2500, x 500), the held one nothing",
_h3 == (500.0, {"x": 500, "y": 2500, "z": 0})
and _h3b == (0.0, {"x": 0, "y": 2000, "z": 0}))
_hd = _hold(2000.0, ("x", "z"), {"z"},
(Claim.set("maintenance", P_MAINTENANCE, 1500.0, DRAIN,
actuator="z"),),
[MaintResult(HOLD, True, 0.0, False, []),
MaintResult(DRAIN, True, 1500.0, False, [])])
_hp = _hold(2000.0, ("x", "z"), {"z"},
(Claim.set("refused", P_SAFETY, -50.0, "probe", actuator="z"),))
print(f" held z draining 1500: {_hd}; held z charge probe: {_hp}")
check("⭐ a held battery's own pin counts: draining 1500 W, the hold gives "
"only the 500 W rest (no over-placement); a charge probe leaves the "
"hold at 0 W (never split both ways, no park)",
_hd == (500.0, {"x": 500, "z": 1500}) and _hp[0] == 0.0)
_unk = three((60.0, 60.0, 60.0), (10, None, 10))
_unr = three((60.0, None, 60.0), (10, 10, 10))
check("⭐ N=3: an unknown cut-out or an unread level holds that battery "
"only; the two others are asked for the need",
_unk.held == ("y",) and not _unk.floor_limited
and _unk.floor_w == 2000.0 and _unr.held == ("y",)
and not _unr.floor_limited and _unr.floor_w == 2000.0)
_stop_row = {"ok": False, "warn": False, "id": "meter", "text": "Meter down."}
_peak_rows = [r for r in _c65.checks() if r.get("id") == "peak_guard"]
_c65.checks = lambda: _peak_rows[:1] + [_stop_row]
_both = _c65.status_payload()
del _c65.checks
check("⭐ a row that stops control outranks the running peak alarm: the "
"banner says 'Not ready' for it and the Home page shows the hold",
len(_peak_rows) == 1 and _peak_rows[0].get("runs")
and _both["banner"].startswith(messages.NOT_READY)
and "Meter down" in _both["banner"]
and "until the alarm clears" in json.dumps(_both, ensure_ascii=False))
for _inv, _s, _r in zip(_ctl2.inverters, (15.0, 60.0), (0, -1)):
_inv.soc, _inv.refused = _s, _r # B refuses only to CHARGE
_chg = _ctl2._peak_on_able(_lim_all, [MaintResult(IDLE, False, None, False,
[])] * 2)
_ctl2.inverters[1].refused = 0
check("⭐ a battery that refuses only to charge can still give: it stays "
"able and carries the floor (2000 W of 2500 W)",
not _chg.floor_limited and _chg.floor_w == 2000.0
and _chg.able_w == 2500.0 and _chg.held == (A,))
def _short_for(held_w, secs=30.0):
"""`_reserve_short()` over `secs` of a steady quarter: the meter at
`held_w` with the able batteries already at their full 2500 W."""
s = types.SimpleNamespace(_reserve_batt_prev=None,
_reserve_high_since=None,
_peak_floor_limited=False, site_batt_w=2500.0)
g = dataclasses.replace(_lim, floor_w=2500.0, able_w=2500.0)
out = []
for k in range(int(secs / 5.0) + 1):
s._peak_proj = types.SimpleNamespace(
block_start=rig.BASE, elapsed_s=450.0 + 5.0 * k,
projected_w=held_w, held_w=held_w)
out.append(Controller._reserve_short(s, g))
return out
_aim_miss, _limit_miss = _short_for(3480.0), _short_for(3600.0)
check("⭐ the reserve alarm is graded on the real limit, not the aim: a "
"quarter ending at 3480 W (over the 3450 W aim, under 3500 W) never "
"alarms; one ending at 3600 W alarms once it holds ALARM_HOLD_S",
not any(_aim_miss) and not any(_limit_miss[:2])
and all(_limit_miss[2:]))
_runs_src = {}
for _f in ("main.py", "homeview.py"):
with open(os.path.join(HERE, "app", _f), encoding="utf-8") as _fh:
_src = _fh.read()
for _part in _src.split('out.append({')[1:]:
if '"runs": True' in _part.split("})", 1)[0]:
_runs_src.setdefault(_f, []).append(
_part.split('"id": "', 1)[-1].split('"', 1)[0])
check("⭐ 'control still runs' is set on the two peak rows only (saturated "
"and reserve); no row that stops control carries it",
_runs_src == {"main.py": ["peak_guard", "peak_guard"]})
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all SAFETY-87 checks passed")
+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
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""SAFETY-87: at one inverter, is the peak guard byte-identical to before?
python3 tools/safety87_identity.py # prints one digest per scenario
safety84_identity's harness (the REAL Controller on a P1 meter, every claim
and the guard's decision hashed) with the battery under, crossing and above
the peak reserve, the house over the aim, in a maintenance charge and HOLD,
and under a month peak banked at 4000 W. Uses only the API release/1.0
b8ddfd7 has, so the same file runs on both trees; test_safety87.py holds this
tree to the digests printed there.
"""
import os
import sys
from datetime import timedelta
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import opt11_identity as rig # noqa: E402
import safety84_identity as s84 # noqa: E402
DUR = 1800.0
def crossing(u, t):
"""30 % falling 1 % a minute to 12 %: crosses the 20 % reserve."""
return max(12.0, 30.0 - t / 60.0)
def store(phase=None):
"""In `phase` from the first cycle, or idle with a cycle done yesterday."""
s = rig._Store()
if phase is None:
s.data["last_completed"] = (rig.BASE - timedelta(days=1)).isoformat()
else:
s.data["phase"], s.data["phase_started"] = phase, rig.BASE.isoformat()
return s
SCENARIOS = {
"n1_under": dict(soc=lambda u, t: 15.0, load=lambda t: 5500.0),
"n1_crossing": dict(soc=crossing, load=lambda t: 5000.0),
"n1_above": dict(soc=lambda u, t: 60.0, load=lambda t: 5500.0),
"n1_under_banked": dict(soc=lambda u, t: 15.0, load=lambda t: 6000.0,
month_peak=4000.0),
"n1_charge_low": dict(soc=lambda u, t: 11.0, load=lambda t: 5000.0,
store="charge"),
"n1_hold": dict(soc=lambda u, t: 99.0, load=lambda t: 5500.0,
store="hold", cheap=False,
extra={"maintenance_hold_min": 600}),
}
def digests() -> dict:
out = {}
for k, v in SCENARIOS.items():
v = dict(v)
st = store(v.pop("store", None))
out[k] = s84.digest(s84.run(1, dur=DUR, store=st, **v)[0])
return out
if __name__ == "__main__":
for k, v in digests().items():
print(f"{k} {v}")
+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."