checks / unit (pull_request) Successful in 3m15s
OPT-04's reviewer weakened `if price < 0` to `if price < -0.001` and every suite stayed green while a -0.0005 EUR/kWh sell hour discharged. The shipped code was right the whole time; the CHECK could not tell correct from nearly-correct. The only probe near the boundary sat at 0.0000, which catches a `<=` and says nothing about a tolerance invented on the other side - and a tolerance on the other side is §17's named regression walking through with the suite passing. A dearest hour at -0.0005 is an ordinary Belgian day-ahead print, not a fuzz value. `app/` IS NOT TOUCHED. `if price < 0` is already correct and changing it would be the defect; this card closes the check, not the behaviour. The 0.0000 probe stays and is now labelled as the half that pins the boundary from above. The new probe is a SWEEP rather than a point, because one probe at -0.0005 closes `-0.001` and nothing else - a mutation reaching for `-0.01` would be the identical defect one decimal further out. Five prices span four orders of magnitude down to -0.000001, the last place the canonical six-decimal format can express; each is asserted to survive that rounding as strictly negative, to still be the DEAREST hour, and to still clear the widened threshold, so §17 is the only rung that can refuse. The same five magnitudes with the sign flipped all discharge, so this is a boundary at exactly zero rather than a refusal of small numbers. AC4, the look: every price/quantity boundary in arbitrage.py and static_tariff.py was given the same tolerance mutation, both directions where both exist. Two survive, both in static_tariff.py and both NAMED RATHER THAN FIXED - they are TARIFF-03's rungs and want probes in its own file. `spread == 0.0` loosened to `<= 0.001` flips a real 0.0005 spread from allowed to refused at min_spread_eur_kwh=0.0, a documented supported value, and otherwise prints a false reason about a tariff that is not flat. `len(firm) < 2` loosened to `< 1` survives at +0 bytes - TEST-15's risky delta - and is masked only by the flat rung, so the two mask each other and a later edit to either opens a hole nothing reports. Mutation verified under `python -B` with a recursive __pycache__ purge, byte delta printed (+5 B), the mutated line asserted changed, and - per the reviewer's addition to TEST-15 - the tree re-read from disk and asserted to be the tree that was mutated before each run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
1116 lines
57 KiB
Python
1116 lines
57 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. ⚠️ SEE SECTION 7. THIS
|
|
REPOSITORY HOLDS NO PRICE HISTORY AT ALL - `scenarios/` carries two days
|
|
of real METER data and nothing else - so the criterion as written cannot
|
|
be met, and it is NOT redefined here into something that can be. What
|
|
section 7 does instead is stated there in full, and what it cannot say is
|
|
stated with it.
|
|
|
|
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.
|
|
"""
|
|
|
|
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.
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
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.
|
|
#
|
|
# The acceptance criterion is "net-positive across 12 months of replayed prices
|
|
# in simulation". THIS REPOSITORY CONTAINS NO PRICE HISTORY. `scenarios/` holds
|
|
# three gzipped captures and all three are P1 METER data - two days of net power
|
|
# from the reference house plus one age capture. There is no EPEX archive, no
|
|
# supplier export, and no CSV of a past year anywhere in the tree.
|
|
#
|
|
# TARIFF-01 and SAFETY-07 both hit an unmeetable criterion and both DECLINED to
|
|
# redefine it into one they could pass, and both were upheld for it. So:
|
|
#
|
|
# AC4 IS NOT MET, AND IT IS NOT RESTATED. What follows is not a replay of the
|
|
# Belgian market; it is a replay of a SYNTHETIC year, and a euro figure from a
|
|
# synthetic year grades the generator that produced it, not the strategy.
|
|
#
|
|
# What the replay is FOR is the half of AC4 that does not need real prices. Net
|
|
# positivity here is not an empirical outcome to be hoped for - it is a property
|
|
# of the decision rule, and the replay's job is to demonstrate that the property
|
|
# holds on every trade the strategy actually chose, over a year of varied days
|
|
# including negative-price ones. Stated as an invariant:
|
|
#
|
|
# 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 days. What it does NOT establish -
|
|
# and no synthetic series could - is how OFTEN a real Belgian year clears the
|
|
# threshold, which is the actual open question and the reason the shipped
|
|
# default is a product decision rather than a number this card picked.
|
|
|
|
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("AC4 IS REPORTED AS NOT MET rather than redefined: this file says so in "
|
|
"section 7's preamble, and there is no price history in the repository "
|
|
"to meet it with",
|
|
not any(f.endswith((".csv", ".json.gz"))
|
|
and "price" in f.lower()
|
|
for f in os.listdir(os.path.join(HERE, "scenarios"))))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
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")
|