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

CHANGELOG.md was the only conflict: both entries belong under the same
`## Unreleased` heading, TARIFF-10's first because it merged first. No
version number picked.

app/main.py auto-merged, and was re-read because of it. TARIFF-10 changed
WHICH installs run `run_tariff_refresh()` (`arbitrage.enabled(opts)` ->
`tariff_refresh_wanted(opts)`); this card changed what that task does on
a failed fetch. Nothing in the keep/replace reasoning changed, and the
two newly reachable configurations are now DRIVEN in test_arbitrage.py
8b rather than reasoned about:

  * a provider configured with trading DISARMED is the case the guard
    was designed for, and needs no second fixture because nothing on
    this path reads `arbitrage_enabled` at all - asserted over the AST's
    symbols and non-docstring constants, after two drafts of that check
    went red on a CORRECT tree for matching the word "arbitrage" in a
    docstring and in a log line;
  * `price_provider: off` + a `static_profile` resolves to the
    CONFIGURED product (`origin: "static"`), so `standing.from_feed` is
    false on every refresh and the guard never latches - driven twice in
    a row, because a guard that latched there would freeze a static
    site's plan at its first window for the life of the install.

Also: the kept-plan log line said "arbitrage stops with it", which after
TARIFF-10 is false on a site that fetches without trading; it now says
"arbitrage, where it is armed, stops with it". The keep decision is
strictly downstream of `resolve_tariff()`, so the §8.3 carry into
price_history - TARIFF-10's whole point - is unaffected either way.

