OPT-11 P3, owner option A: selling off, Leftover solar takes solar only
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:
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"}},
|
||||
|
||||
Reference in New Issue
Block a user