Compare commits

..
6 Commits
Author SHA1 Message Date
glenn schrooyenandClaude bb57a85b35 OPT-34 round 3: the latch ends on time, with the run, with the mode, and with the night
checks / unit (pull_request) Successful in 20m15s
infra-pin / fresh (pull_request) Successful in 1s
- 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
2026-10-31 23:04:36 +01:00
Claude (OPT-34 review) 57b0eb2f20 OPT-34 review: read the inverter in part 4 with a default
checks / unit (pull_request) Successful in 23m57s
infra-pin / fresh (pull_request) Successful in 2s
test_named_absence.py flagged next() without a default (CI run 1473).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 21:18:59 +01:00
Claude (OPT-34 review) 8f0af9f318 OPT-34 review: test that a battery leaving the plan leaves the latch
checks / unit (pull_request) Failing after 19m32s
infra-pin / fresh (pull_request) Successful in 1s
test_opt34.py part 4 drives Controller._latch_charge() directly: a battery
that leaves the plan is dropped at once, does not rejoin while nothing
charges, an empty latch is cleared from the store, and the screen says the
charge goes on only while the latched battery is below its level. Catches
two reviewer mutants that survived the round-2 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:50:02 +01:00
glenn schrooyenandClaude eee7616022 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
2026-10-31 19:17:20 +01:00
glenn schrooyenandClaude 412e47f111 OPT-34: the night charge keeps its level when the kept forecast ends
checks / unit (pull_request) Successful in 18m59s
infra-pin / fresh (pull_request) Successful in 1s
With the forecast sensors silent from 17:05 the kept evening figure ended at
midnight + 3 h, in the middle of the night grid charge, and Leftover solar's
planned level went with it (rig: A 49.8 % against 79.9 %).

- A night grid charge that has started keeps its planned level when the forecast
  goes (Controller._latch_charge, store key night_charge_latch), and the narrow
  price gap with it (weather_held). A fresh figure still moves the level.
- The evening's tomorrow figure is today's until the bell starts (06:00 local),
  up to 15 h old but only if it was usable at midnight (solarfc.Hold.stand_in).
- Screen: "No solar forecast now. The charge under way goes on to its planned
  level." (EN/NL) while a latched battery is below its level.
- test_opt34.py (whole nights through the real control cycle, mutants), test_opt27
  and test_modes_p4 adjusted to the new window and the kept level.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-31 17:00:31 +01:00
