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
checks / unit (pull_request) Successful in 47m47s
infra-pin / fresh (pull_request) Successful in 1s

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:
glenn schrooyen
2026-10-22 14:59:13 +02:00
co-authored by Claude
parent 891ed7bdd7
commit 3dd4dd89e0
3 changed files with 151 additions and 18 deletions
@@ -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.
+14 -8
View File
@@ -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,
+115 -6
View File
@@ -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))