OPT-36 round 4: count only charge control will make; Solar first drain at the night base
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:
co-authored by
Claude
parent
1ac7fe98d7
commit
eb9ccc2730
@@ -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']} -> "
|
||||
|
||||
Reference in New Issue
Block a user