OPT-34 round 2: the latch follows the plan, up or down, and drops what left it
checks / unit (pull_request) Successful in 18m37s
infra-pin / fresh (pull_request) Successful in 1s

While a forecast is present and the cheap run holds, the night-charge latch is
rebuilt from the plan: a latched battery takes its current level whatever its
role this tick (a fresh figure may lower it below the SoC), one that left the
plan is dropped, a new one comes in only while it charges from the grid.
Test: a fresh brighter figure lowers A below its SoC, the sensors go quiet, A
does not charge back up. CHANGELOG summary says two fixes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
This commit is contained in:
glenn schrooyen
2026-10-31 19:17:20 +01:00
co-authored by Claude
parent 412e47f111
commit eee7616022
3 changed files with 53 additions and 21 deletions
+1 -1
View File
@@ -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.
+23 -17
View File
@@ -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 {
+29 -3
View File
@@ -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 "