From 3dd4dd89e025a821e854e853e182942061e96d1e Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Thu, 22 Oct 2026 14:59:13 +0200 Subject: [PATCH] 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 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC --- ...RICE-02-the-verdict-skips-ended-periods.md | 26 +++- goodwe_controller/test_arbitrage.py | 22 ++-- goodwe_controller/test_price02.py | 121 +++++++++++++++++- 3 files changed, 151 insertions(+), 18 deletions(-) diff --git a/goodwe_controller/DECISIONS/PRICE-02-the-verdict-skips-ended-periods.md b/goodwe_controller/DECISIONS/PRICE-02-the-verdict-skips-ended-periods.md index 0ee7b46..0583b76 100644 --- a/goodwe_controller/DECISIONS/PRICE-02-the-verdict-skips-ended-periods.md +++ b/goodwe_controller/DECISIONS/PRICE-02-the-verdict-skips-ended-periods.md @@ -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 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. +- An hourly point that is still current stays in. But an hourly or static plan + 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 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. @@ -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. - 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 every command is +Measured: with price off, every command over the modes-off replay 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" 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 @@ -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, 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. + +## 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. diff --git a/goodwe_controller/test_arbitrage.py b/goodwe_controller/test_arbitrage.py index cfe3f70..ddb21e0 100644 --- a/goodwe_controller/test_arbitrage.py +++ b/goodwe_controller/test_arbitrage.py @@ -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): """The shipped `decide()` over the real year. Returns the ledger, nothing else. - One `TariffPlan` per Brussels day, so "the cheapest hour" means the cheapest - hour of that day - the same shape as section 7's replay, and the same shape - a site with a day-ahead feed actually sees. + The plan is built the way production builds it: one per hour, from that + hour (the refresh floors to the hour) to the end of the Brussels day, + 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 trades, holdings, bad, neg_sells = [], [], [], 0 - for day in days: - plan = TariffPlan( - PriceRead.series(day, PriceProduct.DAY_AHEAD, "generic_dayahead", - day[0].start), None, "provider") + for di, day in enumerate(days): + nxt = days[di + 1] if di + 1 < len(days) else [] 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), peak_guard_active=False, soc=soc, min_spread_eur_kwh=threshold, uncertainty=unc, diff --git a/goodwe_controller/test_price02.py b/goodwe_controller/test_price02.py index 6ceb4b0..7ef5c7e 100644 --- a/goodwe_controller/test_price02.py +++ b/goodwe_controller/test_price02.py @@ -12,8 +12,11 @@ The trading verdict is taken over the price periods that have not ended. 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. +3. An hourly feed and the static day/night tariff: an hourly point that is + 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 opens the gate (`Controller._mode_roles`), nor the Home page verdict. 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)) # --------------------------------------------------------------------------- # -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[1], HR[17] = 0.05, 0.50 # cheapest 03:00, dearest 19:00 HR_PLAN = qplan(HR, minutes=60) @@ -224,9 +227,89 @@ for h in range(0, 24): 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) +check(f"the static day/night plan, built at the instant it is used (so it " + f"holds no ended hour), gives the same verdict with and without `now` " + 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") @@ -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", 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() if fails: print(f"{len(fails)} FAILED: " + "; ".join(fails))