PRICE-02: the trading verdict skips price periods that have ended #206
@@ -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.
|
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
|
- An hourly point that is still current stays in, so hourly feeds and the
|
||||||
static day/night plan give the same verdict as before.
|
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,
|
- 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
|
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
|
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
|
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
|
`test_modes_p3.py` and `test_price_run_fixes.py` hide just that figure and the
|
||||||
word change (`opt11p3_identity.trade_digest()`).
|
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.
|
||||||
|
|||||||
@@ -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)}
|
"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
|
"""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()`:
|
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
|
`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)))
|
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:
|
"""Which of `TariffPlan.arbitrage()`'s refusals fired, in its own order:
|
||||||
the peak guard, no prices, under two published points, then the gap."""
|
the peak guard, no prices, under two published points, then the gap."""
|
||||||
if guard:
|
if guard:
|
||||||
@@ -292,9 +292,8 @@ def refusal_key(plan, guard, now=None):
|
|||||||
if not plan.has_prices:
|
if not plan.has_prices:
|
||||||
return "no_prices"
|
return "no_prices"
|
||||||
pts = _points(plan)
|
pts = _points(plan)
|
||||||
if now is not None:
|
ahead = {id(p) for _s, end, p in _cover_table(pts) if end > now}
|
||||||
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]
|
||||||
pts = [p for p in pts if id(p) in ahead]
|
|
||||||
if sum(1 for p in pts if not p.provisional) < 2:
|
if sum(1 for p in pts if not p.provisional) < 2:
|
||||||
return "not_firm"
|
return "not_firm"
|
||||||
return "not_worth"
|
return "not_worth"
|
||||||
|
|||||||
@@ -986,20 +986,25 @@ class TariffPlan:
|
|||||||
return here.eur_per_kwh < (sum(p.eur_per_kwh for p in firm) / len(firm))
|
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,
|
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.
|
"""May OPT-04 trade, and on what numbers. See the module docstring.
|
||||||
|
|
||||||
PRICE-02. `now` (tz-aware) drops the points whose price period has
|
PRICE-02. `now` (tz-aware) drops the points whose price period has
|
||||||
ended (`end <= now`, the span `price._cover_table()` gives it), so the
|
ended (`end <= now`, the span `price._cover_table()` gives it), so the
|
||||||
cheapest and dearest point are always ones that can still be traded.
|
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
|
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
|
built at 03:00 is still used at 03:20. An hourly point that is still
|
||||||
`now`. An hourly point that is still current stays in. Left out
|
current stays in.
|
||||||
(None), the whole series counts, as before.
|
|
||||||
|
|
||||||
⚠️ 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:
|
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,
|
`peak_guard_active` §9.1/§10.1 put peak shaving above arbitrage,
|
||||||
and this is how that ordering survives a
|
and this is how that ordering survives a
|
||||||
strategy that has not been written yet: the
|
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"{sorted(map(repr, caller_sources))} - a string widens by "
|
||||||
f"nothing while reporting that it widened.")
|
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:
|
if peak_guard_active:
|
||||||
return Arbitrage.refuse(
|
return Arbitrage.refuse(
|
||||||
"the capacity-tariff peak guard is holding, and §10.1 puts peak "
|
"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
|
# 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.
|
# is the previous behaviour arrived at by a truer route.
|
||||||
pts = self.read.points
|
pts = self.read.points
|
||||||
if now is not None:
|
ahead = {id(p) for _s, end, p in _cover_table(pts) if end > now}
|
||||||
if now.tzinfo is None or now.utcoffset() is None:
|
pts = tuple(p for p in pts if id(p) in ahead)
|
||||||
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)
|
|
||||||
firm = tuple(p for p in pts if not p.provisional)
|
firm = tuple(p for p in pts if not p.provisional)
|
||||||
if len(firm) < 2:
|
if len(firm) < 2:
|
||||||
# ⚠️ POINTS, AND THE HOUR FIGURE BESIDE IT RATHER THAN INSTEAD OF
|
# ⚠️ POINTS, AND THE HOUR FIGURE BESIDE IT RATHER THAN INSTEAD OF
|
||||||
|
|||||||
@@ -93,11 +93,9 @@ from app.control import Tuning, compute
|
|||||||
from app.p1 import QUARTER_S, QuarterAverager, make_sample, split_signed
|
from app.p1 import QUARTER_S, QuarterAverager, make_sample, split_signed
|
||||||
from app.tariff import PeakTracker
|
from app.tariff import PeakTracker
|
||||||
from app.arbitrage import (
|
from app.arbitrage import (
|
||||||
CHARGE, DISCHARGE, ArbitrageDecision, claim, enabled, soc_bounds,
|
CHARGE, DISCHARGE, ArbitrageDecision, claim, decide, enabled, soc_bounds,
|
||||||
threshold_eur_kwh,
|
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.
|
# SAFETY-47 section 3d. The shipped shadow-mode row, not a transcription of it.
|
||||||
from app import shadow as _shadow
|
from app import shadow as _shadow
|
||||||
from app.price import (
|
from app.price import (
|
||||||
@@ -111,30 +109,14 @@ from app.validate import validate_startup_config
|
|||||||
|
|
||||||
UTC = timezone.utc
|
UTC = timezone.utc
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
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 = []
|
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):
|
def check(name, cond):
|
||||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||||
if not cond:
|
if not cond:
|
||||||
@@ -217,13 +199,22 @@ NO_WEATHER = frozenset({Uncertainty.NO_WEATHER})
|
|||||||
|
|
||||||
|
|
||||||
def plan_of(prices, *, product=PriceProduct.DAY_AHEAD, start=D0,
|
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`.
|
"""A TariffPlan over consecutive hourly prices starting at `start`.
|
||||||
|
|
||||||
`provisional` may be a single bool or a per-hour sequence.
|
`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)
|
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)),
|
pts = [PricePoint(start=brussels(start + timedelta(hours=i)),
|
||||||
eur_per_kwh=round(float(p), 6), provisional=f)
|
eur_per_kwh=round(float(p), 6), provisional=f)
|
||||||
for i, (p, f) in enumerate(zip(prices, flags))]
|
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",
|
f"{_st_spread!r} EUR/kWh - the number the product decision is about",
|
||||||
near(_st_spread, 0.08))
|
near(_st_spread, 0.08))
|
||||||
_shipped = PRICE_OPTION_DEFAULTS["cycle_cost_threshold_eur"]
|
_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,
|
peak_guard_active=False, min_spread_eur_kwh=_shipped,
|
||||||
uncertainty=NO_WEATHER))
|
uncertainty=NO_WEATHER))
|
||||||
check(f"...and at the SHIPPED threshold of {_shipped} EUR/kWh that site does "
|
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 "
|
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 "
|
"spread, so the refusal above is about the number and not about static "
|
||||||
"tariffs being unsupported",
|
"tariffs being unsupported",
|
||||||
derived(lambda: _st_plan.arbitrage(
|
derived(lambda: _st_plan.arbitrage(now=LONG_AGO,
|
||||||
peak_guard_active=False,
|
peak_guard_active=False,
|
||||||
min_spread_eur_kwh=_st_spread / widen_factor(NO_WEATHER) - 1e-4,
|
min_spread_eur_kwh=_st_spread / widen_factor(NO_WEATHER) - 1e-4,
|
||||||
uncertainty=NO_WEATHER).kind) == "allowed")
|
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",
|
"caller cannot reach a spread without having answered it",
|
||||||
all(p.default is inspect.Parameter.empty
|
all(p.default is inspect.Parameter.empty
|
||||||
for n, p in inspect.signature(TariffPlan.arbitrage).parameters.items()
|
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 "
|
check("...nor does arbitrage.decide(): every argument of it is keyword-only "
|
||||||
"with no default, for the same reason",
|
"with no default, for the same reason",
|
||||||
all(p.default is inspect.Parameter.empty
|
all(p.default is inspect.Parameter.empty
|
||||||
and (p.kind is inspect.Parameter.KEYWORD_ONLY or n in ("plan", "now"))
|
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
|
# 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
|
# 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
|
# standing if a future caller ever hands `_act` a plan the verdict did not come
|
||||||
# from.
|
# from.
|
||||||
_probe = _arb._point_at(PLAN, hour(CHEAP_H))
|
_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())
|
uncertainty=frozenset())
|
||||||
check("the point matched for the buy hour IS the verdict's own cheapest point, "
|
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 "
|
"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
|
# threshold repeatedly, hour to hour, which is the shape that makes a
|
||||||
# per-tick price-vs-threshold comparison flap.
|
# per-tick price-vs-threshold comparison flap.
|
||||||
FLAP = [0.20 + (0.101 if i % 2 else 0.0) for i in range(24)]
|
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)
|
_flap_plan = plan_of(FLAP)
|
||||||
check("the flapping fixture really does straddle the threshold hour by hour - "
|
check("the flapping fixture really does straddle the threshold hour by hour - "
|
||||||
"every neighbouring pair differs by more than it",
|
"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"every hour, the strategy changes what it is doing {_transitions} "
|
||||||
f"times - not once per tick, because the decision is 'which HOUR is "
|
f"times - not once per tick, because the decision is 'which HOUR is "
|
||||||
f"this', and an hour is 3600 s wide",
|
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 "
|
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",
|
"acted rather than on one that did nothing",
|
||||||
_transitions > 0 and CHARGE in _ticks and DISCHARGE in _ticks)
|
_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
|
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):
|
def replay(threshold, unc, *, soc0=50.0, floor=20.0, ceiling=95.0):
|
||||||
"""Run `decide()` over a synthetic year. Returns the ledger, nothing else."""
|
"""Run `decide()` over a synthetic year. Returns the ledger, nothing else."""
|
||||||
soc = soc0
|
soc = soc0
|
||||||
trades, holdings, bad = [], [], []
|
trades, holdings, bad = [], [], []
|
||||||
neg_sells = 0
|
neg_sells = 0
|
||||||
TRADE_HOURS.clear()
|
|
||||||
for n in range(365):
|
for n in range(365):
|
||||||
day0 = REPLAY_START + timedelta(days=n)
|
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)
|
plan = plan_of(prices, start=day0)
|
||||||
open_buy = None
|
open_buy = None
|
||||||
for h in range(24):
|
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)
|
ceiling=ceiling, start=day0)
|
||||||
if d.action is None:
|
if d.action is None:
|
||||||
continue
|
continue
|
||||||
TRADE_HOURS.append((n, h))
|
|
||||||
price = prices[h]
|
price = prices[h]
|
||||||
if d.action == CHARGE:
|
if d.action == CHARGE:
|
||||||
soc = min(ceiling, soc + STEP_PCT)
|
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 "
|
check("...and every trade carries the spread and the threshold it cleared, so "
|
||||||
"the ledger is auditable rather than a count",
|
"the ledger is auditable rather than a count",
|
||||||
_bad == [])
|
_bad == [])
|
||||||
# PRICE-02: the plan now rolls with the clock, so a day can hold several round
|
check("...and NOT every day traded, so the strategy is selective rather than "
|
||||||
# trips and every day may trade once. Selective is judged per hour instead.
|
"unconditional - a replay in which everything trades grades nothing",
|
||||||
check(f"...and the strategy acted in {len(TRADE_HOURS)} of {365 * 24} hours, a "
|
len(_trades) < 365)
|
||||||
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)
|
|
||||||
_losses = [(b, s) for b, s, _ in _trades if s - b < 0.05]
|
_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 "
|
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"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",
|
"a fresh observation",
|
||||||
isinstance(_t12_fb, TariffPlan) and _t12_fb.provisional is True
|
isinstance(_t12_fb, TariffPlan) and _t12_fb.provisional is True
|
||||||
and _t12_fb.firm_points() == ()
|
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")
|
uncertainty=NO_WEATHER).kind == "refused")
|
||||||
_t12_f.tariff_plan, _t12_f.tariff_at = _t12_good, datetime.now(UTC) - \
|
_t12_f.tariff_plan, _t12_f.tariff_at = _t12_good, datetime.now(UTC) - \
|
||||||
timedelta(seconds=1800)
|
timedelta(seconds=1800)
|
||||||
@@ -2820,7 +2819,7 @@ check(f"the shipped rule and this one give DIFFERENT points at both probes - "
|
|||||||
|
|
||||||
print()
|
print()
|
||||||
print("10b. the buy/sell identity test matches the actual extremum interval")
|
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)
|
uncertainty=NO_WEATHER)
|
||||||
check("the verdict's cheapest and dearest are the two QUARTERS, not the hours "
|
check("the verdict's cheapest and dearest are the two QUARTERS, not the hours "
|
||||||
"containing them - `static_tariff` reduces over every firm point and "
|
"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"{_mix_dear_i}, neither on a whole hour, so the identity test below is "
|
||||||
f"about the quarter and not about the hour",
|
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)
|
(_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)
|
uncertainty=NO_WEATHER)
|
||||||
_mix_buy_t = qprobe(_mix_cheap_i, gaps=MIX_GAPS)
|
_mix_buy_t = qprobe(_mix_cheap_i, gaps=MIX_GAPS)
|
||||||
_mix_sell_t = qprobe(_mix_dear_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]
|
is MIX_PTS[_mix_cheap_i]
|
||||||
and derived(lambda: _arb._point_at(MIX_PLAN, _mix_sell_t))
|
and derived(lambda: _arb._point_at(MIX_PLAN, _mix_sell_t))
|
||||||
is MIX_PTS[_mix_dear_i])
|
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 "
|
check("...so the mixed day CHARGES and DISCHARGES in its firm quarters, at "
|
||||||
"their own prices",
|
"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,
|
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||||
charge_headroom_w=HEADROOM_CLEAR,
|
charge_headroom_w=HEADROOM_CLEAR,
|
||||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||||
soc_ceiling_pct=95.0).action == CHARGE
|
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,
|
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||||
charge_headroom_w=HEADROOM_CLEAR,
|
charge_headroom_w=HEADROOM_CLEAR,
|
||||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||||
|
|||||||
@@ -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 "
|
check("...placed where the control cycle's own verdict puts them: the same "
|
||||||
"plan.arbitrage() call on the same plan",
|
"plan.arbitrage() call on the same plan",
|
||||||
c_arb.fresh_tariff_plan(NOW).arbitrage(
|
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=frozenset({__import__("app.price", fromlist=["x"])
|
||||||
.Uncertainty.NO_WEATHER}))
|
.Uncertainty.NO_WEATHER}))
|
||||||
.match(allowed=lambda v: (v.cheapest.start, v.dearest.start),
|
.match(allowed=lambda v: (v.cheapest.start, v.dearest.start),
|
||||||
@@ -717,7 +717,7 @@ def _dn(arb):
|
|||||||
|
|
||||||
_c_off, _p_off = _dn(False)
|
_c_off, _p_off = _dn(False)
|
||||||
_c_on, _p_on = _dn(True)
|
_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: "
|
check("UX-45: day/night tariff with the stored plan and price trading off: "
|
||||||
"24 bars, no cheap or dear mark",
|
"24 bars, no cheap or dear mark",
|
||||||
len(_p_off) == 24 and {b["kind"] for b in _p_off} == {"mid"})
|
len(_p_off) == 24 and {b["kind"] for b in _p_off} == {"mid"})
|
||||||
|
|||||||
@@ -67,27 +67,53 @@ A, B = "inverter-a", "inverter-b"
|
|||||||
# ============================================================================ #
|
# ============================================================================ #
|
||||||
print("1. modes off: byte-identical to 7a00f12 on the trade path")
|
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
|
# 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
|
# cycles each; the trade CHARGEs and DISCHARGEs.
|
||||||
# digest is `ident.trade_digest()`, which hides two things in the trade
|
#
|
||||||
# sentence: the "above the cheapest" figure (the plan now drops price periods
|
# PRICE-02 (2026-10-22) changed two things in the trade sentence and nothing
|
||||||
# that have ended, so it is another number once the first cheap period is
|
# else: the plan now drops price periods that have ended, so a sale's "N above
|
||||||
# over) and "hour" -> "price period". Re-measured with it on 7a00f12, on
|
# the cheapest" is another number once the first cheap period is over, and the
|
||||||
# release/1.0 90bc901 and on this tree: the same four digests.
|
# 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 = {
|
AT_7A00F12 = {
|
||||||
"n1": "33886ef03f364852883f0370230e6e04823fbb46a4704f88ca75bf9082d98c01",
|
"n1": "33886ef03f364852883f0370230e6e04823fbb46a4704f88ca75bf9082d98c01",
|
||||||
"n2": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
|
"n2": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
|
||||||
"n1_off": "33886ef03f364852883f0370230e6e04823fbb46a4704f88ca75bf9082d98c01",
|
"n1_off": "33886ef03f364852883f0370230e6e04823fbb46a4704f88ca75bf9082d98c01",
|
||||||
"n2_off": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
|
"n2_off": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
|
||||||
}
|
}
|
||||||
|
AT_NOW = {
|
||||||
|
"n1": "939b3d47bbd64fa50e3d70538f092fb98e5c4bd5ead85a7fc696a1dbb514039e",
|
||||||
|
"n2": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d",
|
||||||
|
"n1_off": "939b3d47bbd64fa50e3d70538f092fb98e5c4bd5ead85a7fc696a1dbb514039e",
|
||||||
|
"n2_off": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d",
|
||||||
|
}
|
||||||
_cmd_digest = ident.trade_digest
|
_cmd_digest = ident.trade_digest
|
||||||
_rows = {k: ident.run(**ident.SCENARIOS[k])[0] for k in AT_7A00F12}
|
_rows = {k: ident.run(**ident.SCENARIOS[k])[0] for k in AT_7A00F12}
|
||||||
_got = {k: _cmd_digest(v) for k, v in _rows.items()}
|
_got = {k: _cmd_digest(v) for k, v in _rows.items()}
|
||||||
for k in AT_7A00F12:
|
for k in AT_7A00F12:
|
||||||
print(f" {k} {'same' if _got[k] == AT_7A00F12[k] else 'DIFFERS'} "
|
print(f" {k} {'same' if _got[k] == AT_7A00F12[k] else 'DIFFERS'} "
|
||||||
f"{_got[k][:16]}")
|
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, "
|
check("with battery modes off (option absent or false), every command, "
|
||||||
"integrator, allocation and trade sentence equals 7a00f12's, at one "
|
"integrator, allocation and trade action equals 7a00f12's, at one "
|
||||||
"inverter and at two", _got == AT_7A00F12)
|
"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]}
|
_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 "
|
check("...and the runs reach the trade P3 now skips with modes on: a charge "
|
||||||
"and a sale", {arbitrage.CHARGE, arbitrage.DISCHARGE} <= _acts)
|
"and a sale", {arbitrage.CHARGE, arbitrage.DISCHARGE} <= _acts)
|
||||||
|
|||||||
@@ -99,6 +99,11 @@ from app.static_tariff import (
|
|||||||
TariffPlan, parse_taxes_profile, static_status)
|
TariffPlan, parse_taxes_profile, static_status)
|
||||||
from app.validate import validate_startup_config
|
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 = []
|
fails = []
|
||||||
|
|
||||||
|
|
||||||
@@ -4518,8 +4523,8 @@ _T11_FLAT = TariffPlan(
|
|||||||
[PricePoint(_T11_NOW + timedelta(minutes=15 * i), 0.10, False)
|
[PricePoint(_T11_NOW + timedelta(minutes=15 * i), 0.10, False)
|
||||||
for i in range(96)],
|
for i in range(96)],
|
||||||
PriceProduct.DAY_AHEAD, "generic_dayahead", _T11_NOW), None, "provider")
|
PriceProduct.DAY_AHEAD, "generic_dayahead", _T11_NOW), None, "provider")
|
||||||
_r83 = derived(lambda: _T11_ONEFIRM.arbitrage(**_T11_A9).reason)
|
_r83 = derived(lambda: _T11_ONEFIRM.arbitrage(now=LONG_AGO, **_T11_A9).reason)
|
||||||
_rflat = derived(lambda: _T11_FLAT.arbitrage(**_T11_A9).reason)
|
_rflat = derived(lambda: _T11_FLAT.arbitrage(now=LONG_AGO, **_T11_A9).reason)
|
||||||
print(f" §8.3 refusal : {_r83[:118]}")
|
print(f" §8.3 refusal : {_r83[:118]}")
|
||||||
print(f" flat refusal : {_rflat[:118]}")
|
print(f" flat refusal : {_rflat[:118]}")
|
||||||
# 96 quarter-hourly points span 24 hours; ONE firm point spans one of them.
|
# 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",
|
read = PriceRead.series(points, PriceProduct.DAY_AHEAD, "t17",
|
||||||
datetime.now(UTC))
|
datetime.now(UTC))
|
||||||
plan = TariffPlan(read, None, "provider")
|
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[
|
min_spread_eur_kwh=PRICE_OPTION_DEFAULTS[
|
||||||
"cycle_cost_threshold_eur"],
|
"cycle_cost_threshold_eur"],
|
||||||
uncertainty=frozenset())
|
uncertainty=frozenset())
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ The trading verdict is taken over the price periods that have not ended.
|
|||||||
point that is still current stays in.
|
point that is still current stays in.
|
||||||
4. Modes on: a spread that exists only because of an ended period no longer
|
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.
|
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
|
Mutations: MUTATION.md says how to run them (purged copies); the ones of
|
||||||
PRICE-02 are listed in the pull request.
|
PRICE-02 are listed in the pull request.
|
||||||
@@ -50,6 +55,7 @@ def check(name, cond):
|
|||||||
|
|
||||||
|
|
||||||
UTC = timezone.utc
|
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})
|
NO_WEATHER = frozenset({Uncertainty.NO_WEATHER})
|
||||||
D0 = datetime(2026, 10, 20, 0, 0, tzinfo=UTC) # 02:00 Brussels
|
D0 = datetime(2026, 10, 20, 0, 0, tzinfo=UTC) # 02:00 Brussels
|
||||||
H3 = D0 + timedelta(hours=1) # 03: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")
|
start), None, "provider")
|
||||||
|
|
||||||
|
|
||||||
|
ARGS_NO_NOW = dict(ARGS)
|
||||||
|
ARGS5 = dict(ARGS, min_spread_eur_kwh=0.05, uncertainty=frozenset())
|
||||||
|
|
||||||
|
|
||||||
def dec(plan, now, **kw):
|
def dec(plan, now, **kw):
|
||||||
base = dict(peak_guard_active=False, charge_headroom_w=5000.0, soc=50.0,
|
base = dict(peak_guard_active=False, charge_headroom_w=5000.0, soc=50.0,
|
||||||
min_spread_eur_kwh=0.10, uncertainty=frozenset(),
|
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)
|
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 "
|
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",
|
"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),
|
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)
|
10.0, 90.0, 5000.0).action is None)
|
||||||
|
|
||||||
# A cheapest period still AHEAD stays the target from every probe.
|
# 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):
|
for m in (0, 5, 20, 35, 59):
|
||||||
now = H3 + timedelta(minutes=m)
|
now = H3 + timedelta(minutes=m)
|
||||||
a = HR_PLAN.arbitrage(now=now, **ARGS)
|
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 \
|
same = same and a.cheapest is b.cheapest and a.dearest is b.dearest \
|
||||||
and a.spread_eur_kwh == b.spread_eur_kwh
|
and a.spread_eur_kwh == b.spread_eur_kwh
|
||||||
check("an hourly point that is still current stays in: at 03:00, :05, :20, "
|
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
|
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)
|
plan = static_plan(OPTS, now, now + timedelta(hours=48), now=now)
|
||||||
a = plan.arbitrage(now=now, **ARGS)
|
a = plan.arbitrage(now=now, **ARGS)
|
||||||
b = plan.arbitrage(**ARGS)
|
b = plan.arbitrage(now=LONG_AGO, **ARGS)
|
||||||
n_checked += 1
|
n_checked += 1
|
||||||
ok = ok and a.kind == b.kind == "allowed" and a.cheapest is b.cheapest \
|
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
|
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)
|
now = H3 + timedelta(minutes=20)
|
||||||
check("the fixture: with the ended 03:00 period in view the verdict is "
|
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",
|
"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 GATE_PLAN.arbitrage(now=now, **ARGS).kind == "refused"
|
||||||
and "flat" in GATE_PLAN.arbitrage(now=now, **ARGS).reason)
|
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')",
|
"'sell period')",
|
||||||
"the buy period is here" in _hi.reason
|
"the buy period is here" in _hi.reason
|
||||||
and "the sell period is here" in _lo.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:
|
try:
|
||||||
BUY_PLAN.arbitrage(now=datetime(2026, 10, 20, 3, 20), **ARGS)
|
BUY_PLAN.arbitrage(now=datetime(2026, 10, 20, 3, 20), **ARGS)
|
||||||
_naive = False
|
_naive = False
|
||||||
@@ -314,6 +340,88 @@ except TypeError as e:
|
|||||||
check("a naive `now` is refused: it is ambiguous for one hour every October",
|
check("a naive `now` is refused: it is ambiguous for one hour every October",
|
||||||
_naive)
|
_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()
|
print()
|
||||||
if fails:
|
if fails:
|
||||||
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ from app.static_tariff import Arbitrage, TariffPlan
|
|||||||
|
|
||||||
UTC = timezone.utc
|
UTC = timezone.utc
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
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 = []
|
fails = []
|
||||||
|
|
||||||
|
|
||||||
@@ -905,7 +910,7 @@ def min_accepted_spread(product, sources, base, provider="generic_dayahead"):
|
|||||||
lo, hi = 0.0, 4.0
|
lo, hi = 0.0, 4.0
|
||||||
for _ in range(60):
|
for _ in range(60):
|
||||||
mid = (lo + hi) / 2
|
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,
|
peak_guard_active=False, min_spread_eur_kwh=base,
|
||||||
uncertainty=sources)
|
uncertainty=sources)
|
||||||
if v.kind == "allowed":
|
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"{_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",
|
f"different numbers, so a verdict can be traced to one of them",
|
||||||
not near(_all_spread, _firm_spread, 1e-6))
|
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,
|
min_spread_eur_kwh=0.0,
|
||||||
uncertainty=frozenset()))
|
uncertainty=frozenset()))
|
||||||
check("a PART-published series still allows arbitrage - §8.3 forbids acting on "
|
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,
|
_all_prov = TariffPlan(PriceRead.series(_cheap_stale, PriceProduct.DAY_AHEAD,
|
||||||
"generic_dayahead", D0), None,
|
"generic_dayahead", D0), None,
|
||||||
"fallback")
|
"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,
|
min_spread_eur_kwh=0.0,
|
||||||
uncertainty=frozenset()))
|
uncertainty=frozenset()))
|
||||||
check("a WHOLLY provisional series - the §11.4 fallback, or a day before any "
|
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")
|
"generic_dayahead", D0), None, "provider")
|
||||||
check("one firm hour is not enough either: a buy hour and a sell hour must "
|
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",
|
"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")
|
uncertainty=frozenset()).kind == "refused")
|
||||||
check("§10.1 still holds above all of it: the peak guard refuses the same "
|
check("§10.1 still holds above all of it: the peak guard refuses the same "
|
||||||
"series that is otherwise allowed",
|
"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")
|
uncertainty=frozenset()).kind == "refused")
|
||||||
check("the three questions cannot be skipped: all of peak_guard_active, "
|
check("the three questions cannot be skipped: all of peak_guard_active, "
|
||||||
"min_spread_eur_kwh and uncertainty are required and keyword-only",
|
"min_spread_eur_kwh and uncertainty are required and keyword-only",
|
||||||
raises(lambda: _mixed.arbitrage(peak_guard_active=False), TypeError)
|
raises(lambda: _mixed.arbitrage(now=LONG_AGO, peak_guard_active=False), 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.0), TypeError)
|
min_spread_eur_kwh=0.0), TypeError)
|
||||||
and raises(lambda: _mixed.arbitrage(False, 0.0, frozenset()), TypeError))
|
and raises(lambda: _mixed.arbitrage(False, 0.0, frozenset()), TypeError))
|
||||||
check("...nor answered with a truthy 'unknown' or a negative threshold",
|
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,
|
min_spread_eur_kwh=0.0,
|
||||||
uncertainty=frozenset()), TypeError)
|
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,
|
min_spread_eur_kwh=-0.1,
|
||||||
uncertainty=frozenset()), TypeError))
|
uncertainty=frozenset()), TypeError))
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,11 @@ from app.static_tariff import resolve_tariff
|
|||||||
|
|
||||||
UTC = timezone.utc
|
UTC = timezone.utc
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
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 = []
|
fails = []
|
||||||
_tmpdirs = []
|
_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 ------------------- #
|
# --- the trade set, which is where the whole thing lands ------------------- #
|
||||||
_A9 = dict(peak_guard_active=False, min_spread_eur_kwh=0.0,
|
_A9 = dict(peak_guard_active=False, min_spread_eur_kwh=0.0,
|
||||||
uncertainty=frozenset())
|
uncertainty=frozenset())
|
||||||
_v2 = _pub.arbitrage(**_A9)
|
_v2 = _pub.arbitrage(now=LONG_AGO, **_A9)
|
||||||
_v1 = _pre.arbitrage(**_A9)
|
_v1 = _pre.arbitrage(now=LONG_AGO, **_A9)
|
||||||
check("both plans still ALLOW a trade, so the two verdicts below are compared "
|
check("both plans still ALLOW a trade, so the two verdicts below are compared "
|
||||||
"against each other and not against a refusal",
|
"against each other and not against a refusal",
|
||||||
_v2.kind == "allowed" and _v1.kind == "allowed")
|
_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,
|
_A = dict(peak_guard_active=False, min_spread_eur_kwh=0.0,
|
||||||
uncertainty=frozenset())
|
uncertainty=frozenset())
|
||||||
_v_before = _before.arbitrage(**_A)
|
_v_before = _before.arbitrage(now=LONG_AGO, **_A)
|
||||||
_v_after = _after.arbitrage(**_A)
|
_v_after = _after.arbitrage(now=LONG_AGO, **_A)
|
||||||
check("both plans still ALLOW a trade, so this section compares two verdicts "
|
check("both plans still ALLOW a trade, so this section compares two verdicts "
|
||||||
"rather than a verdict and a refusal",
|
"rather than a verdict and a refusal",
|
||||||
_v_before.kind == "allowed" and _v_after.kind == "allowed")
|
_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)
|
_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)
|
- 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,
|
_q_spread_got = _qv.match(allowed=lambda v: v.spread_eur_kwh,
|
||||||
refused=lambda _v: None)
|
refused=lambda _v: None)
|
||||||
check(f"...and the spread is the fixture's own max - min over that window "
|
check(f"...and the spread is the fixture's own max - min over that window "
|
||||||
|
|||||||
@@ -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
|
# 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
|
# 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
|
# 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
|
# edge tick's blindness at N=2).
|
||||||
# `ident.trade_digest()`, which hides two things in the trade sentence: the
|
#
|
||||||
# "above the cheapest" figure (the plan now drops price periods that have
|
# PRICE-02 (2026-10-22) changed two things in the trade sentence and nothing
|
||||||
# ended) and "hour" -> "price period". Re-measured with it on e3e4ba6, on
|
# else (see test_modes_p3.py part 1): the "N above the cheapest" figure of a
|
||||||
# release/1.0 90bc901 and on this tree: the same four digests.
|
# 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 = {
|
AT_BASE = {
|
||||||
"n1": "33886ef03f364852883f0370230e6e04823fbb46a4704f88ca75bf9082d98c01",
|
"n1": "33886ef03f364852883f0370230e6e04823fbb46a4704f88ca75bf9082d98c01",
|
||||||
"n2": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
|
"n2": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
|
||||||
"n1_lag": "f6c67b351bd732fb69e62cafbfdb2bcff6032f5d8845836659d096b8c677d3c0",
|
"n1_lag": "f6c67b351bd732fb69e62cafbfdb2bcff6032f5d8845836659d096b8c677d3c0",
|
||||||
"n2_lag": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
|
"n2_lag": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f",
|
||||||
}
|
}
|
||||||
|
AT_NOW = {
|
||||||
|
"n1": "939b3d47bbd64fa50e3d70538f092fb98e5c4bd5ead85a7fc696a1dbb514039e",
|
||||||
|
"n2": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d",
|
||||||
|
"n1_lag": "78aac892975be81a7ceac0c000ed441a731bd4f42ff9f729996cbee53d6b7f02",
|
||||||
|
"n2_lag": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d",
|
||||||
|
}
|
||||||
_cmd_digest = ident.trade_digest
|
_cmd_digest = ident.trade_digest
|
||||||
_got = {k: _cmd_digest(ident.run(**ident.SCENARIOS[k])[0])
|
_rows5 = {k: ident.run(**ident.SCENARIOS[k])[0] for k in AT_BASE}
|
||||||
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:
|
for _k in AT_BASE:
|
||||||
print(f" {_k} {'same' if _got[_k] == AT_BASE[_k] else 'DIFFERS'} "
|
print(f" {_k} {'same' if _got[_k] == AT_BASE[_k] else 'DIFFERS'} "
|
||||||
f"{_got[_k][:16]}")
|
f"{_got[_k][:16]}")
|
||||||
check("with battery modes off every command, integrator, allocation and "
|
check("with battery modes off every command, integrator, allocation and "
|
||||||
"trade sentence equals e3e4ba6's, also when every quarter boundary's "
|
"trade action equals e3e4ba6's (the sentence masked as above), also when "
|
||||||
"cycle still sees the old P1 block", _got == AT_BASE)
|
"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
|
_real_project = tariff.PeakTracker.project
|
||||||
tariff.PeakTracker.project = staticmethod(
|
tariff.PeakTracker.project = staticmethod(
|
||||||
|
|||||||
@@ -1178,7 +1178,7 @@ async def _modes_plan():
|
|||||||
c = W.ctl
|
c = W.ctl
|
||||||
plan = c.fresh_tariff_plan(t)
|
plan = c.fresh_tariff_plan(t)
|
||||||
verdict = plan.arbitrage(
|
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())
|
uncertainty=frozenset())
|
||||||
return verdict.match(allowed=lambda v: ("allowed", v.spread_eur_kwh),
|
return verdict.match(allowed=lambda v: ("allowed", v.spread_eur_kwh),
|
||||||
refused=lambda v: ("refused", v.reason))
|
refused=lambda v: ("refused", v.reason))
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ from app.validate import validate_startup_config
|
|||||||
|
|
||||||
HERE = Path(__file__).resolve().parent
|
HERE = Path(__file__).resolve().parent
|
||||||
UTC = timezone.utc
|
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 = []
|
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}",
|
f"{_spread_two}, re-derived here as {DAY_P - NIGHT_P:.4f}",
|
||||||
near(_spread_two, DAY_P - NIGHT_P, 1e-9))
|
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_fixed = p_fixed.arbitrage(now=LONG_AGO, 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_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("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 "
|
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",
|
"low and sell high, not because a FIXED enum was matched on",
|
||||||
@@ -354,7 +359,7 @@ _flat_dayahead = TariffPlan(
|
|||||||
None, "provider")
|
None, "provider")
|
||||||
check("a DAY_AHEAD series that happens to be flat is refused too, so the rule "
|
check("a DAY_AHEAD series that happens to be flat is refused too, so the rule "
|
||||||
"is arithmetic and not a product label",
|
"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.
|
# The numbers an optimiser needs exist ONLY on the permitting variant.
|
||||||
check("the allowed verdict carries the spread, the cheapest hour and the "
|
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.
|
# §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 "
|
check("peak shaving outranks arbitrage: the SAME allowed plan is refused when "
|
||||||
"the peak guard is holding",
|
"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",
|
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 "
|
check("...and the question cannot be skipped: peak_guard_active is required "
|
||||||
"and keyword-only, so a spread is not obtainable without answering it",
|
"and keyword-only, so a spread is not obtainable without answering it",
|
||||||
raises(lambda: p_two.arbitrage(), TypeError)
|
raises(lambda: p_two.arbitrage(), TypeError)
|
||||||
and raises(lambda: p_two.arbitrage(False), TypeError))
|
and raises(lambda: p_two.arbitrage(False), TypeError))
|
||||||
check("...nor answered with a truthy 'unknown'",
|
check("...nor answered with a truthy 'unknown'",
|
||||||
raises(lambda: p_two.arbitrage(peak_guard_active=None), TypeError)
|
raises(lambda: p_two.arbitrage(now=LONG_AGO, peak_guard_active=None), TypeError)
|
||||||
and raises(lambda: p_two.arbitrage(peak_guard_active="no"), 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",
|
"cannot run against it",
|
||||||
raises(lambda: _none.read.points, Absence))
|
raises(lambda: _none.read.points, Absence))
|
||||||
check("...and arbitrage is refused, per §11.4",
|
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)
|
and arbitrage_allowed(_none.read) is False)
|
||||||
check("...and static_status() says so in words rather than reporting a price",
|
check("...and static_status() says so in words rather than reporting a price",
|
||||||
static_status(opts(None), THU) == (False, "No fixed tariff set"))
|
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))
|
_fb.provisional and plan_is_provisional(_fb.read))
|
||||||
check("...and arbitrage is refused on it, per §11.4, even though a full series "
|
check("...and arbitrage is refused on it, per §11.4, even though a full series "
|
||||||
"exists and has a real spread",
|
"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))
|
and near(_fb.spread_eur_kwh(), DAY_P - NIGHT_P))
|
||||||
check("...while the SAME profile as the configured product allows it - so the "
|
check("...while the SAME profile as the configured product allows it - so the "
|
||||||
"refusal is about provisionality and not about the profile",
|
"refusal is about provisionality and not about the profile",
|
||||||
static_plan(opts(TWO_PERIOD), THU, THU + timedelta(days=1)
|
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 "
|
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",
|
"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)))
|
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)
|
and _live_plan.product is PriceProduct.DAY_AHEAD)
|
||||||
check("...and arbitrage is allowed on it, with the spread derived from the "
|
check("...and arbitrage is allowed on it, with the spread derived from the "
|
||||||
"feed rather than from the profile",
|
"feed rather than from the profile",
|
||||||
_live_plan.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "allowed"
|
_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(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).spread_eur_kwh,
|
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))
|
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)
|
all(near(p.spread_eur_kwh(), d, 1e-12)
|
||||||
and len([q for q in p.read.points if not q.provisional]) == 2
|
and len([q for q in p.read.points if not q.provisional]) == 2
|
||||||
for d, p in _tiny9))
|
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 "
|
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"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 "
|
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 "
|
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 "
|
"still refused, and refused BY THE FLAT RUNG - without this the sweep "
|
||||||
"above would also pass for a rung that had been deleted outright",
|
"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])
|
_q9 = plan9([0.20000, 0.2000005])
|
||||||
check(f"...and a difference below the rung's own 6-decimal rounding is flat BY "
|
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 "
|
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",
|
f"to bisect further",
|
||||||
not near(_q9.spread_eur_kwh(), 0.0, 1e-9)
|
not near(_q9.spread_eur_kwh(), 0.0, 1e-9)
|
||||||
and near(round(_q9.spread_eur_kwh(), 6), 0.0, 1e-12)
|
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 -----
|
# --- 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 "
|
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"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 "
|
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 "
|
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. "
|
"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",
|
"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 "
|
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",
|
"series is half-published rather than told it is fixed-price",
|
||||||
"§8.3" in _one9.arbitrage(**_A9).reason
|
"§8.3" in _one9.arbitrage(now=LONG_AGO, **_A9).reason
|
||||||
and "only 1 of 3" in _one9.arbitrage(**_A9).reason
|
and "only 1 of 3" in _one9.arbitrage(now=LONG_AGO, **_A9).reason
|
||||||
and "flat" not in _one9.arbitrage(**_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
|
# 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.
|
# +0 B edit in the other direction - would pass everything above.
|
||||||
_two9 = plan9([0.20, 0.40, 0.10], [False, False, True])
|
_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 "
|
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`",
|
"rung is pinned from above too and `< 3` is red as well as `< 1`",
|
||||||
rung9(_two9.arbitrage(**_A9)) == "allowed"
|
rung9(_two9.arbitrage(now=LONG_AGO, **_A9)) == "allowed"
|
||||||
and near(_two9.arbitrage(**_A9).spread_eur_kwh, 0.20))
|
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:
|
# ...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
|
# `max()` over an empty `firm` raises ValueError, so `< 0` does not degrade the
|
||||||
# reason, it takes the verdict away entirely.
|
# 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 "
|
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 "
|
"not raise - `max()` over an empty firm set is a ValueError, so this row "
|
||||||
"is what makes `len(firm) < 0` red",
|
"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 ------------------------------
|
# --- 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.
|
# ⚠️ 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"),
|
("two firm hours of three", _two9, "allowed"),
|
||||||
("no firm hours at all", _zero9, "firm-pair"),
|
("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]
|
for name, pl, want in _TABLE9]
|
||||||
check(f"TEST-16 AC3: each stimulus is answered by the rung that OWNS it, and "
|
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 "
|
f"by no other - the flat rung is asked a question the firm-pair rung "
|
||||||
|
|||||||
@@ -123,7 +123,10 @@ def _trade_words(text):
|
|||||||
plan now drops price periods that have ended, so a sale's "above the
|
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
|
cheapest" figure is a different number once the first cheap period is
|
||||||
over, and the sentences say "price period" where they said "hour". Both
|
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",
|
text = text.replace("this hour is neither the buy hour nor the sell hour",
|
||||||
"now is not the cheapest or the dearest price period")
|
"now is not the cheapest or the dearest price period")
|
||||||
text = re.sub(r"[0-9]+\.[0-9]+ above the cheapest",
|
text = re.sub(r"[0-9]+\.[0-9]+ above the cheapest",
|
||||||
|
|||||||
Reference in New Issue
Block a user