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
+1 -1
View File
@@ -4,7 +4,7 @@
### Not in a release yet
- Leftover solar no longer charges from the grid for hours that Cheap hours only or Solar first batteries will cover. It counts what they will hold when the expensive hours start (a Solar first battery after the night's use), and buys only the rest. The Batteries screen says which battery covers, and the "gives up to" figure is read after the planned charge.
- Leftover solar now counts what Cheap hours only and Solar first batteries will still hold when the expensive hours start, and buys from the grid only the rest. The Batteries screen says when other batteries cover those hours.
**A minor bump: a Forecast tab, a night grid charge sized to what the sun will not give, a kept evening forecast, and no option changes.**
No option was added, removed or renamed, and no default changed (86 options). It is a minor bump because Leftover solar now buys different amounts from the grid and the night charge keeps its level when the forecast goes quiet; every saved setting still starts.
@@ -6,7 +6,7 @@
- **Shortfall.** `sum_h max(0, house_h - trust x sun_h)` over every expensive hour, `/ EFF`, from the floor. It replaces the use over the run with no sun counted, which on a day and night tariff bought for the whole day (G1, `reports/UX-69-design-report.md`). The sun before the run is unchanged. A run with its dear hours after sunset gives the old number.
- **House use.** An hour with sun is read at no less than the night base, as the sun sum reads it (the profile is net of the sun). An hour without is read as measured. An hour with no forecast counts no sun.
- **Several batteries.** One total, shared by capacity with the ceiling passed on (`dayplan._spread()`). With the same floor and ceiling for all, they reach the ceiling together, so a shortfall that one battery cannot hold fills both to the same level, not one first. More than all hold: all go to the ceiling, `Plan.uncovered_kwh` is the rest, carried by another battery (Cheap hours, Solar first) or the grid, as the screen says.
- **Several batteries.** One total, shared by capacity with the ceiling passed on (`dayplan._spread()`). With the same floor and ceiling for all, they reach the ceiling together, so a shortfall that one battery cannot hold fills both to the same level, not one first. More than all hold: all go to the ceiling, `Plan.uncovered_kwh` is the rest, carried by the grid (OPT-36 counts the other batteries first), as the screen says.
- **Cheap hours** still fills up; **Solar first** still never charges from the grid. The mode help says so.
- **OPT-36 (0.20.1):** what Cheap hours batteries will give in the expensive hours is taken off the shortfall before Leftover solar shares it (`dayplan._cheap_give_kwh()`: its level when they start, the ceiling or what it charges under the peak aim in the cheap run and the later slots still worth charging in, minus the floor, x capacity x EFF; the run alone, sized at the ratings, let A's level fall after A had bought). Owner, 2026-11-02: Solar first batteries count too (`_solar_first_give_kwh()`): their level now less the house's night use until the hours start, shared by the energy held, then the same sum; a battery the night drains to the floor counts 0. Live, 2026-11-02: A at 60 % went to 95 % while B at 94 % covered the hours. Test: `test_opt36.py`.
- **Not done:** the run length (OPT-25).
- **OPT-36 (0.20.1):** Cheap hours and Solar first batteries give first; Leftover solar shares only the rest of the shortfall (`dayplan._cheap_give_kwh()`, `_solar_first_give_kwh()`). A Cheap hours battery counts the level its charge reaches: the cheap run, then later slots still worth charging in and no dearer than any slot from the expensive hours on, under the peak aim, x EFF stored. A Solar first battery counts what is left after the house's use until the hours start, each hour at no less than the night base. Live, 2026-11-02: A at 60 % went to 95 % while B at 94 % covered the hours. Owner, 2026-11-02: Solar first counts too. Test: `test_opt36.py`.
- **Not done:** the run length (OPT-25). On a dynamic tariff the Cheap hours count guesses where control will charge; a counted slot can still go unused, and that gap comes from the grid.
+24 -13
View File
@@ -240,14 +240,18 @@ def _cheap_give_kwh(runs, now, house, packs, *, floor_pct, ceiling_pct,
when the time left cannot get there. That time is the cheap run from now
(or the whole run ahead of it) and, given the firm price `slots`, each
later slot before the expensive hours (`until`, a give run) still worth
charging in: its price plus the spread control asks (`spread`) at most
the give run's dearest slot, `priceruns.plan()`'s test. The run is sized
at the batteries' ratings (`priceruns.need_hours()`), not the peak limit,
so a run too short to fill them is planned again in that time; counting
the run alone let A's level fall in the night after A had bought. In
that time the batteries together charge at their ratings, held to what
the peak aim leaves above the house's use that hour (`aim_w`, None: no
limit), shared by rating. No cheap run worth charging in: the level now.
charging in (its price plus the spread control asks, `spread`, at most
the give run's dearest slot, `priceruns.plan()`'s test) and no dearer
than every slot from the start of those hours on: control plans the
charge left in the cheapest window ahead, so a slot with a cheaper one
after it may never be used (round 4, a three-price night). The run is
sized at the batteries' ratings (`priceruns.need_hours()`), not the peak
limit, so a run too short to fill them is planned again in that time;
counting the run alone let A's level fall in the night after A had
bought. In that time the batteries together charge at their ratings,
held to what the peak aim leaves above the house's use that hour
(`aim_w`, None: no limit), shared by rating; x `EFF` of it is stored.
No cheap run worth charging in: the level now.
A battery that refuses a direction counts 0."""
ps = [p for p in packs if p.mode == CHEAP_HOURS and p.refused == 0]
if not ps:
@@ -261,10 +265,13 @@ def _cheap_give_kwh(runs, now, house, packs, *, floor_pct, ceiling_pct,
top = max((eur for s, _e, eur in slots or ()
if until is not None and until.start <= s < until.end),
default=None)
low = min((eur for s, _e, eur in slots or ()
if until is not None and s >= until.start), default=None)
if top is not None and spread is not None:
spans += [(max(s, now, run.end), min(e, until.start))
for s, e, eur in slots
if eur + float(spread) <= top + 1e-9
and (low is None or eur <= low + 1e-9)
and e > run.end and s < until.start]
if runs.charge_useful() and total > 0.0:
for t0, t1 in spans:
@@ -275,7 +282,8 @@ def _cheap_give_kwh(runs, now, house, packs, *, floor_pct, ceiling_pct,
- max(house_w(h), base_w, 0.0)))
hrs = (e - s).total_seconds() / 3600.0
for p in ps:
gain[p.name] += w * rate[p.name] / total * hrs / 1000.0
gain[p.name] += w * rate[p.name] / total * hrs \
/ 1000.0 * EFF
out = 0.0
for p in ps:
cap = float(p.capacity_kwh)
@@ -289,15 +297,18 @@ def _solar_first_give_kwh(house, now, start, packs, *, floor_pct):
"""OPT-36 (owner, 2026-11-02): what the Solar first batteries in `packs`
will still give when the expensive hours start at `start`, in kWh on the
house's side. They give at any hour, so the house's use from now to
`start` (the plan's own profile) is taken off first, shared by the energy
each holds above the floor, and what is left x `EFF` counts. A battery
`start` is taken off first, shared by the energy each holds above the
floor, and what is left x `EFF` counts. Each hour is read at no less than
the night base: the profile is net of the sun the house used, and read
net it let a dark day's morning drain a battery that the plan still
counted (round 4, real-price replays). A battery
that runs down to the floor before then counts 0; one that refuses a
direction counts 0."""
ps = [p for p in packs if p.mode == SOLAR_FIRST and p.refused == 0]
if not ps:
return 0.0
house_w, _base = house
use = sum(max(0.0, house_w(h)) * (e - s).total_seconds() / 3.6e6
house_w, base_w = house
use = sum(max(0.0, house_w(h), base_w) * (e - s).total_seconds() / 3.6e6
for s, e, h in _hour_slices(now, start))
have = {p.name: max(0.0, float(p.soc) - float(floor_pct)) / 100.0
* float(p.capacity_kwh) for p in ps}
+2 -1
View File
@@ -1489,7 +1489,8 @@ def line_solar(fc_kwh, count_kwh, at, kept=False) -> str:
def line_uncovered(kwh) -> str:
"""OPT-28: the Leftover solar batteries cannot hold the expensive hours'
shortfall. The rest comes from another battery or the grid."""
shortfall. The rest comes from the grid: OPT-36 counts the other
batteries first."""
return _t("solar.uncovered", kwh=_kwh(kwh))
+1 -1
View File
@@ -361,7 +361,7 @@
"tops_up": "It charges from the grid to {pct}. That covers {kwh} of expensive hours the sun will not.",
"no_forecast": "No solar forecast, so it charges from solar only."
},
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "uncovered": "The expensive hours need more than Leftover solar can hold. The rest, {kwh}, comes from another battery or the grid.", "kept": "Solar forecast read {at}, kept for tonight.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "latched": "No solar forecast now. The charge under way goes on to its planned level.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "uncovered": "The expensive hours need more than Leftover solar can hold. The rest, {kwh}, comes from the grid.", "kept": "Solar forecast read {at}, kept for tonight.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "latched": "No solar forecast now. The charge under way goes on to its planned level.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
"today": {
"not_planned": "Not planned yet.",
"no_runs": "No cheap or expensive hours left today.",
+1 -1
View File
@@ -301,7 +301,7 @@
"tops_up": "Ze laadt van het net tot {pct}. Dat dekt {kwh} dat de zon in dure uren niet levert.",
"no_forecast": "Geen zonneprognose, dus ze laadt alleen met zonne-energie."
},
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "uncovered": "De dure uren vragen meer dan Overschot zonne-energie kan opslaan. De rest, {kwh}, komt van een andere batterij of het net.", "kept": "Zonneprognose van {at}, bewaard voor vannacht.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "latched": "Nu geen zonneprognose. De lopende lading gaat door tot het geplande niveau.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "uncovered": "De dure uren vragen meer dan Overschot zonne-energie kan opslaan. De rest, {kwh}, komt van het net.", "kept": "Zonneprognose van {at}, bewaard voor vannacht.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "latched": "Nu geen zonneprognose. De lopende lading gaat door tot het geplande niveau.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
"today": {
"not_planned": "Nog niet gepland.",
"no_runs": "Vandaag geen goedkope of dure uren meer.",
+10 -7
View File
@@ -348,8 +348,8 @@ print(f" 4 kW house, 2 kWh: shortfall {sh4:.1f} kWh; both hold "
f"levels {p4.targets}")
check("more than all of them hold: both go to the ceiling, never above",
p4.targets[A] == CEIL and p4.targets[B] == CEIL)
check("...and the rest is said, not bought: another battery or the grid "
"carries it (`uncovered_kwh` = shortfall less what they cover)",
check("...and the rest is said, not bought: the grid carries it (OPT-36: "
"the other batteries are counted before) (`uncovered_kwh` = shortfall less what they cover)",
abs(p4.uncovered_kwh - (sh4 - rooms * EFF)) < 0.01
and abs(sum(p4.cover_kwh.values()) - rooms * EFF) < 0.01)
# The pass-through itself: a battery that reaches its ceiling passes the rest.
@@ -505,8 +505,9 @@ for lang in ("en", "nl"):
check(f"{lang}: '{key}' is at most 2 sentences of at most 12 words",
len(sentences) <= 2 and all(
len(s.split()) <= 12 for s in sentences))
# More than all hold: the Batteries screen says another battery or the grid
# carries the rest (here B is on Cheap hours: "the grid" alone is not true).
# More than all hold: the Batteries screen says the grid carries the rest.
# OPT-36 (on purpose, was "another battery or the grid"): B on Cheap hours is
# counted before, so what is left comes from the grid.
c2 = S.make("dynamic", now, soc=(20.0, 20.0))
c2.solar_fc = S.forecast(now, 1.0, 1.0)
c2.house_profile = profile_of({h: 3500.0 for h in range(24)})
@@ -519,14 +520,16 @@ print(f" a 3.5 kW house: shortfall {dp2.short_kwh:.1f} kWh, unheld "
check("the plan finds more than the battery holds", dp2.uncovered_kwh > 1.0
and c2.plan_targets()[A] == 95.0)
check("EN: the line under the Batteries cards says Leftover solar cannot "
"hold it all and the rest comes from another battery or the grid",
"hold it all and the rest comes from the grid",
"more than Leftover solar can hold" in sol["line"]
and "another battery or the grid" in sol["line"]
and "comes from the grid." in sol["line"]
and "another battery" not in sol["line"]
and homeview._kwh(dp2.uncovered_kwh) in sol["line"])
with homeview.in_lang("nl"):
sol_nl = homeview.solar_block(c2, now)
check("NL: and in Dutch", "meer dan Overschot zonne-energie kan opslaan"
in sol_nl["line"] and "andere batterij of het net" in sol_nl["line"]
in sol_nl["line"] and "komt van het net." in sol_nl["line"]
and "andere batterij" not in sol_nl["line"]
and "{" not in sol_nl["line"])
check("a covered plan has no such sentence",
"can hold" not in homeview.solar_block(c, now)["line"])
+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']} -> "
+2 -2
View File
@@ -912,9 +912,9 @@ t22 = at(22, 0, 5)
# OPT-36: the plan counts B's charge under the peak limit, the tab's curve
# charges at the batteries' ratings. Walked along the curve, B runs ahead of
# the plan and A's level falls behind the tab's block. A limit that does not
# bind (20 kW) makes the curve the charge control plans.
# bind (15 kW, the option's top) makes the curve the charge control plans.
c1, o1, s1, d01 = sweep("dynamic", t22, (20.0, 20.0), evening=4800.0,
peak_w=20000.0)
peak_w=15000.0)
tm1 = o1["days"][1]["batteries"]
print(" 22:00, both batteries at 20 %: tomorrow", [[(r["kind"], r["from"], r["to"]) for r in b["blocks"]] for b in tm1])
check("F1, modes on: at 22:00 with both batteries at 20 % tomorrow's give "