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
+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