Compare commits

...
Author SHA1 Message Date
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
adminandClaude Opus 5.5 5c9f64d87a SAFETY-84 review: check the charging pack gives under the guard, and no-P1 at N=2
checks / unit (pull_request) Successful in 23m51s
infra-pin / fresh (pull_request) Successful in 1s
A 6500 W house needs more than B alone: A must give (the N>=2 suspension),
with a non-vacuous run without it (banks over the cap, 'Load too high').
Without P1 the charge is not sized, as at N=1: A charges at 2000 W.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:36:46 +02:00
glenn schrooyenandClaude Opus 5.5 43a52e7f60 SAFETY-84: maintenance charge at N=2 no longer stalls
checks / unit (pull_request) Successful in 23m57s
infra-pin / fresh (pull_request) Successful in 2s
At N>=2 a cheap-window maintenance charge files a site `hi` (`charge`,
P_MAINT_SHAPE) equal to what the charging pack's own `hi` places, so the
other pack never has to give against it. The charge is sized to the
quarter's headroom under the peak aim so it yields to the peak guard, and
to 0 W under a site safety stop. N=1 byte-identical (safety84_identity).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:47:15 +02:00
admin 9d5b7d129a Merge pull request 'SAFETY-83: peak guard outranks the maintenance drain at N=1' (#158) from safety-83-peak-over-drain into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-09-30 14:58:41 +02:00
glenn schrooyenandClaude Opus 5.5 bc6d744c6c SAFETY-83 review: banked month peak and no false alarm during a drain
checks / unit (pull_request) Successful in 23m23s
infra-pin / fresh (pull_request) Successful in 1s
test_safety83 part 1: under a month peak banked above the limit the drain
is lifted to peak - margin, and a drain the guard raises (steady or with
5 s spikes that saturate it) gives no 'Load too high' alarm; the part 3
overload still does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:41:47 +02:00
glenn schrooyenandClaude Opus 5.5 79b94024cb Merge release/1.0 (OPT-11 P4, #156) into safety-83-peak-over-drain
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:35:28 +02:00
admin 1145a931ac Merge pull request 'OPT-11 P4: solar forecast and day plan' (#156) from opt11-p4-solar-forecast into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-09-29 23:01:48 +02:00
adminandClaude Opus 5.5 56cafefa79 SAFETY-83: a refiled peak floor stays under a safety SET, graded directly
checks / unit (pull_request) Successful in 23m32s
infra-pin / fresh (pull_request) Successful in 1s
Kills M5 (refiled at P_SAFETY) on its own sentence, not only through the
per-cycle claim check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:52:52 +02:00
adminandClaude Opus 5.5 3f69c174af SAFETY-83: grade the refile at its boundary, and N=2 on its own
checks / unit (pull_request) Successful in 23m37s
infra-pin / fresh (pull_request) Successful in 1s
A floor exactly at the drain rate stays at P_PEAK (kills `lo >= rate`), and
part 4's N=2 check no longer shares its failure list with N=1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:50:46 +02:00
adminandClaude Opus 5.5 d77f1b1fdf SAFETY-83: peak guard outranks the maintenance drain at N=1
checks / unit (pull_request) Successful in 16m54s
infra-pin / fresh (pull_request) Successful in 1s
At N=1 the drain is the site SET at P_MAINTENANCE (60), so the peak floor
(P_PEAK, 50) was not applied and the house imported what the drain left.
Where the floor asks for more than the drain rate, main.py now refiles the
peak claim at P_MAINTENANCE, so it wins; safety SETs still park everything.
Nothing else changes: safety78_identity --diff against 35cdffe changes only
cycles where the guard wanted more than the drain; N=2 is byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:40:14 +02:00
24 changed files with 2948 additions and 120 deletions
@@ -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
@@ -35,10 +35,9 @@ direction. Opposite splits stay forbidden.
(`tools/safety78_identity.py`, which hashes the claims too).
**Not changed (measured in the PR #154 review, follow-ups).**
- At N=1 a drain (60) outranks the peak floor (50), so the guard does not add
to it: a 5000 W house imports 3000 W against a 2500 W limit, a 6000 W house
4000 W. Pre-existing, kept by the N=1 identity.
- At N=1 a drain (60) outranked the peak floor (50): a 5000 W house imported
3000 W against a 2500 W limit. Fixed by [[SAFETY-83]].
- At N=2 a maintenance CHARGE in a cheap window has the same stall: the
charging pack's `hi = -floor` against the loop asking the other to give is
refused on 141 of 261 cycles. The mirror fix is a site `hi`, which can meet
the peak floor's `lo` (0 W), so it is not this card.
the peak floor's `lo` (0 W), so it is not this card. Fixed by [[SAFETY-84]].
@@ -0,0 +1,34 @@
# SAFETY-83 - the peak guard outranks the maintenance drain at N=1
**Status: DECIDED 2026-09-29 (owner go, P1).** Follows SAFETY-78's "Not
changed" list. Tests: `test_safety83.py`.
**The defect.** At N=1 the drain is the site SET at `P_MAINTENANCE` (60). The
peak floor is a limit at `P_PEAK` (50), and clause 2 applies only limits at or
above the winner, so the floor was ignored and the house imported what the
drain left. A 5000 W house under a 2500 W limit and a 2000 W drain banked
3000 W quarters; on the owner's settings (limit 3500, drain 2500, margin 50) a
breach starts at about 5950 W.
**The fix.** While a drain owns the setpoint at N=1 and the peak floor asks
for more than the drain rate, `main.py` (`_peak_over_drain()`) refiles that
peak claim at `P_MAINTENANCE`. The arbiter then applies it: the target is the
floor, still inside the safety limits, and a safety SET (100) still parks
everything. Where the floor is at or below the drain rate nothing changes.
- The order in effect is now the same at N=1 and N=2: safety limits and the
SAFETY-73 refusal > peak guard (with the SAFETY-79 margin) > maintenance >
modes > trade > loop (`arbiter.py`, beside the priorities).
- The drain keeps counting: the battery discharges at least the drain rate on
every cycle, so the cycle is not reset or stalled; it only ends sooner.
- Below the drain rate the rest is still exported (owner, 2026-09-29).
- Identity: `tools/safety78_identity.py --diff` against 35cdffe changes only
cycles where the guard asked for more than the drain; after such an episode
the commands are identical again and only the log and the idle loop's
integrator differ. N=2 is unchanged.
**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 outranked the peak
guard at N=1. Fixed by [[SAFETY-86]].
@@ -0,0 +1,46 @@
# SAFETY-84 - a maintenance charge at two inverters
**Status: DECIDED 2026-09-30.** Follows SAFETY-78's "Not changed" list.
Tests: `test_safety84.py`; identity: `tools/safety84_identity.py`.
**The defect.** In a cheap window the charging pack files its own
`hi = -charge` (P_MAINT_SHAPE). At N>=2 the loop's site target sat above it
(a house to cover, or its 400 W slew step from 0 W), so the other pack had to
give while this one charged. The allocator refuses opposite directions
(SAFETY-31), so the site sat at 0 W whatever the house
(`tools/safety84_identity.py` on 9d5b7d1: 436 of 480 cycles refused). N=1
was fine.
**The fix.**
- While a pack's own limits make it charge at N>=2, `main.py` files a site
limit `charge`, `hi` = what that pack's own `hi` places
(`allocator.hi_w()`, whole W as the allocator rounds). The site target is
never above it, so the other pack only adds power in the same direction.
Opposite splits stay forbidden. Mirror of SAFETY-78's `drain` floor.
- Peak guard first (owner order, SAFETY-83). The charge is sized to the
quarter's headroom under the aim (`charge_headroom_w()`, cap or banked peak
minus `peak_margin_w`), so it charges less instead of stopping: the meter
sits on the aim and the guard does not need to step in. Where the house
alone is over the aim the guard engages, the shaping is suspended as before
(`shaping_suspended()`) and no `charge` is filed, so the guard's `lo` and
this `hi` never meet in one cycle (no `lo > hi`, no park at 0 W).
Without the headroom sizing the aim still held (the suspension did it), but
a battery gave power back on 285 of 540 cycles at a 3000 W house
(test_safety84.py part 2).
- A site stop (a safety SET above P_MAINT_SHAPE) sizes the charge to 0 W:
the unit `hi` is a hard limit, so a charge under a 0 W stop needs the other
pack to give. It was refused, with an ARBITER line, on most cycles of the
stop (test_safety84.py part 4). The stop still parked the site at 0 W.
- A pack that cannot take the charge (a refusal, a full pack, a stale link)
files no ceiling; the other pack follows the loop.
- Not filed at N=1: byte-identical (`tools/safety84_identity.py`, which hashes
every claim, in and out of a cheap window and under a banked peak).
**Not changed.**
- At N=1 the charge is not sized to the headroom: the guard still suspends it
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 (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,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.
+18 -3
View File
@@ -1639,7 +1639,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). 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 |
@@ -1664,6 +1664,15 @@ maintenance schedule.
With two or more batteries, the one being emptied gives its drain rate and the
others only add power when the house needs more; none charges while it drains
(SAFETY-78). Below the drain rate the rest is exported, as with one battery.
If the house would still go over the peak limit, the battery being emptied
gives more than its drain rate, with one battery too (SAFETY-83).
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
@@ -3148,6 +3157,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.
@@ -3243,7 +3256,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*.
@@ -3381,7 +3396,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. |
+5
View File
@@ -354,6 +354,11 @@ def pin_w(b):
return float(pv) if lo > hi else float(_clamp_int(round(float(pv)), lo, hi))
def hi_w(b):
"""A unit's upper hard limit as `plan()` narrows it to whole W (SAFETY-84)."""
return float(_int_envelope(b)[1])
def loop_bound(bounds, live=None):
"""OPT-11. `(lo, hi)`: what the units can place when the LOOP decides.
+24 -1
View File
@@ -76,6 +76,12 @@ from . import allocator
P_SAFETY = 100 # stopped, inputs missing, shutting down, device limits
P_MAINTENANCE = 60 # maintenance owns the actuator outright (drain, hold)
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); 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)
# OPT-04. §10.1 priority 3 - price arbitrage. ⚠️ ITS POSITION IS THE SPEC, NOT A
# preference: P_PEAK (50) is ABOVE it so clause 2 lets the capacity-tariff floor
@@ -190,6 +196,16 @@ CLAIM_SCOPE = {
# charge against the drain (circulation, refused, 0 W). A site `lo`,
# never filed at N=1. DECISIONS/SAFETY-78-drain-at-two.md.
"drain": SCOPE_SITE,
# main.py: `Claim.limit("charge", P_MAINT_SHAPE, hi=...)`, SAFETY-84. The
# mirror of `drain` for a cheap-window maintenance charge at N>=2: the site
# target may not go above what the charging pack's own `hi` asks, or a
# 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,
@@ -619,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
@@ -721,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)))
+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)
+282 -25
View File
@@ -25,6 +25,7 @@ battery to zero on its own. Never "hold the last value to be safe".
import asyncio
import contextlib
import dataclasses
import functools
import json
import logging
@@ -73,7 +74,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,
@@ -935,6 +936,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
@@ -2513,6 +2517,205 @@ 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.
The drain is the site SET at P_MAINTENANCE, above the peak floor
(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, 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:
return
rate = float(res.setpoint_w or 0.0)
for i, c in enumerate(claims):
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
yields just enough to the peak guard, and nothing under a site stop
(its 0 W would need the other pack to give). N=1 is unchanged."""
if len(self.inverters) < 2:
return floor
if any(c.kind == SET and c.actuator == ACTUATOR_SITE
and c.priority > P_MAINT_SHAPE for c in claims):
return 0.0
room = peakguard.charge_headroom_w(self.peak_guard)
return floor if room is None else min(floor, room)
def _charge_ceiling(self, claims):
"""SAFETY-84: the site `hi` for a cheap-window charge at N>=2, or None.
The charging pack's own `hi` (P_MAINT_SHAPE) makes it charge; above
that the other pack would have to give (opposite directions, refused,
0 W). So the site target stays at or below it. Not filed at N=1, nor
when the pack's own limits cancel the charge (a refusal, a stale link).
DECISIONS/SAFETY-84-charge-at-two.md.
"""
if len(self.inverters) < 2:
return None
live = set(self.live_units())
ceiling = 0.0
for inv in self.inverters:
u = self.actuator_of("maintenance", inv)
b = pre_resolve([c for c in claims if c.actuator == u])
if u in live and not b.contradiction and b.hi < 0.0:
ceiling += allocator.hi_w(b)
return ceiling if ceiling < 0.0 else None
def _maint_stop_off(self, machine, now) -> None:
"""OPT-11: stop this battery's maintenance cycle because it is off."""
if machine.phase == IDLE:
@@ -3849,12 +4052,11 @@ class Controller:
# claim is not sticky. Filed once and skipped next cycle, the floor
# lapses and the actuator goes silently back to whatever was pushing the
# peak. Which of the higher-priority claims outranks it is arbiter.py's
# business, not this block's - and the answer is already right: a
# maintenance DRAIN or HOLD owns the setpoint at P_MAINTENANCE and
# commands discharge or zero, neither of which can create offtake, while
# a maintenance CHARGE is only ever a P_MAINT_SHAPE limit (40) and is
# therefore bound by this floor. That is §9.1's "money outranks the
# maintenance schedule", now as a consequence of the priorities.
# 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), 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
@@ -3922,6 +4124,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
@@ -4170,6 +4377,7 @@ class Controller:
floor = maintenance_charge_floor(
self.maint.cfg.charge_w,
self.peak_fc, self.peak_aim_w())
floor = self._charge_headroom(floor, claims)
if floor > 0:
hi, reason = -floor, "cheap-window charge"
# PER-INVERTER, like the SET above and for the same reason -
@@ -4254,6 +4462,20 @@ class Controller:
claims.append(Claim.limit("drain", P_MAINTENANCE, lo=drain,
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:
claims.append(Claim.limit("charge", P_MAINT_SHAPE, hi=charge,
reason="maintenance charge",
actuator=self.actuator_of("charge")))
# --- OPT-11 P3: the site trade, after every unit claim --------------
# Sized from what the batteries may place with their modes and hard
# limits (the loop bound), so none is asked for watts it may not give.
@@ -4439,7 +4661,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
@@ -5055,6 +5278,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`.
@@ -5075,6 +5300,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
@@ -5097,13 +5326,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.
@@ -5112,8 +5336,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.
@@ -5884,8 +6131,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
@@ -5911,18 +6160,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(
@@ -6245,6 +6493,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
@@ -6297,6 +6550,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.
@@ -6418,6 +6674,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,
@@ -6432,7 +6689,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):
+23 -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.")
@@ -835,6 +847,8 @@ WHY_LIMIT = {
"pack": "the battery level",
"reserve": "the battery reserve",
"maintenance": "battery maintenance",
"charge": "battery maintenance",
"hold": "battery maintenance",
"refused": "a battery that refuses power",
}
@@ -1012,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(
@@ -1114,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)
+12 -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: fourteen 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
@@ -175,6 +175,13 @@ SITE_SOURCES = {
"drain": "the site target may not go below a pinned maintenance "
"drain, or a sibling would charge against it. Filed at N>1 "
"only",
# SAFETY-84.
"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",
@@ -206,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 FOURTEEN claim sources are classified, and only those fourteen",
check("all SIXTEEN claim sources are classified, and only those sixteen",
set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES))
check("the seven 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))
@@ -230,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 "
"seven 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}})
@@ -511,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,
+17 -6
View File
@@ -64,9 +64,19 @@ _default = rig.digests()
for k in ED739D4:
print(f" {k:7} margin 0 {'same' if _zero[k] == ED739D4[k] else 'DIFFERS'}"
f" {_zero[k][:16]}, default {_default[k][:16]}")
# 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).
SAFETY84_N2_MAINT = \
"95722b262f1bcf504493d7b5babc3895799ac56cc15f5d2ccdbe7c9f04adfcbb"
check("⭐ at N=1 and N=2, with and without battery modes and with a "
"maintenance charge, margin 0 is "
"byte-identical to ed739d4", _zero == ED739D4)
"byte-identical to ed739d4 (n2_maint apart, below)",
{k: v for k, v in _zero.items() if k != "n2_maint"}
== {k: v for k, v in ED739D4.items() if k != "n2_maint"})
check("...and n2_maint at margin 0 is the SAFETY-84 digest: the N=2 charge "
"that 9d5b7d1 refused on 214 of 540 cycles now runs (test_safety84.py)",
_zero["n2_maint"] == SAFETY84_N2_MAINT != ED739D4["n2_maint"])
check("...and the default (key absent) changes every scenario, so the "
"scenarios do reach the margin", all(_default[k] != ED739D4[k]
for k in ED739D4))
@@ -163,14 +173,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}")
+75 -49
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 "
@@ -2687,9 +2694,16 @@ check("...and so does the persistent notification, which outlives the page and "
# state of charge, it never closes discharge (a `lo`, not a `hi`), and it is
# never filed at N=1, so the sweeps below still see {device, supervised,
# loop}. DECISIONS/SAFETY-78-drain-at-two.md.
# ⚠️ SAFETY-84 ADDED `charge`, ITS MIRROR, AND IT IS NOT THE RESERVE EITHER. A
# 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"}
"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.
@@ -2731,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 FOURTEEN. 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 "
@@ -2871,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 and SAFETY-78's additions: the seven 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; "
@@ -2881,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"}
"drain", "charge", "hold"}
and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT}
== {"device", "supervised", "failsafe", "maintenance", "pack",
"refused", "mode"})
@@ -2986,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})
"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)
@@ -3065,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 FIFTEEN 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) == 15
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 "
@@ -3088,7 +3103,7 @@ check("⚠️ AND EVERY PER-INVERTER APPEND NAMES THE INVERTER IT IS ABOUT. "
"site-scoped sources and is the whole defect for a per-inverter one: a "
"`device` limit built inside the roster loop and routed without `inv` "
"files inverter B's rating on inverter A, where it binds the wrong "
"share. Nine of the fifteen appends are SCOPE_UNIT - `device`, "
"share. Nine of the sixteen appends are SCOPE_UNIT - `device`, "
"`supervised`, `failsafe`, maintenance's two, OPT-09's `pack`, "
"SAFETY-73's two `refused` and OPT-11's `mode` - and all nine pass one. "
"SAFETY-30's `sign_site` is deliberately NOT one of them: it is filed on "
@@ -3105,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 seventeen appends account for exactly the "
"fourteen "
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())
@@ -3167,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"})
"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
@@ -4131,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 "
@@ -4213,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 "
@@ -4378,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")
@@ -4573,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 "
@@ -5719,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 "
+21 -15
View File
@@ -15,7 +15,7 @@ DECISIONS/SAFETY-78-drain-at-two.md has the record.
3. Battery modes on, a Cheap hours battery in a cheap run during the drain:
no grid charge, and its card says why.
4. The peak guard binds during a drain, and the arbiter's order holds.
5. N=1 is byte-identical to release/1.0 5a4cdc2, and the harness bites.
5. The N=1 and N=2 digests hold, and the harness bites.
6. A seeded sweep of the floor against the loop, trades, the peak floor, a
safety stop, refusals and an Off battery.
@@ -326,26 +326,32 @@ check("⭐ the order holds: a safety stop parks everything; the peak floor "
and _got["a grid charge (trade)"][1] == {A: 2000, B: 0})
# ============================================================================ #
print("\n5. N=1: byte-identical to release/1.0 5a4cdc2")
# Printed by `python3 tools/safety78_identity.py` on release/1.0 5a4cdc2 (the
# same file copied in, nothing else changed), goodwe-addon-checks:1,
print("\n5. the N=1 and N=2 digests")
# Printed by `python3 tools/safety78_identity.py`, goodwe-addon-checks:1,
# 2026-09-29. 480 cycles each, draining throughout, every claim and the
# newest action-log line hashed.
AT_5A4CDC2 = {
# 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).
PINNED = {
"n1_drain":
"55fcfc4a0b9251746f315cfdea607a33640d3a81faf6604c4718a04bbc7dd776",
"b0f9ef44149163bc0acece537fe047871c00f0d9ea213e7a7fc8efc4ad3aa500",
"n1_drain_modes":
"93867eb4ee4132b47c030668bd7f07587a353bced2461524e09421125c3c4edf",
"95ab82426e23bce308db3edebe18b1ab37d956952e9f521adce37150ce87ee8a",
"n1_drain_owner":
"10f7f42a6a952e899d739eb10c674874215c440f41ead7a9f2408bc9b4cb9ea4",
"n2_drain":
"055d83de0552d3a014dc73d87fdd697dde8b5933b8032f9b3a464ff0d08795fb",
"n2_drain_owner":
"05750347f1829c249f99154613f068f442a59ef59f409093907f6d0add78dd4c",
}
_now = s78.digests()
for k in AT_5A4CDC2:
print(f" {k} {'same' if _now[k] == AT_5A4CDC2[k] else 'DIFFERS'} "
for k in PINNED:
print(f" {k} {'same' if _now[k] == PINNED[k] else 'DIFFERS'} "
f"{_now[k][:16]}")
check("⭐ at one inverter a drain under a house below, at and above its "
"rate, a surplus and a load over the peak limit, with battery modes "
"off and on, is byte-identical to 5a4cdc2: commands, reasons, roles, "
"trades and every claim",
_now == AT_5A4CDC2)
check("⭐ a drain under a house below, at and above its rate, a surplus and a "
"load over the peak limit, at N=1 (modes off and on) and N=2, matches "
"the pinned digests: commands, reasons, roles, trades and every claim",
_now == PINNED)
Controller._drain_floor = lambda self, claims, results: (
2000.0 if any(r.phase == DRAIN for r in results) else None)
try:
+307
View File
@@ -0,0 +1,307 @@
"""Runnable check for SAFETY-83: the peak guard outranks the drain at N=1.
`python3 test_safety83.py`
At one inverter the monthly drain is the site SET at P_MAINTENANCE, above the
peak floor, so the house imported what the drain left and quarters went over
the peak limit. Now a peak floor above the drain rate is refiled at
P_MAINTENANCE and wins. Drives the REAL Controller (tools/opt11_identity.py's
harness on a P1 meter). DECISIONS/SAFETY-83-peak-over-drain.md has the record.
1. A house of 5000, 6000 and 7000 W during a drain, limits 2500 and 3500,
margins 0 and 50: every quarter banks at or under the aim, the drain keeps
running, no 'Load too high' alarm, and without the fix the same runs go over.
Under a month peak banked above the limit the aim is that peak - margin.
2. The drain never makes the banked quarter worse than no drain, below and
above the drain rate; below it the rest is still exported.
3. The drain still ends, and the order holds: a lost reading parks the
battery, the rating caps the floor without a clash.
4. Only cycles where the guard wanted more than the drain change, and N=2 is
untouched.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import functools
import logging
import os
import sys
import tempfile
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 safety78_identity as s78 # noqa: E402
import safety79_identity as s79 # noqa: E402
from app import tariff # 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, MaintResult # noqa: E402
fails = []
TOL = 1.0 # watts over the aim a banked quarter may show
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def run(house, cap, margin, drain_w, *, maint=True, dur=2700.0, soc=None,
before=None, store=None, month_peak=None, **extra):
"""N=1 on a P1 meter; returns (rows, controller, banked quarters).
`month_peak` banks that quarter earlier in the month."""
opts = dict(s79.P1, peak_cap_w=cap, peak_margin_w=margin, **extra)
if maint:
opts.update(maintenance_enabled=True, maintenance_start_hour=11,
maintenance_discharge_w=drain_w)
db = os.path.join(tempfile.mkdtemp(prefix="s83-"), "tariff.db")
if month_peak is not None:
tariff.PeakStore(db)._db.execute(
"INSERT INTO monthly_peak VALUES (?, ?, ?, NULL)",
(tariff.month_key(rig.BASE), month_peak,
"2026-09-02T10:00:00+00:00")).connection.commit()
real = rig.Controller
rig.Controller = functools.partial(real, peak_db_path=db)
try:
rows, c = rig.run(1, extra=opts, dur=dur,
load=house if callable(house) else lambda t: house,
soc=soc or (lambda u, t: 60.0),
cut=lambda u, t: False, before=before or s79._feed,
store=store)
finally:
rig.Controller = real
return rows, c, {b.start.strftime("%H:%M"): b.offtake_avg_w
for b in c.p1.blocks}
def without_fix(fn):
real = Controller._peak_over_drain
Controller._peak_over_drain = lambda self, claims, results: None
try:
return fn()
finally:
Controller._peak_over_drain = real
def cmd(r):
return dict(r[2])["a"]
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)
# ============================================================================ #
print("1. banked quarters during a drain, N=1")
CASES = [(cap, drain, m, h) for cap, drain in ((2500, 2000), (3500, 2500))
for m in (0, 50) for h in (5000.0, 6000.0, 7000.0)]
RES, OLD = {}, {}
for k in CASES:
cap, drain, m, h = k
RES[k] = run(h, cap, m, drain)
OLD[k] = without_fix(lambda: run(h, cap, m, drain))
print(f" limit {cap} drain {drain} margin {m:>2} house {h:.0f}: "
f"banked {[round(v) for v in RES[k][2].values()]} W "
f"(before the fix {[round(v) for v in OLD[k][2].values()]} W)")
check("⭐ every quarter banks at or under the aim (limit - margin) plus 1 W, "
"for a 5000, 6000 and 7000 W house, limits 2500 and 3500, margins 0 "
"and 50",
all(len(b) >= 2 and max(b.values()) <= k[0] - k[2] + TOL
for k, (_r, _c, b) in RES.items()))
check("⭐ the drain keeps running: the phase is drain on every run and the "
"battery gives at least the drain rate on every cycle",
all(c.maints[0].phase == DRAIN and all(cmd(r) >= k[1] for r in rows)
for k, (rows, c, _b) in RES.items()))
check("...and where the house is over drain + aim, the battery gives more "
"than the drain rate, bound by the peak floor",
all(any(cmd(r) > k[1] and "limited by peak" in r[6] for r in rows)
for k, (rows, _c, _b) in RES.items() if k[3] - k[1] > k[0] - k[2]))
check("non-vacuous: without the fix the same runs bank over the limit "
"wherever the house is above drain + aim (the reported defect: 3000 W "
"at a 5000 W house against 2500 W)",
all(max(b.values()) > k[0] + 100.0 for k, (_r, _c, b) in OLD.items()
if k[3] - k[1] > k[0])
and round(OLD[(2500, 2000, 50, 5000.0)][2]["11:15"]) == 3000)
# A hob cycling: one 5 s spike to 11000 W every 150 s on a 6000 W house. Each
# spike saturates the guard for one cycle, shorter than ALARM_HOLD_S.
_sat = []
def _spiky_feed(c, t):
s79._feed(c, t)
_sat.append(bool(c.peak_guard is not None and c.peak_guard.saturated))
_k_rows, _k, _kb = run(lambda t: 11000.0 if t % 150 < 5 else 6000.0,
3500, 50, 2500, before=_spiky_feed)
check("⭐ a drain the guard raises gives no 'Load too high' alarm (SAFETY-81), "
"steady or with 5 s spikes that saturate the guard",
not any(alarmed(c) for _r, c, _b in RES.values())
and any(_sat) and not alarmed(_k))
# Owner (SAFETY-79): under a month peak banked above the limit, the aim is that
# peak - margin, during a drain too. Owner's settings, peak banked at 4500 W.
BANK = {h: (run(h, 3500, 50, 2500, month_peak=4500.0),
without_fix(lambda: run(h, 3500, 50, 2500, month_peak=4500.0)))
for h in (6500.0, 7500.0)}
for h, (new, old) in BANK.items():
print(f" month peak 4500, house {h:.0f}: banked "
f"{[round(v) for v in new[2].values()]} W (before the fix "
f"{[round(v) for v in old[2].values()]} W)")
check("⭐ under a month peak of 4500 W: a 7500 W house banks at 4450 W "
"(peak - margin, not the 3450 W cap aim), was 5000 W; a 6500 W house "
"stays under the drain alone at 4000 W",
all(4430.0 <= v <= 4450.0 + TOL for v in BANK[7500.0][0][2].values())
and max(BANK[7500.0][1][2].values()) > 4900.0
and all(round(v) in (4000, 4014)
for v in BANK[6500.0][0][2].values()))
# ============================================================================ #
print("\n2. the drain never makes the banked quarter worse, N=1")
CAP, DRAIN_W, MARGIN = 3500, 2500, 50 # the owner's settings
AIM = CAP - MARGIN
WORSE = {}
for h in (1200.0, 3000.0, 5000.0, 6500.0, 7000.0):
_rows, _c, b = run(h, CAP, MARGIN, DRAIN_W)
_r0, _c0, b0 = run(h, CAP, MARGIN, DRAIN_W, maint=False)
WORSE[h] = (b, b0, _rows)
print(f" house {h:.0f}: with the drain {[round(v) for v in b.values()]}"
f" W, without {[round(v) for v in b0.values()]} W")
check("⭐ below and above the drain rate, every quarter with the drain banks "
"no more than the larger of the aim and the same quarter without it",
all(b[q] <= max(AIM, b0[q]) + TOL for b, b0, _r in WORSE.values()
for q in b))
_r12 = WORSE[1200.0][2]
check("below the drain rate the battery gives the drain rate and exports the "
"rest (owner, 2026-09-29): 2500 W against a 1200 W house",
all(cmd(r) == DRAIN_W for r in _r12[1:]))
# ============================================================================ #
print("\n3. the drain ends, and the order holds")
_e_rows, _e, _eb = run(6500.0, CAP, MARGIN, DRAIN_W, dur=1200.0,
soc=lambda u, t: max(5.0, 60.0 - t / 20.0))
check("⭐ with the guard binding, the drain still reaches its floor and moves "
"to the charge phase, with no abort",
_e.maints[0].phase == CHARGE
and not any("abort" in e.lower() for e in _e.events))
def blind_at(t0):
"""The battery power reading goes missing at `t0`: a safety SET."""
def before(c, t):
s79._feed(c, t)
if t >= t0 and not getattr(c.hass, "_blind", False):
real = c.hass.number
async def number(e, invert=False):
return None if e == "sensor.batt_a" else await real(e, invert)
c.hass.number, c.hass._blind = number, True
return before
_s_rows, _s, _sb = run(6500.0, CAP, MARGIN, DRAIN_W, dur=600.0,
before=blind_at(300.0))
print(f" reading lost at 300 s: last {cmd(_s_rows[-1]):+.0f} W, "
f"{_s_rows[-1][6]}")
check("⭐ a safety SET outranks the refiled peak floor: before the reading "
"goes the battery gives above the drain rate, after it it is at 0 W",
any(cmd(r) > DRAIN_W for r in _s_rows if r[0] < 300.0)
and all(cmd(r) == 0.0 for r in _s_rows if r[0] >= 400.0)
and _s_rows[-1][6].startswith("safety -> 0 W"))
_m_rows, _m, _mb = run(7000.0, 2500, 50, 2000, dur=900.0, max_w=3000)
print(f" max_w 3000, house 7000: last {cmd(_m_rows[-1]):+.0f} W, "
f"{_m_rows[-1][6]}")
check("⭐ the rating caps the floor without a clash: at max_w 3000 the "
"battery gives 3000 W, never 0 W, and nothing contradicts",
all(cmd(r) >= 2000.0 for r in _m_rows)
and cmd(_m_rows[-1]) == 3000.0
and not any("CONTRADICTION" in (r[6] or "") for r in _m_rows))
check("...and there, with the battery at its rating and the house still over, "
"the 'Load too high' alarm does fire", alarmed(_m))
# ============================================================================ #
print("\n4. only the cycles where the guard wanted more change")
def promoted(row):
return [c for c in row[-1][0] if c[0] == "peak"]
_id = {k: s78.run(**v)[0] for k, v in s78.SCENARIOS.items()}
_bad = []
for k, rows in _id.items():
for r in rows:
claims, guard = r[-1][0], r[-1][1]
rate = [c[3] for c in claims if c[0] == "maintenance" and c[2] == "set"]
pk = promoted(r)
if k.startswith("n2"):
ok = all(c[1] == P_PEAK for c in pk)
elif s78.wanted_more(r):
ok = ([c[1] for c in pk] == [P_MAINTENANCE]
and r[1] == guard[1] and "limited by peak" in r[6])
else:
ok = (all(c[1] == P_PEAK for c in pk)
and (not rate or r[1] == rate[0]))
if not ok:
_bad.append((k, r[0], r[1], r[6], pk))
_bad1 = [b for b in _bad if b[0].startswith("n1")]
_bad2 = [b for b in _bad if b[0].startswith("n2")]
_more = {k: sum(map(s78.wanted_more, rows)) for k, rows in _id.items()}
print(f" cycles with the guard above the drain: {_more}; bad {_bad[:3]}")
check("⭐ at N=1, on every cycle where the guard asks for more than the drain "
"the peak claim is refiled at P_MAINTENANCE and the target is its floor; "
"on every other cycle the peak claim stays at P_PEAK and the target is "
"the drain rate",
not _bad1 and all(_more[k] > 50 for k in _id if k.startswith("n1")))
check("⭐ at N=2 the peak claim is never refiled (the SAFETY-78 floor does "
"that job), and test_safety78.py part 5 holds N=2 to the digests of "
"release/1.0 35cdffe", not _bad2)
# The rule itself, at the boundary: a floor AT the drain rate changes nothing.
_one = types.SimpleNamespace(inverters=[None])
_edge = {}
for lo in (1999.5, 2000.0, 2000.5):
cl = [Claim.set("maintenance", P_MAINTENANCE, 2000.0, DRAIN),
Claim.limit("peak", P_PEAK, lo=lo, reason="quarter peak")]
Controller._peak_over_drain(_one, cl, [MaintResult(DRAIN, True, 2000.0,
False, [])])
_edge[lo] = cl[1].priority
check("at the boundary: a floor at or below the 2000 W drain stays at P_PEAK, "
"a floor 0.5 W above it is refiled at P_MAINTENANCE",
_edge == {1999.5: P_PEAK, 2000.0: P_PEAK, 2000.5: P_MAINTENANCE})
_stop = [Claim.set("maintenance", P_MAINTENANCE, 2000.0, DRAIN),
Claim.limit("peak", P_PEAK, lo=4000.0, reason="quarter peak"),
Claim.set("safety", P_SAFETY, 0.0, "inputs missing")]
Controller._peak_over_drain(_one, _stop, [MaintResult(DRAIN, True, 2000.0,
False, [])])
check("⭐ and a refiled floor stays under a safety SET: the site resolves "
"to 0 W", _stop[1].priority == P_MAINTENANCE
and resolve(_stop).target_w == 0.0)
def store_hold():
st = rig._Store()
st.data["phase"], st.data["phase_started"] = "hold", rig.BASE.isoformat()
return st
_h_rows, _h, _hb = run(6000.0, 2500, 50, 2000, dur=600.0, store=store_hold())
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 cmd(_h_rows[-1]) > 0.0 and "limited by peak" in _h_rows[-1][6])
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
sys.exit(1)
print("all checks passed")
+503
View File
@@ -0,0 +1,503 @@
"""Runnable check for SAFETY-84: a maintenance charge at N=2 no longer stalls.
`python3 test_safety84.py`
In a cheap window the charging battery's own `hi` makes it charge. At two
inverters the loop's site target sat above it, the other battery had to give,
the allocator refused the opposite directions and the site sat at 0 W. Now a
site `hi` (`charge`, P_MAINT_SHAPE) keeps the site target at or below the
charge, and the charge is sized to the quarter's headroom so it yields to the
peak guard. Drives the REAL Controller (tools/safety84_identity.py's harness).
DECISIONS/SAFETY-84-charge-at-two.md has the record.
1. N=2 in a cheap window, house below, at and above the charge rate and a
solar surplus: the charge runs, no opposite split, no refusal, and it ends.
2. The peak guard: the charge yields just enough to hold the aim (cap -
margin, and a banked month peak - margin); over the aim the guard wins,
the charging battery gives too, and nothing parks; no false 'Load too
high'. Without P1 the charge is not sized, as at N=1.
3. Outside a cheap window nothing changes.
4. A safety stop parks both batteries without a refusal, and the charge
resumes after it.
5. One battery refusing, or dropped, and B Off with battery modes on.
6. A trade outranked by the charge (P_ARBITRAGE < P_MAINT_SHAPE).
7. A seeded sweep of the ceiling against the loop, trades, refusals, packs
and modes.
8. The N=1 digests equal release/1.0 9d5b7d1, and the N=2 ones are pinned.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import logging
import os
import random
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import opt11p3_identity as p3 # noqa: E402
import safety84_identity as s84 # noqa: E402
from app import arbitrage, packmode # noqa: E402
from app.arbiter import (Claim, P_ARBITRAGE, P_LOOP, P_MAINT_SHAPE, # noqa
P_MODE, P_SAFETY, resolve)
from app.main import Controller # noqa: E402
from app.maintenance import CHARGE, HOLD # noqa: E402
from app.store import unit_key # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
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 cmd(r):
return dict(r[2])
def refused(rows):
return sum(1 for r in rows if r[7] and "REFUSED" in r[7])
def opposite(rows):
return sum(1 for r in rows if cmd(r)["a"] * cmd(r)["b"] < 0)
def charge_claims(r):
"""The site `charge` claims main.py handed resolve() on row `r`."""
return [c for c in r[-1][0] if c[0] == "charge"]
def banked(c):
return [b.offtake_avg_w for b in c.p1.blocks]
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)
def run(house, n=2, **kw):
return s84.run(n, load=house if callable(house) else lambda t: house,
**kw)
def rise(u, t):
"""A fills from 60 % (1 % a cycle); B stays at 50 %."""
return min(100.0, 60.0 + t / 5.0) if u == "a" else 50.0
# ============================================================================ #
print("1. N=2 in a cheap window: the charge runs whatever the house uses")
RUNS = {h: run(h, dur=900.0) for h in (800.0, 2000.0, 3000.0, -1500.0)}
for h, (rows, c) in RUNS.items():
print(f" house {h:+.0f} W: last A {cmd(rows[-1])['a']:+.0f} W, B "
f"{cmd(rows[-1])['b']:+.0f} W; {rows[-1][6]}; refusals "
f"{refused(rows)}")
check("⭐ below, at and above the charge rate and under a solar surplus, "
"battery A charges on EVERY cycle (it used to sit at 0 W from the "
"first cycle)",
all(cmd(r)["a"] < 0.0 for rows, _c in RUNS.values() for r in rows)
and all(c.maints[0].phase == CHARGE for _r, c in RUNS.values()))
check("⭐ no cycle splits the site both ways and the allocator refuses "
"nothing: battery B never gives while A charges",
all(opposite(rows) == 0 and refused(rows) == 0
and all(cmd(r)["b"] <= 0.0 for r in rows)
for rows, _c in RUNS.values()))
check("...and nothing is logged per cycle: no ARBITER line in the action log",
not any("ARBITER" in e for _r, c in RUNS.values() for e in c.events))
check("with the quarter's headroom above the charge rate, A charges at the "
"full 2000 W and the Why names the charge",
all(cmd(r)["a"] == -2000.0 for h in (800.0, -1500.0)
for r in RUNS[h][0])
and "limited by charge" in RUNS[800.0][0][-1][6])
print(f" Why: {RUNS[800.0][1].why_words}")
check("...and the page's Why reads as at one battery: 'held back by battery "
"maintenance'",
RUNS[800.0][1].why_words.endswith("held back by battery maintenance"))
_end_rows, _end = run(800.0, dur=600.0, soc=rise)
check("⭐ and the charge ENDS: A reaches its 99 % target and the cycle moves "
"on to its hold, with no refusal on the way",
_end.maints[0].phase == HOLD and refused(_end_rows) == 0
and any("charge complete" in e for e in _end.events))
_real = Controller._charge_ceiling
Controller._charge_ceiling = lambda self, claims: None
try:
_bite, _bc = run(800.0, dur=120.0)
finally:
Controller._charge_ceiling = _real
print(f" without the ceiling: refusals {refused(_bite)}/{len(_bite)}, "
f"last A {cmd(_bite[-1])['a']:+.0f} W")
check("non-vacuous: with the ceiling taken out, the same fixture refuses "
"every cycle and parks A at 0 W (the reported defect)",
refused(_bite) == len(_bite) and cmd(_bite[-1])["a"] == 0.0)
# ============================================================================ #
print("\n2. the peak guard outranks the charge")
_q_rows, _q = run(3000.0, dur=2700.0)
print(f" house 3000: banked {[round(v) for v in banked(_q)]} W, last A "
f"{cmd(_q_rows[-1])['a']:+.0f} W")
check("⭐ house 3000 W, aim 3450 W: the charge yields just enough - every "
"banked quarter lands on the aim, not on house + 2000 W",
len(banked(_q)) >= 2
and all(AIM - TOL <= v <= AIM + TOL for v in banked(_q)))
check("...and it yields by charging less, not by stopping: A charges and B "
"is 0 W on every cycle, nothing is refused, no 'Load too high'",
all(cmd(r)["a"] < 0.0 and cmd(r)["b"] == 0.0 for r in _q_rows)
and refused(_q_rows) == 0 and not alarmed(_q))
check("...and the peak guard never has to step in (the charge stays under "
"what the quarter can afford)",
not any(r[-1][1] and r[-1][1][0] for r in _q_rows))
_o_rows, _o = run(5000.0, dur=2700.0)
_both = [r for r in _o_rows
if charge_claims(r) and any(c[0] == "peak" for c in r[-1][0])]
print(f" house 5000: banked {[round(v) for v in banked(_o)]} W, last "
f"{cmd(_o_rows[-1])}, {_o_rows[-1][6]}")
check("⭐ house 5000 W, over the aim alone: the peak guard wins - every "
"banked quarter is at or under the aim, both batteries give in the "
"same direction, nothing is refused and nothing parks at 0 W",
all(v <= AIM + TOL for v in banked(_o)) and opposite(_o_rows) == 0
and refused(_o_rows) == 0
and sum(cmd(r)["a"] + cmd(r)["b"] > 0.0 for r in _o_rows) > 100)
check("...and the guard's `lo` never meets the charge's `hi` in one cycle "
"(the charge is suspended while the guard holds)", not _both)
check("...with no false 'Load too high' alarm", not alarmed(_o))
_k_rows, _k = run(3000.0, dur=2700.0, month_peak=4000.0)
print(f" month peak 4000 banked, house 3000: banked "
f"{[round(v) for v in banked(_k)]} W")
check("⭐ under a month peak banked at 4000 W the aim is 3950 W: the charge "
"takes the extra headroom and every banked quarter lands on 3950 W",
len(banked(_k)) >= 2
and all(3950.0 - TOL <= v <= 3950.0 + TOL for v in banked(_k))
and refused(_k_rows) == 0 and not alarmed(_k))
_real = Controller._charge_headroom
Controller._charge_headroom = lambda self, floor, claims: floor
try:
_nh_rows, _nh = run(3000.0, dur=2700.0)
finally:
Controller._charge_headroom = _real
_gave = sum(1 for r in _nh_rows if cmd(r)["a"] > 0.0 or cmd(r)["b"] > 0.0)
print(f" without the headroom: cycles a battery gives {_gave}, banked "
f"{[round(v) for v in banked(_nh)]} W")
check("non-vacuous: charging at the full rate instead, the guard has to "
"step in and the batteries give back on many cycles",
_gave > 50)
def peak_run(house):
rows, c = run(house, dur=1800.0)
return rows, c, [r for r in rows if r[-1][1] and r[-1][1][0]]
# A 6500 W house needs 3050 W off the meter: more than B's 2500 W alone.
_p_rows, _p, _p_eng = peak_run(6500.0)
print(f" house 6500: banked {[round(v) for v in banked(_p)]} W, A gives "
f"on {sum(cmd(r)['a'] > 0.0 for r in _p_eng)} of {len(_p_eng)} "
f"engaged cycles")
check("⭐ house 6500 W, more than B alone can cover: the charge is suspended "
"and A, the battery being filled, gives too; the quarter lands on the "
"aim, nothing is refused, no 'Load too high'",
len(_p_eng) > 100 and all(cmd(r)["a"] > 0.0 for r in _p_eng)
and all(v <= AIM + TOL for v in banked(_p))
and refused(_p_rows) == 0 and not alarmed(_p))
_real = s84.m.peakguard.shaping_suspended
s84.m.peakguard.shaping_suspended = lambda *a: False
try:
_ns_rows, _ns, _ns_eng = peak_run(6500.0)
finally:
s84.m.peakguard.shaping_suspended = _real
check("non-vacuous: with the suspension taken out, A is held at 0 W, the "
"quarter goes over the cap and 'Load too high' fires",
not any(cmd(r)["a"] > 0.0 for r in _ns_eng)
and max(banked(_ns)) > 3500.0 and alarmed(_ns))
def prices_only(c, t):
"""A cheap price and no P1 telegram (`meter_source: off`)."""
if not getattr(c.hass, "_px", False):
read, px = c.hass.number, {"sensor.pnow": 0.05, "sensor.pavg": 0.20}
async def number(e, invert=False):
return px[e] if e in px else await read(e, invert)
c.hass.number, c.hass._px = number, True
_p1 = s84.s79.P1
s84.s79.P1 = {"meter_source": "off", "peak_cap_w": 3500}
try:
_off_rows, _off = run(800.0, dur=600.0, before=prices_only)
finally:
s84.s79.P1 = _p1
check("⭐ with no P1 meter nothing measures the quarter and the charge is not "
"sized, as at N=1: A charges at 2000 W on every cycle, nothing refused",
_off.peak_guard is None and all(cmd(r)["a"] == -2000.0 for r in _off_rows)
and refused(_off_rows) == 0)
# ============================================================================ #
print("\n3. outside a cheap window nothing changes")
_d = {h: run(h, cheap=False, dur=600.0) for h in (800.0, -1500.0)}
check("⭐ no `charge` claim is filed outside a cheap window: B covers the "
"house, the charge takes solar only, nothing is refused",
not any(charge_claims(r) for rows, _c in _d.values() for r in rows)
and cmd(_d[800.0][0][-1]) == {"a": 0.0, "b": 800.0}
and cmd(_d[-1500.0][0][-1])["a"] < 0.0
and all(refused(rows) == 0 for rows, _c in _d.values()))
# ============================================================================ #
print("\n4. a safety stop outranks the charge")
def blind_at(t0, t1):
"""A's power reading is missing from `t0` to `t1`: a safety SET."""
def before(c, t):
s84.feed(True)(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
_s_rows, _s = run(800.0, dur=600.0, before=blind_at(150.0, 400.0))
_stop = [r for r in _s_rows if 175.0 <= r[0] < 400.0]
_last = _s_rows[79] # t = 395 s, still without the reading
print(f" A's reading lost 150-400 s: {_last[2]}, {_last[6]}; after: "
f"{_s_rows[-1][2]}")
check("⭐ with a reading gone the safety SET parks both batteries at 0 W "
"with no refusal and no ARBITER line (it used to refuse every cycle)",
all(cmd(r) == {"a": 0.0, "b": 0.0} for r in _stop)
and _last[0] == 395.0 and _last[6].startswith("safety -> 0 W")
and refused(_s_rows) == 0 and not any("ARBITER" in e for e in _s.events))
check("...and the charge ran before and runs again after",
cmd(_s_rows[20])["a"] == -2000.0 and cmd(_s_rows[-1])["a"] == -2000.0)
_full = _s._charge_headroom(2000.0, [])
check("the stop test reads only SITE SETs: a unit's own safety SET (on B) "
"or a site limit does not cut A's charge, a site safety SET does",
_s._charge_headroom(2000.0, [Claim.set("failsafe", P_SAFETY, 0.0,
actuator=B)]) == _full > 0.0
and _s._charge_headroom(2000.0, [Claim.limit("peak", P_SAFETY,
lo=0.0)]) == _full
and _s._charge_headroom(2000.0, [Claim.set("safety", P_SAFETY,
0.0)]) == 0.0)
def stop_at(t0):
def before(c, t):
s84.feed(True)(c, t)
if t >= t0:
c.auto = False
return before
_st_rows, _st = run(800.0, dur=400.0, before=stop_at(200.0))
check("control switched off mid-charge: both batteries at 0 W, no refusal",
cmd(_st_rows[-1]) == {"a": 0.0, "b": 0.0} and refused(_st_rows) == 0)
# ============================================================================ #
print("\n5. one battery refusing, dropped, or Off")
def a_takes_nothing(c, t):
"""A reads 0 W whenever it is asked to charge (a battery that refuses)."""
s84.feed(True)(c, t)
if not getattr(c.hass, "_rt", False):
real = c.hass.power
c.hass.power = lambda u: (0.0 if u == "a" and c.hass.cmd["a"] < 0.0
else real(u))
c.hass._rt = True
_r_rows, _r = run(800.0, dur=900.0, before=a_takes_nothing)
print(f" A refuses to charge: {_r_rows[-1][2]}, {_r_rows[-1][6]}")
check("⭐ A refuses to charge (SAFETY-73): no ceiling is filed for it, B "
"covers the 800 W house, and no cycle is split both ways or refused",
_r.inverters[0].refused == -1 and cmd(_r_rows[-1]) == {"a": 0.0,
"b": 800.0}
and not charge_claims(_r_rows[-1])
and opposite(_r_rows) == 0 and refused(_r_rows) == 0)
_g_rows, _g = run(3000.0, dur=600.0, cut=lambda u, t: u == "b")
check("B refuses to give: A still charges and nothing is refused",
all(cmd(r)["a"] < 0.0 for r in _g_rows) and refused(_g_rows) == 0)
def reject_a(c, t):
"""Every write to A is rejected, so A's link goes stale (not live)."""
s84.feed(True)(c, t)
if not getattr(c.hass, "_rej", False):
real = c.hass.set_number
async def set_number(e, v):
return False if e == "number.sp_a" else await real(e, v)
c.hass.set_number, c.hass._rej = set_number, True
_l_rows, _l = run(1500.0, dur=400.0, before=reject_a)
print(f" A's writes rejected: {_l_rows[-1][2]}, {_l_rows[-1][7][-40:]}")
check("⭐ a charging battery that is dropped (stale link) adds no ceiling: "
"B covers the 1500 W house instead of charging for A",
"dropped inverter-a" in _l_rows[-1][7]
and 1490.0 <= cmd(_l_rows[-1])["b"] <= 1530.0
and not charge_claims(_l_rows[-1]))
def modes_store(b_mode=None, b_off=False):
s = rig._Store()
for k in ("phase", "phase_started"):
s.data[unit_key(A, k)] = ("charge" if k == "phase"
else rig.BASE.isoformat())
if b_mode:
s.data[unit_key(B, packmode.MODE_KEY)] = b_mode
if b_off:
s.data[unit_key(B, packmode.USE_KEY)] = False
return s
_m = {h: run(h, dur=600.0, extra={"battery_modes_enabled": True},
store=modes_store(b_off=True)) for h in (800.0, -3000.0)}
print(f" B Off: {[(h, cmd(rows[-1])) for h, (rows, _c) in _m.items()]}")
check("⭐ battery modes on, B Off: A charges at 2000 W over an 800 W house "
"and takes 2500 W of a 3000 W surplus, B stays at 0 W, nothing is "
"refused",
cmd(_m[800.0][0][-1]) == {"a": -2000.0, "b": 0.0}
and cmd(_m[-3000.0][0][-1]) == {"a": -2500.0, "b": 0.0}
and all(refused(rows) == 0 and opposite(rows) == 0
for rows, _c in _m.values()))
# ============================================================================ #
print("\n6. a trade does not outrank the charge")
def sell_feed(c, t):
p3._feed(c, t)
s84.feed(True)(c, t)
_trades = []
_t_rows, _t = run(800.0, dur=5400.0, before=sell_feed,
extra=dict(p3.ARB),
soc=lambda u, t: 60.0)
print(f" modes off, a sell at 75-90 min: last {_t_rows[-1][2]}")
_sells = [r for r in _t_rows if any(c[0] == "arbitrage" and c[3] > 0.0
for c in r[-1][0])]
check("⭐ battery modes off: the site trade asks to SELL while A charges; "
"the charge (P_MAINT_SHAPE) outranks it, so A keeps charging and "
"nothing is refused",
len(_sells) > 50
and all(cmd(r)["a"] < 0.0 and cmd(r)["b"] <= 0.0 for r in _sells)
and refused(_t_rows) == 0)
# ============================================================================ #
print("\n7. a seeded sweep of the ceiling")
_c = run(800.0, dur=30.0)[1]
_rnd = random.Random(84)
_bad = []
for _i in range(20000):
ra, rb = _rnd.choice((2000.0, 2500.0)), _rnd.choice((1000.0, 2500.0))
f = _rnd.uniform(1.0, ra)
cl = [Claim.limit("supervised", P_SAFETY, -ra, ra, actuator=A),
Claim.limit("supervised", P_SAFETY, -rb, rb, actuator=B),
Claim.limit("maintenance", P_MAINT_SHAPE, hi=-f, actuator=A)]
x = _rnd.random()
if x < 0.1:
cl.append(Claim.limit("refused", P_SAFETY, 0.0, actuator=A))
elif x < 0.2:
cl.append(Claim.limit("pack", P_SAFETY, 0.0, actuator=A))
y = _rnd.random()
if y < 0.15:
cl.append(Claim.limit("refused", P_SAFETY, *_rnd.choice(
((0.0, float("inf")), (float("-inf"), 0.0))), actuator=B))
elif y < 0.3:
cl.append(Claim.limit("mode", P_MODE, *_rnd.choice(
((0.0, 0.0), (0.0, rb), (-rb, 0.0))), actuator=B))
cl.append(Claim.set("loop", P_LOOP, _rnd.uniform(-6000, 6000)))
if _rnd.random() < 0.4:
cl.append(Claim.set("arbitrage", P_ARBITRAGE,
_rnd.uniform(-5000, 5000)))
ceil = _c._charge_ceiling(cl)
if ceil is not None:
cl.append(Claim.limit("charge", P_MAINT_SHAPE, hi=ceil))
res = resolve(cl, units=(A, B), live={A, B})
sh = res.shares or {}
why = []
if not res.allocated or res.contradiction:
why.append("refused or contradicted")
if sh.get(A, 0.0) * sh.get(B, 0.0) < 0.0:
why.append("opposite")
a_free = x >= 0.2
if a_free and (ceil is None or sh.get(A, 0.0) > -f + 1.0):
why.append("A does not charge")
if not a_free and ceil is not None:
why.append("ceiling filed for a pack that cannot charge")
if why:
_bad.append((_i, why))
print(f" 20000 claim lists, bad {len(_bad)}: {_bad[:3]}")
check("⭐ 20000 random N=2 claim lists (a charge on A, refusals, a full "
"pack, B's mode, the loop and trades in both directions): never "
"refused, never both ways, A always charges at least its rate, and "
"no ceiling for a pack that cannot charge", not _bad)
# ============================================================================ #
print("\n8. the N=1 and N=2 digests")
# Printed by `python3 tools/safety84_identity.py` in goodwe-addon-checks:1,
# 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.
PINNED = {
"n1_charge":
"0ed25c856456b97acb07a39299a8cb7b1a762c4d0005b88a0823c03057a80be5",
"n1_charge_dear":
"c25340b56060d4581f7204e200b07e7c8618711be2baca3b1667b7ebeb600dc3",
"n1_charge_banked":
"2619acdd06b04e56c000b168d64e1d044346c6ba3f929c94258d6ad8189ec609",
"n2_charge":
"748abe785a79084bc738c82d0d6f90b951e1cb78e9629195c0c01d3bb67b55e2",
"n2_charge_dear":
"c34f08b3b5b6256e68cf05030fb0c29522916dc8f57c351008f48384657966c0",
"n2_charge_banked":
"fb600ee1dd3202910715c97c5809584d50c6b8b57b55aaa4dc83bcaaa97627ef",
}
_now = s84.digests()
for k in PINNED:
print(f" {k} {'same' if _now[k] == PINNED[k] else 'DIFFERS'} "
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",
all(_now[k] == PINNED[k] for k in ("n1_charge", "n1_charge_dear",
"n1_charge_banked",
"n2_charge_dear")))
check("...and the N=2 cheap-window digests match the pins",
_now["n2_charge"] == PINNED["n2_charge"]
and _now["n2_charge_banked"] == PINNED["n2_charge_banked"])
_n1 = s84.run(1, dur=600.0)[0]
check("...and no N=1 cycle files a `charge` claim",
not any(charge_claims(r) for r in _n1))
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all SAFETY-84 checks passed")
+422
View File
@@ -0,0 +1,422 @@
"""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?"""
return 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)
_nz_rows, _nz = run(noisy, 2)
_nz_old = 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 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)
# ============================================================================ #
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")
+78 -2
View File
@@ -13,11 +13,18 @@ no watt still moves the digest. The `_modes` scenarios run battery modes on,
Cheap hours only, over opt11p3_identity's prices. Uses only the API release/1.0 5a4cdc2 has, so the same file runs on
both trees; test_safety78.py part 5 holds this tree to the digests printed
there.
SAFETY-83 adds the owner's settings (limit 3500, drain 2500, margin 50) with a
house that goes over drain + aim and back, and N=2 runs of both. At N=1 the
peak floor now outranks the drain where it asks for more, so the N=1 digests
moved; `--dump FILE` on the base tree and `--diff FILE` here list every
changed cycle and whether the peak guard wanted more than the drain on it.
"""
import functools
import hashlib
import os
import pickle
import sys
import tempfile
@@ -47,14 +54,25 @@ def store_cheap(n):
return s
def load_owner(t):
"""Below the 2500 W drain, above it, over drain + aim (5950 W), back."""
return (1200.0, 3000.0, 6500.0, 3000.0, -1500.0)[min(int(t // 480), 4)]
OWNER = {"peak_cap_w": 3500, "peak_margin_w": 50,
"maintenance_discharge_w": 2500}
SCENARIOS = {
"n1_drain": dict(n=1),
"n1_drain_modes": dict(n=1, modes=True),
"n1_drain_owner": dict(n=1, load=load_owner, extra=OWNER),
"n2_drain": dict(n=2),
"n2_drain_owner": dict(n=2, load=load_owner, extra=OWNER),
}
def run(n, *, modes=False, dur=DUR):
extra = dict(s79.P1, **DRAIN)
def run(n, *, modes=False, dur=DUR, load=load, extra=None):
extra = dict(s79.P1, **DRAIN, **(extra or {}))
if modes:
extra.update(p3.ARB, battery_modes_enabled=True)
seen, extra_rows = [], []
@@ -99,7 +117,65 @@ def digests() -> dict:
return {k: digest(run(**v)[0]) for k, v in SCENARIOS.items()}
FIELDS = ("t", "target", "commands", "loop reason", "integrator",
"saturation", "why", "allocation")
XFIELDS = ("claims", "guard", "roles", "trade", "phase", "log length",
"newest log line")
def wanted_more(row) -> bool:
"""Did the peak guard ask for more than the drain rate on this cycle?"""
claims, guard = row[-1][0] or (), row[-1][1]
rate = [c[3] for c in claims if c[0] == "maintenance" and c[2] == "set"]
return (bool(rate) and guard is not None and bool(guard[0])
and guard[1] is not None and guard[1] > max(rate))
def diff(base: dict, now: dict) -> dict:
"""Per scenario: (cycles, changed, changed where the guard wanted more on
either tree, changed cycles whose commands differ without that)."""
out = {}
for k, rows in now.items():
old = base.get(k)
if old is None:
continue
ch = [i for i, (a, b) in enumerate(zip(old, rows)) if a != b]
ch += list(range(min(len(old), len(rows)), max(len(old), len(rows))))
more = [i for i in ch if i < min(len(old), len(rows))
and (wanted_more(old[i]) or wanted_more(rows[i]))]
cmd = [i for i in ch if i not in more
and (i >= min(len(old), len(rows))
or old[i][1:3] != rows[i][1:3])]
fields = set()
for i in set(ch) - set(more) - set(cmd):
fields |= {FIELDS[j] for j in range(8) if old[i][j] != rows[i][j]}
fields |= {XFIELDS[j] for j in range(7)
if old[i][8][j] != rows[i][8][j]}
out[k] = (len(rows), ch, more, cmd, sorted(fields))
return out
def all_rows() -> dict:
return {k: run(**v)[0] for k, v in SCENARIOS.items()}
if __name__ == "__main__":
if "--dump" in sys.argv:
with open(sys.argv[sys.argv.index("--dump") + 1], "wb") as f:
pickle.dump(all_rows(), f)
sys.exit(0)
if "--diff" in sys.argv:
with open(sys.argv[sys.argv.index("--diff") + 1], "rb") as f:
base = pickle.load(f)
for k, (n, ch, more, cmd, fields) in diff(base, all_rows()).items():
rest = sorted(set(ch) - set(more))
print(f"{k}: {n} cycles, {len(ch)} changed, {len(more)} with the "
f"guard above the drain, {len(rest)} others "
f"({len(cmd)} with a different target or command"
+ (f"; the rest differ only in {', '.join(fields)}"
if fields else "") + ")"
+ (f", first changed t={ch[0] * rig.TICK:.0f} s" if ch else ""))
sys.exit(0)
if "--summary" in sys.argv:
for k, v in SCENARIOS.items():
rows = run(**v)[0]
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""SAFETY-84: at one inverter, is a maintenance charge byte-identical to before?
python3 tools/safety84_identity.py # prints one digest per scenario
safety78_identity's harness (the REAL Controller on a P1 meter, every claim
hashed) with the battery in its maintenance CHARGE phase from the first cycle,
on the owner's settings (limit 3500, margin 50, charge 2000 W). The house steps
below the charge rate, at it, under the aim, over it and into a solar surplus,
in a cheap window, outside one, and under a month peak banked at 4000 W. Uses
only the API release/1.0 9d5b7d1 has, so the same file runs on both trees;
test_safety84.py holds this tree to the digests printed there (N=1) and to the
new N=2 ones.
"""
import functools
import hashlib
import os
import sys
import tempfile
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import opt11_identity as rig # noqa: E402
import safety79_identity as s79 # noqa: E402
import app.main as m # noqa: E402
from app import tariff # noqa: E402
DUR = 2400.0
OPT = {"peak_cap_w": 3500, "peak_margin_w": 50, "maintenance_enabled": True,
"maintenance_start_hour": 11, "maintenance_charge_w": 2000,
"price_now_entity": "sensor.pnow", "price_avg_entity": "sensor.pavg"}
def load(t):
"""Below the 2000 W charge, at it, under the aim, over it, a surplus."""
return (800.0, 2000.0, 3000.0, 5000.0, -1500.0)[min(int(t // 480), 4)]
def feed(cheap):
"""s79's P1 feed plus a price now under (cheap) or over the average."""
now = 0.05 if cheap else 0.30
def before(c, t):
if not getattr(c.hass, "_s84", False):
read = c.hass.number
async def number(e, invert=False):
if e == "sensor.pnow":
return now
if e == "sensor.pavg":
return 0.20
return await read(e, invert)
c.hass.number, c.hass._s84 = number, True
s79._feed(c, t)
return before
SCENARIOS = {
"n1_charge": dict(n=1),
"n1_charge_dear": dict(n=1, cheap=False),
"n1_charge_banked": dict(n=1, month_peak=4000.0),
"n2_charge": dict(n=2),
"n2_charge_dear": dict(n=2, cheap=False),
"n2_charge_banked": dict(n=2, month_peak=4000.0),
}
def run(n, *, cheap=True, month_peak=None, dur=DUR, load=load, soc=None,
before=None, cut=None, extra=None, store=None):
"""Rows (opt11_identity's fields plus every claim, the guard and the
phases) and the controller. `month_peak` banks that peak this month."""
seen, extra_rows = [], []
real_resolve = m.resolve
def spy(claims, **kw):
seen.append(tuple((c.source, c.priority, c.kind, c.value, c.lo, c.hi,
c.reason, c.actuator) for c in claims))
return real_resolve(claims, **kw)
def on_cycle(c, t):
g = c.peak_guard
extra_rows.append((
seen[-1] if seen else None,
None if g is None else (g.engaged, g.floor_w, g.reason),
c._maint_shape_suspended, [mm.phase for mm in c.maints],
len(c.events), c.events[0] if c.events else None))
db = os.path.join(tempfile.mkdtemp(prefix="s84-"), "tariff.db")
if month_peak is not None:
tariff.PeakStore(db)._db.execute(
"INSERT INTO monthly_peak VALUES (?, ?, ?, NULL)",
(tariff.month_key(rig.BASE), month_peak,
"2026-09-02T10:00:00+00:00")).connection.commit()
real = rig.Controller
rig.Controller = functools.partial(real, peak_db_path=db)
m.resolve = spy
try:
rows, c = rig.run(n, extra=dict(s79.P1, **OPT, **(extra or {})),
dur=dur, load=load, soc=soc or s79.soc,
cut=cut or (lambda u, t: False),
before=before or feed(cheap), on_cycle=on_cycle,
store=store or s79.store_charging(n))
finally:
rig.Controller = real
m.resolve = real_resolve
return [r + (x,) for r, x in zip(rows, extra_rows)], c
def digest(rows) -> str:
return hashlib.sha256(repr(rows).encode()).hexdigest()
def digests() -> dict:
return {k: digest(run(**v)[0]) for k, v in SCENARIOS.items()}
if __name__ == "__main__":
for k, v in digests().items():
print(f"{k} {v}")
@@ -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}")