The production-shaped plan sees tomorrow from 13:00, so most buys are sold the next day or later. The ledger forgot the open buy at each midnight and booked 46 of 51 sales as holdings: 5 trips, +1.21 EUR. Carried over, the same decisions give 51 trips, +13.81 EUR, all clearing the threshold. Decisions are unchanged and identical on the old and new verdict. No assertion changed. DECISIONS: 27.5 -> 42.6 kWh is the refresh-:00, tomorrow-known variant; give the range of the other three. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
3034 lines
164 KiB
Python
3034 lines
164 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 math
|
|
import os
|
|
import sys
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from app import arbitrage as _arb
|
|
from named_absence import first
|
|
from app import main as _main
|
|
from app.price_chain import PriceWatch
|
|
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,
|
|
)
|
|
# SAFETY-45 section 3c. The shipped loop, the shipped billed-quarter
|
|
# accumulator and the shipped projection - nothing here is transcribed.
|
|
from app.control import Tuning, compute
|
|
from app.p1 import QUARTER_S, QuarterAverager, make_sample, split_signed
|
|
from app.tariff import PeakTracker
|
|
from app.arbitrage import (
|
|
CHARGE, DISCHARGE, ArbitrageDecision, claim, decide, enabled, soc_bounds,
|
|
threshold_eur_kwh,
|
|
)
|
|
# SAFETY-47 section 3d. The shipped shadow-mode row, not a transcription of it.
|
|
from app import shadow as _shadow
|
|
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, resolve_tariff, static_plan,
|
|
)
|
|
from app.validate import validate_startup_config
|
|
|
|
UTC = timezone.utc
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
|
|
# point these fixtures build, so no period has ended and the whole series counts.
|
|
from datetime import datetime as _DT, timezone as _TZ # noqa: E402
|
|
LONG_AGO = _DT(2000, 1, 1, tzinfo=_TZ.utc)
|
|
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", days=2):
|
|
"""A TariffPlan over consecutive hourly prices starting at `start`.
|
|
|
|
`provisional` may be a single bool or a per-hour sequence.
|
|
|
|
PRICE-02: the verdict skips price periods that have ended, so a plan
|
|
probed at 18:30 no longer sees the cheap hour at 03:00 of the same day. A
|
|
running site holds 24-48 h, so `days=2` repeats the day (tomorrow's
|
|
prices are today's): probes in day one keep their extremes, and the
|
|
first cheapest/dearest hour still ahead is today's. Pass `days=1` for a
|
|
plan that must be exactly the given hours.
|
|
"""
|
|
prices = list(prices) * days
|
|
flags = ([bool(provisional)] * len(prices)
|
|
if isinstance(provisional, bool)
|
|
else list(provisional) * days)
|
|
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)
|
|
|
|
|
|
# SAFETY-45. THE HEADROOM EVERY CHECK THAT IS NOT ABOUT THE HEADROOM USES, and
|
|
# it is deliberately NOT binding: `min(MAX_W, this)` is MAX_W, so every existing
|
|
# check in this file grades what it always graded and the new rung is decoration
|
|
# in all of them. Section 3b is where it is made to bind, and 3b asserts the
|
|
# precondition that it does (MUTATION.md habit 1) rather than hoping.
|
|
HEADROOM_CLEAR = MAX_W * 10.0
|
|
|
|
|
|
def act(plan, i, *, peak=False, soc=50.0, thresh=0.0, unc=frozenset(),
|
|
floor=20.0, ceiling=95.0, start=D0, headroom=HEADROOM_CLEAR):
|
|
"""`decide()` with the fixture's constants filled in. One call shape."""
|
|
return decide(plan, hour(i, start=start), peak_guard_active=peak, soc=soc,
|
|
charge_headroom_w=headroom,
|
|
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(now=LONG_AGO,
|
|
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(now=LONG_AGO,
|
|
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("2c. TARIFF-18 - where the \u00a717 boundary sits in SPOT terms, and the "
|
|
"shipped default that decides it")
|
|
# ⚠️ WHAT THIS GRADES AND WHAT IT DOES NOT CLAIM. `price_taxes_eur_kwh` is a
|
|
# LEVEL component: it is added identically to every hour, so it cancels exactly
|
|
# in `max - min` and cannot move which hour is cheapest. test_price.py 16b
|
|
# measures that as a FAMILY - five flat scalars spanning and exceeding a real
|
|
# per-DSO range, identical argmin and identical spread to the last digit - and
|
|
# nothing here re-litigates it. Raising the shipped default 0.06 -> 0.11736 is
|
|
# therefore NOT an arbitrage-correctness fix, and this section does not say it
|
|
# is.
|
|
#
|
|
# What it IS: §17's guard is the one rung in this module that reads an
|
|
# ABSOLUTE price rather than a difference, so it is the one decision a level
|
|
# component can reach. The boundary is `spot < -(markup + taxes)`, and moving
|
|
# the taxes default moves it. Below, driven through the shipped `decide()`.
|
|
#
|
|
# ⚠️ AND THE TWO ERROR DIRECTIONS ARE NOT SYMMETRIC, WHICH IS WHY THE DEFAULT
|
|
# SITS AT THE BOTTOM OF THE PER-DSO BAND RATHER THAN IN THE MIDDLE. A taxes
|
|
# figure set too HIGH carries a genuinely negative hour up above zero and the
|
|
# add-on exports into paid-for disposal - §17's own regression case. A figure
|
|
# set too LOW only declines a discharge that would have paid. Where one default
|
|
# must stand in for eight Fluvius DSOs, it belongs at the low end.
|
|
_T18_MARKUP = PRICE_OPTION_DEFAULTS["price_markup_eur_kwh"]
|
|
_T18_TAXES = PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"]
|
|
_T18_OLD_TAXES = 0.06 # what config.yaml shipped before TARIFF-18
|
|
|
|
# ⚠️ THE PRECONDITION FIRST (MUTATION.md habit 1). Every comparison below is
|
|
# "the old level and the new level answer differently". If the two levels were
|
|
# ever the same number, all of it would be one figure graded against itself -
|
|
# DEV-02's `(-5000, 5000)` against `(-5000, 5000)`, one card over.
|
|
check(f"the shipped taxes default {_T18_TAXES} and the superseded "
|
|
f"{_T18_OLD_TAXES} are DIFFERENT numbers, and differ by more than the "
|
|
f"0.01 EUR/kWh step this fixture's spot series is built on",
|
|
abs(_T18_TAXES - _T18_OLD_TAXES) > 0.01)
|
|
|
|
# The §17 boundary, computed HERE from the two option values rather than read
|
|
# off arbitrage.py - habit 2. all-in = spot + markup + taxes, so the guard
|
|
# fires exactly when spot < -(markup + taxes).
|
|
_T18_B_NEW = round(-(_T18_MARKUP + _T18_TAXES), 6)
|
|
_T18_B_OLD = round(-(_T18_MARKUP + _T18_OLD_TAXES), 6)
|
|
_T18_PROBE = round((_T18_B_NEW + _T18_B_OLD) / 2, 6)
|
|
check(f"the guard's spot-price boundary moves {_T18_B_OLD:+.5f} -> "
|
|
f"{_T18_B_NEW:+.5f} EUR/kWh, and the probe {_T18_PROBE:+.5f} lies "
|
|
f"STRICTLY BETWEEN them - so one level answers each way and the probe is "
|
|
f"not on either boundary",
|
|
_T18_B_NEW < _T18_PROBE < _T18_B_OLD)
|
|
|
|
# A whole-day-negative spot series whose DEAREST hour is the probe. Same shape
|
|
# as section 2's: the case a guard written "not all prices are negative" lets
|
|
# through, in SPOT rather than all-in terms.
|
|
_T18_SPOT = [round(-0.40 + 0.010 * i, 6) for i in range(24)]
|
|
_T18_SPOT[DEAR_H] = _T18_PROBE
|
|
check(f"the spot day's dearest hour is slot {DEAR_H} at {_T18_PROBE:+.5f} and "
|
|
f"every other hour is below it, so the hour the guard is asked about is "
|
|
f"the one the probe was chosen for",
|
|
_T18_SPOT.index(max(_T18_SPOT)) == DEAR_H
|
|
and _T18_SPOT.count(max(_T18_SPOT)) == 1
|
|
and all(p < 0 for p in _T18_SPOT))
|
|
|
|
|
|
def _t18_all_in(taxes):
|
|
"""spot + markup + taxes, added here, by this file's own arithmetic."""
|
|
return [round(p + _T18_MARKUP + taxes, 6) for p in _T18_SPOT]
|
|
|
|
|
|
_T18_OLD_SERIES = _t18_all_in(_T18_OLD_TAXES)
|
|
_T18_NEW_SERIES = _t18_all_in(_T18_TAXES)
|
|
print(f" dearest hour all-in: old level {_T18_OLD_SERIES[DEAR_H]:+.5f}, "
|
|
f"shipped level {_T18_NEW_SERIES[DEAR_H]:+.5f} EUR/kWh")
|
|
|
|
# ⚠️ THE LEVEL BUCKET RESTATED ON THIS FIXTURE, BEFORE ANYTHING IS CONCLUDED
|
|
# FROM IT. If the two arms differed in spread or in argmin, the verdicts below
|
|
# could be attributed to that instead, and this section would be claiming an
|
|
# arbitrage effect it must not claim.
|
|
check(f"the two levels leave the SPREAD identical to the last digit "
|
|
f"({round(max(_T18_OLD_SERIES) - min(_T18_OLD_SERIES), 6)} EUR/kWh) and "
|
|
f"the cheapest hour in the same slot - so nothing below is an optimiser "
|
|
f"effect",
|
|
round(max(_T18_OLD_SERIES) - min(_T18_OLD_SERIES), 6)
|
|
== round(max(_T18_NEW_SERIES) - min(_T18_NEW_SERIES), 6)
|
|
and _T18_OLD_SERIES.index(min(_T18_OLD_SERIES))
|
|
== _T18_NEW_SERIES.index(min(_T18_NEW_SERIES)))
|
|
|
|
_T18_SELL_OLD = act(plan_of(_T18_OLD_SERIES), DEAR_H, thresh=_shipped,
|
|
unc=NO_WEATHER)
|
|
_T18_SELL_NEW = act(plan_of(_T18_NEW_SERIES), DEAR_H, thresh=_shipped,
|
|
unc=NO_WEATHER)
|
|
check(f"at the SUPERSEDED 0.06 the same spot hour is "
|
|
f"{_T18_OLD_SERIES[DEAR_H]:+.5f} all-in, so \u00a717 REFUSES the discharge "
|
|
f"- and the reason names \u00a717 and that price, not merely that nothing "
|
|
f"happened",
|
|
_T18_SELL_OLD.action is None
|
|
and "§17" in _T18_SELL_OLD.reason
|
|
and f"{_T18_OLD_SERIES[DEAR_H]:+.4f}" in _T18_SELL_OLD.reason)
|
|
check(f"...and at the SHIPPED {_T18_TAXES} the SAME spot hour is "
|
|
f"{_T18_NEW_SERIES[DEAR_H]:+.5f} all-in and the battery DISCHARGES. That "
|
|
f"is the whole of what this card changes in a decision: the operator "
|
|
f"really does pay those levies, so this hour was never paid-for disposal "
|
|
f"and the old default was declining a trade that paid",
|
|
_T18_SELL_NEW.action == DISCHARGE)
|
|
|
|
# ⚠️ AND THE OTHER SIDE OF THE SHIPPED BOUNDARY, or the pair above grades
|
|
# "bigger taxes discharge more" rather than "the boundary is at -(markup +
|
|
# taxes)". One step below the shipped boundary is still refused.
|
|
_T18_BELOW = list(_T18_SPOT)
|
|
_T18_BELOW[DEAR_H] = round(_T18_B_NEW - 0.001, 6)
|
|
_T18_SELL_BELOW = act(
|
|
plan_of([round(p + _T18_MARKUP + _T18_TAXES, 6) for p in _T18_BELOW]),
|
|
DEAR_H, thresh=_shipped, unc=NO_WEATHER)
|
|
check(f"a spot hour at {_T18_BELOW[DEAR_H]:+.5f} - one tenth of a cent BELOW "
|
|
f"the shipped boundary - is still refused by \u00a717, so the boundary is "
|
|
f"where -(markup + taxes) puts it and not merely somewhere lower than "
|
|
f"before",
|
|
_T18_SELL_BELOW.action is None and "§17" in _T18_SELL_BELOW.reason)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
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_headroom_w=HEADROOM_CLEAR,
|
|
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("3b. SAFETY-45 - the buy leg is SIZED by the guard, not switched by it")
|
|
# ---------------------------------------------------------------------------
|
|
# AC3 above is the SWITCH: `peak_guard_active` refuses the whole trade while
|
|
# the guard holds. That is right and it stays. What was missing is the other
|
|
# half - the guard measures a CONTINUOUS number, "the watts this billed quarter
|
|
# can still afford", and until this card nothing consumed it. So a trade sat at
|
|
# max_w through the whole window in which the guard was silent, blew the
|
|
# quarter, tripped the switch, was cancelled outright, and came back at max_w
|
|
# the moment the meter fell far enough to release it. Measured on TEST-23
|
|
# stage 3's closed rig at peak_cap_w 1500 / max_w 2000 / a 1200 W house:
|
|
# 28 charge onsets in one hour, mean period 131 s, grid -456 <-> +3200 W.
|
|
#
|
|
# `control.maintenance_charge_floor()` has always given a MAINTENANCE charge
|
|
# exactly this shape - "capped by the headroom left under the site's cap" - and
|
|
# its own docstring says why: "a maintenance charge is the only thing this
|
|
# system does that is big enough and long enough to set that peak". OPT-04 is
|
|
# the second such thing and was given no cap. This section grades the cap.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# ⚠️ THE PRECONDITION, AND IT IS THE HABIT-1 ONE. The headroom must be strictly
|
|
# between 0 and the size the trade would otherwise ask for, or "sized by the
|
|
# guard" is indistinguishable from "not sized at all" and from "refused".
|
|
S45_HEADROOM = 300.0
|
|
check(f"PRECONDITION: the headroom under test ({S45_HEADROOM:.0f} W) is "
|
|
f"strictly inside the trade's own sizing ({MAX_W:.0f} W) and strictly "
|
|
f"positive, so a cap that did nothing and a cap that refused everything "
|
|
f"are both distinguishable from the answer below",
|
|
0.0 < S45_HEADROOM < MAX_W)
|
|
_s45_capped = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER,
|
|
headroom=S45_HEADROOM)
|
|
_s45_clear = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER)
|
|
print(f" headroom {S45_HEADROOM:.0f} W -> {_s45_capped.power_w:+.0f} W; "
|
|
f"headroom clear -> {_s45_clear.power_w:+.0f} W")
|
|
print(f" {_s45_capped.reason}")
|
|
check("the trade still HAPPENS - §10.1 ranks peak shaving above arbitrage, it "
|
|
"does not abolish it - and it is sized to the watts the billed quarter "
|
|
"can afford rather than to max_w",
|
|
_s45_capped.action == CHARGE
|
|
and near(_s45_capped.power_w, -S45_HEADROOM))
|
|
check("...and the claim the arbiter receives carries the SIZED number, so the "
|
|
"cap reaches the actuator and is not merely printed",
|
|
near(claim(_s45_capped).value, -S45_HEADROOM)
|
|
and claim(_s45_capped).priority == P_ARBITRAGE)
|
|
check("...and the SAME call with the headroom clear asks for max_w, so the "
|
|
"check above is graded against a difference this fixture can actually "
|
|
"produce (habit 7: one thing varied, from an accepted baseline)",
|
|
_s45_clear.action == CHARGE and near(_s45_clear.power_w, -MAX_W))
|
|
|
|
# ⚠️ THE ORDERED PAIR (MUTATION.md habit 14). Two numbers in one sentence can
|
|
# always be transposed by a ZERO-BYTE edit, and this sentence is what an
|
|
# operator reads when the battery buys less than the price says it should. So
|
|
# the check asserts each value inside the literal text that names it, and
|
|
# asserts that the TRANSPOSED sentence is not what it says.
|
|
_S45_PAIR = (f"the trade asked for {MAX_W:.0f} W and this billed quarter can "
|
|
f"afford {S45_HEADROOM:.0f} W")
|
|
_S45_SWAP = (f"the trade asked for {S45_HEADROOM:.0f} W and this billed "
|
|
f"quarter can afford {MAX_W:.0f} W")
|
|
check("the reason names WHAT WAS ASKED FOR and WHAT WAS AFFORDABLE in their "
|
|
"own slots, so an operator can tell a small trade from a small "
|
|
"opportunity", _S45_PAIR in _s45_capped.reason)
|
|
check("...and the transposed sentence is NOT what it says, so the check above "
|
|
"discriminates rather than decorates - both numbers are in the string "
|
|
"either way and a substring test would pass under the swap",
|
|
_S45_SWAP not in _s45_capped.reason)
|
|
check("...and it names the guard and the two spec sections, so the sentence "
|
|
"cannot be read as a pack limit or a price limit",
|
|
"CAPACITY-TARIFF GUARD" in _s45_capped.reason
|
|
and "§9.1" in _s45_capped.reason and "§10.1" in _s45_capped.reason)
|
|
check("...and an UNCAPPED trade does not carry that clause at all: a sentence "
|
|
"that always says 'sized by the guard' tells nobody anything",
|
|
"CAPACITY-TARIFF GUARD" not in _s45_clear.reason
|
|
and f"charging {MAX_W:.0f} W" in _s45_clear.reason)
|
|
|
|
# --- the two refusals, and they are told apart from the ones that existed ---
|
|
_s45_none = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER, headroom=None)
|
|
_s45_zero = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER, headroom=0.0)
|
|
print(f" headroom None -> {_s45_none.action}; headroom 0 W -> "
|
|
f"{_s45_zero.action}")
|
|
check("a headroom nothing MEASURED refuses. This is guard_state()'s None "
|
|
"argument one layer down: charging creates the billed quantity (§9.1) "
|
|
"and an unmeasured quarter is not a clear one",
|
|
not _s45_none.trading
|
|
and _s45_none.reason == _arb.IDLE_HEADROOM_UNMEASURED)
|
|
check("...and it is DISTINGUISHABLE from the guard never having run at all, so "
|
|
"the status page does not report a missing battery sensor as a missing "
|
|
"meter", _s45_none.reason != _arb.IDLE_NOT_COVERED)
|
|
check("a headroom of ZERO refuses rather than filing a 0 W claim - "
|
|
"`Claim.set(..., 0.0)` at P_ARBITRAGE is a PARK ORDER that outranks the "
|
|
"loop, which is the failure claim()'s own note is written against",
|
|
not _s45_zero.trading and claim(_s45_zero) is None
|
|
and _s45_zero.reason == _arb.IDLE_NO_HEADROOM)
|
|
# ⚠️ TEST-16's RULE: TWO RUNGS THAT CAN ANSWER THE SAME STIMULUS MUST BE
|
|
# SEPARATED. `charge_w = 0` and `headroom = 0` both produce a 0 W sizing, and
|
|
# grading only `kind == "refused"` would let either absorb the other's work -
|
|
# after which weakening either one reports nothing. So the REASONS are graded,
|
|
# and each rung is reached with the other one clear.
|
|
_s45_nosize = decide(PLAN, hour(CHEAP_H), peak_guard_active=False, soc=50.0,
|
|
charge_headroom_w=HEADROOM_CLEAR,
|
|
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
|
charge_w=0.0, discharge_w=MAX_W,
|
|
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
|
|
check("...and the PACK being sized at 0 W still reports OPT-09's own sentence, "
|
|
"not this card's: the two rungs answer the same stimulus and a refusal "
|
|
"that named the wrong subsystem would send an operator to the wrong page",
|
|
not _s45_nosize.trading
|
|
and _s45_nosize.reason == _arb.IDLE_NO_SIZE
|
|
and _s45_zero.reason != _s45_nosize.reason)
|
|
|
|
# --- §9.1: the SELL leg is untouched ---------------------------------------
|
|
_s45_sell = act(PLAN, DEAR_H, thresh=_shipped, unc=NO_WEATHER, headroom=0.0)
|
|
check("a ZERO headroom does not cap a DISCHARGE. §9.1: 'discharge and export "
|
|
"are never capped by the peak guard; only charging and import are' - the "
|
|
"capacity tariff bills offtake, and a sell creates none",
|
|
_s45_sell.action == DISCHARGE and near(_s45_sell.power_w, MAX_W))
|
|
|
|
# --- and main.py actually passes it, pinned on the AST ----------------------
|
|
# ⚠️ THE CALL SITE IS THE UNIT OF COVERAGE (MUTATION.md habit 15). Nothing
|
|
# behavioural in this file can see main.py drop the argument - every check
|
|
# above calls decide() itself - and a required keyword-only parameter that no
|
|
# caller fills is a TypeError only on the one path a suite does not run. Same
|
|
# pin, same reason, as `weights=` in test_packshare.py part 10.
|
|
_S45_MAIN = open(os.path.join(HERE, "app", "main.py"), encoding="utf-8").read()
|
|
_S45_MAIN_TREE = ast.parse(_S45_MAIN)
|
|
_s45_calls = [n for n in ast.walk(_S45_MAIN_TREE)
|
|
if isinstance(n, ast.Call)
|
|
and ast.unparse(n.func).endswith("arbitrage.decide")]
|
|
_s45_arg = [ast.unparse(k.value) for c in _s45_calls for k in c.keywords
|
|
if k.arg == "charge_headroom_w"]
|
|
|
|
|
|
def _s45_assigned(name, clear=("None",)):
|
|
"""Every expression main.py assigns to `self.<name>` that is not a CLEAR.
|
|
|
|
⚠️ SAFETY-47 HOISTED THE HEADROOM OUT OF THE CALL, so pinning the call
|
|
argument alone stopped covering anything: the literal a mutation would put
|
|
there now goes one line up instead. Both ends are pinned.
|
|
|
|
⚠️ `clear` NAMES THE CONSTANTS THAT MEAN "NOTHING HAS ANSWERED THIS YET" -
|
|
`__init__`'s value and the per-cycle reset - and they are not a second
|
|
source for the number. Excluding them does NOT soften the pin: a mutation
|
|
that replaces the real assignment with one of those constants leaves this
|
|
list EMPTY, which matches no expected value and is red.
|
|
"""
|
|
return [ast.unparse(n.value) for n in ast.walk(_S45_MAIN_TREE)
|
|
if isinstance(n, ast.Assign)
|
|
and [ast.unparse(t) for t in n.targets] == [f"self.{name}"]
|
|
and ast.unparse(n.value) not in clear]
|
|
|
|
|
|
_s45_head_src = _s45_assigned("arb_headroom_w")
|
|
print(f" main.py has {len(_s45_calls)} `arbitrage.decide(` call site(s); "
|
|
f"charge_headroom_w= is {_s45_arg}, and self.arb_headroom_w is assigned "
|
|
f"from {_s45_head_src}")
|
|
check("main.py passes the guard's OWN measured headroom at its one "
|
|
"arbitrage.decide() call site - the shipped expression, not a "
|
|
"transcription of it, so a mutation back to a literal or to max_w is red "
|
|
"rather than silent",
|
|
_s45_arg == ["self.arb_headroom_w"]
|
|
and _s45_head_src == ["peakguard.charge_headroom_w(self.peak_guard)"])
|
|
# ⚠️ AND THE HOIST DID NOT MOVE IT OUT OF THE CYCLE. An `arb_headroom_w` set
|
|
# once in `__init__` and never refreshed would satisfy the pin above and would
|
|
# size every trade from the guard decision of whatever cycle happened to run
|
|
# last - which is the defect the hoist's own comment warns about, one level up.
|
|
_s45_cycle = first((n for n in ast.walk(_S45_MAIN_TREE)
|
|
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
and n.name == "cycle"
|
|
and any(isinstance(c, ast.Call)
|
|
and ast.unparse(c.func).endswith("arbitrage.decide")
|
|
for c in ast.walk(n))), "no cycle() calling arbitrage.decide")
|
|
check("...and the assignment is INSIDE the same `cycle()` that makes the call, "
|
|
"so the headroom the trade is sized from is this cycle's guard decision "
|
|
"and not one carried from an arbitrary earlier one",
|
|
any(isinstance(n, ast.Assign)
|
|
and [ast.unparse(t) for t in n.targets] == ["self.arb_headroom_w"]
|
|
and ast.unparse(n.value)
|
|
== "peakguard.charge_headroom_w(self.peak_guard)"
|
|
for n in ast.walk(_s45_cycle)))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print()
|
|
print("3c. SAFETY-45 - THE REGRESSION: the closed loop SETTLES")
|
|
# ---------------------------------------------------------------------------
|
|
# ⚠️ THIS IS THE CHECK THE CARD IS ACTUALLY ABOUT, AND A UNIT TEST ON decide()
|
|
# IS NOT IT. The defect is an INTERACTION - two strategies, a 900 s integrator
|
|
# and a plant that answers back - and every individual decision in it was
|
|
# correct. So the loop is closed here: prices decide, the battery model
|
|
# responds, the shipped QuarterAverager reports the consequence, and the
|
|
# shipped guard reacts to that.
|
|
#
|
|
# WHAT IS REAL, AND WHAT IS MODELLED - the same discipline TEST-23 stage 3's
|
|
# harness holds itself to, because a transcription proves what somebody typed:
|
|
#
|
|
# REAL peakguard.decide/claim/charge_headroom_w, arbitrage.decide/claim/
|
|
# guard_state, control.compute + Tuning, arbiter.resolve, and
|
|
# p1.QuarterAverager - the billed-quarter accumulator itself, so the
|
|
# quarters reported below are the ones a meter would bill.
|
|
# MODEL ONE cycle's ORDER (peak block, then arbitrage reading its
|
|
# conclusion, then the loop claim, then resolve) - twelve lines
|
|
# transcribed from main.py:2660-2900 and pinned above in 3b; the
|
|
# PLANT, an 800 W/s ramp into a 10 s first-order lag, which is
|
|
# plantsim's; and a flat synthetic house.
|
|
#
|
|
# ⚠️ AND THE HEADLINE NUMBERS WERE NOT TAKEN HERE. They were taken on the
|
|
# closed rig at /home/homelab/test23s3 against the shipped container's own
|
|
# checkout, which is where a divergence between this model and main.py's real
|
|
# event loop would show up. This section exists so CI can go red.
|
|
# ---------------------------------------------------------------------------
|
|
S45_CAP_W, S45_LOAD_W, S45_CYCLE_S, S45_RUN_S = 1500.0, 1200.0, 5.0, 2700.0
|
|
S45_RAMP_W_S, S45_LAG_S = 800.0, 10.0
|
|
# ⚠️ THE RUN STARTS ON A QUARTER BOUNDARY AND ENDS INSIDE THE SAME CHEAP HOUR,
|
|
# and both halves matter. Starting on the boundary makes the three closed
|
|
# blocks below whole billed quarters. Ending inside the hour keeps TEST-23
|
|
# finding 1 - the export overshoot at the END of any trade, ~0.37 x max_w, a
|
|
# separate and already-recorded property - out of a swing figure this card is
|
|
# grading. The run still crosses TWO quarter boundaries, which is the half of
|
|
# the defect the card says arbitrage must learn from.
|
|
S45_T0 = D0 + timedelta(hours=CHEAP_H)
|
|
|
|
|
|
def s45_loop(*, sized: bool, cap_w=S45_CAP_W, load_w=S45_LOAD_W,
|
|
seconds=S45_RUN_S, shadow=False):
|
|
"""One closed hour. `sized=False` is the behaviour this card removes.
|
|
|
|
⚠️ THE COUNTERFACTUAL IS ONE ARGUMENT, NOT A SECOND CODE PATH. `sized=False`
|
|
hands `decide()` a headroom nothing can bind - which is exactly what the
|
|
tree did before this card, because nothing was passing one - so the two runs
|
|
below differ in one value and share every line of shipped code. Habit 7.
|
|
|
|
⚠️ SAFETY-47's `shadow=True` IS THE SAME DISCIPLINE, ONE ARGUMENT AGAIN. It
|
|
reproduces the THREE things `Controller._write_unit()` and
|
|
`_shadow_settle()` do and nothing else: the resolved command is RECORDED,
|
|
the plant is driven from `shadow.WIRE_W` instead of from it, and the
|
|
carried command is RE-SEEDED to `shadow.WIRE_W` after the record is taken.
|
|
Those three lines are a TRANSCRIPTION of main.py's placement and are marked
|
|
as one - `test_clamp.py` part 8 is the check on the shipped placement, and
|
|
it drives the real `Controller`. What is NOT transcribed is every number
|
|
that matters here: the averager, the projection, the guard, the headroom,
|
|
the strategy, the arbiter and the row's own words are all the shipped ones.
|
|
"""
|
|
|
|
avg = QuarterAverager(phases=1, max_hold_s=900.0)
|
|
tuning = Tuning(gain=0.6, max_w=MAX_W, slew_w=0.0)
|
|
t0 = S45_T0
|
|
target = i_w = 0.0
|
|
sat = 0
|
|
loop_w = None
|
|
batt_cmd = batt = 0.0 # + discharge, as main.py's site_batt_w
|
|
onsets = blocks = 0
|
|
rec = []
|
|
prev_action = "<start>"
|
|
grid_lo, grid_hi = None, None
|
|
billed = []
|
|
acc = 0.0
|
|
t = 0.0
|
|
while t <= seconds:
|
|
grid = load_w - batt
|
|
imp, exp = split_signed(grid)
|
|
for b in avg.add(make_sample("ha_signed", imp, exp, phases=1,
|
|
ingest_ts=t0 + timedelta(seconds=t),
|
|
ingest_mono=(t0.timestamp() + t))):
|
|
billed.append(round(float(b.offtake_avg_w), 1))
|
|
blocks += 1
|
|
if acc <= 1e-9:
|
|
acc = S45_CYCLE_S
|
|
now = t0 + timedelta(seconds=t)
|
|
claims = []
|
|
pk = _pg.decide(PeakTracker.project(avg, now), batt_w=batt,
|
|
cap_w=cap_w, margin_w=0.0, reading=None, achievable_w=MAX_W,
|
|
soc=50.0, soc_floor=20.0, max_hold_s=900.0,
|
|
frozen=False)
|
|
pc = _pg.claim(pk)
|
|
if pc is not None:
|
|
claims.append(pc)
|
|
arb = decide(PLAN, now,
|
|
peak_guard_active=_arb.guard_state(pk),
|
|
charge_headroom_w=(_pg.charge_headroom_w(pk) if sized
|
|
else HEADROOM_CLEAR),
|
|
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)
|
|
ac = claim(arb)
|
|
if ac is not None:
|
|
claims.append(ac)
|
|
if arb.action == CHARGE and prev_action != CHARGE:
|
|
onsets += 1
|
|
prev_action = arb.action
|
|
if loop_w is None or target != loop_w:
|
|
i_w = target
|
|
d = compute(prev_w=target, grid_w=grid, actual_w=batt,
|
|
tuning=tuning, sat_count=sat, i_w=i_w)
|
|
sat, i_w, loop_w = d.sat_count, d.i_w, d.target_w
|
|
claims.append(Claim.limit("supervised", P_SAFETY, -MAX_W, MAX_W,
|
|
"max_w"))
|
|
claims.append(Claim.set("loop", P_LOOP, d.target_w, d.reason))
|
|
res = resolve(claims)
|
|
target = res.target_w
|
|
head_w = _pg.charge_headroom_w(pk) if sized else HEADROOM_CLEAR
|
|
row = dict(
|
|
t_s=t, action=arb.action, power_w=arb.power_w,
|
|
reason=arb.reason, price=arb.price_now_eur_kwh,
|
|
spread=arb.spread_eur_kwh, winner=res.winner,
|
|
target_w=float(res.target_w), headroom_w=head_w,
|
|
engaged=bool(pk.engaged),
|
|
sized_from_meter=_arb.sized_from_meter(
|
|
arb, head_w, winner=res.winner))
|
|
rec.append(row)
|
|
if shadow:
|
|
# --- TRANSCRIBED 1/3: `_shadow_settle()`'s record, taken
|
|
# BEFORE the re-seed destroys its evidence.
|
|
row["would_w"] = float(res.target_w)
|
|
row["row"] = _shadow.words(
|
|
{"shadow_mode": "on"}, control_started=False,
|
|
would_w=float(res.target_w),
|
|
sized_from_meter=row["sized_from_meter"],
|
|
reason=res.explain())
|
|
# --- TRANSCRIBED 2/3: `_write_unit()`'s substitution - the
|
|
# plant below is driven from WIRE_W, not from the decision.
|
|
# --- TRANSCRIBED 3/3: `_shadow_settle()`'s re-seed of the
|
|
# carried command, which is what `compute(prev_w=...)`,
|
|
# `standing_w()` and SAFETY-42 read next cycle.
|
|
target = _shadow.WIRE_W
|
|
# THE PLANT, MODELLED: an 800 W/s ramp into a 10 s first-order lag.
|
|
step = min(1.0, acc)
|
|
delta = target - batt_cmd
|
|
batt_cmd += math.copysign(min(abs(delta), S45_RAMP_W_S * step), delta)
|
|
batt += (batt_cmd - batt) * (step / S45_LAG_S)
|
|
acc -= step
|
|
t += step
|
|
grid_lo = grid if grid_lo is None else min(grid_lo, grid)
|
|
grid_hi = grid if grid_hi is None else max(grid_hi, grid)
|
|
return dict(onsets=onsets, grid_lo=round(grid_lo, 1),
|
|
grid_hi=round(grid_hi, 1), swing=round(grid_hi - grid_lo, 1),
|
|
billed=billed, blocks=blocks, rec=rec,
|
|
engaged=sum(1 for r in rec if r["engaged"]),
|
|
cycles=len(rec))
|
|
|
|
S45_BEFORE = derived(lambda: s45_loop(sized=False))
|
|
S45_AFTER = derived(lambda: s45_loop(sized=True))
|
|
for _lab, _r in (("unsized (the shipped behaviour before this card)", S45_BEFORE),
|
|
("sized by the guard", S45_AFTER)):
|
|
print(f" {_lab}: {_r['onsets']} charge onsets/h, grid "
|
|
f"{_r['grid_lo']:.0f} .. {_r['grid_hi']:.0f} W (swing "
|
|
f"{_r['swing']:.0f} W), billed quarters {_r['billed']} W")
|
|
|
|
# ⚠️ THE NEGATIVE CONTROL FIRST. If the unsized run did NOT limit-cycle, every
|
|
# assertion below would be a statement about a loop that was never unstable -
|
|
# MUTATION.md's "a check whose data cannot reach the failure it names", which
|
|
# has now appeared in eleven cards in this repository.
|
|
check("PRECONDITION: with the trade UNSIZED this loop really does run away - "
|
|
"many charge onsets in one hour and a grid swing of kilowatts. Without "
|
|
"this, everything below grades a loop that was never unstable",
|
|
S45_BEFORE["onsets"] >= 10 and S45_BEFORE["swing"] > 2000.0)
|
|
check("...and it is the bang-bang shape and not a slow drift: the meter "
|
|
"reaches both a hard import and an EXPORT, so the actuator is being "
|
|
"handed back and forth rather than converging",
|
|
S45_BEFORE["grid_hi"] > S45_CAP_W + 1000.0
|
|
and S45_BEFORE["grid_lo"] < 0.0)
|
|
check(f"THE REGRESSION: sizing the trade to the guard's measured headroom "
|
|
f"settles the loop. {S45_BEFORE['onsets']} charge onsets an hour become "
|
|
f"{S45_AFTER['onsets']}, and the grid swing falls from "
|
|
f"{S45_BEFORE['swing']:.0f} W to {S45_AFTER['swing']:.0f} W",
|
|
S45_AFTER["onsets"] <= 2
|
|
and S45_AFTER["swing"] < 0.25 * S45_BEFORE["swing"])
|
|
check("...and the battery is still TRADING for the whole hour rather than "
|
|
"settling by giving up: §10.1 ranks peak shaving above arbitrage, it "
|
|
"does not cancel it, and a fix that simply stopped buying would pass an "
|
|
"onset count and fail the card",
|
|
S45_AFTER["onsets"] == 1)
|
|
check(f"...and every BILLED quarter comes in at or under the "
|
|
f"{S45_CAP_W:.0f} W cap, which the unsized run does not manage - the "
|
|
f"oscillation was not merely ugly, it was losing the money it was "
|
|
f"oscillating about (§10.2, §9.1)",
|
|
S45_AFTER["blocks"] >= 3
|
|
and all(v <= S45_CAP_W + 0.5 for v in S45_AFTER["billed"])
|
|
and any(v > S45_CAP_W for v in S45_BEFORE["billed"]))
|
|
check("...and the two runs are the same code on the same fixture: one "
|
|
"argument differs, and the loop, the guard, the arbiter, the averager "
|
|
"and the plant are shared line for line",
|
|
S45_BEFORE["blocks"] == S45_AFTER["blocks"])
|
|
|
|
# ⚠️ AND IT IS INERT WHERE THE DEFECT IS INERT, WHICH IS THE OTHER HALF OF A
|
|
# FIX. The rig's sweep found the bite rule `load + max_w > peak_cap_w`; below
|
|
# it, the trade never threatens the quarter and this card must change NOTHING.
|
|
S45_WIDE = derived(lambda: s45_loop(sized=False, cap_w=5000.0, load_w=1200.0,
|
|
seconds=900.0))
|
|
S45_WIDE_S = derived(lambda: s45_loop(sized=True, cap_w=5000.0, load_w=1200.0,
|
|
seconds=900.0))
|
|
print(f" peak_cap_w 5000 W, 1200 W house (load + max_w = "
|
|
f"{S45_LOAD_W + MAX_W:.0f} W, under the cap): unsized "
|
|
f"{S45_WIDE['billed']} W, sized {S45_WIDE_S['billed']} W")
|
|
|
|
|
|
|
|
def _s45_outcome(r):
|
|
"""Everything the two runs put on the PLANT and the METER.
|
|
|
|
⚠️ `rec` IS EXCLUDED AND THE EXCLUSION IS NAMED. SAFETY-47 added a
|
|
per-cycle record to this harness, and it carries `headroom_w` and
|
|
`sized_from_meter` - which are THE ONE ARGUMENT the two runs differ in, by
|
|
construction. Comparing them would make this check fail for the reason it
|
|
exists to rule out. The resolved command per cycle IS compared, below, and
|
|
that is a stronger statement than the six aggregates ever were.
|
|
"""
|
|
return {k: v for k, v in r.items() if k != "rec"}
|
|
|
|
|
|
check("on a site where the trade cannot reach the cap the two runs are "
|
|
"IDENTICAL to the watt - so this card costs nothing on the installs it "
|
|
"is not about, and the check above is not passing because the cap "
|
|
"dominates everything",
|
|
_s45_outcome(S45_WIDE) == _s45_outcome(S45_WIDE_S)
|
|
and S45_WIDE["onsets"] == 1)
|
|
check("...and identical CYCLE BY CYCLE, not merely in the six aggregates: "
|
|
"every resolved command and every traded size matches to the float",
|
|
[(r["target_w"], r["power_w"]) for r in S45_WIDE["rec"]]
|
|
== [(r["target_w"], r["power_w"]) for r in S45_WIDE_S["rec"]])
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print()
|
|
print("3d. SAFETY-47 - the SHADOW record against a LIVE run, at a BINDING cap")
|
|
# ---------------------------------------------------------------------------
|
|
# WHAT THIS PART EXISTS FOR, AND IT IS NOT A SECOND SAFETY-45 CHECK.
|
|
#
|
|
# SAFETY-10 shipped shadow mode saying, in three merged documents, that "the
|
|
# strategies are unaffected - they ask for a fixed power from inputs that do
|
|
# not depend on what the battery did - so a shadow run does show you the
|
|
# trades, the peak-guard engagements and the SoC-limit behaviour". That was
|
|
# true until 3c's own fix merged. SAFETY-45 makes the buy leg
|
|
# `min(asked, headroom)`, and the headroom is
|
|
#
|
|
# (900 / remaining) x (ceiling - projected)
|
|
#
|
|
# where `projected` reads the OPEN QUARTER'S ACCUMULATOR - the meter - which is
|
|
# a function of what the battery did earlier in that same quarter. The strategy
|
|
# became closed-loop the day 3c landed, and shadow mode holds the battery at
|
|
# 0 W, so in a shadow run that accumulator contains THE HOUSE AND NOTHING ELSE.
|
|
#
|
|
# ⚠️ AND SAFETY-10 §6's COMMITTED EVIDENCE COULD NEVER HAVE FAILED - TWO
|
|
# INDEPENDENT REASONS, AND CLOSING EITHER ONE ALONE CLOSES NOTHING.
|
|
# (1) its fixture is `peak_cap_w: 15000` against a 350 W house, so the guard
|
|
# never engages and the sizing is inert - MUTATION.md habit 4, inside a
|
|
# merged card's own proof;
|
|
# (2) every identity row reads THE FIRST CHARGING SAMPLE, and at `elapsed=0`
|
|
# the projection IS the held level, so the two runs agree to the float
|
|
# there even AT a binding cap. 3d-2 drives that and says so.
|
|
#
|
|
# So this part is the grader for the class: a shadow run and a live run, the
|
|
# same hour, the same code, at a cap that BINDS. What is REAL and what is
|
|
# TRANSCRIBED is in `s45_loop()`'s own docstring - the three lines that stand
|
|
# in for `Controller._write_unit()` and `_shadow_settle()` are named there, and
|
|
# `test_clamp.py` part 8 is the check on the shipped placement.
|
|
# ---------------------------------------------------------------------------
|
|
S47_LIVE = derived(lambda: s45_loop(sized=True))
|
|
S47_SHAD = derived(lambda: s45_loop(sized=True, shadow=True))
|
|
# The SAFETY-45 defect itself, watched from inside shadow mode - which is what
|
|
# SAFETY-10's header claimed a shadow run would surface.
|
|
S47_UNSIZED_SHAD = derived(lambda: s45_loop(sized=False, shadow=True))
|
|
|
|
_s47_lc = derived(lambda: [r for r in S47_LIVE["rec"] if r["action"] == CHARGE])
|
|
_s47_sc = derived(lambda: [r for r in S47_SHAD["rec"] if r["action"] == CHARGE])
|
|
|
|
# ⚠️ THE SECTION CARRIES ITS OWN PRECONDITION INTO EVERY CHECK, AND THIS IS
|
|
# FCST-06's SHAPE RATHER THAN TIDINESS. A mutation that stops either run
|
|
# trading at all leaves these lists EMPTY; the first `min()` below then raises,
|
|
# the file dies before part 4, and the run reports ZERO reds - which reads
|
|
# exactly like a survivor. Measured: driving `wanted = batt_w - ex` through
|
|
# this section did precisely that. So the lists are replaced by a sentinel that
|
|
# cannot satisfy anything, and `s47check()` fails every check in the section
|
|
# when the stimulus was not produced.
|
|
_S47_DEAD = dict(action=None, power_w=0.0, reason="<no charging cycle>",
|
|
price=None, spread=None, winner=None, target_w=0.0,
|
|
headroom_w=0.0, engaged=False, sized_from_meter=False,
|
|
would_w=0.0, row="", t_s=0.0)
|
|
_S47_OK = bool(_s47_lc) and bool(_s47_sc)
|
|
if not _S47_OK:
|
|
_s47_lc = _s47_sc = [dict(_S47_DEAD)]
|
|
_s47_n = min(len(_s47_lc), len(_s47_sc))
|
|
|
|
|
|
def s47check(name, cond):
|
|
"""A 3d check, AND the section's own precondition, in one verdict."""
|
|
check(name, _S47_OK and bool(cond))
|
|
|
|
|
|
check("PRECONDITION: both runs actually reached a charging decision - without "
|
|
"this the section raises on its first `min()`, the file dies before "
|
|
"part 4 and the run reports ZERO reds, which reads exactly like a "
|
|
"survivor (MUTATION.md, FCST-06's shape). Measured, not imagined",
|
|
_S47_OK)
|
|
print(f" live : {len(_s47_lc)} charging cycles, traded "
|
|
f"{min(r['power_w'] for r in _s47_lc):+.1f} .. "
|
|
f"{max(r['power_w'] for r in _s47_lc):+.1f} W, "
|
|
f"headroom {min(r['headroom_w'] for r in _s47_lc):.1f} .. "
|
|
f"{max(r['headroom_w'] for r in _s47_lc):.1f} W, "
|
|
f"grid {S47_LIVE['grid_lo']:.0f} .. {S47_LIVE['grid_hi']:.0f} W")
|
|
print(f" shadow: {len(_s47_sc)} charging cycles, RECORDED "
|
|
f"{min(r['power_w'] for r in _s47_sc):+.1f} .. "
|
|
f"{max(r['power_w'] for r in _s47_sc):+.1f} W, "
|
|
f"headroom {min(r['headroom_w'] for r in _s47_sc):.1f} .. "
|
|
f"{max(r['headroom_w'] for r in _s47_sc):.1f} W, "
|
|
f"grid {S47_SHAD['grid_lo']:.0f} .. {S47_SHAD['grid_hi']:.0f} W")
|
|
|
|
# --- 3d-1. THE PRECONDITION: the cap binds, and the meter proves it --------
|
|
# ⚠️ WITHOUT THIS EVERY CHECK BELOW IS SAFETY-10 §6's DEFECT IN A NEW FILE. At
|
|
# a cap the trade cannot reach, live and shadow agree exactly and nothing here
|
|
# could ever go red - which is precisely what the committed evidence did.
|
|
s47check("PRECONDITION: the cap BINDS - the live run's trade is sized by the "
|
|
f"guard on every charging cycle, never reaching the {MAX_W:.0f} W ask",
|
|
_s47_lc and all(abs(r["power_w"]) < MAX_W - 1.0 for r in _s47_lc))
|
|
s47check("...and the live METER is what closed the gap: it is held up at the "
|
|
f"{S45_CAP_W:.0f} W ceiling rather than sitting at the "
|
|
f"{S45_LOAD_W:.0f} W house, which is the feedback a shadow run does not "
|
|
f"have",
|
|
S47_LIVE["grid_hi"] > S45_LOAD_W + 200.0)
|
|
s47check("...and the SHADOW meter sees the house and nothing else for the whole "
|
|
"run, which is what makes its billed quarter a different quarter",
|
|
near(S47_SHAD["grid_lo"], S45_LOAD_W, 0.001)
|
|
and near(S47_SHAD["grid_hi"], S45_LOAD_W, 0.001))
|
|
|
|
# --- 3d-2. WHY THE OLD SHAPE COULD NOT FAIL --------------------------------
|
|
# Graded as an EXPECTATION rather than left as a footnote, so that nobody
|
|
# rebuilds a check on `[0]` and believes it covers something.
|
|
print(f" first charging cycle: live {_s47_lc[0]['power_w']:+.4f} W, "
|
|
f"shadow {_s47_sc[0]['power_w']:+.4f} W")
|
|
s47check("⚠️ THE FIRST CHARGING CYCLE IS IDENTICAL IN BOTH RUNS EVEN HERE, so "
|
|
"widening SAFETY-10 §6's fixture ALONE would not have made its table go "
|
|
"red: at elapsed=0 the projection IS the held level and neither plant "
|
|
"has moved yet. Both reasons had to be closed, and this is the one a "
|
|
"reviewer cannot see from a diff",
|
|
_s47_lc[0]["power_w"] == _s47_sc[0]["power_w"]
|
|
and _s47_lc[0]["reason"] == _s47_sc[0]["reason"])
|
|
|
|
# --- 3d-3. AND ACROSS THE WHOLE RUN THEY DIVERGE ---------------------------
|
|
_s47_mis = sum(1 for i in range(_s47_n)
|
|
if _s47_lc[i]["power_w"] != _s47_sc[i]["power_w"])
|
|
_s47_worst = min(_s47_sc, key=lambda r: r["power_w"])
|
|
_s47_live_worst = min(_s47_lc, key=lambda r: r["power_w"])
|
|
# ⚠️ NaN AND NOT AN EXCEPTION when the sentinel above is in play: `nan > 2.0`
|
|
# is False, so the check goes RED, while a ZeroDivisionError here would kill
|
|
# the file and report zero reds. Same defect, one line further down - it was
|
|
# driven, and it is why this is not a plain division.
|
|
_s47_over = (abs(_s47_worst["power_w"]) / abs(_s47_live_worst["power_w"])
|
|
if _s47_live_worst["power_w"] else float("nan"))
|
|
print(f" size mismatches {_s47_mis}/{_s47_n} charging cycles; the shadow "
|
|
f"run records {_s47_worst['power_w']:+.0f} W where the live run's "
|
|
f"hardest trade is {_s47_live_worst['power_w']:+.1f} W - "
|
|
f"{_s47_over:.2f}x")
|
|
s47check("ACROSS THE WHOLE RUN the recorded trade size diverges on all but the "
|
|
"first few cycles - the check SAFETY-10 §6 should have been",
|
|
_s47_mis > 0.9 * _s47_n)
|
|
s47check(f"...and the shadow run RECORDS a trade the live run never commanded, "
|
|
f"over by {_s47_over:.2f}x. `900/remaining` amplifies a "
|
|
f"(ceiling - projected) gap the battery never closed, and the row "
|
|
f"carried that number to a commissioning engineer with no caveat",
|
|
_s47_over > 2.0)
|
|
s47check("...and the amplification is IN THE HEADROOM, which is where the "
|
|
f"measurement enters: the shadow guard offers up to "
|
|
f"{max(r['headroom_w'] for r in _s47_sc):.0f} W of charging headroom "
|
|
f"against a {S45_CAP_W:.0f} W ceiling and a {S45_LOAD_W:.0f} W house, "
|
|
f"while the live guard never exceeds "
|
|
f"{max(r['headroom_w'] for r in _s47_lc):.0f} W",
|
|
max(r["headroom_w"] for r in _s47_sc) > MAX_W
|
|
and max(r["headroom_w"] for r in _s47_lc) < MAX_W)
|
|
|
|
# --- 3d-3b. THE SENTENCE THE ROW PRINTS, GRADED AS ARITHMETIC --------------
|
|
# ⚠️ THE ROW CLAIMS THE RECORDED NUMBER IS AN **UPPER BOUND**, and a claim on
|
|
# an operator-facing surface is a claim this file has to be able to fail.
|
|
# Charging only ADDS offtake, so a live `projected` is never below a shadow
|
|
# one and the headroom is never above it. The earlier wording said "smaller",
|
|
# which is FALSE on every cycle where the two are equal - the start of each
|
|
# quarter, where `900/remaining` is 1.0 and neither plant has moved.
|
|
_s47_below = [i for i in range(_s47_n)
|
|
if abs(_s47_sc[i]["power_w"]) < abs(_s47_lc[i]["power_w"]) - 1e-9]
|
|
_s47_equal = sum(1 for i in range(_s47_n)
|
|
if abs(_s47_sc[i]["power_w"]) == abs(_s47_lc[i]["power_w"]))
|
|
print(f" the shadow record is below the live command on "
|
|
f"{len(_s47_below)}/{_s47_n} cycles and exactly equal on {_s47_equal}")
|
|
s47check("the recorded size is an UPPER BOUND on the live one, on every compared "
|
|
"cycle - which is the sentence `shadow.SIZED_FROM_METER` puts on the "
|
|
"status row, so the row is making a claim this file can fail",
|
|
not _s47_below)
|
|
s47check("...and it is NOT strictly greater: the two are equal at the start of "
|
|
f"each quarter ({_s47_equal} cycles here), which is why the row says "
|
|
f"'upper bound' and not 'smaller' - the second would be a false "
|
|
f"sentence on exactly those cycles",
|
|
_s47_equal > 0)
|
|
|
|
# ⚠️ AND THE MECHANISM BEHIND THE BOUND, AS TWO POINTS OF THE SHIPPED
|
|
# ARITHMETIC RATHER THAN AS ONE PROPERTY OF A WHOLE RUN. Same ceiling, same
|
|
# elapsed, same house: one quarter that carried the battery's charge at the
|
|
# meter (LIVE) and one that did not (SHADOW). The closed-loop version above
|
|
# cannot be driven red by any mutation that ALSO stops the fixture trading -
|
|
# measured - so the relation it rests on is graded directly here.
|
|
def _s47_headroom(*, meter_w, batt_w, elapsed_s=450.0):
|
|
"""`charge_headroom_w()` for a quarter that banked `meter_w` for
|
|
`elapsed_s`, with `batt_w` measured at the pack. All shipped."""
|
|
avg = QuarterAverager(phases=1, max_hold_s=900.0)
|
|
t = 0.0
|
|
while t <= elapsed_s:
|
|
imp, exp = split_signed(meter_w)
|
|
avg.add(make_sample("ha_signed", imp, exp, phases=1,
|
|
ingest_ts=S45_T0 + timedelta(seconds=t),
|
|
ingest_mono=(S45_T0.timestamp() + t)))
|
|
t += 5.0
|
|
pk = _pg.decide(
|
|
PeakTracker.project(avg, S45_T0 + timedelta(seconds=elapsed_s)),
|
|
batt_w=batt_w, cap_w=S45_CAP_W, margin_w=0.0, reading=None, achievable_w=MAX_W,
|
|
soc=50.0, soc_floor=20.0, max_hold_s=900.0, frozen=False)
|
|
return _pg.charge_headroom_w(pk)
|
|
|
|
|
|
_S47_TRADE_W = 300.0
|
|
_s47_h_shadow = derived(lambda: _s47_headroom(meter_w=S45_LOAD_W, batt_w=0.0))
|
|
_s47_h_live = derived(lambda: _s47_headroom(
|
|
meter_w=S45_LOAD_W + _S47_TRADE_W, batt_w=-_S47_TRADE_W))
|
|
print(f" half-way through one quarter, {S45_LOAD_W:.0f} W house under a "
|
|
f"{S45_CAP_W:.0f} W ceiling: the SHADOW meter (battery at 0 W) offers "
|
|
f"{_s47_h_shadow:.0f} W of headroom, the LIVE meter (the same house "
|
|
f"plus a {_S47_TRADE_W:.0f} W charge) offers {_s47_h_live:.0f} W")
|
|
s47check("the SHADOW meter offers STRICTLY MORE charging headroom than the "
|
|
"live one on the same house, same ceiling and same elapsed - which "
|
|
"is the whole mechanism, in two points of the shipped arithmetic "
|
|
"rather than as a property of a whole run",
|
|
_s47_h_shadow > _s47_h_live)
|
|
s47check("...and the live answer is the one that lands the block ON the "
|
|
"ceiling, so the shadow answer is the one that is wrong about the "
|
|
"bill rather than the other way round",
|
|
near(_s47_h_live, S45_CAP_W - S45_LOAD_W, 1.0))
|
|
|
|
# --- 3d-4. WHAT SURVIVES OF THE EXEMPTION, GRADED --------------------------
|
|
# The narrowed claim the three documents now make, as a check rather than as a
|
|
# sentence: WHICH hour and WHY are unaffected; HOW HARD is not.
|
|
s47check("WHICH hour the strategy picked and at WHAT price are identical on "
|
|
f"every one of the {_s47_n} compared cycles - the clock and the price "
|
|
f"series are not functions of what the battery did, which is the half "
|
|
f"of the old exemption that is still true",
|
|
all(_s47_lc[i]["price"] == _s47_sc[i]["price"] for i in range(_s47_n)))
|
|
s47check("...and so is the spread it had to clear",
|
|
all(_s47_lc[i]["spread"] == _s47_sc[i]["spread"]
|
|
for i in range(_s47_n)))
|
|
|
|
# --- 3d-5. THE CLOSED-LOOP DEFECT THE DOCUMENTS SAID WAS VISIBLE -----------
|
|
# ⚠️ SAFETY-10's HEADER NAMED SAFETY-45's LIMIT CYCLE AS EXAMPLE #1 OF WHAT A
|
|
# SHADOW RUN SURFACES. 3c's own PRECONDITION establishes that the unsized loop
|
|
# really does run away; the same stimulus watched from inside shadow mode is
|
|
# a settled, uneventful hour.
|
|
print(f" SAFETY-45's own defect, unsized: live {S45_BEFORE['onsets']} "
|
|
f"onsets/swing {S45_BEFORE['swing']:.0f} W, guard engaged "
|
|
f"{S45_BEFORE['engaged']}/{S45_BEFORE['cycles']} -> in shadow mode "
|
|
f"{S47_UNSIZED_SHAD['onsets']} onsets/swing "
|
|
f"{S47_UNSIZED_SHAD['swing']:.0f} W, guard engaged "
|
|
f"{S47_UNSIZED_SHAD['engaged']}/{S47_UNSIZED_SHAD['cycles']}")
|
|
s47check("⚠️ SAFETY-45's LIMIT CYCLE IS NOT VISIBLE FROM A SHADOW RUN, and three "
|
|
"merged documents said it was. The same hour, the same unsized code: "
|
|
f"{S45_BEFORE['onsets']} charge onsets and a "
|
|
f"{S45_BEFORE['swing']:.0f} W grid swing live, "
|
|
f"{S47_UNSIZED_SHAD['onsets']} onsets and "
|
|
f"{S47_UNSIZED_SHAD['swing']:.0f} W in shadow",
|
|
S45_BEFORE["onsets"] >= 10 and S45_BEFORE["swing"] > 2000.0
|
|
and S47_UNSIZED_SHAD["onsets"] <= 2
|
|
and S47_UNSIZED_SHAD["swing"] < 1.0)
|
|
s47check("...and neither are the guard ENGAGEMENTS the same documents promised: "
|
|
f"{S45_BEFORE['engaged']} engaged cycles live against "
|
|
f"{S47_UNSIZED_SHAD['engaged']} in shadow. Every term of "
|
|
f"`wanted = batt_w + excess` is MEASURED, and in shadow mode the "
|
|
f"battery contributes nothing to either term",
|
|
S45_BEFORE["engaged"] > 100 and S47_UNSIZED_SHAD["engaged"] == 0)
|
|
|
|
# --- 3d-6. THE THREE RUNGS OF `sized_from_meter()`, SEPARATED --------------
|
|
# TEST-16's rule: three rungs answer one question, so each is reached with the
|
|
# other two clear. Any two of them left standing would mask the third.
|
|
_s47_chg = _s47_worst
|
|
_s47_sell = derived(lambda: act(PLAN, DEAR_H, thresh=_shipped, unc=NO_WEATHER,
|
|
headroom=0.0))
|
|
s47check("the CHARGE rung: a metered headroom that sized a buy leg answers True, "
|
|
"which is the state the caveat exists for",
|
|
_arb.sized_from_meter(
|
|
ArbitrageDecision(action=CHARGE, power_w=-300.0, reason="x",
|
|
spread_eur_kwh=1.0, required_eur_kwh=0.1,
|
|
price_now_eur_kwh=0.05),
|
|
300.0, winner=_arb.SOURCE) is True)
|
|
s47check("...the SELL rung: a DISCHARGE answers False however the headroom reads. "
|
|
"§9.1 - discharge and export are never capped by the peak guard - so a "
|
|
"caveat on a sell would be a FALSE sentence on the status row",
|
|
_s47_sell.action == DISCHARGE
|
|
and _arb.sized_from_meter(_s47_sell, 300.0, winner=_arb.SOURCE) is False)
|
|
s47check("...the UNMEASURED rung: a None headroom answers False. Nothing sized "
|
|
"anything from a meter, so there is nothing to disclose - and `decide()` "
|
|
"refuses the trade there anyway",
|
|
_arb.sized_from_meter(
|
|
ArbitrageDecision(action=CHARGE, power_w=-300.0, reason="x",
|
|
spread_eur_kwh=1.0, required_eur_kwh=0.1,
|
|
price_now_eur_kwh=0.05),
|
|
None, winner=_arb.SOURCE) is False)
|
|
s47check("...the WINNER rung: the same sized charge answers False once something "
|
|
"else owns the resolution. `Resolution.target_w` is then somebody "
|
|
"else's number and a caveat filed against it would be false",
|
|
_arb.sized_from_meter(
|
|
ArbitrageDecision(action=CHARGE, power_w=-300.0, reason="x",
|
|
spread_eur_kwh=1.0, required_eur_kwh=0.1,
|
|
price_now_eur_kwh=0.05),
|
|
300.0, winner="loop") is False)
|
|
|
|
# --- 3d-7. THE ROW, AT THE WORST CYCLE -------------------------------------
|
|
_s47_row = _s47_chg["row"]
|
|
_s47_first_row = _s47_sc[0]["row"]
|
|
print(f" worst cycle row: {_s47_row[:230]}")
|
|
s47check("the shipped row carries the caveat at the worst cycle: the operator is "
|
|
"told the number was sized from a meter with only the house in it",
|
|
_shadow.SIZED_FROM_METER in _s47_row)
|
|
s47check("...and it still carries the watts, in their own slot, and the standing "
|
|
"0 W beside them - the caveat NARROWS the claim rather than deleting "
|
|
"the record a shadow run exists to produce",
|
|
"it would ask for" in _s47_row
|
|
and "the inverters stay at 0 W" in _s47_row
|
|
and f"{round(_s47_chg['would_w']):+d}".replace("+", "") != "")
|
|
s47check("...and the watts are introduced as what it would ask for THIS CYCLE, "
|
|
"in every shadow state and not only the caveated ones - no number a "
|
|
"shadow run records is a forecast of a live run (UX-08: in plain words)",
|
|
"This cycle it would ask for" in _s47_row
|
|
and "This cycle it would ask for" in _s47_first_row)
|
|
|
|
# --- 3d-7b. THE ACTION LOG, WHICH IS THE RECORD READ AFTERWARDS -----------
|
|
# ⚠️ THE TWO SURFACES ARE GRADED SEPARATELY BECAUSE THEY FAILED SEPARATELY.
|
|
# Driven as one mutation, deleting the caveat from the log line alone left all
|
|
# 29 files green: the only check that could see that line drives a fixture with
|
|
# `meter_source: off`, where no arbitrage decision - and therefore no caveat -
|
|
# is reachable. The sentence is `shadow.log_line()` now, so it can be graded.
|
|
_s47_log = derived(lambda: _shadow.log_line(
|
|
would_w=_s47_chg["would_w"], reason="arbitrage -> -2000 W",
|
|
sized_from_meter=_s47_chg["sized_from_meter"]))
|
|
_s47_log_plain = derived(lambda: _shadow.log_line(
|
|
would_w=_s47_chg["would_w"], reason="loop -> -300 W",
|
|
sized_from_meter=False))
|
|
print(f" action log: {_s47_log[:190]}")
|
|
s47check("the ACTION LOG line carries the same caveat the row does - a page that "
|
|
"discloses and a 40-row record that does not is the worse half of the "
|
|
"two, because the record is what is read after the run",
|
|
_shadow.SIZED_FROM_METER in _s47_log)
|
|
s47check("...and it is the SAME constant, not a second wording of it, so the two "
|
|
"surfaces cannot start disagreeing about which cycles are caveated",
|
|
_shadow.SIZED_FROM_METER in _s47_row and _shadow.SIZED_FROM_METER
|
|
in _s47_log)
|
|
s47check("...and an uncaveated cycle gets the plain sentence, so the caveat is "
|
|
"not simply printed on everything",
|
|
_shadow.SIZED_FROM_METER not in _s47_log_plain
|
|
and "would have commanded" in _s47_log_plain
|
|
and "ON THIS CYCLE" in _s47_log_plain)
|
|
|
|
# --- 3d-8. ⚠️ AND IT IS NOT KEYED ON WHETHER THE TRADE WAS CAPPED ----------
|
|
# THE TRAP THIS PART EXISTS TO SPRING. `arbitrage`'s own sentence says "SIZED
|
|
# BY THE CAPACITY-TARIFF GUARD" only when the headroom came out BELOW the ask.
|
|
# In a shadow run that is the START of the quarter - where the two runs still
|
|
# agree to the float. By the end `900/remaining` has multiplied the headroom
|
|
# PAST the ask, the trade is NOT capped, the sentence is silent, and the
|
|
# recorded number is at its most wrong. A caveat keyed on that sentence would
|
|
# be quietest exactly where it is needed loudest.
|
|
_S47_CAPPED = "SIZED BY THE CAPACITY-TARIFF GUARD"
|
|
print(f" at the worst cycle the shipped trade sentence says "
|
|
f"{'' if _S47_CAPPED in _s47_chg['reason'] else 'NOT '}capped; at the "
|
|
f"first it says "
|
|
f"{'' if _S47_CAPPED in _s47_sc[0]['reason'] else 'NOT '}capped")
|
|
s47check("PRECONDITION: the shipped 'capped' sentence IS present at the first "
|
|
"charging cycle, where the shadow number is RIGHT - so the inverse "
|
|
"below is a real inversion and not an absence everywhere",
|
|
_S47_CAPPED in _s47_sc[0]["reason"])
|
|
s47check("⚠️ ...and ABSENT at the worst cycle, where the shadow number is "
|
|
f"{_s47_over:.2f}x out. A caveat keyed on `capped` would fire only "
|
|
f"where the record is accurate and go silent where it is multiples "
|
|
f"wrong; `sized_from_meter()` is keyed on the headroom having been "
|
|
f"MEASURED, so it fires on both",
|
|
_S47_CAPPED not in _s47_chg["reason"]
|
|
and _s47_chg["sized_from_meter"] is True
|
|
and _s47_sc[0]["sized_from_meter"] is True)
|
|
|
|
# --- 3d-9. main.py's CALL SITES, PINNED ------------------------------------
|
|
# ⚠️ THE CALL SITE IS THE UNIT OF COVERAGE (habit 15), same as 3b's. Nothing
|
|
# behavioural in this file can see main.py render the row with a hard-coded
|
|
# False, because every check above calls `shadow.words()` itself.
|
|
_s47_words = [n for n in ast.walk(_S45_MAIN_TREE)
|
|
if isinstance(n, ast.Call)
|
|
and ast.unparse(n.func).endswith("shadow.words")]
|
|
_s47_flag = [ast.unparse(k.value) for c in _s47_words for k in c.keywords
|
|
if k.arg == "sized_from_meter"]
|
|
_s47_flag_src = _s45_assigned("shadow_sized_from_meter",
|
|
clear=("None", "False"))
|
|
print(f" main.py: shadow.words(sized_from_meter={_s47_flag}), and "
|
|
f"self.shadow_sized_from_meter is assigned from {_s47_flag_src}")
|
|
s47check("main.py renders the row from the flag THE CYCLE RECORDED, at its one "
|
|
"shadow.words() call site - not from a literal and not re-derived on "
|
|
"the status task, where `self.peak_guard` is a different cycle's",
|
|
_s47_flag == ["self.shadow_sized_from_meter"])
|
|
s47check("...and that flag comes from the SHIPPED predicate with all three of its "
|
|
"arguments - the decision, the headroom the trade was sized from, and "
|
|
"the arbiter's winner - so a mutation to `False`, to a literal or to a "
|
|
"two-argument transcription is red rather than silent",
|
|
_s47_flag_src == ["arbitrage.sized_from_meter("
|
|
"self.arb, self.arb_headroom_w, "
|
|
"winner=resolution.winner)"])
|
|
_s47_logcalls = [n for n in ast.walk(_S45_MAIN_TREE)
|
|
if isinstance(n, ast.Call)
|
|
and ast.unparse(n.func).endswith("shadow.log_line")]
|
|
_s47_logflag = [ast.unparse(k.value) for c in _s47_logcalls for k in c.keywords
|
|
if k.arg == "sized_from_meter"]
|
|
print(f" main.py: shadow.log_line(sized_from_meter={_s47_logflag})")
|
|
s47check("...and the ACTION LOG line comes from the shipped sentence with the "
|
|
"same flag, at main.py's one call site - not re-assembled as an "
|
|
"f-string where no check can reach it, which is how it survived a whole "
|
|
"mutation battery once already",
|
|
_s47_logflag == ["self.shadow_sized_from_meter"])
|
|
# ⚠️ AND THE PARAMETER HAS NO DEFAULT. TEST-14's rule, and this card is the
|
|
# reason it matters here: a caller that FORGOT the caveat and a caller that
|
|
# DECIDED it did not apply would render the identical row.
|
|
_s47_sig = inspect.signature(_shadow.words)
|
|
_s47_logsig = inspect.signature(_shadow.log_line)
|
|
s47check("`shadow.words()` and `shadow.log_line()` BOTH require "
|
|
"`sized_from_meter` - no default on either, so a caller cannot omit the "
|
|
"caveat by accident and get the un-caveated surface",
|
|
"sized_from_meter" in _s47_sig.parameters
|
|
and _s47_sig.parameters["sized_from_meter"].default
|
|
is inspect.Parameter.empty
|
|
and "sized_from_meter" in _s47_logsig.parameters
|
|
and _s47_logsig.parameters["sized_from_meter"].default
|
|
is inspect.Parameter.empty)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
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(now=LONG_AGO, 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)]
|
|
# PRICE-02: the verdict skips periods that have ended, and this day had ties
|
|
# (0.20 at every even hour, 0.301 at every odd one). With ties the first hour
|
|
# still ahead is the extreme, so the verdict moves on every hour - once per
|
|
# hour, never per tick, which test_price02.py part 6 grades. This check is
|
|
# about a verdict that stays put while the prices keep crossing the threshold,
|
|
# so the day gets one cheapest and one dearest hour at its end (22: 0.60,
|
|
# 23: 0.05), unique and ahead of every probe until they come.
|
|
FLAP[22], FLAP[23] = 0.60, 0.05
|
|
_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_headroom_w=HEADROOM_CLEAR,
|
|
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_headroom_w=HEADROOM_CLEAR,
|
|
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)
|
|
# PRICE-02: no two hours share a price. The synthetic day is
|
|
# symmetric, so it prints the same maximum twice; the verdict now takes
|
|
# the first one still AHEAD, where it used to take the first of the
|
|
# day (a tie, graded on its own in test_price02.py part 6). So the
|
|
# ties are broken by a jitter of at most 5e-5 EUR/kWh that has no
|
|
# direction: not later-is-dearer, which would send every day to a
|
|
# buy-then-sell and hide the selectivity this replay checks.
|
|
prices = [round(p + 1e-6 * ((37 * h + 101 * n) % 50), 6)
|
|
for h, p in enumerate(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.
|
|
|
|
The plan is built the way production builds it: one per hour, from that
|
|
hour (the refresh floors to the hour) to the end of the Brussels day,
|
|
plus the next day from 13:00 Brussels, when the day-ahead prices are
|
|
published. So a site sees 11 to 35 hours ahead, never a whole day behind
|
|
it and never a day on its own. (An earlier version built one plan per
|
|
calendar day with no tomorrow: after the cheap hour no cheap hour was left
|
|
ahead, and the ledger shrank to a few trades that no real site would see.)
|
|
An open buy is kept over midnight: with tomorrow in view most buys are
|
|
sold the next day or later (46 of 51 at the shipped threshold).
|
|
"""
|
|
soc = soc0
|
|
trades, holdings, bad, neg_sells = [], [], [], 0
|
|
open_buy = None
|
|
for di, day in enumerate(days):
|
|
nxt = days[di + 1] if di + 1 < len(days) else []
|
|
for i, p in enumerate(day):
|
|
ahead = day[i:] + (nxt if brussels(p.start).hour >= 13 else [])
|
|
plan = TariffPlan(
|
|
PriceRead.series(ahead, PriceProduct.DAY_AHEAD,
|
|
"generic_dayahead", p.start), None, "provider")
|
|
d = decide(plan, p.start + timedelta(minutes=30),
|
|
peak_guard_active=False, soc=soc,
|
|
min_spread_eur_kwh=threshold, uncertainty=unc,
|
|
charge_headroom_w=HEADROOM_CLEAR,
|
|
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("8b. TARIFF-12 - a RETURNED degradation must not clear a fresh plan")
|
|
|
|
# ⚠️ THE STIMULUS IS A RETURN, NOT A RAISE, AND THAT IS THE WHOLE CHECK.
|
|
# `run_tariff_refresh()` guarded the plan with `except Exception` alone, and
|
|
# `load_prices()` states in its own docstring that it "RETURNS, NEVER RAISES,
|
|
# for every failure a site can actually have" - so the one case the guard was
|
|
# written for, a supplier outage, never reached it. Everything below drives the
|
|
# SHIPPED chain and stubs nothing but the socket: the transport raises the way
|
|
# aiohttp does, `load_prices()` turns that into a `no_data` read, `_degrade()`
|
|
# turns THAT into a §11.4 plan, and `run_tariff_refresh()` decides what to do
|
|
# with it. A check that patched `resolve_tariff` to raise would have passed
|
|
# against the defective code.
|
|
import asyncio # noqa: E402
|
|
from app import price as _price # noqa: E402
|
|
from app.price_history import NoHistory # noqa: E402
|
|
|
|
|
|
class ClientConnectorError(Exception):
|
|
"""aiohttp's own class name, so the §11.4 reason this drives is the
|
|
sentence the 40 h soak recorded at refresh 56, verbatim."""
|
|
|
|
|
|
_T12_FEED_UP = {"up": True}
|
|
|
|
|
|
async def _t12_transport(url, timeout_s):
|
|
"""The socket, and nothing else, is what this replaces."""
|
|
if not _T12_FEED_UP["up"]:
|
|
raise ClientConnectorError(
|
|
"Cannot connect to host api.energy-charts.info:443 ssl:default")
|
|
t0 = datetime.now(UTC).replace(minute=0, second=0, microsecond=0)
|
|
return json.dumps({
|
|
"unix_seconds": [int((t0 + timedelta(hours=i)).timestamp())
|
|
for i in range(48)],
|
|
"price": [80.0 + 40.0 * (i % 6) for i in range(48)],
|
|
"unit": "EUR / MWh"}).encode()
|
|
|
|
|
|
class _T12Controller:
|
|
"""Just enough Controller to run the SHIPPED refresh coroutine.
|
|
|
|
Both methods are bound off the shipped class rather than copied, for
|
|
`fresh_tariff_plan()`'s own stated reason: a transcription drifts.
|
|
"""
|
|
run_tariff_refresh = _main.Controller.run_tariff_refresh
|
|
fresh_tariff_plan = _main.Controller.fresh_tariff_plan
|
|
|
|
def __init__(self, opts):
|
|
self.o = opts
|
|
self.tariff_plan = None
|
|
self.tariff_at = None
|
|
self.price_history = NoHistory()
|
|
self.price_watch = PriceWatch() # PRICE-01: the refresh passes it on
|
|
self.stop_event = asyncio.Event()
|
|
self.stop_event.set() # so the inter-refresh wait returns
|
|
self._ticks = 0
|
|
|
|
@property
|
|
def stopping(self):
|
|
self._ticks -= 1
|
|
return self._ticks < 0
|
|
|
|
def refresh_once(self):
|
|
self._ticks = 1
|
|
asyncio.run(self.run_tariff_refresh())
|
|
return self
|
|
|
|
|
|
def _t12_opts(profile=""):
|
|
# `price_timeout_s` is read with a plain `.get()` by every plugin, so it
|
|
# has to be present - an options dict that relies on price_option()'s
|
|
# defaults for it fetches with `float(None)` and this section would grade
|
|
# a TypeError instead of an outage.
|
|
return {"price_provider": "generic_dayahead", "price_timeout_s": 10,
|
|
"price_horizon_h": 48, "static_profile": profile}
|
|
|
|
|
|
_T12_HORIZON_H = 48
|
|
_T12_STALE_S = _main.TARIFF_MAX_AGE_S
|
|
_t12_saved_transport = _price.https_get
|
|
_price.https_get = _t12_transport
|
|
|
|
# -- the accepted baseline (habit 7): one good fetch, varied one thing at a
|
|
# -- time from here.
|
|
_T12_FEED_UP["up"] = True
|
|
_t12_a = derived(lambda: _T12Controller(_t12_opts()).refresh_once())
|
|
_t12_good = derived(lambda: _t12_a.tariff_plan)
|
|
check(f"BASELINE: a fetch that succeeds stores an OBSERVATION - "
|
|
f"{getattr(_t12_good, 'words', _t12_good)!r}",
|
|
getattr(_t12_good, "origin", None) == "provider"
|
|
and _t12_good.from_feed is True
|
|
and len(_t12_good.read.points) == _T12_HORIZON_H)
|
|
|
|
# -- the stimulus, graded before it is used on anything (habit 6/7) ----------
|
|
_T12_FEED_UP["up"] = False
|
|
_t12_now = datetime.now(UTC)
|
|
_t12_deg = derived(lambda: asyncio.run(resolve_tariff(
|
|
_t12_opts(), _t12_now, _t12_now + timedelta(hours=_T12_HORIZON_H),
|
|
now=_t12_now, history=NoHistory())))
|
|
check("STIMULUS: a dead feed makes resolve_tariff() RETURN a degraded plan - "
|
|
"it does not raise, so `except Exception` is not what was protecting "
|
|
"the plan and the defect was never on that branch",
|
|
isinstance(_t12_deg, TariffPlan))
|
|
check(f"...and the returned plan is the §11.4 no-profile row the soak saw - "
|
|
f"{getattr(_t12_deg, 'words', _t12_deg)!r}",
|
|
isinstance(_t12_deg, TariffPlan) and _t12_deg.origin == "none"
|
|
and _t12_deg.from_feed is False and _t12_deg.has_prices is False
|
|
and "ClientConnectorError" in _t12_deg.words)
|
|
|
|
# -- AC1: the degradation must not replace a plan still inside the bound -----
|
|
# ⚠️ 1800 s IS STATED HERE, NOT DERIVED FROM THE MODULE BEING GRADED - section
|
|
# 8's own lesson. A probe at `TARIFF_MAX_AGE_S - 1` moves with a mutation of
|
|
# the constant; half an hour is inside any reading of a 5400 s bound, and it is
|
|
# the interval the soak actually ran at.
|
|
_t12_b = derived(lambda: _T12Controller(_t12_opts()))
|
|
_t12_b.tariff_plan, _t12_b.tariff_at = _t12_good, datetime.now(UTC) - \
|
|
timedelta(seconds=1800)
|
|
_t12_at_before = _t12_b.tariff_at
|
|
check(f"PRECONDITION: the standing plan is 1800 s old and TARIFF_MAX_AGE_S is "
|
|
f"{_T12_STALE_S:.0f} s, so it IS fresh when the failed refresh runs - "
|
|
f"without this the next check grades an expired plan",
|
|
1800 < _T12_STALE_S
|
|
and _t12_b.fresh_tariff_plan(datetime.now(UTC)) is _t12_good)
|
|
_t12_b.refresh_once()
|
|
check("a RETURNED degradation does not replace a plan still inside "
|
|
"TARIFF_MAX_AGE_S - the 48 h series is still there, by identity, and "
|
|
"not a 0-point `origin: none` read",
|
|
_t12_b.tariff_plan is _t12_good
|
|
and _t12_b.tariff_plan.has_prices is True
|
|
and len(_t12_b.tariff_plan.read.points) == _T12_HORIZON_H)
|
|
check("...and `tariff_at` did NOT move, so the kept plan still ages out from "
|
|
"the fetch that produced it and is distinguishable from a freshly "
|
|
"fetched one. Re-stamping it here is how a plan freezes at its last "
|
|
"value and looks current forever",
|
|
_t12_b.tariff_at == _t12_at_before)
|
|
check("...and the two timestamps a successful refresh and a kept plan produce "
|
|
"really are different values, or the check above compares a number "
|
|
"against itself",
|
|
_t12_a.tariff_at != _t12_at_before)
|
|
|
|
# -- AC3: TARIFF_MAX_AGE_S governs - the bound is where the module says ------
|
|
_t12_c = derived(lambda: _T12Controller(_t12_opts()))
|
|
_t12_c.tariff_plan, _t12_c.tariff_at = _t12_good, datetime.now(UTC) - \
|
|
timedelta(seconds=_T12_STALE_S + 5)
|
|
_t12_c.refresh_once()
|
|
check(f"a plan that has aged PAST {_T12_STALE_S:.0f} s IS replaced by the "
|
|
f"degradation - the plan is served stale for the age bound and no "
|
|
f"longer, so TARIFF_MAX_AGE_S governs something rather than being "
|
|
f"unreachable",
|
|
_t12_c.tariff_plan is not _t12_good
|
|
and _t12_c.tariff_plan.from_feed is False
|
|
and _t12_c.tariff_plan.has_prices is False)
|
|
_t12_d = derived(lambda: _T12Controller(_t12_opts()))
|
|
_t12_d.tariff_plan, _t12_d.tariff_at = _t12_good, datetime.now(UTC) - \
|
|
timedelta(seconds=_T12_STALE_S - 60)
|
|
_t12_d.refresh_once()
|
|
check("...and one minute SHORT of the bound it is still kept, so the line "
|
|
"above is a bound rather than 'the degradation always wins'",
|
|
_t12_d.tariff_plan is _t12_good)
|
|
_t12_e = derived(lambda: _T12Controller(_t12_opts()).refresh_once())
|
|
check("a degradation with NO plan standing at all is stored - §11.4 still "
|
|
"reaches an install that has never had a good fetch, and the guard is "
|
|
"not 'never overwrite'",
|
|
_t12_e.tariff_plan is not None
|
|
and _t12_e.tariff_plan.from_feed is False
|
|
and _t12_e.tariff_at is not None)
|
|
|
|
# -- the §11.4 STATIC FALLBACK, which is the judgement this card had to make -
|
|
_t12_f = derived(lambda: _T12Controller(_t12_opts(STATIC_BE)))
|
|
_t12_fb = derived(lambda: asyncio.run(resolve_tariff(
|
|
_t12_opts(STATIC_BE), _t12_now,
|
|
_t12_now + timedelta(hours=_T12_HORIZON_H), now=_t12_now,
|
|
history=NoHistory())))
|
|
check(f"PRECONDITION: with a static_profile configured the SAME dead feed "
|
|
f"degrades to a §11.4 FALLBACK carrying real prices, not to the "
|
|
f"no-profile row - {getattr(_t12_fb, 'words', _t12_fb)!r}",
|
|
isinstance(_t12_fb, TariffPlan) and _t12_fb.origin == "fallback"
|
|
and _t12_fb.has_prices is True and _t12_fb.from_feed is False)
|
|
check("...and every one of its points is provisional, so firm_points() is "
|
|
"empty, arbitrage() refuses and cheap_now() is False - it is a real "
|
|
"tariff and nothing may be acted on it. That is why it does not replace "
|
|
"a fresh observation",
|
|
isinstance(_t12_fb, TariffPlan) and _t12_fb.provisional is True
|
|
and _t12_fb.firm_points() == ()
|
|
and _t12_fb.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0,
|
|
uncertainty=NO_WEATHER).kind == "refused")
|
|
_t12_f.tariff_plan, _t12_f.tariff_at = _t12_good, datetime.now(UTC) - \
|
|
timedelta(seconds=1800)
|
|
_t12_f_at = _t12_f.tariff_at
|
|
_t12_f.refresh_once()
|
|
check("a §11.4 static FALLBACK does not replace a fresh provider plan either, "
|
|
"although it has prices - a day-ahead price for an hour does not change "
|
|
"once published, so the 30-minute-old observation is no less true and "
|
|
"is the only one of the two that carries a firm hour",
|
|
_t12_f.tariff_plan is _t12_good and _t12_f.tariff_at == _t12_f_at)
|
|
|
|
# -- ...and the rule is NOT symmetrical: a degradation replaces a degradation.
|
|
_t12_g = derived(lambda: _T12Controller(_t12_opts(STATIC_BE)))
|
|
_t12_g.tariff_plan, _t12_g.tariff_at = _t12_fb, datetime.now(UTC) - \
|
|
timedelta(seconds=1800)
|
|
_t12_g_at = _t12_g.tariff_at
|
|
_t12_g.refresh_once()
|
|
check("a standing plan that is ITSELF a §11.4 fallback IS replaced by the "
|
|
"next one, and its timestamp moves - otherwise an outage would freeze "
|
|
"the operator's static profile at the window it was first generated "
|
|
"for, and the guard would be 'never overwrite' wearing a bound's name",
|
|
_t12_g.tariff_plan is not _t12_fb and _t12_g.tariff_plan.origin ==
|
|
"fallback" and _t12_g.tariff_at != _t12_g_at)
|
|
|
|
# -- ...and a recovered feed replaces a kept plan, which is the third term ---
|
|
_T12_FEED_UP["up"] = True
|
|
_t12_h = derived(lambda: _T12Controller(_t12_opts()))
|
|
_t12_h.tariff_plan, _t12_h.tariff_at = _t12_good, datetime.now(UTC) - \
|
|
timedelta(seconds=1800)
|
|
_t12_h_at = _t12_h.tariff_at
|
|
_t12_h.refresh_once()
|
|
check("when the feed answers again the observation replaces the kept plan and "
|
|
"`tariff_at` moves - the guard keeps a plan against a DEGRADATION, not "
|
|
"against a fetch that worked",
|
|
_t12_h.tariff_plan is not _t12_good
|
|
and _t12_h.tariff_plan.from_feed is True
|
|
and _t12_h.tariff_at != _t12_h_at)
|
|
|
|
# -- TARIFF-10 widened WHICH INSTALLS RUN THIS TASK, so the configurations it
|
|
# -- newly keeps alive are graded here rather than reasoned about.
|
|
# ⚠️ `price_provider: off` + a `static_profile` + `arbitrage_enabled: true` is a
|
|
# configuration validate.py ACCEPTS and TARIFF-10's `or` exists to protect. On
|
|
# it `resolve_tariff()` returns `static_plan(provisional=False)` - the
|
|
# CONFIGURED product, origin "static" - so `standing.from_feed` is false on
|
|
# every refresh and this card's guard must never fire. Confirmed by driving it,
|
|
# not by reading the origin table: a guard that quietly latched here would
|
|
# freeze a static site's plan at its first window for the life of the install.
|
|
_t12_i = derived(lambda: _T12Controller(
|
|
{"price_provider": "off", "price_timeout_s": 10, "price_horizon_h": 48,
|
|
"static_profile": STATIC_BE}).refresh_once())
|
|
_t12_i_plan, _t12_i_at = _t12_i.tariff_plan, _t12_i.tariff_at
|
|
_t12_i.refresh_once()
|
|
check("PRECONDITION: a `price_provider: off` + static_profile site resolves to "
|
|
"the CONFIGURED product, not to a §11.4 row - origin 'static', prices, "
|
|
"and from_feed false because there is no feed to have observed",
|
|
getattr(_t12_i_plan, "origin", None) == "static"
|
|
and _t12_i_plan.has_prices is True and _t12_i_plan.from_feed is False)
|
|
check("...and TARIFF-10 now keeps this task alive on exactly that site, where "
|
|
"this card's guard must NEVER latch: the second refresh replaces the "
|
|
"first and the timestamp moves, so the plan is regenerated for the "
|
|
"current window every 30 minutes as it always was",
|
|
_t12_i.tariff_plan is not _t12_i_plan
|
|
and _t12_i.tariff_plan.origin == "static"
|
|
and _t12_i.tariff_at != _t12_i_at)
|
|
|
|
# ⚠️ AND THE OTHER CONFIGURATION TARIFF-10 ADDED - a provider configured with
|
|
# trading DISARMED - is graded by every driven check above without a second
|
|
# fixture, because nothing on this path reads `arbitrage_enabled` at all. That
|
|
# is a claim about the code rather than about a behaviour, so it is asserted as
|
|
# one (habit 4's exception, FCST-06's precedent): a fixture that set the option
|
|
# and asserted no difference would be comparing a thing against itself.
|
|
_T12_TRADING_OPTS = ("arbitrage_enabled", "cycle_cost_threshold_eur",
|
|
"arbitrage_soc_floor", "arbitrage_soc_ceiling")
|
|
# ⚠️ AND IT IS WHAT THE FUNCTION READS THAT IS SCANNED, NOT ITS TEXT. Two
|
|
# earlier drafts of this check were red against a CORRECT tree: the first
|
|
# because the docstring paragraph that SAYS `arbitrage_enabled` is not read is
|
|
# itself an occurrence of the string, the second because the log line says
|
|
# "arbitrage, where it is armed, stops with it" - which is a sentence, not a
|
|
# read. A substring test over a function grades its prose as much as its
|
|
# behaviour (habit 4). So the symbols are taken from the AST, and the string
|
|
# constants are taken separately and with the docstring dropped: naming
|
|
# arbitrage in a message is fine, READING a trading option is the thing claimed
|
|
# not to happen.
|
|
_t12_syms = derived(lambda: {getattr(n, "id", None) or getattr(n, "attr", None)
|
|
for n in ast.walk(_refresh)
|
|
if isinstance(n, (ast.Name, ast.Attribute))})
|
|
_t12_consts = derived(lambda: {c.value for b in _refresh.body[1:]
|
|
for c in ast.walk(b)
|
|
if isinstance(c, ast.Constant)
|
|
and isinstance(c.value, str)})
|
|
check("run_tariff_refresh() READS no trading option and calls nothing in "
|
|
"app/arbitrage.py, so keeping a plan is a decision about what the add-on "
|
|
"has LOOKED UP - TARIFF-10's own distinction, and why the checks above "
|
|
"cover a provider-configured site whether or not arbitrage is armed",
|
|
isinstance(_t12_syms, set)
|
|
and not any("arbitrage" in (n or "") for n in _t12_syms)
|
|
and not (_t12_consts & set(_T12_TRADING_OPTS))
|
|
# ...and both scanners can see what IS there, or they grade nothing: the
|
|
# one option this task does read, and a name it really does call.
|
|
and "price_horizon_h" in _t12_consts
|
|
and "fresh_tariff_plan" in _t12_syms
|
|
# ...and the docstring really was dropped, or the first draft's defect is
|
|
# back and the constant set is grading prose again.
|
|
and not any("RETURNS, NEVER RAISES" in c for c in _t12_consts))
|
|
|
|
# -- the vocabulary, graded on its own so a mutation of it has a named home --
|
|
check("TariffPlan.from_feed is true of exactly the observation - a §11.4 "
|
|
"fallback, a no-data read and a CONFIGURED static profile are all false, "
|
|
"and it is not a synonym for has_prices",
|
|
_t12_good.from_feed is True and _t12_fb.from_feed is False
|
|
and _t12_deg.from_feed is False
|
|
and static_plan({"static_profile": STATIC_BE}, D0,
|
|
D0 + timedelta(days=2), now=D0).from_feed is False
|
|
and static_plan({"static_profile": STATIC_BE}, D0,
|
|
D0 + timedelta(days=2), now=D0).has_prices is True)
|
|
|
|
# ⚠️ A SHAPE CLAIM, AND IT IS ALLOWED TO BE ONE (FCST-06's precedent). "The age
|
|
# is judged in ONE place" is a claim about the code, not about a behaviour: a
|
|
# transcribed copy of the same comparison behaves identically until the day the
|
|
# two drift. Every other check in this section is driven.
|
|
check("run_tariff_refresh() asks fresh_tariff_plan() rather than re-deriving "
|
|
"the age bound - one place judges it, which is what that method's own "
|
|
"docstring is for",
|
|
_refresh is not None
|
|
and {n.func.attr for n in ast.walk(_refresh)
|
|
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)}
|
|
>= {"fresh_tariff_plan"})
|
|
|
|
_price.https_get = _t12_saved_transport
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
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), "the site has no prices"))
|
|
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), "Make the lower limit the smaller number"))
|
|
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), "Set it to 0 or more")
|
|
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()
|
|
print("10. TARIFF-14 - the QUARTER-HOURLY day, end to end through decide()")
|
|
# ⚠️ THIS IS THE PROPERTY THE DEFECT ACTUALLY BROKE, WHICH IS WHY IT IS GRADED
|
|
# HERE AND NOT ONLY ON `at()`. `_act()` decides whether this interval is the
|
|
# buy one or the sell one with `here.start == v.cheapest.start` - an IDENTITY
|
|
# test between the point `_point_at()` matched and the point the verdict was
|
|
# built from. `_Series.at()` used to return the first point whose start was
|
|
# within one hour BEFORE the instant, in list order, so on a 900 s series that
|
|
# identity almost never held: the strategy idled through exactly the interval
|
|
# it exists to trade, and read `here.provisional` and §17's `price < 0` off a
|
|
# point that was not the one being traded.
|
|
#
|
|
# Belgium moved to a 15-minute MTU on 2025-10-01 and the live day-ahead feed
|
|
# returns 96 points at 900 s. `static_tariff.py` emits HOURLY points, so every
|
|
# fixture above this line - and every static site - is immune; this is
|
|
# reachable only down the dynamic path TARIFF-08/09 made work.
|
|
|
|
QD0 = datetime(2026, 1, 15, 0, 0, tzinfo=timezone(timedelta(hours=1)))
|
|
QUARTER = timedelta(minutes=15)
|
|
|
|
|
|
def qplan_of(prices, *, provisional=False, start=QD0, gaps=None):
|
|
"""A TariffPlan over `prices` spaced by `gaps` seconds (900 s by default).
|
|
|
|
`gaps` is what makes the MIXED fixture below expressible: FCST-04's
|
|
carry-forward emits HOURLY carried points beside QUARTER-HOURLY firm ones,
|
|
and the soak measured spacing [900, 3600] in 70 % of samples.
|
|
"""
|
|
g = [900] * (len(prices) - 1) if gaps is None else list(gaps)
|
|
flags = ([bool(provisional)] * len(prices)
|
|
if isinstance(provisional, bool) else list(provisional))
|
|
pts, t = [], start
|
|
for i, (p, f) in enumerate(zip(prices, flags)):
|
|
pts.append(PricePoint(start=brussels(t), eur_per_kwh=round(float(p), 6),
|
|
provisional=bool(f)))
|
|
if i < len(g):
|
|
t += timedelta(seconds=g[i])
|
|
return TariffPlan(PriceRead.series(pts, PriceProduct.DAY_AHEAD,
|
|
"generic_dayahead", start),
|
|
None, "provider")
|
|
|
|
|
|
def qprobe(i, *, start=QD0, gaps=None):
|
|
"""An instant 7 minutes into slot `i` - inside every window, never on a
|
|
boundary, and NOT the midpoint of an hour either, so an implementation that
|
|
answered per hour and one that answers per quarter differ here."""
|
|
g = [900] * i if gaps is None else list(gaps)[:i]
|
|
return start + timedelta(seconds=sum(g)) + timedelta(minutes=7)
|
|
|
|
|
|
def shipped_at(series, u):
|
|
"""THE RULE TARIFF-14 REPLACED, kept here as the COUNTERFACTUAL ONLY.
|
|
|
|
The first point whose start is within one hour before `u`, in list order.
|
|
Nothing below is graded against it; it exists so that the fixtures can be
|
|
shown to SEPARATE the two rules. A check whose data cannot reach the
|
|
failure it names is decoration (MUTATION.md), and on an hourly series the
|
|
two rules agree at every instant - which is why 29 files stayed green.
|
|
"""
|
|
uu = u.astimezone(UTC)
|
|
for p in series.points:
|
|
s = p.start.astimezone(UTC)
|
|
if s <= uu < s + timedelta(hours=1):
|
|
return p
|
|
return None
|
|
|
|
|
|
# The hourly day interpolated across its quarters, with the two extremes
|
|
# planted OFF the hour boundary on purpose.
|
|
QBASE = []
|
|
for _i in range(24):
|
|
_a, _b = DAY[_i], DAY[(_i + 1) % 24]
|
|
for _q in range(4):
|
|
QBASE.append(round(_a + (_b - _a) * _q / 4 + 0.000001 * (_i * 4 + _q),
|
|
6))
|
|
Q_CHEAP_I = 4 * 5 + 3 # 05:45 - a :45 quarter, not a whole hour
|
|
Q_DEAR_I = 4 * 19 + 1 # 19:15 - a :15 quarter, not a whole hour
|
|
QDAY = list(QBASE)
|
|
QDAY[Q_CHEAP_I] = round(min(QBASE) - 0.030, 6)
|
|
QDAY[Q_DEAR_I] = round(max(QBASE) + 0.030, 6)
|
|
QPLAN = qplan_of(QDAY)
|
|
QPTS = QPLAN.read.points
|
|
|
|
print("10a. the fixture is not degenerate, and it SEPARATES the two rules")
|
|
_qgaps = sorted({int((b.start - a.start).total_seconds())
|
|
for a, b in zip(QPTS, QPTS[1:])})
|
|
check(f"the day is 96 points at {_qgaps} s - the shape the live feed has "
|
|
f"returned since Belgium's 2025-10-01 move to a 15-minute MTU",
|
|
len(QPTS) == 96 and _qgaps == [900])
|
|
check(f"the cheapest quarter is slot {Q_CHEAP_I} and the dearest slot "
|
|
f"{Q_DEAR_I} - neither first, neither last, {abs(Q_CHEAP_I - Q_DEAR_I)} "
|
|
f"slots apart, and NEITHER ON A WHOLE HOUR, without which this section "
|
|
f"would grade nothing an hourly fixture does not already grade",
|
|
QDAY.index(min(QDAY)) == Q_CHEAP_I and QDAY.index(max(QDAY)) == Q_DEAR_I
|
|
and Q_CHEAP_I % 4 != 0 and Q_DEAR_I % 4 != 0
|
|
and Q_CHEAP_I not in (0, 95) and Q_DEAR_I not in (0, 95)
|
|
and abs(Q_CHEAP_I - Q_DEAR_I) > 1)
|
|
check("...and all 96 prices are distinct, so 'the cheapest quarter' names one "
|
|
"quarter and no tie-break is what any check below is really grading",
|
|
len(set(QDAY)) == 96)
|
|
check(f"...and its spread is {round(max(QDAY) - min(QDAY), 6):.4f} EUR/kWh, "
|
|
f"clear of the widened threshold, so what follows grades the strategy "
|
|
f"and not the spread rung",
|
|
round(max(QDAY) - min(QDAY), 6) > required_spread(_shipped, NO_WEATHER))
|
|
# ⚠️ HABIT 4, AND THE ONLY REASON THIS SECTION IS WORTH ITS LINES. State, as a
|
|
# check, that the shipped rule and the fixed one give DIFFERENT points at the
|
|
# two probes that matter. Without it, every check below could be passing on a
|
|
# fixture the defect could not have changed.
|
|
_cf_buy = derived(lambda: shipped_at(QPLAN.read, qprobe(Q_CHEAP_I)))
|
|
_cf_sell = derived(lambda: shipped_at(QPLAN.read, qprobe(Q_DEAR_I)))
|
|
check(f"the shipped rule and this one give DIFFERENT points at both probes - "
|
|
f"buy: {derived(lambda: _cf_buy.eur_per_kwh)} instead of "
|
|
f"{QDAY[Q_CHEAP_I]}, sell: {derived(lambda: _cf_sell.eur_per_kwh)} "
|
|
f"instead of {QDAY[Q_DEAR_I]} - so the fixture can reach the failure it "
|
|
f"names",
|
|
_cf_buy is not QPTS[Q_CHEAP_I] and _cf_sell is not QPTS[Q_DEAR_I]
|
|
and _cf_buy is not None and _cf_sell is not None)
|
|
|
|
print()
|
|
print("10b. the buy/sell identity test matches the actual extremum interval")
|
|
_qv = QPLAN.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=_shipped,
|
|
uncertainty=NO_WEATHER)
|
|
check("the verdict's cheapest and dearest are the two QUARTERS, not the hours "
|
|
"containing them - `static_tariff` reduces over every firm point and "
|
|
"carries no resolution assumption of its own",
|
|
derived(lambda: _qv.cheapest) is QPTS[Q_CHEAP_I]
|
|
and derived(lambda: _qv.dearest) is QPTS[Q_DEAR_I])
|
|
_pa_buy = derived(lambda: _arb._point_at(QPLAN, qprobe(Q_CHEAP_I)))
|
|
_pa_sell = derived(lambda: _arb._point_at(QPLAN, qprobe(Q_DEAR_I)))
|
|
check("THE PROPERTY THE DEFECT BROKE: at the buy probe `_point_at()` returns "
|
|
"the verdict's OWN cheapest point, by identity - which is what makes "
|
|
"`_act()`'s `here.start == v.cheapest.start` true, and what made it "
|
|
"false on every quarter-hourly series the add-on has ever fetched",
|
|
_pa_buy is derived(lambda: _qv.cheapest))
|
|
check("...and the same at the sell probe, against the verdict's dearest",
|
|
_pa_sell is derived(lambda: _qv.dearest))
|
|
_qbuy = decide(QPLAN, qprobe(Q_CHEAP_I), peak_guard_active=False, soc=50.0,
|
|
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
|
charge_headroom_w=HEADROOM_CLEAR,
|
|
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
|
soc_ceiling_pct=95.0)
|
|
_qsell = decide(QPLAN, qprobe(Q_DEAR_I), peak_guard_active=False, soc=50.0,
|
|
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
|
charge_headroom_w=HEADROOM_CLEAR,
|
|
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
|
soc_ceiling_pct=95.0)
|
|
check("...so decide() CHARGES in the cheapest quarter and DISCHARGES in the "
|
|
"dearest one, which is the trade the strategy exists to make and the "
|
|
"one it slept through",
|
|
_qbuy.action == CHARGE and _qsell.action == DISCHARGE)
|
|
check(f"...at THAT QUARTER's price and not at the price of the hour "
|
|
f"containing it (buy {_qbuy.price_now_eur_kwh}, sell "
|
|
f"{_qsell.price_now_eur_kwh})",
|
|
near(_qbuy.price_now_eur_kwh, QDAY[Q_CHEAP_I])
|
|
and near(_qsell.price_now_eur_kwh, QDAY[Q_DEAR_I]))
|
|
check("...and both reasons quote that quarter's price, so the operator's log "
|
|
"names the interval that was actually traded",
|
|
f"{QDAY[Q_CHEAP_I]:.4f}" in _qbuy.reason
|
|
and f"{QDAY[Q_DEAR_I]:.4f}" in _qsell.reason)
|
|
# The negative arm. Without it "it acted" could be "it acts everywhere".
|
|
_QOFF_I = Q_CHEAP_I + 2
|
|
check(f"the off-peak probe's own price differs from its hour's :00 price "
|
|
f"({QDAY[_QOFF_I]} vs {QDAY[4 * (_QOFF_I // 4)]}), so the check below "
|
|
f"can tell which point the decision read",
|
|
QDAY[_QOFF_I] != QDAY[4 * (_QOFF_I // 4)]
|
|
and _QOFF_I not in (Q_CHEAP_I, Q_DEAR_I))
|
|
_qoff = decide(QPLAN, qprobe(_QOFF_I), peak_guard_active=False, soc=50.0,
|
|
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
|
charge_headroom_w=HEADROOM_CLEAR,
|
|
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
|
soc_ceiling_pct=95.0)
|
|
check("a quarter that is neither extremum still idles, for the reason that "
|
|
"names the right rule, and reports ITS OWN price - not the price of the "
|
|
"quarter the shipped scan would have matched",
|
|
_qoff.action is None and _qoff.reason == _arb.IDLE_OFF_PEAK_HOUR
|
|
and near(_qoff.price_now_eur_kwh, QDAY[_QOFF_I]))
|
|
|
|
print()
|
|
print("10c. §17 and §8.3 are evaluated on the QUARTER being traded")
|
|
# ⚠️ §17 AT 900 s. The whole day is below zero and the dearest QUARTER is the
|
|
# least negative one - section 2's shape, at the resolution the live feed
|
|
# actually publishes. Under the shipped scan this probe never reached the
|
|
# guard at all: the identity test failed first and the decision idled as
|
|
# "neither the buy hour nor the sell hour", so §17 was not so much wrong as
|
|
# never consulted.
|
|
_shift = round(max(QDAY) + 0.020, 6)
|
|
QNEG = [round(p - _shift, 6) for p in QDAY]
|
|
QNEG_PLAN = qplan_of(QNEG)
|
|
check(f"the negative day's dearest quarter is slot {Q_DEAR_I} and it is BELOW "
|
|
f"ZERO ({max(QNEG):+.4f}), and its spread still clears the widened "
|
|
f"threshold - so the refusal below is §17's and not the spread rung's",
|
|
QNEG.index(max(QNEG)) == Q_DEAR_I and max(QNEG) < 0
|
|
and round(max(QNEG) - min(QNEG), 6) > required_spread(_shipped,
|
|
NO_WEATHER))
|
|
_qneg_sell = decide(QNEG_PLAN, qprobe(Q_DEAR_I), peak_guard_active=False,
|
|
soc=50.0, min_spread_eur_kwh=_shipped,
|
|
uncertainty=NO_WEATHER, charge_headroom_w=HEADROOM_CLEAR,
|
|
charge_w=MAX_W, discharge_w=MAX_W,
|
|
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
|
|
check("selling into a negative-price QUARTER is refused, and the reason names "
|
|
"§17 and THAT QUARTER's price",
|
|
_qneg_sell.action is None and "§17" in _qneg_sell.reason
|
|
and f"{max(QNEG):+.4f}" in _qneg_sell.reason)
|
|
# ...and the other side, so the refusal is about the sign and nothing else.
|
|
QPOS = list(QNEG)
|
|
QPOS[Q_DEAR_I] = round(-max(QNEG), 6)
|
|
check("...while the SAME quarter-hourly series with that ONE price's SIGN "
|
|
"FLIPPED discharges instead",
|
|
decide(qplan_of(QPOS), qprobe(Q_DEAR_I), peak_guard_active=False,
|
|
soc=50.0, min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
|
charge_headroom_w=HEADROOM_CLEAR,
|
|
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
|
soc_ceiling_pct=95.0).action == DISCHARGE
|
|
and sum(1 for a, b in zip(QNEG, QPOS) if a != b) == 1)
|
|
# §8.3's flag, read off the covering point. `_act`'s own provisional guard
|
|
# stays unreachable here for section 4's reason - the extrema are firm by
|
|
# `_firm_or_die` and starts are unique - and THAT ARGUMENT IS EXACTLY WHAT THE
|
|
# DEFECT BROKE at 900 s, because the point `at()` returned was not the
|
|
# verdict's point at all. It is restated here at the resolution that matters.
|
|
check("the point matched for the buy quarter IS the verdict's own cheapest "
|
|
"point and is FIRM, so `_act`'s §8.3 guard is unreachable at 900 s for "
|
|
"the same construction section 4 states at 3600 s",
|
|
_pa_buy is derived(lambda: _qv.cheapest)
|
|
and derived(lambda: _pa_buy.provisional) is False
|
|
and len({p.start for p in QPTS}) == len(QPTS))
|
|
|
|
print()
|
|
print("10d. the MIXED series FCST-04 actually emits, traded end to end")
|
|
# ⚠️ 70 % OF SOAK SAMPLES, NOT AN EDGE CASE. The carry-forward emits HOURLY
|
|
# carried points beside QUARTER-HOURLY firm ones, so a window derived from one
|
|
# global spacing would be wrong in the NORMAL steady state. Here: 12 carried
|
|
# hourly points for the morning, then firm quarters for the rest of the day.
|
|
MIX_N_H, MIX_N_Q = 12, 48
|
|
MIX_GAPS = [3600] * MIX_N_H + [900] * (MIX_N_Q - 1)
|
|
MIX_PRICES = [QDAY[4 * i] for i in range(MIX_N_H)] + QDAY[4 * MIX_N_H:]
|
|
MIX_FLAGS = [True] * MIX_N_H + [False] * MIX_N_Q
|
|
MIX_PLAN = qplan_of(MIX_PRICES, provisional=MIX_FLAGS, gaps=MIX_GAPS)
|
|
MIX_PTS = MIX_PLAN.read.points
|
|
_mix_gaps = sorted({int((b.start - a.start).total_seconds())
|
|
for a, b in zip(MIX_PTS, MIX_PTS[1:])})
|
|
check(f"the mixed plan really is mixed - spacing {_mix_gaps} s, "
|
|
f"{MIX_N_H} carried and {MIX_N_Q} firm points, so neither half is what "
|
|
f"the other half's window rule would give",
|
|
_mix_gaps == [900, 3600]
|
|
and sum(1 for p in MIX_PTS if p.provisional) == MIX_N_H
|
|
and sum(1 for p in MIX_PTS if not p.provisional) == MIX_N_Q)
|
|
_mix_cheap_i = MIX_N_H + (MIX_PRICES[MIX_N_H:].index(min(MIX_PRICES[MIX_N_H:])))
|
|
_mix_dear_i = MIX_N_H + (MIX_PRICES[MIX_N_H:].index(max(MIX_PRICES[MIX_N_H:])))
|
|
check(f"...and its firm half's extremes are quarters {_mix_cheap_i} and "
|
|
f"{_mix_dear_i}, neither on a whole hour, so the identity test below is "
|
|
f"about the quarter and not about the hour",
|
|
(_mix_cheap_i - MIX_N_H) % 4 != 0 and (_mix_dear_i - MIX_N_H) % 4 != 0)
|
|
_mv = MIX_PLAN.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=_shipped,
|
|
uncertainty=NO_WEATHER)
|
|
_mix_buy_t = qprobe(_mix_cheap_i, gaps=MIX_GAPS)
|
|
_mix_sell_t = qprobe(_mix_dear_i, gaps=MIX_GAPS)
|
|
check("the verdict over a MIXED series still names firm quarters, and "
|
|
"`_point_at()` matches them by identity at their own probes",
|
|
derived(lambda: _mv.cheapest) is MIX_PTS[_mix_cheap_i]
|
|
and derived(lambda: _mv.dearest) is MIX_PTS[_mix_dear_i]
|
|
and derived(lambda: _arb._point_at(MIX_PLAN, _mix_buy_t))
|
|
is MIX_PTS[_mix_cheap_i]
|
|
and derived(lambda: _arb._point_at(MIX_PLAN, _mix_sell_t))
|
|
is MIX_PTS[_mix_dear_i])
|
|
# PRICE-02: the same day twice, as a running site holds 24-48 h (see plan_of).
|
|
# The probes stay in day one and keep their extremes: once the cheap quarter is
|
|
# over, the verdict at the dear quarter needs a cheap one still ahead.
|
|
MIX2_PLAN = qplan_of(MIX_PRICES * 2, provisional=MIX_FLAGS * 2,
|
|
gaps=MIX_GAPS + [900] + MIX_GAPS)
|
|
check("...so the mixed day CHARGES and DISCHARGES in its firm quarters, at "
|
|
"their own prices",
|
|
decide(MIX2_PLAN, _mix_buy_t, peak_guard_active=False, soc=50.0,
|
|
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
|
charge_headroom_w=HEADROOM_CLEAR,
|
|
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
|
soc_ceiling_pct=95.0).action == CHARGE
|
|
and decide(MIX2_PLAN, _mix_sell_t, peak_guard_active=False, soc=50.0,
|
|
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
|
charge_headroom_w=HEADROOM_CLEAR,
|
|
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
|
soc_ceiling_pct=95.0).action == DISCHARGE)
|
|
# An instant inside a CARRIED hour of the same series. The covering point is
|
|
# the carried one; the shipped scan answered a point from the FIRM block
|
|
# earlier in the list, which is §8.3's flag read off the wrong point.
|
|
_mix_carried_t = QD0 + timedelta(hours=MIX_N_H - 1, minutes=37)
|
|
check("an instant inside a CARRIED hour of the mixed series matches the "
|
|
"CARRIED point, whose provisional flag is what §8.3 reads - and the "
|
|
"strategy does nothing there, because a carried point is not in the "
|
|
"firm trade set",
|
|
derived(lambda: _arb._point_at(MIX_PLAN, _mix_carried_t))
|
|
is MIX_PTS[MIX_N_H - 1]
|
|
and MIX_PTS[MIX_N_H - 1].provisional is True
|
|
and decide(MIX_PLAN, _mix_carried_t, peak_guard_active=False, soc=50.0,
|
|
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
|
charge_headroom_w=HEADROOM_CLEAR,
|
|
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
|
soc_ceiling_pct=95.0).action is None)
|
|
|
|
print()
|
|
print("10e. the HOURLY day is unchanged, which is the regression half")
|
|
# ⚠️ A REGRESSION GUARD, NOT A WITNESS. On an hourly series the shipped rule
|
|
# and this one agree at every instant, which is precisely why 29 files stayed
|
|
# green through the defect. It is here so that a fix which got 900 s right by
|
|
# breaking 3600 s cannot pass.
|
|
_h_buy = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER)
|
|
_h_sell = act(PLAN, DEAR_H, thresh=_shipped, unc=NO_WEATHER)
|
|
check("the hourly fixture still charges in its cheapest hour and discharges "
|
|
"in its dearest, at those hours' prices",
|
|
_h_buy.action == CHARGE and _h_sell.action == DISCHARGE
|
|
and near(_h_buy.price_now_eur_kwh, DAY[CHEAP_H])
|
|
and near(_h_sell.price_now_eur_kwh, DAY[DEAR_H]))
|
|
check("...and a probe 59 minutes into an hourly slot still matches that slot, "
|
|
"so the hourly window did not shrink under the fix",
|
|
derived(lambda: _arb._point_at(
|
|
PLAN, D0 + timedelta(hours=CHEAP_H, minutes=59)))
|
|
is derived(lambda: PLAN.read.points[CHEAP_H]))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
|
sys.exit(1)
|
|
print("all checks passed")
|