diff --git a/goodwe_controller/DECISIONS/OPT-34-the-night-charge-keeps-its-level.md b/goodwe_controller/DECISIONS/OPT-34-the-night-charge-keeps-its-level.md index f879cc2..f8dc7ad 100644 --- a/goodwe_controller/DECISIONS/OPT-34-the-night-charge-keeps-its-level.md +++ b/goodwe_controller/DECISIONS/OPT-34-the-night-charge-keeps-its-level.md @@ -16,7 +16,16 @@ known, so the add-on does not depend on it. no-forecast gap does not refuse the next slice of the run (`weather_held()`, as TARIFF-24 does for the run under way). While a forecast is there the latch follows the plan, up or down as a fresh figure moves - it. It lasts through every cheap run on and one price slot after the last. + it. It lasts through every cheap run on and one price slot after the last, and it + ends at once when its end time passes, whatever the run state (it is also + ignored at a restart if it has ended). A battery that is switched off or + changed to another mode leaves it. + *Night bound (manager's call, 2026-10-31):* a run carries the latch on only if + it starts by the bell start (06:00 local) after the latch began; that run + finishes, plus one slot. Chosen over a fixed 12 h because the night tariff + (22:00-07:00) needs the 06:00-07:00 slice and 12 h from a late start would + reach into the day. With dynamic prices a chain of cheap slots cannot carry a + night's level into the next day. 2. *The evening's tomorrow figure is today's until the bell starts*, 06:00 local (`BELL_FROM_H`, `solarfc._bell_start()`), not until midnight + 3 h. Nothing has been produced yet, so it is still the whole day. @@ -37,7 +46,7 @@ plan, the screens and a restart also need the figure itself. 06:00 and unchanged. After the figure ends, with a charge still going on: "No solar forecast now. The charge under way goes on to its planned level." / "Nu geen zonneprognose. De lopende lading gaat door tot het geplande niveau." -(`home_words.solar.latched`, only while a latched battery is below its level). +(`home_words.solar.latched`, only while a cheap run is on and a latched battery is below its level). **Not used.** A figure over 15 h old; a morning figure at night (12 h); a figure for another day; a bad value or unit; and no latch for a charge that has not @@ -50,4 +59,4 @@ A fresh figure that LOWERS the level during a charge still lowers it, as before. **Mutants** (all red in `test_opt34.py`): no latch; window back to midnight + 3 h; 15 h without a limit; no "usable at midnight" rule; the latch without the narrow gap, without its save, or not kept through the next run; the window 6 h after -midnight instead of 06:00 local (DST); 15 h cut to 12 h; the new screen line off. +midnight instead of 06:00 local (DST); 15 h cut to 12 h; the new screen line off or shown without a cheap run; no expiry check; no pruning of a changed mode; no night bound; a left-the-plan battery keeping its level. diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index bedf661..9acf705 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -536,8 +536,13 @@ def plan_cover(c, name): def _latched_below(c) -> bool: - """OPT-34: no forecast, but a battery is still below the level its night - charge keeps (`Controller.plan_targets()`), so control goes on charging.""" + """OPT-34: no forecast, but a cheap run is on and a battery is still below + the level its night charge keeps (`Controller.plan_targets()`), so control + goes on charging.""" + rp = getattr(c, "mode_runs", None) + if rp is None or rp.cheap is None or not rp.cheap.holds( + datetime.now(timezone.utc)): + return False targets = getattr(c, "plan_targets", lambda: None)() or {} return any(inv.soc is not None and inv.name in targets and inv.soc < targets[inv.name] for inv in c.inverters) diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 1a1c610..35fced1 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -221,18 +221,29 @@ CHARGE_LATCH_GRACE_S = 900.0 FORECAST_READ_S = 60.0 -def _load_latch(data): - """`(until, {name: level %})` from the store, or None if it is not a - well-formed latch (OPT-34).""" +def _load_latch(data, now=None): + """`(until, {name: level %}, born)` from the store, or None if it is not a + well-formed latch or has ended (OPT-34).""" try: until = datetime.fromisoformat(data["until"]) + born = datetime.fromisoformat(data.get("born") or data["until"]) levels = {str(n): float(v) for n, v in data["levels"].items()} except (TypeError, KeyError, ValueError, AttributeError): return None - if until.tzinfo is None or not levels or not all( + if until.tzinfo is None or born.tzinfo is None or not levels or not all( math.isfinite(v) and 0.0 <= v <= 100.0 for v in levels.values()): return None - return until, levels + if now is not None and now >= until: + return None + return until, levels, born + + +def _night_limit(born): + """OPT-34: a run that starts after this cannot carry the latch on: the + first bell start (`solarfc.BELL_FROM_H`, local) after `born`.""" + d0, d1 = solarfc._bounds(born) + bell = solarfc._bell_start(d0) + return bell if bell > born else solarfc._bell_start(d1) def why_raised(resolution, claims) -> bool: @@ -1028,7 +1039,8 @@ class Controller: self._plan_targets = None # OPT-34: `(until, {name: level %})` of a night charge under way. self._charge_latch = _load_latch( - (getattr(store, "data", None) or {}).get(CHARGE_LATCH_KEY)) + (getattr(store, "data", None) or {}).get(CHARGE_LATCH_KEY), + datetime.now(timezone.utc)) self.house_profile = None if self.fc_entity is not None: self.house_profile = dayplan.HouseProfile( @@ -2384,10 +2396,12 @@ class Controller: of a night charge that has started (OPT-34): it keeps that level.""" if self.solar_fc is None: lt = self._charge_latch - return None if lt is None else dict(lt[1]) + if lt is None or datetime.now(timezone.utc) >= lt[0]: + return None + return dict(lt[1]) return self._plan_targets - def _latch_charge(self, now, runs, targets) -> None: + def _latch_charge(self, now, runs, targets, packs=None) -> None: """OPT-34, once per cycle after `modetrade.decide()`. A night grid charge that has started keeps its planned level when the forecast goes mid-charge (the kept figure ends and the sensors are still silent). @@ -2400,15 +2414,26 @@ class Controller: is none, `plan_targets()` gives the latched levels, so the charge goes on to them, and `weather_held()` keeps the narrow price gap. The latch lasts through every cheap run on and one price slot after the last - (`CHARGE_LATCH_GRACE_S`); a charge that never started has none.""" + (`CHARGE_LATCH_GRACE_S`), but a run is carried on only if it starts + by the bell start of the night the latch began in (`_night_limit()`; + the run under way then finishes). It is dropped when `now` passes its + end whatever the run state, and a battery that is off or no longer in + a mode with a planned level (`packs`) leaves it. A charge that never + started has none.""" old = lt = self._charge_latch tick = self.mode_tick run = None if runs is None else runs.cheap in_run = run is not None and run.holds(now) - until = None if run is None else run.end + timedelta( - seconds=CHARGE_LATCH_GRACE_S) - if lt is not None and not in_run and now >= lt[0]: + grace = timedelta(seconds=CHARGE_LATCH_GRACE_S) + if lt is not None and now >= lt[0]: lt = None + if lt is not None and packs is not None: + ok = {p.name for p in packs + if p.use and p.mode in packmode.PLAN_CHARGE_MODES} + lv = {n: v for n, v in lt[1].items() if n in ok} + lt = (lt[0], lv, lt[2]) if lv else None + # A run may carry the latch on only if it starts by the night's bell. + carry = in_run and (lt is None or run.start <= _night_limit(lt[2])) if in_run and self.solar_fc is not None: # Rebuilt from the plan: a battery already latched follows its # level up or down whatever its role this tick (a fresh figure may @@ -2419,14 +2444,20 @@ class Controller: if tick is not None and tick.action == modetrade.CHARGE: lv.update({n: float(tg[n]) for n, r in tick.roles.items() if r == modetrade.CHARGE and n in tg}) - lt = (max(until, lt[0]) if lt is not None else until, lv) \ - if lv else None - elif lt is not None and in_run: - lt = (max(lt[0], until), lt[1]) + if not lv: + lt = None + elif lt is None: + lt = (run.end + grace, lv, now) + else: + lt = (max(run.end + grace, lt[0]) if carry else lt[0], lv, + lt[2]) + elif lt is not None and carry: + lt = (max(lt[0], run.end + grace), lt[1], lt[2]) if lt != old: self._charge_latch = lt self.store.set(CHARGE_LATCH_KEY, None if lt is None else { - "until": lt[0].isoformat(), "levels": lt[1]}) + "until": lt[0].isoformat(), "levels": lt[1], + "born": lt[2].isoformat()}) def _mode_roles(self, now, maint_results): """OPT-11 P3: each battery's role and range this tick. Grid charging @@ -2500,7 +2531,7 @@ class Controller: blocked=self._charge_blocked_q == quarter, done_charge=done_c, done_sell=done_s, targets=targets, draining=any(r.phase == DRAIN for r in maint_results or ())) - self._latch_charge(now, runs, targets) + self._latch_charge(now, runs, targets, packs) # A charge the guard or the headroom stopped stays off this period. # OPT-15: only a MEASURED stop latches; the first unmeasured headroom # (None) in a quarter stops this tick only, so a gap cannot cost the diff --git a/goodwe_controller/test_modes_p4.py b/goodwe_controller/test_modes_p4.py index b9a705b..698be67 100644 --- a/goodwe_controller/test_modes_p4.py +++ b/goodwe_controller/test_modes_p4.py @@ -810,11 +810,16 @@ 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") c._charge_latch = latch +# the latch ends at its end time; this replay ran in September, so ask at a +# moment inside it +_real_dt = rp.m.datetime +rp.m.datetime = type("_DT", (datetime,), {"now": classmethod( + lambda cls, tz=None: latch[0] - timedelta(hours=1))}) check("OPT-34: with a charge under way (B charged in this run), the level " - "stays: plan_targets() gives it, the line says the charge goes on", + "stays: plan_targets() gives it (the line: test_opt34.py)", latch is not None and c.plan_targets() is not None - and B in c.plan_targets() and c.plan_targets()[B] == c._plan_targets[B] - and homeview.day_key(c, B) is None) + and B in c.plan_targets() and c.plan_targets()[B] == c._plan_targets[B]) +rp.m.datetime = _real_dt check("the Why line wins over 'saves its power' only while holding", homeview.battery_why(modes_on=True, mode="leftover_solar", off=False, reading=True, phase="idle", shadow=False, diff --git a/goodwe_controller/test_opt34.py b/goodwe_controller/test_opt34.py index 7e89e2e..516fd01 100644 --- a/goodwe_controller/test_opt34.py +++ b/goodwe_controller/test_opt34.py @@ -262,6 +262,8 @@ def run_night(spec): for k, val in (("pack_use", use), ("pack_mode", mode), ("pack_sell", sell)): store.data[unit_key(f"inverter-{u}", k)] = val + if spec.get("latch"): + store.data[m.CHARGE_LATCH_KEY] = spec["latch"] sens = sensors(silent=spec.get("silent", True), fresh_from=spec.get("fresh_from"), fresh=spec.get("fresh", (1.0, 5.0)), @@ -270,7 +272,7 @@ def run_night(spec): class H(rp.Hass): async def state(self, e): - return sens(clock[0], e) + return GONE if spec.get("gone") else sens(clock[0], e) hass = H(("a", "b"), lambda t: 300.0, soc0) out = dict(marks={}, soc_at={}) @@ -410,6 +412,18 @@ SCEN = { soc={"a": 30.0, "b": 30.0}, fresh_from=at(4, 3, 10), fresh_until=at(4, 3, 10), fresh=(30.0, 30.0), marks=[at(4, 3, 5), at(4, 3, 20), at(4, 6, 30)]), + # F1: the add-on was down when the latch of the night before ended; it is + # back inside a cheap run, with the sensors unavailable (no forecast) + "stale": dict(start=at(3, 22, 0), hours=0.5, gone=True, + latch={"until": at(3, 7, 15).isoformat(), + "born": at(2, 23, 0).isoformat(), + "levels": {A: 80.0, B: 80.0}}, + marks=[at(3, 22, 20)]), + "stale_live": dict(start=at(3, 22, 0), hours=0.5, gone=True, + latch={"until": at(4, 7, 15).isoformat(), + "born": at(3, 22, 0).isoformat(), + "levels": {A: 80.0, B: 80.0}}, + marks=[at(3, 22, 20)]), # the same, B already full and a 1.5 kW limit: the run is sized at the # battery's 2.5 kW rating and ends at 06:33, before A is there, so the # next slice is planned after 06:00 and is only A's @@ -537,6 +551,18 @@ check("the sensors go quiet, the forecast ends (06:30): the latch holds the " and l2["target_a"] < l2["soc_a"] and l2["p_a"] > -200.0 and lo["soc_at"]["06:55"][0] < lo["soc_at"]["03:30"][0] + 1.0 and lo["soc"][1] > lo["soc_at"]["03:30"][1] + 15.0) +st_, sl_ = R["stale"], R["stale_live"] +check("F1: a latch from the night before (ended 07:15) loaded at a restart " + "inside a cheap run, with no forecast: nothing grid-charges " + f"(A {st_['soc'][0]:.1f}, B {st_['soc'][1]:.1f} %) and the line does " + "not say the charge goes on", + st_["soc"][0] < 10.2 and st_["soc"][1] < 10.2 + and st_["marks"]["22:20"]["state"] == "no_forecast" + and not st_["latch"]) +check("(control: the same latch, still valid until tomorrow 07:15, does " + f"charge both: A {sl_['soc'][0]:.1f}, B {sl_['soc'][1]:.1f} %)", + sl_["soc"][0] > 11.0 and sl_["soc"][1] > 11.0 + and sl_["marks"]["22:20"]["state"] == "latched") tb = R["tail_bfull"] check("B full: the run ends at 06:33 before A is at its level, so the next " "slice is only A's, planned after the figure has ended with A's " @@ -573,6 +599,17 @@ print("\n4. The latch alone: a battery that leaves the plan leaves the latch") from types import SimpleNamespace # noqa: E402 from app import modetrade, priceruns # noqa: E402 +CLOCK = [at(4, 1, 0)] +_dt0 = m.datetime + + +class _FixedDT(_dt0): + @classmethod + def now(cls, tz=None): + return CLOCK[0] if tz is None else CLOCK[0].astimezone(tz) + + +m.datetime = homeview.datetime = _FixedDT _tmp = tempfile.mkdtemp(prefix="o34-") lc = Controller(dict(opts()), rp.Hass(("a", "b"), lambda t: 300.0, {"a": 40.0, "b": 40.0}), @@ -587,9 +624,10 @@ resting = SimpleNamespace(action=None, roles={A: modetrade.HOLD, B: modetrade.HOLD}) -def latch_step(fc, tick, targets, now=n0): +def latch_step(fc, tick, targets, now=n0, runs=None, packs=None): + CLOCK[0] = now lc.solar_fc, lc.mode_tick = fc, tick - lc._latch_charge(now, lruns, targets) + lc._latch_charge(now, runs or lruns, targets, packs) return lc._charge_latch, lc.store.data.get(m.CHARGE_LATCH_KEY) @@ -617,15 +655,88 @@ check("B leaves the plan too: the latch is empty, so it is gone and " latch_step(FC, charging, {A: 80.0}) lc.solar_fc = None ia = next((i for i in lc.inverters if i.name == A), None) -st_below = st_at = None +st_below = st_at = st_after = None +lc.mode_runs = lruns if ia is not None: ia.soc = 50.0 st_below = homeview.plan_state(lc) + CLOCK[0] = at(4, 7, 5) # the run has ended, the latch has not + st_after = homeview.plan_state(lc) + CLOCK[0] = n0 ia.soc = 80.0 st_at = homeview.plan_state(lc) check("the screen says the charge goes on only while the latched battery is " f"below its level ({st_below!r} at 50 %, {st_at!r} at 80 %)", st_below == "latched" and st_at == "no_forecast") +check("F2: and only while a cheap run is on: at 07:05, the run over and the " + f"latch in its grace slot, it does not ({st_after!r})", + st_after == "no_forecast") + +print("\n5. The latch ends: expiry, a changed mode, the night's bell") +long_run = SimpleNamespace(cheap=priceruns.Run(at(4, 7, 0), at(4, 23, 0), .2)) +lc._charge_latch = (at(4, 7, 15), {A: 80.0}, at(4, 1, 0)) +lt, saved = latch_step(None, resting, None, now=at(4, 7, 20), runs=long_run) +check("F1: past its end (07:15) the latch is dropped even with a cheap run " + "on and no forecast (it was only dropped when no run held)", + lt is None and saved is None) +lc._charge_latch = (at(4, 7, 15), {A: 80.0}, at(4, 1, 0)) +CLOCK[0] = at(4, 7, 20) +lc.solar_fc = None +check("... plan_targets() ignores a latch past its end", lc.plan_targets() + is None) +CLOCK[0] = at(4, 1, 0) +check("... and _load_latch() does not bring one back from the store", + m._load_latch({"until": at(4, 7, 15).isoformat(), "born": + at(4, 1, 0).isoformat(), "levels": {A: 80.0}}, + at(4, 7, 20)) is None + and m._load_latch({"until": at(4, 7, 15).isoformat(), "born": + at(4, 1, 0).isoformat(), "levels": {A: 80.0}}, + at(4, 6, 0)) is not None) + +# F3: the mode or use of a latched battery changes +def packs_of(a_mode, a_use=True): + return [modetrade.Pack(A, a_use, a_mode, False, 2500.0, 50.0, 10.0), + modetrade.Pack(B, True, "cheap_hours", False, 2500.0, 50.0, 10.0)] + + +lc._charge_latch = (at(4, 7, 15), {A: 80.0}, at(4, 1, 0)) +lt, _s = latch_step(None, resting, None, packs=packs_of("leftover_solar")) +check("F3: A is still Leftover solar and on: it stays latched", + lt is not None and lt[1] == {A: 80.0}) +lt, saved = latch_step(None, resting, None, packs=packs_of("solar_first")) +check("F3: the user changes A to Solar first (no forecast): it leaves the " + "latch, which is then gone and cleared, so the narrow gap goes too", + lt is None and saved is None) +lc._charge_latch = (at(4, 7, 15), {A: 80.0}, at(4, 1, 0)) +lt, _s = latch_step(None, resting, None, + packs=packs_of("leftover_solar", a_use=False)) +check("F3: or switches A off", lt is None) + +# F4: the night's bell +lc._charge_latch = None +lt, _s = latch_step(FC, charging, {A: 80.0}, now=at(4, 1, 0)) +check("F4: the latch began at 01:00, so its night's bell is 06:00 the same " + "day", m._night_limit(lt[2]) == at(4, 6, 0) + and m._night_limit(at(3, 23, 0)) == at(4, 6, 0) + and m._night_limit(at(4, 6, 0)) == at(5, 6, 0)) +r1 = SimpleNamespace(cheap=priceruns.Run(at(4, 0, 0), at(4, 7, 0), .2)) +r2 = SimpleNamespace(cheap=priceruns.Run(at(4, 7, 0), at(4, 23, 0), .2)) +r3 = SimpleNamespace(cheap=priceruns.Run(at(4, 6, 0), at(4, 9, 0), .2)) +lc._charge_latch = None +lt, _s = latch_step(FC, charging, {A: 80.0}, now=at(4, 1, 0), runs=r1) +check("F4: first run 00:00-07:00 sets the end at 07:15", lt[0] == at(4, 7, 15)) +lt, _s = latch_step(None, resting, None, now=at(4, 6, 50), runs=r1) +lt2, _s = latch_step(None, resting, None, now=at(4, 7, 5), runs=r2) +check("F4: a back-to-back run that starts after the bell (07:00-23:00) does " + "NOT carry it on: still 07:15", lt2 is not None + and lt2[0] == at(4, 7, 15)) +lt3, _s = latch_step(None, resting, None, now=at(4, 7, 20), runs=r2) +check("F4: and at 07:20 it has ended, though the run goes on", lt3 is None) +lc._charge_latch = (at(4, 6, 10), {A: 80.0}, at(4, 1, 0)) +lt, _s = latch_step(None, resting, None, now=at(4, 6, 5), runs=r3) +check("F4: a run that starts AT the bell (06:00-09:00) does carry it on, " + "to its end + one slot", lt is not None and lt[0] == at(4, 9, 15)) +m.datetime = homeview.datetime = _dt0 print() if fails: