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:
@@ -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.
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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"}},
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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:")
|
||||||
|
|||||||
Reference in New Issue
Block a user