PRICE-02 round 3: say that hourly and static plans change too, test plans built off the hour, exact period end and the Home page path, production-shaped real-year replay
Docs and tests only; no change under goodwe_controller/app. Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
This commit is contained in:
co-authored by
Claude
parent
891ed7bdd7
commit
3dd4dd89e0
@@ -15,8 +15,14 @@ ended (`end <= now`, the span `price._cover_table()` gives, as
|
|||||||
|
|
||||||
- Taken in the verdict, not when the plan is built: a plan built at 03:00 is
|
- 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.
|
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. But an hourly or static plan
|
||||||
static day/night plan give the same verdict as before.
|
is rebuilt every 30 min counted from add-on start, so it is usually off the
|
||||||
|
hour and, until the next refresh, holds the hour that has just ended. That
|
||||||
|
hour is dropped too (round 3, owner-accepted 2026-10-22). On a day/night
|
||||||
|
tariff all day hours tie and the old verdict took the first, the ended one,
|
||||||
|
so it paused the trade every hour until the refresh: 91 on/off changes over
|
||||||
|
2 days with the refresh at :10, 4 now (4 before as well, with the refresh on
|
||||||
|
the hour). With the refresh on the hour nothing changes.
|
||||||
- `now` is required: keyword-only, no default, like the other three arguments. A
|
- `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
|
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.
|
defect with them. A test that wants the whole series passes an instant before it.
|
||||||
@@ -26,8 +32,10 @@ ended (`end <= now`, the span `price._cover_table()` gives, as
|
|||||||
that say "cheapest hour" (AUDIT-01 T1-T5) are not touched here.
|
that say "cheapest hour" (AUDIT-01 T1-T5) are not touched here.
|
||||||
- Not changed here: F2 (static profile sampled at HH:00) and F3 (carried value).
|
- 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
|
Measured: with price off, every command over the modes-off replay is
|
||||||
modes-off replay is byte-identical. On the dynamic replay every command is
|
byte-identical. On a static tariff rebuilt on the hour it is too; rebuilt 20
|
||||||
|
min off the hour the commands differ (digest 79f06d6381ff3358 on release/1.0,
|
||||||
|
18de19d9fe919fe5 here), for the reason above. On the dynamic replay every command is
|
||||||
the same too; only a sale's sentence quotes a smaller "above the cheapest"
|
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
|
||||||
@@ -67,3 +75,13 @@ 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,
|
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
|
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.
|
tested the first of them only because its plan began at midnight.
|
||||||
|
|
||||||
|
## Battery throughput (round 3)
|
||||||
|
|
||||||
|
On real 15-minute days the trade runs longer: about 1.5 to 2.5 times the
|
||||||
|
minutes spent charging or discharging, with fewer on/off switches (for example
|
||||||
|
78 -> 68, 92 -> 72). Through a modelled 10 kWh battery that is 27.5 -> 42.6 kWh
|
||||||
|
over 16 days (2026-09-02 to 09-17, a decision every minute, refresh at :00 or
|
||||||
|
:10, tomorrow's prices known always or from 13:00; reviewer's sim). Charge to
|
||||||
|
discharge reversals are unchanged (10 -> 10, 13 -> 13). More cycling is the
|
||||||
|
price of not pausing.
|
||||||
|
|||||||
@@ -2086,18 +2086,24 @@ check(f"BUT NOT ONE OF THE 365 DAYS HAS A NEGATIVE DEAREST HOUR "
|
|||||||
def replay_real(days, threshold, unc, *, soc0=50.0, floor=20.0, ceiling=95.0):
|
def replay_real(days, threshold, unc, *, soc0=50.0, floor=20.0, ceiling=95.0):
|
||||||
"""The shipped `decide()` over the real year. Returns the ledger, nothing else.
|
"""The shipped `decide()` over the real year. Returns the ledger, nothing else.
|
||||||
|
|
||||||
One `TariffPlan` per Brussels day, so "the cheapest hour" means the cheapest
|
The plan is built the way production builds it: one per hour, from that
|
||||||
hour of that day - the same shape as section 7's replay, and the same shape
|
hour (the refresh floors to the hour) to the end of the Brussels day,
|
||||||
a site with a day-ahead feed actually sees.
|
plus the next day from 13:00 Brussels, when the day-ahead prices are
|
||||||
|
published. So a site sees 11 to 35 hours ahead, never a whole day behind
|
||||||
|
it and never a day on its own. (An earlier version built one plan per
|
||||||
|
calendar day with no tomorrow: after the cheap hour no cheap hour was left
|
||||||
|
ahead, and the ledger shrank to a few trades that no real site would see.)
|
||||||
"""
|
"""
|
||||||
soc = soc0
|
soc = soc0
|
||||||
trades, holdings, bad, neg_sells = [], [], [], 0
|
trades, holdings, bad, neg_sells = [], [], [], 0
|
||||||
for day in days:
|
for di, day in enumerate(days):
|
||||||
plan = TariffPlan(
|
nxt = days[di + 1] if di + 1 < len(days) else []
|
||||||
PriceRead.series(day, PriceProduct.DAY_AHEAD, "generic_dayahead",
|
|
||||||
day[0].start), None, "provider")
|
|
||||||
open_buy = None
|
open_buy = None
|
||||||
for p in day:
|
for i, p in enumerate(day):
|
||||||
|
ahead = day[i:] + (nxt if brussels(p.start).hour >= 13 else [])
|
||||||
|
plan = TariffPlan(
|
||||||
|
PriceRead.series(ahead, PriceProduct.DAY_AHEAD,
|
||||||
|
"generic_dayahead", p.start), None, "provider")
|
||||||
d = decide(plan, p.start + timedelta(minutes=30),
|
d = decide(plan, p.start + timedelta(minutes=30),
|
||||||
peak_guard_active=False, soc=soc,
|
peak_guard_active=False, soc=soc,
|
||||||
min_spread_eur_kwh=threshold, uncertainty=unc,
|
min_spread_eur_kwh=threshold, uncertainty=unc,
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ The trading verdict is taken over the price periods that have not ended.
|
|||||||
and a future period are all covered.
|
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
|
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).
|
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
|
3. An hourly feed and the static day/night tariff: an hourly point that is
|
||||||
point that is still current stays in.
|
still current stays in. A plan built off the hour (the refresh runs every
|
||||||
|
30 min from add-on start, so :20 and :50 are normal) no longer aims at the
|
||||||
|
hour that has just ended, so a trade runs through (owner, 2026-10-22), and
|
||||||
|
a period that ends exactly at `now` is over.
|
||||||
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"; `now` is required (no default).
|
5. The reason text says "price period"; `now` is required (no default).
|
||||||
@@ -188,7 +191,7 @@ check("the shipped refresh at 03:20 still holds the 03:00 period, as before, "
|
|||||||
== H3 + timedelta(minutes=15))
|
== H3 + timedelta(minutes=15))
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
print("\n3. hourly feeds and the static tariff do not move")
|
print("\n3. hourly feeds and the static tariff: a current hour stays in")
|
||||||
HR = [0.30] * 48 # 48 hourly points from 02:00
|
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[1], HR[17] = 0.05, 0.50 # cheapest 03:00, dearest 19:00
|
||||||
HR_PLAN = qplan(HR, minutes=60)
|
HR_PLAN = qplan(HR, minutes=60)
|
||||||
@@ -224,9 +227,89 @@ for h in range(0, 24):
|
|||||||
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
|
||||||
check(f"the static day/night plan (built from the current hour, as main.py "
|
check(f"the static day/night plan, built at the instant it is used (so it "
|
||||||
f"does) gives the same verdict with and without `now` at {n_checked} "
|
f"holds no ended hour), gives the same verdict with and without `now` "
|
||||||
f"instants", ok and n_checked == 72)
|
f"at {n_checked} instants", ok and n_checked == 72)
|
||||||
|
|
||||||
|
# 3b. The refresh is NOT on the hour. main.py rebuilds the plan every 30 min
|
||||||
|
# counted from add-on start (TARIFF_REFRESH_S = 1800), so a plan built at :20
|
||||||
|
# or :50 starts at the floored hour and, until the next refresh, still holds
|
||||||
|
# the hour that has just ended. Owner decision 2026-10-22: the verdict skips
|
||||||
|
# that hour too, so a trade runs through instead of pausing until the refresh.
|
||||||
|
print("\n3b. hourly and static plans built off the hour: the ended hour is not the target")
|
||||||
|
HR2 = [0.30] * 48 # hourly from 02:00 Brussels
|
||||||
|
HR2[1], HR2[2], HR2[17] = 0.05, 0.05, 0.50 # cheap 03:00 and 04:00 (a tie), dear 19:00
|
||||||
|
built = H3 + timedelta(minutes=50) # the refresh at :50 floors to 03:00
|
||||||
|
plan_h = TariffPlan(fill_from_yesterday(
|
||||||
|
qplan(HR2, minutes=60).read, yesterday=PriceRead.no_data("none"),
|
||||||
|
since=built, until=built + timedelta(hours=48), stamp=built),
|
||||||
|
None, "provider")
|
||||||
|
now = H3 + timedelta(hours=1, minutes=5) # 04:05, before the next refresh
|
||||||
|
check("the hourly plan built at 03:50 starts at 03:00 and is used at 04:05, so "
|
||||||
|
"it still holds the 03:00 hour, which is over",
|
||||||
|
min(p.start for p in plan_h.read.points) == H3)
|
||||||
|
old_v = plan_h.arbitrage(now=LONG_AGO, **ARGS)
|
||||||
|
new_v = plan_h.arbitrage(now=now, **ARGS)
|
||||||
|
check("without `now` (the old verdict) the buy leg is the ended 03:00 hour "
|
||||||
|
"(first of the tie) and decide() does nothing at 04:05 - the pause",
|
||||||
|
old_v.cheapest.start == H3
|
||||||
|
and arbitrage._act(plan_h, now, old_v, 50.0, 2500.0, 2500.0, 10.0,
|
||||||
|
90.0, 5000.0).action is None)
|
||||||
|
check("with `now` the buy leg is 04:00, the hour in progress, and decide() "
|
||||||
|
"charges at 04:05: the trade runs through",
|
||||||
|
new_v.cheapest.start == H3 + timedelta(hours=1)
|
||||||
|
and dec(plan_h, now).action == CHARGE)
|
||||||
|
|
||||||
|
# The day/night tariff, refresh at :20 of the hour: all 15 day hours tie.
|
||||||
|
b_local = D0 + timedelta(hours=10, minutes=20) # 12:20 Brussels
|
||||||
|
plan_s = static_plan(OPTS, b_local, b_local + timedelta(hours=48), now=b_local)
|
||||||
|
now = D0 + timedelta(hours=11, minutes=5) # 13:05 Brussels
|
||||||
|
old_v = plan_s.arbitrage(now=LONG_AGO, **ARGS)
|
||||||
|
new_v = plan_s.arbitrage(now=now, **ARGS)
|
||||||
|
check("the day/night plan built at 12:20 starts at 12:00; used at 13:05 its "
|
||||||
|
"sell leg without `now` is the ended 12:00 hour (first of the tied day "
|
||||||
|
"hours) and decide() does nothing - the old hourly pause",
|
||||||
|
min(p.start for p in plan_s.read.points) == D0 + timedelta(hours=10)
|
||||||
|
and old_v.dearest.start == D0 + timedelta(hours=10)
|
||||||
|
and arbitrage._act(plan_s, now, old_v, 50.0, 2500.0, 2500.0, 10.0,
|
||||||
|
90.0, 5000.0).action is None)
|
||||||
|
check("with `now` the sell leg is 13:00, the hour in progress, and decide() "
|
||||||
|
"discharges at 13:05: the trade runs through",
|
||||||
|
new_v.dearest.start == D0 + timedelta(hours=11)
|
||||||
|
and dec(plan_s, now).action == DISCHARGE)
|
||||||
|
ok = True
|
||||||
|
for m in (0, 5, 20, 35, 50, 59): # every minute class of the hour
|
||||||
|
t = D0 + timedelta(hours=12, minutes=m) # 14:mm Brussels
|
||||||
|
v = plan_s.arbitrage(now=t, **ARGS)
|
||||||
|
ok = ok and v.dearest.start == D0 + timedelta(hours=12) \
|
||||||
|
and dec(plan_s, t).action == DISCHARGE
|
||||||
|
check("...and through the whole of 14:00-14:59 the target is the 14:00 hour "
|
||||||
|
"and the trade stays on (no pause at the top of the hour)", ok)
|
||||||
|
|
||||||
|
# The exact boundary: a period ends AT its `end`, so at that instant it is over.
|
||||||
|
# (`end >= now` would keep it for one more instant.)
|
||||||
|
print("\n3c. the exact end of a period")
|
||||||
|
at = H3 + timedelta(minutes=15) # 03:15:00 exactly
|
||||||
|
v = BUY_PLAN.arbitrage(now=at, **ARGS)
|
||||||
|
check("at 03:15:00 exactly the 03:00 quarter is over: the buy leg is 03:15 "
|
||||||
|
"and decide() charges", v.cheapest.start == at and dec(BUY_PLAN, at)
|
||||||
|
.action == CHARGE)
|
||||||
|
check("one microsecond earlier the 03:00 quarter is still in view",
|
||||||
|
BUY_PLAN.arbitrage(now=at - timedelta(microseconds=1), **ARGS)
|
||||||
|
.cheapest.start == H3)
|
||||||
|
at_h = H3 + timedelta(hours=1) # 04:00:00 exactly
|
||||||
|
check("at 04:00:00 exactly the 03:00 hour is over (hourly feed), one "
|
||||||
|
"microsecond earlier it is not",
|
||||||
|
plan_h.arbitrage(now=at_h, **ARGS).cheapest.start == at_h
|
||||||
|
and plan_h.arbitrage(now=at_h - timedelta(microseconds=1), **ARGS)
|
||||||
|
.cheapest.start == H3)
|
||||||
|
two = qplan([0.10, 0.20]) # two firm quarters from 02:00
|
||||||
|
check("the Home page's refusal key at the exact end of the first of two "
|
||||||
|
"quarters: one is left, so 'not_firm' (with `end >= now` it would say "
|
||||||
|
"'not_worth'); a microsecond earlier both count",
|
||||||
|
homeview.refusal_key(two, False, D0 + timedelta(minutes=15)) == "not_firm"
|
||||||
|
and homeview.refusal_key(two, False, D0 + timedelta(minutes=15)
|
||||||
|
- timedelta(microseconds=1)) == "not_worth")
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
print("\n4. modes on: an ended period no longer opens the gate")
|
print("\n4. modes on: an ended period no longer opens the gate")
|
||||||
@@ -422,6 +505,32 @@ check(f"alternating hourly prices: {changes(ticks)} action changes over "
|
|||||||
f"2880 ticks - at most one an hour, not one a tick",
|
f"2880 ticks - at most one an hour, not one a tick",
|
||||||
changes(ticks) <= 24 and CHARGE in ticks and DISCHARGE in ticks)
|
changes(ticks) <= 24 and CHARGE in ticks and DISCHARGE in ticks)
|
||||||
|
|
||||||
|
# The Home page path: `homeview.build_plan()` hands its own `now` to
|
||||||
|
# `trade_verdict`. With a `now` floored to the hour it would still aim at the
|
||||||
|
# 03:00 period at 03:20 and draw a trade that the control cycle does not take.
|
||||||
|
print("\n4b. the Home page plan (build_plan) uses the instant it is rendered at")
|
||||||
|
c.o["arbitrage_enabled"] = True
|
||||||
|
c.modes_on = False
|
||||||
|
|
||||||
|
|
||||||
|
def home_at(t):
|
||||||
|
c.tariff_plan, c.tariff_at = GATE_PLAN, t
|
||||||
|
return homeview.build_plan(c, t, False, True)
|
||||||
|
|
||||||
|
|
||||||
|
h_early = home_at(H3 + timedelta(minutes=5))
|
||||||
|
h_late = home_at(H3 + timedelta(minutes=20))
|
||||||
|
trade_kinds = ("grid", "give")
|
||||||
|
check("build_plan at 03:05 (03:00 still current): the spread is real, so the "
|
||||||
|
"page draws a trade (verdict_key 'trade')",
|
||||||
|
h_early["verdict_key"] == "trade"
|
||||||
|
and any(r["kind"] in trade_kinds for r in h_early["runs"]))
|
||||||
|
check("build_plan at 03:20 (only flat periods ahead): no trade on the page, "
|
||||||
|
"the same refusal as trade_verdict gives (verdict_key 'house')",
|
||||||
|
h_late["verdict_key"] == "house"
|
||||||
|
and not any(r["kind"] in trade_kinds for r in h_late["runs"])
|
||||||
|
and h_late["lines"] != h_early["lines"])
|
||||||
|
|
||||||
print()
|
print()
|
||||||
if fails:
|
if fails:
|
||||||
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
||||||
|
|||||||
Reference in New Issue
Block a user