OPT-34 round 3: the latch ends on time, with the run, with the mode, and with the night
- F1: an expired latch is dropped whatever the run state; plan_targets() and the store loader ignore one that has ended (no stale latch after a restart). - F2: the "charge goes on" line only while a cheap run is on. - F3: a battery that is off or no longer Leftover solar leaves the latch. - F4: a run carries the latch on only if it starts by the bell start (06:00 local) after the latch began (manager's call, 2026-10-31). Tests: test_opt34 scenarios stale/stale_live and part 5; test_modes_p4 asks at a moment inside the latch. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
This commit is contained in:
co-authored by
Claude
parent
57b0eb2f20
commit
bb57a85b35
@@ -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()`,
|
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
|
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
|
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
|
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.
|
(`BELL_FROM_H`, `solarfc._bell_start()`), not until midnight + 3 h.
|
||||||
Nothing has been produced yet, so it is still the whole day.
|
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
|
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." /
|
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."
|
"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
|
**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
|
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;
|
**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
|
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
|
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.
|
||||||
|
|||||||
@@ -536,8 +536,13 @@ def plan_cover(c, name):
|
|||||||
|
|
||||||
|
|
||||||
def _latched_below(c) -> bool:
|
def _latched_below(c) -> bool:
|
||||||
"""OPT-34: no forecast, but a battery is still below the level its night
|
"""OPT-34: no forecast, but a cheap run is on and a battery is still below
|
||||||
charge keeps (`Controller.plan_targets()`), so control goes on charging."""
|
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 {}
|
targets = getattr(c, "plan_targets", lambda: None)() or {}
|
||||||
return any(inv.soc is not None and inv.name in targets
|
return any(inv.soc is not None and inv.name in targets
|
||||||
and inv.soc < targets[inv.name] for inv in c.inverters)
|
and inv.soc < targets[inv.name] for inv in c.inverters)
|
||||||
|
|||||||
@@ -221,18 +221,29 @@ CHARGE_LATCH_GRACE_S = 900.0
|
|||||||
FORECAST_READ_S = 60.0
|
FORECAST_READ_S = 60.0
|
||||||
|
|
||||||
|
|
||||||
def _load_latch(data):
|
def _load_latch(data, now=None):
|
||||||
"""`(until, {name: level %})` from the store, or None if it is not a
|
"""`(until, {name: level %}, born)` from the store, or None if it is not a
|
||||||
well-formed latch (OPT-34)."""
|
well-formed latch or has ended (OPT-34)."""
|
||||||
try:
|
try:
|
||||||
until = datetime.fromisoformat(data["until"])
|
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()}
|
levels = {str(n): float(v) for n, v in data["levels"].items()}
|
||||||
except (TypeError, KeyError, ValueError, AttributeError):
|
except (TypeError, KeyError, ValueError, AttributeError):
|
||||||
return None
|
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()):
|
math.isfinite(v) and 0.0 <= v <= 100.0 for v in levels.values()):
|
||||||
return None
|
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:
|
def why_raised(resolution, claims) -> bool:
|
||||||
@@ -1028,7 +1039,8 @@ class Controller:
|
|||||||
self._plan_targets = None
|
self._plan_targets = None
|
||||||
# OPT-34: `(until, {name: level %})` of a night charge under way.
|
# OPT-34: `(until, {name: level %})` of a night charge under way.
|
||||||
self._charge_latch = _load_latch(
|
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
|
self.house_profile = None
|
||||||
if self.fc_entity is not None:
|
if self.fc_entity is not None:
|
||||||
self.house_profile = dayplan.HouseProfile(
|
self.house_profile = dayplan.HouseProfile(
|
||||||
@@ -2384,10 +2396,12 @@ class Controller:
|
|||||||
of a night charge that has started (OPT-34): it keeps that level."""
|
of a night charge that has started (OPT-34): it keeps that level."""
|
||||||
if self.solar_fc is None:
|
if self.solar_fc is None:
|
||||||
lt = self._charge_latch
|
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
|
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
|
"""OPT-34, once per cycle after `modetrade.decide()`. A night grid
|
||||||
charge that has started keeps its planned level when the forecast goes
|
charge that has started keeps its planned level when the forecast goes
|
||||||
mid-charge (the kept figure ends and the sensors are still silent).
|
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
|
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
|
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
|
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
|
old = lt = self._charge_latch
|
||||||
tick = self.mode_tick
|
tick = self.mode_tick
|
||||||
run = None if runs is None else runs.cheap
|
run = None if runs is None else runs.cheap
|
||||||
in_run = run is not None and run.holds(now)
|
in_run = run is not None and run.holds(now)
|
||||||
until = None if run is None else run.end + timedelta(
|
grace = timedelta(seconds=CHARGE_LATCH_GRACE_S)
|
||||||
seconds=CHARGE_LATCH_GRACE_S)
|
if lt is not None and now >= lt[0]:
|
||||||
if lt is not None and not in_run and now >= lt[0]:
|
|
||||||
lt = None
|
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:
|
if in_run and self.solar_fc is not None:
|
||||||
# Rebuilt from the plan: a battery already latched follows its
|
# Rebuilt from the plan: a battery already latched follows its
|
||||||
# level up or down whatever its role this tick (a fresh figure may
|
# 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:
|
if tick is not None and tick.action == modetrade.CHARGE:
|
||||||
lv.update({n: float(tg[n]) for n, r in tick.roles.items()
|
lv.update({n: float(tg[n]) for n, r in tick.roles.items()
|
||||||
if r == modetrade.CHARGE and n in tg})
|
if r == modetrade.CHARGE and n in tg})
|
||||||
lt = (max(until, lt[0]) if lt is not None else until, lv) \
|
if not lv:
|
||||||
if lv else None
|
lt = None
|
||||||
elif lt is not None and in_run:
|
elif lt is None:
|
||||||
lt = (max(lt[0], until), lt[1])
|
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:
|
if lt != old:
|
||||||
self._charge_latch = lt
|
self._charge_latch = lt
|
||||||
self.store.set(CHARGE_LATCH_KEY, None if lt is None else {
|
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):
|
def _mode_roles(self, now, maint_results):
|
||||||
"""OPT-11 P3: each battery's role and range this tick. Grid charging
|
"""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,
|
blocked=self._charge_blocked_q == quarter,
|
||||||
done_charge=done_c, done_sell=done_s, targets=targets,
|
done_charge=done_c, done_sell=done_s, targets=targets,
|
||||||
draining=any(r.phase == DRAIN for r in maint_results or ()))
|
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.
|
# A charge the guard or the headroom stopped stays off this period.
|
||||||
# OPT-15: only a MEASURED stop latches; the first unmeasured headroom
|
# 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
|
# (None) in a quarter stops this tick only, so a gap cannot cost the
|
||||||
|
|||||||
@@ -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"]
|
homeview.solar_block(c, datetime.now(timezone.utc))["state"]
|
||||||
== "no_forecast" and homeview.day_key(c, B) == "no_forecast")
|
== "no_forecast" and homeview.day_key(c, B) == "no_forecast")
|
||||||
c._charge_latch = latch
|
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 "
|
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
|
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 B in c.plan_targets() and c.plan_targets()[B] == c._plan_targets[B])
|
||||||
and homeview.day_key(c, B) is None)
|
rp.m.datetime = _real_dt
|
||||||
check("the Why line wins over 'saves its power' only while holding",
|
check("the Why line wins over 'saves its power' only while holding",
|
||||||
homeview.battery_why(modes_on=True, mode="leftover_solar", off=False,
|
homeview.battery_why(modes_on=True, mode="leftover_solar", off=False,
|
||||||
reading=True, phase="idle", shadow=False,
|
reading=True, phase="idle", shadow=False,
|
||||||
|
|||||||
@@ -262,6 +262,8 @@ def run_night(spec):
|
|||||||
for k, val in (("pack_use", use), ("pack_mode", mode),
|
for k, val in (("pack_use", use), ("pack_mode", mode),
|
||||||
("pack_sell", sell)):
|
("pack_sell", sell)):
|
||||||
store.data[unit_key(f"inverter-{u}", k)] = val
|
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),
|
sens = sensors(silent=spec.get("silent", True),
|
||||||
fresh_from=spec.get("fresh_from"),
|
fresh_from=spec.get("fresh_from"),
|
||||||
fresh=spec.get("fresh", (1.0, 5.0)),
|
fresh=spec.get("fresh", (1.0, 5.0)),
|
||||||
@@ -270,7 +272,7 @@ def run_night(spec):
|
|||||||
|
|
||||||
class H(rp.Hass):
|
class H(rp.Hass):
|
||||||
async def state(self, e):
|
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)
|
hass = H(("a", "b"), lambda t: 300.0, soc0)
|
||||||
out = dict(marks={}, soc_at={})
|
out = dict(marks={}, soc_at={})
|
||||||
@@ -410,6 +412,18 @@ SCEN = {
|
|||||||
soc={"a": 30.0, "b": 30.0}, fresh_from=at(4, 3, 10),
|
soc={"a": 30.0, "b": 30.0}, fresh_from=at(4, 3, 10),
|
||||||
fresh_until=at(4, 3, 10), fresh=(30.0, 30.0),
|
fresh_until=at(4, 3, 10), fresh=(30.0, 30.0),
|
||||||
marks=[at(4, 3, 5), at(4, 3, 20), at(4, 6, 30)]),
|
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
|
# 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
|
# 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
|
# 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 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_at"]["06:55"][0] < lo["soc_at"]["03:30"][0] + 1.0
|
||||||
and lo["soc"][1] > lo["soc_at"]["03:30"][1] + 15.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"]
|
tb = R["tail_bfull"]
|
||||||
check("B full: the run ends at 06:33 before A is at its level, so the next "
|
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 "
|
"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 types import SimpleNamespace # noqa: E402
|
||||||
from app import modetrade, priceruns # 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-")
|
_tmp = tempfile.mkdtemp(prefix="o34-")
|
||||||
lc = Controller(dict(opts()), rp.Hass(("a", "b"), lambda t: 300.0,
|
lc = Controller(dict(opts()), rp.Hass(("a", "b"), lambda t: 300.0,
|
||||||
{"a": 40.0, "b": 40.0}),
|
{"a": 40.0, "b": 40.0}),
|
||||||
@@ -587,9 +624,10 @@ resting = SimpleNamespace(action=None, roles={A: modetrade.HOLD,
|
|||||||
B: 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.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)
|
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})
|
latch_step(FC, charging, {A: 80.0})
|
||||||
lc.solar_fc = None
|
lc.solar_fc = None
|
||||||
ia = next((i for i in lc.inverters if i.name == A), 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:
|
if ia is not None:
|
||||||
ia.soc = 50.0
|
ia.soc = 50.0
|
||||||
st_below = homeview.plan_state(lc)
|
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
|
ia.soc = 80.0
|
||||||
st_at = homeview.plan_state(lc)
|
st_at = homeview.plan_state(lc)
|
||||||
check("the screen says the charge goes on only while the latched battery is "
|
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 %)",
|
f"below its level ({st_below!r} at 50 %, {st_at!r} at 80 %)",
|
||||||
st_below == "latched" and st_at == "no_forecast")
|
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()
|
print()
|
||||||
if fails:
|
if fails:
|
||||||
|
|||||||
Reference in New Issue
Block a user