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
+53 -8
View File
@@ -681,15 +681,16 @@ floor of 60 s that figure is 60 W and only the margin is left.
### Energy price providers
> **No *fetched* price moves energy in 0.5.x.** This release ships the
> *plumbing*: the five provider plugins, the canonical all-in format they emit,
> and the config that selects one. Turning a provider on fetches, validates and
> publishes prices and changes no charging decision; the arbitrage strategy that
> would use it is a later ticket.
> **A fetched price moves energy only when you switch arbitrage on.** This
> release ships the five provider plugins, the canonical all-in format they
> emit, the config that selects one, and — new in OPT-04 — a price-arbitrage
> strategy that trades on them. With `arbitrage_enabled: false`, which is the
> shipped default, turning a provider on still fetches, validates and publishes
> prices and changes no charging decision.
>
> The one exception is `static_profile` below. A static day/night tariff needs
> no network, so its cheap window is computed on every cycle and does reach the
> maintenance cycle's grid top-up.
> `static_profile` below needs no network at all, so its cheap window is
> computed on every cycle and reaches the maintenance cycle's grid top-up
> whether or not arbitrage is armed.
| option | meaning |
|---|---|
@@ -841,6 +842,50 @@ to, and the site simply has no tariff until the feed returns. It is not required
— the add-on warns rather than refusing — but filling it in is what makes that
fallback work.
### Price arbitrage (`arbitrage_enabled`)
§10.1 priority 3: charge in the cheapest published hour, discharge in the
dearest, **and only when the spread covers what the round trip costs.** Off by
default — this is the first strategy in the add-on that commands the battery
*against* the meter on purpose, so it is armed deliberately or not at all.
| option | meaning |
|---|---|
| `arbitrage_enabled` | `false` by default. Arms the strategy |
| `cycle_cost_threshold_eur` | What one full round trip costs per kWh — pack wear plus conversion losses. A spread below this is not traded |
| `arbitrage_soc_floor` | The reserve arbitrage may not spend, % |
| `arbitrage_soc_ceiling` | The headroom it may not overfill, % |
**What it needs, and what the add-on refuses.** Arbitrage requires a price
source (`price_provider` or `static_profile`) and a P1 meter (`meter_source`).
Without a price source there is nothing to tell cheap from expensive; without
P1 the capacity-tariff peak guard never runs, and §9.1 puts peak shaving above
arbitrage — a charge that nothing measures is exactly what that guard exists to
prevent. Both combinations are **refused at startup** rather than left looking
armed.
**Peak shaving always wins.** The strategy cannot obtain a spread at all
without first answering whether the peak guard is holding, and even if it
could, the guard's floor is filed at a higher arbiter priority and clamps an
arbitrage charge. Discharging is never capped by it — only offtake is billed.
**It never exports into a negative price** (§17). A negative hour is the best
possible hour to *charge* in and that is still allowed; selling into one gives
energy away and pays for the privilege.
**At the shipped threshold a static day/night site does not trade.** The
realistic Belgian day/night spread is about 0.08 EUR/kWh and the default
threshold is 0.10, so the honest answer on such a site is "no opportunity", and
the status page says so with the numbers. Whether 0.10 is the right default is
a product decision, not a bug: lower it only if you know what a cycle costs
your pack.
**The prices are fetched in the background**, never on the control tick — a
supplier's TLS timeout on that path would be a supplier able to push the add-on
towards the firmware's 30 s failsafe. The plan refreshes every 30 minutes and
retires itself if the refresh stops answering, at which point arbitrage stops
with it.
### Site
| option | meaning |