PRICE-02 round 2: require now, undo the test loosening, analyse ties
checks / unit (pull_request) Successful in 47m49s
infra-pin / fresh (pull_request) Successful in 2s

- TariffPlan.arbitrage(now=...) is required (keyword-only, no default); so are
  homeview.trade_verdict/refusal_key's now. Existing test calls pass one.
- test_arbitrage.py: no module-level decide wrapper. Fixtures that probed a
  one-day plan after its extremes now hold two days (as a site does); the
  flap day gets a unique cheapest and dearest hour; the replay breaks its
  ties without direction. The flap bound is <= 4 and the replay check is
  len(_trades) < 365 again. 228 checks pass on the old and the new code.
- test_price02.py part 6: ties (equal quarters, a real 15-minute day,
  alternating hourly prices) and the required now.
- Identity pins: the masked digest is only for the older-tree comparison;
  this tree's own digests are pinned with nothing hidden.
- DECISIONS: the tie analysis with numbers.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-10-22 10:37:43 +02:00
co-authored by Claude
parent 2ff9be710c
commit 891ed7bdd7
14 changed files with 362 additions and 134 deletions
@@ -17,7 +17,9 @@ ended (`end <= now`, the span `price._cover_table()` gives, as
still used at 03:20, and the Home chart keeps the day's earlier bars.
- An hourly point that is still current stays in, so hourly feeds and the
static day/night plan give the same verdict as before.
- `now=None` keeps the old reach (the whole series); only tests leave it out.
- `now` is required: keyword-only, no default, like the other three arguments. A
caller that could leave it out would get the elapsed periods back and the
defect with them. A test that wants the whole series passes an instant before it.
- The sentences of this verdict in `arbitrage.py` (status row and action log,
English only) say "price period" where they said "hour": the idle one now
reads "now is not the cheapest or the dearest price period". The UI texts
@@ -30,3 +32,38 @@ the same too; only a sale's sentence quotes a smaller "above the cheapest"
figure once the first cheap period is over. The identity pins in
`test_modes_p3.py` and `test_price_run_fixes.py` hide just that figure and the
word change (`opt11p3_identity.trade_digest()`).
## Ties (round 2)
The verdict takes `min`/`max` over the points still ahead and the first one wins
a tie, so with equal prices it is the first period still ahead, which is the
current one. Before, it was the first of the whole plan, often one that had
ended. Two effects follow, and both are the card's intent, not a side effect:
- Equal quarters (hour-constant data, or a feed that prints the same price in
several quarters) now act in every quarter of the cheap (or dear) block,
not in the first only. Test: `test_price02.py` part 6.
- After the cheapest period has passed, the next cheapest still ahead is the
target, if its spread still clears the cost. A hourly plan did this at every
refresh already (it started at the current hour); a 15-minute plan did it
only from the next hour on, because it kept the elapsed quarters of this one.
Measured with `scratch/PRICE-02/dev/tiesim.py` (refresh every 30 min from the
floored hour, verdict at 7 min into each period, threshold 0.05, old = the
verdict over the whole plan, new = this card):
| data | days | acting periods old -> new | action changes old -> new |
|---|---|---|---|
| real 15-minute day, energy-charts 2026-09-09 | 1 | 4 -> 5 | 6 -> 6 |
| real 15-minute days 2026-09-09 and -10 | 2 | 14 -> 26 | 24 -> 24 |
| EPEX 2025-26 hourly prices as four equal quarters | 12 | 18 -> 72 | 36 -> 20 |
| the same with random +-0.002 noise per quarter (no ties) | 12 | 18 -> 33 | 36 -> 48 |
So a real 15-minute day does not switch more often (24 -> 24), and a day of
equal quarters switches less. A synthetic day of noisy, tie-free quarters does
(3.0 -> 4.0 changes a day): each period that passes hands the target to the
next cheapest one ahead. Every change is still at a period edge, never within
one. The replay in `test_arbitrage.py` (synthetic year, hourly) gives the same
ledger on the old and the new code once its ties are broken (280 round trips,
85 held, both): it printed one maximum twice per day, and the old fixture
tested the first of them only because its plan began at midnight.
+4 -5
View File
@@ -262,7 +262,7 @@ def _run(i, kind, start, end, day0, day1, what, why, amount, *, kwh=None,
"eur_kwh": None if price is None else round(price, 4)}
def trade_verdict(c, plan, now=None):
def trade_verdict(c, plan, now):
"""The arbitrage verdict the control cycle would reach on this plan, or a
reason there is none. The SAME call and inputs as `Controller.cycle()`:
`plan.arbitrage()` with the peak guard's state, the cycle cost and the
@@ -284,7 +284,7 @@ def trade_verdict(c, plan, now=None):
refused=lambda _v: (None, refusal_key(plan, guard, now)))
def refusal_key(plan, guard, now=None):
def refusal_key(plan, guard, now):
"""Which of `TariffPlan.arbitrage()`'s refusals fired, in its own order:
the peak guard, no prices, under two published points, then the gap."""
if guard:
@@ -292,9 +292,8 @@ def refusal_key(plan, guard, now=None):
if not plan.has_prices:
return "no_prices"
pts = _points(plan)
if now is not None:
ahead = {id(p) for _s, end, p in _cover_table(pts) if end > now}
pts = [p for p in pts if id(p) in ahead]
ahead = {id(p) for _s, end, p in _cover_table(pts) if end > now}
pts = [p for p in pts if id(p) in ahead]
if sum(1 for p in pts if not p.provisional) < 2:
return "not_firm"
return "not_worth"
+18 -11
View File
@@ -986,20 +986,25 @@ class TariffPlan:
return here.eur_per_kwh < (sum(p.eur_per_kwh for p in firm) / len(firm))
def arbitrage(self, *, peak_guard_active: bool, min_spread_eur_kwh,
uncertainty, now: datetime | None = None) -> Arbitrage:
uncertainty, now: datetime) -> Arbitrage:
"""May OPT-04 trade, and on what numbers. See the module docstring.
PRICE-02. `now` (tz-aware) drops the points whose price period has
ended (`end <= now`, the span `price._cover_table()` gives it), so the
cheapest and dearest point are always ones that can still be traded.
It is taken here, per call, and not when the plan is built: a plan
built at 03:00 is still used at 03:20. Every caller passes its own
`now`. An hourly point that is still current stays in. Left out
(None), the whole series counts, as before.
built at 03:00 is still used at 03:20. An hourly point that is still
current stays in.
⚠️ ALL THREE ARGUMENTS ARE REQUIRED AND KEYWORD-ONLY, for one reason
⚠️ ALL FOUR ARGUMENTS ARE REQUIRED AND KEYWORD-ONLY, for one reason
each, and none of them has a default:
`now` PRICE-02. A caller that could leave it out
would get the whole series back, elapsed
periods included, and the verdict could aim
at a period that is over. A test that wants
the whole series passes an instant before it.
`peak_guard_active` §9.1/§10.1 put peak shaving above arbitrage,
and this is how that ordering survives a
strategy that has not been written yet: the
@@ -1037,6 +1042,12 @@ class TariffPlan:
f"{sorted(map(repr, caller_sources))} - a string widens by "
f"nothing while reporting that it widened.")
if not isinstance(now, datetime) or now.tzinfo is None \
or now.utcoffset() is None:
raise TypeError(f"now must be a tz-aware datetime, not {now!r}; "
f"a naive instant is ambiguous for one hour "
f"every October")
if peak_guard_active:
return Arbitrage.refuse(
"the capacity-tariff peak guard is holding, and §10.1 puts peak "
@@ -1064,12 +1075,8 @@ class TariffPlan:
# day with no fetch at all) has no firm pair left and is refused, which
# is the previous behaviour arrived at by a truer route.
pts = self.read.points
if now is not None:
if now.tzinfo is None or now.utcoffset() is None:
raise TypeError("now must be tz-aware; a naive instant is "
"ambiguous for one hour every October")
ahead = {id(p) for _s, end, p in _cover_table(pts) if end > now}
pts = tuple(p for p in pts if id(p) in ahead)
ahead = {id(p) for _s, end, p in _cover_table(pts) if end > now}
pts = tuple(p for p in pts if id(p) in ahead)
firm = tuple(p for p in pts if not p.provisional)
if len(firm) < 2:
# ⚠️ POINTS, AND THE HOUR FIGURE BESIDE IT RATHER THAN INSTEAD OF
+53 -49
View File
@@ -93,11 +93,9 @@ 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, enabled, soc_bounds,
CHARGE, DISCHARGE, ArbitrageDecision, claim, decide, enabled, soc_bounds,
threshold_eur_kwh,
)
from app.arbitrage import decide as _decide_now
from app.price import _cover_table as _cover
# SAFETY-47 section 3d. The shipped shadow-mode row, not a transcription of it.
from app import shadow as _shadow
from app.price import (
@@ -111,30 +109,14 @@ 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 decide(plan, now, **kw):
"""`arbitrage.decide()` on the plan as a running site holds it at `now`.
PRICE-02: the verdict drops price periods that have ended. These fixtures
are whole-day plans probed at every hour, so the periods before `now` are
moved one day on (the next day's prices are the same): the plan then
starts at `now`, as a refreshed plan does, and every check keeps asking
what it asked before. Nothing before `now` is left to drop. The new
behaviour itself is graded in test_price02.py."""
if plan is None or plan.read.kind != "series":
return _decide_now(plan, now, **kw)
r = plan.read
pts = [PricePoint(start=brussels(p.start + timedelta(hours=24)),
eur_per_kwh=p.eur_per_kwh, provisional=p.provisional)
if end <= now else p for _s, end, p in _cover(r.points)]
pts.sort(key=lambda p: p.start)
rolled = TariffPlan(PriceRead.series(pts, r.product, r.provider,
r.fetched_at), plan.profile, plan.origin)
return _decide_now(rolled, now, **kw)
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
@@ -217,13 +199,22 @@ NO_WEATHER = frozenset({Uncertainty.NO_WEATHER})
def plan_of(prices, *, product=PriceProduct.DAY_AHEAD, start=D0,
provisional=False, provider="generic_dayahead"):
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))
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))]
@@ -367,7 +358,7 @@ 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(
_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 "
@@ -384,7 +375,7 @@ check(f"...and it is the SPREAD rung that refuses it, not a flat-tariff or a "
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(
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")
@@ -707,12 +698,12 @@ check("...and the interlock is REQUIRED rather than defaulted: "
"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 not in ("self", "now"))) # PRICE-02: now=None keeps the old reach
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_now).parameters.items()))
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
@@ -1667,7 +1658,7 @@ check("a FULLY carried series trades nothing at all, whatever its spread - "
# standing if a future caller ever hands `_act` a plan the verdict did not come
# from.
_probe = _arb._point_at(PLAN, hour(CHEAP_H))
_verdict = PLAN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0,
_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 "
@@ -1688,6 +1679,14 @@ print("4b. §10.2 hysteresis - a price on the boundary cannot flap at 30 s")
# 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",
@@ -1705,7 +1704,7 @@ 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 <= 24)
_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)
@@ -1862,18 +1861,22 @@ 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
TRADE_HOURS = [] # (day, hour) the strategy acted in (PRICE-02)
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
TRADE_HOURS.clear()
for n in range(365):
day0 = REPLAY_START + timedelta(days=n)
prices = synth_day(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):
@@ -1881,7 +1884,6 @@ def replay(threshold, unc, *, soc0=50.0, floor=20.0, ceiling=95.0):
ceiling=ceiling, start=day0)
if d.action is None:
continue
TRADE_HOURS.append((n, h))
price = prices[h]
if d.action == CHARGE:
soc = min(ceiling, soc + STEP_PCT)
@@ -1910,12 +1912,9 @@ check(f"the replay is 365 days long and actually traded ({len(_trades)} "
check("...and every trade carries the spread and the threshold it cleared, so "
"the ledger is auditable rather than a count",
_bad == [])
# PRICE-02: the plan now rolls with the clock, so a day can hold several round
# trips and every day may trade once. Selective is judged per hour instead.
check(f"...and the strategy acted in {len(TRADE_HOURS)} of {365 * 24} hours, a "
f"minority, so it is selective rather than unconditional - a replay in "
f"which everything trades grades nothing",
0 < len(TRADE_HOURS) < 365 * 24 // 4)
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 "
@@ -2475,7 +2474,7 @@ check("...and every one of its points is provisional, so firm_points() is "
"a fresh observation",
isinstance(_t12_fb, TariffPlan) and _t12_fb.provisional is True
and _t12_fb.firm_points() == ()
and _t12_fb.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0,
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)
@@ -2820,7 +2819,7 @@ check(f"the shipped rule and this one give DIFFERENT points at both probes - "
print()
print("10b. the buy/sell identity test matches the actual extremum interval")
_qv = QPLAN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=_shipped,
_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 "
@@ -2952,7 +2951,7 @@ 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(peak_guard_active=False, min_spread_eur_kwh=_shipped,
_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)
@@ -2964,14 +2963,19 @@ check("the verdict over a MIXED series still names firm quarters, and "
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(MIX_PLAN, _mix_buy_t, peak_guard_active=False, soc=50.0,
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(MIX_PLAN, _mix_sell_t, peak_guard_active=False, soc=50.0,
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,
+2 -2
View File
@@ -264,7 +264,7 @@ check("trading on price: a charge block at the cheapest hour (13:00) and a "
check("...placed where the control cycle's own verdict puts them: the same "
"plan.arbitrage() call on the same plan",
c_arb.fresh_tariff_plan(NOW).arbitrage(
peak_guard_active=False, min_spread_eur_kwh=0.05,
now=NOW, peak_guard_active=False, min_spread_eur_kwh=0.05,
uncertainty=frozenset({__import__("app.price", fromlist=["x"])
.Uncertainty.NO_WEATHER}))
.match(allowed=lambda v: (v.cheapest.start, v.dearest.start),
@@ -717,7 +717,7 @@ def _dn(arb):
_c_off, _p_off = _dn(False)
_c_on, _p_on = _dn(True)
_v_on, _why_on = homeview.trade_verdict(_c_on, _c_on.fresh_tariff_plan(NOW))
_v_on, _why_on = homeview.trade_verdict(_c_on, _c_on.fresh_tariff_plan(NOW), NOW)
check("UX-45: day/night tariff with the stored plan and price trading off: "
"24 bars, no cheap or dear mark",
len(_p_off) == 24 and {b["kind"] for b in _p_off} == {"mid"})
+34 -8
View File
@@ -67,27 +67,53 @@ A, B = "inverter-a", "inverter-b"
# ============================================================================ #
print("1. modes off: byte-identical to 7a00f12 on the trade path")
# Printed on release/1.0 7a00f12 (2026-09-29), goodwe-addon-checks:1, 1080
# cycles each; the trade CHARGEs and DISCHARGEs. PRICE-02 (2026-10-22): the
# digest is `ident.trade_digest()`, which hides two things in the trade
# sentence: the "above the cheapest" figure (the plan now drops price periods
# that have ended, so it is another number once the first cheap period is
# over) and "hour" -> "price period". Re-measured with it on 7a00f12, on
# release/1.0 90bc901 and on this tree: the same four digests.
# cycles each; the trade CHARGEs and DISCHARGEs.
#
# PRICE-02 (2026-10-22) changed two things in the trade sentence and nothing
# else: the plan now drops price periods that have ended, so a sale's "N above
# the cheapest" is another number once the first cheap period is over, and the
# sentences say "price period" for "hour". So there are three pins:
# AT_7A00F12_OLD the digests printed on 7a00f12, sentence included. They no
# longer match, and the check below says so: that is what
# makes the mask necessary.
# AT_7A00F12 the same rows through `ident.trade_digest()`, which hides
# exactly those two things (and only for this comparison with
# an older tree). It equals what 7a00f12 gives through it.
# AT_NOW this tree's own digests, sentence included, with nothing
# hidden: any later change to a command, a sentence or a
# figure moves it. The mask cannot hide a future drift.
AT_7A00F12_OLD = {
"n1": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c",
"n2": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
}
AT_7A00F12 = {
"n1": "33886ef03f364852883f0370230e6e04823fbb46a4704f88ca75bf9082d98c01",
"n2": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
"n1_off": "33886ef03f364852883f0370230e6e04823fbb46a4704f88ca75bf9082d98c01",
"n2_off": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
}
AT_NOW = {
"n1": "939b3d47bbd64fa50e3d70538f092fb98e5c4bd5ead85a7fc696a1dbb514039e",
"n2": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d",
"n1_off": "939b3d47bbd64fa50e3d70538f092fb98e5c4bd5ead85a7fc696a1dbb514039e",
"n2_off": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d",
}
_cmd_digest = ident.trade_digest
_rows = {k: ident.run(**ident.SCENARIOS[k])[0] for k in AT_7A00F12}
_got = {k: _cmd_digest(v) for k, v in _rows.items()}
for k in AT_7A00F12:
print(f" {k} {'same' if _got[k] == AT_7A00F12[k] else 'DIFFERS'} "
f"{_got[k][:16]}")
_full = {k: ident.digest(v) for k, v in _rows.items()}
check("with battery modes off (option absent or false), every command, "
"integrator, allocation and trade sentence equals 7a00f12's, at one "
"inverter and at two", _got == AT_7A00F12)
"integrator, allocation and trade action equals 7a00f12's, at one "
"inverter and at two (the sentence masked as above)",
_got == AT_7A00F12)
check("...the mask is needed and nothing more: unmasked, n1 and n2 differ "
"from 7a00f12's printed digests, and unmasked they equal this tree's "
"own pins, so a later change to any sentence or figure fails here",
all(_full[k] != AT_7A00F12_OLD[k] for k in AT_7A00F12_OLD)
and _full == AT_NOW)
_acts = {r[-1][0] for r in _rows["n2"] if r[-1]}
check("...and the runs reach the trade P3 now skips with modes on: a charge "
"and a sale", {arbitrage.CHARGE, arbitrage.DISCHARGE} <= _acts)
+8 -3
View File
@@ -99,6 +99,11 @@ from app.static_tariff import (
TariffPlan, parse_taxes_profile, static_status)
from app.validate import validate_startup_config
# 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 = []
@@ -4518,8 +4523,8 @@ _T11_FLAT = TariffPlan(
[PricePoint(_T11_NOW + timedelta(minutes=15 * i), 0.10, False)
for i in range(96)],
PriceProduct.DAY_AHEAD, "generic_dayahead", _T11_NOW), None, "provider")
_r83 = derived(lambda: _T11_ONEFIRM.arbitrage(**_T11_A9).reason)
_rflat = derived(lambda: _T11_FLAT.arbitrage(**_T11_A9).reason)
_r83 = derived(lambda: _T11_ONEFIRM.arbitrage(now=LONG_AGO, **_T11_A9).reason)
_rflat = derived(lambda: _T11_FLAT.arbitrage(now=LONG_AGO, **_T11_A9).reason)
print(f" §8.3 refusal : {_r83[:118]}")
print(f" flat refusal : {_rflat[:118]}")
# 96 quarter-hourly points span 24 hours; ONE firm point spans one of them.
@@ -4828,7 +4833,7 @@ def _t17_verdict(points):
read = PriceRead.series(points, PriceProduct.DAY_AHEAD, "t17",
datetime.now(UTC))
plan = TariffPlan(read, None, "provider")
return plan.arbitrage(peak_guard_active=False,
return plan.arbitrage(now=LONG_AGO, peak_guard_active=False,
min_spread_eur_kwh=PRICE_OPTION_DEFAULTS[
"cycle_cost_threshold_eur"],
uncertainty=frozenset())
+114 -6
View File
@@ -16,7 +16,12 @@ The trading verdict is taken over the price periods that have not ended.
point that is still current stays in.
4. Modes on: a spread that exists only because of an ended period no longer
opens the gate (`Controller._mode_roles`), nor the Home page verdict.
5. The reason text says "price period".
5. The reason text says "price period"; `now` is required (no default).
6. Ties. The verdict takes the first extreme still ahead, where it used to
take the first of the whole plan. On hour-constant 15-minute data (four
equal quarters) that is four acting periods in the cheap hour, not one;
the number of times the action changes does not grow. The same on a real
15-minute day, and hourly alternating prices change at most once an hour.
Mutations: MUTATION.md says how to run them (purged copies); the ones of
PRICE-02 are listed in the pull request.
@@ -50,6 +55,7 @@ def check(name, cond):
UTC = timezone.utc
LONG_AGO = datetime(2000, 1, 1, tzinfo=UTC) # before every point: nothing has ended, the old reach
NO_WEATHER = frozenset({Uncertainty.NO_WEATHER})
D0 = datetime(2026, 10, 20, 0, 0, tzinfo=UTC) # 02:00 Brussels
H3 = D0 + timedelta(hours=1) # 03:00 Brussels
@@ -64,6 +70,10 @@ def qplan(prices, start=D0, minutes=15, product=PriceProduct.DAY_AHEAD):
start), None, "provider")
ARGS_NO_NOW = dict(ARGS)
ARGS5 = dict(ARGS, min_spread_eur_kwh=0.05, uncertainty=frozenset())
def dec(plan, now, **kw):
base = dict(peak_guard_active=False, charge_headroom_w=5000.0, soc=50.0,
min_spread_eur_kwh=0.10, uncertainty=frozenset(),
@@ -115,9 +125,9 @@ for mins, q in want_q.items():
and abs(d.price_now_eur_kwh - BUY[q]) < 1e-9)
check("before the change the same plan at 03:20 aimed at 03:00 (the "
"verdict without `now`) and decide() did nothing - the defect, shown",
BUY_PLAN.arbitrage(**ARGS).cheapest.start == H3
BUY_PLAN.arbitrage(now=LONG_AGO, **ARGS).cheapest.start == H3
and arbitrage._act(BUY_PLAN, H3 + timedelta(minutes=20),
BUY_PLAN.arbitrage(**ARGS), 50.0, 2500.0, 2500.0,
BUY_PLAN.arbitrage(now=LONG_AGO, **ARGS), 50.0, 2500.0, 2500.0,
10.0, 90.0, 5000.0).action is None)
# A cheapest period still AHEAD stays the target from every probe.
@@ -186,7 +196,7 @@ same = True
for m in (0, 5, 20, 35, 59):
now = H3 + timedelta(minutes=m)
a = HR_PLAN.arbitrage(now=now, **ARGS)
b = HR_PLAN.arbitrage(**ARGS)
b = HR_PLAN.arbitrage(now=LONG_AGO, **ARGS)
same = same and a.cheapest is b.cheapest and a.dearest is b.dearest \
and a.spread_eur_kwh == b.spread_eur_kwh
check("an hourly point that is still current stays in: at 03:00, :05, :20, "
@@ -210,7 +220,7 @@ for h in range(0, 24):
now = D0 + timedelta(hours=h - 2, minutes=m) # from 22:00 UTC the day before
plan = static_plan(OPTS, now, now + timedelta(hours=48), now=now)
a = plan.arbitrage(now=now, **ARGS)
b = plan.arbitrage(**ARGS)
b = plan.arbitrage(now=LONG_AGO, **ARGS)
n_checked += 1
ok = ok and a.kind == b.kind == "allowed" and a.cheapest is b.cheapest \
and a.dearest is b.dearest and a.spread_eur_kwh == b.spread_eur_kwh
@@ -226,7 +236,7 @@ GATE_PLAN = qplan(GATE)
now = H3 + timedelta(minutes=20)
check("the fixture: with the ended 03:00 period in view the verdict is "
"allowed; from 03:20 on the rest of the series is flat",
GATE_PLAN.arbitrage(**ARGS).kind == "allowed"
GATE_PLAN.arbitrage(now=LONG_AGO, **ARGS).kind == "allowed"
and GATE_PLAN.arbitrage(now=now, **ARGS).kind == "refused"
and "flat" in GATE_PLAN.arbitrage(now=now, **ARGS).reason)
@@ -306,6 +316,22 @@ check("...and so do the full-pack and empty-pack sentences ('buy period', "
"'sell period')",
"the buy period is here" in _hi.reason
and "the sell period is here" in _lo.reason)
import inspect # noqa: E402
_par = inspect.signature(TariffPlan.arbitrage).parameters
check("`now` is required: keyword-only with no default, like the three "
"arguments beside it, so a caller that forgets it fails loudly instead "
"of getting the elapsed periods back",
_par["now"].default is inspect.Parameter.empty
and _par["now"].kind is inspect.Parameter.KEYWORD_ONLY
and all(p.default is inspect.Parameter.empty
for n, p in _par.items() if n != "self"))
try:
BUY_PLAN.arbitrage(**ARGS_NO_NOW)
_forgot = False
except TypeError:
_forgot = True
check("...and calling without it raises TypeError", _forgot)
try:
BUY_PLAN.arbitrage(now=datetime(2026, 10, 20, 3, 20), **ARGS)
_naive = False
@@ -314,6 +340,88 @@ except TypeError as e:
check("a naive `now` is refused: it is ambiguous for one hour every October",
_naive)
# --------------------------------------------------------------------------- #
print("\n6. ties: the first extreme still ahead")
def acts(plan, instants, ago=False, args=ARGS):
out = []
for t in instants:
v = plan.arbitrage(now=LONG_AGO if ago else t, **args)
d = arbitrage._act(plan, t, v, 50.0, 2500.0, 2500.0, 10.0, 90.0,
5000.0) if v.kind == "allowed" else None
out.append(None if d is None else d.action)
return out
# Hour-constant 15-minute data: the cheap hour is four equal quarters.
HC = [round(0.40 - 0.0001 * i, 4) for i in range(192)] # falling, no ties
for q in range(4, 8):
HC[q] = 0.05 # 03:00-04:00 four equal quarters
HC[68:72] = [0.50] * 4 # 19:00-20:00 four equal quarters
HC_PLAN = qplan(HC)
probe = [H3 + timedelta(minutes=m) for m in (5, 20, 35, 50, 65)]
old = acts(HC_PLAN, probe, ago=True)
new = acts(HC_PLAN, probe)
check(f"before: four equal cheap quarters, the verdict is the first of them, "
f"so the trade acts in one quarter ({old})",
old == [CHARGE, None, None, None, None])
check(f"after: the first one still ahead, so it acts in all four and stops "
f"with the hour ({new}) - four acting periods for the same hour, one "
f"block either way",
new == [CHARGE, CHARGE, CHARGE, CHARGE, None])
changes = lambda a: sum(1 for x, y in zip(a, a[1:]) if x != y) # noqa: E731
check("...the action changes the same number of times (one start, one stop)",
changes(old) == changes(new) == 1)
# A real 15-minute day, as production runs it: refresh every 30 min from the
# floored hour, the verdict at 7 min into every period.
REAL = os.path.join(HERE, "scenarios",
"energy-charts-be-price-2026-09-09_2026-09-10-twoday.json")
_d = json.load(open(REAL))
_pts = [PricePoint(brussels(datetime.fromtimestamp(t, UTC)), p / 1000.0, False)
for t, p in zip(_d["unix_seconds"], _d["price"])]
_feed = PriceRead.series(_pts, PriceProduct.DAY_AHEAD, "generic_dayahead",
_pts[0].start)
_day0 = _pts[0].start.astimezone(UTC)
def real_day(ago):
out = []
for q in range(96):
t = _day0 + timedelta(minutes=15 * q + 7)
ref = t.replace(minute=0 if t.minute < 30 else 30, second=0,
microsecond=0)
plan = TariffPlan(fill_from_yesterday(
_feed, yesterday=PriceRead.no_data("none"), since=ref,
until=ref + timedelta(hours=36), stamp=ref), None, "provider")
v = plan.arbitrage(now=LONG_AGO if ago else t, **ARGS5)
out.append(arbitrage._act(plan, t, v, 50.0, 2500.0, 2500.0, 10.0,
90.0, 5000.0).action
if v.kind == "allowed" else None)
return out
r_old, r_new = real_day(True), real_day(False)
n_old = sum(1 for a in r_old if a)
n_new = sum(1 for a in r_new if a)
print(f" real 15-minute day 2026-09-09: acting periods {n_old} -> {n_new}, "
f"action changes {changes(r_old)} -> {changes(r_new)}")
check("a real 15-minute day (energy-charts, 2026-09-09) does not switch more "
"often: the action changes no more often than before",
changes(r_new) <= changes(r_old))
check("...and every period the old verdict acted in is still acted in, with "
"the quarters it had lost to the elapsed-period tie added",
all(n is not None for o, n in zip(r_old, r_new) if o) and n_new >= n_old)
# Alternating hourly prices (the flapping day): ties everywhere, so the verdict
# moves on every hour. At most once an hour, never once a tick.
FL = [0.20 + (0.101 if i % 2 else 0.0) for i in range(24)] * 2
FL_PLAN = qplan(FL, minutes=60)
ticks = [acts(FL_PLAN, [D0 + timedelta(seconds=30 * k)], args=ARGS5)[0]
for k in range(24 * 120)]
check(f"alternating hourly prices: {changes(ticks)} action changes over "
f"2880 ticks - at most one an hour, not one a tick",
changes(ticks) <= 24 and CHARGE in ticks and DISCHARGE in ticks)
print()
if fails:
print(f"{len(fails)} FAILED: " + "; ".join(fails))
+14 -9
View File
@@ -87,6 +87,11 @@ from app.static_tariff import Arbitrage, TariffPlan
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 = []
@@ -905,7 +910,7 @@ def min_accepted_spread(product, sources, base, provider="generic_dayahead"):
lo, hi = 0.0, 4.0
for _ in range(60):
mid = (lo + hi) / 2
v = plan_with_spread(mid, product, provider).arbitrage(
v = plan_with_spread(mid, product, provider).arbitrage(now=LONG_AGO,
peak_guard_active=False, min_spread_eur_kwh=base,
uncertainty=sources)
if v.kind == "allowed":
@@ -1013,7 +1018,7 @@ check(f"the fixture is non-degenerate: the whole-series spread "
f"{_all_spread:.6f} and the firm-only spread {_firm_spread:.6f} are "
f"different numbers, so a verdict can be traced to one of them",
not near(_all_spread, _firm_spread, 1e-6))
_v = derived(lambda: _mixed.arbitrage(peak_guard_active=False,
_v = derived(lambda: _mixed.arbitrage(now=LONG_AGO, peak_guard_active=False,
min_spread_eur_kwh=0.0,
uncertainty=frozenset()))
check("a PART-published series still allows arbitrage - §8.3 forbids acting on "
@@ -1032,7 +1037,7 @@ check("...and it reports the threshold it cleared and why, rather than leaving "
_all_prov = TariffPlan(PriceRead.series(_cheap_stale, PriceProduct.DAY_AHEAD,
"generic_dayahead", D0), None,
"fallback")
_vp = derived(lambda: _all_prov.arbitrage(peak_guard_active=False,
_vp = derived(lambda: _all_prov.arbitrage(now=LONG_AGO, peak_guard_active=False,
min_spread_eur_kwh=0.0,
uncertainty=frozenset()))
check("a WHOLLY provisional series - the §11.4 fallback, or a day before any "
@@ -1046,23 +1051,23 @@ _one_firm = TariffPlan(
"generic_dayahead", D0), None, "provider")
check("one firm hour is not enough either: a buy hour and a sell hour must "
"BOTH be published before a cycle can be committed",
_one_firm.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0,
_one_firm.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0,
uncertainty=frozenset()).kind == "refused")
check("§10.1 still holds above all of it: the peak guard refuses the same "
"series that is otherwise allowed",
_mixed.arbitrage(peak_guard_active=True, min_spread_eur_kwh=0.0,
_mixed.arbitrage(now=LONG_AGO, peak_guard_active=True, min_spread_eur_kwh=0.0,
uncertainty=frozenset()).kind == "refused")
check("the three questions cannot be skipped: all of peak_guard_active, "
"min_spread_eur_kwh and uncertainty are required and keyword-only",
raises(lambda: _mixed.arbitrage(peak_guard_active=False), TypeError)
and raises(lambda: _mixed.arbitrage(peak_guard_active=False,
raises(lambda: _mixed.arbitrage(now=LONG_AGO, peak_guard_active=False), TypeError)
and raises(lambda: _mixed.arbitrage(now=LONG_AGO, peak_guard_active=False,
min_spread_eur_kwh=0.0), TypeError)
and raises(lambda: _mixed.arbitrage(False, 0.0, frozenset()), TypeError))
check("...nor answered with a truthy 'unknown' or a negative threshold",
raises(lambda: _mixed.arbitrage(peak_guard_active=None,
raises(lambda: _mixed.arbitrage(now=LONG_AGO, peak_guard_active=None,
min_spread_eur_kwh=0.0,
uncertainty=frozenset()), TypeError)
and raises(lambda: _mixed.arbitrage(peak_guard_active=False,
and raises(lambda: _mixed.arbitrage(now=LONG_AGO, peak_guard_active=False,
min_spread_eur_kwh=-0.1,
uncertainty=frozenset()), TypeError))
+10 -5
View File
@@ -118,6 +118,11 @@ from app.static_tariff import resolve_tariff
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 = []
_tmpdirs = []
@@ -699,8 +704,8 @@ check(f"the short answer stays a SERIES of what exists - {_pre.read.kind}, "
# --- the trade set, which is where the whole thing lands ------------------- #
_A9 = dict(peak_guard_active=False, min_spread_eur_kwh=0.0,
uncertainty=frozenset())
_v2 = _pub.arbitrage(**_A9)
_v1 = _pre.arbitrage(**_A9)
_v2 = _pub.arbitrage(now=LONG_AGO, **_A9)
_v1 = _pre.arbitrage(now=LONG_AGO, **_A9)
check("both plans still ALLOW a trade, so the two verdicts below are compared "
"against each other and not against a refusal",
_v2.kind == "allowed" and _v1.kind == "allowed")
@@ -744,8 +749,8 @@ print("2. AC2 - the spread stopped being taken over hours that have gone")
_A = dict(peak_guard_active=False, min_spread_eur_kwh=0.0,
uncertainty=frozenset())
_v_before = _before.arbitrage(**_A)
_v_after = _after.arbitrage(**_A)
_v_before = _before.arbitrage(now=LONG_AGO, **_A)
_v_after = _after.arbitrage(now=LONG_AGO, **_A)
check("both plans still ALLOW a trade, so this section compares two verdicts "
"rather than a verdict and a refusal",
_v_before.kind == "allowed" and _v_after.kind == "allowed")
@@ -843,7 +848,7 @@ check("...and no two of them share a whole hour by accident: the fixture puts "
_q_spread_want = round(max(p.eur_per_kwh for p in _q_win)
- min(p.eur_per_kwh for p in _q_win), 6)
_qv = _q_after.arbitrage(**_A)
_qv = _q_after.arbitrage(now=LONG_AGO, **_A)
_q_spread_got = _qv.match(allowed=lambda v: v.spread_eur_kwh,
refused=lambda _v: None)
check(f"...and the spread is the fixture's own max - min over that window "
+33 -9
View File
@@ -599,26 +599,50 @@ print("\n5. modes off: byte-identical to release/1.0 e3e4ba6")
# Printed on release/1.0 e3e4ba6 (2026-10-13), goodwe-addon-checks:1, with this
# branch's harness (same API). n1 and n2 are also 5a4cdc2's; the lag ones moved
# with SAFETY-90/91 (n2_lag now equals n2: the next-quarter grading removed the
# edge tick's blindness at N=2). PRICE-02 (2026-10-22): the digest is
# `ident.trade_digest()`, which hides two things in the trade sentence: the
# "above the cheapest" figure (the plan now drops price periods that have
# ended) and "hour" -> "price period". Re-measured with it on e3e4ba6, on
# release/1.0 90bc901 and on this tree: the same four digests.
# edge tick's blindness at N=2).
#
# PRICE-02 (2026-10-22) changed two things in the trade sentence and nothing
# else (see test_modes_p3.py part 1): the "N above the cheapest" figure of a
# sale and "hour" -> "price period". Three pins, as there:
# AT_BASE_OLD the digests printed on e3e4ba6, sentence included (no longer
# matched: the mask is needed);
# AT_BASE the same rows through `ident.trade_digest()`, equal to what
# e3e4ba6 gives through it;
# AT_NOW this tree's own digests with nothing hidden.
AT_BASE_OLD = {
"n1": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c",
"n2": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
"n1_lag": "86ef0f5ad8da1498ff7dcf59262a177bef847a0b201ce804e947c8eab5f07190",
"n2_lag": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
}
AT_BASE = {
"n1": "33886ef03f364852883f0370230e6e04823fbb46a4704f88ca75bf9082d98c01",
"n2": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
"n1_lag": "f6c67b351bd732fb69e62cafbfdb2bcff6032f5d8845836659d096b8c677d3c0",
"n2_lag": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
}
AT_NOW = {
"n1": "939b3d47bbd64fa50e3d70538f092fb98e5c4bd5ead85a7fc696a1dbb514039e",
"n2": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d",
"n1_lag": "78aac892975be81a7ceac0c000ed441a731bd4f42ff9f729996cbee53d6b7f02",
"n2_lag": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d",
}
_cmd_digest = ident.trade_digest
_got = {k: _cmd_digest(ident.run(**ident.SCENARIOS[k])[0])
for k in AT_BASE}
_rows5 = {k: ident.run(**ident.SCENARIOS[k])[0] for k in AT_BASE}
_got = {k: _cmd_digest(v) for k, v in _rows5.items()}
_full5 = {k: ident.digest(v) for k, v in _rows5.items()}
for _k in AT_BASE:
print(f" {_k} {'same' if _got[_k] == AT_BASE[_k] else 'DIFFERS'} "
f"{_got[_k][:16]}")
check("with battery modes off every command, integrator, allocation and "
"trade sentence equals e3e4ba6's, also when every quarter boundary's "
"cycle still sees the old P1 block", _got == AT_BASE)
"trade action equals e3e4ba6's (the sentence masked as above), also when "
"every quarter boundary's cycle still sees the old P1 block",
_got == AT_BASE)
check("...the mask is needed and nothing more: unmasked, the digests differ "
"from e3e4ba6's printed ones, and equal this tree's own pins, so a "
"later change to any sentence or figure fails here",
all(_full5[k] != AT_BASE_OLD[k] for k in AT_BASE_OLD)
and _full5 == AT_NOW)
_real_project = tariff.PeakTracker.project
tariff.PeakTracker.project = staticmethod(
+1 -1
View File
@@ -1178,7 +1178,7 @@ async def _modes_plan():
c = W.ctl
plan = c.fresh_tariff_plan(t)
verdict = plan.arbitrage(
peak_guard_active=False, min_spread_eur_kwh=0.05,
now=t, peak_guard_active=False, min_spread_eur_kwh=0.05,
uncertainty=frozenset())
return verdict.match(allowed=lambda v: ("allowed", v.spread_eur_kwh),
refused=lambda v: ("refused", v.reason))
+29 -24
View File
@@ -82,6 +82,11 @@ from app.validate import validate_startup_config
HERE = Path(__file__).resolve().parent
UTC = timezone.utc
# 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 = []
@@ -333,8 +338,8 @@ check(f"...while the two-period spread is the day/night difference "
f"{_spread_two}, re-derived here as {DAY_P - NIGHT_P:.4f}",
near(_spread_two, DAY_P - NIGHT_P, 1e-9))
_v_fixed = p_fixed.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset())
_v_two = p_two.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset())
_v_fixed = p_fixed.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset())
_v_two = p_two.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset())
check("a fully-fixed tariff REFUSES arbitrage", _v_fixed.kind == "refused")
check("...for the true reason - the series is flat, so there is nothing to buy "
"low and sell high, not because a FIXED enum was matched on",
@@ -354,7 +359,7 @@ _flat_dayahead = TariffPlan(
None, "provider")
check("a DAY_AHEAD series that happens to be flat is refused too, so the rule "
"is arithmetic and not a product label",
_flat_dayahead.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused")
_flat_dayahead.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused")
# The numbers an optimiser needs exist ONLY on the permitting variant.
check("the allowed verdict carries the spread, the cheapest hour and the "
@@ -379,16 +384,16 @@ check("...and there is no falsy variant, so `if verdict:` cannot look like a "
# §10.1 priority 2 over priority 3, expressed so OPT-04 cannot skip it.
check("peak shaving outranks arbitrage: the SAME allowed plan is refused when "
"the peak guard is holding",
p_two.arbitrage(peak_guard_active=True, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused")
p_two.arbitrage(now=LONG_AGO, peak_guard_active=True, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused")
check("...and the reason names the ordering rather than the tariff",
"priority 2" in p_two.arbitrage(peak_guard_active=True, min_spread_eur_kwh=0.0, uncertainty=frozenset()).reason)
"priority 2" in p_two.arbitrage(now=LONG_AGO, peak_guard_active=True, min_spread_eur_kwh=0.0, uncertainty=frozenset()).reason)
check("...and the question cannot be skipped: peak_guard_active is required "
"and keyword-only, so a spread is not obtainable without answering it",
raises(lambda: p_two.arbitrage(), TypeError)
and raises(lambda: p_two.arbitrage(False), TypeError))
check("...nor answered with a truthy 'unknown'",
raises(lambda: p_two.arbitrage(peak_guard_active=None), TypeError)
and raises(lambda: p_two.arbitrage(peak_guard_active="no"), TypeError))
raises(lambda: p_two.arbitrage(now=LONG_AGO, peak_guard_active=None), TypeError)
and raises(lambda: p_two.arbitrage(now=LONG_AGO, peak_guard_active="no"), TypeError))
# --------------------------------------------------------------------------- #
@@ -524,7 +529,7 @@ check("...and its underlying read has no .points at all, so `min(read.points)` "
"cannot run against it",
raises(lambda: _none.read.points, Absence))
check("...and arbitrage is refused, per §11.4",
_none.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused"
_none.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused"
and arbitrage_allowed(_none.read) is False)
check("...and static_status() says so in words rather than reporting a price",
static_status(opts(None), THU) == (False, "No fixed tariff set"))
@@ -776,12 +781,12 @@ check("...marked PROVISIONAL, which is the one flag that differs",
_fb.provisional and plan_is_provisional(_fb.read))
check("...and arbitrage is refused on it, per §11.4, even though a full series "
"exists and has a real spread",
_fb.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused"
_fb.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused"
and near(_fb.spread_eur_kwh(), DAY_P - NIGHT_P))
check("...while the SAME profile as the configured product allows it - so the "
"refusal is about provisionality and not about the profile",
static_plan(opts(TWO_PERIOD), THU, THU + timedelta(days=1)
).arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "allowed")
).arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "allowed")
check("...and the provisional plan has no cheap window either: §8.3 forbids an "
"irreversible action on a provisional price, and a grid top-up is one",
not any(_fb.cheap_now(at_local(THU, h)) for h in range(24)))
@@ -821,8 +826,8 @@ check("a live feed is used in preference to the static profile",
and _live_plan.product is PriceProduct.DAY_AHEAD)
check("...and arbitrage is allowed on it, with the spread derived from the "
"feed rather than from the profile",
_live_plan.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "allowed"
and near(_live_plan.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).spread_eur_kwh,
_live_plan.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "allowed"
and near(_live_plan.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).spread_eur_kwh,
0.023, 1e-9))
@@ -1036,7 +1041,7 @@ check(f"each tiny-spread probe really carries the spread it is named for, and "
all(near(p.spread_eur_kwh(), d, 1e-12)
and len([q for q in p.read.points if not q.provisional]) == 2
for d, p in _tiny9))
_tinyv9 = [(d, rung9(p.arbitrage(**_A9))) for d, p in _tiny9]
_tinyv9 = [(d, rung9(p.arbitrage(now=LONG_AGO, **_A9))) for d, p in _tiny9]
check(f"TEST-16 AC1: a spread that is SMALL BUT REAL is ALLOWED at "
f"min_spread_eur_kwh=0.0 - down to 0.000001 EUR/kWh, so the flat rung is "
f"`spread == 0.0` and not `spread <= epsilon` for any epsilon "
@@ -1045,7 +1050,7 @@ check(f"TEST-16 AC1: a spread that is SMALL BUT REAL is ALLOWED at "
check("...and the exactly flat series on the OTHER side of that boundary is "
"still refused, and refused BY THE FLAT RUNG - without this the sweep "
"above would also pass for a rung that had been deleted outright",
rung9(plan9([0.20000, 0.20000]).arbitrage(**_A9)) == "flat")
rung9(plan9([0.20000, 0.20000]).arbitrage(now=LONG_AGO, **_A9)) == "flat")
_q9 = plan9([0.20000, 0.2000005])
check(f"...and a difference below the rung's own 6-decimal rounding is flat BY "
f"CONSTRUCTION and says so: the raw difference is "
@@ -1056,10 +1061,10 @@ check(f"...and a difference below the rung's own 6-decimal rounding is flat BY "
f"to bisect further",
not near(_q9.spread_eur_kwh(), 0.0, 1e-9)
and near(round(_q9.spread_eur_kwh(), 6), 0.0, 1e-12)
and rung9(_q9.arbitrage(**_A9)) == "flat")
and rung9(_q9.arbitrage(now=LONG_AGO, **_A9)) == "flat")
# --- and what D's survival actually costs: the sentence, not the verdict -----
_lie9 = plan9([0.20000, 0.20050]).arbitrage(**_A9)
_lie9 = plan9([0.20000, 0.20050]).arbitrage(now=LONG_AGO, **_A9)
check(f"TEST-16 AC4: the 0.0005 verdict carries the real spread rather than a "
f"flatness claim about a tariff that is not flat - the false sentence is "
f"what a loosened rung ships to the operator while the outcome at a "
@@ -1085,19 +1090,19 @@ check(f"the one-firm probe is well formed: exactly ONE firm hour out of three, "
check("TEST-16 AC2: one firm hour is refused BY §8.3's FIRM-PAIR RUNG, named "
"in the reason - not by the flat rung that would also have refused it. "
"This is what takes `len(firm) < 2` -> `< 1` red on its own",
rung9(_one9.arbitrage(**_A9)) == "firm-pair")
rung9(_one9.arbitrage(now=LONG_AGO, **_A9)) == "firm-pair")
check("...and the sentence is §8.3's in full, so the operator is told the "
"series is half-published rather than told it is fixed-price",
"§8.3" in _one9.arbitrage(**_A9).reason
and "only 1 of 3" in _one9.arbitrage(**_A9).reason
and "flat" not in _one9.arbitrage(**_A9).reason)
"§8.3" in _one9.arbitrage(now=LONG_AGO, **_A9).reason
and "only 1 of 3" in _one9.arbitrage(now=LONG_AGO, **_A9).reason
and "flat" not in _one9.arbitrage(now=LONG_AGO, **_A9).reason)
# The rung pinned from ABOVE as well. Without this, `len(firm) < 3` - the same
# +0 B edit in the other direction - would pass everything above.
_two9 = plan9([0.20, 0.40, 0.10], [False, False, True])
check("...and TWO firm hours are ALLOWED on the identical series shape, so the "
"rung is pinned from above too and `< 3` is red as well as `< 1`",
rung9(_two9.arbitrage(**_A9)) == "allowed"
and near(_two9.arbitrage(**_A9).spread_eur_kwh, 0.20))
rung9(_two9.arbitrage(now=LONG_AGO, **_A9)) == "allowed"
and near(_two9.arbitrage(now=LONG_AGO, **_A9).spread_eur_kwh, 0.20))
# ...and from below, where the failure is not a wrong sentence but a crash:
# `max()` over an empty `firm` raises ValueError, so `< 0` does not degrade the
# reason, it takes the verdict away entirely.
@@ -1105,7 +1110,7 @@ _zero9 = plan9([0.20, 0.40, 0.10], [True, True, True])
check("...and a FULLY provisional series is refused by the same rung and does "
"not raise - `max()` over an empty firm set is a ValueError, so this row "
"is what makes `len(firm) < 0` red",
rung9(derived(lambda: _zero9.arbitrage(**_A9))) == "firm-pair")
rung9(derived(lambda: _zero9.arbitrage(now=LONG_AGO, **_A9))) == "firm-pair")
# --- AC3: the two rungs do not mask each other ------------------------------
# ⚠️ THE POINT OF THE TICKET, AND IT IS A PROPERTY OF THE TABLE, NOT OF A ROW.
@@ -1130,7 +1135,7 @@ _TABLE9 = (
("two firm hours of three", _two9, "allowed"),
("no firm hours at all", _zero9, "firm-pair"),
)
_got9 = [(name, want, rung9(derived(lambda p=pl: p.arbitrage(**_A9))))
_got9 = [(name, want, rung9(derived(lambda p=pl: p.arbitrage(now=LONG_AGO, **_A9))))
for name, pl, want in _TABLE9]
check(f"TEST-16 AC3: each stimulus is answered by the rung that OWNS it, and "
f"by no other - the flat rung is asked a question the firm-pair rung "
+4 -1
View File
@@ -123,7 +123,10 @@ def _trade_words(text):
plan now drops price periods that have ended, so a sale's "above the
cheapest" figure is a different number once the first cheap period is
over, and the sentences say "price period" where they said "hour". Both
maps are applied to both sides, so only those two things are hidden."""
maps are applied to both sides, so only those two things are hidden.
ONLY FOR COMPARING WITH A TREE OLDER THAN PRICE-02. A pin of this tree
itself uses `digest()`, with nothing hidden."""
text = text.replace("this hour is neither the buy hour nor the sell hour",
"now is not the cheapest or the dearest price period")
text = re.sub(r"[0-9]+\.[0-9]+ above the cheapest",