diff --git a/goodwe_controller/DECISIONS/OPT-11-battery-modes.md b/goodwe_controller/DECISIONS/OPT-11-battery-modes.md index 2345e92..5d806af 100644 --- a/goodwe_controller/DECISIONS/OPT-11-battery-modes.md +++ b/goodwe_controller/DECISIONS/OPT-11-battery-modes.md @@ -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. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 5589c11..1deffb8 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -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. diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index de8241e..89d0641 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -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 diff --git a/goodwe_controller/app/ui_text/en.json b/goodwe_controller/app/ui_text/en.json index abd574f..565ca62 100644 --- a/goodwe_controller/app/ui_text/en.json +++ b/goodwe_controller/app/ui_text/en.json @@ -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."}, diff --git a/goodwe_controller/app/ui_text/nl.json b/goodwe_controller/app/ui_text/nl.json index f374c88..9151605 100644 --- a/goodwe_controller/app/ui_text/nl.json +++ b/goodwe_controller/app/ui_text/nl.json @@ -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."}, diff --git a/goodwe_controller/test_modes_p3.py b/goodwe_controller/test_modes_p3.py index 33dc83e..5a8207b 100644 --- a/goodwe_controller/test_modes_p3.py +++ b/goodwe_controller/test_modes_p3.py @@ -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:")