admin deee98d352 Merge pull request 'SAFETY-126: no false peak reserve alarm during the night grid charge' (#221) from SAFETY-126 into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-10-31 12:26:03 +01:00
12 changed files with 1038 additions and 65 deletions
+2 -1
View File
@@ -5,8 +5,9 @@
### Not in a release yet
- 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.
@@ -19,14 +19,16 @@ still keeps a run under way when a read is lost.
**The rule** (`Hold.stand_in()`). A kept reading is for one day. It covers a
sensor that is missing or stale, never one with a bad value or unit, and:
- for up to `FRESH_S` when the sensor is missing: a short outage, any time of day;
- from the end of the bell (20:00 local, `BELL_TO_H`) until local midnight + 3 h
(`KEPT_UNTIL_H`), if the reading is at most `KEPT_MAX_AGE_S` (12 h) old when used;
- from the end of the bell (20:00 local, `BELL_TO_H`) until local midnight, if the
reading is at most `KEPT_MAX_AGE_S` (12 h) old when used; after midnight the
tomorrow figure is today's until the bell starts (06:00), up to 15 h old
(OPT-34 changed this from midnight + 3 h);
- the day it is for: a today figure only on that day, a tomorrow figure as
tomorrow until midnight and, if the today sensors are still stale, as today's
until midnight + 3 h (all sensors of the sum are needed, as for any sum);
until 06:00 (all sensors of the sum are needed, as for any sum);
- until the next fresh reading, which replaces it at once.
So a figure is never used for another day and never older than 12 hours (`KEPT_MAX_AGE_S`): a 17:05 reading serves until 03:00 (9 h 55 min), a 09:05 reading only until 21:05.
So a figure is never used for another day and never older than 12 hours (`KEPT_MAX_AGE_S`), except the morning case above: a 17:05 reading serves until 06:00 (12 h 55 min), a 09:05 reading only until 21:05. Until OPT-34 the figure ended at 03:00, and a charge under way lost its level there; OPT-34 closes that limit.
Daytime is as before: a sensor that stopped is stale after 3 h, and no forecast
all day is still no forecast (the night run still needs 1.5x).
@@ -0,0 +1,62 @@
# OPT-34 - the night charge keeps its level when the kept forecast ends
**Status: done on `release/1.0`, not released.** Tests: `test_opt34.py`.
**The defect** (OPT-27 rig test, F-1). The kept evening figure ended at midnight + 3 h.
With the sensors silent from 17:05 the Leftover solar battery's level went with
it in the middle of the charge: A ended at 49.8 % against 79.9 % with a fresh
figure. Whether Forecast.Solar refreshes after midnight on a live site is not
known, so the add-on does not depend on it.
**The rule.**
1. *A charge under way keeps its level* (`Controller._latch_charge()`). While
there is a forecast (fresh or kept), each battery that charges from the grid in the
cheap run takes its planned level into a latch (store key `night_charge_latch`).
With no forecast, `plan_targets()` gives the latched level, and the wide
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, 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.
3. *Up to 15 h old for that case only* (`KEPT_MORNING_MAX_AGE_S`): a 17:05
reading is 12 h 55 min old at 06:00. It must have been at most 12 h old at
midnight, so a 09:05 reading, unusable since 21:05, does not come back for five
minutes after midnight. Same-day figures and the daytime rule keep 12 h and 3 h.
4. A fresh figure replaces the kept one at once, as before.
**Step 1 alone is enough for the owner's shape, steps 2-3 are still needed.**
With the window put back to midnight + 3 h the owner's night still ends as a
fresh figure ends it (A 86.9 %, B 95.0 %), because the charge had started at
22:00. But a charge that has not started by 03:00 (the price dips at 04:00, or
the headroom is taken) has no level to latch: A ends at 38 % against 78 %. The
plan, the screens and a restart also need the figure itself.
**Screens.** "Solar forecast read {at}, kept for tonight." is still true until
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 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
started, for a battery that is not charging, or after the cheap runs have ended
(plus one slot).
**Not changed.** Charge sizing, the peak guard, Cheap hours, modes off, arbitrage.
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 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.
+1 -1
View File
@@ -557,7 +557,7 @@ With battery modes on, these choices replace the *Charging and selling on price*
- The plan is checked every 15 minutes. A planned level moves only by 1 kWh or 10 %, so a wobbling forecast does not flip it.
- No forecast, one older than 3 hours or from before midnight, or no house use yet: *Leftover solar* takes solar only and *Cheap hours only* fills to the top level. The Batteries screen says so.
- A battery needs its capacity (`inverters.capacity_kwh`) for a plan.
- **At night.** Some forecasts stop updating in the evening (Forecast.Solar does). The add-on keeps the last reading of each sensor and uses it for that night's plan: from 20:00 until 03:00, or until a newer reading comes. A sensor that is missing for up to 3 hours is covered the same way. A figure is never used for another day, and never when it is more than 12 hours old. The Batteries screen says "Solar forecast read 17:05, kept for tonight." With no reading all day, the night run still needs the wider gap.
- **At night.** Some forecasts stop updating in the evening (Forecast.Solar does). The add-on keeps the last reading of each sensor and uses it for that night's plan: from 20:00 until 06:00, or until a newer reading comes. After midnight the evening's figure for tomorrow is today's, whole, because nothing has been produced yet. A sensor that is missing for up to 3 hours is covered the same way. A figure is never used for another day, and never when it is more than 12 hours old (15 hours after midnight, for a figure that was still in use at midnight). A charge from the grid that has started keeps its level when the forecast ends, and goes on to it; the Batteries screen says so. The Batteries screen says "Solar forecast read 17:05, kept for tonight." With no reading all day, the night run still needs the wider gap.
**What always comes first.**
- The peak limit, the safety stops and the monthly cycle come before a battery's mode.
+20 -5
View File
@@ -535,15 +535,29 @@ def plan_cover(c, name):
return targets[name], max(0.0, top - floor) / 100.0 * cap * EFF
def _latched_below(c) -> bool:
"""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)
def plan_state(c):
"""P4: `"plan"` (a day plan is made), `"learning"` (no house profile
yet), `"no_forecast"` (set but missing or stale), or None (not set, or
battery modes off)."""
yet), `"no_forecast"` (set but missing or stale), `"latched"` (OPT-34:
the same, but a night charge under way is still going on to its level),
or None (not set, or battery modes off)."""
if not getattr(c, "modes_on", False) or \
getattr(c, "fc_entity", None) is None:
return None
if getattr(c, "solar_fc", None) is None:
return "no_forecast"
return "latched" if _latched_below(c) else "no_forecast"
hp = getattr(c, "house_profile", None)
if hp is not None and not hp.ready():
return "learning"
@@ -564,7 +578,7 @@ def solar_block(c, now):
out.update(fc_kwh=round(fc.today.kwh, 1),
count_kwh=round(fc.today.kwh * trust, 1),
updated=hhmm(fc.at))
if st in ("no_forecast", "learning"):
if st in ("no_forecast", "learning", "latched"):
out["line"] = _t("solar." + st)
elif fc is not None:
out["line"] = line_solar(fc.today.kwh, out["count_kwh"],
@@ -1642,7 +1656,8 @@ def _samples() -> list:
kwh="9.4 kWh")) for k in words["why"]]
out += [("plan", today_words(k)) for k in words["today"]]
out += [("plan", line_solar(9.2, 6.4, "10:15")),
("plan", _t("solar.no_forecast")), ("plan", _t("solar.learning"))]
("plan", _t("solar.no_forecast")), ("plan", _t("solar.learning")),
("plan", _t("solar.latched"))]
out += [("plan", day_note(k, pct=70, name="Battery A", at="08:15",
kwh="9.2 kWh", count="6.4 kWh"))
for k in words["day"]]
+108 -5
View File
@@ -211,9 +211,41 @@ HOUSE_PROFILE_KEY = "house_profile"
# OPT-27: the forecast sensors' last fresh readings (`solarfc.Hold`), so an
# evening restart keeps the figure the night plan uses.
FC_HOLD_KEY = "solar_fc_hold"
# OPT-34: the level of a night grid charge that has started, so it does not
# drop when the forecast goes (`Controller._latch_charge()`).
CHARGE_LATCH_KEY = "night_charge_latch"
# OPT-34: a latch outlives the cheap run it was last used in by one price
# slot, because the next run is planned at the slot edge, in the cycle after
# the old one ended.
CHARGE_LATCH_GRACE_S = 900.0
FORECAST_READ_S = 60.0
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 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
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:
"""F2 (OPT-11 P1 test): did the limit that bound the site target push it
ABOVE what the winner asked (the peak floor asking for more power)? Then
@@ -1005,6 +1037,10 @@ class Controller:
(getattr(store, "data", None) or {}).get(FC_HOLD_KEY))
self.day_plan = None
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),
datetime.now(timezone.utc))
self.house_profile = None
if self.fc_entity is not None:
self.house_profile = dayplan.HouseProfile(
@@ -2265,9 +2301,13 @@ class Controller:
def weather_held(self, now) -> bool:
"""TARIFF-24: no forecast now, but a run planned with one is under
way: it keeps its gap to its end. The wider gap applies from the next
run. Pure; Home asks the same (`homeview.trade_verdict()`)."""
return (self.solar_fc is None and self._wx_free_to is not None
and now < self._wx_free_to)
run. OPT-34: a night charge that keeps its level (`_latch_charge()`)
keeps the gap too, run after run, or the next slice of it would be
refused. Pure; Home asks the same (`homeview.trade_verdict()`)."""
return self.solar_fc is None and (
(self._wx_free_to is not None and now < self._wx_free_to)
or (self._charge_latch is not None
and now < self._charge_latch[0]))
def _price_runs(self, now, packs, floor_pct, ceiling_pct):
"""The kept run plan, rebuilt at most once per price slot (15 min) or
@@ -2352,11 +2392,73 @@ class Controller:
def plan_targets(self):
"""P4: each battery's planned level, or None (no day plan). Dropped
at once when the forecast goes (stale or missing)."""
at once when the forecast goes (stale or missing), except the level
of a night charge that has started (OPT-34): it keeps that level."""
if self.solar_fc is None:
return None
lt = self._charge_latch
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, 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).
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
(`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)
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
# 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})
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],
"born": lt[2].isoformat()})
def _mode_roles(self, now, maint_results):
"""OPT-11 P3: each battery's role and range this tick. Grid charging
and selling need a fresh plan that `TariffPlan.arbitrage()` allows
@@ -2429,6 +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, 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
+42 -24
View File
@@ -34,11 +34,13 @@ evening and api.forecast.solar is sometimes out for a poll. A kept reading
stands in for its sensor (a) for up to `FRESH_S` when the sensor is missing
or unavailable, as a sensor that merely stopped updating is already
tolerated, and (b) from the end of the bell (`BELL_TO_H`) until local
midnight + `KEPT_UNTIL_H`, if it is at most `KEPT_MAX_AGE_S` old when used.
Never beyond that, and never
for another day: a today figure is only today's, a tomorrow figure is
tomorrow's until midnight and then, until midnight + 3 h, today's.
DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md.
midnight, if it is at most `KEPT_MAX_AGE_S` old when used.
Never beyond that, and never for another day: a today figure is only today's,
a tomorrow figure is tomorrow's until midnight and then, until the bell starts
(`BELL_FROM_H`, 06:00 local), today's (OPT-34: at most `KEPT_MORNING_MAX_AGE_S`
old, and it must have been usable at midnight).
DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md,
DECISIONS/OPT-34-the-night-charge-keeps-its-level.md.
OPT-17: each day may instead name a list (`pv_forecast_entities`,
`pv_forecast_tomorrow_entities`), summed by `read_sum()`. One member that is
@@ -63,11 +65,16 @@ FRESH_S = 3 * 3600.0
BELL_FROM_H, BELL_TO_H = 6.0, 20.0
# OPT-27: a kept reading serves the night plan from the end of the bell until
# this many hours after local midnight (the card: "local midnight + 3 h").
KEPT_UNTIL_H = 3.0
# OPT-27: and a reading older than this when used is not kept (a morning
# figure must not drive the night).
# local midnight, and a reading older than this when used is not kept (a
# morning figure must not drive the night).
KEPT_MAX_AGE_S = 12 * 3600.0
# OPT-34: the evening's "tomorrow" figure, once midnight has passed, is today's
# until the bell starts (`BELL_FROM_H`): nothing has been produced yet, so it
# is still the whole day. A 17:05 reading is 12 h 55 min old at 06:00, so this
# case may be older than `KEPT_MAX_AGE_S`, up to this, but only a figure that
# was usable at midnight (at most `KEPT_MAX_AGE_S` old then), so one that has
# not been used all evening does not come back at 00:00.
KEPT_MORNING_MAX_AGE_S = 15 * 3600.0
SAVE_EVERY_S = 900.0
# Why there is no forecast (the page words are in ui_text `home_words`).
@@ -279,7 +286,7 @@ class Hold:
`day0`) or "tomorrow" (all of `day0`), and `day0` is the day the figure is
FOR. A today figure therefore never answers for another day, and a
tomorrow figure answers for its own day only: tomorrow until midnight,
then today until midnight + `KEPT_UNTIL_H`. Only the newest reading of a
then today until the bell starts (OPT-34). Only the newest reading of a
key is kept, so the next fresh reading ends the hold at once.
`dump()` / `load()` are what the controller keeps in its store, so an
@@ -322,25 +329,27 @@ class Hold:
The kept reading must be for `day0`, and either (a) at most `FRESH_S`
old, or (b) used at night: from the end of the bell to local midnight,
or within `KEPT_UNTIL_H` after it, and then at most `KEPT_MAX_AGE_S`
old. `kept` is true when (b) was needed,
or `as_today` (a tomorrow figure standing in for today, after
midnight)."""
at most `KEPT_MAX_AGE_S` old. `kept` is true when (b) was needed,
or `as_today` (OPT-34: a tomorrow figure standing in for today, after
midnight and until the bell starts, `KEPT_MORNING_MAX_AGE_S`)."""
rec = self._r.get((kind, entity, day0))
if rec is None:
return None
day, at = rec
if (now - at).total_seconds() <= FRESH_S and not as_today:
age = (now - at).total_seconds()
if age <= FRESH_S and not as_today:
return day, at, False
if (now - at).total_seconds() > KEPT_MAX_AGE_S:
return None
# Both sides: a tomorrow sensor is asked with day0 = the next
# midnight, which is in the future all day (round 2).
after_midnight = day0 <= now < day0 + timedelta(hours=KEPT_UNTIL_H)
evening = _local_hour(now) >= BELL_TO_H and now < _bounds(day0)[1]
if as_today:
return (day, at, True) if after_midnight else None
return (day, at, True) if (evening or after_midnight) else None
ok = (day0 <= now < _bell_start(day0)
and age <= KEPT_MORNING_MAX_AGE_S
and (day0 - at).total_seconds() <= KEPT_MAX_AGE_S)
return (day, at, True) if ok else None
if age > KEPT_MAX_AGE_S:
return None
# A tomorrow sensor is asked with day0 = the next midnight, which is
# in the future all day, so the evening ends at that day's end.
evening = _local_hour(now) >= BELL_TO_H and now < _bounds(day0)[1]
return (day, at, True) if evening else None
def dump(self) -> dict:
return {"v": 1, "r": [
@@ -395,6 +404,15 @@ def _bounds(now):
return d0.astimezone(timezone.utc), d1.astimezone(timezone.utc)
def _bell_start(day0):
"""The moment the bell starts on the local day that begins at `day0`
(`BELL_FROM_H` local, so 06:00 on a 23 h or 25 h day too)."""
here = day0.astimezone(BILLING_TZ)
t = datetime(here.year, here.month, here.day, tzinfo=BILLING_TZ) \
+ timedelta(hours=BELL_FROM_H)
return t.astimezone(timezone.utc)
def _today(state, day0, now):
"""`(Day, at, None)` or `(None, None, why)` for one today sensor."""
today, why = _day(state, day0, now, today=True)
@@ -474,7 +492,7 @@ def read_sum(today, tomorrow, now: datetime, hold: Hold | None = None):
where `Hold.stand_in()` allows it (the forecast then has `kept` set when
that took a reading older than `FRESH_S`). After midnight, if the today
side is still short, the tomorrow figures read before midnight are
today's, until midnight + `KEPT_UNTIL_H`. Without a `hold` this is
today's, until the bell starts (`stand_in()`). Without a `hold` this is
exactly as before."""
if not today:
return None, NOT_SET, ()
+1 -1
View File
@@ -360,7 +360,7 @@
"tops_up": "It charges from the grid to {pct}. That covers {kwh} of expensive hours the sun will not.",
"no_forecast": "No solar forecast, so it charges from solar only."
},
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "uncovered": "The expensive hours need more than Leftover solar can hold. The rest, {kwh}, comes from another battery or the grid.", "kept": "Solar forecast read {at}, kept for tonight.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "uncovered": "The expensive hours need more than Leftover solar can hold. The rest, {kwh}, comes from another battery or the grid.", "kept": "Solar forecast read {at}, kept for tonight.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "latched": "No solar forecast now. The charge under way goes on to its planned level.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
"today": {
"not_planned": "Not planned yet.",
"no_runs": "No cheap or expensive hours left today.",
+1 -1
View File
@@ -300,7 +300,7 @@
"tops_up": "Ze laadt van het net tot {pct}. Dat dekt {kwh} dat de zon in dure uren niet levert.",
"no_forecast": "Geen zonneprognose, dus ze laadt alleen met zonne-energie."
},
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "uncovered": "De dure uren vragen meer dan Overschot zonne-energie kan opslaan. De rest, {kwh}, komt van een andere batterij of het net.", "kept": "Zonneprognose van {at}, bewaard voor vannacht.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "uncovered": "De dure uren vragen meer dan Overschot zonne-energie kan opslaan. De rest, {kwh}, komt van een andere batterij of het net.", "kept": "Zonneprognose van {at}, bewaard voor vannacht.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "latched": "Nu geen zonneprognose. De lopende lading gaat door tot het geplande niveau.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
"today": {
"not_planned": "Nog niet gepland.",
"no_runs": "Vandaag geen goedkope of dure uren meer.",
+21 -5
View File
@@ -671,7 +671,9 @@ check("Cheap hours with a sun forecast charges as with none (P3)",
charge and abs(max(r["soc"]["b"] for r in rows_nf) - top) < 1.0
and top > 80.0)
# The forecast goes away mid-run: Leftover solar stops at once (P3 option A).
# The forecast goes away mid-run: a charge under way goes on to its level
# (OPT-34; it stopped at once, P3 option A, before). With no charge under way
# the levels go with the forecast (below, and test_opt27.py).
# OPT-27: a sensor that is unavailable is covered by its last fresh reading
# for up to 3 h (`solarfc.Hold`), so "lost" here is a hold that never covers;
# the next scenario is the same loss with the hold (test_opt27.py).
@@ -695,8 +697,9 @@ before = [r for r in rows if r["now"] < DROP and r["action"]
== arbitrage.CHARGE]
late = [r for r in rows if r["now"] > DROP + timedelta(
seconds=2 * 60 + 10) and r["action"] == arbitrage.CHARGE]
check("forecast lost mid-run: Leftover solar was charging, and stops within "
"a forecast read (60 s) and a cycle", before and not late)
check("forecast lost mid-run (OPT-34): Leftover solar was charging, and goes "
"on charging past a forecast read (60 s) and a cycle, to its level",
before and late)
rows, st_, c = run_day(SF_LS, fc=lambda now: 1.0 if now < DROP else None,
soc0={"a": 60.0, "b": 10.0})
invariants("forecast unavailable mid-run, covered", rows, c)
@@ -798,12 +801,25 @@ _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)
latch, c._charge_latch = c._charge_latch, None
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("with no charge under way, 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")
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: 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])
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,
+27 -18
View File
@@ -7,9 +7,10 @@ Forecast.Solar site was "no forecast": a price mode's night cheap run needed
fresh reading of each sensor, for the day it is FOR:
* up to `FRESH_S` when the sensor is missing or unavailable (a short outage);
* from the end of the bell (20:00) until local midnight + 3 h, if the reading
is at most `KEPT_MAX_AGE_S` (12 h) old when used (the night plan); never
beyond, never for another day.
* from the end of the bell (20:00) until local midnight, if the reading is at
most `KEPT_MAX_AGE_S` (12 h) old when used (the night plan); after midnight
the evening's tomorrow figure is today's until the bell starts (06:00, OPT-34,
test_opt34.py); never beyond, never for another day.
DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md.
@@ -21,7 +22,7 @@ DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md.
b. no forecast all day: the night run still needs 1.5x and does not charge;
c. an evening restart keeps it (a new Controller on the saved store);
d. a one-hour outage at 18:47 does not drop it;
e. past midnight + 3 h the figure is not used, before it it is.
e. from 06:00 (the bell) the figure is not used, before it it is (OPT-34).
3. The screens: the forecast line says when it was read and that it is kept
for tonight (EN and NL), and Home agrees with control.
@@ -153,7 +154,7 @@ for label, tm in (("stale", st(12.0, at(4, 9, 5))), ("unavailable", GONE)):
check(f"11:00, tomorrow {label} for under 3 h: still covered, not kept",
fs is not None and fs.tomorrow is not None and not fs.kept)
# The bound: midnight + 3 h, and the day a figure is for.
# The bound: the bell start (06:00), and the day a figure is for.
fm = rs(st(0.0, at(3, 23, 0)), st(0.0, at(3, 23, 0)), at(4, 1, 0), h2)[0]
check("00:30 is covered by the 18:05 tomorrow figure, as TODAY's",
rs(GONE, GONE, at(4, 0, 30), h2)[0] is not None)
@@ -165,9 +166,12 @@ check("02:59: tomorrow's 12.0 kWh is now today's, there is no tomorrow",
check("... its sun is counted on the day it is for (06:00-20:00 on the 4th)",
fmid is not None and fmid.between(at(4, 3, 0), at(4, 7, 0)) is not None
and fmid.between(at(4, 3, 0), at(4, 7, 0)) > 0.0)
check("03:01 (midnight + 3 h): not used any more",
rs(GONE, GONE, at(4, 3, 1), h2)[0] is None)
check("... and not the next evening either (past midnight + 3 h)",
check("03:01: still used (OPT-34: until the bell starts at 06:00)",
rs(GONE, GONE, at(4, 3, 1), h2)[0] is not None
and rs(GONE, GONE, at(4, 5, 59), h2)[0] is not None)
check("06:00 (the bell starts): not used any more",
rs(GONE, GONE, at(4, 6, 0), h2)[0] is None)
check("... and not the next evening either (past 06:00)",
rs(GONE, GONE, at(4, 22, 0), h2)[0] is None)
fe = rs(st(0.1, at(4, 21, 50)), GONE, at(4, 22, 0), h2)[0]
check("... the next evening, with today fresh and tomorrow's sensor down, "
@@ -268,11 +272,16 @@ hy = solarfc.Hold()
rs(st(0.4, at(3, 17, 5)), st(12.0, at(3, 17, 5)), at(3, 17, 10), hy)
check("F1: a 17:05 reading is kept at 22:00 (5 h) ...",
(lambda f: f is not None and f.kept)(rs(GONE, GONE, at(3, 22, 0), hy)[0]))
check("... and at 02:59 next day (9 h 54 min), the midnight + 3 h bound ...",
check("... and at 02:59 next day (9 h 54 min) ...",
(lambda f: f is not None and f.kept)(rs(GONE, GONE, at(4, 2, 59),
hy)[0]))
check("... and at 03:00 next day it is not (midnight + 3 h; 9 h 55 min old)",
rs(GONE, GONE, at(4, 3, 0), hy)[0] is None)
check("... and at 03:00 and 05:59 next day (OPT-34: the bell starts at 06:00)",
(lambda f: f is not None and f.kept)(rs(GONE, GONE, at(4, 3, 0),
hy)[0])
and (lambda f: f is not None and f.kept)(rs(GONE, GONE, at(4, 5, 59),
hy)[0]))
check("... and at 06:00 it is not (12 h 55 min old, the bell has started)",
rs(GONE, GONE, at(4, 6, 0), hy)[0] is None)
hm = solarfc.Hold()
rs(st(0.9, at(3, 9, 5)), st(12.0, at(3, 9, 5)), at(3, 9, 10), hm)
check("F1: a 09:05 reading is kept at 20:00 (10 h 55 min) but not at 22:00",
@@ -564,18 +573,18 @@ check("... and the 22:00 night run is planned and charges",
present(ld) and ld["runs_on"] is True
and sum(r["charging"] for r in rd) > len(rd) // 2)
# e. the bound: midnight + 3 h
# e. the bound: until the bell starts (06:00, OPT-34; it was midnight + 3 h)
mid = [at(DAY3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)]
ce, re_, pe = drive27(opts(), stops_at(LAST), pre=mid + [at(4, 2, 30)],
start=at(4, 2, 30))
ce, re_, pe = drive27(opts(), stops_at(LAST), pre=mid + [at(4, 5, 30)],
start=at(4, 5, 30))
le = nth(re_, -1, "last cycle")
check("02:30 (before midnight + 3 h): the figure from 17:05 is still used, "
check("05:30 (before the bell starts): the figure from 17:05 is still used, "
"as today's; the night run is planned",
present(le) and le["kept"] and le["runs_on"] is True)
cf, rf, pf = drive27(opts(), stops_at(LAST), pre=mid + [at(4, 3, 30)],
start=at(4, 3, 30))
cf, rf, pf = drive27(opts(), stops_at(LAST), pre=mid + [at(4, 6, 30)],
start=at(4, 6, 30))
lf = nth(rf, -1, "last cycle")
check("03:30 (after midnight + 3 h): a figure from before the night is not "
check("06:30 (the bell has started): a figure from before the night is not "
"used: no forecast, 1.5x, not worth, no charge",
present(lf) and lf["fc_none"] and lf["runs_on"] is False
and lf["reason"] == "not_worth" and not any(r["charging"] for r in rf))
+747
View File
@@ -0,0 +1,747 @@
"""Runnable check for OPT-34. `python3 test_opt34.py`
OPT-27 keeps the last fresh Forecast.Solar figure over the night. With the
sensors silent from 17:05 it ended at local midnight + 3 h, in the middle of the
night grid charge: Leftover solar's planned level went with it and the charge
stopped (rig, 2026-10-31: battery A 49.8 % at 07:00 against 79.9 % with a fresh
figure). Forecast.Solar may not refresh after midnight on a live site, so the
add-on must not depend on it. OPT-34:
1. a night charge that has started keeps its planned level when the forecast
goes (`Controller._latch_charge()`), and the next slice of the run is planned
with it (the wide no-forecast gap does not refuse it);
2. the evening's "tomorrow" figure is today's until the bell starts (06:00
local, `BELL_FROM_H`) instead of until midnight + 3 h;
3. for that case only, a reading may be up to 15 h old (a 17:05 reading is
12 h 55 min old at 06:00), but only if it was at most 12 h old at midnight;
4. a fresh figure replaces the kept one at once.
DECISIONS/OPT-34-the-night-charge-keeps-its-level.md.
Mutations (MUTATION.md, purged copies, graded over this file): see the
DECISIONS note and the dev report.
1. `solarfc` alone: the window, the age limits, the day, DST, restart.
2. The real Controller, the owner's shape (modes on, arbitrage off, A Leftover
solar + B Cheap hours, both 10 % at the start of the night, the night rate
22:00-07:00, tomorrow 6 kWh, a 2.5 kW peak limit so the charge takes hours),
sensors silent from 17:05, whole nights at 5 s per cycle (run in parallel
processes, about 2.5 minutes each):
a. silent from 17:05: both batteries end the night where a fresh figure
ends them (the base left A at 57 % against 87 %), and the screens agree;
b. restarts at 01:00 and 04:00 change nothing;
c. a charge that cannot start before 03:00 (the price dips at 04:00) still
starts at 04:00 on the kept figure;
d. a fresh figure at 02:00 replaces the level at once, up, during the charge;
and before a charge has started, up and down;
e. the last hour, after the kept figure has ended at 06:00: the charge under
way goes on to its level, also over a restart; the screens say so.
3. The text, EN and NL.
4. `_latch_charge()` alone: a battery that leaves the plan leaves the latch.
"""
import asyncio
import json
import logging
import os
import sys
import tempfile
import time
from concurrent.futures import ProcessPoolExecutor
from datetime import datetime, timedelta, timezone
from multiprocessing import get_context
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11p3_replay as rp # noqa: E402
import app.main as m # noqa: E402
from app import homeview, solarfc # noqa: E402
from app.main import Controller # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
from app.store import unit_key # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
L = timezone(timedelta(hours=1)) # Brussels in winter (CET)
def at(day, hh, mm=0):
"""Local time on 2026-11-`day` (CET) as UTC."""
return datetime(2026, 11, day, hh, mm, tzinfo=L).astimezone(timezone.utc)
def st(kwh, when, *, unit="kWh"):
return {"state": f"{kwh}", "attributes": {"unit_of_measurement": unit},
"last_updated": when.isoformat(),
"last_reported": when.isoformat()}
GONE = {"state": "unavailable", "attributes": {}}
T_ENT, M_ENT = "sensor.fc_today", "sensor.fc_tomorrow"
BRU = solarfc.BILLING_TZ
def bru(y, mo, d, hh, mm=0):
return datetime(y, mo, d, hh, mm, tzinfo=BRU).astimezone(timezone.utc)
def rs(today, tomorrow, now, hold):
return solarfc.read_sum([(T_ENT, today)], [(M_ENT, tomorrow)], now, hold)
def near(a, b, tol):
"""`|a - b| < tol`, and False (not a crash) when `a` is missing."""
return a is not None and abs(a - b) < tol
def used(hold, now):
f = rs(GONE, GONE, now, hold)[0]
return f is not None
def hold_of(read_at, today=0.4, tomorrow=12.0):
h = solarfc.Hold()
rs(st(today, read_at), st(tomorrow, read_at),
read_at + timedelta(minutes=5), h)
return h
# --------------------------------------------------------------------------- #
print("1. solarfc: the evening figure serves until the bell starts")
h17 = hold_of(at(3, 17, 5))
check("a 17:05 reading is kept at 20:00, 22:00, 00:00, 03:00 and 05:59",
all(used(h17, t) for t in (at(3, 20, 0), at(3, 22, 0), at(4, 0, 0),
at(4, 3, 0), at(4, 5, 59))))
f_late = rs(GONE, GONE, at(4, 5, 59), h17)[0]
check("... at 05:59 it is tomorrow's 12.0 kWh as TODAY's, the whole day",
f_late is not None and f_late.kept and f_late.today.kwh == 12.0
and f_late.tomorrow is None and f_late.at == at(3, 17, 5)
and f_late.between(at(4, 6, 0), at(4, 20, 0)) is not None
and abs(f_late.between(at(4, 6, 0), at(4, 20, 0)) - 12.0) < 1e-6)
check("... and at 06:00 (12 h 55 min old, the bell starts) it is not",
not used(h17, at(4, 6, 0)) and not used(h17, at(4, 6, 30))
and not used(h17, at(4, 12, 0)))
check("... not the next evening either, and not on the 5th",
not used(h17, at(4, 22, 0)) and not used(h17, at(5, 3, 0)))
# The age limit of this case: 15 h, for a figure that was usable at midnight.
h1230 = hold_of(at(3, 12, 30))
check("a 12:30 reading (11 h 30 min old at midnight): used until 03:29 "
"(14 h 59 min old) ...",
used(h1230, at(4, 0, 0)) and used(h1230, at(4, 3, 29)))
check("... and not from 03:31 (15 h 01 min), though it is before 06:00",
not used(h1230, at(4, 3, 31)) and not used(h1230, at(4, 5, 30)))
h1600 = hold_of(at(3, 16, 0))
check("a 16:00 reading: the 06:00 window ends it, not the 15 h limit "
"(14 h old at 06:00)", used(h1600, at(4, 5, 59))
and not used(h1600, at(4, 6, 0)))
# Not used: a morning figure at night (12 h), and no come-back after midnight.
h0905 = hold_of(at(3, 9, 5))
check("a 09:05 reading: kept at 20:00 (10 h 55 min), not at 22:00 (12 h 55 min)",
used(h0905, at(3, 20, 0)) and not used(h0905, at(3, 22, 0)))
check("... and it does not come back after midnight (14 h 55 min at 00:00, "
"under the 15 h of the evening-to-morning case): it was not usable "
"at midnight",
not used(h0905, at(4, 0, 0)) and not used(h0905, at(4, 0, 2))
and not used(h0905, at(4, 2, 0)))
h1105 = hold_of(at(3, 11, 59))
check("the same for a reading that is 12 h 01 min old at midnight",
not used(h1105, at(4, 0, 0)) and not used(h1105, at(4, 0, 30)))
h1201 = hold_of(at(3, 12, 1))
check("... and one that is 11 h 59 min old at midnight is used",
used(h1201, at(4, 0, 0)) and used(h1201, at(4, 3, 0)))
check("a same-day figure past 12 h is not used: a 07:05 reading at 20:00 "
"(12 h 55 min)", not used(hold_of(at(3, 7, 5)), at(3, 20, 0)))
check("a figure over 15 h old is not used: 17:05 read, 08:06 next day "
"(15 h 01 min, bell started)", not used(h17, at(4, 8, 6)))
check("(a figure from the day before is not used either)",
not used(hold_of(at(2, 17, 5)), at(3, 22, 0))
and not used(hold_of(at(2, 17, 5)), at(4, 3, 0)))
# Today's figure is not tomorrow's, and the figure is for its own day only.
hd = solarfc.Hold()
rs(st(3.0, at(3, 17, 0)), st(12.0, at(3, 17, 0)), at(3, 17, 5), hd)
fx = rs(GONE, GONE, at(4, 4, 0), hd)[0]
check("04:00: today's sensor is gone: the 4th gets the 3rd's TOMORROW "
"figure (12.0), never the 3rd's today figure (3.0)",
fx is not None and fx.today.kwh == 12.0)
fy = rs(st(5.0, at(4, 3, 50)), st(9.0, at(4, 3, 50)), at(4, 4, 0), hd)[0]
check("a fresh figure replaces the kept one at once (04:00: 5.0 today, "
"9.0 tomorrow; not kept)", fy is not None and not fy.kept
and fy.today.kwh == 5.0 and fy.tomorrow.kwh == 9.0)
fy2 = rs(st(5.0, at(4, 7, 55)), GONE, at(4, 8, 0), hd)[0]
check("... and at 08:00, today fresh and tomorrow's sensor down for over 3 h: "
"no tomorrow (the 3rd's figure is not the 5th's)",
fy2 is not None and fy2.tomorrow is None and fy2.today.kwh == 5.0)
# Restart: the saved hold keeps the figure.
for label, now in (("01:00", at(4, 1, 0)), ("04:00", at(4, 4, 0))):
back = solarfc.Hold(json.loads(json.dumps(hold_of(at(3, 17, 5)).dump())))
fr = rs(GONE, GONE, now, back)[0]
check(f"restart at {label}: the saved hold (FC_HOLD_KEY) gives the "
"17:05 figure again", fr is not None and fr.kept
and fr.today.kwh == 12.0 and fr.at == at(3, 17, 5))
# DST: the 25 h autumn day and the 23 h spring day. The window ends at 06:00
# LOCAL, not 6 h after midnight.
for (y, mo, d), label in (((2026, 10, 25), "25 h day 2026-10-25"),
((2027, 3, 28), "23 h day 2027-03-28")):
t17 = bru(y, mo, d - 1, 17, 5)
hs = hold_of(t17)
ok_in = used(hs, bru(y, mo, d - 1, 23, 59)) and all(
used(hs, bru(y, mo, d, hh, mm)) for hh, mm in
((0, 0), (1, 30), (4, 0), (5, 59)))
check(f"{label}: the 17:05 figure serves 23:59 to 05:59 local", ok_in)
check(f"... and not at 06:00 local", not used(hs, bru(y, mo, d, 6, 0))
and not used(hs, bru(y, mo, d, 6, 30)))
# --------------------------------------------------------------------------- #
print("\n2. The real Controller, whole nights (parallel processes)")
A, B = "inverter-a", "inverter-b"
PROFILE = ('{"periods": ['
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
' "eur_per_kwh": 0.26},'
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
' "eur_per_kwh": 0.34}]}')
# The price dips at 04:00 (a dynamic site): the cheap run starts after 03:00.
PROFILE_LATE = ('{"periods": ['
'{"name": "night", "days": "all", "from": "22:00",'
' "to": "04:00", "eur_per_kwh": 0.28},'
'{"name": "dawn", "days": "all", "from": "04:00",'
' "to": "07:00", "eur_per_kwh": 0.20},'
'{"name": "day", "days": "all", "from": "07:00",'
' "to": "22:00", "eur_per_kwh": 0.34}]}')
CHOICE = {"a": (True, "leftover_solar", False),
"b": (True, "cheap_hours", False)}
LAST = at(3, 17, 5)
EVENING = [at(3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)] \
+ [at(3, 21, 55)]
def opts(late=False, cap=2500):
o = dict(rp.options(True, False, 2))
o.update(cycle_cost_threshold_eur=0.07,
static_profile=PROFILE_LATE if late else PROFILE,
pv_forecast_entity=T_ENT, pv_forecast_tomorrow_entity=M_ENT,
peak_cap_w=cap)
return o
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:
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
def run_night(spec):
"""One scenario in this process -> a plain dict. `spec`: start (UTC),
hours, silent, fresh_from, fresh, restarts, soc, load_until_s, marks."""
start = spec["start"]
soc0 = spec.get("soc", {"a": 10.0, "b": 10.0})
house_w = spec.get("house_w", 700.0)
store = rp._Store()
for u, (use, mode, sell) in CHOICE.items():
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)),
fresh_until=spec.get("fresh_until"))
clock = [EVENING[0]]
class H(rp.Hass):
async def state(self, 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={})
def build(sto):
tmp = tempfile.mkdtemp(prefix="o34-")
c = Controller(dict(opts(spec.get("late", False),
spec.get("cap", 2500))), hass, sto,
rp._Pub(),
stop=asyncio.Event(),
peak_db_path=os.path.join(tmp, "tariff.db"),
price_history_path=os.path.join(tmp, "ph.json"))
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
c.house_profile.load({d: {str(hh): house_w for hh in range(24)}
for d in ("2026-10-31", "2026-11-01",
"2026-11-02")})
return c
async def go():
cur = store
c = build(cur)
real_dt, real_hv = m.datetime, homeview.datetime
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
try:
for t_pre in EVENING:
clock[0] = t_pre
await c._read_forecast()
pending = sorted(spec.get("restarts", ()))
marks = sorted(spec.get("marks", ()))
first, t = True, 0.0
hz = spec["hours"] * 3600.0
while t < hz:
now = start + timedelta(seconds=t)
clock[0] = now
if pending and now >= pending[0]:
pending.pop(0)
data = json.loads(json.dumps(cur.data))
cur = rp._Store()
cur.data.update(data)
c = build(cur)
first = True
if first or int(t) % 600 == 0:
# The tariff, resolved here at its own pace: the real
# refresh task sleeps on the wall clock.
hor = float(m.price_option(c.o, "price_horizon_h"))
c.tariff_plan = await m.resolve_tariff(
c.o, now, now + timedelta(hours=hor), now=now,
history=c.price_history, watch=c.price_watch)
c.tariff_at = now
first = False
hass.t = t
imp, exp = split_signed(hass.grid())
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=now,
ingest_mono=time.monotonic()))
await c.read_inputs(now_mono=t)
await c.cycle()
hass.advance(5.0, 10.0)
if int(t) % 300 == 0:
out["soc_at"][now.astimezone(L).strftime("%H:%M")] = (
hass.plants["a"].soc, hass.plants["b"].soc,
hass.plants["a"].p, c.solar_fc is None,
bool(c.solar_fc and c.solar_fc.kept),
None if c.plan_targets() is None
else c.plan_targets().get(A))
while marks and now >= marks[0]:
marks.pop(0)
cards = homeview.battery_rows(c, False, True)
blk = homeview.solar_block(c, now)
a_card = next(b for b in cards if b["name"] == A)
out["marks"][now.astimezone(L).strftime("%H:%M")] = dict(
line=blk["line"], state=blk["state"],
why_a=a_card["why_key"],
fc_none=c.solar_fc is None,
kept=bool(c.solar_fc and c.solar_fc.kept),
target_a=(c.plan_targets() or {}).get(A),
soc_a=hass.plants["a"].soc,
p_a=hass.plants["a"].p,
charging=hass.plants["b"].p < -200.0)
t += 5.0
out["soc"] = (hass.plants["a"].soc, hass.plants["b"].soc)
out["latch"] = getattr(c, "_charge_latch", None) is not None
finally:
m.datetime, homeview.datetime = real_dt, real_hv
return out
return asyncio.run(go())
NIGHT = at(3, 22, 0)
SCEN = {
"fresh": dict(start=NIGHT, hours=9, silent=False,
marks=[at(4, 4, 30)]),
"silent": dict(start=NIGHT, hours=9,
marks=[at(4, 1, 0), at(4, 4, 30), at(4, 6, 30)]),
"restart": dict(start=NIGHT, hours=9,
restarts=[at(4, 1, 0), at(4, 4, 0)],
marks=[at(4, 1, 30), at(4, 4, 30)]),
# c. the charge cannot start before 03:00 (the price dips at 04:00)
"late": dict(start=at(4, 2, 30), hours=4.5, late=True, house_w=500.0,
soc={"a": 40.0, "b": 70.0}, marks=[at(4, 3, 30)]),
"late_fresh": dict(start=at(4, 2, 30), hours=4.5, late=True,
house_w=500.0, soc={"a": 40.0, "b": 70.0},
silent=False),
# d. a fresh, darker figure at 02:00 during the charge (a lighter house
# use, so the level has room to rise)
"dark": dict(start=at(4, 1, 30), hours=2.0, soc={"a": 45.0, "b": 45.0},
house_w=500.0, fresh_from=at(4, 2, 0), fresh=(1.0, 5.0),
marks=[at(4, 1, 59), at(4, 2, 5)]),
"kept": dict(start=at(4, 1, 30), hours=2.0, soc={"a": 45.0, "b": 45.0},
house_w=500.0, marks=[at(4, 1, 59), at(4, 2, 5)]),
# before the charge starts: a fresh figure replaces the kept one, up and
# down, at once
"pre_kept": dict(start=at(4, 2, 30), hours=0.25, late=True,
house_w=500.0, marks=[at(4, 2, 44)]),
"pre_dark": dict(start=at(4, 2, 30), hours=0.25, late=True,
house_w=500.0, fresh_from=at(4, 2, 40),
fresh=(1.0, 5.0), marks=[at(4, 2, 44)]),
"pre_bright": dict(start=at(4, 2, 30), hours=0.25, late=True,
house_w=500.0, fresh_from=at(4, 2, 40),
fresh=(30.0, 30.0), marks=[at(4, 2, 44)]),
# d. the last hour, after the kept figure ended at 06:00
"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)]),
# 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
"tail_bfull": dict(start=at(4, 5, 40), hours=80 / 60.0, cap=1500,
soc={"a": 65.0, "b": 95.0},
marks=[at(4, 5, 50), at(4, 6, 10), at(4, 6, 45)]),
}
t0 = time.time()
with ProcessPoolExecutor(max_workers=min(6, os.cpu_count() or 2),
mp_context=get_context("fork")) as pool:
futs = {k: pool.submit(run_night, v) for k, v in SCEN.items()}
R = {k: f.result() for k, f in futs.items()}
print(f" {len(R)} scenarios in {time.time() - t0:.0f} s")
def show(k):
r = R[k]
print(f" {k:10} A {r['soc'][0]:.2f} % B {r['soc'][1]:.2f} % "
f"marks {', '.join(sorted(r['marks']))}")
for k in R:
show(k)
fa, si, rr = R["fresh"], R["silent"], R["restart"]
# a. silent from 17:05
check("a fresh figure all night: A ends at its planned level (87 %), B "
"full", abs(fa["soc"][0] - 86.9) < 0.5 and fa["soc"][1] > 94.5)
check("sensors silent from 17:05: A ends where the fresh run ends it "
f"({si['soc'][0]:.1f} against {fa['soc'][0]:.1f} %)",
abs(si["soc"][0] - fa["soc"][0]) < 0.3)
check("... and so does B (Cheap hours; the wide no-forecast gap does not "
f"stop its slices): {si['soc'][1]:.1f} against {fa['soc'][1]:.1f} %",
abs(si["soc"][1] - fa["soc"][1]) < 0.3)
check("A charges through 03:00 without a break (no step at 03:00)",
si["soc_at"]["03:30"][0] > si["soc_at"]["03:00"][0] + 4.0
and si["soc_at"]["03:00"][0] > si["soc_at"]["02:55"][0] + 0.3)
m1, m4 = si["marks"]["01:00"], si["marks"]["04:30"]
check("01:00 and 04:30: the kept figure is in use, with a day plan, and the "
"line says so (EN)",
not m1["fc_none"] and m1["kept"] and not m4["fc_none"] and m4["kept"]
and m4["state"] == "plan"
and m4["line"] == "Solar forecast read 17:05, kept for tonight."
and m4["target_a"] is not None
and near(m4["target_a"], fa["marks"]["04:30"]["target_a"], 1e-9))
check("... and the level is the fresh run's (the kept figure counts as a "
"fresh one)", near(m4["target_a"], 86.87, 0.2))
m6 = si["marks"]["06:30"]
check("06:30: the kept figure has ended (the bell started) and the screen "
"does not say 'kept' (the charge was done by then in this shape)",
m6["fc_none"] and "kept" not in m6["line"])
# b. restarts
check("restarts at 01:00 and 04:00 (a new Controller on the saved store): "
f"A {rr['soc'][0]:.1f}, B {rr['soc'][1]:.1f} % as without them",
abs(rr["soc"][0] - si["soc"][0]) < 0.3
and abs(rr["soc"][1] - si["soc"][1]) < 0.3)
check("... with the figure kept right after each restart",
all(rr["marks"][k]["kept"] and not rr["marks"][k]["fc_none"]
and rr["marks"][k]["target_a"] is not None
for k in ("01:30", "04:30")))
# c. a charge that cannot start before 03:00
la, lf = R["late"], R["late_fresh"]
ml = la["marks"]["03:30"]
check("the cheap hours start at 04:00: nothing charges at 03:30",
ml["soc_a"] < 40.1 and not ml["charging"])
check("... the kept figure still stands at 03:30 (no charge had started, so "
"no level to latch): the plan is kept, not 'no forecast'",
not ml["fc_none"] and ml["kept"] and ml["target_a"] is not None)
check("... and the charge starts at 04:00 on it: A ends where a fresh "
f"figure ends it ({la['soc'][0]:.1f} against {lf['soc'][0]:.1f} %)",
lf["soc"][0] > 50.0 and abs(la["soc"][0] - lf["soc"][0]) < 0.3
and abs(la["soc"][1] - lf["soc"][1]) < 0.3)
# d. a fresh figure replaces the kept one at once
dk, kp = R["dark"], R["kept"]
check("a fresh, darker figure at 02:00 during the charge: the level goes "
"UP at once (02:05)",
kp["marks"]["02:05"]["target_a"] is not None
and dk["marks"]["02:05"]["target_a"] is not None
and dk["marks"]["02:05"]["target_a"] > kp["marks"]["02:05"]["target_a"]
+ 3.0 and not dk["marks"]["02:05"]["kept"]
and dk["marks"]["01:59"]["kept"])
check("... and A charges on to it (more than on the kept figure: "
f"{dk['soc'][0]:.1f} against {kp['soc'][0]:.1f} %)",
dk["soc"][0] > kp["soc"][0] + 3.0)
pk, pd, pb = R["pre_kept"], R["pre_dark"], R["pre_bright"]
t_k, t_d, t_b = (x["marks"]["02:44"]["target_a"] for x in (pk, pd, pb))
print(f" level before any charge, 02:44: kept {t_k}, dark {t_d}, "
f"bright {t_b}")
check("before a charge has started (nothing charges at 02:44): a darker "
"fresh figure raises the level and a brighter one lowers it, at once",
all(not x["marks"]["02:44"]["charging"] for x in (pk, pd, pb))
and t_k is not None and t_d is not None and t_b is not None
and t_d > t_k + 3.0 and t_b < t_k - 3.0
and not pd["marks"]["02:44"]["kept"]
and not pb["marks"]["02:44"]["kept"])
# e. the last hour, after the kept figure ended
tl = R["tail"]
t5, t6, t7 = (tl["marks"][k] for k in ("05:50", "06:10", "06:40"))
check("05:50: charging on the kept figure; 06:10: the figure has ended",
t5["kept"] and not t5["fc_none"] and t6["fc_none"])
check("06:10 and 06:40 (the second after a restart at 06:30): A still "
"charges to its level, 'charges from the grid' is the Why",
t6["p_a"] < -500.0 and t7["p_a"] < -500.0
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)
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 "
"latched level: A goes on charging (06:10, 06:45; "
f"{tb['soc_at']['06:00'][0]:.1f} to {tb['soc_at']['06:55'][0]:.1f} %)",
tb["marks"]["06:10"]["fc_none"] and tb["marks"]["06:10"]["p_a"] < -500.0
and tb["marks"]["06:45"]["p_a"] < -500.0
and tb["soc_at"]["06:55"][0] > tb["soc_at"]["06:00"][0] + 8.0)
check("the screens say what control does (EN): 'No solar forecast now. The "
"charge under way goes on to its planned level.'",
t6["state"] == "latched" and t7["state"] == "latched"
and t6["line"] == "No solar forecast now. The charge under way goes "
"on to its planned level.")
# --------------------------------------------------------------------------- #
print("\n3. The text (EN and NL)")
en = json.load(open(os.path.join(HERE, "app", "ui_text", "en.json"),
encoding="utf-8"))
nl = json.load(open(os.path.join(HERE, "app", "ui_text", "nl.json"),
encoding="utf-8"))
l_en, l_nl = (x["home_words"]["solar"]["latched"] for x in (en, nl))
print(f" EN {l_en!r}\n NL {l_nl!r}")
check("the new line is short and plain in both languages and the NL is not "
"the English", l_en != l_nl and len(l_en.split()) <= 24
and len(l_nl.split()) <= 24 and "{" not in l_en + l_nl)
check("'kept for tonight' is unchanged (EN and NL)",
en["home_words"]["solar"]["kept"]
== "Solar forecast read {at}, kept for tonight."
and nl["home_words"]["solar"]["kept"]
== "Zonneprognose van {at}, bewaard voor vannacht.")
# --------------------------------------------------------------------------- #
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}),
rp._Store(), rp._Pub(), stop=asyncio.Event(),
peak_db_path=os.path.join(_tmp, "tariff.db"),
price_history_path=os.path.join(_tmp, "ph.json"))
n0 = at(4, 1, 0)
lruns = SimpleNamespace(cheap=priceruns.Run(at(3, 22, 0), at(4, 7, 0), 0.26))
charging = SimpleNamespace(action=modetrade.CHARGE,
roles={A: modetrade.CHARGE, B: modetrade.CHARGE})
resting = SimpleNamespace(action=None, roles={A: modetrade.HOLD,
B: modetrade.HOLD})
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, runs or lruns, targets, packs)
return lc._charge_latch, lc.store.data.get(m.CHARGE_LATCH_KEY)
FC = object() # a forecast is read (fresh or kept)
lt, saved = latch_step(FC, charging, {A: 80.0, B: 70.0})
check("both batteries charge with a forecast: both are latched at their "
"levels, to the run's end + one slot, and saved",
lt is not None and lt[1] == {A: 80.0, B: 70.0}
and lt[0] == at(4, 7, 15) and saved is not None
and saved["levels"] == {A: 80.0, B: 70.0})
lt, saved = latch_step(FC, charging, {B: 70.0})
check("A leaves the plan (its mode or use changed): A is dropped from the "
"latch at once, B stays", lt is not None and lt[1] == {B: 70.0}
and saved["levels"] == {B: 70.0})
lc.solar_fc = None
check("... and with the forecast then gone, A has no latched level (it is "
"not charged back to 80 %)", lc.plan_targets() == {B: 70.0})
lt, saved = latch_step(FC, resting, {A: 80.0, B: 70.0})
check("A back in the plan while nothing charges: it does not rejoin",
lt is not None and lt[1] == {B: 70.0})
lt, saved = latch_step(FC, resting, {A: 80.0})
check("B leaves the plan too: the latch is empty, so it is gone and "
"cleared from the store", lt is None and saved is None
and lc.plan_targets() is None)
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 = 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:
print(f"{len(fails)} FAILED:")
for f in fails:
print(" -", f)
sys.exit(1)
print("all checks passed")