diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index d0a9970..2af807f 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -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. diff --git a/goodwe_controller/DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md b/goodwe_controller/DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md index 973f238..ae9d68a 100644 --- a/goodwe_controller/DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md +++ b/goodwe_controller/DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md @@ -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). diff --git a/goodwe_controller/DECISIONS/OPT-34-the-night-charge-keeps-its-level.md b/goodwe_controller/DECISIONS/OPT-34-the-night-charge-keeps-its-level.md new file mode 100644 index 0000000..f8dc7ad --- /dev/null +++ b/goodwe_controller/DECISIONS/OPT-34-the-night-charge-keeps-its-level.md @@ -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. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 2f08c2f..f7ca6f9 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -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. diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index f9d564c..9acf705 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -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"]] diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 0434928..35fced1 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -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 diff --git a/goodwe_controller/app/solarfc.py b/goodwe_controller/app/solarfc.py index 7ddfa82..f4ce6bb 100644 --- a/goodwe_controller/app/solarfc.py +++ b/goodwe_controller/app/solarfc.py @@ -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, () diff --git a/goodwe_controller/app/ui_text/en.json b/goodwe_controller/app/ui_text/en.json index 135cb06..969cc99 100644 --- a/goodwe_controller/app/ui_text/en.json +++ b/goodwe_controller/app/ui_text/en.json @@ -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.", diff --git a/goodwe_controller/app/ui_text/nl.json b/goodwe_controller/app/ui_text/nl.json index 84dadf5..ab96551 100644 --- a/goodwe_controller/app/ui_text/nl.json +++ b/goodwe_controller/app/ui_text/nl.json @@ -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.", diff --git a/goodwe_controller/test_modes_p4.py b/goodwe_controller/test_modes_p4.py index 957fa05..698be67 100644 --- a/goodwe_controller/test_modes_p4.py +++ b/goodwe_controller/test_modes_p4.py @@ -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, diff --git a/goodwe_controller/test_opt27.py b/goodwe_controller/test_opt27.py index 2793b49..0967f3e 100644 --- a/goodwe_controller/test_opt27.py +++ b/goodwe_controller/test_opt27.py @@ -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)) diff --git a/goodwe_controller/test_opt34.py b/goodwe_controller/test_opt34.py new file mode 100644 index 0000000..516fd01 --- /dev/null +++ b/goodwe_controller/test_opt34.py @@ -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")