Compare commits
6
Commits
SAFETY-126
..
OPT-34
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb57a85b35 | ||
|
|
57b0eb2f20 | ||
|
|
8f0af9f318 | ||
|
|
eee7616022 | ||
|
|
412e47f111 | ||
|
|
deee98d352 |
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ()
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user