OPT-11 P3 review: sale shows no money, honest sell text, tests for four surviving mutants
- 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:
co-authored by
Claude Opus 5.5
parent
902524ae5e
commit
b014fd2e16
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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."},
|
||||
|
||||
@@ -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."},
|
||||
|
||||
@@ -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:")
|
||||
|
||||
Reference in New Issue
Block a user