diff --git a/goodwe_controller/DECISIONS/TARIFF-24-modes-need-the-tariff-task.md b/goodwe_controller/DECISIONS/TARIFF-24-modes-need-the-tariff-task.md index e6e6f8a..322049a 100644 --- a/goodwe_controller/DECISIONS/TARIFF-24-modes-need-the-tariff-task.md +++ b/goodwe_controller/DECISIONS/TARIFF-24-modes-need-the-tariff-task.md @@ -54,8 +54,10 @@ No forecast, or a stale or unusable one: 1.5 x, as before. It is read in three places that must agree: the run plan (`_price_runs()`), the charge gate (`_mode_roles()`) and Home's mirror (`homeview.trade_verdict()`). The forecast is read at most every 60 s and the plan key already holds "forecast or not", so -the gap follows in the cycle the read changes, both ways. A run already under -way is kept to its end (`priceruns.keep_started()`), as for any change. +the gap follows in the cycle the read changes, both ways, with one exception: +a run planned with a forecast that is under way keeps that gap to its end +(`Controller.weather_held()`), so a sensor that goes unavailable does not stop +or restart a charge. The wider gap applies from the next run. **Not changed.** Modes off (the site trade) keeps the doubt always. The shipped cost 0.10 still needs a gap of 0.10 even with a forecast: a 0.08 diff --git a/goodwe_controller/app/arbitrage.py b/goodwe_controller/app/arbitrage.py index 86ab97e..eb4e0c6 100644 --- a/goodwe_controller/app/arbitrage.py +++ b/goodwe_controller/app/arbitrage.py @@ -267,15 +267,17 @@ def threshold_eur_kwh(opts: dict) -> float: return v -def weather_uncertainty(modes_on: bool, solar_fc) -> frozenset: +def weather_uncertainty(modes_on: bool, solar_fc, held: bool = False) -> frozenset: """The "no weather" doubt (x1.5) a trade must clear. TARIFF-24. Battery modes read a solar forecast; with one read (`solar_fc` not None) the - doubt is gone. Everything else, and the modes without a forecast, keep it. - One rule for the run plan, the charge gate and Home's mirror of it. See + doubt is gone, and `held` (a run planned with a forecast is under way, see + `Controller.weather_held()`) keeps it gone until that run ends. Everything + else, and the modes without a forecast, keep it. One rule for the run plan, + the charge gate and Home's mirror. See DECISIONS/TARIFF-24-modes-need-the-tariff-task.md. """ - if modes_on and solar_fc is not None: + if modes_on and (solar_fc is not None or held): return frozenset() return frozenset({Uncertainty.NO_WEATHER}) diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index 2a7cbe7..03e98b9 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -279,7 +279,9 @@ def trade_verdict(c, plan, now): min_spread_eur_kwh=arbitrage.threshold_eur_kwh(c.o), uncertainty=arbitrage.weather_uncertainty( bool(getattr(c, "modes_on", False)), - getattr(c, "solar_fc", None)), + getattr(c, "solar_fc", None), + bool(getattr(c, "weather_held", + lambda _n: False)(now))), now=now) return verdict.match( allowed=lambda v: (v, "allowed"), diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 7283bcb..8902a73 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -960,6 +960,9 @@ class Controller: # and whether last tick filed a mode trade (a run edge reseeds). self.mode_runs = None self._runs_key = None + # TARIFF-24: the end of the run that was under way while a forecast + # was read; until then losing the forecast does not widen the gap. + self._wx_free_to = None self.mode_tick = None self._run_done = {} self._mode_trade_was = False @@ -2226,6 +2229,28 @@ class Controller: probing=probing)) return out + def _wx_track(self, now) -> None: + """TARIFF-24, once per cycle, before the plan: with a forecast read, + remember when the run now under way (cheap, give or sell) ends.""" + if self.solar_fc is None: + return + rp = self.mode_runs + ends = [] + if rp is not None and rp.on: + ends = [r.end for r in (rp.cheap, rp.sell) + if r is not None and r.holds(now)] + g = rp.give_at(now) + if g is not None: + ends.append(g.end) + self._wx_free_to = max(ends) if ends else None + + def weather_held(self, now) -> bool: + """TARIFF-24: no forecast now, but a run planned with one is under + way: it keeps its gap to its end. The wider gap applies from the next + run. Pure; Home asks the same (`homeview.trade_verdict()`).""" + return (self.solar_fc is None and self._wx_free_to is not None + and now < self._wx_free_to) + def _price_runs(self, now, packs, floor_pct, ceiling_pct): """The kept run plan, rebuilt at most once per price slot (15 min) or when the prices or a battery's choice change; a run under way is @@ -2239,9 +2264,11 @@ class Controller: slot = int(now.timestamp() // 900) key = (id(plan), slot, tuple((p.name, p.use, p.mode, p.sell) for p in packs)) + held = self.weather_held(now) if self.fc_entity is not None: - # P4: a forecast arriving or going rebuilds the plan at once. - key += (self.solar_fc is None,) + # P4: a forecast arriving or going rebuilds the plan at once (and + # so does the end of a run that held the gap, TARIFF-24). + key += (self.solar_fc is None, held) if key != self._runs_key: done_c = self._run_done.get("charge", (None, set()))[1] done_s = self._run_done.get("sell", (None, set()))[1] @@ -2250,7 +2277,7 @@ class Controller: required = price_required_spread( arbitrage.threshold_eur_kwh(self.o), price_uncertainty_of(plan.read.product) - | arbitrage.weather_uncertainty(True, self.solar_fc)) + | arbitrage.weather_uncertainty(True, self.solar_fc, held)) ref = self._ref_cheap if ref is not None and now - ref.end > REF_CHEAP_KEEP: ref = self._ref_cheap = None @@ -2319,6 +2346,7 @@ class Controller: quarter), exactly the gate `arbitrage.decide()` has.""" floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o) packs = self._mode_packs(maint_results) + self._wx_track(now) runs = self._price_runs(now, packs, floor_pct, ceiling_pct) guard = arbitrage.guard_state(self.peak_guard) plan = self.fresh_tariff_plan(now) @@ -2329,7 +2357,8 @@ class Controller: return plan.arbitrage( peak_guard_active=active, min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o), - uncertainty=arbitrage.weather_uncertainty(True, self.solar_fc), + uncertainty=arbitrage.weather_uncertainty( + True, self.solar_fc, self.weather_held(now)), 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: diff --git a/goodwe_controller/test_modes_p4.py b/goodwe_controller/test_modes_p4.py index c31e4ee..39ea544 100644 --- a/goodwe_controller/test_modes_p4.py +++ b/goodwe_controller/test_modes_p4.py @@ -614,6 +614,35 @@ check("sunny: a day plan is made, and says sunny", for r in rows)) check("sunny: no grid charge at all", not charge) +# TARIFF-24 round 2: the same day at the cycle cost 0.20 itself. With the +# forecast read the gap needed is 0.20 (not 1.5 x = 0.30), so the midday +# quarter 0.339 against the evening 0.551 (gap 0.21, between 1x and 1.5x) is +# a planned top-up for B (Leftover solar) up to its planned level. +rows_g, st_, c_g = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0}) +charge_g = invariants("gap between 1x and 1.5x", rows_g, c_g) +_first_g = nth(charge_g, 0, "a charge cycle") +_gaps = [min(g.mean_eur_kwh for g in r["runs"].give) + - r["runs"].cheap.mean_eur_kwh for r in charge_g + if r["runs"] is not None and r["runs"].give] +print(f" gap 0.20 <= gap < 0.30: {len(charge_g)} charge cycles, run gap " + f"{min(_gaps, default=0):.3f}, B to " + f"{max((r['soc']['b'] for r in rows_g), default=0):.1f} %") +check("a forecast day whose price gap lies between 1x and 1.5x the cycle " + "cost (0.20 < 0.21 < 0.30) plans a cheap run: B charges from the grid " + "inside it, only B, and only up to its planned level", + present(_first_g) and len(_gaps) == len(charge_g) + and all(0.20 <= g < 0.30 for g in _gaps) + and all(r["runs"].cheap.holds(r["now"]) for r in charge_g) + and all(r["roles"][B] == modetrade.CHARGE for r in charge_g) + and all(r["roles"][A] != modetrade.CHARGE for r in charge_g) + and max(r["soc"]["b"] for r in rows_g) <= 45.64 + 2.0) +check("...and the same day with no forecast read does not (it needs 0.30): " + "the old gate, kept for a day without a forecast", + not [r for r in run_day(SF_LS, fc=None, + soc0={"a": 60.0, "b": 30.0})[0] + if r["action"] == arbitrage.CHARGE + and r["now"].astimezone(L).hour == 12]) + # Dark: 1 kWh. B buys the evening's shortfall only, in the cheap run. rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0}) charge = invariants("dark", rows, c) diff --git a/goodwe_controller/test_tariff24_weather.py b/goodwe_controller/test_tariff24_weather.py index 9af4f52..e0ec286 100644 --- a/goodwe_controller/test_tariff24_weather.py +++ b/goodwe_controller/test_tariff24_weather.py @@ -14,8 +14,10 @@ DECISIONS/TARIFF-24-modes-need-the-tariff-task.md. 0.105 and the reason is `not_worth` / "Prices differ too little". 3. A dynamic day with a 0.12 gap at cost 0.10: on with a forecast, not worth without. -4. The forecast going and coming mid-run: the gate follows in the same cycle - the forecast read changes, both ways. +4. The forecast coming (the gate follows in the same cycle) and going mid-run: + a run under way keeps its gap to its end, the next run needs the wider one; + a forecast unavailable for one or two reads mid-run does not stop or + restart the charge. 5. Home agrees with control in each case (Batteries Why, Home line, and `trade_verdict()`, Home's mirror of the gate). @@ -69,6 +71,7 @@ NIGHT = datetime(2026, 10, 30, 23, 0, tzinfo=L).astimezone(timezone.utc) CHOICE = {"a": (True, "leftover_solar", False), "b": (True, "cheap_hours", False)} FC_TODAY, FC_TOMORROW = "sensor.fc_today", "sensor.fc_tomorrow" +TARIFF_REFRESH_S = m.TARIFF_REFRESH_S def opts(cost, *, arb=False, sensors=True): @@ -108,8 +111,23 @@ def forecast_mode(mode, t, now): return at +def take(c, now): + """What Home and the Batteries screen say now, and Home's mirror of the + gate (it reads the trade switch, so ask it as a site that has it on next + to the modes).""" + keep = c.o + c.o = dict(keep, arbitrage_enabled=True) + try: + mirror, why = homeview.trade_verdict(c, c.fresh_tariff_plan(now), now) + finally: + c.o = keep + return dict(home=homeview.build_plan(c, now, False, True), + cards=homeview.battery_rows(c, False, True), + mirror=mirror, mirror_why=why) + + def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0, - house_w=300.0, soc0=None): + house_w=300.0, soc0=None, snap_at=()): """-> (controller, rows). `points`: a feed-shaped plan injected every cycle (no task); else the static profile via the real tariff task.""" store = rp._Store() @@ -118,6 +136,7 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0, ("pack_sell", sell)): store.data[unit_key(f"inverter-{u}", k)] = val rows = [] + snaps = {} class H(rp.Hass): async def state(self, e): @@ -163,9 +182,19 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0, "generic_dayahead", now), None, "provider") c.tariff_at = now - elif t == 0.0: + elif t == 0.0 or t % TARIFF_REFRESH_S == 0.0: + # The task's own 30 min wait is wall clock, so the + # simulated clock restarts it: one refresh per period. + if task is not None and t > 0.0: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + task = asyncio.create_task(c.run_tariff_refresh()) for _ in range(500): - if c.tariff_plan is not None: + if c.tariff_plan is not None and ( + c.tariff_at is not None and c.tariff_at >= now): break await asyncio.sleep(0) imp, exp = split_signed(hass.grid()) @@ -182,18 +211,17 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0, reason=None if rp_ is None else rp_.reason, roles=dict(c.mode_tick.roles) if c.mode_tick else {}, why=None if c.mode_tick is None else c.mode_tick.why, - charging=hass.plants["b"].p < -200.0)) + charging=hass.plants["b"].p < -200.0, + soc_b=hass.plants["b"].soc, + cheap_end=None if rp_ is None or rp_.cheap is None + else rp_.cheap.end)) + if t in snap_at: + snaps[t] = take(c, now) t += tick - c.home_plan = homeview.build_plan(c, clock[0], False, True) - c.cards = homeview.battery_rows(c, False, True) - # Home's mirror of the gate (it reads the trade switch, so ask it - # as a site that has it on next to the modes). - c2 = c - c2.o = dict(c.o, arbitrage_enabled=True) - c.mirror, c.mirror_why = homeview.trade_verdict( - c2, c.fresh_tariff_plan(clock[0]), clock[0]) - c.o = dict(c.o, arbitrage_enabled=o.get("arbitrage_enabled", - False)) + last = take(c, clock[0]) + c.home_plan, c.cards = last["home"], last["cards"] + c.mirror, c.mirror_why = last["mirror"], last["mirror_why"] + c.snaps = snaps finally: if task is not None: task.cancel() @@ -274,8 +302,9 @@ check("without one it is not_worth (needs 0.15 > 0.12) and says so", and why_of(cn, B) == "no_spread") # --------------------------------------------------------------------------- # -print("\n4. the forecast going and coming mid-run") +print("\n4. the forecast going and coming mid-run (a run under way keeps its gap)") CUT = 600.0 # 10 min in +SOC = {"a": 40.0, "b": 60.0} # B's cheap run is then about 2 h def going(t): @@ -286,8 +315,73 @@ def coming(t): return "gone" if t < CUT else "fresh" -cg, rg = drive(opts(0.07), fc=going, minutes=25) +def flap(n_reads): + """Unavailable for `n_reads` forecast reads (60 s apart) from CUT.""" + return lambda t: "gone" if CUT <= t < CUT + 60.0 * n_reads else "fresh" + + +MID, AFTER = 3000.0, 7800.0 +cg, rg = drive(opts(0.07), fc=going, minutes=135, soc0=SOC, + snap_at=(MID, AFTER)) cc, rc = drive(opts(0.07), fc=coming, minutes=25) +_end = nth([r["cheap_end"] for r in rg if r["cheap_end"] is not None], 0, + "the cheap run's end") +_end_t = (_end - NIGHT).total_seconds() if present(_end) else 1e9 +lost = [r for r in rg if r["fc_none"]] +print(f" going: cheap run ends at t={_end_t:.0f} s; forecast lost from " + f"t={lost[0]['t'] if lost else None}") +check("going: the run planned with the forecast ends before the snapshot " + "taken after it (the scenario is long enough)", + present(_end) and CUT + 300.0 < _end_t < AFTER - 300.0 and bool(lost) + and lost[0]["t"] - CUT <= 60.0) +check("going: the forecast read is lost mid-run and the run under way KEEPS " + "its gap: the plan stays on and B keeps charging, cycle after cycle, " + "until B is full or the run ends", + all(r["runs_on"] is True and (r["roles"].get(B) == modetrade.CHARGE + or r["soc_b"] >= 94.0) + for r in rg if r["fc_none"] and r["t"] < _end_t - 60.0) + and all(r["charging"] or r["soc_b"] >= 94.0 for r in rg + if 60.0 <= r["t"] < _end_t - 60.0) + and max(r["soc_b"] for r in rg if r["fc_none"]) >= 90.0) +check("going: from the run's end the wider gap is back: the next run is " + "not_worth (needs 0.105 at 0.07), no new run, and no grid charge", + all(r["runs_on"] is False and r["reason"] == "not_worth" + for r in rg if r["t"] >= _end_t + 30.0) + and not any(r["charging"] for r in rg if r["t"] >= _end_t + 60.0)) +_sm, _sa = cg.snaps.get(MID), cg.snaps.get(AFTER) +check("going, Home agrees mid-run: its mirror still allows, the Batteries " + "screen says it charges from the grid, and Home does not say the gap " + "is too small", + _sm is not None and _sm["mirror"] is not None + and first((b for b in _sm["cards"] if b["name"] == B), + "a card")["why_key"] == "grid_charge" + and not any("price gap is too small" in ln + for ln in _sm["home"]["lines"])) +check("going, Home agrees after the run: its mirror refuses, the Batteries " + "screen says 'differ too little' and Home says the gap is too small", + _sa is not None and _sa["mirror"] is None + and first((b for b in _sa["cards"] if b["name"] == B), + "a card")["why_key"] == "no_spread" + and any("price gap is too small" in ln for ln in _sa["home"]["lines"])) +for n in (1, 2): + cf, rf = drive(opts(0.07), fc=flap(n), minutes=30, soc0=SOC, + snap_at=(CUT + 30.0,)) + gone = [r for r in rf if r["fc_none"]] + print(f" flap, {n} read(s) lost: {len(gone)} cycles without a " + f"forecast; B charging in {sum(r['charging'] for r in rf)} of " + f"{len(rf)}") + check(f"flap: the forecast is unavailable for {n} read(s) mid-run: the " + f"plan stays on and B charges without a break (no stop, no " + f"restart)", + bool(gone) and all(r["runs_on"] is True for r in rf) + and all(r["charging"] and r["roles"].get(B) == modetrade.CHARGE + for r in rf if r["t"] >= 60.0)) + sf = cf.snaps.get(CUT + 30.0) + check(f"flap, {n} read(s): Home agrees while the read is lost (mirror " + f"allows, Batteries 'charges from the grid')", + sf is not None and sf["mirror"] is not None + and first((b for b in sf["cards"] if b["name"] == B), + "a card")["why_key"] == "grid_charge") def flips(rows): @@ -301,19 +395,10 @@ def flips(rows): return out -fg, fcm = flips(rg), flips(rc) -print(f" going: forecast read lost at cycle/t {fg}; coming: {fcm}") -check("going: the plan is on with the forecast and not_worth the cycle the " - "read is lost (within one forecast read, 60 s, of the sensor change)", - len(fg) == 1 and fg[0][1] - CUT <= 60.0 and fg[0][2] is False - and all(r["runs_on"] is True for r in rg if not r["fc_none"]) - and all(r["runs_on"] is False for r in rg if r["fc_none"])) -check("...and the grid charge stops with it: no charging after the cycle " - "that lost the forecast, plus the 15 s the inverter ramp takes", - len(fg) == 1 and not any( - r["charging"] for r in rg if r["t"] >= fg[0][1] + 40.0)) -check("coming: not_worth until the read is back, then on in the same cycle, " - "and the battery charges", +fcm = flips(rc) +print(f" coming: forecast read arrives at cycle/t {fcm}") +check("coming (no run under way before): not_worth until the read is back, " + "then on in the same cycle, and the battery charges", len(fcm) == 1 and fcm[0][1] - CUT <= 60.0 and fcm[0][2] is True and all(r["runs_on"] is False for r in rc if r["fc_none"]) and all(r["runs_on"] is True for r in rc if not r["fc_none"])