OPT-36 round 3: B's charge counts the cheap time left; OPT-34 nights on their own shapes
checks / unit (pull_request) Failing after 36m29s
infra-pin / fresh (pull_request) Successful in 2s

The run is sized at the batteries' ratings, not the peak limit, so a run too
short to fill a Cheap hours battery is planned again later in the night.
Counting that run alone made B's give grow through the night and A's level
fall after A had bought (round 2: 55 -> 69 -> 41 -> 37 %, A to 56 %).
dayplan._cheap_give_kwh() now also counts the later firm slots before the
expensive hours that a run would still be worth charging in (price + the
spread control asks at most the give run's dearest slot); the Controller
passes the slots and the spread.

test_opt34: back to the original house shapes, fixture and expected values.
B is Off in the nights that test A's level (a Cheap hours B now covers
these hours); B's checks run in new `_b` nights; the 03:00 check runs in a
night from midnight; tail starts A at 60 %; late_fresh reads the kept 6.0.
test_opt36: slot cases, a whole night on one figure (level never moves),
F-1 with B counted (raised level reached and held). test_ux54: the run end
is read from the plan and asserted (02:30); the F1/F2 sweep uses a peak
limit that does not bind and reads a level shown as the target as at it.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-02 10:15:20 +01:00
co-authored by Claude
parent 3a18475a5a
commit e4b9dee699
7 changed files with 232 additions and 74 deletions
+27 -7
View File
@@ -225,7 +225,12 @@ check("Leftover solar and Cheap hours: a give block exactly on the give run "
# OPT-36: a larger evening (4.8 kW) so that B (Cheap hours) does not cover the
# dear hours alone and A still buys from the grid; A starts at 30 %.
c_n = S.make("dynamic", at(0, 18), soc=(30.0, 40.0), evening=4800.0)
END_S, END_H = "03:00", 3.0 # the run's end, recomputed for this house
# The run's end, read from the plan; for this house it is 02:30, as in 0.20.0.
END = c_n.mode_runs.cheap.end
END_H = (END - DAY0).total_seconds() / 3600.0
END_S = S.brussels(END).strftime("%H:%M")
check(f"the cheap run is 01:00 to {END_S} (02:30 for this house)",
c_n.mode_runs.cheap.start == at(1, 0, 0) and END == at(2, 30, 0))
nb = tab(c_n, at(0, 18))["days"][0]["batteries"]
check("in the night before the cheap run both charge from the grid in it "
f"(01:00 to {END_S}), then give at 17:00",
@@ -249,11 +254,12 @@ def at_h(b, h):
return dict((round(x, 3), v) for x, v in b["levels"]).get(h, float("nan"))
check("the curve charges in a grid block: A (planned level 73 %) and B "
"(Cheap hours, to the end of its run) are well above where they "
LVL_A = c_n.plan_targets()[c_n.inverters[0].name]
check(f"the curve charges in a grid block: A (planned level {LVL_A:.0f} %) "
"and B (Cheap hours, to the end of its run) are well above where they "
"were at 01:00, and stay put after the run",
all(at_h(b, END_H) - at_h(b, 1.0) > 8.0
and abs(at_h(b, 4.0) - at_h(b, END_H)) < 1e-6
and abs(at_h(b, END_H + 0.5) - at_h(b, END_H)) < 1e-6
for b in nb) and nb[0]["levels"][0][1] == 30.0)
# Modes off: the verdict's legs.
c_f = S.make("off", at(10, 7))
@@ -828,14 +834,16 @@ def may_give(inv):
def sweep(name, now0, soc, *, guard=None, until=48.0, sun=None,
evening=None):
evening=None, peak_w=None):
"""Walk the real control cycle (`_mode_roles()`) every 15 minutes from
`now0` to the end of tomorrow, each battery at the level the tab's curve
has at that time, and return the tab and the role seen per battery and
quarter. A GIVE role on a pack that control's discharge envelope closes
is seen as "closed": the pack is not asked to give. The tab is built
once, at `now0`."""
once, at `now0`. `peak_w`: the site's peak limit (OPT-36, below)."""
c = S.make(name, now0, soc=soc, evening=evening)
if peak_w is not None:
c.o = dict(c.o, peak_cap_w=peak_w)
cut_out_at_floor(c)
c.tariff_plan = S.quarters(now0, night_evening, days=3)
c.tariff_at = now0
@@ -856,6 +864,13 @@ def sweep(name, now0, soc, *, guard=None, until=48.0, sun=None,
b = day["batteries"][i]
inv.soc = level_at(b, h if h < 24 else h - 24) if b["levels"] \
else inv.soc
# OPT-36: the tab shows a level to 0.1 % (`dayview.LEVEL_DP`); a
# battery whose curve shows its planned level (50.0 for 50.01) is
# at it, or control charges it for the last 0.01 %.
tg = (c.plan_targets() or {}).get(inv.name)
if tg is not None and inv.soc is not None and round(
inv.soc, dayview.LEVEL_DP) == round(tg, dayview.LEVEL_DP):
inv.soc = max(inv.soc, tg)
c.tariff_at = t
tick = c._mode_roles(t, None)
for inv in c.inverters:
@@ -894,7 +909,12 @@ def tomorrow_match(out, seen, d0, role, kind):
t22 = at(22, 0, 5)
c1, o1, s1, d01 = sweep("dynamic", t22, (20.0, 20.0), evening=4800.0)
# 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.
c1, o1, s1, d01 = sweep("dynamic", t22, (20.0, 20.0), evening=4800.0,
peak_w=20000.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 "