All 29 suites green on the merged tree, including TARIFF-10's new
test_price.py section 13. All eleven mutations RE-DRIVEN on the merged
tree rather than carried over: eleven red, no survivors, every revert
fingerprinted. M11 is new - making the guard conditional on
`arbitrage.enabled(self.o)` (+86 B) is red on five named checks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
Claude
2026-09-12 21:31:27 +02:00
12 changed files with 1348 additions and 26 deletions
+86 -9
View File
@@ -67,7 +67,7 @@ from .mqtt import (MqttPublisher, announced_units, unit_slugs,
from . import peakguard
from . import packshare
from .p1 import P1Ingest, build_source, is_enabled
from .price import Uncertainty, price_option
from .price import Uncertainty, price_option, provider_configured
from .price_history import PriceHistory
from .static_tariff import resolve_tariff, static_status
from .tariff import PeakStore, PeakTracker
@@ -138,6 +138,60 @@ TARIFF_REFRESH_S = 1800.0
TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S
def tariff_refresh_wanted(opts: dict) -> bool:
"""TARIFF-10. Does this install need the background tariff refresh at all?
THE DEFECT THIS PREVENTS, and it shipped: the gate below used to be
`arbitrage.enabled(opts)` alone. That option defaults to `false`, and
`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
provider configured NOTHING was ever fetched, no plan was ever resolved,
`/data/price_history.json` was never created, the *Price history* row read
`empty` forever, and TARIFF-09's short-horizon warning could not fire
because there was no fetch to warn about. Measured over a 40 h soak of two
instances side by side: zero feed requests and `present: false` across all
802 samples on the shipped default, against a resolved plan from the same
feed on the instance with arbitrage armed. ⚠️ AND `DOCS.md` HAS
DESCRIBED THE BEHAVIOUR BELOW SINCE THE COMMIT THAT WROTE THE GATE:
OPT-04 (bdd7f18) added the refresh task, the `arbitrage.enabled()` guard on
it, and the sentence saying a configured provider still fetches, all in one
change. config.yaml has said it since TARIFF-02 (852fc7c).
Two terms, and they answer two different questions:
provider_configured(opts) -> "the operator asked for a price feed", so
fetch it, validate it and record the day
(§8.3). This is the term TARIFF-10 ADDS.
arbitrage.enabled(opts) -> "the operator armed trading". This term is
what the gate USED to be, kept verbatim.
⚠️ IT IS AN `or`, SO THE GATE IS A STRICT WIDENING. Every configuration
that started this task before still starts it; none that did not start it
stops anything. That matters because the second term is not redundant:
`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`. Dropping that term to make the gate read
`provider_configured(opts)` alone would leave `self.tariff_plan` at None on
that site forever and `decide()` would answer "no tariff plan has been
resolved yet" on every cycle - silently disarming a strategy the UI says is
running, which is the exact failure validate.py's OPT-04 rules exist to
refuse.
⚠️ AND NO CONFIGURATION ACQUIRES A PERIODIC HTTPS CALL FROM THIS TICKET.
With `price_provider` off, `resolve_tariff()` returns `static_plan()` - pure
arithmetic, no socket - so the second term starts a task that fetches
nothing. The true shipped default (no provider, no arbitrage) starts no
task at all. What is fetched is decided by the FIRST term only.
⚠️ THIS IS NOT A TRADING SWITCH. It decides whether prices are LOOKED UP.
Whether they move energy is `arbitrage.enabled()` at the one call site in
`cycle()`, and TARIFF-10 did not touch it: `arbitrage_enabled: false` still
means no trade, ever, on any configuration.
"""
return provider_configured(opts) or arbitrage.enabled(opts)
def load_options() -> dict:
try:
with open(OPTIONS_PATH, "r", encoding="utf-8") as fh:
@@ -3521,6 +3575,15 @@ class Controller:
or an outage would freeze the operator's static profile at the window
it was generated for.
⚠️ AND NOTHING HERE READS `arbitrage_enabled`, WHICH TARIFF-10 MAKES
WORTH SAYING. Since that card this task also runs on a site with a
provider configured and trading DISARMED, so "keep the plan" is a
decision about what the add-on has LOOKED UP, not about what it may
trade on. It is also strictly downstream of `resolve_tariff()`: the
§8.3 carry into `price_history` happens on the `series` branch inside
that call, before this decision exists, so which of two plans is kept
cannot add or lose a recorded day.
⚠️ AND `tariff_at` DOES NOT MOVE ON A KEPT PLAN. It is the moment the
fetch that produced the standing plan happened, and
`fresh_tariff_plan()` judges the age from it - re-stamping it here
@@ -3548,9 +3611,10 @@ class Controller:
_LOG.warning(
"tariff refresh degraded (%s); KEEPING the plan "
"resolved at %s, %.0f s ago. tariff_at is NOT moved, "
"so it retires %.0f s after that fetch and arbitrage "
"stops with it - a kept plan is never presented as a "
"current one.", plan.words, self.tariff_at,
"so it retires %.0f s after that fetch; arbitrage, "
"where it is armed, stops with it. A kept plan is "
"never presented as a current one.", plan.words,
self.tariff_at,
(now - self.tariff_at).total_seconds(),
TARIFF_MAX_AGE_S)
else:
@@ -4416,6 +4480,14 @@ class Controller:
# OPT-04. The strategy's own sentence, not a re-derivation of it -
# every refusal it can make already carries a reason a human can
# act on, and "Arbitrage: no" would throw all of them away.
#
# ⚠️ TARIFF-10 RE-READ THIS STRING AND LEFT IT ALONE, which is worth
# saying because that card is about the two switches being
# different. `self.arb` is None if and only if
# `arbitrage.enabled(self.o)` was False, so `off (arbitrage_enabled)`
# still names the option that is actually off - and on a site with a
# provider configured it is now the row that says the prices
# arriving on the *Price history* row above are moving nothing.
("Arbitrage", "off (arbitrage_enabled)" if self.arb is None
else self.arb.explain()),
]
@@ -4668,11 +4740,16 @@ async def amain() -> None:
# ESP32 heartbeat. A fetch on that path is a supplier's TLS timeout able
# to push this add-on towards layer 1's 30 s veto. See TARIFF_REFRESH_S.
#
# ⚠️ STARTED ONLY WHEN ARBITRAGE IS ARMED. Nothing else in the add-on
# reads the resolved plan - `cheap` still comes from static_status() on
# the tick, which is pure - so on a default install this task would be
# network traffic in aid of an attribute nobody reads.
if arbitrage.enabled(opts):
# ⚠️ STARTED WHEN A PROVIDER IS CONFIGURED, *OR* WHEN TRADING IS ARMED
# - TARIFF-10, and it used to be the second term alone. `arbitrage_
# enabled` ships `false`, so a site that had selected a price feed
# fetched nothing at all; the whole argument, the measurement and what
# the `or` is protecting are in `tariff_refresh_wanted()` above.
#
# ⚠️ IT DOES NOT ARM ANYTHING. Fetching a price and trading on one are
# two decisions; this is the first, and `cycle()`'s
# `if arbitrage.enabled(self.o):` is still the only gate on the second.
if tariff_refresh_wanted(opts):
tasks.append(asyncio.create_task(controller.run_tariff_refresh()))
# P1 ingestion runs as its own long-lived task. ⚠️ It must not be driven