OPT-11 P3 review: sale shows no money, honest sell text, tests for four surviving mutants
checks / unit (pull_request) Successful in 14m5s
infra-pin / fresh (pull_request) Successful in 1s

- A sale block on the plan no longer says "Worth up to" at the import
  price: a sale earns the lower injection price.
- The selling switch help (EN/NL) and DOCS say the grid pays less than
  power costs; DECISIONS records why P3 loses on the replay.
- test_modes_p3 part 11: modes on ignore the site trade switch (R13), a
  battery in maintenance takes no part (R10), a headroom stop latches for
  the period (R8). All three mutants survived before and are red now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-09-29 03:51:15 +02:00
co-authored by Claude Opus 5.5
parent 902524ae5e
commit b014fd2e16
6 changed files with 77 additions and 12 deletions
@@ -51,12 +51,22 @@ P1 is its section 6 P1, and the pull request lists what was measured.
on flat morning prices, then charging went on at them);
- a run edge reseeds the loop at the house's level (a 3 kW import spike
removed; about 0.1 x rating still overshoots as export).
- Replay (tools/opt11p3_replay.py, 16 recorded days, EPEX prices a year
earlier, modelled batteries): P3 does not beat the P2 baseline on money
(net 19.57 / 19.60 EUR against 19.33; the old one-period trade 18.14).
A sale is judged on import prices but exports earn the injection price,
so selling lost money in the model. The owner should see this before
selling is recommended.
- Replay (tools/opt11p3_replay.py, 16 recorded days, EPEX hourly prices a
year earlier, modelled batteries), net EUR, lower is better: P2 19.33, P3
Solar first + Leftover solar 19.57, with a sale 19.60, the old one-period
trade 18.14. Review of #153 (same model, with a per-action split):
- the cheapest hours were at midday on 15 of 16 days, so Leftover solar (acting
as Cheap hours until P4) bought 12.2 kWh there that the sun would have
given: 7 kWh more exported, 12 kWh less solar stored. Holding for the
expensive hours saved 4 kWh of import, not enough. With no grid charge on
Leftover solar: 19.44;
- the old trade charged one hour, so the afternoon sun still fit;
- a sale earned the wholesale price (2.27 EUR for 11.4 kWh) and the house
then bought the same energy back (2.29 EUR): a wash before wear. It pays
only when the sale hour's wholesale price beats the later import price,
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.
+1 -1
View File
@@ -524,7 +524,7 @@ Off by default. Turn it on, restart the add-on, and the Batteries screen (and Se
- *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`).
- **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`).
With battery modes on, these choices replace the *Charging and selling on price* switch (`arbitrage_enabled`); that switch is only read with modes off.
+5 -3
View File
@@ -366,9 +366,10 @@ def mode_runs(c, day0, day1, now, shadow):
run_cheap_why(price),
run_grid_amount(kwh * price))
elif kind == "sell":
# No money line: the price is what the house pays, and a sale
# earns the lower injection price (OPT-11 P3 review).
what, why, amount = (run_sell_what(kwh, shadow),
run_dear_why(price),
run_give_amount(kwh * price))
run_dear_why(price), None)
else:
what, why, amount = (run_give_what(kwh, shadow),
run_dear_why(price),
@@ -376,7 +377,8 @@ def mode_runs(c, day0, day1, now, shadow):
out.append(_run(i, "give" if kind == "sell" else kind,
max(run.start, day0), min(run.end, day1), day0,
day1, what, why, amount, kwh=kwh,
eur=kwh * price, price=price))
eur=None if kind == "sell" else kwh * price,
price=price))
return out, None
+1 -1
View File
@@ -92,7 +92,7 @@
"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.", "off": "Not for Solar first."},
"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."},
+1 -1
View File
@@ -89,7 +89,7 @@
"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.", "off": "Niet voor Eerst zonne-energie."},
"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."},
+53
View File
@@ -21,6 +21,8 @@ time. DECISIONS/OPT-11-battery-modes.md has the record.
8. The site Why row: a limit that pushes power up does not "hold it back" (F2).
9. The selling switch (`pack_sell`).
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.
Mutations (MUTATION.md, purged copies, graded over this file and the named
tests) are listed in the pull request.
@@ -596,6 +598,10 @@ check("the plan draws B's expensive run, the sale inside it, and nothing "
"for Solar first A", _plan[0]
and {r["battery"] for r in _plan[0]} == {1}
and any(r["kind"] == "give" for r in _plan[0]))
_sale = [r for r in _plan[0] if r["what"].startswith("Sells")]
check("a sale block shows no money: the price shown is what the house pays, "
"and a sale earns the lower injection price",
_sale and all(r["amount"] is None and r["eur"] is None for r in _sale))
_why = homeview.battery_why(
modes_on=True, mode=packmode.CHEAP_HOURS, off=False, reading=True,
phase="idle", shadow=False, running=True, held=False, refused=0,
@@ -604,6 +610,53 @@ check("a holding battery's Why names when it gives power",
_why == "saves" and "18:00 - 19:00" in homeview.why_words(
_why, at="18:00 - 19:00"))
# ============================================================================ #
print("\n11. review additions (PR #153): gaps four mutants survived")
import types # noqa: E402
import app.main as _main # noqa: E402
# Modes on with the site switch on: only the modes trade (R13).
ROWS_X, _, _ = run_day("p2_arb", hours=3.0, soc0={"a": 30.0, "b": 30.0},
choice={"a": (True, "solar_first", False),
"b": (True, "solar_first", False)})
check("modes on, 'Trade on the price spread' on, both batteries Solar first: "
"no trade all through the cheap hours (the site trade is not run)",
ROWS_X and all(r["action"] is None for r in ROWS_X)
and all(v >= 0 for r in ROWS_X for v in r["shares"].values())
and not any(r["winner"].startswith("arbitrage") for r in ROWS_X))
# A battery in a maintenance phase takes no part in a run (R10).
ROWS_M, _, _cm = run_day("p3_ch_sell", hours=0.6, house_w=800.0,
soc0={"a": 60.0, "b": 30.0})
_lm = nth(ROWS_M, -1, "the last cycle")
_nm = _lm["now"] if present(_lm) else None
_tm = _cm._mode_roles(_nm, [types.SimpleNamespace(phase=_main.IDLE),
types.SimpleNamespace(phase="CHARGE")]) \
if _nm is not None else None
check("a battery in its monthly cycle is not a charger: no range, no charge "
"role, and no grid charge when it is the only price battery",
_tm is not None and B not in _tm.ranges
and _tm.roles.get(B) != modetrade.CHARGE and _tm.action is None)
# The headroom stopping a charge latches it off for the period (R8).
_cm2 = run_day("p3_ch_sell", hours=0.6, house_w=800.0,
soc0={"a": 60.0, "b": 30.0})[2]
_ok8 = False
if _nm is not None and _cm2.mode_tick is not None \
and _cm2.mode_tick.action == modetrade.CHARGE:
_q = int(_nm.timestamp() // 900)
_cm2.arb_headroom_w = 0.0
_t1 = _cm2._mode_roles(_nm, None)
_cm2.arb_headroom_w = 3000.0
_t2 = _cm2._mode_roles(_nm + timedelta(seconds=5), None)
_nq = datetime.fromtimestamp((_q + 1) * 900 + 5, timezone.utc)
_cm2.tariff_at = _nq
_t3 = _cm2._mode_roles(_nq, None)
_ok8 = (_t1.why == "headroom" and _cm2._charge_blocked_q in (_q, _q + 1)
and _t2.action is None and _t3.action == modetrade.CHARGE)
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()
if fails:
print(f"{len(fails)} FAILED:")