Compare commits

...
Author SHA1 Message Date
admin 8b9680fc35 Merge pull request 'Deploy 0.13.0 to master' (#166) from deploy-0.13.0 into master
Reviewed-on: #166
2026-10-02 15:26:46 +02:00
admin 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
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
28 changed files with 2560 additions and 137 deletions
+90
View File
@@ -1,5 +1,95 @@
# Changelog
## 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
@@ -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.
+31 -7
View File
@@ -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,6 +588,7 @@ 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 probe` | the add-on asks 200 W to test whether a refusing battery gives power again |
@@ -1639,7 +1640,7 @@ 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_discharge_w` | 2000 | Drain rate (exports the surplus). The peak guard may ask for more (SAFETY-83; at N≥2 while another battery is at its peak reserve, SAFETY-87). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower |
| `maintenance_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 |
@@ -1669,6 +1670,10 @@ gives more than its drain rate, with one battery too (SAFETY-83).
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).
#### Capacity-tariff exposure of the grid-following loop
@@ -1949,6 +1954,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 +3132,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 +3170,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 +3240,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 +3270,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 +3410,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. |
+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: units under their peak reserve weigh 0 while the site gives,
# so the others carry the peak floor. An Off battery gives the peak's
# shortfall, so it goes before them when no other unit is left.
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)
+7 -1
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:
@@ -614,11 +617,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
@@ -839,6 +844,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 +866,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(
+6 -5
View File
@@ -923,24 +923,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)
+290 -28
View File
@@ -59,7 +59,8 @@ from .circulation import (
)
from .clamp import (
REASON_CODE as CLAMP_REASON, ClampWatchdog,
clamp_to_rating, is_commandable, loop_clamp_note, write_clamp_note,
clamp_to_rating, delivery_short, is_commandable, loop_clamp_note,
shortfall_note, write_clamp_note,
)
from . import freshness
from . import homeview
@@ -74,7 +75,7 @@ from .hass import HomeAssistant
from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
roster_entries, roster_names)
from .maintenance import (
DRAIN, IDLE, POWER_MOVING, MaintConfig, Maintenance, MaintResult,
DRAIN, HOLD, IDLE, POWER_MOVING, MaintConfig, Maintenance, MaintResult,
arbitrate as maint_arbitrate,
)
from .mqtt import (MODE_CONFIGS_KEY, MODE_LABELS, USE_OFF, USE_ON,
@@ -280,6 +281,11 @@ def pack_energy_of(inv):
# ordinary load steps. At 2 s and slower this changes nothing.
REFUSED_SPAN_S = (_ctl.SETTLE_CYCLES - 1) * _ctl.LOOP_TICK_S
# SAFETY-85 review: a battery another owner set is graded against the least it
# was told over the same span as above (the plant's measured settle time), so
# a healthy battery still settling on a new command is not short.
SHORT_CYCLES, SHORT_SPAN_S = _ctl.SETTLE_CYCLES, REFUSED_SPAN_S
def _refused_interval(sign: int) -> tuple:
"""SAFETY-73: the (lo, hi) a refusing unit may be asked: 0 W that way."""
@@ -936,6 +942,9 @@ class Controller:
self._peak_alarm_block = None
self._peak_high_since = None
self._peak_floor_limited = False
# SAFETY-87: the reserve alarm's hold, as SAFETY-81's above.
self._reserve_batt_prev = None
self._reserve_high_since = None
self._maint_shape_suspended = False
# OPT-11. Battery modes, off by default so an install that has not
# turned them on behaves exactly as before (SAFETY-10 section 1). Each
@@ -1176,6 +1185,12 @@ class Controller:
self.loop_clamped: tuple[bool, str, bool] = (False, "", False)
# UX-08: the numbers of a loop-side over-rating, for the notification.
self.loop_clamp_facts: tuple = ()
# SAFETY-85: this cycle's loop Decision, each battery's recent
# standing commands, and how many cycles in a row a battery set by
# another owner gave less than asked.
self._loop_decision = None
self._asked_hist: dict = {}
self._short_run = 0
self.stopping = False
# SAFETY-61. time.monotonic() at the end of the last run_control() pass.
self.loop_pass_mono = None
@@ -2514,6 +2529,44 @@ class Controller:
self._drain_waits = waits
return floor if floor > 0.0 else None
def _peak_on_able(self, decision, maint_results):
"""SAFETY-87: at N>=2, `decision` placed on the batteries that can
still give: live, not refusing, above their own reserve. `able_w` is
their rating plus the drain a held battery gives anyway (its
maintenance pin). Unchanged when no battery is held back.
DECISIONS/SAFETY-87-guard-uses-able-batteries.md."""
if len(self.inverters) < 2:
return decision
floor = float(self.o.get(
"peak_soc_floor", peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"]))
ok, held = peakguard.reserve_split(self.packs, floor)
if not held:
return decision
live = set(self.live_units())
able = [p for p, inv in zip(self.packs, self.inverters)
if p in ok and inv.name in live and not (inv.refused or 0) > 0]
if not able:
return dataclasses.replace(decision, held=held)
drain = sum(min(float(r.setpoint_w), i.rating_w())
for i, r in zip(self.inverters, maint_results)
if i.name in held and r.phase == DRAIN
and r.owns_setpoint and (r.setpoint_w or 0.0) > 0.0)
names = {p.name for p in able}
return peakguard.place_on_able(
decision, held,
able_w=drain + sum(i.rating_w() for i in self.inverters
if i.name in names),
soc=min(float(p.soc) for p in able),
soc_floor=peakguard.binding_soc_floor(able, floor))
def _peak_reserve(self) -> frozenset:
"""SAFETY-87: the batteries that give nothing for the peak this
cycle: under their reserve while the others carry a peak floor."""
g = self.peak_guard
if g is None or g.able_w is None or not (g.floor_w or 0.0) > 0.0:
return frozenset()
return frozenset(g.held)
def _peak_over_drain(self, claims, maint_results) -> None:
"""SAFETY-83: at N=1 a peak floor above the drain rate outranks it.
@@ -2521,9 +2574,11 @@ class Controller:
(P_PEAK), so the floor did not bind and the house imported what the
drain left. Where the floor asks for more than the drain rate, it is
refiled at P_MAINTENANCE and wins; nothing else changes. Safety claims
(P_SAFETY) still outrank it. At N>=2 the `drain` floor does this.
(P_SAFETY) still outrank it. At N>=2 the `drain` floor does this, and
`_drain_over_reserve()` when a battery is held at its reserve.
"""
if len(self.inverters) != 1:
self._drain_over_reserve(claims, maint_results)
return
res = maint_results[0]
if res.phase != DRAIN or not res.owns_setpoint:
@@ -2533,6 +2588,113 @@ class Controller:
if c.source == peakguard.SOURCE and c.lo > rate:
claims[i] = dataclasses.replace(c, priority=P_MAINTENANCE)
def _held_pin(self, claims, u):
"""SAFETY-87: what held unit `u` places anyway (its own pin), or None
when that pin charges, so the site is never split both ways."""
b = pre_resolve([c for c in claims if c.actuator == u])
p = None if b.contradiction else allocator.pin_w(b)
return None if p is not None and p < 0.0 else max(p or 0.0, 0.0)
def _drain_over_reserve(self, claims, maint_results) -> None:
"""SAFETY-87, N>=2: while a battery is held at its peak reserve, a
draining battery that may still give is raised from its drain rate
towards its rating for the peak floor, after the others. Otherwise
the floor's rest lands on the held battery (its weight 0 only orders
the split; the drain pin binds). A site stop still decides alone."""
reserve = self._peak_reserve()
floor = max((c.lo for c in claims if c.source == peakguard.SOURCE),
default=0.0)
if not reserve or not floor > 0.0 or any(
c.kind == SET and c.actuator == ACTUATOR_SITE
and c.priority > P_PEAK for c in claims):
return
live = set(self.live_units())
drains, others = [], 0.0
for inv, res in zip(self.inverters, maint_results):
u = self.actuator_of("maintenance", inv)
if u not in live:
continue
if inv.name in reserve:
p = self._held_pin(claims, u)
if p is None:
return
others += p
continue
b = pre_resolve([c for c in claims if c.actuator == u])
if b.contradiction:
continue
p = allocator.pin_w(b)
if p is not None and p < 0.0:
return # a pack pinned to charge: never split both ways
if res.phase == DRAIN and b.pinned_by == "maintenance" \
and p is not None and p > 0.0:
drains.append((u, p, allocator.hi_w(b)))
others += allocator.hi_w(b) if p is None else p
give = floor - others
for u, p, hi in drains:
if give <= 0.0:
break
up = max(p, min(hi, p + give))
give -= up - p
for i, c in enumerate(claims):
if c.source == "maintenance" and c.kind == SET \
and c.actuator == u:
claims[i] = dataclasses.replace(c, value=up)
def _peak_over_hold(self, claims, maint_results):
"""SAFETY-86: a peak floor outranks a maintenance HOLD (0 W).
At N=1 the floor is refiled at P_MAINTENANCE, as for a drain. At N>=2
the holding pack gives only what the others cannot: its pin is raised
to the floor minus what they may place, and the floor is returned as
the site `hold` ceiling (else None). A site stop still decides alone.
DECISIONS/SAFETY-86-peak-over-hold.md.
"""
hold = {self.actuator_of("maintenance", inv)
for inv, res in zip(self.inverters, maint_results)
if res.phase == HOLD and res.owns_setpoint}
peak = [i for i, c in enumerate(claims)
if c.source == peakguard.SOURCE and c.lo > 0.0]
if not hold or not peak:
return
if len(self.inverters) == 1:
for i in peak:
claims[i] = dataclasses.replace(claims[i],
priority=P_MAINTENANCE)
return
if any(c.kind == SET and c.actuator == ACTUATOR_SITE
and c.priority > P_PEAK for c in claims):
return
live = set(self.live_units())
reserve = self._peak_reserve() # SAFETY-87: they give nothing
others = 0.0
for inv in self.inverters:
u = self.actuator_of("maintenance", inv)
if u in hold or u not in live:
continue
if inv.name in reserve: # only its own pin, never both ways
p = self._held_pin(claims, u)
if p is None:
return
others += p
continue
b = pre_resolve([c for c in claims if c.actuator == u])
if b.contradiction:
continue
p = allocator.pin_w(b)
if p is not None and p < 0.0:
return # a pack pinned to charge: never split both ways
others += allocator.hi_w(b) if p is None else p
floor = max(claims[i].lo for i in peak)
give = floor - others
if give <= 0.0:
return
for i, c in enumerate(claims):
if c.source == "maintenance" and c.kind == SET \
and c.actuator in hold:
claims[i] = dataclasses.replace(c, value=give)
return floor
def _charge_headroom(self, floor: float, claims) -> float:
"""SAFETY-84: at N>=2 a cheap-window charge pulls no more than the
quarter's headroom under the peak aim (`charge_headroom_w()`), so it
@@ -3905,7 +4067,8 @@ class Controller:
# business, not this block's: a maintenance DRAIN or HOLD owns the
# setpoint at P_MAINTENANCE, and a maintenance CHARGE is only ever a
# P_MAINT_SHAPE limit (40), bound by this floor. A floor above the drain
# rate still wins (SAFETY-78 at N>=2, `_peak_over_drain()` at N=1).
# rate still wins (SAFETY-78 at N>=2, `_peak_over_drain()` at N=1), and
# so does a floor over a HOLD (`_peak_over_hold()`, SAFETY-86).
#
# ⚠️ AND IT IS NOT RUN AT ALL WITHOUT P1. `meter_source: off` is the
# legacy single-entity path: there is no telegram stream, so
@@ -3973,6 +4136,11 @@ class Controller:
dev_min=self.site_dev_bound("dev_min"),
dev_max=self.site_dev_bound("dev_max")),
)
# SAFETY-87: at N>=2 a battery at or under its own reserve gives
# nothing for the peak, and the others carry the guard's need.
if self.peak_guard is not None:
self.peak_guard = self._peak_on_able(self.peak_guard,
maint_results)
# SITE, and peakguard.claim() says so on the object it builds. The
# quantity is the 15-minute average OFFTAKE AT THE GRID CONNECTION,
# which is what the capacity tariff bills; there is one quarter-hour
@@ -4307,6 +4475,13 @@ class Controller:
reason="maintenance drain",
actuator=self.actuator_of("drain")))
self._peak_over_drain(claims, maint_results)
# --- SAFETY-86: while a HOLD gives, the site stays at the peak floor --
# So the loop never asks for watts the hold keeps back.
hold_hi = self._peak_over_hold(claims, maint_results)
if hold_hi is not None:
claims.append(Claim.limit("hold", P_MAINTENANCE, hi=hold_hi,
reason="maintenance hold",
actuator=self.actuator_of("hold")))
# --- SAFETY-84: a cheap-window charge is a ceiling over the site ----
charge = self._charge_ceiling(claims)
if charge is not None:
@@ -4384,9 +4559,13 @@ class Controller:
refused=self.batt_refused if len(self.inverters) == 1
else self._site_refused(),
probe=probe,
# OPT-11: None unless battery modes are on, which is the law
# exactly as it was.
bound=self.loop_bound(claims) if self.modes_on else None,
# OPT-11: None unless battery modes are on. SAFETY-85: or while
# maintenance holds a battery at N>=2, so the loop does not
# wind past what the others can give and jump when it ends.
bound=(self.loop_bound(claims) if self.modes_on or (
len(self.inverters) > 1
and any(r.phase != IDLE for r in maint_results))
else None),
)
if decision.frozen and self.sat_count < self.tuning.saturation_cycles:
self.log_event(f"saturation freeze ({self.target:.0f} W vs "
@@ -4395,6 +4574,7 @@ class Controller:
self.sat_count = decision.sat_count
self.i_w = decision.i_w
self.loop_w = decision.target_w
self._loop_decision = decision
if len(self.inverters) == 1:
self._refusal_update(decision, loop_prev, site_batt, probe)
# --- TEL-10 ------------------------------------------------------
@@ -4474,6 +4654,7 @@ class Controller:
# de-assert when control is stopped.
self.loop_clamped = (False, "", False)
self.loop_clamp_facts = ()
self._loop_decision = None
# SAFETY-28. A new resolution is a new tick, and the previous record is
# stale from this line onwards. Incremented BEFORE resolve() so that the
@@ -4498,7 +4679,8 @@ class Controller:
_live = self.live_units()
while True:
resolution = resolve(claims, units=self.units, live=_live,
weights=self.pack_weights, tick=self.alloc_tick)
weights=self.pack_weights, tick=self.alloc_tick,
reserve=self._peak_reserve())
# SAFETY-73: a probe pin stands only while the resolved site target
# asks at least PROBE_W its way. Below that a sibling would carry
# the opposite direction (circulation), so the pin goes back to the
@@ -4692,6 +4874,8 @@ class Controller:
# That is the dropped-link case: the inverter holds its last command
# FOREVER, so "not in the split" must mean zero on the wire, not
# silence. The write is the same clamped, budgeted path as every other.
# SAFETY-85, before the writes: the wire still holds last cycle's.
self._loop_alarm_input(resolution)
writes = []
for inv in self.inverters:
inv.target = float(resolution.shares.get(inv.name, 0.0))
@@ -4905,6 +5089,49 @@ class Controller:
for inv in self.inverters:
inv.target = shadow.WIRE_W
def _loop_alarm_input(self, resolution) -> None:
"""SAFETY-85. The loop's clamp counts only on a cycle the loop decided
(a rating may cut it). A battery another owner set to discharge counts
when it gave less than it was told for `saturation_cycles` cycles in a
row. DECISIONS/SAFETY-85-no-false-not-keeping-up.md."""
d = self._loop_decision
if d is None:
self._short_run = 0
self._asked_hist = {}
return
unmet = self.allocation.unmet_w if self.allocation is not None else 0
cut = (resolution.bound_by or "").split("(")[0]
owns = (resolution.winner == "loop" and not d.held and not unmet
and (resolution.target_w == d.target_w
or cut in ("device", "supervised")))
# Graded against the least that stood on the wire (0 W in shadow
# mode) over SHORT_CYCLES cycles and SHORT_SPAN_S seconds.
now, asked = self._now_mono, {}
for inv in self.inverters:
h = self._asked_hist.setdefault(inv.name, [])
h.append((now, inv.standing_w()))
while len(h) > SHORT_CYCLES and (
now is None or h[0][0] is None
or now - h[0][0] > SHORT_SPAN_S):
h.pop(0)
asked[inv.name] = min(w for _t, w in h)
others = [inv for inv in self.inverters if not owns
or allocator.pin_w(resolution.unit_bounds.get(inv.name))
is not None]
short = [(inv, asked[inv.name], inv.batt) for inv in others
if inv.batt is not None
and delivery_short(asked[inv.name], inv.batt,
self.tuning.saturation_w)]
self._short_run = self._short_run + 1 if short else 0
if owns and self.loop_clamped[0]:
return
engaged = self._short_run >= self.tuning.saturation_cycles
named = len(self.inverters) > 1
self.loop_clamped = (engaged, shortfall_note(
[(inv.name if named else None, a, g) for inv, a, g in short])
if engaged else "", False)
self.loop_clamp_facts = ()
async def check_output_clamp(self) -> None:
"""SAFETY-03. Alarm whenever a clamp actually engaged this cycle.
@@ -5114,6 +5341,8 @@ class Controller:
floor-limited the guard asked for NOTHING, because the pack is at or
under the EMS floor, or its SoC could not be read.
Nothing is being discharged for this quarter at all.
(SAFETY-87, N>=2: or the batteries above their
reserve give all they can and it is not enough.)
They cannot both be true: peakguard.decide()'s floor-limited branch
returns before the `achievable_w` clamp that sets `saturated`.
@@ -5134,6 +5363,10 @@ class Controller:
"""
g = self.peak_guard
limited = bool(g is not None and g.floor_limited)
if limited and (g.floor_w or 0.0) > 0.0:
limited = self._reserve_short(g)
else:
self._reserve_batt_prev = self._reserve_high_since = None
was, self._peak_floor_limited = self._peak_floor_limited, limited
if not limited or was:
return
@@ -5156,13 +5389,8 @@ class Controller:
# think the pack is AT it. `decide()` guards its own reason string the
# same way; a f-string that formats a None here would turn the one
# notification that matters into a traceback.
# ⚠️ SAFETY-32. WHICH PACK STOPPED THE SITE. `g.soc_floor` is the floor
# the decision was actually taken with, so this re-grades nothing and
# cannot disagree with the alarm it is printed inside. At a roster of one
# no pack is named - there is exactly one.
_blocked = [name for name, why
in peakguard.pack_permissions(self.packs, g.soc_floor)
if why is not None]
# SAFETY-32/87: which battery is at its reserve, each graded on its own
# floor (`g.held`). At a roster of one no battery is named.
# ⚠️ SAFETY-25's sentence (the reserve binds the peak guard only) is
# part of messages.peak_reserve() on every branch, the unreadable-SoC
# one included.
@@ -5171,8 +5399,31 @@ class Controller:
g.ceiling_w, soc_unread=g.soc is None,
floor_unknown=(g.soc is not None
and g.soc_floor is None),
packs=(_blocked if len(self.inverters) > 1
else ())).data())
packs=(g.held if len(self.inverters) > 1
else ()),
others=(g.floor_w or 0.0) > 0.0).data())
def _reserve_short(self, g) -> bool:
"""SAFETY-87: the batteries that can still give, at full power, miss
the limit (`load_too_high()` on `g.able_w`), for ALARM_HOLD_S, as
SAFETY-81 grades 'Load too high'."""
p, batt = self._peak_proj, self.site_batt_w
if batt is None:
return self._peak_floor_limited
prev, self._reserve_batt_prev = self._reserve_batt_prev, batt
if prev is not None:
batt = min(float(batt), float(prev))
if not (p is not None and p.block_start is not None
and peakguard.load_too_high(p, batt_w=batt,
ceiling_w=g.ceiling_w,
achievable_w=g.able_w)):
self._reserve_high_since = None
return False
t = p.block_start + timedelta(seconds=float(p.elapsed_s))
if self._reserve_high_since is None:
self._reserve_high_since = t
return ((t - self._reserve_high_since).total_seconds()
>= peakguard.ALARM_HOLD_S)
async def check_circulating(self) -> None:
"""SAFETY-31 + SAFETY-34. The alarm, and it must survive a restart.
@@ -5943,8 +6194,10 @@ class Controller:
"text": messages.row_peak_guard_waiting()})
elif _pg.saturated:
out.append({"ok": False, "warn": False, "id": "peak_guard",
"runs": True,
"text": messages.row_peak_saturated(_pg.ceiling_w)})
elif _pg.floor_limited:
elif _pg.floor_limited and (not (_pg.floor_w or 0.0) > 0.0
or self._peak_floor_limited):
# SAFETY-24. NOT ok, like saturation: in both states the money
# is going to be spent and a human is the only actuator left. A
# separate row from saturation because the two ask for opposite
@@ -5970,18 +6223,17 @@ class Controller:
# UX-08: the same three branches, and the same pack naming, as
# check_peak_floor_limited()'s notification. The figures stay in
# `_pg.reason`, on the log and the event list.
_blocked = [name for name, why
in peakguard.pack_permissions(self.packs,
_pg.soc_floor)
if why is not None]
# SAFETY-87: `runs`, control goes on (status_payload()).
out.append({"ok": False, "warn": False, "id": "peak_guard",
"runs": True,
"banner": messages.banner_peak_reserve(),
"text": messages.row_peak_reserve(
_pg.ceiling_w, soc_unread=_pg.soc is None,
floor_unknown=(_pg.soc is not None
and _pg.soc_floor is None),
packs=(_blocked if len(self.inverters) > 1
else ()))})
packs=(_pg.held if len(self.inverters) > 1
else ()),
others=(_pg.floor_w or 0.0) > 0.0)})
elif _pg.blind:
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_blind(
@@ -6102,7 +6354,8 @@ class Controller:
elif _cw.active:
# SAFETY-40: the integrator bound alone is amber, not red.
out.append({"ok": False, "warn": True, "id": "not_keeping_up",
"text": messages.row_not_keeping_up(_cw.bound)})
"text": messages.row_not_keeping_up(
_cw.bound_episodes)})
# No over-rating in THIS episode (`bug` would be set), so any count
# is from an earlier one: keep that software-fault evidence shown.
if _cw.over_rating:
@@ -6114,10 +6367,10 @@ class Controller:
out.append({"ok": False, "warn": True, "id": "power_too_high",
"text": messages.row_before(
messages.T_OUTPUT_CLAMP, _cw.over_rating)})
if _cw.bound:
if _cw.bound_episodes:
out.append({"ok": False, "warn": True, "id": "not_keeping_up",
"text": messages.row_before(
messages.T_LOOP_BOUND, _cw.bound)})
messages.T_LOOP_BOUND, _cw.bound_episodes)})
for inv in self.inverters:
if inv.last_write_ok is False:
@@ -6304,6 +6557,11 @@ class Controller:
def status_payload(self, lang: str = "en") -> dict:
checks = self.checks()
bad = [c for c in checks if not c["ok"] and not c["warn"]]
# SAFETY-87: a red row with `runs` (the peak alarms) is an alarm while
# control goes on, so it neither reads "Not ready" nor holds the page.
runs = [c for c in bad if c.get("runs")]
bad = [c for c in bad if not c.get("runs")]
running_alarm = False
# --- SAFETY-10, AND IT IS FIRST IN THE CASCADE -----------------------
# "The battery is NOT being controlled" is the one sentence on this
# page that may never be displaced by another condition. A banner that
@@ -6356,6 +6614,9 @@ class Controller:
level, banner = "bad", messages.banner_not_ready(bad[0])
elif not self.auto:
level, banner = "warn", messages.banner_stopped()
elif runs:
level, running_alarm = "bad", True
banner = runs[0].get("banner") or messages.headline(runs[0]["text"])
elif any(i.delivery_wd.tripped for i in self.inverters):
# ⚠️ SAFETY-42, AND IT IS A `warn` BANNER RATHER THAN A `bad` ONE,
# WHICH IS THE WHOLE "reports, does not act" DECISION IN ONE LINE.
@@ -6477,6 +6738,7 @@ class Controller:
decided_at=self.arb_at, cycle_at=self.cycle_at,
now=datetime.now(timezone.utc), why=self.why_words)),
]
held = level == "bad" and not running_alarm
out = {
"banner": banner, "level": level, "rows": rows, "checks": checks,
"hint": self.hint,
@@ -6491,7 +6753,7 @@ class Controller:
# fields are served alone (the page then shows the installer details).
try:
out.update(homeview.build(self, level=level, checks=checks,
lang=lang))
lang=lang, held=held))
except Exception: # noqa: BLE001
# Once per process at ERROR: the page polls every 3 s.
if not getattr(self, "_home_failed", False):
+22 -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.")
@@ -836,6 +848,7 @@ WHY_LIMIT = {
"reserve": "the battery reserve",
"maintenance": "battery maintenance",
"charge": "battery maintenance",
"hold": "battery maintenance",
"refused": "a battery that refuses power",
}
@@ -1013,6 +1026,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(
@@ -1115,6 +1131,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"]),
+46 -1
View File
@@ -397,7 +397,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 +490,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
@@ -733,6 +740,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 +1141,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)
+1 -1
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller
version: "0.12.0"
version: "0.13.0"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
+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,
+7 -5
View File
@@ -553,12 +553,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 "
+20 -6
View File
@@ -108,15 +108,27 @@ 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"
for k in A5DDDAD:
print(f" {k:7} {'same' if _now[k] == A5DDDAD[k] else 'DIFFERS'} "
f"{_now[k][:16]}")
@@ -129,12 +141,14 @@ check("⭐ at N=1 and N=2, with the option absent and with it false, every "
{k: v for k, v in _now.items() if k != "n2_maint"}
== {k: v for k, v in A5DDDAD.items() if k != "n2_maint"})
check("...and n2_maint is the SAFETY-78 digest: the N=2 drain that a5dddad "
"refused on 392 of 540 cycles now runs (test_safety78.py)",
"refused on 392 of 540 cycles now runs (test_safety78.py), bounded by "
"SAFETY-85",
_now["n2_maint"] == SAFETY78_N2_MAINT != A5DDDAD["n2_maint"])
check("...and no cycle in those runs filed a `mode` claim or handed "
"`compute()` a bound",
check("...and no cycle in those runs filed a `mode` claim, and only "
"n2_maint's (SAFETY-85: maintenance at N=2) handed `compute()` a bound",
_claims and not any(c.source == "mode" for cl in _claims for c in cl)
and _bounds and set(_bounds) == {None})
and _bounds and set(_bounds) == {None}
and any(b is not None for b in _bounds_m))
# ============================================================================ #
+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}")
+70 -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())
@@ -3171,7 +3182,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 +4146,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 +4232,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 +4397,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 +4598,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 +5745,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 -2
View File
@@ -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")
+709
View File
@@ -0,0 +1,709 @@
"""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
Controller._drain_over_reserve = lambda self, claims, results: None
try:
DR_OLD = _drain_run()
finally:
Controller._drain_over_reserve = _real_dor
def _a_for_peak(rows):
"""A's command on the cycles where the site target is the peak floor
and B alone (2500 W) could place it."""
return [cmd(r)["a"] for r in rows
if r[-1][1] and r[-1][1][1] and r[1] <= 2500.0
and abs(r[1] - r[-1][1][1]) < 1.0]
print(f" B draining, A held: A for the peak {_a_for_peak(DR[0])[:4]}, "
f"without the raise {_a_for_peak(DR_OLD[0])[:4]}")
check("⭐ B draining (2000 W pin) while A is held: B is raised to its rating "
"for the peak floor, so A gives nothing for it (peak > drain)",
DR[1].maints[1].phase == DRAIN and _a_for_peak(DR[0])
and all(a <= 0.0 for a in _a_for_peak(DR[0])))
check("non-vacuous: without the raise A gave the floor's rest above B's pin",
any(a > 0.0 for a in _a_for_peak(DR_OLD[0])))
def _stub(names, reserve, live=None):
"""A Controller stand-in for the pin rules, on SIMULATED units."""
s = types.SimpleNamespace(
inverters=[types.SimpleNamespace(name=n) for n in names],
actuator_of=lambda src, inv=None: inv.name,
live_units=lambda: tuple(live or names),
_peak_reserve=lambda: frozenset(reserve))
s._held_pin = lambda cl, u: Controller._held_pin(s, cl, u)
return s
def _hold(floor, names, reserve, extra=(), results=None):
"""`_peak_over_hold()`: x HOLDs; returns (x's pin, resolved shares)."""
s = _stub(names, reserve)
cl = [Claim.limit("supervised", P_SAFETY, -2500.0, 2500.0, actuator=n)
for n in names]
cl += [Claim.set("maintenance", P_MAINTENANCE, 0.0, HOLD, actuator="x"),
Claim.limit("peak", P_PEAK, lo=floor, reason="quarter peak"),
*extra]
res = results or ([MaintResult(HOLD, True, 0.0, False, [])]
+ [MaintResult(IDLE, False, None, False, [])]
* (len(names) - 1))
hi = Controller._peak_over_hold(s, cl, res)
if hi is not None:
cl.append(Claim.limit("hold", P_MAINTENANCE, hi=hi))
r = resolve(cl + [Claim.set("loop", 10, floor)], units=tuple(names),
live=set(names), reserve=reserve)
pin = next((c.value for c in cl
if c.source == "maintenance" and c.actuator == "x"), None)
return pin, {u: round(v) for u, v in r.shares.items()}
_h3 = _hold(3000.0, ("x", "y", "z"), {"z"})
_h3b = _hold(2000.0, ("x", "y", "z"), {"z"})
print(f" N=3 HOLD x, y able, z held: floor 3000 {_h3}, 2000 {_h3b}")
check("⭐ HOLD at N=3 with one held: the able battery gives first, the "
"holding one the rest (3000 -> y 2500, x 500), the held one nothing",
_h3 == (500.0, {"x": 500, "y": 2500, "z": 0})
and _h3b == (0.0, {"x": 0, "y": 2000, "z": 0}))
_hd = _hold(2000.0, ("x", "z"), {"z"},
(Claim.set("maintenance", P_MAINTENANCE, 1500.0, DRAIN,
actuator="z"),),
[MaintResult(HOLD, True, 0.0, False, []),
MaintResult(DRAIN, True, 1500.0, False, [])])
_hp = _hold(2000.0, ("x", "z"), {"z"},
(Claim.set("refused", P_SAFETY, -50.0, "probe", actuator="z"),))
print(f" held z draining 1500: {_hd}; held z charge probe: {_hp}")
check("⭐ a held battery's own pin counts: draining 1500 W, the hold gives "
"only the 500 W rest (no over-placement); a charge probe leaves the "
"hold at 0 W (never split both ways, no park)",
_hd == (500.0, {"x": 500, "z": 1500}) and _hp[0] == 0.0)
_unk = three((60.0, 60.0, 60.0), (10, None, 10))
_unr = three((60.0, None, 60.0), (10, 10, 10))
check("⭐ N=3: an unknown cut-out or an unread level holds that battery "
"only; the two others are asked for the need",
_unk.held == ("y",) and not _unk.floor_limited
and _unk.floor_w == 2000.0 and _unr.held == ("y",)
and not _unr.floor_limited and _unr.floor_w == 2000.0)
_stop_row = {"ok": False, "warn": False, "id": "meter", "text": "Meter down."}
_peak_rows = [r for r in _c65.checks() if r.get("id") == "peak_guard"]
_c65.checks = lambda: _peak_rows[:1] + [_stop_row]
_both = _c65.status_payload()
del _c65.checks
check("⭐ a row that stops control outranks the running peak alarm: the "
"banner says 'Not ready' for it and the Home page shows the hold",
len(_peak_rows) == 1 and _peak_rows[0].get("runs")
and _both["banner"].startswith(messages.NOT_READY)
and "Meter down" in _both["banner"]
and "until the alarm clears" in json.dumps(_both, ensure_ascii=False))
for _inv, _s, _r in zip(_ctl2.inverters, (15.0, 60.0), (0, -1)):
_inv.soc, _inv.refused = _s, _r # B refuses only to CHARGE
_chg = _ctl2._peak_on_able(_lim_all, [MaintResult(IDLE, False, None, False,
[])] * 2)
_ctl2.inverters[1].refused = 0
check("⭐ a battery that refuses only to charge can still give: it stays "
"able and carries the floor (2000 W of 2500 W)",
not _chg.floor_limited and _chg.floor_w == 2000.0
and _chg.able_w == 2500.0 and _chg.held == (A,))
def _short_for(held_w, secs=30.0):
"""`_reserve_short()` over `secs` of a steady quarter: the meter at
`held_w` with the able batteries already at their full 2500 W."""
s = types.SimpleNamespace(_reserve_batt_prev=None,
_reserve_high_since=None,
_peak_floor_limited=False, site_batt_w=2500.0)
g = dataclasses.replace(_lim, floor_w=2500.0, able_w=2500.0)
out = []
for k in range(int(secs / 5.0) + 1):
s._peak_proj = types.SimpleNamespace(
block_start=rig.BASE, elapsed_s=450.0 + 5.0 * k,
projected_w=held_w, held_w=held_w)
out.append(Controller._reserve_short(s, g))
return out
_aim_miss, _limit_miss = _short_for(3480.0), _short_for(3600.0)
check("⭐ the reserve alarm is graded on the real limit, not the aim: a "
"quarter ending at 3480 W (over the 3450 W aim, under 3500 W) never "
"alarms; one ending at 3600 W alarms once it holds ALARM_HOLD_S",
not any(_aim_miss) and not any(_limit_miss[:2])
and all(_limit_miss[2:]))
_runs_src = {}
for _f in ("main.py", "homeview.py"):
with open(os.path.join(HERE, "app", _f), encoding="utf-8") as _fh:
_src = _fh.read()
for _part in _src.split('out.append({')[1:]:
if '"runs": True' in _part.split("})", 1)[0]:
_runs_src.setdefault(_f, []).append(
_part.split('"id": "', 1)[-1].split('"', 1)[0])
check("⭐ 'control still runs' is set on the two peak rows only (saturated "
"and reserve); no row that stops control carries it",
_runs_src == {"main.py": ["peak_guard", "peak_guard"]})
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all SAFETY-87 checks passed")
@@ -0,0 +1,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}")