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
+120 -10
View File
@@ -11,15 +11,19 @@ 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.
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 is not counted; N=1 and Leftover
solar only are unchanged.
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, and the OPT-34 latch still
holds a started run (a lowered level stays lowered).
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).
Mutations (MUTATION.md, purged copies): listed in the pull request.
"""
@@ -345,6 +349,66 @@ give_two = sum((expect_b_level(30.0, CAP_B, g) - FLOOR) / 100.0 * CAP_B * EFF
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")
# ============================================================================ #
@@ -654,6 +718,7 @@ def run_night(spec):
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.
@@ -663,7 +728,21 @@ SCEN = {
# 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=[at(3, 22, 30), at(4, 5, 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.
"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.
@@ -701,11 +780,42 @@ 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: A's commands follow the levels: it stops charging at "
"its level and ends well under the ceiling (0.20.0: 95 %), and not "
"under the level the plan ends the night with",
md["soc"][0] < 85.0 and md["soc"][0] >= m2["target_a"] - 1.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()}
print(f" mid_flat: A level(s) {sorted(lv_f)}; A ends {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 %)",
len(lv_f) == 1 and None not in lv_f
and abs(mf["soc"][0] - next(iter(lv_f))) < 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)
lw = R["lowered"]
l0, l1, l2 = (lw["marks"][k] for k in ("03:05", "03:20", "06:30"))