OPT-36 round 4: count only charge control will make; Solar first drain at the night base
checks / unit (pull_request) Successful in 44m14s
infra-pin / fresh (pull_request) Successful in 2s

Review F1: a slot after B's run counts only if no slot from the expensive
hours on is cheaper (control plans B's charge left in the cheapest window
ahead), and x 0.95 of B's charge is stored. Three-price night: 1ac7fe9 left
the expensive hours 2.61 kWh short; now 0.
Review F2: a Solar first battery's drain until the expensive hours reads each
hour at no less than the night base (the profile is net of the sun). Read net,
a dark morning drained the battery the plan still counted; A's level fell,
the cheap run moved and was lost before it started (+0.40 EUR per 3 days).
The two real-price replays now cost what 0.20.0 does.
F3: the ux54 sweep's peak limit is 15 kW (the option's top). F4: the rest
comes from the grid (EN/NL). F5: CHANGELOG and DECISIONS wording.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-02 12:56:17 +01:00
co-authored by Claude
parent 1ac7fe98d7
commit eb9ccc2730
9 changed files with 132 additions and 40 deletions
+88 -11
View File
@@ -23,7 +23,9 @@ hold" out; this adds it.
cycle): A's commands follow the new level, which holds all night while
the figure does; OPT-34 F-1 with B counted (a raised level is reached and
held); the OPT-34 latch still holds a started run (a lowered level stays
lowered).
lowered). Round 4: a three-price night holds the shortfall at 07:00, and
two real-price replays with a Solar first battery cost no more than
0.20.0.
Mutations (MUTATION.md, purged copies): listed in the pull request.
"""
@@ -208,12 +210,15 @@ check("EN and NL say that other batteries cover, in plain words",
# expensive hours start: level now less the house's night use to 07:00
# (01:01 to 07:00: 0.58 kW to 06:00, then 1.17 kW), shared among Solar first
# batteries, x 0.95. A Leftover solar at its floor buys only what is left.
use_n = sum(LIVE[h_] / 1000.0 * (e - s_).total_seconds() / 3600.0
c3 = make(LIVE, soc=(20.0, 94.0), modes=("leftover_solar", "solar_first"))
# Round 4: each hour at no less than the night base (the profile is net of
# the sun the house used).
_hw, base_n = c3.house_profile.estimator()
use_n = sum(max(LIVE[h_], base_n) / 1000.0 * (e - s_).total_seconds() / 3600.0
for s_, e, h_ in dayplan._hour_slices(
NOW, datetime(2026, 11, 2, 7, 0, tzinfo=BILLING_TZ)))
have_b = (94.0 - FLOOR) / 100.0 * CAP_B
give_sf = (have_b - use_n / EFF) * EFF
c3 = make(LIVE, soc=(20.0, 94.0), modes=("leftover_solar", "solar_first"))
d3 = c3.day_plan
want_hold = (d3.short_kwh - give_sf) / EFF
print(f" B Solar first 94 %: night use {use_n:.2f}, B gives {give_sf:.2f} "
@@ -278,7 +283,8 @@ def expect_short(kwh, shape=SHAPE, trust=0.7):
def expect_b_level(soc, cap, gain_kwh):
return max(soc, min(CEIL, soc + gain_kwh / cap * 100.0))
"""`gain_kwh` drawn from the grid; x 0.95 of it is stored (round 4)."""
return max(soc, min(CEIL, soc + gain_kwh * EFF / cap * 100.0))
SH = expect_short(6.0)
@@ -311,10 +317,13 @@ check("the grid kWh A saves is reported (`saved_kwh`)",
# No peak limit given: the batteries' ratings alone (4.6 kW x 3 h reaches 95).
p_free = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)], aim=None)
check("with no peak aim the run is held by the rating only: B reaches the "
"ceiling and gives all it can; A buys only the 0.16 kWh left",
abs(p_free.cheap_kwh - (CEIL - FLOOR) / 100.0 * CAP_B * EFF) < 0.005
and 0.0 < p_free.need_kwh[A] < 0.3 and p_free.uncovered_kwh == 0.0)
GIVE_FREE = (expect_b_level(30.0, CAP_B, 4.6 * 3.0) - FLOOR) / 100.0 \
* CAP_B * EFF
check("with no peak aim the run is held by the rating only: 4.6 kW x 3 h "
"x 0.95 stored takes B to 94 %; A buys only what is left",
abs(p_free.cheap_kwh - GIVE_FREE) < 0.005
and abs(p_free.need_kwh[A] - (SH - GIVE_FREE) / EFF) < 0.01
and p_free.uncovered_kwh == 0.0)
# A house at the aim leaves no room: B cannot charge, counts what it holds.
p_full = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)], aim=500.0)
@@ -603,9 +612,9 @@ EVENING = [at(3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)] \
+ [at(3, 21, 55)]
def night_opts(cap):
def night_opts(cap, profile=PROFILE):
o = dict(rp.options(True, False, 2))
o.update(cycle_cost_threshold_eur=0.07, static_profile=PROFILE,
o.update(cycle_cost_threshold_eur=0.07, static_profile=profile,
pv_forecast_entity=T_ENT, pv_forecast_tomorrow_entity=M_ENT,
peak_cap_w=cap)
return o
@@ -640,7 +649,8 @@ def run_night(spec):
hass = H(("a", "b"), lambda t: 300.0, spec["soc"])
out = dict(marks={}, soc_at={}, p_a=[])
tmp = tempfile.mkdtemp(prefix="o36-")
c = Controller(dict(night_opts(spec.get("cap", 2500))), hass, store,
c = Controller(dict(night_opts(spec.get("cap", 2500),
spec.get("profile", PROFILE))), hass, store,
rp._Pub(), stop=asyncio.Event(),
peak_db_path=os.path.join(tmp, "tariff.db"),
price_history_path=os.path.join(tmp, "ph.json"))
@@ -705,6 +715,7 @@ def run_night(spec):
target_a=(c.plan_targets() or {}).get(A),
need_a=None if dpl is None else dpl.need_kwh.get(A),
cheap=None if dpl is None else dpl.cheap_kwh,
short=None if dpl is None else dpl.short_kwh,
soc_a=hass.plants["a"].soc,
p_a=hass.plants["a"].p)
t += 5.0
@@ -717,6 +728,39 @@ def run_night(spec):
return asyncio.run(go())
# Round 4 (review F1): three night prices, the cheapest in the middle.
PROFILE3 = ('{"periods": ['
'{"name": "eve", "days": "all", "from": "22:00", "to": "01:00",'
' "eur_per_kwh": 0.15},'
'{"name": "low", "days": "all", "from": "01:00", "to": "03:00",'
' "eur_per_kwh": 0.10},'
'{"name": "dawn", "days": "all", "from": "03:00", "to": "07:00",'
' "eur_per_kwh": 0.15},'
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
' "eur_per_kwh": 0.34}]}')
def run_replay(variant, fc):
"""One 3-day opt11p4 replay (real 2026 prices, 2026-09-02) -> net EUR."""
import contextlib
import io
import opt11p4_replay as p4
rp.VARIANTS["own_sf"] = (True, False, {
"a": (True, "leftover_solar", False),
"b": (True, "solar_first", False)})
argv = sys.argv
sys.argv = ["p4", "--variant", variant, "--fc", fc, "--prices",
"real2026", "--shape", "periods", "--first", "2", "--days",
"3"]
buf = io.StringIO()
try:
with contextlib.redirect_stdout(buf):
p4.main()
finally:
sys.argv = argv
return json.loads(buf.getvalue().strip().splitlines()[-1])
NIGHT = at(3, 22, 0)
HOURLY = [NIGHT + timedelta(minutes=30 * i) for i in range(18)]
SCEN = {
@@ -737,6 +781,13 @@ SCEN = {
# Round 3, OPT-34 F-1 with B on Cheap hours and counted: a darker fresh
# figure at 02:00, then silent (no forecast from 05:00). The level rises,
# A charges to it, and the latch holds it after 05:00.
# Round 4 (review F1): the three-price night. B's charge after the run is
# planned in the next night's 0.10 hours, not in tonight's 0.15 ones, so
# those may not count. On 1ac7fe9 the plan counted B to 95 %, B ended at
# 67.5 % and the expensive hours were 2.61 kWh short; 0.20.0: none.
"three": dict(start=NIGHT, hours=9, soc={"a": 30.0, "b": 20.0},
house_w=700.0, cap=10000, fresh=(1.0, 1.0),
profile=PROFILE3, marks=HOURLY),
"f1_dark_b": dict(start=at(4, 0, 30), hours=6.5, cap=3500,
soc={"a": 50.0, "b": 50.0}, house_w=900.0,
fresh=(5.0, 5.0), change=at(4, 2, 0),
@@ -755,7 +806,12 @@ t0 = time.time()
with ProcessPoolExecutor(max_workers=min(4, os.cpu_count() or 2),
mp_context=get_context("fork")) as pool:
futs = {k: pool.submit(run_night, v) for k, v in SCEN.items()}
# Round 4 (review F2): the real-price replays with a Solar first battery.
reps = {k: pool.submit(run_replay, *v) for k, v in (
("ownsf_hourly_real", ("own_sf", "perfect")),
("p4_ls_dark_real", ("p4_ls", "zero")))}
R = {k: f.result() for k, f in futs.items()}
REP = {k: f.result() for k, f in reps.items()}
print(f" {len(R)} nights in {time.time() - t0:.0f} s")
lv = R["live"]
@@ -819,6 +875,27 @@ check("F-1 with B counted: a darker fresh figure at 02:00 raises A's level, "
and abs(fd["soc_at"]["06:55"][0] - dk["02:15"]["target_a"]) < 1.5
and fd["soc"][1] > 94.0)
th = R["three"]
sh3 = [mk["short"] for mk in th["marks"].values() if mk["short"] is not None]
held3 = sum(max(0.0, x - FLOOR) for x in th["soc"]) / 100.0 \
* rp.CAP_KWH * EFF
sh3_0 = nth(sh3, 0)
print(f" three prices: A {th['soc'][0]:.2f} %, B {th['soc'][1]:.2f} %; "
f"held at 07:00 {held3:.2f} kWh for a shortfall of {sh3_0} kWh "
"(1ac7fe9: 7.20 for 9.81, 0.20.0: 13.89)")
check("three night prices: B's charge is not counted in the 0.15 hours "
"after its run (the next night's 0.10 hours are cheaper), so A and B "
"together hold the expensive hours' shortfall at 07:00 (1ac7fe9: "
"2.61 kWh short, bought at 0.34)",
present(sh3_0) and held3 >= sh3_0 - 0.05)
for k, base_eur in (("ownsf_hourly_real", 4.00), ("p4_ls_dark_real", 3.84)):
r_ = REP[k]
print(f" replay {k}: {r_['net_eur']} EUR (0.20.0: {base_eur}), end "
f"{r_['end_soc']}")
check(f"real 2026 prices, 3 days, a Solar first battery counted ({k}): "
f"it costs no more than 0.20.0 ({base_eur} EUR; 1ac7fe9: "
f"{base_eur + 0.40:.2f})", r_["net_eur"] <= base_eur + 0.005)
lw = R["lowered"]
l0, l1, l2 = (lw["marks"][k] for k in ("03:05", "03:20", "06:30"))
print(f" lowered: A level {l0['target_a']} -> {l1['target_a']} -> "