From 412e47f111ab4582d8b921af4b830a18ec8ac757 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sat, 31 Oct 2026 17:00:31 +0100 Subject: [PATCH 1/5] OPT-34: the night charge keeps its level when the kept forecast ends With the forecast sensors silent from 17:05 the kept evening figure ended at midnight + 3 h, in the middle of the night grid charge, and Leftover solar's planned level went with it (rig: A 49.8 % against 79.9 %). - A night grid charge that has started keeps its planned level when the forecast goes (Controller._latch_charge, store key night_charge_latch), and the narrow price gap with it (weather_held). A fresh figure still moves the level. - The evening's tomorrow figure is today's until the bell starts (06:00 local), up to 15 h old but only if it was usable at midnight (solarfc.Hold.stand_in). - Screen: "No solar forecast now. The charge under way goes on to its planned level." (EN/NL) while a latched battery is below its level. - test_opt34.py (whole nights through the real control cycle, mutants), test_opt27 and test_modes_p4 adjusted to the new window and the kept level. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC --- goodwe_controller/CHANGELOG.md | 1 + ...PT-27-last-fresh-forecast-for-the-night.md | 10 +- ...OPT-34-the-night-charge-keeps-its-level.md | 53 ++ goodwe_controller/DOCS.md | 2 +- goodwe_controller/app/homeview.py | 20 +- goodwe_controller/app/main.py | 76 ++- goodwe_controller/app/solarfc.py | 66 ++- goodwe_controller/app/ui_text/en.json | 2 +- goodwe_controller/app/ui_text/nl.json | 2 +- goodwe_controller/test_modes_p4.py | 21 +- goodwe_controller/test_opt27.py | 45 +- goodwe_controller/test_opt34.py | 549 ++++++++++++++++++ 12 files changed, 783 insertions(+), 64 deletions(-) create mode 100644 goodwe_controller/DECISIONS/OPT-34-the-night-charge-keeps-its-level.md create mode 100644 goodwe_controller/test_opt34.py diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index d0a9970..21244f5 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -5,6 +5,7 @@ ### 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.** No option was added, removed or renamed, and no default changed (86 options). 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..f879cc2 --- /dev/null +++ b/goodwe_controller/DECISIONS/OPT-34-the-night-charge-keeps-its-level.md @@ -0,0 +1,53 @@ +# 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. +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 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. 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..bedf661 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -535,15 +535,24 @@ 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 battery is still below the level its night + charge keeps (`Controller.plan_targets()`), so control goes on charging.""" + 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 +573,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 +1651,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..6d152af 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -211,9 +211,30 @@ 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): + """`(until, {name: level %})` from the store, or None if it is not a + well-formed latch (OPT-34).""" + try: + until = datetime.fromisoformat(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 not levels or not all( + math.isfinite(v) and 0.0 <= v <= 100.0 for v in levels.values()): + return None + return until, levels + + 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 +1026,9 @@ 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)) self.house_profile = None if self.fc_entity is not None: self.house_profile = dayplan.HouseProfile( @@ -2265,9 +2289,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 +2380,48 @@ 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 + return None if lt is None else dict(lt[1]) return self._plan_targets + def _latch_charge(self, now, runs, targets) -> 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, each battery that charges + from the grid in the cheap run takes its planned level into the latch; + a fresh figure moves it up or down, as it moves the plan. While there + 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`); 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) + if lt is not None: + if in_run: + lt = (max(lt[0], run.end + timedelta( + seconds=CHARGE_LATCH_GRACE_S)), lt[1]) + elif now >= lt[0]: + lt = None + if (in_run and self.solar_fc is not None and targets + and tick is not None and tick.action == modetrade.CHARGE): + lv = dict(lt[1]) if lt is not None else {} + lv.update({n: float(targets[n]) for n, r in tick.roles.items() + if r == modetrade.CHARGE and n in targets}) + if lv: + until = run.end + timedelta(seconds=CHARGE_LATCH_GRACE_S) + lt = (max(until, lt[0]) if lt is not None else until, lv) + 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]}) + 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 +2494,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) # 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..b9a705b 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,20 @@ _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 +check("OPT-34: with a charge under way (B charged in this run), the level " + "stays: plan_targets() gives it, the line says the charge goes on", + latch is not None and c.plan_targets() is not None + and B in c.plan_targets() and c.plan_targets()[B] == c._plan_targets[B] + and homeview.day_key(c, B) is None) 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..267ebaf --- /dev/null +++ b/goodwe_controller/test_opt34.py @@ -0,0 +1,549 @@ +"""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. +""" + +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)): + """Sensors that update until 17:05 (or all night) and then show that + reading for good; from `fresh_from` a new reading every call.""" + def f(now, ent): + if fresh_from is not None and now >= fresh_from: + return st(fresh[0] if ent == T_ENT else fresh[1], now) + when = 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 + sens = sensors(silent=spec.get("silent", True), + fresh_from=spec.get("fresh_from"), + fresh=spec.get("fresh", (1.0, 5.0))) + clock = [EVENING[0]] + + class H(rp.Hass): + async def state(self, e): + return 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)]), + # 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) +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() +if fails: + print(f"{len(fails)} FAILED:") + for f in fails: + print(" -", f) + sys.exit(1) +print("all checks passed") -- 2.54.0 From eee761602270951beb26bd23871f4d3bd20c077a Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sat, 31 Oct 2026 19:17:20 +0100 Subject: [PATCH 2/5] OPT-34 round 2: the latch follows the plan, up or down, and drops what left it While a forecast is present and the cheap run holds, the night-charge latch is rebuilt from the plan: a latched battery takes its current level whatever its role this tick (a fresh figure may lower it below the SoC), one that left the plan is dropped, a new one comes in only while it charges from the grid. Test: a fresh brighter figure lowers A below its SoC, the sensors go quiet, A does not charge back up. CHANGELOG summary says two fixes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC --- goodwe_controller/CHANGELOG.md | 2 +- goodwe_controller/app/main.py | 40 +++++++++++++++++++-------------- goodwe_controller/test_opt34.py | 32 +++++++++++++++++++++++--- 3 files changed, 53 insertions(+), 21 deletions(-) diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 21244f5..2af807f 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -7,7 +7,7 @@ - The night grid charge no longer raises "Battery stopped limiting the peak" when the 15-minute period stays on the aim. A steadily rising load now alarms a little later, and with a peak margin of 0 a period can end up to 10 W over the limit without this alarm ([[SAFETY-126]]). - A night grid charge for *Leftover solar* no longer stops at 03:00 when the solar forecast sensors went quiet in the evening. The evening's figure is used until 06:00, and a charge that has started goes on to its level ([[OPT-34]]). -**A patch bump: one fix for charging from the grid under the peak limit, and no option changes.** +**A patch bump: two fixes, one for charging from the grid under the peak limit and one for the night charge of Leftover solar, and no option changes.** No option was added, removed or renamed, and no default changed (86 options). **Existing installs need no option change:** options that start 0.19.1 also start 0.19.2. The change below applies with one inverter or two. diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 6d152af..1a1c610 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -2392,9 +2392,11 @@ class Controller: charge that has started keeps its planned level when the forecast goes mid-charge (the kept figure ends and the sensors are still silent). - While there is a forecast, fresh or kept, each battery that charges - from the grid in the cheap run takes its planned level into the latch; - a fresh figure moves it up or down, as it moves the plan. While there + While there is a forecast, fresh or kept, the latch is rebuilt each + cycle from the plan: a battery that charges from the grid in the cheap + run comes in with its planned level, and stays in it at whatever level + the plan gives it, up or down as a fresh figure moves it (also when it + has stopped, at a lowered level), until it leaves the plan. While there is none, `plan_targets()` gives the latched levels, so the charge goes on to them, and `weather_held()` keeps the narrow price gap. The latch lasts through every cheap run on and one price slot after the last @@ -2403,20 +2405,24 @@ class Controller: tick = self.mode_tick run = None if runs is None else runs.cheap in_run = run is not None and run.holds(now) - if lt is not None: - if in_run: - lt = (max(lt[0], run.end + timedelta( - seconds=CHARGE_LATCH_GRACE_S)), lt[1]) - elif now >= lt[0]: - lt = None - if (in_run and self.solar_fc is not None and targets - and tick is not None and tick.action == modetrade.CHARGE): - lv = dict(lt[1]) if lt is not None else {} - lv.update({n: float(targets[n]) for n, r in tick.roles.items() - if r == modetrade.CHARGE and n in targets}) - if lv: - until = run.end + timedelta(seconds=CHARGE_LATCH_GRACE_S) - lt = (max(until, lt[0]) if lt is not None else until, lv) + until = None if run is None else run.end + timedelta( + seconds=CHARGE_LATCH_GRACE_S) + if lt is not None and not in_run and now >= lt[0]: + lt = None + if in_run and self.solar_fc is not None: + # Rebuilt from the plan: a battery already latched follows its + # level up or down whatever its role this tick (a fresh figure may + # lower it below the SoC), one that left the plan is dropped, and + # a new one comes in only while it charges from the grid. + tg = targets or {} + lv = {n: float(tg[n]) for n in (lt[1] if lt else {}) if n in tg} + if tick is not None and tick.action == modetrade.CHARGE: + lv.update({n: float(tg[n]) for n, r in tick.roles.items() + if r == modetrade.CHARGE and n in tg}) + lt = (max(until, lt[0]) if lt is not None else until, lv) \ + if lv else None + elif lt is not None and in_run: + lt = (max(lt[0], until), lt[1]) if lt != old: self._charge_latch = lt self.store.set(CHARGE_LATCH_KEY, None if lt is None else { diff --git a/goodwe_controller/test_opt34.py b/goodwe_controller/test_opt34.py index 267ebaf..1d3b74f 100644 --- a/goodwe_controller/test_opt34.py +++ b/goodwe_controller/test_opt34.py @@ -236,12 +236,14 @@ def opts(late=False, cap=2500): return o -def sensors(*, silent=True, fresh_from=None, fresh=(1.0, 5.0)): +def sensors(*, silent=True, fresh_from=None, fresh=(1.0, 5.0), + fresh_until=None): """Sensors that update until 17:05 (or all night) and then show that reading for good; from `fresh_from` a new reading every call.""" def f(now, ent): if fresh_from is not None and now >= fresh_from: - return st(fresh[0] if ent == T_ENT else fresh[1], now) + when = now if fresh_until is None else min(now, fresh_until) + return st(fresh[0] if ent == T_ENT else fresh[1], when) when = min(now, LAST) if silent else now return st(0.4 if ent == T_ENT else 6.0, when) return f @@ -260,7 +262,8 @@ def run_night(spec): store.data[unit_key(f"inverter-{u}", k)] = val sens = sensors(silent=spec.get("silent", True), fresh_from=spec.get("fresh_from"), - fresh=spec.get("fresh", (1.0, 5.0))) + fresh=spec.get("fresh", (1.0, 5.0)), + fresh_until=spec.get("fresh_until")) clock = [EVENING[0]] class H(rp.Hass): @@ -399,6 +402,12 @@ SCEN = { "tail": dict(start=at(4, 5, 40), hours=80 / 60.0, soc={"a": 70.0, "b": 70.0}, restarts=[at(4, 6, 30)], marks=[at(4, 5, 50), at(4, 6, 10), at(4, 6, 40)]), + # a fresh, brighter figure at 03:10 lowers A's level below its SoC (A + # stops, B goes on), then the sensors go quiet: the forecast ends at 06:10 + "lowered": dict(start=at(4, 2, 30), hours=4.5, house_w=500.0, + soc={"a": 30.0, "b": 30.0}, fresh_from=at(4, 3, 10), + fresh_until=at(4, 3, 10), fresh=(30.0, 30.0), + marks=[at(4, 3, 5), at(4, 3, 20), at(4, 6, 30)]), # the same, B already full and a 1.5 kW limit: the run is sized at the # battery's 2.5 kW rating and ends at 06:33, before A is there, so the # next slice is planned after 06:00 and is only A's @@ -509,6 +518,23 @@ check("06:10 and 06:40 (the second after a restart at 06:30): A still " and t6["why_a"] == "grid_charge" and t7["why_a"] == "grid_charge") check("... and ends the hour higher than it was (+8 % or more from 06:00)", tl["soc_at"]["06:55"][0] > tl["soc_at"]["06:00"][0] + 8.0) +lo = R["lowered"] +l0, l1, l2 = (lo["marks"][k] for k in ("03:05", "03:20", "06:30")) +check("a fresh, brighter figure lowers A's level below its SoC during the " + f"charge ({l0['target_a']} -> {l1['target_a']}, SoC " + f"{l1['soc_a']:.1f} %), and A stops", + l0["target_a"] is not None and l1["target_a"] is not None + and l1["target_a"] < l1["soc_a"] - 2.0 + and l1["target_a"] < l0["target_a"] - 10.0 + and lo["soc_at"]["03:30"][0] < lo["soc_at"]["03:20"][0] + 0.5) +check("the sensors go quiet, the forecast ends (06:30): the latch holds the " + "LOWERED level, so A does not charge back up to the old one " + f"({lo['soc_at']['03:30'][0]:.1f} -> {lo['soc_at']['06:55'][0]:.1f} %), " + "while B goes on", + l2["fc_none"] and l2["target_a"] is not None + and l2["target_a"] < l2["soc_a"] and l2["p_a"] > -200.0 + and lo["soc_at"]["06:55"][0] < lo["soc_at"]["03:30"][0] + 1.0 + and lo["soc"][1] > lo["soc_at"]["03:30"][1] + 15.0) tb = R["tail_bfull"] check("B full: the run ends at 06:33 before A is at its level, so the next " "slice is only A's, planned after the figure has ended with A's " -- 2.54.0 From 8f0af9f318bb080be63d16fbb13c68152651a535 Mon Sep 17 00:00:00 2001 From: "Claude (OPT-34 review)" Date: Sat, 31 Oct 2026 20:50:02 +0100 Subject: [PATCH 3/5] OPT-34 review: test that a battery leaving the plan leaves the latch test_opt34.py part 4 drives Controller._latch_charge() directly: a battery that leaves the plan is dropped at once, does not rejoin while nothing charges, an empty latch is cleared from the store, and the screen says the charge goes on only while the latched battery is below its level. Catches two reviewer mutants that survived the round-2 tests. Co-Authored-By: Claude Opus 5.5 --- goodwe_controller/test_opt34.py | 59 +++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/goodwe_controller/test_opt34.py b/goodwe_controller/test_opt34.py index 1d3b74f..516f55a 100644 --- a/goodwe_controller/test_opt34.py +++ b/goodwe_controller/test_opt34.py @@ -36,6 +36,8 @@ DECISIONS note and the dev report. 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 @@ -566,6 +568,63 @@ check("'kept for tonight' is unchanged (EN and NL)", 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 + +_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): + lc.solar_fc, lc.mode_tick = fc, tick + lc._latch_charge(now, lruns, targets) + 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) +ia.soc = 50.0 +st_below = homeview.plan_state(lc) +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") + print() if fails: print(f"{len(fails)} FAILED:") -- 2.54.0 From 57b0eb2f2095385f362048c8f00774bbe9d4ffbd Mon Sep 17 00:00:00 2001 From: "Claude (OPT-34 review)" Date: Sat, 31 Oct 2026 21:18:59 +0100 Subject: [PATCH 4/5] OPT-34 review: read the inverter in part 4 with a default test_named_absence.py flagged next() without a default (CI run 1473). Co-Authored-By: Claude Opus 5.5 --- goodwe_controller/test_opt34.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/goodwe_controller/test_opt34.py b/goodwe_controller/test_opt34.py index 516f55a..7e89e2e 100644 --- a/goodwe_controller/test_opt34.py +++ b/goodwe_controller/test_opt34.py @@ -616,11 +616,13 @@ check("B leaves the plan too: the latch is empty, so it is gone and " 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) -ia.soc = 50.0 -st_below = homeview.plan_state(lc) -ia.soc = 80.0 -st_at = homeview.plan_state(lc) +ia = next((i for i in lc.inverters if i.name == A), None) +st_below = st_at = None +if ia is not None: + ia.soc = 50.0 + st_below = homeview.plan_state(lc) + 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") -- 2.54.0 From bb57a85b3533b0d64da6a3ec7764e5522545e456 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sat, 31 Oct 2026 23:04:36 +0100 Subject: [PATCH 5/5] OPT-34 round 3: the latch ends on time, with the run, with the mode, and with the night - F1: an expired latch is dropped whatever the run state; plan_targets() and the store loader ignore one that has ended (no stale latch after a restart). - F2: the "charge goes on" line only while a cheap run is on. - F3: a battery that is off or no longer Leftover solar leaves the latch. - F4: a run carries the latch on only if it starts by the bell start (06:00 local) after the latch began (manager's call, 2026-10-31). Tests: test_opt34 scenarios stale/stale_live and part 5; test_modes_p4 asks at a moment inside the latch. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC --- ...OPT-34-the-night-charge-keeps-its-level.md | 15 ++- goodwe_controller/app/homeview.py | 9 +- goodwe_controller/app/main.py | 67 +++++++--- goodwe_controller/test_modes_p4.py | 11 +- goodwe_controller/test_opt34.py | 119 +++++++++++++++++- 5 files changed, 191 insertions(+), 30 deletions(-) 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 index f879cc2..f8dc7ad 100644 --- 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 @@ -16,7 +16,16 @@ known, so the add-on does not depend on it. no-forecast gap does not refuse the next slice of the run (`weather_held()`, 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. + 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. @@ -37,7 +46,7 @@ plan, the screens and a restart also need the figure itself. 06:00 and unchanged. After the figure ends, with a charge still going on: "No 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 latched battery is below its level). +(`home_words.solar.latched`, only while a cheap run is on and a latched battery is below its level). **Not used.** A figure over 15 h old; a morning figure at night (12 h); a figure for another day; a bad value or unit; and no latch for a charge that has not @@ -50,4 +59,4 @@ A fresh figure that LOWERS the level during a charge still lowers it, as before. **Mutants** (all red in `test_opt34.py`): no latch; window back to midnight + 3 h; 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. +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/app/homeview.py b/goodwe_controller/app/homeview.py index bedf661..9acf705 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -536,8 +536,13 @@ def plan_cover(c, name): def _latched_below(c) -> bool: - """OPT-34: no forecast, but a battery is still below the level its night - charge keeps (`Controller.plan_targets()`), so control goes on charging.""" + """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) diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 1a1c610..35fced1 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -221,18 +221,29 @@ CHARGE_LATCH_GRACE_S = 900.0 FORECAST_READ_S = 60.0 -def _load_latch(data): - """`(until, {name: level %})` from the store, or None if it is not a - well-formed latch (OPT-34).""" +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 not levels or not all( + if until.tzinfo is None or born.tzinfo is None or not levels or not all( math.isfinite(v) and 0.0 <= v <= 100.0 for v in levels.values()): return None - return until, levels + if now is not None and now >= until: + return None + return until, levels, born + + +def _night_limit(born): + """OPT-34: a run that starts after this cannot carry the latch on: the + first bell start (`solarfc.BELL_FROM_H`, local) after `born`.""" + d0, d1 = solarfc._bounds(born) + bell = solarfc._bell_start(d0) + return bell if bell > born else solarfc._bell_start(d1) def why_raised(resolution, claims) -> bool: @@ -1028,7 +1039,8 @@ class Controller: 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)) + (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( @@ -2384,10 +2396,12 @@ class Controller: of a night charge that has started (OPT-34): it keeps that level.""" if self.solar_fc is None: lt = self._charge_latch - return None if lt is None else dict(lt[1]) + if lt is None or datetime.now(timezone.utc) >= lt[0]: + return None + return dict(lt[1]) return self._plan_targets - def _latch_charge(self, now, runs, targets) -> None: + def _latch_charge(self, now, runs, targets, packs=None) -> None: """OPT-34, once per cycle after `modetrade.decide()`. A night grid charge that has started keeps its planned level when the forecast goes mid-charge (the kept figure ends and the sensors are still silent). @@ -2400,15 +2414,26 @@ class Controller: is none, `plan_targets()` gives the latched levels, so the charge goes 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`); a charge that never started has none.""" + (`CHARGE_LATCH_GRACE_S`), but a run is carried on only if it starts + by the bell start of the night the latch began in (`_night_limit()`; + the run under way then finishes). It is dropped when `now` passes its + end whatever the run state, and a battery that is off or no longer in + a mode with a planned level (`packs`) leaves it. A charge that never + started has none.""" old = lt = self._charge_latch tick = self.mode_tick run = None if runs is None else runs.cheap in_run = run is not None and run.holds(now) - until = None if run is None else run.end + timedelta( - seconds=CHARGE_LATCH_GRACE_S) - if lt is not None and not in_run and now >= lt[0]: + 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 @@ -2419,14 +2444,20 @@ class Controller: if tick is not None and tick.action == modetrade.CHARGE: lv.update({n: float(tg[n]) for n, r in tick.roles.items() if r == modetrade.CHARGE and n in tg}) - lt = (max(until, lt[0]) if lt is not None else until, lv) \ - if lv else None - elif lt is not None and in_run: - lt = (max(lt[0], until), lt[1]) + if 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]}) + "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 @@ -2500,7 +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) + 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/test_modes_p4.py b/goodwe_controller/test_modes_p4.py index b9a705b..698be67 100644 --- a/goodwe_controller/test_modes_p4.py +++ b/goodwe_controller/test_modes_p4.py @@ -810,11 +810,16 @@ check("a lost forecast says so: no forecast, Leftover solar solar only", homeview.solar_block(c, datetime.now(timezone.utc))["state"] == "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 says the charge goes on", + "stays: plan_targets() gives it (the line: test_opt34.py)", latch is not None and c.plan_targets() is not None - and B in c.plan_targets() and c.plan_targets()[B] == c._plan_targets[B] - and homeview.day_key(c, B) is 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_opt34.py b/goodwe_controller/test_opt34.py index 7e89e2e..516fd01 100644 --- a/goodwe_controller/test_opt34.py +++ b/goodwe_controller/test_opt34.py @@ -262,6 +262,8 @@ def run_night(spec): 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)), @@ -270,7 +272,7 @@ def run_night(spec): class H(rp.Hass): async def state(self, e): - return sens(clock[0], e) + return GONE if spec.get("gone") else sens(clock[0], e) hass = H(("a", "b"), lambda t: 300.0, soc0) out = dict(marks={}, soc_at={}) @@ -410,6 +412,18 @@ SCEN = { 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 @@ -537,6 +551,18 @@ check("the sensors go quiet, the forecast ends (06:30): the latch holds the " and l2["target_a"] < l2["soc_a"] and l2["p_a"] > -200.0 and 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 " @@ -573,6 +599,17 @@ print("\n4. The latch alone: a battery that leaves the plan leaves the latch") from types import SimpleNamespace # noqa: E402 from 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}), @@ -587,9 +624,10 @@ resting = SimpleNamespace(action=None, roles={A: modetrade.HOLD, B: modetrade.HOLD}) -def latch_step(fc, tick, targets, now=n0): +def latch_step(fc, tick, targets, now=n0, runs=None, packs=None): + CLOCK[0] = now lc.solar_fc, lc.mode_tick = fc, tick - lc._latch_charge(now, lruns, targets) + lc._latch_charge(now, runs or lruns, targets, packs) return lc._charge_latch, lc.store.data.get(m.CHARGE_LATCH_KEY) @@ -617,15 +655,88 @@ check("B leaves the plan too: the latch is empty, so it is gone and " latch_step(FC, charging, {A: 80.0}) lc.solar_fc = None ia = next((i for i in lc.inverters if i.name == A), None) -st_below = st_at = None +st_below = st_at = st_after = None +lc.mode_runs = lruns if ia is not None: 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: -- 2.54.0