Merge release/1.0 (TARIFF-10) into TARIFF-14
checks / unit (pull_request) Successful in 7m10s

CI on PR #80 was NOT a test failure. The workflow's "grade the merge, not
the branch" step refused the PR because it does not apply, and said so
itself: "REFUSED: refs/pull/80/head does not merge into release/1.0.
NOTHING HAS BEEN TESTED. This is not a test failure - no test ran."
Conflicts were CHANGELOG.md and test_price.py; app/price.py auto-merged.

CONFLICTS RESOLVED

* CHANGELOG.md - both entries belong under ## Unreleased. TARIFF-10's
  table row and section come first because it landed first. No version
  number is picked here; PKG-06/07's note stands untouched.

* test_price.py - BOTH cards appended a section "13". Nothing is
  dropped: TARIFF-10's stays 13, this card's is renumbered 14, and
  TARIFF-10's temp-directory teardown stays at the end of the file. The
  two share no fixture, option dict or helper name.

WHAT TARIFF-10 CHANGES FOR THIS CARD - nothing in the reasoning, and the
check is recorded rather than asserted (decision note §7).

In app/price.py the two do not meet: TARIFF-10 added provider_id() and
provider_configured() beside price_option() and routed load_prices()
through them - the option-reading layer, touching nothing in _Series,
at(), covers(), window() or _cover_table(). The merged at() is
byte-identical to the one the mutations were driven against.

Behaviourally TARIFF-10 makes run_tariff_refresh() run with arbitrage
OFF, so a provider-configured site now has a standing plan - and
therefore a _Series - where before it had none. It does NOT add an at()
consumer to the 30 s tick: static_status() builds a STATIC plan (hourly,
immune), main.py calls only plan.words on the fetched plan, and
price_forecast.price_permits()/begin_phase() have no caller in main.py
at all. So TARIFF-10 neither creates nor closes any exposure this card
is about - it enlarges the defect's reach the moment such a site arms
trading, which strengthens the fix without changing it.

RE-GRADED ON THE MERGED TREE, not carried over

* all 29 test files green;
* all EIGHT mutations re-driven, purged before each run, each reverted
  with the fingerprint re-checked. Same verdicts and same red-check
  counts as before the merge: M1 (the shipped one-hour scan, +68 B) red
  on 24 named checks across both files; M3 (the ZERO-BYTE tail rule) red
  on 4; M2/M4/M6 on 1 each, M5 on 2, M7 on 16, M8 on 14.
* Clean app/price.py on the merged tree: 90d8ca31f7c0109d, 85953 B.
* None of TARIFF-10's 650 new lines of checks moves under any of the
  eight - they grade resolve_tariff()'s reachability from a
  configuration, not the lookup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
