The problem in one paragraph
A home battery on a dynamic tariff can buy grid power when it's cheap. But how much, and when? Filling it up every cheap night wastes money twice: tomorrow's sun would have been free, and every round trip loses about a tenth. Buying too little is expensive too: at the floor, the house has to take whatever the grid costs at that moment, often in the evening peak. The planner in episode 3 answers both questions with one rule: buy only what's missing, only when it's needed, at the cheapest time you already know. This page rebuilds it in code.
Key facts
| Question | Answer |
|---|---|
| What it is | A plan for the coming week: the battery level for every hour, the first gap below a mark, and the quarter hours in which to buy. Everything is counted in points of the battery (1 point = 1 % of its usable size) and in ct. |
| When it runs | Every 15 minutes, and right away when new prices arrive. Prices are known about 10 to 35 hours ahead; the battery forecast reaches 168 hours. |
| Why | Tomorrow's sun is free, storing loses about a tenth, and every paid top-up raises the battery price: the only price the heat pump and every other device in the house pay. So the house buys as little as possible, as cheaply as possible. |
| The limits | Floor 20 %, mark 30 % (plus 2 points per day after two days, behind the known prices). At most 90 % from the grid, never in the daily full hour, never while the sun covers the house, never without a solar forecast. At 0 ct or below: always, up to 100 %. |
| Inputs | Battery level now (fresh, from the battery meter), sun per hour, demand per hour (house pattern, car, heat pump with hot water, standby), conversion losses per hour, price per quarter hour. |
| Outputs | The chosen quarter hours with their reason (need, reserve, zero price), the level without and with the plan, charging blocks ("charge up to …, stop at …") and what still waits for its prices. |
| Grid price | Counts only for charging: when, and how much. Through what each top-up cost, it reaches the battery price. No device in the house is priced with it. |
The chain
The plan is the end of a whole chain on the logic map. In reading order:
- Sun: weather forecast (7 days) → PV yield from physics.
- Demand: house load → base load → consumption profile; car charging pattern; miner standby; heat pump power per hour (from the building model, the COP map, the learned heating curve and the weather) and the hot water pattern → total demand.
- Start: battery monitor → freshness rule → charge level source.
- Plan: conversion losses and all of the above → 7-day battery level; operator settings → growing reserve → mark and gap; wholesale price → purchase.
- Execution: grid charging planner, read-only safety switch and the watchdog on the battery system → battery system schedule.
See the chain on the logic map. This package covers the plan itself: the 7-day battery level, mark and gap, and the purchase. The forecasts and the execution are not in it.
The calculation
One hour at a time, in points of the battery:
level(h) = level(h − 1) + sun(h) − demand(h) − losses(h) + charged(h), held between the floor and 100 %
While the battery charges from the grid, the house keeps running from it. The charge comes on top, and a quarter hour only helps if its charge is bigger than what the house uses in it. Below the floor, nothing is held: the shortfall is what the house would have to draw through the charger, unplanned.
mark(h) = floor + buffer, plus 2 points × (1 + ⌊(h − 48) ÷ 24⌋) from hour 48 on, only behind the known prices
gap = deepest dip below the mark up to the last known price, measured without the floor
Then the purchase, one quarter hour per round:
- Zero price first: every known quarter hour at 0 ct or below charges, up to 100 %, whatever the other rules say.
- Need: find the first hour below the mark inside the known prices. Among the known quarter hours up to and including that hour, take the cheapest one that is allowed and closes at least 20 % of its own charge. Simulate again. Repeat until no gap is left inside the known prices. There is no price limit: that power would be bought anyway, at the floor.
- Reserve: if the next gap lies behind the known prices but within three days, do the same, but only with clearly cheap quarter hours: at most 25 ct, at most the average of the known prices, at most the 30th percentile of the last 14 days.
- Rest: still below the mark inside the known prices: cannot be covered under the rules. Behind them: waits for its prices.
Why one quarter hour at a time? Every purchase lifts the whole curve after it, so the first gap moves later, and quarter hours that were out of reach come within reach. In the example week, the cheap late evening on Wednesday is only chosen once the gap has moved there.
Where the house gets its power while charging
The charger only charges the battery. The house hangs on the battery, through the inverter system, and keeps running from it while it charges. There is no line from the grid straight to the house.
That is why the plan never treats a charging quarter hour as a free ride for the house: the battery gains the charge minus what the house uses at the same time. With the neutral example speed, a charging quarter hour adds 1 point while the house takes about a quarter of a point at night.
The example week
The same week as in the video: grey, in December, Tuesday 3 pm, the battery at 52 %. All inputs are invented, not this house's data. The charging speed is a neutral example value of 1 point per quarter hour, not the speed of any real installation; with another speed, the number of quarter hours changes, the rules do not.
- With the plan
- Without buying
- Mark (floor + reserve)
- Floor 20 %
Example inputs, not this house's data · neutral charging speed, not this house's. Calculated with the code package.
- Price per hour (ct)
- Bought for the gap
- Bought in reserve
Below each bar, four boxes for the four quarter hours of that hour, top to bottom :00, :15, :30, :45. Neutral example speed of 1 point per quarter hour.
| Result | |
|---|---|
| Prices known | until Wednesday midnight, average 31.8 ct |
| Without buying | below 30 % at 7 am on Wednesday, on the floor from 5 pm |
| Gap | 22.5 points up to the last known price |
| Need | 23 quarter hours: 16 at night (21.5 to 22.9 ct), 4 at the edges of the night (25.0 to 25.7 ct), 3 late on Wednesday evening (21.0 to 21.5 ct) |
| Reserve | 5 quarter hours late on Wednesday evening (21.3 to 22.4 ct) |
| Together | 28 points, just over a quarter of the battery, at about 22.5 ct |
| With the plan | between 30.2 and 50.4 % inside the known prices |
| Rest | below the mark again from 5 am on Thursday: waits for its prices |
Note what 22.5 ct is: the price of the top-ups. The heat pump never pays it directly. It goes into the battery price, the mix of everything inside the battery, sun at zero included; see the battery account.
And a real plan, worked out on an October afternoon: battery at about three quarters, the lowest point of the week about 48 %, still about ten points above the mark, which by then had climbed to 38 %. The plan said: nothing needed. The known prices ranged from about 19 to 39 ct, the cheapest around noon on Saturday, while the sun was filling the battery anyway. Cheap alone is never a reason.
The code, explained
The package has three files in src/ladeplan/. The excerpts below are taken straight from the download, so they match it line for line. Standard library only, no other packages.
simulate()src/ladeplan/simulation.py · lines 73–94def simulate(start_pct: float, hours: list[Hour], charged_pp: list[float] | None = None,
floor_pct: float = 20.0, top_pct: float = 100.0) -> Run:
"""Battery level for every hour, with the grid charging in charged_pp (points per hour)."""
if not floor_pct <= start_pct <= top_pct:
start_pct = min(max(start_pct, floor_pct), top_pct)
if charged_pp is not None and len(charged_pp) != len(hours):
raise ValueError("charged_pp needs one value per hour")
run = Run(start_pct=start_pct)
level = raw = start_pct
for h, hour in enumerate(hours):
change = hour.net_pp() + (charged_pp[h] if charged_pp else 0.0)
new = level + change
spilled = max(0.0, new - top_pct)
new = min(new, top_pct)
at_floor = max(0.0, floor_pct - new)
level = max(new, floor_pct)
raw = min(raw + change, top_pct) # no floor here, only the top
run.levels.append(level)
run.raw.append(raw)
run.at_floor_pp.append(at_floor)
run.spilled_pp.append(spilled)
return run- One loop over the hours.
changeis sun minus demand minus losses plus what is charged in that hour. - Two levels come out:
levels, held between the floor and 100 %, andraw, held only at the top. Below the floor,rawkeeps falling by what the house would still need; that is what makes a gap measurable. at_floor_ppis the unplanned top-up at the floor,spilled_ppthe solar power with nowhere to go at 100 %.
mark_pct(), first_gap(), gap_pp()src/ladeplan/marke.py · lines 38–66def mark_pct(hour: int, settings: MarkSettings = MarkSettings(), last_priced_hour: int = -1) -> float:
"""The mark for hour h (0 = the hour starting now).
last_priced_hour: the last hour with a known price. Up to there, the plain
mark applies (floor + buffer), whatever the hour.
"""
mark = settings.floor_pct + settings.buffer_pts
if hour >= settings.step_from_hour and hour > last_priced_hour:
days = 1 + (hour - settings.step_from_hour) // 24
mark += days * settings.step_pts_per_day
return mark
def first_gap(levels: list[float], marks: list[float], first: int = 0, last: int | None = None,
tolerance_pp: float = 0.02) -> int | None:
"""First hour (index) whose end level is below its mark, or None."""
last = len(levels) - 1 if last is None else min(last, len(levels) - 1)
for h in range(first, last + 1):
if levels[h] < marks[h] - tolerance_pp:
return h
return None
def gap_pp(raw: list[float], marks: list[float], last: int) -> float:
"""How many points are missing up to hour `last`: the deepest dip below the mark,
measured without the floor (so a stretch at the floor still counts)."""
if last < 0:
return 0.0
return max(0.0, max(marks[h] - raw[h] for h in range(min(last, len(raw) - 1) + 1)))- The mark is floor plus buffer. From hour 48 on it climbs by
step_pts_per_dayper started day, but only behind the last known price. first_gap()looks for the first hour whose end level is below its mark; dips of up to 0.02 points are ignored.gap_pp()measures the deepest dip withraw, so a long stretch at the floor still counts in full.
make_plan()src/ladeplan/einkauf.py · lines 195–311def make_plan(start_pct: float, hours: list[Hour], prices_ct: list[float | None],
settings: PlanSettings = PlanSettings(), clock: Clock = Clock()) -> Plan:
"""The charging plan for the coming hours.
start_pct battery level now, from a trusted reading (no fresh reading: no plan)
hours forecast per hour, usually 168 (seven days)
prices_ct price per quarter hour from now, None where not known yet
clock start of hour 0 (weekday, clock hour): for the full hour and for labels
"""
n = len(hours)
if n == 0:
raise ValueError("no hours")
if len(prices_ct) > 4 * n:
raise ValueError("more prices than quarter hours")
s = settings
floor = s.mark.floor_pct
prices = list(prices_ct) + [None] * (4 * n - len(prices_ct))
priced = [q for q, p in enumerate(prices) if p is not None]
last_priced = priced[-1] // 4 if priced else -1
known_mean = sum(prices[q] for q in priced) / len(priced) if priced else None
marks = [mark_pct(h, s.mark, last_priced) for h in range(n)]
charge = [0.0] * (4 * n)
purchases: list[Purchase] = []
rejected: set[int] = set()
def simulate_now() -> Run:
per_hour = [sum(charge[4 * h:4 * h + 4]) for h in range(n)]
return simulate(start_pct, hours, per_hour, floor, s.top_pct)
def allowed(q: int) -> bool:
hour = hours[q // 4]
if prices[q] is None or charge[q] > 0 or q in rejected:
return False
if hour.solar_pp is None: # no solar forecast: never buy
return False
if s.full_hour is not None and (clock.hour + q // 4) % 24 == s.full_hour:
return False # the daily full hour is for the sun
if hour.solar_pp >= hour.demand_pp: # the sun already covers the house
return False
use_per_quarter = (hour.demand_pp + hour.losses_pp - hour.solar_pp) / 4
if s.charge_pp_per_quarter <= use_per_quarter: # would not raise the level
return False
return True
def within_cap(run: Run, new_q: int) -> bool:
"""No grid charge may end above max_target_pct (zero-price charges excepted)."""
qs = [p.quarter for p in purchases if p.kind != ZERO] + [new_q]
return all(quarter_level(run, hours, charge, q) <= s.max_target_pct + 1e-9 for q in qs)
def buy_best(until_hour: int, price_ok, missing) -> tuple[int, Run] | None:
"""Cheapest allowed quarter hour up to and including until_hour that really helps.
On equal prices the earlier one wins."""
before = missing(simulate_now())
candidates = sorted((prices[q], q) for q in range(4 * (until_hour + 1))
if allowed(q) and price_ok(prices[q]))
for _, q in candidates:
charge[q] = s.charge_pp_per_quarter
trial = simulate_now()
if within_cap(trial, q) and before - missing(trial) >= s.min_effect * s.charge_pp_per_quarter:
return q, trial
charge[q] = 0.0
rejected.add(q) # never effective, never again
return None
without = simulate_now()
run = without
# 1 zero price: always charge, up to 100 %, without the other rules
for q in priced:
if prices[q] <= 0 and quarter_level(run, hours, charge, q) < s.top_pct - s.tolerance_pp:
charge[q] = s.charge_pp_per_quarter
purchases.append(Purchase(q, prices[q], ZERO, len(purchases) + 1, None))
run = simulate_now()
first = first_gap(without.levels, marks, 0, None, s.tolerance_pp)
gap = gap_pp(without.raw, marks, last_priced)
# 2 need: gaps inside the known prices, any price
def missing_inside(r: Run) -> float:
return gap_pp(r.raw, marks, last_priced)
while True:
e = first_gap(run.levels, marks, 0, last_priced, s.tolerance_pp)
if e is None:
break
found = buy_best(e, lambda price: True, missing_inside)
if found is None:
break
q, run = found
purchases.append(Purchase(q, prices[q], NEED, len(purchases) + 1, e))
# 3 reserve: gaps behind the known prices, within reserve_days, clearly cheap only
def clearly_cheap(price: float) -> bool:
if price > s.reserve_max_ct or known_mean is None or price > known_mean:
return False
return s.reserve_percentile_ct is None or price <= s.reserve_percentile_ct
reserve_last = min(n - 1, 24 * s.reserve_days - 1)
def missing_reserve(r: Run) -> float:
return gap_pp(r.raw, marks, reserve_last)
if first_gap(run.levels, marks, 0, last_priced, s.tolerance_pp) is None:
while True:
e = first_gap(run.levels, marks, last_priced + 1, reserve_last, s.tolerance_pp)
if e is None:
break
found = buy_best(min(e, last_priced), clearly_cheap, missing_reserve)
if found is None:
break
q, run = found
purchases.append(Purchase(q, prices[q], RESERVE, len(purchases) + 1, e))
# 4 rest
r = first_gap(run.levels, marks, 0, None, s.tolerance_pp)
rest = NOTHING_MISSING if r is None else (NOT_COVERABLE if r <= last_priced else WAITS_FOR_PRICES)
return Plan(purchases=purchases, without=without, with_plan=run, marks=marks, last_priced_hour=last_priced,
first_gap_hour=first, gap_pp=gap, rest=rest, rest_hour=r, known_mean_ct=known_mean,
charge_pp=charge)allowed()holds the rules: a known price, a solar forecast for that hour, not the full hour, the sun not covering the house, and a charge bigger than what the house uses in that quarter hour.within_cap()checks the 90 % limit at the end of every charging quarter hour, also the ones chosen earlier.buy_best()tries the candidates from cheap to expensive (on equal prices the earlier one) and keeps the first that closes at least 20 % of its charge. A candidate that fails is dropped for good.- Then the four phases: zero price, need inside the known prices (
price_okalways true), reserve withclearly_cheap(), and the rest.
Run it and check the tests
You need Python 3.10 or newer (free from python.org); the code itself needs nothing else. Only the tests also need pytest, a free test tool for Python; pip, the installer that comes with Python, fetches it in the second line below. Unpack the zip and open a terminal in the folder ladeplan:
python examples/example_week.py # the example week, step by step
python -m pip install pytest # only needed for the tests
python -m pytest # ends with: 31 passedThe example prints every step of the week, shortened here:
example_week.py (shortened)Start Tue 15:00: battery 52.0 %, prices known until Thu 00:00
Known prices average 31.8 ct
Step 1 - the line without buying
first below the mark: Wed 07:00 (29.13 %)
on the floor from: Wed 17:00
Step 2 - the gap up to the last known price
22.49 points
Step 3 - the cheapest quarter hours within reach, one at a time
1. Wed 01:00 21.5 ct need for the gap at Wed 07:00
2. Wed 03:00 21.5 ct need for the gap at Wed 08:00
3. Wed 02:30 21.7 ct need for the gap at Wed 08:00
...
17. Wed 05:00 25.0 ct need for the gap at Wed 20:00
18. Wed 05:45 25.3 ct need for the gap at Wed 21:00
19. Wed 00:30 25.7 ct need for the gap at Wed 21:00
20. Wed 05:15 25.7 ct need for the gap at Wed 22:00
21. Wed 22:30 21.2 ct need for the gap at Wed 23:00
22. Wed 22:00 21.5 ct need for the gap at Wed 23:00
23. Wed 23:00 21.0 ct need for the gap at Thu 00:00
24. Wed 23:45 21.3 ct reserve for the gap at Thu 01:00
25. Wed 23:15 21.7 ct reserve for the gap at Thu 02:00
26. Wed 22:15 21.9 ct reserve for the gap at Thu 03:00
27. Wed 22:45 22.2 ct reserve for the gap at Thu 04:00
28. Wed 23:30 22.4 ct reserve for the gap at Thu 04:00
Result
need 23 quarter hours, 23.0 points, average 22.60 ct
reserve 5 quarter hours, 5.0 points, average 21.90 ct
all 28 quarter hours, 28.0 points, average 22.47 ct
with the plan: low 30.17 %, high 50.37 % (inside the known prices)
rest: waits_for_prices from Thu 05:00
Charging blocks (charge up to ..., stop at ...)
Wed 00:30 - 00:45: charge up to 37.1 %
Wed 01:00 - 05:30: charge up to 49.9 %
Wed 05:45 - 06:00: charge up to 50.4 %
Wed 22:00 - 00:00: charge up to 35.5 %The 31 tests recalculate the example week from the video (which quarter hours, in which order, at what price, and where the level ends up) and check every rule with small cases you can follow by hand:
| # | Case | Expected |
|---|---|---|
| 1 | 22 % at the start, the house uses 5 points per hour | stops at 20 %; 3, then 5 points unplanned at the floor |
| 2 | the mark at hour 0, 48, 72 and 144 | 30, 32, 34 and 40 % |
| 3 | 20 ct before the first gap, 5 ct behind it | 20 ct first; 5 ct only once the gap gets there |
| 4 | a gap inside the known prices, every hour at 80 ct | bought anyway |
| 5 | battery at 89.5 %, the cheapest hours first thing | skipped: they would end above 90 % |
| 6 | reserve with 24, 26, 30 and 40 ct known | only 24 ct; with a percentile of 23 ct, nothing |
| 7 | −1 ct in one hour, no gap, battery at 89 % | charged anyway, up to 93 % |
All seven are tests in the package, named after what they check.
def test_twenty_three_plus_five_quarter_hours(week):
_, plan = week
assert len(plan.chosen(NEED)) == 23
assert len(plan.chosen(RESERVE)) == 5
assert plan.bought_pp() == pytest.approx(28.0)weekis a fixture: pytest runs the example week once and hands the plan to every test in the file.pytest.approxcompares decimal numbers with a tiny tolerance, so rounding in the last digit never fails a test.
Variables
The most important names, with unit and typical range. All ranges are example ranges, not the values of a real installation. The complete list is in VARIABLEN.md in the package.
| Name | Unit | Meaning | Typical range | Comes from |
|---|---|---|---|---|
start_pct | % | Battery level now | floor … 100 | a reading you trust, fresh |
solar_pp | points/h | What the sun brings; None = unknown, nothing bought in that hour | 0 at night … several points at noon | your solar forecast |
demand_pp | points/h | Everything the house uses: pattern, heat pump, car, standby | about 1 … 2.5 in winter | learned patterns and heat pump forecast |
losses_pp | points/h | Conversion losses: always-on draw plus a share per direction | about 0.1 … 0.3 | measured or estimated |
prices_ct | ct/kWh | Price per quarter hour, None where not known yet | about −5 … 60 | your dynamic tariff |
| Name | Unit | Meaning | Typical range | Comes from |
|---|---|---|---|---|
floor_pct | % | Lowest level the battery may reach | 10 … 30 (example 20) | setting |
buffer_pts | points | Buffer above the floor | 5 … 15 (example 10) | setting |
step_pts_per_day | points | Extra points per day from hour 48 on | 0 … 4 (example 2) | setting; ideally learned from forecast errors |
charge_pp_per_quarter | points | What one charging quarter hour adds | example 1.0 (neutral) | charging power, efficiency and battery size |
max_target_pct | % | Grid charging stops here (zero price excepted) | 80 … 95 (example 90) | setting |
full_hour | clock hour | Daily hour that belongs to the sun | example 15 | setting |
reserve_max_ct | ct/kWh | Clearly cheap: at most this price | example 25 | setting |
reserve_percentile_ct | ct/kWh | Clearly cheap: 30th percentile of 14 days | example 27.7 | percentile() of your price history |
gap_pp | points | Missing up to the last known price, without buying | 0 … | make_plan() |
target_pct | % | "Charge up to …" at the end of a charging block | floor … 90 | Plan.blocks() |
Adapting it to your house
- Your battery in points: points = kWh ÷ usable kWh × 100. The charging speed in points per quarter hour = charging power (kW) × 0.25 h × charging efficiency ÷ usable kWh × 100.
- Your own floor and buffer:
MarkSettings(floor_pct=…, buffer_pts=…, step_pts_per_day=…). Ask yourself the two questions from the video: how big are the peaks the grid can't cover, and how far does your charge reading drift? - You are paid for feeding solar into the grid: the plan assumes the house sells nothing, so a charge the sun would bring tomorrow is never bought. With a feed-in tariff, compare the price of a top-up with what the sun could have earned.
- No heat pump: leave it out of
demand_pp. A fixed tariff: there is nothing to plan, every quarter hour costs the same. - Hourly prices only: repeat each price four times. No price history yet: leave
reserve_percentile_ctatNone; then only the other two tests apply. - Run it often: every 15 minutes and when new prices arrive, with fresh inputs only. A stale battery reading means no plan.
Working with an AI assistant? Give it the package, VARIABLEN.md and the names of your own forecasts and readings, and ask it for the adapter that turns them into Hour values and quarter-hour prices. The tests tell you whether the logic still holds afterwards.
Download
Code package: charging plan
Source code, the example week, 31 tests, the variable list and notes on its origin. Python standard library only.
- File
ladeplan-1.0.0.zip- Version
- 1.0.0 · 13 October 2026
- Size
- 28 KB · 14 files
- Licence
- MIT · see the note in the imprint
- Price
- Free
- SHA-256
05c5da41f37f244e199d81c55ca9bfb76f5e6974646c24fd8168bf64b26d0e67
Download zip (28 KB)Checksum file
To check the download, compare the SHA-256 checksum: certutil -hashfile ladeplan-1.0.0.zip SHA256 on Windows, shasum -a 256 ladeplan-1.0.0.zip on macOS, sha256sum ladeplan-1.0.0.zip on Linux.
Version and origin
Sources
- SMARD – electricity market data for Germany, incl. wholesale prices (Federal Network Agency)(external)
- European Power Exchange: how the daily day-ahead auction works (Wikipedia)(external)
- Percentile (Wikipedia)(external)
- pytest documentation(external)
- The MIT License (Open Source Initiative)(external)
- Real October plan: own evaluation of the running system, percent and cents only, rounded; the example week uses invented inputs (own data)
Links checked on 13 October 2026.