diff --git a/goodwe_controller/DECISIONS/OPT-11-battery-modes.md b/goodwe_controller/DECISIONS/OPT-11-battery-modes.md index 5d806af..5c58558 100644 --- a/goodwe_controller/DECISIONS/OPT-11-battery-modes.md +++ b/goodwe_controller/DECISIONS/OPT-11-battery-modes.md @@ -67,6 +67,11 @@ P1 is its section 6 P1, and the pull request lists what was measured. levies included (about 0.13 EUR/kWh above that hour's wholesale here); - valuing the charge left at the end, wear and real Flemish levies make selling worse and do not change the order. -- Not done in P3: the forecast (P4: Leftover solar acts as Cheap hours only), - a headroom too short for the run moving the rest to the next cheap periods, - and an HA switch for selling. +- Owner decision 2026-09-29 (option A): selling is off until it can be judged + on the price the grid pays (`packmode.SELLING_ENABLED`, code kept), and + Leftover solar takes solar only until P4's forecast + (`packmode.GRID_CHARGE_MODES`); Cheap hours only still charges from the grid. Replayed + (16 days, as above): Solar first + Leftover solar 19.44 EUR net against + P2's 19.33; Solar first + Cheap hours only 19.57. +- Not done in P3: the forecast (P4), and a headroom too short for the run + moving the rest to the next cheap periods. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 1deffb8..2da5da8 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -522,9 +522,9 @@ Off by default. Turn it on, restart the add-on, and the Batteries screen (and Se - **On or off.** Off asks the battery for 0 W and starts no monthly cycle on it. A cycle that is running stops, and the add-on log says so. The peak limit may still use it, but only for what the other batteries cannot cover. - **Mode.** - *Solar first* charges from spare solar before the other batteries and covers the house at any hour. It never charges from the grid and never sells. - - *Leftover solar* charges from the solar the others cannot take, and from the grid in the cheapest hours. It saves its power for the expensive hours and covers the house then. - - *Cheap hours only* does the same with the grid. Until the solar forecast arrives, *Leftover solar* also tops up from the grid like this. -- **Sell in expensive hours.** For *Leftover solar* and *Cheap hours only*. In the most expensive hours the battery gives its full power: the house first, the rest to the grid. It stops at *Trading keeps at least* (`arbitrage_soc_floor`). The sale is judged on the price you pay, but the grid pays you the lower injection price, so selling rarely pays; on the replay it did not (`DECISIONS/OPT-11-battery-modes.md`). + - *Leftover solar* charges from the solar the others cannot take, never from the grid (until the solar forecast arrives). It saves its power for the expensive hours and covers the house then. + - *Cheap hours only* charges from the grid in the cheapest hours, saves its power for the expensive hours and covers the house then. +- **Selling** is off until it can be judged on the price the grid pays (owner decision 2026-09-29, option A). With battery modes on, these choices replace the *Charging and selling on price* switch (`arbitrage_enabled`); that switch is only read with modes off. @@ -547,7 +547,7 @@ With battery modes on, these choices replace the *Charging and selling on price* *For installers:* a mode is a limit at priority 35, kept apart from the hard limits. It gives way only as far as a limit above it (the peak guard) needs. A mode range always holds 0 W, so a mode can forbid a direction and never force one. With one inverter the mode also bounds the loop. The record is `DECISIONS/OPT-11-battery-modes.md`. -*For installers:* when a charge or sale ends, the loop restarts at the house's own level rather than at the trade's watts. On the replay model that removes a ~3 kW import spike after a 2.5 kW charge and halves the settling time; about 0.1 × the battery's power still overshoots as export. Selling is the `pack_sell` action (and the *Mode* list gains *Cheap hours only* in Home Assistant, at the end). +*For installers:* when a charge or sale ends, the loop restarts at the house's own level rather than at the trade's watts. On the replay model that removes a ~3 kW import spike after a 2.5 kW charge and halves the settling time; about 0.1 × the battery's power still overshoots as export. The *Mode* list gains *Cheap hours only* in Home Assistant, at the end. ### Reading the grid-following loop: `Loop integrator`, `Loop integrator lead`, `Loop reason` @@ -3282,9 +3282,8 @@ The **Batteries** tab has a card per battery: its level, and three lines. **Now** says what it does, **Today** lists the blocks planned for it (price trades, the monthly cycle, the peak limit), and **Why** says the reason. With battery modes on, each card also has the On/Off switch and the mode list; both -apply at once. Charging in cheap hours and selling are greyed out until a later -update, and Today reads "Not planned yet" when nothing is planned for the -battery. +apply at once. Today reads "Not planned yet" until the add-on has made the +day's price runs. An Off battery that the peak limit still needs says so, with the power it gives: "Off, but gives 0.5 kW for the peak limit." diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index 89d0641..72520d4 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -21,7 +21,7 @@ from datetime import datetime, timedelta, timezone from . import arbitrage, messages, peakguard, uitext from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD from .maintenance import IDLE as M_IDLE -from .packmode import CHEAP_HOURS, LEFTOVER_SOLAR, SOLAR_FIRST +from .packmode import CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, SOLAR_FIRST from .price import Absence, Uncertainty, brussels from .static_tariff import static_plan from .tariff import BILLING_TZ @@ -345,9 +345,9 @@ def mode_runs(c, day0, day1, now, shadow): else (soc - floor)) / 100.0 * cap) return kwh blocks = [] - if rp.charge_useful(): + if rp.charge_useful() and pm.mode in GRID_CHARGE_MODES: blocks.append(("grid", rp.cheap)) - sell = rp.sell if pm.sell else None + sell = rp.sell if pm.sell and pm.can_sell else None for g in rp.give: if sell is not None and g.start <= sell.start < g.end: blocks.append(("sell", sell)) diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 75c968c..ac2962b 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -2303,6 +2303,8 @@ class Controller: self._maint_stop_off(self.maints[self.inverters.index(inv)], now) elif what == "pack_sell": + if not packmode.SELLING_ENABLED: + return {"error": messages.pack_refused("sell_off")} if not isinstance(value, bool): return {"error": messages.pack_refused("value")} inv.store_set(packmode.SELL_KEY, value) diff --git a/goodwe_controller/app/messages.py b/goodwe_controller/app/messages.py index b4fa6f7..4445a60 100644 --- a/goodwe_controller/app/messages.py +++ b/goodwe_controller/app/messages.py @@ -938,6 +938,8 @@ PACK_REFUSED = { "off": "Battery modes are off. Turn on “Battery modes” first.", "unit": "There is no battery with that name.", "value": "That choice is not possible for this battery.", + # OPT-11 P3, owner decision 2026-09-29: selling is off for now. + "sell_off": "Selling is off for now.", # OPT-11 P2: a Home Assistant command dropped before it reaches a battery. "retained": "An old saved command is not used.", "size": "That command is too long.", diff --git a/goodwe_controller/app/modetrade.py b/goodwe_controller/app/modetrade.py index 1c5b4a6..81fa61d 100644 --- a/goodwe_controller/app/modetrade.py +++ b/goodwe_controller/app/modetrade.py @@ -44,7 +44,8 @@ Rules that keep this safe (tests: test_modes_p3.py): from dataclasses import dataclass -from .packmode import CHEAP_HOURS, LEFTOVER_SOLAR, SOLAR_FIRST +from .packmode import (CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, + SOLAR_FIRST) OFF, HOUSE, HOLD, GIVE, SELL, CHARGE, REST = ( "off", "house", "hold", "give", "sell", "charge", "rest") @@ -88,10 +89,10 @@ def _room_kwh(p: Pack, pct: float) -> float: def chargers(packs, ceiling_pct, done=()) -> list: - """Batteries that may charge from the grid in a cheap run (P3: no - forecast, so Leftover solar acts as Cheap hours only; owner answer 4).""" + """Batteries that may charge from the grid in a cheap run: the modes in + `packmode.GRID_CHARGE_MODES` (Cheap hours only until P4's forecast).""" return [p for p in packs - if p.use and p.idle and p.live and p.mode in PRICE_MODES + if p.use and p.idle and p.live and p.mode in GRID_CHARGE_MODES and p.soc is not None and p.soc < ceiling_pct and p.refused >= 0 and p.name not in done] diff --git a/goodwe_controller/app/packmode.py b/goodwe_controller/app/packmode.py index e13f05a..5fd71fe 100644 --- a/goodwe_controller/app/packmode.py +++ b/goodwe_controller/app/packmode.py @@ -40,6 +40,17 @@ USE_KEY = "pack_use" MODE_KEY = "pack_mode" SELL_KEY = "pack_sell" # OPT-11 P3: sell in the sell run +# Owner decision 2026-09-29 (option A): selling is off until it can be judged +# on the price the grid pays. While False, `read()` answers `sell=False` +# whatever the store holds, the page shows no switch and `pack_sell` is +# refused; the sell code in modetrade/priceruns stays, unreachable. +SELLING_ENABLED = False + +# The modes that may charge from the grid in a cheap run. Leftover solar joins +# when the solar forecast exists (P4); until then it takes solar only (owner +# decision 2026-09-29, option A). +GRID_CHARGE_MODES = (CHEAP_HOURS,) + def enabled(opts) -> bool: """Battery modes are on only when the option is exactly true. One answer, @@ -56,8 +67,9 @@ class PackMode: @property def can_sell(self) -> bool: - """Selling applies to the price modes only; Solar first never sells.""" - return self.mode in (LEFTOVER_SOLAR, CHEAP_HOURS) + """Selling applies to the price modes only; Solar first never sells. + False for every battery while SELLING_ENABLED is False.""" + return SELLING_ENABLED and self.mode in (LEFTOVER_SOLAR, CHEAP_HOURS) @property def key(self) -> str: @@ -73,7 +85,8 @@ def read(data: dict, name: str) -> PackMode: mode = data.get(unit_key(name, MODE_KEY), DEFAULT_MODE) return PackMode(use=use is not False, mode=mode if mode in MODES else DEFAULT_MODE, - sell=data.get(unit_key(name, SELL_KEY)) is True) + sell=SELLING_ENABLED + and data.get(unit_key(name, SELL_KEY)) is True) def mode_range(pm: PackMode, rating_w: float) -> tuple: diff --git a/goodwe_controller/app/static/app.js b/goodwe_controller/app/static/app.js index ce1f46b..8c3fca9 100644 --- a/goodwe_controller/app/static/app.js +++ b/goodwe_controller/app/static/app.js @@ -314,8 +314,8 @@ /* ---------------- Batteries (UX-13 P3) ---------------- */ // One card per battery. With battery modes on (`mode_key` in /status) each // card has an On/Off switch and a mode list, sent as `pack_use` and - // `pack_mode` through /action (OPT-11 P1), and a selling switch, sent as - // `pack_sell` (P3; Leftover solar and Cheap hours only). The Now, Today and Why lines are + // `pack_mode` through /action (OPT-11 P1). Selling has no switch while it + // is off (owner decision 2026-09-29). The Now, Today and Why lines are // homeview.py's sentences. The Simple settings view draws the same switch // and list into its own card (simpleHost). const MODES = ["solar_first", "leftover_solar", "cheap_hours"]; @@ -331,7 +331,6 @@ const j = await r.json().catch(() => ({})); if (!r.ok) toast(j.error || t("toast.failed")); else if (what === "pack_use") toast(say(value ? t("batteries.toast.on") : t("batteries.toast.off"), { name: b.name })); - else if (what === "pack_sell") toast(say(value ? t("batteries.toast.sell_on") : t("batteries.toast.sell_off"), { name: b.name })); else toast(say(t("batteries.toast.mode"), { name: b.name, mode: t("batteries.choice." + value + ".name") })); } catch (e) { toast(t("toast.failed")); } finally { packBusy = false; } @@ -348,16 +347,6 @@ onchange: (e) => packAct("pack_mode", b, e.target.value) }, ...MODES.map((k) => h("option", { value: k, selected: b.mode_choice === k }, t("batteries.choice." + k + ".name")))); } - function sellRow(b, i, where) { - const lid = "bsl-" + where + i; - const can = b.use && b.can_sell === true; - return h("div", { class: "f inline" }, - h("div", {}, h("div", { class: "lbl", id: lid }, t("batteries.sell.name")), - h("div", { class: "help" }, b.can_sell === true ? t("batteries.sell.description") : t("batteries.sell.off"))), - h("div", { class: "ctl" }, h("button", { type: "button", class: "switch", role: "switch", id: "bsw-sell-" + where + i, - "aria-checked": String(can && b.sell === true), "aria-labelledby": lid, disabled: !can, - onclick: () => packAct("pack_sell", b, !(b.sell === true)) }))); - } function todayLines(b) { if (!b.today || !b.today.length) return b.today_line || "–"; return b.today.map((r) => h("div", {}, r.from + " " + t("plan.time_to") + " " + r.to + ": " + r.what)); @@ -375,8 +364,7 @@ modes ? h("div", { class: "modebox" }, h("label", { class: "lbl small", for: "bsel-c" + i }, t("batteries.mode")), modeSelect(b, i, "c", null), - h("div", { class: "help" }, t("batteries.choice." + b.mode_key + ".description")), - sellRow(b, i, "c")) : null, + h("div", { class: "help" }, t("batteries.choice." + b.mode_key + ".description"))) : null, levelBar(b.level_pct, b.name), h("dl", { class: "kv" }, h("dt", {}, t("batteries.level")), h("dd", {}, lvl + stored), diff --git a/goodwe_controller/app/ui_text/en.json b/goodwe_controller/app/ui_text/en.json index 565ca62..ddb987b 100644 --- a/goodwe_controller/app/ui_text/en.json +++ b/goodwe_controller/app/ui_text/en.json @@ -87,20 +87,19 @@ "on": "On", "off": "Off", "choice": { "solar_first": {"name": "Solar first", "description": "Charges from solar before the other batteries and covers the house."}, - "leftover_solar": {"name": "Leftover solar", "description": "Gets solar the others cannot take, charges in cheap hours and gives power in expensive ones."}, + "leftover_solar": {"name": "Leftover solar", "description": "Gets solar the others cannot take, and gives power in expensive hours."}, "cheap_hours": {"name": "Cheap hours only", "description": "Charges in the cheapest hours and gives power in the expensive ones."}, "off": {"name": "Off", "description": "Rests, and helps only when the peak limit needs it."} }, "today": "Today", "why": "Why", - "sell": {"name": "Sell in expensive hours", "description": "Also sells to the grid in the most expensive hours, but you are paid less than you pay for power.", "off": "Not for Solar first."}, "modes_off": {"name": "Battery modes are off"}, "how": {"name": "How the modes work", "description": "Which battery gets solar first, and when each gives power.", "solar_first": {"description": "Solar first gets spare solar before the other batteries, and covers the house at any hour."}, - "leftover_solar": {"description": "Leftover solar gets the solar the Solar first batteries cannot take, charges in cheap hours, and saves its power for expensive hours."}, + "leftover_solar": {"description": "Leftover solar gets the solar the Solar first batteries cannot take, and saves its power for expensive hours."}, "cheap_hours": {"description": "Cheap hours only charges in the cheapest hours and gives power in the expensive ones."}, "off": {"description": "Off rests the battery and starts no monthly cycle, but the peak limit may still use it."}, "rules": {"description": "The peak limit and the safety stops come first, and while one battery charges from the grid the others rest."}}, - "toast": {"on": "{name} is on.", "off": "{name} is off.", "mode": "{name}: {mode}.", "sell_on": "{name} sells in expensive hours.", "sell_off": "{name} does not sell."}}, + "toast": {"on": "{name} is on.", "off": "{name} is off.", "mode": "{name}: {mode}."}}, "settings": {"name": "Settings", "description": "The settings are grouped by task below.", "open": "Open the settings page"} }, "choices_later": { diff --git a/goodwe_controller/app/ui_text/nl.json b/goodwe_controller/app/ui_text/nl.json index 9151605..086cb56 100644 --- a/goodwe_controller/app/ui_text/nl.json +++ b/goodwe_controller/app/ui_text/nl.json @@ -84,20 +84,19 @@ "on": "Aan", "off": "Uit", "choice": { "solar_first": {"name": "Eerst zonne-energie", "description": "Laadt met zonne-energie vóór de andere batterijen en dekt het huis."}, - "leftover_solar": {"name": "Overschot zonne-energie", "description": "Krijgt zonne-energie die de andere niet opnemen, laadt in goedkope uren en levert in dure uren."}, + "leftover_solar": {"name": "Overschot zonne-energie", "description": "Krijgt zonne-energie die de andere niet opnemen, en levert in dure uren."}, "cheap_hours": {"name": "Alleen goedkope uren", "description": "Laadt in de goedkoopste uren en levert in de dure uren."}, "off": {"name": "Uit", "description": "Rust, en helpt alleen als de piekgrens haar nodig heeft."} }, "today": "Vandaag", "why": "Waarom", - "sell": {"name": "Verkopen in dure uren", "description": "Verkoopt ook aan het net in de duurste uren, maar u krijgt minder dan u voor stroom betaalt.", "off": "Niet voor Eerst zonne-energie."}, "modes_off": {"name": "Batterijmodi staan uit"}, "how": {"name": "Zo werken de modi", "description": "Welke batterij eerst zonne-energie krijgt, en wanneer elke batterij levert.", "solar_first": {"description": "Eerst zonne-energie krijgt het overschot vóór de andere batterijen, en dekt het huis op elk uur."}, - "leftover_solar": {"description": "Overschot zonne-energie krijgt wat de batterijen met Eerst zonne-energie niet opnemen, laadt in goedkope uren en spaart haar stroom voor dure uren."}, + "leftover_solar": {"description": "Overschot zonne-energie krijgt wat de batterijen met Eerst zonne-energie niet opnemen, en spaart haar stroom voor dure uren."}, "cheap_hours": {"description": "Alleen goedkope uren laadt in de goedkoopste uren en levert in de dure uren."}, "off": {"description": "Uit laat de batterij rusten en start geen maandelijkse cyclus, maar de piekgrens mag haar nog gebruiken."}, "rules": {"description": "De piekgrens en de veiligheidsstops gaan voor, en terwijl een batterij van het net laadt rusten de andere."}}, - "toast": {"on": "{name} staat aan.", "off": "{name} staat uit.", "mode": "{name}: {mode}.", "sell_on": "{name} verkoopt in dure uren.", "sell_off": "{name} verkoopt niet."}}, + "toast": {"on": "{name} staat aan.", "off": "{name} staat uit.", "mode": "{name}: {mode}."}}, "settings": {"name": "Instellingen", "description": "De instellingen staan hieronder per taak.", "open": "Instellingen openen"} }, "choices_later": {"region": {"wallonia": "Wallonië, nog geen standaardwaarde", "brussels": "Brussel, nog geen standaardwaarde"}}, diff --git a/goodwe_controller/test_batteries_screen.py b/goodwe_controller/test_batteries_screen.py index 4654f01..cb457c1 100644 --- a/goodwe_controller/test_batteries_screen.py +++ b/goodwe_controller/test_batteries_screen.py @@ -36,7 +36,7 @@ import aiohttp # noqa: E402 from aiohttp import web as aioweb # noqa: E402 import ux13_demo as D # noqa: E402 -from app import homeview, packmode, uilayout as L, uipage, web # noqa: E402 +from app import homeview, messages, packmode, uilayout as L, uipage, web # noqa from app.maintenance import IDLE, DRAIN # noqa: E402 fails = [] @@ -225,11 +225,14 @@ print(f" morning: {_kinds}, lines {_p['plan']['lines']}") check("in the morning, with trades and a cycle ahead: Solar first A follows " "the house and has its cycle but no price block (OPT-11 P3: Solar first " "never trades), Off B has no block and waits for no cycle; with B on " - "(Leftover solar) B has its cheap and expensive runs and waits", + "(Leftover solar) B has its expensive run but no grid block (it takes " + "solar only until the forecast, owner decision 2026-09-29) and waits", {"house", "cycle"} == _kinds[0] and not _kinds[1] and not any(B_NAME in x for x in _p["plan"]["lines"]) - and {"grid", "give"} <= {r["kind"] for r in _m["plan"]["runs"] - if r["battery"] == 1} + and "give" in {r["kind"] for r in _m["plan"]["runs"] + if r["battery"] == 1} + and "grid" not in {r["kind"] for r in _m["plan"]["runs"] + if r["battery"] == 1} and any(B_NAME in x for x in _m["plan"]["lines"])) _p = build("off_peak") check("...but the peak block stays on it while the peak limit needs it", @@ -381,14 +384,15 @@ check("switch B back on: its stored mode returns", == packmode.LEFTOVER_SOLAR) check("OPT-11 P3: Cheap hours only is a mode now: 200, and /status shows it", _s4 == 200 and _st4["batteries"][0]["mode_choice"] - == packmode.CHEAP_HOURS and _st4["batteries"][0]["can_sell"] is True) -check("...selling on for A: 200, stored, and /status says so", - _s5 == 200 and _j5["result"]["sell"] is True - and _st5["batteries"][0]["sell"] is True) -check("...a selling value that is not true or false, and a mode that does not " - "exist, are refused with a plain 400, and nothing changes", - _s6 == 400 and _j6["error"] and _s7 == 400 and _j7["error"] - and _st7["batteries"][0]["sell"] is True + == packmode.CHEAP_HOURS) +check("owner decision 2026-09-29 (option A): selling is off, so a posted " + "pack_sell is refused with the plain reason 'Selling is off for now.', " + "and /status says no battery sells or can", + _s5 == 400 and _j5["error"] == messages.pack_refused("sell_off") + and _s6 == 400 and all(b["sell"] is False and b["can_sell"] is False + for b in _st5["batteries"] + _st6["batteries"])) +check("...a mode that does not exist is refused with a plain 400, and " + "nothing changes", _s7 == 400 and _j7["error"] and _st7["batteries"][0]["mode_choice"] == packmode.CHEAP_HOURS) _r = asyncio.run(_serve("running", [ ({"action": "pack_use", "unit": B, "value": False}, "en")])) @@ -412,18 +416,19 @@ check("Settings has a Simple / Installer choice as a radio group, and Simple " and PAGE.count('role="radio"') >= 2 and 'id="simple"' in PAGE) _acts = set(re.findall(r'packAct\("(\w+)"', JS)) check(f"the cards post only the existing actions ({sorted(_acts)}), as " - f"{{action, unit, value}}", _acts == set(web.PACK_ACTIONS) + f"{{action, unit, value}}; selling has no control while it is off", + _acts == set(web.PACK_ACTIONS) - {"pack_sell"} + and packmode.SELLING_ENABLED is False and "action: what, unit: b.name, value: value" in JS) check("the switch is a real switch and the list offers the three modes, " "cheap hours included, with nothing greyed out", 'role: "switch"' in JS and '"aria-checked"' in JS and 'const MODES = ["solar_first", "leftover_solar", "cheap_hours"];' in JS and "batteries.later" not in JS) -check("the selling switch posts pack_sell, and is greyed out where selling " - "does not apply (Off, Solar first)", - 'packAct("pack_sell", b, ' in JS - and "const can = b.use && b.can_sell === true;" in JS - and "disabled: !can" in JS) +check("no selling switch anywhere on the page or in Settings, and no words " + "for one", "pack_sell" not in JS and "sell" not in SJS + and "batteries.sell" not in JS + and all("sell" not in W["home"]["batteries"] for W in LANGS.values())) _T = uipage.ui_text("en") _lay = uipage.layout(D.schema_ui(D.CONFIG), {"en": {}}) _simple = [f for g in _lay["groups"] for p in g["parts"] for f in p["fields"] diff --git a/goodwe_controller/test_modes_p3.py b/goodwe_controller/test_modes_p3.py index 5a8207b..5dc2fa2 100644 --- a/goodwe_controller/test_modes_p3.py +++ b/goodwe_controller/test_modes_p3.py @@ -19,11 +19,13 @@ time. DECISIONS/OPT-11-battery-modes.md has the record. alone never trades. 7. A run edge reseeds the loop (handover measured with and without). 8. The site Why row: a limit that pushes power up does not "hold it back" (F2). -9. The selling switch (`pack_sell`). +9. Selling is off (owner, 2026-09-29): `pack_sell` refused, read as false. 10. The Batteries screen: Why and Today lines from the roles and runs. 11. Review additions: modes on ignore the site trade switch, a battery in maintenance takes no part, the headroom stop latches for the period. +12. Option A: no sale in a day; Leftover solar never grid-charges (P4). + Mutations (MUTATION.md, purged copies, graded over this file and the named tests) are listed in the pull request. """ @@ -272,7 +274,7 @@ for _ in range(20000): _bad.append(("range without 0", name, lo, hi)) for name, role in tk.roles.items(): p = by[name] - if role == modetrade.CHARGE and (p.mode == packmode.SOLAR_FIRST + if role == modetrade.CHARGE and (p.mode != packmode.CHEAP_HOURS or p.refused < 0 or not p.use or p.soc is None or p.soc >= 95.0): _bad.append(("bad charger", p)) @@ -359,6 +361,11 @@ def run_day(variant, *, house_w=2600.0, start_h=0.75, hours=21.0, return rows, st, c +# Selling is off (owner, 2026-09-29, option A). Its code is kept behind +# packmode.SELLING_ENABLED, and these checks turn it on to hold that code to +# its invariants; part 12 checks it is unreachable with the flag as shipped. +_SELL_SHIPPED = packmode.SELLING_ENABLED +packmode.SELLING_ENABLED = True ROWS, ST, _c4 = run_day("p3_ch_sell", soc0={"a": 60.0, "b": 30.0}) charge = [r for r in ROWS if r["action"] == arbitrage.CHARGE] sell = [r for r in ROWS if r["action"] == arbitrage.DISCHARGE] @@ -455,6 +462,7 @@ print(f" from 24 %: sold {len(_sf)} cycles, B ends at " check("the sale stops at arbitrage_soc_floor and does not re-arm in the run", _sf and _after and all(r["action"] != arbitrage.DISCHARGE for r in _after)) +packmode.SELLING_ENABLED = _SELL_SHIPPED # N=1: one Cheap hours battery. ROWS1, _, _ = run_day("p3_ch_sell", n=1, soc0={"a": 30.0}, @@ -551,32 +559,24 @@ check("...and a trade the peak floor raised reads the same way", [Claim.set("arbitrage", P_ARBITRAGE, -1000.0)]) is True) # ============================================================================ # -print("\n9. the selling switch") +print("\n9. the selling switch is off (owner decision 2026-09-29, option A)") _st = rp._Store() +_st.data[unit_key(B, packmode.SELL_KEY)] = True # an old stored "on" +_st.data[unit_key(B, packmode.MODE_KEY)] = packmode.CHEAP_HOURS _ctl = Controller(dict(rp.options(True, False)), rp.Hass(("a", "b"), lambda t: 0.0), _st, rp._Pub(), stop=asyncio.Event()) -_r1 = asyncio.run(_ctl.handle_action("pack_sell", unit=B, value=True)) -_bad9 = [asyncio.run(_ctl.handle_action("pack_sell", unit=B, value=v)) - for v in ("yes", 1, 0, None)] -_bad9.append(asyncio.run(_ctl.handle_action("pack_sell", unit="x", - value=True))) -check("pack_sell stores true/false per battery, logs it, and returns it", - _r1 == {"unit": B, "use": True, "mode": packmode.SOLAR_FIRST, - "sell": True} +_r9 = [asyncio.run(_ctl.handle_action("pack_sell", unit=B, value=v)) + for v in (True, False, "yes")] +check("pack_sell is refused with the plain reason 'Selling is off for now.' " + "and stores nothing", + all(x == {"error": messages.pack_refused("sell_off")} for x in _r9) and _st.data[unit_key(B, packmode.SELL_KEY)] is True - and any("selling switched on" in str(e) for e in _ctl.events)) -check("...anything else (1, 0, 'yes', None, an unknown battery) is refused " - "with a plain reason and stores nothing", - all(set(x) == {"error"} for x in _bad9) - and _st.data[unit_key(B, packmode.SELL_KEY)] is True) -check("...Solar first never sells, even with the switch on", - packmode.read(_st.data, B).sell is True - and packmode.read(_st.data, B).can_sell is False) -_off_ctl = Controller(dict(rp.options(False, False)), rp.Hass( - ("a", "b"), lambda t: 0.0), rp._Store(), rp._Pub(), stop=asyncio.Event()) -check("...and with battery modes off it is refused like the others", - "error" in asyncio.run(_off_ctl.handle_action("pack_sell", unit=B, - value=True))) + and not any("selling switched" in str(e) for e in _ctl.events)) +check("...a stored 'on' reads as false: no battery sells or can, whatever " + "the store holds", packmode.SELLING_ENABLED is False + and packmode.read(_st.data, B).sell is False + and packmode.read(_st.data, B).can_sell is False + and all(p.sell is False for p in _ctl._mode_packs())) # ============================================================================ # print("\n10. the Batteries screen") @@ -587,11 +587,13 @@ check("every role's Why line has words in English and Dutch, and they differ", all(k in _en["home_words"]["why"] and k in _nl["home_words"]["why"] and _en["home_words"]["why"][k] != _nl["home_words"]["why"][k] for k in _keys)) +packmode.SELLING_ENABLED = True # the kept sell code, as in part 4 _c10 = run_day("p3_ch_sell", start_h=18.0, hours=0.5, soc0={"a": 60.0, "b": 80.0})[2] _now10 = D0 + timedelta(hours=18, minutes=30) _plan = homeview.mode_runs(_c10, *homeview.local_day(_now10.astimezone( timezone.utc)), _now10.astimezone(timezone.utc), False) +packmode.SELLING_ENABLED = _SELL_SHIPPED print(f" plan blocks for B at 18:30: " f"{[(r['from'], r['to'], r['kind'], r['what']) for r in _plan[0]]}") check("the plan draws B's expensive run, the sale inside it, and nothing " @@ -657,6 +659,60 @@ if _nm is not None and _cm2.mode_tick is not None \ check("a charge the headroom stopped stays off for the rest of the period, " "even when the headroom comes back, and may start in the next one", _ok8) +# ============================================================================ # +print("\n12. option A: no sale, and Leftover solar takes solar only (P4)") +ROWS_A, _, _ = run_day("p3_ch_sell", soc0={"a": 60.0, "b": 30.0}, + choice={"a": (True, "solar_first", False), + "b": (True, "cheap_hours", True)}) +print(f" Cheap hours B with a stored sell 'on': " + f"{sum(r['action'] == arbitrage.CHARGE for r in ROWS_A)} charging, " + f"{sum(r['action'] == arbitrage.DISCHARGE for r in ROWS_A)} selling") +check("with selling on in the store, a whole day has no sale and no sell " + "role, while Cheap hours only still charges from the grid", + all(r["action"] != arbitrage.DISCHARGE for r in ROWS_A) + and all(modetrade.SELL not in r["roles"].values() for r in ROWS_A) + and any(r["action"] == arbitrage.CHARGE for r in ROWS_A)) +ROWS_L, _, _c12 = run_day("p3_ch_sell", soc0={"a": 60.0, "b": 30.0}, + choice={"a": (True, "solar_first", False), + "b": (True, "leftover_solar", True)}) +_lh = [r for r in ROWS_L if r["roles"].get(B) == modetrade.HOLD] +_lg = [r for r in ROWS_L if r["roles"].get(B) == modetrade.GIVE] +print(f" Leftover solar B: {len(_lh)} holding, {len(_lg)} giving, " + f"grid trades {sorted({str(r['action']) for r in ROWS_L})}") +check("Leftover solar never charges from the grid before the forecast: no " + "grid trade and no charge role all day, in the same cheap run", + all(r["action"] is None for r in ROWS_L) + and all(r["roles"].get(B) != modetrade.CHARGE for r in ROWS_L)) +check("...it still saves its power and gives it in the expensive hours", + _lh and _lg and all(r["now"].astimezone(L).hour == 19 for r in _lg)) +_pk = [modetrade.Pack("x", True, m, False, 2500.0, 30.0, 10.0) + for m in packmode.MODES] +check("...only Cheap hours only is a grid charger (packmode.GRID_CHARGE_MODES)", + [p.mode for p in modetrade.chargers(_pk, 95.0)] + == [packmode.CHEAP_HOURS]) +_c12b = run_day("p3_ch_sell", start_h=0.75, hours=0.25, + soc0={"a": 60.0, "b": 30.0}, + choice={"a": (True, "solar_first", False), + "b": (True, "leftover_solar", True)})[2] +_n12 = UTC0 + timedelta(hours=1) +_plan12 = homeview.mode_runs(_c12b, *homeview.local_day(_n12), _n12, + False)[0] +print(f" Leftover solar plan at 01:00: " + f"{[(r['from'], r['to'], r['kind']) for r in _plan12]}") +check("...and the plan draws no grid block for it, only its expensive run", + _plan12 and all(r["kind"] != "grid" for r in _plan12)) +_enw, _nlw = homeview.uitext.words("en"), homeview.uitext.words("nl") +check("...and its help text promises no grid charging (EN and NL)", + all("cheap" not in t.lower() and "goedkop" not in t.lower() + for t in (_enw["home"]["batteries"]["choice"]["leftover_solar"] + ["description"], + _nlw["home"]["batteries"]["choice"]["leftover_solar"] + ["description"], + _enw["home"]["batteries"]["how"]["leftover_solar"] + ["description"], + _nlw["home"]["batteries"]["how"]["leftover_solar"] + ["description"]))) + print() if fails: print(f"{len(fails)} FAILED:")