checks / unit (pull_request) Successful in 3m51s
SAFETY-26's AC4 gave the same tolerance mutation to every price and quantity
boundary in arbitrage.py and static_tariff.py. Everything in arbitrage.py went
red - `price < 0`, `soc <= floor_pct`, `soc >= ceiling_pct` are each red under
+/-5. Two survived, both in static_tariff.py, and this card closes them.
D `spread == 0.0` (:912) weakened to `spread <= 0.001` SURVIVED at +2 B.
F `len(firm) < 2` (:901) weakened to `len(firm) < 1` SURVIVED at +0 B - the
delta MUTATION.md names as the risky one.
THE DEFECT IS NOT EITHER ROW, IT IS THE INTERACTION. D and F MASK EACH OTHER. A
single firm hour also has a spread of 0.0, so with F weakened the flat rung
refuses the stimulus anyway; with D weakened the small-but-real spread is still
refused, only for a lie. Each rung's failure is absorbed by the other, so BOTH
can be weakened together and a suite grading `kind == "refused"` reports
nothing. That is structurally invisible to single-mutation testing, which is how
this pair came through four rounds of review on TARIFF-03 and FCST-03 - and it
is the arrangement in which the next edit to either rung opens a real hole with
nothing to report it.
D is not cosmetic either. With the tolerance in place a series priced 0.20000 /
0.20050 - a REAL 0.0005 EUR/kWh spread - flips ALLOWED to REFUSED at
min_spread_eur_kwh=0.0, a value arbitrage()'s own docstring documents ("pass 0.0
to mean any positive spread, explicitly") and its validator at :861 accepts on
purpose. At a shipped threshold the outcome does not move, but the operator
reads the false sentence "the tariff is flat - every one of the 2 firm hours
costs 0.2000 EUR/kWh" about a tariff that is not flat.
WHAT WAS ADDED. test_static_tariff.py section 9, and the shape of it is the
argument:
* THE REASON IS GRADED, NOT THE VERDICT. The masked case refuses CORRECTLY
while reporting the wrong sentence, so a check asserting only "refused"
passes on a broken tree. A `rung9()` helper names which refusal answered,
from the sentence the operator would read; an unrecognised refusal reports
itself as UNATTRIBUTED rather than quietly matching nothing.
* EACH RUNG IS GIVEN A STIMULUS THE OTHER CANNOT ABSORB, or the pinning is
circular. The flat rung gets a THREE-firm-hour flat series, which no
`len(firm) < N` a person would write can reach. The firm-pair rung gets a
ONE-FIRM-HOUR series, which the flat rung CAN reach - so its attribution is
the whole check, and that is why it is written as a rung name rather than a
verdict.
* AC1 is a sweep of small-but-real spreads (0.0005 down to 0.000001) all
ALLOWED at min_spread_eur_kwh=0.0, with the exactly flat series on the other
side still refused BY THE FLAT RUNG - without that mirror the sweep would
also pass for a rung deleted outright. 1e-6 is the floor because the shipped
line rounds the spread to 6 decimals; that quantisation is graded in the
open as its own row rather than left unstated.
* AC3 is a five-row table graded on rung attribution, which denies the masking
from both sides at once.
* The firm-pair count is also pinned from ABOVE (`< 3`) and BELOW (`< 0`),
which the criteria do not ask for but which is what stops this section being
one-sided in exactly the way it was written to fix.
EVIDENCE. Eight rounds through TEST-15's tools/mutation_run.sh, used UNCHANGED:
recursive __pycache__ purge before each run, python3 -B, the tree re-copied FROM
DISK between rounds, the mutated line grepped out of the file before the run,
byte delta printed, test_static_tariff.py and test_arbitrage.py graded each
time. Baseline green.
baseline, shipped +0 B PASS
D1 `spread == 0.0` -> `<= 0.001` +2 B RED, 3 checks
D2 `spread == 0.0` -> `<= 0.000001` +5 B RED, AC1 alone
D3 `spread == 0.0` -> `< 0.0` (rung gone) +0 B RED (section 2 as well)
F1 `len(firm) < 2` -> `< 1` +0 B RED, 3 checks
F2 `len(firm) < 2` -> `< 3` +0 B RED, 6 checks
F3 `len(firm) < 2` -> `< 0` (rung gone) +0 B RED (ValueError)
D1+F1 BOTH AT ONCE - THE TICKET +2 B RED, 5 checks
Four of the seven are +0 B, the delta MUTATION.md names as the one stale
bytecode can hide. All are REDS, the direction that defect cannot fabricate -
but they were run under the purge anyway, because "it went red so the cache
cannot have lied" is a conclusion and not a procedure.
NO SHIPPED BEHAVIOUR CHANGED. app/ is untouched; both rungs were correct all
along, and the card is explicit that a rung found actually wrong is a stop-and-
report rather than an edit. Also updated: MUTATION.md gains a short section on
masking as a SECOND way a survivor lies, with the two rules that fall out of it
(grade the reason, and mutate suspected pairs together); and test_arbitrage.py's
SAFETY-26 AC4 note is marked closed rather than left reading as an open finding.
UNSURE ABOUT. The 5e-7 row asserts the shipped `round(spread, 6)` quantisation.
That is a real property of the line and worth stating, but it does pin a
rounding the ticket never named - if a reviewer would rather that stayed
unpinned, it is one check to drop and nothing else depends on it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1385 lines
72 KiB
Python
1385 lines
72 KiB
Python
"""Runnable check for OPT-04 - price arbitrage. `python3 test_arbitrage.py`
|
|
|
|
⚠️ RUN IT WITH `python3 -B` AND PURGE `__pycache__` FIRST. TEST-15 records a
|
|
size-preserving edit, reverted inside one second, whose OLD BYTECODE was still
|
|
what ran - confirmed by disassembly - so a mutation graded code that was not
|
|
there. Every mutation reported in the PR for this card was run as:
|
|
|
|
find . -name __pycache__ -type d -exec rm -rf {} + ; python3 -B test_*.py
|
|
|
|
WHAT THIS FILE IS DEFENDING, in the order the card's acceptance criteria name it:
|
|
|
|
AC1 NO ACTION when the expected benefit is below `cycle_cost_threshold_eur`.
|
|
Measured by BISECTING the accept/refuse boundary and comparing the
|
|
boundary with a threshold derived here by different arithmetic - not by
|
|
reading a constant back out of the module that defines it.
|
|
AC2 NEGATIVE-PRICE HOURS NEVER TRIGGER EXPORT (§17). Both sides: the same
|
|
series with the sign of one hour changed must trade in one case and
|
|
refuse in the other, or the guard is graded by nothing.
|
|
AC3 YIELDS TO PEAK SHAVING (§9.1). Through the two mechanisms that already
|
|
exist - static_tariff's required `peak_guard_active` interlock and
|
|
arbiter.py's clause 2 - both driven end to end, plus the third state
|
|
(the guard never ran) that neither of them can express on its own.
|
|
AC4 NET-POSITIVE ACROSS 12 MONTHS OF REPLAYED PRICES. ⚠️ MET AS OF DATA-02,
|
|
AND SECTIONS 7 AND 7B DO DIFFERENT HALVES OF IT. 7B replays twelve months
|
|
of REAL EPEX BE day-ahead prices - `scenarios/epex-be-dayahead-*.json`,
|
|
8760 hours, public data under CC BY 4.0 - through the shipped `decide()`,
|
|
and reports the euro figure at the shipped threshold. Section 7 keeps the
|
|
SYNTHETIC year, because the real one turns out to be unable to grade §17
|
|
at all: not one of its 365 days has a negative DEAREST hour, so a count
|
|
of zero negative-price discharges over it grades nothing. 7B asserts that
|
|
degeneracy rather than quietly reporting the zero.
|
|
|
|
THREE RULES THIS FILE HOLDS ITSELF TO, because the reviewer will:
|
|
|
|
* EVERY VERDICT IS DERIVED. No `check("the guard works", True)`. The widened
|
|
threshold is bisected rather than read; the arbiter's ordering is computed
|
|
from the priority constants rather than typed; the replay's net figure is
|
|
accumulated from the same trades the strategy actually chose.
|
|
|
|
* NO STIMULUS IS EVALUATED WHERE THE QUANTITY IT GRADES IS DEGENERATE. This
|
|
ticket is thick with that trap and each one is asserted before it is used:
|
|
|
|
- A SERIES WHOSE CHEAPEST HOUR IS ALSO ITS FIRST HOUR cannot tell "acts in
|
|
the cheapest hour" from "acts in the first hour". The fixture's cheapest
|
|
and dearest hours are neither the first nor the last, and neither is
|
|
adjacent to the other.
|
|
- THE NEGATIVE-PRICE CASE MUST STILL CLEAR THE THRESHOLD, or §17's guard
|
|
is graded on a series that the spread rung already refused, and deleting
|
|
the guard changes nothing. The fixture's all-negative day has a
|
|
0.25 EUR/kWh spread, asserted before it is used.
|
|
- IT MUST ALSO BE THE DEAREST HOUR THAT IS NEGATIVE. A day with one
|
|
negative hour that is the CHEAPEST one is a day the guard never
|
|
consults, and it would pass with the guard removed.
|
|
- `peak_guard_active` FALSE AND TRUE MUST DIFFER ON THE SAME PLAN, or AC3
|
|
is graded by a plan that refuses for some other reason anyway.
|
|
- THE PEAK FLOOR MUST BE INSIDE THE ARBITRAGE CHARGE, or clause 2 clamps
|
|
nothing and the arbiter check passes for an unbound claim.
|
|
- SoC MUST START STRICTLY BETWEEN FLOOR AND CEILING in every check that is
|
|
not about SoC, or a refusal attributed to price is really about charge.
|
|
|
|
* A FAILED READ IS NOT AN OBSERVATION. No plan yet resolved, a plan that has
|
|
aged out, an hour the series does not cover, an unread state of charge and a
|
|
peak guard that never ran are five DIFFERENT reasons - and none of them is
|
|
an answer. Section 5 drives all five and asserts they are distinguishable.
|
|
|
|
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
|
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
|
stops bytecode being WRITTEN, not a stale one being READ, and a size-preserving
|
|
edit reverted inside one second is invisible to CPython's staleness test. That
|
|
is TEST-15, and it has already turned one live mutation into a false survivor.
|
|
Read MUTATION.md before you report a survivor.
|
|
"""
|
|
|
|
import ast
|
|
import inspect
|
|
import os
|
|
import sys
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from app import arbitrage as _arb
|
|
from app import main as _main
|
|
from app import peakguard as _pg
|
|
from app.arbiter import (
|
|
P_ARBITRAGE, P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim,
|
|
resolve,
|
|
)
|
|
from app.arbitrage import (
|
|
CHARGE, DISCHARGE, ArbitrageDecision, claim, decide, enabled, soc_bounds,
|
|
threshold_eur_kwh,
|
|
)
|
|
from app.price import (
|
|
PRICE_OPTION_DEFAULTS, Absence, PricePoint, PriceProduct, PriceRead,
|
|
Uncertainty, WIDEN_FACTOR, brussels, required_spread, widen_factor,
|
|
)
|
|
from app.static_tariff import TariffPlan, parse_static_profile, static_plan
|
|
from app.validate import validate_startup_config
|
|
|
|
UTC = timezone.utc
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
fails = []
|
|
|
|
|
|
def check(name, cond):
|
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
|
if not cond:
|
|
fails.append(name)
|
|
|
|
|
|
def raises(fn, exc):
|
|
"""True only if fn() raises EXACTLY `exc`. A different exception is False -
|
|
'it was refused' and 'it broke on the way to being refused' must not grade
|
|
the same."""
|
|
try:
|
|
fn()
|
|
except exc:
|
|
return True
|
|
except Exception:
|
|
return False
|
|
return False
|
|
|
|
|
|
class _Raised:
|
|
def __init__(self, e):
|
|
self.e = e
|
|
|
|
def __eq__(self, other):
|
|
return False
|
|
|
|
def __hash__(self):
|
|
return id(self)
|
|
|
|
def __bool__(self):
|
|
return False
|
|
|
|
def __repr__(self):
|
|
return f"<raised {type(self.e).__name__}: {self.e}>"
|
|
|
|
|
|
def derived(fn):
|
|
"""A value a check grades. An exception becomes `_Raised`, which equals
|
|
nothing and is falsy - NOT a default. See test_price.py for why."""
|
|
try:
|
|
return fn()
|
|
except Exception as e: # noqa: BLE001
|
|
return _Raised(e)
|
|
|
|
|
|
def _fn(module_src, name):
|
|
tree = ast.parse(module_src)
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) \
|
|
and node.name == name:
|
|
return node
|
|
return None
|
|
|
|
|
|
def _awaited(node):
|
|
"""Every name awaited anywhere inside `node`."""
|
|
out = set()
|
|
for n in ast.walk(node):
|
|
if not isinstance(n, ast.Await):
|
|
continue
|
|
f = n.value.func if isinstance(n.value, ast.Call) else n.value
|
|
out.add(getattr(f, "id", None) or getattr(f, "attr", None))
|
|
return {n for n in out if n}
|
|
|
|
|
|
def near(a, b, tol=1e-9):
|
|
try:
|
|
return abs(float(a) - float(b)) <= tol
|
|
except (TypeError, ValueError):
|
|
return False
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Fixtures
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
D0 = datetime(2026, 1, 15, 0, 0, tzinfo=UTC)
|
|
MAX_W = 2000.0
|
|
NO_WEATHER = frozenset({Uncertainty.NO_WEATHER})
|
|
|
|
|
|
def plan_of(prices, *, product=PriceProduct.DAY_AHEAD, start=D0,
|
|
provisional=False, provider="generic_dayahead"):
|
|
"""A TariffPlan over consecutive hourly prices starting at `start`.
|
|
|
|
`provisional` may be a single bool or a per-hour sequence.
|
|
"""
|
|
flags = ([bool(provisional)] * len(prices)
|
|
if isinstance(provisional, bool) else list(provisional))
|
|
pts = [PricePoint(start=brussels(start + timedelta(hours=i)),
|
|
eur_per_kwh=round(float(p), 6), provisional=f)
|
|
for i, (p, f) in enumerate(zip(prices, flags))]
|
|
return TariffPlan(PriceRead.series(pts, product, provider, start), None,
|
|
"provider")
|
|
|
|
|
|
def hour(i, *, start=D0):
|
|
"""Mid-hour instant of slot `i` - never a boundary, where two answers agree."""
|
|
return start + timedelta(hours=i, minutes=30)
|
|
|
|
|
|
def act(plan, i, *, peak=False, soc=50.0, thresh=0.0, unc=frozenset(),
|
|
floor=20.0, ceiling=95.0, start=D0):
|
|
"""`decide()` with the fixture's constants filled in. One call shape."""
|
|
return decide(plan, hour(i, start=start), peak_guard_active=peak, soc=soc,
|
|
min_spread_eur_kwh=thresh, uncertainty=unc,
|
|
charge_w=MAX_W, discharge_w=MAX_W,
|
|
soc_floor_pct=floor, soc_ceiling_pct=ceiling)
|
|
|
|
|
|
# ⚠️ THE SHAPE OF THIS DAY IS THE WHOLE DEGENERACY ARGUMENT. The cheapest hour
|
|
# is slot 3 and the dearest is slot 18: neither is first, neither is last,
|
|
# neither is adjacent to the other, and the last hour is not the dearest. An
|
|
# implementation that acted on "the first hour", "the last hour", "the hour
|
|
# after the cheapest" or "min/max swapped" produces a different answer from the
|
|
# correct one at every probe below.
|
|
DAY = [0.240, 0.230, 0.215, 0.150, 0.190, 0.225,
|
|
0.260, 0.300, 0.320, 0.310, 0.295, 0.280,
|
|
0.270, 0.275, 0.285, 0.300, 0.330, 0.360,
|
|
0.410, 0.380, 0.340, 0.300, 0.270, 0.250]
|
|
CHEAP_H, DEAR_H = 3, 18
|
|
SPREAD = round(max(DAY) - min(DAY), 6)
|
|
|
|
print("0. the fixture is not degenerate (asserted before anything is graded)")
|
|
check(f"the cheapest hour is slot {CHEAP_H} and the dearest slot {DEAR_H} - "
|
|
f"neither first, neither last, and not adjacent",
|
|
DAY.index(min(DAY)) == CHEAP_H and DAY.index(max(DAY)) == DEAR_H
|
|
and CHEAP_H not in (0, len(DAY) - 1) and DEAR_H not in (0, len(DAY) - 1)
|
|
and abs(CHEAP_H - DEAR_H) > 1)
|
|
check(f"...and its spread is {SPREAD:.4f} EUR/kWh, comfortably above the "
|
|
f"shipped threshold, so the checks below grade the strategy rather than "
|
|
f"the spread rung",
|
|
SPREAD > PRICE_OPTION_DEFAULTS["cycle_cost_threshold_eur"] * 1.5)
|
|
check("...and its minimum and its maximum each occur EXACTLY ONCE, so 'the "
|
|
"cheapest hour' names one hour and an undefined tie-break is not what "
|
|
"any check below is really grading",
|
|
DAY.count(min(DAY)) == 1 and DAY.count(max(DAY)) == 1)
|
|
check("...and no price in it is zero or negative, so section 2's sign flip is "
|
|
"the only source of a negative price in this file",
|
|
all(p > 0 for p in DAY))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print()
|
|
print("1. AC1 - nothing happens below cycle_cost_threshold_eur")
|
|
|
|
PLAN = plan_of(DAY)
|
|
|
|
_buy = act(PLAN, CHEAP_H, thresh=0.0)
|
|
_sell = act(PLAN, DEAR_H, thresh=0.0)
|
|
check("with a zero threshold the cheapest hour CHARGES and the dearest hour "
|
|
"DISCHARGES - the two actions this strategy has, both reachable",
|
|
_buy.action == CHARGE and _sell.action == DISCHARGE)
|
|
check("...charging is a NEGATIVE setpoint and discharging a positive one, "
|
|
"which is the sign convention maintenance's `hi <= 0` shaping and "
|
|
"peakguard's `lo >= 0` floor are both written against",
|
|
_buy.power_w is not None and _sell.power_w is not None
|
|
and _buy.power_w < 0 < _sell.power_w)
|
|
check("...and an hour that is neither buy nor sell does NOTHING, so the "
|
|
"strategy is not simply always-on",
|
|
act(PLAN, 10, thresh=0.0).action is None)
|
|
|
|
# ⚠️ MEASURED, NOT READ. The boundary is found by bisection on the threshold,
|
|
# so this grades the shipped refusal ladder rather than restating a constant
|
|
# that lives in the module under test.
|
|
def boundary(unc, plan=PLAN, lo=0.0, hi=10.0):
|
|
"""The largest threshold this plan still trades under, to 1e-9."""
|
|
for _ in range(200):
|
|
mid = (lo + hi) / 2.0
|
|
if act(plan, CHEAP_H, thresh=mid, unc=unc).action is None:
|
|
hi = mid
|
|
else:
|
|
lo = mid
|
|
return lo
|
|
|
|
|
|
_b_plain = derived(lambda: boundary(frozenset()))
|
|
check(f"the accept/refuse boundary with no uncertainty is the spread itself "
|
|
f"({_b_plain!r} vs {SPREAD}) - a cycle trades exactly while its spread "
|
|
f"covers its cost, and not one cent further",
|
|
near(_b_plain, SPREAD, 1e-6))
|
|
check("...so a threshold a hair ABOVE the spread refuses and a hair BELOW it "
|
|
"trades: the boundary is a boundary, not a slope",
|
|
act(PLAN, CHEAP_H, thresh=SPREAD + 1e-4).action is None
|
|
and act(PLAN, CHEAP_H, thresh=SPREAD - 1e-4).action == CHARGE)
|
|
_refusal = act(PLAN, CHEAP_H, thresh=SPREAD + 1e-4).reason
|
|
check("...and the refusal SAYS the spread and the number it had to cover, so "
|
|
"an operator can tell 'no opportunity' from 'broken'",
|
|
"does not cover" in _refusal or "needs" in _refusal)
|
|
|
|
# The widening path, end to end: the boundary must move by exactly the declared
|
|
# factor. Derived twice - once by bisection, once through required_spread().
|
|
_b_wide = derived(lambda: boundary(NO_WEATHER))
|
|
check("the two widening factors this repository ships DIFFER, or 'max', 'sum' "
|
|
"and 'product' would be indistinguishable combination rules",
|
|
len(set(WIDEN_FACTOR.values())) == len(WIDEN_FACTOR))
|
|
check(f"a widened threshold moves the boundary by exactly the declared factor: "
|
|
f"{_b_wide!r} x {widen_factor(NO_WEATHER)} = {_b_plain!r}",
|
|
near(_b_wide * widen_factor(NO_WEATHER), _b_plain, 1e-6))
|
|
check("...which is the same number required_spread() computes from the other "
|
|
"direction, so the widening is one mechanism and not two",
|
|
isinstance(_b_wide, float)
|
|
and near(required_spread(_b_wide, NO_WEATHER), _b_plain, 1e-6))
|
|
_wide_at = round(SPREAD / widen_factor(NO_WEATHER) + 1e-4, 6)
|
|
check("...and the difference is LIVE, not arithmetic on paper: at one "
|
|
"threshold the plain site trades and the no-weather site does not",
|
|
act(PLAN, CHEAP_H, thresh=_wide_at, unc=frozenset()).action == CHARGE
|
|
and act(PLAN, CHEAP_H, thresh=_wide_at, unc=NO_WEATHER).action is None)
|
|
|
|
# The threshold this add-on SHIPS, against the tariff most of the installed
|
|
# base is on. Reported, not tuned - see the module docstring in app/arbitrage.py.
|
|
STATIC_BE = ('{"periods": ['
|
|
'{"name":"day","from":"07:00","to":"22:00","days":"all",'
|
|
'"eur_per_kwh":0.34},'
|
|
'{"name":"night","from":"22:00","to":"07:00","days":"all",'
|
|
'"eur_per_kwh":0.26}]}')
|
|
_st_plan = derived(lambda: static_plan({"static_profile": STATIC_BE}, D0,
|
|
D0 + timedelta(days=2), now=D0))
|
|
_st_spread = derived(lambda: _st_plan.spread_eur_kwh())
|
|
check(f"a realistic Belgian day/night profile has a spread of "
|
|
f"{_st_spread!r} EUR/kWh - the number the product decision is about",
|
|
near(_st_spread, 0.08))
|
|
_shipped = PRICE_OPTION_DEFAULTS["cycle_cost_threshold_eur"]
|
|
_st_verdict = derived(lambda: _st_plan.arbitrage(
|
|
peak_guard_active=False, min_spread_eur_kwh=_shipped,
|
|
uncertainty=NO_WEATHER))
|
|
check(f"...and at the SHIPPED threshold of {_shipped} EUR/kWh that site does "
|
|
f"NOT trade. Reported, not tuned: the shipped default is the owner's "
|
|
f"decision and this check exists to make the consequence visible rather "
|
|
f"than to argue with it",
|
|
_st_verdict.kind == "refused")
|
|
check(f"...and it is the SPREAD rung that refuses it, not a flat-tariff or a "
|
|
f"provisional rung that would have refused any static site whatever the "
|
|
f"threshold was: {_st_spread!r} < "
|
|
f"{required_spread(_shipped, NO_WEATHER)!r}",
|
|
_st_spread < required_spread(_shipped, NO_WEATHER)
|
|
and "cover its own round-trip cost" in _st_verdict.reason)
|
|
check("...while the SAME site trades once the threshold is set below its "
|
|
"spread, so the refusal above is about the number and not about static "
|
|
"tariffs being unsupported",
|
|
derived(lambda: _st_plan.arbitrage(
|
|
peak_guard_active=False,
|
|
min_spread_eur_kwh=_st_spread / widen_factor(NO_WEATHER) - 1e-4,
|
|
uncertainty=NO_WEATHER).kind) == "allowed")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print()
|
|
print("2. AC2 - §17: a negative-price hour never triggers export")
|
|
|
|
# ⚠️ THE WHOLE DAY IS NEGATIVE AND THE DEAREST HOUR IS THE LEAST NEGATIVE ONE.
|
|
# That is the case a guard written as "the spread is positive" or "not all
|
|
# prices are negative" lets through, and it is the realistic one: Belgian
|
|
# day-ahead has gone negative for a whole summer midday block.
|
|
NEG = [round(-0.30 + 0.010 * i, 6) for i in range(24)] # -0.30 .. -0.07
|
|
NEG[DEAR_H] = -0.05 # dearest, still < 0
|
|
NEG_PLAN = plan_of(NEG)
|
|
_neg_spread = round(max(NEG) - min(NEG), 6)
|
|
check(f"the negative day's dearest hour is slot {DEAR_H} and it is BELOW ZERO "
|
|
f"({max(NEG):+.4f}), which is the only shape that exercises the guard",
|
|
NEG.index(max(NEG)) == DEAR_H and max(NEG) < 0)
|
|
check(f"...and its spread is {_neg_spread:.4f} EUR/kWh, which CLEARS the "
|
|
f"shipped threshold - so the trade is refused by §17 and not by a rung "
|
|
f"that would have refused it anyway",
|
|
_neg_spread > required_spread(_shipped, NO_WEATHER))
|
|
_neg_sell = act(NEG_PLAN, DEAR_H, thresh=_shipped, unc=NO_WEATHER)
|
|
check("selling into a negative-price hour is REFUSED", _neg_sell.action is None)
|
|
check("...and the reason names §17 and the price, so the log says which rule "
|
|
"and not merely that nothing happened",
|
|
"§17" in _neg_sell.reason and "-0.0500" in _neg_sell.reason)
|
|
# The other side of the guard, without which nothing above is discriminating.
|
|
POS = list(NEG)
|
|
POS[DEAR_H] = +0.05 # the SAME day with ONE SIGN FLIPPED
|
|
POS_PLAN = plan_of(POS)
|
|
_pos_sell = act(POS_PLAN, DEAR_H, thresh=_shipped, unc=NO_WEATHER)
|
|
check("...while the SAME series with that one price's SIGN FLIPPED does "
|
|
"discharge - so the refusal is about the sign and about nothing else "
|
|
"that differs between the two runs",
|
|
_pos_sell.action == DISCHARGE)
|
|
check("...and the two runs differ in exactly one price, by exactly its sign",
|
|
sum(1 for a, b in zip(NEG, POS) if a != b) == 1
|
|
and near(NEG[DEAR_H], -POS[DEAR_H]))
|
|
# §17 is about EXPORT. Buying in a negative hour is the right thing to do.
|
|
check("BUYING in a negative-price hour is still permitted - §17 forbids "
|
|
"exporting into one, and a guard that also blocked the buy would be "
|
|
"refusing to be paid to charge",
|
|
act(NEG_PLAN, NEG.index(min(NEG)), thresh=_shipped,
|
|
unc=NO_WEATHER).action == CHARGE)
|
|
# A zero price is the boundary and it is NOT negative. ⚠️ KEEP THIS PROBE. It
|
|
# pins the boundary from ABOVE - it is what catches a guard written `<= 0` - and
|
|
# the SAFETY-26 block below pins it from BELOW. Neither replaces the other, and
|
|
# the whole defect that card exists to close is that only one of the two sides
|
|
# was ever stated.
|
|
ZERO = list(POS)
|
|
ZERO[DEAR_H] = 0.0
|
|
check("a sell hour priced at exactly ZERO is not a negative one, so the guard "
|
|
"is `< 0` rather than `<= 0` - and the boundary is stated by a check "
|
|
"rather than left to whoever reads the operator",
|
|
act(plan_of(ZERO), DEAR_H, thresh=_shipped, unc=NO_WEATHER).action
|
|
== DISCHARGE)
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# SAFETY-26. THE OTHER SIDE OF THE SAME BOUNDARY, AND WHY IT HAD TO BE ADDED.
|
|
#
|
|
# OPT-04's reviewer mutated `if price < 0` to `if price < -0.001` and EVERY
|
|
# SUITE STAYED GREEN, with a -0.0005 EUR/kWh sell hour discharging. The shipped
|
|
# code was right the whole time; the CHECKS could not tell correct from
|
|
# nearly-correct, because the only probe near the boundary sat at 0.0000. A
|
|
# probe above the line catches `<=`. It says nothing at all about a tolerance
|
|
# invented below the line, and a tolerance below the line IS §17's regression
|
|
# case walking through with the suite passing.
|
|
#
|
|
# ⚠️ AND -0.0005 IS NOT AN EXOTIC INPUT. Belgian day-ahead prints small negative
|
|
# hours regularly; an all-in price a twentieth of a cent below zero is an
|
|
# ordinary day, not a fuzz value. That is what makes this a live regression
|
|
# rather than a theoretical one.
|
|
#
|
|
# ⚠️ SO THE PROBE IS A SWEEP, NOT A POINT, and the sweep is the discriminating
|
|
# part. One probe at -0.0005 closes `-0.001` and nothing else; a mutation
|
|
# reaching for `-0.01` would survive it, which is the identical defect one
|
|
# decimal place further out. The sweep spans four orders of magnitude down to
|
|
# the last place the canonical six-decimal format can even express, so there is
|
|
# no tolerance a guard can invent that some member of it does not straddle.
|
|
# `_ROUNDING` asserts the smallest member SURVIVES the format rather than being
|
|
# rounded to zero on the way in - a probe that silently became 0.0 would be the
|
|
# `0.0000` check again, wearing a different name.
|
|
TINY_NEG = (-0.05, -0.005, -0.0005, -0.00005, -0.000001)
|
|
_ROUNDING = [round(p, 6) for p in TINY_NEG]
|
|
check(f"every probe price survives the canonical 6-decimal rounding as a "
|
|
f"STRICTLY negative number {_ROUNDING} - one that rounded to 0.0 would "
|
|
f"be the check above again under another name",
|
|
all(r < 0 for r in _ROUNDING) and len(set(_ROUNDING)) == len(TINY_NEG))
|
|
check("...and they span four orders of magnitude, so no single invented "
|
|
"tolerance can sit below all of them and above none",
|
|
abs(min(TINY_NEG)) / abs(max(TINY_NEG)) > 10000)
|
|
|
|
|
|
def tiny_neg_plan(price):
|
|
"""The POS day with its DEAREST hour set to a small negative print."""
|
|
day = list(POS)
|
|
day[DEAR_H] = price
|
|
return plan_of(day), day
|
|
|
|
|
|
# The stimulus has to survive the rungs ABOVE §17 or it grades nothing: the
|
|
# hour must still be the dearest, and the spread must still clear the widened
|
|
# threshold, at every probe. Asserted first, for all of them, as its own check.
|
|
_wellformed = [
|
|
(p, day.index(max(day)) == DEAR_H,
|
|
round(max(day) - min(day), 6) > required_spread(_shipped, NO_WEATHER))
|
|
for p, (_pl, day) in ((p, tiny_neg_plan(p)) for p in TINY_NEG)]
|
|
check(f"at every probe the negative hour is still the DEAREST one and the "
|
|
f"spread still clears the threshold, so §17 is the only rung that can "
|
|
f"refuse {_wellformed}",
|
|
all(dearest and clears for _p, dearest, clears in _wellformed))
|
|
_swept = [(p, act(tiny_neg_plan(p)[0], DEAR_H, thresh=_shipped,
|
|
unc=NO_WEATHER).action) for p in TINY_NEG]
|
|
check(f"SAFETY-26: a sell hour that is negative BY ANY MARGIN AT ALL refuses - "
|
|
f"down to -0.000001 EUR/kWh, so the guard is `< 0` and not `< -epsilon` "
|
|
f"for any epsilon {_swept}",
|
|
all(a is None for _p, a in _swept))
|
|
check("...and each refusal is §17's, not some other rung that happens to fire "
|
|
"at small numbers",
|
|
all("§17" in act(tiny_neg_plan(p)[0], DEAR_H, thresh=_shipped,
|
|
unc=NO_WEATHER).reason for p in TINY_NEG))
|
|
# The mirror image, one place above zero. Without this the sweep above would
|
|
# pass for a guard that refused EVERYTHING, which grades §17 by nothing.
|
|
_tiny_pos = [(-p, act(tiny_neg_plan(-p)[0], DEAR_H, thresh=_shipped,
|
|
unc=NO_WEATHER).action) for p in TINY_NEG]
|
|
check(f"...while THE SAME MAGNITUDES with the sign flipped all DISCHARGE, so "
|
|
f"the sweep is a boundary at exactly zero rather than a refusal of small "
|
|
f"numbers - which a guard that refused everything would also have passed "
|
|
f"{_tiny_pos}",
|
|
all(a == DISCHARGE for _p, a in _tiny_pos))
|
|
|
|
# SAFETY-26 AC4 - THE LOOK AT THE OTHER BOUNDARIES, NAMED RATHER THAN FIXED.
|
|
#
|
|
# The card asks whether the same one-sided pinning exists elsewhere. Every
|
|
# price/quantity boundary in arbitrage.py and static_tariff.py was given the
|
|
# tolerance mutation that survived on `price < 0`, in both directions where
|
|
# both exist, under `-B` with a recursive purge. Two survived, BOTH in
|
|
# static_tariff.py - TARIFF-03's file, and deliberately NOT touched here:
|
|
#
|
|
# D `spread == 0.0` (the flat-tariff rung) -> `spread <= 0.001` SURVIVES.
|
|
# Measured, not reasoned: with it loosened, a series priced 0.20000 /
|
|
# 0.20050 - a REAL 0.0005 spread - flips from ALLOWED to REFUSED at
|
|
# min_spread_eur_kwh=0.0, which `arbitrage()`'s own docstring documents as
|
|
# a supported value ("pass 0.0 to mean any positive spread, explicitly").
|
|
# At a shipped threshold the OUTCOME is unchanged, but the operator's
|
|
# reason becomes the false sentence "the tariff is flat - every one of the
|
|
# 2 firm hours costs 0.2000 EUR/kWh" about a tariff that is not flat.
|
|
# Same shape as §17's: the rung is only ever probed with an EXACTLY flat
|
|
# series, so nothing pins the tiny-but-real side.
|
|
#
|
|
# F `len(firm) < 2` (§8.3's firm-pair rung) -> `< 1` SURVIVES, at +0 BYTES,
|
|
# which is the delta TEST-15 names as the risky one. It is not live on its
|
|
# own: a single firm hour has a spread of 0.0, so the flat rung refuses it
|
|
# anyway - but the reason degrades from §8.3's honest "only 1 of 3 hours
|
|
# carry a published price" to "the tariff is flat". THE FINDING THAT
|
|
# MATTERS IS THE INTERACTION: D and F MASK EACH OTHER. Neither is
|
|
# individually live because the other catches it, which is exactly the
|
|
# arrangement in which a later edit to either one opens a hole nothing
|
|
# reports.
|
|
#
|
|
# Everything else in arbitrage.py is pinned on BOTH sides and was verified so:
|
|
# `price < 0` (this card), `soc <= floor_pct` and `soc >= ceiling_pct` are each
|
|
# red under a +5 and a -5 tolerance; static_tariff's `spread < need` is red
|
|
# under +/-0.001 and additionally bisected to 1e-6 in section 1; `cheap_now`'s
|
|
# `price < mean` is red under a +0.001 tolerance in test_static_tariff.py.
|
|
#
|
|
# Not fixed here on purpose. D and F are TARIFF-03's rungs, they need probes in
|
|
# test_static_tariff.py rather than in this file, and the card is explicit that
|
|
# its fourth criterion is a look and not a refactor.
|
|
#
|
|
# ✅ CLOSED BY TEST-16, in test_static_tariff.py SECTION 9, and the two rows
|
|
# above are kept as the diagnosis rather than as an open finding. D is pinned by
|
|
# a sweep of small-but-real spreads down to 0.000001 EUR/kWh at
|
|
# min_spread_eur_kwh=0.0; F is pinned by a ONE-FIRM-HOUR series graded on WHICH
|
|
# RUNG'S SENTENCE came back, which is the only way it goes red without borrowing
|
|
# the flat rung; and the masking is denied by a table that grades every stimulus
|
|
# on its rung's attribution rather than on `kind`. Both are red on their own and
|
|
# five checks are red with BOTH weakened at once. Ledger in that section's
|
|
# header. Nothing in app/ changed - these rungs were correct all along.
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print()
|
|
print("3. AC3 - peak shaving outranks arbitrage, through both mechanisms")
|
|
|
|
# MECHANISM 1: static_tariff's interlock. The spread is not obtainable without
|
|
# answering the question, so this cannot be skipped by a caller.
|
|
_clear = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER, peak=False)
|
|
_held = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER, peak=True)
|
|
check("on ONE plan, at ONE instant, with ONE threshold, the peak guard being "
|
|
"CLEAR trades and the peak guard HOLDING does not - the only difference "
|
|
"between the two runs is that boolean",
|
|
_clear.action == CHARGE and _held.action is None)
|
|
check("...and the refusal cites §10.1's ordering and the capaciteitstarief, so "
|
|
"it cannot be mistaken for 'no opportunity today'",
|
|
"§10.1" in _held.reason and "peak" in _held.reason.lower())
|
|
check("...and the interlock is REQUIRED rather than defaulted: "
|
|
"TariffPlan.arbitrage() has no default for peak_guard_active, so a "
|
|
"caller cannot reach a spread without having answered it",
|
|
all(p.default is inspect.Parameter.empty
|
|
for n, p in inspect.signature(TariffPlan.arbitrage).parameters.items()
|
|
if n != "self"))
|
|
check("...nor does arbitrage.decide(): every argument of it is keyword-only "
|
|
"with no default, for the same reason",
|
|
all(p.default is inspect.Parameter.empty
|
|
and (p.kind is inspect.Parameter.KEYWORD_ONLY or n in ("plan", "now"))
|
|
for n, p in inspect.signature(decide).parameters.items()))
|
|
|
|
# MECHANISM 2: the arbiter. Even with the interlock bypassed, the peak claim
|
|
# binds the arbitrage claim - because P_PEAK > P_ARBITRAGE and clause 2 applies
|
|
# every limit at or above the winner's priority.
|
|
check("the priority ordering IS §10.1's, derived from the constants rather "
|
|
"than asserted about them: safety > maintenance > peak > maintenance "
|
|
"shaping > arbitrage > loop",
|
|
P_SAFETY > P_MAINTENANCE > P_PEAK > P_MAINT_SHAPE > P_ARBITRAGE > P_LOOP)
|
|
_charge_claim = claim(_clear)
|
|
PEAK_FLOOR = 500.0
|
|
check(f"the peak floor used below ({PEAK_FLOOR:+.0f} W) is strictly INSIDE the "
|
|
f"arbitrage charge ({_charge_claim.value:+.0f} W), or clause 2 would "
|
|
f"clamp nothing and the resolution would pass for an unbound claim",
|
|
_charge_claim.value < PEAK_FLOOR)
|
|
_res = resolve([
|
|
Claim.limit("supervised", P_SAFETY, -MAX_W, MAX_W, "max_w"),
|
|
Claim.limit(_pg.SOURCE, P_PEAK, lo=PEAK_FLOOR, reason="quarter peak"),
|
|
_charge_claim,
|
|
Claim.set("loop", P_LOOP, -120.0, "following"),
|
|
])
|
|
check(f"an arbitrage charge that WINS the arbitration is still clamped by the "
|
|
f"peak floor: {_res.explain()}",
|
|
near(_res.target_w, PEAK_FLOOR) and _res.winner == _arb.SOURCE
|
|
and _res.bound_by is not None and _pg.SOURCE in _res.bound_by)
|
|
check("...and it is P_PEAK > P_ARBITRAGE that does it, not luck: the identical "
|
|
"floor filed BELOW the arbitrage priority does not bind at all",
|
|
near(resolve([
|
|
Claim.limit("supervised", P_SAFETY, -MAX_W, MAX_W, "max_w"),
|
|
Claim.limit(_pg.SOURCE, P_ARBITRAGE - 1, lo=PEAK_FLOOR),
|
|
_charge_claim]).target_w, _charge_claim.value))
|
|
# The floor really is peakguard's own claim, not a hand-rolled stand-in.
|
|
_pd = _pg.PeakDecision(engaged=True, floor_w=PEAK_FLOOR, ceiling_w=3500.0,
|
|
ceiling_from=_pg.CEILING_CAP, reason="test")
|
|
_real_peak = derived(lambda: _pg.claim(_pd))
|
|
check("...and the claim doing the clamping is peakguard.claim()'s own output, "
|
|
"so this grades the shipped floor rather than a transcription of it",
|
|
_real_peak is not None and _real_peak.priority == P_PEAK
|
|
and near(resolve([
|
|
Claim.limit("supervised", P_SAFETY, -MAX_W, MAX_W, "max_w"),
|
|
_real_peak, _charge_claim]).target_w, PEAK_FLOOR))
|
|
# ...and a DISCHARGE is deliberately not capped by it (§9.1: only offtake bills).
|
|
check("a peak floor does NOT cap an arbitrage DISCHARGE - §9.1 bills offtake "
|
|
"only, and peakguard's claim leaves `hi` at +inf to say so",
|
|
near(resolve([
|
|
Claim.limit("supervised", P_SAFETY, -MAX_W, MAX_W, "max_w"),
|
|
_real_peak, claim(_sell)]).target_w, MAX_W))
|
|
|
|
# MECHANISM 3, which is neither of the above: the guard that never ran.
|
|
_blind = decide(PLAN, hour(CHEAP_H), peak_guard_active=None, soc=50.0,
|
|
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
|
charge_w=MAX_W, discharge_w=MAX_W,
|
|
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
|
|
check("a peak guard that NEVER RAN is a third state, and it refuses: `None` is "
|
|
"not `False`, and 'nothing measured the quarter' is not 'the quarter is "
|
|
"clear'",
|
|
_blind.action is None and "NOT COVERED" in _blind.reason)
|
|
check("...and it is distinguishable from the guard HOLDING, so the status page "
|
|
"does not report an unmeasured install as a peak event",
|
|
_blind.reason != _held.reason)
|
|
# ⚠️ AND THE THREE STATES ARE GRADED ON THE SHIPPED EXPRESSION. `guard_state()`
|
|
# is what main.py's cycle() passes; a transcription of it here would be the
|
|
# defect `shaping_suspended()` was extracted to remove - a mutation back to a
|
|
# bare `engaged_now` left every suite green in the case that record describes.
|
|
_idle_pd = _pg.PeakDecision(engaged=False, floor_w=None, ceiling_w=3500.0,
|
|
ceiling_from=_pg.CEILING_CAP, reason="idle")
|
|
check("guard_state() maps peakguard's three real situations onto the three "
|
|
"states decide() takes, and None is NOT False",
|
|
_arb.guard_state(None) is None
|
|
and _arb.guard_state(_idle_pd) is False
|
|
and _arb.guard_state(_pd) is True)
|
|
check("...and cycle() calls it rather than inlining the conjunction, so a "
|
|
"mutation back to a bare `engaged_now` is red rather than silent",
|
|
"arbitrage.guard_state(self.peak_guard)" in
|
|
ast.get_source_segment(
|
|
open(os.path.join(HERE, "app", "main.py"), encoding="utf-8").read(),
|
|
_fn(open(os.path.join(HERE, "app", "main.py"),
|
|
encoding="utf-8").read(), "cycle")))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print()
|
|
print("4. §8.3 - no irreversible action on a provisional price")
|
|
|
|
# The realistic shape: the feed has published the morning and the afternoon is
|
|
# carried from yesterday. The CHEAPEST hour of the whole day is in the carried
|
|
# part, which is the lure - and the firm part still has a tradeable spread, so
|
|
# a correct implementation trades on the firm hours rather than doing nothing.
|
|
_flags = [i >= 12 for i in range(24)]
|
|
_part = plan_of(DAY, provisional=_flags)
|
|
_firm = [p for p, f in zip(DAY, _flags) if not f]
|
|
check("the fixture's carried half contains the day's DEAREST hour, so 'the "
|
|
"carried hours are ignored' is graded by a case where ignoring them "
|
|
"changes the answer",
|
|
_flags[DEAR_H] is True)
|
|
check("...and the firm half still has a spread over the shipped threshold, so "
|
|
"the correct behaviour is to TRADE on the firm hours - a check that "
|
|
"expected silence would pass for an implementation that gave up",
|
|
round(max(_firm) - min(_firm), 6) > required_spread(_shipped, NO_WEATHER))
|
|
_p_buy = act(_part, CHEAP_H, thresh=_shipped, unc=NO_WEATHER)
|
|
_p_sell_carried = act(_part, DEAR_H, thresh=_shipped, unc=NO_WEATHER)
|
|
_p_sell_firm = act(_part, _firm.index(max(_firm)), thresh=_shipped,
|
|
unc=NO_WEATHER)
|
|
check("the firm cheapest hour still CHARGES", _p_buy.action == CHARGE)
|
|
check("...the firm dearest hour still DISCHARGES", _p_sell_firm.action == DISCHARGE)
|
|
check("...and the CARRIED hour that is the day's real maximum does nothing - "
|
|
"it is not the sell hour, because it is not an observation",
|
|
_p_sell_carried.action is None)
|
|
_all_prov = act(plan_of(DAY, provisional=True), CHEAP_H, thresh=0.0)
|
|
check("a FULLY carried series trades nothing at all, whatever its spread - "
|
|
"§11.4's fallback plan and a pre-publication day both land here",
|
|
_all_prov.action is None and "§8.3" in _all_prov.reason)
|
|
# ⚠️ A SURVIVOR, REPORTED RATHER THAN HIDDEN, AND THE ARGUMENT THAT MAKES IT
|
|
# HARMLESS. Disabling `_act`'s own `if here.provisional:` guard changes no
|
|
# answer in this file, and it cannot: the verdict's `cheapest`/`dearest` are
|
|
# firm by `_firm_or_die`, starts are unique within a series, and the point
|
|
# `at()` returns for a matched instant is therefore THE SAME OBJECT. The guard
|
|
# is unreachable by construction, exactly the way peakguard's seventh mutation
|
|
# is - and the honest response is to demonstrate the construction rather than
|
|
# to write a check that cannot bite. It stays because it is the only thing
|
|
# standing if a future caller ever hands `_act` a plan the verdict did not come
|
|
# from.
|
|
_probe = _arb._point_at(PLAN, hour(CHEAP_H))
|
|
_verdict = PLAN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0,
|
|
uncertainty=frozenset())
|
|
check("the point matched for the buy hour IS the verdict's own cheapest point, "
|
|
"by identity - which is why `_act`'s provisional guard is unreachable "
|
|
"here, and why disabling it changes nothing this file can observe",
|
|
_probe is _verdict.cheapest and _probe.provisional is False)
|
|
check("...and the starts really are unique in a series, which is the other "
|
|
"half of that argument",
|
|
len({p.start for p in PLAN.read.points}) == len(PLAN.read.points))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print()
|
|
print("4b. §10.2 hysteresis - a price on the boundary cannot flap at 30 s")
|
|
|
|
# The card's technical notes ask for this to be "testable by replaying a price
|
|
# series oscillating around the threshold and asserting a bounded transition
|
|
# count". ⚠️ THE OSCILLATION IS THE POINT: these prices cross the shipped
|
|
# threshold repeatedly, hour to hour, which is the shape that makes a
|
|
# per-tick price-vs-threshold comparison flap.
|
|
FLAP = [0.20 + (0.101 if i % 2 else 0.0) for i in range(24)]
|
|
_flap_plan = plan_of(FLAP)
|
|
check("the flapping fixture really does straddle the threshold hour by hour - "
|
|
"every neighbouring pair differs by more than it",
|
|
all(abs(FLAP[i] - FLAP[i + 1]) > _shipped for i in range(len(FLAP) - 1)))
|
|
_ticks = []
|
|
for _t in range(24 * 120): # 24 h at the 30 s control cadence
|
|
_d = decide(_flap_plan, D0 + timedelta(seconds=30 * _t),
|
|
peak_guard_active=False, soc=50.0, min_spread_eur_kwh=_shipped,
|
|
uncertainty=frozenset(), charge_w=MAX_W, discharge_w=MAX_W,
|
|
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
|
|
_ticks.append(_d.action)
|
|
_transitions = sum(1 for a, b in zip(_ticks, _ticks[1:]) if a != b)
|
|
check(f"over {len(_ticks)} ticks of a day whose price crosses the threshold "
|
|
f"every hour, the strategy changes what it is doing {_transitions} "
|
|
f"times - not once per tick, because the decision is 'which HOUR is "
|
|
f"this', and an hour is 3600 s wide",
|
|
_transitions <= 4)
|
|
check("...and it is not zero, so the bound above is a bound on a strategy that "
|
|
"acted rather than on one that did nothing",
|
|
_transitions > 0 and CHARGE in _ticks and DISCHARGE in _ticks)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print()
|
|
print("5. a failed read is not an observation - five reasons, all different")
|
|
|
|
_no_plan = act(None, CHEAP_H, thresh=_shipped)
|
|
_no_soc = act(PLAN, CHEAP_H, thresh=_shipped, soc=None)
|
|
_uncovered = decide(PLAN, D0 + timedelta(days=9), peak_guard_active=False,
|
|
soc=50.0, min_spread_eur_kwh=_shipped,
|
|
uncertainty=frozenset(), charge_w=MAX_W, discharge_w=MAX_W,
|
|
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
|
|
_reasons = [_no_plan.reason, _no_soc.reason, _uncovered.reason, _blind.reason,
|
|
_held.reason]
|
|
check("no plan, no state of charge, an uncovered hour, an unmeasured peak and "
|
|
"a held peak are five DISTINCT reasons - none of them silence, none of "
|
|
"them each other",
|
|
len(set(_reasons)) == 5
|
|
and all(a.action is None for a in
|
|
(_no_plan, _no_soc, _uncovered, _blind, _held)))
|
|
check("...and none of them is empty: `_idle` refuses a blank reason at the "
|
|
"call site, so a status line cannot decay into nothing",
|
|
all(r.strip() for r in _reasons)
|
|
and raises(lambda: _arb._idle(""), ValueError))
|
|
# The no-data plan: `price_at` must RAISE rather than answer 0.0, and 0.0 is
|
|
# exactly the value that would make every hour look like the cheapest.
|
|
_empty = TariffPlan(PriceRead.no_data("nothing was fetched"), None, "none")
|
|
check("a plan built on a no_data read trades nothing, and asking it for a "
|
|
"price RAISES rather than answering 0.0 - 'no data' becoming 'free' is "
|
|
"the defect this whole type family exists to remove",
|
|
act(_empty, 0, thresh=0.0).action is None
|
|
and raises(lambda: _empty.price_at(hour(0)), Absence))
|
|
|
|
# SoC: the reserve and the headroom, each graded where the other cannot explain
|
|
# the answer.
|
|
check("at the reserve FLOOR the sell hour does nothing, and the reason names "
|
|
"the floor rather than the price",
|
|
act(PLAN, DEAR_H, thresh=0.0, soc=20.0, floor=20.0).action is None
|
|
and "reserve floor" in act(PLAN, DEAR_H, thresh=0.0, soc=20.0,
|
|
floor=20.0).reason)
|
|
check("...one percent above it, the same hour sells - so the refusal is about "
|
|
"the floor and not about the hour",
|
|
act(PLAN, DEAR_H, thresh=0.0, soc=21.0, floor=20.0).action == DISCHARGE)
|
|
check("at the CEILING the buy hour does nothing, and one percent below it buys",
|
|
act(PLAN, CHEAP_H, thresh=0.0, soc=95.0, ceiling=95.0).action is None
|
|
and act(PLAN, CHEAP_H, thresh=0.0, soc=94.0,
|
|
ceiling=95.0).action == CHARGE)
|
|
check("...and a FULL pack in a NEGATIVE sell hour reports §17 rather than the "
|
|
"SoC - the rule that actually forbids the trade is the one logged",
|
|
"§17" in act(NEG_PLAN, DEAR_H, thresh=0.0, soc=20.0,
|
|
floor=20.0).reason)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print()
|
|
print("6. the claim, and what NO claim means")
|
|
|
|
check("an idle decision files NO claim - not a 0 W one, which would out-rank "
|
|
"the loop with a park order and silently switch self-consumption off on "
|
|
"every site that armed arbitrage",
|
|
claim(_held) is None and claim(_no_plan) is None)
|
|
_idle_res = resolve([Claim.limit("supervised", P_SAFETY, -MAX_W, MAX_W),
|
|
Claim.set("loop", P_LOOP, -120.0, "following")])
|
|
check("...demonstrated through the arbiter: with arbitrage idle the loop's "
|
|
"number is what goes on the wire, unchanged",
|
|
near(_idle_res.target_w, -120.0) and _idle_res.winner == "loop")
|
|
check("...whereas a trading decision DOES out-rank the loop, which is the "
|
|
"point: the loop would otherwise cancel every charge the moment it "
|
|
"created import",
|
|
resolve([Claim.limit("supervised", P_SAFETY, -MAX_W, MAX_W),
|
|
Claim.set("loop", P_LOOP, -120.0, "following"),
|
|
_charge_claim]).winner == _arb.SOURCE)
|
|
check("the claim carries the §10.2 reason code, so the action log names the "
|
|
"strategy rather than describing it",
|
|
_charge_claim.reason == CHARGE and claim(_sell).reason == DISCHARGE)
|
|
# SAFETY-02's arming gate, which an arbitrage winner must NOT leave armed.
|
|
check("SAFETY-02 is disarmed while arbitrage drives, exactly as it is during a "
|
|
"maintenance grid charge: the strategy commands AGAINST the meter on "
|
|
"purpose, so sign agreement is not the expectation",
|
|
_main.loop_is_following(
|
|
resolve([Claim.limit("supervised", P_SAFETY, -MAX_W, MAX_W),
|
|
Claim.set("loop", P_LOOP, -120.0, "following"),
|
|
_charge_claim]), -120.0) is False)
|
|
check("...and it is still ARMED when arbitrage is idle and the loop wins, so "
|
|
"the line above is not simply a watchdog that never fires",
|
|
_main.loop_is_following(_idle_res, -120.0) is True)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print()
|
|
print("7. AC4 - 12 months of replayed prices, and what could NOT be measured")
|
|
|
|
# ⚠️ READ THIS BEFORE READING THE NUMBERS BELOW. THIS SECTION IS THE SYNTHETIC
|
|
# YEAR, AND IT IS NOT AC4's EVIDENCE ANY MORE - SECTION 7B IS.
|
|
#
|
|
# When OPT-04 shipped, this repository held no price history: `scenarios/`
|
|
# carried three gzipped P1 METER captures and nothing else. AC4 was therefore
|
|
# reported NOT MET rather than redefined into something passable, which is what
|
|
# TARIFF-01 and SAFETY-07 had both been upheld for doing.
|
|
#
|
|
# DATA-02 pointed out that the missing ingredient was never a capture. EPEX BE
|
|
# day-ahead is PUBLIC DATA; the 30-day meter criterion it was being carried
|
|
# beside genuinely needs waiting, and this one only ever needed a fetch. Section
|
|
# 7B replays the real year and AC4 is met there.
|
|
#
|
|
# ⚠️ SO WHY IS THE SYNTHETIC YEAR STILL HERE? Because the real one cannot grade
|
|
# §17, and 7B proves that rather than assuming it. Across all 365 real days, the
|
|
# number whose DEAREST hour is priced below zero is ZERO - raw wholesale and
|
|
# all-in alike - so "no discharges into a negative hour" over the real year is a
|
|
# count of something that never had the chance to happen. Deleting the guard
|
|
# would not change it. This section's generator drives a summer midday block
|
|
# negative ON PURPOSE, and it is the only thing in this file that puts a
|
|
# negative price where the strategy would otherwise sell.
|
|
#
|
|
# What this section establishes is the half of AC4 that does not need real
|
|
# prices, and it is a property of the decision rule rather than an outcome to be
|
|
# hoped for:
|
|
#
|
|
# every executed round trip has (sell - buy) >= required >= threshold,
|
|
# so its benefit net of the threshold is >= 0, on every day, always.
|
|
#
|
|
# That is asserted per trade below, over 365 varied days including negative ones.
|
|
# HOW OFTEN a real Belgian year clears the threshold is the other question, it
|
|
# is the one the product owner's open decision turns on, and section 7B is the
|
|
# only place in this file that answers it.
|
|
|
|
def synth_day(n):
|
|
"""A synthetic all-in day-ahead day. Deterministic, seeded by day number.
|
|
|
|
A seasonal base plus a two-peak diurnal shape plus a per-day amplitude, and
|
|
a summer midday block that is driven NEGATIVE - the §17 case has to occur in
|
|
the replay or the replay never exercises it.
|
|
"""
|
|
import math
|
|
base = 0.22 + 0.06 * math.cos(2 * math.pi * (n - 10) / 365.0)
|
|
amp = 0.05 + 0.09 * abs(math.sin(n * 1.7)) # varies day to day
|
|
out = []
|
|
for h in range(24):
|
|
shape = (math.sin(2 * math.pi * (h - 8) / 24.0)
|
|
+ 0.6 * math.sin(4 * math.pi * (h - 5) / 24.0))
|
|
p = base + amp * shape
|
|
if 140 < n < 230 and 11 <= h <= 14: # summer solar glut
|
|
p -= 0.45 * amp * 6
|
|
out.append(round(p, 6))
|
|
return out
|
|
|
|
|
|
REPLAY_START = datetime(2026, 1, 1, 0, 0, tzinfo=UTC)
|
|
CAP_WH = 10000.0 # the pack the replay moves energy through
|
|
STEP_PCT = 100.0 * MAX_W / CAP_WH # one hour at the rail, in percent
|
|
|
|
|
|
def replay(threshold, unc, *, soc0=50.0, floor=20.0, ceiling=95.0):
|
|
"""Run `decide()` over a synthetic year. Returns the ledger, nothing else."""
|
|
soc = soc0
|
|
trades, holdings, bad = [], [], []
|
|
neg_sells = 0
|
|
for n in range(365):
|
|
day0 = REPLAY_START + timedelta(days=n)
|
|
prices = synth_day(n)
|
|
plan = plan_of(prices, start=day0)
|
|
open_buy = None
|
|
for h in range(24):
|
|
d = act(plan, h, thresh=threshold, unc=unc, soc=soc, floor=floor,
|
|
ceiling=ceiling, start=day0)
|
|
if d.action is None:
|
|
continue
|
|
price = prices[h]
|
|
if d.action == CHARGE:
|
|
soc = min(ceiling, soc + STEP_PCT)
|
|
open_buy = (price, d)
|
|
else:
|
|
if price < 0:
|
|
neg_sells += 1
|
|
soc = max(floor, soc - STEP_PCT)
|
|
if open_buy is not None:
|
|
buy_price, buy_d = open_buy
|
|
trades.append((buy_price, price, d))
|
|
open_buy = None
|
|
else:
|
|
holdings.append((n, h, price))
|
|
if d.spread_eur_kwh is None or d.required_eur_kwh is None:
|
|
bad.append((n, h))
|
|
return trades, holdings, bad, neg_sells
|
|
|
|
|
|
_trades, _held_over, _bad, _neg_sells = derived(
|
|
lambda: replay(0.05, NO_WEATHER)) or (None, None, None, None)
|
|
_kwh = MAX_W / 1000.0
|
|
check(f"the replay is 365 days long and actually traded ({len(_trades)} "
|
|
f"completed round trips), or every invariant below would hold vacuously",
|
|
len(_trades) > 30)
|
|
check("...and every trade carries the spread and the threshold it cleared, so "
|
|
"the ledger is auditable rather than a count",
|
|
_bad == [])
|
|
check("...and NOT every day traded, so the strategy is selective rather than "
|
|
"unconditional - a replay in which everything trades grades nothing",
|
|
len(_trades) < 365)
|
|
_losses = [(b, s) for b, s, _ in _trades if s - b < 0.05]
|
|
check(f"EVERY completed round trip cleared the threshold it was run at: sell "
|
|
f"minus buy >= 0.05 EUR/kWh on all {len(_trades)} of them "
|
|
f"({len(_losses)} exceptions)", _losses == [])
|
|
_under = [d.spread_eur_kwh for _b, _s, d in _trades
|
|
if d.spread_eur_kwh < d.required_eur_kwh]
|
|
check("...and each one cleared its own WIDENED requirement, which is the "
|
|
"stricter statement and the one §8.3 actually asks for",
|
|
_under == [])
|
|
_net = sum((s - b - 0.05) * _kwh for b, s, _ in _trades)
|
|
check(f"so the replayed year is net-positive BY CONSTRUCTION, not by luck: "
|
|
f"{_net:+.2f} EUR of benefit net of the cycle cost over "
|
|
f"{len(_trades)} trades at {_kwh:.1f} kWh each, and no single trade "
|
|
f"contributes a negative term", _net > 0)
|
|
check(f"§17 held for the whole year: {_neg_sells} discharges into a "
|
|
f"negative-price hour", _neg_sells == 0)
|
|
# ...and the replay really did contain negative hours, or the line above is
|
|
# a count of something that never had the chance to happen.
|
|
_neg_hours = sum(1 for n in range(365) for p in synth_day(n) if p < 0)
|
|
check(f"...and it had the chance to fail: {_neg_hours} hours of the synthetic "
|
|
f"year are priced below zero", _neg_hours > 100)
|
|
# The shipped default, over the same year. Reported, not tuned.
|
|
_sh_trades = derived(lambda: replay(_shipped, NO_WEATHER)[0])
|
|
print(f" [report] at the SHIPPED threshold {_shipped} EUR/kWh widened by "
|
|
f"{widen_factor(NO_WEATHER)}x, the same synthetic year completes "
|
|
f"{len(_sh_trades)} round trips; at 0.05 it completes {len(_trades)}. "
|
|
f"The shipped default is deliberately conservative and the number that "
|
|
f"matters is the one a REAL year would give, which this repository "
|
|
f"cannot answer.")
|
|
check("the shipped threshold is strictly more selective than the loose one on "
|
|
"the same year - the parameter does what it says, at whatever value the "
|
|
"owner sets it to",
|
|
len(_sh_trades) < len(_trades))
|
|
check("...and the shipped default was NOT moved to make any of this look "
|
|
"better: app/arbitrage.py reads the threshold through price_option() and "
|
|
"names no number of its own",
|
|
"0.10" not in open(os.path.join(HERE, "app", "arbitrage.py"),
|
|
encoding="utf-8").read().split('"""', 2)[2])
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print()
|
|
print("7B. AC4 - twelve months of REAL EPEX BE prices, replayed")
|
|
|
|
# THE DATASET, AND WHAT IT IS NOT.
|
|
#
|
|
# `scenarios/epex-be-dayahead-2025-09-01_2026-09-01.json` - 8760 hourly Belgian
|
|
# day-ahead auction prices, 2025-09-01 to 2026-09-01 Brussels-local, fetched by
|
|
# `scenarios/fetch_epex_be.py` from Fraunhofer ISE's Energy-Charts API, which
|
|
# publishes the BE zone under CC BY 4.0 from Bundesnetzagentur | SMARD.de. The
|
|
# licence was checked BEFORE the data was committed, because the same API serves
|
|
# other zones under private-use-only terms; the script refuses to write a file
|
|
# whose per-response `license_info` stops saying CC BY 4.0. Provenance,
|
|
# attribution and the DST accounting live in the file's own `provenance` block
|
|
# and `fetch_epex_be.py --verify` re-derives every claim in it.
|
|
#
|
|
# ⚠️ THE FIGURES IN THAT FILE ARE RAW WHOLESALE, NOT ALL-IN, AND THIS SECTION IS
|
|
# WHERE THE DIFFERENCE IS MADE VISIBLE. §9.3's canonical point is all-in; the
|
|
# committed file says `"all_in": false` and carries the markup/tax assumption as
|
|
# NUMBERS TO APPLY rather than numbers already applied - TARIFF-02's
|
|
# double-counting trap, avoided by never baking the guess in. `_all_in()` below
|
|
# applies it in one place where a reader can watch it happen, and both replays
|
|
# are run, so exactly how much of the answer rests on the guess is reported
|
|
# rather than hidden.
|
|
|
|
import json # noqa: E402
|
|
from collections import defaultdict # noqa: E402
|
|
|
|
_DS_PATH = os.path.join(HERE, "scenarios",
|
|
"epex-be-dayahead-2025-09-01_2026-09-01.json")
|
|
_DS = derived(lambda: json.load(open(_DS_PATH, encoding="utf-8")))
|
|
_ASSUME = _DS["provenance"]["all_in_assumption"]
|
|
|
|
|
|
def _all_in(wholesale):
|
|
"""The stated assumption, applied HERE and nowhere in the committed data.
|
|
|
|
all_in = (wholesale + markup + network_and_levies) * (1 + vat), with the
|
|
coefficients read from the dataset's own provenance block rather than
|
|
retyped - a second copy is a second thing to disagree with the file.
|
|
"""
|
|
return round((wholesale + _ASSUME["markup_eur_per_kwh"]
|
|
+ _ASSUME["network_and_levies_eur_per_kwh"])
|
|
* (1.0 + _ASSUME["vat_fraction"]), 6)
|
|
|
|
|
|
def _days(price_of):
|
|
"""The dataset as Brussels calendar days of `PricePoint`s, in order.
|
|
|
|
⚠️ GROUPED BY THE LOCAL DATE IN THE `start` STRING, NOT BY SLICING 24 AT A
|
|
TIME. Two of these days are not 24 hours long, which is the whole DST check,
|
|
and a fixed stride would put October's 25th hour into the next day and
|
|
misalign every day after it.
|
|
"""
|
|
out = defaultdict(list)
|
|
for p in _DS["prices"]:
|
|
out[p["start"][:10]].append(
|
|
PricePoint(start=datetime.fromisoformat(p["start"]),
|
|
eur_per_kwh=price_of(p["eur_per_kwh"]),
|
|
provisional=bool(p["provisional"])))
|
|
return [out[k] for k in sorted(out)]
|
|
|
|
|
|
_AI_DAYS = derived(lambda: _days(_all_in))
|
|
_RAW_DAYS = derived(lambda: _days(lambda w: w))
|
|
|
|
# ---- the dataset is not degenerate, asserted BEFORE anything is graded ------
|
|
_lens = [len(d) for d in _AI_DAYS]
|
|
check(f"the replay has {len(_AI_DAYS)} Brussels days and {sum(_lens)} hourly "
|
|
f"points, which is a full year rather than a fortnight repeated",
|
|
len(_AI_DAYS) == 365 and sum(_lens) == 8760)
|
|
check(f"...and the day lengths are {sorted(set(_lens))}, so BOTH DST days are "
|
|
f"in it: an importer that produced 24 everywhere would have lost an hour "
|
|
f"in October and invented one in March",
|
|
sorted(set(_lens)) == [23, 24, 25]
|
|
and _lens.count(23) == 1 and _lens.count(25) == 1)
|
|
_dst_day = [d for d in _AI_DAYS if len(d) == 25][0]
|
|
check("...and the 25-hour day's two 02:00 points are two different INSTANTS at "
|
|
"two different offsets, which is what `at()` has to tell apart",
|
|
len({p.start for p in _dst_day}) == 25
|
|
and {p.start.utcoffset() for p in _dst_day}
|
|
== {timedelta(hours=1), timedelta(hours=2)})
|
|
|
|
_ai_spreads = sorted(round(max(p.eur_per_kwh for p in d)
|
|
- min(p.eur_per_kwh for p in d), 6)
|
|
for d in _AI_DAYS)
|
|
_raw_spreads = sorted(round(max(p.eur_per_kwh for p in d)
|
|
- min(p.eur_per_kwh for p in d), 6)
|
|
for d in _RAW_DAYS)
|
|
_req_shipped = required_spread(_shipped, NO_WEATHER)
|
|
_clears = [s for s in _ai_spreads if s > _req_shipped]
|
|
check(f"...and the year's daily spreads STRADDLE the shipped requirement of "
|
|
f"{_req_shipped:.4f} EUR/kWh - {len(_clears)} of {len(_ai_spreads)} days "
|
|
f"clear it - so the threshold is grading something. A year in which every "
|
|
f"day cleared, or none did, would grade nothing at any value",
|
|
0 < len(_clears) < len(_ai_spreads))
|
|
check(f"...and the spreads are not one repeated number: median "
|
|
f"{_ai_spreads[len(_ai_spreads) // 2]:.4f}, min {_ai_spreads[0]:.4f}, "
|
|
f"max {_ai_spreads[-1]:.4f}",
|
|
len(set(_ai_spreads)) > 300 and _ai_spreads[-1] > 4 * _ai_spreads[0])
|
|
|
|
# ---- the assumption's arithmetic, asserted rather than asserted-about -------
|
|
check("the ADDITIVE half of the all-in assumption cancels out of every daily "
|
|
"spread, so the trade counts below do not depend on the markup guess at "
|
|
"all - only on the VAT multiplier",
|
|
all(near(a, r * (1.0 + _ASSUME["vat_fraction"]), 1e-6)
|
|
for a, r in zip(_ai_spreads, _raw_spreads)))
|
|
|
|
# ---- ⚠️ THE DEGENERACY THAT KEEPS SECTION 7 ALIVE --------------------------
|
|
_raw_neg_h = sum(1 for d in _RAW_DAYS for p in d if p.eur_per_kwh < 0)
|
|
_ai_neg_h = sum(1 for d in _AI_DAYS for p in d if p.eur_per_kwh < 0)
|
|
_raw_neg_dear = sum(1 for d in _RAW_DAYS if max(p.eur_per_kwh for p in d) < 0)
|
|
_ai_neg_dear = sum(1 for d in _AI_DAYS if max(p.eur_per_kwh for p in d) < 0)
|
|
check(f"the real year DOES contain negative hours - {_raw_neg_h} of them at "
|
|
f"wholesale, {_ai_neg_h} still negative once the assumed markup and "
|
|
f"charges are added - so §17 is a live case on this market and not a "
|
|
f"hypothetical", _raw_neg_h > 100 and _ai_neg_h > 0)
|
|
check(f"BUT NOT ONE OF THE 365 DAYS HAS A NEGATIVE DEAREST HOUR "
|
|
f"({_raw_neg_dear} raw, {_ai_neg_dear} all-in), and the guard is on the "
|
|
f"hour being SOLD INTO. So a count of zero negative-price discharges over "
|
|
f"this year would be a count of something that could not happen, and "
|
|
f"section 7's synthetic year stays because it is the only thing here that "
|
|
f"puts a negative price where the strategy would sell",
|
|
_raw_neg_dear == 0 and _ai_neg_dear == 0)
|
|
|
|
|
|
def replay_real(days, threshold, unc, *, soc0=50.0, floor=20.0, ceiling=95.0):
|
|
"""The shipped `decide()` over the real year. Returns the ledger, nothing else.
|
|
|
|
One `TariffPlan` per Brussels day, so "the cheapest hour" means the cheapest
|
|
hour of that day - the same shape as section 7's replay, and the same shape
|
|
a site with a day-ahead feed actually sees.
|
|
"""
|
|
soc = soc0
|
|
trades, holdings, bad, neg_sells = [], [], [], 0
|
|
for day in days:
|
|
plan = TariffPlan(
|
|
PriceRead.series(day, PriceProduct.DAY_AHEAD, "generic_dayahead",
|
|
day[0].start), None, "provider")
|
|
open_buy = None
|
|
for p in day:
|
|
d = decide(plan, p.start + timedelta(minutes=30),
|
|
peak_guard_active=False, soc=soc,
|
|
min_spread_eur_kwh=threshold, uncertainty=unc,
|
|
charge_w=MAX_W, discharge_w=MAX_W,
|
|
soc_floor_pct=floor, soc_ceiling_pct=ceiling)
|
|
if d.action is None:
|
|
continue
|
|
if d.spread_eur_kwh is None or d.required_eur_kwh is None:
|
|
bad.append(p.start)
|
|
if d.action == CHARGE:
|
|
soc = min(ceiling, soc + STEP_PCT)
|
|
open_buy = (p.eur_per_kwh, d)
|
|
else:
|
|
if p.eur_per_kwh < 0:
|
|
neg_sells += 1
|
|
soc = max(floor, soc - STEP_PCT)
|
|
if open_buy is not None:
|
|
trades.append((open_buy[0], p.eur_per_kwh, d))
|
|
open_buy = None
|
|
else:
|
|
holdings.append(p.start)
|
|
return trades, holdings, bad, neg_sells
|
|
|
|
|
|
# ⚠️ AT THE SHIPPED THRESHOLD, UNTOUCHED, AND WIDENED BY FCST-03's NO-WEATHER
|
|
# FACTOR - the harsher of the two operating states, so the figure reported is
|
|
# the conservative one.
|
|
_r_trades, _r_held, _r_bad, _r_neg = derived(
|
|
lambda: replay_real(_AI_DAYS, _shipped, NO_WEATHER)) or (None,) * 4
|
|
_r_kwh = MAX_W / 1000.0
|
|
_r_net = round(sum((s - b - _shipped) * _r_kwh for b, s, _ in _r_trades), 2)
|
|
|
|
check(f"the real year traded at the SHIPPED threshold: {len(_r_trades)} "
|
|
f"completed round trips over 365 days of Belgian day-ahead prices - so "
|
|
f"the criterion is being graded on trades that happened, not on an empty "
|
|
f"ledger that is net-positive by vacuity", len(_r_trades) > 0)
|
|
check("...and every one of them carries the spread and the widened requirement "
|
|
"it cleared, so the ledger is auditable rather than a count", _r_bad == [])
|
|
check(f"...and it did NOT trade every day ({len(_r_trades)} round trips over "
|
|
f"365 days), so the threshold selected rather than waved everything "
|
|
f"through", len(_r_trades) < 365)
|
|
_r_losses = [(b, s) for b, s, _ in _r_trades if s - b < _shipped]
|
|
check(f"AC4: every completed round trip on the REAL year cleared the shipped "
|
|
f"{_shipped} EUR/kWh - {len(_r_losses)} exceptions of {len(_r_trades)}",
|
|
_r_losses == [])
|
|
_r_under = [d.spread_eur_kwh for _b, _s, d in _r_trades
|
|
if d.spread_eur_kwh < d.required_eur_kwh]
|
|
check(f"...and each cleared its own WIDENED requirement of "
|
|
f"{_req_shipped:.4f} EUR/kWh, which is the stricter statement §8.3 asks "
|
|
f"for", _r_under == [])
|
|
check(f"AC4 IS MET: twelve months of REAL replayed EPEX BE prices are "
|
|
f"NET-POSITIVE at the shipped default - {_r_net:+.2f} EUR of benefit net "
|
|
f"of the cycle cost over {len(_r_trades)} round trips of {_r_kwh:.1f} kWh "
|
|
f"each, accumulated from the trades the strategy actually chose",
|
|
_r_net > 0)
|
|
|
|
# ---- what the owner's open decision actually turns on -----------------------
|
|
# ⚠️ REPORTED, NOT SETTLED. `cycle_cost_threshold_eur` and FCST-03's 2.5/1.5 are
|
|
# the product owner's decision. DATA-02 supplies the input and changes nothing:
|
|
# every number below comes out of the same untouched `decide()`.
|
|
_r_calm = derived(lambda: replay_real(_AI_DAYS, _shipped, frozenset())[0])
|
|
_r_raw = derived(lambda: replay_real(_RAW_DAYS, _shipped, NO_WEATHER)[0])
|
|
# The threshold at which the MEDIAN day of this year clears its widened
|
|
# requirement. Derived from the observed spreads, not chosen.
|
|
_median_spread = _ai_spreads[len(_ai_spreads) // 2]
|
|
_suggested = round(_median_spread / widen_factor(NO_WEATHER), 4)
|
|
_r_sugg, _, _, _ = derived(
|
|
lambda: replay_real(_AI_DAYS, _suggested, NO_WEATHER)) or (None,) * 4
|
|
_sugg_net = round(sum((s - b - _suggested) * _r_kwh for b, s, _ in _r_sugg), 2)
|
|
print(f" [report] SHIPPED {_shipped} EUR/kWh, widened {widen_factor(NO_WEATHER)}x "
|
|
f"to {_req_shipped:.4f}: {len(_r_trades)} round trips, {_r_net:+.2f} EUR "
|
|
f"over the year. Unwidened (weather present): {len(_r_calm)} trips. On "
|
|
f"RAW wholesale instead of the assumed all-in: {len(_r_raw)} trips, so "
|
|
f"the markup/VAT guess moves the count by "
|
|
f"{len(_r_trades) - len(_r_raw):+d}.")
|
|
print(f" [report] the year's MEDIAN daily all-in spread is "
|
|
f"{_median_spread:.4f} EUR/kWh, so the threshold at which a median day "
|
|
f"clears its widened requirement is {_suggested} - and at that value the "
|
|
f"same year completes {len(_r_sugg)} round trips for {_sugg_net:+.2f} "
|
|
f"EUR. THE SHIPPED DEFAULT IS NOT CHANGED BY THIS CARD; both numbers are "
|
|
f"reported so the owner can decide between them.")
|
|
print(f" [report] and the four-developer finding stands unchanged: a STATIC "
|
|
f"day/night spread of ~0.0840 EUR/kWh still does not clear "
|
|
f"{_req_shipped:.4f}, so a static-tariff site never trades at the shipped "
|
|
f"default. What this year adds is that a DYNAMIC site does - on "
|
|
f"{len(_clears)} of its 365 days the spread was there to be taken.")
|
|
check("the loose threshold is strictly less selective than the shipped one on "
|
|
"the SAME real year - the parameter does what it says at whatever value "
|
|
"the owner sets, and neither run tuned anything",
|
|
len(_r_sugg) > len(_r_trades) > 0)
|
|
check(f"§17 held over the real year too ({_r_neg} discharges into a negative "
|
|
f"hour) - but see the degeneracy check above: on this data that zero is "
|
|
f"structural, and section 7's synthetic year is what actually grades the "
|
|
f"guard", _r_neg == 0)
|
|
check("the dataset carries its own provenance, states it is NOT all-in, and "
|
|
"keeps the markup/tax figures as an assumption rather than applying "
|
|
"them - the TARIFF-02 trap, in data form",
|
|
_DS["provenance"]["all_in"] is False
|
|
and "ASSUMPTION" in _ASSUME["status"]
|
|
and _DS["provenance"]["licence"].startswith("CC BY 4.0"))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print()
|
|
print("8. the background refresh - the tick must never await a fetch")
|
|
|
|
|
|
_MAIN_SRC = derived(lambda: open(os.path.join(HERE, "app", "main.py"),
|
|
encoding="utf-8").read())
|
|
_cycle = derived(lambda: _fn(_MAIN_SRC, "cycle"))
|
|
_refresh = derived(lambda: _fn(_MAIN_SRC, "run_tariff_refresh"))
|
|
_read_inputs = derived(lambda: _fn(_MAIN_SRC, "read_inputs"))
|
|
_FETCHERS = {"resolve_tariff", "load_prices", "https_get"}
|
|
check("app/main.py has a run_tariff_refresh() coroutine at all",
|
|
_refresh is not None)
|
|
check("...and IT is what awaits resolve_tariff(), so the fetch has somewhere "
|
|
"to live that is not the control path",
|
|
isinstance(_refresh, ast.AsyncFunctionDef)
|
|
and "resolve_tariff" in _awaited(_refresh))
|
|
# ⚠️ THE FORWARD HAZARD, AND IT IS THE ONE THE CARD CALLS THE MOST DANGEROUS
|
|
# THING IN THIS TICKET. cycle() and read_inputs() are both on the path that
|
|
# feeds the ESP32 heartbeat; layer 1 commands 0 W if that stops for ~30 s. A
|
|
# supplier's TLS timeout on either of them is a SUPPLIER able to drive this
|
|
# add-on into the failsafe. Nothing warns about it at runtime - the add-on would
|
|
# simply be slow - so it is asserted structurally, on the shipped AST.
|
|
check(f"cycle() awaits NONE of {sorted(_FETCHERS)} - a supplier's TLS timeout "
|
|
f"on the tick is a supplier able to push this add-on towards layer 1's "
|
|
f"30 s veto {sorted(_awaited(_cycle))}",
|
|
isinstance(_cycle, ast.AsyncFunctionDef)
|
|
and not (_awaited(_cycle) & _FETCHERS))
|
|
check("...and neither does read_inputs(), which is on the same path and is "
|
|
"where a fetch would most naturally be put",
|
|
isinstance(_read_inputs, ast.AsyncFunctionDef)
|
|
and not (_awaited(_read_inputs) & _FETCHERS))
|
|
check("...and the check above can fail: the scanner really does find the "
|
|
"awaits that ARE there",
|
|
"resolve_tariff" in _awaited(_refresh)
|
|
and len(_awaited(_cycle)) > 0)
|
|
|
|
# The age bound, driven on the shipped expression rather than a copy of it.
|
|
class _Stub:
|
|
"""Just enough Controller for fresh_tariff_plan(), which touches nothing else."""
|
|
fresh_tariff_plan = _main.Controller.fresh_tariff_plan
|
|
|
|
def __init__(self, plan, at):
|
|
self.tariff_plan, self.tariff_at = plan, at
|
|
|
|
|
|
_NOW = datetime(2026, 6, 1, 12, 0, tzinfo=UTC)
|
|
_fresh_s = _main.TARIFF_MAX_AGE_S
|
|
# ⚠️ THE ABSOLUTE PROBE COMES FIRST, AND THE FIRST DRAFT SHOWED WHY. Probing at
|
|
# `TARIFF_MAX_AGE_S + 1` reads the constant under test, so widening the constant
|
|
# moves the probe with it and the check passes for a bound that has been made
|
|
# unreachable - a mutation multiplying it by a million SURVIVED that draft.
|
|
# Twelve hours is stale by any reading of a feed that publishes daily, and it is
|
|
# stated here rather than derived from the module being graded.
|
|
check("a plan resolved TWELVE HOURS ago is not handed to the strategy, whatever "
|
|
"the module's own constant says - a stale price is not a fresh one",
|
|
_Stub(PLAN, _NOW - timedelta(hours=12)).fresh_tariff_plan(_NOW) is None)
|
|
check(f"...and the constant is inside the range that makes that true: "
|
|
f"{_fresh_s:.0f} s is under six hours and over one refresh interval",
|
|
_main.TARIFF_REFRESH_S < _fresh_s < 6 * 3600)
|
|
check("a plan resolved a moment ago IS handed to the strategy, so the two "
|
|
"lines above are a bound rather than a blanket refusal",
|
|
_Stub(PLAN, _NOW - timedelta(seconds=1)).fresh_tariff_plan(_NOW) is PLAN)
|
|
check(f"...and the bound is where the module says it is: one second past "
|
|
f"{_fresh_s:.0f} s fails and one second short of it passes",
|
|
_Stub(PLAN, _NOW - timedelta(seconds=_fresh_s + 1)
|
|
).fresh_tariff_plan(_NOW) is None
|
|
and _Stub(PLAN, _NOW - timedelta(seconds=_fresh_s - 1)
|
|
).fresh_tariff_plan(_NOW) is PLAN)
|
|
check("a plan with no timestamp is not fresh either - both terms are required, "
|
|
"and 'nothing stored it' is not 'it is current'",
|
|
_Stub(PLAN, None).fresh_tariff_plan(_NOW) is None
|
|
and _Stub(None, _NOW).fresh_tariff_plan(_NOW) is None)
|
|
check("the refresh cadence is strictly under the age bound, or the plan would "
|
|
"expire between two healthy refreshes and arbitrage would flicker",
|
|
_main.TARIFF_REFRESH_S < _main.TARIFF_MAX_AGE_S)
|
|
# CFG-03: main.py gained no fourth copy of an option default.
|
|
_names = derived(lambda: {n.id for n in ast.walk(ast.parse(_MAIN_SRC))
|
|
if isinstance(n, ast.Name)})
|
|
check("main.py reads every arbitrage option through app/arbitrage.py or "
|
|
"price_option(), never through `o.get('cycle_cost_threshold_eur', <a "
|
|
"number>)` - CFG-03 still tracks three places, not four",
|
|
all(f'"{k}"' not in _MAIN_SRC and f"'{k}'" not in _MAIN_SRC
|
|
for k in ("cycle_cost_threshold_eur", "arbitrage_soc_floor",
|
|
"arbitrage_soc_ceiling")))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print()
|
|
print("9. the startup gate - the configurations that cannot work are refused")
|
|
|
|
|
|
def vbase(**over):
|
|
o = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
|
|
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
|
"meter_source": "off",
|
|
"maintenance_charge_w": 2000, "maintenance_discharge_w": 2000}
|
|
o.update(over)
|
|
return o
|
|
|
|
|
|
def why(problems, needle):
|
|
return any(needle in p for p in problems)
|
|
|
|
|
|
P1_ON = {"meter_source": "ha_signed", "p1_net_entity": "sensor.p1"}
|
|
check("the shipped configuration still starts - arbitrage_enabled ships off "
|
|
"and this ticket may not have made an existing install unbootable",
|
|
validate_startup_config(vbase(), -5000, 5000) == []
|
|
and enabled({}) is False)
|
|
check("arbitrage on with a provider and P1 starts",
|
|
validate_startup_config(
|
|
vbase(arbitrage_enabled=True, price_provider="generic_dayahead",
|
|
**P1_ON), -5000, 5000) == [])
|
|
check("...and so does arbitrage on a static profile with P1, because §9.2 "
|
|
"makes a static site first-class rather than unsupported",
|
|
validate_startup_config(
|
|
vbase(arbitrage_enabled=True, static_profile=STATIC_BE, **P1_ON),
|
|
-5000, 5000) == [])
|
|
check("arbitrage on with NO price source at all is refused - a strategy that "
|
|
"must refuse on every cycle forever looks exactly like one that is "
|
|
"running and finding nothing",
|
|
why(validate_startup_config(vbase(arbitrage_enabled=True, **P1_ON),
|
|
-5000, 5000), "NO PRICES AT ALL"))
|
|
check("arbitrage on with meter_source off is refused - §9.1 puts peak shaving "
|
|
"above arbitrage and an unmeasured peak cannot be shaved",
|
|
why(validate_startup_config(
|
|
vbase(arbitrage_enabled=True, price_provider="generic_dayahead"),
|
|
-5000, 5000), "meter_source"))
|
|
check("a floor at or above the ceiling is refused - there is no band left "
|
|
"between them and the strategy could never act",
|
|
why(validate_startup_config(
|
|
vbase(arbitrage_enabled=True, price_provider="generic_dayahead",
|
|
arbitrage_soc_floor=90, arbitrage_soc_ceiling=90, **P1_ON),
|
|
-5000, 5000), "no band left"))
|
|
check("...and the same numbers the right way round start",
|
|
validate_startup_config(
|
|
vbase(arbitrage_enabled=True, price_provider="generic_dayahead",
|
|
arbitrage_soc_floor=25, arbitrage_soc_ceiling=90, **P1_ON),
|
|
-5000, 5000) == [])
|
|
check("a NEGATIVE cycle_cost_threshold_eur is refused even with arbitrage OFF "
|
|
"- it is a cost, and a negative one is a threshold every spread clears",
|
|
why(validate_startup_config(vbase(cycle_cost_threshold_eur=-0.01),
|
|
-5000, 5000), "non-negative")
|
|
and raises(lambda: threshold_eur_kwh({"cycle_cost_threshold_eur": -1}),
|
|
ValueError))
|
|
# Rule 7's wording, which OPT-04 had to update because it named what main.py
|
|
# did NOT do. test_price.py section 12 pins the load_prices consumer set; this
|
|
# pins the sentence that set exists to keep honest.
|
|
_V = derived(lambda: open(os.path.join(HERE, "app", "validate.py"),
|
|
encoding="utf-8").read())
|
|
check("rule 7's message no longer says a configured provider shapes no energy "
|
|
"decision - OPT-04 made that false, and the instruction attached to the "
|
|
"tripwire was to fix the wording rather than to silence the check",
|
|
isinstance(_V, str)
|
|
and "shape no energy decision" not in _V
|
|
and "still shapes no energy decision" not in _V)
|
|
check("...and it names the consumer that now exists, so it cannot decay into a "
|
|
"refusal with no stated reason",
|
|
isinstance(_V, str) and "app/arbitrage.py" in _V)
|
|
# The option mirror, pinned to config.yaml the way TARIFF-02 pinned the rest.
|
|
_CFG = derived(lambda: open(os.path.join(HERE, "config.yaml"),
|
|
encoding="utf-8").read())
|
|
check("every option this card added is in config.yaml's options block AND its "
|
|
"schema block, or the Supervisor drops it and the add-on silently runs "
|
|
"on the mirror instead of the operator's value",
|
|
isinstance(_CFG, str)
|
|
and all(_CFG.count(f"\n {k}:") == 2
|
|
for k in ("arbitrage_enabled", "cycle_cost_threshold_eur",
|
|
"arbitrage_soc_floor", "arbitrage_soc_ceiling")))
|
|
check("...and app/arbitrage.py reads them through price_option() only - one "
|
|
"read path, one place the defaults live",
|
|
"PRICE_OPTION_DEFAULTS[" not in
|
|
open(os.path.join(HERE, "app", "arbitrage.py"), encoding="utf-8").read())
|
|
check("soc_bounds() returns the shipped pair, floor first",
|
|
soc_bounds({}) == (float(PRICE_OPTION_DEFAULTS["arbitrage_soc_floor"]),
|
|
float(PRICE_OPTION_DEFAULTS["arbitrage_soc_ceiling"])))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
|
sys.exit(1)
|
|
print("all checks passed")
|