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 new file mode 100644 index 0000000..e95b443 --- /dev/null +++ b/goodwe_controller/DECISIONS/PRICE-02-the-verdict-skips-ended-periods.md @@ -0,0 +1,89 @@ +# 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), 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). Through a modelled 10 kWh battery that is 27.5 -> 42.6 kWh +(refresh at :00, tomorrow always known); 19.6-31.9 -> 43.4-48.7 kWh in the +other three variants. Charge to +discharge reversals are unchanged (10 -> 10, 13 -> 13). More cycling is the +price of not pausing. diff --git a/goodwe_controller/app/arbitrage.py b/goodwe_controller/app/arbitrage.py index da18309..2fbde48 100644 --- a/goodwe_controller/app/arbitrage.py +++ b/goodwe_controller/app/arbitrage.py @@ -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} %" diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index 00bd30f..dfc6940 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -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): """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,24 @@ 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): """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) + 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 +607,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)) diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index c98b91d..bc71984 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -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 diff --git a/goodwe_controller/app/static_tariff.py b/goodwe_controller/app/static_tariff.py index 3cb7a37..013b7ee 100644 --- a/goodwe_controller/app/static_tariff.py +++ b/goodwe_controller/app/static_tariff.py @@ -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,12 +986,25 @@ 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) -> Arbitrage: """May OPT-04 trade, and on what numbers. See the module docstring. - ⚠️ ALL THREE ARGUMENTS ARE REQUIRED AND KEYWORD-ONLY, for one reason + 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. An hourly point that is still + current stays in. + + ⚠️ ALL FOUR ARGUMENTS ARE REQUIRED AND KEYWORD-ONLY, for one reason each, and none of them has a default: + `now` PRICE-02. A caller that could leave it out + would get the whole series back, elapsed + periods included, and the verdict could aim + at a period that is over. A test that wants + the whole series passes an instant before it. + `peak_guard_active` §9.1/§10.1 put peak shaving above arbitrage, and this is how that ordering survives a strategy that has not been written yet: the @@ -1029,6 +1042,12 @@ class TariffPlan: f"{sorted(map(repr, caller_sources))} - a string widens by " f"nothing while reporting that it widened.") + if not isinstance(now, datetime) or now.tzinfo is None \ + or now.utcoffset() is None: + raise TypeError(f"now must be a tz-aware datetime, not {now!r}; " + f"a naive instant is ambiguous for one hour " + f"every October") + if peak_guard_active: return Arbitrage.refuse( "the capacity-tariff peak guard is holding, and §10.1 puts peak " @@ -1056,6 +1075,8 @@ 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 + 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 +1355,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): diff --git a/goodwe_controller/test_arbitrage.py b/goodwe_controller/test_arbitrage.py index 724cc00..4c72d1f 100644 --- a/goodwe_controller/test_arbitrage.py +++ b/goodwe_controller/test_arbitrage.py @@ -109,6 +109,11 @@ from app.validate import validate_startup_config UTC = timezone.utc HERE = os.path.dirname(os.path.abspath(__file__)) + +# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every +# point these fixtures build, so no period has ended and the whole series counts. +from datetime import datetime as _DT, timezone as _TZ # noqa: E402 +LONG_AGO = _DT(2000, 1, 1, tzinfo=_TZ.utc) fails = [] @@ -194,13 +199,22 @@ NO_WEATHER = frozenset({Uncertainty.NO_WEATHER}) def plan_of(prices, *, product=PriceProduct.DAY_AHEAD, start=D0, - provisional=False, provider="generic_dayahead"): + provisional=False, provider="generic_dayahead", days=2): """A TariffPlan over consecutive hourly prices starting at `start`. `provisional` may be a single bool or a per-hour sequence. + + PRICE-02: the verdict skips price periods that have ended, so a plan + probed at 18:30 no longer sees the cheap hour at 03:00 of the same day. A + running site holds 24-48 h, so `days=2` repeats the day (tomorrow's + prices are today's): probes in day one keep their extremes, and the + first cheapest/dearest hour still ahead is today's. Pass `days=1` for a + plan that must be exactly the given hours. """ + prices = list(prices) * days flags = ([bool(provisional)] * len(prices) - if isinstance(provisional, bool) else list(provisional)) + if isinstance(provisional, bool) + else list(provisional) * days) pts = [PricePoint(start=brussels(start + timedelta(hours=i)), eur_per_kwh=round(float(p), 6), provisional=f) for i, (p, f) in enumerate(zip(prices, flags))] @@ -344,7 +358,7 @@ check(f"a realistic Belgian day/night profile has a spread of " f"{_st_spread!r} EUR/kWh - the number the product decision is about", near(_st_spread, 0.08)) _shipped = PRICE_OPTION_DEFAULTS["cycle_cost_threshold_eur"] -_st_verdict = derived(lambda: _st_plan.arbitrage( +_st_verdict = derived(lambda: _st_plan.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER)) check(f"...and at the SHIPPED threshold of {_shipped} EUR/kWh that site does " @@ -361,7 +375,7 @@ check(f"...and it is the SPREAD rung that refuses it, not a flat-tariff or a " check("...while the SAME site trades once the threshold is set below its " "spread, so the refusal above is about the number and not about static " "tariffs being unsupported", - derived(lambda: _st_plan.arbitrage( + derived(lambda: _st_plan.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=_st_spread / widen_factor(NO_WEATHER) - 1e-4, uncertainty=NO_WEATHER).kind) == "allowed") @@ -1644,7 +1658,7 @@ check("a FULLY carried series trades nothing at all, whatever its spread - " # standing if a future caller ever hands `_act` a plan the verdict did not come # from. _probe = _arb._point_at(PLAN, hour(CHEAP_H)) -_verdict = PLAN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, +_verdict = PLAN.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()) check("the point matched for the buy hour IS the verdict's own cheapest point, " "by identity - which is why `_act`'s provisional guard is unreachable " @@ -1665,6 +1679,14 @@ print("4b. §10.2 hysteresis - a price on the boundary cannot flap at 30 s") # threshold repeatedly, hour to hour, which is the shape that makes a # per-tick price-vs-threshold comparison flap. FLAP = [0.20 + (0.101 if i % 2 else 0.0) for i in range(24)] +# PRICE-02: the verdict skips periods that have ended, and this day had ties +# (0.20 at every even hour, 0.301 at every odd one). With ties the first hour +# still ahead is the extreme, so the verdict moves on every hour - once per +# hour, never per tick, which test_price02.py part 6 grades. This check is +# about a verdict that stays put while the prices keep crossing the threshold, +# so the day gets one cheapest and one dearest hour at its end (22: 0.60, +# 23: 0.05), unique and ahead of every probe until they come. +FLAP[22], FLAP[23] = 0.60, 0.05 _flap_plan = plan_of(FLAP) check("the flapping fixture really does straddle the threshold hour by hour - " "every neighbouring pair differs by more than it", @@ -1846,7 +1868,15 @@ def replay(threshold, unc, *, soc0=50.0, floor=20.0, ceiling=95.0): neg_sells = 0 for n in range(365): day0 = REPLAY_START + timedelta(days=n) - prices = synth_day(n) + # PRICE-02: no two hours share a price. The synthetic day is + # symmetric, so it prints the same maximum twice; the verdict now takes + # the first one still AHEAD, where it used to take the first of the + # day (a tie, graded on its own in test_price02.py part 6). So the + # ties are broken by a jitter of at most 5e-5 EUR/kWh that has no + # direction: not later-is-dearer, which would send every day to a + # buy-then-sell and hide the selectivity this replay checks. + prices = [round(p + 1e-6 * ((37 * h + 101 * n) % 50), 6) + for h, p in enumerate(synth_day(n))] plan = plan_of(prices, start=day0) open_buy = None for h in range(24): @@ -2056,18 +2086,26 @@ 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.) + An open buy is kept over midnight: with tomorrow in view most buys are + sold the next day or later (46 of 51 at the shipped threshold). """ 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") - open_buy = None - for p in day: + open_buy = None + for di, day in enumerate(days): + nxt = days[di + 1] if di + 1 < len(days) else [] + 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, @@ -2444,7 +2482,7 @@ check("...and every one of its points is provisional, so firm_points() is " "a fresh observation", isinstance(_t12_fb, TariffPlan) and _t12_fb.provisional is True and _t12_fb.firm_points() == () - and _t12_fb.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, + and _t12_fb.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=NO_WEATHER).kind == "refused") _t12_f.tariff_plan, _t12_f.tariff_at = _t12_good, datetime.now(UTC) - \ timedelta(seconds=1800) @@ -2789,7 +2827,7 @@ check(f"the shipped rule and this one give DIFFERENT points at both probes - " print() print("10b. the buy/sell identity test matches the actual extremum interval") -_qv = QPLAN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=_shipped, +_qv = QPLAN.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER) check("the verdict's cheapest and dearest are the two QUARTERS, not the hours " "containing them - `static_tariff` reduces over every firm point and " @@ -2921,7 +2959,7 @@ check(f"...and its firm half's extremes are quarters {_mix_cheap_i} and " f"{_mix_dear_i}, neither on a whole hour, so the identity test below is " f"about the quarter and not about the hour", (_mix_cheap_i - MIX_N_H) % 4 != 0 and (_mix_dear_i - MIX_N_H) % 4 != 0) -_mv = MIX_PLAN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=_shipped, +_mv = MIX_PLAN.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER) _mix_buy_t = qprobe(_mix_cheap_i, gaps=MIX_GAPS) _mix_sell_t = qprobe(_mix_dear_i, gaps=MIX_GAPS) @@ -2933,14 +2971,19 @@ check("the verdict over a MIXED series still names firm quarters, and " is MIX_PTS[_mix_cheap_i] and derived(lambda: _arb._point_at(MIX_PLAN, _mix_sell_t)) is MIX_PTS[_mix_dear_i]) +# PRICE-02: the same day twice, as a running site holds 24-48 h (see plan_of). +# The probes stay in day one and keep their extremes: once the cheap quarter is +# over, the verdict at the dear quarter needs a cheap one still ahead. +MIX2_PLAN = qplan_of(MIX_PRICES * 2, provisional=MIX_FLAGS * 2, + gaps=MIX_GAPS + [900] + MIX_GAPS) check("...so the mixed day CHARGES and DISCHARGES in its firm quarters, at " "their own prices", - decide(MIX_PLAN, _mix_buy_t, peak_guard_active=False, soc=50.0, + decide(MIX2_PLAN, _mix_buy_t, peak_guard_active=False, soc=50.0, min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER, charge_headroom_w=HEADROOM_CLEAR, charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0, soc_ceiling_pct=95.0).action == CHARGE - and decide(MIX_PLAN, _mix_sell_t, peak_guard_active=False, soc=50.0, + and decide(MIX2_PLAN, _mix_sell_t, peak_guard_active=False, soc=50.0, min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER, charge_headroom_w=HEADROOM_CLEAR, charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0, diff --git a/goodwe_controller/test_home.py b/goodwe_controller/test_home.py index 8a00330..249c653 100644 --- a/goodwe_controller/test_home.py +++ b/goodwe_controller/test_home.py @@ -264,7 +264,7 @@ check("trading on price: a charge block at the cheapest hour (13:00) and a " check("...placed where the control cycle's own verdict puts them: the same " "plan.arbitrage() call on the same plan", c_arb.fresh_tariff_plan(NOW).arbitrage( - peak_guard_active=False, min_spread_eur_kwh=0.05, + now=NOW, peak_guard_active=False, min_spread_eur_kwh=0.05, uncertainty=frozenset({__import__("app.price", fromlist=["x"]) .Uncertainty.NO_WEATHER})) .match(allowed=lambda v: (v.cheapest.start, v.dearest.start), @@ -717,7 +717,7 @@ def _dn(arb): _c_off, _p_off = _dn(False) _c_on, _p_on = _dn(True) -_v_on, _why_on = homeview.trade_verdict(_c_on, _c_on.fresh_tariff_plan(NOW)) +_v_on, _why_on = homeview.trade_verdict(_c_on, _c_on.fresh_tariff_plan(NOW), NOW) check("UX-45: day/night tariff with the stored plan and price trading off: " "24 bars, no cheap or dear mark", len(_p_off) == 24 and {b["kind"] for b in _p_off} == {"mid"}) diff --git a/goodwe_controller/test_modes_p3.py b/goodwe_controller/test_modes_p3.py index 5670c4b..ecb13cd 100644 --- a/goodwe_controller/test_modes_p3.py +++ b/goodwe_controller/test_modes_p3.py @@ -66,22 +66,54 @@ 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. -AT_7A00F12 = { +# 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) changed two things in the trade sentence and nothing +# else: the plan now drops price periods that have ended, so a sale's "N above +# the cheapest" is another number once the first cheap period is over, and the +# sentences say "price period" for "hour". So there are three pins: +# AT_7A00F12_OLD the digests printed on 7a00f12, sentence included. They no +# longer match, and the check below says so: that is what +# makes the mask necessary. +# AT_7A00F12 the same rows through `ident.trade_digest()`, which hides +# exactly those two things (and only for this comparison with +# an older tree). It equals what 7a00f12 gives through it. +# AT_NOW this tree's own digests, sentence included, with nothing +# hidden: any later change to a command, a sentence or a +# figure moves it. The mask cannot hide a future drift. +AT_7A00F12_OLD = { "n1": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c", "n2": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722", - "n1_off": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c", - "n2_off": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722", } +AT_7A00F12 = { + "n1": "33886ef03f364852883f0370230e6e04823fbb46a4704f88ca75bf9082d98c01", + "n2": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f", + "n1_off": "33886ef03f364852883f0370230e6e04823fbb46a4704f88ca75bf9082d98c01", + "n2_off": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f", +} +AT_NOW = { + "n1": "939b3d47bbd64fa50e3d70538f092fb98e5c4bd5ead85a7fc696a1dbb514039e", + "n2": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d", + "n1_off": "939b3d47bbd64fa50e3d70538f092fb98e5c4bd5ead85a7fc696a1dbb514039e", + "n2_off": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d", +} +_cmd_digest = ident.trade_digest _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]}") +_full = {k: ident.digest(v) for k, v in _rows.items()} check("with battery modes off (option absent or false), every command, " - "integrator, allocation and trade sentence equals 7a00f12's, at one " - "inverter and at two", _got == AT_7A00F12) + "integrator, allocation and trade action equals 7a00f12's, at one " + "inverter and at two (the sentence masked as above)", + _got == AT_7A00F12) +check("...the mask is needed and nothing more: unmasked, n1 and n2 differ " + "from 7a00f12's printed digests, and unmasked they equal this tree's " + "own pins, so a later change to any sentence or figure fails here", + all(_full[k] != AT_7A00F12_OLD[k] for k in AT_7A00F12_OLD) + and _full == AT_NOW) _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 " "and a sale", {arbitrage.CHARGE, arbitrage.DISCHARGE} <= _acts) @@ -96,7 +128,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 +144,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 diff --git a/goodwe_controller/test_price.py b/goodwe_controller/test_price.py index f8449be..0cd2da4 100644 --- a/goodwe_controller/test_price.py +++ b/goodwe_controller/test_price.py @@ -99,6 +99,11 @@ from app.static_tariff import ( TariffPlan, parse_taxes_profile, static_status) from app.validate import validate_startup_config + +# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every +# point these fixtures build, so no period has ended and the whole series counts. +from datetime import datetime as _DT, timezone as _TZ # noqa: E402 +LONG_AGO = _DT(2000, 1, 1, tzinfo=_TZ.utc) fails = [] @@ -4518,8 +4523,8 @@ _T11_FLAT = TariffPlan( [PricePoint(_T11_NOW + timedelta(minutes=15 * i), 0.10, False) for i in range(96)], PriceProduct.DAY_AHEAD, "generic_dayahead", _T11_NOW), None, "provider") -_r83 = derived(lambda: _T11_ONEFIRM.arbitrage(**_T11_A9).reason) -_rflat = derived(lambda: _T11_FLAT.arbitrage(**_T11_A9).reason) +_r83 = derived(lambda: _T11_ONEFIRM.arbitrage(now=LONG_AGO, **_T11_A9).reason) +_rflat = derived(lambda: _T11_FLAT.arbitrage(now=LONG_AGO, **_T11_A9).reason) print(f" §8.3 refusal : {_r83[:118]}") print(f" flat refusal : {_rflat[:118]}") # 96 quarter-hourly points span 24 hours; ONE firm point spans one of them. @@ -4828,7 +4833,7 @@ def _t17_verdict(points): read = PriceRead.series(points, PriceProduct.DAY_AHEAD, "t17", datetime.now(UTC)) plan = TariffPlan(read, None, "provider") - return plan.arbitrage(peak_guard_active=False, + return plan.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=PRICE_OPTION_DEFAULTS[ "cycle_cost_threshold_eur"], uncertainty=frozenset()) diff --git a/goodwe_controller/test_price02.py b/goodwe_controller/test_price02.py new file mode 100644 index 0000000..7ef5c7e --- /dev/null +++ b/goodwe_controller/test_price02.py @@ -0,0 +1,538 @@ +"""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: 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). +6. Ties. The verdict takes the first extreme still ahead, where it used to + take the first of the whole plan. On hour-constant 15-minute data (four + equal quarters) that is four acting periods in the cheap hour, not one; + the number of times the action changes does not grow. The same on a real + 15-minute day, and hourly alternating prices change at most once an hour. + +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 +LONG_AGO = datetime(2000, 1, 1, tzinfo=UTC) # before every point: nothing has ended, the old reach +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") + + +ARGS_NO_NOW = dict(ARGS) +ARGS5 = dict(ARGS, min_spread_eur_kwh=0.05, uncertainty=frozenset()) + + +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(now=LONG_AGO, **ARGS).cheapest.start == H3 + and arbitrage._act(BUY_PLAN, H3 + timedelta(minutes=20), + BUY_PLAN.arbitrage(now=LONG_AGO, **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: 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) +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(now=LONG_AGO, **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(now=LONG_AGO, **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 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") +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(now=LONG_AGO, **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) +import inspect # noqa: E402 + +_par = inspect.signature(TariffPlan.arbitrage).parameters +check("`now` is required: keyword-only with no default, like the three " + "arguments beside it, so a caller that forgets it fails loudly instead " + "of getting the elapsed periods back", + _par["now"].default is inspect.Parameter.empty + and _par["now"].kind is inspect.Parameter.KEYWORD_ONLY + and all(p.default is inspect.Parameter.empty + for n, p in _par.items() if n != "self")) +try: + BUY_PLAN.arbitrage(**ARGS_NO_NOW) + _forgot = False +except TypeError: + _forgot = True +check("...and calling without it raises TypeError", _forgot) +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("\n6. ties: the first extreme still ahead") +def acts(plan, instants, ago=False, args=ARGS): + out = [] + for t in instants: + v = plan.arbitrage(now=LONG_AGO if ago else t, **args) + d = arbitrage._act(plan, t, v, 50.0, 2500.0, 2500.0, 10.0, 90.0, + 5000.0) if v.kind == "allowed" else None + out.append(None if d is None else d.action) + return out + + +# Hour-constant 15-minute data: the cheap hour is four equal quarters. +HC = [round(0.40 - 0.0001 * i, 4) for i in range(192)] # falling, no ties +for q in range(4, 8): + HC[q] = 0.05 # 03:00-04:00 four equal quarters +HC[68:72] = [0.50] * 4 # 19:00-20:00 four equal quarters +HC_PLAN = qplan(HC) +probe = [H3 + timedelta(minutes=m) for m in (5, 20, 35, 50, 65)] +old = acts(HC_PLAN, probe, ago=True) +new = acts(HC_PLAN, probe) +check(f"before: four equal cheap quarters, the verdict is the first of them, " + f"so the trade acts in one quarter ({old})", + old == [CHARGE, None, None, None, None]) +check(f"after: the first one still ahead, so it acts in all four and stops " + f"with the hour ({new}) - four acting periods for the same hour, one " + f"block either way", + new == [CHARGE, CHARGE, CHARGE, CHARGE, None]) +changes = lambda a: sum(1 for x, y in zip(a, a[1:]) if x != y) # noqa: E731 +check("...the action changes the same number of times (one start, one stop)", + changes(old) == changes(new) == 1) + +# A real 15-minute day, as production runs it: refresh every 30 min from the +# floored hour, the verdict at 7 min into every period. +REAL = os.path.join(HERE, "scenarios", + "energy-charts-be-price-2026-09-09_2026-09-10-twoday.json") +_d = json.load(open(REAL)) +_pts = [PricePoint(brussels(datetime.fromtimestamp(t, UTC)), p / 1000.0, False) + for t, p in zip(_d["unix_seconds"], _d["price"])] +_feed = PriceRead.series(_pts, PriceProduct.DAY_AHEAD, "generic_dayahead", + _pts[0].start) +_day0 = _pts[0].start.astimezone(UTC) + +def real_day(ago): + out = [] + for q in range(96): + t = _day0 + timedelta(minutes=15 * q + 7) + ref = t.replace(minute=0 if t.minute < 30 else 30, second=0, + microsecond=0) + plan = TariffPlan(fill_from_yesterday( + _feed, yesterday=PriceRead.no_data("none"), since=ref, + until=ref + timedelta(hours=36), stamp=ref), None, "provider") + v = plan.arbitrage(now=LONG_AGO if ago else t, **ARGS5) + out.append(arbitrage._act(plan, t, v, 50.0, 2500.0, 2500.0, 10.0, + 90.0, 5000.0).action + if v.kind == "allowed" else None) + return out + + +r_old, r_new = real_day(True), real_day(False) +n_old = sum(1 for a in r_old if a) +n_new = sum(1 for a in r_new if a) +print(f" real 15-minute day 2026-09-09: acting periods {n_old} -> {n_new}, " + f"action changes {changes(r_old)} -> {changes(r_new)}") +check("a real 15-minute day (energy-charts, 2026-09-09) does not switch more " + "often: the action changes no more often than before", + changes(r_new) <= changes(r_old)) +check("...and every period the old verdict acted in is still acted in, with " + "the quarters it had lost to the elapsed-period tie added", + all(n is not None for o, n in zip(r_old, r_new) if o) and n_new >= n_old) + +# Alternating hourly prices (the flapping day): ties everywhere, so the verdict +# moves on every hour. At most once an hour, never once a tick. +FL = [0.20 + (0.101 if i % 2 else 0.0) for i in range(24)] * 2 +FL_PLAN = qplan(FL, minutes=60) +ticks = [acts(FL_PLAN, [D0 + timedelta(seconds=30 * k)], args=ARGS5)[0] + for k in range(24 * 120)] +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)) + sys.exit(1) +print("all checks passed") diff --git a/goodwe_controller/test_price_forecast.py b/goodwe_controller/test_price_forecast.py index 65d5459..93a1012 100644 --- a/goodwe_controller/test_price_forecast.py +++ b/goodwe_controller/test_price_forecast.py @@ -87,6 +87,11 @@ from app.static_tariff import Arbitrage, TariffPlan UTC = timezone.utc HERE = os.path.dirname(os.path.abspath(__file__)) + +# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every +# point these fixtures build, so no period has ended and the whole series counts. +from datetime import datetime as _DT, timezone as _TZ # noqa: E402 +LONG_AGO = _DT(2000, 1, 1, tzinfo=_TZ.utc) fails = [] @@ -905,7 +910,7 @@ def min_accepted_spread(product, sources, base, provider="generic_dayahead"): lo, hi = 0.0, 4.0 for _ in range(60): mid = (lo + hi) / 2 - v = plan_with_spread(mid, product, provider).arbitrage( + v = plan_with_spread(mid, product, provider).arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=base, uncertainty=sources) if v.kind == "allowed": @@ -1013,7 +1018,7 @@ check(f"the fixture is non-degenerate: the whole-series spread " f"{_all_spread:.6f} and the firm-only spread {_firm_spread:.6f} are " f"different numbers, so a verdict can be traced to one of them", not near(_all_spread, _firm_spread, 1e-6)) -_v = derived(lambda: _mixed.arbitrage(peak_guard_active=False, +_v = derived(lambda: _mixed.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset())) check("a PART-published series still allows arbitrage - §8.3 forbids acting on " @@ -1032,7 +1037,7 @@ check("...and it reports the threshold it cleared and why, rather than leaving " _all_prov = TariffPlan(PriceRead.series(_cheap_stale, PriceProduct.DAY_AHEAD, "generic_dayahead", D0), None, "fallback") -_vp = derived(lambda: _all_prov.arbitrage(peak_guard_active=False, +_vp = derived(lambda: _all_prov.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset())) check("a WHOLLY provisional series - the §11.4 fallback, or a day before any " @@ -1046,23 +1051,23 @@ _one_firm = TariffPlan( "generic_dayahead", D0), None, "provider") check("one firm hour is not enough either: a buy hour and a sell hour must " "BOTH be published before a cycle can be committed", - _one_firm.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, + _one_firm.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused") check("§10.1 still holds above all of it: the peak guard refuses the same " "series that is otherwise allowed", - _mixed.arbitrage(peak_guard_active=True, min_spread_eur_kwh=0.0, + _mixed.arbitrage(now=LONG_AGO, peak_guard_active=True, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused") check("the three questions cannot be skipped: all of peak_guard_active, " "min_spread_eur_kwh and uncertainty are required and keyword-only", - raises(lambda: _mixed.arbitrage(peak_guard_active=False), TypeError) - and raises(lambda: _mixed.arbitrage(peak_guard_active=False, + raises(lambda: _mixed.arbitrage(now=LONG_AGO, peak_guard_active=False), TypeError) + and raises(lambda: _mixed.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0), TypeError) and raises(lambda: _mixed.arbitrage(False, 0.0, frozenset()), TypeError)) check("...nor answered with a truthy 'unknown' or a negative threshold", - raises(lambda: _mixed.arbitrage(peak_guard_active=None, + raises(lambda: _mixed.arbitrage(now=LONG_AGO, peak_guard_active=None, min_spread_eur_kwh=0.0, uncertainty=frozenset()), TypeError) - and raises(lambda: _mixed.arbitrage(peak_guard_active=False, + and raises(lambda: _mixed.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=-0.1, uncertainty=frozenset()), TypeError)) diff --git a/goodwe_controller/test_price_history.py b/goodwe_controller/test_price_history.py index 8e4a743..33827f9 100644 --- a/goodwe_controller/test_price_history.py +++ b/goodwe_controller/test_price_history.py @@ -118,6 +118,11 @@ from app.static_tariff import resolve_tariff UTC = timezone.utc HERE = os.path.dirname(os.path.abspath(__file__)) + +# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every +# point these fixtures build, so no period has ended and the whole series counts. +from datetime import datetime as _DT, timezone as _TZ # noqa: E402 +LONG_AGO = _DT(2000, 1, 1, tzinfo=_TZ.utc) fails = [] _tmpdirs = [] @@ -699,8 +704,8 @@ check(f"the short answer stays a SERIES of what exists - {_pre.read.kind}, " # --- the trade set, which is where the whole thing lands ------------------- # _A9 = dict(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()) -_v2 = _pub.arbitrage(**_A9) -_v1 = _pre.arbitrage(**_A9) +_v2 = _pub.arbitrage(now=LONG_AGO, **_A9) +_v1 = _pre.arbitrage(now=LONG_AGO, **_A9) check("both plans still ALLOW a trade, so the two verdicts below are compared " "against each other and not against a refusal", _v2.kind == "allowed" and _v1.kind == "allowed") @@ -744,8 +749,8 @@ print("2. AC2 - the spread stopped being taken over hours that have gone") _A = dict(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()) -_v_before = _before.arbitrage(**_A) -_v_after = _after.arbitrage(**_A) +_v_before = _before.arbitrage(now=LONG_AGO, **_A) +_v_after = _after.arbitrage(now=LONG_AGO, **_A) check("both plans still ALLOW a trade, so this section compares two verdicts " "rather than a verdict and a refusal", _v_before.kind == "allowed" and _v_after.kind == "allowed") @@ -843,7 +848,7 @@ check("...and no two of them share a whole hour by accident: the fixture puts " _q_spread_want = round(max(p.eur_per_kwh for p in _q_win) - min(p.eur_per_kwh for p in _q_win), 6) -_qv = _q_after.arbitrage(**_A) +_qv = _q_after.arbitrage(now=LONG_AGO, **_A) _q_spread_got = _qv.match(allowed=lambda v: v.spread_eur_kwh, refused=lambda _v: None) check(f"...and the spread is the fixture's own max - min over that window " diff --git a/goodwe_controller/test_price_run_fixes.py b/goodwe_controller/test_price_run_fixes.py index ecd6da1..b98a04c 100644 --- a/goodwe_controller/test_price_run_fixes.py +++ b/goodwe_controller/test_price_run_fixes.py @@ -596,30 +596,59 @@ check("selling is off: no sale on any cycle", # ============================================================================ # print("\n5. modes off: byte-identical to release/1.0 e3e4ba6") -# Printed by tools/opt11p3_identity.py on release/1.0 e3e4ba6 (2026-10-13), -# goodwe-addon-checks:1, with this branch's harness (same API). n1 and n2 are -# also 5a4cdc2's; the lag ones moved with SAFETY-90/91 (n2_lag now equals n2: -# the next-quarter grading removed the edge tick's blindness at N=2). -AT_BASE = { +# Printed on release/1.0 e3e4ba6 (2026-10-13), goodwe-addon-checks:1, with this +# branch's harness (same API). n1 and n2 are also 5a4cdc2's; the lag ones moved +# with SAFETY-90/91 (n2_lag now equals n2: the next-quarter grading removed the +# edge tick's blindness at N=2). +# +# PRICE-02 (2026-10-22) changed two things in the trade sentence and nothing +# else (see test_modes_p3.py part 1): the "N above the cheapest" figure of a +# sale and "hour" -> "price period". Three pins, as there: +# AT_BASE_OLD the digests printed on e3e4ba6, sentence included (no longer +# matched: the mask is needed); +# AT_BASE the same rows through `ident.trade_digest()`, equal to what +# e3e4ba6 gives through it; +# AT_NOW this tree's own digests with nothing hidden. +AT_BASE_OLD = { "n1": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c", "n2": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722", "n1_lag": "86ef0f5ad8da1498ff7dcf59262a177bef847a0b201ce804e947c8eab5f07190", "n2_lag": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722", } -_got = {k: ident.digest(ident.run(**ident.SCENARIOS[k])[0]) - for k in AT_BASE} +AT_BASE = { + "n1": "33886ef03f364852883f0370230e6e04823fbb46a4704f88ca75bf9082d98c01", + "n2": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f", + "n1_lag": "f6c67b351bd732fb69e62cafbfdb2bcff6032f5d8845836659d096b8c677d3c0", + "n2_lag": "666a31f41491c22750720524853d95944d5fba395d1d14040c460d71414b275f", +} +AT_NOW = { + "n1": "939b3d47bbd64fa50e3d70538f092fb98e5c4bd5ead85a7fc696a1dbb514039e", + "n2": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d", + "n1_lag": "78aac892975be81a7ceac0c000ed441a731bd4f42ff9f729996cbee53d6b7f02", + "n2_lag": "c51008213dd739e1e5142c236d9a950aa585992393e4af120086f1ba52ff3e0d", +} +_cmd_digest = ident.trade_digest +_rows5 = {k: ident.run(**ident.SCENARIOS[k])[0] for k in AT_BASE} +_got = {k: _cmd_digest(v) for k, v in _rows5.items()} +_full5 = {k: ident.digest(v) for k, v in _rows5.items()} for _k in AT_BASE: print(f" {_k} {'same' if _got[_k] == AT_BASE[_k] else 'DIFFERS'} " f"{_got[_k][:16]}") check("with battery modes off every command, integrator, allocation and " - "trade sentence equals e3e4ba6's, also when every quarter boundary's " - "cycle still sees the old P1 block", _got == AT_BASE) + "trade action equals e3e4ba6's (the sentence masked as above), also when " + "every quarter boundary's cycle still sees the old P1 block", + _got == AT_BASE) +check("...the mask is needed and nothing more: unmasked, the digests differ " + "from e3e4ba6's printed ones, and equal this tree's own pins, so a " + "later change to any sentence or figure fails here", + all(_full5[k] != AT_BASE_OLD[k] for k in AT_BASE_OLD) + and _full5 == AT_NOW) _real_project = tariff.PeakTracker.project tariff.PeakTracker.project = staticmethod( lambda a, now, roll=False: _real_project(a, now, roll=True)) try: - _moved = ident.digest(ident.run(**ident.SCENARIOS["n1_lag"])[0]) \ + _moved = _cmd_digest(ident.run(**ident.SCENARIOS["n1_lag"])[0]) \ != AT_BASE["n1_lag"] finally: tariff.PeakTracker.project = _real_project diff --git a/goodwe_controller/test_price_service.py b/goodwe_controller/test_price_service.py index 063337f..bfe94ae 100644 --- a/goodwe_controller/test_price_service.py +++ b/goodwe_controller/test_price_service.py @@ -1178,7 +1178,7 @@ async def _modes_plan(): c = W.ctl plan = c.fresh_tariff_plan(t) verdict = plan.arbitrage( - peak_guard_active=False, min_spread_eur_kwh=0.05, + now=t, peak_guard_active=False, min_spread_eur_kwh=0.05, uncertainty=frozenset()) return verdict.match(allowed=lambda v: ("allowed", v.spread_eur_kwh), refused=lambda v: ("refused", v.reason)) diff --git a/goodwe_controller/test_static_tariff.py b/goodwe_controller/test_static_tariff.py index cc9f2d1..2537ac3 100644 --- a/goodwe_controller/test_static_tariff.py +++ b/goodwe_controller/test_static_tariff.py @@ -82,6 +82,11 @@ from app.validate import validate_startup_config HERE = Path(__file__).resolve().parent UTC = timezone.utc + +# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every +# point these fixtures build, so no period has ended and the whole series counts. +from datetime import datetime as _DT, timezone as _TZ # noqa: E402 +LONG_AGO = _DT(2000, 1, 1, tzinfo=_TZ.utc) fails = [] @@ -333,8 +338,8 @@ check(f"...while the two-period spread is the day/night difference " f"{_spread_two}, re-derived here as {DAY_P - NIGHT_P:.4f}", near(_spread_two, DAY_P - NIGHT_P, 1e-9)) -_v_fixed = p_fixed.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()) -_v_two = p_two.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()) +_v_fixed = p_fixed.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()) +_v_two = p_two.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()) check("a fully-fixed tariff REFUSES arbitrage", _v_fixed.kind == "refused") check("...for the true reason - the series is flat, so there is nothing to buy " "low and sell high, not because a FIXED enum was matched on", @@ -354,7 +359,7 @@ _flat_dayahead = TariffPlan( None, "provider") check("a DAY_AHEAD series that happens to be flat is refused too, so the rule " "is arithmetic and not a product label", - _flat_dayahead.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused") + _flat_dayahead.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused") # The numbers an optimiser needs exist ONLY on the permitting variant. check("the allowed verdict carries the spread, the cheapest hour and the " @@ -379,16 +384,16 @@ check("...and there is no falsy variant, so `if verdict:` cannot look like a " # §10.1 priority 2 over priority 3, expressed so OPT-04 cannot skip it. check("peak shaving outranks arbitrage: the SAME allowed plan is refused when " "the peak guard is holding", - p_two.arbitrage(peak_guard_active=True, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused") + p_two.arbitrage(now=LONG_AGO, peak_guard_active=True, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused") check("...and the reason names the ordering rather than the tariff", - "priority 2" in p_two.arbitrage(peak_guard_active=True, min_spread_eur_kwh=0.0, uncertainty=frozenset()).reason) + "priority 2" in p_two.arbitrage(now=LONG_AGO, peak_guard_active=True, min_spread_eur_kwh=0.0, uncertainty=frozenset()).reason) check("...and the question cannot be skipped: peak_guard_active is required " "and keyword-only, so a spread is not obtainable without answering it", raises(lambda: p_two.arbitrage(), TypeError) and raises(lambda: p_two.arbitrage(False), TypeError)) check("...nor answered with a truthy 'unknown'", - raises(lambda: p_two.arbitrage(peak_guard_active=None), TypeError) - and raises(lambda: p_two.arbitrage(peak_guard_active="no"), TypeError)) + raises(lambda: p_two.arbitrage(now=LONG_AGO, peak_guard_active=None), TypeError) + and raises(lambda: p_two.arbitrage(now=LONG_AGO, peak_guard_active="no"), TypeError)) # --------------------------------------------------------------------------- # @@ -524,7 +529,7 @@ check("...and its underlying read has no .points at all, so `min(read.points)` " "cannot run against it", raises(lambda: _none.read.points, Absence)) check("...and arbitrage is refused, per §11.4", - _none.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused" + _none.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused" and arbitrage_allowed(_none.read) is False) check("...and static_status() says so in words rather than reporting a price", static_status(opts(None), THU) == (False, "No fixed tariff set")) @@ -776,12 +781,12 @@ check("...marked PROVISIONAL, which is the one flag that differs", _fb.provisional and plan_is_provisional(_fb.read)) check("...and arbitrage is refused on it, per §11.4, even though a full series " "exists and has a real spread", - _fb.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused" + _fb.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused" and near(_fb.spread_eur_kwh(), DAY_P - NIGHT_P)) check("...while the SAME profile as the configured product allows it - so the " "refusal is about provisionality and not about the profile", static_plan(opts(TWO_PERIOD), THU, THU + timedelta(days=1) - ).arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "allowed") + ).arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "allowed") check("...and the provisional plan has no cheap window either: §8.3 forbids an " "irreversible action on a provisional price, and a grid top-up is one", not any(_fb.cheap_now(at_local(THU, h)) for h in range(24))) @@ -821,8 +826,8 @@ check("a live feed is used in preference to the static profile", and _live_plan.product is PriceProduct.DAY_AHEAD) check("...and arbitrage is allowed on it, with the spread derived from the " "feed rather than from the profile", - _live_plan.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "allowed" - and near(_live_plan.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).spread_eur_kwh, + _live_plan.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "allowed" + and near(_live_plan.arbitrage(now=LONG_AGO, peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).spread_eur_kwh, 0.023, 1e-9)) @@ -1036,7 +1041,7 @@ check(f"each tiny-spread probe really carries the spread it is named for, and " all(near(p.spread_eur_kwh(), d, 1e-12) and len([q for q in p.read.points if not q.provisional]) == 2 for d, p in _tiny9)) -_tinyv9 = [(d, rung9(p.arbitrage(**_A9))) for d, p in _tiny9] +_tinyv9 = [(d, rung9(p.arbitrage(now=LONG_AGO, **_A9))) for d, p in _tiny9] check(f"TEST-16 AC1: a spread that is SMALL BUT REAL is ALLOWED at " f"min_spread_eur_kwh=0.0 - down to 0.000001 EUR/kWh, so the flat rung is " f"`spread == 0.0` and not `spread <= epsilon` for any epsilon " @@ -1045,7 +1050,7 @@ check(f"TEST-16 AC1: a spread that is SMALL BUT REAL is ALLOWED at " check("...and the exactly flat series on the OTHER side of that boundary is " "still refused, and refused BY THE FLAT RUNG - without this the sweep " "above would also pass for a rung that had been deleted outright", - rung9(plan9([0.20000, 0.20000]).arbitrage(**_A9)) == "flat") + rung9(plan9([0.20000, 0.20000]).arbitrage(now=LONG_AGO, **_A9)) == "flat") _q9 = plan9([0.20000, 0.2000005]) check(f"...and a difference below the rung's own 6-decimal rounding is flat BY " f"CONSTRUCTION and says so: the raw difference is " @@ -1056,10 +1061,10 @@ check(f"...and a difference below the rung's own 6-decimal rounding is flat BY " f"to bisect further", not near(_q9.spread_eur_kwh(), 0.0, 1e-9) and near(round(_q9.spread_eur_kwh(), 6), 0.0, 1e-12) - and rung9(_q9.arbitrage(**_A9)) == "flat") + and rung9(_q9.arbitrage(now=LONG_AGO, **_A9)) == "flat") # --- and what D's survival actually costs: the sentence, not the verdict ----- -_lie9 = plan9([0.20000, 0.20050]).arbitrage(**_A9) +_lie9 = plan9([0.20000, 0.20050]).arbitrage(now=LONG_AGO, **_A9) check(f"TEST-16 AC4: the 0.0005 verdict carries the real spread rather than a " f"flatness claim about a tariff that is not flat - the false sentence is " f"what a loosened rung ships to the operator while the outcome at a " @@ -1085,19 +1090,19 @@ check(f"the one-firm probe is well formed: exactly ONE firm hour out of three, " check("TEST-16 AC2: one firm hour is refused BY §8.3's FIRM-PAIR RUNG, named " "in the reason - not by the flat rung that would also have refused it. " "This is what takes `len(firm) < 2` -> `< 1` red on its own", - rung9(_one9.arbitrage(**_A9)) == "firm-pair") + rung9(_one9.arbitrage(now=LONG_AGO, **_A9)) == "firm-pair") check("...and the sentence is §8.3's in full, so the operator is told the " "series is half-published rather than told it is fixed-price", - "§8.3" in _one9.arbitrage(**_A9).reason - and "only 1 of 3" in _one9.arbitrage(**_A9).reason - and "flat" not in _one9.arbitrage(**_A9).reason) + "§8.3" in _one9.arbitrage(now=LONG_AGO, **_A9).reason + and "only 1 of 3" in _one9.arbitrage(now=LONG_AGO, **_A9).reason + and "flat" not in _one9.arbitrage(now=LONG_AGO, **_A9).reason) # The rung pinned from ABOVE as well. Without this, `len(firm) < 3` - the same # +0 B edit in the other direction - would pass everything above. _two9 = plan9([0.20, 0.40, 0.10], [False, False, True]) check("...and TWO firm hours are ALLOWED on the identical series shape, so the " "rung is pinned from above too and `< 3` is red as well as `< 1`", - rung9(_two9.arbitrage(**_A9)) == "allowed" - and near(_two9.arbitrage(**_A9).spread_eur_kwh, 0.20)) + rung9(_two9.arbitrage(now=LONG_AGO, **_A9)) == "allowed" + and near(_two9.arbitrage(now=LONG_AGO, **_A9).spread_eur_kwh, 0.20)) # ...and from below, where the failure is not a wrong sentence but a crash: # `max()` over an empty `firm` raises ValueError, so `< 0` does not degrade the # reason, it takes the verdict away entirely. @@ -1105,7 +1110,7 @@ _zero9 = plan9([0.20, 0.40, 0.10], [True, True, True]) check("...and a FULLY provisional series is refused by the same rung and does " "not raise - `max()` over an empty firm set is a ValueError, so this row " "is what makes `len(firm) < 0` red", - rung9(derived(lambda: _zero9.arbitrage(**_A9))) == "firm-pair") + rung9(derived(lambda: _zero9.arbitrage(now=LONG_AGO, **_A9))) == "firm-pair") # --- AC3: the two rungs do not mask each other ------------------------------ # ⚠️ THE POINT OF THE TICKET, AND IT IS A PROPERTY OF THE TABLE, NOT OF A ROW. @@ -1130,7 +1135,7 @@ _TABLE9 = ( ("two firm hours of three", _two9, "allowed"), ("no firm hours at all", _zero9, "firm-pair"), ) -_got9 = [(name, want, rung9(derived(lambda p=pl: p.arbitrage(**_A9)))) +_got9 = [(name, want, rung9(derived(lambda p=pl: p.arbitrage(now=LONG_AGO, **_A9)))) for name, pl, want in _TABLE9] check(f"TEST-16 AC3: each stimulus is answered by the rung that OWNS it, and " f"by no other - the flat rung is asked a question the firm-pair rung " diff --git a/goodwe_controller/tools/opt11p3_identity.py b/goodwe_controller/tools/opt11p3_identity.py index c2eb29e..fd9760c 100644 --- a/goodwe_controller/tools/opt11p3_identity.py +++ b/goodwe_controller/tools/opt11p3_identity.py @@ -17,6 +17,7 @@ test_modes_p3.py part 1 holds this tree to the digests printed on 7a00f12. import functools import hashlib import os +import re import sys import tempfile import time @@ -117,6 +118,31 @@ def digest(rows) -> str: return hashlib.sha256(repr(rows).encode()).hexdigest() +def _trade_words(text): + """PRICE-02: the trade sentence as release/1.0 before it printed it. The + plan now drops price periods that have ended, so a sale's "above the + cheapest" figure is a different number once the first cheap period is + over, and the sentences say "price period" where they said "hour". Both + maps are applied to both sides, so only those two things are hidden. + + ONLY FOR COMPARING WITH A TREE OLDER THAN PRICE-02. A pin of this tree + itself uses `digest()`, with nothing hidden.""" + text = text.replace("this hour is neither the buy hour nor the sell hour", + "now is not the cheapest or the dearest price period") + text = re.sub(r"[0-9]+\.[0-9]+ above the cheapest", + "N above the cheapest", text) + return text.replace("firm hour at", "firm price period at") + + +def trade_digest(rows) -> str: + """`digest()` with the trade sentence passed through `_trade_words()`: + every command, integrator and allocation, and the action, watts and the + rest of the sentence, byte for byte.""" + return digest([r[:-1] + (None if r[-1] is None else + (r[-1][0], r[-1][1], _trade_words(r[-1][2])),) + for r in rows]) + + def digests() -> dict: return {k: digest(run(**v)[0]) for k, v in SCENARIOS.items()}