Thinking House Lab
EN

Language

  • English
  • Deutsch
  • Français · coming later
  • Español · coming later
  • 中文 · coming later

Power Flow Logic · Episode 3 · Level 3

Grid charging: when and how much

The planner from episode 3 as small, readable Python (a free, widely used programming language): a week of battery levels, hour by hour, the gap below a mark, and the cheapest known quarter hours, one at a time. The example week from the video is a test you can run.

RebuildFree15 min read

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

The charging plan at a glance
QuestionAnswer
What it isA 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 runsEvery 15 minutes, and right away when new prices arrive. Prices are known about 10 to 35 hours ahead; the battery forecast reaches 168 hours.
WhyTomorrow'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 limitsFloor 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 %.
InputsBattery 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.
OutputsThe 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 priceCounts 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

While the battery charges: one path for the power
PublicgridChargerBatteryInvertersystemHouse

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.

Battery level without buying and with the plan
prices known20 %30 %40 %50 %WedThuFriSatSunMonTue
  • 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.

The 33 hours with known prices: price per hour and the quarter hours bought
10 ct20 ct30 ct40 ctTue 18:00Wed 00:0006:0012:0018:00
  • 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.

What the code calculates for the example week
Result
Prices knownuntil Wednesday midnight, average 31.8 ct
Without buyingbelow 30 % at 7 am on Wednesday, on the floor from 5 pm
Gap22.5 points up to the last known price
Need23 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)
Reserve5 quarter hours late on Wednesday evening (21.3 to 22.4 ct)
Together28 points, just over a quarter of the battery, at about 22.5 ct
With the planbetween 30.2 and 50.4 % inside the known prices
Restbelow 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.

The week, hour by hour: simulate()src/ladeplan/simulation.py · lines 73–94
def 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. change is sun minus demand minus losses plus what is charged in that hour.
  • Two levels come out: levels, held between the floor and 100 %, and raw, held only at the top. Below the floor, raw keeps falling by what the house would still need; that is what makes a gap measurable.
  • at_floor_pp is the unplanned top-up at the floor, spilled_pp the solar power with nowhere to go at 100 %.
The mark and the gap: mark_pct(), first_gap(), gap_pp()src/ladeplan/marke.py · lines 38–66
def 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_day per 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 with raw, so a long stretch at the floor still counts in full.
The plan: make_plan()src/ladeplan/einkauf.py · lines 195–311
def 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_ok always true), reserve with clearly_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:

Example and tests
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 passed

The example prints every step of the week, shortened here:

Output of 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:

Cases to check by hand
#CaseExpected
122 % at the start, the house uses 5 points per hourstops at 20 %; 3, then 5 points unplanned at the floor
2the mark at hour 0, 48, 72 and 14430, 32, 34 and 40 %
320 ct before the first gap, 5 ct behind it20 ct first; 5 ct only once the gap gets there
4a gap inside the known prices, every hour at 80 ctbought anyway
5battery at 89.5 %, the cheapest hours first thingskipped: they would end above 90 %
6reserve with 24, 26, 30 and 40 ct knownonly 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.

The example week as a test: 23 + 5 quarter hourstests/test_example_week.py · lines 53–57
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)
  • week is a fixture: pytest runs the example week once and hands the plan to every test in the file.
  • pytest.approx compares 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.

Inputs per hour
NameUnitMeaningTypical rangeComes from
start_pct%Battery level nowfloor … 100a reading you trust, fresh
solar_pppoints/hWhat the sun brings; None = unknown, nothing bought in that hour0 at night … several points at noonyour solar forecast
demand_pppoints/hEverything the house uses: pattern, heat pump, car, standbyabout 1 … 2.5 in winterlearned patterns and heat pump forecast
losses_pppoints/hConversion losses: always-on draw plus a share per directionabout 0.1 … 0.3measured or estimated
prices_ctct/kWhPrice per quarter hour, None where not known yetabout −5 … 60your dynamic tariff
Settings and results
NameUnitMeaningTypical rangeComes from
floor_pct%Lowest level the battery may reach10 … 30 (example 20)setting
buffer_ptspointsBuffer above the floor5 … 15 (example 10)setting
step_pts_per_daypointsExtra points per day from hour 48 on0 … 4 (example 2)setting; ideally learned from forecast errors
charge_pp_per_quarterpointsWhat one charging quarter hour addsexample 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_hourclock hourDaily hour that belongs to the sunexample 15setting
reserve_max_ctct/kWhClearly cheap: at most this priceexample 25setting
reserve_percentile_ctct/kWhClearly cheap: 30th percentile of 14 daysexample 27.7percentile() of your price history
gap_pppointsMissing up to the last known price, without buying0 …make_plan()
target_pct%"Charge up to …" at the end of a charging blockfloor … 90Plan.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_ct at None; 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

Links checked on 13 October 2026.

Your experience counts

Which system runs in your house, and where does it get stuck? I'd like to hear about your experiences, your doubts and your ideas, in the comments under the videos. Good ideas get picked up and built in public: as a video of their own or as an addition to an existing one.

To the channel