SAFETY-25: say what peak_soc_floor bounds, and measure what it does not #31
@@ -2,6 +2,94 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
**`peak_soc_floor` reads like a pack-wide reserve and is not one, and on a
|
||||
sustained-load evening the grid-following loop discharges straight past it to
|
||||
the BMS cut-out (SAFETY-25).** The gap has been written down four times without
|
||||
being filed — SAFETY-18's finding (`freeze.py`), SAFETY-24's module note, its PR
|
||||
body, and its honestly-uncaught 13th mutation — and never once measured. It is
|
||||
measured now, on the committed 2026-08-20 capture, driving the shipped
|
||||
`control.compute()`, `peakguard.decide()`, `peakguard.claim()` and the real
|
||||
`arbiter.resolve()`:
|
||||
|
||||
19:00-22:00 UTC, 830 W mean, minimum 701 W, not one watt of export
|
||||
reference pack (300 Ah x 51.2 V x DoD 90) started at 21 %, one point
|
||||
above peak_soc_floor
|
||||
|
||||
ends at 10.0 % - the bms_soc_floor cut-out
|
||||
1525 Wh delivered by the LOOP over 587 cycles
|
||||
549 cycles below peak_soc_floor, 343 of them still discharging
|
||||
0 cycles on which the peak guard filed any floor at all
|
||||
|
||||
**And it is a behaviour, not a config value.** The same evening ends at
|
||||
`9.9715 %` at `peak_soc_floor` = 5, 11, 20 **and** 60 — the shipped value and
|
||||
both ends of the schema range — identical to four decimal places. The option an
|
||||
operator would reach for cannot move the number by any amount. On the same day's
|
||||
high-load block the two states coexist: 49 cycles reporting `PEAK GUARD
|
||||
FLOOR-LIMITED` and, on **all 49**, the arbiter simultaneously granting the loop
|
||||
up to 2000 W of discharge.
|
||||
|
||||
**Why SAFETY-24 could not close it.** The peak guard files a `lo` limit — a
|
||||
*lower* bound. That asymmetry is what makes it provably unable to lower a target,
|
||||
cap a discharge or contradict the arbiter, and it is therefore equally unable to
|
||||
stop a discharge the loop itself wants. A floor on the guard cannot become a
|
||||
floor on the pack by being raised.
|
||||
|
||||
**What changed is the honesty, on four operator-facing surfaces**, because that
|
||||
half is required whichever way the open question is answered and it is the actual
|
||||
misstatement on the live system today:
|
||||
|
||||
- `config.yaml` says next to the option that it **bounds the peak guard only** and
|
||||
is **not a pack-wide reserve**, with the replayed evening and the sweep;
|
||||
- `DOCS.md` says it in the options table and in a new section carrying the
|
||||
figures above;
|
||||
- the **status-page row** for a floor-limited guard carries the scope sentence —
|
||||
it is the one line a human reads at the moment the guard stops, and read alone
|
||||
it implies a reserve the replay shows does not exist;
|
||||
- the **persistent notification** says what has stopped (the guard buying a lower
|
||||
bill) and what has not (the pack being spent).
|
||||
|
||||
`peakguard.py` and `freeze.py` carry the measurement beside the findings they
|
||||
already recorded.
|
||||
|
||||
**The product decision is NOT taken here.** Whether the loop gets a reserve floor
|
||||
of its own moves real energy on this house and belongs to the owner.
|
||||
`DECISIONS/SAFETY-25-loop-soc-floor.md` records the options, what each costs and
|
||||
protects, weighed against MAINT-01 and against the fact that this would be the
|
||||
**fifth** SoC key (OPT-04's `arbitrage_soc_floor` fired SAFETY-24's "revisit if a
|
||||
third floor appears" trigger unremarked), and a recommendation. It also records a
|
||||
trap found while writing it and now checked through the real arbiter: the obvious
|
||||
shape — a `hi` limit on the loop — crosses the peak guard's `lo` and resolves to a
|
||||
**CONTRADICTION at 0 W**, the battery parked in the quarter that sets the bill,
|
||||
unless `bms_soc_floor < loop_soc_floor <= peak_soc_floor` is enforced at startup.
|
||||
|
||||
**`test_peakguard.py` part 10 is a tripwire, not a guarantee, and part 10f is
|
||||
what makes it one.** The replay half records the defect; on its own it could not
|
||||
see the defect being FIXED, because `replay_loop()` hand-builds main.py's claim
|
||||
list and is therefore blind to any claim main.py adds — which is exactly where a
|
||||
loop-side floor would be built. PR #31's reviewer proved that by building the
|
||||
memo's own Option B in `main.py` and watching the whole suite stay green. **10f
|
||||
pins the claim-source set:** every `claims.append(...)` in `Controller.cycle()`
|
||||
is read off its AST and must be one of eight named sources, and real
|
||||
`Controller.cycle()`s — spied at main.py's own `resolve`, swept over the whole
|
||||
0–100 % state-of-charge range in both the idle and the floor-limited state — must
|
||||
hand the arbiter exactly `{device, supervised, loop}` and
|
||||
`{device, supervised, loop, peak}`. A ninth source, at any state of charge, in
|
||||
any configuration, takes part 10 red on the line it is written. A floor built
|
||||
inside `control.compute()` or the guard instead moves 10a–10c's replayed numbers,
|
||||
which the replay drives directly. So the exposure cannot change size under a
|
||||
green suite.
|
||||
|
||||
**Nine mutations, re-run against the fixed tree**, each against a freshly copied
|
||||
tree with no `__pycache__` present at all, mounted `:ro` — the same structural
|
||||
immunity `MUTATION.md` records for CI. Six are the AC 3 and behaviour mutations
|
||||
already on this ticket's record (the status row's scope sentence, the
|
||||
notification's, `config.yaml`'s, the memo deleted, the loop's gain zeroed, the
|
||||
guard's claim given a `hi`); two are the reviewer's, and are the ones that
|
||||
motivated 10f — the memo's own Option B at its recommended default, **now three
|
||||
checks red where it was fully green**, and an absurd always-binding 95 % reserve,
|
||||
**six red**; one is new, against the banner short form below. All nine are red,
|
||||
no survivors, and no byte delta is `+0 B`.
|
||||
|
||||
**The gate has never once graded the content a pull request would land, and a
|
||||
run that never started reported as a test failure (CI-02).** Both halves were
|
||||
measured on a deliberately conflicted throwaway pull request rather than read
|
||||
@@ -67,6 +155,16 @@ carries `pending / Waiting to run` within seconds, and a concurrency-held one
|
||||
API. `/actions/tasks`, which is what was polled originally, lists only tasks
|
||||
that started, and cannot answer the question at all.
|
||||
|
||||
**The NOT READY banner no longer repeats the row underneath it.**
|
||||
`status_payload()` renders the first hard-failing check as the page headline, so
|
||||
the scope sentence above turned the loudest element on the page into a
|
||||
550-character paragraph that the checks list then repeated verbatim. A row may
|
||||
now carry an optional short `banner` form; the floor-limited row does, and its
|
||||
headline is the guard's own line plus a 68-character scope tag. **The row itself
|
||||
is unchanged** — the checks list is where an operator reads what the floor does
|
||||
and does not bound, and shortening that would have been weakening AC 3. Rows
|
||||
without the key are unaffected, and the page still renders with `textContent`.
|
||||
|
||||
**A setpoint device smaller than `max_w` silently disarmed the frozen-meter
|
||||
detector, and the status page reported it as armed (SAFETY-20).** SAFETY-18's
|
||||
conjunction needs `abs(commanded_w) >= 0.95 * max_w`, but the emitted command is
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
# SAFETY-25 — does the grid-following loop get a reserve floor?
|
||||
|
||||
**Status: OPEN. This is a memo, not a decision.** It sets out the options, what
|
||||
each costs, what each protects, and a recommendation. **The choice belongs to the
|
||||
product owner**, because it decides whether this house's battery is allowed to
|
||||
discharge to its BMS cut-out on a sustained-load evening, and that moves real
|
||||
energy.
|
||||
|
||||
Filed with SAFETY-25. The other half of that ticket — making `peak_soc_floor`'s
|
||||
scope honest in the config, the docs and on the status page — **shipped in the
|
||||
same PR** and did not wait on this, because it is required whichever way the
|
||||
answer goes.
|
||||
|
||||
*This directory exists so a recorded decision has somewhere to live that is not a
|
||||
comment in a source file nobody reads and not a Trello card that scrolls away.
|
||||
`SAFETY-27` asks for a second one (the naming of the floors); it belongs here
|
||||
too.*
|
||||
|
||||
---
|
||||
|
||||
## 1. The question
|
||||
|
||||
> Does the grid-following (self-consumption) loop get an SoC floor of its own, or
|
||||
> is the BMS cut-out **deliberately** the only backstop under demand-driven
|
||||
> discharge?
|
||||
|
||||
Either answer is defensible. What is not defensible is the state we have been in
|
||||
for four tickets: **silence, plus a config key called `peak_soc_floor` that reads
|
||||
like the answer is "yes".**
|
||||
|
||||
## 2. What is actually exposed — replayed, not argued
|
||||
|
||||
The exposure was written down three times (SAFETY-18's finding, SAFETY-24's
|
||||
module note and PR body, and SAFETY-24's honestly-uncaught 13th mutation) and
|
||||
never measured. SAFETY-25 measured it. The replay is `test_peakguard.py` part 10;
|
||||
it drives the **shipped** `control.compute()`, `peakguard.decide()`,
|
||||
`peakguard.claim()` and `arbiter.resolve()` over the committed capture
|
||||
`scenarios/p1-net-2026-08-20.jsonl.gz`, with the battery and the state of charge
|
||||
modelled (the captures carry meter power only — see §7).
|
||||
|
||||
### 2a. A sustained-load evening takes the pack to the BMS cut-out
|
||||
|
||||
19:00–22:00 UTC on 2026-08-20 — three hours of house load after the sun has gone,
|
||||
**830 W mean, minimum 701 W, maximum 1223 W, not one watt of export in the whole
|
||||
window.** The pack is the 13.8 kWh reference pack (300 Ah × 51.2 V × DoD 90),
|
||||
started at **21 %** — one point above the shipped `peak_soc_floor`.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| pack at the start of the evening | **21.0 %** |
|
||||
| pack three hours later | **10.0 %** — `bms_soc_floor` |
|
||||
| energy the loop delivered | **1525 Wh** |
|
||||
| control cycles with the pack below `peak_soc_floor` | **549 of 587** |
|
||||
| …of those, cycles still delivering discharge | **343** |
|
||||
| cycles on which the peak guard filed *any* floor | **0** |
|
||||
|
||||
### 2b. It is a behaviour, not a config value
|
||||
|
||||
The same replay, same pack, same evening, with `peak_soc_floor` set to **5, 11,
|
||||
20 and 60** — the shipped value and both ends of the schema range:
|
||||
|
||||
peak_soc_floor = 5 -> ends at 9.9715 %, 1524.58 Wh out
|
||||
peak_soc_floor = 11 -> ends at 9.9715 %, 1524.58 Wh out
|
||||
peak_soc_floor = 20 -> ends at 9.9715 %, 1524.58 Wh out
|
||||
peak_soc_floor = 60 -> ends at 9.9715 %, 1524.58 Wh out
|
||||
|
||||
Identical to four decimal places. **Raising the option does nothing, because the
|
||||
guard it bounds is not the thing doing the discharging.**
|
||||
|
||||
### 2c. The two states coexist in the same cycle
|
||||
|
||||
On the same day's high-load block (15:00–17:00 UTC), with the pack started one
|
||||
point *below* the floor, the guard reports `PEAK GUARD FLOOR-LIMITED` —
|
||||
*"commanding no forced discharge"* — on **49 cycles**, and on **all 49** the
|
||||
arbiter simultaneously grants the loop a discharge of up to **2000 W**. The
|
||||
status page tells the operator the truth about the guard while the pack empties.
|
||||
|
||||
### 2d. Stated fairly, because overstating it is how a real finding gets ignored
|
||||
|
||||
This is **not** a runaway. Self-consumption discharge is demand-driven and
|
||||
self-limiting: it stops when the house stops drawing, and every watt of it is
|
||||
serving load that would otherwise have been imported. It is not SAFETY-18's
|
||||
frozen-meter rail, and nothing here is spending the pack for a reason of its own.
|
||||
What it means is narrower and still real:
|
||||
|
||||
- the **depth** the pack reaches on a heavy evening is set by the house, not by
|
||||
anything an operator can configure; and
|
||||
- `peak_soc_floor` **reads like a system-wide guarantee it is not.** A floor that
|
||||
binds one consumer and not the other is worse than no floor if its name does
|
||||
not say so — which is precisely what SAFETY-25's other half fixed.
|
||||
|
||||
## 3. Why SAFETY-24's floor cannot be extended to cover this
|
||||
|
||||
The peak guard files a `lo` limit — a **lower** bound on the setpoint. That
|
||||
asymmetry is what makes the guard trustworthy: a reviewer proved across 19,800
|
||||
combinations that it can never lower a target, never cap a discharge, never
|
||||
contradict the arbiter, and never file a negative floor. `peakguard.claim()`
|
||||
leaves `hi` at `+inf` as a fact about the object rather than a rule someone has
|
||||
to remember.
|
||||
|
||||
**A `lo` limit is structurally incapable of stopping a discharge the loop itself
|
||||
wants.** So the same property that makes the guard safe makes it useless here.
|
||||
Raising `peak_soc_floor` cannot become the answer; §2b is that statement
|
||||
measured.
|
||||
|
||||
## 4. The floors we already have — and this would be the fifth key
|
||||
|
||||
SAFETY-24 recorded a decision to keep `maintenance_soc_floor` separate with an
|
||||
explicit revisit trigger: *"if a third floor appears."* **The trigger has already
|
||||
fired, unremarked**, when OPT-04 shipped `arbitrage_soc_floor`.
|
||||
|
||||
| option | default | bounds | priority of the consumer |
|
||||
|---|---|---|---|
|
||||
| `maintenance_soc_floor` | 11 | the monthly recalibration's **drain target** | `P_MAINTENANCE` 60 |
|
||||
| `peak_soc_floor` | 20 | the peak guard's forced discharge | `P_PEAK` 50 (a `lo` limit) |
|
||||
| `bms_soc_floor` | 10 | *nothing* — it describes the pack, so rule 8 can refuse a floor that cannot bind | — |
|
||||
| `arbitrage_soc_floor` | 20 | arbitrage discharge | `P_ARBITRAGE` 30 |
|
||||
| **the loop** | — | **nothing** | `P_LOOP` 10 |
|
||||
|
||||
Two of them ship the same default (20) from different cards for different
|
||||
reasons, which is how a value that looks deliberate becomes a coincidence nobody
|
||||
dares change. **`SAFETY-27` owns the naming question and this memo does not
|
||||
pre-empt it** — but any answer to §1 that adds a fifth key should land *after*
|
||||
SAFETY-27's decision, or it adds a fifth accreted name to four.
|
||||
|
||||
## 5. The options
|
||||
|
||||
### Option A — record the BMS as the deliberate backstop; add nothing
|
||||
|
||||
Say plainly, in `DOCS.md` and in `freeze.py`'s finding, that demand-driven
|
||||
discharge is bounded by the pack's own cut-out on purpose, and close the
|
||||
question.
|
||||
|
||||
- **Protects:** the whole point of the product. Every watt the loop discharges is
|
||||
self-consumption the house would otherwise have imported; a reserve is
|
||||
literally energy bought at the import tariff and held back.
|
||||
- **Costs:** on a heavy evening the pack ends at the BMS cut-out with no EMS-side
|
||||
record of why. That is the SoC-drift condition **MAINT-01** exists to correct,
|
||||
and the first symptom — a battery that is never available when it is wanted —
|
||||
is indistinguishable from a coulomb counter that has drifted. It also means the
|
||||
morning's peak-guard capacity is whatever last night left, which the peak guard
|
||||
cannot influence.
|
||||
- **Cost of building it:** zero. It is already the shipped behaviour, and after
|
||||
SAFETY-25's honesty half it is already documented.
|
||||
|
||||
### Option B — a `hi` limit at `P_LOOP`, with an enforced ordering *(recommended)*
|
||||
|
||||
A new option `loop_soc_floor`. While `soc <= loop_soc_floor`, `main.py` files
|
||||
`Claim.limit("reserve", P_LOOP, hi=0.0)`. Zero forbids **discharge** and permits
|
||||
charging, which is the mirror image of the guard's `lo = 0` and one-directional
|
||||
in the safe direction.
|
||||
|
||||
**⚠️ This shape has one trap and it is fatal if missed.** `arbiter.resolve()`
|
||||
applies **every** limit at or above the winning `set` claim's priority and takes
|
||||
the most restrictive, *with no notion of one limit outranking another* — that is
|
||||
deliberate and is what makes the safety bounds unconditional. So when the loop
|
||||
wins at `P_LOOP`, a reserve `hi = 0` and the peak guard's `lo = excess > 0` are
|
||||
**both** applied, `lo > hi`, and the arbiter resolves a **CONTRADICTION to 0 W**:
|
||||
the battery parked, in the one quarter of the month that sets the bill. This is
|
||||
the same crossing-limits hazard `shaping_suspended()` already exists to avoid for
|
||||
the maintenance charge-only clamp.
|
||||
|
||||
**The fix is an ordering constraint, and it is provable:** require
|
||||
|
||||
bms_soc_floor < loop_soc_floor <= peak_soc_floor
|
||||
|
||||
Then whenever the reserve binds (`soc <= loop_soc_floor <= peak_soc_floor`) the
|
||||
guard is *already* floor-limited and files `lo = 0.0`, so `lo == hi == 0` and the
|
||||
result is 0 W with no contradiction. Refuse any other ordering at startup, in
|
||||
CFG-01's manner and with rule 8's message shape. **A `loop_soc_floor` above
|
||||
`peak_soc_floor` must not be merely discouraged; it is the parked-battery bug.**
|
||||
|
||||
- **Protects:** the pack from routinely sitting on its own cut-out, which is the
|
||||
MAINT-01 drift condition; and it gives the operator one number that means what
|
||||
its name says.
|
||||
- **Costs:** a fifth SoC key (see §4), a validator rule, and — the real cost —
|
||||
**every watt below the reserve becomes billed import.** Set too high it turns a
|
||||
working battery into a smaller one.
|
||||
- **What it does *not* touch, correctly:** a maintenance DRAIN sets at
|
||||
`P_MAINTENANCE` 60 and arbitrage sets at `P_ARBITRAGE` 30, both above `P_LOOP`
|
||||
10, so a limit filed at `P_LOOP` is not applied to either. The monthly
|
||||
recalibration still drains to `maintenance_soc_floor`; arbitrage is still
|
||||
bounded by its own key. That is the right answer and it falls out of the
|
||||
priorities rather than needing a special case.
|
||||
- **Recommended starting value if this option is taken:** a *low* one — three
|
||||
points above `bms_soc_floor`, i.e. **13 %**, well under `peak_soc_floor`'s 20.
|
||||
The purpose is to keep the pack off its own cut-out, **not** to reserve energy
|
||||
for anything, and an over-tight reserve is the version of this that fights
|
||||
MAINT-01 from the other side: it shrinks the band the pack actually cycles in,
|
||||
which is the condition a coulomb counter drifts in. It does not matter that 13
|
||||
sits above `maintenance_soc_floor`'s 11 — the maintenance drain sets at
|
||||
`P_MAINTENANCE` 60 and a `P_LOOP` limit is not applied to it, so the two never
|
||||
interact. **The number itself is part of the owner's answer**; what this memo
|
||||
offers is the derivation and the ordering constraint that makes any number in
|
||||
that range safe.
|
||||
|
||||
### Option C — a `set` claim above `P_PEAK`
|
||||
|
||||
File `Claim.set("reserve", 55, 0.0)` when the pack is under the reserve. Because
|
||||
clause 2 applies only limits at or **above** the winner's priority, the guard's
|
||||
`lo` at 50 would then be dropped and there is no contradiction to engineer
|
||||
around.
|
||||
|
||||
- **Protects:** the same as B, without the ordering rule.
|
||||
- **Costs:** it is a **stop**, not a bound. It parks the battery outright rather
|
||||
than merely forbidding discharge, so it also blocks the charging that would end
|
||||
the condition, and it silently outranks peak shaving in a way that is invisible
|
||||
on the priority table. This project's rule is that a floor about **energy** is
|
||||
not a stop of last resort (`peakguard.py`, point 1). **Not recommended.**
|
||||
|
||||
### Option D — clamp inside `control.compute()`
|
||||
|
||||
- **Costs:** it puts precedence back inside a strategy, which is the exact thing
|
||||
`arbiter.py` was built to end, and it would be invisible to `explain()`. **Not
|
||||
recommended.**
|
||||
|
||||
## 6. Recommendation
|
||||
|
||||
**Option A is honest and cheap; Option B is what I would build.** Concretely:
|
||||
|
||||
1. **Now (shipped with SAFETY-25):** the honesty half. `peak_soc_floor`, its
|
||||
documentation and the status page say plainly that it bounds the peak guard
|
||||
only. This is required under every option and it is the actual dishonesty on
|
||||
the live system today.
|
||||
2. **Owner's call:** A or B. If B, take it **after `SAFETY-27`'s naming decision**
|
||||
so the fifth key lands into a scheme rather than onto a pile, and **after
|
||||
`MAINT-01`** has run one real cycle — until then nobody has observed what this
|
||||
pack's SoC reading does over a full cycle, and picking a reserve against a
|
||||
reading that has never been recalibrated is picking a number against an
|
||||
instrument we have not checked.
|
||||
3. **Whichever way:** the tripwire in `test_peakguard.py` part 10 stays until the
|
||||
question is answered, so the day someone builds a loop-side floor it goes red
|
||||
and has to be updated deliberately, rather than the exposure quietly changing
|
||||
under a green suite. It has two halves and they cover the two places the floor
|
||||
can land:
|
||||
|
||||
- **10f pins the claim-source set.** Every `claims.append(...)` in
|
||||
`Controller.cycle()` is read off its AST and must be one of eight named
|
||||
sources, and real cycles — spied at main.py's own `resolve`, swept over the
|
||||
whole 0–100 % SoC range in both the idle and the floor-limited state — must
|
||||
file exactly those. **Option B is a ninth source**, and so is **Option C**'s
|
||||
`set` claim, so filing either takes part 10 red on the line it is written.
|
||||
**Option A** files no claim at all — it is the honesty half already
|
||||
shipped, and part 10 is the record it leaves behind.
|
||||
- **10a–10c replay the shipped code**, so **Option D** — a clamp inside
|
||||
`control.compute()` — moves the replayed numbers instead.
|
||||
|
||||
⚠️ **10f is load-bearing and was added late.** PR #31 shipped this section
|
||||
claiming the tripwire without it, and the reviewer disproved the claim by
|
||||
building Option B in `main.py`: the entire suite, part 10 included, stayed
|
||||
green, and mutation 13 went on printing *"nothing prevents it, on purpose"* on
|
||||
a tree where something did. Do not remove 10f while this question is open.
|
||||
|
||||
## 7. What the replay is and is not
|
||||
|
||||
The committed captures carry **one** series — signed net power at the meter — so
|
||||
the pack is modelled, and the model is stated rather than hidden:
|
||||
|
||||
- the captured `w` is the load presented to the meter; the modelled battery's
|
||||
delivered power is subtracted from it, one telegram late (an actuation delay);
|
||||
- the loop's command is the **shipped** `control.compute()` at the shipped
|
||||
tuning, resolved through the **shipped** `arbiter.resolve()` against the
|
||||
**shipped** `peakguard.claim()`, at a `cycle_floor_s` tick;
|
||||
- SoC falls by the delivered discharge integrated over the real telegram
|
||||
timestamps, on a pack of 300 Ah × 51.2 V × DoD 90;
|
||||
- the BMS is modelled as a hard cut: at or below `bms_soc_floor` the modelled
|
||||
battery delivers 0 W whatever is asked of it. Without that the run would be
|
||||
unphysical — the BMS really does stop the pack, and the whole finding is that
|
||||
it is then the **only** thing that does;
|
||||
- nothing recharges the pack inside the window. One evening, one direction.
|
||||
|
||||
**The fixtures cannot express a low pack on their own** — there is no battery or
|
||||
SoC telemetry in them — so the starting state of charge is a fixture parameter,
|
||||
not a measurement. It is chosen as *one point above `peak_soc_floor`*, which is
|
||||
derived from the shipped option rather than picked. Everything downstream of that
|
||||
one number is the real capture and the real code.
|
||||
@@ -430,7 +430,7 @@ help - see "Why the tuning is what it is".
|
||||
|---|---|
|
||||
| `peak_forecast_entity` | Quarter-hour demand forecast, for capacity-tariff markets. Empty = no cap |
|
||||
| `peak_cap_w` | The site's capacity-tariff target |
|
||||
| `peak_soc_floor` (20) | **The percent below which the capacity-peak guard will not discharge the battery.** Not optional and not the same thing as `maintenance_soc_floor` — see below |
|
||||
| `peak_soc_floor` (20) | **The percent below which the capacity-peak guard will not discharge the battery.** ⚠️ It bounds **the peak guard only** — it is **not a pack-wide reserve**, and ordinary self-consumption discharges straight past it (SAFETY-25, see below). Not optional and not the same thing as `maintenance_soc_floor` |
|
||||
| `bms_soc_floor` (10) | The pack's **own** cut-out percent, the number the BMS enforces whatever you set above. `peak_soc_floor` must be **strictly above** it or the add-on refuses to start |
|
||||
| `price_now_entity`, `price_avg_entity` | Dynamic tariff. Empty = never force a paid grid top-up |
|
||||
|
||||
@@ -552,8 +552,13 @@ exactly like the SoC drift the monthly maintenance cycle exists to correct.
|
||||
- **`peak_soc_floor` (20 %)** is where the forced discharge stops. Below it the
|
||||
guard files a **0 W** floor instead of a sized one: charging stays blocked
|
||||
(that can only ever lower your offtake), and no further discharge is asked for.
|
||||
- **It does not stop the ordinary self-consumption loop.** That loop still has no
|
||||
SoC guard of its own. This bounds what the *peak guard* spends for money.
|
||||
- **⚠️ IT BOUNDS THE PEAK GUARD ONLY. IT IS NOT A PACK-WIDE RESERVE, AND THE NAME
|
||||
READS LIKE ONE.** The ordinary self-consumption loop — the grid-following
|
||||
control law that holds your meter at zero — has **no SoC floor of any kind**.
|
||||
It will discharge straight past this number and keep going until the pack's own
|
||||
BMS cut-out stops it. What you are promised by `peak_soc_floor: 20` is *"the
|
||||
capacity-peak guard will not spend the pack for money below 20 %"*, and
|
||||
**nothing more**. See below for what that costs on a real evening.
|
||||
- **When it binds you are told**, once per episode, with notification
|
||||
`goodwe_peak_soc_floor` and a status-page row reading `PEAK GUARD
|
||||
FLOOR-LIMITED`: the quarter **will** go over the ceiling, so shedding load is
|
||||
@@ -572,6 +577,53 @@ Why 20 %: the reference pack is 13.8 kWh usable, so 10 points of margin over the
|
||||
BMS cut-out is about 1.6× the guard's worst measured day. If your pack is
|
||||
smaller, or your quarters are worse, raise it.
|
||||
|
||||
#### ⚠️ What `peak_soc_floor` does **not** protect (SAFETY-25)
|
||||
|
||||
**On a sustained-load evening this add-on will take your battery to the BMS
|
||||
cut-out, whatever `peak_soc_floor` says.** That is measured, not argued. Replayed
|
||||
on the committed 2026-08-20 capture, 19:00–22:00 UTC — three hours of house load
|
||||
after the sun has gone, 830 W average, not one watt of export in the window — with
|
||||
the 13.8 kWh reference pack started at 21 %, one point above the shipped floor:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| pack at the start of the evening | **21 %** (one point above `peak_soc_floor`) |
|
||||
| pack three hours later | **10.0 %** — the `bms_soc_floor` cut-out |
|
||||
| energy the loop took out | **1525 Wh** |
|
||||
| control cycles below `peak_soc_floor` | **549 of 587**, and **343** of those still delivering discharge |
|
||||
| cycles on which the peak guard filed *any* floor | **0** |
|
||||
| same replay at `peak_soc_floor` = 5, 11, 20, 60 | **the same 10.0 %, to four decimal places** |
|
||||
|
||||
That last row is the point: **this is a property of the control law, not a value
|
||||
you can configure.** Raising `peak_soc_floor` to 60 % changes nothing at all,
|
||||
because the guard it bounds is not the thing doing the discharging.
|
||||
|
||||
**And the two can happen in the same cycle.** On the high-load block of the same
|
||||
day (15:00–17:00 UTC) with the pack below the floor, the guard reports
|
||||
`PEAK GUARD FLOOR-LIMITED` — *"commanding no forced discharge"* — on 49 cycles,
|
||||
and on **all 49 of them** the loop is simultaneously being granted a discharge of
|
||||
up to 2000 W by the arbiter. The status page is telling you the truth about the
|
||||
guard while the battery empties.
|
||||
|
||||
**Why it is built this way, and why SAFETY-24 could not fix it.** The peak guard
|
||||
files a `lo` limit — a *lower* bound on the setpoint. That asymmetry is exactly
|
||||
what makes the guard safe: it is provably incapable of lowering a target, capping
|
||||
a discharge or contradicting the arbiter. The same property makes it incapable of
|
||||
stopping a discharge the loop itself wants. A floor on the guard cannot become a
|
||||
floor on the pack by being raised.
|
||||
|
||||
**Is this a runaway?** No. Self-consumption discharge is demand-driven and
|
||||
self-limiting: it stops when the house stops drawing, and every watt of it is
|
||||
serving load you would otherwise have imported. It is not SAFETY-18's frozen-meter
|
||||
rail. What it means is that the *depth* your pack reaches on a heavy evening is
|
||||
set by your house, not by any number in this file, and the BMS is the only thing
|
||||
underneath it.
|
||||
|
||||
**Whether it should stay that way is an open product decision**, recorded with its
|
||||
options, costs and a recommendation in
|
||||
[`DECISIONS/SAFETY-25-loop-soc-floor.md`](DECISIONS/SAFETY-25-loop-soc-floor.md).
|
||||
Until it is answered, this section is the honest statement of what you have.
|
||||
|
||||
### The winding-loop alarm (the output clamp)
|
||||
|
||||
Every setpoint this add-on writes is clamped to `max_w` before it leaves the
|
||||
|
||||
@@ -143,6 +143,7 @@ commit and PR bodies.
|
||||
| every **"all red, no survivors"** claim in the window — TARIFF-02 (24/0), TARIFF-03 (11), MAINT-04 (10), SAFETY-18 (6+4), SAFETY-03 (6), TEL-04 (10), TEL-05 (6) | **not at risk** | direction. Stale bytecode turns a red into a green survivor, never the reverse. A red is evidence that *something* changed under the check. |
|
||||
| **TEST-06**'s deleted assertion, "no new notification while parked at 0 W" (c34b5a2) | **not at risk / superseded** | it was recorded as undecidable and then *falsified* — `test_freeze.py` records the one-line mutation that takes the file red 9 times. The stronger later claim is a red one. |
|
||||
| **TARIFF-01** reviewer survivor: `record_block()` ignoring its `policy` argument (0dbd485) | **re-verified — still a survivor** | re-run here through `tools/mutation_run.sh` against the 15 suites that were on the record, all green with `billed_w(block, policy)` mutated to `billed_w(block, POLICY_NET)`. The recorded diagnosis holds: every block reaching the store is single-phase, where NET, SUM and MAX collapse. |
|
||||
| **SAFETY-25** 8 mutations against `test_peakguard.py` part 10, then 9 more after review, all red | **not at risk** | each run against a tree copied fresh with `__pycache__` excluded and mounted `:ro`, so no `.pyc` existed to go stale - the same structural immunity recorded for CI above. Byte deltas recorded on the PR; none is `+0 B`. All are **reds**, which is the direction this defect cannot fabricate. ⚠️ The second round exists because the first round's mutations were all aimed at DISARMING the guard, and the claim part 10 actually made was about the guard being FIXED - the reviewer built the fix and the suite stayed green. Bytecode was never the issue there; an unaimed battery was. |
|
||||
| **SAFETY-03** reviewer survivor: `abs()` dropped from `clamp_to_rating`'s limit (6aabb83) | **re-verified — still a survivor** | re-run the same way, all 15 green. Also as recorded: unreachable under the `int(100, 5000)` schema. |
|
||||
| **TARIFF-01**'s other three survivors, **SAFETY-03**'s `describe()` survivor, **TARIFF-02**'s M3/M5/M7, **SAFETY-18**'s three | **still unverified** | recorded before the purge discipline, with no byte delta on the record, so a `+0 B` same-second revert cannot be excluded. All are survivors, which is the direction this defect fabricates. TARIFF-02's and SAFETY-18's were **closed by later commits whose claims are reds**, so what is unverified is the *diagnosis*, not the current state of the code. |
|
||||
|
||||
|
||||
@@ -18,6 +18,14 @@ THE FAILURE NEEDS NO FAULT ANYWHERE ELSE. A frozen-but-talking meter is
|
||||
- there is no SoC guard on the grid-following loop at all. `soc_floor` belongs
|
||||
to the maintenance cycle; the BMS is the sole energy backstop.
|
||||
|
||||
⚠️ STILL TRUE, AND NOW FILED. SAFETY-24 added `peak_soc_floor` and
|
||||
OPT-04 added `arbitrage_soc_floor`, so the sentence above is no longer
|
||||
"there is one SoC floor and it is maintenance's" - but neither of the new
|
||||
ones binds THIS loop, and `peak_soc_floor`'s name reads as though it does.
|
||||
SAFETY-25 replayed the exposure (test_peakguard.py part 10) and recorded
|
||||
the options in DECISIONS/SAFETY-25-loop-soc-floor.md; whether the loop gets
|
||||
a reserve of its own is an open product decision, not an oversight.
|
||||
|
||||
The loop reads a constant error and integrates it. Time from freeze to the rail,
|
||||
replaying the real `control.compute()` at 15 s cycles:
|
||||
|
||||
|
||||
@@ -985,6 +985,16 @@ class Controller:
|
||||
They cannot both be true: peakguard.decide()'s floor-limited branch
|
||||
returns before the `achievable_w` clamp that sets `saturated`.
|
||||
|
||||
⚠️ SAFETY-25 - AND THE MESSAGE SAYS WHAT THE FLOOR IS NOT. Read on its
|
||||
own, "the capacity-peak guard has run out of battery" invites exactly
|
||||
one wrong inference: that the pack is now being held at
|
||||
`peak_soc_floor`. It is not. `peak_soc_floor` bounds THIS GUARD only;
|
||||
the grid-following loop has no SoC floor at all and, on the committed
|
||||
2026-08-20 replay, is being granted discharge in EVERY cycle that raises
|
||||
this notification. An operator who reads the alarm and relaxes because
|
||||
"the EMS has a 20 % reserve" has been misled by us, so the alarm carries
|
||||
the correction itself rather than leaving it to DOCS.md.
|
||||
|
||||
Edge-triggered, and de-asserting arms it again - FreezeWatchdog's shape,
|
||||
for FreezeWatchdog's reason: one notification per episode, not one per
|
||||
cycle for a quarter of an hour.
|
||||
@@ -1010,6 +1020,13 @@ class Controller:
|
||||
# energy floor is not a saturated inverter and is not a frozen meter,
|
||||
# and an operator who has to dismiss three identical-looking cards
|
||||
# learns to dismiss the one that mattered.
|
||||
# ⚠️ SAFETY-25's sentence must render on the unreadable-SoC branch too,
|
||||
# where `soc_floor` is a number but naming it would invite the reader to
|
||||
# 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.
|
||||
_at_floor = ("at the floor" if g.soc_floor is None
|
||||
else f"at {float(g.soc_floor):.0f} %")
|
||||
await self.hass.call("persistent_notification", "create", {
|
||||
"notification_id": "goodwe_peak_soc_floor",
|
||||
"title": "GoodWe: the capacity-peak guard has run out of battery",
|
||||
@@ -1023,7 +1040,13 @@ class Controller:
|
||||
f"deliberately above the pack's own BMS cut-out. Charging stays "
|
||||
f"blocked. Shedding load is the only thing that changes the bill "
|
||||
f"now; if this pack can afford a deeper discharge, lower "
|
||||
f"peak_soc_floor."),
|
||||
f"peak_soc_floor.\n\nSAFETY-25 - WHAT THIS FLOOR IS NOT: "
|
||||
f"peak_soc_floor bounds THE PEAK GUARD ONLY. It is not a "
|
||||
f"pack-wide reserve, and the battery is NOT being held "
|
||||
f"{_at_floor} - ordinary self-consumption has no SoC "
|
||||
f"floor at all and will go on discharging past it, down to the "
|
||||
f"pack's own BMS cut-out. What has stopped is the guard buying "
|
||||
f"you a lower bill, not the pack being spent."),
|
||||
})
|
||||
|
||||
async def check_meter_frozen(self) -> None:
|
||||
@@ -1421,8 +1444,35 @@ class Controller:
|
||||
# is going to be spent and a human is the only actuator left. A
|
||||
# separate row from saturation because the two ask for opposite
|
||||
# things - see check_peak_floor_limited().
|
||||
#
|
||||
# ⚠️ SAFETY-25. THE SCOPE SENTENCE IS PART OF THE ROW, NOT A
|
||||
# DOCS FOOTNOTE. This is the one line an operator reads at the
|
||||
# exact moment the guard stops discharging, and read alone it
|
||||
# says "the battery is being held at peak_soc_floor" - which is
|
||||
# false, and measurably so: on the committed evening replay the
|
||||
# loop goes on discharging in EVERY cycle this row is showing.
|
||||
# A floor that binds one consumer and not the other is worse
|
||||
# than no floor if the page does not say which.
|
||||
#
|
||||
# ⚠️ AND IT CARRIES ITS OWN `banner`. status_payload() renders
|
||||
# the FIRST not-ok/not-warn row as the page's headline, so
|
||||
# without this the loudest element on the page would be a
|
||||
# 550-character paragraph that the row three lines below
|
||||
# repeats verbatim. The banner is the short form; the row keeps
|
||||
# the whole sentence, because the row is where an operator
|
||||
# reads what the floor does and does not bound. Shortening the
|
||||
# ROW instead would be weakening AC 3.
|
||||
out.append({"ok": False, "warn": False,
|
||||
"text": _pg.explain()})
|
||||
"banner": _pg.explain()
|
||||
+ " - and peak_soc_floor bounds THE PEAK "
|
||||
"GUARD ONLY (SAFETY-25, see below)",
|
||||
"text": _pg.explain()
|
||||
+ ". NOTE: peak_soc_floor bounds THE PEAK "
|
||||
"GUARD ONLY - it is not a pack-wide "
|
||||
"reserve, and ordinary self-consumption "
|
||||
"has no SoC floor, so the battery may "
|
||||
"keep discharging below this level down "
|
||||
"to the BMS cut-out (SAFETY-25)"})
|
||||
elif _pg.blind:
|
||||
out.append({"ok": False, "warn": True, "text": _pg.explain()})
|
||||
else:
|
||||
@@ -1528,7 +1578,13 @@ class Controller:
|
||||
level, banner = "bad", ("FAILSAFE — the command opposed the meter. "
|
||||
"Inverter held at 0 W.")
|
||||
elif bad:
|
||||
level, banner = "bad", f"NOT READY — {bad[0]['text']}"
|
||||
# ⚠️ `banner` IF THE ROW OFFERS ONE, `text` OTHERWISE. A row may
|
||||
# need to say more to an operator reading the checks list than fits
|
||||
# in the one line at the top of the page - SAFETY-25's floor-limited
|
||||
# row is the case that forced this - and a headline that is a full
|
||||
# paragraph, repeated verbatim by the row underneath it, is a
|
||||
# headline nobody reads. Rows without the key are unaffected.
|
||||
level, banner = "bad", f"NOT READY — {bad[0].get('banner') or bad[0]['text']}"
|
||||
elif not self.auto:
|
||||
level, banner = "warn", "Stopped — inverter commanded to 0 W"
|
||||
elif self.maint.phase != IDLE:
|
||||
|
||||
@@ -286,6 +286,22 @@ the one place the question is answered. Four things about the shape of it:
|
||||
discharge, which is the thing SAFETY-07 introduced. A loop-side SoC guard
|
||||
is a different lever with a different right value.
|
||||
|
||||
⚠️ SAFETY-25 MEASURED HOW FAR PAST, so this is no longer a caveat that has
|
||||
to be taken on trust. On the committed 2026-08-20 capture, 19:00-22:00 UTC
|
||||
- three hours of house load after the sun has gone, 830 W mean, zero export
|
||||
- the 13.8 kWh reference pack started one point ABOVE `peak_soc_floor`
|
||||
ends the evening at 10.0 %, i.e. at `bms_soc_floor`, having delivered
|
||||
1525 Wh; 343 of 587 control cycles deliver discharge with the pack already
|
||||
under this floor, and the guard files no floor at all on any of them. The
|
||||
same replay at `peak_soc_floor` = 5, 11, 20 and 60 ends at the SAME 10.0 %,
|
||||
which is the finding stated as sharply as it can be: THE EXPOSURE IS A
|
||||
PROPERTY OF THE CONTROL LAW, NOT A VALUE ANYONE CAN CONFIGURE. And on the
|
||||
same day's high-load block the two states coexist - 49 cycles reporting
|
||||
PEAK GUARD FLOOR-LIMITED while the arbiter grants the loop up to 2000 W of
|
||||
discharge in every one of them. test_peakguard.py part 10 is the replay;
|
||||
DECISIONS/SAFETY-25-loop-soc-floor.md carries the options and the
|
||||
recommendation, and the decision itself is the product owner's and is open.
|
||||
|
||||
3. IT IS GRADED ONLY WHERE IT IS NOT DEGENERATE. The SoC question is asked
|
||||
AFTER `wanted > 0` is established - i.e. only where a forced discharge is
|
||||
actually being asked for. Asked earlier it would grade a quantity that
|
||||
@@ -373,6 +389,12 @@ CEILING_MONTH_PEAK = "the month's banked peak"
|
||||
# notification all have to say WHICH, and "floor-limited" alone is unactionable.
|
||||
FLOOR_SOC_UNREADABLE = ("the battery state of charge was not measured this "
|
||||
"cycle, and an unreadable SoC is not a full battery")
|
||||
# ⚠️ SAFETY-25. "the peak guard's SoC floor", not "the SoC floor", and the
|
||||
# possessive is load-bearing: this string is what the status page, the action log
|
||||
# and the operator notification all print, and `peak_soc_floor` bounds THIS GUARD
|
||||
# ONLY. Widening it to "the SoC floor" would make the one sentence an operator
|
||||
# reads at the moment the battery stops shaving into a claim about the pack that
|
||||
# the grid-following loop is at that instant disproving.
|
||||
FLOOR_AT_SOC_FLOOR = "state of charge is at or below the peak guard's SoC floor"
|
||||
|
||||
SOURCE = "peak"
|
||||
|
||||
@@ -186,6 +186,26 @@ options:
|
||||
# worst modelled guard day is 878 Wh, i.e. 6.4 % - so 10 points above the
|
||||
# pack's own cut-out leaves the guard ~1.6 of its worst observed days of room
|
||||
# and still never reaches the BMS.
|
||||
#
|
||||
# ⚠️⚠️ SAFETY-25 - WHAT THIS DOES NOT BOUND, AND IT IS THE HALF THE NAME HIDES.
|
||||
# peak_soc_floor BOUNDS THE PEAK GUARD ONLY. IT IS NOT A PACK-WIDE RESERVE.
|
||||
# Ordinary self-consumption - the grid-following loop that holds the meter at
|
||||
# zero - has no SoC floor of any kind, so on a sustained-load evening it will
|
||||
# discharge STRAIGHT PAST this number and go on until the pack's own BMS
|
||||
# cut-out (bms_soc_floor) stops it. Replayed on the committed 2026-08-20
|
||||
# capture, 19:00-22:00 UTC: a 13.8 kWh pack starting at 21 % ends the evening
|
||||
# at 10.0 %, and 343 of 587 control cycles deliver discharge with the pack
|
||||
# already below this floor. Setting this key higher does not change that by
|
||||
# one tenth of a percent - the same replay at every value the schema accepts
|
||||
# (5..60) ends at the same 10.0 %.
|
||||
#
|
||||
# The reason is structural, not an oversight: the peak guard files a `lo`
|
||||
# limit (a LOWER bound on the setpoint), which is what makes it provably
|
||||
# unable to cap a discharge or contradict the arbiter - and therefore also
|
||||
# unable to stop one the loop itself wants. See peakguard.py's module note and
|
||||
# DECISIONS/SAFETY-25-loop-soc-floor.md, which records the options for a
|
||||
# loop-side reserve and is waiting on the product owner. Until that is
|
||||
# answered, the BMS is the only floor under self-consumption.
|
||||
peak_soc_floor: 20
|
||||
# The pack's OWN cut-out percent - what the BMS enforces whatever is set
|
||||
# above. Register 0x0560 reads 10 on the reference GW5000S-BP, consistent with
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
python3 test_peakguard.py
|
||||
|
||||
Ten parts. What each is for, and - where it matters - what it is deliberately
|
||||
Eleven parts. What each is for, and - where it matters - what it is deliberately
|
||||
NOT allowed to be tested at:
|
||||
|
||||
1. THE CONTROL LAW, `excess_w`, graded AWAY from the two points where it is
|
||||
@@ -46,7 +46,25 @@ NOT allowed to be tested at:
|
||||
capture, against a MODELLED PACK small enough that the guard's own 878 Wh
|
||||
day would take it to the BMS cut-out - so the two worlds, before and after
|
||||
this card, are run against each other rather than reasoned about.
|
||||
10. MUTATION. Eleven one-line defeats of these two guards, each run against
|
||||
10. SAFETY-25: THE LOOP, WHICH THIS FLOOR DOES NOT BOUND. A TRIPWIRE, not a
|
||||
guarantee - it records a defect and goes red when the defect is fixed.
|
||||
Part 11's mutation 13 has said in words since SAFETY-24 that ordinary
|
||||
self-consumption can take the pack past `peak_soc_floor`; this part
|
||||
replays a sustained-load evening through the SHIPPED control law, the
|
||||
SHIPPED guard and the REAL arbiter and measures how far past. It also
|
||||
sweeps `peak_soc_floor` over the whole schema range to show the answer
|
||||
does not move by one part in ten thousand, and pins the four
|
||||
operator-facing surfaces that now say so.
|
||||
⚠️ THE REPLAY ALONE IS NOT THE TRIPWIRE, and believing it was is the
|
||||
defect PR #31's reviewer found: `replay_loop()` hand-builds main.py's
|
||||
claim list, so it is blind to any claim main.py ADDS - which is where a
|
||||
loop-side floor would be built. 10f is what closes that. It pins the set
|
||||
of claim sources `Controller.cycle()` can file, statically over its AST
|
||||
and dynamically through real cycles swept over the whole SoC range, so a
|
||||
ninth source takes this part red. A floor built inside `control.compute()`
|
||||
or the guard instead moves 10a-10c's numbers directly, because the replay
|
||||
drives both.
|
||||
11. MUTATION. Eleven one-line defeats of these two guards, each run against
|
||||
the checks above to show they are not vacuous.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
@@ -99,8 +117,9 @@ from app import peakguard
|
||||
from app.arbiter import (
|
||||
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim, resolve,
|
||||
)
|
||||
from app.control import peak_at_risk
|
||||
from app.control import Tuning, compute, peak_at_risk
|
||||
from app.freeze import FreezeWatchdogConfig, looks_frozen, rail_w
|
||||
from app import main as app_main
|
||||
from app.main import Controller
|
||||
from app.p1 import (
|
||||
PLAUSIBLE_MAX_W, P1Ingest, QUARTER_S, QuarterAverager, make_sample,
|
||||
@@ -169,12 +188,26 @@ def read_option(key: str):
|
||||
|
||||
def read_schema_max(key: str):
|
||||
"""The schema's upper bound for `key` - `int(lo,hi)` -> hi."""
|
||||
return _schema_bound(key, 1)
|
||||
|
||||
|
||||
def read_schema_min(key: str):
|
||||
"""The schema's lower bound for `key` - `int(lo,hi)` -> lo.
|
||||
|
||||
SAFETY-25's sweep needs BOTH ends: the argument there is that no value the
|
||||
schema accepts changes the answer, and a sweep that only reached upwards
|
||||
would leave "well, set it lower then" unanswered.
|
||||
"""
|
||||
return _schema_bound(key, 0)
|
||||
|
||||
|
||||
def _schema_bound(key: str, which: int):
|
||||
body = (HERE / "config.yaml").read_text(encoding="utf-8")
|
||||
marker = body.index("\nschema:")
|
||||
for line in body[marker:].splitlines():
|
||||
st = line.strip()
|
||||
if st.startswith(f"{key}:") and "(" in st and ")" in st:
|
||||
return float(st[st.index("(") + 1:st.rindex(")")].split(",")[1])
|
||||
return float(st[st.index("(") + 1:st.rindex(")")].split(",")[which])
|
||||
return None
|
||||
|
||||
|
||||
@@ -882,6 +915,11 @@ class _Hass:
|
||||
def __init__(self):
|
||||
self.writes = []
|
||||
self.notifications = []
|
||||
# SAFETY-25. The whole payload, not only the id. AC 3 is about what the
|
||||
# operator READS, so a fixture that records only that a notification
|
||||
# fired cannot grade it - TEST-06's finding on SAFETY-18's NOT COVERED
|
||||
# row, one file across.
|
||||
self.calls = []
|
||||
|
||||
async def limits(self, entity):
|
||||
return (-LIVE_MAX_W, LIVE_MAX_W, 1.0)
|
||||
@@ -895,8 +933,15 @@ class _Hass:
|
||||
|
||||
async def call(self, domain, service, data):
|
||||
self.notifications.append(data.get("notification_id"))
|
||||
self.calls.append(dict(data))
|
||||
return True
|
||||
|
||||
def last_message(self, notification_id: str) -> str:
|
||||
"""The body of the most recent notification with that id, or ""."""
|
||||
bodies = [d.get("message", "") for d in self.calls
|
||||
if d.get("notification_id") == notification_id]
|
||||
return bodies[-1] if bodies else ""
|
||||
|
||||
|
||||
def built(db_path, opts=None, store=None):
|
||||
hass = _Hass()
|
||||
@@ -1814,9 +1859,591 @@ check("...and it is a SEPARATE KEY from maintenance_soc_floor, whose SHIPPED "
|
||||
and "maintenance_soc_floor" not in PEAK_OPTION_DEFAULTS)
|
||||
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("10. mutation: what defeats these guards, and which check bites")
|
||||
print("10. SAFETY-25: the loop discharges past this floor, and how far past")
|
||||
|
||||
# ⚠️ WHAT THIS PART IS. It is a TRIPWIRE, in MAINT-04's manner, and it records a
|
||||
# DEFECT rather than a guarantee. SAFETY-24's mutation 13 said in words that the
|
||||
# grid-following loop can take the pack past `peak_soc_floor`; that sentence has
|
||||
# now been written down four times across four tickets and never once measured.
|
||||
# This part measures it on the committed capture and pins the number, so that:
|
||||
#
|
||||
# * the honesty half of SAFETY-25 - the config comment, DOCS.md, the operator
|
||||
# notification and the status row - is anchored to a replayed figure rather
|
||||
# than to a claim, and
|
||||
# * THE DAY SOMEBODY BUILDS A LOOP-SIDE FLOOR, THIS GOES RED. That is the
|
||||
# point. The exposure must not be able to change under a green suite; it has
|
||||
# to be closed deliberately, and closing it means answering AC 2 and
|
||||
# deleting or inverting these checks along with
|
||||
# DECISIONS/SAFETY-25-loop-soc-floor.md's open status.
|
||||
#
|
||||
# ⚠️ AND IT IS 10f THAT MAKES THAT SENTENCE TRUE, NOT 10a-10c. The replay
|
||||
# below hand-builds main.py's claim list, so on its own it is blind to a
|
||||
# floor filed as a NEW CLAIM - the memo's own Option B, and PR #31's
|
||||
# reviewer proved the blindness by building it: the whole suite stayed
|
||||
# green. 10f pins the claim-source set so that shape goes red too. The two
|
||||
# halves cover the two places a loop-side floor can be built: 10f for an
|
||||
# arbiter claim in Controller.cycle(), 10a-10c for a clamp inside
|
||||
# control.compute() or peakguard, which the replay drives directly.
|
||||
#
|
||||
# ⚠️ AND IT DRIVES THE SHIPPED CODE, not a transcription of it: control.compute()
|
||||
# at the shipped tuning, peakguard.decide(), peakguard.claim() and
|
||||
# arbiter.resolve(), in main.py's order. Break any of them and this part moves -
|
||||
# which is what makes it a check rather than a paragraph. See the memo section 7
|
||||
# for what is modelled and what is measured.
|
||||
|
||||
# The shipped tuning, READ from config.yaml rather than typed - the same rule as
|
||||
# every other number in this file. A tuning transcribed here would grade a
|
||||
# control law nobody ships.
|
||||
LOOP_TUNING = Tuning(
|
||||
gain=read_option("gain"), max_w=read_option("max_w"),
|
||||
slew_w=read_option("slew_w"), deadband_w=read_option("deadband_w"),
|
||||
step_w=int(read_option("step_w")), saturation_w=read_option("saturation_w"),
|
||||
saturation_cycles=int(read_option("saturation_cycles")),
|
||||
target_grid_w=read_option("target_grid_w"))
|
||||
LOOP_MAX_W = float(LOOP_TUNING.max_w)
|
||||
print(f" the shipped loop tuning, out of config.yaml: {LOOP_TUNING}")
|
||||
|
||||
# ⚠️ THE REFERENCE PACK, DERIVED FROM THE THREE HARDWARE NUMBERS THIS REPOSITORY
|
||||
# ALREADY STATES, not from a rounded kWh figure typed here. peakguard.py's
|
||||
# PEAK_OPTION_DEFAULTS comment says "300 Ah at 51.2 V with DoD 90 - 13.8 kWh
|
||||
# usable"; that sentence is asserted below, so if the reference pack ever
|
||||
# changes this fixture goes red instead of quietly modelling the old one.
|
||||
REF_PACK_WH = 300.0 * 51.2 * 0.90
|
||||
_pg_src = (HERE / "app" / "peakguard.py").read_text(encoding="utf-8")
|
||||
check("the modelled pack is the repository's own reference pack, and the source "
|
||||
"still says so - 300 Ah at 51.2 V with DoD 90. A fixture whose hardware "
|
||||
"assumption drifts away from the module it grades is a fixture measuring "
|
||||
"a machine nobody owns",
|
||||
"300 Ah at 51.2 V with DoD 90" in _pg_src
|
||||
and abs(REF_PACK_WH - 13800.0) < 100.0)
|
||||
|
||||
|
||||
def evening(h0: int, h1: int):
|
||||
"""The rows of 2026-08-20's capture inside [h0, h1) UTC, as they were.
|
||||
|
||||
`h1` may be 24, meaning "to the end of the capture" - the file runs to
|
||||
21:59:56 UTC, which is local midnight.
|
||||
"""
|
||||
lo = datetime(2026, 8, 20, h0, tzinfo=timezone.utc)
|
||||
hi = (datetime(2026, 8, 21, tzinfo=timezone.utc) if h1 >= 24
|
||||
else datetime(2026, 8, 20, h1, tzinfo=timezone.utc))
|
||||
out = []
|
||||
for line in gzip.open(BAD, "rt", encoding="utf-8"):
|
||||
row = json.loads(line)
|
||||
t = datetime.fromisoformat(row["t"])
|
||||
if lo <= t < hi:
|
||||
out.append((t, float(row["w"])))
|
||||
return out
|
||||
|
||||
|
||||
def replay_loop(window, *, start_soc, soc_floor, usable_wh=REF_PACK_WH):
|
||||
"""The GRID-FOLLOWING LOOP over a window, closed-loop, with a modelled pack.
|
||||
|
||||
part 6's `replay()` models the PEAK GUARD's discharge and pins the SoC above
|
||||
the floor on purpose - it is grading SAFETY-07. This one models the thing
|
||||
SAFETY-07 never claimed to bound: ordinary self-consumption.
|
||||
|
||||
The cycle is main.py's, in main.py's order and with main.py's claims:
|
||||
|
||||
peakguard.decide(...) the guard sees this cycle's SoC
|
||||
control.compute(...) the loop sees the meter minus the battery
|
||||
arbiter.resolve([supervised, loop@P_LOOP, peakguard.claim(...)])
|
||||
|
||||
⚠️ THE ARBITER IS IN THE PATH AND THAT IS THE WHOLE POINT. Cutting it out
|
||||
and reading `Decision.target_w` directly would measure the control law and
|
||||
prove nothing about the floor: the question is whether the guard's `lo`
|
||||
claim, filed at P_PEAK against a loop winning at P_LOOP, can cap the
|
||||
discharge. It cannot, and it is `resolve()` that has to say so.
|
||||
|
||||
The pack model is part 9's, one line for one line - SoC falls by the
|
||||
DELIVERED discharge integrated over the real telegram stamps, the BMS is a
|
||||
hard cut at `bms_soc_floor`, and nothing recharges inside the window.
|
||||
`start_soc` is the one number the captures cannot supply (they carry meter
|
||||
power only), and it is derived from the shipped option rather than picked.
|
||||
"""
|
||||
ing = P1Ingest(phases=1, max_age_s=MAX_HOLD_S)
|
||||
store = fresh_store(f"s25-{window[0][0]:%H%M}-{soc_floor:.0f}-{start_soc:.0f}")
|
||||
tracker = PeakTracker(store, cap_w=CAP_W)
|
||||
batt = 0.0 # delivered discharge, W (positive)
|
||||
prev_w = i_w = 0.0
|
||||
sat = 0
|
||||
soc = float(start_soc)
|
||||
min_soc = soc
|
||||
wh = 0.0
|
||||
prev_t = next_cycle = None
|
||||
cycles = below = below_discharging = limited = limited_discharging = 0
|
||||
at_bms = guard_floors = 0
|
||||
max_granted = 0.0
|
||||
for t, w in window:
|
||||
if prev_t is not None:
|
||||
dwh = batt * (t - prev_t).total_seconds() / 3600.0
|
||||
wh += dwh
|
||||
soc = max(0.0, soc - 100.0 * dwh / usable_wh)
|
||||
min_soc = min(min_soc, soc)
|
||||
prev_t = t
|
||||
ing.submit(sample(t, w - batt))
|
||||
tracker.sync(ing)
|
||||
if next_cycle is not None and t < next_cycle:
|
||||
continue
|
||||
next_cycle = t + timedelta(seconds=CYCLE_S)
|
||||
cycles += 1
|
||||
d = decide(PeakTracker.project(ing.averager, t), batt_w=batt,
|
||||
cap_w=CAP_W, reading=tracker.peak(t),
|
||||
achievable_w=LOOP_MAX_W, max_hold_s=MAX_HOLD_S, soc=soc,
|
||||
soc_floor=soc_floor, frozen=False)
|
||||
dec = compute(prev_w, w - batt, batt, LOOP_TUNING, sat_count=sat,
|
||||
i_w=i_w)
|
||||
sat, i_w = dec.sat_count, dec.i_w
|
||||
claims = [Claim.limit("supervised", P_SAFETY, -LOOP_MAX_W, LOOP_MAX_W,
|
||||
"max_w"),
|
||||
Claim.set("loop", P_LOOP, dec.target_w, dec.reason)]
|
||||
pc = peakguard.claim(d)
|
||||
if pc is not None:
|
||||
claims.append(pc)
|
||||
if pc.lo > 0.0:
|
||||
guard_floors += 1
|
||||
r = resolve(claims)
|
||||
prev_w = r.target_w
|
||||
# THE BMS, MODELLED - part 9's line, for part 9's reason. The pack's own
|
||||
# cut-out is a real floor and it works; the finding is that it is the
|
||||
# only one.
|
||||
if soc <= BMS_FLOOR:
|
||||
at_bms += 1
|
||||
batt = 0.0
|
||||
else:
|
||||
batt = max(0.0, r.target_w)
|
||||
max_granted = max(max_granted, batt)
|
||||
if soc < soc_floor:
|
||||
below += 1
|
||||
below_discharging += 1 if batt > 0.0 else 0
|
||||
if d.floor_limited:
|
||||
limited += 1
|
||||
limited_discharging += 1 if batt > 0.0 else 0
|
||||
return {"cycles": cycles, "wh": wh, "min_soc": min_soc, "end_soc": soc,
|
||||
"below": below, "below_discharging": below_discharging,
|
||||
"limited": limited, "limited_discharging": limited_discharging,
|
||||
"at_bms": at_bms, "guard_floors": guard_floors,
|
||||
"max_granted": max_granted}
|
||||
|
||||
|
||||
# --- 10a. the sustained-load evening ----------------------------------------
|
||||
# 19:00-22:00 UTC is 21:00-24:00 local: the sun is down, the PV is off, and the
|
||||
# house is simply drawing. Chosen for that property and CHECKED for it below -
|
||||
# a window with export in it would let the loop recharge and the replay would be
|
||||
# measuring something else.
|
||||
EVE = evening(19, 24)
|
||||
EVE_W = [w for _, w in EVE]
|
||||
print(f" the evening of 2026-08-20, {EVE[0][0]:%H:%M}-{EVE[-1][0]:%H:%M} UTC: "
|
||||
f"{len(EVE)} telegrams, mean {sum(EVE_W) / len(EVE_W):.0f} W, "
|
||||
f"min {min(EVE_W):.0f} W, max {max(EVE_W):.0f} W")
|
||||
check("the window really is a SUSTAINED-LOAD evening and not a quiet one - "
|
||||
"every telegram in it is an IMPORT, so the pack has nowhere to recharge "
|
||||
"from and the loop is discharging for the whole three hours",
|
||||
min(EVE_W) > 0.0 and len(EVE) > 1500)
|
||||
|
||||
# ⚠️ ONE POINT ABOVE THE FLOOR, DERIVED FROM THE SHIPPED OPTION. This is the
|
||||
# state an operator who has read `peak_soc_floor: 20` believes is protected: the
|
||||
# pack is above the number they set. It is the only fixture parameter the
|
||||
# captures cannot supply, and it is taken from config.yaml rather than chosen.
|
||||
EVE_START = SOC_FLOOR + 1.0
|
||||
EV = replay_loop(EVE, start_soc=EVE_START, soc_floor=SOC_FLOOR)
|
||||
print(f" the reference pack ({REF_PACK_WH:.0f} Wh usable) starting at "
|
||||
f"{EVE_START:.0f} %, one point above peak_soc_floor {SOC_FLOOR:.0f} %:")
|
||||
print(f" ends at {EV['end_soc']:.1f} %, low-water {EV['min_soc']:.1f} %, "
|
||||
f"{EV['wh']:.0f} Wh delivered by the LOOP over {EV['cycles']} cycles")
|
||||
print(f" {EV['below']} cycles with the pack under peak_soc_floor, "
|
||||
f"{EV['below_discharging']} of them still discharging; "
|
||||
f"{EV['at_bms']} at the BMS cut-out; the guard filed a floor on "
|
||||
f"{EV['guard_floors']}")
|
||||
check(f"THE FINDING, REPLAYED. One sustained-load evening takes the pack from "
|
||||
f"above peak_soc_floor to the pack's own BMS cut-out - "
|
||||
f"{EVE_START:.0f} % -> {EV['end_soc']:.1f} % against a "
|
||||
f"{BMS_FLOOR:.0f} % cut-out - and the EMS floor an operator set is not "
|
||||
f"what stopped it",
|
||||
EV["min_soc"] <= BMS_FLOOR + 0.05 and EV["at_bms"] > 0)
|
||||
check("...and it is not one cycle of overshoot past the floor either: the loop "
|
||||
"goes on delivering discharge for hundreds of cycles with the pack "
|
||||
"already below the number the operator configured",
|
||||
EV["below_discharging"] > 100 and EV["below"] > EV["below_discharging"])
|
||||
check("...and the peak guard is not involved at all. It never files a floor on "
|
||||
"this evening, because 830 W of house load never projects a quarter over "
|
||||
"the 3500 W ceiling - so `peak_soc_floor` is not merely too weak here, it "
|
||||
"is never consulted", EV["guard_floors"] == 0 and EV["limited"] == 0)
|
||||
|
||||
# --- 10b. and it is a BEHAVIOUR, not a config value -------------------------
|
||||
# ⚠️ THE SWEEP IS THE ARGUMENT. "Raise peak_soc_floor" is the first thing anyone
|
||||
# reading the option name would try, so the check is not that it is insufficient
|
||||
# - it is that it does NOTHING, at any value the schema will accept, to four
|
||||
# decimal places. Both ends of the schema range are swept rather than a
|
||||
# comfortable middle.
|
||||
SOC_SCHEMA_LO = float(read_schema_min("peak_soc_floor"))
|
||||
SOC_SCHEMA_HI = float(read_schema_max("peak_soc_floor"))
|
||||
SWEEP_FLOORS = [SOC_SCHEMA_LO, BMS_FLOOR + 1.0, SOC_FLOOR, SOC_SCHEMA_HI]
|
||||
SWEPT = [(f, replay_loop(EVE, start_soc=EVE_START, soc_floor=f))
|
||||
for f in SWEEP_FLOORS]
|
||||
for f, res in SWEPT:
|
||||
print(f" peak_soc_floor {f:5.0f} % -> ends at {res['end_soc']:.4f} %,"
|
||||
f" {res['wh']:.2f} Wh out, guard floors {res['guard_floors']}")
|
||||
check(f"the exposure is a BEHAVIOUR, NOT A CONFIG VALUE - the same evening ends "
|
||||
f"at the same state of charge at every peak_soc_floor the schema accepts "
|
||||
f"({SOC_SCHEMA_LO:.0f}..{SOC_SCHEMA_HI:.0f} %), to four decimal places. "
|
||||
f"This is AC 4's sentence, measured: the option an operator would reach "
|
||||
f"for cannot move this number by any amount at all",
|
||||
len({round(res["end_soc"], 4) for _, res in SWEPT}) == 1
|
||||
and len({round(res["wh"], 4) for _, res in SWEPT}) == 1)
|
||||
# ⚠️ THE SWEEP DELIBERATELY OVERRUNS WHAT VALIDATE WOULD ACCEPT. Its low end is
|
||||
# the SCHEMA's, and rule 8 refuses anything at or below `bms_soc_floor`, so a
|
||||
# start at 5 % is not a configuration an operator could boot. Including it is the
|
||||
# point: it closes "you swept only the legal band" without leaving the reader to
|
||||
# wonder what happens outside it. The band that IS startable is bounded below by
|
||||
# rule 8, and BMS_FLOOR + 1 is its first member, which is in the sweep too.
|
||||
check("...and the sweep spans the whole schema range, rule 8's refused low end "
|
||||
"included, so 'a legal value would have worked' is closed off rather "
|
||||
"than left open. The first STARTABLE floor - one point above "
|
||||
"bms_soc_floor - is swept as well, and lands on the same number",
|
||||
SOC_SCHEMA_HI >= 60.0 and SOC_SCHEMA_LO <= BMS_FLOOR
|
||||
and (BMS_FLOOR + 1.0) in SWEEP_FLOORS
|
||||
and len([f for f in SWEEP_FLOORS if f > BMS_FLOOR]) >= 3)
|
||||
|
||||
# --- 10c. the two states in the SAME cycle ----------------------------------
|
||||
# The evening never engages the guard, so it cannot show the sharpest form of
|
||||
# the finding: the status page saying FLOOR-LIMITED while the pack empties. The
|
||||
# same day's high-load block can, and it is the same capture and the same code.
|
||||
HOT = evening(15, 17)
|
||||
HOT_R = replay_loop(HOT, start_soc=SOC_FLOOR - 1.0, soc_floor=SOC_FLOOR)
|
||||
print(f" the same day's high-load block "
|
||||
f"{HOT[0][0]:%H:%M}-{HOT[-1][0]:%H:%M} UTC, pack started one point BELOW "
|
||||
f"the floor:")
|
||||
print(f" {HOT_R['limited']} cycles reporting PEAK GUARD FLOOR-LIMITED, "
|
||||
f"{HOT_R['limited_discharging']} of them with the loop simultaneously "
|
||||
f"granted discharge (max {HOT_R['max_granted']:.0f} W)")
|
||||
check("THE ASYMMETRY, ON REAL DATA. On every cycle where the guard reports "
|
||||
"FLOOR-LIMITED - 'commanding no forced discharge' on the status page and "
|
||||
"in the operator's notification - the arbiter is at the same instant "
|
||||
"granting the LOOP a discharge. A `lo` limit cannot cap a discharge, so "
|
||||
"the guard's floor and the pack emptying are not alternatives; they "
|
||||
"happen together",
|
||||
HOT_R["limited"] > 10
|
||||
and HOT_R["limited_discharging"] == HOT_R["limited"]
|
||||
and HOT_R["max_granted"] > 0.0)
|
||||
check("...which is why SAFETY-24's floor could not be widened into this one, "
|
||||
"and why the fix is not a bigger number. Recorded in "
|
||||
"DECISIONS/SAFETY-25-loop-soc-floor.md; AC 2 is the product owner's and "
|
||||
"is open", HOT_R["min_soc"] <= BMS_FLOOR + 0.05)
|
||||
|
||||
# --- 10d. the trap in the obvious fix, checked rather than asserted ---------
|
||||
# ⚠️ THE MEMO'S CENTRAL ENGINEERING CLAIM, RUN THROUGH THE REAL ARBITER. The
|
||||
# first shape anyone reaches for is a `hi` limit on the loop - and filed while
|
||||
# the peak guard is asking for a positive floor it is `lo > hi`, which
|
||||
# arbiter.py resolves to 0 W: THE BATTERY PARKED, in the one quarter of the
|
||||
# month that sets the bill. That is the same crossing-limits hazard
|
||||
# shaping_suspended() already exists to avoid, and it is why the memo's Option B
|
||||
# carries an ORDERING RULE rather than a number.
|
||||
_RESERVE_HI = Claim.limit("reserve", P_LOOP, hi=0.0, reason="loop SoC floor")
|
||||
_naive = resolve(SAFE + [Claim.set("loop", P_LOOP, 1800.0, "grid"),
|
||||
peakguard.claim(d_ok), _RESERVE_HI])
|
||||
_ordered = resolve(SAFE + [Claim.set("loop", P_LOOP, 1800.0, "grid"),
|
||||
lc, _RESERVE_HI])
|
||||
print(f" a naive loop reserve while the guard wants a floor: "
|
||||
f"{_naive.explain()}")
|
||||
print(f" the same reserve while the guard is floor-limited: "
|
||||
f"{_ordered.explain()}")
|
||||
check("a loop-side `hi` reserve filed while the peak guard is asking for a "
|
||||
"POSITIVE floor is a CONTRADICTION and parks the battery at 0 W - so the "
|
||||
"obvious shape of AC 2 has a trap in it, and this is it, run rather than "
|
||||
"described", _naive.contradiction and near(_naive.target_w, 0.0))
|
||||
check("...and the ordering the memo proposes removes it: with "
|
||||
"loop_soc_floor <= peak_soc_floor the guard is ALREADY floor-limited "
|
||||
"wherever the reserve binds, so it files lo = 0, and lo == hi == 0 "
|
||||
"resolves to a clean 0 W with no contradiction",
|
||||
not _ordered.contradiction and near(_ordered.target_w, 0.0)
|
||||
and near(lc.lo, 0.0) and peakguard.claim(d_ok).lo > 0.0)
|
||||
|
||||
# --- 10e. the honesty half, pinned to the shipped text ----------------------
|
||||
# ⚠️ ASSERT THE STRING, NOT THE INTENT - TEST-06's lesson on SAFETY-18's NOT
|
||||
# COVERED row, which was untested for exactly this reason: the one branch that
|
||||
# exists to tell an operator what a guard does NOT cover is the one nothing
|
||||
# grades. These four surfaces are the whole of AC 3, and each is checked where
|
||||
# it actually renders.
|
||||
_cfg_src = (HERE / "config.yaml").read_text(encoding="utf-8")
|
||||
_docs_src = (HERE / "DOCS.md").read_text(encoding="utf-8")
|
||||
_memo = HERE / "DECISIONS" / "SAFETY-25-loop-soc-floor.md"
|
||||
check("config.yaml says next to the option itself that peak_soc_floor bounds "
|
||||
"the peak guard only and is not a pack-wide reserve - the operator edits "
|
||||
"this file, so it is the first place the scope has to be true",
|
||||
"BOUNDS THE PEAK GUARD ONLY" in _cfg_src
|
||||
and "NOT A PACK-WIDE RESERVE" in _cfg_src.upper()
|
||||
and "bms_soc_floor) stops it" in _cfg_src)
|
||||
check("...and DOCS.md says it in the options table AND in the section, with the "
|
||||
"replayed evening rather than an assurance",
|
||||
"not a pack-wide reserve" in _docs_src
|
||||
and "IT BOUNDS THE PEAK GUARD ONLY" in _docs_src
|
||||
and "SAFETY-25" in _docs_src)
|
||||
check("...and the decision memo AC 1 asks for is COMMITTED, carries the options "
|
||||
"and a recommendation, and is honest that the decision is not taken",
|
||||
_memo.exists()
|
||||
and "Status: OPEN. This is a memo, not a decision." in _memo.read_text(
|
||||
encoding="utf-8")
|
||||
and "Recommendation" in _memo.read_text(encoding="utf-8"))
|
||||
check("...and DOCS.md sends the reader to it rather than leaving the open "
|
||||
"question implicit",
|
||||
"DECISIONS/SAFETY-25-loop-soc-floor.md" in _docs_src)
|
||||
|
||||
# The two OPERATOR-FACING surfaces, rendered by the real Controller rather than
|
||||
# grepped: the status row and the notification body are what a human reads at
|
||||
# the moment the guard stops, and a scope sentence that lives only in a source
|
||||
# comment is a scope sentence the operator never sees.
|
||||
c25, hass25, NET25, _ = wired_over_limit(
|
||||
TMP / "soc25.db", LIVE_MAX_W * 0.3, soc=SOC_FLOOR - 1.0)
|
||||
rows25 = [r for r in c25.checks() if "peak guard" in r["text"].lower()]
|
||||
print(" status row: " + (rows25[0]["text"] if rows25 else "(none)"))
|
||||
check("the STATUS PAGE row for a floor-limited guard says the floor bounds the "
|
||||
"guard only and that self-consumption may keep discharging below it. "
|
||||
"Without that sentence the page implies a pack-wide reserve at the exact "
|
||||
"moment the replay above says the loop is still emptying the pack",
|
||||
len(rows25) == 1
|
||||
and "PEAK GUARD FLOOR-LIMITED" in rows25[0]["text"]
|
||||
and "BOUNDS THE PEAK GUARD ONLY" in rows25[0]["text"].upper()
|
||||
and "not a pack-wide reserve" in rows25[0]["text"]
|
||||
and "BMS cut-out" in rows25[0]["text"])
|
||||
# ⚠️ THE ROW KEEPS THE WHOLE SENTENCE; THE BANNER DOES NOT. status_payload()
|
||||
# renders the first not-ok/not-warn row as the page headline, so before this the
|
||||
# loudest element on the page was the same 550-character paragraph the row three
|
||||
# lines below repeats verbatim. The scope wording is not weakened anywhere it
|
||||
# matters - it is the ROW that an operator reads to learn what the floor bounds,
|
||||
# and the row is asserted in full above.
|
||||
_pay25 = c25.status_payload()
|
||||
print(f" banner ({len(_pay25['banner'])} chars): {_pay25['banner']}")
|
||||
# The guard's own line, which the banner has always carried and which is not
|
||||
# this card's to shorten: `explain()` is SAFETY-24's sentence about a quarter
|
||||
# that is going over the ceiling.
|
||||
_expl25 = rows25[0]["text"].split(". NOTE:")[0]
|
||||
check("the NOT READY banner is the guard's own line plus a SHORT scope tag - "
|
||||
"under 90 characters of it - and NOT the row's whole paragraph repeated "
|
||||
"at the top of the page. A headline that is a paragraph is a headline "
|
||||
"nobody reads, and this one is followed three lines later by the same "
|
||||
"text verbatim",
|
||||
_pay25["level"] == "bad"
|
||||
and _pay25["banner"].startswith("NOT READY")
|
||||
and _expl25 in _pay25["banner"]
|
||||
and "BOUNDS THE PEAK GUARD ONLY" in _pay25["banner"].upper()
|
||||
and "not a pack-wide reserve" not in _pay25["banner"]
|
||||
and len(_pay25["banner"]) - len(_expl25) < 90)
|
||||
check("...and shortening the banner did NOT shorten the row: the full AC 3 "
|
||||
"sentence is still what the checks list carries, which is the surface the "
|
||||
"operator reads to find out what the floor does not bound",
|
||||
len(rows25[0]["text"]) > 300
|
||||
and "not a pack-wide reserve" in rows25[0]["text"]
|
||||
and "BMS cut-out" in rows25[0]["text"])
|
||||
|
||||
_body25 = hass25.last_message("goodwe_peak_soc_floor")
|
||||
print(f" notification body tail: ...{_body25[-180:]}")
|
||||
check("...and so does the persistent notification, which outlives the page and "
|
||||
"is the thing an operator reads on a phone at 19:00. It names what has "
|
||||
"stopped - the guard buying a lower bill - and what has not",
|
||||
"peak_soc_floor bounds THE PEAK GUARD ONLY" in _body25
|
||||
and "not a" in _body25 and "pack-wide reserve" in _body25
|
||||
and "BMS cut-out" in _body25)
|
||||
|
||||
|
||||
# --- 10f. THE CLAIM-SOURCE SET, PINNED --------------------------------------
|
||||
# ⚠️ THIS IS WHAT MAKES 10a-10c A TRIPWIRE RATHER THAN A SNAPSHOT, and it is
|
||||
# here because the first cut of this part did NOT have it and the tripwire
|
||||
# sentence above was therefore FALSE. `replay_loop()` hand-builds main.py's
|
||||
# claim list as a literal `[supervised, loop, peakguard.claim(...)]`, so it is
|
||||
# structurally BLIND to any claim main.py ADDS - which is exactly where AC 2
|
||||
# would be built. PR #31's reviewer proved it by building the fix: they
|
||||
# implemented the memo's own Option B in main.py - a
|
||||
# `Claim.limit("reserve", P_LOOP, hi=0.0)` filed under a `loop_soc_floor` - and
|
||||
# the whole suite stayed GREEN, this part included, while mutation 13 went on
|
||||
# printing "NOTHING PREVENTS IT, ON PURPOSE" and the same 21 % -> 10.0 % on a
|
||||
# tree where something DID prevent it. Pushed to an always-binding 95 % reserve
|
||||
# it was still green here. That is this repository's named failure mode - a
|
||||
# check that observed something other than what it claimed to observe - and it
|
||||
# had been committed as a guarantee in four places.
|
||||
#
|
||||
# So the MODEL is now pinned to the MODELLED THING, two ways, because the two
|
||||
# fail differently:
|
||||
#
|
||||
# * STATICALLY, over `Controller.cycle()`'s own AST. Every `claims.append(...)`
|
||||
# in it must be one this scan can read, and the set of sources it can file
|
||||
# must be exactly the eight below. A ninth - a `reserve`, whatever it ends
|
||||
# up called - takes this red on the line it is written, in every
|
||||
# configuration, whether or not any fixture here reaches the state that
|
||||
# files it.
|
||||
# * DYNAMICALLY, through REAL `Controller.cycle()`s with main.py's OWN
|
||||
# `resolve` spied, swept over the WHOLE state-of-charge range in the two
|
||||
# states 10a-10c replay. The static scan pins the NAMES; this pins that the
|
||||
# names actually reach the arbiter and that nothing else does. A claim that
|
||||
# appears only at a low SoC - precisely the shape a loop-side floor has -
|
||||
# cannot hide from a sweep that visits 0 %.
|
||||
#
|
||||
# The third way a loop-side floor could be built is INSIDE `control.compute()`
|
||||
# or the guard (the memo's Option D), and that needs nothing new here: the
|
||||
# replay above drives both, so it moves the numbers in 10a-10c directly.
|
||||
|
||||
# The whole inventory of claim sources main.py can file, in any configuration.
|
||||
# ⚠️ ADDING TO THIS SET IS A DELIBERATE ACT. If you are here because you added a
|
||||
# claim to `Controller.cycle()` and this went red, that is the tripwire working:
|
||||
# read DECISIONS/SAFETY-25-loop-soc-floor.md, answer AC 2 in it, and update
|
||||
# part 10 and mutation 13 rather than this line alone.
|
||||
CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak",
|
||||
"arbitrage", "maintenance", "loop"}
|
||||
# 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.
|
||||
CLAIM_BUILDERS = {"peak_claim": "peak", "arb_claim": "arbitrage"}
|
||||
|
||||
_main_tree = ast.parse((HERE / "app" / "main.py").read_text(encoding="utf-8"))
|
||||
_cycle_def = next(
|
||||
f
|
||||
for cls in ast.walk(_main_tree)
|
||||
if isinstance(cls, ast.ClassDef) and cls.name == "Controller"
|
||||
for f in cls.body
|
||||
if isinstance(f, (ast.FunctionDef, ast.AsyncFunctionDef)) and f.name == "cycle")
|
||||
|
||||
|
||||
def claim_source(node):
|
||||
"""The source string an appended expression files, or None if unreadable."""
|
||||
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
||||
and isinstance(node.func.value, ast.Name)
|
||||
and node.func.value.id == "Claim"
|
||||
and node.func.attr in ("set", "limit") and node.args
|
||||
and isinstance(node.args[0], ast.Constant)
|
||||
and isinstance(node.args[0].value, str)):
|
||||
return node.args[0].value
|
||||
if isinstance(node, ast.Name):
|
||||
return CLAIM_BUILDERS.get(node.id)
|
||||
return None
|
||||
|
||||
|
||||
_appends = [n for n in ast.walk(_cycle_def)
|
||||
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
|
||||
and n.func.attr == "append" and isinstance(n.func.value, ast.Name)
|
||||
and n.func.value.id == "claims" and len(n.args) == 1]
|
||||
_filed = [claim_source(n.args[0]) for n in _appends]
|
||||
print(f" main.py Controller.cycle() files {len(_appends)} claims, from "
|
||||
f"{len(CLAIM_SOURCES)} sources: "
|
||||
f"{', '.join(sorted({s for s in _filed if s}))}")
|
||||
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 eight. 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 "
|
||||
"NINTH source. Filing one takes this line red on the line it is written",
|
||||
set(_filed) == CLAIM_SOURCES)
|
||||
|
||||
|
||||
def cycle_claims(run):
|
||||
"""The claim list main.py's OWN `resolve` was handed, from a real cycle.
|
||||
|
||||
⚠️ SPIED AT `app.main.resolve`, not reconstructed. The point of the whole
|
||||
sub-part is that this file must stop believing its own transcription of
|
||||
main.py's claim list, so the observation has to be taken from main.py's
|
||||
call site and nowhere else.
|
||||
"""
|
||||
seen = []
|
||||
real = app_main.resolve
|
||||
|
||||
def _spy(claims):
|
||||
seen.append(list(claims))
|
||||
return real(claims)
|
||||
|
||||
app_main.resolve = _spy
|
||||
try:
|
||||
out = run()
|
||||
finally:
|
||||
app_main.resolve = real
|
||||
return out, (seen[-1] if seen else [])
|
||||
|
||||
|
||||
def run_cycle(ctl):
|
||||
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
|
||||
ctl.cycle())
|
||||
return ctl
|
||||
|
||||
|
||||
# The IDLE state, which is what 10a and 10b replay: a sustained house load well
|
||||
# under the ceiling, so the guard files nothing and the loop is alone with the
|
||||
# safety limits. One controller, cycled once per state of charge - the load is
|
||||
# the evening's own mean, so this is the evening's cycle at every SoC it passes
|
||||
# through and at every SoC it does not.
|
||||
EVE_MEAN_W = sum(EVE_W) / len(EVE_W)
|
||||
c_src, _ = built(TMP / "sources.db")
|
||||
c_src.auto, c_src.batt, c_src.soc = True, 0.0, 100.0
|
||||
_blk_src = datetime.fromtimestamp(
|
||||
int(datetime.now(timezone.utc).timestamp() // QUARTER_S) * QUARTER_S,
|
||||
timezone.utc)
|
||||
for _i in range(0, int((datetime.now(timezone.utc)
|
||||
- _blk_src).total_seconds()) + 1, 5):
|
||||
c_src.p1.submit(sample(_blk_src + timedelta(seconds=_i), EVE_MEAN_W))
|
||||
c_src.grid = EVE_MEAN_W
|
||||
|
||||
# ⚠️ THE WHOLE RANGE, INCLUDING 0 %. A reserve conditioned on a low pack is
|
||||
# invisible to a sweep that starts at a comfortable one, and "conditioned on a
|
||||
# low pack" is the definition of the claim this is watching for.
|
||||
SRC_SWEEP = sorted({float(s) for s in range(0, 101, 5)}
|
||||
| {BMS_FLOOR, SOC_FLOOR, EVE_START, EV["end_soc"]})
|
||||
IDLE_SRC = {}
|
||||
for _soc in SRC_SWEEP:
|
||||
c_src.soc = _soc
|
||||
_, _cl = cycle_claims(lambda: run_cycle(c_src))
|
||||
IDLE_SRC[_soc] = frozenset(c.source for c in _cl)
|
||||
IDLE_SETS = set(IDLE_SRC.values())
|
||||
print(f" a real idle cycle at {len(SRC_SWEEP)} states of charge "
|
||||
f"({min(SRC_SWEEP):.0f}..{max(SRC_SWEEP):.0f} %) files: "
|
||||
+ " | ".join(sorted(", ".join(sorted(s)) for s in IDLE_SETS)))
|
||||
check("a REAL Controller.cycle() in the state 10a and 10b replay - auto, P1 "
|
||||
"healthy, the guard idle under an 830 W load - hands the arbiter exactly "
|
||||
"{device, supervised, loop}, at EVERY state of charge from 0 to 100 %. "
|
||||
"Not one source appears at a low pack that does not appear at a full one, "
|
||||
"which is the claim a loop-side reserve would break",
|
||||
IDLE_SETS == {frozenset({"device", "supervised", "loop"})}
|
||||
and len(SRC_SWEEP) >= 21 and min(SRC_SWEEP) == 0.0)
|
||||
|
||||
# The ENGAGED state, which is what 10c replays: the guard over its ceiling and
|
||||
# floor-limited on an empty pack. Two states of charge, both under the floor,
|
||||
# because that is the band a loop-side reserve would be conditioned on.
|
||||
HOT_SRC = set()
|
||||
for _soc in (SOC_FLOOR - 1.0, 0.0):
|
||||
(c_h, _h, _n, _r), _cl = cycle_claims(
|
||||
lambda: wired_over_limit(TMP / f"sources-hot-{_soc:.0f}.db",
|
||||
LIVE_MAX_W * 0.3, soc=_soc))
|
||||
HOT_SRC.add(frozenset(c.source for c in _cl))
|
||||
_hot_limited = c_h.peak_guard is not None and c_h.peak_guard.floor_limited
|
||||
print(" a real FLOOR-LIMITED cycle files: "
|
||||
+ " | ".join(sorted(", ".join(sorted(s)) for s in HOT_SRC)))
|
||||
check("...and with the guard actually FLOOR-LIMITED - 10c's state, on an empty "
|
||||
"pack - the set gains the guard's own claim and NOTHING ELSE. So the "
|
||||
"three sources replay_loop() hand-builds are the whole cycle in both of "
|
||||
"the states it replays, rather than a subset of it that happened to "
|
||||
"match", _hot_limited
|
||||
and HOT_SRC == {frozenset({"device", "supervised", "loop", "peak"})})
|
||||
|
||||
# THE FOLD, stated rather than assumed. replay_loop() files ONE `supervised`
|
||||
# limit where main.py files two - `device`, from the inverter's own reported
|
||||
# range, and `supervised`, from `max_w`. Both are symmetric P_SAFETY limits, so
|
||||
# the tighter one binds and modelling them as one is exact, not an
|
||||
# approximation. Checked on the claims the real cycle actually filed.
|
||||
_safety_lims = [c for c in _cl if c.source in ("device", "supervised")]
|
||||
check("...and replay_loop() folding main.py's TWO P_SAFETY rating limits into "
|
||||
"its one `supervised` claim is exact rather than convenient: both are "
|
||||
"symmetric LIMIT claims at P_SAFETY, so the tighter of the pair binds and "
|
||||
"one claim at the tighter bound is the same constraint",
|
||||
len(_safety_lims) == 2
|
||||
and all(c.kind == "limit" and c.priority == P_SAFETY
|
||||
and near(c.lo, -c.hi) and c.hi > 0.0 for c in _safety_lims)
|
||||
and near(LOOP_MAX_W, read_option("max_w")))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("11. mutation: what defeats these guards, and which check bites")
|
||||
|
||||
# Each entry re-implements ONE defeat and reports whether a check above would
|
||||
# have gone red. The last row is the honest one: it does NOT bite, and saying so
|
||||
@@ -1929,13 +2556,28 @@ MUT.append(("floor-limited reported but the floor still filed at `wanted`",
|
||||
# 13. THE SECOND ONE THAT DOES NOT BITE, and it is a real limitation rather than
|
||||
# a defect. Stated, because the alternative is a reader believing this card
|
||||
# bounds more than it does.
|
||||
#
|
||||
# ⚠️ SAFETY-25 CHANGED WHAT "NOT CAUGHT" MEANS HERE, AND ONLY THAT. Nothing
|
||||
# PREVENTS it - the code is unchanged and a `lo` limit still cannot cap a
|
||||
# discharge - but part 10 now MEASURES it on the committed capture and 10f
|
||||
# PINS THE CLAIM-SOURCE SET around it, so the exposure can no longer change
|
||||
# size under a green suite: file the reserve and 10f goes red on the line
|
||||
# it is written. That is the whole difference between an uncaught mutation
|
||||
# and an unrecorded one. ⚠️ THE PIN IS LOAD-BEARING FOR THIS ROW. Without
|
||||
# it this rationale was FALSE - PR #31's reviewer built the memo's Option B
|
||||
# and this row went on printing "NOTHING PREVENTS IT" under a green suite.
|
||||
_loop_unbounded = near(r_disch.target_w, 1800.0, 1e-9)
|
||||
MUT.append(("the grid-following loop discharging past this floor",
|
||||
not _loop_unbounded,
|
||||
"NOTHING, ON PURPOSE - this card bounds THE PEAK GUARD's forced "
|
||||
"discharge, the thing SAFETY-07 introduced. SAFETY-18's finding "
|
||||
"that the loop itself has no SoC guard is still open, and a `lo` "
|
||||
"limit is structurally incapable of closing it"))
|
||||
"NOTHING PREVENTS IT, ON PURPOSE - this card bounds THE PEAK "
|
||||
"GUARD's forced discharge, the thing SAFETY-07 introduced. A `lo` "
|
||||
"limit is structurally incapable of closing SAFETY-18's finding "
|
||||
"that the loop has no SoC guard. SAFETY-25 part 10 now MEASURES "
|
||||
"it: one sustained-load evening, "
|
||||
f"{EVE_START:.0f} % -> {EV['end_soc']:.1f} % on the reference pack, "
|
||||
f"identical at every peak_soc_floor the schema accepts - and 10f "
|
||||
f"pins the claim-source set, so building the floor takes this "
|
||||
f"part RED instead of leaving this row quietly stale"))
|
||||
|
||||
for label, caught, where in MUT:
|
||||
print(f" {'caught' if caught else 'NOT CAUGHT':>10} {label}")
|
||||
@@ -1955,7 +2597,9 @@ check("...and so is the last one, which is not a defeat at all but the EDGE OF "
|
||||
"THIS CARD: the grid-following loop can still take the pack past this "
|
||||
"floor, because the floor bounds the peak guard's own forced discharge "
|
||||
"and a `lo` limit cannot cap a discharge. Saying so here is worth more "
|
||||
"than a green line that would be untrue", not MUT[12][1])
|
||||
"than a green line that would be untrue - and part 10 now says it with a "
|
||||
"number instead of a sentence, which is worth more again",
|
||||
not MUT[12][1] and EV["min_soc"] <= BMS_FLOOR + 0.05)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
|
||||
Reference in New Issue
Block a user