TARIFF-10: configuring a price provider is what fetches prices
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
This commit is contained in:
2026-09-12 20:58:48 +02:00
co-authored by Claude Opus 5
parent 88c59a9918
commit e6c45a61e7
10 changed files with 1188 additions and 21 deletions
+660
View File
@@ -51,7 +51,9 @@ import io
import json
import logging
import os
import shutil
import sys
import tempfile
from datetime import date, datetime, timedelta, timezone
from textwrap import dedent
@@ -67,6 +69,10 @@ from app.price import (
volatility_regime,
)
from app import price as _price
from app import arbitrage as _arb
from app import main as _main
from app.p1 import make_sample, split_signed
from app.static_tariff import TariffPlan
from app.validate import validate_startup_config
fails = []
@@ -1945,6 +1951,660 @@ check("'off' is not refused, on any of the other configurations",
# --------------------------------------------------------------------------- #
print()
print("13. TARIFF-10 - a configured provider is FETCHED, and trading is still "
"its own switch")
# WHAT SHIPPED, AND WHY THIS SECTION IS NOT A CHECK ON AN `if`. `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()`, and `arbitrage_enabled` ships FALSE.
# So on a shipped install with a provider configured NOTHING was fetched, no
# plan was resolved, /data/price_history.json was never created, the *Price
# history* row read `empty` for the life of the install, and TARIFF-09's
# short-horizon warning could not fire because no fetch happened to warn about.
# Measured over a 40 h two-instance soak: zero feed requests and
# `present: false` across all 802 samples.
#
# ⚠️ A CHECK THAT PINS THE `if` WOULD NOT HAVE CAUGHT IT, because the `if` was
# EXACTLY WHAT SOMEBODY MEANT TO WRITE. What was wrong is the SET OF
# CONFIGURATIONS FROM WHICH A FETCH CAN BE REACHED, so that is what is graded:
# 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 been
# moved to), and then the fetch is DRIVEN and the requests counted, because a
# reachability graph is an argument and a counted request is an observation.
# -- the predicate the gate is built on, before anything is wired to it -------
# ⚠️ ONE SPELLING, AND IT NOW HAS THREE READERS. `str(price_option(opts,
# "price_provider") or "off").strip()` was written out TWICE - in
# `load_prices()` and in `static_tariff.resolve_tariff()` - and main.py's
# startup gate would have been a third. "Is a provider configured" is now a
# DECISION rather than a detail, because it is what starts a background task,
# so the three readers have to agree about the edge cases and `provider_id()`
# is the one place they can. (`validate.py` keeps its own reader on purpose: it
# validates the RAW options blob and must not route through the defaults.)
check("`provider_configured()` is False on the shipped default, on an explicit "
"'off', and on a key that is absent, empty or None - `price_option()` "
"maps the last three onto the default and none of them may read as a "
"configured feed",
not any(_price.provider_configured(o) for o in
({}, {"price_provider": "off"}, {"price_provider": ""},
{"price_provider": None})))
check("...and True on each shipped plugin id, so it is a predicate and not a "
f"constant {sorted(PROVIDERS)}",
all(_price.provider_configured({"price_provider": pid})
for pid in PROVIDERS))
# ⚠️ WHITESPACE IS THE EDGE THE `.strip()` IS FOR, AND NOTHING GRADED IT. 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 - and dropping the `.strip()` left all four price suites green
# (measured, -8 B) until this pair was written.
check("...and a padded id is normalised on both sides of the decision: "
"' off ' is still off, and ' generic_dayahead ' is still that plugin",
_price.provider_id({"price_provider": " off "}) == "off"
and not _price.provider_configured({"price_provider": " off "})
and _price.provider_id({"price_provider": " generic_dayahead "})
== "generic_dayahead"
and _price.provider_configured(
{"price_provider": " generic_dayahead "}))
check("...and `load_prices()` answers `no_data` for exactly the dicts "
"`provider_configured()` calls unconfigured, so the startup gate and the "
"fetch cannot disagree about what 'a provider is set' means",
all((run(load_prices(o, datetime(2026, 1, 1, tzinfo=UTC),
datetime(2026, 1, 2, tzinfo=UTC),
transport=None)).kind == "no_data")
== (not _price.provider_configured(o))
for o in ({}, {"price_provider": "off"},
{"price_provider": " off "},
{"price_provider": "manual_csv",
"price_csv_path": CSV_PATH})))
def _coroutine_name(node):
"""`controller.run_tariff_refresh()` -> `run_tariff_refresh`."""
if isinstance(node, ast.Call):
if isinstance(node.func, ast.Attribute):
return node.func.attr
if isinstance(node.func, ast.Name):
return node.func.id
return ast.unparse(node)
def _task_sites(node, guards=()):
"""Every `create_task(...)` under `node`, with the `if`s that guard it.
The guard is kept as SOURCE and evaluated later against an options dict, so
this reports what the tree says rather than what any one ticket expected to
find there. An `else` branch is recorded as `not (<test>)`.
"""
out = []
for child in ast.iter_child_nodes(node):
if isinstance(child, ast.If):
test = ast.unparse(child.test)
for stmt in child.body:
out += _task_sites(stmt, guards + (test,))
for stmt in child.orelse:
out += _task_sites(stmt, guards + (f"not ({test})",))
continue
if isinstance(child, ast.Call) and \
ast.unparse(child.func).endswith("create_task"):
if child.args:
out.append((guards, _coroutine_name(child.args[0])))
continue
out += _task_sites(child, guards)
return out
def _call_graph(app_dir):
"""name -> the names it calls, over every module in `app/`.
⚠️ IT IS KEYED BY NAME AND THEREFORE OVER-APPROXIMATES, AND THAT IS THE
SAFE DIRECTION HERE. Two functions called `run` in two modules are one node;
a method call on an unrelated object joins the same node. So a `reachable`
answer is an upper bound and a `NOT reachable` answer is the strong one -
which is why every positive row below is also DRIVEN, with the requests
counted, rather than being left to this graph (MUTATION.md habit 13).
"""
graph = {}
for name in sorted(os.listdir(app_dir)):
if not name.endswith(".py"):
continue
tree = ast.parse(open(os.path.join(app_dir, name),
encoding="utf-8").read())
for node in ast.walk(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
called = set()
for inner in ast.walk(node):
if isinstance(inner, ast.Call):
fn = inner.func
if isinstance(fn, ast.Attribute):
called.add(fn.attr)
elif isinstance(fn, ast.Name):
called.add(fn.id)
graph.setdefault(node.name, set()).update(called)
return graph
def _reaches(graph, start, target):
"""Can `start` arrive at `target` through the call graph?"""
seen, stack = set(), [start]
while stack:
cur = stack.pop()
if cur in seen:
continue
seen.add(cur)
for nxt in graph.get(cur, ()):
if nxt == target:
return True
stack.append(nxt)
return False
def _started(sites, opts):
"""[(coroutine, guards, verdict)] for `opts`. Verdict True/False/'?'.
`'?'` is a guard this cannot decide from the options alone - `p1_source is
not None` is one - and it is reported rather than assumed either way. A
fetch-reaching task whose guard is undecidable would make every row below
vacuous, so that is a check of its own.
"""
out = []
for guards, name in sites:
verdict = True
for guard in guards:
try:
answer = bool(eval(guard, vars(_main), {"opts": opts}))
except NameError:
verdict = "?"
break
if not answer:
verdict = False
break
out.append((name, guards, verdict))
return out
_MAIN_TREE = derived(lambda: ast.parse(open(os.path.join(_APP, "main.py"),
encoding="utf-8").read()))
_AMAIN = derived(lambda: next(n for n in ast.walk(_MAIN_TREE)
if isinstance(n, ast.AsyncFunctionDef)
and n.name == "amain"))
_SITES = derived(lambda: _task_sites(_AMAIN))
_GRAPH = derived(lambda: _call_graph(_APP))
_FETCH_ENTRY = "resolve_tariff"
print(f" create_task sites found in amain(): "
f"{[(n, list(g)) for g, n in _SITES]}")
print(f" call graph: {len(_GRAPH)} named functions across app/")
print(" ⚠️ what this instrument CANNOT see, largest hole first: a coroutine "
"started by anything other than `create_task` (gather, ensure_future, a "
"thread, a timer); a guard that is not an enclosing `if` in amain() (a "
"while, a try/except, an early return above the site, or a refusal "
"INSIDE the coroutine); any module 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.")
# -- the instrument is calibrated before anything is graded with it (habit 1) --
check(f"the scanner finds amain()'s create_task sites at all - it found "
f"{len(_SITES) if not isinstance(_SITES, _Raised) else _SITES}, and at "
f"zero every row below would be vacuously true",
not isinstance(_SITES, _Raised) and len(_SITES) >= 2)
check(f"...and the call graph can tell reaching from not-reaching: "
f"`run_tariff_refresh` arrives at {_FETCH_ENTRY!r} and `run_control` - "
f"the control tick, which must never await a fetch - does not. A graph "
f"that answered the same for both would pass every row below however the "
f"gate was wired",
_reaches(_GRAPH, "run_tariff_refresh", _FETCH_ENTRY)
and not _reaches(_GRAPH, "run_control", _FETCH_ENTRY))
def _fetchers(opts):
"""The coroutines amain() STARTS, for `opts`, that can reach the feed."""
return sorted(n for n, _, v in _started(_SITES, opts)
if v is True and _reaches(_GRAPH, n, _FETCH_ENTRY))
def _undecidable_fetchers(opts):
return sorted(n for n, _, v in _started(_SITES, opts)
if v == "?" and _reaches(_GRAPH, n, _FETCH_ENTRY))
# The four configurations. `_T10_BASE` carries no price keys at all, so the
# shipped defaults in PRICE_OPTION_DEFAULTS are what answer - which is the
# install the defect was measured on.
_T10_BASE = {"setpoint_entity": "number.sp", "max_w": 5000,
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "maintenance_charge_w": 2000,
"maintenance_discharge_w": 2000}
_T10_PROFILE = json.dumps({"periods": [
{"name": "night", "days": "all", "from": "22:00", "to": "07:00",
"eur_per_kwh": 0.20},
{"name": "day", "days": "all", "from": "07:00", "to": "22:00",
"eur_per_kwh": 0.284}]})
_T10_CASES = (
("shipped default: no provider, no trading", {}, False),
("PROVIDER configured, trading OFF (the defect)",
{"price_provider": "generic_dayahead"}, True),
("PROVIDER configured, trading ON",
{"price_provider": "generic_dayahead", "arbitrage_enabled": True}, True),
("no provider, trading ON on a static profile",
{"arbitrage_enabled": True, "static_profile": _T10_PROFILE}, True),
)
print(" startup reachability of resolve_tariff(), by configuration:")
for _label, _over, _want in _T10_CASES:
print(f" {'reachable' if _fetchers(dict(_T10_BASE, **_over)) else '- '}"
f" {_label}")
check(f"the shipped defaults really do leave `arbitrage_enabled` off "
f"({PRICE_OPTION_DEFAULTS['arbitrage_enabled']!r}) and `price_provider` "
f"off ({PRICE_OPTION_DEFAULTS['price_provider']!r}) - the precondition "
f"every row below varies from, and if a later card changes either "
f"default these rows stop meaning what they say. Section 1 is what pins "
f"this dict to config.yaml",
PRICE_OPTION_DEFAULTS["price_provider"] == "off"
and PRICE_OPTION_DEFAULTS["arbitrage_enabled"] is False
and not _arb.enabled(dict(_T10_BASE))
and not _price.provider_configured(dict(_T10_BASE)))
check(f"AC1: with a `price_provider` set and `arbitrage_enabled` at its shipped "
f"default, the startup path REACHES the feed "
f"{_fetchers(dict(_T10_BASE, price_provider='generic_dayahead'))}. This "
f"is the shipped defect: it read `[]`, so nothing was ever fetched",
_fetchers(dict(_T10_BASE, price_provider="generic_dayahead")) != [])
check(f"AC2: with `price_provider: off` and trading off - the true shipped "
f"default - NO task reaches the feed "
f"{_fetchers(dict(_T10_BASE))}, so an install that asked for no prices "
f"acquires no periodic work from this card",
_fetchers(dict(_T10_BASE)) == [])
check("...and the guard on the fetch-reaching task is decidable from the "
"OPTIONS alone. A gate moved onto a local variable would make every row "
"in this section vacuous rather than red",
_undecidable_fetchers(dict(_T10_BASE, price_provider="generic_dayahead"))
== [])
# ⚠️ THE `or` IS LOAD-BEARING AND THIS IS THE ROW THAT SAYS SO. `price_provider:
# off` + a `static_profile` + `arbitrage_enabled: true` is a configuration
# validate.py ACCEPTS and that trades (measured: ARBITRAGE_CHARGE -5000 W at
# `cycle_cost_threshold_eur: 0.05` on a 0.20/0.284 day/night profile). Gating
# the refresh on the provider ALONE would leave that site's `tariff_plan` at
# None forever and silently disarm a strategy the UI says is running.
_T10_OLD = {lbl: _arb.enabled(dict(_T10_BASE, **over))
for lbl, over, _ in _T10_CASES}
_T10_NEW = {lbl: bool(_fetchers(dict(_T10_BASE, **over)))
for lbl, over, _ in _T10_CASES}
print(f" old gate (arbitrage.enabled alone) -> {list(_T10_OLD.values())}")
print(f" shipped gate -> {list(_T10_NEW.values())}")
check(f"the gate is a STRICT WIDENING: every configuration that started the "
f"refresh before still starts it, so nothing was taken away from a live "
f"install {[l for l in _T10_OLD if _T10_OLD[l] and not _T10_NEW[l]]}",
all(_T10_NEW[l] for l in _T10_OLD if _T10_OLD[l]))
check("...and it is a widening rather than a no-op: at least one configuration "
"fetches now that did not before, which is the whole ticket",
any(_T10_NEW[l] and not _T10_OLD[l] for l in _T10_OLD))
# -- and the fetch is DRIVEN, because a graph is an argument and a counted ----
# -- request is an observation (habit 4) -------------------------------------
_CAPTURE = derived(lambda: json.load(open(
os.path.join(HERE, "scenarios",
"energy-charts-be-price-2026-09-09_2026-09-10-twoday.json"),
encoding="utf-8")))
def _restamped_body(now):
"""The COMMITTED capture, with only its instants moved onto `now`.
⚠️ THE SHAPE AND THE PRICES ARE THE CAPTURE'S (TARIFF-08, habit 19). What
is synthetic is the CLOCK and nothing else: `run_tariff_refresh` reads the
wall clock and there is no seam to inject one, so a 2026-09-09 capture
would fall outside every horizon it asks for. Section 2b replays the
capture unmodified against the parser; this one is grading the WIRING.
"""
base = int(now.timestamp()) // 3600 * 3600
doc = dict(_CAPTURE)
doc["unix_seconds"] = [base + 900 * i
for i in range(len(_CAPTURE["price"]))]
return json.dumps(doc).encode()
class _T10Hass:
async def limits(self, entity):
return (-5000.0, 5000.0, 1.0)
async def number(self, entity, invert=False):
return {"sensor.grid": 0.0, "sensor.soc": 55.0,
"sensor.batt": 0.0}.get(entity)
async def set_number(self, entity, value):
return True
async def call(self, domain, service, data):
return True
class _T10Store:
data = {}
def set(self, *a):
pass
def get_time(self, key):
return None
class _T10Pub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
def _drive_startup(over):
"""Start exactly what amain() would start for `opts`, and watch the wire.
⚠️ THE TASK IS STARTED ONLY IF THE SHIPPED GUARD SAYS SO. That is the whole
point: the coroutine is not called because this file knows its name, it is
called because `_fetchers()` evaluated amain()'s own guard against these
options. A gate that goes back to `arbitrage.enabled(opts)` leaves the
coroutine uncalled and the request count at zero.
⚠️ AND IT RUNS UNDER A DEADLINE (habit 5). The refresh loop is infinite by
design; the failure mode of a mutation that never resolves a plan is a HANG,
which reads like flaky infrastructure rather than like a named check.
"""
opts = dict(_T10_BASE, **over)
tmp = tempfile.mkdtemp(prefix="tariff10-")
_tmpdirs_t10.append(tmp)
store_path = os.path.join(tmp, "price_history.json")
seen = []
async def _counting_get(url, timeout_s):
seen.append(url)
return _restamped_body(datetime.now(timezone.utc))
real_get = _price.https_get
_price.https_get = _counting_get
try:
async def _go():
stop = asyncio.Event()
ctl = _main.Controller(opts, _T10Hass(), _T10Store(), _T10Pub(),
stop=stop,
peak_db_path=os.path.join(tmp, "tariff.db"),
price_history_path=store_path)
names = _fetchers(opts)
if "run_tariff_refresh" not in names:
# Nothing amain() starts reaches the feed. Anything ELSE that
# does is a task this harness cannot drive, and it is a FAILURE
# rather than a shrug - see the check below.
return ctl, names
task = asyncio.create_task(ctl.run_tariff_refresh())
for _ in range(250): # ~5 s ceiling
if ctl.tariff_at is not None:
break
await asyncio.sleep(0.02)
ctl.stopping = True
stop.set()
await asyncio.wait_for(task, timeout=5)
return ctl, names
ctl, names = asyncio.run(_go())
finally:
_price.https_get = real_get
try:
n_points = len(list(ctl.tariff_plan.read.points))
except Exception: # noqa: BLE001 - Absence
n_points = 0
return {"requests": seen, "started": names,
"store_present": os.path.exists(store_path),
"points": n_points,
"history": ctl.price_history.words}
_tmpdirs_t10 = []
_on = derived(lambda: _drive_startup({"price_provider": "generic_dayahead"}))
_off = derived(lambda: _drive_startup({}))
_static_armed = derived(lambda: _drive_startup(
{"arbitrage_enabled": True, "static_profile": _T10_PROFILE}))
print(f" provider + trading OFF -> {len(_on['requests'])} request(s), "
f"store written {_on['store_present']}, {_on['points']} point plan, "
f"Price history row: {_on['history'][:70]!r}")
print(f" no provider -> {len(_off['requests'])} request(s), "
f"store written {_off['store_present']}, "
f"Price history row: {_off['history'][:40]!r}")
check(f"AC1, DRIVEN: a shipped-default install with a provider configured "
f"actually ASKS THE FEED - {len(_on['requests'])} request(s), "
f"{_on['requests'][:1]}. The soak measured ZERO here over 40 hours",
len(_on["requests"]) >= 1)
check(f"...and a plan RESOLVES from it: {_on['points']} points, against the "
f"{len(_CAPTURE['price'])} the capture publishes",
_on["points"] == len(_CAPTURE["price"]))
check(f"...and /data/price_history.json is WRITTEN - present={_on['store_present']}. "
f"The soak measured `present: false` on all 802 samples",
_on["store_present"] is True)
check(f"...and the *Price history* status row POPULATES rather than reading "
f"`empty` forever: {_on['history'][:60]!r}",
isinstance(_on["history"], str)
and not _on["history"].startswith("empty")
and not _on["history"].startswith("unavailable"))
check(f"AC2, DRIVEN: `price_provider: off` issues NO request and creates no "
f"store - {len(_off['requests'])} request(s), "
f"present={_off['store_present']}",
_off["requests"] == [] and _off["store_present"] is False)
check(f"...and neither does the one configuration that keeps its task without "
f"a provider - trading armed on a static profile makes "
f"{len(_static_armed['requests'])} request(s), because "
f"`resolve_tariff()` answers a provider-off site out of the static "
f"profile without opening a socket. NOBODY acquires a periodic HTTPS "
f"call from this card",
_static_armed["requests"] == []
and "run_tariff_refresh" in _static_armed["started"])
check(f"...and no OTHER startup task can reach the feed - if one appears, this "
f"harness cannot drive it and every driven row above stops covering the "
f"startup path {_on['started']}",
_on["started"] == ["run_tariff_refresh"])
# -- AC3, and it is the property this card is most dangerous to: ARBITRAGE -----
# -- OFF STILL MEANS NO TRADE, EVER. Graded by DRIVING `Controller.cycle()` ---
# -- with a provider plan resolved - the state TARIFF-10 newly creates - and ---
# -- moving exactly one option (habit 7). ------------------------------------
#
# ⚠️ THE BASELINE IS THE HALF THAT MAKES THIS A CHECK. "No trade" is satisfied
# by a fixture in which nothing could have traded anyway - a flat series, a SoC
# outside the bounds, a peak guard that never ran - and a check like that stays
# green however the gate is wired. So the SAME plan, the SAME SoC, the SAME P1
# history and the SAME threshold are run with `arbitrage_enabled: true`, and
# that run must COMMAND THE BATTERY. If the armed row stops trading, the
# unarmed row stops being evidence and both go red together.
def _t10_sample(when, net_w):
"""One P1 telegram through the shipped derivation."""
imp, exp = split_signed(net_w)
return make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=when, ingest_mono=when.timestamp())
class _T10TradeHass(_T10Hass):
def __init__(self):
self.writes = []
async def set_number(self, entity, value):
self.writes.append(float(value))
return True
def _t10_plan(now, cheap_now):
"""A 24 h FIRM series whose cheapest hour is now, or whose dearest is.
Firm, because refusal 3 drops provisional hours from the trade set and a
plan of carried hours cannot trade whatever the gate says - which would
make the armed baseline below vacuous exactly as a flat series would.
"""
top = now.replace(minute=0, second=0, microsecond=0)
lo, hi = 0.05, 0.60
return TariffPlan(
PriceRead.series(
[PricePoint(start=top + timedelta(hours=i),
eur_per_kwh=(lo if (i < 3) == cheap_now else hi),
provisional=False)
for i in range(24)],
PriceProduct.DAY_AHEAD, "generic_dayahead", now),
None, "provider")
def _t10_trade_cycle(armed, cheap_now):
"""One real `Controller.cycle()` on a resolved provider plan."""
tmp = tempfile.mkdtemp(prefix="tariff10t-")
_tmpdirs_t10.append(tmp)
opts = dict(_T10_BASE, meter_source="ha_dsmr", meter_entity="sensor.grid",
heartbeat_s=10, price_provider="generic_dayahead",
cycle_cost_threshold_eur=0.10, arbitrage_enabled=armed)
async def _go():
hass = _T10TradeHass()
ctl = _main.Controller(opts, hass, _T10Store(), _T10Pub(),
stop=asyncio.Event(),
peak_db_path=os.path.join(tmp, "tariff.db"),
price_history_path=os.path.join(tmp, "ph.json"))
ctl.auto = True
ctl.dev_min, ctl.dev_max = -5000.0, 5000.0
now = datetime.now(timezone.utc)
# ⚠️ A WHOLE QUARTER OF TELEGRAMS, AND FIVE MINUTES IS NOT ENOUGH.
# Without any, `p1_enabled` is on, `inputs_ok` is False and cycle()
# returns before it reaches the strategy at all. With only five
# minutes, whether the quarter is COVERED depends on where the wall
# clock happens to sit inside it: run late in a quarter and the guard
# is blind, engages conservatively, and `decide()` refuses for §9.1's
# reason instead of trading - which would make the armed baseline
# below vacuous on some runs and not others. Measured: the same four
# rows came back `peak guard is holding` at one time of day and
# `ARBITRAGE_CHARGE` at another. 900 s at the shipped 5 s telegram
# cadence covers any quarter from any starting phase.
for k in range(181):
ctl.p1.submit(_t10_sample(now - timedelta(seconds=5 * (180 - k)),
300.0))
ctl.tariff_plan, ctl.tariff_at = _t10_plan(now, cheap_now), now
await ctl.read_inputs()
ctl.tariff_plan, ctl.tariff_at = _t10_plan(now, cheap_now), now
await ctl.cycle()
return ctl, hass
ctl, hass = asyncio.run(_go())
return {"arb": None if ctl.arb is None else ctl.arb.action,
"target": ctl.target, "reason": ctl.reason,
"writes": hass.writes,
"guard": None if ctl.peak_guard is None else ctl.peak_guard.engaged}
_T10_TRADE = derived(lambda: {(a, c): _t10_trade_cycle(a, c)
for a in (False, True) for c in (True, False)})
for _key in sorted(_T10_TRADE, key=str):
_r = _T10_TRADE[_key]
print(f" arbitrage_enabled={str(_key[0]):5s} cheapest hour is now="
f"{str(_key[1]):5s} -> arb={str(_r['arb']):16s} "
f"commanded {_r['target']:+.0f} W, reason {_r['reason'][:40]!r}")
# ⚠️ THE PRECONDITION, AND IT IS THE MASKING HAZARD IN THIS FIXTURE (habit 1).
# `decide()` refuses outright while the peak guard is engaged, §9.1 outranking
# §10.1 - so a fixture whose guard is holding answers "no trade" in EVERY row,
# the armed one included, and the AC3 rows below would grade the guard rather
# than the gate. It is asserted, not assumed.
check(f"the peak guard is CLEAR in all four rows, so what refuses a trade "
f"below is the arbitrage gate and not §9.1's interlock "
f"{ {k: _T10_TRADE[k]['guard'] for k in sorted(_T10_TRADE, key=str)} }",
all(_T10_TRADE[k]["guard"] is False for k in _T10_TRADE))
check("the ARMED baseline actually trades on the plan TARIFF-10 now resolves - "
"it charges in the cheap hour and discharges in the dear one. Without "
"this row, 'trading off never trades' would be satisfied by a fixture "
"that could not have traded either way",
_T10_TRADE[(True, True)]["arb"] == "ARBITRAGE_CHARGE"
and _T10_TRADE[(True, False)]["arb"] == "ARBITRAGE_DISCHARGE")
check(f"...and the site is actually COMMANDED there, which is the only "
f"observable that moves energy: {_T10_TRADE[(True, True)]['target']:+.0f} W "
f"on the cheap hour against "
f"{_T10_TRADE[(True, False)]['target']:+.0f} W on the dear one",
_T10_TRADE[(True, True)]["target"] < -1000.0
and _T10_TRADE[(True, False)]["target"] > 1000.0)
check(f"AC3: with `arbitrage_enabled: false` and the SAME resolved provider "
f"plan, the strategy is never consulted and NOTHING trades - "
f"arb={_T10_TRADE[(False, True)]['arb']!r} / "
f"{_T10_TRADE[(False, False)]['arb']!r} in both directions",
_T10_TRADE[(False, True)]["arb"] is None
and _T10_TRADE[(False, False)]["arb"] is None)
check(f"...and the command is the LOOP's, not a trade that merely went "
f"unnamed: {_T10_TRADE[(False, True)]['target']:+.0f} W / "
f"{_T10_TRADE[(False, False)]['target']:+.0f} W, reason "
f"{_T10_TRADE[(False, True)]['reason'][:30]!r} - the same small "
f"self-consumption figure on both plans, where the armed rows differ by "
f"10 kW between them",
all(abs(_T10_TRADE[(False, c)]["target"]) < 1000.0
and "arbitrage" not in _T10_TRADE[(False, c)]["reason"]
for c in (True, False)))
check("...and the trading gate is untouched by this card: it is still "
"`arbitrage.enabled()` and it is still the only thing the price "
"strategy is gated on - the four rows above differ in that option and "
"in nothing else",
_arb.enabled({"arbitrage_enabled": True})
and not _arb.enabled({})
and not _arb.enabled({"price_provider": "generic_dayahead"}))
# -- AC4: the four surfaces that DESCRIBE this, pinned as a PAIR with it ------
# ⚠️ THIS IS A TEXT CHECK AND IT IS WEAK ON PURPOSE (test_translations.py §3's
# argument). It cannot know a sentence is TRUE. What it CAN do is refuse to let
# the code and the prose move independently: the left side of each pairing is
# DRIVEN above, the right side is read off the file, and the two must agree. It
# shipped as False against True - the docs described Option A for as long as
# both existed - and an overclaim in a doc is a defect in this repository.
_T10_DOCS = derived(lambda: open(os.path.join(HERE, "DOCS.md"),
encoding="utf-8").read())
_T10_CFG = derived(lambda: open(os.path.join(HERE, "config.yaml"),
encoding="utf-8").read())
_T10_TR = derived(lambda: {lang: open(os.path.join(HERE, "translations",
f"{lang}.yaml"),
encoding="utf-8").read()
for lang in ("en", "nl")})
_T10_FETCHES = bool(_on["requests"])
_T10_DOCS_SAYS = "selecting a provider is what starts the fetch" \
in _T10_DOCS.lower()
check(f"AC4: the BEHAVIOUR and DOCS.md agree about what a configured provider "
f"does on a shipped-default install - it fetches = {_T10_FETCHES}, DOCS "
f"says it fetches = {_T10_DOCS_SAYS}. These two are the pair that has to "
f"move together",
_T10_FETCHES == _T10_DOCS_SAYS)
# The claim that had rotted in the OTHER direction: OPT-04 shipped the
# consumer, so "nothing consumes the price series yet" had been false for a
# release before this card made the first half false too.
_T10_STALE = {"config.yaml": "consumes\n # the series yet" in _T10_CFG
or "nothing consumes the price series" in _T10_CFG,
"en.yaml": "nothing consumes the price series yet"
in _T10_TR["en"],
"nl.yaml": "er is nog niets dat de prijsreeks gebruikt"
in _T10_TR["nl"]}
check(f"...and no surface still says the price series has no consumer - untrue "
f"since OPT-04, and it sat beside the claim this card is fixing in all "
f"three files {[k for k, v in _T10_STALE.items() if v]}",
not any(_T10_STALE.values()))
check("...and BOTH translations tell an operator which switch does which - "
"the provider option says it is what starts the fetch, and the trading "
"option says turning it off does not turn the feed off. One language "
"updated and not the other is how this surface rots",
"STARTS THE PRICE FETCH" in _T10_TR["en"]
and "DOES NOT TURN THE PRICE FEED OFF" in _T10_TR["en"]
and "HET OPHALEN VAN PRIJZEN START" in _T10_TR["nl"]
and "ZET DE PRIJSFEED NIET UIT" in _T10_TR["nl"])
# --------------------------------------------------------------------------- #
for _d in _tmpdirs_t10:
shutil.rmtree(_d, ignore_errors=True)
print()
if fails:
print(f"{len(fails)} FAILED: " + "; ".join(fails))