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:
@@ -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 = []
|
||||
@@ -1946,7 +1952,658 @@ check("'off' is not refused, on any of the other configurations",
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("13. TARIFF-14 - the point whose OWN interval covers the instant")
|
||||
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"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("14. TARIFF-14 - the point whose OWN interval covers the instant")
|
||||
# ⚠️ THE RESOLUTION IS THE WHOLE SECTION, AND ITS ABSENCE IS WHY THE DEFECT
|
||||
# SHIPPED. Every price fixture in this repository before TARIFF-08 was the
|
||||
# invented HOURLY row shape, and on an hourly series `s <= u < s + 1 h` and
|
||||
@@ -2000,7 +2657,7 @@ MIX = spaced([900] * 8 + [3600] * 3,
|
||||
MIX_TAIL = spaced([900] * 8, provisional=[False] * 8 + [True])
|
||||
ONE = spaced([])
|
||||
|
||||
print("13a. the fixtures are not degenerate - asserted before anything else")
|
||||
print("14a. the fixtures are not degenerate - asserted before anything else")
|
||||
check(f"the quarter-hourly fixture is the live shape - 96 points, spacing "
|
||||
f"{sorted(set(gaps_of(Q)))} s",
|
||||
len(Q.points) == 96 and set(gaps_of(Q)) == {900})
|
||||
@@ -2028,7 +2685,7 @@ check("...and the mixed fixtures carry BOTH firm and carried points, which is "
|
||||
for s in (MIX, MIX_TAIL)))
|
||||
|
||||
print()
|
||||
print("13b. the reading the shipped scan got wrong, stated as literals")
|
||||
print("14b. the reading the shipped scan got wrong, stated as literals")
|
||||
# ⚠️ ASSERTED AGAINST TYPED-OUT INDICES AND PRICES, not against a window this
|
||||
# file recomputed by price.py's own rule. The sweep below is exhaustive and
|
||||
# the sweep is derived; these four are the anchors that keep it honest.
|
||||
@@ -2056,7 +2713,7 @@ check("on the MIXED series the quarter-hourly half answers per QUARTER and "
|
||||
Q0 + timedelta(hours=2))
|
||||
|
||||
print()
|
||||
print("13c. the exhaustive sweep, at 900 s, 3600 s and mixed")
|
||||
print("14c. the exhaustive sweep, at 900 s, 3600 s and mixed")
|
||||
|
||||
|
||||
def windows(s):
|
||||
@@ -2100,7 +2757,7 @@ for _name, _s in (("900 s", Q), ("3600 s", H), ("mixed 900/3600", MIX),
|
||||
f"({_w if _w != [] else 'no misses'})", _w == [])
|
||||
|
||||
print()
|
||||
print("13d. the one-hour cap, which is what keeps FCST-04's holes holes")
|
||||
print("14d. the one-hour cap, which is what keeps FCST-04's holes holes")
|
||||
# ⚠️ NOT DECORATION. FCST-04 leaves an hour OUT when nothing published it and
|
||||
# yesterday has no value either, and price_forecast.py's module note names
|
||||
# `at()` raising Absence for it as the behaviour that sends the caller to the
|
||||
@@ -2176,7 +2833,7 @@ check(f"an instant inside the carried hour is covered by the CARRIED point, "
|
||||
and derived(lambda: _sand_old.provisional) is False)
|
||||
|
||||
print()
|
||||
print("13e. the LAST point's window - decided and documented, not inherited")
|
||||
print("14e. the LAST point's window - decided and documented, not inherited")
|
||||
# There is no next start and §9.3 says nothing about how wide the final
|
||||
# interval is, so THE LAST POINT IS GIVEN THE SAME SPAN AS THE POINT BEFORE IT
|
||||
# (one hour when it is the only point). The alternative - a flat one-hour tail
|
||||
@@ -2223,7 +2880,7 @@ check("a series of ONE point has no gap in front of it to copy, so it gets an "
|
||||
and raises(lambda: ONE.at(Q0 + HOUR), Absence))
|
||||
|
||||
print()
|
||||
print("13f. order is established by the lookup, not assumed of the caller")
|
||||
print("14f. order is established by the lookup, not assumed of the caller")
|
||||
# ⚠️ `at()` now reads a point's NEIGHBOURS, so it WOULD depend on sortedness.
|
||||
# The index is sorted at construction so that it cannot. `_validate_raw()`
|
||||
# refuses out-of-order and duplicate starts on the `load_prices()` path
|
||||
@@ -2260,7 +2917,7 @@ check("a DUPLICATED start does not raise anywhere - not at construction and "
|
||||
PriceProduct.DAY_AHEAD, "tariff14", Q0).at(Q0).eur_per_kwh) == 0.20)
|
||||
|
||||
print()
|
||||
print("13g. covers() shared it; window() did not")
|
||||
print("14g. covers() shared it; window() did not")
|
||||
check("covers() shared the defect EXACTLY, because it has no window rule of "
|
||||
"its own - it asks at() - so the one change fixes both. On the 900 s "
|
||||
"day it is now True 14:59 past the last point and False 15:00 past it, "
|
||||
@@ -2282,7 +2939,7 @@ check("window() did NOT share it, and the reason is worth a check rather than "
|
||||
is Q.points[0])
|
||||
|
||||
print()
|
||||
print("13h. the 'no data is not free' contract, unchanged")
|
||||
print("14h. the 'no data is not free' contract, unchanged")
|
||||
check("at() answers with a PricePoint or raises Absence - never None, at any "
|
||||
"resolution, so `series.at(t) or fallback` cannot become a price of zero",
|
||||
all(isinstance(derived(lambda s=s: s.at(s.points[0].start)), PricePoint)
|
||||
@@ -2302,6 +2959,9 @@ check("...and it never invents a neighbour: the instant one second before the "
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
for _d in _tmpdirs_t10:
|
||||
shutil.rmtree(_d, ignore_errors=True)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
||||
|
||||
Reference in New Issue
Block a user