SAFETY-45: a price-driven charge is sized by the capacity guard, not switched by it #88

Merged
admin merged 1 commits from SAFETY-45 into release/1.0 2026-09-14 16:20:40 +02:00
9 changed files with 1178 additions and 24 deletions
+75
View File
@@ -27,6 +27,7 @@ if it has, it needs 0.10.0. Whoever deploys decides.
| _this branch_ | TARIFF-17 - a price component that varies by hour has somewhere to live, so a Walloon day/night network tariff can change which hour is cheapest |
| _this branch_ | TARIFF-18 - `price_taxes_eur_kwh` stops shipping at half the real Flemish figure **⚠️ CHANGED DEFAULT** |
| _this branch_ | TARIFF-16 - the injection leg stops being one number: an injection formula with its own coefficients, its own VAT and no floor under zero |
| _this branch_ | SAFETY-45 - a price-driven charge is SIZED by the capacity-tariff guard instead of being switched on and off by it **⚠️ CONTROL STABILITY** |
⚠️ **A CONFIG THAT STARTS ON THE CURRENT VERSION CAN BE REFUSED ON THE NEXT
ONE.** TARIFF-07 withdraws `engie_dynamic`, `totalenergies_dynamic` and
@@ -62,6 +63,80 @@ separate card. *(Annotated 2026-09-13: that card is **TARIFF-15**, below, and it
shipped on this branch as `custom_formula`. It is still not an alias — the
operator types their own coefficients; nothing is invented on their behalf.)*
### SAFETY-45 - the trade is sized by the capacity guard, not switched by it
**A price-driven charge now asks the peak guard HOW HARD it may pull, instead of
only whether it may pull at all.** On any site where `load + max_w` reaches
`peak_cap_w` - at the shipped `peak_cap_w: 3500` and `max_w: 2000`, any house
drawing over 1500 W in the cheapest quarter - arbitrage and the capacity-tariff
guard were driving each other in a sustained limit cycle. Measured on TEST-23
stage 3's closed rig at `peak_cap_w: 1500` against a 1200 W house: **28 charge
onsets in one hour, mean period 131 s, the grid swinging −456 to +3200 W**, and
every billed quarter in the three affected configurations finishing **over** the
cap it was oscillating about. §10.2: *"an EMS that oscillates is worse than no
EMS, both for hardware wear and - per §9.1 - for the peak it is trying to
avoid."*
**What was happening.** The guard computes `wanted = batt_w + excess` - the
battery discharge that would land the quarter exactly on the ceiling - and files
a claim only while that number is positive. While a trade charges at `max_w`
the number is negative for the first minute or so of every cycle, so the guard
files **nothing** and the trade is not constrained at all. `wanted` then crosses
zero, the guard engages on a floor of a few watts, and that flips
`arbitrage.guard_state()` - which cancels **the whole trade**. The
grid-following loop takes the actuator, drives the meter to zero and past it,
the projection collapses, the guard releases, and the trade comes back at
`max_w`. 115 s later, again.
**The fix, and it is a shape this add-on already had.**
`control.maintenance_charge_floor()` has always capped a MAINTENANCE charge at
"the headroom left under the site's cap", because *"a maintenance charge is the
only thing this system does that is big enough and long enough to set that
peak"*. ⚠️ That stopped being true when OPT-04 landed: a trade commands `max_w`
for a whole tariff interval, deliberately, against the meter.
`peakguard.charge_headroom_w()` publishes the same quantity off the **measured**
guard rather than off the optional `peak_forecast_entity`, and
`arbitrage.decide()` sizes the buy leg to it.
**No new arithmetic and no new constant.** The number is `wanted_w` read in the
other direction: a negative `wanted` is not "nothing to say", it is a permitted
charge of `−wanted`. `peakguard.decide()` is **unchanged** - byte-identical to
`release/1.0`, `sha256 = 200090d0835a3a55` - so 2026-08-20's anti-bang-bang rung
is exactly where it was, and `tools/replay_ac3.py` reproduces the same 190/190
guarded quarters, 110 engaged cycles and 878 Wh on this branch as on the tip.
**After, on the same rig, same command:**
| `peak_cap_w` / house | onsets per hour | grid swing | energy bought | billed quarters |
|---|---|---|---|---|
| 1500 / 1200 W | 28 → **1** | 3656 → **282 W** | 180 → **285 Wh** | 1490/1497/**1509** → **1500/1500/1500** |
| 3500 / 1800 W | 8 → **1** | 3673 → 1688 W | 1653 → 1616 Wh | **3511/3536/3559** → **3500/3500/3500** |
| 3500 / 3000 W | 32 → **1** | 3798 → **475 W** | 483 → 475 Wh | **3519/3542/3557** → **3500/3500/3500** |
| 5000 / 1200 W | 1 → 1 | *identical* | *identical* | *identical* |
One onset means the trade starts once and runs for the whole window - the
battery is now trading 100 % of the cheap hour where it traded 43 %, 92 % and
54 %. **Every billed quarter now lands on the cap and none over it.** At the
aggressive cap the site buys 58 % MORE energy, because a continuous 300 W trade
beats a 2000 W trade that is cancelled for well over half the hour; at the two
shipped-default rows it buys about 2 % less and the worst quarter drops from
59 W over the cap to zero. The last row is the negative control - `load + max_w`
never reaches the cap there, and the two runs are bit-identical. **This costs
nothing on the installs it is not about.**
⚠️ **NOT A CONFIG CHANGE.** No option is added, removed or re-defaulted, and
arbitrage remains off in the shipped configuration. A site that has not armed
`arbitrage_enabled` is unaffected in every respect.
⚠️ **WHAT IS NOT FIXED, AND IT IS NAMED RATHER THAN IMPLIED.** TEST-23 finding 1
- the export overshoot at the END of any trade, 356-938 W, about 0.37 × `max_w`
- is untouched and is a different card. A house whose load ALONE exceeds
`peak_cap_w` still has the guard engage and the trade refused outright, which is
correct, and the grid-following loop then drives to grid = 0 rather than to the
ceiling. See `DECISIONS/SAFETY-45-sizing-the-trade.md` §5, and §2 there for the
negative-`lo` design that was rejected because it creates a new `lo > hi`
contradiction against the maintenance charge shaping.
### TARIFF-16 - the injection leg stops being one number
**`price_export_formula`: what a site is PAID for export, as `factor × index +
@@ -0,0 +1,221 @@
# SAFETY-45 — the trade is SIZED by the capacity guard, not switched by it
**Status: closed.** `peakguard.charge_headroom_w()` publishes the watts the open
billed quarter can still afford; `arbitrage.decide()` takes it as a required
keyword-only argument and sizes the BUY leg to it; `main.py` passes the measured
guard's own number at its one call site. `peakguard.decide()` is **not changed** —
byte-identical to `release/1.0` at `478ba93`, `sha256(decide) = 200090d0835a3a55`,
7252 B on both trees.
⚠️ **THE HOUSE LOAD IN EVERY FIGURE BELOW IS SYNTHETIC.** TEST-23 stage 3's
`priceloop.py` drives a flat modelled house against a modelled pack
(`plantsim.Plant`, 800 W/s ramp into a 10 s lag, 15 360 Wh). The PRICES are real,
the decision code is the shipped code, the billed-quarter accumulator is
`app/p1.py`'s own — the load is not. Nothing here is a measurement of the live
site at 192.168.2.158, which this work did not touch.
## 0. The defect, measured
TEST-23 stage 3 found a sustained limit cycle with arbitrage armed and a charge
decision active. Re-measured here on the same rig against the same checkout,
metrics recomputed rather than copied — a **charge onset** is a cycle at which
`arbitrage.decide()` transitions into `ARBITRAGE_CHARGE`, which is the edge the
rig already logs:
| `peak_cap_w` | house | onsets/h | mean period | grid swing | billed quarters (W) |
|---|---|---|---|---|---|
| 1500 | 1200 W | **28** | 131.1 s | **−455.5 … +3200.0 W** | 1490.1 / 1496.5 / **1508.8** |
| 3500 *(shipped)* | 1800 W | 8 | 509.3 s | +127.5 … +3800.0 W | **3511.3 / 3536.0 / 3559.1** |
| 3500 *(shipped)* | 3000 W | 32 | 114.8 s | +1202.0 … +5000.0 W | **3518.9 / 3542.2 / 3556.7** |
| 5000 | 1200 W | 1 | — | +1240.6 … +3200.0 W | 3169.5 / 3200.0 / 3200.0 |
The last row is the **negative control**: `load + max_w = 3200 W` never reaches
the cap, so there is no cycle to remove. The bite rule is `load + max_w >
peak_cap_w`, which at the shipped `peak_cap_w: 3500` / `max_w: 2000` is any house
drawing over 1500 W in the cheapest quarter.
§10.1's ordering did win on the money — the guard kept the quarters within tens
of watts of the cap — but **every billed quarter in the three biting rows
finished OVER it**, which is the thing §9.1 bills. §10.2: *"an EMS that
oscillates is worse than no EMS, both for hardware wear and — per §9.1 — for the
peak it is trying to avoid."*
## 1. ⚠️ The mechanism is NOT mainly the quarter boundary, and the card says it is
The card, and `.cache/s45.md` before it, describe the loop as *"at each quarter
boundary the projection resets to 0, the guard disengages, and arbitrage re-arms
at full power."* **That is not what the 28 onsets are.** Their timestamps, off
the run above:
1, 231, 351, 471, 581, 696, 806, 896, 1136, 1256, 1371, 1486, 1596,
1711, 1811, 2061, 2176, 2296, 2411, 2521, 2626, 2716, 2971, 3091,
3206, 3321, 3431, 3541 (s, quarter boundaries at 900/1800/2700)
**23 of the 27 gaps are 90–120 s, mean 111.5 s, entirely inside one quarter.**
The four long gaps — 230, 240, 250, 255 s — are precisely the four that SPAN a
boundary, so the boundary **lengthens** the interval rather than causing an
onset. Only three onsets land within 15 s of a boundary and one of those is
`t = 1`, the start of the run.
The real cycle, read off the recorded fields at t = 1000…1180 s:
1. the trade charges at `−2000 W`; the meter sits at 3200 W against a 1500 W
ceiling; `wanted = batt_w + excess` is **negative** (−132 → −8 W), so
`decide()` files **nothing** and arbitrage is not constrained at all;
2. `wanted` drifts up as the quarter's own history worsens and the `900 /
remaining` gain grows, and crosses zero at t = 1055 with **`floor_w = 5.3 W`**;
3. that 5 W floor flips `arbitrage.guard_state()` to `True`, which refuses the
**whole trade** — so the setpoint goes from −2000 W to +5 W and the
grid-following loop, which targets grid = 0 and not grid = ceiling, takes the
actuator and discharges to **+1870 W**, exporting 420 W;
4. the projection collapses, `wanted` goes negative, the guard releases, and
arbitrage re-arms at **`max_w`**. 115 s later, again.
So the defect is **a binary interlock keyed on a continuous quantity**, and the
quarter boundary is one of the ways it is crossed rather than the cause. The
card's other claim — *"projection 3092 W against a 1509 W ceiling, `engaged=False`,
for 170 consecutive seconds"* — reproduces, but it is the **first** such window
(`first_engaged_t_s = 151.0`); the steady-state silent window is ~40–65 s.
None of this changes what the fix has to do. It is recorded because a card that
says "the boundary is the trigger" invites a fix in the boundary handling, and
such a fix would have removed four of twenty-eight onsets.
## 2. What was rejected, and why
### 2a. Reverting `decide()`'s `wanted <= 0.0` rung — rejected, and it is a trap
That rung is 2026-08-20's anti-bang-bang. Its own comment records the cost of not
having it: 17 engage/release cycles in 900 s and a quarter finishing 49 W over
the limit it had spent a quarter of an hour holding. It is untouched, pinned on
the AST in `test_peakguard.py` 13g, and `tools/replay_ac3.py` reproduces the same
190/190, 110 engaged cycles, 5 blind, 878 Wh on this branch as on `478ba93`.
### 2b. Filing the affordable charge as a NEGATIVE `lo` — rejected
Arithmetically this is the prettiest answer: `lo = wanted` with no sign gate is
continuous, is exactly the control law `decide()` already computes, and would
have put the constraint on the arbiter where it binds every claimant rather than
only arbitrage. It was rejected on two grounds, in this order:
1. ⚠️ **IT CREATES A NEW `lo > hi` CONTRADICTION CLASS AGAINST THE MAINTENANCE
SHAPING.** `main.py`'s cheap-window maintenance charge files
`Claim.limit("maintenance", P_MAINT_SHAPE, hi=-floor)` — a NEGATIVE `hi`.
`arbiter.py` clause 2 applies every limit at or above the winner's priority
and takes the most restrictive, with **no notion of one limit out-ranking
another**; a peak `lo = −300 W` against a shaping `hi = −1000 W` is `lo > hi`,
which resolves to **0 W — the battery parked, in the one quarter of the month
that sets the bill**. That is the exact failure `peakguard.shaping_suspended()`
exists to prevent, and the only way to close it would be to key that
suppression on "a claim exists" — which, on every site this card is about,
means the maintenance charge-only shaping is suspended **permanently**. A
silent, large change to a different subsystem is not a price this card should
pay.
2. It changes the meaning of `PeakDecision.engaged` at five call sites outside
this module — `arbitrage.guard_state()`, `shaping_suspended()`,
`claim()`, `main.py`'s edge-logged action line and `main.py`'s status row —
each of which today means "the guard is HOLDING" and would silently start
meaning "a claim exists".
`test_peakguard.py` part 5's sweep asserts **no negative floor, ever**, and the
reason it gives ("a `lo` limit is incapable of capping discharge or export only
while it is non-negative") is, strictly, not the true reason — a `lo` of any sign
caps neither. The invariant is still right and is still worth holding; §2b above
is the argument that actually supports it, and it is recorded here rather than
left for the next person to re-derive.
### 2c. A backoff, cooldown or hysteresis constant in `arbitrage.py` — rejected
The card asks whether the second half of the fix belongs in arbitrage: *"a trade
refused at 14:59 and re-armed at 15:00 has learned nothing from the refusal."*
It does belong there, but as a **size**, not as a memory. `arbitrage.py`'s
hysteresis note is explicit that the switching decision is "which hour is it" and
that **no timer is needed and none is invented**; `peakguard.py`'s design note
spends a paragraph on why it carries no hysteresis constant. A cooldown would be
a second control loop compensating for a limit the first one declined to file,
and its constant would outlive whatever it was tuned against. The trade now
re-arms at `ceiling − load` instead of at `max_w`, which is what "learning from
the refusal" means when the refusal is a measurement.
## 3. What was done, and the precedent it follows
`control.maintenance_charge_floor()` has capped MAINTENANCE charging at *"the
headroom left under the site's cap"* since the loop was written, and its
docstring says why:
> "A maintenance charge is the only thing this system does that is big enough and
> long enough to set that peak, so it is capped by the headroom left under the
> site's cap."
⚠️ **THAT SENTENCE STOPPED BEING TRUE WHEN OPT-04 LANDED.** Price arbitrage is a
second thing that is big enough and long enough — it commands `max_w` for a whole
tariff interval, deliberately, against the meter — and it was given no such cap.
This card gives it the same shape, with one difference that matters:
`maintenance_charge_floor()` sizes itself from `peak_forecast_entity`, which is
OPTIONAL and empty on a default install — the exact hole SAFETY-25 had to close
in `shaping_suspended()`. `charge_headroom_w()` comes off `decide()`, which runs
wherever P1 does.
**The number is `wanted_w` read in the other direction.** `decide()` already
computes `wanted = batt_w + excess` as the DISCHARGE that lands the quarter on the
ceiling; a negative `wanted` is a permitted CHARGE of `−wanted`. No new
arithmetic, no new constant.
Two properties make it safe to hand a strategy, and both are swept rather than
argued (`test_peakguard.py` part 13):
* ⚠️ **IT IS INVARIANT IN THE BATTERY.** Raise the charge by dW and the meter
rises by dW, so `excess` rises by exactly dW while `batt_w` falls by dW:
`wanted` does not move. Measured across `batt_w` from −5000 W to +2000 W on one
banked quarter — the headroom is 1350.0 W at every one of the six points while
the `excess` those runs see moves through six distinct values. It is a
**feed-forward** number, so consuming it adds no feedback path around the plant.
The fixed point is `charge = ceiling − load`, with the meter ON the ceiling and
`wanted` parked a whole `ceiling − load` clear of the engagement boundary.
* **HOLDING IMPLIES ZERO OR UNKNOWN.** Across 1080 swept combinations, not one
ENGAGED decision answers a positive headroom, so the function can never hand a
caller permission the guard's own `lo` is at that instant refusing.
## 4. After, on the same rig, same command, same checkout
| `peak_cap_w` | house | onsets/h | grid swing | energy bought | billed quarters (W) |
|---|---|---|---|---|---|
| 1500 | 1200 W | 28 → **1** | 3655.5 → **282.0 W** | 180.0 → **285.2 Wh** | **1500.0 / 1500.0 / 1500.0** |
| 3500 | 1800 W | 8 → **1** | 3672.5 → 1687.6 W | 1652.9 → 1616.1 Wh | **3500.0 / 3500.0 / 3500.0** |
| 3500 | 3000 W | 32 → **1** | 3798.0 → **474.7 W** | 482.9 → 475.3 Wh | **3500.0 / 3500.0 / 3500.0** |
| 5000 | 1200 W | 1 → 1 | **identical** | **identical** | **identical** |
* **One onset** means the trade starts once and runs for the whole window. The
battery is trading 100 % of the hour where it used to trade 43 %, 92 % and 54 %.
* **Every billed quarter lands exactly on the cap and none over it**, where every
quarter in the three biting rows used to finish over.
* **The money.** At 1500/1200 the site buys **58 % MORE** energy, because a
continuous 300 W trade beats a 2000 W trade that is cancelled for 57 % of the
hour. At the two shipped-default rows it buys **2.2 % and 1.6 % less** — and in
exchange the worst quarter drops from 59 W over the cap to 0 W over it. The
capacity tariff bills the month's single worst quarter with no averaging and no
forgiveness (specs.md:583), so that is not a trade-off, it is the ordering
§10.1 asks for.
* The remaining 1687.6 W at 3500/1800 is the opening **ramp**, not a cycle: the
pack goes from 0 to 1700 W of charge in about ten seconds and the meter with
it. One onset, no export, no reversal.
* The last row is bit-identical before and after. **This card costs nothing on
the installs it is not about.**
## 5. What this does NOT fix, with sizes
* **TEST-23 finding 1 — the export overshoot at the END of a trade** (356–938 W,
≈0.37 × `max_w`) is untouched and is a different card. It happens when the
cheap interval ends and the grid-following loop takes back an actuator that was
holding a charge; nothing here changes the handover.
* **A house whose load ALONE exceeds the cap** (`load > peak_cap_w`) still puts
`wanted > 0`, still engages the guard, and still refuses the trade outright
through the interlock — correctly, since you cannot buy in a quarter you are
already over on. The loop then drives to grid = 0 rather than to grid =
ceiling, which is more discharge than the guard asked for. Not swept here: all
four (cap, load) pairs on the record have `load < cap`.
* **`arbitrage.claim()` still files the SIZED number, but the ARBITER can clamp
it further** and the strategy is not told. The reason line names the guard's
headroom, not the arbiter's `bound_by`. Pre-existing; not widened.
* **The container leg was not re-run.** `hadrive.py` needs `test23s3-addon`
started, and that container is deliberately stopped because it is a second
writer on the setpoint entity under measurement. The in-process rig's twelve
transcribed lines are pinned by `test_arbitrage.py` 3b's AST check on
`main.py`'s call site, which is the line this card adds to them.
+14
View File
@@ -979,6 +979,7 @@ What exists today, and what does not:
|---|---|
| `max_w` bounds the magnitude | ✅ — but the exposure IS the magnitude |
| Maintenance charging capped under `peak_cap_w` | ✅ |
| **Arbitrage charging capped under `peak_cap_w`** | ✅ — SAFETY-45, sized to the guard's own MEASURED headroom rather than to `peak_forecast_entity` |
| **Loop capped under `peak_cap_w`** | ✅ — SAFETY-07, on every `meter_source` but `off` |
| Frozen meter detected | ✅ on `homewizard_local` only, **alarm only** |
| Frozen meter *stopped* | ✅ — SAFETY-07 forbids importing while the meter is frozen |
@@ -1041,6 +1042,19 @@ projected over — raises one notification (`goodwe_peak_saturated`), because th
is the one state where the money will be spent and shedding load is the only
thing left that changes the bill.
**And it sizes a price-driven charge, not only a forced discharge (SAFETY-45).**
The same arithmetic read the other way answers *"how hard may this site charge
without putting the quarter over"* — `peakguard.charge_headroom_w()` — and
`arbitrage.decide()` sizes its buy leg to it. Before that, a trade commanded
`max_w` until the projection rose far enough to engage the guard, at which point
the interlock cancelled the trade outright, the loop took the actuator, the
projection collapsed and the trade came back at `max_w`: **28 charge onsets in
one hour** on TEST-23 stage 3's closed rig, with the grid swinging −456 to
+3200 W and every billed quarter finishing over the cap. It is one onset now,
the trade runs continuously at the affordable rate, and the billed quarters land
**on** the cap. ⚠️ The guard is what changed *around* arbitrage; `decide()`
itself is untouched, so nothing above this line is different.
**No new option for the CEILING.** It is `peak_cap_w`, which already shipped. The
coverage threshold is `meter_max_age_s`, the freeze predicate is
`meter_freeze_s`, and the achievable discharge comes from `max_w` and the device
+62 -6
View File
@@ -184,6 +184,17 @@ IDLE_OFF_PEAK_HOUR = "this hour is neither the buy hour nor the sell hour"
IDLE_NO_SIZE = (
"no pack's state of charge permits this side of the trade, so it is sized "
"at 0 W - and a 0 W arbitrage claim is a park order, not a trade")
# SAFETY-45. THE BUY LEG'S SIZE, AND ONLY THE BUY LEG'S - §9.1: "Discharge and
# export are never capped by the peak guard; only charging and import are."
IDLE_NO_HEADROOM = (
"the capacity-tariff guard has measured this billed quarter and it can "
"afford no charging at all, so a trade of any size would push it over the "
"ceiling - and a 0 W arbitrage claim is a park order, not a trade")
IDLE_HEADROOM_UNMEASURED = (
"the capacity-tariff guard did not size this quarter's charging headroom "
"this cycle, and an UNMEASURED headroom is not an unlimited one - charging "
"creates the billed quantity (§9.1) and nothing is measuring the quarter "
"this one would land in")
NEGATIVE_PRICE = (
"§17: the sell hour is priced BELOW ZERO, so discharging into it would give "
"energy away and pay for the privilege. Arbitrage must not export at a "
@@ -303,7 +314,7 @@ def _point_at(plan, when: datetime):
return None
def decide(plan, now: datetime, *, peak_guard_active, soc,
def decide(plan, now: datetime, *, peak_guard_active, charge_headroom_w, soc,
min_spread_eur_kwh, uncertainty, charge_w, discharge_w,
soc_floor_pct, soc_ceiling_pct) -> ArbitrageDecision:
"""Should the battery trade this cycle, and how hard.
@@ -314,6 +325,18 @@ def decide(plan, now: datetime, *, peak_guard_active, soc,
None refuses, because the alternative is a strategy that creates the billed
quantity while the thing that would have limited it is switched off.
⚠️ `charge_headroom_w` IS SAFETY-45's SECOND, CONTINUOUS ANSWER FROM THE
SAME GUARD, AND IT IS NOT A DUPLICATE OF THE FIRST. `peak_guard_active` is a
SWITCH - it says whether the guard is holding - and a switch keyed on a
continuous quantity is what made the loop oscillate: the guard engaged at a
5 W floor, this module refused the whole trade, the loop took the actuator,
the meter fell, the guard released and the trade re-armed AT FULL POWER
130 s later. 28 times an hour, measured. `peakguard.charge_headroom_w()` is
the watts the billed quarter can still afford; sizing the buy leg to it means
the trade is CLAMPED rather than switched, which is the same shape
`control.maintenance_charge_floor()` has always given a maintenance charge.
⚠️ None IS NOT UNLIMITED here either - see IDLE_HEADROOM_UNMEASURED.
⚠️ EVERY OTHER ARGUMENT IS KEYWORD-ONLY AND HAS NO DEFAULT, for
`TariffPlan.arbitrage()`'s reason. A defaulted `soc_floor_pct` is a reserve
floor this module asserted rather than one the operator configured, and the
@@ -330,10 +353,12 @@ def decide(plan, now: datetime, *, peak_guard_active, soc,
return verdict.match(
refused=lambda v: _idle(v.reason),
allowed=lambda v: _act(plan, now, v, soc, charge_w, discharge_w,
soc_floor_pct, soc_ceiling_pct))
soc_floor_pct, soc_ceiling_pct,
charge_headroom_w))
def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct):
def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct,
headroom_w):
"""The permitted case: which hour is this, and may the pack do it."""
here = _point_at(plan, now)
if here is None:
@@ -403,13 +428,44 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct):
f"nowhere to put the cheap energy", **common)
if not abs(float(charge_w)) > 0.0:
return _idle(IDLE_NO_SIZE, **common)
# SAFETY-45. THE BILLED QUARTER IS ASKED LAST, AND THE PLACEMENT IS
# DELIBERATE: every refusal that existed before this card keeps the
# precedence it had, so no sentence an operator already reads changes when
# it fires, and the two added here are placed where they cannot displace
# one. The pack's reasons come first because they are properties of the
# hardware in the cupboard; this one is a property of the fifteen minutes
# the trade would land in.
if headroom_w is None:
# ⚠️ NOT "the guard is clear". This is `guard_state()`'s None argument
# one layer down: a headroom nothing measured is not an unlimited one,
# and charging at max_w creates the billed quantity (§9.1).
return _idle(IDLE_HEADROOM_UNMEASURED, **common)
asked_w = abs(float(charge_w))
size_w = min(asked_w, max(0.0, float(headroom_w)))
if not size_w > 0.0:
# ⚠️ NOT IDLE_NO_SIZE, WHICH IS THE LINE ABOVE AND BLAMES THE PACKS.
# `asked_w > 0` is already established here, so the pack may be empty
# and willing and it is the billed quarter that has run out. A refusal
# naming the wrong subsystem sends an operator to the wrong page
# (§10.2), and these two are a whole quarter-hour apart in what they
# ask a human to do about them.
return _idle(IDLE_NO_HEADROOM, **common)
# ⚠️ THE SENTENCE CARRIES BOTH NUMBERS IN THEIR OWN SLOTS, not one figure
# twice. MUTATION.md habit 14: two values in one sentence can be transposed
# by a ZERO-BYTE edit that no byte delta and no shape pin can see, so the
# trade's own size and the guard's headroom are each named by the literal
# text around them.
capped = size_w < asked_w
return ArbitrageDecision(
action=CHARGE, power_w=-abs(float(charge_w)),
action=CHARGE, power_w=-size_w,
reason=(f"{CHARGE}: this is the cheapest firm hour at "
f"{price:.4f} EUR/kWh, {v.spread_eur_kwh:.4f} below the dearest "
f"and clear of the {v.required_spread_eur_kwh:.4f} this cycle "
f"had to cover; charging {abs(float(charge_w)):.0f} W from "
f"{soc:g} %"),
f"had to cover; charging {size_w:.0f} W from {soc:g} %"
+ (f" - SIZED BY THE CAPACITY-TARIFF GUARD: the trade asked for "
f"{asked_w:.0f} W and this billed quarter can afford "
f"{float(headroom_w):.0f} W (§9.1, §10.1 priority 2)"
if capped else "")),
spread_eur_kwh=v.spread_eur_kwh,
required_eur_kwh=v.required_spread_eur_kwh,
price_now_eur_kwh=price)
+16
View File
@@ -2726,6 +2726,22 @@ class Controller:
self.arb = arbitrage.decide(
self.fresh_tariff_plan(now), now,
peak_guard_active=arbitrage.guard_state(self.peak_guard),
# SAFETY-45. THE SAME GUARD'S SECOND ANSWER, AND THE CONTINUOUS
# ONE. `guard_state()` is a switch and it was the whole loop:
# the guard engaged on a 5 W floor, the trade was refused
# outright, the loop took the actuator, the meter fell, the
# guard released and the trade came back AT max_w. Measured on
# TEST-23 stage 3 at the shipped peak_cap_w/max_w: 28 charge
# onsets an hour, grid -456 <-> +3200 W. This is the watts the
# billed quarter can still afford, so the buy leg is CLAMPED
# instead - the shape control.maintenance_charge_floor() has
# always given a maintenance charge, off the MEASURED guard
# rather than off the optional peak_forecast_entity.
# ⚠️ SAME ORDERING ARGUMENT AS THE LINE ABOVE: this reads the
# peak block's conclusion, ~50 lines up, so it must stay after
# it. Evaluated earlier it would size this quarter's trade from
# the previous cycle's quarter.
charge_headroom_w=peakguard.charge_headroom_w(self.peak_guard),
# The emptiest pack again, and for the sell side that is the
# right reduction. ⚠️ THE BUY SIDE WANTS THE FULLEST, and there
# is one `soc` argument - `pack_floor_soc` names that residual
+139
View File
@@ -822,6 +822,145 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
f"only give {ceil_top:.0f} W" if saturated else "")))
def charge_headroom_w(decision) -> float | None:
"""SAFETY-45. The hardest this SITE may charge without putting the billed
quarter over the ceiling, in W, positive - or None when nothing measured it.
THE ONE PLACE THIS QUESTION IS ANSWERED, and it is public for the reason
`discharge_permitted()`, `shaping_suspended()`, `guard_state()` and
`looks_frozen()` are: the check that grades it has to assert on THE SHIPPED
EXPRESSION, and a predicate transcribed into its own test is one copy plus a
future disagreement about what the check means.
------------------------------------------------------------------------
THE DEFECT IT PREVENTS, AND IT IS NOT A NEW IDEA IN THIS REPOSITORY
`control.maintenance_charge_floor()` already caps MAINTENANCE charging at
"the headroom left under the site's cap", and its docstring says why in
words this card only has to widen:
"A maintenance charge is the only thing this system does that is big
enough and long enough to set that peak, so it is capped by the
headroom left under the site's cap."
⚠️ THAT SENTENCE STOPPED BEING TRUE WHEN OPT-04 LANDED. Price arbitrage is a
SECOND thing that is big enough and long enough - it commands `max_w` for a
whole tariff interval, deliberately, against the meter - and it was given no
such cap. Measured on TEST-23 stage 3's closed rig at the shipped
`peak_cap_w: 3500` / `max_w: 2000`: 28 charge onsets in one hour, mean period
131 s, grid swinging -456 <-> +3200 W, on any house drawing over 1500 W in
the cheapest quarter. §10.2: "an EMS that oscillates is worse than no EMS,
both for hardware wear and - per §9.1 - for the peak it is trying to avoid."
⚠️ AND IT IS THE **MEASURED** GUARD, NOT `peak_forecast_entity`.
`maintenance_charge_floor()` sizes itself from the forecast sensor, which is
OPTIONAL and empty on a default install - the exact hole SAFETY-25 had to
close in `shaping_suspended()`, where a suspension gated on the forecast
alone could not fire on the very site that needed it. This number comes off
`decide()`, which runs wherever P1 does.
------------------------------------------------------------------------
WHERE THE NUMBER COMES FROM: IT IS `wanted_w`, READ IN THE OTHER DIRECTION
`decide()` already computes
wanted = batt_w + excess = (the offtake there would be with the battery
at zero) - (the level this quarter can still afford)
as the battery DISCHARGE that would land the quarter exactly on the ceiling.
A negative `wanted` is not "nothing to say" - it is a permitted CHARGE of
`-wanted` watts, and throwing it away is SAFETY-45's whole defect. So this
function invents no arithmetic; it reads the sign the guard already had.
⚠️ AND THE QUANTITY IS INVARIANT IN THE BATTERY, WHICH IS WHY SIZING A TRADE
TO IT CANNOT OSCILLATE. Raise the charge by dW and the meter rises by dW, so
`excess` rises by exactly dW while `batt_w` falls by dW: `wanted` does not
move. It is a FEED-FORWARD number - a function of the house, the ceiling and
the seconds already banked - so consuming it adds no feedback path around the
plant. The fixed point is `charge = ceiling - load`, with the meter sitting
ON the ceiling and `wanted` parked at `load - ceiling`, a whole `ceiling -
load` clear of the engagement boundary rather than chattering across it.
⚠️ AND A QUARTER BOUNDARY DOES NOT HAND IT BACK AT FULL POWER, WHICH IS THE
OTHER HALF OF SAFETY-45 AND IS NOT THE SAME CLAIM AS "IT DOES NOT CHANGE".
It DOES change - it is `(900 / remaining) * (ceiling - projected)`, so it is
largest in the last seconds of a block and collapses at the boundary. What
matters is WHAT IT COLLAPSES TO: at `elapsed = 0` the projection IS the held
level, so the answer is `ceiling - (load - pv)` - the house and the cap, and
nothing else. A trade refused at 14:59 therefore resumes at 15:00 at the rate
the new quarter can afford, not at `max_w`, and §10.1's answer survives the
boundary because the arithmetic does: no timer, no backoff constant, no state
carried between calls.
⚠️ AND THE AMPLIFICATION IS BOUNDED IN ITS EFFECT, NOT MERELY LARGE. Hold a
charge of exactly this size for the rest of the block and the block's average
lands on `ceiling` EXACTLY, wherever in the block it started:
(acc + (held + headroom) * remaining) / 900
= projected + (900/remaining) * (ceiling - projected) * remaining/900
= ceiling
so 162 kW of headroom with 5 s left and 900 W of it with the whole quarter to
go are the same statement about the bill. `test_peakguard.py` part 13 sweeps
that identity across the block rather than arguing it here.
------------------------------------------------------------------------
THE THREE ANSWERS
`None` nothing measured it: the guard never ran (`meter_source: off`), or
it ran with no battery reading to size a floor from, or the block
had no seconds left to influence. ⚠️ None IS NOT UNLIMITED. A caller
that treats it as "charge away" is `arbitrage.guard_state()`'s
collapse of None into False, one layer down - a strategy creating
the billed quantity while nothing measures the quarter it lands in.
`0.0` measured, and the answer is no charging: the guard is BLIND (its
claim already says so at `floor 0 W` and this agrees with it rather
than restating it), or it is holding and `wanted >= 0`.
`> 0` the watts a charge may pull this cycle.
⚠️ HOLDING IMPLIES ZERO, BY CONSTRUCTION AND NOT BY COINCIDENCE. Every state
that files a claim with `engaged=True` has `wanted > 0`, `wanted is None` or
`blind`, so this answers 0.0 or None there and can never hand a strategy
permission the guard's own `lo` is at that instant refusing.
------------------------------------------------------------------------
⚠️ WHY THIS IS A SIZING AND NOT A CLAIM, AND DO NOT "SIMPLIFY" IT INTO ONE
The obvious tidier answer is to delete `decide()`'s `wanted <= 0` gate and
let it file `lo = wanted` unconditionally - a NEGATIVE floor, meaning "you
may charge, but no harder than this". The arithmetic is identical, it is
continuous, and it would bind every claimant rather than only arbitrage. It
was measured, considered and REJECTED, and the reason is not style:
* `main.py`'s cheap-window maintenance charge files
`Claim.limit("maintenance", P_MAINT_SHAPE, hi=-floor)` - a NEGATIVE `hi`.
arbiter.py clause 2 applies EVERY limit at or above the winner's priority
and takes the most restrictive, with no notion of one limit out-ranking
another. A peak `lo = -300 W` against a shaping `hi = -1000 W` is
`lo > hi`: a CONTRADICTION, which resolves to 0 W - the battery PARKED,
in the one quarter of the month that sets the bill. That is the exact
failure `shaping_suspended()` exists to prevent, and the only way to
close it would be to key that suppression on "a claim exists", which on
every site this card is about suspends the maintenance charge-only
shaping PERMANENTLY.
* and it would change what `engaged` MEANS at five call sites outside this
module - `arbitrage.guard_state()`, `shaping_suspended()`, `claim()`,
main.py's edge-logged action line and main.py's status row - each of
which today reads it as "the guard is HOLDING".
`control.maintenance_charge_floor()` is a sizing and not a limit for the same
reason, which is why this is the shape that already fits.
DECISIONS/SAFETY-45-sizing-the-trade.md §2b carries the full argument.
"""
if decision is None:
return None
if decision.blind:
return 0.0
if decision.wanted_w is None:
return None
return max(0.0, -float(decision.wanted_w))
def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool:
"""Must the maintenance charge-only shaping stand down this cycle?
+355 -11
View File
@@ -73,6 +73,7 @@ Read MUTATION.md before you report a survivor.
import ast
import inspect
import math
import os
import sys
from datetime import datetime, timedelta, timezone
@@ -84,6 +85,11 @@ from app.arbiter import (
P_ARBITRAGE, P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim,
resolve,
)
# SAFETY-45 section 3c. The shipped loop, the shipped billed-quarter
# accumulator and the shipped projection - nothing here is transcribed.
from app.control import Tuning, compute
from app.p1 import QUARTER_S, QuarterAverager, make_sample, split_signed
from app.tariff import PeakTracker
from app.arbitrage import (
CHARGE, DISCHARGE, ArbitrageDecision, claim, decide, enabled, soc_bounds,
threshold_eur_kwh,
@@ -203,10 +209,19 @@ def hour(i, *, start=D0):
return start + timedelta(hours=i, minutes=30)
# SAFETY-45. THE HEADROOM EVERY CHECK THAT IS NOT ABOUT THE HEADROOM USES, and
# it is deliberately NOT binding: `min(MAX_W, this)` is MAX_W, so every existing
# check in this file grades what it always graded and the new rung is decoration
# in all of them. Section 3b is where it is made to bind, and 3b asserts the
# precondition that it does (MUTATION.md habit 1) rather than hoping.
HEADROOM_CLEAR = MAX_W * 10.0
def act(plan, i, *, peak=False, soc=50.0, thresh=0.0, unc=frozenset(),
floor=20.0, ceiling=95.0, start=D0):
floor=20.0, ceiling=95.0, start=D0, headroom=HEADROOM_CLEAR):
"""`decide()` with the fixture's constants filled in. One call shape."""
return decide(plan, hour(i, start=start), peak_guard_active=peak, soc=soc,
charge_headroom_w=headroom,
min_spread_eur_kwh=thresh, uncertainty=unc,
charge_w=MAX_W, discharge_w=MAX_W,
soc_floor_pct=floor, soc_ceiling_pct=ceiling)
@@ -721,6 +736,7 @@ check("a peak floor does NOT cap an arbitrage DISCHARGE - §9.1 bills offtake "
# MECHANISM 3, which is neither of the above: the guard that never ran.
_blind = decide(PLAN, hour(CHEAP_H), peak_guard_active=None, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W,
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
check("a peak guard that NEVER RAN is a third state, and it refuses: `None` is "
@@ -750,6 +766,323 @@ check("...and cycle() calls it rather than inlining the conjunction, so a "
encoding="utf-8").read(), "cycle")))
# --------------------------------------------------------------------------- #
print()
print("3b. SAFETY-45 - the buy leg is SIZED by the guard, not switched by it")
# ---------------------------------------------------------------------------
# AC3 above is the SWITCH: `peak_guard_active` refuses the whole trade while
# the guard holds. That is right and it stays. What was missing is the other
# half - the guard measures a CONTINUOUS number, "the watts this billed quarter
# can still afford", and until this card nothing consumed it. So a trade sat at
# max_w through the whole window in which the guard was silent, blew the
# quarter, tripped the switch, was cancelled outright, and came back at max_w
# the moment the meter fell far enough to release it. Measured on TEST-23
# stage 3's closed rig at peak_cap_w 1500 / max_w 2000 / a 1200 W house:
# 28 charge onsets in one hour, mean period 131 s, grid -456 <-> +3200 W.
#
# `control.maintenance_charge_floor()` has always given a MAINTENANCE charge
# exactly this shape - "capped by the headroom left under the site's cap" - and
# its own docstring says why: "a maintenance charge is the only thing this
# system does that is big enough and long enough to set that peak". OPT-04 is
# the second such thing and was given no cap. This section grades the cap.
# ---------------------------------------------------------------------------
# ⚠️ THE PRECONDITION, AND IT IS THE HABIT-1 ONE. The headroom must be strictly
# between 0 and the size the trade would otherwise ask for, or "sized by the
# guard" is indistinguishable from "not sized at all" and from "refused".
S45_HEADROOM = 300.0
check(f"PRECONDITION: the headroom under test ({S45_HEADROOM:.0f} W) is "
f"strictly inside the trade's own sizing ({MAX_W:.0f} W) and strictly "
f"positive, so a cap that did nothing and a cap that refused everything "
f"are both distinguishable from the answer below",
0.0 < S45_HEADROOM < MAX_W)
_s45_capped = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER,
headroom=S45_HEADROOM)
_s45_clear = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER)
print(f" headroom {S45_HEADROOM:.0f} W -> {_s45_capped.power_w:+.0f} W; "
f"headroom clear -> {_s45_clear.power_w:+.0f} W")
print(f" {_s45_capped.reason}")
check("the trade still HAPPENS - §10.1 ranks peak shaving above arbitrage, it "
"does not abolish it - and it is sized to the watts the billed quarter "
"can afford rather than to max_w",
_s45_capped.action == CHARGE
and near(_s45_capped.power_w, -S45_HEADROOM))
check("...and the claim the arbiter receives carries the SIZED number, so the "
"cap reaches the actuator and is not merely printed",
near(claim(_s45_capped).value, -S45_HEADROOM)
and claim(_s45_capped).priority == P_ARBITRAGE)
check("...and the SAME call with the headroom clear asks for max_w, so the "
"check above is graded against a difference this fixture can actually "
"produce (habit 7: one thing varied, from an accepted baseline)",
_s45_clear.action == CHARGE and near(_s45_clear.power_w, -MAX_W))
# ⚠️ THE ORDERED PAIR (MUTATION.md habit 14). Two numbers in one sentence can
# always be transposed by a ZERO-BYTE edit, and this sentence is what an
# operator reads when the battery buys less than the price says it should. So
# the check asserts each value inside the literal text that names it, and
# asserts that the TRANSPOSED sentence is not what it says.
_S45_PAIR = (f"the trade asked for {MAX_W:.0f} W and this billed quarter can "
f"afford {S45_HEADROOM:.0f} W")
_S45_SWAP = (f"the trade asked for {S45_HEADROOM:.0f} W and this billed "
f"quarter can afford {MAX_W:.0f} W")
check("the reason names WHAT WAS ASKED FOR and WHAT WAS AFFORDABLE in their "
"own slots, so an operator can tell a small trade from a small "
"opportunity", _S45_PAIR in _s45_capped.reason)
check("...and the transposed sentence is NOT what it says, so the check above "
"discriminates rather than decorates - both numbers are in the string "
"either way and a substring test would pass under the swap",
_S45_SWAP not in _s45_capped.reason)
check("...and it names the guard and the two spec sections, so the sentence "
"cannot be read as a pack limit or a price limit",
"CAPACITY-TARIFF GUARD" in _s45_capped.reason
and "§9.1" in _s45_capped.reason and "§10.1" in _s45_capped.reason)
check("...and an UNCAPPED trade does not carry that clause at all: a sentence "
"that always says 'sized by the guard' tells nobody anything",
"CAPACITY-TARIFF GUARD" not in _s45_clear.reason
and f"charging {MAX_W:.0f} W" in _s45_clear.reason)
# --- the two refusals, and they are told apart from the ones that existed ---
_s45_none = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER, headroom=None)
_s45_zero = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER, headroom=0.0)
print(f" headroom None -> {_s45_none.action}; headroom 0 W -> "
f"{_s45_zero.action}")
check("a headroom nothing MEASURED refuses. This is guard_state()'s None "
"argument one layer down: charging creates the billed quantity (§9.1) "
"and an unmeasured quarter is not a clear one",
not _s45_none.trading
and _s45_none.reason == _arb.IDLE_HEADROOM_UNMEASURED)
check("...and it is DISTINGUISHABLE from the guard never having run at all, so "
"the status page does not report a missing battery sensor as a missing "
"meter", _s45_none.reason != _arb.IDLE_NOT_COVERED)
check("a headroom of ZERO refuses rather than filing a 0 W claim - "
"`Claim.set(..., 0.0)` at P_ARBITRAGE is a PARK ORDER that outranks the "
"loop, which is the failure claim()'s own note is written against",
not _s45_zero.trading and claim(_s45_zero) is None
and _s45_zero.reason == _arb.IDLE_NO_HEADROOM)
# ⚠️ TEST-16's RULE: TWO RUNGS THAT CAN ANSWER THE SAME STIMULUS MUST BE
# SEPARATED. `charge_w = 0` and `headroom = 0` both produce a 0 W sizing, and
# grading only `kind == "refused"` would let either absorb the other's work -
# after which weakening either one reports nothing. So the REASONS are graded,
# and each rung is reached with the other one clear.
_s45_nosize = decide(PLAN, hour(CHEAP_H), peak_guard_active=False, soc=50.0,
charge_headroom_w=HEADROOM_CLEAR,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_w=0.0, discharge_w=MAX_W,
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
check("...and the PACK being sized at 0 W still reports OPT-09's own sentence, "
"not this card's: the two rungs answer the same stimulus and a refusal "
"that named the wrong subsystem would send an operator to the wrong page",
not _s45_nosize.trading
and _s45_nosize.reason == _arb.IDLE_NO_SIZE
and _s45_zero.reason != _s45_nosize.reason)
# --- §9.1: the SELL leg is untouched ---------------------------------------
_s45_sell = act(PLAN, DEAR_H, thresh=_shipped, unc=NO_WEATHER, headroom=0.0)
check("a ZERO headroom does not cap a DISCHARGE. §9.1: 'discharge and export "
"are never capped by the peak guard; only charging and import are' - the "
"capacity tariff bills offtake, and a sell creates none",
_s45_sell.action == DISCHARGE and near(_s45_sell.power_w, MAX_W))
# --- and main.py actually passes it, pinned on the AST ----------------------
# ⚠️ THE CALL SITE IS THE UNIT OF COVERAGE (MUTATION.md habit 15). Nothing
# behavioural in this file can see main.py drop the argument - every check
# above calls decide() itself - and a required keyword-only parameter that no
# caller fills is a TypeError only on the one path a suite does not run. Same
# pin, same reason, as `weights=` in test_packshare.py part 10.
_S45_MAIN = open(os.path.join(HERE, "app", "main.py"), encoding="utf-8").read()
_s45_calls = [n for n in ast.walk(ast.parse(_S45_MAIN))
if isinstance(n, ast.Call)
and ast.unparse(n.func).endswith("arbitrage.decide")]
_s45_arg = [ast.unparse(k.value) for c in _s45_calls for k in c.keywords
if k.arg == "charge_headroom_w"]
print(f" main.py has {len(_s45_calls)} `arbitrage.decide(` call site(s); "
f"charge_headroom_w= is {_s45_arg}")
check("main.py passes the guard's OWN measured headroom at its one "
"arbitrage.decide() call site - the shipped expression, not a "
"transcription of it, so a mutation back to a literal or to max_w is red "
"rather than silent",
_s45_arg == ["peakguard.charge_headroom_w(self.peak_guard)"])
# --------------------------------------------------------------------------- #
print()
print("3c. SAFETY-45 - THE REGRESSION: the closed loop SETTLES")
# ---------------------------------------------------------------------------
# ⚠️ THIS IS THE CHECK THE CARD IS ACTUALLY ABOUT, AND A UNIT TEST ON decide()
# IS NOT IT. The defect is an INTERACTION - two strategies, a 900 s integrator
# and a plant that answers back - and every individual decision in it was
# correct. So the loop is closed here: prices decide, the battery model
# responds, the shipped QuarterAverager reports the consequence, and the
# shipped guard reacts to that.
#
# WHAT IS REAL, AND WHAT IS MODELLED - the same discipline TEST-23 stage 3's
# harness holds itself to, because a transcription proves what somebody typed:
#
# REAL peakguard.decide/claim/charge_headroom_w, arbitrage.decide/claim/
# guard_state, control.compute + Tuning, arbiter.resolve, and
# p1.QuarterAverager - the billed-quarter accumulator itself, so the
# quarters reported below are the ones a meter would bill.
# MODEL ONE cycle's ORDER (peak block, then arbitrage reading its
# conclusion, then the loop claim, then resolve) - twelve lines
# transcribed from main.py:2660-2900 and pinned above in 3b; the
# PLANT, an 800 W/s ramp into a 10 s first-order lag, which is
# plantsim's; and a flat synthetic house.
#
# ⚠️ AND THE HEADLINE NUMBERS WERE NOT TAKEN HERE. They were taken on the
# closed rig at /home/homelab/test23s3 against the shipped container's own
# checkout, which is where a divergence between this model and main.py's real
# event loop would show up. This section exists so CI can go red.
# ---------------------------------------------------------------------------
S45_CAP_W, S45_LOAD_W, S45_CYCLE_S, S45_RUN_S = 1500.0, 1200.0, 5.0, 2700.0
S45_RAMP_W_S, S45_LAG_S = 800.0, 10.0
# ⚠️ THE RUN STARTS ON A QUARTER BOUNDARY AND ENDS INSIDE THE SAME CHEAP HOUR,
# and both halves matter. Starting on the boundary makes the three closed
# blocks below whole billed quarters. Ending inside the hour keeps TEST-23
# finding 1 - the export overshoot at the END of any trade, ~0.37 x max_w, a
# separate and already-recorded property - out of a swing figure this card is
# grading. The run still crosses TWO quarter boundaries, which is the half of
# the defect the card says arbitrage must learn from.
S45_T0 = D0 + timedelta(hours=CHEAP_H)
def s45_loop(*, sized: bool, cap_w=S45_CAP_W, load_w=S45_LOAD_W,
seconds=S45_RUN_S):
"""One closed hour. `sized=False` is the behaviour this card removes.
⚠️ THE COUNTERFACTUAL IS ONE ARGUMENT, NOT A SECOND CODE PATH. `sized=False`
hands `decide()` a headroom nothing can bind - which is exactly what the
tree did before this card, because nothing was passing one - so the two runs
below differ in one value and share every line of shipped code. Habit 7.
"""
avg = QuarterAverager(phases=1, max_hold_s=900.0)
tuning = Tuning(gain=0.6, max_w=MAX_W, slew_w=0.0)
t0 = S45_T0
target = i_w = 0.0
sat = 0
loop_w = None
batt_cmd = batt = 0.0 # + discharge, as main.py's site_batt_w
onsets = blocks = 0
prev_action = "<start>"
grid_lo, grid_hi = None, None
billed = []
acc = 0.0
t = 0.0
while t <= seconds:
grid = load_w - batt
imp, exp = split_signed(grid)
for b in avg.add(make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=t0 + timedelta(seconds=t),
ingest_mono=(t0.timestamp() + t))):
billed.append(round(float(b.offtake_avg_w), 1))
blocks += 1
if acc <= 1e-9:
acc = S45_CYCLE_S
now = t0 + timedelta(seconds=t)
claims = []
pk = _pg.decide(PeakTracker.project(avg, now), batt_w=batt,
cap_w=cap_w, reading=None, achievable_w=MAX_W,
soc=50.0, soc_floor=20.0, max_hold_s=900.0,
frozen=False)
pc = _pg.claim(pk)
if pc is not None:
claims.append(pc)
arb = decide(PLAN, now,
peak_guard_active=_arb.guard_state(pk),
charge_headroom_w=(_pg.charge_headroom_w(pk) if sized
else HEADROOM_CLEAR),
soc=50.0, min_spread_eur_kwh=_shipped,
uncertainty=NO_WEATHER, charge_w=MAX_W,
discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0)
ac = claim(arb)
if ac is not None:
claims.append(ac)
if arb.action == CHARGE and prev_action != CHARGE:
onsets += 1
prev_action = arb.action
if loop_w is None or target != loop_w:
i_w = target
d = compute(prev_w=target, grid_w=grid, actual_w=batt,
tuning=tuning, sat_count=sat, i_w=i_w)
sat, i_w, loop_w = d.sat_count, d.i_w, d.target_w
claims.append(Claim.limit("supervised", P_SAFETY, -MAX_W, MAX_W,
"max_w"))
claims.append(Claim.set("loop", P_LOOP, d.target_w, d.reason))
target = resolve(claims).target_w
# THE PLANT, MODELLED: an 800 W/s ramp into a 10 s first-order lag.
step = min(1.0, acc)
delta = target - batt_cmd
batt_cmd += math.copysign(min(abs(delta), S45_RAMP_W_S * step), delta)
batt += (batt_cmd - batt) * (step / S45_LAG_S)
acc -= step
t += step
grid_lo = grid if grid_lo is None else min(grid_lo, grid)
grid_hi = grid if grid_hi is None else max(grid_hi, grid)
return dict(onsets=onsets, grid_lo=round(grid_lo, 1),
grid_hi=round(grid_hi, 1), swing=round(grid_hi - grid_lo, 1),
billed=billed, blocks=blocks)
S45_BEFORE = derived(lambda: s45_loop(sized=False))
S45_AFTER = derived(lambda: s45_loop(sized=True))
for _lab, _r in (("unsized (the shipped behaviour before this card)", S45_BEFORE),
("sized by the guard", S45_AFTER)):
print(f" {_lab}: {_r['onsets']} charge onsets/h, grid "
f"{_r['grid_lo']:.0f} .. {_r['grid_hi']:.0f} W (swing "
f"{_r['swing']:.0f} W), billed quarters {_r['billed']} W")
# ⚠️ THE NEGATIVE CONTROL FIRST. If the unsized run did NOT limit-cycle, every
# assertion below would be a statement about a loop that was never unstable -
# MUTATION.md's "a check whose data cannot reach the failure it names", which
# has now appeared in eleven cards in this repository.
check("PRECONDITION: with the trade UNSIZED this loop really does run away - "
"many charge onsets in one hour and a grid swing of kilowatts. Without "
"this, everything below grades a loop that was never unstable",
S45_BEFORE["onsets"] >= 10 and S45_BEFORE["swing"] > 2000.0)
check("...and it is the bang-bang shape and not a slow drift: the meter "
"reaches both a hard import and an EXPORT, so the actuator is being "
"handed back and forth rather than converging",
S45_BEFORE["grid_hi"] > S45_CAP_W + 1000.0
and S45_BEFORE["grid_lo"] < 0.0)
check(f"THE REGRESSION: sizing the trade to the guard's measured headroom "
f"settles the loop. {S45_BEFORE['onsets']} charge onsets an hour become "
f"{S45_AFTER['onsets']}, and the grid swing falls from "
f"{S45_BEFORE['swing']:.0f} W to {S45_AFTER['swing']:.0f} W",
S45_AFTER["onsets"] <= 2
and S45_AFTER["swing"] < 0.25 * S45_BEFORE["swing"])
check("...and the battery is still TRADING for the whole hour rather than "
"settling by giving up: §10.1 ranks peak shaving above arbitrage, it "
"does not cancel it, and a fix that simply stopped buying would pass an "
"onset count and fail the card",
S45_AFTER["onsets"] == 1)
check(f"...and every BILLED quarter comes in at or under the "
f"{S45_CAP_W:.0f} W cap, which the unsized run does not manage - the "
f"oscillation was not merely ugly, it was losing the money it was "
f"oscillating about (§10.2, §9.1)",
S45_AFTER["blocks"] >= 3
and all(v <= S45_CAP_W + 0.5 for v in S45_AFTER["billed"])
and any(v > S45_CAP_W for v in S45_BEFORE["billed"]))
check("...and the two runs are the same code on the same fixture: one "
"argument differs, and the loop, the guard, the arbiter, the averager "
"and the plant are shared line for line",
S45_BEFORE["blocks"] == S45_AFTER["blocks"])
# ⚠️ AND IT IS INERT WHERE THE DEFECT IS INERT, WHICH IS THE OTHER HALF OF A
# FIX. The rig's sweep found the bite rule `load + max_w > peak_cap_w`; below
# it, the trade never threatens the quarter and this card must change NOTHING.
S45_WIDE = derived(lambda: s45_loop(sized=False, cap_w=5000.0, load_w=1200.0,
seconds=900.0))
S45_WIDE_S = derived(lambda: s45_loop(sized=True, cap_w=5000.0, load_w=1200.0,
seconds=900.0))
print(f" peak_cap_w 5000 W, 1200 W house (load + max_w = "
f"{S45_LOAD_W + MAX_W:.0f} W, under the cap): unsized "
f"{S45_WIDE['billed']} W, sized {S45_WIDE_S['billed']} W")
check("on a site where the trade cannot reach the cap the two runs are "
"IDENTICAL to the watt - so this card costs nothing on the installs it "
"is not about, and the check above is not passing because the cap "
"dominates everything",
S45_WIDE == S45_WIDE_S and S45_WIDE["onsets"] == 1)
# --------------------------------------------------------------------------- #
print()
print("4. §8.3 - no irreversible action on a provisional price")
@@ -822,7 +1155,8 @@ _ticks = []
for _t in range(24 * 120): # 24 h at the 30 s control cadence
_d = decide(_flap_plan, D0 + timedelta(seconds=30 * _t),
peak_guard_active=False, soc=50.0, min_spread_eur_kwh=_shipped,
uncertainty=frozenset(), charge_w=MAX_W, discharge_w=MAX_W,
uncertainty=frozenset(), charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W,
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
_ticks.append(_d.action)
_transitions = sum(1 for a, b in zip(_ticks, _ticks[1:]) if a != b)
@@ -844,7 +1178,8 @@ _no_plan = act(None, CHEAP_H, thresh=_shipped)
_no_soc = act(PLAN, CHEAP_H, thresh=_shipped, soc=None)
_uncovered = decide(PLAN, D0 + timedelta(days=9), peak_guard_active=False,
soc=50.0, min_spread_eur_kwh=_shipped,
uncertainty=frozenset(), charge_w=MAX_W, discharge_w=MAX_W,
uncertainty=frozenset(), charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W,
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
_reasons = [_no_plan.reason, _no_soc.reason, _uncovered.reason, _blind.reason,
_held.reason]
@@ -1218,7 +1553,8 @@ def replay_real(days, threshold, unc, *, soc0=50.0, floor=20.0, ceiling=95.0):
d = decide(plan, p.start + timedelta(minutes=30),
peak_guard_active=False, soc=soc,
min_spread_eur_kwh=threshold, uncertainty=unc,
charge_w=MAX_W, discharge_w=MAX_W,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W,
soc_floor_pct=floor, soc_ceiling_pct=ceiling)
if d.action is None:
continue
@@ -1952,10 +2288,12 @@ check("...and the same at the sell probe, against the verdict's dearest",
_pa_sell is derived(lambda: _qv.dearest))
_qbuy = decide(QPLAN, qprobe(Q_CHEAP_I), peak_guard_active=False, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0)
_qsell = decide(QPLAN, qprobe(Q_DEAR_I), peak_guard_active=False, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0)
check("...so decide() CHARGES in the cheapest quarter and DISCHARGES in the "
@@ -1980,7 +2318,8 @@ check(f"the off-peak probe's own price differs from its hour's :00 price "
and _QOFF_I not in (Q_CHEAP_I, Q_DEAR_I))
_qoff = decide(QPLAN, qprobe(_QOFF_I), peak_guard_active=False, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0)
check("a quarter that is neither extremum still idles, for the reason that "
"names the right rule, and reports ITS OWN price - not the price of the "
@@ -2007,7 +2346,8 @@ check(f"the negative day's dearest quarter is slot {Q_DEAR_I} and it is BELOW "
NO_WEATHER))
_qneg_sell = decide(QNEG_PLAN, qprobe(Q_DEAR_I), peak_guard_active=False,
soc=50.0, min_spread_eur_kwh=_shipped,
uncertainty=NO_WEATHER, charge_w=MAX_W, discharge_w=MAX_W,
uncertainty=NO_WEATHER, charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W,
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
check("selling into a negative-price QUARTER is refused, and the reason names "
"§17 and THAT QUARTER's price",
@@ -2020,7 +2360,8 @@ check("...while the SAME quarter-hourly series with that ONE price's SIGN "
"FLIPPED discharges instead",
decide(qplan_of(QPOS), qprobe(Q_DEAR_I), peak_guard_active=False,
soc=50.0, min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0).action == DISCHARGE
and sum(1 for a, b in zip(QNEG, QPOS) if a != b) == 1)
# §8.3's flag, read off the covering point. `_act`'s own provisional guard
@@ -2077,11 +2418,13 @@ check("...so the mixed day CHARGES and DISCHARGES in its firm quarters, at "
"their own prices",
decide(MIX_PLAN, _mix_buy_t, peak_guard_active=False, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0).action == CHARGE
and decide(MIX_PLAN, _mix_sell_t, peak_guard_active=False, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0).action == DISCHARGE)
# An instant inside a CARRIED hour of the same series. The covering point is
# the carried one; the shipped scan answered a point from the FIRM block
@@ -2096,7 +2439,8 @@ check("an instant inside a CARRIED hour of the mixed series matches the "
and MIX_PTS[MIX_N_H - 1].provisional is True
and decide(MIX_PLAN, _mix_carried_t, peak_guard_active=False, soc=50.0,
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
charge_headroom_w=HEADROOM_CLEAR,
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
soc_ceiling_pct=95.0).action is None)
print()
+9 -4
View File
@@ -1067,14 +1067,19 @@ class _V:
_here = _Pt(_NOW)
_other = _Pt(_NOW - timedelta(hours=3))
# SAFETY-45's `headroom_w` is the last positional. It is set to a value that
# CANNOT BIND on either call - larger than every sizing below - so this part
# goes on grading OPT-09's per-pack sizing and not the capacity-tariff cap that
# now sits beside it. test_arbitrage.py section 3b is where it is made to bind.
_CLEAR_W = 1e9
_sell = arbitrage._act(_Plan(_here), _NOW, _V(_other, _here), 60.0,
0.0, 0.0, 20.0, 90.0)
0.0, 0.0, 20.0, 90.0, _CLEAR_W)
_sell_ok = arbitrage._act(_Plan(_here), _NOW, _V(_other, _here), 60.0,
0.0, 4600.0, 20.0, 90.0)
0.0, 4600.0, 20.0, 90.0, _CLEAR_W)
_buy = arbitrage._act(_Plan(_here), _NOW, _V(_here, _other), 60.0,
0.0, 0.0, 20.0, 90.0)
0.0, 0.0, 20.0, 90.0, _CLEAR_W)
_buy_ok = arbitrage._act(_Plan(_here), _NOW, _V(_here, _other), 60.0, 5000.0,
0.0, 20.0, 90.0)
0.0, 20.0, 90.0, _CLEAR_W)
print(f" sized at 0 W: sell -> {_sell.action}, buy -> {_buy.action}; "
f"sized at a rating: sell -> {_sell_ok.action}, buy -> {_buy_ok.action}")
check("⚠️ A TRADE NOTHING CAN TAKE IS NOT A TRADE. A 0 W sizing IDLES on both "
+287 -3
View File
@@ -138,8 +138,8 @@ from app.peakguard import (
BLIND_COVERAGE, BLIND_FROZEN, BLIND_NO_BLOCK, CEILING_CAP,
CEILING_MONTH_PEAK, FLOOR_AT_SOC_FLOOR, FLOOR_SOC_UNREADABLE,
PEAK_OPTION_DEFAULTS, Pack, PeakDecision, binding_soc_floor, blindness,
decide, discharge_permitted, excess_w, pack_floor_pct, pack_permissions,
quarter_ceiling_w, uncovered_s,
charge_headroom_w, decide, discharge_permitted, excess_w, pack_floor_pct,
pack_permissions, quarter_ceiling_w, uncovered_s,
)
from app.validate import validate_startup_config
from app.tariff import (
@@ -379,8 +379,17 @@ check("at the engagement boundary the excess is zero, so the floor is exactly "
released = decide(p_edge, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
would_release_on_excess = excess_w(p_edge, CEIL) <= 0.0
# ⚠️ `floor_w` IS FORMATTED DEFENSIVELY, AND MUTATION.md's OWN LEDGER IS WHY.
# The mutation this check exists for - reverting the `floor > 0` rung to
# `excess > 0` - makes `floor_w` None HERE, so a bare `:.0f` raises TypeError
# and ABORTS THE WHOLE FILE on the line after the check that just went red.
# Parts 2-13 are then ungraded and the recorded coverage of that mutation is
# smaller than the truth: exactly what MAINT-05's `delinquent(...)['b']`
# subscript did to test_delivery.py, and habit 5 ("a check whose failure mode
# is a hang - or a crash - needs a deadline, or a name") in the other costume.
print(f" at the boundary: excess {e_edge:.3f} W (an `excess > 0` test would "
f"RELEASE: {would_release_on_excess}), floor {released.floor_w:.0f} W "
f"RELEASE: {would_release_on_excess}), floor "
f"{'(none)' if released.floor_w is None else f'{released.floor_w:.0f} W'} "
f"(the shipped `floor > 0` test holds: {released.engaged})")
check("...and the claim is held there, not released. `excess` falls to zero as "
"soon as the battery is already taking the watts, so thresholding IT is "
@@ -5947,6 +5956,281 @@ check("...and the row is green and un-warned, which is what makes the "
"operator scrolls past", _rows38[0]["ok"] and not _rows38[0]["warn"]
and _S38_CEIL_W - _S38_PROJ.projected_w == 2713.0)
# ==============================================================================
print()
print("13. SAFETY-45 - charge_headroom_w(): the number decide() used to throw "
"away")
# ---------------------------------------------------------------------------
# `decide()` computes `wanted = batt_w + excess` - the DISCHARGE that lands the
# quarter exactly on the ceiling - and returns `engaged=False, floor_w=None`
# the moment that number is not positive. A negative `wanted` is not "nothing
# to say": it is a permitted CHARGE of `-wanted` W, and discarding it is what
# let an arbitrage charge sit at max_w against a quarter it was blowing.
#
# ⚠️ NOTHING IN decide() CHANGES IN THIS CARD, AND THAT IS THE POINT. The
# `wanted <= 0` branch is 2026-08-20's anti-bang-bang rung and part 6's replay
# is what proves it still holds; this part grades a PURE READ of the number it
# already carried. 13g asserts the branch is still there rather than trusting
# the diff.
# ---------------------------------------------------------------------------
def _proj_at(net_w, elapsed):
return PeakTracker.project(averager_at(net_w, elapsed),
B + timedelta(seconds=elapsed))
def _decide_on(projection, *, batt_w, soc=SOC_OK, cap_w=CAP_W,
achievable_w=LIVE_MAX_W, reading=None, frozen=False):
return decide(projection, batt_w=batt_w, cap_w=cap_w, reading=reading,
achievable_w=achievable_w, soc=soc, soc_floor=SOC_FLOOR,
max_hold_s=MAX_HOLD_S, frozen=frozen)
def _decide_at(net_w, elapsed, **kw):
return _decide_on(_proj_at(net_w, elapsed), **kw)
def _avg_held(base_w, elapsed, held_w, *, start=B, step=5.0):
"""`elapsed` seconds BANKED at `base_w`, then `held_w` standing at the edge.
⚠️ THE HISTORY AND THE HELD VALUE ARE SEPARATE FIXTURE AXES, and 13c is why
this helper exists rather than `averager_at(load - batt, ...)`. The averager
credits a sample from its own timestamp to the NEXT one, so the last sample
contributes ZERO accumulated seconds and only becomes `held`. That is the
real shape of a battery whose command changed a moment ago - the quarter's
banked seconds do not retroactively change - and a fixture that moved the
whole history with it would be grading a different quarter each time.
"""
a = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
t = 0.0
while t < elapsed:
a.add(sample(start + timedelta(seconds=t), base_w))
t += step
a.add(sample(start + timedelta(seconds=elapsed), held_w))
return a
# --- 13a. the number, and it is DERIVED rather than read back ---------------
# The house is flat at 2600 W under a 3500 W cap, the battery is idle, and 300 s
# of the block are banked. 600 s remain, so the block can still absorb
# 900 W x 900/600 = 1350 W of extra offtake and finish exactly on the ceiling.
_H45_GAP = 900.0
_H45_LOAD = CAP_W - _H45_GAP
_H45_EL, _H45_REM = 300.0, QUARTER_S - 300.0
_h45_idle = _decide_at(_H45_LOAD, _H45_EL, batt_w=0.0)
_h45_head = charge_headroom_w(_h45_idle)
# The CONSEQUENCE, computed from the fixture's own literals and not from the
# module's arithmetic: hold that charge for the rest of the block and the
# BILLED AVERAGE is the ceiling. This is the meaning of the number, which is a
# different statement from the expression that produces it (habit 2).
_H45_FINAL = (_H45_LOAD * _H45_EL
+ (_H45_LOAD + _h45_head) * _H45_REM) / QUARTER_S
print(f" house {_H45_LOAD:.0f} W under a {CAP_W:.0f} W cap, battery 0 W, "
f"{_H45_EL:.0f} s banked: engaged={_h45_idle.engaged}, "
f"wanted={_h45_idle.wanted_w:+.0f} W, headroom={_h45_head:.0f} W")
print(f" holding that charge for the remaining {_H45_REM:.0f} s bills the "
f"block at {_H45_FINAL:.1f} W against a {CAP_W:.0f} W ceiling")
check("the guard files NOTHING here - this is the shipped `wanted <= 0` branch, "
"untouched, and the state SAFETY-45 is about",
not _h45_idle.engaged and _h45_idle.floor_w is None
and _h45_idle.wanted_w is not None and _h45_idle.wanted_w < 0.0)
check(f"...and yet the quarter can afford {_h45_head:.0f} W of charging, which "
f"is the number that was being discarded. Graded by its CONSEQUENCE - "
f"the block lands on the ceiling to the watt - rather than by "
f"re-deriving -wanted_w with the module's own formula",
near(_H45_FINAL, CAP_W, 0.5))
check(f"...and it is none of the other numbers this fixture has: not the cap "
f"({CAP_W:.0f}), not max_w ({LIVE_MAX_W:.0f}), not the house "
f"({_H45_LOAD:.0f}) and not the raw gap under the ceiling "
f"({_H45_GAP:.0f}) - the 900/remaining gain is 1.5 here and a check that "
f"could not tell those apart would be decoration",
not any(near(_h45_head, v, 1.0)
for v in (CAP_W, LIVE_MAX_W, _H45_LOAD, _H45_GAP)))
# --- 13a2. AND THE IDENTITY HOLDS ACROSS THE WHOLE BLOCK --------------------
# ⚠️ THE AMPLIFICATION IS REAL AND IT IS REPORTED WITH ITS SIZE (habit 13).
# 900/remaining reaches 180 in the last five seconds, so the headroom there is
# enormous - and it is bounded in EFFECT, not in magnitude: at every elapsed,
# holding it bills the block at exactly the ceiling.
H45_ID = []
for _el in (0.0, 60.0, 300.0, 600.0, 870.0, 895.0):
_rem = QUARTER_S - _el
_hd = charge_headroom_w(_decide_at(_H45_LOAD, _el, batt_w=0.0))
H45_ID.append((_el, _hd,
(_H45_LOAD * _el + (_H45_LOAD + _hd) * _rem) / QUARTER_S))
print(" headroom across the block (elapsed, headroom W, billed W): "
+ ", ".join(f"({a:.0f}, {b:.0f}, {c:.1f})" for a, b, c in H45_ID))
check("at EVERY point in the block, holding the headroom for the rest of it "
"bills the block at exactly the ceiling - so the 162 kW answer five "
"seconds from the boundary and the 900 W answer at the top of it are the "
"same statement about the bill",
all(near(c, CAP_W, 0.5) for _, _, c in H45_ID))
check("...and the fixture SPANS the amplification rather than sitting in one "
f"corner of it: the headroom moves by a factor of "
f"{max(b for _, b, _ in H45_ID) / min(b for _, b, _ in H45_ID):.0f} "
f"across those six points",
max(b for _, b, _ in H45_ID) > 100.0 * min(b for _, b, _ in H45_ID))
# --- 13b. HOLDING implies ZERO OR UNKNOWN, swept - the family, not one case -
# ⚠️ THE PROPERTY THAT MAKES THIS SAFE TO HAND A STRATEGY. Every state that
# files a claim has `wanted > 0`, `wanted is None` or `blind`, so this function
# can never hand a caller permission the guard's own `lo` is at that instant
# refusing. Swept over the same grid part 5's no-negative-floor proof uses,
# widened with the two ways a cycle can fail to measure at all.
H45_N = H45_BAD = H45_POS = H45_NONE = H45_ZERO = 0
for _load in (0.0, CAP_W - 500.0, CAP_W, CAP_W + 300.0, CAP_W + 3000.0,
LIVE_MAX_W * 3):
for _el in (30.0, 450.0, 870.0):
for _bw in (-LIVE_MAX_W, -2000.0, 0.0, 400.0, 4000.0, None):
for _sc in (None, BMS_FLOOR, SOC_FLOOR, SOC_FLOOR + 0.5, 100.0):
for _fz in (False, True):
_d45 = _decide_at(_load, _el, batt_w=_bw, soc=_sc,
frozen=_fz)
_hr = charge_headroom_w(_d45)
H45_N += 1
if _hr is None:
H45_NONE += 1
elif _hr > 0.0:
H45_POS += 1
else:
H45_ZERO += 1
if _d45.engaged and _hr not in (None, 0.0):
H45_BAD += 1
print(f" swept {H45_N} (load x elapsed x battery x SoC x frozen) "
f"combinations: {H45_BAD} ENGAGED decisions answering a positive "
f"headroom; {H45_POS} permit charging, {H45_ZERO} answer 0 W, "
f"{H45_NONE} unmeasured")
check(f"NOT ONE of the {H45_N} combinations answers a positive headroom while "
f"the guard is ENGAGED. A decision whose `lo` is at that instant "
f"forbidding charging can never be read as permission to charge",
H45_BAD == 0 and H45_N > 800)
check("...and the sweep is vacuous in no direction: it reaches decisions that "
f"permit charging ({H45_POS}), decisions that permit none ({H45_ZERO}) "
f"and decisions nothing measured ({H45_NONE}), so all three answers are "
f"exercised", H45_POS > 0 and H45_ZERO > 0 and H45_NONE > 0)
# --- 13c. IT IS INVARIANT IN THE BATTERY, WHICH IS WHY SIZING A TRADE TO IT
# CANNOT OSCILLATE -------------------------------------------------
# Raise the charge by dW and the METER rises by dW, so `excess` rises by exactly
# dW while `batt_w` falls by dW: `wanted` does not move. The headroom is a
# FEED-FORWARD number - a function of the house, the ceiling and the seconds
# already banked - so consuming it adds no feedback path around the plant.
# Without this property the whole fix would be one loop replacing another.
H45_INV, H45_EX = [], []
for _bw in (-LIVE_MAX_W, -2000.0, -500.0, 0.0, 500.0, 2000.0):
# ONE banked history, and the battery's change visible only in the HELD
# value - which is what a command given a moment ago actually looks like.
_d = _decide_on(PeakTracker.project(
_avg_held(_H45_LOAD, _H45_EL, _H45_LOAD - _bw),
B + timedelta(seconds=_H45_EL)), batt_w=_bw)
H45_INV.append(charge_headroom_w(_d))
H45_EX.append(round(_d.excess_w, 1))
print(f" same house, same banked quarter, battery {-LIVE_MAX_W:+.0f}.."
f"{2000.0:+.0f} W: headroom {[round(v, 3) for v in H45_INV]}")
print(f" ...while the EXCESS those runs see is {H45_EX}")
check("the headroom does NOT move when the battery moves across the whole "
"range of what it can do - it is a property of the HOUSE and the "
"CEILING, not of what the battery happens to be doing, which is what "
"makes a trade sized from it feed-forward rather than a second loop "
"around the plant",
max(H45_INV) - min(H45_INV) < 1.0)
check(f"...and the fixture SEPARATES it from the two quantities it is built "
f"out of: the battery moved {LIVE_MAX_W + 2000.0:.0f} W and the excess "
f"with it, over {len(set(H45_EX))} distinct values, so an implementation "
f"answering `-batt_w` or `abs(excess)` would give six different answers "
f"here (habit 4)",
len(set(H45_EX)) == len(H45_EX)
and len(set(round(v, 3) for v in H45_INV)) == 1)
# --- 13d. AND A BOUNDARY HANDS IT BACK AT THE AFFORDABLE RATE, NOT AT max_w --
# ⚠️ SAFETY-45's SECOND HALF, AND THE CLAIM IS NOT "IT DOES NOT CHANGE".
# `project()` resets to the held level at `elapsed = 0`, and the headroom
# collapses with it - from 162 kW to 900 W here. What the card is about is WHAT
# IT COLLAPSES TO: the house and the cap, and nothing else. The behaviour being
# removed re-armed at `max_w` regardless of either.
_H45_END = charge_headroom_w(_decide_at(_H45_LOAD, 895.0, batt_w=0.0))
_H45_TOP = charge_headroom_w(_decide_at(_H45_LOAD, 0.0, batt_w=0.0))
print(f" across a boundary, same house: {_H45_END:.0f} W with 5 s of the "
f"old quarter left -> {_H45_TOP:.0f} W with the new one 0 s old, against "
f"a max_w of {LIVE_MAX_W:.0f} W")
check(f"at the top of the new quarter the headroom is exactly the ceiling less "
f"the house ({CAP_W:.0f} - {_H45_LOAD:.0f} = {_H45_GAP:.0f} W). A trade "
f"refused at 14:59 resumes at 15:00 at the rate the new quarter can "
f"afford - the projection resets, this does not reset TO max_w",
near(_H45_TOP, _H45_GAP, 1.0) and _H45_TOP < LIVE_MAX_W)
check("...and the check is not passing because nothing moved: the headroom on "
"the other side of that boundary is two orders of magnitude larger, and "
"the PROJECTION really did reset",
_H45_END > 100.0 * _H45_TOP and _proj_at(_H45_LOAD, 0.0).elapsed_s < 1.0)
# --- 13e. the unmeasured answers, and None is NOT unlimited -----------------
_h45_nobatt = _decide_at(_H45_LOAD, _H45_EL, batt_w=None)
_h45_blind = _decide_at(_H45_LOAD, _H45_EL, batt_w=0.0, frozen=True)
print(f" no battery reading: {charge_headroom_w(_h45_nobatt)}; "
f"BLIND: {charge_headroom_w(_h45_blind)}; no decision at all: "
f"{charge_headroom_w(None)}")
check("a guard that never ran answers None, not a number - `meter_source: off` "
"reaches this with no decision at all and a caller that read it as a "
"large headroom would be guard_state()'s None-is-not-False collapse one "
"layer down", charge_headroom_w(None) is None)
check("...and a cycle with NO BATTERY MEASUREMENT answers None too: there is "
"nothing to size a floor from, so there is nothing to size a trade from "
"either", charge_headroom_w(_h45_nobatt) is None
and _h45_nobatt.wanted_w is None)
check("...and BLIND answers 0.0 - a NUMBER, and the number is no charging. "
"The claim it files already says exactly that at `floor 0 W`, and this "
"agrees with it rather than restating it",
charge_headroom_w(_h45_blind) == 0.0 and _h45_blind.blind
and _h45_blind.floor_w == 0.0)
check("...and the three are TOLD APART: blind is 0.0 and the two unmeasured "
"states are None, so a caller can refuse for the reason that is true "
"rather than for whichever fired first",
charge_headroom_w(_h45_blind) is not None
and charge_headroom_w(_h45_nobatt) is None)
# --- 13f. the SoC floor does not become charging permission -----------------
# ⚠️ THE ONE ENGAGED STATE WHERE THE GUARD IS DELIBERATELY NOT DISCHARGING.
# SAFETY-24's floor-limited decision files `lo = 0.0` - "charging stays
# blocked" - on a quarter that IS over the ceiling. If that read as headroom,
# an empty pack in a blown quarter would be told to buy.
_h45_lim = _decide_at(CAP_W + 2000.0, _H45_EL, batt_w=0.0, soc=SOC_FLOOR - 1.0)
print(f" floor-limited over a blown quarter: engaged={_h45_lim.engaged}, "
f"floor_limited={_h45_lim.floor_limited}, wanted="
f"{_h45_lim.wanted_w:+.0f} W, headroom={charge_headroom_w(_h45_lim)}")
check("a FLOOR-LIMITED decision - the guard wanted a discharge and SoC refused "
"to size it - answers 0.0 headroom. The quarter is over the ceiling; the "
"pack being empty is not a reason to fill it from the grid in it",
_h45_lim.floor_limited and _h45_lim.engaged
and charge_headroom_w(_h45_lim) == 0.0)
# --- 13g. decide() ITSELF IS UNCHANGED, PINNED -----------------------------
# ⚠️ MUTATION.md habit 4, in the direction a diff cannot cover. The whole safety
# argument of this card is "the 2026-08-20 rung is still there and part 6 still
# grades it". That is a claim about the SOURCE, so it is pinned on the source -
# and on the BEHAVIOUR, because a shape pin alone would pass a rung that had
# been inverted.
_H45_SRC = (HERE / "app" / "peakguard.py").read_text(encoding="utf-8")
_H45_TREE = ast.parse(_H45_SRC)
_h45_fn = next(n for n in ast.walk(_H45_TREE)
if isinstance(n, ast.FunctionDef) and n.name == "decide")
_H45_RUNG = [n for n in ast.walk(_h45_fn) if isinstance(n, ast.If)
and ast.unparse(n.test) == "wanted <= 0.0"]
check("the `wanted <= 0.0` rung is still in decide(), spelled exactly that "
"way, on the AST rather than as a substring - it is the rung whose "
"removal put 2026-08-20's quarter 49 W over the limit after seventeen "
"engage/release cycles in 900 s", len(_H45_RUNG) == 1)
check("...and it still DISENGAGES rather than merely reporting: the fixture "
"13a is built on reaches it and files no claim, so the pin above is not "
"grading a rung that has been inverted under it",
peakguard.claim(_h45_idle) is None)
_h45_head_fn = next(n for n in _H45_TREE.body if isinstance(n, ast.FunctionDef)
and n.name == "charge_headroom_w")
check("...and charge_headroom_w() is a PURE READ of the decision - one "
"positional parameter, no projection, no clock and no options - so it "
"cannot become a second answer to the question decide() already answers",
[a.arg for a in _h45_head_fn.args.args] == ["decision"]
and not _h45_head_fn.args.kwonlyargs)
# ==============================================================================
print()
shutil.rmtree(TMP, ignore_errors=True)