diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 21244f5..2af807f 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -7,7 +7,7 @@ - The night grid charge no longer raises "Battery stopped limiting the peak" when the 15-minute period stays on the aim. A steadily rising load now alarms a little later, and with a peak margin of 0 a period can end up to 10 W over the limit without this alarm ([[SAFETY-126]]). - A night grid charge for *Leftover solar* no longer stops at 03:00 when the solar forecast sensors went quiet in the evening. The evening's figure is used until 06:00, and a charge that has started goes on to its level ([[OPT-34]]). -**A patch bump: one fix for charging from the grid under the peak limit, and no option changes.** +**A patch bump: two fixes, one for charging from the grid under the peak limit and one for the night charge of Leftover solar, and no option changes.** No option was added, removed or renamed, and no default changed (86 options). **Existing installs need no option change:** options that start 0.19.1 also start 0.19.2. The change below applies with one inverter or two. diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 6d152af..1a1c610 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -2392,9 +2392,11 @@ class Controller: charge that has started keeps its planned level when the forecast goes mid-charge (the kept figure ends and the sensors are still silent). - While there is a forecast, fresh or kept, each battery that charges - from the grid in the cheap run takes its planned level into the latch; - a fresh figure moves it up or down, as it moves the plan. While there + While there is a forecast, fresh or kept, the latch is rebuilt each + cycle from the plan: a battery that charges from the grid in the cheap + run comes in with its planned level, and stays in it at whatever level + the plan gives it, up or down as a fresh figure moves it (also when it + has stopped, at a lowered level), until it leaves the plan. While there 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 @@ -2403,20 +2405,24 @@ class Controller: tick = self.mode_tick run = None if runs is None else runs.cheap in_run = run is not None and run.holds(now) - if lt is not None: - if in_run: - lt = (max(lt[0], run.end + timedelta( - seconds=CHARGE_LATCH_GRACE_S)), lt[1]) - elif now >= lt[0]: - lt = None - if (in_run and self.solar_fc is not None and targets - and tick is not None and tick.action == modetrade.CHARGE): - lv = dict(lt[1]) if lt is not None else {} - lv.update({n: float(targets[n]) for n, r in tick.roles.items() - if r == modetrade.CHARGE and n in targets}) - if lv: - until = run.end + timedelta(seconds=CHARGE_LATCH_GRACE_S) - lt = (max(until, lt[0]) if lt is not None else until, lv) + 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]: + lt = None + 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 + # lower it below the SoC), one that left the plan is dropped, and + # a new one comes in only while it charges from the grid. + tg = targets or {} + lv = {n: float(tg[n]) for n in (lt[1] if lt else {}) if n in tg} + 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 lt != old: self._charge_latch = lt self.store.set(CHARGE_LATCH_KEY, None if lt is None else { diff --git a/goodwe_controller/test_opt34.py b/goodwe_controller/test_opt34.py index 267ebaf..1d3b74f 100644 --- a/goodwe_controller/test_opt34.py +++ b/goodwe_controller/test_opt34.py @@ -236,12 +236,14 @@ def opts(late=False, cap=2500): return o -def sensors(*, silent=True, fresh_from=None, fresh=(1.0, 5.0)): +def sensors(*, silent=True, fresh_from=None, fresh=(1.0, 5.0), + fresh_until=None): """Sensors that update until 17:05 (or all night) and then show that reading for good; from `fresh_from` a new reading every call.""" def f(now, ent): if fresh_from is not None and now >= fresh_from: - return st(fresh[0] if ent == T_ENT else fresh[1], now) + when = now if fresh_until is None else min(now, fresh_until) + return st(fresh[0] if ent == T_ENT else fresh[1], when) when = min(now, LAST) if silent else now return st(0.4 if ent == T_ENT else 6.0, when) return f @@ -260,7 +262,8 @@ def run_night(spec): store.data[unit_key(f"inverter-{u}", k)] = val sens = sensors(silent=spec.get("silent", True), fresh_from=spec.get("fresh_from"), - fresh=spec.get("fresh", (1.0, 5.0))) + fresh=spec.get("fresh", (1.0, 5.0)), + fresh_until=spec.get("fresh_until")) clock = [EVENING[0]] class H(rp.Hass): @@ -399,6 +402,12 @@ SCEN = { "tail": dict(start=at(4, 5, 40), hours=80 / 60.0, soc={"a": 70.0, "b": 70.0}, restarts=[at(4, 6, 30)], marks=[at(4, 5, 50), at(4, 6, 10), at(4, 6, 40)]), + # a fresh, brighter figure at 03:10 lowers A's level below its SoC (A + # stops, B goes on), then the sensors go quiet: the forecast ends at 06:10 + "lowered": dict(start=at(4, 2, 30), hours=4.5, house_w=500.0, + 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)]), # 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 @@ -509,6 +518,23 @@ check("06:10 and 06:40 (the second after a restart at 06:30): A still " and t6["why_a"] == "grid_charge" and t7["why_a"] == "grid_charge") check("... and ends the hour higher than it was (+8 % or more from 06:00)", tl["soc_at"]["06:55"][0] > tl["soc_at"]["06:00"][0] + 8.0) +lo = R["lowered"] +l0, l1, l2 = (lo["marks"][k] for k in ("03:05", "03:20", "06:30")) +check("a fresh, brighter figure lowers A's level below its SoC during the " + f"charge ({l0['target_a']} -> {l1['target_a']}, SoC " + f"{l1['soc_a']:.1f} %), and A stops", + l0["target_a"] is not None and l1["target_a"] is not None + and l1["target_a"] < l1["soc_a"] - 2.0 + and l1["target_a"] < l0["target_a"] - 10.0 + and lo["soc_at"]["03:30"][0] < lo["soc_at"]["03:20"][0] + 0.5) +check("the sensors go quiet, the forecast ends (06:30): the latch holds the " + "LOWERED level, so A does not charge back up to the old one " + f"({lo['soc_at']['03:30'][0]:.1f} -> {lo['soc_at']['06:55'][0]:.1f} %), " + "while B goes on", + l2["fc_none"] and l2["target_a"] is not None + 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) 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 "