checks / unit (pull_request) Successful in 3m43s
The product owner answered SAFETY-25's §1 on 2026-09-02: OPTION A. The BMS cut-out is the DELIBERATE and only backstop for demand-driven discharge on the grid-following loop, and Option B - a loop-side reserve floor - is DEFERRED rather than rejected. Option A is the shipped behaviour, so this is a recording, not a change. `app/` is touched in exactly two places and both are comments that claimed the question was still open (freeze.py's finding, peakguard.py's point 2). No setpoint, no claim, no option, no default moved. The 17-suite run is green on the same numbers as before. WHY DEFERRING IS THE SAFE DIRECTION. Option B's proof - bms_soc_floor < loop_soc_floor <= peak_soc_floor, run through the real arbiter in test_peakguard.py part 10d - has an unstated premise: the reserve predicate and discharge_permitted() read the SAME state of charge. With two packs they read different numbers. The reserve binds on the emptier pack and files hi = 0; the peak guard, reading the other pack or an average, files lo = excess > 0; lo > hi is arbiter clause 3 and the answer is 0 W - the battery parked in the billed quarter, which is the failure the memo itself marks fatal. And no config validator can catch it: it is a runtime divergence between two readings, not a config ordering, so CFG-01's shape of check passes on the exact tree that parks the battery. Option A's cost is energy; Option B's cost built too early is the one quarter of the month that sets the bill. THE REVISIT TRIGGER IS ALLOC-01, recorded in §0c. Under ARCH-01's host-side allocator the two claims stop sharing a list: the loop floor becomes a per-inverter `hi` consumed as an INTERVAL at stage A, while the peak floor stays a site `lo` at stage B. Different claim lists, different stages, structurally unable to cross - a stronger argument than the ordering rule, because it makes the crossing unreachable rather than harmless. That is the shape in which Option B becomes safe to build. WHAT HAS TO BE RE-EARNED AT N=2 is stated plainly in §0d rather than left for someone to discover. SAFETY-27 now names five floors across two packs, so "the SoC" stops being a scalar the config can bound. And part 10f - the pin that made SAFETY-25 mergeable - CANNOT EXPRESS TWO PACKS: its dynamic half is a scalar 0-100 % SoC sweep against a single-series capture, and a two-pack exposure lives in the DIFFERENCE between two readings, which that sweep visits at no resolution. Its static half, the AST scan pinning the eight claim sources, still bites at N=2; the dynamic half goes silent. Merging a two-pack reserve on 10f's current green would be merging on a check that cannot see the failure. The options analysis is kept intact on purpose - §1-§7 stand exactly as written, including §5's "(recommended)" on Option B and §6's recommendation, with a new §0 saying what was decided and §6 marked as answered. A memo that recorded the verdict and deleted the reasoning is the failure this DECISIONS/ directory exists to prevent. Part 10e's check moved with the memo: it used to pin the honest "Status: OPEN" line and now pins that the memo names the option, the date and the trigger AND still carries both options and the recommendation. The tripwire itself stays - a ninth claim source in Controller.cycle() still takes 10f red on the line it is written, and "deferred" is precisely the state in which someone builds Option B by accident. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
418 lines
23 KiB
Markdown
418 lines
23 KiB
Markdown
# SAFETY-25 — does the grid-following loop get a reserve floor?
|
||
|
||
**Status: DECIDED 2026-09-02 — Option A.** The product owner has answered §1:
|
||
**the BMS cut-out stays the only backstop for demand-driven discharge on the
|
||
grid-following loop.** **Option B — a loop-side reserve floor — is DEFERRED**,
|
||
not rejected, with an explicit revisit trigger recorded in §0c: **`ALLOC-01`**.
|
||
|
||
Option A **is the shipped behaviour**, so recording this decision changed no
|
||
code. The honesty half of SAFETY-25 — `peak_soc_floor`'s scope made true in the
|
||
config, in `DOCS.md` and on the status page — had already shipped and was
|
||
required under every option. What was missing was the answer, and the answer is
|
||
now here rather than in a Trello card that scrolls away.
|
||
|
||
**Everything from §1 down is the memo exactly as it was written before the
|
||
decision, and is deliberately left intact.** It is the record of *why*, and a
|
||
future reader arriving at the revisit trigger needs the options analysis, the
|
||
costs, the measured replay and the trap in Option B's obvious shape — not a
|
||
one-line verdict. §5 still labels Option B *"(recommended)"* and §6 still says
|
||
"Option B is what I would build"; those were the engineering recommendation put
|
||
to the owner, they are preserved as written, and **§0 is what was decided.**
|
||
|
||
Filed with SAFETY-25. Card 6a94b2b98b73123d520e69f2. 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 SAFETY-25's own PR** and did not wait on this,
|
||
because it was required whichever way the answer went.
|
||
|
||
*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, and §0d says what N=2 does to it.*
|
||
|
||
---
|
||
|
||
## 0. The decision
|
||
|
||
### 0a. What was decided, and the owner's reasoning
|
||
|
||
**Option A. The BMS remains the only backstop for demand-driven discharge on the
|
||
grid-following loop. Option B is deferred until `ALLOC-01` exists.**
|
||
|
||
The reasoning is not "Option B is wrong" — §5 and §6 still stand as engineering.
|
||
It is that **deferring is the safe direction, and building now is not.**
|
||
|
||
Option B's safety proof is the ordering constraint in §5:
|
||
|
||
bms_soc_floor < loop_soc_floor <= peak_soc_floor
|
||
|
||
That proof has an unstated premise: **the reserve predicate and
|
||
`discharge_permitted()` must read the SAME state of charge.** At N=1 they do,
|
||
which is why the proof reads as airtight, and why part 10d can run it through
|
||
the real arbiter and watch the contradiction disappear.
|
||
|
||
**With two packs they read different numbers.** The reserve binds on whichever
|
||
pack is emptier and files `hi = 0`; the peak guard, reading the other pack or an
|
||
average of the two, is *not* floor-limited and files `lo = excess > 0`. Then
|
||
`lo > hi`, arbiter clause 3 fires, and the result is **0 W — the battery parked,
|
||
in the billed quarter.** That is precisely the failure §5 marks *"⚠️ fatal if
|
||
missed"*, arriving by a route the ordering rule cannot close, because
|
||
`loop_soc_floor <= peak_soc_floor` is still perfectly satisfied while it happens.
|
||
|
||
**And no config-ordering validator can catch it.** The rule §5 proposes — refuse
|
||
the bad ordering at startup, in CFG-01's manner — grades *configuration*. This is
|
||
a **runtime divergence between two readings**, not a config ordering. The startup
|
||
check would pass, every time, on the exact tree that parks the battery.
|
||
|
||
So the shape of the risk decides the direction: **Option A's cost is energy
|
||
(§5, "every watt below the reserve becomes billed import" — inverted: the pack
|
||
may sit on its own cut-out). Option B's cost, built too early, is a parked
|
||
battery in the one quarter of the month that sets the bill.** One of those is a
|
||
tariff line; the other is the failure this memo already calls fatal. Take the
|
||
one that cannot park the battery, and build Option B when its proof holds again.
|
||
|
||
### 0b. What this decision does NOT say
|
||
|
||
- It does **not** say a loop-side reserve is a bad idea. §5's protection
|
||
argument — keeping the pack off its own cut-out, which is the `MAINT-01` drift
|
||
condition — is unchanged and still wanted.
|
||
- It does **not** close §4's fifth-key question. `SAFETY-27` still owns the
|
||
naming of the floors, and any future `loop_soc_floor` still lands after it.
|
||
- It does **not** weaken §6 item 3: **the tripwire in `test_peakguard.py`
|
||
part 10 stays.** A deferred option needs its tripwire more than an open one
|
||
does, because "deferred" is exactly the state in which someone builds it by
|
||
accident. Part 10f still takes red on the line a ninth claim source is
|
||
written. Do not remove it.
|
||
|
||
### 0c. The revisit trigger: `ALLOC-01`
|
||
|
||
**`ALLOC-01` is the trigger, and it is a trigger because it changes the SHAPE of
|
||
the two claims, not because it changes anyone's mind.**
|
||
|
||
`ARCH-01` decided that the site grid target is **allocated host-side, above the
|
||
driver seam**: one integrator on P1, a sum-invariant split into per-inverter
|
||
shares, and each inverter told a function of **its own share** only. It also
|
||
decided that **per-inverter constraints are consumed by the allocator as
|
||
INTERVALS, never filed as site-level claims** — which is the structural fix for
|
||
the cross-inverter `lo > hi -> 0 W` contradiction.
|
||
|
||
Under the allocator, the two claims that collide in §0a stop sharing a claim
|
||
list at all:
|
||
|
||
| | today (N=1, no allocator) | under `ALLOC-01` |
|
||
|---|---|---|
|
||
| the loop reserve | a site `hi` at `P_LOOP` | a **per-inverter `hi`**, consumed at **stage A** as that unit's interval |
|
||
| the peak floor | a site `lo` at `P_PEAK` | still a **site `lo`**, filed at **stage B** against the site target |
|
||
|
||
**Different claim lists, evaluated at different stages, structurally unable to
|
||
cross.** The reserve bounds one inverter's share; the peak floor bounds the site
|
||
sum. There is no arbiter resolution in which one is the other's `lo` and `hi`,
|
||
so §0a's failure cannot be constructed — not "is guarded against", *cannot be
|
||
constructed*. That is the same class of argument as §5's ordering rule but a
|
||
stronger one: §5 makes the crossing harmless, `ALLOC-01` makes it unreachable.
|
||
|
||
**That is the shape in which Option B becomes safe to build.** When `ALLOC-01`
|
||
exists, reopen this memo at §5 Option B, restate the reserve as a stage-A
|
||
per-inverter interval, and the ordering rule becomes an implementation
|
||
convenience rather than the load-bearing proof.
|
||
|
||
### 0d. What has to be RE-EARNED at N=2, stated plainly
|
||
|
||
Two things this decision leans on do not survive a second pack, and saying so is
|
||
part of the decision rather than a footnote to it.
|
||
|
||
**1. `SAFETY-27` now names FIVE floors across TWO packs.** §4's table already
|
||
carries four keys plus "the loop"; two of them ship the same default of 20 from
|
||
different cards. At N=2 each of those is a floor *per pack*, and "the SoC" stops
|
||
being a scalar the config can bound — which is the same divergence that makes
|
||
§0a's contradiction possible. `SAFETY-27` inherits a harder question than the
|
||
one it was filed with.
|
||
|
||
**2. `test_peakguard.py` part 10f — the guarantee that made SAFETY-25 mergeable —
|
||
CANNOT EXPRESS TWO PACKS, and will not cover the two-pack case.** Say it flatly,
|
||
because it is the load-bearing pin (§6 item 3: PR #31 shipped this section
|
||
claiming a tripwire it did not have, and the reviewer disproved the claim by
|
||
building Option B under a green suite):
|
||
|
||
- 10f's dynamic half is a **scalar 0–100 % SoC sweep** — one number swept over
|
||
one range, in the idle and floor-limited states — against a **single-series
|
||
capture** (§7: the fixtures carry signed net power at the meter and nothing
|
||
else; the pack is modelled, and one pack is modelled).
|
||
- A two-pack exposure lives in the **difference** between two SoC readings. A
|
||
sweep over one scalar visits no point of that space, at any resolution. It
|
||
cannot fail on the divergence because it cannot represent it.
|
||
- 10f's **static** half — the AST scan pinning the eight claim sources in
|
||
`Controller.cycle()` — survives, and is the half that keeps working: a ninth
|
||
source still goes red on the line it is written, at N=2 as at N=1. It is the
|
||
*dynamic* half, the one that proves the named sources actually reach the
|
||
arbiter across the whole state space, that goes silent.
|
||
|
||
So: **10f remains a valid tripwire against building Option B today, and is not a
|
||
guarantee about N=2.** Whoever reaches the `ALLOC-01` revisit inherits the job of
|
||
re-earning it — a two-pack fixture with two SoC series, or a claim-shape argument
|
||
at stage A that does not need one. Merging a two-pack reserve on 10f's current
|
||
green is merging on a check that cannot see the failure.
|
||
|
||
---
|
||
|
||
## 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
|
||
|
||
⚠️ **This section is the recommendation that was PUT to the owner, kept as
|
||
written. It was answered on 2026-09-02 — see §0. The owner took Option A and
|
||
deferred Option B to `ALLOC-01`; item 3 below (keep the part 10 tripwire)
|
||
was taken as written and still stands.**
|
||
|
||
**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.
|