SAFETY-45: a price-driven charge is sized by the capacity guard, not switched by it #88
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user