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
@@ -8,5 +8,5 @@
- **House use.** An hour with sun is read at no less than the night base, as the sun sum reads it (the profile is net of the sun). An hour without is read as measured. An hour with no forecast counts no sun.
- **Several batteries.** One total, shared by capacity with the ceiling passed on (`dayplan._spread()`). With the same floor and ceiling for all, they reach the ceiling together, so a shortfall that one battery cannot hold fills both to the same level, not one first. More than all hold: all go to the ceiling, `Plan.uncovered_kwh` is the rest, carried by another battery (Cheap hours, Solar first) or the grid, as the screen says.
- **Cheap hours** still fills up; **Solar first** still never charges from the grid. The mode help says so.
- **OPT-36 (0.20.1):** what Cheap hours batteries will give in the expensive hours is taken off the shortfall before Leftover solar shares it (`dayplan._cheap_give_kwh()`: its level when they start, the ceiling or what its cheap run reaches under the peak aim, minus the floor, x capacity x EFF). Owner, 2026-11-02: Solar first batteries count too (`_solar_first_give_kwh()`): their level now less the house's night use until the hours start, shared by the energy held, then the same sum; a battery the night drains to the floor counts 0. Live, 2026-11-02: A at 60 % went to 95 % while B at 94 % covered the hours. Test: `test_opt36.py`.
- **OPT-36 (0.20.1):** what Cheap hours batteries will give in the expensive hours is taken off the shortfall before Leftover solar shares it (`dayplan._cheap_give_kwh()`: its level when they start, the ceiling or what it charges under the peak aim in the cheap run and the later slots still worth charging in, minus the floor, x capacity x EFF; the run alone, sized at the ratings, let A's level fall after A had bought). Owner, 2026-11-02: Solar first batteries count too (`_solar_first_give_kwh()`): their level now less the house's night use until the hours start, shared by the energy held, then the same sum; a battery the night drains to the floor counts 0. Live, 2026-11-02: A at 60 % went to 95 % while B at 94 % covered the hours. Test: `test_opt36.py`.
- **Not done:** the run length (OPT-25).
+1 -1
View File
@@ -549,7 +549,7 @@ With battery modes on, these choices replace the *Charging and selling on price*
- The hourly shape is kept only if every sensor has one. Otherwise the total is spread over the day as for Forecast.Solar.
- Before each cheap run the add-on asks: will the sun fill the batteries before the expensive hours? It counts on `solar_trust_pct` of the forecast (70 %), less what the house uses. Solar first batteries get that sun first.
- *Leftover solar* then charges from the grid only the shortfall: for each expensive hour, what the house uses less the sun counted for that hour (never below 0), added up over all the expensive hours, early morning and evening. With a day and night tariff that is 07:00 to 22:00. Not up to the top level.
- All *Leftover solar* batteries hold that shortfall together, shared by their size. A battery that reaches the top level passes the rest to the others. If all of them together cannot hold it, they all fill to the top level. *Cheap hours only* and *Solar first* batteries give first: what they will hold when the expensive hours start (a *Cheap hours only* battery full, or as far as its cheap run reaches under the peak limit; a *Solar first* battery after the night's use) is taken off the shortfall. In the expensive hours the rest comes from the grid. The Batteries screen says so. The sun before the expensive hours still fills first, and is taken off what is bought.
- All *Leftover solar* batteries hold that shortfall together, shared by their size. A battery that reaches the top level passes the rest to the others. If all of them together cannot hold it, they all fill to the top level. *Cheap hours only* and *Solar first* batteries give first: what they will hold when the expensive hours start (a *Cheap hours only* battery full, or as far as it can charge under the peak limit in the cheap hours left; a *Solar first* battery after the night's use) is taken off the shortfall. In the expensive hours the rest comes from the grid. The Batteries screen says so. The sun before the expensive hours still fills first, and is taken off what is bought.
- A forecast that gives only one figure for the day (Forecast.Solar) is spread over the hours from now, so the hours add up to that figure.
- *Cheap hours only* ignores the forecast and fills to the top level.
- *Solar first* never charges from the grid.
+36 -16
View File
@@ -231,14 +231,21 @@ def _room(p, to_pct):
def _cheap_give_kwh(runs, now, house, packs, *, floor_pct, ceiling_pct,
aim_w):
aim_w, until=None, slots=None, spread=None):
"""OPT-36: what the Cheap hours batteries in `packs` give in the
expensive hours, in kWh on the house's side: (the level at the start of
those hours - the floor) x capacity x `EFF`.
That level is the one its own cheap run reaches: the ceiling, or less when
the run cannot get there. From now to the run's end (or the whole run
ahead of it) the batteries together charge at their ratings, held to what
That level is the one its cheap charging reaches: the ceiling, or less
when the time left cannot get there. That time is the cheap run from now
(or the whole run ahead of it) and, given the firm price `slots`, each
later slot before the expensive hours (`until`, a give run) still worth
charging in: its price plus the spread control asks (`spread`) at most
the give run's dearest slot, `priceruns.plan()`'s test. The run is sized
at the batteries' ratings (`priceruns.need_hours()`), not the peak limit,
so a run too short to fill them is planned again in that time; counting
the run alone let A's level fall in the night after A had bought. In
that time the batteries together charge at their ratings, held to what
the peak aim leaves above the house's use that hour (`aim_w`, None: no
limit), shared by rating. No cheap run worth charging in: the level now.
A battery that refuses a direction counts 0."""
@@ -249,16 +256,26 @@ def _cheap_give_kwh(runs, now, house, packs, *, floor_pct, ceiling_pct,
gain = {p.name: 0.0 for p in ps}
rate = {p.name: abs(float(p.rating_w)) for p in ps}
total = sum(rate.values())
run = runs.cheap
spans = [(max(now, run.start), run.end)]
top = max((eur for s, _e, eur in slots or ()
if until is not None and until.start <= s < until.end),
default=None)
if top is not None and spread is not None:
spans += [(max(s, now, run.end), min(e, until.start))
for s, e, eur in slots
if eur + float(spread) <= top + 1e-9
and e > run.end and s < until.start]
if runs.charge_useful() and total > 0.0:
for s, e, h in _hour_slices(max(now, runs.cheap.start),
runs.cheap.end):
w = total
if aim_w is not None:
w = min(w, max(0.0, float(aim_w)
- max(house_w(h), base_w, 0.0)))
hrs = (e - s).total_seconds() / 3600.0
for p in ps:
gain[p.name] += w * rate[p.name] / total * hrs / 1000.0
for t0, t1 in spans:
for s, e, h in _hour_slices(t0, t1):
w = total
if aim_w is not None:
w = min(w, max(0.0, float(aim_w)
- max(house_w(h), base_w, 0.0)))
hrs = (e - s).total_seconds() / 3600.0
for p in ps:
gain[p.name] += w * rate[p.name] / total * hrs / 1000.0
out = 0.0
for p in ps:
cap = float(p.capacity_kwh)
@@ -289,11 +306,13 @@ def _solar_first_give_kwh(house, now, start, packs, *, floor_pct):
def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
ceiling_pct, aim_w=None):
ceiling_pct, aim_w=None, slots=None, spread=None):
"""The day plan, or None when there is none to make: no forecast, no
house profile yet, no price plan, or no expensive run after the cheap
run. `house` is `HouseProfile.estimator()`'s answer. `packs` are
`modetrade.Pack`s."""
`modetrade.Pack`s. `aim_w`, `slots` (`priceruns.firm_slots()`) and
`spread` (the price gap control asks, EUR/kWh) size a Cheap hours
battery's charge (`_cheap_give_kwh()`)."""
if forecast is None or house is None or runs is None or not runs.on \
or runs.cheap is None:
return None
@@ -346,7 +365,8 @@ def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
# Leftover solar holds the rest. A Leftover solar battery's own stored energy counts through its
# level: it buys from where it is to the level it needs.
cheap = _cheap_give_kwh(runs, now, house, usable, floor_pct=floor_pct,
ceiling_pct=ceiling_pct, aim_w=aim_w) \
ceiling_pct=ceiling_pct, aim_w=aim_w,
until=until, slots=slots, spread=spread) \
if price else 0.0
cheap = min(cheap, short)
sfirst = _solar_first_give_kwh(house, now, until.start, usable,
+3 -1
View File
@@ -2375,7 +2375,9 @@ class Controller:
else self.house_profile.estimator(),
priceruns.keep_started(self.mode_runs, runs, now), now, packs,
trust_pct=self.solar_trust_pct, floor_pct=floor_pct,
ceiling_pct=ceiling_pct, aim_w=self.peak_aim_w())
ceiling_pct=ceiling_pct, aim_w=self.peak_aim_w(),
slots=priceruns.firm_slots(plan.read.points, now),
spread=required)
self.day_plan = dp
if dp is None:
self._plan_targets = None
+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 "
+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"))
+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 "