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
+44 -38
View File
@@ -26,7 +26,9 @@ DECISIONS note and the dev report.
solar + B Cheap hours, both 10 % at the start of the night, the night rate
22:00-07:00, tomorrow 6 kWh, a 2.5 kW peak limit so the charge takes hours),
sensors silent from 17:05, whole nights at 5 s per cycle (run in parallel
processes, about 2.5 minutes each):
processes, about 2.5 minutes each). OPT-36: B is Off in the nights that
test A's level (a Cheap hours B now counts before A buys and would cover
these hours); the `_b` nights keep it on for B's checks:
a. silent from 17:05: both batteries end the night where a fresh figure
ends them (the base left A at 57 % against 87 %), and the screens agree;
b. restarts at 01:00 and 04:00 change nothing;
@@ -267,8 +269,11 @@ PROFILE_LATE = ('{"periods": ['
' "to": "22:00", "eur_per_kwh": 0.34}]}')
CHOICE = {"a": (True, "leftover_solar", False),
"b": (True, "cheap_hours", False)}
# OPT-36: a Cheap hours B now gives first, and with these house shapes it would
# cover the dear hours alone, so A would not charge. The nights that test A's
# level have B Off (`b_off`, the default; the house is as written); `_b`
# nights keep B on Cheap hours for B's own checks.
LAST = at(3, 17, 5)
MIDNIGHT = at(4, 0, 0)
EVENING = [at(3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)] \
+ [at(3, 21, 55)]
@@ -291,12 +296,7 @@ def sensors(*, silent=True, fresh_from=None, fresh=(1.0, 5.0),
when = now if fresh_until is None else min(now, fresh_until)
return st(fresh[0] if ent == T_ENT else fresh[1], when)
when = min(now, LAST) if silent else now
# OPT-36: a fresh sensor reads the day it is on. After midnight "today"
# is the 4th (6.0 kWh, the figure the 17:05 reading called tomorrow's),
# not the evening's 0.4 kWh left of the 3rd; with that, fresh and
# kept are the same figure all night (before, the plans only agreed
# because A was capped at the ceiling in both).
return st(0.4 if ent == T_ENT and now < MIDNIGHT else 6.0, when)
return st(0.4 if ent == T_ENT else 6.0, when)
return f
@@ -305,12 +305,12 @@ def run_night(spec):
hours, silent, fresh_from, fresh, restarts, soc, load_until_s, marks."""
start = spec["start"]
soc0 = spec.get("soc", {"a": 10.0, "b": 10.0})
# OPT-36: Cheap hours B now covers part of the dear hours, so the house the
# plan expects is 1.6 x larger than it was written for (700 W -> 1.1 kW):
# A still has a shortfall to buy and the night charge is still long.
house_w = 1.6 * spec.get("house_w", 700.0)
house_w = spec.get("house_w", 700.0)
store = rp._Store()
for u, (use, mode, sell) in CHOICE.items():
choice = dict(CHOICE)
if spec.get("b_off", True):
choice["b"] = (False, "cheap_hours", False)
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
@@ -428,15 +428,24 @@ SCEN = {
marks=[at(4, 4, 30)]),
"silent": dict(start=NIGHT, hours=9,
marks=[at(4, 1, 0), at(4, 4, 30), at(4, 6, 30)]),
# OPT-36: B on Cheap hours, for B's checks
"fresh_b": dict(start=NIGHT, hours=9, silent=False, b_off=False),
"silent_b": dict(start=NIGHT, hours=9, b_off=False),
# OPT-36: alone, A is full by 02:00 from 22:00; from midnight its charge
# runs through 03:00
"midnight": dict(start=at(4, 0, 0), hours=7),
"restart": dict(start=NIGHT, hours=9,
restarts=[at(4, 1, 0), at(4, 4, 0)],
marks=[at(4, 1, 30), at(4, 4, 30)]),
# c. the charge cannot start before 03:00 (the price dips at 04:00)
"late": dict(start=at(4, 2, 30), hours=4.5, late=True, house_w=500.0,
soc={"a": 40.0, "b": 70.0}, marks=[at(4, 3, 30)]),
# OPT-36: the fresh sensors read the kept figure's 6.0 kWh for the 4th.
# Alone, A reaches its level, so the runs must plan on the same sun (with
# B charging too, A reached neither level by 07:00 and they matched).
"late_fresh": dict(start=at(4, 2, 30), hours=4.5, late=True,
house_w=500.0, soc={"a": 40.0, "b": 70.0},
silent=False),
fresh_from=at(4, 2, 30), fresh=(6.0, 6.0)),
# d. a fresh, darker figure at 02:00 during the charge (a lighter house
# use, so the level has room to rise)
"dark": dict(start=at(4, 1, 30), hours=2.0, soc={"a": 45.0, "b": 45.0},
@@ -455,8 +464,10 @@ SCEN = {
house_w=500.0, fresh_from=at(4, 2, 40),
fresh=(30.0, 30.0), marks=[at(4, 2, 44)]),
# d. the last hour, after the kept figure ended at 06:00
# (OPT-36: A at 60 %, not 70 %: alone it charges twice as fast and from
# 70 % is full at 06:38, before the 06:40 mark)
"tail": dict(start=at(4, 5, 40), hours=80 / 60.0,
soc={"a": 70.0, "b": 70.0}, restarts=[at(4, 6, 30)],
soc={"a": 60.0, "b": 70.0}, restarts=[at(4, 6, 30)],
marks=[at(4, 5, 50), at(4, 6, 10), at(4, 6, 40)]),
# a fresh, brighter figure at 03:10 lowers A's level below its SoC (A
# stops, B goes on), then the sensors go quiet: the forecast ends at 06:10
@@ -470,12 +481,12 @@ SCEN = {
latch={"until": at(3, 7, 15).isoformat(),
"born": at(2, 23, 0).isoformat(),
"levels": {A: 80.0, B: 80.0}},
marks=[at(3, 22, 20)]),
marks=[at(3, 22, 20)], b_off=False),
"stale_live": dict(start=at(3, 22, 0), hours=0.5, gone=True,
latch={"until": at(4, 7, 15).isoformat(),
"born": at(3, 22, 0).isoformat(),
"levels": {A: 80.0, B: 80.0}},
marks=[at(3, 22, 20)]),
marks=[at(3, 22, 20)], b_off=False),
# the same, B already full and a 1.5 kW limit: the run is sized at the
# battery's 2.5 kW rating and ends at 06:33, before A is there, so the
# next slice is planned after 06:00 and is only A's
@@ -515,20 +526,20 @@ for k in R:
fa, si, rr = R["fresh"], R["silent"], R["restart"]
# a. silent from 17:05
# OPT-36: A's level is 95 % to ~05:00, then 78.4 % when B's cheap run is longer
# (B's give 5.9 -> 7.1 kWh); A, already at 81.5 %, stops there.
check("a fresh figure all night: A ends at 81.5 % (the plan's 78.4 % came "
"after it had passed), B full",
abs(fa["soc"][0] - 81.5) < 0.5 and fa["soc"][1] > 94.5)
fb_, sb_ = R["fresh_b"], R["silent_b"]
check("a fresh figure all night: A ends at its planned level (87 %), B "
"(Cheap hours, on in the `_b` night) full",
abs(fa["soc"][0] - 86.9) < 0.5 and fb_["soc"][1] > 94.5)
check("sensors silent from 17:05: A ends where the fresh run ends it "
f"({si['soc'][0]:.1f} against {fa['soc'][0]:.1f} %)",
abs(si["soc"][0] - fa["soc"][0]) < 0.3)
check("... and so does B (Cheap hours; the wide no-forecast gap does not "
f"stop its slices): {si['soc'][1]:.1f} against {fa['soc'][1]:.1f} %",
abs(si["soc"][1] - fa["soc"][1]) < 0.3)
f"stop its slices): {sb_['soc'][1]:.1f} against {fb_['soc'][1]:.1f} %",
abs(sb_["soc"][1] - fb_["soc"][1]) < 0.3)
mn = R["midnight"]
check("A charges through 03:00 without a break (no step at 03:00)",
si["soc_at"]["03:30"][0] > si["soc_at"]["03:00"][0] + 4.0
and si["soc_at"]["03:00"][0] > si["soc_at"]["02:55"][0] + 0.3)
mn["soc_at"]["03:30"][0] > mn["soc_at"]["03:00"][0] + 4.0
and mn["soc_at"]["03:00"][0] > mn["soc_at"]["02:55"][0] + 0.3)
m1, m4 = si["marks"]["01:00"], si["marks"]["04:30"]
check("01:00 and 04:30: the kept figure is in use, with a day plan, and the "
"line says so (EN)",
@@ -538,7 +549,7 @@ check("01:00 and 04:30: the kept figure is in use, with a day plan, and the "
and m4["target_a"] is not None
and near(m4["target_a"], fa["marks"]["04:30"]["target_a"], 1e-9))
check("... and the level is the fresh run's (the kept figure counts as a "
"fresh one)", near(m4["target_a"], 95.0, 0.2))
"fresh one)", near(m4["target_a"], 86.87, 0.2))
m6 = si["marks"]["06:30"]
check("06:30: the kept figure has ended (the bell started) and the screen "
"does not say 'kept' (the charge was done by then in this shape)",
@@ -613,12 +624,12 @@ check("a fresh, brighter figure lowers A's level below its SoC during the "
and lo["soc_at"]["03:30"][0] < lo["soc_at"]["03:20"][0] + 0.5)
check("the sensors go quiet, the forecast ends (06:30): the latch holds the "
"LOWERED level, so A does not charge back up to the old one "
f"({lo['soc_at']['03:30'][0]:.1f} -> {lo['soc_at']['06:55'][0]:.1f} %), "
"while B goes on",
f"({lo['soc_at']['03:30'][0]:.1f} -> {lo['soc_at']['06:55'][0]:.1f} %)"
" (OPT-36: B is Off here; B's slices with no forecast are the `_b` "
"night's check above)",
l2["fc_none"] and l2["target_a"] is not None
and l2["target_a"] < l2["soc_a"] and l2["p_a"] > -200.0
and lo["soc_at"]["06:55"][0] < lo["soc_at"]["03:30"][0] + 1.0
and lo["soc"][1] > lo["soc_at"]["03:30"][1] + 15.0)
and lo["soc_at"]["06:55"][0] < lo["soc_at"]["03:30"][0] + 1.0)
st_, sl_ = R["stale"], R["stale_live"]
check("F1: a latch from the night before (ended 07:15) loaded at a restart "
"inside a cheap run, with no forecast: nothing grid-charges "
@@ -661,19 +672,14 @@ md = fd["marks"]
print(f" f1_dark: A level 01:55 {md['01:55']['target_a']} -> 02:15 "
f"{md['02:15']['target_a']}; A {fd['soc_at']['06:55'][0]:.1f} % "
"at 06:55")
# OPT-36: the level (82.3 % at 02:15) falls to 64.2 % by 04:00 as B's cheap run
# is made longer (B's expected give grows); A stops at its SoC of that moment
# (72.8 %) and the latch holds 64.2 % after the figure ends.
check("F-1 darker fresh figure at 02:00: the level rises, is held after "
"05:00 with no forecast (never 'read 17:05'), and A charges while it is "
"above its level",
"05:00 with no forecast (never 'read 17:05'), and A charges to it",
md["02:15"]["target_a"] is not None
and md["01:55"]["target_a"] is not None
and md["02:15"]["target_a"] > md["01:55"]["target_a"] + 3.0
and all("17:05" not in md[h]["line"] for h in md if h >= "02:15")
and all(md[h]["fc_none"] for h in md if "05:15" <= h < "07:00")
and near(md["05:15"]["target_a"], 64.24, 0.2)
and near(fd["soc_at"]["06:55"][0], 72.8, 0.5))
and near(fd["soc_at"]["06:55"][0], md["02:15"]["target_a"], 1.5))
tb = R["tail_bfull"]
check("B full: the run ends at 06:33 before A is at its level, so the next "