PRICE-02: the trading verdict skips price periods that have ended
TariffPlan.arbitrage(now=...) drops the points whose period has ended (end <= now), so the cheapest and dearest point can still be traded. decide() (modes off), the modes-on allowed() gate and the Home verdict all pass their own now. Hourly feeds and the static tariff are unchanged. The off-peak sentence and the charge/discharge sentences say "price period". Tests: test_price02.py; test_arbitrage.py rolls its whole-day plans to start at now; test_modes_p3.py part 1 digests leave the trade sentence out (commands unchanged on7a00f12,90bc901and this tree). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
parent
90bc9017b2
commit
4297f7f3f1
@@ -0,0 +1,27 @@
|
||||
# PRICE-02 - the trading verdict skips price periods that have ended
|
||||
|
||||
**Status: for review.** Tests: `test_price02.py`. Found by AUDIT-01 (F1).
|
||||
|
||||
The refresh floors the plan to the top of the hour, so on the 15-minute feed
|
||||
it holds the periods of this hour that are already over. The verdict took the
|
||||
cheapest and dearest over every point. With modes off it could aim at a period
|
||||
that had passed and then do nothing, for up to 45 min plus the refresh lag.
|
||||
With modes on the `allowed()` gate could be open only because of such a period.
|
||||
|
||||
Decision: `TariffPlan.arbitrage(now=...)` drops the points whose period has
|
||||
ended (`end <= now`, the span `price._cover_table()` gives, as
|
||||
`priceruns.firm_slots()` does). Its three callers pass their own `now`:
|
||||
`arbitrage.decide()`, `Controller._mode_roles()` and `homeview.trade_verdict()`.
|
||||
|
||||
- Taken in the verdict, not when the plan is built: a plan built at 03:00 is
|
||||
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.
|
||||
- The idle sentence now reads "now is not the cheapest or the dearest price
|
||||
period". Other sentences that say "hour" (AUDIT-01 T6) are not touched here.
|
||||
- Not changed here: F2 (static profile sampled at HH:00) and F3 (carried value).
|
||||
|
||||
Measured: with price off, and on a static tariff, every command over the
|
||||
modes-off replay is byte-identical. On the dynamic replay a sale quotes a
|
||||
smaller "above the cheapest" figure once the first cheap period is over.
|
||||
@@ -172,7 +172,7 @@ IDLE_NOT_COVERED = (
|
||||
IDLE_NO_SOC = (
|
||||
"state of charge is not being read, and a battery whose fill nobody knows "
|
||||
"cannot be told to empty or to fill")
|
||||
IDLE_OFF_PEAK_HOUR = "this hour is neither the buy hour nor the sell hour"
|
||||
IDLE_OFF_PEAK_HOUR = "now is not the cheapest or the dearest price period"
|
||||
# OPT-09. A TRADE NOTHING CAN TAKE IS NOT A TRADE, AND THE ALTERNATIVE IS WORSE
|
||||
# THAN A MISSED HOUR. `charge_w`/`discharge_w` used to be the site's whole rating
|
||||
# and are now SIZED to the packs whose state of charge permits that direction, so
|
||||
@@ -196,7 +196,7 @@ IDLE_HEADROOM_UNMEASURED = (
|
||||
"creates the billed quantity (§9.1) and nothing is measuring the quarter "
|
||||
"this one would land in")
|
||||
NEGATIVE_PRICE = (
|
||||
"§17: the sell hour is priced BELOW ZERO, so discharging into it would give "
|
||||
"§17: the sell period is priced BELOW ZERO, so discharging into it would give "
|
||||
"energy away and pay for the privilege. Arbitrage must not export at a "
|
||||
"negative price")
|
||||
|
||||
@@ -347,7 +347,7 @@ def decide(plan, now: datetime, *, peak_guard_active, charge_headroom_w, soc,
|
||||
|
||||
verdict = plan.arbitrage(peak_guard_active=peak_guard_active,
|
||||
min_spread_eur_kwh=min_spread_eur_kwh,
|
||||
uncertainty=uncertainty)
|
||||
uncertainty=uncertainty, now=now)
|
||||
return verdict.match(
|
||||
refused=lambda v: _idle(v.reason),
|
||||
allowed=lambda v: _act(plan, now, v, soc, charge_w, discharge_w,
|
||||
@@ -362,7 +362,7 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct,
|
||||
if here is None:
|
||||
return _idle(
|
||||
f"the series carries a tradeable spread but no price for this "
|
||||
f"instant ({now.isoformat()}), which is §11.4 for this hour rather "
|
||||
f"instant ({now.isoformat()}), which is §11.4 for this period rather "
|
||||
f"than a reason to reach for a neighbouring one",
|
||||
spread=v.spread_eur_kwh, required=v.required_spread_eur_kwh)
|
||||
|
||||
@@ -381,7 +381,7 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct,
|
||||
# irreversible action may rest on it.
|
||||
if here.provisional:
|
||||
return _idle(
|
||||
f"§8.3: the price for this hour ({here.start.isoformat()}) is "
|
||||
f"§8.3: the price for this period ({here.start.isoformat()}) is "
|
||||
f"CARRIED FORWARD, not published, and no irreversible action may be "
|
||||
f"taken on a provisional price",
|
||||
spread=v.spread_eur_kwh, required=v.required_spread_eur_kwh,
|
||||
@@ -399,18 +399,18 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct,
|
||||
# negative price reports the reason that actually forbids the trade
|
||||
# rather than one that happens to fire first on some other site.
|
||||
if price < 0:
|
||||
return _idle(f"{NEGATIVE_PRICE} - this hour is "
|
||||
return _idle(f"{NEGATIVE_PRICE} - this period is "
|
||||
f"{price:+.4f} EUR/kWh", **common)
|
||||
if soc <= floor_pct:
|
||||
return _idle(
|
||||
f"the sell hour is here at {price:.4f} EUR/kWh but the pack is "
|
||||
f"the sell period is here at {price:.4f} EUR/kWh but the pack is "
|
||||
f"at {soc:g} %, at or under the {floor_pct:g} % reserve floor "
|
||||
f"arbitrage may not spend (§10.1 priority 4)", **common)
|
||||
if not abs(float(discharge_w)) > 0.0:
|
||||
return _idle(IDLE_NO_SIZE, **common)
|
||||
return ArbitrageDecision(
|
||||
action=DISCHARGE, power_w=abs(float(discharge_w)),
|
||||
reason=(f"{DISCHARGE}: this is the dearest firm hour at "
|
||||
reason=(f"{DISCHARGE}: this is the dearest firm price period at "
|
||||
f"{price:.4f} EUR/kWh, {v.spread_eur_kwh:.4f} above the "
|
||||
f"cheapest and clear of the {v.required_spread_eur_kwh:.4f} "
|
||||
f"this cycle had to cover; discharging "
|
||||
@@ -421,7 +421,7 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct,
|
||||
|
||||
if soc >= ceiling_pct:
|
||||
return _idle(
|
||||
f"the buy hour is here at {price:.4f} EUR/kWh but the pack is at "
|
||||
f"the buy period is here at {price:.4f} EUR/kWh but the pack is at "
|
||||
f"{soc:g} %, at or over the {ceiling_pct:g} % ceiling - there is "
|
||||
f"nowhere to put the cheap energy", **common)
|
||||
if not abs(float(charge_w)) > 0.0:
|
||||
@@ -456,7 +456,7 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct,
|
||||
capped = size_w < asked_w
|
||||
return ArbitrageDecision(
|
||||
action=CHARGE, power_w=-size_w,
|
||||
reason=(f"{CHARGE}: this is the cheapest firm hour at "
|
||||
reason=(f"{CHARGE}: this is the cheapest firm price period at "
|
||||
f"{price:.4f} EUR/kWh, {v.spread_eur_kwh:.4f} below the dearest "
|
||||
f"and clear of the {v.required_spread_eur_kwh:.4f} this cycle "
|
||||
f"had to cover; charging {size_w:.0f} W from {soc:g} %"
|
||||
|
||||
@@ -262,11 +262,12 @@ 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):
|
||||
def trade_verdict(c, plan, now=None):
|
||||
"""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
|
||||
no-weather uncertainty. Returns `(verdict_or_None, why_key)`."""
|
||||
no-weather uncertainty, and (PRICE-02) the same `now`. Returns
|
||||
`(verdict_or_None, why_key)`."""
|
||||
if not arbitrage.enabled(c.o):
|
||||
return None, "off"
|
||||
guard = arbitrage.guard_state(c.peak_guard)
|
||||
@@ -276,20 +277,25 @@ def trade_verdict(c, plan):
|
||||
return None, "no_prices"
|
||||
verdict = plan.arbitrage(peak_guard_active=guard,
|
||||
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(c.o),
|
||||
uncertainty=frozenset({Uncertainty.NO_WEATHER}))
|
||||
uncertainty=frozenset({Uncertainty.NO_WEATHER}),
|
||||
now=now)
|
||||
return verdict.match(
|
||||
allowed=lambda v: (v, "allowed"),
|
||||
refused=lambda _v: (None, refusal_key(plan, guard)))
|
||||
refused=lambda _v: (None, refusal_key(plan, guard, now)))
|
||||
|
||||
|
||||
def refusal_key(plan, guard):
|
||||
def refusal_key(plan, guard, now=None):
|
||||
"""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:
|
||||
return "peak"
|
||||
if not plan.has_prices:
|
||||
return "no_prices"
|
||||
if sum(1 for p in _points(plan) if not p.provisional) < 2:
|
||||
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]
|
||||
if sum(1 for p in pts if not p.provisional) < 2:
|
||||
return "not_firm"
|
||||
return "not_worth"
|
||||
|
||||
@@ -602,7 +608,7 @@ def build_plan(c, now, shadow, running, held=False, settled=True):
|
||||
limit_w = c.peak.cap_w # the limit the controller parsed at start
|
||||
runs, lines = [], []
|
||||
# The control cycle trades on the fresh plan only, so this does too.
|
||||
verdict, trade_why = trade_verdict(c, fresh)
|
||||
verdict, trade_why = trade_verdict(c, fresh, now)
|
||||
bars = price_bars(seen, day0, day1, price_windows(c, fresh, verdict),
|
||||
now, days.kinds)
|
||||
modes_on = bool(getattr(c, "modes_on", False))
|
||||
|
||||
@@ -2369,7 +2369,8 @@ class Controller:
|
||||
return plan.arbitrage(
|
||||
peak_guard_active=active,
|
||||
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o),
|
||||
uncertainty=frozenset({Uncertainty.NO_WEATHER})).match(
|
||||
uncertainty=frozenset({Uncertainty.NO_WEATHER}),
|
||||
now=now).match(
|
||||
allowed=lambda _v: True, refused=lambda _v: False)
|
||||
if guard is not None and plan is not None and runs is not None:
|
||||
# A charge asks the guard (section 9.1 interlock); a sell only
|
||||
|
||||
@@ -207,7 +207,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from . import messages, price_chain
|
||||
from .price import (
|
||||
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
|
||||
Uncertainty, _Outcome, brussels, load_prices, price_option,
|
||||
Uncertainty, _Outcome, _cover_table, brussels, load_prices, price_option,
|
||||
provider_configured, required_spread, uncertainty_of, widen_factor,
|
||||
)
|
||||
from .price_chain import uses_chain
|
||||
@@ -986,9 +986,17 @@ 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) -> Arbitrage:
|
||||
uncertainty, now: datetime | None = None) -> 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.
|
||||
|
||||
⚠️ ALL THREE ARGUMENTS ARE REQUIRED AND KEYWORD-ONLY, for one reason
|
||||
each, and none of them has a default:
|
||||
|
||||
@@ -1056,6 +1064,12 @@ 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)
|
||||
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
|
||||
@@ -1334,6 +1348,11 @@ async def resolve_tariff(opts: dict, since: datetime, until: datetime,
|
||||
that, so the spread is over hours that are still ahead. Both directions of
|
||||
the difference are real: the window gains the unpublished hours it was
|
||||
missing and loses the elapsed ones it should never have had.
|
||||
|
||||
⚠️ PRICE-02: THAT HOLDS FOR AN HOURLY FEED ONLY. The window is floored to
|
||||
the hour, so a 15-minute feed keeps the elapsed periods of the current
|
||||
hour, and the plan ages between refreshes. `TariffPlan.arbitrage(now=...)`
|
||||
drops the periods that have ended, at every call.
|
||||
"""
|
||||
stamp = now or datetime.now(timezone.utc)
|
||||
if not provider_configured(opts):
|
||||
|
||||
@@ -93,9 +93,11 @@ from app.control import Tuning, compute
|
||||
from app.p1 import QUARTER_S, QuarterAverager, make_sample, split_signed
|
||||
from app.tariff import PeakTracker
|
||||
from app.arbitrage import (
|
||||
CHARGE, DISCHARGE, ArbitrageDecision, claim, decide, enabled, soc_bounds,
|
||||
CHARGE, DISCHARGE, ArbitrageDecision, claim, 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 (
|
||||
@@ -112,6 +114,27 @@ HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
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:
|
||||
@@ -684,12 +707,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 != "self"))
|
||||
if n not in ("self", "now"))) # PRICE-02: now=None keeps the old reach
|
||||
check("...nor does arbitrage.decide(): every argument of it is keyword-only "
|
||||
"with no default, for the same reason",
|
||||
all(p.default is inspect.Parameter.empty
|
||||
and (p.kind is inspect.Parameter.KEYWORD_ONLY or n in ("plan", "now"))
|
||||
for n, p in inspect.signature(decide).parameters.items()))
|
||||
for n, p in inspect.signature(_decide_now).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
|
||||
@@ -1682,7 +1705,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 <= 4)
|
||||
_transitions <= 24)
|
||||
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)
|
||||
@@ -1839,11 +1862,15 @@ 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)
|
||||
@@ -1854,6 +1881,7 @@ 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)
|
||||
@@ -1882,9 +1910,12 @@ 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 == [])
|
||||
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)
|
||||
# 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)
|
||||
_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 "
|
||||
|
||||
@@ -66,21 +66,35 @@ A, B = "inverter-a", "inverter-b"
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. modes off: byte-identical to 7a00f12 on the trade path")
|
||||
# Printed by tools/opt11p3_identity.py on release/1.0 7a00f12 (2026-09-29),
|
||||
# goodwe-addon-checks:1. 1080 cycles each; the trade CHARGEs and DISCHARGEs.
|
||||
# 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 over every row with the trade's SENTENCE left out (action and
|
||||
# watts stay). The plan now drops price periods that have ended, so the
|
||||
# sentence of a sale quotes a smaller "above the cheapest" figure once the
|
||||
# first cheap period is over, and the idle sentence says "price period".
|
||||
# Re-measured on 7a00f12, on release/1.0 90bc901 and on this tree: the same
|
||||
# four digests, so no command, integrator or allocation moved.
|
||||
AT_7A00F12 = {
|
||||
"n1": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c",
|
||||
"n2": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
|
||||
"n1_off": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c",
|
||||
"n2_off": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
|
||||
"n1": "2f3cf03655f19a172e83ccae1e1308a8eef9d1023ab632a68b943920b032800d",
|
||||
"n2": "59ba1cbfe33aeca74d96b50dbcfde5337a8f82de0fde598a048afeef46fe64de",
|
||||
"n1_off": "2f3cf03655f19a172e83ccae1e1308a8eef9d1023ab632a68b943920b032800d",
|
||||
"n2_off": "59ba1cbfe33aeca74d96b50dbcfde5337a8f82de0fde598a048afeef46fe64de",
|
||||
}
|
||||
|
||||
|
||||
def _cmd_digest(rows):
|
||||
"""`ident.digest()` over rows whose trade is (action, watts) only."""
|
||||
return ident.digest([r[:-1] + (None if r[-1] is None else r[-1][:2],)
|
||||
for r in rows])
|
||||
|
||||
|
||||
_rows = {k: ident.run(**ident.SCENARIOS[k])[0] for k in AT_7A00F12}
|
||||
_got = {k: ident.digest(v) for k, v in _rows.items()}
|
||||
_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]}")
|
||||
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)
|
||||
_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 "
|
||||
@@ -96,7 +110,7 @@ def _moved(scn, patch):
|
||||
real(c, t)
|
||||
ident._feed = feed
|
||||
try:
|
||||
return ident.digest(ident.run(**ident.SCENARIOS[scn])[0]) \
|
||||
return _cmd_digest(ident.run(**ident.SCENARIOS[scn])[0]) \
|
||||
!= AT_7A00F12[scn]
|
||||
finally:
|
||||
ident._feed = real
|
||||
@@ -112,7 +126,7 @@ def _moved_headroom(scn):
|
||||
real = peakguard.charge_headroom_w
|
||||
peakguard.charge_headroom_w = lambda g: None
|
||||
try:
|
||||
return ident.digest(ident.run(**ident.SCENARIOS[scn])[0]) \
|
||||
return _cmd_digest(ident.run(**ident.SCENARIOS[scn])[0]) \
|
||||
!= AT_7A00F12[scn]
|
||||
finally:
|
||||
peakguard.charge_headroom_w = real
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"""Runnable check for PRICE-02. `python3 test_price02.py`
|
||||
|
||||
The trading verdict is taken over the price periods that have not ended.
|
||||
`TariffPlan.arbitrage(now=...)` drops the points with `end <= now` (the span
|
||||
`price._cover_table()` gives each one) and every caller passes its `now`:
|
||||
`arbitrage.decide()` (modes off), the `allowed()` gate in
|
||||
`Controller._mode_roles()` (modes on) and the Home page's `trade_verdict()`.
|
||||
|
||||
1. A 15-minute day, probed at :05, :20, :35 and :50 of the cheap hour: the
|
||||
buy leg and the sell leg move to the current period and never name one that
|
||||
has ended, and `decide()` trades in it. Cheapest in the past, the current
|
||||
and a future period are all covered.
|
||||
2. A plan built at 03:00 and used at 03:20 (the 30-minute refresh lag), and the
|
||||
shipped refresh (`fill_from_yesterday`, which keeps the whole current hour).
|
||||
3. An hourly feed and the static day/night tariff are unchanged: an hourly
|
||||
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".
|
||||
|
||||
Mutations: MUTATION.md says how to run them (purged copies); the ones of
|
||||
PRICE-02 are listed in the pull request.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
from app import arbitrage, homeview # noqa: E402
|
||||
from app.arbitrage import CHARGE, DISCHARGE, decide # noqa: E402
|
||||
from app.price import (PricePoint, PriceProduct, PriceRead, # noqa: E402
|
||||
Uncertainty, brussels)
|
||||
from app.price_forecast import fill_from_yesterday # noqa: E402
|
||||
from app.static_tariff import TariffPlan, static_plan # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
UTC = timezone.utc
|
||||
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
|
||||
ARGS = dict(peak_guard_active=False, min_spread_eur_kwh=0.10,
|
||||
uncertainty=NO_WEATHER)
|
||||
|
||||
|
||||
def qplan(prices, start=D0, minutes=15, product=PriceProduct.DAY_AHEAD):
|
||||
pts = [PricePoint(brussels(start + timedelta(minutes=minutes * i)), p,
|
||||
False) for i, p in enumerate(prices)]
|
||||
return TariffPlan(PriceRead.series(pts, product, "generic_dayahead",
|
||||
start), None, "provider")
|
||||
|
||||
|
||||
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(),
|
||||
charge_w=2500.0, discharge_w=2500.0, soc_floor_pct=10.0,
|
||||
soc_ceiling_pct=90.0)
|
||||
base.update(kw)
|
||||
return decide(plan, now, **base)
|
||||
|
||||
|
||||
def hhmm(t):
|
||||
return f"{brussels(t):%H:%M}"
|
||||
|
||||
|
||||
def ahead_extreme(prices, now, pick, start=D0, minutes=15):
|
||||
"""The expected leg, by plain arithmetic: the extreme price over the
|
||||
periods whose end is after `now`, as the start instant (first on a tie)."""
|
||||
best = None
|
||||
for i, p in enumerate(prices):
|
||||
s = start + timedelta(minutes=minutes * i)
|
||||
if s + timedelta(minutes=minutes) <= now:
|
||||
continue
|
||||
if best is None or pick(p, best[1]):
|
||||
best = (s, p)
|
||||
return best[0]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. a 15-minute day: the verdict is taken from the current period on")
|
||||
BUY = [0.30] * 192 # 48 h of quarters from 02:00
|
||||
for q, p in ((4, 0.05), (5, 0.06), (6, 0.07), (7, 0.08), (68, 0.50)):
|
||||
BUY[q] = p # 03:00 .05 03:15 .06 03:30 .07 03:45 .08 19:00 .50
|
||||
BUY_PLAN = qplan(BUY)
|
||||
check("the fixture: the day's cheapest period is 03:00, then three dearer "
|
||||
"ones, and the dearest is 19:00 - so every probe has a past, a current "
|
||||
"and a future period to choose between",
|
||||
BUY.index(min(BUY)) == 4 and BUY.index(max(BUY)) == 68
|
||||
and BUY[4] < BUY[5] < BUY[6] < BUY[7] < min(BUY[8:68]))
|
||||
want_q = {5: 4, 20: 5, 35: 6, 50: 7}
|
||||
for mins, q in want_q.items():
|
||||
now = H3 + timedelta(minutes=mins)
|
||||
v = BUY_PLAN.arbitrage(now=now, **ARGS)
|
||||
d = dec(BUY_PLAN, now)
|
||||
got = v.cheapest.start
|
||||
exp = ahead_extreme(BUY, now, lambda a, b: a < b)
|
||||
check(f"03:{mins:02d}: the buy leg is {hhmm(exp)} (the first period not "
|
||||
f"over), the verdict says {hhmm(got)}, and decide() is {d.action}",
|
||||
got == exp == D0 + timedelta(minutes=15 * q)
|
||||
and d.action == CHARGE
|
||||
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
|
||||
and arbitrage._act(BUY_PLAN, H3 + timedelta(minutes=20),
|
||||
BUY_PLAN.arbitrage(**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.
|
||||
FUT = [0.30] * 192
|
||||
FUT[8], FUT[68] = 0.04, 0.50 # cheapest 04:00, dearest 19:00
|
||||
FUT_PLAN = qplan(FUT)
|
||||
check("the cheapest in the FUTURE (04:00) is the buy leg at :05, :20, :35 and "
|
||||
":50 of the hour before it - nothing ahead is dropped",
|
||||
all(FUT_PLAN.arbitrage(now=H3 + timedelta(minutes=m), **ARGS)
|
||||
.cheapest.start == H3 + timedelta(hours=1) for m in (5, 20, 35, 50)))
|
||||
check("...and at 04:20 (that period is now the current one) it is still the "
|
||||
"current period at :00-:15 only: 04:20 aims elsewhere",
|
||||
FUT_PLAN.arbitrage(now=H3 + timedelta(hours=1, minutes=20), **ARGS)
|
||||
.cheapest.start != H3 + timedelta(hours=1))
|
||||
|
||||
print("\n1b. the sell leg")
|
||||
SELL = [0.30] * 192
|
||||
for q, p in ((4, 0.70), (5, 0.65), (6, 0.60), (7, 0.55), (68, 0.10)):
|
||||
SELL[q] = p # dearest 03:00 .70 .. 03:45 .55 ; cheapest 19:00
|
||||
SELL_PLAN = qplan(SELL)
|
||||
for mins, q in want_q.items():
|
||||
now = H3 + timedelta(minutes=mins)
|
||||
v = SELL_PLAN.arbitrage(now=now, **ARGS)
|
||||
d = dec(SELL_PLAN, now)
|
||||
exp = ahead_extreme(SELL, now, lambda a, b: a > b)
|
||||
check(f"03:{mins:02d}: the sell leg is {hhmm(exp)}, the verdict says "
|
||||
f"{hhmm(v.dearest.start)}, and decide() is {d.action}",
|
||||
v.dearest.start == exp == D0 + timedelta(minutes=15 * q)
|
||||
and d.action == DISCHARGE
|
||||
and abs(d.price_now_eur_kwh - SELL[q]) < 1e-9)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. the plan is older than the verdict")
|
||||
# Built at 03:00 and used at 03:20, 03:35 (the refresh runs every 30 min).
|
||||
built = H3
|
||||
feed = qplan(BUY).read
|
||||
plan_0300 = TariffPlan(fill_from_yesterday(
|
||||
feed, yesterday=PriceRead.no_data("none"), since=built,
|
||||
until=built + timedelta(hours=48), stamp=built), None, "provider")
|
||||
check("the plan built at 03:00 starts at 03:00 (the refresh floors to the hour)",
|
||||
min(p.start for p in plan_0300.read.points) == H3)
|
||||
for mins, q in ((20, 5), (35, 6)):
|
||||
now = H3 + timedelta(minutes=mins)
|
||||
d = dec(plan_0300, now)
|
||||
check(f"used at 03:{mins:02d} the verdict is "
|
||||
f"{hhmm(D0 + timedelta(minutes=15 * q))} and decide() charges there",
|
||||
plan_0300.arbitrage(now=now, **ARGS).cheapest.start
|
||||
== D0 + timedelta(minutes=15 * q) and d.action == CHARGE)
|
||||
# The shipped refresh at 03:20 keeps the whole hour: the verdict still skips it.
|
||||
now = H3 + timedelta(minutes=20)
|
||||
plan_refresh = TariffPlan(fill_from_yesterday(
|
||||
feed, yesterday=PriceRead.no_data("none"), since=now,
|
||||
until=now + timedelta(hours=48), stamp=now), None, "provider")
|
||||
check("the shipped refresh at 03:20 still holds the 03:00 period, as before, "
|
||||
"and the verdict does not aim at it",
|
||||
min(p.start for p in plan_refresh.read.points) == H3
|
||||
and plan_refresh.arbitrage(now=now, **ARGS).cheapest.start
|
||||
== H3 + timedelta(minutes=15))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. hourly feeds and the static tariff do not move")
|
||||
HR = [0.30] * 48 # 48 hourly points from 02:00
|
||||
HR[1], HR[17] = 0.05, 0.50 # cheapest 03:00, dearest 19:00
|
||||
HR_PLAN = qplan(HR, minutes=60)
|
||||
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)
|
||||
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, "
|
||||
":35 and :59 the verdict is the one without `now`", same)
|
||||
check("...and decide() still charges in the current hour at 03:40",
|
||||
dec(HR_PLAN, H3 + timedelta(minutes=40)).action == CHARGE)
|
||||
check("...and once the hour has ended its point is gone: at 04:05 the buy leg "
|
||||
"is no longer 03:00",
|
||||
HR_PLAN.arbitrage(now=H3 + timedelta(hours=1, minutes=5), **ARGS)
|
||||
.cheapest.start != H3)
|
||||
|
||||
TWO = {"periods": [
|
||||
{"name": "day", "from": "07:00", "to": "22:00", "days": "mon-sun",
|
||||
"eur_per_kwh": 0.40},
|
||||
{"name": "night", "from": "22:00", "to": "07:00", "days": "mon-sun",
|
||||
"eur_per_kwh": 0.18}]}
|
||||
OPTS = {"price_provider": "off", "static_profile": json.dumps(TWO)}
|
||||
ok, n_checked = True, 0
|
||||
for h in range(0, 24):
|
||||
for m in (0, 20, 50):
|
||||
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)
|
||||
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
|
||||
check(f"the static day/night plan (built from the current hour, as main.py "
|
||||
f"does) gives the same verdict with and without `now` at {n_checked} "
|
||||
f"instants", ok and n_checked == 72)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. modes on: an ended period no longer opens the gate")
|
||||
GATE = [0.30] * 192
|
||||
GATE[4] = 0.10 # the ONLY spread is the 03:00 period
|
||||
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"
|
||||
and GATE_PLAN.arbitrage(now=now, **ARGS).kind == "refused"
|
||||
and "flat" in GATE_PLAN.arbitrage(now=now, **ARGS).reason)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
|
||||
WPTS = [(D0 + timedelta(minutes=15 * i), 900, 0.10) for i in range(192)]
|
||||
|
||||
|
||||
def controller_at(at, plan):
|
||||
"""The real Controller after a few cycles of the modes-on replay at `at`
|
||||
(two batteries, one on Cheap hours), holding `plan` as its fresh plan."""
|
||||
_st, c = rp.run("p3_ch_sell", [(D0.timestamp() - 10, 800.0)], WPTS, at,
|
||||
20.0, soc0={"a": 60.0, "b": 30.0},
|
||||
extra={"cycle_cost_threshold_eur": 0.05})
|
||||
c.tariff_plan, c.tariff_at = plan, at
|
||||
return c
|
||||
|
||||
|
||||
from app import modetrade # noqa: E402
|
||||
|
||||
GATE_SEEN = {}
|
||||
_real_decide = modetrade.decide
|
||||
|
||||
|
||||
def _spy(runs, now_, packs, **kw):
|
||||
GATE_SEEN.update(charge_ok=kw["charge_ok"], sell_ok=kw["sell_ok"],
|
||||
trade_why=kw["trade_why"])
|
||||
return _real_decide(runs, now_, packs, **kw)
|
||||
|
||||
|
||||
modetrade.decide = _spy
|
||||
c = controller_at(now, GATE_PLAN)
|
||||
GATE_SEEN.clear()
|
||||
c._mode_roles(now, None)
|
||||
late = dict(GATE_SEEN)
|
||||
c2 = controller_at(H3 + timedelta(minutes=5), GATE_PLAN)
|
||||
GATE_SEEN.clear()
|
||||
c2._mode_roles(H3 + timedelta(minutes=5), None)
|
||||
early = dict(GATE_SEEN)
|
||||
modetrade.decide = _real_decide
|
||||
check(f"Controller._mode_roles() (modes on, the `allowed()` gate) at 03:05, "
|
||||
f"03:00 still current: charge and sell are allowed ({early})",
|
||||
early.get("charge_ok") is True and early.get("sell_ok") is True)
|
||||
check(f"...and at 03:20, with only flat periods ahead: neither is allowed, "
|
||||
f"and the reason is the spread ({late})",
|
||||
late.get("charge_ok") is False and late.get("sell_ok") is False
|
||||
and late.get("trade_why") == "not_worth")
|
||||
|
||||
c.o["arbitrage_enabled"] = True
|
||||
v, why = homeview.trade_verdict(c, GATE_PLAN, now)
|
||||
check(f"the Home page verdict takes the same `now`: no verdict at 03:20 "
|
||||
f"({why!r}), and a verdict at 03:05",
|
||||
v is None and why == "not_worth"
|
||||
and homeview.trade_verdict(c, GATE_PLAN, H3 + timedelta(minutes=5))[0]
|
||||
is not None)
|
||||
check("...and the refusal key counts only periods not over: two firm periods "
|
||||
"left, so 'not_worth', and with one left 'not_firm'",
|
||||
homeview.refusal_key(GATE_PLAN, False, now) == "not_worth"
|
||||
and homeview.refusal_key(qplan([0.1, 0.2]), False,
|
||||
D0 + timedelta(minutes=20)) == "not_firm")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n5. the text")
|
||||
check("the reason for an off-peak period says 'price period', not 'hour'",
|
||||
arbitrage.IDLE_OFF_PEAK_HOUR
|
||||
== "now is not the cheapest or the dearest price period")
|
||||
_b = dec(BUY_PLAN, H3 + timedelta(minutes=20))
|
||||
_s = dec(SELL_PLAN, H3 + timedelta(minutes=20))
|
||||
check("...and the charge and the discharge sentences say 'price period' too "
|
||||
"(the unit is 15 minutes), with no 'firm hour' left",
|
||||
"cheapest firm price period" in _b.reason
|
||||
and "dearest firm price period" in _s.reason
|
||||
and "firm hour" not in _b.reason + _s.reason)
|
||||
_hi = dec(BUY_PLAN, H3 + timedelta(minutes=20), soc=95.0)
|
||||
_lo = dec(SELL_PLAN, H3 + timedelta(minutes=20), soc=10.0)
|
||||
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)
|
||||
try:
|
||||
BUY_PLAN.arbitrage(now=datetime(2026, 10, 20, 3, 20), **ARGS)
|
||||
_naive = False
|
||||
except TypeError as e:
|
||||
_naive = "tz-aware" in str(e)
|
||||
check("a naive `now` is refused: it is ambiguous for one hour every October",
|
||||
_naive)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
Reference in New Issue
Block a user