OPT-11 P3, owner option A: selling off, Leftover solar takes solar only
checks / unit (pull_request) Successful in 15m12s
infra-pin / fresh (pull_request) Successful in 1s

Owner decision 2026-09-29. Selling is off until it can be judged on the price
the grid pays: packmode.SELLING_ENABLED = False makes read() answer
sell=False whatever the store holds, pack_sell is refused ("Selling is off
for now."), and the page has no selling switch; the sell code stays. Until
the P4 forecast, only Cheap hours only charges from the grid
(packmode.GRID_CHARGE_MODES); Leftover solar holds and gives in the
expensive runs. Help, Today and plan lines, DOCS and DECISIONS follow.
Replay, 16 days: Solar first + Leftover solar 19.44 EUR net (P2 19.33).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Claude dev agent
2026-09-29 10:42:58 +02:00
parent b014fd2e16
commit 6dab4ec16b
12 changed files with 154 additions and 85 deletions
@@ -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); 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 - valuing the charge left at the end, wear and real Flemish levies make
selling worse and do not change the order. selling worse and do not change the order.
- Not done in P3: the forecast (P4: Leftover solar acts as Cheap hours only), - Owner decision 2026-09-29 (option A): selling is off until it can be judged
a headroom too short for the run moving the rest to the next cheap periods, on the price the grid pays (`packmode.SELLING_ENABLED`, code kept), and
and an HA switch for selling. 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.
+6 -7
View File
@@ -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. - **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.** - **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. - *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. - *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* does the same with the grid. Until the solar forecast arrives, *Leftover solar* also tops up from the grid like this. - *Cheap hours only* charges from the grid in the cheapest hours, saves its power for the expensive hours and covers the house then.
- **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`). - **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. 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:* 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` ### 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 **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 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 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 apply at once. Today reads "Not planned yet" until the add-on has made the
update, and Today reads "Not planned yet" when nothing is planned for the day's price runs.
battery.
An Off battery that the peak limit still needs says so, with the power it 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." gives: "Off, but gives 0.5 kW for the peak limit."
+3 -3
View File
@@ -21,7 +21,7 @@ from datetime import datetime, timedelta, timezone
from . import arbitrage, messages, peakguard, uitext from . import arbitrage, messages, peakguard, uitext
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
from .maintenance import IDLE as M_IDLE 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 .price import Absence, Uncertainty, brussels
from .static_tariff import static_plan from .static_tariff import static_plan
from .tariff import BILLING_TZ from .tariff import BILLING_TZ
@@ -345,9 +345,9 @@ def mode_runs(c, day0, day1, now, shadow):
else (soc - floor)) / 100.0 * cap) else (soc - floor)) / 100.0 * cap)
return kwh return kwh
blocks = [] blocks = []
if rp.charge_useful(): if rp.charge_useful() and pm.mode in GRID_CHARGE_MODES:
blocks.append(("grid", rp.cheap)) 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: for g in rp.give:
if sell is not None and g.start <= sell.start < g.end: if sell is not None and g.start <= sell.start < g.end:
blocks.append(("sell", sell)) blocks.append(("sell", sell))
+2
View File
@@ -2303,6 +2303,8 @@ class Controller:
self._maint_stop_off(self.maints[self.inverters.index(inv)], self._maint_stop_off(self.maints[self.inverters.index(inv)],
now) now)
elif what == "pack_sell": elif what == "pack_sell":
if not packmode.SELLING_ENABLED:
return {"error": messages.pack_refused("sell_off")}
if not isinstance(value, bool): if not isinstance(value, bool):
return {"error": messages.pack_refused("value")} return {"error": messages.pack_refused("value")}
inv.store_set(packmode.SELL_KEY, value) inv.store_set(packmode.SELL_KEY, value)
+2
View File
@@ -938,6 +938,8 @@ PACK_REFUSED = {
"off": "Battery modes are off. Turn on “Battery modes” first.", "off": "Battery modes are off. Turn on “Battery modes” first.",
"unit": "There is no battery with that name.", "unit": "There is no battery with that name.",
"value": "That choice is not possible for this battery.", "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. # OPT-11 P2: a Home Assistant command dropped before it reaches a battery.
"retained": "An old saved command is not used.", "retained": "An old saved command is not used.",
"size": "That command is too long.", "size": "That command is too long.",
+5 -4
View File
@@ -44,7 +44,8 @@ Rules that keep this safe (tests: test_modes_p3.py):
from dataclasses import dataclass 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 = (
"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: def chargers(packs, ceiling_pct, done=()) -> list:
"""Batteries that may charge from the grid in a cheap run (P3: no """Batteries that may charge from the grid in a cheap run: the modes in
forecast, so Leftover solar acts as Cheap hours only; owner answer 4).""" `packmode.GRID_CHARGE_MODES` (Cheap hours only until P4's forecast)."""
return [p for p in packs 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.soc is not None and p.soc < ceiling_pct
and p.refused >= 0 and p.name not in done] and p.refused >= 0 and p.name not in done]
+16 -3
View File
@@ -40,6 +40,17 @@ USE_KEY = "pack_use"
MODE_KEY = "pack_mode" MODE_KEY = "pack_mode"
SELL_KEY = "pack_sell" # OPT-11 P3: sell in the sell run 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: def enabled(opts) -> bool:
"""Battery modes are on only when the option is exactly true. One answer, """Battery modes are on only when the option is exactly true. One answer,
@@ -56,8 +67,9 @@ class PackMode:
@property @property
def can_sell(self) -> bool: def can_sell(self) -> bool:
"""Selling applies to the price modes only; Solar first never sells.""" """Selling applies to the price modes only; Solar first never sells.
return self.mode in (LEFTOVER_SOLAR, CHEAP_HOURS) False for every battery while SELLING_ENABLED is False."""
return SELLING_ENABLED and self.mode in (LEFTOVER_SOLAR, CHEAP_HOURS)
@property @property
def key(self) -> str: 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) mode = data.get(unit_key(name, MODE_KEY), DEFAULT_MODE)
return PackMode(use=use is not False, return PackMode(use=use is not False,
mode=mode if mode in MODES else DEFAULT_MODE, 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: def mode_range(pm: PackMode, rating_w: float) -> tuple:
+3 -15
View File
@@ -314,8 +314,8 @@
/* ---------------- Batteries (UX-13 P3) ---------------- */ /* ---------------- Batteries (UX-13 P3) ---------------- */
// One card per battery. With battery modes on (`mode_key` in /status) each // 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 // 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_mode` through /action (OPT-11 P1). Selling has no switch while it
// `pack_sell` (P3; Leftover solar and Cheap hours only). The Now, Today and Why lines are // 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 // homeview.py's sentences. The Simple settings view draws the same switch
// and list into its own card (simpleHost). // and list into its own card (simpleHost).
const MODES = ["solar_first", "leftover_solar", "cheap_hours"]; const MODES = ["solar_first", "leftover_solar", "cheap_hours"];
@@ -331,7 +331,6 @@
const j = await r.json().catch(() => ({})); const j = await r.json().catch(() => ({}));
if (!r.ok) toast(j.error || t("toast.failed")); 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_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") })); else toast(say(t("batteries.toast.mode"), { name: b.name, mode: t("batteries.choice." + value + ".name") }));
} catch (e) { toast(t("toast.failed")); } } catch (e) { toast(t("toast.failed")); }
finally { packBusy = false; } finally { packBusy = false; }
@@ -348,16 +347,6 @@
onchange: (e) => packAct("pack_mode", b, e.target.value) }, 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")))); ...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) { function todayLines(b) {
if (!b.today || !b.today.length) return b.today_line || "–"; 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)); 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" }, modes ? h("div", { class: "modebox" },
h("label", { class: "lbl small", for: "bsel-c" + i }, t("batteries.mode")), h("label", { class: "lbl small", for: "bsel-c" + i }, t("batteries.mode")),
modeSelect(b, i, "c", null), modeSelect(b, i, "c", null),
h("div", { class: "help" }, t("batteries.choice." + b.mode_key + ".description")), h("div", { class: "help" }, t("batteries.choice." + b.mode_key + ".description"))) : null,
sellRow(b, i, "c")) : null,
levelBar(b.level_pct, b.name), levelBar(b.level_pct, b.name),
h("dl", { class: "kv" }, h("dl", { class: "kv" },
h("dt", {}, t("batteries.level")), h("dd", {}, lvl + stored), h("dt", {}, t("batteries.level")), h("dd", {}, lvl + stored),
+3 -4
View File
@@ -87,20 +87,19 @@
"on": "On", "off": "Off", "on": "On", "off": "Off",
"choice": { "choice": {
"solar_first": {"name": "Solar first", "description": "Charges from solar before the other batteries and covers the house."}, "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."}, "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."} "off": {"name": "Off", "description": "Rests, and helps only when the peak limit needs it."}
}, },
"today": "Today", "why": "Why", "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"}, "modes_off": {"name": "Battery modes are off"},
"how": {"name": "How the modes work", "description": "Which battery gets solar first, and when each gives power.", "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."}, "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."}, "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."}, "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."}}, "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"} "settings": {"name": "Settings", "description": "The settings are grouped by task below.", "open": "Open the settings page"}
}, },
"choices_later": { "choices_later": {
+3 -4
View File
@@ -84,20 +84,19 @@
"on": "Aan", "off": "Uit", "on": "Aan", "off": "Uit",
"choice": { "choice": {
"solar_first": {"name": "Eerst zonne-energie", "description": "Laadt met zonne-energie vóór de andere batterijen en dekt het huis."}, "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."}, "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."} "off": {"name": "Uit", "description": "Rust, en helpt alleen als de piekgrens haar nodig heeft."}
}, },
"today": "Vandaag", "why": "Waarom", "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"}, "modes_off": {"name": "Batterijmodi staan uit"},
"how": {"name": "Zo werken de modi", "description": "Welke batterij eerst zonne-energie krijgt, en wanneer elke batterij levert.", "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."}, "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."}, "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."}, "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."}}, "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"} "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"}}, "choices_later": {"region": {"wallonia": "Wallonië, nog geen standaardwaarde", "brussels": "Brussel, nog geen standaardwaarde"}},
+23 -18
View File
@@ -36,7 +36,7 @@ import aiohttp # noqa: E402
from aiohttp import web as aioweb # noqa: E402 from aiohttp import web as aioweb # noqa: E402
import ux13_demo as D # 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 from app.maintenance import IDLE, DRAIN # noqa: E402
fails = [] 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 " 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 " "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 " "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] {"house", "cycle"} == _kinds[0] and not _kinds[1]
and not any(B_NAME in x for x in _p["plan"]["lines"]) and not any(B_NAME in x for x in _p["plan"]["lines"])
and {"grid", "give"} <= {r["kind"] for r in _m["plan"]["runs"] and "give" in {r["kind"] for r in _m["plan"]["runs"]
if r["battery"] == 1} 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"])) and any(B_NAME in x for x in _m["plan"]["lines"]))
_p = build("off_peak") _p = build("off_peak")
check("...but the peak block stays on it while the peak limit needs it", 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) == packmode.LEFTOVER_SOLAR)
check("OPT-11 P3: Cheap hours only is a mode now: 200, and /status shows it", check("OPT-11 P3: Cheap hours only is a mode now: 200, and /status shows it",
_s4 == 200 and _st4["batteries"][0]["mode_choice"] _s4 == 200 and _st4["batteries"][0]["mode_choice"]
== packmode.CHEAP_HOURS and _st4["batteries"][0]["can_sell"] is True) == packmode.CHEAP_HOURS)
check("...selling on for A: 200, stored, and /status says so", check("owner decision 2026-09-29 (option A): selling is off, so a posted "
_s5 == 200 and _j5["result"]["sell"] is True "pack_sell is refused with the plain reason 'Selling is off for now.', "
and _st5["batteries"][0]["sell"] is True) "and /status says no battery sells or can",
check("...a selling value that is not true or false, and a mode that does not " _s5 == 400 and _j5["error"] == messages.pack_refused("sell_off")
"exist, are refused with a plain 400, and nothing changes", and _s6 == 400 and all(b["sell"] is False and b["can_sell"] is False
_s6 == 400 and _j6["error"] and _s7 == 400 and _j7["error"] for b in _st5["batteries"] + _st6["batteries"]))
and _st7["batteries"][0]["sell"] is True 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) and _st7["batteries"][0]["mode_choice"] == packmode.CHEAP_HOURS)
_r = asyncio.run(_serve("running", [ _r = asyncio.run(_serve("running", [
({"action": "pack_use", "unit": B, "value": False}, "en")])) ({"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) and PAGE.count('role="radio"') >= 2 and 'id="simple"' in PAGE)
_acts = set(re.findall(r'packAct\("(\w+)"', JS)) _acts = set(re.findall(r'packAct\("(\w+)"', JS))
check(f"the cards post only the existing actions ({sorted(_acts)}), as " 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) and "action: what, unit: b.name, value: value" in JS)
check("the switch is a real switch and the list offers the three modes, " check("the switch is a real switch and the list offers the three modes, "
"cheap hours included, with nothing greyed out", "cheap hours included, with nothing greyed out",
'role: "switch"' in JS and '"aria-checked"' in JS 'role: "switch"' in JS and '"aria-checked"' in JS
and 'const MODES = ["solar_first", "leftover_solar", "cheap_hours"];' and 'const MODES = ["solar_first", "leftover_solar", "cheap_hours"];'
in JS and "batteries.later" not in JS) in JS and "batteries.later" not in JS)
check("the selling switch posts pack_sell, and is greyed out where selling " check("no selling switch anywhere on the page or in Settings, and no words "
"does not apply (Off, Solar first)", "for one", "pack_sell" not in JS and "sell" not in SJS
'packAct("pack_sell", b, ' in JS and "batteries.sell" not in JS
and "const can = b.use && b.can_sell === true;" in JS and all("sell" not in W["home"]["batteries"] for W in LANGS.values()))
and "disabled: !can" in JS)
_T = uipage.ui_text("en") _T = uipage.ui_text("en")
_lay = uipage.layout(D.schema_ui(D.CONFIG), {"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"] _simple = [f for g in _lay["groups"] for p in g["parts"] for f in p["fields"]
+80 -24
View File
@@ -19,11 +19,13 @@ time. DECISIONS/OPT-11-battery-modes.md has the record.
alone never trades. alone never trades.
7. A run edge reseeds the loop (handover measured with and without). 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). 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. 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 11. Review additions: modes on ignore the site trade switch, a battery in
maintenance takes no part, the headroom stop latches for the period. 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 Mutations (MUTATION.md, purged copies, graded over this file and the named
tests) are listed in the pull request. tests) are listed in the pull request.
""" """
@@ -272,7 +274,7 @@ for _ in range(20000):
_bad.append(("range without 0", name, lo, hi)) _bad.append(("range without 0", name, lo, hi))
for name, role in tk.roles.items(): for name, role in tk.roles.items():
p = by[name] 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.refused < 0 or not p.use
or p.soc is None or p.soc >= 95.0): or p.soc is None or p.soc >= 95.0):
_bad.append(("bad charger", p)) _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 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}) 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] charge = [r for r in ROWS if r["action"] == arbitrage.CHARGE]
sell = [r for r in ROWS if r["action"] == arbitrage.DISCHARGE] 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", 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 _sf and _after and all(r["action"] != arbitrage.DISCHARGE
for r in _after)) for r in _after))
packmode.SELLING_ENABLED = _SELL_SHIPPED
# N=1: one Cheap hours battery. # N=1: one Cheap hours battery.
ROWS1, _, _ = run_day("p3_ch_sell", n=1, soc0={"a": 30.0}, 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) [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 = 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"), _ctl = Controller(dict(rp.options(True, False)), rp.Hass(("a", "b"),
lambda t: 0.0), _st, rp._Pub(), stop=asyncio.Event()) lambda t: 0.0), _st, rp._Pub(), stop=asyncio.Event())
_r1 = asyncio.run(_ctl.handle_action("pack_sell", unit=B, value=True)) _r9 = [asyncio.run(_ctl.handle_action("pack_sell", unit=B, value=v))
_bad9 = [asyncio.run(_ctl.handle_action("pack_sell", unit=B, value=v)) for v in (True, False, "yes")]
for v in ("yes", 1, 0, None)] check("pack_sell is refused with the plain reason 'Selling is off for now.' "
_bad9.append(asyncio.run(_ctl.handle_action("pack_sell", unit="x", "and stores nothing",
value=True))) all(x == {"error": messages.pack_refused("sell_off")} for x in _r9)
check("pack_sell stores true/false per battery, logs it, and returns it",
_r1 == {"unit": B, "use": True, "mode": packmode.SOLAR_FIRST,
"sell": True}
and _st.data[unit_key(B, packmode.SELL_KEY)] is True and _st.data[unit_key(B, packmode.SELL_KEY)] is True
and any("selling switched on" in str(e) for e in _ctl.events)) and not any("selling switched" in str(e) for e in _ctl.events))
check("...anything else (1, 0, 'yes', None, an unknown battery) is refused " check("...a stored 'on' reads as false: no battery sells or can, whatever "
"with a plain reason and stores nothing", "the store holds", packmode.SELLING_ENABLED is False
all(set(x) == {"error"} for x in _bad9) and packmode.read(_st.data, B).sell is False
and _st.data[unit_key(B, packmode.SELL_KEY)] is True) and packmode.read(_st.data, B).can_sell is False
check("...Solar first never sells, even with the switch on", and all(p.sell is False for p in _ctl._mode_packs()))
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)))
# ============================================================================ # # ============================================================================ #
print("\n10. the Batteries screen") 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"] 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] and _en["home_words"]["why"][k] != _nl["home_words"]["why"][k]
for k in _keys)) 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, _c10 = run_day("p3_ch_sell", start_h=18.0, hours=0.5,
soc0={"a": 60.0, "b": 80.0})[2] soc0={"a": 60.0, "b": 80.0})[2]
_now10 = D0 + timedelta(hours=18, minutes=30) _now10 = D0 + timedelta(hours=18, minutes=30)
_plan = homeview.mode_runs(_c10, *homeview.local_day(_now10.astimezone( _plan = homeview.mode_runs(_c10, *homeview.local_day(_now10.astimezone(
timezone.utc)), _now10.astimezone(timezone.utc), False) timezone.utc)), _now10.astimezone(timezone.utc), False)
packmode.SELLING_ENABLED = _SELL_SHIPPED
print(f" plan blocks for B at 18:30: " print(f" plan blocks for B at 18:30: "
f"{[(r['from'], r['to'], r['kind'], r['what']) for r in _plan[0]]}") 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 " 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, " 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) "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() print()
if fails: if fails:
print(f"{len(fails)} FAILED:") print(f"{len(fails)} FAILED:")