glenn schrooyen
2026-09-12 21:38:28 +02:00
co-authored by Claude Opus 5
11 changed files with 1271 additions and 48 deletions
+90 -1
View File
@@ -17,8 +17,96 @@ if it has, it needs 0.10.0. Whoever deploys decides.
| _this branch_ | FCST-06 - "cannot persist" stops meaning "has no /data" and starts meaning what it says |
| _this branch_ | TARIFF-08 - the day-ahead feed is read in the shape it actually publishes, and the fixtures stop agreeing with the parser |
| _this branch_ | TARIFF-09 - the day-ahead feed is asked for the horizon it was given, so tomorrow arrives firm instead of carried |
| _this branch_ | TARIFF-10 - configuring a price provider is what fetches prices; trading stays its own switch |
| _this branch_ | TARIFF-14 - the price a trade is decided against is the price of the interval being traded |
### TARIFF-10 - a configured provider is fetched, whether or not you trade
**`amain()` started the background tariff refresh only when
`arbitrage_enabled` was on, and that option ships `false`.**
`run_tariff_refresh` is the only caller of `resolve_tariff()`, which is the
only caller of `price_history.carry()` - so on a shipped install with a price
provider configured **nothing was ever fetched**: no plan resolved,
`/data/price_history.json` never created, the *Price history* status row
reading `empty` for the life of the install, and TARIFF-09's short-horizon
warning unable to fire because there was no fetch to warn about.
Measured over a 40 h soak of two instances side by side on the same feed:
| | shipped default + provider | same, arbitrage armed |
|---|---|---|
| feed requests | **0** | fetching |
| `/data/price_history.json` | `present: false` on **all 802 samples** | written |
| resolved plan | none | 132 points |
⚠️ **`DOCS.md` has described the fixed behaviour since the commit that wrote
the gate.** OPT-04 (`bdd7f18`) added the refresh task, the
`arbitrage.enabled()` guard on it, and the sentence *"turning a provider on
still fetches, validates and publishes prices and changes no charging
decision"* — all in one change. `config.yaml` has said the same since
TARIFF-02 (`852fc7c`). The documentation and the code have therefore disagreed
for as long as both existed, and nothing compared them. `config.yaml` and both translations carried the same
claim, plus a second one that had gone stale in the other direction (*"nothing
consumes the price series yet"*, untrue since OPT-04). All four now say what
the add-on does.
**The gate is `tariff_refresh_wanted()`, and it is a STRICT WIDENING.**
```
provider_configured(opts) or arbitrage.enabled(opts)
```
Every configuration that started the refresh before still starts it. The second
term is not redundant and is not there for symmetry: `price_provider: off` with
a `static_profile` and `arbitrage_enabled: true` is a configuration
`validate.py` explicitly **accepts**, and it trades - measured, at
`cycle_cost_threshold_eur: 0.05` on a 0.20/0.284 EUR/kWh day/night profile,
`ARBITRAGE_CHARGE -5000 W`. Gating the refresh on the provider alone would
leave that site's `tariff_plan` at `None` forever and answer *"no tariff plan
has been resolved yet"* on every cycle - silently disarming a strategy the UI
says is running.
⚠️ **Nothing acquires a periodic HTTPS call from this.** With `price_provider`
off, `resolve_tariff()` answers out of the static profile without opening a
socket; and the true shipped default - no provider, no trading - starts **no
task at all**. What decides whether a request leaves the machine is
`price_provider`, and only that.
⚠️ **AND NOTHING TRADES THAT DID NOT TRADE BEFORE.** `cycle()`'s
`if arbitrage.enabled(self.o):` is untouched. Graded directly rather than
inferred from the gate having moved: `Controller.cycle()` is driven on a
resolved provider plan whose cheapest firm hour is now, with the peak guard
asserted clear, and `arbitrage_enabled` is the single option that moves between
the rows.
| `arbitrage_enabled` | cheapest hour is now | commanded |
|---|---|---|
| `false` | yes | **+180 W** (`loop -> 180 W`) |
| `false` | no | **+180 W** (`loop -> 180 W`) |
| `true` | yes | -5000 W (`arbitrage -> -5000 W`) |
| `true` | no | +5000 W (`arbitrage -> 5000 W`) |
**`TariffPlan.cheap_now()` is deliberately NOT wired, and that is now a
decision rather than an inheritance.** A provider-backed plan exists on every
configured site from this commit, so *Cheap window* - which feeds the
maintenance cycle's grid top-up, i.e. the one place a price reaches something
that **moves energy** without trading being armed - could now read it. It does
not: `self.cheap` still comes from `static_status()`, and on a provider-only
site (`static_profile` empty) that is `False` on every cycle, so no paid grid
top-up is ever forced. Wiring it would take that from *never* to *sometimes*.
`DECISIONS/TARIFF-10-fetching-is-not-trading.md` §4 carries the argument and
the list of what adopting it would take.
**The check that would have caught this** is `test_price.py` section 13, and it
grades **reachability from a configuration**, not the shape of an `if`. The
guards in `amain()` are *evaluated* against real option dicts, whatever they are
spelled as; the call graph under `app/` is walked to see whether the tasks those
guards start can arrive at `resolve_tariff()`, wherever it has moved to; and
then the fetch is **driven** with a counting transport, because a graph is an
argument and a counted request is an observation. The shipped defect - the gate
back at `arbitrage.enabled(opts)`, **-4 B** - is red on seven named checks.
### TARIFF-14 - the trade was decided against the wrong price
**On the only price feed that works, `_Series.at()` returned the wrong point,
@@ -95,7 +183,7 @@ nothing about duration), and its docstring now says so.
**Nothing about §9.3 or a point's `start` changed.** `PricePoint` still has
three fields; the span is derived from the neighbours, not stored.
Graded by `test_price.py` section 13 - 900 s, 3600 s, **mixed**, holes, the
Graded by `test_price.py` section 14 - 900 s, 3600 s, **mixed**, holes, the
tail, caller order, `covers()`/`window()` - and end to end by
`test_arbitrage.py` section 10, which drives `decide()` over a 96-point
quarter-hourly day whose cheapest and dearest quarters are deliberately **not**
@@ -103,6 +191,7 @@ on a whole hour. Eight mutations, all red, including a **zero-byte** one on the
tail rule; the table with byte deltas and the checks that were NOT reached is
in the decision note §6.
### TARIFF-09 - the feed is asked for the range the caller wanted
**`generic_dayahead` took `since` and `until` and requested the bare feed URL,