checks / unit (pull_request) Successful in 7m19s
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 two-instance soak on one feed: 0 requests and `present: false` across all
802 samples, against a resolved 132-point plan on the instance with arbitrage
armed.
git log -S puts the refresh task, its arbitrage.enabled() guard AND the DOCS.md
sentence saying a configured provider still fetches in the same commit - OPT-04,
bdd7f18; config.yaml has carried the claim since TARIFF-02, 852fc7c. The prose
and the code disagreed from the moment both existed and nothing compared them.
config.yaml and both translations additionally carried a claim that had rotted
the other way - "nothing consumes the price series yet", untrue since OPT-04
shipped the consumer. All four surfaces now say what the add-on does, and a
check pairs DOCS against the DRIVEN behaviour so they cannot drift again.
The gate is tariff_refresh_wanted() = provider_configured(opts) or
arbitrage.enabled(opts), and the `or` is a STRICT WIDENING: every configuration
that started the task before still starts it. The second term is not there for
symmetry. price_provider: off + a static_profile + 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 day/night profile,
ARBITRAGE_CHARGE -5000 W. Gating 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. With price_provider off, resolve_tariff()
answers out of the static profile without opening a socket (driven and counted:
0 requests), 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. The literal reading of the ticket's AC2 - drop the `or` - is one
token and is recorded in DECISIONS §3 with what it costs, for the owner.
AND NOTHING TRADES THAT DID NOT TRADE BEFORE. cycle()'s
`if arbitrage.enabled(self.o):` is untouched, and that is graded directly rather
than inferred from the gate having moved: Controller.cycle() is driven on a
resolved provider plan with the peak guard asserted clear, and arbitrage_enabled
is the only option that moves between the rows - false/false give +180 W
("loop -> 180 W") on both the cheap-now and dear-now plans, true/true give
-5000 W and +5000 W ("arbitrage -> ...").
TariffPlan.cheap_now() is deliberately NOT wired, decided rather than inherited
(DECISIONS §4). A provider-backed plan now exists on every configured site, so
Cheap window could read it - and Cheap window feeds the maintenance cycle's grid
top-up, the one place a price moves energy without trading being armed. On a
provider-only site static_status() returns False on every cycle, so adopting it
would take a paid grid top-up from never to sometimes. self.cheap stays on
static_status(); what adoption would take is listed rather than left implied.
THE CHECK THAT WOULD HAVE CAUGHT THIS is test_price.py section 13, and it grades
REACHABILITY FROM A CONFIGURATION rather than the shape of an `if` - the `if` was
exactly what somebody meant to write. amain()'s guards 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 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. What the
instrument cannot see is printed on every run, largest hole first.
Evidence: 29/29 green. 13 mutations in 14 runs - M10 re-run as M10b once it was
closed - each applied to a fresh cp -a of the clean worktree rather than
reverted (MUTATION.md habit 18), purged, :ro, -B. The
shipped defect - the gate back at arbitrage.enabled(opts), -4 B - is red on
seven named checks. Dropping the `or` (-27 B) is red on the strict-widening row;
removing cycle()'s trading gate (+8 B) is red on both AC3 rows. The instrument
is mutated too (habit 16): _reaches() always True and _task_sites() returning []
are both +0 B and both red.
Two findings are recorded rather than rounded off. provider_id() dropping
.strip() (-8 B) was a real SURVIVOR across four price suites - nothing fed a
padded provider id - and is closed in this commit by the padded-id pair; and the
first draft of the AC3 fixture fed five minutes of P1 telegrams, so whether the
peak guard was blind depended on where the wall clock sat inside the billing
quarter and the armed baseline failed to trade on some runs. It is 900 s now,
and the guard's clear state is a check rather than an assumption.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
278 lines
15 KiB
Markdown
278 lines
15 KiB
Markdown
# TARIFF-10 — fetching a price and trading on one are two decisions
|
|
|
|
**Status: DECIDED 2026-09-12 — Option A. If a price provider is configured,
|
|
prices are fetched. Trading stays a separate switch (`arbitrage_enabled`), and
|
|
this card moves nothing that moves energy.**
|
|
|
|
Filed with TARIFF-10. Follows [[TARIFF-09]] and [[FCST-04]]; §4 settles the
|
|
limitation FCST-04 recorded and did not decide.
|
|
|
|
---
|
|
|
|
## 1. The defect, and why it was invisible
|
|
|
|
`app/main.py`'s `amain()` read:
|
|
|
|
```
|
|
if arbitrage.enabled(opts):
|
|
tasks.append(asyncio.create_task(controller.run_tariff_refresh()))
|
|
```
|
|
|
|
`run_tariff_refresh` is the **only** caller of `resolve_tariff()`, which is the
|
|
**only** caller of `price_history.carry()`. `arbitrage_enabled` ships `false`.
|
|
|
|
So on a shipped install with a provider configured:
|
|
|
|
* no HTTPS request was ever made to the feed;
|
|
* no plan was ever resolved, so `self.tariff_plan` stayed `None` forever;
|
|
* `/data/price_history.json` was never created;
|
|
* the *Price history* status row read `empty` for the life of the install;
|
|
* TARIFF-09's short-horizon warning could not fire, because it is logged on a
|
|
fetch and there was no fetch.
|
|
|
|
Measured, not inferred — a 40 h soak, two instances, one feed:
|
|
|
|
| | shipped default + provider | same, arbitrage armed |
|
|
|---|---|---|
|
|
| feed requests | **0** | fetching |
|
|
| `/data/price_history.json` | `present: false`, **802/802 samples** | written |
|
|
| resolved plan | none | 132 points |
|
|
|
|
⚠️ **AND FOUR SURFACES ALREADY DESCRIBED THE FIXED BEHAVIOUR.** `DOCS.md`
|
|
§*Energy price providers*: *"With `arbitrage_enabled: false`, which is the
|
|
shipped default, turning a provider on still fetches, validates and publishes
|
|
prices and changes no charging decision."* `config.yaml` and both translations
|
|
said the same, and additionally carried a claim that had gone stale in the other
|
|
direction — *"nothing consumes the price series yet"*, untrue since OPT-04
|
|
shipped the consumer. The code was the only surface that disagreed with all of
|
|
them, and no check compared the two.
|
|
|
|
The dating is worth being exact about, because it says this was never a
|
|
regression. `git log -S` puts the refresh task, its `arbitrage.enabled()` guard
|
|
**and** the DOCS sentence in the *same commit* — OPT-04, `bdd7f18`;
|
|
`config.yaml`'s version of the claim goes back further, to TARIFF-02,
|
|
`852fc7c`. The prose and the code disagreed from the moment both existed.
|
|
|
|
## 2. Why a check on the `if` would not have caught it
|
|
|
|
The `if` was exactly what somebody meant to write, and its comment argued for
|
|
it honestly:
|
|
|
|
> ⚠️ STARTED ONLY WHEN ARBITRAGE IS ARMED. Nothing else in the add-on reads the
|
|
> resolved plan … so on a default install this task would be network traffic in
|
|
> aid of an attribute nobody reads.
|
|
|
|
That was true when OPT-04 wrote it and stopped being true when FCST-04 gave the
|
|
plan a *store* and a *status row*. A pin on the expression would have gone red
|
|
on the fix and green on the defect. What was wrong is the **set of
|
|
configurations from which a fetch is reachable**, so that is what
|
|
`test_price.py` section 13 grades:
|
|
|
|
1. the guards on every `asyncio.create_task` in `amain()` are **evaluated**
|
|
against real option dicts — whatever the guard is spelled as;
|
|
2. the call graph under `app/` is walked to see whether the coroutines those
|
|
guards start can arrive at `resolve_tariff()` — wherever it has moved to;
|
|
3. and then the fetch is **driven**, with a counting transport, a real
|
|
`Controller` and a real store path, because a reachability graph is an
|
|
argument and a counted request is an observation.
|
|
|
|
⚠️ **Step 3 grades the gate only because it starts the task the way `amain()`
|
|
would.** The driver asks `_fetchers(opts)` — the evaluated guard — before it
|
|
calls the coroutine. Measured: with the shipped defect restored **and** that one
|
|
line defeated (`if False and "run_tariff_refresh" not in names:`), the four
|
|
driven rows go **green** while only the structural rows stay red. Without it
|
|
they are decoration, which is MUTATION.md's habit 4 in a harness.
|
|
|
|
What the instrument **cannot** see is printed on every run: a coroutine started
|
|
by anything but `create_task`; a guard that is not an enclosing `if` in
|
|
`amain()`; anything outside `app/`; and — because the graph is keyed by **name**
|
|
— two same-named functions as one node, which makes every *reachable* answer an
|
|
**upper** bound and every *not reachable* answer the strong one.
|
|
|
|
## 3. The gate, and why it is an `or`
|
|
|
|
```
|
|
def tariff_refresh_wanted(opts: dict) -> bool:
|
|
return provider_configured(opts) or arbitrage.enabled(opts)
|
|
```
|
|
|
|
The first term is what this card adds. **The second term is the old gate, kept
|
|
verbatim, and it is not there for symmetry.**
|
|
|
|
`price_provider: off` + a `static_profile` + `arbitrage_enabled: true` is a
|
|
configuration `validate.py` explicitly **accepts** (rule 7 refuses only the
|
|
no-prices-at-all case), and it **trades**. Measured on this branch:
|
|
|
|
```
|
|
opts: price_provider off, arbitrage_enabled true,
|
|
cycle_cost_threshold_eur 0.05,
|
|
static_profile 0.20 night / 0.284 day
|
|
plan : static_profile from static_profile, 48 h, 48 points
|
|
WITH the plan -> ARBITRAGE_CHARGE -5000.0 W
|
|
"cheapest firm hour at 0.2000 EUR/kWh, 0.0840 below the
|
|
dearest and clear of the 0.0750 this cycle had to cover"
|
|
WITHOUT the plan -> None "no tariff plan has been resolved yet"
|
|
```
|
|
|
|
Gating the refresh on the provider **alone** — which is the literal reading of
|
|
the ticket's second acceptance criterion — would leave that site's
|
|
`tariff_plan` at `None` forever and answer *"no tariff plan has been resolved
|
|
yet"* on every cycle. That is a strategy the UI says is running and that cannot
|
|
fire: the exact state `validate.py`'s OPT-04 rules exist to refuse, arrived at
|
|
from inside the code instead of from the configuration.
|
|
|
|
So the gate is a **strict widening**: every configuration that started the task
|
|
before still starts it, and `test_price.py` grades that as its own row rather
|
|
than leaving it to prose.
|
|
|
|
⚠️ **The AC's stated harm is still fully met, and it is worth being exact about
|
|
this rather than quietly declaring victory.** The criterion reads *"with
|
|
`price_provider: off`: no fetch task at all, whatever `arbitrage_enabled` says
|
|
— an operator with no provider must not acquire a periodic HTTPS call."* The
|
|
letter of it is not met in one configuration (provider off, trading armed),
|
|
because that configuration keeps the task it already had. The harm it names is:
|
|
|
|
* with `price_provider` off, `resolve_tariff()` returns `static_plan()` — pure
|
|
arithmetic, **no socket**. Driven and counted: **0 requests**.
|
|
* the true shipped default — no provider, no trading — starts **no task at
|
|
all**.
|
|
|
|
Nobody *acquires* a periodic HTTPS call from this card, and the only thing that
|
|
decides whether a request leaves the machine is `price_provider`. **If the
|
|
owner wants the literal reading instead, it is a one-token change** (drop the
|
|
`or` term) **and it costs the site in the measurement above its trading.** That
|
|
is the trade, stated rather than chosen silently.
|
|
|
|
## 4. `TariffPlan.cheap_now()` — available, and deliberately NOT consumed
|
|
|
|
**Decision: do not wire it in this card. `self.cheap` stays on
|
|
`static_status()`.**
|
|
|
|
FCST-04 recorded that `cheap_now()` has no production caller on the provider
|
|
plan: `main.read_inputs()` assigns `self.cheap, self.tariff_words =
|
|
static_status(o, now)`, which builds its **own** static plan and calls
|
|
`cheap_now()` on that. Under Option A a provider-backed plan now exists on every
|
|
configured site, so the limitation stops being structural and becomes a choice.
|
|
|
|
**It is not cosmetic, and that is the whole reason for the decision.**
|
|
`self.cheap` is read in `cycle()` by the maintenance cycle's cheap-window grid
|
|
top-up — the add-on's one shiftable load, and the one place a *price* reaches
|
|
something that **moves energy** on an install that has not armed trading.
|
|
|
|
⚠️ **And the direction it would move is not "a slightly different window".**
|
|
`static_status()` returns `(False, "no static profile")` whenever
|
|
`static_profile` is empty, so today a provider-only site — a plausible shape
|
|
for a dynamic-tariff install, and the shape this card makes work — has
|
|
`self.cheap = False` on **every** cycle and never forces a paid grid top-up at
|
|
all. Wiring `cheap_now()` would take that from *never* to *sometimes*. That is
|
|
a strictly larger behaviour change than this card's headline, and it belongs to
|
|
whoever owns the maintenance cycle rather than to the ticket that made it
|
|
reachable.
|
|
|
|
Three further reasons, in the order they would bite:
|
|
|
|
1. **Two sources, one attribute, and the loser is silent.** `validate.py`
|
|
already refuses `static_profile` alongside the Home Assistant price
|
|
entities *because* `read_inputs()` picks one and the other disappears. A
|
|
provider plan would be a third writer of the same attribute with no rule
|
|
saying which wins.
|
|
2. **The freshness question is not answered for this consumer.** The plan
|
|
retires after `TARIFF_MAX_AGE_S` and `fresh_tariff_plan()` returns `None`.
|
|
Arbitrage has an answer for that (refuse). The maintenance top-up has none:
|
|
"the plan expired" would have to mean *not cheap*, and *not cheap* on this
|
|
path means **wait for sun**, which is a real decision nobody has taken.
|
|
3. **The comparison basis changes, and so does the answer on a bad feed.**
|
|
`cheap_now()` is strictly-below-the-mean-of-the-**firm**-hours, and it is
|
|
`False` when THIS hour is provisional or is not in the series at all
|
|
(FCST-03's rule: this hour decides, not the whole plan). So a feed that
|
|
published nothing, or an hour the §8.3 fill could not cover, reads *not
|
|
cheap* where a `static_profile` reads whatever the clock says. Defensible
|
|
either way — and it is a change to the shipped maintenance cycle that needs
|
|
its own measurement rather than a side effect of this one.
|
|
|
|
**What adopting it would take**, so the next card does not start from scratch:
|
|
|
|
* a rule for which source wins when both a provider plan and a
|
|
`static_profile` are configured, enforced in `validate.py` the way the
|
|
entity/profile conflict already is;
|
|
* a decision on what an **expired** or **absent** plan means for the grid
|
|
top-up, stated as a default rather than left to `None`;
|
|
* `read_inputs()` splitting `self.cheap` from `self.tariff_words`, which are
|
|
currently one assignment from one function;
|
|
* a check that the maintenance top-up actually fires and does not fire on the
|
|
provider plan — driven through `cycle()`, not asserted on the attribute;
|
|
* and the *Tariff* status row, which FCST-04 pinned as a **pair** with
|
|
`DOCS.md` in `test_price_history.py` §9: it does not report the provider
|
|
plan, DOCS does not claim it does, and moving one without the other is
|
|
already red.
|
|
|
|
## 5. The *Arbitrage* status row, re-read and left alone
|
|
|
|
`main.status_payload()` renders `("Arbitrage", "off (arbitrage_enabled)" if
|
|
self.arb is None else self.arb.explain())`. `self.arb` is assigned only inside
|
|
`if arbitrage.enabled(self.o):` and `arbitrage.decide()` never returns `None`,
|
|
so `self.arb is None` **iff** the option is off. The string still names the
|
|
option that is actually off, and under Option A it gains a job it did not have:
|
|
on a site with a provider configured it is the row that tells an operator the
|
|
prices arriving on the *Price history* row above are moving nothing. Unchanged,
|
|
with a note at the call site saying it was checked.
|
|
|
|
## 6. What this card did not do
|
|
|
|
* It did not touch `run_tariff_refresh()`'s body (TARIFF-12 is there).
|
|
* It did not change `cycle()`'s `if arbitrage.enabled(self.o):`, the sizing,
|
|
the thresholds, the SoC bounds or the peak interlock.
|
|
* It did not wire the provider plan into the *Tariff* status row or into
|
|
`self.cheap` — §4.
|
|
* It did not pick a version number (PKG-06).
|
|
|
|
## 7. Mutation ledger
|
|
|
|
Every mutation applied to a **fresh `cp -a`** of the clean worktree rather than
|
|
reverted (habit 18: there is no revert to verify and no shared scratch path),
|
|
`__pycache__` purged, tree mounted `:ro`, run under `python3 -B`.
|
|
|
|
| id | mutation | Δ bytes | result |
|
|
|---|---|---|---|
|
|
| M1 | the shipped defect: gate back to `arbitrage.enabled(opts)` | **-4 B** | **RED**, 7 named checks |
|
|
| M2 | drop the `or arbitrage.enabled(opts)` term | -27 B | **RED**, 2 named checks (strict-widening + the static-profile row) |
|
|
| M3 | `tariff_refresh_wanted` -> always `True` | +8 B | **RED**, the AC2 reachability row |
|
|
| M4 | `provider_configured`: `"off"` counts as configured | -6 B | **RED**, the AC2 reachability row |
|
|
| M5 | `cycle()`'s trading gate -> `if True or arbitrage.enabled(self.o)` | +8 B | **RED**, both AC3 rows (`-5000 W` with trading off) |
|
|
| M6 | instrument: `_reaches()` always `True` | **+0 B** | **RED**, 4 checks incl. its own calibration row |
|
|
| M7 | instrument: `_task_sites()` returns `[]` | **+0 B** | **RED**, 10 checks incl. "it found 0 sites" |
|
|
| M8 | fixture: 5 minutes of telegrams instead of a whole quarter | **+0 B** | **RED**, the peak-guard precondition and both armed rows |
|
|
| M9 | M1 **plus** the harness's guard-driven start defeated | -4 B | the four DRIVEN rows go **GREEN** — see §2 |
|
|
| M10 | `provider_id()` drops `.strip()` | -8 B | **survivor**, then CLOSED — see below |
|
|
| M11 | `provider_configured()` -> `return True` | -32 B | **RED**, 4 checks |
|
|
| M12 | DOCS.md's sentence reverted to the old, vaguer claim | +0 B (docs) | **RED**, the AC4 pairing row (`fetches = True, DOCS says = False`) |
|
|
| M13 | the Dutch translation left behind while English moved | +0 B (docs) | **RED**, the both-languages row |
|
|
|
|
⚠️ **M10 was a real survivor and is recorded as one.** Dropping `.strip()` left
|
|
`test_price.py`, `test_price_history.py`, `test_static_tariff.py` and
|
|
`test_validate.py` all green: nothing in the repository fed a `price_provider`
|
|
with surrounding whitespace, and the `.strip()` predates this card — it was
|
|
carried into `provider_id()` from the three call sites it replaced. The
|
|
Supervisor's `list(off|…)` schema cannot produce `" off "`, but
|
|
`validate_startup_config()` is also `sim/inject.py`'s entry point and takes any
|
|
dict at all. Closed in the same commit by the padded-id pair in section 13;
|
|
re-run as **M10b**, red on two named checks.
|
|
|
|
⚠️ **M8 is the fixture defect this card walked into and then fenced.** The
|
|
first draft fed five minutes of telegrams. Whether that **covers** the current
|
|
billing quarter depends on where the wall clock sits inside it, so the peak
|
|
guard was blind and engaged on some runs and not others — and `decide()` refuses
|
|
outright while the guard is holding (§9.1 over §10.1). On those runs the armed
|
|
baseline did not trade, and "trading off never trades" would have been graded
|
|
on a fixture in which nothing could trade either way. It is now 900 s of
|
|
telegrams, and the guard's clear state is a **check** rather than an assumption.
|
|
|
|
## 8. What is still open
|
|
|
|
* The literal reading of AC2 (§3). One token, and it costs a measured trading
|
|
configuration its plan. **Owner's call.**
|
|
* `cheap_now()` (§4). Owner's call, with the adoption list above.
|
|
* The *Tariff* status row still shows the static-profile sentence on a
|
|
provider-backed site; the provider plan's own line goes to the log. Pinned as
|
|
a pair with `DOCS.md` by FCST-04 and untouched here.
|