Merge release/1.0 (OPT-04) into SAFETY-24, and fix one grader the merge broke
checks / unit (pull_request) Successful in 3m36s

OPT-04 landed after the previous rebase. Conflicts were CHANGELOG.md and one
IMPORT LINE in validate.py; both refusal blocks and both option sets survive
untouched, and DOCS.md, main.py and config.yaml auto-merged.

⚠️ AND THE MERGE BROKE A CHECK THAT NEITHER CARD COULD HAVE SEEN ALONE.
test_peakguard.py graded "the first `.decide(` call in main.py" and asserted
there was exactly one. That was true when SAFETY-24 was written; OPT-04 added
`arbitrage.decide(...)`, so the merged tree had two and the assertion failed.
It failed loudly rather than grading the wrong call - the guard did its job -
but it cannot ship red.

Fixed by selecting the call by QUALIFIED NAME (`peakguard.decide`) instead of
by position, and requiring exactly one of THAT. Other modules may now call
their own decide(); this grader can no longer be moved by one of them. Verified
still discriminating: a decoy `peakguard.decide` added to main.py turns it red
on 7 assertions, and the `maintenance_soc_floor` swap still turns it red on 6.

Also folded in the review's wording finding. An assertion read "a CONTROLLER
whose SoC entity does not answer hands decide() None" while driving no
Controller. The wiring property is stronger than the one claimed, and is now
asserted on the SERVICE CALL: a Controller with no SoC never reaches the guard
at all, because `inputs_ok` is False and cycle() stops at the safety claim
first - so a `float(soc or 100)` defaulting line above the call site cannot
disarm the floor. The original decide()-level check is kept, reworded to say
what it actually grades.

Full suite green on the merged tree: 15 files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
This commit is contained in:
glenn schrooyen
2026-08-31 07:39:40 +02:00
co-authored by Claude Opus 5
12 changed files with 1947 additions and 38 deletions
+49
View File
@@ -46,6 +46,55 @@ startup, in CFG-01/MAINT-04's manner. `maintenance_soc_floor` is deliberately
spend limit, and the rename is recorded as a decision to revisit if a third
floor ever appears.
**Price arbitrage - the first strategy that trades on a price (OPT-04).**
Four cards built the price stack and nothing traded on it. `app/arbitrage.py`
is §10.1's priority 3: charge in the cheapest published hour, discharge in the
dearest, **and only when the spread covers `cycle_cost_threshold_eur`**. It is
off by default (`arbitrage_enabled: false`) - it is the first strategy in this
add-on that commands the battery against the meter on purpose.
**It consumes the four cards rather than re-deciding anything.** Whether a
trade is permitted at all, on which two hours, and against which widened
threshold is `TariffPlan.arbitrage()`'s answer (TARIFF-03), and that answer
cannot be obtained without first stating whether the peak guard is holding. So
§9.1's "give peak shaving priority over arbitrage" is honoured
**structurally**: there is no code path to a tradeable spread that skips the
question. And because the claim is filed at `P_ARBITRAGE` (30) while peakguard
files its floor at `P_PEAK` (50), the arbiter clamps an arbitrage charge even
if that interlock were bypassed. Two independent mechanisms, neither
re-implemented here.
**A guard that never ran is not a guard that is clear.** On `meter_source: off`
there is no telegram stream, so the peak guard is never evaluated. That third
state is carried as `None` and it **refuses**: charging at the rail creates the
billed quantity while nothing measures the quarter it lands in. `validate.py`
refuses that configuration at startup too, along with arbitrage armed on a site
with no price source at all, and a SoC floor at or above the ceiling.
**§17: never export into a negative price.** The guard is on the hour being
sold into, not on the spread - a day priced -0.30 ... -0.05 has a 0.25 EUR/kWh
spread that clears any shipped threshold and a dearest hour that is still
negative. Buying in a negative hour stays allowed; being paid to charge is the
right trade.
**Prices are fetched in the background, never on the tick.** `resolve_tariff()`
may open an HTTPS connection, and the control tick is what feeds the ESP32
heartbeat - a supplier's TLS timeout there is a supplier able to push the
add-on towards layer 1's 30 s veto. A separate task refreshes every 30 minutes;
the tick reads an attribute and an age bound, and a plan whose refresh has
stopped answering retires rather than standing as if it were current.
**What is honestly not measured.** The acceptance criterion asked for
net-positivity across 12 months of *replayed* prices. This repository holds no
price history - `scenarios/` is P1 meter data - so that criterion is **not met,
and is not restated**. What `test_arbitrage.py` section 7 does instead is
replay a synthetic year and assert the property that does not need real prices:
every executed round trip cleared its own widened requirement, on all of them,
across a year containing 522 hours priced below zero and zero discharges into
one. And at the shipped 0.10 EUR/kWh threshold a realistic Belgian day/night
site **does not trade at all** - reported, not tuned. That number, and the
widening factors beside it, are one product decision and it is open.
**Provisional prices, and what may not rest on one (FCST-03).**
Day-ahead prices arrive once a day, around 14–16 h CET. Until they do, §8.3 says
the unknown hours use yesterday's profile and are flagged `provisional: true` —