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);
- 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.
+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.
- **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."
+3 -3
View File
@@ -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))
+2
View File
@@ -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)
+2
View File
@@ -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.",
+5 -4
View File
@@ -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]
+16 -3
View File
@@ -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:
+3 -15
View File
@@ -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),
+3 -4
View File
@@ -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": {
+3 -4
View File
@@ -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"}},
+23 -18
View File
@@ -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"]
+80 -24
View File
@@ -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:")