Files
goodwe-addon/goodwe_controller/DECISIONS/PRICE-02-the-verdict-skips-ended-periods.md
T
glenn schrooyenandClaude 3dd4dd89e0
checks / unit (pull_request) Successful in 47m47s
infra-pin / fresh (pull_request) Successful in 1s
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
2026-10-22 14:59:13 +02:00

5.2 KiB

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. 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.
  • 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 reads "now is not the cheapest or the dearest price period". The UI texts 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, 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 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.

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.