diff --git a/goodwe_controller/DECISIONS/OPT-11-battery-modes.md b/goodwe_controller/DECISIONS/OPT-11-battery-modes.md index b5d970c..5a68ede 100644 --- a/goodwe_controller/DECISIONS/OPT-11-battery-modes.md +++ b/goodwe_controller/DECISIONS/OPT-11-battery-modes.md @@ -114,5 +114,9 @@ P1 is its section 6 P1, and the pull request lists what was measured. - the days' real 2026 quarter-hourly prices (held out): P2 32.26, option A 32.01, old trade 31.40, P4 29.19 (perfect 29.21, too high 29.28, missing 32.01). + - Solar first + Cheap hours only: 19.57 on both P3 and P4 (year-earlier + prices); on real 2026 prices P4 29.72 against P3's 29.41. Skipping the + sun's share does not pay for Cheap hours there; Leftover solar is the + mode the forecast pays for. No threshold was tuned to these days. The 2026-09-24 PV recording could not be replayed: it is only in the live Home Assistant. diff --git a/goodwe_controller/test_modes_p4.py b/goodwe_controller/test_modes_p4.py index 7847e3c..74b5b2e 100644 --- a/goodwe_controller/test_modes_p4.py +++ b/goodwe_controller/test_modes_p4.py @@ -36,6 +36,7 @@ sys.path.insert(0, HERE) sys.path.insert(0, os.path.join(HERE, "tools")) logging.disable(logging.WARNING) +from named_absence import nth, present # noqa: E402 import opt11p3_replay as rp # noqa: E402 from app import arbitrage, dayplan, homeview, modetrade, packmode # noqa from app import priceruns, solarfc, validate # noqa: E402 @@ -320,8 +321,21 @@ check("Cheap hours: dark fills to the ceiling (as P3)", check("...and skips the part the sun covers on a partly sunny day", ch.targets[B] < 95.0 and ch.share_kwh[B] > 0 and abs(ch.targets[B] - (95.0 - ch.share_kwh[B] * 10.0)) < 0.01) -check("trust 100 % counts more sun than 70 %: a lower or equal level", - dplan(12.0, trust=100).targets[B] <= partly.targets[B]) +check("trust 100 % counts more sun than 70 %: a lower level", + dplan(12.0, trust=100).targets[B] < partly.targets[B]) +check("...and the sun counted scales with the trust exactly", + abs(dplan(12.0, trust=100).sun_kwh * 0.7 - partly.sun_kwh) < 0.01) +_room_a = (95.0 - 60.0) / 10.0 * 10.0 / 10.0 # A: 60 % -> 95 % of 10 kWh +check("Solar first takes the sun first: B's share is what is left after " + f"A's {_room_a:.1f} kWh of room", + abs(partly.share_kwh[B] - max(0.0, partly.sun_kwh - _room_a)) < 0.01 + and partly.share_kwh[B] < partly.sun_kwh) +_flat = (lambda h: 600.0, 600.0) +_sunny_net = (lambda h: -2000.0 if 9 <= h < 17 else 600.0, 600.0) +check("the house's net use in sunny hours (negative) is lifted to the night " + "floor: the same sun as a flat 600 W house", + abs(dplan(12.0, est=_flat).sun_kwh - dplan(12.0, est=_sunny_net) + .sun_kwh) < 1e-6) check("no forecast, no house profile or no price plan: no plan", dayplan.plan(None, EST, R0, T0, PACKS, trust_pct=70, floor_pct=20, ceiling_pct=95) is None @@ -530,7 +544,8 @@ def invariants(tag, rows, c): # Sunny: 40 kWh forecast. B (Leftover solar) at 30 % needs nothing. rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0}) charge = invariants("sunny", rows, c) -print(f" sunny: verdict {rows[-1]['plan'] and rows[-1]['plan'].verdict}, " +_last = nth(rows, -1, "a cycle") +print(f" sunny: verdict {present(_last) and _last['plan'] and _last['plan'].verdict}, " f"{len(charge)} charge cycles") check("sunny: a day plan is made, and says sunny", any(r["plan"] is not None and r["plan"].verdict == dayplan.SUNNY @@ -540,7 +555,7 @@ check("sunny: no grid charge at all", not charge) # 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) -tgt = next(r["targets"][B] for r in rows if r["targets"]) +tgt = nth([r["targets"][B] for r in rows if r["targets"]], 0, "a level") top = max(r["soc"]["b"] for r in rows) print(f" dark: B level {tgt:.1f} %, B reached {top:.1f} %, " f"{len(charge)} charge cycles") @@ -548,18 +563,18 @@ check("dark: Leftover solar charges from the grid in the cheap run", charge and all(r["runs"].cheap.holds(r["now"]) for r in charge) and all(r["roles"][B] == modetrade.CHARGE for r in charge)) check("dark: ...only up to its level (the shortfall), not 95 %", - tgt < 60.0 and top <= tgt + 2.0) + present(tgt) and tgt < 60.0 and top <= tgt + 2.0) check("dark: A rests while B charges", all(r["roles"][A] == modetrade.REST for r in charge)) # Cheap hours on a partly sunny day: skips the part the sun covers. rows, st_, c = run_day(SF_CH, fc=14.0, soc0={"a": 60.0, "b": 30.0}) charge = invariants("cheap hours, partly", rows, c) -tgt = next(r["targets"][B] for r in rows if r["targets"]) +tgt = nth([r["targets"][B] for r in rows if r["targets"]], 0, "a level") top = max(r["soc"]["b"] for r in rows) print(f" cheap hours partly: B level {tgt:.1f} %, reached {top:.1f} %") check("Cheap hours with sun forecast: charges, but stops below 95 %", - charge and tgt < 90.0 and top <= tgt + 2.0) + charge and present(tgt) and tgt < 90.0 and top <= tgt + 2.0) rows_nf, _s, c_nf = run_day(SF_CH, fc=None, soc0={"a": 60.0, "b": 30.0}) check("...and with the forecast down it fills towards 95 % as in P3", max(r["soc"]["b"] for r in rows_nf) > top + 10.0) @@ -614,6 +629,30 @@ check("...while the profile learns and the store keeps each closed hour", c.store.data.get("house_profile") or {}) >= 1) check("selling stays off (option A)", packmode.SELLING_ENABLED is False) +# A forecast arriving mid-period makes the plan at once, not at the next +# 15-minute period. +COME = datetime(2026, 9, 21, 1, 3, tzinfo=L).astimezone(timezone.utc) +rows, st_, c = run_day(SF_LS, fc=lambda now: None if now < COME else 1.0, + soc0={"a": 60.0, "b": 30.0}, hours=0.5) +first = next((r["now"] for r in rows if r["targets"]), None) +check("a forecast that arrives at 01:03 makes the plan before 01:15", + first is not None and first < COME + timedelta(minutes=3)) + +# Battery modes off: the forecast is never read. +READS = [0] +_real_sensor = rp.Hass.state + + +async def _counted(self, e): + READS[0] += 1 + return await _real_sensor(self, e) +rp.Hass.state = _counted +FC_KWH[0] = 1.0 +rp.run("modes_off", house_flat(800.0), WPTS, UTC0 + timedelta(hours=1), + 600.0, extra=dict(FC_OPTS)) +rp.Hass.state = _real_sensor +check("battery modes off: the forecast sensor is never read", READS[0] == 0) + # ============================================================================ # print("7. the Batteries screen and the Simple view") rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0}, @@ -627,7 +666,22 @@ why = homeview.day_key(c, B) check("B's Why key (plan, dark) is tops_up", why == "tops_up") check("...and says so in plain words", homeview.why_words(why) == "Too little sun is forecast, so it charges some from the grid.") +_d0, _d1 = homeview.local_day(c.solar_fc.at) +_blocks, _ = homeview.mode_runs(c, _d0, _d1, c.solar_fc.at, False) +_grid = [b for b in _blocks if b["kind"] == "grid" and b["battery"] == 1] +_need = homeview.plan_need(c, B) +check(f"Today: B's grid block is the planned shortfall ({_need:.2f} kWh), " + "not its room to 95 %", _grid and abs(_grid[0]["kwh"] - _need) < 0.01 + and _need < 3.0) +_run = c.mode_runs.cheap +_packs = c._mode_packs() +_m1 = c._run_mark("charge", _run, _packs, lambda p: True, ((B, 40.0),)) +_m2 = c._run_mark("charge", _run, _packs, lambda p: False, ((B, 50.0),)) +check("a level the hysteresis moved opens the run to that battery again", + _m1 and not _m2) c.solar_fc = None +check("the levels are dropped as soon as the forecast goes, even before " + "the plan is rebuilt", c._plan_targets and c.plan_targets() is None) check("a lost forecast says so: no forecast, Leftover solar solar only", homeview.solar_block(c, datetime.now(timezone.utc))["state"] == "no_forecast" and homeview.day_key(c, B) == "no_forecast")