Files
goodwe-addon/goodwe_controller/DECISIONS/TARIFF-10-fetching-is-not-trading.md
adminandClaude Opus 5 e6c45a61e7
checks / unit (pull_request) Successful in 7m19s
TARIFF-10: configuring a price provider is what fetches prices
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
2026-09-12 20:58:48 +02:00

15 KiB

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.