Files
goodwe-addon/goodwe_controller/test_opt36.py
T
glenn schrooyenandClaude eb9ccc2730
checks / unit (pull_request) Successful in 44m14s
infra-pin / fresh (pull_request) Successful in 2s
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>
2026-11-02 12:56:17 +01:00

920 lines
45 KiB
Python

"""Runnable check for OPT-36. `python3 test_opt36.py`
Leftover solar counts what Cheap hours only batteries will give in the
expensive hours before it buys from the grid at night (hotfix 0.20.1).
Live 0.20.0, 2026-11-02 01:01: A (Leftover solar, 60 %) went to 95 %
(5.38 kWh) while B (Cheap hours only, 94 %) already covered the expensive
hours. DECISIONS/OPT-23-28 had left "netting what a Cheap hours battery will
hold" out; this adds it.
1. The live figures through the real Controller: A buys 0, nothing is
"uncovered", and Home says the same in EN and NL.
2. `dayplan.plan()` alone: B low with a short cheap run under the peak limit,
A buys only what B cannot reach; the numbers written out. Round 3: B's
charge counts the cheap slots left before the expensive hours, not only
the run (sized at the ratings), so the level does not fall in the night.
3. A Cheap hours battery that is off, refusing, in maintenance, not live or
without capacity counts 0; Solar first counts after the night's use
(owner, 2026-11-02); N=1 and Leftover solar only are unchanged.
4. The screens: the give line reads the level after the planned charge,
`plan_cover()` is the plan's own figure, the Why line says who covers.
5. The real Controller through a whole night (parallel processes, 5 s per
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). 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.
"""
import asyncio
import json
import logging
import os
import sys
import tempfile
import time
from concurrent.futures import ProcessPoolExecutor
from datetime import datetime, timedelta, timezone
from multiprocessing import get_context
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
from named_absence import nth, present # noqa: E402
import ux54_scenarios as S # noqa: E402
import opt11p3_replay as rp # noqa: E402
import app.main as m # noqa: E402
from app import dayplan, homeview, modetrade, packmode # noqa: E402
from app import priceruns, solarfc, uitext # noqa: E402
from app.main import Controller # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
from app.peakguard import PeakDecision # noqa: E402
from app.static_tariff import static_plan # noqa: E402
from app.store import unit_key # noqa: E402
from app.tariff import BILLING_TZ # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
UTC = timezone.utc
EFF = dayplan.EFF
A, B = "inverter-a", "inverter-b"
FLOOR, CEIL = 20.0, 95.0
CAP_A, CAP_B = 15.36, 20.48
# ============================================================================ #
print("1. the live figures through the real Controller")
# ============================================================================ #
NOW = datetime(2026, 11, 2, 1, 1, 26, tzinfo=BILLING_TZ).astimezone(UTC)
def shape_of(scale=1.0):
"""The brief's owner example (0.6 kW at night, 1.2 kW 06-10, 0.9 kW by
day, 1.8 kW 17-22), scaled."""
return {h: scale * (600.0 if h < 6 or h >= 22 else 1200.0 if h < 10
else 900.0 if h < 17 else 1800.0) for h in range(24)}
def profile_of(shape, now=NOW):
d = now.astimezone(BILLING_TZ).date()
return dayplan.HouseProfile(
{(d - timedelta(days=k)).isoformat():
{str(h): w for h, w in shape.items()} for k in (1, 2, 3)})
def make(shape, soc=(60.0, 94.0), modes=("leftover_solar", "cheap_hours"),
today=10.2, tomorrow=10.0, now=NOW, peak_cap=None):
"""A Controller at `now`: day/night tariff, floor 20 %, ceiling 95 %,
three state-only sensors (10.2 kWh today), its mode roles run once."""
opts = dict(S.BASE)
opts.update(battery_modes_enabled=True,
pv_forecast_entities=[f"{S.FC1}_{i}" for i in range(3)],
pv_forecast_tomorrow_entities=[f"{S.FCT1}_{i}"
for i in range(3)],
static_profile=json.dumps(S.DN), arbitrage_enabled=False,
arbitrage_soc_floor=20, arbitrage_soc_ceiling=95,
cycle_cost_threshold_eur=0.04)
if peak_cap is not None:
opts.update(peak_cap_w=peak_cap)
plan = static_plan(opts, S.day0_of(now), S.day0_of(now) + timedelta(
hours=48), now=now)
c = Controller(opts, S.Hass(), S.Store(), S.Pub(), stop=S.asyncio.Event())
c.auto = True
for i, inv in enumerate(c.inverters):
inv.soc, inv.batt, inv.last_write = soc[i], 0.0, 0.0
inv.dev_min, inv.dev_max = -5000.0, 5000.0
c.store.data[unit_key(inv.name, packmode.MODE_KEY)] = modes[i]
c.grid, c.pv_w = 3000.0, 0.0
c.tariff_plan, c.tariff_at = plan, now
c.peak_guard = PeakDecision(engaged=False, floor_w=None, ceiling_w=7000.0,
ceiling_from="cap", projected_w=800.0)
c.arb_headroom_w = 3450.0 - 3000.0 + 2000.0
c.solar_fc = S.forecast(now, today, tomorrow, members=3)
c.solar_fc_why = None
c.house_profile = profile_of(shape, now)
c.tariff_at = now
c._mode_roles(now, None)
return c
def screen(c, lang="en", now=NOW):
st = homeview.build(c, level="ok", checks=[], now=now, lang=lang)
rows = {r["name"]: r for r in st["batteries"]}
return st, rows
LIVE = shape_of(0.973) # the scale whose plan matches live (11.3 kWh)
c = make(LIVE)
dp = c.day_plan
tg = c.plan_targets()
print(f" short {dp.short_kwh:.2f} kWh, Cheap hours give {dp.cheap_kwh:.2f}, "
f"A to {tg[A]} %, A buys {dp.need_kwh[A]:.2f} kWh, uncovered "
f"{dp.uncovered_kwh:.2f}")
check("live: A (Leftover solar 60 %) buys nothing while B (Cheap hours 94 %) "
"covers the expensive hours (was: to 95 %, 5.38 kWh)",
dp.need_kwh[A] == 0.0 and tg[A] <= 60.0
and homeview.plan_need(c, A) == 0.0)
check("live: nothing is left uncovered (was 0.4 kWh), and B's give is "
"counted up to the shortfall and no more",
dp.uncovered_kwh == 0.0 and abs(dp.cheap_kwh - dp.short_kwh) < 1e-6)
check("live: control gives A no grid charge at all (the run charges B only)",
[p.name for p in modetrade.chargers(
c._mode_packs(None), CEIL, targets=c.plan_targets())] == [B])
for lang in ("en", "nl"):
st, rows = screen(c, lang)
mine = [r for r in st["plan"]["runs"]
if r["battery"] == 0 and r["kind"] != "house"]
theirs = [r for r in st["plan"]["runs"]
if r["battery"] == 1 and r["kind"] != "house"]
line = st["solar"]["line"]
check(f"{lang}: A has no grid block, only its give block "
f"({[r['kind'] for r in mine]})",
[r["kind"] for r in mine] == ["give"])
check(f"{lang}: A's give block is what A holds (60 % -> 6.1 kWh)",
abs(nth(mine, 0)["kwh"] - (60.0 - FLOOR) / 100.0 * CAP_A) < 0.01)
check(f"{lang}: B's give block reads B after its charge (to 95 %: "
"15.4 kWh, not 15.2 from 94 %)",
abs(nth(theirs, -1)["kwh"] - (CEIL - FLOOR) / 100.0 * CAP_B) < 0.01)
check(f"{lang}: the solar line has no 'the rest' sentence",
"0.4" not in line and "0,4" not in line
and "rest, " not in line and "rest van" not in line
and "rest," not in line)
check(f"{lang}: no line anywhere says Leftover solar charges from the "
"grid to 95",
all("95" not in r["why"] for r in rows.values())
and all("95 %" not in t["what"] for r in rows.values()
for t in r["today"] if "grid" in t["kind"]
and r is rows[A]))
check("live: while B charges, A's Why says another battery charges (it "
"rests)", screen(c, "en")[1][A]["why_key"] == "rests_charge")
# B at 100 % of its run done: the cheap run is over, B only gives. A's Why
# says who covers the hours, not the sun.
c2 = make(LIVE, soc=(60.0, 95.0))
c2._mode_roles(NOW, None)
role_a = c2.mode_tick.roles[A]
key_a = homeview.day_key(c2, A)
print(f" B full: A's role {role_a}, day key {key_a}")
check("B already at the ceiling: A has no charge, and the day key says the "
"Cheap hours battery covers, not the sun",
key_a == "others_cover" and c2.day_plan.need_kwh[A] == 0.0
and c2.day_plan.saved_kwh[A] > 5.0)
for lang in ("en", "nl"):
with homeview.in_lang(lang):
txt = homeview.why_words("others_cover")
sentences = [x for x in txt.split(".") if x.strip()]
check(f"{lang}: the 'others_cover' line is at most 2 sentences of at "
f"most 12 words and has no raw placeholder: {txt!r}",
len(sentences) <= 2 and all(len(x.split()) <= 12
for x in sentences) and "{" not in txt)
check("EN and NL say that other batteries cover, in plain words",
"Other batteries" in uitext.words("en")["home_words"]["why"][
"others_cover"]
and "Andere batterijen" in uitext.words("nl")["home_words"]["why"][
"others_cover"])
# Owner, 2026-11-02: a Solar first battery counts too, at its level when the
# 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.
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
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} "
f"of {d3.short_kwh:.2f}; A (20 %) buys {d3.need_kwh[A]:.3f}")
check("B on Solar first at 94 % counts: its level at 07:00 is 94 % less the "
"night's use, (level - floor) x cap x 0.95",
abs(d3.solar_first_kwh - give_sf) < 0.01 and d3.cheap_kwh == 0.0)
check("...and A (at its floor) buys only the shortfall after that "
"(was 5.38 kWh and to 95 %)",
abs(d3.need_kwh[A] - want_hold) < 0.02 and d3.need_kwh[A] < 1.5
and d3.uncovered_kwh == 0.0)
c3b = make(LIVE, soc=(60.0, 94.0), modes=("leftover_solar", "solar_first"))
check("...and A at 60 % with B Solar first 94 % buys nothing",
c3b.day_plan.need_kwh[A] == 0.0 and c3b.day_plan.uncovered_kwh == 0.0)
# ============================================================================ #
print("2. dayplan.plan() alone: B low, a short cheap run under the peak limit")
# ============================================================================ #
def loc(day, hour, minute=0):
return datetime(2026, 10, day, hour, minute,
tzinfo=BILLING_TZ).astimezone(UTC)
SHAPE = shape_of(1.0)
T = loc(30, 4, 0) # 04:00, three cheap hours left
RUNS = priceruns.RunPlan(
priceruns.Run(loc(29, 22), loc(30, 7), 0.2333),
(priceruns.Run(loc(30, 7), loc(30, 22), 0.2801),
priceruns.Run(loc(31, 7), loc(31, 22), 0.2801)),
None, 0.2801, None, True)
def pk(name, mode, soc, cap, rating=4600.0, **kw):
return modetrade.Pack(name, True, mode, False, rating, soc, cap, **kw)
def fc_of(kwh, now=T):
d0, _d1 = solarfc._bounds(now)
return solarfc.Forecast(solarfc.Day(d0, kwh),
solarfc.Day(d0 + timedelta(days=1), kwh), now)
def mk_plan(packs, *, aim=3450.0, runs=RUNS, now=T, kwh=6.0, shape=SHAPE):
return dayplan.plan(fc_of(kwh, now), profile_of(shape, now).estimator(),
runs, now, packs, trust_pct=70, floor_pct=FLOOR,
ceiling_pct=CEIL, aim_w=aim)
def expect_short(kwh, shape=SHAPE, trust=0.7):
"""The shortfall written out: per hour 07..21 max(0, house - 0.7 x sun),
an hour with sun read at no less than the night base."""
base = sorted(shape[h] for h in (0, 1, 2, 3, 4, 5, 22, 23))
base = (base[3] + base[4]) / 2.0
tot = 0.0
for h in range(7, 22):
pv = kwh * (solarfc._bell(h + 1.0) - solarfc._bell(float(h)))
use = (max(shape[h], base) if pv > 0 else shape[h]) / 1000.0
tot += max(0.0, use - trust * pv)
return tot
def expect_b_level(soc, cap, gain_kwh):
"""`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)
# 04:00-07:00 at the night 0.6 kW (04, 05) and 1.2 kW (06): the peak aim 3450
# leaves 2850, 2850 and 2250 W of the battery's 4600 W.
GAIN = 2.85 + 2.85 + 2.25
LB = expect_b_level(30.0, CAP_B, GAIN)
GIVE_B = (LB - FLOOR) / 100.0 * CAP_B * EFF
A_LVL = FLOOR + (SH - GIVE_B) / EFF / CAP_A * 100.0
p_low = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)])
print(f" short {SH:.2f}; B 30 % + {GAIN:.2f} kWh -> {LB:.1f} % gives "
f"{GIVE_B:.2f}; A to {A_LVL:.1f} % (plan: {p_low.targets[A]} %, B "
f"gives {p_low.cheap_kwh})")
check("the shortfall is the written-out sum", abs(p_low.short_kwh - SH) < 0.005)
check("B's give is (its level at the start of the dear hours - floor) x "
"capacity x 0.95, the level being what 3 h under the peak aim reach",
abs(p_low.cheap_kwh - GIVE_B) < 0.005 and LB < CEIL - 20.0)
check("A buys only what B cannot reach: its level is floor + (shortfall - "
"B's give) / 0.95",
abs(p_low.targets[A] - A_LVL) < 0.05
and abs(p_low.need_kwh[A] - (A_LVL - FLOOR) / 100.0 * CAP_A) < 0.01
and p_low.uncovered_kwh == 0.0)
alone = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A)])
check("...which is less than A would buy with no Cheap hours battery",
alone.need_kwh[A] > p_low.need_kwh[A] + 4.0 and alone.cheap_kwh == 0.0)
check("the grid kWh A saves is reported (`saved_kwh`)",
abs(p_low.saved_kwh[A] - (alone.need_kwh[A] - p_low.need_kwh[A])) < 0.01)
# 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)
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)
check("a house at the peak aim leaves no room: B counts its level now "
"((30 - 20) % x 20.48 x 0.95)",
abs(p_full.cheap_kwh - 10.0 / 100.0 * CAP_B * EFF) < 0.005)
# The run is not worth charging in: B stays where it is.
NOCH = priceruns.RunPlan(RUNS.cheap, RUNS.give, None, RUNS.level_eur_kwh,
None, False)
p_noch = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)], runs=NOCH,
aim=None)
check("a cheap run not worth charging in: B counts its level now",
abs(p_noch.cheap_kwh - 10.0 / 100.0 * CAP_B * EFF) < 0.005)
# A cheap run that is already over (now past its end): the level now.
p_over = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
pk(B, packmode.CHEAP_HOURS, 80.0, CAP_B)],
now=loc(30, 7, 0) - timedelta(seconds=1), aim=None)
check("the last second of the run: B counts its level (80 %), no more",
abs(p_over.cheap_kwh - (80.0 - FLOOR) / 100.0 * CAP_B * EFF) < 0.01)
# Two Cheap hours batteries share the peak room by rating.
p_two = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B, rating=3000.0),
pk("inverter-c", packmode.CHEAP_HOURS, 30.0, CAP_B,
rating=1500.0)])
g_b = GAIN * 3000.0 / 4500.0
g_c = GAIN * 1500.0 / 4500.0
give_two = sum((expect_b_level(30.0, CAP_B, g) - FLOOR) / 100.0 * CAP_B * EFF
for g in (g_b, g_c))
# 2.85 kW is under the 4.5 kW of the two together: shared 2:1. Hours 04-05
# take 2.85 of 4.5, hour 06 takes 2.25 of 4.5.
check("two Cheap hours batteries share the peak room by rating",
abs(p_two.cheap_kwh - min(give_two, p_two.short_kwh)) < 0.01)
# Round 3: the run is sized at the ratings, not the peak limit, so a run too
# short to fill B is planned again later in the night. B's charge counts the
# firm price slots after the run, up to the expensive hours, that a run would
# still be worth charging in: price + the spread control asks (`spread`, 0.04
# here) at most the give run's dearest slot (0.2801). Without them A's level
# fell through the night after A had bought.
RUN1 = priceruns.RunPlan(
priceruns.Run(loc(30, 4), loc(30, 5), 0.2333), RUNS.give, None,
RUNS.level_eur_kwh, None, True)
def quarters(t0, t1, eur):
out, t = [], t0
while t < t1:
out.append((t, t + timedelta(minutes=15), eur))
t += timedelta(minutes=15)
return out
NIGHT_Q = quarters(loc(30, 4), loc(30, 7), 0.2333)
DAY_Q = quarters(loc(30, 7), loc(30, 22), 0.2801)
LATE_Q = quarters(loc(30, 22), loc(31, 7), 0.2333)
PK2 = [pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)]
def mk_slots(slots, runs=RUN1, spread=0.04):
return dayplan.plan(fc_of(6.0, T), profile_of(SHAPE, T).estimator(),
runs, T, PK2, trust_pct=70, floor_pct=FLOOR,
ceiling_pct=CEIL, aim_w=3450.0, slots=slots,
spread=spread)
p_run1 = mk_slots(None)
p_q = mk_slots(NIGHT_Q + DAY_Q + LATE_Q)
GIVE_1H = (expect_b_level(30.0, CAP_B, 2.85) - FLOOR) / 100.0 * CAP_B * EFF
print(f" a 1 h run (04:00-05:00): B gives {p_run1.cheap_kwh:.3f} without "
f"the slots, {p_q.cheap_kwh:.3f} with the night's slots to 07:00")
check("a 1 h run with no slots given: B counts what 1 h reaches (as before)",
abs(p_run1.cheap_kwh - GIVE_1H) < 0.005)
check("...and with the slots but no spread given, the same",
abs(mk_slots(NIGHT_Q + DAY_Q, spread=None).cheap_kwh - GIVE_1H)
< 0.005)
check("the same run with the night's quarters (0.2333) to 07:00: B "
"counts what 04:00-07:00 reach, as the 3 h run does (and A buys the "
"same); the day and the next night are not counted",
abs(p_q.cheap_kwh - GIVE_B) < 0.005
and abs(p_q.targets[A] - p_low.targets[A]) < 0.01)
p_dear = mk_slots(quarters(loc(30, 5), loc(30, 7), 0.2500) + DAY_Q)
check("slots after the run not worth charging in (0.25 + 0.04 over the "
"dearest 0.2801) are not counted: B counts the 1 h run only",
abs(p_dear.cheap_kwh - GIVE_1H) < 0.005)
p_part = mk_slots(quarters(loc(30, 5), loc(30, 6), 0.2400)
+ quarters(loc(30, 6), loc(30, 7), 0.2500) + DAY_Q)
GIVE_2H = (expect_b_level(30.0, CAP_B, 2.85 + 2.85) - FLOOR) / 100.0 \
* CAP_B * EFF
check("...and only those are: 05:00-06:00 at 0.24 (dearer than the run, "
"still worth it) adds one hour (04:00-06:00 reach)",
abs(p_part.cheap_kwh - GIVE_2H) < 0.005)
# ============================================================================ #
print("3. who counts, and who does not")
# ============================================================================ #
base_pk = [pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A)]
want = mk_plan(base_pk)
for label, b in (
("off", modetrade.Pack(B, False, packmode.CHEAP_HOURS, False, 4600.0,
94.0, CAP_B)),
("refusing to give", pk(B, packmode.CHEAP_HOURS, 94.0, CAP_B,
refused=1)),
("refusing to charge", pk(B, packmode.CHEAP_HOURS, 94.0, CAP_B,
refused=-1)),
("in maintenance", pk(B, packmode.CHEAP_HOURS, 94.0, CAP_B,
idle=False)),
("not live", pk(B, packmode.CHEAP_HOURS, 94.0, CAP_B, live=False)),
("without capacity", modetrade.Pack(B, True, packmode.CHEAP_HOURS,
False, 4600.0, 94.0, None)),
("without a level", modetrade.Pack(B, True, packmode.CHEAP_HOURS,
False, 4600.0, None, CAP_B)),
("Solar first refusing to give",
pk(B, packmode.SOLAR_FIRST, 94.0, CAP_B, refused=1)),
("Solar first in maintenance",
pk(B, packmode.SOLAR_FIRST, 94.0, CAP_B, idle=False)),
("Solar first with the night draining it to the floor",
pk(B, packmode.SOLAR_FIRST, 24.0, CAP_B))):
pl = mk_plan(base_pk + [b])
check(f"B {label}: counts 0, A's plan is as with no B",
pl.cheap_kwh == 0.0 and pl.targets == want.targets
and pl.need_kwh == want.need_kwh
and pl.uncovered_kwh == want.uncovered_kwh
and pl.cover_kwh == want.cover_kwh)
on = mk_plan(base_pk + [pk(B, packmode.CHEAP_HOURS, 94.0, CAP_B)])
check("(control: the same B, on and idle, counts)",
on.cheap_kwh > 5.0 and on.need_kwh[A] < want.need_kwh[A] - 1.0)
# Solar first at 94 % at 04:00: three night hours (0.6, 0.6, 1.2 kW) are used
# before 07:00, shared with nobody else; the rest counts.
p_sf = mk_plan(base_pk + [pk(B, packmode.SOLAR_FIRST, 94.0, CAP_B)])
use_t = 0.6 + 0.6 + 1.2
give_t = ((94.0 - FLOOR) / 100.0 * CAP_B - use_t / EFF) * EFF
check("Solar first at 94 % at 04:00: counts its level at 07:00 "
f"({give_t:.2f} kWh), A buys only the rest",
abs(p_sf.solar_first_kwh - give_t) < 0.005 and p_sf.cheap_kwh == 0.0
and p_sf.need_kwh[A] < want.need_kwh[A] - 4.0)
# Two Solar first batteries share the night's use by the energy they hold.
p_sf2 = mk_plan(base_pk + [pk(B, packmode.SOLAR_FIRST, 60.0, CAP_B),
pk("inverter-c", packmode.SOLAR_FIRST, 40.0,
CAP_B)])
tot_h = (60.0 - FLOOR) / 100.0 * CAP_B + (40.0 - FLOOR) / 100.0 * CAP_B
check("two Solar first batteries: the night's use is shared by the energy "
"held; the total is the same sum",
abs(p_sf2.solar_first_kwh - min(
p_sf2.short_kwh, (tot_h - use_t / EFF) * EFF)) < 0.005)
# Both kinds: Cheap hours and Solar first add up (capped at the shortfall).
p_both = mk_plan(base_pk + [pk(B, packmode.SOLAR_FIRST, 60.0, CAP_B),
pk("inverter-c", packmode.CHEAP_HOURS, 30.0,
CAP_B)])
check("Cheap hours and Solar first batteries both count",
p_both.cheap_kwh > 5.0 and p_both.solar_first_kwh > 3.0
and abs(p_both.cheap_kwh + p_both.solar_first_kwh
- min(p_both.short_kwh, p_both.cheap_kwh
+ p_both.solar_first_kwh)) < 1e-6)
# A Cheap hours battery with Leftover solar A: when A is not Leftover solar
# (N=1 on Cheap hours alone) the plan has no Leftover solar to size.
solo = mk_plan([pk(B, packmode.CHEAP_HOURS, 94.0, CAP_B)])
check("Cheap hours only sites: no Leftover solar battery, nothing is sized",
solo.targets == {} and solo.need_kwh == {} and solo.cheap_kwh == 0.0
and solo.uncovered_kwh == 0.0)
# Everything the plan says about a Leftover solar only site is the same as
# before OPT-36: the new fields are at rest.
two_ls = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
pk(B, packmode.LEFTOVER_SOLAR, 94.0, CAP_B)])
check("Leftover solar only (two batteries): cheap_kwh 0, nothing saved",
two_ls.cheap_kwh == 0.0
and all(v == 0.0 for v in two_ls.saved_kwh.values())
and abs(sum(two_ls.cover_kwh.values()) - two_ls.short_kwh) < 0.01)
# More than the Cheap hours battery holds: A buys the rest, "the rest" is said
# only for what nothing holds.
big = shape_of(2.0)
p_big = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
pk(B, packmode.CHEAP_HOURS, 94.0, CAP_B)], shape=big,
aim=None)
want_big = expect_short(6.0, big)
cheap_big = (CEIL - FLOOR) / 100.0 * CAP_B * EFF
check("a shortfall B cannot cover: A holds the rest, to the ceiling; what "
"neither holds is 'uncovered'",
abs(p_big.cheap_kwh - cheap_big) < 0.005
and p_big.targets[A] == CEIL
and abs(p_big.uncovered_kwh - max(
0.0, want_big - cheap_big - (CEIL - FLOOR) / 100.0 * CAP_A * EFF))
< 0.01)
check("the plan is pure: the same call twice gives the same plan",
mk_plan(base_pk + [pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)])
== mk_plan(base_pk + [pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)]))
# ============================================================================ #
print("4. the screens agree with control")
# ============================================================================ #
# A tops up (B low, the house large): the give block reads A after its charge.
cs = make(shape_of(1.5), soc=(20.0, 30.0), now=NOW)
dps = cs.day_plan
tgs = cs.plan_targets()
print(f" A 20 %, B 30 %: B gives {dps.cheap_kwh:.2f}, A to {tgs[A]} %, "
f"buys {dps.need_kwh[A]:.2f}, uncovered {dps.uncovered_kwh:.2f}")
check("(A does buy here: B cannot cover the larger house)",
dps.need_kwh[A] > 1.0 and dps.cheap_kwh > 5.0)
for lang in ("en", "nl"):
st, rows = screen(cs, lang)
runs_a = [r for r in st["plan"]["runs"] if r["battery"] == 0]
grid = next((r for r in runs_a if r["kind"] == "grid"), None)
give = next((r for r in runs_a if r["kind"] == "give"), None)
cov = homeview.plan_cover(cs, A)
with homeview.in_lang(lang):
pct, kwh = homeview._pct(tgs[A]), homeview._kwh(dps.cover_kwh[A])
check(f"{lang}: A tops up: the grid block names the plan's level and "
"the plan's own cover figure",
grid is not None and pct in grid["what"] and kwh in grid["why"]
and cov == (tgs[A], dps.cover_kwh[A]))
check(f"{lang}: the give block is A at the level it charges to "
f"({(tgs[A] - FLOOR) / 100.0 * CAP_A:.2f} kWh), not at 20 % (0)",
give is not None and abs(
give["kwh"] - (tgs[A] - FLOOR) / 100.0 * CAP_A) < 0.02)
# The Batteries line for a battery that holds (`tops_up`) is made from the
# same figures: the plan's level and its own cover figure.
check("(A's day key is tops_up)", homeview.day_key(cs, A) == "tops_up")
for lang in ("en", "nl"):
with homeview.in_lang(lang):
words = homeview.plan_words(cs, A, "tops_up")
txt = homeview.why_words("tops_up", **words)
pct, kwh = homeview._pct(tgs[A]), homeview._kwh(dps.cover_kwh[A])
check(f"{lang}: A's tops_up line names the plan's level ({pct}) and "
f"cover figure ({kwh}): {txt!r}", pct in txt and kwh in txt)
# The controller hands the plan its peak aim (peak_cap_w - peak_margin_w): a
# lower peak limit leaves B less room in its run, so it covers less.
lo_cap = make(shape_of(1.5), soc=(20.0, 30.0), peak_cap=2000)
hi_cap = make(shape_of(1.5), soc=(20.0, 30.0), peak_cap=20000)
print(f" peak limit 2000 W: B gives {lo_cap.day_plan.cheap_kwh:.2f} kWh; "
f"20000 W: {hi_cap.day_plan.cheap_kwh:.2f} kWh")
check("the Controller passes its peak aim: a 2 kW limit leaves B less room "
"than a 20 kW one, and A buys the difference",
lo_cap.day_plan.cheap_kwh < hi_cap.day_plan.cheap_kwh - 2.0
and lo_cap.day_plan.need_kwh[A] > hi_cap.day_plan.need_kwh[A] + 1.0)
# A hysteresis-held level: the cover figure is still the plan's own.
real = cs.plan_targets
cs.plan_targets = lambda: {A: tgs[A] + 0.7}
cv = homeview.plan_cover(cs, A)
check("plan_cover() is the plan's cover figure whatever level control holds "
"(hysteresis), the level being the held one",
cv == (tgs[A] + 0.7, dps.cover_kwh[A]))
cs.plan_targets = real
# A give run before the cheap run (10:00: tomorrow night's charge is after
# today's give): the give block reads the level now.
c10 = make(shape_of(2.0), soc=(40.0, 25.0), modes=("leftover_solar", "solar_first"),
now=datetime(2026, 11, 2, 10, 0, tzinfo=BILLING_TZ).astimezone(UTC))
st10 = homeview.build(c10, level="ok", checks=[],
now=datetime(2026, 11, 2, 10, 0,
tzinfo=BILLING_TZ).astimezone(UTC),
lang="en")
g10 = [r for r in st10["plan"]["runs"]
if r["battery"] == 0 and r["kind"] == "give"]
check("a give run that comes BEFORE the next cheap run is read at the level "
"now (40 % -> 3.1 kWh), not at tonight's charge",
g10 and abs(g10[0]["kwh"] - (40.0 - FLOOR) / 100.0 * CAP_A) < 0.02
and any(r["kind"] == "grid" and r["battery"] == 0
for r in st10["plan"]["runs"]))
# ============================================================================ #
print("5. the real Controller through a whole night")
# ============================================================================ #
L = timezone(timedelta(hours=1)) # Brussels in winter (CET)
def at(day, hh, mm=0):
return datetime(2026, 11, day, hh, mm, tzinfo=L).astimezone(UTC)
def st_of(kwh, when, *, unit="kWh"):
return {"state": f"{kwh}", "attributes": {"unit_of_measurement": unit},
"last_updated": when.isoformat(),
"last_reported": when.isoformat()}
GONE = {"state": "unavailable", "attributes": {}}
T_ENT, M_ENT = "sensor.fc_today", "sensor.fc_tomorrow"
PROFILE = ('{"periods": ['
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
' "eur_per_kwh": 0.26},'
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
' "eur_per_kwh": 0.34}]}')
CHOICE = {"a": (True, "leftover_solar", False),
"b": (True, "cheap_hours", False)}
EVENING = [at(3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)] \
+ [at(3, 21, 55)]
def night_opts(cap, profile=PROFILE):
o = dict(rp.options(True, False, 2))
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
def run_night(spec):
"""One whole night in this process -> a plain dict. `spec`: start (UTC),
hours, soc, house_w, cap, fresh (today, tomorrow) before `change`,
change (UTC) and the figures after it, silent_from (UTC): the sensors
show their last reading from then on."""
start = spec["start"]
store = rp._Store()
for u, (use, mode, sell) in CHOICE.items():
for k, val in (("pack_use", use), ("pack_mode", mode),
("pack_sell", sell)):
store.data[unit_key(f"inverter-{u}", k)] = val
clock = [EVENING[0]]
figs = spec.get("fresh", (1.0, 5.0))
change = spec.get("change")
after = spec.get("after", figs)
silent = spec.get("silent_from")
def sens(now, ent):
f = after if change is not None and now >= change else figs
when = now if silent is None else min(now, silent)
return st_of(f[0] if ent == T_ENT else f[1], when)
class H(rp.Hass):
async def state(self, e):
return sens(clock[0], e)
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),
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"))
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
hw = spec.get("house_w", 700.0)
c.house_profile.load({d: {str(hh): hw for hh in range(24)}
for d in ("2026-10-31", "2026-11-01", "2026-11-02")})
async def go():
real_dt, real_hv = m.datetime, homeview.datetime
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
try:
for t_pre in EVENING:
clock[0] = t_pre
await c._read_forecast()
marks = sorted(spec.get("marks", ()))
first, t = True, 0.0
hz = spec["hours"] * 3600.0
while t < hz:
now = start + timedelta(seconds=t)
clock[0] = now
if first or int(t) % 600 == 0:
hor = float(m.price_option(c.o, "price_horizon_h"))
c.tariff_plan = await m.resolve_tariff(
c.o, now, now + timedelta(hours=hor), now=now,
history=c.price_history, watch=c.price_watch)
c.tariff_at = now
first = False
hass.t = t
imp, exp = split_signed(hass.grid())
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=now,
ingest_mono=time.monotonic()))
await c.read_inputs(now_mono=t)
await c.cycle()
hass.advance(5.0, 10.0)
if int(t) % 300 == 0:
tgt = c.plan_targets()
out["soc_at"][now.astimezone(L).strftime("%H:%M")] = (
hass.plants["a"].soc, hass.plants["b"].soc,
hass.plants["a"].p, c.solar_fc is None,
None if tgt is None else tgt.get(A))
if int(t) % 60 == 0:
out["p_a"].append(hass.plants["a"].p)
while marks and now >= marks[0]:
marks.pop(0)
cards = homeview.battery_rows(c, False, True)
blk = homeview.solar_block(c, now)
a_card = next(b for b in cards if b["name"] == A)
dpl = c.day_plan
out["marks"][now.astimezone(L).strftime("%H:%M")] = dict(
line=blk["line"], why_a=a_card["why_key"],
fc_none=c.solar_fc is None,
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
out["soc"] = (hass.plants["a"].soc, hass.plants["b"].soc)
out["latch"] = getattr(c, "_charge_latch", None) is not None
finally:
m.datetime, homeview.datetime = real_dt, real_hv
return out
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 = {
# The live night: A 60 % Leftover solar, B 94 % Cheap hours, a fresh
# figure all night. A must not charge from the grid.
"live": dict(start=NIGHT, hours=9, soc={"a": 60.0, "b": 94.0},
house_w=700.0, marks=[at(4, 1, 0), at(4, 5, 0)]),
# A mid case: both low (25 %), the house 0.8 kW, a 2.5 kW peak limit
# leaves B 1.65 kW in a 3 h run (the run is sized to the batteries'
# ratings, not the limit), so B counts for part and A buys the rest.
"mid": dict(start=NIGHT, hours=9, soc={"a": 25.0, "b": 25.0},
house_w=800.0, marks=HOURLY),
# Round 3: the same with one figure for the 4th all night (today 5.0
# after midnight = tomorrow 5.0 before): A's level must not move at all.
# On round 2's code it went 55 -> 69 -> 41 -> 37 % and A bought to 56 %.
"mid_flat": dict(start=NIGHT, hours=9, soc={"a": 25.0, "b": 25.0},
house_w=800.0, fresh=(5.0, 5.0), marks=HOURLY),
# 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),
after=(1.0, 1.0), silent_from=at(4, 2, 0),
marks=[at(4, 1, 55)] + [at(4, 2, 15) + timedelta(
minutes=15 * i) for i in range(19)]),
# OPT-34 with the new level: a fresh, brighter figure at 03:10 lowers A's
# level below its SoC (A stops), then the sensors stay silent: the
# forecast ends and the latch holds the LOWERED level to 07:00.
"lowered": dict(start=at(4, 2, 0), hours=5.0, soc={"a": 25.0, "b": 25.0},
house_w=1200.0, fresh=(1.0, 5.0), change=at(4, 3, 10),
after=(30.0, 30.0), silent_from=at(4, 3, 10),
marks=[at(4, 3, 5), at(4, 3, 20), at(4, 6, 30)]),
}
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"]
print(f" live: A {lv['soc'][0]:.2f} % B {lv['soc'][1]:.2f} % "
f"marks {lv['marks']}")
check("live night: A takes no grid charge: it never charges (its power "
"stays above -200 W) and ends where it started",
min(lv["p_a"]) > -200.0 and abs(lv["soc"][0] - 60.0) < 1.5)
check("live night: B fills to the ceiling as before",
lv["soc"][1] > 94.0)
check("live night: the plan gives A a level at or under its SoC and the "
"screen says nothing about 'the rest'",
all(mk["need_a"] == 0.0 and mk["cheap"] > 0.0
and "rest" not in mk["line"] for mk in lv["marks"].values()))
md = R["mid"]
m1, m2 = md["marks"]["22:30"], md["marks"]["05:00"]
print(f" mid: A level {m1['target_a']} % at 22:30, {m2['target_a']} % at "
f"05:00; A ends {md['soc'][0]:.2f} %, B {md['soc'][1]:.2f} %; B gives "
f"{m1['cheap']}")
check("mid night: the plan counts B's give and A's level is a partial one "
"(not the ceiling, as 0.20.0 gave: 95 %)",
m1["target_a"] is not None and 25.0 < m1["target_a"] < CEIL - 15.0
and m1["cheap"] > 3.0)
check("mid night: B's counted give is the same at every mark (B reaches the "
"ceiling in the night left), and A's level moves only at midnight, "
"when the 4th's figure changes from tomorrow's 5.0 to today's 1.0",
len({mk["cheap"] for mk in md["marks"].values()}) == 1
and len({mk["target_a"] for h, mk in md["marks"].items()
if h >= "12"}) == 1
and len({mk["target_a"] for h, mk in md["marks"].items()
if h < "12"}) == 1)
check("mid night: A's commands follow the levels: it charges to its level "
"and stops there, well under the ceiling (0.20.0: 95 %)",
md["soc"][0] < 85.0 and abs(md["soc"][0] - m2["target_a"]) < 0.5)
check("mid night: B fills as before (to the ceiling)", md["soc"][1] > 94.0)
mf = R["mid_flat"]
lv_f = [mk["target_a"] for mk in mf["marks"].values()]
lv0 = nth(lv_f, 0)
print(f" mid_flat: A level(s) {sorted(set(map(str, lv_f)))}; A ends "
f"{mf['soc'][0]:.2f} %")
check("one figure all night: A's level never moves (round 2: 55 -> 69 -> "
"41 -> 37 %), A charges to it and stops (round 2: 56 %)",
present(lv0) and lv0 is not None and len(set(lv_f)) == 1
and abs(mf["soc"][0] - lv0) < 0.5)
fd = R["f1_dark_b"]
dk = fd["marks"]
held = [dk[h]["target_a"] for h in dk if "05:15" <= h < "07:00"]
print(f" f1_dark_b: A level 01:55 {dk['01:55']['target_a']} -> 02:15 "
f"{dk['02:15']['target_a']}, held {sorted(set(held))}; A "
f"{fd['soc_at']['06:55'][0]:.1f} % at 06:55, B {fd['soc'][1]:.1f} %")
check("F-1 with B counted: a darker fresh figure at 02:00 raises A's level, "
"the level does not move after that (B's give is the same), it is held "
"with no forecast from 05:15, and A charges to it",
dk["01:55"]["target_a"] is not None
and dk["02:15"]["target_a"] > dk["01:55"]["target_a"] + 3.0
and dk["01:55"]["cheap"] > 3.0
and all(dk[h]["target_a"] == dk["02:15"]["target_a"] for h in dk
if h >= "02:15")
and all(dk[h]["fc_none"] for h in dk if "05:15" <= h < "07:00")
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']} -> "
f"{l2['target_a']}; SoC {l1['soc_a']:.1f} -> {lw['soc'][0]:.1f} %")
check("a fresh, brighter figure lowers A's level below its SoC during the "
"charge, and A stops",
l0["target_a"] is not None and l1["target_a"] is not None
and l1["target_a"] < l0["target_a"] - 5.0
and lw["soc_at"]["03:30"][0] < lw["soc_at"]["03:20"][0] + 0.5)
check("the sensors go quiet and the forecast ends (06:30): the latch holds "
"the LOWERED level, A does not charge back up to the old one",
l2["fc_none"] and l2["target_a"] is not None
and l2["target_a"] < l0["target_a"] - 5.0 and l2["p_a"] > -200.0
and lw["soc_at"]["06:55"][0] < lw["soc_at"]["03:30"][0] + 1.0)
# ============================================================================ #
print("\n" + ("all checks passed" if not fails
else f"FAILED: {len(fails)}"))
for f in fails:
print(" -", f)
sys.exit(1 if fails else 0)