Merge release/1.0 (OPT-04) into SAFETY-24, and fix one grader the merge broke
checks / unit (pull_request) Successful in 3m36s
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:
@@ -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` —
|
||||
|
||||
Reference in New Issue
Block a user