OPT-36 round 3: B's charge counts the cheap time left; OPT-34 nights on their own shapes
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:
co-authored by
Claude
parent
3a18475a5a
commit
e4b9dee699
+120
-10
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user